v3: allow users to edit dump title
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 2m54s
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 2m54s
This commit is contained in:
83
src/App.css
83
src/App.css
@@ -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%;
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -504,6 +504,7 @@ export interface CreateUrlDumpRequest {
|
||||
|
||||
export interface UpdateDumpRequest {
|
||||
url?: string;
|
||||
title?: string;
|
||||
comment?: string;
|
||||
isPrivate?: boolean;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user