├── public ├── robots.txt ├── favicon-48.png ├── icon-192.png ├── icon-512.png ├── icon-maskable-512.png └── favicon-any.svg ├── src ├── store │ ├── index.ts │ └── user-store.ts ├── vite-env.d.ts ├── features │ ├── misc │ │ ├── components │ │ │ ├── index.ts │ │ │ └── showcase │ │ │ │ ├── index.ts │ │ │ │ ├── with-showcase.tsx │ │ │ │ ├── analyzer-showcase.tsx │ │ │ │ ├── generator-showcase.tsx │ │ │ │ ├── editor-showcase.tsx │ │ │ │ ├── scroll-down-button.tsx │ │ │ │ ├── hero-showcase.tsx │ │ │ │ └── showcase-template.tsx │ │ ├── index.ts │ │ └── pages │ │ │ ├── index.ts │ │ │ ├── home.tsx │ │ │ └── error-component.tsx │ ├── syntax-analyzer │ │ ├── constants │ │ │ ├── index.ts │ │ │ └── settings.ts │ │ ├── pages │ │ │ ├── index.ts │ │ │ └── syntax-analyzer.tsx │ │ ├── api │ │ │ ├── index.ts │ │ │ ├── get-random-sentences.ts │ │ │ ├── create-analysis.ts │ │ │ └── get-remaining-counts.ts │ │ ├── hooks │ │ │ ├── index.ts │ │ │ ├── use-inject-analysis.ts │ │ │ ├── use-random-sentence-form.ts │ │ │ └── use-analysis-form.ts │ │ ├── schemes │ │ │ ├── index.ts │ │ │ ├── analysis-form-schema.ts │ │ │ ├── english-sentence-schema.ts │ │ │ └── random-sentence-form-schema.ts │ │ ├── index.ts │ │ └── components │ │ │ ├── analysis-form │ │ │ ├── index.ts │ │ │ ├── model-choice-group.tsx │ │ │ ├── analysis-counter.tsx │ │ │ ├── sentence-input.tsx │ │ │ └── analysis-form.tsx │ │ │ ├── index.ts │ │ │ ├── field-group-header.tsx │ │ │ ├── usage-limit-tooltip.tsx │ │ │ ├── random-sentence-form │ │ │ ├── index.ts │ │ │ ├── random-sentence-instructions.tsx │ │ │ ├── sentence-count-picker.tsx │ │ │ ├── generate-button.tsx │ │ │ ├── topic-tag-list.tsx │ │ │ ├── random-sentence-form.tsx │ │ │ ├── random-sentence-list.tsx │ │ │ └── add-topic-form.tsx │ │ │ ├── loading-transition.tsx │ │ │ └── analysis-load-indicator.tsx │ └── syntax-editor │ │ ├── types │ │ ├── index.ts │ │ ├── constituent.ts │ │ └── analysis.ts │ │ ├── constants │ │ ├── index.ts │ │ ├── constituent-dom.ts │ │ ├── settings.ts │ │ └── colors.ts │ │ ├── data │ │ ├── index.ts │ │ ├── constituent-translations.ts │ │ └── syntax-constituents.ts │ │ ├── components │ │ ├── index.ts │ │ ├── tag-list-accordion │ │ │ ├── index.ts │ │ │ ├── selectable-tag-button.tsx │ │ │ └── tag-list-accordion.tsx │ │ ├── sentence-manager │ │ │ ├── index.ts │ │ │ ├── deletable-sentence.tsx │ │ │ ├── add-sentence-form.tsx │ │ │ └── sentence-list.tsx │ │ ├── syntax-parser │ │ │ ├── index.ts │ │ │ ├── token-list.tsx │ │ │ ├── sentence.tsx │ │ │ ├── segment.tsx │ │ │ ├── token.tsx │ │ │ ├── syntax-parser.tsx │ │ │ ├── constituent.tsx │ │ │ └── segment-list.tsx │ │ └── control-panel │ │ │ ├── index.ts │ │ │ ├── tag-info-switch.tsx │ │ │ ├── abbr-info-switch.tsx │ │ │ ├── undo-button.tsx │ │ │ ├── redo-button.tsx │ │ │ ├── delete-button.tsx │ │ │ ├── reset-button.tsx │ │ │ ├── control-panel.tsx │ │ │ └── save-button.tsx │ │ ├── helpers │ │ ├── index.ts │ │ ├── constituent.ts │ │ ├── analysis.ts │ │ ├── nesting-level-calculator.ts │ │ └── selection-validation.ts │ │ ├── pages │ │ ├── index.ts │ │ ├── syntax-editor-root.tsx │ │ ├── syntax-editor.tsx │ │ └── sentence-manager.tsx │ │ ├── store │ │ ├── index.ts │ │ ├── control-panel-store.ts │ │ ├── analysis-store.ts │ │ └── segment-history-store.ts │ │ ├── index.ts │ │ ├── hooks │ │ ├── index.ts │ │ ├── use-syntax-parser-analysis.ts │ │ ├── use-syntax-editor-initializer.ts │ │ ├── use-calculate-nesting-level.ts │ │ ├── use-constituent-hover.ts │ │ ├── use-analysis-data-loader.ts │ │ ├── use-segment-mouse-event.ts │ │ └── use-sentence-handler.ts │ │ └── styles │ │ └── constituent.scss ├── routes │ ├── index.ts │ ├── paths.ts │ └── router.tsx ├── lib │ ├── index.ts │ ├── axios.ts │ └── react-query.tsx ├── base │ ├── constants │ │ ├── index.ts │ │ ├── abbreviations.ts │ │ └── regex.ts │ ├── types │ │ ├── index.ts │ │ ├── common.ts │ │ └── mouse-events.ts │ ├── components │ │ ├── layout │ │ │ ├── index.ts │ │ │ ├── layout.tsx │ │ │ └── header.tsx │ │ ├── index.ts │ │ ├── modal │ │ │ ├── index.ts │ │ │ ├── confirm-modal.tsx │ │ │ └── confirm-popover.tsx │ │ └── ui │ │ │ ├── themed-spinner.tsx │ │ │ ├── date-chip.tsx │ │ │ ├── centered-divider.tsx │ │ │ ├── notice.tsx │ │ │ ├── index.ts │ │ │ ├── text-placeholder.tsx │ │ │ ├── delete-button-icon.tsx │ │ │ ├── lazy-image.tsx │ │ │ ├── link-particles.tsx │ │ │ └── three-dots-wave.tsx │ ├── index.ts │ ├── utils │ │ ├── index.ts │ │ ├── identifier.ts │ │ ├── timers.ts │ │ ├── date.ts │ │ ├── react-utils.ts │ │ ├── image.ts │ │ ├── selection.ts │ │ └── string.ts │ └── hooks │ │ ├── index.ts │ │ ├── use-transition-loading.ts │ │ ├── use-remove-body-bg-color.ts │ │ ├── use-is-mounted.ts │ │ ├── use-hide-body-scroll.ts │ │ ├── use-before-unload.ts │ │ └── use-local-storage.ts ├── assets │ └── lottie │ │ └── index.ts ├── theme.ts ├── app.tsx └── index.tsx ├── vercel.json ├── .husky └── pre-commit ├── .env.example ├── .prettierrc ├── tsconfig.node.json ├── .gitignore ├── .github ├── workflows │ └── qodana_code_quality.yml └── ISSUE_TEMPLATE │ ├── feature_request.md │ ├── sweep-template.yml │ ├── sweep-fast-template.yml │ ├── sweep-slow-template.yml │ └── bug_report.md ├── tsconfig.json ├── qodana.yaml ├── index.html ├── package.json ├── .eslintrc.cjs └── vite.config.ts /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / 3 | -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | export * from './user-store'; 2 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/features/misc/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './showcase'; 2 | -------------------------------------------------------------------------------- /src/routes/index.ts: -------------------------------------------------------------------------------- 1 | export * from './paths'; 2 | export * from './router'; 3 | -------------------------------------------------------------------------------- /src/features/syntax-analyzer/constants/index.ts: -------------------------------------------------------------------------------- 1 | export * from './settings'; 2 | -------------------------------------------------------------------------------- /src/lib/index.ts: -------------------------------------------------------------------------------- 1 | export * from './axios'; 2 | export * from './react-query'; 3 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "rewrites": [{ "source": "/(.*)", "destination": "/" }] 3 | } 4 | -------------------------------------------------------------------------------- /src/base/constants/index.ts: -------------------------------------------------------------------------------- 1 | export * from './regex'; 2 | export * from './abbreviations'; 3 | -------------------------------------------------------------------------------- /src/base/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './common'; 2 | export * from './mouse-events'; 3 | -------------------------------------------------------------------------------- /src/features/misc/index.ts: -------------------------------------------------------------------------------- 1 | export * from './pages'; 2 | export * from './components'; 3 | -------------------------------------------------------------------------------- /src/base/components/layout/index.ts: -------------------------------------------------------------------------------- 1 | export * from './layout'; 2 | export * from './header'; 3 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | pnpx lint-staged 5 | -------------------------------------------------------------------------------- /public/favicon-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/romantech/syntax-analyzer/HEAD/public/favicon-48.png -------------------------------------------------------------------------------- /public/icon-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/romantech/syntax-analyzer/HEAD/public/icon-192.png -------------------------------------------------------------------------------- /public/icon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/romantech/syntax-analyzer/HEAD/public/icon-512.png -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | VITE_API_BASE_URL_DEV= 2 | VITE_API_BASE_URL_PROD= 3 | VITE_IMAGE_KIT_BASE_URL= 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/base/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ui'; 2 | export * from './modal'; 3 | export * from './layout'; 4 | -------------------------------------------------------------------------------- /src/features/syntax-analyzer/pages/index.ts: -------------------------------------------------------------------------------- 1 | export { default as SyntaxAnalyzer } from './syntax-analyzer'; 2 | -------------------------------------------------------------------------------- /public/icon-maskable-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/romantech/syntax-analyzer/HEAD/public/icon-maskable-512.png -------------------------------------------------------------------------------- /src/features/syntax-editor/types/index.ts: -------------------------------------------------------------------------------- 1 | export type * from './analysis'; 2 | export type * from './constituent'; 3 | -------------------------------------------------------------------------------- /src/features/misc/pages/index.ts: -------------------------------------------------------------------------------- 1 | export { default as ErrorComponent } from './error-component'; 2 | export { default as Home } from './home'; 3 | -------------------------------------------------------------------------------- /src/features/syntax-editor/constants/index.ts: -------------------------------------------------------------------------------- 1 | export * from './colors'; 2 | export * from './constituent-dom'; 3 | export * from './settings'; 4 | -------------------------------------------------------------------------------- /src/base/components/modal/index.ts: -------------------------------------------------------------------------------- 1 | export { default as ConfirmModal } from './confirm-modal'; 2 | export { default as ConfirmPopover } from './confirm-popover'; 3 | -------------------------------------------------------------------------------- /src/features/syntax-analyzer/api/index.ts: -------------------------------------------------------------------------------- 1 | export * from './create-analysis'; 2 | export * from './get-remaining-counts'; 3 | export * from './get-random-sentences'; 4 | -------------------------------------------------------------------------------- /src/base/index.ts: -------------------------------------------------------------------------------- 1 | export * from './components'; 2 | export * from './constants'; 3 | export * from './hooks'; 4 | export * from './types'; 5 | export * from './utils'; 6 | -------------------------------------------------------------------------------- /src/features/syntax-editor/data/index.ts: -------------------------------------------------------------------------------- 1 | export * from './syntax-constituents'; 2 | export * from './sample-analysis'; 3 | export * from './constituent-translations'; 4 | -------------------------------------------------------------------------------- /src/features/syntax-analyzer/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from './use-analysis-form'; 2 | export * from './use-random-sentence-form'; 3 | export * from './use-inject-analysis'; 4 | -------------------------------------------------------------------------------- /src/features/syntax-analyzer/schemes/index.ts: -------------------------------------------------------------------------------- 1 | export * from './analysis-form-schema'; 2 | export * from './english-sentence-schema'; 3 | export * from './random-sentence-form-schema'; 4 | -------------------------------------------------------------------------------- /src/features/syntax-editor/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './control-panel'; 2 | export * from './sentence-manager'; 3 | export * from './syntax-parser'; 4 | export * from './tag-list-accordion'; 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": true, 4 | "useTabs": false, 5 | "tabWidth": 2, 6 | "trailingComma": "all", 7 | "printWidth": 80, 8 | "arrowParens": "always" 9 | } 10 | -------------------------------------------------------------------------------- /src/assets/lottie/index.ts: -------------------------------------------------------------------------------- 1 | export { default as loadingAnimation } from './loading.json'; 2 | export { default as planeAnimation } from './plane.json'; 3 | export { default as rippleAnimation } from './ripple.json'; 4 | -------------------------------------------------------------------------------- /src/features/syntax-editor/components/tag-list-accordion/index.ts: -------------------------------------------------------------------------------- 1 | export { default as TagListAccordion } from './tag-list-accordion'; 2 | export { default as SelectableTagButton } from './selectable-tag-button'; 3 | -------------------------------------------------------------------------------- /src/features/syntax-analyzer/index.ts: -------------------------------------------------------------------------------- 1 | export * from './api'; 2 | export * from './constants'; 3 | export * from './components'; 4 | export * from './schemes'; 5 | export * from './hooks'; 6 | export * from './pages'; 7 | -------------------------------------------------------------------------------- /src/features/syntax-editor/helpers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './analysis'; 2 | export * from './constituent'; 3 | export * from './segment'; 4 | export * from './nesting-level-calculator'; 5 | export * from './selection-validation'; 6 | -------------------------------------------------------------------------------- /src/base/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './date'; 2 | export * from './identifier'; 3 | export * from './selection'; 4 | export * from './string'; 5 | export * from './react-utils'; 6 | export * from './timers'; 7 | export * from './image'; 8 | -------------------------------------------------------------------------------- /src/features/syntax-editor/pages/index.ts: -------------------------------------------------------------------------------- 1 | export { default as SyntaxEditorRoot } from './syntax-editor-root'; 2 | export { default as SentenceManager } from './sentence-manager'; 3 | export { default as SyntaxEditor } from './syntax-editor'; 4 | -------------------------------------------------------------------------------- /src/features/syntax-editor/store/index.ts: -------------------------------------------------------------------------------- 1 | import { createStore } from 'jotai'; 2 | 3 | export * from './analysis-store'; 4 | export * from './segment-history-store'; 5 | export * from './control-panel-store'; 6 | 7 | export const analysisStore = createStore(); 8 | -------------------------------------------------------------------------------- /src/features/syntax-editor/components/sentence-manager/index.ts: -------------------------------------------------------------------------------- 1 | export { default as AddSentenceForm } from './add-sentence-form'; 2 | export { default as SentenceList } from './sentence-list'; 3 | export { default as DeletableSentence } from './deletable-sentence'; 4 | -------------------------------------------------------------------------------- /src/features/syntax-editor/index.ts: -------------------------------------------------------------------------------- 1 | export * from './components'; 2 | export * from './hooks'; 3 | export * from './helpers'; 4 | export * from './constants'; 5 | export * from './data'; 6 | export * from './types'; 7 | export * from './store'; 8 | export * from './pages'; 9 | -------------------------------------------------------------------------------- /src/base/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from './use-is-mounted'; 2 | export * from './use-transition-loading'; 3 | export * from './use-local-storage'; 4 | export * from './use-hide-body-scroll'; 5 | export * from './use-remove-body-bg-color'; 6 | export * from './use-before-unload'; 7 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts", "package.json"] 10 | } 11 | -------------------------------------------------------------------------------- /src/features/syntax-analyzer/components/analysis-form/index.ts: -------------------------------------------------------------------------------- 1 | export { default as ModelChoiceGroup } from './model-choice-group'; 2 | export { default as AnalysisForm } from './analysis-form'; 3 | export { default as AnalysisCounter } from './analysis-counter'; 4 | export { default as SentenceInput } from './sentence-input'; 5 | -------------------------------------------------------------------------------- /src/base/utils/identifier.ts: -------------------------------------------------------------------------------- 1 | import { customAlphabet } from 'nanoid'; 2 | 3 | const nanoId = customAlphabet('123456789', 10); 4 | 5 | /** 6 | * Generates a number ID using the nanoId library. 7 | * 8 | * @return {number} - The generated number ID. 9 | */ 10 | export const generateNumberID = () => Number(nanoId()); 11 | -------------------------------------------------------------------------------- /src/features/syntax-editor/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from './use-analysis-data-loader'; 2 | export * from './use-calculate-nesting-level'; 3 | export * from './use-constituent-hover'; 4 | export * from './use-segment-mouse-event'; 5 | export * from './use-sentence-handler'; 6 | export * from './use-syntax-editor-initializer'; 7 | export * from './use-syntax-parser-analysis'; 8 | -------------------------------------------------------------------------------- /src/base/hooks/use-transition-loading.ts: -------------------------------------------------------------------------------- 1 | import { startTransition, useEffect, useState } from 'react'; 2 | 3 | export const useTransitionLoading = (dependencies: unknown[]) => { 4 | const [isLoading, setIsLoading] = useState(true); 5 | 6 | useEffect(() => { 7 | startTransition(() => setIsLoading(false)); 8 | }, [dependencies]); 9 | 10 | return isLoading; 11 | }; 12 | -------------------------------------------------------------------------------- /src/base/components/ui/themed-spinner.tsx: -------------------------------------------------------------------------------- 1 | import { Spinner, SpinnerProps } from '@chakra-ui/react'; 2 | 3 | export default function ThemedSpinner(spinnerProps: SpinnerProps) { 4 | return ( 5 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /src/lib/axios.ts: -------------------------------------------------------------------------------- 1 | import Axios from 'axios'; 2 | import { stringify } from 'qs'; 3 | 4 | const baseURL = import.meta.env.DEV 5 | ? import.meta.env.VITE_API_BASE_URL_DEV 6 | : import.meta.env.VITE_API_BASE_URL_PROD; 7 | 8 | export const paramsSerializer = (params: T) => { 9 | return stringify(params, { arrayFormat: 'repeat' }); 10 | }; 11 | 12 | export const axios = Axios.create({ baseURL }); 13 | -------------------------------------------------------------------------------- /src/base/hooks/use-remove-body-bg-color.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | /** 4 | * - 어플리케이션을 처음 로드할 때 하얀색 깜빡임 현상을 방지 하기 위해, 5 | * - index.html-body 태그에 백그라운드 색상(initialColorMode 색상) 설정. 6 | * - React 로드 이후 body 태그에 지정된 백그라운드 색상 제거 7 | * */ 8 | export const useRemoveBodyBgColor = () => { 9 | useEffect(() => { 10 | document.body.style.removeProperty('background-color'); 11 | }, []); 12 | }; 13 | -------------------------------------------------------------------------------- /src/base/types/common.ts: -------------------------------------------------------------------------------- 1 | export type ColorMode = 'dark' | 'light'; 2 | 3 | export type Tuple = [T, T]; 4 | export type NumberTuple = Tuple; 5 | export type Nullable = T | null; 6 | export type VoidFunc = (...args: T[]) => void; 7 | 8 | export type Entries = { [K in keyof T]: [K, T[K]] }[keyof T][]; 9 | export type ValueOf = T[keyof T]; 10 | 11 | export type ISODateString = string; 12 | -------------------------------------------------------------------------------- /src/features/syntax-analyzer/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './analysis-form'; 2 | export * from './random-sentence-form'; 3 | 4 | export { default as AnalysisLoadIndicator } from './analysis-load-indicator'; 5 | export { default as FieldGroupHeader } from './field-group-header'; 6 | export { default as UsageLimitTooltip } from './usage-limit-tooltip'; 7 | export { default as LoadingTransition } from './loading-transition'; 8 | -------------------------------------------------------------------------------- /src/features/syntax-editor/components/syntax-parser/index.ts: -------------------------------------------------------------------------------- 1 | export { default as SyntaxParser } from './syntax-parser'; 2 | export { default as Constituent } from './constituent'; 3 | export { default as Segment } from './segment'; 4 | export { default as SegmentList } from './segment-list'; 5 | export { default as Sentence } from './sentence'; 6 | export { default as Token } from './token'; 7 | export { default as TokenList } from './token-list'; 8 | -------------------------------------------------------------------------------- /src/features/syntax-analyzer/components/field-group-header.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from 'react'; 2 | 3 | import { Heading, HeadingProps } from '@chakra-ui/react'; 4 | 5 | export default function FieldGroupHeader({ 6 | children, 7 | ...headingProps 8 | }: PropsWithChildren) { 9 | return ( 10 | 11 | {children} 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /src/features/misc/components/showcase/index.ts: -------------------------------------------------------------------------------- 1 | export * from './showcase-template'; 2 | export * from './with-showcase'; 3 | 4 | export { default as HeroShowcase } from './hero-showcase'; 5 | export { default as ScrollDownButton } from './scroll-down-button'; 6 | export { default as AnalyzerShowcase } from './analyzer-showcase'; 7 | export { default as EditorShowcase } from './editor-showcase'; 8 | export { default as GeneratorShowcase } from './generator-showcase'; 9 | -------------------------------------------------------------------------------- /src/features/syntax-editor/hooks/use-syntax-parser-analysis.ts: -------------------------------------------------------------------------------- 1 | import { useAtomValue } from 'jotai'; 2 | 3 | import { 4 | currentSegmentFromHistoryAtom, 5 | selectedAnalysisAtom, 6 | } from '@/features/syntax-editor'; 7 | 8 | export const useSyntaxParserAnalysis = () => { 9 | const sentence = useAtomValue(selectedAnalysisAtom)?.sentence ?? null; 10 | const segment = useAtomValue(currentSegmentFromHistoryAtom); 11 | return { sentence, segment }; 12 | }; 13 | -------------------------------------------------------------------------------- /src/base/components/ui/date-chip.tsx: -------------------------------------------------------------------------------- 1 | import { Text, TextProps } from '@chakra-ui/react'; 2 | 3 | import { getFormattedKoDate } from '@/base/utils/date'; 4 | 5 | interface DateChipProps extends TextProps { 6 | date: string; 7 | } 8 | 9 | export default function DateChip({ date, ...textProps }: DateChipProps) { 10 | return ( 11 | 12 | {getFormattedKoDate(date)} 13 | 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dev-dist 13 | dist-ssr 14 | *.local 15 | 16 | # Editor directories and files 17 | .vscode/* 18 | !.vscode/extensions.json 19 | .idea 20 | .env 21 | .DS_Store 22 | *.suo 23 | *.ntvs* 24 | *.njsproj 25 | *.sln 26 | *.sw? 27 | 28 | # Build statistics 29 | stats.* 30 | 31 | # ESLint cache file 32 | .eslintcache 33 | 34 | .qodo 35 | -------------------------------------------------------------------------------- /src/routes/paths.ts: -------------------------------------------------------------------------------- 1 | import { AnalysisSource } from '@/features/syntax-editor'; 2 | 3 | export const SITE_URLS = { 4 | ROOT: '/', 5 | ANALYZER: { 6 | ROOT: '/analyzer', 7 | }, 8 | EDITOR: { 9 | ROOT: '/editor', 10 | EDIT: '/editor/:source/:index', 11 | }, 12 | } as const; 13 | 14 | export const getSyntaxEditorPath = (source: AnalysisSource, index: number) => 15 | SITE_URLS.EDITOR.EDIT.replace(/:source|:index/g, (m) => { 16 | return m === ':source' ? source : index.toString(); 17 | }); 18 | -------------------------------------------------------------------------------- /src/features/syntax-editor/constants/constituent-dom.ts: -------------------------------------------------------------------------------- 1 | export const CONSTITUENT_DATA_ATTRS = { 2 | INDEX: 'data-token-index', 3 | ABBR: 'data-constituent-abbr', 4 | LABEL: 'data-constituent-label', 5 | ID: 'data-constituent-id', 6 | BEGIN: 'data-segment-begin', 7 | END: 'data-segment-end', 8 | } as const; 9 | 10 | export const CONSTITUENT_CLASSES = { 11 | CONSTITUENT: 'constituent', 12 | TOKEN_GROUP: 'token-group', 13 | CLAUSE: 'clause', 14 | PHRASE: 'phrase', 15 | TOKEN: 'token', 16 | } as const; 17 | -------------------------------------------------------------------------------- /src/store/user-store.ts: -------------------------------------------------------------------------------- 1 | import { load } from '@fingerprintjs/fingerprintjs'; 2 | import { atom } from 'jotai'; 3 | 4 | /** 5 | * 4.0 버전부터 Business Source License 1.1이 적용돼서 배포 목적은 유료 결제 필요 6 | * {@link https://github.com/fingerprintjs/fingerprintjs/blob/master/LICENSE 라이선스 정보} 7 | * 3.x 버전은 배포 버전에서도 무료로 사용할 수 있지만 정확도는 떨어짐 8 | * */ 9 | export const fingerprintAtom = atom(async () => { 10 | const fp = await load({ monitoring: false }); 11 | 12 | const { visitorId } = await fp.get(); 13 | return visitorId; 14 | }); 15 | -------------------------------------------------------------------------------- /src/features/syntax-editor/components/control-panel/index.ts: -------------------------------------------------------------------------------- 1 | export { default as ControlPanel } from './control-panel'; 2 | export { default as AbbrInfoSwitch } from './abbr-info-switch'; 3 | export { default as TagInfoSwitch } from './tag-info-switch'; 4 | export { default as DeleteButton } from './delete-button'; 5 | export { default as RedoButton } from './redo-button'; 6 | export { default as UndoButton } from './undo-button'; 7 | export { default as ResetButton } from './reset-button'; 8 | export { default as SaveButton } from './save-button'; 9 | -------------------------------------------------------------------------------- /src/base/hooks/use-is-mounted.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | /** 4 | * Returns a boolean indicating whether the component is mounted or not. 5 | * 6 | * @return {boolean} The value indicating whether the component is mounted or not. 7 | */ 8 | export const useIsMounted = () => { 9 | const [isMounted, setMounted] = useState(false); 10 | 11 | useEffect(() => { 12 | setMounted(true); 13 | 14 | return () => { 15 | setMounted(false); 16 | }; 17 | }, []); 18 | 19 | return isMounted; 20 | }; 21 | -------------------------------------------------------------------------------- /src/features/syntax-analyzer/components/usage-limit-tooltip.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from 'react'; 2 | 3 | import { Tooltip, TooltipProps } from '@chakra-ui/react'; 4 | 5 | export default function UsageLimitTooltip({ 6 | children, 7 | ...tooltipProps 8 | }: PropsWithChildren) { 9 | return ( 10 | 16 | {children} 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/base/components/layout/layout.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from 'react'; 2 | 3 | import { Container, ContainerProps, VStack } from '@chakra-ui/react'; 4 | 5 | import { Header } from '@/base/components'; 6 | 7 | export function Layout({ 8 | children, 9 | ...containerProps 10 | }: PropsWithChildren) { 11 | return ( 12 | 13 |
14 | 15 | {children} 16 | 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/features/syntax-analyzer/components/random-sentence-form/index.ts: -------------------------------------------------------------------------------- 1 | export { default as TopicTagList } from './topic-tag-list'; 2 | export { default as AddTopicForm } from './add-topic-form'; 3 | export { default as RandomSentenceInstructions } from './random-sentence-instructions'; 4 | export { default as RandomSentenceForm } from './random-sentence-form'; 5 | export { default as SentenceCountPicker } from './sentence-count-picker'; 6 | export { default as RandomSentenceList } from './random-sentence-list'; 7 | export { default as GenerateButton } from './generate-button'; 8 | -------------------------------------------------------------------------------- /src/base/hooks/use-hide-body-scroll.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | 3 | /** 4 | * Generates a function comment for the given function body. 5 | * 6 | * @return {void} No return value. 7 | */ 8 | export const useHideBodyScroll = () => { 9 | const originalOverflow = useRef(''); 10 | 11 | useEffect(() => { 12 | originalOverflow.current = document.body.style.overflow; 13 | document.body.style.overflow = 'hidden'; 14 | 15 | return () => { 16 | document.body.style.overflow = originalOverflow.current; 17 | }; 18 | }, []); 19 | }; 20 | -------------------------------------------------------------------------------- /src/base/types/mouse-events.ts: -------------------------------------------------------------------------------- 1 | import { MouseEventHandler } from 'react'; 2 | 3 | type MouseEventKeys = 4 | | 'onClick' // 동일한 요소에서 마우스 버튼을 클릭할 때, 버블링 O 5 | | 'onMouseDown' // 마우스 버튼을 누를 때, 버블링 O 6 | | 'onMouseUp' // 마우스 버튼을 눌렀다 뗄 때, 버블링 O 7 | | 'onMouseEnter' // 마우스가 요소의 경계로 진입할 때, 버블링 X 8 | | 'onMouseLeave' // 마우스가 요소의 경계를 벗어날 때, 버블링 X 9 | | 'onMouseOver' // 요소 안에 들어올 때 (또는 그 안의 자식 요소로 들어올 때), 버블링 O 10 | | 'onMouseOut'; // 요소를 벗어날 때 (또는 그 안의 자식 요소를 벗어날 때), 버블링 O 11 | 12 | export type MouseEventHandlers = { 13 | [key in MouseEventKeys]?: MouseEventHandler; 14 | }; 15 | -------------------------------------------------------------------------------- /src/features/misc/components/showcase/with-showcase.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentType } from 'react'; 2 | 3 | import { getDisplayName } from '@/base'; 4 | import { ShowcaseTemplateProps } from '@/features/misc'; 5 | 6 | export const withShowcase = ( 7 | Component: ComponentType, 8 | showcaseProps: ShowcaseTemplateProps, 9 | ) => { 10 | const WithShowcase = (props: Partial) => ( 11 | 12 | ); 13 | 14 | WithShowcase.displayName = getDisplayName(Component); 15 | return WithShowcase; 16 | }; 17 | -------------------------------------------------------------------------------- /src/features/syntax-analyzer/schemes/analysis-form-schema.ts: -------------------------------------------------------------------------------- 1 | import * as yup from 'yup'; 2 | 3 | import { AnalysisModel } from '@/features/syntax-analyzer'; 4 | 5 | import { englishSentenceSchema } from './english-sentence-schema'; 6 | 7 | export const addSentenceFormSchema = yup.object({ 8 | sentence: englishSentenceSchema.required().ensure(), 9 | }); 10 | 11 | export const createAnalysisFormSchema = yup.object({ 12 | model: yup 13 | .mixed() 14 | .oneOf(Object.values(AnalysisModel)) 15 | .default(AnalysisModel.GPT_4O_MINI_FT), 16 | sentence: englishSentenceSchema.ensure(), 17 | }); 18 | -------------------------------------------------------------------------------- /src/features/syntax-editor/constants/settings.ts: -------------------------------------------------------------------------------- 1 | export const INVALID_POPUP_DELAY = 800; 2 | export const CONTROL_OPEN_POPUP_DELAY = 600; 3 | 4 | export const DEFAULT_TAG_INFO_MODE = true; 5 | export const DEFAULT_ABBR_INFO_MODE = true; 6 | export const DEFAULT_SENTENCE_LIST_TAB = 1; // 0(추가한 문장), 1 (샘플 문장) 7 | 8 | export const SAVE_SEGMENT_DELAY = 500; 9 | export const SAVE_SEGMENT_SUCCESS_TOAST_DURATION = 5000; 10 | export const COPY_SENTENCE_SUCCESS_TOAST_DURATION = 1000; 11 | 12 | export const NEW_BADGE_DISPLAY_DURATION = 60 * 5; // seconds 13 | export const TAG_LIST_DEFAULT_INDEX = 0; // null, 0(general), 1(phrase), 2(clause) 14 | -------------------------------------------------------------------------------- /src/base/components/ui/centered-divider.tsx: -------------------------------------------------------------------------------- 1 | import { Center, CenterProps, Divider, DividerProps } from '@chakra-ui/react'; 2 | 3 | interface CenteredDividerProps extends CenterProps { 4 | orientation?: 'horizontal' | 'vertical'; 5 | dividerH?: DividerProps['h']; 6 | dividerW?: DividerProps['w']; 7 | } 8 | 9 | export default function CenteredDivider({ 10 | orientation = 'horizontal', 11 | dividerH, 12 | dividerW, 13 | ...centerProps 14 | }: CenteredDividerProps) { 15 | return ( 16 |
17 | 18 |
19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/base/components/ui/notice.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Card, 3 | CardBody, 4 | CardProps, 5 | HStack, 6 | Icon, 7 | Text, 8 | } from '@chakra-ui/react'; 9 | import { FaMagnifyingGlass } from 'react-icons/fa6'; 10 | 11 | interface NoticeProps extends CardProps { 12 | text: string; 13 | } 14 | 15 | export default function Notice({ text, ...cardProps }: NoticeProps) { 16 | return ( 17 | 18 | 19 | 20 | 21 | {text} 22 | 23 | 24 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /src/features/syntax-analyzer/hooks/use-inject-analysis.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | 3 | import { useLocalStorage } from '@/base'; 4 | import { 5 | analysisStore, 6 | TAnalysis, 7 | userAnalysisListAtom, 8 | } from '@/features/syntax-editor'; 9 | 10 | export const useInjectAnalysis = () => { 11 | const [userAnalysis] = useLocalStorage('userAnalysisList', []); 12 | 13 | const injectAnalysis = useCallback( 14 | (analysis: TAnalysis) => { 15 | analysisStore.set(userAnalysisListAtom, [analysis, ...userAnalysis]); 16 | }, 17 | [userAnalysis], 18 | ); 19 | 20 | return { injectAnalysis }; 21 | }; 22 | -------------------------------------------------------------------------------- /src/base/components/ui/index.ts: -------------------------------------------------------------------------------- 1 | export { default as DateChip } from './date-chip'; 2 | export { default as Notice } from './notice'; 3 | export { default as TextPlaceholder } from './text-placeholder'; 4 | export { default as DeleteButtonIcon } from './delete-button-icon'; 5 | export { default as CenteredDivider } from './centered-divider'; 6 | export { default as LinkParticles } from './link-particles'; 7 | export { default as LazyImage, type LazyImageProps } from './lazy-image'; 8 | 9 | export { default as ThemedSpinner } from './themed-spinner'; 10 | export { default as ThreeDotsWave } from './three-dots-wave'; 11 | export { domAnimation } from 'framer-motion'; 12 | -------------------------------------------------------------------------------- /src/base/components/ui/text-placeholder.tsx: -------------------------------------------------------------------------------- 1 | import { Icon, Text, TextProps } from '@chakra-ui/react'; 2 | import { IconType } from 'react-icons'; 3 | 4 | interface TextPlaceholderProps extends TextProps { 5 | text: string; 6 | startIcon?: IconType; 7 | endIcon?: IconType; 8 | } 9 | 10 | export default function TextPlaceholder({ 11 | text, 12 | startIcon, 13 | endIcon, 14 | ...textProps 15 | }: TextPlaceholderProps) { 16 | return ( 17 | 18 | {startIcon && } 19 | {text} 20 | {endIcon && } 21 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /.github/workflows/qodana_code_quality.yml: -------------------------------------------------------------------------------- 1 | name: Qodana 2 | # Qodana Community 라이선스에선 JS/TS 포함 안돼서 pull_request/push 비활성 3 | # https://www.jetbrains.com/help/qodana/pricing.html#license-comparison-matrix 4 | on: 5 | workflow_dispatch: 6 | # pull_request: 7 | # push: 8 | # branches: 9 | # - main 10 | 11 | permissions: 12 | contents: read 13 | 14 | jobs: 15 | qodana: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v3 19 | with: 20 | fetch-depth: 0 21 | - name: 'Qodana Scan' 22 | uses: JetBrains/qodana-action@v2022.3.4 23 | env: 24 | QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }} 25 | -------------------------------------------------------------------------------- /src/features/syntax-analyzer/components/random-sentence-form/random-sentence-instructions.tsx: -------------------------------------------------------------------------------- 1 | import { ListItem, ListProps, UnorderedList } from '@chakra-ui/react'; 2 | 3 | import { MAX_TOPIC_ADDITION } from '@/features/syntax-analyzer'; 4 | 5 | const INSTRUCTIONS = [ 6 | `최대 ${MAX_TOPIC_ADDITION}개의 토픽을 추가해서 랜덤 문장을 생성할 수 있어요`, 7 | '토픽을 등록하지 않으면 랜덤한 주제로 문장을 생성해요', 8 | '생성한 문장을 클릭하면 클립보드에 복사돼요', 9 | ]; 10 | 11 | export default function RandomSentenceInstructions(listProps: ListProps) { 12 | return ( 13 | 14 | {INSTRUCTIONS.map((instruction, i) => ( 15 | {instruction} 16 | ))} 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/sweep-template.yml: -------------------------------------------------------------------------------- 1 | name: Sweep Issue 2 | title: 'Sweep: ' 3 | description: For small bugs, features, refactors, and tests to be handled by Sweep, an AI-powered junior developer. 4 | labels: sweep 5 | body: 6 | - type: textarea 7 | id: description 8 | attributes: 9 | label: Details 10 | description: Tell Sweep where and what to edit and provide enough context for a new developer to the codebase 11 | placeholder: | 12 | Bugs: The bug might be in ... file. Here are the logs: ... 13 | Features: the new endpoint should use the ... class from ... file because it contains ... logic. 14 | Refactors: We are migrating this function to ... version because ... 15 | -------------------------------------------------------------------------------- /src/features/syntax-editor/constants/colors.ts: -------------------------------------------------------------------------------- 1 | import { ColorMode } from '@/base'; 2 | import { ConstituentColors } from '@/features/syntax-editor'; 3 | 4 | export const DELETE_MODE_HOVER_COLOR_SCHEME = (mode: ColorMode) => { 5 | return mode === 'dark' 6 | ? 'var(--chakra-colors-gray-500)' 7 | : 'var(--chakra-colors-gray-300)'; 8 | }; 9 | 10 | export const CONSTITUENT_COLORS: ConstituentColors = { 11 | token: { 12 | dark: 'red.200', 13 | light: 'red.400', 14 | }, 15 | 'token-group': { 16 | dark: 'blue.200', 17 | light: 'blue.400', 18 | }, 19 | phrase: { 20 | dark: 'purple.200', 21 | light: 'purple.400', 22 | }, 23 | clause: { 24 | dark: 'teal.200', 25 | light: 'teal.400', 26 | }, 27 | }; 28 | -------------------------------------------------------------------------------- /src/features/syntax-analyzer/constants/settings.ts: -------------------------------------------------------------------------------- 1 | export const DAILY_ANALYSIS_LIMIT = 10; 2 | 3 | export const MAX_TOPIC_ADDITION = 3; 4 | export const DAILY_SENTENCE_LIMIT = 20; 5 | 6 | export const MIN_TOPIC_LENGTH = 2; 7 | export const MAX_TOPIC_LENGTH = 20; 8 | 9 | export const MIN_PICKER_SENTENCE = 1; 10 | export const MAX_PICKER_SENTENCE = 5; 11 | export const DEFAULT_PICKER_COUNT = 3; 12 | 13 | export const MAX_SENTENCE_LENGTH = 80; 14 | export const MIN_SENTENCE_WORDS = 3; 15 | 16 | /** 서버 허용값과 일치 필요 */ 17 | export enum AnalysisModel { 18 | GPT_4O_MINI_FT = 'gpt-4o-mini-ft', 19 | GPT_4O_FT = 'gpt-4o-ft', 20 | } 21 | 22 | export const ANALYSIS_DECREMENT_COUNT = { 23 | [AnalysisModel.GPT_4O_MINI_FT]: 1, 24 | [AnalysisModel.GPT_4O_FT]: 2, 25 | }; 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/sweep-fast-template.yml: -------------------------------------------------------------------------------- 1 | name: Sweep Fast Issue 2 | title: 'Sweep (fast): ' 3 | description: For few-line fixes to be handled by Sweep, an AI-powered junior developer. Sweep will use GPT-3.5 to quickly create a PR for very small changes. 4 | labels: sweep 5 | body: 6 | - type: textarea 7 | id: description 8 | attributes: 9 | label: Details 10 | description: Tell Sweep where and what to edit and provide enough context for a new developer to the codebase 11 | placeholder: | 12 | Bugs: The bug might be in ... file. Here are the logs: ... 13 | Features: the new endpoint should use the ... class from ... file because it contains ... logic. 14 | Refactors: We are migrating this function to ... version because ... 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/sweep-slow-template.yml: -------------------------------------------------------------------------------- 1 | name: Sweep Slow Issue 2 | title: 'Sweep (slow): ' 3 | description: For larger bugs, features, refactors, and tests to be handled by Sweep, an AI-powered junior developer. Sweep will perform a deeper search and more self-reviews but will take longer. 4 | labels: sweep 5 | body: 6 | - type: textarea 7 | id: description 8 | attributes: 9 | label: Details 10 | description: Tell Sweep where and what to edit and provide enough context for a new developer to the codebase 11 | placeholder: | 12 | Bugs: The bug might be in ... file. Here are the logs: ... 13 | Features: the new endpoint should use the ... class from ... file because it contains ... logic. 14 | Refactors: We are migrating this function to ... version because ... 15 | -------------------------------------------------------------------------------- /src/features/syntax-editor/components/syntax-parser/token-list.tsx: -------------------------------------------------------------------------------- 1 | import { isPunctuation } from '@/base'; 2 | import { Token } from '@/features/syntax-editor'; 3 | 4 | interface TokensProps { 5 | sentence: string[]; 6 | } 7 | 8 | export default function TokenList({ sentence }: TokensProps) { 9 | return sentence.map((token, i) => { 10 | const isNextTokenPunctuation = isPunctuation(sentence[i + 1]); 11 | const isCurrentTokenPunctuation = isPunctuation(token); 12 | 13 | const spaceAfter = isNextTokenPunctuation ? 0 : '3px'; 14 | const spaceBefore = isCurrentTokenPunctuation ? 0 : '3px'; 15 | 16 | return ( 17 | 24 | ); 25 | }); 26 | } 27 | -------------------------------------------------------------------------------- /src/features/misc/components/showcase/analyzer-showcase.tsx: -------------------------------------------------------------------------------- 1 | import { getImageKitPlaceholder } from '@/base'; 2 | import { ShowcaseTemplate, withShowcase } from '@/features/misc'; 3 | import { SITE_URLS } from '@/routes'; 4 | 5 | const baseUrl = import.meta.env.VITE_IMAGE_KIT_BASE_URL; 6 | const src = `${baseUrl}/syntax-analyzer/analysis.png`; 7 | 8 | const AnalyzerShowcase = withShowcase(ShowcaseTemplate, { 9 | imageProps: { 10 | src, 11 | placeholderSrc: getImageKitPlaceholder(src), 12 | alt: 'Analyzer Overview', 13 | }, 14 | title: `Syntax Unpacked\n with a Single Click`, 15 | description: 16 | 'Input a sentence, choose your model, and see it deftly unravel the complex ties between subjects, verbs, objects, and beyond.', 17 | linkUrl: SITE_URLS.ANALYZER.ROOT, 18 | }); 19 | 20 | export default AnalyzerShowcase; 21 | -------------------------------------------------------------------------------- /src/features/misc/components/showcase/generator-showcase.tsx: -------------------------------------------------------------------------------- 1 | import { getImageKitPlaceholder } from '@/base'; 2 | import { ShowcaseTemplate, withShowcase } from '@/features/misc'; 3 | import { SITE_URLS } from '@/routes'; 4 | 5 | const baseUrl = import.meta.env.VITE_IMAGE_KIT_BASE_URL; 6 | const src = `${baseUrl}/syntax-analyzer/generator.png`; 7 | 8 | const EditorShowcase = withShowcase(ShowcaseTemplate, { 9 | imageProps: { 10 | src, 11 | placeholderSrc: getImageKitPlaceholder(src), 12 | alt: 'Editor Overview', 13 | }, 14 | title: `Generate Tailored\nRandom Sentences`, 15 | description: 16 | 'Select themes and watch as sentences are uniquely crafted for your preference. Mix up to 3 topics to form an assortment of distinguished expressions.', 17 | linkUrl: SITE_URLS.EDITOR.ROOT, 18 | }); 19 | 20 | export default EditorShowcase; 21 | -------------------------------------------------------------------------------- /src/features/syntax-editor/pages/syntax-editor-root.tsx: -------------------------------------------------------------------------------- 1 | import { Provider } from 'jotai'; 2 | import { DevTools } from 'jotai-devtools'; 3 | import { Outlet } from 'react-router-dom'; 4 | 5 | import { Layout } from '@/base'; 6 | import { analysisStore } from '@/features/syntax-editor'; 7 | 8 | export default function SyntaxEditorRoot() { 9 | return ( 10 | 11 | 12 | 13 | 14 | 15 | 16 | ); 17 | } 18 | 19 | /** 20 | * Provider를 지정하지 않으면 Jotai 기본 저장소를 사용해서 전역 상태처럼 작동. 21 | * 때문에 어떤 컴포넌트든지 atom을 참조하면 항상 동일한 값을 갖게됨. 22 | * 반면, Provider를 사용하면 Provider 범위 내에서만 상태를 공유함. 23 | * 즉, 동일한 atom 이라도 서로 다른 Provider 내에선 다른 값을 가질 수 있음 24 | * 또한 마운트시 초기값을 갖을 수 있고, 언마운트시 모든 atom 값이 삭제됨. 25 | * @see https://jotai.org/docs/core/provider 26 | * */ 27 | -------------------------------------------------------------------------------- /src/features/syntax-editor/components/control-panel/tag-info-switch.tsx: -------------------------------------------------------------------------------- 1 | import { FormLabel, HStack, StackProps, Switch } from '@chakra-ui/react'; 2 | import { useAtom } from 'jotai'; 3 | 4 | import { tagInfoModeAtom } from '@/features/syntax-editor'; 5 | 6 | export default function TagInfoSwitch({ 7 | w = 'full', 8 | justify = 'space-between', 9 | ...stackProps 10 | }: StackProps) { 11 | const [isTagInfoMode, setTagInfoMode] = useAtom(tagInfoModeAtom); 12 | return ( 13 | 14 | 15 | 태그 정보 툴팁 16 | 17 | setTagInfoMode((prev) => !prev)} 22 | /> 23 | 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /src/features/syntax-editor/components/control-panel/abbr-info-switch.tsx: -------------------------------------------------------------------------------- 1 | import { FormLabel, HStack, StackProps, Switch } from '@chakra-ui/react'; 2 | import { useAtom } from 'jotai'; 3 | 4 | import { abbrInfoModeAtom } from '@/features/syntax-editor'; 5 | 6 | export default function AbbrInfoSwitch({ 7 | w = 'full', 8 | justify = 'space-between', 9 | ...stackProps 10 | }: StackProps) { 11 | const [isAbbrInfoMode, setIsAbbrInfoMode] = useAtom(abbrInfoModeAtom); 12 | return ( 13 | 14 | 15 | 태그 약어 툴팁 16 | 17 | setIsAbbrInfoMode((prev) => !prev)} 22 | /> 23 | 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /src/features/misc/components/showcase/editor-showcase.tsx: -------------------------------------------------------------------------------- 1 | import { getImageKitPlaceholder } from '@/base'; 2 | import { ShowcaseTemplate, withShowcase } from '@/features/misc'; 3 | import { getSyntaxEditorPath } from '@/routes'; 4 | 5 | const baseUrl = import.meta.env.VITE_IMAGE_KIT_BASE_URL; 6 | const src = `${baseUrl}/syntax-analyzer/editor.png`; 7 | 8 | const EditorShowcase = withShowcase(ShowcaseTemplate, { 9 | imageProps: { 10 | src, 11 | placeholderSrc: getImageKitPlaceholder(src), 12 | alt: 'Editor Overview', 13 | }, 14 | title: `Refined Visual\nSyntax Exploration`, 15 | description: 16 | 'Engage with precise tagging of words, phrases, and clauses. Discover over 30 essential syntax tags and enhance your English mastery with sleek visual cues.', 17 | linkUrl: getSyntaxEditorPath('sample', 4), 18 | imageFirst: false, 19 | }); 20 | 21 | export default EditorShowcase; 22 | -------------------------------------------------------------------------------- /src/features/syntax-editor/components/control-panel/undo-button.tsx: -------------------------------------------------------------------------------- 1 | import { IconButton, Tooltip } from '@chakra-ui/react'; 2 | import { useAtom } from 'jotai'; 3 | import { ImUndo } from 'react-icons/im'; 4 | 5 | import { clearSelection } from '@/base'; 6 | import { 7 | CONTROL_OPEN_POPUP_DELAY, 8 | undoRedoActionAtom, 9 | } from '@/features/syntax-editor'; 10 | 11 | export default function UndoButton() { 12 | const [actionable, action] = useAtom(undoRedoActionAtom); 13 | const onClick = () => { 14 | action('undo'); 15 | clearSelection(); 16 | }; 17 | 18 | return ( 19 | 20 | } 24 | isDisabled={!actionable.undo} 25 | onClick={onClick} 26 | /> 27 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /src/features/syntax-editor/components/control-panel/redo-button.tsx: -------------------------------------------------------------------------------- 1 | import { IconButton, Tooltip } from '@chakra-ui/react'; 2 | import { useAtom } from 'jotai'; 3 | import { ImRedo } from 'react-icons/im'; 4 | 5 | import { clearSelection } from '@/base'; 6 | import { 7 | CONTROL_OPEN_POPUP_DELAY, 8 | undoRedoActionAtom, 9 | } from '@/features/syntax-editor'; 10 | 11 | export default function RedoButton() { 12 | const [actionable, action] = useAtom(undoRedoActionAtom); 13 | const onClick = () => { 14 | action('redo'); 15 | clearSelection(); 16 | }; 17 | 18 | return ( 19 | 20 | } 24 | isDisabled={!actionable.redo} 25 | onClick={onClick} 26 | /> 27 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /src/features/syntax-editor/types/constituent.ts: -------------------------------------------------------------------------------- 1 | import { ColorMode } from '@/base'; 2 | 3 | export type ConstituentType = 'clause' | 'phrase' | 'token' | 'token-group'; 4 | 5 | type ConstituentDataSetKey = 6 | | 'constituentLabel' 7 | | 'constituentId' 8 | | 'constituentAbbr' 9 | | 'tokenIndex'; 10 | 11 | export type ConstituentDataSet = { [key in ConstituentDataSetKey]?: string }; 12 | 13 | export type ConstituentColors = { 14 | [key in ConstituentType]: { [key in ColorMode]: string }; 15 | }; 16 | 17 | export type TConstituent = { 18 | id: number; // Random 9-digit number 19 | elementId: number; // Constituent ID 20 | label: string; // Grammatical label, e.g., "subject" 21 | abbreviation: string; // Abbreviation, e.g., "s" 22 | type: ConstituentType; // Type of constituent 23 | comment?: string; // Optional commentary 24 | }; 25 | 26 | export type ConstituentWithoutId = Omit; 27 | -------------------------------------------------------------------------------- /src/base/components/ui/delete-button-icon.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef } from 'react'; 2 | 3 | import { ButtonProps, Center, Icon, useColorModeValue } from '@chakra-ui/react'; 4 | import { CgClose } from 'react-icons/cg'; 5 | 6 | const DeleteButtonIcon = forwardRef( 7 | function DeleteButtonIcon(buttonProps, ref) { 8 | return ( 9 |
23 | 24 |
25 | ); 26 | }, 27 | ); 28 | 29 | export default DeleteButtonIcon; 30 | -------------------------------------------------------------------------------- /src/base/utils/timers.ts: -------------------------------------------------------------------------------- 1 | import { Nullable } from '@/base'; 2 | 3 | /** 4 | * Debounce a callback function by delaying its execution until a specified 5 | * amount of time has passed without the callback being called again. 6 | * 7 | * @param {(...args: T) => void} callback - The callback function to be debounced. 8 | * @param {number} delay - The delay in milliseconds before the callback is executed. 9 | * @template T - The type of the arguments for the callback function. 10 | * @returns {(...args: T) => void} - A debounced version of the original callback function. 11 | */ 12 | export const debounce = ( 13 | callback: (...args: T) => void, 14 | delay: number, 15 | ): ((...args: T) => void) => { 16 | let timeoutId: Nullable = null; 17 | 18 | return (...args: T) => { 19 | if (timeoutId) clearTimeout(timeoutId); 20 | timeoutId = setTimeout(() => callback(...args), delay); 21 | }; 22 | }; 23 | -------------------------------------------------------------------------------- /src/features/syntax-editor/types/analysis.ts: -------------------------------------------------------------------------------- 1 | import { ISODateString } from '@/base'; 2 | import { TConstituent } from '@/features/syntax-editor'; 3 | 4 | export type AnalysisSource = 'user' | 'sample'; 5 | 6 | export type TSegment = { 7 | id: number; // Random 9-digit number 8 | begin: number; // Start index 9 | end: number; // End index 10 | constituents: TConstituent[]; // Segment constituents 11 | children: TSegment[]; // Sub-segments 12 | }; 13 | 14 | export type TAnalysis = { 15 | id: string; // Random 21-byte string 16 | source: AnalysisSource; // Data source 17 | createdAt: ISODateString; // ISO 8601 timestamp 18 | sentence: string[]; // Tokenized sentence 19 | rootSegment: TSegment; // Root segment 20 | isAnalyzedByGPT: boolean; // AI-analyzed status 21 | }; 22 | 23 | export type CombinedAnalysisList = { [key in AnalysisSource]: TAnalysis[] }; 24 | 25 | export type AnalysisPathParams = { source: AnalysisSource; index: string }; 26 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable", "webworker"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | /* 12 | *.ts, *.tsx 등 확장자가 붙은 파일을 import 할 수 있도록 할지 여부 13 | noEmit 옵션이 true 일때만 사용할 수 있는 옵션 14 | true로 설정하면 import 할 때 항상 확장자가 붙어서(*.ts 등) 비활성화 15 | */ 16 | "allowImportingTsExtensions": false, 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "noEmit": true, 20 | "jsx": "react-jsx", 21 | 22 | /* Linting */ 23 | "strict": true, 24 | "noFallthroughCasesInSwitch": true, 25 | "baseUrl": ".", 26 | "paths": { 27 | "@/*": ["src/*"] // alias 사용시 폴더명에 대문자 있으면 오류 발생하므로 주의 28 | } 29 | }, 30 | "include": ["src"], 31 | "references": [{ "path": "./tsconfig.node.json" }] 32 | } 33 | -------------------------------------------------------------------------------- /src/features/syntax-editor/components/syntax-parser/sentence.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef, PropsWithChildren } from 'react'; 2 | 3 | import { Text, useColorModeValue } from '@chakra-ui/react'; 4 | import { useAtomValue } from 'jotai'; 5 | 6 | import { deleteModeAtom, useSentenceHandler } from '@/features/syntax-editor'; 7 | 8 | const Sentence = forwardRef( 9 | function Sentence({ children }, ref) { 10 | const isDeleteMode = useAtomValue(deleteModeAtom); 11 | const handlers = useSentenceHandler(); 12 | const textColor = useColorModeValue('gray.700', 'gray.300'); 13 | 14 | return ( 15 | 24 | {children} 25 | 26 | ); 27 | }, 28 | ); 29 | 30 | export default Sentence; 31 | -------------------------------------------------------------------------------- /src/features/syntax-editor/components/control-panel/delete-button.tsx: -------------------------------------------------------------------------------- 1 | import { IconButton, Tooltip } from '@chakra-ui/react'; 2 | import { useAtom, useAtomValue } from 'jotai'; 3 | import { BsFillEraserFill } from 'react-icons/bs'; 4 | 5 | import { 6 | CONTROL_OPEN_POPUP_DELAY, 7 | isDisableDeleteButtonAtom, 8 | toggleDeleteModeActionAtom, 9 | } from '@/features/syntax-editor'; 10 | 11 | export default function DeleteButton() { 12 | const [isDeleteMode, toggleDeleteMode] = useAtom(toggleDeleteModeActionAtom); 13 | const isDisableDeleteButton = useAtomValue(isDisableDeleteButtonAtom); 14 | 15 | return ( 16 | 17 | } 22 | onClick={toggleDeleteMode} 23 | isDisabled={isDisableDeleteButton} 24 | /> 25 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /src/features/syntax-editor/helpers/constituent.ts: -------------------------------------------------------------------------------- 1 | import { generateNumberID } from '@/base'; 2 | import { 3 | ConstituentType, 4 | ConstituentWithoutId, 5 | } from '@/features/syntax-editor'; 6 | 7 | export const isMultipleTokensInRange = (begin: number, end: number) => { 8 | return Math.abs(begin - end) > 1; 9 | }; 10 | 11 | export const transformConstituentType = ( 12 | type: ConstituentType, 13 | begin: number, 14 | end: number, 15 | ): ConstituentType => { 16 | const hasMultipleTokens = isMultipleTokensInRange(begin, end); 17 | switch (type) { 18 | case 'token': 19 | return hasMultipleTokens ? 'token-group' : 'token'; 20 | default: 21 | return type; 22 | } 23 | }; 24 | 25 | export const generateConstituent = ( 26 | selectedTag: ConstituentWithoutId, 27 | begin: number, 28 | end: number, 29 | ) => { 30 | return { 31 | ...selectedTag, 32 | id: generateNumberID(), 33 | type: transformConstituentType(selectedTag.type, begin, end), 34 | }; 35 | }; 36 | -------------------------------------------------------------------------------- /src/features/syntax-editor/hooks/use-syntax-editor-initializer.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect } from 'react'; 2 | 3 | import { useResetAtom } from 'jotai/utils'; 4 | 5 | import { 6 | resetControlPanelAtom, 7 | resetSegmentHistoryAtom, 8 | } from '@/features/syntax-editor'; 9 | 10 | interface UseInitializerProps { 11 | resetOnUnmount?: boolean; 12 | } 13 | 14 | export const useSyntaxEditorInitializer = ({ 15 | resetOnUnmount = false, 16 | }: UseInitializerProps = {}) => { 17 | const resetSegmentHistory = useResetAtom(resetSegmentHistoryAtom); 18 | const resetControlPanel = useResetAtom(resetControlPanelAtom); 19 | 20 | const initializer = useCallback(() => { 21 | [resetSegmentHistory, resetControlPanel].forEach((reset) => reset()); 22 | }, [resetControlPanel, resetSegmentHistory]); 23 | 24 | useEffect(() => { 25 | return () => { 26 | if (resetOnUnmount) initializer(); 27 | }; 28 | }, [resetOnUnmount, initializer]); 29 | 30 | return { initializer }; 31 | }; 32 | -------------------------------------------------------------------------------- /src/features/syntax-analyzer/schemes/english-sentence-schema.ts: -------------------------------------------------------------------------------- 1 | import * as yup from 'yup'; 2 | 3 | import { ENGLISH_INPUT_PATTERN, THREE_WORDS_PATTERN } from '@/base'; 4 | import { 5 | MAX_SENTENCE_LENGTH, 6 | MIN_SENTENCE_WORDS, 7 | } from '@/features/syntax-analyzer'; 8 | 9 | export const HELPER_MESSAGES = { 10 | ENGLISH_OR_SYMBOL: '영어 혹은 문장 부호만 입력할 수 있어요', 11 | MIN_WORDS: `최소 ${MIN_SENTENCE_WORDS} 단어로 이루어진 문장을 입력해 주세요`, 12 | MAX_LENGTH: `최대 ${MAX_SENTENCE_LENGTH}자까지만 입력할 수 있어요`, 13 | } as const; 14 | 15 | export const englishInputSchema = yup 16 | .string() 17 | .trim() 18 | .matches(ENGLISH_INPUT_PATTERN, HELPER_MESSAGES.ENGLISH_OR_SYMBOL); 19 | 20 | export const threeWordsSchema = yup 21 | .string() 22 | .trim() 23 | .matches(THREE_WORDS_PATTERN, HELPER_MESSAGES.MIN_WORDS); 24 | 25 | export const englishSentenceSchema = yup 26 | .string() 27 | .trim() 28 | .max(MAX_SENTENCE_LENGTH, HELPER_MESSAGES.MAX_LENGTH) 29 | .concat(englishInputSchema) 30 | .concat(threeWordsSchema); 31 | -------------------------------------------------------------------------------- /src/features/syntax-editor/hooks/use-calculate-nesting-level.ts: -------------------------------------------------------------------------------- 1 | import { RefObject, useEffect } from 'react'; 2 | 3 | import { useBoolean } from '@chakra-ui/react'; 4 | import { useAtomValue } from 'jotai'; 5 | 6 | import { 7 | calculateNestingLevel, 8 | segmentHistoryIndexAtom, 9 | } from '@/features/syntax-editor'; 10 | 11 | interface UseCalculateNestedLevelProps { 12 | targetRef: RefObject; 13 | trigger?: unknown; 14 | } 15 | 16 | export const useCalculateNestingLevel = ({ 17 | targetRef, 18 | trigger, 19 | }: UseCalculateNestedLevelProps) => { 20 | const [isNestingLevelCalculated, setNestingLevelCalculated] = useBoolean(); 21 | const segmentHistoryIndex = useAtomValue(segmentHistoryIndexAtom); 22 | 23 | useEffect(() => { 24 | if (targetRef.current) { 25 | calculateNestingLevel(targetRef); 26 | setNestingLevelCalculated.on(); 27 | } 28 | }, [targetRef, trigger, segmentHistoryIndex, setNestingLevelCalculated]); 29 | 30 | return isNestingLevelCalculated; 31 | }; 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /src/features/syntax-editor/helpers/analysis.ts: -------------------------------------------------------------------------------- 1 | import { nanoid } from 'nanoid'; 2 | 3 | import { expandAbbreviations, generateNumberID, tokenizer } from '@/base'; 4 | 5 | import type { AnalysisSource, TAnalysis } from '@/features/syntax-editor'; 6 | 7 | export const generateAnalysis = ( 8 | sentence: string, 9 | source: AnalysisSource, 10 | ): TAnalysis => { 11 | const expandedSentence = expandAbbreviations(sentence); 12 | const tokenizedSentence = tokenizer(expandedSentence); 13 | 14 | return { 15 | id: nanoid(), 16 | source, 17 | sentence: tokenizedSentence, 18 | createdAt: new Date().toISOString(), 19 | rootSegment: { 20 | id: generateNumberID(), 21 | begin: 0, 22 | end: tokenizedSentence.length, 23 | constituents: [], 24 | children: [], 25 | }, 26 | isAnalyzedByGPT: false, 27 | }; 28 | }; 29 | 30 | export const updateAnalysisMetaData = (analysis: TAnalysis) => ({ 31 | ...analysis, 32 | id: nanoid(), 33 | createdAt: new Date().toISOString(), 34 | }); 35 | -------------------------------------------------------------------------------- /src/features/syntax-editor/components/syntax-parser/segment.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment, PropsWithChildren, ReactElement } from 'react'; 2 | 3 | import { 4 | Constituent, 5 | isMultipleTokensInRange, 6 | TSegment, 7 | } from '@/features/syntax-editor'; 8 | 9 | interface SegmentProps { 10 | segment: TSegment; 11 | } 12 | 13 | export default function Segment({ 14 | segment, 15 | children, 16 | }: PropsWithChildren) { 17 | if (!segment.constituents.length) return {children}; 18 | 19 | const { begin, end } = segment; 20 | const isMultipleTokenRange = isMultipleTokensInRange(begin, end); 21 | 22 | return segment.constituents.reduce( 23 | (tokens, constituent) => ( 24 | 31 | {tokens} 32 | 33 | ), 34 | children as ReactElement, 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /public/favicon-any.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/features/syntax-editor/pages/syntax-editor.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Flex, Stack } from '@chakra-ui/react'; 2 | import { useAtomValue } from 'jotai'; 3 | 4 | import { Notice, useBeforeUnload } from '@/base'; 5 | import { 6 | ControlPanel, 7 | isSegmentTouchedAtom, 8 | SyntaxParser, 9 | TagListAccordion, 10 | useAnalysisDataLoader, 11 | useSyntaxEditorInitializer, 12 | } from '@/features/syntax-editor'; 13 | 14 | export default function SyntaxEditor() { 15 | const isTouched = useAtomValue(isSegmentTouchedAtom); 16 | 17 | useSyntaxEditorInitializer({ resetOnUnmount: true }); 18 | useAnalysisDataLoader(); 19 | useBeforeUnload(isTouched); 20 | 21 | return ( 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /src/features/syntax-editor/hooks/use-constituent-hover.ts: -------------------------------------------------------------------------------- 1 | import { MouseEvent } from 'react'; 2 | 3 | import { useAtomValue, useSetAtom } from 'jotai'; 4 | import { RESET } from 'jotai/utils'; 5 | 6 | import { getNearestElementByClass, MouseEventHandlers } from '@/base'; 7 | import { 8 | CONSTITUENT_CLASSES, 9 | hoveredConstituentAtom, 10 | isAbbrTooltipVisibleAtom, 11 | } from '@/features/syntax-editor'; 12 | 13 | export const useConstituentHover = (): MouseEventHandlers => { 14 | const isAbbrTooltipVisible = useAtomValue(isAbbrTooltipVisibleAtom); 15 | const setHoveredConstituent = useSetAtom(hoveredConstituentAtom); 16 | 17 | const onMouseOver = ({ target }: MouseEvent) => { 18 | if (!isAbbrTooltipVisible) return; 19 | 20 | const element = getNearestElementByClass( 21 | target as HTMLElement, 22 | CONSTITUENT_CLASSES.CONSTITUENT, 23 | ); 24 | 25 | if (element) { 26 | const constituentId = Number(element.dataset.constituentId); 27 | setHoveredConstituent(constituentId); 28 | } 29 | }; 30 | 31 | const onMouseLeave = () => setHoveredConstituent(RESET); 32 | 33 | return { onMouseOver, onMouseLeave }; 34 | }; 35 | -------------------------------------------------------------------------------- /src/features/misc/components/showcase/scroll-down-button.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentProps } from 'react'; 2 | 3 | import { IconButton } from '@chakra-ui/react'; 4 | import { motion, Variants } from 'framer-motion'; 5 | import { FaAnglesDown } from 'react-icons/fa6'; 6 | 7 | const MotionIconButton = motion.create(IconButton); 8 | 9 | type ScrollDownButtonProps = ComponentProps; 10 | 11 | const motionVariants: Variants = { 12 | initial: { y: '0%' }, 13 | bounce: { 14 | y: ['0%', '50%', '0%'], 15 | transition: { 16 | y: { 17 | duration: 0.7, 18 | repeat: Infinity, 19 | ease: 'linear', 20 | }, 21 | }, 22 | }, 23 | }; 24 | 25 | export default function ScrollDownButton(buttonProps: ScrollDownButtonProps) { 26 | return ( 27 | } 34 | aria-label="Scroll down" 35 | transform="translateX(-50%)" 36 | variants={motionVariants} 37 | initial="initial" 38 | animate="bounce" 39 | {...buttonProps} 40 | /> 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /src/theme.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Colors, 3 | extendTheme, 4 | ThemeConfig, 5 | ToastProviderProps, 6 | } from '@chakra-ui/react'; 7 | 8 | export const toastOptions: ToastProviderProps = { 9 | defaultOptions: { 10 | duration: 3000, 11 | isClosable: true, 12 | containerStyle: { 13 | position: 'relative', 14 | bottom: 5, 15 | }, 16 | }, 17 | }; 18 | 19 | const semanticTokens: Colors = { 20 | colors: { 21 | description: { 22 | default: 'gray.600', 23 | _dark: 'gray.400', 24 | }, 25 | }, 26 | }; 27 | 28 | const colors: Colors = { 29 | grayAlpha: { 30 | 50: 'rgba(26, 32, 43, 0.04)', 31 | 100: 'rgba(26, 32, 43, 0.06)', 32 | 200: 'rgba(26, 32, 43, 0.08)', 33 | 300: 'rgba(26, 32, 43, 0.16)', 34 | 400: 'rgba(26, 32, 43, 0.24)', 35 | 500: 'rgba(26, 32, 43, 0.36)', 36 | 600: 'rgba(26, 32, 43, 0.48)', 37 | 700: 'rgba(26, 32, 43, 0.64)', 38 | 800: 'rgba(26, 32, 43, 0.80)', 39 | 900: 'rgba(26, 32, 43, 0.92)', 40 | }, 41 | }; 42 | 43 | const config: ThemeConfig = { 44 | initialColorMode: 'dark', 45 | useSystemColorMode: false, 46 | }; 47 | 48 | export const theme = extendTheme({ config, colors, semanticTokens }); 49 | -------------------------------------------------------------------------------- /src/features/syntax-analyzer/components/random-sentence-form/sentence-count-picker.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | NumberDecrementStepper, 3 | NumberIncrementStepper, 4 | NumberInput, 5 | NumberInputField, 6 | NumberInputStepper, 7 | } from '@chakra-ui/react'; 8 | import { Controller, useFormContext } from 'react-hook-form'; 9 | 10 | import { 11 | MAX_PICKER_SENTENCE, 12 | MIN_PICKER_SENTENCE, 13 | } from '@/features/syntax-analyzer'; 14 | 15 | export default function SentenceCountPicker() { 16 | const { control } = useFormContext(); 17 | 18 | return ( 19 | ( 23 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | )} 39 | /> 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /src/features/syntax-editor/hooks/use-analysis-data-loader.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef } from 'react'; 2 | 3 | import { useAtomValue, useSetAtom } from 'jotai'; 4 | import { useParams } from 'react-router-dom'; 5 | 6 | import { 7 | analysisListBySourceAtom, 8 | AnalysisPathParams, 9 | selectedAnalysisAtom, 10 | } from '@/features/syntax-editor'; 11 | 12 | export const useAnalysisDataLoader = () => { 13 | const { source, index } = useParams(); 14 | const isProcessed = useRef(false); 15 | 16 | const analysisListBySource = useAtomValue(analysisListBySourceAtom); 17 | const setSelectedAnalysis = useSetAtom(selectedAnalysisAtom); 18 | 19 | const loadAndSetAnalysisBySource = useCallback(() => { 20 | if (!source || !index) return; 21 | 22 | const selectedAnalysis = analysisListBySource[source][+index]; 23 | if (selectedAnalysis) { 24 | setSelectedAnalysis(selectedAnalysis); 25 | isProcessed.current = true; 26 | } 27 | }, [analysisListBySource, setSelectedAnalysis, source, index]); 28 | 29 | useEffect(() => { 30 | if (isProcessed.current) return; 31 | loadAndSetAnalysisBySource(); 32 | }, [loadAndSetAnalysisBySource]); 33 | }; 34 | -------------------------------------------------------------------------------- /src/app.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from 'react'; 2 | 3 | import { useQueryErrorResetBoundary } from '@tanstack/react-query'; 4 | import { LazyMotion } from 'framer-motion'; 5 | import { ErrorBoundary } from 'react-error-boundary'; 6 | import { Outlet } from 'react-router-dom'; 7 | 8 | import { Layout, ThreeDotsWave, useRemoveBodyBgColor } from '@/base'; 9 | import { ErrorComponent } from '@/features/misc'; 10 | 11 | /** 12 | * Reduce bundle size by only importing the domAnimation feature 13 | * @see https://www.framer.com/motion/guide-reduce-bundle-size/ 14 | * */ 15 | const framerFeature = async () => (await import('@/base')).domAnimation; 16 | 17 | const Fallback = () => ( 18 | 19 | 20 | 21 | ); 22 | 23 | export const App = () => { 24 | const { reset } = useQueryErrorResetBoundary(); 25 | useRemoveBodyBgColor(); 26 | 27 | return ( 28 | 29 | 30 | }> 31 | 32 | 33 | 34 | 35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /src/features/syntax-editor/components/syntax-parser/token.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Popover, 3 | PopoverAnchor, 4 | PopoverBody, 5 | PopoverContent, 6 | Portal, 7 | Text, 8 | TextProps, 9 | } from '@chakra-ui/react'; 10 | import { useAtomValue } from 'jotai'; 11 | 12 | import { 13 | CONSTITUENT_DATA_ATTRS, 14 | invalidRangeIndexAtom, 15 | } from '@/features/syntax-editor'; 16 | 17 | interface TokenProps extends TextProps { 18 | token: string; 19 | index: number; 20 | } 21 | 22 | export default function Token({ token, index, ...textProps }: TokenProps) { 23 | const invalidIndex = useAtomValue(invalidRangeIndexAtom); 24 | const dataAttrs = { [CONSTITUENT_DATA_ATTRS.INDEX]: index }; 25 | 26 | return ( 27 | 28 | 29 | 36 | {token} 37 | 38 | 39 | 40 | 41 | 구/절은 서로 교차할 수 없어요 42 | 43 | 44 | 45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /src/features/syntax-analyzer/api/get-random-sentences.ts: -------------------------------------------------------------------------------- 1 | import { useQuery, UseQueryOptions } from '@tanstack/react-query'; 2 | import { AxiosError } from 'axios'; 3 | 4 | import { RandomSentenceFormValues } from '@/features/syntax-analyzer'; 5 | import { axios, paramsSerializer } from '@/lib'; 6 | 7 | type RandomSentenceResponse = string[]; 8 | type RandomSentenceParams = Omit; 9 | 10 | export const getRandomSentences = async < 11 | T = RandomSentenceResponse, 12 | K = RandomSentenceParams, 13 | >( 14 | params: K, 15 | ) => { 16 | const { data } = await axios.get('analyzer/random-sentences', { 17 | params, 18 | paramsSerializer, 19 | }); 20 | return data; 21 | }; 22 | 23 | export const RANDOM_SENTENCE_BASE_KEY = ['random-sentences'] as const; 24 | 25 | export const useRandomSentenceQuery = ( 26 | params: RandomSentenceParams & { timeStamp: number }, 27 | options?: Partial>, 28 | ) => { 29 | return useQuery({ 30 | queryKey: [...RANDOM_SENTENCE_BASE_KEY, params], 31 | queryFn: () => { 32 | const { sent_count, topics } = params; 33 | return getRandomSentences({ sent_count, topics }); 34 | }, 35 | ...options, 36 | }); 37 | }; 38 | -------------------------------------------------------------------------------- /src/features/syntax-editor/components/control-panel/reset-button.tsx: -------------------------------------------------------------------------------- 1 | import { IconButton, Tooltip } from '@chakra-ui/react'; 2 | import { useAtomValue } from 'jotai'; 3 | import { MdOutlineRestore } from 'react-icons/md'; 4 | 5 | import { ConfirmPopover } from '@/base'; 6 | import { 7 | CONTROL_OPEN_POPUP_DELAY, 8 | isSegmentTouchedAtom, 9 | useSyntaxEditorInitializer, 10 | } from '@/features/syntax-editor'; 11 | 12 | export default function ResetButton() { 13 | const { initializer } = useSyntaxEditorInitializer(); 14 | const isTouched = useAtomValue(isSegmentTouchedAtom); 15 | 16 | return ( 17 | 21 | {({ onOpen, isOpen }) => ( 22 | 28 | } 33 | isDisabled={!isTouched} 34 | onClick={onOpen} 35 | /> 36 | 37 | )} 38 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /src/features/syntax-analyzer/api/create-analysis.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, UseMutationOptions } from '@tanstack/react-query'; 2 | import { AxiosError } from 'axios'; 3 | 4 | import { AnalysisModel } from '@/features/syntax-analyzer'; 5 | import { TAnalysis } from '@/features/syntax-editor'; 6 | import { axios } from '@/lib'; 7 | 8 | export type CreateAnalysisResponse = TAnalysis; 9 | export type CreateAnalysisPayload = { 10 | model: AnalysisModel; 11 | sentence: string[]; 12 | }; 13 | 14 | export const createAnalysis = async < 15 | T = CreateAnalysisResponse, 16 | K = CreateAnalysisPayload, 17 | >( 18 | payload: K, 19 | ) => { 20 | const { data } = await axios.post('analyzer', payload); 21 | return data; 22 | }; 23 | 24 | /** 25 | * TData : mutation 함수 실행 결과 타입 26 | * TError: mutation 함수 에러 타입 27 | * TVariables: mutation 함수 파라미터 타입 28 | * TContext: onMutate 콜백 함수 리턴 타입 29 | * */ 30 | export const CREATE_ANALYSIS_BASE_KEY = ['analysis']; 31 | 32 | export const useCreateAnalysisMutation = < 33 | TData = TAnalysis, 34 | TVariables = CreateAnalysisPayload, 35 | >( 36 | options?: UseMutationOptions, 37 | ) => { 38 | return useMutation({ 39 | mutationFn: createAnalysis, 40 | mutationKey: CREATE_ANALYSIS_BASE_KEY, 41 | ...options, 42 | }); 43 | }; 44 | -------------------------------------------------------------------------------- /src/base/constants/abbreviations.ts: -------------------------------------------------------------------------------- 1 | export const ABBREVIATIONS: Record = { 2 | "'ll": ' will', 3 | "'ve": ' have', 4 | "'re": ' are', 5 | "'d": ' would', 6 | "'m": ' am', 7 | "'s": ' is', 8 | "can't": 'cannot', 9 | "couldn't": 'could not', 10 | "shouldn't": 'should not', 11 | "won't": 'will not', 12 | "wouldn't": 'would not', 13 | "doesn't": 'does not', 14 | "don't": 'do not', 15 | "didn't": 'did not', 16 | "n't": ' not', 17 | "ain't": 'am not', // or 'is not', 'are not', 'has not', 'have not' based on context 18 | "aren't": 'are not', 19 | "wasn't": 'was not', 20 | "weren't": 'were not', 21 | "hasn't": 'has not', 22 | "haven't": 'have not', 23 | "isn't": 'is not', 24 | "it's": 'it is', // or 'it has' based on context 25 | "i'm": 'I am', 26 | "i've": 'I have', 27 | "i'd": 'I would', // or 'I had' based on context 28 | "i'll": 'I will', 29 | "you're": 'you are', 30 | "you've": 'you have', 31 | "you'd": 'you would', // or 'you had' based on context 32 | "you'll": 'you will', 33 | "let's": 'let us', 34 | "he's": 'he is', // or 'he has' based on context 35 | "she's": 'she is', // or 'she has' based on context 36 | "they're": 'they are', 37 | "they've": 'they have', 38 | "they'd": 'they would', // or 'they had' based on context 39 | "they'll": 'they will', 40 | }; 41 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | 4 | import { ChakraProvider, ColorModeScript } from '@chakra-ui/react'; 5 | import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; 6 | import { Analytics } from '@vercel/analytics/react'; 7 | import { SpeedInsights } from '@vercel/speed-insights/react'; 8 | import { RouterProvider } from 'react-router-dom'; 9 | 10 | import { ConfiguredQueryProvider } from '@/lib'; 11 | import { router } from '@/routes'; 12 | import { theme, toastOptions } from '@/theme'; 13 | 14 | const rootElement = document.getElementById('root'); 15 | 16 | createRoot(rootElement!).render( 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 31 | 32 | 33 | , 34 | ); 35 | -------------------------------------------------------------------------------- /src/base/components/ui/lazy-image.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment, useState } from 'react'; 2 | 3 | import { AspectRatio, AspectRatioProps, Image } from '@chakra-ui/react'; 4 | 5 | export interface LazyImageProps extends AspectRatioProps { 6 | src: string; 7 | placeholderSrc: string; 8 | alt: string; 9 | } 10 | 11 | export default function LazyImage({ 12 | src, 13 | alt, 14 | maxW = 540, 15 | placeholderSrc, 16 | ...aspectRatioProps 17 | }: LazyImageProps) { 18 | const [isLoaded, setIsLoaded] = useState(false); 19 | 20 | return ( 21 | 31 | 32 | {`${alt} 38 | setIsLoaded(true)} 42 | src={src} 43 | alt={alt} 44 | opacity={isLoaded ? 1 : 0} 45 | transition="opacity 0.7s ease-in-out" 46 | loading="lazy" 47 | /> 48 | 49 | 50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /src/features/syntax-editor/components/tag-list-accordion/selectable-tag-button.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Tooltip } from '@chakra-ui/react'; 2 | import { useAtom, useAtomValue } from 'jotai'; 3 | 4 | import { 5 | CONSTITUENT_TRANSLATIONS, 6 | ConstituentWithoutId, 7 | selectedTagActionAtom, 8 | tagInfoModeAtom, 9 | } from '@/features/syntax-editor'; 10 | 11 | interface TagButtonProps { 12 | constituent: ConstituentWithoutId; 13 | } 14 | 15 | export default function SelectableTagButton({ constituent }: TagButtonProps) { 16 | const isTagInfoMode = useAtomValue(tagInfoModeAtom); 17 | const [selectedTag, setSelectedTag] = useAtom(selectedTagActionAtom); 18 | 19 | const onTagClick = (tag: ConstituentWithoutId) => { 20 | if (selectedTag?.elementId === tag.elementId) { 21 | setSelectedTag(null); 22 | return; 23 | } 24 | setSelectedTag(tag); 25 | }; 26 | 27 | const { ko, desc } = CONSTITUENT_TRANSLATIONS[constituent.label]; 28 | const isSelected = selectedTag?.elementId === constituent.elementId; 29 | return ( 30 | 31 | 38 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /src/base/utils/date.ts: -------------------------------------------------------------------------------- 1 | import { format, isValid, parseISO } from 'date-fns'; 2 | import * as locale from 'date-fns/locale'; 3 | 4 | import { ISODateString } from '@/base/types'; 5 | 6 | /** 7 | * Formats an ISO date string into a formatted Korean date string. 8 | * 9 | * @param {ISODateString} isoString - The ISO date string to format. 10 | * @return {string} The formatted Korean date string. 11 | */ 12 | export const getFormattedKoDate = (isoString: ISODateString): string => { 13 | const date = parseISO(isoString); 14 | if (!isValid(date)) throw new Error(`Invalid ISO string: ${isoString}`); 15 | return format(date, 'PPP(eee) p', { locale: locale.ko }); 16 | }; 17 | 18 | /** 19 | * Checks if the targetDate is less than or equal to the specified offsetInSeconds ago. 20 | * 21 | * @param {ISODateString} targetDate - The target date to compare. 22 | * @param {number} offsetInSeconds - The offset in seconds. 23 | * @return {boolean} True if the targetDate is less than or equal to the offsetInSeconds ago, false otherwise. 24 | */ 25 | export const isLessThanAgo = ( 26 | targetDate: ISODateString, 27 | offsetInSeconds: number, 28 | ): boolean => { 29 | const currentTime = Date.now(); 30 | const targetTime = parseISO(targetDate).getTime(); 31 | const diffInSeconds = (currentTime - targetTime) / 1000; 32 | 33 | return diffInSeconds <= offsetInSeconds; 34 | }; 35 | -------------------------------------------------------------------------------- /src/base/utils/react-utils.ts: -------------------------------------------------------------------------------- 1 | import { ComponentType, lazy } from 'react'; 2 | 3 | type ComponentName = string; 4 | type Loader = () => Promise; 5 | type NamedComponents = Record; 6 | 7 | /** 8 | * A utility function that enhances React.lazy() by allowing named imports. This function 9 | * provides a way to dynamically import individual components using their names. 10 | * 11 | * @example 12 | * const { Home, About } = lazyImport(() => import('@/features/misc')); 13 | * 14 | * @see {https://github.com/facebook/react/issues/14603#issuecomment-736878172 React's discussion on named imports} 15 | * @see {https://github.com/JLarky/react-lazily/blob/main/src/core/lazily.ts react-lazily source code for a similar approach} 16 | */ 17 | export const lazyImport = (loader: Loader) => { 18 | return new Proxy({} as T, { 19 | get: (_target, name: ComponentName) => { 20 | return lazy(async () => { 21 | const module = await loader(); 22 | const Component = module[name] as ComponentType; 23 | if (!Component) throw new Error(`Component ${name} not found`); 24 | 25 | return { default: Component }; 26 | }); 27 | }, 28 | }); 29 | }; 30 | 31 | export const getDisplayName = (Component: ComponentType) => { 32 | return Component.displayName || Component.name || 'Component'; 33 | }; 34 | -------------------------------------------------------------------------------- /qodana.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | #-------------------------------------------------------------------------------# 3 | # Qodana analysis is configured by qodana.yaml file # 4 | # https://www.jetbrains.com/help/qodana/qodana-yaml.html # 5 | #-------------------------------------------------------------------------------# 6 | version: '1.0' 7 | 8 | #Specify inspection profile for code analysis 9 | profile: 10 | name: qodana.recommended 11 | 12 | #Enable inspections 13 | #include: 14 | # - name: 15 | 16 | #Disable inspections 17 | #exclude: 18 | # - name: 19 | # paths: 20 | # - 21 | 22 | exclude: 23 | - name: All 24 | paths: 25 | - node_modules/ 26 | include: 27 | - name: Eslint 28 | - name: GrazieInspection 29 | - name: IdentifierGrammar 30 | - name: LanguageDetectionInspection 31 | - name: CheckDependencyLicenses 32 | 33 | #The following options are only applied in CI/CD environment 34 | #These options are ignored during local run 35 | 36 | #Execute shell command before Qodana execution 37 | #bootstrap: sh ./prepare-qodana.sh 38 | 39 | #Install IDE plugins before Qodana execution 40 | #plugins: 41 | # - id: #(plugin id can be found at https://plugins.jetbrains.com) 42 | 43 | #Specify Qodana linter for analysis 44 | linter: jetbrains/qodana-js:latest 45 | -------------------------------------------------------------------------------- /src/features/syntax-analyzer/components/random-sentence-form/generate-button.tsx: -------------------------------------------------------------------------------- 1 | import { Button, ButtonProps, Skeleton, Text } from '@chakra-ui/react'; 2 | import { RiAiGenerate } from 'react-icons/ri'; 3 | 4 | import { 5 | DAILY_SENTENCE_LIMIT, 6 | UsageLimitTooltip, 7 | useRemainingCountQuery, 8 | } from '@/features/syntax-analyzer'; 9 | 10 | export default function GenerateButton({ 11 | onClick, 12 | isLoading, 13 | ...buttonProps 14 | }: ButtonProps) { 15 | const { data: count = 0 } = useRemainingCountQuery({ 16 | select: (data) => data.random_sentence, 17 | }); 18 | const hasCount = count > 0; 19 | 20 | return ( 21 | 22 | 39 | 40 | ); 41 | } 42 | 43 | const GenerateButtonSkeleton = () => { 44 | return ; 45 | }; 46 | 47 | GenerateButton.Skeleton = GenerateButtonSkeleton; 48 | -------------------------------------------------------------------------------- /src/features/syntax-analyzer/components/random-sentence-form/topic-tag-list.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | HStack, 3 | StackProps, 4 | Tag, 5 | TagCloseButton, 6 | TagLabel, 7 | } from '@chakra-ui/react'; 8 | import { useAutoAnimate } from '@formkit/auto-animate/react'; 9 | import { useFormContext, useWatch } from 'react-hook-form'; 10 | 11 | import { RandomSentenceFormValues } from '@/features/syntax-analyzer'; 12 | 13 | export default function TopicTagList(stackProps: StackProps) { 14 | const { control, setValue } = useFormContext(); 15 | const topics = useWatch({ name: 'topics', control }); 16 | const [parent] = useAutoAnimate({ duration: 200 }); 17 | 18 | const onTagClick = (keyword: string) => { 19 | const filteredTopics = topics.filter((topic) => topic !== keyword); 20 | setValue('topics', filteredTopics); 21 | }; 22 | 23 | /** Wrap 컴포넌트 사용시 useAutoAnimate 작동 안함 */ 24 | return ( 25 | 26 | {topics.map((topic) => ( 27 | 34 | 35 | {topic} 36 | 37 | onTagClick(topic)} /> 38 | 39 | ))} 40 | 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /src/base/utils/image.ts: -------------------------------------------------------------------------------- 1 | import { Entries } from '@/base'; 2 | 3 | /** 4 | * {@link https://docs.imagekit.io/features/image-transformations/resize-crop-and-other-transformations More transform options} 5 | * */ 6 | interface ImageOptions { 7 | width?: number; 8 | blur?: number; 9 | quality?: number; 10 | } 11 | 12 | /** 13 | * Generates an image URL with ImageKit placeholder transformation parameters. 14 | * 15 | * @param {string} originalSrc - The original source URL of the image. 16 | * @param {ImageOptions} options - Optional parameters for the image transformation. 17 | * @return {string} - The transformed image URL with placeholder parameters. 18 | */ 19 | export const getImageKitPlaceholder = ( 20 | originalSrc: string, 21 | options: ImageOptions = {}, 22 | ): string => { 23 | const paramMapping: Record = { 24 | width: 'w', 25 | blur: 'bl', 26 | quality: 'q', 27 | }; 28 | 29 | const defaultOptions = { width: 200, blur: 30, quality: 50 }; 30 | const mergedOptions = { ...defaultOptions, ...options }; 31 | type Options = typeof mergedOptions; 32 | 33 | const entries = Object.entries(mergedOptions) as Entries; 34 | 35 | const params = entries 36 | .reduce((acc: string[], [key, value]) => { 37 | if (value) acc.push(`${paramMapping[key]}-${value}`); 38 | return acc; 39 | }, []) 40 | .join(','); 41 | 42 | return `${originalSrc}?tr=${params}`; 43 | }; 44 | -------------------------------------------------------------------------------- /src/features/misc/components/showcase/hero-showcase.tsx: -------------------------------------------------------------------------------- 1 | import { Heading, Stack, StackProps, Text } from '@chakra-ui/react'; 2 | 3 | import { ScrollDownButton } from '@/features/misc/components'; 4 | 5 | interface TitleSectionProps extends StackProps { 6 | onScrollDown?: () => void; 7 | } 8 | 9 | export default function HeroShowcase({ 10 | onScrollDown, 11 | ...stackProps 12 | }: TitleSectionProps) { 13 | return ( 14 | 15 | 16 | 21 | Interpreting English Structure with AI 22 | 23 | 29 | {`english syntax\nanalyzer`} 30 | 31 | 32 | 33 | Dive into the elegance of language as AI-analyzed English sentences 34 | unfold in a clear hierarchical visualization. Grasp the intricacies at a 35 | glance, and harness the freedom to directly edit and adapt as you see 36 | fit. 37 | 38 | 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /src/base/constants/regex.ts: -------------------------------------------------------------------------------- 1 | import { ABBREVIATIONS } from '@/base/constants/abbreviations'; 2 | 3 | export const DIGITS_PATTERN = /\d+/g; 4 | 5 | /** 6 | * 콤마로 구분된 천 단위 숫자를 찾기 위한 정규식 7 | * 예시: "approximately 6,500 spoken" -> `6,500` 매칭 8 | * 9 | * - `\d{1,3}` 1~3개의 숫자(\d)가 연속해서 나타나는 패턴 탐색. (예: 1, 12, 123) 10 | * - `(?:,\d{3})+` 콤마(,) 뒤에 숫자 3개가 오는 패턴 탐색. 11 | * - `+` 앞 패턴이 1번 이상 반복. 12 | * - `?:` 비캡처링 그룹화 -> 그룹화는 필요하지만 캡처화는 필요하지 않을 때 -> 메모리 절약 13 | */ 14 | export const NUM_WITH_COMMAS_REGEX = /(\d{1,3}(?:,\d{3})+)/g; 15 | 16 | /** 단어 문자(영어/숫자/언더스코어)가 아니거나 아포스트로피(')가 아닌 모든 문자와 일치 (공백, 콤마 등) */ 17 | export const NON_WORD_CHAR_PATTERN = /([^\w'])/g; 18 | export const PUNCTUATION_PATTERN = /([.,!?])/g; 19 | 20 | /** 21 | * 3개 이상의 단어가 연속적으로 나타나고, 각 단어 사이에 콤마나 공백이 1개 이상 있는지 확인하는 정규식 22 | * 예시: "apple, banana, cherry" 또는 "apple banana cherry" 23 | * 24 | * - `\b\w+\b` 단어 경계(\b) 사이에 단어 문자(\w)가 1개 이상 있는지 확인 (예: 'apple') 25 | * - `[,\s]+` 콤마나 공백이 1개 이상 있는지 확인 (예: ', ' 또는 ' ') 26 | * - `(\b\w+\b[,\s]+){2,}` 위의 두 패턴이 2번 이상 연속으로 반복 (예: 'apple, banana, ') 27 | * - `\b\w+\b` 마지막에 나타나는 단어 (예: 'cherry') 28 | */ 29 | export const THREE_WORDS_PATTERN = /(\b\w+\b[,\s]+){2,}\b\w+\b/; 30 | export const TWO_WORDS_PATTERN = /(\b\w+\b[,\s]+)\b\w+\b/; 31 | export const ENGLISH_INPUT_PATTERN = 32 | /^[a-zA-Z0-9 .,!?'":;\-()/@#$%^&*_+=|<>{}[\]~`]*$/; 33 | 34 | export const ABBREVIATIONS_PATTERNS = new RegExp( 35 | Object.keys(ABBREVIATIONS).join('|'), 36 | 'g', 37 | ); 38 | -------------------------------------------------------------------------------- /src/features/syntax-editor/helpers/nesting-level-calculator.ts: -------------------------------------------------------------------------------- 1 | import { RefObject } from 'react'; 2 | 3 | import { kebabToCamel } from '@/base'; 4 | import { 5 | CONSTITUENT_CLASSES, 6 | CONSTITUENT_DATA_ATTRS, 7 | getNumberAttr, 8 | } from '@/features/syntax-editor'; 9 | 10 | const assignCalculatedLevel = (element: HTMLElement) => { 11 | let maxChildLevel = 0; 12 | 13 | for (const child of element.children) { 14 | const childLevel = assignCalculatedLevel(child as HTMLElement); 15 | maxChildLevel = Math.max(maxChildLevel, childLevel); 16 | } 17 | 18 | const hasChild = element.children.length > 0; 19 | const currentLevel = hasChild ? maxChildLevel + 1 : maxChildLevel; 20 | 21 | const classesToCheck = [ 22 | CONSTITUENT_CLASSES.TOKEN, 23 | CONSTITUENT_CLASSES.TOKEN_GROUP, 24 | ]; 25 | 26 | classesToCheck.forEach((className) => { 27 | if (element.classList.contains(className)) { 28 | element.dataset[kebabToCamel(`${className}-lv`)] = `${currentLevel}`; 29 | } 30 | }); 31 | 32 | return currentLevel; 33 | }; 34 | 35 | export const calculateNestingLevel = (ref: RefObject) => { 36 | const childElements = ref.current?.children; 37 | if (!childElements) return; 38 | 39 | Array.from(childElements).forEach((span) => { 40 | const spanElement = span as HTMLElement; 41 | if (getNumberAttr(spanElement, CONSTITUENT_DATA_ATTRS.ID)) { 42 | assignCalculatedLevel(spanElement); 43 | } 44 | }); 45 | }; 46 | -------------------------------------------------------------------------------- /src/features/syntax-analyzer/pages/syntax-analyzer.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from 'react'; 2 | 3 | import { Box, HStack } from '@chakra-ui/react'; 4 | import { useIsMutating } from '@tanstack/react-query'; 5 | 6 | import { CenteredDivider, Layout } from '@/base'; 7 | import { 8 | AnalysisCounter, 9 | AnalysisForm, 10 | AnalysisLoadIndicator, 11 | CREATE_ANALYSIS_BASE_KEY, 12 | FieldGroupHeader, 13 | LoadingTransition, 14 | RandomSentenceForm, 15 | } from '@/features/syntax-analyzer'; 16 | 17 | export default function SyntaxAnalyzer() { 18 | const isMutating = useIsMutating({ mutationKey: CREATE_ANALYSIS_BASE_KEY }); 19 | 20 | return ( 21 | 22 | 23 | }> 24 | 25 | 26 | 27 | }> 28 | 29 | 30 | 31 | 32 | 랜덤 문장 생성 33 | 34 | 35 | 36 | 37 | 38 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /src/features/syntax-analyzer/components/loading-transition.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from 'react'; 2 | 3 | import { Stack, StackProps } from '@chakra-ui/react'; 4 | 5 | interface LoadingFadeProps extends StackProps { 6 | isLoading: boolean; 7 | type: 'content' | 'indicator'; 8 | } 9 | 10 | export default function LoadingTransition({ 11 | children, 12 | isLoading, 13 | type, 14 | ...stackProps 15 | }: PropsWithChildren) { 16 | const getStyleFunc = 17 | type === 'content' ? getContentFadeStyles : getLoadingFadeStyles; 18 | 19 | const props = { ...getStyleFunc(isLoading), ...stackProps }; 20 | 21 | return {children}; 22 | } 23 | 24 | const TRANSFORM_DURATION = '0.7s'; 25 | const OPACITY_DURATION = '0.4s'; 26 | 27 | const getContentFadeStyles = (isLoading: boolean): StackProps => { 28 | return { 29 | transition: `transform ${TRANSFORM_DURATION}, opacity ${OPACITY_DURATION}`, 30 | opacity: isLoading ? 0 : 1, 31 | transform: isLoading ? 'translateX(-100%)' : 'translateX(0)', 32 | }; 33 | }; 34 | 35 | const getLoadingFadeStyles = (isLoading: boolean): StackProps => { 36 | return { 37 | position: 'absolute', 38 | top: '45%', 39 | left: '45%', 40 | opacity: isLoading ? 1 : 0, 41 | transition: `transform ${TRANSFORM_DURATION}, opacity ${OPACITY_DURATION}`, 42 | transform: `translate(-50%, -50%) ${ 43 | isLoading ? 'translateX(0)' : 'translateX(100%)' 44 | }`, 45 | }; 46 | }; 47 | -------------------------------------------------------------------------------- /src/features/syntax-analyzer/api/get-remaining-counts.ts: -------------------------------------------------------------------------------- 1 | import { 2 | QueryClient, 3 | useSuspenseQuery, 4 | UseSuspenseQueryOptions, 5 | } from '@tanstack/react-query'; 6 | import { AxiosError } from 'axios'; 7 | 8 | import { axios } from '@/lib'; 9 | 10 | export type RemainingCountResponse = { 11 | analysis: number; 12 | random_sentence: number; 13 | }; 14 | export const getRemainingCounts = async () => { 15 | const { data } = await axios.get('analyzer/remaining-counts'); 16 | return data; 17 | }; 18 | 19 | export const REMAINING_COUNT_BASE_KEY = ['remaining-counts']; 20 | 21 | /** 22 | * TQueryFnData: query 함수 리턴 타입 23 | * TError: query 함수 에러 타입 24 | * TData : select 함수로 쿼리 리턴값을 가공할 때 사용하는 리턴 타입 25 | * */ 26 | export const useRemainingCountQuery = < 27 | TQueryFnData = RemainingCountResponse, 28 | TData = TQueryFnData, 29 | >( 30 | options?: Partial>, 31 | ) => { 32 | return useSuspenseQuery({ 33 | queryKey: REMAINING_COUNT_BASE_KEY, 34 | queryFn: getRemainingCounts, 35 | ...options, 36 | }); 37 | }; 38 | 39 | /** 40 | * React Router loader 사용시 활용 41 | * @see https://reactrouter.com/en/main/guides/data-libs 42 | * */ 43 | export const analysisCountLoader = (queryClient: QueryClient) => { 44 | return () => { 45 | return queryClient.fetchQuery({ 46 | queryKey: REMAINING_COUNT_BASE_KEY, 47 | queryFn: getRemainingCounts, 48 | }); 49 | }; 50 | }; 51 | -------------------------------------------------------------------------------- /src/base/components/ui/link-particles.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | import { useColorMode } from '@chakra-ui/react'; 4 | import { type ISourceOptions } from '@tsparticles/engine'; 5 | import { loadLinksPreset } from '@tsparticles/preset-links'; 6 | // eslint-disable-next-line import/no-named-as-default 7 | import Particles, { 8 | initParticlesEngine, 9 | type IParticlesProps, 10 | } from '@tsparticles/react'; 11 | 12 | export default function LinkParticles({ 13 | options, 14 | ...particlesProps 15 | }: IParticlesProps) { 16 | const isLightMode = useColorMode().colorMode === 'light'; 17 | const color = isLightMode ? '#5b5b5b' : '#b0b0b0'; 18 | 19 | const [init, setInit] = useState(false); 20 | 21 | const defaultOptions: ISourceOptions = { 22 | preset: 'links', 23 | fullScreen: { enable: true, zIndex: -1 }, 24 | background: { color: 'transparent' }, 25 | particles: { 26 | color: { value: color }, 27 | opacity: { value: 0.3 }, 28 | links: { opacity: 0.3, color }, 29 | }, 30 | }; 31 | 32 | /** {@link https://particles.js.org/docs/modules/_tsparticles_react.html 초기 설정 참고} */ 33 | useEffect(() => { 34 | initParticlesEngine(async (engine) => { 35 | await loadLinksPreset(engine); 36 | }).then(() => setInit(true)); 37 | }, []); 38 | 39 | if (!init) return null; 40 | 41 | return ( 42 | 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /src/features/syntax-editor/pages/sentence-manager.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | import { Box, HStack, ScaleFade, Stack } from '@chakra-ui/react'; 4 | 5 | import { CenteredDivider, Notice, useIsMounted } from '@/base'; 6 | import { 7 | FieldGroupHeader, 8 | RandomSentenceForm, 9 | } from '@/features/syntax-analyzer'; 10 | import { 11 | AddSentenceForm, 12 | DEFAULT_SENTENCE_LIST_TAB, 13 | SentenceList, 14 | } from '@/features/syntax-editor'; 15 | 16 | export default function SentenceManager() { 17 | const isMounted = useIsMounted(); 18 | const [tabIndex, setTabIndex] = useState(DEFAULT_SENTENCE_LIST_TAB); 19 | 20 | return ( 21 | 22 | 23 | 29 | setTabIndex(0)} /> 30 | 31 | 32 | 랜덤 문장 생성 33 | 34 | 35 | 36 | 37 | 38 | setTabIndex(i)} 41 | /> 42 | 43 | 44 | 45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /src/base/hooks/use-before-unload.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect } from 'react'; 2 | 3 | /** 4 | * Creates a hook that adds a 'beforeunload' event listener to the window object. 5 | * The hook prompts the user for confirmation when attempting to navigate away or close the tab. 6 | * The behavior can be toggled using the `isEnabled` parameter. 7 | * 8 | * @param {boolean} isEnabled - Determines whether the beforeunload event is active. 9 | * Defaults to true. 10 | * @returns {void} 11 | */ 12 | export const useBeforeUnload = (isEnabled: boolean = true): void => { 13 | const handleTabClose = useCallback( 14 | (event: BeforeUnloadEvent) => { 15 | // If returns null or undefined, It won't prompt to confirm the page unload 16 | if (!isEnabled) return null; 17 | 18 | // Cancel the event as a standard approach 19 | event.preventDefault(); 20 | /** 21 | * Since Chrome does not support `event.preventDefault()` for 'beforeunload' events, 22 | * setting event.returnValue to an empty string will prompt the user for confirmation. 23 | * @see https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event#compatibility_notes 24 | */ 25 | return (event.returnValue = ''); 26 | }, 27 | [isEnabled], 28 | ); 29 | 30 | useEffect(() => { 31 | window.addEventListener('beforeunload', handleTabClose); 32 | 33 | return () => { 34 | window.removeEventListener('beforeunload', handleTabClose); 35 | }; 36 | }, [handleTabClose]); 37 | }; 38 | -------------------------------------------------------------------------------- /src/base/components/modal/confirm-modal.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useRef } from 'react'; 2 | 3 | import { 4 | AlertDialog, 5 | AlertDialogBody, 6 | AlertDialogCloseButton, 7 | AlertDialogContent, 8 | AlertDialogFooter, 9 | AlertDialogHeader, 10 | AlertDialogOverlay, 11 | Button, 12 | } from '@chakra-ui/react'; 13 | 14 | interface ConfirmModalProps { 15 | onConfirm: () => void; 16 | headerContent: ReactNode; 17 | bodyContent: ReactNode; 18 | isOpen: boolean; 19 | onClose: () => void; 20 | } 21 | 22 | export default function ConfirmModal({ 23 | onConfirm, 24 | isOpen, 25 | onClose, 26 | headerContent, 27 | bodyContent, 28 | }: ConfirmModalProps) { 29 | const cancelRef = useRef(null); 30 | return ( 31 | 37 | 38 | 39 | 40 | {headerContent} 41 | 42 | 43 | {bodyContent} 44 | 45 | 48 | 51 | 52 | 53 | 54 | 55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /src/routes/router.tsx: -------------------------------------------------------------------------------- 1 | import { createBrowserRouter } from 'react-router-dom'; 2 | 3 | import { lazyImport } from '@/base'; 4 | 5 | import { SITE_URLS } from './paths'; 6 | 7 | const { App } = lazyImport(() => import('@/app')); 8 | 9 | const { ErrorComponent, Home } = lazyImport(() => import('@/features/misc')); 10 | 11 | const { SyntaxAnalyzer } = lazyImport( 12 | () => import('@/features/syntax-analyzer'), 13 | ); 14 | 15 | const { SentenceManager, SyntaxEditor, SyntaxEditorRoot } = lazyImport( 16 | () => import('@/features/syntax-editor'), 17 | ); 18 | 19 | export const router = createBrowserRouter( 20 | [ 21 | { 22 | path: SITE_URLS.ROOT, 23 | element: , 24 | errorElement: , 25 | children: [ 26 | { 27 | index: true, 28 | element: , 29 | }, 30 | { 31 | path: SITE_URLS.ANALYZER.ROOT, 32 | element: , 33 | }, 34 | { 35 | path: SITE_URLS.EDITOR.ROOT, 36 | element: , 37 | children: [ 38 | { 39 | index: true, 40 | element: , 41 | }, 42 | { 43 | path: SITE_URLS.EDITOR.EDIT, 44 | element: , 45 | }, 46 | ], 47 | }, 48 | ], 49 | }, 50 | ], 51 | { 52 | future: { 53 | /** @see https://reactrouter.com/en/6.28.2/upgrading/future#v7_relativesplatpath */ 54 | v7_relativeSplatPath: true, 55 | }, 56 | }, 57 | ); 58 | -------------------------------------------------------------------------------- /src/features/syntax-analyzer/schemes/random-sentence-form-schema.ts: -------------------------------------------------------------------------------- 1 | import * as yup from 'yup'; 2 | 3 | import { 4 | DEFAULT_PICKER_COUNT, 5 | MAX_PICKER_SENTENCE, 6 | MAX_TOPIC_ADDITION, 7 | MAX_TOPIC_LENGTH, 8 | MIN_PICKER_SENTENCE, 9 | MIN_TOPIC_LENGTH, 10 | } from '@/features/syntax-analyzer/constants'; 11 | 12 | import { englishInputSchema } from './english-sentence-schema'; 13 | 14 | const keywordSchema = englishInputSchema 15 | .lowercase() 16 | .min(MIN_TOPIC_LENGTH, `최소 ${MIN_TOPIC_LENGTH}자 이상 입력 해주세요`) 17 | .max(MAX_TOPIC_LENGTH, `최대 ${MAX_TOPIC_LENGTH}자 까지 입력할 수 있어요`) 18 | .ensure(); // 기본값 빈 문자열로 설정하고 null/undefined 는 빈 문자열로 변환 19 | 20 | const topicsSchema = yup 21 | .array() 22 | .required() 23 | .of(keywordSchema.required()) 24 | .max( 25 | MAX_TOPIC_ADDITION, 26 | `키워드는 최대 ${MAX_TOPIC_ADDITION}개까지 추가할 수 있어요`, 27 | ) 28 | .test('unique', '키워드는 중복될 수 없어요', (list) => { 29 | return list.length === new Set(list).size; 30 | }) 31 | .ensure(); // 기본값 빈 배열로 설정하고 null/undefined 는 빈 배열로 변환 32 | 33 | export const randomSentenceFormSchema = yup.object({ 34 | sent_count: yup 35 | .number() 36 | .positive(`최소 ${MIN_PICKER_SENTENCE}개 문장`) 37 | .max(MAX_PICKER_SENTENCE, `최대 ${MAX_PICKER_SENTENCE}개 문장`) 38 | .required() 39 | .default(() => DEFAULT_PICKER_COUNT), 40 | topics: topicsSchema, 41 | keyword: keywordSchema, 42 | }); 43 | 44 | export const addTopicSchema = randomSentenceFormSchema.pick([ 45 | 'keyword', 46 | 'topics', 47 | ]); 48 | 49 | export type RandomSentenceFormValues = yup.InferType< 50 | typeof randomSentenceFormSchema 51 | >; 52 | -------------------------------------------------------------------------------- /src/features/syntax-editor/components/control-panel/control-panel.tsx: -------------------------------------------------------------------------------- 1 | import { Card, Divider, HStack, Skeleton, VStack } from '@chakra-ui/react'; 2 | 3 | import { useIsMounted } from '@/base'; 4 | import { 5 | AbbrInfoSwitch, 6 | DeleteButton, 7 | RedoButton, 8 | ResetButton, 9 | SaveButton, 10 | TagInfoSwitch, 11 | UndoButton, 12 | } from '@/features/syntax-editor'; 13 | 14 | /** 15 | * atomWithStorage를 사용했을 때. Jotai는 초기값을 로컬 스토리지 값을 기준으로함 16 | * 예를들어 atomWithStorage('user', false) 상태의 초기값을 false로 명시했지만, 17 | * 로컬 스토리지에 'user' 키 값이 있다면, user 상태를 로컬 스토리지 값으로 변경시킴. 18 | * user 상태가 true로 변하는 과정에서 토글이 자동으로 스위치 되는 현상 발생함 19 | * 이처럼 토클이 자동으로 스위치되는 현상을 방지하기 위해 마운트 후에 컴포넌트 표시 20 | * */ 21 | export default function ControlPanel() { 22 | const isMounted = useIsMounted(); 23 | 24 | return ( 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /src/features/misc/pages/home.tsx: -------------------------------------------------------------------------------- 1 | import { Stack } from '@chakra-ui/react'; 2 | 3 | import { Layout, LinkParticles } from '@/base'; 4 | import { 5 | AnalyzerShowcase, 6 | EditorShowcase, 7 | GeneratorShowcase, 8 | HeroShowcase, 9 | } from '@/features/misc/components'; 10 | 11 | enum ShowCaseID { 12 | HERO = 'hero-showcase', 13 | ANALYZER = 'analyzer-showcase', 14 | EDITOR = 'editor-showcase', 15 | GENERATOR = 'generator-showcase', 16 | } 17 | 18 | const getScrollHandler = (nextSectionId: ShowCaseID) => () => { 19 | const nextSection = document.querySelector(`#${nextSectionId}`); 20 | nextSection?.scrollIntoView({ behavior: 'smooth' }); 21 | }; 22 | 23 | const showCases = [ 24 | { id: ShowCaseID.HERO, Component: HeroShowcase }, 25 | { id: ShowCaseID.ANALYZER, Component: AnalyzerShowcase }, 26 | { id: ShowCaseID.EDITOR, Component: EditorShowcase }, 27 | { id: ShowCaseID.GENERATOR, Component: GeneratorShowcase }, 28 | ]; 29 | 30 | export default function Home() { 31 | return ( 32 | 43 | 44 | 45 | {showCases.map(({ id, Component }, i) => { 46 | const nextId = showCases[i + 1]?.id; 47 | const scrollHandler = nextId ? getScrollHandler(nextId) : undefined; 48 | return ; 49 | })} 50 | 51 | 52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /src/features/syntax-analyzer/components/random-sentence-form/random-sentence-form.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from 'react'; 2 | 3 | import { HStack, Input, Stack, StackProps } from '@chakra-ui/react'; 4 | import { FormProvider } from 'react-hook-form'; 5 | 6 | import { CenteredDivider } from '@/base'; 7 | import { 8 | AddTopicForm, 9 | GenerateButton, 10 | RandomSentenceInstructions, 11 | RandomSentenceList, 12 | SentenceCountPicker, 13 | TopicTagList, 14 | useRandomSentenceForm, 15 | } from '@/features/syntax-analyzer'; 16 | 17 | interface RandomSentenceFormProps extends StackProps { 18 | showInstructions?: boolean; 19 | } 20 | 21 | export default function RandomSentenceForm({ 22 | showInstructions = true, 23 | ...stackProps 24 | }: RandomSentenceFormProps) { 25 | const { methods, isFetching, data, generateSentences } = 26 | useRandomSentenceForm(); 27 | 28 | const { register, getValues } = methods; 29 | 30 | return ( 31 | 32 | 52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /src/features/syntax-analyzer/components/analysis-load-indicator.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | 3 | import { Heading, Stack, StackProps, Text } from '@chakra-ui/react'; 4 | import { Player } from '@lottiefiles/react-lottie-player'; 5 | 6 | import { loadingAnimation } from '@/assets/lottie'; 7 | import { LoadingTransition } from '@/features/syntax-analyzer'; 8 | 9 | interface AnalysisLoadingIndicatorProps extends StackProps { 10 | play: boolean; 11 | } 12 | 13 | export default function AnalysisLoadIndicator({ 14 | play, 15 | ...stackProps 16 | }: AnalysisLoadingIndicatorProps) { 17 | const playerRef = useRef(null); 18 | 19 | useEffect(() => { 20 | /** @see https://dev.to/franklin030601/how-to-use-lottie-animations-react-js-cn0 */ 21 | if (play) playerRef.current?.play(); 22 | else playerRef.current?.stop(); 23 | }, [play]); 24 | 25 | return ( 26 | 35 | 41 | 42 | 43 | 문장 분석 중 44 | 45 | 46 | 최대 47 | 55 | 1분 56 | 57 | 까지 소요될 수 있어요 58 | 59 | 60 | 61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /src/features/syntax-analyzer/hooks/use-random-sentence-form.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | import { useBoolean } from '@chakra-ui/react'; 4 | import { yupResolver } from '@hookform/resolvers/yup'; 5 | import { useForm } from 'react-hook-form'; 6 | 7 | import { 8 | randomSentenceFormSchema, 9 | RandomSentenceFormValues, 10 | REMAINING_COUNT_BASE_KEY, 11 | } from '@/features/syntax-analyzer'; 12 | import { useRandomSentenceQuery } from '@/features/syntax-analyzer/api'; 13 | 14 | /** 15 | * yup 스키마의 cast 기능을 이용해 기본값 설정 16 | * @see https://github.com/orgs/react-hook-form/discussions/1936 17 | * */ 18 | const defaultValues = randomSentenceFormSchema.cast({}); 19 | const { topics, sent_count } = defaultValues; 20 | const defaultParams = { topics, sent_count, timeStamp: Date.now() }; 21 | 22 | export const useRandomSentenceForm = () => { 23 | const [readyToFetch, setReadyToFetch] = useBoolean(); 24 | const [params, setParams] = useState(defaultParams); 25 | 26 | const methods = useForm({ 27 | defaultValues, 28 | resolver: yupResolver(randomSentenceFormSchema), 29 | reValidateMode: 'onSubmit', // 유효성 검사 시점 30 | }); 31 | 32 | const { data, isFetching } = useRandomSentenceQuery(params, { 33 | enabled: readyToFetch, 34 | gcTime: 0, // 비활성화된 쿼리의 캐시 데이터는 바로 삭제 35 | staleTime: Infinity, // 일회성 데이터를 수동으로 조회하므로 자동 refetch 방지 36 | meta: { invalidateQueries: REMAINING_COUNT_BASE_KEY }, 37 | }); 38 | 39 | const generateSentences = async () => { 40 | if (!readyToFetch) setReadyToFetch.on(); // 쿼리 활성화 41 | 42 | const [topics, sent_count] = methods.getValues(['topics', 'sent_count']); 43 | // timeStamp 값이 바뀌면서 이전 쿼리키가 비활성화 되고 캐시 데이터에서 삭제됨 (gcTime 0이므로) 44 | setParams({ topics, sent_count, timeStamp: Date.now() }); 45 | }; 46 | 47 | return { methods, data, isFetching, generateSentences }; 48 | }; 49 | -------------------------------------------------------------------------------- /src/base/hooks/use-local-storage.ts: -------------------------------------------------------------------------------- 1 | import { Dispatch, SetStateAction, useEffect, useState } from 'react'; 2 | 3 | const logError = (operation: string, key: string) => { 4 | console.error(`Error ${operation} data to localStorage for key "${key}".`); 5 | }; 6 | 7 | const isLocalStorageAvailable = (): boolean => { 8 | if (!window.localStorage) { 9 | console.error('LocalStorage is not accessible.'); 10 | return false; 11 | } 12 | return true; 13 | }; 14 | 15 | const safeSetItem = (key: string, value: T) => { 16 | if (!isLocalStorageAvailable()) return; 17 | 18 | try { 19 | window.localStorage.setItem(key, JSON.stringify(value)); 20 | } catch (error) { 21 | logError('setting', key); 22 | } 23 | }; 24 | 25 | const safeGetItem = (key: string, defaultValue: T) => { 26 | if (!isLocalStorageAvailable()) return defaultValue; 27 | 28 | try { 29 | const storedValue = window.localStorage.getItem(key); 30 | if (storedValue) return JSON.parse(storedValue) as T; 31 | } catch (error) { 32 | logError('getting', key); 33 | } 34 | 35 | return defaultValue; 36 | }; 37 | 38 | /** 39 | * A custom hook that allows storing and retrieving values in local storage. 40 | * 41 | * @param {string} key - The key under which the value will be stored in local storage. 42 | * @param {T} defaultValue - The default value to be returned if no value is found in local storage. 43 | * @return {[T, React.Dispatch>]} - A tuple containing the current value and a function to update the value. 44 | */ 45 | export const useLocalStorage = ( 46 | key: string, 47 | defaultValue: T, 48 | ): [T, Dispatch>] => { 49 | const [value, setValue] = useState(() => safeGetItem(key, defaultValue)); 50 | 51 | useEffect(() => { 52 | safeSetItem(key, value); 53 | }, [key, value]); 54 | 55 | return [value, setValue]; 56 | }; 57 | -------------------------------------------------------------------------------- /src/features/syntax-editor/hooks/use-segment-mouse-event.ts: -------------------------------------------------------------------------------- 1 | import { MouseEvent, useRef, useState } from 'react'; 2 | 3 | import { useColorMode } from '@chakra-ui/react'; 4 | import { useAtomValue } from 'jotai'; 5 | 6 | import { getNearestElementByClass } from '@/base'; 7 | import { 8 | ConstituentDataSet, 9 | DELETE_MODE_HOVER_COLOR_SCHEME, 10 | deleteModeAtom, 11 | } from '@/features/syntax-editor'; 12 | import { CONSTITUENT_CLASSES } from '@/features/syntax-editor/constants'; 13 | 14 | const { CONSTITUENT } = CONSTITUENT_CLASSES; 15 | 16 | export const useSegmentMouseEvent = () => { 17 | const hoverRef = useRef(null); 18 | const [targetInfo, setTargetInfo] = useState(null); 19 | const { colorMode } = useColorMode(); 20 | const isDeleteMode = useAtomValue(deleteModeAtom); 21 | 22 | const restoreOriginalColor = () => { 23 | if (hoverRef.current && isDeleteMode) { 24 | hoverRef.current.style.removeProperty('color'); 25 | hoverRef.current = null; 26 | setTargetInfo(null); 27 | } 28 | }; 29 | 30 | const swapColor = (element: HTMLElement | null) => { 31 | if (!element) return; 32 | 33 | hoverRef.current = element; 34 | hoverRef.current.style.color = DELETE_MODE_HOVER_COLOR_SCHEME(colorMode); 35 | 36 | const { constituentLabel, constituentId } = hoverRef.current.dataset; 37 | setTargetInfo({ constituentLabel, constituentId }); 38 | }; 39 | 40 | const onMouseOver = (event: MouseEvent) => { 41 | if (!isDeleteMode) return; 42 | const target = event.target as HTMLElement; 43 | 44 | if (target !== hoverRef.current) { 45 | restoreOriginalColor(); 46 | swapColor(getNearestElementByClass(target, CONSTITUENT)); 47 | } 48 | }; 49 | 50 | return { 51 | onMouseOver, 52 | onMouseLeave: restoreOriginalColor, 53 | targetInfo, 54 | }; 55 | }; 56 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 38 | 39 | Syntax Analyzer 40 | 41 | 42 | 43 |
44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /src/features/syntax-editor/store/control-panel-store.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'jotai'; 2 | import { atomWithReset, atomWithStorage, RESET } from 'jotai/utils'; 3 | 4 | import { 5 | ConstituentWithoutId, 6 | hasAddedTagAtom, 7 | } from '@/features/syntax-editor'; 8 | import { 9 | DEFAULT_ABBR_INFO_MODE, 10 | DEFAULT_TAG_INFO_MODE, 11 | } from '@/features/syntax-editor/constants'; 12 | 13 | export const selectedTagAtom = atomWithReset(null); 14 | export const hoveredConstituentAtom = atomWithReset(null); 15 | export const deleteModeAtom = atomWithReset(false); 16 | 17 | /** 로컬 스토리지에서 키 값을 먼저 찾고 없다면 두번째 인자에 명시한 초기값으로 설정 */ 18 | export const tagInfoModeAtom = atomWithStorage( 19 | 'tagInfoMode', 20 | DEFAULT_TAG_INFO_MODE, 21 | ); 22 | export const abbrInfoModeAtom = atomWithStorage( 23 | 'abbrInfoMode', 24 | DEFAULT_ABBR_INFO_MODE, 25 | ); 26 | 27 | export const resetControlPanelAtom = atom(null, (_get, set) => { 28 | set(selectedTagAtom, RESET); 29 | set(hoveredConstituentAtom, RESET); 30 | set(deleteModeAtom, RESET); 31 | }); 32 | 33 | export const isDisableDeleteButtonAtom = atom((get) => !get(hasAddedTagAtom)); 34 | 35 | export const toggleDeleteModeActionAtom = atom( 36 | (get) => get(deleteModeAtom), 37 | (get, set) => { 38 | const current = get(deleteModeAtom); 39 | set(deleteModeAtom, !current); 40 | set(selectedTagAtom, null); 41 | }, 42 | ); 43 | 44 | export const selectedTagActionAtom = atom( 45 | (get) => get(selectedTagAtom), 46 | (get, set, constituent: ConstituentWithoutId | null) => { 47 | const isDeleteMode = get(deleteModeAtom); 48 | if (isDeleteMode) set(deleteModeAtom, false); 49 | set(selectedTagAtom, constituent); 50 | }, 51 | ); 52 | 53 | export const isAbbrTooltipVisibleAtom = atom((get) => { 54 | const isAbbrInfoMode = get(abbrInfoModeAtom); 55 | const isDeleteMode = get(deleteModeAtom); 56 | return isAbbrInfoMode && !isDeleteMode; 57 | }); 58 | -------------------------------------------------------------------------------- /src/features/syntax-analyzer/components/random-sentence-form/random-sentence-list.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | import { 4 | Highlight, 5 | SlideFade, 6 | StackDivider, 7 | Text, 8 | useClipboard, 9 | useToast, 10 | VStack, 11 | } from '@chakra-ui/react'; 12 | import { useIsFetching } from '@tanstack/react-query'; 13 | 14 | import { RANDOM_SENTENCE_BASE_KEY } from '@/features/syntax-analyzer'; 15 | import { COPY_SENTENCE_SUCCESS_TOAST_DURATION } from '@/features/syntax-editor'; 16 | 17 | interface RandomSentenceListProps { 18 | data?: string[]; 19 | query: string[]; 20 | } 21 | 22 | export default function RandomSentenceList({ 23 | data, 24 | query, 25 | }: RandomSentenceListProps) { 26 | const toast = useToast(); 27 | const isFetching = useIsFetching({ queryKey: RANDOM_SENTENCE_BASE_KEY }); 28 | const { onCopy, setValue, hasCopied } = useClipboard('', 1000); 29 | 30 | useEffect(() => { 31 | if (hasCopied) { 32 | toast({ 33 | title: '클립보드에 복사되었습니다', 34 | status: 'success', 35 | duration: COPY_SENTENCE_SUCCESS_TOAST_DURATION, 36 | }); 37 | } 38 | }, [hasCopied, toast]); 39 | 40 | return ( 41 | 42 | } 47 | > 48 | {data?.map((sentence) => ( 49 | setValue(sentence)} 54 | onClick={onCopy} 55 | > 56 | 60 | {sentence} 61 | 62 | 63 | ))} 64 | 65 | 66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /src/base/components/modal/confirm-popover.tsx: -------------------------------------------------------------------------------- 1 | import { MouseEventHandler, ReactNode } from 'react'; 2 | 3 | import { 4 | Box, 5 | Button, 6 | ButtonGroup, 7 | Popover, 8 | PopoverArrow, 9 | PopoverContent, 10 | PopoverFooter, 11 | PopoverHeader, 12 | PopoverProps, 13 | PopoverTrigger, 14 | Portal, 15 | useDisclosure, 16 | } from '@chakra-ui/react'; 17 | 18 | import { VoidFunc } from '@/base'; 19 | 20 | type ChildrenProps = { onOpen: MouseEventHandler; isOpen: boolean }; 21 | interface ConfirmPopoverProps extends Omit { 22 | onConfirm: VoidFunc; 23 | headerText?: ReactNode; 24 | cancelText?: string; 25 | confirmText?: string; 26 | children: ({ onOpen }: ChildrenProps) => ReactNode; 27 | } 28 | 29 | export default function ConfirmPopover({ 30 | onConfirm, 31 | children, 32 | headerText, 33 | cancelText = '취소', 34 | confirmText = '확인', 35 | ...popoverProps 36 | }: ConfirmPopoverProps) { 37 | const { isOpen, onClose, onOpen } = useDisclosure(); 38 | const onConfirmButtonClick = () => { 39 | onConfirm(); 40 | onClose(); 41 | }; 42 | 43 | return ( 44 | 45 | 46 | {children({ onOpen, isOpen })} 47 | 48 | 49 | 50 | 51 | 52 | {headerText} 53 | 54 | 55 | 56 | 57 | 60 | 61 | 62 | 63 | 64 | 65 | ); 66 | } 67 | -------------------------------------------------------------------------------- /src/features/syntax-editor/components/syntax-parser/syntax-parser.tsx: -------------------------------------------------------------------------------- 1 | import { useRef } from 'react'; 2 | 3 | import { SlideFade } from '@chakra-ui/react'; 4 | import { TbMoodEmpty } from 'react-icons/tb'; 5 | 6 | import { TextPlaceholder, ThreeDotsWave, useTransitionLoading } from '@/base'; 7 | import { 8 | SegmentList, 9 | Sentence, 10 | TokenList, 11 | useCalculateNestingLevel, 12 | useSyntaxParserAnalysis, 13 | } from '@/features/syntax-editor'; 14 | import '@/features/syntax-editor/styles/constituent.scss'; 15 | 16 | /** 17 | * 데이터(초기값 null)를 받아오는 과정에서 TextPlaceholder 가 잠깐 보이는 문제 발생 18 | * 이를 해결하기 위해 isLoading 상태 변화를 지연시켜서 데이터를 완전히 불러오기 전까진 19 | * 스피너를 표시하고, 데이터를 모두 불러온 후 결과에 따라 플레이스 홀더 표시하도록 처리. 20 | * Transition 에 등록한 상태는 우선순위가 낮은 업데이트로 작동하고, 우선 순위가 낮은 21 | * 업데이트는 우선순위가 높은 업데이트에 의해 즉시 중단될 수 있음. useEffect 종속성 22 | * 배열에 데이터를 등록해서, 데이터가 변경될 때마다 이펙트 함수가 호출되고, 이때마다 23 | * 진행중이던 isLoading 상태 업데이트가 중단됨. 이런식으로 항상 isLoading 상태가 데이터를 24 | * 완전히 불러온 후에만 false 로 변경되도록 보장할 수 있음. 25 | * */ 26 | export default function SyntaxParser() { 27 | const sentenceRef = useRef(null); 28 | 29 | const { segment, sentence } = useSyntaxParserAnalysis(); 30 | const isLoading = useTransitionLoading([segment, sentence]); 31 | const isNestingLevelCalculated = useCalculateNestingLevel({ 32 | targetRef: sentenceRef, 33 | trigger: isLoading, 34 | }); 35 | 36 | if (isLoading) return ; 37 | 38 | if (!segment || !sentence) { 39 | return ( 40 | 47 | ); 48 | } 49 | 50 | return ( 51 | 52 | 53 | 57 | 58 | 59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /src/features/syntax-editor/store/analysis-store.ts: -------------------------------------------------------------------------------- 1 | import { atom, Getter, Setter } from 'jotai'; 2 | import { atomWithDefault, atomWithStorage } from 'jotai/utils'; 3 | 4 | import { debounce, Nullable } from '@/base'; 5 | import { 6 | AnalysisSource, 7 | CombinedAnalysisList, 8 | generateAnalysis, 9 | TAnalysis, 10 | } from '@/features/syntax-editor'; 11 | import { INVALID_POPUP_DELAY } from '@/features/syntax-editor/constants'; 12 | import { SAMPLE_ANALYSIS } from '@/features/syntax-editor/data'; 13 | 14 | export const userAnalysisListAtom = atomWithStorage( 15 | 'userAnalysisList', 16 | [], 17 | ); 18 | 19 | export const sampleAnalysisListAtom = atom(SAMPLE_ANALYSIS); 20 | 21 | export const analysisListBySourceAtom = atomWithDefault( 22 | (get) => ({ 23 | user: get(userAnalysisListAtom), 24 | sample: get(sampleAnalysisListAtom), 25 | }), 26 | ); 27 | 28 | export const selectedAnalysisAtom = atom>(null); 29 | 30 | export const addUserAnalysisActionAtom = atom( 31 | null, 32 | (_get, set, payload: { sentence: string; source: AnalysisSource }) => { 33 | const { sentence, source } = payload; 34 | const analysis = generateAnalysis(sentence, source); 35 | set(userAnalysisListAtom, (prev) => [analysis, ...prev]); 36 | }, 37 | ); 38 | 39 | export const removeUserAnalysisActionAtom = atom( 40 | null, 41 | (_, set, sentenceId: string) => { 42 | set(userAnalysisListAtom, (prev) => 43 | prev.filter((analysis) => analysis.id !== sentenceId), 44 | ); 45 | }, 46 | ); 47 | 48 | export const invalidRangeIndexAtom = atom>(null); 49 | 50 | const debouncedClearInvalidRange = debounce((_get: Getter, set: Setter) => { 51 | set(invalidRangeIndexAtom, null); 52 | }, INVALID_POPUP_DELAY); 53 | 54 | export const setAndClearInvalidRangeIndexAtom = atom( 55 | null, 56 | (_get, set, invalidIndex: number) => { 57 | set(invalidRangeIndexAtom, invalidIndex); 58 | debouncedClearInvalidRange(_get, set); 59 | }, 60 | ); 61 | -------------------------------------------------------------------------------- /src/features/syntax-editor/components/sentence-manager/deletable-sentence.tsx: -------------------------------------------------------------------------------- 1 | import { Badge, HStack, StackProps, Text, VStack } from '@chakra-ui/react'; 2 | 3 | import { 4 | ConfirmPopover, 5 | DateChip, 6 | DeleteButtonIcon, 7 | isLessThanAgo, 8 | tokenJoiner, 9 | } from '@/base'; 10 | import { NEW_BADGE_DISPLAY_DURATION } from '@/features/syntax-editor'; 11 | 12 | interface DeletableSentenceProps extends StackProps { 13 | onClick: () => void; 14 | onDelete: () => void; 15 | hideDeleteButton?: boolean; 16 | sentence: string[] | string; 17 | showGPTBadge: boolean; 18 | createdAt: string; 19 | } 20 | 21 | export default function DeletableSentence({ 22 | onClick, 23 | sentence, 24 | createdAt, 25 | onDelete, 26 | showGPTBadge, 27 | hideDeleteButton = false, 28 | ...stackProps 29 | }: DeletableSentenceProps) { 30 | return ( 31 | 32 | 33 | 34 | 35 | {isLessThanAgo(createdAt, NEW_BADGE_DISPLAY_DURATION) && ( 36 | 37 | New 38 | 39 | )} 40 | {showGPTBadge && ( 41 | 42 | GPT 43 | 44 | )} 45 | 46 | 51 | {({ onOpen }) => ( 52 | 55 | 56 | 62 | {typeof sentence === 'string' ? sentence : tokenJoiner(sentence)} 63 | 64 | 65 | ); 66 | } 67 | -------------------------------------------------------------------------------- /src/features/syntax-editor/components/control-panel/save-button.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | import { IconButton, Tooltip, useToast } from '@chakra-ui/react'; 4 | import { useAtomValue, useSetAtom } from 'jotai'; 5 | import { IoSaveSharp } from 'react-icons/io5'; 6 | import { useParams } from 'react-router-dom'; 7 | 8 | import { ConfirmPopover } from '@/base'; 9 | import { 10 | AnalysisPathParams, 11 | CONTROL_OPEN_POPUP_DELAY, 12 | isSegmentTouchedAtom, 13 | SAVE_SEGMENT_DELAY, 14 | SAVE_SEGMENT_SUCCESS_TOAST_DURATION, 15 | saveHistorySegmentAtom, 16 | } from '@/features/syntax-editor'; 17 | 18 | export default function SaveButton() { 19 | const toast = useToast(); 20 | const { source, index } = useParams(); 21 | 22 | const isTouched = useAtomValue(isSegmentTouchedAtom); 23 | const saveHistorySegment = useSetAtom(saveHistorySegmentAtom); 24 | 25 | const [isLoading, setIsLoading] = useState(false); 26 | 27 | const onSave = () => { 28 | if (!source || !index) return; 29 | 30 | setIsLoading(true); 31 | saveHistorySegment({ source, index }); 32 | setTimeout(() => { 33 | setIsLoading(false); 34 | toast({ 35 | title: '저장 성공', 36 | status: 'success', 37 | duration: SAVE_SEGMENT_SUCCESS_TOAST_DURATION, 38 | }); 39 | }, SAVE_SEGMENT_DELAY); 40 | }; 41 | 42 | return ( 43 | 47 | {({ onOpen, isOpen }) => ( 48 | 54 | } 58 | onClick={onOpen} 59 | isDisabled={!isTouched} 60 | isLoading={isLoading} 61 | /> 62 | 63 | )} 64 | 65 | ); 66 | } 67 | -------------------------------------------------------------------------------- /src/base/components/ui/three-dots-wave.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | import { Box, BoxProps, HStack } from '@chakra-ui/react'; 4 | import { motion, Transition, Variants } from 'framer-motion'; 5 | 6 | const DotGroup = motion.create(HStack); 7 | const Dot = motion.create(Box); 8 | 9 | const dotVariants: Variants = { 10 | initial: { y: '0%' }, 11 | animate: { y: '100%' }, 12 | }; 13 | 14 | const dotTransition: Transition = { 15 | duration: 0.5, 16 | repeat: Infinity, 17 | repeatType: 'reverse', 18 | ease: 'easeInOut', 19 | }; 20 | 21 | const containerVariants = { 22 | initial: { transition: { staggerChildren: 0.2 } }, 23 | animate: { transition: { staggerChildren: 0.2 } }, 24 | }; 25 | 26 | const DOT_COUNT = 3; 27 | 28 | interface ThreeDotsLoadingProps { 29 | size?: number; 30 | color?: BoxProps['color']; 31 | gap?: number; 32 | delay?: number; 33 | } 34 | 35 | /** 36 | * {@link https://codesandbox.io/s/loading-animation-with-framer-motion-cfk8n Implement Reference} 37 | * */ 38 | export const ThreeDotsWave = ({ 39 | size = 4, 40 | color = 'teal.200', 41 | delay, 42 | gap, 43 | }: ThreeDotsLoadingProps) => { 44 | const [visible, setVisible] = useState(false); 45 | 46 | useEffect(() => { 47 | if (!delay) return undefined; 48 | 49 | const timer = setTimeout(() => setVisible(true), delay); 50 | return () => clearTimeout(timer); 51 | }, [delay]); 52 | 53 | if (!visible && delay) return null; 54 | 55 | return ( 56 | 63 | {Array.from({ length: DOT_COUNT }).map((_, i) => ( 64 | 73 | ))} 74 | 75 | ); 76 | }; 77 | 78 | export default ThreeDotsWave; 79 | -------------------------------------------------------------------------------- /src/features/misc/components/showcase/showcase-template.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent } from 'react'; 2 | 3 | import { 4 | Flex, 5 | FlexProps, 6 | Heading, 7 | HStack, 8 | Link, 9 | Stack, 10 | Text, 11 | } from '@chakra-ui/react'; 12 | import { NavLink } from 'react-router-dom'; 13 | 14 | import { LazyImage, LazyImageProps } from '@/base'; 15 | import { ScrollDownButton } from '@/features/misc'; 16 | 17 | export interface ShowcaseTemplateProps extends FlexProps { 18 | imageProps: LazyImageProps; 19 | title: string; 20 | description: string; 21 | linkUrl: string; 22 | imageFirst?: boolean; 23 | onScrollDown?: () => void; 24 | } 25 | 26 | export const ShowcaseTemplate = ({ 27 | imageProps, 28 | title, 29 | description, 30 | linkUrl, 31 | onScrollDown, 32 | imageFirst = true, 33 | ...flexProps 34 | }: ShowcaseTemplateProps) => { 35 | const Contents: FunctionComponent[] = []; 36 | 37 | const Description = () => ( 38 | 39 | 44 | {title} 45 | 46 | 47 | {description} 48 | 49 | Try it now 50 | 51 | 52 | 53 | ); 54 | 55 | const Image = () => ; 56 | 57 | if (imageFirst) Contents.push(Image, Description); 58 | else Contents.push(Description, Image); 59 | 60 | return ( 61 | 69 | 70 | {Contents.map((Content, i) => ( 71 | 72 | ))} 73 | 74 | 76 | ); 77 | }; 78 | -------------------------------------------------------------------------------- /src/features/syntax-editor/components/tag-list-accordion/tag-list-accordion.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Accordion, 3 | AccordionButton, 4 | AccordionIcon, 5 | AccordionItem, 6 | AccordionPanel, 7 | type AccordionProps, 8 | Heading, 9 | HStack, 10 | Text, 11 | Wrap, 12 | WrapItem, 13 | } from '@chakra-ui/react'; 14 | 15 | import { 16 | SelectableTagButton, 17 | TAG_LIST_DEFAULT_INDEX, 18 | } from '@/features/syntax-editor'; 19 | import { groupedConstituentsByType } from '@/features/syntax-editor/data'; 20 | 21 | const CONSTITUENT_CATEGORIES = [ 22 | { 23 | label: 'general', 24 | desc: '주어/동사/목적어 등', 25 | constituents: groupedConstituentsByType.token, 26 | }, 27 | { 28 | label: 'phrase', 29 | desc: '전치사구/동명사구 등', 30 | constituents: groupedConstituentsByType.phrase, 31 | }, 32 | { 33 | label: 'clause', 34 | desc: '독립절/종속절 등', 35 | constituents: groupedConstituentsByType.clause, 36 | }, 37 | ]; 38 | 39 | export default function TagListAccordion({ 40 | ...accordionProps 41 | }: AccordionProps) { 42 | return ( 43 | 48 | {CONSTITUENT_CATEGORIES.map((category) => ( 49 | 50 | 51 | 52 | 53 | {category.label} 54 | 55 | {category.desc} 56 | 57 | 58 | 59 | 60 | 61 | 62 | {category.constituents.map((constituent) => ( 63 | 64 | 65 | 66 | ))} 67 | 68 | 69 | 70 | ))} 71 | 72 | ); 73 | } 74 | -------------------------------------------------------------------------------- /src/features/syntax-editor/components/sentence-manager/add-sentence-form.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | BoxProps, 4 | Button, 5 | FormControl, 6 | HStack, 7 | useDisclosure, 8 | } from '@chakra-ui/react'; 9 | import { yupResolver } from '@hookform/resolvers/yup'; 10 | import { useSetAtom } from 'jotai'; 11 | import { useForm } from 'react-hook-form'; 12 | 13 | import { ConfirmModal, VoidFunc } from '@/base'; 14 | import { 15 | addSentenceFormSchema, 16 | SentenceInput, 17 | } from '@/features/syntax-analyzer'; 18 | import { addUserAnalysisActionAtom } from '@/features/syntax-editor'; 19 | 20 | const defaultValues = addSentenceFormSchema.cast({}); 21 | const resolver = yupResolver(addSentenceFormSchema); 22 | const formProps = { resolver, defaultValues }; 23 | 24 | interface AddSentenceProps extends BoxProps { 25 | onConfirmEffect?: VoidFunc; 26 | } 27 | 28 | export default function AddSentenceForm({ 29 | onConfirmEffect, 30 | ...boxProps 31 | }: AddSentenceProps) { 32 | const { register, handleSubmit, getValues, reset, formState } = 33 | useForm(formProps); 34 | 35 | const { isOpen, onOpen, onClose } = useDisclosure(); 36 | const addAnalysis = useSetAtom(addUserAnalysisActionAtom); 37 | 38 | const onConfirm = () => { 39 | addAnalysis({ sentence: getValues('sentence'), source: 'user' }); 40 | onConfirmEffect?.(); 41 | onClose(); 42 | reset(); 43 | }; 44 | 45 | const { errors, isSubmitting } = formState; 46 | 47 | return ( 48 | 49 | 50 | 51 | 55 | 58 | 59 | 60 | 67 | 68 | ); 69 | } 70 | -------------------------------------------------------------------------------- /src/features/syntax-editor/hooks/use-sentence-handler.ts: -------------------------------------------------------------------------------- 1 | import { MouseEvent } from 'react'; 2 | 3 | import { useAtom, useAtomValue, useSetAtom } from 'jotai'; 4 | 5 | import { clearSelection, MouseEventHandlers } from '@/base'; 6 | import { 7 | addConstituent, 8 | deleteModeAtom, 9 | generateConstituent, 10 | removeConstituent, 11 | selectedTagAtom, 12 | setAndClearInvalidRangeIndexAtom, 13 | updateSegmentHistoryAndIndexAtom, 14 | useSegmentMouseEvent, 15 | validateSelectionBounds, 16 | } from '@/features/syntax-editor'; 17 | 18 | /** event.detail 속성은 마우스 클릭 횟수 (더블클릭시 2) */ 19 | const isDoubleClicked = (e: MouseEvent) => e.detail > 1; 20 | 21 | export const useSentenceHandler = (): MouseEventHandlers => { 22 | const { onMouseOver, onMouseLeave, targetInfo } = useSegmentMouseEvent(); 23 | 24 | const isDeleteMode = useAtomValue(deleteModeAtom); 25 | const selectedTag = useAtomValue(selectedTagAtom); 26 | 27 | const setAndClearInvalidIndex = useSetAtom(setAndClearInvalidRangeIndexAtom); 28 | 29 | const [segment, updateSegment] = useAtom(updateSegmentHistoryAndIndexAtom); 30 | 31 | /** 문장 성분 추가 */ 32 | const onMouseUp = (e: MouseEvent) => { 33 | /** 더블 클릭이 아니고, 태그를 선택했을 때만 실행 */ 34 | if (selectedTag && segment && !isDoubleClicked(e)) { 35 | const { begin, end, isValid } = validateSelectionBounds(); 36 | if (!isValid) { 37 | setAndClearInvalidIndex(end - 1); 38 | clearSelection(); 39 | return; 40 | } 41 | 42 | const constituent = generateConstituent(selectedTag, begin, end); 43 | const updatedSegment = addConstituent(segment, begin, end, constituent); 44 | 45 | updateSegment(updatedSegment); 46 | } 47 | }; 48 | 49 | /** 문장 요소 삭제 */ 50 | const onClick = () => { 51 | /** 삭제 모드이고, 드래그해서 선택한 토큰 정보가 있을 때만 실행 */ 52 | if (isDeleteMode && targetInfo && segment) { 53 | const constituentId = Number(targetInfo.constituentId); 54 | const updatedSegment = removeConstituent(segment, constituentId); 55 | 56 | updateSegment(updatedSegment); 57 | } 58 | }; 59 | 60 | const onMouseDown = (e: MouseEvent) => { 61 | /** 더블 클릭시 텍스트 전체 선택 방지 */ 62 | if (isDoubleClicked(e)) e.preventDefault(); 63 | }; 64 | 65 | return { onClick, onMouseOver, onMouseLeave, onMouseUp, onMouseDown }; 66 | }; 67 | -------------------------------------------------------------------------------- /src/lib/react-query.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from 'react'; 2 | 3 | import { useToast } from '@chakra-ui/react'; 4 | import { 5 | DefaultOptions, 6 | MutationCache, 7 | QueryCache, 8 | QueryClient, 9 | QueryClientProvider, 10 | QueryKey, 11 | } from '@tanstack/react-query'; 12 | 13 | /** 14 | * UseQueryOptions.meta 속성 타입 지정 (queryFn의 meta 파라미터 타입) 15 | * {@link https://tanstack.com/query/latest/docs/framework/react/typescript#typing-meta 공식 문서} 16 | * */ 17 | interface CustomMeta extends Record { 18 | invalidateQueries?: QueryKey; 19 | } 20 | 21 | declare module '@tanstack/react-query' { 22 | interface Register { 23 | queryMeta: CustomMeta; 24 | mutationMeta: CustomMeta; 25 | } 26 | } 27 | 28 | const defaultOptions: DefaultOptions = { 29 | queries: { 30 | refetchOnWindowFocus: false, 31 | staleTime: 1000 * 30, // 30 seconds 32 | retry: 1, 33 | }, 34 | }; 35 | 36 | export const ConfiguredQueryProvider = ({ children }: PropsWithChildren) => { 37 | const toast = useToast(); 38 | 39 | const showErrorToast = () => { 40 | toast({ 41 | title: '에러가 발생했어요, 잠시 후 다시 시도해주세요', 42 | status: 'error', 43 | }); 44 | }; 45 | 46 | const queryClient = new QueryClient({ 47 | defaultOptions, 48 | mutationCache: new MutationCache({ 49 | onError: showErrorToast, 50 | onSuccess: (_data, _variables, _context, mutation) => { 51 | const { invalidateQueries = [] } = mutation.meta ?? {}; 52 | if (invalidateQueries.length === 0) return; 53 | queryClient.invalidateQueries({ queryKey: invalidateQueries }); 54 | }, 55 | }), 56 | queryCache: new QueryCache({ 57 | onError: (error, query) => { 58 | /** 59 | * 백그라운드 업데이트 실패시에만 Toast 표시, 나머진 ErrorBoundary 에서 처리 60 | * @see https://tkdodo.eu/blog/react-query-error-handling 61 | * */ 62 | if (query.state.data !== undefined) showErrorToast(); 63 | }, 64 | onSuccess: (_data, query) => { 65 | const { invalidateQueries = [] } = query.meta ?? {}; 66 | if (invalidateQueries.length === 0) return; 67 | queryClient.invalidateQueries({ queryKey: invalidateQueries }); 68 | }, 69 | }), 70 | }); 71 | 72 | return ( 73 | {children} 74 | ); 75 | }; 76 | -------------------------------------------------------------------------------- /src/features/syntax-analyzer/components/analysis-form/model-choice-group.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Badge, 3 | HStack, 4 | Radio, 5 | RadioGroup, 6 | Stack, 7 | Text, 8 | } from '@chakra-ui/react'; 9 | import { Control, Controller } from 'react-hook-form'; 10 | 11 | import { 12 | ANALYSIS_DECREMENT_COUNT, 13 | AnalysisFormValues, 14 | AnalysisModel, 15 | } from '@/features/syntax-analyzer'; 16 | 17 | const MODEL_FIELDS = [ 18 | { 19 | value: AnalysisModel.GPT_4O_MINI_FT, 20 | label: 'GPT-4o-mini', 21 | desc: '대부분의 문장을 잘 분석해요', 22 | count: ANALYSIS_DECREMENT_COUNT[AnalysisModel.GPT_4O_MINI_FT], 23 | recommend: true, 24 | }, 25 | { 26 | value: AnalysisModel.GPT_4O_FT, 27 | label: 'GPT-4o', 28 | desc: '정확도가 조금 더 높아요', 29 | count: ANALYSIS_DECREMENT_COUNT[AnalysisModel.GPT_4O_FT], 30 | recommend: false, 31 | }, 32 | ]; 33 | 34 | interface ModelChoiceGroupProps { 35 | control: Control; 36 | remainingCount: number; 37 | } 38 | 39 | export default function ModelChoiceGroup({ 40 | control, 41 | remainingCount, 42 | }: ModelChoiceGroupProps) { 43 | return ( 44 | ( 48 | 55 | {MODEL_FIELDS.map((field) => ( 56 | 57 | 58 | 63 | {field.label} 64 | 65 | 73 | 74 | 75 | {field.desc} 76 | 77 | 78 | ))} 79 | 80 | )} 81 | /> 82 | ); 83 | } 84 | -------------------------------------------------------------------------------- /src/features/syntax-editor/components/syntax-parser/constituent.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from 'react'; 2 | 3 | import { Text, Tooltip, useColorModeValue } from '@chakra-ui/react'; 4 | import { clsx } from 'clsx'; 5 | import { useAtomValue } from 'jotai'; 6 | 7 | import { NumberTuple } from '@/base'; 8 | import { 9 | CONSTITUENT_CLASSES, 10 | CONSTITUENT_COLORS, 11 | CONSTITUENT_DATA_ATTRS, 12 | CONSTITUENT_TRANSLATIONS, 13 | hoveredConstituentAtom, 14 | isAbbrTooltipVisibleAtom, 15 | type TConstituent, 16 | useConstituentHover, 17 | } from '@/features/syntax-editor'; 18 | 19 | interface ConstituentProps { 20 | constituent: TConstituent; 21 | isMultipleTokenRange: boolean; 22 | begin: number; 23 | end: number; 24 | } 25 | 26 | export default function Constituent({ 27 | children, 28 | constituent, 29 | begin, 30 | end, 31 | isMultipleTokenRange, 32 | }: PropsWithChildren) { 33 | const { dark, light } = CONSTITUENT_COLORS[constituent.type]; 34 | const textColor = useColorModeValue(light, dark); 35 | 36 | const isAbbrTooltipVisible = useAtomValue(isAbbrTooltipVisibleAtom); 37 | const hoveredConstituent = useAtomValue(hoveredConstituentAtom); 38 | const handlers = useConstituentHover(); 39 | 40 | const tooltipOffset: NumberTuple = isMultipleTokenRange ? [0, -10] : [0, 5]; 41 | const koLabel = CONSTITUENT_TRANSLATIONS[constituent.label]?.ko; 42 | const isCurrentHovered = hoveredConstituent === constituent.id; 43 | 44 | const dataAttrs = { 45 | [CONSTITUENT_DATA_ATTRS.ID]: constituent.id, 46 | [CONSTITUENT_DATA_ATTRS.LABEL]: constituent.label, 47 | [CONSTITUENT_DATA_ATTRS.ABBR]: constituent.abbreviation, 48 | [CONSTITUENT_DATA_ATTRS.BEGIN]: begin, 49 | [CONSTITUENT_DATA_ATTRS.END]: end, 50 | }; 51 | 52 | return ( 53 | 59 | 69 | {children} 70 | 71 | 72 | ); 73 | } 74 | -------------------------------------------------------------------------------- /src/features/syntax-editor/styles/constituent.scss: -------------------------------------------------------------------------------- 1 | $max-styling-count: 30; // 스타일을 계산할 최대 문장 성분 개수 2 | 3 | // 절&구 4 | $constituent-margin: 0.15rem; // 문장 성분 좌우 여백 5 | $constituent-label-y-pos: 1.4rem; // 문장 성분 라벨의 y축 위치 6 | $constituent-height: 2.2rem; // 문장 성분 전체 영역 높이 7 | $constituent-initial-pos-factor: 0.48; // 첫번째 문장 성분과 글자 사이 간격 (낮을수록 간격 벌어짐) 8 | $constituent-dashed-border: 2px dashed; 9 | $constituent-dashed-border-start: 10px; // 점선 상단 길이 (글자 상단을 기준으로 높을수록 짧아짐) 10 | 11 | // 토큰 12 | $token-spacing: 2rem; // 토큰과 토큰 사이의 간격 13 | $token-top: 2.5rem; // 토큰 상단 여백 14 | $token-initial-pos-factor: 1; // 첫번째 토큰과 글자 사이 간격 (낮을수록 간격 벌어짐) 15 | 16 | @for $i from 1 through $max-styling-count { 17 | $offset: $constituent-height * ($i - $constituent-initial-pos-factor); 18 | 19 | .constituent.token-group[data-token-group-lv='#{$i}'] { 20 | margin: 0 $constituent-margin; 21 | padding: 0 $constituent-margin $offset; 22 | 23 | // 문장 성분 라벨 24 | &:after { 25 | top: $offset + $constituent-label-y-pos; 26 | } 27 | 28 | // 좌/우/하단 점선 29 | &:before { 30 | content: ''; 31 | position: absolute; 32 | top: $constituent-dashed-border-start; 33 | bottom: 0; 34 | right: 0; 35 | left: 0; 36 | border-left: $constituent-dashed-border; 37 | border-right: $constituent-dashed-border; 38 | border-bottom: $constituent-dashed-border; 39 | } 40 | } 41 | } 42 | 43 | @for $i from 1 through $max-styling-count { 44 | $offset: $token-spacing * ($i - $token-initial-pos-factor); 45 | 46 | .constituent.token[data-token-lv='#{$i}'] { 47 | // 문장 성분 라벨 48 | &:after { 49 | top: $token-top + $offset; 50 | } 51 | } 52 | } 53 | 54 | html[data-theme='light'] { 55 | .constituent:after { 56 | background-color: var(--chakra-colors-gray-100); 57 | } 58 | } 59 | 60 | html[data-theme='dark'] { 61 | .constituent:after { 62 | background-color: var(--chakra-colors-gray-900); 63 | } 64 | } 65 | 66 | .constituent { 67 | position: relative; 68 | 69 | // 문장 성분 라벨 70 | &:after { 71 | position: absolute; 72 | width: fit-content; 73 | left: 50%; 74 | padding: 0 0.5rem 0.15rem 0.5rem; 75 | transform: translateX(-50%); 76 | border-radius: 0.5rem; 77 | 78 | font-size: 1rem; 79 | font-style: italic; 80 | content: attr(data-constituent-abbr); 81 | 82 | text-overflow: ellipsis; 83 | overflow: hidden; 84 | white-space: nowrap; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/features/misc/pages/error-component.tsx: -------------------------------------------------------------------------------- 1 | import { useRef } from 'react'; 2 | 3 | import { 4 | Button, 5 | Heading, 6 | Highlight, 7 | ScaleFade, 8 | SystemStyleObject, 9 | Text, 10 | useBoolean, 11 | VStack, 12 | } from '@chakra-ui/react'; 13 | import { FallbackProps } from 'react-error-boundary'; 14 | import { 15 | isRouteErrorResponse, 16 | Navigate, 17 | useRouteError, 18 | } from 'react-router-dom'; 19 | 20 | import { DIGITS_PATTERN, Layout, LinkParticles, useIsMounted } from '@/base'; 21 | 22 | const highlightStyles: SystemStyleObject = { 23 | p: '1', 24 | bg: 'teal.100', 25 | borderRadius: 'sm', 26 | fontWeight: 'bold', 27 | }; 28 | 29 | export default function ErrorComponent({ 30 | resetErrorBoundary, 31 | error, 32 | }: Partial) { 33 | const routerError = useRouteError() as Error; 34 | const [shouldRedirect, setShouldRedirect] = useBoolean(false); 35 | 36 | const isMounted = useIsMounted(); 37 | const errorMessage = useRef(error?.message ?? '문제가 발생했어요'); 38 | 39 | const isRouteError = isRouteErrorResponse(routerError); 40 | if (isRouteError) { 41 | const { status, statusText } = routerError; 42 | errorMessage.current = `${status} | ${statusText}`; 43 | } 44 | 45 | const onActionButtonClick = isRouteError 46 | ? () => setShouldRedirect.on() 47 | : resetErrorBoundary; 48 | 49 | const buttonText = isRouteError ? '돌아가기' : '다시 시도하기'; 50 | 51 | if (shouldRedirect) return ; 52 | 53 | return ( 54 | 62 | 63 | 64 | 65 | Ooops! 66 | 67 | 68 | 69 | 70 | 71 | 75 | {errorMessage.current} 76 | 77 | 78 | 81 | 82 | 83 | 84 | ); 85 | } 86 | -------------------------------------------------------------------------------- /src/features/syntax-analyzer/components/analysis-form/analysis-counter.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from 'react'; 2 | 3 | import { 4 | Box, 5 | CircularProgress, 6 | CircularProgressLabel, 7 | HStack, 8 | Skeleton, 9 | SkeletonCircle, 10 | Stack, 11 | type StackProps, 12 | Text, 13 | } from '@chakra-ui/react'; 14 | 15 | import { CenteredDivider } from '@/base'; 16 | import { 17 | ANALYSIS_DECREMENT_COUNT, 18 | AnalysisModel, 19 | DAILY_ANALYSIS_LIMIT, 20 | useRemainingCountQuery, 21 | } from '@/features/syntax-analyzer'; 22 | 23 | export default function AnalysisCounter({ ...stackProps }: StackProps) { 24 | const { data: count } = useRemainingCountQuery({ 25 | select: ({ analysis }) => analysis, 26 | }); 27 | 28 | const countTitle = `남은 분석 횟수 ${count}회`; 29 | const limitDesc = `하루 최대 ${DAILY_ANALYSIS_LIMIT}회까지 분석할 수 있어요 (GPT-4o 모델은 요청당 ${ANALYSIS_DECREMENT_COUNT[AnalysisModel.GPT_4O_FT]}회 차감)`; 30 | 31 | return ( 32 | 33 | 39 | 40 | {remainingCountInPercent(count) + '%'} 41 | 42 | 43 | 44 | 45 | {countTitle} 46 | {limitDesc} 47 | 48 | 49 | ); 50 | } 51 | 52 | const AnalysisCounterBox = ({ 53 | children, 54 | ...stackProps 55 | }: PropsWithChildren) => { 56 | return ( 57 | 65 | {children} 66 | 67 | ); 68 | }; 69 | 70 | const AnalysisCounterSkeleton = (stackProps: StackProps) => { 71 | return ( 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | ); 80 | }; 81 | 82 | const remainingCountInPercent = (count?: number) => { 83 | if (!count) return 0; 84 | return Math.round((100 / DAILY_ANALYSIS_LIMIT) * count); 85 | }; 86 | 87 | AnalysisCounter.Skeleton = AnalysisCounterSkeleton; 88 | -------------------------------------------------------------------------------- /src/base/utils/selection.ts: -------------------------------------------------------------------------------- 1 | import { Nullable } from '@/base'; 2 | 3 | interface SelectionIndicesResult { 4 | begin: number; 5 | end: number; 6 | startNode: Nullable; 7 | endNode: Nullable; 8 | } 9 | 10 | /** 11 | * Retrieves the selection indices and nodes within a specified qualified name. 12 | * 13 | * @param {string} qualifiedName - The qualified name to search for in the HTML elements. 14 | * @return {SelectionIndicesResult} - An object containing the beginning and ending indices of the selection, as well as the start and end nodes. 15 | */ 16 | export const getSelectionIndices = ( 17 | qualifiedName: string, 18 | ): SelectionIndicesResult => { 19 | let begin = 0; 20 | let end = 0; 21 | 22 | const sel = window.getSelection(); 23 | if (!sel?.rangeCount) { 24 | return { begin: 0, end: 0, startNode: null, endNode: null }; 25 | } 26 | 27 | let startNode = sel.getRangeAt(0).startContainer as Node; 28 | let endNode = sel.getRangeAt(0).endContainer as Node; 29 | 30 | if (startNode.nodeType === Node.TEXT_NODE) startNode = startNode.parentNode!; 31 | if (endNode.nodeType === Node.TEXT_NODE) endNode = endNode.parentNode!; 32 | 33 | const startElement = startNode as HTMLElement; 34 | const endElement = endNode as HTMLElement; 35 | 36 | const startIndex = startElement.getAttribute(qualifiedName); 37 | const endIndex = endElement.getAttribute(qualifiedName); 38 | 39 | begin = startIndex ? Number(startIndex) : 0; 40 | end = endIndex ? Number(endIndex) + 1 : 0; 41 | 42 | return { begin, end, startNode: startElement, endNode: endElement }; 43 | }; 44 | 45 | /** 46 | * Clears the current selection in the window. 47 | * 48 | * @return {void} No return value. 49 | */ 50 | export const clearSelection = (): void => { 51 | const selection = window.getSelection(); 52 | selection?.removeAllRanges(); 53 | }; 54 | 55 | /** 56 | * Finds and returns the nearest element with the specified class name. 57 | * 58 | * @param {Nullable} elementParam - The starting element to search from. 59 | * @param {string} className - The class name to search for. 60 | * @return {Nullable} - The nearest element with the specified class name, or null if not found. 61 | */ 62 | export const getNearestElementByClass = ( 63 | elementParam: Nullable, 64 | className: string, 65 | ): Nullable => { 66 | let element = elementParam; 67 | while (element) { 68 | if (element.classList.contains(className)) return element; 69 | element = element.parentElement; 70 | } 71 | return null; 72 | }; 73 | -------------------------------------------------------------------------------- /src/features/syntax-analyzer/components/analysis-form/sentence-input.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef } from 'react'; 2 | 3 | import { 4 | Box, 5 | FormErrorMessage, 6 | FormHelperText, 7 | Input, 8 | InputGroup, 9 | InputLeftElement, 10 | InputProps, 11 | List, 12 | ListIcon, 13 | ListItem, 14 | } from '@chakra-ui/react'; 15 | import { useAutoAnimate } from '@formkit/auto-animate/react'; 16 | import { BsMagic } from 'react-icons/bs'; 17 | import { PiNotePencil, PiTextTBold } from 'react-icons/pi'; 18 | import { RiEnglishInput } from 'react-icons/ri'; 19 | import { TbArrowAutofitWidth } from 'react-icons/tb'; 20 | 21 | import { MAX_SENTENCE_LENGTH } from '@/features/syntax-analyzer/constants'; 22 | import { HELPER_MESSAGES } from '@/features/syntax-analyzer/schemes'; 23 | 24 | interface SentenceInputProps extends InputProps { 25 | errorMessage?: string; 26 | helperMessage?: string; 27 | showHelperText?: boolean; 28 | } 29 | 30 | const SentenceInput = forwardRef( 31 | function SentenceInput( 32 | { errorMessage, size = 'lg', showHelperText = false, ...inputProps }, 33 | ref, 34 | ) { 35 | const [parent] = useAutoAnimate({ duration: 180 }); 36 | 37 | return ( 38 | 39 | 40 | 41 | 42 | 43 | 51 | 52 | {errorMessage} 53 | 63 | 64 | ); 65 | }, 66 | ); 67 | 68 | const HELPER_TEXTS = [ 69 | { 70 | icon: BsMagic, 71 | text: `축약 표현은 자동으로 풀어져요 (I'll → I will)`, 72 | }, 73 | { 74 | icon: RiEnglishInput, 75 | text: HELPER_MESSAGES.ENGLISH_OR_SYMBOL, 76 | }, 77 | { 78 | icon: TbArrowAutofitWidth, 79 | text: HELPER_MESSAGES.MAX_LENGTH, 80 | }, 81 | { 82 | icon: PiNotePencil, 83 | text: HELPER_MESSAGES.MIN_WORDS, 84 | }, 85 | ]; 86 | 87 | export default SentenceInput; 88 | -------------------------------------------------------------------------------- /src/base/utils/string.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ABBREVIATIONS, 3 | ABBREVIATIONS_PATTERNS, 4 | NON_WORD_CHAR_PATTERN, 5 | NUM_WITH_COMMAS_REGEX, 6 | PUNCTUATION_PATTERN, 7 | } from '@/base/constants'; 8 | 9 | /** 10 | * Checks if a given token is a punctuation. 11 | * 12 | * @param {string} token - The token to check. 13 | * @return {boolean} True if the token is a punctuation, false otherwise. 14 | */ 15 | export const isPunctuation = (token?: string): boolean => 16 | Boolean(token?.match(PUNCTUATION_PATTERN)); 17 | 18 | /** split(/\s+/) : 1개 이상의 연속된 공백을 기준으로 분리 */ 19 | export const tokenizer = (text: string) => { 20 | return text 21 | .replace(NON_WORD_CHAR_PATTERN, ' $1 ') 22 | .split(/\s+/) 23 | .filter(Boolean); 24 | }; 25 | 26 | /** 27 | * Joins an array of tokens into a single string, separating them with spaces. 28 | * 29 | * @param {ReturnType} tokens - The array of tokens to be joined. 30 | * @return {string} - The joined string. 31 | */ 32 | export const tokenJoiner = (tokens: ReturnType): string => { 33 | return tokens.reduce((prev, cur) => { 34 | if (cur.match(NON_WORD_CHAR_PATTERN)) return prev + cur; 35 | else return prev + ' ' + cur; 36 | }, ''); 37 | }; 38 | 39 | /** 40 | * Replaces kebab-case with camelCase. 41 | * 42 | * @param {string} str - The kebab-case string to be converted. 43 | * @return {string} The camelCase string. 44 | */ 45 | export const kebabToCamel = (str: string): string => { 46 | /** 47 | * -([a-z]) : -로 시작하고 소문자로 끝나는 문자열 48 | * e.g. hello-world -> '-w' 매칭 49 | * 매칭된 문자열은 replacer 함수의 첫번째 인자로 전달됨 50 | * */ 51 | return str.replace(/-([a-z])/g, (g) => g[1].toUpperCase()); 52 | }; 53 | 54 | /** 55 | * Replaces abbreviations in a sentence with their expanded forms. 56 | * 57 | * @param {string} sentence - The sentence to expand abbreviations in. 58 | * @return {string} The sentence with abbreviations expanded. 59 | */ 60 | export const expandAbbreviations = (sentence: string): string => { 61 | return sentence.replace( 62 | ABBREVIATIONS_PATTERNS, 63 | (match) => ABBREVIATIONS[match], 64 | ); 65 | }; 66 | 67 | /** 68 | * Removes comma as a thousand separator from a sentence. 69 | * 70 | * @param {string} sentence - The sentence with comma as a thousand separator. 71 | * @return {string} The sentence without a thousand separator. 72 | */ 73 | export const removeThousandSeparator = (sentence: string): string => { 74 | return sentence.replace(NUM_WITH_COMMAS_REGEX, (match) => 75 | match.replace(/,/g, ''), 76 | ); 77 | }; 78 | 79 | /** 80 | * Ensures that a sentence ends with a period. 81 | * 82 | * @param {string} sentence - The sentence to check. 83 | * @return {string} The sentence with a period at the end. 84 | */ 85 | export const ensurePeriod = (sentence: string): string => { 86 | return sentence.endsWith('.') ? sentence : `${sentence}.`; 87 | }; 88 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "syntax-analyzer", 3 | "private": true, 4 | "version": "1.0.0", 5 | "type": "module", 6 | "author": "Romantech", 7 | "license": "MIT", 8 | "description": "Visual tool for English syntax analysis", 9 | "keywords": [ 10 | "syntax", 11 | "analysis", 12 | "English", 13 | "OpenAI", 14 | "ChatGPT" 15 | ], 16 | "repository": { 17 | "type": "git", 18 | "url": "git@github.com:romantech/syntax-analyzer.git" 19 | }, 20 | "bugs": { 21 | "url": "https://github.com/romantech/syntax-analyzer/issues" 22 | }, 23 | "lint-staged": { 24 | "*.{ts,tsx}": "pnpm run lint:fix" 25 | }, 26 | "scripts": { 27 | "dev": "vite", 28 | "build": "tsc && vite build", 29 | "lint": "eslint --cache . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 30 | "lint:fix": "pnpm run lint --fix", 31 | "preview": "vite preview", 32 | "prepare": "husky" 33 | }, 34 | "dependencies": { 35 | "@chakra-ui/react": "^2.10.9", 36 | "@emotion/react": "^11.14.0", 37 | "@emotion/styled": "^11.14.0", 38 | "@fingerprintjs/fingerprintjs": "3.4.2", 39 | "@formkit/auto-animate": "^0.8.2", 40 | "@hookform/resolvers": "^3.10.0", 41 | "@lottiefiles/react-lottie-player": "^3.6.0", 42 | "@tanstack/react-query": "^5.80.7", 43 | "@tsparticles/engine": "^3.8.1", 44 | "@tsparticles/preset-links": "^3.2.0", 45 | "@tsparticles/react": "^3.0.0", 46 | "@vercel/analytics": "^1.5.0", 47 | "@vercel/speed-insights": "^1.2.0", 48 | "axios": "^1.12.0", 49 | "clsx": "^2.1.1", 50 | "date-fns": "^3.6.0", 51 | "framer-motion": "^11.18.2", 52 | "jotai": "^2.12.5", 53 | "nanoid": "^5.1.5", 54 | "qs": "^6.14.0", 55 | "react": "^18.3.1", 56 | "react-dom": "^18.3.1", 57 | "react-error-boundary": "^4.1.2", 58 | "react-hook-form": "^7.57.0", 59 | "react-icons": "^5.5.0", 60 | "react-router-dom": "^6.30.1", 61 | "sass": "^1.89.2", 62 | "yup": "^1.6.1" 63 | }, 64 | "devDependencies": { 65 | "@hookform/devtools": "^4.4.0", 66 | "@tanstack/eslint-plugin-query": "^5.78.0", 67 | "@tanstack/react-query-devtools": "^5.80.7", 68 | "@types/node": "^20.19.0", 69 | "@types/qs": "^6.14.0", 70 | "@types/react": "^18.3.23", 71 | "@types/react-dom": "^18.3.7", 72 | "@typescript-eslint/eslint-plugin": "^7.18.0", 73 | "@typescript-eslint/parser": "^7.18.0", 74 | "@vitejs/plugin-react": "^4.5.2", 75 | "eslint": "^8.57.1", 76 | "eslint-config-prettier": "^9.1.0", 77 | "eslint-import-resolver-typescript": "^3.10.1", 78 | "eslint-plugin-import": "^2.31.0", 79 | "eslint-plugin-prettier": "^5.4.1", 80 | "eslint-plugin-react": "^7.37.5", 81 | "eslint-plugin-react-hooks": "^4.6.2", 82 | "eslint-plugin-react-refresh": "^0.4.20", 83 | "husky": "^9.1.7", 84 | "jotai-devtools": "^0.8.0", 85 | "lint-staged": "^15.5.2", 86 | "prettier": "^3.5.3", 87 | "rollup-plugin-visualizer": "^5.14.0", 88 | "typescript": "^5.8.3", 89 | "vite": "^5.4.21", 90 | "vite-plugin-pwa": "^0.19.8", 91 | "vite-tsconfig-paths": "^4.3.2" 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/features/syntax-analyzer/hooks/use-analysis-form.ts: -------------------------------------------------------------------------------- 1 | import { useDisclosure, useToast, UseToastOptions } from '@chakra-ui/react'; 2 | import { yupResolver } from '@hookform/resolvers/yup'; 3 | import { useForm } from 'react-hook-form'; 4 | import { useNavigate } from 'react-router-dom'; 5 | 6 | import { 7 | ensurePeriod, 8 | expandAbbreviations, 9 | removeThousandSeparator, 10 | tokenizer, 11 | } from '@/base'; 12 | import { 13 | AnalysisModel, 14 | createAnalysisFormSchema, 15 | REMAINING_COUNT_BASE_KEY, 16 | useCreateAnalysisMutation, 17 | useInjectAnalysis, 18 | useRemainingCountQuery, 19 | } from '@/features/syntax-analyzer'; 20 | import { updateAnalysisMetaData } from '@/features/syntax-editor'; 21 | import { getSyntaxEditorPath } from '@/routes'; 22 | 23 | export type AnalysisFormValues = { model: AnalysisModel; sentence: string }; 24 | 25 | const toastOptions: UseToastOptions = { 26 | title: '문장 분석을 완료했습니다', 27 | status: 'success', 28 | duration: 4000, 29 | }; 30 | 31 | /** 32 | * Expands abbreviations and removes comma as a thousand separator from a sentence. 33 | * 34 | * @param {string} sentence - The sentence to be processed. 35 | * @return {string} The processed sentence. 36 | */ 37 | export const processSentence = (sentence: string): string => { 38 | const expanded = expandAbbreviations(ensurePeriod(sentence)); 39 | return removeThousandSeparator(expanded); 40 | }; 41 | 42 | export const useAnalysisForm = () => { 43 | const navigate = useNavigate(); 44 | const toast = useToast(); 45 | const { injectAnalysis } = useInjectAnalysis(); 46 | 47 | const { 48 | isOpen: isModalOpen, 49 | onClose: closeModal, 50 | onOpen: openModal, 51 | } = useDisclosure(); 52 | 53 | const { data: remainingCount = 0 } = useRemainingCountQuery({ 54 | select: ({ analysis }) => analysis, 55 | // useSuspenseQuery는 placeholderData 미지원 56 | // 참고로 placeholderData는 observer 레벨에서 동작하는 가짜 데이터로 캐시에 저장 안됨 57 | // placeholderData: { analysis: 0, random_sentence: 0 }, 58 | }); 59 | 60 | const formResults = useForm({ 61 | resolver: yupResolver(createAnalysisFormSchema), 62 | defaultValues: createAnalysisFormSchema.cast({}), 63 | }); 64 | 65 | const mutationResults = useCreateAnalysisMutation({ 66 | onMutate: closeModal, 67 | onSuccess: (data) => { 68 | const analysis = updateAnalysisMetaData(data); 69 | injectAnalysis(analysis); 70 | 71 | navigate(getSyntaxEditorPath('user', 0)); 72 | toast(toastOptions); 73 | }, 74 | meta: { invalidateQueries: REMAINING_COUNT_BASE_KEY }, 75 | }); 76 | 77 | const { getValues, handleSubmit } = formResults; 78 | const { mutate } = mutationResults; 79 | 80 | const onSubmitConfirm = () => { 81 | const { model, sentence } = getValues(); 82 | const tokenized = tokenizer(processSentence(sentence)); 83 | const payload = { model, sentence: tokenized }; 84 | mutate(payload); 85 | }; 86 | 87 | const onSubmit = handleSubmit(openModal); 88 | 89 | return { 90 | onSubmitConfirm, 91 | onSubmit, 92 | isModalOpen, 93 | closeModal, 94 | remainingCount, 95 | ...formResults, 96 | ...mutationResults, 97 | }; 98 | }; 99 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | browser: true, 5 | es2020: true, 6 | node: true, // Node.js 환경의 전역 변수 등록 7 | }, 8 | extends: [ 9 | 'eslint:recommended', 10 | 'plugin:@tanstack/eslint-plugin-query/recommended', 11 | 'plugin:react/jsx-runtime', 12 | 'plugin:import/recommended', 13 | 'plugin:import/typescript', 14 | 'plugin:react-hooks/recommended', 15 | 'plugin:react/recommended', 16 | 'plugin:@typescript-eslint/recommended', 17 | 'plugin:prettier/recommended', 18 | ], 19 | ignorePatterns: ['dist', '.eslintrc.cjs'], 20 | parser: '@typescript-eslint/parser', 21 | plugins: ['react-refresh', '@typescript-eslint'], 22 | settings: { 23 | react: { version: 'detect' }, 24 | 'import/resolver': { typescript: true, node: true }, 25 | }, 26 | rules: { 27 | 'react-refresh/only-export-components': [ 28 | 'warn', 29 | { allowConstantExport: true }, // 상수 내보내기 허용 30 | ], 31 | 'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 32 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 33 | // Prettier 포맷 관련 내용은 'warn' 으로 취급(ESLint/Prettier 충돌 방지) ▼ 34 | // { endOfLine: 'auto' } 엔드라인 시퀀스 자동 변경 ▼ 35 | 'prettier/prettier': ['warn', { endOfLine: 'auto' }], 36 | 'no-param-reassign': ['error', { props: false }], 37 | 'react/react-in-jsx-scope': 'off', 38 | 'prefer-const': 'warn', 39 | 'no-plusplus': 'off', 40 | 'vars-on-top': 'off', 41 | 'no-underscore-dangle': 'off', // var _foo; 42 | 'comma-dangle': 'off', 43 | 'func-names': 'off', // setTimeout(function () {}, 0); 44 | 'prefer-arrow-callback': 'off', // setTimeout(function () {}, 0); 45 | 'prefer-template': 'off', 46 | 'no-nested-ternary': 'off', 47 | 'max-classes-per-file': 'off', 48 | 'no-restricted-syntax': ['off', 'ForOfStatement'], 49 | 'consistent-return': 'warn', 50 | 'react/prop-types': 'off', 51 | 'no-unused-expressions': 'warn', 52 | 'no-unused-vars': 'off', 53 | '@typescript-eslint/no-unused-vars': [ 54 | 'warn', 55 | { 56 | argsIgnorePattern: '^_', // _로 시작하는 인자 무시 57 | varsIgnorePattern: '^_', // _로 시작하는 변수 무시 58 | }, 59 | ], 60 | 61 | /** import 정렬 관련 설정 */ 62 | 'import/no-unresolved': 'error', 63 | 'import/order': [ 64 | 'warn', 65 | { 66 | // 그룹 순서 지정 67 | groups: [ 68 | 'builtin', // Built-in imports go first 69 | 'external', // External imports 70 | 'internal', // Absolute imports 71 | ['parent', 'sibling'], // Relative imports from siblings and parents can mix 72 | 'index', // index imports 73 | 'object', 74 | 'type', 75 | ], 76 | 'newlines-between': 'always', 77 | // 패턴으로 세부적인 순서 지정 78 | pathGroups: [ 79 | { 80 | pattern: '{react,react-dom/*}', 81 | group: 'external', 82 | position: 'before', 83 | }, 84 | ], 85 | // 리액트 패키지는 external 그룹에서 알파벳 순이 아닌 상단에 위치시키기 위해 예외 처리 86 | pathGroupsExcludedImportTypes: ['react'], 87 | alphabetize: { order: 'asc', caseInsensitive: true }, 88 | }, 89 | ], 90 | }, 91 | }; 92 | -------------------------------------------------------------------------------- /src/features/syntax-editor/data/constituent-translations.ts: -------------------------------------------------------------------------------- 1 | import { TConstituent } from '@/features/syntax-editor'; 2 | 3 | type EnglishLabels = Extract['label']; 4 | type ConstituentTranslation = { ko: string; desc: string }; 5 | 6 | export const CONSTITUENT_TRANSLATIONS: Record< 7 | EnglishLabels, 8 | ConstituentTranslation 9 | > = { 10 | subject: { 11 | ko: '주어', 12 | desc: '문장의 주체를 나타내는 단어나 구', 13 | }, 14 | verb: { 15 | ko: '동사', 16 | desc: '문장의 동작이나 상태를 나타내는 단어', 17 | }, 18 | 'modal verb': { 19 | ko: '조동사', 20 | desc: '다른 동사와 쓰여 의미를 부여하는 동사', 21 | }, 22 | object: { 23 | ko: '목적어', 24 | desc: '동사의 동작이나 상태에 영향을 받는 단어나 구', 25 | }, 26 | 'indirect object': { 27 | ko: '간접 목적어', 28 | desc: '동사의 동작이 미치는 대상을 나타내는 단어나 구', 29 | }, 30 | 'direct object': { 31 | ko: '직접 목적어', 32 | desc: '동사의 동작이 직접적으로 영향을 주는 단어나 구', 33 | }, 34 | 'prepositional object': { 35 | ko: '전치사 목적어', 36 | desc: '전치사에 의해 소개되는 단어나 구', 37 | }, 38 | complement: { 39 | ko: '보어', 40 | desc: '주어나 목적어를 설명하거나 보완하는 단어나 구', 41 | }, 42 | 'object complement': { 43 | ko: '목적보어', 44 | desc: '목적어를 설명하거나 보완하는 단어나 구', 45 | }, 46 | 'to-infinitive': { 47 | ko: 'to 부정사', 48 | desc: "'to'와 함께 사용되는 동사의 기본형", 49 | }, 50 | gerund: { 51 | ko: '동명사', 52 | desc: '동사에 -ing를 붙여 명사로 사용하는 형태', 53 | }, 54 | participle: { 55 | ko: '분사', 56 | desc: '동사의 과거 분사나 현재 분사 형태', 57 | }, 58 | conjunction: { 59 | ko: '접속사', 60 | desc: '문법적으로 연결하는 단어', 61 | }, 62 | 'participle phrase': { 63 | ko: '분사구', 64 | desc: '분사와 그 관련 구성요소로 이루어진 구문', 65 | }, 66 | 'prepositional phrase': { 67 | ko: '전치사구', 68 | desc: '전치사와 그 객체로 이루어진 구', 69 | }, 70 | 'noun phrase': { 71 | ko: '명사구', 72 | desc: '명사와 그와 관련된 수식어들로 이루어진 구', 73 | }, 74 | 'adverbial phrase': { 75 | ko: '부사구', 76 | desc: '문장 내에서 부사의 역할을 하는 구', 77 | }, 78 | 'verb phrase': { 79 | ko: '동사구', 80 | desc: '문장 내에서 동사의 역할을 하는 구', 81 | }, 82 | 'adjectival phrase': { 83 | ko: '형용사구', 84 | desc: '문장 내에서 형용사의 역할을 하는 구', 85 | }, 86 | 'gerund phrase': { 87 | ko: '동명사구', 88 | desc: '동명사와 그와 관련된 단어들로 이루어진 구', 89 | }, 90 | 'infinitive phrase': { 91 | ko: '부정사구', 92 | desc: "'to'와 동사 기본형 및 관련 구성요소로 이루어진 구", 93 | }, 94 | 'coordinating clause': { 95 | ko: '등위절', 96 | desc: '동일한 중요도를 가진 두 개 이상의 절', 97 | }, 98 | 'parallel clause': { 99 | ko: '병렬절', 100 | desc: '동일한 문법 구조를 갖는 두 개 이상의 절', 101 | }, 102 | 'noun clause': { 103 | ko: '명사절', 104 | desc: '문장에서 명사 역할을 하는 절', 105 | }, 106 | 'adjectival clause': { 107 | ko: '형용사절', 108 | desc: '문장에서 형용사 역할을 하는 절', 109 | }, 110 | 'adverbial clause': { 111 | ko: '부사절', 112 | desc: '문장에서 부사 역할을 하는 절', 113 | }, 114 | 'inserted clause': { 115 | ko: '삽입절', 116 | desc: '다른 절 속에 삽입된 절', 117 | }, 118 | 'relative clause': { 119 | ko: '관계절', 120 | desc: '주절에 관계된 세부 정보를 제공하는 절', 121 | }, 122 | 'dependent clause': { 123 | ko: '종속절', 124 | desc: '다른 절에 의존하여 완전한 의미를 가질 수 없는 절', 125 | }, 126 | 'independent clause': { 127 | ko: '독립절', 128 | desc: '독립적으로 완전한 의미를 가진 절', 129 | }, 130 | adverb: { 131 | ko: '부사', 132 | desc: '동사, 형용사, 부사를 수식하는 단어', 133 | }, 134 | }; 135 | -------------------------------------------------------------------------------- /src/features/syntax-analyzer/components/analysis-form/analysis-form.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from 'react'; 2 | 3 | import { 4 | Box, 5 | Button, 6 | FormControl, 7 | HStack, 8 | Skeleton, 9 | SkeletonProps, 10 | Stack, 11 | type StackProps, 12 | } from '@chakra-ui/react'; 13 | import { GiMagicLamp } from 'react-icons/gi'; 14 | 15 | import { ConfirmModal } from '@/base'; 16 | import { 17 | FieldGroupHeader, 18 | ModelChoiceGroup, 19 | SentenceInput, 20 | UsageLimitTooltip, 21 | useAnalysisForm, 22 | } from '@/features/syntax-analyzer'; 23 | 24 | export default function AnalysisForm({ ...stackProps }: StackProps) { 25 | const { 26 | control, 27 | register, 28 | remainingCount, 29 | isPending, // mutation 진행중일 때 true 30 | onSubmit, 31 | isModalOpen, 32 | onSubmitConfirm, 33 | closeModal, 34 | formState: { errors }, 35 | } = useAnalysisForm(); 36 | 37 | return ( 38 | 39 | 40 | ai 모델 선택 41 | 42 | 43 | 44 | 영어 문장 입력 45 | 46 | 52 | 53 | 62 | 63 | 64 | 65 | 72 | 73 | ); 74 | } 75 | 76 | const AnalysisFormBox = ({ 77 | children, 78 | ...stackProps 79 | }: PropsWithChildren) => { 80 | return ( 81 | 82 | {children} 83 | 84 | ); 85 | }; 86 | 87 | const AnalysisFormSkeleton = (stackProps: StackProps) => { 88 | const ESkeleton = (props: SkeletonProps) => ( 89 | 90 | ); 91 | 92 | return ( 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | ); 111 | }; 112 | 113 | AnalysisForm.Skeleton = AnalysisFormSkeleton; 114 | -------------------------------------------------------------------------------- /src/features/syntax-editor/components/syntax-parser/segment-list.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | 3 | import { Segment, TSegment } from '@/features/syntax-editor'; 4 | 5 | interface SegmentsProps { 6 | segment: TSegment; 7 | tokenElements: ReactNode[]; 8 | } 9 | 10 | export default function SegmentList({ segment, tokenElements }: SegmentsProps) { 11 | const childrenWithSegment: ReactNode[] = []; 12 | const { begin, end } = segment; 13 | 14 | for (let index = begin; index < end; index++) { 15 | const childSegment = segment.children.find(({ begin }) => begin === index); 16 | 17 | if (childSegment) { 18 | childrenWithSegment.push( 19 | , 24 | ); 25 | index = childSegment.end - 1; // 자식 파트가 끝나는 지점 이후부터 시작하도록 인덱스 조정 26 | } else { 27 | childrenWithSegment.push(tokenElements[index]); 28 | } 29 | } 30 | 31 | return ( 32 | 33 | {childrenWithSegment.map((token) => token)} 34 | 35 | ); 36 | } 37 | 38 | /** 39 | * 렌더링 시뮬레이션 40 | * ['I', 'am', 'a', 'boy', 'who', 'likes', 'to', 'play', 'tennis', '.'] 41 | * segment1 : 0, 2 (I am) 42 | * segment2 : 2, 10 (a boy who likes to play tennis.) 43 | * segment2.child[0] : 2, 4 (a boy) 44 | * segment2.child[1] : 4, 10 (who likes to play tennis.) 45 | * ----------------------------------------------------------------------------- 46 | * i0 found child -> segment1 재귀 호출 47 | * ----------------------------------------------------------------------------- 48 | * 49 | * i0 not found child -> ['I'] 50 | * i1 not found child -> ['I', 'am'] 51 | * return ['I', 'am'] -> 52 | * ----------------------------------------------------------------------------- 53 | * back to root : childrenWithSegment = [] 54 | * index : segment1.end - 1 + 1 = 2 55 | * i2 found child -> segment2 재귀 호출 56 | * ----------------------------------------------------------------------------- 57 | * 58 | * i2 found child -> segment2.child[0] 재귀 호출 59 | * ----------------------------------------------------------------------------- 60 | * 61 | * i2 not found child -> ['a'] 62 | * i3 not found child -> ['a', 'boy'] 63 | * return ['a', 'boy'] -> 64 | * ------------------------------------------------- 65 | * back to segment2 : childrenWithSegment = [] 66 | * index : segment2.child[0].end - 1 + 1 = 4 67 | * i4 found child -> segment2.child[1] 재귀 호출 68 | * ----------------------------------------------------------------------------- 69 | * 70 | * i4 not found child -> ['who'] 71 | * ...생략 72 | * i9 not found child -> ['who', 'likes', 'to', 'play', 'tennis', '.'] 73 | * return ['who', 'likes', 'to', 'play', 'tennis', '.'] -> 74 | * ----------------------------------------------------------------------------- 75 | * back to segment2 : childrenWithSegment = [, ] 76 | * return ['a', 'boy', 'who', 'likes', ..., '.'] -> 77 | * ----------------------------------------------------------------------------- 78 | * back to root : childrenWithSegment = [, ] 79 | * return ['I', 'am', 'a', 'boy', 'who', 'likes', ..., '.'] -> 80 | * */ 81 | -------------------------------------------------------------------------------- /src/features/syntax-analyzer/components/random-sentence-form/add-topic-form.tsx: -------------------------------------------------------------------------------- 1 | import { useRef } from 'react'; 2 | 3 | import { 4 | Button, 5 | HStack, 6 | Input, 7 | InputGroup, 8 | InputLeftElement, 9 | Popover, 10 | PopoverAnchor, 11 | PopoverArrow, 12 | PopoverBody, 13 | PopoverContent, 14 | StackProps, 15 | } from '@chakra-ui/react'; 16 | import { SubmitHandler, useFormContext } from 'react-hook-form'; 17 | import { CiShoppingTag } from 'react-icons/ci'; 18 | import { ValidationError } from 'yup'; 19 | 20 | import { 21 | addTopicSchema, 22 | MAX_TOPIC_LENGTH, 23 | RandomSentenceFormValues, 24 | } from '@/features/syntax-analyzer'; 25 | 26 | export default function AddTopicForm(stackProps: StackProps) { 27 | const { 28 | register, 29 | clearErrors, 30 | handleSubmit, 31 | setValue, 32 | resetField, 33 | setError, 34 | formState, 35 | } = useFormContext(); 36 | /** 37 | * By default, Popover focus is to sent to PopoverContent when it opens. 38 | * Pass the keywordInputRef prop to send focus to a specific element instead. 39 | * @see https://react-hook-form.com/faqs 40 | * @see https://chakra-ui.com/docs/components/popover/usage 41 | * */ 42 | const keywordInputRef = useRef(null); 43 | const { ref: registerRef, onChange, ...registerRest } = register('keyword'); 44 | 45 | const onAddTopic: SubmitHandler = (data) => { 46 | const { keyword, topics } = data; 47 | 48 | addTopicSchema 49 | .validate({ keyword, topics: [...topics, keyword] }) 50 | .then(() => { 51 | setValue('topics', [...topics, keyword]); 52 | resetField('keyword'); 53 | }) 54 | .catch((err: ValidationError) => { 55 | setError('keyword', { type: 'manual', message: err.errors[0] }); 56 | }); 57 | }; 58 | 59 | const { errors } = formState; 60 | 61 | return ( 62 | 63 | 68 | 69 | 70 | 71 | 72 | 73 | { 76 | await onChange(v); 77 | if (errors.keyword) clearErrors('keyword'); 78 | }} 79 | variant="filled" 80 | ref={(element) => { 81 | // Popover 표시될 때 focus 유지하기 위해 ref share 82 | registerRef(element); 83 | keywordInputRef.current = element; 84 | }} 85 | maxLength={MAX_TOPIC_LENGTH} 86 | minW={240} 87 | focusBorderColor="teal.300" 88 | placeholder="영문 키워드를 입력 해주세요" 89 | onBlur={() => clearErrors('keyword')} 90 | /> 91 | 92 | 93 | 94 | 95 | {errors.keyword?.message} 96 | 97 | 98 | 101 | 102 | ); 103 | } 104 | -------------------------------------------------------------------------------- /src/base/components/layout/header.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | Button, 4 | Flex, 5 | HStack, 6 | Icon, 7 | Spacer, 8 | Tab, 9 | TabList, 10 | Tabs, 11 | useColorMode, 12 | useColorModeValue, 13 | } from '@chakra-ui/react'; 14 | import { BsGithub } from 'react-icons/bs'; 15 | import { FiCoffee } from 'react-icons/fi'; 16 | import { MdModeNight, MdOutlineLightMode } from 'react-icons/md'; 17 | import { matchPath, NavLink, useLocation } from 'react-router-dom'; 18 | 19 | import { SITE_URLS } from '@/routes/paths'; 20 | 21 | const NAV_TABS = [ 22 | { 23 | label: 'home', 24 | path: '/', 25 | matchPath: '/', 26 | }, 27 | { 28 | label: 'analyzer', 29 | path: SITE_URLS.ANALYZER.ROOT, 30 | matchPath: `${SITE_URLS.ANALYZER.ROOT}/*`, 31 | }, 32 | { 33 | label: 'editor', 34 | path: SITE_URLS.EDITOR.ROOT, 35 | matchPath: `${SITE_URLS.EDITOR.ROOT}/*`, 36 | }, 37 | ] as const; 38 | 39 | const useTabIndex = () => { 40 | const { pathname } = useLocation(); 41 | return NAV_TABS.findIndex((tab) => matchPath(tab.matchPath, pathname)); 42 | }; 43 | 44 | export function Header() { 45 | const tabIndex = useTabIndex(); 46 | const { colorMode, toggleColorMode } = useColorMode(); 47 | 48 | const ToggleIcon = colorMode === 'light' ? MdModeNight : MdOutlineLightMode; 49 | 50 | const bg = useColorModeValue('whiteAlpha.800', 'grayAlpha.800'); 51 | const boxShadow = useColorModeValue('sm', 'lg'); 52 | const hoverColor = useColorModeValue('gray.400', 'gray.100'); 53 | 54 | const position = tabIndex === 0 ? 'fixed' : 'sticky'; 55 | 56 | return ( 57 | 68 | 69 | 70 | 71 | {NAV_TABS.map((tab, i) => ( 72 | 81 | {tab.label} 82 | 83 | ))} 84 | 85 | 86 | 87 | 88 | 97 | 106 | 113 | 114 | 115 | 116 | ); 117 | } 118 | -------------------------------------------------------------------------------- /src/features/syntax-editor/store/segment-history-store.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'jotai'; 2 | import { atomWithDefault, atomWithReset, RESET } from 'jotai/utils'; 3 | 4 | import { Nullable } from '@/base'; 5 | import { 6 | AnalysisPathParams, 7 | fillSegment, 8 | removeEmptySegment, 9 | resetControlPanelAtom, 10 | sampleAnalysisListAtom, 11 | selectedAnalysisAtom, 12 | TSegment, 13 | userAnalysisListAtom, 14 | } from '@/features/syntax-editor'; 15 | 16 | import { deleteModeAtom } from './control-panel-store'; 17 | 18 | /** 19 | * useResetAtom 혹은 RESET 심볼을 이용해 초기값으로 되돌릴 수 있음 20 | * @see https://jotai.org/docs/utilities/resettable#atomwithreset 21 | * */ 22 | export const segmentHistoryIndexAtom = atomWithReset(0); 23 | /** 24 | * atomWithDefault 사용시 read function 으로 초기값을 지정할 수 있음 25 | * write function 은 atom()과 동일하게 사용 가능 26 | * e.g. setState(...) 혹은 setState(prev => ...) 형태로 사용 가능 27 | * useResetAtom 혹은 RESET 심볼을 이용해 초기값으로 되돌릴 수 있음 28 | * @see https://jotai.org/docs/utilities/resettable#atomwithdefault 29 | * */ 30 | 31 | export const segmentHistoryAtom = atomWithDefault((get) => { 32 | const selectedAnalysis = get(selectedAnalysisAtom); 33 | return selectedAnalysis ? [selectedAnalysis.rootSegment] : []; 34 | }); 35 | 36 | export const resetSegmentHistoryAtom = atom(null, (_, set) => { 37 | set(segmentHistoryAtom, RESET); 38 | set(segmentHistoryIndexAtom, RESET); 39 | }); 40 | 41 | export const currentSegmentFromHistoryAtom = atom>((get) => { 42 | const history = get(segmentHistoryAtom); 43 | const index = get(segmentHistoryIndexAtom); 44 | return history[index] ?? null; 45 | }); 46 | 47 | export const updateSegmentHistoryAndIndexAtom = atom( 48 | (get) => get(currentSegmentFromHistoryAtom), 49 | (get, set, updatedSegment: TSegment) => { 50 | const cleanedSegment = removeEmptySegment(updatedSegment); 51 | const filledSegment = fillSegment(cleanedSegment, updatedSegment.end); 52 | 53 | set(segmentHistoryAtom, (prev) => { 54 | const newHistory = [...prev, filledSegment]; 55 | set(segmentHistoryIndexAtom, newHistory.length - 1); 56 | return newHistory; 57 | }); 58 | 59 | if (!get(hasAddedTagAtom)) set(deleteModeAtom, RESET); 60 | }, 61 | ); 62 | 63 | export const undoRedoAbilityAtom = atom((get) => { 64 | const index = get(segmentHistoryIndexAtom); 65 | const history = get(segmentHistoryAtom); 66 | 67 | return { 68 | undo: index > 0, 69 | redo: index < history.length - 1, 70 | }; 71 | }); 72 | 73 | export const undoRedoActionAtom = atom( 74 | (get) => get(undoRedoAbilityAtom), 75 | (_get, set, type: 'undo' | 'redo') => { 76 | set(segmentHistoryIndexAtom, (prev) => { 77 | if (type === 'undo') return prev - 1; 78 | return prev + 1; 79 | }); 80 | 81 | set(resetControlPanelAtom); 82 | }, 83 | ); 84 | 85 | export const hasAddedTagAtom = atom((get) => { 86 | const segment = get(currentSegmentFromHistoryAtom); 87 | return Boolean(segment?.children.length); 88 | }); 89 | 90 | export const isSegmentTouchedAtom = atom((get) => { 91 | const segmentFromAnalysis = get(selectedAnalysisAtom)?.rootSegment; 92 | const segmentFromHistory = get(currentSegmentFromHistoryAtom); 93 | return segmentFromAnalysis !== segmentFromHistory; 94 | }); 95 | 96 | export const saveHistorySegmentAtom = atom( 97 | null, 98 | (get, set, { source, index }: AnalysisPathParams) => { 99 | const segment = get(currentSegmentFromHistoryAtom); 100 | if (!segment) return; 101 | 102 | const analysisList = { 103 | user: userAnalysisListAtom, 104 | sample: sampleAnalysisListAtom, 105 | }; 106 | 107 | set(analysisList[source], (prev) => { 108 | const i = parseInt(index, 10); 109 | prev[i] = { ...prev[i], rootSegment: segment }; 110 | set(selectedAnalysisAtom, prev[i]); 111 | return [...prev]; 112 | }); 113 | 114 | set(resetControlPanelAtom); 115 | }, 116 | ); 117 | -------------------------------------------------------------------------------- /src/features/syntax-editor/helpers/selection-validation.ts: -------------------------------------------------------------------------------- 1 | import { getSelectionIndices } from '@/base'; 2 | import { 3 | CONSTITUENT_CLASSES, 4 | CONSTITUENT_DATA_ATTRS, 5 | } from '@/features/syntax-editor'; 6 | 7 | const { INDEX, ID, BEGIN, END } = CONSTITUENT_DATA_ATTRS; 8 | const TOKEN_GROUP_SELECTOR = '.' + CONSTITUENT_CLASSES.TOKEN_GROUP; 9 | 10 | export const getNumberAttr = (element: HTMLElement, key: string) => { 11 | return Number(element.getAttribute(key)); 12 | }; 13 | 14 | /** 유효한 선택 범위인지 확인 */ 15 | const isSelectionEmpty = (begin: number, end: number) => { 16 | return Math.abs(begin - end) === 0; 17 | }; 18 | 19 | /* 주어진 범위 내 문장 성분이 없는지 확인 */ 20 | const hasNoTokenGroups = (beginGroup: HTMLElement, endGroup: HTMLElement) => { 21 | return !beginGroup && !endGroup; 22 | }; 23 | 24 | /** 선택한 범위가 상위 문장 성분 범위와 일치하는지 확인 */ 25 | const isSelectionMatchingSegment = ( 26 | begin: number, 27 | end: number, 28 | segmentBegin: number, 29 | segmentEnd: number, 30 | ) => { 31 | return begin === segmentBegin && end === segmentEnd; 32 | }; 33 | 34 | /** 35 | * 주어진 요소의 상위 문장 성분들 중에서 가장 큰 target 값 반환 36 | * 예: 문장 성분의 구조가 [[0, 1], [1, 3]]이며, 부모는 [0, 3], 자식은 [0, 1], [1, 3] 이라고 가정, 37 | * - [0, 1]을 포함하는 begin(base)가 0인 부모 요소를 탐색하여 가장 큰 end(target) 반환 38 | * - [1, 3]을 포함하는 end(base)가 3인 부모 요소를 탐색하여 가장 큰 begin(target) 반환 39 | */ 40 | const computeMaxRange = ( 41 | element: HTMLElement, 42 | targetAttr: string, 43 | baseAttr: string, 44 | ) => { 45 | let curElem: HTMLElement | null = element; 46 | 47 | let target = getNumberAttr(element, targetAttr); 48 | let parentWithTarget = element; 49 | const base = getNumberAttr(element, baseAttr); 50 | 51 | while (curElem && base === getNumberAttr(curElem, baseAttr)) { 52 | const currentTarget = getNumberAttr(curElem, targetAttr); 53 | 54 | if (currentTarget !== target) { 55 | target = currentTarget; 56 | parentWithTarget = curElem; 57 | } 58 | 59 | curElem = curElem.parentElement; 60 | } 61 | 62 | return { 63 | maxBegin: getNumberAttr(parentWithTarget, BEGIN), 64 | maxEnd: getNumberAttr(parentWithTarget, END), 65 | }; 66 | }; 67 | 68 | /** 69 | * 선택한 범위의 유효성 검사. 유효하지 않은 케이스 예시: 70 | * 1. 구/절이 교차할 때. 예: [[0, 2], [2, 5]]에서 1~3 범위 선택 71 | * 2. 시작(begin)과 끝(end)이 동일할 때 72 | * */ 73 | export const validateSelectionBounds = () => { 74 | const { begin, end, startNode, endNode } = getSelectionIndices(INDEX); 75 | const getReturnValue = (isValid: boolean) => ({ begin, end, isValid }); 76 | 77 | // 선택자와 일치하는 가장 가까운 조상 요소 선택 78 | const beginGroup = startNode?.closest(TOKEN_GROUP_SELECTOR) as HTMLElement; 79 | const endGroup = endNode?.closest(TOKEN_GROUP_SELECTOR) as HTMLElement; 80 | 81 | if (isSelectionEmpty(begin, end)) return getReturnValue(false); 82 | if (hasNoTokenGroups(beginGroup, endGroup)) return getReturnValue(true); 83 | 84 | // begin/end 둘 중 하나의 범위 안에 문장 성분이 없는 경우 85 | if (!beginGroup || !endGroup) { 86 | const group = beginGroup ?? endGroup; 87 | 88 | // [0..., [1, 3]] 혹은 [[0, 1], ...3] 2개 케이스 모두 검사 89 | const resultB = computeMaxRange(group, BEGIN, END); 90 | const resultE = computeMaxRange(group, END, BEGIN); 91 | 92 | const smallestBegin = Math.min(resultB.maxBegin, resultE.maxBegin); 93 | const largestEnd = Math.max(resultB.maxEnd, resultE.maxEnd); 94 | 95 | return getReturnValue(begin <= smallestBegin && end >= largestEnd); 96 | } 97 | 98 | const startId = getNumberAttr(beginGroup, ID); 99 | const endId = getNumberAttr(endGroup, ID); 100 | 101 | const segmentBegin = getNumberAttr(beginGroup, BEGIN); 102 | const segmentEnd = getNumberAttr(endGroup, END); 103 | 104 | if (isSelectionMatchingSegment(begin, end, segmentBegin, segmentEnd)) 105 | return getReturnValue(true); 106 | 107 | // 공통 부모 안에서 서로 다른 문장 성분을 걸쳐서 선택한 경우 108 | // 예: 부모 [0, 4], 자식 1 [0, 2], 자식 2 [2, 4] 일 때 자식 1~2 범위 선택 109 | if (startId !== endId) { 110 | if ( 111 | // 예: [[0, 1], ...4] 일 때 begin/end 범위가 0~2 이면 [0, 1] 문장 성분을 포함하므로 유효 112 | (endGroup.contains(beginGroup) && begin <= segmentBegin) || 113 | (beginGroup.contains(endGroup) && end >= segmentEnd) 114 | ) { 115 | return getReturnValue(true); 116 | } 117 | 118 | // 선택한 범위가 공통 부모 범위와 일치하는지 확인 119 | const { maxBegin, maxEnd } = computeMaxRange(beginGroup, BEGIN, END); 120 | return getReturnValue(maxBegin === begin && maxEnd === end); 121 | } 122 | 123 | // 선택한 범위 안에 다른 문장 성분이 없는지 확인 (없다면 범위 내에서 자유롭게 태깅 가능) 124 | return getReturnValue(beginGroup === endGroup); 125 | }; 126 | -------------------------------------------------------------------------------- /src/features/syntax-editor/components/sentence-manager/sentence-list.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment, useRef } from 'react'; 2 | 3 | import { 4 | Card, 5 | CardBody, 6 | Stack, 7 | StackDivider, 8 | Tab, 9 | TabList, 10 | TabPanel, 11 | TabPanels, 12 | Tabs, 13 | useDisclosure, 14 | } from '@chakra-ui/react'; 15 | import { useAutoAnimate } from '@formkit/auto-animate/react'; 16 | import { useAtomValue, useSetAtom } from 'jotai'; 17 | import { TbMoodEmpty } from 'react-icons/tb'; 18 | import { useNavigate } from 'react-router-dom'; 19 | 20 | import { ConfirmModal, TextPlaceholder } from '@/base'; 21 | import { 22 | AnalysisSource, 23 | DeletableSentence, 24 | TAnalysis, 25 | } from '@/features/syntax-editor'; 26 | import { DEFAULT_SENTENCE_LIST_TAB } from '@/features/syntax-editor/constants'; 27 | import { getSyntaxEditorPath } from '@/routes'; 28 | import { 29 | analysisListBySourceAtom, 30 | removeUserAnalysisActionAtom, 31 | selectedAnalysisAtom, 32 | } from 'src/features/syntax-editor/store'; 33 | 34 | type AnalysisInfo = { index: number; analysis: TAnalysis }; 35 | 36 | interface SentenceListProps { 37 | tabIndex?: number; 38 | onTabChange?: (index: number) => void; 39 | } 40 | 41 | const TAB_LIST: { label: string; source: AnalysisSource }[] = [ 42 | { label: '추가한 문장', source: 'user' }, 43 | { label: '샘플 문장', source: 'sample' }, 44 | ]; 45 | 46 | export default function SentenceList({ 47 | tabIndex, 48 | onTabChange, 49 | }: SentenceListProps) { 50 | const selectedAnalysis = useRef(); 51 | const { isOpen, onOpen, onClose } = useDisclosure(); 52 | const navigate = useNavigate(); 53 | 54 | const combinedAnalysisList = useAtomValue(analysisListBySourceAtom); 55 | const setSelectedAnalysis = useSetAtom(selectedAnalysisAtom); 56 | const removeUserAnalysis = useSetAtom(removeUserAnalysisActionAtom); 57 | 58 | const [parent] = useAutoAnimate(); 59 | 60 | const onSentenceClick = (analysisInfo: AnalysisInfo) => { 61 | onOpen(); 62 | selectedAnalysis.current = analysisInfo; 63 | }; 64 | 65 | const onSelectSentenceConfirm = () => { 66 | if (!selectedAnalysis.current) return; 67 | 68 | const { analysis, index } = selectedAnalysis.current; 69 | setSelectedAnalysis(analysis); 70 | navigate(getSyntaxEditorPath(analysis.source, index)); 71 | }; 72 | 73 | return ( 74 | 75 | 81 | 82 | {TAB_LIST.map(({ label }) => ( 83 | {label} 84 | ))} 85 | 86 | 87 | 88 | {TAB_LIST.map(({ source, label }) => ( 89 | 90 | 91 | 92 | } 94 | ref={parent} 95 | overflowY="hidden" 96 | > 97 | {!combinedAnalysisList[source].length && ( 98 | 103 | )} 104 | {combinedAnalysisList[source].map((analysis, index) => ( 105 | onSentenceClick({ analysis, index })} 111 | onDelete={() => removeUserAnalysis(analysis.id)} 112 | showGPTBadge={analysis.isAnalyzedByGPT} 113 | /> 114 | ))} 115 | 116 | 117 | 118 | 119 | ))} 120 | 121 | 122 | 129 | 130 | ); 131 | } 132 | -------------------------------------------------------------------------------- /src/features/syntax-editor/data/syntax-constituents.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ConstituentType, 3 | ConstituentWithoutId, 4 | } from '@/features/syntax-editor'; 5 | 6 | export const SYNTAX_CONSTITUENTS: ConstituentWithoutId[] = [ 7 | { 8 | elementId: 1, 9 | label: 'subject', 10 | abbreviation: 's', 11 | type: 'token', 12 | }, 13 | { 14 | elementId: 2, 15 | label: 'verb', 16 | abbreviation: 'v', 17 | type: 'token', 18 | }, 19 | { 20 | elementId: 3, 21 | label: 'modal verb', 22 | abbreviation: 'mod', 23 | type: 'token', 24 | }, 25 | { 26 | elementId: 4, 27 | label: 'object', 28 | abbreviation: 'o', 29 | type: 'token', 30 | }, 31 | { 32 | elementId: 5, 33 | label: 'indirect object', 34 | abbreviation: 'i.o.', 35 | type: 'token', 36 | }, 37 | { 38 | elementId: 6, 39 | label: 'direct object', 40 | abbreviation: 'd.o.', 41 | type: 'token', 42 | }, 43 | { 44 | elementId: 7, 45 | label: 'prepositional object', 46 | abbreviation: 'prp.o.', 47 | type: 'token', 48 | }, 49 | { 50 | elementId: 8, 51 | label: 'complement', 52 | abbreviation: 'c', 53 | type: 'token', 54 | }, 55 | { 56 | elementId: 9, 57 | label: 'object complement', 58 | abbreviation: 'o.c.', 59 | type: 'token', 60 | }, 61 | { 62 | elementId: 10, 63 | label: 'to-infinitive', 64 | abbreviation: 'to-inf', 65 | type: 'token', 66 | }, 67 | { 68 | elementId: 11, 69 | label: 'gerund', 70 | abbreviation: 'g', 71 | type: 'token', 72 | }, 73 | { 74 | elementId: 12, 75 | label: 'participle', 76 | abbreviation: 'pt', 77 | type: 'token', 78 | }, 79 | { 80 | elementId: 13, 81 | label: 'conjunction', 82 | abbreviation: 'conj', 83 | type: 'token', 84 | }, 85 | { 86 | elementId: 14, 87 | label: 'participle phrase', 88 | abbreviation: 'pt.phr', 89 | type: 'phrase', 90 | }, 91 | { 92 | elementId: 15, 93 | label: 'prepositional phrase', 94 | abbreviation: 'prp.phr', 95 | type: 'phrase', 96 | }, 97 | { 98 | elementId: 16, 99 | label: 'noun phrase', 100 | abbreviation: 'n.phr', 101 | type: 'phrase', 102 | }, 103 | { 104 | elementId: 17, 105 | label: 'adverbial phrase', 106 | abbreviation: 'adv.phr', 107 | type: 'phrase', 108 | }, 109 | { 110 | elementId: 18, 111 | label: 'verb phrase', 112 | abbreviation: 'v.phr', 113 | type: 'phrase', 114 | }, 115 | { 116 | elementId: 19, 117 | label: 'adjectival phrase', 118 | abbreviation: 'adj.phr', 119 | type: 'phrase', 120 | }, 121 | { 122 | elementId: 20, 123 | label: 'gerund phrase', 124 | abbreviation: 'g.phr', 125 | type: 'phrase', 126 | }, 127 | { 128 | elementId: 21, 129 | label: 'infinitive phrase', 130 | abbreviation: 'inf.phr', 131 | type: 'phrase', 132 | }, 133 | { 134 | elementId: 22, 135 | label: 'coordinating clause', 136 | abbreviation: 'co.cl', 137 | type: 'clause', 138 | }, 139 | { 140 | elementId: 23, 141 | label: 'parallel clause', 142 | abbreviation: 'p.cl', 143 | type: 'clause', 144 | }, 145 | { 146 | elementId: 24, 147 | label: 'noun clause', 148 | abbreviation: 'n.cl', 149 | type: 'clause', 150 | }, 151 | { 152 | elementId: 25, 153 | label: 'adjectival clause', 154 | abbreviation: 'adj.cl', 155 | type: 'clause', 156 | }, 157 | { 158 | elementId: 26, 159 | label: 'adverbial clause', 160 | abbreviation: 'adv.cl', 161 | type: 'clause', 162 | }, 163 | { 164 | elementId: 27, 165 | label: 'inserted clause', 166 | abbreviation: 'i.cl', 167 | type: 'clause', 168 | }, 169 | { 170 | elementId: 28, 171 | label: 'relative clause', 172 | abbreviation: 'rel.cl', 173 | type: 'clause', 174 | }, 175 | { 176 | elementId: 29, 177 | label: 'dependent clause', 178 | abbreviation: 'dep.cl', 179 | type: 'clause', 180 | }, 181 | { 182 | elementId: 30, 183 | label: 'independent clause', 184 | abbreviation: 'ind.cl', 185 | type: 'clause', 186 | }, 187 | { 188 | elementId: 31, 189 | label: 'adverb', 190 | abbreviation: 'adv', 191 | type: 'token', 192 | }, 193 | ]; 194 | 195 | type ConstituentGroup = { [key in ConstituentType]: ConstituentWithoutId[] }; 196 | export const groupedConstituentsByType = SYNTAX_CONSTITUENTS.reduce( 197 | (group, constituent) => { 198 | if (!group[constituent.type]) group[constituent.type] = []; 199 | group[constituent.type].push(constituent); 200 | return group; 201 | }, 202 | {} as ConstituentGroup, 203 | ); 204 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from '@vitejs/plugin-react'; 2 | import { visualizer } from 'rollup-plugin-visualizer'; 3 | import { defineConfig, PluginOption } from 'vite'; 4 | import { VitePWA } from 'vite-plugin-pwa'; 5 | import tsconfigPaths from 'vite-tsconfig-paths'; 6 | 7 | import { dependencies } from './package.json'; 8 | 9 | /** Filter out React-related dependencies from the list */ 10 | const reactDeps = Object.keys(dependencies).filter( 11 | (key) => key === 'react' || key.startsWith('react-'), 12 | ); 13 | 14 | /** 15 | * Generates custom chunks for Rollup or Vite based on the dependencies provided. 16 | * The 'vendor' chunk includes all React-related dependencies, while additional chunks 17 | * are created for each remaining dependency not already included in 'vendor'. 18 | * For more information on this approach, see the following article: 19 | * {@link https://sambitsahoo.com/blog/vite-code-splitting-that-works.html Referenced Code} 20 | */ 21 | const manualChunks = { 22 | // Include all React-related dependencies in a 'vendor' chunk 23 | vendor: reactDeps, 24 | // Generate additional chunks for remaining dependencies 25 | ...Object.keys(dependencies).reduce((chunks, name) => { 26 | // Skip dependencies already included in 'vendor' 27 | if (!reactDeps.includes(name)) chunks[name] = [name]; 28 | return chunks; 29 | }, {}), 30 | }; 31 | 32 | // https://vitejs.dev/config/ 33 | export default defineConfig(({ mode }) => ({ 34 | plugins: [ 35 | /** 36 | * react() 는 vitejs/plugin-react 플러그인의 메인 함수 37 | * 이 플러그인을 통해 React 프로젝트를 빌드하고 개발시 필요한 기능 제공 38 | * */ 39 | react({ 40 | babel: { 41 | plugins: [ 42 | /** 43 | * Jotai 아톰에 대한 React Refresh 지원 플러그인 44 | * React Refresh 핫리로딩은 코드 변경사항을 반영하면서 상태는 유지하는 기능 45 | * */ 46 | 'jotai/babel/plugin-react-refresh', 47 | /** 48 | * Jotai 는 리코일 처럼 키(key)가 아닌 객체 참조 기반 작동 -> 아톰 식별자 없음 49 | * 수동으로 debugLabel 을 추가할 수 있지만 번거로움. 50 | * 아래 플러그인을 사용하면 모든 아톰에 debugLabel 추가해줌(개발자 도구에서 확인 可) 51 | * */ 52 | 'jotai/babel/plugin-debug-label', 53 | ], 54 | }, 55 | }), 56 | tsconfigPaths(), 57 | visualizer() as unknown as PluginOption, 58 | 59 | /** 60 | * vite-plugin-pwa 플러그인으로 서비스 워커 스크립트 자동 등록 61 | * 서비스 워커는 웹페이지와 브라우저 사이에서 작동하는 프록시 스크립트 62 | * 오프라인 모드, 푸시, 백그라운드 데이터 동기화 등의 기능 지원 63 | * {@link https://vite-pwa-org.netlify.app/guide/} 64 | * */ 65 | VitePWA({ 66 | registerType: 'autoUpdate', 67 | devOptions: { enabled: true }, // enable the service worker on development 68 | /** 69 | * 명시한 manifest 속성을 기반으로 manifest.json 자동 생성되고, public 폴더에 추가됨 70 | * index.html 파일에는 필요한 링크/스크립트도 자동으로 삽입됨 71 | * 이미지, 아이콘, robots.txt 같은건 직접 public 폴더에 추가 필요 72 | * */ 73 | manifest: { 74 | short_name: '구문 분석기', 75 | name: 'Syntax Analyzer', 76 | description: 77 | 'Visual tool for English syntax analysis. Explore and edit sentence structures with a single click. Discover over 30 essential tags and generate tailored random sentences.', 78 | lang: 'en', 79 | categories: ['education', 'tools'], 80 | icons: [ 81 | { 82 | src: '/favicon-48.png', 83 | sizes: '48x48', 84 | type: 'image/png', 85 | }, 86 | { 87 | src: '/favicon-any.svg', 88 | sizes: 'any', 89 | type: 'image/svg+xml', 90 | }, 91 | { 92 | src: '/icon-192.png', 93 | sizes: '192x192', 94 | type: 'image/png', 95 | }, 96 | { 97 | src: '/icon-512.png', 98 | sizes: '512x512', 99 | type: 'image/png', 100 | }, 101 | { 102 | src: '/icon-maskable-512.png', 103 | sizes: '512x512', 104 | type: 'image/png', 105 | purpose: 'maskable', 106 | }, 107 | ], 108 | start_url: '/', 109 | display: 'standalone', 110 | background_color: '#1a202b', 111 | theme_color: '#1a202b', 112 | orientation: 'portrait-primary', 113 | }, 114 | }), 115 | ], 116 | esbuild: { 117 | /** 배포 환경에서만 콘솔/디버거 비활성; 참고로 build.minify 기본값은 esbuild */ 118 | pure: mode === 'production' ? ['console', 'debugger'] : [], 119 | }, 120 | build: { rollupOptions: { output: { manualChunks } } }, 121 | server: { open: true }, 122 | define: { 123 | /** Jotai Devtools process is not defined 문제 해결 124 | * @see https://github.com/jotaijs/jotai-devtools/issues/82#issuecomment-1632818246 */ 125 | 'process.platform': null, 126 | }, 127 | })); 128 | --------------------------------------------------------------------------------