- Web Push (VAPID + RFC 8291, pure Rust): permission/question/finished notifications while no app window is visible; settings with test button - Guided new-session flow: task, craft target (website/app/game/old software/fresh), clean room or deep, description, model, folder (new, existing or cloned git repository); originals upload to original/ - Craft phase bar (craft_phase tool) for study/specify/build/check/ready - Chunked uploads (8 MB) up to server.max_upload_mb (default 500 MB) - All UI glyphs and emojis replaced by themeable SVG icons - Rename dialog with manual name or model-generated suggestion Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
144 lines
4.7 KiB
TypeScript
144 lines
4.7 KiB
TypeScript
import { Icon } from "./components/Icon";
|
|
// In-app dialogs and toasts instead of the browser's alert/confirm/prompt.
|
|
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
import { t } from "./i18n";
|
|
|
|
interface ConfirmOpts {
|
|
title?: string;
|
|
message: string;
|
|
confirmLabel?: string;
|
|
/** Destructive action: red button. */
|
|
danger?: boolean;
|
|
}
|
|
|
|
interface PromptOpts {
|
|
title?: string;
|
|
message: string;
|
|
defaultValue?: string;
|
|
placeholder?: string;
|
|
password?: boolean;
|
|
confirmLabel?: string;
|
|
}
|
|
|
|
type Dialog =
|
|
| ({ kind: "confirm"; id: number; resolve: (v: boolean) => void } & ConfirmOpts)
|
|
| ({ kind: "prompt"; id: number; resolve: (v: string | null) => void } & PromptOpts);
|
|
|
|
interface Toast {
|
|
id: number;
|
|
message: string;
|
|
kind: "error" | "info" | "success";
|
|
}
|
|
|
|
let dialogs: Dialog[] = [];
|
|
let toasts: Toast[] = [];
|
|
let seq = 0;
|
|
const listeners = new Set<() => void>();
|
|
const emit = () => listeners.forEach((l) => l());
|
|
const subscribe = (l: () => void) => (listeners.add(l), () => listeners.delete(l));
|
|
|
|
export function confirmDialog(opts: ConfirmOpts | string): Promise<boolean> {
|
|
const o = typeof opts === "string" ? { message: opts } : opts;
|
|
return new Promise((resolve) => {
|
|
dialogs = [...dialogs, { kind: "confirm", id: ++seq, resolve, ...o }];
|
|
emit();
|
|
});
|
|
}
|
|
|
|
export function promptDialog(opts: PromptOpts | string): Promise<string | null> {
|
|
const o = typeof opts === "string" ? { message: opts } : opts;
|
|
return new Promise((resolve) => {
|
|
dialogs = [...dialogs, { kind: "prompt", id: ++seq, resolve, ...o }];
|
|
emit();
|
|
});
|
|
}
|
|
|
|
export function toast(message: unknown, kind: Toast["kind"] = "error") {
|
|
const text = message instanceof Error ? message.message : typeof message === "string" ? message : String((message as any)?.message ?? message);
|
|
const id = ++seq;
|
|
toasts = [...toasts, { id, message: text, kind }];
|
|
emit();
|
|
setTimeout(() => {
|
|
toasts = toasts.filter((x) => x.id !== id);
|
|
emit();
|
|
}, kind === "error" ? 7000 : 3500);
|
|
}
|
|
|
|
function close(d: Dialog, value: any) {
|
|
dialogs = dialogs.filter((x) => x.id !== d.id);
|
|
emit();
|
|
(d.resolve as (v: any) => void)(value);
|
|
}
|
|
|
|
function DialogView({ d }: { d: Dialog }) {
|
|
const [value, setValue] = useState(d.kind === "prompt" ? d.defaultValue ?? "" : "");
|
|
const input = useRef<HTMLInputElement>(null);
|
|
const ok = useRef<HTMLButtonElement>(null);
|
|
const cancelValue = d.kind === "confirm" ? false : null;
|
|
useEffect(() => {
|
|
if (d.kind === "prompt") {
|
|
input.current?.focus();
|
|
input.current?.select();
|
|
} else ok.current?.focus();
|
|
const h = (e: KeyboardEvent) => e.key === "Escape" && close(d, cancelValue);
|
|
window.addEventListener("keydown", h);
|
|
return () => window.removeEventListener("keydown", h);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
const submit = () => close(d, d.kind === "confirm" ? true : value);
|
|
return (
|
|
<div className="modal-backdrop" onClick={() => close(d, cancelValue)}>
|
|
<form
|
|
className="modal dialog"
|
|
role={d.kind === "confirm" ? "alertdialog" : "dialog"}
|
|
aria-label={d.title ?? d.message}
|
|
onClick={(e) => e.stopPropagation()}
|
|
onSubmit={(e) => (e.preventDefault(), submit())}
|
|
>
|
|
{d.title && <h3>{d.title}</h3>}
|
|
<p className="dialog-message">{d.message}</p>
|
|
{d.kind === "prompt" && (
|
|
<input
|
|
ref={input}
|
|
type={d.password ? "password" : "text"}
|
|
value={value}
|
|
placeholder={d.placeholder}
|
|
onChange={(e) => setValue(e.target.value)}
|
|
autoComplete={d.password ? "new-password" : "off"}
|
|
style={{ width: "100%" }}
|
|
/>
|
|
)}
|
|
<div className="row dialog-actions">
|
|
<button type="button" onClick={() => close(d, cancelValue)}>
|
|
{t("common.cancel")}
|
|
</button>
|
|
<button ref={ok} type="submit" className={d.kind === "confirm" && d.danger ? "danger-solid" : "primary"}>
|
|
{d.confirmLabel ?? t("common.ok")}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Renders open dialogs and toasts; mounted once in App. */
|
|
export function DialogHost() {
|
|
const state = useSyncExternalStore(subscribe, () => dialogs);
|
|
const ts = useSyncExternalStore(subscribe, () => toasts);
|
|
return (
|
|
<>
|
|
{state.map((d) => (
|
|
<DialogView key={d.id} d={d} />
|
|
))}
|
|
<div className="toasts" role="status" aria-live="polite">
|
|
{ts.map((x) => (
|
|
<div key={x.id} className={`toast ${x.kind}`} onClick={() => ((toasts = toasts.filter((y) => y.id !== x.id)), emit())}>
|
|
{x.kind === "error" ? <Icon name="warning" /> : x.kind === "success" ? <Icon name="check" /> : null}
|
|
{x.message}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|