From 246f7357a95e1f5cb7f81bfbcf832156d17e5e8e Mon Sep 17 00:00:00 2001 From: SirBlobby Date: Sat, 4 Apr 2026 23:22:30 -0400 Subject: [PATCH] Initial Commit --- .gitignore | 38 ++ .npmrc | 1 + Dockerfile | 27 + README.md | 91 +++ docker-compose.yml | 8 + package.json | 42 ++ server/Cargo.toml | 32 + server/src/auth.rs | 190 ++++++ server/src/compiler.rs | 97 +++ server/src/db.rs | 67 ++ server/src/docs.rs | 227 +++++++ server/src/files.rs | 186 ++++++ server/src/folders.rs | 127 ++++ server/src/handlers.rs | 213 ++++++ server/src/main.rs | 98 +++ server/src/models.rs | 91 +++ server/src/world.rs | 131 ++++ src/app.css | 15 + src/app.d.ts | 13 + src/app.html | 12 + src/lib/components/Editor.svelte | 91 +++ src/lib/components/ErrorBanner.svelte | 22 + src/lib/components/PageSettingsModal.svelte | 171 +++++ src/lib/components/Preview.svelte | 31 + src/lib/components/ShareModal.svelte | 88 +++ src/lib/components/ThemePicker.svelte | 36 + src/lib/components/Toolbar.svelte | 625 ++++++++++++++++++ .../dashboard/CreateDocModal.svelte | 52 ++ .../dashboard/CreateFolderModal.svelte | 52 ++ .../components/dashboard/DeleteModal.svelte | 36 + src/lib/components/dashboard/DocCard.svelte | 81 +++ src/lib/components/dashboard/FileCard.svelte | 36 + src/lib/components/dashboard/FolderRow.svelte | 36 + src/lib/components/dashboard/InfoModal.svelte | 41 ++ src/lib/components/dashboard/Navbar.svelte | 46 ++ .../components/dashboard/RenameModal.svelte | 48 ++ .../components/modals/CreateDocModal.svelte | 0 .../modals/CreateFolderModal.svelte | 0 src/lib/components/modals/DeleteModal.svelte | 0 src/lib/components/modals/InfoModal.svelte | 0 src/lib/components/modals/RenameModal.svelte | 0 src/lib/index.ts | 1 + src/lib/ts/auth.ts | 22 + src/lib/ts/store.ts | 39 ++ src/lib/ts/themes.ts | 143 ++++ src/lib/ts/typst-api.ts | 46 ++ src/lib/ts/yjs-setup.ts | 97 +++ src/routes/+error.svelte | 32 + src/routes/+layout.svelte | 40 ++ src/routes/+layout.ts | 2 + src/routes/+page.svelte | 22 + src/routes/dashboard/+page.svelte | 477 +++++++++++++ src/routes/doc/[id]/+page.svelte | 91 +++ src/routes/login/+page.svelte | 106 +++ src/routes/register/+page.svelte | 115 ++++ src/routes/settings/+page.svelte | 292 ++++++++ static/robots.txt | 3 + svelte.config.js | 26 + tsconfig.json | 15 + typst | 1 + vite.config.ts | 24 + 61 files changed, 4792 insertions(+) create mode 100644 .gitignore create mode 100644 .npmrc create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 package.json create mode 100644 server/Cargo.toml create mode 100644 server/src/auth.rs create mode 100644 server/src/compiler.rs create mode 100644 server/src/db.rs create mode 100644 server/src/docs.rs create mode 100644 server/src/files.rs create mode 100644 server/src/folders.rs create mode 100644 server/src/handlers.rs create mode 100644 server/src/main.rs create mode 100644 server/src/models.rs create mode 100644 server/src/world.rs create mode 100644 src/app.css create mode 100644 src/app.d.ts create mode 100644 src/app.html create mode 100644 src/lib/components/Editor.svelte create mode 100644 src/lib/components/ErrorBanner.svelte create mode 100644 src/lib/components/PageSettingsModal.svelte create mode 100644 src/lib/components/Preview.svelte create mode 100644 src/lib/components/ShareModal.svelte create mode 100644 src/lib/components/ThemePicker.svelte create mode 100644 src/lib/components/Toolbar.svelte create mode 100644 src/lib/components/dashboard/CreateDocModal.svelte create mode 100644 src/lib/components/dashboard/CreateFolderModal.svelte create mode 100644 src/lib/components/dashboard/DeleteModal.svelte create mode 100644 src/lib/components/dashboard/DocCard.svelte create mode 100644 src/lib/components/dashboard/FileCard.svelte create mode 100644 src/lib/components/dashboard/FolderRow.svelte create mode 100644 src/lib/components/dashboard/InfoModal.svelte create mode 100644 src/lib/components/dashboard/Navbar.svelte create mode 100644 src/lib/components/dashboard/RenameModal.svelte create mode 100644 src/lib/components/modals/CreateDocModal.svelte create mode 100644 src/lib/components/modals/CreateFolderModal.svelte create mode 100644 src/lib/components/modals/DeleteModal.svelte create mode 100644 src/lib/components/modals/InfoModal.svelte create mode 100644 src/lib/components/modals/RenameModal.svelte create mode 100644 src/lib/index.ts create mode 100644 src/lib/ts/auth.ts create mode 100644 src/lib/ts/store.ts create mode 100644 src/lib/ts/themes.ts create mode 100644 src/lib/ts/typst-api.ts create mode 100644 src/lib/ts/yjs-setup.ts create mode 100644 src/routes/+error.svelte create mode 100644 src/routes/+layout.svelte create mode 100644 src/routes/+layout.ts create mode 100644 src/routes/+page.svelte create mode 100644 src/routes/dashboard/+page.svelte create mode 100644 src/routes/doc/[id]/+page.svelte create mode 100644 src/routes/login/+page.svelte create mode 100644 src/routes/register/+page.svelte create mode 100644 src/routes/settings/+page.svelte create mode 100644 static/robots.txt create mode 100644 svelte.config.js create mode 100644 tsconfig.json create mode 160000 typst create mode 100644 vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cf47b2e --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +node_modules + +# Output +.output +.vercel +.netlify +.wrangler +/.svelte-kit +/build + +# OS +.DS_Store +Thumbs.db + +# Env +.env +.env.* +!.env.example +!.env.test + +# Vite +vite.config.js.timestamp-* +vite.config.ts.timestamp-* + +# Bun +.bun +bun.lock + +# Rust +target/ +Cargo.lock + +# Data +*.db + +# Editor +.vscode + diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8752f77 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +# Build Frontend +FROM node:20-alpine AS frontend-builder +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +# Build Backend +FROM rust:1.82-alpine AS backend-builder +WORKDIR /app +RUN apk add --no-cache musl-dev sqlite-dev openssl-dev pkgconfig +COPY typst/ typst/ +COPY server/Cargo.* server/ +COPY server/src server/src +WORKDIR /app/server +RUN cargo build --release + +# Final Runtime Image +FROM alpine:3.19 +WORKDIR /app +RUN apk add --no-cache libgcc sqlite-libs openssl +COPY --from=frontend-builder /app/build /app/build +COPY --from=backend-builder /app/server/target/release/server /app/server +ENV PORT=3000 +EXPOSE 3000 +CMD ["/app/server"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..015f78b --- /dev/null +++ b/README.md @@ -0,0 +1,91 @@ +# TypstDrive + +[![Version](https://img.shields.io/badge/version-1.0.0-blue.svg)](https://github.com/your-username/typstdrive) +[![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/) +[![Tailwind CSS](https://img.shields.io/badge/Tailwind_CSS-06B6D4?logo=tailwindcss&logoColor=white)](https://tailwindcss.com/) +[![Bun](https://img.shields.io/badge/Bun-latest-black?logo=bun)](https://bun.sh/) +[![SQLite](https://img.shields.io/badge/SQLite-003B57?logo=sqlite&logoColor=white)](https://www.sqlite.org/) +[![Docker](https://img.shields.io/badge/Docker-2496ED?logo=docker&logoColor=white)](https://www.docker.com/) + +TypstDrive is a real-time collaborative web editor for Typst. With built-in dark mode, multiple themes, and a clean Google Docs-like interface, it makes creating and sharing documents effortless. + +## 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. +- **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, or SVG. +- **User Authentication**: Secure accounts and workspaces for all your documents. +- **Link Sharing**: Share documents with configurable permissions (Viewer / Editor). +- **Asset Management**: Upload and seamlessly use custom fonts and images directly within your documents. + +## Fonts & Images + +TypstDrive allows you to upload custom `.ttf` or `.otf` fonts and image files (`.png`, `.jpg`, `.svg`, etc.) to your folders or directly to a document's workspace. + +### Custom Fonts + +When you upload a font file (e.g., `JetBrainsMono-Regular.ttf`), it is automatically made available to the Typst compiler. You can use the font in two ways: + +1. **By Typographic Family Name:** You can use the internal font family name embedded in the file. + ```typst + #set text(font: "JetBrains Mono") + ``` +2. **By Filename (Convenience Alias):** You can also use the exact name of the uploaded file (without the extension), which is extremely helpful if you are unsure of the exact typographic family name. + ```typst + #set text(font: "JetBrainsMono-Regular") + ``` + +### Images + +Uploaded images can be referenced natively using the `#image` function in Typst. Simply upload your image file (e.g., `logo.png`) to your dashboard and reference it by its exact filename in your `.typ` document. + +```typst +#image("logo.png", width: 50%) +``` + +## Self-Hosting + +TypstDrive is completely self-hostable. We provide a Docker image that packages both the Rust backend and the SvelteKit frontend. + +### Prerequisites + +- [Docker](https://docs.docker.com/get-docker/) +- [Docker Compose](https://docs.docker.com/compose/install/) + +### Getting Started + +1. Clone the repository: + ```bash + git clone https://github.com/your-username/typstdrive.git + cd typstdrive + ``` + +2. Start the application: + ```bash + docker-compose up -d + ``` + +3. Open your browser and navigate to: + ``` + http://localhost:3000 + ``` + +### Data Storage + +The SQLite database containing users and documents is stored in the `./data` directory relative to your `docker-compose.yml` file. This is automatically mounted by Docker Compose to ensure your data persists across container restarts. + +## Local Development + +If you'd like to contribute or run TypstDrive without Docker: + +### Frontend +1. Install dependencies: `npm install` +2. Run the dev server: `npm run dev` + +### Backend +1. Navigate to the `server/` directory. +2. Build and run: `cargo run` + +Note: The frontend expects the backend to be running on port 3000. During local development via Vite, API calls are proxied automatically. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b78cd2e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,8 @@ +services: + typstdrive: + build: . + ports: + - "3000:3000" + volumes: + - ./data:/app/data + restart: unless-stopped diff --git a/package.json b/package.json new file mode 100644 index 0000000..f1f539f --- /dev/null +++ b/package.json @@ -0,0 +1,42 @@ +{ + "name": "typstdrive", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite dev --host", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" + }, + "devDependencies": { + "@sveltejs/adapter-auto": "^7.0.1", + "@sveltejs/adapter-static": "^3.0.10", + "@sveltejs/kit": "^2.56.1", + "@sveltejs/vite-plugin-svelte": "^6.2.4", + "@tailwindcss/forms": "^0.5.11", + "@tailwindcss/typography": "^0.5.19", + "@tailwindcss/vite": "^4.2.2", + "svelte": "^5.55.1", + "svelte-check": "^4.4.6", + "tailwindcss": "^4.2.2", + "typescript": "^5.9.3", + "vite": "^7.3.1", + "vite-plugin-top-level-await": "^1.6.0", + "vite-plugin-wasm": "^3.6.0" + }, + "dependencies": { + "@codemirror/commands": "^6.10.3", + "@codemirror/lang-rust": "^6.0.2", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.41.0", + "@iconify/svelte": "^5.2.1", + "codemirror": "^6.0.2", + "codemirror-lang-typst": "^0.4.0", + "y-codemirror.next": "^0.3.5", + "y-websocket": "^3.0.0", + "yjs": "^13.6.30" + } +} diff --git a/server/Cargo.toml b/server/Cargo.toml new file mode 100644 index 0000000..8fcc6a0 --- /dev/null +++ b/server/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "server" +version = "1.0.0" +edition = "2021" + +[dependencies] +axum = { version = "0.8", features = ["ws", "multipart", "macros"] } +axum-extra = { version = "0.10", features = ["cookie", "cookie-private", "cookie-signed"] } +tokio = { version = "1", features = ["full", "macros", "rt-multi-thread"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio-rustls", "chrono", "uuid"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +chrono = { version = "0.4", features = ["serde"] } +uuid = { version = "1", features = ["v4", "serde"] } +tower-http = { version = "0.6", features = ["fs", "trace", "cors"] } +tower = "0.5" +argon2 = "0.5" +futures-util = "0.3" +ecow = "0.2" + +typst = { version = "0.14.2", path = "../typst/crates/typst" } +typst-kit = { path = "../typst/crates/typst-kit", features = ["system-downloader", "system-packages", "embedded-fonts"] } +typst-layout = { path = "../typst/crates/typst-layout" } +typst-pdf = { path = "../typst/crates/typst-pdf" } +typst-render = { path = "../typst/crates/typst-render" } +typst-svg = { path = "../typst/crates/typst-svg" } + +yrs = "0.18.8" +yrs-axum = "0.8" + diff --git a/server/src/auth.rs b/server/src/auth.rs new file mode 100644 index 0000000..08f1575 --- /dev/null +++ b/server/src/auth.rs @@ -0,0 +1,190 @@ +use axum::{ + extract::State, + http::StatusCode, + Json, +}; +use axum_extra::extract::cookie::{Cookie, SameSite, SignedCookieJar}; +use uuid::Uuid; + +use crate::{ + models::{User, RegisterRequest, LoginRequest, ChangePasswordRequest, UpdateProfileRequest}, + AppState, +}; + +use argon2::{ + password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, + Argon2, +}; + +pub async fn register( + State(state): State, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + if payload.username.is_empty() || payload.password.is_empty() { + return Err((StatusCode::BAD_REQUEST, "Username and password cannot be empty".to_string())); + } + + let salt = SaltString::generate(&mut OsRng); + let argon2 = Argon2::default(); + let password_hash = argon2 + .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 result = sqlx::query_as::<_, User>( + "INSERT INTO users (id, username, password_hash) VALUES (?, ?, ?) RETURNING id, username, password_hash" + ) + .bind(&user_id) + .bind(&payload.username) + .bind(&password_hash) + .fetch_one(&state.db) + .await; + + match result { + Ok(user) => Ok(Json(user)), + Err(sqlx::Error::Database(err)) if err.is_unique_violation() => { + Err((StatusCode::CONFLICT, "Username already exists".to_string())) + } + Err(err) => Err((StatusCode::INTERNAL_SERVER_ERROR, err.to_string())), + } +} + +pub async fn login( + State(state): State, + jar: SignedCookieJar, + Json(payload): Json, +) -> Result<(SignedCookieJar, Json), (StatusCode, String)> { + let user = sqlx::query_as::<_, User>("SELECT id, username, password_hash FROM users WHERE username = ?") + .bind(&payload.username) + .fetch_optional(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let user = match user { + Some(u) => u, + None => return Err((StatusCode::UNAUTHORIZED, "Invalid username or password".to_string())), + }; + + let parsed_hash = PasswordHash::new(&user.password_hash) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + if Argon2::default().verify_password(payload.password.as_bytes(), &parsed_hash).is_err() { + return Err((StatusCode::UNAUTHORIZED, "Invalid username or password".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("/"); + + let jar = jar.add(cookie); + + Ok((jar, Json(user))) +} + +pub async fn update_profile( + State(state): State, + jar: SignedCookieJar, + Json(payload): Json, +) -> Result, (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()))?; + + if payload.username.is_empty() { + return Err((StatusCode::BAD_REQUEST, "Username cannot be empty".to_string())); + } + + let result = sqlx::query("UPDATE users SET username = ? WHERE id = ?") + .bind(&payload.username) + .bind(&user_id) + .execute(&state.db) + .await; + + match result { + Ok(_) => { + let user = sqlx::query_as::<_, User>("SELECT id, username, password_hash 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)) + } + Err(sqlx::Error::Database(err)) if err.is_unique_violation() => { + Err((StatusCode::CONFLICT, "Username already exists".to_string())) + } + Err(err) => Err((StatusCode::INTERNAL_SERVER_ERROR, err.to_string())), + } +} + +pub async fn logout(jar: SignedCookieJar) -> Result<(SignedCookieJar, StatusCode), (StatusCode, String)> { + let jar = jar.remove(Cookie::from("session_user_id")); + Ok((jar, StatusCode::OK)) +} + +pub async fn me( + State(state): State, + jar: SignedCookieJar, +) -> Result, (StatusCode, String)> { + let user_id = jar.get("session_user_id").map(|c| c.value().to_string()); + + let user_id = match user_id { + Some(id) => id, + None => return Err((StatusCode::UNAUTHORIZED, "Not logged in".to_string())), + }; + + let user = sqlx::query_as::<_, User>("SELECT id, username, password_hash FROM users WHERE id = ?") + .bind(&user_id) + .fetch_optional(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + match user { + Some(u) => Ok(Json(u)), + None => Err((StatusCode::UNAUTHORIZED, "User not found".to_string())), + } +} + +pub async fn change_password( + State(state): State, + jar: SignedCookieJar, + Json(payload): Json, +) -> Result { + let user_id = jar.get("session_user_id").map(|c| c.value().to_string()) + .ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?; + + if payload.current_password.is_empty() || payload.new_password.is_empty() { + return Err((StatusCode::BAD_REQUEST, "Passwords cannot be empty".to_string())); + } + + let user = sqlx::query_as::<_, User>("SELECT id, username, password_hash 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::UNAUTHORIZED, "User not found".to_string()))?; + + let parsed_hash = PasswordHash::new(&user.password_hash) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + if Argon2::default().verify_password(payload.current_password.as_bytes(), &parsed_hash).is_err() { + return Err((StatusCode::UNAUTHORIZED, "Invalid current password".to_string())); + } + + let salt = SaltString::generate(&mut OsRng); + let new_password_hash = Argon2::default() + .hash_password(payload.new_password.as_bytes(), &salt) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .to_string(); + + sqlx::query("UPDATE users SET password_hash = ? WHERE id = ?") + .bind(&new_password_hash) + .bind(&user_id) + .execute(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(StatusCode::OK) +} diff --git a/server/src/compiler.rs b/server/src/compiler.rs new file mode 100644 index 0000000..6ba0f7f --- /dev/null +++ b/server/src/compiler.rs @@ -0,0 +1,97 @@ +use crate::world::MemoryWorld; +use std::collections::HashMap; +use typst::diag::{SourceDiagnostic, Warned}; +use typst_layout::PagedDocument; +use typst_pdf::{pdf, PdfOptions}; +use typst_render::render; + +pub struct TypstCompiler; + +impl TypstCompiler { + pub fn new() -> Self { + Self + } + + pub fn compile_svg( + &self, + text: String, + files: HashMap>, + ) -> Result<(Vec, String), Vec> { + let world = MemoryWorld::new(text, files); + match typst::compile::(&world) { + Warned { + output: Ok(doc), + warnings: _, + } => { + let svgs = doc + .pages() + .iter() + .map(|page| typst_svg::svg(page)) + .collect(); + let thumbnail = if let Some(page) = doc.pages().first() { + typst_svg::svg(page) + } else { + String::new() + }; + Ok((svgs, thumbnail)) + } + Warned { + output: Err(errors), + warnings: _, + } => { + let diag = errors.into_iter().collect(); + Err(diag) + } + } + } + + pub fn export_pdf( + &self, + text: String, + files: HashMap>, + ) -> Result, Vec> { + let world = MemoryWorld::new(text, files); + match typst::compile::(&world) { + Warned { + output: Ok(doc), + warnings: _, + } => { + let opts = PdfOptions::default(); + match pdf(&doc, &opts) { + Ok(bytes) => Ok(bytes), + Err(_) => Err(vec![]), + } + } + Warned { + output: Err(errors), + warnings: _, + } => Err(errors.into_iter().collect()), + } + } + + pub fn export_png( + &self, + text: String, + files: HashMap>, + ) -> Result, Vec> { + let world = MemoryWorld::new(text, files); + match typst::compile::(&world) { + Warned { + output: Ok(doc), + warnings: _, + } => { + if let Some(page) = doc.pages().first() { + let pixmap = render(page, 2.0); + if let Ok(encoded) = pixmap.encode_png() { + return Ok(encoded); + } + } + Ok(vec![]) + } + Warned { + output: Err(errors), + warnings: _, + } => Err(errors.into_iter().collect()), + } + } +} diff --git a/server/src/db.rs b/server/src/db.rs new file mode 100644 index 0000000..fe8cf7f --- /dev/null +++ b/server/src/db.rs @@ -0,0 +1,67 @@ +use sqlx::sqlite::SqlitePoolOptions; +use sqlx::{Pool, Sqlite}; + +pub async fn init_db() -> Pool { + let pool = SqlitePoolOptions::new() + .max_connections(5) + .connect("sqlite:typstdrive.db?mode=rwc") + .await + .expect("Failed to create pool."); + + sqlx::query( + r#" +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS folders ( + id TEXT PRIMARY KEY, + owner_id TEXT NOT NULL, + parent_id TEXT, + name TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(owner_id) REFERENCES users(id), + FOREIGN KEY(parent_id) REFERENCES folders(id) +); +CREATE TABLE IF NOT EXISTS documents ( + id TEXT PRIMARY KEY, + owner_id TEXT NOT NULL, + folder_id TEXT, + title TEXT NOT NULL, + content BLOB, + thumbnail_svg TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(owner_id) REFERENCES users(id), + FOREIGN KEY(folder_id) REFERENCES folders(id) +); +CREATE TABLE IF NOT EXISTS files ( + id TEXT PRIMARY KEY, + owner_id TEXT NOT NULL, + document_id TEXT, + name TEXT NOT NULL, + mime_type TEXT NOT NULL, + data BLOB NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(owner_id) REFERENCES users(id), + FOREIGN KEY(document_id) REFERENCES documents(id) +); + "#, + ) + .execute(&pool) + .await + .expect("Failed to initialize database schema"); + + + let _ = sqlx::query("ALTER TABLE documents ADD COLUMN folder_id TEXT REFERENCES folders(id)") + .execute(&pool) + .await; + + + let _ = sqlx::query("ALTER TABLE documents ADD COLUMN thumbnail_svg TEXT") + .execute(&pool) + .await; + + pool +} diff --git a/server/src/docs.rs b/server/src/docs.rs new file mode 100644 index 0000000..ca8d79e --- /dev/null +++ b/server/src/docs.rs @@ -0,0 +1,227 @@ +use axum::{ + extract::{Path, State, Multipart}, + http::StatusCode, + Json, +}; +use axum_extra::extract::cookie::SignedCookieJar; +use uuid::Uuid; +use yrs::{Doc, ReadTxn, Transact, Text}; + +use crate::{ + models::{Document, CreateDocumentRequest}, + AppState, +}; + +#[derive(serde::Deserialize)] +pub struct ListDocsQuery { + pub folder_id: Option, +} + +pub async fn list_documents( + axum::extract::Query(query): axum::extract::Query, + State(state): State, + jar: SignedCookieJar, +) -> Result>, (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 docs = if let Some(folder_id) = query.folder_id { + sqlx::query_as::<_, Document>( + "SELECT id, owner_id, folder_id, title, content, thumbnail_svg, created_at, updated_at FROM documents WHERE owner_id = ? AND folder_id = ? ORDER BY updated_at DESC" + ) + .bind(&user_id) + .bind(&folder_id) + .fetch_all(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + } else { + sqlx::query_as::<_, Document>( + "SELECT id, owner_id, folder_id, title, content, thumbnail_svg, created_at, updated_at FROM documents WHERE owner_id = ? AND folder_id IS NULL ORDER BY updated_at DESC" + ) + .bind(&user_id) + .fetch_all(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + }; + + Ok(Json(docs)) +} + +#[axum::debug_handler] +pub async fn create_document( + State(state): State, + jar: SignedCookieJar, + Json(payload): Json, +) -> Result, (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 doc_id = Uuid::new_v4().to_string(); + + let content = { + let ydoc = Doc::new(); + let text = ydoc.get_or_insert_text("typst"); + let initial_text = payload.content.clone().unwrap_or_else(|| "== New Document".to_string()); + println!("Creating document with content length: {}", initial_text.len()); + text.insert(&mut ydoc.transact_mut(), 0, &initial_text); + let encoded = ydoc.transact().encode_state_as_update_v1(&yrs::StateVector::default()); + println!("Encoded Yjs state length: {}", encoded.len()); + encoded + }; + + let doc = sqlx::query_as::<_, Document>( + "INSERT INTO documents (id, owner_id, folder_id, title, content) VALUES (?, ?, ?, ?, ?) RETURNING id, owner_id, folder_id, title, content, thumbnail_svg, created_at, updated_at" + ) + .bind(&doc_id) + .bind(&user_id) + .bind(&payload.folder_id) + .bind(&payload.title) + .bind(&content) + .fetch_one(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(doc)) +} + +pub async fn get_document( + State(state): State, + Path(id): Path, + jar: SignedCookieJar, +) -> Result, (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 doc = sqlx::query_as::<_, Document>( + "SELECT id, owner_id, folder_id, title, content, thumbnail_svg, created_at, updated_at FROM documents WHERE id = ? AND owner_id = ?" + ) + .bind(&id) + .bind(&user_id) + .fetch_optional(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + match doc { + Some(d) => Ok(Json(d)), + None => Err((StatusCode::NOT_FOUND, "Document not found".to_string())), + } +} + +pub async fn update_document( + State(state): State, + Path(id): Path, + jar: SignedCookieJar, + Json(payload): Json, +) -> Result, (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 mut doc = sqlx::query_as::<_, Document>( + "SELECT id, owner_id, folder_id, title, content, thumbnail_svg, created_at, updated_at FROM documents WHERE id = ? AND owner_id = ?" + ) + .bind(&id) + .bind(&user_id) + .fetch_optional(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or((StatusCode::NOT_FOUND, "Document not found".to_string()))?; + + if let Some(new_title) = payload.title { + doc.title = new_title; + } + if let Some(new_folder_id) = payload.folder_id { + if new_folder_id.is_empty() { + doc.folder_id = None; + } else { + doc.folder_id = Some(new_folder_id); + } + } + + + let doc = sqlx::query_as::<_, Document>( + "UPDATE documents SET title = ?, folder_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND owner_id = ? RETURNING id, owner_id, folder_id, title, content, thumbnail_svg, created_at, updated_at" + ) + .bind(&doc.title) + .bind(&doc.folder_id) + .bind(&id) + .bind(&user_id) + .fetch_one(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(doc)) +} + +pub async fn delete_document( + State(state): State, + Path(id): Path, + jar: SignedCookieJar, +) -> Result { + let user_id = jar.get("session_user_id").map(|c| c.value().to_string()) + .ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?; + + let result = sqlx::query("DELETE FROM documents WHERE id = ? AND owner_id = ?") + .bind(&id) + .bind(&user_id) + .execute(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + if result.rows_affected() == 0 { + return Err((StatusCode::NOT_FOUND, "Document not found or unauthorized".to_string())); + } + + Ok(StatusCode::NO_CONTENT) +} + +pub async fn upload_file( + State(state): State, + Path(doc_id): Path, + jar: SignedCookieJar, + mut multipart: Multipart, +) -> Result, (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 doc_exists = sqlx::query_as::<_, (String, Option)>("SELECT id, folder_id FROM documents WHERE id = ? AND owner_id = ?") + .bind(&doc_id) + .bind(&user_id) + .fetch_optional(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + if doc_exists.is_none() { + return Err((StatusCode::NOT_FOUND, "Document not found or unauthorized".to_string())); + } + + let (_, folder_id) = doc_exists.unwrap(); + + let mut uploaded_filename = String::new(); + + while let Some(field) = multipart.next_field().await.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))? { + let file_name = field.file_name().unwrap_or("unnamed").to_string(); + let content_type = field.content_type().unwrap_or("application/octet-stream").to_string(); + let data = field.bytes().await.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?.to_vec(); + + let file_id = Uuid::new_v4().to_string(); + + sqlx::query("INSERT INTO files (id, owner_id, document_id, folder_id, name, mime_type, data) VALUES (?, ?, ?, ?, ?, ?, ?)") + .bind(&file_id) + .bind(&user_id) + .bind(&doc_id) + .bind(&folder_id) + .bind(&file_name) + .bind(&content_type) + .bind(&data) + .execute(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + uploaded_filename = file_name; + break; + } + + Ok(Json(serde_json::json!({"filename": uploaded_filename}))) +} diff --git a/server/src/files.rs b/server/src/files.rs new file mode 100644 index 0000000..e092395 --- /dev/null +++ b/server/src/files.rs @@ -0,0 +1,186 @@ +use axum::{ + extract::{Path, State, Query, Multipart}, + http::{StatusCode, header}, + response::IntoResponse, + Json, +}; +use axum_extra::extract::cookie::SignedCookieJar; +use serde::Deserialize; +use uuid::Uuid; + +use crate::{ + models::{File}, + AppState, +}; + +#[derive(Deserialize)] +pub struct ListFilesQuery { + pub folder_id: Option, +} + +pub async fn list_files( + State(state): State, + jar: SignedCookieJar, + Query(query): Query, +) -> Result>, (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 files = if let Some(folder_id) = query.folder_id { + sqlx::query_as::<_, File>( + "SELECT id, owner_id, document_id, folder_id, name, mime_type, created_at FROM files WHERE owner_id = ? AND folder_id = ? ORDER BY name ASC" + ) + .bind(&user_id) + .bind(&folder_id) + .fetch_all(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + } else { + sqlx::query_as::<_, File>( + "SELECT id, owner_id, document_id, folder_id, name, mime_type, created_at FROM files WHERE owner_id = ? AND folder_id IS NULL ORDER BY name ASC" + ) + .bind(&user_id) + .fetch_all(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + }; + + Ok(Json(files)) +} + +#[derive(Deserialize)] +pub struct UploadFileQuery { + pub folder_id: Option, +} + +pub async fn upload_file_global( + State(state): State, + jar: SignedCookieJar, + Query(query): Query, + mut multipart: Multipart, +) -> Result, (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 mut uploaded_files = vec![]; + + while let Some(field) = multipart.next_field().await.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))? { + let file_name = field.file_name().unwrap_or("unnamed").to_string(); + let content_type = field.content_type().unwrap_or("application/octet-stream").to_string(); + let data = field.bytes().await.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?.to_vec(); + + let file_id = Uuid::new_v4().to_string(); + + sqlx::query("INSERT INTO files (id, owner_id, folder_id, name, mime_type, data) VALUES (?, ?, ?, ?, ?, ?)") + .bind(&file_id) + .bind(&user_id) + .bind(&query.folder_id) + .bind(&file_name) + .bind(&content_type) + .bind(&data) + .execute(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + uploaded_files.push(file_name); + } + + Ok(Json(serde_json::json!({"files": uploaded_files}))) +} + +pub async fn get_file_data( + State(state): State, + Path(id): Path, + jar: SignedCookieJar, +) -> Result { + let user_id = jar.get("session_user_id").map(|c| c.value().to_string()) + .ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?; + + let file = sqlx::query_as::<_, (String, Vec)>("SELECT mime_type, data FROM files WHERE id = ? AND owner_id = ?") + .bind(&id) + .bind(&user_id) + .fetch_optional(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + if let Some((mime_type, data)) = file { + Ok(( + [(header::CONTENT_TYPE, mime_type)], + data, + )) + } else { + Err((StatusCode::NOT_FOUND, "File not found".to_string())) + } +} + +pub async fn delete_file( + State(state): State, + Path(id): Path, + jar: SignedCookieJar, +) -> Result { + let user_id = jar.get("session_user_id").map(|c| c.value().to_string()) + .ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?; + + let result = sqlx::query("DELETE FROM files WHERE id = ? AND owner_id = ?") + .bind(&id) + .bind(&user_id) + .execute(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + if result.rows_affected() == 0 { + return Err((StatusCode::NOT_FOUND, "File not found or unauthorized".to_string())); + } + + Ok(StatusCode::NO_CONTENT) +} + +#[derive(Deserialize)] +pub struct UpdateFileRequest { + pub name: Option, + pub folder_id: Option, +} + +pub async fn update_file( + State(state): State, + Path(id): Path, + jar: SignedCookieJar, + Json(payload): Json, +) -> Result, (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 mut file = sqlx::query_as::<_, File>( + "SELECT id, owner_id, document_id, folder_id, name, mime_type, created_at FROM files WHERE id = ? AND owner_id = ?" + ) + .bind(&id) + .bind(&user_id) + .fetch_optional(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or((StatusCode::NOT_FOUND, "File not found".to_string()))?; + + if let Some(new_name) = payload.name { + file.name = new_name; + } + if let Some(new_folder_id) = payload.folder_id { + if new_folder_id.is_empty() { + file.folder_id = None; + } else { + file.folder_id = Some(new_folder_id); + } + } + + let file = sqlx::query_as::<_, File>( + "UPDATE files SET name = ?, folder_id = ? WHERE id = ? AND owner_id = ? RETURNING id, owner_id, document_id, folder_id, name, mime_type, created_at" + ) + .bind(&file.name) + .bind(&file.folder_id) + .bind(&id) + .bind(&user_id) + .fetch_one(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(file)) +} diff --git a/server/src/folders.rs b/server/src/folders.rs new file mode 100644 index 0000000..a62ea2d --- /dev/null +++ b/server/src/folders.rs @@ -0,0 +1,127 @@ +use axum::{ + extract::{Path, State, Query}, + http::StatusCode, + Json, +}; +use axum_extra::extract::cookie::SignedCookieJar; +use serde::Deserialize; +use uuid::Uuid; + +use crate::{ + models::{Folder, CreateFolderRequest}, + AppState, +}; + +#[derive(Deserialize)] +pub struct ListFoldersQuery { + pub parent_id: Option, +} + +pub async fn list_folders( + State(state): State, + jar: SignedCookieJar, + Query(query): Query, +) -> Result>, (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 folders = if let Some(parent_id) = query.parent_id { + sqlx::query_as::<_, Folder>( + "SELECT id, owner_id, parent_id, name, created_at FROM folders WHERE owner_id = ? AND parent_id = ? ORDER BY name ASC" + ) + .bind(&user_id) + .bind(&parent_id) + .fetch_all(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + } else { + sqlx::query_as::<_, Folder>( + "SELECT id, owner_id, parent_id, name, created_at FROM folders WHERE owner_id = ? AND parent_id IS NULL ORDER BY name ASC" + ) + .bind(&user_id) + .fetch_all(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + }; + + Ok(Json(folders)) +} + +pub async fn create_folder( + State(state): State, + jar: SignedCookieJar, + Json(payload): Json, +) -> Result, (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 folder_id = Uuid::new_v4().to_string(); + + let folder = sqlx::query_as::<_, Folder>( + "INSERT INTO folders (id, owner_id, parent_id, name) VALUES (?, ?, ?, ?) RETURNING id, owner_id, parent_id, name, created_at" + ) + .bind(&folder_id) + .bind(&user_id) + .bind(&payload.parent_id) + .bind(&payload.name) + .fetch_one(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(folder)) +} + +pub async fn delete_folder( + State(state): State, + Path(id): Path, + jar: SignedCookieJar, +) -> Result { + let user_id = jar.get("session_user_id").map(|c| c.value().to_string()) + .ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?; + + + + let result = sqlx::query("DELETE FROM folders WHERE id = ? AND owner_id = ?") + .bind(&id) + .bind(&user_id) + .execute(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + if result.rows_affected() == 0 { + return Err((StatusCode::NOT_FOUND, "Folder not found or unauthorized".to_string())); + } + + Ok(StatusCode::NO_CONTENT) +} + +#[derive(Deserialize)] +pub struct UpdateFolderRequest { + pub name: String, +} + +pub async fn update_folder( + State(state): State, + Path(id): Path, + jar: SignedCookieJar, + Json(payload): Json, +) -> Result, (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 folder = sqlx::query_as::<_, Folder>( + "UPDATE folders SET name = ? WHERE id = ? AND owner_id = ? RETURNING id, owner_id, parent_id, name, created_at" + ) + .bind(&payload.name) + .bind(&id) + .bind(&user_id) + .fetch_optional(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + match folder { + Some(f) => Ok(Json(f)), + None => Err((StatusCode::NOT_FOUND, "Folder not found".to_string())), + } +} + diff --git a/server/src/handlers.rs b/server/src/handlers.rs new file mode 100644 index 0000000..6d43686 --- /dev/null +++ b/server/src/handlers.rs @@ -0,0 +1,213 @@ +use axum::{ + extract::{Path, State}, + http::{header, StatusCode}, + response::IntoResponse, + Json, +}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use tokio::sync::{Mutex, RwLock}; +use yrs_axum::ws::{AxumSink, AxumStream}; +use yrs_axum::broadcast::BroadcastGroup; +use yrs::sync::Awareness; +use yrs::{Doc, ReadTxn, Transact, Update}; +use yrs::updates::decoder::Decode; +use futures_util::stream::StreamExt; +use crate::AppState; +use crate::models::Document; + +#[derive(Deserialize)] +pub struct CompileRequest { + pub text: String, + pub document_id: Option, +} + +#[derive(Serialize)] +pub struct CompileResponse { + pub svgs: Option>, + pub errors: Option>, +} + +#[derive(Serialize)] +pub struct Diagnostic { + pub message: String, + pub severity: String, +} + +pub async fn yjs_handler( + ws: axum::extract::ws::WebSocketUpgrade, + Path(id): Path, + State(state): State, +) -> impl IntoResponse { + let mut bcast_map = state.bcast_map.lock().await; + let bcast = if let Some(bcast) = bcast_map.get(&id) { + bcast.clone() + } else { + let doc = sqlx::query_as::<_, Document>( + "SELECT id, owner_id, folder_id, title, content, thumbnail_svg, created_at, updated_at FROM documents WHERE id = ?" + ) + .bind(&id) + .fetch_optional(&state.db) + .await; + + let ydoc = Doc::new(); + + if let Ok(Some(db_doc)) = doc { + if let Some(content) = db_doc.content { + if let Ok(update) = Update::decode_v1(&content) { + ydoc.transact_mut().apply_update(update); + } + } + } + + let awareness = Arc::new(RwLock::new(Awareness::new(ydoc))); + let new_bcast = Arc::new(BroadcastGroup::new(awareness.clone(), 10).await); + bcast_map.insert(id.clone(), new_bcast.clone()); + + let save_db = state.db.clone(); + let save_id = id.clone(); + let save_awareness = awareness.clone(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(5)); + loop { + interval.tick().await; + let doc = save_awareness.read().await; + let content = doc.doc().transact().encode_state_as_update_v1(&yrs::StateVector::default()); + let _ = sqlx::query("UPDATE documents SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?") + .bind(content) + .bind(&save_id) + .execute(&save_db) + .await; + } + }); + + new_bcast + }; + + drop(bcast_map); + + ws.on_upgrade(move |socket| async move { + let (sink, stream) = socket.split(); + let sink = Arc::new(Mutex::new(AxumSink(sink))); + let stream = AxumStream(stream); + let sub = bcast.subscribe(sink, stream); + match sub.completed().await { + Ok(_) => println!("broadcasting for channel finished successfully"), + Err(e) => eprintln!("broadcasting for channel finished abruptly: {}", e), + } + }) +} + +pub async fn compile_handler( + State(state): State, + Json(payload): Json, +) -> impl IntoResponse { + let mut files_map = std::collections::HashMap::new(); + if let Some(doc_id) = &payload.document_id { + if let Ok(doc) = sqlx::query_as::<_, crate::models::Document>("SELECT id, owner_id, folder_id, title, content, thumbnail_svg, created_at, updated_at FROM documents WHERE id = ?").bind(doc_id).fetch_one(&state.db).await { + if let Ok(files) = sqlx::query_as::<_, (String, Vec)>("SELECT name, data FROM files WHERE owner_id = ?") + .bind(doc.owner_id) + .fetch_all(&state.db) + .await + { + for (name, data) in files { + files_map.insert(name, data); + } + } + } + } + + let compiler = state.compiler.lock().await; + match compiler.compile_svg(payload.text, files_map) { + Ok((svgs, thumbnail)) => { + if let Some(doc_id) = &payload.document_id { + let _ = sqlx::query("UPDATE documents SET thumbnail_svg = ? WHERE id = ?") + .bind(&thumbnail) + .bind(doc_id) + .execute(&state.db) + .await; + } + + Json(CompileResponse { + svgs: Some(svgs), + errors: None, + }) + } + Err(diags) => { + let errors = diags + .into_iter() + .map(|d| Diagnostic { + message: d.message.to_string(), + severity: format!("{:?}", d.severity), + }) + .collect(); + Json(CompileResponse { + svgs: None, + errors: Some(errors), + }) + } + } +} + +pub async fn export_handler( + State(state): State, + Path(format): Path, + Json(payload): Json, +) -> impl IntoResponse { + let mut files_map = std::collections::HashMap::new(); + if let Some(doc_id) = &payload.document_id { + if let Ok(doc) = sqlx::query_as::<_, crate::models::Document>("SELECT id, owner_id, folder_id, title, content, thumbnail_svg, created_at, updated_at FROM documents WHERE id = ?").bind(doc_id).fetch_one(&state.db).await { + if let Ok(files) = sqlx::query_as::<_, (String, Vec)>("SELECT name, data FROM files WHERE owner_id = ?") + .bind(doc.owner_id) + .fetch_all(&state.db) + .await + { + for (name, data) in files { + files_map.insert(name, data); + } + } + } + } + + let compiler = state.compiler.lock().await; + + match format.as_str() { + "pdf" => match compiler.export_pdf(payload.text, files_map.clone()) { + Ok(bytes) => ( + StatusCode::OK, + [(header::CONTENT_TYPE, "application/pdf")], + bytes, + ) + .into_response(), + Err(_) => (StatusCode::BAD_REQUEST, "Compilation failed").into_response(), + }, + "png" => match compiler.export_png(payload.text, files_map.clone()) { + Ok(bytes) => ( + StatusCode::OK, + [(header::CONTENT_TYPE, "image/png")], + bytes, + ) + .into_response(), + Err(_) => (StatusCode::BAD_REQUEST, "Compilation failed").into_response(), + }, + "svg" => match compiler.compile_svg(payload.text, files_map.clone()) { + Ok((svgs, _)) => { + + + let mut combined = String::new(); + for svg in svgs { + combined.push_str(&svg); + combined.push_str("\n"); + } + ( + StatusCode::OK, + [(header::CONTENT_TYPE, "image/svg+xml")], + combined.into_bytes(), + ) + .into_response() + } + Err(_) => (StatusCode::BAD_REQUEST, "Compilation failed").into_response(), + }, + _ => (StatusCode::NOT_FOUND, "Format not supported").into_response(), + } +} diff --git a/server/src/main.rs b/server/src/main.rs new file mode 100644 index 0000000..8024f59 --- /dev/null +++ b/server/src/main.rs @@ -0,0 +1,98 @@ +use axum::{ + routing::{get, post, put, delete}, + Router, +}; +use axum_extra::extract::cookie::Key; +use sqlx::{Pool, Sqlite}; +use std::sync::Arc; +use std::collections::HashMap; +use tokio::sync::Mutex; +use yrs_axum::broadcast::BroadcastGroup; +use tower_http::services::{ServeDir, ServeFile}; +use tower_http::trace::TraceLayer; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +mod auth; +mod compiler; +mod db; +mod docs; +mod folders; +mod files; +mod handlers; +mod models; +mod world; + +use compiler::TypstCompiler; +use handlers::{compile_handler, export_handler, yjs_handler}; + +#[derive(Clone)] +pub struct AppState { + pub compiler: Arc>, + pub bcast_map: Arc>>>, + pub db: Pool, + pub key: Key, +} + +impl axum::extract::FromRef for Key { + fn from_ref(state: &AppState) -> Self { + state.key.clone() + } +} + +#[tokio::main] +async fn main() { + tracing_subscriber::registry() + .with( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "server=debug,tower_http=debug".into()), + ) + .with(tracing_subscriber::fmt::layer()) + .init(); + + tracing::info!("Starting TypstDrive Server"); + + let db = db::init_db().await; + + + let key = Key::generate(); + + let state = AppState { + compiler: Arc::new(Mutex::new(TypstCompiler::new())), + bcast_map: Arc::new(Mutex::new(HashMap::new())), + db, + key, + }; + + let api_routes = Router::new() + .route("/compile", post(compile_handler)) + .route("/export/{format}", post(export_handler)) + .route("/auth/register", post(auth::register)) + .route("/auth/login", post(auth::login)) + .route("/auth/logout", post(auth::logout)) + .route("/auth/me", get(auth::me).put(auth::update_profile)) + .route("/auth/change-password", put(auth::change_password)) + .route("/folders", get(folders::list_folders).post(folders::create_folder)) + .route("/folders/{id}", delete(folders::delete_folder).patch(folders::update_folder)) + .route("/files", get(files::list_files).post(files::upload_file_global)) + .route("/files/{id}", delete(files::delete_file).patch(files::update_file)) + .route("/files/{id}/data", get(files::get_file_data)) + .route("/docs", get(docs::list_documents).post(docs::create_document)) + .route("/docs/{id}", get(docs::get_document).delete(docs::delete_document).patch(docs::update_document)) + .route("/docs/{id}/files", post(docs::upload_file)); + + let yjs_routes = Router::new() + .route("/{id}", get(yjs_handler)); + + let app = Router::new() + .nest("/api", api_routes.layer(TraceLayer::new_for_http())) + .nest("/yjs", yjs_routes.layer(TraceLayer::new_for_http())) + .fallback_service(ServeDir::new("../build").fallback(ServeFile::new("../build/index.html"))) + .with_state(state); + + let listener = tokio::net::TcpListener::bind("0.0.0.0:3000") + .await + .unwrap(); + tracing::info!("Server listening on http://0.0.0.0:3000"); + axum::serve(listener, app).await.unwrap(); +} + diff --git a/server/src/models.rs b/server/src/models.rs new file mode 100644 index 0000000..b84c727 --- /dev/null +++ b/server/src/models.rs @@ -0,0 +1,91 @@ +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; + +#[derive(Debug, Serialize, Deserialize, FromRow)] +pub struct User { + pub id: String, + pub username: String, + #[serde(skip_serializing)] + pub password_hash: String, +} + +#[derive(Debug, Serialize, Deserialize, FromRow)] +pub struct Folder { + pub id: String, + pub owner_id: String, + pub parent_id: Option, + pub name: String, + pub created_at: chrono::NaiveDateTime, +} + +#[derive(Debug, Serialize, Deserialize, FromRow)] +pub struct File { + pub id: String, + pub owner_id: String, + pub document_id: Option, + pub folder_id: Option, + pub name: String, + pub mime_type: String, + pub created_at: chrono::NaiveDateTime, +} + +#[derive(Debug, Serialize, Deserialize, FromRow)] +pub struct Document { + pub id: String, + pub owner_id: String, + pub folder_id: Option, + pub title: String, + #[serde(skip_serializing)] + pub content: Option>, + pub thumbnail_svg: Option, + pub created_at: chrono::NaiveDateTime, + pub updated_at: chrono::NaiveDateTime, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct RegisterRequest { + pub username: String, + pub password: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct LoginRequest { + pub username: String, + pub password: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct UpdateProfileRequest { + pub username: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ChangePasswordRequest { + pub current_password: String, + pub new_password: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CreateFolderRequest { + pub name: String, + pub parent_id: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CreateDocumentRequest { + pub title: String, + pub folder_id: Option, + pub content: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct UpdateDocumentRequest { + pub title: Option, + pub folder_id: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct UpdateFileRequest { + pub name: Option, + pub folder_id: Option, +} diff --git a/server/src/world.rs b/server/src/world.rs new file mode 100644 index 0000000..24e6d83 --- /dev/null +++ b/server/src/world.rs @@ -0,0 +1,131 @@ +use chrono::Datelike; +use std::collections::HashMap; + +use typst::diag::{FileError, FileResult}; +use typst::foundations::{Bytes, Datetime, Duration}; +use typst::syntax::{FileId, RootedPath, Source, VirtualPath, VirtualRoot}; +use typst::text::{Font, FontBook}; +use typst::World; +use typst::{Library, LibraryExt}; +use typst_kit::downloader::SystemDownloader; +use typst_kit::fonts::FontStore; +use typst_kit::packages::SystemPackages; + +pub struct MemoryWorld { + library: typst::utils::LazyHash, + main: FileId, + source: Source, + files: HashMap>, + fonts: std::sync::LazyLock FontStore + Send + Sync>>, + packages: SystemPackages, +} + +impl MemoryWorld { + pub fn new(text: String, files: HashMap>) -> Self { + let main = FileId::new(RootedPath::new( + VirtualRoot::Project, + VirtualPath::new("main.typ").unwrap(), + )); + let source = Source::new(main, text); + let files_clone = files.clone(); + let downloader = SystemDownloader::new("TypstDrive (typst-kit)"); + let packages = SystemPackages::new(downloader); + + Self { + library: typst::utils::LazyHash::new(Library::builder().build()), + main, + source, + fonts: std::sync::LazyLock::new(Box::new(move || { + let mut store = FontStore::new(); + store.extend(typst_kit::fonts::embedded()); + + for (name, data) in &files { + if name.ends_with(".ttf") || name.ends_with(".otf") { + for font in Font::iter(Bytes::new(data.clone())) { + let info = font.info().clone(); + store.push((font.clone(), info.clone())); + + let mut custom_info = info; + if let Some(stem) = std::path::Path::new(name).file_stem() { + if let Some(stem_str) = stem.to_str() { + custom_info.family = stem_str.to_string(); + store.push((font, custom_info)); + } + } + } + } + } + + store + })), + files: files_clone, + packages, + } + } +} + +impl World for MemoryWorld { + fn library(&self) -> &typst::utils::LazyHash { + &self.library + } + + fn book(&self) -> &typst::utils::LazyHash { + self.fonts.book() + } + + fn main(&self) -> FileId { + self.main + } + + fn source(&self, id: FileId) -> FileResult { + if id == self.main { + Ok(self.source.clone()) + } else if let typst::syntax::VirtualRoot::Package(package) = id.root() { + let root = self + .packages + .obtain(package) + .map_err(|e| FileError::Other(Some(e.to_string().into())))?; + let data = root.load(id.vpath())?; + let text = String::from_utf8(data.to_vec()).map_err(|_| FileError::InvalidUtf8)?; + Ok(Source::new(id, text)) + } else { + Err(FileError::NotFound( + std::path::Path::new(id.vpath().get_without_slash()).into(), + )) + } + } + + fn file(&self, id: FileId) -> FileResult { + if id == self.main { + Ok(Bytes::from_string(self.source.text().to_string())) + } else if let typst::syntax::VirtualRoot::Package(package) = id.root() { + let root = self + .packages + .obtain(package) + .map_err(|e| FileError::Other(Some(e.to_string().into())))?; + root.load(id.vpath()) + } else if let Some(data) = self.files.get(id.vpath().get_without_slash()) { + Ok(Bytes::new(data.clone())) + } else { + Err(FileError::NotFound( + std::path::Path::new(id.vpath().get_without_slash()).into(), + )) + } + } + + fn font(&self, index: usize) -> Option { + self.fonts.font(index) + } + + fn today(&self, offset: Option) -> Option { + let now = chrono::Local::now(); + let date = if let Some(offset) = offset { + let offset = chrono::FixedOffset::east_opt(offset.seconds() as i32)?; + now.with_timezone(&offset).date_naive() + } else { + now.date_naive() + }; + + Datetime::from_ymd(date.year(), date.month() as u8, date.day() as u8) + } +} diff --git a/src/app.css b/src/app.css new file mode 100644 index 0000000..869e3e9 --- /dev/null +++ b/src/app.css @@ -0,0 +1,15 @@ +@import 'tailwindcss'; + +@custom-variant dark (&:where(.dark, .dark *)); + +@theme { + --font-sans: 'Inter', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; +} + +html, body { + margin: 0; + padding: 0; + height: 100vh; + width: 100vw; + overflow: hidden; +} \ No newline at end of file diff --git a/src/app.d.ts b/src/app.d.ts new file mode 100644 index 0000000..24cb590 --- /dev/null +++ b/src/app.d.ts @@ -0,0 +1,13 @@ + + +declare global { + namespace App { + + + + + + } +} + +export {}; diff --git a/src/app.html b/src/app.html new file mode 100644 index 0000000..6a2bb58 --- /dev/null +++ b/src/app.html @@ -0,0 +1,12 @@ + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte new file mode 100644 index 0000000..e5d53f4 --- /dev/null +++ b/src/lib/components/Editor.svelte @@ -0,0 +1,91 @@ + + +
diff --git a/src/lib/components/ErrorBanner.svelte b/src/lib/components/ErrorBanner.svelte new file mode 100644 index 0000000..3aca6ef --- /dev/null +++ b/src/lib/components/ErrorBanner.svelte @@ -0,0 +1,22 @@ + + +{#if errors && errors.length > 0} +
+

+ + Compilation Errors +

+
    + {#each errors as error} +
  • + [{error.severity}] + {error.message} +
  • + {/each} +
+
+{/if} diff --git a/src/lib/components/PageSettingsModal.svelte b/src/lib/components/PageSettingsModal.svelte new file mode 100644 index 0000000..b6782e4 --- /dev/null +++ b/src/lib/components/PageSettingsModal.svelte @@ -0,0 +1,171 @@ + + + diff --git a/src/lib/components/Preview.svelte b/src/lib/components/Preview.svelte new file mode 100644 index 0000000..f22aeb5 --- /dev/null +++ b/src/lib/components/Preview.svelte @@ -0,0 +1,31 @@ + + +
+
+ {#if svgs.length > 0} + {#each svgs as svg, i} +
+ {@html svg} +
+ {/each} + {:else} +
+

Document is empty or compiling...

+
+ {/if} +
+
+ + diff --git a/src/lib/components/ShareModal.svelte b/src/lib/components/ShareModal.svelte new file mode 100644 index 0000000..6c4f618 --- /dev/null +++ b/src/lib/components/ShareModal.svelte @@ -0,0 +1,88 @@ + + + diff --git a/src/lib/components/ThemePicker.svelte b/src/lib/components/ThemePicker.svelte new file mode 100644 index 0000000..e30b7f0 --- /dev/null +++ b/src/lib/components/ThemePicker.svelte @@ -0,0 +1,36 @@ + + +
+ + +
diff --git a/src/lib/components/Toolbar.svelte b/src/lib/components/Toolbar.svelte new file mode 100644 index 0000000..df9db9a --- /dev/null +++ b/src/lib/components/Toolbar.svelte @@ -0,0 +1,625 @@ + + + + +
+ +
+
+ + +
+
+

+ {title} +

+ +
+
+ {$connectionStatus === 'connected' ? 'Synced' : 'Connecting...'} +
+
+ + +
+
+ + {#if activeMenu === 'file'} +
+ +
+ + + +
+ +
+
Download
+ + + + +
+ +
+ {/if} +
+ +
+ + {#if activeMenu === 'edit'} +
+ + +
+ + + +
+ {/if} +
+ +
+ + {#if activeMenu === 'view'} +
+ +
+ {/if} +
+
+
+
+ +
+ + {#if $connectedUsers.length > 0} +
+ {#each $connectedUsers as user} +
+ {getInitials(user.name)} +
+ {/each} +
+ {/if} + + + + +
+ + + + +
+ +
+ + + + +
+
+
+ + +
+ + +
+ + + +
+ + +
+ + +
+ + +
+ +
+ +
+ + +
+ +
+ +
+ +
+ +
+ +
+ + { if (e.key === "Enter") $documentZoomStore = 100; }} class="text-[11px] font-semibold text-gray-700 dark:text-gray-200 min-w-[3rem] text-center select-none" ondblclick={() => $documentZoomStore = 100}> + {$documentZoomStore}% + + +
+ +
+ +
+ +
+ +
+ + +
+
+ +{#if isShareModalOpen} + (isShareModalOpen = false)} /> +{/if} + +{#if isPageSettingsOpen} + (isPageSettingsOpen = false)} onApply={handlePageSettings} currentSettings={parseSettings()} /> +{/if} + +{#if showInfoModal} + +
showInfoModal = false} role="presentation" onkeydown={(e) => { if (e.key === "Enter") { showInfoModal = false; } }}> +
e.stopPropagation()} role="dialog" tabindex="-1" onkeydown={(e) => e.stopPropagation()}> +
+
+
+ +
+

{docInfo?.title || title}

+
+
+
+
+

Type

+

Document

+
+ {#if docInfo?.created_at} +
+

Created At

+

{new Date(docInfo.created_at).toLocaleString()}

+
+ {/if} + {#if docInfo?.updated_at} +
+

Last Modified

+

{new Date(docInfo.updated_at).toLocaleString()}

+
+ {/if} +
+
+ +
+
+
+{/if} + +{#if showRenameModal} + +
showRenameModal = false} role="presentation" onkeydown={(e) => { if (e.key === "Enter") { showRenameModal = false; } }}> +
e.stopPropagation()} role="dialog" tabindex="-1" onkeydown={(e) => e.stopPropagation()}> +
+
+
+ +
+

Rename

+
+ +
+ + +
+ +
+ + +
+
+
+
+{/if} + +{#if showDeleteModal} + +
showDeleteModal = false} role="presentation" onkeydown={(e) => { if (e.key === "Enter") { showDeleteModal = false; } }}> +
e.stopPropagation()} role="dialog" tabindex="-1" onkeydown={(e) => e.stopPropagation()}> +
+
+
+ +
+

Delete Document

+
+ +

+ Are you sure you want to delete this document? This action cannot be undone. +

+ +
+ + +
+
+
+
+{/if} diff --git a/src/lib/components/dashboard/CreateDocModal.svelte b/src/lib/components/dashboard/CreateDocModal.svelte new file mode 100644 index 0000000..6d07693 --- /dev/null +++ b/src/lib/components/dashboard/CreateDocModal.svelte @@ -0,0 +1,52 @@ + + + diff --git a/src/lib/components/dashboard/CreateFolderModal.svelte b/src/lib/components/dashboard/CreateFolderModal.svelte new file mode 100644 index 0000000..33821d5 --- /dev/null +++ b/src/lib/components/dashboard/CreateFolderModal.svelte @@ -0,0 +1,52 @@ + + + diff --git a/src/lib/components/dashboard/DeleteModal.svelte b/src/lib/components/dashboard/DeleteModal.svelte new file mode 100644 index 0000000..08d2dc1 --- /dev/null +++ b/src/lib/components/dashboard/DeleteModal.svelte @@ -0,0 +1,36 @@ + + + diff --git a/src/lib/components/dashboard/DocCard.svelte b/src/lib/components/dashboard/DocCard.svelte new file mode 100644 index 0000000..b5b92a2 --- /dev/null +++ b/src/lib/components/dashboard/DocCard.svelte @@ -0,0 +1,81 @@ + + +
goto(`/doc/${doc.id}`)} + onkeydown={(e) => e.key === 'Enter' && goto(`/doc/${doc.id}`)} + draggable="true" + ondragstart={(e) => e.dataTransfer?.setData('text/plain', JSON.stringify({ type: 'document', id: doc.id }))} +> + +
+ {#if doc.thumbnail_svg} +
+ Thumbnail +
+ {:else} +
+ +
+ {/if} +
+ + +
+
+

{doc.title}

+ + +
+ + + {#if activeMenu === doc.id} +
+ + + +
+ +
+ {/if} +
+
+

+ + Edited {new Date(doc.updated_at.endsWith('Z') ? doc.updated_at : doc.updated_at + 'Z').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })} +

+
+
diff --git a/src/lib/components/dashboard/FileCard.svelte b/src/lib/components/dashboard/FileCard.svelte new file mode 100644 index 0000000..1b32cf2 --- /dev/null +++ b/src/lib/components/dashboard/FileCard.svelte @@ -0,0 +1,36 @@ + + +
window.open(`/api/files/${file.id}/data`, '_blank')} + onkeydown={(e) => e.key === 'Enter' && window.open(`/api/files/${file.id}/data`, '_blank')} + draggable="true" + ondragstart={(e) => e.dataTransfer?.setData('text/plain', JSON.stringify({ type: 'file', id: file.id }))} +> +
+
+ {#if file.mime_type.startsWith('image/')} + {file.name} + {:else} + + {/if} +
+ +
+

{file.name}

+

+ + Uploaded {new Date(file.created_at ? (file.created_at.endsWith('Z') ? file.created_at : file.created_at + 'Z') : Date.now()).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })} +

+
diff --git a/src/lib/components/dashboard/FolderRow.svelte b/src/lib/components/dashboard/FolderRow.svelte new file mode 100644 index 0000000..e5ff218 --- /dev/null +++ b/src/lib/components/dashboard/FolderRow.svelte @@ -0,0 +1,36 @@ + + +
navigateToFolder(folder)} + onkeydown={(e) => e.key === 'Enter' && navigateToFolder(folder)} + ondragover={(e) => { e.preventDefault(); setDragOverFolderId(folder.id); }} + ondragleave={() => setDragOverFolderId(null)} + ondrop={(e) => handleDrop(e, folder.id)} +> +
+ + {folder.name} +
+
+ + +
+
diff --git a/src/lib/components/dashboard/InfoModal.svelte b/src/lib/components/dashboard/InfoModal.svelte new file mode 100644 index 0000000..78215ec --- /dev/null +++ b/src/lib/components/dashboard/InfoModal.svelte @@ -0,0 +1,41 @@ + + + diff --git a/src/lib/components/dashboard/Navbar.svelte b/src/lib/components/dashboard/Navbar.svelte new file mode 100644 index 0000000..18c7bd8 --- /dev/null +++ b/src/lib/components/dashboard/Navbar.svelte @@ -0,0 +1,46 @@ + + + diff --git a/src/lib/components/dashboard/RenameModal.svelte b/src/lib/components/dashboard/RenameModal.svelte new file mode 100644 index 0000000..a88e936 --- /dev/null +++ b/src/lib/components/dashboard/RenameModal.svelte @@ -0,0 +1,48 @@ + + + diff --git a/src/lib/components/modals/CreateDocModal.svelte b/src/lib/components/modals/CreateDocModal.svelte new file mode 100644 index 0000000..e69de29 diff --git a/src/lib/components/modals/CreateFolderModal.svelte b/src/lib/components/modals/CreateFolderModal.svelte new file mode 100644 index 0000000..e69de29 diff --git a/src/lib/components/modals/DeleteModal.svelte b/src/lib/components/modals/DeleteModal.svelte new file mode 100644 index 0000000..e69de29 diff --git a/src/lib/components/modals/InfoModal.svelte b/src/lib/components/modals/InfoModal.svelte new file mode 100644 index 0000000..e69de29 diff --git a/src/lib/components/modals/RenameModal.svelte b/src/lib/components/modals/RenameModal.svelte new file mode 100644 index 0000000..e69de29 diff --git a/src/lib/index.ts b/src/lib/index.ts new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/lib/index.ts @@ -0,0 +1 @@ + diff --git a/src/lib/ts/auth.ts b/src/lib/ts/auth.ts new file mode 100644 index 0000000..5d125bd --- /dev/null +++ b/src/lib/ts/auth.ts @@ -0,0 +1,22 @@ +import { writable } from 'svelte/store'; + +export type User = { + id: string; + username: string; +}; + +export const userStore = writable(null); + +export async function fetchUser() { + try { + const res = await fetch('/api/auth/me'); + if (res.ok) { + const user = await res.json(); + userStore.set(user); + } else { + userStore.set(null); + } + } catch { + userStore.set(null); + } +} diff --git a/src/lib/ts/store.ts b/src/lib/ts/store.ts new file mode 100644 index 0000000..9e92ba0 --- /dev/null +++ b/src/lib/ts/store.ts @@ -0,0 +1,39 @@ +import { writable } from 'svelte/store'; + +export const themeStore = writable('Catppuccin'); +export const darkModeStore = writable(true); +export const connectionStatus = writable('connecting'); +export const editorViewStore = writable(null); +export const documentZoomStore = writable(100); + +export interface AwarenessUser { + clientId: number; + name: string; + color: string; + colorLight: string; + isLocal?: boolean; +} + +export const connectedUsers = writable([]); + + +if (typeof window !== 'undefined') { + const savedTheme = localStorage.getItem('editor-theme'); + const savedDark = localStorage.getItem('editor-dark-mode'); + const savedZoom = localStorage.getItem('editor-document-zoom'); + + if (savedTheme) themeStore.set(savedTheme); + if (savedDark !== null) darkModeStore.set(savedDark === 'true'); + if (savedZoom !== null) documentZoomStore.set(parseInt(savedZoom, 10)); + + themeStore.subscribe(value => localStorage.setItem('editor-theme', value)); + darkModeStore.subscribe(value => { + localStorage.setItem('editor-dark-mode', value.toString()); + if (value) { + document.documentElement.classList.add('dark'); + } else { + document.documentElement.classList.remove('dark'); + } + }); + documentZoomStore.subscribe(value => localStorage.setItem('editor-document-zoom', value.toString())); +} diff --git a/src/lib/ts/themes.ts b/src/lib/ts/themes.ts new file mode 100644 index 0000000..04d6ee2 --- /dev/null +++ b/src/lib/ts/themes.ts @@ -0,0 +1,143 @@ +import { EditorView } from '@codemirror/view'; +import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'; +import { tags as t } from '@lezer/highlight'; + +export interface ThemeColors { + background: string; + text: string; + selection: string; + cursor: string; + keyword: string; + string: string; + number: string; + comment: string; + variable: string; + function: string; +} + +export interface ThemeConfig { + icon: string; + dark: ThemeColors; + light: ThemeColors; +} + +export const themes: Record = { + Cerberus: { + icon: "mdi:dog", + dark: { + background: "#171717", text: "#f5f5f5", selection: "#262626", cursor: "#f5f5f5", + keyword: "#e879f9", string: "#2dd4bf", number: "#fbbf24", comment: "#737373", variable: "#f5f5f5", function: "#818cf8" + }, + light: { + background: "#ffffff", text: "#171717", selection: "#f5f5f5", cursor: "#171717", + keyword: "#c026d3", string: "#0d9488", number: "#d97706", comment: "#525252", variable: "#171717", function: "#4f46e5" + } + }, + Catppuccin: { + icon: "mdi:cat", + dark: { + background: "#1e1e2e", text: "#cdd6f4", selection: "#313244", cursor: "#f5e0dc", + keyword: "#cba6f7", string: "#a6e3a1", number: "#fab387", comment: "#6c7086", variable: "#cdd6f4", function: "#89b4fa" + }, + light: { + background: "#eff1f5", text: "#4c4f69", selection: "#e6e9ef", cursor: "#dc8a78", + keyword: "#8839ef", string: "#40a02b", number: "#fe640b", comment: "#9ca0b0", variable: "#4c4f69", function: "#1e66f5" + } + }, + "Arch Linux": { + icon: "mdi:penguin", + dark: { + background: "#0d1117", text: "#c9d1d9", selection: "#21262d", cursor: "#c9d1d9", + keyword: "#bc8cff", string: "#3fb950", number: "#ffa657", comment: "#6e7681", variable: "#c9d1d9", function: "#1793d1" + }, + light: { + background: "#ffffff", text: "#24292f", selection: "#f6f8fa", cursor: "#24292f", + keyword: "#8250df", string: "#1a7f37", number: "#bc4c00", comment: "#6e7781", variable: "#24292f", function: "#1793d1" + } + } +}; + +export function getThemeExtension(themeName: keyof typeof themes, isDark: boolean) { + const colors = themes[themeName][isDark ? 'dark' : 'light']; + + const theme = EditorView.theme({ + "&": { + color: colors.text, + backgroundColor: colors.background, + height: "100%", + fontSize: "14px" + }, + ".cm-content": { + caretColor: colors.cursor + }, + ".cm-cursor, .cm-dropCursor": { borderLeftColor: colors.cursor }, + "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": { backgroundColor: colors.selection }, + ".cm-panels": { backgroundColor: colors.background, color: colors.text }, + ".cm-panels.cm-panels-top": { borderBottom: "2px solid black" }, + ".cm-panels.cm-panels-bottom": { borderTop: "2px solid black" }, + ".cm-searchMatch": { + backgroundColor: "#72a1ff59", + outline: "1px solid #457dff" + }, + ".cm-searchMatch.cm-searchMatch-selected": { + backgroundColor: "#6199ff2f" + }, + ".cm-activeLine": { backgroundColor: colors.selection }, + ".cm-selectionMatch": { backgroundColor: "#aafe661a" }, + "&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket": { + backgroundColor: "#bad0f847" + }, + ".cm-gutters": { + backgroundColor: colors.background, + color: colors.comment, + border: "none" + }, + ".cm-activeLineGutter": { + backgroundColor: colors.selection + }, + ".cm-foldPlaceholder": { + backgroundColor: "transparent", + border: "none", + color: "#ddd" + }, + ".cm-tooltip": { + border: "none", + backgroundColor: colors.background + }, + ".cm-tooltip .cm-tooltip-arrow:before": { + borderTopColor: "transparent", + borderBottomColor: "transparent" + }, + ".cm-tooltip .cm-tooltip-arrow:after": { + borderTopColor: colors.background, + borderBottomColor: colors.background + }, + ".cm-tooltip-autocomplete": { + "& > ul > li[aria-selected]": { + backgroundColor: colors.selection, + color: colors.text + } + } + }, { dark: isDark }); + + const highlightStyle = HighlightStyle.define([ + { tag: t.keyword, color: colors.keyword }, + { tag: [t.name, t.deleted, t.character, t.propertyName, t.macroName], color: colors.variable }, + { tag: [t.function(t.variableName), t.labelName], color: colors.function }, + { tag: [t.color, t.constant(t.name), t.standard(t.name)], color: colors.function }, + { tag: [t.definition(t.name), t.separator], color: colors.variable }, + { tag: [t.typeName, t.className, t.number, t.changed, t.annotation, t.modifier, t.self, t.namespace], color: colors.number }, + { tag: [t.operator, t.operatorKeyword, t.url, t.escape, t.regexp, t.link, t.special(t.string)], color: colors.keyword }, + { tag: [t.meta, t.comment], color: colors.comment }, + { tag: t.strong, fontWeight: "bold" }, + { tag: t.emphasis, fontStyle: "italic" }, + { tag: t.strikethrough, textDecoration: "line-through" }, + { tag: t.link, color: colors.comment, textDecoration: "underline" }, + { tag: t.heading, fontWeight: "bold", color: colors.function }, + { tag: [t.atom, t.bool, t.special(t.variableName)], color: colors.number }, + { tag: [t.processingInstruction, t.string, t.inserted], color: colors.string }, + { tag: t.invalid, color: "#ff0000" }, + ]); + + return [theme, syntaxHighlighting(highlightStyle)]; +} diff --git a/src/lib/ts/typst-api.ts b/src/lib/ts/typst-api.ts new file mode 100644 index 0000000..81720a8 --- /dev/null +++ b/src/lib/ts/typst-api.ts @@ -0,0 +1,46 @@ +export interface Diagnostic { + message: string; + severity: string; +} + +export interface CompileResponse { + svgs: string[] | null; + errors: Diagnostic[] | null; +} + +export async function compileTypst(text: string, document_id?: string): Promise { + const res = await fetch('/api/compile', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text, document_id }), + }); + return await res.json(); +} + +export function exportTypst(text: string, format: 'pdf' | 'png' | 'svg', title: string = 'document', document_id?: string) { + const form = document.createElement('form'); + form.method = 'POST'; + form.action = `/api/export/${format}`; + form.target = '_blank'; + + + + + return fetch(`/api/export/${format}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text, document_id }), + }) + .then((res) => { + if (!res.ok) throw new Error('Export failed'); + return res.blob(); + }) + .then((blob) => { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${title}.${format}`; + a.click(); + URL.revokeObjectURL(url); + }); +} diff --git a/src/lib/ts/yjs-setup.ts b/src/lib/ts/yjs-setup.ts new file mode 100644 index 0000000..d1b1512 --- /dev/null +++ b/src/lib/ts/yjs-setup.ts @@ -0,0 +1,97 @@ +import * as Y from 'yjs'; +import { WebsocketProvider } from 'y-websocket'; +import { get } from 'svelte/store'; +import { userStore } from './auth'; +import { connectionStatus, connectedUsers } from './store'; +import type { AwarenessUser } from './store'; + +export let doc: Y.Doc | null = null; +export let text: Y.Text | null = null; +export let provider: WebsocketProvider | null = null; + +const userColors = [ + '#30bced', '#6eeb83', '#ffbc42', '#ecd444', '#ee6352', + '#9ac2c9', '#8acb88', '#1be7ff', '#ff0054', '#9e0059' +]; + +export function initYjs(docId: string) { + if (typeof window === 'undefined') return; + + + if (provider) { + provider.disconnect(); + provider = null; + } + + doc = new Y.Doc(); + text = doc.getText('typst'); + + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const host = window.location.host; + + connectionStatus.set('connecting'); + + provider = new WebsocketProvider( + `${protocol}//${host}/yjs`, + docId, + doc + ); + + const user = get(userStore); + const color = userColors[Math.floor(Math.random() * userColors.length)]; + + provider.awareness.setLocalStateField('user', { + name: user?.username || 'Anonymous', + color: color, + colorLight: color + '33' + }); + + provider.on('status', (event: { status: string }) => { + connectionStatus.set(event.status); + console.log(`Yjs connection status for ${docId}:`, event.status); + }); + + provider.awareness.on('change', () => { + if (!provider) return; + const states = provider.awareness.getStates(); + const localId = provider.awareness.clientID; + + + + const uniqueUsers = new Map(); + + states.forEach((state, clientId) => { + if (state.user) { + const isLocal = clientId === localId; + const userObj = { + clientId, + ...state.user, + isLocal + }; + + if (isLocal) { + + uniqueUsers.set(state.user.name, userObj); + } else if (!uniqueUsers.has(state.user.name) || !uniqueUsers.get(state.user.name)!.isLocal) { + + + uniqueUsers.set(state.user.name, userObj); + } + } + }); + + connectedUsers.set(Array.from(uniqueUsers.values())); + }); +} + +export function cleanupYjs() { + if (provider) { + provider.disconnect(); + provider = null; + } + doc = null; + text = null; + connectionStatus.set('disconnected'); + connectedUsers.set([]); +} + diff --git a/src/routes/+error.svelte b/src/routes/+error.svelte new file mode 100644 index 0000000..276e06d --- /dev/null +++ b/src/routes/+error.svelte @@ -0,0 +1,32 @@ + + +
+
+
+ +
+ +

+ {$page.status} +

+ +

+ Something went wrong +

+ +

+ {$page.error?.message || 'We experienced an unexpected error processing your request.'} +

+ + + + Return to Dashboard + +
+
\ No newline at end of file diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte new file mode 100644 index 0000000..75247aa --- /dev/null +++ b/src/routes/+layout.svelte @@ -0,0 +1,40 @@ + + + + TypstDrive + + + + + + + +{#if loaded} +
+ {@render children()} +
+{:else} +
+
Loading TypstDrive...
+
+{/if} diff --git a/src/routes/+layout.ts b/src/routes/+layout.ts new file mode 100644 index 0000000..ceccaaf --- /dev/null +++ b/src/routes/+layout.ts @@ -0,0 +1,2 @@ +export const prerender = true; +export const ssr = false; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte new file mode 100644 index 0000000..ac9e819 --- /dev/null +++ b/src/routes/+page.svelte @@ -0,0 +1,22 @@ + + + + TypstDrive + + + +
+ Redirecting... +
diff --git a/src/routes/dashboard/+page.svelte b/src/routes/dashboard/+page.svelte new file mode 100644 index 0000000..7303580 --- /dev/null +++ b/src/routes/dashboard/+page.svelte @@ -0,0 +1,477 @@ + + + + Dashboard - TypstDrive + + + + + +
+ + + + +
+
+

My Documents

+ +
+ + + {#if showPlusDropdown} +
+ + + +
+ {/if} + +
+
+ + +
+ + {#each folderPath as folder, index} + + + {/each} +
+ + {#if loading} +
+
+ +

Loading your workspace...

+
+
+ {:else if documents.length === 0 && folders.length === 0 && files.length === 0 && currentFolderId === null} +
+
+
+ +
+

No documents yet

+

Get started by creating your first Typst document. It's fast, collaborative, and beautiful.

+ +
+
+ {:else} + + {#if folders.length > 0} +
+
+ Folders +
+
+ {#each folders as folder} + dragOverFolderId = id} + /> + {/each} +
+
+ {/if} + +{#if showShareModal && shareTarget} + + showShareModal = false} /> +{/if} + +{#if showDeleteModal && deleteTarget} + showDeleteModal = false} /> +{/if} + +{#if showInfoModal && selectedInfo} + showInfoModal = false} /> +{/if} + +{#if showRenameModal} + showRenameModal = false} /> +{/if} + +{#if showCreateModal} + showCreateModal = false} /> +{/if} + +{#if showCreateFolderModal} + showCreateFolderModal = false} /> +{/if} + + + {#if documents.length > 0 || files.length > 0} +
+ {#each documents as doc} + activeMenu = id} + {openInfo} + {openRename} + {shareItem} + {deleteDoc} + /> + {/each} + + {#each files as file} + + {/each} +
+ {/if} + + {#if documents.length === 0 && folders.length === 0 && files.length === 0} +
+

This folder is empty.

+
+ {/if} + {/if} +
+
diff --git a/src/routes/doc/[id]/+page.svelte b/src/routes/doc/[id]/+page.svelte new file mode 100644 index 0000000..4ac48bc --- /dev/null +++ b/src/routes/doc/[id]/+page.svelte @@ -0,0 +1,91 @@ + + + + {documentTitle} - TypstDrive + + + + +
+ + +
+ +
+ {#if initialized} + + {/if} +
+ + +
+ + +
+
+
diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte new file mode 100644 index 0000000..ad35e65 --- /dev/null +++ b/src/routes/login/+page.svelte @@ -0,0 +1,106 @@ + + + + Login - TypstDrive + + + +
+ +
+
+
+ +
+
+
+ +
+

+ Welcome back +

+

+ Sign in to your TypstDrive workspace +

+
+
+
+
+ +
+
+ +
+ +
+
+
+ +
+
+ +
+ +
+
+
+ + {#if errorMsg} +
+ + {errorMsg} +
+ {/if} + +
+ +
+
+ New to TypstDrive? + + Create an account + +
+
+
+
diff --git a/src/routes/register/+page.svelte b/src/routes/register/+page.svelte new file mode 100644 index 0000000..c5fefcc --- /dev/null +++ b/src/routes/register/+page.svelte @@ -0,0 +1,115 @@ + + + + Register - TypstDrive + + + +
+ +
+
+
+ +
+
+
+ +
+

+ Create an account +

+

+ Join TypstDrive to start collaborating +

+
+
+
+
+ +
+
+ +
+ +
+
+
+ +
+
+ +
+ +
+
+
+ + {#if errorMsg} +
+ + {errorMsg} +
+ {/if} + +
+ +
+
+ Already have an account? + + Sign in + +
+
+
+
diff --git a/src/routes/settings/+page.svelte b/src/routes/settings/+page.svelte new file mode 100644 index 0000000..29932da --- /dev/null +++ b/src/routes/settings/+page.svelte @@ -0,0 +1,292 @@ + + + + Settings - TypstDrive + + + +
+ + +
+ + +
+
+

+ + Account Settings +

+ +
+
+
+ {$userStore?.username?.[0]?.toUpperCase() || '?'} +
+
+

{$userStore?.username}

+

Manage your profile and preferences.

+
+
+ +
+ +
+

Profile

+ + {#if usernameError} +
+ {usernameError} +
+ {/if} + + {#if usernameSuccess} +
+ Username successfully updated. +
+ {/if} + +
+ + +
+ +
+ +
+
+ +
+ +
+

Change Password

+ + {#if passwordError} +
+ {passwordError} +
+ {/if} + + {#if passwordSuccess} +
+ Password successfully changed. +
+ {/if} + +
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+ +
+ + +
+
+
+
+ + +
+
+

+ + Theme Settings +

+ +
+

Customize the appearance of your editor and dashboard. These settings are saved to your browser.

+ +
+
+
+ + +
+
+

+ + Storage Tracking +

+ +
+
+ Total Space Used + 45 MB +
+ +
+
+
+
+

Documents

+

12 MB

+
+
+
+
+
+

Images & Assets

+

33 MB

+
+
+
+
+
+
+ +
+
\ No newline at end of file diff --git a/static/robots.txt b/static/robots.txt new file mode 100644 index 0000000..b6dd667 --- /dev/null +++ b/static/robots.txt @@ -0,0 +1,3 @@ +# allow crawling everything by default +User-agent: * +Disallow: diff --git a/svelte.config.js b/svelte.config.js new file mode 100644 index 0000000..97ca435 --- /dev/null +++ b/svelte.config.js @@ -0,0 +1,26 @@ +import adapter from '@sveltejs/adapter-static'; +import { relative, sep } from 'node:path'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + compilerOptions: { + runes: ({ filename }) => { + const relativePath = relative(import.meta.dirname, filename); + const pathSegments = relativePath.toLowerCase().split(sep); + const isExternalLibrary = pathSegments.includes('node_modules'); + + return isExternalLibrary ? undefined : true; + } + }, + kit: { + adapter: adapter({ + fallback: 'index.html' // Enable SPA mode + }), + prerender: { + entries: ['*'], + handleUnseenRoutes: 'ignore' + } + } +}; + +export default config; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..feea18b --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "rewriteRelativeImportExtensions": true, + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } +} diff --git a/typst b/typst new file mode 160000 index 0000000..d6848a8 --- /dev/null +++ b/typst @@ -0,0 +1 @@ +Subproject commit d6848a802e86a6269300f9768c054a641c2da77f diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..1fc0665 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,24 @@ +import tailwindcss from '@tailwindcss/vite'; +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; +import wasm from 'vite-plugin-wasm'; +import topLevelAwait from 'vite-plugin-top-level-await'; + +export default defineConfig({ + plugins: [tailwindcss(), sveltekit(), wasm(), topLevelAwait()], + server: { + proxy: { + '/api': 'http://127.0.0.1:3000', + '/yjs': { + target: 'ws://127.0.0.1:3000', + ws: true, + }, + }, + }, + optimizeDeps: { + exclude: ['codemirror-lang-typst'] + }, + build: { + target: 'esnext' + } +});