v3: the frontend gets a test suite, and the bugs it turned up are fixed — deno task test:web runs 612 tests over src/ in about 11s: Vitest on jsdom with Testing Library, built on the app's own Vite config so the SWC/Lingui transform and the .po loader are what ship. src/test/ holds the harness: a routing fetch stub (queued once-responses, fallback defaults), a FakeWebSocket the tests open, feed and drop, jsdom polyfills, fixtures, and two render helpers — one over hand-set context values, one over the real provider tree (auth, socket, player, follows, categories, chat). It covers every util and hook, every provider (the socket protocol end to end: votes and likes with ack/timeout/error rollback, chat sends/echoes/retries/read markers/reconcile, reconnect backfill), the components (header and search history, cards, Markdown, thumbnails, media and global player, the text editor's mentions/emoji/uploads, comments, the new-dump modal, the chat), and every page.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 48s
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 48s
Fixed along the way. Dumps: clearing a dump's note sent `comment: undefined`, which JSON drops, so the server kept the old one; it's sent as null now. Replacing a file dropped a visibility change made in the same save (the file endpoint doesn't read it); it follows as a separate update. Renaming a dump onto a new slug kept showing the old title until reload. The thumbnail picker and delete on the edit page failed without a word, the latter as an unhandled rejection. Playlists: an edit that kept the slug waited on the socket's echo to show, and a new cover never appeared (the upload's answer was ignored, a refused one said nothing); covers were also served stale for an hour, since `max-age=3600` met an unversioned URL — setPlaylistImage now bumps updated_at and every cover URL carries `?v=` (playlistImageUrl). Removing a dump, reordering, and deleting a playlist ignored non-OK answers, leaving page and server apart; they restore, reload or report. Profile: clearing "Who am I?" sent undefined and came back on reload; your own upvoted list (and the upvoted page) was empty until the socket's welcome, and forever without one; "View all" under Following linked to a route that doesn't exist and is a Load more now. Chat: Escape on "Delete this message?" closed the whole chat — ConfirmModal takes Escape first, in the capture phase. Player: a pause or play from a media key, headset or the OS wasn't reflected (MediaPlayer follows the element's own play/pause events), and the seek bar tracked dragging with mouse events only, so on touch the thumb fought timeupdate. Feeds: a guest on /~/followed got a blank page; a server error in the followed feed read as "Could not connect". Search: a failed later page threw away the results shown. Notifications: a failed mark-read replaced a loaded list with an error. Silent failures now say so: deleting a comment, the voters/likers "Load more" (stuck on "Loading…"), and the password-reset request, which said "sent" whatever happened. Notifications also stop hiding what was new: the bell still clears on opening the page, but the dots and the "N new" count stay for the visit instead of vanishing the moment the mark-read lands, and one arriving while the page is open is marked read too, so the bell doesn't count it. Accessibility: Modal and ConfirmModal are dialogs (role, aria-modal, labelled/described); playlist membership rows are checkboxes that work from the keyboard; a dump's title, and the profile's email and description, can be opened for editing from the keyboard (the pencil shows on focus); the category editor's ✓ and the global player's video button have names. Player, rich-content and download labels were hard-coded English and are translated, with the new French strings. Lint: fast-refresh and render-purity rules are off for src/test/ only, where probes hand their context out on purpose. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -302,6 +302,12 @@ export function Dump() {
|
||||
.map((id) => byId.get(id))
|
||||
.filter((c) => c !== undefined);
|
||||
|
||||
const startTitleEdit = () => {
|
||||
setTitleDraft(dump.title);
|
||||
setTitleError(null);
|
||||
setTitleEditing(true);
|
||||
};
|
||||
|
||||
const handleTitleSave = async () => {
|
||||
const trimmed = titleDraft.trim();
|
||||
if (!trimmed || trimmed.length > VALIDATION.DUMP_TITLE_MAX) return;
|
||||
@@ -322,13 +328,14 @@ export function Dump() {
|
||||
}
|
||||
const updatedDump = deserializeDump(apiResponse.data);
|
||||
setTitleEditing(false);
|
||||
// The page stays mounted across the slug change below, so it has to be
|
||||
// told about the new title itself.
|
||||
setDumpState({ status: "loaded", dump: updatedDump });
|
||||
if (updatedDump.slug !== dump.slug) {
|
||||
navigate(dumpUrl(updatedDump), {
|
||||
replace: true,
|
||||
state: { dump: updatedDump },
|
||||
});
|
||||
} else {
|
||||
setDumpState({ status: "loaded", dump: updatedDump });
|
||||
}
|
||||
} catch (err) {
|
||||
setTitleError(friendlyFetchError(err));
|
||||
@@ -411,11 +418,17 @@ export function Dump() {
|
||||
className={`dump-title-view${
|
||||
canEdit ? " dump-title-view--editable" : ""
|
||||
}`}
|
||||
onClick={canEdit
|
||||
? () => {
|
||||
setTitleDraft(dump.title);
|
||||
setTitleError(null);
|
||||
setTitleEditing(true);
|
||||
onClick={canEdit ? startTitleEdit : undefined}
|
||||
// Reachable from the keyboard too; not a button, so the
|
||||
// title inside stays a heading.
|
||||
tabIndex={canEdit ? 0 : undefined}
|
||||
title={canEdit ? t`Edit title` : undefined}
|
||||
onKeyDown={canEdit
|
||||
? (e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
startTitleEdit();
|
||||
}
|
||||
}
|
||||
: undefined}
|
||||
>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { formatBytes } from "../utils/format.ts";
|
||||
import { dumpFileUrl, dumpThumbnailUrl, dumpUrl } from "../utils/urls.ts";
|
||||
import { PageShell } from "../components/PageShell.tsx";
|
||||
import { PageError } from "../components/PageError.tsx";
|
||||
import { ErrorCard } from "../components/ErrorCard.tsx";
|
||||
import { friendlyFetchError } from "../utils/apiError.ts";
|
||||
import { ConfirmModal } from "../components/ConfirmModal.tsx";
|
||||
import RichContentCard from "../components/RichContentCard.tsx";
|
||||
@@ -55,6 +56,8 @@ export function DumpEdit() {
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [refreshError, setRefreshError] = useState<string | null>(null);
|
||||
const [thumbUploading, setThumbUploading] = useState(false);
|
||||
const [thumbError, setThumbError] = useState<string | null>(null);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedDump) return;
|
||||
@@ -105,54 +108,49 @@ export function DumpEdit() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleThumbnailChange = async (file: File) => {
|
||||
const changeThumbnail = async (init: RequestInit) => {
|
||||
if (state.status !== "loaded") return;
|
||||
setThumbUploading(true);
|
||||
setThumbError(null);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const res = await authFetch(
|
||||
`${API_URL}/api/dumps/${state.dump.id}/thumbnail`,
|
||||
{ method: "PUT", body: formData },
|
||||
const updated = await expectOk<RawDump>(
|
||||
await authFetch(
|
||||
`${API_URL}/api/dumps/${state.dump.id}/thumbnail`,
|
||||
init,
|
||||
),
|
||||
);
|
||||
const apiResponse = parseAPIResponse<RawDump>(await res.json());
|
||||
if (apiResponse.success) {
|
||||
setState({ status: "loaded", dump: deserializeDump(apiResponse.data) });
|
||||
}
|
||||
setState({ status: "loaded", dump: deserializeDump(updated) });
|
||||
} catch (err) {
|
||||
setThumbError(friendlyFetchError(err));
|
||||
} finally {
|
||||
setThumbUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleThumbnailReset = async () => {
|
||||
if (state.status !== "loaded") return;
|
||||
setThumbUploading(true);
|
||||
try {
|
||||
const res = await authFetch(
|
||||
`${API_URL}/api/dumps/${state.dump.id}/thumbnail`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
const apiResponse = parseAPIResponse<RawDump>(await res.json());
|
||||
if (apiResponse.success) {
|
||||
setState({ status: "loaded", dump: deserializeDump(apiResponse.data) });
|
||||
}
|
||||
} finally {
|
||||
setThumbUploading(false);
|
||||
}
|
||||
const handleThumbnailChange = (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
return changeThumbnail({ method: "PUT", body: formData });
|
||||
};
|
||||
|
||||
const handleThumbnailReset = () => changeThumbnail({ method: "DELETE" });
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (state.status !== "loaded") return;
|
||||
|
||||
const res = await authFetch(`${API_URL}/api/dumps/${state.dump.id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
setState({ status: "error", error: `Delete failed (${res.status})` });
|
||||
setConfirmDelete(false);
|
||||
setDeleteError(null);
|
||||
try {
|
||||
const res = await authFetch(`${API_URL}/api/dumps/${state.dump.id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => null);
|
||||
throw new Error(body?.error?.message ?? t`Delete failed (${res.status})`);
|
||||
}
|
||||
} catch (err) {
|
||||
setDeleteError(friendlyFetchError(err));
|
||||
return;
|
||||
}
|
||||
|
||||
navigate("/", { state: { deletedDumpId: state.dump.id } });
|
||||
};
|
||||
|
||||
@@ -252,6 +250,8 @@ export function DumpEdit() {
|
||||
}
|
||||
currentThumbnailSrc={currentThumbnailSrc}
|
||||
thumbUploading={thumbUploading}
|
||||
thumbError={thumbError}
|
||||
deleteError={deleteError}
|
||||
onThumbnailChange={handleThumbnailChange}
|
||||
onThumbnailReset={handleThumbnailReset}
|
||||
onRequestDelete={() => setConfirmDelete(true)}
|
||||
@@ -276,6 +276,8 @@ interface DumpEditFormProps {
|
||||
preview: React.ReactNode;
|
||||
currentThumbnailSrc: string | null;
|
||||
thumbUploading: boolean;
|
||||
thumbError: string | null;
|
||||
deleteError: string | null;
|
||||
onThumbnailChange: (file: File) => void;
|
||||
onThumbnailReset: () => void;
|
||||
onRequestDelete: () => void;
|
||||
@@ -295,6 +297,8 @@ function DumpEditForm({
|
||||
preview,
|
||||
currentThumbnailSrc,
|
||||
thumbUploading,
|
||||
thumbError,
|
||||
deleteError,
|
||||
onThumbnailChange,
|
||||
onThumbnailReset,
|
||||
onRequestDelete,
|
||||
@@ -343,18 +347,29 @@ function DumpEditForm({
|
||||
method: "PUT",
|
||||
body: formData,
|
||||
});
|
||||
// Replacing the file doesn't touch visibility, so a change made in
|
||||
// the same save goes separately, once the file is in.
|
||||
if (isPrivate !== dump.isPrivate) {
|
||||
await expectOk<RawDump>(res);
|
||||
res = await authFetch(`${API_URL}/api/dumps/${dump.id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ isPrivate } satisfies UpdateDumpRequest),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// An emptied note is sent as null: left out, the server keeps the old
|
||||
// one.
|
||||
const body: UpdateDumpRequest = dump.kind === "url"
|
||||
? {
|
||||
url: url.trim() || undefined,
|
||||
title: trimmedTitle,
|
||||
comment: comment.trim() || undefined,
|
||||
comment: comment.trim() || null,
|
||||
isPrivate,
|
||||
categoryIds,
|
||||
}
|
||||
: {
|
||||
title: trimmedTitle,
|
||||
comment: comment.trim() || undefined,
|
||||
comment: comment.trim() || null,
|
||||
isPrivate,
|
||||
categoryIds,
|
||||
};
|
||||
@@ -400,6 +415,11 @@ function DumpEditForm({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{thumbError && (
|
||||
<span className="form-hint form-hint--error" role="alert">
|
||||
{thumbError}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{dump.kind === "url"
|
||||
@@ -470,6 +490,9 @@ function DumpEditForm({
|
||||
</div>
|
||||
|
||||
<div className="dump-edit-footer">
|
||||
{deleteError && (
|
||||
<ErrorCard title={t`Could not delete`} message={deleteError} />
|
||||
)}
|
||||
<div className="form-actions">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -91,7 +91,10 @@ export function Index({ categorySlug }: { categorySlug?: string }) {
|
||||
const defaultTab: FeedTab = preferredTab === "followed" && !user
|
||||
? DEFAULT_FEED_TAB
|
||||
: preferredTab;
|
||||
const tab: FeedTab = (FEED_TABS as readonly string[]).includes(rawTab ?? "")
|
||||
// "followed" needs a session: a guest there (a shared link, or signed out
|
||||
// on it) gets the default feed rather than an empty page.
|
||||
const tab: FeedTab = (FEED_TABS as readonly string[]).includes(rawTab ?? "") &&
|
||||
(rawTab !== "followed" || !!user)
|
||||
? (rawTab as FeedTab)
|
||||
: defaultTab;
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import type {
|
||||
import { deserializeNotification } from "../model.ts";
|
||||
import { PageShell } from "../components/PageShell.tsx";
|
||||
import { friendlyFetchError } from "../utils/apiError.ts";
|
||||
import { expectOk } from "../components/form/index.ts";
|
||||
|
||||
type State =
|
||||
| { status: "loading" }
|
||||
@@ -269,13 +270,15 @@ export function Notifications() {
|
||||
const [state, setState] = useState<State>({ status: "loading" });
|
||||
|
||||
useEffect(() => {
|
||||
authFetch(
|
||||
`${API_URL}/api/notifications?page=1&limit=${NOTIFICATIONS_PAGE_SIZE}`,
|
||||
)
|
||||
.then((r) => r.json())
|
||||
.then((body) => {
|
||||
if (!body.success) throw new Error("Failed to load");
|
||||
const data = body.data as PaginatedData<RawNotification>;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const data = await expectOk<PaginatedData<RawNotification>>(
|
||||
await authFetch(
|
||||
`${API_URL}/api/notifications?page=1&limit=${NOTIFICATIONS_PAGE_SIZE}`,
|
||||
),
|
||||
);
|
||||
if (cancelled) return;
|
||||
setState({
|
||||
status: "loaded",
|
||||
items: data.items.map(deserializeNotification),
|
||||
@@ -283,25 +286,31 @@ export function Notifications() {
|
||||
page: 1,
|
||||
loadingMore: false,
|
||||
});
|
||||
return authFetch(`${API_URL}/api/notifications/read-all`, {
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setState({ status: "error", error: friendlyFetchError(err) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Marking them read is a separate matter: if it fails, the list that
|
||||
// loaded is still worth showing, and they stay unread for next time.
|
||||
try {
|
||||
const res = await authFetch(`${API_URL}/api/notifications/read-all`, {
|
||||
method: "POST",
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
if (cancelled || !res.ok) return;
|
||||
// Only the bell clears. The list keeps showing which ones were new
|
||||
// when the page opened: flipping them to read here, a moment after
|
||||
// they appear, would take the dots and the "new" count away before
|
||||
// anyone could see them.
|
||||
clearUnreadNotifications();
|
||||
setState((s) =>
|
||||
s.status === "loaded"
|
||||
? { ...s, items: s.items.map((n) => ({ ...n, read: true })) }
|
||||
: s
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err instanceof TypeError) {
|
||||
setState({ status: "error", error: friendlyFetchError(err) });
|
||||
} else if (err instanceof Error && err.message === "Failed to load") {
|
||||
setState({ status: "error", error: err.message });
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
// Offline: see above.
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [authFetch, clearUnreadNotifications]);
|
||||
|
||||
const [prevLastNotification, setPrevLastNotification] = useState(
|
||||
@@ -316,6 +325,18 @@ export function Notifications() {
|
||||
});
|
||||
}
|
||||
|
||||
// One that arrives while the page is open is being seen: it stays
|
||||
// highlighted here, but the bell shouldn't count it.
|
||||
const loaded = state.status === "loaded";
|
||||
useEffect(() => {
|
||||
if (!lastNotification || !loaded) return;
|
||||
authFetch(`${API_URL}/api/notifications/read-all`, { method: "POST" })
|
||||
.then((res) => {
|
||||
if (res.ok) clearUnreadNotifications();
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [lastNotification, loaded, authFetch, clearUnreadNotifications]);
|
||||
|
||||
const loadMore = () => {
|
||||
if (state.status !== "loaded" || !state.hasMore || state.loadingMore) {
|
||||
return;
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
deserializePlaylist,
|
||||
deserializePlaylistWithDumps,
|
||||
} from "../model.ts";
|
||||
import { playlistUrl } from "../utils/urls.ts";
|
||||
import { playlistImageUrl, playlistUrl } from "../utils/urls.ts";
|
||||
import { can } from "../utils/permissions.ts";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
|
||||
@@ -34,6 +34,7 @@ import { relativeTime } from "../utils/relativeTime.ts";
|
||||
import { DumpCard } from "../components/DumpCard.tsx";
|
||||
import { PageShell } from "../components/PageShell.tsx";
|
||||
import { PageError } from "../components/PageError.tsx";
|
||||
import { ErrorCard } from "../components/ErrorCard.tsx";
|
||||
import { ConfirmModal } from "../components/ConfirmModal.tsx";
|
||||
import { ImagePicker } from "../components/ImagePicker.tsx";
|
||||
import { Markdown } from "../components/Markdown.tsx";
|
||||
@@ -108,6 +109,8 @@ export function PlaylistDetail() {
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const headerRef = useHeightTransition<HTMLDivElement>(editOpen);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
// A delete, removal or reorder the server refused.
|
||||
const [actionError, setActionError] = 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.
|
||||
@@ -469,16 +472,21 @@ export function PlaylistDetail() {
|
||||
activeDumpIds.has(d.id)
|
||||
);
|
||||
try {
|
||||
await authFetch(`${API_URL}/api/playlists/${playlistId}/order`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(
|
||||
{
|
||||
dumpIds: activeDumps.map((d) => d.id),
|
||||
} satisfies ReorderPlaylistRequest,
|
||||
),
|
||||
});
|
||||
const res = await authFetch(
|
||||
`${API_URL}/api/playlists/${playlistId}/order`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(
|
||||
{
|
||||
dumpIds: activeDumps.map((d) => d.id),
|
||||
} satisfies ReorderPlaylistRequest,
|
||||
),
|
||||
},
|
||||
);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
} catch {
|
||||
// Back to the order the server actually kept.
|
||||
fetchPlaylist();
|
||||
}
|
||||
};
|
||||
@@ -488,10 +496,13 @@ export function PlaylistDetail() {
|
||||
// Fire-and-forget the API call; animate immediately
|
||||
authFetch(`${API_URL}/api/playlists/${playlistId}/dumps/${dumpId}`, {
|
||||
method: "DELETE",
|
||||
}).then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
}).catch(() => {
|
||||
// On failure, cancel the fade and restore the item
|
||||
// Refused or offline: cancel the fade and restore the item
|
||||
cancels.current.get(dumpId)?.();
|
||||
setActiveDumpIds((prev) => new Set([...prev, dumpId]));
|
||||
setActionError(t`The dump couldn't be removed. Try again.`);
|
||||
});
|
||||
setActiveDumpIds((prev) => {
|
||||
const n = new Set(prev);
|
||||
@@ -538,14 +549,34 @@ export function PlaylistDetail() {
|
||||
|
||||
const handleEditSaved = (updated: Playlist) => {
|
||||
setEditOpen(false);
|
||||
navigate(playlistUrl(updated), { replace: true });
|
||||
// Shown at once rather than left to the socket's echo, which may never
|
||||
// come (disconnected); a new slug also moves the URL.
|
||||
setState((prev) =>
|
||||
prev.status === "loaded"
|
||||
? {
|
||||
...prev,
|
||||
playlist: { ...prev.playlist, ...updated, dumps: prev.playlist.dumps },
|
||||
}
|
||||
: prev
|
||||
);
|
||||
if (state.status === "loaded" && updated.slug !== state.playlist.slug) {
|
||||
navigate(playlistUrl(updated), { replace: true });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!playlistId) return;
|
||||
await authFetch(`${API_URL}/api/playlists/${playlistId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
setConfirmDelete(false);
|
||||
setActionError(null);
|
||||
try {
|
||||
const res = await authFetch(`${API_URL}/api/playlists/${playlistId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok) await expectOk(res); // throws the server's message
|
||||
} catch (err) {
|
||||
setActionError(friendlyFetchError(err));
|
||||
return;
|
||||
}
|
||||
navigate("/");
|
||||
};
|
||||
|
||||
@@ -604,7 +635,7 @@ export function PlaylistDetail() {
|
||||
<>
|
||||
{playlist.imageMime && (
|
||||
<img
|
||||
src={`${API_URL}/api/playlists/${playlist.id}/image`}
|
||||
src={playlistImageUrl(playlist)}
|
||||
alt=""
|
||||
className="playlist-detail-img"
|
||||
/>
|
||||
@@ -676,6 +707,10 @@ export function PlaylistDetail() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{actionError && (
|
||||
<ErrorCard title={t`Something went wrong`} message={actionError} />
|
||||
)}
|
||||
|
||||
{visibleDumps.length === 0
|
||||
? (
|
||||
<p className="empty-state">
|
||||
@@ -819,10 +854,17 @@ function PlaylistEditForm(
|
||||
if (image) {
|
||||
const fd = new FormData();
|
||||
fd.append("file", image);
|
||||
await authFetch(`${API_URL}/api/playlists/${playlist.id}/image`, {
|
||||
method: "POST",
|
||||
body: fd,
|
||||
});
|
||||
// The upload answers with the playlist as it now stands (new cover,
|
||||
// new updatedAt), which is what the page should show.
|
||||
onSaved(deserializePlaylist(
|
||||
await expectOk<RawPlaylist>(
|
||||
await authFetch(`${API_URL}/api/playlists/${playlist.id}/image`, {
|
||||
method: "POST",
|
||||
body: fd,
|
||||
}),
|
||||
),
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
onSaved(updated);
|
||||
@@ -840,9 +882,7 @@ function PlaylistEditForm(
|
||||
render={({ field }) => (
|
||||
<ImagePicker
|
||||
src={imagePreview ??
|
||||
(playlist.imageMime
|
||||
? `${API_URL}/api/playlists/${playlist.id}/image`
|
||||
: null)}
|
||||
(playlist.imageMime ? playlistImageUrl(playlist) : null)}
|
||||
alt="Cover"
|
||||
size={72}
|
||||
className="animate-in"
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
type RawPublicUser,
|
||||
} from "../model.ts";
|
||||
import { DEFAULT_PAGE_SIZE, SEARCH_URL } from "../config/api.ts";
|
||||
import { friendlyFetchError } from "../utils/apiError.ts";
|
||||
|
||||
const SEARCH_TABS = ["dumps", "users", "playlists"] as const;
|
||||
type Tab = (typeof SEARCH_TABS)[number];
|
||||
@@ -86,7 +87,10 @@ export function Search() {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`Search failed (${res.status})`);
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => null);
|
||||
throw new Error(body?.error?.message ?? `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
const body = await res.json() as {
|
||||
success: true;
|
||||
@@ -133,7 +137,17 @@ export function Search() {
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as Error).name === "AbortError") return;
|
||||
setState({ status: "error", error: (err as Error).message });
|
||||
// A later page that fails leaves the results already shown; scrolling
|
||||
// back to the end tries it again.
|
||||
if (page > 1) {
|
||||
setState((prev) =>
|
||||
prev.status === "loaded"
|
||||
? { ...prev, dumps: { ...prev.dumps, loadingMore: false } }
|
||||
: prev
|
||||
);
|
||||
return;
|
||||
}
|
||||
setState({ status: "error", error: friendlyFetchError(err) });
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
|
||||
@@ -107,11 +107,15 @@ function ResetRequestPanel() {
|
||||
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() }),
|
||||
});
|
||||
// The answer is the same whether or not the address is known; only a
|
||||
// server that failed to take the request is worth reporting.
|
||||
await expectOk(
|
||||
await fetch(`${API_URL}/api/users/request-password-reset`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: email.trim() }),
|
||||
}),
|
||||
);
|
||||
setSent(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -36,6 +36,8 @@ import { ProfileSubpageHeader } from "../components/ProfileSubpageHeader.tsx";
|
||||
import { ConfirmModal } from "../components/ConfirmModal.tsx";
|
||||
import { PageShell } from "../components/PageShell.tsx";
|
||||
import { PageError } from "../components/PageError.tsx";
|
||||
import { ErrorCard } from "../components/ErrorCard.tsx";
|
||||
import { expectOk } from "../components/form/index.ts";
|
||||
|
||||
interface PlaylistFeed {
|
||||
items: Playlist[];
|
||||
@@ -321,10 +323,19 @@ export function UserPlaylists() {
|
||||
}
|
||||
}, [state.status, cachedCreated]);
|
||||
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
|
||||
const handleDelete = async (playlistId: string) => {
|
||||
await authFetch(`${API_URL}/api/playlists/${playlistId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
setDeleteError(null);
|
||||
try {
|
||||
const res = await authFetch(`${API_URL}/api/playlists/${playlistId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok) await expectOk(res); // throws the server's message
|
||||
} catch (err) {
|
||||
setDeleteError(friendlyFetchError(err));
|
||||
return;
|
||||
}
|
||||
setState((s) =>
|
||||
s.status === "loaded"
|
||||
? {
|
||||
@@ -385,6 +396,10 @@ export function UserPlaylists() {
|
||||
)}
|
||||
/>
|
||||
|
||||
{deleteError && (
|
||||
<ErrorCard title={t`Could not delete`} message={deleteError} />
|
||||
)}
|
||||
|
||||
<section className="profile-section">
|
||||
<div className="profile-section-header">
|
||||
<h2 className="profile-section-title">
|
||||
|
||||
@@ -701,6 +701,35 @@ export function UserPublicProfile() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tab, state.status, username]);
|
||||
|
||||
// There's no page of its own for who someone follows, so the list grows
|
||||
// here.
|
||||
const loadMoreFollowedUsers = () => {
|
||||
if (
|
||||
followedState?.status !== "loaded" || followedState.users.loadingMore
|
||||
) return;
|
||||
const nextPage = followedState.users.page + 1;
|
||||
const setUsers = (
|
||||
fn: (u: PaginatedList<PublicUser>) => PaginatedList<PublicUser>,
|
||||
) =>
|
||||
setFollowedState((s) =>
|
||||
s?.status === "loaded" ? { ...s, users: fn(s.users) } : s
|
||||
);
|
||||
setUsers((u) => ({ ...u, loadingMore: true }));
|
||||
fetch(
|
||||
`${API_URL}/api/users/${username}/followed-users?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`,
|
||||
)
|
||||
.then(expectOk<PaginatedData<RawPublicUser>>)
|
||||
.then(({ items, hasMore }) =>
|
||||
setUsers((u) => ({
|
||||
items: [...u.items, ...items.map(deserializePublicUser)],
|
||||
hasMore,
|
||||
page: nextPage,
|
||||
loadingMore: false,
|
||||
}))
|
||||
)
|
||||
.catch(() => setUsers((u) => ({ ...u, loadingMore: false })));
|
||||
};
|
||||
|
||||
// Restore scroll position after cache restoration
|
||||
const scrollRestored = useRef(false);
|
||||
useLayoutEffect(() => {
|
||||
@@ -863,7 +892,15 @@ export function UserPublicProfile() {
|
||||
<p
|
||||
className="profile-email-display"
|
||||
onClick={() => setEmailEditing(true)}
|
||||
title="Edit email"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setEmailEditing(true);
|
||||
}
|
||||
}}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
title={t`Edit email`}
|
||||
>
|
||||
{me?.email ?? t`Add email…`}
|
||||
<span className="profile-description-edit-btn" aria-hidden>
|
||||
@@ -913,6 +950,21 @@ export function UserPublicProfile() {
|
||||
onClick={isOwnProfile
|
||||
? () => setDescEditing(true)
|
||||
: undefined}
|
||||
// Reachable from the keyboard too; not a button, so the
|
||||
// markdown inside keeps its own structure.
|
||||
tabIndex={isOwnProfile ? 0 : undefined}
|
||||
title={isOwnProfile ? t`Edit description` : undefined}
|
||||
onKeyDown={isOwnProfile
|
||||
? (e) => {
|
||||
if (
|
||||
(e.key === "Enter" || e.key === " ") &&
|
||||
e.target === e.currentTarget
|
||||
) {
|
||||
e.preventDefault();
|
||||
setDescEditing(true);
|
||||
}
|
||||
}
|
||||
: undefined}
|
||||
>
|
||||
{profileUser.description
|
||||
? (
|
||||
@@ -1077,12 +1129,16 @@ export function UserPublicProfile() {
|
||||
))}
|
||||
</ul>
|
||||
{followedState.users.hasMore && (
|
||||
<Link
|
||||
to={`/users/${profileUser.username}/following`}
|
||||
className="profile-view-all"
|
||||
<button
|
||||
type="button"
|
||||
className="load-more-btn"
|
||||
onClick={loadMoreFollowedUsers}
|
||||
disabled={followedState.users.loadingMore}
|
||||
>
|
||||
<Trans>View all →</Trans>
|
||||
</Link>
|
||||
{followedState.users.loadingMore
|
||||
? <Trans>Loading…</Trans>
|
||||
: <Trans>Load more</Trans>}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -1496,13 +1552,19 @@ function UpvotedDumpList(
|
||||
useEffect(() => {
|
||||
if (!profileUserId || !isOwnProfile) return;
|
||||
if (prevMyVotesRef.current === null) {
|
||||
// setVotedIds + prevMyVotesRef must be co-located to stay consistent.
|
||||
setVotedIds(new Set(wsMyVotes));
|
||||
// Only a baseline: the list as loaded stands. myVotes may still be the
|
||||
// empty set from before the socket's welcome, and taking it as the
|
||||
// truth would hide every dump.
|
||||
prevMyVotesRef.current = new Set(wsMyVotes);
|
||||
return;
|
||||
}
|
||||
const prev = prevMyVotesRef.current;
|
||||
setVotedIds(new Set(wsMyVotes));
|
||||
setVotedIds((ids) => {
|
||||
const next = new Set(ids);
|
||||
for (const id of prev) if (!wsMyVotes.has(id)) next.delete(id);
|
||||
for (const id of wsMyVotes) next.add(id);
|
||||
return next;
|
||||
});
|
||||
for (const id of prev) if (!wsMyVotes.has(id)) startFading(id);
|
||||
for (const id of wsMyVotes) if (!prev.has(id)) cancelFading(id);
|
||||
prevMyVotesRef.current = new Set(wsMyVotes);
|
||||
@@ -1787,7 +1849,8 @@ function DescriptionEditor(
|
||||
const submitting = form.formState.isSubmitting;
|
||||
|
||||
const onSubmit = form.submit(async ({ description }) => {
|
||||
const trimmed = description.trim() || undefined;
|
||||
const trimmed = description.trim();
|
||||
// Sent even when empty: left out, the server would keep the old one.
|
||||
await expectOk<RawPublicUser>(
|
||||
await authFetch(`${API_URL}/api/users/me`, {
|
||||
method: "PATCH",
|
||||
@@ -1797,7 +1860,7 @@ function DescriptionEditor(
|
||||
),
|
||||
}),
|
||||
);
|
||||
onSaved(trimmed);
|
||||
onSaved(trimmed || undefined);
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -72,13 +72,20 @@ export function UserUpvoted() {
|
||||
useEffect(() => {
|
||||
if (!profileUserId || me?.id !== profileUserId) return;
|
||||
if (prevMyVotesRef.current === null) {
|
||||
// setVotedIds + prevMyVotesRef must be co-located to stay consistent.
|
||||
setVotedIds(new Set(myVotes));
|
||||
// Only a baseline: the list as loaded stands. myVotes may still be the
|
||||
// empty set from before the socket's welcome (or the socket may never
|
||||
// connect), and taking it as the truth here would hide every dump.
|
||||
prevMyVotesRef.current = new Set(myVotes);
|
||||
return;
|
||||
}
|
||||
const prev = prevMyVotesRef.current;
|
||||
setVotedIds(new Set(myVotes));
|
||||
// Votes cast or removed since — from this tab or another — show or fade.
|
||||
setVotedIds((ids) => {
|
||||
const next = new Set(ids);
|
||||
for (const id of prev) if (!myVotes.has(id)) next.delete(id);
|
||||
for (const id of myVotes) next.add(id);
|
||||
return next;
|
||||
});
|
||||
for (const id of prev) if (!myVotes.has(id)) startFading(id);
|
||||
for (const id of myVotes) if (!prev.has(id)) cancelFading(id);
|
||||
prevMyVotesRef.current = new Set(myVotes);
|
||||
|
||||
@@ -31,6 +31,16 @@ type FeedState =
|
||||
loadingMore: boolean;
|
||||
};
|
||||
|
||||
/** Page of a followed feed. Throws the server's own message on an error
|
||||
* envelope, so it isn't mistaken for a lost connection. */
|
||||
async function readPage(res: Response): Promise<PaginatedData<RawDump>> {
|
||||
const body = await res.json();
|
||||
if (!body.success) {
|
||||
throw new Error(body.error?.message ?? `HTTP ${res.status}`);
|
||||
}
|
||||
return body.data as PaginatedData<RawDump>;
|
||||
}
|
||||
|
||||
type FollowedSection = "users" | "playlists";
|
||||
|
||||
interface FollowedFeedProps {
|
||||
@@ -203,9 +213,8 @@ export function FollowedFeed({
|
||||
`${API_URL}/api/follows/feed/users?page=1&limit=${DEFAULT_PAGE_SIZE}${catQuery}`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
)
|
||||
.then((r) => r.json())
|
||||
.then((body) => {
|
||||
const { items, hasMore } = body.data as PaginatedData<RawDump>;
|
||||
.then(readPage)
|
||||
.then(({ items, hasMore }) => {
|
||||
setUsersState({
|
||||
status: "loaded",
|
||||
dumps: items.map(deserializeDump),
|
||||
@@ -223,9 +232,8 @@ export function FollowedFeed({
|
||||
`${API_URL}/api/follows/feed/playlists?page=1&limit=${DEFAULT_PAGE_SIZE}${catQuery}`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
)
|
||||
.then((r) => r.json())
|
||||
.then((body) => {
|
||||
const { items, hasMore } = body.data as PaginatedData<RawDump>;
|
||||
.then(readPage)
|
||||
.then(({ items, hasMore }) => {
|
||||
setPlaylistsState({
|
||||
status: "loaded",
|
||||
dumps: items.map(deserializeDump),
|
||||
@@ -278,9 +286,8 @@ export function FollowedFeed({
|
||||
`${API_URL}/api/follows/feed/users?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}${catQuery}`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
)
|
||||
.then((r) => r.json())
|
||||
.then((body) => {
|
||||
const { items, hasMore } = body.data as PaginatedData<RawDump>;
|
||||
.then(readPage)
|
||||
.then(({ items, hasMore }) => {
|
||||
setUsersState((s) =>
|
||||
s.status === "loaded"
|
||||
? {
|
||||
@@ -313,9 +320,8 @@ export function FollowedFeed({
|
||||
`${API_URL}/api/follows/feed/playlists?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}${catQuery}`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
)
|
||||
.then((r) => r.json())
|
||||
.then((body) => {
|
||||
const { items, hasMore } = body.data as PaginatedData<RawDump>;
|
||||
.then(readPage)
|
||||
.then(({ items, hasMore }) => {
|
||||
setPlaylistsState((s) =>
|
||||
s.status === "loaded"
|
||||
? {
|
||||
|
||||
Reference in New Issue
Block a user