123
This commit is contained in:
47
src/app/components/AutoLoginAdmin.tsx
Normal file
47
src/app/components/AutoLoginAdmin.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function AutoLoginAdmin() {
|
||||
useEffect(() => {
|
||||
// 쿠키에 uid가 없으면 어드민으로 자동 로그인
|
||||
const checkCookie = () => {
|
||||
const cookies = document.cookie.split(";");
|
||||
const uidCookie = cookies.find((cookie) => cookie.trim().startsWith("uid="));
|
||||
|
||||
if (!uidCookie) {
|
||||
// 어드민 사용자 정보 가져오기
|
||||
fetch("/api/auth/session")
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (!data.ok || !data.user) {
|
||||
// 어드민으로 로그인 시도
|
||||
fetch("/api/auth/session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ nickname: "admin", password: "1234" }),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((loginData) => {
|
||||
if (loginData.ok) {
|
||||
// 페이지 새로고침하여 적용
|
||||
window.location.reload();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// 에러 무시
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// 에러 무시
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
checkCookie();
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { RankIcon3rd } from "./RankIcon3rd";
|
||||
import { UserAvatar } from "./UserAvatar";
|
||||
import { ImagePlaceholderIcon } from "./ImagePlaceholderIcon";
|
||||
import { PostList } from "./PostList";
|
||||
import { GradeIcon } from "./GradeIcon";
|
||||
|
||||
type BoardMeta = {
|
||||
id: string;
|
||||
@@ -21,12 +22,14 @@ type UserData = {
|
||||
nickname: string | null;
|
||||
points: number;
|
||||
profileImage: string | null;
|
||||
grade: number;
|
||||
};
|
||||
|
||||
type PostData = {
|
||||
id: string;
|
||||
title: string;
|
||||
createdAt: Date;
|
||||
content?: string | null;
|
||||
attachments?: { url: string }[];
|
||||
stat?: { recommendCount: number | null };
|
||||
};
|
||||
@@ -61,6 +64,27 @@ export function BoardPanelClient({
|
||||
return `${yyyy}.${mm}.${dd}`;
|
||||
}
|
||||
|
||||
function stripHtml(html: string | null | undefined): string {
|
||||
if (!html) return "";
|
||||
// HTML 태그 제거
|
||||
return html.replace(/<[^>]*>/g, "").trim();
|
||||
}
|
||||
|
||||
function extractImageFromContent(content: string | null | undefined): string | null {
|
||||
if (!content) return null;
|
||||
// img 태그에서 src 속성 추출
|
||||
const imgMatch = content.match(/<img[^>]+src=["']([^"']+)["'][^>]*>/i);
|
||||
if (imgMatch && imgMatch[1]) {
|
||||
return imgMatch[1];
|
||||
}
|
||||
// figure 안의 img 태그도 확인
|
||||
const figureMatch = content.match(/<figure[^>]*>.*?<img[^>]+src=["']([^"']+)["'][^>]*>/is);
|
||||
if (figureMatch && figureMatch[1]) {
|
||||
return figureMatch[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const isTextMain = board.mainTypeKey === "main_text";
|
||||
const isSpecialRank = board.mainTypeKey === "main_special_rank";
|
||||
const isPreview = board.mainTypeKey === "main_preview";
|
||||
@@ -103,6 +127,9 @@ export function BoardPanelClient({
|
||||
height={150}
|
||||
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">
|
||||
<GradeIcon grade={user.grade} width={40} height={40} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center gap-[10px] px-[30px] py-[24px] min-w-0">
|
||||
<div className="flex flex-col gap-[12px] min-w-0 flex-1">
|
||||
@@ -165,14 +192,15 @@ export function BoardPanelClient({
|
||||
<div className="px-[24px] pt-[8px] pb-[16px]">
|
||||
<div className="flex flex-col gap-[16px]">
|
||||
{selectedBoardData.previewPosts.map((post) => {
|
||||
const firstImage = post.attachments?.[0]?.url;
|
||||
// attachments에서 이미지를 먼저 찾고, 없으면 content에서 추출
|
||||
const firstImage = post.attachments?.[0]?.url || extractImageFromContent(post.content);
|
||||
return (
|
||||
<Link key={post.id} href={`/posts/${post.id}`} className="flex h-[150px] items-start rounded-[16px] overflow-hidden bg-white">
|
||||
<div className="h-[150px] w-[214px] relative shrink-0 bg-[#ededed] overflow-hidden">
|
||||
{firstImage ? (
|
||||
<img
|
||||
src={firstImage}
|
||||
alt={post.title}
|
||||
alt={stripHtml(post.title)}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
@@ -191,7 +219,7 @@ export function BoardPanelClient({
|
||||
<div className="absolute inset-0 rounded-full bg-[#f45f00] opacity-0" />
|
||||
<div className="absolute inset-0 flex items-center justify-center text-[10px] text-white font-extrabold select-none">n</div>
|
||||
</div>
|
||||
<span className="text-[16px] leading-[22px] text-[#5c5c5c] truncate">{post.title}</span>
|
||||
<span className="text-[16px] leading-[22px] text-[#5c5c5c] truncate">{stripHtml(post.title)}</span>
|
||||
</div>
|
||||
<div className="h-[16px] relative">
|
||||
<span className="absolute top-1/2 translate-y-[-50%] text-[12px] leading-[12px] tracking-[-0.24px] text-[#8c8c8c]">
|
||||
@@ -244,14 +272,14 @@ export function BoardPanelClient({
|
||||
{selectedBoardData.textPosts.map((p) => (
|
||||
<li key={p.id} className="border-b border-[#ededed] h-[56px] pl-0 pr-[24px] pt-[16px] pb-[16px]">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex items-center gap-[4px] h-[24px] overflow-hidden">
|
||||
<Link href={`/posts/${p.id}`} className="flex items-center gap-[4px] h-[24px] overflow-hidden flex-1 min-w-0">
|
||||
<div className="relative w-[16px] h-[16px] shrink-0">
|
||||
<div className="absolute inset-0 rounded-full bg-[#f45f00] opacity-0" />
|
||||
<div className="absolute inset-0 flex items-center justify-center text-[10px] text-white font-extrabold select-none">n</div>
|
||||
</div>
|
||||
<span className="text-[16px] leading-[22px] text-[#5c5c5c] truncate max-w-[calc(100vw-280px)]">{p.title}</span>
|
||||
<span className="text-[16px] leading-[22px] text-[#5c5c5c] truncate max-w-[calc(100vw-280px)]">{stripHtml(p.title)}</span>
|
||||
<span className="text-[16px] text-[#f45f00] font-bold">+{p.stat?.recommendCount ?? 0}</span>
|
||||
</div>
|
||||
</Link>
|
||||
<span className="text-[12px] leading-[12px] tracking-[-0.24px] text-[#8c8c8c]">{formatDateYmd(p.createdAt)}</span>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
45
src/app/components/GradeIcon.tsx
Normal file
45
src/app/components/GradeIcon.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
export function GradeIcon({ grade, width = 32, height = 32, className }: { grade: number; width?: number; height?: number; className?: string }) {
|
||||
// grade: 0~7 (bronze, silver, gold, platinum, diamond, master, grandmaster, god)
|
||||
const gradeImages = [
|
||||
"/svgs/01_bronze.svg",
|
||||
"/svgs/02_silver.svg.svg",
|
||||
"/svgs/03_gold.svg",
|
||||
"/svgs/04_platinum.svg",
|
||||
"/svgs/05_diamond.svg",
|
||||
"/svgs/06_master.svg",
|
||||
"/svgs/07_grandmaster.svg",
|
||||
"/svgs/08_god.svg",
|
||||
];
|
||||
|
||||
const gradeIndex = Math.min(7, Math.max(0, grade));
|
||||
const imageSrc = gradeImages[gradeIndex];
|
||||
|
||||
return (
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt={`등급 ${gradeIndex + 1}`}
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
style={{ width, height }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 등급 숫자를 등급 이름으로 변환하는 함수
|
||||
export function getGradeName(grade: number): string {
|
||||
const gradeNames = [
|
||||
"Bronze",
|
||||
"Silver",
|
||||
"Gold",
|
||||
"Platinum",
|
||||
"Diamond",
|
||||
"Master",
|
||||
"Grandmaster",
|
||||
"God",
|
||||
];
|
||||
|
||||
const gradeIndex = Math.min(7, Math.max(0, grade));
|
||||
return gradeNames[gradeIndex];
|
||||
}
|
||||
|
||||
@@ -29,20 +29,37 @@ type Resp = {
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
||||
|
||||
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;
|
||||
function stripHtml(html: string | null | undefined): string {
|
||||
if (!html) return "";
|
||||
// HTML 태그 제거
|
||||
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 }) {
|
||||
const sp = useSearchParams();
|
||||
const listContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [lockedMinHeight, setLockedMinHeight] = useState<number | null>(null);
|
||||
|
||||
// board 변형에서는 URL에서 pageSize를 읽고, 기본값은 20
|
||||
const defaultPageSize = variant === "board" ? 20 : 10;
|
||||
const pageSizeParam = sp.get("pageSize");
|
||||
const pageSize = pageSizeParam ? Math.min(50, Math.max(10, parseInt(pageSizeParam, 10))) : defaultPageSize;
|
||||
|
||||
// board 변형: 번호 페이지네이션
|
||||
const initialPage = useMemo(() => Math.max(1, parseInt(sp.get("page") || "1", 10)), [sp]);
|
||||
const [page, setPage] = useState(initialPage);
|
||||
const [currentPageSize, setCurrentPageSize] = useState(pageSize);
|
||||
|
||||
const getKey = (index: number, prev: Resp | null) => {
|
||||
if (prev && prev.items.length === 0) return null;
|
||||
const page = index + 1;
|
||||
// 무한 스크롤은 board variant가 아닐 때 사용되므로 pageSize 사용
|
||||
const sp = new URLSearchParams({ page: String(page), pageSize: String(pageSize), sort });
|
||||
if (boardId) sp.set("boardId", boardId);
|
||||
if (q) sp.set("q", q);
|
||||
if (tag) sp.set("tag", tag);
|
||||
if (author) sp.set("author", author);
|
||||
if (authorId) sp.set("authorId", authorId);
|
||||
if (start) sp.set("start", start);
|
||||
if (end) sp.set("end", end);
|
||||
return `/api/posts?${sp.toString()}`;
|
||||
@@ -50,29 +67,39 @@ export function PostList({ boardId, sort = "recent", q, tag, author, start, end,
|
||||
// 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 effectivePageSize = variant === "board" ? currentPageSize : pageSize;
|
||||
const canLoadMore = (data?.at(-1)?.items.length ?? 0) === effectivePageSize;
|
||||
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]);
|
||||
useEffect(() => { setCurrentPageSize(pageSize); }, [pageSize]);
|
||||
|
||||
const singleKey = useMemo(() => {
|
||||
if (variant !== "board") return null;
|
||||
const usp = new URLSearchParams({ page: String(page), pageSize: String(pageSize), sort });
|
||||
const usp = new URLSearchParams({ page: String(page), pageSize: String(currentPageSize), 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 (authorId) usp.set("authorId", authorId);
|
||||
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]);
|
||||
}, [variant, page, currentPageSize, sort, boardId, q, tag, author, authorId, 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 [stableData, setStableData] = useState<Resp | null>(null);
|
||||
useEffect(() => {
|
||||
if (singlePageResp) {
|
||||
setStableData(singlePageResp);
|
||||
}
|
||||
}, [singlePageResp]);
|
||||
|
||||
const itemsSingle = stableData?.items ?? [];
|
||||
const totalSingle = stableData?.total ?? singlePageResp?.total ?? 0;
|
||||
const totalPages = Math.max(1, Math.ceil(totalSingle / currentPageSize));
|
||||
const isEmptySingle = !isLoadingSingle && itemsSingle.length === 0 && !stableData;
|
||||
|
||||
const items = variant === "board" ? itemsSingle : itemsInfinite;
|
||||
const isEmpty = variant === "board" ? isEmptySingle : isEmptyInfinite;
|
||||
@@ -149,7 +176,7 @@ export function PostList({ boardId, sort = "recent", q, tag, author, start, end,
|
||||
<div className="min-w-0">
|
||||
<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}
|
||||
{stripHtml(p.title)}
|
||||
</Link>
|
||||
{!!p.postTags?.length && (
|
||||
<div className="mt-1 hidden md:flex flex-wrap gap-1 text-[11px] text-neutral-500">
|
||||
@@ -254,6 +281,30 @@ export function PostList({ boardId, sort = "recent", q, tag, author, start, end,
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-neutral-600">표시 개수</span>
|
||||
<select
|
||||
value={currentPageSize}
|
||||
onChange={(e) => {
|
||||
const newSize = parseInt(e.target.value, 10);
|
||||
setCurrentPageSize(newSize);
|
||||
setPage(1);
|
||||
const nextSp = new URLSearchParams(Array.from(sp.entries()));
|
||||
nextSp.set("pageSize", String(newSize));
|
||||
nextSp.set("page", "1");
|
||||
if (typeof window !== "undefined") {
|
||||
window.history.replaceState(null, "", `?${nextSp.toString()}`);
|
||||
}
|
||||
}}
|
||||
className="h-9 px-2 rounded-md border border-neutral-300 bg-white text-sm"
|
||||
>
|
||||
<option value="10">10개</option>
|
||||
<option value="20">20개</option>
|
||||
<option value="30">30개</option>
|
||||
<option value="40">40개</option>
|
||||
<option value="50">50개</option>
|
||||
</select>
|
||||
</div>
|
||||
{newPostHref && (
|
||||
<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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export function RankIcon1st() {
|
||||
export function RankIcon1st({ width = 20, height = 21 }: { width?: number; height?: number }) {
|
||||
return (
|
||||
<svg width="20" height="21" viewBox="0 0 20 21" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg width={width} height={height} viewBox="0 0 20 21" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g filter="url(#filter0_d_9465_19783)">
|
||||
<path d="M12.0854 18.801C10.8419 19.733 9.1581 19.733 7.91462 18.801L1.96272 14.3398C0.719241 13.4077 0.198919 11.7556 0.673887 10.2475L2.94731 3.02913C3.42228 1.52105 4.7845 0.5 6.32153 0.5H13.6785C15.2155 0.5 16.5777 1.52105 17.0527 3.02913L19.3261 10.2475C19.8011 11.7556 19.2808 13.4077 18.0373 14.3398L12.0854 18.801Z" fill="url(#paint0_linear_9465_19783)"/>
|
||||
<path d="M6.32129 1.5H13.6787C14.7619 1.50011 15.7495 2.22129 16.0986 3.3291L18.3721 10.5479C18.7226 11.6607 18.3334 12.8685 17.4375 13.54L11.4854 18.001C10.5974 18.6663 9.40256 18.6663 8.51465 18.001L2.5625 13.54C1.6666 12.8685 1.27744 11.6607 1.62793 10.5479L3.90137 3.3291C4.25048 2.22129 5.23809 1.50011 6.32129 1.5Z" stroke="white" strokeOpacity="0.4" strokeWidth="2"/>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export function RankIcon2nd() {
|
||||
export function RankIcon2nd({ width = 20, height = 21 }: { width?: number; height?: number }) {
|
||||
return (
|
||||
<svg width="20" height="21" viewBox="0 0 20 21" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg width={width} height={height} viewBox="0 0 20 21" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g filter="url(#filter0_d_9465_19777)">
|
||||
<path d="M12.569 18.9359C11.1502 20.3547 8.84982 20.3547 7.43099 18.9359L1.06412 12.569C-0.354706 11.1502 -0.354706 8.84982 1.06412 7.43099L7.43099 1.06412C8.84982 -0.354706 11.1502 -0.354706 12.569 1.06412L18.9359 7.43099C20.3547 8.84982 20.3547 11.1502 18.9359 12.569L12.569 18.9359Z" fill="url(#paint0_linear_9465_19777)"/>
|
||||
<path d="M8.1377 1.77148C9.16599 0.743186 10.834 0.743186 11.8623 1.77148L18.2285 8.1377C19.2568 9.16599 19.2568 10.834 18.2285 11.8623L11.8623 18.2285C10.834 19.2568 9.16599 19.2568 8.1377 18.2285L1.77148 11.8623C0.743186 10.834 0.743186 9.16599 1.77148 8.1377L8.1377 1.77148Z" stroke="white" strokeOpacity="0.4" strokeWidth="2"/>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export function RankIcon3rd() {
|
||||
export function RankIcon3rd({ width = 20, height = 20 }: { width?: number; height?: number }) {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg width={width} height={height} viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g filter="url(#filter0_d_9465_19771)">
|
||||
<mask id="path-1-inside-1_9465_19771" fill="white">
|
||||
<path d="M1.5 2.9C1.5 1.85066 2.35066 1 3.4 1L16.6 1C17.6493 1 18.5 1.85066 18.5 2.9V10.9C18.5 15.3735 14.5333 19 10 19C5.46667 19 1.5 15.3735 1.5 10.9V2.9Z"/>
|
||||
|
||||
Reference in New Issue
Block a user