feat(drive): add drive picker in sidebar
- select by default the home drive
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Drives endpoints. D0 ships read-only listing; mutations (create / rename /
|
||||
* member changes) land in D2/D3 and will be added here under the same shape.
|
||||
*
|
||||
* Consumers usually go through the `drives` store (`$lib/stores/drives.svelte`)
|
||||
* which dedupes the request and caches the list — touch this module directly
|
||||
* only when bypassing the cache is intentional (e.g. an explicit refresh).
|
||||
*/
|
||||
import { apiJson } from '$lib/api/client';
|
||||
import type { Drive } from '$lib/api/types';
|
||||
|
||||
/** `GET /api/drives` — every drive the caller can read, default first by convention. */
|
||||
export function listDrives(): Promise<Drive[]> {
|
||||
return apiJson<Drive[]>('/api/drives', { credentials: 'same-origin' });
|
||||
}
|
||||
@@ -103,11 +103,6 @@ function parseListing(raw: unknown): FolderListing {
|
||||
};
|
||||
}
|
||||
|
||||
/** Top-level folders for the user; the first entry is the home folder. */
|
||||
export function listRootFolders(): Promise<FolderItem[]> {
|
||||
return apiJson<FolderItem[]>('/api/folders', { credentials: 'same-origin' });
|
||||
}
|
||||
|
||||
export async function getFolder(id: string): Promise<FolderItem> {
|
||||
const folder = await apiJson<FolderItem>(`/api/folders/${id}`, NO_CACHE);
|
||||
rememberFolderName(folder.id, folder.name);
|
||||
|
||||
@@ -196,3 +196,24 @@ export interface SearchResults {
|
||||
query_time_ms: number;
|
||||
sort_by: string;
|
||||
}
|
||||
|
||||
export type DriveKind = 'personal' | 'shared';
|
||||
|
||||
/**
|
||||
* One row from `GET /api/drives`. Mirrors `DriveDto` in
|
||||
* `src/application/dtos/drive_dto.rs`. `default_for_user` is the caller's
|
||||
* id when present, `null`/undefined otherwise — used to pick the default
|
||||
* personal drive without hard-coding name conventions.
|
||||
*/
|
||||
export interface Drive {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: DriveKind;
|
||||
default_for_user?: string | null;
|
||||
root_folder_id: string;
|
||||
quota_bytes?: number | null;
|
||||
used_bytes: number;
|
||||
policies: Record<string, unknown>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
import { fileInlineUrl } from '$lib/api/endpoints/files';
|
||||
import type { FileItem, FolderItem } from '$lib/api/types';
|
||||
import { lazyComponent } from '$lib/composables/lazyComponent.svelte';
|
||||
import CommandPalette from '$lib/components/CommandPalette.svelte';
|
||||
import DrivePicker from '$lib/components/DrivePicker.svelte';
|
||||
import Icon from '$lib/icons/Icon.svelte';
|
||||
import { iconNameFromClass } from '$lib/utils/display';
|
||||
import { userInitials, avatarColorIndex } from '$lib/utils/avatar';
|
||||
@@ -281,6 +283,9 @@
|
||||
<Icon name={link.icon} />
|
||||
<span>{link.label}</span>
|
||||
</a>
|
||||
{#if link.href === '/files' && !session.isExternalUser}
|
||||
<DrivePicker onnavigate={() => (sidebarOpen = false)} />
|
||||
{/if}
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
import type { Drive } from '$lib/api/types';
|
||||
import Icon from '$lib/icons/Icon.svelte';
|
||||
import { t } from '$lib/i18n/index.svelte';
|
||||
import { drives as drivesStore, driveIcon } from '$lib/stores/drives.svelte';
|
||||
import { formatBytes } from '$lib/utils/format';
|
||||
|
||||
interface Props {
|
||||
onnavigate?: () => void;
|
||||
}
|
||||
let { onnavigate }: Props = $props();
|
||||
|
||||
// URL of `/files/<first>/<second>/...` — the first segment identifies the
|
||||
// drive root the user navigated through. We use it to highlight the active
|
||||
// drive in the picker. Deep-linking to a descendant folder of a non-default
|
||||
// drive bypasses this highlight (the URL's leading segment is the deep
|
||||
// folder id, not the drive root); that's acceptable — D2 can refine this
|
||||
// by resolving `folder.drive_id` server-side when the gap matters.
|
||||
const firstFilesSegment = $derived.by(() => {
|
||||
const m = /^\/files\/([^/]+)/.exec(page.url.pathname);
|
||||
return m ? m[1] : null;
|
||||
});
|
||||
|
||||
// Sorting: default-personal drive first, then secondary personals, then
|
||||
// shared. Within each group, by name. Picker UX puts "home" at the top so
|
||||
// the common case is one click.
|
||||
const sortedDrives = $derived(
|
||||
[...drivesStore.drives].sort((a, b) => {
|
||||
const rank = (d: Drive) => (d.default_for_user ? 0 : d.kind === 'personal' ? 1 : 2);
|
||||
const r = rank(a) - rank(b);
|
||||
return r !== 0 ? r : a.name.localeCompare(b.name);
|
||||
})
|
||||
);
|
||||
|
||||
function isActive(d: Drive): boolean {
|
||||
return firstFilesSegment === d.root_folder_id;
|
||||
}
|
||||
|
||||
function pctUsed(d: Drive): number | null {
|
||||
if (!d.quota_bytes || d.quota_bytes <= 0) return null;
|
||||
return Math.min(100, (d.used_bytes / d.quota_bytes) * 100);
|
||||
}
|
||||
|
||||
async function open(d: Drive) {
|
||||
onnavigate?.();
|
||||
// Remember which drive root the user picked so a later click on the
|
||||
// sidebar "Files" link (which goes to bare `/files`) returns here
|
||||
// instead of always bouncing to the default drive.
|
||||
try {
|
||||
localStorage.setItem('oxi-last-drive-root', d.root_folder_id);
|
||||
} catch {
|
||||
/* private mode / quota — silently fall back to default */
|
||||
}
|
||||
await goto(`/files/${d.root_folder_id}`);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
void drivesStore.load();
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if drivesStore.loaded && drivesStore.drives.length > 0}
|
||||
<ul class="drive-picker" aria-label={t('drive.picker', 'Drives')}>
|
||||
{#each sortedDrives as d (d.id)}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="drive-picker__item"
|
||||
class:drive-picker__item--active={isActive(d)}
|
||||
onclick={() => open(d)}
|
||||
title={pctUsed(d) !== null
|
||||
? `${d.name} — ${formatBytes(d.used_bytes)} / ${formatBytes(d.quota_bytes ?? 0)}`
|
||||
: `${d.name} — ${formatBytes(d.used_bytes)}`}
|
||||
>
|
||||
<Icon name={driveIcon(d)} />
|
||||
<span class="drive-picker__name">{d.name}</span>
|
||||
</button>
|
||||
{#if pctUsed(d) !== null}
|
||||
<div
|
||||
class="drive-picker__bar"
|
||||
role="progressbar"
|
||||
aria-valuenow={Math.round(pctUsed(d) ?? 0)}
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
aria-label={t('drive.usage_aria', 'Drive usage')}
|
||||
>
|
||||
<div class="drive-picker__bar-fill" style:width="{pctUsed(d)}%"></div>
|
||||
</div>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Rendered as nested children under the "Files" nav item — no own border or
|
||||
title; visual nesting via left padding aligned to the parent icon. */
|
||||
.drive-picker {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 0.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.drive-picker__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
width: 100%;
|
||||
padding: 0.3rem 1rem 0.3rem 2rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--color-sidebar-text);
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.drive-picker__item:hover {
|
||||
background: var(--color-sidebar-hover-bg);
|
||||
color: var(--color-sidebar-text-hover);
|
||||
}
|
||||
|
||||
/* Active drive: just a text-color shift. The parent "Files" row already
|
||||
carries the orange-tinted active bg — anything more on the child
|
||||
crowds the sidebar. Typography alone reads as "you are here" since
|
||||
only one drive can be active at a time. */
|
||||
.drive-picker__item--active {
|
||||
color: var(--color-sidebar-text-active);
|
||||
font-weight: var(--weight-semibold);
|
||||
}
|
||||
|
||||
.drive-picker__name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Mini usage bar tucked under the row, indented to align with the name. */
|
||||
.drive-picker__bar {
|
||||
height: 3px;
|
||||
background: var(--color-sidebar-storage-bar);
|
||||
border-radius: 1.5px;
|
||||
margin: 0 1rem 0.25rem 2rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.drive-picker__bar-fill {
|
||||
height: 100%;
|
||||
background: var(--color-accent);
|
||||
transition: width 200ms ease;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Drives store — caches `GET /api/drives` so the picker, the breadcrumb
|
||||
* icon, and the session bootstrap all share one fetch. Idempotent `load()`.
|
||||
*
|
||||
* Identifying the user's home: always via `default_for_user`, never by
|
||||
* folder name (users can rename "Personal").
|
||||
*/
|
||||
import { listDrives } from '$lib/api/endpoints/drives';
|
||||
import type { Drive } from '$lib/api/types';
|
||||
|
||||
class DrivesStore {
|
||||
drives = $state<Drive[]>([]);
|
||||
loaded = $state(false);
|
||||
private inflight: Promise<Drive[]> | null = null;
|
||||
|
||||
async load(): Promise<Drive[]> {
|
||||
if (this.loaded) return this.drives;
|
||||
if (this.inflight) return this.inflight;
|
||||
this.inflight = (async () => {
|
||||
try {
|
||||
this.drives = await listDrives();
|
||||
} catch {
|
||||
this.drives = [];
|
||||
} finally {
|
||||
this.loaded = true;
|
||||
this.inflight = null;
|
||||
}
|
||||
return this.drives;
|
||||
})();
|
||||
return this.inflight;
|
||||
}
|
||||
|
||||
/** Force a refresh after a mutation (rename, member change, …). */
|
||||
invalidate(): void {
|
||||
this.loaded = false;
|
||||
this.drives = [];
|
||||
}
|
||||
|
||||
/** Caller's default-personal drive (one per internal user), or null. */
|
||||
findDefault(): Drive | null {
|
||||
return this.drives.find((d) => d.default_for_user != null) ?? null;
|
||||
}
|
||||
|
||||
/** Drive whose root folder UUID matches `id`, or null. */
|
||||
findByRootFolderId(id: string | null | undefined): Drive | null {
|
||||
if (!id) return null;
|
||||
return this.drives.find((d) => d.root_folder_id === id) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
export const drives = new DrivesStore();
|
||||
|
||||
/**
|
||||
* Picker / breadcrumb icon for a drive:
|
||||
* home — default-personal (the user's home)
|
||||
* folder — secondary personal drive
|
||||
* users — shared / team drive
|
||||
*/
|
||||
export function driveIcon(d: Drive): string {
|
||||
if (d.default_for_user) return 'home';
|
||||
return d.kind === 'shared' ? 'users' : 'folder';
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
* 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 { drives } from '$lib/stores/drives.svelte';
|
||||
import type { User } from '$lib/api/types';
|
||||
|
||||
class SessionStore {
|
||||
@@ -41,20 +41,21 @@ class SessionStore {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the home folder (first entry of GET /api/folders). Externals
|
||||
* (grant-only) have no home folder, so this is skipped for them.
|
||||
* Resolve the caller's default personal drive's root folder — the landing
|
||||
* point for `/files` and the `/` redirect. Externals (grant-only) have no
|
||||
* personal drive, so this is skipped for them.
|
||||
*
|
||||
* Identifies the default via `default_for_user`, not folder name: users
|
||||
* can rename "Personal" without breaking this lookup.
|
||||
*/
|
||||
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 */
|
||||
await drives.load();
|
||||
const def = drives.findDefault();
|
||||
if (def) {
|
||||
this.homeFolderId = def.root_folder_id;
|
||||
this.homeFolderName = def.name;
|
||||
}
|
||||
return this.homeFolderId;
|
||||
}
|
||||
|
||||
@@ -47,12 +47,14 @@
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* First crumb (drive root) — icon sits inline with the drive name. The home
|
||||
icon plays the role of the standalone "home" button in earlier designs;
|
||||
here it visually fuses with the root crumb so the chain reads
|
||||
"🏠 Personal > Documents" instead of "🏠 > Personal > Documents". */
|
||||
.breadcrumb-home {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
gap: 0.35em;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { session } from '$lib/stores/session.svelte';
|
||||
|
||||
// The app root redirects to the primary files view.
|
||||
// The app root redirects by user kind:
|
||||
// - external users (magic-link / OIDC-only / OCM recipients) have no
|
||||
// personal drive, so they land on Shared-with-me;
|
||||
// - internal users go to the files browser, which resolves the default
|
||||
// personal drive's root folder via `session.loadHomeFolder()` (post-D0
|
||||
// this reads `GET /api/drives` and picks the row whose
|
||||
// `default_for_user` matches the caller).
|
||||
onMount(() => {
|
||||
void goto('/files', { replaceState: true });
|
||||
const target = session.isExternalUser ? '/shared-with-me' : '/files';
|
||||
void goto(target, { replaceState: true });
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
import { lazyComponent } from '$lib/composables/lazyComponent.svelte';
|
||||
import { t } from '$lib/i18n/index.svelte';
|
||||
import { confirmDialog, promptDialog } from '$lib/stores/dialogs.svelte';
|
||||
import { drives as drivesStore, driveIcon } from '$lib/stores/drives.svelte';
|
||||
import { files as filesStore } from '$lib/stores/files.svelte';
|
||||
import { session } from '$lib/stores/session.svelte';
|
||||
import { ui } from '$lib/stores/ui.svelte';
|
||||
@@ -68,6 +69,17 @@
|
||||
// /files → home root; /files/a/b → folder b inside a inside home.
|
||||
const pathSegments = $derived((page.params.path ?? '').split('/').filter((s) => s.length > 0));
|
||||
|
||||
// First-crumb icon mirrors the drive at pathSegments[0]: `home` for the
|
||||
// default-personal, `folder` for a secondary personal, `users` for a
|
||||
// shared drive. Falls back to `home` while the drives list is loading
|
||||
// or when the URL's leading segment isn't a known drive root (deep-link
|
||||
// into a sub-folder bypasses drive identification — same limitation as
|
||||
// the breadcrumb name resolution).
|
||||
const rootIcon = $derived.by(() => {
|
||||
const drive = drivesStore.findByRootFolderId(pathSegments[0] ?? null);
|
||||
return drive ? driveIcon(drive) : 'home';
|
||||
});
|
||||
|
||||
let listing = $state<FolderListing>({ folders: [], files: [], favoriteIds: [], sharedIds: [] });
|
||||
let crumbs = $state<Array<{ id: string; name: string }>>([]);
|
||||
let currentId = $state<string | null>(null);
|
||||
@@ -170,6 +182,21 @@
|
||||
return;
|
||||
}
|
||||
const home = await session.loadHomeFolder();
|
||||
|
||||
// Canonicalize bare `/files` → `/files/<last-chosen-drive-root>` (or
|
||||
// the default drive's root when there's no memory yet). Keeps the URL
|
||||
// explicit, the breadcrumb populated, and the drive picker correctly
|
||||
// highlighted. The DrivePicker writes `oxi-last-drive-root` on click.
|
||||
if (pathSegments.length === 0) {
|
||||
const last =
|
||||
typeof localStorage !== 'undefined' ? localStorage.getItem('oxi-last-drive-root') : null;
|
||||
const target = last ?? home;
|
||||
if (target) {
|
||||
await goto(`/files/${target}`, { replaceState: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const folderId = pathSegments.at(-1) ?? home;
|
||||
if (!folderId) {
|
||||
error = t('files.no_home', 'No home folder available.');
|
||||
@@ -194,6 +221,8 @@
|
||||
}, 100);
|
||||
|
||||
// Breadcrumbs resolve independently so they never block the grid paint.
|
||||
// Bare `/files` was canonicalized above to `/files/<id>` so pathSegments
|
||||
// is always non-empty here for internal users.
|
||||
void buildCrumbs(pathSegments).then((trail) => {
|
||||
if (seq === loadSeq) crumbs = trail;
|
||||
});
|
||||
@@ -1277,26 +1306,27 @@
|
||||
</ListToolbar>
|
||||
|
||||
<nav class="breadcrumb" aria-label="Breadcrumb">
|
||||
<a
|
||||
href="/files"
|
||||
class="breadcrumb-item breadcrumb-home breadcrumb-link"
|
||||
title={t('breadcrumb.home', 'Home')}
|
||||
ondragover={(e) => e.dataTransfer?.types.includes(DRAG_TYPE) && e.preventDefault()}
|
||||
ondrop={(e) => session.homeFolderId && onCrumbDrop(e, session.homeFolderId)}
|
||||
>
|
||||
<Icon name="home" />
|
||||
</a>
|
||||
{#each crumbs as c, i (c.id)}
|
||||
<span class="breadcrumb-separator">></span>
|
||||
{#if i > 0}
|
||||
<span class="breadcrumb-separator">></span>
|
||||
{/if}
|
||||
{#if i === crumbs.length - 1}
|
||||
<span class="breadcrumb-item breadcrumb-current">{c.name}</span>
|
||||
<span class="breadcrumb-item breadcrumb-current" class:breadcrumb-home={i === 0}>
|
||||
{#if i === 0}<Icon name={rootIcon} />{/if}
|
||||
{c.name}
|
||||
</span>
|
||||
{:else}
|
||||
<a
|
||||
href={crumbHref(i)}
|
||||
class="breadcrumb-item breadcrumb-link"
|
||||
class:breadcrumb-home={i === 0}
|
||||
title={i === 0 ? t('breadcrumb.home', 'Home') : undefined}
|
||||
ondragover={(e) => e.dataTransfer?.types.includes(DRAG_TYPE) && e.preventDefault()}
|
||||
ondrop={(e) => onCrumbDrop(e, c.id)}>{c.name}</a
|
||||
ondrop={(e) => onCrumbDrop(e, c.id)}
|
||||
>
|
||||
{#if i === 0}<Icon name={rootIcon} />{/if}
|
||||
{c.name}
|
||||
</a>
|
||||
{/if}
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
Reference in New Issue
Block a user