791 lines
25 KiB
Svelte
791 lines
25 KiB
Svelte
<script lang="ts">
|
|
import Icon from "@iconify/svelte";
|
|
import { onMount } from "svelte";
|
|
import { save } from "@tauri-apps/plugin-dialog";
|
|
import { getCurrentWebview } from "@tauri-apps/api/webview";
|
|
|
|
import FileViewer from "$lib/components/FileViewer.svelte";
|
|
import FileTree from "$lib/components/FileTree.svelte";
|
|
import Editor from "$lib/components/Editor.svelte";
|
|
import Preview from "$lib/components/Preview.svelte";
|
|
import PromptModal from "$lib/components/PromptModal.svelte";
|
|
import ConfirmModal from "$lib/components/ConfirmModal.svelte";
|
|
import ConflictModal from "$lib/components/ConflictModal.svelte";
|
|
import LoginModal from "$lib/components/LoginModal.svelte";
|
|
import SettingsModal from "$lib/components/SettingsModal.svelte";
|
|
import AssetsModal from "$lib/components/AssetsModal.svelte";
|
|
import WindowControls from "$lib/components/WindowControls.svelte";
|
|
import ImageViewer from "$lib/components/ImageViewer.svelte";
|
|
import InfoModal from "$lib/components/InfoModal.svelte";
|
|
import EditorToolbar from "$lib/components/EditorToolbar.svelte";
|
|
import PageSettingsModal from "$lib/components/PageSettingsModal.svelte";
|
|
|
|
import type { EditorView } from "@codemirror/view";
|
|
import { insertText } from "$lib/ts/editor-actions";
|
|
|
|
import * as api from "$lib/ts/api";
|
|
import type { BrowseEntry } from "$lib/ts/api";
|
|
import { pickFiles } from "$lib/ts/import";
|
|
import {
|
|
app,
|
|
browseTo,
|
|
clearMessages,
|
|
closeTarget,
|
|
compile,
|
|
downloadDocument,
|
|
openFile,
|
|
openTarget,
|
|
refreshAccount,
|
|
refreshEntries,
|
|
refreshSpaces,
|
|
refreshTarget,
|
|
runSync,
|
|
saveAndCompile,
|
|
scheduleAutosave,
|
|
scheduleCompile,
|
|
setError,
|
|
setStatus,
|
|
} from "$lib/ts/state.svelte";
|
|
|
|
type Dialog =
|
|
| { kind: "none" }
|
|
| { kind: "new-project" }
|
|
| { kind: "new-folder" }
|
|
| { kind: "new-document" }
|
|
| { kind: "rename-entry"; entry: BrowseEntry }
|
|
| { kind: "delete-entry"; entry: BrowseEntry }
|
|
| { kind: "link-entry"; entry: BrowseEntry }
|
|
| { kind: "new-space" }
|
|
| { kind: "delete-space"; id: string }
|
|
| { kind: "clone-space"; id: string; name: string }
|
|
| { kind: "new-file" }
|
|
| { kind: "new-subfolder" }
|
|
| { kind: "rename-file"; path: string }
|
|
| { kind: "delete-file"; path: string }
|
|
| { kind: "login" }
|
|
| { kind: "settings" }
|
|
| { kind: "info" }
|
|
| { kind: "assets" }
|
|
| { kind: "page-settings" }
|
|
| { kind: "conflicts" };
|
|
|
|
let dialog = $state<Dialog>({ kind: "none" });
|
|
let editorView = $state<EditorView | null>(null);
|
|
let imageViewer = $state<{ paths: string[]; index: number } | null>(null);
|
|
|
|
const activeFile = $derived(
|
|
app.target?.files.find((file) => file.path === app.activePath) ?? null,
|
|
);
|
|
|
|
function close() {
|
|
dialog = { kind: "none" };
|
|
}
|
|
|
|
async function guard(action: () => Promise<void>) {
|
|
try {
|
|
await action();
|
|
close();
|
|
} catch (error) {
|
|
setError(error);
|
|
}
|
|
}
|
|
|
|
const createProject = (name: string) =>
|
|
guard(async () => {
|
|
const path = await api.createProjectEntry(app.currentDir, name);
|
|
await refreshEntries();
|
|
await openTarget(path);
|
|
});
|
|
|
|
const createFolder = (name: string) =>
|
|
guard(async () => {
|
|
await api.createFolderEntry(app.currentDir, name);
|
|
await refreshEntries();
|
|
});
|
|
|
|
const createDocument = (name: string) =>
|
|
guard(async () => {
|
|
const path = await api.createDocumentEntry(app.currentDir, name);
|
|
await refreshEntries();
|
|
await openTarget(path);
|
|
});
|
|
|
|
const renameEntry = (entry: BrowseEntry, name: string) =>
|
|
guard(async () => {
|
|
await api.renameEntry(entry.path, name);
|
|
await refreshEntries();
|
|
});
|
|
|
|
const deleteEntry = (entry: BrowseEntry) =>
|
|
guard(async () => {
|
|
await api.deleteEntry(entry.path);
|
|
await refreshEntries();
|
|
setStatus(`Deleted '${entry.name}'`);
|
|
});
|
|
|
|
const linkEntry = (entry: BrowseEntry) =>
|
|
guard(async () => {
|
|
const report = await api.cloudLinkProject(entry.path);
|
|
await refreshEntries();
|
|
await refreshSpaces();
|
|
setStatus(`Uploaded ${report.pushed.length} files to a new cloud space`);
|
|
});
|
|
|
|
const createSpace = (name: string) =>
|
|
guard(async () => {
|
|
await api.cloudCreateSpace(name);
|
|
await refreshSpaces();
|
|
});
|
|
|
|
const deleteSpace = (id: string) =>
|
|
guard(async () => {
|
|
await api.cloudDeleteSpace(id);
|
|
await refreshSpaces();
|
|
});
|
|
|
|
const cloneSpace = (id: string, name: string) =>
|
|
guard(async () => {
|
|
await api.cloudCloneSpace(id, name);
|
|
app.scope = "local";
|
|
await browseTo("");
|
|
setStatus(`Downloaded '${name}' to this device`);
|
|
});
|
|
|
|
async function importFiles() {
|
|
const sources = await pickFiles("all");
|
|
if (sources.length === 0) return;
|
|
|
|
try {
|
|
if (app.view === "editor" && app.target) {
|
|
const imported = await api.importIntoTarget(app.target.path, sources);
|
|
await refreshTarget();
|
|
await compile();
|
|
setStatus(`Imported ${imported.length} file(s)`);
|
|
} else {
|
|
const imported = await api.importIntoFolder(app.currentDir, sources);
|
|
await refreshEntries();
|
|
setStatus(`Imported ${imported.length} file(s)`);
|
|
}
|
|
} catch (error) {
|
|
setError(error);
|
|
}
|
|
}
|
|
|
|
const createFileInTarget = (name: string) =>
|
|
guard(async () => {
|
|
const path = name.includes(".") ? name : `${name}.typ`;
|
|
await api.writeTargetFile(app.target!.path, path, "");
|
|
await refreshTarget();
|
|
await openFile(path);
|
|
});
|
|
|
|
const createFolderInTarget = (path: string) =>
|
|
guard(async () => {
|
|
await api.createFolderEntry(app.target!.path, path);
|
|
await refreshTarget();
|
|
});
|
|
|
|
const renameFile = (path: string, next: string) =>
|
|
guard(async () => {
|
|
const payload = await api.readTargetFile(app.target!.path, path);
|
|
await api.writeTargetFile(app.target!.path, next, payload.content);
|
|
await api.deleteEntry(`${app.target!.path}/${path}`);
|
|
await refreshTarget();
|
|
if (app.activePath === path) await openFile(next);
|
|
});
|
|
|
|
const deleteFile = (path: string) =>
|
|
guard(async () => {
|
|
await api.deleteEntry(`${app.target!.path}/${path}`);
|
|
if (app.activePath === path) {
|
|
app.activePath = null;
|
|
app.editorContent = "";
|
|
}
|
|
await refreshTarget();
|
|
await compile();
|
|
});
|
|
|
|
async function setEntrypoint(path: string) {
|
|
if (!app.target) return;
|
|
try {
|
|
await api.setTargetEntrypoint(app.target.path, path);
|
|
await refreshTarget();
|
|
await compile();
|
|
setStatus(`'${path}' is now the entrypoint`);
|
|
} catch (error) {
|
|
setError(error);
|
|
}
|
|
}
|
|
|
|
async function exportAs(format: string) {
|
|
if (!app.target) return;
|
|
|
|
const name = app.target.path.split("/").pop() ?? "document";
|
|
const destination = await save({
|
|
defaultPath: `${name.replace(/\.typ$/i, "")}.${format}`,
|
|
filters: [{ name: format.toUpperCase(), extensions: [format] }],
|
|
});
|
|
if (!destination) return;
|
|
|
|
try {
|
|
await api.exportTarget(app.target.path, format, destination);
|
|
setStatus(`Exported to ${destination}`);
|
|
} catch (error) {
|
|
setError(error);
|
|
}
|
|
}
|
|
|
|
const resolveDocumentConflicts = (resolutions: api.Resolution[]) =>
|
|
guard(async () => {
|
|
for (const resolution of resolutions) {
|
|
await api.cloudResolveDocument(
|
|
app.target!.path,
|
|
resolution.content,
|
|
resolution.server_hash,
|
|
);
|
|
}
|
|
app.conflicts = [];
|
|
if (app.activePath) await openFile(app.activePath);
|
|
setStatus("Conflicts resolved and uploaded");
|
|
});
|
|
|
|
const resolveConflicts = (resolutions: api.Resolution[]) =>
|
|
guard(async () => {
|
|
const report = await api.cloudResolveConflicts(
|
|
app.target!.path,
|
|
resolutions,
|
|
);
|
|
app.conflicts = report.conflicts;
|
|
await refreshTarget();
|
|
if (app.activePath) await openFile(app.activePath);
|
|
setStatus("Conflicts resolved and uploaded");
|
|
});
|
|
|
|
function handleKeydown(event: KeyboardEvent) {
|
|
if ((event.metaKey || event.ctrlKey) && event.key === "s") {
|
|
event.preventDefault();
|
|
if (app.view === "editor") saveAndCompile();
|
|
}
|
|
}
|
|
|
|
let dropActive = $state(false);
|
|
|
|
function dropDestination():
|
|
| { kind: "folder"; path: string }
|
|
| { kind: "target"; path: string }
|
|
| null {
|
|
if (app.view === "editor" && app.target) {
|
|
return { kind: "target", path: app.target.path };
|
|
}
|
|
if (app.view === "files" && app.scope === "local") {
|
|
return { kind: "folder", path: app.currentDir };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function dropPaths(paths: string[]) {
|
|
const destination = dropDestination();
|
|
if (!destination || paths.length === 0) return;
|
|
|
|
try {
|
|
const imported =
|
|
destination.kind === "target"
|
|
? await api.importIntoTarget(destination.path, paths)
|
|
: await api.importIntoFolder(destination.path, paths);
|
|
|
|
if (destination.kind === "target") {
|
|
await refreshTarget();
|
|
await compile();
|
|
} else {
|
|
await refreshEntries();
|
|
}
|
|
setStatus(`Imported ${imported.length} item(s)`);
|
|
} catch (error) {
|
|
setError(error);
|
|
}
|
|
}
|
|
|
|
onMount(() => {
|
|
const pending = getCurrentWebview().onDragDropEvent((event) => {
|
|
if (event.payload.type === "over") {
|
|
dropActive = dropDestination() !== null;
|
|
} else if (event.payload.type === "drop") {
|
|
dropActive = false;
|
|
dropPaths(event.payload.paths);
|
|
} else {
|
|
dropActive = false;
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
pending.then((unlisten) => unlisten());
|
|
};
|
|
});
|
|
|
|
const lspLabel: Record<string, string> = {
|
|
off: "LSP off",
|
|
starting: "LSP starting",
|
|
on: "LSP ready",
|
|
unavailable: "LSP unavailable",
|
|
};
|
|
</script>
|
|
|
|
<svelte:window on:keydown={handleKeydown} />
|
|
|
|
<div class="flex h-screen flex-col">
|
|
<header
|
|
data-tauri-drag-region
|
|
class="flex h-11 shrink-0 select-none items-center gap-2 border-b border-[var(--color-line)] bg-[var(--color-surface)] pl-3"
|
|
>
|
|
{#if app.view === "editor"}
|
|
<button
|
|
class="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-ink)]"
|
|
onclick={closeTarget}
|
|
>
|
|
<Icon icon="ph:arrow-left" />
|
|
Files
|
|
</button>
|
|
<span data-tauri-drag-region class="text-sm font-medium">
|
|
{app.target?.path.split("/").pop()}
|
|
</span>
|
|
{#if app.target?.standalone}
|
|
<span
|
|
class="rounded bg-[var(--color-surface-sunken)] px-1.5 py-0.5 text-[10px] text-[var(--color-ink-muted)]"
|
|
>
|
|
single file
|
|
</span>
|
|
{/if}
|
|
{#if app.dirty}
|
|
<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 data-tauri-drag-region class="text-sm font-semibold">
|
|
Typst Desktop
|
|
</span>
|
|
{/if}
|
|
|
|
<div data-tauri-drag-region class="h-full flex-1"></div>
|
|
|
|
{#if app.view === "editor"}
|
|
<span
|
|
class="flex items-center gap-1 text-[10px] text-[var(--color-ink-muted)]"
|
|
title="Typst language server (tinymist)"
|
|
>
|
|
<span
|
|
class="h-1.5 w-1.5 rounded-full
|
|
{app.lspStatus === 'on'
|
|
? 'bg-[var(--color-success)]'
|
|
: app.lspStatus === 'starting'
|
|
? 'bg-[var(--color-accent)]'
|
|
: 'bg-[var(--color-ink-muted)]'}"
|
|
></span>
|
|
{lspLabel[app.lspStatus]}
|
|
</span>
|
|
|
|
<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={saveAndCompile}
|
|
>
|
|
<Icon icon="ph:floppy-disk" />
|
|
Save
|
|
</button>
|
|
|
|
<div class="group relative">
|
|
<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)]"
|
|
>
|
|
<Icon icon="ph:export" />
|
|
Export
|
|
</button>
|
|
<div
|
|
class="invisible absolute right-0 top-full z-20 flex w-32 flex-col rounded-md border border-[var(--color-line)] bg-[var(--color-surface)] py-1 text-xs shadow-lg group-hover:visible"
|
|
>
|
|
{#each ["pdf", "png", "html"] as format}
|
|
<button
|
|
class="px-3 py-1.5 text-left uppercase hover:bg-[var(--color-surface-sunken)]"
|
|
onclick={() => exportAs(format)}
|
|
>
|
|
{format}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
|
|
{#if app.target?.space_id || app.documentLink}
|
|
<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 disabled:opacity-50"
|
|
disabled={app.syncing}
|
|
onclick={() => runSync("sync")}
|
|
>
|
|
<Icon
|
|
icon={app.syncing ? "ph:circle-notch" : "ph:arrows-clockwise"}
|
|
class={app.syncing ? "animate-spin" : ""}
|
|
/>
|
|
Sync
|
|
</button>
|
|
{/if}
|
|
{/if}
|
|
|
|
<button
|
|
class="rounded-md p-1.5 text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-ink)]"
|
|
onclick={() => (dialog = { kind: "settings" })}
|
|
aria-label="Settings"
|
|
>
|
|
<Icon icon="ph:gear-six" />
|
|
</button>
|
|
|
|
<button
|
|
class="rounded-md p-1.5 text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-ink)]"
|
|
onclick={() => (dialog = { kind: "info" })}
|
|
aria-label="About"
|
|
>
|
|
<Icon icon="ph:info" />
|
|
</button>
|
|
|
|
<div class="ml-1 h-5 w-px bg-[var(--color-line)]"></div>
|
|
|
|
<WindowControls />
|
|
</header>
|
|
|
|
{#if app.status || app.error}
|
|
<div
|
|
class="flex shrink-0 items-center gap-2 border-b px-3 py-1.5 text-xs
|
|
{app.error
|
|
? 'border-[var(--color-danger)]/30 bg-[var(--color-danger)]/10 text-[var(--color-danger)]'
|
|
: 'border-[var(--color-success)]/30 bg-[var(--color-success)]/10 text-[var(--color-success)]'}"
|
|
>
|
|
<Icon icon={app.error ? "ph:warning-circle" : "ph:check-circle"} />
|
|
<span class="flex-1">{app.error || app.status}</span>
|
|
{#if app.conflicts.length > 0}
|
|
<button
|
|
class="rounded border border-current px-2 py-0.5 font-medium"
|
|
onclick={() => (dialog = { kind: "conflicts" })}
|
|
>
|
|
Resolve
|
|
</button>
|
|
{/if}
|
|
<button onclick={clearMessages} aria-label="Dismiss">
|
|
<Icon icon="ph:x" />
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="flex min-h-0 flex-1">
|
|
{#if app.view === "files"}
|
|
<div class="flex-1">
|
|
<FileViewer
|
|
onnewproject={() => (dialog = { kind: "new-project" })}
|
|
onnewfolder={() => (dialog = { kind: "new-folder" })}
|
|
onnewdocument={() => (dialog = { kind: "new-document" })}
|
|
onupload={importFiles}
|
|
onrename={(entry) => (dialog = { kind: "rename-entry", entry })}
|
|
ondelete={(entry) => (dialog = { kind: "delete-entry", entry })}
|
|
onlink={(entry) => (dialog = { kind: "link-entry", entry })}
|
|
onviewimage={(paths, index) => (imageViewer = { paths, index })}
|
|
ondownloaddocument={(documentId, title) =>
|
|
downloadDocument(documentId, title)}
|
|
onnewspace={() => (dialog = { kind: "new-space" })}
|
|
onclonespace={(id, name) => (dialog = { kind: "clone-space", id, name })}
|
|
ondeletespace={(id) => (dialog = { kind: "delete-space", id })}
|
|
onsignin={() => (dialog = { kind: "login" })}
|
|
/>
|
|
</div>
|
|
{:else}
|
|
{#if !app.target?.standalone}
|
|
<div
|
|
class="flex w-56 shrink-0 flex-col border-r border-[var(--color-line)] bg-[var(--color-surface)]"
|
|
>
|
|
<div
|
|
class="flex items-center justify-between border-b border-[var(--color-line)] px-3 py-1.5"
|
|
>
|
|
<span
|
|
class="text-[10px] font-semibold uppercase tracking-wider text-[var(--color-ink-muted)]"
|
|
>
|
|
Files
|
|
</span>
|
|
<div class="flex gap-0.5">
|
|
<button
|
|
class="rounded p-1 text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)]"
|
|
onclick={() => (dialog = { kind: "new-file" })}
|
|
aria-label="New file"
|
|
>
|
|
<Icon icon="ph:file-plus" />
|
|
</button>
|
|
<button
|
|
class="rounded p-1 text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)]"
|
|
onclick={() => (dialog = { kind: "new-subfolder" })}
|
|
aria-label="New folder"
|
|
>
|
|
<Icon icon="ph:folder-plus" />
|
|
</button>
|
|
<button
|
|
class="rounded p-1 text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)]"
|
|
onclick={importFiles}
|
|
aria-label="Import files from this computer"
|
|
>
|
|
<Icon icon="ph:upload-simple" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<FileTree
|
|
files={app.target?.files ?? []}
|
|
activePath={app.activePath}
|
|
entrypoint={app.target?.entrypoint ?? "main.typ"}
|
|
onopen={openFile}
|
|
onrename={(path) => (dialog = { kind: "rename-file", path })}
|
|
ondelete={(path) => (dialog = { kind: "delete-file", path })}
|
|
onsetentry={setEntrypoint}
|
|
/>
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="flex min-w-0 flex-1">
|
|
<div class="flex min-w-0 flex-1 flex-col border-r border-[var(--color-line)]">
|
|
{#if app.activePath && activeFile?.is_text}
|
|
<EditorToolbar
|
|
view={editorView}
|
|
disabled={app.activePath.toLowerCase().endsWith(".toml")}
|
|
onassets={() => (dialog = { kind: "assets" })}
|
|
onpagesettings={() => (dialog = { kind: "page-settings" })}
|
|
/>
|
|
|
|
<div
|
|
class="flex shrink-0 items-center gap-2 border-b border-[var(--color-line)] bg-[var(--color-surface)] px-3 py-2 text-xs"
|
|
>
|
|
<Icon icon="ph:file-text" class="text-[var(--color-ink-muted)]" />
|
|
<span>{app.activePath}</span>
|
|
{#if app.dirty}
|
|
<span class="text-[var(--color-ink-muted)]">edited</span>
|
|
{/if}
|
|
</div>
|
|
|
|
{#key app.target?.path + ":" + app.activePath}
|
|
<div class="min-h-0 flex-1">
|
|
<Editor
|
|
content={app.editorContent}
|
|
filePath={app.activePath}
|
|
targetPath={app.target?.path ?? ""}
|
|
diagnostics={app.diagnostics}
|
|
onchange={(value) => {
|
|
app.editorContent = value;
|
|
app.dirty = true;
|
|
scheduleCompile();
|
|
scheduleAutosave();
|
|
}}
|
|
onsave={saveAndCompile}
|
|
onlspstatus={(status) => (app.lspStatus = status)}
|
|
onready={(view) => (editorView = view)}
|
|
/>
|
|
</div>
|
|
{/key}
|
|
{:else}
|
|
<div
|
|
class="flex flex-1 flex-col items-center justify-center gap-2 bg-[var(--color-surface)] text-[var(--color-ink-muted)]"
|
|
>
|
|
<Icon icon="ph:file-dashed" class="text-4xl" />
|
|
<p class="text-sm">
|
|
{app.activePath
|
|
? "This file cannot be edited as text"
|
|
: "Select a file to edit"}
|
|
</p>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="min-w-0 flex-1">
|
|
<Preview
|
|
compiled={app.compiled}
|
|
diagnostics={app.diagnostics}
|
|
compiling={app.compiling}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
{#if imageViewer}
|
|
<ImageViewer
|
|
paths={imageViewer.paths}
|
|
index={imageViewer.index}
|
|
onclose={() => (imageViewer = null)}
|
|
/>
|
|
{/if}
|
|
|
|
{#if dropActive}
|
|
<div
|
|
class="pointer-events-none fixed inset-0 z-40 flex items-center justify-center bg-[var(--color-accent)]/10 p-8"
|
|
>
|
|
<div
|
|
class="flex flex-col items-center gap-3 rounded-xl border-2 border-dashed border-[var(--color-accent)] bg-[var(--color-surface)]/95 px-10 py-8 shadow-xl"
|
|
>
|
|
<Icon icon="ph:tray-arrow-down" class="text-4xl text-[var(--color-accent)]" />
|
|
<p class="text-sm font-medium">
|
|
{app.view === "editor"
|
|
? `Drop to add files to ${app.target?.path.split("/").pop()}`
|
|
: app.currentDir
|
|
? `Drop to add files to ${app.currentDir.split("/").pop()}`
|
|
: "Drop to add files to your workspace"}
|
|
</p>
|
|
<p class="text-xs text-[var(--color-ink-muted)]">
|
|
Images, fonts, and Typst files are copied in.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if dialog.kind === "new-project"}
|
|
<PromptModal
|
|
title="New project"
|
|
label="Project name"
|
|
icon="ph:folder-plus"
|
|
placeholder="My thesis"
|
|
onsubmit={createProject}
|
|
onclose={close}
|
|
/>
|
|
{:else if dialog.kind === "new-folder"}
|
|
<PromptModal
|
|
title="New folder"
|
|
label="Folder name"
|
|
icon="ph:folder-plus"
|
|
onsubmit={createFolder}
|
|
onclose={close}
|
|
/>
|
|
{:else if dialog.kind === "new-document"}
|
|
<PromptModal
|
|
title="New document"
|
|
label="Document name"
|
|
icon="ph:file-plus"
|
|
placeholder="notes"
|
|
suffix=".typ"
|
|
onsubmit={createDocument}
|
|
onclose={close}
|
|
/>
|
|
{:else if dialog.kind === "rename-entry"}
|
|
{@const target = dialog}
|
|
<PromptModal
|
|
title="Rename"
|
|
label="New name"
|
|
value={target.entry.name}
|
|
confirmLabel="Rename"
|
|
onsubmit={(name) => renameEntry(target.entry, name)}
|
|
onclose={close}
|
|
/>
|
|
{:else if dialog.kind === "delete-entry"}
|
|
{@const target = dialog}
|
|
<ConfirmModal
|
|
title="Delete"
|
|
message="'{target.entry.name}' will be permanently removed from this device."
|
|
onconfirm={() => deleteEntry(target.entry)}
|
|
onclose={close}
|
|
/>
|
|
{:else if dialog.kind === "link-entry"}
|
|
{@const target = dialog}
|
|
<ConfirmModal
|
|
title="Upload to cloud"
|
|
message="A new cloud space will be created for '{target.entry.name}' and its files uploaded."
|
|
confirmLabel="Upload"
|
|
onconfirm={() => linkEntry(target.entry)}
|
|
onclose={close}
|
|
/>
|
|
{:else if dialog.kind === "new-space"}
|
|
<PromptModal
|
|
title="New cloud space"
|
|
label="Space name"
|
|
icon="ph:cloud-plus"
|
|
onsubmit={createSpace}
|
|
onclose={close}
|
|
/>
|
|
{:else if dialog.kind === "delete-space"}
|
|
{@const target = dialog}
|
|
<ConfirmModal
|
|
title="Delete cloud space"
|
|
message="This permanently deletes the space and its files from TypstDrive. Local copies are kept."
|
|
onconfirm={() => deleteSpace(target.id)}
|
|
onclose={close}
|
|
/>
|
|
{:else if dialog.kind === "clone-space"}
|
|
{@const target = dialog}
|
|
<PromptModal
|
|
title="Download space"
|
|
label="Save as project"
|
|
icon="ph:download-simple"
|
|
value={target.name}
|
|
confirmLabel="Download"
|
|
onsubmit={(name) => cloneSpace(target.id, name)}
|
|
onclose={close}
|
|
/>
|
|
{:else if dialog.kind === "new-file"}
|
|
<PromptModal
|
|
title="New file"
|
|
label="File name"
|
|
icon="ph:file-plus"
|
|
placeholder="chapter-1"
|
|
onsubmit={createFileInTarget}
|
|
onclose={close}
|
|
/>
|
|
{:else if dialog.kind === "new-subfolder"}
|
|
<PromptModal
|
|
title="New folder"
|
|
label="Folder name"
|
|
icon="ph:folder-plus"
|
|
placeholder="figures"
|
|
onsubmit={createFolderInTarget}
|
|
onclose={close}
|
|
/>
|
|
{:else if dialog.kind === "rename-file"}
|
|
{@const target = dialog}
|
|
<PromptModal
|
|
title="Rename file"
|
|
label="New path"
|
|
value={target.path}
|
|
confirmLabel="Rename"
|
|
onsubmit={(next) => renameFile(target.path, next)}
|
|
onclose={close}
|
|
/>
|
|
{:else if dialog.kind === "delete-file"}
|
|
{@const target = dialog}
|
|
<ConfirmModal
|
|
title="Delete file"
|
|
message="'{target.path}' will be permanently deleted."
|
|
onconfirm={() => deleteFile(target.path)}
|
|
onclose={close}
|
|
/>
|
|
{:else if dialog.kind === "login"}
|
|
<LoginModal
|
|
serverUrl={app.settings?.server_url ?? ""}
|
|
onsuccess={async () => {
|
|
close();
|
|
await refreshAccount();
|
|
app.scope = "cloud";
|
|
setStatus("Connected to TypstDrive");
|
|
}}
|
|
onclose={close}
|
|
/>
|
|
{:else if dialog.kind === "settings"}
|
|
<SettingsModal onclose={close} onsignin={() => (dialog = { kind: "login" })} />
|
|
{:else if dialog.kind === "info"}
|
|
<InfoModal onclose={close} />
|
|
{:else if dialog.kind === "assets"}
|
|
<AssetsModal
|
|
oninsert={app.view === "editor" && editorView
|
|
? (snippet) => {
|
|
insertText(editorView, snippet);
|
|
close();
|
|
}
|
|
: undefined}
|
|
onchanged={compile}
|
|
onclose={close}
|
|
/>
|
|
{:else if dialog.kind === "page-settings"}
|
|
<PageSettingsModal view={editorView} onclose={close} />
|
|
{:else if dialog.kind === "conflicts"}
|
|
<ConflictModal
|
|
conflicts={app.conflicts}
|
|
onresolve={app.documentLink ? resolveDocumentConflicts : resolveConflicts}
|
|
onclose={close}
|
|
/>
|
|
{/if}
|