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.
This commit is contained in:
parent
50bc35d78f
commit
48cb11434d
7 changed files with 103 additions and 9 deletions
|
|
@ -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)}
|
||||
|
|
|
|||
74
frontend/src/components/ConfirmProvider.tsx
Normal file
74
frontend/src/components/ConfirmProvider.tsx
Normal file
|
|
@ -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
|
||||
// <ConfirmProvider> 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<boolean>;
|
||||
|
||||
const ConfirmContext = createContext<ConfirmFn>(() => 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<ConfirmFn>(
|
||||
(opts) => new Promise<boolean>((resolve) => setState({ opts, resolve })),
|
||||
[]
|
||||
);
|
||||
|
||||
function close(ok: boolean) {
|
||||
state?.resolve(ok);
|
||||
setState(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfirmContext.Provider value={confirm}>
|
||||
{children}
|
||||
{state && (
|
||||
<Modal
|
||||
title={state.opts.title ?? t("common.confirmTitle")}
|
||||
onClose={() => close(false)}
|
||||
maxWidth="max-w-sm"
|
||||
>
|
||||
<p className="text-sm text-muted leading-relaxed">{state.opts.message}</p>
|
||||
<div className="flex justify-end gap-2 mt-5">
|
||||
<button
|
||||
onClick={() => close(false)}
|
||||
className="px-4 py-2 rounded-lg text-sm border border-border text-muted hover:text-fg hover:bg-surface transition"
|
||||
>
|
||||
{state.opts.cancelLabel ?? t("common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
autoFocus
|
||||
onClick={() => close(true)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-semibold transition ${
|
||||
state.opts.danger
|
||||
? "bg-red-500 text-white hover:bg-red-600"
|
||||
: "bg-accent text-accent-fg hover:opacity-90"
|
||||
}`}
|
||||
>
|
||||
{state.opts.confirmLabel ?? t("common.confirm")}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</ConfirmContext.Provider>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<number | null>(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"] });
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
"termsOfService": "Terms of Service",
|
||||
"close": "Close",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"confirmTitle": "Are you sure?",
|
||||
"save": "Save",
|
||||
"loading": "Loading…",
|
||||
"language": "Language",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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" ? <Terms /> : (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ErrorBoundary>
|
||||
<ConfirmProvider>
|
||||
<App />
|
||||
</ConfirmProvider>
|
||||
</ErrorBoundary>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue