akiyama.mizuki.guru/apps/frontend/src/app/add/page.tsx

497 lines
21 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.

"use client";
import { FormEvent, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import Header from "../../components/header";
type SourceType = "twitter" | "pixiv";
type PreviewItem = {
url: string;
};
type TweetApiResponse = {
tweet?: {
author?: { name?: string };
media?: { photos?: Array<{ url?: string }> };
};
};
type PixivApiResponse = {
image_proxy_urls?: string[];
author_name?: string;
tags?: string[];
};
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";
}
if (/^https?:\/\/(www\.)?pixiv\.net\//.test(url)) {
return "pixiv";
}
return null;
}
function splitTags(text: string) {
return text
.split(/[\n,]/)
.map((tag) => tag.trim().replace(/^#/, ""))
.filter((tag) => tag.length > 0);
}
export default function AddPage() {
const [url, setUrl] = useState("");
const [author, setAuthor] = useState("");
const [tagsText, setTagsText] = useState("");
const [previewItems, setPreviewItems] = useState<PreviewItem[]>([]);
const [selected, setSelected] = useState<boolean[]>([]);
const [sourceType, setSourceType] = useState<SourceType | null>(null);
const [loadingPreview, setLoadingPreview] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
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,
[selected],
);
const canPreview = url.trim().length > 0 && !loadingPreview;
const canSubmit = !submitting && previewItems.length > 0 && selectedCount > 0;
const tags = useMemo(() => splitTags(tagsText), [tagsText]);
const sourceLabel = sourceType === "twitter" ? "𝕏" : sourceType === "pixiv" ? "Pixiv" : "-";
const canManagePost = viewerRole === "admin" || viewerRole === "writer";
useEffect(() => {
let active = true;
async function loadMe() {
try {
const response = await fetch("/api/auth/me", { cache: "no-store" });
if (!response.ok) {
if (active) setViewerRole("guest");
return;
}
const me = (await response.json()) as Me;
if (active) {
setViewerRole(me.role);
}
} catch {
if (active) {
setViewerRole("guest");
}
} finally {
if (active) {
setLoadingRole(false);
}
}
}
void loadMe();
return () => {
active = false;
};
}, []);
function resetPreview() {
setPreviewItems([]);
setSelected([]);
setSourceType(null);
setLastFetchedUrl("");
setError(null);
setSuccess(null);
setExistingDetailId(null);
}
async function fetchPreview(targetUrl?: string) {
const trimmedUrl = (targetUrl ?? url).trim();
const source = detectSource(trimmedUrl);
setError(null);
setSuccess(null);
if (!source) {
setError("지원하지 않는 URL 형식입니다. Twitter(X) 또는 Pixiv URL을 입력해 주세요.");
return;
}
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",
});
if (!response.ok) {
throw new Error(`트위터 미리보기 요청 실패: ${response.status}`);
}
const data = (await response.json()) as TweetApiResponse;
const items = (data.tweet?.media?.photos ?? [])
.map((photo) => photo.url)
.filter((photoUrl): photoUrl is string => typeof photoUrl === "string" && photoUrl.length > 0)
.map((photoUrl) => ({ url: photoUrl }));
if (items.length === 0) {
throw new Error("이미지를 찾지 못했습니다.");
}
setPreviewItems(items);
setSelected(items.map(() => true));
if (!author.trim()) {
setAuthor(data.tweet?.author?.name ?? "");
}
setLastFetchedUrl(trimmedUrl);
return;
}
const response = await fetch(`/api/pixiv/fetch?url=${encodeURIComponent(trimmedUrl)}`, {
cache: "no-store",
});
if (!response.ok) {
throw new Error(`픽시브 미리보기 요청 실패: ${response.status}`);
}
const data = (await response.json()) as PixivApiResponse;
const items = (data.image_proxy_urls ?? [])
.filter((imageUrl): imageUrl is string => typeof imageUrl === "string" && imageUrl.length > 0)
.map((imageUrl) => ({ url: imageUrl }));
if (items.length === 0) {
throw new Error("이미지를 찾지 못했습니다.");
}
setPreviewItems(items);
setSelected(items.map(() => true));
if (!author.trim()) {
setAuthor(data.author_name ?? "");
}
if (!tagsText.trim()) {
const pixivTags = (data.tags ?? []).map((tag) => tag.replace(/^#/, "")).join(", ");
setTagsText(pixivTags);
}
setLastFetchedUrl(trimmedUrl);
} catch (fetchError) {
setPreviewItems([]);
setSelected([]);
setLastFetchedUrl("");
setError(fetchError instanceof Error ? fetchError.message : "미리보기를 불러오지 못했습니다.");
} finally {
setLoadingPreview(false);
}
}
useEffect(() => {
if (loadingPreview) {
return;
}
const trimmed = url.trim();
if (!trimmed || trimmed === lastFetchedUrl) {
return;
}
if (!detectSource(trimmed)) {
return;
}
const timer = window.setTimeout(() => {
void fetchPreview(trimmed);
}, 450);
return () => window.clearTimeout(timer);
}, [lastFetchedUrl, loadingPreview, url]);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
setSuccess(null);
if (!canManagePost) {
setError("writer 또는 admin 권한이 필요합니다.");
return;
}
if (!sourceType) {
setError("먼저 미리보기를 불러와 주세요.");
return;
}
if (previewItems.length === 0) {
setError("업로드할 이미지가 없습니다.");
return;
}
if (selectedCount === 0) {
setError("최소 한 장 이상 선택해 주세요.");
return;
}
setSubmitting(true);
try {
const tags = splitTags(tagsText);
const response = await fetch("/api/post/upload", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
url: url.trim(),
author: author.trim() || undefined,
tag: tags.length > 0 ? tags : undefined,
selected,
}),
});
let data: UploadApiResponse | null = null;
try {
data = (await response.json()) as UploadApiResponse;
} catch {
data = null;
}
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 {
setSubmitting(false);
}
}
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">Add Post</h1>
<p className="mt-1 text-sm text-foreground/50"> URL을 !</p>
<ul className="text-sm text-foreground/50">
<li> - https://x.com, https://twitter.com (기타 FxEmbed URL)</li>
<li> - https://pixiv.net (#R-18 태그가 들어갈 시 거부)</li>
</ul>
</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">Selected: <span className="font-medium text-foreground/90">{selectedCount}/{previewItems.length}</span></p>
</div>
</div>
<form className="mt-5 space-y-4" onSubmit={submit}>
{!loadingRole && !canManagePost ? (
<div className="border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
. writer admin .
</div>
) : null}
<div className="space-y-2">
<label className="block text-sm text-foreground/80" htmlFor="url">URL</label>
<div className="flex flex-col gap-2 sm:flex-row">
<input
id="url"
type="url"
value={url}
onChange={(event) => {
setUrl(event.target.value);
if (previewItems.length > 0 || sourceType) {
resetPreview();
}
}}
placeholder="https://x.com/... or https://www.pixiv.net/artworks/..."
className="w-full border-b border-border bg-transparent px-2 py-2 text-sm outline-none ring-0 transition focus:border-foreground/40"
disabled={loadingPreview}
required
/>
</div>
<p className="text-xs text-foreground/55">URL .</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<label className="block text-sm text-foreground/80" htmlFor="author"></label>
<input
id="author"
type="text"
value={author}
onChange={(event) => setAuthor(event.target.value)}
className="w-full border-b border-border bg-transparent px-2 py-2 text-sm outline-none transition focus:border-foreground/40"
/>
<p className="text-xs text-red-600/50">* . .</p>
</div>
<div className="space-y-2">
<label className="block text-sm text-foreground/80" htmlFor="tags"></label>
<input
id="tags"
type="text"
value={tagsText}
onChange={(event) => setTagsText(event.target.value)}
placeholder=""
className="w-full border-b border-border bg-transparent px-2 py-2 text-sm outline-none transition focus:border-foreground/40"
/>
<p className="text-xs text-foreground/50">* . (,) .</p>
</div>
</div>
{error ? <p className="text-sm text-red-600">{error}</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">
. .
</div>
) : null}
<div className="flex flex-wrap items-center gap-2 text-xs text-foreground/60">
<span>source: {sourceLabel}</span>
<span>selected: {selectedCount}/{previewItems.length}</span>
<span>tags: {tags.length}</span>
</div>
{previewItems.length > 0 ? (
<section className="mt-8 border-t border-border/70 pt-5">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-sm tracking-wide text-foreground/80">Preview</h2>
<div className="flex items-center gap-2 text-xs text-foreground/60">
<span>{selectedCount} </span>
<button
type="button"
className="border border-border px-2 py-1 text-xs text-foreground/70 hover:bg-black/5"
onClick={() => setSelected(previewItems.map(() => true))}
>
</button>
<button
type="button"
className="border border-border px-2 py-1 text-xs text-foreground/70 hover:bg-black/5"
onClick={() => setSelected(previewItems.map(() => false))}
>
</button>
</div>
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{previewItems.map((item, index) => (
<label
key={`${item.url}-${index}`}
className={`group relative block cursor-pointer overflow-hidden border bg-white/20 transition ${selected[index] ? "border-foreground/35" : "border-border"}`}
>
<img
src={item.url}
alt={`preview ${index + 1}`}
loading="lazy"
decoding="async"
className={`aspect-4/5 w-full object-cover transition ${selected[index] ? "opacity-100" : "opacity-35 grayscale-20"}`}
/>
<div className="absolute left-2 top-2 border border-border/60 bg-background/80 px-2 py-1 text-xs backdrop-blur">
<input
type="checkbox"
checked={selected[index] ?? false}
onChange={() => {
setSelected((current) => current.map((value, i) => (i === index ? !value : value)));
}}
/>
</div>
{selected[index] ? (
<div className="pointer-events-none absolute right-2 top-2 border border-foreground bg-foreground px-2 py-0.5 text-[11px] text-background">
selected
</div>
) : null}
<div className="pointer-events-none absolute inset-x-2 bottom-2 truncate border border-white/20 bg-black/65 px-2 py-1 text-xs text-white opacity-0 transition group-hover:opacity-100">
#{index + 1}
</div>
</label>
))}
</div>
</section>
) : null}
<div className="flex flex-wrap items-center justify-end gap-2">
<button
type="submit"
disabled={!canSubmit || !canManagePost || loadingRole}
className="border border-border bg-foreground px-4 py-2 text-sm text-background transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
>
{submitting ? "업로드 중..." : "업로드"}
</button>
</div>
</form>
</section>
</main>
</div>
);
}