v3: editor fixes

This commit is contained in:
khannurien
2026-03-23 17:57:25 +00:00
parent b96879a556
commit cd4076343b
5 changed files with 54 additions and 320 deletions

View File

@@ -1,302 +0,0 @@
import { useEffect, useRef, useState } from "react";
import type { SubmitEvent } from "react";
import { Link, useNavigate } from "react-router";
import { API_URL } from "../config/api.ts";
import type { CreateUrlDumpRequest, RichContent } from "../model.ts";
import { useRequiredAuth } from "../hooks/useAuth.ts";
import { dumpUrl } from "../utils/urls.ts";
import { PageShell } from "../components/PageShell.tsx";
import RichContentCard from "../components/RichContentCard.tsx";
import { MediaPlayer } from "../components/MediaPlayer.tsx";
import { TextEditor } from "../components/TextEditor.tsx";
import { ErrorCard } from "../components/ErrorCard.tsx";
import { FileDropZone } from "../components/FileDropZone.tsx";
import { friendlyFetchError } from "../utils/apiError.ts";
import { MAX_FILE_SIZE } from "../config/upload.ts";
type Mode = "url" | "file";
type DumpCreateState =
| { 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);
const mime = file.type;
useEffect(() => {
const url = URL.createObjectURL(file);
setSrc(url);
return () => URL.revokeObjectURL(url);
}, [file]);
if (!src) return null;
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} />;
}
// For other types the drop zone chip already shows name + size.
return null;
}
export function DumpCreate() {
const navigate = useNavigate();
const { authFetch } = useRequiredAuth();
const [mode, setMode] = useState<Mode>("url");
const [url, setUrl] = useState("");
const [file, setFile] = useState<File | null>(null);
const [comment, setComment] = useState("");
const [state, setState] = useState<DumpCreateState>({ status: "idle" });
const [urlPreview, setUrlPreview] = useState<UrlPreview>({ status: "idle" });
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Debounced URL preview fetch
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
let trimmed: string;
try {
const u = new URL(url.trim());
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]);
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
setState({ status: "submitting" });
try {
let res: Response;
if (mode === "url") {
if (!url.trim()) {
setState({ status: "error", error: "URL is required." });
return;
}
const body: CreateUrlDumpRequest = {
url: url.trim(),
comment: comment.trim() || undefined,
};
res = await authFetch(`${API_URL}/api/dumps`, {
method: "POST",
body: JSON.stringify(body),
});
} else {
if (!file) {
setState({ status: "error", error: "Please select a file." });
return;
}
if (file.size > MAX_FILE_SIZE) {
setState({ status: "error", error: "File too large (max 50 MB)." });
return;
}
const formData = new FormData();
formData.append("file", file);
if (comment.trim()) formData.append("comment", comment.trim());
res = await authFetch(`${API_URL}/api/dumps`, {
method: "POST",
body: formData,
});
}
const apiResponse = await res.json();
if (apiResponse.success) {
navigate(dumpUrl(apiResponse.data));
} else {
setState({
status: "error",
error: apiResponse.error?.message ?? "Failed to create dump.",
});
}
} catch (err) {
setState({ status: "error", error: friendlyFetchError(err) });
}
};
const submitting = state.status === "submitting";
useEffect(() => {
const handler = (e: ClipboardEvent) => {
const pastedFile = e.clipboardData?.files[0];
if (pastedFile) {
setMode("file");
setUrl("");
setUrlPreview({ status: "idle" });
setFile(pastedFile);
setState({ status: "idle" });
return;
}
// Only intercept text pastes when outside an input/textarea
const tag = (e.target as HTMLElement).tagName;
if (tag === "INPUT" || tag === "TEXTAREA") return;
const text = e.clipboardData?.getData("text") ?? "";
try {
const u = new URL(text.trim());
if (u.protocol === "http:" || u.protocol === "https:") {
setMode("url");
setFile(null);
setUrl(text.trim());
setState({ status: "idle" });
}
} catch { /* not a URL */ }
};
globalThis.addEventListener("paste", handler);
return () => globalThis.removeEventListener("paste", handler);
}, []);
return (
<PageShell centered>
<div className="dump-create-wrapper">
<div className="dump-create-header">
<h1 className="dump-create-title">New dump</h1>
<div className="visibility-toggle">
<button
type="button"
className={mode === "url" ? "active" : ""}
onClick={() => {
setMode("url");
setFile(null);
setState({ status: "idle" });
}}
disabled={submitting}
>
🔗 URL
</button>
<button
type="button"
className={mode === "file" ? "active" : ""}
onClick={() => {
setMode("file");
setUrl("");
setUrlPreview({ status: "idle" });
setState({ status: "idle" });
}}
disabled={submitting}
>
📎 File
</button>
</div>
</div>
<form onSubmit={handleSubmit} className="dump-create-form dump-form">
{state.status === "error" && (
<ErrorCard title="Failed to post" message={state.error} />
)}
{mode === "url"
? (
<>
<div key="url-field" className="form-group">
<label htmlFor="url">URL</label>
<input
id="url"
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
onPaste={(e) => {
const pastedFile = e.clipboardData.files[0];
if (pastedFile) {
e.preventDefault();
setMode("file");
setUrl("");
setUrlPreview({ status: "idle" });
setFile(pastedFile);
setState({ status: "idle" });
}
}}
disabled={submitting}
placeholder="https://..."
required
autoFocus
/>
</div>
{urlPreview.status === "loading" && (
<p className="preview-loading">Fetching preview</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="comment">Why are you dumping this?</label>
<TextEditor
id="comment"
value={comment}
onChange={setComment}
disabled={submitting}
placeholder="Tell the community what makes this worth their time..."
rows={3}
/>
</div>
<div className="form-actions">
<div className="form-actions-right">
<Link to="/" className="form-cancel">Cancel</Link>
<button
type="submit"
className="btn-primary"
disabled={submitting}
>
{submitting
? (mode === "url" ? "Fetching…" : "Uploading…")
: "Dump it"}
</button>
</div>
</div>
</form>
</div>
</PageShell>
);
}