2026-06-17 17:06:30 -06:00
|
|
|
|
/**
|
2026-06-17 22:07:18 -06:00
|
|
|
|
* Transient UI state — toasts plus the persistent notification feed shown in the
|
|
|
|
|
|
* top-bar bell. `notify()` raises a transient toast and records a notification
|
|
|
|
|
|
* entry (so uploads, errors and successes accumulate in the bell). Component-local
|
|
|
|
|
|
* state is preferred; only state that must cross component boundaries lives here.
|
2026-06-17 17:06:30 -06:00
|
|
|
|
*/
|
|
|
|
|
|
export type ToastKind = 'info' | 'success' | 'error' | 'warning';
|
|
|
|
|
|
|
|
|
|
|
|
export interface Toast {
|
|
|
|
|
|
id: number;
|
|
|
|
|
|
message: string;
|
|
|
|
|
|
kind: ToastKind;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-17 22:07:18 -06:00
|
|
|
|
export interface Notification {
|
|
|
|
|
|
id: number;
|
|
|
|
|
|
message: string;
|
|
|
|
|
|
kind: ToastKind;
|
|
|
|
|
|
at: number;
|
|
|
|
|
|
read: boolean;
|
|
|
|
|
|
/** 0–100 while an operation is in progress; undefined for plain notifications. */
|
|
|
|
|
|
progress?: number;
|
|
|
|
|
|
/** Optional icon-registry name override (defaults derived from kind). */
|
|
|
|
|
|
icon?: string;
|
|
|
|
|
|
/** Current per-file label (e.g. the filename being uploaded). */
|
|
|
|
|
|
currentFile?: string;
|
|
|
|
|
|
/** Files finished so far in the batch (for the "N / M files" counter). */
|
|
|
|
|
|
completed?: number;
|
|
|
|
|
|
/** Total files in the batch (for the "N / M files" counter). */
|
|
|
|
|
|
total?: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Announce a message to the matching ARIA live region (errors are assertive). */
|
|
|
|
|
|
function announce(message: string, assertive = false): void {
|
|
|
|
|
|
const msg = message.trim();
|
|
|
|
|
|
if (!msg || typeof document === 'undefined' || !document.body) return;
|
|
|
|
|
|
const id = assertive ? 'a11y-live-assertive' : 'a11y-live-polite';
|
|
|
|
|
|
let region = document.getElementById(id);
|
|
|
|
|
|
if (!region) {
|
|
|
|
|
|
region = document.createElement('div');
|
|
|
|
|
|
region.id = id;
|
|
|
|
|
|
region.className = 'sr-only';
|
|
|
|
|
|
region.setAttribute('aria-live', assertive ? 'assertive' : 'polite');
|
|
|
|
|
|
region.setAttribute('aria-atomic', 'true');
|
|
|
|
|
|
region.setAttribute('role', assertive ? 'alert' : 'status');
|
|
|
|
|
|
document.body.appendChild(region);
|
|
|
|
|
|
}
|
|
|
|
|
|
// Clear first, then set next frame so repeats register as a change.
|
|
|
|
|
|
region.textContent = '';
|
|
|
|
|
|
const target = region;
|
|
|
|
|
|
if (typeof requestAnimationFrame !== 'undefined') {
|
|
|
|
|
|
requestAnimationFrame(() => (target.textContent = msg));
|
|
|
|
|
|
} else {
|
|
|
|
|
|
target.textContent = msg;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-17 17:06:30 -06:00
|
|
|
|
class UiStore {
|
|
|
|
|
|
toasts = $state<Toast[]>([]);
|
2026-06-17 22:07:18 -06:00
|
|
|
|
notifications = $state<Notification[]>([]);
|
2026-06-17 17:06:30 -06:00
|
|
|
|
#seq = 0;
|
|
|
|
|
|
|
2026-06-17 22:07:18 -06:00
|
|
|
|
/**
|
|
|
|
|
|
* Bumped to request the bell panel auto-open (e.g. on upload start) and to
|
|
|
|
|
|
* trigger the bell "ring" animation. AppShell watches this token.
|
|
|
|
|
|
*/
|
|
|
|
|
|
bellPing = $state(0);
|
|
|
|
|
|
|
|
|
|
|
|
unread = $derived(this.notifications.filter((n) => !n.read).length);
|
|
|
|
|
|
|
|
|
|
|
|
/** Unread count clamped for the badge — caps at "99+" like the original. */
|
|
|
|
|
|
unreadBadge = $derived(this.unread > 99 ? '99+' : String(this.unread));
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Raise a toast and record a notification. `at` is stamped from the clock at
|
|
|
|
|
|
* call time; pass `record: false` for purely transient messages.
|
|
|
|
|
|
*/
|
|
|
|
|
|
notify(message: string, kind: ToastKind = 'info', timeoutMs = 4000, record = true): number {
|
2026-06-17 17:06:30 -06:00
|
|
|
|
const id = ++this.#seq;
|
|
|
|
|
|
this.toasts = [...this.toasts, { id, message, kind }];
|
2026-06-17 22:07:18 -06:00
|
|
|
|
if (record) {
|
|
|
|
|
|
this.notifications = [
|
|
|
|
|
|
{ id, message, kind, at: Date.now(), read: false },
|
|
|
|
|
|
...this.notifications
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
announce(message, kind === 'error');
|
2026-06-17 17:06:30 -06:00
|
|
|
|
if (timeoutMs > 0 && typeof setTimeout !== 'undefined') {
|
|
|
|
|
|
setTimeout(() => this.dismiss(id), timeoutMs);
|
|
|
|
|
|
}
|
|
|
|
|
|
return id;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
dismiss(id: number): void {
|
|
|
|
|
|
this.toasts = this.toasts.filter((t) => t.id !== id);
|
|
|
|
|
|
}
|
2026-06-17 22:07:18 -06:00
|
|
|
|
|
|
|
|
|
|
/** Request the bell panel to open and play its ring animation. */
|
|
|
|
|
|
ringBell(): void {
|
|
|
|
|
|
this.bellPing++;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Begin a progress notification (e.g. an upload). Pass `total` to show the
|
|
|
|
|
|
* "N / M files" counter. Opens the bell, rings it, and announces the start.
|
|
|
|
|
|
*/
|
|
|
|
|
|
startProgress(message: string, icon = 'cloud-upload-alt', total?: number): number {
|
|
|
|
|
|
const id = ++this.#seq;
|
|
|
|
|
|
this.notifications = [
|
|
|
|
|
|
{
|
|
|
|
|
|
id,
|
|
|
|
|
|
message,
|
|
|
|
|
|
kind: 'info',
|
|
|
|
|
|
at: Date.now(),
|
|
|
|
|
|
read: false,
|
|
|
|
|
|
progress: 0,
|
|
|
|
|
|
icon,
|
|
|
|
|
|
...(total !== undefined ? { total, completed: 0 } : {})
|
|
|
|
|
|
},
|
|
|
|
|
|
...this.notifications
|
|
|
|
|
|
];
|
|
|
|
|
|
this.ringBell();
|
|
|
|
|
|
announce(message);
|
|
|
|
|
|
return id;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Update the percentage (0–100) of an in-flight progress notification. */
|
|
|
|
|
|
updateProgress(
|
|
|
|
|
|
id: number,
|
|
|
|
|
|
progress: number,
|
|
|
|
|
|
message?: string,
|
|
|
|
|
|
extra?: { currentFile?: string; completed?: number }
|
|
|
|
|
|
): void {
|
|
|
|
|
|
this.notifications = this.notifications.map((n) =>
|
|
|
|
|
|
n.id === id
|
|
|
|
|
|
? {
|
|
|
|
|
|
...n,
|
|
|
|
|
|
progress,
|
|
|
|
|
|
...(message ? { message } : {}),
|
|
|
|
|
|
...(extra?.currentFile !== undefined ? { currentFile: extra.currentFile } : {}),
|
|
|
|
|
|
...(extra?.completed !== undefined ? { completed: extra.completed } : {})
|
|
|
|
|
|
}
|
|
|
|
|
|
: n
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Resolve a progress notification into a final success/error entry. */
|
|
|
|
|
|
finishProgress(id: number, message: string, kind: ToastKind = 'success'): void {
|
|
|
|
|
|
this.notifications = this.notifications.map((n) =>
|
|
|
|
|
|
n.id === id
|
|
|
|
|
|
? {
|
|
|
|
|
|
...n,
|
|
|
|
|
|
message,
|
|
|
|
|
|
kind,
|
|
|
|
|
|
progress: undefined,
|
|
|
|
|
|
currentFile: undefined,
|
|
|
|
|
|
at: Date.now()
|
|
|
|
|
|
}
|
|
|
|
|
|
: n
|
|
|
|
|
|
);
|
|
|
|
|
|
this.toasts = [...this.toasts, { id: ++this.#seq, message, kind }];
|
|
|
|
|
|
announce(message, kind === 'error');
|
|
|
|
|
|
const tid = this.#seq;
|
|
|
|
|
|
if (typeof setTimeout !== 'undefined') setTimeout(() => this.dismiss(tid), 4000);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
markNotificationsRead(): void {
|
|
|
|
|
|
this.notifications = this.notifications.map((n) => (n.read ? n : { ...n, read: true }));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
clearNotifications(): void {
|
|
|
|
|
|
this.notifications = [];
|
|
|
|
|
|
}
|
2026-06-17 17:06:30 -06:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export const ui = new UiStore();
|