Files
gerbeur/src/components/DumpCreateModal.tsx
2026-04-06 16:30:00 +00:00

470 lines
15 KiB
TypeScript

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<string | null>(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 <img src={src} alt={file.name} className="local-preview-image" />;
}
if (mime.startsWith("video/")) {
return <MediaPlayer key={src} src={src} kind="video" mime={mime} />;
}
if (mime.startsWith("audio/")) {
return <MediaPlayer key={src} src={src} kind="audio" mime={mime} />;
}
return null;
}
interface DumpCreateModalProps {
onClose: () => void;
initialUrl?: string;
}
export function DumpCreateModal(
{ onClose, initialUrl = "" }: DumpCreateModalProps,
) {
const { authFetch } = useAuth();
const { injectDump } = useWS();
const [phase, setPhase] = useState<Phase>("create");
const [createdDump, setCreatedDump] = useState<Dump | null>(null);
// Create phase state
const [mode, setMode] = useState<Mode>("url");
const [url, setUrl] = useState(initialUrl);
const [file, setFile] = useState<File | null>(null);
const [comment, setComment] = useState("");
const [isPrivate, setIsPrivate] = useState(false);
const [submitState, setSubmitState] = useState<SubmitState>({
status: "idle",
});
const [urlPreview, setUrlPreview] = useState<UrlPreview>({ status: "idle" });
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Playlist phase state
const [memberships, setMemberships] = useState<PlaylistMembership[]>([]);
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<HTMLFormElement>) => {
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<RawDump>(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 (
<Modal
title={phase === "create" ? t`New dump` : t`Add to playlist`}
onClose={onClose}
wide
>
{phase === "create"
? (
<>
<div className="visibility-toggle">
<button
type="button"
className={mode === "url" ? "active" : ""}
onClick={() => {
setMode("url");
setFile(null);
setSubmitState({ status: "idle" });
}}
disabled={submitting}
>
🔗 URL
</button>
<button
type="button"
className={mode === "file" ? "active" : ""}
onClick={() => {
setMode("file");
setUrl("");
setUrlPreview({ status: "idle" });
setSubmitState({ status: "idle" });
}}
disabled={submitting}
>
📎 <Trans>File</Trans>
</button>
</div>
<form onSubmit={handleSubmit} className="dump-form">
{submitState.status === "error" && (
<ErrorCard
title={t`Failed to post`}
message={submitState.error}
/>
)}
{mode === "url"
? (
<>
<div className="form-group">
<label htmlFor="dc-url">
<Trans>URL</Trans>
</label>
<input
id="dc-url"
type="url"
value={url}
onChange={(e) => 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
/>
</div>
{urlPreview.status === "loading" && (
<p className="preview-loading">
<Trans>Fetching preview</Trans>
</p>
)}
{urlPreview.status === "done" &&
urlPreview.richContent && (
<RichContentCard
richContent={urlPreview.richContent}
/>
)}
</>
)
: (
<>
<FileDropZone
file={file}
onChange={setFile}
disabled={submitting}
/>
{file && <LocalFilePreview file={file} />}
</>
)}
<div className="form-group">
<label htmlFor="dc-comment">
<Trans>Why are you dumping this?</Trans>
</label>
<TextEditor
id="dc-comment"
value={comment}
onChange={setComment}
disabled={submitting}
placeholder={t`Tell the community what makes this worth their time...`}
rows={3}
maxLength={VALIDATION.DUMP_COMMENT_MAX}
/>
</div>
<div className="visibility-toggle">
<button
type="button"
className={!isPrivate ? "active" : ""}
disabled={submitting}
onClick={() => setIsPrivate(false)}
>
<Trans>Public</Trans>
</button>
<button
type="button"
className={isPrivate ? "active" : ""}
disabled={submitting}
onClick={() => setIsPrivate(true)}
>
<Trans>Private</Trans>
</button>
</div>
<div className="form-actions">
<div className="form-actions-right">
<button
type="button"
className="form-cancel"
onClick={onClose}
>
<Trans>Cancel</Trans>
</button>
<button
type="submit"
className="btn-primary"
disabled={submitting ||
comment.length > VALIDATION.DUMP_COMMENT_MAX}
>
{submitting
? (mode === "url"
? <Trans>Fetching</Trans>
: <Trans>Uploading</Trans>)
: <Trans>Dump it</Trans>}
</button>
</div>
</div>
</form>
</>
)
: (
<>
{createdDump && (
<p className="dump-create-success">
<Trans>Dumped!</Trans>{" "}
<Link to={dumpUrl(createdDump)} onClick={onClose}>
<Trans>View dump </Trans>
</Link>
</p>
)}
<PlaylistMembershipPanel
dumpId={createdDump?.id ?? ""}
memberships={memberships}
loading={playlistsLoading}
onToggle={toggleMembership}
onPlaylistCreated={(membership) =>
setMemberships((prev) => [membership, ...prev])}
/>
<div className="form-actions">
<div className="form-actions-right">
<button
type="button"
className="btn-primary"
onClick={onClose}
>
<Trans>Done</Trans>
</button>
</div>
</div>
</>
)}
</Modal>
);
}