Added Typst Spaces and Internal Packages

This commit is contained in:
2026-05-31 18:03:04 -04:00
parent c1fdb5a1b7
commit bbd7be86a5
26 changed files with 2873 additions and 114 deletions
+32 -1
View File
@@ -14,6 +14,7 @@
import InfoModal from '$lib/components/dashboard/InfoModal.svelte';
import RenameModal from '$lib/components/dashboard/RenameModal.svelte';
import CreateDocModal from '$lib/components/dashboard/CreateDocModal.svelte';
import CreateSpaceModal from '$lib/components/dashboard/CreateSpaceModal.svelte';
import CreateFolderModal from '$lib/components/dashboard/CreateFolderModal.svelte';
import Footer from '$lib/components/Footer.svelte';
@@ -26,6 +27,7 @@
let newFolderName = $state('');
let loading = $state(true);
let showCreateModal = $state(false);
let showCreateSpaceModal = $state(false);
let newDocTitle = $state('');
let showPlusDropdown = $state(false);
let dragOverFolderId = $state<string | null>(null);
@@ -190,7 +192,7 @@
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: title.trim(), folder_id: currentFolderId || undefined })
});
if (res.ok) {
const doc = await res.json();
showCreateModal = false;
@@ -198,6 +200,27 @@
}
}
function openCreateSpaceModal() {
showPlusDropdown = false;
showCreateSpaceModal = true;
}
async function createSpace(name: string) {
if (!name.trim()) return;
const res = await fetch('/api/spaces', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name.trim(), folder_id: currentFolderId || undefined })
});
if (res.ok) {
const space = await res.json();
showCreateSpaceModal = false;
goto(`/space/${space.id}`);
}
}
async function handleImportUpload(e: Event) {
const target = e.target as HTMLInputElement;
if (!target.files || target.files.length === 0) return;
@@ -414,6 +437,10 @@
<Icon icon="mdi:file-document-plus" class="text-lg text-blue-500" />
New Document
</button>
<button onclick={openCreateSpaceModal} class="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-black/5 dark:hover:bg-white/5 flex items-center gap-2">
<Icon icon="mdi:folder-multiple-plus" class="text-lg text-indigo-500" />
New Space
</button>
<button onclick={openCreateFolderModal} class="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-black/5 dark:hover:bg-white/5 flex items-center gap-2">
<Icon icon="mdi:folder-plus" class="text-lg text-yellow-500" />
New Folder
@@ -602,6 +629,10 @@
<CreateDocModal {createDoc} onClose={() => showCreateModal = false} />
{/if}
{#if showCreateSpaceModal}
<CreateSpaceModal {createSpace} onClose={() => showCreateSpaceModal = false} />
{/if}
{#if showCreateFolderModal}
<CreateFolderModal {createFolder} onClose={() => showCreateFolderModal = false} />
{/if}
+106
View File
@@ -0,0 +1,106 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import Icon from '@iconify/svelte';
import Navbar from '$lib/components/dashboard/Navbar.svelte';
interface Package {
id: string;
name: string;
description?: string;
owner_name?: string;
latest_version?: string;
}
let packages = $state<Package[]>([]);
let loading = $state(true);
let copied = $state('');
async function load() {
loading = true;
const res = await fetch('/api/packages');
packages = res.ok ? await res.json() : [];
loading = false;
}
function importSnippet(pkg: Package): string {
return `#import "@typstdrive/${pkg.name}:${pkg.latest_version ?? '0.1.0'}": *`;
}
async function copy(pkg: Package) {
await navigator.clipboard.writeText(importSnippet(pkg));
copied = pkg.id;
setTimeout(() => (copied = ''), 2000);
}
async function remove(pkg: Package) {
if (!confirm(`Delete package "${pkg.name}" and all its versions?`)) return;
const res = await fetch(`/api/packages/${pkg.name}`, { method: 'DELETE' });
if (res.ok) packages = packages.filter((p) => p.id !== pkg.id);
}
onMount(load);
</script>
<svelte:head>
<title>Packages - TypstDrive</title>
</svelte:head>
<div class="min-h-screen bg-gray-50 dark:bg-[var(--theme-bg)]">
<Navbar />
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-6">
<button onclick={() => goto('/dashboard')} class="text-sm font-medium text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white transition-colors mb-2 flex items-center gap-1.5">
<Icon icon="mdi:arrow-left" class="text-lg" />
Back to Dashboard
</button>
<h2 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
<Icon icon="mdi:package-variant-closed" class="text-purple-500" />
Packages
</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
Instance-local Typst packages, published from Spaces and importable as
<code class="font-mono text-xs bg-gray-100 dark:bg-white/10 px-1.5 py-0.5 rounded">@typstdrive/&lt;name&gt;:&lt;version&gt;</code>.
</p>
</div>
{#if loading}
<p class="text-gray-500 dark:text-gray-400">Loading…</p>
{:else if packages.length === 0}
<div class="text-center py-16 text-gray-500 dark:text-gray-400">
<Icon icon="mdi:package-variant" class="text-5xl mx-auto mb-3 opacity-50" />
<p>No packages published yet. Open a Space and use “Publish” to create one.</p>
</div>
{:else}
<div class="space-y-3">
{#each packages as pkg (pkg.id)}
<div class="bg-white dark:bg-black/20 rounded-xl border border-gray-200 dark:border-white/10 p-4 flex items-start justify-between gap-4">
<div class="min-w-0">
<div class="flex items-center gap-2">
<p class="font-semibold text-gray-900 dark:text-white truncate">@typstdrive/{pkg.name}</p>
{#if pkg.latest_version}
<span class="text-xs font-mono bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 px-1.5 py-0.5 rounded">v{pkg.latest_version}</span>
{/if}
</div>
{#if pkg.description}
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1 truncate">{pkg.description}</p>
{/if}
<p class="text-xs text-gray-400 mt-1">by {pkg.owner_name ?? 'unknown'}</p>
<pre class="mt-2 text-xs font-mono bg-gray-50 dark:bg-black/30 border border-gray-100 dark:border-white/5 rounded px-2 py-1 overflow-x-auto">{importSnippet(pkg)}</pre>
</div>
<div class="flex flex-col items-end gap-2 flex-shrink-0">
<button onclick={() => copy(pkg)} class="text-xs px-2 py-1 rounded-md bg-gray-100 dark:bg-white/5 hover:bg-gray-200 dark:hover:bg-white/10 flex items-center gap-1">
<Icon icon={copied === pkg.id ? 'mdi:check' : 'mdi:content-copy'} class="text-sm" />
{copied === pkg.id ? 'Copied' : 'Copy'}
</button>
<button onclick={() => remove(pkg)} title="Delete" class="text-xs px-2 py-1 rounded-md text-gray-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center gap-1">
<Icon icon="mdi:trash-can-outline" class="text-sm" /> Delete
</button>
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
+258
View File
@@ -0,0 +1,258 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import Editor from '$lib/components/Editor.svelte';
import Preview from '$lib/components/Preview.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import DocFooter from '$lib/components/DocFooter.svelte';
import FileTree from '$lib/components/space/FileTree.svelte';
import SpaceToolbar from '$lib/components/space/SpaceToolbar.svelte';
import PublishPackageModal from '$lib/components/PublishPackageModal.svelte';
import { compileSpace } from '$lib/ts/typst-api';
import type { Diagnostic } from '$lib/ts/typst-api';
import { editorErrors, documentStatsStore, previewOpenStore, editorViewStore } from '$lib/ts/store';
import { setSpace, openFile, getOpenFile, closeFile, renameOpenFile, getAllText, cleanupSpace } from '$lib/ts/yjs-space';
interface SpaceFile {
id: string;
path: string;
kind: string;
}
const spaceId = $page.params.id;
let spaceName = $state('Space');
let entrypoint = $state('main.typ');
let role = $state('owner');
let files = $state<SpaceFile[]>([]);
let activeFileId = $state('');
let svgs = $state<string[]>([]);
let errors = $state<Diagnostic[]>([]);
let showPublish = $state(false);
let ready = $state(false);
let timeoutId: number | undefined;
let contextMenu = $state({ show: false, x: 0, y: 0, text: '' });
let readOnly = $derived(role === 'viewer');
let activeEntry = $derived(activeFileId ? getOpenFile(activeFileId) : undefined);
let activePath = $derived(files.find((f) => f.id === activeFileId)?.path ?? '');
function scheduleCompile() {
if (timeoutId) clearTimeout(timeoutId);
timeoutId = window.setTimeout(triggerCompile, 500);
}
function triggerCompile() {
if (!$previewOpenStore) return;
compileSpace(spaceId, getAllText())
.then((res) => {
if (res.stats) $documentStatsStore = res.stats;
if (res.svgs) {
svgs = res.svgs;
errors = [];
$editorErrors = [];
} else if (res.errors) {
errors = res.errors;
$editorErrors = res.errors;
}
})
.catch(() => {
errors = [{ message: 'Network or server error compiling space.', severity: 'error' }];
});
}
async function loadFiles() {
const res = await fetch(`/api/spaces/${spaceId}/files`);
if (!res.ok) return;
files = await res.json();
for (const f of files) {
if (f.kind === 'text') {
const entry = openFile(f.id, f.path);
entry.text.observe(scheduleCompile);
}
}
if (!activeFileId) {
const entry = files.find((f) => f.path === entrypoint) ?? files.find((f) => f.kind === 'text');
if (entry) activeFileId = entry.id;
}
}
function selectFile(file: SpaceFile) {
if (file.kind !== 'text') return;
activeFileId = file.id;
}
async function createFile(path: string) {
const res = await fetch(`/api/spaces/${spaceId}/files`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path, kind: 'text', content: '' })
});
if (res.ok) {
const file = await res.json();
files = [...files, file].sort((a, b) => a.path.localeCompare(b.path));
const entry = openFile(file.id, file.path);
entry.text.observe(scheduleCompile);
activeFileId = file.id;
}
}
async function uploadFiles(fileList: FileList) {
const form = new FormData();
for (const f of fileList) form.append('file', f);
const res = await fetch(`/api/spaces/${spaceId}/files/upload`, { method: 'POST', body: form });
if (res.ok) {
await loadFiles();
triggerCompile();
}
}
async function renameFile(file: SpaceFile, path: string) {
const res = await fetch(`/api/spaces/${spaceId}/files/${file.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path })
});
if (res.ok) {
files = files.map((f) => (f.id === file.id ? { ...f, path } : f));
renameOpenFile(file.id, path);
scheduleCompile();
}
}
async function deleteFile(file: SpaceFile) {
if (!confirm(`Delete ${file.path}?`)) return;
const res = await fetch(`/api/spaces/${spaceId}/files/${file.id}`, { method: 'DELETE' });
if (res.ok) {
closeFile(file.id);
files = files.filter((f) => f.id !== file.id);
if (activeFileId === file.id) {
activeFileId = files.find((f) => f.kind === 'text')?.id ?? '';
}
scheduleCompile();
}
}
async function setEntry(file: SpaceFile) {
const res = await fetch(`/api/spaces/${spaceId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ entrypoint: file.path })
});
if (res.ok) {
entrypoint = file.path;
scheduleCompile();
}
}
function handleContextMenu(e: MouseEvent) {
const view = $editorViewStore;
if (!view) return;
const target = e.target as HTMLElement;
if (!target.closest('.cm-editor') && !target.closest('.cm-content')) return;
const selection = view.state.selection.main;
const selectedText = view.state.doc.sliceString(selection.from, selection.to);
if (selectedText.trim()) {
e.preventDefault();
contextMenu = { show: true, x: e.clientX, y: e.clientY, text: selectedText.trim() };
}
}
function closeContextMenu() {
contextMenu.show = false;
}
onMount(() => {
setSpace(spaceId);
fetch(`/api/spaces/${spaceId}`)
.then((r) => r.json())
.then((s) => {
if (s && s.name) spaceName = s.name;
if (s && s.entrypoint) entrypoint = s.entrypoint;
if (s && s.effective_role) role = s.effective_role;
})
.then(loadFiles)
.then(() => {
ready = true;
triggerCompile();
})
.catch((e) => console.error('Failed to load space', e));
return () => {
if (timeoutId) clearTimeout(timeoutId);
cleanupSpace();
};
});
</script>
<svelte:head>
<title>{spaceName} - TypstDrive</title>
</svelte:head>
<svelte:window onclick={closeContextMenu} />
<div class="flex flex-col h-screen relative">
<SpaceToolbar
{spaceName}
{spaceId}
{entrypoint}
{role}
activeText={activeEntry?.text ?? null}
{activePath}
{getAllText}
onPublish={() => (showPublish = true)}
onFilesChanged={loadFiles}
/>
<main class="flex-1 flex overflow-hidden relative" oncontextmenu={handleContextMenu}>
<aside class="w-56 flex-shrink-0 hidden md:block">
<FileTree
{files}
{activeFileId}
{entrypoint}
{readOnly}
onSelect={selectFile}
onCreate={createFile}
onUpload={uploadFiles}
onRename={renameFile}
onDelete={deleteFile}
onSetEntry={setEntry}
/>
</aside>
{#if !readOnly}
<div class="flex flex-col min-h-0 {$previewOpenStore ? 'w-full md:w-1/2 border-r border-gray-200 dark:border-white/10' : 'flex-1'}">
{#if ready && activeEntry}
{#key activeFileId}
<Editor ytext={activeEntry.text} awarenessProvider={activeEntry.provider} filePath={activePath} enableLsp={false} />
{/key}
{/if}
</div>
{/if}
{#if $previewOpenStore || readOnly}
<div class="{readOnly ? 'flex-1' : 'w-full md:w-1/2'} relative bg-white/50 dark:bg-black/20 flex flex-col">
<Preview {svgs} />
<ErrorBanner {errors} />
</div>
{/if}
</main>
<DocFooter />
</div>
{#if contextMenu.show}
<div class="fixed z-[9999] bg-[var(--theme-bg)] text-[var(--theme-text)] rounded-lg shadow-xl border border-[var(--theme-border)] py-1 min-w-[180px] overflow-hidden" style="left: {contextMenu.x}px; top: {contextMenu.y}px;">
<button onclick={() => { navigator.clipboard.writeText(contextMenu.text); closeContextMenu(); }} class="w-full text-left px-4 py-2 text-sm hover:bg-[var(--theme-border)] flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-gray-500"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
Copy Text
</button>
</div>
{/if}
{#if showPublish}
<PublishPackageModal {spaceId} onClose={() => (showPublish = false)} />
{/if}
+207
View File
@@ -0,0 +1,207 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import Icon from '@iconify/svelte';
import Navbar from '$lib/components/dashboard/Navbar.svelte';
import SpaceCard from '$lib/components/dashboard/SpaceCard.svelte';
interface Space {
id: string;
name: string;
entrypoint: string;
thumbnail_svg?: string;
updated_at: string;
effective_role?: string;
}
let spaces = $state<Space[]>([]);
let shared = $state<Space[]>([]);
let loading = $state(true);
let showCreate = $state(false);
let newName = $state('');
let creating = $state(false);
let activeMenu = $state<string | null>(null);
let showRename = $state(false);
let renameId = $state('');
let renameName = $state('');
let showInfo = $state(false);
let infoSpace = $state<Space | null>(null);
function setActiveMenu(id: string | null) { activeMenu = id; }
function openInfo(space: Space) { activeMenu = null; infoSpace = space; showInfo = true; }
function openRename(id: string, name: string) { activeMenu = null; renameId = id; renameName = name; showRename = true; }
async function submitRename(e: Event) {
e.preventDefault();
if (!renameName.trim()) return;
const res = await fetch(`/api/spaces/${renameId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: renameName.trim() })
});
if (res.ok) {
spaces = spaces.map((s) => (s.id === renameId ? { ...s, name: renameName.trim() } : s));
}
showRename = false;
}
function handleWindowClick(e: MouseEvent) {
const target = e.target as HTMLElement;
if (!target.closest('.action-menu-container')) activeMenu = null;
}
async function load() {
loading = true;
const [own, sh] = await Promise.all([
fetch('/api/spaces').then((r) => (r.ok ? r.json() : [])),
fetch('/api/spaces/shared').then((r) => (r.ok ? r.json() : []))
]);
spaces = own;
shared = sh;
loading = false;
}
async function create() {
if (!newName.trim()) return;
creating = true;
const res = await fetch('/api/spaces', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newName.trim() })
});
creating = false;
if (res.ok) {
const space = await res.json();
goto(`/space/${space.id}`);
}
}
async function remove(id: string, name: string) {
if (!confirm(`Delete space "${name}"? This cannot be undone.`)) return;
const res = await fetch(`/api/spaces/${id}`, { method: 'DELETE' });
if (res.ok) spaces = spaces.filter((s) => s.id !== id);
}
onMount(load);
</script>
<svelte:head>
<title>Spaces - TypstDrive</title>
</svelte:head>
<svelte:window onclick={handleWindowClick} />
<div class="min-h-screen bg-gray-50 dark:bg-[var(--theme-bg)]">
<Navbar />
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="flex items-center justify-between mb-6">
<div>
<button onclick={() => goto('/dashboard')} class="text-sm font-medium text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white transition-colors mb-2 flex items-center gap-1.5">
<Icon icon="mdi:arrow-left" class="text-lg" />
Back to Dashboard
</button>
<h2 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
<Icon icon="mdi:folder-multiple-outline" class="text-blue-500" />
Spaces
</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">Multi-file Typst workspaces with their own <code class="font-mono text-xs">typst.toml</code>.</p>
</div>
<button onclick={() => { showCreate = true; newName = ''; }} class="px-4 py-2 text-sm rounded-lg bg-blue-600 text-white hover:bg-blue-700 flex items-center gap-2">
<Icon icon="mdi:plus" class="text-lg" /> New Space
</button>
</div>
{#if loading}
<p class="text-gray-500 dark:text-gray-400">Loading…</p>
{:else}
{#if spaces.length === 0}
<div class="text-center py-16 text-gray-500 dark:text-gray-400">
<Icon icon="mdi:folder-multiple-outline" class="text-5xl mx-auto mb-3 opacity-50" />
<p>No spaces yet. Create one to start a multi-file project.</p>
</div>
{:else}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{#each spaces as space (space.id)}
<SpaceCard
{space}
{activeMenu}
{setActiveMenu}
{openInfo}
{openRename}
deleteSpace={remove}
/>
{/each}
</div>
{/if}
{#if shared.length > 0}
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-10 mb-4 flex items-center gap-2">
<Icon icon="mdi:account-group-outline" class="text-blue-500" /> Shared with me
</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{#each shared as space (space.id)}
<button onclick={() => goto(`/space/${space.id}`)} class="text-left bg-white dark:bg-black/20 rounded-xl border border-gray-200 dark:border-white/10 overflow-hidden hover:shadow-md transition-shadow">
<div class="h-32 bg-gray-50 dark:bg-black/30 flex items-center justify-center overflow-hidden border-b border-gray-100 dark:border-white/5">
{#if space.thumbnail_svg}
{@html space.thumbnail_svg}
{:else}
<Icon icon="mdi:folder-multiple-outline" class="text-4xl text-gray-300 dark:text-gray-600" />
{/if}
</div>
<div class="p-3">
<p class="font-medium text-gray-900 dark:text-white truncate">{space.name}</p>
<p class="text-xs text-gray-400 mt-0.5">{space.effective_role}</p>
</div>
</button>
{/each}
</div>
{/if}
{/if}
</div>
</div>
{#if showCreate}
<div class="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50" onclick={() => (showCreate = false)} role="presentation">
<div class="bg-[var(--theme-bg)] text-[var(--theme-text)] rounded-xl shadow-2xl border border-gray-200 dark:border-white/10 w-full max-w-md p-6" onclick={(e) => e.stopPropagation()} role="presentation">
<h2 class="text-lg font-bold mb-4 flex items-center gap-2"><Icon icon="mdi:folder-plus-outline" class="text-blue-500" /> New Space</h2>
<input bind:value={newName} placeholder="Space name" onkeydown={(e) => e.key === 'Enter' && create()} class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-white/10 bg-transparent text-sm mb-4 focus:outline-none focus:ring-2 focus:ring-blue-500/40" />
<div class="flex justify-end gap-2">
<button onclick={() => (showCreate = false)} class="px-4 py-2 text-sm rounded-lg bg-gray-100 dark:bg-white/5 hover:bg-gray-200 dark:hover:bg-white/10">Cancel</button>
<button onclick={create} disabled={creating} class="px-4 py-2 text-sm rounded-lg bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50">Create</button>
</div>
</div>
</div>
{/if}
{#if showRename}
<div class="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50" onclick={() => (showRename = false)} role="presentation">
<form onsubmit={submitRename} class="bg-[var(--theme-bg)] text-[var(--theme-text)] rounded-xl shadow-2xl border border-gray-200 dark:border-white/10 w-full max-w-md p-6" onclick={(e) => e.stopPropagation()}>
<h2 class="text-lg font-bold mb-4 flex items-center gap-2"><Icon icon="mdi:pencil-outline" class="text-yellow-500" /> Rename Space</h2>
<input bind:value={renameName} class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-white/10 bg-transparent text-sm mb-4 focus:outline-none focus:ring-2 focus:ring-blue-500/40" />
<div class="flex justify-end gap-2">
<button type="button" onclick={() => (showRename = false)} class="px-4 py-2 text-sm rounded-lg bg-gray-100 dark:bg-white/5 hover:bg-gray-200 dark:hover:bg-white/10">Cancel</button>
<button type="submit" class="px-4 py-2 text-sm rounded-lg bg-blue-600 text-white hover:bg-blue-700">Save</button>
</div>
</form>
</div>
{/if}
{#if showInfo && infoSpace}
<div class="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50" onclick={() => (showInfo = false)} role="presentation">
<div class="bg-[var(--theme-bg)] text-[var(--theme-text)] rounded-xl shadow-2xl border border-gray-200 dark:border-white/10 w-full max-w-sm overflow-hidden" onclick={(e) => e.stopPropagation()} role="presentation">
<div class="p-6 border-b border-gray-100 dark:border-white/10 flex items-center gap-3">
<Icon icon="mdi:folder-multiple-outline" class="text-xl text-blue-500" />
<h3 class="text-lg font-semibold flex-grow truncate">{infoSpace.name}</h3>
</div>
<div class="p-6 space-y-4 text-sm">
<div><p class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1">Entrypoint</p><p class="font-mono">{infoSpace.entrypoint}</p></div>
<div><p class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1">Last Modified</p><p>{new Date(infoSpace.updated_at.endsWith('Z') ? infoSpace.updated_at : infoSpace.updated_at + 'Z').toLocaleString()}</p></div>
</div>
<div class="p-4 bg-gray-50 dark:bg-white/5 border-t border-gray-100 dark:border-white/10 flex justify-end">
<button onclick={() => (showInfo = false)} class="px-5 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg">Close</button>
</div>
</div>
</div>
{/if}