feat: implement post existence check and detail page

This commit is contained in:
암냥 2026-04-18 06:48:21 +09:00
commit b18cff8b1a
No known key found for this signature in database
10 changed files with 646 additions and 43 deletions

View file

@ -1,6 +1,7 @@
"use client";
import { FormEvent, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import Header from "../../components/header";
type SourceType = "twitter" | "pixiv";
@ -26,6 +27,22 @@ type Me = {
role: "admin" | "writer" | "reader";
};
type UploadApiResponse = {
success: boolean;
message: string;
savedCount?: number;
failedCount?: number;
};
type ExistsApiResponse = {
success: boolean;
message: string;
exists: boolean;
source: SourceType | null;
postId: string | null;
documentId: string | null;
};
function detectSource(url: string): SourceType | null {
if (/^https?:\/\/(www\.)?(x\.com|twitter\.com|fxtwitter\.com|fixupx\.com|vxwitter\.com)\//.test(url)) {
return "twitter";
@ -59,6 +76,7 @@ export default function AddPage() {
const [lastFetchedUrl, setLastFetchedUrl] = useState("");
const [viewerRole, setViewerRole] = useState<Me["role"] | "guest">("guest");
const [loadingRole, setLoadingRole] = useState(true);
const [existingDetailId, setExistingDetailId] = useState<string | null>(null);
const selectedCount = useMemo(
() => selected.filter(Boolean).length,
@ -110,6 +128,7 @@ export default function AddPage() {
setLastFetchedUrl("");
setError(null);
setSuccess(null);
setExistingDetailId(null);
}
async function fetchPreview(targetUrl?: string) {
@ -125,8 +144,25 @@ export default function AddPage() {
setLoadingPreview(true);
setSourceType(source);
setExistingDetailId(null);
try {
const existsResponse = await fetch(`/api/post/exists?url=${encodeURIComponent(trimmedUrl)}`, {
cache: "no-store",
});
if (existsResponse.ok) {
const existsData = (await existsResponse.json()) as ExistsApiResponse;
if (existsData.exists) {
setPreviewItems([]);
setSelected([]);
setLastFetchedUrl(trimmedUrl);
setExistingDetailId(existsData.documentId);
setSuccess(existsData.message);
return;
}
}
if (source === "twitter") {
const response = await fetch(`/api/tweet/fetch?url=${encodeURIComponent(trimmedUrl)}`, {
cache: "no-store",
@ -255,17 +291,28 @@ export default function AddPage() {
}),
});
if (!response.ok) {
const responseText = await response.text();
throw new Error(responseText || `업로드 실패: ${response.status}`);
let data: UploadApiResponse | null = null;
try {
data = (await response.json()) as UploadApiResponse;
} catch {
data = null;
}
const uploadedCount = selectedCount;
setUrl("");
setAuthor("");
setTagsText("");
resetPreview();
setSuccess(`${uploadedCount}개 이미지 업로드를 요청했습니다.`);
const message = data?.message || `업로드 실패: ${response.status}`;
if (!response.ok || !data?.success) {
throw new Error(message);
}
const uploadedCount = data.savedCount ?? selectedCount;
if (uploadedCount > 0) {
setUrl("");
setAuthor("");
setTagsText("");
resetPreview();
}
setSuccess(message);
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : "업로드에 실패했습니다.");
} finally {
@ -351,7 +398,14 @@ export default function AddPage() {
</div>
{error ? <p className="text-sm text-red-600">{error}</p> : null}
{success ? <p className="text-sm text-emerald-600">{success}</p> : null}
{success ? <p className="text-sm text-emerald-600">{success} {existingDetailId ? (
<Link
href={`/detail/${existingDetailId}`}
className="underline"
>
</Link>
) : null}</p> : null}
{loadingPreview ? (
<div className="border-l-2 border-border/80 pl-3 text-xs text-foreground/65">

View file

@ -0,0 +1,225 @@
import Link from "next/link";
import { cookies, headers } from "next/headers";
import { notFound } from "next/navigation";
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[];
mediaUrl?: string;
mediaIndex?: number;
};
type Me = {
role: "admin" | "writer" | "reader";
};
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) {
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 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-[radial-gradient(circle_at_top,rgba(255,255,255,0.9),rgba(247,229,236,0.85)_45%,rgba(244,240,243,1)_100%)] text-foreground">
<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-[radial-gradient(circle_at_top,rgba(255,255,255,0.9),rgba(247,229,236,0.85)_45%,rgba(244,240,243,1)_100%)] text-foreground">
<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-white object-contain image-scale"
/>
) : (
<div className="aspect-4/5 w-full border border-border bg-black/5" />
)}
</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-black/5"
>
</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-black/5 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>
);
}

View file

@ -0,0 +1,15 @@
export default function NotFound() {
return (
<div className="flex flex-col items-center justify-center min-h-screen">
<figure>
<picture className="block bg-gray-100 rounded-xl aspect-1-1 overflow-hidden image-scale object-shadowed mb-8">
<img src="https://api.imnya.ng/mitda/mizuki" width={200} height={200} alt="Not Found Image" />
</picture>
</figure>
<h1 className="text-4xl font-bold">404</h1>
<p>. </p>
<br/>
<a href="/"> Go Home</a>
</div>
)
}

View file

@ -27,7 +27,6 @@ type GalleryPhoto = {
width: number;
height: number;
key: string;
href: string;
alt: string;
author: string;
};
@ -413,7 +412,6 @@ export default function App() {
width: image.naturalWidth,
height: image.naturalHeight,
key: upload._id,
href: upload.tweet.url,
alt: `tweet ${upload.tweetId} media ${upload.mediaIndex + 1}`,
author: upload.author?.trim() || "unknown",
});
@ -424,7 +422,6 @@ export default function App() {
width: 1,
height: 1,
key: upload._id,
href: upload.mediaUrl,
alt: `tweet ${upload.tweetId} media ${upload.mediaIndex + 1}`,
author: upload.author?.trim() || "unknown",
});
@ -499,7 +496,7 @@ export default function App() {
photo: ({ onClick }, { photo }) => (
<a
key={photo.key}
href={photo.href}
href={`/detail/${photo.key}`}
onClick={onClick}
onContextMenu={(event) => {
event.preventDefault();
@ -508,8 +505,6 @@ export default function App() {
const safeY = Math.min(event.clientY, window.innerHeight - 220);
setContextMenu({ x: Math.max(12, safeX), y: Math.max(12, safeY), photo });
}}
target="_blank"
rel="noopener noreferrer"
className="group relative block overflow-hidden image-scale"
title={`작가 ${photo.author}`}
>

View file

@ -0,0 +1,120 @@
"use client";
import { useEffect, useState } from "react";
import type { ReactNode } from "react";
type RawJsonPanelProps = {
data: unknown;
};
function renderJsonValue(value: unknown, indentLevel = 0): ReactNode {
const indent = " ".repeat(indentLevel);
if (value === null) {
return <span className="text-foreground/50">null</span>;
}
if (Array.isArray(value)) {
if (value.length === 0) {
return <span className="text-foreground/50">[]</span>;
}
return (
<>
<span className="text-foreground/70">[</span>
<span className="block">
{value.map((item, index) => (
<span key={index} className="block">
<span className="text-foreground/40">{indent} </span>
{renderJsonValue(item, indentLevel + 1)}
{index < value.length - 1 ? <span className="text-foreground/70">,</span> : null}
</span>
))}
</span>
<span className="text-foreground/70">{indent}]</span>
</>
);
}
if (typeof value === "object") {
const entries = Object.entries(value as Record<string, unknown>);
if (entries.length === 0) {
return <span className="text-foreground/50">{`{}`}</span>;
}
return (
<>
<span className="text-foreground/70">{`{`}</span>
<span className="block">
{entries.map(([key, childValue], index) => (
<span key={key} className="block">
<span className="text-foreground/40">{indent} </span>
<span className="text-sky-600">&quot;{key}&quot;</span>
<span className="text-foreground/70">: </span>
{renderJsonValue(childValue, indentLevel + 1)}
{index < entries.length - 1 ? <span className="text-foreground/70">,</span> : null}
</span>
))}
</span>
<span className="text-foreground/70">{indent}{"}"}</span>
</>
);
}
if (typeof value === "string") {
return <span className="text-emerald-700">&quot;{value}&quot;</span>;
}
if (typeof value === "number") {
return <span className="text-amber-700">{value}</span>;
}
if (typeof value === "boolean") {
return <span className="text-violet-700">{String(value)}</span>;
}
return <span className="text-foreground/60">{String(value)}</span>;
}
export default function DetailRawPanel({ data }: RawJsonPanelProps) {
const [isDeveloper, setIsDeveloper] = useState(false);
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
const syncDeveloperState = () => {
setIsDeveloper(window.localStorage.getItem("developer") === "true");
};
syncDeveloperState();
window.addEventListener("storage", syncDeveloperState);
window.addEventListener("focus", syncDeveloperState);
return () => {
window.removeEventListener("storage", syncDeveloperState);
window.removeEventListener("focus", syncDeveloperState);
};
}, []);
if (!isDeveloper) {
return null;
}
return (
<div className="mt-6 border border-border bg-background/80 p-4">
<button
type="button"
className="flex w-full items-center justify-between gap-3 text-left"
onClick={() => setIsOpen((current) => !current)}
>
<p className="text-xs uppercase tracking-[0.3em] text-foreground/40">Raw</p>
<span className="text-xs text-foreground/50">{isOpen ? "o" : "x"}</span>
</button>
{isOpen ? (
<pre className="mt-3 overflow-auto border border-border/70 bg-black/5 p-3 text-xs leading-5 text-foreground/80">
{renderJsonValue(data)}
</pre>
) : null}
</div>
);
}

View file

@ -1,5 +1,6 @@
"use client";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
type Me = {
@ -27,6 +28,53 @@ export default function Header() {
const [me, setMe] = useState<Me | null>(null);
const [menuOpen, setMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement | null>(null);
const developerClickTimeoutRef = useRef<number | null>(null);
function handleIconClick() {
if (typeof window === "undefined") {
return;
}
const clickCountKey = "developer_icon_click_count";
const lastClickAtKey = "developer_icon_click_last_at";
const developerKey = "developer";
const now = Date.now();
const lastClickAt = Number(window.localStorage.getItem(lastClickAtKey) ?? "0");
const previousCount = Number(window.localStorage.getItem(clickCountKey) ?? "0");
const nextCount = now - lastClickAt > 1500 ? 1 : previousCount + 1;
window.localStorage.setItem(clickCountKey, String(nextCount));
window.localStorage.setItem(lastClickAtKey, String(now));
if (developerClickTimeoutRef.current !== null) {
window.clearTimeout(developerClickTimeoutRef.current);
}
developerClickTimeoutRef.current = window.setTimeout(() => {
window.localStorage.removeItem(clickCountKey);
window.localStorage.removeItem(lastClickAtKey);
developerClickTimeoutRef.current = null;
}, 1500);
if (nextCount >= 10) {
window.localStorage.setItem(developerKey, "true");
window.localStorage.removeItem(clickCountKey);
window.localStorage.removeItem(lastClickAtKey);
if (developerClickTimeoutRef.current !== null) {
window.clearTimeout(developerClickTimeoutRef.current);
developerClickTimeoutRef.current = null;
}
}
}
useEffect(() => {
return () => {
if (developerClickTimeoutRef.current !== null) {
window.clearTimeout(developerClickTimeoutRef.current);
}
};
}, []);
useEffect(() => {
let active = true;
@ -91,11 +139,11 @@ export default function Header() {
return (
<header className="relative z-60 flex h-16 items-center justify-between border-b border-border bg-background/90 backdrop-blur px-6">
<a href="/" className="text-2xl">🎀</a>
<Link href="/" className="text-2xl" id="icon" onClick={handleIconClick}>🎀</Link>
{me ? (
<div className="relative flex items-center gap-4" id="menu" ref={menuRef}>
{me.role === "admin" || me.role === "writer" ? (
<a href="/add" className="text-[16px] text-foreground/50">[ <span className="text-foreground">+</span> ]</a>
<Link href="/add" className="text-[16px] text-foreground/50">[ <span className="text-foreground">+</span> ]</Link>
) : null}
<button