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:
@@ -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