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
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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue