├── .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 |
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 |
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 | {text} |
19 | ))}
20 |
21 |
22 |
23 | {body.map((row, bodyIdx) => (
24 |
25 | {row.map((text, rowIdx) => (
26 |
29 | {text}
30 | |
31 | ))}
32 |
33 | ))}
34 |
35 |
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 | }
--------------------------------------------------------------------------------