Added Typst Spaces and Internal Packages
This commit is contained in:
@@ -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/<name>:<version>` (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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -35,3 +35,4 @@ tokio-stream = "0.1.18"
|
||||
tempfile = "3.27.0"
|
||||
sha2 = "0.10"
|
||||
base64 = "0.22"
|
||||
toml = "0.8"
|
||||
|
||||
+28
-9
@@ -50,6 +50,28 @@ fn extract_frame_text(frame: &Frame, text: &mut String) {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ProjectInput {
|
||||
pub entrypoint: String,
|
||||
pub files: HashMap<String, Vec<u8>>,
|
||||
pub packages: HashMap<String, HashMap<String, Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl ProjectInput {
|
||||
pub fn single(text: String, files: HashMap<String, Vec<u8>>) -> 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<String, Vec<u8>>,
|
||||
input: ProjectInput,
|
||||
) -> Result<
|
||||
(Vec<String>, String, DocumentStats),
|
||||
Vec<(SourceDiagnostic, Option<std::ops::Range<usize>>)>,
|
||||
> {
|
||||
let world = MemoryWorld::new(text, files);
|
||||
let world = input.into_world();
|
||||
match typst::compile::<PagedDocument>(&world) {
|
||||
Warned {
|
||||
output: Ok(doc),
|
||||
@@ -104,10 +125,9 @@ impl TypstCompiler {
|
||||
|
||||
pub fn export_pdf(
|
||||
&self,
|
||||
text: String,
|
||||
files: HashMap<String, Vec<u8>>,
|
||||
input: ProjectInput,
|
||||
) -> Result<Vec<u8>, Vec<(SourceDiagnostic, Option<std::ops::Range<usize>>)>> {
|
||||
let world = MemoryWorld::new(text, files);
|
||||
let world = input.into_world();
|
||||
match typst::compile::<PagedDocument>(&world) {
|
||||
Warned {
|
||||
output: Ok(doc),
|
||||
@@ -137,10 +157,9 @@ impl TypstCompiler {
|
||||
|
||||
pub fn export_png(
|
||||
&self,
|
||||
text: String,
|
||||
files: HashMap<String, Vec<u8>>,
|
||||
input: ProjectInput,
|
||||
) -> Result<Vec<u8>, Vec<(SourceDiagnostic, Option<std::ops::Range<usize>>)>> {
|
||||
let world = MemoryWorld::new(text, files);
|
||||
let world = input.into_world();
|
||||
match typst::compile::<PagedDocument>(&world) {
|
||||
Warned {
|
||||
output: Ok(doc),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+154
-47
@@ -50,11 +50,29 @@ impl Stream for ViewerFilterStream {
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CompileRequest {
|
||||
pub text: String,
|
||||
#[serde(default)]
|
||||
pub text: Option<String>,
|
||||
pub document_id: Option<String>,
|
||||
pub space_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub files: Option<std::collections::HashMap<String, String>>,
|
||||
}
|
||||
|
||||
use crate::compiler::DocumentStats;
|
||||
use crate::compiler::{DocumentStats, ProjectInput};
|
||||
|
||||
fn map_diagnostics(
|
||||
diags: Vec<(typst::diag::SourceDiagnostic, Option<std::ops::Range<usize>>)>,
|
||||
) -> Vec<Diagnostic> {
|
||||
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<Vec<u8>> = 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<Vec<u8>>,)>(
|
||||
"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;
|
||||
|
||||
+12
-1
@@ -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));
|
||||
|
||||
@@ -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<String>,
|
||||
pub name: String,
|
||||
pub entrypoint: String,
|
||||
pub thumbnail_svg: Option<String>,
|
||||
pub public_role: Option<String>,
|
||||
#[serde(default)]
|
||||
#[sqlx(default)]
|
||||
pub effective_role: Option<String>,
|
||||
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<Vec<u8>>,
|
||||
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<String>,
|
||||
pub created_at: String,
|
||||
#[serde(default)]
|
||||
#[sqlx(default)]
|
||||
pub owner_name: Option<String>,
|
||||
#[serde(default)]
|
||||
#[sqlx(default)]
|
||||
pub latest_version: Option<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
pub template: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct UpdateSpaceRequest {
|
||||
pub name: Option<String>,
|
||||
pub folder_id: Option<String>,
|
||||
pub entrypoint: Option<String>,
|
||||
pub public_role: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct CreateSpaceFileRequest {
|
||||
pub path: String,
|
||||
pub kind: Option<String>,
|
||||
pub content: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct UpdateSpaceFileRequest {
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct PublishPackageRequest {
|
||||
pub space_id: String,
|
||||
pub version: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct RegisterRequest {
|
||||
pub username: String,
|
||||
|
||||
@@ -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<String>,
|
||||
description: Option<String>,
|
||||
}
|
||||
|
||||
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<AppState>,
|
||||
jar: SignedCookieJar,
|
||||
Json(payload): Json<PublishPackageRequest>,
|
||||
) -> Result<Json<Package>, (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<Vec<u8>>)>(
|
||||
"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<u8>)> = Vec::new();
|
||||
let mut manifest_text: Option<String> = 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<AppState>,
|
||||
jar: SignedCookieJar,
|
||||
) -> Result<Json<Vec<Package>>, (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<AppState>,
|
||||
Path(name): Path<String>,
|
||||
jar: SignedCookieJar,
|
||||
) -> Result<Json<Vec<PackageVersion>>, (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<AppState>,
|
||||
Path(name): Path<String>,
|
||||
jar: SignedCookieJar,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
let user_id = jar.get("session_user_id").map(|c| c.value().to_string())
|
||||
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?;
|
||||
|
||||
let is_admin = 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)
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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<u8> {
|
||||
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<String>,
|
||||
) -> 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<String, HashMap<String, Vec<u8>>> {
|
||||
let mut packages: HashMap<String, HashMap<String, Vec<u8>>> = HashMap::new();
|
||||
|
||||
let rows = sqlx::query_as::<_, (String, String, String, Vec<u8>)>(
|
||||
"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<String, String>,
|
||||
) -> ProjectInput {
|
||||
let mut files: HashMap<String, Vec<u8>> = 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<u8>)>(
|
||||
"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<Vec<u8>>)>(
|
||||
"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<String>,
|
||||
}
|
||||
|
||||
pub async fn list_spaces(
|
||||
Query(query): Query<ListSpacesQuery>,
|
||||
State(state): State<AppState>,
|
||||
jar: SignedCookieJar,
|
||||
) -> Result<Json<Vec<Space>>, (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<AppState>,
|
||||
jar: SignedCookieJar,
|
||||
) -> Result<Json<Vec<Space>>, (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<AppState>,
|
||||
jar: SignedCookieJar,
|
||||
Json(payload): Json<CreateSpaceRequest>,
|
||||
) -> Result<Json<Space>, (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<AppState>,
|
||||
Path(id): Path<String>,
|
||||
jar: SignedCookieJar,
|
||||
) -> Result<Json<Space>, (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<AppState>,
|
||||
Path(id): Path<String>,
|
||||
jar: SignedCookieJar,
|
||||
Json(payload): Json<UpdateSpaceRequest>,
|
||||
) -> Result<Json<Space>, (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<AppState>,
|
||||
Path(id): Path<String>,
|
||||
jar: SignedCookieJar,
|
||||
) -> Result<StatusCode, (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 _ = 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<AppState>,
|
||||
Path(id): Path<String>,
|
||||
jar: SignedCookieJar,
|
||||
) -> Result<Json<Vec<SpaceFile>>, (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<AppState>,
|
||||
Path(id): Path<String>,
|
||||
jar: SignedCookieJar,
|
||||
Json(payload): Json<CreateSpaceFileRequest>,
|
||||
) -> Result<Json<SpaceFile>, (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<AppState>,
|
||||
Path(id): Path<String>,
|
||||
jar: SignedCookieJar,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Json<serde_json::Value>, (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<AppState>,
|
||||
Path((id, file_id)): Path<(String, String)>,
|
||||
jar: SignedCookieJar,
|
||||
) -> Result<impl IntoResponse, (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 file = sqlx::query_as::<_, (String, String, Option<Vec<u8>>)>(
|
||||
"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<AppState>,
|
||||
Path((id, file_id)): Path<(String, String)>,
|
||||
jar: SignedCookieJar,
|
||||
Json(payload): Json<UpdateSpaceFileRequest>,
|
||||
) -> Result<StatusCode, (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 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<AppState>,
|
||||
Path((id, file_id)): Path<(String, String)>,
|
||||
jar: SignedCookieJar,
|
||||
) -> Result<StatusCode, (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 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)
|
||||
}
|
||||
+48
-38
@@ -13,20 +13,29 @@ use typst_kit::packages::SystemPackages;
|
||||
pub struct MemoryWorld {
|
||||
library: typst::utils::LazyHash<Library>,
|
||||
main: FileId,
|
||||
source: Source,
|
||||
files: HashMap<String, Vec<u8>>,
|
||||
local_packages: HashMap<String, HashMap<String, Vec<u8>>>,
|
||||
book: typst::utils::LazyHash<FontBook>,
|
||||
fonts: Vec<Font>,
|
||||
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<String, Vec<u8>>) -> Self {
|
||||
pub fn new_project(
|
||||
entrypoint: String,
|
||||
files: HashMap<String, Vec<u8>>,
|
||||
local_packages: HashMap<String, HashMap<String, Vec<u8>>>,
|
||||
) -> 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<Vec<u8>> {
|
||||
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<Source> {
|
||||
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<Bytes> {
|
||||
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<Font> {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
|
||||
let {
|
||||
spaceId,
|
||||
onClose
|
||||
}: {
|
||||
spaceId: string;
|
||||
onClose: () => void;
|
||||
} = $props();
|
||||
|
||||
let version = $state('');
|
||||
let publishing = $state(false);
|
||||
let error = $state('');
|
||||
let success = $state('');
|
||||
|
||||
async function publish() {
|
||||
publishing = true;
|
||||
error = '';
|
||||
success = '';
|
||||
try {
|
||||
const res = await fetch('/api/packages/publish', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ space_id: spaceId, version: version.trim() || undefined })
|
||||
});
|
||||
if (!res.ok) {
|
||||
error = await res.text();
|
||||
} else {
|
||||
const pkg = await res.json();
|
||||
success = `Published @typstdrive/${pkg.name}`;
|
||||
}
|
||||
} catch (e) {
|
||||
error = 'Network error while publishing.';
|
||||
}
|
||||
publishing = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50" onclick={onClose} role="presentation">
|
||||
<div class="bg-[var(--theme-bg)] text-[var(--theme-text)] rounded-xl shadow-2xl border border-gray-200 dark:border-white/10 w-full max-w-md p-6" onclick={(e) => e.stopPropagation()} role="presentation">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<Icon icon="mdi:package-variant-closed" class="text-2xl text-purple-500" />
|
||||
<h2 class="text-lg font-bold">Publish as Package</h2>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||
Snapshots this space's files into an immutable package version, importable instance-wide as
|
||||
<code class="font-mono text-xs bg-gray-100 dark:bg-white/10 px-1.5 py-0.5 rounded">@typstdrive/<name>:<version></code>.
|
||||
The name, version and entrypoint come from your <code class="font-mono text-xs bg-gray-100 dark:bg-white/10 px-1.5 py-0.5 rounded">typst.toml</code>.
|
||||
</p>
|
||||
|
||||
<label class="block text-sm font-medium mb-1" for="pkg-version">Version override (optional)</label>
|
||||
<input
|
||||
id="pkg-version"
|
||||
bind:value={version}
|
||||
placeholder="e.g. 0.1.0 (defaults to typst.toml)"
|
||||
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-purple-500/40"
|
||||
/>
|
||||
|
||||
{#if error}
|
||||
<div class="text-sm text-red-600 dark:text-red-400 mb-3 break-words">{error}</div>
|
||||
{/if}
|
||||
{#if success}
|
||||
<div class="text-sm text-green-600 dark:text-green-400 mb-3">{success}</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<button onclick={onClose} class="px-4 py-2 text-sm rounded-lg bg-gray-100 dark:bg-white/5 hover:bg-gray-200 dark:hover:bg-white/10">Close</button>
|
||||
<button onclick={publish} disabled={publishing} class="px-4 py-2 text-sm rounded-lg bg-purple-600 text-white hover:bg-purple-700 disabled:opacity-50 flex items-center gap-2">
|
||||
{#if publishing}<Icon icon="mdi:loading" class="animate-spin" />{/if}
|
||||
Publish
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,53 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
let { createSpace, onClose } = $props<{
|
||||
createSpace: (name: string) => void,
|
||||
onClose: () => void
|
||||
}>();
|
||||
|
||||
let newSpaceName = $state('Untitled Space');
|
||||
|
||||
function onSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
createSpace(newSpaceName);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 animate-in fade-in duration-200" role="presentation" onclick={onClose}>
|
||||
<div tabindex="-1" class="bg-[var(--theme-bg)] rounded-xl shadow-2xl border border-gray-200 dark:border-white/10 w-full max-w-md overflow-hidden" role="dialog" aria-modal="true" aria-labelledby="create-space-title" onclick={(e) => e.stopPropagation()} onkeydown={(e) => { if (e.key === 'Escape') onClose(); }}>
|
||||
<div class="flex justify-between items-center p-5 border-b border-gray-200 dark:border-white/10">
|
||||
<h2 id="create-space-title" class="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Icon icon="mdi:folder-multiple-plus" class="text-blue-500 text-xl" />
|
||||
Create Space
|
||||
</h2>
|
||||
<button onclick={onClose} aria-label="Close" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-full p-1 transition-colors">
|
||||
<Icon icon="mdi:close" class="text-xl" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onsubmit={onSubmit} class="p-5 space-y-4">
|
||||
<div class="space-y-2">
|
||||
<label for="space-name-input" class="text-sm font-medium text-gray-700 dark:text-gray-300 block">Space Name</label>
|
||||
<input
|
||||
id="space-name-input"
|
||||
type="text"
|
||||
required
|
||||
bind:value={newSpaceName}
|
||||
class="w-full bg-black/5 dark:bg-white/5 border border-gray-300 dark:border-white/20 text-gray-900 dark:text-white text-sm rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
placeholder="Untitled Space"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">A multi-file workspace, seeded with a <code class="font-mono">typst.toml</code> and <code class="font-mono">main.typ</code>.</p>
|
||||
</div>
|
||||
|
||||
<div class="pt-4 flex justify-end gap-3">
|
||||
<button type="button" onclick={onClose} class="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-white/10 rounded-lg transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="bg-blue-600 hover:bg-blue-700 text-white px-5 py-2 rounded-lg text-sm font-medium transition-colors shadow-sm flex items-center gap-2">
|
||||
<Icon icon="mdi:plus" class="text-lg" />
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -23,7 +23,14 @@
|
||||
</div>
|
||||
<div class="h-6 w-px bg-gray-300 dark:bg-white/20"></div>
|
||||
|
||||
|
||||
<a href="/spaces" class="text-sm font-medium text-gray-600 hover:text-blue-600 dark:text-gray-300 dark:hover:text-blue-400 transition-colors bg-gray-100 hover:bg-gray-200 dark:bg-white/5 dark:hover:bg-white/10 px-3 py-2 rounded-lg flex items-center gap-2 border border-transparent dark:border-white/10" title="Spaces">
|
||||
<Icon icon="mdi:folder-multiple-outline" class="text-xl" />
|
||||
</a>
|
||||
<a href="/packages" class="text-sm font-medium text-gray-600 hover:text-purple-600 dark:text-gray-300 dark:hover:text-purple-400 transition-colors bg-gray-100 hover:bg-gray-200 dark:bg-white/5 dark:hover:bg-white/10 px-3 py-2 rounded-lg flex items-center gap-2 border border-transparent dark:border-white/10" title="Packages">
|
||||
<Icon icon="mdi:package-variant-closed" class="text-xl" />
|
||||
</a>
|
||||
|
||||
|
||||
<a href="/api-docs" class="text-sm font-medium text-gray-600 hover:text-green-600 dark:text-gray-300 dark:hover:text-green-400 transition-colors bg-gray-100 hover:bg-gray-200 dark:bg-white/5 dark:hover:bg-white/10 px-3 py-2 rounded-lg flex items-center gap-2 border border-transparent dark:border-white/10" title="API Docs">
|
||||
<Icon icon="mdi:api" class="text-xl" />
|
||||
</a>
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
let { space, activeMenu, setActiveMenu, openInfo, openRename, deleteSpace } = $props<{
|
||||
space: any;
|
||||
activeMenu: string | null;
|
||||
setActiveMenu: (id: string | null) => void;
|
||||
openInfo: (space: any) => void;
|
||||
openRename: (id: string, name: string) => void;
|
||||
deleteSpace: (id: string, name: string) => void;
|
||||
}>();
|
||||
|
||||
let dropUp = $state(false);
|
||||
|
||||
function toggleMenu(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
if (activeMenu === space.id) {
|
||||
setActiveMenu(null);
|
||||
} else {
|
||||
const button = e.currentTarget as HTMLElement;
|
||||
const rect = button.getBoundingClientRect();
|
||||
dropUp = window.innerHeight - rect.bottom < 200;
|
||||
setActiveMenu(space.id);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="bg-white dark:bg-black/20 rounded-xl shadow-sm border border-gray-200 dark:border-white/10 flex flex-col hover:shadow-lg hover:border-blue-400 dark:hover:border-blue-500/50 transition-all duration-200 relative group transform hover:-translate-y-1 cursor-pointer overflow-visible"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => goto(`/space/${space.id}`)}
|
||||
onkeydown={(e) => e.key === 'Enter' && goto(`/space/${space.id}`)}
|
||||
>
|
||||
<div class="h-40 w-full bg-gray-50 dark:bg-black/40 rounded-t-xl overflow-hidden flex items-center justify-center border-b border-gray-100 dark:border-white/10 relative pointer-events-none">
|
||||
{#if space.thumbnail_svg}
|
||||
<div class="w-full h-full flex items-start justify-center bg-white transition-transform duration-300 group-hover:scale-110">
|
||||
<img src={`data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(space.thumbnail_svg)))}`} class="w-full h-auto shadow-sm border border-gray-200" alt="Thumbnail" draggable="false" />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="p-4 bg-blue-50 dark:bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-full transition-transform duration-300 group-hover:scale-110">
|
||||
<Icon icon="mdi:folder-multiple-outline" class="text-4xl" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="p-4 flex flex-col flex-grow">
|
||||
<div class="flex items-start justify-between">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white truncate pr-2 pointer-events-none" title={space.name}>{space.name}</h3>
|
||||
|
||||
<div class="relative action-menu-container">
|
||||
<button aria-label="Space actions" onclick={toggleMenu} class="text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors p-1 rounded-full hover:bg-gray-100 dark:hover:bg-white/10 pointer-events-auto">
|
||||
<Icon icon="mdi:dots-vertical" class="text-xl" />
|
||||
</button>
|
||||
|
||||
{#if activeMenu === space.id}
|
||||
<div class="absolute right-0 {dropUp ? 'bottom-full mb-1' : 'top-full mt-1'} w-48 bg-[var(--theme-bg)] rounded-xl shadow-xl border border-gray-200 dark:border-white/10 py-1 z-[100]">
|
||||
<button onclick={(e) => { e.stopPropagation(); openInfo(space); }} class="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-black/5 dark:hover:bg-white/5 flex items-center gap-2">
|
||||
<Icon icon="mdi:information-outline" class="text-lg text-blue-500" />
|
||||
View Info
|
||||
</button>
|
||||
<button onclick={(e) => { e.stopPropagation(); openRename(space.id, space.name); }} class="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-black/5 dark:hover:bg-white/5 flex items-center gap-2">
|
||||
<Icon icon="mdi:pencil-outline" class="text-lg text-yellow-500" />
|
||||
Rename
|
||||
</button>
|
||||
<button onclick={(e) => { e.stopPropagation(); goto(`/space/${space.id}`); }} class="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-black/5 dark:hover:bg-white/5 flex items-center gap-2">
|
||||
<Icon icon="mdi:open-in-new" class="text-lg text-green-500" />
|
||||
Open
|
||||
</button>
|
||||
<div class="h-px bg-gray-200 dark:bg-white/10 my-1"></div>
|
||||
<button onclick={(e) => { e.stopPropagation(); deleteSpace(space.id, space.name); }} class="w-full text-left px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-500/10 flex items-center gap-2">
|
||||
<Icon icon="mdi:trash-can-outline" class="text-lg" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1 mt-2 pointer-events-none">
|
||||
<Icon icon="mdi:clock-outline" class="text-sm" />
|
||||
Edited {new Date(space.updated_at.endsWith('Z') ? space.updated_at : space.updated_at + 'Z').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,101 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
|
||||
interface SpaceFile {
|
||||
id: string;
|
||||
path: string;
|
||||
kind: string;
|
||||
}
|
||||
|
||||
let {
|
||||
files = [],
|
||||
activeFileId = '',
|
||||
entrypoint = 'main.typ',
|
||||
readOnly = false,
|
||||
onSelect,
|
||||
onCreate,
|
||||
onUpload,
|
||||
onRename,
|
||||
onDelete,
|
||||
onSetEntry
|
||||
}: {
|
||||
files?: SpaceFile[];
|
||||
activeFileId?: string;
|
||||
entrypoint?: string;
|
||||
readOnly?: boolean;
|
||||
onSelect: (file: SpaceFile) => void;
|
||||
onCreate: (path: string) => void;
|
||||
onUpload: (fileList: FileList) => void;
|
||||
onRename: (file: SpaceFile, path: string) => void;
|
||||
onDelete: (file: SpaceFile) => void;
|
||||
onSetEntry: (file: SpaceFile) => void;
|
||||
} = $props();
|
||||
|
||||
let fileInput: HTMLInputElement;
|
||||
|
||||
function iconFor(path: string): string {
|
||||
const lower = path.toLowerCase();
|
||||
if (lower.endsWith('.typ')) return 'mdi:language-markdown-outline';
|
||||
if (lower.endsWith('.toml')) return 'mdi:cog-outline';
|
||||
if (lower.endsWith('.bib')) return 'mdi:book-open-variant';
|
||||
if (lower.endsWith('.png') || lower.endsWith('.jpg') || lower.endsWith('.jpeg') || lower.endsWith('.svg') || lower.endsWith('.gif')) return 'mdi:image-outline';
|
||||
if (lower.endsWith('.ttf') || lower.endsWith('.otf')) return 'mdi:format-font';
|
||||
return 'mdi:file-outline';
|
||||
}
|
||||
|
||||
function handleCreate() {
|
||||
const path = prompt('New file path (e.g. chapter.typ, refs.bib):');
|
||||
if (path && path.trim()) onCreate(path.trim());
|
||||
}
|
||||
|
||||
function handleRename(file: SpaceFile) {
|
||||
const path = prompt('Rename file to:', file.path);
|
||||
if (path && path.trim() && path.trim() !== file.path) onRename(file, path.trim());
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="h-full flex flex-col bg-[var(--theme-bg)] border-r border-gray-200 dark:border-white/10">
|
||||
<div class="flex items-center justify-between px-3 py-2 border-b border-gray-200 dark:border-white/10">
|
||||
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Files</span>
|
||||
{#if !readOnly}
|
||||
<div class="flex items-center gap-1">
|
||||
<button onclick={handleCreate} title="New file" class="p-1 rounded hover:bg-gray-100 dark:hover:bg-white/10 text-gray-600 dark:text-gray-300">
|
||||
<Icon icon="mdi:file-plus-outline" class="text-lg" />
|
||||
</button>
|
||||
<button onclick={() => fileInput.click()} title="Upload file" class="p-1 rounded hover:bg-gray-100 dark:hover:bg-white/10 text-gray-600 dark:text-gray-300">
|
||||
<Icon icon="mdi:upload" class="text-lg" />
|
||||
</button>
|
||||
<input bind:this={fileInput} type="file" multiple class="hidden" onchange={(e) => { const t = e.target as HTMLInputElement; if (t.files) onUpload(t.files); t.value = ''; }} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto py-1">
|
||||
{#each files as file (file.id)}
|
||||
<div class="group flex items-center gap-1 px-2 py-1.5 text-sm cursor-pointer {activeFileId === file.id ? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-white/5'}">
|
||||
<button class="flex items-center gap-2 flex-1 min-w-0 text-left" onclick={() => onSelect(file)}>
|
||||
<Icon icon={iconFor(file.path)} class="text-base flex-shrink-0" />
|
||||
<span class="truncate">{file.path}</span>
|
||||
{#if file.path === entrypoint}
|
||||
<Icon icon="mdi:star" class="text-amber-500 text-xs flex-shrink-0" title="Entrypoint" />
|
||||
{/if}
|
||||
</button>
|
||||
{#if !readOnly}
|
||||
<div class="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{#if file.kind === 'text' && file.path.toLowerCase().endsWith('.typ') && file.path !== entrypoint}
|
||||
<button onclick={() => onSetEntry(file)} title="Set as entrypoint" class="p-0.5 rounded hover:bg-gray-200 dark:hover:bg-white/10 text-gray-500">
|
||||
<Icon icon="mdi:star-outline" class="text-sm" />
|
||||
</button>
|
||||
{/if}
|
||||
<button onclick={() => handleRename(file)} title="Rename" class="p-0.5 rounded hover:bg-gray-200 dark:hover:bg-white/10 text-gray-500">
|
||||
<Icon icon="mdi:pencil-outline" class="text-sm" />
|
||||
</button>
|
||||
<button onclick={() => onDelete(file)} title="Delete" class="p-0.5 rounded hover:bg-red-100 dark:hover:bg-red-900/30 text-gray-500 hover:text-red-600">
|
||||
<Icon icon="mdi:trash-can-outline" class="text-sm" />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,523 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import Icon from '@iconify/svelte';
|
||||
import { undo, redo } from '@codemirror/commands';
|
||||
import {
|
||||
connectedUsers,
|
||||
darkModeStore,
|
||||
editorViewStore,
|
||||
documentZoomStore,
|
||||
previewOpenStore
|
||||
} from '../../ts/store';
|
||||
import { exportSpace } from '../../ts/typst-api';
|
||||
import ThemePicker from '../ThemePicker.svelte';
|
||||
import PageSettingsModal from '../PageSettingsModal.svelte';
|
||||
import PresentationMode from '../PresentationMode.svelte';
|
||||
|
||||
let {
|
||||
spaceName = 'Space',
|
||||
spaceId,
|
||||
entrypoint = 'main.typ',
|
||||
role = 'owner',
|
||||
activeText = null,
|
||||
activePath = '',
|
||||
getAllText,
|
||||
onPublish,
|
||||
onFilesChanged
|
||||
}: {
|
||||
spaceName?: string;
|
||||
spaceId: string;
|
||||
entrypoint?: string;
|
||||
role?: string;
|
||||
activeText?: any;
|
||||
activePath?: string;
|
||||
getAllText: () => Record<string, string>;
|
||||
onPublish: () => void;
|
||||
onFilesChanged: () => void;
|
||||
} = $props();
|
||||
|
||||
let isViewer = $derived(role === 'viewer');
|
||||
|
||||
let uploadedFonts = $state<string[]>([]);
|
||||
$effect(() => {
|
||||
fetch('/api/fonts')
|
||||
.then((res) => res.json())
|
||||
.then((data) => { uploadedFonts = Array.isArray(data) ? data : []; })
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
let isPageSettingsOpen = $state(false);
|
||||
let isPresentationOpen = $state(false);
|
||||
let fileInput = $state<HTMLInputElement | null>(null);
|
||||
let activeMenu = $state<string | null>(null);
|
||||
let showInfoModal = $state(false);
|
||||
let showRenameModal = $state(false);
|
||||
let showDeleteModal = $state(false);
|
||||
let renameName = $state('');
|
||||
|
||||
$effect(() => { renameName = spaceName; });
|
||||
|
||||
function safeName() {
|
||||
return spaceName.replace(/[^a-z0-9_-]/gi, '_');
|
||||
}
|
||||
|
||||
function handleExport(format: 'pdf' | 'png' | 'svg' | 'typ') {
|
||||
if (format === 'typ') {
|
||||
const content = activeText ? activeText.toString() : '';
|
||||
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${(activePath || 'main').replace(/\.[^.]+$/, '')}.typ`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
return;
|
||||
}
|
||||
exportSpace(spaceId, getAllText(), format, safeName()).catch((e) => {
|
||||
console.error(`Export to ${format} failed:`, e);
|
||||
alert(`Failed to export as ${format.toUpperCase()}`);
|
||||
});
|
||||
}
|
||||
|
||||
function handlePrint() {
|
||||
fetch(`/api/export/pdf`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ space_id: spaceId, files: getAllText() })
|
||||
})
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error('Print failed');
|
||||
return res.blob();
|
||||
})
|
||||
.then((blob) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.style.position = 'fixed';
|
||||
iframe.style.right = '0';
|
||||
iframe.style.bottom = '0';
|
||||
iframe.style.width = '0';
|
||||
iframe.style.height = '0';
|
||||
iframe.style.border = '0';
|
||||
iframe.src = url;
|
||||
document.body.appendChild(iframe);
|
||||
iframe.onload = () => setTimeout(() => iframe.contentWindow?.print(), 100);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error('Print failed:', e);
|
||||
alert('Failed to print');
|
||||
});
|
||||
}
|
||||
|
||||
function handlePandocExport(format: string) {
|
||||
const files = getAllText();
|
||||
const content = files[entrypoint] ?? (activeText ? activeText.toString() : '');
|
||||
fetch(`/api/export/pandoc/${format}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: content })
|
||||
})
|
||||
.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;
|
||||
let ext = format;
|
||||
if (format === 'latex') ext = 'tex';
|
||||
if (format === 'markdown') ext = 'md';
|
||||
a.download = `${safeName()}.${ext}`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
alert(`Failed to export as ${format}`);
|
||||
});
|
||||
}
|
||||
|
||||
function applyFormat(prefix: string, suffix: string, defaultText: string = '') {
|
||||
const view = $editorViewStore;
|
||||
if (!view) return;
|
||||
const selection = view.state.selection.main;
|
||||
const selectedText = view.state.doc.sliceString(selection.from, selection.to);
|
||||
const replacement = prefix + (selectedText || defaultText) + suffix;
|
||||
view.dispatch({
|
||||
changes: { from: selection.from, to: selection.to, insert: replacement },
|
||||
selection: { anchor: selection.from + prefix.length, head: selection.from + prefix.length + (selectedText || defaultText).length }
|
||||
});
|
||||
view.focus();
|
||||
}
|
||||
|
||||
function insertTypstConfig(setting: string, value: string) {
|
||||
if (!activeText) return;
|
||||
const content = activeText.toString();
|
||||
const regex = new RegExp(`^#set\\s+${setting}\\s*\\(([^)]*)\\)`, 'm');
|
||||
const match = content.match(regex);
|
||||
const [propKey, ...propValParts] = value.split(':');
|
||||
const propKeyTrimmed = propKey.trim();
|
||||
const propValTrimmed = propValParts.join(':').trim();
|
||||
if (match) {
|
||||
const index = match.index!;
|
||||
const oldArgs = match[1];
|
||||
const propRegex = new RegExp(`${propKeyTrimmed}\\s*:\\s*(?:\\([^)]*\\)|"[^"]*"|[^,)]+)`);
|
||||
let newArgs;
|
||||
if (propRegex.test(oldArgs)) {
|
||||
newArgs = oldArgs.replace(propRegex, `${propKeyTrimmed}: ${propValTrimmed}`);
|
||||
} else {
|
||||
newArgs = oldArgs.trim() ? `${oldArgs}, ${propKeyTrimmed}: ${propValTrimmed}` : `${propKeyTrimmed}: ${propValTrimmed}`;
|
||||
}
|
||||
activeText.delete(index, match[0].length);
|
||||
activeText.insert(index, `#set ${setting}(${newArgs})`);
|
||||
} else {
|
||||
activeText.insert(0, `#set ${setting}(${value})\n`);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePageSettings(settings: Record<string, string>, docSettings: Record<string, string>) {
|
||||
if (!activeText) return;
|
||||
if (Object.keys(settings).length > 0) {
|
||||
const args = Object.entries(settings).map(([k, v]) => `${k}: ${v}`).join(', ');
|
||||
const regex = new RegExp(`^#set\\s+page\\s*\\(([^)]*)\\)`, 'm');
|
||||
const match = activeText.toString().match(regex);
|
||||
if (match) {
|
||||
activeText.delete(match.index!, match[0].length);
|
||||
activeText.insert(match.index!, `#set page(${args})`);
|
||||
} else {
|
||||
activeText.insert(0, `#set page(${args})\n`);
|
||||
}
|
||||
}
|
||||
if (Object.keys(docSettings).length > 0) {
|
||||
const docArgs = Object.entries(docSettings).map(([k, v]) => `${k}: ${v}`).join(', ');
|
||||
const docRegex = new RegExp(`^#set\\s+document\\s*\\(([^)]*)\\)`, 'm');
|
||||
const docMatch = activeText.toString().match(docRegex);
|
||||
if (docMatch) {
|
||||
activeText.delete(docMatch.index!, docMatch[0].length);
|
||||
activeText.insert(docMatch.index!, `#set document(${docArgs})`);
|
||||
} else {
|
||||
activeText.insert(0, `#set document(${docArgs})\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseSettings() {
|
||||
if (!activeText) return {};
|
||||
const content = activeText.toString();
|
||||
const settings: Record<string, string> = {};
|
||||
const pageMatch = content.match(/^#set\s+page\s*\(([^)]*)\)/m);
|
||||
if (pageMatch) {
|
||||
for (const arg of pageMatch[1].split(',').map((s: string) => s.trim())) {
|
||||
const [k, ...vParts] = arg.split(':').map((s: string) => s.trim());
|
||||
if (k && vParts.length) {
|
||||
let v = vParts.join(':').trim();
|
||||
if (v.startsWith('"') && v.endsWith('"')) v = v.substring(1, v.length - 1);
|
||||
settings[k] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
|
||||
function handleUpload(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
if (!target.files || target.files.length === 0) return;
|
||||
const file = target.files[0];
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
fetch(`/api/spaces/${spaceId}/files/upload`, { method: 'POST', body: form })
|
||||
.then((res) => res.json())
|
||||
.then(() => {
|
||||
const lower = file.name.toLowerCase();
|
||||
const view = $editorViewStore;
|
||||
if (view) {
|
||||
const selection = view.state.selection.main;
|
||||
let replacement = '';
|
||||
if (lower.endsWith('.ttf') || lower.endsWith('.otf')) {
|
||||
replacement = `// Font ${file.name} uploaded — use #set text(font: "Family Name")\n`;
|
||||
} else {
|
||||
replacement = `#image("${file.name}")\n`;
|
||||
}
|
||||
view.dispatch({
|
||||
changes: { from: selection.from, to: selection.to, insert: replacement },
|
||||
selection: { anchor: selection.from + replacement.length }
|
||||
});
|
||||
view.focus();
|
||||
}
|
||||
onFilesChanged();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
alert('Failed to upload file');
|
||||
});
|
||||
target.value = '';
|
||||
}
|
||||
|
||||
function getInitials(name: string) {
|
||||
return name.substring(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
function submitRename(e: Event) {
|
||||
e.preventDefault();
|
||||
if (renameName && renameName !== spaceName) {
|
||||
fetch(`/api/spaces/${spaceId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: renameName })
|
||||
}).then((res) => { if (res.ok) window.location.reload(); });
|
||||
}
|
||||
showRenameModal = false;
|
||||
}
|
||||
|
||||
function confirmDelete() {
|
||||
fetch(`/api/spaces/${spaceId}`, { method: 'DELETE' }).then((res) => {
|
||||
if (res.ok) goto('/spaces');
|
||||
});
|
||||
}
|
||||
|
||||
function handleWindowClick(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target.closest('.action-menu-container')) activeMenu = null;
|
||||
}
|
||||
|
||||
function handleUndo() { activeMenu = null; if ($editorViewStore) undo($editorViewStore); }
|
||||
function handleRedo() { activeMenu = null; if ($editorViewStore) redo($editorViewStore); }
|
||||
</script>
|
||||
|
||||
<svelte:window onclick={handleWindowClick} />
|
||||
|
||||
<header class="flex flex-col border-b border-[var(--theme-border)] bg-[var(--theme-bg)] text-[var(--theme-text)] select-none w-full relative z-[70]">
|
||||
<div class="flex items-center justify-between px-4 py-2.5">
|
||||
<div class="flex items-center gap-3">
|
||||
<button onclick={() => goto('/spaces')} class="p-1.5 text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-white/10 transition-colors" title="Spaces">
|
||||
<Icon icon="mdi:arrow-left" class="text-xl" />
|
||||
</button>
|
||||
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon icon="mdi:folder-multiple-outline" class="text-blue-500 text-base" />
|
||||
<h1 class="text-[16px] font-semibold text-gray-900 dark:text-white tracking-tight truncate max-w-[200px] md:max-w-xs" title={spaceName}>{spaceName}</h1>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-0.5 text-[13px] font-medium text-gray-600 dark:text-gray-300 -ml-1 action-menu-container">
|
||||
<div class="relative">
|
||||
<button onclick={(e) => { e.stopPropagation(); activeMenu = activeMenu === 'file' ? null : 'file'; }} class="px-2 py-0.5 rounded transition-colors {activeMenu === 'file' ? 'bg-[var(--theme-border)]' : 'hover:bg-[var(--theme-border)]'}">File</button>
|
||||
{#if activeMenu === 'file'}
|
||||
<div class="absolute left-0 top-full mt-1 w-48 bg-[var(--theme-bg)] rounded-xl shadow-xl border border-[var(--theme-border)] py-1 z-[100] max-h-[calc(100vh-8rem)] overflow-y-auto">
|
||||
<button onclick={() => { activeMenu = null; goto('/spaces'); }} class="w-full text-left px-4 py-1.5 text-sm hover:bg-[var(--theme-border)]">All Spaces</button>
|
||||
<button onclick={() => { activeMenu = null; showInfoModal = true; }} class="w-full text-left px-4 py-1.5 text-sm hover:bg-[var(--theme-border)]">Space Info</button>
|
||||
{#if !isViewer}
|
||||
<div class="h-px bg-[var(--theme-border)] my-1"></div>
|
||||
<button onclick={() => { activeMenu = null; renameName = spaceName; showRenameModal = true; }} class="w-full text-left px-4 py-1.5 text-sm hover:bg-[var(--theme-border)]">Rename</button>
|
||||
<button onclick={() => { activeMenu = null; isPageSettingsOpen = true; }} class="w-full text-left px-4 py-1.5 text-sm hover:bg-[var(--theme-border)]">Page Settings</button>
|
||||
{#if role === 'owner'}
|
||||
<button onclick={() => { activeMenu = null; onPublish(); }} class="w-full text-left px-4 py-1.5 text-sm hover:bg-[var(--theme-border)]">Publish as Package</button>
|
||||
{/if}
|
||||
{/if}
|
||||
<div class="h-px bg-[var(--theme-border)] my-1"></div>
|
||||
<div class="px-4 py-1.5 text-xs font-semibold uppercase tracking-wider opacity-60">Download</div>
|
||||
<button onclick={() => { activeMenu = null; handlePrint(); }} class="w-full text-left px-4 py-1 text-sm hover:bg-[var(--theme-border)] flex items-center gap-1.5"><Icon icon="mdi:printer" /> Print</button>
|
||||
<button onclick={() => { activeMenu = null; handleExport('typ'); }} class="w-full text-left px-4 py-1 text-sm hover:bg-[var(--theme-border)] flex items-center gap-1.5"><Icon icon="mdi:code-braces" /> active .typ</button>
|
||||
<button onclick={() => { activeMenu = null; handleExport('pdf'); }} class="w-full text-left px-4 py-1 text-sm hover:bg-[var(--theme-border)] flex items-center gap-1.5"><Icon icon="mdi:file-pdf-box" /> .pdf document</button>
|
||||
<button onclick={() => { activeMenu = null; handleExport('png'); }} class="w-full text-left px-4 py-1 text-sm hover:bg-[var(--theme-border)] flex items-center gap-1.5"><Icon icon="mdi:image" /> .png image</button>
|
||||
<button onclick={() => { activeMenu = null; handleExport('svg'); }} class="w-full text-left px-4 py-1 text-sm hover:bg-[var(--theme-border)] flex items-center gap-1.5"><Icon icon="mdi:svg" /> .svg graphics</button>
|
||||
<div class="h-px bg-[var(--theme-border)] my-1"></div>
|
||||
<div class="px-4 py-1.5 text-xs font-semibold uppercase tracking-wider opacity-60">Export (Pandoc)</div>
|
||||
<button onclick={() => { activeMenu = null; handlePandocExport('docx'); }} class="w-full text-left px-4 py-1 text-sm hover:bg-[var(--theme-border)]">Word (.docx)</button>
|
||||
<button onclick={() => { activeMenu = null; handlePandocExport('latex'); }} class="w-full text-left px-4 py-1 text-sm hover:bg-[var(--theme-border)]">LaTeX (.tex)</button>
|
||||
<button onclick={() => { activeMenu = null; handlePandocExport('markdown'); }} class="w-full text-left px-4 py-1 text-sm hover:bg-[var(--theme-border)]">Markdown (.md)</button>
|
||||
<button onclick={() => { activeMenu = null; handlePandocExport('html'); }} class="w-full text-left px-4 py-1 text-sm hover:bg-[var(--theme-border)]">HTML (.html)</button>
|
||||
{#if role === 'owner'}
|
||||
<div class="h-px bg-[var(--theme-border)] my-1"></div>
|
||||
<button onclick={() => { activeMenu = null; showDeleteModal = true; }} class="w-full text-left px-4 py-1.5 text-sm text-red-500 hover:bg-red-500/10">Delete Space</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !isViewer}
|
||||
<div class="relative">
|
||||
<button onclick={(e) => { e.stopPropagation(); activeMenu = activeMenu === 'edit' ? null : 'edit'; }} class="px-2 py-0.5 rounded transition-colors {activeMenu === 'edit' ? 'bg-[var(--theme-border)]' : 'hover:bg-[var(--theme-border)]'}">Edit</button>
|
||||
{#if activeMenu === 'edit'}
|
||||
<div class="absolute left-0 top-full mt-1 w-48 bg-[var(--theme-bg)] rounded-xl shadow-xl border border-[var(--theme-border)] py-1 z-[100]">
|
||||
<button onclick={handleUndo} class="w-full text-left px-4 py-1.5 text-sm hover:bg-[var(--theme-border)]">Undo (Ctrl+Z)</button>
|
||||
<button onclick={handleRedo} class="w-full text-left px-4 py-1.5 text-sm hover:bg-[var(--theme-border)]">Redo (Ctrl+Y)</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="relative">
|
||||
<button onclick={(e) => { e.stopPropagation(); activeMenu = activeMenu === 'view' ? null : 'view'; }} class="px-2 py-0.5 rounded transition-colors {activeMenu === 'view' ? 'bg-[var(--theme-border)]' : 'hover:bg-[var(--theme-border)]'}">View</button>
|
||||
{#if activeMenu === 'view'}
|
||||
<div class="absolute left-0 top-full mt-1 w-48 bg-[var(--theme-bg)] rounded-xl shadow-xl border border-[var(--theme-border)] py-1 z-[100]">
|
||||
<button onclick={() => { activeMenu = null; $previewOpenStore = !$previewOpenStore; }} class="w-full text-left px-4 py-1.5 text-sm hover:bg-[var(--theme-border)] flex items-center justify-between">Preview<Icon icon={$previewOpenStore ? 'mdi:check' : ''} class="text-sm" /></button>
|
||||
<button onclick={() => { activeMenu = null; $darkModeStore = !$darkModeStore; document.documentElement.classList.toggle('dark', $darkModeStore); }} class="w-full text-left px-4 py-1.5 text-sm hover:bg-[var(--theme-border)] flex items-center justify-between">Dark Mode<Icon icon={$darkModeStore ? 'mdi:check' : ''} class="text-sm" /></button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
{#if $connectedUsers.length > 0}
|
||||
<div class="flex items-center -space-x-2 mr-2">
|
||||
{#each $connectedUsers as user}
|
||||
<div class="w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold text-white border-2 border-white dark:border-zinc-950 shadow-sm" style="background-color: {user.color}; z-index: {user.isLocal ? 10 : 1};" title={user.name + (user.isLocal ? ' (You)' : '')}>{getInitials(user.name)}</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="w-px h-5 bg-gray-300 dark:bg-white/10"></div>
|
||||
|
||||
{#if !isViewer}
|
||||
<button onclick={() => (isPresentationOpen = true)} class="flex items-center gap-1.5 px-3.5 py-1.5 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 dark:text-gray-200 dark:bg-black/20 dark:hover:bg-white/10 rounded-md transition-colors">
|
||||
<Icon icon="mdi:presentation-play" class="text-[16px]" /> Present
|
||||
</button>
|
||||
{#if role === 'owner'}
|
||||
<button onclick={onPublish} class="flex items-center gap-1.5 px-3.5 py-1.5 text-sm font-medium text-white bg-purple-600 hover:bg-purple-700 rounded-md transition-colors">
|
||||
<Icon icon="mdi:package-variant-closed" class="text-[16px]" /> Publish
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<div class="w-px h-5 bg-gray-300 dark:bg-white/10"></div>
|
||||
|
||||
<button onclick={() => ($previewOpenStore = !$previewOpenStore)} class="flex items-center gap-1.5 px-3.5 py-1.5 text-sm font-medium transition-colors rounded-md {$previewOpenStore ? 'text-gray-700 bg-gray-100 hover:bg-gray-200 dark:text-gray-200 dark:bg-black/20 dark:hover:bg-white/10' : 'text-blue-600 bg-blue-50 hover:bg-blue-100 dark:text-blue-400 dark:bg-blue-900/20'}" title={$previewOpenStore ? 'Hide preview' : 'Show preview'}>
|
||||
<Icon icon={$previewOpenStore ? 'mdi:eye-off-outline' : 'mdi:eye-outline'} class="text-[16px]" /> Preview
|
||||
</button>
|
||||
|
||||
<button onclick={handlePrint} class="flex items-center gap-1.5 px-3.5 py-1.5 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 dark:text-gray-200 dark:bg-black/20 dark:hover:bg-white/10 rounded-md transition-colors" title="Print">
|
||||
<Icon icon="mdi:printer" class="text-[16px]" /> Print
|
||||
</button>
|
||||
|
||||
<div class="relative action-menu-container">
|
||||
<button onclick={(e) => { e.stopPropagation(); activeMenu = activeMenu === 'export' ? null : 'export'; }} class="flex items-center gap-1.5 px-3.5 py-1.5 text-sm font-medium text-blue-600 bg-blue-50 hover:bg-blue-100 dark:text-blue-400 dark:bg-blue-900/20 rounded-md transition-colors {activeMenu === 'export' ? 'ring-2 ring-blue-500/30' : ''}">
|
||||
<Icon icon="mdi:export-variant" class="text-[16px]" /> Export <Icon icon="mdi:chevron-down" class="text-sm opacity-70" />
|
||||
</button>
|
||||
{#if activeMenu === 'export'}
|
||||
<div class="absolute right-0 top-full mt-1 w-44 bg-[var(--theme-bg)] rounded-xl shadow-xl border border-[var(--theme-border)] py-1 z-[100]">
|
||||
<button onclick={() => { activeMenu = null; handleExport('pdf'); }} class="w-full text-left px-4 py-1.5 text-sm hover:bg-[var(--theme-border)] flex items-center gap-2"><Icon icon="mdi:file-pdf-box" class="text-base text-red-500" /> PDF document</button>
|
||||
<button onclick={() => { activeMenu = null; handleExport('typ'); }} class="w-full text-left px-4 py-1.5 text-sm hover:bg-[var(--theme-border)] flex items-center gap-2"><Icon icon="mdi:code-braces" class="text-base text-purple-500" /> active .typ</button>
|
||||
<button onclick={() => { activeMenu = null; handleExport('svg'); }} class="w-full text-left px-4 py-1.5 text-sm hover:bg-[var(--theme-border)] flex items-center gap-2"><Icon icon="mdi:svg" class="text-base text-orange-500" /> SVG graphics</button>
|
||||
<button onclick={() => { activeMenu = null; handleExport('png'); }} class="w-full text-left px-4 py-1.5 text-sm hover:bg-[var(--theme-border)] flex items-center gap-2"><Icon icon="mdi:image" class="text-base text-blue-500" /> PNG image</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center px-4 py-1.5 bg-white/50 dark:bg-black/10 border-t border-gray-200/60 dark:border-white/10 gap-4 overflow-x-auto no-scrollbar">
|
||||
<div class="flex items-center gap-1">
|
||||
<button onclick={() => applyFormat('*', '*', 'bold')} class="p-1.5 text-gray-600 hover:text-gray-900 hover:bg-gray-200 dark:text-gray-400 dark:hover:text-white dark:hover:bg-white/10 rounded transition-colors" title="Bold"><Icon icon="mdi:format-bold" class="text-lg" /></button>
|
||||
<button onclick={() => applyFormat('_', '_', 'italic')} class="p-1.5 text-gray-600 hover:text-gray-900 hover:bg-gray-200 dark:text-gray-400 dark:hover:text-white dark:hover:bg-white/10 rounded transition-colors" title="Italic"><Icon icon="mdi:format-italic" class="text-lg" /></button>
|
||||
<button onclick={() => applyFormat('`', '`', 'code')} class="p-1.5 text-gray-600 hover:text-gray-900 hover:bg-gray-200 dark:text-gray-400 dark:hover:text-white dark:hover:bg-white/10 rounded transition-colors" title="Code"><Icon icon="mdi:code-tags" class="text-lg" /></button>
|
||||
<div class="w-px h-4 mx-1 bg-gray-300 dark:bg-white/10"></div>
|
||||
<button onclick={() => applyFormat('$ ', ' $', 'x = y')} class="p-1.5 text-gray-600 hover:text-gray-900 hover:bg-gray-200 dark:text-gray-400 dark:hover:text-white dark:hover:bg-white/10 rounded transition-colors" title="Math (Inline)"><Icon icon="mdi:sigma" class="text-lg" /></button>
|
||||
<button onclick={() => applyFormat('- ', '', 'List item')} class="p-1.5 text-gray-600 hover:text-gray-900 hover:bg-gray-200 dark:text-gray-400 dark:hover:text-white dark:hover:bg-white/10 rounded transition-colors" title="Bullet List"><Icon icon="mdi:format-list-bulleted" class="text-lg" /></button>
|
||||
<button onclick={() => applyFormat('+ ', '', 'Numbered item')} class="p-1.5 text-gray-600 hover:text-gray-900 hover:bg-gray-200 dark:text-gray-400 dark:hover:text-white dark:hover:bg-white/10 rounded transition-colors" title="Numbered List"><Icon icon="mdi:format-list-numbered" class="text-lg" /></button>
|
||||
<div class="w-px h-4 mx-1 bg-gray-300 dark:bg-white/10"></div>
|
||||
<input type="file" bind:this={fileInput} onchange={handleUpload} class="hidden" accept="image/*,.ttf,.otf" />
|
||||
{#if !isViewer}
|
||||
<button onclick={() => fileInput?.click()} class="p-1.5 text-gray-600 hover:text-gray-900 hover:bg-gray-200 dark:text-gray-400 dark:hover:text-white dark:hover:bg-white/10 rounded transition-colors" title="Upload Image / Font"><Icon icon="mdi:image-plus" class="text-lg" /></button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="w-px h-4 bg-gray-300 dark:bg-white/10"></div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<label for="space-font-select" class="text-[11px] font-semibold uppercase tracking-wider opacity-60">Font</label>
|
||||
<select id="space-font-select" onchange={(e) => insertTypstConfig('text', `font: "${e.currentTarget.value}"`)} class="bg-[var(--theme-bg)] text-[var(--theme-text)] border border-[var(--theme-border)] text-xs rounded shadow-sm block py-1 pl-2 pr-6 appearance-none cursor-pointer">
|
||||
<option value="New Computer Modern">Default (New CM)</option>
|
||||
<option value="Libertinus Serif">Libertinus Serif</option>
|
||||
<option value="PT Sans">PT Sans</option>
|
||||
<option value="Roboto">Roboto</option>
|
||||
{#if uploadedFonts.length > 0}
|
||||
<optgroup label="Uploaded Fonts">
|
||||
{#each uploadedFonts as font}
|
||||
<option value={font}>{font}</option>
|
||||
{/each}
|
||||
</optgroup>
|
||||
{/if}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="w-px h-4 bg-gray-300 dark:bg-white/10"></div>
|
||||
|
||||
{#if !isViewer}
|
||||
<button onclick={() => (isPageSettingsOpen = true)} class="flex items-center gap-1.5 px-3 py-1 text-[11px] font-semibold bg-[var(--theme-bg)] text-[var(--theme-text)] border border-[var(--theme-border)] rounded shadow-sm transition-colors opacity-90 hover:opacity-100">
|
||||
<Icon icon="mdi:file-document-edit-outline" class="text-sm" /> Page Settings
|
||||
</button>
|
||||
<div class="w-px h-4 bg-gray-300 dark:bg-white/10"></div>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center gap-1 bg-white dark:bg-black/20 border border-gray-300 dark:border-white/20 rounded shadow-sm overflow-hidden">
|
||||
<button onclick={() => $documentZoomStore = Math.max(10, $documentZoomStore - 10)} class="px-2 py-1 text-gray-600 hover:text-gray-900 hover:bg-gray-100 dark:text-gray-300 dark:hover:text-white dark:hover:bg-white/10 transition-colors" title="Zoom Out"><Icon icon="mdi:minus" class="text-sm" /></button>
|
||||
<span role="button" tabindex="0" onkeydown={(e) => { 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}%</span>
|
||||
<button onclick={() => $documentZoomStore = Math.min(500, $documentZoomStore + 10)} class="px-2 py-1 text-gray-600 hover:text-gray-900 hover:bg-gray-100 dark:text-gray-300 dark:hover:text-white dark:hover:bg-white/10 transition-colors" title="Zoom In"><Icon icon="mdi:plus" class="text-sm" /></button>
|
||||
</div>
|
||||
|
||||
<div class="w-px h-4 bg-gray-300 dark:bg-white/10"></div>
|
||||
|
||||
<ThemePicker />
|
||||
<div class="flex-grow"></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if isPageSettingsOpen}
|
||||
<PageSettingsModal onClose={() => (isPageSettingsOpen = false)} onApply={handlePageSettings} currentSettings={parseSettings()} />
|
||||
{/if}
|
||||
|
||||
{#if isPresentationOpen}
|
||||
<PresentationMode onClose={() => (isPresentationOpen = false)} />
|
||||
{/if}
|
||||
|
||||
{#if showInfoModal}
|
||||
<div class="fixed inset-0 bg-black/60 backdrop-blur-sm z-[100] flex items-center justify-center p-4" onclick={() => showInfoModal = false} role="presentation">
|
||||
<div class="bg-[var(--theme-bg)] text-[var(--theme-text)] rounded-2xl shadow-2xl border border-gray-200 dark:border-white/10 w-full max-w-sm overflow-hidden" onclick={(e) => e.stopPropagation()} role="presentation">
|
||||
<div class="p-6 border-b border-gray-100 dark:border-white/10 flex items-center gap-3">
|
||||
<Icon icon="mdi:folder-multiple-outline" class="text-xl text-blue-500" />
|
||||
<h3 class="text-lg font-semibold flex-grow truncate">{spaceName}</h3>
|
||||
</div>
|
||||
<div class="p-6 space-y-4 text-sm">
|
||||
<div><p class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1">Type</p><p>Space (multi-file)</p></div>
|
||||
<div><p class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1">Entrypoint</p><p class="font-mono">{entrypoint}</p></div>
|
||||
<div><p class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1">Your role</p><p class="capitalize">{role}</p></div>
|
||||
</div>
|
||||
<div class="p-4 bg-gray-50 dark:bg-white/5 border-t border-gray-100 dark:border-white/10 flex justify-end">
|
||||
<button onclick={() => showInfoModal = false} class="px-5 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showRenameModal}
|
||||
<div class="fixed inset-0 bg-black/60 backdrop-blur-sm z-[100] flex items-center justify-center p-4" onclick={() => showRenameModal = false} role="presentation">
|
||||
<div class="bg-[var(--theme-bg)] text-[var(--theme-text)] rounded-2xl shadow-2xl border border-gray-200 dark:border-white/10 w-full max-w-sm overflow-hidden" onclick={(e) => e.stopPropagation()} role="presentation">
|
||||
<form onsubmit={submitRename} class="p-6">
|
||||
<h3 class="text-lg font-semibold mb-4 flex items-center gap-2"><Icon icon="mdi:pencil-outline" class="text-yellow-500" /> Rename Space</h3>
|
||||
<input type="text" required bind:value={renameName} class="w-full bg-transparent border border-gray-300 dark:border-white/20 text-sm rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||
<div class="pt-6 flex justify-end gap-3">
|
||||
<button type="button" onclick={() => showRenameModal = false} class="px-4 py-2 text-sm font-medium hover:bg-gray-100 dark:hover:bg-white/10 rounded-lg">Cancel</button>
|
||||
<button type="submit" class="bg-blue-600 hover:bg-blue-700 text-white px-5 py-2 rounded-lg text-sm font-medium">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showDeleteModal}
|
||||
<div class="fixed inset-0 bg-black/60 backdrop-blur-sm z-[100] flex items-center justify-center p-4" onclick={() => showDeleteModal = false} role="presentation">
|
||||
<div class="bg-[var(--theme-bg)] text-[var(--theme-text)] rounded-2xl shadow-2xl border border-gray-200 dark:border-white/10 w-full max-w-sm overflow-hidden" onclick={(e) => e.stopPropagation()} role="presentation">
|
||||
<div class="p-6">
|
||||
<h3 class="text-lg font-semibold mb-4 flex items-center gap-2"><Icon icon="mdi:trash-can-outline" class="text-red-500" /> Delete Space</h3>
|
||||
<p class="text-gray-600 dark:text-gray-300 text-sm mb-6">Delete this space and all its files? This cannot be undone.</p>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button" onclick={() => showDeleteModal = false} class="px-4 py-2 text-sm font-medium hover:bg-gray-100 dark:hover:bg-white/10 rounded-lg">Cancel</button>
|
||||
<button type="button" onclick={confirmDelete} class="bg-red-600 hover:bg-red-700 text-white px-5 py-2 rounded-lg text-sm font-medium">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -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<string, string>): Promise<CompileResponse> {
|
||||
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<string, string>, 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';
|
||||
|
||||
@@ -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<string, OpenFile>();
|
||||
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<string, AwarenessUser>();
|
||||
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<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
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([]);
|
||||
}
|
||||
@@ -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<string | null>(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 @@
|
||||
<Icon icon="mdi:file-document-plus" class="text-lg text-blue-500" />
|
||||
New Document
|
||||
</button>
|
||||
<button onclick={openCreateSpaceModal} class="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-black/5 dark:hover:bg-white/5 flex items-center gap-2">
|
||||
<Icon icon="mdi:folder-multiple-plus" class="text-lg text-indigo-500" />
|
||||
New Space
|
||||
</button>
|
||||
<button onclick={openCreateFolderModal} class="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-black/5 dark:hover:bg-white/5 flex items-center gap-2">
|
||||
<Icon icon="mdi:folder-plus" class="text-lg text-yellow-500" />
|
||||
New Folder
|
||||
@@ -602,6 +629,10 @@
|
||||
<CreateDocModal {createDoc} onClose={() => showCreateModal = false} />
|
||||
{/if}
|
||||
|
||||
{#if showCreateSpaceModal}
|
||||
<CreateSpaceModal {createSpace} onClose={() => showCreateSpaceModal = false} />
|
||||
{/if}
|
||||
|
||||
{#if showCreateFolderModal}
|
||||
<CreateFolderModal {createFolder} onClose={() => showCreateFolderModal = false} />
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import Icon from '@iconify/svelte';
|
||||
import Navbar from '$lib/components/dashboard/Navbar.svelte';
|
||||
|
||||
interface Package {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
owner_name?: string;
|
||||
latest_version?: string;
|
||||
}
|
||||
|
||||
let packages = $state<Package[]>([]);
|
||||
let loading = $state(true);
|
||||
let copied = $state('');
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
const res = await fetch('/api/packages');
|
||||
packages = res.ok ? await res.json() : [];
|
||||
loading = false;
|
||||
}
|
||||
|
||||
function importSnippet(pkg: Package): string {
|
||||
return `#import "@typstdrive/${pkg.name}:${pkg.latest_version ?? '0.1.0'}": *`;
|
||||
}
|
||||
|
||||
async function copy(pkg: Package) {
|
||||
await navigator.clipboard.writeText(importSnippet(pkg));
|
||||
copied = pkg.id;
|
||||
setTimeout(() => (copied = ''), 2000);
|
||||
}
|
||||
|
||||
async function remove(pkg: Package) {
|
||||
if (!confirm(`Delete package "${pkg.name}" and all its versions?`)) return;
|
||||
const res = await fetch(`/api/packages/${pkg.name}`, { method: 'DELETE' });
|
||||
if (res.ok) packages = packages.filter((p) => p.id !== pkg.id);
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Packages - TypstDrive</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-screen bg-gray-50 dark:bg-[var(--theme-bg)]">
|
||||
<Navbar />
|
||||
|
||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="mb-6">
|
||||
<button onclick={() => goto('/dashboard')} class="text-sm font-medium text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white transition-colors mb-2 flex items-center gap-1.5">
|
||||
<Icon icon="mdi:arrow-left" class="text-lg" />
|
||||
Back to Dashboard
|
||||
</button>
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Icon icon="mdi:package-variant-closed" class="text-purple-500" />
|
||||
Packages
|
||||
</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Instance-local Typst packages, published from Spaces and importable as
|
||||
<code class="font-mono text-xs bg-gray-100 dark:bg-white/10 px-1.5 py-0.5 rounded">@typstdrive/<name>:<version></code>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<p class="text-gray-500 dark:text-gray-400">Loading…</p>
|
||||
{:else if packages.length === 0}
|
||||
<div class="text-center py-16 text-gray-500 dark:text-gray-400">
|
||||
<Icon icon="mdi:package-variant" class="text-5xl mx-auto mb-3 opacity-50" />
|
||||
<p>No packages published yet. Open a Space and use “Publish” to create one.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each packages as pkg (pkg.id)}
|
||||
<div class="bg-white dark:bg-black/20 rounded-xl border border-gray-200 dark:border-white/10 p-4 flex items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="font-semibold text-gray-900 dark:text-white truncate">@typstdrive/{pkg.name}</p>
|
||||
{#if pkg.latest_version}
|
||||
<span class="text-xs font-mono bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 px-1.5 py-0.5 rounded">v{pkg.latest_version}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if pkg.description}
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1 truncate">{pkg.description}</p>
|
||||
{/if}
|
||||
<p class="text-xs text-gray-400 mt-1">by {pkg.owner_name ?? 'unknown'}</p>
|
||||
<pre class="mt-2 text-xs font-mono bg-gray-50 dark:bg-black/30 border border-gray-100 dark:border-white/5 rounded px-2 py-1 overflow-x-auto">{importSnippet(pkg)}</pre>
|
||||
</div>
|
||||
<div class="flex flex-col items-end gap-2 flex-shrink-0">
|
||||
<button onclick={() => copy(pkg)} class="text-xs px-2 py-1 rounded-md bg-gray-100 dark:bg-white/5 hover:bg-gray-200 dark:hover:bg-white/10 flex items-center gap-1">
|
||||
<Icon icon={copied === pkg.id ? 'mdi:check' : 'mdi:content-copy'} class="text-sm" />
|
||||
{copied === pkg.id ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
<button onclick={() => remove(pkg)} title="Delete" class="text-xs px-2 py-1 rounded-md text-gray-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center gap-1">
|
||||
<Icon icon="mdi:trash-can-outline" class="text-sm" /> Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,258 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import Editor from '$lib/components/Editor.svelte';
|
||||
import Preview from '$lib/components/Preview.svelte';
|
||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||
import DocFooter from '$lib/components/DocFooter.svelte';
|
||||
import FileTree from '$lib/components/space/FileTree.svelte';
|
||||
import SpaceToolbar from '$lib/components/space/SpaceToolbar.svelte';
|
||||
import PublishPackageModal from '$lib/components/PublishPackageModal.svelte';
|
||||
import { compileSpace } from '$lib/ts/typst-api';
|
||||
import type { Diagnostic } from '$lib/ts/typst-api';
|
||||
import { editorErrors, documentStatsStore, previewOpenStore, editorViewStore } from '$lib/ts/store';
|
||||
import { setSpace, openFile, getOpenFile, closeFile, renameOpenFile, getAllText, cleanupSpace } from '$lib/ts/yjs-space';
|
||||
|
||||
interface SpaceFile {
|
||||
id: string;
|
||||
path: string;
|
||||
kind: string;
|
||||
}
|
||||
|
||||
const spaceId = $page.params.id;
|
||||
|
||||
let spaceName = $state('Space');
|
||||
let entrypoint = $state('main.typ');
|
||||
let role = $state('owner');
|
||||
let files = $state<SpaceFile[]>([]);
|
||||
let activeFileId = $state('');
|
||||
let svgs = $state<string[]>([]);
|
||||
let errors = $state<Diagnostic[]>([]);
|
||||
let showPublish = $state(false);
|
||||
let ready = $state(false);
|
||||
let timeoutId: number | undefined;
|
||||
|
||||
let contextMenu = $state({ show: false, x: 0, y: 0, text: '' });
|
||||
|
||||
let readOnly = $derived(role === 'viewer');
|
||||
let activeEntry = $derived(activeFileId ? getOpenFile(activeFileId) : undefined);
|
||||
let activePath = $derived(files.find((f) => f.id === activeFileId)?.path ?? '');
|
||||
|
||||
function scheduleCompile() {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
timeoutId = window.setTimeout(triggerCompile, 500);
|
||||
}
|
||||
|
||||
function triggerCompile() {
|
||||
if (!$previewOpenStore) return;
|
||||
compileSpace(spaceId, getAllText())
|
||||
.then((res) => {
|
||||
if (res.stats) $documentStatsStore = res.stats;
|
||||
if (res.svgs) {
|
||||
svgs = res.svgs;
|
||||
errors = [];
|
||||
$editorErrors = [];
|
||||
} else if (res.errors) {
|
||||
errors = res.errors;
|
||||
$editorErrors = res.errors;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
errors = [{ message: 'Network or server error compiling space.', severity: 'error' }];
|
||||
});
|
||||
}
|
||||
|
||||
async function loadFiles() {
|
||||
const res = await fetch(`/api/spaces/${spaceId}/files`);
|
||||
if (!res.ok) return;
|
||||
files = await res.json();
|
||||
|
||||
for (const f of files) {
|
||||
if (f.kind === 'text') {
|
||||
const entry = openFile(f.id, f.path);
|
||||
entry.text.observe(scheduleCompile);
|
||||
}
|
||||
}
|
||||
|
||||
if (!activeFileId) {
|
||||
const entry = files.find((f) => f.path === entrypoint) ?? files.find((f) => f.kind === 'text');
|
||||
if (entry) activeFileId = entry.id;
|
||||
}
|
||||
}
|
||||
|
||||
function selectFile(file: SpaceFile) {
|
||||
if (file.kind !== 'text') return;
|
||||
activeFileId = file.id;
|
||||
}
|
||||
|
||||
async function createFile(path: string) {
|
||||
const res = await fetch(`/api/spaces/${spaceId}/files`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path, kind: 'text', content: '' })
|
||||
});
|
||||
if (res.ok) {
|
||||
const file = await res.json();
|
||||
files = [...files, file].sort((a, b) => a.path.localeCompare(b.path));
|
||||
const entry = openFile(file.id, file.path);
|
||||
entry.text.observe(scheduleCompile);
|
||||
activeFileId = file.id;
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadFiles(fileList: FileList) {
|
||||
const form = new FormData();
|
||||
for (const f of fileList) form.append('file', f);
|
||||
const res = await fetch(`/api/spaces/${spaceId}/files/upload`, { method: 'POST', body: form });
|
||||
if (res.ok) {
|
||||
await loadFiles();
|
||||
triggerCompile();
|
||||
}
|
||||
}
|
||||
|
||||
async function renameFile(file: SpaceFile, path: string) {
|
||||
const res = await fetch(`/api/spaces/${spaceId}/files/${file.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path })
|
||||
});
|
||||
if (res.ok) {
|
||||
files = files.map((f) => (f.id === file.id ? { ...f, path } : f));
|
||||
renameOpenFile(file.id, path);
|
||||
scheduleCompile();
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteFile(file: SpaceFile) {
|
||||
if (!confirm(`Delete ${file.path}?`)) return;
|
||||
const res = await fetch(`/api/spaces/${spaceId}/files/${file.id}`, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
closeFile(file.id);
|
||||
files = files.filter((f) => f.id !== file.id);
|
||||
if (activeFileId === file.id) {
|
||||
activeFileId = files.find((f) => f.kind === 'text')?.id ?? '';
|
||||
}
|
||||
scheduleCompile();
|
||||
}
|
||||
}
|
||||
|
||||
async function setEntry(file: SpaceFile) {
|
||||
const res = await fetch(`/api/spaces/${spaceId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ entrypoint: file.path })
|
||||
});
|
||||
if (res.ok) {
|
||||
entrypoint = file.path;
|
||||
scheduleCompile();
|
||||
}
|
||||
}
|
||||
|
||||
function handleContextMenu(e: MouseEvent) {
|
||||
const view = $editorViewStore;
|
||||
if (!view) return;
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target.closest('.cm-editor') && !target.closest('.cm-content')) return;
|
||||
const selection = view.state.selection.main;
|
||||
const selectedText = view.state.doc.sliceString(selection.from, selection.to);
|
||||
if (selectedText.trim()) {
|
||||
e.preventDefault();
|
||||
contextMenu = { show: true, x: e.clientX, y: e.clientY, text: selectedText.trim() };
|
||||
}
|
||||
}
|
||||
|
||||
function closeContextMenu() {
|
||||
contextMenu.show = false;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
setSpace(spaceId);
|
||||
fetch(`/api/spaces/${spaceId}`)
|
||||
.then((r) => r.json())
|
||||
.then((s) => {
|
||||
if (s && s.name) spaceName = s.name;
|
||||
if (s && s.entrypoint) entrypoint = s.entrypoint;
|
||||
if (s && s.effective_role) role = s.effective_role;
|
||||
})
|
||||
.then(loadFiles)
|
||||
.then(() => {
|
||||
ready = true;
|
||||
triggerCompile();
|
||||
})
|
||||
.catch((e) => console.error('Failed to load space', e));
|
||||
|
||||
return () => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
cleanupSpace();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{spaceName} - TypstDrive</title>
|
||||
</svelte:head>
|
||||
|
||||
<svelte:window onclick={closeContextMenu} />
|
||||
|
||||
<div class="flex flex-col h-screen relative">
|
||||
<SpaceToolbar
|
||||
{spaceName}
|
||||
{spaceId}
|
||||
{entrypoint}
|
||||
{role}
|
||||
activeText={activeEntry?.text ?? null}
|
||||
{activePath}
|
||||
{getAllText}
|
||||
onPublish={() => (showPublish = true)}
|
||||
onFilesChanged={loadFiles}
|
||||
/>
|
||||
|
||||
<main class="flex-1 flex overflow-hidden relative" oncontextmenu={handleContextMenu}>
|
||||
<aside class="w-56 flex-shrink-0 hidden md:block">
|
||||
<FileTree
|
||||
{files}
|
||||
{activeFileId}
|
||||
{entrypoint}
|
||||
{readOnly}
|
||||
onSelect={selectFile}
|
||||
onCreate={createFile}
|
||||
onUpload={uploadFiles}
|
||||
onRename={renameFile}
|
||||
onDelete={deleteFile}
|
||||
onSetEntry={setEntry}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
{#if !readOnly}
|
||||
<div class="flex flex-col min-h-0 {$previewOpenStore ? 'w-full md:w-1/2 border-r border-gray-200 dark:border-white/10' : 'flex-1'}">
|
||||
{#if ready && activeEntry}
|
||||
{#key activeFileId}
|
||||
<Editor ytext={activeEntry.text} awarenessProvider={activeEntry.provider} filePath={activePath} enableLsp={false} />
|
||||
{/key}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if $previewOpenStore || readOnly}
|
||||
<div class="{readOnly ? 'flex-1' : 'w-full md:w-1/2'} relative bg-white/50 dark:bg-black/20 flex flex-col">
|
||||
<Preview {svgs} />
|
||||
<ErrorBanner {errors} />
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<DocFooter />
|
||||
</div>
|
||||
|
||||
{#if contextMenu.show}
|
||||
<div class="fixed z-[9999] bg-[var(--theme-bg)] text-[var(--theme-text)] rounded-lg shadow-xl border border-[var(--theme-border)] py-1 min-w-[180px] overflow-hidden" style="left: {contextMenu.x}px; top: {contextMenu.y}px;">
|
||||
<button onclick={() => { navigator.clipboard.writeText(contextMenu.text); closeContextMenu(); }} class="w-full text-left px-4 py-2 text-sm hover:bg-[var(--theme-border)] flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-gray-500"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
|
||||
Copy Text
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showPublish}
|
||||
<PublishPackageModal {spaceId} onClose={() => (showPublish = false)} />
|
||||
{/if}
|
||||
@@ -0,0 +1,207 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import Icon from '@iconify/svelte';
|
||||
import Navbar from '$lib/components/dashboard/Navbar.svelte';
|
||||
import SpaceCard from '$lib/components/dashboard/SpaceCard.svelte';
|
||||
|
||||
interface Space {
|
||||
id: string;
|
||||
name: string;
|
||||
entrypoint: string;
|
||||
thumbnail_svg?: string;
|
||||
updated_at: string;
|
||||
effective_role?: string;
|
||||
}
|
||||
|
||||
let spaces = $state<Space[]>([]);
|
||||
let shared = $state<Space[]>([]);
|
||||
let loading = $state(true);
|
||||
let showCreate = $state(false);
|
||||
let newName = $state('');
|
||||
let creating = $state(false);
|
||||
|
||||
let activeMenu = $state<string | null>(null);
|
||||
let showRename = $state(false);
|
||||
let renameId = $state('');
|
||||
let renameName = $state('');
|
||||
let showInfo = $state(false);
|
||||
let infoSpace = $state<Space | null>(null);
|
||||
|
||||
function setActiveMenu(id: string | null) { activeMenu = id; }
|
||||
function openInfo(space: Space) { activeMenu = null; infoSpace = space; showInfo = true; }
|
||||
function openRename(id: string, name: string) { activeMenu = null; renameId = id; renameName = name; showRename = true; }
|
||||
|
||||
async function submitRename(e: Event) {
|
||||
e.preventDefault();
|
||||
if (!renameName.trim()) return;
|
||||
const res = await fetch(`/api/spaces/${renameId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: renameName.trim() })
|
||||
});
|
||||
if (res.ok) {
|
||||
spaces = spaces.map((s) => (s.id === renameId ? { ...s, name: renameName.trim() } : s));
|
||||
}
|
||||
showRename = false;
|
||||
}
|
||||
|
||||
function handleWindowClick(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target.closest('.action-menu-container')) activeMenu = null;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
const [own, sh] = await Promise.all([
|
||||
fetch('/api/spaces').then((r) => (r.ok ? r.json() : [])),
|
||||
fetch('/api/spaces/shared').then((r) => (r.ok ? r.json() : []))
|
||||
]);
|
||||
spaces = own;
|
||||
shared = sh;
|
||||
loading = false;
|
||||
}
|
||||
|
||||
async function create() {
|
||||
if (!newName.trim()) return;
|
||||
creating = true;
|
||||
const res = await fetch('/api/spaces', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: newName.trim() })
|
||||
});
|
||||
creating = false;
|
||||
if (res.ok) {
|
||||
const space = await res.json();
|
||||
goto(`/space/${space.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id: string, name: string) {
|
||||
if (!confirm(`Delete space "${name}"? This cannot be undone.`)) return;
|
||||
const res = await fetch(`/api/spaces/${id}`, { method: 'DELETE' });
|
||||
if (res.ok) spaces = spaces.filter((s) => s.id !== id);
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Spaces - TypstDrive</title>
|
||||
</svelte:head>
|
||||
|
||||
<svelte:window onclick={handleWindowClick} />
|
||||
|
||||
<div class="min-h-screen bg-gray-50 dark:bg-[var(--theme-bg)]">
|
||||
<Navbar />
|
||||
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<button onclick={() => goto('/dashboard')} class="text-sm font-medium text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white transition-colors mb-2 flex items-center gap-1.5">
|
||||
<Icon icon="mdi:arrow-left" class="text-lg" />
|
||||
Back to Dashboard
|
||||
</button>
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Icon icon="mdi:folder-multiple-outline" class="text-blue-500" />
|
||||
Spaces
|
||||
</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">Multi-file Typst workspaces with their own <code class="font-mono text-xs">typst.toml</code>.</p>
|
||||
</div>
|
||||
<button onclick={() => { showCreate = true; newName = ''; }} class="px-4 py-2 text-sm rounded-lg bg-blue-600 text-white hover:bg-blue-700 flex items-center gap-2">
|
||||
<Icon icon="mdi:plus" class="text-lg" /> New Space
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<p class="text-gray-500 dark:text-gray-400">Loading…</p>
|
||||
{:else}
|
||||
{#if spaces.length === 0}
|
||||
<div class="text-center py-16 text-gray-500 dark:text-gray-400">
|
||||
<Icon icon="mdi:folder-multiple-outline" class="text-5xl mx-auto mb-3 opacity-50" />
|
||||
<p>No spaces yet. Create one to start a multi-file project.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{#each spaces as space (space.id)}
|
||||
<SpaceCard
|
||||
{space}
|
||||
{activeMenu}
|
||||
{setActiveMenu}
|
||||
{openInfo}
|
||||
{openRename}
|
||||
deleteSpace={remove}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if shared.length > 0}
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-10 mb-4 flex items-center gap-2">
|
||||
<Icon icon="mdi:account-group-outline" class="text-blue-500" /> Shared with me
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{#each shared as space (space.id)}
|
||||
<button onclick={() => goto(`/space/${space.id}`)} class="text-left bg-white dark:bg-black/20 rounded-xl border border-gray-200 dark:border-white/10 overflow-hidden hover:shadow-md transition-shadow">
|
||||
<div class="h-32 bg-gray-50 dark:bg-black/30 flex items-center justify-center overflow-hidden border-b border-gray-100 dark:border-white/5">
|
||||
{#if space.thumbnail_svg}
|
||||
{@html space.thumbnail_svg}
|
||||
{:else}
|
||||
<Icon icon="mdi:folder-multiple-outline" class="text-4xl text-gray-300 dark:text-gray-600" />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="p-3">
|
||||
<p class="font-medium text-gray-900 dark:text-white truncate">{space.name}</p>
|
||||
<p class="text-xs text-gray-400 mt-0.5">{space.effective_role}</p>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showCreate}
|
||||
<div class="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50" onclick={() => (showCreate = false)} role="presentation">
|
||||
<div class="bg-[var(--theme-bg)] text-[var(--theme-text)] rounded-xl shadow-2xl border border-gray-200 dark:border-white/10 w-full max-w-md p-6" onclick={(e) => e.stopPropagation()} role="presentation">
|
||||
<h2 class="text-lg font-bold mb-4 flex items-center gap-2"><Icon icon="mdi:folder-plus-outline" class="text-blue-500" /> New Space</h2>
|
||||
<input bind:value={newName} placeholder="Space name" onkeydown={(e) => 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" />
|
||||
<div class="flex justify-end gap-2">
|
||||
<button onclick={() => (showCreate = false)} class="px-4 py-2 text-sm rounded-lg bg-gray-100 dark:bg-white/5 hover:bg-gray-200 dark:hover:bg-white/10">Cancel</button>
|
||||
<button onclick={create} disabled={creating} class="px-4 py-2 text-sm rounded-lg bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showRename}
|
||||
<div class="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50" onclick={() => (showRename = false)} role="presentation">
|
||||
<form onsubmit={submitRename} class="bg-[var(--theme-bg)] text-[var(--theme-text)] rounded-xl shadow-2xl border border-gray-200 dark:border-white/10 w-full max-w-md p-6" onclick={(e) => e.stopPropagation()}>
|
||||
<h2 class="text-lg font-bold mb-4 flex items-center gap-2"><Icon icon="mdi:pencil-outline" class="text-yellow-500" /> Rename Space</h2>
|
||||
<input bind:value={renameName} 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" />
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" onclick={() => (showRename = false)} class="px-4 py-2 text-sm rounded-lg bg-gray-100 dark:bg-white/5 hover:bg-gray-200 dark:hover:bg-white/10">Cancel</button>
|
||||
<button type="submit" class="px-4 py-2 text-sm rounded-lg bg-blue-600 text-white hover:bg-blue-700">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showInfo && infoSpace}
|
||||
<div class="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50" onclick={() => (showInfo = false)} role="presentation">
|
||||
<div class="bg-[var(--theme-bg)] text-[var(--theme-text)] rounded-xl shadow-2xl border border-gray-200 dark:border-white/10 w-full max-w-sm overflow-hidden" onclick={(e) => e.stopPropagation()} role="presentation">
|
||||
<div class="p-6 border-b border-gray-100 dark:border-white/10 flex items-center gap-3">
|
||||
<Icon icon="mdi:folder-multiple-outline" class="text-xl text-blue-500" />
|
||||
<h3 class="text-lg font-semibold flex-grow truncate">{infoSpace.name}</h3>
|
||||
</div>
|
||||
<div class="p-6 space-y-4 text-sm">
|
||||
<div><p class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1">Entrypoint</p><p class="font-mono">{infoSpace.entrypoint}</p></div>
|
||||
<div><p class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1">Last Modified</p><p>{new Date(infoSpace.updated_at.endsWith('Z') ? infoSpace.updated_at : infoSpace.updated_at + 'Z').toLocaleString()}</p></div>
|
||||
</div>
|
||||
<div class="p-4 bg-gray-50 dark:bg-white/5 border-t border-gray-100 dark:border-white/10 flex justify-end">
|
||||
<button onclick={() => (showInfo = false)} class="px-5 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
Reference in New Issue
Block a user