Initial Commit
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import Icon from '@iconify/svelte';
|
||||
</script>
|
||||
|
||||
<div class="min-h-[80vh] flex flex-col items-center justify-center p-4">
|
||||
<div class="text-center max-w-md bg-white dark:bg-zinc-900 rounded-2xl shadow-xl border border-gray-200 dark:border-zinc-800 p-8">
|
||||
<div class="inline-flex items-center justify-center w-20 h-20 rounded-full bg-red-100 dark:bg-red-500/10 mb-6">
|
||||
<Icon icon="mdi:alert-circle-outline" class="h-10 w-10 text-red-600 dark:text-red-500" />
|
||||
</div>
|
||||
|
||||
<h1 class="text-6xl font-bold text-gray-900 dark:text-white mb-2 tracking-tight">
|
||||
{$page.status}
|
||||
</h1>
|
||||
|
||||
<h2 class="text-xl font-semibold text-gray-800 dark:text-gray-200 mb-4">
|
||||
Something went wrong
|
||||
</h2>
|
||||
|
||||
<p class="text-base text-gray-600 dark:text-gray-400 mb-8 leading-relaxed">
|
||||
{$page.error?.message || 'We experienced an unexpected error processing your request.'}
|
||||
</p>
|
||||
|
||||
<a
|
||||
href="/"
|
||||
class="inline-flex items-center justify-center gap-2 px-6 py-3 border border-transparent text-sm font-semibold rounded-xl shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-zinc-900 transition-colors w-full"
|
||||
>
|
||||
<Icon icon="mdi:home" class="text-lg" />
|
||||
Return to Dashboard
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import { fetchUser } from '$lib/ts/auth';
|
||||
import { onMount } from 'svelte';
|
||||
import { themeStore, darkModeStore } from '$lib/ts/store';
|
||||
import { themes } from '$lib/ts/themes';
|
||||
|
||||
let { children } = $props();
|
||||
let loaded = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
await fetchUser();
|
||||
loaded = true;
|
||||
});
|
||||
|
||||
let currentTheme = $derived(themes[$themeStore as keyof typeof themes] || themes['Catppuccin']);
|
||||
let currentColors = $derived($darkModeStore ? currentTheme.dark : currentTheme.light);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>TypstDrive</title>
|
||||
<meta name="description" content="TypstDrive - A collaborative Typst editor and document manager." />
|
||||
<meta name="theme-color" content={currentColors.background} />
|
||||
<meta property="og:title" content="TypstDrive" />
|
||||
<meta property="og:description" content="A collaborative Typst editor and document manager." />
|
||||
<meta property="og:type" content="website" />
|
||||
</svelte:head>
|
||||
|
||||
{#if loaded}
|
||||
<div
|
||||
class="h-screen w-screen flex flex-col font-sans transition-colors duration-200"
|
||||
style="background-color: {currentColors.background}; color: {currentColors.text};"
|
||||
>
|
||||
{@render children()}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="h-screen w-screen flex items-center justify-center bg-gray-50 dark:bg-zinc-950">
|
||||
<div class="text-gray-500 dark:text-gray-400 font-medium animate-pulse">Loading TypstDrive...</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,2 @@
|
||||
export const prerender = true;
|
||||
export const ssr = false;
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { userStore } from '$lib/ts/auth';
|
||||
|
||||
onMount(() => {
|
||||
if ($userStore) {
|
||||
goto('/dashboard');
|
||||
} else {
|
||||
goto('/login');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>TypstDrive</title>
|
||||
<meta name="description" content="Collaborative Typst Editor." />
|
||||
</svelte:head>
|
||||
|
||||
<div class="h-full flex items-center justify-center text-gray-500">
|
||||
Redirecting...
|
||||
</div>
|
||||
@@ -0,0 +1,477 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { userStore } from '$lib/ts/auth';
|
||||
import { themeStore, darkModeStore } from '$lib/ts/store';
|
||||
import Icon from '@iconify/svelte';
|
||||
import ThemePicker from '$lib/components/ThemePicker.svelte';
|
||||
import Navbar from '$lib/components/dashboard/Navbar.svelte';
|
||||
import FolderRow from '$lib/components/dashboard/FolderRow.svelte';
|
||||
import DocCard from '$lib/components/dashboard/DocCard.svelte';
|
||||
import FileCard from '$lib/components/dashboard/FileCard.svelte';
|
||||
import ShareModal from '$lib/components/ShareModal.svelte';
|
||||
import DeleteModal from '$lib/components/dashboard/DeleteModal.svelte';
|
||||
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 CreateFolderModal from '$lib/components/dashboard/CreateFolderModal.svelte';
|
||||
|
||||
let documents = $state<any[]>([]);
|
||||
let folders = $state<any[]>([]);
|
||||
let files = $state<any[]>([]);
|
||||
let currentFolderId = $state<string | null>(null);
|
||||
let folderPath = $state<{id: string, name: string}[]>([]);
|
||||
let showCreateFolderModal = $state(false);
|
||||
let newFolderName = $state('');
|
||||
let loading = $state(true);
|
||||
let showCreateModal = $state(false);
|
||||
let newDocTitle = $state('');
|
||||
let showPlusDropdown = $state(false);
|
||||
let dragOverFolderId = $state<string | null>(null);
|
||||
let dragOverBreadcrumbIndex = $state<number | null>(null);
|
||||
let fileInput = $state<HTMLInputElement | null>(null);
|
||||
|
||||
let showDeleteModal = $state(false);
|
||||
let deleteTarget = $state<{id: string, type: 'document'|'folder'|'file', name: string} | null>(null);
|
||||
|
||||
function openDelete(id: string, type: 'document'|'folder'|'file', name: string) {
|
||||
deleteTarget = { id, type, name };
|
||||
showDeleteModal = true;
|
||||
activeMenu = null;
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!deleteTarget) return;
|
||||
const { id, type } = deleteTarget;
|
||||
|
||||
let endpoint = `/api/docs/${id}`;
|
||||
if (type === 'folder') endpoint = `/api/folders/${id}`;
|
||||
if (type === 'file') endpoint = `/api/files/${id}`;
|
||||
|
||||
const res = await fetch(endpoint, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
if (type === 'document') documents = documents.filter(d => d.id !== id);
|
||||
if (type === 'folder') folders = folders.filter(f => f.id !== id);
|
||||
if (type === 'file') files = files.filter(f => f.id !== id);
|
||||
}
|
||||
showDeleteModal = false;
|
||||
deleteTarget = null;
|
||||
}
|
||||
|
||||
async function handleFileUpload(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
if (!target.files || target.files.length === 0) return;
|
||||
|
||||
for (let i = 0; i < target.files.length; i++) {
|
||||
const file = target.files[i];
|
||||
|
||||
if (file.name.endsWith('.typ')) {
|
||||
|
||||
const content = await file.text();
|
||||
|
||||
const title = file.name.replace(/\.typ$/i, '');
|
||||
|
||||
await fetch('/api/docs', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: title,
|
||||
folder_id: currentFolderId || undefined,
|
||||
content: content
|
||||
})
|
||||
});
|
||||
} else {
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const query = currentFolderId ? `?folder_id=${currentFolderId}` : '';
|
||||
await fetch(`/api/files${query}`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
}
|
||||
}
|
||||
target.value = '';
|
||||
loadDocs();
|
||||
}
|
||||
|
||||
async function deleteFile(id: string, name: string) {
|
||||
openDelete(id, 'file', name);
|
||||
}
|
||||
|
||||
async function loadDocs() {
|
||||
loading = true;
|
||||
try {
|
||||
const folderQuery = currentFolderId ? `?parent_id=${currentFolderId}` : '';
|
||||
const docQuery = currentFolderId ? `?folder_id=${currentFolderId}` : '';
|
||||
const fileQuery = currentFolderId ? `?folder_id=${currentFolderId}` : '';
|
||||
|
||||
const [resFolders, resDocs, resFiles] = await Promise.all([
|
||||
fetch(`/api/folders${folderQuery}`),
|
||||
fetch(`/api/docs${docQuery}`),
|
||||
fetch(`/api/files${fileQuery}`)
|
||||
]);
|
||||
|
||||
if (resFolders.ok) {
|
||||
folders = await resFolders.json();
|
||||
}
|
||||
if (resDocs.ok) {
|
||||
documents = await resDocs.json();
|
||||
}
|
||||
if (resFiles.ok) {
|
||||
files = await resFiles.json();
|
||||
}
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateFolderModal() {
|
||||
showPlusDropdown = false;
|
||||
newFolderName = 'New Folder';
|
||||
showCreateFolderModal = true;
|
||||
}
|
||||
|
||||
async function createFolder(name: string) {
|
||||
if (!name.trim()) return;
|
||||
|
||||
const body: any = { name: name.trim() };
|
||||
if (currentFolderId) body.parent_id = currentFolderId;
|
||||
|
||||
const res = await fetch('/api/folders', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
showCreateFolderModal = false;
|
||||
loadDocs();
|
||||
}
|
||||
}
|
||||
|
||||
function navigateToFolder(folder: {id: string, name: string}) {
|
||||
folderPath = [...folderPath, folder];
|
||||
currentFolderId = folder.id;
|
||||
loadDocs();
|
||||
}
|
||||
|
||||
function navigateToBreadcrumb(index: number) {
|
||||
if (index === -1) {
|
||||
folderPath = [];
|
||||
currentFolderId = null;
|
||||
} else {
|
||||
folderPath = folderPath.slice(0, index + 1);
|
||||
currentFolderId = folderPath[folderPath.length - 1].id;
|
||||
}
|
||||
loadDocs();
|
||||
}
|
||||
|
||||
async function deleteFolder(id: string, name: string) {
|
||||
openDelete(id, 'folder', name);
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
showPlusDropdown = false;
|
||||
newDocTitle = 'Untitled Document';
|
||||
showCreateModal = true;
|
||||
}
|
||||
|
||||
async function createDoc(title: string) {
|
||||
if (!title.trim()) return;
|
||||
|
||||
const res = await fetch('/api/docs', {
|
||||
method: 'POST',
|
||||
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;
|
||||
goto(`/doc/${doc.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteDoc(id: string, name: string) {
|
||||
openDelete(id, 'document', name);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (!$userStore) {
|
||||
goto('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
loadDocs();
|
||||
});
|
||||
|
||||
let activeMenu = $state<string | null>(null);
|
||||
|
||||
|
||||
function handleWindowClick(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target.closest('.plus-dropdown-container')) {
|
||||
showPlusDropdown = false;
|
||||
}
|
||||
if (!target.closest('.action-menu-container')) {
|
||||
activeMenu = null;
|
||||
}
|
||||
}
|
||||
|
||||
let showInfoModal = $state(false);
|
||||
let selectedInfo = $state<any>(null);
|
||||
|
||||
let showRenameModal = $state(false);
|
||||
let renameId = $state<string | null>(null);
|
||||
let renameType = $state<'document'|'folder'|'file'>('document');
|
||||
let renameTitle = $state('');
|
||||
|
||||
function openInfo(item: any, type: string) {
|
||||
selectedInfo = { ...item, type };
|
||||
showInfoModal = true;
|
||||
activeMenu = null;
|
||||
}
|
||||
|
||||
function openRename(id: string, currentTitle: string, type: 'document'|'folder'|'file') {
|
||||
renameId = id;
|
||||
renameTitle = currentTitle;
|
||||
renameType = type;
|
||||
showRenameModal = true;
|
||||
activeMenu = null;
|
||||
}
|
||||
|
||||
async function handleRename(newTitle: string) {
|
||||
if (!newTitle.trim() || !renameId) return;
|
||||
|
||||
let endpoint = `/api/docs/${renameId}`;
|
||||
if (renameType === 'folder') endpoint = `/api/folders/${renameId}`;
|
||||
if (renameType === 'file') return;
|
||||
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: newTitle.trim(), name: newTitle.trim() })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
showRenameModal = false;
|
||||
loadDocs();
|
||||
}
|
||||
}
|
||||
|
||||
let showShareModal = $state(false);
|
||||
let shareTarget = $state<any>(null);
|
||||
|
||||
function shareItem(item: any) {
|
||||
|
||||
showShareModal = true;
|
||||
shareTarget = item;
|
||||
activeMenu = null;
|
||||
}
|
||||
|
||||
async function handleDrop(e: DragEvent, targetFolderId: string | null) {
|
||||
e.preventDefault();
|
||||
dragOverFolderId = null;
|
||||
dragOverBreadcrumbIndex = null;
|
||||
|
||||
const dataString = e.dataTransfer?.getData('text/plain');
|
||||
if (!dataString) return;
|
||||
|
||||
try {
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(dataString);
|
||||
} catch (err) {
|
||||
// For backward compatibility if it's just an id
|
||||
data = { type: 'document', id: dataString };
|
||||
}
|
||||
|
||||
const { type, id } = data;
|
||||
|
||||
let endpoint = '';
|
||||
if (type === 'document') {
|
||||
endpoint = `/api/docs/${id}`;
|
||||
} else if (type === 'file') {
|
||||
endpoint = `/api/files/${id}`;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ folder_id: targetFolderId || "" })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
loadDocs();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to move item', err);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Dashboard - TypstDrive</title>
|
||||
<meta name="description" content="Manage your Typst documents and folders." />
|
||||
</svelte:head>
|
||||
|
||||
<svelte:window onclick={handleWindowClick} />
|
||||
|
||||
<div class="min-h-screen bg-transparent flex flex-col">
|
||||
|
||||
<Navbar />
|
||||
|
||||
|
||||
<main class="max-w-7xl w-full mx-auto py-10 px-4 sm:px-6 lg:px-8 flex-grow flex flex-col overflow-y-auto">
|
||||
<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>
|
||||
|
||||
<div class="relative plus-dropdown-container">
|
||||
<button onclick={() => showPlusDropdown = !showPlusDropdown} class="flex items-center justify-center bg-blue-600 hover:bg-blue-700 text-white w-10 h-10 rounded-full shadow-md hover:shadow-lg transition-all duration-200 focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-zinc-950 focus:ring-blue-500 transform hover:-translate-y-0.5">
|
||||
<Icon icon="mdi:plus" class="text-2xl" />
|
||||
</button>
|
||||
|
||||
{#if showPlusDropdown}
|
||||
<div class="absolute right-0 mt-2 w-48 bg-white dark:bg-zinc-800 rounded-lg shadow-xl border border-gray-100 dark:border-zinc-700 py-1 z-20">
|
||||
<button onclick={openCreateModal} 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">
|
||||
<Icon icon="mdi:file-document-plus" class="text-lg text-blue-500" />
|
||||
New Document
|
||||
</button>
|
||||
<button onclick={openCreateFolderModal} 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">
|
||||
<Icon icon="mdi:folder-plus" class="text-lg text-yellow-500" />
|
||||
New Folder
|
||||
</button>
|
||||
<button onclick={() => { showPlusDropdown = false; fileInput?.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">
|
||||
<Icon icon="mdi:upload" class="text-lg text-green-500" />
|
||||
Upload File
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
<input type="file" bind:this={fileInput} accept="image/*,font/*,.typ,.ttf,.otf" multiple onchange={handleFileUpload} class="hidden" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 mb-6 bg-white/50 dark:bg-black/20 p-3 rounded-lg border border-gray-200 dark:border-white/10">
|
||||
<button
|
||||
onclick={() => navigateToBreadcrumb(-1)}
|
||||
ondragover={(e) => { e.preventDefault(); dragOverBreadcrumbIndex = -1; }}
|
||||
ondragleave={() => dragOverBreadcrumbIndex = null}
|
||||
ondrop={(e) => handleDrop(e, null)}
|
||||
class="hover:text-blue-600 dark:hover:text-blue-400 font-medium transition-colors px-2 py-1 rounded {dragOverBreadcrumbIndex === -1 ? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300' : ''}">
|
||||
<Icon icon="mdi:home" class="text-lg inline-block pb-0.5" /> Home
|
||||
</button>
|
||||
{#each folderPath as folder, index}
|
||||
<Icon icon="mdi:chevron-right" class="text-lg text-gray-400" />
|
||||
<button
|
||||
onclick={() => navigateToBreadcrumb(index)}
|
||||
ondragover={(e) => { e.preventDefault(); dragOverBreadcrumbIndex = index; }}
|
||||
ondragleave={() => dragOverBreadcrumbIndex = null}
|
||||
ondrop={(e) => handleDrop(e, folder.id)}
|
||||
class="hover:text-blue-600 dark:hover:text-blue-400 font-medium transition-colors px-2 py-1 rounded {dragOverBreadcrumbIndex === index ? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300' : ''}">
|
||||
{folder.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex-grow flex items-center justify-center">
|
||||
<div class="flex flex-col items-center gap-4 text-gray-500 dark:text-gray-400 animate-pulse">
|
||||
<Icon icon="mdi:loading" class="text-4xl animate-spin" />
|
||||
<p class="text-lg font-medium">Loading your workspace...</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if documents.length === 0 && folders.length === 0 && files.length === 0 && currentFolderId === null}
|
||||
<div class="flex-grow flex items-center justify-center">
|
||||
<div class="text-center p-12 bg-white/50 dark:bg-black/20 backdrop-blur-sm rounded-2xl shadow-sm border border-gray-200 dark:border-white/10 max-w-md w-full">
|
||||
<div class="inline-flex items-center justify-center w-20 h-20 rounded-full bg-blue-100/50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 mb-6">
|
||||
<Icon icon="mdi:file-document-outline" class="text-4xl" />
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-2">No documents yet</h3>
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-8">Get started by creating your first Typst document. It's fast, collaborative, and beautiful.</p>
|
||||
<button onclick={openCreateModal} class="w-full flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-lg shadow-sm text-base font-medium transition-colors">
|
||||
<Icon icon="mdi:plus" class="text-xl" />
|
||||
Create Document
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
|
||||
{#if folders.length > 0}
|
||||
<div class="mb-8 bg-white/50 dark:bg-black/20 backdrop-blur-sm rounded-xl shadow-sm border border-gray-200 dark:border-white/10 overflow-hidden">
|
||||
<div class="px-4 py-3 border-b border-gray-200 dark:border-white/10 bg-white/40 dark:bg-white/5 text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
Folders
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100 dark:divide-white/5">
|
||||
{#each folders as folder}
|
||||
<FolderRow
|
||||
{folder}
|
||||
{dragOverFolderId}
|
||||
{navigateToFolder}
|
||||
{handleDrop}
|
||||
{deleteFolder}
|
||||
setDragOverFolderId={(id) => dragOverFolderId = id}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showShareModal && shareTarget}
|
||||
|
||||
<ShareModal docId={shareTarget.id} onClose={() => showShareModal = false} />
|
||||
{/if}
|
||||
|
||||
{#if showDeleteModal && deleteTarget}
|
||||
<DeleteModal {deleteTarget} {confirmDelete} onClose={() => showDeleteModal = false} />
|
||||
{/if}
|
||||
|
||||
{#if showInfoModal && selectedInfo}
|
||||
<InfoModal {selectedInfo} onClose={() => showInfoModal = false} />
|
||||
{/if}
|
||||
|
||||
{#if showRenameModal}
|
||||
<RenameModal initialTitle={renameTitle} {handleRename} onClose={() => showRenameModal = false} />
|
||||
{/if}
|
||||
|
||||
{#if showCreateModal}
|
||||
<CreateDocModal {createDoc} onClose={() => showCreateModal = false} />
|
||||
{/if}
|
||||
|
||||
{#if showCreateFolderModal}
|
||||
<CreateFolderModal {createFolder} onClose={() => showCreateFolderModal = false} />
|
||||
{/if}
|
||||
|
||||
|
||||
{#if documents.length > 0 || files.length > 0}
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
|
||||
{#each documents as doc}
|
||||
<DocCard
|
||||
{doc}
|
||||
{activeMenu}
|
||||
setActiveMenu={(id) => activeMenu = id}
|
||||
{openInfo}
|
||||
{openRename}
|
||||
{shareItem}
|
||||
{deleteDoc}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
{#each files as file}
|
||||
<FileCard {file} {deleteFile} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if documents.length === 0 && folders.length === 0 && files.length === 0}
|
||||
<div class="flex-grow flex items-center justify-center">
|
||||
<p class="text-gray-500 dark:text-gray-400">This folder is empty.</p>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
@@ -0,0 +1,91 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import Editor from '$lib/components/Editor.svelte';
|
||||
import Preview from '$lib/components/Preview.svelte';
|
||||
import Toolbar from '$lib/components/Toolbar.svelte';
|
||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||
import { text, initYjs, cleanupYjs } from '$lib/ts/yjs-setup';
|
||||
import { compileTypst } from '$lib/ts/typst-api';
|
||||
import type { Diagnostic } from '$lib/ts/typst-api';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
let svgs = $state<string[]>([]);
|
||||
let errors = $state<Diagnostic[]>([]);
|
||||
let timeoutId: number | undefined;
|
||||
let initialized = $state(false);
|
||||
let documentTitle = $state('Untitled Document');
|
||||
|
||||
function triggerCompile() {
|
||||
if (!text) return;
|
||||
const content = text.toString();
|
||||
const docId = $page.params.id;
|
||||
compileTypst(content, docId)
|
||||
.then((res) => {
|
||||
if (res.svgs) {
|
||||
svgs = res.svgs;
|
||||
errors = [];
|
||||
} else if (res.errors) {
|
||||
errors = res.errors;
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error('Compilation fetch failed', e);
|
||||
errors = [{ message: 'Network or Server Error compiling document.', severity: 'error' }];
|
||||
});
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const docId = $page.params.id;
|
||||
if (!docId) return;
|
||||
|
||||
|
||||
fetch(`/api/docs/${docId}`)
|
||||
.then(res => res.json())
|
||||
.then(doc => {
|
||||
if (doc && doc.title) {
|
||||
documentTitle = doc.title;
|
||||
}
|
||||
})
|
||||
.catch(err => console.error("Failed to fetch document title:", err));
|
||||
|
||||
initYjs(docId);
|
||||
initialized = true;
|
||||
|
||||
text?.observe(() => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
timeoutId = window.setTimeout(triggerCompile, 500);
|
||||
});
|
||||
|
||||
triggerCompile();
|
||||
|
||||
return () => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
cleanupYjs();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{documentTitle} - TypstDrive</title>
|
||||
<meta name="description" content={`Editing ${documentTitle} in TypstDrive.`} />
|
||||
<meta property="og:title" content={`${documentTitle} - TypstDrive`} />
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex flex-col h-full relative">
|
||||
<Toolbar title={documentTitle} docId={$page.params.id} />
|
||||
|
||||
<main class="flex-1 flex flex-col md:flex-row overflow-hidden relative">
|
||||
|
||||
<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>
|
||||
|
||||
|
||||
<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">
|
||||
<Preview {svgs} />
|
||||
<ErrorBanner {errors} />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
@@ -0,0 +1,106 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { userStore } from '$lib/ts/auth';
|
||||
import Icon from '@iconify/svelte';
|
||||
|
||||
let username = $state('');
|
||||
let password = $state('');
|
||||
let errorMsg = $state('');
|
||||
|
||||
async function login(e: Event) {
|
||||
e.preventDefault();
|
||||
errorMsg = '';
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
errorMsg = text || 'Login failed';
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await res.json();
|
||||
userStore.set(user);
|
||||
goto('/dashboard');
|
||||
} catch (e: any) {
|
||||
errorMsg = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if ($userStore) goto('/dashboard');
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Login - TypstDrive</title>
|
||||
<meta name="description" content="Sign in to TypstDrive." />
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-screen flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 relative overflow-hidden bg-transparent">
|
||||
|
||||
<div class="absolute -top-40 -left-40 w-96 h-96 bg-blue-400/20 dark:bg-blue-600/10 rounded-full blur-3xl mix-blend-multiply"></div>
|
||||
<div class="absolute top-40 -right-40 w-96 h-96 bg-purple-400/20 dark:bg-purple-600/10 rounded-full blur-3xl mix-blend-multiply"></div>
|
||||
<div class="absolute -bottom-40 left-20 w-96 h-96 bg-indigo-400/20 dark:bg-indigo-600/10 rounded-full blur-3xl mix-blend-multiply"></div>
|
||||
|
||||
<div class="max-w-md w-full space-y-8 bg-white/80 dark:bg-black/40 backdrop-blur-xl p-10 rounded-2xl shadow-2xl border border-gray-200/50 dark:border-white/10 relative z-10">
|
||||
<div class="text-center">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-blue-100/50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-500 mb-6 mx-auto shadow-sm">
|
||||
<Icon icon="mdi:script-text" class="text-3xl" />
|
||||
</div>
|
||||
<h2 class="text-3xl font-extrabold tracking-tight text-gray-900 dark:text-white">
|
||||
Welcome back
|
||||
</h2>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400 font-medium">
|
||||
Sign in to your TypstDrive workspace
|
||||
</p>
|
||||
</div>
|
||||
<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>
|
||||
<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" />
|
||||
</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">
|
||||
</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">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Icon icon="mdi:lock" class="text-gray-400 dark:text-gray-500" />
|
||||
</div>
|
||||
<input id="password" name="password" type="password" required bind:value={password} 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="••••••••">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if errorMsg}
|
||||
<div class="flex items-center gap-2 text-red-600 bg-red-50 dark:bg-red-500/10 dark:text-red-400 p-3 rounded-lg text-sm border border-red-200 dark:border-red-500/20 shadow-sm animate-in slide-in-from-top-1 fade-in duration-200">
|
||||
<Icon icon="mdi:alert-circle" class="text-lg flex-shrink-0" />
|
||||
<span class="font-medium">{errorMsg}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="pt-2">
|
||||
<button type="submit" class="group w-full flex justify-center items-center gap-2 py-3 px-4 border border-transparent text-sm font-bold rounded-xl text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-zinc-900 focus:ring-blue-500 transition-all duration-200 shadow-md hover:shadow-lg transform hover:-translate-y-0.5">
|
||||
Sign In
|
||||
<Icon icon="mdi:arrow-right" class="text-lg group-hover:translate-x-1 transition-transform" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-sm text-center pt-4 border-t border-gray-200 dark:border-white/10">
|
||||
<span class="text-gray-500 dark:text-gray-400">New to TypstDrive? </span>
|
||||
<a href="/register" class="font-bold text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 transition-colors hover:underline">
|
||||
Create an account
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,115 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { userStore } from '$lib/ts/auth';
|
||||
import Icon from '@iconify/svelte';
|
||||
|
||||
let username = $state('');
|
||||
let password = $state('');
|
||||
let errorMsg = $state('');
|
||||
|
||||
async function register(e: Event) {
|
||||
e.preventDefault();
|
||||
errorMsg = '';
|
||||
try {
|
||||
const res = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
errorMsg = text || 'Registration failed';
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const loginRes = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
|
||||
if (loginRes.ok) {
|
||||
const user = await loginRes.json();
|
||||
userStore.set(user);
|
||||
goto('/dashboard');
|
||||
}
|
||||
} catch (e: any) {
|
||||
errorMsg = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if ($userStore) goto('/dashboard');
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Register - TypstDrive</title>
|
||||
<meta name="description" content="Create a new TypstDrive account." />
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-screen flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 relative overflow-hidden bg-transparent">
|
||||
|
||||
<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"></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"></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"></div>
|
||||
|
||||
<div class="max-w-md w-full space-y-8 bg-white/80 dark:bg-black/40 backdrop-blur-xl p-10 rounded-2xl shadow-2xl border border-gray-200/50 dark:border-white/10 relative z-10">
|
||||
<div class="text-center">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-blue-100/50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-500 mb-6 mx-auto shadow-sm">
|
||||
<Icon icon="mdi:account-plus" class="text-3xl" />
|
||||
</div>
|
||||
<h2 class="text-3xl font-extrabold tracking-tight text-gray-900 dark:text-white">
|
||||
Create an account
|
||||
</h2>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400 font-medium">
|
||||
Join TypstDrive to start collaborating
|
||||
</p>
|
||||
</div>
|
||||
<form class="mt-8 space-y-6" onsubmit={register}>
|
||||
<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>
|
||||
<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" />
|
||||
</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="Choose a username">
|
||||
</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">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Icon icon="mdi:lock" class="text-gray-400 dark:text-gray-500" />
|
||||
</div>
|
||||
<input id="password" name="password" type="password" required bind:value={password} 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="••••••••">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if errorMsg}
|
||||
<div class="flex items-center gap-2 text-red-600 bg-red-50 dark:bg-red-500/10 dark:text-red-400 p-3 rounded-lg text-sm border border-red-200 dark:border-red-500/20 shadow-sm animate-in slide-in-from-top-1 fade-in duration-200">
|
||||
<Icon icon="mdi:alert-circle" class="text-lg flex-shrink-0" />
|
||||
<span class="font-medium">{errorMsg}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="pt-2">
|
||||
<button type="submit" class="group w-full flex justify-center items-center gap-2 py-3 px-4 border border-transparent text-sm font-bold rounded-xl text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-zinc-900 focus:ring-blue-500 transition-all duration-200 shadow-md hover:shadow-lg transform hover:-translate-y-0.5">
|
||||
Register
|
||||
<Icon icon="mdi:arrow-right" class="text-lg group-hover:translate-x-1 transition-transform" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-sm text-center pt-4 border-t border-gray-200 dark:border-white/10">
|
||||
<span class="text-gray-500 dark:text-gray-400">Already have an account? </span>
|
||||
<a href="/login" class="font-bold text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 transition-colors hover:underline">
|
||||
Sign in
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,292 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { userStore } from '$lib/ts/auth';
|
||||
import { themeStore, darkModeStore } from '$lib/ts/store';
|
||||
import { themes } from '$lib/ts/themes';
|
||||
import Icon from '@iconify/svelte';
|
||||
import ThemePicker from '$lib/components/ThemePicker.svelte';
|
||||
|
||||
let username = $state('');
|
||||
let isSaving = $state(false);
|
||||
|
||||
let currentPassword = $state('');
|
||||
let newPassword = $state('');
|
||||
let confirmPassword = $state('');
|
||||
let isSavingPassword = $state(false);
|
||||
let passwordError = $state('');
|
||||
let passwordSuccess = $state(false);
|
||||
|
||||
let usernameError = $state('');
|
||||
let usernameSuccess = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
if (!$userStore) {
|
||||
goto('/login');
|
||||
} else {
|
||||
username = $userStore.username;
|
||||
}
|
||||
});
|
||||
|
||||
async function logout() {
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
userStore.set(null);
|
||||
goto('/login');
|
||||
}
|
||||
|
||||
async function saveProfile() {
|
||||
if (!username || username === $userStore?.username) return;
|
||||
|
||||
isSaving = true;
|
||||
usernameError = '';
|
||||
usernameSuccess = false;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/me', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username })
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
usernameError = text || "Failed to update profile";
|
||||
} else {
|
||||
const updatedUser = await res.json();
|
||||
userStore.set(updatedUser);
|
||||
usernameSuccess = true;
|
||||
}
|
||||
} catch (e) {
|
||||
usernameError = "Network error occurred.";
|
||||
}
|
||||
|
||||
isSaving = false;
|
||||
}
|
||||
|
||||
async function changePassword() {
|
||||
passwordError = '';
|
||||
passwordSuccess = false;
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
passwordError = "New passwords don't match.";
|
||||
return;
|
||||
}
|
||||
|
||||
isSavingPassword = true;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/change-password', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
passwordError = text || "Failed to change password";
|
||||
} else {
|
||||
passwordSuccess = true;
|
||||
currentPassword = '';
|
||||
newPassword = '';
|
||||
confirmPassword = '';
|
||||
}
|
||||
} catch (e) {
|
||||
passwordError = "Network error occurred.";
|
||||
}
|
||||
|
||||
isSavingPassword = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Settings - TypstDrive</title>
|
||||
<meta name="description" content="Manage your TypstDrive settings." />
|
||||
</svelte:head>
|
||||
|
||||
<div class="h-screen overflow-y-auto bg-transparent flex flex-col">
|
||||
<nav class="bg-white/80 dark:bg-black/20 backdrop-blur-md shadow-sm border-b border-gray-200 dark:border-white/10 px-6 py-4 flex justify-between items-center sticky top-0 z-10 transition-colors duration-200 flex-shrink-0">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
|
||||
<Icon icon="mdi:cog" class="text-blue-600 dark:text-blue-400 text-3xl" />
|
||||
Settings
|
||||
</h1>
|
||||
<div class="flex items-center gap-4">
|
||||
<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 bg-gray-100 hover:bg-gray-200 dark:bg-white/5 dark:hover:bg-white/10 px-4 py-2 rounded-lg flex items-center gap-2">
|
||||
<Icon icon="mdi:arrow-left" class="text-lg" />
|
||||
Back to Dashboard
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="max-w-3xl w-full mx-auto py-10 px-4 sm:px-6 lg:px-8 flex-grow pb-32 mb-16 space-y-8">
|
||||
|
||||
|
||||
<div class="bg-white dark:bg-black/20 rounded-xl shadow-sm border border-gray-200 dark:border-white/10 overflow-hidden">
|
||||
<div class="p-6 sm:p-8">
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-white mb-6 flex items-center gap-2">
|
||||
<Icon icon="mdi:account-outline" class="text-2xl text-blue-500 dark:text-blue-400" />
|
||||
Account Settings
|
||||
</h2>
|
||||
|
||||
<div class="bg-gray-50 dark:bg-black/20 rounded-lg p-5 border border-gray-200 dark:border-white/10 flex flex-col gap-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="h-16 w-16 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center text-blue-600 dark:text-blue-400 text-2xl font-bold border border-blue-500/20">
|
||||
{$userStore?.username?.[0]?.toUpperCase() || '?'}
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-lg font-bold text-gray-900 dark:text-white">{$userStore?.username}</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Manage your profile and preferences.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-px bg-gray-200 dark:bg-white/10"></div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<h3 class="text-md font-bold text-gray-900 dark:text-white">Profile</h3>
|
||||
|
||||
{#if usernameError}
|
||||
<div class="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 p-3 rounded-lg text-sm">
|
||||
{usernameError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if usernameSuccess}
|
||||
<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.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<label for="username-input" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Username</label>
|
||||
<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="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">
|
||||
{#if isSaving}
|
||||
<Icon icon="mdi:loading" class="animate-spin text-lg" />
|
||||
Saving...
|
||||
{:else}
|
||||
<Icon icon="mdi:content-save" class="text-lg" />
|
||||
Save Profile
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-px bg-gray-200 dark:bg-white/10"></div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<h3 class="text-md font-bold text-gray-900 dark:text-white">Change Password</h3>
|
||||
|
||||
{#if passwordError}
|
||||
<div class="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 p-3 rounded-lg text-sm">
|
||||
{passwordError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if passwordSuccess}
|
||||
<div class="bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400 p-3 rounded-lg text-sm">
|
||||
Password successfully changed.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<label for="current-password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Current Password</label>
|
||||
<input id="current-password" type="password" bind:value={currentPassword} 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>
|
||||
<label for="new-password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">New Password</label>
|
||||
<input id="new-password" type="password" bind:value={newPassword} 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>
|
||||
<label for="confirm-password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Confirm New Password</label>
|
||||
<input id="confirm-password" type="password" bind:value={confirmPassword} 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={changePassword} disabled={isSavingPassword || !currentPassword || !newPassword || !confirmPassword} 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 isSavingPassword}
|
||||
<Icon icon="mdi:loading" class="animate-spin text-lg" />
|
||||
Updating...
|
||||
{:else}
|
||||
<Icon icon="mdi:lock-reset" class="text-lg" />
|
||||
Update Password
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{#if isSaving}
|
||||
<Icon icon="mdi:loading" class="animate-spin text-lg" />
|
||||
Saving...
|
||||
{:else}
|
||||
<Icon icon="mdi:content-save" class="text-lg" />
|
||||
Save Changes
|
||||
{/if}
|
||||
</button>
|
||||
<button onclick={logout} class="bg-red-50 hover:bg-red-100 text-red-600 dark:bg-red-900/20 dark:hover:bg-red-900/40 dark:text-red-400 px-5 py-2.5 rounded-lg shadow-sm text-sm font-semibold transition-colors flex items-center gap-2">
|
||||
<Icon icon="mdi:logout" class="text-lg" />
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="bg-white dark:bg-black/20 rounded-xl shadow-sm border border-gray-200 dark:border-white/10 overflow-hidden">
|
||||
<div class="p-6 sm:p-8">
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-white mb-6 flex items-center gap-2">
|
||||
<Icon icon="mdi:palette-outline" class="text-2xl text-blue-500 dark:text-blue-400" />
|
||||
Theme Settings
|
||||
</h2>
|
||||
|
||||
<div class="bg-gray-50 dark:bg-black/20 rounded-lg p-5 border border-gray-200 dark:border-white/10">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">Customize the appearance of your editor and dashboard. These settings are saved to your browser.</p>
|
||||
<ThemePicker />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="bg-white dark:bg-black/20 rounded-xl shadow-sm border border-gray-200 dark:border-white/10 overflow-hidden">
|
||||
<div class="p-6 sm:p-8">
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-white mb-6 flex items-center gap-2">
|
||||
<Icon icon="mdi:harddisk" class="text-2xl text-blue-500 dark:text-blue-400" />
|
||||
Storage Tracking
|
||||
</h2>
|
||||
|
||||
<div class="bg-gray-50 dark:bg-black/20 rounded-lg p-5 border border-gray-200 dark:border-white/10">
|
||||
<div class="mb-2 flex justify-between items-end">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Total Space Used</span>
|
||||
<span class="text-sm font-bold text-gray-900 dark:text-white">45 MB</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 text-sm mt-6">
|
||||
<div class="bg-white dark:bg-black/40 p-3 rounded-lg border border-gray-200 dark:border-white/10 flex items-center gap-3">
|
||||
<div class="w-3 h-3 rounded-full bg-blue-500"></div>
|
||||
<div>
|
||||
<p class="text-gray-500 dark:text-gray-400">Documents</p>
|
||||
<p class="font-semibold text-gray-900 dark:text-white">12 MB</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-black/40 p-3 rounded-lg border border-gray-200 dark:border-white/10 flex items-center gap-3">
|
||||
<div class="w-3 h-3 rounded-full bg-purple-500"></div>
|
||||
<div>
|
||||
<p class="text-gray-500 dark:text-gray-400">Images & Assets</p>
|
||||
<p class="font-semibold text-gray-900 dark:text-white">33 MB</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
Reference in New Issue
Block a user