init new frontend
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Files view state — replaces the navigation-related fields of the legacy `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';
|
||||
|
||||
export type ViewMode = 'grid' | 'list';
|
||||
export type Section =
|
||||
| 'files'
|
||||
| 'shared'
|
||||
| 'shared-with-me'
|
||||
| 'recent'
|
||||
| 'favorites'
|
||||
| 'trash'
|
||||
| 'photos'
|
||||
| 'music';
|
||||
|
||||
const VIEW_KEY = 'oxicloud_view_mode';
|
||||
|
||||
function readViewMode(): ViewMode {
|
||||
if (typeof localStorage === 'undefined') return 'grid';
|
||||
return localStorage.getItem(VIEW_KEY) === 'list' ? 'list' : 'grid';
|
||||
}
|
||||
|
||||
class FilesStore {
|
||||
currentFolder = $state<string | null>(null);
|
||||
currentFolderInfo = $state<FolderItem | null>(null);
|
||||
breadcrumbPath = $state<Array<{ id: string; name: string }>>([]);
|
||||
viewMode = $state<ViewMode>(readViewMode());
|
||||
section = $state<Section>('files');
|
||||
isSearchMode = $state(false);
|
||||
selection = $state<Set<string>>(new Set());
|
||||
|
||||
setViewMode(mode: ViewMode): void {
|
||||
this.viewMode = mode;
|
||||
if (typeof localStorage !== 'undefined') localStorage.setItem(VIEW_KEY, mode);
|
||||
}
|
||||
|
||||
clearSelection(): void {
|
||||
this.selection = new Set();
|
||||
}
|
||||
|
||||
toggleSelected(id: string): void {
|
||||
const next = new Set(this.selection);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
this.selection = next;
|
||||
}
|
||||
}
|
||||
|
||||
export const files = new FilesStore();
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Session store — the authenticated user and derived flags.
|
||||
*
|
||||
* Replaces the user-related fields of the legacy `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.
|
||||
*/
|
||||
import { fetchMe, tryRefresh } from '$lib/api/endpoints/auth';
|
||||
import { listRootFolders } from '$lib/api/endpoints/folders';
|
||||
import type { User } from '$lib/api/types';
|
||||
|
||||
class SessionStore {
|
||||
user = $state<User | null>(null);
|
||||
loaded = $state(false);
|
||||
homeFolderId = $state<string | null>(null);
|
||||
homeFolderName = $state<string | null>(null);
|
||||
|
||||
isExternalUser = $derived(this.user?.is_external ?? false);
|
||||
isAuthenticated = $derived(this.user !== null);
|
||||
|
||||
/**
|
||||
* Resolve the session once. Probes /api/auth/me; on 401 it makes a single
|
||||
* refresh attempt and re-probes. Never redirects — the layout guard decides
|
||||
* what to do with an unauthenticated result. Idempotent: subsequent calls
|
||||
* return the cached result (so client-side navigation doesn't re-probe).
|
||||
*/
|
||||
async load(): Promise<User | null> {
|
||||
if (this.loaded) return this.user;
|
||||
try {
|
||||
let me = await fetchMe();
|
||||
if (!me && (await tryRefresh())) {
|
||||
me = await fetchMe();
|
||||
}
|
||||
this.user = me;
|
||||
} catch {
|
||||
this.user = null;
|
||||
}
|
||||
this.loaded = true;
|
||||
return this.user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the home folder (first entry of GET /api/folders). Externals
|
||||
* (grant-only) have no home folder, so this is skipped for them.
|
||||
*/
|
||||
async loadHomeFolder(): Promise<string | null> {
|
||||
if (this.homeFolderId) return this.homeFolderId;
|
||||
if (this.isExternalUser) return null;
|
||||
try {
|
||||
const folders = await listRootFolders();
|
||||
if (folders.length > 0) {
|
||||
this.homeFolderId = folders[0].id;
|
||||
this.homeFolderName = folders[0].name;
|
||||
}
|
||||
} catch {
|
||||
/* leave null — caller handles */
|
||||
}
|
||||
return this.homeFolderId;
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.user = null;
|
||||
this.homeFolderId = null;
|
||||
this.homeFolderName = null;
|
||||
}
|
||||
}
|
||||
|
||||
export const session = new SessionStore();
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Theme store — light / dark / auto.
|
||||
*
|
||||
* Mirrors the legacy 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
|
||||
* owns runtime changes from the UI.
|
||||
*/
|
||||
export type Theme = 'light' | 'dark' | 'auto';
|
||||
|
||||
const STORAGE_KEY = 'oxicloud_theme';
|
||||
|
||||
function readInitial(): Theme {
|
||||
if (typeof localStorage === 'undefined') return 'auto';
|
||||
const v = localStorage.getItem(STORAGE_KEY);
|
||||
return v === 'light' || v === 'dark' ? v : 'auto';
|
||||
}
|
||||
|
||||
const store = $state<{ theme: Theme }>({ theme: readInitial() });
|
||||
|
||||
function apply(theme: Theme): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
const html = document.documentElement;
|
||||
if (theme === 'light' || theme === 'dark') html.setAttribute('data-color-scheme', theme);
|
||||
else html.removeAttribute('data-color-scheme');
|
||||
}
|
||||
|
||||
export function setTheme(theme: Theme): void {
|
||||
store.theme = theme;
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
if (theme === 'auto') localStorage.removeItem(STORAGE_KEY);
|
||||
else localStorage.setItem(STORAGE_KEY, theme);
|
||||
}
|
||||
apply(theme);
|
||||
}
|
||||
|
||||
export const theme = {
|
||||
get current() {
|
||||
return store.theme;
|
||||
},
|
||||
set: setTheme
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export type ToastKind = 'info' | 'success' | 'error' | 'warning';
|
||||
|
||||
export interface Toast {
|
||||
id: number;
|
||||
message: string;
|
||||
kind: ToastKind;
|
||||
}
|
||||
|
||||
class UiStore {
|
||||
toasts = $state<Toast[]>([]);
|
||||
#seq = 0;
|
||||
|
||||
notify(message: string, kind: ToastKind = 'info', timeoutMs = 4000): number {
|
||||
const id = ++this.#seq;
|
||||
this.toasts = [...this.toasts, { id, message, kind }];
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
export const ui = new UiStore();
|
||||
Reference in New Issue
Block a user