Cloud folder organization, file uploads, appearance themes, title bar rework, faster autosync

This commit is contained in:
2026-07-20 23:58:53 -04:00
parent 46e2de2af9
commit 62f2f8483f
9 changed files with 808 additions and 97 deletions
+88
View File
@@ -31,6 +31,94 @@
--color-success: #4cc47f;
}
:root[data-color-theme="slate"] {
--color-surface: #ffffff;
--color-surface-muted: #f2f5f7;
--color-surface-sunken: #e6ebef;
--color-line: #d7dee4;
--color-ink: #12181f;
--color-ink-muted: #5a6672;
--color-accent: #0f9b8e;
--color-accent-soft: #e1f5f2;
}
:root[data-color-theme="slate"][data-theme="dark"] {
--color-surface: #12181e;
--color-surface-muted: #182027;
--color-surface-sunken: #1f2830;
--color-line: #2b3640;
--color-ink: #eef2f5;
--color-ink-muted: #8b98a5;
--color-accent: #3fc2b3;
--color-accent-soft: #163330;
}
:root[data-color-theme="sunset"] {
--color-surface: #fffdf9;
--color-surface-muted: #faf3e9;
--color-surface-sunken: #f3e6d3;
--color-line: #e7d5b8;
--color-ink: #241a10;
--color-ink-muted: #7a6650;
--color-accent: #e8623f;
--color-accent-soft: #fbe4dc;
}
:root[data-color-theme="sunset"][data-theme="dark"] {
--color-surface: #1f1712;
--color-surface-muted: #261c15;
--color-surface-sunken: #2f241a;
--color-line: #3d2f22;
--color-ink: #f7ede1;
--color-ink-muted: #b9a48c;
--color-accent: #f4805c;
--color-accent-soft: #3a2419;
}
:root[data-color-theme="forest"] {
--color-surface: #fbfdfb;
--color-surface-muted: #eef5ee;
--color-surface-sunken: #dfebe0;
--color-line: #c9dccb;
--color-ink: #12201a;
--color-ink-muted: #57685c;
--color-accent: #2f9457;
--color-accent-soft: #dcf0e2;
}
:root[data-color-theme="forest"][data-theme="dark"] {
--color-surface: #121a15;
--color-surface-muted: #17211b;
--color-surface-sunken: #1e2c22;
--color-line: #2b3c30;
--color-ink: #e9f3ec;
--color-ink-muted: #8fa896;
--color-accent: #4fbf7c;
--color-accent-soft: #1a3324;
}
:root[data-color-theme="grape"] {
--color-surface: #fdfbff;
--color-surface-muted: #f4eefb;
--color-surface-sunken: #e8daf5;
--color-line: #d7c3ec;
--color-ink: #1c1526;
--color-ink-muted: #6a5d7c;
--color-accent: #8b47d6;
--color-accent-soft: #f0e2fb;
}
:root[data-color-theme="grape"][data-theme="dark"] {
--color-surface: #17121e;
--color-surface-muted: #1d1725;
--color-surface-sunken: #251d30;
--color-line: #362a42;
--color-ink: #f1eaf7;
--color-ink-muted: #a998b8;
--color-accent: #b47af0;
--color-accent-soft: #2e2140;
}
:root[data-contrast="high"] {
--color-line: #9aa0ab;
--color-ink-muted: #33363c;
+166 -53
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import * as api from "$lib/ts/api";
import type { BrowseEntry, EntryKind } from "$lib/ts/api";
import type { BrowseEntry, CloudFile, CloudFolder, EntryKind } from "$lib/ts/api";
import { clampMenu } from "$lib/ts/menu-position";
import {
app,
@@ -31,11 +31,16 @@
onremovedownload: (path: string) => void;
ondownloadfile: (fileId: string, name: string) => void;
ondeletefile: (fileId: string) => void;
onuploadfile: () => void;
onrenamefile: (file: CloudFile) => void;
oncloneproject: (cloudProjectId: string, name: string) => void;
ondeleteproject: (cloudProjectId: string) => void;
ondeletedocument: (documentId: string) => void;
onnewcloudproject: () => void;
onnewclouddocument: () => void;
onnewcloudfolder: () => void;
onrenamecloudfolder: (folder: CloudFolder) => void;
ondeletecloudfolder: (folder: CloudFolder) => void;
onsignin: () => void;
}
@@ -53,11 +58,16 @@
onremovedownload,
ondownloadfile,
ondeletefile,
onuploadfile,
onrenamefile,
oncloneproject,
ondeleteproject,
ondeletedocument,
onnewcloudproject,
onnewclouddocument,
onnewcloudfolder,
onrenamecloudfolder,
ondeletecloudfolder,
onsignin,
}: Props = $props();
@@ -234,6 +244,55 @@
if (source) moveTo(source, destination);
}
type CloudDragItem = {
kind: "project" | "document" | "folder" | "file";
id: string;
};
let cloudDragging = $state<CloudDragItem | null>(null);
let cloudDropTarget = $state<string | null>(null);
async function moveCloudItem(item: CloudDragItem, folderId: string | null) {
try {
if (item.kind === "folder") {
await api.cloudMoveFolder(item.id, folderId);
} else if (item.kind === "project") {
await api.cloudMoveProject(item.id, folderId);
} else if (item.kind === "document") {
await api.cloudMoveDocument(item.id, folderId);
} else {
await api.cloudMoveFile(item.id, folderId);
}
await refreshCloud();
} catch (error) {
setError(error);
}
}
function startCloudDrag(event: DragEvent, item: CloudDragItem) {
cloudDragging = item;
event.dataTransfer?.setData("text/plain", item.id);
}
function endCloudDrag() {
cloudDragging = null;
cloudDropTarget = null;
}
function allowCloudDrop(event: DragEvent, folderId: string) {
if (!cloudDragging) return;
if (cloudDragging.kind === "folder" && cloudDragging.id === folderId) return;
event.preventDefault();
cloudDropTarget = folderId;
}
function handleCloudDrop(event: DragEvent, folderId: string) {
event.preventDefault();
const item = cloudDragging;
cloudDragging = null;
cloudDropTarget = null;
if (item) moveCloudItem(item, folderId === "" ? null : folderId);
}
function activate(entry: BrowseEntry) {
if (entry.kind === "folder") {
browseTo(entry.path);
@@ -262,6 +321,7 @@
onopen: (() => void) | null,
ondownload: () => void,
onremove: (() => void) | null,
onrename: (() => void) | null,
ondelete: (() => void) | null,
)}
{#if cloudMenuFor === id}
@@ -302,6 +362,17 @@
Remove from this device
</button>
{/if}
{#if onrename}
<button
class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]"
onclick={() => {
onrename();
cloudMenuFor = null;
}}
>
Rename
</button>
{/if}
{#if ondelete}
<button
class="px-3 py-1.5 text-left text-[var(--color-danger)] hover:bg-[var(--color-surface-sunken)]"
@@ -328,11 +399,18 @@
onremove: (() => void) | null,
ondelete: (() => void) | null,
)}
{@const [dragKind, dragId] = id.split(":") as [
"project" | "document",
string,
]}
<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)}
draggable="true"
ondragstart={(event) => startCloudDrag(event, { kind: dragKind, id: dragId })}
ondragend={endCloudDrag}
>
{@render cloudMenu(id, onopen, ondownload, onremove, ondelete)}
{@render cloudMenu(id, onopen, ondownload, onremove, null, ondelete)}
<div
class="flex h-24 items-center justify-center overflow-hidden border-b border-[var(--color-line)] bg-[var(--color-surface-muted)]"
>
@@ -545,20 +623,39 @@
<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]}
{#if app.scope === "local"}
<button
class="flex items-center gap-1.5 rounded px-2 py-1 text-xs 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="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")}
class="rounded px-2 py-1 text-xs 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)}
>
<Icon {icon} />
{label}
{crumb.name}
</button>
{/each}
</div>
{/if}
<div class="flex-1"></div>
@@ -592,6 +689,20 @@
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={onuploadfile}
>
<Icon icon="ph:upload-simple" />
Upload
</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={onnewcloudfolder}
>
<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={onnewcloudproject}
@@ -610,42 +721,6 @@
</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
@@ -840,8 +915,14 @@
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)]'}"
: 'border-[var(--color-line)] bg-[var(--color-surface)] text-[var(--color-ink-muted)] hover:border-[var(--color-accent)] hover:text-[var(--color-ink)]'}
{cloudDropTarget === '' ? 'ring-2 ring-[var(--color-accent)]' : ''}"
onclick={() => openCloudFolder(null)}
ondragover={(event) => allowCloudDrop(event, "")}
ondragleave={() => {
if (cloudDropTarget === "") cloudDropTarget = null;
}}
ondrop={(event) => handleCloudDrop(event, "")}
>
<Icon icon="ph:cloud-fill" class="text-base" />
My Drive
@@ -869,8 +950,14 @@
{#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)]"
class="rounded px-2 py-1 text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface)] hover:text-[var(--color-ink)]
{cloudDropTarget === '' ? 'bg-[var(--color-accent-soft)] text-[var(--color-accent)]' : ''}"
onclick={() => openCloudFolder(null)}
ondragover={(event) => allowCloudDrop(event, "")}
ondragleave={() => {
if (cloudDropTarget === "") cloudDropTarget = null;
}}
ondrop={(event) => handleCloudDrop(event, "")}
>
My Drive
</button>
@@ -884,8 +971,16 @@
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)]'}"
: 'text-[var(--color-ink-muted)] hover:text-[var(--color-ink)]'}
{cloudDropTarget === folder.id
? 'bg-[var(--color-accent-soft)] text-[var(--color-accent)]'
: ''}"
onclick={() => openCloudFolder(folder.id)}
ondragover={(event) => allowCloudDrop(event, folder.id)}
ondragleave={() => {
if (cloudDropTarget === folder.id) cloudDropTarget = null;
}}
ondrop={(event) => handleCloudDrop(event, folder.id)}
>
{folder.name}
</button>
@@ -900,10 +995,22 @@
{#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)]"
class="flex w-full items-center gap-2.5 rounded-lg border px-3 py-2.5 text-left transition hover:border-[var(--color-accent)] hover:bg-[var(--color-surface)]
{cloudDropTarget === folder.id
? 'border-[var(--color-accent)] bg-[var(--color-accent-soft)]'
: 'border-[var(--color-line)] bg-[var(--color-surface-sunken)]'}"
onclick={() => openCloudFolder(folder.id)}
oncontextmenu={(event) =>
openCloudContextMenu(event, `folder:${folder.id}`)}
draggable="true"
ondragstart={(event) =>
startCloudDrag(event, { kind: "folder", id: folder.id })}
ondragend={endCloudDrag}
ondragover={(event) => allowCloudDrop(event, folder.id)}
ondragleave={() => {
if (cloudDropTarget === folder.id) cloudDropTarget = null;
}}
ondrop={(event) => handleCloudDrop(event, folder.id)}
>
<Icon
icon="ph:folder-fill"
@@ -916,7 +1023,8 @@
() => openCloudFolder(folder.id),
() => {},
null,
null,
() => onrenamecloudfolder(folder),
() => ondeletecloudfolder(folder),
)}
</div>
{/each}
@@ -965,12 +1073,17 @@
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}`)}
draggable="true"
ondragstart={(event) =>
startCloudDrag(event, { kind: "file", id: file.id })}
ondragend={endCloudDrag}
>
{@render cloudMenu(
`file:${file.id}`,
null,
() => ondownloadfile(file.id, file.name),
null,
() => onrenamefile(file),
() => ondeletefile(file.id),
)}
<Icon
+39 -7
View File
@@ -9,10 +9,12 @@
import {
app,
applyTheme,
applyColorTheme,
applyAccent,
applyTextScale,
applyReduceMotion,
applyContrast,
colorThemes,
refreshEntries,
restartAutoSync,
setError,
@@ -85,7 +87,7 @@
}, 600);
});
let autosaveSeconds = $state(untrack(() => app.settings?.autosave_seconds ?? 0));
let syncMinutes = $state(untrack(() => app.settings?.sync_minutes ?? 0));
let syncSeconds = $state(untrack(() => app.settings?.sync_seconds ?? 0));
let saving = $state(false);
let info = $state<AppInfo | null>(null);
@@ -107,9 +109,11 @@
const syncOptions = [
{ value: 0, label: "Off" },
{ value: 1, label: "1 minute" },
{ value: 2, label: "2 minutes" },
{ value: 5, label: "5 minutes" },
{ value: 15, label: "15 seconds" },
{ value: 30, label: "30 seconds" },
{ value: 60, label: "1 minute" },
{ value: 120, label: "2 minutes" },
{ value: 300, label: "5 minutes" },
];
const links = [
@@ -149,7 +153,7 @@
workspaceRoot,
serverUrl,
autosaveSeconds,
syncMinutes,
syncSeconds,
});
restartAutoSync();
await refreshEntries();
@@ -250,6 +254,30 @@
</span>
</div>
<div class="flex flex-col gap-2 text-xs">
<span class="font-medium text-[var(--color-ink-muted)]">Color theme</span>
<div class="flex flex-wrap gap-2">
{#each colorThemes as entry}
<button
class="flex items-center gap-1.5 rounded-md border px-3 py-2 transition
{app.colorTheme === entry.id
? 'border-[var(--color-accent)] bg-[var(--color-accent-soft)] text-[var(--color-accent)]'
: 'border-[var(--color-line)] text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-muted)]'}"
onclick={() => applyColorTheme(entry.id)}
>
<span
class="h-3 w-3 rounded-full"
style="background-color: {entry.accent}"
></span>
{entry.label}
</button>
{/each}
</div>
<span class="text-[var(--color-ink-muted)]">
Sets the surface colors and default accent for the app.
</span>
</div>
<div class="flex flex-col gap-2 text-xs">
<span class="font-medium text-[var(--color-ink-muted)]">
Accent color
@@ -420,7 +448,7 @@
<span class="font-medium text-[var(--color-ink-muted)]">
Automatic sync
</span>
<select class={fieldClass} bind:value={syncMinutes}>
<select class={fieldClass} bind:value={syncSeconds}>
{#each syncOptions as option}
<option value={option.value}>{option.label}</option>
{/each}
@@ -434,7 +462,11 @@
{:else}
<div class="flex flex-col gap-4">
<div class="flex items-center gap-3">
<Icon icon="ph:file-code" class="text-3xl text-[var(--color-accent)]" />
<span
class="flex h-16 w-16 items-center justify-center rounded-lg bg-[var(--color-accent)]"
>
<img src="/favicon.png" alt="Typst Desktop" class="h-11 w-11" />
</span>
<div>
<p class="text-sm font-semibold">Typst Desktop</p>
<p class="text-xs text-[var(--color-ink-muted)]">
+50 -8
View File
@@ -7,7 +7,7 @@ export interface Settings {
account_email: string | null;
account_username: string | null;
autosave_seconds: number;
sync_minutes: number;
sync_seconds: number;
}
export interface FileEntry {
@@ -57,6 +57,7 @@ export interface ProjectSummary {
id: string;
name: string;
entrypoint: string;
folder_id: string | null;
role: string;
updated_at: string;
}
@@ -248,7 +249,7 @@ export const updateSettings = (changes: {
workspaceRoot?: string;
serverUrl?: string;
autosaveSeconds?: number;
syncMinutes?: number;
syncSeconds?: number;
}) => invoke<Settings>("update_settings", changes);
export interface CompatibilityStatus {
@@ -305,6 +306,21 @@ export interface DocumentLink {
export const cloudListFolders = () =>
invoke<CloudFolder[]>("cloud_list_folders");
export const cloudCreateFolder = (name: string, parentId?: string | null) =>
invoke<CloudFolder>("cloud_create_folder", {
name,
parentId: parentId ?? null,
});
export const cloudRenameFolder = (folderId: string, name: string) =>
invoke<CloudFolder>("cloud_rename_folder", { folderId, name });
export const cloudMoveFolder = (folderId: string, parentId: string | null) =>
invoke<CloudFolder>("cloud_move_folder", { folderId, parentId });
export const cloudDeleteFolder = (folderId: string) =>
invoke<void>("cloud_delete_folder", { folderId });
export const cloudListDocuments = (folderId?: string | null) =>
invoke<CloudDocument[]>("cloud_list_documents", {
folderId: folderId ?? null,
@@ -329,6 +345,15 @@ export const cloudDownloadFile = (fileId: string) =>
export const cloudDeleteFile = (fileId: string) =>
invoke<void>("cloud_delete_file", { fileId });
export const cloudUploadFile = (path: string, folderId?: string | null) =>
invoke<CloudFile>("cloud_upload_file", { path, folderId: folderId ?? null });
export const cloudRenameFile = (fileId: string, name: string) =>
invoke<CloudFile>("cloud_rename_file", { fileId, name });
export const cloudMoveFile = (fileId: string, folderId: string | null) =>
invoke<CloudFile>("cloud_move_file", { fileId, folderId });
export const cloudDownloadDocument = (documentId: string, parent: string) =>
invoke<string>("cloud_download_document", { documentId, parent });
@@ -338,6 +363,9 @@ export const cloudDeleteDocument = (documentId: string) =>
export const cloudCreateDocument = (path: string, title: string) =>
invoke<string>("cloud_create_document", { path, title });
export const cloudMoveDocument = (documentId: string, folderId: string | null) =>
invoke<CloudDocument>("cloud_move_document", { documentId, folderId });
export interface DocumentContent {
id: string;
title: string;
@@ -346,8 +374,11 @@ export interface DocumentContent {
content: string;
}
export const cloudNewDocument = (title: string) =>
invoke<DocumentContent>("cloud_new_document", { title });
export const cloudNewDocument = (title: string, folderId?: string | null) =>
invoke<DocumentContent>("cloud_new_document", {
title,
folderId: folderId ?? null,
});
export const cloudSyncDocument = (path: string) =>
invoke<SyncReport>("cloud_sync_document", { path });
@@ -384,14 +415,25 @@ export const cloudDocumentLink = (path: string) =>
export const cloudUnlinkDocument = (path: string) =>
invoke<void>("cloud_unlink_document", { path });
export const cloudCreateProject = (name: string) =>
invoke<ProjectSummary>("cloud_create_project", { name });
export const cloudCreateProject = (name: string, folderId?: string | null) =>
invoke<ProjectSummary>("cloud_create_project", {
name,
folderId: folderId ?? null,
});
export const cloudDeleteProject = (cloudProjectId: string) =>
invoke<void>("cloud_delete_project", { cloudProjectId });
export const cloudCloneProject = (cloudProjectId: string, projectName: string) =>
invoke<SyncReport>("cloud_clone_project", { cloudProjectId, projectName });
export const cloudMoveProject = (
cloudProjectId: string,
folderId: string | null,
) => invoke<ProjectSummary>("cloud_move_project", { cloudProjectId, folderId });
export const cloudCloneProject = (
cloudProjectId: string,
projectName: string,
parent: string,
) => invoke<SyncReport>("cloud_clone_project", { cloudProjectId, projectName, parent });
export const cloudLinkProject = (project: string, cloudProjectId?: string) =>
invoke<SyncReport>("cloud_link_project", {
+35 -5
View File
@@ -22,6 +22,15 @@ export type LspStatus = "off" | "starting" | "on" | "unavailable";
export type ThemePreference = "light" | "dark" | "system";
export type TextScale = "small" | "default" | "large" | "xlarge";
export type ContrastLevel = "normal" | "high";
export type ColorTheme = "default" | "slate" | "sunset" | "forest" | "grape";
export const colorThemes: { id: ColorTheme; label: string; accent: string }[] = [
{ id: "default", label: "Default", accent: "#3b6cf6" },
{ id: "slate", label: "Slate", accent: "#0f9b8e" },
{ id: "sunset", label: "Sunset", accent: "#e8623f" },
{ id: "forest", label: "Forest", accent: "#2f9457" },
{ id: "grape", label: "Grape", accent: "#8b47d6" },
];
interface AppState {
view: View;
@@ -58,6 +67,7 @@ interface AppState {
error: string;
theme: "light" | "dark";
themePreference: ThemePreference;
colorTheme: ColorTheme;
accent: string | null;
textScale: TextScale;
reduceMotion: boolean;
@@ -99,6 +109,7 @@ export const app = $state<AppState>({
error: "",
theme: "light",
themePreference: "light",
colorTheme: "default",
accent: null,
textScale: "default",
reduceMotion: false,
@@ -146,6 +157,7 @@ export function clearMessages() {
}
const THEME_KEY = "typst-desktop-theme";
const COLOR_THEME_KEY = "typst-desktop-color-theme";
const ACCENT_KEY = "typst-desktop-accent";
const TEXT_SCALE_KEY = "typst-desktop-text-scale";
const REDUCE_MOTION_KEY = "typst-desktop-reduce-motion";
@@ -182,6 +194,13 @@ export function applyTheme(preference: ThemePreference) {
if (app.accent) applyAccent(app.accent);
}
export function applyColorTheme(theme: ColorTheme) {
app.colorTheme = theme;
document.documentElement.dataset.colorTheme = theme;
localStorage.setItem(COLOR_THEME_KEY, theme);
applyAccent(null);
}
function hexToRgb(hex: string): [number, number, number] {
const value = parseInt(hex.replace("#", ""), 16);
return [(value >> 16) & 255, (value >> 8) & 255, value & 255];
@@ -296,6 +315,13 @@ export async function bootstrap() {
: "light",
);
const storedColorTheme = localStorage.getItem(COLOR_THEME_KEY);
const validColorTheme = colorThemes.some((entry) => entry.id === storedColorTheme);
document.documentElement.dataset.colorTheme = validColorTheme
? (storedColorTheme as ColorTheme)
: "default";
app.colorTheme = validColorTheme ? (storedColorTheme as ColorTheme) : "default";
const storedAccent = localStorage.getItem(ACCENT_KEY);
if (storedAccent) applyAccent(storedAccent);
@@ -382,7 +408,11 @@ export async function refreshCloud() {
(folder) => (folder.parent_id ?? null) === app.cloudFolder,
);
app.cloudDocuments = documents;
app.cloudProjects = projects;
app.cloudProjects = projects.filter(
(project) =>
project.role !== "owner" ||
(project.folder_id ?? null) === app.cloudFolder,
);
app.cloudFiles = files;
}
} catch (error) {
@@ -414,7 +444,7 @@ export async function openCloudFolder(id: string | null | "shared") {
export async function downloadDocument(documentId: string, title: string) {
try {
const path = await api.cloudDownloadDocument(documentId, "");
const path = await api.cloudDownloadDocument(documentId, app.currentDir);
await refreshCloud();
setStatus(`Downloaded '${title}' to this device`);
return path;
@@ -634,12 +664,12 @@ export function restartAutoSync() {
syncTimer = null;
}
const minutes = app.settings?.sync_minutes ?? 0;
if (minutes <= 0) return;
const seconds = app.settings?.sync_seconds ?? 0;
if (seconds <= 0) return;
syncTimer = setInterval(() => {
autoSync();
}, minutes * 60 * 1000);
}, seconds * 1000);
}
async function autoSync() {
+118 -10
View File
@@ -25,7 +25,7 @@
import { insertText } from "$lib/ts/editor-actions";
import * as api from "$lib/ts/api";
import type { BrowseEntry } from "$lib/ts/api";
import type { BrowseEntry, CloudFile, CloudFolder } from "$lib/ts/api";
import { pickFiles } from "$lib/ts/import";
import {
app,
@@ -39,7 +39,6 @@
openTarget,
refreshAccount,
refreshCloud,
refreshCloudProjects,
refreshEntries,
refreshTarget,
removeDownloadedDocument,
@@ -64,9 +63,13 @@
| { kind: "save-document-to-cloud"; entry: BrowseEntry }
| { kind: "new-cloud-project" }
| { kind: "new-cloud-document" }
| { kind: "new-cloud-folder" }
| { kind: "rename-cloud-folder"; folder: CloudFolder }
| { kind: "delete-cloud-folder"; folder: CloudFolder }
| { kind: "delete-cloud-project"; id: string }
| { kind: "delete-cloud-document"; id: string }
| { kind: "delete-cloud-file"; id: string }
| { kind: "rename-cloud-file"; file: CloudFile }
| { kind: "clone-cloud-project"; id: string; name: string }
| { kind: "new-file"; parent: string }
| { kind: "new-subfolder"; parent: string }
@@ -154,11 +157,14 @@
setStatus(`Deleted '${entry.name}'`);
});
const currentCloudFolder = () =>
app.cloudFolder === "shared" ? null : app.cloudFolder;
const linkEntry = (entry: BrowseEntry) =>
guard(async () => {
const report = await api.cloudLinkProject(entry.path);
await refreshEntries();
await refreshCloudProjects();
await refreshCloud();
setStatus(`Uploaded ${report.pushed.length} files to a new cloud project`);
});
@@ -171,20 +177,38 @@
const createCloudProject = (name: string) =>
guard(async () => {
await api.cloudCreateProject(name);
await refreshCloudProjects();
await api.cloudCreateProject(name, currentCloudFolder());
await refreshCloud();
});
const createCloudDocument = (title: string) =>
guard(async () => {
await api.cloudNewDocument(title);
await api.cloudNewDocument(title, currentCloudFolder());
await refreshCloud();
});
const createCloudFolder = (name: string) =>
guard(async () => {
await api.cloudCreateFolder(name, currentCloudFolder());
await refreshCloud();
});
const renameCloudFolder = (folder: CloudFolder, name: string) =>
guard(async () => {
await api.cloudRenameFolder(folder.id, name);
await refreshCloud();
});
const deleteCloudFolder = (folder: CloudFolder) =>
guard(async () => {
await api.cloudDeleteFolder(folder.id);
await refreshCloud();
});
const deleteCloudProject = (id: string) =>
guard(async () => {
await api.cloudDeleteProject(id);
await refreshCloudProjects();
await refreshCloud();
});
const deleteCloudDocument = (id: string) =>
@@ -199,11 +223,31 @@
await refreshCloud();
});
const uploadCloudFiles = () =>
guard(async () => {
const sources = await pickFiles("assets");
if (sources.length === 0) return;
const folderId = currentCloudFolder();
for (const source of sources) {
await api.cloudUploadFile(source, folderId);
}
await refreshCloud();
setStatus(`Uploaded ${sources.length} file(s) to TypstDrive`);
});
const renameCloudFile = (file: CloudFile, name: string) =>
guard(async () => {
await api.cloudRenameFile(file.id, name);
await refreshCloud();
});
const cloneCloudProject = (id: string, name: string) =>
guard(async () => {
await api.cloudCloneProject(id, name);
const parent = app.currentDir;
await api.cloudCloneProject(id, name, parent);
app.scope = "local";
await browseTo("");
await browseTo(parent);
setStatus(`Downloaded '${name}' to this device`);
});
@@ -477,7 +521,11 @@
<span class="h-1.5 w-1.5 rounded-full bg-[var(--color-accent)]"></span>
{/if}
{:else}
<Icon icon="ph:file-code" class="text-lg text-[var(--color-accent)]" />
<span
class="flex h-6 w-6 items-center justify-center rounded-md bg-[var(--color-accent)]"
>
<img src="/favicon.png" alt="Typst Desktop" class="h-6 w-6" />
</span>
<span data-tauri-drag-region class="text-sm font-semibold">
Typst Desktop
</span>
@@ -485,6 +533,23 @@
<div data-tauri-drag-region class="h-full flex-1"></div>
{#if app.view !== "editor"}
<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>
{/if}
{#if app.view === "editor"}
<span
class="flex items-center gap-1 text-[10px] text-[var(--color-ink-muted)]"
@@ -641,8 +706,15 @@
onremovedownload={removeDownloadedDocument}
ondownloadfile={downloadCloudFile}
ondeletefile={(id) => (dialog = { kind: "delete-cloud-file", id })}
onuploadfile={uploadCloudFiles}
onrenamefile={(file) => (dialog = { kind: "rename-cloud-file", file })}
onnewcloudproject={() => (dialog = { kind: "new-cloud-project" })}
onnewclouddocument={() => (dialog = { kind: "new-cloud-document" })}
onnewcloudfolder={() => (dialog = { kind: "new-cloud-folder" })}
onrenamecloudfolder={(folder) =>
(dialog = { kind: "rename-cloud-folder", folder })}
ondeletecloudfolder={(folder) =>
(dialog = { kind: "delete-cloud-folder", folder })}
oncloneproject={(id, name) =>
(dialog = { kind: "clone-cloud-project", id, name })}
ondeleteproject={(id) => (dialog = { kind: "delete-cloud-project", id })}
@@ -866,6 +938,32 @@
onsubmit={createCloudDocument}
onclose={close}
/>
{:else if dialog.kind === "new-cloud-folder"}
<PromptModal
title="New cloud folder"
label="Folder name"
icon="ph:folder-plus"
onsubmit={createCloudFolder}
onclose={close}
/>
{:else if dialog.kind === "rename-cloud-folder"}
{@const target = dialog}
<PromptModal
title="Rename folder"
label="New name"
value={target.folder.name}
confirmLabel="Rename"
onsubmit={(name) => renameCloudFolder(target.folder, name)}
onclose={close}
/>
{:else if dialog.kind === "delete-cloud-folder"}
{@const target = dialog}
<ConfirmModal
title="Delete cloud folder"
message="'{target.folder.name}' will be permanently removed from TypstDrive. It must be empty first."
onconfirm={() => deleteCloudFolder(target.folder)}
onclose={close}
/>
{:else if dialog.kind === "delete-cloud-project"}
{@const target = dialog}
<ConfirmModal
@@ -890,6 +988,16 @@
onconfirm={() => deleteCloudFile(target.id)}
onclose={close}
/>
{:else if dialog.kind === "rename-cloud-file"}
{@const target = dialog}
<PromptModal
title="Rename file"
label="New name"
value={target.file.name}
confirmLabel="Rename"
onsubmit={(name) => renameCloudFile(target.file, name)}
onclose={close}
/>
{:else if dialog.kind === "clone-cloud-project"}
{@const target = dialog}
<PromptModal