import { useEffect, useRef, useState } from "react"; import { Link } from "react-router"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { API_URL, VALIDATION } from "../config/api.ts"; import type { CreateUrlDumpRequest, Dump, PlaylistMembership, RawDump, RawPlaylistMembership, } from "../model.ts"; import { deserializeDump, deserializePlaylistMembership, parseAPIResponse, } from "../model.ts"; import { useAuth } from "../hooks/useAuth.ts"; import { useWS } from "../hooks/useWS.ts"; import { dumpUrl, normalizeUrl } from "../utils/urls.ts"; import { MAX_FILE_SIZE } from "../config/upload.ts"; import RichContentCard from "./RichContentCard.tsx"; import { MediaPlayer } from "./MediaPlayer.tsx"; import type { RichContent } from "../model.ts"; import { ErrorCard } from "./ErrorCard.tsx"; import { FileDropZone } from "./FileDropZone.tsx"; import { TextEditor } from "./TextEditor.tsx"; import { Modal } from "./Modal.tsx"; import { PlaylistMembershipPanel } from "./PlaylistMembershipPanel.tsx"; import { friendlyFetchError } from "../utils/apiError.ts"; type Mode = "url" | "file"; type Phase = "create" | "playlist"; type SubmitState = | { status: "idle" } | { status: "submitting" } | { status: "error"; error: string }; type UrlPreview = | { status: "idle" } | { status: "loading" } | { status: "done"; richContent: RichContent | null }; function LocalFilePreview({ file }: { file: File }) { const [src, setSrc] = useState(null); useEffect(() => { const url = URL.createObjectURL(file); // Blob URL lifecycle requires setState in effect — no synchronous alternative. // eslint-disable-next-line react-hooks/set-state-in-effect setSrc(url); return () => { URL.revokeObjectURL(url); }; }, [file]); if (!src) return null; const mime = file.type; if (mime.startsWith("image/")) { return {file.name}; } if (mime.startsWith("video/")) { return ; } if (mime.startsWith("audio/")) { return ; } return null; } interface DumpCreateModalProps { onClose: () => void; initialUrl?: string; } export function DumpCreateModal( { onClose, initialUrl = "" }: DumpCreateModalProps, ) { const { authFetch } = useAuth(); const { injectDump } = useWS(); const [phase, setPhase] = useState("create"); const [createdDump, setCreatedDump] = useState(null); // Create phase state const [mode, setMode] = useState("url"); const [url, setUrl] = useState(initialUrl); const [file, setFile] = useState(null); const [comment, setComment] = useState(""); const [isPrivate, setIsPrivate] = useState(false); const [submitState, setSubmitState] = useState({ status: "idle", }); const [urlPreview, setUrlPreview] = useState({ status: "idle" }); const debounceRef = useRef | null>(null); // Playlist phase state const [memberships, setMemberships] = useState([]); const [playlistsLoading, setPlaylistsLoading] = useState(false); // Debounced URL preview useEffect(() => { if (debounceRef.current) clearTimeout(debounceRef.current); let trimmed: string; try { const u = new URL(normalizeUrl(url)); if (u.protocol !== "http:" && u.protocol !== "https:") throw new Error(); trimmed = u.toString(); } catch { setUrlPreview({ status: "idle" }); return; } setUrlPreview({ status: "loading" }); debounceRef.current = setTimeout(async () => { try { const res = await fetch( `${API_URL}/api/preview?url=${encodeURIComponent(trimmed)}`, ); const body = await res.json(); setUrlPreview({ status: "done", richContent: body.success ? body.data : null, }); } catch { setUrlPreview({ status: "done", richContent: null }); } }, 600); return () => { if (debounceRef.current) clearTimeout(debounceRef.current); }; }, [url]); // Paste handler useEffect(() => { const handler = (e: ClipboardEvent) => { const pastedFile = e.clipboardData?.files[0]; if (pastedFile) { setMode("file"); setUrl(""); setUrlPreview({ status: "idle" }); setFile(pastedFile); setSubmitState({ status: "idle" }); return; } const tag = (e.target as HTMLElement).tagName; if (tag === "INPUT" || tag === "TEXTAREA") return; const text = e.clipboardData?.getData("text") ?? ""; try { const u = new URL(normalizeUrl(text)); if (u.protocol === "http:" || u.protocol === "https:") { setMode("url"); setFile(null); setUrl(u.toString()); setSubmitState({ status: "idle" }); } } catch { /* not a URL */ } }; globalThis.addEventListener("paste", handler); return () => globalThis.removeEventListener("paste", handler); }, []); const handleSubmit = async (e: React.SubmitEvent) => { e.preventDefault(); if (comment.length > VALIDATION.DUMP_COMMENT_MAX) return; setSubmitState({ status: "submitting" }); try { let res: Response; if (mode === "url") { const normalizedUrl = normalizeUrl(url); if (!normalizedUrl) { setSubmitState({ status: "error", error: t`URL is required.` }); return; } setUrl(normalizedUrl); const body: CreateUrlDumpRequest = { url: normalizedUrl, comment: comment.trim() || undefined, isPrivate, }; res = await authFetch(`${API_URL}/api/dumps`, { method: "POST", body: JSON.stringify(body), }); } else { if (!file) { setSubmitState({ status: "error", error: t`Please select a file.`, }); return; } if (file.size > MAX_FILE_SIZE) { setSubmitState({ status: "error", error: t`File too large (max 50 MB).`, }); return; } const formData = new FormData(); formData.append("file", file); if (comment.trim()) formData.append("comment", comment.trim()); formData.append("isPrivate", String(isPrivate)); res = await authFetch(`${API_URL}/api/dumps`, { method: "POST", body: formData, }); } const apiResponse = parseAPIResponse(await res.json()); if (apiResponse.success) { const dump = deserializeDump(apiResponse.data); injectDump(dump); setCreatedDump(dump); setPhase("playlist"); setPlaylistsLoading(true); authFetch(`${API_URL}/api/playlists/by-dump/${dump.id}/memberships`) .then((r) => r.json()) .then((body) => { if (body.success) { setMemberships( (body.data as RawPlaylistMembership[]).map( deserializePlaylistMembership, ), ); } }) .catch(() => {}) .finally(() => setPlaylistsLoading(false)); } else { setSubmitState({ status: "error", error: apiResponse.error.message, }); } } catch (err) { setSubmitState({ status: "error", error: friendlyFetchError(err) }); } }; const toggleMembership = async (membership: PlaylistMembership) => { if (!createdDump) return; const { playlist, hasDump } = membership; if (hasDump) { await authFetch( `${API_URL}/api/playlists/${playlist.id}/dumps/${createdDump.id}`, { method: "DELETE" }, ); setMemberships((prev) => prev.map((m) => m.playlist.id === playlist.id ? { ...m, hasDump: false } : m ) ); } else { await authFetch( `${API_URL}/api/playlists/${playlist.id}/dumps/${createdDump.id}`, { method: "POST" }, ); setMemberships((prev) => prev.map((m) => m.playlist.id === playlist.id ? { ...m, hasDump: true } : m ) ); } }; const submitting = submitState.status === "submitting"; return ( {phase === "create" ? ( <>
{submitState.status === "error" && ( )} {mode === "url" ? ( <>
setUrl(e.target.value)} onBlur={(e) => setUrl(normalizeUrl(e.target.value))} onPaste={(e) => { const pastedFile = e.clipboardData.files[0]; if (pastedFile) { e.preventDefault(); setMode("file"); setUrl(""); setUrlPreview({ status: "idle" }); setFile(pastedFile); setSubmitState({ status: "idle" }); } }} disabled={submitting} placeholder="https://..." required autoFocus />
{urlPreview.status === "loading" && (

Fetching preview…

)} {urlPreview.status === "done" && urlPreview.richContent && ( )} ) : ( <> {file && } )}
) : ( <> {createdDump && (

Dumped!{" "} View dump →

)} setMemberships((prev) => [membership, ...prev])} />
)}
); }