v3: code quality and visual consistency pass (refactored all forms across the app), fixed filename-related upload bug
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 47s
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 47s
This commit is contained in:
@@ -15,9 +15,20 @@ import { friendlyFetchError } from "../utils/apiError.ts";
|
||||
import { ConfirmModal } from "../components/ConfirmModal.tsx";
|
||||
import RichContentCard from "../components/RichContentCard.tsx";
|
||||
import FilePreview from "../components/FilePreview.tsx";
|
||||
import { TextEditor } from "../components/TextEditor.tsx";
|
||||
import { FileDropZone } from "../components/FileDropZone.tsx";
|
||||
import { ImagePicker } from "../components/ImagePicker.tsx";
|
||||
import {
|
||||
expectOk,
|
||||
FormError,
|
||||
FormProvider,
|
||||
RichTextField,
|
||||
SubmitButton,
|
||||
TextField,
|
||||
useApiForm,
|
||||
VisibilityToggle,
|
||||
} from "../components/form/index.ts";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { Controller } from "react-hook-form";
|
||||
|
||||
type DumpEditState =
|
||||
| { status: "loading" }
|
||||
@@ -30,11 +41,6 @@ export function DumpEdit() {
|
||||
const { authFetch, token } = useRequiredAuth();
|
||||
|
||||
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);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [thumbUploading, setThumbUploading] = useState(false);
|
||||
@@ -52,12 +58,7 @@ export function DumpEdit() {
|
||||
const apiResponse = parseAPIResponse<RawDump>(await res.json());
|
||||
|
||||
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 });
|
||||
setState({ status: "loaded", dump: deserializeDump(apiResponse.data) });
|
||||
} else {
|
||||
setState({ status: "error", error: apiResponse.error.message });
|
||||
}
|
||||
@@ -67,56 +68,6 @@ export function DumpEdit() {
|
||||
})();
|
||||
}, [selectedDump, token]);
|
||||
|
||||
const handleSave = async () => {
|
||||
const trimmedTitle = title.trim();
|
||||
if (
|
||||
state.status !== "loaded" ||
|
||||
comment.length > VALIDATION.DUMP_COMMENT_MAX ||
|
||||
!trimmedTitle || trimmedTitle.length > VALIDATION.DUMP_TITLE_MAX
|
||||
) return;
|
||||
|
||||
let res: Response;
|
||||
|
||||
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",
|
||||
body: formData,
|
||||
});
|
||||
} else {
|
||||
const body: UpdateDumpRequest = state.dump.kind === "url"
|
||||
? {
|
||||
url: url.trim() || undefined,
|
||||
title: trimmedTitle,
|
||||
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),
|
||||
});
|
||||
}
|
||||
|
||||
const apiResponse = parseAPIResponse<RawDump>(await res.json());
|
||||
if (!apiResponse.success) {
|
||||
setState({ status: "error", error: apiResponse.error.message });
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedDump: Dump = deserializeDump(apiResponse.data);
|
||||
setState({ status: "loaded", dump: updatedDump });
|
||||
setNewFile(null);
|
||||
navigate(dumpUrl(updatedDump), { state: { dump: updatedDump } });
|
||||
};
|
||||
|
||||
const handleRefreshMetadata = async () => {
|
||||
if (state.status !== "loaded" || state.dump.kind !== "url") return;
|
||||
|
||||
@@ -128,8 +79,7 @@ export function DumpEdit() {
|
||||
);
|
||||
const apiResponse = await res.json();
|
||||
if (apiResponse.success) {
|
||||
const updatedDump: Dump = deserializeDump(apiResponse.data);
|
||||
setState({ status: "loaded", dump: updatedDump });
|
||||
setState({ status: "loaded", dump: deserializeDump(apiResponse.data) });
|
||||
}
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
@@ -274,138 +224,17 @@ export function DumpEdit() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="dump-form"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
}}
|
||||
>
|
||||
<div className="form-group">
|
||||
<label>
|
||||
<Trans>Thumbnail</Trans>
|
||||
</label>
|
||||
<div className="dump-edit-thumbnail-row">
|
||||
<ImagePicker
|
||||
src={currentThumbnailSrc}
|
||||
size={64}
|
||||
onChange={handleThumbnailChange}
|
||||
uploading={thumbUploading}
|
||||
/>
|
||||
{dump.thumbnailMime && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-border"
|
||||
onClick={handleThumbnailReset}
|
||||
disabled={thumbUploading}
|
||||
>
|
||||
<Trans>Reset to default</Trans>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<label htmlFor="url">
|
||||
<Trans>URL</Trans>
|
||||
</label>
|
||||
<input
|
||||
id="url"
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.currentTarget.value)}
|
||||
placeholder="https://..."
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="form-group">
|
||||
<p className="dump-file-notice">
|
||||
<strong>{dump.fileName}</strong>
|
||||
{dump.fileSize != null && ` — ${formatBytes(dump.fileSize)}`}
|
||||
</p>
|
||||
<FileDropZone
|
||||
file={newFile}
|
||||
onChange={setNewFile}
|
||||
label={t`Replace file`}
|
||||
hint={t`Drop a replacement here`}
|
||||
showLimit={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="comment">
|
||||
<Trans>Why?</Trans>
|
||||
</label>
|
||||
<TextEditor
|
||||
id="comment"
|
||||
value={comment}
|
||||
onChange={setComment}
|
||||
placeholder={t`What makes it worth it?`}
|
||||
rows={3}
|
||||
maxLength={VALIDATION.DUMP_COMMENT_MAX}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="visibility-toggle">
|
||||
<button
|
||||
type="button"
|
||||
className={!isPrivate ? "active" : ""}
|
||||
onClick={() => setIsPrivate(false)}
|
||||
>
|
||||
<Trans>Public</Trans>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={isPrivate ? "active" : ""}
|
||||
onClick={() => setIsPrivate(true)}
|
||||
>
|
||||
<Trans>Private</Trans>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
className="btn-danger"
|
||||
>
|
||||
<Trans>Delete dump</Trans>
|
||||
</button>
|
||||
<div className="form-actions-right">
|
||||
<Link to={dumpUrl(dump)} className="form-cancel">
|
||||
<Trans>Cancel</Trans>
|
||||
</Link>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
disabled={comment.length > VALIDATION.DUMP_COMMENT_MAX ||
|
||||
!title.trim() ||
|
||||
title.trim().length > VALIDATION.DUMP_TITLE_MAX}
|
||||
>
|
||||
<Trans>Save</Trans>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<DumpEditForm
|
||||
key={dump.id}
|
||||
dump={dump}
|
||||
currentThumbnailSrc={currentThumbnailSrc}
|
||||
thumbUploading={thumbUploading}
|
||||
onThumbnailChange={handleThumbnailChange}
|
||||
onThumbnailReset={handleThumbnailReset}
|
||||
onRequestDelete={() => setConfirmDelete(true)}
|
||||
onSaved={(updated) =>
|
||||
navigate(dumpUrl(updated), { state: { dump: updated } })}
|
||||
/>
|
||||
</div>
|
||||
{confirmDelete && (
|
||||
<ConfirmModal
|
||||
@@ -418,3 +247,180 @@ export function DumpEdit() {
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
interface DumpEditFormProps {
|
||||
dump: Dump;
|
||||
currentThumbnailSrc: string | null;
|
||||
thumbUploading: boolean;
|
||||
onThumbnailChange: (file: File) => void;
|
||||
onThumbnailReset: () => void;
|
||||
onRequestDelete: () => void;
|
||||
onSaved: (dump: Dump) => void;
|
||||
}
|
||||
|
||||
interface EditValues {
|
||||
title: string;
|
||||
url: string;
|
||||
comment: string;
|
||||
isPublic: boolean;
|
||||
newFile: File | null;
|
||||
}
|
||||
|
||||
function DumpEditForm({
|
||||
dump,
|
||||
currentThumbnailSrc,
|
||||
thumbUploading,
|
||||
onThumbnailChange,
|
||||
onThumbnailReset,
|
||||
onRequestDelete,
|
||||
onSaved,
|
||||
}: DumpEditFormProps) {
|
||||
const { authFetch } = useAuth();
|
||||
const form = useApiForm<EditValues>({
|
||||
defaultValues: {
|
||||
title: dump.title,
|
||||
url: dump.url ?? "",
|
||||
comment: dump.comment ?? "",
|
||||
isPublic: !dump.isPrivate,
|
||||
newFile: null,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = form.submit(
|
||||
async ({ title, url, comment, isPublic, newFile }) => {
|
||||
const trimmedTitle = title.trim();
|
||||
const isPrivate = !isPublic;
|
||||
let res: Response;
|
||||
|
||||
if (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/${dump.id}/file`, {
|
||||
method: "PUT",
|
||||
body: formData,
|
||||
});
|
||||
} else {
|
||||
const body: UpdateDumpRequest = dump.kind === "url"
|
||||
? {
|
||||
url: url.trim() || undefined,
|
||||
title: trimmedTitle,
|
||||
comment: comment.trim() || undefined,
|
||||
isPrivate,
|
||||
}
|
||||
: {
|
||||
title: trimmedTitle,
|
||||
comment: comment.trim() || undefined,
|
||||
isPrivate,
|
||||
};
|
||||
res = await authFetch(`${API_URL}/api/dumps/${dump.id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
onSaved(deserializeDump(await expectOk<RawDump>(res)));
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form className="form" onSubmit={onSubmit}>
|
||||
<FormError title={t`Could not save`} />
|
||||
|
||||
<div className="form-field">
|
||||
<span className="form-label">
|
||||
<Trans>Thumbnail</Trans>
|
||||
</span>
|
||||
<div className="dump-edit-thumbnail-row">
|
||||
<ImagePicker
|
||||
src={currentThumbnailSrc}
|
||||
size={64}
|
||||
onChange={onThumbnailChange}
|
||||
uploading={thumbUploading}
|
||||
/>
|
||||
{dump.thumbnailMime && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-border"
|
||||
onClick={onThumbnailReset}
|
||||
disabled={thumbUploading}
|
||||
>
|
||||
<Trans>Reset to default</Trans>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TextField<EditValues>
|
||||
name="title"
|
||||
label={t`Title`}
|
||||
maxLength={VALIDATION.DUMP_TITLE_MAX}
|
||||
rules={{ required: true }}
|
||||
/>
|
||||
|
||||
{dump.kind === "url"
|
||||
? (
|
||||
<TextField<EditValues>
|
||||
name="url"
|
||||
type="url"
|
||||
label={t`URL`}
|
||||
placeholder="https://..."
|
||||
rules={{ required: true }}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<div className="form-field">
|
||||
<p className="dump-file-notice">
|
||||
<strong>{dump.fileName}</strong>
|
||||
{dump.fileSize != null && ` — ${formatBytes(dump.fileSize)}`}
|
||||
</p>
|
||||
<Controller
|
||||
name="newFile"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FileDropZone
|
||||
file={field.value}
|
||||
onChange={field.onChange}
|
||||
label={t`Replace file`}
|
||||
hint={t`Drop a replacement here`}
|
||||
showLimit={false}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<RichTextField<EditValues>
|
||||
name="comment"
|
||||
label={t`Why?`}
|
||||
placeholder={t`What makes it worth it?`}
|
||||
rows={3}
|
||||
maxLength={VALIDATION.DUMP_COMMENT_MAX}
|
||||
rules={{ maxLength: VALIDATION.DUMP_COMMENT_MAX }}
|
||||
/>
|
||||
|
||||
<VisibilityToggle<EditValues> name="isPublic" />
|
||||
|
||||
<div className="form-actions">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRequestDelete}
|
||||
className="btn-danger"
|
||||
>
|
||||
<Trans>Delete dump</Trans>
|
||||
</button>
|
||||
<div className="form-actions-right">
|
||||
<Link to={dumpUrl(dump)} className="form-cancel">
|
||||
<Trans>Cancel</Trans>
|
||||
</Link>
|
||||
<SubmitButton>
|
||||
<Trans>Save</Trans>
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Trans } from "@lingui/react/macro";
|
||||
import { API_URL, VALIDATION } from "../config/api.ts";
|
||||
import { CountedInput } from "../components/CountedInput.tsx";
|
||||
import type {
|
||||
Playlist,
|
||||
PlaylistWithDumps,
|
||||
RawDump,
|
||||
RawPlaylist,
|
||||
@@ -22,7 +23,6 @@ import {
|
||||
deserializeDump,
|
||||
deserializePlaylist,
|
||||
deserializePlaylistWithDumps,
|
||||
parseAPIResponse,
|
||||
} from "../model.ts";
|
||||
import { playlistUrl } from "../utils/urls.ts";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
@@ -36,9 +36,16 @@ import { ImagePicker } from "../components/ImagePicker.tsx";
|
||||
import { Markdown } from "../components/Markdown.tsx";
|
||||
import { TextEditor } from "../components/TextEditor.tsx";
|
||||
import { FollowPlaylistButton } from "../components/FollowButton.tsx";
|
||||
import { ErrorCard } from "../components/ErrorCard.tsx";
|
||||
import { Tooltip } from "../components/Tooltip.tsx";
|
||||
import { friendlyFetchError } from "../utils/apiError.ts";
|
||||
import { Controller } from "react-hook-form";
|
||||
import {
|
||||
expectOk,
|
||||
FormError,
|
||||
FormProvider,
|
||||
SubmitButton,
|
||||
useApiForm,
|
||||
} from "../components/form/index.ts";
|
||||
|
||||
type LoadState =
|
||||
| { status: "loading" }
|
||||
@@ -88,14 +95,7 @@ export function PlaylistDetail() {
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [editTitle, setEditTitle] = useState("");
|
||||
const [editDescription, setEditDescription] = useState("");
|
||||
const [editIsPublic, setEditIsPublic] = useState(true);
|
||||
const [editSaving, setEditSaving] = useState(false);
|
||||
const [editError, setEditError] = useState<string | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||
|
||||
// Mirrors activeDumpIds for use in effects without adding it as a dep.
|
||||
// Updated on every render via useLayoutEffect so it's always current.
|
||||
@@ -522,68 +522,11 @@ export function PlaylistDetail() {
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
const openEdit = () => {
|
||||
if (state.status !== "loaded") return;
|
||||
setEditTitle(state.playlist.title);
|
||||
setEditDescription(state.playlist.description ?? "");
|
||||
setEditIsPublic(state.playlist.isPublic);
|
||||
setImageFile(null);
|
||||
setImagePreview(null);
|
||||
setEditError(null);
|
||||
setEditOpen(true);
|
||||
};
|
||||
const openEdit = () => setEditOpen(true);
|
||||
|
||||
const handleEditSave = async () => {
|
||||
if (
|
||||
!playlistId || state.status !== "loaded" ||
|
||||
editDescription.length > VALIDATION.PLAYLIST_DESCRIPTION_MAX
|
||||
) return;
|
||||
setEditSaving(true);
|
||||
setEditError(null);
|
||||
try {
|
||||
const updateRes = await authFetch(
|
||||
`${API_URL}/api/playlists/${playlistId}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(
|
||||
{
|
||||
...(editTitle !== state.playlist.title
|
||||
? { title: editTitle }
|
||||
: {}),
|
||||
...(editDescription !== (state.playlist.description ?? "")
|
||||
? { description: editDescription || null }
|
||||
: {}),
|
||||
isPublic: editIsPublic,
|
||||
} satisfies UpdatePlaylistRequest,
|
||||
),
|
||||
},
|
||||
);
|
||||
const updateJson = parseAPIResponse<RawPlaylist>(await updateRes.json());
|
||||
const updatedPlaylist = updateJson.success
|
||||
? deserializePlaylist(updateJson.data)
|
||||
: null;
|
||||
|
||||
if (imageFile) {
|
||||
const fd = new FormData();
|
||||
fd.append("file", imageFile);
|
||||
await authFetch(`${API_URL}/api/playlists/${playlistId}/image`, {
|
||||
method: "POST",
|
||||
body: fd,
|
||||
});
|
||||
}
|
||||
|
||||
setEditOpen(false);
|
||||
if (updatedPlaylist) {
|
||||
navigate(playlistUrl(updatedPlaylist), { replace: true });
|
||||
} else {
|
||||
fetchPlaylist();
|
||||
}
|
||||
} catch (err) {
|
||||
setEditError(friendlyFetchError(err));
|
||||
} finally {
|
||||
setEditSaving(false);
|
||||
}
|
||||
const handleEditSaved = (updated: Playlist) => {
|
||||
setEditOpen(false);
|
||||
navigate(playlistUrl(updated), { replace: true });
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
@@ -636,125 +579,49 @@ export function PlaylistDetail() {
|
||||
<div className="playlist-detail-header-top">
|
||||
{editOpen
|
||||
? (
|
||||
<ImagePicker
|
||||
src={imagePreview ??
|
||||
(playlist.imageMime
|
||||
? `${API_URL}/api/playlists/${playlist.id}/image`
|
||||
: null)}
|
||||
alt="Cover"
|
||||
size={72}
|
||||
onChange={(file) => {
|
||||
setImageFile(file);
|
||||
setImagePreview(URL.createObjectURL(file));
|
||||
}}
|
||||
<PlaylistEditForm
|
||||
playlist={playlist}
|
||||
onCancel={() => setEditOpen(false)}
|
||||
onRequestDelete={() => setConfirmDelete(true)}
|
||||
onSaved={handleEditSaved}
|
||||
/>
|
||||
)
|
||||
: playlist.imageMime && (
|
||||
<img
|
||||
src={`${API_URL}/api/playlists/${playlist.id}/image`}
|
||||
alt=""
|
||||
className="playlist-detail-img"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="playlist-detail-content">
|
||||
{editOpen
|
||||
? (
|
||||
<div className="playlist-detail-title-row">
|
||||
<CountedInput
|
||||
className="playlist-edit-input"
|
||||
value={editTitle}
|
||||
onChange={(e) => setEditTitle(e.target.value)}
|
||||
autoFocus
|
||||
maxLength={VALIDATION.PLAYLIST_TITLE_MAX}
|
||||
: (
|
||||
<>
|
||||
{playlist.imageMime && (
|
||||
<img
|
||||
src={`${API_URL}/api/playlists/${playlist.id}/image`}
|
||||
alt=""
|
||||
className="playlist-detail-img"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
disabled={editSaving ||
|
||||
editDescription.length >
|
||||
VALIDATION.PLAYLIST_DESCRIPTION_MAX}
|
||||
onClick={handleEditSave}
|
||||
>
|
||||
{editSaving ? <Trans>Saving…</Trans> : <Trans>Save</Trans>}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="form-cancel"
|
||||
onClick={() => setEditOpen(false)}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-danger"
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
>
|
||||
<Trans>Delete</Trans>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="playlist-detail-title-row">
|
||||
<h1 className="playlist-detail-title">{playlist.title}</h1>
|
||||
{!isOwner && (
|
||||
<FollowPlaylistButton
|
||||
targetPlaylistId={playlist.id}
|
||||
isPublic={playlist.isPublic}
|
||||
/>
|
||||
)}
|
||||
{isOwner && (
|
||||
<button
|
||||
type="button"
|
||||
className="playlist-edit-btn"
|
||||
onClick={openEdit}
|
||||
>
|
||||
<Trans>Edit</Trans>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editOpen
|
||||
? (
|
||||
<TextEditor
|
||||
className="playlist-edit-textarea"
|
||||
value={editDescription}
|
||||
onChange={setEditDescription}
|
||||
placeholder={t`Description (optional)`}
|
||||
autoResize
|
||||
rows={1}
|
||||
maxLength={VALIDATION.PLAYLIST_DESCRIPTION_MAX}
|
||||
/>
|
||||
)
|
||||
: playlist.description && (
|
||||
<Markdown className="playlist-detail-description">
|
||||
{playlist.description}
|
||||
</Markdown>
|
||||
)}
|
||||
|
||||
<div className="playlist-detail-meta">
|
||||
{editOpen
|
||||
? (
|
||||
<div className="visibility-toggle playlist-edit-toggle">
|
||||
<button
|
||||
type="button"
|
||||
className={editIsPublic ? "active" : ""}
|
||||
onClick={() => setEditIsPublic(true)}
|
||||
>
|
||||
<Trans>Public</Trans>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={!editIsPublic ? "active" : ""}
|
||||
onClick={() => setEditIsPublic(false)}
|
||||
>
|
||||
<Trans>Private</Trans>
|
||||
</button>
|
||||
)}
|
||||
<div className="playlist-detail-content">
|
||||
<div className="playlist-detail-title-row">
|
||||
<h1 className="playlist-detail-title">{playlist.title}</h1>
|
||||
{!isOwner && (
|
||||
<FollowPlaylistButton
|
||||
targetPlaylistId={playlist.id}
|
||||
isPublic={playlist.isPublic}
|
||||
/>
|
||||
)}
|
||||
{isOwner && (
|
||||
<button
|
||||
type="button"
|
||||
className="playlist-edit-btn"
|
||||
onClick={openEdit}
|
||||
>
|
||||
<Trans>Edit</Trans>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
|
||||
{playlist.description && (
|
||||
<Markdown className="playlist-detail-description">
|
||||
{playlist.description}
|
||||
</Markdown>
|
||||
)}
|
||||
|
||||
<div className="playlist-detail-meta">
|
||||
<span
|
||||
className={`playlist-badge${
|
||||
playlist.isPublic ? "" : " playlist-badge--private"
|
||||
@@ -788,13 +655,10 @@ export function PlaylistDetail() {
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{editError && (
|
||||
<ErrorCard title={t`Failed to save`} message={editError} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -887,3 +751,173 @@ export function PlaylistDetail() {
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
interface PlaylistEditFormProps {
|
||||
playlist: PlaylistWithDumps;
|
||||
onCancel: () => void;
|
||||
onRequestDelete: () => void;
|
||||
onSaved: (playlist: Playlist) => void;
|
||||
}
|
||||
|
||||
interface EditValues {
|
||||
title: string;
|
||||
description: string;
|
||||
isPublic: boolean;
|
||||
image: File | null;
|
||||
}
|
||||
|
||||
function PlaylistEditForm(
|
||||
{ playlist, onCancel, onRequestDelete, onSaved }: PlaylistEditFormProps,
|
||||
) {
|
||||
const { authFetch } = useAuth();
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||
const form = useApiForm<EditValues>({
|
||||
defaultValues: {
|
||||
title: playlist.title,
|
||||
description: playlist.description ?? "",
|
||||
isPublic: playlist.isPublic,
|
||||
image: null,
|
||||
},
|
||||
});
|
||||
const description = form.watch("description");
|
||||
|
||||
const onSubmit = form.submit(
|
||||
async ({ title, description, isPublic, image }) => {
|
||||
const updated = deserializePlaylist(
|
||||
await expectOk<RawPlaylist>(
|
||||
await authFetch(`${API_URL}/api/playlists/${playlist.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(
|
||||
{
|
||||
...(title !== playlist.title ? { title } : {}),
|
||||
...(description !== (playlist.description ?? "")
|
||||
? { description: description || null }
|
||||
: {}),
|
||||
isPublic,
|
||||
} satisfies UpdatePlaylistRequest,
|
||||
),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
if (image) {
|
||||
const fd = new FormData();
|
||||
fd.append("file", image);
|
||||
await authFetch(`${API_URL}/api/playlists/${playlist.id}/image`, {
|
||||
method: "POST",
|
||||
body: fd,
|
||||
});
|
||||
}
|
||||
|
||||
onSaved(updated);
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
{/* `display: contents` keeps the image-beside-content flex layout while
|
||||
still giving us a real <form> for submit + Enter handling. */}
|
||||
<form style={{ display: "contents" }} onSubmit={onSubmit}>
|
||||
<Controller
|
||||
name="image"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<ImagePicker
|
||||
src={imagePreview ??
|
||||
(playlist.imageMime
|
||||
? `${API_URL}/api/playlists/${playlist.id}/image`
|
||||
: null)}
|
||||
alt="Cover"
|
||||
size={72}
|
||||
onChange={(file) => {
|
||||
field.onChange(file);
|
||||
setImagePreview(URL.createObjectURL(file));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="playlist-detail-content playlist-edit-content">
|
||||
<Controller
|
||||
name="title"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<CountedInput
|
||||
className="playlist-edit-input"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
autoFocus
|
||||
placeholder={t`Playlist title`}
|
||||
maxLength={VALIDATION.PLAYLIST_TITLE_MAX}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="description"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<TextEditor
|
||||
className="playlist-edit-textarea"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder={t`Description (optional)`}
|
||||
autoResize
|
||||
rows={2}
|
||||
maxLength={VALIDATION.PLAYLIST_DESCRIPTION_MAX}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="isPublic"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<div className="visibility-toggle playlist-edit-toggle">
|
||||
<button
|
||||
type="button"
|
||||
className={field.value ? "active" : ""}
|
||||
onClick={() => field.onChange(true)}
|
||||
>
|
||||
<Trans>Public</Trans>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={!field.value ? "active" : ""}
|
||||
onClick={() => field.onChange(false)}
|
||||
>
|
||||
<Trans>Private</Trans>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormError title={t`Failed to save`} />
|
||||
|
||||
<div className="form-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-danger"
|
||||
onClick={onRequestDelete}
|
||||
>
|
||||
<Trans>Delete</Trans>
|
||||
</button>
|
||||
<div className="form-actions-right">
|
||||
<button type="button" className="form-cancel" onClick={onCancel}>
|
||||
<Trans>Cancel</Trans>
|
||||
</button>
|
||||
<SubmitButton
|
||||
pendingLabel={<Trans>Saving…</Trans>}
|
||||
disabled={description.length >
|
||||
VALIDATION.PLAYLIST_DESCRIPTION_MAX}
|
||||
>
|
||||
<Trans>Save</Trans>
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,27 +4,42 @@ import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
|
||||
import { API_URL, VALIDATION } from "../config/api.ts";
|
||||
import { ErrorCard } from "../components/ErrorCard.tsx";
|
||||
import { PageShell } from "../components/PageShell.tsx";
|
||||
import {
|
||||
expectOk,
|
||||
FormError,
|
||||
FormProvider,
|
||||
SubmitButton,
|
||||
TextField,
|
||||
useApiForm,
|
||||
} from "../components/form/index.ts";
|
||||
|
||||
type State =
|
||||
| { status: "idle" }
|
||||
| { status: "submitting" }
|
||||
| { status: "done" }
|
||||
| { status: "error"; error: string };
|
||||
interface Values {
|
||||
newPassword: string;
|
||||
confirm: string;
|
||||
}
|
||||
|
||||
export function ResetPassword() {
|
||||
const [params] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const token = params.get("token") ?? "";
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const [state, setState] = useState<State>({ status: "idle" });
|
||||
const form = useApiForm<Values>({
|
||||
mode: "onChange",
|
||||
defaultValues: { newPassword: "", confirm: "" },
|
||||
});
|
||||
|
||||
const mismatch = confirm.length > 0 && newPassword !== confirm;
|
||||
const tooShort = newPassword.length > 0 &&
|
||||
newPassword.length < VALIDATION.PASSWORD_MIN;
|
||||
const onSubmit = form.submit(async ({ newPassword }) => {
|
||||
await expectOk(
|
||||
await fetch(`${API_URL}/api/users/reset-password`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token, newPassword }),
|
||||
}),
|
||||
);
|
||||
setDone(true);
|
||||
});
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
@@ -48,7 +63,7 @@ export function ResetPassword() {
|
||||
);
|
||||
}
|
||||
|
||||
if (state.status === "done") {
|
||||
if (done) {
|
||||
return (
|
||||
<PageShell centered>
|
||||
<div className="auth-card">
|
||||
@@ -70,31 +85,6 @@ export function ResetPassword() {
|
||||
);
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.SubmitEvent) => {
|
||||
e.preventDefault();
|
||||
if (mismatch || tooShort || !newPassword) return;
|
||||
|
||||
setState({ status: "submitting" });
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/users/reset-password`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token, newPassword }),
|
||||
});
|
||||
const body = await res.json();
|
||||
if (body.success) {
|
||||
setState({ status: "done" });
|
||||
} else {
|
||||
setState({
|
||||
status: "error",
|
||||
error: body.error?.message ?? t`Unknown error`,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
setState({ status: "error", error: t`Could not connect to server` });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageShell centered>
|
||||
<div className="auth-card">
|
||||
@@ -102,57 +92,40 @@ export function ResetPassword() {
|
||||
<Trans>Set new password</Trans>
|
||||
</h1>
|
||||
|
||||
{state.status === "error" && (
|
||||
<ErrorCard title={t`Reset failed`} message={state.error} />
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="auth-form">
|
||||
<div className="form-group">
|
||||
<input
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={onSubmit} className="form">
|
||||
<FormError title={t`Reset failed`} />
|
||||
<TextField<Values>
|
||||
name="newPassword"
|
||||
type="password"
|
||||
placeholder={t`New password`}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
minLength={VALIDATION.PASSWORD_MIN}
|
||||
maxLength={VALIDATION.PASSWORD_MAX}
|
||||
required
|
||||
autoFocus
|
||||
disabled={state.status === "submitting"}
|
||||
maxLength={VALIDATION.PASSWORD_MAX}
|
||||
rules={{
|
||||
required: true,
|
||||
minLength: {
|
||||
value: VALIDATION.PASSWORD_MIN,
|
||||
message: t`At least ${VALIDATION.PASSWORD_MIN} characters`,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{tooShort && (
|
||||
<span className="auth-field-hint auth-field-hint--error">
|
||||
<Trans>At least {VALIDATION.PASSWORD_MIN} characters</Trans>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<input
|
||||
<TextField<Values>
|
||||
name="confirm"
|
||||
type="password"
|
||||
placeholder={t`Confirm new password`}
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
disabled={state.status === "submitting"}
|
||||
rules={{
|
||||
required: true,
|
||||
validate: (value, values) =>
|
||||
value === values.newPassword || t`Passwords do not match`,
|
||||
}}
|
||||
/>
|
||||
{mismatch && (
|
||||
<span className="auth-field-hint auth-field-hint--error">
|
||||
<Trans>Passwords do not match</Trans>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
disabled={state.status === "submitting" || mismatch || tooShort ||
|
||||
!newPassword || !confirm}
|
||||
>
|
||||
{state.status === "submitting"
|
||||
? <Trans>Saving…</Trans>
|
||||
: <Trans>Set new password</Trans>}
|
||||
</button>
|
||||
</form>
|
||||
<SubmitButton pendingLabel={<Trans>Saving…</Trans>}>
|
||||
<Trans>Set new password</Trans>
|
||||
</SubmitButton>
|
||||
</form>
|
||||
</FormProvider>
|
||||
|
||||
<p className="auth-card-footer">
|
||||
<Link to="/login">
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import type { SubmitEvent } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
@@ -8,77 +7,48 @@ import { API_URL } from "../config/api.ts";
|
||||
import {
|
||||
deserializeAuthResponse,
|
||||
type LoginRequest,
|
||||
parseAPIResponse,
|
||||
type RawAuthResponse,
|
||||
} from "../model.ts";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { PageShell } from "../components/PageShell.tsx";
|
||||
import { ErrorCard } from "../components/ErrorCard.tsx";
|
||||
import { friendlyFetchError } from "../utils/apiError.ts";
|
||||
import {
|
||||
expectOk,
|
||||
FormError,
|
||||
FormProvider,
|
||||
SubmitButton,
|
||||
TextField,
|
||||
useApiForm,
|
||||
} from "../components/form/index.ts";
|
||||
|
||||
type LoginState =
|
||||
| { status: "idle" }
|
||||
| { status: "submitting" }
|
||||
| { status: "error"; error: string };
|
||||
interface LoginValues {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
type ResetState =
|
||||
| { status: "idle" }
|
||||
| { status: "submitting" }
|
||||
| { status: "sent" }
|
||||
| { status: "error"; error: string };
|
||||
interface ResetValues {
|
||||
email: string;
|
||||
}
|
||||
|
||||
export function UserLogin() {
|
||||
const navigate = useNavigate();
|
||||
const { login } = useAuth();
|
||||
|
||||
const [loginState, setLoginState] = useState<LoginState>({ status: "idle" });
|
||||
const [showReset, setShowReset] = useState(false);
|
||||
const [resetEmail, setResetEmail] = useState("");
|
||||
const [resetState, setResetState] = useState<ResetState>({ status: "idle" });
|
||||
|
||||
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setLoginState({ status: "submitting" });
|
||||
const form = useApiForm<LoginValues>({
|
||||
defaultValues: { username: "", password: "" },
|
||||
});
|
||||
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const username = formData.get("username") as string;
|
||||
const password = formData.get("password") as string;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/users/login`, {
|
||||
const onSubmit = form.submit(async ({ username, password }) => {
|
||||
const data = await expectOk<RawAuthResponse>(
|
||||
await fetch(`${API_URL}/api/users/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password } satisfies LoginRequest),
|
||||
});
|
||||
|
||||
const apiResponse = parseAPIResponse<RawAuthResponse>(await res.json());
|
||||
|
||||
if (apiResponse.success) {
|
||||
login(deserializeAuthResponse(apiResponse.data));
|
||||
navigate("/");
|
||||
} else {
|
||||
setLoginState({ status: "error", error: apiResponse.error.message });
|
||||
}
|
||||
} catch (err) {
|
||||
setLoginState({ status: "error", error: friendlyFetchError(err) });
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetRequest = async (e: React.SubmitEvent) => {
|
||||
e.preventDefault();
|
||||
if (!resetEmail.trim()) return;
|
||||
setResetState({ status: "submitting" });
|
||||
try {
|
||||
await fetch(`${API_URL}/api/users/request-password-reset`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: resetEmail.trim() }),
|
||||
});
|
||||
setResetState({ status: "sent" });
|
||||
} catch {
|
||||
setResetState({ status: "error", error: t`Could not connect to server` });
|
||||
}
|
||||
};
|
||||
}),
|
||||
);
|
||||
login(deserializeAuthResponse(data));
|
||||
navigate("/");
|
||||
});
|
||||
|
||||
return (
|
||||
<PageShell centered>
|
||||
@@ -87,98 +57,38 @@ export function UserLogin() {
|
||||
<Trans>Log in</Trans>
|
||||
</h1>
|
||||
|
||||
{loginState.status === "error" && (
|
||||
<ErrorCard title={t`Login failed`} message={loginState.error} />
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="auth-form">
|
||||
<input
|
||||
name="username"
|
||||
type="text"
|
||||
placeholder={t`Username`}
|
||||
required
|
||||
disabled={loginState.status === "submitting"}
|
||||
autoFocus
|
||||
/>
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder={t`Password`}
|
||||
required
|
||||
disabled={loginState.status === "submitting"}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
disabled={loginState.status === "submitting"}
|
||||
>
|
||||
{loginState.status === "submitting"
|
||||
? <Trans>Logging in…</Trans>
|
||||
: <Trans>Log in</Trans>}
|
||||
</button>
|
||||
</form>
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={onSubmit} className="form">
|
||||
<FormError title={t`Login failed`} />
|
||||
<TextField<LoginValues>
|
||||
name="username"
|
||||
placeholder={t`Username`}
|
||||
autoFocus
|
||||
rules={{ required: true }}
|
||||
/>
|
||||
<TextField<LoginValues>
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder={t`Password`}
|
||||
rules={{ required: true }}
|
||||
/>
|
||||
<SubmitButton pendingLabel={<Trans>Logging in…</Trans>}>
|
||||
<Trans>Log in</Trans>
|
||||
</SubmitButton>
|
||||
</form>
|
||||
</FormProvider>
|
||||
|
||||
<p className="auth-card-footer">
|
||||
<button
|
||||
type="button"
|
||||
className="auth-link-btn"
|
||||
onClick={() => {
|
||||
setShowReset((v) => !v);
|
||||
setResetState({ status: "idle" });
|
||||
setResetEmail("");
|
||||
}}
|
||||
onClick={() => setShowReset((v) => !v)}
|
||||
>
|
||||
<Trans>Forgot password?</Trans>
|
||||
</button>
|
||||
</p>
|
||||
|
||||
{showReset && (
|
||||
<div className="auth-reset-panel">
|
||||
{resetState.status === "sent"
|
||||
? (
|
||||
<p className="auth-reset-sent">
|
||||
<Trans>
|
||||
If that address is registered you'll receive a reset link
|
||||
shortly.
|
||||
</Trans>
|
||||
</p>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
{resetState.status === "error" && (
|
||||
<ErrorCard
|
||||
title={t`Request failed`}
|
||||
message={resetState.error}
|
||||
/>
|
||||
)}
|
||||
<form
|
||||
onSubmit={handleResetRequest}
|
||||
className="auth-form auth-reset-form"
|
||||
>
|
||||
<input
|
||||
type="email"
|
||||
placeholder={t`Your email address`}
|
||||
value={resetEmail}
|
||||
onChange={(e) => setResetEmail(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
disabled={resetState.status === "submitting"}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
disabled={resetState.status === "submitting" ||
|
||||
!resetEmail.trim()}
|
||||
>
|
||||
{resetState.status === "submitting"
|
||||
? <Trans>Sending…</Trans>
|
||||
: <Trans>Send reset link</Trans>}
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{showReset && <ResetRequestPanel />}
|
||||
|
||||
<p className="auth-card-footer">
|
||||
<Trans>This is a mirage.</Trans>
|
||||
@@ -187,3 +97,49 @@ export function UserLogin() {
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
function ResetRequestPanel() {
|
||||
const [sent, setSent] = useState(false);
|
||||
const form = useApiForm<ResetValues>({ defaultValues: { email: "" } });
|
||||
|
||||
const onSubmit = form.submit(async ({ email }) => {
|
||||
await fetch(`${API_URL}/api/users/request-password-reset`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: email.trim() }),
|
||||
});
|
||||
setSent(true);
|
||||
});
|
||||
|
||||
if (sent) {
|
||||
return (
|
||||
<div className="auth-reset-panel">
|
||||
<p className="auth-reset-sent">
|
||||
<Trans>
|
||||
If that address is registered you'll receive a reset link shortly.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-reset-panel">
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={onSubmit} className="form auth-reset-form">
|
||||
<FormError title={t`Request failed`} />
|
||||
<TextField<ResetValues>
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder={t`Your email address`}
|
||||
autoFocus
|
||||
rules={{ required: true }}
|
||||
/>
|
||||
<SubmitButton pendingLabel={<Trans>Sending…</Trans>}>
|
||||
<Trans>Send reset link</Trans>
|
||||
</SubmitButton>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -55,6 +55,14 @@ import { Markdown } from "../components/Markdown.tsx";
|
||||
import { ChangePasswordModal } from "../components/ChangePasswordModal.tsx";
|
||||
import { TabBar } from "../components/TabBar.tsx";
|
||||
import { useTabParam } from "../hooks/useTabParam.ts";
|
||||
import { Controller } from "react-hook-form";
|
||||
import {
|
||||
expectOk,
|
||||
FormError,
|
||||
FormProvider,
|
||||
SubmitButton,
|
||||
useApiForm,
|
||||
} from "../components/form/index.ts";
|
||||
|
||||
function InviteButton() {
|
||||
const { authFetch } = useAuth();
|
||||
@@ -292,14 +300,7 @@ export function UserPublicProfile() {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [descEditing, setDescEditing] = useState(false);
|
||||
const [descDraft, setDescDraft] = useState("");
|
||||
const [descSaving, setDescSaving] = useState(false);
|
||||
const [descError, setDescError] = useState<string | null>(null);
|
||||
|
||||
const [emailEditing, setEmailEditing] = useState(false);
|
||||
const [emailDraft, setEmailDraft] = useState("");
|
||||
const [emailSaving, setEmailSaving] = useState(false);
|
||||
const [emailError, setEmailError] = useState<string | null>(null);
|
||||
const prevMyVotesRef = useRef<Set<string> | null>(null);
|
||||
|
||||
const [changePasswordOpen, setChangePasswordOpen] = useState(false);
|
||||
@@ -674,72 +675,22 @@ export function UserPublicProfile() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleEmailSave = async () => {
|
||||
if (state.status !== "loaded") return;
|
||||
setEmailSaving(true);
|
||||
setEmailError(null);
|
||||
try {
|
||||
const res = await authFetch(`${API_URL}/api/users/me`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(
|
||||
{ email: emailDraft.trim() } satisfies UpdateUserRequest,
|
||||
),
|
||||
});
|
||||
const body = parseAPIResponse<RawPublicUser>(await res.json());
|
||||
if (!body.success) {
|
||||
setEmailError(body.error.message);
|
||||
return;
|
||||
}
|
||||
const storedRaw = localStorage.getItem("authResponse");
|
||||
if (storedRaw) {
|
||||
const prev = deserializeAuthResponse(JSON.parse(storedRaw));
|
||||
login({ ...prev, user: { ...prev.user, email: emailDraft.trim() } });
|
||||
}
|
||||
setEmailEditing(false);
|
||||
} catch {
|
||||
setEmailError(t`Failed to save`);
|
||||
} finally {
|
||||
setEmailSaving(false);
|
||||
const handleEmailSaved = (email: string) => {
|
||||
const storedRaw = localStorage.getItem("authResponse");
|
||||
if (storedRaw) {
|
||||
const prev = deserializeAuthResponse(JSON.parse(storedRaw));
|
||||
login({ ...prev, user: { ...prev.user, email } });
|
||||
}
|
||||
setEmailEditing(false);
|
||||
};
|
||||
|
||||
const handleDescSave = async () => {
|
||||
if (
|
||||
state.status !== "loaded" ||
|
||||
descDraft.length > VALIDATION.USER_DESCRIPTION_MAX
|
||||
) return;
|
||||
setDescSaving(true);
|
||||
setDescError(null);
|
||||
try {
|
||||
const res = await authFetch(`${API_URL}/api/users/me`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(
|
||||
{
|
||||
description: descDraft.trim() || undefined,
|
||||
} satisfies UpdateUserRequest,
|
||||
),
|
||||
});
|
||||
const body = parseAPIResponse<RawPublicUser>(await res.json());
|
||||
if (!body.success) {
|
||||
setDescError(body.error.message);
|
||||
return;
|
||||
}
|
||||
setState((s) =>
|
||||
s.status === "loaded"
|
||||
? {
|
||||
...s,
|
||||
user: { ...s.user, description: descDraft.trim() || undefined },
|
||||
}
|
||||
: s
|
||||
);
|
||||
setDescEditing(false);
|
||||
} catch {
|
||||
setDescError(t`Failed to save`);
|
||||
} finally {
|
||||
setDescSaving(false);
|
||||
}
|
||||
const handleDescSaved = (description: string | undefined) => {
|
||||
setState((s) =>
|
||||
s.status === "loaded"
|
||||
? { ...s, user: { ...s.user, description } }
|
||||
: s
|
||||
);
|
||||
setDescEditing(false);
|
||||
};
|
||||
|
||||
if (state.status === "loading") {
|
||||
@@ -826,59 +777,16 @@ export function UserPublicProfile() {
|
||||
{isOwnProfile && (
|
||||
emailEditing
|
||||
? (
|
||||
<form
|
||||
className="profile-email-editor"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleEmailSave();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="email"
|
||||
className="profile-email-input"
|
||||
value={emailDraft}
|
||||
onChange={(e) => setEmailDraft(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") setEmailEditing(false);
|
||||
}}
|
||||
disabled={emailSaving}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="profile-email-actions">
|
||||
<button
|
||||
type="submit"
|
||||
className="profile-email-btn profile-email-btn--save"
|
||||
disabled={emailSaving || !emailDraft.trim()}
|
||||
>
|
||||
{emailSaving
|
||||
? <Trans>Saving…</Trans>
|
||||
: <Trans>Save</Trans>}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="profile-email-btn profile-email-btn--cancel"
|
||||
onClick={() => setEmailEditing(false)}
|
||||
disabled={emailSaving}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</button>
|
||||
</div>
|
||||
{emailError && (
|
||||
<ErrorCard
|
||||
title={t`Failed to save`}
|
||||
message={emailError}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
<EmailEditor
|
||||
initialEmail={me?.email ?? ""}
|
||||
onCancel={() => setEmailEditing(false)}
|
||||
onSaved={handleEmailSaved}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<p
|
||||
className="profile-email-display"
|
||||
onClick={() => {
|
||||
setEmailDraft(me?.email ?? "");
|
||||
setEmailError(null);
|
||||
setEmailEditing(true);
|
||||
}}
|
||||
onClick={() => setEmailEditing(true)}
|
||||
title="Edit email"
|
||||
>
|
||||
{me?.email ?? t`Add email…`}
|
||||
@@ -916,39 +824,11 @@ export function UserPublicProfile() {
|
||||
<div className="profile-description">
|
||||
{descEditing
|
||||
? (
|
||||
<div className="profile-description-editor">
|
||||
<TextEditor
|
||||
className="comment-reply-textarea"
|
||||
value={descDraft}
|
||||
onChange={setDescDraft}
|
||||
onSubmit={handleDescSave}
|
||||
placeholder={t`Who am I?`}
|
||||
autoResize
|
||||
maxLength={VALIDATION.USER_DESCRIPTION_MAX}
|
||||
/>
|
||||
<div className="profile-description-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
onClick={handleDescSave}
|
||||
disabled={descSaving ||
|
||||
descDraft.length > VALIDATION.USER_DESCRIPTION_MAX}
|
||||
>
|
||||
{descSaving ? <Trans>Saving…</Trans> : <Trans>Save</Trans>}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-border"
|
||||
onClick={() => setDescEditing(false)}
|
||||
disabled={descSaving}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</button>
|
||||
{descError && (
|
||||
<ErrorCard title={t`Failed to save`} message={descError} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DescriptionEditor
|
||||
initialDescription={profileUser.description ?? ""}
|
||||
onCancel={() => setDescEditing(false)}
|
||||
onSaved={handleDescSaved}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<div
|
||||
@@ -956,11 +836,7 @@ export function UserPublicProfile() {
|
||||
isOwnProfile ? " profile-description-view--editable" : ""
|
||||
}`}
|
||||
onClick={isOwnProfile
|
||||
? () => {
|
||||
setDescDraft(profileUser.description ?? "");
|
||||
setDescError(null);
|
||||
setDescEditing(true);
|
||||
}
|
||||
? () => setDescEditing(true)
|
||||
: undefined}
|
||||
>
|
||||
{profileUser.description
|
||||
@@ -1548,3 +1424,136 @@ function FollowedUserCard({ user }: { user: PublicUser }) {
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
interface EmailEditorProps {
|
||||
initialEmail: string;
|
||||
onCancel: () => void;
|
||||
onSaved: (email: string) => void;
|
||||
}
|
||||
|
||||
function EmailEditor({ initialEmail, onCancel, onSaved }: EmailEditorProps) {
|
||||
const { authFetch } = useAuth();
|
||||
const form = useApiForm<{ email: string }>({
|
||||
defaultValues: { email: initialEmail },
|
||||
});
|
||||
const email = form.watch("email");
|
||||
const submitting = form.formState.isSubmitting;
|
||||
|
||||
const onSubmit = form.submit(async ({ email }) => {
|
||||
await expectOk<RawPublicUser>(
|
||||
await authFetch(`${API_URL}/api/users/me`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(
|
||||
{ email: email.trim() } satisfies UpdateUserRequest,
|
||||
),
|
||||
}),
|
||||
);
|
||||
onSaved(email.trim());
|
||||
});
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form className="profile-email-editor" onSubmit={onSubmit}>
|
||||
<input
|
||||
type="email"
|
||||
className="profile-email-input"
|
||||
{...form.register("email")}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") onCancel();
|
||||
}}
|
||||
disabled={submitting}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="profile-email-actions">
|
||||
<SubmitButton
|
||||
className="profile-email-btn profile-email-btn--save"
|
||||
pendingLabel={<Trans>Saving…</Trans>}
|
||||
disabled={!email.trim()}
|
||||
>
|
||||
<Trans>Save</Trans>
|
||||
</SubmitButton>
|
||||
<button
|
||||
type="button"
|
||||
className="profile-email-btn profile-email-btn--cancel"
|
||||
onClick={onCancel}
|
||||
disabled={submitting}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</button>
|
||||
</div>
|
||||
<FormError title={t`Failed to save`} />
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
interface DescriptionEditorProps {
|
||||
initialDescription: string;
|
||||
onCancel: () => void;
|
||||
onSaved: (description: string | undefined) => void;
|
||||
}
|
||||
|
||||
function DescriptionEditor(
|
||||
{ initialDescription, onCancel, onSaved }: DescriptionEditorProps,
|
||||
) {
|
||||
const { authFetch } = useAuth();
|
||||
const form = useApiForm<{ description: string }>({
|
||||
defaultValues: { description: initialDescription },
|
||||
});
|
||||
const description = form.watch("description");
|
||||
const submitting = form.formState.isSubmitting;
|
||||
|
||||
const onSubmit = form.submit(async ({ description }) => {
|
||||
const trimmed = description.trim() || undefined;
|
||||
await expectOk<RawPublicUser>(
|
||||
await authFetch(`${API_URL}/api/users/me`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(
|
||||
{ description: trimmed } satisfies UpdateUserRequest,
|
||||
),
|
||||
}),
|
||||
);
|
||||
onSaved(trimmed);
|
||||
});
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form className="profile-description-editor" onSubmit={onSubmit}>
|
||||
<Controller
|
||||
name="description"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<TextEditor
|
||||
className="comment-reply-textarea"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onSubmit={() => void onSubmit()}
|
||||
placeholder={t`Who am I?`}
|
||||
autoResize
|
||||
maxLength={VALIDATION.USER_DESCRIPTION_MAX}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div className="profile-description-actions">
|
||||
<SubmitButton
|
||||
pendingLabel={<Trans>Saving…</Trans>}
|
||||
disabled={description.length > VALIDATION.USER_DESCRIPTION_MAX}
|
||||
>
|
||||
<Trans>Save</Trans>
|
||||
</SubmitButton>
|
||||
<button
|
||||
type="button"
|
||||
className="form-cancel"
|
||||
onClick={onCancel}
|
||||
disabled={submitting}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</button>
|
||||
<FormError title={t`Failed to save`} />
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { SubmitEvent } from "react";
|
||||
import { Link, useNavigate, useSearchParams } from "react-router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
@@ -7,24 +6,31 @@ import { Trans } from "@lingui/react/macro";
|
||||
import { API_URL, VALIDATION } from "../config/api.ts";
|
||||
import {
|
||||
deserializeAuthResponse,
|
||||
parseAPIResponse,
|
||||
type RawAuthResponse,
|
||||
type RegisterRequest,
|
||||
} from "../model.ts";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { PageShell } from "../components/PageShell.tsx";
|
||||
import { ErrorCard } from "../components/ErrorCard.tsx";
|
||||
import { friendlyFetchError } from "../utils/apiError.ts";
|
||||
import {
|
||||
expectOk,
|
||||
FormError,
|
||||
FormProvider,
|
||||
SubmitButton,
|
||||
TextField,
|
||||
useApiForm,
|
||||
} from "../components/form/index.ts";
|
||||
|
||||
type TokenState =
|
||||
| { status: "checking" }
|
||||
| { status: "invalid" }
|
||||
| { status: "valid" };
|
||||
|
||||
type FormState =
|
||||
| { status: "idle" }
|
||||
| { status: "submitting" }
|
||||
| { status: "error"; error: string };
|
||||
interface Values {
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export function UserRegister() {
|
||||
const navigate = useNavigate();
|
||||
@@ -35,9 +41,12 @@ export function UserRegister() {
|
||||
const [tokenState, setTokenState] = useState<TokenState>(() =>
|
||||
token ? { status: "checking" } : { status: "invalid" }
|
||||
);
|
||||
const [formState, setFormState] = useState<FormState>({ status: "idle" });
|
||||
const [prevToken, setPrevToken] = useState(token);
|
||||
|
||||
const form = useApiForm<Values>({
|
||||
defaultValues: { username: "", email: "", password: "" },
|
||||
});
|
||||
|
||||
if (prevToken !== token) {
|
||||
setPrevToken(token);
|
||||
setTokenState(token ? { status: "checking" } : { status: "invalid" });
|
||||
@@ -52,41 +61,20 @@ export function UserRegister() {
|
||||
.catch(() => setTokenState({ status: "invalid" }));
|
||||
}, [token]);
|
||||
|
||||
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setFormState({ status: "submitting" });
|
||||
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const username = formData.get("username") as string;
|
||||
const password = formData.get("password") as string;
|
||||
const email = formData.get("email") as string;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/users/register`, {
|
||||
const onSubmit = form.submit(async ({ username, email, password }) => {
|
||||
const data = await expectOk<RawAuthResponse>(
|
||||
await fetch(`${API_URL}/api/users/register`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(
|
||||
{
|
||||
username,
|
||||
password,
|
||||
inviteToken: token,
|
||||
email,
|
||||
} satisfies RegisterRequest,
|
||||
{ username, password, inviteToken: token, email } satisfies
|
||||
RegisterRequest,
|
||||
),
|
||||
});
|
||||
|
||||
const apiResponse = parseAPIResponse<RawAuthResponse>(await res.json());
|
||||
|
||||
if (apiResponse.success) {
|
||||
login(deserializeAuthResponse(apiResponse.data));
|
||||
navigate("/");
|
||||
} else {
|
||||
setFormState({ status: "error", error: apiResponse.error.message });
|
||||
}
|
||||
} catch (err) {
|
||||
setFormState({ status: "error", error: friendlyFetchError(err) });
|
||||
}
|
||||
};
|
||||
}),
|
||||
);
|
||||
login(deserializeAuthResponse(data));
|
||||
navigate("/");
|
||||
});
|
||||
|
||||
if (tokenState.status === "checking") {
|
||||
return (
|
||||
@@ -118,48 +106,49 @@ export function UserRegister() {
|
||||
<Trans>Register</Trans>
|
||||
</h1>
|
||||
|
||||
{formState.status === "error" && (
|
||||
<ErrorCard title={t`Registration failed`} message={formState.error} />
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="auth-form">
|
||||
<input
|
||||
name="username"
|
||||
type="text"
|
||||
placeholder={t`Username`}
|
||||
required
|
||||
pattern={`[a-zA-Z0-9_]{${VALIDATION.USERNAME_MIN},${VALIDATION.USERNAME_MAX}}`}
|
||||
title={t`${VALIDATION.USERNAME_MIN}–${VALIDATION.USERNAME_MAX} characters: letters, numbers, or underscores`}
|
||||
maxLength={VALIDATION.USERNAME_MAX}
|
||||
disabled={formState.status === "submitting"}
|
||||
autoFocus
|
||||
/>
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder={t`Email address`}
|
||||
required
|
||||
disabled={formState.status === "submitting"}
|
||||
/>
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder={t`Password (min. ${VALIDATION.PASSWORD_MIN} characters)`}
|
||||
required
|
||||
minLength={VALIDATION.PASSWORD_MIN}
|
||||
maxLength={VALIDATION.PASSWORD_MAX}
|
||||
disabled={formState.status === "submitting"}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
disabled={formState.status === "submitting"}
|
||||
>
|
||||
{formState.status === "submitting"
|
||||
? <Trans>Registering…</Trans>
|
||||
: <Trans>Register</Trans>}
|
||||
</button>
|
||||
</form>
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={onSubmit} className="form">
|
||||
<FormError title={t`Registration failed`} />
|
||||
<TextField<Values>
|
||||
name="username"
|
||||
placeholder={t`Username`}
|
||||
autoFocus
|
||||
maxLength={VALIDATION.USERNAME_MAX}
|
||||
rules={{
|
||||
required: true,
|
||||
pattern: {
|
||||
value: new RegExp(
|
||||
`^[a-zA-Z0-9_]{${VALIDATION.USERNAME_MIN},${VALIDATION.USERNAME_MAX}}$`,
|
||||
),
|
||||
message:
|
||||
t`${VALIDATION.USERNAME_MIN}–${VALIDATION.USERNAME_MAX} characters: letters, numbers, or underscores`,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<TextField<Values>
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder={t`Email address`}
|
||||
rules={{ required: true }}
|
||||
/>
|
||||
<TextField<Values>
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder={t`Password (min. ${VALIDATION.PASSWORD_MIN} characters)`}
|
||||
maxLength={VALIDATION.PASSWORD_MAX}
|
||||
rules={{
|
||||
required: true,
|
||||
minLength: {
|
||||
value: VALIDATION.PASSWORD_MIN,
|
||||
message: t`At least ${VALIDATION.PASSWORD_MIN} characters`,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<SubmitButton pendingLabel={<Trans>Registering…</Trans>}>
|
||||
<Trans>Register</Trans>
|
||||
</SubmitButton>
|
||||
</form>
|
||||
</FormProvider>
|
||||
|
||||
<p className="auth-card-footer">
|
||||
<Trans>
|
||||
|
||||
Reference in New Issue
Block a user