├── apps
├── next-app
│ ├── .husky
│ │ └── _
│ │ │ ├── .gitignore
│ │ │ ├── husky.sh
│ │ │ ├── pre-push
│ │ │ └── h
│ ├── src
│ │ ├── styles
│ │ │ ├── variables.css
│ │ │ ├── constants.ts
│ │ │ ├── colors.ts
│ │ │ ├── font.css
│ │ │ ├── global.css
│ │ │ ├── mediaQuery.ts
│ │ │ ├── registry.tsx
│ │ │ ├── dark.css
│ │ │ └── light.css
│ │ ├── constants
│ │ │ ├── pagination.ts
│ │ │ ├── auth.ts
│ │ │ ├── url.ts
│ │ │ ├── dateTime.ts
│ │ │ └── route.ts
│ │ ├── views
│ │ │ └── myFeed
│ │ │ │ ├── index.ts
│ │ │ │ ├── MyFeed.style.ts
│ │ │ │ └── MyFeed.tsx
│ │ ├── components
│ │ │ ├── common
│ │ │ │ ├── Input
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── Input.tsx
│ │ │ │ │ └── Input.style.ts
│ │ │ │ ├── Tab
│ │ │ │ │ ├── index.tsx
│ │ │ │ │ ├── Tab.style.ts
│ │ │ │ │ └── Tab.tsx
│ │ │ │ ├── Button
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── Button.tsx
│ │ │ │ ├── Dialog
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── DialogTitle.tsx
│ │ │ │ │ ├── DialogContent.tsx
│ │ │ │ │ ├── DialogActions.tsx
│ │ │ │ │ ├── Dialog.tsx
│ │ │ │ │ └── Dialog.style.tsx
│ │ │ │ ├── Layout
│ │ │ │ │ ├── Nav
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── Profile.tsx
│ │ │ │ │ │ └── Nav.tsx
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── Layout.style.tsx
│ │ │ │ │ └── Layout.tsx
│ │ │ │ ├── Loading
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── Loading.tsx
│ │ │ │ │ └── Loading.style.tsx
│ │ │ │ ├── Paging
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── Page.tsx
│ │ │ │ │ ├── Paging.style.tsx
│ │ │ │ │ └── hooks
│ │ │ │ │ │ └── usePageInfo.tsx
│ │ │ │ ├── Popover
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── PopoverLayout.tsx
│ │ │ │ │ └── PopoverItem.tsx
│ │ │ │ ├── Scripts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── Scripts.tsx
│ │ │ │ ├── FeedItem
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── FeedItem.utils.ts
│ │ │ │ │ ├── Post
│ │ │ │ │ │ └── Post.style.tsx
│ │ │ │ │ ├── Popovers
│ │ │ │ │ │ ├── icons.tsx
│ │ │ │ │ │ └── PublicFeedItemPopover.tsx
│ │ │ │ │ ├── Channel
│ │ │ │ │ │ └── Channel.style.tsx
│ │ │ │ │ ├── hooks
│ │ │ │ │ │ ├── useToggleLike.ts
│ │ │ │ │ │ └── useReadPost.ts
│ │ │ │ │ └── FeedItem.tsx
│ │ │ │ ├── Modal
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── ModalLayout.tsx
│ │ │ │ │ ├── ModalLayout.styles.tsx
│ │ │ │ │ ├── DimmerLayout.tsx
│ │ │ │ │ └── useModal.tsx
│ │ │ │ ├── Skeleton
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── ChannelType.tsx
│ │ │ │ │ └── PostType.tsx
│ │ │ │ ├── Notification
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── Notification.tsx
│ │ │ │ │ └── Notification.style.tsx
│ │ │ │ ├── Anchor
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── Portal
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── PageContainer
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── LogoIcon
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── EmptyContents
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── ToastIcon
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── ColorModeScript
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── Divider
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── Toast
│ │ │ │ │ ├── Toast.tsx
│ │ │ │ │ └── methods.tsx
│ │ │ │ ├── Providers
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── LogoDesktop
│ │ │ │ │ └── index.tsx
│ │ │ │ └── Flex
│ │ │ │ │ └── index.tsx
│ │ │ └── views
│ │ │ │ ├── Error
│ │ │ │ ├── 404
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── 404Container.style.tsx
│ │ │ │ │ └── 404Container.tsx
│ │ │ │ ├── 500
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── 500Container.tsx
│ │ │ │ ├── index.ts
│ │ │ │ └── error.style.tsx
│ │ │ │ ├── Feeds
│ │ │ │ ├── MyFeed
│ │ │ │ │ └── index.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── FeedsContainer.style.tsx
│ │ │ │ ├── Recommended
│ │ │ │ │ ├── RecommendedPosts.tsx
│ │ │ │ │ └── RecommendedChannels.tsx
│ │ │ │ ├── FeedsContainer.tsx
│ │ │ │ └── FeedTab
│ │ │ │ │ └── styles.tsx
│ │ │ │ ├── UserPage
│ │ │ │ ├── List
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── PostList
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ └── hooks
│ │ │ │ │ │ │ └── usePostListByUsername.ts
│ │ │ │ │ ├── ChannelList
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ └── hooks
│ │ │ │ │ │ │ └── useChannelListByUsername.ts
│ │ │ │ │ ├── List.style.ts
│ │ │ │ │ └── List.tsx
│ │ │ │ ├── index.ts
│ │ │ │ ├── UserPageContainer.utils.ts
│ │ │ │ └── UserPageContainer.style.ts
│ │ │ │ ├── RssInput
│ │ │ │ ├── index.ts
│ │ │ │ ├── hooks
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── useControlled.ts
│ │ │ │ ├── RssInputContainer.utils.ts
│ │ │ │ ├── RssUrlInput.tsx
│ │ │ │ └── BlogUrlInput.tsx
│ │ │ │ ├── MyAccount
│ │ │ │ └── index.ts
│ │ │ │ └── Channel
│ │ │ │ ├── ChannelDetailContainer.style.ts
│ │ │ │ └── ChannalSubscription.tsx
│ │ ├── shared
│ │ │ ├── ui
│ │ │ │ └── FeedTab
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── styles.tsx
│ │ │ ├── utils
│ │ │ │ ├── checkLoggedIn.ts
│ │ │ │ └── mutex.ts
│ │ │ └── libs
│ │ │ │ ├── context.ts
│ │ │ │ └── nextjs.ts
│ │ ├── app
│ │ │ ├── (hasGNB)
│ │ │ │ ├── _components
│ │ │ │ │ ├── Nav
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── GoToSignUpButton.tsx
│ │ │ │ │ │ ├── LogoButton.tsx
│ │ │ │ │ │ └── Nav.tsx
│ │ │ │ │ └── Container
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ └── Container.tsx
│ │ │ │ ├── layout.tsx
│ │ │ │ └── feed
│ │ │ │ │ ├── layout.tsx
│ │ │ │ │ └── me
│ │ │ │ │ └── page.tsx
│ │ │ ├── page.tsx
│ │ │ ├── signup
│ │ │ │ ├── layout.tsx
│ │ │ │ ├── _utils
│ │ │ │ │ └── SignUp.utils.ts
│ │ │ │ ├── page.tsx
│ │ │ │ └── SignUp.style.tsx
│ │ │ ├── introduce
│ │ │ │ ├── _components
│ │ │ │ │ ├── IntroduceHeaderTitle.tsx
│ │ │ │ │ ├── IntroduceHeaderBanner.tsx
│ │ │ │ │ ├── FeedoongSticker.tsx
│ │ │ │ │ ├── StartFeedoongWithGoogle.tsx
│ │ │ │ │ ├── IntroduceFeature3.tsx
│ │ │ │ │ ├── IntroduceFeature2.tsx
│ │ │ │ │ └── IntroduceFeature1.tsx
│ │ │ │ ├── layout.tsx
│ │ │ │ └── page.tsx
│ │ │ └── providers.tsx
│ │ ├── features
│ │ │ ├── post
│ │ │ │ └── ui
│ │ │ │ │ └── PostFeedItem
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── PostFeedItem.style.tsx
│ │ │ ├── auth
│ │ │ │ ├── tokenRefreshMutex.ts
│ │ │ │ └── logout.ts
│ │ │ ├── errors
│ │ │ │ ├── errors.ts
│ │ │ │ └── globalQueryErrorHandler.ts
│ │ │ └── user
│ │ │ │ ├── useCheckIsMyProfile.ts
│ │ │ │ └── userProfile.ts
│ │ ├── utils
│ │ │ ├── index.ts
│ │ │ ├── env.ts
│ │ │ ├── date.ts
│ │ │ ├── hooks
│ │ │ │ ├── index.ts
│ │ │ │ ├── useColors.ts
│ │ │ │ ├── useLockBodyScroll.ts
│ │ │ │ ├── useGoogleAnalytics.ts
│ │ │ │ └── useColorMode.ts
│ │ │ ├── gtag.ts
│ │ │ ├── common.ts
│ │ │ ├── url.ts
│ │ │ ├── errors.ts
│ │ │ └── auth.ts
│ │ ├── assets
│ │ │ ├── images
│ │ │ │ ├── User.png
│ │ │ │ ├── introduce_1.webp
│ │ │ │ ├── introduce_2.webp
│ │ │ │ ├── introduce_3.webp
│ │ │ │ ├── introduce_header.webp
│ │ │ │ └── index.ts
│ │ │ ├── channels
│ │ │ │ ├── kakao.ico
│ │ │ │ ├── toss.ico
│ │ │ │ ├── velog.ico
│ │ │ │ ├── brunch.ico
│ │ │ │ ├── chrome.png
│ │ │ │ ├── tistory.ico
│ │ │ │ ├── vercel.png
│ │ │ │ ├── vscode.ico
│ │ │ │ ├── youtube.ico
│ │ │ │ ├── naver_blog.ico
│ │ │ │ ├── nhnToast.ico
│ │ │ │ ├── hyperconnect.ico
│ │ │ │ └── index.ts
│ │ │ ├── icons
│ │ │ │ ├── account.png
│ │ │ │ ├── add.svg
│ │ │ │ ├── bookmark.svg
│ │ │ │ ├── bookmark-deactive.svg
│ │ │ │ ├── Darkmode.svg
│ │ │ │ ├── toast-basic.svg
│ │ │ │ ├── toast-error.svg
│ │ │ │ ├── lightning-mono.svg
│ │ │ │ ├── right_arrow.svg
│ │ │ │ ├── logo-desktop-no-background.svg
│ │ │ │ ├── folder-mono.svg
│ │ │ │ ├── logo-desktop.svg
│ │ │ │ ├── star.svg
│ │ │ │ ├── add-mono.svg
│ │ │ │ ├── google_icon.svg
│ │ │ │ ├── notification-icon.svg
│ │ │ │ ├── left_arrow.svg
│ │ │ │ ├── card_view.svg
│ │ │ │ ├── card_view-deactive.svg
│ │ │ │ ├── x-mono.svg
│ │ │ │ ├── rss-circle.svg
│ │ │ │ ├── dot-vertical.svg
│ │ │ │ ├── menu_icon.svg
│ │ │ │ ├── cancel.svg
│ │ │ │ └── trashcan.svg
│ │ │ └── fonts
│ │ │ │ └── Satoshi-Bold.woff2
│ │ ├── pages
│ │ │ ├── 404.tsx
│ │ │ ├── 500.tsx
│ │ │ ├── mypage
│ │ │ │ └── account
│ │ │ │ │ └── index.tsx
│ │ │ ├── [userName].tsx
│ │ │ ├── channels
│ │ │ │ └── [id].tsx
│ │ │ ├── feed
│ │ │ │ └── recommended
│ │ │ │ │ ├── channels
│ │ │ │ │ └── index.tsx
│ │ │ │ │ └── posts
│ │ │ │ │ └── index.tsx
│ │ │ └── oauth
│ │ │ │ └── index.tsx
│ │ ├── types
│ │ │ ├── colorMode.ts
│ │ │ ├── common.ts
│ │ │ ├── subscriptions.ts
│ │ │ └── feeds.ts
│ │ ├── entities
│ │ │ ├── user
│ │ │ │ └── api
│ │ │ │ │ └── index.ts
│ │ │ └── item
│ │ │ │ └── api
│ │ │ │ └── index.ts
│ │ ├── services
│ │ │ ├── cacheKeys.ts
│ │ │ ├── types
│ │ │ │ └── _generated
│ │ │ │ │ ├── status-controller.ts
│ │ │ │ │ ├── like.ts
│ │ │ │ │ └── subscription.ts
│ │ │ └── auth
│ │ │ │ └── index.ts
│ │ ├── middleware.ts
│ │ ├── envs
│ │ │ └── index.ts
│ │ └── core
│ │ │ └── getQueryClient.ts
│ ├── .gitignore
│ ├── .dockerignore
│ ├── public
│ │ ├── og_image.png
│ │ └── logo-desktop.svg
│ ├── Dockerfile
│ ├── orval.config.js
│ ├── scripts
│ │ ├── syncCodeGenSpec.mjs
│ │ └── codeGen.mjs
│ ├── tsconfig.json
│ └── next.config.js
└── extension
│ ├── favicon.png
│ ├── index.js
│ ├── index.html
│ └── manifest.json
├── .yarnrc.yml
├── appspec.yml
├── .prettierrc
├── .yarn
└── sdks
│ ├── integrations.yml
│ ├── prettier
│ ├── package.json
│ ├── index.cjs
│ └── bin
│ │ └── prettier.cjs
│ ├── typescript
│ ├── package.json
│ ├── lib
│ │ ├── typescript.js
│ │ └── tsc.js
│ └── bin
│ │ ├── tsc
│ │ └── tsserver
│ └── eslint
│ ├── package.json
│ ├── lib
│ ├── api.js
│ └── unsupported-api.js
│ └── bin
│ └── eslint.js
├── .gitattributes
├── .vscode
├── extensions.json
└── settings.json
├── .github
├── pull_request_template.md
├── workflows
│ └── auto_assign.yml
└── auto_assign_config.yml
├── .editorconfig
├── README.md
├── deploy.sh
├── package.json
├── tsconfig.json
└── .gitignore
/apps/next-app/.husky/_/.gitignore:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/apps/next-app/.husky/_/husky.sh:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/apps/next-app/src/styles/variables.css:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.yarnrc.yml:
--------------------------------------------------------------------------------
1 | yarnPath: .yarn/releases/yarn-4.3.1.cjs
2 |
--------------------------------------------------------------------------------
/apps/next-app/src/constants/pagination.ts:
--------------------------------------------------------------------------------
1 | export const ITEMS_PER_PAGE = 10
2 |
--------------------------------------------------------------------------------
/apps/next-app/src/views/myFeed/index.ts:
--------------------------------------------------------------------------------
1 | export { default } from './MyFeed'
2 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/common/Input/index.ts:
--------------------------------------------------------------------------------
1 | export { default } from './Input'
2 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/common/Tab/index.tsx:
--------------------------------------------------------------------------------
1 | export { default } from './Tab'
2 |
--------------------------------------------------------------------------------
/apps/next-app/src/shared/ui/FeedTab/index.ts:
--------------------------------------------------------------------------------
1 | export { default } from './FeedTab'
2 |
--------------------------------------------------------------------------------
/apps/next-app/src/app/(hasGNB)/_components/Nav/index.ts:
--------------------------------------------------------------------------------
1 | export { default } from './Nav'
2 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/common/Button/index.ts:
--------------------------------------------------------------------------------
1 | export { default } from './Button'
2 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/common/Dialog/index.ts:
--------------------------------------------------------------------------------
1 | export { default } from './Dialog'
2 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/common/Layout/Nav/index.ts:
--------------------------------------------------------------------------------
1 | export { default } from './Nav'
2 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/common/Layout/index.ts:
--------------------------------------------------------------------------------
1 | export { default } from './Layout'
2 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/common/Loading/index.ts:
--------------------------------------------------------------------------------
1 | export { default } from './Loading'
2 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/common/Paging/index.ts:
--------------------------------------------------------------------------------
1 | export { default } from './Paging'
2 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/common/Popover/index.ts:
--------------------------------------------------------------------------------
1 | export { default } from './Popover'
2 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/common/Scripts/index.ts:
--------------------------------------------------------------------------------
1 | export { default } from './Scripts'
2 |
--------------------------------------------------------------------------------
/apps/next-app/src/features/post/ui/PostFeedItem/index.ts:
--------------------------------------------------------------------------------
1 | export * from './PostFeedItem'
2 |
--------------------------------------------------------------------------------
/apps/next-app/.gitignore:
--------------------------------------------------------------------------------
1 | .yarn/install-state.gz
2 |
3 | .env
4 | # Sentry
5 | .sentryclirc
6 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/common/FeedItem/index.ts:
--------------------------------------------------------------------------------
1 | export { default } from './FeedItem'
2 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/views/Error/404/index.ts:
--------------------------------------------------------------------------------
1 | export { default } from './404Container'
2 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/views/Error/500/index.ts:
--------------------------------------------------------------------------------
1 | export { default } from './500Container'
2 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/views/Feeds/MyFeed/index.ts:
--------------------------------------------------------------------------------
1 | export { default } from './MyFeed'
2 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/views/Feeds/index.ts:
--------------------------------------------------------------------------------
1 | export { default } from './FeedsContainer'
2 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/views/UserPage/List/index.ts:
--------------------------------------------------------------------------------
1 | export { default } from './List'
2 |
--------------------------------------------------------------------------------
/apps/next-app/src/app/(hasGNB)/_components/Container/index.ts:
--------------------------------------------------------------------------------
1 | export { default } from './Container'
2 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/views/RssInput/index.ts:
--------------------------------------------------------------------------------
1 | export { default } from './RssInputContainer'
2 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/views/UserPage/index.ts:
--------------------------------------------------------------------------------
1 | export { default } from './UserPageContainer'
2 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/views/MyAccount/index.ts:
--------------------------------------------------------------------------------
1 | export { default } from './MyAccountContainer'
2 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/views/UserPage/List/PostList/index.ts:
--------------------------------------------------------------------------------
1 | export { default } from './PostList'
2 |
--------------------------------------------------------------------------------
/appspec.yml:
--------------------------------------------------------------------------------
1 | version: 0.0
2 | os: linux
3 | hooks:
4 | ApplicationStart:
5 | - location: deploy.sh
6 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/views/UserPage/List/ChannelList/index.ts:
--------------------------------------------------------------------------------
1 | export { default } from './ChannelList'
2 |
--------------------------------------------------------------------------------
/apps/next-app/src/utils/index.ts:
--------------------------------------------------------------------------------
1 | export * from './url'
2 | export * from './date'
3 | export * from './env'
4 |
--------------------------------------------------------------------------------
/apps/extension/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/feedoong/feedoong-frontend/HEAD/apps/extension/favicon.png
--------------------------------------------------------------------------------
/apps/next-app/src/app/page.tsx:
--------------------------------------------------------------------------------
1 | const HomePage = () => {
2 | return <>>
3 | }
4 |
5 | export default HomePage
6 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "trailingComma": "es5",
3 | "tabWidth": 2,
4 | "semi": false,
5 | "singleQuote": true
6 | }
7 |
--------------------------------------------------------------------------------
/apps/next-app/.dockerignore:
--------------------------------------------------------------------------------
1 | Dockerfile
2 | .dockerignore
3 | node_modules
4 | npm-debug.log
5 | README.md
6 | .next
7 | .git
8 |
--------------------------------------------------------------------------------
/apps/next-app/public/og_image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/feedoong/feedoong-frontend/HEAD/apps/next-app/public/og_image.png
--------------------------------------------------------------------------------
/apps/next-app/src/constants/auth.ts:
--------------------------------------------------------------------------------
1 | export const AccessToken = 'accessToken'
2 | export const RefreshToken = 'refreshToken'
3 |
--------------------------------------------------------------------------------
/apps/next-app/.husky/_/pre-push:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 | . "${0%/*}/husky.sh"
3 |
4 | cd apps/next-app
5 | yarn lint
6 | yarn tsc
7 |
--------------------------------------------------------------------------------
/apps/next-app/src/assets/images/User.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/feedoong/feedoong-frontend/HEAD/apps/next-app/src/assets/images/User.png
--------------------------------------------------------------------------------
/apps/next-app/src/assets/channels/kakao.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/feedoong/feedoong-frontend/HEAD/apps/next-app/src/assets/channels/kakao.ico
--------------------------------------------------------------------------------
/apps/next-app/src/assets/channels/toss.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/feedoong/feedoong-frontend/HEAD/apps/next-app/src/assets/channels/toss.ico
--------------------------------------------------------------------------------
/apps/next-app/src/assets/channels/velog.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/feedoong/feedoong-frontend/HEAD/apps/next-app/src/assets/channels/velog.ico
--------------------------------------------------------------------------------
/apps/next-app/src/assets/icons/account.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/feedoong/feedoong-frontend/HEAD/apps/next-app/src/assets/icons/account.png
--------------------------------------------------------------------------------
/apps/next-app/src/assets/channels/brunch.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/feedoong/feedoong-frontend/HEAD/apps/next-app/src/assets/channels/brunch.ico
--------------------------------------------------------------------------------
/apps/next-app/src/assets/channels/chrome.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/feedoong/feedoong-frontend/HEAD/apps/next-app/src/assets/channels/chrome.png
--------------------------------------------------------------------------------
/apps/next-app/src/assets/channels/tistory.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/feedoong/feedoong-frontend/HEAD/apps/next-app/src/assets/channels/tistory.ico
--------------------------------------------------------------------------------
/apps/next-app/src/assets/channels/vercel.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/feedoong/feedoong-frontend/HEAD/apps/next-app/src/assets/channels/vercel.png
--------------------------------------------------------------------------------
/apps/next-app/src/assets/channels/vscode.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/feedoong/feedoong-frontend/HEAD/apps/next-app/src/assets/channels/vscode.ico
--------------------------------------------------------------------------------
/apps/next-app/src/assets/channels/youtube.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/feedoong/feedoong-frontend/HEAD/apps/next-app/src/assets/channels/youtube.ico
--------------------------------------------------------------------------------
/apps/next-app/src/assets/channels/naver_blog.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/feedoong/feedoong-frontend/HEAD/apps/next-app/src/assets/channels/naver_blog.ico
--------------------------------------------------------------------------------
/apps/next-app/src/assets/channels/nhnToast.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/feedoong/feedoong-frontend/HEAD/apps/next-app/src/assets/channels/nhnToast.ico
--------------------------------------------------------------------------------
/apps/next-app/src/assets/images/introduce_1.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/feedoong/feedoong-frontend/HEAD/apps/next-app/src/assets/images/introduce_1.webp
--------------------------------------------------------------------------------
/apps/next-app/src/assets/images/introduce_2.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/feedoong/feedoong-frontend/HEAD/apps/next-app/src/assets/images/introduce_2.webp
--------------------------------------------------------------------------------
/apps/next-app/src/assets/images/introduce_3.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/feedoong/feedoong-frontend/HEAD/apps/next-app/src/assets/images/introduce_3.webp
--------------------------------------------------------------------------------
/apps/next-app/src/styles/constants.ts:
--------------------------------------------------------------------------------
1 | export const Z_INDEX = {
2 | popOver: 10,
3 | navBar: 1_000,
4 | modal: 10_000,
5 | toast: 20_000,
6 | }
7 |
--------------------------------------------------------------------------------
/.yarn/sdks/integrations.yml:
--------------------------------------------------------------------------------
1 | # This file is automatically generated by @yarnpkg/sdks.
2 | # Manual changes might be lost!
3 |
4 | integrations:
5 | - vscode
6 |
--------------------------------------------------------------------------------
/apps/next-app/src/assets/channels/hyperconnect.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/feedoong/feedoong-frontend/HEAD/apps/next-app/src/assets/channels/hyperconnect.ico
--------------------------------------------------------------------------------
/apps/next-app/src/assets/fonts/Satoshi-Bold.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/feedoong/feedoong-frontend/HEAD/apps/next-app/src/assets/fonts/Satoshi-Bold.woff2
--------------------------------------------------------------------------------
/apps/next-app/src/assets/images/introduce_header.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/feedoong/feedoong-frontend/HEAD/apps/next-app/src/assets/images/introduce_header.webp
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | /.yarn/** linguist-vendored
2 | /.yarn/releases/* binary
3 | /.yarn/plugins/**/* binary
4 | /.pnp.* binary linguist-generated
5 |
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": [
3 | "arcanis.vscode-zipfs",
4 | "dbaeumer.vscode-eslint",
5 | "esbenp.prettier-vscode"
6 | ]
7 | }
8 |
--------------------------------------------------------------------------------
/apps/extension/index.js:
--------------------------------------------------------------------------------
1 | chrome.tabs.getCurrent(function (tab) {
2 | chrome.tabs.update(tab.id, {
3 | url: "https://feedoong.io",
4 | highlighted: true,
5 | });
6 | });
7 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/views/UserPage/List/List.style.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components'
2 |
3 | export const ListContainer = styled.div`
4 | width: 100%;
5 | `
6 |
--------------------------------------------------------------------------------
/.github/pull_request_template.md:
--------------------------------------------------------------------------------
1 | ## Motivation 🤔
2 |
3 | -
4 |
5 |
6 |
7 | ## Key Changes 🔑
8 |
9 | -
10 |
11 |
12 |
13 | ## To Reviews 🙏🏻
14 |
15 | -
16 |
--------------------------------------------------------------------------------
/apps/next-app/src/constants/url.ts:
--------------------------------------------------------------------------------
1 | export const FEEDOONG_EXTENSION_URL =
2 | 'https://chrome.google.com/webstore/detail/feedoong-rss-feed-reader/djocleehibgjoijlphimcjilcflimjdn/'
3 |
--------------------------------------------------------------------------------
/.yarn/sdks/prettier/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "prettier",
3 | "version": "3.2.5-sdk",
4 | "main": "./index.cjs",
5 | "type": "commonjs",
6 | "bin": "./bin/prettier.cjs"
7 | }
8 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/common/Modal/index.ts:
--------------------------------------------------------------------------------
1 | export { useModal } from './useModal'
2 | export { DimmerLayout } from './DimmerLayout'
3 | export { ModalLayout } from './ModalLayout'
4 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | end_of_line = lf
5 | insert_final_newline = true
6 |
7 | [*.{js,json,yml}]
8 | charset = utf-8
9 | indent_style = space
10 | indent_size = 2
11 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/views/Error/index.ts:
--------------------------------------------------------------------------------
1 | import Custom404 from './404'
2 | import Custom500 from './500'
3 |
4 | export { Custom404 as Custom404Container, Custom500 as Custom500Container }
5 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/common/Skeleton/index.ts:
--------------------------------------------------------------------------------
1 | import SkeletonPostType from './PostType'
2 | import SkeletonChannelType from './ChannelType'
3 |
4 | export { SkeletonPostType, SkeletonChannelType }
5 |
--------------------------------------------------------------------------------
/apps/next-app/src/utils/env.ts:
--------------------------------------------------------------------------------
1 | export const isServer = () => {
2 | return typeof window === 'undefined'
3 | }
4 |
5 | export const isProduction = () => {
6 | return process.env.NEXT_PUBLIC_APP_ENV === 'production'
7 | }
8 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/common/Notification/index.ts:
--------------------------------------------------------------------------------
1 | import { clear, show, config } from './methods'
2 |
3 | const Notification = {
4 | show,
5 | clear,
6 | config,
7 | }
8 |
9 | export default Notification
10 |
--------------------------------------------------------------------------------
/apps/next-app/src/constants/dateTime.ts:
--------------------------------------------------------------------------------
1 | export const SECOND = 1000
2 | export const MINUTE = SECOND * 60
3 | export const HOUR = MINUTE * 60
4 | export const DAY = HOUR * 24
5 | export const WEEK = DAY * 7
6 | export const MONTH = DAY * 30
7 |
--------------------------------------------------------------------------------
/apps/next-app/src/views/myFeed/MyFeed.style.ts:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import styled from 'styled-components'
4 |
5 | export const CardContainer = styled.ul`
6 | display: flex;
7 | gap: 20px;
8 | flex-direction: column;
9 | `
10 |
--------------------------------------------------------------------------------
/apps/next-app/src/pages/404.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import { Custom404Container } from 'components/views/Error'
4 |
5 | const Custom404 = () => {
6 | return
7 | }
8 |
9 | export default Custom404
10 |
--------------------------------------------------------------------------------
/apps/next-app/src/pages/500.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import { Custom500Container } from 'components/views/Error'
4 |
5 | const Custom500 = () => {
6 | return
7 | }
8 |
9 | export default Custom500
10 |
--------------------------------------------------------------------------------
/apps/next-app/src/utils/date.ts:
--------------------------------------------------------------------------------
1 | import dayjs from 'dayjs'
2 |
3 | export const getFormatDate = (
4 | date: Parameters[0],
5 | format: Parameters['format']>[0]
6 | ) => {
7 | return dayjs(date).format(format)
8 | }
9 |
--------------------------------------------------------------------------------
/.yarn/sdks/typescript/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "typescript",
3 | "version": "5.5.2-sdk",
4 | "main": "./lib/typescript.js",
5 | "type": "commonjs",
6 | "bin": {
7 | "tsc": "./bin/tsc",
8 | "tsserver": "./bin/tsserver"
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/apps/next-app/src/pages/mypage/account/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import MyAccountContainer from 'components/views/MyAccount'
4 |
5 | const MyAccount = () => {
6 | return
7 | }
8 |
9 | export default MyAccount
10 |
--------------------------------------------------------------------------------
/apps/next-app/src/utils/hooks/index.ts:
--------------------------------------------------------------------------------
1 | import { useGoogleAnalytics } from './useGoogleAnalytics'
2 | import { useLockBodyScroll } from './useLockBodyScroll'
3 | import { useColorMode } from './useColorMode'
4 |
5 | export { useGoogleAnalytics, useLockBodyScroll, useColorMode }
6 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/views/RssInput/hooks/index.ts:
--------------------------------------------------------------------------------
1 | import useControlled from './useControlled'
2 | import useRssInput from './useRssInput'
3 | import useRssDirectInputModal from './useRssDirectInputModal'
4 |
5 | export { useControlled, useRssInput, useRssDirectInputModal }
6 |
--------------------------------------------------------------------------------
/apps/next-app/src/app/(hasGNB)/_components/Container/Container.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import styled from 'styled-components'
3 |
4 | const Container = styled.main`
5 | min-height: calc(100dvh - 75px);
6 | padding-top: 75px; // Nav 높이 만큼
7 | `
8 |
9 | export default Container
10 |
--------------------------------------------------------------------------------
/apps/next-app/src/assets/icons/add.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/apps/next-app/src/shared/utils/checkLoggedIn.ts:
--------------------------------------------------------------------------------
1 | import type { ReadonlyRequestCookies } from 'next/dist/server/web/spec-extension/adapters/request-cookies'
2 |
3 | export const checkLoggedIn = (cookies: ReadonlyRequestCookies) => {
4 | return Boolean(cookies.get('accessToken')?.value)
5 | }
6 |
--------------------------------------------------------------------------------
/apps/next-app/src/utils/gtag.ts:
--------------------------------------------------------------------------------
1 | // https://developers.google.com/analytics/devguides/collection/gtagjs/pages
2 | export const pageview = (url: string) => {
3 | ;(window as any).gtag(
4 | 'config',
5 | process.env.NEXT_PUBLIC_GA_TRACKING_ID as string,
6 | { page_path: url }
7 | )
8 | }
9 |
--------------------------------------------------------------------------------
/apps/next-app/src/shared/libs/context.ts:
--------------------------------------------------------------------------------
1 | import type { IncomingMessage } from 'http'
2 |
3 | export let asyncLocalStorage: any
4 | const isServer = typeof window === 'undefined'
5 |
6 | if (isServer) {
7 | asyncLocalStorage = new AsyncLocalStorage<{
8 | req: IncomingMessage
9 | }>()
10 | }
11 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/common/Dialog/DialogTitle.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import * as S from './Dialog.style'
4 |
5 | interface Props {
6 | children: React.ReactNode
7 | }
8 |
9 | export const DialogTitle = ({ children }: Props) => {
10 | return {children}
11 | }
12 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ### FEEDOONG-Frontend
2 |
3 | > **인사이트가 피둥피둥**
4 | >
5 | > 여기저기 둥둥 떠다니는 나의 인사이트 컨텐츠들을 피둥에서 모아보세요!
6 |
7 | ## 👨💻 만든 사람들
8 |
9 | - 신은선 [:octocat:](https://github.com/eunsonny) [📚](https://eunsonny.github.io/)
10 | - 오종택 [:octocat:](https://github.com/saengmotmi) [📚](https://saengmotmi.netlify.app/)
11 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/common/Dialog/DialogContent.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import * as S from './Dialog.style'
4 |
5 | interface Props {
6 | children: React.ReactNode
7 | }
8 |
9 | export const DialogContent = ({ children }: Props) => {
10 | return {children}
11 | }
12 |
--------------------------------------------------------------------------------
/apps/next-app/src/types/colorMode.ts:
--------------------------------------------------------------------------------
1 | import type useColors from 'utils/hooks/useColors'
2 |
3 | export type ColorModeType = 'dark' | 'light'
4 |
5 | export type ColorModeColorKey = keyof ReturnType['colorSet']
6 | export type ColorModeColorValue = ReturnType<
7 | typeof useColors
8 | >['colorSet'][ColorModeColorKey]
9 |
--------------------------------------------------------------------------------
/apps/next-app/src/app/signup/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { ReactNode } from 'react'
2 |
3 | import * as S from './SignUp.style'
4 |
5 | interface Props {
6 | children: ReactNode
7 | }
8 |
9 | const SignUpLayout = ({ children }: Props) => {
10 | return {children}
11 | }
12 |
13 | export default SignUpLayout
14 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/common/Dialog/DialogActions.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import * as S from './Dialog.style'
4 |
5 | interface Props {
6 | children: React.ReactNode
7 | }
8 |
9 | export const DialogActions = ({ children }: Props) => {
10 | return {children}
11 | }
12 |
--------------------------------------------------------------------------------
/apps/next-app/src/features/auth/tokenRefreshMutex.ts:
--------------------------------------------------------------------------------
1 | import Mutex from 'shared/utils/mutex'
2 |
3 | // 클라이언트에서만 `Mutex` 인스턴스를 생성하고, 서버에서는 `null`을 export
4 | let tokenRefreshMutex: Mutex | null = null
5 |
6 | if (typeof window !== 'undefined') {
7 | tokenRefreshMutex = new Mutex()
8 | }
9 |
10 | export default tokenRefreshMutex
11 |
--------------------------------------------------------------------------------
/apps/next-app/src/features/errors/errors.ts:
--------------------------------------------------------------------------------
1 | import httpStatus from 'http-status-codes'
2 |
3 | export const isErrorPage = (pathname: string) => {
4 | return [
5 | `/${httpStatus.NOT_FOUND}`, // 400
6 | `/${httpStatus.INTERNAL_SERVER_ERROR}`, // 500
7 | ].some((path) => {
8 | return pathname === path
9 | })
10 | }
11 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/views/Error/404/404Container.style.tsx:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components'
2 |
3 | import { getTypographyStyles } from 'styles/fonts'
4 |
5 | export const SubDescription = styled.div`
6 | ${getTypographyStyles('Headline3_M')}
7 | color: var(--color-font-secondary);
8 | text-align: center;
9 | `
10 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/views/UserPage/UserPageContainer.utils.ts:
--------------------------------------------------------------------------------
1 | import { getDomainName } from 'envs'
2 | import type { PublicUserInfoResponse } from 'services/types/_generated/apiDocumentation.schemas'
3 |
4 | export const getFeedoongUrl = (userProfile?: PublicUserInfoResponse) => {
5 | return `${getDomainName()}/${userProfile?.username}`
6 | }
7 |
--------------------------------------------------------------------------------
/apps/next-app/src/features/user/useCheckIsMyProfile.ts:
--------------------------------------------------------------------------------
1 | import { useRouter } from 'next/router'
2 |
3 | import { useGetUserProfile } from './userProfile'
4 |
5 | export const useCheckIsMyProfile = () => {
6 | const router = useRouter()
7 | const { data: me } = useGetUserProfile()
8 |
9 | return router.query.userName === me?.username
10 | }
11 |
--------------------------------------------------------------------------------
/apps/next-app/src/utils/common.ts:
--------------------------------------------------------------------------------
1 | export const mergeObjectsByMutate = (target: any, source: any) => {
2 | for (const key of Object.keys(source)) {
3 | if (source[key] instanceof Object)
4 | Object.assign(source[key], mergeObjectsByMutate(target[key], source[key]))
5 | }
6 |
7 | Object.assign(target || {}, source)
8 | return target
9 | }
10 |
--------------------------------------------------------------------------------
/deploy.sh:
--------------------------------------------------------------------------------
1 |
2 |
3 | docker stop feedoong-frontend
4 | docker rm feedoong-frontend
5 |
6 | docker rmi $(docker images jamessoun93/feedoong-frontend -q)
7 |
8 | docker pull jamessoun93/feedoong-frontend
9 |
10 | docker run --name feedoong-frontend -d -p 3000:3000 jamessoun93/feedoong-frontend:latest
11 |
12 | echo "Deployment script executed successfully."
13 |
--------------------------------------------------------------------------------
/apps/next-app/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:20-alpine
2 |
3 | WORKDIR /app
4 |
5 | COPY ./apps/next-app/package* ./
6 | COPY yarn.lock .pnp* ./
7 | COPY .yarnrc.yml ./
8 | COPY .yarn .yarn
9 |
10 | RUN yarn install
11 |
12 | COPY ./apps/next-app .
13 |
14 | RUN yarn build
15 |
16 | EXPOSE 3000
17 |
18 | CMD ["yarn", "start"]
19 |
--------------------------------------------------------------------------------
/.yarn/sdks/eslint/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "eslint",
3 | "version": "8.57.0-sdk",
4 | "main": "./lib/api.js",
5 | "type": "commonjs",
6 | "bin": {
7 | "eslint": "./bin/eslint.js"
8 | },
9 | "exports": {
10 | "./package.json": "./package.json",
11 | ".": "./lib/api.js",
12 | "./use-at-your-own-risk": "./lib/unsupported-api.js"
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/apps/extension/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Document
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/apps/next-app/src/app/introduce/_components/IntroduceHeaderTitle.tsx:
--------------------------------------------------------------------------------
1 | import * as S from '../Introduce.style'
2 |
3 | const IntroduceHeaderTitle = () => {
4 | return (
5 |
6 | 여기저기 둥둥💭 떠있는 나의 인사이트💡 콘텐츠📚를{' '}
7 | 피둥🐽으로 모아보세요.
8 |
9 | )
10 | }
11 |
12 | export default IntroduceHeaderTitle
13 |
--------------------------------------------------------------------------------
/apps/next-app/orval.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | feedoongApp: {
3 | input: 'src/services/spec.json',
4 | output: {
5 | target: 'src/services/types/_generated',
6 | mode: 'tags',
7 | override: {
8 | mutator: {
9 | path: 'src/services/api/index.ts',
10 | name: 'feedoongApi',
11 | },
12 | },
13 | },
14 | },
15 | }
16 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/common/Loading/Loading.tsx:
--------------------------------------------------------------------------------
1 | import * as S from './Loading.style'
2 |
3 | const Loading: React.FC = () => {
4 | return (
5 |
6 |
12 |
13 | )
14 | }
15 |
16 | export default Loading
17 |
--------------------------------------------------------------------------------
/apps/next-app/src/utils/hooks/useColors.ts:
--------------------------------------------------------------------------------
1 | import { DARK_MODE_COLORS, LIGHT_MODE_COLORS } from 'constants/colorMode'
2 | import { useColorMode } from './useColorMode'
3 |
4 | const useColors = () => {
5 | const { isDarkMode } = useColorMode()
6 |
7 | return {
8 | colorSet: isDarkMode ? DARK_MODE_COLORS : LIGHT_MODE_COLORS,
9 | isDarkMode,
10 | }
11 | }
12 |
13 | export default useColors
14 |
--------------------------------------------------------------------------------
/apps/next-app/src/app/introduce/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { ReactNode } from 'react'
2 |
3 | import * as S from './Introduce.style'
4 |
5 | interface Props {
6 | children: ReactNode
7 | }
8 |
9 | const IntroduceLayout = ({ children }: Props) => {
10 | return (
11 |
12 | {children}
13 |
14 | )
15 | }
16 |
17 | export default IntroduceLayout
18 |
--------------------------------------------------------------------------------
/apps/next-app/src/assets/icons/bookmark.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/apps/next-app/src/pages/[userName].tsx:
--------------------------------------------------------------------------------
1 | import type { NextPage } from 'next'
2 |
3 | import UserPageContainer from 'components/views/UserPage'
4 | import { withPrefetchUser } from 'features/auth/withAuthQueryServerSideProps'
5 |
6 | const UserProfile: NextPage = () => {
7 | return
8 | }
9 |
10 | export default UserProfile
11 |
12 | export const getServerSideProps = withPrefetchUser
13 |
--------------------------------------------------------------------------------
/apps/next-app/src/assets/icons/bookmark-deactive.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/apps/next-app/src/utils/hooks/useLockBodyScroll.ts:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import { useLayoutEffect } from 'react'
3 |
4 | export const useLockBodyScroll = () => {
5 | useLayoutEffect(() => {
6 | const originalStyle = window.getComputedStyle(document.body).overflow
7 | document.body.style.overflow = 'hidden'
8 |
9 | return () => {
10 | document.body.style.overflow = originalStyle
11 | }
12 | }, [])
13 | }
14 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/common/Layout/Layout.style.tsx:
--------------------------------------------------------------------------------
1 | import styled, { css } from 'styled-components'
2 |
3 | export const Container = styled.main<{ $fullHeight: boolean }>`
4 | min-height: calc(100dvh - 75px);
5 | padding-top: 75px; // Nav 높이 만큼
6 |
7 | ${({ $fullHeight }) =>
8 | $fullHeight
9 | ? css`
10 | min-height: 100dvh;
11 | padding-top: 0px;
12 | `
13 | : ''}
14 | `
15 |
--------------------------------------------------------------------------------
/apps/extension/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Feedoong: RSS Feed Reader",
3 | "description": "여기저기 둥둥 떠있는 나의 인사이트 컨텐츠들을 피둥에서 모아보기! 크롬 새 탭에서 바로 시작하세요!",
4 | "version": "1.1.0",
5 | "manifest_version": 3,
6 | "chrome_url_overrides": {
7 | "newtab": "index.html"
8 | },
9 | "action": {
10 | "default_title": "Feedoong"
11 | },
12 | "icons": {
13 | "48": "./favicon.png",
14 | "96": "./favicon.png"
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/apps/next-app/src/app/(hasGNB)/layout.tsx:
--------------------------------------------------------------------------------
1 | import Nav from './_components/Nav'
2 | import Container from './_components/Container'
3 |
4 | interface HasGNBLayoutProps {
5 | children: React.ReactNode
6 | }
7 |
8 | const HasGNBLayout = async ({ children }: HasGNBLayoutProps) => {
9 | return (
10 | <>
11 |
12 | {children}
13 | >
14 | )
15 | }
16 |
17 | export default HasGNBLayout
18 |
--------------------------------------------------------------------------------
/apps/next-app/src/pages/channels/[id].tsx:
--------------------------------------------------------------------------------
1 | import type { NextPage } from 'next'
2 |
3 | import ChannelDetailView from 'components/views/Channel/ChannelDetailContainer'
4 | import { withPrefetchUser } from 'features/auth/withAuthQueryServerSideProps'
5 |
6 | const ChannelDetail: NextPage = () => {
7 | return
8 | }
9 |
10 | export default ChannelDetail
11 |
12 | export const getServerSideProps = withPrefetchUser
13 |
--------------------------------------------------------------------------------
/.github/workflows/auto_assign.yml:
--------------------------------------------------------------------------------
1 | name: "Auto Assign"
2 | on:
3 | pull_request_target:
4 | types: [opened, reopened, ready_for_review]
5 |
6 | jobs:
7 | add-reviews:
8 | runs-on: ubuntu-latest
9 | steps:
10 | - name: Auto Assign Action
11 | uses: kentaro-m/auto-assign-action@v1.2.1
12 | with:
13 | configuration-path: ".github/auto_assign_config.yml" # Only needed if you use something other than .github/auto_assign.yml
14 |
--------------------------------------------------------------------------------
/apps/next-app/src/types/common.ts:
--------------------------------------------------------------------------------
1 | import type { AxiosResponse } from 'axios'
2 |
3 | export type ApiResponseData = {
4 | data: D
5 | }
6 |
7 | export type ApiResponse = AxiosResponse>
8 |
9 | export interface ErrorResponse {
10 | message: string
11 | code: string
12 | }
13 |
14 | export const RESPONSE_CODE = {
15 | REFRESH_TOKEN_NOT_FOUND: 'REFRESH_TOKEN_NOT_FOUND',
16 | EXPIRED_REFRESH_TOKEN: 'EXPIRED_REFRESH_TOKEN',
17 | }
18 |
--------------------------------------------------------------------------------
/apps/next-app/src/app/(hasGNB)/_components/Nav/GoToSignUpButton.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useRouter } from 'next/navigation'
4 |
5 | import { ROUTE } from 'constants/route'
6 | import * as S from 'components/common/Layout/Nav/Nav.style'
7 |
8 | export const GoToSignUpButton = () => {
9 | const router = useRouter()
10 |
11 | return (
12 | router.push(ROUTE.SIGN_UP)}>
13 | 피둥 시작하기
14 |
15 | )
16 | }
17 |
--------------------------------------------------------------------------------
/apps/next-app/src/shared/libs/nextjs.ts:
--------------------------------------------------------------------------------
1 | export const isAppRouter = () => {
2 | try {
3 | // Throws an error if we are not in the App Router context.
4 | // eslint-disable-next-line @typescript-eslint/no-var-requires
5 | const { cookies } = require('next/headers')
6 | cookies()
7 | return true
8 | } catch (e) {
9 | return false
10 | }
11 | }
12 |
13 | // eslint-disable-next-line @typescript-eslint/no-var-requires
14 | export const getNextCookies = () => require('next/headers').cookies()
15 |
--------------------------------------------------------------------------------
/apps/next-app/.husky/_/h:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 | [ "$HUSKY" = "2" ] && set -x
3 | h="${0##*/}"
4 | s="${0%/*/*}/$h"
5 |
6 | [ ! -f "$s" ] && exit 0
7 |
8 | for f in "${XDG_CONFIG_HOME:-$HOME/.config}/husky/init.sh" "$HOME/.huskyrc"; do
9 | # shellcheck disable=SC1090
10 | [ -f "$f" ] && . "$f"
11 | done
12 |
13 | [ "${HUSKY-}" = "0" ] && exit 0
14 |
15 | sh -e "$s" "$@"
16 | c=$?
17 |
18 | [ $c != 0 ] && echo "husky - $h script failed (code $c)"
19 | [ $c = 127 ] && echo "husky - command not found in PATH=$PATH"
20 | exit $c
--------------------------------------------------------------------------------
/apps/next-app/src/assets/icons/Darkmode.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/apps/next-app/src/assets/icons/toast-basic.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/apps/next-app/src/assets/icons/toast-error.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/apps/next-app/src/styles/colors.ts:
--------------------------------------------------------------------------------
1 | export const colors = {
2 | mainBG: '#EDECE6',
3 | mainPink: '#ECAFCB',
4 | red: '#E14942',
5 | blue: '#3872E0',
6 | white: '#fff',
7 | black: '#000',
8 | gray100: '#F5F5F5',
9 | gray200: '#EBEBEB',
10 | gray300: '#E0E0E0',
11 | gray400: '#CCCCCC',
12 | gray500: '#AEAEAE',
13 | gray600: '#8C8C8C',
14 | gray700: '#646464',
15 | gray800: '#424242',
16 | gray900: '#212322',
17 | error: '#E14942',
18 | /** @description figma 용어 통일 필요 */
19 | primary: '#3872E0',
20 | }
21 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.defaultFormatter": "dbaeumer.vscode-eslint",
3 | "editor.formatOnSave": true,
4 | "editor.codeActionsOnSave": [
5 | "source.addMissingImports",
6 | "source.fixAll.eslint"
7 | ],
8 | "search.exclude": {
9 | "**/.yarn": true,
10 | "**/.pnp.*": true
11 | },
12 | "eslint.nodePath": ".yarn/sdks",
13 | "prettier.prettierPath": ".yarn/sdks/prettier/index.cjs",
14 | "typescript.tsdk": ".yarn/sdks/typescript/lib",
15 | "typescript.enablePromptUseWorkspaceTsdk": true
16 | }
17 |
--------------------------------------------------------------------------------
/apps/next-app/src/constants/route.ts:
--------------------------------------------------------------------------------
1 | export const FEED_ROUTE = {
2 | MY_FEED: '/feed/me',
3 | RECOMMENDED_FEED: '/feed/recommended',
4 | RECOMMENDED_CHANNELS: '/feed/recommended/channels',
5 | RECOMMENDED_POSTS: '/feed/recommended/posts',
6 | } as const
7 |
8 | export const ROUTE = {
9 | INTRODUCE: '/introduce',
10 | SIGN_UP: '/signup',
11 | MY_ACCOUNT: '/mypage/account',
12 | ...FEED_ROUTE,
13 | } as const
14 |
15 | export const PRIVATE_ROUTE = {
16 | MY_FEED: ROUTE.MY_FEED,
17 | MY_ACCOUNT: ROUTE.MY_ACCOUNT,
18 | }
19 |
--------------------------------------------------------------------------------
/apps/next-app/src/types/subscriptions.ts:
--------------------------------------------------------------------------------
1 | export interface Channel {
2 | description: string
3 | feedUrl: string
4 | id: number
5 | title: string
6 | url: string
7 | imageUrl: string
8 | isSubscribed: boolean
9 | }
10 |
11 | export interface PrivateChannel extends Channel {
12 | isViewed: boolean
13 | isLiked: boolean
14 | }
15 |
16 | export interface Channels {
17 | channels: Channel[]
18 | totalCount: number
19 | }
20 |
21 | export const isChannel = (obj: any): obj is Channel => {
22 | return obj.feedUrl
23 | }
24 |
--------------------------------------------------------------------------------
/apps/next-app/src/entities/user/api/index.ts:
--------------------------------------------------------------------------------
1 | import { queryOptions } from '@tanstack/react-query'
2 |
3 | import { getRefreshTokenFromCookie } from 'features/auth/token'
4 | import { CACHE_KEYS } from 'services/cacheKeys'
5 | import { getUserInfoUsingGET } from 'services/types/_generated/user'
6 |
7 | export const userQueries = {
8 | all: () => ['user'],
9 | me: () =>
10 | queryOptions({
11 | queryKey: CACHE_KEYS.me,
12 | queryFn: getUserInfoUsingGET,
13 | enabled: !!getRefreshTokenFromCookie(),
14 | }),
15 | }
16 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/common/Anchor/index.tsx:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components'
2 |
3 | interface Props extends React.HTMLAttributes {
4 | href?: string
5 | target?: string
6 | children: React.ReactNode
7 | }
8 |
9 | const Anchor = ({ children, ...rest }: Props) => {
10 | return (
11 |
12 | {children}
13 |
14 | )
15 | }
16 |
17 | export default Anchor
18 |
19 | const Container = styled.a`
20 | all: unset;
21 | cursor: pointer;
22 | `
23 |
--------------------------------------------------------------------------------
/apps/next-app/src/styles/font.css:
--------------------------------------------------------------------------------
1 | html,
2 | body {
3 | font-family: 'Pretendard Variable', -apple-system, BlinkMacSystemFont,
4 | system-ui, Roboto, 'Helvetica Neue', 'Segoe UI', 'Apple SD Gothic Neo',
5 | 'Noto Sans KR', 'Malgun Gothic', 'Apple Color Emoji', 'Segoe UI Emoji',
6 | 'Segoe UI Symbol', sans-serif;
7 | }
8 |
9 | @font-face {
10 | font-family: 'Satoshi-Bold';
11 | font-weight: 700;
12 | font-display: swap;
13 | src: local('Satoshi-Bold'),
14 | url('../assets/fonts/Satoshi-Bold.woff2') format('woff2');
15 | }
16 |
--------------------------------------------------------------------------------
/apps/next-app/src/utils/url.ts:
--------------------------------------------------------------------------------
1 | import { getIconByHostname } from 'assets/channels'
2 |
3 | /**
4 | * @param {string} url
5 | * @link https://regexr.com/39nr7
6 | */
7 | export const checkURLValid = (inputUrl: string) => {
8 | return /[(http(s)?):\/\/(www\.)?a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/.test(
9 | inputUrl
10 | )
11 | }
12 |
13 | export const getWellKnownChannelImg = (url: string) => {
14 | try {
15 | return getIconByHostname(new URL(url).hostname)
16 | } catch {
17 | return
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/apps/next-app/src/app/(hasGNB)/_components/Nav/LogoButton.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import { useRouter } from 'next/navigation'
3 |
4 | import * as S from 'components/common/Layout/Nav/Nav.style'
5 | import LogoDesktopNoBackground from 'components/common/LogoDesktop'
6 |
7 | export const LogoButton = () => {
8 | const router = useRouter()
9 |
10 | return (
11 | router.push('/')}>
12 |
13 | Feedoong
14 |
15 | )
16 | }
17 |
--------------------------------------------------------------------------------
/apps/next-app/src/utils/errors.ts:
--------------------------------------------------------------------------------
1 | import type { AxiosError } from 'axios'
2 |
3 | export interface ErrorBody {
4 | status: string
5 | message: string
6 | exceptions: string[]
7 | }
8 |
9 | export const getAxiosError = (error: AxiosError) => {
10 | if (!error.response) {
11 | throw Error("response doesn't exist")
12 | }
13 | return error.response.data
14 | }
15 |
16 | export const isAxiosError = (
17 | error: unknown
18 | ): error is AxiosError => {
19 | return (error as AxiosError).response !== undefined
20 | }
21 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/common/FeedItem/FeedItem.utils.ts:
--------------------------------------------------------------------------------
1 | import Toast from '../Toast'
2 |
3 | export const copyToClipboard = async (linkUrl: string) => {
4 | try {
5 | await navigator.clipboard.writeText(linkUrl)
6 | Toast.show({ content: '링크 복사 완료' })
7 | } catch (error) {
8 | Toast.show({ type: 'error', content: '복사에 실패하였습니다.' })
9 | }
10 | }
11 |
12 | export const getDiameterByType = (type: string) => {
13 | if (type.includes('card')) {
14 | return 20
15 | }
16 | if (type.includes('subscription')) {
17 | return 48
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/common/Popover/PopoverLayout.tsx:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components'
2 |
3 | interface Props extends React.HTMLAttributes {
4 | children: React.ReactNode
5 | }
6 |
7 | const PopoverLayout = ({ children, ...props }: Props) => {
8 | return {children}
9 | }
10 |
11 | export default PopoverLayout
12 |
13 | const Container = styled.ul`
14 | padding: 12px 0px;
15 | background: var(--color-surface-container-lowest);
16 | border: 1px solid var(--color-varients);
17 | border-radius: 10px;
18 | `
19 |
--------------------------------------------------------------------------------
/apps/next-app/src/styles/global.css:
--------------------------------------------------------------------------------
1 | * {
2 | -webkit-tap-highlight-color: transparent;
3 | font-family: 'Pretendard', 'sans-serif';
4 | -ms-overflow-style: none; /* IE and Edge */
5 | scrollbar-width: none; /* Firefox */
6 | }
7 |
8 | *::-webkit-scrollbar {
9 | display: none; /* Chrome, Safari, Opera*/
10 | }
11 |
12 |
13 | html {
14 | height: -webkit-fill-available;
15 | background-color: var(--color-surface);
16 | }
17 |
18 | body {
19 | overflow: unset;
20 | /* mobile viewport bug fix */
21 | min-height: -webkit-fill-available;
22 | overscroll-behavior-y: none;
23 | }
--------------------------------------------------------------------------------
/apps/next-app/src/components/common/Portal/index.tsx:
--------------------------------------------------------------------------------
1 | import ReactDOM from 'react-dom'
2 | import type { ReactNode } from 'react'
3 |
4 | import { isServer } from 'utils'
5 |
6 | interface Props {
7 | children: ReactNode
8 | selector: string
9 | }
10 |
11 | const Portal = ({ children, selector }: Props) => {
12 | if (!children) {
13 | throw Error('children must be provided')
14 | }
15 |
16 | const targetDOM = !isServer() && document.querySelector(selector)
17 | return !!targetDOM ? ReactDOM.createPortal(<>{children}>, targetDOM) : null
18 | }
19 |
20 | export default Portal
21 |
--------------------------------------------------------------------------------
/apps/next-app/src/features/auth/logout.ts:
--------------------------------------------------------------------------------
1 | import type { QueryClient } from '@tanstack/react-query'
2 | import httpStatus from 'http-status-codes'
3 |
4 | import { CACHE_KEYS } from 'services/cacheKeys'
5 | import { destroyTokensClientSide } from 'utils/auth'
6 |
7 | export const logoutAction = (client: QueryClient) => {
8 | client.invalidateQueries({ queryKey: CACHE_KEYS.me })
9 |
10 | destroyTokensClientSide()
11 | window.location.href = '/'
12 | }
13 |
14 | export const isAuthError = (errorStatus: number) => {
15 | return [httpStatus.UNAUTHORIZED, httpStatus.FORBIDDEN].includes(errorStatus)
16 | }
17 |
--------------------------------------------------------------------------------
/apps/next-app/src/services/cacheKeys.ts:
--------------------------------------------------------------------------------
1 | // TODO: 키값 정리 필요
2 | export const CACHE_KEYS = {
3 | feeds: ['feeds'],
4 | likedPosts: ['likedPosts'],
5 | likePost: (id: number) => [...CACHE_KEYS.likedPosts, id],
6 | channels: ['feeds', 'channels'],
7 | channel: (id: number) => [...CACHE_KEYS.channels, id],
8 | preview: (url?: string) => ['channels', 'preview', url],
9 | signup: ['signup'],
10 | me: ['user', 'me'],
11 | viewItem: (id: number) => ['viewItem', id],
12 | recommended: (slug: string[] = []) => ['feeds', 'recommended', ...slug],
13 | user: (username: string) => ['user', username],
14 | }
15 |
--------------------------------------------------------------------------------
/apps/next-app/src/entities/item/api/index.ts:
--------------------------------------------------------------------------------
1 | import { infiniteQueryOptions } from '@tanstack/react-query'
2 |
3 | import { getItemsUsingGET } from 'services/types/_generated/item'
4 |
5 | export const itemQueries = {
6 | all: () => ['item'],
7 | list: () =>
8 | infiniteQueryOptions({
9 | queryKey: [...itemQueries.all(), 'list'],
10 | queryFn: ({ pageParam = 1 }) => {
11 | return getItemsUsingGET({
12 | page: pageParam,
13 | size: 10,
14 | })
15 | },
16 | initialPageParam: 1,
17 | getNextPageParam: (lastPage) => {
18 | return lastPage.next
19 | },
20 | }),
21 | }
22 |
--------------------------------------------------------------------------------
/apps/next-app/src/assets/icons/lightning-mono.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/apps/next-app/src/app/signup/_utils/SignUp.utils.ts:
--------------------------------------------------------------------------------
1 | import { getDomainName } from 'envs'
2 |
3 | const hostname = 'https://accounts.google.com/o/oauth2/v2/auth'
4 |
5 | const clientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID!
6 | const redirectUri = `${getDomainName()}/oauth`
7 | const responseType = 'token'
8 | const scope =
9 | 'https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile'
10 |
11 | const query = new URLSearchParams({
12 | client_id: clientId,
13 | redirect_uri: redirectUri,
14 | response_type: responseType,
15 | scope,
16 | })
17 |
18 | export const googleAuthUrl = `${hostname}?${query.toString()}`
19 |
--------------------------------------------------------------------------------
/apps/next-app/src/middleware.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse, type NextRequest } from 'next/server'
2 | import httpStatus from 'http-status-codes'
3 |
4 | import { ROUTE } from 'constants/route'
5 | import { isLoginValidServerSide } from 'utils/auth'
6 |
7 | // 정적인 경로에 대해서만 처리가 가능해서 정말 필요한 기능인지 고민해볼 것
8 | export const config = {
9 | matcher: ['/feed/me', '/mypage/:path*'],
10 | }
11 |
12 | export function middleware(request: NextRequest) {
13 | if (!isLoginValidServerSide(request)) {
14 | return NextResponse.redirect(
15 | new URL(ROUTE.INTRODUCE, request.url),
16 | httpStatus.MOVED_TEMPORARILY
17 | )
18 | }
19 |
20 | return NextResponse.next()
21 | }
22 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/views/Error/error.style.tsx:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components'
2 |
3 | import { getTypographyStyles } from 'styles/fonts'
4 | import Flex from 'components/common/Flex'
5 |
6 | export const ContentWrapper = styled(Flex)<{
7 | errorType?: '404' | '500'
8 | }>`
9 | width: 100%;
10 | height: calc(100dvh - 75px);
11 | background-color: var(--color-surface);
12 | padding-top: ${({ errorType }) =>
13 | `calc((100dvh - ${errorType === '500' ? 300 : 400}px) * 0.44)`};
14 | `
15 |
16 | export const ContentContainer = styled(Flex)``
17 |
18 | export const MainDescription = styled.p`
19 | ${getTypographyStyles('Headline1_B')};
20 | color: var(--color-font-primary);
21 | `
22 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/views/RssInput/RssInputContainer.utils.ts:
--------------------------------------------------------------------------------
1 | import Toast from 'components/common/Toast'
2 | import { checkURLValid } from 'utils'
3 |
4 | export const isRssUrlValid = (_url?: string) => {
5 | if (_url === undefined) {
6 | return _url
7 | }
8 | return checkURLValid(_url)
9 | }
10 |
11 | export const ChannelToast = {
12 | addChannel: () => {
13 | Toast.show({ content: '새로운 채널이 추가 되었습니다.' })
14 | },
15 | failAddChannel: (errorMessage: string) => {
16 | Toast.show({
17 | type: 'error',
18 | content: `채널 추가에 실패했습니다. ${errorMessage}`,
19 | })
20 | },
21 | emptyUrl: () => {
22 | Toast.show({ type: 'error', content: 'URL을 입력해주세요.' })
23 | },
24 | }
25 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/common/Skeleton/ChannelType.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import Skeleton from 'react-loading-skeleton'
3 |
4 | import Flex from '../Flex'
5 | import Anchor from '../Anchor'
6 | import { Container } from '../FeedItem/Channel/Channel.style'
7 |
8 | const ChannelType = () => {
9 | return (
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | )
23 | }
24 |
25 | export default ChannelType
26 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "feedoong-frontend",
3 | "version": "0.1.0",
4 | "private": true,
5 | "workspaces": [
6 | "apps/*"
7 | ],
8 | "scripts": {
9 | "dev:app": "yarn workspace feedoong-app dev",
10 | "build:app": "yarn workspace feedoong-app build"
11 | },
12 | "devDependencies": {
13 | "@types/node": "^20.14.8",
14 | "@typescript-eslint/eslint-plugin": "^6.0.0",
15 | "@typescript-eslint/parser": "^6.0.0",
16 | "eslint": "^8.57.0",
17 | "eslint-config-next": "^13.0.0",
18 | "eslint-config-prettier": "^8.8.0",
19 | "eslint-plugin-prettier": "5.0.0",
20 | "prettier": "^3.2.5",
21 | "typescript": "^5.5.2"
22 | },
23 | "packageManager": "yarn@4.3.1"
24 | }
25 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/common/PageContainer/index.tsx:
--------------------------------------------------------------------------------
1 | import type { ReactNode } from 'react'
2 | import styled from 'styled-components'
3 |
4 | interface Props {
5 | children: ReactNode
6 | padding?: string
7 | backgroundColor?: string
8 | }
9 |
10 | const PageContainer = ({ children }: Props) => {
11 | return {children}
12 | }
13 |
14 | export default PageContainer
15 |
16 | const Container = styled.div<{
17 | padding?: string
18 | backgroundColor?: string
19 | }>`
20 | width: 100%;
21 | min-height: calc(100dvh - 75px);
22 | padding: ${({ padding }) => padding || '60px 0 40px'};
23 | background-color: ${({ backgroundColor }) =>
24 | backgroundColor || 'var(--color-surface)'};
25 | `
26 |
--------------------------------------------------------------------------------
/apps/next-app/src/utils/hooks/useGoogleAnalytics.ts:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react'
2 | import { useRouter } from 'next/router'
3 |
4 | import * as gtag from '../gtag'
5 |
6 | export const useGoogleAnalytics = () => {
7 | const router = useRouter()
8 |
9 | useEffect(() => {
10 | const handleRouteChange = (url: string) => {
11 | gtag.pageview(url)
12 | }
13 | router.events.on('routeChangeComplete', handleRouteChange)
14 | router.events.on('hashChangeComplete', handleRouteChange)
15 | return () => {
16 | router.events.off('routeChangeComplete', handleRouteChange)
17 | router.events.off('hashChangeComplete', handleRouteChange)
18 | }
19 | }, [router.events])
20 |
21 | return null
22 | }
23 |
--------------------------------------------------------------------------------
/apps/next-app/src/services/types/_generated/status-controller.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Generated by orval v6.19.1 🍺
3 | * Do not edit manually.
4 | * Api Documentation
5 | * Api Documentation
6 | * OpenAPI spec version: 1.0
7 | */
8 | import type {
9 | CheckHealthUsingGET200
10 | } from './apiDocumentation.schemas'
11 | import { feedoongApi } from '../../api/index';
12 |
13 |
14 |
15 |
16 | /**
17 | * @summary checkHealth
18 | */
19 | export const checkHealthUsingGET = (
20 |
21 | ) => {
22 | return feedoongApi(
23 | {url: `/v1/status/health`, method: 'get'
24 | },
25 | );
26 | }
27 | export type CheckHealthUsingGETResult = NonNullable>>
28 |
--------------------------------------------------------------------------------
/apps/next-app/scripts/syncCodeGenSpec.mjs:
--------------------------------------------------------------------------------
1 | import fs from 'fs'
2 |
3 | const SPEC_URL = 'https://api.feedoong.io/v3/api-docs' // OpenAPI 스펙이 있는 URL
4 | const SPEC_WRITE_PATH = 'src/services/spec.json' // 스펙을 저장할 파일 경로
5 |
6 | console.log('API 스펙을 가져오는 중...')
7 |
8 | await fetch(SPEC_URL)
9 | .then((response) => response.json())
10 | .then((data) => {
11 | const jsonContent = JSON.stringify(data, null, 2) // 보기 좋게 포맷팅
12 | fs.writeFile(SPEC_WRITE_PATH, jsonContent, 'utf8', (err) => {
13 | if (err) {
14 | console.log('파일 저장 중 에러 발생:', err)
15 | } else {
16 | console.log('spec.json 파일이 성공적으로 저장되었습니다.')
17 | }
18 | })
19 | })
20 | .catch((error) => {
21 | console.log('API 요청 중 에러 발생:', error)
22 | })
23 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "noEmit": true,
10 | "esModuleInterop": true,
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve",
16 | "incremental": true,
17 | "baseUrl": "./",
18 | "paths": {
19 | "*": ["./apps/next-app/src/*"]
20 | }
21 | },
22 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
23 | "exclude": ["node_modules", "**/_generated/**/*"],
24 | "types": ["@types/gtag.js"]
25 | }
26 |
--------------------------------------------------------------------------------
/apps/next-app/src/assets/images/index.ts:
--------------------------------------------------------------------------------
1 | import IntroduceHeader from './introduce_header.webp'
2 | import Introduce1 from './introduce_1.webp'
3 | import Introduce2 from './introduce_2.webp'
4 | import Introduce3 from './introduce_3.webp'
5 | import FocusSticker from './focus.svg'
6 | import Fly2Sticker from './fly-2.svg'
7 | import HeartSticker from './heart.svg'
8 | import MobileSticker from './mobile.svg'
9 | import SurpriseSticker from './surprise.svg'
10 | import error from './error.svg'
11 |
12 | export const Images = {
13 | IntroduceHeader,
14 | Introduce1,
15 | Introduce2,
16 | Introduce3,
17 | FocusSticker,
18 | Fly2Sticker,
19 | HeartSticker,
20 | MobileSticker,
21 | SurpriseSticker,
22 | error,
23 | }
24 |
25 | export default Images
26 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/common/FeedItem/Post/Post.style.tsx:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components'
2 |
3 | import { getTypographyStyles, ellipsis } from 'styles/fonts'
4 |
5 | export const Container = styled.div`
6 | width: 100%;
7 | background-color: var(--color-surface-container-lowest);
8 | padding: 20px;
9 | display: flex;
10 | flex-direction: column;
11 | gap: 12px;
12 | border-radius: 20px;
13 | border-bottom-left-radius: 0px;
14 | `
15 |
16 | export const Title = styled.h2`
17 | ${getTypographyStyles('Headline3_B')}
18 | ${ellipsis(1)}
19 |
20 | color: var(--color-font-primary);
21 | `
22 |
23 | export const CardActions = styled.div`
24 | display: flex;
25 | align-items: center;
26 | gap: 12px;
27 | flex-shrink: 0;
28 | `
29 |
--------------------------------------------------------------------------------
/apps/next-app/src/assets/icons/right_arrow.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/common/LogoIcon/index.tsx:
--------------------------------------------------------------------------------
1 | import Icons from 'assets/icons'
2 |
3 | interface Props {
4 | src?: string
5 | diameter?: number
6 | style?: React.CSSProperties
7 | }
8 |
9 | const LogoIcon = ({ src, diameter, style }: Props) => {
10 | return (
11 |
25 | )
26 | }
27 |
28 | export default LogoIcon
29 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/common/EmptyContents/index.tsx:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components'
2 |
3 | import { getTypographyStyles } from 'styles/fonts'
4 |
5 | interface Props {
6 | content: React.ReactNode
7 | }
8 |
9 | const EmptyContents = ({ content }: Props) => {
10 | return (
11 |
12 | 🐽 {content}
13 |
14 | )
15 | }
16 |
17 | export default EmptyContents
18 |
19 | const Container = styled.div`
20 | ${getTypographyStyles('Headline3_M')}
21 |
22 | width: 100%;
23 | height: 100%;
24 | margin: 60px 0;
25 |
26 | display: flex;
27 | flex-direction: column;
28 | align-items: center;
29 | justify-content: center;
30 | gap: 12px;
31 |
32 | color: var(--color-gray-900);
33 | `
34 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/common/FeedItem/Popovers/icons.tsx:
--------------------------------------------------------------------------------
1 | import Image from 'next/image'
2 |
3 | import Icons from 'assets/icons'
4 |
5 | export const PopoverIcons = {
6 | 채널_상세: (
7 |
8 | ),
9 | 링크_복사: (
10 |
11 | ),
12 | 구독_해제: (
13 |
20 | ),
21 | 옵션_메뉴: (
22 |
30 | ),
31 | }
32 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/common/Tab/Tab.style.ts:
--------------------------------------------------------------------------------
1 | import styled, { css } from 'styled-components'
2 |
3 | import { getTypographyStyles } from 'styles/fonts'
4 |
5 | export const TabContainer = styled.div`
6 | display: flex;
7 | `
8 |
9 | export const Tab = styled.button<{ $isSelected: boolean }>`
10 | all: unset;
11 | cursor: pointer;
12 | padding: 8px 16px;
13 | line-height: 24px;
14 | border-radius: 50px;
15 | color: var(--color-gray-500);
16 | background-color: var(--color-gray-50);
17 |
18 | ${getTypographyStyles('Body1_M')};
19 |
20 | ${({ $isSelected }) =>
21 | $isSelected &&
22 | css`
23 | color: var(--color-white);
24 | background-color: var(--color-gray-900);
25 | ${getTypographyStyles('Body1_B')};
26 | `}
27 | `
28 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/views/UserPage/List/List.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import Flex from 'components/common/Flex'
4 | import EmptyContents from 'components/common/EmptyContents'
5 |
6 | import * as S from './List.style'
7 |
8 | export interface Props {
9 | renderList: () => React.ReactNode
10 | renderEmptyContent?: (emptyContent?: React.ReactNode) => React.ReactNode
11 | }
12 |
13 | const List = ({
14 | renderList,
15 | renderEmptyContent = () => ,
16 | }: Props) => {
17 | return (
18 |
19 |
20 | {renderList()}
21 |
22 | {renderEmptyContent()}
23 |
24 | )
25 | }
26 |
27 | export default List
28 |
--------------------------------------------------------------------------------
/apps/next-app/src/envs/index.ts:
--------------------------------------------------------------------------------
1 | export type AppEnv = 'production' | 'staging' | 'development'
2 |
3 | const getAppEnv = (): AppEnv =>
4 | (process.env.NEXT_PUBLIC_APP_ENV as Exclude) ||
5 | 'development'
6 |
7 | export const getApiEndpoint = () => {
8 | switch (getAppEnv()) {
9 | case 'production':
10 | case 'staging':
11 | case 'development':
12 | default:
13 | return 'https://api.feedoong.io'
14 | }
15 | }
16 |
17 | export const getDomainName = () => {
18 | switch (getAppEnv()) {
19 | case 'production':
20 | return 'https://feedoong.io'
21 | case 'staging':
22 | return 'https://dev.feedoong.io'
23 | case 'development':
24 | default:
25 | return 'http://localhost:3000'
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/apps/next-app/src/pages/feed/recommended/channels/index.tsx:
--------------------------------------------------------------------------------
1 | import type { NextPage } from 'next'
2 | import Head from 'next/head'
3 |
4 | import FeedsContainerView from 'components/views/Feeds/FeedsContainer'
5 | import RssInputView from 'components/views/RssInput'
6 | import { withPrefetchUser } from 'features/auth/withAuthQueryServerSideProps'
7 | import { useGetUserProfile } from 'features/user/userProfile'
8 |
9 | const Home: NextPage = () => {
10 | useGetUserProfile()
11 |
12 | return (
13 | <>
14 |
15 | 채널 둘러보기 | 인사이트가 피둥피둥
16 |
17 |
18 |
19 | >
20 | )
21 | }
22 |
23 | export default Home
24 |
25 | export const getServerSideProps = withPrefetchUser
26 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | build
17 | .next
18 |
19 | # misc
20 | .DS_Store
21 | *.pem
22 |
23 | # debug
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 | .pnpm-debug.log*
28 |
29 | # local env files
30 | .env*.local
31 |
32 | # vercel
33 | .vercel
34 |
35 | # typescript
36 | *.tsbuildinfo
37 | next-env.d.ts
38 |
39 | # webstorm config files
40 | .idea/
41 |
42 | #yarn
43 | .yarn/*
44 | .yarn/install-state.gz
45 | !.yarn/cache
46 | !.yarn/patches
47 | !.yarn/plugins
48 | !.yarn/releases
49 | !.yarn/sdks
50 | !.yarn/versions
51 |
--------------------------------------------------------------------------------
/apps/next-app/src/assets/icons/logo-desktop-no-background.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/apps/next-app/src/styles/mediaQuery.ts:
--------------------------------------------------------------------------------
1 | import type { CSSProp } from 'styled-components'
2 | import { css } from 'styled-components'
3 |
4 | export const breakpoints: {
5 | [key: string]: number
6 | } = {
7 | mobile: 0,
8 | mobileL: 425,
9 | tablet: 768,
10 | desktop: 1024,
11 | }
12 |
13 | export const mediaQuery = Object.keys(breakpoints).reduce(
14 | (acc, label) => {
15 | acc[label] = (
16 | literals: TemplateStringsArray,
17 | ...placeholders: any[]
18 | ) => css`
19 | @media only screen and (max-width: ${breakpoints[label]}px) {
20 | ${css(literals, ...placeholders)};
21 | }
22 | `
23 | return acc
24 | },
25 | {} as Record<
26 | keyof typeof breakpoints,
27 | (l: TemplateStringsArray, ...p: any[]) => CSSProp
28 | >
29 | )
30 |
--------------------------------------------------------------------------------
/apps/next-app/src/assets/icons/folder-mono.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/apps/next-app/src/app/(hasGNB)/feed/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { ReactNode } from 'react'
2 |
3 | import RssInput from 'components/views/RssInput'
4 | import * as S from 'components/views/Feeds/FeedsContainer.style'
5 | import FeedTab from 'shared/ui/FeedTab'
6 |
7 | interface Props {
8 | children: ReactNode
9 | }
10 |
11 | export const metadata = {
12 | title: '내 피드 | 인사이트가 피둥피둥',
13 | }
14 |
15 | const FeedLayout = ({ children }: Props) => {
16 | return (
17 | <>
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 | {children}
28 |
29 |
30 | >
31 | )
32 | }
33 |
34 | export default FeedLayout
35 |
--------------------------------------------------------------------------------
/apps/next-app/public/logo-desktop.svg:
--------------------------------------------------------------------------------
1 |
7 |
--------------------------------------------------------------------------------
/apps/next-app/src/app/introduce/_components/IntroduceHeaderBanner.tsx:
--------------------------------------------------------------------------------
1 | import * as S from '../Introduce.style'
2 | import FeedoongSticker from './FeedoongSticker'
3 |
4 | import Images from 'assets/images'
5 |
6 | const IntroduceHeaderBanner = () => {
7 | return (
8 |
9 |
17 |
25 |
26 |
27 | )
28 | }
29 |
30 | export default IntroduceHeaderBanner
31 |
--------------------------------------------------------------------------------
/apps/next-app/src/assets/icons/logo-desktop.svg:
--------------------------------------------------------------------------------
1 |
7 |
--------------------------------------------------------------------------------
/apps/next-app/src/pages/feed/recommended/posts/index.tsx:
--------------------------------------------------------------------------------
1 | import type { NextPage } from 'next'
2 | import Head from 'next/head'
3 |
4 | import RssInputView from 'components/views/RssInput'
5 | import FeedsContainerView from 'components/views/Feeds/FeedsContainer'
6 | import {
7 | withAuthQueryServerSideProps,
8 | withRequestContext,
9 | } from 'features/auth/withAuthQueryServerSideProps'
10 | import { useGetUserProfile } from 'features/user/userProfile'
11 |
12 | const Home: NextPage = () => {
13 | useGetUserProfile()
14 |
15 | return (
16 | <>
17 |
18 | 게시물 둘러보기 | 인사이트가 피둥피둥
19 |
20 |
21 |
22 | >
23 | )
24 | }
25 |
26 | export default Home
27 |
28 | export const getServerSideProps = withRequestContext(
29 | withAuthQueryServerSideProps()
30 | )
31 |
--------------------------------------------------------------------------------
/apps/next-app/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "noEmit": true,
10 | "esModuleInterop": true,
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve",
16 | "incremental": true,
17 | "baseUrl": "./",
18 | "paths": {
19 | "*": ["./src/*"]
20 | },
21 | "plugins": [
22 | {
23 | "name": "next"
24 | }
25 | ]
26 | },
27 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
28 | "exclude": ["node_modules", "**/_generated/**/*"],
29 | "types": ["@types/gtag.js"]
30 | }
31 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/common/Paging/Page.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import Image from 'next/legacy/image'
3 |
4 | import * as S from './Paging.style'
5 |
6 | interface Props {
7 | image?: string
8 | isActive?: boolean
9 | disabled?: boolean
10 | pageText?: string
11 | pageNumber: number
12 | onClick: (pageNumber: number) => void
13 | }
14 | const Page = ({ isActive, pageNumber, pageText, onClick, image }: Props) => {
15 | const renderTextOrImage = () => {
16 | if (image) {
17 | return
18 | } else {
19 | return pageText
20 | }
21 | }
22 |
23 | return (
24 | onClick(pageNumber)}
28 | >
29 | {renderTextOrImage()}
30 |
31 | )
32 | }
33 |
34 | export default Page
35 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/common/Paging/Paging.style.tsx:
--------------------------------------------------------------------------------
1 | import styled, { css } from 'styled-components'
2 |
3 | import { getTypographyStyles } from 'styles/fonts'
4 |
5 | export const Page = styled.div<{ $isActive?: boolean; $isImage: boolean }>`
6 | ${getTypographyStyles('Body1_M')}
7 | width: 28px;
8 | height: 28px;
9 | border-radius: 50%;
10 | color: var(--color-white-fixed);
11 | background-color: var(--color-gray-500);
12 | display: flex;
13 | align-items: center;
14 | justify-content: center;
15 | cursor: pointer;
16 |
17 | ${({ $isActive }) =>
18 | $isActive &&
19 | css`
20 | ${getTypographyStyles('Body1_B')}
21 | color: var(--color-white-fixed);
22 | background-color: var(--color-primary-500);
23 | `}
24 |
25 | ${({ $isImage }) =>
26 | $isImage &&
27 | css`
28 | background: none;
29 | `}
30 | `
31 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/common/ToastIcon/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | interface Props {
4 | color?: string
5 | }
6 |
7 | const ToastIcon = ({ color }: Props) => {
8 | return (
9 |
21 | )
22 | }
23 |
24 | export default ToastIcon
25 |
--------------------------------------------------------------------------------
/apps/next-app/src/services/types/_generated/like.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Generated by orval v6.19.1 🍺
3 | * Do not edit manually.
4 | * Api Documentation
5 | * Api Documentation
6 | * OpenAPI spec version: 1.0
7 | */
8 | import type {
9 | LikeResponse
10 | } from './apiDocumentation.schemas'
11 | import { feedoongApi } from '../../api/index';
12 |
13 |
14 |
15 |
16 | /**
17 | * @summary 아이템 보관(좋아요)
18 | */
19 | export const likeUsingPOST = (
20 | itemId: number,
21 | ) => {
22 | return feedoongApi(
23 | {url: `/v1/likes/${itemId}`, method: 'post'
24 | },
25 | );
26 | }
27 | /**
28 | * @summary 아이템 보관(좋아요) 취소
29 | */
30 | export const unlikeUsingDELETE = (
31 | itemId: number,
32 | ) => {
33 | return feedoongApi(
34 | {url: `/v1/likes/${itemId}`, method: 'delete'
35 | },
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/common/ColorModeScript/index.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | /**
4 | *
5 | * @see useColorMode의 내부 로직을 참고한 스크립트
6 | *
7 | */
8 | const ColorModeScript = () => {
9 | const themeInitializerScript = `
10 | (function () {
11 | const preferredColorMode = localStorage.getItem('colorMode')
12 | const systemColorMode = window.matchMedia('(prefers-color-scheme: dark)')
13 | .matches
14 | ? 'dark'
15 | : 'light'
16 | const colorMode = preferredColorMode ?? systemColorMode
17 |
18 | document.documentElement.className = colorMode
19 | return
20 | })();
21 | `
22 | return (
23 |
29 | )
30 | }
31 |
32 | export default ColorModeScript
33 |
--------------------------------------------------------------------------------
/.github/auto_assign_config.yml:
--------------------------------------------------------------------------------
1 | # Set to true to add reviewers to pull requests
2 | addReviewers: true
3 |
4 | # Set to true to add assignees to pull requests
5 | addAssignees: author
6 |
7 | # A list of reviewers to be added to pull requests (GitHub user name)
8 | reviewers:
9 | - saengmotmi
10 | - eunsonny
11 |
12 | # A number of reviewers added to the pull request
13 | # Set 0 to add all the reviewers (default: 0)
14 | numberOfReviewers: 2
15 | # A list of assignees, overrides reviewers if set
16 | # assignees:
17 | # - assigneeA
18 |
19 | useReviewGroups: false
20 | useAssigneeGroups: false
21 | # A number of assignees to add to the pull request
22 | # Set to 0 to add all of the assignees.
23 | # Uses numberOfReviewers if unset.
24 | # numberOfAssignees: 2
25 |
26 | # A list of keywords to be skipped the process that add reviewers if pull requests include it
27 | # skipKeywords:
28 | # - wip
29 |
--------------------------------------------------------------------------------
/apps/next-app/src/shared/utils/mutex.ts:
--------------------------------------------------------------------------------
1 | class Mutex {
2 | private promise: Promise | null = null
3 | private resolve: VoidFunction | null = null
4 |
5 | async lock() {
6 | if (!this.promise) {
7 | // 잠금을 설정하고, 다음 해제를 대기하도록 Promise를 생성합니다.
8 | this.promise = new Promise((res) => {
9 | this.resolve = res
10 | })
11 | return Promise.resolve()
12 | }
13 | return this.promise
14 | }
15 |
16 | unlock() {
17 | if (this.resolve) {
18 | this.resolve() // 잠금 해제
19 | this.promise = null
20 | this.resolve = null
21 | }
22 | }
23 |
24 | get isLocked() {
25 | return this.promise !== null
26 | }
27 |
28 | async runExclusive(callback: () => Promise) {
29 | await this.lock()
30 | try {
31 | return await callback()
32 | } finally {
33 | this.unlock()
34 | }
35 | }
36 | }
37 |
38 | export default Mutex
39 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/common/Layout/Nav/Profile.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import { useSuspenseQuery } from '@tanstack/react-query'
3 |
4 | import { userQueries } from 'entities/user/api'
5 | import ProfilePopover from './ProfilePopover'
6 |
7 | import * as S from './Nav.style'
8 |
9 | export const Profile = () => {
10 | const { data: profile } = useSuspenseQuery({
11 | ...userQueries.me(),
12 | retry: false,
13 | meta: { ignoreToast: true },
14 | })
15 |
16 | return (
17 |
18 |
19 | {`${profile.name}님, 안녕하세요!`}
20 | {profile.profileImageUrl && (
21 |
28 | )}
29 |
30 |
31 | )
32 | }
33 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/views/RssInput/hooks/useControlled.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useRef, useState } from 'react'
2 |
3 | interface Props {
4 | controlled: T
5 | default?: T
6 | }
7 |
8 | const useControlled = ({
9 | controlled,
10 | default: defaultProp,
11 | }: Props): [T | undefined, (newValue: T) => void] => {
12 | // isControlled is ignored in the hook dependency lists as it should never change.
13 | const { current: isControlled } = useRef(controlled !== undefined)
14 | const [valueState, setValue] = useState(defaultProp)
15 | const value = isControlled !== undefined ? controlled : valueState
16 |
17 | const setValueIfUncontrolled = useCallback((newValue: T) => {
18 | if (!isControlled) {
19 | setValue(newValue)
20 | }
21 | // eslint-disable-next-line react-hooks/exhaustive-deps
22 | }, [])
23 |
24 | return [value, setValueIfUncontrolled]
25 | }
26 |
27 | export default useControlled
28 |
--------------------------------------------------------------------------------
/.yarn/sdks/eslint/lib/api.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | const {existsSync} = require(`fs`);
4 | const {createRequire, register} = require(`module`);
5 | const {resolve} = require(`path`);
6 | const {pathToFileURL} = require(`url`);
7 |
8 | const relPnpApiPath = "../../../../.pnp.cjs";
9 |
10 | const absPnpApiPath = resolve(__dirname, relPnpApiPath);
11 | const absRequire = createRequire(absPnpApiPath);
12 |
13 | const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`);
14 | const isPnpLoaderEnabled = existsSync(absPnpLoaderPath);
15 |
16 | if (existsSync(absPnpApiPath)) {
17 | if (!process.versions.pnp) {
18 | // Setup the environment to be able to require eslint
19 | require(absPnpApiPath).setup();
20 | if (isPnpLoaderEnabled && register) {
21 | register(pathToFileURL(absPnpLoaderPath));
22 | }
23 | }
24 | }
25 |
26 | // Defer to the real eslint your application uses
27 | module.exports = absRequire(`eslint`);
28 |
--------------------------------------------------------------------------------
/.yarn/sdks/prettier/index.cjs:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | const {existsSync} = require(`fs`);
4 | const {createRequire, register} = require(`module`);
5 | const {resolve} = require(`path`);
6 | const {pathToFileURL} = require(`url`);
7 |
8 | const relPnpApiPath = "../../../.pnp.cjs";
9 |
10 | const absPnpApiPath = resolve(__dirname, relPnpApiPath);
11 | const absRequire = createRequire(absPnpApiPath);
12 |
13 | const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`);
14 | const isPnpLoaderEnabled = existsSync(absPnpLoaderPath);
15 |
16 | if (existsSync(absPnpApiPath)) {
17 | if (!process.versions.pnp) {
18 | // Setup the environment to be able to require prettier
19 | require(absPnpApiPath).setup();
20 | if (isPnpLoaderEnabled && register) {
21 | register(pathToFileURL(absPnpLoaderPath));
22 | }
23 | }
24 | }
25 |
26 | // Defer to the real prettier your application uses
27 | module.exports = absRequire(`prettier`);
28 |
--------------------------------------------------------------------------------
/apps/next-app/src/app/introduce/_components/FeedoongSticker.tsx:
--------------------------------------------------------------------------------
1 | import type { CSSProperties } from 'styled-components'
2 |
3 | import Images from 'assets/images'
4 |
5 | const FeedoongSticker = {
6 | Heart: ({ style }: { style: CSSProperties }) => {
7 | return
8 | },
9 | Fly: ({ style }: { style: CSSProperties }) => {
10 | return
11 | },
12 | Focus: ({ style }: { style: CSSProperties }) => {
13 | return
14 | },
15 | Mobile: ({ style }: { style: CSSProperties }) => {
16 | return
17 | },
18 | Surprise: ({ style }: { style: CSSProperties }) => {
19 | return
20 | },
21 | }
22 |
23 | export default FeedoongSticker
24 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/common/Divider/index.tsx:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components'
2 |
3 | import type { ColorModeColorKey } from 'types/colorMode'
4 |
5 | interface Props {
6 | color?: ColorModeColorKey
7 | thickness?: number
8 | mt?: number
9 | mb?: number
10 | }
11 |
12 | const Divider = ({ thickness = 1, mt, mb, color }: Props) => {
13 | return (
14 |
20 | )
21 | }
22 |
23 | const Container = styled.div<{
24 | color: string
25 | thickness: number
26 | $marginTop: number
27 | $marginBottom: number
28 | }>`
29 | border-bottom: ${({ thickness, color }) => `${thickness}px solid ${color}`};
30 | margin-top: ${({ $marginTop }) => $marginTop}px;
31 | margin-bottom: ${({ $marginBottom }) => $marginBottom}px;
32 | `
33 |
34 | export default Divider
35 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/common/FeedItem/Channel/Channel.style.tsx:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components'
2 |
3 | import { getTypographyStyles, ellipsis } from 'styles/fonts'
4 | import Button from '../../Button'
5 |
6 | export const Container = styled.div`
7 | background-color: var(--color-surface-container-lowest);
8 | padding: 20px;
9 | display: flex;
10 | flex-direction: column;
11 | gap: 3px;
12 | border-radius: 20px;
13 | border-bottom-left-radius: 0px;
14 | width: 100%;
15 | `
16 |
17 | export const Title = styled.h2`
18 | ${getTypographyStyles('Headline3_B')}
19 | ${ellipsis(1)}
20 |
21 | max-width: 526px;
22 | color: var(--color-font-primary);
23 | `
24 |
25 | export const Url = styled.span`
26 | ${getTypographyStyles('Body2_M')}
27 | ${ellipsis(1)}
28 | color: var(--color-font-secondary);
29 | word-break: break-all;
30 | `
31 |
32 | export const AddButton = styled(Button)`
33 | all: unset;
34 | cursor: pointer;
35 | `
36 |
--------------------------------------------------------------------------------
/.yarn/sdks/typescript/lib/typescript.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | const {existsSync} = require(`fs`);
4 | const {createRequire, register} = require(`module`);
5 | const {resolve} = require(`path`);
6 | const {pathToFileURL} = require(`url`);
7 |
8 | const relPnpApiPath = "../../../../.pnp.cjs";
9 |
10 | const absPnpApiPath = resolve(__dirname, relPnpApiPath);
11 | const absRequire = createRequire(absPnpApiPath);
12 |
13 | const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`);
14 | const isPnpLoaderEnabled = existsSync(absPnpLoaderPath);
15 |
16 | if (existsSync(absPnpApiPath)) {
17 | if (!process.versions.pnp) {
18 | // Setup the environment to be able to require typescript
19 | require(absPnpApiPath).setup();
20 | if (isPnpLoaderEnabled && register) {
21 | register(pathToFileURL(absPnpLoaderPath));
22 | }
23 | }
24 | }
25 |
26 | // Defer to the real typescript your application uses
27 | module.exports = absRequire(`typescript`);
28 |
--------------------------------------------------------------------------------
/apps/next-app/src/assets/icons/star.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/apps/next-app/src/utils/auth.ts:
--------------------------------------------------------------------------------
1 | import Cookies from 'js-cookie'
2 | import type { NextRequest } from 'next/server'
3 |
4 | import { AccessToken, RefreshToken } from 'constants/auth'
5 |
6 | export const isLoginValidServerSide = (request: NextRequest) => {
7 | const accessToken = request.cookies.get(AccessToken)
8 | const refreshToken = request.cookies.get(RefreshToken)
9 |
10 | // Check if the access token is valid
11 | if (accessToken) {
12 | // TODO: Check if the access token is still valid
13 | return true
14 | }
15 |
16 | // Check if the refresh token is valid
17 | if (refreshToken) {
18 | // TODO: Use the refresh token to get a new access token
19 | return true
20 | }
21 |
22 | // If neither token is valid, the login is not valid
23 | return false
24 | }
25 |
26 | export const destroyTokensClientSide = () => {
27 | Cookies.remove(RefreshToken)
28 | Cookies.remove(AccessToken)
29 |
30 | // TODO: Invalidate the tokens on the server
31 | }
32 |
--------------------------------------------------------------------------------
/.yarn/sdks/typescript/bin/tsc:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | const {existsSync} = require(`fs`);
4 | const {createRequire, register} = require(`module`);
5 | const {resolve} = require(`path`);
6 | const {pathToFileURL} = require(`url`);
7 |
8 | const relPnpApiPath = "../../../../.pnp.cjs";
9 |
10 | const absPnpApiPath = resolve(__dirname, relPnpApiPath);
11 | const absRequire = createRequire(absPnpApiPath);
12 |
13 | const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`);
14 | const isPnpLoaderEnabled = existsSync(absPnpLoaderPath);
15 |
16 | if (existsSync(absPnpApiPath)) {
17 | if (!process.versions.pnp) {
18 | // Setup the environment to be able to require typescript/bin/tsc
19 | require(absPnpApiPath).setup();
20 | if (isPnpLoaderEnabled && register) {
21 | register(pathToFileURL(absPnpLoaderPath));
22 | }
23 | }
24 | }
25 |
26 | // Defer to the real typescript/bin/tsc your application uses
27 | module.exports = absRequire(`typescript/bin/tsc`);
28 |
--------------------------------------------------------------------------------
/.yarn/sdks/eslint/bin/eslint.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | const {existsSync} = require(`fs`);
4 | const {createRequire, register} = require(`module`);
5 | const {resolve} = require(`path`);
6 | const {pathToFileURL} = require(`url`);
7 |
8 | const relPnpApiPath = "../../../../.pnp.cjs";
9 |
10 | const absPnpApiPath = resolve(__dirname, relPnpApiPath);
11 | const absRequire = createRequire(absPnpApiPath);
12 |
13 | const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`);
14 | const isPnpLoaderEnabled = existsSync(absPnpLoaderPath);
15 |
16 | if (existsSync(absPnpApiPath)) {
17 | if (!process.versions.pnp) {
18 | // Setup the environment to be able to require eslint/bin/eslint.js
19 | require(absPnpApiPath).setup();
20 | if (isPnpLoaderEnabled && register) {
21 | register(pathToFileURL(absPnpLoaderPath));
22 | }
23 | }
24 | }
25 |
26 | // Defer to the real eslint/bin/eslint.js your application uses
27 | module.exports = absRequire(`eslint/bin/eslint.js`);
28 |
--------------------------------------------------------------------------------
/.yarn/sdks/typescript/lib/tsc.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | const {existsSync} = require(`fs`);
4 | const {createRequire, register} = require(`module`);
5 | const {resolve} = require(`path`);
6 | const {pathToFileURL} = require(`url`);
7 |
8 | const relPnpApiPath = "../../../../.pnp.cjs";
9 |
10 | const absPnpApiPath = resolve(__dirname, relPnpApiPath);
11 | const absRequire = createRequire(absPnpApiPath);
12 |
13 | const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`);
14 | const isPnpLoaderEnabled = existsSync(absPnpLoaderPath);
15 |
16 | if (existsSync(absPnpApiPath)) {
17 | if (!process.versions.pnp) {
18 | // Setup the environment to be able to require typescript/lib/tsc.js
19 | require(absPnpApiPath).setup();
20 | if (isPnpLoaderEnabled && register) {
21 | register(pathToFileURL(absPnpLoaderPath));
22 | }
23 | }
24 | }
25 |
26 | // Defer to the real typescript/lib/tsc.js your application uses
27 | module.exports = absRequire(`typescript/lib/tsc.js`);
28 |
--------------------------------------------------------------------------------
/apps/next-app/src/app/providers.tsx:
--------------------------------------------------------------------------------
1 | /** @see https://tanstack.com/query/latest/docs/framework/react/guides/advanced-ssr */
2 |
3 | // In Next.js, this file would be called: app/providers.jsx
4 | 'use client'
5 |
6 | import { QueryClientProvider } from '@tanstack/react-query'
7 | import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
8 |
9 | import { getQueryClient } from 'core/getQueryClient'
10 |
11 | export default function Providers({ children }: React.PropsWithChildren) {
12 | // NOTE: Avoid useState when initializing the query client if you don't
13 | // have a suspense boundary between this and the code that may
14 | // suspend because React will throw away the client on the initial
15 | // render if it suspends and there is no boundary
16 | const queryClient = getQueryClient()
17 |
18 | return (
19 |
20 | {children}
21 |
22 |
23 | )
24 | }
25 |
--------------------------------------------------------------------------------
/apps/next-app/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | eslint: {
4 | ignoreDuringBuilds: true,
5 | },
6 | reactStrictMode: true,
7 | compiler: {
8 | styledComponents: true,
9 | },
10 | images: {
11 | remotePatterns: [
12 | {
13 | hostname:'lh3.googleusercontent.com'
14 | },
15 | ]
16 | },
17 | redirects: async () => {
18 | return [
19 | {
20 | source: '/',
21 | destination: '/feed/me',
22 | permanent: true,
23 | },
24 | {
25 | source: '/feed',
26 | destination: '/feed/me',
27 | permanent: true,
28 | },
29 | {
30 | source: '/feed/recommended',
31 | destination: '/feed/recommended/channels',
32 | permanent: true,
33 | },
34 | {
35 | source: '/mypage',
36 | destination: '/404',
37 | permanent: true,
38 | },
39 | ]
40 | },
41 | }
42 |
43 | module.exports = nextConfig
44 |
--------------------------------------------------------------------------------
/apps/next-app/src/app/introduce/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import IntroduceHeaderBanner from 'app/introduce/_components/IntroduceHeaderBanner'
3 | import IntroduceHeaderTitle from 'app/introduce/_components/IntroduceHeaderTitle'
4 | import IntroduceFeature1 from 'app/introduce/_components/IntroduceFeature1'
5 | import IntroduceFeature2 from 'app/introduce/_components/IntroduceFeature2'
6 | import IntroduceFeature3 from 'app/introduce/_components/IntroduceFeature3'
7 | import StartFeedoongWithGoogle from 'app/introduce/_components/StartFeedoongWithGoogle'
8 |
9 | import * as S from './Introduce.style'
10 |
11 | const IntroducePage = () => {
12 | return (
13 | <>
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | >
23 | )
24 | }
25 |
26 | export default IntroducePage
27 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/common/Tab/Tab.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import * as S from './Tab.style'
4 |
5 | export type TabItem = { label: string; value: string }
6 |
7 | interface Props {
8 | tabData: readonly TabItem[]
9 | selectedTab: TabItem
10 | onClick: (tab: TabItem) => void
11 | }
12 | const Tab = ({ tabData, selectedTab, onClick }: Props) => {
13 | return (
14 |
15 | {tabData.map((item) => {
16 | return (
17 | onClick(item)}
21 | >
22 | {item.label}
23 |
24 | )
25 | })}
26 |
27 | )
28 | }
29 |
30 | export default Tab
31 |
32 | export const getSelectedTab = (
33 | tabData: TabItem[],
34 | currentTab?: TabItem['value']
35 | ) => {
36 | return tabData.find((tab) => tab.value === currentTab) || tabData[0]
37 | }
38 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/common/Toast/Toast.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import Flex from '../Flex'
4 | import { Z_INDEX } from 'styles/constants'
5 |
6 | import * as S from './Toast.style'
7 |
8 | import Icons from 'assets/icons'
9 |
10 | export type ToastProps = {
11 | type?: 'basic' | 'error'
12 | content: string
13 | duration?: number
14 | position?: 'bottom' | 'top'
15 | afterClose?: () => void
16 | }
17 |
18 | export const ToastElement = ({
19 | type = 'basic',
20 | content,
21 | duration = 2000,
22 | position = 'bottom',
23 | }: ToastProps) => {
24 | return (
25 |
26 |
27 |
32 | {content}
33 |
34 |
35 | )
36 | }
37 |
--------------------------------------------------------------------------------
/.yarn/sdks/typescript/bin/tsserver:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | const {existsSync} = require(`fs`);
4 | const {createRequire, register} = require(`module`);
5 | const {resolve} = require(`path`);
6 | const {pathToFileURL} = require(`url`);
7 |
8 | const relPnpApiPath = "../../../../.pnp.cjs";
9 |
10 | const absPnpApiPath = resolve(__dirname, relPnpApiPath);
11 | const absRequire = createRequire(absPnpApiPath);
12 |
13 | const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`);
14 | const isPnpLoaderEnabled = existsSync(absPnpLoaderPath);
15 |
16 | if (existsSync(absPnpApiPath)) {
17 | if (!process.versions.pnp) {
18 | // Setup the environment to be able to require typescript/bin/tsserver
19 | require(absPnpApiPath).setup();
20 | if (isPnpLoaderEnabled && register) {
21 | register(pathToFileURL(absPnpLoaderPath));
22 | }
23 | }
24 | }
25 |
26 | // Defer to the real typescript/bin/tsserver your application uses
27 | module.exports = absRequire(`typescript/bin/tsserver`);
28 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/common/Dialog/Dialog.tsx:
--------------------------------------------------------------------------------
1 | import React, { type ReactNode, forwardRef } from 'react'
2 |
3 | import Portal from 'components/common/Portal'
4 | import { DialogTitle } from './DialogTitle'
5 | import { DialogContent } from './DialogContent'
6 | import { DialogActions } from './DialogActions'
7 |
8 | import * as S from './Dialog.style'
9 |
10 | interface Props {
11 | isOpen: boolean
12 | children: ReactNode
13 | width?: string
14 | }
15 |
16 | const Dialog = forwardRef(function Dialog(
17 | { isOpen, children, width },
18 | ref
19 | ) {
20 | return isOpen ? (
21 |
22 |
23 |
24 | {children}
25 |
26 |
27 |
28 | ) : null
29 | })
30 |
31 | export default Object.assign(Dialog, {
32 | Title: DialogTitle,
33 | Content: DialogContent,
34 | Actions: DialogActions,
35 | })
36 |
--------------------------------------------------------------------------------
/apps/next-app/src/services/types/_generated/subscription.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Generated by orval v6.19.1 🍺
3 | * Do not edit manually.
4 | * Api Documentation
5 | * Api Documentation
6 | * OpenAPI spec version: 1.0
7 | */
8 | import type {
9 | GetSubscriptionsUsingGETParams,
10 | SubscriptionListResponse
11 | } from './apiDocumentation.schemas'
12 | import { feedoongApi } from '../../api/index';
13 |
14 |
15 |
16 |
17 | /**
18 | * @summary 자신이 구독한 채널 리스트 확인
19 | */
20 | export const getSubscriptionsUsingGET = (
21 | params: GetSubscriptionsUsingGETParams,
22 | ) => {
23 | return feedoongApi(
24 | {url: `/v1/subscriptions`, method: 'get',
25 | params
26 | },
27 | );
28 | }
29 | /**
30 | * @summary 채널 구독 취소
31 | */
32 | export const unsubscribeUsingDELETE = (
33 | channelId: number,
34 | ) => {
35 | return feedoongApi(
36 | {url: `/v1/subscriptions/${channelId}`, method: 'delete'
37 | },
38 | );
39 | }
40 |
--------------------------------------------------------------------------------
/.yarn/sdks/prettier/bin/prettier.cjs:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | const {existsSync} = require(`fs`);
4 | const {createRequire, register} = require(`module`);
5 | const {resolve} = require(`path`);
6 | const {pathToFileURL} = require(`url`);
7 |
8 | const relPnpApiPath = "../../../../.pnp.cjs";
9 |
10 | const absPnpApiPath = resolve(__dirname, relPnpApiPath);
11 | const absRequire = createRequire(absPnpApiPath);
12 |
13 | const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`);
14 | const isPnpLoaderEnabled = existsSync(absPnpLoaderPath);
15 |
16 | if (existsSync(absPnpApiPath)) {
17 | if (!process.versions.pnp) {
18 | // Setup the environment to be able to require prettier/bin/prettier.cjs
19 | require(absPnpApiPath).setup();
20 | if (isPnpLoaderEnabled && register) {
21 | register(pathToFileURL(absPnpLoaderPath));
22 | }
23 | }
24 | }
25 |
26 | // Defer to the real prettier/bin/prettier.cjs your application uses
27 | module.exports = absRequire(`prettier/bin/prettier.cjs`);
28 |
--------------------------------------------------------------------------------
/.yarn/sdks/eslint/lib/unsupported-api.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | const {existsSync} = require(`fs`);
4 | const {createRequire, register} = require(`module`);
5 | const {resolve} = require(`path`);
6 | const {pathToFileURL} = require(`url`);
7 |
8 | const relPnpApiPath = "../../../../.pnp.cjs";
9 |
10 | const absPnpApiPath = resolve(__dirname, relPnpApiPath);
11 | const absRequire = createRequire(absPnpApiPath);
12 |
13 | const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`);
14 | const isPnpLoaderEnabled = existsSync(absPnpLoaderPath);
15 |
16 | if (existsSync(absPnpApiPath)) {
17 | if (!process.versions.pnp) {
18 | // Setup the environment to be able to require eslint/use-at-your-own-risk
19 | require(absPnpApiPath).setup();
20 | if (isPnpLoaderEnabled && register) {
21 | register(pathToFileURL(absPnpLoaderPath));
22 | }
23 | }
24 | }
25 |
26 | // Defer to the real eslint/use-at-your-own-risk your application uses
27 | module.exports = absRequire(`eslint/use-at-your-own-risk`);
28 |
--------------------------------------------------------------------------------
/apps/next-app/src/assets/icons/add-mono.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/common/Popover/PopoverItem.tsx:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components'
2 |
3 | import { getTypographyStyles } from 'styles/fonts'
4 |
5 | interface Props extends React.HTMLAttributes {
6 | icon: React.ReactNode
7 | children: React.ReactNode
8 | color?: string
9 | }
10 |
11 | const PopoverItem = ({ color, icon, children, ...props }: Props) => {
12 | return (
13 |
14 | {icon}
15 | {children}
16 |
17 | )
18 | }
19 |
20 | export default PopoverItem
21 |
22 | const Container = styled.li`
23 | padding: 12px 16px;
24 | display: flex;
25 | align-items: center;
26 | gap: 8px;
27 | cursor: pointer;
28 |
29 | :hover {
30 | background: var(--color-gray-100);
31 | }
32 | `
33 |
34 | const Text = styled.span<{
35 | color?: string
36 | }>`
37 | ${getTypographyStyles('Headline3_M')};
38 | white-space: nowrap;
39 | color: ${({ color }) => color ?? 'var(--color-font-primary)'};
40 | `
41 |
--------------------------------------------------------------------------------
/apps/next-app/src/assets/icons/google_icon.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/views/Error/500/500Container.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import Image from 'next/image'
3 | import { useRouter } from 'next/router'
4 |
5 | import Button from 'components/common/Button'
6 | import * as S from '../error.style'
7 |
8 | import Images from 'assets/images'
9 |
10 | const Custom500Container = () => {
11 | const router = useRouter()
12 | return (
13 |
19 |
25 |
26 | 페이지를 표시할 수 없습니다.
27 |
30 |
31 |
32 | )
33 | }
34 |
35 | export default Custom500Container
36 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/views/Feeds/FeedsContainer.style.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import styled from 'styled-components'
3 |
4 | import { mediaQuery } from 'styles/mediaQuery'
5 |
6 | export const Container = styled.div`
7 | display: flex;
8 | align-items: center;
9 | flex-direction: column;
10 | min-height: calc(100dvh - 203px);
11 | background-color: var(--color-surface);
12 | `
13 |
14 | export const FeedWrapper = styled.div`
15 | margin: 40px 20px 60px;
16 | padding: 0 12px;
17 | max-width: 650px;
18 | width: 100%;
19 | border-radius: 4px;
20 |
21 | ${mediaQuery.tablet`
22 | padding: 0px 20px;
23 | `}
24 | `
25 |
26 | export const Header = styled.div`
27 | display: flex;
28 | justify-content: space-between;
29 | margin-bottom: 20px;
30 | width: 100%;
31 | `
32 |
33 | export const TitleWrapper = styled.div`
34 | display: flex;
35 | gap: 20px;
36 | width: 100%;
37 | `
38 |
39 | export const CardContainer = styled.ul`
40 | display: flex;
41 | gap: 20px;
42 | flex-direction: column;
43 | `
44 |
--------------------------------------------------------------------------------
/apps/next-app/src/assets/icons/notification-icon.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/views/Feeds/Recommended/RecommendedPosts.tsx:
--------------------------------------------------------------------------------
1 | import { useQuery } from '@tanstack/react-query'
2 |
3 | import FeedItem from 'components/common/FeedItem'
4 | import { SkeletonPostType } from 'components/common/Skeleton'
5 | import { CACHE_KEYS } from 'services/cacheKeys'
6 | import { getRecommendedItemsUsingGET } from 'services/types/_generated/item'
7 | import * as S from '../FeedsContainer.style'
8 |
9 | const RecommendedPosts = () => {
10 | const { data, isFetching } = useQuery({
11 | queryKey: CACHE_KEYS.recommended(['posts']),
12 | queryFn: getRecommendedItemsUsingGET,
13 | })
14 |
15 | const showSkeleton = isFetching && !data
16 |
17 | return (
18 |
19 | {showSkeleton &&
20 | Array.from({ length: 10 }).map((_, idx) => {
21 | return
22 | })}
23 | {data?.items.map((item) => (
24 |
25 | ))}
26 |
27 | )
28 | }
29 |
30 | export default RecommendedPosts
31 |
--------------------------------------------------------------------------------
/apps/next-app/src/app/introduce/_components/StartFeedoongWithGoogle.tsx:
--------------------------------------------------------------------------------
1 | import { useRouter } from 'next/navigation'
2 |
3 | import Button from 'components/common/Button/Button'
4 | import Flex from 'components/common/Flex'
5 | import { ROUTE } from 'constants/route'
6 | import * as S from '../Introduce.style'
7 |
8 | const StartFeedoongWithGoogle = () => {
9 | const router = useRouter()
10 |
11 | return (
12 |
18 |
19 | 무료로 손쉽게 만드는
20 |
21 | 나만의 인사이트 피드
22 |
23 |
30 |
31 | 여기저기 즐겨찾기 해둔 링크들을 피둥에 추가해보세요
32 |
33 |
34 | )
35 | }
36 |
37 | export default StartFeedoongWithGoogle
38 |
--------------------------------------------------------------------------------
/apps/next-app/src/features/user/userProfile.ts:
--------------------------------------------------------------------------------
1 | import { useQuery, type UseQueryOptions } from '@tanstack/react-query'
2 | import { useRouter } from 'next/router'
3 |
4 | import { userQueries } from 'entities/user/api'
5 | import { CACHE_KEYS } from 'services/cacheKeys'
6 | import type { PublicUserInfoResponse } from 'services/types/_generated/apiDocumentation.schemas'
7 | import { getPublicUserInfoUsingGET } from 'services/types/_generated/user'
8 |
9 | export const useGetUserProfile = () => {
10 | return useQuery(userQueries.me())
11 | }
12 |
13 | export const useGetUserProfileByUsername = (
14 | username: string,
15 | options: Omit, 'queryKey'> = {}
16 | ) => {
17 | return useQuery({
18 | queryKey: [CACHE_KEYS.user, username],
19 | queryFn: () => getPublicUserInfoUsingGET(username),
20 | ...options,
21 | enabled: !!username,
22 | })
23 | }
24 |
25 | export const useGetUsernameFromPath = () => {
26 | const router = useRouter()
27 |
28 | return router.query.userName as string
29 | }
30 |
--------------------------------------------------------------------------------
/apps/next-app/src/app/(hasGNB)/_components/Nav/Nav.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import { ErrorBoundary, Suspense } from '@suspensive/react'
3 | import { useRouter } from 'next/navigation'
4 | import { forwardRef } from 'react'
5 |
6 | import * as S from 'components/common/Layout/Nav/Nav.style'
7 | import { Profile } from 'components/common/Layout/Nav/Profile'
8 | import { ROUTE } from 'constants/route'
9 | import { LogoButton } from './LogoButton'
10 |
11 | // NOTE: 서버 컴포넌트로 만들면 클라 측에서 갱신이 안됨
12 | // app router용
13 | const Nav = forwardRef(function Nav(props, ref) {
14 | const router = useRouter()
15 |
16 | return (
17 |
18 |
19 | router.push(ROUTE.SIGN_UP)}>
22 | 피둥 시작하기
23 |
24 | }
25 | >
26 |
27 |
28 |
29 |
30 |
31 | )
32 | })
33 |
34 | export default Nav
35 |
--------------------------------------------------------------------------------
/apps/next-app/src/app/(hasGNB)/feed/me/page.tsx:
--------------------------------------------------------------------------------
1 | import { HydrationBoundary, dehydrate } from '@tanstack/react-query'
2 | import type { NextPage } from 'next'
3 | import { cookies } from 'next/headers'
4 | import { Suspense } from 'react'
5 |
6 | import { SkeletonPostType } from 'components/common/Skeleton'
7 | import { getQueryClient } from 'core/getQueryClient'
8 | import { itemQueries } from 'entities/item/api'
9 | import { checkLoggedIn } from 'shared/utils/checkLoggedIn'
10 | import MyFeed from 'views/myFeed'
11 |
12 | const FeedMePage: NextPage = async () => {
13 | const queryClient = getQueryClient()
14 | await queryClient.prefetchInfiniteQuery(itemQueries.list())
15 |
16 | return (
17 |
18 | (
20 |
21 | ))}
22 | >
23 |
24 |
25 |
26 | )
27 | }
28 |
29 | export default FeedMePage
30 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/views/RssInput/RssUrlInput.tsx:
--------------------------------------------------------------------------------
1 | import type { ChangeEvent } from 'react'
2 | import Image from 'next/image'
3 |
4 | import Input from './Input'
5 | import { isRssUrlValid } from './RssInputContainer.utils'
6 |
7 | import Icons from 'assets/icons'
8 |
9 | const RssUrlInput = ({
10 | url: rssDirectRssUrl,
11 | onChange,
12 | }: {
13 | url: string
14 | onChange: (e: ChangeEvent | string) => void
15 | }) => (
16 |
23 | selectedValue && (
24 |
32 | )
33 | }
34 | />
35 | )
36 |
37 | export default RssUrlInput
38 |
--------------------------------------------------------------------------------
/apps/next-app/src/utils/hooks/useColorMode.ts:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import { useState, useEffect } from 'react'
3 |
4 | import { COLOR_MODE } from 'constants/colorMode'
5 | import type { ColorModeType } from 'types/colorMode'
6 |
7 | export const useColorMode = () => {
8 | const [isDarkMode, setIsDarkMode] = useState(false)
9 |
10 | useEffect(() => {
11 | const preferredColorMode = localStorage.getItem(COLOR_MODE)
12 | const systemColorMode: ColorModeType = window.matchMedia(
13 | '(prefers-color-scheme: dark)'
14 | ).matches
15 | ? 'dark'
16 | : 'light'
17 | const colorMode = preferredColorMode ?? systemColorMode
18 | setIsDarkMode(colorMode === 'dark')
19 | }, [])
20 |
21 | const toggleColorMode = () => {
22 | const toggledColorMode = !isDarkMode
23 | localStorage.setItem(COLOR_MODE, toggledColorMode ? 'dark' : 'light')
24 | document.documentElement.className = toggledColorMode ? 'dark' : 'light'
25 | setIsDarkMode(toggledColorMode)
26 | }
27 |
28 | return {
29 | isDarkMode,
30 | toggleColorMode,
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/common/Layout/Layout.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import { usePathname } from 'next/navigation'
3 |
4 | import Nav from './Nav'
5 | import { ROUTE } from 'constants/route'
6 |
7 | import { Container } from './Layout.style'
8 |
9 | interface Props {
10 | children: React.ReactNode
11 | }
12 |
13 | const Layout = ({ children }: Props) => {
14 | const pathname = usePathname()
15 |
16 | const { isSignUpPage, isUnderAppRouter } = routerBranch(pathname)
17 | const hasGNB = !isSignUpPage && !isUnderAppRouter
18 |
19 | return (
20 | <>
21 | {hasGNB && }
22 | {children}
23 | >
24 | )
25 | }
26 |
27 | export default Layout
28 |
29 | const routerBranch = (pathname: string | null) => {
30 | return {
31 | isSignUpPage: pathname === ROUTE.SIGN_UP,
32 | isUnderAppRouter: pathname === ROUTE.MY_FEED,
33 | // isRequiredAuthPage: requiredAuthMatcher(pathname),
34 | // isIntroducePage: pathname === ROUTE.RECOMMENDED_CHANNELS,
35 | // isErrorPage: isErrorPage(pathname),
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/views/RssInput/BlogUrlInput.tsx:
--------------------------------------------------------------------------------
1 | import type { ChangeEvent } from 'react'
2 | import Image from 'next/image'
3 |
4 | import { isRssUrlValid } from './RssInputContainer.utils'
5 | import Input from './Input'
6 |
7 | import Icons from 'assets/icons'
8 |
9 | const BlogUrlInput = ({
10 | url: rssDirectChannelUrl,
11 | onChange,
12 | }: {
13 | url: string
14 | onChange: (e: ChangeEvent | string) => void
15 | }) => (
16 |
23 | selectedValue && (
24 |
32 | )
33 | }
34 | />
35 | )
36 |
37 | export default BlogUrlInput
38 |
--------------------------------------------------------------------------------
/apps/next-app/src/services/auth/index.ts:
--------------------------------------------------------------------------------
1 | import type { AxiosRequestConfig } from 'axios'
2 |
3 | import {
4 | getRefreshTokenFromCookie,
5 | setAccessTokenToCookie,
6 | setRefreshTokenToCookie,
7 | } from 'features/auth/token'
8 | import type UserProfile from 'pages/[userName]'
9 | import { reissueTokenUsingPOST } from 'services/types/_generated/user'
10 |
11 | export interface UserProfile {
12 | email: string
13 | name: string
14 | profileImageUrl: string
15 | username: string
16 | }
17 |
18 | export interface SignUpResponse extends UserProfile {
19 | accessToken: string
20 | refreshToken: string
21 | }
22 |
23 | export const refreshAccessToken = async (config: AxiosRequestConfig) => {
24 | // TODO: 리프레시 토큰 만료시 로그아웃 처리도 필요
25 | const data = await reissueTokenUsingPOST({
26 | refreshToken: getRefreshTokenFromCookie(),
27 | })
28 |
29 | setRefreshTokenToCookie(data.refreshToken)
30 | setAccessTokenToCookie(data.accessToken)
31 |
32 | Object.assign(config.headers ?? {}, {
33 | Authorization: `Bearer ${data.accessToken}`,
34 | })
35 |
36 | return config
37 | }
38 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/common/FeedItem/Popovers/PublicFeedItemPopover.tsx:
--------------------------------------------------------------------------------
1 | import Anchor from 'components/common/Anchor'
2 | import Popover from 'components/common/Popover'
3 | import type { PrivateChannel } from 'types/subscriptions'
4 | import { copyToClipboard } from '../FeedItem.utils'
5 | import { PopoverIcons } from './icons'
6 |
7 | interface Props {
8 | item: PrivateChannel
9 | }
10 |
11 | const PublicFeedItemPopover = ({ item }: Props) => {
12 | return (
13 | (
16 |
17 |
18 | 채널 상세
19 |
20 | copyToClipboard(item.url)}
22 | icon={PopoverIcons.링크_복사}
23 | >
24 | 링크 복사
25 |
26 |
27 | )}
28 | >
29 | {PopoverIcons.옵션_메뉴}
30 |
31 | )
32 | }
33 |
34 | export default PublicFeedItemPopover
35 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/common/Dialog/Dialog.style.tsx:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components'
2 |
3 | import { getTypographyStyles } from 'styles/fonts'
4 |
5 | export const Background = styled.div`
6 | height: 100%;
7 | width: 100%;
8 | display: flex;
9 | align-items: center;
10 | justify-content: center;
11 | position: fixed;
12 | left: 0;
13 | top: 0;
14 | text-align: center;
15 | background: rgba(0, 0, 0, 0.5);
16 | `
17 |
18 | export const DialogContainer = styled.div<{ width?: string }>`
19 | width: ${({ width }) => width || '320px'};
20 | min-height: 190px;
21 | border-radius: 10px;
22 | background-color: var(--color-surface-container-lowest);
23 | padding: 32px 16px 20px;
24 | `
25 |
26 | export const Title = styled.p`
27 | ${getTypographyStyles('Headline2_B')};
28 | color: var(--color-font-primary);
29 | margin-bottom: 12px;
30 | `
31 |
32 | export const Content = styled.div`
33 | ${getTypographyStyles('Body1_M')};
34 | color: var(--color-font-secondary);
35 | `
36 |
37 | export const ActionContainer = styled.div`
38 | display: flex;
39 | margin-top: 32px;
40 | gap: 10px;
41 | `
42 |
--------------------------------------------------------------------------------
/apps/next-app/src/assets/icons/left_arrow.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/common/Button/Button.tsx:
--------------------------------------------------------------------------------
1 | import { useDebounce } from '@toss/react'
2 | import { forwardRef } from 'react'
3 |
4 | import * as S from './Button.style'
5 |
6 | export type ButtonStyle = 'primary' | 'secondary' | 'normal' | 'disabled'
7 | export type ButtonSize = 'large' | 'medium' | 'small' | 'tiny'
8 |
9 | export interface Props extends React.ButtonHTMLAttributes {
10 | children?: React.ReactNode
11 | as?: keyof JSX.IntrinsicElements
12 | buttonStyle?: ButtonStyle
13 | size?: ButtonSize
14 | outline?: boolean
15 | }
16 |
17 | const Button = forwardRef(function Button(
18 | {
19 | children,
20 | as = 'button',
21 | size = 'medium',
22 | buttonStyle = 'normal',
23 | outline = false,
24 | onClick,
25 | ...rest
26 | }: Props,
27 | ref
28 | ) {
29 | return (
30 | onClick && onClick(e), 500)}
36 | ref={ref}
37 | {...rest}
38 | >
39 | {children}
40 |
41 | )
42 | })
43 |
44 | export default Button
45 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/common/Notification/Notification.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import Flex from '../Flex'
4 | import { Z_INDEX } from 'styles/constants'
5 |
6 | import * as S from './Notification.style'
7 |
8 | import Icons from 'assets/icons'
9 |
10 | export type NotificationProps = {
11 | type?: 'basic' | 'error'
12 | title: string
13 | content: JSX.Element
14 | duration?: number
15 | position?: 'bottom' | 'top'
16 | afterClose?: () => void
17 | onClose?: VoidFunction
18 | }
19 |
20 | export const Notification = ({
21 | title,
22 | content,
23 | duration = 5000,
24 | onClose,
25 | }: NotificationProps) => {
26 | return (
27 |
28 |
29 |
30 |
31 |
32 | {title}
33 |
34 |
35 |
36 |
37 | {content}
38 |
39 |
40 | )
41 | }
42 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/common/Layout/Nav/Nav.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { ErrorBoundary, Suspense } from '@suspensive/react'
4 | import { useRouter } from 'next/navigation'
5 | import { forwardRef } from 'react'
6 |
7 | import LogoDesktopNoBackground from 'components/common/LogoDesktop'
8 | import { ROUTE } from 'constants/route'
9 | import { Profile } from './Profile'
10 |
11 | import * as S from './Nav.style'
12 |
13 | // pages router용
14 | const Nav = forwardRef(function TopNavBar(props, ref) {
15 | const router = useRouter()
16 |
17 | return (
18 |
19 | router.push('/')}>
20 |
21 | Feedoong
22 |
23 | router.push(ROUTE.SIGN_UP)}>
26 | 피둥 시작하기
27 |
28 | }
29 | >
30 |
31 |
32 |
33 |
34 |
35 | )
36 | })
37 |
38 | export default Nav
39 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/views/Feeds/FeedsContainer.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useRouter } from 'next/router'
4 | import { SwitchCase } from '@toss/react'
5 |
6 | import MyFeed from './MyFeed'
7 | import RecommendedChannels from './Recommended/RecommendedChannels'
8 | import RecommendedPosts from './Recommended/RecommendedPosts'
9 | import FeedTab from './FeedTab'
10 | import { FEED_ROUTE } from 'constants/route'
11 |
12 | import * as S from './FeedsContainer.style'
13 |
14 | const FeedsContainer = () => {
15 | const router = useRouter()
16 |
17 | return (
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | ,
29 | [FEED_ROUTE.RECOMMENDED_CHANNELS]: ,
30 | [FEED_ROUTE.RECOMMENDED_POSTS]: ,
31 | }}
32 | defaultComponent={}
33 | />
34 |
35 |
36 | )
37 | }
38 |
39 | export default FeedsContainer
40 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/common/Input/Input.tsx:
--------------------------------------------------------------------------------
1 | import { forwardRef } from 'react'
2 |
3 | import Flex from '../Flex'
4 |
5 | import * as S from './Input.style'
6 |
7 | interface Props extends React.InputHTMLAttributes {
8 | label: string
9 | buttonName?: string
10 | buttonAction?: VoidFunction
11 | }
12 |
13 | // TODO: supportMessage & clear 버튼 추가해야함
14 | const Input = forwardRef(function Input(
15 | { value, label, buttonName, buttonAction, ...rest }: Props,
16 | ref
17 | ) {
18 | return (
19 |
20 |
21 | {label}
22 | {buttonName && (
23 | {buttonName}
24 | )}
25 |
26 |
27 |
28 |
29 | {/* {value && (
30 |
31 |
32 |
33 | )} */}
34 |
35 |
36 | )
37 | })
38 |
39 | export default Input
40 |
--------------------------------------------------------------------------------
/apps/next-app/src/app/introduce/_components/IntroduceFeature3.tsx:
--------------------------------------------------------------------------------
1 | import Flex from 'components/common/Flex'
2 | import * as S from '../Introduce.style'
3 | import FeedoongSticker from './FeedoongSticker'
4 |
5 | import Images from 'assets/images'
6 |
7 | const IntroduceFeature3 = () => {
8 | return (
9 |
10 |
11 |
12 | 좋았던 글은 저장해두세요!
13 |
14 | 우연히 만난 콘텐츠가 마음에 들었다면 그냥 흘려보내지 마세요.
15 |
16 |
17 | 구독한 채널의 컨텐츠를 저장해뒀다가 나중에 다시 보거나, 링크를
18 | 복사해서 쉽게 공유할 수 있어요.
19 |
20 |
21 |
29 |
{' '}
30 |
31 |
32 | )
33 | }
34 |
35 | export default IntroduceFeature3
36 |
--------------------------------------------------------------------------------
/apps/next-app/src/core/getQueryClient.ts:
--------------------------------------------------------------------------------
1 | import {
2 | QueryClient,
3 | defaultShouldDehydrateQuery,
4 | isServer,
5 | } from '@tanstack/react-query'
6 |
7 | function makeQueryClient() {
8 | return new QueryClient({
9 | defaultOptions: {
10 | queries: {
11 | staleTime: 60 * 1000,
12 | },
13 | dehydrate: {
14 | // include pending queries in dehydration
15 | shouldDehydrateQuery: (query) =>
16 | defaultShouldDehydrateQuery(query) ||
17 | query.state.status === 'pending',
18 | },
19 | },
20 | })
21 | }
22 |
23 | let browserQueryClient: QueryClient | undefined = undefined
24 |
25 | export function getQueryClient() {
26 | if (isServer) {
27 | // Server: always make a new query client
28 | return makeQueryClient()
29 | } else {
30 | // Browser: make a new query client if we don't already have one
31 | // This is very important, so we don't re-make a new client if React
32 | // suspends during the initial render. This may not be needed if we
33 | // have a suspense boundary BELOW the creation of the query client
34 | if (!browserQueryClient) browserQueryClient = makeQueryClient()
35 | return browserQueryClient
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/common/Modal/ModalLayout.tsx:
--------------------------------------------------------------------------------
1 | import Image from 'next/image'
2 |
3 | import Divider from 'components/common/Divider'
4 | import * as S from './ModalLayout.styles'
5 |
6 | import Icons from 'assets/icons'
7 |
8 | interface Props {
9 | title?: string
10 | size: 'small' | 'medium' | 'large'
11 | hasHeader?: boolean
12 | onClose?: VoidFunction
13 | children?: React.ReactNode
14 | style?: React.CSSProperties
15 | }
16 |
17 | export const ModalLayout: React.FC = ({
18 | title,
19 | onClose,
20 | style,
21 | size,
22 | children,
23 | hasHeader = true,
24 | }) => {
25 | return (
26 |
27 | {hasHeader && (
28 | <>
29 |
30 | {title}
31 |
39 |
40 |
41 | >
42 | )}
43 | {children}
44 |
45 | )
46 | }
47 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/views/Feeds/Recommended/RecommendedChannels.tsx:
--------------------------------------------------------------------------------
1 | import { useQuery } from '@tanstack/react-query'
2 |
3 | import FeedItem from 'components/common/FeedItem'
4 | import { SkeletonChannelType } from 'components/common/Skeleton'
5 | import { CACHE_KEYS } from 'services/cacheKeys'
6 | import { getRecommendedChannelsUsingGET } from 'services/types/_generated/channel'
7 | import * as S from '../FeedsContainer.style'
8 |
9 | const RecommendedChannels = () => {
10 | const { data, isFetching } = useQuery({
11 | queryKey: CACHE_KEYS.recommended(['channels']),
12 | queryFn: getRecommendedChannelsUsingGET,
13 | })
14 |
15 | const showSkeleton = isFetching && !data
16 |
17 | return (
18 |
19 | {showSkeleton &&
20 | Array.from({ length: 10 }).map((_, idx) => {
21 | return
22 | })}
23 | {data?.channels.map((channel) => {
24 | return (
25 |
31 | )
32 | })}
33 |
34 | )
35 | }
36 |
37 | export default RecommendedChannels
38 |
--------------------------------------------------------------------------------
/apps/next-app/scripts/codeGen.mjs:
--------------------------------------------------------------------------------
1 | import path, { dirname } from 'path'
2 | import { fileURLToPath } from 'url'
3 | import { spawn } from 'child_process'
4 | import fs from 'fs'
5 |
6 | const genPath = '../src/services/types/_generated'
7 |
8 | const __dirname = dirname(fileURLToPath(import.meta.url))
9 | const BASE_PATH = path.join(__dirname, genPath)
10 | console.log(BASE_PATH)
11 |
12 | console.log('>>> generated 폴더를 삭제합니다.')
13 |
14 | await fs.rm(BASE_PATH, { recursive: true }, (err) => {
15 | // 디렉토리가 없는 경우를 제외함.
16 | if (err && err.code !== 'ENOENT') {
17 | console.log('>>> 폴더 삭제 중 에러 발생:', err)
18 | } else {
19 | console.log('>>> 폴더 삭제가 성공적으로 완료되었습니다.')
20 |
21 | const process = spawn('bash')
22 |
23 | console.log('>>> orval을 실행합니다.')
24 |
25 | try {
26 | process.stdin.write('orval --config ./orval.config.js')
27 | process.stdin.end()
28 |
29 | process.on('close', function (code) {
30 | if (code === 0) {
31 | console.log('>>> orval 실행이 성공적으로 완료되었습니다.')
32 | } else {
33 | console.error(
34 | `>>> orval 실행이 오류와 함께 종료되었습니다. 종료 코드: ${code}`
35 | )
36 | }
37 | })
38 | } catch (err) {
39 | console.error('>>> orval 실행 중 오류 발생:', err)
40 | }
41 | }
42 | })
43 |
--------------------------------------------------------------------------------
/apps/next-app/src/types/feeds.ts:
--------------------------------------------------------------------------------
1 | import type { Channel } from './subscriptions'
2 |
3 | export interface Feed {
4 | items: Post[]
5 | channel: Channel
6 | next: number | null
7 | prev: number | null
8 | totalCount: 0
9 | }
10 |
11 | export interface Post {
12 | imageUrl: string
13 | description: string
14 | guid: string
15 | id: number
16 | link: string
17 | publishedAt: string // 2022-10-09T18:11:18.497Z
18 | title: string
19 | channelImageUrl: string
20 | channelTitle: string
21 | channelId: number
22 | isLiked: boolean
23 | isViewed: boolean
24 | }
25 |
26 | export interface PrivatePost extends Post {
27 | isLiked: boolean
28 | isViewed: boolean
29 | }
30 |
31 | export interface PreviewResponse {
32 | description: string
33 | imageUrl: string
34 | feedUrl: string
35 | title: string
36 | url: string
37 | }
38 |
39 | export interface SubmitRssUrlParams {
40 | feedUrl: string
41 | url: string
42 | }
43 |
44 | export interface SubmitRssUrlResponse {
45 | channelId: 0
46 | createdAt: string // 2022-10-09T18:11:18.492Z
47 | link: string
48 | }
49 |
50 | export interface LikePostResponse {
51 | isLiked: boolean
52 | itemId: number
53 | }
54 |
55 | export interface SubmitViewedPost {
56 | id: number
57 | isViewed: boolean
58 | }
59 |
--------------------------------------------------------------------------------
/apps/next-app/src/app/introduce/_components/IntroduceFeature2.tsx:
--------------------------------------------------------------------------------
1 | import Flex from 'components/common/Flex'
2 | import * as S from '../Introduce.style'
3 | import FeedoongSticker from './FeedoongSticker'
4 |
5 | import Images from 'assets/images'
6 |
7 | const IntroduceFeature2 = () => {
8 | return (
9 |
10 |
11 |
12 | 구독 중인 블로그를 한눈에!
13 |
14 | 브라우저 북마크에 정신 없이 늘어놓은 구독 채널들을 한눈에
15 | 관리하세요.
16 |
17 |
18 | 더 이상 어떤 북마크 폴더에 어떤 블로그를 넣어뒀는지 힘들게 찾지
19 | 않아도 됩니다. 피둥에서는 구독 중인 채널들을 한눈에 관리할 수
20 | 있어요.
21 |
22 |
23 |
31 |
{' '}
32 |
33 |
34 | )
35 | }
36 |
37 | export default IntroduceFeature2
38 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/views/Channel/ChannelDetailContainer.style.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components'
2 |
3 | import { colors } from 'styles/colors'
4 | import { getTypographyStyles } from 'styles/fonts'
5 | import { mediaQuery } from 'styles/mediaQuery'
6 |
7 | export const Container = styled.div`
8 | height: 100%;
9 | overflow: auto;
10 | display: flex;
11 | align-items: center;
12 | flex-direction: column;
13 | background-color: ${colors.mainBG};
14 | `
15 |
16 | export const FeedWrapper = styled.div`
17 | margin: 0 auto;
18 | padding: 0 12px;
19 | max-width: 640px;
20 | width: 100%;
21 | border-radius: 4px;
22 |
23 | ${mediaQuery.tablet`
24 | padding: 0px 20px;
25 | `}
26 | `
27 |
28 | export const Header = styled.div`
29 | display: flex;
30 | justify-content: space-between;
31 | margin-bottom: 20px;
32 | `
33 |
34 | export const TitleWrapper = styled.div`
35 | width: 100%;
36 | display: flex;
37 | gap: 20px;
38 | justify-content: space-between;
39 | `
40 |
41 | export const Title = styled.h1`
42 | ${getTypographyStyles('Headline2_B')}
43 |
44 | color: var(--color-gray-900);
45 | cursor: pointer;
46 | `
47 |
48 | export const CardContainer = styled.ul`
49 | display: flex;
50 | gap: 20px;
51 | flex-direction: column;
52 | `
53 |
--------------------------------------------------------------------------------
/apps/next-app/src/app/introduce/_components/IntroduceFeature1.tsx:
--------------------------------------------------------------------------------
1 | import Flex from 'components/common/Flex'
2 | import * as S from '../Introduce.style'
3 | import FeedoongSticker from './FeedoongSticker'
4 |
5 | import Images from 'assets/images'
6 |
7 | const IntroduceFeature1 = () => {
8 | return (
9 |
10 |
11 |
12 | 내가 보고싶은 컨텐츠만 모아두는 나만의 피드
13 |
14 | RSS로 발행된 컨텐츠라면 어떤 것이든 구독할 수 있습니다.
15 |
16 |
17 | 피둥을 크롬 시작 화면으로 설정하면 네이버 블로그, 브런치, 티스토리,
18 | 유튜브, 벨로그 등 여러분이 구독하고 싶은 다양한 채널의 업데이트를
19 | 브라우저 첫 페이지에서 한눈에 확인할 수 있어요.
20 |
21 |
22 |
30 |
{' '}
31 |
32 |
33 | )
34 | }
35 |
36 | export default IntroduceFeature1
37 |
--------------------------------------------------------------------------------
/apps/next-app/src/views/myFeed/MyFeed.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import { useSuspenseInfiniteQuery } from '@tanstack/react-query'
3 | import { useEffect } from 'react'
4 | import { useInView } from 'react-intersection-observer'
5 |
6 | import Loading from 'components/common/Loading'
7 | import { itemQueries } from 'entities/item/api'
8 | import { PostFeedItem } from 'features/post/ui/PostFeedItem'
9 |
10 | import * as S from './MyFeed.style'
11 |
12 | interface Props {
13 | isLoggedIn: boolean
14 | }
15 |
16 | const MyFeed = ({ isLoggedIn }: Props) => {
17 | const { data, fetchNextPage, isFetchingNextPage, hasNextPage } =
18 | useSuspenseInfiniteQuery(itemQueries.list())
19 |
20 | const { ref, inView } = useInView({ rootMargin: '25px' })
21 |
22 | useEffect(() => {
23 | if (inView) {
24 | fetchNextPage()
25 | }
26 | }, [inView, fetchNextPage])
27 |
28 | return (
29 | <>
30 |
31 | {data?.pages.map((page) =>
32 | page.items.map((item) => (
33 |
34 | ))
35 | )}
36 |
37 | {isFetchingNextPage && }
38 | {hasNextPage && }
39 | >
40 | )
41 | }
42 |
43 | export default MyFeed
44 |
--------------------------------------------------------------------------------
/apps/next-app/src/app/signup/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import type { NextPage } from 'next'
3 | import Image from 'next/image'
4 | import { useRouter } from 'next/navigation'
5 |
6 | import { googleAuthUrl } from './_utils/SignUp.utils'
7 |
8 | import * as S from './SignUp.style'
9 |
10 | import Icons from 'assets/icons'
11 |
12 | const SignUpPage: NextPage = () => {
13 | const router = useRouter()
14 |
15 | const signUp = () => {
16 | router.push(googleAuthUrl)
17 | }
18 |
19 | return (
20 | <>
21 | 인사이트가 피둥피둥
22 |
23 | 여기저기 둥둥 떠있는 나의 인사이트 컨텐츠들을 피둥에서 모아보기
24 |
25 | 크롬 새탭에서 바로 시작하세요!
26 |
27 |
28 |
29 |
35 | 구글 계정으로 3초 만에 시작하기
36 |
37 |
38 |
39 | 로그인은 개인 정보 보호 정책 및 서비스 약관에 동의하는 것을 의미하며,
40 |
41 |
42 | 서비스 이용을 위해 이메일과 이름, 프로필 이미지를 수집합니다.
43 |
44 | >
45 | )
46 | }
47 |
48 | export default SignUpPage
49 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/common/Skeleton/PostType.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import Skeleton from 'react-loading-skeleton'
3 |
4 | import { Container } from 'components/common/FeedItem/Post/Post.style'
5 | import * as S from '../FeedItem/FeedItem.style'
6 | import Flex from 'components/common/Flex'
7 | import Divider from 'components/common/Divider'
8 |
9 | const SkeletonPostType = () => {
10 | return (
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 | )
35 | }
36 |
37 | export default SkeletonPostType
38 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/views/Error/404/404Container.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import Image from 'next/image'
3 | import { useRouter } from 'next/router'
4 |
5 | import Button from 'components/common/Button'
6 | import * as Error from '../error.style'
7 |
8 | import * as S from './404Container.style'
9 |
10 | import Images from 'assets/images'
11 |
12 | const Custom404Container = () => {
13 | const router = useRouter()
14 | return (
15 |
16 |
22 |
23 |
24 | 요청하신 페이지를 찾을 수 없습니다.
25 |
26 |
27 | 방문하시려는 페이지의 주소가 잘못 입력되었거나,
28 | 페이지의 주소가 변경 혹은 삭제되어 요청하신 페이지를 찾을 수 없습니다.
29 |
30 | 입력하신 주소가 정확한지 다시 한번 확인해 주시기 바랍니다.
31 |
32 |
35 |
36 |
37 | )
38 | }
39 |
40 | export default Custom404Container
41 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/common/Loading/Loading.style.tsx:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components'
2 |
3 | export const Container = styled.div`
4 | display: flex;
5 | justify-content: center;
6 | align-items: center;
7 | flex-direction: column;
8 | background-color: var(--color-surface);
9 | opacity: 0.25;
10 |
11 | > :not(:last-child) {
12 | margin-bottom: 15px;
13 | }
14 |
15 | .lds-ring {
16 | display: inline-block;
17 | position: relative;
18 | width: 80px;
19 | height: 80px;
20 | }
21 | .lds-ring div {
22 | box-sizing: border-box;
23 | display: block;
24 | position: absolute;
25 | width: 64px;
26 | height: 64px;
27 | margin: 8px;
28 | border: 8px solid var(--color-gray-900);
29 | border-radius: 50%;
30 | animation: lds-ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
31 | border-color: var(--color-gray-900) transparent transparent transparent;
32 | }
33 | .lds-ring div:nth-child(1) {
34 | animation-delay: -0.45s;
35 | }
36 | .lds-ring div:nth-child(2) {
37 | animation-delay: -0.3s;
38 | }
39 | .lds-ring div:nth-child(3) {
40 | animation-delay: -0.15s;
41 | }
42 | @keyframes lds-ring {
43 | 0% {
44 | transform: rotate(0deg);
45 | }
46 | 100% {
47 | transform: rotate(360deg);
48 | }
49 | }
50 | `
51 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/common/Providers/index.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | HydrationBoundary,
3 | QueryClientProvider,
4 | QueryClient,
5 | QueryCache,
6 | } from '@tanstack/react-query'
7 | import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
8 | import { useState } from 'react'
9 |
10 | import { globalQueryErrorHandler } from 'features/errors/globalQueryErrorHandler'
11 |
12 | interface Props {
13 | children: React.ReactNode
14 | pageProps: any
15 | }
16 |
17 | const Providers = ({ pageProps, children }: Props) => {
18 | const [queryClient] = useState(
19 | () =>
20 | new QueryClient({
21 | defaultOptions: {
22 | queries: {
23 | retryOnMount: false,
24 | refetchOnWindowFocus: false,
25 | refetchOnReconnect: false,
26 | },
27 | },
28 | queryCache: new QueryCache({
29 | onError: (err: unknown, query) =>
30 | globalQueryErrorHandler(err, query, queryClient),
31 | }),
32 | })
33 | )
34 |
35 | return (
36 |
37 |
38 | {children}
39 |
40 |
41 |
42 | )
43 | }
44 |
45 | export default Providers
46 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/views/UserPage/List/PostList/hooks/usePostListByUsername.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from '@tanstack/react-query'
2 | import { useRouter } from 'next/router'
3 |
4 | import { useCheckIsMyProfile } from 'features/user/useCheckIsMyProfile'
5 | import { CACHE_KEYS } from 'services/cacheKeys'
6 | import { getLikesUsingGET } from 'services/types/_generated/item'
7 | import { getUserLikedItemsUsingGET } from 'services/types/_generated/user'
8 |
9 | const usePostListByUsername = (username?: string) => {
10 | const router = useRouter()
11 | const currentPage = Number(router.query.page) || 1
12 | const isMyProfile = useCheckIsMyProfile()
13 |
14 | const { data, isLoading } = useQuery({
15 | queryKey: [CACHE_KEYS.likedPosts, { page: currentPage }],
16 | queryFn: () =>
17 | isMyProfile
18 | ? getLikesUsingGET({
19 | page: currentPage,
20 | size: 10,
21 | })
22 | : getUserLikedItemsUsingGET(username!, {
23 | page: currentPage,
24 | size: 10,
25 | }),
26 | enabled: !!username,
27 | })
28 |
29 | return {
30 | listData: data?.items,
31 | isLoading,
32 | isEmptyList: !isLoading && data?.items.length === 0,
33 | totalCount: data?.totalCount,
34 | }
35 | }
36 |
37 | export default usePostListByUsername
38 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/common/LogoDesktop/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | interface Props {
4 | color?: string
5 | width?: string
6 | height?: string
7 | }
8 |
9 | const LogoDesktopNoBackground = ({ color, width, height }: Props) => {
10 | return (
11 |
31 | )
32 | }
33 |
34 | export default LogoDesktopNoBackground
35 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/views/UserPage/UserPageContainer.style.ts:
--------------------------------------------------------------------------------
1 | import Image from 'next/image'
2 | import styled from 'styled-components'
3 |
4 | import { getTypographyStyles } from 'styles/fonts'
5 | import { mediaQuery } from 'styles/mediaQuery'
6 |
7 | export const Contents = styled.div`
8 | width: 100%;
9 | padding: 0 12px;
10 | max-width: 640px;
11 | border-radius: 4px;
12 | margin: 0 auto;
13 |
14 | ${mediaQuery.tablet`
15 | padding: 0px 20px;
16 | `}
17 | `
18 |
19 | export const Header = styled.div`
20 | display: flex;
21 | gap: 12px;
22 | margin-bottom: 60px;
23 | `
24 |
25 | export const UserImage = styled(Image)`
26 | width: 72px;
27 | height: 72px;
28 | border-radius: 50%;
29 | `
30 |
31 | export const NickName = styled.span`
32 | ${getTypographyStyles('Headline1_B')}
33 | color: var(--color-gray-900);
34 | `
35 |
36 | export const SettingButton = styled.button`
37 | all: unset;
38 | cursor: pointer;
39 | `
40 |
41 | export const FeedoongUrl = styled.span`
42 | ${getTypographyStyles('Body1_M')}
43 | color: var(--color-gray-600);
44 | cursor: pointer;
45 |
46 | &:hover {
47 | text-decoration: underline;
48 | text-decoration-thickness: 1.5px;
49 | text-underline-position: under;
50 | }
51 | `
52 |
53 | export const TabWrapper = styled.div`
54 | margin-bottom: 24px;
55 | `
56 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/common/Input/Input.style.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components'
2 |
3 | import { getTypographyStyles } from 'styles/fonts'
4 |
5 | export const Container = styled.div`
6 | width: 100%;
7 | gap: 8px;
8 | display: flex;
9 | flex-direction: column;
10 | `
11 |
12 | export const Label = styled.span`
13 | ${getTypographyStyles('Body1_B')}
14 | color: var(--color-font-secondary);
15 | `
16 |
17 | export const TextButton = styled.button`
18 | all: unset;
19 | color: var(--color-primary-500);
20 | cursor: pointer;
21 | ${getTypographyStyles('Body2_M')}
22 | `
23 |
24 | export const Input = styled.input`
25 | ${getTypographyStyles('Headline3_M')}
26 | width: 100%;
27 | height: 48px;
28 | border: none;
29 | border-radius: 100px;
30 | outline: none;
31 | padding: 11px 20px;
32 | color: var(--color-font-tertiary);
33 | background-color: var(--color-surface-container-lowest);
34 |
35 | &:read-only {
36 | cursor: default;
37 | color: var(--color-font-disabled);
38 | background-color: var(--color-surface-container-highest);
39 | }
40 | `
41 |
42 | export const InputWrapper = styled.div`
43 | display: flex;
44 | position: relative;
45 | `
46 |
47 | export const ClearButtonWrapper = styled.div`
48 | position: absolute;
49 | top: 13px;
50 | right: 7px;
51 | cursor: pointer;
52 | `
53 |
--------------------------------------------------------------------------------
/apps/next-app/src/assets/icons/card_view.svg:
--------------------------------------------------------------------------------
1 |
12 |
--------------------------------------------------------------------------------
/apps/next-app/src/assets/icons/card_view-deactive.svg:
--------------------------------------------------------------------------------
1 |
12 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/views/UserPage/List/ChannelList/hooks/useChannelListByUsername.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from '@tanstack/react-query'
2 | import { useRouter } from 'next/router'
3 |
4 | import { useCheckIsMyProfile } from 'features/user/useCheckIsMyProfile'
5 | import { CACHE_KEYS } from 'services/cacheKeys'
6 | import { getSubscriptionsUsingGET } from 'services/types/_generated/subscription'
7 | import { getUserSubscriptionsUsingGET } from 'services/types/_generated/user'
8 |
9 | const useChannelListByUsername = (username?: string) => {
10 | const router = useRouter()
11 | const currentPage = Number(router.query.page) || 1
12 | const isMyProfile = useCheckIsMyProfile()
13 |
14 | const { data, isLoading } = useQuery({
15 | queryKey: [CACHE_KEYS.channels, { page: currentPage }],
16 | queryFn: () =>
17 | isMyProfile
18 | ? getSubscriptionsUsingGET({
19 | page: currentPage,
20 | size: 10,
21 | })
22 | : getUserSubscriptionsUsingGET(username!, {
23 | page: currentPage,
24 | size: 10,
25 | }),
26 |
27 | enabled: !!username,
28 | })
29 |
30 | return {
31 | listData: data?.channels,
32 | isLoading,
33 | isEmptyList: !isLoading && data?.channels.length === 0,
34 | totalCount: data?.totalCount,
35 | }
36 | }
37 |
38 | export default useChannelListByUsername
39 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/views/Channel/ChannalSubscription.tsx:
--------------------------------------------------------------------------------
1 | import Image from 'next/image'
2 |
3 | import { AddButton } from 'components/common/FeedItem/Channel/Channel.style'
4 | import PrivateFeedItemPopover from 'components/common/FeedItem/Popovers/PrivateFeedItemPopover'
5 | import { subscribeChannel } from 'features/channel'
6 | import type { Channel } from 'types/subscriptions'
7 | import { useGetUserProfile } from 'features/user/userProfile'
8 | import { useCheckLoginModal } from 'features/auth/checkLogin'
9 |
10 | import Icons from 'assets/icons'
11 |
12 | interface Props {
13 | channel: Channel
14 | }
15 |
16 | const ChannelSubscription = ({ channel }: Props) => {
17 | const { openLoginModal, renderModal } = useCheckLoginModal()
18 |
19 | const { data: user } = useGetUserProfile()
20 |
21 | return channel.isSubscribed ? (
22 |
23 | ) : (
24 | <>
25 | {
27 | if (!user) {
28 | return openLoginModal()
29 | }
30 | subscribeChannel(channel)
31 | }}
32 | >
33 |
40 |
41 | {renderModal()}
42 | >
43 | )
44 | }
45 |
46 | export default ChannelSubscription
47 |
--------------------------------------------------------------------------------
/apps/next-app/src/assets/channels/index.ts:
--------------------------------------------------------------------------------
1 | import NaverBlog from './naver_blog.ico'
2 | import Brunch from './brunch.ico'
3 | import Vercel from './vercel.png'
4 | import Kakao from './kakao.ico'
5 | import toss from './toss.ico'
6 | import Tistory from './tistory.ico'
7 | import Vscode from './vscode.ico'
8 | import Chrome from './chrome.png'
9 | import Hyperconnect from './hyperconnect.ico'
10 | import NhnToast from './nhnToast.ico'
11 | import Velog from './velog.ico'
12 | import Youtube from './youtube.ico'
13 |
14 | export const getIconByHostname = (hostname: string) => {
15 | // 로직으로 처리할 경우
16 | if (hostname.includes('kakao')) {
17 | return Kakao.src
18 | }
19 | if (hostname.includes('toss')) {
20 | return toss.src
21 | }
22 | if (hostname.includes('tistory')) {
23 | return Tistory.src
24 | }
25 | if (hostname.includes('youtube')) {
26 | return Youtube.src
27 | }
28 | // 정확한 hostname으로 처리할 경우
29 | switch (hostname) {
30 | case 'blog.naver.com':
31 | return NaverBlog.src
32 | case 'brunch.co.kr':
33 | return Brunch.src
34 | case 'vercel.com':
35 | return Vercel.src
36 | case 'code.visualstudio.com':
37 | return Vscode.src
38 | case 'developer.chrome.com':
39 | return Chrome.src
40 | case 'hyperconnect.github.io':
41 | return Hyperconnect.src
42 | case 'meetup.toast.com':
43 | return NhnToast.src
44 | case 'velog.io':
45 | return Velog.src
46 | }
47 | return
48 | }
49 |
--------------------------------------------------------------------------------
/apps/next-app/src/assets/icons/x-mono.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/common/Scripts/Scripts.tsx:
--------------------------------------------------------------------------------
1 | import Script from 'next/script'
2 |
3 | const Scripts = () => {
4 | return (
5 | <>
6 |
19 | {/* Global Site Tag (gtag.js) - Google Analytics */}
20 |
24 |
38 | >
39 | )
40 | }
41 |
42 | export default Scripts
43 |
--------------------------------------------------------------------------------
/apps/next-app/src/app/signup/SignUp.style.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import styled from 'styled-components'
3 |
4 | import { getTypographyStyles } from 'styles/fonts'
5 |
6 | export const Wrapper = styled.div`
7 | display: flex;
8 | flex-direction: column;
9 | justify-content: center;
10 | align-items: center;
11 | width: 100vw;
12 | height: 100dvh;
13 | background-color: var(--color-surface);
14 | padding: 25px;
15 | `
16 |
17 | export const Title = styled.h1`
18 | font-size: 40px;
19 | font-weight: 700;
20 | line-height: 48px;
21 | color: var(--color-black);
22 | text-align: center;
23 | margin-bottom: 11px;
24 | `
25 |
26 | export const Subtitle = styled.h2`
27 | ${getTypographyStyles('Headline3_M')}
28 | color: var(--color-gray-400);
29 | text-align: center;
30 | word-break: keep-all;
31 | `
32 |
33 | export const GoogleLoginButton = styled.button`
34 | all: unset;
35 | margin: 40px 0;
36 | padding: 14px 40px;
37 | border-radius: 30px;
38 | background: var(--color-black);
39 | color: var(--color-white);
40 | cursor: pointer;
41 | `
42 |
43 | export const ButtonContentsWrapper = styled.div`
44 | display: flex;
45 | gap: 10px;
46 | align-items: center;
47 |
48 | p {
49 | ${getTypographyStyles('Headline3_B')}
50 | }
51 | `
52 |
53 | export const Anchor = styled.a`
54 | ${getTypographyStyles('Body2_M')}
55 | color: var(--color-gray-600);
56 | text-align: center;
57 | cursor: pointer;
58 |
59 | &:hover {
60 | text-decoration: underline;
61 | }
62 | `
63 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/common/FeedItem/hooks/useToggleLike.ts:
--------------------------------------------------------------------------------
1 | import { useMutation, useQueryClient } from '@tanstack/react-query'
2 |
3 | import Toast from 'components/common/Toast'
4 | import { itemQueries } from 'entities/item/api'
5 | import { CACHE_KEYS } from 'services/cacheKeys'
6 | import {
7 | likeUsingPOST,
8 | unlikeUsingDELETE,
9 | } from 'services/types/_generated/like'
10 |
11 | // TODO: 추후에 useToggleLike 자체를 재작성해야 함. 임시로 인자 타입 변경
12 | const useToggleLike = (item: { id: number; isLiked: boolean }) => {
13 | const client = useQueryClient()
14 |
15 | const { mutate: handleLike } = useMutation({
16 | mutationKey: CACHE_KEYS.likePost(item.id),
17 | mutationFn: !item.isLiked ? likeUsingPOST : unlikeUsingDELETE,
18 | onSuccess: async (data) => {
19 | client.invalidateQueries(itemQueries.list())
20 | client.invalidateQueries({ queryKey: CACHE_KEYS.feeds })
21 | client.invalidateQueries({
22 | predicate: ({ queryHash }) => {
23 | if (
24 | queryHash.includes('likedItems') ||
25 | queryHash.includes('likedPosts')
26 | ) {
27 | return true
28 | }
29 | return false
30 | },
31 | })
32 |
33 | let toastMessage = '게시물이 저장되었습니다.'
34 | if (!data.isLiked) {
35 | toastMessage = '게시물 저장이 해제되었습니다.'
36 | }
37 | Toast.show({ content: toastMessage })
38 | },
39 | })
40 |
41 | return {
42 | handleLike,
43 | }
44 | }
45 |
46 | export default useToggleLike
47 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/views/Feeds/FeedTab/styles.tsx:
--------------------------------------------------------------------------------
1 | import styled, { css } from 'styled-components'
2 |
3 | import { getTypographyStyles } from 'styles/fonts'
4 |
5 | export const TabContainer = styled.div`
6 | display: flex;
7 | width: 100%;
8 | `
9 |
10 | export const Tab = styled.div<{ $isSelected: boolean; $fullWidth?: boolean }>`
11 | all: unset;
12 | cursor: pointer;
13 | padding: 8px 20px;
14 | line-height: 24px;
15 | border-radius: 20px;
16 | color: var(--color-gray-500);
17 | background-color: var(--color-gray-50);
18 | white-space: nowrap;
19 |
20 | ${getTypographyStyles('Body1_M')};
21 |
22 | ${({ $isSelected }) =>
23 | $isSelected &&
24 | css`
25 | color: var(--color-white);
26 | background-color: var(--color-gray-900);
27 | ${getTypographyStyles('Body1_B')};
28 | `}
29 |
30 | ${({ $fullWidth }) =>
31 | $fullWidth &&
32 | css`
33 | width: 100%;
34 | `}
35 | `
36 |
37 | export const SubTab = styled.button<{ $isSelected: boolean }>`
38 | all: unset;
39 |
40 | ${getTypographyStyles('Body2_M')};
41 | color: var(--color-font-disabled);
42 |
43 | display: inline-block;
44 | width: 40px;
45 | text-align: center;
46 | cursor: pointer;
47 |
48 | ${({ $isSelected }) =>
49 | $isSelected &&
50 | css`
51 | color: var(--color-white);
52 | ${getTypographyStyles('Body2_B')};
53 | `}
54 | `
55 |
56 | export const VerticalDivider = styled.div`
57 | width: 1px;
58 | height: 20px;
59 | background-color: var(--color-divider);
60 | `
61 |
--------------------------------------------------------------------------------
/apps/next-app/src/shared/ui/FeedTab/styles.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import styled, { css } from 'styled-components'
3 |
4 | import { getTypographyStyles } from 'styles/fonts'
5 |
6 | export const TabContainer = styled.div`
7 | display: flex;
8 | width: 100%;
9 | `
10 |
11 | export const Tab = styled.div<{ $isSelected: boolean; $fullWidth?: boolean }>`
12 | all: unset;
13 | cursor: pointer;
14 | padding: 8px 20px;
15 | line-height: 24px;
16 | border-radius: 20px;
17 | color: var(--color-gray-500);
18 | background-color: var(--color-gray-50);
19 | white-space: nowrap;
20 |
21 | ${getTypographyStyles('Body1_M')};
22 |
23 | ${({ $isSelected }) =>
24 | $isSelected &&
25 | css`
26 | color: var(--color-white);
27 | background-color: var(--color-gray-900);
28 | ${getTypographyStyles('Body1_B')};
29 | `}
30 |
31 | ${({ $fullWidth }) =>
32 | $fullWidth &&
33 | css`
34 | width: 100%;
35 | `}
36 | `
37 |
38 | export const SubTab = styled.button<{ $isSelected: boolean }>`
39 | all: unset;
40 |
41 | ${getTypographyStyles('Body2_M')};
42 | color: var(--color-font-disabled);
43 |
44 | display: inline-block;
45 | width: 40px;
46 | text-align: center;
47 | cursor: pointer;
48 |
49 | ${({ $isSelected }) =>
50 | $isSelected &&
51 | css`
52 | color: var(--color-white);
53 | ${getTypographyStyles('Body2_B')};
54 | `}
55 | `
56 |
57 | export const VerticalDivider = styled.div`
58 | width: 1px;
59 | height: 20px;
60 | background-color: var(--color-divider);
61 | `
62 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/common/Paging/hooks/usePageInfo.tsx:
--------------------------------------------------------------------------------
1 | interface Props {
2 | totalPage: number
3 | currentPage: number
4 | displayedPageRange: number
5 | }
6 |
7 | const usePageInfo = ({ totalPage, currentPage, displayedPageRange }: Props) => {
8 | const total_page = totalPage
9 | const current_page = currentPage
10 | const hasPreviousPage = currentPage > 1
11 | const previousPage = currentPage - 1
12 | const hasNextPage = totalPage > 0 && currentPage !== totalPage
13 | const nextPage = currentPage + 1
14 |
15 | let firstPage = Math.max(1, current_page - Math.floor(displayedPageRange / 2))
16 | let lastPage = Math.min(
17 | totalPage,
18 | currentPage + Math.floor(displayedPageRange / 2)
19 | )
20 |
21 | if (lastPage - firstPage + 1 < displayedPageRange) {
22 | if (currentPage < total_page / 2) {
23 | lastPage = Math.min(
24 | total_page,
25 | lastPage + (displayedPageRange - (lastPage - firstPage))
26 | )
27 | } else {
28 | firstPage = Math.max(
29 | 1,
30 | firstPage - (displayedPageRange - (lastPage - firstPage))
31 | )
32 | }
33 | }
34 |
35 | if (lastPage - firstPage + 1 > displayedPageRange) {
36 | if (current_page > total_page / 2) {
37 | firstPage++
38 | } else {
39 | lastPage--
40 | }
41 | }
42 |
43 | return {
44 | hasPreviousPage: hasPreviousPage,
45 | previousPage: previousPage,
46 | hasNextPage: hasNextPage,
47 | nextPage: nextPage,
48 | firstPage: firstPage,
49 | lastPage: lastPage,
50 | }
51 | }
52 |
53 | export default usePageInfo
54 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/common/Modal/ModalLayout.styles.tsx:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components'
2 |
3 | import Flex from 'components/common/Flex'
4 | import { getTypographyStyles } from 'styles/fonts'
5 | import { mediaQuery } from 'styles/mediaQuery'
6 |
7 | export const Container = styled.div<{
8 | size?: 'small' | 'medium' | 'large'
9 | }>`
10 | ${({ size = 'large' }) => {
11 | // TODO: 임시로 사이즈 나눠둠. 추후에 디자인 시스템에 맞게 수정 필요
12 | switch (size) {
13 | case 'small':
14 | return `
15 | width: 250px;
16 | `
17 | case 'medium':
18 | return `
19 | width: 400px;
20 | `
21 | case 'large':
22 | return `
23 | width: 600px;
24 | `
25 | }
26 | }}
27 | background-color: var(--color-surface-container-lowest);
28 | border-radius: 12px;
29 |
30 | ${mediaQuery.mobileL`
31 | width: 350px;
32 | `}
33 | `
34 |
35 | export const Header = styled(Flex)`
36 | padding: 16px 20px;
37 | `
38 |
39 | export const Title = styled.h2`
40 | ${getTypographyStyles('Headline2_B')}
41 |
42 | color: var(--color-font-primary);
43 | `
44 |
45 | export const Body = styled.div`
46 | display: flex;
47 | flex-direction: column;
48 | gap: 12px;
49 | padding: 16px 20px;
50 | `
51 |
52 | export const Description = styled.div`
53 | margin-bottom: 30px;
54 | `
55 |
56 | export const Text = styled.span`
57 | font-size: 16px;
58 | `
59 |
60 | export const Cancel = styled(Text)`
61 | margin-right: 30px;
62 | cursor: pointer;
63 | `
64 |
65 | export const Submit = styled.div`
66 | text-align: right;
67 | `
68 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/common/FeedItem/hooks/useReadPost.ts:
--------------------------------------------------------------------------------
1 | import { useMutation, useQueryClient } from '@tanstack/react-query'
2 | import produce from 'immer'
3 |
4 | import { CACHE_KEYS } from 'services/cacheKeys'
5 | import { viewItemUsingPOST } from 'services/types/_generated/item'
6 | import type { Feed, SubmitViewedPost } from 'types/feeds'
7 | import { mergeObjectsByMutate } from 'utils/common'
8 |
9 | interface PrevDataType {
10 | pages: Feed[]
11 | pageParams: Array
12 | }
13 |
14 | // TODO: 인자타입 임시 변경. 추후에 useReadPost 자체를 재작성 해야 함. 기존 인자타입 Post (types/feeds)
15 | const useReadPost = (item: { id: number }) => {
16 | const client = useQueryClient()
17 |
18 | const { mutate: handleRead } = useMutation({
19 | mutationKey: CACHE_KEYS.viewItem(item.id),
20 | mutationFn: viewItemUsingPOST,
21 | onSuccess: (data, variables) => {
22 | client.setQueryData(CACHE_KEYS.feeds, (prev) => {
23 | if (!prev) {
24 | return
25 | }
26 | return getAfterReadData(prev, data, variables)
27 | })
28 | },
29 | })
30 |
31 | return {
32 | handleRead,
33 | }
34 | }
35 |
36 | export default useReadPost
37 |
38 | function getAfterReadData(
39 | prev: PrevDataType,
40 | data: SubmitViewedPost,
41 | variables: number
42 | ) {
43 | return produce(prev, (draft) => {
44 | const targetItem = draft.pages
45 | .flatMap((page) => page.items)
46 | .find((item) => item.id === variables)
47 |
48 | if (targetItem) {
49 | mergeObjectsByMutate(targetItem, data)
50 | }
51 | return draft
52 | })
53 | }
54 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/common/Modal/DimmerLayout.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react'
2 | import styled from 'styled-components'
3 |
4 | import { Z_INDEX } from 'styles/constants'
5 | import { useLockBodyScroll } from 'utils/hooks'
6 |
7 | interface Props {
8 | isOpen: boolean
9 | handleClose: VoidFunction
10 | handleOpenedCallback?: VoidFunction
11 | handleClosedCallback?: VoidFunction
12 | children: React.ReactNode
13 | }
14 |
15 | export const DimmerLayout: React.FC = ({
16 | isOpen,
17 | handleClose,
18 | handleOpenedCallback,
19 | handleClosedCallback,
20 | children,
21 | }) => {
22 | useLockBodyScroll()
23 |
24 | useEffect(() => {
25 | if (isOpen) {
26 | handleOpenedCallback?.()
27 | }
28 |
29 | return () => {
30 | handleClosedCallback?.()
31 | }
32 | // eslint-disable-next-line react-hooks/exhaustive-deps
33 | }, [isOpen])
34 |
35 | return (
36 |
37 |
38 | {children}
39 |
40 | )
41 | }
42 |
43 | const Container = styled.div`
44 | position: fixed;
45 | top: 0;
46 | left: 0;
47 | width: 100%;
48 | height: 100%;
49 | z-index: ${Z_INDEX.modal};
50 | `
51 |
52 | const Dimmer = styled.div`
53 | position: absolute;
54 | top: 0;
55 | width: 100%;
56 | height: 100%;
57 | background-color: var(--color-black-fixed);
58 | opacity: 0.25;
59 | `
60 |
61 | const Content = styled.div`
62 | position: absolute;
63 | top: 50%;
64 | left: 50%;
65 | padding: 30px;
66 | transform: translate(-50%, -50%);
67 | border-radius: 10px;
68 | `
69 |
--------------------------------------------------------------------------------
/apps/next-app/src/styles/registry.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import React, { useState } from 'react'
4 | import { useServerInsertedHTML } from 'next/navigation'
5 | import { ServerStyleSheet, StyleSheetManager } from 'styled-components'
6 | import isPropValid from '@emotion/is-prop-valid'
7 |
8 | /** @see https://nextjs.org/docs/app/building-your-application/styling/css-in-js#styled-components */
9 | export default function StyledComponentsRegistry({
10 | children,
11 | }: {
12 | children: React.ReactNode
13 | }) {
14 | // Only create stylesheet once with lazy initial state
15 | // x-ref: https://reactjs.org/docs/hooks-reference.html#lazy-initial-state
16 | const [styledComponentsStyleSheet] = useState(() => new ServerStyleSheet())
17 |
18 | useServerInsertedHTML(() => {
19 | const styles = styledComponentsStyleSheet.getStyleElement()
20 | styledComponentsStyleSheet.instance.clearTag()
21 | return <>{styles}>
22 | })
23 |
24 | if (typeof window !== 'undefined') return <>{children}>
25 |
26 | return (
27 |
31 | <>{children}>
32 |
33 | )
34 | }
35 |
36 | // This implements the default behavior from styled-components v5
37 | function shouldForwardProp(
38 | propName: string,
39 | target: string | React.ElementType
40 | ) {
41 | if (typeof target === 'string') {
42 | // For HTML elements, forward the prop if it is a valid HTML attribute
43 | return isPropValid(propName)
44 | }
45 | // For other elements, forward all props
46 | return true
47 | }
48 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/common/Notification/Notification.style.tsx:
--------------------------------------------------------------------------------
1 | import styled, { keyframes } from 'styled-components'
2 | import Image from 'next/image'
3 |
4 | import { getTypographyStyles } from 'styles/fonts'
5 | import { Z_INDEX } from 'styles/constants'
6 | import { mediaQuery } from 'styles/mediaQuery'
7 |
8 | const FadeOut = keyframes`
9 | 0% {
10 | opacity: 1;
11 | }
12 | 70% {
13 | opacity: 1;
14 | }
15 | 100% {
16 | opacity: 0;
17 | }
18 | `
19 |
20 | export const NotificationWrapper = styled.div<{
21 | duration: number
22 | }>`
23 | ${getTypographyStyles('Body2_M')};
24 |
25 | width: 420px;
26 | height: 90px;
27 | padding: 16px 20px;
28 | border-radius: 32px;
29 | background-color: var(--color-surface-container-highest);
30 |
31 | position: fixed;
32 | bottom: 40px;
33 | left: 40px;
34 | animation-name: ${FadeOut};
35 | animation-duration: ${({ duration }) => `${duration}ms`};
36 | animation-fill-mode: forwards;
37 | z-index: ${Z_INDEX.toast};
38 |
39 | display: flex;
40 | flex-direction: column;
41 | gap: 8px;
42 |
43 | ${mediaQuery.tablet`
44 | width: 100%;
45 | height: min-content;
46 | left: 50%;
47 | transform: translateX(-50%);
48 | `}
49 | `
50 |
51 | export const Title = styled.p`
52 | color: var(--color-font-primary);
53 | ${getTypographyStyles('Headline2_B')};
54 | `
55 |
56 | export const Content = styled.div`
57 | ${getTypographyStyles('Body1_M')};
58 | color: var(--color-font-primary);
59 | `
60 |
61 | export const Icon = styled(Image)`
62 | margin-right: 8px;
63 | `
64 |
65 | export const CloseButton = styled(Image)`
66 | cursor: pointer;
67 | `
68 |
--------------------------------------------------------------------------------
/apps/next-app/src/styles/dark.css:
--------------------------------------------------------------------------------
1 | html.dark,
2 | .dark *:after,
3 | .dark *:before {
4 | --color-surface-dim: #000000;
5 | --color-surface: #1B181A;
6 | --color-surface-bright: #897981;
7 | --color-surface-container-lowest: #2A2528;
8 | --color-surface-container-low: #423C3F;
9 | --color-surface-container: #5F555A;
10 | --color-surface-container-high: #71646A;
11 | --color-surface-container-highest: #897981;
12 | --color-font-primary: #F1EFF0;
13 | --color-font-secondary: #A1929A;
14 | --color-font-tertiary: #897981;
15 | --color-font-disabled: #5F555A;
16 | --color-divider: #5F555A;
17 | --color-varients: #423C3F;
18 | --color-error: #DA6E74;
19 | --color-error-container: #6B090E;
20 | --color-on-error: #000000;
21 | --color-on-error-container: #DA6E74;
22 | --color-primary-50: #451222;
23 | --color-primary-100: #732840;
24 | --color-primary-200: #892B49;
25 | --color-primary-300: #A63057;
26 | --color-primary-400: #C1416F;
27 | --color-primary-500: #D4608F;
28 | --color-primary-600: #E185AD;
29 | --color-primary-700: #ECAFCB;
30 | --color-primary-800: #F5D5E4;
31 | --color-primary-900: #F9EAF2;
32 | --color-primary-1000: #FBF4F7;
33 | --color-white: #000000;
34 | --color-white-fixed: #ffffff;
35 | --color-gray-50: #2A2528;
36 | --color-gray-100: #423C3F;
37 | --color-gray-200: #5F555A;
38 | --color-gray-300: #71646A;
39 | --color-gray-400: #897981;
40 | --color-gray-500: #A1929A;
41 | --color-gray-600: #BAAFB4;
42 | --color-gray-700: #D4CDD1;
43 | --color-gray-800: #E9E5E7;
44 | --color-gray-900: #F1EFF0;
45 | --color-gray-1000: #F9F7F8;
46 | --color-black: #FFFFFF;
47 | --color-black-fixed: #000000;
48 | }
49 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/common/Modal/useModal.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | cloneElement,
3 | isValidElement,
4 | useCallback,
5 | useState,
6 | useEffect,
7 | useRef,
8 | } from 'react'
9 |
10 | import Portal from '../Portal'
11 | import { DimmerLayout } from './DimmerLayout'
12 |
13 | interface Props {
14 | content: React.ReactNode
15 | handleOpenedCallback?: VoidFunction
16 | handleClosedCallback?: VoidFunction
17 | }
18 |
19 | export const useModal = ({
20 | handleClosedCallback,
21 | handleOpenedCallback,
22 | content,
23 | }: Props) => {
24 | const [isOpen, setIsOpen] = useState(false)
25 | const [isMounted, setIsMounted] = useState(false)
26 | const portalRef = useRef(null)
27 |
28 | useEffect(() => {
29 | portalRef.current = document.querySelector('#modal')
30 | setIsMounted(true)
31 | }, [])
32 |
33 | const handleOpen = useCallback(() => {
34 | setIsOpen(true)
35 | }, [])
36 |
37 | const handleClose = useCallback(() => {
38 | setIsOpen(false)
39 | }, [])
40 |
41 | const renderModal = () =>
42 | isMounted &&
43 | isOpen && (
44 |
45 |
51 | {isValidElement<{ onClose: VoidFunction }>(content)
52 | ? cloneElement(content, { onClose: handleClose })
53 | : content}
54 |
55 |
56 | )
57 |
58 | return {
59 | handleOpen,
60 | handleClose,
61 | renderModal,
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/apps/next-app/src/styles/light.css:
--------------------------------------------------------------------------------
1 | html.light,
2 | .light *:after,
3 | .light *:before {
4 | --color-surface-dim: rgba(0, 0, 0, 0.5);
5 | --color-surface: #ece9ea;
6 | --color-surface-bright: #ece9ea;
7 | --color-surface-container-lowest: #ffffff;
8 | --color-surface-container-low: #f9f7f8;
9 | --color-surface-container: #f1eff0;
10 | --color-surface-container-high: #e9e5e7;
11 | --color-surface-container-highest: #d4cdd1;
12 | --color-font-primary: #423c3f;
13 | --color-font-secondary: #71646a;
14 | --color-font-tertiary: #897981;
15 | --color-font-disabled: #a1929a;
16 | --color-divider: #e9e5e7;
17 | --color-varients: #f1eff0;
18 | --color-error: #a12027;
19 | --color-error-container: #f9dbd7;
20 | --color-on-error: #ffffff;
21 | --color-on-error-container: #a12027;
22 | --color-primary-50: #fbf4f7;
23 | --color-primary-100: #f9eaf2;
24 | --color-primary-200: #f5d5e4;
25 | --color-primary-300: #ecafcb;
26 | --color-primary-400: #e185ad;
27 | --color-primary-500: #d4608f;
28 | --color-primary-600: #c1416f;
29 | --color-primary-700: #a63057;
30 | --color-primary-800: #892b49;
31 | --color-primary-900: #732840;
32 | --color-primary-1000: #451222;
33 | --color-white: #ffffff;
34 | --color-white-fixed: #ffffff;
35 | --color-gray-50: #f9f7f8;
36 | --color-gray-100: #f1eff0;
37 | --color-gray-200: #e9e5e7;
38 | --color-gray-300: #d4cdd1;
39 | --color-gray-400: #baafb4;
40 | --color-gray-500: #a1929a;
41 | --color-gray-600: #897981;
42 | --color-gray-700: #71646a;
43 | --color-gray-800: #5f555a;
44 | --color-gray-900: #423c3f;
45 | --color-gray-1000: #2a2528;
46 | --color-black: #000000;
47 | --color-black-fixed: #000000;
48 | }
49 |
--------------------------------------------------------------------------------
/apps/next-app/src/features/post/ui/PostFeedItem/PostFeedItem.style.tsx:
--------------------------------------------------------------------------------
1 | import Image from 'next/image'
2 | import styled from 'styled-components'
3 |
4 | import { ellipsis, getTypographyStyles } from 'styles/fonts'
5 |
6 | export const Container = styled.div`
7 | border-top-right-radius: 32px;
8 | border-top-left-radius: 32px;
9 | border-bottom-right-radius: 32px;
10 | border-bottom-left-radius: 0px;
11 | overflow: hidden;
12 | `
13 |
14 | export const Body = styled.div`
15 | background-color: var(--color-surface-container-lowest);
16 | padding: 20px;
17 | display: flex;
18 | justify-content: space-between;
19 | `
20 |
21 | export const Footer = styled.div`
22 | display: flex;
23 | align-items: center;
24 | justify-content: space-between;
25 | padding: 12px 20px 16px;
26 | background-color: var(--color-surface-container-lowest);
27 | border-top: 1px solid var(--color-divider);
28 | `
29 |
30 | export const Title = styled.p`
31 | ${getTypographyStyles('Headline3_B')};
32 | ${ellipsis(1)};
33 | color: var(--color-font-primary);
34 | `
35 |
36 | export const Contents = styled.p`
37 | ${getTypographyStyles('Body1_M')};
38 | ${ellipsis(2)};
39 | color: var(--color-font-secondary);
40 | `
41 |
42 | export const Thumbnail = styled.img`
43 | object-fit: cover;
44 | border-radius: 16px;
45 | `
46 |
47 | export const ChannelTitle = styled.p`
48 | cursor: pointer;
49 | ${getTypographyStyles('Body2_B')};
50 | color: var(--color-font-tertiary);
51 | `
52 |
53 | export const SubText = styled.p`
54 | ${getTypographyStyles('Body2_M')};
55 | color: var(--color-font-tertiary);
56 | `
57 |
58 | export const ImageButton = styled(Image)`
59 | cursor: pointer;
60 | `
61 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/common/FeedItem/FeedItem.tsx:
--------------------------------------------------------------------------------
1 | import { SwitchCase } from '@toss/react'
2 |
3 | import { useGetUserProfile } from 'features/user/userProfile'
4 | import type { Post, PrivatePost } from 'types/feeds'
5 | import type { PrivateChannel, Channel } from 'types/subscriptions'
6 | import { PrivatePostType, PublicPostType } from './Post'
7 | import { PrivateChannelType, PublicChannelType } from './Channel'
8 |
9 | type Props =
10 | | {
11 | type: 'post'
12 | item: Post | PrivatePost
13 | isPrivate?: boolean
14 | }
15 | | {
16 | type: 'channel'
17 | item: Channel | PrivateChannel
18 | isPrivate?: boolean
19 | }
20 |
21 | const FeedItem = ({ type, item, isPrivate }: Props) => {
22 | const { data: userProfile } = useGetUserProfile()
23 | const _isPrivate = isPrivate ?? userProfile
24 |
25 | if (type === 'post') {
26 | return (
27 | ,
31 | 'post/private': ,
32 | }}
33 | defaultComponent={null}
34 | />
35 | )
36 | }
37 | if (type === 'channel') {
38 | return (
39 | ,
43 | 'channel/private': (
44 |
45 | ),
46 | }}
47 | defaultComponent={null}
48 | />
49 | )
50 | }
51 | return null
52 | }
53 |
54 | export default FeedItem
55 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/common/Flex/index.tsx:
--------------------------------------------------------------------------------
1 | import styled, { css } from 'styled-components'
2 |
3 | type Justify = 'start' | 'end' | 'center' | 'between' | 'around'
4 | type Align = 'start' | 'end' | 'center' | 'baseline'
5 | type Direction = 'row' | 'column'
6 |
7 | interface Props extends React.HTMLAttributes {
8 | children: React.ReactNode
9 | gap?: number
10 | justify?: Justify
11 | align?: Align
12 | direction?: Direction
13 | style?: React.CSSProperties
14 | }
15 |
16 | const Flex = ({
17 | gap,
18 | justify,
19 | align,
20 | direction,
21 | children,
22 | style,
23 | ...rest
24 | }: Props) => {
25 | return (
26 |
34 | {children}
35 |
36 | )
37 | }
38 |
39 | export default Flex
40 |
41 | const Container = styled.div<{
42 | $gap?: number
43 | $justify?: Justify
44 | $align?: Align
45 | direction?: Direction
46 | }>`
47 | display: flex;
48 | flex-direction: ${({ direction = 'row' }) => direction};
49 | justify-content: ${({ $justify = 'start' }) => justifyContent[$justify]};
50 | align-items: ${({ $align = 'start' }) => alignItems[$align]};
51 | ${({ $gap }) => css`
52 | gap: ${$gap}px;
53 | `}
54 | `
55 |
56 | const justifyContent = {
57 | start: 'flex-start',
58 | end: 'flex-end',
59 | center: 'center',
60 | between: 'space-between',
61 | around: 'space-around',
62 | } as const
63 |
64 | const alignItems = {
65 | start: 'flex-start',
66 | end: 'flex-end',
67 | center: 'center',
68 | baseline: 'baseline',
69 | } as const
70 |
--------------------------------------------------------------------------------
/apps/next-app/src/assets/icons/rss-circle.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
48 |
--------------------------------------------------------------------------------
/apps/next-app/src/assets/icons/dot-vertical.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/apps/next-app/src/components/common/Toast/methods.tsx:
--------------------------------------------------------------------------------
1 | import type { ToastProps } from './Toast'
2 | import { ToastElement } from './Toast'
3 | import { renderImperatively, type ImperativeHandler } from 'utils/popUp'
4 |
5 | let currentHandler: ImperativeHandler | null = null
6 | let currentTimeout: number | null = null
7 |
8 | export type ToastHandler = {
9 | close: () => void
10 | }
11 |
12 | const ToastInner = (props: ToastProps & { onClose?: () => void }) => (
13 |
14 | )
15 |
16 | const defaultProps: Partial = {
17 | duration: 2000,
18 | position: 'bottom',
19 | }
20 |
21 | export const show = (toastProps: ToastProps) => {
22 | const props = {
23 | ...defaultProps,
24 | ...toastProps,
25 | }
26 |
27 | const element = (
28 | {
31 | currentHandler = null
32 | }}
33 | />
34 | )
35 |
36 | if (currentHandler) {
37 | currentHandler.replace(element)
38 | } else {
39 | currentHandler = renderImperatively(element)
40 | }
41 |
42 | if (currentTimeout) {
43 | window.clearTimeout(currentTimeout)
44 | }
45 |
46 | if (props.duration !== 0) {
47 | currentTimeout = window.setTimeout(() => {
48 | clear()
49 | }, props.duration)
50 | }
51 |
52 | return currentHandler as ToastHandler
53 | }
54 |
55 | export const clear = () => {
56 | currentHandler?.close()
57 | currentHandler = null
58 | }
59 |
60 | export const config = (props: Pick) => {
61 | if (props.duration !== undefined) {
62 | defaultProps.duration = props.duration
63 | }
64 | if (props.position !== undefined) {
65 | defaultProps.position = props.position
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/apps/next-app/src/features/errors/globalQueryErrorHandler.ts:
--------------------------------------------------------------------------------
1 | import type { Query, QueryClient } from '@tanstack/react-query'
2 | import { AxiosError } from 'axios'
3 |
4 | import Toast from 'components/common/Toast'
5 | import { PRIVATE_ROUTE, ROUTE } from 'constants/route'
6 | import { userQueries } from 'entities/user/api'
7 | import { CACHE_KEYS } from 'services/cacheKeys'
8 | import { RESPONSE_CODE } from 'types/common'
9 | import { isServer } from 'utils'
10 | import { destroyTokensClientSide } from 'utils/auth'
11 |
12 | export const globalQueryErrorHandler = (
13 | err: unknown,
14 | query: Query,
15 | queryClient: QueryClient
16 | ) => {
17 | if (err instanceof AxiosError) {
18 | const code = err.response?.data?.code
19 |
20 | if (isDestroyTokenError(code)) {
21 | destroyTokensClientSide()
22 | queryClient.invalidateQueries(userQueries.me())
23 | }
24 | const isClient = !isServer()
25 | const ignoreToast = query.meta?.ignoreToast
26 |
27 | if (isClient && !ignoreToast) {
28 | Toast.show({
29 | type: 'error',
30 | content: err.response?.data.message ?? '에러가 발생했습니다.',
31 | })
32 | }
33 |
34 | if (isClient && isPrivatePath()) {
35 | goToIntroducePage()
36 | }
37 | }
38 | }
39 |
40 | const goToIntroducePage = () => {
41 | const isClient = !isServer()
42 | if (isClient) {
43 | window.location.href = ROUTE.INTRODUCE
44 | }
45 | }
46 |
47 | const isPrivatePath = () =>
48 | Object.values(PRIVATE_ROUTE).find((path) =>
49 | window.location.pathname.includes(path)
50 | )
51 |
52 | const isDestroyTokenError = (code: string) =>
53 | [
54 | RESPONSE_CODE.REFRESH_TOKEN_NOT_FOUND,
55 | RESPONSE_CODE.EXPIRED_REFRESH_TOKEN,
56 | ].includes(code)
57 |
--------------------------------------------------------------------------------
/apps/next-app/src/assets/icons/menu_icon.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/apps/next-app/src/pages/oauth/index.tsx:
--------------------------------------------------------------------------------
1 | import { useRouter } from 'next/router'
2 | import { useQuery, useQueryClient } from '@tanstack/react-query'
3 | import qs from 'query-string'
4 | import humps from 'humps'
5 | import { useEffect } from 'react'
6 |
7 | import { loginUsingPOST } from 'services/types/_generated/user'
8 | import { CACHE_KEYS } from 'services/cacheKeys'
9 | import {
10 | setAccessTokenToCookie,
11 | setRefreshTokenToCookie,
12 | } from 'features/auth/token'
13 |
14 | const Oauth = () => {
15 | const router = useRouter()
16 | const client = useQueryClient()
17 |
18 | const { data, isError } = useQuery({
19 | queryKey: CACHE_KEYS.signup,
20 | queryFn: () =>
21 | loginUsingPOST({ accessToken: parseAccessToken(router.asPath) }),
22 | })
23 |
24 | useEffect(() => {
25 | if (data) {
26 | setRefreshTokenToCookie(data.refreshToken)
27 | setAccessTokenToCookie(data.accessToken)
28 | client.setQueryData(CACHE_KEYS.me, data)
29 | router.replace('/')
30 | }
31 | }, [data])
32 |
33 | useEffect(() => {
34 | if (isError) {
35 | alert('로그인에 실패했습니다. 다시 시도해주세요.')
36 | router.replace('/')
37 | }
38 | }, [isError])
39 |
40 | return null
41 | }
42 |
43 | export default Oauth
44 |
45 | const parseAccessToken = (asPath: string) => {
46 | const { fragmentIdentifier } = qs.parseUrl(asPath, {
47 | parseFragmentIdentifier: true,
48 | })
49 | if (!fragmentIdentifier) {
50 | throw new Error('No fragment identifier')
51 | }
52 | const query = humps.camelizeKeys(qs.parse(fragmentIdentifier)) as {
53 | accessToken: string
54 | authuser: string
55 | expiresIn: string
56 | prompt: string
57 | scope: string
58 | tokenType: string
59 | }
60 | return query.accessToken
61 | }
62 |
--------------------------------------------------------------------------------
/apps/next-app/src/assets/icons/cancel.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/apps/next-app/src/assets/icons/trashcan.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------