diff --git a/content/posts/INSIGHT/git-merge.mdx b/content/posts/INSIGHT/git-merge.mdx index d692433..c985613 100644 --- a/content/posts/INSIGHT/git-merge.mdx +++ b/content/posts/INSIGHT/git-merge.mdx @@ -6,7 +6,7 @@ category: "Insight" series: "git/github" tags: ["git", "github"] summary: "Git Flow를 전제로 merge, squash, rebase의 차이와 협업에서 rebase를 조심해야 하는 이유를 정리한 글" -thumbnail: "https://res.cloudinary.com/dvapam1ks/image/upload/v1770430277/git_github%E1%84%8A%E1%85%A5%E1%86%B7%E1%84%82%E1%85%A6%E1%84%8B%E1%85%B5%E1%86%AF_sfk61p.png" +thumbnail: "https://res.cloudinary.com/dvapam1ks/image/upload/v1770451051/git-1%E1%84%8A%E1%85%A5%E1%86%B7%E1%84%82%E1%85%A6%E1%84%8B%E1%85%B5%E1%86%AF_zilex3.png" draft: false --- diff --git a/src/app/(layout)/(shell)/(category)/[category]/layout.tsx b/src/app/(layout)/(shell)/(category)/[category]/layout.tsx index f812cda..d8366a3 100644 --- a/src/app/(layout)/(shell)/(category)/[category]/layout.tsx +++ b/src/app/(layout)/(shell)/(category)/[category]/layout.tsx @@ -4,7 +4,7 @@ export default function SiteLayout({ children: React.ReactNode; }) { return ( -
+
{children}
); diff --git a/src/app/(layout)/(shell)/_components/posts/PostListGrid.tsx b/src/app/(layout)/(shell)/_components/posts/PostListGrid.tsx index 6eefb99..bcbfbfb 100644 --- a/src/app/(layout)/(shell)/_components/posts/PostListGrid.tsx +++ b/src/app/(layout)/(shell)/_components/posts/PostListGrid.tsx @@ -18,7 +18,7 @@ export default function PostListGrid({ className={clsx( "md:mt-10", isSmall ? "gap-5" : "gap-8", - "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4", + "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 3xl:grid-cols-4", "items-stretch justify-items-center" )} > diff --git a/src/app/globals.css b/src/app/globals.css index 2702816..6e87f82 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,6 +1,6 @@ @import "tailwindcss"; @plugin "@tailwindcss/typography"; -@config "../../tailwind.config.ts"; +@config "../../tailwind.config.ts"; /* 디자인 토큰 & 다크모드 */ @import "../styles/tokens.css"; @@ -22,6 +22,7 @@ /* 백그라운드 애니메이션 우주 배경 */ @import "../styles/components/background.css"; + .heading-anchor { text-decoration: none; } diff --git a/src/components/common/CommandPalette.tsx b/src/components/common/CommandPalette.tsx index 6f6b238..7c17922 100644 --- a/src/components/common/CommandPalette.tsx +++ b/src/components/common/CommandPalette.tsx @@ -2,6 +2,7 @@ import useCommandPaletteInternal from "@/hooks/useCommandPaletteInternal"; import * as Dialog from "@radix-ui/react-dialog"; +import clsx from "clsx"; import { Command } from "cmdk"; import { Search } from "lucide-react"; import { createContext, useContext, type ReactNode } from "react"; @@ -25,14 +26,14 @@ interface CommandPaletteProviderProps { } const CommandPaletteContext = createContext( - null + null, ); export function useCommandPalette() { const ctx = useContext(CommandPaletteContext); if (!ctx) { throw new Error( - "useCommandPalette must be used within CommandPaletteProvider" + "useCommandPalette must be used within CommandPaletteProvider", ); } return ctx; @@ -44,7 +45,7 @@ export function CommandPaletteProvider({ const { open, query, - filteredItems, + displayItems, openPalette, closePalette, togglePalette, @@ -62,12 +63,12 @@ export function CommandPaletteProvider({ 사이트 검색 @@ -86,13 +87,19 @@ export function CommandPaletteProvider({ - + 검색 결과가 없습니다. - {filteredItems.map((item) => ( + {displayItems.map((item) => ( void; + onPrefetch?: (value: PostSortValue) => void; className?: string; } @@ -15,7 +16,7 @@ const SORT_OPTIONS: { value: PostSortValue; label: string }[] = [ { value: "popular", label: "인기순" }, ]; -export default function SortSelect({ value, onChange, className }: Props) { +export default function SortSelect({ value, onChange, onPrefetch, className }: Props) { const [internalValue, setInternalValue] = useState("latest"); const currentValue = value ?? internalValue; @@ -42,6 +43,7 @@ export default function SortSelect({ value, onChange, className }: Props) { key={option.value} type="button" onClick={() => handleChange(option.value)} + onPointerEnter={() => onPrefetch?.(option.value)} aria-pressed={isActive} className={clsx( "inline-flex items-center justify-center rounded-full px-1 py-1", diff --git a/src/components/common/controls/sort/SortSelectClient.tsx b/src/components/common/controls/sort/SortSelectClient.tsx index 789a27c..3c9765f 100644 --- a/src/components/common/controls/sort/SortSelectClient.tsx +++ b/src/components/common/controls/sort/SortSelectClient.tsx @@ -13,14 +13,27 @@ export default function SortSelectClient({ value }: SortSelectClientProps) { const router = useRouter(); const searchParams = useSearchParams(); - const handleChange = (next: PostSortValue) => { + const buildUrl = (next: PostSortValue) => { const params = new URLSearchParams(searchParams.toString()); - params.set("sort", next); params.set("page", "1"); + return `?${params.toString()}`; + }; + + const handleChange = (next: PostSortValue) => { + router.push(buildUrl(next), { scroll: false }); + }; - router.push(`?${params.toString()}`, { scroll: false }); + const handlePrefetch = (next: PostSortValue) => { + if (next === value) return; + router.prefetch(buildUrl(next)); }; - return ; + return ( + + ); } diff --git a/src/components/common/icons/FolderIcon.tsx b/src/components/common/icons/FolderIcon.tsx index cdba501..a34ba37 100644 --- a/src/components/common/icons/FolderIcon.tsx +++ b/src/components/common/icons/FolderIcon.tsx @@ -6,11 +6,11 @@ import { CSSProperties } from "react"; export type FolderTone = "gray" | "blue" | "pink" | "purple" | "orange" | "darkblue"; const TONE_COLOR: Record = { - gray: "#9c9fa9", - blue: "#0ea5e9", - darkblue: "#357fff", + gray: "#6c6c6c", + blue: "#0da1e6", + darkblue: "#2371f8", pink: "#ff66b2", - purple: "#8679ec", + purple: "#6454de", orange: "#f1842a", }; diff --git a/src/hooks/useCommandPaletteInternal.ts b/src/hooks/useCommandPaletteInternal.ts index e0b914c..b1edb56 100644 --- a/src/hooks/useCommandPaletteInternal.ts +++ b/src/hooks/useCommandPaletteInternal.ts @@ -6,8 +6,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; type UseCommandPaletteResult = { open: boolean; query: string; - items: CommandItem[]; - filteredItems: CommandItem[]; + displayItems: CommandItem[]; openPalette: () => void; closePalette: () => void; togglePalette: () => void; @@ -38,6 +37,12 @@ const STATIC_ITEMS: CommandItem[] = [ }, ]; +function isPostItem(item: CommandItem) { + return ( + item.id.startsWith("post-") || (item.href?.startsWith("/posts/") ?? false) + ); +} + function buildPostItems(): CommandItem[] { const posts = getAllPosts({ includeDrafts: false }); @@ -72,19 +77,53 @@ function buildPostItems(): CommandItem[] { }); } +const RECENT_KEY = "b_log_cmdk_recent"; +const RECENT_LIMIT = 3; + +function readRecent(): CommandItem[] { + if (typeof window === "undefined") return []; + try { + const raw = sessionStorage.getItem(RECENT_KEY); + const parsed = raw ? (JSON.parse(raw) as CommandItem[]) : []; + return Array.isArray(parsed) ? parsed.slice(0, RECENT_LIMIT) : []; + } catch { + return []; + } +} + +function writeRecent(items: CommandItem[]) { + sessionStorage.setItem( + RECENT_KEY, + JSON.stringify(items.slice(0, RECENT_LIMIT)), + ); +} + +function pushRecent(item: CommandItem): CommandItem[] { + const current = readRecent(); + const next = [item, ...current.filter((x) => x.id !== item.id)].slice( + 0, + RECENT_LIMIT, + ); + writeRecent(next); + return next; +} + export default function useCommandPaletteInternal(): UseCommandPaletteResult { const router = useRouter(); const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); + const [recentItems, setRecentItems] = useState(() => + readRecent(), + ); const items = useMemo(() => { const postItems = buildPostItems(); return [...STATIC_ITEMS, ...postItems]; }, []); - const filteredItems = useMemo(() => { + const searchResults = useMemo(() => { const q = query.trim().toLowerCase(); - if (!q) return items; + if (!q) return []; return items.filter((item) => { const target = ( @@ -101,6 +140,14 @@ export default function useCommandPaletteInternal(): UseCommandPaletteResult { }); }, [items, query]); + const displayItems = useMemo(() => { + const q = query.trim(); + if (q.length > 0) return searchResults; + + if (recentItems.length === 0) return STATIC_ITEMS; + return [...STATIC_ITEMS, ...recentItems]; + }, [query, recentItems, searchResults]); + const openPalette = useCallback(() => { setQuery(""); setOpen(true); @@ -123,13 +170,16 @@ export default function useCommandPaletteInternal(): UseCommandPaletteResult { const handleSelect = useCallback( (item: CommandItem) => { + if (isPostItem(item)) { + setRecentItems(pushRecent(item)); + } if (item.href) { router.push(item.href); } setOpen(false); setQuery(""); }, - [router] + [router], ); const handleOpenChange = useCallback((next: boolean) => { @@ -157,8 +207,7 @@ export default function useCommandPaletteInternal(): UseCommandPaletteResult { return { open, query, - items, - filteredItems, + displayItems, openPalette, closePalette, togglePalette, @@ -166,4 +215,4 @@ export default function useCommandPaletteInternal(): UseCommandPaletteResult { handleSelect, handleOpenChange, }; -} \ No newline at end of file +} diff --git a/src/hooks/usePostLike.ts b/src/hooks/usePostLike.ts index a17b5c7..118683f 100644 --- a/src/hooks/usePostLike.ts +++ b/src/hooks/usePostLike.ts @@ -22,7 +22,7 @@ export function usePostLike(postId: string): UsePostLikeResult { const [liked, setLiked] = useState(false); const [loading, setLoading] = useState(true); - const viewerIdRef = useRef(null); + const viewerIdRef = useRef(""); useEffect(() => { viewerIdRef.current = getOrCreateViewerId(); @@ -32,7 +32,7 @@ export function usePostLike(postId: string): UsePostLikeResult { if (!postId) return; const run = async () => { - const viewerId = viewerIdRef.current ?? getOrCreateViewerId(); + const viewerId = viewerIdRef.current; const { count, liked } = await fetchPostLikeState(postId, viewerId); setCount(count); @@ -44,7 +44,7 @@ export function usePostLike(postId: string): UsePostLikeResult { }, [postId]); const toggleLike = async () => { - const viewerId = viewerIdRef.current ?? getOrCreateViewerId(); + const viewerId = viewerIdRef.current; if (!viewerId || !postId) return; if (liked) { @@ -60,6 +60,9 @@ export function usePostLike(postId: string): UsePostLikeResult { } const { error } = await addPostLike(postId, viewerId); + if (error) { + console.error("add like error:", error.code, error.message, error.details, error.hint); +} if (error) { if (isDuplicateKeyError(error)) { diff --git a/src/hooks/useShare.ts b/src/hooks/useShare.ts index 56c7406..af891f7 100644 --- a/src/hooks/useShare.ts +++ b/src/hooks/useShare.ts @@ -6,6 +6,7 @@ type ShareOptions = { title: string; url?: string; imageUrl?: string; + includeImageFile?: boolean; }; type ShareResult = @@ -17,6 +18,28 @@ type NavigatorWithShare = Navigator & { canShare?: (data?: ShareData) => boolean; }; +type ShareDataWithFiles = ShareData & { + files?: File[]; +}; + +function normalizeUrl(url: string) { + return new URL(url, window.location.href).toString(); +} + +async function buildShareFile(imageUrl: string): Promise { + const normalized = normalizeUrl(imageUrl); + const response = await fetch(normalized, { cache: "force-cache" }); + const blob = await response.blob(); + + return new File([blob], "post-thumbnail.png", { + type: blob.type || "image/png", + }); +} + +function isUserAbort(error: Error) { + return error.name === "AbortError" || error.name === "InvalidStateError"; +} + export function useShare(defaultOptions?: Partial) { const isSharingRef = useRef(false); @@ -25,7 +48,7 @@ export function useShare(defaultOptions?: Partial) { if (typeof window === "undefined") { return { ok: false, - error: new Error("클라이언트 환경에서만 공유 기능을 사용할 수 있습니다.") + error: new Error("클라이언트 환경에서만 공유 기능을 사용할 수 있습니다."), }; } @@ -33,75 +56,77 @@ export function useShare(defaultOptions?: Partial) { return { ok: false, aborted: true, - error: new Error("이미 공유가 진행 중입니다.") + error: new Error("이미 공유가 진행 중입니다."), }; } const merged: ShareOptions = { title: options?.title ?? defaultOptions?.title ?? document.title, url: options?.url ?? defaultOptions?.url ?? window.location.href, - imageUrl: options?.imageUrl ?? defaultOptions?.imageUrl + imageUrl: options?.imageUrl ?? defaultOptions?.imageUrl, + includeImageFile: + options?.includeImageFile ?? + defaultOptions?.includeImageFile ?? + false, }; if (!merged.url) { - return { - ok: false, - error: new Error("공유할 URL이 없습니다.") - }; + return { ok: false, error: new Error("공유할 URL이 없습니다.") }; } const nav = navigator as NavigatorWithShare; - const shareData: ShareData & { files?: File[] } = { - title: merged.title, - url: merged.url - }; + const url = normalizeUrl(merged.url); isSharingRef.current = true; try { - if (merged.imageUrl && typeof nav.canShare === "function") { + const canNativeShare = typeof nav.share === "function"; + + if ( + merged.includeImageFile && + merged.imageUrl && + canNativeShare && + typeof nav.canShare === "function" + ) { try { - const response = await fetch(merged.imageUrl); - const blob = await response.blob(); - const file = new File([blob], "post-thumbnail.png", { - type: blob.type || "image/png" - }); - - if (nav.canShare({ files: [file] })) { - shareData.files = [file]; + const file = await buildShareFile(merged.imageUrl); + const data: ShareDataWithFiles = { + title: merged.title, + url, + files: [file], + }; + + if (nav.canShare(data)) { + await nav.share(data); + return { ok: true, method: "native" }; } - } catch {} + } catch (e) { + const err = e instanceof Error ? e : new Error(String(e)); + if (isUserAbort(err)) { + return { ok: false, aborted: true, error: err }; + } + } } - if (typeof nav.share === "function") { + if (canNativeShare) { try { - await nav.share(shareData); + await nav.share({ title: merged.title, url }); return { ok: true, method: "native" }; - } catch (error) { - const err = error instanceof Error ? error : new Error(String(error)); - if (err.name === "AbortError") { - return { ok: false, aborted: true, error: err }; - } - if (err.name === "InvalidStateError") { + } catch (e) { + const err = e instanceof Error ? e : new Error(String(e)); + if (isUserAbort(err)) { return { ok: false, aborted: true, error: err }; } } } - if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") { - try { - await navigator.clipboard.writeText(merged.url); - return { ok: true, method: "clipboard" }; - } catch (error) { - return { - ok: false, - error: error instanceof Error ? error : new Error("클립보드 복사에 실패했습니다.") - }; - } + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(url); + return { ok: true, method: "clipboard" }; } return { ok: false, - error: new Error("이 브라우저에서는 공유 기능을 사용할 수 없습니다.") + error: new Error("이 브라우저에서는 공유 기능을 사용할 수 없습니다."), }; } finally { isSharingRef.current = false; diff --git a/src/lib/posts/query.ts b/src/lib/posts/query.ts index 05f9309..75d9681 100644 --- a/src/lib/posts/query.ts +++ b/src/lib/posts/query.ts @@ -1,20 +1,26 @@ -import type { VelitePost } from "./source"; +import { fetchPopularRankPage } from "@/lib/supabase/postLikes"; import { getAllPosts } from "./queries"; -import { sortPosts, type PostSort, paginate, type PaginatedResult, getPageRange } from "./utils"; -import { getLikeCountsForPosts } from "@/lib/supabase/postLikes"; +import type { VelitePost } from "./source"; +import { + getPageRange, + paginate, + sortPosts, + type PaginatedResult, + type PostSort, +} from "./utils"; export interface QueryPostsParams { category?: string; series?: string; tag?: string; - sort?: PostSort; - page?: number; + sort?: PostSort; + page?: number; perPage?: number; includeDrafts?: boolean; - visiblePages?: number; + visiblePages?: number; } export interface QueryPostsResult { @@ -26,14 +32,23 @@ export interface QueryPostsResult { pageRange: number[]; - applied: Required> & { + applied: Required< + Pick< + QueryPostsParams, + "sort" | "page" | "perPage" | "includeDrafts" | "visiblePages" + > + > & { category?: string; series?: string; tag?: string; }; } -export async function queryPosts(params: QueryPostsParams = {}): Promise { +const POPULAR_CANDIDATE_MULTIPLIER = 10; + +export async function queryPosts( + params: QueryPostsParams = {}, +): Promise { const { category, series, @@ -57,21 +72,64 @@ export async function queryPosts(params: QueryPostsParams = {}): Promise p.tags?.includes(tag)); } - let sorted: VelitePost[]; - if (sort === "popular") { - const ids = pool.map((p) => p.slug); - - const likeCounts = await getLikeCountsForPosts(ids); - - sorted = sortPosts(pool, "popular", likeCounts); - } else { - sorted = sortPosts(pool, sort); + const safePage = Math.max(1, Math.floor(page)); + const safePerPage = Math.max(1, Math.floor(perPage)); + + const candidateLimit = safePerPage * POPULAR_CANDIDATE_MULTIPLIER; + const candidateRows = await fetchPopularRankPage(candidateLimit, 0); + + const rankedIds = candidateRows.map((r) => r.post_id); + const rankIndex = new Map( + rankedIds.map((id, i) => [id, i]), + ); + + const likedSorted = pool + .filter((p) => rankIndex.has(p.slug)) + .sort((a, b) => rankIndex.get(a.slug)! - rankIndex.get(b.slug)!); + + const zeroLikeSorted = sortPosts( + pool.filter((p) => !rankIndex.has(p.slug)), + "latest", + ); + + const merged = [...likedSorted, ...zeroLikeSorted]; + + const pagination = paginate(merged, { + page: safePage, + perPage: safePerPage, + }); + const pageRange = getPageRange( + pagination.page, + pagination.totalPages, + visiblePages, + ); + + return { + posts: pagination.items, + totalFiltered: merged.length, + pagination, + pageRange, + applied: { + category, + series, + tag, + sort, + page: pagination.page, + perPage: pagination.perPage, + includeDrafts, + visiblePages, + }, + }; } + const sorted = sortPosts(pool, sort); const pagination = paginate(sorted, { page, perPage }); - - const pageRange = getPageRange(pagination.page, pagination.totalPages, visiblePages); + const pageRange = getPageRange( + pagination.page, + pagination.totalPages, + visiblePages, + ); return { posts: pagination.items, @@ -80,7 +138,7 @@ export async function queryPosts(params: QueryPostsParams = {}): Promise; export type LikeState = { @@ -104,3 +106,23 @@ export async function getLikeCountsForPosts( return map; } + +export async function fetchPopularRankPage( + limit: number, + offset: number, +): Promise { + const { data, error } = await supabase + .from("post_like_counts") + .select("post_id, like_count") + .gt("like_count", 0) + .order("like_count", { ascending: false }) + .order("updated_at", { ascending: false }) + .range(offset, offset + limit - 1); + + if (error) { + console.error("🚨 인기순 페이지 패치 실패:", error); + return []; + } + + return (data ?? []) as PopularRow[]; +} \ No newline at end of file diff --git a/src/lib/supabase/viewerId.ts b/src/lib/supabase/viewerId.ts index 15e5e0f..035ad6b 100644 --- a/src/lib/supabase/viewerId.ts +++ b/src/lib/supabase/viewerId.ts @@ -1,19 +1,18 @@ -const VIEWER_ID_KEY = "b_log_viewer_id"; +const UUID_REGEX = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; -export function getOrCreateViewerId(): string | null { - if (typeof window === "undefined") return null; +const KEY = "viewer_id"; +export function getOrCreateViewerId(): string { + if (typeof window === "undefined") return ""; - let id = window.localStorage.getItem(VIEWER_ID_KEY); + try { + const existing = window.localStorage.getItem(KEY); + if (existing && UUID_REGEX.test(existing)) return existing; - 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); + const id = crypto.randomUUID(); + window.localStorage.setItem(KEY, id); + return id; + } catch { + return ""; } - - return id; } diff --git a/src/styles/tokens.css b/src/styles/tokens.css index 801718e..cd26542 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -1,4 +1,6 @@ @theme { + --breakpoint-3xl: 106.875rem; + --font-body: var(--font-pretendard), system-ui, -apple-system, sans-serif; --font-point: var(--font-permanent), system-ui, -apple-system, sans-serif; @@ -15,6 +17,9 @@ --color-tag-soft-alt: #f1f5f9; --color-post: #f9fafb; + + --color-scrollbar-thumb: rgba(15, 23, 42, 0.18); + --color-scrollbar-thumb-hover: rgba(15, 23, 42, 0.28); } body { @@ -31,5 +36,33 @@ body { --color-accent: #ec4899; --color-post: #071024; + + --color-scrollbar-thumb: rgba(187, 187, 187, 0.852); + --color-scrollbar-thumb-hover: rgba(15, 23, 42, 0.28); + } +} + +@layer base { + ::-webkit-scrollbar { + width: 10px; + height: 10px; + } + + ::-webkit-scrollbar-track { + background: transparent; + } + + ::-webkit-scrollbar-thumb { + background-color: var(--color-scrollbar-thumb); + border-radius: 9999px; + } + + ::-webkit-scrollbar-thumb:hover { + background-color: var(--color-scrollbar-thumb-hover); + } + + * { + scrollbar-width: thin; + scrollbar-color: var(--color-scrollbar-thumb) transparent; } } \ No newline at end of file diff --git a/tailwind.config.ts b/tailwind.config.ts index f760d1f..d87b6f5 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -7,13 +7,6 @@ const config: Config = { "./src/components/**/*.{js,ts,jsx,tsx,mdx}", "./src/**/*.{js,ts,jsx,tsx,mdx}", ], - theme: { - extend: { - screens: { - xl2: "1410px", - }, - }, - }, }; export default config;