470 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|