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

@@ -2269,11 +2269,11 @@ body.has-player .fab-new {
.modal-body { .modal-body {
padding: 1rem 1.25rem; padding: 1rem 1.25rem;
overflow-y: auto;
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.75rem; gap: 0.75rem;
min-height: 0;
} }
.confirm-modal-message { .confirm-modal-message {
@@ -2296,6 +2296,8 @@ body.has-player .fab-new {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.25rem; gap: 0.25rem;
overflow-y: auto;
max-height: 40vh;
} }
.playlist-membership-row { .playlist-membership-row {
@@ -3278,14 +3280,20 @@ body.has-player .fab-new {
overflow: hidden; overflow: hidden;
} }
.emoji-picker-float input { .emoji-picker-search-row {
display: block; display: flex;
width: 100%; align-items: center;
box-sizing: border-box;
padding: 8px 10px;
border: none;
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
background: var(--color-bg); background: var(--color-bg);
}
.emoji-picker-float input {
display: block;
flex: 1;
min-width: 0;
padding: 8px 10px;
border: none;
background: transparent;
color: var(--color-text); color: var(--color-text);
font-size: 0.9rem; font-size: 0.9rem;
outline: none; outline: none;
@@ -3293,6 +3301,22 @@ body.has-player .fab-new {
.emoji-picker-float input::placeholder { .emoji-picker-float input::placeholder {
color: var(--color-text-muted, #888); color: var(--color-text-muted, #888);
} }
.emoji-picker-close-btn {
flex-shrink: 0;
padding: 0 10px;
height: 100%;
background: none;
border: none;
color: var(--color-text);
opacity: 0.45;
cursor: pointer;
font-size: 0.75rem;
line-height: 1;
}
.emoji-picker-close-btn:hover {
opacity: 1;
}
/* frimousse uses bare attributes (no data- prefix) */ /* frimousse uses bare attributes (no data- prefix) */
.emoji-picker-float [frimousse-viewport] { .emoji-picker-float [frimousse-viewport] {
max-height: 220px; max-height: 220px;

View File

@@ -22,6 +22,7 @@ import { ErrorCard } from "./ErrorCard.tsx";
import { FileDropZone } from "./FileDropZone.tsx"; import { FileDropZone } from "./FileDropZone.tsx";
import { friendlyFetchError } from "../utils/apiError.ts"; import { friendlyFetchError } from "../utils/apiError.ts";
import { MAX_FILE_SIZE } from "../config/upload.ts"; import { MAX_FILE_SIZE } from "../config/upload.ts";
import { TextEditor } from "./TextEditor.tsx";
type Mode = "url" | "file"; type Mode = "url" | "file";
type Phase = "create" | "playlist"; type Phase = "create" | "playlist";
@@ -98,10 +99,10 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
}; };
}, []); }, []);
// Escape key to close // Escape key to close (skip if a picker/dropdown already handled it)
useEffect(() => { useEffect(() => {
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose(); if (e.key === "Escape" && !e.defaultPrevented) onClose();
}; };
document.addEventListener("keydown", handler); document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler); return () => document.removeEventListener("keydown", handler);
@@ -389,10 +390,10 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
<label htmlFor="dc-comment"> <label htmlFor="dc-comment">
Why are you dumping this? Why are you dumping this?
</label> </label>
<textarea <TextEditor
id="dc-comment" id="dc-comment"
value={comment} value={comment}
onChange={(e) => setComment(e.target.value)} onChange={setComment}
disabled={submitting} disabled={submitting}
placeholder="Tell the community what makes this worth their time..." placeholder="Tell the community what makes this worth their time..."
rows={3} rows={3}

View File

@@ -4,6 +4,7 @@ import type { Playlist, RawPlaylist } from "../model.ts";
import { deserializePlaylist } from "../model.ts"; import { deserializePlaylist } from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts"; import { useAuth } from "../hooks/useAuth.ts";
import { ErrorCard } from "./ErrorCard.tsx"; import { ErrorCard } from "./ErrorCard.tsx";
import { TextEditor } from "./TextEditor.tsx";
interface PlaylistCreateFormProps { interface PlaylistCreateFormProps {
/** If provided, the new playlist will have this dump added to it. */ /** If provided, the new playlist will have this dump added to it. */
@@ -67,10 +68,10 @@ export function PlaylistCreateForm(
autoFocus autoFocus
required required
/> />
<textarea <TextEditor
placeholder="Description (optional)" placeholder="Description (optional)"
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={setDescription}
rows={3} rows={3}
/> />
<div className="visibility-toggle"> <div className="visibility-toggle">

View File

@@ -144,11 +144,21 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
<EmojiPicker.Root <EmojiPicker.Root
onEmojiSelect={(e) => handleEmojiSelect(e.emoji)} onEmojiSelect={(e) => handleEmojiSelect(e.emoji)}
> >
<EmojiPicker.Search <div className="emoji-picker-search-row">
ref={emojiSearchRef} <EmojiPicker.Search
defaultValue={emojiQuery} ref={emojiSearchRef}
placeholder="Search emoji…" defaultValue={emojiQuery}
/> placeholder="Search emoji…"
/>
<button
type="button"
className="emoji-picker-close-btn"
onClick={closeEmoji}
aria-label="Close emoji picker"
>
</button>
</div>
<EmojiPicker.Viewport <EmojiPicker.Viewport
ref={emojiViewportRef} ref={emojiViewportRef}
// tabIndex={-1} makes the div programmatically focusable so // tabIndex={-1} makes the div programmatically focusable so

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>
);
}