diff --git a/package-lock.json b/package-lock.json index fc2967f..659097f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,11 +8,19 @@ "name": "diglog-react", "version": "0.0.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@faker-js/faker": "^9.4.0", "@reduxjs/toolkit": "^2.5.0", "@tailwindcss/vite": "^4.0.0", + "@tinymce/tinymce-react": "^5.1.1", "axios": "^1.7.9", + "dompurify": "^3.2.4", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-icons": "^5.4.0", "react-redux": "^9.2.0", "react-router-dom": "^7.1.3", "tailwindcss": "^4.0.0" @@ -327,6 +335,73 @@ "node": ">=6.9.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/modifiers": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-9.0.0.tgz", + "integrity": "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.24.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz", @@ -868,6 +943,22 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@faker-js/faker": { + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-9.4.0.tgz", + "integrity": "sha512-85+k0AxaZSTowL0gXp8zYWDIrWclTbRPg/pm/V0dSFZ6W6D4lhcG3uuZl4zLsEKfEvs69xDbLN2cHQudwp95JA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/fakerjs" + } + ], + "license": "MIT", + "engines": { + "node": ">=18.0.0", + "npm": ">=9.0.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1520,6 +1611,20 @@ "vite": "^5.2.0 || ^6" } }, + "node_modules/@tinymce/tinymce-react": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@tinymce/tinymce-react/-/tinymce-react-5.1.1.tgz", + "integrity": "sha512-DQ0wpvnf/9z8RsOEAmrWZ1DN1PKqcQHfU+DpM3llLze7FHmxVtzuN8O+FYh0oAAF4stzAXwiCIVacfqjMwRieQ==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.6.2", + "tinymce": "^7.0.0 || ^6.0.0 || ^5.5.1" + }, + "peerDependencies": { + "react": "^18.0.0 || ^17.0.1 || ^16.7.0", + "react-dom": "^18.0.0 || ^17.0.1 || ^16.7.0" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1612,6 +1717,13 @@ "@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", @@ -2172,6 +2284,15 @@ "node": ">=0.10" } }, + "node_modules/dompurify": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.4.tgz", + "integrity": "sha512-ysFSFEDVduQpyhzAob/kkuJjf5zWkZD8/A9ywSp1byueyuCfHamrCBa14/Oc2iiB0e51B+NpxSl5gmzn+Ms/mg==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.86", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.86.tgz", @@ -3220,6 +3341,15 @@ "dev": true, "license": "MIT" }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -3360,6 +3490,17 @@ "node": ">= 0.8.0" } }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -3422,6 +3563,21 @@ "react": "^18.3.1" } }, + "node_modules/react-icons": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.4.0.tgz", + "integrity": "sha512-7eltJxgVt7X64oHh6wSWNwwbKTCtMfK35hcjvJS0yxEAhPM8oUKdS3+kqaW1vicIltw+kR2unHaa12S9pPALoQ==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/react-redux": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", @@ -3697,6 +3853,12 @@ "node": ">=6" } }, + "node_modules/tinymce": { + "version": "7.6.1", + "resolved": "https://registry.npmjs.org/tinymce/-/tinymce-7.6.1.tgz", + "integrity": "sha512-5cHhaAoyyTHfAVTInNfoSp0KkUHmeVUbGSu37QKQbOFIPqxYPhqBiaLm1WVLgoNBYOHRProVc3xzxnNTeWHyoQ==", + "license": "GPL-2.0-or-later" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -3723,6 +3885,12 @@ "typescript": ">=4.8.4" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/turbo-stream": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/turbo-stream/-/turbo-stream-2.4.0.tgz", diff --git a/package.json b/package.json index 04bae5f..f9c565d 100644 --- a/package.json +++ b/package.json @@ -10,11 +10,19 @@ "preview": "vite preview" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@faker-js/faker": "^9.4.0", "@reduxjs/toolkit": "^2.5.0", "@tailwindcss/vite": "^4.0.0", + "@tinymce/tinymce-react": "^5.1.1", "axios": "^1.7.9", + "dompurify": "^3.2.4", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-icons": "^5.4.0", "react-redux": "^9.2.0", "react-router-dom": "^7.1.3", "tailwindcss": "^4.0.0" diff --git a/public/kakao-logo.png b/public/kakao-logo.png new file mode 100644 index 0000000..a782468 Binary files /dev/null and b/public/kakao-logo.png differ diff --git a/public/kakao_login_medium_wide.png b/public/kakao_login_medium_wide.png deleted file mode 100644 index c882acc..0000000 Binary files a/public/kakao_login_medium_wide.png and /dev/null differ diff --git a/public/logo-black.png b/public/logo-black.png deleted file mode 100644 index b9dcf3a..0000000 Binary files a/public/logo-black.png and /dev/null differ diff --git a/public/logo.png b/public/logo.png new file mode 100644 index 0000000..e4809f5 Binary files /dev/null and b/public/logo.png differ diff --git a/src/App.tsx b/src/App.tsx index ab59ad6..06da3e8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,5 @@ import {RouterProvider} from 'react-router-dom' -import root from './router/root' +import root from './common/router/root' function App() { diff --git a/src/apis/AxiosApi.tsx b/src/common/apis/AxiosApi.tsx similarity index 97% rename from src/apis/AxiosApi.tsx rename to src/common/apis/AxiosApi.tsx index 442dca6..f654483 100644 --- a/src/apis/AxiosApi.tsx +++ b/src/common/apis/AxiosApi.tsx @@ -1,5 +1,5 @@ import axios from "axios"; -import store from "../store"; +import store from "../../store.tsx"; import {login} from "../slices/loginSlice.tsx"; const axiosApi = axios.create({ diff --git a/src/apis/member.tsx b/src/common/apis/member.tsx similarity index 100% rename from src/apis/member.tsx rename to src/common/apis/member.tsx diff --git a/src/common/assets/fonts/Jalnan2.otf b/src/common/assets/fonts/Jalnan2.otf new file mode 100644 index 0000000..1e3ef1d Binary files /dev/null and b/src/common/assets/fonts/Jalnan2.otf differ diff --git a/src/common/assets/fonts/s-core/SCDream1.otf b/src/common/assets/fonts/s-core/SCDream1.otf new file mode 100644 index 0000000..c8eac97 Binary files /dev/null and b/src/common/assets/fonts/s-core/SCDream1.otf differ diff --git a/src/common/assets/fonts/s-core/SCDream2.otf b/src/common/assets/fonts/s-core/SCDream2.otf new file mode 100644 index 0000000..c5625d9 Binary files /dev/null and b/src/common/assets/fonts/s-core/SCDream2.otf differ diff --git a/src/common/assets/fonts/s-core/SCDream3.otf b/src/common/assets/fonts/s-core/SCDream3.otf new file mode 100644 index 0000000..28a160c Binary files /dev/null and b/src/common/assets/fonts/s-core/SCDream3.otf differ diff --git a/src/common/assets/fonts/s-core/SCDream4.otf b/src/common/assets/fonts/s-core/SCDream4.otf new file mode 100644 index 0000000..1978ea4 Binary files /dev/null and b/src/common/assets/fonts/s-core/SCDream4.otf differ diff --git a/src/common/assets/fonts/s-core/SCDream5.otf b/src/common/assets/fonts/s-core/SCDream5.otf new file mode 100644 index 0000000..628e828 Binary files /dev/null and b/src/common/assets/fonts/s-core/SCDream5.otf differ diff --git a/src/common/assets/fonts/s-core/SCDream6.otf b/src/common/assets/fonts/s-core/SCDream6.otf new file mode 100644 index 0000000..4e20297 Binary files /dev/null and b/src/common/assets/fonts/s-core/SCDream6.otf differ diff --git a/src/common/assets/fonts/s-core/SCDream7.otf b/src/common/assets/fonts/s-core/SCDream7.otf new file mode 100644 index 0000000..7e38170 Binary files /dev/null and b/src/common/assets/fonts/s-core/SCDream7.otf differ diff --git a/src/common/assets/fonts/s-core/SCDream8.otf b/src/common/assets/fonts/s-core/SCDream8.otf new file mode 100644 index 0000000..5d7e4be Binary files /dev/null and b/src/common/assets/fonts/s-core/SCDream8.otf differ diff --git a/src/common/assets/fonts/s-core/SCDream9.otf b/src/common/assets/fonts/s-core/SCDream9.otf new file mode 100644 index 0000000..f6db18f Binary files /dev/null and b/src/common/assets/fonts/s-core/SCDream9.otf differ diff --git "a/src/common/assets/fonts/s-core/\354\227\220\354\212\244\354\275\224\354\226\264 \353\223\234\353\246\274_\353\235\274\354\235\264\354\204\240\354\212\244.png" "b/src/common/assets/fonts/s-core/\354\227\220\354\212\244\354\275\224\354\226\264 \353\223\234\353\246\274_\353\235\274\354\235\264\354\204\240\354\212\244.png" new file mode 100644 index 0000000..d06ffa0 Binary files /dev/null and "b/src/common/assets/fonts/s-core/\354\227\220\354\212\244\354\275\224\354\226\264 \353\223\234\353\246\274_\353\235\274\354\235\264\354\204\240\354\212\244.png" differ diff --git a/src/common/router/blogRouter.tsx b/src/common/router/blogRouter.tsx new file mode 100644 index 0000000..c5b6a3c --- /dev/null +++ b/src/common/router/blogRouter.tsx @@ -0,0 +1,14 @@ +import {Suspense} from "react"; +import {Blog, Loading} from "./page.tsx"; + +const blogRouter = () => { + + return [ + { + path: ':username', + element: + }, + ]; +} + +export default blogRouter; \ No newline at end of file diff --git a/src/router/page.tsx b/src/common/router/page.tsx similarity index 63% rename from src/router/page.tsx rename to src/common/router/page.tsx index 6379c43..63bcab4 100644 --- a/src/router/page.tsx +++ b/src/common/router/page.tsx @@ -14,5 +14,17 @@ export const Loading = ; -export const Main = lazy(() => import('../pages/MainPage')); -export const Login = lazy(() => import('../pages/member/LoginPage')); \ No newline at end of file +export const Main = lazy(() => import('../../pages/MainPage.tsx')); +export const Login = lazy(() => import('../../pages/member/LoginPage.tsx')); +export const Platform = lazy(() => import('../../pages/member/PlatformPage.tsx')); +export const Email = lazy(() => import('../../pages/member/EmailPage.tsx')); +export const Code = lazy(() => import('../../pages/member/CodePage.tsx')); +export const Signup = lazy(() => import('../../pages/member/SignupPage.tsx')); +export const Setting = lazy(() => import('../../pages/setting/SettingPage.tsx')); + +export const Search = lazy(() => import('../../pages/post/SearchPage.tsx')); + +export const Post = lazy(() => import('../../pages/post/PostPage.tsx')); +export const Write = lazy(() => import('../../pages/post/WritePage.tsx')); + +export const Blog = lazy(() => import('../../pages/blog/BlogPage.tsx')); diff --git a/src/common/router/postRouter.tsx b/src/common/router/postRouter.tsx new file mode 100644 index 0000000..1188aa8 --- /dev/null +++ b/src/common/router/postRouter.tsx @@ -0,0 +1,14 @@ +import {Suspense} from "react"; +import {Loading, Post} from "./page.tsx"; + +const postRouter = () => { + + return [ + { + path: ':id', + element: + }, + ]; +} + +export default postRouter; \ No newline at end of file diff --git a/src/common/router/root.tsx b/src/common/router/root.tsx new file mode 100644 index 0000000..467853e --- /dev/null +++ b/src/common/router/root.tsx @@ -0,0 +1,43 @@ +import {createBrowserRouter} from "react-router-dom"; +import {Suspense} from "react"; +import {Loading, Login, Main, Search, Setting, Write} from "./page.tsx"; +import postRouter from "./postRouter.tsx"; +import blogRouter from "./blogRouter.tsx"; +import signupRouter from "./signupRouter.tsx"; + +const root = createBrowserRouter([ + { + path: '', + element:
+ }, + { + path: '/login', + element: + }, + { + path: '/setting', + element: + }, + { + path: '/search', + element: + }, + { + path: '/write', + element: + }, + { + path: '/signup', + children: signupRouter() + }, + { + path: '/post', + children: postRouter() + }, + { + path: '/blog', + children: blogRouter() + }, +]); + +export default root; \ No newline at end of file diff --git a/src/common/router/signupRouter.tsx b/src/common/router/signupRouter.tsx new file mode 100644 index 0000000..f8c46b6 --- /dev/null +++ b/src/common/router/signupRouter.tsx @@ -0,0 +1,26 @@ +import {Code, Email, Loading, Platform, Signup} from "./page.tsx"; +import {Suspense} from "react"; + +const signupRouter = () => { + + return [ + { + path: 'platform', + element: + }, + { + path: 'email', + element: + }, + { + path: 'code', + element: + }, + { + path: '', + element: + }, + ]; +} + +export default signupRouter; \ No newline at end of file diff --git a/src/slices/loginSlice.tsx b/src/common/slices/loginSlice.tsx similarity index 63% rename from src/slices/loginSlice.tsx rename to src/common/slices/loginSlice.tsx index 7040b2e..6b84a14 100644 --- a/src/slices/loginSlice.tsx +++ b/src/common/slices/loginSlice.tsx @@ -1,11 +1,11 @@ import {createSlice} from "@reduxjs/toolkit"; const initialState = { - isLogin: false, - accessToken: "", - email: "", - username: "", - roles: [], + isLogin: true, + accessToken: "token", + email: "diglog@example.com", + username: "diglog", + roles: ["ROLE_USER"], } const loginSlice = createSlice({ @@ -24,9 +24,15 @@ const loginSlice = createSlice({ logout: () => { return initialState; }, + setUsername: (state, action) => { + return { + ...state, + username: action.payload.username, + }; + }, } }); -export const {login, logout} = loginSlice.actions; +export const {login, logout, setUsername} = loginSlice.actions; export default loginSlice.reducer; \ No newline at end of file diff --git a/src/common/types/common.tsx b/src/common/types/common.tsx new file mode 100644 index 0000000..9f428c4 --- /dev/null +++ b/src/common/types/common.tsx @@ -0,0 +1,6 @@ +export interface PageResponse { + size: number; + number: number; + totalElements: number; + totalPages: number; +} \ No newline at end of file diff --git a/src/common/types/post.tsx b/src/common/types/post.tsx new file mode 100644 index 0000000..b77f423 --- /dev/null +++ b/src/common/types/post.tsx @@ -0,0 +1,21 @@ +import {PageResponse} from "./common.tsx"; + +export interface PostListResponse { + content: PostResponse[]; + page: PageResponse; +} + +export interface PostResponse { + id: string; + title: string; + content: string; + username: string; + tags: TagResponse[]; + createdAt: Date; +} + +export interface TagResponse { + id: string; + name: string; +} + diff --git a/src/common/util/date.tsx b/src/common/util/date.tsx new file mode 100644 index 0000000..9980acb --- /dev/null +++ b/src/common/util/date.tsx @@ -0,0 +1,27 @@ +export const fullDateToKorean = (date: Date) => { + const outputDate = new Date(date); + return outputDate.getFullYear() + '년 ' + + (outputDate.getMonth() + 1) + '월 ' + + outputDate.getDate() + '일 ' + + outputDate.getHours().toString().padStart(2, '0') + ':' + + outputDate.getMinutes().toString().padStart(2, '0'); +} + +export const dateToKorean = (date: Date) => { + const outputDate = new Date(date); + return outputDate.getFullYear() + '년 ' + + (outputDate.getMonth() + 1) + '월 ' + + outputDate.getDate() + '일'; +} + +export const shortDateToKorean = (date: Date) => { + const outputDate = new Date(date); + return (outputDate.getMonth() + 1) + '월 ' + + outputDate.getDate() + '일 '; +} + +export const formatTimer = (seconds: number) => { + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + return `${String(minutes).padStart(2, '0')}:${String(remainingSeconds).padStart(2, '0')}`; +}; \ No newline at end of file diff --git a/src/common/util/html.tsx b/src/common/util/html.tsx new file mode 100644 index 0000000..2cec434 --- /dev/null +++ b/src/common/util/html.tsx @@ -0,0 +1,12 @@ +export const removeImgTags = (content: string) => { + const doc = new DOMParser().parseFromString(content, 'text/html'); + const imgTags = doc.querySelectorAll('img'); + imgTags.forEach((img) => img.remove()); + return doc.body.innerHTML; +} + +export const getImgSrc = (content: string) => { + const doc = new DOMParser().parseFromString(content, 'text/html'); + const imgTag = doc.querySelector('img'); + return imgTag ? imgTag.src : null; +} \ No newline at end of file diff --git a/src/common/util/regex.tsx b/src/common/util/regex.tsx new file mode 100644 index 0000000..fc95398 --- /dev/null +++ b/src/common/util/regex.tsx @@ -0,0 +1,10 @@ +export const checkEmail = (email: string) => { + const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return email !== "" && email.match(regex); +} + +export const checkPassword = (password: string) => { + const regex = /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d\W_]{8,16}$/; + + return password !== "" && password.match(regex); +} \ No newline at end of file diff --git a/src/components/common/FillButton.tsx b/src/components/common/FillButton.tsx new file mode 100644 index 0000000..46a3652 --- /dev/null +++ b/src/components/common/FillButton.tsx @@ -0,0 +1,25 @@ +import {Link} from "react-router-dom"; + +const className = " bg-lime-500 hover:bg-lime-400 text-white font-bold py-2 px-4 rounded hover:cursor-pointer"; + +export function FillButton({text, onClick, addStyle}: { text: string, onClick: () => void, addStyle?: string }) { + return ( + + ); +} + +export function FillLink({text, to}: { text: string, to: string }) { + return ( + + {text} + + ); +} + +export function LoadMoreButton({onClick, addStyle}: { onClick: () => void, addStyle?: string }) { + return ( + + ); +} \ No newline at end of file diff --git a/src/components/common/Footer.tsx b/src/components/common/Footer.tsx new file mode 100644 index 0000000..1d3b6a8 --- /dev/null +++ b/src/components/common/Footer.tsx @@ -0,0 +1,15 @@ +import {FaGithub} from "react-icons/fa"; +import {Link} from "react-router-dom"; + +function Footer() { + return ( +
+ + + +
+ ); +} + +export default Footer; \ No newline at end of file diff --git a/src/components/common/Header.tsx b/src/components/common/Header.tsx index 27564c1..b9d9617 100644 --- a/src/components/common/Header.tsx +++ b/src/components/common/Header.tsx @@ -1,32 +1,110 @@ -import {Link} from "react-router-dom"; +import {Link, useNavigate} from "react-router-dom"; import {useSelector} from "react-redux"; import {RootState} from "../../store.tsx"; +import {faker} from "@faker-js/faker/locale/ko"; +import {useEffect, useRef, useState} from "react"; +import {TextLink} from "./TextButton.tsx"; +import {MdOutlineSearch} from "react-icons/md"; +import IconButton from "./IconButton.tsx"; +import {logoutApi} from "../../common/apis/member.tsx"; +import {logout} from "../../common/slices/loginSlice.tsx"; function Header() { const loginState = useSelector((state: RootState) => state.loginSlice); + const navigate = useNavigate(); + + const dashboardRef = useRef(null); + const [isOpen, setIsOpen] = useState(false); + + const handleDropDown = () => { + setIsOpen(cur => !cur); + } + + const handleClickOutside = (event: MouseEvent) => { + if (dashboardRef.current && !dashboardRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + + const handleLogout = () => { + logoutApi(loginState.email) + .then(() => { + logout(); + }) + .catch((error) => { + alert(`알 수 없는 에러 : ${error.message}`); + }) + .finally(() => { + navigate(0); + }); + } + + useEffect(() => { + window.scrollTo(0, 0); + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); return ( -
- -
- Logo +
+ + logo +
+ DIGLOG
- {loginState.isLogin - ?
-
- {loginState.email}, {loginState.username} -
-
- : -
- 로그인 +
+ } + onClick={() => navigate("/search")}/> + {loginState.isLogin + ?
+
+ user_image +
+
+
{loginState.username}
+
{loginState.email}
+
+
+ + 게시글 작성 + + + 내 블로그 + + + 설정 + +
+
+ +
+
+
- } + :
+ + +
} +
); } diff --git a/src/components/common/IconButton.tsx b/src/components/common/IconButton.tsx new file mode 100644 index 0000000..4c9b0f6 --- /dev/null +++ b/src/components/common/IconButton.tsx @@ -0,0 +1,11 @@ +import {ReactNode} from 'react'; + +function IconButton({icon, onClick}: { icon: ReactNode; onClick: () => void }) { + return ( + + ); +} + +export default IconButton; \ No newline at end of file diff --git a/src/components/common/OutlineButton.tsx b/src/components/common/OutlineButton.tsx new file mode 100644 index 0000000..f2735e0 --- /dev/null +++ b/src/components/common/OutlineButton.tsx @@ -0,0 +1,19 @@ +import {Link} from "react-router-dom"; + +const className = "bg-transparent hover:bg-lime-300 text-lime-700 font-semibold hover:text-white py-2 px-4 border border-lime-500 hover:border-transparent rounded hover:cursor-pointer"; + +export function OutlineButton({text, onClick, addStyle}: { text: string, onClick?: () => void, addStyle?: string }) { + return ( + + ); +} + +export function OutlineLink({text, to}: { text: string, to: string }) { + return ( + + {text} + + ); +} \ No newline at end of file diff --git a/src/components/common/PaginationButton.tsx b/src/components/common/PaginationButton.tsx new file mode 100644 index 0000000..c8f8082 --- /dev/null +++ b/src/components/common/PaginationButton.tsx @@ -0,0 +1,61 @@ +import {PageResponse} from "../../common/types/common.tsx"; +import {MdArrowLeft, MdArrowRight} from "react-icons/md"; +import {useEffect, useState} from "react"; + +function PaginationButton({pageInfo, setPage}: { + pageInfo: PageResponse, + setPage: (page: number) => void, +}) { + + const paginationSize = 5; + + const [pageList, setPageList] = useState([]); + const [startPage, setStartPage] = useState(0); + + const getArray = (start: number, size: number) => { + return Array.from({length: size}, (_, index) => index + start); + } + + useEffect(() => { + if (pageInfo.totalPages < startPage + paginationSize) { + setPageList(getArray(startPage, pageInfo.totalPages - startPage)); + } else { + setPageList(getArray(startPage, paginationSize)); + } + }, [startPage]); + + return ( +
+ + {pageList.map((page) => ( + + ))} + +
+ ); +} + +export default PaginationButton; \ No newline at end of file diff --git a/src/components/common/TextButton.tsx b/src/components/common/TextButton.tsx new file mode 100644 index 0000000..64f7e80 --- /dev/null +++ b/src/components/common/TextButton.tsx @@ -0,0 +1,20 @@ +import {Link} from "react-router-dom"; + +const className = " flex justify-center items-center bg-transparent py-2 px-4 hover:cursor-pointer"; + +export function TextButton({text, onClick, addStyle}: { text: string, onClick?: () => void, addStyle?: string }) { + return ( + + ); +} + +export function TextLink({text, to, addStyle}: { text: string, to: string, addStyle?: string }) { + + return ( + + {text} + + ); +} \ No newline at end of file diff --git a/src/components/member/LoginButton.tsx b/src/components/member/LoginButton.tsx new file mode 100644 index 0000000..3167e69 --- /dev/null +++ b/src/components/member/LoginButton.tsx @@ -0,0 +1,19 @@ +import {ReactNode} from "react"; + +function LoginButton({text, onClick, bgColor, icon}: { + text: string, + onClick: () => void, + bgColor: string, + icon?: ReactNode +}) { + return ( + + ); +} + +export default LoginButton; \ No newline at end of file diff --git a/src/components/member/LoginTextField.tsx b/src/components/member/LoginTextField.tsx new file mode 100644 index 0000000..e41c4d2 --- /dev/null +++ b/src/components/member/LoginTextField.tsx @@ -0,0 +1,26 @@ +import * as React from "react"; + +function LoginTextField({label, type, placeholder, value, setValue, onKeyDown}: { + label?: string, + type: string, + placeholder: string, + value: string, + setValue: (value: string) => void, + onKeyDown?: (event: React.KeyboardEvent) => void, +}) { + return ( +
+ + setValue(event.target.value))} + onKeyDown={onKeyDown}/> +
+ ); +} + +export default LoginTextField; \ No newline at end of file diff --git a/src/components/post/PostCard.tsx b/src/components/post/PostCard.tsx new file mode 100644 index 0000000..31f8c8a --- /dev/null +++ b/src/components/post/PostCard.tsx @@ -0,0 +1,47 @@ +import {PostResponse} from "../../common/types/post.tsx"; +import {getImgSrc, removeImgTags} from "../../common/util/html.tsx"; +import DOMPurify from "dompurify"; +import {MdImage} from "react-icons/md"; +import {dateToKorean} from "../../common/util/date.tsx"; +import {Link} from "react-router-dom"; + +function PostCard(post: PostResponse) { + + const safeContent = DOMPurify.sanitize(post.content); + const url = getImgSrc(safeContent); + + return ( +
+ + {(url) + ? post_image + :
+ +
} + +
+
+ + {post.username} + +
+ {dateToKorean(post.createdAt)} +
+
+ +
+ {post.title} +
+ +
+ {removeImgTags(safeContent)} +
+
+
+ ); +} + +export default PostCard; \ No newline at end of file diff --git a/src/components/post/TagCard.tsx b/src/components/post/TagCard.tsx new file mode 100644 index 0000000..1902c5e --- /dev/null +++ b/src/components/post/TagCard.tsx @@ -0,0 +1,14 @@ +import {TagResponse} from "../../common/types/post.tsx"; + +function TagCard({tag, onClick}: { tag: TagResponse, onClick: (tagName: string) => void }) { + return ( + + ); +} + +export default TagCard; \ No newline at end of file diff --git a/src/components/post/TagChip.tsx b/src/components/post/TagChip.tsx new file mode 100644 index 0000000..ea9a5d4 --- /dev/null +++ b/src/components/post/TagChip.tsx @@ -0,0 +1,14 @@ +import {MdOutlineClear} from "react-icons/md"; + +function TagChip({name, removeTag}: { name: string, removeTag: (selectTag: string) => void }) { + return ( + + ); +} + +export default TagChip; \ No newline at end of file diff --git a/src/components/setting/CategoryAddModal.tsx b/src/components/setting/CategoryAddModal.tsx new file mode 100644 index 0000000..d539b3d --- /dev/null +++ b/src/components/setting/CategoryAddModal.tsx @@ -0,0 +1,94 @@ +import ModalLayout from "../../layout/ModalLayout.tsx"; +import {useState} from "react"; +import {FillButton} from "../common/FillButton.tsx"; +import {CategoryType} from "../../pages/setting/SettingPage.tsx"; +import {TextButton} from "../common/TextButton.tsx"; +import {MdOutlineArrowDropDown} from "react-icons/md"; + +function CategoryAddModal({categories, setShowCategoryAddModal}: { + categories: CategoryType[], + setShowCategoryAddModal: (modal: boolean) => void +}) { + + const [inputCategory, setInputCategory] = useState(""); + const [categoryOpen, setCategoryOpen] = useState(false); + const [selectedCategory, setSelectedCategory] = useState("블로그"); + + return ( + +
+

카테고리 추가

+ +
+
+ +
+ {categories.map((category) => { + if (!category.subCategories) { + return
+ +
+ } else { + return
+ + {category.subCategories.map((subCategory: CategoryType) => + + )} +
; + } + })} +
+ setInputCategory(e.target.value)} + placeholder="카테고리 이름을 입력해주세요." + className="border border-gray-400 p-4 font-bold"/> +
+ setShowCategoryAddModal(false)}/> + { + alert("카테고리가 추가되었습니다."); + setShowCategoryAddModal(false); + }}/> +
+
+
+ ); +} + +export default CategoryAddModal; \ No newline at end of file diff --git a/src/components/setting/CategoryCard.tsx b/src/components/setting/CategoryCard.tsx new file mode 100644 index 0000000..ef83fc8 --- /dev/null +++ b/src/components/setting/CategoryCard.tsx @@ -0,0 +1,102 @@ +import {CategoryType} from "../../pages/setting/SettingPage.tsx"; +import {DndContext, DragEndEvent} from "@dnd-kit/core"; +import {SortableContext, useSortable} from "@dnd-kit/sortable"; +import {CSS} from "@dnd-kit/utilities"; +import {MdOutlineMenu} from "react-icons/md"; +import {TextButton} from "../common/TextButton.tsx"; +import {restrictToVerticalAxis} from "@dnd-kit/modifiers"; +import {useState} from "react"; +import {FillButton} from "../common/FillButton.tsx"; + +function CategoryCard({setSelectedCategory, category, setShowModal, handleDrag, isHover, handleHover, isSub}: { + setSelectedCategory: (category: CategoryType) => void, + category: CategoryType, + setShowModal: (modal: boolean) => void, + handleDrag?: (event: DragEndEvent) => void, + isHover: boolean, + handleHover: (hover: boolean) => void, + isSub?: boolean +}) { + + const [isEdit, setIsEdit] = useState(false); + const [categoryNameInput, setCategoryNameInput] = useState(category.name); + + const {attributes, listeners, setNodeRef, transform, transition} = useSortable({id: category.id}); + + const style = { + transform: CSS.Translate.toString(transform), + transition, + }; + + const handleCategoryNameChange = () => { + category.name = categoryNameInput; + setIsEdit(false); + } + + return ( +
+
{ + if (isSub) { + handleHover(true); + } + }} + onMouseLeave={() => { + if (isSub) { + handleHover(false); + } + }}> + + {(isEdit) + ? { + setCategoryNameInput(e.target.value) + }}/> + :

{category.name}

} +
+ {(isSub && !isEdit) && + { + setSelectedCategory(category); + setShowModal(true); + }}/>} + {(isEdit) + ? + : setIsEdit(true)}/>} +
+
+ {category.subCategories && ( + + + {category.subCategories.map((subCategory) => ( + + ))} + + + )} +
+ ); +} + +export default CategoryCard; \ No newline at end of file diff --git a/src/components/setting/CategoryMoveModal.tsx b/src/components/setting/CategoryMoveModal.tsx new file mode 100644 index 0000000..a7b62d6 --- /dev/null +++ b/src/components/setting/CategoryMoveModal.tsx @@ -0,0 +1,53 @@ +import {CategoryType} from "../../pages/setting/SettingPage.tsx"; +import {FillButton} from "../common/FillButton.tsx"; +import {useState} from "react"; +import {TextButton} from "../common/TextButton.tsx"; +import {useSelector} from "react-redux"; +import {RootState} from "../../store.tsx"; +import ModalLayout from "../../layout/ModalLayout.tsx"; + +function CategoryMoveModal({selectedCategory, categories, handleCategoryMove, setShowModal}: { + selectedCategory: CategoryType | null, + categories: CategoryType[], + handleCategoryMove: (categoryId: string) => void + setShowModal: (showModal: boolean) => void, +}) { + + const loginState = useSelector((state: RootState) => state.loginSlice); + + const [selectedCategoryId, setSelectedCategoryId] = useState(""); + + const handleCategoryChange = () => { + handleCategoryMove(selectedCategoryId); + + setShowModal(false); + } + + return ( + +
+

+ "{selectedCategory?.name}" 카테고리를 이동할 곳을 골라주세요. +

+ + {categories.map(category => ( + + ))} +
+ setShowModal(false)}/> + +
+
+
+ ); +} + +export default CategoryMoveModal; \ No newline at end of file diff --git a/src/index.css b/src/index.css index a461c50..143a76d 100644 --- a/src/index.css +++ b/src/index.css @@ -1 +1,69 @@ -@import "tailwindcss"; \ No newline at end of file +@import "tailwindcss"; + +@theme { + --font-*: initial; + --default-font-family: 'SCDream'; + --font-jalnan: 'Jalnan2'; +} + +@font-face { + font-family: 'Jalnan2'; + src: url('./common/assets/fonts/Jalnan2.otf'); + font-weight: 400; + font-style: normal; +} + +@font-face { + font-family: 'SCDream'; + src: url('./common/assets/fonts/s-core/SCDream1.otf'); + font-weight: 100; + font-style: normal; +} +@font-face { + font-family: 'SCDream'; + src: url('./common/assets/fonts/s-core/SCDream2.otf'); + font-weight: 200; + font-style: normal; +} +@font-face { + font-family: 'SCDream'; + src: url('./common/assets/fonts/s-core/SCDream3.otf'); + font-weight: 300; + font-style: normal; +} +@font-face { + font-family: 'SCDream'; + src: url('./common/assets/fonts/s-core/SCDream4.otf'); + font-weight: 400; + font-style: normal; +} +@font-face { + font-family: 'SCDream'; + src: url('./common/assets/fonts/s-core/SCDream5.otf'); + font-weight: 500; + font-style: normal; +} +@font-face { + font-family: 'SCDream'; + src: url('./common/assets/fonts/s-core/SCDream6.otf'); + font-weight: 600; + font-style: normal; +} +@font-face { + font-family: 'SCDream'; + src: url('./common/assets/fonts/s-core/SCDream7.otf'); + font-weight: 700; + font-style: normal; +} +@font-face { + font-family: 'SCDream'; + src: url('./common/assets/fonts/s-core/SCDream8.otf'); + font-weight: 800; + font-style: normal; +} +@font-face { + font-family: 'SCDream'; + src: url('./common/assets/fonts/s-core/SCDream9.otf'); + font-weight: 900; + font-style: normal; +} \ No newline at end of file diff --git a/src/layout/BasicLayout.tsx b/src/layout/BasicLayout.tsx index 3f7072c..1803c7b 100644 --- a/src/layout/BasicLayout.tsx +++ b/src/layout/BasicLayout.tsx @@ -1,14 +1,16 @@ import {ReactNode} from "react"; import Header from "../components/common/Header.tsx"; +import Footer from "../components/common/Footer.tsx"; -function BasicLayout({children}: { children: ReactNode }) { +function BasicLayout({children, center}: { children: ReactNode, center?: boolean }) { return ( -
-
-
+
+
+
{children}
+
); } diff --git a/src/layout/ModalLayout.tsx b/src/layout/ModalLayout.tsx new file mode 100644 index 0000000..131b304 --- /dev/null +++ b/src/layout/ModalLayout.tsx @@ -0,0 +1,14 @@ +import {ReactNode} from "react"; + +function ModalLayout({children}: { children: ReactNode }) { + return ( +
+
+
+ {children} +
+
+ ); +} + +export default ModalLayout; \ No newline at end of file diff --git a/src/pages/MainPage.tsx b/src/pages/MainPage.tsx index 7fb8a3e..25406f4 100644 --- a/src/pages/MainPage.tsx +++ b/src/pages/MainPage.tsx @@ -1,9 +1,11 @@ import BasicLayout from "../layout/BasicLayout.tsx"; import {useEffect} from "react"; -import {getProfile} from "../apis/member.tsx"; -import {login} from "../slices/loginSlice.tsx"; +import {getProfile} from "../common/apis/member.tsx"; +import {login} from "../common/slices/loginSlice.tsx"; import {useDispatch, useSelector} from "react-redux"; import {RootState} from "../store.tsx"; +import PostCard from "../components/post/PostCard.tsx"; +import {faker} from "@faker-js/faker/locale/ko"; function MainPage() { @@ -25,10 +27,39 @@ function MainPage() { }); }, []); + return ( -
- MainPage +
+
+ 인기 있는 게시글 +
+
+ {[Array.from({length: 6}).map(() => ( + `} + username={faker.animal.cat()} + tags={[{ + id: faker.number.int().toString(), + name: faker.word.sample() + }, {id: faker.number.int().toString(), name: faker.word.sample()}]} + createdAt={new Date()}/> + ))]} + +
); diff --git a/src/pages/blog/BlogPage.tsx b/src/pages/blog/BlogPage.tsx new file mode 100644 index 0000000..8e33a3a --- /dev/null +++ b/src/pages/blog/BlogPage.tsx @@ -0,0 +1,201 @@ +import BasicLayout from "../../layout/BasicLayout.tsx"; +import {useParams, useSearchParams} from "react-router-dom"; +import {faker} from "@faker-js/faker/locale/ko"; +import PostCard from "../../components/post/PostCard.tsx"; +import TagCard from "../../components/post/TagCard.tsx"; +import {useEffect, useRef, useState} from "react"; +import {MdMenu, MdOutlineExitToApp} from "react-icons/md"; +import PaginationButton from "../../components/common/PaginationButton.tsx"; +import TagChip from "../../components/post/TagChip.tsx"; + +function BlogPage() { + + const {username} = useParams(); + + const [searchParams, setSearchParams] = useSearchParams({"category": ""}); + + const [isOpen, setIsOpen] = useState(false); + const [selectedCategory, setSelectedCategory] = useState(searchParams.get("category") || ""); + const [selectedTagList, setSelectedTagList] = useState([]); + + const mainRef = useRef(null); + const sideBarRef = useRef(null); + + const handleMenuOpen = () => { + setIsOpen(cur => !cur); + } + const handleClickOutside = (event: MouseEvent) => { + if (sideBarRef.current && !sideBarRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + + const addTag = (selectTag: string) => { + setSelectedTagList([...selectedTagList, selectTag]); + } + + const removeTag = (selectTag: string) => { + setSelectedTagList(prevState => prevState.filter(tag => tag !== selectTag)); + } + + const removeCategory = (selectCategory: string) => { + console.log(selectCategory); + setSelectedCategory(""); + } + + useEffect(() => { + if (sideBarRef.current && mainRef.current) { + sideBarRef.current.style.height = `${mainRef.current.offsetHeight + 220}px`; + } + }, []); + + + useEffect(() => { + document.addEventListener('mousedown', handleClickOutside); + document.title = username || "DIGLOG"; + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + document.title = "DIGLOG"; + }; + }, []); + + useEffect(() => { + setSearchParams({ + "category": selectedCategory + }) + }, [selectedCategory]); + + useEffect(() => { + if (isOpen) { + document.body.classList.remove('overflow-x-hidden'); + } else { + document.body.classList.add('overflow-x-hidden'); + } + + return () => { + document.body.classList.remove('overflow-x-hidden'); + }; + }, [isOpen]); + + return ( + +
+
+
{username}의 블로그
+ +
+
+
+ 카테고리 {selectedCategory !== "" && } +
+
+ 태그 {selectedTagList.map((tag) => )} +
+
+
+
+ {[Array.from({length: 3}).map(() => ( + `} + username={faker.animal.cat()} + tags={[{ + id: faker.number.int().toString(), + name: faker.word.sample() + }, {id: faker.number.int().toString(), name: faker.word.sample()}]} + createdAt={new Date()}/> + ))]} + { + }}/> +
+
+ +
+
+
+
+ + +
+
+ ); +} + +function SideBar({username, addTag, setSelectedCategory, bgColor}: { + username: string | undefined, + addTag: (tagName: string) => void, + setSelectedCategory: (category: string) => void, + bgColor?: string +}) { + + return ( +
+
+ username +
+ {username} +
+
+
+
카테고리
+
+ {[Array.from({length: 10}).map((_, i) => ( +
+ +
+ {Array.from({length: 3}).map((_, i) => ( + + ))} +
+
+ ))]} +
+
+
+
태그
+
+ {[Array.from({length: 24}).map(() => ( + + ))]} +
+
+
+ ); +} + +export default BlogPage; \ No newline at end of file diff --git a/src/pages/member/CodePage.tsx b/src/pages/member/CodePage.tsx new file mode 100644 index 0000000..5eaed50 --- /dev/null +++ b/src/pages/member/CodePage.tsx @@ -0,0 +1,109 @@ +import React, {useEffect, useRef, useState} from "react"; +import BasicLayout from "../../layout/BasicLayout.tsx"; +import {useLocation, useNavigate} from "react-router-dom"; +import {formatTimer} from "../../common/util/date.tsx"; +import LoadingLayout from "../../layout/LoadingLayout.tsx"; + +function CodePage() { + + const {state} = useLocation(); + const {email} = state; + const navigate = useNavigate(); + + const [loading, setLoading] = useState(false); + + const inputRefs = useRef([]); + const [code, setCode] = useState(Array(6).fill("")); + + const [timer, setTimer] = useState(600); + + const handleChange = (index: number, value: string) => { + if (/[^0-9]/g.test(value)) { + return; + } + + const newCode = [...code]; + + newCode[index] = value.replace(/[^0-9]/g, ""); + setCode(newCode); + + if (value && index < inputRefs.current.length - 1) { + inputRefs.current[index + 1].focus(); + } + }; + + const handleBackspace = (event: React.KeyboardEvent, index: number) => { + if (event.key === "Backspace" && !event.currentTarget.value && index > 0) { + inputRefs.current[index - 1].focus(); + } + }; + + useEffect(() => { + if (inputRefs.current) { + inputRefs.current[0].focus(); + } + }, []); + + useEffect(() => { + let interval = null; + + if (timer > 0) { + interval = setInterval(() => { + setTimer((prev) => prev - 1); + }, 1000); + } else if (timer === 0) { + clearInterval(interval!); + alert("유효 시간이 만료되었습니다. 회원가입을 다시 시도해주세요."); + } + + return () => clearInterval(interval!); + }, [timer]); + + useEffect(() => { + if (code[5] !== "") { + setLoading(true); + + navigate("/signup", {state: {email: email, code: code.join("")}}); + } + }, [code]); + + return ( + +
+
+

{email} 로 보내진

+

인증코드 6자리를 입력해주세요.

+
+
+ {code.map((digit, index) => ( + + { + if (el) { + inputRefs.current[index] = el; + } + }} + type=" text" + maxLength={1} + className="w-12 h-16 rounded-lg font-bold text-lg appearance-none border border-gray-200 text-center focus:outline-1" + value={digit} + onChange={(e) => handleChange(index, e.target.value)} + onKeyDown={(e) => { + handleBackspace(e, index) + }} + /> + + ))} +
+
+ 유효시간 : {formatTimer(timer)} +
+
+ +
+ ) + ; +} + +export default CodePage; \ No newline at end of file diff --git a/src/pages/member/EmailPage.tsx b/src/pages/member/EmailPage.tsx new file mode 100644 index 0000000..e834a00 --- /dev/null +++ b/src/pages/member/EmailPage.tsx @@ -0,0 +1,49 @@ +import BasicLayout from "../../layout/BasicLayout.tsx"; +import LoginTextField from "../../components/member/LoginTextField.tsx"; +import {useState} from "react"; +import LoginButton from "../../components/member/LoginButton.tsx"; +import {useNavigate} from "react-router-dom"; +import * as React from "react"; +import {checkEmail} from "../../common/util/regex.tsx"; + +function EmailPage() { + + const [signupInfo, setSignupInfo] = useState({email: "", code: "", password: ""}); + + const navigate = useNavigate(); + + const handleEmailEnter = (event: React.KeyboardEvent) => { + if (event.key !== "Enter") { + return; + } + + handleVerifyEmail(); + } + + const handleVerifyEmail = () => { + if (!checkEmail(signupInfo.email)) { + alert("이메일 형식을 확인해주세요."); + return; + } + + navigate("/signup/code", {state: {email: signupInfo.email}}); + } + + return ( + +
+ setSignupInfo({...signupInfo, email: value})} + onKeyDown={handleEmailEnter}/> + +
+
+ ); +} + +export default EmailPage; \ No newline at end of file diff --git a/src/pages/member/LoginPage.tsx b/src/pages/member/LoginPage.tsx index abd1aa0..0d00df2 100644 --- a/src/pages/member/LoginPage.tsx +++ b/src/pages/member/LoginPage.tsx @@ -2,8 +2,13 @@ import BasicLayout from "../../layout/BasicLayout.tsx"; import {useNavigate} from "react-router-dom"; import {useState} from "react"; import {useDispatch} from "react-redux"; -import {handleKakaoLogin, loginApi} from "../../apis/member.tsx"; -import {login} from "../../slices/loginSlice.tsx"; +import {handleKakaoLogin, loginApi} from "../../common/apis/member.tsx"; +import {login} from "../../common/slices/loginSlice.tsx"; +import LoginButton from "../../components/member/LoginButton.tsx"; +import LoginTextField from "../../components/member/LoginTextField.tsx"; +import {checkEmail} from "../../common/util/regex.tsx"; +import * as React from "react"; +import {TextLink} from "../../components/common/TextButton.tsx"; function LoginPage() { @@ -28,26 +33,50 @@ function LoginPage() { }) } + const handlePasswordEnter = (event: React.KeyboardEvent) => { + if (event.key !== "Enter") { + return; + } + if (!checkEmail(loginInfo.email)) { + alert("이메일 형식을 확인해주세요."); + return; + } + + handleLogin(); + } + return ( - -
- setLoginInfo({...loginInfo, email: e.target.value})}/> - setLoginInfo({...loginInfo, password: e.target.value})}/> - - + +
+
+ logo +
+ DIGLOG +
+
+
+ setLoginInfo({...loginInfo, email: value})}/> + setLoginInfo({...loginInfo, password: value})} + onKeyDown={handlePasswordEnter}/> +
+
+ + }/> + +
); diff --git a/src/pages/member/PlatformPage.tsx b/src/pages/member/PlatformPage.tsx new file mode 100644 index 0000000..77a588b --- /dev/null +++ b/src/pages/member/PlatformPage.tsx @@ -0,0 +1,19 @@ +import BasicLayout from "../../layout/BasicLayout.tsx"; +import LoginButton from "../../components/member/LoginButton.tsx"; +import {handleKakaoLogin} from "../../common/apis/member.tsx"; +import {TextLink} from "../../components/common/TextButton.tsx"; + +function PlatformPage() { + + return ( + +
+ }/> + +
+
+ ); +} + +export default PlatformPage; \ No newline at end of file diff --git a/src/pages/member/SignupPage.tsx b/src/pages/member/SignupPage.tsx new file mode 100644 index 0000000..b5aa726 --- /dev/null +++ b/src/pages/member/SignupPage.tsx @@ -0,0 +1,69 @@ +import BasicLayout from "../../layout/BasicLayout.tsx"; +import {useLocation, useNavigate} from "react-router-dom"; +import {useState} from "react"; +import LoginTextField from "../../components/member/LoginTextField.tsx"; +import {checkPassword} from "../../common/util/regex.tsx"; +import LoadingLayout from "../../layout/LoadingLayout.tsx"; +import * as React from "react"; +import LoginButton from "../../components/member/LoginButton.tsx"; + +function SignupPage() { + + const navigate = useNavigate(); + const {state} = useLocation(); + const {email, code} = state; + + const [loading, setLoading] = useState(false); + const [passwordInfo, setPasswordInfo] = useState({password: "", confirmPassword: ""}); + + const handlePasswordEnter = (event: React.KeyboardEvent) => { + if (event.key !== "Enter") { + return; + } + + handleSignup(); + } + + const handleSignup = () => { + setLoading(true); + console.log(email, code); + + if (!checkPassword(passwordInfo.password) || passwordInfo.password !== passwordInfo.confirmPassword) { + setLoading(false); + return; + } + + alert("회원가입 되었습니다."); + + navigate("/login"); + } + + return ( + +
+

비밀번호 설정을 마치면 회원가입이 완료됩니다.

+
+ setPasswordInfo({...passwordInfo, password: value})}/> + setPasswordInfo({...passwordInfo, confirmPassword: value})} + onKeyDown={handlePasswordEnter}/> +
+ +
+ +
+ ) + ; +} + +export default SignupPage; \ No newline at end of file diff --git a/src/pages/post/PostPage.tsx b/src/pages/post/PostPage.tsx new file mode 100644 index 0000000..4dfcc50 --- /dev/null +++ b/src/pages/post/PostPage.tsx @@ -0,0 +1,67 @@ +import {Link, useNavigate, useParams} from "react-router-dom"; +import BasicLayout from "../../layout/BasicLayout.tsx"; +import {PostResponse} from "../../common/types/post.tsx"; +import {faker} from "@faker-js/faker/locale/ko"; +import DOMPurify from "dompurify"; +import {fullDateToKorean} from "../../common/util/date.tsx"; +import TagCard from "../../components/post/TagCard.tsx"; + +function PostPage() { + + const {id} = useParams(); + console.log(id); + const navigate = useNavigate(); + + const post: PostResponse = { + id: faker.number.int().toString(), + title: faker.lorem.sentence(), + content: `
${faker.lorem.paragraphs()}
`, + username: faker.animal.cat(), + tags: [{id: "1", name: faker.lorem.word()}, {id: "2", name: faker.lorem.word()}], + createdAt: new Date(), + }; + + const safeContent = DOMPurify.sanitize(post.content); + + return ( + +
+
+
+ Home +
{` > `}
+ 카테고리 +
{` > `}
+
{post.title}
+
+
+ + {post.username} + +
+ {fullDateToKorean(post.createdAt)} +
+
+
+ {post.title} +
+
+ {post.tags.map(tag => + { + navigate(`/search?word=${tag.name}&option=태그`) + }}/>)} +
+
+ +
+
+
+
+
+ + ); +} + +export default PostPage; \ No newline at end of file diff --git a/src/pages/post/SearchPage.tsx b/src/pages/post/SearchPage.tsx new file mode 100644 index 0000000..129bab1 --- /dev/null +++ b/src/pages/post/SearchPage.tsx @@ -0,0 +1,266 @@ +import BasicLayout from "../../layout/BasicLayout.tsx"; +import {MdArrowDropDown, MdOutlineClear, MdOutlineSearch} from "react-icons/md"; +import {Ref, useEffect, useRef, useState} from "react"; +import * as React from "react"; +import PostCard from "../../components/post/PostCard.tsx"; +import {faker} from "@faker-js/faker/locale/ko"; +import {Link, useSearchParams} from "react-router-dom"; +import {LoadMoreButton} from "../../components/common/FillButton.tsx"; + +function SearchPage() { + + const optionRef = useRef(null); + const sortRef = useRef(null); + + const [openOption, setOpenOption] = useState(false); + const [openSort, setOpenSort] = useState(false); + + const [searchParams, setSearchParams] = useSearchParams({"word": "", "option": "전체", "sort": "최신순", "tab": "게시글"}); + const [searchWord, setSearchWord] = useState(searchParams.get("word") || ""); + const [option, setOption] = useState(searchParams.get("option") || "전체"); + const [sort, setSort] = useState(searchParams.get("sort") || "최신순"); + const [selectedTab, setSelectedTab] = useState(searchParams.get("tab") || "게시글"); + + const handleOpenOption = () => { + setOpenOption(cur => !cur); + } + const handleOpenSort = () => { + setOpenSort(cur => !cur); + } + + const handleClickOutside = (event: MouseEvent) => { + if (optionRef.current && !optionRef.current.contains(event.target as Node)) { + setOpenOption(false); + } + if (sortRef.current && !sortRef.current.contains(event.target as Node)) { + setOpenSort(false); + } + }; + + const handleSearchEnter = (event: React.KeyboardEvent) => { + if (event.key !== "Enter") { + return; + } + handleSearchWord(); + } + + const handleSearchWord = () => { + if (searchWord === "") { + return; + } + + setSearchParams({ + "word": searchWord, + "option": option, + "sort": sort, + "tab": selectedTab, + }); + } + + useEffect(() => { + setSearchParams({ + "word": searchWord, + "option": option, + "sort": sort, + "tab": selectedTab, + }); + }, [option, sort, selectedTab]); + + useEffect(() => { + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + + return ( + +
+
+
+
+ setSearchWord(e.target.value)} + placeholder={"검색어를 입력해주세요."} + className="w-full block mt-0.5 p-3 mr-4 font-jalnan text-xl text-gray-900 border-b-2 border-white focus:outline-none focus:border-black" + onKeyDown={handleSearchEnter}/> + + +
+
+
+
+
+
99개의 검색결과
+
+ + +
+
+ + +
+
+
+ ); +} + +function SearchTab({selectedTab, setSelectedTab}: { + selectedTab: string, + setSelectedTab: (tab: string) => void, +}) { + + const tabs = ["게시글", "블로그"]; + + return ( +
+
    + {tabs.map((tab) => ( +
  • + +
  • + ))} +
+
+ ); +} + +function SearchResults({selectedTab}: { selectedTab: string }) { + + if (selectedTab === "게시글") { + return ( +
+ {(Array.from({length: 5}).map(() => ( + `} + username={faker.animal.cat()} + tags={[{ + id: faker.number.int().toString(), + name: faker.word.sample() + }, {id: faker.number.int().toString(), name: faker.word.sample()}]} + createdAt={new Date()}/> + )))} + { + }} addStyle={"w-full"}/> +
+ ); + } else if (selectedTab === "블로그") { + return ( +
+ {(Array.from({length: 5}).map(() => ( + + username +
+
{faker.animal.dog()}
+
{faker.animal.dog()}
+
{faker.animal.dog()}
+
+ + )))} + { + }}/> +
+ ); + } + + return <>; +} + +function SearchMenu({type, open, handleOpen, value, setValue, customRef}: { + type: string, + open: boolean, + handleOpen: () => void, + value: string, + setValue: (newOption: string) => void, + customRef: Ref, +}) { + + let title = ""; + let searchMenuList: string[] = []; + if (type === "option") { + title = "검색 조건"; + searchMenuList = ["전체", "제목", "내용", "태그", "작성자"]; + } else if (type === "sort") { + title = "정렬 조건"; + searchMenuList = ["최신순", "오래된순", "조회순"]; + } + + return ( +
+
+

{title}

+
+ +
+
    + {searchMenuList.map((menu) => ( + + ))} +
+
+
+
+
+ ); +} + +function SortMenu({value, current, setValue, handleOpen}: { + value: string, + current: string, + setValue: (newSort: string) => void, + handleOpen: () => void +}) { + return ( +
  • + +
  • + ); +} + +export default SearchPage; \ No newline at end of file diff --git a/src/pages/post/WritePage.tsx b/src/pages/post/WritePage.tsx new file mode 100644 index 0000000..919a164 --- /dev/null +++ b/src/pages/post/WritePage.tsx @@ -0,0 +1,234 @@ +import BasicLayout from "../../layout/BasicLayout.tsx"; +import {useEffect, useRef, useState} from "react"; +import {useBlocker, useNavigate} from "react-router-dom"; +import {Editor} from "@tinymce/tinymce-react"; +import {FillButton} from "../../components/common/FillButton.tsx"; +import {useSelector} from "react-redux"; +import {RootState} from "../../store.tsx"; +import {faker} from "@faker-js/faker/locale/ko"; +import {MdOutlineArrowDropDown, MdOutlineClear} from "react-icons/md"; + +interface WritePostType { + inputTag: string; + tags: string[]; + title: string; + content: string; +} + +interface CategoryType { + name: string; + subCategories?: CategoryType[]; +} + +function WritePage() { + + const loginState = useSelector((state: RootState) => state.loginSlice); + const navigate = useNavigate(); + const categoryRef = useRef(null); + + const [post, setPost] = useState({ + inputTag: "", + tags: [], + title: "", + content: "", + }); + const [showTag, setShowTag] = useState(false); + + const [categoryOpen, setCategoryOpen] = useState(false); + const [selectedCategory, setSelectedCategory] = useState("카테고리 선택"); + const [uploadCount, setUploadCount] = useState(0); + const [exitPage, setExitPage] = useState(false); + const handleCategoryOpen = () => { + setCategoryOpen(prev => !prev); + } + + const removeTag = (tag: string | null) => { + setPost({...post, tags: post.tags.filter(prevTag => prevTag !== tag)}); + } + const handleTag = (tag: string) => { + if (!tag.endsWith(",")) { + setPost({...post, inputTag: tag}); + return; + } + if (post.tags.includes(tag.substring(0, tag.length - 1))) { + setPost({...post, inputTag: ""}); + return; + } + setPost({...post, tags: [...post.tags, post.inputTag], inputTag: ""}); + } + + const handleSubmit = () => { + if (uploadCount > 0) { + alert("업로드 중인 이미지가 있습니다. 잠시만 기다려주세요."); + return; + } + + alert("작성되었습니다."); + setExitPage(true); + navigate(`/blog/${loginState.username}username`); + } + + const handleClickOutside = (event: MouseEvent) => { + if (categoryRef.current && !categoryRef.current.contains(event.target as Node)) { + setCategoryOpen(false); + } + }; + + // 뒤로가기 방지 + useBlocker(() => { + return (!!post.title || !!post.content) && + exitPage && + !confirm("페이지를 이동하시겠습니까?\n\n작성중인 내용이 저장되지 않습니다."); + } + ); + + // 새로고침 방지 + useEffect(() => { + const handleBeforeUnload = (event: BeforeUnloadEvent) => { + event.preventDefault(); + }; + + document.addEventListener('mousedown', handleClickOutside); + window.addEventListener('beforeunload', handleBeforeUnload); + + return () => { + window.removeEventListener('beforeunload', handleBeforeUnload); + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + + const categoryData: CategoryType[] = [ + { + name: faker.lorem.words(), + subCategories: [ + {name: faker.lorem.words()}, + {name: faker.lorem.words()} + ] + }, + { + name: faker.lorem.words(), + }, + { + name: faker.lorem.words(), + subCategories: [ + {name: faker.lorem.words()}, + {name: faker.lorem.words()}, + {name: faker.lorem.words()} + ] + }, + ]; + + return ( + +
    +
    +
    + +
    + {categoryData.map((category) => { + if (!category.subCategories) { + return
    + +
    + } else { + return
    + + {category.subCategories.map((subCategory: CategoryType) => + + )} +
    ; + } + })} +
    +
    +
    +
    + setPost({...post, title: e.target.value})} + placeholder={"제목을 입력해주세요."} + className="w-full py-2 font-jalnan text-2xl text-gray-900 border-b-2 border-white focus:outline-none focus:border-black"/> +
    +
    +
    + {post.tags.map((tag, i) => + )} + handleTag(event.target.value)} + onFocus={() => setShowTag(true)} + onBlur={() => setShowTag(false)}/> +
    + {showTag &&
    + 쉼표를 입력하여 태그를 등록할 수 있습니다. +
    } +
    + { + setUploadCount((prev) => prev + 1); + // const res = await uploadImage(blobInfo); + // return res.data.url; + console.log("Image Upload...... ", blobInfo); + setUploadCount((prev) => prev - 1); + return faker.image.url(); + } + }} + value={post.content} + onEditorChange={(content) => setPost({...post, content: content})} + /> +
    + +
    +
    +
    + ); +} + +export default WritePage; \ No newline at end of file diff --git a/src/pages/setting/CategorySettingPage.tsx b/src/pages/setting/CategorySettingPage.tsx new file mode 100644 index 0000000..53bd333 --- /dev/null +++ b/src/pages/setting/CategorySettingPage.tsx @@ -0,0 +1,58 @@ +import {DndContext, DragEndEvent} from "@dnd-kit/core"; +import {restrictToVerticalAxis} from "@dnd-kit/modifiers"; +import {SortableContext} from "@dnd-kit/sortable"; +import {FillButton} from "../../components/common/FillButton.tsx"; +import {CategoryType} from "./SettingPage.tsx"; +import CategoryCard from "../../components/setting/CategoryCard.tsx"; +import {OutlineButton} from "../../components/common/OutlineButton.tsx"; + +function CategorySettingPage({ + setSelectedCategory, + categories, + setShowModal, + setShowCategoryAddModal, + handleDragEnd, + isHover, + handleHover, + submitCategoryChange + }: { + setSelectedCategory: (category: CategoryType) => void, + categories: CategoryType[], + setShowModal: (modal: boolean) => void, + setShowCategoryAddModal: (modal: boolean) => void, + handleDragEnd: (event: DragEndEvent) => void, + isHover: boolean, + handleHover: (hover: boolean) => void, + submitCategoryChange: () => void, +}) { + + return ( +
    +

    카테고리 관리

    +
    + + + {categories.map((category) => ( +
    + +
    + ))} +
    +
    +
    +
    + setShowCategoryAddModal(true)} addStyle={"font-normal"}/> + +
    +
    + ); +} + +export default CategorySettingPage; \ No newline at end of file diff --git a/src/pages/setting/PostSettingPage.tsx b/src/pages/setting/PostSettingPage.tsx new file mode 100644 index 0000000..f035013 --- /dev/null +++ b/src/pages/setting/PostSettingPage.tsx @@ -0,0 +1,9 @@ +function PostSettingPage() { + return ( +
    +

    게시글 관리

    +
    + ); +} + +export default PostSettingPage; \ No newline at end of file diff --git a/src/pages/setting/ProfileSettingPage.tsx b/src/pages/setting/ProfileSettingPage.tsx new file mode 100644 index 0000000..3c382d8 --- /dev/null +++ b/src/pages/setting/ProfileSettingPage.tsx @@ -0,0 +1,122 @@ +import {useDispatch, useSelector} from "react-redux"; +import {RootState} from "../../store.tsx"; +import {faker} from "@faker-js/faker/locale/ko"; +import {MdOutlineEdit} from "react-icons/md"; +import {ChangeEvent, useRef, useState} from "react"; +import {FillButton} from "../../components/common/FillButton.tsx"; +import {setUsername} from "../../common/slices/loginSlice.tsx"; +import {TextButton} from "../../components/common/TextButton.tsx"; + +function ProfileSettingPage() { + + const loginState = useSelector((state: RootState) => state.loginSlice); + const dispatch = useDispatch(); + const fileInputRef = useRef(null); + + const [input, setInput] = useState(""); + const [image, setImage] = useState(null); + const [isUsernameEdit, setIsUsernameEdit] = useState(false); + const [isImageEdit, setIsImageEdit] = useState(false); + + const handleUsernameEdit = () => { + setIsUsernameEdit(true); + } + + const handleUsernameEditCancel = () => { + setIsUsernameEdit(false); + } + + const handleUsernameSubmit = () => { + alert("변경되었습니다."); + dispatch(setUsername({ + username: input, + })); + setIsUsernameEdit(false); + } + + const handleImageEdit = () => { + setIsImageEdit(true); + fileInputRef.current!.click(); + } + + const handleImageChange = (event: ChangeEvent) => { + if (event.target.files && event.target.files[0]) { + setImage(URL.createObjectURL(event.target.files[0])); + } + } + + const handleImageEditCancel = () => { + setIsImageEdit(false); + } + + const handleImageSubmit = () => { + alert("변경되었습니다."); + setIsImageEdit(false); + } + + return ( +
    +

    프로필 관리

    +
    +
    +
    +
    + {isImageEdit && image + ? Edit Profile Image + : Profile Image} +
    + + + +
    +
    + {isImageEdit &&
    + + +
    } +
    +
    + {isUsernameEdit + ?
    + setInput(e.target.value)} + placeholder="Username"/> + + +
    + :
    +
    +

    + {loginState.username} +

    + +
    } +
    +
    +
    + ); +} + +export default ProfileSettingPage; \ No newline at end of file diff --git a/src/pages/setting/SettingPage.tsx b/src/pages/setting/SettingPage.tsx new file mode 100644 index 0000000..87f39e9 --- /dev/null +++ b/src/pages/setting/SettingPage.tsx @@ -0,0 +1,209 @@ +import BasicLayout from "../../layout/BasicLayout.tsx"; +import {useState} from "react"; +import {faker} from "@faker-js/faker/locale/ko"; +import {DragEndEvent} from "@dnd-kit/core"; +import {arrayMove} from "@dnd-kit/sortable"; +import {useNavigate} from "react-router-dom"; +import CategorySettingPage from "./CategorySettingPage.tsx"; +import PostSettingPage from "./PostSettingPage.tsx"; +import ProfileSettingPage from "./ProfileSettingPage.tsx"; +import CategoryMoveModal from "../../components/setting/CategoryMoveModal.tsx"; +import CategoryAddModal from "../../components/setting/CategoryAddModal.tsx"; + +export interface CategoryType { + id: string; + name: string; + subCategories?: CategoryType[]; +} + +function SettingPage() { + + const navigate = useNavigate(); + + const [showCategoryModal, setShowCategoryModal] = useState(false); + const [showCategoryAddModal, setShowCategoryAddModal] = useState(false); + + const tabList = ["프로필", "카테고리", "게시글"]; + const categoryData: CategoryType[] = [ + { + id: "1", + name: faker.lorem.words(), + subCategories: [ + {id: "4", name: faker.lorem.words()}, + {id: "5", name: faker.lorem.words()} + ] + }, + { + id: "2", + name: faker.lorem.words(), + }, + { + id: "3", + name: faker.lorem.words(), + subCategories: [ + {id: "6", name: faker.lorem.words()}, + {id: "7", name: faker.lorem.words()}, + {id: "8", name: faker.lorem.words()} + ] + }, + ]; + + const [selectedTab, setSelectedTab] = useState("프로필"); + const [categories, setCategories] = useState(categoryData); + const [selectedCategory, setSelectedCategory] = useState(null); + const [isHover, setIsHover] = useState(false); + + const handleHover = (hover: boolean) => { + setIsHover(hover); + } + + const handleDragEnd = (event: DragEndEvent) => { + const {active, over} = event; + + if (!over || active.id === over.id) { + return; + } + + let categoryIndex = -1; + let oldIndex = -1; + let newIndex = -1; + + oldIndex = categories.findIndex(category => category.id === active.id); + newIndex = categories.findIndex(category => category.id === over.id); + + if (oldIndex !== -1 && newIndex !== -1) { + setCategories(category => { + return arrayMove(category, oldIndex, newIndex); + }); + return; + } + + for (const category of categories) { + if (category.subCategories) { + oldIndex = category.subCategories.findIndex(category => category.id === active.id); + newIndex = category.subCategories.findIndex(category => category.id === over.id); + + if (oldIndex !== -1 && newIndex !== -1) { + categoryIndex = categories.indexOf(category); + break; + } + } + } + + if (categoryIndex !== -1 && oldIndex !== -1 && newIndex !== -1) { + setCategories(currentCategories => { + const updatedSubCategories = [...currentCategories[categoryIndex].subCategories!]; + const [movedCategory] = updatedSubCategories.splice(oldIndex, 1); + updatedSubCategories.splice(newIndex, 0, movedCategory); + + const updatedCategories = [...currentCategories]; + updatedCategories[categoryIndex] = { + ...updatedCategories[categoryIndex], + subCategories: updatedSubCategories + }; + + return updatedCategories; + }); + } + } + + const handleCategoryMove = (categoryId: string) => { + setCategories(prevCategories => { + const newCategories = [...prevCategories]; + let categoryToMove; + let targetCategory: CategoryType | undefined; + + newCategories.forEach(category => { + if (category.subCategories) { + const subCategoryIndex = category.subCategories.findIndex(sub => sub.id === selectedCategory?.id); + if (subCategoryIndex !== -1) { + categoryToMove = category.subCategories[subCategoryIndex]; + category.subCategories.splice(subCategoryIndex, 1); + } + } + + if (category.id === categoryId) { + targetCategory = category; + } + }); + + if (!categoryToMove) { + return prevCategories; + } + + if (categoryId === "top") { + newCategories.push(categoryToMove); + return newCategories; + } + + if (!targetCategory) { + return prevCategories; + } + + if (!targetCategory.subCategories) { + targetCategory.subCategories = []; + } + + targetCategory.subCategories.push(categoryToMove); + + return newCategories; + }); + } + + const submitCategoryChange = () => { + if (confirm("변경사항을 저장하시겠습니까?")) { + alert("저장되었습니다."); + navigate(0); + } + } + + return ( + +
    +
    +
      + {tabList.map((tab) => +
    • + +
    • )} +
    +
    + {(selectedTab === "카테고리") && +
    + +
    } + {(selectedTab === "게시글") && +
    + +
    } + {(selectedTab === "프로필") && +
    + +
    } +
    + {showCategoryModal && } + {showCategoryAddModal && } +
    + ); +} + +export default SettingPage; \ No newline at end of file diff --git a/src/router/root.tsx b/src/router/root.tsx deleted file mode 100644 index b371c63..0000000 --- a/src/router/root.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import {createBrowserRouter} from "react-router-dom"; -import {Suspense} from "react"; -import {Loading, Login, Main} from "./page.tsx"; - -const root = createBrowserRouter([ - { - path: '', - element:
    - }, - { - path: '/login', - element: - }, -]); - -export default root; \ No newline at end of file diff --git a/src/store.tsx b/src/store.tsx index 9385c45..df571e2 100644 --- a/src/store.tsx +++ b/src/store.tsx @@ -1,5 +1,5 @@ import {configureStore} from "@reduxjs/toolkit"; -import loginSlice from "./slices/loginSlice.tsx"; +import loginSlice from "./common/slices/loginSlice.tsx"; const store = configureStore({ reducer: {