Files
typst-desktop/src/lib/components/FileViewer.svelte
T

1059 lines
37 KiB
Svelte

<script lang="ts">
import Icon from "@iconify/svelte";
import * as api from "$lib/ts/api";
import type { BrowseEntry, EntryKind } from "$lib/ts/api";
import { clampMenu } from "$lib/ts/menu-position";
import {
app,
breadcrumbs,
browseTo,
cloudBreadcrumbs,
linkedDocument,
linkedProject,
openCloudFolder,
openTarget,
refreshCloud,
refreshEntries,
setError,
} from "$lib/ts/state.svelte";
interface Props {
onnewfolder: () => void;
onnewproject: () => void;
onnewdocument: () => void;
onupload: () => void;
onrename: (entry: BrowseEntry) => void;
ondelete: (entry: BrowseEntry) => void;
onlink: (entry: BrowseEntry) => void;
onsavetocloud: (entry: BrowseEntry) => void;
onviewimage: (paths: string[], index: number) => void;
ondownloaddocument: (documentId: string, title: string) => void;
onremovedownload: (path: string) => void;
ondownloadfile: (fileId: string, name: string) => void;
ondeletefile: (fileId: string) => void;
oncloneproject: (cloudProjectId: string, name: string) => void;
ondeleteproject: (cloudProjectId: string) => void;
ondeletedocument: (documentId: string) => void;
onnewcloudproject: () => void;
onnewclouddocument: () => void;
onsignin: () => void;
}
let {
onnewfolder,
onnewproject,
onnewdocument,
onupload,
onrename,
ondelete,
onlink,
onsavetocloud,
onviewimage,
ondownloaddocument,
onremovedownload,
ondownloadfile,
ondeletefile,
oncloneproject,
ondeleteproject,
ondeletedocument,
onnewcloudproject,
onnewclouddocument,
onsignin,
}: Props = $props();
let menuFor = $state<string | null>(null);
let menuAt = $state({ x: 0, y: 0 });
function openContextMenu(event: MouseEvent, path: string) {
event.preventDefault();
event.stopPropagation();
menuAt = { x: event.clientX, y: event.clientY };
menuFor = path;
}
let cloudMenuFor = $state<string | null>(null);
let cloudMenuAt = $state({ x: 0, y: 0 });
function openCloudContextMenu(event: MouseEvent, id: string) {
event.preventDefault();
event.stopPropagation();
cloudMenuAt = { x: event.clientX, y: event.clientY };
cloudMenuFor = id;
}
const trail = $derived(breadcrumbs());
const cloudTrail = $derived(cloudBreadcrumbs());
$effect(() => {
if (app.scope === "cloud" && app.account) {
refreshCloud();
}
});
const containers = $derived(
app.entries.filter(
(entry) => entry.kind === "folder" || entry.kind === "project",
),
);
const documents = $derived(
app.entries.filter(
(entry) => entry.kind === "document" || entry.kind === "file",
),
);
let thumbs = $state<Record<string, { kind: string; data: string }>>({});
let cloudThumbs = $state<Record<string, { kind: string; data: string }>>({});
$effect(() => {
if (app.scope !== "cloud") return;
const pending = [
...app.linkedDocuments.map((linked) => linked.path),
...app.linkedProjects.map((linked) => linked.path),
];
let cancelled = false;
(async () => {
for (const path of pending) {
if (cancelled) return;
if (cloudThumbs[path]) continue;
try {
const result = await api.thumbnail(path);
if (!cancelled) cloudThumbs[path] = result;
} catch {
continue;
}
}
})();
return () => {
cancelled = true;
};
});
$effect(() => {
const pending = documents.map((entry) => entry.path);
let cancelled = false;
(async () => {
for (const path of pending) {
if (cancelled) return;
if (thumbs[path]) continue;
try {
const result = await api.thumbnail(path);
if (!cancelled) thumbs[path] = result;
} catch {
continue;
}
}
})();
return () => {
cancelled = true;
};
});
const iconFor: Record<EntryKind, string> = {
project: "ph:folder-star",
folder: "ph:folder",
document: "ph:file-text",
file: "ph:file",
};
const colorFor: Record<EntryKind, string> = {
project: "text-[var(--color-accent)]",
folder: "text-[var(--color-ink-muted)]",
document: "text-[var(--color-accent)]",
file: "text-[var(--color-ink-muted)]",
};
function relativeTime(value: string | null): string {
if (!value) return "";
const then = new Date(value).getTime();
if (Number.isNaN(then)) return "";
const minutes = Math.round((Date.now() - then) / 60000);
if (minutes < 1) return "just now";
if (minutes < 60) return `${minutes}m ago`;
if (minutes < 1440) return `${Math.round(minutes / 60)}h ago`;
return `${Math.round(minutes / 1440)}d ago`;
}
function syncLabel(entry: BrowseEntry): string {
if (entry.sync_state === "pending") return "Local changes not yet synced";
if (entry.sync_state === "synced") {
const when = relativeTime(entry.last_synced_at);
return when ? `Synced ${when}` : "Synced";
}
return "Linked to the cloud";
}
const imagePaths = $derived(
app.entries
.filter((entry) => api.isImagePath(entry.path))
.map((entry) => entry.path),
);
let dragging = $state<string | null>(null);
let dropTarget = $state<string | null>(null);
async function moveTo(source: string, destination: string) {
if (source === destination) return;
const parent = source.includes("/") ? source.slice(0, source.lastIndexOf("/")) : "";
if (parent === destination) return;
try {
await api.moveEntry(source, destination);
await refreshEntries();
} catch (error) {
setError(error);
}
}
function startDrag(event: DragEvent, path: string) {
dragging = path;
event.dataTransfer?.setData("text/plain", path);
}
function endDrag() {
dragging = null;
dropTarget = null;
}
function allowDrop(event: DragEvent, path: string) {
if (!dragging) return;
event.preventDefault();
dropTarget = path;
}
function handleDrop(event: DragEvent, destination: string) {
event.preventDefault();
const source = dragging ?? event.dataTransfer?.getData("text/plain");
dragging = null;
dropTarget = null;
if (source) moveTo(source, destination);
}
function activate(entry: BrowseEntry) {
if (entry.kind === "folder") {
browseTo(entry.path);
} else if (entry.kind === "project" || entry.kind === "document") {
openTarget(entry.path);
} else if (api.isImagePath(entry.path)) {
onviewimage(imagePaths, imagePaths.indexOf(entry.path));
}
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
function formatDate(value: string | null): string {
if (!value) return "";
const date = new Date(value);
return Number.isNaN(date.getTime()) ? "" : date.toLocaleDateString();
}
</script>
{#snippet cloudMenu(
id: string,
onopen: (() => void) | null,
ondownload: () => void,
onremove: (() => void) | null,
ondelete: (() => void) | null,
)}
{#if cloudMenuFor === id}
<div
use:clampMenu
class="fixed z-50 flex w-44 flex-col rounded-md border border-[var(--color-line)] bg-[var(--color-surface)] py-1 text-xs shadow-lg"
style="left: {cloudMenuAt.x}px; top: {cloudMenuAt.y}px"
>
{#if onopen}
<button
class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]"
onclick={() => {
onopen();
cloudMenuFor = null;
}}
>
Open
</button>
{:else}
<button
class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]"
onclick={() => {
ondownload();
cloudMenuFor = null;
}}
>
Download
</button>
{/if}
{#if onremove}
<button
class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]"
onclick={() => {
onremove();
cloudMenuFor = null;
}}
>
Remove from this device
</button>
{/if}
{#if ondelete}
<button
class="px-3 py-1.5 text-left text-[var(--color-danger)] hover:bg-[var(--color-surface-sunken)]"
onclick={() => {
ondelete();
cloudMenuFor = null;
}}
>
Delete from cloud
</button>
{/if}
</div>
{/if}
{/snippet}
{#snippet cloudCard(
id: string,
icon: string,
title: string,
meta: string,
link: { sync_state: string | null; path: string } | undefined,
onopen: (() => void) | null,
ondownload: () => void,
onremove: (() => void) | null,
ondelete: (() => void) | null,
)}
<div
class="group flex flex-col overflow-hidden rounded-lg border border-[var(--color-line)] bg-[var(--color-surface)] transition hover:border-[var(--color-accent)] hover:shadow-sm"
oncontextmenu={(event) => openCloudContextMenu(event, id)}
>
{@render cloudMenu(id, onopen, ondownload, onremove, ondelete)}
<div
class="flex h-24 items-center justify-center overflow-hidden border-b border-[var(--color-line)] bg-[var(--color-surface-muted)]"
>
{#if link && cloudThumbs[link.path]}
{#if cloudThumbs[link.path].kind === "svg"}
<span
class="flex w-full items-start justify-center bg-white p-1 [&_svg]:h-auto [&_svg]:w-full"
>
{@html cloudThumbs[link.path].data}
</span>
{:else}
<img
src={cloudThumbs[link.path].data}
alt={title}
class="h-full w-full object-contain"
/>
{/if}
{:else}
<Icon
{icon}
class="text-3xl {link
? 'text-[var(--color-accent)]'
: 'text-[var(--color-ink-muted)]'}"
/>
{/if}
</div>
<div class="flex flex-col gap-2.5 p-3">
<div class="flex items-start gap-2">
<div class="min-w-0 flex-1">
<p class="truncate text-xs font-medium" {title}>{title}</p>
<p class="truncate text-[10px] text-[var(--color-ink-muted)]">{meta}</p>
</div>
{#if link}
<span
class="flex shrink-0 items-center gap-1 rounded-full px-1.5 py-0.5 text-[9px] font-medium
{link.sync_state === 'pending'
? 'bg-[var(--color-accent-soft)] text-[var(--color-accent)]'
: 'bg-[var(--color-success)]/10 text-[var(--color-success)]'}"
title={link.sync_state === "pending"
? "Local changes not yet synced"
: "On this device and up to date"}
>
<Icon
icon={link.sync_state === "pending"
? "ph:cloud-arrow-up"
: "ph:cloud-check"}
class="text-[11px]"
/>
{link.sync_state === "pending" ? "Unsynced" : "Synced"}
</span>
{/if}
</div>
<div class="flex gap-1">
{#if link && onopen}
<button
class="flex flex-1 items-center justify-center gap-1 rounded-md bg-[var(--color-accent)] px-2 py-1.5 text-[10px] font-medium text-white transition hover:opacity-90"
onclick={onopen}
>
<Icon icon="ph:pencil-simple" />
Open
</button>
{:else}
<button
class="flex flex-1 items-center justify-center gap-1 rounded-md border border-[var(--color-line)] px-2 py-1.5 text-[10px] transition hover:bg-[var(--color-surface-muted)]"
onclick={ondownload}
>
<Icon icon="ph:download-simple" />
Download
</button>
{/if}
{#if onremove || ondelete}
<button
class="rounded-md border border-[var(--color-line)] px-2 py-1.5 text-[10px] text-[var(--color-ink-muted)] transition hover:border-[var(--color-danger)] hover:text-[var(--color-danger)]"
onclick={onremove ?? ondelete}
aria-label={onremove ? "Remove" : "Delete"}
>
<Icon icon="ph:trash" />
</button>
{/if}
</div>
</div>
</div>
{/snippet}
{#snippet syncBadge(entry: BrowseEntry)}
{#if entry.cloud_linked}
<span class="flex shrink-0 items-center" title={syncLabel(entry)}>
{#if entry.sync_state === "pending"}
<Icon
icon="ph:cloud-arrow-up"
class="text-sm text-[var(--color-accent)]"
/>
{:else}
<Icon
icon="ph:cloud-check"
class="text-sm text-[var(--color-success)]"
/>
{/if}
</span>
{/if}
{/snippet}
{#snippet actions(entry: BrowseEntry)}
<button
data-card-menu
class="shrink-0 rounded p-1 text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)]"
onclick={(event) => {
event.stopPropagation();
if (menuFor === entry.path) {
menuFor = null;
return;
}
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
menuAt = { x: rect.right, y: rect.bottom + 4 };
menuFor = entry.path;
}}
aria-label="Actions"
>
<Icon icon="ph:dots-three-vertical" />
</button>
{#if menuFor === entry.path}
<div
use:clampMenu
class="fixed z-50 flex w-40 flex-col rounded-md border border-[var(--color-line)] bg-[var(--color-surface)] py-1 text-xs shadow-lg"
style="left: {menuAt.x}px; top: {menuAt.y}px"
>
{#if entry.kind === "project" || entry.kind === "document"}
<button
class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]"
onclick={() => {
openTarget(entry.path);
menuFor = null;
}}
>
Open in editor
</button>
{:else if api.isImagePath(entry.path)}
<button
class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]"
onclick={() => {
onviewimage(imagePaths, imagePaths.indexOf(entry.path));
menuFor = null;
}}
>
Open image
</button>
{/if}
{#if entry.kind === "project" && !entry.cloud_project_id && app.account}
<button
class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]"
onclick={() => {
onlink(entry);
menuFor = null;
}}
>
Upload to cloud
</button>
{/if}
{#if entry.kind === "document" && !entry.cloud_linked && app.account}
<button
class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]"
onclick={() => {
onsavetocloud(entry);
menuFor = null;
}}
>
Save to cloud
</button>
{/if}
<button
class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]"
onclick={() => {
onrename(entry);
menuFor = null;
}}
>
Rename
</button>
<button
class="px-3 py-1.5 text-left text-[var(--color-danger)] hover:bg-[var(--color-surface-sunken)]"
onclick={() => {
ondelete(entry);
menuFor = null;
}}
>
Delete
</button>
</div>
{/if}
{/snippet}
<svelte:window
on:click={(event) => {
if (!(event.target as HTMLElement).closest("[data-card-menu]")) {
menuFor = null;
}
cloudMenuFor = null;
}}
/>
<div
class="flex h-full flex-col bg-[var(--color-surface-muted)]"
oncontextmenu={(event) => event.preventDefault()}
>
<div
class="flex items-center gap-2 border-b border-[var(--color-line)] bg-[var(--color-surface)] px-4 py-2.5"
>
<div class="flex rounded-lg bg-[var(--color-surface-sunken)] p-0.5 text-xs font-medium">
{#each [["local", "Local", "ph:hard-drives"], ["cloud", "Cloud", "ph:cloud"]] as [value, label, icon]}
<button
class="flex items-center gap-1.5 rounded-md px-3 py-1.5 transition
{app.scope === value
? 'bg-[var(--color-surface)] text-[var(--color-ink)] shadow-sm'
: 'text-[var(--color-ink-muted)] hover:text-[var(--color-ink)]'}"
onclick={() => (app.scope = value as "local" | "cloud")}
>
<Icon {icon} />
{label}
</button>
{/each}
</div>
<div class="flex-1"></div>
{#if app.scope === "local"}
<button
class="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-ink)]"
onclick={onupload}
>
<Icon icon="ph:upload-simple" />
Import
</button>
<button
class="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-ink)]"
onclick={onnewfolder}
>
<Icon icon="ph:folder-plus" />
Folder
</button>
<button
class="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-ink)]"
onclick={onnewproject}
>
<Icon icon="ph:folder-star" />
Project
</button>
<button
class="flex items-center gap-1.5 rounded-md bg-[var(--color-accent)] px-2.5 py-1.5 text-xs font-medium text-white transition hover:opacity-90"
onclick={onnewdocument}
>
<Icon icon="ph:plus" />
New document
</button>
{:else if app.account}
<button
class="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-ink)]"
onclick={onnewcloudproject}
>
<Icon icon="ph:folder-star" />
Project
</button>
<button
class="flex items-center gap-1.5 rounded-md bg-[var(--color-accent)] px-2.5 py-1.5 text-xs font-medium text-white transition hover:opacity-90"
onclick={onnewclouddocument}
>
<Icon icon="ph:plus" />
New document
</button>
{/if}
</div>
{#if app.scope === "local"}
<div
class="flex items-center gap-1 border-b border-[var(--color-line)] bg-[var(--color-surface)] px-4 py-2 text-xs"
>
<button
class="flex items-center gap-1.5 rounded px-2 py-1 transition hover:bg-[var(--color-surface-muted)]
{app.currentDir === '' ? 'font-medium' : 'text-[var(--color-ink-muted)]'}
{dropTarget === '' ? 'bg-[var(--color-accent-soft)] text-[var(--color-accent)]' : ''}"
onclick={() => browseTo("")}
ondragover={(event) => allowDrop(event, "")}
ondragleave={() => {
if (dropTarget === "") dropTarget = null;
}}
ondrop={(event) => handleDrop(event, "")}
>
<Icon icon="ph:house" />
Workspace
</button>
{#each trail as crumb, index}
<Icon icon="ph:caret-right" class="text-[10px] text-[var(--color-ink-muted)]" />
<button
class="rounded px-2 py-1 transition hover:bg-[var(--color-surface-muted)]
{index === trail.length - 1 ? 'font-medium' : 'text-[var(--color-ink-muted)]'}
{dropTarget === crumb.path ? 'bg-[var(--color-accent-soft)] text-[var(--color-accent)]' : ''}"
onclick={() => browseTo(crumb.path)}
ondragover={(event) => allowDrop(event, crumb.path)}
ondragleave={() => {
if (dropTarget === crumb.path) dropTarget = null;
}}
ondrop={(event) => handleDrop(event, crumb.path)}
>
{crumb.name}
</button>
{/each}
</div>
<div class="scroll-thin flex-1 overflow-y-auto p-4">
{#if app.entries.length === 0}
<div
class="flex h-full flex-col items-center justify-center gap-3 text-[var(--color-ink-muted)]"
>
<Icon icon="ph:folder-open" class="text-5xl" />
<p class="text-sm">This folder is empty.</p>
<div class="flex gap-2">
<button
class="rounded-md bg-[var(--color-accent)] px-3 py-1.5 text-xs font-medium text-white hover:opacity-90"
onclick={onnewdocument}
>
New document
</button>
<button
class="rounded-md border border-[var(--color-line)] px-3 py-1.5 text-xs hover:bg-[var(--color-surface)]"
onclick={onnewproject}
>
New project
</button>
</div>
</div>
{:else}
<div class="flex flex-col gap-5">
{#if containers.length > 0}
<section class="flex flex-col gap-2">
<h2
class="text-[10px] font-semibold uppercase tracking-wider text-[var(--color-ink-muted)]"
>
Folders
</h2>
<div class="grid grid-cols-[repeat(auto-fill,minmax(210px,1fr))] gap-2">
{#each containers as entry (entry.path)}
<div
class="group relative flex items-center gap-2.5 rounded-lg border px-3 py-2.5 transition hover:border-[var(--color-accent)] hover:bg-[var(--color-surface)]
{dropTarget === entry.path
? 'border-[var(--color-accent)] bg-[var(--color-accent-soft)]'
: 'border-[var(--color-line)] bg-[var(--color-surface-sunken)]'}"
draggable="true"
ondragstart={(event) => startDrag(event, entry.path)}
ondragend={endDrag}
ondragover={(event) => allowDrop(event, entry.path)}
ondragleave={() => {
if (dropTarget === entry.path) dropTarget = null;
}}
ondrop={(event) => handleDrop(event, entry.path)}
oncontextmenu={(event) => openContextMenu(event, entry.path)}
>
<button
class="flex min-w-0 flex-1 items-center gap-2.5 text-left"
ondblclick={() => activate(entry)}
onclick={() => activate(entry)}
>
<Icon
icon={entry.kind === "project"
? "ph:folder-star-fill"
: "ph:folder-fill"}
class="shrink-0 text-2xl {entry.kind === 'project'
? 'text-[var(--color-accent)]'
: 'text-[var(--color-ink-muted)]'}"
/>
<span class="min-w-0 flex-1">
<span class="flex items-center gap-1.5">
<span
class="truncate text-xs font-medium"
title={entry.name}
>
{entry.name}
</span>
{@render syncBadge(entry)}
</span>
<span
class="block truncate text-[10px] text-[var(--color-ink-muted)]"
>
{entry.kind === "project" ? "Project · " : ""}{entry.child_count}
items
</span>
</span>
</button>
{@render actions(entry)}
</div>
{/each}
</div>
</section>
{/if}
{#if documents.length > 0}
<section class="flex flex-col gap-2">
<h2
class="text-[10px] font-semibold uppercase tracking-wider text-[var(--color-ink-muted)]"
>
Documents
</h2>
<div class="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
{#each documents as entry (entry.path)}
<div
class="group relative flex flex-col overflow-hidden rounded-lg border border-[var(--color-line)] bg-[var(--color-surface)] transition hover:border-[var(--color-accent)] hover:shadow-md"
draggable="true"
ondragstart={(event) => startDrag(event, entry.path)}
ondragend={endDrag}
oncontextmenu={(event) => openContextMenu(event, entry.path)}
>
<button
class="flex flex-col text-left"
ondblclick={() => activate(entry)}
onclick={() => activate(entry)}
>
<span
class="flex h-24 items-center justify-center overflow-hidden border-b border-[var(--color-line)] bg-[var(--color-surface-muted)]"
>
{#if thumbs[entry.path]?.kind === "svg"}
<span
class="flex w-full origin-top scale-100 items-start justify-center bg-white p-1 [&_svg]:h-auto [&_svg]:w-full"
>
{@html thumbs[entry.path].data}
</span>
{:else if thumbs[entry.path]?.kind === "image"}
<img
src={thumbs[entry.path].data}
alt={entry.name}
class="h-full w-full object-contain"
/>
{:else}
<Icon
icon={iconFor[entry.kind]}
class="text-3xl {colorFor[entry.kind]}"
/>
{/if}
</span>
</button>
<div class="flex items-center gap-1 px-2.5 py-2">
<button
class="flex min-w-0 flex-1 flex-col gap-0.5 text-left"
onclick={() => activate(entry)}
>
<span class="flex items-center gap-1.5">
<span
class="min-w-0 flex-1 truncate text-xs font-medium"
title={entry.name}
>
{entry.name}
</span>
{@render syncBadge(entry)}
</span>
<span class="text-[10px] text-[var(--color-ink-muted)]">
{#if entry.cloud_linked && entry.sync_state === "pending"}
Not synced
{:else if entry.cloud_linked}
Synced {relativeTime(entry.last_synced_at)}
{:else}
{formatSize(entry.size)}
{#if entry.modified}
· {formatDate(entry.modified)}
{/if}
{/if}
</span>
</button>
{@render actions(entry)}
</div>
</div>
{/each}
</div>
</section>
{/if}
</div>
{/if}
</div>
{:else}
<div class="scroll-thin flex-1 overflow-y-auto p-4">
{#if !app.account}
<div
class="flex h-full flex-col items-center justify-center gap-3 text-[var(--color-ink-muted)]"
>
<Icon icon="ph:cloud-slash" class="text-5xl" />
<p class="max-w-xs text-center text-sm">
Connect a TypstDrive account to sync your projects across devices.
</p>
<button
class="rounded-md bg-[var(--color-accent)] px-3 py-2 text-xs font-medium text-white hover:opacity-90"
onclick={onsignin}
>
Sign in
</button>
</div>
{:else}
<div class="mb-4 flex items-center gap-2">
<button
class="flex items-center gap-2 rounded-lg border px-3.5 py-2 text-xs font-medium transition
{app.cloudFolder !== 'shared'
? 'border-[var(--color-accent)] bg-[var(--color-accent)] text-white shadow-sm'
: 'border-[var(--color-line)] bg-[var(--color-surface)] text-[var(--color-ink-muted)] hover:border-[var(--color-accent)] hover:text-[var(--color-ink)]'}"
onclick={() => openCloudFolder(null)}
>
<Icon icon="ph:cloud-fill" class="text-base" />
My Drive
</button>
<button
class="flex items-center gap-2 rounded-lg border px-3.5 py-2 text-xs font-medium transition
{app.cloudFolder === 'shared'
? 'border-[var(--color-accent)] bg-[var(--color-accent)] text-white shadow-sm'
: 'border-[var(--color-line)] bg-[var(--color-surface)] text-[var(--color-ink-muted)] hover:border-[var(--color-accent)] hover:text-[var(--color-ink)]'}"
onclick={() => openCloudFolder("shared")}
>
<Icon icon="ph:users-three-fill" class="text-base" />
Shared with me
</button>
{#if app.cloudLoading}
<Icon
icon="ph:circle-notch"
class="animate-spin text-base text-[var(--color-accent)]"
/>
{/if}
</div>
{#if cloudTrail.length > 0}
<div class="mb-3 flex flex-wrap items-center gap-1 text-xs">
<button
class="rounded px-2 py-1 text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface)] hover:text-[var(--color-ink)]"
onclick={() => openCloudFolder(null)}
>
My Drive
</button>
{#each cloudTrail as folder, index}
<Icon
icon="ph:caret-right"
class="text-[10px] text-[var(--color-ink-muted)]"
/>
<button
class="rounded px-2 py-1 transition hover:bg-[var(--color-surface)]
{index === cloudTrail.length - 1
? 'font-medium'
: 'text-[var(--color-ink-muted)] hover:text-[var(--color-ink)]'}"
onclick={() => openCloudFolder(folder.id)}
>
{folder.name}
</button>
{/each}
</div>
{/if}
{#if app.cloudFolders.length > 0}
<div
class="mb-4 grid grid-cols-[repeat(auto-fill,minmax(210px,1fr))] gap-2"
>
{#each app.cloudFolders as folder (folder.id)}
<div class="relative">
<button
class="flex w-full items-center gap-2.5 rounded-lg border border-[var(--color-line)] bg-[var(--color-surface-sunken)] px-3 py-2.5 text-left transition hover:border-[var(--color-accent)] hover:bg-[var(--color-surface)]"
onclick={() => openCloudFolder(folder.id)}
oncontextmenu={(event) =>
openCloudContextMenu(event, `folder:${folder.id}`)}
>
<Icon
icon="ph:folder-fill"
class="shrink-0 text-2xl text-[var(--color-ink-muted)]"
/>
<span class="truncate text-xs font-medium">{folder.name}</span>
</button>
{@render cloudMenu(
`folder:${folder.id}`,
() => openCloudFolder(folder.id),
() => {},
null,
null,
)}
</div>
{/each}
</div>
{/if}
{#if app.cloudDocuments.length > 0}
<h2
class="mb-2 text-[10px] font-semibold uppercase tracking-wider text-[var(--color-ink-muted)]"
>
Documents
</h2>
<div
class="mb-4 grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-3"
>
{#each app.cloudDocuments as entry (entry.id)}
{@const linked = linkedDocument(entry.id)}
{@render cloudCard(
`document:${entry.id}`,
"ph:file-text",
entry.title,
linked
? `Document · ${entry.role}`
: `${entry.role} · ${formatDate(entry.updated_at)}`,
linked,
linked ? () => openTarget(linked.path) : null,
() => ondownloaddocument(entry.id, entry.title),
linked ? () => onremovedownload(linked.path) : null,
() => ondeletedocument(entry.id),
)}
{/each}
</div>
{/if}
{#if app.cloudFiles.length > 0}
<h2
class="mb-2 text-[10px] font-semibold uppercase tracking-wider text-[var(--color-ink-muted)]"
>
Images and fonts
</h2>
<div
class="mb-4 grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-3"
>
{#each app.cloudFiles as file (file.id)}
<div
class="flex items-center gap-2.5 rounded-lg border border-[var(--color-line)] bg-[var(--color-surface)] p-3 transition hover:border-[var(--color-accent)]"
oncontextmenu={(event) =>
openCloudContextMenu(event, `file:${file.id}`)}
>
{@render cloudMenu(
`file:${file.id}`,
null,
() => ondownloadfile(file.id, file.name),
null,
() => ondeletefile(file.id),
)}
<Icon
icon={api.isImagePath(file.name)
? "ph:image"
: /\.(ttf|otf|ttc|otc)$/i.test(file.name)
? "ph:text-aa"
: "ph:file"}
class="shrink-0 text-xl text-[var(--color-accent)]"
/>
<div class="min-w-0 flex-1">
<p class="truncate text-xs font-medium" title={file.name}>
{file.name}
</p>
<p class="truncate text-[10px] text-[var(--color-ink-muted)]">
{formatDate(file.created_at)}
</p>
</div>
<button
class="shrink-0 rounded-md border border-[var(--color-line)] p-1.5 text-[var(--color-ink-muted)] transition hover:border-[var(--color-accent)] hover:text-[var(--color-accent)]"
onclick={() => ondownloadfile(file.id, file.name)}
title="Add to shared assets on this device"
aria-label="Download"
>
<Icon icon="ph:download-simple" />
</button>
</div>
{/each}
</div>
{/if}
{#if app.cloudProjects.length === 0 && app.cloudDocuments.length === 0 && app.cloudFolders.length === 0 && app.cloudFiles.length === 0}
<div
class="flex flex-col items-center justify-center gap-3 py-16 text-[var(--color-ink-muted)]"
>
<Icon icon="ph:cloud" class="text-5xl" />
<p class="text-sm">Nothing here yet.</p>
{#if app.cloudFolder !== "shared"}
<div class="flex gap-2">
<button
class="rounded-md bg-[var(--color-accent)] px-3 py-1.5 text-xs font-medium text-white hover:opacity-90"
onclick={onnewclouddocument}
>
New document
</button>
<button
class="rounded-md border border-[var(--color-line)] px-3 py-1.5 text-xs hover:bg-[var(--color-surface)]"
onclick={onnewcloudproject}
>
New project
</button>
</div>
{/if}
</div>
{/if}
{#if app.cloudProjects.length > 0}
<h2
class="mb-2 text-[10px] font-semibold uppercase tracking-wider text-[var(--color-ink-muted)]"
>
Cloud projects
</h2>
{/if}
<div class="grid grid-cols-[repeat(auto-fill,minmax(220px,1fr))] gap-3">
{#each app.cloudProjects as project (project.id)}
{@const linked = linkedProject(project.id)}
{@render cloudCard(
`project:${project.id}`,
"ph:folder-star",
project.name,
linked
? `Project · ${project.role}`
: `${project.role} · ${formatDate(project.updated_at)}`,
linked,
linked ? () => openTarget(linked.path) : null,
() => oncloneproject(project.id, project.name),
null,
project.role === "owner" ? () => ondeleteproject(project.id) : null,
)}
{/each}
</div>
{/if}
</div>
{/if}
</div>