├── .github └── workflows │ ├── BUILD_BE_BASE_IMAGE.yml │ ├── BUILD_BE_IMAGE.yml │ ├── BUILD_FE_IMAGE.yml │ ├── CI_BE.yml │ └── CI_FE.yml ├── .gitignore ├── LICENSE ├── README.md ├── backend ├── Dockerfile ├── DockerfileBase └── src │ ├── .gitignore │ ├── README.md │ ├── board │ ├── __init__.py │ ├── admin │ │ ├── __init__.py │ │ ├── auth.py │ │ ├── comment.py │ │ ├── connection.py │ │ ├── developer.py │ │ ├── device.py │ │ ├── form.py │ │ ├── image.py │ │ ├── invitation.py │ │ ├── notify.py │ │ ├── post.py │ │ ├── referer.py │ │ ├── report.py │ │ ├── search.py │ │ ├── series.py │ │ ├── service.py │ │ ├── service │ │ │ ├── __init__.py │ │ │ ├── display__service.py │ │ │ └── link__service.py │ │ ├── tag.py │ │ └── user.py │ ├── apps.py │ ├── constants │ │ └── config_meta.py │ ├── feeds.py │ ├── forms.py │ ├── models.py │ ├── modules │ │ ├── analytics.py │ │ ├── notify.py │ │ ├── paginator.py │ │ ├── post_description.py │ │ ├── requests.py │ │ ├── response.py │ │ └── time.py │ ├── schema │ │ ├── __init__.py │ │ └── types.py │ ├── sitemaps.py │ ├── templates │ │ └── robots.txt │ ├── tests.py │ ├── urls.py │ └── views │ │ └── api │ │ └── v1 │ │ ├── __init__.py │ │ ├── auth.py │ │ ├── auth__spec.py │ │ ├── comment.py │ │ ├── comment__spec.py │ │ ├── form.py │ │ ├── form__spec.py │ │ ├── image.py │ │ ├── invitation.py │ │ ├── invitation__spec.py │ │ ├── post.py │ │ ├── post__spec.py │ │ ├── report.py │ │ ├── search.py │ │ ├── search__spec.py │ │ ├── series.py │ │ ├── setting.py │ │ ├── setting__spec.py │ │ ├── tag.py │ │ ├── tag__spec.py │ │ ├── telegram.py │ │ ├── temp_post.py │ │ ├── temp_post__spec.py │ │ └── user.py │ ├── main │ ├── middleware │ │ ├── __init__.py │ │ ├── access_admin.py │ │ ├── access_sitemap.py │ │ ├── disable_csrf.py │ │ └── query_debugger.py │ ├── settings.py │ ├── urls.py │ └── wsgi.py │ ├── manage.py │ ├── modules │ ├── challenge.py │ ├── cipher.py │ ├── discord.py │ ├── hash.py │ ├── markdown.py │ ├── oauth.py │ ├── randomness.py │ ├── scrap.py │ ├── sub_task.py │ ├── sysutil.py │ └── telegram.py │ ├── requirements.txt │ ├── static │ ├── assets │ │ └── images │ │ │ ├── 403.jpg │ │ │ ├── default-avatar.jpg │ │ │ ├── default-cover-1.jpg │ │ │ ├── default-cover-2.jpg │ │ │ ├── default-cover-3.jpg │ │ │ ├── default-cover-4.jpg │ │ │ ├── default-cover-5.jpg │ │ │ ├── default-cover-6.jpg │ │ │ └── ghost.jpg │ └── robots.txt │ ├── utility │ ├── cleaner │ │ ├── admin_log.py │ │ ├── bot.py │ │ ├── content_image.py │ │ ├── referer.py │ │ └── title_image.py │ ├── create_user.py │ ├── dash_board.py │ ├── make_superuser.py │ └── password_reset.py │ └── uwsgi_params ├── dev-tools ├── core.ts ├── deploy.ts ├── dev.ts ├── sample │ ├── backend │ │ └── .env │ └── frontend │ │ └── .env └── script │ ├── deploy.sh │ └── dev.sh ├── docker-compose.bak.yml ├── docker-compose.dev.yml ├── docker-compose.yml ├── documents ├── Architecture-Decision-Records.md ├── Architecture.md └── Tech-Stack.md ├── frontend ├── Dockerfile └── src │ ├── .babelrc │ ├── .eslintrc │ ├── .gitignore │ ├── .npmrc │ ├── README.md │ ├── components │ ├── design-system │ │ ├── atoms │ │ │ ├── Accordion │ │ │ │ ├── Accordion.module.scss │ │ │ │ └── index.tsx │ │ │ ├── Alert │ │ │ │ ├── Alert.module.scss │ │ │ │ └── index.tsx │ │ │ ├── Badge │ │ │ │ ├── Badge.module.scss │ │ │ │ └── index.tsx │ │ │ ├── Breadcrumb │ │ │ │ ├── Breadcrumb.module.scss │ │ │ │ └── index.tsx │ │ │ ├── Button │ │ │ │ ├── Button.module.scss │ │ │ │ └── index.tsx │ │ │ ├── Card │ │ │ │ ├── Card.module.scss │ │ │ │ └── index.tsx │ │ │ ├── Carousel │ │ │ │ ├── Carousel.module.scss │ │ │ │ └── index.tsx │ │ │ ├── Container │ │ │ │ ├── Container.module.scss │ │ │ │ └── index.tsx │ │ │ ├── Dropdown │ │ │ │ ├── Dropdown.module.scss │ │ │ │ └── index.tsx │ │ │ ├── Flex │ │ │ │ ├── Flex.module.scss │ │ │ │ └── index.tsx │ │ │ ├── GlitchText │ │ │ │ ├── GlitchText.module.scss │ │ │ │ └── index.tsx │ │ │ ├── Grid │ │ │ │ ├── Grid.module.scss │ │ │ │ └── index.tsx │ │ │ ├── ImageCard │ │ │ │ ├── ImageCard.module.scss │ │ │ │ └── index.tsx │ │ │ ├── ImageInput │ │ │ │ ├── ImageInput.module.scss │ │ │ │ └── index.tsx │ │ │ ├── ImagePreload │ │ │ │ ├── ImagePreload.module.scss │ │ │ │ └── index.tsx │ │ │ ├── LazyLoadedImage │ │ │ │ ├── LazyLoadedImage.module.scss │ │ │ │ └── index.tsx │ │ │ ├── Loading │ │ │ │ ├── Loading.module.scss │ │ │ │ └── index.tsx │ │ │ ├── Masonry │ │ │ │ └── index.tsx │ │ │ ├── PageNavigation │ │ │ │ ├── PageNavigation.module.scss │ │ │ │ └── index.tsx │ │ │ ├── PopOver │ │ │ │ ├── PopOver.module.scss │ │ │ │ └── index.tsx │ │ │ ├── Progress │ │ │ │ ├── Progress.module.scss │ │ │ │ ├── ProgressBar.tsx │ │ │ │ ├── ProgressTimer.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── types.ts │ │ │ ├── SpeechBubble │ │ │ │ ├── SpeechBubble.module.scss │ │ │ │ └── index.tsx │ │ │ ├── SplitLine │ │ │ │ ├── SplitLine.module.scss │ │ │ │ └── index.tsx │ │ │ ├── Table │ │ │ │ ├── Table.module.scss │ │ │ │ └── index.tsx │ │ │ ├── Text │ │ │ │ ├── Text.module.scss │ │ │ │ └── index.tsx │ │ │ ├── Toggle │ │ │ │ ├── Toggle.module.scss │ │ │ │ └── index.tsx │ │ │ └── index.ts │ │ ├── forms │ │ │ ├── BaseInput │ │ │ │ ├── BaseInput.module.scss │ │ │ │ └── index.tsx │ │ │ ├── Checkbox │ │ │ │ ├── Checkbox.module.scss │ │ │ │ └── index.tsx │ │ │ ├── DateInput │ │ │ │ ├── DateInput.module.scss │ │ │ │ └── index.tsx │ │ │ ├── ErrorMessage │ │ │ │ ├── ErrorMessage.module.scss │ │ │ │ └── index.tsx │ │ │ ├── FormControl │ │ │ │ ├── FormControl.module.scss │ │ │ │ └── index.tsx │ │ │ ├── KeywordInput │ │ │ │ └── index.tsx │ │ │ ├── Label │ │ │ │ ├── Label.module.scss │ │ │ │ └── index.tsx │ │ │ └── index.ts │ │ ├── index.tsx │ │ ├── layouts │ │ │ ├── DualWidgetLayout │ │ │ │ ├── DualWidgetLayout.module.scss │ │ │ │ └── index.tsx │ │ │ ├── PageNavigationLayout │ │ │ │ ├── PageNavigationLayout.module.scss │ │ │ │ └── index.tsx │ │ │ ├── SingleWidgetLayout │ │ │ │ ├── SingleWidgetLayout.module.scss │ │ │ │ └── index.tsx │ │ │ └── index.ts │ │ └── molecules │ │ │ ├── BadgeGroup │ │ │ └── index.tsx │ │ │ ├── CapsuleCard │ │ │ ├── CapsuleCard.module.scss │ │ │ └── index.tsx │ │ │ ├── Modal │ │ │ ├── Modal.module.scss │ │ │ └── index.tsx │ │ │ ├── SortableItem │ │ │ └── index.tsx │ │ │ ├── VerticalSortable │ │ │ └── index.tsx │ │ │ └── index.tsx │ ├── global.d.ts │ ├── index.ts │ ├── system-design │ │ ├── article-detail-page │ │ │ ├── article-action │ │ │ │ ├── ArticleAction.module.scss │ │ │ │ └── index.tsx │ │ │ ├── article-author │ │ │ │ ├── ArticleAuthor.module.scss │ │ │ │ └── index.tsx │ │ │ ├── article-comment │ │ │ │ ├── comment-card │ │ │ │ │ ├── CommentCard.module.scss │ │ │ │ │ └── index.tsx │ │ │ │ ├── comment-editor │ │ │ │ │ └── index.tsx │ │ │ │ ├── comment-form │ │ │ │ │ └── index.tsx │ │ │ │ └── index.tsx │ │ │ ├── article-content │ │ │ │ ├── ArticleContent.module.scss │ │ │ │ └── index.tsx │ │ │ ├── article-cover │ │ │ │ ├── ArticleCover.module.scss │ │ │ │ └── index.tsx │ │ │ ├── article-nav │ │ │ │ ├── ArticleNav.module.scss │ │ │ │ └── index.tsx │ │ │ ├── article-report │ │ │ │ ├── ArticleReport.module.scss │ │ │ │ └── index.tsx │ │ │ ├── article-series │ │ │ │ ├── ArticleSeries.module.scss │ │ │ │ └── index.tsx │ │ │ ├── article-thanks │ │ │ │ └── index.tsx │ │ │ ├── index.tsx │ │ │ └── related-articles │ │ │ │ ├── RelatedArticles.module.scss │ │ │ │ └── index.tsx │ │ ├── article-editor-page │ │ │ ├── editor-content │ │ │ │ ├── EditorContent.module.scss │ │ │ │ ├── EditorContent.tsx │ │ │ │ └── index.tsx │ │ │ ├── editor-layout │ │ │ │ └── index.tsx │ │ │ ├── editor-title │ │ │ │ ├── EditorTitle.module.scss │ │ │ │ └── index.tsx │ │ │ ├── index.ts │ │ │ └── modals │ │ │ │ ├── forms.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── temp-article.tsx │ │ │ │ └── youtube.tsx │ │ ├── article │ │ │ ├── CapsuleArticleCard │ │ │ │ ├── CapsuleArticleCard.module.scss │ │ │ │ └── index.tsx │ │ │ ├── article-card-group │ │ │ │ ├── ArticleCardGroup.module.scss │ │ │ │ └── index.tsx │ │ │ ├── article-card-list │ │ │ │ ├── ArticleCardList.module.scss │ │ │ │ └── index.tsx │ │ │ ├── article-card │ │ │ │ ├── ArticleCard.module.scss │ │ │ │ └── index.tsx │ │ │ ├── collection-layout │ │ │ │ └── index.tsx │ │ │ └── index.tsx │ │ ├── profile │ │ │ ├── index.tsx │ │ │ ├── profile-layout │ │ │ │ ├── Layout.module.scss │ │ │ │ └── index.tsx │ │ │ ├── profile-navigation │ │ │ │ ├── Navigation.module.scss │ │ │ │ └── index.tsx │ │ │ ├── recent-activity │ │ │ │ ├── RecentActivity.module.scss │ │ │ │ ├── activity-item.tsx │ │ │ │ └── index.tsx │ │ │ ├── user-articles │ │ │ │ └── index.tsx │ │ │ └── user-series │ │ │ │ └── index.tsx │ │ ├── series │ │ │ ├── index.tsx │ │ │ └── series-article-card │ │ │ │ ├── SeriesArticleCard.module.scss │ │ │ │ └── index.tsx │ │ ├── setting │ │ │ ├── index.tsx │ │ │ ├── setting-layout │ │ │ │ ├── SettingLayout.module.scss │ │ │ │ └── index.tsx │ │ │ └── setting-navigation │ │ │ │ ├── SettingNavigation.module.scss │ │ │ │ └── index.tsx │ │ ├── shared │ │ │ ├── adsense.tsx │ │ │ ├── day-night │ │ │ │ ├── DayNight.module.scss │ │ │ │ └── index.tsx │ │ │ ├── footer │ │ │ │ ├── Footer.module.scss │ │ │ │ └── index.tsx │ │ │ ├── heatmap │ │ │ │ ├── Heatmap.module.scss │ │ │ │ └── index.tsx │ │ │ ├── index.tsx │ │ │ ├── modals │ │ │ │ ├── account-create-modal.tsx │ │ │ │ ├── account-delete-modal.tsx │ │ │ │ ├── auth-get-modal.tsx │ │ │ │ ├── help-modal.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── styles.module.scss │ │ │ │ ├── two-factor-auth-get-modal.tsx │ │ │ │ └── two-factor-auth-sync-modal.tsx │ │ │ ├── pagination │ │ │ │ ├── Pagination.module.scss │ │ │ │ └── index.tsx │ │ │ ├── search-box │ │ │ │ ├── SearchBox.module.scss │ │ │ │ └── index.tsx │ │ │ ├── seo │ │ │ │ └── index.tsx │ │ │ ├── social │ │ │ │ ├── Social.module.scss │ │ │ │ └── index.tsx │ │ │ ├── subscribe-button │ │ │ │ └── index.tsx │ │ │ └── top-navigation │ │ │ │ ├── TopNavigation.module.scss │ │ │ │ └── index.tsx │ │ ├── tag │ │ │ ├── index.tsx │ │ │ └── tag-card │ │ │ │ └── index.tsx │ │ └── widgets │ │ │ ├── calendar-widget │ │ │ └── index.tsx │ │ │ ├── index.ts │ │ │ ├── service-info-widget │ │ │ └── index.tsx │ │ │ └── trending-posts-widget │ │ │ └── index.tsx │ └── types.d.ts │ ├── hooks │ ├── use-debounce-value.ts │ ├── use-detect-bottom-approach.ts │ ├── use-fetch.ts │ ├── use-form.ts │ ├── use-hide-primary-button.ts │ ├── use-infinity-scroll.ts │ ├── use-life-cycle.ts │ ├── use-like-post.ts │ └── use-memory-store.ts │ ├── jest.config.ts │ ├── jest.setup.ts │ ├── modules │ ├── api │ │ ├── auth.ts │ │ ├── comments.ts │ │ ├── forms.ts │ │ ├── image.ts │ │ ├── index.ts │ │ ├── invitation.ts │ │ ├── posts.ts │ │ ├── report.ts │ │ ├── request.ts │ │ ├── search.ts │ │ ├── series.ts │ │ ├── setting.ts │ │ ├── tags.ts │ │ ├── telegram.ts │ │ └── users.ts │ ├── library │ │ ├── codemirror.helper.ts │ │ └── codemirror.ts │ ├── middleware │ │ └── author.ts │ ├── optimize │ │ ├── event.ts │ │ └── lazy.ts │ ├── settings.ts │ ├── ui │ │ └── snack-bar │ │ │ ├── index.ts │ │ │ └── style.module.scss │ └── utility │ │ ├── blexer.js │ │ ├── blexer.test.ts │ │ ├── cookie.ts │ │ ├── darkmode.ts │ │ ├── hash.ts │ │ ├── icon-class.ts │ │ ├── image.ts │ │ ├── message.ts │ │ ├── oauth.ts │ │ ├── object.ts │ │ ├── report.ts │ │ └── string.ts │ ├── next-env.d.ts │ ├── next.config.js │ ├── package.json │ ├── pages │ ├── 404.tsx │ ├── [author] │ │ ├── [posturl] │ │ │ ├── analytics.tsx │ │ │ ├── edit.tsx │ │ │ └── index.tsx │ │ ├── about │ │ │ ├── edit.tsx │ │ │ └── index.tsx │ │ ├── index.tsx │ │ ├── posts │ │ │ ├── [tag].tsx │ │ │ └── index.tsx │ │ └── series │ │ │ ├── [seriesurl].tsx │ │ │ ├── create.tsx │ │ │ └── index.tsx │ ├── _app.tsx │ ├── _document.tsx │ ├── api │ │ └── blexer.ts │ ├── favorite.tsx │ ├── forms │ │ ├── [formId] │ │ │ └── edit.tsx │ │ └── write.tsx │ ├── index.tsx │ ├── invitation.tsx │ ├── login │ │ └── callback │ │ │ └── [social].tsx │ ├── search.tsx │ ├── setting │ │ ├── account.tsx │ │ ├── analytics │ │ │ ├── referer.tsx │ │ │ └── views.tsx │ │ ├── forms.tsx │ │ ├── integration │ │ │ └── telegram.tsx │ │ ├── invitation.tsx │ │ ├── notify.tsx │ │ ├── posts │ │ │ ├── draft.tsx │ │ │ ├── index.tsx │ │ │ └── reserved.tsx │ │ ├── profile.tsx │ │ └── series.tsx │ ├── tags │ │ ├── [tag].tsx │ │ └── index.tsx │ ├── verify.tsx │ └── write │ │ └── index.tsx │ ├── pnpm-lock.yaml │ ├── public │ ├── favicon.ico │ ├── fonts │ │ └── SpoqaHanSansNeo │ │ │ ├── SpoqaHanSansNeo-Bold.ttf │ │ │ ├── SpoqaHanSansNeo-Bold.woff │ │ │ ├── SpoqaHanSansNeo-Bold.woff2 │ │ │ ├── SpoqaHanSansNeo-Regular.ttf │ │ │ ├── SpoqaHanSansNeo-Regular.woff │ │ │ └── SpoqaHanSansNeo-Regular.woff2 │ ├── illustrators │ │ ├── about-me.svg │ │ ├── doll-play.svg │ │ ├── egg-hunt.svg │ │ ├── focus.svg │ │ ├── notify.svg │ │ ├── portfolio.svg │ │ ├── to-the-moon.svg │ │ ├── toy-car.svg │ │ └── welcome.svg │ ├── logo114.png │ ├── logo120.png │ ├── logo144.png │ ├── logo152.png │ ├── logo16.png │ ├── logo192.png │ ├── logo32.png │ ├── logo512.png │ ├── logo57.png │ ├── logo72.png │ ├── logo76.png │ ├── logo96.png │ ├── logob.svg │ ├── logow.svg │ └── robots.txt │ ├── stores │ ├── auth.ts │ ├── config.ts │ ├── loading.ts │ └── modal.ts │ ├── styles │ ├── _common │ │ ├── _color.scss │ │ ├── _font.scss │ │ ├── _layout.scss │ │ ├── _margin.scss │ │ └── index.scss │ ├── _fontawesome.override.scss │ ├── _mixin.scss │ ├── _var │ │ ├── _color.scss │ │ ├── _font.scss │ │ ├── _margin.scss │ │ └── index.scss │ └── main.scss │ ├── tsconfig.json │ └── types │ ├── frappe-charts │ └── index.d.ts │ └── style.ts ├── nginx.conf ├── nginx ├── default.conf └── development.conf ├── package-lock.json ├── package.json ├── pnpm-lock.yaml └── tsconfig.json /.github/workflows/BUILD_BE_BASE_IMAGE.yml: -------------------------------------------------------------------------------- 1 | name: BUILD BE BASE IMAGE 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - 'backend/DockerfileBase' 9 | workflow_dispatch: 10 | 11 | jobs: 12 | backend_base_image_build: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | 18 | - name: Set up Docker Buildx 19 | uses: docker/setup-buildx-action@v2 20 | 21 | - name: Login to DockerHub 22 | uses: docker/login-action@v2 23 | with: 24 | username: ${{ secrets.DOCKERHUB_USERNAME }} 25 | password: ${{ secrets.DOCKERHUB_TOKEN }} 26 | 27 | - name: Build and push 28 | uses: docker/build-push-action@v4 29 | with: 30 | context: ./backend 31 | file: ./backend/DockerfileBase 32 | platforms: linux/amd64,linux/arm64/v8 33 | push: true 34 | tags: baealex/blex-backend-base:latest 35 | cache-from: type=gha 36 | cache-to: type=gha,mode=max 37 | -------------------------------------------------------------------------------- /.github/workflows/BUILD_BE_IMAGE.yml: -------------------------------------------------------------------------------- 1 | name: BUILD BE IMAGE 2 | 3 | on: 4 | workflow_run: 5 | workflows: ["BUILD BE BASE IMAGE", "CI BE"] 6 | types: 7 | - completed 8 | branches: 9 | - main 10 | workflow_dispatch: 11 | 12 | jobs: 13 | backend_image_build: 14 | if: ${{ github.event.workflow_run.conclusion == 'success' }} 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | 20 | - name: Set up Docker Buildx 21 | uses: docker/setup-buildx-action@v2 22 | 23 | - name: Login to DockerHub 24 | uses: docker/login-action@v2 25 | with: 26 | username: ${{ secrets.DOCKERHUB_USERNAME }} 27 | password: ${{ secrets.DOCKERHUB_TOKEN }} 28 | 29 | - name: Build and push 30 | uses: docker/build-push-action@v4 31 | with: 32 | context: ./backend 33 | file: ./backend/Dockerfile 34 | platforms: linux/amd64,linux/arm64/v8 35 | push: true 36 | tags: baealex/blex-backend:latest 37 | cache-from: type=gha 38 | cache-to: type=gha,mode=max 39 | -------------------------------------------------------------------------------- /.github/workflows/BUILD_FE_IMAGE.yml: -------------------------------------------------------------------------------- 1 | name: BUILD FE IMAGE 2 | 3 | on: 4 | workflow_run: 5 | workflows: ["CI FE"] 6 | types: 7 | - completed 8 | branches: 9 | - main 10 | workflow_dispatch: 11 | 12 | jobs: 13 | frontend_image_build: 14 | if: ${{ github.event.workflow_run.conclusion == 'success' }} 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | 20 | - name: Set up Docker Buildx 21 | uses: docker/setup-buildx-action@v2 22 | 23 | - name: Login to DockerHub 24 | uses: docker/login-action@v2 25 | with: 26 | username: ${{ secrets.DOCKERHUB_USERNAME }} 27 | password: ${{ secrets.DOCKERHUB_TOKEN }} 28 | 29 | - name: Build and push 30 | uses: docker/build-push-action@v4 31 | with: 32 | context: ./frontend 33 | file: ./frontend/Dockerfile 34 | platforms: linux/amd64,linux/arm64/v8 35 | push: true 36 | tags: baealex/blex-frontend:latest 37 | cache-from: type=gha 38 | cache-to: type=gha,mode=max 39 | -------------------------------------------------------------------------------- /.github/workflows/CI_BE.yml: -------------------------------------------------------------------------------- 1 | name: CI BE 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - 'backend/src/**' 9 | pull_request: 10 | branches: 11 | - main 12 | paths: 13 | - 'backend/src/**' 14 | workflow_dispatch: 15 | 16 | jobs: 17 | backend_ci: 18 | runs-on: ubuntu-latest 19 | 20 | strategy: 21 | max-parallel: 4 22 | matrix: 23 | python-version: [3.12] 24 | 25 | steps: 26 | - uses: actions/checkout@v4 27 | 28 | - name: Load .env file 29 | uses: xom9ikk/dotenv@v2.2.0 30 | with: 31 | path: dev-tools/sample/backend 32 | 33 | - name: Set up Python ${{ matrix.python-version }} 34 | uses: actions/setup-python@v5 35 | with: 36 | python-version: ${{ matrix.python-version }} 37 | 38 | - name: Install 39 | run: | 40 | python -m pip install --upgrade pip 41 | pip install -r requirements.txt 42 | working-directory: backend/src 43 | 44 | - name: Test 45 | run: | 46 | python manage.py test --verbosity 2 47 | working-directory: backend/src 48 | -------------------------------------------------------------------------------- /.github/workflows/CI_FE.yml: -------------------------------------------------------------------------------- 1 | name: CI FE 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - 'frontend/src/**' 9 | pull_request: 10 | branches: 11 | - main 12 | paths: 13 | - 'frontend/src/**' 14 | workflow_dispatch: 15 | 16 | jobs: 17 | frontend_ci: 18 | runs-on: ubuntu-latest 19 | 20 | strategy: 21 | matrix: 22 | node-version: [21.x] 23 | 24 | steps: 25 | - uses: actions/checkout@v4 26 | 27 | - name: Load .env file 28 | uses: xom9ikk/dotenv@v2.2.0 29 | with: 30 | path: dev-tools/sample/frontend 31 | 32 | - name: Use Node.js ${{ matrix.node-version }} 33 | uses: actions/setup-node@v3 34 | with: 35 | node-version: ${{ matrix.node-version }} 36 | 37 | - name: Install 38 | run: npx pnpm i --no-frozen-lockfile 39 | working-directory: frontend/src 40 | 41 | - name: Lint 42 | run: npm run lint 43 | working-directory: frontend/src 44 | 45 | - name: Test 46 | run: npm run test 47 | working-directory: frontend/src 48 | 49 | - name: Build 50 | run: npm run build 51 | working-directory: frontend/src 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | /node_modules 3 | /backend/src/static/images/ 4 | /backend/src/static/assets/admin/ 5 | /backend/src/static/assets/graphene_django/ 6 | /frontend/**.env 7 | /backend/**.env 8 | 9 | /*.sh 10 | /*.log 11 | 12 | DockerfileDev 13 | 14 | deploy.overrides.sh 15 | 16 | .DS_Store 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Jino Bae 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /backend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM baealex/blex-backend-base 2 | 3 | COPY ./src/requirements.txt /app/ 4 | 5 | WORKDIR /app 6 | RUN pip install -r requirements.txt --use-pep517 7 | 8 | COPY ./src /app 9 | 10 | # ENV STATIC_URL= 11 | # RUN python manage.py collectstatic --noinput 12 | 13 | ENTRYPOINT ["uwsgi"] 14 | CMD ["--socket", ":9000", "--module", "main.wsgi", "-b", "32768", "--max-requests", "5000", "--master", "--harakiri", "120", "--vacuum", "--die-on-term", "--no-orphans", "--single-interpreter", "--enable-threads", "--threads", "2"] 15 | -------------------------------------------------------------------------------- /backend/DockerfileBase: -------------------------------------------------------------------------------- 1 | FROM alpine:latest 2 | 3 | ARG PYTHON_VERSION=3.12.5 4 | 5 | RUN apk update && apk add --no-cache \ 6 | build-base \ 7 | libffi-dev \ 8 | openssl-dev \ 9 | zlib-dev \ 10 | bzip2-dev \ 11 | readline-dev \ 12 | sqlite-dev \ 13 | wget \ 14 | curl \ 15 | llvm \ 16 | ncurses \ 17 | ncurses-dev \ 18 | xz \ 19 | tk \ 20 | git \ 21 | ffmpeg \ 22 | bash 23 | 24 | ENV LANG="C.UTF-8" \ 25 | LC_ALL="C.UTF-8" \ 26 | PATH="/root/.pyenv/shims:/root/.pyenv/bin:$PATH" \ 27 | PYENV_ROOT="/root/.pyenv" \ 28 | PYENV_SHELL="bash" 29 | 30 | RUN git clone https://github.com/pyenv/pyenv.git $PYENV_ROOT \ 31 | && cd $PYENV_ROOT \ 32 | && src/configure \ 33 | && make -C src \ 34 | && pyenv install $PYTHON_VERSION \ 35 | && pyenv global $PYTHON_VERSION 36 | 37 | RUN pip install --upgrade pip 38 | 39 | ENV PYTHONIOENCODING=utf-8 40 | -------------------------------------------------------------------------------- /backend/src/README.md: -------------------------------------------------------------------------------- 1 | ### 패키지 업그레이드 2 | 3 | ```bash 4 | pip install $(pip list --outdated --format=columns |tail -n +3|cut -d" " -f1) --upgrade 5 | ``` -------------------------------------------------------------------------------- /backend/src/board/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baealex/BLEX/98dc2a4b9d5258b5302c4c72c63381fadac29ad8/backend/src/board/__init__.py -------------------------------------------------------------------------------- /backend/src/board/admin/__init__.py: -------------------------------------------------------------------------------- 1 | from .auth import * 2 | from .comment import * 3 | from .connection import * 4 | from .developer import * 5 | from .form import * 6 | from .image import * 7 | from .invitation import * 8 | from .notify import * 9 | from .referer import * 10 | from .report import * 11 | from .search import * 12 | from .series import * 13 | from .post import * 14 | from .tag import * 15 | from .user import * 16 | 17 | from django.contrib import admin 18 | from django.contrib.admin.models import LogEntry 19 | 20 | 21 | admin.site.register(LogEntry) 22 | -------------------------------------------------------------------------------- /backend/src/board/admin/auth.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from board.models import TwoFactorAuth, SocialAuth, SocialAuthProvider 4 | 5 | 6 | @admin.register(TwoFactorAuth) 7 | class TwoFactorAuthAdmin(admin.ModelAdmin): 8 | list_display = ['user', 'created_date'] 9 | 10 | def get_form(self, request, obj=None, **kwargs): 11 | kwargs['exclude'] = ['user'] 12 | return super().get_form(request, obj, **kwargs) 13 | 14 | 15 | @admin.register(SocialAuth) 16 | class SocialAuthAdmin(admin.ModelAdmin): 17 | list_display = ['user', 'provider', 'created_date'] 18 | 19 | def get_form(self, request, obj=None, **kwargs): 20 | kwargs['exclude'] = ['user', 'provider', 'uid'] 21 | return super().get_form(request, obj, **kwargs) 22 | 23 | 24 | @admin.register(SocialAuthProvider) 25 | class SocialAuthProviderAdmin(admin.ModelAdmin): 26 | list_display = ['key', 'name', 'icon', 'color'] 27 | list_editable = ['name', 'icon', 'color'] -------------------------------------------------------------------------------- /backend/src/board/admin/connection.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from board.models import TelegramSync 4 | 5 | from .service import AdminLinkService, AdminDisplayService 6 | 7 | 8 | @admin.register(TelegramSync) 9 | class TelegramSyncAdmin(admin.ModelAdmin): 10 | list_display = ['id', 'user_link', 'synced', 'created_date'] 11 | 12 | def get_queryset(self, request): 13 | return super().get_queryset(request).select_related('user') 14 | 15 | def user_link(self, obj): 16 | return AdminLinkService.create_user_link(obj.user) 17 | user_link.short_description = 'user' 18 | 19 | def synced(self, obj: TelegramSync): 20 | return AdminDisplayService.check_mark(obj.tid) 21 | 22 | def get_form(self, request, obj=None, **kwargs): 23 | kwargs['exclude'] = ['user'] 24 | return super().get_form(request, obj, **kwargs) 25 | -------------------------------------------------------------------------------- /backend/src/board/admin/developer.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from board.models import DeveloperToken, DeveloperRequestLog 4 | 5 | 6 | @admin.register(DeveloperToken) 7 | class DeveloperTokenAdmin(admin.ModelAdmin): 8 | list_display = ['user', 'name', 'created_date'] 9 | 10 | def get_form(self, request, obj=None, **kwargs): 11 | kwargs['exclude'] = ['user', 'token'] 12 | return super().get_form(request, obj, **kwargs) 13 | 14 | 15 | @admin.register(DeveloperRequestLog) 16 | class DeveloperRequestLogAdmin(admin.ModelAdmin): 17 | list_display = ['developer', 'endpoint', 'created_date'] 18 | 19 | def get_form(self, request, obj=None, **kwargs): 20 | kwargs['exclude'] = ['user'] 21 | return super().get_form(request, obj, **kwargs) -------------------------------------------------------------------------------- /backend/src/board/admin/device.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from board.models import Device 4 | 5 | 6 | @admin.register(Device) 7 | class DeviceAdmin(admin.ModelAdmin): 8 | list_display = ['ip', 'agent', 'category'] 9 | list_editable = ['category'] 10 | list_filter = ['category'] 11 | list_per_page = 50 12 | 13 | def get_form(self, request, obj=None, **kwargs): 14 | kwargs['exclude'] = ['key'] 15 | return super().get_form(request, obj, **kwargs) -------------------------------------------------------------------------------- /backend/src/board/admin/form.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import reverse 3 | from django.utils.safestring import mark_safe 4 | 5 | from board.models import Form 6 | 7 | from .service import AdminLinkService 8 | 9 | 10 | @admin.register(Form) 11 | class FormAdmin(admin.ModelAdmin): 12 | list_display = ['id', 'user_link', 'title', 'is_public', 'created_date'] 13 | list_per_page = 30 14 | 15 | def get_queryset(self, request): 16 | return super().get_queryset(request).select_related('user') 17 | 18 | def user_link(self, obj): 19 | return AdminLinkService.create_user_link(obj.user) 20 | user_link.short_description = 'user' 21 | 22 | def get_form(self, request, obj=None, **kwargs): 23 | kwargs['exclude'] = ['user'] 24 | return super().get_form(request, obj, **kwargs) -------------------------------------------------------------------------------- /backend/src/board/admin/invitation.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from board.models import Invitation, InvitationRequest 4 | 5 | @admin.register(Invitation) 6 | class InvitationAdmin(admin.ModelAdmin): 7 | list_display = ['id', 'sender', 'receiver', 'created_date'] 8 | list_per_page = 30 9 | 10 | @admin.register(InvitationRequest) 11 | class InvitationRequestAdmin(admin.ModelAdmin): 12 | list_display = ['id','sender','receiver', 'created_date'] 13 | list_per_page = 30 -------------------------------------------------------------------------------- /backend/src/board/admin/notify.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import reverse 3 | from django.utils.safestring import mark_safe 4 | from django.template.defaultfilters import truncatewords 5 | 6 | from board.models import Notify 7 | 8 | from .service import AdminLinkService 9 | 10 | 11 | @admin.register(Notify) 12 | class NotifyAdmin(admin.ModelAdmin): 13 | list_display = ['id', 'user_link', '_content', 'has_read', 'created_date'] 14 | list_display_links = ['_content'] 15 | 16 | def get_queryset(self, request): 17 | return super().get_queryset(request).select_related('user') 18 | 19 | def user_link(self, obj): 20 | return AdminLinkService.create_user_link(obj.user) 21 | user_link.short_description = 'user' 22 | 23 | def _content(self, obj): 24 | return truncatewords(obj.content, 8) 25 | _content.short_description = 'content' 26 | 27 | def save_model(self, request, obj: Notify, form, change): 28 | obj.key = Notify.create_hash_key(user=obj.user, url=obj.url, content=obj.content) 29 | if Notify.objects.filter(key=obj.key).exists(): 30 | return 31 | 32 | super().save_model(request, obj, form, change) 33 | obj.send_notify() 34 | 35 | def get_fieldsets(self, request, obj: Notify): 36 | return ( 37 | (None, { 38 | 'fields': ( 39 | 'user', 40 | 'url', 41 | 'content', 42 | 'has_read', 43 | ) 44 | }), 45 | ) -------------------------------------------------------------------------------- /backend/src/board/admin/report.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from board.models import Report 4 | 5 | from .service import AdminLinkService 6 | 7 | 8 | @admin.register(Report) 9 | class ReportAdmin(admin.ModelAdmin): 10 | list_display = ['user', 'post_link', 'content_link', 'config_link', 'content', 'created_date'] 11 | list_per_page = 50 12 | 13 | def post_link(self, obj): 14 | return AdminLinkService.create_post_link(obj.post) 15 | post_link.short_description = 'post' 16 | 17 | def content_link(self, obj): 18 | return AdminLinkService.create_post_content_link(obj.post.content) 19 | content_link.short_description = 'content' 20 | 21 | def config_link(self, obj): 22 | return AdminLinkService.create_post_config_link(obj.post.config) 23 | config_link.short_description = 'config' 24 | 25 | def get_form(self, request, obj=None, **kwargs): 26 | kwargs['exclude'] = ['user', 'post', 'device'] 27 | return super().get_form(request, obj, **kwargs) 28 | -------------------------------------------------------------------------------- /backend/src/board/admin/search.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.db.models import Count 3 | 4 | from board.models import Search, SearchValue 5 | 6 | 7 | @admin.register(Search) 8 | class SearchAdmin(admin.ModelAdmin): 9 | list_display = ['search_value', 'user', 'created_date'] 10 | list_per_page = 50 11 | 12 | def get_queryset(self, request): 13 | return super().get_queryset(request).select_related('search_value', 'user') 14 | 15 | def get_form(self, request, obj=None, **kwargs): 16 | kwargs['exclude'] = ['search_value', 'user', 'device'] 17 | return super().get_form(request, obj, **kwargs) 18 | 19 | 20 | @admin.register(SearchValue) 21 | class SearchValueAdmin(admin.ModelAdmin): 22 | list_display = ['value', 'reference_count', 'count'] 23 | list_per_page = 50 24 | 25 | def get_queryset(self, request): 26 | return super().get_queryset(request).annotate(count=Count('searches', distinct=True)) 27 | 28 | def count(self, obj): 29 | return obj.count 30 | count.admin_order_field = 'count' 31 | -------------------------------------------------------------------------------- /backend/src/board/admin/service.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baealex/BLEX/98dc2a4b9d5258b5302c4c72c63381fadac29ad8/backend/src/board/admin/service.py -------------------------------------------------------------------------------- /backend/src/board/admin/service/__init__.py: -------------------------------------------------------------------------------- 1 | from .display__service import * 2 | from .link__service import * 3 | -------------------------------------------------------------------------------- /backend/src/board/admin/service/display__service.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from django.utils.safestring import mark_safe 4 | 5 | 6 | class AdminDisplayService: 7 | @staticmethod 8 | def check_mark(obj): 9 | return '✅' if obj else '❌' 10 | 11 | @staticmethod 12 | def html(html, remove_lazy_load=False, use_folding=False): 13 | if remove_lazy_load: 14 | html = re.sub(r' src="[^"]*"', '', html) 15 | html = re.sub(r' data-src="([^"]*)"', r' src="\1"', html) 16 | if use_folding: 17 | html = '
View Detail
' + html + '
' 18 | return mark_safe(html) 19 | 20 | @staticmethod 21 | def image(image_path, image_size='480px'): 22 | return mark_safe(''.format(image_path, image_size)) 23 | 24 | @staticmethod 25 | def video(video_path, image_size='480px'): 26 | return mark_safe(''.format(video_path, image_size)) 27 | 28 | @staticmethod 29 | def link(link, text = '🔗'): 30 | return mark_safe('{}'.format(link, text)) -------------------------------------------------------------------------------- /backend/src/board/admin/service/link__service.py: -------------------------------------------------------------------------------- 1 | from django.urls import reverse 2 | from django.utils.safestring import mark_safe 3 | 4 | class AdminLinkService: 5 | @staticmethod 6 | def create_user_link(user): 7 | return mark_safe('{}'.format(reverse('admin:auth_user_change', args=(user.id,)), user.username)) 8 | 9 | @staticmethod 10 | def create_post_link(post): 11 | return mark_safe('{}'.format(reverse('admin:board_post_change', args=(post.id,)), post.title)) 12 | 13 | @staticmethod 14 | def create_post_content_link(post_content): 15 | return mark_safe('{}'.format(reverse('admin:board_postcontent_change', args=(post_content.id,)), '🚀')) 16 | 17 | @staticmethod 18 | def create_post_config_link(post_config): 19 | return mark_safe('{}'.format(reverse('admin:board_postconfig_change', args=(post_config.id,)), '🚀')) 20 | 21 | @staticmethod 22 | def create_series_link(series): 23 | return mark_safe('{}'.format(reverse('admin:board_series_change', args=(series.id,)), series.name)) 24 | 25 | @staticmethod 26 | def create_referer_from_link(referer_from): 27 | return mark_safe('{}'.format(reverse('admin:board_refererfrom_change', args=(referer_from.id,)), referer_from.title if referer_from.title else referer_from.location)) -------------------------------------------------------------------------------- /backend/src/board/admin/tag.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.db.models import Count 3 | 4 | from board.models import Tag 5 | 6 | 7 | @admin.register(Tag) 8 | class TagAdmin(admin.ModelAdmin): 9 | actions = ['clear'] 10 | 11 | list_display = ['value', 'count'] 12 | list_per_page = 30 13 | 14 | def get_queryset(self, request): 15 | return super().get_queryset(request).annotate(count=Count('posts', distinct=True)) 16 | 17 | def clear(self, request, queryset): 18 | count = 0 19 | for data in queryset: 20 | if data.count == 0: 21 | count += 1 22 | data.delete() 23 | self.message_user(request, f'{len(queryset)}개의 태그중 참조가 없는 {count}개의 태그 삭제') 24 | clear.short_description = '참조가 없는 태그 삭제' 25 | 26 | def count(self, obj): 27 | return obj.count 28 | count.admin_order_field = 'count' -------------------------------------------------------------------------------- /backend/src/board/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class BoardConfig(AppConfig): 5 | name = 'board' 6 | -------------------------------------------------------------------------------- /backend/src/board/modules/notify.py: -------------------------------------------------------------------------------- 1 | from board.models import Notify 2 | 3 | def create_notify(user, url: str, content: str, hidden_key: str = None): 4 | key = Notify.create_hash_key(user=user, url=url, content=content, hidden_key=hidden_key) 5 | 6 | if Notify.objects.filter(key=key).exists(): 7 | return 8 | 9 | new_notify = Notify.objects.create( 10 | user=user, 11 | key=key, 12 | url=url, 13 | content=content, 14 | ) 15 | new_notify.send_notify() 16 | -------------------------------------------------------------------------------- /backend/src/board/modules/paginator.py: -------------------------------------------------------------------------------- 1 | from django.core.paginator import Paginator as _Paginator 2 | from django.http import Http404 3 | 4 | def Paginator(objects, offset, page): 5 | paginator = _Paginator(objects, offset) 6 | 7 | try: 8 | page = int(page) 9 | except: 10 | raise Http404 11 | 12 | if not page or int(page) > paginator.num_pages or int(page) < 1: 13 | raise Http404 14 | 15 | return paginator.get_page(page) -------------------------------------------------------------------------------- /backend/src/board/modules/post_description.py: -------------------------------------------------------------------------------- 1 | from django.utils.html import strip_tags 2 | from django.template.defaultfilters import truncatewords 3 | 4 | def create_post_description(post_content_html: str) -> str: 5 | post_content = strip_tags(post_content_html) 6 | return truncatewords(post_content, 50) 7 | -------------------------------------------------------------------------------- /backend/src/board/modules/requests.py: -------------------------------------------------------------------------------- 1 | def BooleanType(data: str): 2 | return True if data == 'true' else False 3 | -------------------------------------------------------------------------------- /backend/src/board/modules/response.py: -------------------------------------------------------------------------------- 1 | import humps 2 | 3 | from enum import Enum 4 | 5 | from django.http import JsonResponse 6 | 7 | 8 | def CamelizeJsonResponse(obj, json_dumps_params={ 9 | 'ensure_ascii': True 10 | }): 11 | return JsonResponse( 12 | humps.camelize(obj), 13 | json_dumps_params=json_dumps_params 14 | ) 15 | 16 | 17 | def StatusDone(body=None): 18 | return CamelizeJsonResponse({ 19 | 'status': 'DONE', 20 | 'body': body if body else {}, 21 | }) 22 | 23 | 24 | class ErrorCode(Enum): 25 | REQUIRE = 'RE' 26 | REJECT = 'RJ' 27 | EXPIRED = 'EP' 28 | VALIDATE = 'VA' 29 | NEED_LOGIN = 'NL' 30 | AUTHENTICATION = 'AT' 31 | SIZE_OVERFLOW = 'OF' 32 | ALREADY_EXISTS = 'AE' 33 | ALREADY_CONNECTED = 'AC' 34 | ALREADY_DISCONNECTED = 'AU' 35 | ALREADY_VERIFICATION = 'AV' 36 | NEED_TELEGRAM = 'NT' 37 | EMAIL_NOT_MATCH = 'EN' 38 | USERNAME_NOT_MATCH = 'UN' 39 | INVALID_PARAMETER = 'IP' 40 | 41 | 42 | def StatusError(code: ErrorCode, message: str = ''): 43 | return CamelizeJsonResponse({ 44 | 'status': 'ERROR', 45 | 'error_code': 'error:' + code.value, 46 | 'error_message': message, 47 | }) 48 | -------------------------------------------------------------------------------- /backend/src/board/modules/time.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import pytz 3 | 4 | from django.utils import timezone 5 | from django.utils.timesince import timesince 6 | 7 | 8 | def time_stamp(date, kind=''): 9 | if kind == 'grass': 10 | date = date + datetime.timedelta(hours=9) 11 | date = date.replace(hour=12, minute=0, second=0) 12 | 13 | timestamp = str(date.timestamp()).replace('.', '') 14 | timestamp = timestamp + '0' * (16 - len(timestamp)) 15 | return timestamp 16 | 17 | 18 | def time_since(date): 19 | one_year_ago = timezone.now() - datetime.timedelta(days=365) 20 | 21 | if date < one_year_ago: 22 | return date.strftime('%Y. %m. %d.') 23 | 24 | date_since = timesince(date) 25 | if ',' in date_since: 26 | date_since = date_since.split(',')[0] 27 | return f'{date_since} 전' 28 | 29 | 30 | def convert_to_localtime(utctime): 31 | utc = utctime.replace(tzinfo=pytz.UTC) 32 | localtz = utc.astimezone(timezone.get_current_timezone()) 33 | return localtz 34 | -------------------------------------------------------------------------------- /backend/src/board/schema/__init__.py: -------------------------------------------------------------------------------- 1 | import graphene 2 | 3 | from board.models import Post 4 | 5 | from .types import PostType 6 | 7 | class Query(graphene.ObjectType): 8 | posts = graphene.Field(PostType, id=graphene.String(required=True)) 9 | 10 | def resolve_posts(root, info, id): 11 | return Post.objects.get(id=id) 12 | 13 | all_posts = graphene.List(PostType) 14 | 15 | def resolve_all_posts(root, info): 16 | return Post.objects.select_related('author', 'config').all() 17 | 18 | schema = graphene.Schema(query=Query) -------------------------------------------------------------------------------- /backend/src/board/schema/types.py: -------------------------------------------------------------------------------- 1 | from graphene_django import DjangoObjectType 2 | 3 | from board.models import ( 4 | Comment, Config, Form, Notify, Tag, 5 | Post, PostContent, PostConfig, Profile, 6 | Report, Search, Series, User) 7 | 8 | class CommentType(DjangoObjectType): 9 | class Meta: 10 | model = Comment 11 | 12 | class UserConfigType(DjangoObjectType): 13 | class Meta: 14 | model = Config 15 | 16 | class FormType(DjangoObjectType): 17 | class Meta: 18 | model = Form 19 | 20 | class NotifyType(DjangoObjectType): 21 | class Meta: 22 | model = Notify 23 | 24 | class TagType(DjangoObjectType): 25 | class Meta: 26 | model = Tag 27 | 28 | class PostType(DjangoObjectType): 29 | class Meta: 30 | model = Post 31 | 32 | class PostContentType(DjangoObjectType): 33 | class Meta: 34 | model = PostContent 35 | 36 | class PostConfigType(DjangoObjectType): 37 | class Meta: 38 | model = PostConfig 39 | 40 | class ProfileType(DjangoObjectType): 41 | class Meta: 42 | model = Profile 43 | 44 | class ReportType(DjangoObjectType): 45 | class Meta: 46 | model = Report 47 | 48 | class SearchType(DjangoObjectType): 49 | class Meta: 50 | model = Search 51 | 52 | class SeriesType(DjangoObjectType): 53 | class Meta: 54 | model = Series 55 | 56 | class UserType(DjangoObjectType): 57 | class Meta: 58 | model = User 59 | -------------------------------------------------------------------------------- /backend/src/board/templates/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: / -------------------------------------------------------------------------------- /backend/src/board/tests.py: -------------------------------------------------------------------------------- 1 | from board.views.api.v1.auth__spec import * 2 | from board.views.api.v1.comment__spec import * 3 | from board.views.api.v1.form__spec import * 4 | from board.views.api.v1.post__spec import * 5 | from board.views.api.v1.search__spec import * 6 | from board.views.api.v1.setting__spec import * 7 | from board.views.api.v1.tag__spec import * 8 | from board.views.api.v1.temp_post__spec import * 9 | -------------------------------------------------------------------------------- /backend/src/board/views/api/v1/__init__.py: -------------------------------------------------------------------------------- 1 | # export 2 | from .auth import * 3 | from .comment import * 4 | from .form import * 5 | from .image import * 6 | from .invitation import * 7 | from .post import * 8 | from .report import * 9 | from .search import * 10 | from .series import * 11 | from .setting import * 12 | from .tag import * 13 | from .telegram import * 14 | from .temp_post import * 15 | from .user import * 16 | -------------------------------------------------------------------------------- /backend/src/board/views/api/v1/invitation__spec.py: -------------------------------------------------------------------------------- 1 | import json 2 | import datetime 3 | 4 | from unittest.mock import patch 5 | 6 | from django.test import TestCase 7 | 8 | from board.models import User, Invitation 9 | 10 | 11 | class InvitationTestCase(TestCase): 12 | @classmethod 13 | def setUpTestData(cls): 14 | User.objects.create_user( 15 | username='invitation_owner', 16 | password='invitation_owner', 17 | email='invitation_owner@invitation_owner.com', 18 | first_name='Invitation Owner User', 19 | ) 20 | 21 | def test_get_invitation_owner_list(self): 22 | response = self.client.get('/v1/invitation/owners') -------------------------------------------------------------------------------- /backend/src/board/views/api/v1/tag__spec.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | from django.test import TestCase 4 | 5 | from board.models import User, Tag, Invitation 6 | 7 | 8 | class TagTestCase(TestCase): 9 | @classmethod 10 | def setUpTestData(cls): 11 | user = User.objects.create_user( 12 | username='test', 13 | password='test', 14 | email='test@test.com', 15 | first_name='test User', 16 | ) 17 | 18 | Invitation.objects.create( 19 | receiver=user 20 | ) 21 | 22 | Tag.objects.create(value='test1') 23 | Tag.objects.create(value='test2') 24 | Tag.objects.create(value='test3') 25 | 26 | @patch('modules.markdown.parse_to_html', return_value='

Mocked Text

') 27 | def test_create_tag_when_create_post_if_not_exists(self, mock_service): 28 | self.client.login(username='test', password='test') 29 | 30 | response = self.client.post('/v1/posts', { 31 | 'title': 'test', 32 | 'text_md': 'test', 33 | 'is_hide': False, 34 | 'is_advertise': False, 35 | 'tag': 'test3,test4,test5' 36 | }) 37 | self.assertEqual(response.status_code, 200) 38 | 39 | tags = list(Tag.objects.all().values_list('value', flat=True)) 40 | self.assertEqual('test4' in tags, True) 41 | self.assertEqual('test5' in tags, True) 42 | 43 | def test_get_tag_list(self): 44 | response = self.client.get('/v1/tags') 45 | self.assertEqual(response.status_code, 200) 46 | -------------------------------------------------------------------------------- /backend/src/main/middleware/__init__.py: -------------------------------------------------------------------------------- 1 | from .access_admin import AccessAdminOnlyStaff 2 | from .access_sitemap import AccessSitemapOnlyBot 3 | from .disable_csrf import DisableCSRF 4 | from .query_debugger import QueryDebugger -------------------------------------------------------------------------------- /backend/src/main/middleware/access_admin.py: -------------------------------------------------------------------------------- 1 | from django.utils.deprecation import MiddlewareMixin 2 | from django.urls import reverse 3 | from django.http import Http404 4 | 5 | class AccessAdminOnlyStaff(MiddlewareMixin): 6 | def process_request(self, request): 7 | if request.path.startswith(reverse('admin:index')): 8 | if not request.user.is_active: 9 | raise Http404 10 | if not request.user.is_staff: 11 | raise Http404 12 | -------------------------------------------------------------------------------- /backend/src/main/middleware/access_sitemap.py: -------------------------------------------------------------------------------- 1 | from django.utils.deprecation import MiddlewareMixin 2 | from django.urls import reverse 3 | from django.http import Http404 4 | 5 | class AccessSitemapOnlyBot(MiddlewareMixin): 6 | def process_request(self, request): 7 | if request.path.startswith(reverse('sitemap')) \ 8 | or request.path.startswith(reverse('sitemap_section', args=['posts'])) \ 9 | or request.path.startswith(reverse('sitemap_section', args=['series'])) \ 10 | or request.path.startswith(reverse('sitemap_section', args=['user'])): 11 | if not 'bot' in request.META.get('HTTP_USER_AGENT', '').lower(): 12 | raise Http404 13 | -------------------------------------------------------------------------------- /backend/src/main/middleware/disable_csrf.py: -------------------------------------------------------------------------------- 1 | from django.utils.deprecation import MiddlewareMixin 2 | 3 | class DisableCSRF(MiddlewareMixin): 4 | def process_request(self, request): 5 | setattr(request, '_dont_enforce_csrf_checks', True) 6 | -------------------------------------------------------------------------------- /backend/src/main/middleware/query_debugger.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from django.db import connection, reset_queries 4 | from django.utils.deprecation import MiddlewareMixin 5 | 6 | from modules.sysutil import flush_print 7 | 8 | 9 | class QueryDebugger(MiddlewareMixin): 10 | def process_request(self, request): 11 | reset_queries() 12 | self.number_of_start_queries = len(connection.queries) 13 | self.start = time.perf_counter() 14 | 15 | def process_response(self, request, response): 16 | self.end = time.perf_counter() 17 | self.number_of_end_queries = len(connection.queries) 18 | flush_print(f'-------------------------------------------------------------------') 19 | flush_print(f'Request : {request}') 20 | flush_print(f'Number of Queries : {self.number_of_end_queries-self.number_of_start_queries}') 21 | flush_print(f'Finished in : {(self.end - self.start):.2f}s') 22 | flush_print(f'-------------------------------------------------------------------') 23 | return response 24 | -------------------------------------------------------------------------------- /backend/src/main/urls.py: -------------------------------------------------------------------------------- 1 | """main URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/2.0/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.urls import include, path 17 | from django.contrib import admin 18 | from django.views.generic import RedirectView 19 | 20 | urlpatterns = [ 21 | path('djangomyadmin/', admin.site.urls), 22 | path('', include('board.urls')), 23 | ] -------------------------------------------------------------------------------- /backend/src/main/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for main project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.0/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "main.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /backend/src/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "main.settings") 7 | try: 8 | from django.core.management import execute_from_command_line 9 | except ImportError as exc: 10 | raise ImportError( 11 | "Couldn't import Django. Are you sure it's installed and " 12 | "available on your PYTHONPATH environment variable? Did you " 13 | "forget to activate a virtual environment?" 14 | ) from exc 15 | execute_from_command_line(sys.argv) 16 | -------------------------------------------------------------------------------- /backend/src/modules/challenge.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | from django.conf import settings 4 | 5 | def auth_hcaptcha(response): 6 | data = { 7 | 'response': response, 8 | 'secret': settings.HCAPTCHA_SECRET_KEY 9 | } 10 | response = requests.post('https://hcaptcha.com/siteverify', data=data) 11 | if response.json().get('success'): 12 | return True 13 | return False -------------------------------------------------------------------------------- /backend/src/modules/cipher.py: -------------------------------------------------------------------------------- 1 | import base64 2 | 3 | from cryptography.fernet import Fernet 4 | 5 | from django.conf import settings 6 | 7 | 8 | key = base64.urlsafe_b64encode(settings.CIPHER_KEY) 9 | 10 | def encrypt_value(value) -> bytes: 11 | return Fernet(key).encrypt(value.encode()) 12 | 13 | def decrypt_value(value) -> str: 14 | return Fernet(key).decrypt(value).decode() 15 | -------------------------------------------------------------------------------- /backend/src/modules/discord.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | 4 | class Discord: 5 | def send_webhook(url, content): 6 | req_data = {'content': content} 7 | requests.post(url, req_data) 8 | -------------------------------------------------------------------------------- /backend/src/modules/hash.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import hashlib 3 | 4 | from typing import Union 5 | 6 | def get_hash(method, data: Union[str, bytes]) -> str: 7 | if 'str' in str(type(data)): 8 | data = data.encode() 9 | if not 'bytes' in str(type(data)): 10 | raise Exception('data is not string or bytes.') 11 | return base64.b64encode(method(data).digest()).decode() 12 | 13 | def get_md5(data: Union[str, bytes]) -> str: 14 | return get_hash(hashlib.md5, data) 15 | 16 | def get_sha256(data: Union[str, bytes]) -> str: 17 | return get_hash(hashlib.sha256, data) 18 | 19 | def get_sha512(data: Union[str, bytes]) -> str: 20 | return get_hash(hashlib.sha512, data) 21 | 22 | if __name__ == '__main__': 23 | text = 'baealex' 24 | print('baealex md5 hash :', get_md5(text)) 25 | print('baealex sha256 hash :', get_sha256(text)) 26 | print('baealex sha512 hash :', get_sha512(text)) 27 | 28 | print('========================') 29 | 30 | text_bytes = 'baejino'.encode() 31 | print('baejino md5 hash :', get_md5(text_bytes)) 32 | print('baejino sha256 hash :', get_sha256(text_bytes)) 33 | print('baejino sha512 hash :', get_sha512(text_bytes)) -------------------------------------------------------------------------------- /backend/src/modules/markdown.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import requests 4 | 5 | 6 | class ParseData: 7 | def __init__(self, item): 8 | self.text = item['text'] 9 | self.token = item['token'] 10 | 11 | def to_dict(self): 12 | return { 13 | 'text': self.text, 14 | 'token': self.token, 15 | } 16 | 17 | @staticmethod 18 | def from_dict(item): 19 | return ParseData(item) 20 | 21 | 22 | def parse_to_html(api_url: str, data: ParseData): 23 | response = requests.post(f'{api_url}/api/blexer', data=data.to_dict()) 24 | return response.json()['text'] 25 | 26 | 27 | def get_images(markdown: str): 28 | return re.findall(r'!\[[^\]]*\]\((.*?)\s*("(?:.*[^"])")?\s*\)', markdown) 29 | -------------------------------------------------------------------------------- /backend/src/modules/randomness.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | from typing import Union 4 | 5 | def randpick(items: Union[str, list], length: int): 6 | items_len = len(items) - 1 7 | 8 | result = '' 9 | for i in range(length): 10 | result += str(items[random.randint(0, items_len)]) 11 | return result 12 | 13 | def randnum(length: int): 14 | return randpick('0123456789', length) 15 | 16 | def randstr(length: int): 17 | return randpick('0123456789abcdefghijklnmopqrstuvwxyzABCDEFGHIJKLNMOPQRSTUVWXYZ', length) 18 | 19 | if __name__ == '__main__': 20 | print('randpick', randpick([0, 1], 10)) 21 | print('randpick', randpick('!@#$%^&*()', 10)) 22 | print('randnum', randnum(10)) 23 | print('randstr', randstr(20)) -------------------------------------------------------------------------------- /backend/src/modules/sub_task.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from functools import wraps, partial 4 | 5 | 6 | def asynchronously(func): 7 | @wraps(func) 8 | async def coro(*args, loop=None, executor=None, **kwargs): 9 | if loop is None: 10 | loop = asyncio.get_event_loop() 11 | partial_func = partial(func, *args, **kwargs) 12 | return await loop.run_in_executor(executor, partial_func) 13 | return coro 14 | 15 | 16 | class SubTaskProcessor: 17 | @staticmethod 18 | def process(func, *args, **kwargs): 19 | asyncio.run(asynchronously(func)(*args, **kwargs)) 20 | -------------------------------------------------------------------------------- /backend/src/modules/sysutil.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | 5 | def make_path(dir_list): 6 | upload_path = '' 7 | for dir_name in dir_list: 8 | upload_path += dir_name + '/' 9 | if not os.path.exists(upload_path): 10 | os.makedirs(upload_path) 11 | return upload_path 12 | 13 | 14 | def flush_print(*args, **kwargs): 15 | print(*args, **kwargs) 16 | sys.stdout.flush() 17 | -------------------------------------------------------------------------------- /backend/src/modules/telegram.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import json 3 | 4 | class TelegramBot: 5 | def __init__(self, token): 6 | self.token = token 7 | self.url = 'https://api.telegram.org/bot' + self.token 8 | 9 | def send_message(self, chat_id, text): 10 | req_url = self.url + '/sendMessage' 11 | req_data = { 12 | 'chat_id': chat_id, 13 | 'text': text, 14 | } 15 | return json.loads(requests.get(req_url, req_data).text) 16 | 17 | def send_messages(self, chat_id, text_list): 18 | req_url = self.url + '/sendMessage' 19 | result = [] 20 | for text in text_list: 21 | req_data = { 22 | 'chat_id': chat_id, 23 | 'text': text, 24 | } 25 | result.append(requests.get(req_url, req_data)) 26 | return result 27 | 28 | def get_updateds(self): 29 | req_url = self.url + '/getUpdates' 30 | return json.loads(requests.get(req_url).text) 31 | 32 | def set_webhook(self, url): 33 | req_url = self.url + '/setWebhook' 34 | req_data = { 35 | 'url': url, 36 | } 37 | return json.loads(requests.get(req_url, req_data).text) 38 | 39 | def get_webhook_info(self): 40 | req_url = self.url + '/getWebhookInfo' 41 | return json.loads(requests.get(req_url).text) 42 | 43 | def delete_webhook(self): 44 | req_url = self.url + '/deleteWebhook' 45 | return json.loads(requests.get(req_url).text) -------------------------------------------------------------------------------- /backend/src/requirements.txt: -------------------------------------------------------------------------------- 1 | aniso8601==10.0.0 2 | asgiref==3.8.1 3 | certifi==2024.12.14 4 | cffi==1.17.1 5 | charset-normalizer==3.4.1 6 | cryptography==44.0.1 7 | Django==5.1.7 8 | django-cors-headers==4.6.0 9 | graphene==3.4.3 10 | graphene-django==3.2.2 11 | graphql-core==3.2.5 12 | graphql-relay==3.2.0 13 | idna==3.10 14 | pillow==11.1.0 15 | promise==2.3 16 | pycparser==2.22 17 | pyhumps==3.8.0 18 | python-dateutil==2.9.0.post0 19 | pytz==2024.2 20 | requests==2.32.3 21 | six==1.17.0 22 | sqlparse==0.5.3 23 | text-unidecode==1.3 24 | typing_extensions==4.12.2 25 | urllib3==2.3.0 26 | uWSGI==2.0.28 27 | -------------------------------------------------------------------------------- /backend/src/static/assets/images/403.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baealex/BLEX/98dc2a4b9d5258b5302c4c72c63381fadac29ad8/backend/src/static/assets/images/403.jpg -------------------------------------------------------------------------------- /backend/src/static/assets/images/default-avatar.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baealex/BLEX/98dc2a4b9d5258b5302c4c72c63381fadac29ad8/backend/src/static/assets/images/default-avatar.jpg -------------------------------------------------------------------------------- /backend/src/static/assets/images/default-cover-1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baealex/BLEX/98dc2a4b9d5258b5302c4c72c63381fadac29ad8/backend/src/static/assets/images/default-cover-1.jpg -------------------------------------------------------------------------------- /backend/src/static/assets/images/default-cover-2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baealex/BLEX/98dc2a4b9d5258b5302c4c72c63381fadac29ad8/backend/src/static/assets/images/default-cover-2.jpg -------------------------------------------------------------------------------- /backend/src/static/assets/images/default-cover-3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baealex/BLEX/98dc2a4b9d5258b5302c4c72c63381fadac29ad8/backend/src/static/assets/images/default-cover-3.jpg -------------------------------------------------------------------------------- /backend/src/static/assets/images/default-cover-4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baealex/BLEX/98dc2a4b9d5258b5302c4c72c63381fadac29ad8/backend/src/static/assets/images/default-cover-4.jpg -------------------------------------------------------------------------------- /backend/src/static/assets/images/default-cover-5.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baealex/BLEX/98dc2a4b9d5258b5302c4c72c63381fadac29ad8/backend/src/static/assets/images/default-cover-5.jpg -------------------------------------------------------------------------------- /backend/src/static/assets/images/default-cover-6.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baealex/BLEX/98dc2a4b9d5258b5302c4c72c63381fadac29ad8/backend/src/static/assets/images/default-cover-6.jpg -------------------------------------------------------------------------------- /backend/src/static/assets/images/ghost.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baealex/BLEX/98dc2a4b9d5258b5302c4c72c63381fadac29ad8/backend/src/static/assets/images/ghost.jpg -------------------------------------------------------------------------------- /backend/src/static/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / 3 | Disallow: /*.preview.jpg 4 | Disallow: /*.minify.* 5 | -------------------------------------------------------------------------------- /backend/src/utility/cleaner/admin_log.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import django 4 | 5 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 6 | 7 | sys.path.append(BASE_DIR) 8 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'main.settings') 9 | django.setup() 10 | 11 | from django.contrib.admin.models import LogEntry 12 | 13 | if __name__ == '__main__': 14 | LogEntry.objects.all().delete() -------------------------------------------------------------------------------- /backend/src/utility/cleaner/bot.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import django 4 | import time 5 | 6 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 7 | 8 | sys.path.append(BASE_DIR) 9 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'main.settings') 10 | django.setup() 11 | 12 | from board.models import * 13 | from board.modules.analytics import NONE_HUMANS, get_bot_name 14 | 15 | if __name__ == '__main__': 16 | humans = Device.objects.exclude(category__contains='bot') 17 | 18 | for human in humans: 19 | for none_human in NONE_HUMANS: 20 | if none_human in human.agent.lower(): 21 | print(f'new bot ==> {human.agent}') 22 | human.category = 'temp-bot' 23 | human.save() 24 | time.sleep(0.1) 25 | 26 | bots = Device.objects.filter(category__contains='bot') 27 | 28 | for bot in bots: 29 | prev_category = bot.category 30 | next_category = get_bot_name(bot.category) 31 | 32 | if prev_category != next_category: 33 | print(f'{prev_category} ==> {next_category}') 34 | bot.category = next_category 35 | bot.save() 36 | time.sleep(0.1) 37 | -------------------------------------------------------------------------------- /backend/src/utility/cleaner/referer.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import django 4 | import time 5 | 6 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 7 | 8 | sys.path.append(BASE_DIR) 9 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'main.settings') 10 | django.setup() 11 | 12 | from board.models import * 13 | from board.modules.analytics import INVALID_REFERERS 14 | 15 | if __name__ == '__main__': 16 | for item in INVALID_REFERERS: 17 | rfs = RefererFrom.objects.filter(location__contains=item) 18 | 19 | for rf in rfs: 20 | if item in rf.location: 21 | print(f'Remove referer : {rf.pk} - {rf.location}') 22 | rf.delete() 23 | time.sleep(0.1) 24 | -------------------------------------------------------------------------------- /backend/src/utility/cleaner/title_image.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import time 4 | import django 5 | 6 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 7 | 8 | TITLE_IMAGE_DIR = BASE_DIR + '/static/images/title' 9 | 10 | sys.path.append(BASE_DIR) 11 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'main.settings') 12 | django.setup() 13 | 14 | from board.models import Post, Comment, TempPosts, ImageCache 15 | 16 | def get_clean_filename(filename): 17 | if 'preview' in filename: 18 | filename = filename.split('.preview')[0] 19 | if 'minify' in filename: 20 | filename = filename.split('.minify')[0] 21 | return filename 22 | 23 | if __name__ == '__main__': 24 | used_filename_dict = dict() 25 | 26 | posts = Post.objects.all() 27 | 28 | for post in posts: 29 | if post.image: 30 | used_filename_dict[str(post.image).split('/')[-1]] = True 31 | 32 | for (path, dir, files) in os.walk(TITLE_IMAGE_DIR): 33 | for filename in files: 34 | filename_for_search = get_clean_filename(filename) 35 | 36 | if not filename_for_search in used_filename_dict: 37 | print(f'Remove file : {path}/{filename}') 38 | os.remove(path + '/' + filename) 39 | time.sleep(0.1) -------------------------------------------------------------------------------- /backend/src/utility/create_user.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import django 4 | 5 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 6 | 7 | sys.path.append(BASE_DIR) 8 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'main.settings') 9 | django.setup() 10 | 11 | from django.contrib.auth.models import User 12 | from board.models import Profile, Config 13 | 14 | if __name__ == '__main__': 15 | username = '' 16 | 17 | if len(sys.argv) > 1 and sys.argv[1]: 18 | username = sys.argv[1] 19 | else: 20 | username = input('Input username : ') 21 | 22 | user_exists = User.objects.filter(username=username).exists() 23 | if user_exists: 24 | print(f"User {username} already exists.") 25 | sys.exit() 26 | 27 | password = input('Input password : ') 28 | 29 | user = User.objects.create_user(username=username, password=password) 30 | user.save() 31 | 32 | profile = Profile.objects.create(user=user) 33 | profile.save() 34 | 35 | config = Config.objects.create(user=user) 36 | config.save() 37 | 38 | print('DONE') 39 | -------------------------------------------------------------------------------- /backend/src/utility/dash_board.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import django 4 | 5 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 6 | 7 | sys.path.append(BASE_DIR) 8 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'main.settings') 9 | django.setup() 10 | 11 | from django.db.models import Sum, Count, F 12 | from django.utils import timezone 13 | 14 | from board.models import User, PostAnalytics 15 | 16 | if __name__ == '__main__': 17 | today = timezone.now() 18 | 19 | joined_users = User.objects.filter(date_joined=today) 20 | print(f"- Today joined user : {joined_users.count()}") 21 | 22 | login_users = User.objects.filter(last_login=today) 23 | print(f"- Today login user : {login_users.count()}") 24 | 25 | today_views = PostAnalytics.objects.filter( 26 | created_date=today 27 | ).annotate( 28 | table_count=Count('devices') 29 | ).aggregate( 30 | total=Sum('table_count') 31 | ) 32 | print(f"- Today site view : {today_views['total']}") 33 | 34 | best_articles = PostAnalytics.objects.filter( 35 | created_date=today 36 | ).annotate( 37 | title=F('posts__title'), 38 | table_count=Count('devices') 39 | ).order_by('-table_count')[:10] 40 | print('- Today best article TOP 10') 41 | for article in best_articles: 42 | print(f" - {article.title} ({article.table_count})") -------------------------------------------------------------------------------- /backend/src/utility/make_superuser.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import django 4 | 5 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 6 | 7 | sys.path.append(BASE_DIR) 8 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'main.settings') 9 | django.setup() 10 | 11 | from django.contrib.auth.models import User 12 | 13 | if __name__ == '__main__': 14 | username = '' 15 | 16 | if len(sys.argv) > 1 and sys.argv[1]: 17 | username = sys.argv[1] 18 | else: 19 | username = input('Input username : ') 20 | 21 | user = User.objects.get(username=username) 22 | answer = input(f"make a superuser {user.first_name}? (Y/N) ").upper() 23 | 24 | if answer == 'Y': 25 | user.is_superuser = True 26 | user.is_staff = True 27 | user.save() 28 | if answer == 'N': 29 | user.is_superuser = False 30 | user.is_staff = False 31 | user.save() 32 | 33 | superusers = User.objects.filter(is_superuser=True) 34 | print(f"Now superuser is {superusers.count()}") 35 | print('DONE') 36 | -------------------------------------------------------------------------------- /backend/src/utility/password_reset.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import django 4 | 5 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 6 | 7 | sys.path.append(BASE_DIR) 8 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'main.settings') 9 | django.setup() 10 | 11 | from django.contrib.auth.models import User 12 | 13 | from modules.randomness import randstr 14 | 15 | if __name__ == '__main__': 16 | username = '' 17 | 18 | if len(sys.argv) > 1 and sys.argv[1]: 19 | username = sys.argv[1] 20 | else: 21 | username = input('Input username : ') 22 | 23 | new_password = randstr(16) 24 | 25 | user = User.objects.get(username=username) 26 | user.set_password(new_password) 27 | user.save() 28 | 29 | print('New password: ', new_password) 30 | print('DONE') 31 | -------------------------------------------------------------------------------- /backend/src/uwsgi_params: -------------------------------------------------------------------------------- 1 | uwsgi_param QUERY_STRING $query_string; 2 | uwsgi_param REQUEST_METHOD $request_method; 3 | uwsgi_param CONTENT_TYPE $content_type; 4 | uwsgi_param CONTENT_LENGTH $content_length; 5 | 6 | uwsgi_param REQUEST_URI $request_uri; 7 | uwsgi_param PATH_INFO $document_uri; 8 | uwsgi_param DOCUMENT_ROOT $document_root; 9 | uwsgi_param SERVER_PROTOCOL $server_protocol; 10 | uwsgi_param REQUEST_SCHEME $scheme; 11 | uwsgi_param HTTPS $https if_not_empty; 12 | 13 | uwsgi_param REMOTE_ADDR $remote_addr; 14 | uwsgi_param REMOTE_PORT $remote_port; 15 | uwsgi_param SERVER_PORT $server_port; 16 | uwsgi_param SERVER_NAME $server_name; -------------------------------------------------------------------------------- /dev-tools/core.ts: -------------------------------------------------------------------------------- 1 | import { spawn } from 'child_process' 2 | import { copyFileSync, existsSync, writeFileSync } from 'fs' 3 | import { resolve } from 'path' 4 | 5 | export const SAMPLE_PATH = './dev-tools/sample'; 6 | export const SCRIPT_PATH = './dev-tools/script'; 7 | 8 | export function runScript(scriptName: string, option?: string[]) { 9 | spawn('sh', [resolve(`${SCRIPT_PATH}/${scriptName}.sh`)].concat(option ? option : []), { stdio: 'inherit' }) 10 | } 11 | 12 | export function copySampleData() { 13 | if (!existsSync(resolve('./backend/.env'))) 14 | copyFileSync( 15 | resolve('./dev-tools/sample/backend/.env'), 16 | resolve('./backend/.env') 17 | ) 18 | 19 | if (!existsSync(resolve('./frontend/.env'))) 20 | copyFileSync( 21 | resolve('./dev-tools/sample/frontend/.env'), 22 | resolve('./frontend/.env') 23 | ) 24 | 25 | if (!existsSync(resolve('./backend/src/db.sqlite3'))) 26 | fetch('https://www.dropbox.com/scl/fi/frtd7j45fg3nrxrw64oue/db.sqlite3?rlkey=dqbi2f6rrykz2ykq04md3pglg&dl=1') 27 | .then(async res => { 28 | writeFileSync(resolve('./backend/src/db.sqlite3'), Buffer.from(await res.arrayBuffer())) 29 | }) 30 | } 31 | -------------------------------------------------------------------------------- /dev-tools/deploy.ts: -------------------------------------------------------------------------------- 1 | import { existsSync } from 'fs' 2 | import { copySampleData, runScript } from './core' 3 | 4 | async function main() { 5 | copySampleData() 6 | 7 | if (existsSync('deploy.overrides.sh')) { 8 | runScript('deploy.overrides') 9 | } else { 10 | runScript('deploy') 11 | } 12 | } 13 | 14 | main() 15 | -------------------------------------------------------------------------------- /dev-tools/dev.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from 'fs' 2 | import { resolve } from 'path' 3 | import { copySampleData, runScript } from './core' 4 | 5 | function overrideEntrypoint(file: string, command: string) { 6 | return file.split('ENTRYPOINT')[0] + command 7 | } 8 | 9 | function main() { 10 | copySampleData() 11 | 12 | writeFileSync( 13 | resolve('./backend/DockerfileDev'), 14 | overrideEntrypoint(readFileSync(resolve('./backend/Dockerfile')).toString(), [ 15 | `ENTRYPOINT ["python", "manage.py"]`, 16 | `CMD ["runserver", "0.0.0.0:9000"]` 17 | ].join('\n')) 18 | ) 19 | 20 | writeFileSync( 21 | resolve('./frontend/DockerfileDev'), 22 | overrideEntrypoint(readFileSync(resolve('./frontend/Dockerfile')).toString(), [ 23 | `ENTRYPOINT ["npm", "run"]`, 24 | `CMD ["dev"]` 25 | ].join('\n')) 26 | ) 27 | 28 | runScript('dev', process.argv.slice(2)) 29 | } 30 | 31 | main() -------------------------------------------------------------------------------- /dev-tools/sample/backend/.env: -------------------------------------------------------------------------------- 1 | TZ=Asia/Seoul 2 | LANG=C.UTF-8 3 | 4 | SECRET_KEY=FOR_YOUR_BLEX 5 | CIPHER_KEY=BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB 6 | 7 | DEBUG=TRUE 8 | API_KEY= 9 | API_URL=http://host.docker.internal:3000 10 | SITE_URL=http://blex.test:3000 11 | STATIC_URL=http://blex.test:8080 12 | SESSION_COOKIE_DOMAIN=.blex.test 13 | 14 | TELEGRAM_BOT_TOKEN= 15 | TELEGRAM_CHANNEL_ID= 16 | TELEGRAM_ERROR_REPORT_ID= 17 | 18 | DISCORD_NEW_POSTS_WEBHOOK= 19 | 20 | GOOGLE_OAUTH_CLIENT_ID= 21 | GOOGLE_OAUTH_CLIENT_SECRET= 22 | GITHUB_OAUTH_CLIENT_ID= 23 | GITHUB_OAUTH_CLIENT_SECRET= 24 | 25 | HCAPTCHA_SECRET_KEY= 26 | -------------------------------------------------------------------------------- /dev-tools/sample/frontend/.env: -------------------------------------------------------------------------------- 1 | API_KEY= 2 | PROXY_API_SERVER=http://host.docker.internal:9000 3 | PUBLIC_API_SERVER=http://blex.test:9000 4 | PUBLIC_STATIC_SERVER=http://blex.test:8080 5 | PUBLIC_GOOGLE_OAUTH_CLIENT_ID= 6 | PUBLIC_GITHUB_OAUTH_CLIENT_ID= 7 | PUBLIC_GOOGLE_ANALYTICS_V4= 8 | PUBLIC_MICROSFT_CLARITY= 9 | PUBLIC_HCAPTCHA_SITE_KEY= 10 | PUBLIC_GOOGLE_ADSENESE_CLIENT_ID= 11 | PUBLIC_BLOG_TITLE= 12 | -------------------------------------------------------------------------------- /dev-tools/script/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | git pull 4 | 5 | docker compose -p bak -f docker-compose.bak.yml up -d --build 6 | 7 | sleep 15 8 | 9 | docker logs blex_backend_1 2> "`date +"%Y%m%d_%H%M%S"`.log" 10 | 11 | docker compose up -d --build 12 | 13 | sleep 15 14 | 15 | docker compose -p bak -f docker-compose.bak.yml down 16 | 17 | docker rmi $(docker images -f "dangling=true" -q) -------------------------------------------------------------------------------- /dev-tools/script/dev.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | docker compose -p blex_dev -f docker-compose.dev.yml up "$@" 4 | 5 | docker rmi $(docker images -f "dangling=true" -q) 6 | -------------------------------------------------------------------------------- /docker-compose.bak.yml: -------------------------------------------------------------------------------- 1 | version: "2.1" 2 | 3 | services: 4 | frontend: 5 | image: baealex/blex-frontend 6 | env_file: ./frontend/.env 7 | restart: always 8 | ports: 9 | - 20011:3000 10 | 11 | backend: 12 | image: baealex/blex-backend 13 | env_file: ./backend/.env 14 | restart: always 15 | volumes: 16 | - ./backend/src/:/app/ 17 | ports: 18 | - 20012:9000 19 | -------------------------------------------------------------------------------- /docker-compose.dev.yml: -------------------------------------------------------------------------------- 1 | version: "2.1" 2 | 3 | services: 4 | frontend: 5 | build: 6 | context: ./frontend 7 | dockerfile: ./DockerfileDev 8 | env_file: ./frontend/.env 9 | restart: always 10 | volumes: 11 | - ./frontend/src/:/app/src/ 12 | - ./frontend/src/package.json:/app/package.json 13 | - ./frontend/src/package-lock.json:/app/package-lock.json 14 | - frontend_node_modules:/app/node_modules 15 | ports: 16 | - 3000:3000 17 | 18 | backend: 19 | build: 20 | context: ./backend 21 | dockerfile: ./DockerfileDev 22 | env_file: ./backend/.env 23 | restart: always 24 | volumes: 25 | - ./backend/src/:/app/ 26 | ports: 27 | - 9000:9000 28 | 29 | static: 30 | image: nginx:latest 31 | restart: always 32 | volumes: 33 | - ./backend/src/static/:/app/static 34 | - ./nginx/development.conf:/etc/nginx/conf.d/default.conf 35 | ports: 36 | - 8080:8080 37 | 38 | volumes: 39 | frontend_node_modules: 40 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "2.1" 2 | 3 | services: 4 | frontend: 5 | image: baealex/blex-frontend 6 | env_file: ./frontend/.env 7 | restart: always 8 | ports: 9 | - 20001:3000 10 | 11 | backend: 12 | image: baealex/blex-backend 13 | env_file: ./backend/.env 14 | restart: always 15 | volumes: 16 | - ./backend/src/:/app/ 17 | ports: 18 | - 20002:9000 19 | -------------------------------------------------------------------------------- /documents/Architecture.md: -------------------------------------------------------------------------------- 1 | ## FRONTEND 2 | 3 | - Next의 기본 규칙에 따라 `pages`안에 디렉터리 및 파일은 각각의 URL 주소와 일치합니다. 프론트엔드를 수정하려는 경우 이 디렉터리 안에서 해당 경로의 파일을 탐색한 후 페이지 안에 포함된 각각의 컴포넌트를 수정할 수 있습니다. 4 | - 컴포넌트는 클래스형과 함수형이 혼합되어 있으나 클래스형에서 함수형으로 전환하는 과정에서 남아있는 것입니다. 새롭게 개발하는 모든 컴포넌트는 타입스크립트 기반의 함수형으로 선언합니다. 5 | 6 | - 일반적인 컴포넌트 7 | ```typescript 8 | export default function Component(props: Props) { 9 | return; 10 | } 11 | ``` 12 | 13 | - 레이아웃이 포함된 컴포넌트 14 | ```typescript 15 | const Component: PageComponent = (props) => { 16 | return ( 17 | <> 18 | ); 19 | } 20 | 21 | Component.pageLayout = (page, props) => { 22 | return ( 23 | 24 | {page} 25 | 26 | ); 27 | } 28 | 29 | export default Component; 30 | ``` 31 | - 스타일링은 CSS 모듈(with SCSS)을 사용하고 있습니다. 필요하다면 다크모드를 지원해야 합니다. 다크모드는 body에 dark class의 존재 여부로 판단할 수 있습니다. 32 | 33 | ```scss 34 | :global(body.dark) & { 35 | // dark mode style... 36 | } 37 | ``` 38 | 39 |
40 | 41 | ## BACKEND 42 | 43 | - Djagno의 기본 규칙에 따라 `urls.py`안에 각 URL과 URL에 어떤 모듈이 매핑되어 있는지 나열되어 있습니다. 기본적인 백엔드의 URL 구조를 살펴보려면 이 파일을 열어보십시오. 44 | - 모듈은 각각의 디렉터리에 분포되어 있습니다. 최상의 디렉터리의 모듈은 장고에 의존적이지 않으며 하위 디렉터리 안에 존재하는 모듈은 장고 혹은 모델에 의존적인 모듈입니다. 45 | - API는 버저닝하지 않는 것을 지향하지만 현재 아름다운 API 구조를 찾아가는 과정에 있으므로 버저닝하여 관리하고 있습니다. 같은 버전의 API는 응답을 유사하게 내려주며 특히 오류에 대한 응답을 통일해야 합니다. -------------------------------------------------------------------------------- /documents/Tech-Stack.md: -------------------------------------------------------------------------------- 1 | ## BACKEND 2 | 3 | **Web Server** 4 | - [x] Nginx 5 | 6 | **Language & Framework** 7 | - [x] Python, Django 8 | 9 | **Database** 10 | - [x] SQLite 11 | - [ ] PostgreSQL 12 | 13 | **Library** 14 | - [x] Pillow 15 | - [x] FFmpeg 16 | - [ ] Parsedown (deprecated) 17 | 18 |
19 | 20 | ## FRONTEND 21 | 22 | **Language & Framework** 23 | - [ ] Python, Django Template (deprecated) 24 | - [x] Typescript, Next.js 25 | 26 | **Library** 27 | - [ ] Bootstrap (deprecated) 28 | - [ ] jQuery (deprecated) 29 | - [x] Remarkable 30 | - [ ] Editormd (deprecated) 31 | - [x] EasyMDE 32 | - [ ] PrismJS (deprecated) 33 | - [X] CodeMirror 34 | - [x] Frappe Charts 35 | 36 |
37 | 38 | ## UTILITIES 39 | 40 | - [x] Font Awesome 41 | - [ ] CloudFlare 42 | - [ ] Google Fonts 43 | - [ ] Google Analytics 44 | - [x] Microsoft Clarity 45 | 46 |
47 | 48 | ## DEVOPS 49 | 50 | - [x] GitHub 51 | - [x] Docker 52 | 53 |
54 | 55 | ## BUSINESS TOOLS 56 | 57 | - [ ] Daum Mail 58 | -------------------------------------------------------------------------------- /frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:21-alpine 2 | 3 | WORKDIR /app 4 | 5 | COPY ./src/package.json ./ 6 | COPY ./src/pnpm-lock.yaml ./ 7 | 8 | RUN npx pnpm i 9 | 10 | COPY ./src/ ./ 11 | 12 | ENTRYPOINT ["npm", "run"] 13 | CMD ["build-and-start"] 14 | -------------------------------------------------------------------------------- /frontend/src/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "next/babel", 5 | { 6 | "styled-jsx": { 7 | "plugins": [ 8 | "@styled-jsx/plugin-sass" 9 | ] 10 | } 11 | } 12 | ] 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /frontend/src/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "env": { 4 | "browser": true, 5 | "es6": true, 6 | "jest": true, 7 | "node": true 8 | }, 9 | "ignorePatterns": [ 10 | "**/storybook-static/**", 11 | "**/library/*.js", 12 | "next.config.js" 13 | ], 14 | "parser": "@typescript-eslint/parser", 15 | "parserOptions": { 16 | "ecmaVersion": 2017, 17 | "sourceType": "module" 18 | }, 19 | "extends": [ 20 | "@baejino/eslint-config", 21 | "@baejino/eslint-config-react" 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /frontend/src/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | ads.txt 3 | 4 | # dependencies 5 | /node_modules 6 | /.pnp 7 | .pnp.js 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # storybook 34 | /storybook-static 35 | -------------------------------------------------------------------------------- /frontend/src/.npmrc: -------------------------------------------------------------------------------- 1 | public-hoist-pattern[]=*eslint* 2 | node-linker=hoisted 3 | -------------------------------------------------------------------------------- /frontend/src/README.md: -------------------------------------------------------------------------------- 1 | ### 패키지 업데이트 2 | 3 | ```bash 4 | npx pnpm update --save && npx pnpm update --save 5 | ``` 6 | 7 | ### 패키지 업그레이드 8 | 9 | ```bash 10 | npx pnpm upgrade --latest --save 11 | ``` 12 | 13 | ```bash 14 | npx pnpm i --save $(npm outdate | tail -n +2 | grep '^[^ ]*' | awk '{print $1 "@" $4}') 15 | ``` -------------------------------------------------------------------------------- /frontend/src/components/design-system/atoms/Accordion/Accordion.module.scss: -------------------------------------------------------------------------------- 1 | @import 'mixin'; 2 | 3 | .Accordion { 4 | transition: height 1s; 5 | overflow: hidden; 6 | position: relative; 7 | 8 | & > div { 9 | display: block; 10 | } 11 | 12 | & > button { 13 | @include BACKGROUND; 14 | color: #ccc; 15 | border: none; 16 | outline: none; 17 | width: 100%; 18 | position: absolute; 19 | bottom: 0; 20 | left: 0; 21 | 22 | i { 23 | transition: transform 0.5s; 24 | transform: rotateZ(-180deg); 25 | 26 | &.isOpen { 27 | transform: rotateZ(0deg); 28 | } 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /frontend/src/components/design-system/atoms/Accordion/index.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames/bind'; 2 | import styles from './Accordion.module.scss'; 3 | const cx = classNames.bind(styles); 4 | 5 | import { 6 | useEffect, 7 | useRef, 8 | useState 9 | } from 'react'; 10 | 11 | export interface AccordionProps { 12 | minHeight?: number; 13 | children?: React.ReactNode; 14 | } 15 | 16 | export function Accordion({ 17 | minHeight = 130, 18 | children 19 | }: AccordionProps) { 20 | const ref = useRef(null); 21 | 22 | const [isOpen, setIsOpen] = useState(false); 23 | const [maxHeight, setMaxHeight] = useState(0); 24 | 25 | useEffect(() => { 26 | if (ref.current) { 27 | setMaxHeight(ref.current.clientHeight + 30); 28 | } 29 | }, [ref.current]); 30 | 31 | return ( 32 |
39 |
40 | {children} 41 |
42 | 45 |
46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /frontend/src/components/design-system/atoms/Alert/Alert.module.scss: -------------------------------------------------------------------------------- 1 | @import 'mixin'; 2 | ; 3 | 4 | .alert { 5 | padding: 1rem; 6 | font-family: $FONT_GOTHIC; 7 | color: #333; 8 | background-color: #eee; 9 | border-left: 5px solid #333; 10 | 11 | :global(body.dark) & { 12 | color: #eee; 13 | background-color: #111; 14 | } 15 | 16 | &.canClick { 17 | cursor: pointer; 18 | } 19 | 20 | &.information { 21 | color: #8c00ff; 22 | background-color: #eef; 23 | border-left: 5px solid #9c56d4; 24 | 25 | :global(body.dark) & { 26 | color: #eef; 27 | background-color: #403846; 28 | } 29 | } 30 | 31 | &.warning { 32 | color: #856404; 33 | background-color: #fff3cd; 34 | border-left: 5px solid #ffc405; 35 | 36 | :global(body.dark) & { 37 | color: #fff3cd; 38 | background-color: #413103; 39 | } 40 | } 41 | 42 | &.danger { 43 | color: #721c24; 44 | background-color: #f8d7da; 45 | border-left: 5px solid #ff5656; 46 | 47 | :global(body.dark) & { 48 | color: #f8d7da; 49 | background-color: #471419; 50 | } 51 | } 52 | 53 | &.success { 54 | color: #155724; 55 | background-color: #d4edda; 56 | border-left: 5px solid #28a745; 57 | 58 | :global(body.dark) & { 59 | color: #d4edda; 60 | background-color: #1e2a1e; 61 | border-left: 5px solid #28a745; 62 | } 63 | } 64 | } -------------------------------------------------------------------------------- /frontend/src/components/design-system/atoms/Alert/index.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames/bind'; 2 | import styles from './Alert.module.scss'; 3 | const cx = classNames.bind(styles); 4 | 5 | import React from 'react'; 6 | 7 | export interface AlertProps { 8 | type?: 'default' | 'danger' | 'warning' | 'information' | 'success'; 9 | onClick?: (e: React.MouseEvent) => void; 10 | children?: React.ReactNode; 11 | className?: string; 12 | } 13 | 14 | export function Alert(props: AlertProps) { 15 | return ( 16 |
23 | {props.children} 24 |
25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /frontend/src/components/design-system/atoms/Badge/Badge.module.scss: -------------------------------------------------------------------------------- 1 | @import 'mixin'; 2 | ; 3 | 4 | .badge { 5 | display: inline-block; 6 | background-color: #f7f7fa; 7 | border-radius: 3px; 8 | padding: 8px 12px; 9 | font-size: 14px; 10 | color: #555; 11 | 12 | :global(body.dark) & { 13 | color: #aaa; 14 | background-color: #272727; 15 | } 16 | 17 | &>a { 18 | color: #666; 19 | 20 | :global(body.dark) & { 21 | color: #aaa; 22 | } 23 | 24 | &:hover { 25 | color: #000; 26 | text-decoration: none; 27 | 28 | :global(body.dark) & { 29 | color: #fff; 30 | } 31 | } 32 | } 33 | 34 | &.ir { 35 | // isRounded 36 | border-radius: 100px; 37 | } 38 | 39 | &.hs { 40 | 41 | // hasSharp 42 | &::before { 43 | content: '#'; 44 | margin-right: 2px; 45 | color: $COLOR_DEFAULT_SECONDARY; 46 | font-weight: bold; 47 | 48 | :global(body.dark) & { 49 | color: $COLOR_DARK_SECONDARY; 50 | } 51 | } 52 | } 53 | 54 | &.clickable { 55 | cursor: pointer; 56 | user-select: none; 57 | } 58 | 59 | &.size-small { 60 | font-size: 10px; 61 | padding: 2px 8px; 62 | } 63 | } -------------------------------------------------------------------------------- /frontend/src/components/design-system/atoms/Badge/index.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames/bind'; 2 | import styles from './Badge.module.scss'; 3 | const cx = classNames.bind(styles); 4 | 5 | interface BadgeProps { 6 | isRounded?: boolean; 7 | hasHash?: boolean; 8 | size?: 'small' | 'medium'; 9 | className?: string; 10 | onClick?: () => void; 11 | children: React.ReactNode; 12 | style?: React.CSSProperties; 13 | } 14 | 15 | export function Badge({ 16 | isRounded = false, 17 | hasHash = false, 18 | size = 'medium', 19 | className, 20 | onClick, 21 | children, 22 | style 23 | }: BadgeProps) { 24 | return ( 25 |
36 | {children} 37 |
38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /frontend/src/components/design-system/atoms/Breadcrumb/Breadcrumb.module.scss: -------------------------------------------------------------------------------- 1 | @import 'mixin'; 2 | 3 | .breadcrumb { 4 | display: flex; 5 | gap: $MARGIN_SIZE_2; 6 | 7 | &> :not(:last-child) { 8 | &:after { 9 | @include COLOR_GRAY; 10 | content: '>'; 11 | margin-left: $MARGIN_SIZE_2; 12 | } 13 | } 14 | 15 | .depth { 16 | @include COLOR_ASHGRAY; 17 | } 18 | 19 | .current { 20 | @include COLOR_DARK; 21 | font-weight: 600; 22 | } 23 | } -------------------------------------------------------------------------------- /frontend/src/components/design-system/atoms/Breadcrumb/index.tsx: -------------------------------------------------------------------------------- 1 | 2 | import classNames from 'classnames/bind'; 3 | import styles from './Breadcrumb.module.scss'; 4 | const cx = classNames.bind(styles); 5 | 6 | import Link from 'next/link'; 7 | 8 | export interface BreadcrumbProps { 9 | className?: string; 10 | depths: { 11 | label: string; 12 | url: string; 13 | }[]; 14 | current: string; 15 | } 16 | 17 | export function Breadcrumb({ 18 | className, 19 | depths, 20 | current 21 | }: BreadcrumbProps) { 22 | return ( 23 |
24 | {depths.filter(depth => depth.label !== current).map(({ label, url }, index) => ( 25 | 29 | {label} 30 | 31 | ))} 32 | 33 | {current} 34 | 35 |
36 | ); 37 | } -------------------------------------------------------------------------------- /frontend/src/components/design-system/atoms/Card/Card.module.scss: -------------------------------------------------------------------------------- 1 | @import 'mixin';; 2 | 3 | .card { 4 | width: 100%; 5 | display: block; 6 | border: 1px solid #dedede; 7 | overflow: hidden; 8 | 9 | :global(body.dark) & { 10 | border: 1px solid #444; 11 | } 12 | 13 | &.hs { // hasShadow 14 | border: none; 15 | 16 | :global(body.dark) & { 17 | border: none; 18 | } 19 | 20 | &.sl-main { 21 | @include MAIN_SHADOW; 22 | } 23 | 24 | &.sl-sub { 25 | @include SUB_SHADOW; 26 | } 27 | } 28 | 29 | &.fb { // fillBack 30 | &.fb-background { 31 | @include BACKGROUND; 32 | } 33 | 34 | &.fb-card { 35 | @include CARD_BACKGROUND; 36 | } 37 | } 38 | 39 | &.ir { // isRounded 40 | border-radius: 24px; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /frontend/src/components/design-system/atoms/Card/index.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames/bind'; 2 | import styles from './Card.module.scss'; 3 | 4 | const cx = classNames.bind(styles); 5 | 6 | export interface CardProps { 7 | style?: React.CSSProperties; 8 | isRounded?: boolean; 9 | hasShadow?: boolean; 10 | shadowLevel?: 'main' | 'sub'; 11 | hasBackground?: boolean; 12 | backgroundType?: 'background' | 'card'; 13 | children?: React.ReactNode; 14 | className?: string; 15 | } 16 | 17 | export function Card({ 18 | isRounded = false, 19 | hasShadow = false, 20 | shadowLevel = 'main', 21 | hasBackground = false, 22 | backgroundType = 'card', 23 | className = '', 24 | style, 25 | children 26 | }: CardProps) { 27 | return ( 28 |
39 | {children} 40 |
41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /frontend/src/components/design-system/atoms/Carousel/Carousel.module.scss: -------------------------------------------------------------------------------- 1 | .carousel { 2 | height: 30px; 3 | line-height: 30px; 4 | font-size: 16px; 5 | overflow-y: hidden; 6 | 7 | & > span, 8 | & > span > * { 9 | white-space: nowrap; 10 | overflow: hidden; 11 | text-overflow: ellipsis; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /frontend/src/components/design-system/atoms/Container/Container.module.scss: -------------------------------------------------------------------------------- 1 | @import 'var'; 2 | 3 | .container { 4 | width: 100%; 5 | margin: 0 auto; 6 | padding: 0 $MARGIN_SIZE_3; 7 | 8 | &.size-xs { 9 | max-width: 480px; 10 | } 11 | 12 | &.size-xs-sm { 13 | max-width: 600px; 14 | } 15 | 16 | &.size-sm { 17 | max-width: 768px; 18 | } 19 | 20 | &.size-md { 21 | max-width: 992px; 22 | } 23 | 24 | &.size-lg { 25 | max-width: 1200px; 26 | } 27 | 28 | &.size-xl { 29 | max-width: 1440px; 30 | } 31 | } -------------------------------------------------------------------------------- /frontend/src/components/design-system/atoms/Container/index.tsx: -------------------------------------------------------------------------------- 1 | import styles from './Container.module.scss'; 2 | 3 | interface Container { 4 | size?: 'xs' | 'xs-sm' | 'sm' | 'md' | 'lg' | 'xl'; 5 | children: React.ReactNode; 6 | } 7 | 8 | export function Container({ 9 | children, 10 | size = 'lg' 11 | }: Container) { 12 | return ( 13 |
14 | {children} 15 |
16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /frontend/src/components/design-system/atoms/GlitchText/GlitchText.module.scss: -------------------------------------------------------------------------------- 1 | .header { 2 | width: 100%; 3 | color: #10A3FF; 4 | opacity: 0.95; 5 | font-size: 120px; 6 | 7 | @media only screen and (max-width: 991px) { 8 | font-size: 80px; 9 | } 10 | 11 | :global(body.dark) & { 12 | color: #fff; 13 | } 14 | } 15 | 16 | .header:before { 17 | position: absolute; 18 | content: attr(data-text); 19 | color: #f00; 20 | opacity: 0.8; 21 | font-size: 120px; 22 | mix-blend-mode: multiply; 23 | transform: translateX(-5px); 24 | animation: glitch 0.5s infinite linear; 25 | 26 | @media only screen and (max-width: 991px) { 27 | font-size: 80px; 28 | } 29 | 30 | :global(body.dark) & { 31 | mix-blend-mode: soft-light; 32 | } 33 | } 34 | 35 | @keyframes glitch { 36 | 50% { 37 | -webkit-transform: translateX(7px); 38 | transform: translateX(7px); 39 | } 40 | } -------------------------------------------------------------------------------- /frontend/src/components/design-system/atoms/GlitchText/index.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames/bind'; 2 | import styles from './GlitchText.module.scss'; 3 | const cx = classNames.bind(styles); 4 | 5 | import { 6 | useEffect, 7 | useState 8 | } from 'react'; 9 | 10 | export interface GlitchTextProps { 11 | letters: string[]; 12 | } 13 | 14 | export function GlitchText(props: GlitchTextProps) { 15 | const [text, setText] = useState('PAGE'); 16 | 17 | useEffect(() => { 18 | const letters = props.letters; 19 | const pick = () => { 20 | return Math.floor(Math.random() * letters.length); 21 | }; 22 | 23 | (function swap() { 24 | setText( 25 | letters[pick()] + 26 | letters[pick()] + 27 | letters[pick()] + 28 | letters[pick()] 29 | ); 30 | setTimeout(swap, 100); 31 | })(); 32 | }, []); 33 | 34 | return ( 35 |
36 | {text} 37 |
38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /frontend/src/components/design-system/atoms/Grid/Grid.module.scss: -------------------------------------------------------------------------------- 1 | @import 'var'; 2 | 3 | $gridCellSizes: ( 4 | 1: 1, 5 | 2: 2, 6 | 3: 3, 7 | 4: 4, 8 | 5: 5, 9 | ); 10 | 11 | .grid { 12 | display: grid; 13 | 14 | @each $key, 15 | $value in $gridCellSizes { 16 | @media (min-width: $BREAKPOINT_MOBILE) { 17 | &.gtc-m-#{$key} { 18 | grid-template-columns: repeat($value, minmax(0, 1fr)); 19 | } 20 | 21 | &.gtr-m-#{$key} { 22 | grid-template-rows: repeat($value, minmax(0, 1fr)); 23 | } 24 | } 25 | 26 | @media (min-width: $BREAKPOINT_TABLET) { 27 | &.gtc-t-#{$key} { 28 | grid-template-columns: repeat($value, minmax(0, 1fr)); 29 | } 30 | 31 | &.gtr-t-#{$key} { 32 | grid-template-rows: repeat($value, minmax(0, 1fr)); 33 | } 34 | } 35 | 36 | @media (min-width: $BREAKPOINT_DESKTOP) { 37 | &.gtc-p-#{$key} { 38 | grid-template-columns: repeat($value, minmax(0, 1fr)); 39 | } 40 | 41 | &.gtr-p-#{$key} { 42 | grid-template-rows: repeat($value, minmax(0, 1fr)); 43 | } 44 | } 45 | } 46 | 47 | @each $key, 48 | $value in $marginSizes { 49 | &.g-#{$key} { 50 | gap: $value; 51 | } 52 | 53 | &.rg-#{$key} { 54 | row-gap: $value; 55 | } 56 | 57 | &.cg-#{$key} { 58 | column-gap: $value; 59 | } 60 | } 61 | } -------------------------------------------------------------------------------- /frontend/src/components/design-system/atoms/Grid/index.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames/bind'; 2 | import styles from './Grid.module.scss'; 3 | const cx = classNames.bind(styles); 4 | 5 | type Gap = 0 | 1 | 2 | 3 | 4 | 5; 6 | 7 | type GridCell = 1 | 2 | 3 | 4 | 5; 8 | 9 | interface Responsive { 10 | desktop?: GridCell; 11 | tablet?: GridCell; 12 | mobile?: GridCell; 13 | } 14 | 15 | export interface GridProps { 16 | className?: string; 17 | gap?: Gap; 18 | rowGap?: Gap; 19 | columnGap?: Gap; 20 | column?: Responsive; 21 | row?: Responsive; 22 | children: React.ReactNode; 23 | } 24 | 25 | export function Grid({ 26 | gap = 0, 27 | rowGap = 0, 28 | columnGap = 0, 29 | column, 30 | row, 31 | children, 32 | className 33 | }: GridProps) { 34 | return ( 35 |
49 | {children} 50 |
51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /frontend/src/components/design-system/atoms/ImageCard/ImageCard.module.scss: -------------------------------------------------------------------------------- 1 | .card { 2 | position: relative; 3 | overflow: hidden; 4 | border-radius: 16px; 5 | height: 250px; 6 | background: #000; 7 | 8 | .card-image { 9 | img { 10 | position: absolute; 11 | object-fit: cover; 12 | width: 100%; 13 | height: 100%; 14 | } 15 | } 16 | 17 | .card-body { 18 | position: absolute; 19 | bottom: 0; 20 | left: 0; 21 | width: 100%; 22 | height: 100%; 23 | display: flex; 24 | justify-content: center; 25 | align-items: center; 26 | background: rgba(0, 0, 0, 0.65); 27 | 28 | p { 29 | max-width: 80%; 30 | overflow: hidden; 31 | white-space: nowrap; 32 | text-overflow: ellipsis; 33 | } 34 | } 35 | } 36 | 37 | .white { 38 | color: #fff; 39 | } 40 | -------------------------------------------------------------------------------- /frontend/src/components/design-system/atoms/ImageCard/index.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames/bind'; 2 | import styles from './ImageCard.module.scss'; 3 | const cx = classNames.bind(styles); 4 | 5 | import { createColorHash } from '~/modules/utility/image'; 6 | import { useMemo } from 'react'; 7 | 8 | export interface ImageCardProps { 9 | image?: React.ReactNode; 10 | bgHash: string; 11 | children: React.ReactNode; 12 | } 13 | 14 | export function ImageCard(props: ImageCardProps) { 15 | const colorHash = useMemo(() => createColorHash(props.bgHash), [props.bgHash]); 16 | 17 | return ( 18 |
19 |
20 | {props.image} 21 |
22 |
23 | {props.children} 24 |
25 |
26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /frontend/src/components/design-system/atoms/ImageInput/ImageInput.module.scss: -------------------------------------------------------------------------------- 1 | .image { 2 | cursor: pointer; 3 | background-size: cover; 4 | position: relative; 5 | width: 150px; 6 | height: 150px; 7 | border-radius: 100%; 8 | 9 | img { 10 | position: absolute; 11 | object-fit: cover; 12 | width: 100%; 13 | height: 100%; 14 | border-radius: 100%; 15 | } 16 | } 17 | 18 | .cover { 19 | position: absolute; 20 | display: block; 21 | background: rgba(0, 0, 0, 0.25); 22 | content: ''; 23 | width: 100%; 24 | height: 100%; 25 | border-radius: 100%; 26 | color: #fff; 27 | display: flex; 28 | justify-content: center; 29 | align-items: center; 30 | opacity: 0; 31 | 32 | &:hover { 33 | opacity: 1; 34 | } 35 | } -------------------------------------------------------------------------------- /frontend/src/components/design-system/atoms/ImageInput/index.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames/bind'; 2 | import styles from './ImageInput.module.scss'; 3 | const cx = classNames.bind(styles); 4 | 5 | import type { ChangeEvent } from 'react'; 6 | import { useRef } from 'react'; 7 | 8 | export interface ImageInputProps { 9 | url: string; 10 | label?: string; 11 | onChange?: (file: File) => void; 12 | } 13 | 14 | export function ImageInput(props: ImageInputProps) { 15 | const input = useRef(null); 16 | 17 | const onClickButton = () => { 18 | input.current?.click(); 19 | }; 20 | 21 | const onChangeImage = (e: ChangeEvent) => { 22 | const { files } = e.target; 23 | 24 | if (files) { 25 | if (props.onChange) { 26 | const image = files[0]; 27 | props.onChange(image); 28 | } 29 | } 30 | }; 31 | 32 | return ( 33 |
36 | onChangeImage(e)} 42 | /> 43 | 44 |
45 | {props.label} 46 |
47 |
48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /frontend/src/components/design-system/atoms/ImagePreload/ImagePreload.module.scss: -------------------------------------------------------------------------------- 1 | .preload { 2 | display: none; 3 | } 4 | -------------------------------------------------------------------------------- /frontend/src/components/design-system/atoms/ImagePreload/index.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames/bind'; 2 | import styles from './ImagePreload.module.scss'; 3 | const cx = classNames.bind(styles); 4 | 5 | interface ImagePreloadProps { 6 | links: string[]; 7 | } 8 | 9 | export function ImagePreload({ links }: ImagePreloadProps) { 10 | return ( 11 |
12 | {links.map((link, idx) => ( 13 | 14 | ))} 15 |
16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /frontend/src/components/design-system/atoms/LazyLoadedImage/LazyLoadedImage.module.scss: -------------------------------------------------------------------------------- 1 | .image { 2 | font-size: 0; 3 | } 4 | -------------------------------------------------------------------------------- /frontend/src/components/design-system/atoms/LazyLoadedImage/index.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import styles from './LazyLoadedImage.module.scss'; 3 | 4 | import { useEffect, useRef } from 'react'; 5 | 6 | interface LazyLoadedImageProps { 7 | className?: string; 8 | previewImage: string; 9 | src: string; 10 | alt: string; 11 | style?: React.CSSProperties; 12 | } 13 | 14 | export function LazyLoadedImage({ 15 | className, 16 | src, 17 | alt, 18 | previewImage, 19 | style 20 | }: LazyLoadedImageProps) { 21 | const ref = useRef(null); 22 | 23 | useEffect(() => { 24 | if (ref.current && previewImage && src && src !== previewImage) { 25 | const observer = new IntersectionObserver(([entry]) => { 26 | if (entry.isIntersecting) { 27 | observer.disconnect(); 28 | ref.current!.src = src; 29 | } 30 | }, { threshold: 0.1 }); 31 | 32 | observer.observe(ref.current); 33 | } 34 | }, [ref, previewImage, src]); 35 | 36 | return ( 37 | {alt} 45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /frontend/src/components/design-system/atoms/Loading/Loading.module.scss: -------------------------------------------------------------------------------- 1 | @import 'mixin';; 2 | 3 | .center, .full, .inline { 4 | display: flex; 5 | justify-content: center; 6 | align-items: center; 7 | } 8 | 9 | .center, .full { 10 | z-index: 9999; 11 | position: fixed; 12 | } 13 | 14 | .center, .inline { 15 | width: 50px; 16 | height: 50px; 17 | border-radius: 8px; 18 | @include MAIN_SHADOW; 19 | @include BACKGROUND; 20 | } 21 | 22 | .center { 23 | top: 50%; 24 | left: 50%; 25 | transform: translate(-50%, -50%); 26 | } 27 | 28 | .full { 29 | top: 0; 30 | left: 0; 31 | width: 100%; 32 | height: 100%; 33 | background-color: rgba(255, 255, 255, 0.95); 34 | 35 | :global(body.dark) & { 36 | background-color: rgba(0, 0, 0, 0.95); 37 | } 38 | } 39 | 40 | @keyframes spin { 41 | 0% { 42 | transform: rotate(0deg); 43 | } 44 | 100% { 45 | transform: rotate(360deg); 46 | } 47 | } 48 | 49 | .spinner { 50 | animation: spin 0.75s infinite ease; 51 | border: 3px solid transparent; 52 | border-color: $COLOR_DEFAULT_SECONDARY transparent; 53 | width: 25px; 54 | height: 25px; 55 | border-radius: 50%; 56 | } 57 | -------------------------------------------------------------------------------- /frontend/src/components/design-system/atoms/Loading/index.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames/bind'; 2 | import styles from './Loading.module.scss'; 3 | const cx = classNames.bind(styles); 4 | 5 | export interface LoadingProps { 6 | position?: 'center' | 'full' | 'inline'; 7 | } 8 | 9 | export function Loading({ position = 'center' }: LoadingProps) { 10 | return ( 11 |
12 |
13 |
14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /frontend/src/components/design-system/atoms/PageNavigation/index.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames/bind'; 2 | import styles from './PageNavigation.module.scss'; 3 | const cx = classNames.bind(styles); 4 | 5 | import Link from 'next/link'; 6 | import { useMemo } from 'react'; 7 | 8 | import { clearMemoryStore } from '~/hooks/use-memory-store'; 9 | 10 | export interface PageNavigationProps { 11 | active: string; 12 | items: { 13 | link: string; 14 | name: string; 15 | }[]; 16 | disableLink?: boolean; 17 | } 18 | 19 | export function PageNavigation(props: PageNavigationProps) { 20 | const active = useMemo(() => props.items.findIndex((item) => item.name === props.active), [props.active, props.items]); 21 | 22 | return ( 23 |
    24 | 25 | {props.items.map((item, idx) => ( 26 |
  • 29 | {props.disableLink ? ( 30 | item.name 31 | ) : ( 32 | clearMemoryStore()}> 33 | {item.name} 34 | 35 | )} 36 |
  • 37 | ))} 38 |
39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /frontend/src/components/design-system/atoms/PopOver/PopOver.module.scss: -------------------------------------------------------------------------------- 1 | .popover { 2 | pointer-events: none; 3 | transition: opacity 0.5s ease; 4 | opacity: 0; 5 | position: absolute; 6 | padding: 4px; 7 | width: 120px; 8 | color: #ccc; 9 | background: #000; 10 | border-radius: 4px; 11 | text-align: center; 12 | 13 | &.hover { 14 | opacity: 1; 15 | } 16 | 17 | &::after { 18 | position: absolute; 19 | transform: rotate(-90deg); 20 | right: -16px; 21 | top: 4px; 22 | width: 0; 23 | height: 0; 24 | content: ''; 25 | border-width: 10px 10px 10px 10px; 26 | border-style: solid; 27 | border-color: #000 transparent transparent transparent; 28 | filter: drop-shadow(0 2px 1px rgba(0, 0, 0, 0.1)); 29 | } 30 | 31 | &.top { 32 | &::after { 33 | top: auto; 34 | bottom: -16px; 35 | right: 50%; 36 | transform: rotate(-180deg) translateX(-50%); 37 | border-color: transparent transparent #000 transparent; 38 | } 39 | } 40 | 41 | &.right { 42 | &::after { 43 | right: auto; 44 | left: -16px; 45 | transform: rotate(90deg); 46 | } 47 | } 48 | 49 | &.bottom { 50 | &::after { 51 | top: -16px; 52 | bottom: auto; 53 | right: 50%; 54 | transform: rotate(180deg) translateX(-50%); 55 | } 56 | } 57 | } -------------------------------------------------------------------------------- /frontend/src/components/design-system/atoms/Progress/Progress.module.scss: -------------------------------------------------------------------------------- 1 | @import 'mixin'; 2 | 3 | @keyframes progress { 4 | 0% { 5 | transform: translateX(-100%); 6 | } 7 | 8 | 100% { 9 | transform: translateX(0); 10 | } 11 | } 12 | 13 | @keyframes progress-reverse { 14 | 0% { 15 | transform: translateX(100%); 16 | } 17 | 18 | 100% { 19 | transform: translateX(0); 20 | } 21 | } 22 | 23 | .progress { 24 | width: 100%; 25 | background-color: #e9ecef; 26 | height: 4px; 27 | overflow: hidden; 28 | 29 | :global(body.dark) & { 30 | background-color: #343a40; 31 | } 32 | } 33 | 34 | .progress-bar { 35 | background-color: $COLOR_DEFAULT_SECONDARY; 36 | height: 100%; 37 | transform: translateX(-100%); 38 | } 39 | 40 | .progress-timer { 41 | animation: progress 0s linear 1; 42 | height: 100%; 43 | background-color: $COLOR_DEFAULT_SECONDARY; 44 | transform: translateX(0%); 45 | 46 | &.isReversed { 47 | transform: translateX(-100%); 48 | } 49 | } -------------------------------------------------------------------------------- /frontend/src/components/design-system/atoms/Progress/ProgressBar.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames/bind'; 2 | import styles from './Progress.module.scss'; 3 | const cx = classNames.bind(styles); 4 | 5 | import type { ProgressBarProps } from './types'; 6 | 7 | export function ProgressBar({ 8 | value, 9 | max = 100, 10 | ...props 11 | }: ProgressBarProps) { 12 | 13 | return ( 14 |
15 |
23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /frontend/src/components/design-system/atoms/Progress/index.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames/bind'; 2 | import styles from './Progress.module.scss'; 3 | const cx = classNames.bind(styles); 4 | 5 | import type { ProgressProps } from './types'; 6 | 7 | import { ProgressBar } from './ProgressBar'; 8 | import { ProgressTimer } from './ProgressTimer'; 9 | 10 | export function Progress(props: ProgressProps) { 11 | return ( 12 |
13 | {props.type === 'timer' && ( 14 | 15 | )} 16 | {props.type === 'bar' && ( 17 | 18 | )} 19 |
20 | ); 21 | } -------------------------------------------------------------------------------- /frontend/src/components/design-system/atoms/Progress/types.ts: -------------------------------------------------------------------------------- 1 | export interface CommonProgressProps { 2 | color?: string; 3 | size?: string; 4 | className?: string; 5 | } 6 | 7 | export interface ProgressBarProps extends CommonProgressProps { 8 | type: 'bar'; 9 | value: number; 10 | max?: number; 11 | } 12 | 13 | export interface ProgressTimerProps extends CommonProgressProps { 14 | type: 'timer'; 15 | time: number; 16 | onEnd?: () => void; 17 | isReady?: boolean; 18 | isReversed?: boolean; 19 | repeat?: boolean; 20 | } 21 | 22 | export type ProgressProps = ProgressBarProps | ProgressTimerProps; 23 | -------------------------------------------------------------------------------- /frontend/src/components/design-system/atoms/SpeechBubble/index.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames/bind'; 2 | import styles from './SpeechBubble.module.scss'; 3 | const cx = classNames.bind(styles); 4 | 5 | import React from 'react'; 6 | 7 | interface SpeechBubbleProps { 8 | image: React.ReactNode; 9 | className?: string; 10 | children: React.ReactNode; 11 | } 12 | 13 | export function SpeechBubble({ 14 | className, 15 | image, 16 | children 17 | }: SpeechBubbleProps) { 18 | return ( 19 |
20 |
21 | {children} 22 |
23 |
24 | {image} 25 |
26 |
27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /frontend/src/components/design-system/atoms/SplitLine/SplitLine.module.scss: -------------------------------------------------------------------------------- 1 | @import 'mixin';; 2 | 3 | .group { 4 | font-size: 12px; 5 | text-align: center; 6 | padding: 4px 0; 7 | width: 100%; 8 | 9 | span { 10 | position: relative; 11 | @include COLOR_ASHGRAY; 12 | padding: 0 8px; 13 | @include BACKGROUND; 14 | } 15 | } 16 | 17 | .line { 18 | display: block; 19 | content: ''; 20 | background: #ddd; 21 | height: 1px; 22 | width: 100%; 23 | transform: translateY(10px); 24 | 25 | :global(body.dark) & { 26 | background: #666; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /frontend/src/components/design-system/atoms/SplitLine/index.tsx: -------------------------------------------------------------------------------- 1 | import styles from './SplitLine.module.scss'; 2 | 3 | interface SplitLineProps { 4 | hasText?: boolean; 5 | } 6 | 7 | export function SplitLine({ hasText }: SplitLineProps) { 8 | return ( 9 |
10 |
11 | {hasText && ( 12 | 또는 13 | )} 14 |
15 | ); 16 | } -------------------------------------------------------------------------------- /frontend/src/components/design-system/atoms/Table/index.tsx: -------------------------------------------------------------------------------- 1 | import styles from './Table.module.scss'; 2 | 3 | export interface TableProps { 4 | head: string[]; 5 | body: string[][]; 6 | } 7 | 8 | export function Table({ 9 | head, 10 | body 11 | }: TableProps) { 12 | return ( 13 |
14 | 15 | 16 | 17 | {head.map((text, idx) => ( 18 | 19 | ))} 20 | 21 | 22 | 23 | {body.map((row, bodyIdx) => ( 24 | 25 | {row.map((text, rowIdx) => ( 26 | 31 | ))} 32 | 33 | ))} 34 | 35 |
{text}
29 | {text} 30 |
36 |
37 | ); 38 | } -------------------------------------------------------------------------------- /frontend/src/components/design-system/atoms/Text/Text.module.scss: -------------------------------------------------------------------------------- 1 | @import 'mixin'; 2 | 3 | .text { 4 | line-height: 1.5; 5 | word-break: keep-all; 6 | overflow-wrap: anywhere; 7 | 8 | @for $i from 1 through 9 { 9 | &.fw-#{$i * 100} { 10 | font-weight: ($i * 100); 11 | } 12 | } 13 | 14 | @each $sizeName, $size in $fontSizes { 15 | &.fs-#{$sizeName} { 16 | font-size: $size; 17 | } 18 | } 19 | } -------------------------------------------------------------------------------- /frontend/src/components/design-system/atoms/Text/index.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames/bind'; 2 | import styles from './Text.module.scss'; 3 | const cx = classNames.bind(styles); 4 | 5 | import React from 'react'; 6 | 7 | export interface TextProps { 8 | children: React.ReactNode; 9 | className?: string; 10 | style?: React.CSSProperties; 11 | fontSize?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8; 12 | fontWeight?: 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900; 13 | tag?: 'p' | 'span' | 'div' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; 14 | } 15 | 16 | export function Text(props: TextProps) { 17 | const { 18 | tag = 'p', 19 | fontSize = 4, 20 | fontWeight = 400 21 | } = props; 22 | 23 | return React.createElement( 24 | tag, 25 | { 26 | className: cx( 27 | 'text', 28 | `fs-${fontSize}`, 29 | `fw-${fontWeight}`, 30 | props.className 31 | ), 32 | style: props.style 33 | }, 34 | props.children 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /frontend/src/components/design-system/atoms/Toggle/Toggle.module.scss: -------------------------------------------------------------------------------- 1 | @import 'mixin'; 2 | 3 | .toggle { 4 | display: flex; 5 | align-items: center; 6 | cursor: pointer; 7 | 8 | .label { 9 | @include COLOR_DARK; 10 | margin-left: 0.5rem; 11 | user-select: none; 12 | } 13 | 14 | .switch { 15 | @each $theme, $color in $COLOR_GRAY { 16 | :global(body.#{$theme}) & { 17 | background-color: $color; 18 | } 19 | } 20 | display: inline-block; 21 | width: 36px; 22 | height: 20px; 23 | border-radius: 1rem; 24 | position: relative; 25 | transition: background-color 0.2s ease; 26 | } 27 | 28 | .switch::after { 29 | content: ''; 30 | @include BACKGROUND; 31 | display: block; 32 | width: 16px; 33 | height: 16px; 34 | border-radius: 50%; 35 | position: absolute; 36 | top: 2px; 37 | left: 2px; 38 | transition: transform 0.2s ease-out; 39 | } 40 | 41 | input:checked + .switch { 42 | background-color: $COLOR_DEFAULT_SECONDARY; 43 | } 44 | 45 | input:checked + .switch::after { 46 | transform: translateX(16px); 47 | } 48 | 49 | input { 50 | display: none; 51 | } 52 | 53 | } 54 | -------------------------------------------------------------------------------- /frontend/src/components/design-system/atoms/Toggle/index.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames/bind'; 2 | import styles from './Toggle.module.scss'; 3 | const cx = classNames.bind(styles); 4 | 5 | import { useRef } from 'react'; 6 | 7 | export interface ToggleProps { 8 | label: string; 9 | onClick: (value: boolean) => void; 10 | defaultChecked?: boolean; 11 | } 12 | 13 | export function Toggle(props: ToggleProps) { 14 | const checkbox = useRef(null); 15 | 16 | return ( 17 | <> 18 | 28 | 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /frontend/src/components/design-system/atoms/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Accordion'; 2 | export * from './Alert'; 3 | export * from './Badge'; 4 | export * from './Breadcrumb'; 5 | export * from './Button'; 6 | export * from './Card'; 7 | export * from './Carousel'; 8 | export * from './Container'; 9 | export * from './Dropdown'; 10 | export * from './Flex'; 11 | export * from './GlitchText'; 12 | export * from './Grid'; 13 | export * from './ImageCard'; 14 | export * from './ImageInput'; 15 | export * from './ImagePreload'; 16 | export * from './LazyLoadedImage'; 17 | export * from './Loading'; 18 | export * from './Masonry'; 19 | export * from './PageNavigation'; 20 | export * from './PopOver'; 21 | export * from './Progress'; 22 | export * from './SpeechBubble'; 23 | export * from './SplitLine'; 24 | export * from './Text'; 25 | export * from './Table'; 26 | export * from './Toggle'; 27 | -------------------------------------------------------------------------------- /frontend/src/components/design-system/forms/BaseInput/BaseInput.module.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | width: 100%; 4 | align-items: center; 5 | border: 1px solid #e6e6e6; 6 | padding: 6px 12px; 7 | border-radius: 5px; 8 | user-select: none; 9 | background: #fff; 10 | 11 | :global(body.dark) & { 12 | border: 1px solid #363636; 13 | background: #363636; 14 | } 15 | } 16 | 17 | .input { 18 | width: 100%; 19 | border: none; 20 | outline: none; 21 | background: transparent; 22 | 23 | 24 | &::placeholder { 25 | color: #888; 26 | font-size: 0.9em; 27 | } 28 | 29 | :global(body.dark) & { 30 | color: #fff; 31 | } 32 | } 33 | 34 | textarea.input { 35 | height: 88px; 36 | resize: none; 37 | overflow: auto; 38 | line-height: 1.75; 39 | } 40 | 41 | .icon { 42 | margin-right: 10px; 43 | font-size: 18px; 44 | color: #ccc; 45 | pointer-events: none; 46 | } -------------------------------------------------------------------------------- /frontend/src/components/design-system/forms/Checkbox/Checkbox.module.scss: -------------------------------------------------------------------------------- 1 | .checkbox { 2 | margin: 4px 0; 3 | border: none; 4 | width: 100%; 5 | border-radius: 5px; 6 | padding: 2px 0; 7 | 8 | .input { 9 | margin: 0; 10 | cursor: pointer; 11 | } 12 | 13 | .label { 14 | color: #000; 15 | font-size: 0.9em; 16 | padding: 0 4px; 17 | margin: 0; 18 | user-select: none; 19 | cursor: pointer; 20 | 21 | :global(body.dark) & { 22 | color: #eee; 23 | } 24 | } 25 | } -------------------------------------------------------------------------------- /frontend/src/components/design-system/forms/Checkbox/index.tsx: -------------------------------------------------------------------------------- 1 | import styles from './Checkbox.module.scss'; 2 | 3 | import { forwardRef } from 'react'; 4 | 5 | import { Flex } from '~/components/design-system'; 6 | 7 | export interface CheckboxProps extends React.InputHTMLAttributes { 8 | label: string; 9 | } 10 | 11 | export const Checkbox = forwardRef((props, ref) => ( 12 | 13 | 26 | 27 | )); 28 | -------------------------------------------------------------------------------- /frontend/src/components/design-system/forms/DateInput/index.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames/bind'; 2 | import styles from './DateInput.module.scss'; 3 | const cx = classNames.bind(styles); 4 | 5 | import React from 'react'; 6 | import { default as ReactDatePicker } from 'react-datepicker'; 7 | 8 | import 'react-datepicker/dist/react-datepicker.css'; 9 | 10 | import { BaseInput } from '../BaseInput'; 11 | 12 | export interface DateInputProps { 13 | placeholder?: string; 14 | className?: string; 15 | showTime?: boolean; 16 | minDate?: Date; 17 | maxDate?: Date; 18 | selected: Date | null; 19 | style?: React.CSSProperties; 20 | onChange: (date: Date) => void; 21 | } 22 | 23 | export const DateInput = ({ 24 | showTime = false, 25 | style, 26 | ...props 27 | }: DateInputProps) => { 28 | return ( 29 |
30 | )} 35 | style={style} 36 | /> 37 | )} 38 | dateFormat={showTime ? 'yyyy-MM-dd HH:mm' : 'yyyy-MM-dd'} 39 | showTimeSelect={showTime} 40 | placeholderText={props.placeholder} 41 | {...props} 42 | /> 43 |
44 | ); 45 | }; 46 | -------------------------------------------------------------------------------- /frontend/src/components/design-system/forms/ErrorMessage/ErrorMessage.module.scss: -------------------------------------------------------------------------------- 1 | .error-message { 2 | color: #d65f6b; 3 | font-size: 12px; 4 | margin-top: 4px; 5 | } -------------------------------------------------------------------------------- /frontend/src/components/design-system/forms/ErrorMessage/index.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames/bind'; 2 | import styles from './ErrorMessage.module.scss'; 3 | const cx = classNames.bind(styles); 4 | 5 | export interface ErrorMessageProps { 6 | className?: string; 7 | children: React.ReactNode; 8 | } 9 | 10 | export function ErrorMessage(props: ErrorMessageProps) { 11 | return ( 12 |
13 | {props.children} 14 |
15 | ); 16 | } -------------------------------------------------------------------------------- /frontend/src/components/design-system/forms/FormControl/FormControl.module.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baealex/BLEX/98dc2a4b9d5258b5302c4c72c63381fadac29ad8/frontend/src/components/design-system/forms/FormControl/FormControl.module.scss -------------------------------------------------------------------------------- /frontend/src/components/design-system/forms/FormControl/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export interface FormControlProps { 4 | className?: string; 5 | required?: boolean; 6 | invalid?: boolean; 7 | children: React.ReactNode | React.ReactNode[]; 8 | } 9 | 10 | export function FormControl(props: FormControlProps) { 11 | return ( 12 |
13 | {React.Children.map(props.children, (child) => { 14 | if (React.isValidElement(child)) { 15 | return React.cloneElement(child, { 16 | required: props.required, 17 | invalid: props.invalid 18 | } as unknown as typeof child); 19 | } 20 | return child; 21 | })} 22 |
23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /frontend/src/components/design-system/forms/KeywordInput/index.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | 3 | import { BadgeGroup, BaseInput } from '~/components/design-system'; 4 | 5 | import { slugify } from '~/modules/utility/string'; 6 | 7 | interface Props { 8 | name: string; 9 | maxLength: number; 10 | onChange: (e: React.ChangeEvent) => void; 11 | value: string; 12 | placeholder?: string; 13 | } 14 | 15 | export function KeywordInput(props: Props) { 16 | const badges = useMemo(() => { 17 | return [...new Set(slugify(props.value).split('-').filter(x => !!x))]; 18 | }, [props.value]); 19 | 20 | return ( 21 | <> 22 | props.onChange(e)} 27 | placeholder={props.placeholder} 28 | value={props.value} 29 | /> 30 | 31 | 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /frontend/src/components/design-system/forms/Label/Label.module.scss: -------------------------------------------------------------------------------- 1 | .label { 2 | font-size: 0.8rem; 3 | font-weight: 500; 4 | color: #333; 5 | 6 | :global(body.dark) & { 7 | color: #ccc; 8 | } 9 | } 10 | 11 | .required { 12 | color: #d65f6b; 13 | margin-left: 2px; 14 | } -------------------------------------------------------------------------------- /frontend/src/components/design-system/forms/Label/index.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames/bind'; 2 | import styles from './Label.module.scss'; 3 | const cx = classNames.bind(styles); 4 | 5 | export interface LabelProps { 6 | className?: string; 7 | required?: boolean; 8 | children: string; 9 | } 10 | 11 | export function Label(props: LabelProps) { 12 | return ( 13 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /frontend/src/components/design-system/forms/index.ts: -------------------------------------------------------------------------------- 1 | export * from './BaseInput'; 2 | export * from './Checkbox'; 3 | export * from './DateInput'; 4 | export * from './ErrorMessage'; 5 | export * from './FormControl'; 6 | export * from './KeywordInput'; 7 | export * from './Label'; -------------------------------------------------------------------------------- /frontend/src/components/design-system/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './atoms'; 2 | export * from './forms'; 3 | export * from './molecules'; 4 | export * from './layouts'; 5 | -------------------------------------------------------------------------------- /frontend/src/components/design-system/layouts/DualWidgetLayout/DualWidgetLayout.module.scss: -------------------------------------------------------------------------------- 1 | @import 'var'; 2 | 3 | .layout { 4 | display: grid; 5 | grid-template-columns: 200px minmax(0, 1fr) 200px; 6 | flex-direction: row; 7 | gap: $MARGIN_SIZE_3; 8 | } 9 | 10 | .left-widget { 11 | order: 0; 12 | } 13 | 14 | .content { 15 | order: 1; 16 | } 17 | 18 | .right-widget { 19 | order: 2; 20 | } 21 | 22 | @media (max-width: $BREAKPOINT_DESKTOP_LARGE) { 23 | .layout { 24 | grid-template-columns: minmax(0, 1fr); 25 | justify-items: center; 26 | } 27 | 28 | .left-widget { 29 | display: contents; 30 | } 31 | 32 | .content { 33 | max-width: 100%; 34 | width: 760px; 35 | order: -1; 36 | } 37 | 38 | .right-widget { 39 | max-width: 100%; 40 | width: 760px; 41 | padding: 0; 42 | order: -2; 43 | } 44 | } -------------------------------------------------------------------------------- /frontend/src/components/design-system/layouts/DualWidgetLayout/index.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames/bind'; 2 | import styles from './DualWidgetLayout.module.scss'; 3 | const cx = classNames.bind(styles); 4 | 5 | interface Props { 6 | leftWidget: React.ReactNode; 7 | rightWidget: React.ReactNode; 8 | content: React.ReactNode; 9 | } 10 | 11 | export function DualWidgetLayout(props: Props) { 12 | return ( 13 |
14 | 17 |
18 | {props.content} 19 |
20 | 23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /frontend/src/components/design-system/layouts/PageNavigationLayout/PageNavigationLayout.module.scss: -------------------------------------------------------------------------------- 1 | @import 'var'; 2 | 3 | .layout { 4 | display: flex; 5 | flex-direction: column; 6 | gap: $MARGIN_SIZE_3; 7 | } 8 | -------------------------------------------------------------------------------- /frontend/src/components/design-system/layouts/PageNavigationLayout/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | PageNavigation, 3 | type PageNavigationProps 4 | } from '~/components/design-system'; 5 | 6 | import styles from './PageNavigationLayout.module.scss'; 7 | 8 | export interface PageNavigationLayoutProps { 9 | navigationItems: PageNavigationProps['items']; 10 | navigationActive: PageNavigationProps['active']; 11 | children: React.ReactNode; 12 | } 13 | 14 | export function PageNavigationLayout({ navigationActive, navigationItems, children }: PageNavigationLayoutProps) { 15 | return ( 16 |
17 | 21 |
22 | {children} 23 |
24 |
25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /frontend/src/components/design-system/layouts/SingleWidgetLayout/SingleWidgetLayout.module.scss: -------------------------------------------------------------------------------- 1 | @import 'var'; 2 | 3 | .layout { 4 | display: grid; 5 | gap: $MARGIN_SIZE_4; 6 | grid-template-columns: minmax(0, 1fr); 7 | grid-template-areas: "content"; 8 | 9 | 10 | &.widgetPositionRight { 11 | grid-template-columns: minmax(0, 1fr) 300px; 12 | grid-template-areas: "content widget"; 13 | } 14 | 15 | &.widgetPositionLeft { 16 | grid-template-columns: 300px minmax(0, 1fr); 17 | grid-template-areas: "widget content"; 18 | } 19 | 20 | .content { 21 | grid-area: content; 22 | } 23 | 24 | .widgetContainer { 25 | grid-area: widget; 26 | } 27 | 28 | .widget { 29 | max-height: calc(100vh - 90px); 30 | position: sticky; 31 | top: 90px; 32 | padding: $MARGIN_SIZE_3; 33 | overflow-y: auto; 34 | gap: $MARGIN_SIZE_4; 35 | 36 | &>*:not(:first-child) { 37 | margin-top: $MARGIN_SIZE_4; 38 | } 39 | } 40 | 41 | @media (max-width: $BREAKPOINT-TABLET) { 42 | 43 | &.widgetPositionRight, 44 | &.widgetPositionLeft { 45 | grid-template-columns: minmax(0, 1fr); 46 | grid-template-areas: 47 | "widget" 48 | "content" 49 | } 50 | 51 | .widget { 52 | padding: 0; 53 | max-height: none; 54 | overflow-y: initial; 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /frontend/src/components/design-system/layouts/SingleWidgetLayout/index.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames/bind'; 2 | import styles from './SingleWidgetLayout.module.scss'; 3 | const cx = classNames.bind(styles); 4 | 5 | export interface SingleWidgetLayoutProps { 6 | children: React.ReactNode; 7 | widget?: React.ReactNode; 8 | widgetPosition?: 'Left' | 'Right'; 9 | } 10 | 11 | export const SingleWidgetLayout = ({ 12 | children, 13 | widget, 14 | widgetPosition = 'Right' 15 | }: SingleWidgetLayoutProps) => { 16 | return ( 17 |
22 |
23 | {children} 24 |
25 | {widget && ( 26 | 31 | )} 32 |
33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /frontend/src/components/design-system/layouts/index.ts: -------------------------------------------------------------------------------- 1 | export * from './DualWidgetLayout'; 2 | export * from './PageNavigationLayout'; 3 | export * from './SingleWidgetLayout'; 4 | -------------------------------------------------------------------------------- /frontend/src/components/design-system/molecules/BadgeGroup/index.tsx: -------------------------------------------------------------------------------- 1 | import { Badge, Flex } from '~/components/design-system'; 2 | 3 | export interface BadgeGroupProps { 4 | className?: string; 5 | hasHash?: boolean; 6 | items: React.ReactNode[]; 7 | } 8 | 9 | export function BadgeGroup(props: BadgeGroupProps) { 10 | return ( 11 | 12 | {props.items.map((item, idx) => ( 13 | item && ( 14 | 15 | {item} 16 | 17 | ) 18 | ))} 19 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /frontend/src/components/design-system/molecules/CapsuleCard/CapsuleCard.module.scss: -------------------------------------------------------------------------------- 1 | @import 'var'; 2 | 3 | .image { 4 | img { 5 | border-radius: 8px 8px 0 0; 6 | object-fit: cover; 7 | width: 100%; 8 | height: 150px; 9 | 10 | @media only screen and (max-width: $BREAKPOINT_DESKTOP) { 11 | height: 200px; 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /frontend/src/components/design-system/molecules/CapsuleCard/index.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames/bind'; 2 | import styles from './CapsuleCard.module.scss'; 3 | const cx = classNames.bind(styles); 4 | 5 | import React from 'react'; 6 | 7 | import type { Gap } from '~/types/style'; 8 | 9 | import type { CardProps } from '~/components/design-system'; 10 | import { Card } from '~/components/design-system'; 11 | 12 | interface CapsuleCardProps extends CardProps { 13 | image: React.ReactNode; 14 | children: React.ReactNode; 15 | padding?: Gap; 16 | } 17 | 18 | export function CapsuleCard({ image, children, padding = 3, ...rest } : CapsuleCardProps) { 19 | return ( 20 | 21 |
22 | {image} 23 |
24 |
25 | {children} 26 |
27 |
28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /frontend/src/components/design-system/molecules/SortableItem/index.tsx: -------------------------------------------------------------------------------- 1 | import { CSS } from '@dnd-kit/utilities'; 2 | import { useSortable } from '@dnd-kit/sortable'; 3 | 4 | interface SortableItemRenderProps { 5 | listeners: ReturnType['listeners']; 6 | } 7 | 8 | interface SortableItemProps { 9 | id: string; 10 | className?: string; 11 | render: (props: SortableItemRenderProps) => React.ReactNode; 12 | } 13 | 14 | export function SortableItem({ id, className, render }: SortableItemProps) { 15 | const { 16 | attributes, 17 | listeners, 18 | setNodeRef, 19 | transform, 20 | transition 21 | } = useSortable({ id }); 22 | 23 | return ( 24 |
32 | {render({ listeners })} 33 |
34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /frontend/src/components/design-system/molecules/VerticalSortable/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | DndContext, 3 | KeyboardSensor, 4 | PointerSensor, 5 | closestCenter, 6 | useSensor, 7 | useSensors, 8 | type DragEndEvent 9 | } from '@dnd-kit/core'; 10 | import { SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy } from '@dnd-kit/sortable'; 11 | import { restrictToFirstScrollableAncestor, restrictToParentElement, restrictToVerticalAxis } from '@dnd-kit/modifiers'; 12 | 13 | interface VerticalSortableProps { 14 | items: string[]; 15 | children: React.ReactNode; 16 | onDragEnd: (event: DragEndEvent) => void; 17 | } 18 | 19 | export function VerticalSortable({ 20 | items, 21 | onDragEnd, 22 | children 23 | }: VerticalSortableProps) { 24 | const sensors = useSensors( 25 | useSensor(PointerSensor), 26 | useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) 27 | ); 28 | 29 | return ( 30 | 39 | 42 | {children} 43 | 44 | 45 | ); 46 | 47 | } 48 | -------------------------------------------------------------------------------- /frontend/src/components/design-system/molecules/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './BadgeGroup'; 2 | export * from './CapsuleCard'; 3 | export * from './Modal'; 4 | export * from './SortableItem'; 5 | export * from './VerticalSortable'; 6 | -------------------------------------------------------------------------------- /frontend/src/components/global.d.ts: -------------------------------------------------------------------------------- 1 | interface Window { 2 | adsbygoogle: { 3 | google_ad_client: string; 4 | enable_page_level_ads: boolean; 5 | }[]; 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './types'; -------------------------------------------------------------------------------- /frontend/src/components/system-design/article-detail-page/article-author/ArticleAuthor.module.scss: -------------------------------------------------------------------------------- 1 | @import 'mixin'; 2 | 3 | .image { 4 | width: 80px; 5 | height: 80px; 6 | 7 | img { 8 | width: 100%; 9 | height: 100%; 10 | border-radius: 50%; 11 | object-fit: cover; 12 | object-position: center; 13 | } 14 | } 15 | 16 | .info { 17 | .username { 18 | margin-bottom: 4px; 19 | } 20 | 21 | .username a { 22 | text-decoration: none; 23 | color: #eee; 24 | font-weight: 600; 25 | } 26 | 27 | .bio a { 28 | text-decoration: none; 29 | color: #777; 30 | } 31 | 32 | ul { 33 | margin-bottom: 0; 34 | } 35 | } -------------------------------------------------------------------------------- /frontend/src/components/system-design/article-detail-page/article-comment/comment-editor/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | 3 | import { Button } from '~/components/design-system'; 4 | import { EditorContent } from '~/components/system-design/article-editor-page'; 5 | 6 | import { snackBar } from '~/modules/ui/snack-bar'; 7 | 8 | export interface CommentEditorProps { 9 | id: number; 10 | content: string; 11 | onCancel: (id: number) => void; 12 | onSubmit: (id: number, content: string) => void; 13 | } 14 | 15 | export function CommentEditor(props: CommentEditorProps) { 16 | const [content, setContent] = useState(props.content); 17 | 18 | const handleSubmit = () => { 19 | if (content == '') { 20 | snackBar('😅 댓글의 내용을 입력해주세요.'); 21 | return; 22 | } 23 | if (content === props.content) { 24 | props.onCancel(props.id); 25 | return; 26 | } 27 | props.onSubmit(props.id, content); 28 | }; 29 | 30 | return ( 31 |
32 | setContent(value)} /> 33 | 37 |
38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /frontend/src/components/system-design/article-detail-page/article-report/ArticleReport.module.scss: -------------------------------------------------------------------------------- 1 | @import 'mixin'; 2 | 3 | .article-report { 4 | display: flex; 5 | justify-content: flex-end; 6 | margin-bottom: 16px; 7 | } 8 | 9 | .button { 10 | @include SUB_SHADOW; 11 | cursor: pointer; 12 | padding: 8px 16px; 13 | border-radius: 50px; 14 | font-size: 0.8rem; 15 | display: flex; 16 | align-items: center; 17 | justify-content: center; 18 | gap: 8px; 19 | 20 | :global(body.dark) & { 21 | color: #ccc; 22 | } 23 | } 24 | 25 | .textarea { 26 | width: 100%; 27 | height: 100px; 28 | border: 1px solid #ccc; 29 | border-radius: 8px; 30 | padding: 10px; 31 | resize: none; 32 | outline: none; 33 | font-size: 1rem; 34 | font-family: $FONT_GOTHIC; 35 | line-height: 1.5; 36 | word-break: keep-all; 37 | overflow-wrap: anywhere; 38 | 39 | :global(body.dark) & { 40 | color: #ccc; 41 | border: 1px solid #333; 42 | background: $COLOR_DARK_BACKGROUND; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /frontend/src/components/system-design/article-detail-page/article-series/ArticleSeries.module.scss: -------------------------------------------------------------------------------- 1 | .series { 2 | ul { 3 | padding: 0; 4 | list-style: none; 5 | display: flex; 6 | flex-direction: column; 7 | gap: 8px; 8 | } 9 | 10 | li { 11 | display: flex; 12 | align-items: center; 13 | gap: 8px; 14 | word-break: keep-all; 15 | overflow-wrap: anywhere; 16 | } 17 | 18 | .count { 19 | margin-left: 5px; 20 | display: inline-block; 21 | font-size: 10px; 22 | padding: 0 3px; 23 | color: #888; 24 | border: 1px solid #ccc; 25 | border-radius: 5px; 26 | flex: 0 0 auto; 27 | 28 | :global(body.dark) & { 29 | color: #ccc; 30 | border: 1px solid #888; 31 | } 32 | } 33 | } -------------------------------------------------------------------------------- /frontend/src/components/system-design/article-detail-page/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './article-action'; 2 | export * from './article-author'; 3 | export * from './article-comment'; 4 | export * from './article-content'; 5 | export * from './article-cover'; 6 | export * from './article-nav'; 7 | export * from './article-report'; 8 | export * from './article-series'; 9 | export * from './article-thanks'; 10 | export * from './related-articles'; 11 | -------------------------------------------------------------------------------- /frontend/src/components/system-design/article-detail-page/related-articles/RelatedArticles.module.scss: -------------------------------------------------------------------------------- 1 | .list { 2 | margin: 24px 0; 3 | padding: 32px 0 8px; 4 | display: flex; 5 | justify-content: space-between; 6 | word-break: keep-all; 7 | border-top: 1px solid #333; 8 | gap: 24px; 9 | 10 | .image { 11 | order: 1; 12 | width: 180px; 13 | height: 180px; 14 | 15 | img { 16 | width: 100%; 17 | height: 100%; 18 | object-fit: cover; 19 | object-position: center; 20 | border-radius: 16px; 21 | } 22 | } 23 | 24 | .content { 25 | flex: 1; 26 | } 27 | 28 | .description { 29 | display: -webkit-box; 30 | -webkit-line-clamp: 3; 31 | -webkit-box-orient: vertical; 32 | overflow: hidden; 33 | 34 | a { 35 | color: #777; 36 | } 37 | } 38 | 39 | @media (max-width: 678px) { 40 | flex-direction: column; 41 | 42 | .image { 43 | order: 0; 44 | width: 100%; 45 | height: 220px; 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /frontend/src/components/system-design/article-editor-page/editor-content/index.tsx: -------------------------------------------------------------------------------- 1 | import dynamic from 'next/dynamic'; 2 | 3 | export const EditorContent = dynamic(() => import('./EditorContent'), { ssr: false }); 4 | 5 | export type * from './EditorContent'; 6 | -------------------------------------------------------------------------------- /frontend/src/components/system-design/article-editor-page/editor-title/EditorTitle.module.scss: -------------------------------------------------------------------------------- 1 | .layout { 2 | height: 200px; 3 | margin-bottom: 8px; 4 | background-color: #fafafa; 5 | background-size: cover; 6 | background-repeat: no-repeat; 7 | background-position: center; 8 | 9 | :global(body.dark) & { 10 | background-color: #151515; 11 | } 12 | 13 | & > div { 14 | position: relative; 15 | display: flex; 16 | justify-content: center; 17 | align-items: center; 18 | width: 100%; 19 | height: 100%; 20 | padding: 16px; 21 | background: rgba(0, 0, 0, 0.5); 22 | 23 | button { 24 | position: absolute; 25 | right: 16px; 26 | bottom: 16px; 27 | background: none; 28 | border: none; 29 | padding: 8px 16px; 30 | outline: none; 31 | color: #aaa; 32 | 33 | &:hover { 34 | color: #ccc; 35 | } 36 | } 37 | 38 | input { 39 | width: 100%; 40 | border: none; 41 | display: block; 42 | font-size: 2rem; 43 | font-weight: bold; 44 | margin-bottom: 15px; 45 | background: none; 46 | color: #fff; 47 | 48 | &:focus { 49 | outline: none; 50 | } 51 | 52 | &::placeholder { 53 | color: #ccc; 54 | } 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /frontend/src/components/system-design/article-editor-page/index.ts: -------------------------------------------------------------------------------- 1 | export * from './editor-content'; 2 | export * from './editor-layout'; 3 | export * from './editor-title'; 4 | export * from './modals'; 5 | -------------------------------------------------------------------------------- /frontend/src/components/system-design/article-editor-page/modals/forms.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Card, 3 | Flex, 4 | Modal 5 | } from '~/components/design-system'; 6 | 7 | import type * as API from '~/modules/api'; 8 | 9 | interface Props { 10 | isOpen: boolean; 11 | onClose: () => void; 12 | forms?: API.GetUserFormsResponseData['forms']; 13 | onFetch: (id: number) => void; 14 | } 15 | 16 | export function FormsModal(props: Props) { 17 | const { forms = [] } = props; 18 | 19 | return ( 20 | 24 | {forms.map((item, idx) => ( 25 | 26 | 27 | props.onFetch(item.id)} className="c-pointer"> 28 | {item.title} 29 | 30 | 31 | 32 | ))} 33 | 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /frontend/src/components/system-design/article-editor-page/modals/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './forms'; 2 | export * from './temp-article'; 3 | export * from './youtube'; -------------------------------------------------------------------------------- /frontend/src/components/system-design/article/CapsuleArticleCard/CapsuleArticleCard.module.scss: -------------------------------------------------------------------------------- 1 | .title { 2 | font-weight: 600; 3 | letter-spacing: -1px; 4 | margin-bottom: 8px; 5 | white-space: nowrap; 6 | overflow: hidden; 7 | text-overflow: ellipsis; 8 | } 9 | -------------------------------------------------------------------------------- /frontend/src/components/system-design/article/article-card-group/ArticleCardGroup.module.scss: -------------------------------------------------------------------------------- 1 | @import 'var'; 2 | 3 | .group { 4 | & > * { 5 | width: 100%; 6 | } 7 | 8 | &.hr { 9 | & > :not(:last-child) { 10 | border-bottom: 1px solid #ede9f5; 11 | 12 | :global(body.dark) & { 13 | border-color: #313136; 14 | } 15 | } 16 | } 17 | 18 | @each $key, $value in $marginSizes { 19 | &.g-#{$key} { 20 | & > :not(:first-child) { 21 | margin-top: $value; 22 | } 23 | & > :not(:last-child) { 24 | padding-bottom: $value; 25 | } 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /frontend/src/components/system-design/article/article-card-group/index.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames/bind'; 2 | import styles from './ArticleCardGroup.module.scss'; 3 | const cx = classNames.bind(styles); 4 | 5 | import type { Gap } from '~/types/style'; 6 | 7 | interface ArticleCardGroupProps { 8 | className?: string; 9 | hasDivider?: boolean; 10 | children: React.ReactNode; 11 | gap?: Gap; 12 | } 13 | 14 | export function ArticleCardGroup({ 15 | className, 16 | children, 17 | hasDivider = false, 18 | gap = 3 19 | }: ArticleCardGroupProps) { 20 | return ( 21 |
22 | {children} 23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /frontend/src/components/system-design/article/article-card-list/ArticleCardList.module.scss: -------------------------------------------------------------------------------- 1 | .list { 2 | display: flex; 3 | justify-content: space-between; 4 | word-break: keep-all; 5 | gap: 24px; 6 | 7 | .image { 8 | order: 1; 9 | width: 180px; 10 | height: 180px; 11 | 12 | img { 13 | width: 100%; 14 | height: 100%; 15 | object-fit: cover; 16 | object-position: center; 17 | border-radius: 16px; 18 | } 19 | } 20 | 21 | .content { 22 | flex: 1; 23 | } 24 | 25 | .description { 26 | display: -webkit-box; 27 | -webkit-line-clamp: 3; 28 | -webkit-box-orient: vertical; 29 | overflow: hidden; 30 | 31 | a { 32 | color: #666; 33 | 34 | :global(body.dark) & { 35 | color: #aaa; 36 | } 37 | } 38 | } 39 | 40 | @media (max-width: 678px) { 41 | flex-direction: column; 42 | 43 | .image { 44 | order: 0; 45 | width: 100%; 46 | height: 220px; 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /frontend/src/components/system-design/article/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './article-card'; 2 | export * from './article-card-group'; 3 | export * from './article-card-list'; 4 | export * from './CapsuleArticleCard'; 5 | export * from './collection-layout'; 6 | -------------------------------------------------------------------------------- /frontend/src/components/system-design/profile/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './profile-layout'; 2 | export * from './profile-navigation'; 3 | export * from './recent-activity'; 4 | export * from './user-articles'; 5 | export * from './user-series'; 6 | -------------------------------------------------------------------------------- /frontend/src/components/system-design/profile/profile-layout/Layout.module.scss: -------------------------------------------------------------------------------- 1 | @import 'mixin'; 2 | 3 | .layout { 4 | display: grid; 5 | grid-template-columns: 320px minmax(0, 1fr); 6 | gap: $MARGIN_SIZE_5; 7 | 8 | @media (max-width: $BREAKPOINT_DESKTOP) { 9 | gap: $MARGIN_SIZE_7; 10 | grid-template-columns: minmax(0, 1fr); 11 | } 12 | } 13 | 14 | .avatar { 15 | width: 100%; 16 | text-align: center; 17 | 18 | @media (max-width: $BREAKPOINT_DESKTOP) { 19 | text-align: left; 20 | } 21 | 22 | img { 23 | border: 1px solid #ccc; 24 | width: 250px; 25 | height: 250px; 26 | background: #fff; 27 | border-radius: 100%; 28 | margin: 16px 0; 29 | object-fit: cover; 30 | 31 | @media (max-width: $BREAKPOINT_DESKTOP) { 32 | width: 100px; 33 | height: 100px; 34 | margin: 4px 0; 35 | } 36 | 37 | :global(body.dark) & { 38 | background: #000; 39 | border-color: #494949; 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /frontend/src/components/system-design/profile/profile-navigation/Navigation.module.scss: -------------------------------------------------------------------------------- 1 | .navigation { 2 | width: 100%; 3 | height: 100px; 4 | 5 | .mask { 6 | width: 100%; 7 | height: 100%; 8 | background-color: rgba(0, 0, 0, .88); 9 | 10 | @media only screen and (max-width: 991px) { 11 | overflow-x: auto; 12 | overflow-y: hidden; 13 | } 14 | } 15 | 16 | ul { 17 | padding: 15px 0; 18 | list-style: none; 19 | text-align: center; 20 | white-space: nowrap; 21 | 22 | li { 23 | display: inline-block; 24 | font-size: 1.2em; 25 | margin: 20px; 26 | cursor: pointer; 27 | 28 | a { 29 | color: #aaa; 30 | text-decoration: none; 31 | } 32 | 33 | &.active { 34 | a { 35 | color: #fff; 36 | } 37 | } 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /frontend/src/components/system-design/profile/recent-activity/RecentActivity.module.scss: -------------------------------------------------------------------------------- 1 | .activity { 2 | position: relative; 3 | margin-top: 1rem; 4 | padding: 0; 5 | 6 | &::before { 7 | content: ''; 8 | z-index: -1; 9 | position: absolute; 10 | top: 0; 11 | left: 1.5rem; 12 | width: 1px; 13 | height: 100%; 14 | background-color: #eee; 15 | 16 | :global(body.dark) & { 17 | background-color: #333; 18 | } 19 | } 20 | 21 | li { 22 | list-style: none; 23 | word-break: keep-all; 24 | overflow-wrap: anywhere; 25 | margin-bottom: 0.25rem; 26 | padding: 0.5rem 0.75rem; 27 | border-radius: 0.5rem; 28 | 29 | i { 30 | margin-top: -0.125rem; 31 | font-size: 0.75rem; 32 | background-color: #eee; 33 | border-radius: 50%; 34 | min-width: 1.5rem; 35 | min-height: 1.5rem; 36 | display: inline-flex; 37 | justify-content: center; 38 | align-items: center; 39 | 40 | :global(body.dark) & { 41 | background-color: #333; 42 | } 43 | } 44 | } 45 | } -------------------------------------------------------------------------------- /frontend/src/components/system-design/profile/recent-activity/index.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames/bind'; 2 | import styles from './RecentActivity.module.scss'; 3 | const cx = classNames.bind(styles); 4 | 5 | import type { ActivityItemProps } from './activity-item'; 6 | import { ActivityItem } from './activity-item'; 7 | 8 | export interface RecentActivityProps { 9 | items: ActivityItemProps[]; 10 | } 11 | 12 | export function RecentActivity({ items }: RecentActivityProps) { 13 | return items.length > 0 && ( 14 |
    15 | {items.map((item, idx) => ( 16 |
  • 17 | 18 |
  • 19 | ))} 20 |
21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /frontend/src/components/system-design/series/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './series-article-card'; 2 | -------------------------------------------------------------------------------- /frontend/src/components/system-design/series/series-article-card/SeriesArticleCard.module.scss: -------------------------------------------------------------------------------- 1 | @import 'mixin'; 2 | 3 | .box { 4 | .thumbnail { 5 | width: 100%; 6 | height: 200px; 7 | object-fit: cover; 8 | } 9 | 10 | .title, 11 | .description { 12 | min-height: 48px; 13 | display: -webkit-box; 14 | -webkit-line-clamp: 2; 15 | -webkit-box-orient: vertical; 16 | overflow: hidden; 17 | } 18 | 19 | .title { 20 | letter-spacing: -0.009em; 21 | } 22 | 23 | .description { 24 | min-height: 63px; 25 | -webkit-line-clamp: 3; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /frontend/src/components/system-design/setting/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './setting-layout'; 2 | export * from './setting-navigation'; -------------------------------------------------------------------------------- /frontend/src/components/system-design/setting/setting-navigation/SettingNavigation.module.scss: -------------------------------------------------------------------------------- 1 | @import 'mixin';; 2 | 3 | .box { 4 | & > .section:not(:last-child) { 5 | border-bottom: 1px solid #ccc; 6 | padding-bottom: 8px; 7 | margin-bottom: 8px; 8 | 9 | :global(body.dark) & { 10 | border-bottom: 1px solid #444; 11 | } 12 | } 13 | 14 | .section { 15 | .title { 16 | @include COLOR_ASHGRAY; 17 | font-size: 12px; 18 | } 19 | 20 | .sub-item { 21 | transition: background-color 1s ease; 22 | 23 | :hover { 24 | background-color: rgba(255, 255, 255, 0.3); 25 | 26 | :global(body.dark) & { 27 | background-color: rgba(0, 0, 0, 0.1); 28 | } 29 | } 30 | 31 | .active { 32 | border-radius: 8px; 33 | @include BACKGROUND; 34 | @include SUB_SHADOW; 35 | } 36 | 37 | a { 38 | @include COLOR_DARK; 39 | text-decoration: none; 40 | } 41 | } 42 | } 43 | } 44 | 45 | -------------------------------------------------------------------------------- /frontend/src/components/system-design/shared/adsense.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | 3 | interface Props { 4 | className?: string; 5 | style?: React.CSSProperties; 6 | client: string; 7 | slot: string; 8 | layout?: string; 9 | layoutKey?: string; 10 | format?: string; 11 | responsive?: string; 12 | pageLevelAds?: boolean; 13 | } 14 | 15 | export const Adsense = ({ 16 | className = '', 17 | style = { display: 'block' }, 18 | client, 19 | slot, 20 | layout = '', 21 | layoutKey = '', 22 | format = 'auto', 23 | responsive = 'false', 24 | pageLevelAds = false, 25 | ...rest 26 | }: Props) => { 27 | useEffect(() => { 28 | try { 29 | if (typeof window === 'object') { 30 | (window.adsbygoogle = window.adsbygoogle || []).push({ 31 | google_ad_client: client, 32 | enable_page_level_ads: pageLevelAds 33 | }); 34 | } 35 | } catch (e) { 36 | // pass 37 | } 38 | }, []); 39 | 40 | return ( 41 | 52 | ); 53 | }; 54 | -------------------------------------------------------------------------------- /frontend/src/components/system-design/shared/footer/Footer.module.scss: -------------------------------------------------------------------------------- 1 | @import 'mixin'; 2 | 3 | .footer { 4 | margin-top: 45px; 5 | 6 | a { 7 | color: #a8a8a8; 8 | 9 | &:hover { 10 | color: $COLOR_DEFAULT_SECONDARY; 11 | text-decoration: none; 12 | } 13 | } 14 | 15 | .content { 16 | margin-bottom: 45px; 17 | } 18 | 19 | .items { 20 | border-top: 1px solid #e7e7e7; 21 | display: flex; 22 | align-items: center; 23 | justify-content: center; 24 | gap: 24px; 25 | padding: 20px 0; 26 | text-align: center; 27 | 28 | :global(body.dark) & { 29 | border-top: 1px solid #3a3a3a; 30 | } 31 | 32 | @media (max-width: 540px) { 33 | padding: 32px; 34 | flex-direction: column; 35 | align-items: flex-start; 36 | } 37 | } 38 | 39 | &.isDark { 40 | margin-top: 0px; 41 | background: #000; 42 | color: #ccc; 43 | 44 | a { 45 | color: #f1f1f1; 46 | } 47 | 48 | .items { 49 | border-top: 1px solid #3a3a3a; 50 | } 51 | } 52 | } -------------------------------------------------------------------------------- /frontend/src/components/system-design/shared/heatmap/Heatmap.module.scss: -------------------------------------------------------------------------------- 1 | .heatmap { 2 | min-height: 200px; 3 | overflow-x: auto !important; 4 | 5 | div { 6 | margin: 0 auto; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /frontend/src/components/system-design/shared/heatmap/index.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames/bind'; 2 | import styles from './Heatmap.module.scss'; 3 | const cx = classNames.bind(styles); 4 | 5 | import { useEffect, useMemo } from 'react'; 6 | import { Chart } from 'frappe-charts'; 7 | 8 | import { Card } from '~/components/design-system'; 9 | 10 | export interface HeatmapProps { 11 | isNightMode: boolean; 12 | data?: { 13 | [key: string]: number; 14 | }; 15 | } 16 | 17 | export function Heatmap(props: HeatmapProps) { 18 | const { data = {} } = props; 19 | 20 | const totalActivity = useMemo(() => { 21 | return Object.values(data).reduce((acc, cur) => acc + cur, 0); 22 | }, [data]); 23 | 24 | useEffect(() => { 25 | new Chart('#heatmap', { 26 | type: 'heatmap', 27 | title: `지난 1년 동안 ${totalActivity}건의 활동을 기록했습니다.`, 28 | data: { 29 | end: new Date(), 30 | dataPoints: data 31 | }, 32 | width: 800, 33 | countLabel: 'Activity', 34 | discreteDomains: 0, 35 | colors: props.isNightMode ? ['#14120f', '#391b74', '#843690', '#dc65c4', '#e69ed8'] : undefined 36 | }); 37 | }, [data, props.isNightMode]); 38 | 39 | return ( 40 | 41 |
42 | 43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /frontend/src/components/system-design/shared/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './day-night'; 2 | export * from './adsense'; 3 | export * from './footer'; 4 | export * from './heatmap'; 5 | export * from './pagination'; 6 | export * from './search-box'; 7 | export * from './seo'; 8 | export * from './social'; 9 | export * from './subscribe-button'; 10 | export * from './top-navigation'; 11 | -------------------------------------------------------------------------------- /frontend/src/components/system-design/shared/modals/account-create-modal.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Modal } from '~/components/design-system'; 4 | 5 | import { oauth } from '~/modules/utility/oauth'; 6 | 7 | import { modalStore } from '~/stores/modal'; 8 | 9 | interface Props { 10 | isOpen: boolean; 11 | onClose: () => void; 12 | } 13 | 14 | export function AccountCreateModal(props: Props) { 15 | return ( 16 | 17 | 22 | 27 |
28 | 35 |
36 |
37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /frontend/src/components/system-design/shared/modals/help-modal.tsx: -------------------------------------------------------------------------------- 1 | import { Modal } from '~/components/design-system'; 2 | 3 | export interface HelpModalProps { 4 | isOpen: boolean; 5 | onClose: () => void; 6 | } 7 | 8 | export function HelpModal({ isOpen, onClose }: HelpModalProps) { 9 | return ( 10 | 11 |

12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /frontend/src/components/system-design/shared/modals/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './account-create-modal'; 2 | export * from './account-delete-modal'; 3 | export * from './auth-get-modal'; 4 | export * from './help-modal'; 5 | export * from './two-factor-auth-get-modal'; 6 | export * from './two-factor-auth-sync-modal'; 7 | -------------------------------------------------------------------------------- /frontend/src/components/system-design/shared/modals/styles.module.scss: -------------------------------------------------------------------------------- 1 | .split { 2 | display: grid; 3 | gap: 16px; 4 | grid-template-columns: 1fr 1fr; 5 | 6 | img { 7 | width: 240px; 8 | max-width: 100%; 9 | 10 | :global(body.dark) & { 11 | filter: invert(1) hue-rotate(180deg) brightness(.8) contrast(1.2); 12 | } 13 | } 14 | 15 | & > div { 16 | display: flex; 17 | flex-direction: column; 18 | justify-content: center; 19 | align-items: center; 20 | } 21 | 22 | .welcome { 23 | gap: 24px; 24 | text-align: center; 25 | word-break: keep-all; 26 | 27 | p { 28 | margin: 0; 29 | color: #666; 30 | font-size: 16px; 31 | } 32 | } 33 | 34 | @media (max-width: 600px) { 35 | grid-template-columns: 1fr; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /frontend/src/components/system-design/shared/social/Social.module.scss: -------------------------------------------------------------------------------- 1 | @import 'mixin'; 2 | 3 | .social { 4 | list-style: none; 5 | padding: 0px; 6 | flex-direction: column; 7 | font-size: 14px; 8 | 9 | a { 10 | @include COLOR_ASHGRAY; 11 | display: flex; 12 | gap: 8px; 13 | 14 | i { 15 | margin-top: 4px; 16 | } 17 | } 18 | 19 | & > li { 20 | margin-top: 8px; 21 | word-break: keep-all; 22 | overflow-wrap: anywhere; 23 | } 24 | 25 | & > li:not(:last-child) { 26 | padding-bottom: 8px; 27 | border-bottom: 1px solid #eee; 28 | 29 | :global(body.dark) & { 30 | border-bottom: 1px solid #444; 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /frontend/src/components/system-design/shared/social/index.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames/bind'; 2 | import styles from './Social.module.scss'; 3 | const cx = classNames.bind(styles); 4 | 5 | import { getIconClassName } from '~/modules/utility/icon-class'; 6 | 7 | export interface SocialProps { 8 | username: string; 9 | homepage?: string; 10 | social?: { 11 | name: string; 12 | value: string; 13 | }[]; 14 | } 15 | 16 | const replaceProtocol = (text: string) => { 17 | return text.replace(/(^\w+:|^)\/\//, ''); 18 | }; 19 | 20 | export function Social(props: SocialProps) { 21 | return ( 22 |

43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /frontend/src/components/system-design/tag/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './tag-card'; 2 | -------------------------------------------------------------------------------- /frontend/src/components/system-design/tag/tag-card/index.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | 3 | import { ImageCard, LazyLoadedImage, Text } from '~/components/design-system'; 4 | 5 | import { getPostImage } from '~/modules/utility/image'; 6 | 7 | export interface TagCardProps { 8 | name: string; 9 | count: number; 10 | image?: string; 11 | } 12 | 13 | export function TagCard(props: TagCardProps) { 14 | return ( 15 | 16 | 24 | )}> 25 | 26 | ({props.count}) {props.name} 27 | 28 | 29 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /frontend/src/components/system-design/widgets/index.ts: -------------------------------------------------------------------------------- 1 | export * from './calendar-widget'; 2 | export * from './service-info-widget'; 3 | export * from './trending-posts-widget'; 4 | -------------------------------------------------------------------------------- /frontend/src/components/types.d.ts: -------------------------------------------------------------------------------- 1 | export type PageLayout = (page: JSX.Element, props: T) => JSX.Element; 2 | 3 | export interface PageComponent { 4 | (props: T): JSX.Element; 5 | pageLayout?: PageLayout; 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/hooks/use-debounce-value.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | export const useDebounceValue = (value: T, delay: number) => { 4 | const [debouncedValue, setDebouncedValue] = useState(value); 5 | 6 | useEffect(() => { 7 | const handler = setTimeout(() => { 8 | setDebouncedValue(value); 9 | }, delay); 10 | 11 | return () => { 12 | clearTimeout(handler); 13 | }; 14 | }, [value, delay]); 15 | 16 | return debouncedValue; 17 | }; 18 | -------------------------------------------------------------------------------- /frontend/src/hooks/use-hide-primary-button.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | export function useHidePrimaryButton() { 4 | useEffect(() => { 5 | document.documentElement.classList.add('hide-primary-button'); 6 | 7 | return () => { 8 | document.documentElement.classList.remove('hide-primary-button'); 9 | }; 10 | }, []); 11 | } 12 | -------------------------------------------------------------------------------- /frontend/src/hooks/use-life-cycle.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | export function useDidMount(fn: () => void) { 4 | useEffect(() => { 5 | fn(); 6 | }, []); 7 | } 8 | 9 | export function useDidUnmount(fn: () => void) { 10 | useEffect(() => { 11 | return () => { 12 | fn(); 13 | }; 14 | }, []); 15 | } 16 | 17 | export function useDidUpdate(fn: () => void, deps: unknown[]) { 18 | useEffect(() => { 19 | fn(); 20 | }, [...deps]); 21 | } 22 | -------------------------------------------------------------------------------- /frontend/src/hooks/use-like-post.ts: -------------------------------------------------------------------------------- 1 | import * as API from '~/modules/api'; 2 | import { snackBar } from '~/modules/ui/snack-bar'; 3 | 4 | import { modalStore } from '~/stores/modal'; 5 | 6 | interface Post { 7 | url: string; 8 | author: string; 9 | hasLiked: boolean; 10 | countLikes: number; 11 | } 12 | 13 | interface Options { 14 | onLike: (post: T, countLikes: number) => void; 15 | onError?: (error?: string) => void; 16 | } 17 | 18 | export const useLikePost = ({ 19 | onLike, 20 | onError 21 | }: Options) => { 22 | return async (post: T) => { 23 | const { data } = await API.putAnUserPosts('@' + post.author, post.url, 'like'); 24 | if (data.status === 'DONE') { 25 | if (typeof data.body.countLikes === 'number') { 26 | onLike(post, data.body.countLikes); 27 | } 28 | } 29 | if (data.status === 'ERROR') { 30 | if (data.errorCode === API.ERROR.NEED_LOGIN) { 31 | snackBar('😅 로그인이 필요합니다.', { 32 | onClick: () => { 33 | modalStore.open('isOpenAuthGetModal'); 34 | } 35 | }); 36 | } 37 | onError?.(data.errorMessage); 38 | } 39 | }; 40 | }; -------------------------------------------------------------------------------- /frontend/src/hooks/use-memory-store.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | 3 | const cache = new Map(); 4 | 5 | function createMemoryStore(key: string, initialValue: T) { 6 | if (cache.has(key)) { 7 | initialValue = cache.get(key); 8 | } 9 | 10 | const itemProxy = new Proxy(initialValue, { 11 | get(target: T, prop) { 12 | return target[prop as keyof T]; 13 | }, 14 | set(target: T, prop, value) { 15 | target[prop as keyof T] = value; 16 | cache.set(key, target); 17 | return true; 18 | } 19 | }); 20 | 21 | return itemProxy; 22 | } 23 | 24 | export function clearMemoryStore(key?: string | unknown[]) { 25 | if (key) { 26 | if (typeof key !== 'string') key = key.join('/'); 27 | cache.delete(key); 28 | } else { 29 | cache.clear(); 30 | } 31 | } 32 | 33 | export function useMemoryStore(key: string | unknown[], initialValue: T) { 34 | if (typeof key !== 'string') key = key.join('/'); 35 | 36 | const store = useMemo(() => createMemoryStore(key as string, initialValue), [key]); 37 | 38 | return store; 39 | } 40 | -------------------------------------------------------------------------------- /frontend/src/jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'jest'; 2 | import nextJest from 'next/jest.js'; 3 | 4 | const createJestConfig = nextJest({ dir: './' }); 5 | 6 | const config: Config = { 7 | coverageProvider: 'v8', 8 | testEnvironment: 'jsdom', 9 | setupFilesAfterEnv: ['./jest.setup.ts'], 10 | moduleNameMapper: { '^~/(.*)$': '/src/$1' } 11 | }; 12 | 13 | export default createJestConfig(config); 14 | -------------------------------------------------------------------------------- /frontend/src/jest.setup.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | -------------------------------------------------------------------------------- /frontend/src/modules/api/forms.ts: -------------------------------------------------------------------------------- 1 | import request, { serializeObject } from './request'; 2 | import type { Headers } from './request'; 3 | 4 | export interface UserFormModel { 5 | title: string; 6 | content: string; 7 | } 8 | 9 | export interface GetUserFormsResponseData { 10 | forms: { 11 | id: number; 12 | title: string; 13 | content: string; 14 | }[]; 15 | } 16 | 17 | export async function getUserForms(headers?: Headers) { 18 | return await request({ 19 | url: '/v1/forms', 20 | method: 'GET', 21 | headers 22 | }); 23 | } 24 | 25 | export interface CreateUserFormResponseData { 26 | id: number; 27 | } 28 | 29 | export async function createUserForm(data: UserFormModel) { 30 | return request({ 31 | url: '/v1/forms', 32 | method: 'POST', 33 | data: serializeObject(data) 34 | }); 35 | } 36 | 37 | export async function getUserForm(id: number) { 38 | return request({ 39 | url: `/v1/forms/${id}`, 40 | method: 'GET' 41 | }); 42 | } 43 | 44 | export async function updateUserForm(id: number, data: UserFormModel) { 45 | return request({ 46 | url: `/v1/forms/${id}`, 47 | method: 'PUT', 48 | data: serializeObject(data) 49 | }); 50 | } 51 | 52 | export async function deleteForms(id: number) { 53 | return request({ 54 | url: `/v1/forms/${id}`, 55 | method: 'DELETE' 56 | }); 57 | } 58 | -------------------------------------------------------------------------------- /frontend/src/modules/api/image.ts: -------------------------------------------------------------------------------- 1 | import request, { objectToForm } from './request'; 2 | 3 | export interface PostImageResponseData { 4 | url: string; 5 | } 6 | 7 | export async function postImage(file: File) { 8 | return await request({ 9 | url: '/v1/image', 10 | method: 'POST', 11 | headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 12 | data: objectToForm({ image: file }) 13 | }); 14 | } 15 | -------------------------------------------------------------------------------- /frontend/src/modules/api/index.ts: -------------------------------------------------------------------------------- 1 | export * from './auth'; 2 | export * from './comments'; 3 | export * from './forms'; 4 | export * from './image'; 5 | export * from './invitation'; 6 | export * from './posts'; 7 | export * from './report'; 8 | export * from './search'; 9 | export * from './series'; 10 | export * from './setting'; 11 | export * from './tags'; 12 | export * from './telegram'; 13 | export * from './users'; 14 | export { ERROR } from './request'; 15 | export type { ResponseData } from './request'; 16 | -------------------------------------------------------------------------------- /frontend/src/modules/api/invitation.ts: -------------------------------------------------------------------------------- 1 | import request, { objectToForm } from './request'; 2 | 3 | export interface GetInvitationOwnerResponse { 4 | user: string; 5 | userImage: string; 6 | userDescription: string; 7 | } 8 | 9 | export async function getInvitationOwners() { 10 | return await request({ 11 | url: '/v1/invitation/owners', 12 | method: 'GET' 13 | }); 14 | } 15 | 16 | export interface GetInvitationRequestsResponse { 17 | sender: string; 18 | senderImage: string; 19 | content: string; 20 | createDate: string; 21 | } 22 | 23 | export async function getInvitationRequests() { 24 | return await request({ 25 | url: '/v1/invitation/requests', 26 | method: 'GET' 27 | }); 28 | } 29 | 30 | export interface CreateRequestInvitationRequestData { 31 | receiver: string; 32 | content: string; 33 | } 34 | 35 | export async function createRequestInvitation(data: CreateRequestInvitationRequestData) { 36 | return await request({ 37 | url: '/v1/invitation/requests', 38 | method: 'POST', 39 | data: objectToForm(data) 40 | }); 41 | } -------------------------------------------------------------------------------- /frontend/src/modules/api/report.ts: -------------------------------------------------------------------------------- 1 | import request, { serializeObject } from './request'; 2 | 3 | export interface PostReportErrorRequestData { 4 | user?: string; 5 | path: string; 6 | content: string; 7 | } 8 | 9 | export async function postReportError(data: PostReportErrorRequestData) { 10 | return await request({ 11 | url: '/v1/report/error', 12 | method: 'POST', 13 | data: serializeObject(data) 14 | }); 15 | } 16 | 17 | export interface PostReportArticleRequestData { 18 | url: string; 19 | content: string; 20 | } 21 | 22 | export async function postReportArticle(data: PostReportArticleRequestData) { 23 | return await request({ 24 | url: `/v1/report/article/${data.url}`, 25 | method: 'POST', 26 | data: serializeObject({ content: data.content }) 27 | }); 28 | } 29 | -------------------------------------------------------------------------------- /frontend/src/modules/api/tags.ts: -------------------------------------------------------------------------------- 1 | import request from './request'; 2 | 3 | export interface GetTagsResponseData { 4 | tags: { 5 | name: string; 6 | count: number; 7 | description: string; 8 | }[]; 9 | lastPage: number; 10 | } 11 | 12 | export async function getTags(page: number) { 13 | return await request({ 14 | url: '/v1/tags', 15 | method: 'GET', 16 | params: { page } 17 | }); 18 | } 19 | 20 | export interface GetTagResponseData { 21 | tag: string; 22 | headPost?: { 23 | url: string; 24 | author: string; 25 | authorImage: string; 26 | description: string; 27 | }; 28 | posts: { 29 | url: string; 30 | title: string; 31 | image: string; 32 | readTime: number; 33 | createdDate: string; 34 | author: string; 35 | authorImage: string; 36 | hasLiked: boolean; 37 | countLikes: number; 38 | countComments: number; 39 | }[]; 40 | lastPage: number; 41 | } 42 | 43 | export async function getTag(tag: string, page: number, cookie?: string) { 44 | return await request({ 45 | url: `/v1/tags/${encodeURIComponent(tag)}`, 46 | method: 'GET', 47 | params: { page }, 48 | headers: { cookie } 49 | }); 50 | } 51 | -------------------------------------------------------------------------------- /frontend/src/modules/api/telegram.ts: -------------------------------------------------------------------------------- 1 | import request from './request'; 2 | 3 | interface PostTelegramResponseData { 4 | token?: string; 5 | } 6 | 7 | export async function postTelegram(parameter: 'unsync' | 'makeToken') { 8 | return await request({ 9 | url: `/v1/telegram/${parameter}`, 10 | method: 'POST', 11 | headers: { 'Content-Type': 'application/x-www-form-urlencoded' } 12 | }); 13 | } 14 | -------------------------------------------------------------------------------- /frontend/src/modules/library/codemirror.ts: -------------------------------------------------------------------------------- 1 | import { getMode } from './codemirror.helper'; 2 | 3 | export async function codeMirrorAll(element?: HTMLElement) { 4 | if (typeof window !== 'undefined') { 5 | const CodeMirror = (await import('codemirror')).default; 6 | 7 | const elements = (element || document).getElementsByTagName('pre'); 8 | 9 | Array.from(elements).forEach(async element => { 10 | const language = element.firstElementChild?.className.replace(/language-(.*)/, '$1'); 11 | const { textContent } = element; 12 | 13 | const textarea = document.createElement('textarea'); 14 | textarea.style.display = 'none'; 15 | textarea.value = textContent?.trimEnd() || ''; 16 | element.replaceWith(textarea); 17 | 18 | CodeMirror.fromTextArea(textarea, { 19 | mode: await getMode(language || ''), 20 | lineNumbers: true, 21 | readOnly: true, 22 | theme: 'material-darker' 23 | }); 24 | }); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /frontend/src/modules/middleware/author.ts: -------------------------------------------------------------------------------- 1 | import { AxiosError } from 'axios'; 2 | import type { GetServerSidePropsResult } from 'next'; 3 | 4 | import * as API from '~/modules/api'; 5 | 6 | export async function authorRenameCheck(error: unknown, options: { 7 | author: string; 8 | continuePath?: string; 9 | }): Promise> { 15 | if (error instanceof AxiosError) { 16 | if (error.response?.status === 404) { 17 | try { 18 | const { data } = await API.checkRedirect({ username: options.author as string }); 19 | 20 | if (data.body.newUsername) { 21 | const encodedUsername = encodeURI(data.body.newUsername); 22 | 23 | return { 24 | redirect: { 25 | destination: `/@${encodedUsername}${options.continuePath ? options.continuePath : ''}`, 26 | permanent: true 27 | } 28 | }; 29 | } 30 | } catch (error) { 31 | console.error(error); 32 | } 33 | } 34 | } 35 | 36 | return { notFound: true }; 37 | } 38 | -------------------------------------------------------------------------------- /frontend/src/modules/optimize/event.ts: -------------------------------------------------------------------------------- 1 | export function optimizeEvent(func: (e?: Event) => void) { 2 | let ticking = false; 3 | 4 | return (e?: Event) => { 5 | if (ticking) return; 6 | 7 | window.requestAnimationFrame(() => { 8 | func(e); 9 | ticking = false; 10 | }); 11 | ticking = true; 12 | }; 13 | } 14 | 15 | export interface DebounceEventRunner { 16 | (e?: T): void; 17 | clear(): void; 18 | } 19 | 20 | export function debounceEvent(callback: (value?: T) => void, ms: number) { 21 | let timer: NodeJS.Timeout; 22 | 23 | const runner: DebounceEventRunner = (value) => { 24 | if (timer) clearTimeout(timer); 25 | timer = setTimeout(() => callback(value), ms); 26 | }; 27 | 28 | runner.clear = () => { 29 | if (timer) clearTimeout(timer); 30 | }; 31 | 32 | return runner; 33 | } 34 | 35 | export function throttleEvent(callback: (value?: T) => void, timing: number) { 36 | let isReady = true; 37 | 38 | return (value?: T) => { 39 | if (isReady) { 40 | isReady = false; 41 | callback(value); 42 | setTimeout(() => isReady = true, timing); 43 | } 44 | }; 45 | } 46 | -------------------------------------------------------------------------------- /frontend/src/modules/settings.ts: -------------------------------------------------------------------------------- 1 | import getConfig from 'next/config'; 2 | 3 | interface Config { 4 | API_KEY?: string; 5 | API_SERVER: string; 6 | STATIC_SERVER: string; 7 | GOOGLE_OAUTH_CLIENT_ID: string; 8 | GITHUB_OAUTH_CLIENT_ID: string; 9 | GOOGLE_ANALYTICS_V4: string; 10 | MICROSOFT_CLARITY: string; 11 | HCAPTCHA_SITE_KEY: string; 12 | GOOGLE_ADSENSE_CLIENT_ID: string; 13 | BLOG_TITLE: string; 14 | } 15 | 16 | const { 17 | publicRuntimeConfig, 18 | serverRuntimeConfig 19 | } = getConfig(); 20 | 21 | export const CONFIG: Config = { 22 | ...publicRuntimeConfig, 23 | ...serverRuntimeConfig, 24 | BLOG_TITLE: publicRuntimeConfig.BLOG_TITLE || 'BLEX' 25 | }; 26 | -------------------------------------------------------------------------------- /frontend/src/modules/ui/snack-bar/index.ts: -------------------------------------------------------------------------------- 1 | import style from './style.module.scss'; 2 | 3 | interface SnackBarOptions { 4 | onClick?: (e: MouseEvent) => void; 5 | } 6 | 7 | const container = (function () { 8 | if (typeof window !== 'undefined') { 9 | const containerName = 'snackbar-container'; 10 | 11 | if (!document.getElementById(containerName)) { 12 | const div = document.createElement('div'); 13 | div.id = containerName; 14 | div.className = style[containerName]; 15 | document.body.appendChild(div); 16 | } 17 | return document.getElementById(containerName); 18 | } 19 | }()) as HTMLElement; 20 | 21 | export function snackBar(text: string, options?: SnackBarOptions) { 22 | container.childNodes.forEach(($node) => { 23 | $node.remove(); 24 | }); 25 | 26 | const snackBar = document.createElement('div'); 27 | snackBar.textContent = text; 28 | snackBar.classList.add(style['snack-bar']); 29 | 30 | if (options?.onClick) { 31 | snackBar.classList.add(style['have-event']); 32 | snackBar.addEventListener('click', options.onClick); 33 | } 34 | 35 | container.appendChild(snackBar); 36 | snackBar.addEventListener('animationend', () => { 37 | snackBar.remove(); 38 | }); 39 | } 40 | -------------------------------------------------------------------------------- /frontend/src/modules/ui/snack-bar/style.module.scss: -------------------------------------------------------------------------------- 1 | @keyframes snack-bar-slide { 2 | 0% { 3 | opacity: 0; 4 | transform: translate(-50%, 100%); 5 | } 6 | 7 | 10% { 8 | opacity: 1; 9 | transform: translate(-50%, 0%); 10 | } 11 | 12 | 90% { 13 | opacity: 1; 14 | transform: translate(-50%, 0%); 15 | } 16 | 17 | 100% { 18 | opacity: 0; 19 | transform: translate(-50%, 100%); 20 | } 21 | } 22 | 23 | .snackbar-container { 24 | position: fixed; 25 | z-index: 1001; 26 | } 27 | 28 | .snack-bar { 29 | pointer-events: none; 30 | position: fixed; 31 | box-sizing: border-box; 32 | width: 500px; 33 | border-radius: 16px 16px 0 0; 34 | max-width: 100%; 35 | bottom: 0; 36 | left: 50%; 37 | padding: 16px; 38 | color: #fff; 39 | text-align: center; 40 | background: rgba(0, 0, 0, 0.75); 41 | transform: translate(-50%, 100%); 42 | animation: snack-bar-slide 4s ease 1; 43 | } 44 | 45 | .snack-bar.have-event { 46 | pointer-events: all; 47 | cursor: pointer; 48 | } 49 | -------------------------------------------------------------------------------- /frontend/src/modules/utility/cookie.ts: -------------------------------------------------------------------------------- 1 | interface CookieOption { 2 | expire?: number; 3 | domain?: string; 4 | path?: string; 5 | } 6 | 7 | function createCookie(name: string, value: string, { 8 | expire, 9 | domain, 10 | path 11 | }: CookieOption) { 12 | let cookie = `${name}=${value};`; 13 | if (expire) { 14 | const date = new Date(); 15 | date.setTime(date.getTime() + (expire * 24 * 60 * 60 * 1000)); 16 | cookie += `Expires=${date.toUTCString()};`; 17 | } 18 | if (domain) { 19 | cookie += `Domain=${domain};`; 20 | } 21 | if (path) { 22 | cookie += `Path=${path};`; 23 | } 24 | return cookie; 25 | } 26 | 27 | export function setCookie(name: string, value: string, options: CookieOption) { 28 | document.cookie = createCookie(name, value, options); 29 | } 30 | 31 | export function getCookie(name: string) { 32 | const value = document.cookie.match(`(^|;) ?${name}=([^;]*)(;|$)`); 33 | return value ? value[2] : null; 34 | } 35 | -------------------------------------------------------------------------------- /frontend/src/modules/utility/darkmode.ts: -------------------------------------------------------------------------------- 1 | export function systemTheme() { 2 | if (typeof window !== 'undefined') { 3 | if (window.matchMedia) { 4 | return window.matchMedia('(prefers-color-scheme: dark)'); 5 | } 6 | } 7 | return null; 8 | } 9 | 10 | export const syncTheme = (() => { 11 | let handleChange: (value: boolean) => void; 12 | const system = systemTheme(); 13 | 14 | system?.addEventListener('change', e => { 15 | if (handleChange) { 16 | if (e.matches) { 17 | handleChange(true); 18 | } else { 19 | handleChange(false); 20 | } 21 | } 22 | }); 23 | 24 | return (onChange: (isDark: boolean) => void, init: boolean) => { 25 | if (init && system?.matches) { 26 | onChange(true); 27 | } 28 | 29 | handleChange = onChange; 30 | }; 31 | })(); 32 | -------------------------------------------------------------------------------- /frontend/src/modules/utility/hash.ts: -------------------------------------------------------------------------------- 1 | export function getHash(text: string) { 2 | let hash = 0; 3 | for (let i = 0; i < text.length; i++) { 4 | hash += text.charCodeAt(i); 5 | } 6 | return hash; 7 | } 8 | -------------------------------------------------------------------------------- /frontend/src/modules/utility/icon-class.ts: -------------------------------------------------------------------------------- 1 | export const getIconClassName = (name: string) => { 2 | if ( 3 | name === 'github' || 4 | name === 'twitter' || 5 | name === 'facebook' || 6 | name === 'instagram' || 7 | name === 'linkedin' || 8 | name === 'youtube' || 9 | name === 'telegram' 10 | ) { 11 | return `fab fa-${name}`; 12 | } 13 | return 'fa fa-link'; 14 | }; 15 | -------------------------------------------------------------------------------- /frontend/src/modules/utility/message.ts: -------------------------------------------------------------------------------- 1 | const LEVEL = { 2 | CONFIRM: '😮 ', 3 | SYSTEM_ERR: '😱 ', 4 | BEFORE_REQ_ERR: '🤔 ', 5 | AFTER_REQ_ERR: '😥 ', 6 | AFTER_REQ_DONE: '😀 ' 7 | }; 8 | 9 | type MessageLevel = keyof typeof LEVEL; 10 | 11 | export function message(level: MessageLevel, text?: string) { 12 | return LEVEL[level] + (text ? text : ''); 13 | } -------------------------------------------------------------------------------- /frontend/src/modules/utility/oauth.ts: -------------------------------------------------------------------------------- 1 | import { CONFIG } from '~/modules/settings'; 2 | import { setCookie } from '~/modules/utility/cookie'; 3 | 4 | export type OauthSocial = 'google' | 'github'; 5 | 6 | export function oauth(social: OauthSocial) { 7 | setCookie('oauth_redirect', location.href, { 8 | path: '/', 9 | expire: 0.1 10 | }); 11 | let url = ''; 12 | switch (social) { 13 | case 'google': 14 | url += 'https://accounts.google.com/o/oauth2/auth'; 15 | url += `?client_id=${CONFIG.GOOGLE_OAUTH_CLIENT_ID}.apps.googleusercontent.com`; 16 | url += `&redirect_uri=${window.location.protocol}//${window.location.hostname}/login/callback/google`; 17 | url += '&response_type=code'; 18 | url += '&scope=openid profile email'; 19 | url += '&approval_prompt=force'; 20 | url += '&access_type=offline'; 21 | break; 22 | case 'github': 23 | url += 'https://github.com/login/oauth/authorize'; 24 | url += `?client_id=${CONFIG.GITHUB_OAUTH_CLIENT_ID}`; 25 | url += `&redirect_uri=${window.location.protocol}//${window.location.hostname}/login/callback/github`; 26 | break; 27 | } 28 | location.href = url; 29 | } -------------------------------------------------------------------------------- /frontend/src/modules/utility/object.ts: -------------------------------------------------------------------------------- 1 | interface SortedOptions { 2 | key?: keyof T; 3 | reverse?: boolean; 4 | } 5 | 6 | export function sorted(list: readonly T[], { 7 | key = undefined, 8 | reverse = false 9 | }: SortedOptions) { 10 | const newList = [...list]; 11 | 12 | if (key) { 13 | newList.sort((left, right) => 14 | left[key] < right[key] 15 | ? -1 16 | : left[key] > right[key] 17 | ? 1 18 | : 0 19 | ); 20 | } else { 21 | newList.sort(); 22 | } 23 | 24 | return reverse 25 | ? newList.reverse() 26 | : newList; 27 | } -------------------------------------------------------------------------------- /frontend/src/modules/utility/report.ts: -------------------------------------------------------------------------------- 1 | import { postReportError } from '~/modules/api'; 2 | 3 | import { authStore } from '~/stores/auth'; 4 | 5 | export function bindErrorReport() { 6 | if (typeof window !== 'undefined') { 7 | window.onerror = (e) => { 8 | const { href: path } = window.location; 9 | postReportError({ 10 | user: authStore.state.username, 11 | path, 12 | content: JSON.stringify(e) 13 | }); 14 | }; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /frontend/src/modules/utility/string.ts: -------------------------------------------------------------------------------- 1 | export function minify(text: string) { 2 | return text.replace(/\s/g, '') 3 | .replace(/function/g, 'function ') 4 | .replace(/var/g, 'var ') 5 | .replace(/new/g, 'new '); 6 | } 7 | 8 | export function unescape(text: string) { 9 | return text.replace(/</g, '<') 10 | .replace(/>/g, '>') 11 | .replace(/&/g, '&'); 12 | } 13 | 14 | export function slugify(text: string) { 15 | return text.toString() 16 | .toLowerCase() 17 | .replace(/[^ㄱ-ㅎ가-힣a-z0-9-]/g, '-') 18 | .replace(/--+/g, '-'); 19 | } 20 | -------------------------------------------------------------------------------- /frontend/src/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information. 6 | -------------------------------------------------------------------------------- /frontend/src/pages/404.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | 3 | import { Button, Container, Flex, Text } from '~/components/design-system'; 4 | import { SEO } from '~/components/system-design/shared'; 5 | 6 | export default function NotFound() { 7 | return ( 8 | <> 9 | 10 | 11 | 12 | 13 | 14 | 15 | 해당 페이지는 삭제되었거나, 수정되었습니다. 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /frontend/src/pages/[author]/about/index.tsx: -------------------------------------------------------------------------------- 1 | import type { GetServerSideProps } from 'next'; 2 | 3 | export const getServerSideProps: GetServerSideProps = async (context) => { 4 | const { author = '' } = context.query as Record; 5 | 6 | if (!author.startsWith('@')) { 7 | return { notFound: true }; 8 | } 9 | 10 | return { 11 | props: {}, 12 | redirect: { 13 | destination: `/${author}`, 14 | permanent: true 15 | } 16 | }; 17 | }; 18 | 19 | export default function About() { 20 | return null; 21 | } 22 | -------------------------------------------------------------------------------- /frontend/src/pages/[author]/posts/[tag].tsx: -------------------------------------------------------------------------------- 1 | import type { GetServerSideProps } from 'next'; 2 | 3 | export const getServerSideProps: GetServerSideProps = async (context) => { 4 | const { 5 | author = '', 6 | tag = '' 7 | } = context.query as Record; 8 | 9 | if (!author.startsWith('@')) { 10 | return { notFound: true }; 11 | } 12 | 13 | return { 14 | redirect: { 15 | destination: encodeURI(`/${author}/posts?tag=${tag}`), 16 | permanent: true 17 | }, 18 | props: {} 19 | }; 20 | }; 21 | 22 | export default function UserTagPosts() { 23 | return null; 24 | } 25 | -------------------------------------------------------------------------------- /frontend/src/pages/api/blexer.ts: -------------------------------------------------------------------------------- 1 | import { CONFIG } from '~/modules/settings'; 2 | import blexer from '~/modules/utility/blexer'; 3 | 4 | import type { 5 | NextApiRequest, 6 | NextApiResponse 7 | } from 'next'; 8 | 9 | export default function handler(req: NextApiRequest, res: NextApiResponse) { 10 | if (req.method === 'POST') { 11 | const { token, text } = req.body; 12 | 13 | if (token === CONFIG.API_KEY) { 14 | res.status(200).json({ text: blexer(text) }); 15 | } 16 | } 17 | res.status(404).end(); 18 | } -------------------------------------------------------------------------------- /frontend/src/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baealex/BLEX/98dc2a4b9d5258b5302c4c72c63381fadac29ad8/frontend/src/public/favicon.ico -------------------------------------------------------------------------------- /frontend/src/public/fonts/SpoqaHanSansNeo/SpoqaHanSansNeo-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baealex/BLEX/98dc2a4b9d5258b5302c4c72c63381fadac29ad8/frontend/src/public/fonts/SpoqaHanSansNeo/SpoqaHanSansNeo-Bold.ttf -------------------------------------------------------------------------------- /frontend/src/public/fonts/SpoqaHanSansNeo/SpoqaHanSansNeo-Bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baealex/BLEX/98dc2a4b9d5258b5302c4c72c63381fadac29ad8/frontend/src/public/fonts/SpoqaHanSansNeo/SpoqaHanSansNeo-Bold.woff -------------------------------------------------------------------------------- /frontend/src/public/fonts/SpoqaHanSansNeo/SpoqaHanSansNeo-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baealex/BLEX/98dc2a4b9d5258b5302c4c72c63381fadac29ad8/frontend/src/public/fonts/SpoqaHanSansNeo/SpoqaHanSansNeo-Bold.woff2 -------------------------------------------------------------------------------- /frontend/src/public/fonts/SpoqaHanSansNeo/SpoqaHanSansNeo-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baealex/BLEX/98dc2a4b9d5258b5302c4c72c63381fadac29ad8/frontend/src/public/fonts/SpoqaHanSansNeo/SpoqaHanSansNeo-Regular.ttf -------------------------------------------------------------------------------- /frontend/src/public/fonts/SpoqaHanSansNeo/SpoqaHanSansNeo-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baealex/BLEX/98dc2a4b9d5258b5302c4c72c63381fadac29ad8/frontend/src/public/fonts/SpoqaHanSansNeo/SpoqaHanSansNeo-Regular.woff -------------------------------------------------------------------------------- /frontend/src/public/fonts/SpoqaHanSansNeo/SpoqaHanSansNeo-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baealex/BLEX/98dc2a4b9d5258b5302c4c72c63381fadac29ad8/frontend/src/public/fonts/SpoqaHanSansNeo/SpoqaHanSansNeo-Regular.woff2 -------------------------------------------------------------------------------- /frontend/src/public/logo114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baealex/BLEX/98dc2a4b9d5258b5302c4c72c63381fadac29ad8/frontend/src/public/logo114.png -------------------------------------------------------------------------------- /frontend/src/public/logo120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baealex/BLEX/98dc2a4b9d5258b5302c4c72c63381fadac29ad8/frontend/src/public/logo120.png -------------------------------------------------------------------------------- /frontend/src/public/logo144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baealex/BLEX/98dc2a4b9d5258b5302c4c72c63381fadac29ad8/frontend/src/public/logo144.png -------------------------------------------------------------------------------- /frontend/src/public/logo152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baealex/BLEX/98dc2a4b9d5258b5302c4c72c63381fadac29ad8/frontend/src/public/logo152.png -------------------------------------------------------------------------------- /frontend/src/public/logo16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baealex/BLEX/98dc2a4b9d5258b5302c4c72c63381fadac29ad8/frontend/src/public/logo16.png -------------------------------------------------------------------------------- /frontend/src/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baealex/BLEX/98dc2a4b9d5258b5302c4c72c63381fadac29ad8/frontend/src/public/logo192.png -------------------------------------------------------------------------------- /frontend/src/public/logo32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baealex/BLEX/98dc2a4b9d5258b5302c4c72c63381fadac29ad8/frontend/src/public/logo32.png -------------------------------------------------------------------------------- /frontend/src/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baealex/BLEX/98dc2a4b9d5258b5302c4c72c63381fadac29ad8/frontend/src/public/logo512.png -------------------------------------------------------------------------------- /frontend/src/public/logo57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baealex/BLEX/98dc2a4b9d5258b5302c4c72c63381fadac29ad8/frontend/src/public/logo57.png -------------------------------------------------------------------------------- /frontend/src/public/logo72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baealex/BLEX/98dc2a4b9d5258b5302c4c72c63381fadac29ad8/frontend/src/public/logo72.png -------------------------------------------------------------------------------- /frontend/src/public/logo76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baealex/BLEX/98dc2a4b9d5258b5302c4c72c63381fadac29ad8/frontend/src/public/logo76.png -------------------------------------------------------------------------------- /frontend/src/public/logo96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baealex/BLEX/98dc2a4b9d5258b5302c4c72c63381fadac29ad8/frontend/src/public/logo96.png -------------------------------------------------------------------------------- /frontend/src/public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / 3 | Disallow: /search 4 | Sitemap: https://blex.me/sitemap.xml -------------------------------------------------------------------------------- /frontend/src/stores/auth.ts: -------------------------------------------------------------------------------- 1 | import Store from 'badland'; 2 | 3 | import type { GetLoginResponseData } from '~/modules/api'; 4 | 5 | export interface AuthStoreState extends GetLoginResponseData { 6 | isConfirmed: boolean; 7 | isLogin: boolean; 8 | } 9 | 10 | const INIT_STATE = { 11 | isConfirmed: false, 12 | isLogin: false, 13 | username: '', 14 | name: '', 15 | email: '', 16 | avatar: '', 17 | notifyCount: 0, 18 | isFirstLogin: false, 19 | hasConnectedTelegram: false, 20 | hasConnected2fa: false, 21 | hasEditorRole: false 22 | }; 23 | 24 | class AuthStore extends Store { 25 | constructor() { 26 | super(); 27 | this.state = { ...INIT_STATE }; 28 | } 29 | 30 | logout() { 31 | if (location.pathname.startsWith('/setting')) { 32 | location.href = '/'; 33 | return; 34 | } 35 | this.state = { ...INIT_STATE }; 36 | } 37 | } 38 | 39 | export const authStore = new AuthStore(); 40 | -------------------------------------------------------------------------------- /frontend/src/stores/loading.ts: -------------------------------------------------------------------------------- 1 | import Store from 'badland'; 2 | 3 | export interface LoadingStoreState { 4 | isLoading: boolean; 5 | } 6 | 7 | class LoadingStore extends Store { 8 | constructor() { 9 | super(); 10 | this.state = { isLoading: false }; 11 | } 12 | 13 | start() { 14 | this.set((prevState) => ({ 15 | ...prevState, 16 | isLoading: true 17 | })); 18 | } 19 | 20 | end() { 21 | this.set((prevState) => ({ 22 | ...prevState, 23 | isLoading: false 24 | })); 25 | } 26 | } 27 | 28 | export const loadingStore = new LoadingStore(); 29 | -------------------------------------------------------------------------------- /frontend/src/stores/modal.ts: -------------------------------------------------------------------------------- 1 | import Store from 'badland'; 2 | 3 | export interface ModalStoreState { 4 | isOpenAuthGetModal: boolean; 5 | isOpenAccountCreateModal: boolean; 6 | isOpenAccountDeleteModal: boolean; 7 | isOpenArticlePublishModal: boolean; 8 | isOpenHelpModal: boolean; 9 | isOpenTwoFactorAuthGetModal: boolean; 10 | isOpenTwoFactorAuthSyncModal: boolean; 11 | } 12 | 13 | type ModalName = keyof ModalStoreState; 14 | 15 | class ModalStore extends Store { 16 | constructor() { 17 | super(); 18 | this.state = { 19 | isOpenAuthGetModal: false, 20 | isOpenAccountCreateModal: false, 21 | isOpenAccountDeleteModal: false, 22 | isOpenArticlePublishModal: false, 23 | isOpenHelpModal: false, 24 | isOpenTwoFactorAuthGetModal: false, 25 | isOpenTwoFactorAuthSyncModal: false 26 | }; 27 | } 28 | 29 | async open(modalName: ModalName) { 30 | await this.set((prevState) => ({ 31 | ...prevState, 32 | [modalName]: true 33 | })); 34 | } 35 | 36 | async close(modalName: ModalName) { 37 | await this.set((prevState) => ({ 38 | ...prevState, 39 | [modalName]: false 40 | })); 41 | } 42 | } 43 | 44 | export const modalStore = new ModalStore(); 45 | -------------------------------------------------------------------------------- /frontend/src/styles/_common/_font.scss: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'Spoqa Han Sans Neo'; 3 | font-weight: 700; 4 | src: local('Spoqa Han Sans Bold'), 5 | url('/fonts/SpoqaHanSansNeo/SpoqaHanSansNeo-Bold.woff2') format('woff2'), 6 | url('/fonts/SpoqaHanSansNeo/SpoqaHanSansNeo-Bold.woff') format('woff'), 7 | url('/fonts/SpoqaHanSansNeo/SpoqaHanSansNeo-Bold.ttf') format('truetype'); 8 | } 9 | 10 | @font-face { 11 | font-family: 'Spoqa Han Sans Neo'; 12 | font-weight: 400; 13 | src: local('Spoqa Han Sans Regular'), 14 | url('/fonts/SpoqaHanSansNeo/SpoqaHanSansNeo-Regular.woff2') format('woff2'), 15 | url('/fonts/SpoqaHanSansNeo/SpoqaHanSansNeo-Regular.woff') format('woff'), 16 | url('/fonts/SpoqaHanSansNeo/SpoqaHanSansNeo-Regular.ttf') format('truetype'); 17 | } 18 | 19 | /* Style */ 20 | body { 21 | font-family: $FONT_GOTHIC !important; 22 | } 23 | 24 | /* Size */ 25 | .ns { 26 | font-size: 14px; 27 | } 28 | 29 | .vs { 30 | font-size: 10px; 31 | } 32 | 33 | .text-center { 34 | text-align: center; 35 | } 36 | 37 | .text-right { 38 | text-align: right; 39 | } 40 | 41 | .title-1-spacing { 42 | letter-spacing: -0.125em; 43 | } 44 | 45 | .title-2-spacing { 46 | letter-spacing: -0.0625em; 47 | } 48 | 49 | .title-3-spacing { 50 | letter-spacing: -0.03125em; 51 | } -------------------------------------------------------------------------------- /frontend/src/styles/_common/_layout.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | max-width: 1200px; 3 | margin: 0 auto; 4 | padding: 0 15px; 5 | } 6 | 7 | .w-100 { 8 | width: 100%; 9 | } 10 | 11 | .h-100 { 12 | height: 100%; 13 | } -------------------------------------------------------------------------------- /frontend/src/styles/_common/_margin.scss: -------------------------------------------------------------------------------- 1 | @each $key, $value in $marginSizes { 2 | .m-#{$key} { 3 | margin: $value; 4 | } 5 | .mt-#{$key} { 6 | margin-top: $value; 7 | } 8 | .mr-#{$key} { 9 | margin-right: $value; 10 | } 11 | .mb-#{$key} { 12 | margin-bottom: $value; 13 | } 14 | .ml-#{$key} { 15 | margin-left: $value; 16 | } 17 | .mx-#{$key} { 18 | margin-left: $value; 19 | margin-right: $value; 20 | } 21 | .my-#{$key} { 22 | margin-top: $value; 23 | margin-bottom: $value; 24 | } 25 | } 26 | 27 | @each $key, $value in $marginSizes { 28 | .p-#{$key} { 29 | padding: $value; 30 | } 31 | .pt-#{$key} { 32 | padding-top: $value; 33 | } 34 | .pr-#{$key} { 35 | padding-right: $value; 36 | } 37 | .pb-#{$key} { 38 | padding-bottom: $value; 39 | } 40 | .pl-#{$key} { 41 | padding-left: $value; 42 | } 43 | .px-#{$key} { 44 | padding-left: $value; 45 | padding-right: $value; 46 | } 47 | .py-#{$key} { 48 | padding-top: $value; 49 | padding-bottom: $value; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /frontend/src/styles/_common/index.scss: -------------------------------------------------------------------------------- 1 | @import './color'; 2 | @import './font'; 3 | @import './layout'; 4 | @import './margin' -------------------------------------------------------------------------------- /frontend/src/styles/_fontawesome.override.scss: -------------------------------------------------------------------------------- 1 | $rotates: 45, 90, 180, 270; 2 | 3 | @each $rotate in $rotates { 4 | .fa-rotate-#{$rotate} { 5 | transform: rotate(#{$rotate}deg); 6 | } 7 | } 8 | 9 | .fa-heading { 10 | 11 | .one &, 12 | .two &, 13 | .three & { 14 | position: relative; 15 | } 16 | 17 | .one &::after, 18 | .two &::after, 19 | .three &::after { 20 | position: absolute; 21 | display: block; 22 | font-size: 0.5em; 23 | content: ''; 24 | bottom: 0; 25 | right: -5px; 26 | } 27 | 28 | .one &::after { 29 | content: '1' 30 | } 31 | 32 | .two &::after { 33 | content: '2' 34 | } 35 | 36 | .three &::after { 37 | content: '3' 38 | } 39 | } -------------------------------------------------------------------------------- /frontend/src/styles/_var/_color.scss: -------------------------------------------------------------------------------- 1 | $COLOR_DEFAULT_PRIMARY: #8C00FF; 2 | $COLOR_DEFAULT_SECONDARY: #A076F1; 3 | $COLOR_DEFAULT_POINT: #ffdd00; 4 | $COLOR_DEFAULT_BACKGROUND: #FFFFFF; 5 | 6 | $COLOR_DARK_PRIMARY: scale-color($COLOR_DEFAULT_PRIMARY, $lightness: +50%); 7 | $COLOR_DARK_SECONDARY: $COLOR_DEFAULT_SECONDARY; 8 | $COLOR_DARK_POINT: $COLOR_DEFAULT_POINT; 9 | $COLOR_DARK_BACKGROUND: #1E1E1E; 10 | 11 | $COLOR_DEFAULT_DARK: rgba(0, 0, 0, 0.839); 12 | $COLOR_DEFAULT_ASHGRAY: rgba(0, 0, 0, 0.541); 13 | $COLOR_DEFAULT_GRAY: rgba(160, 160, 160, 0.541); 14 | 15 | $COLOR_DARK_DARK: rgba(255, 255, 255, 0.839); 16 | $COLOR_DARK_ASHGRAY: rgba(255, 255, 255, 0.541); 17 | 18 | $COLOR_BACKGROUND: ( 19 | 'default': #FFF, 20 | 'dark': #1E1E1E, 21 | ); 22 | $COLOR_CARD: ( 23 | 'default': #F5F5F8, 24 | 'dark': #2A2A2A, 25 | ); 26 | $COLOR_PRIMARY: ( 27 | 'default': $COLOR_DEFAULT_PRIMARY, 28 | 'dark': $COLOR_DARK_PRIMARY, 29 | ); 30 | $COLOR_SECONDARY: ( 31 | 'default': $COLOR_DEFAULT_SECONDARY, 32 | 'dark': $COLOR_DARK_SECONDARY, 33 | ); 34 | $COLOR_POINT: ( 35 | 'default': $COLOR_DEFAULT_POINT, 36 | 'dark': $COLOR_DARK_POINT, 37 | ); 38 | $COLOR_DARK: ( 39 | 'default': $COLOR_DEFAULT_DARK, 40 | 'dark': $COLOR_DARK_DARK, 41 | ); 42 | $COLOR_ASHGRAY: ( 43 | 'default': $COLOR_DEFAULT_ASHGRAY, 44 | 'dark': $COLOR_DARK_ASHGRAY, 45 | ); 46 | $COLOR_GRAY: ( 47 | 'default': $COLOR_DEFAULT_GRAY, 48 | 'dark': $COLOR_DEFAULT_GRAY, 49 | ); 50 | -------------------------------------------------------------------------------- /frontend/src/styles/_var/_font.scss: -------------------------------------------------------------------------------- 1 | $FONT_SERIF: 'Noto Serif KR', serif; 2 | $FONT_GOTHIC: 'Spoqa Han Sans Neo', -apple-system, BlinkMacSystemFont, sans-serif; 3 | 4 | $FONT_SIZE_1: 10px; 5 | $FONT_SIZE_2: 12px; 6 | $FONT_SIZE_3: 14px; 7 | $FONT_SIZE_4: 16px; 8 | $FONT_SIZE_5: 18px; 9 | $FONT_SIZE_6: 20px; 10 | $FONT_SIZE_7: 22px; 11 | $FONT_SIZE_8: 24px; 12 | 13 | $fontSizes: ( 14 | '1': $FONT_SIZE_1, 15 | '2': $FONT_SIZE_2, 16 | '3': $FONT_SIZE_3, 17 | '4': $FONT_SIZE_4, 18 | '5': $FONT_SIZE_5, 19 | '6': $FONT_SIZE_6, 20 | '7': $FONT_SIZE_7, 21 | '8': $FONT_SIZE_8, 22 | ); 23 | -------------------------------------------------------------------------------- /frontend/src/styles/_var/_margin.scss: -------------------------------------------------------------------------------- 1 | $MARGIN_SIZE_0: 0; 2 | $MARGIN_SIZE_1: 4px; 3 | $MARGIN_SIZE_2: 8px; 4 | $MARGIN_SIZE_3: 16px; 5 | $MARGIN_SIZE_4: 24px; 6 | $MARGIN_SIZE_5: 32px; 7 | $MARGIN_SIZE_6: 40px; 8 | $MARGIN_SIZE_7: 48px; 9 | 10 | $marginSizes : ( 11 | 0: $MARGIN_SIZE_0, 12 | 1: $MARGIN_SIZE_1, 13 | 2: $MARGIN_SIZE_2, 14 | 3: $MARGIN_SIZE_3, 15 | 4: $MARGIN_SIZE_4, 16 | 5: $MARGIN_SIZE_5, 17 | 6: $MARGIN_SIZE_6, 18 | 7: $MARGIN_SIZE_7 19 | ); 20 | -------------------------------------------------------------------------------- /frontend/src/styles/_var/index.scss: -------------------------------------------------------------------------------- 1 | @import '@baejino/style/scss/var/breakpoint'; 2 | @import './color'; 3 | @import './font'; 4 | @import './margin'; 5 | -------------------------------------------------------------------------------- /frontend/src/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "allowSyntheticDefaultImports": true, 5 | "baseUrl": ".", 6 | "paths": { 7 | "~/*": [ 8 | "./*" 9 | ], 10 | }, 11 | "jsx": "preserve", 12 | "lib": [ 13 | "dom", 14 | "es2017" 15 | ], 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "noEmit": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "preserveConstEnums": true, 22 | "removeComments": false, 23 | "skipLibCheck": true, 24 | "sourceMap": true, 25 | "strict": true, 26 | "target": "esnext", 27 | "forceConsistentCasingInFileNames": true, 28 | "esModuleInterop": true, 29 | "resolveJsonModule": true, 30 | "isolatedModules": true, 31 | "incremental": true, 32 | "plugins": [ 33 | { 34 | "name": "next" 35 | } 36 | ] 37 | }, 38 | "files": [ 39 | "next-env.d.ts" 40 | ], 41 | "include": [ 42 | "**/*.ts", 43 | "**/*.tsx", 44 | "pages/tags", 45 | ".next/types/**/*.ts" 46 | ], 47 | "exclude": [ 48 | "node_modules" 49 | ] 50 | } 51 | -------------------------------------------------------------------------------- /frontend/src/types/frappe-charts/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'frappe-charts'; -------------------------------------------------------------------------------- /frontend/src/types/style.ts: -------------------------------------------------------------------------------- 1 | export type Gap = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7; 2 | -------------------------------------------------------------------------------- /nginx/development.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 8080 default_server; 3 | listen [::]:8080 default_server; 4 | 5 | root /app/static; 6 | } 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "blex", 3 | "version": "1.0.0", 4 | "description": "BLOG EXPRESS ME", 5 | "scripts": { 6 | "dev": "ts-node dev-tools/dev.ts", 7 | "deploy": "ts-node dev-tools/deploy.ts", 8 | "backend::shell": "docker compose -p blex exec backend python manage.py shell", 9 | "backend::connect": "docker compose -p blex exec backend /bin/bash", 10 | "backend::dev::test": "docker compose -p blex_dev -f docker-compose.dev.yml exec backend python manage.py test -v 2", 11 | "backend::dev::shell": "docker compose -p blex_dev -f docker-compose.dev.yml exec backend python manage.py shell", 12 | "backend::dev::connect": "docker compose -p blex_dev -f docker-compose.dev.yml exec backend /bin/bash" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/baealex/BLEX.git" 17 | }, 18 | "author": "", 19 | "license": "ISC", 20 | "bugs": { 21 | "url": "https://github.com/baealex/BLEX/issues" 22 | }, 23 | "homepage": "https://github.com/baealex/BLEX#readme", 24 | "devDependencies": { 25 | "ts-node": "^10.9.2", 26 | "typescript": "^5.5.4" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | }, 5 | } --------------------------------------------------------------------------------