ㄱㄱ
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
import Link from "next/link";
|
||||
export function AppSidebar() {
|
||||
return (
|
||||
<aside style={{ width: 200, borderRight: "1px solid #eee", padding: 12 }}>
|
||||
<ul style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<li><a href="/boards">게시판</a></li>
|
||||
<li><a href="/admin">관리</a></li>
|
||||
<li><Link href="/boards">게시판</Link></li>
|
||||
<li><Link href="/admin">관리</Link></li>
|
||||
</ul>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -2,12 +2,22 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { SelectedBanner } from "@/app/components/SelectedBanner";
|
||||
import Link from "next/link";
|
||||
import useSWR from "swr";
|
||||
|
||||
type Banner = { id: string; title: string; imageUrl: string; linkUrl?: string | null };
|
||||
type SubItem = { id: string; name: string; href: string };
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
||||
|
||||
export function HeroBanner({ subItems, activeSubId }: { subItems?: SubItem[]; activeSubId?: string }) {
|
||||
const [banners, setBanners] = useState<Banner[]>([]);
|
||||
// SWR 캐시 사용: 페이지 전환 시 재요청/깜빡임 최소화
|
||||
const { data } = useSWR<{ banners: Banner[] }>("/api/banners", fetcher, {
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 10 * 60 * 1000, // 10분 간 동일 요청 합치기
|
||||
keepPreviousData: true,
|
||||
});
|
||||
const banners = data?.banners ?? [];
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [progress, setProgress] = useState(0); // 0..1
|
||||
@@ -15,10 +25,6 @@ export function HeroBanner({ subItems, activeSubId }: { subItems?: SubItem[]; ac
|
||||
const rafIdRef = useRef<number | null>(null);
|
||||
const startedAtRef = useRef<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/banners").then((r) => r.json()).then((d) => setBanners(d.banners ?? []));
|
||||
}, []);
|
||||
|
||||
const numSlides = banners.length;
|
||||
const canAutoPlay = numSlides > 1 && !isHovered;
|
||||
|
||||
@@ -67,7 +73,33 @@ export function HeroBanner({ subItems, activeSubId }: { subItems?: SubItem[]; ac
|
||||
|
||||
const translatePercent = useMemo(() => (numSlides > 0 ? -(activeIndex * 100) : 0), [activeIndex, numSlides]);
|
||||
|
||||
if (numSlides === 0) return <SelectedBanner height={224} />;
|
||||
if (numSlides === 0) {
|
||||
return (
|
||||
<section className="relative w-full overflow-hidden rounded-xl bg-neutral-900 text-white" aria-roledescription="carousel">
|
||||
<SelectedBanner className="h-56 sm:h-72 md:h-[264px]" />
|
||||
{/* 분리된 하단 블랙 바: 높이 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 flex-wrap items-center gap-[8px]">
|
||||
{subItems.map((s) => (
|
||||
<Link
|
||||
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}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
@@ -121,11 +153,11 @@ export function HeroBanner({ subItems, activeSubId }: { subItems?: SubItem[]; ac
|
||||
{/* 분리된 하단 블랙 바: 높이 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
|
||||
<div className="flex flex-wrap items-center gap-[8px]">
|
||||
{subItems.map((s) => (
|
||||
<Link
|
||||
key={s.id}
|
||||
href={s.href}
|
||||
href={s.href}
|
||||
className={
|
||||
s.id === activeSubId
|
||||
? "px-3 h-[28px] rounded-full bg-[#F94B37] text-white text-[12px] leading-[28px] whitespace-nowrap"
|
||||
@@ -133,7 +165,7 @@ export function HeroBanner({ subItems, activeSubId }: { subItems?: SubItem[]; ac
|
||||
}
|
||||
>
|
||||
{s.name}
|
||||
</a>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Link from "next/link";
|
||||
"use client";
|
||||
import useSWR from "swr";
|
||||
|
||||
@@ -16,7 +17,7 @@ export function PersonalWidgets() {
|
||||
<ul style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
{(recent?.items ?? []).map((i) => (
|
||||
<li key={i.id}>
|
||||
<a href={`/posts/${i.id}`}>{i.title}</a>
|
||||
<Link href={`/posts/${i.id}`}>{i.title}</Link>
|
||||
</li>
|
||||
))}
|
||||
{(!recent || recent.items.length === 0) && <li>최근 본 글이 없습니다.</li>}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import useSWRInfinite from "swr/infinite";
|
||||
import useSWR from "swr";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import ViewsIcon from "@/app/svgs/ViewsIcon";
|
||||
import LikeIcon from "@/app/svgs/LikeIcon";
|
||||
@@ -129,14 +130,14 @@ export function PostList({ boardId, sort = "recent", q, tag, author, start, end,
|
||||
<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">
|
||||
<Link 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>}
|
||||
{p.title}
|
||||
</a>
|
||||
</Link>
|
||||
{!!p.postTags?.length && (
|
||||
<div className="mt-1 hidden md:flex flex-wrap gap-1 text-[11px] text-neutral-500">
|
||||
{p.postTags?.map((pt) => (
|
||||
<a key={pt.tag.slug} href={`/search?tag=${pt.tag.slug}`} className="hover:underline">#{pt.tag.name}</a>
|
||||
<Link key={pt.tag.slug} href={`/search?tag=${pt.tag.slug}`} className="hover:underline">#{pt.tag.name}</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -227,9 +228,9 @@ export function PostList({ boardId, sort = "recent", q, tag, author, start, end,
|
||||
</button>
|
||||
</div>
|
||||
{newPostHref && (
|
||||
<a href={newPostHref} className="shrink-0">
|
||||
<Link 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>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Link from "next/link";
|
||||
"use client";
|
||||
import { usePermission } from "@/lib/usePermission";
|
||||
|
||||
@@ -7,8 +8,8 @@ export function QuickActions() {
|
||||
const isAdmin = can("ADMIN", "ADMINISTER") || can("BOARD", "MODERATE");
|
||||
return (
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
{canWrite && <a href="/posts/new"><button>글쓰기</button></a>}
|
||||
{isAdmin && <a href="/admin"><button>관리자</button></a>}
|
||||
{canWrite && <Link href="/posts/new"><button>글쓰기</button></Link>}
|
||||
{isAdmin && <Link href="/admin"><button>관리자</button></Link>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,24 +2,19 @@
|
||||
import React from "react";
|
||||
|
||||
type Props = {
|
||||
height?: number | string; // ex) 224 or '14rem'
|
||||
height?: number | string; // ex) 224 or '14rem' (지정 시 고정 높이, 미지정 시 클래스 높이 사용)
|
||||
className?: string;
|
||||
};
|
||||
|
||||
// Figma 선택 요소(Container_Event)를 그대로 옮긴 플레이스홀더형 배너
|
||||
export function SelectedBanner({ height = 122, className }: Props) {
|
||||
export function SelectedBanner({ height, className }: Props) {
|
||||
return (
|
||||
<section
|
||||
className={`relative w-full overflow-hidden rounded-[12px] bg-[#D9D9D9] ${className ?? ""}`}
|
||||
style={{ height }}
|
||||
style={height != null ? { height } : undefined}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Link from "next/link";
|
||||
"use client";
|
||||
import useSWR from "swr";
|
||||
|
||||
@@ -8,11 +9,11 @@ export function TagFilter({ basePath, current }: { basePath: string; current?: s
|
||||
const tags = data?.tags ?? [];
|
||||
return (
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 8 }}>
|
||||
<a href={`${basePath}`} style={{ textDecoration: !current ? "underline" : "none" }}>전체</a>
|
||||
<Link href={`${basePath}`} style={{ textDecoration: !current ? "underline" : "none" }}>전체</Link>
|
||||
{tags.map((t) => (
|
||||
<a key={t.slug} href={`${basePath}?tag=${t.slug}`} style={{ textDecoration: current === t.slug ? "underline" : "none" }}>
|
||||
<Link key={t.slug} href={`${basePath}?tag=${t.slug}`} style={{ textDecoration: current === t.slug ? "underline" : "none" }}>
|
||||
#{t.name}
|
||||
</a>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -11,10 +11,11 @@ export function Modal({ open, onClose, children }: Props) {
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
background: "rgba(0,0,0,0.4)",
|
||||
background: "transparent",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 50,
|
||||
}}
|
||||
>
|
||||
<div onClick={(e) => e.stopPropagation()} style={{ background: "#fff", padding: 16, borderRadius: 8 }}>
|
||||
|
||||
Reference in New Issue
Block a user