디자인디테일

This commit is contained in:
koreacomp5
2025-11-01 23:16:22 +09:00
parent f84111b9cc
commit 27cf98eef2
20 changed files with 735 additions and 384 deletions

View File

@@ -5,6 +5,7 @@ import Link from "next/link";
import { SearchBar } from "@/app/components/SearchBar";
import React from "react";
import { usePathname, useSearchParams } from "next/navigation";
import { SinglePageLogo } from "@/app/components/SinglePageLogo";
export function AppHeader() {
const [categories, setCategories] = React.useState<Array<{ id: string; name: string; slug: string; boards: Array<{ id: string; name: string; slug: string }> }>>([]);
@@ -309,7 +310,7 @@ export function AppHeader() {
</span>
</button>
<Link href="/" aria-label="홈" className="shrink-0">
<Image src="/logo.png" alt="logo" width={120} height={28} priority className="w-20 xl:w-[120px] h-auto" />
<SinglePageLogo width={120} height={28} className="w-20 xl:w-[120px] h-auto" />
</Link>
</div>
<nav className="flex flex-1 items-center gap-4 justify-between">

View File

@@ -0,0 +1,75 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import React from "react";
export function BoardToolbar({ boardId }: { boardId: string }) {
const router = useRouter();
const sp = useSearchParams();
const sort = (sp.get("sort") as "recent" | "popular" | null) ?? "recent";
const scope = (sp.get("scope") as "q" | "author" | null) ?? "q"; // q: 제목+내용, author: 작성자
const defaultText = scope === "author" ? sp.get("author") ?? "" : sp.get("q") ?? "";
const period = sp.get("period") ?? "all"; // all | 1d | 1w | 1m
const onChangeSort = (e: React.ChangeEvent<HTMLSelectElement>) => {
const next = new URLSearchParams(sp.toString());
next.set("sort", e.target.value);
router.push(`/boards/${boardId}?${next.toString()}`);
};
const onChangePeriod = (e: React.ChangeEvent<HTMLSelectElement>) => {
const next = new URLSearchParams(sp.toString());
const v = e.target.value;
next.set("period", v);
// 계산된 start 적용
const now = new Date();
if (v === "1d") now.setDate(now.getDate() - 1);
if (v === "1w") now.setDate(now.getDate() - 7);
if (v === "1m") now.setMonth(now.getMonth() - 1);
if (v === "all") next.delete("start"); else next.set("start", now.toISOString());
router.push(`/boards/${boardId}?${next.toString()}`);
};
const onSubmit = (formData: FormData) => {
const next = new URLSearchParams(sp.toString());
const scopeSel = String(formData.get("scope") || "q");
const text = String(formData.get("text") || "");
next.set("scope", scopeSel);
if (scopeSel === "author") {
next.delete("q");
if (text) next.set("author", text); else next.delete("author");
} else {
next.delete("author");
if (text) next.set("q", text); else next.delete("q");
}
router.push(`/boards/${boardId}?${next.toString()}`);
};
return (
<div className="flex items-center justify-between px-0 py-2">
<div className="flex items-center gap-2">
<select aria-label="정렬" className="h-8 px-2 rounded-md border border-neutral-300 bg-white text-sm" defaultValue={sort} onChange={onChangeSort}>
<option value="recent"></option>
<option value="popular"></option>
</select>
<select aria-label="기간" className="h-8 px-2 rounded-md border border-neutral-300 bg-white text-sm" defaultValue={period} onChange={onChangePeriod}>
<option value="all"></option>
<option value="1d">24</option>
<option value="1w">1</option>
<option value="1m">1</option>
</select>
</div>
<form action={onSubmit} className="flex items-center gap-2">
<select name="scope" aria-label="검색대상" className="h-8 px-2 rounded-md border border-neutral-300 bg-white text-sm" defaultValue={scope}>
<option value="q">+</option>
<option value="author"></option>
</select>
<input name="text" defaultValue={defaultText} placeholder="검색어를 입력해 주세요." className="h-8 w-56 md:w-72 px-3 rounded-md border border-neutral-300 text-sm placeholder:text-neutral-400 focus:outline-none focus:ring-2 focus:ring-neutral-300" />
<button type="submit" className="h-8 px-3 rounded-md bg-neutral-900 text-white text-sm hover:bg-neutral-800"></button>
</form>
</div>
);
}
export default BoardToolbar;

View File

@@ -1,8 +1,8 @@
"use client";
import { useEffect, useRef, useCallback } from "react";
import { useEffect, useRef, useCallback, useMemo } from "react";
import { useToast } from "@/app/components/ui/ToastProvider";
type Props = { value: string; onChange: (v: string) => void; placeholder?: string };
type Props = { value: string; onChange: (v: string) => void; placeholder?: string; withToolbar?: boolean };
async function readFileAsDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
@@ -36,7 +36,7 @@ async function resizeImageToBlob(dataUrl: string, opts: { maxWidth: number; maxH
return await new Promise((resolve) => canvas.toBlob((b) => resolve(b as Blob), "image/webp", opts.quality));
}
export function Editor({ value, onChange, placeholder }: Props) {
export function Editor({ value, onChange, placeholder, withToolbar = true }: Props) {
const ref = useRef<HTMLDivElement | null>(null);
const { show } = useToast();
@@ -99,23 +99,107 @@ export function Editor({ value, onChange, placeholder }: Props) {
[uploadAndInsert]
);
const sync = useCallback(() => {
const el = ref.current;
if (el) onChange(el.innerHTML);
}, [onChange]);
const exec = useCallback(
(command: string, value?: string) => {
const el = ref.current;
if (!el) return;
el.focus();
try {
// eslint-disable-next-line deprecation/deprecation
document.execCommand(command, false, value ?? "");
} catch {
/* no-op */
}
sync();
},
[sync]
);
const wrapSelectionWithHtml = useCallback(
(prefix: string, suffix: string) => {
const selection = window.getSelection();
const text = selection?.toString() ?? "";
if (!text) return;
// eslint-disable-next-line deprecation/deprecation
document.execCommand("insertHTML", false, `${prefix}${text}${suffix}`);
sync();
},
[sync]
);
const toolbar = useMemo(() => {
if (!withToolbar) return null;
return (
<div className="flex flex-wrap items-center gap-1">
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("bold")} aria-label="굵게">B</button>
<button type="button" className="px-2 py-1 text-sm italic rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("italic")} aria-label="기울임">I</button>
<button type="button" className="px-2 py-1 text-sm line-through rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("strikeThrough")} aria-label="취소선">S</button>
<button type="button" className="px-2 py-1 text-sm underline rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("underline")} aria-label="밑줄">U</button>
<button
type="button"
className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100"
onClick={() => {
const url = window.prompt("링크 URL 입력 (예: https://example.com)")?.trim();
if (url) exec("createLink", url);
}}
aria-label="링크"
>
link
</button>
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("unlink")} aria-label="링크 제거">unlink</button>
<span className="mx-2 h-4 w-px bg-neutral-300" />
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("formatBlock", "H1")} aria-label="H1">H1</button>
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("formatBlock", "H2")} aria-label="H2">H2</button>
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("formatBlock", "H3")} aria-label="H3">H3</button>
<span className="mx-2 h-4 w-px bg-neutral-300" />
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("insertOrderedList")} aria-label="번호 목록">1.</button>
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("insertUnorderedList")} aria-label="글머리 목록"></button>
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => wrapSelectionWithHtml("<code>", "</code>")} aria-label="코드">&lt;/&gt;</button>
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("formatBlock", "BLOCKQUOTE")} aria-label="인용구"></button>
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("insertHorizontalRule")} aria-label="구분선"></button>
<span className="mx-2 h-4 w-px bg-neutral-300" />
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("justifyLeft")} aria-label="왼쪽 정렬"></button>
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("justifyCenter")} aria-label="가운데 정렬"></button>
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("justifyRight")} aria-label="오른쪽 정렬"></button>
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("justifyFull")} aria-label="양쪽 정렬"></button>
<span className="mx-2 h-4 w-px bg-neutral-300" />
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("indent")} aria-label="들여쓰기"></button>
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("outdent")} aria-label="내어쓰기"></button>
<span className="mx-2 h-4 w-px bg-neutral-300" />
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("removeFormat")} aria-label="서식 제거">clear</button>
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("undo")} aria-label="되돌리기"></button>
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("redo")} aria-label="다시하기"></button>
</div>
);
}, [exec, withToolbar, wrapSelectionWithHtml]);
return (
<div
ref={ref}
contentEditable
onInput={(e) => onChange((e.target as HTMLDivElement).innerHTML)}
onPaste={onPaste}
onDrop={onDrop}
onDragOver={(e) => e.preventDefault()}
data-placeholder={placeholder}
style={{
minHeight: 160,
border: "1px solid #ddd",
borderRadius: 6,
padding: 12,
}}
suppressContentEditableWarning
/>
<div>
{toolbar && (
<div className="mb-3">{toolbar}</div>
)}
<div
ref={ref}
contentEditable
onInput={(e) => onChange((e.target as HTMLDivElement).innerHTML)}
onPaste={onPaste}
onDrop={onDrop}
onDragOver={(e) => e.preventDefault()}
data-placeholder={placeholder}
style={{
minHeight: 160,
border: "1px solid #ddd",
borderRadius: 6,
padding: 12,
}}
suppressContentEditableWarning
/>
</div>
);
}

View File

@@ -1,10 +1,12 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import Image from "next/image";
import { SelectedBanner } from "@/app/components/SelectedBanner";
type Banner = { id: string; title: string; imageUrl: string; linkUrl?: string | null };
type SubItem = { id: string; name: string; href: string };
export function HeroBanner() {
export function HeroBanner({ subItems, activeSubId }: { subItems?: SubItem[]; activeSubId?: string }) {
const [banners, setBanners] = useState<Banner[]>([]);
const [activeIndex, setActiveIndex] = useState(0);
const [isHovered, setIsHovered] = useState(false);
@@ -65,7 +67,7 @@ export function HeroBanner() {
const translatePercent = useMemo(() => (numSlides > 0 ? -(activeIndex * 100) : 0), [activeIndex, numSlides]);
if (numSlides === 0) return null;
if (numSlides === 0) return <SelectedBanner height={224} />;
return (
<section
@@ -88,7 +90,7 @@ export function HeroBanner() {
) : (
<Image src={banner.imageUrl} alt={banner.title} fill priority className="object-cover" />
)}
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/60 via-black/10 to-transparent" />
{/* Figma 스타일: 오버레이 제거 */}
<div className="absolute bottom-3 left-4 right-4 md:bottom-5 md:left-6 md:right-6">
<h2 className="line-clamp-2 text-lg font-semibold md:text-2xl">{banner.title}</h2>
</div>
@@ -96,26 +98,44 @@ export function HeroBanner() {
))}
</div>
{/* Controls */}
{/* Pagination - Figma 스타일: 활성은 주황 바, 비활성은 회색 점 (배너 위에 오버랩) */}
{numSlides > 1 && (
<>
<button
type="button"
aria-label="Previous slide"
onClick={goPrev}
className="absolute left-2 top-1/2 z-10 -translate-y-1/2 rounded-full bg-black/40 p-2 text-white backdrop-blur transition hover:bg-black/60 focus:outline-none focus:ring-2 focus:ring-white/60"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="h-5 w-5"><path d="M15.75 19.5 8.25 12l7.5-7.5" /></svg>
</button>
<button
type="button"
aria-label="Next slide"
onClick={goNext}
className="absolute right-2 top-1/2 z-10 -translate-y-1/2 rounded-full bg-black/40 p-2 text-white backdrop-blur transition hover:bg-black/60 focus:outline-none focus:ring-2 focus:ring-white/60"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="h-5 w-5"><path d="M8.25 4.5 15.75 12l-7.5 7.5" /></svg>
</button>
</>
<div className="pointer-events-auto absolute bottom-3 left-1/2 -translate-x-1/2 z-10 flex items-center gap-[6px]">
{banners.map((_, i) => (
<button
key={i}
aria-label={`Go to slide ${i + 1}`}
aria-current={activeIndex === i ? "true" : undefined}
onClick={() => goTo(i)}
className={
activeIndex === i
? "h-[4px] w-[18px] rounded-full bg-[#F94B37]"
: "h-[6px] w-[6px] rounded-full bg-[rgba(255,255,255,0.6)] hover:bg-white"
}
/>
))}
</div>
)}
</div>
{/* 분리된 하단 블랙 바: 높이 58px, 중앙 서브카테고리 */}
<div className="mt-2 h-[58px] bg-black rounded-xl flex items-center justify-center px-2">
{Array.isArray(subItems) && subItems.length > 0 && (
<div className="flex items-center gap-[8px] overflow-x-auto no-scrollbar">
{subItems.map((s) => (
<a
key={s.id}
href={s.href}
className={
s.id === activeSubId
? "px-3 h-[28px] rounded-full bg-[#F94B37] text-white text-[12px] leading-[28px] whitespace-nowrap"
: "px-3 h-[28px] rounded-full bg-transparent text-white/85 hover:text-white border border-white/30 text-[12px] leading-[28px] whitespace-nowrap"
}
>
{s.name}
</a>
))}
</div>
)}
</div>
@@ -126,20 +146,7 @@ export function HeroBanner() {
</div>
)} */}
{/* Pagination */}
{numSlides > 1 && (
<div className="absolute bottom-3 left-1/2 -translate-x-1/2 z-10 flex gap-2">
{banners.map((_, i) => (
<button
key={i}
aria-label={`Go to slide ${i + 1}`}
aria-current={activeIndex === i ? "true" : undefined}
onClick={() => goTo(i)}
className={`h-2 w-2 rounded-full transition ${activeIndex === i ? "bg-white" : "bg-white/40 hover:bg-white/70"}`}
/>
))}
</div>
)}
</section>
);
}

View File

@@ -1,5 +1,11 @@
"use client";
import useSWRInfinite from "swr/infinite";
import useSWR from "swr";
import { useEffect, useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import ViewsIcon from "@/app/svgs/ViewsIcon";
import LikeIcon from "@/app/svgs/LikeIcon";
import CommentIcon from "@/app/svgs/CommentIcon";
type Item = {
id: string;
@@ -22,8 +28,11 @@ type Resp = {
const fetcher = (url: string) => fetch(url).then((r) => r.json());
export function PostList({ boardId, sort = "recent", q, tag, author, start, end }: { boardId?: string; sort?: "recent" | "popular"; q?: string; tag?: string; author?: string; start?: string; end?: string }) {
export function PostList({ boardId, sort = "recent", q, tag, author, start, end, variant = "default", newPostHref }: { boardId?: string; sort?: "recent" | "popular"; q?: string; tag?: string; author?: string; start?: string; end?: string; variant?: "default" | "board"; newPostHref?: string }) {
const pageSize = 10;
const router = useRouter();
const sp = useSearchParams();
const getKey = (index: number, prev: Resp | null) => {
if (prev && prev.items.length === 0) return null;
const page = index + 1;
@@ -36,46 +45,89 @@ export function PostList({ boardId, sort = "recent", q, tag, author, start, end
if (end) sp.set("end", end);
return `/api/posts?${sp.toString()}`;
};
const { data, size, setSize, isLoading } = useSWRInfinite<Resp>(getKey, fetcher);
const items = data?.flatMap((d) => d.items) ?? [];
// default(무한 스크롤 형태)
const { data, size, setSize, isLoading } = useSWRInfinite<Resp>(getKey, fetcher, { revalidateFirstPage: false });
const itemsInfinite = data?.flatMap((d) => d.items) ?? [];
const canLoadMore = (data?.at(-1)?.items.length ?? 0) === pageSize;
const isEmptyInfinite = !isLoading && itemsInfinite.length === 0;
// board 변형: 번호 페이지네이션
const initialPage = useMemo(() => Math.max(1, parseInt(sp.get("page") || "1", 10)), [sp]);
const [page, setPage] = useState(initialPage);
useEffect(() => { setPage(initialPage); }, [initialPage]);
const singleKey = useMemo(() => {
if (variant !== "board") return null;
const usp = new URLSearchParams({ page: String(page), pageSize: String(pageSize), sort });
if (boardId) usp.set("boardId", boardId);
if (q) usp.set("q", q);
if (tag) usp.set("tag", tag);
if (author) usp.set("author", author);
if (start) usp.set("start", start);
if (end) usp.set("end", end);
return `/api/posts?${usp.toString()}`;
}, [variant, page, pageSize, sort, boardId, q, tag, author, start, end]);
const { data: singlePageResp, isLoading: isLoadingSingle } = useSWR<Resp>(singleKey, fetcher);
const itemsSingle = singlePageResp?.items ?? [];
const totalSingle = singlePageResp?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(totalSingle / pageSize));
const isEmptySingle = !isLoadingSingle && itemsSingle.length === 0;
const items = variant === "board" ? itemsSingle : itemsInfinite;
const isEmpty = variant === "board" ? isEmptySingle : isEmptyInfinite;
const initials = (name?: string | null) => (name ? name.trim().slice(0, 1) : "익");
return (
<div className="w-full">
{/* 정렬 스위치 */}
<div className="px-4 py-2 border-b border-neutral-200 mb-2 flex items-center gap-2">
<span className="text-xs text-neutral-500 mr-1"></span>
<a
className={`text-xs px-3 py-1 rounded-full border transition-colors ${
sort === "recent" ? "bg-neutral-900 text-white border-neutral-900" : "bg-white text-neutral-700 border-neutral-300 hover:bg-neutral-100"
}`}
href={`${q ? "/search" : "/"}?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); if (boardId) p.set("boardId", boardId); p.set("sort", "recent"); return p.toString(); })()}`}
>
</a>
<a
className={`text-xs px-3 py-1 rounded-full border transition-colors ${
sort === "popular" ? "bg-neutral-900 text-white border-neutral-900" : "bg-white text-neutral-700 border-neutral-300 hover:bg-neutral-100"
}`}
href={`${q ? "/search" : "/"}?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); if (boardId) p.set("boardId", boardId); p.set("sort", "popular"); return p.toString(); })()}`}
>
</a>
</div>
{/* 정렬 스위치 (board 변형에서는 상단 툴바를 숨김) */}
{variant !== "board" && (
<div className="px-4 py-2 border-b border-neutral-200 mb-2 flex items-center gap-2">
<span className="text-xs text-neutral-500 mr-1"></span>
<a
className={`text-xs px-3 py-1 rounded-full border transition-colors ${
sort === "recent" ? "bg-neutral-900 text-white border-neutral-900" : "bg-white text-neutral-700 border-neutral-300 hover:bg-neutral-100"
}`}
href={`${q ? "/search" : "/"}?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); if (boardId) p.set("boardId", boardId); p.set("sort", "recent"); return p.toString(); })()}`}
>
</a>
<a
className={`text-xs px-3 py-1 rounded-full border transition-colors ${
sort === "popular" ? "bg-neutral-900 text-white border-neutral-900" : "bg-white text-neutral-700 border-neutral-300 hover:bg-neutral-100"
}`}
href={`${q ? "/search" : "/"}?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); if (boardId) p.set("boardId", boardId); p.set("sort", "popular"); return p.toString(); })()}`}
>
</a>
</div>
)}
{/* 리스트 테이블 헤더 */}
<div className="hidden md:grid grid-cols-[1fr_auto_auto_auto] items-center px-4 py-2 text-xs text-neutral-500 border-b border-neutral-200">
<div></div>
<div className="w-28 text-center"></div>
<div className="w-24 text-center"></div>
<div className="w-24 text-right"></div>
</div>
{/* 리스트 테이블 헤더 (board 변형에서는 숨김) */}
{variant !== "board" && (
<div className="hidden md:grid grid-cols-[20px_1fr_120px_120px_80px] items-center px-4 py-2 text-[12px] text-[#8c8c8c] bg-[#f6f4f4] border-b border-[#e6e6e6] rounded-t-xl">
<div />
<div></div>
<div className="text-center"></div>
<div className="text-center"></div>
<div className="text-right"></div>
</div>
)}
{/* 빈 상태 */}
{isEmpty && (
<div className="h-[400px] flex items-center justify-center border-t border-b border-[#8c8c8c]">
<p className="text-[20px] leading-[20px] text-[#161616]"> .</p>
</div>
)}
{/* 아이템들 */}
<ul className="divide-y divide-neutral-100">
<ul className="divide-y divide-[#ececec]">
{items.map((p) => (
<li key={p.id} className="px-4 py-3 hover:bg-neutral-50 transition-colors">
<div className="grid grid-cols-1 md:grid-cols-[1fr_auto_auto_auto] items-center gap-2">
<li key={p.id} className={`px-4 ${variant === "board" ? "py-2.5" : "py-3 md:py-3"} hover:bg-neutral-50 transition-colors`}>
<div className="grid grid-cols-1 md:grid-cols-[20px_1fr_120px_120px_80px] items-center gap-2">
{/* bullet/공지 아이콘 자리 */}
<div className="hidden md:flex items-center justify-center text-[#f94b37]">{p.isPinned ? "★" : "•"}</div>
<div className="min-w-0">
<a href={`/posts/${p.id}`} className="block truncate text-[15px] md:text-base text-neutral-900">
{p.isPinned && <span className="mr-2 inline-flex items-center rounded bg-orange-100 text-orange-700 px-1.5 py-0.5 text-[11px]"></span>}
@@ -89,28 +141,109 @@ export function PostList({ boardId, sort = "recent", q, tag, author, start, end
</div>
)}
</div>
<div className="md:w-28 text-xs text-neutral-600 text-center">{p.author?.nickname ?? "익명"}</div>
<div className="md:w-24 text-[11px] text-neutral-600 text-center flex md:block gap-3 md:gap-0">
<span>👍 {p.stat?.recommendCount ?? 0}</span>
<span>👁 {p.stat?.views ?? 0}</span>
<span>💬 {p.stat?.commentsCount ?? 0}</span>
<div className="md:w-[120px] text-xs text-neutral-600 text-center hidden md:flex items-center justify-center gap-2">
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-neutral-200 text-[11px] text-neutral-700">{initials(p.author?.nickname)}</span>
<span className="truncate max-w-[84px]">{p.author?.nickname ?? "익명"}</span>
</div>
<div className="md:w-24 text-xs text-neutral-500 text-right">{new Date(p.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</div>
<div className="md:w-[120px] text-[11px] text-[#8c8c8c] text-center flex items-center justify-center gap-3">
<span className="inline-flex items-center gap-1"><ViewsIcon width={16} height={16} />{p.stat?.views ?? 0}</span>
<span className="inline-flex items-center gap-1"><LikeIcon width={16} height={16} />{p.stat?.recommendCount ?? 0}</span>
<span className="inline-flex items-center gap-1"><CommentIcon width={16} height={16} />{p.stat?.commentsCount ?? 0}</span>
</div>
<div className="md:w-[80px] text-xs text-neutral-500 text-right">{new Date(p.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</div>
</div>
</li>
))}
</ul>
{/* 페이지 더보기 */}
<div className="mt-3 flex justify-center">
<button
className="h-9 px-4 rounded-md border border-neutral-300 bg-white text-sm hover:bg-neutral-100 disabled:opacity-50"
disabled={!canLoadMore || isLoading}
onClick={() => setSize(size + 1)}
>
{isLoading ? "로딩 중..." : canLoadMore ? "더 보기" : "끝"}
</button>
</div>
{/* 페이지네이션 */}
{!isEmpty && (
variant === "board" ? (
<div className="mt-4 flex items-center justify-between px-4">
<div className="flex items-center gap-2">
{/* Previous */}
<button
onClick={() => {
const next = Math.max(1, page - 1);
setPage(next);
const nextSp = new URLSearchParams(Array.from(sp.entries()));
nextSp.set("page", String(next));
router.push(`?${nextSp.toString()}`);
}}
disabled={page <= 1}
className="h-9 px-3 rounded-md border border-neutral-300 bg-white text-sm disabled:opacity-50"
>
Previous
</button>
{/* Numbers with ellipsis */}
<div className="flex items-center gap-1">
{(() => {
const nodes: (number | string)[] = [];
if (totalPages <= 7) {
for (let i = 1; i <= totalPages; i++) nodes.push(i);
} else {
nodes.push(1);
const start = Math.max(2, page - 1);
const end = Math.min(totalPages - 1, page + 1);
if (start > 2) nodes.push("...");
for (let i = start; i <= end; i++) nodes.push(i);
if (end < totalPages - 1) nodes.push("...");
nodes.push(totalPages);
}
return nodes.map((n, idx) =>
typeof n === "number" ? (
<button
key={`p-${n}-${idx}`}
onClick={() => {
setPage(n);
const nextSp = new URLSearchParams(Array.from(sp.entries()));
nextSp.set("page", String(n));
router.push(`?${nextSp.toString()}`);
}}
aria-current={n === page ? "page" : undefined}
className={`h-9 w-9 rounded-md border ${n === page ? "border-neutral-300 text-[#f94b37] font-semibold" : "border-neutral-300 text-neutral-900"}`}
>
{n}
</button>
) : (
<span key={`e-${idx}`} className="h-9 w-9 inline-flex items-center justify-center text-neutral-900"></span>
)
);
})()}
</div>
{/* Next */}
<button
onClick={() => {
const next = Math.min(totalPages, page + 1);
setPage(next);
const nextSp = new URLSearchParams(Array.from(sp.entries()));
nextSp.set("page", String(next));
router.push(`?${nextSp.toString()}`);
}}
disabled={page >= totalPages}
className="h-9 px-3 rounded-md border border-neutral-300 bg-white text-sm disabled:opacity-50"
>
Next
</button>
</div>
{newPostHref && (
<a href={newPostHref} className="shrink-0">
<button className="h-9 px-4 rounded-[10px] bg-[#f94b37] text-white text-sm border border-[#d73b29] hover:opacity-95"></button>
</a>
)}
</div>
) : (
<div className="mt-3 flex justify-center">
<button
className="h-9 px-4 rounded-md border border-neutral-300 bg-white text-sm hover:bg-neutral-100 disabled:opacity-50"
disabled={!canLoadMore || isLoading}
onClick={() => setSize(size + 1)}
>
{isLoading ? "로딩 중..." : canLoadMore ? "더 보기" : "끝"}
</button>
</div>
)
)}
</div>
);
}

View File

@@ -0,0 +1,29 @@
"use client";
import React from "react";
type Props = {
height?: number | string; // ex) 224 or '14rem'
className?: string;
};
// Figma 선택 요소(Container_Event)를 그대로 옮긴 플레이스홀더형 배너
export function SelectedBanner({ height = 122, className }: Props) {
return (
<section
className={`relative w-full overflow-hidden rounded-[12px] bg-[#D9D9D9] ${className ?? ""}`}
style={{ height }}
aria-label="banner"
>
<div className="absolute left-1/2 -translate-x-1/2 flex items-center gap-[6px]" style={{ bottom: 12 }}>
<span className="block h-[4px] w-[18px] rounded-full bg-[#F94B37]" aria-hidden />
{Array.from({ length: 12 }).map((_, i) => (
<span key={i} className="block h-[6px] w-[6px] rounded-full bg-[#B9B9B9]" aria-hidden />
))}
</div>
</section>
);
}
export default SelectedBanner;

View File

@@ -0,0 +1,29 @@
"use client";
import React from "react";
export function SinglePageLogo({ width = 120, height = 28, className }: { width?: number; height?: number; className?: string }) {
// 원본 SVG는 512x512 정사각형입니다. 주어진 width/height 중 높이를 기준으로 정사각형 로고를 맞추고,
// 가로 폭은 전달받은 width 박스 안에서 가운데 정렬합니다.
const squareSize = height; // 로고 자체는 정사각형
return (
<div className={className} style={{ position: "relative", width, height, display: "inline-flex", alignItems: "center", justifyContent: "flex-start" }} aria-label="logo">
<svg width={squareSize} height={squareSize} viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-hidden={false}>
<g clipPath="url(#clip0_9739_16177)">
<path d="M371.99 0H139.94C62.6533 0 0 62.6533 0 139.94V371.99C0 449.277 62.6533 511.93 139.94 511.93H371.99C449.277 511.93 511.93 449.277 511.93 371.99V139.94C511.93 62.6533 449.277 0 371.99 0Z" fill="#5C5C5C"/>
<path d="M141.801 414.32L221.991 512H372.051C437.291 512 492.111 467.35 507.611 406.94C510.471 395.07 512.001 382.54 512.001 369.49V263.21L402.381 136.92L413.091 258.53L397.761 314.72L332.371 336.18L265.551 339.25H187.281L171.951 344.36L154.581 366.84L141.801 414.32Z" fill="#333333"/>
<path d="M247.849 74.16C294.009 71.07 344.459 85.39 381.369 116.03C455.159 177.27 451.759 279.02 379.519 339.81C365.789 351.36 351.499 358.08 337.619 367.97C313.749 384.98 335.579 425.85 319.879 442.83C313.839 449.36 304.699 450.21 298.049 444.33C282.919 430.94 298.229 402.44 283.699 388.06C272.859 377.33 254.969 383.45 250.369 398.22C247.149 408.57 251.369 429.43 251.769 441.22C252.209 454.15 254.029 480.74 238.219 483.24C223.489 485.56 217.279 476.88 216.879 461.71C216.329 441.16 221.509 415.25 219.329 395.63C217.049 375.09 180.939 361.76 175.979 381.71C172.449 395.94 180.599 424.11 157.669 423.6C128.939 422.96 139.289 374.75 137.799 354.07C136.349 333.98 128.279 331.17 117.829 318.14C60.8193 247.04 79.0293 156.62 149.889 107.9C178.259 88.4 214.349 76.4 247.839 74.16H247.849Z" fill="white"/>
<path d="M339.518 242.97H189.838C173.858 242.97 160.898 231.58 160.898 217.54C160.898 203.5 173.848 192.11 189.838 192.11H339.518C355.498 192.11 368.458 203.5 368.458 217.54C368.458 231.58 355.508 242.97 339.518 242.97Z" fill="#333333"/>
</g>
<defs>
<clipPath id="clip0_9739_16177">
<rect width="512" height="512" fill="white"/>
</clipPath>
</defs>
</svg>
</div>
);
}
export default SinglePageLogo;

View File

@@ -79,7 +79,8 @@ export function UploadButton({ onUploaded, multiple = false, maxWidth = 1600, ma
const { show } = useToast();
const [loading, setLoading] = useState(false);
async function onChange(e: React.ChangeEvent<HTMLInputElement>) {
const files = Array.from(e.target.files ?? []);
const input = e.currentTarget; // React 이벤트 풀링 대비: 참조 보관
const files = Array.from(input.files ?? []);
if (files.length === 0) return;
try {
setLoading(true);
@@ -99,7 +100,8 @@ export function UploadButton({ onUploaded, multiple = false, maxWidth = 1600, ma
show("업로드 실패");
} finally {
setLoading(false);
e.currentTarget.value = "";
// 선택값 초기화(같은 파일 재선택 가능하도록)
if (input) input.value = "";
}
}
return <label style={{ display: "inline-block" }}>