diff --git a/.github/labeler.yml b/.github/labeler.yml deleted file mode 100644 index 9302245..0000000 --- a/.github/labeler.yml +++ /dev/null @@ -1,47 +0,0 @@ -home: - - changed-files: - - any-glob-to-any-file: "app/(home)/**" - -post: - - changed-files: - - any-glob-to-any-file: "app/post/**" - -category: - - changed-files: - - any-glob-to-any-file: "app/(category)/**" - -dock: - - changed-files: - - any-glob-to-any-file: "components/dock/**" - -pagination: - - changed-files: - - any-glob-to-any-file: "components/pagination/**" - -widget: - - changed-files: - - any-glob-to-any-file: "components/widgets/**" - -series: - - changed-files: - - any-glob-to-any-file: "components/series/**" - -comments: - - changed-files: - - any-glob-to-any-file: "components/comments/**" - -storybook: - - changed-files: - - any-glob-to-any-file: "stories/**" - -infra: - - changed-files: - - any-glob-to-any-file: ".github/**" - -test: - - changed-files: - - any-glob-to-any-file: "tests/**" - -util: - - changed-files: - - any-glob-to-any-file: "lib/**" diff --git a/.github/labels.yml b/.github/labels.yml deleted file mode 100644 index c15c138..0000000 --- a/.github/labels.yml +++ /dev/null @@ -1,45 +0,0 @@ -- name: feat - color: "1d76db" - description: New feature -- name: chore - color: "cccccc" - description: Chore / infra -- name: test - color: "0e8a16" - description: Testing -- name: a11y - color: "5319e7" - description: Accessibility -- name: home - color: "0052cc" - description: Home page -- name: post - color: "fbca04" - description: Post detail -- name: category - color: "d93f0b" - description: Category page -- name: dock - color: "5319e7" - description: DockMenu -- name: comments - color: "0366d6" - description: Giscus / Comments -- name: widget - color: "0b7285" - description: Widgets / metrics -- name: series - color: "a2eeef" - description: Series -- name: pagination - color: "7057ff" - description: Pagination -- name: util - color: "6f42c1" - description: Utils / helpers -- name: infra - color: "24292e" - description: Infra / CI -- name: storybook - color: "ededed" - description: Storybook diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 92f27f7..ef5564c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,7 +24,6 @@ jobs: - run: pnpm typecheck - run: pnpm lint - - run: pnpm test:ci - run: pnpm build diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml deleted file mode 100644 index 37ae8e6..0000000 --- a/.github/workflows/labeler.yml +++ /dev/null @@ -1,17 +0,0 @@ -name: PR Labeler -on: - pull_request_target: - types: [opened, synchronize, reopened] - -permissions: - contents: read - pull-requests: write - -jobs: - label: - runs-on: ubuntu-latest - steps: - - uses: actions/labeler@v5 - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - configuration-path: .github/labeler.yml diff --git a/.github/workflows/labels-sync.yml b/.github/workflows/labels-sync.yml deleted file mode 100644 index 907e1c6..0000000 --- a/.github/workflows/labels-sync.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: Sync labels -on: - workflow_dispatch: - push: - paths: - - .github/labels.yml - -permissions: - issues: write - contents: read - -jobs: - labels: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - sparse-checkout: .github/labels.yml - - uses: EndBug/label-sync@v2 - with: - config-file: .github/labels.yml - delete-other-labels: false diff --git a/src/app/(layout)/(shell)/_components/layout/SiteHeader.tsx b/src/app/(layout)/(shell)/_components/layout/SiteHeader.tsx index fe124f2..7c1a3b4 100644 --- a/src/app/(layout)/(shell)/_components/layout/SiteHeader.tsx +++ b/src/app/(layout)/(shell)/_components/layout/SiteHeader.tsx @@ -38,14 +38,14 @@ export default function SiteHeader() { "min-w-46 cursor-pointer ", "hidden items-center gap-2 rounded-full", "bg-neutral-200/50 ", - "dark:border dark:border-foreground/15 dark:bg-background/40 pl-3 pr-2 py-1.5", + "dark:border dark:border-foreground/15 dark:bg-foreground/7 pl-3 pr-2 py-1", "text-xs text-foreground/70 md:inline-flex" )} >
검색 - + ⌘K
diff --git a/src/app/(layout)/posts/[slug]/_components/PostActions.tsx b/src/app/(layout)/posts/[slug]/_components/PostActions.tsx index a8d4485..100174d 100644 --- a/src/app/(layout)/posts/[slug]/_components/PostActions.tsx +++ b/src/app/(layout)/posts/[slug]/_components/PostActions.tsx @@ -5,9 +5,13 @@ interface PostActionsProps { variant?: "desktop" | "mobile"; title: string; thumbnail?: string; + post: { + slug: string + }; } export default function PostActions({ + post, variant = "desktop", title, thumbnail, @@ -15,7 +19,7 @@ export default function PostActions({ if (variant === "mobile") { return ( ); @@ -23,9 +27,9 @@ export default function PostActions({ return ( ); diff --git a/src/app/(layout)/posts/[slug]/_components/post-actions/LikeButton.tsx b/src/app/(layout)/posts/[slug]/_components/post-actions/LikeButton.tsx index 49d0bee..3b17d85 100644 --- a/src/app/(layout)/posts/[slug]/_components/post-actions/LikeButton.tsx +++ b/src/app/(layout)/posts/[slug]/_components/post-actions/LikeButton.tsx @@ -1,34 +1,40 @@ "use client"; +import { usePostLike } from "@/hooks/usePostLike"; import clsx from "clsx"; import { Heart } from "lucide-react"; import { useState } from "react"; -export default function LikeButton() { - const [isLiked, setIsLiked] = useState(false); - const [likeCount] = useState(0); - const [isPopping, setIsPopping] = useState(false); +interface PostLikeButtonProps { + postId: string; +} - const handleClick = () => { - setIsLiked((v) => !v); +export default function LikeButton({ postId }: PostLikeButtonProps) { + const { count, liked, loading, toggleLike } = usePostLike(postId); + + const [isPopping, setIsPopping] = useState(false); + const handleClick = async () => { + await toggleLike(); setIsPopping(true); window.setTimeout(() => setIsPopping(false), 180); }; return ( ); diff --git a/src/app/(layout)/posts/[slug]/_components/post-actions/ShareButton.tsx b/src/app/(layout)/posts/[slug]/_components/post-actions/ShareButton.tsx index 7751855..45e7076 100644 --- a/src/app/(layout)/posts/[slug]/_components/post-actions/ShareButton.tsx +++ b/src/app/(layout)/posts/[slug]/_components/post-actions/ShareButton.tsx @@ -38,7 +38,7 @@ export default function ShareButton({ title, thumbnail }: ShareButtonProps) {
- +
diff --git a/src/hooks/usePostLike.ts b/src/hooks/usePostLike.ts new file mode 100644 index 0000000..7fae34b --- /dev/null +++ b/src/hooks/usePostLike.ts @@ -0,0 +1,127 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import type { PostgrestError } from "@supabase/supabase-js"; +import { supabase } from "@/lib/supabase/client"; +import { getOrCreateViewerId } from "@/lib/supabase/viewerId"; + +type UsePostLikeResult = { + count: number; + liked: boolean; + loading: boolean; + toggleLike: () => Promise; +}; + +function isDuplicateKeyError(error: PostgrestError | null): boolean { + return !!error && error.code === "23505"; +} + +export function usePostLike(postId: string): UsePostLikeResult { + const [count, setCount] = useState(0); + const [liked, setLiked] = useState(false); + const [loading, setLoading] = useState(true); + + const viewerIdRef = useRef(null); + + useEffect(() => { + viewerIdRef.current = getOrCreateViewerId(); + }, []); + + useEffect(() => { + if (!postId) return; + + const fetchLikeState = async () => { + const viewerId = viewerIdRef.current ?? getOrCreateViewerId(); + if (!viewerId) { + setLoading(false); + return; + } + + const { + data, + count: total, + error, + } = await supabase + .from("post_likes") + .select("viewer_id", { count: "exact" }) + .eq("post_id", postId); + + if (error) { + console.error("좋아요 조회 실패:", error); + setLoading(false); + return; + } + + const rows = data ?? []; + + setCount(total ?? rows.length); + setLiked(rows.some((row) => row.viewer_id === viewerId)); + setLoading(false); + }; + + void fetchLikeState(); + }, [postId]); + + const refreshCount = async () => { + const { count: total, error } = await supabase + .from("post_likes") + .select("*", { count: "exact", head: true }) + .eq("post_id", postId); + + if (error) { + console.error("좋아요 카운트 재조회 실패:", error); + return; + } + + if (typeof total === "number") { + setCount(total); + } + }; + + const toggleLike = async () => { + const viewerId = viewerIdRef.current ?? getOrCreateViewerId(); + if (!viewerId) return; + + if (liked) { + const { error } = await supabase + .from("post_likes") + .delete() + .eq("post_id", postId) + .eq("viewer_id", viewerId); + + if (error) { + console.error("좋아요 취소 실패:", error); + return; + } + + setLiked(false); + setCount((prev) => Math.max(0, prev - 1)); + return; + } + + const { error } = await supabase.from("post_likes").insert({ + post_id: postId, + viewer_id: viewerId, + }); + + if (error) { + if (isDuplicateKeyError(error)) { + setLiked(true); + await refreshCount(); + } else { + console.error("좋아요 추가 실패:", error); + } + return; + } + + setLiked(true); + setCount((prev) => prev + 1); + }; + + return { + count, + liked, + loading, + toggleLike, + }; +} \ No newline at end of file diff --git a/src/lib/supabase/viewerId.ts b/src/lib/supabase/viewerId.ts new file mode 100644 index 0000000..15e5e0f --- /dev/null +++ b/src/lib/supabase/viewerId.ts @@ -0,0 +1,19 @@ +const VIEWER_ID_KEY = "b_log_viewer_id"; + +export function getOrCreateViewerId(): string | null { + if (typeof window === "undefined") return null; + + let id = window.localStorage.getItem(VIEWER_ID_KEY); + + if (!id) { + if (window.crypto?.randomUUID) { + id = window.crypto.randomUUID(); + } else { + id = `${Date.now()}-${Math.random().toString(16).slice(2)}`; + } + + window.localStorage.setItem(VIEWER_ID_KEY, id); + } + + return id; +}