v3: editor fixes
This commit is contained in:
38
src/App.css
38
src/App.css
@@ -2269,11 +2269,11 @@ body.has-player .fab-new {
|
||||
|
||||
.modal-body {
|
||||
padding: 1rem 1.25rem;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.confirm-modal-message {
|
||||
@@ -2296,6 +2296,8 @@ body.has-player .fab-new {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
overflow-y: auto;
|
||||
max-height: 40vh;
|
||||
}
|
||||
|
||||
.playlist-membership-row {
|
||||
@@ -3278,14 +3280,20 @@ body.has-player .fab-new {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.emoji-picker-float input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 8px 10px;
|
||||
border: none;
|
||||
.emoji-picker-search-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
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);
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
@@ -3293,6 +3301,22 @@ body.has-player .fab-new {
|
||||
.emoji-picker-float input::placeholder {
|
||||
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) */
|
||||
.emoji-picker-float [frimousse-viewport] {
|
||||
max-height: 220px;
|
||||
|
||||
@@ -22,6 +22,7 @@ import { ErrorCard } from "./ErrorCard.tsx";
|
||||
import { FileDropZone } from "./FileDropZone.tsx";
|
||||
import { friendlyFetchError } from "../utils/apiError.ts";
|
||||
import { MAX_FILE_SIZE } from "../config/upload.ts";
|
||||
import { TextEditor } from "./TextEditor.tsx";
|
||||
|
||||
type Mode = "url" | "file";
|
||||
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(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
if (e.key === "Escape" && !e.defaultPrevented) onClose();
|
||||
};
|
||||
document.addEventListener("keydown", handler);
|
||||
return () => document.removeEventListener("keydown", handler);
|
||||
@@ -389,10 +390,10 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
|
||||
<label htmlFor="dc-comment">
|
||||
Why are you dumping this?
|
||||
</label>
|
||||
<textarea
|
||||
<TextEditor
|
||||
id="dc-comment"
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
onChange={setComment}
|
||||
disabled={submitting}
|
||||
placeholder="Tell the community what makes this worth their time..."
|
||||
rows={3}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Playlist, RawPlaylist } from "../model.ts";
|
||||
import { deserializePlaylist } from "../model.ts";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { ErrorCard } from "./ErrorCard.tsx";
|
||||
import { TextEditor } from "./TextEditor.tsx";
|
||||
|
||||
interface PlaylistCreateFormProps {
|
||||
/** If provided, the new playlist will have this dump added to it. */
|
||||
@@ -67,10 +68,10 @@ export function PlaylistCreateForm(
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
<textarea
|
||||
<TextEditor
|
||||
placeholder="Description (optional)"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
onChange={setDescription}
|
||||
rows={3}
|
||||
/>
|
||||
<div className="visibility-toggle">
|
||||
|
||||
@@ -144,11 +144,21 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
|
||||
<EmojiPicker.Root
|
||||
onEmojiSelect={(e) => handleEmojiSelect(e.emoji)}
|
||||
>
|
||||
<EmojiPicker.Search
|
||||
ref={emojiSearchRef}
|
||||
defaultValue={emojiQuery}
|
||||
placeholder="Search emoji…"
|
||||
/>
|
||||
<div className="emoji-picker-search-row">
|
||||
<EmojiPicker.Search
|
||||
ref={emojiSearchRef}
|
||||
defaultValue={emojiQuery}
|
||||
placeholder="Search emoji…"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="emoji-picker-close-btn"
|
||||
onClick={closeEmoji}
|
||||
aria-label="Close emoji picker"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<EmojiPicker.Viewport
|
||||
ref={emojiViewportRef}
|
||||
// tabIndex={-1} makes the div programmatically focusable so
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user