Version checks, drop native drag plugin, cloud menus and delete, about links
This commit is contained in:
@@ -1,14 +1,12 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import { resolveResource } from "@tauri-apps/api/path";
|
||||
import { startDrag } from "@crabnebula/tauri-plugin-drag";
|
||||
import { absolutePath, type FileEntry } from "$lib/ts/api";
|
||||
import type { FileEntry } from "$lib/ts/api";
|
||||
import { clampMenu } from "$lib/ts/menu-position";
|
||||
|
||||
interface Props {
|
||||
files: FileEntry[];
|
||||
activePath: string | null;
|
||||
entrypoint: string;
|
||||
targetPath: string;
|
||||
selected: Set<string>;
|
||||
dropTarget: string | null;
|
||||
onopen: (path: string) => void;
|
||||
@@ -28,7 +26,6 @@
|
||||
files,
|
||||
activePath,
|
||||
entrypoint,
|
||||
targetPath,
|
||||
selected,
|
||||
dropTarget,
|
||||
onopen,
|
||||
@@ -115,10 +112,7 @@
|
||||
null,
|
||||
);
|
||||
let dragging = $state<string | null>(null);
|
||||
let dragPaths = $state<string[]>([]);
|
||||
let anchorPath = $state<string | null>(null);
|
||||
let nativeDragSent = false;
|
||||
let iconPathPromise: Promise<string> | null = null;
|
||||
|
||||
function flattenVisible(nodes: TreeNode[]): TreeNode[] {
|
||||
const result: TreeNode[] = [];
|
||||
@@ -217,40 +211,6 @@
|
||||
return index === -1 ? "" : path.slice(0, index);
|
||||
}
|
||||
|
||||
function joinTargetPath(path: string): string {
|
||||
return targetPath ? `${targetPath}/${path}` : path;
|
||||
}
|
||||
|
||||
function resolveAbsolutePaths(paths: string[]): Promise<string[]> {
|
||||
return Promise.all(paths.map((path) => absolutePath(joinTargetPath(path))));
|
||||
}
|
||||
|
||||
function dragIcon(): Promise<string> {
|
||||
if (!iconPathPromise) iconPathPromise = resolveResource("icons/32x32.png");
|
||||
return iconPathPromise;
|
||||
}
|
||||
|
||||
async function exportViaOsDrag(paths: string[]) {
|
||||
if (nativeDragSent || paths.length === 0) return;
|
||||
nativeDragSent = true;
|
||||
try {
|
||||
const [absolutePaths, icon] = await Promise.all([
|
||||
resolveAbsolutePaths(paths),
|
||||
dragIcon(),
|
||||
]);
|
||||
await startDrag({ item: absolutePaths, icon });
|
||||
} catch (error) {
|
||||
console.error("Failed to start native drag", error);
|
||||
}
|
||||
}
|
||||
|
||||
function handleWindowDragLeave(event: DragEvent) {
|
||||
if (!dragging || nativeDragSent) return;
|
||||
// relatedTarget is null only when the pointer leaves the whole window,
|
||||
// as opposed to moving between elements inside it.
|
||||
if (event.relatedTarget !== null) return;
|
||||
exportViaOsDrag(dragPaths);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
@@ -258,7 +218,6 @@
|
||||
on:contextmenu={(event) => {
|
||||
if (!(event.target as HTMLElement).closest("[data-tree-row]")) menu = null;
|
||||
}}
|
||||
on:dragleave={handleWindowDragLeave}
|
||||
/>
|
||||
|
||||
{#snippet branch(nodes: TreeNode[], depth: number)}
|
||||
@@ -287,17 +246,10 @@
|
||||
onkeydown={(event) => handleKey(event, node)}
|
||||
ondragstart={(event) => {
|
||||
dragging = node.path;
|
||||
dragPaths =
|
||||
selected.has(node.path) && selected.size > 1
|
||||
? [...selected]
|
||||
: [node.path];
|
||||
nativeDragSent = false;
|
||||
event.dataTransfer?.setData("text/plain", node.path);
|
||||
}}
|
||||
ondragend={() => {
|
||||
dragging = null;
|
||||
dragPaths = [];
|
||||
nativeDragSent = false;
|
||||
}}
|
||||
ondragover={(event) => {
|
||||
if (!dragging || !node.isDir) return;
|
||||
@@ -432,6 +384,7 @@
|
||||
selected.has(target.path) && selected.size > 1 ? [...selected] : [target.path]}
|
||||
{@const single = menuPaths.length === 1}
|
||||
<div
|
||||
use:clampMenu
|
||||
class="fixed z-50 flex w-48 flex-col rounded-md border border-[var(--color-line)] bg-[var(--color-surface)] py-1 text-xs shadow-lg"
|
||||
style="left: {target.x}px; top: {target.y}px"
|
||||
>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
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,
|
||||
@@ -12,6 +13,8 @@
|
||||
openCloudFolder,
|
||||
openTarget,
|
||||
refreshCloud,
|
||||
refreshEntries,
|
||||
setError,
|
||||
} from "$lib/ts/state.svelte";
|
||||
|
||||
interface Props {
|
||||
@@ -27,9 +30,12 @@
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -46,15 +52,35 @@
|
||||
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());
|
||||
|
||||
@@ -169,6 +195,45 @@
|
||||
.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);
|
||||
@@ -192,7 +257,68 @@
|
||||
}
|
||||
</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,
|
||||
@@ -200,10 +326,13 @@
|
||||
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)]"
|
||||
>
|
||||
@@ -278,11 +407,11 @@
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if onremove}
|
||||
{#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}
|
||||
aria-label="Remove"
|
||||
onclick={onremove ?? ondelete}
|
||||
aria-label={onremove ? "Remove" : "Delete"}
|
||||
>
|
||||
<Icon icon="ph:trash" />
|
||||
</button>
|
||||
@@ -331,7 +460,8 @@
|
||||
|
||||
{#if menuFor === entry.path}
|
||||
<div
|
||||
class="fixed z-50 flex w-40 -translate-x-full flex-col rounded-md border border-[var(--color-line)] bg-[var(--color-surface)] py-1 text-xs shadow-lg"
|
||||
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"}
|
||||
@@ -404,10 +534,14 @@
|
||||
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)]">
|
||||
<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"
|
||||
>
|
||||
@@ -459,11 +593,18 @@
|
||||
</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"
|
||||
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 cloud project
|
||||
New document
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -474,8 +615,14 @@
|
||||
>
|
||||
<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)]'}"
|
||||
{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
|
||||
@@ -485,8 +632,14 @@
|
||||
<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)]'}"
|
||||
{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>
|
||||
@@ -527,7 +680,19 @@
|
||||
<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)]"
|
||||
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"
|
||||
@@ -579,6 +744,10 @@
|
||||
{#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"
|
||||
@@ -729,16 +898,27 @@
|
||||
class="mb-4 grid grid-cols-[repeat(auto-fill,minmax(210px,1fr))] gap-2"
|
||||
>
|
||||
{#each app.cloudFolders as folder (folder.id)}
|
||||
<button
|
||||
class="flex 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)}
|
||||
>
|
||||
<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>
|
||||
<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}
|
||||
@@ -755,6 +935,7 @@
|
||||
{#each app.cloudDocuments as entry (entry.id)}
|
||||
{@const linked = linkedDocument(entry.id)}
|
||||
{@render cloudCard(
|
||||
`document:${entry.id}`,
|
||||
"ph:file-text",
|
||||
entry.title,
|
||||
linked
|
||||
@@ -764,6 +945,7 @@
|
||||
linked ? () => openTarget(linked.path) : null,
|
||||
() => ondownloaddocument(entry.id, entry.title),
|
||||
linked ? () => onremovedownload(linked.path) : null,
|
||||
() => ondeletedocument(entry.id),
|
||||
)}
|
||||
{/each}
|
||||
</div>
|
||||
@@ -781,7 +963,16 @@
|
||||
{#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"
|
||||
@@ -817,6 +1008,22 @@
|
||||
>
|
||||
<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}
|
||||
|
||||
@@ -831,6 +1038,7 @@
|
||||
{#each app.cloudProjects as project (project.id)}
|
||||
{@const linked = linkedProject(project.id)}
|
||||
{@render cloudCard(
|
||||
`project:${project.id}`,
|
||||
"ph:folder-star",
|
||||
project.name,
|
||||
linked
|
||||
@@ -839,6 +1047,7 @@
|
||||
linked,
|
||||
linked ? () => openTarget(linked.path) : null,
|
||||
() => oncloneproject(project.id, project.name),
|
||||
null,
|
||||
project.role === "owner" ? () => ondeleteproject(project.id) : null,
|
||||
)}
|
||||
{/each}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import Modal from "./Modal.svelte";
|
||||
import * as api from "$lib/ts/api";
|
||||
import type { AppInfo } from "$lib/ts/api";
|
||||
import type { AppInfo, CompatibilityStatus } from "$lib/ts/api";
|
||||
import {
|
||||
app,
|
||||
applyTheme,
|
||||
@@ -62,6 +62,28 @@
|
||||
|
||||
let workspaceRoot = $state(untrack(() => app.settings?.workspace_root ?? ""));
|
||||
let serverUrl = $state(untrack(() => app.settings?.server_url ?? ""));
|
||||
let compatibility = $state<CompatibilityStatus | null>(null);
|
||||
let checkingCompatibility = $state(false);
|
||||
let compatibilityTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
$effect(() => {
|
||||
const url = serverUrl.trim();
|
||||
clearTimeout(compatibilityTimer);
|
||||
if (!url) {
|
||||
compatibility = null;
|
||||
return;
|
||||
}
|
||||
compatibilityTimer = setTimeout(async () => {
|
||||
checkingCompatibility = true;
|
||||
try {
|
||||
compatibility = await api.cloudCheckCompatibility(url);
|
||||
} catch {
|
||||
compatibility = null;
|
||||
} finally {
|
||||
checkingCompatibility = false;
|
||||
}
|
||||
}, 600);
|
||||
});
|
||||
let autosaveSeconds = $state(untrack(() => app.settings?.autosave_seconds ?? 0));
|
||||
let syncMinutes = $state(untrack(() => app.settings?.sync_minutes ?? 0));
|
||||
let saving = $state(false);
|
||||
@@ -101,6 +123,16 @@
|
||||
url: "https://typst.app/universe/",
|
||||
icon: "ph:planet",
|
||||
},
|
||||
{
|
||||
label: "GitHub Repository",
|
||||
url: "https://github.com/SirBlobby/typst-desktop",
|
||||
icon: "ph:github-logo",
|
||||
},
|
||||
{
|
||||
label: "Report an Issue",
|
||||
url: "https://github.com/SirBlobby/typst-desktop/issues",
|
||||
icon: "ph:bug",
|
||||
},
|
||||
];
|
||||
|
||||
async function browse() {
|
||||
@@ -366,6 +398,22 @@
|
||||
TypstDrive server
|
||||
</span>
|
||||
<input class={fieldClass} bind:value={serverUrl} />
|
||||
{#if checkingCompatibility}
|
||||
<span class="flex items-center gap-1 text-[var(--color-ink-muted)]">
|
||||
<Icon icon="ph:circle-notch" class="animate-spin" />
|
||||
Checking server version...
|
||||
</span>
|
||||
{:else if compatibility && !compatibility.compatible}
|
||||
<span class="flex items-center gap-1 text-[var(--color-danger)]">
|
||||
<Icon icon="ph:warning-circle" />
|
||||
{compatibility.message}
|
||||
</span>
|
||||
{:else if compatibility && compatibility.compatible}
|
||||
<span class="flex items-center gap-1 text-[var(--color-success)]">
|
||||
<Icon icon="ph:check-circle" />
|
||||
Compatible (server v{compatibility.server_version})
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1 text-xs">
|
||||
|
||||
@@ -251,6 +251,18 @@ export const updateSettings = (changes: {
|
||||
syncMinutes?: number;
|
||||
}) => invoke<Settings>("update_settings", changes);
|
||||
|
||||
export interface CompatibilityStatus {
|
||||
compatible: boolean;
|
||||
server_version: string;
|
||||
desktop_version: string;
|
||||
min_server_version: string;
|
||||
min_desktop_version: string;
|
||||
message: string | null;
|
||||
}
|
||||
|
||||
export const cloudCheckCompatibility = (serverUrl: string) =>
|
||||
invoke<CompatibilityStatus>("cloud_check_compatibility", { serverUrl });
|
||||
|
||||
export const cloudLogin = (
|
||||
serverUrl: string,
|
||||
email: string,
|
||||
@@ -314,12 +326,29 @@ export const cloudListFiles = (folderId?: string | null) =>
|
||||
export const cloudDownloadFile = (fileId: string) =>
|
||||
invoke<string>("cloud_download_file", { fileId });
|
||||
|
||||
export const cloudDeleteFile = (fileId: string) =>
|
||||
invoke<void>("cloud_delete_file", { fileId });
|
||||
|
||||
export const cloudDownloadDocument = (documentId: string, parent: string) =>
|
||||
invoke<string>("cloud_download_document", { documentId, parent });
|
||||
|
||||
export const cloudDeleteDocument = (documentId: string) =>
|
||||
invoke<void>("cloud_delete_document", { documentId });
|
||||
|
||||
export const cloudCreateDocument = (path: string, title: string) =>
|
||||
invoke<string>("cloud_create_document", { path, title });
|
||||
|
||||
export interface DocumentContent {
|
||||
id: string;
|
||||
title: string;
|
||||
role: string;
|
||||
hash: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export const cloudNewDocument = (title: string) =>
|
||||
invoke<DocumentContent>("cloud_new_document", { title });
|
||||
|
||||
export const cloudSyncDocument = (path: string) =>
|
||||
invoke<SyncReport>("cloud_sync_document", { path });
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
export function clampMenu(node: HTMLElement) {
|
||||
const margin = 8;
|
||||
const rect = node.getBoundingClientRect();
|
||||
|
||||
let left = rect.left;
|
||||
let top = rect.top;
|
||||
|
||||
if (rect.right > window.innerWidth - margin) {
|
||||
left = window.innerWidth - rect.width - margin;
|
||||
}
|
||||
if (rect.bottom > window.innerHeight - margin) {
|
||||
top = window.innerHeight - rect.height - margin;
|
||||
}
|
||||
|
||||
left = Math.max(margin, left);
|
||||
top = Math.max(margin, top);
|
||||
|
||||
node.style.left = `${left}px`;
|
||||
node.style.top = `${top}px`;
|
||||
}
|
||||
Reference in New Issue
Block a user