Update 1.2.0
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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...
|
||||
|
||||
Reference in New Issue
Block a user