v3: allow users to edit dump title
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 2m54s

This commit is contained in:
khannurien
2026-06-21 12:57:46 +00:00
parent 34933a3d4f
commit 888aae45cf
12 changed files with 322 additions and 51 deletions

View File

@@ -128,6 +128,63 @@
word-break: break-word;
}
.dump-title-view {
position: relative;
display: inline-flex;
align-items: baseline;
gap: 0.4rem;
border-radius: 6px;
max-width: 100%;
}
.dump-title-view--editable {
cursor: pointer;
}
.dump-title-view--editable:hover {
background: var(--color-surface);
}
.dump-title-edit-btn {
font-size: 0.85rem;
color: var(--color-muted);
opacity: 0;
transition: opacity 0.1s;
pointer-events: none;
}
.dump-title-view--editable:hover .dump-title-edit-btn {
opacity: 1;
}
.dump-title-editor {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.dump-title-input {
flex: 1;
min-width: 12rem;
padding: 0.4rem 0.6rem;
border-radius: 8px;
border: 2px solid var(--color-border);
background-color: var(--color-bg);
color: var(--color-text);
font-size: 1.25rem;
font-weight: 700;
font-family: "Saira", sans-serif;
outline: none;
}
.dump-title-error {
width: 100%;
margin: 0;
font-size: 0.85rem;
color: var(--color-danger);
}
.dump-comment {
font-size: 1.05rem;
line-height: 1.72;
@@ -254,6 +311,32 @@
opacity: 0.6;
}
.dump-create-title-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.dump-create-title-row input {
flex: 1;
}
.dump-create-title-edit-btn {
background: none;
border: none;
cursor: pointer;
font-size: 1rem;
line-height: 1;
color: var(--color-muted);
border-radius: 4px;
padding: 0.4rem;
transition: color 0.15s;
}
.dump-create-title-edit-btn:hover {
color: var(--color-text);
}
/* ── New dump form ── */
.dump-create-wrapper {
width: 100%;

View File

@@ -88,6 +88,9 @@ export function DumpCreateModal(
const [mode, setMode] = useState<Mode>("url");
const [url, setUrl] = useState(initialUrl);
const [file, setFile] = useState<File | null>(null);
const [title, setTitle] = useState("");
const [titleEditing, setTitleEditing] = useState(false);
const titleInputRef = useRef<HTMLInputElement | null>(null);
const [comment, setComment] = useState("");
const [isPrivate, setIsPrivate] = useState(false);
const [submitState, setSubmitState] = useState<SubmitState>({
@@ -100,6 +103,12 @@ export function DumpCreateModal(
const [memberships, setMemberships] = useState<PlaylistMembership[]>([]);
const [playlistsLoading, setPlaylistsLoading] = useState(false);
const selectFile = (f: File | null) => {
setFile(f);
setTitle(f?.name ?? "");
setTitleEditing(false);
};
// Debounced URL preview
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
@@ -143,7 +152,7 @@ export function DumpCreateModal(
setMode("file");
setUrl("");
setUrlPreview({ status: "idle" });
setFile(pastedFile);
selectFile(pastedFile);
setSubmitState({ status: "idle" });
return;
}
@@ -154,7 +163,7 @@ export function DumpCreateModal(
const u = new URL(normalizeUrl(text));
if (u.protocol === "http:" || u.protocol === "https:") {
setMode("url");
setFile(null);
selectFile(null);
setUrl(u.toString());
setSubmitState({ status: "idle" });
}
@@ -206,6 +215,7 @@ export function DumpCreateModal(
const formData = new FormData();
formData.append("file", file);
if (comment.trim()) formData.append("comment", comment.trim());
if (title.trim()) formData.append("title", title.trim());
formData.append("isPrivate", String(isPrivate));
res = await authFetch(`${API_URL}/api/dumps`, {
method: "POST",
@@ -287,7 +297,7 @@ export function DumpCreateModal(
className={mode === "url" ? "active" : ""}
onClick={() => {
setMode("url");
setFile(null);
selectFile(null);
setSubmitState({ status: "idle" });
}}
disabled={submitting}
@@ -337,7 +347,7 @@ export function DumpCreateModal(
setMode("file");
setUrl("");
setUrlPreview({ status: "idle" });
setFile(pastedFile);
selectFile(pastedFile);
setSubmitState({ status: "idle" });
}
}}
@@ -364,10 +374,46 @@ export function DumpCreateModal(
<>
<FileDropZone
file={file}
onChange={setFile}
onChange={selectFile}
disabled={submitting}
/>
{file && <LocalFilePreview file={file} />}
{file && (
<div className="form-group">
<label htmlFor="dc-title">
<Trans>Title</Trans>
</label>
<div className="dump-create-title-row">
<input
id="dc-title"
ref={titleInputRef}
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
disabled={submitting || !titleEditing}
maxLength={VALIDATION.DUMP_TITLE_MAX}
required
/>
{!titleEditing && (
<button
type="button"
className="dump-create-title-edit-btn"
onClick={() => {
setTitleEditing(true);
setTimeout(
() => titleInputRef.current?.focus(),
0,
);
}}
disabled={submitting}
aria-label={t`Edit title`}
>
</button>
)}
</div>
</div>
)}
</>
)}

View File

@@ -504,6 +504,7 @@ export interface CreateUrlDumpRequest {
export interface UpdateDumpRequest {
url?: string;
title?: string;
comment?: string;
isPrivate?: boolean;
}

View File

@@ -5,7 +5,7 @@ import { Trans } from "@lingui/react/macro";
import { dumpUrl } from "../utils/urls.ts";
import { AddToPlaylistModal } from "../components/AddToPlaylistModal.tsx";
import { API_URL } from "../config/api.ts";
import { API_URL, VALIDATION } from "../config/api.ts";
import type {
Comment,
@@ -13,6 +13,7 @@ import type {
PublicUser,
RawComment,
RawDump,
UpdateDumpRequest,
} from "../model.ts";
import {
deserializeComment,
@@ -54,7 +55,12 @@ export function Dump() {
const [comments, setComments] = useState<Comment[]>([]);
const { user, token } = useAuth();
const [titleEditing, setTitleEditing] = useState(false);
const [titleDraft, setTitleDraft] = useState("");
const [titleSaving, setTitleSaving] = useState(false);
const [titleError, setTitleError] = useState<string | null>(null);
const { user, token, authFetch } = useAuth();
const {
voteCounts,
myVotes,
@@ -226,6 +232,41 @@ export function Dump() {
const { dump } = dumpState;
const canEdit = !!user && (dump.userId === user.id || user.isAdmin === true);
const handleTitleSave = async () => {
const trimmed = titleDraft.trim();
if (!trimmed || trimmed.length > VALIDATION.DUMP_TITLE_MAX) return;
setTitleSaving(true);
setTitleError(null);
try {
const res = await authFetch(`${API_URL}/api/dumps/${dump.id}`, {
method: "PUT",
body: JSON.stringify(
{ title: trimmed } satisfies UpdateDumpRequest,
),
});
const apiResponse = parseAPIResponse<RawDump>(await res.json());
if (!apiResponse.success) {
setTitleError(apiResponse.error.message);
return;
}
const updatedDump = deserializeDump(apiResponse.data);
setTitleEditing(false);
if (updatedDump.slug !== dump.slug) {
navigate(dumpUrl(updatedDump), {
replace: true,
state: { dump: updatedDump },
});
} else {
setDumpState({ status: "loaded", dump: updatedDump });
}
} catch (err) {
setTitleError(friendlyFetchError(err));
} finally {
setTitleSaving(false);
}
};
return (
<PageShell>
<div className="dump-detail">
@@ -252,7 +293,68 @@ export function Dump() {
)}
</div>
<div className="dump-header-right">
<h1 className="dump-title">{dump.title}</h1>
{titleEditing
? (
<div className="dump-title-editor">
<input
type="text"
className="dump-title-input"
value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleTitleSave();
} else if (e.key === "Escape") {
setTitleEditing(false);
}
}}
maxLength={VALIDATION.DUMP_TITLE_MAX}
disabled={titleSaving}
autoFocus
/>
<button
type="button"
className="btn-primary"
onClick={handleTitleSave}
disabled={titleSaving || !titleDraft.trim()}
>
{titleSaving ? <Trans>Saving</Trans> : <Trans>Save</Trans>}
</button>
<button
type="button"
className="btn-border"
onClick={() => setTitleEditing(false)}
disabled={titleSaving}
>
<Trans>Cancel</Trans>
</button>
{titleError && (
<p className="dump-title-error">{titleError}</p>
)}
</div>
)
: (
<div
className={`dump-title-view${
canEdit ? " dump-title-view--editable" : ""
}`}
onClick={canEdit
? () => {
setTitleDraft(dump.title);
setTitleError(null);
setTitleEditing(true);
}
: undefined}
>
<h1 className="dump-title">{dump.title}</h1>
{canEdit && (
<span className="dump-title-edit-btn" aria-hidden>
</span>
)}
</div>
)}
<div className="dump-op">
<Avatar
userId={dump.userId}

View File

@@ -30,6 +30,7 @@ export function DumpEdit() {
const [state, setState] = useState<DumpEditState>({ status: "loading" });
const [url, setUrl] = useState("");
const [title, setTitle] = useState("");
const [comment, setComment] = useState("");
const [isPrivate, setIsPrivate] = useState(false);
const [newFile, setNewFile] = useState<File | null>(null);
@@ -52,6 +53,7 @@ export function DumpEdit() {
if (apiResponse.success) {
const dump: Dump = deserializeDump(apiResponse.data);
setUrl(dump.url ?? "");
setTitle(dump.title);
setComment(dump.comment ?? "");
setIsPrivate(dump.isPrivate);
setState({ status: "loaded", dump });
@@ -65,8 +67,11 @@ export function DumpEdit() {
}, [selectedDump, token]);
const handleSave = async () => {
const trimmedTitle = title.trim();
if (
state.status !== "loaded" || comment.length > VALIDATION.DUMP_COMMENT_MAX
state.status !== "loaded" ||
comment.length > VALIDATION.DUMP_COMMENT_MAX ||
!trimmedTitle || trimmedTitle.length > VALIDATION.DUMP_TITLE_MAX
) return;
let res: Response;
@@ -74,6 +79,7 @@ export function DumpEdit() {
if (state.dump.kind === "file" && newFile) {
const formData = new FormData();
formData.append("file", newFile);
formData.append("title", trimmedTitle);
if (comment.trim()) formData.append("comment", comment.trim());
res = await authFetch(`${API_URL}/api/dumps/${state.dump.id}/file`, {
method: "PUT",
@@ -83,10 +89,11 @@ export function DumpEdit() {
const body: UpdateDumpRequest = state.dump.kind === "url"
? {
url: url.trim() || undefined,
title: trimmedTitle,
comment: comment.trim() || undefined,
isPrivate,
}
: { comment: comment.trim() || undefined, isPrivate };
: { title: trimmedTitle, comment: comment.trim() || undefined, isPrivate };
res = await authFetch(`${API_URL}/api/dumps/${state.dump.id}`, {
method: "PUT",
body: JSON.stringify(body),
@@ -223,6 +230,20 @@ export function DumpEdit() {
handleSave();
}}
>
<div className="form-group">
<label htmlFor="title">
<Trans>Title</Trans>
</label>
<input
id="title"
type="text"
value={title}
onChange={(e) => setTitle(e.currentTarget.value)}
maxLength={VALIDATION.DUMP_TITLE_MAX}
required
/>
</div>
{dump.kind === "url"
? (
<div className="form-group">
@@ -301,7 +322,9 @@ export function DumpEdit() {
<button
type="submit"
className="btn-primary"
disabled={comment.length > VALIDATION.DUMP_COMMENT_MAX}
disabled={comment.length > VALIDATION.DUMP_COMMENT_MAX ||
!title.trim() ||
title.trim().length > VALIDATION.DUMP_TITLE_MAX}
>
<Trans>Save</Trans>
</button>