Add Typst Desktop app

This commit is contained in:
2026-07-18 15:00:22 -04:00
commit 8853c614d7
72 changed files with 15548 additions and 0 deletions
+513
View File
@@ -0,0 +1,513 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import * as api from "$lib/ts/api";
import type { BrowseEntry, EntryKind } from "$lib/ts/api";
import {
app,
breadcrumbs,
browseTo,
openTarget,
} from "$lib/ts/state.svelte";
interface Props {
onnewfolder: () => void;
onnewproject: () => void;
onnewdocument: () => void;
onupload: () => void;
onassets: () => void;
onrename: (entry: BrowseEntry) => void;
ondelete: (entry: BrowseEntry) => void;
onlink: (entry: BrowseEntry) => void;
onviewimage: (paths: string[], index: number) => void;
onclonespace: (spaceId: string, name: string) => void;
ondeletespace: (spaceId: string) => void;
onnewspace: () => void;
onsignin: () => void;
}
let {
onnewfolder,
onnewproject,
onnewdocument,
onupload,
onassets,
onrename,
ondelete,
onlink,
onviewimage,
onclonespace,
ondeletespace,
onnewspace,
onsignin,
}: Props = $props();
let menuFor = $state<string | null>(null);
const trail = $derived(breadcrumbs());
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 }>>({});
$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 localSpaceIds = $derived(
new Set(
app.entries
.filter((entry) => entry.space_id)
.map((entry) => entry.space_id as string),
),
);
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)]",
};
const imagePaths = $derived(
app.entries
.filter((entry) => api.isImagePath(entry.path))
.map((entry) => entry.path),
);
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 actions(entry: BrowseEntry, offset: string)}
<button
class="absolute right-2 {offset} rounded p-1 text-[var(--color-ink-muted)] opacity-0 transition group-hover:opacity-100 hover:bg-[var(--color-surface-sunken)]"
onclick={() => (menuFor = menuFor === entry.path ? null : entry.path)}
aria-label="Actions"
>
<Icon icon="ph:dots-three-vertical" />
</button>
{#if menuFor === entry.path}
<div
class="absolute right-2 top-9 z-10 flex w-40 flex-col rounded-md border border-[var(--color-line)] bg-[var(--color-surface)] py-1 text-xs shadow-lg"
>
{#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.space_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}
<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}
<div class="flex h-full flex-col bg-[var(--color-surface-muted)]">
<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={onassets}
>
<Icon icon="ph:images" />
Assets
</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={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={onnewdocument}
>
<Icon icon="ph:file-plus" />
Document
</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={onnewproject}
>
<Icon icon="ph:plus" />
New project
</button>
{:else if app.account}
<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={onnewspace}
>
<Icon icon="ph:plus" />
New space
</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)]'}"
onclick={() => browseTo("")}
>
<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)]'}"
onclick={() => browseTo(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={onnewproject}
>
New project
</button>
<button
class="rounded-md border border-[var(--color-line)] px-3 py-1.5 text-xs hover:bg-[var(--color-surface)]"
onclick={onnewdocument}
>
New document
</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 border-[var(--color-line)] bg-[var(--color-surface-sunken)] px-3 py-2.5 transition hover:border-[var(--color-accent)] hover:bg-[var(--color-surface)]"
>
<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>
{#if entry.space_id}
<Icon
icon="ph:cloud-check"
class="shrink-0 text-xs text-[var(--color-success)]"
/>
{/if}
</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, "top-2")}
</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"
>
<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>
<span class="flex flex-col gap-0.5 px-2.5 py-2">
<span
class="truncate text-xs font-medium"
title={entry.name}
>
{entry.name}
</span>
<span class="text-[10px] text-[var(--color-ink-muted)]">
{formatSize(entry.size)}
{#if entry.modified}
· {formatDate(entry.modified)}
{/if}
</span>
</span>
</button>
{@render actions(entry, "top-2")}
</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 if app.spaces.length === 0}
<div
class="flex h-full flex-col items-center justify-center gap-3 text-[var(--color-ink-muted)]"
>
<Icon icon="ph:cloud" class="text-5xl" />
<p class="text-sm">No cloud spaces yet.</p>
</div>
{:else}
<div class="grid grid-cols-[repeat(auto-fill,minmax(220px,1fr))] gap-3">
{#each app.spaces as space (space.id)}
<div
class="group flex flex-col gap-2 rounded-lg border border-[var(--color-line)] bg-[var(--color-surface)] p-3 transition hover:border-[var(--color-accent)]"
>
<div class="flex items-center gap-2">
<Icon icon="ph:cloud" class="text-xl text-[var(--color-accent)]" />
{#if localSpaceIds.has(space.id)}
<span title="Downloaded to this device" class="flex">
<Icon
icon="ph:hard-drives"
class="text-sm text-[var(--color-success)]"
/>
</span>
{/if}
</div>
<span class="truncate text-xs font-medium">{space.name}</span>
<span class="text-[10px] text-[var(--color-ink-muted)]">
{space.role} · {formatDate(space.updated_at)}
</span>
<div class="mt-1 flex gap-1">
{#if !localSpaceIds.has(space.id)}
<button
class="flex flex-1 items-center justify-center gap-1 rounded border border-[var(--color-line)] px-2 py-1 text-[10px] hover:bg-[var(--color-surface-muted)]"
onclick={() => onclonespace(space.id, space.name)}
>
<Icon icon="ph:download-simple" />
Download
</button>
{/if}
{#if space.role === "owner"}
<button
class="rounded border border-[var(--color-line)] px-2 py-1 text-[10px] text-[var(--color-danger)] hover:bg-[var(--color-surface-muted)]"
onclick={() => ondeletespace(space.id)}
aria-label="Delete space"
>
<Icon icon="ph:trash" />
</button>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>
{/if}
</div>