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:
Binary file not shown.
@@ -202,6 +202,7 @@ function CommentNode({
|
||||
const [replyOpen, setReplyOpen] = useState(false);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [deleteFailed, setDeleteFailed] = useState(false);
|
||||
const innerRef = useHeightTransition<HTMLDivElement>(
|
||||
`${editOpen}:${replyOpen}`,
|
||||
);
|
||||
@@ -227,11 +228,14 @@ function CommentNode({
|
||||
|
||||
async function handleDelete() {
|
||||
if (!token) return;
|
||||
setDeleteFailed(false);
|
||||
const res = await authFetch(`${API_URL}/api/comments/${comment.id}`, {
|
||||
method: "DELETE",
|
||||
}).catch(() => null);
|
||||
if (res?.ok) {
|
||||
onCommentDeleted(comment.id);
|
||||
} else {
|
||||
setDeleteFailed(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -407,6 +411,11 @@ function CommentNode({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{deleteFailed && (
|
||||
<p className="form-hint form-hint--error" role="alert">
|
||||
<Trans>This comment couldn't be deleted. Try again.</Trans>
|
||||
</p>
|
||||
)}
|
||||
{replyOpen && (
|
||||
<CommentForm
|
||||
editorRef={replyEditorRef}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useId } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
@@ -15,20 +15,33 @@ export function ConfirmModal(
|
||||
{ message, confirmLabel, onConfirm, onCancel }: ConfirmModalProps,
|
||||
) {
|
||||
const label = confirmLabel ?? t`Delete`;
|
||||
const messageId = useId();
|
||||
useScrollLock();
|
||||
|
||||
useEffect(() => {
|
||||
// A confirm can sit on top of a Modal (deleting a chat message), whose
|
||||
// own Escape handler is also on the document and was added first. Take
|
||||
// the key in the capture phase and mark it handled, so the Modal leaves
|
||||
// it alone and only the confirm goes.
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onCancel();
|
||||
if (e.key !== "Escape" || e.defaultPrevented) return;
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
document.addEventListener("keydown", onKey, true);
|
||||
return () => document.removeEventListener("keydown", onKey, true);
|
||||
}, [onCancel]);
|
||||
|
||||
return createPortal(
|
||||
<div className="modal-backdrop" onClick={onCancel}>
|
||||
<div className="confirm-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<p className="confirm-modal-message">{message}</p>
|
||||
<div
|
||||
className="confirm-modal"
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-describedby={messageId}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<p className="confirm-modal-message" id={messageId}>{message}</p>
|
||||
<div className="confirm-modal-actions">
|
||||
<button type="button" className="btn-secondary" onClick={onCancel}>
|
||||
<Trans>Cancel</Trans>
|
||||
|
||||
@@ -20,7 +20,8 @@ export function FeedTabBar() {
|
||||
const defaultTab: FeedTab = preferredTab === "followed" && !user
|
||||
? DEFAULT_FEED_TAB
|
||||
: preferredTab;
|
||||
const active: FeedTab = (FEED_TABS as readonly string[]).includes(feedTab ?? "")
|
||||
const active: FeedTab = (FEED_TABS as readonly string[]).includes(feedTab ?? "") &&
|
||||
(feedTab !== "followed" || !!user)
|
||||
? (feedTab as FeedTab)
|
||||
: defaultTab;
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
VIEWBOX_W,
|
||||
WAVEFORM_H,
|
||||
} from "../utils/waveform.ts";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
|
||||
interface FilePreviewProps {
|
||||
dump: Dump;
|
||||
@@ -103,7 +105,7 @@ function AudioFilePreview(
|
||||
type="button"
|
||||
className="audio-player-btn"
|
||||
onClick={handlePlayBtn}
|
||||
aria-label={isPlaying ? "Pause" : "Play"}
|
||||
aria-label={isPlaying ? t`Pause` : t`Play`}
|
||||
>
|
||||
{isPlaying ? <IconPause /> : <IconPlay />}
|
||||
</button>
|
||||
@@ -208,7 +210,7 @@ export default function FilePreview(
|
||||
e.stopPropagation();
|
||||
play(filePlayerItem(dump, fileUrl, mime, token));
|
||||
}}
|
||||
aria-label="Play"
|
||||
aria-label={t`Play`}
|
||||
>
|
||||
<VideoThumb src={thumbUrl} fallback={mimeIcon(mime)} />
|
||||
<span className="rich-content-play-overlay" aria-hidden="true">
|
||||
@@ -229,7 +231,7 @@ export default function FilePreview(
|
||||
e.stopPropagation();
|
||||
play(filePlayerItem(dump, fileUrl, mime, token));
|
||||
}}
|
||||
aria-label="Play"
|
||||
aria-label={t`Play`}
|
||||
>
|
||||
{thumbOverride
|
||||
? <VideoThumb src={thumbOverride} fallback={mimeIcon(mime)} />
|
||||
@@ -264,6 +266,7 @@ export default function FilePreview(
|
||||
<button
|
||||
type="button"
|
||||
className={`file-preview-play-btn${videoActive ? " is-playing" : ""}`}
|
||||
aria-label={videoPlaying ? t`Pause` : t`Play`}
|
||||
onClick={() =>
|
||||
videoActive
|
||||
? togglePlay()
|
||||
@@ -311,7 +314,7 @@ export default function FilePreview(
|
||||
|
||||
return (
|
||||
<a href={fileUrl} download={dump.fileName} className="file-download-link">
|
||||
{mimeIcon(mime)} Download {dump.fileName}
|
||||
{mimeIcon(mime)} <Trans>Download {dump.fileName}</Trans>
|
||||
{dump.fileSize != null && ` (${formatBytes(dump.fileSize)})`}
|
||||
</a>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
WAVEFORM_H,
|
||||
} from "../utils/waveform.ts";
|
||||
import { fmt } from "../utils/duration.ts";
|
||||
import { t } from "@lingui/core/macro";
|
||||
|
||||
export const IconPlay = () => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" style={{ marginLeft: "2px" }}>
|
||||
@@ -170,10 +171,12 @@ function SeekBar(
|
||||
max={duration || 1}
|
||||
step={0.01}
|
||||
value={current}
|
||||
onMouseDown={() => onDragChange(true)}
|
||||
onMouseUp={() => onDragChange(false)}
|
||||
// Pointer events, so a finger holds the thumb as a mouse does.
|
||||
onPointerDown={() => onDragChange(true)}
|
||||
onPointerUp={() => onDragChange(false)}
|
||||
onPointerCancel={() => onDragChange(false)}
|
||||
onChange={(e) => onSeek(Number(e.target.value))}
|
||||
aria-label="Seek"
|
||||
aria-label={t`Seek`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -369,12 +372,27 @@ export function MediaPlayer(
|
||||
setPlaying(false);
|
||||
onErrorRef.current?.();
|
||||
};
|
||||
// The element can also be started or stopped from outside the page — a
|
||||
// media key, a headset, the OS's media controls — so follow it rather
|
||||
// than only what our own button did.
|
||||
const onPlayEvent = () => {
|
||||
setPlaying(true);
|
||||
onPlayStateChangeRef.current?.(true);
|
||||
};
|
||||
const onPauseEvent = () => {
|
||||
setPlaying(false);
|
||||
onPlayStateChangeRef.current?.(false);
|
||||
};
|
||||
a.addEventListener("timeupdate", onTime);
|
||||
a.addEventListener("play", onPlayEvent);
|
||||
a.addEventListener("pause", onPauseEvent);
|
||||
a.addEventListener("durationchange", onDuration);
|
||||
a.addEventListener("ended", onEndedEvent);
|
||||
a.addEventListener("error", onErrorEvent);
|
||||
return () => {
|
||||
a.removeEventListener("timeupdate", onTime);
|
||||
a.removeEventListener("play", onPlayEvent);
|
||||
a.removeEventListener("pause", onPauseEvent);
|
||||
a.removeEventListener("durationchange", onDuration);
|
||||
a.removeEventListener("ended", onEndedEvent);
|
||||
a.removeEventListener("error", onErrorEvent);
|
||||
@@ -462,7 +480,7 @@ export function MediaPlayer(
|
||||
type="button"
|
||||
className="audio-player-btn"
|
||||
onClick={toggle}
|
||||
aria-label={playing ? "Pause" : "Play"}
|
||||
aria-label={playing ? t`Pause` : t`Play`}
|
||||
>
|
||||
{playing ? <IconPause /> : <IconPlay />}
|
||||
</button>
|
||||
@@ -478,7 +496,7 @@ export function MediaPlayer(
|
||||
type="button"
|
||||
className="audio-player-vol-btn"
|
||||
onClick={toggleMute}
|
||||
aria-label={muted ? "Unmute" : "Mute"}
|
||||
aria-label={muted ? t`Unmute` : t`Mute`}
|
||||
>
|
||||
<IconVolume muted={muted} />
|
||||
</button>
|
||||
@@ -495,7 +513,7 @@ export function MediaPlayer(
|
||||
step={0.01}
|
||||
value={muted ? 0 : volume}
|
||||
onChange={changeVolume}
|
||||
aria-label="Volume"
|
||||
aria-label={t`Volume`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -505,7 +523,7 @@ export function MediaPlayer(
|
||||
type="button"
|
||||
className="audio-player-vol-btn"
|
||||
onClick={goFullscreen}
|
||||
aria-label="Fullscreen"
|
||||
aria-label={t`Fullscreen`}
|
||||
>
|
||||
<IconFullscreen />
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type ReactNode, useCallback, useEffect, useRef } from "react";
|
||||
import { type ReactNode, useCallback, useEffect, useId, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useScrollLock } from "../hooks/useScrollLock.ts";
|
||||
@@ -20,6 +20,7 @@ export function Modal(
|
||||
{ title, onClose, children, wide = false, onBeforeClose }: ModalProps,
|
||||
) {
|
||||
const backdropRef = useRef<HTMLDivElement>(null);
|
||||
const titleId = useId();
|
||||
|
||||
const requestClose = useCallback(() => {
|
||||
if (onBeforeClose?.() === false) return;
|
||||
@@ -44,9 +45,14 @@ export function Modal(
|
||||
if (e.target === backdropRef.current) requestClose();
|
||||
}}
|
||||
>
|
||||
<div className={`modal-card${wide ? " modal-card--wide" : ""}`}>
|
||||
<div
|
||||
className={`modal-card${wide ? " modal-card--wide" : ""}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
>
|
||||
<div className="modal-header">
|
||||
<span className="modal-title">{title}</span>
|
||||
<span className="modal-title" id={titleId}>{title}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="modal-close-btn"
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Plural, Trans } from "@lingui/react/macro";
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import type { Playlist } from "../model.ts";
|
||||
import { relativeTime } from "../utils/relativeTime.ts";
|
||||
import { playlistUrl } from "../utils/urls.ts";
|
||||
import { playlistImageUrl, playlistUrl } from "../utils/urls.ts";
|
||||
import {
|
||||
isPlaylistVisited,
|
||||
isRecent,
|
||||
@@ -40,7 +39,7 @@ export function PlaylistCard(
|
||||
{playlist.imageMime
|
||||
? (
|
||||
<img
|
||||
src={`${API_URL}/api/playlists/${playlist.id}/image`}
|
||||
src={playlistImageUrl(playlist)}
|
||||
alt=""
|
||||
className="playlist-card-img"
|
||||
/>
|
||||
|
||||
@@ -42,9 +42,18 @@ export function PlaylistMembershipPanel({
|
||||
className={`playlist-membership-row${
|
||||
m.hasDump ? " playlist-membership-row--active" : ""
|
||||
}`}
|
||||
role="checkbox"
|
||||
aria-checked={m.hasDump}
|
||||
tabIndex={0}
|
||||
onClick={() => onToggle(m)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onToggle(m);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="playlist-membership-check">
|
||||
<span className="playlist-membership-check" aria-hidden="true">
|
||||
{m.hasDump ? "✓" : "○"}
|
||||
</span>
|
||||
<span className="playlist-membership-name">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useContext } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import type { RichContent } from "../model.ts";
|
||||
import { PlayerContext } from "../contexts/PlayerContext.ts";
|
||||
import {
|
||||
@@ -79,7 +80,7 @@ export default function RichContentCard(
|
||||
if (isActive && current?.kind === "stream") togglePlay();
|
||||
else void playOrOpen();
|
||||
}}
|
||||
aria-label={isPlaying ? "Pause" : "Play"}
|
||||
aria-label={isPlaying ? t`Pause` : t`Play`}
|
||||
>
|
||||
{thumbnail}
|
||||
<span className="rich-content-play-overlay">
|
||||
@@ -97,8 +98,8 @@ export default function RichContentCard(
|
||||
className="rich-content-compact"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={richContent.siteName
|
||||
? `Open on ${richContent.siteName}`
|
||||
: "Open source"}
|
||||
? t`Open on ${richContent.siteName}`
|
||||
: t`Open source`}
|
||||
>
|
||||
{thumbnail}
|
||||
<span className="rich-content-open-overlay" aria-hidden="true">
|
||||
@@ -125,7 +126,7 @@ export default function RichContentCard(
|
||||
className={`rich-content-thumbnail-btn${pending ? " is-pending" : ""}`}
|
||||
disabled={pending}
|
||||
onClick={() => void playOrOpen()}
|
||||
aria-label="Play"
|
||||
aria-label={t`Play`}
|
||||
>
|
||||
{thumbnailImg}
|
||||
<span className="rich-content-play-overlay">▶</span>
|
||||
|
||||
@@ -169,7 +169,13 @@ export function UserListPopover(
|
||||
loadingMore: false,
|
||||
};
|
||||
});
|
||||
});
|
||||
})
|
||||
// Offline or an unreadable reply: give the button back for a retry.
|
||||
.catch(() =>
|
||||
setState((prev) =>
|
||||
prev.status === "loaded" ? { ...prev, loadingMore: false } : prev
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
if (!placement) return null;
|
||||
|
||||
@@ -7,6 +7,8 @@ interface SubmitButtonProps {
|
||||
pendingLabel?: ReactNode;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
/** For an icon-only button (e.g. "✓"), its accessible name. */
|
||||
"aria-label"?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -19,11 +21,17 @@ export function SubmitButton({
|
||||
pendingLabel,
|
||||
disabled,
|
||||
className = "btn-primary",
|
||||
"aria-label": ariaLabel,
|
||||
}: SubmitButtonProps) {
|
||||
const { isSubmitting } = useFormState();
|
||||
|
||||
return (
|
||||
<button type="submit" className={className} disabled={isSubmitting || disabled}>
|
||||
<button
|
||||
type="submit"
|
||||
className={className}
|
||||
disabled={isSubmitting || disabled}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{isSubmitting && pendingLabel != null ? pendingLabel : children}
|
||||
</button>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user