Merge branch 'homework'
All checks were successful
deploy-on-main / deploy (push) Successful in 35s

This commit is contained in:
koreacomp5
2025-11-05 23:05:38 +09:00
8 changed files with 193 additions and 100 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

View File

@@ -377,7 +377,7 @@ export function AppHeader() {
if (!e.currentTarget.contains(next)) setMegaOpen(false); if (!e.currentTarget.contains(next)) setMegaOpen(false);
}} }}
> >
<div className="relative flex items-center gap-8" ref={navRowRef}> <div className="relative flex items-center gap-0" ref={navRowRef}>
{categories.map((cat, idx) => ( {categories.map((cat, idx) => (
<div <div
key={cat.id} key={cat.id}
@@ -389,7 +389,7 @@ export function AppHeader() {
> >
<Link <Link
href={cat.boards?.[0]?.slug ? `/boards/${cat.boards[0].slug}` : `/boards?category=${cat.slug}`} href={cat.boards?.[0]?.slug ? `/boards/${cat.boards[0].slug}` : `/boards?category=${cat.slug}`}
className={`block w-full px-2 py-2 text-sm font-medium transition-colors duration-200 whitespace-nowrap ${ className={`block w-full px-6 py-2 text-sm font-medium transition-colors duration-200 whitespace-nowrap ${
(megaOpen && openSlug === cat.slug) || activeCategorySlug === cat.slug ? "" : "text-neutral-700" (megaOpen && openSlug === cat.slug) || activeCategorySlug === cat.slug ? "" : "text-neutral-700"
}`} }`}
style={(megaOpen && openSlug === cat.slug) || activeCategorySlug === cat.slug ? { color: "var(--red-50, #F94B37)" } : undefined} style={(megaOpen && openSlug === cat.slug) || activeCategorySlug === cat.slug ? { color: "var(--red-50, #F94B37)" } : undefined}
@@ -423,7 +423,7 @@ export function AppHeader() {
/> />
</div> </div>
<div <div
className={`fixed left-0 right-0 z-50 bg-white shadow-[0_12px_32px_rgba(0,0,0,0.12)] transition-all duration-200 ${ className={`fixed left-0 right-0 z-50 bg-white/80 backdrop-blur supports-[backdrop-filter]:bg-white/60 shadow-[0_12px_32px_rgba(0,0,0,0.12)] transition-all duration-200 ${
megaOpen ? "opacity-100" : "pointer-events-none opacity-0" megaOpen ? "opacity-100" : "pointer-events-none opacity-0"
}`} }`}
style={{ top: headerBottom }} style={{ top: headerBottom }}
@@ -442,15 +442,16 @@ export function AppHeader() {
style={{ left: (leftPositions[cat.slug] ?? 0), width: blockWidths[cat.slug] ?? undefined }} style={{ left: (leftPositions[cat.slug] ?? 0), width: blockWidths[cat.slug] ?? undefined }}
> >
{/* <div className="mb-2 font-semibold text-neutral-800">{cat.name}</div> */} {/* <div className="mb-2 font-semibold text-neutral-800">{cat.name}</div> */}
<div className="mx-auto flex flex-col items-center gap-3 w-full"> <div className="mx-auto flex flex-col items-center gap-0 w-full">
{cat.boards.map((b) => ( {cat.boards.map((b) => (
<Link <Link
key={b.id} key={b.id}
href={`/boards/${b.slug}`} href={`/boards/${b.slug}`}
className={`rounded px-2 py-1 text-sm transition-colors duration-150 text-center whitespace-nowrap ${ className={`rounded px-2 pb-4 text-sm transition-colors duration-150 text-center whitespace-nowrap ${
activeBoardId === b.slug ? "font-semibold" : "text-neutral-700 hover:text-neutral-900 hover:bg-neutral-100" activeBoardId === b.slug
? "text-[var(--red-50,#F94B37)] font-semibold"
: "text-neutral-700 hover:text-[var(--red-50,#F94B37)]"
}`} }`}
style={activeBoardId === b.slug ? { color: "var(--red-50, #F94B37)" } : undefined}
aria-current={activeBoardId === b.slug ? "page" : undefined} aria-current={activeBoardId === b.slug ? "page" : undefined}
> >
{b.name} {b.name}

View File

@@ -56,6 +56,13 @@ export function BoardPanelClient({
const selectedBoardData = boardsData.find(bd => bd.board.id === selectedBoardId) || boardsData[0]; const selectedBoardData = boardsData.find(bd => bd.board.id === selectedBoardId) || boardsData[0];
const { board, categoryName, siblingBoards } = selectedBoardData; const { board, categoryName, siblingBoards } = selectedBoardData;
const isNewWithin1Hour = (createdAt: Date | string | number | null | undefined): boolean => {
if (!createdAt) return false;
const t = new Date(createdAt).getTime();
if (Number.isNaN(t)) return false;
return (Date.now() - t) <= 60 * 60 * 1000;
};
function formatDateYmd(d: Date) { function formatDateYmd(d: Date) {
const date = new Date(d); const date = new Date(d);
const yyyy = date.getFullYear(); const yyyy = date.getFullYear();
@@ -96,14 +103,21 @@ export function BoardPanelClient({
<div className="content-stretch flex gap-[30px] items-start w-full mb-2"> <div className="content-stretch flex gap-[30px] items-start w-full mb-2">
<div className="flex items-center gap-[8px] shrink-0"> <div className="flex items-center gap-[8px] shrink-0">
<div className="text-[30px] text-[#5c5c5c] leading-[30px]">{categoryName || board.name}</div> <div className="text-[30px] text-[#5c5c5c] leading-[30px]">{categoryName || board.name}</div>
<Link href={`/boards/${board.slug}`} aria-label={`${board.name} 게시판으로 이동`} className="shrink-0 group">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 19 19" fill="none" className="block group-hover:hidden">
<path d="M10.25 12.5L13.25 9.5M13.25 9.5L10.25 6.5M13.25 9.5L5.75 9.5M18.5 9.5C18.5 14.4706 14.4706 18.5 9.5 18.5C4.52944 18.5 0.5 14.4706 0.5 9.5C0.5 4.52944 4.52944 0.5 9.5 0.5C14.4706 0.5 18.5 4.52944 18.5 9.5Z" stroke="#8C8C8C" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none" className="hidden group-hover:block">
<path fillRule="evenodd" clipRule="evenodd" d="M9.75 0C4.36522 0 0 4.36522 0 9.75C0 15.1348 4.36522 19.5 9.75 19.5C15.1348 19.5 19.5 15.1348 19.5 9.75C19.5 4.36522 15.1348 0 9.75 0ZM14.0303 10.2803C14.171 10.1397 14.25 9.94891 14.25 9.75C14.25 9.55109 14.171 9.36032 14.0303 9.21967L11.0303 6.21967C10.7374 5.92678 10.2626 5.92678 9.96967 6.21967C9.67678 6.51256 9.67678 6.98744 9.96967 7.28033L11.6893 9L6 9C5.58579 9 5.25 9.33578 5.25 9.75C5.25 10.1642 5.58579 10.5 6 10.5L11.6893 10.5L9.96967 12.2197C9.67678 12.5126 9.67678 12.9874 9.96967 13.2803C10.2626 13.5732 10.7374 13.5732 11.0303 13.2803L14.0303 10.2803Z" fill="#5C5C5C" />
</svg>
</Link>
</div> </div>
<div className="flex items-center gap-[8px] overflow-x-auto flex-nowrap min-w-0 flex-1 no-scrollbar"> <div className="flex items-center gap-[8px] overflow-x-auto flex-nowrap min-w-0 flex-1 no-scrollbar">
{siblingBoards.map((sb) => ( {siblingBoards.map((sb) => (
<button <button
key={sb.id} key={sb.id}
onClick={() => setSelectedBoardId(sb.id)} onClick={() => setSelectedBoardId(sb.id)}
className={`px-[16px] py-[8px] rounded-[14px] text-[14px] shrink-0 cursor-pointer ${ className={`px-[16px] py-[8px] rounded-[14px] text-[14px] shrink-0 cursor-pointer ${sb.id === selectedBoardId ? "bg-[#5c5c5c] text-white border border-[#5c5c5c]" : "bg-white text-[#5c5c5c] border border-[#d5d5d5] hover:bg-[#5c5c5c] hover:text-white hover:border-[#5c5c5c] transition-colors"
sb.id === selectedBoardId ? "bg-[#5c5c5c] text-white border border-[#5c5c5c]" : "bg-white text-[#5c5c5c] border border-[#d5d5d5]"
}`} }`}
> >
{sb.name} {sb.name}
@@ -113,45 +127,49 @@ export function BoardPanelClient({
</div> </div>
<div className="rounded-xl overflow-hidden h-full min-h-0 flex flex-col"> <div className="rounded-xl overflow-hidden h-full min-h-0 flex flex-col">
<div className="flex-1 min-h-0 overflow-hidden p-0"> <div className="flex-1 min-h-0 overflow-hidden p-0">
<div className="px-[0px] pt-[8px] pb-[16px]"> <div className="px-[0px] pt-[6px] pb-[6px]">
<div className="flex flex-col gap-[16px]"> <div className="flex flex-col gap-[6px]">
{selectedBoardData.specialRankUsers.map((user, idx) => { {selectedBoardData.specialRankUsers.map((user, idx) => {
const rank = idx + 1; const rank = idx + 1;
return ( return (
<Link href="/boards/ranking" key={user.userId} className="flex h-[150px] items-center rounded-[16px] overflow-hidden bg-white hover:bg-neutral-50"> <Link href="/boards/ranking" key={user.userId} className=" mx-[4px] flex h-[72px] items-center rounded-[10px] overflow-hidden bg-white hover:bg-neutral-50 transition-shadow hover:[box-shadow:0_0_2px_0_var(--color-alpha-shadow2,_rgba(0,0,0,0.08)),_0_var(--shadow-y-3,_8px)_var(--shadow-blur-3,_16px)_0_var(--color-alpha-shadow3,_rgba(0,0,0,0.12))]">
<div className="h-[150px] w-[160px] relative shrink-0 bg-[#d5d5d5] overflow-hidden"> <div className="h-[72px] w-[90px] relative shrink-0 bg-[#d5d5d5] overflow-hidden">
<UserAvatar <UserAvatar
src={user.profileImage} src={user.profileImage}
alt={user.nickname || "프로필"} alt={user.nickname || "프로필"}
width={160} width={90}
height={150} height={72}
className="w-full h-full object-cover rounded-none" className="w-full h-full object-cover rounded-none"
/> />
<div className="absolute top-0 right-0 w-[40px] h-[40px] flex items-center justify-center"> <div className="absolute top-0 right-0 w-[20px] h-[20px] flex items-center justify-center">
<GradeIcon grade={user.grade} width={40} height={40} /> <GradeIcon grade={user.grade} width={20} height={20} />
</div> </div>
</div> </div>
<div className="flex-1 flex items-center gap-[10px] px-[24px] md:px-[30px] py-[24px] min-w-0"> <div className="flex-1 flex items-center gap-[6px] px-[12px] md:px-[12px] py-[8px] min-w-0">
<div className="flex flex-col gap-[12px] min-w-0 flex-1"> <div className="flex flex-col gap-[4px] min-w-0 flex-1">
<div className="flex items-center gap-[12px]"> <div className="flex items-center gap-[6px]">
<div className="relative w-[20px] h-[20px] shrink-0"> {
(rank === 1 || rank === 2 || rank === 3) && (
<div className="relative w-[22px] h-[22px] shrink-0">
{rank === 1 && <RankIcon1st />} {rank === 1 && <RankIcon1st />}
{rank === 2 && <RankIcon2nd />} {rank === 2 && <RankIcon2nd />}
{rank === 3 && <RankIcon3rd />} {rank === 3 && <RankIcon3rd />}
</div> </div>
<div className="bg-white border border-[#d5d5d5] px-[16px] py-[5px] rounded-[12px] text-[14px] text-[#5c5c5c] shrink-0"> )
}
<div className="bg-white border border-[#d5d5d5] px-[8px] py-[2px] rounded-[8px] text-[11px] text-[#5c5c5c] shrink-0">
{rank} {rank}
</div> </div>
</div> </div>
<div className="text-[24px] font-medium text-[#5c5c5c] truncate leading-[22px]"> <div className="text-[13px] font-medium text-[#5c5c5c] truncate leading-[16px]">
{user.nickname || "익명"} {user.nickname || "익명"}
</div> </div>
</div> </div>
<div className="h-[24px] flex items-center gap-[4px] shrink-0"> <div className="h-[16px] flex items-center gap-[4px] shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" className="shrink-0"> <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 16 16" fill="none" className="shrink-0">
<path d="M8.17377 0.0481988C8.1834 0.0326271 8.19596 0.0201252 8.21037 0.0117499C8.22478 0.00337451 8.24062 -0.000628204 8.25656 7.99922e-05C8.2725 0.000788189 8.28806 0.0061865 8.30194 0.0158187C8.31582 0.0254509 8.3276 0.0390338 8.33629 0.0554196L11.3323 5.55973C11.3575 5.60512 11.4038 5.62472 11.4468 5.60718L15.8683 3.80198C15.9441 3.77104 16.0174 3.85666 15.9963 3.95053L14.2584 16H1.74226L0.00344328 3.95156C-0.00125237 3.93019 -0.0011435 3.90765 0.00375838 3.88635C0.00866025 3.86505 0.0181727 3.84576 0.0312889 3.83054C0.0444051 3.81532 0.0606369 3.80472 0.0782661 3.79988C0.0958953 3.79503 0.114266 3.79612 0.131434 3.80302L4.55889 5.61131C4.59846 5.62678 4.64309 5.61131 4.66835 5.57005L8.17461 0.0481988H8.17377Z" fill="#FFB625"/> <path d="M8.17377 0.0481988C8.1834 0.0326271 8.19596 0.0201252 8.21037 0.0117499C8.22478 0.00337451 8.24062 -0.000628204 8.25656 7.99922e-05C8.2725 0.000788189 8.28806 0.0061865 8.30194 0.0158187C8.31582 0.0254509 8.3276 0.0390338 8.33629 0.0554196L11.3323 5.55973C11.3575 5.60512 11.4038 5.62472 11.4468 5.60718L15.8683 3.80198C15.9441 3.77104 16.0174 3.85666 15.9963 3.95053L14.2584 16H1.74226L0.00344328 3.95156C-0.00125237 3.93019 -0.0011435 3.90765 0.00375838 3.88635C0.00866025 3.86505 0.0181727 3.84576 0.0312889 3.83054C0.0444051 3.81532 0.0606369 3.80472 0.0782661 3.79988C0.0958953 3.79503 0.114266 3.79612 0.131434 3.80302L4.55889 5.61131C4.59846 5.62678 4.64309 5.61131 4.66835 5.57005L8.17461 0.0481988H8.17377Z" fill="#FFB625" />
</svg> </svg>
<span className="text-[20px] font-semibold text-[#5c5c5c] leading-[22px]">{user.points.toLocaleString()}</span> <span className="text-[12px] font-semibold text-[#5c5c5c] leading-[16px]">{user.points.toLocaleString()}</span>
</div> </div>
</div> </div>
</Link> </Link>
@@ -172,14 +190,21 @@ export function BoardPanelClient({
<div className="content-stretch flex gap-[30px] items-start w-full mb-2"> <div className="content-stretch flex gap-[30px] items-start w-full mb-2">
<div className="flex items-center gap-[8px] shrink-0"> <div className="flex items-center gap-[8px] shrink-0">
<div className="text-[30px] text-[#5c5c5c] leading-[30px]">{categoryName || board.name}</div> <div className="text-[30px] text-[#5c5c5c] leading-[30px]">{categoryName || board.name}</div>
<Link href={`/boards/${board.slug}`} aria-label={`${board.name} 게시판으로 이동`} className="shrink-0 group">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 19 19" fill="none" className="block group-hover:hidden">
<path d="M10.25 12.5L13.25 9.5M13.25 9.5L10.25 6.5M13.25 9.5L5.75 9.5M18.5 9.5C18.5 14.4706 14.4706 18.5 9.5 18.5C4.52944 18.5 0.5 14.4706 0.5 9.5C0.5 4.52944 4.52944 0.5 9.5 0.5C14.4706 0.5 18.5 4.52944 18.5 9.5Z" stroke="#8C8C8C" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none" className="hidden group-hover:block">
<path fillRule="evenodd" clipRule="evenodd" d="M9.75 0C4.36522 0 0 4.36522 0 9.75C0 15.1348 4.36522 19.5 9.75 19.5C15.1348 19.5 19.5 15.1348 19.5 9.75C19.5 4.36522 15.1348 0 9.75 0ZM14.0303 10.2803C14.171 10.1397 14.25 9.94891 14.25 9.75C14.25 9.55109 14.171 9.36032 14.0303 9.21967L11.0303 6.21967C10.7374 5.92678 10.2626 5.92678 9.96967 6.21967C9.67678 6.51256 9.67678 6.98744 9.96967 7.28033L11.6893 9L6 9C5.58579 9 5.25 9.33578 5.25 9.75C5.25 10.1642 5.58579 10.5 6 10.5L11.6893 10.5L9.96967 12.2197C9.67678 12.5126 9.67678 12.9874 9.96967 13.2803C10.2626 13.5732 10.7374 13.5732 11.0303 13.2803L14.0303 10.2803Z" fill="#5C5C5C" />
</svg>
</Link>
</div> </div>
<div className="flex items-center gap-[8px] overflow-x-auto flex-nowrap min-w-0 flex-1 no-scrollbar"> <div className="flex items-center gap-[8px] overflow-x-auto flex-nowrap min-w-0 flex-1 no-scrollbar">
{siblingBoards.map((sb) => ( {siblingBoards.map((sb) => (
<button <button
key={sb.id} key={sb.id}
onClick={() => setSelectedBoardId(sb.id)} onClick={() => setSelectedBoardId(sb.id)}
className={`px-[16px] py-[8px] rounded-[14px] text-[14px] shrink-0 cursor-pointer ${ className={`px-[16px] py-[8px] rounded-[14px] text-[14px] shrink-0 cursor-pointer ${sb.id === selectedBoardId ? "bg-[#5c5c5c] text-white border border-[#5c5c5c]" : "bg-white text-[#5c5c5c] border border-[#d5d5d5] hover:bg-[#5c5c5c] hover:text-white hover:border-[#5c5c5c] transition-colors"
sb.id === selectedBoardId ? "bg-[#5c5c5c] text-white border border-[#5c5c5c]" : "bg-white text-[#5c5c5c] border border-[#d5d5d5]"
}`} }`}
> >
{sb.name} {sb.name}
@@ -189,14 +214,14 @@ export function BoardPanelClient({
</div> </div>
<div className="rounded-xl overflow-hidden h-full min-h-0 flex flex-col"> <div className="rounded-xl overflow-hidden h-full min-h-0 flex flex-col">
<div className="flex-1 min-h-0 overflow-hidden p-0"> <div className="flex-1 min-h-0 overflow-hidden p-0">
<div className="px-[0px] pt-[8px] pb-[16px]"> <div className="px-[0px] pt-[6px] pb-[6px]">
<div className="flex flex-col gap-[16px]"> <div className="flex flex-col gap-[6px]">
{selectedBoardData.previewPosts.map((post) => { {selectedBoardData.previewPosts.map((post) => {
// attachments에서 이미지를 먼저 찾고, 없으면 content에서 추출 // attachments에서 이미지를 먼저 찾고, 없으면 content에서 추출
const firstImage = post.attachments?.[0]?.url || extractImageFromContent(post.content); const firstImage = post.attachments?.[0]?.url || extractImageFromContent(post.content);
return ( return (
<Link key={post.id} href={`/posts/${post.id}`} className="flex h-[150px] items-start rounded-[16px] overflow-hidden bg-white"> <Link key={post.id} href={`/posts/${post.id}`} className="mx-[4px] group flex h-[72px] items-start rounded-[10px] overflow-hidden bg-white hover:bg-neutral-50 transition-shadow hover:[box-shadow:0_0_2px_0_var(--color-alpha-shadow2,_rgba(0,0,0,0.08)),_0_var(--shadow-y-3,_8px)_var(--shadow-blur-3,_16px)_0_var(--color-alpha-shadow3,_rgba(0,0,0,0.12))]">
<div className="h-[150px] w-[214px] relative shrink-0 bg-[#ededed] overflow-hidden"> <div className="h-[72px] w-[90px] relative shrink-0 bg-[#ededed] overflow-hidden">
{firstImage ? ( {firstImage ? (
<img <img
src={firstImage} src={firstImage}
@@ -209,23 +234,27 @@ export function BoardPanelClient({
</div> </div>
)} )}
</div> </div>
<div className="flex-1 flex items-center gap-[10px] px-[24px] md:px-[30px] py-[24px] min-w-0"> <div className="flex-1 flex items-center gap-[6px] px-[10px] md:px-[10px] py-[6px] min-w-0">
<div className="flex flex-col gap-[12px] min-w-0 flex-1"> <div className="flex flex-col gap-[4px] min-w-0 flex-1">
<div className="bg-white border border-[#d5d5d5] px-[16px] py-[5px] rounded-[12px] text-[14px] text-[#5c5c5c] shrink-0 w-fit"> <div className="bg-white border border-[#d5d5d5] px-[8px] py-[2px] rounded-[8px] text-[11px] text-[#5c5c5c] shrink-0 w-fit">
{board.name} {board.name}
</div> </div>
<div className="flex items-center gap-[4px] overflow-hidden"> <div className="flex items-center gap-[4px] overflow-hidden">
{isNewWithin1Hour(post.createdAt) && (
<div className="relative w-[16px] h-[16px] shrink-0"> <div className="relative w-[16px] h-[16px] shrink-0">
<div className="absolute inset-0 rounded-full bg-[#f45f00] opacity-0" /> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path fillRule="evenodd" clipRule="evenodd" d="M1 8C1 4.5691 4.26184 2 8 2C11.7382 2 15 4.5691 15 8C15 11.4309 11.7382 14 8 14C7.57698 14 7.16215 13.9679 6.7588 13.9061C5.85852 14.4801 4.81757 14.8544 3.6995 14.9654C3.41604 14.9936 3.1411 14.8587 2.98983 14.6174C2.83857 14.376 2.83711 14.0698 2.98605 13.8269C3.21838 13.4482 3.38055 13.0221 3.45459 12.5659C1.97915 11.4858 1 9.86014 1 8Z" fill="#F45F00" />
</svg>
<div className="absolute inset-0 flex items-center justify-center text-[10px] text-white font-extrabold select-none">n</div> <div className="absolute inset-0 flex items-center justify-center text-[10px] text-white font-extrabold select-none">n</div>
</div> </div>
<span className="text-[16px] leading-[22px] text-[#5c5c5c] truncate">{stripHtml(post.title)}</span> )}
<span className="text-[13px] leading-[18px] text-[#5c5c5c] truncate group-hover:text-[var(--red-50,#F94B37)] group-hover:font-[700]" style={{ fontFamily: "var(--font-family-Font-1, Pretendard)" }}>{stripHtml(post.title)}</span>
{(post.stat?.commentsCount ?? 0) > 0 && ( {(post.stat?.commentsCount ?? 0) > 0 && (
<span className="ml-1 text-[14px] text-[#f45f00] font-bold shrink-0">[{post.stat?.commentsCount}]</span> <span className="ml-1 text-[11px] text-[#f45f00] font-bold shrink-0">[{post.stat?.commentsCount}]</span>
)} )}
</div> </div>
<div className="h-[16px] relative"> <div className="h-[16px] relative">
<span className="absolute top-1/2 translate-y-[-50%] text-[12px] leading-[12px] tracking-[-0.24px] text-[#8c8c8c]"> <span className="absolute top-1/2 translate-y-[-50%] text-[10px] leading-[10px] tracking-[-0.24px] text-[#8c8c8c]">
{formatDateYmd(post.createdAt)} {formatDateYmd(post.createdAt)}
</span> </span>
</div> </div>
@@ -248,14 +277,23 @@ export function BoardPanelClient({
<div className="content-stretch flex gap-[30px] items-start w-full mb-2"> <div className="content-stretch flex gap-[30px] items-start w-full mb-2">
<div className="flex items-center gap-[8px] shrink-0"> <div className="flex items-center gap-[8px] shrink-0">
<div className="text-[30px] text-[#5c5c5c] leading-[30px]">{categoryName || board.name}</div> <div className="text-[30px] text-[#5c5c5c] leading-[30px]">{categoryName || board.name}</div>
<Link href={`/boards/${board.slug}`} aria-label={`${board.name} 게시판으로 이동`} className="shrink-0 group">
{/* 기본 아이콘 */}
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 19 19" fill="none" className="block group-hover:hidden">
<path d="M10.25 12.5L13.25 9.5M13.25 9.5L10.25 6.5M13.25 9.5L5.75 9.5M18.5 9.5C18.5 14.4706 14.4706 18.5 9.5 18.5C4.52944 18.5 0.5 14.4706 0.5 9.5C0.5 4.52944 4.52944 0.5 9.5 0.5C14.4706 0.5 18.5 4.52944 18.5 9.5Z" stroke="#8C8C8C" strokeLinecap="round" strokeLinejoin="round" />
</svg>
{/* 호버 아이콘 */}
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none" className="hidden group-hover:block">
<path fillRule="evenodd" clipRule="evenodd" d="M9.75 0C4.36522 0 0 4.36522 0 9.75C0 15.1348 4.36522 19.5 9.75 19.5C15.1348 19.5 19.5 15.1348 19.5 9.75C19.5 4.36522 15.1348 0 9.75 0ZM14.0303 10.2803C14.171 10.1397 14.25 9.94891 14.25 9.75C14.25 9.55109 14.171 9.36032 14.0303 9.21967L11.0303 6.21967C10.7374 5.92678 10.2626 5.92678 9.96967 6.21967C9.67678 6.51256 9.67678 6.98744 9.96967 7.28033L11.6893 9L6 9C5.58579 9 5.25 9.33578 5.25 9.75C5.25 10.1642 5.58579 10.5 6 10.5L11.6893 10.5L9.96967 12.2197C9.67678 12.5126 9.67678 12.9874 9.96967 13.2803C10.2626 13.5732 10.7374 13.5732 11.0303 13.2803L14.0303 10.2803Z" fill="#5C5C5C" />
</svg>
</Link>
</div> </div>
<div className="flex items-center gap-[8px] overflow-x-auto flex-nowrap min-w-0 flex-1 no-scrollbar"> <div className="flex items-center gap-[8px] overflow-x-auto flex-nowrap min-w-0 flex-1 no-scrollbar">
{siblingBoards.map((sb) => ( {siblingBoards.map((sb) => (
<button <button
key={sb.id} key={sb.id}
onClick={() => setSelectedBoardId(sb.id)} onClick={() => setSelectedBoardId(sb.id)}
className={`px-[16px] py-[8px] rounded-[14px] text-[14px] shrink-0 cursor-pointer ${ className={`px-[16px] py-[8px] rounded-[14px] text-[14px] shrink-0 cursor-pointer ${sb.id === selectedBoardId ? "bg-[#5c5c5c] text-white border border-[#5c5c5c]" : "bg-white text-[#5c5c5c] border border-[#d5d5d5] hover:bg-[#5c5c5c] hover:text-white hover:border-[#5c5c5c] transition-colors"
sb.id === selectedBoardId ? "bg-[#5c5c5c] text-white border border-[#5c5c5c]" : "bg-white text-[#5c5c5c] border border-[#d5d5d5]"
}`} }`}
> >
{sb.name} {sb.name}
@@ -264,23 +302,29 @@ export function BoardPanelClient({
</div> </div>
</div> </div>
<div className="rounded-xl overflow-hidden h-full min-h-0 flex flex-col bg-white"> <div className="rounded-xl overflow-hidden h-full min-h-0 flex flex-col bg-white">
{!isTextMain && (
<div className="px-3 py-2 border-b border-neutral-200 flex items-center justify-between"> <div className="px-3 py-2 border-b border-neutral-200 flex items-center justify-between">
<Link href={`/boards/${board.slug}`} className="text-lg md:text-xl font-bold text-neutral-800 truncate">{board.name}</Link> <Link href={`/boards/${board.slug}`} className="text-lg md:text-xl font-bold text-neutral-800 truncate">{board.name}</Link>
<Link href={`/boards/${board.slug}`} className="text-xs px-3 py-1 rounded-full border border-neutral-300 text-neutral-700 hover:bg-neutral-100"></Link> <Link href={`/boards/${board.slug}`} className="text-xs px-3 py-1 rounded-full border border-neutral-300 text-neutral-700 hover:bg-neutral-100"></Link>
</div> </div>
)}
<div className="flex-1 min-h-0 overflow-hidden p-0"> <div className="flex-1 min-h-0 overflow-hidden p-0">
{isTextMain && selectedBoardData.textPosts ? ( {isTextMain && selectedBoardData.textPosts ? (
<div className="bg-white px-[24px] pt-[8px] pb-[16px]"> <div className="bg-white px-[24px] pt-[8px] pb-[16px]">
<ul className="min-h-[326px]"> <ul className="min-h-[326px]">
{selectedBoardData.textPosts.map((p) => ( {selectedBoardData.textPosts.map((p) => (
<li key={p.id} className="border-b border-[#ededed] h-[56px] pl-0 pr-[24px] pt-[16px] pb-[16px]"> <li key={p.id} className="border-b border-[#ededed] h-[28px] pl-0 pr-[4px] pt-0 pb-0">
<div className="flex items-center justify-between w-full"> <div className="flex items-center justify-between w-full">
<Link href={`/posts/${p.id}`} className="flex items-center gap-[4px] h-[24px] overflow-hidden flex-1 min-w-0"> <Link href={`/posts/${p.id}`} className="group flex items-center gap-[4px] h-[32px] overflow-hidden flex-1 min-w-0">
{isNewWithin1Hour(p.createdAt) && (
<div className="relative w-[16px] h-[16px] shrink-0"> <div className="relative w-[16px] h-[16px] shrink-0">
<div className="absolute inset-0 rounded-full bg-[#f45f00] opacity-0" /> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path fillRule="evenodd" clipRule="evenodd" d="M1 8C1 4.5691 4.26184 2 8 2C11.7382 2 15 4.5691 15 8C15 11.4309 11.7382 14 8 14C7.57698 14 7.16215 13.9679 6.7588 13.9061C5.85852 14.4801 4.81757 14.8544 3.6995 14.9654C3.41604 14.9936 3.1411 14.8587 2.98983 14.6174C2.83857 14.376 2.83711 14.0698 2.98605 13.8269C3.21838 13.4482 3.38055 13.0221 3.45459 12.5659C1.97915 11.4858 1 9.86014 1 8Z" fill="#F45F00" />
</svg>
<div className="absolute inset-0 flex items-center justify-center text-[10px] text-white font-extrabold select-none">n</div> <div className="absolute inset-0 flex items-center justify-center text-[10px] text-white font-extrabold select-none">n</div>
</div> </div>
<span className="text-[16px] leading-[22px] text-[#5c5c5c] truncate max-w-[calc(100vw-280px)]">{stripHtml(p.title)}</span> )}
<span className="text-[14px] leading-[20px] text-[#5c5c5c] truncate max-w-[calc(100vw-280px)] group-hover:text-[var(--red-50,#F94B37)] group-hover:font-[700]" style={{ fontFamily: "var(--font-family-Font-1, Pretendard)" }}>{stripHtml(p.title)}</span>
{(p.stat?.commentsCount ?? 0) > 0 && ( {(p.stat?.commentsCount ?? 0) > 0 && (
<span className="text-[14px] text-[#f45f00] font-bold">[{p.stat?.commentsCount}]</span> <span className="text-[14px] text-[#f45f00] font-bold">[{p.stat?.commentsCount}]</span>
)} )}
@@ -292,7 +336,7 @@ export function BoardPanelClient({
</ul> </ul>
</div> </div>
) : ( ) : (
<PostList key={board.id} boardId={board.id} sort="recent" /> <PostList key={board.id} boardId={board.id} sort="recent" titleHoverOrange pageSizeOverride={16} compact />
)} )}
</div> </div>
</div> </div>

View File

@@ -88,7 +88,7 @@ export function HeroBanner({ subItems, activeSubId, hideSubOnMobile }: { subItem
className={ className={
s.id === activeSubId 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-[#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" : "px-3 h-[28px] rounded-full bg-transparent text-white/85 hover:bg-[#F94B37] hover:text-white text-[12px] leading-[28px] whitespace-nowrap"
} }
> >
{s.name} {s.name}
@@ -130,6 +130,32 @@ export function HeroBanner({ subItems, activeSubId, hideSubOnMobile }: { subItem
))} ))}
</div> </div>
{/* 좌우 내비게이션 버튼 */}
{numSlides > 1 && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-between px-1 sm:px-2">
<button
type="button"
onClick={goPrev}
aria-label="이전 배너"
className="pointer-events-auto inline-flex items-center justify-center h-8 w-8 text-white/80 hover:text-[var(--red-50,#F94B37)] transition-colors focus:outline-none"
>
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.5 15L7.5 10L12.5 5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
<button
type="button"
onClick={goNext}
aria-label="다음 배너"
className="pointer-events-auto inline-flex items-center justify-center h-8 w-8 text-white/80 hover:text-[var(--red-50,#F94B37)] transition-colors focus:outline-none"
>
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.5 5L12.5 10L7.5 15" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
</div>
)}
{/* Pagination - Figma 스타일: 활성은 주황 바, 비활성은 회색 점 (배너 위에 오버랩) */} {/* Pagination - Figma 스타일: 활성은 주황 바, 비활성은 회색 점 (배너 위에 오버랩) */}
{numSlides > 1 && ( {numSlides > 1 && (
<div className="pointer-events-auto absolute bottom-3 left-1/2 -translate-x-1/2 z-10 flex items-center gap-[10px]"> <div className="pointer-events-auto absolute bottom-3 left-1/2 -translate-x-1/2 z-10 flex items-center gap-[10px]">
@@ -161,7 +187,7 @@ export function HeroBanner({ subItems, activeSubId, hideSubOnMobile }: { subItem
className={ className={
s.id === activeSubId 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-[#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" : "px-3 h-[28px] rounded-full bg-transparent text-white/85 hover:bg-[#F94B37] hover:text-white text-[12px] leading-[28px] whitespace-nowrap"
} }
> >
{s.name} {s.name}

View File

@@ -35,7 +35,7 @@ function stripHtml(html: string | null | undefined): string {
return html.replace(/<[^>]*>/g, "").trim(); return html.replace(/<[^>]*>/g, "").trim();
} }
export function PostList({ boardId, sort = "recent", q, tag, author, authorId, start, end, variant = "default", newPostHref }: { boardId?: string; sort?: "recent" | "popular"; q?: string; tag?: string; author?: string; authorId?: string; start?: string; end?: string; variant?: "default" | "board"; newPostHref?: string }) { export function PostList({ boardId, sort = "recent", q, tag, author, authorId, start, end, variant = "default", newPostHref, titleHoverOrange, pageSizeOverride, compact }: { boardId?: string; sort?: "recent" | "popular"; q?: string; tag?: string; author?: string; authorId?: string; start?: string; end?: string; variant?: "default" | "board"; newPostHref?: string; titleHoverOrange?: boolean; pageSizeOverride?: number; compact?: boolean }) {
const sp = useSearchParams(); const sp = useSearchParams();
const listContainerRef = useRef<HTMLDivElement | null>(null); const listContainerRef = useRef<HTMLDivElement | null>(null);
const [lockedMinHeight, setLockedMinHeight] = useState<number | null>(null); const [lockedMinHeight, setLockedMinHeight] = useState<number | null>(null);
@@ -43,7 +43,9 @@ export function PostList({ boardId, sort = "recent", q, tag, author, authorId, s
// board 변형에서는 URL에서 pageSize를 읽고, 기본값은 20 // board 변형에서는 URL에서 pageSize를 읽고, 기본값은 20
const defaultPageSize = variant === "board" ? 20 : 10; const defaultPageSize = variant === "board" ? 20 : 10;
const pageSizeParam = sp.get("pageSize"); const pageSizeParam = sp.get("pageSize");
const pageSize = pageSizeParam ? Math.min(50, Math.max(10, parseInt(pageSizeParam, 10))) : defaultPageSize; const pageSize = (variant === "board" && pageSizeOverride)
? pageSizeOverride
: (pageSizeParam ? Math.min(50, Math.max(10, parseInt(pageSizeParam, 10))) : defaultPageSize);
// board 변형: 번호 페이지네이션 // board 변형: 번호 페이지네이션
const initialPage = useMemo(() => Math.max(1, parseInt(sp.get("page") || "1", 10)), [sp]); const initialPage = useMemo(() => Math.max(1, parseInt(sp.get("page") || "1", 10)), [sp]);
@@ -168,15 +170,17 @@ export function PostList({ boardId, sort = "recent", q, tag, author, authorId, s
<div ref={listContainerRef} style={{ minHeight: lockedMinHeight ? `${lockedMinHeight}px` : undefined }}> <div ref={listContainerRef} style={{ minHeight: lockedMinHeight ? `${lockedMinHeight}px` : undefined }}>
<ul className="divide-y divide-[#ececec]"> <ul className="divide-y divide-[#ececec]">
{items.map((p) => ( {items.map((p) => (
<li key={p.id} className={`px-4 ${variant === "board" ? "py-2.5" : "py-3 md:py-3"} hover:bg-neutral-50 transition-colors`}> <li key={p.id} className={`px-4 ${variant === "board" ? (compact ? "py-1.5" : "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"> <div className="grid grid-cols-1 md:grid-cols-[20px_1fr_120px_120px_80px] items-center gap-2">
{/* bullet/공지 아이콘 자리 */} {/* bullet/공지 아이콘 자리 */}
<div className="hidden md:flex items-center justify-center text-[#f94b37]">{p.isPinned ? "★" : "•"}</div> <div className="hidden md:flex items-center justify-center text-[#f94b37]">{p.isPinned ? "★" : "•"}</div>
<div className="min-w-0"> <div className="min-w-0">
<Link href={`/posts/${p.id}`} className="block truncate text-[15px] md:text-base text-neutral-900"> <Link href={`/posts/${p.id}`} className={`group block truncate 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>} {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>}
<span className={`${titleHoverOrange ? "group-hover:text-[var(--red-50,#F94B37)] group-hover:font-[700] group-hover:text-[16px] group-hover:leading-[22px]" : ""} ${compact ? "text-[14px] leading-[20px]" : "text-[15px] md:text-base"}`} style={{ fontFamily: "var(--font-family-Font-1, Pretendard)" }}>
{stripHtml(p.title)} {stripHtml(p.title)}
</span>
{(p.stat?.commentsCount ?? 0) > 0 && ( {(p.stat?.commentsCount ?? 0) > 0 && (
<span className="ml-1 text-[12px] md:text-[12px] text-[#f94b37] align-middle">[{p.stat?.commentsCount}]</span> <span className="ml-1 text-[12px] md:text-[12px] text-[#f94b37] align-middle">[{p.stat?.commentsCount}]</span>
)} )}

View File

@@ -57,6 +57,14 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s
if (admin) currentUser = admin; if (admin) currentUser = admin;
} }
// 내가 쓴 게시글/댓글 수
let myPostsCount = 0;
let myCommentsCount = 0;
if (currentUser) {
myPostsCount = await prisma.post.count({ where: { authorId: currentUser.userId, status: "published" } });
myCommentsCount = await prisma.comment.count({ where: { authorId: currentUser.userId } });
}
// 메인페이지 설정 불러오기 // 메인페이지 설정 불러오기
const SETTINGS_KEY = "mainpage_settings" as const; const SETTINGS_KEY = "mainpage_settings" as const;
const settingRow = await prisma.setting.findUnique({ where: { key: SETTINGS_KEY } }); const settingRow = await prisma.setting.findUnique({ where: { key: SETTINGS_KEY } });
@@ -138,7 +146,7 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s
select: { userId: true, nickname: true, points: true, profileImage: true, grade: true }, select: { userId: true, nickname: true, points: true, profileImage: true, grade: true },
where: { status: "active" }, where: { status: "active" },
orderBy: { points: "desc" }, orderBy: { points: "desc" },
take: 3, take: 6,
}); });
} else if (isPreview) { } else if (isPreview) {
previewPosts = await prisma.post.findMany({ previewPosts = await prisma.post.findMany({
@@ -157,14 +165,14 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s
stat: { select: { commentsCount: true } }, stat: { select: { commentsCount: true } },
}, },
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
take: 3, take: 6,
}); });
} else if (isTextMain) { } else if (isTextMain) {
textPosts = await prisma.post.findMany({ textPosts = await prisma.post.findMany({
where: { boardId: sb.id, status: "published" }, where: { boardId: sb.id, status: "published" },
select: { id: true, title: true, createdAt: true, stat: { select: { recommendCount: true, commentsCount: true } } }, select: { id: true, title: true, createdAt: true, stat: { select: { recommendCount: true, commentsCount: true } } },
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
take: 7, take: 16,
}); });
} }
// 기본 타입은 PostList가 자체적으로 API를 호출하므로 데이터 미리 가져오지 않음 // 기본 타입은 PostList가 자체적으로 API를 호출하므로 데이터 미리 가져오지 않음
@@ -270,28 +278,38 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s
</div> </div>
</div> </div>
<div className="flex flex-col gap-[12px] relative z-10"> <div className="flex flex-col gap-[12px] relative z-10">
<Link href="/my-page" className="relative w-[300px] h-[32px] rounded-full bg-[#8c8c8c] hover:bg-[#7a7a7a] text-white text-[12px] font-[700] flex items-center justify-center"> <Link href="/my-page" className="w-[300px] h-[32px] rounded-full bg-[#8c8c8c] hover:bg-[#5c5c5c] text-white text-[12px] font-[700] flex items-center px-[12px]">
<span className="inline-flex items-center"> <span className="flex items-center w-full pl-[88px]">
<span className="flex items-center gap-[8px]">
<SearchIcon width={16} height={16} /> <SearchIcon width={16} height={16} />
<span className="ml-[8px]"> </span> <span> </span>
</span>
</span> </span>
</Link> </Link>
<Link href="/my-page?tab=points" className="relative w-[300px] h-[32px] rounded-full bg-[#8c8c8c] hover:bg-[#7a7a7a] text-white text-[12px] font-[700] flex items-center"> <Link href="/my-page?tab=points" className="w-[300px] h-[32px] rounded-full bg-[#8c8c8c] hover:bg-[#5c5c5c] text-white text-[12px] font-[700] flex items-center px-[12px]">
<span className="absolute left-[100px] inline-flex items-center"> <span className="flex items-center w-full pl-[88px]">
<span className="flex items-center gap-[8px]">
<SearchIcon width={16} height={16} /> <SearchIcon width={16} height={16} />
<span className="ml-[8px]"> </span> <span> </span>
</span>
</span> </span>
</Link> </Link>
<Link href={`/my-page?tab=posts`} className="relative w-[300px] h-[32px] rounded-full bg-[#8c8c8c] hover:bg-[#7a7a7a] text-white text-[12px] font-[700] flex items-center"> <Link href={`/my-page?tab=posts`} className="w-[300px] h-[32px] rounded-full bg-[#8c8c8c] hover:bg-[#5c5c5c] text-white text-[12px] font-[700] flex items-center px-[12px]">
<span className="absolute left-[100px] inline-flex items-center"> <span className="flex items-center w-full pl-[88px]">
<span className="flex items-center gap-[8px]">
<SearchIcon width={16} height={16} /> <SearchIcon width={16} height={16} />
<span className="ml-[8px]"> </span> <span> </span>
</span>
<span className="ml-auto inline-flex items-center justify-center h-[20px] px-[8px] rounded-full bg-white text-[#5c5c5c] text-[12px] leading-[20px] shrink-0">{myPostsCount.toLocaleString()}</span>
</span> </span>
</Link> </Link>
<Link href={`/my-page?tab=comments`} className="relative w-[300px] h-[32px] rounded-full bg-[#8c8c8c] hover:bg-[#7a7a7a] text-white text-[12px] font-[700] flex items-center"> <Link href={`/my-page?tab=comments`} className="w-[300px] h-[32px] rounded-full bg-[#8c8c8c] hover:bg-[#5c5c5c] text-white text-[12px] font-[700] flex items-center px-[12px]">
<span className="absolute left-[100px] inline-flex items-center"> <span className="flex items-center w-full pl-[88px]">
<span className="flex items-center gap-[8px]">
<SearchIcon width={16} height={16} /> <SearchIcon width={16} height={16} />
<span className="ml-[8px]"> </span> <span> </span>
</span>
<span className="ml-auto inline-flex items-center justify-center h-[20px] px-[8px] rounded-full bg-white text-[#5c5c5c] text-[12px] leading-[20px] shrink-0">{myCommentsCount.toLocaleString()}</span>
</span> </span>
</Link> </Link>
</div> </div>

View File

@@ -82,7 +82,7 @@ export default function NewPostPage() {
className="h-16 rounded-2xl border border-neutral-300 px-6 text-base text-neutral-900 bg-white hover:bg-neutral-50" className="h-16 rounded-2xl border border-neutral-300 px-6 text-base text-neutral-900 bg-white hover:bg-neutral-50"
onClick={() => {/* 태그 선택 자리표시 */}} onClick={() => {/* 태그 선택 자리표시 */}}
> >
</button> </button>
</div> </div>