diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 000000000..2558d65ff --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,55 @@ +name: Deploy to GitHub Pages +on: + push: + branches: + - main + +permissions: + contents: read + pages: write + id-token: write + +jobs: + deploy: + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v2 + with: + version: latest + + - name: Setup Node + uses: actions/setup-node@v3 + with: + node-version: "20" + cache: "pnpm" + + - name: Install dependencies + run: pnpm install + + - name: Build with Vite + run: pnpm run build + + - name: Create 404 page + run: cp dist/index.html dist/404.html + + - name: Disable Jekyll + run: touch dist/.nojekyll + + - name: Setup Pages + uses: actions/configure-pages@v4 + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: "./dist" + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/package.json b/package.json index fcb2757eb..37e7836c4 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,10 @@ "coverage": "vitest run --coverage" }, "dependencies": { + "@tanstack/react-query": "^5.74.7", "react": "^19.1.0", - "react-dom": "^19.1.0" + "react-dom": "^19.1.0", + "zustand": "^5.0.3" }, "devDependencies": { "@eslint/js": "^9.25.1", @@ -41,5 +43,6 @@ "vite": "^6.3.3", "vitest": "^3.1.2", "vitest-browser-react": "^0.1.1" - } + }, + "packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e1c5650db..8bc4d5686 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,12 +8,18 @@ importers: .: dependencies: + '@tanstack/react-query': + specifier: ^5.74.7 + version: 5.74.7(react@19.1.0) react: specifier: ^19.1.0 version: 19.1.0 react-dom: specifier: ^19.1.0 version: 19.1.0(react@19.1.0) + zustand: + specifier: ^5.0.3 + version: 5.0.3(@types/react@19.1.2)(react@19.1.0) devDependencies: '@eslint/js': specifier: ^9.25.1 @@ -899,6 +905,14 @@ packages: cpu: [x64] os: [win32] + '@tanstack/query-core@5.74.7': + resolution: {integrity: sha512-X3StkN/Y6KGHndTjJf8H8th7AX4bKfbRpiVhVqevf0QWlxl6DhyJ0TYG3R0LARa/+xqDwzU9mA4pbJxzPCI29A==} + + '@tanstack/react-query@5.74.7': + resolution: {integrity: sha512-u4o/RIWnnrq26orGZu2NDPwmVof1vtAiiV6KYUXd49GuK+8HX+gyxoAYqIaZogvCE1cqOuZAhQKcrKGYGkrLxg==} + peerDependencies: + react: ^18 || ^19 + '@testing-library/dom@10.4.0': resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==} engines: {node: '>=18'} @@ -2229,6 +2243,24 @@ packages: resolution: {integrity: sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==} engines: {node: '>=18'} + zustand@5.0.3: + resolution: {integrity: sha512-14fwWQtU3pH4dE0dOpdMiWjddcH+QzKIgk1cl8epwSE7yag43k/AD/m4L6+K7DytAOr9gGBe3/EXj9g7cdostg==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + snapshots: '@adobe/css-tools@4.4.0': {} @@ -2936,6 +2968,13 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.40.0': optional: true + '@tanstack/query-core@5.74.7': {} + + '@tanstack/react-query@5.74.7(react@19.1.0)': + dependencies: + '@tanstack/query-core': 5.74.7 + react: 19.1.0 + '@testing-library/dom@10.4.0': dependencies: '@babel/code-frame': 7.26.2 @@ -3177,7 +3216,7 @@ snapshots: '@vitest/utils@2.1.3': dependencies: '@vitest/pretty-format': 2.1.3 - loupe: 3.1.2 + loupe: 3.1.3 tinyrainbow: 1.2.0 '@vitest/utils@3.1.2': @@ -4234,3 +4273,8 @@ snapshots: yocto-queue@0.1.0: {} yoctocolors-cjs@2.1.2: {} + + zustand@5.0.3(@types/react@19.1.2)(react@19.1.0): + optionalDependencies: + '@types/react': 19.1.2 + react: 19.1.0 diff --git a/src/App.tsx b/src/App.tsx deleted file mode 100644 index 82d35d55b..000000000 --- a/src/App.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { BrowserRouter as Router } from "react-router-dom" -import Header from "./widgets/ui/Header.tsx" -import Footer from "./widgets/ui/Footer.tsx" -import PostsManagerPage from "./pages/PostsManagerPage.tsx" - -const App = () => { - return ( - -
-
-
- -
-
-
- ) -} - -export default App diff --git a/src/app/App.tsx b/src/app/App.tsx new file mode 100644 index 000000000..884065521 --- /dev/null +++ b/src/app/App.tsx @@ -0,0 +1,25 @@ +import { PostsManagerPage } from "@/pages/posts/manager" + +import { Footer } from "@/widgets/common/footer" +import { Header } from "@/widgets/common/header" +import { BrowserRouter as Router } from "react-router-dom" +import { TanstackQueryProvider } from "./provider/tanstack-query" +import "./styles/index.css" + +const App = () => { + return ( + + +
+
+
+ +
+
+
+
+ ) +} + +export default App diff --git a/src/app/assets/react.svg b/src/app/assets/react.svg deleted file mode 100644 index 6c87de9bb..000000000 --- a/src/app/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/app/provider/tanstack-query/index.ts b/src/app/provider/tanstack-query/index.ts new file mode 100644 index 000000000..548b0980f --- /dev/null +++ b/src/app/provider/tanstack-query/index.ts @@ -0,0 +1 @@ +export { TanstackQueryProvider } from "./tanstack-query-provider"; diff --git a/src/app/provider/tanstack-query/tanstack-query-provider.tsx b/src/app/provider/tanstack-query/tanstack-query-provider.tsx new file mode 100644 index 000000000..8d01c3a9f --- /dev/null +++ b/src/app/provider/tanstack-query/tanstack-query-provider.tsx @@ -0,0 +1,7 @@ +import { queryClient } from "@/shared/api" +import { QueryClientProvider } from "@tanstack/react-query" +import * as React from "react" + +export const TanstackQueryProvider = ({ children }: { children: React.ReactNode }) => { + return {children} +} diff --git a/src/index.css b/src/app/styles/index.css similarity index 100% rename from src/index.css rename to src/app/styles/index.css diff --git a/src/entities/comment/api/comment.adapter.ts b/src/entities/comment/api/comment.adapter.ts new file mode 100644 index 000000000..25746c696 --- /dev/null +++ b/src/entities/comment/api/comment.adapter.ts @@ -0,0 +1,52 @@ +import { ApiClient } from "@/shared/api/api" +import { ApiResponse } from "@/shared/types" +import { CommentResponseDto } from "../dto/comment.dto" +import { CommentEntity } from "../types" + +export const commentAdapter = (apiClient: ApiClient) => ({ + listByPost: async (postId: number): Promise => { + return await apiClient + .get>(`/comments/post/${postId}`) + .then((response) => response.data) + .catch((error) => { + console.error("Comments List Error: ", error) + return error + }) + }, + create: async (body: string, postId: number, userId: number): Promise => { + return await apiClient + .post>(`/comments/add`, { body, postId, userId }) + .then((response) => response.data) + .catch((error) => { + console.error("Comment Create Error: ", error) + return error + }) + }, + update: async (id: number, body: string): Promise => { + return await apiClient + .put>(`/comments/${id}`, { body }) + .then((response) => response.data) + .catch((error) => { + console.error("Comment Update Error: ", error) + return error + }) + }, + remove: async (id: number): Promise => { + return await apiClient + .delete>(`/comments/${id}`) + .then((response) => response.ok) + .catch((error) => { + console.error("Comment Remove Error: ", error) + return error + }) + }, + likeComment: async (id: number): Promise => { + return await apiClient + .patch>(`/comments/${id}/like`) + .then((response) => response.ok) + .catch((error) => { + console.error("Comment Like Error: ", error) + return error + }) + }, +}) diff --git a/src/entities/comment/api/comment.query-key.ts b/src/entities/comment/api/comment.query-key.ts new file mode 100644 index 000000000..eb87c72c8 --- /dev/null +++ b/src/entities/comment/api/comment.query-key.ts @@ -0,0 +1,3 @@ +export const COMMENT_QUERY_KEY = { + byPostId: (postId: number) => ["comments", postId] as const, +} diff --git a/src/entities/comment/api/index.ts b/src/entities/comment/api/index.ts new file mode 100644 index 000000000..8563d57a9 --- /dev/null +++ b/src/entities/comment/api/index.ts @@ -0,0 +1,2 @@ +export { commentAdapter } from "./comment.adapter" +export { COMMENT_QUERY_KEY } from "./comment.query-key" diff --git a/src/entities/comment/core/comment.domain.ts b/src/entities/comment/core/comment.domain.ts new file mode 100644 index 000000000..051d76894 --- /dev/null +++ b/src/entities/comment/core/comment.domain.ts @@ -0,0 +1,77 @@ +import { CommentEntity, UserReference } from "@/entities/comment/types" +import { CommentBody, Timestamp, UserReferenceVO } from "../value-objects" + +/** + * Comment 도메인 엔티티 + * Value Object를 활용하여 비즈니스 규칙과 불변성을 강화 + */ +export class Comment implements CommentEntity { + private readonly _id: number + private _body: CommentBody + private readonly _postId: number + private readonly _user: UserReferenceVO + private readonly _createdAt: Timestamp + private _likes: number + private _updatedAt: Timestamp | null + + constructor( + id: number, + body: string, + postId: number, + user: UserReference, + createdAt: Date | string | number = new Date(), + likes: number = 0, + updatedAt: Date | string | number | null = null, + ) { + this._id = id + this._body = new CommentBody(body) + this._postId = postId + this._user = new UserReferenceVO(user) + this._createdAt = new Timestamp(createdAt) + this._likes = likes + this._updatedAt = updatedAt ? new Timestamp(updatedAt) : null + } + + updateBody(newBody: string): void { + this._body = new CommentBody(newBody) + this._updatedAt = Timestamp.now() + } + + like(): void { + this._likes += 1 + } + + unlike(): void { + if (this._likes > 0) { + this._likes -= 1 + } + } + + get id(): number { + return this._id + } + + get body(): string { + return this._body.text + } + + get postId(): number { + return this._postId + } + + get user(): UserReference { + return this._user.toDTO() + } + + get createdAt(): Date { + return this._createdAt.toDate() + } + + get updatedAt(): Date | null { + return this._updatedAt ? this._updatedAt.toDate() : null + } + + get likes(): number { + return this._likes + } +} diff --git a/src/entities/comment/core/comment.factory.ts b/src/entities/comment/core/comment.factory.ts new file mode 100644 index 000000000..bf0d68dd3 --- /dev/null +++ b/src/entities/comment/core/comment.factory.ts @@ -0,0 +1,37 @@ +import { CommentDto } from "@/entities/comment/dto" +import { UserReference } from "@/entities/comment/types" +import { Comment } from "./comment.domain" + +export class CommentFactory { + static createNew(body: string, postId: number, user: UserReference): Comment { + return new Comment( + 0, + body, + postId, + { + id: user.id, + username: user.username, + fullName: user.fullName, + }, + new Date(), // 현재 시간 + 0, // 좋아요 0개로 시작 + null, // 아직 수정되지 않음 + ) + } + + static fromDTO(dto: CommentDto): Comment { + return new Comment( + dto.id, + dto.body, + dto.postId, + dto.user, + new Date(), // 생성 시간 (API에서 제공하지 않는 경우 현재 시간 사용) + dto.likes || 0, + null, // 수정 시간 정보가 없는 경우 null + ) + } + + static fromDTOList(dtos: CommentDto[]): Comment[] { + return dtos.map((dto) => this.fromDTO(dto)) + } +} diff --git a/src/entities/comment/dto/comment.dto.ts b/src/entities/comment/dto/comment.dto.ts new file mode 100644 index 000000000..21e857d8f --- /dev/null +++ b/src/entities/comment/dto/comment.dto.ts @@ -0,0 +1,14 @@ +import { UserReference } from "@/entities/comment/types" +import { Pagination } from "@/shared/types" + +export type CommentDto = { + id: number + body: string + postId: number + likes: number + user: UserReference +} + +export type CommentResponseDto = Pagination & { + comments: CommentDto[] +} diff --git a/src/entities/comment/dto/index.ts b/src/entities/comment/dto/index.ts new file mode 100644 index 000000000..9dc4a8c73 --- /dev/null +++ b/src/entities/comment/dto/index.ts @@ -0,0 +1 @@ +export { type CommentDto, type CommentResponseDto } from "./comment.dto" diff --git a/src/entities/comment/repository/comment.repository.ts b/src/entities/comment/repository/comment.repository.ts new file mode 100644 index 000000000..88e9ba872 --- /dev/null +++ b/src/entities/comment/repository/comment.repository.ts @@ -0,0 +1,85 @@ +import { ApiClient } from "@/shared/api/api" +import { commentAdapter } from "../api/comment.adapter" +import { Comment } from "../core/comment.domain" +import { CommentFactory } from "../core/comment.factory" + +/** + * Comment 도메인 객체의 영속성을 담당하는 Repository 인터페이스 + */ +export interface CommentRepository { + getByPostId(postId: number): Promise + create(comment: Comment): Promise + update(comment: Comment): Promise + delete(id: number): Promise + like(id: number): Promise +} + +/** + * API를 통해 Comment 도메인 객체의 영속성을 구현하는 Repository + */ +export class CommentApiRepository implements CommentRepository { + private api: ReturnType + + constructor(apiClient: ApiClient) { + this.api = commentAdapter(apiClient) + } + + async getByPostId(postId: number): Promise { + try { + const { comments } = await this.api.listByPost(postId) + if (!comments || !comments.length) return [] + const adaptedComments = comments.map((apiComment) => ({ + id: apiComment.id, + body: apiComment.body, + postId: apiComment.postId, + user: apiComment.user, + likes: apiComment.likes || 0, + })) + + return adaptedComments.map((dto) => CommentFactory.fromDTO(dto)) + } catch (error) { + console.error("CommentRepository getByPostId Error:", error) + return [] + } + } + + async create(comment: Comment): Promise { + try { + const result = await this.api.create(comment.body, comment.postId, comment.user.id) + if (!result) return null + return CommentFactory.fromDTO(result) + } catch (error) { + console.error("CommentRepository create Error:", error) + return null + } + } + + async update(comment: Comment): Promise { + try { + const result = await this.api.update(comment.id, comment.body) + if (!result) return null + return CommentFactory.fromDTO(result) + } catch (error) { + console.error("CommentRepository update Error:", error) + return null + } + } + + async delete(id: number): Promise { + try { + return await this.api.remove(id) + } catch (error) { + console.error("CommentRepository delete Error:", error) + return false + } + } + + async like(id: number): Promise { + try { + return await this.api.likeComment(id) + } catch (error) { + console.error("CommentRepository like Error:", error) + return false + } + } +} diff --git a/src/entities/comment/repository/index.ts b/src/entities/comment/repository/index.ts new file mode 100644 index 000000000..f3312e1ca --- /dev/null +++ b/src/entities/comment/repository/index.ts @@ -0,0 +1 @@ +export { CommentApiRepository, type CommentRepository } from "./comment.repository" diff --git a/src/entities/comment/service/index.ts b/src/entities/comment/service/index.ts new file mode 100644 index 000000000..22ef68f78 --- /dev/null +++ b/src/entities/comment/service/index.ts @@ -0,0 +1 @@ +export { CommentMapperService } from "./mapper.service" diff --git a/src/entities/comment/service/mapper.service.ts b/src/entities/comment/service/mapper.service.ts new file mode 100644 index 000000000..806285a3b --- /dev/null +++ b/src/entities/comment/service/mapper.service.ts @@ -0,0 +1,26 @@ +import { Comment } from "../core/comment.domain" +import { CommentDto } from "../dto" + +export class CommentMapperService { + static toDto(comment: Comment): CommentDto { + return { + id: comment.id, + body: comment.body, + postId: comment.postId, + user: comment.user, + likes: comment.likes, + } + } + + static toDomain(dto: CommentDto): Comment { + return new Comment(dto.id, dto.body, dto.postId, dto.user, dto.likes) + } + + static toDomainList(dtos: CommentDto[]): Comment[] { + return dtos.map((dto) => this.toDomain(dto)) + } + + static toDtoList(comments: Comment[]): CommentDto[] { + return comments.map((comment) => this.toDto(comment)) + } +} diff --git a/src/entities/comment/store/comment.store.ts b/src/entities/comment/store/comment.store.ts new file mode 100644 index 000000000..78209815c --- /dev/null +++ b/src/entities/comment/store/comment.store.ts @@ -0,0 +1,25 @@ +import { CommentDto } from "@/entities/comment/dto" +import { create } from "zustand" + +type CommentStoreState = Pick & { + userId: number + + setBody: (body: string) => void + setPostId: (postId: number) => void + setUserId: (userId: number) => void + setSelectedComment: (postId: number, commentId: number, body: string) => void + reset: () => void +} + +export const useCommentStore = create((set) => ({ + body: "", + postId: 0, + userId: 0, + id: 0, + + setBody: (body) => set({ body }), + setPostId: (postId) => set({ postId }), + setUserId: (userId) => set({ userId }), + setSelectedComment: (postId: number, commentId: number, body: string) => set({ postId, id: commentId, body }), + reset: () => set({ body: "", postId: 0 }), +})) diff --git a/src/entities/comment/store/index.ts b/src/entities/comment/store/index.ts new file mode 100644 index 000000000..63e91d6d1 --- /dev/null +++ b/src/entities/comment/store/index.ts @@ -0,0 +1 @@ +export { useCommentStore } from "./comment.store" diff --git a/src/entities/comment/types/comment.types.ts b/src/entities/comment/types/comment.types.ts new file mode 100644 index 000000000..fc4f040db --- /dev/null +++ b/src/entities/comment/types/comment.types.ts @@ -0,0 +1,14 @@ +export interface UserReference { + id: number + username: string + fullName: string +} + +export interface CommentEntity { + body: string + id: number + likes: number + postId: number + user: UserReference + updateBody(newBody: string): void +} diff --git a/src/entities/comment/types/index.ts b/src/entities/comment/types/index.ts new file mode 100644 index 000000000..d3a79c488 --- /dev/null +++ b/src/entities/comment/types/index.ts @@ -0,0 +1 @@ +export { type CommentEntity, type UserReference } from "./comment.types" diff --git a/src/entities/comment/ui/comment-view/CommentView.tsx b/src/entities/comment/ui/comment-view/CommentView.tsx new file mode 100644 index 000000000..ec67642c5 --- /dev/null +++ b/src/entities/comment/ui/comment-view/CommentView.tsx @@ -0,0 +1,18 @@ +import { HighlightText } from "@/shared/ui" +import { CommentDto } from "../../dto" + +interface CommentViewProps extends React.HTMLAttributes { + comment: CommentDto + searchQuery: string +} + +export const CommentView: React.FC = ({ comment, searchQuery, ...props }) => { + return ( +
+ {comment.user.username}: + + + +
+ ) +} diff --git a/src/entities/comment/ui/index.ts b/src/entities/comment/ui/index.ts new file mode 100644 index 000000000..7e59b615e --- /dev/null +++ b/src/entities/comment/ui/index.ts @@ -0,0 +1 @@ +export { CommentView } from "./comment-view/CommentView" diff --git a/src/entities/comment/value-objects/comment-body.vo.ts b/src/entities/comment/value-objects/comment-body.vo.ts new file mode 100644 index 000000000..b050407e0 --- /dev/null +++ b/src/entities/comment/value-objects/comment-body.vo.ts @@ -0,0 +1,23 @@ +import { ValueObject } from "@/shared/domain/value-object" + +export class CommentBody extends ValueObject { + private static readonly MAX_LENGTH = 1000 + + constructor(value: string) { + super(value) + } + + protected validate(value: string): void { + if (!value || !value.trim()) { + throw new Error("댓글 내용은 비어있을 수 없습니다") + } + + if (value.length > CommentBody.MAX_LENGTH) { + throw new Error(`댓글 내용은 ${CommentBody.MAX_LENGTH}자를 초과할 수 없습니다`) + } + } + + public get text(): string { + return this.value + } +} diff --git a/src/entities/comment/value-objects/index.ts b/src/entities/comment/value-objects/index.ts new file mode 100644 index 000000000..1540ba736 --- /dev/null +++ b/src/entities/comment/value-objects/index.ts @@ -0,0 +1,3 @@ +export { CommentBody } from "./comment-body.vo" +export { Timestamp } from "./timestamp.vo" +export { UserReferenceVO } from "./user-reference.vo" diff --git a/src/entities/comment/value-objects/timestamp.vo.ts b/src/entities/comment/value-objects/timestamp.vo.ts new file mode 100644 index 000000000..11780e48d --- /dev/null +++ b/src/entities/comment/value-objects/timestamp.vo.ts @@ -0,0 +1,21 @@ +import { ValueObject } from "@/shared/domain/value-object" + +export class Timestamp extends ValueObject { + constructor(value: Date | string | number = new Date()) { + super(typeof value === "object" ? value : new Date(value)) + } + + protected validate(value: Date): void { + if (!(value instanceof Date) || isNaN(value.getTime())) { + throw new Error("유효하지 않은 날짜 형식입니다") + } + } + + public static now(): Timestamp { + return new Timestamp(new Date()) + } + + public toDate(): Date { + return new Date(this.value) + } +} diff --git a/src/entities/comment/value-objects/user-reference.vo.ts b/src/entities/comment/value-objects/user-reference.vo.ts new file mode 100644 index 000000000..88f40fed5 --- /dev/null +++ b/src/entities/comment/value-objects/user-reference.vo.ts @@ -0,0 +1,45 @@ +import { ValueObject } from "@/shared/domain/value-object" +import { UserReference } from "../types" + +/** + * 사용자 참조를 나타내는 Value Object + */ +export class UserReferenceVO extends ValueObject { + constructor(value: UserReference) { + super(value) + } + + protected validate(value: UserReference): void { + if (value === null || value === undefined) { + throw new Error("사용자 참조는 null이거나 undefined일 수 없습니다") + } + + if (typeof value.id !== "number" || value.id <= 0) { + throw new Error("사용자 ID는 양수여야 합니다") + } + + if (!value.username || !value.username.trim()) { + throw new Error("사용자명은 비어있을 수 없습니다") + } + } + + public get id(): number { + return this.value.id + } + + public get username(): string { + return this.value.username + } + + public get fullName(): string { + return this.value.fullName || this.value.username + } + + public toDTO(): UserReference { + return { + id: this.id, + username: this.username, + fullName: this.fullName, + } + } +} diff --git a/src/entities/post/api/index.ts b/src/entities/post/api/index.ts new file mode 100644 index 000000000..37e7b2bb7 --- /dev/null +++ b/src/entities/post/api/index.ts @@ -0,0 +1,2 @@ +export { POST_QUERY_KEY } from "./post-query-key.api" +export { postApi } from "./post.api" diff --git a/src/entities/post/api/post-query-key.api.ts b/src/entities/post/api/post-query-key.api.ts new file mode 100644 index 000000000..0bdfd5963 --- /dev/null +++ b/src/entities/post/api/post-query-key.api.ts @@ -0,0 +1,7 @@ +export const POST_QUERY_KEY = { + all: ["posts"] as const, + list: (params: { limit?: number; skip?: number }) => ["posts", "list", params] as const, + search: (query: string) => ["posts", "search", query] as const, + tag: (tag: string) => ["posts", "tag", tag] as const, + detail: (id: number) => ["post", id] as const, +} diff --git a/src/entities/post/api/post.api.ts b/src/entities/post/api/post.api.ts new file mode 100644 index 000000000..4a0a7ae46 --- /dev/null +++ b/src/entities/post/api/post.api.ts @@ -0,0 +1,70 @@ +import { ApiClient } from "@/shared/api/api" +import { ApiResponse } from "@/shared/types" +import { PostsResponseDto } from "../dto/post.dto" +import { Post, Tag } from "../types/post.types" + +export const postApi = (apiClient: ApiClient) => ({ + list: async (limit: number, skip: number): Promise => { + return await apiClient + .get>(`/posts?limit=${limit}&skip=${skip}`) + .then((response) => response.data) + .catch((error) => { + console.error("Posts List Error: ", error) + return error + }) + }, + listByTag: async (tag: string): Promise => { + return await apiClient + .get>(`/posts/tag/${tag}`) + .then((response) => response.data) + .catch((error) => { + console.error("Posts by Tag Error: ", error) + return error + }) + }, + getAllTags: async (): Promise => { + return await apiClient + .get>(`/posts/tags`) + .then((response) => response.data) + .catch((error) => { + console.error("Tags List Error: ", error) + return error + }) + }, + search: async (searchQuery: string): Promise => { + return await apiClient + .get>(`/posts/search?q=${searchQuery}`) + .then((response) => response.data) + .catch((error) => { + console.error("Posts Search Error: ", error) + return error + }) + }, + create: async (title: string, body: string, userId: number): Promise => { + return await apiClient + .post>(`/posts/add`, { title, body, userId }) + .then((response) => response.data) + .catch((error) => { + console.error("Post Create Error: ", error) + return error + }) + }, + update: async (post: Post): Promise => { + return await apiClient + .put>(`/posts/${post.id}`, post) + .then((response) => response.data) + .catch((error) => { + console.error("Post Update Error: ", error) + return error + }) + }, + remove: async (id: number): Promise => { + return await apiClient + .delete>(`/posts/${id}`) + .then((response) => response.data) + .catch((error) => { + console.error("Post Remove Error: ", error) + return error + }) + }, +}) diff --git a/src/entities/post/dto/post.dto.ts b/src/entities/post/dto/post.dto.ts new file mode 100644 index 000000000..4d3ee5e79 --- /dev/null +++ b/src/entities/post/dto/post.dto.ts @@ -0,0 +1,6 @@ +import { Pagination } from "@/shared/types" +import { Post } from "../types/post.types" + +export interface PostsResponseDto extends Pagination { + posts: Post[] +} diff --git a/src/entities/post/stores/index.ts b/src/entities/post/stores/index.ts new file mode 100644 index 000000000..26e5cc0c1 --- /dev/null +++ b/src/entities/post/stores/index.ts @@ -0,0 +1,3 @@ +export { usePostTotalStore } from "./post-total.stores" +export { usePostStore } from "./post.stores" +export { postsStore } from "./posts.stores" diff --git a/src/entities/post/stores/post-total.stores.ts b/src/entities/post/stores/post-total.stores.ts new file mode 100644 index 000000000..dfbe8acee --- /dev/null +++ b/src/entities/post/stores/post-total.stores.ts @@ -0,0 +1,11 @@ +import { create } from "zustand" + +interface PostTotalState { + total: number + setTotal: (total: number) => void +} + +export const usePostTotalStore = create((set) => ({ + total: 0, + setTotal: (total: number) => set({ total }), +})) diff --git a/src/entities/post/stores/post.stores.ts b/src/entities/post/stores/post.stores.ts new file mode 100644 index 000000000..b95fd4277 --- /dev/null +++ b/src/entities/post/stores/post.stores.ts @@ -0,0 +1,12 @@ +import { create } from "zustand" +import { PostWithAuthor } from "../types" + +type PostStoreState = { + selectedPost: PostWithAuthor | null + setSelectedPost: (post: PostWithAuthor | null) => void +} + +export const usePostStore = create((set) => ({ + selectedPost: null, + setSelectedPost: (post) => set({ selectedPost: post }), +})) diff --git a/src/entities/post/stores/posts.stores.ts b/src/entities/post/stores/posts.stores.ts new file mode 100644 index 000000000..8f2268029 --- /dev/null +++ b/src/entities/post/stores/posts.stores.ts @@ -0,0 +1,21 @@ +import { create } from "zustand" +import { PostWithAuthor } from "../types" + +interface PostsState { + posts: Array + setPosts: (posts: PostWithAuthor[]) => void + addPosts: (post: PostWithAuthor) => void + removePosts: (id: number) => void + updatePosts: (post: PostWithAuthor) => void +} + +export const postsStore = () => { + return create((set) => ({ + posts: [], + setPosts: (posts: PostWithAuthor[]) => set({ posts }), + addPosts: (post: PostWithAuthor) => set((state) => ({ posts: [...state.posts, post] })), + removePosts: (id: number) => set((state) => ({ posts: state.posts.filter((post) => post.id !== id) })), + updatePosts: (post: PostWithAuthor) => + set((state) => ({ posts: state.posts.map((p) => (p.id === post.id ? post : p)) })), + })) +} diff --git a/src/entities/post/types/index.ts b/src/entities/post/types/index.ts new file mode 100644 index 000000000..9e10e4b42 --- /dev/null +++ b/src/entities/post/types/index.ts @@ -0,0 +1 @@ +export type { Post, PostWithAuthor, Tag } from "./post.types" diff --git a/src/entities/post/types/post.types.ts b/src/entities/post/types/post.types.ts new file mode 100644 index 000000000..736999b0b --- /dev/null +++ b/src/entities/post/types/post.types.ts @@ -0,0 +1,26 @@ +import { UserDto } from "@/entities/user/dto/user.dto" + +export interface Post { + id: number + title: string + body: string + userId?: number + tags: string[] + reactions?: Reactions + views?: number +} + +export interface Tag { + name: string + slug: string + url: string +} + +interface Reactions { + likes: number + dislikes: number +} + +export interface PostWithAuthor extends Post { + author?: UserDto +} diff --git a/src/entities/post/ui/index.ts b/src/entities/post/ui/index.ts new file mode 100644 index 000000000..464bcc35b --- /dev/null +++ b/src/entities/post/ui/index.ts @@ -0,0 +1 @@ +export { TagView } from "./tag-view/TagView" diff --git a/src/entities/post/ui/tag-view/TagView.tsx b/src/entities/post/ui/tag-view/TagView.tsx new file mode 100644 index 000000000..0cbc864b3 --- /dev/null +++ b/src/entities/post/ui/tag-view/TagView.tsx @@ -0,0 +1,18 @@ +interface TagViewProps extends React.HTMLAttributes { + tag: string +} + +export const TagView: React.FC = ({ tag, ...props }) => { + return ( + + {tag} + + ) +} diff --git a/src/entities/user/api/index.ts b/src/entities/user/api/index.ts new file mode 100644 index 000000000..b4636334f --- /dev/null +++ b/src/entities/user/api/index.ts @@ -0,0 +1,2 @@ +export { userAdapter } from "./user.adapter" +export { USER_QUERY_KEY } from "./user.query-key" diff --git a/src/entities/user/api/user.adapter.ts b/src/entities/user/api/user.adapter.ts new file mode 100644 index 000000000..ffe147f21 --- /dev/null +++ b/src/entities/user/api/user.adapter.ts @@ -0,0 +1,24 @@ +import { ApiClient } from "@/shared/api/api" +import { ApiResponse } from "@/shared/types" +import { AllUserProfilesResponse, UserDto, UserProfileDto } from "../dto/user.dto" + +export const userAdapter = (apiClient: ApiClient) => ({ + list: async (): Promise => { + return await apiClient + .get>(`/users?limit=0&select=username,image`) + .then((response) => response.data) + .catch((error) => { + console.error("Users List Error: ", error) + return error + }) + }, + getProfile: async (userId: number): Promise => { + return await apiClient + .get>(`/users/${userId}`) + .then((response) => response.data) + .catch((error) => { + console.error("User Profile Error: ", error) + return error + }) + }, +}) diff --git a/src/entities/user/api/user.query-key.ts b/src/entities/user/api/user.query-key.ts new file mode 100644 index 000000000..3fa4e57fe --- /dev/null +++ b/src/entities/user/api/user.query-key.ts @@ -0,0 +1,3 @@ +export const USER_QUERY_KEY = { + profile: (id: number) => ["user", id] as const, +} diff --git a/src/entities/user/core/user.domain.ts b/src/entities/user/core/user.domain.ts new file mode 100644 index 000000000..4c34b8a19 --- /dev/null +++ b/src/entities/user/core/user.domain.ts @@ -0,0 +1,109 @@ +import { UserDto, UserProfileDto } from "@/entities/user/dto/user.dto" +import { AddressValue, CompanyValue, UserEntity } from "@/entities/user/types" + +export class User implements UserEntity { + private _id: number + private _username: string + private _image: string + private _firstName?: string + private _lastName?: string + private _fullName?: string + private _age?: number + private _email?: string + private _phone?: string + private _address?: AddressValue + private _company?: CompanyValue + + constructor( + id: number, + username: string, + image: string, + firstName?: string, + lastName?: string, + fullName?: string, + age?: number, + email?: string, + phone?: string, + address?: AddressValue, + company?: CompanyValue, + ) { + this._id = id + this._username = username + this._image = image + this._firstName = firstName + this._lastName = lastName + this._fullName = fullName || (firstName && lastName ? `${firstName} ${lastName}` : undefined) + this._age = age + this._email = email + this._phone = phone + this._address = address + this._company = company + } + + get id(): number { + return this._id + } + + get username(): string { + return this._username + } + + get image(): string { + return this._image + } + + get firstName(): string | undefined { + return this._firstName + } + + get lastName(): string | undefined { + return this._lastName + } + + get fullName(): string | undefined { + return this._fullName + } + + get age(): number | undefined { + return this._age + } + + get email(): string | undefined { + return this._email + } + + get phone(): string | undefined { + return this._phone + } + + get address(): AddressValue | undefined { + return this._address + } + + get company(): CompanyValue | undefined { + return this._company + } + + toDto(): UserDto { + return { + id: this.id, + username: this.username, + image: this.image, + } + } + toProfileDto(): UserProfileDto { + return { + id: this.id, + username: this.username, + image: this.image, + firstName: this.firstName, + lastName: this.lastName, + fullName: this.fullName, + age: this.age, + email: this.email, + phone: this.phone, + address: this.address, + company: this.company, + } + } +} diff --git a/src/entities/user/dto/address.dto.ts b/src/entities/user/dto/address.dto.ts new file mode 100644 index 000000000..86436f035 --- /dev/null +++ b/src/entities/user/dto/address.dto.ts @@ -0,0 +1,5 @@ +export type AddressDto = { + address: string + city: string + state: string +} diff --git a/src/entities/user/dto/company.dto.ts b/src/entities/user/dto/company.dto.ts new file mode 100644 index 000000000..6b15940d8 --- /dev/null +++ b/src/entities/user/dto/company.dto.ts @@ -0,0 +1,4 @@ +export type CompanyDto = { + name: string + title: string +} diff --git a/src/entities/user/dto/user.dto.ts b/src/entities/user/dto/user.dto.ts new file mode 100644 index 000000000..387bed99d --- /dev/null +++ b/src/entities/user/dto/user.dto.ts @@ -0,0 +1,24 @@ +import { AddressDto } from "@/entities/user/dto/address.dto" +import { CompanyDto } from "@/entities/user/dto/company.dto" +import { Pagination } from "@/shared/types" + +export type UserDto = { + id: number + image: string + username: string + fullName?: string +} + +export type UserProfileDto = UserDto & { + firstName?: string + lastName?: string + age?: number + email?: string + phone?: string + address?: AddressDto + company?: CompanyDto +} + +export type AllUserProfilesResponse = Pagination & { + users: UserDto[] +} diff --git a/src/entities/user/repository/index.ts b/src/entities/user/repository/index.ts new file mode 100644 index 000000000..4ebcf4386 --- /dev/null +++ b/src/entities/user/repository/index.ts @@ -0,0 +1 @@ +export { UserApiRepository, type UserRepository } from "./user.repository" diff --git a/src/entities/user/repository/user.repository.ts b/src/entities/user/repository/user.repository.ts new file mode 100644 index 000000000..142779dab --- /dev/null +++ b/src/entities/user/repository/user.repository.ts @@ -0,0 +1,37 @@ +import { userAdapter } from "@/entities/user/api" +import { User } from "@/entities/user/core/user.domain" +import { UserMapperService } from "@/entities/user/service" +import { ApiClient } from "@/shared/api" + +export interface UserRepository { + getUserProfile(userId: number): Promise + getAllUsers(): Promise +} + +export class UserApiRepository implements UserRepository { + private api: ReturnType + constructor(apiClient: ApiClient) { + this.api = userAdapter(apiClient) + } + + async getUserProfile(userId: number): Promise { + try { + const response = await this.api.getProfile(userId) + const user = UserMapperService.toDomain(response) + return user + } catch (error) { + console.error("UserRepository getUserProfile Error:", error) + throw error + } + } + + async getAllUsers(): Promise { + try { + const { users } = await this.api.list() + return users.map((user) => UserMapperService.toDomain(user)) + } catch (error) { + console.error("UserRepository getAllUsers Error:", error) + throw error + } + } +} diff --git a/src/entities/user/service/index.ts b/src/entities/user/service/index.ts new file mode 100644 index 000000000..22ee12490 --- /dev/null +++ b/src/entities/user/service/index.ts @@ -0,0 +1 @@ +export { UserMapperService } from "./mapper.service" diff --git a/src/entities/user/service/mapper.service.ts b/src/entities/user/service/mapper.service.ts new file mode 100644 index 000000000..befe685e5 --- /dev/null +++ b/src/entities/user/service/mapper.service.ts @@ -0,0 +1,25 @@ +import { User } from "@/entities/user/core/user.domain" +import { UserDto } from "@/entities/user/dto/user.dto" + +export class UserMapperService { + static toDto(user: User): UserDto { + return { + id: user.id, + username: user.username, + fullName: user.fullName, + image: user.image, + } + } + + static toDomain(dto: UserDto): User { + return new User(dto.id, dto.username, dto.fullName || "", dto.image || "") + } + + static toDomainList(dtos: UserDto[]): User[] { + return dtos.map((dto) => this.toDomain(dto)) + } + + static toDtoList(users: User[]): UserDto[] { + return users.map((user) => this.toDto(user)) + } +} diff --git a/src/entities/user/store/index.ts b/src/entities/user/store/index.ts new file mode 100644 index 000000000..612dd4369 --- /dev/null +++ b/src/entities/user/store/index.ts @@ -0,0 +1 @@ +export { useUserStore } from "./user.store" diff --git a/src/entities/user/store/user.store.ts b/src/entities/user/store/user.store.ts new file mode 100644 index 000000000..2fe81f968 --- /dev/null +++ b/src/entities/user/store/user.store.ts @@ -0,0 +1,9 @@ +import { create } from "zustand" +import { UserDto } from "../dto/user.dto" + +type UserStoreState = Pick + +export const useUserStore = create((set) => ({ + id: 1, + setSelectedUserId: (id: number) => set({ id }), +})) diff --git a/src/entities/user/types/address.types.ts b/src/entities/user/types/address.types.ts new file mode 100644 index 000000000..66c83d707 --- /dev/null +++ b/src/entities/user/types/address.types.ts @@ -0,0 +1,5 @@ +export type AddressValue = { + address: string + city: string + state: string +} diff --git a/src/entities/user/types/company.types.ts b/src/entities/user/types/company.types.ts new file mode 100644 index 000000000..ece9572d6 --- /dev/null +++ b/src/entities/user/types/company.types.ts @@ -0,0 +1,4 @@ +export type CompanyValue = { + name: string + title: string +} diff --git a/src/entities/user/types/index.ts b/src/entities/user/types/index.ts new file mode 100644 index 000000000..547d82c6b --- /dev/null +++ b/src/entities/user/types/index.ts @@ -0,0 +1,3 @@ +export { type AddressValue } from "./address.types" +export { type CompanyValue } from "./company.types" +export { type UserEntity } from "./user.types" diff --git a/src/entities/user/types/user.types.ts b/src/entities/user/types/user.types.ts new file mode 100644 index 000000000..4eb2d4c6e --- /dev/null +++ b/src/entities/user/types/user.types.ts @@ -0,0 +1,23 @@ +import { UserDto, UserProfileDto } from "@/entities/user/dto/user.dto" +import { AddressValue } from "./address.types" +import { CompanyValue } from "./company.types" + +export interface UserEntity { + id: number + username: string + image: string + + firstName?: string + lastName?: string + fullName?: string + age?: number + email?: string + phone?: string + + address?: AddressValue + company?: CompanyValue + + // DTO 변환 메서드 + toDto(): UserDto + toProfileDto(): UserProfileDto +} diff --git a/src/entities/user/ui/index.ts b/src/entities/user/ui/index.ts new file mode 100644 index 000000000..621c9c841 --- /dev/null +++ b/src/entities/user/ui/index.ts @@ -0,0 +1 @@ +export { UserView } from "./user-view/UserView" diff --git a/src/entities/user/ui/user-view/UserView.tsx b/src/entities/user/ui/user-view/UserView.tsx new file mode 100644 index 000000000..6cedcbc60 --- /dev/null +++ b/src/entities/user/ui/user-view/UserView.tsx @@ -0,0 +1,36 @@ +import { UserProfileDto } from "@/entities/user/dto/user.dto" +import React from "react" + +interface UserViewProps extends React.HTMLAttributes { + userProfile: UserProfileDto +} + +export const UserView: React.FC = ({ userProfile, ...props }) => { + return ( +
+ {userProfile?.username} +

{userProfile?.username}

+
+

+ 이름: {userProfile?.firstName} {userProfile?.lastName} +

+

+ 나이: {userProfile?.age} +

+

+ 이메일: {userProfile?.email} +

+

+ 전화번호: {userProfile?.phone} +

+

+ 주소: {userProfile?.address?.address}, {userProfile?.address?.city},{" "} + {userProfile?.address?.state} +

+

+ 직장: {userProfile?.company?.name} - {userProfile?.company?.title} +

+
+
+ ) +} diff --git a/src/features/comment/hooks/index.ts b/src/features/comment/hooks/index.ts new file mode 100644 index 000000000..5de735b38 --- /dev/null +++ b/src/features/comment/hooks/index.ts @@ -0,0 +1,5 @@ +export { useAddComment } from "./useAddComment" +export { useDeleteComment } from "./useDeleteComment" +export { useGetCommentsByPostId } from "./useGetCommentsByPostId" +export { useLikeComment } from "./useLikeComment" +export { useUpdateComment } from "./useUpdateComment" diff --git a/src/features/comment/hooks/useAddComment.ts b/src/features/comment/hooks/useAddComment.ts new file mode 100644 index 000000000..019c33137 --- /dev/null +++ b/src/features/comment/hooks/useAddComment.ts @@ -0,0 +1,32 @@ +import { COMMENT_QUERY_KEY } from "@/entities/comment/api" +import { CommentDto } from "@/entities/comment/dto" +import { commentService } from "@/features/comment/services" + +import { queryClient } from "@/shared/api" +import { useMutation } from "@tanstack/react-query" + +type AddCommentParams = { + body: string + postId: number + userId: number +} + +export const useAddComment = () => { + return useMutation({ + mutationFn: async ({ body, postId, userId }) => { + const result = await commentService.addComment(body, postId, userId) + return result + }, + + // * 성공 시 캐시 직접 업데이트 + onSuccess: (newComment) => { + // * 캐시에 있는 댓글 데이터 직접 업데이트 + queryClient.setQueryData(COMMENT_QUERY_KEY.byPostId(newComment.postId), (oldComments: Comment[] | undefined) => { + if (!oldComments) + return [newComment] // * 기존 댓글이 없으면 새 배열 생성 + // * 기존 배열에 새 댓글 추가 + else return [newComment, ...oldComments] + }) + }, + }) +} diff --git a/src/features/comment/hooks/useDeleteComment.ts b/src/features/comment/hooks/useDeleteComment.ts new file mode 100644 index 000000000..9bee831cd --- /dev/null +++ b/src/features/comment/hooks/useDeleteComment.ts @@ -0,0 +1,26 @@ +import { COMMENT_QUERY_KEY } from "@/entities/comment/api" +import { CommentDto } from "@/entities/comment/dto" +import { commentService } from "@/features/comment/services" +import { queryClient } from "@/shared/api" +import { useMutation } from "@tanstack/react-query" + +type DeleteCommentParams = { + id: number + postId: number +} + +export const useDeleteComment = () => { + return useMutation<{ result: boolean; postId: number }, Error, DeleteCommentParams>({ + mutationFn: async ({ id, postId }) => { + const result = await commentService.deleteComment(id) + return { result, postId } + }, + onSuccess: ({ postId }, { id }) => { + queryClient.setQueryData(COMMENT_QUERY_KEY.byPostId(postId), (oldComments: CommentDto[] | undefined) => { + if (!oldComments) return oldComments + + return oldComments.filter((comment) => comment.id !== id) + }) + }, + }) +} diff --git a/src/features/comment/hooks/useGetCommentsByPostId.ts b/src/features/comment/hooks/useGetCommentsByPostId.ts new file mode 100644 index 000000000..212cc243d --- /dev/null +++ b/src/features/comment/hooks/useGetCommentsByPostId.ts @@ -0,0 +1,13 @@ +import { COMMENT_QUERY_KEY } from "@/entities/comment/api" +import { commentService } from "@/features/comment/services" +import { useQuery } from "@tanstack/react-query" + +export const useGetCommentsByPostId = (postId: number) => { + return useQuery({ + queryKey: COMMENT_QUERY_KEY.byPostId(postId), + queryFn: () => commentService.getAllComments(postId), + staleTime: 0, + refetchOnWindowFocus: false, + enabled: !!postId, + }) +} diff --git a/src/features/comment/hooks/useLikeComment.ts b/src/features/comment/hooks/useLikeComment.ts new file mode 100644 index 000000000..0c438a170 --- /dev/null +++ b/src/features/comment/hooks/useLikeComment.ts @@ -0,0 +1,29 @@ +import { COMMENT_QUERY_KEY } from "@/entities/comment/api" +import { CommentDto } from "@/entities/comment/dto" +import { commentService } from "@/features/comment/services" +import { queryClient } from "@/shared/api" +import { useMutation } from "@tanstack/react-query" + +type LikeCommentParams = { + id: number + postId: number +} + +export const useLikeComment = () => { + return useMutation<{ result: boolean; id: number }, Error, LikeCommentParams>({ + mutationFn: async ({ id }) => { + const result = await commentService.likeComment(id) + + return { result, id } + }, + + onSuccess: ({ result, id }, { postId }) => { + if (!result) return + queryClient.setQueryData(COMMENT_QUERY_KEY.byPostId(postId), (oldComments: CommentDto[] | undefined) => { + if (!oldComments) return oldComments + + return oldComments.map((comment) => (comment.id === id ? { ...comment, likes: comment.likes + 1 } : comment)) + }) + }, + }) +} diff --git a/src/features/comment/hooks/useUpdateComment.ts b/src/features/comment/hooks/useUpdateComment.ts new file mode 100644 index 000000000..e27c9d244 --- /dev/null +++ b/src/features/comment/hooks/useUpdateComment.ts @@ -0,0 +1,32 @@ +import { COMMENT_QUERY_KEY } from "@/entities/comment/api" +import { CommentDto } from "@/entities/comment/dto" +import { commentService } from "@/features/comment/services" +import { queryClient } from "@/shared/api" +import { useMutation } from "@tanstack/react-query" + +type UpdateCommentParams = { + id: number + body: string +} + +export const useUpdateComment = () => { + return useMutation({ + mutationFn: async ({ id, body }) => { + const result = await commentService.updateComment(id, body) + return result + }, + + onSuccess: (updatedComment: CommentDto) => { + queryClient.setQueryData( + COMMENT_QUERY_KEY.byPostId(updatedComment.postId), + (oldComments: CommentDto[] | undefined) => { + if (!oldComments) return oldComments + + return oldComments.map((comment) => + comment.id === updatedComment.id ? { ...comment, body: updatedComment.body } : comment, + ) + }, + ) + }, + }) +} diff --git a/src/features/comment/services/comment-service-factory.ts b/src/features/comment/services/comment-service-factory.ts new file mode 100644 index 000000000..b96974888 --- /dev/null +++ b/src/features/comment/services/comment-service-factory.ts @@ -0,0 +1,38 @@ +import { CommentApiRepository } from "@/entities/comment/repository" +import { UserApiRepository } from "@/entities/user/repository" +import { apiClient } from "@/shared/api/api" +import { + AddCommentUseCase, + DeleteCommentUseCase, + GetCommentsUseCase, + LikeCommentUseCase, + UpdateCommentUseCase, +} from "../usecase" + +/** + * 각 UseCase 인스턴스를 생성하는 팩토리 + */ +export class CommentServiceFactory { + private static commentRepository = new CommentApiRepository(apiClient) + private static userRepository = new UserApiRepository(apiClient) + + static createGetCommentsUseCase(): GetCommentsUseCase { + return new GetCommentsUseCase(this.commentRepository) + } + + static createAddCommentUseCase(): AddCommentUseCase { + return new AddCommentUseCase(this.commentRepository, this.userRepository) + } + + static createUpdateCommentUseCase(): UpdateCommentUseCase { + return new UpdateCommentUseCase(this.commentRepository) + } + + static createDeleteCommentUseCase(): DeleteCommentUseCase { + return new DeleteCommentUseCase(this.commentRepository) + } + + static createLikeCommentUseCase(): LikeCommentUseCase { + return new LikeCommentUseCase(this.commentRepository) + } +} diff --git a/src/features/comment/services/comment.service.ts b/src/features/comment/services/comment.service.ts new file mode 100644 index 000000000..06d79a173 --- /dev/null +++ b/src/features/comment/services/comment.service.ts @@ -0,0 +1,77 @@ +import { CommentFactory } from "@/entities/comment/core/comment.factory" +import { CommentDto } from "@/entities/comment/dto" +import { CommentRepository } from "@/entities/comment/repository" +import { CommentMapperService } from "@/entities/comment/service/mapper.service" +import { UserRepository } from "@/entities/user/repository" +import { CommentUseCase } from "../usecase/comment.usecase" + +export const CommentService = ( + commentRepository: CommentRepository, + userRepository: UserRepository, +): CommentUseCase => ({ + getAllComments: async (postId: number): Promise => { + try { + const domainComments = await commentRepository.getByPostId(postId) + return domainComments.map((comment) => CommentMapperService.toDto(comment)) + } catch (error) { + console.error("GetAllComments Error:", error) + throw error + } + }, + + addComment: async (body: string, postId: number, userId: number) => { + try { + const user = await userRepository.getUserProfile(userId) + + const newComment = CommentFactory.createNew(body, postId, { + id: userId, + username: user.username, + fullName: user.username, + }) + + const savedComment = await commentRepository.create(newComment) + if (!savedComment) throw new Error("Failed to create comment") + + return CommentMapperService.toDto(savedComment) + } catch (error) { + console.error("AddComment Error:", error) + throw error + } + }, + + updateComment: async (id: number, body: string): Promise => { + try { + const comments = await commentRepository.getByPostId(0) + const existingComment = comments.find((c) => c.id === id) + if (!existingComment) throw new Error(`Comment with id ${id} not found`) + + existingComment.updateBody(body) + + const updatedComment = await commentRepository.update(existingComment) + if (!updatedComment) throw new Error("Failed to update comment") + + return CommentMapperService.toDto(updatedComment) + } catch (error) { + console.error("UpdateComment Error:", error) + throw error + } + }, + + deleteComment: async (id: number): Promise => { + try { + return await commentRepository.delete(id) + } catch (error) { + console.error("DeleteComment Error:", error) + throw error + } + }, + + likeComment: async (id: number): Promise => { + try { + return await commentRepository.like(id) + } catch (error) { + console.error("LikeComment Error:", error) + throw error + } + }, +}) diff --git a/src/features/comment/services/index.ts b/src/features/comment/services/index.ts new file mode 100644 index 000000000..5cc1a7000 --- /dev/null +++ b/src/features/comment/services/index.ts @@ -0,0 +1,12 @@ +import { CommentApiRepository } from "@/entities/comment/repository" +import { UserApiRepository } from "@/entities/user/repository" +import { apiClient } from "@/shared/api/api" +import { CommentService } from "./comment.service" + +const createCommentService = () => { + const commentRepository = new CommentApiRepository(apiClient) + const userRepository = new UserApiRepository(apiClient) + return CommentService(commentRepository, userRepository) +} + +export const commentService = createCommentService() diff --git a/src/features/comment/usecase/add-comment.usecase.ts b/src/features/comment/usecase/add-comment.usecase.ts new file mode 100644 index 000000000..b7d5d3412 --- /dev/null +++ b/src/features/comment/usecase/add-comment.usecase.ts @@ -0,0 +1,39 @@ +import { CommentFactory } from "@/entities/comment/core/comment.factory" +import { CommentDto } from "@/entities/comment/dto" +import { CommentRepository } from "@/entities/comment/repository" +import { CommentMapperService } from "@/entities/comment/service" +import { UserRepository } from "@/entities/user/repository" + +/** + * 새로운 댓글을 추가하는 UseCase + */ +export class AddCommentUseCase { + constructor( + private readonly commentRepository: CommentRepository, + private readonly userRepository: UserRepository, + ) {} + + async execute(body: string, postId: number, userId: number): Promise { + try { + // 사용자 정보 가져오기 + const user = await this.userRepository.getUserProfile(userId) + + // 새 도메인 모델 생성 + const newComment = CommentFactory.createNew(body, postId, { + id: userId, + username: user.username, + fullName: user.username, + }) + + // Repository를 통해 저장 + const savedComment = await this.commentRepository.create(newComment) + if (!savedComment) throw new Error("댓글 생성에 실패했습니다") + + // 도메인 모델을 DTO로 변환 + return CommentMapperService.toDto(savedComment) + } catch (error) { + console.error("AddComment Error:", error) + throw error + } + } +} diff --git a/src/features/comment/usecase/comment.usecase.ts b/src/features/comment/usecase/comment.usecase.ts new file mode 100644 index 000000000..1e81d8249 --- /dev/null +++ b/src/features/comment/usecase/comment.usecase.ts @@ -0,0 +1,9 @@ +import { CommentDto } from "@/entities/comment/dto" + +export interface CommentUseCase { + getAllComments: (postId: number) => Promise + addComment: (body: string, postId: number, userId: number) => Promise + updateComment: (id: number, body: string) => Promise + deleteComment: (id: number) => Promise + likeComment: (id: number) => Promise +} diff --git a/src/features/comment/usecase/delete-comment.usecase.ts b/src/features/comment/usecase/delete-comment.usecase.ts new file mode 100644 index 000000000..b15e999fd --- /dev/null +++ b/src/features/comment/usecase/delete-comment.usecase.ts @@ -0,0 +1,18 @@ +import { CommentRepository } from "@/entities/comment/repository" + +/** + * 댓글을 삭제하는 UseCase + */ +export class DeleteCommentUseCase { + constructor(private readonly commentRepository: CommentRepository) {} + + async execute(id: number): Promise { + try { + // Repository를 통해 삭제 + return await this.commentRepository.delete(id) + } catch (error) { + console.error("DeleteComment Error:", error) + throw error + } + } +} diff --git a/src/features/comment/usecase/get-comments.usecase.ts b/src/features/comment/usecase/get-comments.usecase.ts new file mode 100644 index 000000000..ba7ace0e6 --- /dev/null +++ b/src/features/comment/usecase/get-comments.usecase.ts @@ -0,0 +1,20 @@ +import { CommentDto } from "@/entities/comment/dto" +import { CommentRepository } from "@/entities/comment/repository" +import { CommentMapperService } from "@/entities/comment/service/mapper.service" + +/** + * 게시물의 모든 댓글을 조회하는 UseCase + */ +export class GetCommentsUseCase { + constructor(private readonly commentRepository: CommentRepository) {} + + async execute(postId: number): Promise { + try { + const domainComments = await this.commentRepository.getByPostId(postId) + return domainComments.map((comment) => CommentMapperService.toDto(comment)) + } catch (error) { + console.error("GetComments Error:", error) + throw error + } + } +} diff --git a/src/features/comment/usecase/index.ts b/src/features/comment/usecase/index.ts new file mode 100644 index 000000000..763a4b9a2 --- /dev/null +++ b/src/features/comment/usecase/index.ts @@ -0,0 +1,8 @@ +export { AddCommentUseCase } from "./add-comment.usecase" +export { DeleteCommentUseCase } from "./delete-comment.usecase" +export { GetCommentsUseCase } from "./get-comments.usecase" +export { LikeCommentUseCase } from "./like-comment.usecase" +export { UpdateCommentUseCase } from "./update-comment.usecase" + +// 기존 인터페이스도 유지 (점진적 마이그레이션을 위해) +export { type CommentUseCase } from "./comment.usecase" diff --git a/src/features/comment/usecase/like-comment.usecase.ts b/src/features/comment/usecase/like-comment.usecase.ts new file mode 100644 index 000000000..ba20658fe --- /dev/null +++ b/src/features/comment/usecase/like-comment.usecase.ts @@ -0,0 +1,18 @@ +import { CommentRepository } from "@/entities/comment/repository" + +/** + * 댓글에 좋아요를 추가하는 UseCase + */ +export class LikeCommentUseCase { + constructor(private readonly commentRepository: CommentRepository) {} + + async execute(id: number): Promise { + try { + // Repository를 통해 좋아요 추가 + return await this.commentRepository.like(id) + } catch (error) { + console.error("LikeComment Error:", error) + throw error + } + } +} diff --git a/src/features/comment/usecase/update-comment.usecase.ts b/src/features/comment/usecase/update-comment.usecase.ts new file mode 100644 index 000000000..6a2dcced6 --- /dev/null +++ b/src/features/comment/usecase/update-comment.usecase.ts @@ -0,0 +1,32 @@ +import { CommentDto } from "@/entities/comment/dto" +import { CommentRepository } from "@/entities/comment/repository" +import { CommentMapperService } from "@/entities/comment/service/mapper.service" + +/** + * 댓글을 수정하는 UseCase + */ +export class UpdateCommentUseCase { + constructor(private readonly commentRepository: CommentRepository) {} + + async execute(id: number, body: string): Promise { + try { + // 기존 댓글 가져오기 (실제로는 getById 메서드가 필요) + const comments = await this.commentRepository.getByPostId(0) + const existingComment = comments.find(c => c.id === id) + if (!existingComment) throw new Error(`댓글을 찾을 수 없습니다: ${id}`) + + // 도메인 모델 업데이트 (비즈니스 로직 적용) + existingComment.updateBody(body) + + // Repository를 통해 업데이트 + const updatedComment = await this.commentRepository.update(existingComment) + if (!updatedComment) throw new Error("댓글 수정에 실패했습니다") + + // 도메인 모델을 DTO로 변환 + return CommentMapperService.toDto(updatedComment) + } catch (error) { + console.error("UpdateComment Error:", error) + throw error + } + } +} diff --git a/src/features/post/hooks/index.ts b/src/features/post/hooks/index.ts new file mode 100644 index 000000000..624709c85 --- /dev/null +++ b/src/features/post/hooks/index.ts @@ -0,0 +1,5 @@ +export { useGetPostById, useGetPosts } from "./useGetPosts" + +export { useAddPost } from "./useAddPost" +export { useDeletePost } from "./useDeletePost" +export { useUpdatePost } from "./useUpdatePost" diff --git a/src/features/post/hooks/useAddPost.ts b/src/features/post/hooks/useAddPost.ts new file mode 100644 index 000000000..23be46326 --- /dev/null +++ b/src/features/post/hooks/useAddPost.ts @@ -0,0 +1,48 @@ +import { POST_QUERY_KEY, postApi } from "@/entities/post/api" +import { PostWithAuthor } from "@/entities/post/types" +import { userAdapter } from "@/entities/user/api" +import { PostService } from "@/features/post/services" +import { apiClient, queryClient } from "@/shared/api" +import { usePostsQueryParams } from "@/shared/stores/query-params" +import { useMutation } from "@tanstack/react-query" +import { PostsWithResult } from "../types" + +type AddPostParams = { + title: string + body: string + userId: number + tags: string[] +} + +export const useAddPost = () => { + const [{ limit, skip }] = usePostsQueryParams() + return useMutation({ + mutationFn: async ({ title, body, userId, tags }) => { + const service = PostService(postApi(apiClient), userAdapter(apiClient)) + const newPostWithAuthor = await service.addPost(title, body, userId) + if (!newPostWithAuthor) throw new Error("Error: Fail to add post") + + return { + ...newPostWithAuthor, + tags, + reactions: { likes: 0, dislikes: 0 }, + } + }, + + onSuccess: (newPost) => { + queryClient.setQueryData(POST_QUERY_KEY.list({ limit, skip }), (oldData: PostsWithResult | undefined) => { + if (!oldData) return { posts: [newPost], total: 1 } + return { + ...oldData, + posts: [newPost, ...oldData.posts], + total: oldData.total + 1, + } + }) + + queryClient.invalidateQueries({ + queryKey: POST_QUERY_KEY.list({ limit, skip }), + refetchType: "none", + }) + }, + }) +} diff --git a/src/features/post/hooks/useDeletePost.ts b/src/features/post/hooks/useDeletePost.ts new file mode 100644 index 000000000..08670b687 --- /dev/null +++ b/src/features/post/hooks/useDeletePost.ts @@ -0,0 +1,40 @@ +import { POST_QUERY_KEY, postApi } from "@/entities/post/api" +import { userAdapter } from "@/entities/user/api" +import { PostService } from "@/features/post/services" +import { apiClient, queryClient } from "@/shared/api" +import { useMutation } from "@tanstack/react-query" +import { PostsWithResult } from "../types" + +type DeletePostParams = { + id: number +} + +export const useDeletePost = () => { + return useMutation<{ result: boolean; id: number }, Error, DeletePostParams>({ + mutationFn: async ({ id }) => { + const deletedPost = await PostService(postApi(apiClient), userAdapter(apiClient)).deletePost(id) + return { result: !!deletedPost, id } + }, + + onSuccess: ({ result, id }) => { + if (!result) return + + queryClient + .getQueryCache() + .findAll({ queryKey: POST_QUERY_KEY.all }) + .forEach((query) => { + queryClient.setQueryData(query.queryKey, (oldData: PostsWithResult | undefined) => { + if (!oldData) return oldData + + return { + ...oldData, + posts: oldData.posts.filter((post: { id: number }) => post.id !== id), + total: oldData.total - 1, + } + }) + }) + + queryClient.removeQueries({ queryKey: POST_QUERY_KEY.detail(id) }) + }, + }) +} diff --git a/src/features/post/hooks/useGetPosts.ts b/src/features/post/hooks/useGetPosts.ts new file mode 100644 index 000000000..e538b67b9 --- /dev/null +++ b/src/features/post/hooks/useGetPosts.ts @@ -0,0 +1,73 @@ +import { POST_QUERY_KEY, postApi } from "@/entities/post/api" +import { usePostTotalStore } from "@/entities/post/stores/post-total.stores" +import { PostWithAuthor } from "@/entities/post/types" +import { userAdapter } from "@/entities/user/api" +import { PostService } from "@/features/post/services" +import { apiClient } from "@/shared/api" +import { useQuery } from "@tanstack/react-query" + +type GetPostsOptions = { + limit?: number + skip?: number + tag?: string + searchQuery?: string + enabled?: boolean +} + +export const useGetPosts = (options: GetPostsOptions = {}) => { + const { limit = 10, skip = 0, tag, searchQuery, enabled = true } = options + const { setTotal } = usePostTotalStore() + + // * 검색/태그 상태 확인 + const isSearchQuery = !!searchQuery && searchQuery.length >= 2 + const isTagQuery = !!tag && tag !== "all" && !isSearchQuery + + // * 쿼리 활성화 여부 + const isEnabled = isSearchQuery + ? enabled && searchQuery.length >= 2 + : isTagQuery + ? enabled && !!tag && tag !== "all" + : enabled + + // * 쿼리 키 결정 + const queryKey = isSearchQuery + ? POST_QUERY_KEY.search(searchQuery) + : isTagQuery + ? POST_QUERY_KEY.tag(tag) + : POST_QUERY_KEY.list({ limit, skip }) + + return useQuery({ + queryKey, + queryFn: async () => { + const service = PostService(postApi(apiClient), userAdapter(apiClient)) + let result + + if (isSearchQuery) { + result = await service.searchPosts(searchQuery) + } else if (isTagQuery && tag) { + result = await service.getPostsByTag(tag) + } else { + result = await service.getAllPosts(limit, skip) + } + + setTotal(result.total) + return result + }, + enabled: isEnabled, + }) +} + +export const useGetPostById = (id: number, enabled: boolean = !!id) => { + return useQuery({ + queryKey: POST_QUERY_KEY.detail(id), + queryFn: async () => { + const service = PostService(postApi(apiClient), userAdapter(apiClient)) + const result = await service.getAllPosts(1, 0) + const post = result.posts.find((post) => post.id === id) + + if (!post) throw new Error(`useGetPostById: Post not found with id: ${id}`) + return post + }, + enabled, + }) +} diff --git a/src/features/post/hooks/useUpdatePost.ts b/src/features/post/hooks/useUpdatePost.ts new file mode 100644 index 000000000..6c422fbdf --- /dev/null +++ b/src/features/post/hooks/useUpdatePost.ts @@ -0,0 +1,47 @@ +import { POST_QUERY_KEY, postApi } from "@/entities/post/api" +import { PostWithAuthor } from "@/entities/post/types" +import { userAdapter } from "@/entities/user/api" +import { PostService } from "@/features/post/services" +import { apiClient, queryClient } from "@/shared/api" +import { useMutation } from "@tanstack/react-query" +import { PostsWithResult } from "../types" + +interface UpdatePostParams { + id: number + title: string + body: string + tags: string[] +} + +export const useUpdatePost = () => { + return useMutation({ + mutationFn: async ({ id, title, body, tags }) => { + const postService = PostService(postApi(apiClient), userAdapter(apiClient)) + + const updatedPost = await postService.updatePost({ id, title, body, tags }) + if (!updatedPost) throw new Error(`useUpdatePost: Failed to update post with id: ${id}`) + + return { + ...updatedPost, + reactions: updatedPost.reactions || { likes: 0, dislikes: 0 }, + } + }, + + onSuccess: (updatedPost) => { + // * 메인 페이지 목록 업데이트 + queryClient.setQueryData(POST_QUERY_KEY.list({ limit: 10, skip: 0 }), (oldData: PostsWithResult | undefined) => { + if (!oldData) return oldData + return { + ...oldData, + posts: oldData.posts.map((post: PostWithAuthor) => (post.id === updatedPost.id ? updatedPost : post)), + } + }) + + // * 기타 쿼리 무효화 + queryClient.invalidateQueries({ + queryKey: POST_QUERY_KEY.all, + refetchType: "none", + }) + }, + }) +} diff --git a/src/features/post/services/index.ts b/src/features/post/services/index.ts new file mode 100644 index 000000000..dd8e40681 --- /dev/null +++ b/src/features/post/services/index.ts @@ -0,0 +1 @@ +export { PostService } from "./post.service" diff --git a/src/features/post/services/post.service.ts b/src/features/post/services/post.service.ts new file mode 100644 index 000000000..a350fc13d --- /dev/null +++ b/src/features/post/services/post.service.ts @@ -0,0 +1,115 @@ +import { postApi } from "@/entities/post/api" +import { Post } from "@/entities/post/types" +import { userAdapter } from "@/entities/user/api" +import { UserProfileDto } from "@/entities/user/dto/user.dto" +import { PostUseCase } from "../usecase/post.usecase" + +export const PostService = ( + postApiClient: ReturnType, + userApiClient: ReturnType, +): PostUseCase => ({ + getAllPosts: async (limit: number, skip: number) => { + try { + const postsData = await postApiClient.list(limit, skip) + if (!postsData) return { posts: [], total: 0 } + + const usersResponse = await userApiClient.list() + const usersData = usersResponse.users + + const postsWithUsers = postsData.posts.map((post: Post) => ({ + ...post, + author: usersData.find((user: UserProfileDto) => user.id === post.userId), + })) + + return { + posts: postsWithUsers, + total: postsData.total, + } + } catch (error) { + console.error("PostService getAllPosts Error:", error) + return { posts: [], total: 0 } + } + }, + getPostsByTag: async (tag: string) => { + try { + const [postsResponse, usersResponse] = await Promise.all([postApiClient.listByTag(tag), userApiClient.list()]) + const postsWithUsers = postsResponse.posts.map((post: Post) => ({ + ...post, + author: usersResponse.users.find((user) => user.id === post.userId), + })) + return { + posts: postsWithUsers, + total: postsResponse.total, + } + } catch (error) { + console.error("PostService getPostsByTag Error:", error) + throw error + } + }, + getAllTags: async () => { + try { + const result = await postApiClient.getAllTags() + if (!result) return [] + return result + } catch (error) { + console.error("PostService getAllTags Error:", error) + throw error + } + }, + searchPosts: async (searchQuery: string) => { + try { + const result = await postApiClient.search(searchQuery) + if (!result) return { posts: [], total: 0 } + return { + posts: result.posts, + total: result.total, + } + } catch (error) { + console.error("PostService searchPosts Error:", error) + throw error + } + }, + addPost: async (title: string, body: string, userId: number) => { + try { + const result = await postApiClient.create(title, body, userId) + if (!result) return null + const { users } = await userApiClient.list() + const author = users.find((user) => user.id === userId) + + return { + ...result, + author, + } + } catch (error) { + console.error("PostService addPost Error:", error) + throw error + } + }, + updatePost: async (post: Post) => { + try { + const result = await postApiClient.update(post) + if (!result) { + throw new Error(`Failed to update post with id: ${post.id}`) + } + const { users } = await userApiClient.list() + const author = users.find((user) => user.id === result.userId) + return { + ...result, + author, + } + } catch (error) { + console.error("PostService updatePost Error:", error) + throw error + } + }, + deletePost: async (id: number) => { + try { + const result = await postApiClient.remove(id) + if (!result) return null + return result + } catch (error) { + console.error("PostService deletePost Error:", error) + throw error + } + }, +}) diff --git a/src/features/post/types/index.ts b/src/features/post/types/index.ts new file mode 100644 index 000000000..3635c97fc --- /dev/null +++ b/src/features/post/types/index.ts @@ -0,0 +1 @@ +export { type PostsWithResult } from "./post.types" diff --git a/src/features/post/types/post.types.ts b/src/features/post/types/post.types.ts new file mode 100644 index 000000000..71d52d437 --- /dev/null +++ b/src/features/post/types/post.types.ts @@ -0,0 +1,6 @@ +import { PostWithAuthor } from "@/entities/post/types" + +export interface PostsWithResult { + posts: Array + total: number +} diff --git a/src/features/post/usecase/post.usecase.ts b/src/features/post/usecase/post.usecase.ts new file mode 100644 index 000000000..077c98d2c --- /dev/null +++ b/src/features/post/usecase/post.usecase.ts @@ -0,0 +1,12 @@ +import { Post, Tag } from "@/entities/post/types" +import { PostsWithResult } from "../types" + +export interface PostUseCase { + getAllPosts: (limit: number, skip: number) => Promise + getPostsByTag: (tag: string) => Promise + getAllTags: () => Promise + searchPosts: (searchQuery: string) => Promise + addPost: (title: string, body: string, userId: number) => Promise + updatePost: (post: Post) => Promise + deletePost: (id: number) => Promise +} diff --git a/src/features/user/services/index.ts b/src/features/user/services/index.ts new file mode 100644 index 000000000..bf7979a8c --- /dev/null +++ b/src/features/user/services/index.ts @@ -0,0 +1,10 @@ +import { UserApiRepository } from "@/entities/user/repository" +import { UserService } from "@/features/user/services/user.service" +import { apiClient } from "@/shared/api" + +const createUserService = () => { + const repository = new UserApiRepository(apiClient) + return UserService(repository) +} + +export const userService = createUserService() diff --git a/src/features/user/services/user.service.ts b/src/features/user/services/user.service.ts new file mode 100644 index 000000000..d2617aa10 --- /dev/null +++ b/src/features/user/services/user.service.ts @@ -0,0 +1,14 @@ +import { UserRepository } from "@/entities/user/repository" +import { UserUseCase } from "../usecase/user.usecase" + +export const UserService = (userRepository: UserRepository): UserUseCase => ({ + getUserProfile: async (userId: number) => { + try { + const result = await userRepository.getUserProfile(userId) + return result + } catch (error) { + console.error("UserService getUserProfile Error:", error) + throw error + } + }, +}) diff --git a/src/features/user/usecase/user.usecase.ts b/src/features/user/usecase/user.usecase.ts new file mode 100644 index 000000000..5c2e4f3e8 --- /dev/null +++ b/src/features/user/usecase/user.usecase.ts @@ -0,0 +1,5 @@ +import { UserProfileDto } from "@/entities/user/dto/user.dto" + +export interface UserUseCase { + getUserProfile: (userId: number) => Promise +} diff --git a/src/index.tsx b/src/index.tsx index 369e197bb..48326ff9b 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,7 +1,7 @@ import React from "react" import ReactDOM from "react-dom/client" import { BrowserRouter as Router } from "react-router-dom" -import App from "./App" +import App from "./app/App" ReactDOM.createRoot(document.getElementById("root")!).render( diff --git a/src/main.tsx b/src/main.tsx index bef5202a3..1d0b09bc6 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,9 +1,8 @@ -import { StrictMode } from 'react' -import { createRoot } from 'react-dom/client' -import './index.css' -import App from './App.tsx' +import { StrictMode } from "react" +import { createRoot } from "react-dom/client" +import App from "./app/App.tsx" -createRoot(document.getElementById('root')!).render( +createRoot(document.getElementById("root")!).render( , diff --git a/src/pages/PostsManagerPage.tsx b/src/pages/PostsManagerPage.tsx deleted file mode 100644 index 9fa274db4..000000000 --- a/src/pages/PostsManagerPage.tsx +++ /dev/null @@ -1,708 +0,0 @@ -import { useEffect, useState } from "react" -import { Edit2, MessageSquare, Plus, Search, ThumbsDown, ThumbsUp, Trash2 } from "lucide-react" -import { useLocation, useNavigate } from "react-router-dom" -import { - Button, - Card, - CardContent, - CardHeader, - CardTitle, - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - Input, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, - Textarea, -} from "../shared/ui" - -const PostsManager = () => { - const navigate = useNavigate() - const location = useLocation() - const queryParams = new URLSearchParams(location.search) - - // 상태 관리 - const [posts, setPosts] = useState([]) - const [total, setTotal] = useState(0) - const [skip, setSkip] = useState(parseInt(queryParams.get("skip") || "0")) - const [limit, setLimit] = useState(parseInt(queryParams.get("limit") || "10")) - const [searchQuery, setSearchQuery] = useState(queryParams.get("search") || "") - const [selectedPost, setSelectedPost] = useState(null) - const [sortBy, setSortBy] = useState(queryParams.get("sortBy") || "") - const [sortOrder, setSortOrder] = useState(queryParams.get("sortOrder") || "asc") - const [showAddDialog, setShowAddDialog] = useState(false) - const [showEditDialog, setShowEditDialog] = useState(false) - const [newPost, setNewPost] = useState({ title: "", body: "", userId: 1 }) - const [loading, setLoading] = useState(false) - const [tags, setTags] = useState([]) - const [selectedTag, setSelectedTag] = useState(queryParams.get("tag") || "") - const [comments, setComments] = useState({}) - const [selectedComment, setSelectedComment] = useState(null) - const [newComment, setNewComment] = useState({ body: "", postId: null, userId: 1 }) - const [showAddCommentDialog, setShowAddCommentDialog] = useState(false) - const [showEditCommentDialog, setShowEditCommentDialog] = useState(false) - const [showPostDetailDialog, setShowPostDetailDialog] = useState(false) - const [showUserModal, setShowUserModal] = useState(false) - const [selectedUser, setSelectedUser] = useState(null) - - // URL 업데이트 함수 - const updateURL = () => { - const params = new URLSearchParams() - if (skip) params.set("skip", skip.toString()) - if (limit) params.set("limit", limit.toString()) - if (searchQuery) params.set("search", searchQuery) - if (sortBy) params.set("sortBy", sortBy) - if (sortOrder) params.set("sortOrder", sortOrder) - if (selectedTag) params.set("tag", selectedTag) - navigate(`?${params.toString()}`) - } - - // 게시물 가져오기 - const fetchPosts = () => { - setLoading(true) - let postsData - let usersData - - fetch(`/api/posts?limit=${limit}&skip=${skip}`) - .then((response) => response.json()) - .then((data) => { - postsData = data - return fetch("/api/users?limit=0&select=username,image") - }) - .then((response) => response.json()) - .then((users) => { - usersData = users.users - const postsWithUsers = postsData.posts.map((post) => ({ - ...post, - author: usersData.find((user) => user.id === post.userId), - })) - setPosts(postsWithUsers) - setTotal(postsData.total) - }) - .catch((error) => { - console.error("게시물 가져오기 오류:", error) - }) - .finally(() => { - setLoading(false) - }) - } - - // 태그 가져오기 - const fetchTags = async () => { - try { - const response = await fetch("/api/posts/tags") - const data = await response.json() - setTags(data) - } catch (error) { - console.error("태그 가져오기 오류:", error) - } - } - - // 게시물 검색 - const searchPosts = async () => { - if (!searchQuery) { - fetchPosts() - return - } - setLoading(true) - try { - const response = await fetch(`/api/posts/search?q=${searchQuery}`) - const data = await response.json() - setPosts(data.posts) - setTotal(data.total) - } catch (error) { - console.error("게시물 검색 오류:", error) - } - setLoading(false) - } - - // 태그별 게시물 가져오기 - const fetchPostsByTag = async (tag) => { - if (!tag || tag === "all") { - fetchPosts() - return - } - setLoading(true) - try { - const [postsResponse, usersResponse] = await Promise.all([ - fetch(`/api/posts/tag/${tag}`), - fetch("/api/users?limit=0&select=username,image"), - ]) - const postsData = await postsResponse.json() - const usersData = await usersResponse.json() - - const postsWithUsers = postsData.posts.map((post) => ({ - ...post, - author: usersData.users.find((user) => user.id === post.userId), - })) - - setPosts(postsWithUsers) - setTotal(postsData.total) - } catch (error) { - console.error("태그별 게시물 가져오기 오류:", error) - } - setLoading(false) - } - - // 게시물 추가 - const addPost = async () => { - try { - const response = await fetch("/api/posts/add", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(newPost), - }) - const data = await response.json() - setPosts([data, ...posts]) - setShowAddDialog(false) - setNewPost({ title: "", body: "", userId: 1 }) - } catch (error) { - console.error("게시물 추가 오류:", error) - } - } - - // 게시물 업데이트 - const updatePost = async () => { - try { - const response = await fetch(`/api/posts/${selectedPost.id}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(selectedPost), - }) - const data = await response.json() - setPosts(posts.map((post) => (post.id === data.id ? data : post))) - setShowEditDialog(false) - } catch (error) { - console.error("게시물 업데이트 오류:", error) - } - } - - // 게시물 삭제 - const deletePost = async (id) => { - try { - await fetch(`/api/posts/${id}`, { - method: "DELETE", - }) - setPosts(posts.filter((post) => post.id !== id)) - } catch (error) { - console.error("게시물 삭제 오류:", error) - } - } - - // 댓글 가져오기 - const fetchComments = async (postId) => { - if (comments[postId]) return // 이미 불러온 댓글이 있으면 다시 불러오지 않음 - try { - const response = await fetch(`/api/comments/post/${postId}`) - const data = await response.json() - setComments((prev) => ({ ...prev, [postId]: data.comments })) - } catch (error) { - console.error("댓글 가져오기 오류:", error) - } - } - - // 댓글 추가 - const addComment = async () => { - try { - const response = await fetch("/api/comments/add", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(newComment), - }) - const data = await response.json() - setComments((prev) => ({ - ...prev, - [data.postId]: [...(prev[data.postId] || []), data], - })) - setShowAddCommentDialog(false) - setNewComment({ body: "", postId: null, userId: 1 }) - } catch (error) { - console.error("댓글 추가 오류:", error) - } - } - - // 댓글 업데이트 - const updateComment = async () => { - try { - const response = await fetch(`/api/comments/${selectedComment.id}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ body: selectedComment.body }), - }) - const data = await response.json() - setComments((prev) => ({ - ...prev, - [data.postId]: prev[data.postId].map((comment) => (comment.id === data.id ? data : comment)), - })) - setShowEditCommentDialog(false) - } catch (error) { - console.error("댓글 업데이트 오류:", error) - } - } - - // 댓글 삭제 - const deleteComment = async (id, postId) => { - try { - await fetch(`/api/comments/${id}`, { - method: "DELETE", - }) - setComments((prev) => ({ - ...prev, - [postId]: prev[postId].filter((comment) => comment.id !== id), - })) - } catch (error) { - console.error("댓글 삭제 오류:", error) - } - } - - // 댓글 좋아요 - const likeComment = async (id, postId) => { - try { - - const response = await fetch(`/api/comments/${id}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ likes: comments[postId].find((c) => c.id === id).likes + 1 }), - }) - const data = await response.json() - setComments((prev) => ({ - ...prev, - [postId]: prev[postId].map((comment) => (comment.id === data.id ? {...data, likes: comment.likes + 1} : comment)), - })) - } catch (error) { - console.error("댓글 좋아요 오류:", error) - } - } - - // 게시물 상세 보기 - const openPostDetail = (post) => { - setSelectedPost(post) - fetchComments(post.id) - setShowPostDetailDialog(true) - } - - // 사용자 모달 열기 - const openUserModal = async (user) => { - try { - const response = await fetch(`/api/users/${user.id}`) - const userData = await response.json() - setSelectedUser(userData) - setShowUserModal(true) - } catch (error) { - console.error("사용자 정보 가져오기 오류:", error) - } - } - - useEffect(() => { - fetchTags() - }, []) - - useEffect(() => { - if (selectedTag) { - fetchPostsByTag(selectedTag) - } else { - fetchPosts() - } - updateURL() - }, [skip, limit, sortBy, sortOrder, selectedTag]) - - useEffect(() => { - const params = new URLSearchParams(location.search) - setSkip(parseInt(params.get("skip") || "0")) - setLimit(parseInt(params.get("limit") || "10")) - setSearchQuery(params.get("search") || "") - setSortBy(params.get("sortBy") || "") - setSortOrder(params.get("sortOrder") || "asc") - setSelectedTag(params.get("tag") || "") - }, [location.search]) - - // 하이라이트 함수 추가 - const highlightText = (text: string, highlight: string) => { - if (!text) return null - if (!highlight.trim()) { - return {text} - } - const regex = new RegExp(`(${highlight})`, "gi") - const parts = text.split(regex) - return ( - - {parts.map((part, i) => (regex.test(part) ? {part} : {part}))} - - ) - } - - // 게시물 테이블 렌더링 - const renderPostTable = () => ( - - - - ID - 제목 - 작성자 - 반응 - 작업 - - - - {posts.map((post) => ( - - {post.id} - -
-
{highlightText(post.title, searchQuery)}
- -
- {post.tags?.map((tag) => ( - { - setSelectedTag(tag) - updateURL() - }} - > - {tag} - - ))} -
-
-
- -
openUserModal(post.author)}> - {post.author?.username} - {post.author?.username} -
-
- -
- - {post.reactions?.likes || 0} - - {post.reactions?.dislikes || 0} -
-
- -
- - - -
-
-
- ))} -
-
- ) - - // 댓글 렌더링 - const renderComments = (postId) => ( -
-
-

댓글

- -
-
- {comments[postId]?.map((comment) => ( -
-
- {comment.user.username}: - {highlightText(comment.body, searchQuery)} -
-
- - - -
-
- ))} -
-
- ) - - return ( - - - - 게시물 관리자 - - - - -
- {/* 검색 및 필터 컨트롤 */} -
-
-
- - setSearchQuery(e.target.value)} - onKeyPress={(e) => e.key === "Enter" && searchPosts()} - /> -
-
- - - -
- - {/* 게시물 테이블 */} - {loading ?
로딩 중...
: renderPostTable()} - - {/* 페이지네이션 */} -
-
- 표시 - - 항목 -
-
- - -
-
-
-
- - {/* 게시물 추가 대화상자 */} - - - - 새 게시물 추가 - -
- setNewPost({ ...newPost, title: e.target.value })} - /> -