This commit is contained in:
암냥 2026-04-16 00:07:00 +09:00
commit 5207f5d431
No known key found for this signature in database
25 changed files with 2932 additions and 332 deletions

View file

@ -0,0 +1,438 @@
"use client";
import { FormEvent, useEffect, useMemo, useState } from "react";
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";
};
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 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);
}
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);
try {
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,
}),
});
if (!response.ok) {
const responseText = await response.text();
throw new Error(responseText || `업로드 실패: ${response.status}`);
}
setSuccess(`${selectedCount}개 이미지 업로드를 요청했습니다.`);
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : "업로드에 실패했습니다.");
} finally {
setSubmitting(false);
}
}
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">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}</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>
);
}