522 lines
22 KiB
TypeScript
522 lines
22 KiB
TypeScript
"use client";
|
||
|
||
import { FormEvent, useEffect, useMemo, useState } from "react";
|
||
import Link from "next/link";
|
||
import { useRouter } from "next/navigation";
|
||
import Header from "../../components/header";
|
||
|
||
type SourceType = "twitter" | "pixiv";
|
||
|
||
type PreviewItem = {
|
||
url: string;
|
||
type?: "image" | "video";
|
||
};
|
||
|
||
type TweetApiResponse = {
|
||
tweet?: {
|
||
author?: { name?: string };
|
||
media?: {
|
||
photos?: Array<{ url?: string }>;
|
||
all?: Array<{
|
||
url?: string;
|
||
thumbnail_url?: string;
|
||
type?: "photo" | "video" | "gif" | null | undefined;
|
||
}>;
|
||
};
|
||
};
|
||
};
|
||
|
||
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;
|
||
ids?: string[];
|
||
};
|
||
|
||
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 router = useRouter();
|
||
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 mediaItems = data.tweet?.media?.all || data.tweet?.media?.photos || [];
|
||
const items: PreviewItem[] = mediaItems
|
||
.map((m) => ({
|
||
url: (m as any).thumbnail_url || (m as any).url || "",
|
||
type: (((m as any).type === "video" || (m as any).type === "gif" || ((m as any).format && (m as any).format.includes("video"))) ? "video" : "image") as "image" | "video"
|
||
}))
|
||
.filter((item) => item.url.length > 0);
|
||
|
||
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);
|
||
|
||
if (data?.ids && data.ids.length > 0) {
|
||
router.push(`/detail/${data.ids[0]}`);
|
||
}
|
||
} 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"}`}
|
||
/>
|
||
{item.type === "video" && (
|
||
<div className="absolute inset-0 flex items-center justify-center bg-black/10">
|
||
<svg className="h-10 w-10 text-white/80 drop-shadow-md" fill="currentColor" viewBox="0 0 20 20">
|
||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clipRule="evenodd" />
|
||
</svg>
|
||
</div>
|
||
)}
|
||
<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>
|
||
);
|
||
}
|