├── 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 |
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 | }
26 | textTransform="uppercase"
27 | isLoading={isLoading}
28 | isDisabled={!hasCount}
29 | {...buttonProps}
30 | >
31 | 문장 생성
32 | {`(${count}/${DAILY_SENTENCE_LIMIT})`}
38 |
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 |
39 |
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 |
33 |
34 |
35 |
36 |
37 |
38 |
39 | }>
40 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
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 |
53 | )}
54 |
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 |
75 |
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 |
71 | recommended
72 |
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 |
54 |
55 | {HELPER_TEXTS.map(({ icon, text }, i) => (
56 |
57 |
58 | {text}
59 |
60 | ))}
61 |
62 |
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 | }
58 | loadingText="분석중"
59 | >
60 | 분석
61 |
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 |
--------------------------------------------------------------------------------