akiyama.mizuki.guru/apps/frontend/src/app/detail/[id]/page.tsx

326 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}