326 lines
13 KiB
TypeScript
326 lines
13 KiB
TypeScript
import Link from "next/link";
|
||
import { cookies, headers } from "next/headers";
|
||
import { notFound } from "next/navigation";
|
||
import type { Metadata } from "next";
|
||
import Header from "@/components/header";
|
||
import DetailRawPanel from "@/components/detail-raw-panel";
|
||
|
||
type SourceType = "twitter" | "pixiv";
|
||
|
||
type PostDetailResponse = {
|
||
_id: string;
|
||
type: SourceType;
|
||
url?: string;
|
||
author?: string;
|
||
tags?: string[];
|
||
tweet?: {
|
||
text?: string;
|
||
title?: string;
|
||
};
|
||
mediaUrl?: string;
|
||
mediaIndex?: number;
|
||
};
|
||
|
||
type Me = {
|
||
role: "admin" | "writer" | "reader";
|
||
};
|
||
|
||
function getSourceLabel(type: SourceType) {
|
||
return type === "twitter" ? "X" : type === "pixiv" ? "Pixiv" : "-";
|
||
}
|
||
|
||
function createDetailDescription(post: PostDetailResponse) {
|
||
const author = post.author?.trim() || "unknown";
|
||
const source = getSourceLabel(post.type);
|
||
const tags = (post.tags ?? []).filter((tag) => tag.trim().length > 0).slice(0, 5);
|
||
const tagText = tags.length > 0 ? ` | Tags: ${tags.join(", ")}` : "";
|
||
return `${author} | ${source} post${tagText}`;
|
||
}
|
||
|
||
function createDetailTitle(post: PostDetailResponse) {
|
||
const author = post.author?.trim() || "unknown";
|
||
const source = getSourceLabel(post.type);
|
||
const rawText = post.tweet?.text || post.tweet?.title;
|
||
const contentText = rawText?.trim() || post._id;
|
||
return `${author} - ${contentText} | ${source}`;
|
||
}
|
||
|
||
function toAbsoluteUrl(value: string, baseUrl: string) {
|
||
try {
|
||
return new URL(value, baseUrl).toString();
|
||
} catch {
|
||
return value;
|
||
}
|
||
}
|
||
|
||
|
||
|
||
async function getApiBaseUrl() {
|
||
const configuredBaseUrl = process.env.API_BASE_URL;
|
||
if (configuredBaseUrl) {
|
||
return configuredBaseUrl;
|
||
}
|
||
|
||
const requestHeaders = await headers();
|
||
const protocol = requestHeaders.get("x-forwarded-proto") ?? "http";
|
||
const host = requestHeaders.get("x-forwarded-host") ?? requestHeaders.get("host");
|
||
|
||
if (!host) {
|
||
throw new Error("Unable to determine request host for server-side fetches.");
|
||
}
|
||
|
||
return `${protocol}://${host}`;
|
||
}
|
||
|
||
async function fetchPostDetail(apiBaseUrl: string, id: string) {
|
||
const response = await fetch(`${apiBaseUrl}/api/post/detail/${id}`, {
|
||
cache: "no-store",
|
||
});
|
||
|
||
if (response.status === 404 || response.status >= 500) {
|
||
return null;
|
||
}
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`상세 정보를 불러오지 못했습니다: ${response.status}`);
|
||
}
|
||
|
||
return response.json() as Promise<PostDetailResponse>;
|
||
}
|
||
|
||
async function fetchViewerRole(apiBaseUrl: string) {
|
||
const cookieHeader = (await cookies()).toString();
|
||
if (!cookieHeader) {
|
||
return "guest" as const;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`${apiBaseUrl}/api/auth/me`, {
|
||
cache: "no-store",
|
||
headers: {
|
||
cookie: cookieHeader,
|
||
},
|
||
});
|
||
|
||
if (!response.ok) {
|
||
return "guest" as const;
|
||
}
|
||
|
||
const me = (await response.json()) as Me;
|
||
return me.role;
|
||
} catch {
|
||
return "guest" as const;
|
||
}
|
||
}
|
||
|
||
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
|
||
const { id } = await params;
|
||
const fallbackTitle = `Detail ${id} | Akiyama Mizuki`;
|
||
|
||
try {
|
||
const apiBaseUrl = await getApiBaseUrl();
|
||
const post = await fetchPostDetail(apiBaseUrl, id);
|
||
const detailUrl = `${apiBaseUrl}/detail/${id}`;
|
||
|
||
if (!post) {
|
||
return {
|
||
title: fallbackTitle,
|
||
description: "Post detail",
|
||
alternates: {
|
||
canonical: detailUrl,
|
||
},
|
||
robots: {
|
||
index: false,
|
||
follow: false,
|
||
},
|
||
};
|
||
}
|
||
|
||
const source = getSourceLabel(post.type);
|
||
const author = post.author?.trim() || "unknown";
|
||
const title = createDetailTitle(post);
|
||
const description = createDetailDescription(post);
|
||
const ogImageUrl = post.mediaUrl ? toAbsoluteUrl(post.mediaUrl, apiBaseUrl) : undefined;
|
||
const ogImages = ogImageUrl
|
||
? [
|
||
{
|
||
url: ogImageUrl,
|
||
alt: `${author} ${source}`,
|
||
},
|
||
]
|
||
: undefined;
|
||
|
||
return {
|
||
title,
|
||
description,
|
||
alternates: {
|
||
canonical: detailUrl,
|
||
},
|
||
openGraph: {
|
||
type: "article",
|
||
title,
|
||
description,
|
||
url: detailUrl,
|
||
siteName: "Akiyama Mizuki",
|
||
images: ogImages,
|
||
},
|
||
twitter: {
|
||
card: post.mediaUrl ? "summary_large_image" : "summary",
|
||
title,
|
||
description,
|
||
images: ogImageUrl ? [ogImageUrl] : undefined,
|
||
},
|
||
};
|
||
} catch {
|
||
return {
|
||
title: fallbackTitle,
|
||
description: "Post detail",
|
||
};
|
||
}
|
||
}
|
||
|
||
export default async function DetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||
const { id } = await params;
|
||
const apiBaseUrl = await getApiBaseUrl();
|
||
const postPromise = fetchPostDetail(apiBaseUrl, id);
|
||
const viewerRolePromise = fetchViewerRole(apiBaseUrl);
|
||
|
||
let post: PostDetailResponse | null;
|
||
try {
|
||
post = await postPromise;
|
||
} catch (loadError) {
|
||
return (
|
||
<div className="min-h-screen bg-main-gradient text-foreground transition-colors duration-500">
|
||
<Header />
|
||
<main className="mx-auto w-full max-w-6xl px-4 py-8 sm:px-6 lg:px-8">
|
||
<section className="border-b border-border/70 pb-6">
|
||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||
<div>
|
||
<h1 className="text-xl tracking-wide">Detail</h1>
|
||
</div>
|
||
<div className="border-l border-border/80 pl-3 text-xs text-foreground/70">
|
||
<p>ID: <span className="font-medium text-foreground/90">{id}</span></p>
|
||
</div>
|
||
</div>
|
||
<p className="mt-4 text-sm text-red-600">
|
||
{loadError instanceof Error ? loadError.message : "상세 정보를 불러오지 못했습니다."}
|
||
</p>
|
||
<div className="mt-6">
|
||
<Link href="/" className="border border-border px-4 py-2 text-sm text-foreground/70 transition hover:bg-black/5">
|
||
돌아가기
|
||
</Link>
|
||
</div>
|
||
</section>
|
||
</main>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!post) {
|
||
notFound();
|
||
}
|
||
|
||
const viewerRole = await viewerRolePromise;
|
||
const canManagePost = viewerRole === "admin" || viewerRole === "writer";
|
||
const sourceLabel = post.type === "twitter" ? "𝕏" : post.type === "pixiv" ? "Pixiv" : "-";
|
||
const tags = post.tags ?? [];
|
||
|
||
return (
|
||
<div className="min-h-screen bg-main-gradient text-foreground transition-colors duration-500">
|
||
<Header />
|
||
|
||
<main className="mx-auto w-full max-w-6xl px-4 py-8 sm:px-6 lg:px-8">
|
||
<section className="border-b border-border/70 pb-6">
|
||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||
<div>
|
||
<h1 className="text-xl tracking-wide">Detail</h1>
|
||
</div>
|
||
<div className="border-l border-border/80 pl-3 text-xs text-foreground/70">
|
||
<p>Source: <span className="font-medium text-foreground/90">{sourceLabel}</span></p>
|
||
<p className="mt-0.5">ID: <span className="font-medium text-foreground/90">{id}</span></p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-6 grid gap-6 lg:grid-cols-[minmax(0,1.15fr)_minmax(320px,0.85fr)]">
|
||
<div className="space-y-3">
|
||
{post.mediaUrl ? (
|
||
<img
|
||
src={post.mediaUrl}
|
||
alt={post.author ?? post._id}
|
||
loading="eager"
|
||
decoding="async"
|
||
className="w-full border border-border bg-card object-contain image-scale"
|
||
/>
|
||
) : (
|
||
<div className="aspect-4/5 w-full border border-border bg-muted/30" />
|
||
)}
|
||
</div>
|
||
|
||
<aside className="space-y-4">
|
||
<div className="border border-border bg-background/80 p-4">
|
||
<p className="text-xs uppercase tracking-[0.3em] text-foreground/40">Metadata</p>
|
||
<dl className="mt-3 space-y-3 text-sm">
|
||
<div>
|
||
<dt className="text-foreground/45">Author</dt>
|
||
<dd className="mt-0.5 text-foreground/90">{post.author?.trim() || "unknown"}</dd>
|
||
</div>
|
||
<div>
|
||
<dt className="text-foreground/45">Type</dt>
|
||
<dd className="mt-0.5 text-foreground/90">{post.type}</dd>
|
||
</div>
|
||
<div>
|
||
<dt className="text-foreground/45">Media Index</dt>
|
||
<dd className="mt-0.5 text-foreground/90">{typeof post.mediaIndex === "number" ? post.mediaIndex + 1 : "-"}</dd>
|
||
</div>
|
||
</dl>
|
||
</div>
|
||
|
||
<div className="border border-border bg-background/80 p-4">
|
||
<p className="text-xs uppercase tracking-[0.3em] text-foreground/40">Tags</p>
|
||
<div className="mt-3 flex flex-wrap gap-2">
|
||
{tags.length > 0 ? tags.map((tag) => (
|
||
<span key={tag} className="border border-border/80 px-2 py-1 text-xs text-foreground/75">
|
||
{tag}
|
||
</span>
|
||
)) : (
|
||
<span className="text-sm text-foreground/55">태그 없음</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-2">
|
||
{post.url ? (
|
||
<a
|
||
href={post.url}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="border border-border px-4 py-2 text-sm text-foreground/80 transition hover:bg-accent"
|
||
>
|
||
원본 보기
|
||
</a>
|
||
) : null}
|
||
<div className="flex flex-row gap-2">
|
||
<Link
|
||
href="/"
|
||
className="border border-border px-4 py-2 text-sm text-foreground/70 transition hover:bg-accent w-full"
|
||
>
|
||
돌아가기
|
||
</Link>
|
||
{canManagePost ? (
|
||
<Link
|
||
href={`/edit/${post._id}`}
|
||
className="border border-border bg-foreground px-4 py-2 text-sm text-background transition hover:opacity-90 w-full"
|
||
>
|
||
편집
|
||
</Link>
|
||
) : null}
|
||
</div>
|
||
<DetailRawPanel data={post} />
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
</section>
|
||
</main>
|
||
</div>
|
||
);
|
||
}
|