diff --git a/.eslintrc.json b/.eslintrc.json index a42c0ea..ea1493c 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -18,7 +18,7 @@ "rules": { "react/react-in-jsx-scope": "off", "@typescript-eslint/no-unused-vars": "off", - "no-unused-vars": "off", - "prettier/prettier": "warn" + "prettier/prettier": "warn", + "no-unused-vars": "off" } } diff --git a/.gitignore b/.gitignore index a547bf3..3b0b403 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,5 @@ dist-ssr *.njsproj *.sln *.sw? + +.env \ No newline at end of file diff --git a/.prettierrc b/.prettierrc index 2fd1103..91db5df 100644 --- a/.prettierrc +++ b/.prettierrc @@ -5,4 +5,4 @@ "printWidth": 100, "tabWidth": 2, "arrowParens": "avoid" -} \ No newline at end of file +} diff --git a/README.md b/README.md index 233d301..ba703f9 100644 --- a/README.md +++ b/README.md @@ -1,237 +1,655 @@ -# Vite Typescript 프로젝트 세팅 +# Redux -## 프로젝트 생성 +- https://redux-toolkit.js.org/ +- https://ko.redux.js.org +- Context API 와 같은 역할을 함. +- 여러 컴포넌트들이 값(state)를 공유해서 활용한다. +- `Redux` 와 `Redux Toolkit` 이 있다. +- Redux 가 복잡해서 나온 최신 버전이 `Redux Toolkit` + +## 1. 설치 + +- https://redux-toolkit.js.org/introduction/getting-started ```bash -npm create vite@latest . -> React 선택 -> TypeScript 선택 +npm install @reduxjs/toolkit ``` -## npm 설치 - ```bash -npm i -npm run dev +npm install react-redux ``` -## React 18 마이그레이션 +## 2. Props 예제 -### 1. React 18 타입스크립트 +- `State Props Drilling` 을 개발자가 관리해야 함. +- `Drilling` 은 `3 단계 이상 넘어가면 관리`가 어렵습니다. +- App.tsx 대상 코드 진행중 -```bash -npm i react@^18.3.1 react-dom@^18.3.1 -npm i -D @types/react@^18.3.5 @types/react-dom@^18.3.0 -``` +```tsx +import { useState } from 'react'; +// css 객체 +const container_root: React.CSSProperties = { + display: 'flex', + flexDirection: 'column', + border: '5px solid black', + padding: 10, + gap: 10, +}; +const container: React.CSSProperties = { + border: '5px solid red', + display: 'flex', + gap: '10px', +}; +const container_title: React.CSSProperties = { + fontSize: '40px', + color: 'blue', + border: '5px solid orange', +}; +const container_div: React.CSSProperties = { + border: '5px solid hotpink', + margin: 10, +}; +const container_div_2: React.CSSProperties = { + border: '5px solid yellowgreen', + margin: 10, +}; +const btn: React.CSSProperties = { + border: '5px solid #000', + padding: 10, + margin: 20, +}; +export default function App() { + // 만약 props 로 useState 값을 넘겨준다면? + const [num, setNum] = useState(0); + const onIncrease = () => { + setNum(num + 1); + }; + + return ( +
+
Root : {num}
+ +
+
+ +
+
+ +
+
+
+ ); +} +// 각각이 컴포넌트로 되어 있음. +function Left_1(props: { num: number }) { + return ( +
+

Left_1 : {props.num}

+
+ +
+
+ ); +} -### 2. ESLint 버전 8.x +function Left_2(props: { num: number }) { + return ( +
+

Left_2 : {props.num}

+
+ +
+
+ ); +} -```bash -npm i -D eslint@^8.57.0 eslint-plugin-react@^7.37.5 eslint-plugin-react-hooks@^4.6.2 eslint-plugin-jsx-a11y@^6.10.0 eslint-plugin-import@^2.31.0 -``` +function Left_3(props: { num: number }) { + return ( +
+

Left_3 : {props.num}

+
+ +
+
+ ); +} -```bash -npm i -D @typescript-eslint/parser@^7.18.0 @typescript-eslint/eslint-plugin@^7.18.0 -``` +function Left_4(props: { num: number }) { + return ( +
+

Left_4 : {props.num}

+
+ ); +} -- 위 사항 설정 시 오류 발생 처리 (버전 충돌) +// 각각이 컴포넌트로 되어 있음. +function Right_1(props: { action: () => void }) { + return ( +
+
+ +
+
+ ); +} -```bash -npm remove typescript-eslint @typescript-eslint/eslint-plugin @typescript-eslint/parser -``` +function Right_2(props: { action: () => void }) { + return ( +
+
+ +
+
+ ); +} -- 다시 ESLint 7 버전으로 다운그레이드 +function Right_3(props: { action: () => void }) { + return ( +
+
+ +
+
+ ); +} -```bash -npm i -D eslint@^8.57.0 \ - @typescript-eslint/parser@^7.18.0 \ - @typescript-eslint/eslint-plugin@^7.18.0 +function Right_4(props: { action: () => void }) { + return ( +
+
+ +
+
+ ); +} ``` -### 3. Prettier 안정된 버전 (3.x) +## 3. Redux 예제로 변경 -```bash -npm i -D prettier@^3.3.3 eslint-config-prettier@^9.1.0 -``` +### 3.1. 기본적 흐름 -### 4. ESLint Prettier 설정 - -- `.eslintrc.json` 파일 생성 - -```json -{ - "root": true, - "env": { "browser": true, "es2022": true, "node": true }, - "parser": "@typescript-eslint/parser", - "parserOptions": { "ecmaVersion": "latest", "sourceType": "module" }, - "settings": { "react": { "version": "detect" } }, - "plugins": ["react", "react-hooks", "@typescript-eslint", "jsx-a11y", "import"], - "extends": [ - "eslint:recommended", - "plugin:react/recommended", - "plugin:react-hooks/recommended", - "plugin:@typescript-eslint/recommended", - "plugin:jsx-a11y/recommended", - "plugin:import/recommended", - "plugin:import/typescript", - "prettier" - ], - "rules": { - "react/react-in-jsx-scope": "off" +```tsx +import { createStore } from '@reduxjs/toolkit'; +import { useState } from 'react'; +import {} from 'react-redux'; + +// 3. reducer 함수를 만든다. +function reducer(state, action) { + if(action.type ==== "") { + return {...state, num: state.value + 1} } + return state } -``` -- .prettierrc 파일 생성 +// 2. 초기값을 생성한다. +const initialState = { + num: 0, +}; + +// 1. store 를 생성한다. +const store = createStore(reducer, initialState); + +export default function App() { + // 만약 props 로 useState 값을 넘겨준다면? + const [num, setNum] = useState(0); + const onIncrease = () => { + setNum(num + 1); + }; + + return ( +
+
Root : {num}
+ +
+
+ +
+
+ +
+
+
+ ); +} +// 각각이 컴포넌트로 되어 있음. +function Left_1(props: { num: number }) { + return ( +
+

Left_1 : {props.num}

+
+ +
+
+ ); +} -```json -{ - "semi": true, - "singleQuote": true, - "trailingComma": "all", - "printWidth": 100, - "tabWidth": 2, - "arrowParens": "avoid" +function Left_2(props: { num: number }) { + return ( +
+

Left_2 : {props.num}

+
+ +
+
+ ); } -``` -- `eslint.config.js` 삭제 -- `.eslintignore` 생성 +function Left_3(props: { num: number }) { + return ( +
+

Left_3 : {props.num}

+
+ +
+
+ ); +} -``` -node_modules -build -dist -``` +function Left_4(props: { num: number }) { + return ( +
+

Left_4 : {props.num}

+
+ ); +} -## VSCode 환경 설정 (팀이 공유) +// 각각이 컴포넌트로 되어 있음. +function Right_1(props: { action: () => void }) { + return ( +
+
+ +
+
+ ); +} -- `.vscode` 폴더 생성 -- `settings.json` 파일 생성 +function Right_2(props: { action: () => void }) { + return ( +
+
+ +
+
+ ); +} -```json -{ - "editor.formatOnSave": true, - "editor.codeActionsOnSave": { - "source.fixAll": "explicit" - }, - "eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"] +function Right_3(props: { action: () => void }) { + return ( +
+
+ +
+
+ ); } + +function Right_4(props: { action: () => void }) { + return ( +
+
+ +
+
+ ); +} + +// css 객체 +const container_root: React.CSSProperties = { + display: 'flex', + flexDirection: 'column', + border: '5px solid black', + padding: 10, + gap: 10, +}; +const container: React.CSSProperties = { + border: '5px solid red', + display: 'flex', + gap: '10px', +}; +const container_title: React.CSSProperties = { + fontSize: '40px', + color: 'blue', + border: '5px solid orange', +}; +const container_div: React.CSSProperties = { + border: '5px solid hotpink', + margin: 10, +}; +const container_div_2: React.CSSProperties = { + border: '5px solid yellowgreen', + margin: 10, +}; +const btn: React.CSSProperties = { + border: '5px solid #000', + padding: 10, + margin: 20, +}; ``` -## npm 재설치 +### 3.2. 기본 구성 진행 -- `pakage.lock.json`, `node_modules` 폴더 제거 후 +- 단계 1 : `/src/redux 폴더` 만들기 +- 단계 1 : `/src/redux/store.ts 파일` 만들기 -```bash -npm i +```ts +import { configureStore } from '@reduxjs/toolkit'; + +// Redux 는 Store 를 조각조각 내서 사용한다. +// Store 를 조각내서 활용하는 것을 Slice 라고 한다. +import numReducer from './slices/numSlice'; + +export const store = configureStore({ + reducer: { num: numReducer }, +}); + +// 값을 읽을 때의 타입 +export type RootState = ReturnType; +// 값을 갱신 할 때의 타입 +export type AppDispatch = typeof store.dispatch; ``` -## VSCode 재실행 권장 +- 단계 2 : `/src/redux/slices 폴더` 만들기 +- 단계 2 : `/src/redux/slices/numSlice.ts 파일` 만들기 + +```ts +import { createSlice } from '@reduxjs/toolkit'; + +// Slice 의 초기값 +const initialState = { + num: 0, +}; + +// Slice 구성 +const numSlice = createSlice({ + name: 'numSlice', + initialState, + reducers: { + onIncrease: state => { + state.num += 1; + }, + }, +}); -## ESLint rules 및 tsconfig 환경 설정 +// 액션 내보내기 +export const { onIncrease } = numSlice.actions; -### 1. ESLint rules +// 보통 Slice 는 default 로 내보냄 +export default numSlice.reducer; +``` -- `.eslintrc.json` rules 추가 +### 3.3. Redux Provider 적용 -```json -"rules": { - "react/react-in-jsx-scope": "off", - "no-unused-vars": "off", - "@typescript-eslint/no-unused-vars": "off" - } +- Provider 공급 (`/main.tsx`) + +```tsx +import { createRoot } from 'react-dom/client'; + +import './index.css'; +import App from './App'; +import { Provider } from 'react-redux'; +import { store } from './redux/store'; + +createRoot(document.getElementById('root')!).render( + + + , +); ``` -### 2. tsconfig 에서는 `tsconfi.app.json` 관리 +- store 의 `slice 의 state` 와 `slice 의 action` 활용해보기 -```json -/* Linting */ - "noUnusedLocals": false, - "noUnusedParameters": false, +```tsx +function Left_4() { + // state 값 읽기 + const num = useSelector((state: RootState) => state.num.num); + + return ( +
+

Left_4 : {num}

+
+ ); +} ``` -### 3. 최종 세팅 결과물 - -- `.eslintrc.json` - -```json -{ - "root": true, - "env": { "browser": true, "es2022": true, "node": true }, - "parser": "@typescript-eslint/parser", - "parserOptions": { "ecmaVersion": "latest", "sourceType": "module" }, - "settings": { "react": { "version": "detect" } }, - "plugins": ["react", "react-hooks", "@typescript-eslint", "jsx-a11y", "import", "prettier"], - "extends": [ - "eslint:recommended", - "plugin:react/recommended", - "plugin:react-hooks/recommended", - "plugin:@typescript-eslint/recommended", - "plugin:jsx-a11y/recommended", - "plugin:import/recommended", - "plugin:import/typescript", - "prettier" - ], - "rules": { - "react/react-in-jsx-scope": "off", - "@typescript-eslint/no-unused-vars": "off", - "no-unused-vars": "off", - "prettier/prettier": "warn" - } +```tsx +function Right_4() { + const dispatch = useDispatch(); + + return ( +
+
+ +
+
+ ); } ``` -- `tsconfig.app.json` - -```json -{ - "compilerOptions": { - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", - "target": "ES2022", - "useDefineForClassFields": true, - "lib": ["ES2022", "DOM", "DOM.Iterable"], - "module": "ESNext", - "skipLibCheck": true, - - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, - "moduleDetection": "force", - "noEmit": true, - "jsx": "react-jsx", - - /* Linting */ - "strict": true, - "noUnusedLocals": false, - "noUnusedParameters": false, - "erasableSyntaxOnly": true, - "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true +## 4. 예제 2 (좋아요) + +### 4.1. Slice 만들기 + +- `/src/redux/slices/likeSlice.ts 파일` 생성 + +```ts +import { createSlice } from '@reduxjs/toolkit'; + +const initialState = { + count: 0, +}; + +const likeSlice = createSlice({ + name: 'likeSlice', + initialState, + reducers: { + addLike: state => { + state.count += 1; + }, + removeLike: state => { + state.count -= 1; + }, }, - "include": ["src"] -} +}); + +export const { addLike, removeLike } = likeSlice.actions; + +export default likeSlice.reducer; ``` -- App.tsx 테스트 코드 +### 4.2. Store 등록하기 + +- `/src/redux/slice/store.ts` 업데이트 (추가) + +```ts +import { configureStore } from '@reduxjs/toolkit'; + +// Redux 는 Store 를 조각조각 내서 사용함 +// Store 를 조각내서 활용하는 것을 Slice 라고 함 +import numReducer from './slices/numSlice'; +import likeReducer from './slices/likeSlice'; + +export const store = configureStore({ + reducer: { num: numReducer, like: likeReducer }, +}); + +// 값을 읽을 때의 타입 +export type RootState = ReturnType; + +// 값을 갱신 할 때의 타입 +export type AppDispatch = typeof store.dispatch; +``` + +### 4.3. `Provider 작성되었는지 확인`하기 + +- `/main.tsx` + +### 4.4. 활용하기 ```tsx -function App() { - const nounuse = 1; - return
App
; +import { Provider, useDispatch, useSelector } from 'react-redux'; +import { store, type RootState } from './redux/store'; +import { onIncrease } from './redux/slices/numSlice'; +import { addLike, removeLike } from './redux/slices/likeSlice'; + +export default function App() { + // state 값 읽기 + const num = useSelector((state: RootState) => state.num.num); + + // state 값 변경 + const dispatch = useDispatch(); + + return ( +
+
Root : {num}
+ +
+
+ +
+
+ +
+
+
+ ); +} +// 각각이 컴포넌트로 되어 있음. +function Left_1() { + return ( +
+

Left_1 :

+
+ +
+
+ ); } -export default App; -``` +function Left_2() { + const like = useSelector((state: RootState) => state.like.count); + + return ( +
+

Left_2 좋아요 : {like}

+
+ +
+
+ ); +} -# Git 설정 +function Left_3() { + return ( +
+

Left_3 :

+
+ +
+
+ ); +} -```bash -git init -git remote add origin https://github.com/devyubi/til_vite_ts.git -git add . -git commit -m "[docs] 프로젝트 세팅" -git push origin main -``` +function Left_4() { + // state 값 읽기 + const num = useSelector((state: RootState) => state.num.num); + return ( +
+

Left_4 : {num}

+
+ ); +} + +// 각각이 컴포넌트로 되어 있음. +function Right_1() { + return ( +
+
+ +
+
+ ); +} + +function Right_2() { + const dispatch = useDispatch(); + return ( +
+
+ +
+
+ + +
+
+ ); +} + +function Right_3() { + return ( +
+
+ +
+
+ ); +} + +function Right_4() { + const dispatch = useDispatch(); + + return ( +
+
+ +
+
+ ); +} + +// css 객체 +const container_root: React.CSSProperties = { + display: 'flex', + flexDirection: 'column', + border: '5px solid black', + padding: 10, + gap: 10, +}; +const container: React.CSSProperties = { + border: '5px solid red', + display: 'flex', + gap: '10px', +}; +const container_title: React.CSSProperties = { + fontSize: '40px', + color: 'blue', + border: '5px solid orange', +}; +const container_div: React.CSSProperties = { + border: '5px solid hotpink', + margin: 10, +}; +const container_div_2: React.CSSProperties = { + border: '5px solid yellowgreen', + margin: 10, +}; +const btn: React.CSSProperties = { + border: '5px solid #000', + padding: 10, + margin: 20, +}; +``` diff --git a/index.html b/index.html index e4b78ea..22bbc9a 100644 --- a/index.html +++ b/index.html @@ -1,10 +1,10 @@ - + - Vite + React + TS + Vite 프로젝트
diff --git a/package-lock.json b/package-lock.json index e1578c6..4fa32e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,8 +8,22 @@ "name": "til_vite_ts", "version": "0.0.0", "dependencies": { + "@fullcalendar/core": "^6.1.19", + "@fullcalendar/daygrid": "^6.1.19", + "@fullcalendar/interaction": "^6.1.19", + "@fullcalendar/list": "^6.1.19", + "@fullcalendar/react": "^6.1.19", + "@fullcalendar/timegrid": "^6.1.19", + "@reduxjs/toolkit": "^2.11.0", + "@supabase/supabase-js": "^2.56.1", + "dompurify": "^3.2.6", + "quill": "^2.0.3", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "react-infinite-scroll-component": "^6.1.0", + "react-quill": "^2.0.0", + "react-redux": "^9.2.0", + "react-router-dom": "^6.30.1" }, "devDependencies": { "@eslint/js": "^9.33.0", @@ -18,6 +32,7 @@ "@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/parser": "^7.18.0", "@vitejs/plugin-react": "^5.0.0", + "autoprefixer": "^10.4.21", "eslint": "^8.57.1", "eslint-config-prettier": "^9.1.2", "eslint-plugin-import": "^2.32.0", @@ -26,6 +41,7 @@ "eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react-refresh": "^0.4.20", "globals": "^16.3.0", + "postcss": "^8.5.6", "prettier": "^3.6.2", "typescript": "~5.8.3", "vite": "^7.1.2" @@ -895,6 +911,65 @@ "url": "https://eslint.org/donate" } }, + "node_modules/@fullcalendar/core": { + "version": "6.1.19", + "resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.19.tgz", + "integrity": "sha512-z0aVlO5e4Wah6p6mouM0UEqtRf1MZZPt4mwzEyU6kusaNL+dlWQgAasF2cK23hwT4cmxkEmr4inULXgpyeExdQ==", + "license": "MIT", + "dependencies": { + "preact": "~10.12.1" + } + }, + "node_modules/@fullcalendar/daygrid": { + "version": "6.1.19", + "resolved": "https://registry.npmjs.org/@fullcalendar/daygrid/-/daygrid-6.1.19.tgz", + "integrity": "sha512-IAAfnMICnVWPjpT4zi87i3FEw0xxSza0avqY/HedKEz+l5MTBYvCDPOWDATpzXoLut3aACsjktIyw9thvIcRYQ==", + "license": "MIT", + "peerDependencies": { + "@fullcalendar/core": "~6.1.19" + } + }, + "node_modules/@fullcalendar/interaction": { + "version": "6.1.19", + "resolved": "https://registry.npmjs.org/@fullcalendar/interaction/-/interaction-6.1.19.tgz", + "integrity": "sha512-GOciy79xe8JMVp+1evAU3ytdwN/7tv35t5i1vFkifiuWcQMLC/JnLg/RA2s4sYmQwoYhTw/p4GLcP0gO5B3X5w==", + "license": "MIT", + "peerDependencies": { + "@fullcalendar/core": "~6.1.19" + } + }, + "node_modules/@fullcalendar/list": { + "version": "6.1.19", + "resolved": "https://registry.npmjs.org/@fullcalendar/list/-/list-6.1.19.tgz", + "integrity": "sha512-knZHpAVF0LbzZpSJSUmLUUzF0XlU/MRGK+Py2s0/mP93bCtno1k2L3XPs/kzh528hSjehwLm89RgKTSfW1P6cA==", + "license": "MIT", + "peerDependencies": { + "@fullcalendar/core": "~6.1.19" + } + }, + "node_modules/@fullcalendar/react": { + "version": "6.1.19", + "resolved": "https://registry.npmjs.org/@fullcalendar/react/-/react-6.1.19.tgz", + "integrity": "sha512-FP78vnyylaL/btZeHig8LQgfHgfwxLaIG6sKbNkzkPkKEACv11UyyBoTSkaavPsHtXvAkcTED1l7TOunAyPEnA==", + "license": "MIT", + "peerDependencies": { + "@fullcalendar/core": "~6.1.19", + "react": "^16.7.0 || ^17 || ^18 || ^19", + "react-dom": "^16.7.0 || ^17 || ^18 || ^19" + } + }, + "node_modules/@fullcalendar/timegrid": { + "version": "6.1.19", + "resolved": "https://registry.npmjs.org/@fullcalendar/timegrid/-/timegrid-6.1.19.tgz", + "integrity": "sha512-OuzpUueyO9wB5OZ8rs7TWIoqvu4v3yEqdDxZ2VcsMldCpYJRiOe7yHWKr4ap5Tb0fs7Rjbserc/b6Nt7ol6BRg==", + "license": "MIT", + "dependencies": { + "@fullcalendar/daygrid": "~6.1.19" + }, + "peerDependencies": { + "@fullcalendar/core": "~6.1.19" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -1034,6 +1109,41 @@ "node": ">= 8" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.0.tgz", + "integrity": "sha512-hBjYg0aaRL1O2Z0IqWhnTLytnjDIxekmRxm1snsHjHaKVmIF1HiImWqsq+PuEbn6zdMlkIj9WofK1vR8jjx+Xw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz", + "integrity": "sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.32", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.32.tgz", @@ -1328,6 +1438,92 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@supabase/auth-js": { + "version": "2.71.1", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.71.1.tgz", + "integrity": "sha512-mMIQHBRc+SKpZFRB2qtupuzulaUhFYupNyxqDj5Jp/LyPvcWvjaJzZzObv6URtL/O6lPxkanASnotGtNpS3H2Q==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.4.5.tgz", + "integrity": "sha512-v5GSqb9zbosquTo6gBwIiq7W9eQ7rE5QazsK/ezNiQXdCbY+bH8D9qEaBIkhVvX4ZRW5rP03gEfw5yw9tiq4EQ==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@supabase/node-fetch": { + "version": "2.6.15", + "resolved": "https://registry.npmjs.org/@supabase/node-fetch/-/node-fetch-2.6.15.tgz", + "integrity": "sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + } + }, + "node_modules/@supabase/postgrest-js": { + "version": "1.21.3", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-1.21.3.tgz", + "integrity": "sha512-rg3DmmZQKEVCreXq6Am29hMVe1CzemXyIWVYyyua69y6XubfP+DzGfLxME/1uvdgwqdoaPbtjBDpEBhqxq1ZwA==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.15.4.tgz", + "integrity": "sha512-e/FYIWjvQJHOCNACWehnKvg26zosju3694k0NMUNb+JGLdvHJzEa29ZVVLmawd2kvx4hdbv8mxSqfttRnH3+DA==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "^2.6.13", + "@types/phoenix": "^1.6.6", + "@types/ws": "^8.18.1", + "ws": "^8.18.2" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.11.0.tgz", + "integrity": "sha512-Y+kx/wDgd4oasAgoAq0bsbQojwQ+ejIif8uczZ9qufRHWFLMU5cODT+ApHsSrDufqUcVKt+eyxtOXSkeh2v9ww==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.56.1", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.56.1.tgz", + "integrity": "sha512-cb/kS0d6G/qbcmUFItkqVrQbxQHWXzfRZuoiSDv/QiU6RbGNTn73XjjvmbBCZ4MMHs+5teihjhpEVluqbXISEg==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.71.1", + "@supabase/functions-js": "2.4.5", + "@supabase/node-fetch": "2.6.15", + "@supabase/postgrest-js": "1.21.3", + "@supabase/realtime-js": "2.15.4", + "@supabase/storage-js": "^2.10.4" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1387,18 +1583,42 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "24.3.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz", + "integrity": "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.10.0" + } + }, + "node_modules/@types/phoenix": { + "version": "1.6.6", + "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz", + "integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==", + "license": "MIT" + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/@types/quill": { + "version": "1.3.10", + "resolved": "https://registry.npmjs.org/@types/quill/-/quill-1.3.10.tgz", + "integrity": "sha512-IhW3fPW+bkt9MLNlycw8u8fWb7oO7W5URC9MfZYHBlA24rex9rs23D5DETChu1zvgVdc5ka64ICjJOgQMr6Shw==", + "license": "MIT", + "dependencies": { + "parchment": "^1.1.2" + } + }, "node_modules/@types/react": { "version": "18.3.24", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.24.tgz", "integrity": "sha512-0dLEBsA1kI3OezMBF8nSsb7Nk19ZnsyE1LLhB8r27KbgU5H4pvuqZLdtE+aUkJVoXgTVuA+iLIwmZ0TuK4tx6A==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -1415,6 +1635,28 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "7.18.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz", @@ -1906,6 +2148,44 @@ "node": ">= 0.4" } }, + "node_modules/autoprefixer": { + "version": "10.4.21", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", + "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.24.4", + "caniuse-lite": "^1.0.30001702", + "fraction.js": "^4.3.7", + "normalize-range": "^0.1.2", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -2009,7 +2289,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.0", @@ -2028,7 +2307,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -2042,7 +2320,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -2103,6 +2380,15 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2156,7 +2442,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/damerau-levenshtein": { @@ -2238,6 +2524,26 @@ } } }, + "node_modules/deep-equal": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.2.tgz", + "integrity": "sha512-5tdhKF6DbU7iIzrIOa1AOUt39ZRm13cmL1cGEh//aqR8x9+tNfbywRf0n5FD/18OKMdo7DNEtrX2t22ZAkI+eg==", + "license": "MIT", + "dependencies": { + "is-arguments": "^1.1.1", + "is-date-object": "^1.0.5", + "is-regex": "^1.1.4", + "object-is": "^1.1.5", + "object-keys": "^1.1.1", + "regexp.prototype.flags": "^1.5.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2249,7 +2555,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, "license": "MIT", "dependencies": { "es-define-property": "^1.0.0", @@ -2267,7 +2572,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "dev": true, "license": "MIT", "dependencies": { "define-data-property": "^1.0.1", @@ -2307,11 +2611,19 @@ "node": ">=6.0.0" } }, + "node_modules/dompurify": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.6.tgz", + "integrity": "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -2409,7 +2721,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -2419,7 +2730,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -2457,7 +2767,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -3108,6 +3417,18 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3115,6 +3436,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "license": "Apache-2.0" + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -3250,6 +3577,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fraction.js": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://github.com/sponsors/rawify" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -3276,7 +3617,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -3307,7 +3647,6 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -3327,7 +3666,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -3352,7 +3690,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -3494,7 +3831,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -3537,7 +3873,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, "license": "MIT", "dependencies": { "es-define-property": "^1.0.0" @@ -3566,7 +3901,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -3579,7 +3913,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -3595,7 +3928,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -3614,6 +3946,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.0.0.tgz", + "integrity": "sha512-XtRG4SINt4dpqlnJvs70O2j6hH7H0X8fUzFsjMn1rwnETaxwp83HLNimXBjZ78MrKl3/d3/pkzDH0o0Lkxm37Q==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -3675,6 +4017,22 @@ "node": ">= 0.4" } }, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -3797,7 +4155,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -3935,7 +4292,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -4250,6 +4606,31 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", + "license": "MIT" + }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -4283,7 +4664,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4379,6 +4759,16 @@ "dev": true, "license": "MIT" }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -4402,11 +4792,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object-is": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/object-keys": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4580,6 +4985,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parchment": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/parchment/-/parchment-1.1.4.tgz", + "integrity": "sha512-J5FBQt/pM2inLzg4hEWmzQx/8h8D0CiDxaG3vyp9rKrQRSDgBlhjdP5jQGgosEajXPSQouXGHOmVdgo7QmJuOg==", + "license": "BSD-3-Clause" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -4699,6 +5110,23 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/preact": { + "version": "10.12.1", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz", + "integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -4768,6 +5196,41 @@ ], "license": "MIT" }, + "node_modules/quill": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/quill/-/quill-2.0.3.tgz", + "integrity": "sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw==", + "license": "BSD-3-Clause", + "dependencies": { + "eventemitter3": "^5.0.1", + "lodash-es": "^4.17.21", + "parchment": "^3.0.0", + "quill-delta": "^5.1.0" + }, + "engines": { + "npm": ">=8.2.3" + } + }, + "node_modules/quill-delta": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz", + "integrity": "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==", + "license": "MIT", + "dependencies": { + "fast-diff": "^1.3.0", + "lodash.clonedeep": "^4.5.0", + "lodash.isequal": "^4.5.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/quill/node_modules/parchment": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz", + "integrity": "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==", + "license": "BSD-3-Clause" + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -4793,6 +5256,18 @@ "react": "^18.3.1" } }, + "node_modules/react-infinite-scroll-component": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/react-infinite-scroll-component/-/react-infinite-scroll-component-6.1.0.tgz", + "integrity": "sha512-SQu5nCqy8DxQWpnUVLx7V7b7LcA37aM7tvoWjTLZp1dk6EJibM5/4EJKzOnl07/BsM1Y40sKLuqjCwwH/xV0TQ==", + "license": "MIT", + "dependencies": { + "throttle-debounce": "^2.1.0" + }, + "peerDependencies": { + "react": ">=16.0.0" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -4800,6 +5275,84 @@ "dev": true, "license": "MIT" }, + "node_modules/react-quill": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/react-quill/-/react-quill-2.0.0.tgz", + "integrity": "sha512-4qQtv1FtCfLgoD3PXAur5RyxuUbPXQGOHgTlFie3jtxp43mXDtzCKaOgQ3mLyZfi1PUlyjycfivKelFhy13QUg==", + "license": "MIT", + "dependencies": { + "@types/quill": "^1.3.10", + "lodash": "^4.17.4", + "quill": "^1.3.7" + }, + "peerDependencies": { + "react": "^16 || ^17 || ^18", + "react-dom": "^16 || ^17 || ^18" + } + }, + "node_modules/react-quill/node_modules/eventemitter3": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-2.0.3.tgz", + "integrity": "sha512-jLN68Dx5kyFHaePoXWPsCGW5qdyZQtLYHkxkg02/Mz6g0kYpDx4FyP6XfArhQdlOC4b8Mv+EMxPo/8La7Tzghg==", + "license": "MIT" + }, + "node_modules/react-quill/node_modules/fast-diff": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.1.2.tgz", + "integrity": "sha512-KaJUt+M9t1qaIteSvjc6P3RbMdXsNhK61GRftR6SNxqmhthcd9MGIi4T+o0jD8LUSpSnSKXE20nLtJ3fOHxQig==", + "license": "Apache-2.0" + }, + "node_modules/react-quill/node_modules/quill": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/quill/-/quill-1.3.7.tgz", + "integrity": "sha512-hG/DVzh/TiknWtE6QmWAF/pxoZKYxfe3J/d/+ShUWkDvvkZQVTPeVmUJVu1uE6DDooC4fWTiCLh84ul89oNz5g==", + "license": "BSD-3-Clause", + "dependencies": { + "clone": "^2.1.1", + "deep-equal": "^1.0.1", + "eventemitter3": "^2.0.3", + "extend": "^3.0.2", + "parchment": "^1.1.4", + "quill-delta": "^3.6.2" + } + }, + "node_modules/react-quill/node_modules/quill-delta": { + "version": "3.6.3", + "resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-3.6.3.tgz", + "integrity": "sha512-wdIGBlcX13tCHOXGMVnnTVFtGRLoP0imqxM696fIPwIf5ODIYUHIvHbZcyvGlZFiFhK5XzDC2lpjbxRhnM05Tg==", + "license": "MIT", + "dependencies": { + "deep-equal": "^1.0.1", + "extend": "^3.0.2", + "fast-diff": "1.1.2" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -4810,6 +5363,53 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "6.30.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.1.tgz", + "integrity": "sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.1.tgz", + "integrity": "sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.0", + "react-router": "6.30.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -4837,7 +5437,6 @@ "version": "1.5.4", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", - "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -4854,6 +5453,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", @@ -5058,7 +5663,6 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dev": true, "license": "MIT", "dependencies": { "define-data-property": "^1.1.4", @@ -5076,7 +5680,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", - "dev": true, "license": "MIT", "dependencies": { "define-data-property": "^1.1.4", @@ -5418,6 +6021,15 @@ "dev": true, "license": "MIT" }, + "node_modules/throttle-debounce": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-2.3.0.tgz", + "integrity": "sha512-H7oLPV0P7+jgvrk+6mwwwBDmxTaxnu9HMXmloNLXwnNO0ZxZ31Orah2n8lU1eMPvsaowP2CX+USCgyovXfdOFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/tinyglobby": { "version": "0.2.14", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", @@ -5479,6 +6091,12 @@ "node": ">=8.0" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/ts-api-utils": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", @@ -5655,6 +6273,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici-types": { + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz", + "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==", + "license": "MIT" + }, "node_modules/update-browserslist-db": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", @@ -5696,6 +6320,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.3.tgz", @@ -5802,6 +6435,22 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -5924,6 +6573,27 @@ "dev": true, "license": "ISC" }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index c046e36..c93750f 100644 --- a/package.json +++ b/package.json @@ -7,11 +7,26 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "generate-types": "npx supabase gen types typescript --project-id aqliuannjrvmytifedbj --schema public > types_db.ts" }, "dependencies": { + "@fullcalendar/core": "^6.1.19", + "@fullcalendar/daygrid": "^6.1.19", + "@fullcalendar/interaction": "^6.1.19", + "@fullcalendar/list": "^6.1.19", + "@fullcalendar/react": "^6.1.19", + "@fullcalendar/timegrid": "^6.1.19", + "@reduxjs/toolkit": "^2.11.0", + "@supabase/supabase-js": "^2.56.1", + "dompurify": "^3.2.6", + "quill": "^2.0.3", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "react-infinite-scroll-component": "^6.1.0", + "react-quill": "^2.0.0", + "react-redux": "^9.2.0", + "react-router-dom": "^6.30.1" }, "devDependencies": { "@eslint/js": "^9.33.0", @@ -20,6 +35,7 @@ "@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/parser": "^7.18.0", "@vitejs/plugin-react": "^5.0.0", + "autoprefixer": "^10.4.21", "eslint": "^8.57.1", "eslint-config-prettier": "^9.1.2", "eslint-plugin-import": "^2.32.0", @@ -28,6 +44,7 @@ "eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react-refresh": "^0.4.20", "globals": "^16.3.0", + "postcss": "^8.5.6", "prettier": "^3.6.2", "typescript": "~5.8.3", "vite": "^7.1.2" diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..59ba564 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + // tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/public/kakao.svg.png b/public/kakao.svg.png new file mode 100644 index 0000000..841543c Binary files /dev/null and b/public/kakao.svg.png differ diff --git a/public/vite.svg b/public/vite.svg deleted file mode 100644 index e7b8dfb..0000000 --- a/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 3a95e69..de6bd46 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,159 @@ -function App() { - const nounuse = 1; - return
App
; +import { Provider, useDispatch, useSelector } from 'react-redux'; +import { store, type RootState } from './redux/store'; +import { onIncrease } from './redux/slices/numSlice'; +import { addLike, removeLike } from './redux/slices/likeSlice'; + +export default function App() { + // state 값 읽기 + const num = useSelector((state: RootState) => state.num.num); + + // state 값 변경 + const dispatch = useDispatch(); + + return ( +
+
Root : {num}
+ +
+
+ +
+
+ +
+
+
+ ); +} +// 각각이 컴포넌트로 되어 있음. +function Left_1() { + return ( +
+

Left_1 :

+
+ +
+
+ ); +} + +function Left_2() { + const like = useSelector((state: RootState) => state.like.count); + + return ( +
+

Left_2 좋아요 : {like}

+
+ +
+
+ ); +} + +function Left_3() { + return ( +
+

Left_3 :

+
+ +
+
+ ); +} + +function Left_4() { + // state 값 읽기 + const num = useSelector((state: RootState) => state.num.num); + + return ( +
+

Left_4 : {num}

+
+ ); +} + +// 각각이 컴포넌트로 되어 있음. +function Right_1() { + return ( +
+
+ +
+
+ ); +} + +function Right_2() { + const dispatch = useDispatch(); + return ( +
+
+ +
+
+ + +
+
+ ); +} + +function Right_3() { + return ( +
+
+ +
+
+ ); +} + +function Right_4() { + const dispatch = useDispatch(); + + return ( +
+
+ +
+
+ ); } -export default App; +// css 객체 +const container_root: React.CSSProperties = { + display: 'flex', + flexDirection: 'column', + border: '5px solid black', + padding: 10, + gap: 10, +}; +const container: React.CSSProperties = { + border: '5px solid red', + display: 'flex', + gap: '10px', +}; +const container_title: React.CSSProperties = { + fontSize: '40px', + color: 'blue', + border: '5px solid orange', +}; +const container_div: React.CSSProperties = { + border: '5px solid hotpink', + margin: 10, +}; +const container_div_2: React.CSSProperties = { + border: '5px solid yellowgreen', + margin: 10, +}; +const btn: React.CSSProperties = { + border: '5px solid #000', + padding: 10, + margin: 20, +}; diff --git a/src/components/Counter.tsx b/src/components/Counter.tsx new file mode 100644 index 0000000..471ad91 --- /dev/null +++ b/src/components/Counter.tsx @@ -0,0 +1,39 @@ +import { useState } from 'react'; + +type CounterProps = {}; +type VoidFun = () => void; + +const Counter = ({}: CounterProps): JSX.Element => { + const [count, setCount] = useState(0); + const add: VoidFun = () => setCount(count + 1); + const minus: VoidFun = () => setCount(count - 1); + const reset: VoidFun = () => setCount(0); + + return ( +
+

Counter: {count}

+
+ + + +
+
+ ); +}; + +export default Counter; diff --git a/src/components/Couter.tsx b/src/components/Couter.tsx new file mode 100644 index 0000000..8c6cc3a --- /dev/null +++ b/src/components/Couter.tsx @@ -0,0 +1,26 @@ +import { useState } from 'react'; +type CounterProps = {}; +type VoidFun = () => void; + +const Couter = ({}: CounterProps): JSX.Element => { + const [count, setCount] = useState(0); + const add: VoidFun = () => { + setCount(count + 1); + }; + const minus: VoidFun = () => { + setCount(count - 1); + }; + const reset: VoidFun = () => { + setCount(0); + }; + return ( +
+

Couter : {count}

+ + + +
+ ); +}; + +export default Couter; diff --git a/src/components/GoogleLoginButton.tsx b/src/components/GoogleLoginButton.tsx new file mode 100644 index 0000000..87297ef --- /dev/null +++ b/src/components/GoogleLoginButton.tsx @@ -0,0 +1,87 @@ +import { useAuth } from '../contexts/AuthContext'; + +interface GoogleLoginButtonProps { + children?: React.ReactNode; + onError?: (error: string) => void; + onSuccess?: (message: string) => void; +} +const GoogleLoginButton = ({ onError, onSuccess }: GoogleLoginButtonProps) => { + // 구글 로그인 사용 + const { signInWithGoogle } = useAuth(); + // 구글 로그인 실행 + const handleGoogleLogin = async () => { + try { + const { error } = await signInWithGoogle(); + if (error) { + console.log('구글 로그인 에러 메시지 : ', error); + if (onError) { + onError(error); + } + } else { + console.log('구글 로그인 성공'); + if (onSuccess) { + onSuccess('구글 로그인이 성공했습니다.'); + } + } + } catch (err) { + console.log('구글 로그인 오류 : ', err); + } + }; + return ( + + ); +}; + +export default GoogleLoginButton; diff --git a/src/components/KakaoLoginButton.tsx b/src/components/KakaoLoginButton.tsx new file mode 100644 index 0000000..05441e9 --- /dev/null +++ b/src/components/KakaoLoginButton.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import { useAuth } from '../contexts/AuthContext'; + +// 오류 메시지를 사용한 화면에 보여줄 함수 +interface KakaoLoginButtonProps { + children?: React.ReactNode; + onError?: (error: string) => void; + onSuccess?: (message: string) => void; +} +const KakaoLoginButton = ({ onError, onSuccess }: KakaoLoginButtonProps) => { + // 카카오 로그인 사용 + const { signInWithKakao } = useAuth(); + // 카카오 로그인 실행 + const handleKakaoLogin = async () => { + try { + const { error } = await signInWithKakao(); + + if (error) { + console.log('카카오로그인 에러 메시지 : ', error); + if (onError) { + onError(error); + } + } else { + console.log('카카오 로그인 성공'); + if (onSuccess) { + onSuccess('카카오 로그인이 성공했습니다.'); + } + } + } catch (err) { + console.log('카카오 로그인 오류 : ', err); + } + }; + return ( + + ); +}; + +export default KakaoLoginButton; diff --git a/src/components/Loading.tsx b/src/components/Loading.tsx new file mode 100644 index 0000000..242f919 --- /dev/null +++ b/src/components/Loading.tsx @@ -0,0 +1,48 @@ +import React from 'react'; + +type LoadingProps = { + size?: 'sm' | 'md' | 'lg'; + message?: string; + overlay?: boolean; + className?: string; +}; + +const Loading: React.FC = ({ + size = 'md', + message = '로딩 중...', + overlay = false, + className = '', +}) => { + const sizeClasses = { + sm: 'w-4 h-4', + md: 'w-6 h-6', + lg: 'w-8 h-8', + }; + + const textSizes = { + sm: 'text-sm', + md: 'text-base', + lg: 'text-lg', + }; + + const spinner = ( +
+
+
+ ); + + const content = ( +
+ {spinner} + {message && {message}} +
+ ); + + if (overlay) { + return
{content}
; + } + + return
{content}
; +}; + +export default Loading; diff --git a/src/components/NameEditor.tsx b/src/components/NameEditor.tsx new file mode 100644 index 0000000..ef231f3 --- /dev/null +++ b/src/components/NameEditor.tsx @@ -0,0 +1,38 @@ +import { useState } from 'react'; + +type NameEditorProps = { + children?: React.ReactNode; +}; + +function NameEditor({ children }: NameEditorProps): JSX.Element { + const [name, setName] = useState(''); + + const handleChange = (e: React.ChangeEvent): void => { + setName(e.target.value); + }; + const handleSave = () => { + setName(''); + }; + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + console.log('Enter 입력'); + } + }; + + return ( +
+

NameEditor : {name}

+
+ handleChange(e)} + onKeyDown={e => handleKeyDown(e)} + /> + +
+
+ ); +} + +export default NameEditor; diff --git a/src/components/Pagination.tsx b/src/components/Pagination.tsx new file mode 100644 index 0000000..2e5c9e5 --- /dev/null +++ b/src/components/Pagination.tsx @@ -0,0 +1,127 @@ +import React from 'react'; + +interface PaginationProps { + totalCount: number; + totalPages: number; + currentPage: number; + itemsPerPage: number; + handleChangePage: (page: number) => void; +} +const Pagination = ({ + totalCount, + totalPages, + currentPage, + itemsPerPage, + handleChangePage, +}: PaginationProps): JSX.Element => { + // ts 자리 + // 시작 번호를 생성함. + const startItem = (currentPage - 1) * itemsPerPage + 1; + // 마지막 번호를 생성함. + const endItem = Math.min(currentPage * itemsPerPage, totalCount); + + // 페이지 번호 버튼 배열을 생성함 + const getPageNumbers = () => { + const pages = []; + // 한 화면에 몇개의 버튼들을 출력할 것인가? + const maxVisiblePages = 5; + + if (totalPages <= maxVisiblePages) { + // 현재 5 페이지 보다 적은 경우 + for (let i = 1; i <= totalPages; i++) { + pages.push(i); + } + } else { + // 현재 5 페이지 보다 큰 경우 + // 시나리오 + // ... currentpage-2 currentpage-1 currentpage currentpage+1 currentpage+2 ... + // 현재 페이지를 중심으로 앞뒤 2개씩 표현 + const startPage = Math.max(1, currentPage - 2); + const endPage = Math.min(totalPages, currentPage + 2); + + // 시작페이지가 1 보다 크면 첫 페이지와 ... 추가 + if (startPage > 1) { + pages.push(1); + // [1] + if (startPage > 2) { + pages.push('...'); + // [1, "..."] + } + } + // 중간 페이지를 추가 + for (let i = startPage; i <= endPage; i++) { + pages.push(i); + } + + // 끝 페이지가 마지막 보다 작으면 ... 과 페이지 추가 + if (endPage < totalPages) { + if (endPage < totalPages - 1) { + pages.push('...'); + } + pages.push(totalPages); + } + } + + return pages; + }; + + const pageNumbers = getPageNumbers(); + + // 페이지네이션이 무조건 나오는 것은 아닙니다. + if (totalPages <= 1) { + return <>; + } + + // tsx 자리 + return ( +
+ {/* 페이지 정보 */} +
+ 총 {totalCount}개 중{' '} + + {startItem} ~ {endItem} + + 개 표시 +
+ + {/* 페이지 번호들 */} +
+ + + {/* 버튼들 출력 */} +
+ {pageNumbers.map((item, index) => ( + + {item === '...' ? ( + ... + ) : ( + + )} + + ))} +
+ + +
+
+ ); +}; + +export default Pagination; diff --git a/src/components/Protected.tsx b/src/components/Protected.tsx new file mode 100644 index 0000000..68a7098 --- /dev/null +++ b/src/components/Protected.tsx @@ -0,0 +1,44 @@ +import type { PropsWithChildren } from 'react'; +import { useAuth } from '../contexts/AuthContext'; +import { Navigate } from 'react-router-dom'; + +/** + * 로그인 한 사용자가 접근할 수 있는 페이지 + * - 사용자 프로필 페이지 + * - 관리자 대시보드 페이지 + * - 개인 설정 페이지 + * - 구매 내역 페이지 등등 + */ +const Protected: React.FC = ({ children }) => { + const { user, loading } = useAuth(); + + if (loading) { + // 사용자 정보가 로딩중이라면 + return ( +
+
로딩중...
+
+ ); + } + + // 로그인이 안되어서 user 정보가 없으면 로그인 페이지로 이동 + if (!user) { + return ; + } + return
{children}
; +}; + +export default Protected; diff --git a/src/components/RichTextEditor.tsx b/src/components/RichTextEditor.tsx new file mode 100644 index 0000000..c474ba4 --- /dev/null +++ b/src/components/RichTextEditor.tsx @@ -0,0 +1,301 @@ +import React, { useCallback, useEffect, useRef } from 'react'; +import ReactQuill, { type Value } from 'react-quill'; +import 'react-quill/dist/quill.snow.css'; + +// 임시 미리보기 이미지의 데이터 형태 +interface TempImageFile { + file: File; // 사용자가 실제로 선택한 이미지 파일 + tempUrl: string; // URL.createObjectURL 로 만든 blob 임시 URL (본문 보여줌) + id: string; // 관리를 위한 ID 를 할당 +} + +// 컴포넌트가 외부에서 전달받을 데이터 형태 +interface RichTextEditorProps { + value: string; // 에디터에 초기로 보여줄 내용 + onChange: (value: string) => void; // 내용이 변결될때 실행할 함수 + placeholder?: string; // 안내 텍스트 (선택사항) + disabled?: boolean; // 에디터를 비활성화할지 여부 (선택사항) + // 추가됨. + onImagesChange?: (images: File[]) => void; // 파일을 외부에 보관하는 용도 +} + +const RichTextEditor = ({ + value, + onChange, + placeholder = '내용을 입력하세요.', + disabled = false, + onImagesChange, // 외부로 이미지를 전달하는 함수 +}: RichTextEditorProps) => { + // ref 변수들을 저장해둠. + // ReactQuill 을 보관둡니다. + const quilRef = useRef(null); + + // 미리보기 이미지들을 보관할 임시 목록("blob:~~") + const tempImagesRef = useRef([]); + + // 가장 최근의 내용을 관리하기 위한 변수 + const valueRef = useRef(value); + + // 임시 이미지 URL 생성하는 기능 (기능을 한번만 만들고 재활용) + const createTempImageUrl = useCallback((file: File): string => { + // 웹브라우저 임시 파일 주소 생성 + return URL.createObjectURL(file); + }, []); + + // React Quill 의 툴바의 파일 추가 (이미지 아이콘 클릭 처리)를 수정 + // 리랜더링시 다시 함수 안만들도록 useCallback 으로 보관 + const imageHandler = useCallback(() => { + // input 태그를 코딩으로 만들어 낸다. + // + const input = document.createElement('input'); + input.setAttribute('type', 'file'); + // 업데이트 : 여러개 선택 가능 + input.setAttribute('multiple', 'true'); + input.setAttribute('accept', 'image/*'); + input.click(); + input.onchange = async () => { + // 업데이트 : 최소 1개 이상 파일 선택 + const files = input.files; + if (!files || files.length === 0) return; + + // 실제 React Quill 내용 창에 출력 + const quill = quilRef.current?.getEditor(); + if (!quill) return; + + // 어디에다가 이미지를 출력할 것인가 위치를 파악 + const range = quill.getSelection(); + // 특정 범위가 없다면 끝에 배치한다. + let insertIndex = range ? range.index : quill.getLength(); + + for (let i = 0; i < files.length; i++) { + const file = files[i]; + // 파일 크기를 보통 5MB 바이트로 제한 + if (file.size > 5 * 1024 * 1024) { + alert(`${file.name}은 이미지 파일 크기는 5MB 이하여야 합니다.`); + continue; // 이 파일은 건너띄어서 계속 실행 + } + // 임시 주소 생성 + const tempUrl = createTempImageUrl(file); + // 절대 중복되지 않는 임시 ID 를 생성하자. + const tempId = `temp_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; + + // 임시 파일 및 주소를 저장 + const tempImage: TempImageFile = { + file: file, + tempUrl: tempUrl, + id: tempId, + }; + + // 생성된 정보를 보관한다. + tempImagesRef.current.push(tempImage); + console.log(`이미지가 추가됨 : ${tempId} ${tempUrl}`); + + try { + // 직접 html 태그를 만들어서 삽입해줌. + // 나중에 고민 좀 해보자. + //

+ const img = document.createElement('img'); + img.src = tempUrl; + img.style.maxWidth = '100%'; + img.style.height = 'auto'; + img.style.display = 'block'; + img.style.margin = '10px 0'; + + // 유일한 ID 를 부여해서 추후 비교용으로 활용 + img.setAttribute('data-temp-id', tempId); + + const p = document.createElement('p'); + p.appendChild(img); + + // React Quill 직접 추가 + const editorElement = quill.root; + // 현재 위치에 추가 + if (insertIndex === 0) { + // 찾은 root Div 태그에 앞쪽에 추가한다. + editorElement.insertBefore(p, editorElement.firstElementChild); + } else { + const nodes = editorElement.childNodes; + + if (insertIndex < nodes.length) { + editorElement.insertBefore(p, nodes[insertIndex]); + } else { + editorElement.appendChild(p); + } + } + + // 다음 이미지를 위해서 입력 위치만 업데이트 + insertIndex++; + } catch (error) { + console.log('이미지 삽입 중 오류 : ', error); + // 오류 이더라도 다시 html 을 추가해 봄. + try { + const imgHtml = ``; + quill.clipboard.dangerouslyPasteHTML(insertIndex, imgHtml); + insertIndex++; + } catch (err) { + console.log('이미지 삽입 정말 실패 : ', err); + } + } + } + + // 모든 이미지가 배치가 되면 강제렌더링 + quill.update(); + // 마우스 커서 위치 조절 + quill.setSelection(insertIndex); + }; + }, [createTempImageUrl]); + + // value 변경되면 다시 value 를 보관함. + useEffect(() => { + valueRef.current = value; + }, [value]); + + // 에디터 내용과 임시 이미지 목록 즉, tempImagesRef 의 변화를 매칭해줌. 동기화 + // 리랜더링 되더라도 한번만 생성되게 + const syncTempImages = useCallback(() => { + // 현재 에디터 내용에서 사용중인 tempUrl 을 추출함. + // 데이터 타입에서 Array 처럼 Set 도 있습니다. + const usedTempUrls = new Set(); + // 내용에서 blob 으로 된 글자를 찾아줄 겁니다. + // 글자들을 비교할때 정규표현식(Regular Expression)을 사용함. + const tempUrlRegex = /blob:[^"'\s]+/g; + + // 실제로 비교를 실행 + // const matchs = valueRef.current.match(tempUrlRegex); + // if (matchs) { + // matchs.forEach(item => usedTempUrls.add(item)); + // } + + // 오류개선 + const matchs = valueRef.current.match(tempUrlRegex); + + // 순서대로 표시된 이미지를 재정렬 + const orderdImages: TempImageFile[] = []; + matchs?.forEach(tempUrl => { + const foundImage = tempImagesRef.current.find(item => item.tempUrl === tempUrl); + if (foundImage && !usedTempUrls.has(tempUrl)) { + orderdImages.push(foundImage); + usedTempUrls.add(tempUrl); + } + }); + + // 사용하지 않는 임시 이미지들 정리 + // 메모리 누수를 막아주기 위해서 + // tempImagesRef.current = tempImagesRef.current.filter(item => { + // const isUsed = usedTempUrls.has(item.tempUrl); + // // 내용에 임시 미리보기 URL 글자가 없다면 삭제해야 한다. + // if (!isUsed) { + // // 사용하지 않는 blob URL 정리하기 + // URL.revokeObjectURL(item.tempUrl); + // } + // return isUsed; + // }); + + // 개선된 코드 : 사용하지 않는 임시 이미지들을 정리 + // 메모리 누수를 막아주기 위해서 + tempImagesRef.current.forEach(item => { + if (!usedTempUrls.has(item.tempUrl)) { + // 사용하지 않는 blob url 을 정리하기 + URL.revokeObjectURL(item.tempUrl); + console.log(`이미지 삭제됨 : ${item.id} ${item.tempUrl}`); + } + }); + // 에디터 순서대로 재 정렬된 배열로 업데이트 + tempImagesRef.current = orderdImages; + }, []); + + // 에디터의 내용이 변경되면 임시 이미지 동기화 + useEffect(() => { + // 메모리 누수 방지 및 필요없는 파일 업로드 방지용 + syncTempImages(); + }, [value, syncTempImages]); + + // 툴바 설정 - 에디터 상단에 표시될 버튼들을 정의 + const modules = { + toolbar: [ + // 헤더 옵션: H1, H2, H3, 일반 텍스트 + [{ header: [1, 2, 3, false] }], + // 텍스트 서식 옵션 + ['bold', 'italic', 'underline', 'strike'], + // 색상 옵션: 텍스트 색상, 배경 색상 + [{ color: [] }, { background: [] }], + // 텍스트 정렬 옵션: 왼쪽, 가운데, 오른쪽, 양쪽 정렬 + [{ align: [] }], + // 목록 옵션: 순서 있는 목록, 순서 없는 목록 + [{ list: 'ordered' }, { list: 'bullet' }], + // 들여쓰기 옵션: 왼쪽으로 들여쓰기, 오른쪽으로 들여쓰기 + [{ indent: '-1' }, { indent: '+1' }], + // 링크와 이미지 삽입 옵션 + ['link', 'image'], + // 서식 제거 옵션: 선택한 텍스트의 모든 서식을 제거 + ['clean'], + ], + }; + + // 에디터에서 허용할 HTML 태그들을 정의 + // 이 배열에 포함된 태그만 에디터에서 사용할 수 있음 + const formats = [ + 'header', // 헤더 태그 (h1, h2, h3) + 'bold', // 굵은 글씨 (strong, b) + 'italic', // 기울임 글씨 (em, i) + 'underline', // 밑줄 (u) + 'strike', // 취소선 (s, del) + 'color', // 텍스트 색상 (span with color) + 'background', // 배경 색상 (span with background-color) + 'align', // 텍스트 정렬 (text-align) + 'list', // 목록 (ul, ol) + 'bullet', // 순서 없는 목록 (ul) + 'indent', // 들여쓰기 (margin-left) + 'link', // 링크 (a) + 'image', // 이미지 (img) + ]; + + // 이미지 파일을 외부로 전달 + useEffect(() => { + if (onImagesChange) { + // 실제 화면에 보이는 파일만 배열요소로 추출 + const imageFiles = tempImagesRef.current.map(item => item.file); + onImagesChange(imageFiles); + } + }, [onImagesChange, value]); // 에디터에 내용이 바뀔때마다 이미지 목록 업데이트 + + // 에디터가 마운트 되면 + // 즉, 화면에 보이면 이미지 버튼에 이벤트 리스너추가 + useEffect(() => { + // 약간 시간을 두고 핸들러 등록 (에디터가 초기화 하는 데 시간걸림) + const timer = setTimeout(() => { + const quill = quilRef.current?.getEditor(); + if (quill) { + console.log('Quill 에디터 초기화 성공!'); + const toolbar = quill.getModule('toolbar') as any; + if (toolbar && toolbar.addHandler) { + console.log('이미지 핸들러 등록 실행 함'); + // 우리가 원하는 핸들러 등록 + toolbar.addHandler('image', imageHandler); + } + } + }, 100); + + // 클린업 함수 + return () => { + clearTimeout(timer); + }; + }, [imageHandler]); + + return ( +
+ +
+ ); +}; + +export default RichTextEditor; diff --git a/src/components/User.tsx b/src/components/User.tsx new file mode 100644 index 0000000..bfa586e --- /dev/null +++ b/src/components/User.tsx @@ -0,0 +1,41 @@ +import { useEffect, useState } from 'react'; +type UserProps = { + children?: React.ReactNode; + name: string; + age: number; +}; +export type UserType = { + name: string; + age: number; +}; + +const User = ({ name, age }: UserProps): JSX.Element => { + const [user, setUser] = useState(null); + const handleClick = (): void => { + if (user) { + setUser({ ...user, age: user.age + 1 }); + } + }; + useEffect(() => { + setUser({ name, age }); + }, []); + return ( +
+

+ User :{' '} + {user ? ( + + {user.name}님의 나이는 {user.age}살 입니다. + + ) : ( + '사용자 정보가 없습니다.' + )} +

+
+ +
+
+ ); +}; + +export default User; diff --git a/src/components/chat/chat.css b/src/components/chat/chat.css new file mode 100644 index 0000000..103bf57 --- /dev/null +++ b/src/components/chat/chat.css @@ -0,0 +1,663 @@ +/* 채팅 페이지 전체 컨테이너 : 헤더 높이를 제외한 전체 화면 높이 사용 */ +.chat-page { + height: calc(100vh - 120px); + display: flex; + flex-direction: column; +} + +/* 숨김 클래스 */ +.hidden { + display: none !important; +} + +/* 시스템 메시지 스타일 */ +.system-message { + display: flex; + justify-content: center; + margin: 10px 0; +} + +.system-message-content { + background-color: #f0f0f0; + color: #666; + padding: 8px 16px; + border-radius: 16px; + font-size: 12px; + font-style: italic; + max-width: 80%; + text-align: center; +} +/* 메인 채팅 컨테이너 - 사이드바와 채팅방을 나누는 레이아웃 */ +.chat-container { + display: flex; + height: 100%; + border: 1px solid #e0e0e0; + border-radius: 8px; + overflow: hidden; +} +/* 왼쪽 사이드바 - 채팅 목록과 사용자 검색 영역 */ +.chat-sidebar { + width: 300px; + border-right: 1px solid #e0e0e0; + background-color: #f8f9fa; + display: flex; + flex-direction: column; +} +/* 오른쪽 메인 영역 - 채팅방 내용 표시 */ +.chat-main { + flex: 1; + display: flex; + flex-direction: column; + background-color: #ffffff; +} + +/* ==== 채팅 리스트 영역 ==== */ + +/* 채팅 목록 컨테이너 - 세로 방향으로 채팅 목록 표시 */ +.chat-list { + display: flex; + flex-direction: column; + height: 100%; + background-color: #f8f9fa; + border-right: 1px solid #e0e0e0; +} +/* 채팅 목록 헤더 - 제목과 새 채팅 버튼 */ +.chat-list-header { + padding: 20px; + border-bottom: 1px solid #e0e0e0; + display: flex; + justify-content: space-between; + align-items: center; + background-color: #ffffff; +} +/* 채팅 목록 제목 스타일 */ +.chat-list-header h2 { + margin: 0; + font-size: 20px; + font-weight: 600; + color: #333; +} +/* 새 채팅 시작 버튼 - 둥근 모서리와 호버 효과 */ +.new-chat-btn { + background-color: #007bff; + color: #ffffff; + border: none; + padding: 10px 20px; + border-radius: 20px; + cursor: pointer; + font-size: 14px; + font-weight: 500; + opacity: 0.8; + transition: all 0.2s ease; +} +.new-chat-btn:hover { + opacity: 1; +} +/* ==== 사용자 검색 영역 ==== */ +/* 사용자 검색 컨테이너 - 검색 입력과 결과 표시 */ +.user-search { + padding: 16px; + border-bottom: 1px solid #e0e0e0; + background-color: #ffffff; +} +/* 검색 입력 필드 */ +.search-input { + width: 100%; + padding: 8px 12px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 14px; +} +/* 검색 결과 컨테이너 - 스크롤 가능한 최대 높이 설정 */ +.search-result { + margin-top: 8px; + max-height: 200px; + overflow-y: auto; +} +/* 검색된 사용자 아이템 - 아바타와 닉네임 표시 */ +.user-item { + display: flex; + align-items: center; + padding: 8px; + cursor: pointer; + border-radius: 4px; + transition: all 0.2s ease; +} +/* 사용자 아이템 호버효과 */ +.user-item:hover { + background-color: #f0f0f0; +} + +/* 사용자 아바타 컨테이너 */ +.user-avatar { + margin-right: 12px; +} +/* 사용자 아바타 이미지 - 원형으로 표시 */ +.user-avatar img { + width: 32px; + height: 32px; + border-radius: 50%; + object-fit: cover; +} +/* 아바타 플레이스홀더 - 이미지가 없을 때 이니셜 표시 */ +.avatar-placeholder { + width: 32px; + height: 32px; + border-radius: 50%; + background-color: #007bff; + color: #ffffff; + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; + font-size: 14px; +} + +/* 사용자 정보 컨테이너 */ +.user-info { + display: block; +} +/* 사용자 닉네임 스타일 */ +.user-nickname { + font-weight: 500; + color: #333; +} +/* 검색 결과 없음 메시지 */ +.no-results { + padding: 16px; + text-align: center; + color: #666; + font-size: 14px; +} + +/* ==== 채팅 아이템 목록 ==== */ +/* 채팅 아이템 컨테이너 - 스크롤 가능한 목록 */ +.chat-items { + flex: 1; + overflow-y: auto; +} +/* 채팅방이 없을 떄 표시 */ +.no-chats { + padding: 32px 16px; + text-align: center; + color: #666; +} + +/* 개별 채팅 아이템 - 아바타, 이름, 미리보기, 시간 표시 */ +.chat-item { + display: flex; + align-items: center; + padding: 18px 20px; + cursor: pointer; + border-bottom: 1px solid #f5f5f5; + transition: all 0.2s ease; + background-color: #ffffff; +} +/* 선택된 채팅 아이템 - 파란색 테두리, 오른쪽 테두리 */ +.chat-item.selected { + background-color: #e3f2fd; + border-right: 3px solid #007bff; +} +/* 채팅 상대방 아바타 컨테이너 - 읽지 않은 메시지 배치 위치 기준*/ +.chat-avatar { + position: relative; + margin-right: 16px; +} +.chat-avatar img { + width: 50px; + height: 50px; + border-radius: 50%; + object-fit: cover; + border: 2px solid #f0f0f0; +} +.chat-avatar .avatar-placeholder { + width: 50px; + height: 50px; + border-radius: 50%; + background: linear-gradient(135deg, #007bff, #0056b3); + color: #ffffff; + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; + font-size: 19px; + border: 2px solid #f0f0f0; +} +/* 읽지 않은 메시지 개수 배지 - 아바타에 우상단에 표시 */ +.unread-badge { + position: absolute; + top: -2px; + right: -2px; + background-color: #ff4444; + color: #ffffff; + border-radius: 50%; + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 600; +} + +/* 채팅 정보 컨테이너 - 이름, 시간, 미리보기*/ +.chat-info { + flex: 1; + min-width: 0; +} +/* 채팅 헤더 - 이름과 시간 양쪽 끝에 배치 */ +.chat-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 4px; +} + +/* 채팅 상대방 이름 */ +.chat-name { + font-weight: 600; + color: #333; + font-size: 14px; +} + +/* 마지막 메시지 시간 */ +.chat-time { + font-size: 12px; + color: #666; +} + +/* 마지막 메시지 미리보기 - 긴 텍스트는 말줄임표 처리 */ +.chat-preview { + font-size: 13px; + color: #666; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* 읽지 않은 메시지 미리보기 - 굵은 글씨 */ +.chat-preview .unread { + font-weight: 600; + color: #333; +} + +/* 메시지가 없는 경우 표시 */ +.chat-preview .no-message { + font-style: italic; + color: #999; +} + +/* ==== 환영 화면 및 상태 메시지 ==== */ +/* 환영 화면 - 채팅방이 선택되지 않았을 때 표시 */ +.chat-welcome { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + background-color: #f8f9fa; +} +/* 환영 화면 내용 */ +.welcome-content { + text-align: center; + color: #666; + max-width: 400px; + padding: 32px; +} +/* 환영 화면 제목 */ +.welcome-content h2 { + margin-bottom: 16px; + color: #333; + font-size: 24px; +} +/* 환영 화면 설명 텍스트 */ +.welcome-content p { + margin: 8px 0; + font-size: 14px; + line-height: 1.5; +} + +/* 기능 안내 박스 */ +.feature-info { + margin-top: 24px; + padding: 16px; + background-color: #ffffff; + border-radius: 8px; + border: 1px solid #e0e0e0; +} + +/* 기능 안내 텍스트 */ +.feature-info p { + margin: 6px 0; + font-size: 13px; + color: #555; +} + +/* ==== 채팅방 영역 ==== */ + +/* 채팅방 컨테이너 - 헤더, 메시지, 입력 영역으로 구성 */ +.chat-room { + display: flex; + flex-direction: column; + height: 100%; +} + +/* 채팅방 헤더 - 상대방 정보와 채팅 종료 버튼 */ +.chat-room-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + border-bottom: 1px solid #e0e0e0; + background-color: #f8f9fa; +} + +/* 채팅방 정보 컨테이너 */ +.chat-room-info { + display: block; +} +/* 채팅방 정보 제목 - 상대방 닉네임 표시 */ +.chat-room-info h3 { + margin: 0; + font-size: 18px; + font-weight: 600; + color: #333; +} +/* 채팅방 액션 버튼들 컨테이너 */ +.chat-room-actions { + display: flex; + gap: 8px; +} +/* 채팅 종료 버튼 */ +.exit-chat-btn { + padding: 8px 16px; + background-color: #dc3545; + color: #ffffff; + border: none; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + opacity: 0.8px; + transition: all 0.2s ease; +} +.exit-chat-btn:hover { + opacity: 1; +} + +/* ==== 채팅방 메시지 영역 ==== */ + +/* 메시지 컨테이너 - 스크롤 가능한 메시지 목록 */ +.chat-room-message { + flex: 1; + overflow-y: auto; + padding: 16px; +} + +/* 메시지가 없을 때 표시 */ +.no-message { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + color: #666; + text-align: center; +} +.no-message p { + margin: 8px 0; + font-size: 14px; +} + +/* 메시지 그룹 - 같은 날짜의 메시지들은 묶음 */ +.message-group { + margin-bottom: 24px; +} + +/* 날짜 구분선 - 메시지 그룹 사이에 날짜 표시 */ +.date-divider { + text-align: center; + margin: 16px 0; + position: relative; +} +/* 날짜 내용 앞쪽과 뒤쪽에 라인 배치*/ +.date-divider::before { + content: ''; + position: absolute; + display: block; + top: 50%; + left: 0; + width: 50%; + height: 1px; + background-color: #e0e0e0; + z-index: 1; +} +.date-divider::after { + content: ''; + position: absolute; + display: block; + top: 50%; + right: 0; + width: 50%; + height: 1px; + background-color: #e0e0e0; + z-index: 1; +} +.date-divider span { + background-color: #fff; + padding: 4px 12px; + border-radius: 12px; + font-size: 12px; + color: #666; + border: 1px solid #e0e0e0; + position: relative; + z-index: 2; +} + +/* 메시지들 묶음 컨테이너 */ +.message-group-container { + position: relative; + width: 100%; + padding: 0 20px; +} + +/* ==== 개별 메시지 스타일 ==== */ + +/* 메시지 아이템 기본 레이아웃 */ +.message-item { + display: flex; + margin-bottom: 12px; + gap: 8px; +} + +/* 메시지 말풍선 컨테이너 */ +.message-bubble { + position: relative; + display: flex; + flex-direction: column; + max-width: 100%; +} + +/* 메시지 호버시 시간 표시 - 불투명하게 */ +.message-bubble:hover .message-time { + opacity: 1; +} + +/* 메시지 텍스트 - 말풍선 스타일 */ +.message-text { + padding: 8px 12px; + border-radius: 18px; + font-size: 14px; + line-height: 1.4; + word-wrap: break-word; + position: relative; +} +/* 메시지 시간 - 기본적을 반투명 */ +.message-time { + font-size: 14px; + color: #999; + margin-top: 4px; + opacity: 0.7; +} + +/* 나의 메시지 (오른쪽 정렬) - 파란색 말풍선 */ +.message-item.my-message { + display: flex; + justify-content: flex-end; + align-items: flex-end; + max-width: 100%; + gap: 8px; +} + +/* 나의 메시지 말풍선 - 파란색 배경 */ +.my-message .message-text { + background-color: #007bff; + color: #ffffff; + border-bottom-right-radius: 4px; +} + +/* 나의 메시지 시간 - 왼쪽 정렬 */ +.my-message .message-time { + text-align: right; +} + +/* 상대 방의 메시지 (왼쪽 정렬) - 회색 말풍선 */ +.message-item.other-message { + display: flex; + justify-content: flex-start; + align-items: flex-end; + margin-right: auto; + max-width: 100%; + gap: 8px; +} + +/* 상대방의 메시지 말풍선 - 회색 배경 */ +.other-message .message-text { + background-color: #f1f3f4; + color: #333; + border-bottom-left-radius: 4px; +} +/* 나의 메시지 시간 - 왼쪽 정렬 */ +.other-message .message-time { + text-align: left; +} + +/* 채팅룸의 아바타 */ +.message-avatar { + /* flex 에서 내용이 너비 보다 큰 경우 줄여주는 비율 */ + flex-shrink: 0; + margin: 0 4px; +} +/* 메시지 아바타 이미지 - 작은 원형 */ +.message-avatar img { + width: 32px; + height: 32px; + border-radius: 50%; + object-fit: cover; +} +/* 아바타 닉네임을 별도 설정 가능하도록 */ +.message-avatar .avatar-placeholder { + width: 32px; + height: 32px; + border-radius: 50%; + font-size: 12px; + color: #ffffff; +} + +/* ==== 메시지 입력 영역 ==== */ + +/* 메시지 입력 컨테이너 - 하단 고정 */ +.message-input { + border-top: 1px solid #e0e0e0; + background-color: #ffffff; + padding: 16px; +} +/* 메시지 입력 폼 */ +.message-form { + width: 100%; +} + +/* 입력 컨테이너 - 텍스트, 전송 버튼 */ +.input-container { + display: flex; + gap: 8px; +} + +/* 메시지 입력 텍스트 영역 - 자동 높이 조절 */ +.message-textarea { + flex: 1; + min-height: 40px; + max-height: 120px; + border: 1px solid #ddd; + border-radius: 20px; + /* 너비, 높이 제어함 */ + resize: none; + font-size: 14px; + line-height: 1.4; +} + +/* 텍스트 영역 포커스 시 파란색 테두리 */ +.message-textarea:focus { + outline: none; + border-color: #007bff; +} + +/* 전송 버튼 - 원형 버튼 */ +.send-button { + width: 40px; + height: 40px; + border: none; + border-radius: 50%; + background-color: #007bff; + color: #ffffff; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; +} +/* 전송 버튼 호버 효과 - 비활성화 아니라면 */ +.send-button:hover:not(:disabled) { + background-color: #0056b3; +} +/* 전송 버튼 비활성화 상태 */ +.send-button:disabled { + background-color: #cccccc; + cursor: not-allowed; +} + +/* 로딩 스피너 - 전송 중 표시 */ +.loading-spinner { + width: 16px; + height: 16px; + border: 2px solid #ffffff; + border-top: 2px solid transparent; + border-radius: 50%; + + animation: spin 1s linear infinite; +} +/* 스피너 회전 애니메이션 */ +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +/* 에러 메시지 표시 */ +.error-message { + padding: 16px; + text-align: center; + color: #dc3545; +} +/* 에러 메시지 재시도 버튼 */ +.error-message button { + margin-top: 8px; + padding: 8px 16px; + background-color: #007bff; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; +} diff --git a/src/components/chat/chat/MessageInput.tsx b/src/components/chat/chat/MessageInput.tsx new file mode 100644 index 0000000..78ee6b6 --- /dev/null +++ b/src/components/chat/chat/MessageInput.tsx @@ -0,0 +1,132 @@ +/** + * 1:1 채팅에서 메시지를 입력하고 전송하는 컴포넌트 + * - 자동 높이 조절되는 텍스트 영역 + * - Enter 키로 메시지 전송, Shift + Enter 로 줄바꿈 + * - 전송 중 로딩 상태 표시 + * - 빈 메시지 전송 방지 + * - 전송 후 입력 필드 자동 초기화 + */ + +import { useRef, useState } from 'react'; +import { useDirectChat } from '../../../contexts/DirectChatContext'; + +interface MessageInputProps { + chatId: string; +} + +const MessageInput = ({ chatId }: MessageInputProps) => { + // DirectChatContext 에서 메세지 전송 함수 가져오기 + const { sendMessage } = useDirectChat(); + // 메세지 입력 상태관리 + const [message, setMessage] = useState(''); // 현재 입력 중인 메세지 내용 + const [sending, setSending] = useState(false); // 메세지 전송 중 상태 + + // textarea 영역 DOM 참조 (자동 높이 조절 활용) + const textareaRef = useRef(null); + + // 메세지 전송 처리 함수 + const handleSubmit = async (e: React.FormEvent) => { + // 웹 브라우저 새로고침 방지 + e.preventDefault(); + // 메세지가 없거나 전송중인 상태라면 + if (!message.trim() || sending) { + return; + } + // 전송중인 상태로 중복 전송 방지 + setSending(true); + try { + // DirectChatContext 의 sendMessage + const success = await sendMessage({ + chat_id: chatId, // 현재 채팅방 ID + content: message.trim(), // 공백이 제거된 메세지 내용 + }); + + // 메세지 전송 성공 시 처리 + if (success) { + setMessage(''); // 메세지 내용 초기화 + // 텍스트 영역 높이를 자동으로 리셋 + if (textareaRef.current) { + textareaRef.current.style.height = 'auto'; + } + } + } catch (error) { + // 전송 실패시 ERROR + console.log('메세지 전송 오류 :', error); + } finally { + setSending(false); + } + }; + + // 키보드 이벤트 처리 + const handleKeyPress = (e: React.KeyboardEvent) => { + // Enter 키가 눌렸고, Shift 키가 함께 눌리지 않은 경우 + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); // 기본 줄 바꿈 동작 방지 + handleSubmit(e); // 메세지 전송 실행 + } + // Shift + Enter 의 경우 기본 동작(줄바꿈)을 유지 + }; + + // 텍스트 영역 변경 처리 함수 + // 최대 높이 (120px) + // 텍스트 영역의 높이를 내용에 맞게 자동 조절 + const handleTextareaChange = (e: React.ChangeEvent) => { + // 입력된 텍스트를 상태에 저장 + setMessage(e.target.value); + // 자동 높이 조절 + const textarea = e.target; + textarea.style.height = 'auto'; + // 스크롤 높이와 최대 높이 (120px) 중 작은 값을 적용 + textarea.style.height = Math.min(textarea.scrollHeight, 120) + 'px'; + }; + + return ( +
+
+ {/* 입력 컨테이너 - 텍스트 영역과 전송 버튼 */} +
+ {/* 메시지 입력 텍스트 영역 */} +