Rename Space to Project, add desktop document creation

This commit is contained in:
2026-07-20 17:48:14 -04:00
parent 5d3681bd3f
commit 2cfa4afe92
21 changed files with 494 additions and 428 deletions
@@ -2,10 +2,10 @@
import Icon from '@iconify/svelte';
let {
spaceId,
projectId,
onClose
}: {
spaceId: string;
projectId: string;
onClose: () => void;
} = $props();
@@ -22,7 +22,7 @@
const res = await fetch('/api/packages/publish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ space_id: spaceId, version: version.trim() || undefined })
body: JSON.stringify({ project_id: projectId, version: version.trim() || undefined })
});
if (!res.ok) {
error = await res.text();
@@ -45,7 +45,7 @@
</div>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">
Snapshots this space's files into an immutable package version, importable instance-wide as
Snapshots this project's files into an immutable package version, importable instance-wide 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>.
The name, version and entrypoint come from your <code class="font-mono text-xs bg-gray-100 dark:bg-white/10 px-1.5 py-0.5 rounded">typst.toml</code>.
</p>
@@ -1,24 +1,24 @@
<script lang="ts">
import Icon from '@iconify/svelte';
let { createSpace, onClose } = $props<{
createSpace: (name: string) => void,
let { createProject, onClose } = $props<{
createProject: (name: string) => void,
onClose: () => void
}>();
let newSpaceName = $state('Untitled Space');
let newProjectName = $state('Untitled Project');
function onSubmit(e: Event) {
e.preventDefault();
createSpace(newSpaceName);
createProject(newProjectName);
}
</script>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 animate-in fade-in duration-200" role="presentation" onclick={onClose}>
<div tabindex="-1" class="bg-[var(--theme-bg)] rounded-xl shadow-2xl border border-gray-200 dark:border-white/10 w-full max-w-md overflow-hidden" role="dialog" aria-modal="true" aria-labelledby="create-space-title" onclick={(e) => e.stopPropagation()} onkeydown={(e) => { if (e.key === 'Escape') onClose(); }}>
<div tabindex="-1" class="bg-[var(--theme-bg)] rounded-xl shadow-2xl border border-gray-200 dark:border-white/10 w-full max-w-md overflow-hidden" role="dialog" aria-modal="true" aria-labelledby="create-project-title" onclick={(e) => e.stopPropagation()} onkeydown={(e) => { if (e.key === 'Escape') onClose(); }}>
<div class="flex justify-between items-center p-5 border-b border-gray-200 dark:border-white/10">
<h2 id="create-space-title" class="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<h2 id="create-project-title" class="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<Icon icon="mdi:folder-multiple-plus" class="text-blue-500 text-xl" />
Create Space
Create Project
</h2>
<button onclick={onClose} aria-label="Close" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-full p-1 transition-colors">
<Icon icon="mdi:close" class="text-xl" />
@@ -27,14 +27,14 @@
<form onsubmit={onSubmit} class="p-5 space-y-4">
<div class="space-y-2">
<label for="space-name-input" class="text-sm font-medium text-gray-700 dark:text-gray-300 block">Space Name</label>
<label for="project-name-input" class="text-sm font-medium text-gray-700 dark:text-gray-300 block">Project Name</label>
<input
id="space-name-input"
id="project-name-input"
type="text"
required
bind:value={newSpaceName}
bind:value={newProjectName}
class="w-full bg-black/5 dark:bg-white/5 border border-gray-300 dark:border-white/20 text-gray-900 dark:text-white text-sm rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
placeholder="Untitled Space"
placeholder="Untitled Project"
/>
<p class="text-xs text-gray-500 dark:text-gray-400">A multi-file workspace, seeded with a <code class="font-mono">typst.toml</code> and <code class="font-mono">main.typ</code>.</p>
</div>
+1 -1
View File
@@ -23,7 +23,7 @@
</div>
<div class="h-6 w-px bg-gray-300 dark:bg-white/20"></div>
<a href="/spaces" class="text-sm font-medium text-gray-600 hover:text-blue-600 dark:text-gray-300 dark:hover:text-blue-400 transition-colors bg-gray-100 hover:bg-gray-200 dark:bg-white/5 dark:hover:bg-white/10 px-3 py-2 rounded-lg flex items-center gap-2 border border-transparent dark:border-white/10" title="Spaces">
<a href="/projects" class="text-sm font-medium text-gray-600 hover:text-blue-600 dark:text-gray-300 dark:hover:text-blue-400 transition-colors bg-gray-100 hover:bg-gray-200 dark:bg-white/5 dark:hover:bg-white/10 px-3 py-2 rounded-lg flex items-center gap-2 border border-transparent dark:border-white/10" title="Projects">
<Icon icon="mdi:folder-multiple-outline" class="text-xl" />
</a>
<a href="/packages" class="text-sm font-medium text-gray-600 hover:text-purple-600 dark:text-gray-300 dark:hover:text-purple-400 transition-colors bg-gray-100 hover:bg-gray-200 dark:bg-white/5 dark:hover:bg-white/10 px-3 py-2 rounded-lg flex items-center gap-2 border border-transparent dark:border-white/10" title="Packages">
@@ -2,26 +2,26 @@
import Icon from '@iconify/svelte';
import { goto } from '$app/navigation';
let { space, activeMenu, setActiveMenu, openInfo, openRename, deleteSpace } = $props<{
space: any;
let { project, activeMenu, setActiveMenu, openInfo, openRename, deleteProject } = $props<{
project: any;
activeMenu: string | null;
setActiveMenu: (id: string | null) => void;
openInfo: (space: any) => void;
openInfo: (project: any) => void;
openRename: (id: string, name: string) => void;
deleteSpace: (id: string, name: string) => void;
deleteProject: (id: string, name: string) => void;
}>();
let dropUp = $state(false);
function toggleMenu(e: MouseEvent) {
e.stopPropagation();
if (activeMenu === space.id) {
if (activeMenu === project.id) {
setActiveMenu(null);
} else {
const button = e.currentTarget as HTMLElement;
const rect = button.getBoundingClientRect();
dropUp = window.innerHeight - rect.bottom < 200;
setActiveMenu(space.id);
setActiveMenu(project.id);
}
}
</script>
@@ -30,13 +30,13 @@
class="bg-white dark:bg-black/20 rounded-xl shadow-sm border border-gray-200 dark:border-white/10 flex flex-col hover:shadow-lg hover:border-blue-400 dark:hover:border-blue-500/50 transition-all duration-200 relative group transform hover:-translate-y-1 cursor-pointer overflow-visible"
role="button"
tabindex="0"
onclick={() => goto(`/space/${space.id}`)}
onkeydown={(e) => e.key === 'Enter' && goto(`/space/${space.id}`)}
onclick={() => goto(`/project/${project.id}`)}
onkeydown={(e) => e.key === 'Enter' && goto(`/project/${project.id}`)}
>
<div class="h-40 w-full bg-gray-50 dark:bg-black/40 rounded-t-xl overflow-hidden flex items-center justify-center border-b border-gray-100 dark:border-white/10 relative pointer-events-none">
{#if space.thumbnail_svg}
{#if project.thumbnail_svg}
<div class="w-full h-full flex items-start justify-center bg-white transition-transform duration-300 group-hover:scale-110">
<img src={`data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(space.thumbnail_svg)))}`} class="w-full h-auto shadow-sm border border-gray-200" alt="Thumbnail" draggable="false" />
<img src={`data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(project.thumbnail_svg)))}`} class="w-full h-auto shadow-sm border border-gray-200" alt="Thumbnail" draggable="false" />
</div>
{:else}
<div class="p-4 bg-blue-50 dark:bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-full transition-transform duration-300 group-hover:scale-110">
@@ -47,29 +47,29 @@
<div class="p-4 flex flex-col flex-grow">
<div class="flex items-start justify-between">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white truncate pr-2 pointer-events-none" title={space.name}>{space.name}</h3>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white truncate pr-2 pointer-events-none" title={project.name}>{project.name}</h3>
<div class="relative action-menu-container">
<button aria-label="Space actions" onclick={toggleMenu} class="text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors p-1 rounded-full hover:bg-gray-100 dark:hover:bg-white/10 pointer-events-auto">
<button aria-label="Project actions" onclick={toggleMenu} class="text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors p-1 rounded-full hover:bg-gray-100 dark:hover:bg-white/10 pointer-events-auto">
<Icon icon="mdi:dots-vertical" class="text-xl" />
</button>
{#if activeMenu === space.id}
{#if activeMenu === project.id}
<div class="absolute right-0 {dropUp ? 'bottom-full mb-1' : 'top-full mt-1'} w-48 bg-[var(--theme-bg)] rounded-xl shadow-xl border border-gray-200 dark:border-white/10 py-1 z-[100]">
<button onclick={(e) => { e.stopPropagation(); openInfo(space); }} 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">
<button onclick={(e) => { e.stopPropagation(); openInfo(project); }} 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:information-outline" class="text-lg text-blue-500" />
View Info
</button>
<button onclick={(e) => { e.stopPropagation(); openRename(space.id, space.name); }} 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">
<button onclick={(e) => { e.stopPropagation(); openRename(project.id, project.name); }} 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:pencil-outline" class="text-lg text-yellow-500" />
Rename
</button>
<button onclick={(e) => { e.stopPropagation(); goto(`/space/${space.id}`); }} 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">
<button onclick={(e) => { e.stopPropagation(); goto(`/project/${project.id}`); }} 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:open-in-new" class="text-lg text-green-500" />
Open
</button>
<div class="h-px bg-gray-200 dark:bg-white/10 my-1"></div>
<button onclick={(e) => { e.stopPropagation(); deleteSpace(space.id, space.name); }} class="w-full text-left px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-500/10 flex items-center gap-2">
<button onclick={(e) => { e.stopPropagation(); deleteProject(project.id, project.name); }} class="w-full text-left px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-500/10 flex items-center gap-2">
<Icon icon="mdi:trash-can-outline" class="text-lg" />
Delete
</button>
@@ -79,7 +79,7 @@
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1 mt-2 pointer-events-none">
<Icon icon="mdi:clock-outline" class="text-sm" />
Edited {new Date(space.updated_at.endsWith('Z') ? space.updated_at : space.updated_at + 'Z').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
Edited {new Date(project.updated_at.endsWith('Z') ? project.updated_at : project.updated_at + 'Z').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
</p>
</div>
</div>
@@ -1,7 +1,7 @@
<script lang="ts">
import Icon from '@iconify/svelte';
interface SpaceFile {
interface ProjectFile {
id: string;
path: string;
kind: string;
@@ -19,16 +19,16 @@
onDelete,
onSetEntry
}: {
files?: SpaceFile[];
files?: ProjectFile[];
activeFileId?: string;
entrypoint?: string;
readOnly?: boolean;
onSelect: (file: SpaceFile) => void;
onSelect: (file: ProjectFile) => void;
onCreate: (path: string) => void;
onUpload: (fileList: FileList) => void;
onRename: (file: SpaceFile, path: string) => void;
onDelete: (file: SpaceFile) => void;
onSetEntry: (file: SpaceFile) => void;
onRename: (file: ProjectFile, path: string) => void;
onDelete: (file: ProjectFile) => void;
onSetEntry: (file: ProjectFile) => void;
} = $props();
let fileInput: HTMLInputElement = $state()!;
@@ -48,7 +48,7 @@
if (path && path.trim()) onCreate(path.trim());
}
function handleRename(file: SpaceFile) {
function handleRename(file: ProjectFile) {
const path = prompt('Rename file to:', file.path);
if (path && path.trim() && path.trim() !== file.path) onRename(file, path.trim());
}
@@ -9,14 +9,14 @@
documentZoomStore,
previewOpenStore
} from '../../ts/store';
import { exportSpace } from '../../ts/typst-api';
import { exportProject } from '../../ts/typst-api';
import ThemePicker from '../ThemePicker.svelte';
import PageSettingsModal from '../PageSettingsModal.svelte';
import PresentationMode from '../PresentationMode.svelte';
let {
spaceName = 'Space',
spaceId,
projectName = 'Project',
projectId,
entrypoint = 'main.typ',
role = 'owner',
activeText = null,
@@ -25,8 +25,8 @@
onPublish,
onFilesChanged
}: {
spaceName?: string;
spaceId: string;
projectName?: string;
projectId: string;
entrypoint?: string;
role?: string;
activeText?: any;
@@ -55,10 +55,10 @@
let showDeleteModal = $state(false);
let renameName = $state('');
$effect(() => { renameName = spaceName; });
$effect(() => { renameName = projectName; });
function safeName() {
return spaceName.replace(/[^a-z0-9_-]/gi, '_');
return projectName.replace(/[^a-z0-9_-]/gi, '_');
}
function handleExport(format: 'pdf' | 'png' | 'svg' | 'typ') {
@@ -73,7 +73,7 @@
URL.revokeObjectURL(url);
return;
}
exportSpace(spaceId, getAllText(), format, safeName()).catch((e) => {
exportProject(projectId, getAllText(), format, safeName()).catch((e) => {
console.error(`Export to ${format} failed:`, e);
alert(`Failed to export as ${format.toUpperCase()}`);
});
@@ -83,7 +83,7 @@
fetch(`/api/export/pdf`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ space_id: spaceId, files: getAllText() })
body: JSON.stringify({ project_id: projectId, files: getAllText() })
})
.then((res) => {
if (!res.ok) throw new Error('Print failed');
@@ -225,7 +225,7 @@
const file = target.files[0];
const form = new FormData();
form.append('file', file);
fetch(`/api/spaces/${spaceId}/files/upload`, { method: 'POST', body: form })
fetch(`/api/projects/${projectId}/files/upload`, { method: 'POST', body: form })
.then((res) => res.json())
.then(() => {
const lower = file.name.toLowerCase();
@@ -259,8 +259,8 @@
function submitRename(e: Event) {
e.preventDefault();
if (renameName && renameName !== spaceName) {
fetch(`/api/spaces/${spaceId}`, {
if (renameName && renameName !== projectName) {
fetch(`/api/projects/${projectId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: renameName })
@@ -270,8 +270,8 @@
}
function confirmDelete() {
fetch(`/api/spaces/${spaceId}`, { method: 'DELETE' }).then((res) => {
if (res.ok) goto('/spaces');
fetch(`/api/projects/${projectId}`, { method: 'DELETE' }).then((res) => {
if (res.ok) goto('/projects');
});
}
@@ -289,14 +289,14 @@
<header class="flex flex-col border-b border-[var(--theme-border)] bg-[var(--theme-bg)] text-[var(--theme-text)] select-none w-full relative z-[70]">
<div class="flex items-center justify-between px-4 py-2.5">
<div class="flex items-center gap-3">
<button onclick={() => goto('/spaces')} class="p-1.5 text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-white/10 transition-colors" title="Spaces">
<button onclick={() => goto('/projects')} class="p-1.5 text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-white/10 transition-colors" title="Projects">
<Icon icon="mdi:arrow-left" class="text-xl" />
</button>
<div class="flex flex-col gap-0.5">
<div class="flex items-center gap-2">
<Icon icon="mdi:folder-multiple-outline" class="text-blue-500 text-base" />
<h1 class="text-[16px] font-semibold text-gray-900 dark:text-white tracking-tight truncate max-w-[200px] md:max-w-xs" title={spaceName}>{spaceName}</h1>
<h1 class="text-[16px] font-semibold text-gray-900 dark:text-white tracking-tight truncate max-w-[200px] md:max-w-xs" title={projectName}>{projectName}</h1>
</div>
<div class="flex items-center gap-0.5 text-[13px] font-medium text-gray-600 dark:text-gray-300 -ml-1 action-menu-container">
@@ -304,11 +304,11 @@
<button onclick={(e) => { e.stopPropagation(); activeMenu = activeMenu === 'file' ? null : 'file'; }} class="px-2 py-0.5 rounded transition-colors {activeMenu === 'file' ? 'bg-[var(--theme-border)]' : 'hover:bg-[var(--theme-border)]'}">File</button>
{#if activeMenu === 'file'}
<div class="absolute left-0 top-full mt-1 w-48 bg-[var(--theme-bg)] rounded-xl shadow-xl border border-[var(--theme-border)] py-1 z-[100] max-h-[calc(100vh-8rem)] overflow-y-auto">
<button onclick={() => { activeMenu = null; goto('/spaces'); }} class="w-full text-left px-4 py-1.5 text-sm hover:bg-[var(--theme-border)]">All Spaces</button>
<button onclick={() => { activeMenu = null; showInfoModal = true; }} class="w-full text-left px-4 py-1.5 text-sm hover:bg-[var(--theme-border)]">Space Info</button>
<button onclick={() => { activeMenu = null; goto('/projects'); }} class="w-full text-left px-4 py-1.5 text-sm hover:bg-[var(--theme-border)]">All Projects</button>
<button onclick={() => { activeMenu = null; showInfoModal = true; }} class="w-full text-left px-4 py-1.5 text-sm hover:bg-[var(--theme-border)]">Project Info</button>
{#if !isViewer}
<div class="h-px bg-[var(--theme-border)] my-1"></div>
<button onclick={() => { activeMenu = null; renameName = spaceName; showRenameModal = true; }} class="w-full text-left px-4 py-1.5 text-sm hover:bg-[var(--theme-border)]">Rename</button>
<button onclick={() => { activeMenu = null; renameName = projectName; showRenameModal = true; }} class="w-full text-left px-4 py-1.5 text-sm hover:bg-[var(--theme-border)]">Rename</button>
<button onclick={() => { activeMenu = null; isPageSettingsOpen = true; }} class="w-full text-left px-4 py-1.5 text-sm hover:bg-[var(--theme-border)]">Page Settings</button>
{#if role === 'owner'}
<button onclick={() => { activeMenu = null; onPublish(); }} class="w-full text-left px-4 py-1.5 text-sm hover:bg-[var(--theme-border)]">Publish as Package</button>
@@ -329,7 +329,7 @@
<button onclick={() => { activeMenu = null; handlePandocExport('html'); }} class="w-full text-left px-4 py-1 text-sm hover:bg-[var(--theme-border)]">HTML (.html)</button>
{#if role === 'owner'}
<div class="h-px bg-[var(--theme-border)] my-1"></div>
<button onclick={() => { activeMenu = null; showDeleteModal = true; }} class="w-full text-left px-4 py-1.5 text-sm text-red-500 hover:bg-red-500/10">Delete Space</button>
<button onclick={() => { activeMenu = null; showDeleteModal = true; }} class="w-full text-left px-4 py-1.5 text-sm text-red-500 hover:bg-red-500/10">Delete Project</button>
{/if}
</div>
{/if}
@@ -427,8 +427,8 @@
<div class="w-px h-4 bg-gray-300 dark:bg-white/10"></div>
<div class="flex items-center gap-2">
<label for="space-font-select" class="text-[11px] font-semibold uppercase tracking-wider opacity-60">Font</label>
<select id="space-font-select" onchange={(e) => insertTypstConfig('text', `font: "${e.currentTarget.value}"`)} class="bg-[var(--theme-bg)] text-[var(--theme-text)] border border-[var(--theme-border)] text-xs rounded shadow-sm block py-1 pl-2 pr-6 appearance-none cursor-pointer">
<label for="project-font-select" class="text-[11px] font-semibold uppercase tracking-wider opacity-60">Font</label>
<select id="project-font-select" onchange={(e) => insertTypstConfig('text', `font: "${e.currentTarget.value}"`)} class="bg-[var(--theme-bg)] text-[var(--theme-text)] border border-[var(--theme-border)] text-xs rounded shadow-sm block py-1 pl-2 pr-6 appearance-none cursor-pointer">
<option value="New Computer Modern">Default (New CM)</option>
<option value="Libertinus Serif">Libertinus Serif</option>
<option value="PT Sans">PT Sans</option>
@@ -478,10 +478,10 @@
<div class="bg-[var(--theme-bg)] text-[var(--theme-text)] rounded-2xl 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">{spaceName}</h3>
<h3 class="text-lg font-semibold flex-grow truncate">{projectName}</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">Type</p><p>Space (multi-file)</p></div>
<div><p class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1">Type</p><p>Project (multi-file)</p></div>
<div><p class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1">Entrypoint</p><p class="font-mono">{entrypoint}</p></div>
<div><p class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1">Your role</p><p class="capitalize">{role}</p></div>
</div>
@@ -496,7 +496,7 @@
<div class="fixed inset-0 bg-black/60 backdrop-blur-sm z-[100] flex items-center justify-center p-4" onclick={() => showRenameModal = false} role="presentation">
<div class="bg-[var(--theme-bg)] text-[var(--theme-text)] rounded-2xl shadow-2xl border border-gray-200 dark:border-white/10 w-full max-w-sm overflow-hidden" onclick={(e) => e.stopPropagation()} role="presentation">
<form onsubmit={submitRename} class="p-6">
<h3 class="text-lg font-semibold mb-4 flex items-center gap-2"><Icon icon="mdi:pencil-outline" class="text-yellow-500" /> Rename Space</h3>
<h3 class="text-lg font-semibold mb-4 flex items-center gap-2"><Icon icon="mdi:pencil-outline" class="text-yellow-500" /> Rename Project</h3>
<input type="text" required bind:value={renameName} class="w-full bg-transparent border border-gray-300 dark:border-white/20 text-sm rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500" />
<div class="pt-6 flex justify-end gap-3">
<button type="button" onclick={() => showRenameModal = false} class="px-4 py-2 text-sm font-medium hover:bg-gray-100 dark:hover:bg-white/10 rounded-lg">Cancel</button>
@@ -511,8 +511,8 @@
<div class="fixed inset-0 bg-black/60 backdrop-blur-sm z-[100] flex items-center justify-center p-4" onclick={() => showDeleteModal = false} role="presentation">
<div class="bg-[var(--theme-bg)] text-[var(--theme-text)] rounded-2xl 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">
<h3 class="text-lg font-semibold mb-4 flex items-center gap-2"><Icon icon="mdi:trash-can-outline" class="text-red-500" /> Delete Space</h3>
<p class="text-gray-600 dark:text-gray-300 text-sm mb-6">Delete this space and all its files? This cannot be undone.</p>
<h3 class="text-lg font-semibold mb-4 flex items-center gap-2"><Icon icon="mdi:trash-can-outline" class="text-red-500" /> Delete Project</h3>
<p class="text-gray-600 dark:text-gray-300 text-sm mb-6">Delete this project and all its files? This cannot be undone.</p>
<div class="flex justify-end gap-3">
<button type="button" onclick={() => showDeleteModal = false} class="px-4 py-2 text-sm font-medium hover:bg-gray-100 dark:hover:bg-white/10 rounded-lg">Cancel</button>
<button type="button" onclick={confirmDelete} class="bg-red-600 hover:bg-red-700 text-white px-5 py-2 rounded-lg text-sm font-medium">Delete</button>
+4 -4
View File
@@ -20,20 +20,20 @@ export async function compileTypst(text: string, document_id?: string): Promise<
return await res.json();
}
export async function compileSpace(space_id: string, files: Record<string, string>): Promise<CompileResponse> {
export async function compileProject(project_id: string, files: Record<string, string>): Promise<CompileResponse> {
const res = await fetch('/api/compile', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ space_id, files }),
body: JSON.stringify({ project_id, files }),
});
return await res.json();
}
export function exportSpace(space_id: string, files: Record<string, string>, format: 'pdf' | 'png' | 'svg', title: string = 'document') {
export function exportProject(project_id: string, files: Record<string, string>, format: 'pdf' | 'png' | 'svg', title: string = 'document') {
return fetch(`/api/export/${format}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ space_id, files }),
body: JSON.stringify({ project_id, files }),
})
.then((res) => {
if (!res.ok) throw new Error('Export failed');
@@ -19,18 +19,18 @@ const userColors = [
];
const open = new Map<string, OpenFile>();
let spaceId: string | null = null;
let projectId: string | null = null;
const TEXT_NAME = 'typst';
export function setSpace(id: string) {
spaceId = id;
export function setProject(id: string) {
projectId = id;
}
export function openFile(fileId: string, path: string): OpenFile {
const existing = open.get(fileId);
if (existing) return existing;
if (!spaceId) throw new Error('Space not set');
if (!projectId) throw new Error('Project not set');
const doc = new Y.Doc();
const text = doc.getText(TEXT_NAME);
@@ -40,7 +40,7 @@ export function openFile(fileId: string, path: string): OpenFile {
connectionStatus.set('connecting');
const provider = new WebsocketProvider(`${protocol}//${host}/yjs`, `space:${spaceId}:${fileId}`, doc);
const provider = new WebsocketProvider(`${protocol}//${host}/yjs`, `project:${projectId}:${fileId}`, doc);
const user = get(userStore);
const color = userColors[Math.floor(Math.random() * userColors.length)];
@@ -104,11 +104,11 @@ export function getAllText(): Record<string, string> {
return result;
}
export function cleanupSpace() {
export function cleanupProject() {
for (const fileId of Array.from(open.keys())) {
closeFile(fileId);
}
spaceId = null;
projectId = null;
connectionStatus.set('disconnected');
connectedUsers.set([]);
}
+13 -13
View File
@@ -14,7 +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 CreateProjectModal from '$lib/components/dashboard/CreateProjectModal.svelte';
import CreateFolderModal from '$lib/components/dashboard/CreateFolderModal.svelte';
import Footer from '$lib/components/Footer.svelte';
@@ -27,7 +27,7 @@
let newFolderName = $state('');
let loading = $state(true);
let showCreateModal = $state(false);
let showCreateSpaceModal = $state(false);
let showCreateProjectModal = $state(false);
let newDocTitle = $state('');
let showPlusDropdown = $state(false);
let dragOverFolderId = $state<string | null>(null);
@@ -200,24 +200,24 @@
}
}
function openCreateSpaceModal() {
function openCreateProjectModal() {
showPlusDropdown = false;
showCreateSpaceModal = true;
showCreateProjectModal = true;
}
async function createSpace(name: string) {
async function createProject(name: string) {
if (!name.trim()) return;
const res = await fetch('/api/spaces', {
const res = await fetch('/api/projects', {
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}`);
const project = await res.json();
showCreateProjectModal = false;
goto(`/project/${project.id}`);
}
}
@@ -437,9 +437,9 @@
<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">
<button onclick={openCreateProjectModal} 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
New Project
</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" />
@@ -629,8 +629,8 @@
<CreateDocModal {createDoc} onClose={() => showCreateModal = false} />
{/if}
{#if showCreateSpaceModal}
<CreateSpaceModal {createSpace} onClose={() => showCreateSpaceModal = false} />
{#if showCreateProjectModal}
<CreateProjectModal {createProject} onClose={() => showCreateProjectModal = false} />
{/if}
{#if showCreateFolderModal}
+2 -2
View File
@@ -60,7 +60,7 @@
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
Instance-local Typst packages, published from Projects 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>
@@ -70,7 +70,7 @@
{: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>
<p>No packages published yet. Open a Project and use “Publish” to create one.</p>
</div>
{:else}
<div class="space-y-3">
@@ -5,26 +5,26 @@
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 FileTree from '$lib/components/project/FileTree.svelte';
import ProjectToolbar from '$lib/components/project/ProjectToolbar.svelte';
import PublishPackageModal from '$lib/components/PublishPackageModal.svelte';
import { compileSpace } from '$lib/ts/typst-api';
import { compileProject } 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';
import { setProject, openFile, getOpenFile, closeFile, renameOpenFile, getAllText, cleanupProject } from '$lib/ts/yjs-project';
interface SpaceFile {
interface ProjectFile {
id: string;
path: string;
kind: string;
}
const spaceId = $page.params.id as string;
const projectId = $page.params.id as string;
let spaceName = $state('Space');
let projectName = $state('Project');
let entrypoint = $state('main.typ');
let role = $state('owner');
let files = $state<SpaceFile[]>([]);
let files = $state<ProjectFile[]>([]);
let activeFileId = $state('');
let svgs = $state<string[]>([]);
let errors = $state<Diagnostic[]>([]);
@@ -45,7 +45,7 @@
function triggerCompile() {
if (!$previewOpenStore) return;
compileSpace(spaceId, getAllText())
compileProject(projectId, getAllText())
.then((res) => {
if (res.stats) $documentStatsStore = res.stats;
if (res.svgs) {
@@ -58,12 +58,12 @@
}
})
.catch(() => {
errors = [{ message: 'Network or server error compiling space.', severity: 'error' }];
errors = [{ message: 'Network or server error compiling project.', severity: 'error' }];
});
}
async function loadFiles() {
const res = await fetch(`/api/spaces/${spaceId}/files`);
const res = await fetch(`/api/projects/${projectId}/files`);
if (!res.ok) return;
files = await res.json();
@@ -80,13 +80,13 @@
}
}
function selectFile(file: SpaceFile) {
function selectFile(file: ProjectFile) {
if (file.kind !== 'text') return;
activeFileId = file.id;
}
async function createFile(path: string) {
const res = await fetch(`/api/spaces/${spaceId}/files`, {
const res = await fetch(`/api/projects/${projectId}/files`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path, kind: 'text', content: '' })
@@ -103,15 +103,15 @@
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 });
const res = await fetch(`/api/projects/${projectId}/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}`, {
async function renameFile(file: ProjectFile, path: string) {
const res = await fetch(`/api/projects/${projectId}/files/${file.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path })
@@ -123,9 +123,9 @@
}
}
async function deleteFile(file: SpaceFile) {
async function deleteFile(file: ProjectFile) {
if (!confirm(`Delete ${file.path}?`)) return;
const res = await fetch(`/api/spaces/${spaceId}/files/${file.id}`, { method: 'DELETE' });
const res = await fetch(`/api/projects/${projectId}/files/${file.id}`, { method: 'DELETE' });
if (res.ok) {
closeFile(file.id);
files = files.filter((f) => f.id !== file.id);
@@ -136,8 +136,8 @@
}
}
async function setEntry(file: SpaceFile) {
const res = await fetch(`/api/spaces/${spaceId}`, {
async function setEntry(file: ProjectFile) {
const res = await fetch(`/api/projects/${projectId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ entrypoint: file.path })
@@ -166,38 +166,38 @@
}
onMount(() => {
setSpace(spaceId);
fetch(`/api/spaces/${spaceId}`)
setProject(projectId);
fetch(`/api/projects/${projectId}`)
.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((p) => {
if (p && p.name) projectName = p.name;
if (p && p.entrypoint) entrypoint = p.entrypoint;
if (p && p.effective_role) role = p.effective_role;
})
.then(loadFiles)
.then(() => {
ready = true;
triggerCompile();
})
.catch((e) => console.error('Failed to load space', e));
.catch((e) => console.error('Failed to load project', e));
return () => {
if (timeoutId) clearTimeout(timeoutId);
cleanupSpace();
cleanupProject();
};
});
</script>
<svelte:head>
<title>{spaceName} - TypstDrive</title>
<title>{projectName} - TypstDrive</title>
</svelte:head>
<svelte:window onclick={closeContextMenu} />
<div class="flex flex-col h-screen relative">
<SpaceToolbar
{spaceName}
{spaceId}
<ProjectToolbar
{projectName}
{projectId}
{entrypoint}
{role}
activeText={activeEntry?.text ?? null}
@@ -254,5 +254,5 @@
{/if}
{#if showPublish}
<PublishPackageModal {spaceId} onClose={() => (showPublish = false)} />
<PublishPackageModal {projectId} onClose={() => (showPublish = false)} />
{/if}
@@ -3,9 +3,9 @@
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';
import ProjectCard from '$lib/components/dashboard/ProjectCard.svelte';
interface Space {
interface Project {
id: string;
name: string;
entrypoint: string;
@@ -14,8 +14,8 @@
effective_role?: string;
}
let spaces = $state<Space[]>([]);
let shared = $state<Space[]>([]);
let projects = $state<Project[]>([]);
let shared = $state<Project[]>([]);
let loading = $state(true);
let showCreate = $state(false);
let newName = $state('');
@@ -26,22 +26,22 @@
let renameId = $state('');
let renameName = $state('');
let showInfo = $state(false);
let infoSpace = $state<Space | null>(null);
let infoProject = $state<Project | null>(null);
function setActiveMenu(id: string | null) { activeMenu = id; }
function openInfo(space: Space) { activeMenu = null; infoSpace = space; showInfo = true; }
function openInfo(project: Project) { activeMenu = null; infoProject = project; 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}`, {
const res = await fetch(`/api/projects/${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));
projects = projects.map((p) => (p.id === renameId ? { ...p, name: renameName.trim() } : p));
}
showRename = false;
}
@@ -54,10 +54,10 @@
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() : []))
fetch('/api/projects').then((r) => (r.ok ? r.json() : [])),
fetch('/api/projects/shared').then((r) => (r.ok ? r.json() : []))
]);
spaces = own;
projects = own;
shared = sh;
loading = false;
}
@@ -65,29 +65,29 @@
async function create() {
if (!newName.trim()) return;
creating = true;
const res = await fetch('/api/spaces', {
const res = await fetch('/api/projects', {
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}`);
const project = await res.json();
goto(`/project/${project.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);
if (!confirm(`Delete project "${name}"? This cannot be undone.`)) return;
const res = await fetch(`/api/projects/${id}`, { method: 'DELETE' });
if (res.ok) projects = projects.filter((p) => p.id !== id);
}
onMount(load);
</script>
<svelte:head>
<title>Spaces - TypstDrive</title>
<title>Projects - TypstDrive</title>
</svelte:head>
<svelte:window onclick={handleWindowClick} />
@@ -104,33 +104,33 @@
</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
Projects
</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
<Icon icon="mdi:plus" class="text-lg" /> New Project
</button>
</div>
{#if loading}
<p class="text-gray-500 dark:text-gray-400">Loading…</p>
{:else}
{#if spaces.length === 0}
{#if projects.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>
<p>No projects 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}
{#each projects as project (project.id)}
<ProjectCard
{project}
{activeMenu}
{setActiveMenu}
{openInfo}
{openRename}
deleteSpace={remove}
deleteProject={remove}
/>
{/each}
</div>
@@ -141,18 +141,18 @@
<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">
{#each shared as project (project.id)}
<button onclick={() => goto(`/project/${project.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}
{#if project.thumbnail_svg}
{@html project.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>
<p class="font-medium text-gray-900 dark:text-white truncate">{project.name}</p>
<p class="text-xs text-gray-400 mt-0.5">{project.effective_role}</p>
</div>
</button>
{/each}
@@ -165,8 +165,8 @@
{#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" />
<h2 class="text-lg font-bold mb-4 flex items-center gap-2"><Icon icon="mdi:folder-plus-outline" class="text-blue-500" /> New Project</h2>
<input bind:value={newName} placeholder="Project 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>
@@ -178,7 +178,7 @@
{#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>
<h2 class="text-lg font-bold mb-4 flex items-center gap-2"><Icon icon="mdi:pencil-outline" class="text-yellow-500" /> Rename Project</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>
@@ -188,16 +188,16 @@
</div>
{/if}
{#if showInfo && infoSpace}
{#if showInfo && infoProject}
<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>
<h3 class="text-lg font-semibold flex-grow truncate">{infoProject.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><p class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1">Entrypoint</p><p class="font-mono">{infoProject.entrypoint}</p></div>
<div><p class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1">Last Modified</p><p>{new Date(infoProject.updated_at.endsWith('Z') ? infoProject.updated_at : infoProject.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>