"use client"; import { FormEvent, useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import Header from "../../components/header"; import { proxyMediaUrl } from "../../lib/media"; type SourceType = "twitter" | "pixiv" | "direct"; type PreviewItem = { url: string; type?: "image" | "video"; file?: File; }; 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([]); const [selected, setSelected] = useState([]); const [sourceType, setSourceType] = useState(null); const [loadingPreview, setLoadingPreview] = useState(false); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const [success, setSuccess] = useState(null); const [lastFetchedUrl, setLastFetchedUrl] = useState(""); const [viewerRole, setViewerRole] = useState("guest"); const [loadingRole, setLoadingRole] = useState(true); const [existingDetailId, setExistingDetailId] = useState(null); const [uploadMode, setUploadMode] = useState<"url" | "direct">("url"); const fileInputRef = useRef(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() { previewItems.forEach((item) => { if (item.file) URL.revokeObjectURL(item.url); }); setPreviewItems([]); setSelected([]); setSourceType(null); setLastFetchedUrl(""); setError(null); setSuccess(null); setExistingDetailId(null); if (fileInputRef.current) fileInputRef.current.value = ""; } 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: proxyMediaUrl((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); } } function handleFileChange(e: React.ChangeEvent) { const files = Array.from(e.target.files || []); if (files.length === 0) return; setSourceType("direct"); const newItems: PreviewItem[] = files.map((file) => ({ url: URL.createObjectURL(file), type: file.type.startsWith("video/") ? "video" : "image", file, })); setPreviewItems((prev) => [...prev, ...newItems]); setSelected((prev) => [...prev, ...newItems.map(() => true)]); } 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) { 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); let response: Response; if (uploadMode === "direct") { const formData = new FormData(); previewItems.forEach((item, index) => { if (selected[index] && item.file) { formData.append("files", item.file); } }); if (author.trim()) formData.append("author", author.trim()); tags.forEach((t) => formData.append("tag", t)); response = await fetch("/api/post/upload/direct", { method: "POST", body: formData, }); } else { 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 (

Add Post

μ•„λž˜μ™€ 같은 μœ ν˜•μ˜ URL을 μ§€μ›ν•΄μš”!

  • - https://x.com, https://twitter.com (기타 FxEmbed URL)
  • - https://pixiv.net (#R-18 νƒœκ·Έκ°€ λ“€μ–΄κ°ˆ μ‹œ κ±°λΆ€)

Source: {sourceLabel}

Selected: {selectedCount}/{previewItems.length}

{!loadingRole && !canManagePost ? (
μ—…λ‘œλ“œ κΆŒν•œμ΄ μ—†μŠ΅λ‹ˆλ‹€. writer λ˜λŠ” admin κΆŒν•œμ΄ ν•„μš”ν•©λ‹ˆλ‹€.
) : null}
{uploadMode === "url" ? (
{ 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={uploadMode === "url"} />

URL λ³€κ²½ μ‹œ κΈ°μ‘΄ λ―Έλ¦¬λ³΄κΈ°λŠ” μžλ™μœΌλ‘œ μ΄ˆκΈ°ν™”λ©λ‹ˆλ‹€.

) : (
fileInputRef.current?.click()} >

ν΄λ¦­ν•˜μ—¬ μ΄λ―Έμ§€λ‚˜ μ˜μƒμ„ μ„ νƒν•˜μ„Έμš”

μ—¬λŸ¬ νŒŒμΌμ„ λ™μ‹œμ— 선택할 수 μžˆμŠ΅λ‹ˆλ‹€.

)}
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" />

* μžλ™μœΌλ‘œ μž‘κ°€λ₯Ό κ°€μ Έμ˜΅λ‹ˆλ‹€. λ§Œμ•½ 잘λͺ»λœ 이름이라면 μˆ˜λ™μœΌλ‘œ μˆ˜μ •ν•  수 μžˆμŠ΅λ‹ˆλ‹€.

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" />

* μžλ™μœΌλ‘œ νƒœκ·Έλ₯Ό κ°€μ Έμ˜΅λ‹ˆλ‹€. μ‰Όν‘œ(,)λ‚˜ μ€„λ°”κΏˆμœΌλ‘œ κ΅¬λΆ„ν•˜μ—¬ μ—¬λŸ¬ 개 μž…λ ₯ν•  수 μžˆμŠ΅λ‹ˆλ‹€.

{error ?

{error}

: null} {success ?

{success} {existingDetailId ? ( 상세 νŽ˜μ΄μ§€λ‘œ 이동 ) : null}

: null} {loadingPreview ? (
미리보기λ₯Ό λΆˆλŸ¬μ˜€λŠ” μ€‘μž…λ‹ˆλ‹€. 이미지 κ°œμˆ˜μ— 따라 λͺ‡ 초 정도 μ†Œμš”λ  수 μžˆμŠ΅λ‹ˆλ‹€.
) : null}
source: {sourceLabel} selected: {selectedCount}/{previewItems.length} tags: {tags.length}
{previewItems.length > 0 ? (

Preview

{selectedCount}μž₯ 선택됨
{previewItems.map((item, index) => ( ))}
) : null}
); }