Update 1.2.0

This commit is contained in:
2026-04-05 17:59:52 -04:00
parent 88df97f712
commit 37dc7d5610
30 changed files with 2057 additions and 245 deletions
+8 -1
View File
@@ -29,7 +29,14 @@
{#if loaded}
<div
class="min-h-screen w-full flex flex-col font-sans transition-colors duration-200"
style="background-color: {currentColors.background}; color: {currentColors.text}; --theme-bg: {currentColors.background};"
style="
background-color: {currentColors.background};
color: {currentColors.text};
--theme-bg: {currentColors.background};
--theme-text: {currentColors.text};
--theme-border: {currentColors.selection};
--theme-cursor: {currentColors.cursor};
"
>
{@render children()}
</div>
+64 -1
View File
@@ -31,6 +31,8 @@
let dragOverFolderId = $state<string | null>(null);
let dragOverBreadcrumbIndex = $state<number | null>(null);
let fileInput = $state<HTMLInputElement | null>(null);
let importFileInput = $state<HTMLInputElement | null>(null);
let isImporting = $state(false);
let showDeleteModal = $state(false);
let deleteTarget = $state<{id: string, type: 'document'|'folder'|'file', name: string} | null>(null);
@@ -195,6 +197,56 @@
}
}
async function handleImportUpload(e: Event) {
const target = e.target as HTMLInputElement;
if (!target.files || target.files.length === 0) return;
const file = target.files[0];
isImporting = true;
showPlusDropdown = false;
try {
const formData = new FormData();
formData.append('file', file);
const res = await fetch('/api/import/pandoc', {
method: 'POST',
body: formData
});
if (res.ok) {
const typstContent = await res.text();
const title = file.name.replace(/\.[^/.]+$/, "");
const createRes = await fetch('/api/docs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: title,
folder_id: currentFolderId || undefined,
content: typstContent
})
});
if (createRes.ok) {
const doc = await createRes.json();
goto(`/doc/${doc.id}`);
} else {
alert('Failed to create imported document.');
}
} else {
const err = await res.text();
alert(`Failed to import document: ${err}`);
}
} catch (err) {
console.error(err);
alert('Network error during import.');
} finally {
isImporting = false;
target.value = '';
}
}
async function deleteDoc(id: string, name: string) {
openDelete(id, 'document', name);
}
@@ -327,7 +379,7 @@
<Navbar />
<main class="max-w-7xl w-full mx-auto py-10 px-4 sm:px-6 lg:px-8 flex-grow block">
<main class="max-w-7xl w-full mx-auto py-10 px-4 sm:px-6 lg:px-8 grow block">
<div class="flex justify-between items-center mb-6">
<h2 class="text-3xl font-bold text-gray-900 dark:text-white tracking-tight">My Documents</h2>
@@ -350,9 +402,20 @@
<Icon icon="mdi:upload" class="text-lg text-green-500" />
Upload File
</button>
<div class="h-px bg-gray-100 dark:bg-zinc-700 my-1"></div>
<button onclick={() => { showPlusDropdown = false; importFileInput?.click(); }} class="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-zinc-700 flex items-center gap-2" disabled={isImporting}>
{#if isImporting}
<Icon icon="mdi:loading" class="text-lg text-purple-500 animate-spin" />
Importing...
{:else}
<Icon icon="mdi:file-import" class="text-lg text-purple-500" />
Import (.docx, .tex, .md)
{/if}
</button>
</div>
{/if}
<input type="file" bind:this={fileInput} accept="image/*,font/*,.typ,.ttf,.otf" multiple onchange={handleFileUpload} class="hidden" />
<input type="file" bind:this={importFileInput} accept=".docx,.tex,.md,.html" onchange={handleImportUpload} class="hidden" />
</div>
</div>
+66 -5
View File
@@ -8,12 +8,48 @@
import { compileTypst } from '$lib/ts/typst-api';
import type { Diagnostic } from '$lib/ts/typst-api';
import { page } from '$app/stores';
import { commentsSidebarOpen, commentReference, editorViewStore } from '$lib/ts/store';
let svgs = $state<string[]>([]);
let errors = $state<Diagnostic[]>([]);
let timeoutId: number | undefined;
let initialized = $state(false);
let documentTitle = $state('Untitled Document');
let isViewer = $state(false);
let contextMenu = $state({ show: false, x: 0, y: 0, text: '' });
function handleContextMenu(e: MouseEvent) {
const view = $editorViewStore;
if (!view) return;
// Ensure context menu only triggers on editor
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;
}
function handleAddComment() {
$commentReference = contextMenu.text;
$commentsSidebarOpen = true;
closeContextMenu();
}
function triggerCompile() {
if (!text) return;
@@ -45,8 +81,11 @@
if (doc && doc.title) {
documentTitle = doc.title;
}
if (doc && doc.effective_role === 'viewer') {
isViewer = true;
}
})
.catch(err => console.error("Failed to fetch document title:", err));
.catch(err => console.error("Failed to fetch document:", err));
initYjs(docId);
initialized = true;
@@ -71,21 +110,43 @@
<meta property="og:title" content={`${documentTitle} - TypstDrive`} />
</svelte:head>
<div class="flex flex-col h-screen relative">
<Toolbar title={documentTitle} docId={$page.params.id} />
<svelte:window onclick={closeContextMenu} />
<main class="flex-1 flex flex-col md:flex-row overflow-hidden relative">
<div class="flex flex-col h-screen relative">
<Toolbar title={documentTitle} docId={$page.params.id} isViewer={isViewer} />
<main class="flex-1 flex flex-col md:flex-row overflow-hidden relative" oncontextmenu={handleContextMenu}>
{#if !isViewer}
<div class="w-full md:w-1/2 flex flex-col relative min-h-[50%] md:min-h-0 bg-transparent z-10 shadow-[1px_0_10px_rgba(0,0,0,0.05)] dark:shadow-[1px_0_10px_rgba(0,0,0,0.2)] border-r border-gray-200 dark:border-white/10">
{#if initialized}
<Editor />
{/if}
</div>
{/if}
<div class="w-full md:w-1/2 relative bg-white/50 dark:bg-black/20 min-h-[50%] md:min-h-0 flex flex-col">
<div class="{isViewer ? 'w-full' : 'w-full md:w-1/2'} relative bg-white/50 dark:bg-black/20 min-h-[50%] md:min-h-0 flex flex-col">
<Preview {svgs} />
<ErrorBanner {errors} />
</div>
</main>
</div>
<!-- Custom Context Menu for Editor -->
{#if contextMenu.show}
<div
class="fixed z-[9999] bg-white dark:bg-zinc-800 rounded-lg shadow-xl border border-gray-200 dark:border-white/10 py-1 min-w-[200px] overflow-hidden"
style="left: {contextMenu.x}px; top: {contextMenu.y}px;"
>
<button onclick={handleAddComment} class="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-white/10 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-blue-500"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg>
Add Comment on Selection
</button>
<div class="h-px bg-gray-100 dark:bg-white/10 my-1"></div>
<button onclick={() => { navigator.clipboard.writeText(contextMenu.text); closeContextMenu(); }} class="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-white/10 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}
+5 -5
View File
@@ -5,7 +5,7 @@
import Icon from '@iconify/svelte';
import Footer from '$lib/components/Footer.svelte';
let username = $state('');
let email = $state('');
let password = $state('');
let errorMsg = $state('');
@@ -16,7 +16,7 @@
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
body: JSON.stringify({ email, password })
});
if (!res.ok) {
@@ -65,12 +65,12 @@
<form class="mt-8 space-y-6" onsubmit={login}>
<div class="space-y-5">
<div>
<label for="username" class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-1.5">Username</label>
<label for="email" class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-1.5">Email Address</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Icon icon="mdi:account" class="text-gray-400 dark:text-gray-500" />
<Icon icon="mdi:email" class="text-gray-400 dark:text-gray-500" />
</div>
<input id="username" name="username" type="text" required bind:value={username} class="block w-full rounded-xl border border-gray-300 dark:border-white/20 pl-10 pr-3 py-2.5 bg-white/50 dark:bg-black/40 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 sm:text-sm transition-all duration-200" placeholder="Enter your username">
<input id="email" name="email" type="email" required bind:value={email} class="block w-full rounded-xl border border-gray-300 dark:border-white/20 pl-10 pr-3 py-2.5 bg-white/50 dark:bg-black/40 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 sm:text-sm transition-all duration-200" placeholder="[email protected]">
</div>
</div>
<div>
+14 -4
View File
@@ -6,6 +6,7 @@
import Footer from '$lib/components/Footer.svelte';
let username = $state('');
let email = $state('');
let password = $state('');
let errorMsg = $state('');
@@ -16,7 +17,7 @@
const res = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
body: JSON.stringify({ username, email, password })
});
if (!res.ok) {
@@ -25,11 +26,11 @@
return;
}
// Auto login after successful registration using email
const loginRes = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
body: JSON.stringify({ email, password })
});
if (loginRes.ok) {
@@ -54,7 +55,7 @@
<div class="min-h-screen flex flex-col relative overflow-hidden">
<div class="flex-grow flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 relative z-10">
<!-- Background decorative elements -->
<div class="absolute -top-40 right-20 w-96 h-96 bg-emerald-400/20 dark:bg-emerald-600/10 rounded-full blur-3xl mix-blend-multiply z-0"></div>
<div class="absolute top-40 -left-20 w-96 h-96 bg-teal-400/20 dark:bg-teal-600/10 rounded-full blur-3xl mix-blend-multiply z-0"></div>
<div class="absolute -bottom-40 right-40 w-96 h-96 bg-blue-400/20 dark:bg-blue-600/10 rounded-full blur-3xl mix-blend-multiply z-0"></div>
@@ -82,6 +83,15 @@
<input id="username" name="username" type="text" required bind:value={username} class="block w-full rounded-xl border border-gray-300 dark:border-white/20 pl-10 pr-3 py-2.5 bg-white/50 dark:bg-black/40 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 sm:text-sm transition-all duration-200" placeholder="Choose a username">
</div>
</div>
<div>
<label for="email" class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-1.5">Email Address</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Icon icon="mdi:email" class="text-gray-400 dark:text-gray-500" />
</div>
<input id="email" name="email" type="email" required bind:value={email} class="block w-full rounded-xl border border-gray-300 dark:border-white/20 pl-10 pr-3 py-2.5 bg-white/50 dark:bg-black/40 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 sm:text-sm transition-all duration-200" placeholder="[email protected]">
</div>
</div>
<div>
<label for="password" class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-1.5">Password</label>
<div class="relative">
+22 -15
View File
@@ -9,6 +9,7 @@
import Footer from '$lib/components/Footer.svelte';
let username = $state('');
let email = $state('');
let isSaving = $state(false);
let currentPassword = $state('');
@@ -18,8 +19,8 @@
let passwordError = $state('');
let passwordSuccess = $state(false);
let usernameError = $state('');
let usernameSuccess = $state(false);
let profileError = $state('');
let profileSuccess = $state(false);
let storageStats = $state<{documents_size_bytes: number, files_size_bytes: number, total_size_bytes: number} | null>(null);
@@ -36,6 +37,7 @@
goto('/login');
} else {
username = $userStore.username;
email = $userStore.email || '';
try {
const res = await fetch('/api/auth/storage');
@@ -55,29 +57,29 @@
}
async function saveProfile() {
if (!username || username === $userStore?.username) return;
if ((!username || username === $userStore?.username) && (!email || email === $userStore?.email)) return;
isSaving = true;
usernameError = '';
usernameSuccess = false;
profileError = '';
profileSuccess = false;
try {
const res = await fetch('/api/auth/me', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username })
body: JSON.stringify({ username, email })
});
if (!res.ok) {
const text = await res.text();
usernameError = text || "Failed to update profile";
profileError = text || "Failed to update profile";
} else {
const updatedUser = await res.json();
userStore.set(updatedUser);
usernameSuccess = true;
profileSuccess = true;
}
} catch (e) {
usernameError = "Network error occurred.";
profileError = "Network error occurred.";
}
isSaving = false;
@@ -166,15 +168,15 @@
<div class="grid grid-cols-1 gap-4">
<h3 class="text-md font-bold text-gray-900 dark:text-white">Profile</h3>
{#if usernameError}
{#if profileError}
<div class="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 p-3 rounded-lg text-sm">
{usernameError}
{profileError}
</div>
{/if}
{#if usernameSuccess}
{#if profileSuccess}
<div class="bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400 p-3 rounded-lg text-sm">
Username successfully updated.
Profile successfully updated.
</div>
{/if}
@@ -183,8 +185,13 @@
<input id="username-input" type="text" bind:value={username} class="w-full bg-white dark:bg-black/40 border border-gray-300 dark:border-white/20 text-gray-900 dark:text-white rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors" />
</div>
<div class="mt-2">
<label for="email-input" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Email Address</label>
<input id="email-input" type="email" bind:value={email} class="w-full bg-white dark:bg-black/40 border border-gray-300 dark:border-white/20 text-gray-900 dark:text-white rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors" />
</div>
<div class="flex justify-start mt-2">
<button onclick={saveProfile} disabled={isSaving || username === $userStore?.username} class="bg-gray-200 hover:bg-gray-300 text-gray-800 dark:bg-white/10 dark:hover:bg-white/20 dark:text-white px-5 py-2 rounded-lg shadow-sm text-sm font-semibold transition-colors disabled:opacity-50 flex items-center gap-2">
<button onclick={saveProfile} disabled={isSaving || (username === $userStore?.username && email === $userStore?.email)} class="bg-gray-200 hover:bg-gray-300 text-gray-800 dark:bg-white/10 dark:hover:bg-white/20 dark:text-white px-5 py-2 rounded-lg shadow-sm text-sm font-semibold transition-colors disabled:opacity-50 flex items-center gap-2">
{#if isSaving}
<Icon icon="mdi:loading" class="animate-spin text-lg" />
Saving...
@@ -241,7 +248,7 @@
<div class="h-px bg-gray-200 dark:bg-white/10"></div>
<div class="flex justify-end gap-3 pt-2">
<button onclick={saveProfile} disabled={isSaving || username === $userStore?.username} class="bg-blue-600 hover:bg-blue-700 text-white px-5 py-2.5 rounded-lg shadow-sm text-sm font-semibold transition-colors disabled:opacity-50 flex items-center gap-2">
<button onclick={saveProfile} disabled={isSaving || (username === $userStore?.username && email === $userStore?.email)} class="bg-blue-600 hover:bg-blue-700 text-white px-5 py-2.5 rounded-lg shadow-sm text-sm font-semibold transition-colors disabled:opacity-50 flex items-center gap-2">
{#if isSaving}
<Icon icon="mdi:loading" class="animate-spin text-lg" />
Saving...