Merge: R1 S3 — i18n + a11y nits (modal/close i18n, Toaster live region, unsubscribe error)
This commit is contained in:
@@ -120,6 +120,9 @@ export default function ChannelPage({
|
|||||||
qc.invalidateQueries({ queryKey: ["feed"] });
|
qc.invalidateQueries({ queryKey: ["feed"] });
|
||||||
qc.invalidateQueries({ queryKey: ["blocked-channels"] });
|
qc.invalidateQueries({ queryKey: ["blocked-channels"] });
|
||||||
},
|
},
|
||||||
|
// No onError: block/unblock is a local DB op (not a YouTube write, so no 403 "connect" case),
|
||||||
|
// and its 400/409/422/500 are already surfaced by the global error modal (see api.ts req) —
|
||||||
|
// a caller toast here would just double it.
|
||||||
});
|
});
|
||||||
|
|
||||||
const subscribe = useMutation({
|
const subscribe = useMutation({
|
||||||
@@ -144,6 +147,7 @@ export default function ChannelPage({
|
|||||||
qc.invalidateQueries({ queryKey: ["feed"] });
|
qc.invalidateQueries({ queryKey: ["feed"] });
|
||||||
qc.invalidateQueries({ queryKey: ["channels"] });
|
qc.invalidateQueries({ queryKey: ["channels"] });
|
||||||
},
|
},
|
||||||
|
onError: (err) => notifyYouTubeActionError(err, t("channels.notify.unsubscribeFailed")),
|
||||||
});
|
});
|
||||||
const onUnsubscribe = async () => {
|
const onUnsubscribe = async () => {
|
||||||
const ok = await confirm({
|
const ok = await confirm({
|
||||||
|
|||||||
@@ -681,6 +681,8 @@ export default function DownloadCenter() {
|
|||||||
? api.cancelDownload(id)
|
? api.cancelDownload(id)
|
||||||
: api.deleteDownload(id),
|
: api.deleteDownload(id),
|
||||||
onSuccess: invalidate,
|
onSuccess: invalidate,
|
||||||
|
// No onError: a failed job action returns 400/409/422/500, already surfaced by the global
|
||||||
|
// error modal (api.ts req) with the backend's specific message — a caller toast would double it.
|
||||||
});
|
});
|
||||||
|
|
||||||
// Remove a shared-with-me item from your list (only your grant; the owner's file is untouched).
|
// Remove a shared-with-me item from your list (only your grant; the owner's file is untouched).
|
||||||
|
|||||||
@@ -255,7 +255,7 @@ export default function Feed({
|
|||||||
meta: {
|
meta: {
|
||||||
kind: "video-hidden",
|
kind: "video-hidden",
|
||||||
videoId: id,
|
videoId: id,
|
||||||
title: v?.title ?? "this video",
|
title: v?.title ?? i18n.t("feed.thisVideo"),
|
||||||
channelId: v?.channel_id ?? "",
|
channelId: v?.channel_id ?? "",
|
||||||
channelName: v?.channel_title ?? "",
|
channelName: v?.channel_title ?? "",
|
||||||
},
|
},
|
||||||
@@ -270,7 +270,7 @@ export default function Feed({
|
|||||||
meta: {
|
meta: {
|
||||||
kind: "video-watched",
|
kind: "video-watched",
|
||||||
videoId: id,
|
videoId: id,
|
||||||
title: v?.title ?? "this video",
|
title: v?.title ?? i18n.t("feed.thisVideo"),
|
||||||
channelId: v?.channel_id ?? "",
|
channelId: v?.channel_id ?? "",
|
||||||
channelName: v?.channel_title ?? "",
|
channelName: v?.channel_title ?? "",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useRef, type ReactNode } from "react";
|
import { useEffect, useRef, type ReactNode } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { useBackToClose } from "../lib/history";
|
import { useBackToClose } from "../lib/history";
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ export default function Modal({
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
maxWidth?: string;
|
maxWidth?: string;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
// Browser/mouse Back closes the topmost modal instead of navigating away.
|
// Browser/mouse Back closes the topmost modal instead of navigating away.
|
||||||
useBackToClose(onClose);
|
useBackToClose(onClose);
|
||||||
|
|
||||||
@@ -76,7 +78,8 @@ export default function Modal({
|
|||||||
<h2 className="text-lg font-semibold leading-snug">{title}</h2>
|
<h2 className="text-lg font-semibold leading-snug">{title}</h2>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
title="Close"
|
title={t("common.close")}
|
||||||
|
aria-label={t("common.close")}
|
||||||
className="shrink-0 p-1.5 rounded-lg text-muted hover:text-fg hover:bg-surface transition"
|
className="shrink-0 p-1.5 rounded-lg text-muted hover:text-fg hover:bg-surface transition"
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
|
|||||||
@@ -533,6 +533,9 @@ function PlexWatchSync() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// No onError on these: they're Plex, not YouTube (notifyYouTubeActionError would wrongly say
|
||||||
|
// "connect YouTube"), and their 409/500 are already surfaced by the global error modal — a caller
|
||||||
|
// toast would double it. Actual import failures come back as 200 {error}, handled by announce.
|
||||||
const toggle = useMutation({
|
const toggle = useMutation({
|
||||||
mutationFn: (next: boolean) => api.plexWatchSetLink(next),
|
mutationFn: (next: boolean) => api.plexWatchSetLink(next),
|
||||||
onSuccess: (res) => {
|
onSuccess: (res) => {
|
||||||
|
|||||||
@@ -16,12 +16,23 @@ export default function Toaster() {
|
|||||||
const toasts = useSyncExternalStore(subscribe, getActiveToasts, getActiveToasts);
|
const toasts = useSyncExternalStore(subscribe, getActiveToasts, getActiveToasts);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute bottom-4 left-4 z-overlay flex flex-col gap-2 w-80 max-w-[calc(100vw-2rem)]">
|
// Stable, always-mounted polite live region — a polite region must PRE-EXIST in the DOM for a
|
||||||
|
// screen reader to announce content later inserted into it (a region created together with its
|
||||||
|
// first message is unreliably announced). `aria-live` (not role="status") on purpose:
|
||||||
|
// role="status" implies aria-atomic="true", which would re-read the WHOLE stack on every toast;
|
||||||
|
// aria-live defaults atomic=false, so only the newly-added toast is spoken. Error/fatal toasts
|
||||||
|
// additionally carry role="alert", which announces assertively on insertion regardless.
|
||||||
|
<div
|
||||||
|
aria-live="polite"
|
||||||
|
className="absolute bottom-4 left-4 z-overlay flex flex-col gap-2 w-80 max-w-[calc(100vw-2rem)]"
|
||||||
|
>
|
||||||
{toasts.map((toast) => {
|
{toasts.map((toast) => {
|
||||||
const { icon: Icon, color, bar } = LEVEL_STYLE[toast.level];
|
const { icon: Icon, color, bar } = LEVEL_STYLE[toast.level];
|
||||||
|
const urgent = toast.level === "error" || toast.level === "fatal";
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={toast.id}
|
key={toast.id}
|
||||||
|
role={urgent ? "alert" : undefined}
|
||||||
className="toast-card glass relative overflow-hidden rounded-xl px-3 py-3 flex items-start gap-3 animate-[popIn_0.16s_ease]"
|
className="toast-card glass relative overflow-hidden rounded-xl px-3 py-3 flex items-start gap-3 animate-[popIn_0.16s_ease]"
|
||||||
>
|
>
|
||||||
<Icon className={`w-5 h-5 shrink-0 mt-0.5 ${color}`} />
|
<Icon className={`w-5 h-5 shrink-0 mt-0.5 ${color}`} />
|
||||||
|
|||||||
@@ -212,6 +212,7 @@ function Preview({
|
|||||||
// Full-size image viewer: a dimmed, blurred backdrop with the screenshot fit to the viewport
|
// Full-size image viewer: a dimmed, blurred backdrop with the screenshot fit to the viewport
|
||||||
// (aspect ratio preserved via object-contain). Closes on backdrop click, the ✕, or Escape.
|
// (aspect ratio preserved via object-contain). Closes on backdrop click, the ✕, or Escape.
|
||||||
function Lightbox({ src, label, onClose }: { src: string; label: string; onClose: () => void }) {
|
function Lightbox({ src, label, onClose }: { src: string; label: string; onClose: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape") onClose();
|
if (e.key === "Escape") onClose();
|
||||||
@@ -235,7 +236,7 @@ function Lightbox({ src, label, onClose }: { src: string; label: string; onClose
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
aria-label="Close"
|
aria-label={t("common.close")}
|
||||||
className="absolute top-4 right-4 p-2 rounded-full glass-card glass-hover text-fg"
|
className="absolute top-4 right-4 p-2 rounded-full glass-card glass-hover text-fg"
|
||||||
>
|
>
|
||||||
<X className="w-5 h-5" />
|
<X className="w-5 h-5" />
|
||||||
|
|||||||
@@ -45,6 +45,7 @@
|
|||||||
"undo": "Undo",
|
"undo": "Undo",
|
||||||
"markedWatchedNamed": "Marked watched “{{title}}”",
|
"markedWatchedNamed": "Marked watched “{{title}}”",
|
||||||
"markedWatched": "Marked watched",
|
"markedWatched": "Marked watched",
|
||||||
|
"thisVideo": "this video",
|
||||||
"unwatch": "Unwatch",
|
"unwatch": "Unwatch",
|
||||||
"source": {
|
"source": {
|
||||||
"label": "Source",
|
"label": "Source",
|
||||||
|
|||||||
@@ -45,6 +45,7 @@
|
|||||||
"undo": "Visszavonás",
|
"undo": "Visszavonás",
|
||||||
"markedWatchedNamed": "Megnézettnek jelölve: „{{title}}”",
|
"markedWatchedNamed": "Megnézettnek jelölve: „{{title}}”",
|
||||||
"markedWatched": "Megnézettnek jelölve",
|
"markedWatched": "Megnézettnek jelölve",
|
||||||
|
"thisVideo": "ez a videó",
|
||||||
"unwatch": "Megnézés visszavonása",
|
"unwatch": "Megnézés visszavonása",
|
||||||
"source": {
|
"source": {
|
||||||
"label": "Forrás",
|
"label": "Forrás",
|
||||||
|
|||||||
Reference in New Issue
Block a user