visual continunity
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Promise-based confirm/prompt dialogs, rendered by <DialogHost> in the root
|
||||
* layout. Replaces the browser's native `confirm()`/`prompt()` with in-app
|
||||
* modals that match the rest of the UI. One dialog at a time (queued).
|
||||
*
|
||||
* Dialogs can carry an async `action`: when present the host runs it on submit
|
||||
* and only closes the dialog if it resolves. A rejection keeps the dialog open
|
||||
* and surfaces an inline error, so failed renames/deletes don't silently vanish.
|
||||
*/
|
||||
export interface ConfirmOptions {
|
||||
title: string;
|
||||
message?: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
danger?: boolean;
|
||||
/** Optional async action run on confirm; rejection keeps the dialog open. */
|
||||
action?: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
export interface PromptOptions {
|
||||
title: string;
|
||||
message?: string;
|
||||
defaultValue?: string;
|
||||
placeholder?: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
/**
|
||||
* Pre-select the input text on open. `'name'` selects the filename portion
|
||||
* (excluding the extension) — used by rename so typing replaces just the
|
||||
* stem. `true` selects everything; omit/`false` to leave the caret at end.
|
||||
*/
|
||||
selectOnOpen?: boolean | 'name';
|
||||
/** Optional async action run with the entered value; rejection keeps it open. */
|
||||
action?: (value: string) => Promise<void> | void;
|
||||
}
|
||||
|
||||
type Pending =
|
||||
| { kind: 'confirm'; opts: ConfirmOptions; resolve: (v: boolean) => void }
|
||||
| { kind: 'prompt'; opts: PromptOptions; resolve: (v: string | null) => void };
|
||||
|
||||
class DialogStore {
|
||||
current = $state<Pending | null>(null);
|
||||
/** Inline error message for the current dialog (from a failed action). */
|
||||
error = $state<string | null>(null);
|
||||
/** True while the current dialog's async action is running. */
|
||||
busy = $state(false);
|
||||
#queue: Pending[] = [];
|
||||
|
||||
#enqueue(p: Pending) {
|
||||
if (this.current) this.#queue.push(p);
|
||||
else {
|
||||
this.current = p;
|
||||
this.error = null;
|
||||
this.busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
#next() {
|
||||
this.error = null;
|
||||
this.busy = false;
|
||||
this.current = this.#queue.shift() ?? null;
|
||||
}
|
||||
|
||||
confirm(opts: ConfirmOptions): Promise<boolean> {
|
||||
return new Promise((resolve) => this.#enqueue({ kind: 'confirm', opts, resolve }));
|
||||
}
|
||||
|
||||
prompt(opts: PromptOptions): Promise<string | null> {
|
||||
return new Promise((resolve) => this.#enqueue({ kind: 'prompt', opts, resolve }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the host when the user confirms (with a value for prompts).
|
||||
* When the dialog carries an `action`, runs it first: on success the dialog
|
||||
* closes and the promise resolves; on failure the dialog stays open with an
|
||||
* inline error and the promise does NOT resolve yet.
|
||||
*/
|
||||
async resolve(value: boolean | string | null) {
|
||||
const c = this.current;
|
||||
if (!c) return;
|
||||
const action = c.kind === 'confirm' ? c.opts.action : (c.opts as PromptOptions).action;
|
||||
if (action) {
|
||||
this.busy = true;
|
||||
this.error = null;
|
||||
try {
|
||||
if (c.kind === 'confirm') await (action as () => Promise<void> | void)();
|
||||
else await (action as (v: string) => Promise<void> | void)(value as string);
|
||||
} catch (err) {
|
||||
this.busy = false;
|
||||
this.error = err instanceof Error ? err.message : String(err);
|
||||
return; // keep the dialog open
|
||||
}
|
||||
}
|
||||
if (c.kind === 'confirm') c.resolve(value as boolean);
|
||||
else c.resolve(value as string | null);
|
||||
this.#next();
|
||||
}
|
||||
|
||||
/** Cancel/dismiss the current dialog. */
|
||||
cancel() {
|
||||
const c = this.current;
|
||||
if (!c || this.busy) return;
|
||||
if (c.kind === 'confirm') c.resolve(false);
|
||||
else c.resolve(null);
|
||||
this.#next();
|
||||
}
|
||||
}
|
||||
|
||||
export const dialogs = new DialogStore();
|
||||
|
||||
/** Convenience wrappers. */
|
||||
export const confirmDialog = (opts: ConfirmOptions) => dialogs.confirm(opts);
|
||||
export const promptDialog = (opts: PromptOptions) => dialogs.prompt(opts);
|
||||
@@ -1,12 +1,84 @@
|
||||
/**
|
||||
* Files view state — replaces the navigation-related fields of the legacy `app`
|
||||
* Files view state — replaces the navigation-related fields of the original `app`
|
||||
* state object (currentFolder, currentFolderInfo, breadcrumbPath, view mode,
|
||||
* section, selection). Dialog/context-menu targets stay component-local until a
|
||||
* view proves they must be shared.
|
||||
*/
|
||||
import type { FolderItem } from '$lib/api/types';
|
||||
import { t } from '$lib/i18n/index.svelte';
|
||||
|
||||
export type ViewMode = 'grid' | 'list';
|
||||
|
||||
// ── Group-by / display helpers ───────────────────────────────────────────────
|
||||
// Ported from static/js/core/formatters.js (sizeBucket, normalizeDateBucket,
|
||||
// formatRelativeTime) and static/js/components/resourceList.js (type label,
|
||||
// owner label). Pure functions, shared by the files view's swimlane grouping
|
||||
// and cell rendering so the same bucketing logic isn't duplicated per call site.
|
||||
|
||||
/** Normalise an epoch (seconds or ms) into a Date. */
|
||||
function toDate(value: number): Date {
|
||||
return new Date(value < 1e12 ? value * 1000 : value);
|
||||
}
|
||||
|
||||
/** Coarse size bucket label. `bytes < 0` is the "Folders" sentinel. */
|
||||
export function sizeBucket(bytes: number): string {
|
||||
if (bytes < 0) return t('sizeBucket.folders', 'Folders');
|
||||
if (bytes === 0) return t('sizeBucket.empty', 'Empty (0 B)');
|
||||
if (bytes < 1_048_576) return t('sizeBucket.tiny', '< 1 MB');
|
||||
if (bytes < 104_857_600) return t('sizeBucket.small', '1 – 100 MB');
|
||||
if (bytes < 1_073_741_824) return t('sizeBucket.medium', '100 MB – 1 GB');
|
||||
if (bytes < 5 * 1_073_741_824) return t('sizeBucket.large', '1 – 5 GB');
|
||||
return t('sizeBucket.huge', '> 5 GB');
|
||||
}
|
||||
|
||||
/** Coarse date bucket: Today | Last 7 days | Last 30 days | <YYYY>. */
|
||||
export function dateBucket(value: number | null | undefined): string {
|
||||
if (!value) return t('dateBucket.unknown', 'Unknown');
|
||||
const diffDays = Math.floor((Date.now() - toDate(value).getTime()) / 86_400_000);
|
||||
if (diffDays <= 0) return t('dateBucket.today', 'Today');
|
||||
if (diffDays <= 7) return t('dateBucket.last7days', 'Last 7 days');
|
||||
if (diffDays <= 30) return t('dateBucket.last30days', 'Last 30 days');
|
||||
return String(toDate(value).getFullYear());
|
||||
}
|
||||
|
||||
/** Locale-aware relative "time ago" for grid-card metadata lines. */
|
||||
export function relativeTimeAgo(value: number | null | undefined): string {
|
||||
if (!value) return '';
|
||||
const date = toDate(value);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
const diffSec = Math.round((date.getTime() - Date.now()) / 1000);
|
||||
const abs = Math.abs(diffSec);
|
||||
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' });
|
||||
const units: Array<[Intl.RelativeTimeFormatUnit, number]> = [
|
||||
['year', 31536000],
|
||||
['month', 2592000],
|
||||
['week', 604800],
|
||||
['day', 86400],
|
||||
['hour', 3600],
|
||||
['minute', 60]
|
||||
];
|
||||
for (const [unit, secs] of units) {
|
||||
if (abs >= secs) return rtf.format(Math.round(diffSec / secs), unit);
|
||||
}
|
||||
return rtf.format(diffSec, 'second');
|
||||
}
|
||||
|
||||
/** Localise a file `category` (e.g. "Image") via files.file_types.* keys. */
|
||||
export function typeLabel(category: string | null | undefined): string {
|
||||
if (!category) return t('files.file_types.document', 'Document');
|
||||
return t(`files.file_types.${category.toLowerCase()}`, category);
|
||||
}
|
||||
|
||||
/** Owner display: "Me" for the current user, else a short id fallback. */
|
||||
export function ownerLabel(
|
||||
ownerId: string | null | undefined,
|
||||
currentUserId: string | null
|
||||
): string {
|
||||
if (!ownerId) return '';
|
||||
if (currentUserId && ownerId === currentUserId) return t('files.owner_me', 'Me');
|
||||
return ownerId.slice(0, 8);
|
||||
}
|
||||
|
||||
export type Section =
|
||||
| 'files'
|
||||
| 'shared'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Session store — the authenticated user and derived flags.
|
||||
*
|
||||
* Replaces the user-related fields of the legacy `app` state object
|
||||
* Replaces the user-related fields of the original `app` state object
|
||||
* (isExternalUser, userHomeFolderId/Name). `isExternalUser` drives default
|
||||
* routing: externals (magic-link / OIDC-only / OCM recipients) have no home
|
||||
* folder and land on the shared-with-me view.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Theme store — light / dark / auto.
|
||||
*
|
||||
* Mirrors the legacy behaviour: persists to the `oxicloud_theme` localStorage
|
||||
* Mirrors the established behaviour: persists to the `oxicloud_theme` localStorage
|
||||
* key and reflects the choice on `<html data-color-scheme>`. `auto` removes the
|
||||
* attribute so the OS `prefers-color-scheme` takes over. The anti-FOUC inline
|
||||
* script in app.html applies the stored value before first paint; this store
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Transient UI state — toasts now; cross-component dialog targets are added as
|
||||
* the views that need them land (Phases 2–4). Component-local state is preferred;
|
||||
* only state that must cross component boundaries belongs here.
|
||||
* 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.
|
||||
*/
|
||||
export type ToastKind = 'info' | 'success' | 'error' | 'warning';
|
||||
|
||||
@@ -11,13 +12,79 @@ export interface Toast {
|
||||
kind: ToastKind;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
class UiStore {
|
||||
toasts = $state<Toast[]>([]);
|
||||
notifications = $state<Notification[]>([]);
|
||||
#seq = 0;
|
||||
|
||||
notify(message: string, kind: ToastKind = 'info', timeoutMs = 4000): number {
|
||||
/**
|
||||
* 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 {
|
||||
const id = ++this.#seq;
|
||||
this.toasts = [...this.toasts, { id, message, kind }];
|
||||
if (record) {
|
||||
this.notifications = [
|
||||
{ id, message, kind, at: Date.now(), read: false },
|
||||
...this.notifications
|
||||
];
|
||||
}
|
||||
announce(message, kind === 'error');
|
||||
if (timeoutMs > 0 && typeof setTimeout !== 'undefined') {
|
||||
setTimeout(() => this.dismiss(id), timeoutMs);
|
||||
}
|
||||
@@ -27,6 +94,83 @@ class UiStore {
|
||||
dismiss(id: number): void {
|
||||
this.toasts = this.toasts.filter((t) => t.id !== id);
|
||||
}
|
||||
|
||||
/** 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 = [];
|
||||
}
|
||||
}
|
||||
|
||||
export const ui = new UiStore();
|
||||
|
||||
Reference in New Issue
Block a user