visual continunity

This commit is contained in:
Bradley Nelson
2026-06-17 22:07:18 -06:00
parent daa3010458
commit 89e14f8f9e
89 changed files with 19249 additions and 1367 deletions
+113
View File
@@ -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);
+73 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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
+148 -4
View File
@@ -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();