Update Version: 1.4.0

This commit is contained in:
2026-05-14 18:40:27 -04:00
parent 6178e9e55b
commit 7afd726188
21 changed files with 1051 additions and 278 deletions
+13
View File
@@ -2,6 +2,8 @@
import '../app.css';
import { fetchUser } from '$lib/ts/auth';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { themeStore, darkModeStore } from '$lib/ts/store';
import { themes } from '$lib/ts/themes';
@@ -9,6 +11,17 @@
let loaded = $state(false);
onMount(async () => {
const setupRes = await fetch('/api/setup');
if (setupRes.ok) {
const { needs_setup } = await setupRes.json();
if (needs_setup) {
if ($page.url.pathname !== '/setup') {
await goto('/setup');
}
loaded = true;
return;
}
}
await fetchUser();
loaded = true;
});
+12 -5
View File
@@ -9,7 +9,7 @@
import { compileTypst } from '$lib/ts/typst-api';
import type { Diagnostic } from '$lib/ts/typst-api';
import { page } from '$app/stores';
import { commentsSidebarOpen, commentReference, editorViewStore, editorErrors, documentStatsStore } from '$lib/ts/store';
import { commentsSidebarOpen, commentReference, editorViewStore, editorErrors, documentStatsStore, previewOpenStore } from '$lib/ts/store';
let svgs = $state<string[]>([]);
let errors = $state<Diagnostic[]>([]);
@@ -53,7 +53,7 @@
}
function triggerCompile() {
if (!text) return;
if (!text || !$previewOpenStore) return;
const content = text.toString();
const docId = $page.params.id;
compileTypst(content, docId)
@@ -76,6 +76,12 @@
});
}
$effect(() => {
if ($previewOpenStore && initialized) {
triggerCompile();
}
});
onMount(() => {
const docId = $page.params.id;
if (!docId) return;
@@ -122,20 +128,21 @@
<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">
<div class="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)] {$previewOpenStore ? 'w-full md:w-1/2 border-r border-gray-200 dark:border-white/10' : 'w-full'}">
{#if initialized}
<Editor />
{/if}
</div>
{/if}
{#if $previewOpenStore || isViewer}
<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>
{/if}
</main>
<DocFooter />
+12 -2
View File
@@ -8,6 +8,7 @@
let email = $state('');
let password = $state('');
let errorMsg = $state('');
let registrationEnabled = $state(true);
async function login(e: Event) {
e.preventDefault();
@@ -33,8 +34,15 @@
}
}
onMount(() => {
if ($userStore) goto('/dashboard');
onMount(async () => {
if ($userStore) { goto('/dashboard'); return; }
try {
const res = await fetch('/api/setup');
if (res.ok) {
const data = await res.json();
registrationEnabled = data.registration_enabled;
}
} catch {}
});
</script>
@@ -98,12 +106,14 @@
</button>
</div>
</form>
{#if registrationEnabled}
<div class="text-sm text-center mt-6 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>
{/if}
</div>
</div>
<Footer />
+62 -40
View File
@@ -9,6 +9,16 @@
let email = $state('');
let password = $state('');
let errorMsg = $state('');
let registrationDisabled = $state(false);
onMount(async () => {
if ($userStore) { goto('/dashboard'); return; }
const res = await fetch('/api/setup');
if (res.ok) {
const data = await res.json();
registrationDisabled = !data.registration_enabled;
}
});
async function register(e: Event) {
e.preventDefault();
@@ -72,51 +82,63 @@
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="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">
<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="••••••••">
{#if registrationDisabled}
<div class="flex flex-col items-center gap-4 py-4">
<div class="flex items-center gap-3 w-full bg-amber-50 dark:bg-amber-500/10 text-amber-700 dark:text-amber-400 p-4 rounded-xl border border-amber-200 dark:border-amber-500/20">
<Icon icon="mdi:lock-outline" class="text-2xl flex-shrink-0" />
<div>
<p class="font-semibold text-sm">Registration Disabled</p>
<p class="text-xs mt-0.5 text-amber-600 dark:text-amber-500">New account creation has been disabled by the administrator. Please contact your administrator to get an account.</p>
</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>
{:else}
<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="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">
<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}
<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>
</form>
{#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>
</form>
{/if}
<div class="text-sm text-center mt-6 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">
+430 -192
View File
@@ -2,15 +2,25 @@
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';
import Footer from '$lib/components/Footer.svelte';
type AdminUser = {
id: string;
username: string;
email: string;
is_admin: boolean;
created_at: string;
};
let activeSection = $state('account');
let username = $state('');
let email = $state('');
let isSaving = $state(false);
let profileError = $state('');
let profileSuccess = $state(false);
let currentPassword = $state('');
let newPassword = $state('');
@@ -19,10 +29,21 @@
let passwordError = $state('');
let passwordSuccess = $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);
let storageStats = $state<{documents_size_bytes: number, files_size_bytes: number, total_size_bytes: number} | null>(null);
let adminUsers = $state<AdminUser[]>([]);
let adminLoading = $state(false);
let adminError = $state('');
let deletingUserId = $state<string | null>(null);
let confirmDeleteId = $state<string | null>(null);
let showCreateForm = $state(false);
let createUsername = $state('');
let createEmail = $state('');
let createPassword = $state('');
let createIsAdmin = $state(false);
let createError = $state('');
let createLoading = $state(false);
function formatBytes(bytes: number) {
if (bytes === 0) return '0 Bytes';
@@ -32,24 +53,91 @@
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
}
onMount(async () => {
if (!$userStore) {
goto('/login');
} else {
username = $userStore.username;
email = $userStore.email || '';
try {
const res = await fetch('/api/auth/storage');
if (res.ok) {
storageStats = await res.json();
}
} catch (err) {
console.error("Failed to fetch storage stats", err);
if (!$userStore) { goto('/login'); return; }
username = $userStore.username;
email = $userStore.email || '';
try {
const res = await fetch('/api/auth/storage');
if (res.ok) storageStats = await res.json();
} catch {}
});
async function loadAdminUsers() {
adminLoading = true;
adminError = '';
try {
const res = await fetch('/api/admin/users');
if (res.ok) {
adminUsers = await res.json();
} else {
adminError = 'Failed to load users.';
}
} catch {
adminError = 'Network error.';
}
adminLoading = false;
}
$effect(() => {
if (activeSection === 'admin' && $userStore?.is_admin) {
loadAdminUsers();
}
});
async function toggleAdmin(user: AdminUser) {
const res = await fetch(`/api/admin/users/${user.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_admin: !user.is_admin })
});
if (res.ok) {
const updated: AdminUser = await res.json();
adminUsers = adminUsers.map(u => u.id === updated.id ? updated : u);
}
}
async function deleteUser(id: string) {
deletingUserId = id;
const res = await fetch(`/api/admin/users/${id}`, { method: 'DELETE' });
if (res.ok) {
adminUsers = adminUsers.filter(u => u.id !== id);
}
deletingUserId = null;
confirmDeleteId = null;
}
async function createUser(e: Event) {
e.preventDefault();
createError = '';
createLoading = true;
try {
const res = await fetch('/api/admin/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: createUsername, email: createEmail, password: createPassword, is_admin: createIsAdmin })
});
if (!res.ok) {
createError = await res.text() || 'Failed to create user.';
} else {
const created: AdminUser = await res.json();
adminUsers = [...adminUsers, created];
showCreateForm = false;
createUsername = '';
createEmail = '';
createPassword = '';
createIsAdmin = false;
}
} catch {
createError = 'Network error.';
}
createLoading = false;
}
async function logout() {
await fetch('/api/auth/logout', { method: 'POST' });
userStore.set(null);
@@ -57,75 +145,64 @@
}
async function saveProfile() {
if ((!username || username === $userStore?.username) && (!email || email === $userStore?.email)) return;
if (username === $userStore?.username && email === $userStore?.email) return;
isSaving = true;
profileError = '';
profileSuccess = false;
try {
const res = await fetch('/api/auth/me', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, email })
});
if (!res.ok) {
const text = await res.text();
profileError = text || "Failed to update profile";
profileError = await res.text() || 'Failed to update profile';
} else {
const updatedUser = await res.json();
userStore.set(updatedUser);
userStore.set(await res.json());
profileSuccess = true;
}
} catch (e) {
profileError = "Network error occurred.";
} catch {
profileError = 'Network error occurred.';
}
isSaving = false;
}
async function changePassword() {
passwordError = '';
passwordSuccess = false;
if (newPassword !== confirmPassword) {
passwordError = "New passwords don't match.";
return;
}
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
})
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword })
});
if (!res.ok) {
const text = await res.text();
passwordError = text || "Failed to change password";
passwordError = await res.text() || 'Failed to change password';
} else {
passwordSuccess = true;
currentPassword = '';
newPassword = '';
confirmPassword = '';
}
} catch (e) {
passwordError = "Network error occurred.";
} catch {
passwordError = 'Network error occurred.';
}
isSavingPassword = false;
}
const navItems = $derived([
{ id: 'account', label: 'Account', icon: 'mdi:account-outline' },
{ id: 'theme', label: 'Theme', icon: 'mdi:palette-outline' },
{ id: 'storage', label: 'Storage', icon: 'mdi:harddisk' },
...($userStore?.is_admin ? [{ id: 'admin', label: 'Admin', icon: 'mdi:shield-crown-outline' }] : [])
]);
</script>
<svelte:head>
<title>Settings - TypstDrive</title>
<meta name="description" content="Manage your TypstDrive settings." />
<title>Settings - TypstDrive</title>
<meta name="description" content="Manage your TypstDrive settings." />
</svelte:head>
<div class="min-h-screen flex flex-col">
@@ -134,64 +211,89 @@
<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>
<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>
</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 class="flex flex-1 max-w-6xl w-full mx-auto px-4 sm:px-6 lg:px-8 py-8 gap-8">
<!-- Sidebar -->
<aside class="w-56 flex-shrink-0">
<nav class="sticky top-24 space-y-1">
{#each navItems as item}
<button
onclick={() => activeSection = item.id}
class="w-full flex items-center gap-3 px-4 py-2.5 rounded-xl text-sm font-medium transition-all duration-150 {activeSection === item.id
? 'bg-blue-600 text-white shadow-sm'
: 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-white/5'}"
>
<Icon icon={item.icon} class="text-lg flex-shrink-0" />
{item.label}
{#if item.id === 'admin'}
<span class="ml-auto text-xs font-bold px-1.5 py-0.5 rounded-md {activeSection === 'admin' ? 'bg-white/20 text-white' : 'bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-400'}">Admin</span>
{/if}
</button>
{/each}
<div class="pt-4 mt-4 border-t border-gray-200 dark:border-white/10">
<button onclick={logout} class="w-full flex items-center gap-3 px-4 py-2.5 rounded-xl text-sm font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-500/10 transition-all duration-150">
<Icon icon="mdi:logout" class="text-lg flex-shrink-0" />
Sign Out
</button>
</div>
</nav>
</aside>
<!-- Main content -->
<main class="flex-1 min-w-0 space-y-6 pb-16">
<!-- Account Section -->
{#if activeSection === 'account'}
<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="flex items-center gap-4 mb-6">
<div class="h-14 w-14 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 flex-shrink-0">
{$userStore?.username?.[0]?.toUpperCase() || '?'}
</div>
<div>
<p class="text-base font-bold text-gray-900 dark:text-white">{$userStore?.username}</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{$userStore?.email}</p>
{#if $userStore?.is_admin}
<span class="inline-flex items-center gap-1 text-xs font-semibold px-2 py-0.5 rounded-full bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-400 mt-1">
<Icon icon="mdi:shield-crown-outline" class="text-sm" />
Administrator
</span>
{/if}
</div>
</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="h-px bg-gray-200 dark:bg-white/10 mb-6"></div>
<h3 class="text-sm font-bold text-gray-900 dark:text-white mb-4">Profile</h3>
<div class="grid grid-cols-1 gap-4">
<h3 class="text-md font-bold text-gray-900 dark:text-white">Profile</h3>
{#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">
{profileError}
</div>
<div class="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 p-3 rounded-lg text-sm mb-4">{profileError}</div>
{/if}
{#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">
Profile successfully updated.
</div>
<div class="bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400 p-3 rounded-lg text-sm mb-4">Profile updated successfully.</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="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 && 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">
<div class="space-y-4">
<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>
<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>
<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 rounded-lg text-sm font-semibold transition-colors disabled:opacity-50 flex items-center gap-2 shadow-sm">
{#if isSaving}
<Icon icon="mdi:loading" class="animate-spin text-lg" />
Saving...
@@ -201,39 +303,32 @@
{/if}
</button>
</div>
</div>
<div class="h-px bg-gray-200 dark:bg-white/10"></div>
<div class="h-px bg-gray-200 dark:bg-white/10 my-6"></div>
<h3 class="text-sm font-bold text-gray-900 dark:text-white mb-4">Change Password</h3>
<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>
<div class="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 p-3 rounded-lg text-sm mb-4">{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>
<div class="bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400 p-3 rounded-lg text-sm mb-4">Password changed successfully.</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">
<div class="space-y-4">
<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>
<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 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...
@@ -244,83 +339,226 @@
</button>
</div>
</div>
</div>
{/if}
<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 && 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...
{: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>
<!-- Theme Section -->
{#if activeSection === 'theme'}
<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-2 flex items-center gap-2">
<Icon icon="mdi:palette-outline" class="text-2xl text-blue-500 dark:text-blue-400" />
Theme Settings
</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">Customize the appearance of your editor and dashboard. These settings are saved to your browser.</p>
<ThemePicker />
</div>
</div>
</div>
</div>
{/if}
<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">
{storageStats ? formatBytes(storageStats.total_size_bytes) : 'Loading...'}
</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">
{storageStats ? formatBytes(storageStats.documents_size_bytes) : '...'}
</p>
</div>
<!-- Storage Section -->
{#if activeSection === 'storage'}
<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
</h2>
<div class="mb-4 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">
{storageStats ? formatBytes(storageStats.total_size_bytes) : 'Loading...'}
</span>
</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">
{storageStats ? formatBytes(storageStats.files_size_bytes) : '...'}
</p>
<div class="grid grid-cols-2 gap-4 text-sm">
<div class="bg-gray-50 dark:bg-black/30 p-4 rounded-xl border border-gray-200 dark:border-white/10 flex items-center gap-3">
<div class="w-3 h-3 rounded-full bg-blue-500 flex-shrink-0"></div>
<div>
<p class="text-gray-500 dark:text-gray-400 text-xs">Documents</p>
<p class="font-semibold text-gray-900 dark:text-white">
{storageStats ? formatBytes(storageStats.documents_size_bytes) : '...'}
</p>
</div>
</div>
<div class="bg-gray-50 dark:bg-black/30 p-4 rounded-xl border border-gray-200 dark:border-white/10 flex items-center gap-3">
<div class="w-3 h-3 rounded-full bg-purple-500 flex-shrink-0"></div>
<div>
<p class="text-gray-500 dark:text-gray-400 text-xs">Images & Assets</p>
<p class="font-semibold text-gray-900 dark:text-white">
{storageStats ? formatBytes(storageStats.files_size_bytes) : '...'}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{/if}
<!-- Admin Section -->
{#if activeSection === 'admin' && $userStore?.is_admin}
<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">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
<Icon icon="mdi:shield-crown-outline" class="text-2xl text-amber-500 dark:text-amber-400" />
User Management
</h2>
<div class="flex items-center gap-3">
<span class="text-sm text-gray-500 dark:text-gray-400">{adminUsers.length} user{adminUsers.length !== 1 ? 's' : ''}</span>
<button
onclick={() => { showCreateForm = !showCreateForm; createError = ''; }}
class="flex items-center gap-1.5 px-3 py-1.5 text-sm font-semibold rounded-lg transition-colors {showCreateForm ? 'bg-gray-200 dark:bg-white/10 text-gray-700 dark:text-gray-300' : 'bg-blue-600 hover:bg-blue-700 text-white shadow-sm'}"
>
<Icon icon={showCreateForm ? 'mdi:close' : 'mdi:account-plus-outline'} class="text-base" />
{showCreateForm ? 'Cancel' : 'New User'}
</button>
</div>
</div>
{#if showCreateForm}
<form onsubmit={createUser} class="mb-6 p-4 rounded-xl border border-blue-200 dark:border-blue-800/50 bg-blue-50/50 dark:bg-blue-900/10 space-y-3">
<h3 class="text-sm font-bold text-gray-900 dark:text-white mb-3 flex items-center gap-2">
<Icon icon="mdi:account-plus-outline" class="text-blue-500" />
Create New User
</h3>
{#if createError}
<div class="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 px-3 py-2 rounded-lg text-sm">{createError}</div>
{/if}
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Username</label>
<input
type="text"
required
bind:value={createUsername}
placeholder="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-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
/>
</div>
<div>
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Email</label>
<input
type="email"
required
bind:value={createEmail}
placeholder="[email protected]"
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-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
/>
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Temporary Password</label>
<input
type="text"
required
bind:value={createPassword}
placeholder="Set a password the user can change later"
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-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors font-mono"
/>
</div>
<div class="flex items-center justify-between pt-1">
<label class="flex items-center gap-2 cursor-pointer select-none">
<input type="checkbox" bind:checked={createIsAdmin} class="w-4 h-4 rounded accent-amber-500" />
<span class="text-sm text-gray-700 dark:text-gray-300 flex items-center gap-1">
<Icon icon="mdi:shield-crown-outline" class="text-amber-500 text-base" />
Grant admin privileges
</span>
</label>
<button
type="submit"
disabled={createLoading}
class="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm font-semibold rounded-lg transition-colors shadow-sm"
>
{#if createLoading}
<Icon icon="mdi:loading" class="animate-spin text-base" />
Creating...
{:else}
<Icon icon="mdi:account-plus-outline" class="text-base" />
Create User
{/if}
</button>
</div>
</form>
{/if}
{#if adminError}
<div class="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 p-3 rounded-lg text-sm mb-4">{adminError}</div>
{/if}
{#if adminLoading}
<div class="flex items-center justify-center py-12 text-gray-400 dark:text-gray-500">
<Icon icon="mdi:loading" class="animate-spin text-2xl mr-2" />
Loading users...
</div>
{:else}
<div class="space-y-2">
{#each adminUsers as user (user.id)}
<div class="flex items-center gap-4 px-4 py-3 rounded-xl border border-gray-200 dark:border-white/10 bg-gray-50 dark:bg-black/20 group">
<div class="h-9 w-9 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center text-blue-600 dark:text-blue-400 font-bold text-sm flex-shrink-0">
{user.username[0].toUpperCase()}
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<p class="text-sm font-semibold text-gray-900 dark:text-white truncate">{user.username}</p>
{#if user.is_admin}
<span class="text-xs font-bold px-1.5 py-0.5 rounded-md bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-400 flex-shrink-0">Admin</span>
{/if}
{#if user.id === $userStore?.id}
<span class="text-xs px-1.5 py-0.5 rounded-md bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400 flex-shrink-0">You</span>
{/if}
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 truncate">{user.email}</p>
</div>
<p class="text-xs text-gray-400 dark:text-gray-500 flex-shrink-0 hidden sm:block">{formatDate(user.created_at)}</p>
<div class="flex items-center gap-2 flex-shrink-0">
{#if user.id !== $userStore?.id}
<button
onclick={() => toggleAdmin(user)}
title={user.is_admin ? 'Revoke admin' : 'Grant admin'}
class="p-1.5 rounded-lg transition-colors {user.is_admin ? 'text-amber-600 dark:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-500/10' : 'text-gray-400 hover:text-amber-600 dark:hover:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-500/10'}"
>
<Icon icon={user.is_admin ? 'mdi:shield-crown' : 'mdi:shield-crown-outline'} class="text-lg" />
</button>
{#if confirmDeleteId === user.id}
<div class="flex items-center gap-1">
<span class="text-xs text-gray-500 dark:text-gray-400">Delete?</span>
<button
onclick={() => deleteUser(user.id)}
disabled={deletingUserId === user.id}
class="text-xs px-2 py-1 rounded-md bg-red-600 hover:bg-red-700 text-white font-semibold transition-colors disabled:opacity-50"
>
{deletingUserId === user.id ? '...' : 'Yes'}
</button>
<button
onclick={() => confirmDeleteId = null}
class="text-xs px-2 py-1 rounded-md bg-gray-200 hover:bg-gray-300 dark:bg-white/10 dark:hover:bg-white/20 text-gray-700 dark:text-gray-300 font-semibold transition-colors"
>
No
</button>
</div>
{:else}
<button
onclick={() => confirmDeleteId = user.id}
title="Delete user"
class="p-1.5 rounded-lg text-gray-400 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-500/10 transition-colors"
>
<Icon icon="mdi:delete-outline" class="text-lg" />
</button>
{/if}
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
{/if}
</main>
</div>
</main>
<Footer />
</div>
</div>
+137
View File
@@ -0,0 +1,137 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { userStore } from '$lib/ts/auth';
import Icon from '@iconify/svelte';
let username = $state('');
let email = $state('');
let password = $state('');
let confirmPassword = $state('');
let errorMsg = $state('');
let loading = $state(false);
async function handleSetup(e: Event) {
e.preventDefault();
errorMsg = '';
if (password !== confirmPassword) {
errorMsg = "Passwords don't match.";
return;
}
if (password.length < 8) {
errorMsg = "Password must be at least 8 characters.";
return;
}
loading = true;
try {
const res = await fetch('/api/setup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, email, password })
});
if (!res.ok) {
errorMsg = await res.text() || 'Setup failed.';
return;
}
const user = await res.json();
userStore.set(user);
goto('/dashboard');
} catch (e: any) {
errorMsg = e.message;
} finally {
loading = false;
}
}
</script>
<svelte:head>
<title>Setup - TypstDrive</title>
</svelte:head>
<div class="min-h-screen flex items-center justify-center px-4 py-16">
<div class="w-full max-w-md">
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-blue-600 text-white mb-4 shadow-lg">
<Icon icon="mdi:shield-crown-outline" class="text-3xl" />
</div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Welcome to TypstDrive</h1>
<p class="mt-2 text-gray-500 dark:text-gray-400">Create your admin account to get started.</p>
</div>
<div class="bg-white dark:bg-black/20 rounded-2xl shadow-xl border border-gray-200 dark:border-white/10 p-8">
<div class="flex items-center gap-2 bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 text-sm px-4 py-3 rounded-lg mb-6 border border-blue-200 dark:border-blue-800/50">
<Icon icon="mdi:information-outline" class="text-lg flex-shrink-0" />
<span>This is a one-time setup. The account you create here will have full admin privileges.</span>
</div>
{#if errorMsg}
<div class="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 px-4 py-3 rounded-lg text-sm mb-5 border border-red-200 dark:border-red-800/50">
{errorMsg}
</div>
{/if}
<form onsubmit={handleSetup} class="space-y-4">
<div>
<label for="username" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Username</label>
<input
id="username"
type="text"
required
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.5 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
placeholder="admin"
/>
</div>
<div>
<label for="email" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Email</label>
<input
id="email"
type="email"
required
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.5 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
placeholder="[email protected]"
/>
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Password</label>
<input
id="password"
type="password"
required
bind:value={password}
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.5 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
placeholder="Min. 8 characters"
/>
</div>
<div>
<label for="confirm-password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Confirm Password</label>
<input
id="confirm-password"
type="password"
required
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.5 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
placeholder="Repeat password"
/>
</div>
<button
type="submit"
disabled={loading}
class="w-full flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-60 text-white font-semibold py-2.5 rounded-lg transition-colors mt-2 shadow-sm"
>
{#if loading}
<Icon icon="mdi:loading" class="animate-spin text-lg" />
Creating account...
{:else}
<Icon icon="mdi:shield-check-outline" class="text-lg" />
Create Admin Account
{/if}
</button>
</form>
</div>
</div>
</div>