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
+6 -4
View File
@@ -33,10 +33,12 @@ COPY --from=backend-builder /app/server/target/release/server /app/server
# Postgres: postgres://user:pass@host:5432/typstdrive
# DB_TYPE Database backend: "sqlite" or "postgres"
# Auto-detected from DATABASE_URL if not set.
# COOKIE_SECRET 64+ byte secret for signing session cookies.
# If unset, a random key is generated on each start
# and all sessions are invalidated on restart.
# RUST_LOG Log filter (default: server=debug,tower_http=debug)
# COOKIE_SECRET 64+ byte secret for signing session cookies.
# If unset, a random key is generated on each start
# and all sessions are invalidated on restart.
# ALLOW_REGISTRATION Set to "false" to disable public registration.
# Admins can still create accounts via the admin panel.
# RUST_LOG Log filter (default: server=debug,tower_http=debug)
ENV PORT=3000
ENV STATIC_DIR=/app/build
ENV DATABASE_URL=sqlite:///data/typstdrive.db?mode=rwc
+17 -3
View File
@@ -1,6 +1,6 @@
# TypstDrive
[![Version](https://img.shields.io/badge/version-1.3.0-blue.svg)](https://github.com/your-username/typstdrive)
[![Version](https://img.shields.io/badge/version-1.4.0-blue.svg)](https://github.com/your-username/typstdrive)
[![Typst Version](https://img.shields.io/badge/Typst-0.14.2-239dad?logo=typst&logoColor=white)](https://typst.app/)
[![Rust](https://img.shields.io/badge/Rust-1.82+-orange?logo=rust&logoColor=white)](https://www.rust-lang.org/)
[![SvelteKit](https://img.shields.io/badge/SvelteKit-5-ff3e00?logo=svelte)](https://kit.svelte.dev/)
@@ -15,10 +15,11 @@ TypstDrive is a collaborative web editor for Typst. With built-in dark mode, mul
## Features
- **Real-Time Collaboration**: Powered by Yjs and CodeMirror 6, see changes and cursors from other users instantly.
- **Instant Preview**: Compile Typst to SVG on the fly with sub-second latency, featuring interactive document zoom controls.
- **Instant Preview**: Compile Typst to SVG on the fly with sub-second latency, featuring interactive document zoom controls and a collapsible preview pane.
- **Customizable Themes**: Choose from multiple editor themes (Catppuccin, Arch Linux, Cerberus) and toggle global dark mode.
- **Export Options**: Export your compiled documents directly to PDF, PNG, SVG, HTML, Markdown, Word, or LaTeX formats using internal conversion and Pandoc integrations.
- **User Authentication & Document Access**: Secure accounts, workspaces, and sharing features via email-based collaborator invitations (Editor or Viewer roles) for all your documents.
- **Admin System**: First-run setup wizard creates an admin account. Admins can manage all users, create new accounts with temporary passwords, toggle admin privileges, and delete accounts from the Settings panel.
- **Presentation Mode**: Turn your documents into instant slideshows with built-in slide controls and a live drawing/annotation tool overlay.
- **Asset Management**: Upload and seamlessly use custom fonts and images directly within your documents.
@@ -85,6 +86,8 @@ TypstDrive is completely self-hostable. A Docker image packages both the Rust ba
3. Open your browser and navigate to `http://localhost:3000`.
On first launch with no users in the database, you will be redirected to the **Setup** page to create the initial admin account.
### Data Storage
By default, TypstDrive uses **SQLite** — no separate database container required. All data is stored in a single file persisted via the `appdata` Docker volume.
@@ -102,6 +105,7 @@ All variables can be set in the `environment:` section of `docker-compose.yml` o
| `PORT` | `3000` | Port the HTTP server listens on. |
| `STATIC_DIR` | `/app/build` | Path to compiled frontend assets. |
| `COOKIE_SECRET` | *(random)* | 64+ byte secret used to sign session cookies. **If not set, a random key is generated on startup and all sessions are invalidated on every container restart.** Generate a stable value with: `openssl rand -hex 64` |
| `ALLOW_REGISTRATION` | `true` | Set to `false` to disable public self-registration. When disabled, the register link is hidden on the login page and the registration endpoint returns 403. Admins can still create accounts from the Settings panel. |
| `RUST_LOG` | `server=debug,tower_http=debug` | Log filter. Set to `info` for quieter production logs. |
#### Example: production-ready `docker-compose.yml` snippet
@@ -112,6 +116,7 @@ environment:
- DB_TYPE=sqlite
- PORT=3000
- COOKIE_SECRET=your-64-plus-byte-secret-here
- ALLOW_REGISTRATION=false
- RUST_LOG=info
```
@@ -120,6 +125,15 @@ Generate a `COOKIE_SECRET`:
openssl rand -hex 64
```
### Admin Panel
The first account created via the setup wizard is automatically an administrator. Admins have access to an **Admin** section in Settings (`/settings`) which provides:
- A list of all users with creation dates
- **Create User** — set a username, email, and temporary password; optionally grant admin privileges immediately
- **Toggle Admin** — promote or demote any other user
- **Delete User** — permanently remove any account other than your own
## Contributing & Local Development
Clone the official Typst compiler into the `typst/` folder before building the backend:
@@ -156,4 +170,4 @@ The frontend dev server proxies API calls to `localhost:3000` automatically.
<p align="center">
<img src="preview/dashboard.png" alt="Dashboard view" width="49%">
<img src="preview/register.png" alt="Authentication view" width="49%">
</p>
</p>
+2
View File
@@ -21,6 +21,8 @@ services:
# Without this, sessions are cleared on every container restart.
# Generate one with: openssl rand -hex 64
# - COOKIE_SECRET=your-64-plus-byte-secret-here
# Set to "false" to disable public registration (admin panel can still create users)
- ALLOW_REGISTRATION=false
# Log verbosity (default: server=debug,tower_http=debug)
# - RUST_LOG=info
volumes:
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "typstdrive",
"private": true,
"version": "1.3.0",
"version": "1.4.0",
"type": "module",
"scripts": {
"dev": "vite dev --host",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "server"
version = "1.3.0"
version = "1.4.0"
edition = "2021"
[dependencies]
+140
View File
@@ -0,0 +1,140 @@
use axum::{
extract::{Path, State},
http::StatusCode,
Json,
};
use axum_extra::extract::cookie::SignedCookieJar;
use uuid::Uuid;
use argon2::{
password_hash::{rand_core::OsRng, PasswordHasher, SaltString},
Argon2,
};
use crate::{
models::{AdminCreateUserRequest, AdminUserView, UpdateUserRequest},
AppState,
};
async fn require_admin(state: &AppState, jar: &SignedCookieJar) -> Result<String, (StatusCode, String)> {
let user_id = jar.get("session_user_id").map(|c| c.value().to_string())
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?;
let is_admin: Option<(i64,)> = sqlx::query_as("SELECT is_admin FROM users WHERE id = ?")
.bind(&user_id)
.fetch_optional(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
match is_admin {
Some((v,)) if v != 0 => Ok(user_id),
_ => Err((StatusCode::FORBIDDEN, "Admin access required".to_string())),
}
}
pub async fn create_user(
State(state): State<AppState>,
jar: SignedCookieJar,
Json(payload): Json<AdminCreateUserRequest>,
) -> Result<(StatusCode, Json<AdminUserView>), (StatusCode, String)> {
require_admin(&state, &jar).await?;
if payload.username.is_empty() || payload.email.is_empty() || payload.password.is_empty() {
return Err((StatusCode::BAD_REQUEST, "Username, email, and password are required".to_string()));
}
let salt = SaltString::generate(&mut OsRng);
let password_hash = Argon2::default()
.hash_password(payload.password.as_bytes(), &salt)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.to_string();
let user_id = Uuid::new_v4().to_string();
let is_admin = payload.is_admin.unwrap_or(false);
let result = sqlx::query_as::<_, AdminUserView>(
"INSERT INTO users (id, username, email, password_hash, is_admin) VALUES (?, ?, ?, ?, ?) RETURNING id, username, email, is_admin, created_at"
)
.bind(&user_id)
.bind(&payload.username)
.bind(&payload.email)
.bind(&password_hash)
.bind(if is_admin { 1i64 } else { 0i64 })
.fetch_one(&state.db)
.await;
match result {
Ok(user) => Ok((StatusCode::CREATED, Json(user))),
Err(sqlx::Error::Database(err)) if err.is_unique_violation() => {
Err((StatusCode::CONFLICT, "Username or email already exists".to_string()))
}
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e.to_string())),
}
}
pub async fn list_users(
State(state): State<AppState>,
jar: SignedCookieJar,
) -> Result<Json<Vec<AdminUserView>>, (StatusCode, String)> {
require_admin(&state, &jar).await?;
let users = sqlx::query_as::<_, AdminUserView>(
"SELECT id, username, email, is_admin, created_at FROM users ORDER BY created_at ASC"
)
.fetch_all(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(users))
}
pub async fn update_user(
State(state): State<AppState>,
jar: SignedCookieJar,
Path(user_id): Path<String>,
Json(payload): Json<UpdateUserRequest>,
) -> Result<Json<AdminUserView>, (StatusCode, String)> {
let requester_id = require_admin(&state, &jar).await?;
if let Some(is_admin) = payload.is_admin {
if !is_admin && requester_id == user_id {
return Err((StatusCode::BAD_REQUEST, "Cannot remove your own admin privileges".to_string()));
}
sqlx::query("UPDATE users SET is_admin = ? WHERE id = ?")
.bind(if is_admin { 1i64 } else { 0i64 })
.bind(&user_id)
.execute(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
}
let user = sqlx::query_as::<_, AdminUserView>(
"SELECT id, username, email, is_admin, created_at FROM users WHERE id = ?"
)
.bind(&user_id)
.fetch_optional(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "User not found".to_string()))?;
Ok(Json(user))
}
pub async fn delete_user(
State(state): State<AppState>,
jar: SignedCookieJar,
Path(user_id): Path<String>,
) -> Result<StatusCode, (StatusCode, String)> {
let requester_id = require_admin(&state, &jar).await?;
if requester_id == user_id {
return Err((StatusCode::BAD_REQUEST, "Cannot delete your own account via admin panel".to_string()));
}
sqlx::query("DELETE FROM users WHERE id = ?")
.bind(&user_id)
.execute(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(StatusCode::NO_CONTENT)
}
+11 -5
View File
@@ -11,6 +11,8 @@ use crate::{
AppState,
};
const USER_FIELDS: &str = "id, username, email, password_hash, is_admin";
use argon2::{
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2,
@@ -20,6 +22,10 @@ pub async fn register(
State(state): State<AppState>,
Json(payload): Json<RegisterRequest>,
) -> Result<Json<User>, (StatusCode, String)> {
if !state.registration_enabled {
return Err((StatusCode::FORBIDDEN, "Registration is disabled on this instance".to_string()));
}
if payload.username.is_empty() || payload.password.is_empty() || payload.email.is_empty() {
return Err((StatusCode::BAD_REQUEST, "Username, email, and password cannot be empty".to_string()));
}
@@ -34,7 +40,7 @@ pub async fn register(
let user_id = Uuid::new_v4().to_string();
let result = sqlx::query_as::<_, User>(
"INSERT INTO users (id, username, email, password_hash) VALUES (?, ?, ?, ?) RETURNING id, username, email, password_hash"
"INSERT INTO users (id, username, email, password_hash) VALUES (?, ?, ?, ?) RETURNING id, username, email, password_hash, is_admin"
)
.bind(&user_id)
.bind(&payload.username)
@@ -57,7 +63,7 @@ pub async fn login(
jar: SignedCookieJar,
Json(payload): Json<LoginRequest>,
) -> Result<(SignedCookieJar, Json<User>), (StatusCode, String)> {
let user = sqlx::query_as::<_, User>("SELECT id, username, email, password_hash FROM users WHERE email = ?")
let user = sqlx::query_as::<_, User>("SELECT id, username, email, password_hash, is_admin FROM users WHERE email = ?")
.bind(&payload.email)
.fetch_optional(&state.db)
.await
@@ -106,7 +112,7 @@ pub async fn update_profile(
match result {
Ok(_) => {
let user = sqlx::query_as::<_, User>("SELECT id, username, email, password_hash FROM users WHERE id = ?")
let user = sqlx::query_as::<_, User>("SELECT id, username, email, password_hash, is_admin FROM users WHERE id = ?")
.bind(&user_id)
.fetch_optional(&state.db)
.await
@@ -135,7 +141,7 @@ pub async fn me(
None => return Err((StatusCode::UNAUTHORIZED, "Not logged in".to_string())),
};
let user = sqlx::query_as::<_, User>("SELECT id, username, email, password_hash FROM users WHERE id = ?")
let user = sqlx::query_as::<_, User>("SELECT id, username, email, password_hash, is_admin FROM users WHERE id = ?")
.bind(&user_id)
.fetch_optional(&state.db)
.await
@@ -159,7 +165,7 @@ pub async fn change_password(
return Err((StatusCode::BAD_REQUEST, "Passwords cannot be empty".to_string()));
}
let user = sqlx::query_as::<_, User>("SELECT id, username, email, password_hash FROM users WHERE id = ?")
let user = sqlx::query_as::<_, User>("SELECT id, username, email, password_hash, is_admin FROM users WHERE id = ?")
.bind(&user_id)
.fetch_optional(&state.db)
.await
+12 -9
View File
@@ -6,7 +6,9 @@ pub async fn init_schema(pool: &AnyPool) {
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
email TEXT UNIQUE,
password_hash TEXT NOT NULL
password_hash TEXT NOT NULL,
is_admin BOOLEAN NOT NULL DEFAULT FALSE,
created_at TEXT DEFAULT to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS')
)",
"CREATE TABLE IF NOT EXISTS folders (
id TEXT PRIMARY KEY,
@@ -83,12 +85,13 @@ pub async fn init_schema(pool: &AnyPool) {
.expect("Failed to execute Postgres schema");
}
// Idempotent migration for existing databases with TIMESTAMP columns
sqlx::query("ALTER TABLE documents ADD COLUMN IF NOT EXISTS public_role TEXT")
.execute(pool)
.await
.unwrap_or_else(|e| {
eprintln!("Warning: public_role migration: {}", e);
Default::default()
});
// Idempotent migrations for existing databases
let migrations = [
"ALTER TABLE documents ADD COLUMN IF NOT EXISTS public_role TEXT",
"ALTER TABLE users ADD COLUMN IF NOT EXISTS is_admin BOOLEAN NOT NULL DEFAULT FALSE",
"ALTER TABLE users ADD COLUMN IF NOT EXISTS created_at TEXT DEFAULT to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS')",
];
for stmt in &migrations {
sqlx::query(stmt).execute(pool).await.unwrap_or_else(|_| Default::default());
}
}
+12 -1
View File
@@ -11,7 +11,9 @@ pub async fn init_schema(pool: &AnyPool) {
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
email TEXT UNIQUE,
password_hash TEXT NOT NULL
password_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now'))
)",
"CREATE TABLE IF NOT EXISTS folders (
id TEXT PRIMARY KEY,
@@ -87,4 +89,13 @@ pub async fn init_schema(pool: &AnyPool) {
.await
.expect("Failed to execute SQLite schema");
}
// Idempotent migrations for existing databases
let migrations = [
"ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE users ADD COLUMN created_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now'))",
];
for stmt in &migrations {
let _ = sqlx::query(stmt).execute(pool).await;
}
}
+11
View File
@@ -12,6 +12,7 @@ use tower_http::services::{ServeDir, ServeFile};
use tower_http::trace::TraceLayer;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
mod admin;
mod auth;
mod compiler;
mod db;
@@ -20,6 +21,7 @@ mod folders;
mod files;
mod handlers;
mod models;
mod setup;
mod world;
mod collab;
@@ -32,6 +34,7 @@ pub struct AppState {
pub bcast_map: Arc<Mutex<HashMap<String, Arc<BroadcastGroup>>>>,
pub db: AnyPool,
pub key: Key,
pub registration_enabled: bool,
}
impl axum::extract::FromRef<AppState> for Key {
@@ -71,14 +74,22 @@ async fn main() {
}
};
let registration_enabled = std::env::var("ALLOW_REGISTRATION")
.map(|v| v.to_lowercase() != "false")
.unwrap_or(true);
let state = AppState {
compiler: Arc::new(Mutex::new(TypstCompiler::new())),
bcast_map: Arc::new(Mutex::new(HashMap::new())),
db,
key,
registration_enabled,
};
let api_routes = Router::new()
.route("/setup", get(setup::setup_status).post(setup::run_setup))
.route("/admin/users", get(admin::list_users).post(admin::create_user))
.route("/admin/users/{id}", patch(admin::update_user).delete(admin::delete_user))
.route("/compile", post(compile_handler))
.route("/export/{format}", post(export_handler))
.route("/export/pandoc/{format}", post(handlers::pandoc_export_handler))
+53
View File
@@ -1,6 +1,19 @@
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
// sqlx::Any maps SQLite INTEGER to i64 (BIGINT), not bool.
// These helpers let us store is_admin as i64 in DB-mapped structs
// while still serializing it as a JSON boolean for the frontend.
mod serde_i64_bool {
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S: Serializer>(v: &i64, s: S) -> Result<S::Ok, S::Error> {
s.serialize_bool(*v != 0)
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<i64, D::Error> {
Ok(if bool::deserialize(d)? { 1 } else { 0 })
}
}
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct User {
pub id: String,
@@ -8,6 +21,18 @@ pub struct User {
pub email: String,
#[serde(skip_serializing)]
pub password_hash: String,
#[serde(with = "serde_i64_bool")]
pub is_admin: i64,
}
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct AdminUserView {
pub id: String,
pub username: String,
pub email: String,
#[serde(with = "serde_i64_bool")]
pub is_admin: i64,
pub created_at: String,
}
#[derive(Debug, Serialize, Deserialize, FromRow)]
@@ -161,3 +186,31 @@ pub struct InviteRequest {
pub email: String,
pub role: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct AdminCreateUserRequest {
pub username: String,
pub email: String,
pub password: String,
pub is_admin: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct SetupRequest {
pub username: String,
pub email: String,
pub password: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct SetupStatus {
pub needs_setup: bool,
pub registration_enabled: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct UpdateUserRequest {
pub is_admin: Option<bool>,
pub username: Option<String>,
pub email: Option<String>,
}
+73
View File
@@ -0,0 +1,73 @@
use axum::{extract::State, http::StatusCode, Json};
use axum_extra::extract::cookie::{Cookie, SameSite, SignedCookieJar};
use uuid::Uuid;
use argon2::{
password_hash::{rand_core::OsRng, PasswordHasher, SaltString},
Argon2,
};
use crate::{
models::{SetupRequest, SetupStatus, User},
AppState,
};
pub async fn setup_status(
State(state): State<AppState>,
) -> Json<SetupStatus> {
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users")
.fetch_one(&state.db)
.await
.unwrap_or((0,));
Json(SetupStatus {
needs_setup: count.0 == 0,
registration_enabled: state.registration_enabled,
})
}
pub async fn run_setup(
State(state): State<AppState>,
jar: SignedCookieJar,
Json(payload): Json<SetupRequest>,
) -> Result<(SignedCookieJar, Json<User>), (StatusCode, String)> {
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users")
.fetch_one(&state.db)
.await
.unwrap_or((0,));
if count.0 > 0 {
return Err((StatusCode::FORBIDDEN, "Setup has already been completed".to_string()));
}
if payload.username.is_empty() || payload.password.is_empty() || payload.email.is_empty() {
return Err((StatusCode::BAD_REQUEST, "Username, email, and password cannot be empty".to_string()));
}
let salt = SaltString::generate(&mut OsRng);
let password_hash = Argon2::default()
.hash_password(payload.password.as_bytes(), &salt)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.to_string();
let user_id = Uuid::new_v4().to_string();
let user = sqlx::query_as::<_, User>(
"INSERT INTO users (id, username, email, password_hash, is_admin) VALUES (?, ?, ?, ?, ?) RETURNING id, username, email, password_hash, is_admin"
)
.bind(&user_id)
.bind(&payload.username)
.bind(&payload.email)
.bind(&password_hash)
.bind(1i64)
.fetch_one(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let mut cookie = Cookie::new("session_user_id", user.id.clone());
cookie.set_http_only(true);
cookie.set_same_site(SameSite::Lax);
cookie.set_path("/");
Ok((jar.add(cookie), Json(user)))
}
+44 -15
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { exportTypst } from '../ts/typst-api';
import { text } from '../ts/yjs-setup';
import { connectionStatus, connectedUsers, themeStore, darkModeStore, editorViewStore, documentZoomStore, commentsSidebarOpen, versionHistoryOpen, triggerLspReconnect, documentStatsStore } from '../ts/store';
import { connectionStatus, connectedUsers, themeStore, darkModeStore, editorViewStore, documentZoomStore, commentsSidebarOpen, versionHistoryOpen, triggerLspReconnect, documentStatsStore, previewOpenStore } from '../ts/store';
import { themes } from '../ts/themes';
import { goto } from '$app/navigation';
import ShareModal from './ShareModal.svelte';
@@ -395,7 +395,7 @@
<svelte:window onclick={handleWindowClick} />
<header class="flex flex-col border-b border-[var(--theme-border)] bg-[var(--theme-bg)] text-[var(--theme-text)] backdrop-blur-md select-none w-full relative z-[60]" style="background-color: var(--theme-bg); color: var(--theme-text); border-color: var(--theme-border);">
<header class="flex flex-col border-b border-[var(--theme-border)] bg-[var(--theme-bg)] text-[var(--theme-text)] backdrop-blur-md select-none w-full relative z-[70]" style="background-color: var(--theme-bg); color: var(--theme-text); border-color: var(--theme-border);">
<div class="flex items-center justify-between px-4 py-2.5">
<div class="flex items-center gap-3">
@@ -427,7 +427,7 @@
File
</button>
{#if activeMenu === 'file'}
<div class="absolute left-0 top-full mt-1 w-48 bg-[var(--theme-bg)] rounded-xl shadow-xl border border-[var(--theme-border)] py-1 z-[100]">
<div class="absolute left-0 top-full mt-1 w-48 bg-[var(--theme-bg)] rounded-xl shadow-xl border border-[var(--theme-border)] py-1 z-[100] max-h-[calc(100vh-8rem)] overflow-y-auto">
<button onclick={() => { activeMenu = null; goto('/dashboard'); }} class="w-full text-left px-4 py-1.5 text-sm text-[var(--theme-text)] hover:bg-[var(--theme-border)]">New / Open</button>
<button onclick={() => { activeMenu = null; openInfo(); }} class="w-full text-left px-4 py-1.5 text-sm text-[var(--theme-text)] hover:bg-[var(--theme-border)]">Document Info</button>
{#if !isViewer}
@@ -561,19 +561,48 @@
{/if}
<div class="w-px h-5 bg-gray-300 dark:bg-white/10"></div>
<div class="flex items-center bg-gray-100/50 dark:bg-zinc-900/50 rounded-md p-0.5 border border-gray-200 dark:border-white/10">
<button onclick={handlePrint} class="flex items-center gap-1 px-3 py-1 text-xs font-bold text-[var(--theme-bg)] bg-[var(--theme-text)] hover:opacity-80 rounded transition-all shadow-sm" title="Print Document">
<Icon icon="mdi:printer" class="text-sm" />
Print
</button>
<button onclick={() => handleExport('typ')} class="px-2.5 py-1 text-xs font-semibold text-gray-600 hover:text-gray-900 hover:bg-white dark:text-gray-400 dark:hover:text-white dark:hover:bg-white/10 rounded transition-all" title="Download .typ source">TYP</button>
<button onclick={() => handleExport('svg')} class="px-2.5 py-1 text-xs font-semibold text-gray-600 hover:text-gray-900 hover:bg-white dark:text-gray-400 dark:hover:text-white dark:hover:bg-white/10 rounded transition-all" title="Export as SVG">SVG</button>
<button onclick={() => handleExport('png')} class="px-2.5 py-1 text-xs font-semibold text-gray-600 hover:text-gray-900 hover:bg-white dark:text-gray-400 dark:hover:text-white dark:hover:bg-white/10 rounded transition-all" title="Export as PNG">PNG</button>
<button onclick={() => handleExport('pdf')} class="flex items-center gap-1 px-3 py-1 text-xs font-bold text-blue-600 bg-blue-50 hover:bg-blue-100 dark:text-blue-400 dark:bg-blue-900/20 dark:hover:bg-blue-900/40 rounded transition-all">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/><path d="M8 13h2"/><path d="M8 17h2"/><path d="M14 13h2"/><path d="M14 17h2"/></svg>
PDF
<button
onclick={() => ($previewOpenStore = !$previewOpenStore)}
class="flex items-center gap-1.5 px-3.5 py-1.5 text-sm font-medium transition-colors rounded-md {$previewOpenStore ? 'text-gray-700 bg-gray-100 hover:bg-gray-200 dark:text-gray-200 dark:bg-black/20 dark:hover:bg-white/10' : 'text-blue-600 bg-blue-50 hover:bg-blue-100 dark:text-blue-400 dark:bg-blue-900/20 dark:hover:bg-blue-900/40'}"
title={$previewOpenStore ? 'Hide preview' : 'Show preview'}
>
<Icon icon={$previewOpenStore ? 'mdi:eye-off-outline' : 'mdi:eye-outline'} class="text-[16px]" />
Preview
</button>
<div class="w-px h-5 bg-gray-300 dark:bg-white/10"></div>
<button onclick={handlePrint} class="flex items-center gap-1.5 px-3.5 py-1.5 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 dark:text-gray-200 dark:bg-black/20 dark:hover:bg-white/10 rounded-md transition-colors" title="Print Document">
<Icon icon="mdi:printer" class="text-[16px]" />
Print
</button>
<div class="relative action-menu-container">
<button
onclick={(e) => { e.stopPropagation(); activeMenu = activeMenu === 'export' ? null : 'export'; }}
class="flex items-center gap-1.5 px-3.5 py-1.5 text-sm font-medium text-blue-600 bg-blue-50 hover:bg-blue-100 dark:text-blue-400 dark:bg-blue-900/20 dark:hover:bg-blue-900/40 rounded-md transition-colors {activeMenu === 'export' ? 'ring-2 ring-blue-500/30' : ''}"
>
<Icon icon="mdi:export-variant" class="text-[16px]" />
Export
<Icon icon="mdi:chevron-down" class="text-sm opacity-70" />
</button>
{#if activeMenu === 'export'}
<div class="absolute right-0 top-full mt-1 w-44 bg-[var(--theme-bg)] rounded-xl shadow-xl border border-[var(--theme-border)] py-1 z-[100]">
<button onclick={() => { activeMenu = null; handleExport('pdf'); }} class="w-full text-left px-4 py-1.5 text-sm text-[var(--theme-text)] hover:bg-[var(--theme-border)] flex items-center gap-2">
<Icon icon="mdi:file-pdf-box" class="text-base text-red-500" /> PDF document
</button>
<button onclick={() => { activeMenu = null; handleExport('typ'); }} class="w-full text-left px-4 py-1.5 text-sm text-[var(--theme-text)] hover:bg-[var(--theme-border)] flex items-center gap-2">
<Icon icon="mdi:code-braces" class="text-base text-purple-500" /> TYP source
</button>
<button onclick={() => { activeMenu = null; handleExport('svg'); }} class="w-full text-left px-4 py-1.5 text-sm text-[var(--theme-text)] hover:bg-[var(--theme-border)] flex items-center gap-2">
<Icon icon="mdi:svg" class="text-base text-orange-500" /> SVG graphics
</button>
<button onclick={() => { activeMenu = null; handleExport('png'); }} class="w-full text-left px-4 py-1.5 text-sm text-[var(--theme-text)] hover:bg-[var(--theme-border)] flex items-center gap-2">
<Icon icon="mdi:image" class="text-base text-blue-500" /> PNG image
</button>
</div>
{/if}
</div>
</div>
</div>
+1
View File
@@ -4,6 +4,7 @@ export type User = {
id: string;
username: string;
email: string;
is_admin: boolean;
};
export const userStore = writable<User | null>(null);
+1
View File
@@ -12,6 +12,7 @@ export const commentReference = writable('');
export const editorErrors = writable<Diagnostic[]>([]);
export const documentStatsStore = writable<{pages: number; words: number; characters: number; characters_excluding_spaces: number} | null>(null);
export const triggerLspReconnect = writable(0);
export const previewOpenStore = writable(true);
export interface AwarenessUser {
clientId: number;
+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>