diff --git a/README.md b/README.md index c3e5ee6..21a3aa1 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,8 @@ TypstDrive is a collaborative web editor for Typst. With built-in dark mode, mul - **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. - **Document Sharing**: Invite collaborators by email with Editor or Viewer roles. Collaborators' uploaded fonts and images are available to the compiler. A dedicated "Shared with me" folder on the dashboard surfaces all documents others have shared with you. Manage and remove collaborators directly from the Share modal in the editor. +- **Spaces**: Multi-file editor workspaces, each with its own `typst.toml` and any number of `.typ`, `.bib`, and asset files that import and reference one another. Real-time collaboration works per file (live cursors and editing), and you can create and edit text files like `refs.bib` directly in the browser — everything a full template (e.g. an IEEE paper) needs. Manage Spaces at `/spaces`. +- **Global Packages**: Publish any Space as an instance-local Typst package, immutably versioned and importable everywhere as `@typstdrive/:` (e.g. `#import "@typstdrive/charged-ieee:0.1.4": ieee`). The name, version, and entrypoint are read from the Space's `typst.toml`. Browse published packages at `/packages`. - **Public REST API**: Programmatically render Typst documents to PNG or PDF via `POST /v1/render`. Compilation failures return a `422` with a JSON body detailing each Typst error, including its message and source line and column. Manage API keys from the Settings panel, with a live usage chart supporting 1-hour, 1-day, and 1-week views. Full API reference available at `/api-docs`. - **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. diff --git a/package.json b/package.json index 0ed3c75..e6f4c89 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,8 @@ "@codemirror/lang-rust": "^6.0.2", "@codemirror/lint": "^6.9.6", "@codemirror/lsp-client": "^6.2.4", + "@codemirror/legacy-modes": "^6.5.1", + "@codemirror/language": "^6.10.8", "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.43.0", "@iconify/svelte": "^5.2.1", diff --git a/server/Cargo.toml b/server/Cargo.toml index 61c5b04..bdb3da9 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -35,3 +35,4 @@ tokio-stream = "0.1.18" tempfile = "3.27.0" sha2 = "0.10" base64 = "0.22" +toml = "0.8" diff --git a/server/src/compiler.rs b/server/src/compiler.rs index 372f314..f241732 100644 --- a/server/src/compiler.rs +++ b/server/src/compiler.rs @@ -50,6 +50,28 @@ fn extract_frame_text(frame: &Frame, text: &mut String) { } } +pub struct ProjectInput { + pub entrypoint: String, + pub files: HashMap>, + pub packages: HashMap>>, +} + +impl ProjectInput { + pub fn single(text: String, files: HashMap>) -> Self { + let mut project_files = files; + project_files.insert("main.typ".to_string(), text.into_bytes()); + Self { + entrypoint: "main.typ".to_string(), + files: project_files, + packages: HashMap::new(), + } + } + + fn into_world(self) -> MemoryWorld { + MemoryWorld::new_project(self.entrypoint, self.files, self.packages) + } +} + pub struct TypstCompiler; impl TypstCompiler { @@ -59,13 +81,12 @@ impl TypstCompiler { pub fn compile_svg( &self, - text: String, - files: HashMap>, + input: ProjectInput, ) -> Result< (Vec, String, DocumentStats), Vec<(SourceDiagnostic, Option>)>, > { - let world = MemoryWorld::new(text, files); + let world = input.into_world(); match typst::compile::(&world) { Warned { output: Ok(doc), @@ -104,10 +125,9 @@ impl TypstCompiler { pub fn export_pdf( &self, - text: String, - files: HashMap>, + input: ProjectInput, ) -> Result, Vec<(SourceDiagnostic, Option>)>> { - let world = MemoryWorld::new(text, files); + let world = input.into_world(); match typst::compile::(&world) { Warned { output: Ok(doc), @@ -137,10 +157,9 @@ impl TypstCompiler { pub fn export_png( &self, - text: String, - files: HashMap>, + input: ProjectInput, ) -> Result, Vec<(SourceDiagnostic, Option>)>> { - let world = MemoryWorld::new(text, files); + let world = input.into_world(); match typst::compile::(&world) { Warned { output: Ok(doc), diff --git a/server/src/db/postgres.rs b/server/src/db/postgres.rs index 3dec58b..5e6c028 100644 --- a/server/src/db/postgres.rs +++ b/server/src/db/postgres.rs @@ -105,6 +105,60 @@ pub async fn init_schema(pool: &AnyPool) { count INTEGER NOT NULL DEFAULT 1, PRIMARY KEY(key_id, minute) )", + "CREATE TABLE IF NOT EXISTS spaces ( + id TEXT PRIMARY KEY, + owner_id TEXT NOT NULL REFERENCES users(id), + folder_id TEXT REFERENCES folders(id), + name TEXT NOT NULL, + entrypoint TEXT NOT NULL DEFAULT 'main.typ', + thumbnail_svg TEXT, + public_role TEXT DEFAULT NULL, + created_at TEXT DEFAULT to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS'), + updated_at TEXT DEFAULT to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS') + )", + "CREATE TABLE IF NOT EXISTS space_files ( + id TEXT PRIMARY KEY, + space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, + path TEXT NOT NULL, + kind TEXT NOT NULL DEFAULT 'text', + content BYTEA, + mime_type TEXT NOT NULL DEFAULT 'text/plain', + created_at TEXT DEFAULT to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS'), + UNIQUE(space_id, path) + )", + "CREATE TABLE IF NOT EXISTS space_collaborators ( + id TEXT PRIMARY KEY, + space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL, + created_at TEXT DEFAULT to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS'), + UNIQUE(space_id, user_id) + )", + "CREATE TABLE IF NOT EXISTS packages ( + id TEXT PRIMARY KEY, + owner_id TEXT NOT NULL REFERENCES users(id), + namespace TEXT NOT NULL DEFAULT 'typstdrive', + name TEXT NOT NULL, + description TEXT, + created_at TEXT DEFAULT to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS'), + UNIQUE(namespace, name) + )", + "CREATE TABLE IF NOT EXISTS package_versions ( + id TEXT PRIMARY KEY, + package_id TEXT NOT NULL REFERENCES packages(id) ON DELETE CASCADE, + version TEXT NOT NULL, + entrypoint TEXT NOT NULL DEFAULT 'lib.typ', + manifest BYTEA, + created_at TEXT DEFAULT to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS'), + UNIQUE(package_id, version) + )", + "CREATE TABLE IF NOT EXISTS package_files ( + id TEXT PRIMARY KEY, + version_id TEXT NOT NULL REFERENCES package_versions(id) ON DELETE CASCADE, + path TEXT NOT NULL, + data BYTEA NOT NULL, + UNIQUE(version_id, path) + )", ]; for stmt in &statements { diff --git a/server/src/db/sqlite.rs b/server/src/db/sqlite.rs index 07b19b9..55d760c 100644 --- a/server/src/db/sqlite.rs +++ b/server/src/db/sqlite.rs @@ -110,6 +110,60 @@ pub async fn init_schema(pool: &AnyPool) { count INTEGER NOT NULL DEFAULT 1, PRIMARY KEY(key_id, minute) )", + "CREATE TABLE IF NOT EXISTS spaces ( + id TEXT PRIMARY KEY, + owner_id TEXT NOT NULL REFERENCES users(id), + folder_id TEXT REFERENCES folders(id), + name TEXT NOT NULL, + entrypoint TEXT NOT NULL DEFAULT 'main.typ', + thumbnail_svg TEXT, + public_role TEXT DEFAULT NULL, + created_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now')), + updated_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now')) + )", + "CREATE TABLE IF NOT EXISTS space_files ( + id TEXT PRIMARY KEY, + space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, + path TEXT NOT NULL, + kind TEXT NOT NULL DEFAULT 'text', + content BLOB, + mime_type TEXT NOT NULL DEFAULT 'text/plain', + created_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now')), + UNIQUE(space_id, path) + )", + "CREATE TABLE IF NOT EXISTS space_collaborators ( + id TEXT PRIMARY KEY, + space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL, + created_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now')), + UNIQUE(space_id, user_id) + )", + "CREATE TABLE IF NOT EXISTS packages ( + id TEXT PRIMARY KEY, + owner_id TEXT NOT NULL REFERENCES users(id), + namespace TEXT NOT NULL DEFAULT 'typstdrive', + name TEXT NOT NULL, + description TEXT, + created_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now')), + UNIQUE(namespace, name) + )", + "CREATE TABLE IF NOT EXISTS package_versions ( + id TEXT PRIMARY KEY, + package_id TEXT NOT NULL REFERENCES packages(id) ON DELETE CASCADE, + version TEXT NOT NULL, + entrypoint TEXT NOT NULL DEFAULT 'lib.typ', + manifest BLOB, + created_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now')), + UNIQUE(package_id, version) + )", + "CREATE TABLE IF NOT EXISTS package_files ( + id TEXT PRIMARY KEY, + version_id TEXT NOT NULL REFERENCES package_versions(id) ON DELETE CASCADE, + path TEXT NOT NULL, + data BLOB NOT NULL, + UNIQUE(version_id, path) + )", ]; for stmt in &statements { diff --git a/server/src/handlers.rs b/server/src/handlers.rs index 8a47f23..49cf030 100644 --- a/server/src/handlers.rs +++ b/server/src/handlers.rs @@ -50,11 +50,29 @@ impl Stream for ViewerFilterStream { #[derive(Deserialize)] pub struct CompileRequest { - pub text: String, + #[serde(default)] + pub text: Option, pub document_id: Option, + pub space_id: Option, + #[serde(default)] + pub files: Option>, } -use crate::compiler::DocumentStats; +use crate::compiler::{DocumentStats, ProjectInput}; + +fn map_diagnostics( + diags: Vec<(typst::diag::SourceDiagnostic, Option>)>, +) -> Vec { + diags + .into_iter() + .map(|(d, range)| Diagnostic { + message: d.message.to_string(), + severity: format!("{:?}", d.severity), + from: range.as_ref().map(|r| r.start), + to: range.as_ref().map(|r| r.end), + }) + .collect() +} #[derive(Serialize)] pub struct CompileResponse { @@ -79,34 +97,59 @@ pub async fn yjs_handler( ) -> impl IntoResponse { let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string()); - let doc_info = sqlx::query_as::<_, Document>( - "SELECT id, owner_id, folder_id, title, content, thumbnail_svg, public_role, created_at, updated_at FROM documents WHERE id = ?" - ) - .bind(&id) - .fetch_optional(&state.db) - .await; - let mut is_viewer = true; - if let Ok(Some(ref d)) = doc_info { - if let Some(uid) = &user_id_opt { - if &d.owner_id == uid { - is_viewer = false; - } else if let Ok(Some(_)) = sqlx::query_as::<_, (String,)>("SELECT role FROM collaborators WHERE document_id = ? AND user_id = ? AND role = 'editor'") - .bind(&id) - .bind(uid) + let mut initial_content: Option> = None; + // (table, row_id) the autosave task persists into; None means no persistence. + let mut save_target: Option<(&'static str, String)> = None; + + if let Some(rest) = id.strip_prefix("space:") { + if let Some((space_id, file_id)) = rest.split_once(':') { + if let Some((_space, role)) = crate::spaces::space_role(&state, space_id, &user_id_opt).await { + is_viewer = role == "viewer"; + if let Ok(Some((content,))) = sqlx::query_as::<_, (Option>,)>( + "SELECT content FROM space_files WHERE id = ? AND space_id = ?" + ) + .bind(file_id) + .bind(space_id) .fetch_optional(&state.db) .await - { - is_viewer = false; + { + initial_content = content; + } + save_target = Some(("space_files", file_id.to_string())); } } - if is_viewer { - if let Some(pr) = &d.public_role { - if pr == "editor" { + } else { + let doc_info = sqlx::query_as::<_, Document>( + "SELECT id, owner_id, folder_id, title, content, thumbnail_svg, public_role, created_at, updated_at FROM documents WHERE id = ?" + ) + .bind(&id) + .fetch_optional(&state.db) + .await; + + if let Ok(Some(ref d)) = doc_info { + if let Some(uid) = &user_id_opt { + if &d.owner_id == uid { + is_viewer = false; + } else if let Ok(Some(_)) = sqlx::query_as::<_, (String,)>("SELECT role FROM collaborators WHERE document_id = ? AND user_id = ? AND role = 'editor'") + .bind(&id) + .bind(uid) + .fetch_optional(&state.db) + .await + { is_viewer = false; } } + if is_viewer { + if let Some(pr) = &d.public_role { + if pr == "editor" { + is_viewer = false; + } + } + } + initial_content = d.content.clone(); } + save_target = Some(("documents", id.clone())); } let mut bcast_map = state.bcast_map.lock().await; @@ -115,11 +158,9 @@ pub async fn yjs_handler( } else { let ydoc = Doc::new(); - if let Ok(Some(db_doc)) = doc_info { - if let Some(content) = db_doc.content { - if let Ok(update) = Update::decode_v1(&content) { - ydoc.transact_mut().apply_update(update); - } + if let Some(content) = initial_content { + if let Ok(update) = Update::decode_v1(&content) { + ydoc.transact_mut().apply_update(update); } } @@ -127,22 +168,28 @@ pub async fn yjs_handler( 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; - } - }); + if let Some((table, row_id)) = save_target { + let save_db = state.db.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 query = if table == "space_files" { + "UPDATE space_files SET content = ? WHERE id = ?" + } else { + "UPDATE documents SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?" + }; + let _ = sqlx::query(query) + .bind(content) + .bind(&row_id) + .execute(&save_db) + .await; + } + }); + } new_bcast }; @@ -175,6 +222,54 @@ pub async fn compile_handler( let mut can_save_thumbnail = false; let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string()); + if let Some(space_id) = &payload.space_id { + let (space, role) = match crate::spaces::space_role(&state, space_id, &user_id_opt).await { + Some(v) => v, + None => { + return Json(CompileResponse { + svgs: None, + errors: Some(vec![Diagnostic { + message: "Unauthorized".to_string(), + severity: "Error".to_string(), + from: None, + to: None, + }]), + stats: None, + }); + } + }; + + let overrides = payload.files.clone().unwrap_or_default(); + let input = crate::spaces::assemble_project(&state, &space, overrides).await; + let can_save = role == "owner" || role == "editor"; + + let compiler = state.compiler.lock().await; + let result = compiler.compile_svg(input); + drop(compiler); + + return match result { + Ok((svgs, thumbnail, stats)) => { + if can_save { + let _ = sqlx::query("UPDATE spaces SET thumbnail_svg = ? WHERE id = ?") + .bind(&thumbnail) + .bind(&space.id) + .execute(&state.db) + .await; + } + Json(CompileResponse { + svgs: Some(svgs), + errors: None, + stats: Some(stats), + }) + } + Err(diags) => Json(CompileResponse { + svgs: None, + errors: Some(map_diagnostics(diags)), + stats: None, + }), + }; + } + 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, public_role, created_at, updated_at FROM documents WHERE id = ?" @@ -234,7 +329,7 @@ pub async fn compile_handler( } let compiler = state.compiler.lock().await; - match compiler.compile_svg(payload.text, files_map) { + match compiler.compile_svg(ProjectInput::single(payload.text.clone().unwrap_or_default(), files_map)) { Ok((svgs, thumbnail, stats)) => { if let Some(doc_id) = &payload.document_id { if can_save_thumbnail { @@ -335,10 +430,22 @@ pub async fn export_handler( } } + let input = if let Some(space_id) = &payload.space_id { + match crate::spaces::space_role(&state, space_id, &user_id_opt).await { + Some((space, _)) => { + let overrides = payload.files.clone().unwrap_or_default(); + crate::spaces::assemble_project(&state, &space, overrides).await + } + None => return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response(), + } + } else { + ProjectInput::single(payload.text.clone().unwrap_or_default(), files_map) + }; + let compiler = state.compiler.lock().await; match format.as_str() { - "pdf" => match compiler.export_pdf(payload.text, files_map.clone()) { + "pdf" => match compiler.export_pdf(input) { Ok(bytes) => ( StatusCode::OK, [(header::CONTENT_TYPE, "application/pdf")], @@ -347,7 +454,7 @@ pub async fn export_handler( .into_response(), Err(_) => (StatusCode::BAD_REQUEST, "Compilation failed").into_response(), }, - "png" => match compiler.export_png(payload.text, files_map.clone()) { + "png" => match compiler.export_png(input) { Ok(bytes) => ( StatusCode::OK, [(header::CONTENT_TYPE, "image/png")], @@ -356,7 +463,7 @@ pub async fn export_handler( .into_response(), Err(_) => (StatusCode::BAD_REQUEST, "Compilation failed").into_response(), }, - "svg" => match compiler.compile_svg(payload.text, files_map.clone()) { + "svg" => match compiler.compile_svg(input) { Ok((svgs, _, _)) => { let mut combined = String::new(); for svg in svgs { @@ -408,7 +515,7 @@ pub async fn pandoc_export_handler( }; let mut stdin = child.stdin.take().unwrap(); - let text = payload.text.clone(); + let text = payload.text.clone().unwrap_or_default(); tokio::spawn(async move { use tokio::io::AsyncWriteExt; let _ = stdin.write_all(text.as_bytes()).await; diff --git a/server/src/main.rs b/server/src/main.rs index bc59a85..6ecbae0 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -22,8 +22,10 @@ mod folders; mod files; mod handlers; mod models; +mod packages; mod public_api; mod setup; +mod spaces; mod world; mod collab; @@ -127,7 +129,16 @@ async fn main() { .route("/keys", get(api_keys::list_keys).post(api_keys::create_key)) .route("/keys/usage", get(api_keys::get_aggregate_usage)) .route("/keys/{id}", delete(api_keys::delete_key)) - .route("/keys/{id}/regenerate", post(api_keys::regenerate_key)); + .route("/keys/{id}/regenerate", post(api_keys::regenerate_key)) + .route("/spaces/shared", get(spaces::list_shared_spaces)) + .route("/spaces", get(spaces::list_spaces).post(spaces::create_space)) + .route("/spaces/{id}", get(spaces::get_space).delete(spaces::delete_space).patch(spaces::update_space)) + .route("/spaces/{id}/files", get(spaces::list_space_files).post(spaces::create_space_file)) + .route("/spaces/{id}/files/upload", post(spaces::upload_space_file)) + .route("/spaces/{id}/files/{fid}", get(spaces::get_space_file).patch(spaces::update_space_file).delete(spaces::delete_space_file)) + .route("/packages", get(packages::list_packages)) + .route("/packages/publish", post(packages::publish_package)) + .route("/packages/{name}", get(packages::list_versions).delete(packages::delete_package)); let v1_routes = Router::new() .route("/render", post(public_api::render_handler)); diff --git a/server/src/models.rs b/server/src/models.rs index 3b59a2b..78b832d 100644 --- a/server/src/models.rs +++ b/server/src/models.rs @@ -72,6 +72,93 @@ pub struct Document { pub updated_at: String, } +#[derive(Debug, Serialize, Deserialize, FromRow)] +pub struct Space { + pub id: String, + pub owner_id: String, + pub folder_id: Option, + pub name: String, + pub entrypoint: String, + pub thumbnail_svg: Option, + pub public_role: Option, + #[serde(default)] + #[sqlx(default)] + pub effective_role: Option, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Serialize, Deserialize, FromRow)] +pub struct SpaceFile { + pub id: String, + pub space_id: String, + pub path: String, + pub kind: String, + #[serde(skip_serializing)] + #[sqlx(default)] + pub content: Option>, + pub mime_type: String, + pub created_at: String, +} + +#[derive(Debug, Serialize, Deserialize, FromRow)] +pub struct Package { + pub id: String, + pub owner_id: String, + pub namespace: String, + pub name: String, + pub description: Option, + pub created_at: String, + #[serde(default)] + #[sqlx(default)] + pub owner_name: Option, + #[serde(default)] + #[sqlx(default)] + pub latest_version: Option, +} + +#[derive(Debug, Serialize, Deserialize, FromRow)] +pub struct PackageVersion { + pub id: String, + pub package_id: String, + pub version: String, + pub entrypoint: String, + pub created_at: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CreateSpaceRequest { + pub name: String, + pub folder_id: Option, + pub template: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct UpdateSpaceRequest { + pub name: Option, + pub folder_id: Option, + pub entrypoint: Option, + pub public_role: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CreateSpaceFileRequest { + pub path: String, + pub kind: Option, + pub content: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct UpdateSpaceFileRequest { + pub path: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct PublishPackageRequest { + pub space_id: String, + pub version: Option, +} + #[derive(Debug, Serialize, Deserialize)] pub struct RegisterRequest { pub username: String, diff --git a/server/src/packages.rs b/server/src/packages.rs new file mode 100644 index 0000000..90ecb65 --- /dev/null +++ b/server/src/packages.rs @@ -0,0 +1,244 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + Json, +}; +use axum_extra::extract::cookie::SignedCookieJar; +use serde::Deserialize; +use uuid::Uuid; + +use crate::{ + models::{Package, PackageVersion, PublishPackageRequest, Space}, + spaces::decode_text_blob, + AppState, +}; + +#[derive(Deserialize)] +struct Manifest { + package: PackageMeta, +} + +#[derive(Deserialize)] +struct PackageMeta { + name: String, + version: String, + entrypoint: Option, + description: Option, +} + +fn is_valid_name(name: &str) -> bool { + !name.is_empty() + && name.len() <= 64 + && name.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_') +} + +fn is_valid_version(version: &str) -> bool { + let parts: Vec<&str> = version.split('.').collect(); + parts.len() == 3 && parts.iter().all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit())) +} + +pub async fn publish_package( + 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 space = sqlx::query_as::<_, Space>( + "SELECT id, owner_id, folder_id, name, entrypoint, thumbnail_svg, public_role, created_at, updated_at FROM spaces WHERE id = ? AND owner_id = ?" + ) + .bind(&payload.space_id) + .bind(&user_id) + .fetch_optional(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or((StatusCode::NOT_FOUND, "Space not found".to_string()))?; + + let files = sqlx::query_as::<_, (String, String, Option>)>( + "SELECT path, kind, content FROM space_files WHERE space_id = ?" + ) + .bind(&space.id) + .fetch_all(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let mut snapshot: Vec<(String, Vec)> = Vec::new(); + let mut manifest_text: Option = None; + for (path, kind, content) in files { + let bytes = if kind == "binary" { + content.unwrap_or_default() + } else { + decode_text_blob(&content.unwrap_or_default()).into_bytes() + }; + if path == "typst.toml" { + manifest_text = Some(String::from_utf8_lossy(&bytes).to_string()); + } + snapshot.push((path, bytes)); + } + + let manifest_text = manifest_text + .ok_or((StatusCode::BAD_REQUEST, "Space has no typst.toml manifest".to_string()))?; + let manifest: Manifest = toml::from_str(&manifest_text) + .map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid typst.toml: {}", e)))?; + + let name = manifest.package.name.trim().to_string(); + let version = payload.version.unwrap_or(manifest.package.version).trim().to_string(); + let entrypoint = manifest.package.entrypoint.unwrap_or_else(|| "lib.typ".to_string()); + + if !is_valid_name(&name) { + return Err((StatusCode::BAD_REQUEST, "Invalid package name (lowercase letters, digits, '-' and '_' only)".to_string())); + } + if !is_valid_version(&version) { + return Err((StatusCode::BAD_REQUEST, "Version must be in the form major.minor.patch".to_string())); + } + + let existing = sqlx::query_as::<_, Package>( + "SELECT id, owner_id, namespace, name, description, created_at FROM packages WHERE namespace = 'typstdrive' AND name = ?" + ) + .bind(&name) + .fetch_optional(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let package = match existing { + Some(pkg) => { + if pkg.owner_id != user_id { + return Err((StatusCode::FORBIDDEN, "A package with this name is owned by another user".to_string())); + } + pkg + } + None => { + let package_id = Uuid::new_v4().to_string(); + sqlx::query_as::<_, Package>( + "INSERT INTO packages (id, owner_id, namespace, name, description) VALUES (?, ?, 'typstdrive', ?, ?) RETURNING id, owner_id, namespace, name, description, created_at" + ) + .bind(&package_id) + .bind(&user_id) + .bind(&name) + .bind(&manifest.package.description) + .fetch_one(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + } + }; + + let version_exists = sqlx::query_as::<_, (String,)>( + "SELECT id FROM package_versions WHERE package_id = ? AND version = ?" + ) + .bind(&package.id) + .bind(&version) + .fetch_optional(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + if version_exists.is_some() { + return Err((StatusCode::CONFLICT, format!("Version {} already published; versions are immutable", version))); + } + + let version_id = Uuid::new_v4().to_string(); + sqlx::query( + "INSERT INTO package_versions (id, package_id, version, entrypoint, manifest) VALUES (?, ?, ?, ?, ?)" + ) + .bind(&version_id) + .bind(&package.id) + .bind(&version) + .bind(&entrypoint) + .bind(manifest_text.into_bytes()) + .execute(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + for (path, data) in snapshot { + let _ = sqlx::query( + "INSERT INTO package_files (id, version_id, path, data) VALUES (?, ?, ?, ?)" + ) + .bind(Uuid::new_v4().to_string()) + .bind(&version_id) + .bind(&path) + .bind(&data) + .execute(&state.db) + .await; + } + + Ok(Json(package)) +} + +pub async fn list_packages( + State(state): State, + jar: SignedCookieJar, +) -> Result>, (StatusCode, String)> { + jar.get("session_user_id").map(|c| c.value().to_string()) + .ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?; + + let packages = sqlx::query_as::<_, Package>( + "SELECT p.id, p.owner_id, p.namespace, p.name, p.description, p.created_at, \ + u.username as owner_name, \ + (SELECT v.version FROM package_versions v WHERE v.package_id = p.id ORDER BY v.created_at DESC LIMIT 1) as latest_version \ + FROM packages p JOIN users u ON u.id = p.owner_id \ + ORDER BY p.name ASC" + ) + .fetch_all(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(packages)) +} + +pub async fn list_versions( + State(state): State, + Path(name): Path, + jar: SignedCookieJar, +) -> Result>, (StatusCode, String)> { + jar.get("session_user_id").map(|c| c.value().to_string()) + .ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?; + + let versions = sqlx::query_as::<_, PackageVersion>( + "SELECT v.id, v.package_id, v.version, v.entrypoint, v.created_at \ + FROM package_versions v JOIN packages p ON p.id = v.package_id \ + WHERE p.namespace = 'typstdrive' AND p.name = ? ORDER BY v.created_at DESC" + ) + .bind(&name) + .fetch_all(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(versions)) +} + +pub async fn delete_package( + State(state): State, + Path(name): 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 is_admin = sqlx::query_as::<_, (i64,)>("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()))? + .map(|(a,)| a != 0) + .unwrap_or(false); + + let result = if is_admin { + sqlx::query("DELETE FROM packages WHERE namespace = 'typstdrive' AND name = ?") + .bind(&name) + .execute(&state.db) + .await + } else { + sqlx::query("DELETE FROM packages WHERE namespace = 'typstdrive' AND name = ? AND owner_id = ?") + .bind(&name) + .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, "Package not found or unauthorized".to_string())); + } + + Ok(StatusCode::NO_CONTENT) +} diff --git a/server/src/public_api.rs b/server/src/public_api.rs index fb28c21..4097f17 100644 --- a/server/src/public_api.rs +++ b/server/src/public_api.rs @@ -10,7 +10,7 @@ use sha2::{Sha256, Digest}; use std::collections::HashMap; use uuid::Uuid; -use crate::{api_keys::hash_key, AppState}; +use crate::{api_keys::hash_key, compiler::ProjectInput, AppState}; #[derive(Deserialize)] pub struct RenderRequest { @@ -214,8 +214,8 @@ pub async fn render_handler( // Compile let compiler = state.compiler.lock().await; let result = match payload.format.as_str() { - "pdf" => compiler.export_pdf(payload.code.clone(), files_map), - "png" => compiler.export_png(payload.code.clone(), files_map), + "pdf" => compiler.export_pdf(ProjectInput::single(payload.code.clone(), files_map)), + "png" => compiler.export_png(ProjectInput::single(payload.code.clone(), files_map)), _ => unreachable!(), }; drop(compiler); diff --git a/server/src/spaces.rs b/server/src/spaces.rs new file mode 100644 index 0000000..ef681e4 --- /dev/null +++ b/server/src/spaces.rs @@ -0,0 +1,555 @@ +use axum::{ + extract::{Path, Query, State, Multipart}, + http::{header, StatusCode}, + response::IntoResponse, + Json, +}; +use axum_extra::extract::cookie::SignedCookieJar; +use std::collections::HashMap; +use uuid::Uuid; +use yrs::{Doc, GetString, ReadTxn, StateVector, Text, Transact}; +use yrs::updates::decoder::Decode; +use yrs::Update; + +use crate::{ + compiler::ProjectInput, + models::{ + CreateSpaceFileRequest, CreateSpaceRequest, Space, SpaceFile, UpdateSpaceFileRequest, + UpdateSpaceRequest, + }, + AppState, +}; + +const TEXT_NAME: &str = "typst"; + +pub fn encode_text_blob(text: &str) -> Vec { + let doc = Doc::new(); + let handle = doc.get_or_insert_text(TEXT_NAME); + handle.insert(&mut doc.transact_mut(), 0, text); + let bytes = doc.transact().encode_state_as_update_v1(&StateVector::default()); + bytes +} + +pub fn decode_text_blob(blob: &[u8]) -> String { + let doc = Doc::new(); + if let Ok(update) = Update::decode_v1(blob) { + doc.transact_mut().apply_update(update); + } + let handle = doc.get_or_insert_text(TEXT_NAME); + let text = handle.get_string(&doc.transact()); + text +} + +fn is_text_path(path: &str) -> bool { + let lower = path.to_lowercase(); + [".typ", ".toml", ".bib", ".csl", ".yml", ".yaml", ".json", ".md", ".txt", ".csv"] + .iter() + .any(|ext| lower.ends_with(ext)) +} + +fn default_manifest(name: &str) -> String { + format!( + "[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nentrypoint = \"main.typ\"\nauthors = [\"Anonymous\"]\nlicense = \"MIT\"\ndescription = \"\"\n" + ) +} + +fn slugify(name: &str) -> String { + let slug: String = name + .to_lowercase() + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) + .collect(); + let trimmed = slug.trim_matches('-').replace("--", "-"); + if trimmed.is_empty() { + "my-space".to_string() + } else { + trimmed + } +} + +pub async fn space_role( + state: &AppState, + space_id: &str, + user_id_opt: &Option, +) -> Option<(Space, String)> { + let space = sqlx::query_as::<_, Space>( + "SELECT id, owner_id, folder_id, name, entrypoint, thumbnail_svg, public_role, created_at, updated_at FROM spaces WHERE id = ?" + ) + .bind(space_id) + .fetch_optional(&state.db) + .await + .ok()??; + + if let Some(uid) = user_id_opt { + if &space.owner_id == uid { + return Some((space, "owner".to_string())); + } + if let Ok(Some((role,))) = sqlx::query_as::<_, (String,)>( + "SELECT role FROM space_collaborators WHERE space_id = ? AND user_id = ?", + ) + .bind(space_id) + .bind(uid) + .fetch_optional(&state.db) + .await + { + return Some((space, role)); + } + } + + if let Some(pr) = space.public_role.clone() { + if pr == "viewer" || pr == "editor" { + return Some((space, pr)); + } + } + + None +} + +pub async fn load_local_packages(state: &AppState) -> HashMap>> { + let mut packages: HashMap>> = HashMap::new(); + + let rows = sqlx::query_as::<_, (String, String, String, Vec)>( + "SELECT p.name, v.version, f.path, f.data \ + FROM package_files f \ + JOIN package_versions v ON v.id = f.version_id \ + JOIN packages p ON p.id = v.package_id", + ) + .fetch_all(&state.db) + .await + .unwrap_or_default(); + + for (name, version, path, data) in rows { + let key = format!("{}:{}", name, version); + packages.entry(key).or_default().insert(path, data); + } + + packages +} + +pub async fn assemble_project( + state: &AppState, + space: &Space, + overrides: HashMap, +) -> ProjectInput { + let mut files: HashMap> = HashMap::new(); + + // Account-level uploaded files (fonts, images) come first as a base layer so + // they are available inside spaces; space files below override them by name. + if let Ok(account_files) = sqlx::query_as::<_, (String, Vec)>( + "SELECT name, data FROM files WHERE owner_id = ?", + ) + .bind(&space.owner_id) + .fetch_all(&state.db) + .await + { + for (name, data) in account_files { + files.insert(name, data); + } + } + + let rows = sqlx::query_as::<_, (String, String, Option>)>( + "SELECT path, kind, content FROM space_files WHERE space_id = ?", + ) + .bind(&space.id) + .fetch_all(&state.db) + .await + .unwrap_or_default(); + + for (path, kind, content) in rows { + if let Some(live) = overrides.get(&path) { + files.insert(path, live.clone().into_bytes()); + } else if kind == "binary" { + files.insert(path, content.unwrap_or_default()); + } else { + files.insert(path, decode_text_blob(&content.unwrap_or_default()).into_bytes()); + } + } + + for (path, content) in overrides { + files.entry(path).or_insert_with(|| content.into_bytes()); + } + + ProjectInput { + entrypoint: space.entrypoint.clone(), + files, + packages: load_local_packages(state).await, + } +} + +#[derive(serde::Deserialize)] +pub struct ListSpacesQuery { + pub folder_id: Option, +} + +pub async fn list_spaces( + Query(query): 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 spaces = if let Some(folder_id) = query.folder_id { + sqlx::query_as::<_, Space>( + "SELECT id, owner_id, folder_id, name, entrypoint, thumbnail_svg, public_role, created_at, updated_at FROM spaces WHERE owner_id = ? AND folder_id = ? ORDER BY updated_at DESC" + ) + .bind(&user_id) + .bind(&folder_id) + .fetch_all(&state.db) + .await + } else { + sqlx::query_as::<_, Space>( + "SELECT id, owner_id, folder_id, name, entrypoint, thumbnail_svg, public_role, created_at, updated_at FROM spaces 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(spaces)) +} + +pub async fn list_shared_spaces( + 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 spaces = sqlx::query_as::<_, Space>( + "SELECT s.id, s.owner_id, s.folder_id, s.name, s.entrypoint, s.thumbnail_svg, \ + s.public_role, s.created_at, s.updated_at, c.role as effective_role \ + FROM spaces s \ + INNER JOIN space_collaborators c ON c.space_id = s.id AND c.user_id = ? \ + ORDER BY s.updated_at DESC" + ) + .bind(&user_id) + .fetch_all(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(spaces)) +} + +pub async fn create_space( + 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 space_id = Uuid::new_v4().to_string(); + + let space = sqlx::query_as::<_, Space>( + "INSERT INTO spaces (id, owner_id, folder_id, name, entrypoint) VALUES (?, ?, ?, ?, 'main.typ') RETURNING id, owner_id, folder_id, name, entrypoint, thumbnail_svg, public_role, created_at, updated_at" + ) + .bind(&space_id) + .bind(&user_id) + .bind(&payload.folder_id) + .bind(&payload.name) + .fetch_one(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let seeds = [ + ("typst.toml", default_manifest(&slugify(&payload.name))), + ("main.typ", "= New Space\n\nStart writing here.\n".to_string()), + ]; + for (path, content) in seeds { + let _ = sqlx::query( + "INSERT INTO space_files (id, space_id, path, kind, content, mime_type) VALUES (?, ?, ?, 'text', ?, 'text/plain')" + ) + .bind(Uuid::new_v4().to_string()) + .bind(&space_id) + .bind(path) + .bind(encode_text_blob(&content)) + .execute(&state.db) + .await; + } + + Ok(Json(space)) +} + +pub async fn get_space( + State(state): State, + Path(id): Path, + jar: SignedCookieJar, +) -> Result, (StatusCode, String)> { + let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string()); + + let (mut space, role) = space_role(&state, &id, &user_id_opt) + .await + .ok_or((StatusCode::UNAUTHORIZED, "Unauthorized".to_string()))?; + + space.effective_role = Some(role); + Ok(Json(space)) +} + +pub async fn update_space( + 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 space = sqlx::query_as::<_, Space>( + "SELECT id, owner_id, folder_id, name, entrypoint, thumbnail_svg, public_role, created_at, updated_at FROM spaces 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, "Space not found".to_string()))?; + + if let Some(name) = payload.name { + space.name = name; + } + if let Some(entrypoint) = payload.entrypoint { + space.entrypoint = entrypoint; + } + if let Some(folder_id) = payload.folder_id { + space.folder_id = if folder_id.is_empty() { None } else { Some(folder_id) }; + } + if let Some(public_role) = payload.public_role { + space.public_role = if public_role == "none" || public_role.is_empty() { + None + } else { + Some(public_role) + }; + } + + let space = sqlx::query_as::<_, Space>( + "UPDATE spaces SET name = ?, entrypoint = ?, folder_id = ?, public_role = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND owner_id = ? RETURNING id, owner_id, folder_id, name, entrypoint, thumbnail_svg, public_role, created_at, updated_at" + ) + .bind(&space.name) + .bind(&space.entrypoint) + .bind(&space.folder_id) + .bind(&space.public_role) + .bind(&id) + .bind(&user_id) + .fetch_one(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(space)) +} + +pub async fn delete_space( + 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 _ = sqlx::query("DELETE FROM space_files WHERE space_id = ?") + .bind(&id) + .execute(&state.db) + .await; + + let result = sqlx::query("DELETE FROM spaces 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, "Space not found or unauthorized".to_string())); + } + + Ok(StatusCode::NO_CONTENT) +} + +pub async fn list_space_files( + State(state): State, + Path(id): Path, + jar: SignedCookieJar, +) -> Result>, (StatusCode, String)> { + let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string()); + space_role(&state, &id, &user_id_opt) + .await + .ok_or((StatusCode::UNAUTHORIZED, "Unauthorized".to_string()))?; + + let files = sqlx::query_as::<_, SpaceFile>( + "SELECT id, space_id, path, kind, mime_type, created_at FROM space_files WHERE space_id = ? ORDER BY path ASC" + ) + .bind(&id) + .fetch_all(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(files)) +} + +pub async fn create_space_file( + State(state): State, + Path(id): Path, + jar: SignedCookieJar, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string()); + let (_, role) = space_role(&state, &id, &user_id_opt) + .await + .ok_or((StatusCode::UNAUTHORIZED, "Unauthorized".to_string()))?; + if role == "viewer" { + return Err((StatusCode::FORBIDDEN, "Read-only access".to_string())); + } + + let kind = payload.kind.unwrap_or_else(|| "text".to_string()); + let content = payload.content.unwrap_or_default(); + let file_id = Uuid::new_v4().to_string(); + + let file = sqlx::query_as::<_, SpaceFile>( + "INSERT INTO space_files (id, space_id, path, kind, content, mime_type) VALUES (?, ?, ?, ?, ?, 'text/plain') RETURNING id, space_id, path, kind, mime_type, created_at" + ) + .bind(&file_id) + .bind(&id) + .bind(&payload.path) + .bind(&kind) + .bind(encode_text_blob(&content)) + .fetch_one(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(file)) +} + +pub async fn upload_space_file( + State(state): State, + Path(id): Path, + jar: SignedCookieJar, + mut multipart: Multipart, +) -> Result, (StatusCode, String)> { + let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string()); + let (_, role) = space_role(&state, &id, &user_id_opt) + .await + .ok_or((StatusCode::UNAUTHORIZED, "Unauthorized".to_string()))?; + if role == "viewer" { + return Err((StatusCode::FORBIDDEN, "Read-only access".to_string())); + } + + let mut uploaded = vec![]; + + while let Some(field) = multipart.next_field().await.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))? { + let path = field.file_name().unwrap_or("unnamed").to_string(); + let mime_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 (kind, content) = if is_text_path(&path) { + let text = String::from_utf8_lossy(&data).to_string(); + ("text", encode_text_blob(&text)) + } else { + ("binary", data) + }; + + let _ = sqlx::query( + "INSERT INTO space_files (id, space_id, path, kind, content, mime_type) VALUES (?, ?, ?, ?, ?, ?) \ + ON CONFLICT (space_id, path) DO UPDATE SET content = excluded.content, kind = excluded.kind, mime_type = excluded.mime_type" + ) + .bind(Uuid::new_v4().to_string()) + .bind(&id) + .bind(&path) + .bind(kind) + .bind(content) + .bind(&mime_type) + .execute(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + uploaded.push(path); + } + + Ok(Json(serde_json::json!({ "files": uploaded }))) +} + +pub async fn get_space_file( + State(state): State, + Path((id, file_id)): Path<(String, String)>, + jar: SignedCookieJar, +) -> Result { + let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string()); + space_role(&state, &id, &user_id_opt) + .await + .ok_or((StatusCode::UNAUTHORIZED, "Unauthorized".to_string()))?; + + let file = sqlx::query_as::<_, (String, String, Option>)>( + "SELECT kind, mime_type, content FROM space_files WHERE id = ? AND space_id = ?" + ) + .bind(&file_id) + .bind(&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()))?; + + let (kind, mime_type, content) = file; + let bytes = content.unwrap_or_default(); + + if kind == "binary" { + Ok(([(header::CONTENT_TYPE, mime_type)], bytes)) + } else { + Ok(([(header::CONTENT_TYPE, "text/plain".to_string())], decode_text_blob(&bytes).into_bytes())) + } +} + +pub async fn update_space_file( + State(state): State, + Path((id, file_id)): Path<(String, String)>, + jar: SignedCookieJar, + Json(payload): Json, +) -> Result { + let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string()); + let (_, role) = space_role(&state, &id, &user_id_opt) + .await + .ok_or((StatusCode::UNAUTHORIZED, "Unauthorized".to_string()))?; + if role == "viewer" { + return Err((StatusCode::FORBIDDEN, "Read-only access".to_string())); + } + + let result = sqlx::query("UPDATE space_files SET path = ? WHERE id = ? AND space_id = ?") + .bind(&payload.path) + .bind(&file_id) + .bind(&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".to_string())); + } + + Ok(StatusCode::NO_CONTENT) +} + +pub async fn delete_space_file( + State(state): State, + Path((id, file_id)): Path<(String, String)>, + jar: SignedCookieJar, +) -> Result { + let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string()); + let (_, role) = space_role(&state, &id, &user_id_opt) + .await + .ok_or((StatusCode::UNAUTHORIZED, "Unauthorized".to_string()))?; + if role == "viewer" { + return Err((StatusCode::FORBIDDEN, "Read-only access".to_string())); + } + + let result = sqlx::query("DELETE FROM space_files WHERE id = ? AND space_id = ?") + .bind(&file_id) + .bind(&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".to_string())); + } + + Ok(StatusCode::NO_CONTENT) +} diff --git a/server/src/world.rs b/server/src/world.rs index 1f41f82..af182b5 100644 --- a/server/src/world.rs +++ b/server/src/world.rs @@ -13,20 +13,29 @@ use typst_kit::packages::SystemPackages; pub struct MemoryWorld { library: typst::utils::LazyHash, main: FileId, - source: Source, files: HashMap>, + local_packages: HashMap>>, book: typst::utils::LazyHash, fonts: Vec, packages: SystemPackages, } +const LOCAL_NAMESPACE: &str = "typstdrive"; + +fn normalize_path(path: &str) -> String { + path.trim_start_matches('/').replace('\\', "/") +} + impl MemoryWorld { - pub fn new(text: String, files: HashMap>) -> Self { + pub fn new_project( + entrypoint: String, + files: HashMap>, + local_packages: HashMap>>, + ) -> Self { let main = FileId::new(RootedPath::new( VirtualRoot::Project, - VirtualPath::new("main.typ").unwrap(), + VirtualPath::new(&entrypoint).unwrap_or_else(|_| VirtualPath::new("main.typ").unwrap()), )); - let source = Source::new(main, text); let downloader = SystemDownloader::new("TypstDrive (typst-kit)"); let packages = SystemPackages::new(downloader); @@ -56,13 +65,40 @@ impl MemoryWorld { Self { library: typst::utils::LazyHash::new(Library::builder().build()), main, - source, files, + local_packages, book: typst::utils::LazyHash::new(book), fonts, packages, } } + + fn load_bytes(&self, id: FileId) -> FileResult> { + let path = normalize_path(id.vpath().get_without_slash()); + + if let VirtualRoot::Package(package) = id.root() { + if package.namespace.as_str() == LOCAL_NAMESPACE { + let key = format!("{}:{}", package.name, package.version); + return self + .local_packages + .get(&key) + .and_then(|files| files.get(&path)) + .cloned() + .ok_or_else(|| FileError::NotFound(path.clone().into())); + } + + let root = self + .packages + .obtain(package) + .map_err(|e| FileError::Other(Some(e.to_string().into())))?; + return root.load(id.vpath()).map(|bytes| bytes.to_vec()); + } + + self.files + .get(&path) + .cloned() + .ok_or_else(|| FileError::NotFound(path.into())) + } } impl World for MemoryWorld { @@ -79,42 +115,16 @@ impl World for MemoryWorld { } fn source(&self, id: FileId) -> FileResult { - if id == self.main { - Ok(self.source.clone()) - } else if let 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 = std::str::from_utf8(&data) - .map_err(|_| FileError::InvalidUtf8)? - .to_owned(); - Ok(Source::new(id, text)) - } else { - Err(FileError::NotFound(id.vpath().get_without_slash().into())) - } + let data = self.load_bytes(id)?; + let text = std::str::from_utf8(&data) + .map_err(|_| FileError::InvalidUtf8)? + .to_owned(); + Ok(Source::new(id, text)) } fn file(&self, id: FileId) -> FileResult { - if id == self.main { - Ok(Bytes::from_string(self.source.text().to_string())) - } else if let 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() - .to_string() - .replace("\\", "/"), - ) { - Ok(Bytes::new(data.clone())) - } else { - Err(FileError::NotFound(id.vpath().get_without_slash().into())) - } + let data = self.load_bytes(id)?; + Ok(Bytes::new(data)) } fn font(&self, index: usize) -> Option { diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index ecf6fe2..1a322e5 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -5,7 +5,8 @@ import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'; import { autocompletion, snippetCompletion, type CompletionContext } from '@codemirror/autocomplete'; import { typst, TypstParser, typstHighlight } from 'codemirror-lang-typst'; - import { Language } from '@codemirror/language'; + import { Language, StreamLanguage } from '@codemirror/language'; + import { toml } from '@codemirror/legacy-modes/mode/toml'; import { yCollab } from 'y-codemirror.next'; import { text, provider } from '../ts/yjs-setup'; import { getThemeExtension } from '../ts/themes'; @@ -14,6 +15,20 @@ import { LSPClient, languageServerExtensions } from "@codemirror/lsp-client"; import { setDiagnostics, lintGutter } from '@codemirror/lint'; + let { + ytext = undefined, + awarenessProvider = undefined, + lspDocId = undefined, + enableLsp = true, + filePath = undefined + }: { + ytext?: any; + awarenessProvider?: any; + lspDocId?: string; + enableLsp?: boolean; + filePath?: string; + } = $props(); + let editorContainer: HTMLElement; let view: EditorView; let themeCompartment = new Compartment(); @@ -136,7 +151,9 @@ } onMount(() => { - if (!text || !provider) return; + const activeText = ytext ?? text; + const activeProvider = awarenessProvider ?? provider; + if (!activeText || !activeProvider) return; themeStore.subscribe(t => { currentTheme = t; })(); darkModeStore.subscribe(d => { isDark = d; })(); @@ -150,16 +167,20 @@ 'typst' ); + const isToml = (filePath ?? '').toLowerCase().endsWith('.toml'); + const languageExtension = isToml ? StreamLanguage.define(toml) : myLang; + const completionExtensions = isToml ? [] : [autocompletion({ override: [typstCompletions] })]; + state = EditorState.create({ - doc: text.toString(), + doc: activeText.toString(), extensions: [ lineNumbers(), lintGutter(), history(), keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab] as any), - myLang, - yCollab(text, provider.awareness), - autocompletion({ override: [typstCompletions] }), + languageExtension, + yCollab(activeText, activeProvider.awareness), + ...completionExtensions, themeCompartment.of(getThemeExtension(currentTheme as any, isDark)), lspCompartment.of([]), EditorView.lineWrapping, @@ -219,7 +240,7 @@ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const host = window.location.host; - const docId = $page.params.id; + const docId = lspDocId ?? $page.params.id; let lsHandlers: ((value: string) => void)[] = []; let lspInitialized = false; @@ -279,13 +300,15 @@ lsSocket.onopen = () => {}; } - connectLsp(); - - unsubscribeLspReconnect = triggerLspReconnect.subscribe((val) => { - if (val > 0) { - connectLsp(); - } - }); + if (enableLsp && docId) { + connectLsp(); + + unsubscribeLspReconnect = triggerLspReconnect.subscribe((val) => { + if (val > 0) { + connectLsp(); + } + }); + } }); onDestroy(() => { diff --git a/src/lib/components/PublishPackageModal.svelte b/src/lib/components/PublishPackageModal.svelte new file mode 100644 index 0000000..2e83f84 --- /dev/null +++ b/src/lib/components/PublishPackageModal.svelte @@ -0,0 +1,76 @@ + + + diff --git a/src/lib/components/dashboard/CreateSpaceModal.svelte b/src/lib/components/dashboard/CreateSpaceModal.svelte new file mode 100644 index 0000000..cfd73d1 --- /dev/null +++ b/src/lib/components/dashboard/CreateSpaceModal.svelte @@ -0,0 +1,53 @@ + + + diff --git a/src/lib/components/dashboard/Navbar.svelte b/src/lib/components/dashboard/Navbar.svelte index ce9dd8e..02a3f45 100644 --- a/src/lib/components/dashboard/Navbar.svelte +++ b/src/lib/components/dashboard/Navbar.svelte @@ -23,7 +23,14 @@
- + + + + + + + + diff --git a/src/lib/components/dashboard/SpaceCard.svelte b/src/lib/components/dashboard/SpaceCard.svelte new file mode 100644 index 0000000..74ad9c1 --- /dev/null +++ b/src/lib/components/dashboard/SpaceCard.svelte @@ -0,0 +1,85 @@ + + +
goto(`/space/${space.id}`)} + onkeydown={(e) => e.key === 'Enter' && goto(`/space/${space.id}`)} +> +
+ {#if space.thumbnail_svg} +
+ Thumbnail +
+ {:else} +
+ +
+ {/if} +
+ +
+
+

{space.name}

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

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

+
+
diff --git a/src/lib/components/space/FileTree.svelte b/src/lib/components/space/FileTree.svelte new file mode 100644 index 0000000..75379a0 --- /dev/null +++ b/src/lib/components/space/FileTree.svelte @@ -0,0 +1,101 @@ + + +
+
+ Files + {#if !readOnly} +
+ + + { const t = e.target as HTMLInputElement; if (t.files) onUpload(t.files); t.value = ''; }} /> +
+ {/if} +
+ +
+ {#each files as file (file.id)} +
+ + {#if !readOnly} +
+ {#if file.kind === 'text' && file.path.toLowerCase().endsWith('.typ') && file.path !== entrypoint} + + {/if} + + +
+ {/if} +
+ {/each} +
+
diff --git a/src/lib/components/space/SpaceToolbar.svelte b/src/lib/components/space/SpaceToolbar.svelte new file mode 100644 index 0000000..a0cf104 --- /dev/null +++ b/src/lib/components/space/SpaceToolbar.svelte @@ -0,0 +1,523 @@ + + + + +
+
+
+ + +
+
+ +

{spaceName}

+
+ +
+
+ + {#if activeMenu === 'file'} +
+ + + {#if !isViewer} +
+ + + {#if role === 'owner'} + + {/if} + {/if} +
+
Download
+ + + + + +
+
Export (Pandoc)
+ + + + + {#if role === 'owner'} +
+ + {/if} +
+ {/if} +
+ + {#if !isViewer} +
+ + {#if activeMenu === 'edit'} +
+ + +
+ {/if} +
+ {/if} + +
+ + {#if activeMenu === 'view'} +
+ + +
+ {/if} +
+
+
+
+ +
+ {#if $connectedUsers.length > 0} +
+ {#each $connectedUsers as user} +
{getInitials(user.name)}
+ {/each} +
+ {/if} + +
+ + {#if !isViewer} + + {#if role === 'owner'} + + {/if} + {/if} + +
+ + + + + +
+ + {#if activeMenu === 'export'} +
+ + + + +
+ {/if} +
+
+
+ +
+
+ + + +
+ + + +
+ + {#if !isViewer} + + {/if} +
+ +
+ +
+ + +
+ +
+ + {#if !isViewer} + +
+ {/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 isPageSettingsOpen} + (isPageSettingsOpen = false)} onApply={handlePageSettings} currentSettings={parseSettings()} /> +{/if} + +{#if isPresentationOpen} + (isPresentationOpen = false)} /> +{/if} + +{#if showInfoModal} +
showInfoModal = false} role="presentation"> +
e.stopPropagation()} role="presentation"> +
+ +

{spaceName}

+
+
+

Type

Space (multi-file)

+

Entrypoint

{entrypoint}

+

Your role

{role}

+
+
+ +
+
+
+{/if} + +{#if showRenameModal} +
showRenameModal = false} role="presentation"> +
e.stopPropagation()} role="presentation"> +
+

Rename Space

+ +
+ + +
+
+
+
+{/if} + +{#if showDeleteModal} +
showDeleteModal = false} role="presentation"> +
e.stopPropagation()} role="presentation"> +
+

Delete Space

+

Delete this space and all its files? This cannot be undone.

+
+ + +
+
+
+
+{/if} diff --git a/src/lib/ts/typst-api.ts b/src/lib/ts/typst-api.ts index 0c4405d..f35e4d4 100644 --- a/src/lib/ts/typst-api.ts +++ b/src/lib/ts/typst-api.ts @@ -20,6 +20,35 @@ export async function compileTypst(text: string, document_id?: string): Promise< return await res.json(); } +export async function compileSpace(space_id: string, files: Record): Promise { + const res = await fetch('/api/compile', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ space_id, files }), + }); + return await res.json(); +} + +export function exportSpace(space_id: string, files: Record, format: 'pdf' | 'png' | 'svg', title: string = 'document') { + return fetch(`/api/export/${format}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ space_id, files }), + }) + .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); + }); +} + export function exportTypst(text: string, format: 'pdf' | 'png' | 'svg', title: string = 'document', document_id?: string) { const form = document.createElement('form'); form.method = 'POST'; diff --git a/src/lib/ts/yjs-space.ts b/src/lib/ts/yjs-space.ts new file mode 100644 index 0000000..4bb5468 --- /dev/null +++ b/src/lib/ts/yjs-space.ts @@ -0,0 +1,114 @@ +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 interface OpenFile { + fileId: string; + path: string; + doc: Y.Doc; + text: Y.Text; + provider: WebsocketProvider; +} + +const userColors = [ + '#30bced', '#6eeb83', '#ffbc42', '#ecd444', '#ee6352', + '#9ac2c9', '#8acb88', '#1be7ff', '#ff0054', '#9e0059' +]; + +const open = new Map(); +let spaceId: string | null = null; + +const TEXT_NAME = 'typst'; + +export function setSpace(id: string) { + spaceId = id; +} + +export function openFile(fileId: string, path: string): OpenFile { + const existing = open.get(fileId); + if (existing) return existing; + if (!spaceId) throw new Error('Space not set'); + + const doc = new Y.Doc(); + const text = doc.getText(TEXT_NAME); + + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const host = window.location.host; + + connectionStatus.set('connecting'); + + const provider = new WebsocketProvider(`${protocol}//${host}/yjs`, `space:${spaceId}:${fileId}`, doc); + + const user = get(userStore); + const color = userColors[Math.floor(Math.random() * userColors.length)]; + provider.awareness.setLocalStateField('user', { + name: user?.username || 'Anonymous', + color, + colorLight: color + '33' + }); + + provider.on('status', (event: { status: string }) => { + connectionStatus.set(event.status); + }); + + provider.awareness.on('change', () => { + 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())); + }); + + const entry: OpenFile = { fileId, path, doc, text, provider }; + open.set(fileId, entry); + return entry; +} + +export function getOpenFile(fileId: string): OpenFile | undefined { + return open.get(fileId); +} + +export function renameOpenFile(fileId: string, path: string) { + const entry = open.get(fileId); + if (entry) entry.path = path; +} + +export function closeFile(fileId: string) { + const entry = open.get(fileId); + if (entry) { + entry.provider.disconnect(); + entry.provider.destroy(); + entry.doc.destroy(); + open.delete(fileId); + } +} + +export function getAllText(): Record { + const result: Record = {}; + for (const entry of open.values()) { + result[entry.path] = entry.text.toString(); + } + return result; +} + +export function cleanupSpace() { + for (const fileId of Array.from(open.keys())) { + closeFile(fileId); + } + spaceId = null; + connectionStatus.set('disconnected'); + connectedUsers.set([]); +} diff --git a/src/routes/dashboard/+page.svelte b/src/routes/dashboard/+page.svelte index 15657a0..d889d11 100644 --- a/src/routes/dashboard/+page.svelte +++ b/src/routes/dashboard/+page.svelte @@ -14,6 +14,7 @@ import InfoModal from '$lib/components/dashboard/InfoModal.svelte'; import RenameModal from '$lib/components/dashboard/RenameModal.svelte'; import CreateDocModal from '$lib/components/dashboard/CreateDocModal.svelte'; + import CreateSpaceModal from '$lib/components/dashboard/CreateSpaceModal.svelte'; import CreateFolderModal from '$lib/components/dashboard/CreateFolderModal.svelte'; import Footer from '$lib/components/Footer.svelte'; @@ -26,6 +27,7 @@ let newFolderName = $state(''); let loading = $state(true); let showCreateModal = $state(false); + let showCreateSpaceModal = $state(false); let newDocTitle = $state(''); let showPlusDropdown = $state(false); let dragOverFolderId = $state(null); @@ -190,7 +192,7 @@ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: title.trim(), folder_id: currentFolderId || undefined }) }); - + if (res.ok) { const doc = await res.json(); showCreateModal = false; @@ -198,6 +200,27 @@ } } + function openCreateSpaceModal() { + showPlusDropdown = false; + showCreateSpaceModal = true; + } + + async function createSpace(name: string) { + if (!name.trim()) return; + + const res = await fetch('/api/spaces', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: name.trim(), folder_id: currentFolderId || undefined }) + }); + + if (res.ok) { + const space = await res.json(); + showCreateSpaceModal = false; + goto(`/space/${space.id}`); + } + } + async function handleImportUpload(e: Event) { const target = e.target as HTMLInputElement; if (!target.files || target.files.length === 0) return; @@ -414,6 +437,10 @@ New Document + +

+ + Packages +

+

+ Instance-local Typst packages, published from Spaces and importable as + @typstdrive/<name>:<version>. +

+ + + {#if loading} +

Loading…

+ {:else if packages.length === 0} +
+ +

No packages published yet. Open a Space and use “Publish” to create one.

+
+ {:else} +
+ {#each packages as pkg (pkg.id)} +
+
+
+

@typstdrive/{pkg.name}

+ {#if pkg.latest_version} + v{pkg.latest_version} + {/if} +
+ {#if pkg.description} +

{pkg.description}

+ {/if} +

by {pkg.owner_name ?? 'unknown'}

+
{importSnippet(pkg)}
+
+
+ + +
+
+ {/each} +
+ {/if} + + diff --git a/src/routes/space/[id]/+page.svelte b/src/routes/space/[id]/+page.svelte new file mode 100644 index 0000000..be6c470 --- /dev/null +++ b/src/routes/space/[id]/+page.svelte @@ -0,0 +1,258 @@ + + + + {spaceName} - TypstDrive + + + + +
+ (showPublish = true)} + onFilesChanged={loadFiles} + /> + +
+ + + {#if !readOnly} +
+ {#if ready && activeEntry} + {#key activeFileId} + + {/key} + {/if} +
+ {/if} + + {#if $previewOpenStore || readOnly} +
+ + +
+ {/if} +
+ + +
+ +{#if contextMenu.show} +
+ +
+{/if} + +{#if showPublish} + (showPublish = false)} /> +{/if} diff --git a/src/routes/spaces/+page.svelte b/src/routes/spaces/+page.svelte new file mode 100644 index 0000000..7613f3d --- /dev/null +++ b/src/routes/spaces/+page.svelte @@ -0,0 +1,207 @@ + + + + Spaces - TypstDrive + + + + +
+ + +
+
+
+ +

+ + Spaces +

+

Multi-file Typst workspaces with their own typst.toml.

+
+ +
+ + {#if loading} +

Loading…

+ {:else} + {#if spaces.length === 0} +
+ +

No spaces yet. Create one to start a multi-file project.

+
+ {:else} +
+ {#each spaces as space (space.id)} + + {/each} +
+ {/if} + + {#if shared.length > 0} +

+ Shared with me +

+
+ {#each shared as space (space.id)} + + {/each} +
+ {/if} + {/if} +
+
+ +{#if showCreate} +
(showCreate = false)} role="presentation"> +
e.stopPropagation()} role="presentation"> +

New Space

+ e.key === 'Enter' && create()} class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-white/10 bg-transparent text-sm mb-4 focus:outline-none focus:ring-2 focus:ring-blue-500/40" /> +
+ + +
+
+
+{/if} + +{#if showRename} +
(showRename = false)} role="presentation"> +
e.stopPropagation()}> +

Rename Space

+ +
+ + +
+
+
+{/if} + +{#if showInfo && infoSpace} +
(showInfo = false)} role="presentation"> +
e.stopPropagation()} role="presentation"> +
+ +

{infoSpace.name}

+
+
+

Entrypoint

{infoSpace.entrypoint}

+

Last Modified

{new Date(infoSpace.updated_at.endsWith('Z') ? infoSpace.updated_at : infoSpace.updated_at + 'Z').toLocaleString()}

+
+
+ +
+
+
+{/if}