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:
npeter83 2026-06-15 15:06:47 +02:00
parent 49cf2246b8
commit 8a82d56950
7 changed files with 103 additions and 9 deletions

View file

@ -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)}

View 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>
);
}

View file

@ -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"] });