From 48cb11434d4bc8328f539c063f5101957a7a9bb7 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 15 Jun 2026 15:06:47 +0200 Subject: [PATCH] feat(ui): reusable app-styled confirm dialog, replacing window.confirm Add a promise-based ConfirmProvider + useConfirm() hook (built on the Modal shell, liquid-glass styling, danger variant, Esc/backdrop = cancel, Enter = confirm) so confirmations match the app instead of the native browser dialog. Wire it in at the app root and replace both window.confirm call sites (playlist delete, channel unsubscribe). Trilingual common.confirm/confirmTitle strings. --- frontend/src/components/Channels.tsx | 17 +++-- frontend/src/components/ConfirmProvider.tsx | 74 +++++++++++++++++++++ frontend/src/components/Playlists.tsx | 10 ++- frontend/src/i18n/locales/de/common.json | 2 + frontend/src/i18n/locales/en/common.json | 2 + frontend/src/i18n/locales/hu/common.json | 2 + frontend/src/main.tsx | 5 +- 7 files changed, 103 insertions(+), 9 deletions(-) create mode 100644 frontend/src/components/ConfirmProvider.tsx diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index 2e32004..3db9eb1 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -18,6 +18,7 @@ import { formatEta } from "../lib/format"; import { notify } from "../lib/notifications"; import Tooltip from "./Tooltip"; import Avatar from "./Avatar"; +import { useConfirm } from "./ConfirmProvider"; export type ChannelStatusFilter = "all" | "needs_full" | "fully_synced" | "hidden"; @@ -43,6 +44,7 @@ export default function Channels({ }) { const { t } = useTranslation(); const qc = useQueryClient(); + const confirm = useConfirm(); // A YouTube-gated action (sync, backfill, unsubscribe) that 403s means the user hasn't // granted the needed scope — surface that with a "Connect" action instead of a vague fail. @@ -309,13 +311,14 @@ export default function Channels({ c={c} userTags={userTags} canWrite={canWrite} - onUnsubscribe={() => { - if ( - window.confirm( - t("channels.confirmUnsubscribe", { name: c.title ?? c.id }) - ) - ) - unsubscribe.mutate(c.id); + onUnsubscribe={async () => { + const ok = await confirm({ + title: t("channels.row.unsubscribeOnYoutube"), + message: t("channels.confirmUnsubscribe", { name: c.title ?? c.id }), + confirmLabel: t("channels.row.unsubscribeOnYoutube"), + danger: true, + }); + if (ok) unsubscribe.mutate(c.id); }} onView={() => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"))} onPriority={(d) => bumpPriority(c.id, c.priority, d)} diff --git a/frontend/src/components/ConfirmProvider.tsx b/frontend/src/components/ConfirmProvider.tsx new file mode 100644 index 0000000..6794a9b --- /dev/null +++ b/frontend/src/components/ConfirmProvider.tsx @@ -0,0 +1,74 @@ +import { createContext, useCallback, useContext, useState, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import Modal from "./Modal"; + +// App-styled, promise-based replacement for window.confirm. Wrap the tree in +// once, then anywhere: const confirm = useConfirm(); +// if (await confirm({ message: "…", danger: true })) { … } +export interface ConfirmOptions { + title?: string; + message: ReactNode; + confirmLabel?: string; + cancelLabel?: string; + danger?: boolean; +} + +type ConfirmFn = (opts: ConfirmOptions) => Promise; + +const ConfirmContext = createContext(() => Promise.resolve(false)); + +export function useConfirm(): ConfirmFn { + return useContext(ConfirmContext); +} + +export function ConfirmProvider({ children }: { children: ReactNode }) { + const { t } = useTranslation(); + const [state, setState] = useState<{ + opts: ConfirmOptions; + resolve: (ok: boolean) => void; + } | null>(null); + + const confirm = useCallback( + (opts) => new Promise((resolve) => setState({ opts, resolve })), + [] + ); + + function close(ok: boolean) { + state?.resolve(ok); + setState(null); + } + + return ( + + {children} + {state && ( + close(false)} + maxWidth="max-w-sm" + > +

{state.opts.message}

+
+ + +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx index e52f3c9..d9dd279 100644 --- a/frontend/src/components/Playlists.tsx +++ b/frontend/src/components/Playlists.tsx @@ -29,6 +29,7 @@ import { import { api, type Playlist, type Video } from "../lib/api"; import { formatDuration } from "../lib/format"; import PlayerModal from "./PlayerModal"; +import { useConfirm } from "./ConfirmProvider"; function Row({ video, @@ -98,6 +99,7 @@ function Row({ export default function Playlists() { const { t } = useTranslation(); const qc = useQueryClient(); + const confirm = useConfirm(); const [selectedId, setSelectedId] = useState(null); const [newName, setNewName] = useState(""); const [renaming, setRenaming] = useState(false); @@ -173,7 +175,13 @@ export default function Playlists() { async function deletePlaylist() { if (selectedId == null || !detail) return; - if (!window.confirm(t("playlists.confirmDelete", { name: detail.name }))) return; + const ok = await confirm({ + title: t("playlists.delete"), + message: t("playlists.confirmDelete", { name: detail.name }), + confirmLabel: t("playlists.delete"), + danger: true, + }); + if (!ok) return; await api.deletePlaylist(selectedId); setSelectedId(null); qc.invalidateQueries({ queryKey: ["playlists"] }); diff --git a/frontend/src/i18n/locales/de/common.json b/frontend/src/i18n/locales/de/common.json index fe81995..96a3e73 100644 --- a/frontend/src/i18n/locales/de/common.json +++ b/frontend/src/i18n/locales/de/common.json @@ -3,6 +3,8 @@ "termsOfService": "Nutzungsbedingungen", "close": "Schließen", "cancel": "Abbrechen", + "confirm": "Bestätigen", + "confirmTitle": "Bist du sicher?", "save": "Speichern", "loading": "Wird geladen…", "language": "Sprache", diff --git a/frontend/src/i18n/locales/en/common.json b/frontend/src/i18n/locales/en/common.json index f594fe5..a33b2b4 100644 --- a/frontend/src/i18n/locales/en/common.json +++ b/frontend/src/i18n/locales/en/common.json @@ -3,6 +3,8 @@ "termsOfService": "Terms of Service", "close": "Close", "cancel": "Cancel", + "confirm": "Confirm", + "confirmTitle": "Are you sure?", "save": "Save", "loading": "Loading…", "language": "Language", diff --git a/frontend/src/i18n/locales/hu/common.json b/frontend/src/i18n/locales/hu/common.json index 4584d03..d155cd1 100644 --- a/frontend/src/i18n/locales/hu/common.json +++ b/frontend/src/i18n/locales/hu/common.json @@ -3,6 +3,8 @@ "termsOfService": "Felhasználási feltételek", "close": "Bezárás", "cancel": "Mégse", + "confirm": "Megerősítés", + "confirmTitle": "Biztos vagy benne?", "save": "Mentés", "loading": "Betöltés…", "language": "Nyelv", diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 44320e3..88bb2c6 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import App from "./App"; import ErrorBoundary from "./components/ErrorBoundary"; +import { ConfirmProvider } from "./components/ConfirmProvider"; import PrivacyPolicy from "./components/legal/PrivacyPolicy"; import Terms from "./components/legal/Terms"; import "./i18n"; @@ -21,7 +22,9 @@ const root = path === "/terms" ? : ( - + + + );