Rename Space to Project, add desktop document creation

This commit is contained in:
2026-07-20 17:48:14 -04:00
parent 5d3681bd3f
commit 2cfa4afe92
21 changed files with 494 additions and 428 deletions
+1 -1
View File
@@ -19,7 +19,7 @@
"@tailwindcss/forms": "^0.5.11", "@tailwindcss/forms": "^0.5.11",
"@tailwindcss/typography": "^0.5.20", "@tailwindcss/typography": "^0.5.20",
"@tailwindcss/vite": "^4.3.3", "@tailwindcss/vite": "^4.3.3",
"svelte": "^5.56.6", "svelte": "^5.56.7",
"svelte-check": "^4.7.3", "svelte-check": "^4.7.3",
"tailwindcss": "^4.3.3", "tailwindcss": "^4.3.3",
"typescript": "^5.9.3", "typescript": "^5.9.3",
+23 -8
View File
@@ -1,6 +1,21 @@
use sqlx::AnyPool; use sqlx::AnyPool;
pub async fn init_schema(pool: &AnyPool) { pub async fn init_schema(pool: &AnyPool) {
// Rename the legacy "space" tables/columns to the "project" vocabulary on
// existing databases. Best-effort: on a fresh database (or one already
// migrated) the old names don't exist, so these fail silently and the
// CREATE TABLE IF NOT EXISTS statements below take over.
let rename_migrations = [
"ALTER TABLE IF EXISTS spaces RENAME TO projects",
"ALTER TABLE IF EXISTS space_files RENAME TO project_files",
"ALTER TABLE IF EXISTS space_collaborators RENAME TO project_collaborators",
"ALTER TABLE IF EXISTS project_files RENAME COLUMN space_id TO project_id",
"ALTER TABLE IF EXISTS project_collaborators RENAME COLUMN space_id TO project_id",
];
for stmt in &rename_migrations {
let _ = sqlx::query(stmt).execute(pool).await;
}
let statements = [ let statements = [
"CREATE TABLE IF NOT EXISTS users ( "CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@@ -105,7 +120,7 @@ pub async fn init_schema(pool: &AnyPool) {
count INTEGER NOT NULL DEFAULT 1, count INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY(key_id, minute) PRIMARY KEY(key_id, minute)
)", )",
"CREATE TABLE IF NOT EXISTS spaces ( "CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL REFERENCES users(id), owner_id TEXT NOT NULL REFERENCES users(id),
folder_id TEXT REFERENCES folders(id), folder_id TEXT REFERENCES folders(id),
@@ -116,23 +131,23 @@ pub async fn init_schema(pool: &AnyPool) {
created_at TEXT DEFAULT to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS'), 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') updated_at TEXT DEFAULT to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS')
)", )",
"CREATE TABLE IF NOT EXISTS space_files ( "CREATE TABLE IF NOT EXISTS project_files (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
path TEXT NOT NULL, path TEXT NOT NULL,
kind TEXT NOT NULL DEFAULT 'text', kind TEXT NOT NULL DEFAULT 'text',
content BYTEA, content BYTEA,
mime_type TEXT NOT NULL DEFAULT 'text/plain', mime_type TEXT NOT NULL DEFAULT 'text/plain',
created_at TEXT DEFAULT to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS'), created_at TEXT DEFAULT to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS'),
UNIQUE(space_id, path) UNIQUE(project_id, path)
)", )",
"CREATE TABLE IF NOT EXISTS space_collaborators ( "CREATE TABLE IF NOT EXISTS project_collaborators (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL, role TEXT NOT NULL,
created_at TEXT DEFAULT to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS'), created_at TEXT DEFAULT to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS'),
UNIQUE(space_id, user_id) UNIQUE(project_id, user_id)
)", )",
"CREATE TABLE IF NOT EXISTS packages ( "CREATE TABLE IF NOT EXISTS packages (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@@ -181,7 +196,7 @@ pub async fn init_schema(pool: &AnyPool) {
"ALTER TABLE documents ADD COLUMN IF NOT EXISTS public_role TEXT", "ALTER TABLE documents ADD COLUMN IF NOT EXISTS public_role TEXT",
"ALTER TABLE users ADD COLUMN IF NOT EXISTS is_admin BOOLEAN NOT NULL DEFAULT FALSE", "ALTER TABLE users ADD COLUMN IF NOT EXISTS is_admin BOOLEAN NOT NULL DEFAULT FALSE",
"ALTER TABLE users ADD COLUMN IF NOT EXISTS created_at TEXT DEFAULT to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS')", "ALTER TABLE users ADD COLUMN IF NOT EXISTS created_at TEXT DEFAULT to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS')",
"ALTER TABLE space_files ADD COLUMN IF NOT EXISTS updated_at TEXT DEFAULT to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS')", "ALTER TABLE project_files ADD COLUMN IF NOT EXISTS updated_at TEXT DEFAULT to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS')",
]; ];
for stmt in &migrations { for stmt in &migrations {
sqlx::query(stmt).execute(pool).await.unwrap_or_else(|_| Default::default()); sqlx::query(stmt).execute(pool).await.unwrap_or_else(|_| Default::default());
+23 -8
View File
@@ -6,6 +6,21 @@ pub async fn init_schema(pool: &AnyPool) {
.await .await
.expect("Failed to enable SQLite foreign keys"); .expect("Failed to enable SQLite foreign keys");
// Rename the legacy "space" tables/columns to the "project" vocabulary on
// existing databases. Best-effort: on a fresh database (or one already
// migrated) the old names don't exist, so these fail silently and the
// CREATE TABLE IF NOT EXISTS statements below take over.
let rename_migrations = [
"ALTER TABLE spaces RENAME TO projects",
"ALTER TABLE space_files RENAME TO project_files",
"ALTER TABLE space_collaborators RENAME TO project_collaborators",
"ALTER TABLE project_files RENAME COLUMN space_id TO project_id",
"ALTER TABLE project_collaborators RENAME COLUMN space_id TO project_id",
];
for stmt in &rename_migrations {
let _ = sqlx::query(stmt).execute(pool).await;
}
let statements = [ let statements = [
"CREATE TABLE IF NOT EXISTS users ( "CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@@ -110,7 +125,7 @@ pub async fn init_schema(pool: &AnyPool) {
count INTEGER NOT NULL DEFAULT 1, count INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY(key_id, minute) PRIMARY KEY(key_id, minute)
)", )",
"CREATE TABLE IF NOT EXISTS spaces ( "CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL REFERENCES users(id), owner_id TEXT NOT NULL REFERENCES users(id),
folder_id TEXT REFERENCES folders(id), folder_id TEXT REFERENCES folders(id),
@@ -121,23 +136,23 @@ pub async fn init_schema(pool: &AnyPool) {
created_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now')), created_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now')),
updated_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 ( "CREATE TABLE IF NOT EXISTS project_files (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
path TEXT NOT NULL, path TEXT NOT NULL,
kind TEXT NOT NULL DEFAULT 'text', kind TEXT NOT NULL DEFAULT 'text',
content BLOB, content BLOB,
mime_type TEXT NOT NULL DEFAULT 'text/plain', mime_type TEXT NOT NULL DEFAULT 'text/plain',
created_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now')), created_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now')),
UNIQUE(space_id, path) UNIQUE(project_id, path)
)", )",
"CREATE TABLE IF NOT EXISTS space_collaborators ( "CREATE TABLE IF NOT EXISTS project_collaborators (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL, role TEXT NOT NULL,
created_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now')), created_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now')),
UNIQUE(space_id, user_id) UNIQUE(project_id, user_id)
)", )",
"CREATE TABLE IF NOT EXISTS packages ( "CREATE TABLE IF NOT EXISTS packages (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@@ -185,7 +200,7 @@ pub async fn init_schema(pool: &AnyPool) {
let migrations = [ let migrations = [
"ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0", "ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE users ADD COLUMN created_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now'))", "ALTER TABLE users ADD COLUMN created_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now'))",
"ALTER TABLE space_files ADD COLUMN updated_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now'))", "ALTER TABLE project_files ADD COLUMN updated_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now'))",
]; ];
for stmt in &migrations { for stmt in &migrations {
let _ = sqlx::query(stmt).execute(pool).await; let _ = sqlx::query(stmt).execute(pool).await;
+131 -95
View File
@@ -14,8 +14,8 @@ use argon2::{
}; };
use crate::{ use crate::{
models::{Space, User}, models::{Project, User},
spaces::{decode_text_blob, encode_text_blob}, projects::{decode_text_blob, encode_text_blob},
AppState, AppState,
}; };
@@ -81,28 +81,28 @@ pub async fn authenticate(
Ok(user_id) Ok(user_id)
} }
async fn owned_space( async fn owned_project(
state: &AppState, state: &AppState,
space_id: &str, project_id: &str,
user_id: &str, user_id: &str,
) -> Result<Space, (StatusCode, String)> { ) -> Result<Project, (StatusCode, String)> {
let space = sqlx::query_as::<_, Space>( let project = sqlx::query_as::<_, Project>(
"SELECT s.id, s.owner_id, s.folder_id, s.name, s.entrypoint, s.thumbnail_svg, \ "SELECT p.id, p.owner_id, p.folder_id, p.name, p.entrypoint, p.thumbnail_svg, \
s.public_role, s.created_at, s.updated_at FROM spaces s \ p.public_role, p.created_at, p.updated_at FROM projects p \
WHERE s.id = ? AND (s.owner_id = ? OR EXISTS ( \ WHERE p.id = ? AND (p.owner_id = ? OR EXISTS ( \
SELECT 1 FROM space_collaborators c \ SELECT 1 FROM project_collaborators c \
WHERE c.space_id = s.id AND c.user_id = ? AND c.role = 'editor'))", WHERE c.project_id = p.id AND c.user_id = ? AND c.role = 'editor'))",
) )
.bind(space_id) .bind(project_id)
.bind(user_id) .bind(user_id)
.bind(user_id) .bind(user_id)
.fetch_optional(&state.db) .fetch_optional(&state.db)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
space.ok_or(( project.ok_or((
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,
"Space not found or not writable".to_string(), "Project not found or not writable".to_string(),
)) ))
} }
@@ -224,7 +224,7 @@ pub async fn logout(
} }
#[derive(Serialize)] #[derive(Serialize)]
pub struct SpaceSummary { pub struct ProjectSummary {
pub id: String, pub id: String,
pub name: String, pub name: String,
pub entrypoint: String, pub entrypoint: String,
@@ -232,14 +232,14 @@ pub struct SpaceSummary {
pub updated_at: String, pub updated_at: String,
} }
pub async fn list_spaces( pub async fn list_projects(
State(state): State<AppState>, State(state): State<AppState>,
headers: HeaderMap, headers: HeaderMap,
) -> Result<Json<Vec<SpaceSummary>>, (StatusCode, String)> { ) -> Result<Json<Vec<ProjectSummary>>, (StatusCode, String)> {
let user_id = authenticate(&state, &headers).await?; let user_id = authenticate(&state, &headers).await?;
let owned = sqlx::query_as::<_, (String, String, String, String)>( let owned = sqlx::query_as::<_, (String, String, String, String)>(
"SELECT id, name, entrypoint, updated_at FROM spaces WHERE owner_id = ? ORDER BY updated_at DESC", "SELECT id, name, entrypoint, updated_at FROM projects WHERE owner_id = ? ORDER BY updated_at DESC",
) )
.bind(&user_id) .bind(&user_id)
.fetch_all(&state.db) .fetch_all(&state.db)
@@ -247,18 +247,18 @@ pub async fn list_spaces(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let shared = sqlx::query_as::<_, (String, String, String, String, String)>( let shared = sqlx::query_as::<_, (String, String, String, String, String)>(
"SELECT s.id, s.name, s.entrypoint, s.updated_at, c.role FROM spaces s \ "SELECT p.id, p.name, p.entrypoint, p.updated_at, c.role FROM projects p \
INNER JOIN space_collaborators c ON c.space_id = s.id AND c.user_id = ? \ INNER JOIN project_collaborators c ON c.project_id = p.id AND c.user_id = ? \
ORDER BY s.updated_at DESC", ORDER BY p.updated_at DESC",
) )
.bind(&user_id) .bind(&user_id)
.fetch_all(&state.db) .fetch_all(&state.db)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let mut spaces: Vec<SpaceSummary> = owned let mut projects: Vec<ProjectSummary> = owned
.into_iter() .into_iter()
.map(|(id, name, entrypoint, updated_at)| SpaceSummary { .map(|(id, name, entrypoint, updated_at)| ProjectSummary {
id, id,
name, name,
entrypoint, entrypoint,
@@ -267,10 +267,10 @@ pub async fn list_spaces(
}) })
.collect(); .collect();
spaces.extend( projects.extend(
shared shared
.into_iter() .into_iter()
.map(|(id, name, entrypoint, updated_at, role)| SpaceSummary { .map(|(id, name, entrypoint, updated_at, role)| ProjectSummary {
id, id,
name, name,
entrypoint, entrypoint,
@@ -279,36 +279,36 @@ pub async fn list_spaces(
}), }),
); );
Ok(Json(spaces)) Ok(Json(projects))
} }
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct CreateSpaceBody { pub struct CreateProjectBody {
pub name: String, pub name: String,
pub entrypoint: Option<String>, pub entrypoint: Option<String>,
} }
pub async fn create_space( pub async fn create_project(
State(state): State<AppState>, State(state): State<AppState>,
headers: HeaderMap, headers: HeaderMap,
Json(payload): Json<CreateSpaceBody>, Json(payload): Json<CreateProjectBody>,
) -> Result<Json<SpaceSummary>, (StatusCode, String)> { ) -> Result<Json<ProjectSummary>, (StatusCode, String)> {
let user_id = authenticate(&state, &headers).await?; let user_id = authenticate(&state, &headers).await?;
if payload.name.trim().is_empty() { if payload.name.trim().is_empty() {
return Err((StatusCode::BAD_REQUEST, "Name cannot be empty".to_string())); return Err((StatusCode::BAD_REQUEST, "Name cannot be empty".to_string()));
} }
let space_id = Uuid::new_v4().to_string(); let project_id = Uuid::new_v4().to_string();
let entrypoint = payload let entrypoint = payload
.entrypoint .entrypoint
.unwrap_or_else(|| "main.typ".to_string()); .unwrap_or_else(|| "main.typ".to_string());
let space = sqlx::query_as::<_, Space>( let project = sqlx::query_as::<_, Project>(
"INSERT INTO spaces (id, owner_id, name, entrypoint) VALUES (?, ?, ?, ?) \ "INSERT INTO projects (id, owner_id, name, entrypoint) VALUES (?, ?, ?, ?) \
RETURNING id, owner_id, folder_id, name, entrypoint, thumbnail_svg, public_role, created_at, updated_at", RETURNING id, owner_id, folder_id, name, entrypoint, thumbnail_svg, public_role, created_at, updated_at",
) )
.bind(&space_id) .bind(&project_id)
.bind(&user_id) .bind(&user_id)
.bind(payload.name.trim()) .bind(payload.name.trim())
.bind(&entrypoint) .bind(&entrypoint)
@@ -316,36 +316,36 @@ pub async fn create_space(
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(SpaceSummary { Ok(Json(ProjectSummary {
id: space.id, id: project.id,
name: space.name, name: project.name,
entrypoint: space.entrypoint, entrypoint: project.entrypoint,
role: "owner".to_string(), role: "owner".to_string(),
updated_at: space.updated_at, updated_at: project.updated_at,
})) }))
} }
pub async fn delete_space( pub async fn delete_project(
State(state): State<AppState>, State(state): State<AppState>,
headers: HeaderMap, headers: HeaderMap,
Path(space_id): Path<String>, Path(project_id): Path<String>,
) -> Result<StatusCode, (StatusCode, String)> { ) -> Result<StatusCode, (StatusCode, String)> {
let user_id = authenticate(&state, &headers).await?; let user_id = authenticate(&state, &headers).await?;
let _ = sqlx::query("DELETE FROM space_files WHERE space_id = ?") let _ = sqlx::query("DELETE FROM project_files WHERE project_id = ?")
.bind(&space_id) .bind(&project_id)
.execute(&state.db) .execute(&state.db)
.await; .await;
let result = sqlx::query("DELETE FROM spaces WHERE id = ? AND owner_id = ?") let result = sqlx::query("DELETE FROM projects WHERE id = ? AND owner_id = ?")
.bind(&space_id) .bind(&project_id)
.bind(&user_id) .bind(&user_id)
.execute(&state.db) .execute(&state.db)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if result.rows_affected() == 0 { if result.rows_affected() == 0 {
return Err((StatusCode::NOT_FOUND, "Space not found".to_string())); return Err((StatusCode::NOT_FOUND, "Project not found".to_string()));
} }
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
@@ -361,8 +361,8 @@ pub struct ManifestEntry {
} }
#[derive(Serialize)] #[derive(Serialize)]
pub struct SpaceManifest { pub struct ProjectManifest {
pub space_id: String, pub project_id: String,
pub name: String, pub name: String,
pub entrypoint: String, pub entrypoint: String,
pub updated_at: String, pub updated_at: String,
@@ -371,12 +371,12 @@ pub struct SpaceManifest {
async fn plain_contents( async fn plain_contents(
state: &AppState, state: &AppState,
space_id: &str, project_id: &str,
) -> Result<Vec<(String, String, Vec<u8>, String)>, (StatusCode, String)> { ) -> Result<Vec<(String, String, Vec<u8>, String)>, (StatusCode, String)> {
let rows = sqlx::query_as::<_, (String, String, Option<Vec<u8>>, Option<String>)>( let rows = sqlx::query_as::<_, (String, String, Option<Vec<u8>>, Option<String>)>(
"SELECT path, kind, content, updated_at FROM space_files WHERE space_id = ? ORDER BY path ASC", "SELECT path, kind, content, updated_at FROM project_files WHERE project_id = ? ORDER BY path ASC",
) )
.bind(space_id) .bind(project_id)
.fetch_all(&state.db) .fetch_all(&state.db)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
@@ -398,12 +398,12 @@ async fn plain_contents(
pub async fn get_manifest( pub async fn get_manifest(
State(state): State<AppState>, State(state): State<AppState>,
headers: HeaderMap, headers: HeaderMap,
Path(space_id): Path<String>, Path(project_id): Path<String>,
) -> Result<Json<SpaceManifest>, (StatusCode, String)> { ) -> Result<Json<ProjectManifest>, (StatusCode, String)> {
let user_id = authenticate(&state, &headers).await?; let user_id = authenticate(&state, &headers).await?;
let space = owned_space(&state, &space_id, &user_id).await?; let project = owned_project(&state, &project_id, &user_id).await?;
let files = plain_contents(&state, &space_id) let files = plain_contents(&state, &project_id)
.await? .await?
.into_iter() .into_iter()
.map(|(path, kind, plain, updated_at)| ManifestEntry { .map(|(path, kind, plain, updated_at)| ManifestEntry {
@@ -415,11 +415,11 @@ pub async fn get_manifest(
}) })
.collect(); .collect();
Ok(Json(SpaceManifest { Ok(Json(ProjectManifest {
space_id: space.id, project_id: project.id,
name: space.name, name: project.name,
entrypoint: space.entrypoint, entrypoint: project.entrypoint,
updated_at: space.updated_at, updated_at: project.updated_at,
files, files,
})) }))
} }
@@ -452,16 +452,16 @@ fn encode_for_transport(kind: &str, plain: Vec<u8>) -> (String, String) {
pub async fn pull_file( pub async fn pull_file(
State(state): State<AppState>, State(state): State<AppState>,
headers: HeaderMap, headers: HeaderMap,
Path(space_id): Path<String>, Path(project_id): Path<String>,
Query(query): Query<PathQuery>, Query(query): Query<PathQuery>,
) -> Result<Json<FileContent>, (StatusCode, String)> { ) -> Result<Json<FileContent>, (StatusCode, String)> {
let user_id = authenticate(&state, &headers).await?; let user_id = authenticate(&state, &headers).await?;
owned_space(&state, &space_id, &user_id).await?; owned_project(&state, &project_id, &user_id).await?;
let row = sqlx::query_as::<_, (String, Option<Vec<u8>>)>( let row = sqlx::query_as::<_, (String, Option<Vec<u8>>)>(
"SELECT kind, content FROM space_files WHERE space_id = ? AND path = ?", "SELECT kind, content FROM project_files WHERE project_id = ? AND path = ?",
) )
.bind(&space_id) .bind(&project_id)
.bind(&query.path) .bind(&query.path)
.fetch_optional(&state.db) .fetch_optional(&state.db)
.await .await
@@ -530,11 +530,11 @@ impl axum::response::IntoResponse for PushOutcome {
pub async fn push_file( pub async fn push_file(
State(state): State<AppState>, State(state): State<AppState>,
headers: HeaderMap, headers: HeaderMap,
Path(space_id): Path<String>, Path(project_id): Path<String>,
Json(payload): Json<PushFileRequest>, Json(payload): Json<PushFileRequest>,
) -> Result<PushOutcome, (StatusCode, String)> { ) -> Result<PushOutcome, (StatusCode, String)> {
let user_id = authenticate(&state, &headers).await?; let user_id = authenticate(&state, &headers).await?;
owned_space(&state, &space_id, &user_id).await?; owned_project(&state, &project_id, &user_id).await?;
let incoming = match payload.encoding.as_deref() { let incoming = match payload.encoding.as_deref() {
Some("base64") => BASE64 Some("base64") => BASE64
@@ -544,9 +544,9 @@ pub async fn push_file(
}; };
let existing = sqlx::query_as::<_, (String, Option<Vec<u8>>)>( let existing = sqlx::query_as::<_, (String, Option<Vec<u8>>)>(
"SELECT kind, content FROM space_files WHERE space_id = ? AND path = ?", "SELECT kind, content FROM project_files WHERE project_id = ? AND path = ?",
) )
.bind(&space_id) .bind(&project_id)
.bind(&payload.path) .bind(&payload.path)
.fetch_optional(&state.db) .fetch_optional(&state.db)
.await .await
@@ -600,13 +600,13 @@ pub async fn push_file(
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
sqlx::query( sqlx::query(
"INSERT INTO space_files (id, space_id, path, kind, content, mime_type, updated_at) \ "INSERT INTO project_files (id, project_id, path, kind, content, mime_type, updated_at) \
VALUES (?, ?, ?, ?, ?, ?, ?) \ VALUES (?, ?, ?, ?, ?, ?, ?) \
ON CONFLICT (space_id, path) DO UPDATE SET content = excluded.content, \ ON CONFLICT (project_id, path) DO UPDATE SET content = excluded.content, \
kind = excluded.kind, mime_type = excluded.mime_type, updated_at = excluded.updated_at", kind = excluded.kind, mime_type = excluded.mime_type, updated_at = excluded.updated_at",
) )
.bind(Uuid::new_v4().to_string()) .bind(Uuid::new_v4().to_string())
.bind(&space_id) .bind(&project_id)
.bind(&payload.path) .bind(&payload.path)
.bind(kind) .bind(kind)
.bind(&stored) .bind(&stored)
@@ -616,9 +616,9 @@ pub async fn push_file(
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let _ = sqlx::query("UPDATE spaces SET updated_at = ? WHERE id = ?") let _ = sqlx::query("UPDATE projects SET updated_at = ? WHERE id = ?")
.bind(&now) .bind(&now)
.bind(&space_id) .bind(&project_id)
.execute(&state.db) .execute(&state.db)
.await; .await;
@@ -632,14 +632,14 @@ pub async fn push_file(
pub async fn delete_file( pub async fn delete_file(
State(state): State<AppState>, State(state): State<AppState>,
headers: HeaderMap, headers: HeaderMap,
Path(space_id): Path<String>, Path(project_id): Path<String>,
Query(query): Query<PathQuery>, Query(query): Query<PathQuery>,
) -> Result<StatusCode, (StatusCode, String)> { ) -> Result<StatusCode, (StatusCode, String)> {
let user_id = authenticate(&state, &headers).await?; let user_id = authenticate(&state, &headers).await?;
owned_space(&state, &space_id, &user_id).await?; owned_project(&state, &project_id, &user_id).await?;
let result = sqlx::query("DELETE FROM space_files WHERE space_id = ? AND path = ?") let result = sqlx::query("DELETE FROM project_files WHERE project_id = ? AND path = ?")
.bind(&space_id) .bind(&project_id)
.bind(&query.path) .bind(&query.path)
.execute(&state.db) .execute(&state.db)
.await .await
@@ -662,22 +662,22 @@ pub struct BundleFile {
} }
#[derive(Serialize)] #[derive(Serialize)]
pub struct SpaceBundle { pub struct ProjectBundle {
pub space_id: String, pub project_id: String,
pub name: String, pub name: String,
pub entrypoint: String, pub entrypoint: String,
pub files: Vec<BundleFile>, pub files: Vec<BundleFile>,
} }
pub async fn pull_space( pub async fn pull_project(
State(state): State<AppState>, State(state): State<AppState>,
headers: HeaderMap, headers: HeaderMap,
Path(space_id): Path<String>, Path(project_id): Path<String>,
) -> Result<Json<SpaceBundle>, (StatusCode, String)> { ) -> Result<Json<ProjectBundle>, (StatusCode, String)> {
let user_id = authenticate(&state, &headers).await?; let user_id = authenticate(&state, &headers).await?;
let space = owned_space(&state, &space_id, &user_id).await?; let project = owned_project(&state, &project_id, &user_id).await?;
let files = plain_contents(&state, &space_id) let files = plain_contents(&state, &project_id)
.await? .await?
.into_iter() .into_iter()
.map(|(path, kind, plain, _)| { .map(|(path, kind, plain, _)| {
@@ -693,10 +693,10 @@ pub async fn pull_space(
}) })
.collect(); .collect();
Ok(Json(SpaceBundle { Ok(Json(ProjectBundle {
space_id: space.id, project_id: project.id,
name: space.name, name: project.name,
entrypoint: space.entrypoint, entrypoint: project.entrypoint,
files, files,
})) }))
} }
@@ -789,7 +789,7 @@ pub async fn list_documents(
#[derive(Serialize)] #[derive(Serialize)]
pub struct SharedItems { pub struct SharedItems {
pub documents: Vec<CloudDocument>, pub documents: Vec<CloudDocument>,
pub spaces: Vec<SpaceSummary>, pub projects: Vec<ProjectSummary>,
} }
pub async fn list_shared( pub async fn list_shared(
@@ -808,10 +808,10 @@ pub async fn list_shared(
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let spaces = sqlx::query_as::<_, (String, String, String, String, String)>( let projects = sqlx::query_as::<_, (String, String, String, String, String)>(
"SELECT s.id, s.name, s.entrypoint, s.updated_at, c.role FROM spaces s \ "SELECT p.id, p.name, p.entrypoint, p.updated_at, c.role FROM projects p \
INNER JOIN space_collaborators c ON c.space_id = s.id AND c.user_id = ? \ INNER JOIN project_collaborators c ON c.project_id = p.id AND c.user_id = ? \
ORDER BY s.updated_at DESC", ORDER BY p.updated_at DESC",
) )
.bind(&user_id) .bind(&user_id)
.fetch_all(&state.db) .fetch_all(&state.db)
@@ -829,9 +829,9 @@ pub async fn list_shared(
updated_at, updated_at,
}) })
.collect(), .collect(),
spaces: spaces projects: projects
.into_iter() .into_iter()
.map(|(id, name, entrypoint, updated_at, role)| SpaceSummary { .map(|(id, name, entrypoint, updated_at, role)| ProjectSummary {
id, id,
name, name,
entrypoint, entrypoint,
@@ -974,6 +974,42 @@ pub async fn push_document(
}))) })))
} }
#[derive(Deserialize)]
pub struct CreateDocumentRequest {
pub title: String,
pub content: String,
pub folder_id: Option<String>,
}
pub async fn create_document(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<CreateDocumentRequest>,
) -> Result<Json<DocumentContent>, (StatusCode, String)> {
let user_id = authenticate(&state, &headers).await?;
let document_id = Uuid::new_v4().to_string();
sqlx::query(
"INSERT INTO documents (id, owner_id, folder_id, title, content) VALUES (?, ?, ?, ?, ?)",
)
.bind(&document_id)
.bind(&user_id)
.bind(&payload.folder_id)
.bind(&payload.title)
.bind(encode_text_blob(&payload.content))
.execute(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(DocumentContent {
id: document_id,
title: payload.title,
role: "owner".to_string(),
hash: content_hash(payload.content.as_bytes()),
content: payload.content,
}))
}
#[derive(Serialize)] #[derive(Serialize)]
pub struct CloudFile { pub struct CloudFile {
pub id: String, pub id: String,
+18 -18
View File
@@ -53,7 +53,7 @@ pub struct CompileRequest {
#[serde(default)] #[serde(default)]
pub text: Option<String>, pub text: Option<String>,
pub document_id: Option<String>, pub document_id: Option<String>,
pub space_id: Option<String>, pub project_id: Option<String>,
#[serde(default)] #[serde(default)]
pub files: Option<std::collections::HashMap<String, String>>, pub files: Option<std::collections::HashMap<String, String>>,
} }
@@ -102,21 +102,21 @@ pub async fn yjs_handler(
// (table, row_id) the autosave task persists into; None means no persistence. // (table, row_id) the autosave task persists into; None means no persistence.
let mut save_target: Option<(&'static str, String)> = None; let mut save_target: Option<(&'static str, String)> = None;
if let Some(rest) = id.strip_prefix("space:") { if let Some(rest) = id.strip_prefix("project:") {
if let Some((space_id, file_id)) = rest.split_once(':') { if let Some((project_id, file_id)) = rest.split_once(':') {
if let Some((_space, role)) = crate::spaces::space_role(&state, space_id, &user_id_opt).await { if let Some((_project, role)) = crate::projects::project_role(&state, project_id, &user_id_opt).await {
is_viewer = role == "viewer"; is_viewer = role == "viewer";
if let Ok(Some((content,))) = sqlx::query_as::<_, (Option<Vec<u8>>,)>( if let Ok(Some((content,))) = sqlx::query_as::<_, (Option<Vec<u8>>,)>(
"SELECT content FROM space_files WHERE id = ? AND space_id = ?" "SELECT content FROM project_files WHERE id = ? AND project_id = ?"
) )
.bind(file_id) .bind(file_id)
.bind(space_id) .bind(project_id)
.fetch_optional(&state.db) .fetch_optional(&state.db)
.await .await
{ {
initial_content = content; initial_content = content;
} }
save_target = Some(("space_files", file_id.to_string())); save_target = Some(("project_files", file_id.to_string()));
} }
} }
} else { } else {
@@ -177,8 +177,8 @@ pub async fn yjs_handler(
interval.tick().await; interval.tick().await;
let doc = save_awareness.read().await; let doc = save_awareness.read().await;
let content = doc.doc().transact().encode_state_as_update_v1(&yrs::StateVector::default()); let content = doc.doc().transact().encode_state_as_update_v1(&yrs::StateVector::default());
let query = if table == "space_files" { let query = if table == "project_files" {
"UPDATE space_files SET content = ? WHERE id = ?" "UPDATE project_files SET content = ? WHERE id = ?"
} else { } else {
"UPDATE documents SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?" "UPDATE documents SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"
}; };
@@ -222,8 +222,8 @@ pub async fn compile_handler(
let mut can_save_thumbnail = false; let mut can_save_thumbnail = false;
let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string()); let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string());
if let Some(space_id) = &payload.space_id { if let Some(project_id) = &payload.project_id {
let (space, role) = match crate::spaces::space_role(&state, space_id, &user_id_opt).await { let (project, role) = match crate::projects::project_role(&state, project_id, &user_id_opt).await {
Some(v) => v, Some(v) => v,
None => { None => {
return Json(CompileResponse { return Json(CompileResponse {
@@ -240,7 +240,7 @@ pub async fn compile_handler(
}; };
let overrides = payload.files.clone().unwrap_or_default(); let overrides = payload.files.clone().unwrap_or_default();
let input = crate::spaces::assemble_project(&state, &space, overrides).await; let input = crate::projects::assemble_project(&state, &project, overrides).await;
let can_save = role == "owner" || role == "editor"; let can_save = role == "owner" || role == "editor";
let compiler = state.compiler.lock().await; let compiler = state.compiler.lock().await;
@@ -250,9 +250,9 @@ pub async fn compile_handler(
return match result { return match result {
Ok((svgs, thumbnail, stats)) => { Ok((svgs, thumbnail, stats)) => {
if can_save { if can_save {
let _ = sqlx::query("UPDATE spaces SET thumbnail_svg = ? WHERE id = ?") let _ = sqlx::query("UPDATE projects SET thumbnail_svg = ? WHERE id = ?")
.bind(&thumbnail) .bind(&thumbnail)
.bind(&space.id) .bind(&project.id)
.execute(&state.db) .execute(&state.db)
.await; .await;
} }
@@ -430,11 +430,11 @@ pub async fn export_handler(
} }
} }
let input = if let Some(space_id) = &payload.space_id { let input = if let Some(project_id) = &payload.project_id {
match crate::spaces::space_role(&state, space_id, &user_id_opt).await { match crate::projects::project_role(&state, project_id, &user_id_opt).await {
Some((space, _)) => { Some((project, _)) => {
let overrides = payload.files.clone().unwrap_or_default(); let overrides = payload.files.clone().unwrap_or_default();
crate::spaces::assemble_project(&state, &space, overrides).await crate::projects::assemble_project(&state, &project, overrides).await
} }
None => return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response(), None => return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response(),
} }
+12 -12
View File
@@ -24,9 +24,9 @@ mod files;
mod handlers; mod handlers;
mod models; mod models;
mod packages; mod packages;
mod projects;
mod public_api; mod public_api;
mod setup; mod setup;
mod spaces;
mod world; mod world;
mod collab; mod collab;
@@ -131,12 +131,12 @@ async fn main() {
.route("/keys/usage", get(api_keys::get_aggregate_usage)) .route("/keys/usage", get(api_keys::get_aggregate_usage))
.route("/keys/{id}", delete(api_keys::delete_key)) .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("/projects/shared", get(projects::list_shared_projects))
.route("/spaces", get(spaces::list_spaces).post(spaces::create_space)) .route("/projects", get(projects::list_projects).post(projects::create_project))
.route("/spaces/{id}", get(spaces::get_space).delete(spaces::delete_space).patch(spaces::update_space)) .route("/projects/{id}", get(projects::get_project).delete(projects::delete_project).patch(projects::update_project))
.route("/spaces/{id}/files", get(spaces::list_space_files).post(spaces::create_space_file)) .route("/projects/{id}/files", get(projects::list_project_files).post(projects::create_project_file))
.route("/spaces/{id}/files/upload", post(spaces::upload_space_file)) .route("/projects/{id}/files/upload", post(projects::upload_project_file))
.route("/spaces/{id}/files/{fid}", get(spaces::get_space_file).patch(spaces::update_space_file).delete(spaces::delete_space_file)) .route("/projects/{id}/files/{fid}", get(projects::get_project_file).patch(projects::update_project_file).delete(projects::delete_project_file))
.route("/packages", get(packages::list_packages)) .route("/packages", get(packages::list_packages))
.route("/packages/publish", post(packages::publish_package)) .route("/packages/publish", post(packages::publish_package))
.route("/packages/{name}", get(packages::list_versions).delete(packages::delete_package)); .route("/packages/{name}", get(packages::list_versions).delete(packages::delete_package));
@@ -145,16 +145,16 @@ async fn main() {
.route("/auth/login", post(desktop::login)) .route("/auth/login", post(desktop::login))
.route("/auth/logout", post(desktop::logout)) .route("/auth/logout", post(desktop::logout))
.route("/auth/me", get(desktop::me)) .route("/auth/me", get(desktop::me))
.route("/spaces", get(desktop::list_spaces).post(desktop::create_space)) .route("/projects", get(desktop::list_projects).post(desktop::create_project))
.route("/spaces/{id}", get(desktop::pull_space).delete(desktop::delete_space)) .route("/projects/{id}", get(desktop::pull_project).delete(desktop::delete_project))
.route("/spaces/{id}/manifest", get(desktop::get_manifest)) .route("/projects/{id}/manifest", get(desktop::get_manifest))
.route("/folders", get(desktop::list_folders)) .route("/folders", get(desktop::list_folders))
.route("/documents", get(desktop::list_documents)) .route("/documents", get(desktop::list_documents).post(desktop::create_document))
.route("/documents/{id}", get(desktop::pull_document).put(desktop::push_document)) .route("/documents/{id}", get(desktop::pull_document).put(desktop::push_document))
.route("/shared", get(desktop::list_shared)) .route("/shared", get(desktop::list_shared))
.route("/files", get(desktop::list_account_files)) .route("/files", get(desktop::list_account_files))
.route("/files/{id}", get(desktop::pull_account_file)) .route("/files/{id}", get(desktop::pull_account_file))
.route("/spaces/{id}/file", get(desktop::pull_file).put(desktop::push_file).delete(desktop::delete_file)); .route("/projects/{id}/file", get(desktop::pull_file).put(desktop::push_file).delete(desktop::delete_file));
let v1_routes = Router::new() let v1_routes = Router::new()
.route("/render", post(public_api::render_handler)); .route("/render", post(public_api::render_handler));
+8 -8
View File
@@ -73,7 +73,7 @@ pub struct Document {
} }
#[derive(Debug, Serialize, Deserialize, FromRow)] #[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct Space { pub struct Project {
pub id: String, pub id: String,
pub owner_id: String, pub owner_id: String,
pub folder_id: Option<String>, pub folder_id: Option<String>,
@@ -89,9 +89,9 @@ pub struct Space {
} }
#[derive(Debug, Serialize, Deserialize, FromRow)] #[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct SpaceFile { pub struct ProjectFile {
pub id: String, pub id: String,
pub space_id: String, pub project_id: String,
pub path: String, pub path: String,
pub kind: String, pub kind: String,
#[serde(skip_serializing)] #[serde(skip_serializing)]
@@ -127,14 +127,14 @@ pub struct PackageVersion {
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct CreateSpaceRequest { pub struct CreateProjectRequest {
pub name: String, pub name: String,
pub folder_id: Option<String>, pub folder_id: Option<String>,
pub template: Option<String>, pub template: Option<String>,
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct UpdateSpaceRequest { pub struct UpdateProjectRequest {
pub name: Option<String>, pub name: Option<String>,
pub folder_id: Option<String>, pub folder_id: Option<String>,
pub entrypoint: Option<String>, pub entrypoint: Option<String>,
@@ -142,20 +142,20 @@ pub struct UpdateSpaceRequest {
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct CreateSpaceFileRequest { pub struct CreateProjectFileRequest {
pub path: String, pub path: String,
pub kind: Option<String>, pub kind: Option<String>,
pub content: Option<String>, pub content: Option<String>,
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct UpdateSpaceFileRequest { pub struct UpdateProjectFileRequest {
pub path: String, pub path: String,
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct PublishPackageRequest { pub struct PublishPackageRequest {
pub space_id: String, pub project_id: String,
pub version: Option<String>, pub version: Option<String>,
} }
+9 -9
View File
@@ -8,8 +8,8 @@ use serde::Deserialize;
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
models::{Package, PackageVersion, PublishPackageRequest, Space}, models::{Package, PackageVersion, Project, PublishPackageRequest},
spaces::decode_text_blob, projects::decode_text_blob,
AppState, AppState,
}; };
@@ -45,20 +45,20 @@ pub async fn publish_package(
let user_id = jar.get("session_user_id").map(|c| c.value().to_string()) let user_id = jar.get("session_user_id").map(|c| c.value().to_string())
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?; .ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?;
let space = sqlx::query_as::<_, Space>( let project = sqlx::query_as::<_, Project>(
"SELECT id, owner_id, folder_id, name, entrypoint, thumbnail_svg, public_role, created_at, updated_at FROM spaces WHERE id = ? AND owner_id = ?" "SELECT id, owner_id, folder_id, name, entrypoint, thumbnail_svg, public_role, created_at, updated_at FROM projects WHERE id = ? AND owner_id = ?"
) )
.bind(&payload.space_id) .bind(&payload.project_id)
.bind(&user_id) .bind(&user_id)
.fetch_optional(&state.db) .fetch_optional(&state.db)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "Space not found".to_string()))?; .ok_or((StatusCode::NOT_FOUND, "Project not found".to_string()))?;
let files = sqlx::query_as::<_, (String, String, Option<Vec<u8>>)>( let files = sqlx::query_as::<_, (String, String, Option<Vec<u8>>)>(
"SELECT path, kind, content FROM space_files WHERE space_id = ?" "SELECT path, kind, content FROM project_files WHERE project_id = ?"
) )
.bind(&space.id) .bind(&project.id)
.fetch_all(&state.db) .fetch_all(&state.db)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
@@ -78,7 +78,7 @@ pub async fn publish_package(
} }
let manifest_text = manifest_text let manifest_text = manifest_text
.ok_or((StatusCode::BAD_REQUEST, "Space has no typst.toml manifest".to_string()))?; .ok_or((StatusCode::BAD_REQUEST, "Project has no typst.toml manifest".to_string()))?;
let manifest: Manifest = toml::from_str(&manifest_text) let manifest: Manifest = toml::from_str(&manifest_text)
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid typst.toml: {}", e)))?; .map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid typst.toml: {}", e)))?;
+103 -103
View File
@@ -14,8 +14,8 @@ use yrs::Update;
use crate::{ use crate::{
compiler::ProjectInput, compiler::ProjectInput,
models::{ models::{
CreateSpaceFileRequest, CreateSpaceRequest, Space, SpaceFile, UpdateSpaceFileRequest, CreateProjectFileRequest, CreateProjectRequest, Project, ProjectFile, UpdateProjectFileRequest,
UpdateSpaceRequest, UpdateProjectRequest,
}, },
AppState, AppState,
}; };
@@ -61,44 +61,44 @@ fn slugify(name: &str) -> String {
.collect(); .collect();
let trimmed = slug.trim_matches('-').replace("--", "-"); let trimmed = slug.trim_matches('-').replace("--", "-");
if trimmed.is_empty() { if trimmed.is_empty() {
"my-space".to_string() "my-project".to_string()
} else { } else {
trimmed trimmed
} }
} }
pub async fn space_role( pub async fn project_role(
state: &AppState, state: &AppState,
space_id: &str, project_id: &str,
user_id_opt: &Option<String>, user_id_opt: &Option<String>,
) -> Option<(Space, String)> { ) -> Option<(Project, String)> {
let space = sqlx::query_as::<_, Space>( let project = sqlx::query_as::<_, Project>(
"SELECT id, owner_id, folder_id, name, entrypoint, thumbnail_svg, public_role, created_at, updated_at FROM spaces WHERE id = ?" "SELECT id, owner_id, folder_id, name, entrypoint, thumbnail_svg, public_role, created_at, updated_at FROM projects WHERE id = ?"
) )
.bind(space_id) .bind(project_id)
.fetch_optional(&state.db) .fetch_optional(&state.db)
.await .await
.ok()??; .ok()??;
if let Some(uid) = user_id_opt { if let Some(uid) = user_id_opt {
if &space.owner_id == uid { if &project.owner_id == uid {
return Some((space, "owner".to_string())); return Some((project, "owner".to_string()));
} }
if let Ok(Some((role,))) = sqlx::query_as::<_, (String,)>( if let Ok(Some((role,))) = sqlx::query_as::<_, (String,)>(
"SELECT role FROM space_collaborators WHERE space_id = ? AND user_id = ?", "SELECT role FROM project_collaborators WHERE project_id = ? AND user_id = ?",
) )
.bind(space_id) .bind(project_id)
.bind(uid) .bind(uid)
.fetch_optional(&state.db) .fetch_optional(&state.db)
.await .await
{ {
return Some((space, role)); return Some((project, role));
} }
} }
if let Some(pr) = space.public_role.clone() { if let Some(pr) = project.public_role.clone() {
if pr == "viewer" || pr == "editor" { if pr == "viewer" || pr == "editor" {
return Some((space, pr)); return Some((project, pr));
} }
} }
@@ -128,17 +128,17 @@ pub async fn load_local_packages(state: &AppState) -> HashMap<String, HashMap<St
pub async fn assemble_project( pub async fn assemble_project(
state: &AppState, state: &AppState,
space: &Space, project: &Project,
overrides: HashMap<String, String>, overrides: HashMap<String, String>,
) -> ProjectInput { ) -> ProjectInput {
let mut files: HashMap<String, Vec<u8>> = HashMap::new(); let mut files: HashMap<String, Vec<u8>> = HashMap::new();
// Account-level uploaded files (fonts, images) come first as a base layer so // 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. // they are available inside projects; project files below override them by name.
if let Ok(account_files) = sqlx::query_as::<_, (String, Vec<u8>)>( if let Ok(account_files) = sqlx::query_as::<_, (String, Vec<u8>)>(
"SELECT name, data FROM files WHERE owner_id = ?", "SELECT name, data FROM files WHERE owner_id = ?",
) )
.bind(&space.owner_id) .bind(&project.owner_id)
.fetch_all(&state.db) .fetch_all(&state.db)
.await .await
{ {
@@ -148,9 +148,9 @@ pub async fn assemble_project(
} }
let rows = sqlx::query_as::<_, (String, String, Option<Vec<u8>>)>( let rows = sqlx::query_as::<_, (String, String, Option<Vec<u8>>)>(
"SELECT path, kind, content FROM space_files WHERE space_id = ?", "SELECT path, kind, content FROM project_files WHERE project_id = ?",
) )
.bind(&space.id) .bind(&project.id)
.fetch_all(&state.db) .fetch_all(&state.db)
.await .await
.unwrap_or_default(); .unwrap_or_default();
@@ -170,36 +170,36 @@ pub async fn assemble_project(
} }
ProjectInput { ProjectInput {
entrypoint: space.entrypoint.clone(), entrypoint: project.entrypoint.clone(),
files, files,
packages: load_local_packages(state).await, packages: load_local_packages(state).await,
} }
} }
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
pub struct ListSpacesQuery { pub struct ListProjectsQuery {
pub folder_id: Option<String>, pub folder_id: Option<String>,
} }
pub async fn list_spaces( pub async fn list_projects(
Query(query): Query<ListSpacesQuery>, Query(query): Query<ListProjectsQuery>,
State(state): State<AppState>, State(state): State<AppState>,
jar: SignedCookieJar, jar: SignedCookieJar,
) -> Result<Json<Vec<Space>>, (StatusCode, String)> { ) -> Result<Json<Vec<Project>>, (StatusCode, String)> {
let user_id = jar.get("session_user_id").map(|c| c.value().to_string()) let user_id = jar.get("session_user_id").map(|c| c.value().to_string())
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?; .ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?;
let spaces = if let Some(folder_id) = query.folder_id { let projects = if let Some(folder_id) = query.folder_id {
sqlx::query_as::<_, Space>( sqlx::query_as::<_, Project>(
"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" "SELECT id, owner_id, folder_id, name, entrypoint, thumbnail_svg, public_role, created_at, updated_at FROM projects WHERE owner_id = ? AND folder_id = ? ORDER BY updated_at DESC"
) )
.bind(&user_id) .bind(&user_id)
.bind(&folder_id) .bind(&folder_id)
.fetch_all(&state.db) .fetch_all(&state.db)
.await .await
} else { } else {
sqlx::query_as::<_, Space>( sqlx::query_as::<_, Project>(
"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" "SELECT id, owner_id, folder_id, name, entrypoint, thumbnail_svg, public_role, created_at, updated_at FROM projects WHERE owner_id = ? AND folder_id IS NULL ORDER BY updated_at DESC"
) )
.bind(&user_id) .bind(&user_id)
.fetch_all(&state.db) .fetch_all(&state.db)
@@ -207,45 +207,45 @@ pub async fn list_spaces(
} }
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(spaces)) Ok(Json(projects))
} }
pub async fn list_shared_spaces( pub async fn list_shared_projects(
State(state): State<AppState>, State(state): State<AppState>,
jar: SignedCookieJar, jar: SignedCookieJar,
) -> Result<Json<Vec<Space>>, (StatusCode, String)> { ) -> Result<Json<Vec<Project>>, (StatusCode, String)> {
let user_id = jar.get("session_user_id").map(|c| c.value().to_string()) let user_id = jar.get("session_user_id").map(|c| c.value().to_string())
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?; .ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?;
let spaces = sqlx::query_as::<_, Space>( let projects = sqlx::query_as::<_, Project>(
"SELECT s.id, s.owner_id, s.folder_id, s.name, s.entrypoint, s.thumbnail_svg, \ "SELECT p.id, p.owner_id, p.folder_id, p.name, p.entrypoint, p.thumbnail_svg, \
s.public_role, s.created_at, s.updated_at, c.role as effective_role \ p.public_role, p.created_at, p.updated_at, c.role as effective_role \
FROM spaces s \ FROM projects p \
INNER JOIN space_collaborators c ON c.space_id = s.id AND c.user_id = ? \ INNER JOIN project_collaborators c ON c.project_id = p.id AND c.user_id = ? \
ORDER BY s.updated_at DESC" ORDER BY p.updated_at DESC"
) )
.bind(&user_id) .bind(&user_id)
.fetch_all(&state.db) .fetch_all(&state.db)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(spaces)) Ok(Json(projects))
} }
pub async fn create_space( pub async fn create_project(
State(state): State<AppState>, State(state): State<AppState>,
jar: SignedCookieJar, jar: SignedCookieJar,
Json(payload): Json<CreateSpaceRequest>, Json(payload): Json<CreateProjectRequest>,
) -> Result<Json<Space>, (StatusCode, String)> { ) -> Result<Json<Project>, (StatusCode, String)> {
let user_id = jar.get("session_user_id").map(|c| c.value().to_string()) let user_id = jar.get("session_user_id").map(|c| c.value().to_string())
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?; .ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?;
let space_id = Uuid::new_v4().to_string(); let project_id = Uuid::new_v4().to_string();
let space = sqlx::query_as::<_, Space>( let project = sqlx::query_as::<_, Project>(
"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" "INSERT INTO projects (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(&project_id)
.bind(&user_id) .bind(&user_id)
.bind(&payload.folder_id) .bind(&payload.folder_id)
.bind(&payload.name) .bind(&payload.name)
@@ -255,91 +255,91 @@ pub async fn create_space(
let seeds = [ let seeds = [
("typst.toml", default_manifest(&slugify(&payload.name))), ("typst.toml", default_manifest(&slugify(&payload.name))),
("main.typ", "= New Space\n\nStart writing here.\n".to_string()), ("main.typ", "= New Project\n\nStart writing here.\n".to_string()),
]; ];
for (path, content) in seeds { for (path, content) in seeds {
let _ = sqlx::query( let _ = sqlx::query(
"INSERT INTO space_files (id, space_id, path, kind, content, mime_type) VALUES (?, ?, ?, 'text', ?, 'text/plain')" "INSERT INTO project_files (id, project_id, path, kind, content, mime_type) VALUES (?, ?, ?, 'text', ?, 'text/plain')"
) )
.bind(Uuid::new_v4().to_string()) .bind(Uuid::new_v4().to_string())
.bind(&space_id) .bind(&project_id)
.bind(path) .bind(path)
.bind(encode_text_blob(&content)) .bind(encode_text_blob(&content))
.execute(&state.db) .execute(&state.db)
.await; .await;
} }
Ok(Json(space)) Ok(Json(project))
} }
pub async fn get_space( pub async fn get_project(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<String>, Path(id): Path<String>,
jar: SignedCookieJar, jar: SignedCookieJar,
) -> Result<Json<Space>, (StatusCode, String)> { ) -> Result<Json<Project>, (StatusCode, String)> {
let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_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) let (mut project, role) = project_role(&state, &id, &user_id_opt)
.await .await
.ok_or((StatusCode::UNAUTHORIZED, "Unauthorized".to_string()))?; .ok_or((StatusCode::UNAUTHORIZED, "Unauthorized".to_string()))?;
space.effective_role = Some(role); project.effective_role = Some(role);
Ok(Json(space)) Ok(Json(project))
} }
pub async fn update_space( pub async fn update_project(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<String>, Path(id): Path<String>,
jar: SignedCookieJar, jar: SignedCookieJar,
Json(payload): Json<UpdateSpaceRequest>, Json(payload): Json<UpdateProjectRequest>,
) -> Result<Json<Space>, (StatusCode, String)> { ) -> Result<Json<Project>, (StatusCode, String)> {
let user_id = jar.get("session_user_id").map(|c| c.value().to_string()) let user_id = jar.get("session_user_id").map(|c| c.value().to_string())
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?; .ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?;
let mut space = sqlx::query_as::<_, Space>( let mut project = sqlx::query_as::<_, Project>(
"SELECT id, owner_id, folder_id, name, entrypoint, thumbnail_svg, public_role, created_at, updated_at FROM spaces WHERE id = ? AND owner_id = ?" "SELECT id, owner_id, folder_id, name, entrypoint, thumbnail_svg, public_role, created_at, updated_at FROM projects WHERE id = ? AND owner_id = ?"
) )
.bind(&id) .bind(&id)
.bind(&user_id) .bind(&user_id)
.fetch_optional(&state.db) .fetch_optional(&state.db)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "Space not found".to_string()))?; .ok_or((StatusCode::NOT_FOUND, "Project not found".to_string()))?;
if let Some(name) = payload.name { if let Some(name) = payload.name {
space.name = name; project.name = name;
} }
if let Some(entrypoint) = payload.entrypoint { if let Some(entrypoint) = payload.entrypoint {
space.entrypoint = entrypoint; project.entrypoint = entrypoint;
} }
if let Some(folder_id) = payload.folder_id { if let Some(folder_id) = payload.folder_id {
space.folder_id = if folder_id.is_empty() { None } else { Some(folder_id) }; project.folder_id = if folder_id.is_empty() { None } else { Some(folder_id) };
} }
if let Some(public_role) = payload.public_role { if let Some(public_role) = payload.public_role {
space.public_role = if public_role == "none" || public_role.is_empty() { project.public_role = if public_role == "none" || public_role.is_empty() {
None None
} else { } else {
Some(public_role) Some(public_role)
}; };
} }
let space = sqlx::query_as::<_, Space>( let project = sqlx::query_as::<_, Project>(
"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" "UPDATE projects 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(&project.name)
.bind(&space.entrypoint) .bind(&project.entrypoint)
.bind(&space.folder_id) .bind(&project.folder_id)
.bind(&space.public_role) .bind(&project.public_role)
.bind(&id) .bind(&id)
.bind(&user_id) .bind(&user_id)
.fetch_one(&state.db) .fetch_one(&state.db)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(space)) Ok(Json(project))
} }
pub async fn delete_space( pub async fn delete_project(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<String>, Path(id): Path<String>,
jar: SignedCookieJar, jar: SignedCookieJar,
@@ -347,12 +347,12 @@ pub async fn delete_space(
let user_id = jar.get("session_user_id").map(|c| c.value().to_string()) let user_id = jar.get("session_user_id").map(|c| c.value().to_string())
.ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?; .ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?;
let _ = sqlx::query("DELETE FROM space_files WHERE space_id = ?") let _ = sqlx::query("DELETE FROM project_files WHERE project_id = ?")
.bind(&id) .bind(&id)
.execute(&state.db) .execute(&state.db)
.await; .await;
let result = sqlx::query("DELETE FROM spaces WHERE id = ? AND owner_id = ?") let result = sqlx::query("DELETE FROM projects WHERE id = ? AND owner_id = ?")
.bind(&id) .bind(&id)
.bind(&user_id) .bind(&user_id)
.execute(&state.db) .execute(&state.db)
@@ -360,24 +360,24 @@ pub async fn delete_space(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if result.rows_affected() == 0 { if result.rows_affected() == 0 {
return Err((StatusCode::NOT_FOUND, "Space not found or unauthorized".to_string())); return Err((StatusCode::NOT_FOUND, "Project not found or unauthorized".to_string()));
} }
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
pub async fn list_space_files( pub async fn list_project_files(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<String>, Path(id): Path<String>,
jar: SignedCookieJar, jar: SignedCookieJar,
) -> Result<Json<Vec<SpaceFile>>, (StatusCode, String)> { ) -> Result<Json<Vec<ProjectFile>>, (StatusCode, String)> {
let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string()); let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string());
space_role(&state, &id, &user_id_opt) project_role(&state, &id, &user_id_opt)
.await .await
.ok_or((StatusCode::UNAUTHORIZED, "Unauthorized".to_string()))?; .ok_or((StatusCode::UNAUTHORIZED, "Unauthorized".to_string()))?;
let files = sqlx::query_as::<_, SpaceFile>( let files = sqlx::query_as::<_, ProjectFile>(
"SELECT id, space_id, path, kind, mime_type, created_at FROM space_files WHERE space_id = ? ORDER BY path ASC" "SELECT id, project_id, path, kind, mime_type, created_at FROM project_files WHERE project_id = ? ORDER BY path ASC"
) )
.bind(&id) .bind(&id)
.fetch_all(&state.db) .fetch_all(&state.db)
@@ -387,14 +387,14 @@ pub async fn list_space_files(
Ok(Json(files)) Ok(Json(files))
} }
pub async fn create_space_file( pub async fn create_project_file(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<String>, Path(id): Path<String>,
jar: SignedCookieJar, jar: SignedCookieJar,
Json(payload): Json<CreateSpaceFileRequest>, Json(payload): Json<CreateProjectFileRequest>,
) -> Result<Json<SpaceFile>, (StatusCode, String)> { ) -> Result<Json<ProjectFile>, (StatusCode, String)> {
let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_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) let (_, role) = project_role(&state, &id, &user_id_opt)
.await .await
.ok_or((StatusCode::UNAUTHORIZED, "Unauthorized".to_string()))?; .ok_or((StatusCode::UNAUTHORIZED, "Unauthorized".to_string()))?;
if role == "viewer" { if role == "viewer" {
@@ -405,8 +405,8 @@ pub async fn create_space_file(
let content = payload.content.unwrap_or_default(); let content = payload.content.unwrap_or_default();
let file_id = Uuid::new_v4().to_string(); let file_id = Uuid::new_v4().to_string();
let file = sqlx::query_as::<_, SpaceFile>( let file = sqlx::query_as::<_, ProjectFile>(
"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" "INSERT INTO project_files (id, project_id, path, kind, content, mime_type) VALUES (?, ?, ?, ?, ?, 'text/plain') RETURNING id, project_id, path, kind, mime_type, created_at"
) )
.bind(&file_id) .bind(&file_id)
.bind(&id) .bind(&id)
@@ -420,14 +420,14 @@ pub async fn create_space_file(
Ok(Json(file)) Ok(Json(file))
} }
pub async fn upload_space_file( pub async fn upload_project_file(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<String>, Path(id): Path<String>,
jar: SignedCookieJar, jar: SignedCookieJar,
mut multipart: Multipart, mut multipart: Multipart,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> { ) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_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) let (_, role) = project_role(&state, &id, &user_id_opt)
.await .await
.ok_or((StatusCode::UNAUTHORIZED, "Unauthorized".to_string()))?; .ok_or((StatusCode::UNAUTHORIZED, "Unauthorized".to_string()))?;
if role == "viewer" { if role == "viewer" {
@@ -449,8 +449,8 @@ pub async fn upload_space_file(
}; };
let _ = sqlx::query( let _ = sqlx::query(
"INSERT INTO space_files (id, space_id, path, kind, content, mime_type) VALUES (?, ?, ?, ?, ?, ?) \ "INSERT INTO project_files (id, project_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" ON CONFLICT (project_id, path) DO UPDATE SET content = excluded.content, kind = excluded.kind, mime_type = excluded.mime_type"
) )
.bind(Uuid::new_v4().to_string()) .bind(Uuid::new_v4().to_string())
.bind(&id) .bind(&id)
@@ -468,18 +468,18 @@ pub async fn upload_space_file(
Ok(Json(serde_json::json!({ "files": uploaded }))) Ok(Json(serde_json::json!({ "files": uploaded })))
} }
pub async fn get_space_file( pub async fn get_project_file(
State(state): State<AppState>, State(state): State<AppState>,
Path((id, file_id)): Path<(String, String)>, Path((id, file_id)): Path<(String, String)>,
jar: SignedCookieJar, jar: SignedCookieJar,
) -> Result<impl IntoResponse, (StatusCode, String)> { ) -> Result<impl IntoResponse, (StatusCode, String)> {
let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string()); let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_string());
space_role(&state, &id, &user_id_opt) project_role(&state, &id, &user_id_opt)
.await .await
.ok_or((StatusCode::UNAUTHORIZED, "Unauthorized".to_string()))?; .ok_or((StatusCode::UNAUTHORIZED, "Unauthorized".to_string()))?;
let file = sqlx::query_as::<_, (String, String, Option<Vec<u8>>)>( let file = sqlx::query_as::<_, (String, String, Option<Vec<u8>>)>(
"SELECT kind, mime_type, content FROM space_files WHERE id = ? AND space_id = ?" "SELECT kind, mime_type, content FROM project_files WHERE id = ? AND project_id = ?"
) )
.bind(&file_id) .bind(&file_id)
.bind(&id) .bind(&id)
@@ -498,21 +498,21 @@ pub async fn get_space_file(
} }
} }
pub async fn update_space_file( pub async fn update_project_file(
State(state): State<AppState>, State(state): State<AppState>,
Path((id, file_id)): Path<(String, String)>, Path((id, file_id)): Path<(String, String)>,
jar: SignedCookieJar, jar: SignedCookieJar,
Json(payload): Json<UpdateSpaceFileRequest>, Json(payload): Json<UpdateProjectFileRequest>,
) -> Result<StatusCode, (StatusCode, String)> { ) -> Result<StatusCode, (StatusCode, String)> {
let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_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) let (_, role) = project_role(&state, &id, &user_id_opt)
.await .await
.ok_or((StatusCode::UNAUTHORIZED, "Unauthorized".to_string()))?; .ok_or((StatusCode::UNAUTHORIZED, "Unauthorized".to_string()))?;
if role == "viewer" { if role == "viewer" {
return Err((StatusCode::FORBIDDEN, "Read-only access".to_string())); return Err((StatusCode::FORBIDDEN, "Read-only access".to_string()));
} }
let result = sqlx::query("UPDATE space_files SET path = ? WHERE id = ? AND space_id = ?") let result = sqlx::query("UPDATE project_files SET path = ? WHERE id = ? AND project_id = ?")
.bind(&payload.path) .bind(&payload.path)
.bind(&file_id) .bind(&file_id)
.bind(&id) .bind(&id)
@@ -527,20 +527,20 @@ pub async fn update_space_file(
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
pub async fn delete_space_file( pub async fn delete_project_file(
State(state): State<AppState>, State(state): State<AppState>,
Path((id, file_id)): Path<(String, String)>, Path((id, file_id)): Path<(String, String)>,
jar: SignedCookieJar, jar: SignedCookieJar,
) -> Result<StatusCode, (StatusCode, String)> { ) -> Result<StatusCode, (StatusCode, String)> {
let user_id_opt = jar.get("session_user_id").map(|c| c.value().to_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) let (_, role) = project_role(&state, &id, &user_id_opt)
.await .await
.ok_or((StatusCode::UNAUTHORIZED, "Unauthorized".to_string()))?; .ok_or((StatusCode::UNAUTHORIZED, "Unauthorized".to_string()))?;
if role == "viewer" { if role == "viewer" {
return Err((StatusCode::FORBIDDEN, "Read-only access".to_string())); return Err((StatusCode::FORBIDDEN, "Read-only access".to_string()));
} }
let result = sqlx::query("DELETE FROM space_files WHERE id = ? AND space_id = ?") let result = sqlx::query("DELETE FROM project_files WHERE id = ? AND project_id = ?")
.bind(&file_id) .bind(&file_id)
.bind(&id) .bind(&id)
.execute(&state.db) .execute(&state.db)
@@ -2,10 +2,10 @@
import Icon from '@iconify/svelte'; import Icon from '@iconify/svelte';
let { let {
spaceId, projectId,
onClose onClose
}: { }: {
spaceId: string; projectId: string;
onClose: () => void; onClose: () => void;
} = $props(); } = $props();
@@ -22,7 +22,7 @@
const res = await fetch('/api/packages/publish', { const res = await fetch('/api/packages/publish', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ space_id: spaceId, version: version.trim() || undefined }) body: JSON.stringify({ project_id: projectId, version: version.trim() || undefined })
}); });
if (!res.ok) { if (!res.ok) {
error = await res.text(); error = await res.text();
@@ -45,7 +45,7 @@
</div> </div>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4"> <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 Snapshots this project'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/&lt;name&gt;:&lt;version&gt;</code>. <code class="font-mono text-xs bg-gray-100 dark:bg-white/10 px-1.5 py-0.5 rounded">@typstdrive/&lt;name&gt;:&lt;version&gt;</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>. 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> </p>
@@ -1,24 +1,24 @@
<script lang="ts"> <script lang="ts">
import Icon from '@iconify/svelte'; import Icon from '@iconify/svelte';
let { createSpace, onClose } = $props<{ let { createProject, onClose } = $props<{
createSpace: (name: string) => void, createProject: (name: string) => void,
onClose: () => void onClose: () => void
}>(); }>();
let newSpaceName = $state('Untitled Space'); let newProjectName = $state('Untitled Project');
function onSubmit(e: Event) { function onSubmit(e: Event) {
e.preventDefault(); e.preventDefault();
createSpace(newSpaceName); createProject(newProjectName);
} }
</script> </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 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 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-project-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"> <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"> <h2 id="create-project-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" /> <Icon icon="mdi:folder-multiple-plus" class="text-blue-500 text-xl" />
Create Space Create Project
</h2> </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"> <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" /> <Icon icon="mdi:close" class="text-xl" />
@@ -27,14 +27,14 @@
<form onsubmit={onSubmit} class="p-5 space-y-4"> <form onsubmit={onSubmit} class="p-5 space-y-4">
<div class="space-y-2"> <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> <label for="project-name-input" class="text-sm font-medium text-gray-700 dark:text-gray-300 block">Project Name</label>
<input <input
id="space-name-input" id="project-name-input"
type="text" type="text"
required required
bind:value={newSpaceName} bind:value={newProjectName}
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" 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" placeholder="Untitled Project"
/> />
<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> <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>
+1 -1
View File
@@ -23,7 +23,7 @@
</div> </div>
<div class="h-6 w-px bg-gray-300 dark:bg-white/20"></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"> <a href="/projects" 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="Projects">
<Icon icon="mdi:folder-multiple-outline" class="text-xl" /> <Icon icon="mdi:folder-multiple-outline" class="text-xl" />
</a> </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"> <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">
@@ -2,26 +2,26 @@
import Icon from '@iconify/svelte'; import Icon from '@iconify/svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
let { space, activeMenu, setActiveMenu, openInfo, openRename, deleteSpace } = $props<{ let { project, activeMenu, setActiveMenu, openInfo, openRename, deleteProject } = $props<{
space: any; project: any;
activeMenu: string | null; activeMenu: string | null;
setActiveMenu: (id: string | null) => void; setActiveMenu: (id: string | null) => void;
openInfo: (space: any) => void; openInfo: (project: any) => void;
openRename: (id: string, name: string) => void; openRename: (id: string, name: string) => void;
deleteSpace: (id: string, name: string) => void; deleteProject: (id: string, name: string) => void;
}>(); }>();
let dropUp = $state(false); let dropUp = $state(false);
function toggleMenu(e: MouseEvent) { function toggleMenu(e: MouseEvent) {
e.stopPropagation(); e.stopPropagation();
if (activeMenu === space.id) { if (activeMenu === project.id) {
setActiveMenu(null); setActiveMenu(null);
} else { } else {
const button = e.currentTarget as HTMLElement; const button = e.currentTarget as HTMLElement;
const rect = button.getBoundingClientRect(); const rect = button.getBoundingClientRect();
dropUp = window.innerHeight - rect.bottom < 200; dropUp = window.innerHeight - rect.bottom < 200;
setActiveMenu(space.id); setActiveMenu(project.id);
} }
} }
</script> </script>
@@ -30,13 +30,13 @@
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" 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" role="button"
tabindex="0" tabindex="0"
onclick={() => goto(`/space/${space.id}`)} onclick={() => goto(`/project/${project.id}`)}
onkeydown={(e) => e.key === 'Enter' && goto(`/space/${space.id}`)} onkeydown={(e) => e.key === 'Enter' && goto(`/project/${project.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"> <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} {#if project.thumbnail_svg}
<div class="w-full h-full flex items-start justify-center bg-white transition-transform duration-300 group-hover:scale-110"> <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" /> <img src={`data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(project.thumbnail_svg)))}`} class="w-full h-auto shadow-sm border border-gray-200" alt="Thumbnail" draggable="false" />
</div> </div>
{:else} {: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"> <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">
@@ -47,29 +47,29 @@
<div class="p-4 flex flex-col flex-grow"> <div class="p-4 flex flex-col flex-grow">
<div class="flex items-start justify-between"> <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> <h3 class="text-lg font-semibold text-gray-900 dark:text-white truncate pr-2 pointer-events-none" title={project.name}>{project.name}</h3>
<div class="relative action-menu-container"> <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"> <button aria-label="Project 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" /> <Icon icon="mdi:dots-vertical" class="text-xl" />
</button> </button>
{#if activeMenu === space.id} {#if activeMenu === project.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]"> <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"> <button onclick={(e) => { e.stopPropagation(); openInfo(project); }} 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" /> <Icon icon="mdi:information-outline" class="text-lg text-blue-500" />
View Info View Info
</button> </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"> <button onclick={(e) => { e.stopPropagation(); openRename(project.id, project.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" /> <Icon icon="mdi:pencil-outline" class="text-lg text-yellow-500" />
Rename Rename
</button> </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"> <button onclick={(e) => { e.stopPropagation(); goto(`/project/${project.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" /> <Icon icon="mdi:open-in-new" class="text-lg text-green-500" />
Open Open
</button> </button>
<div class="h-px bg-gray-200 dark:bg-white/10 my-1"></div> <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"> <button onclick={(e) => { e.stopPropagation(); deleteProject(project.id, project.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" /> <Icon icon="mdi:trash-can-outline" class="text-lg" />
Delete Delete
</button> </button>
@@ -79,7 +79,7 @@
</div> </div>
<p class="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1 mt-2 pointer-events-none"> <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" /> <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' })} Edited {new Date(project.updated_at.endsWith('Z') ? project.updated_at : project.updated_at + 'Z').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
</p> </p>
</div> </div>
</div> </div>
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import Icon from '@iconify/svelte'; import Icon from '@iconify/svelte';
interface SpaceFile { interface ProjectFile {
id: string; id: string;
path: string; path: string;
kind: string; kind: string;
@@ -19,16 +19,16 @@
onDelete, onDelete,
onSetEntry onSetEntry
}: { }: {
files?: SpaceFile[]; files?: ProjectFile[];
activeFileId?: string; activeFileId?: string;
entrypoint?: string; entrypoint?: string;
readOnly?: boolean; readOnly?: boolean;
onSelect: (file: SpaceFile) => void; onSelect: (file: ProjectFile) => void;
onCreate: (path: string) => void; onCreate: (path: string) => void;
onUpload: (fileList: FileList) => void; onUpload: (fileList: FileList) => void;
onRename: (file: SpaceFile, path: string) => void; onRename: (file: ProjectFile, path: string) => void;
onDelete: (file: SpaceFile) => void; onDelete: (file: ProjectFile) => void;
onSetEntry: (file: SpaceFile) => void; onSetEntry: (file: ProjectFile) => void;
} = $props(); } = $props();
let fileInput: HTMLInputElement = $state()!; let fileInput: HTMLInputElement = $state()!;
@@ -48,7 +48,7 @@
if (path && path.trim()) onCreate(path.trim()); if (path && path.trim()) onCreate(path.trim());
} }
function handleRename(file: SpaceFile) { function handleRename(file: ProjectFile) {
const path = prompt('Rename file to:', file.path); const path = prompt('Rename file to:', file.path);
if (path && path.trim() && path.trim() !== file.path) onRename(file, path.trim()); if (path && path.trim() && path.trim() !== file.path) onRename(file, path.trim());
} }
@@ -9,14 +9,14 @@
documentZoomStore, documentZoomStore,
previewOpenStore previewOpenStore
} from '../../ts/store'; } from '../../ts/store';
import { exportSpace } from '../../ts/typst-api'; import { exportProject } from '../../ts/typst-api';
import ThemePicker from '../ThemePicker.svelte'; import ThemePicker from '../ThemePicker.svelte';
import PageSettingsModal from '../PageSettingsModal.svelte'; import PageSettingsModal from '../PageSettingsModal.svelte';
import PresentationMode from '../PresentationMode.svelte'; import PresentationMode from '../PresentationMode.svelte';
let { let {
spaceName = 'Space', projectName = 'Project',
spaceId, projectId,
entrypoint = 'main.typ', entrypoint = 'main.typ',
role = 'owner', role = 'owner',
activeText = null, activeText = null,
@@ -25,8 +25,8 @@
onPublish, onPublish,
onFilesChanged onFilesChanged
}: { }: {
spaceName?: string; projectName?: string;
spaceId: string; projectId: string;
entrypoint?: string; entrypoint?: string;
role?: string; role?: string;
activeText?: any; activeText?: any;
@@ -55,10 +55,10 @@
let showDeleteModal = $state(false); let showDeleteModal = $state(false);
let renameName = $state(''); let renameName = $state('');
$effect(() => { renameName = spaceName; }); $effect(() => { renameName = projectName; });
function safeName() { function safeName() {
return spaceName.replace(/[^a-z0-9_-]/gi, '_'); return projectName.replace(/[^a-z0-9_-]/gi, '_');
} }
function handleExport(format: 'pdf' | 'png' | 'svg' | 'typ') { function handleExport(format: 'pdf' | 'png' | 'svg' | 'typ') {
@@ -73,7 +73,7 @@
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
return; return;
} }
exportSpace(spaceId, getAllText(), format, safeName()).catch((e) => { exportProject(projectId, getAllText(), format, safeName()).catch((e) => {
console.error(`Export to ${format} failed:`, e); console.error(`Export to ${format} failed:`, e);
alert(`Failed to export as ${format.toUpperCase()}`); alert(`Failed to export as ${format.toUpperCase()}`);
}); });
@@ -83,7 +83,7 @@
fetch(`/api/export/pdf`, { fetch(`/api/export/pdf`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ space_id: spaceId, files: getAllText() }) body: JSON.stringify({ project_id: projectId, files: getAllText() })
}) })
.then((res) => { .then((res) => {
if (!res.ok) throw new Error('Print failed'); if (!res.ok) throw new Error('Print failed');
@@ -225,7 +225,7 @@
const file = target.files[0]; const file = target.files[0];
const form = new FormData(); const form = new FormData();
form.append('file', file); form.append('file', file);
fetch(`/api/spaces/${spaceId}/files/upload`, { method: 'POST', body: form }) fetch(`/api/projects/${projectId}/files/upload`, { method: 'POST', body: form })
.then((res) => res.json()) .then((res) => res.json())
.then(() => { .then(() => {
const lower = file.name.toLowerCase(); const lower = file.name.toLowerCase();
@@ -259,8 +259,8 @@
function submitRename(e: Event) { function submitRename(e: Event) {
e.preventDefault(); e.preventDefault();
if (renameName && renameName !== spaceName) { if (renameName && renameName !== projectName) {
fetch(`/api/spaces/${spaceId}`, { fetch(`/api/projects/${projectId}`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: renameName }) body: JSON.stringify({ name: renameName })
@@ -270,8 +270,8 @@
} }
function confirmDelete() { function confirmDelete() {
fetch(`/api/spaces/${spaceId}`, { method: 'DELETE' }).then((res) => { fetch(`/api/projects/${projectId}`, { method: 'DELETE' }).then((res) => {
if (res.ok) goto('/spaces'); if (res.ok) goto('/projects');
}); });
} }
@@ -289,14 +289,14 @@
<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]"> <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 justify-between px-4 py-2.5">
<div class="flex items-center gap-3"> <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"> <button onclick={() => goto('/projects')} 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="Projects">
<Icon icon="mdi:arrow-left" class="text-xl" /> <Icon icon="mdi:arrow-left" class="text-xl" />
</button> </button>
<div class="flex flex-col gap-0.5"> <div class="flex flex-col gap-0.5">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Icon icon="mdi:folder-multiple-outline" class="text-blue-500 text-base" /> <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> <h1 class="text-[16px] font-semibold text-gray-900 dark:text-white tracking-tight truncate max-w-[200px] md:max-w-xs" title={projectName}>{projectName}</h1>
</div> </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="flex items-center gap-0.5 text-[13px] font-medium text-gray-600 dark:text-gray-300 -ml-1 action-menu-container">
@@ -304,11 +304,11 @@
<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> <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'} {#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"> <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; goto('/projects'); }} class="w-full text-left px-4 py-1.5 text-sm hover:bg-[var(--theme-border)]">All Projects</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> <button onclick={() => { activeMenu = null; showInfoModal = true; }} class="w-full text-left px-4 py-1.5 text-sm hover:bg-[var(--theme-border)]">Project Info</button>
{#if !isViewer} {#if !isViewer}
<div class="h-px bg-[var(--theme-border)] my-1"></div> <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; renameName = projectName; 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> <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'} {#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> <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>
@@ -329,7 +329,7 @@
<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> <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'} {#if role === 'owner'}
<div class="h-px bg-[var(--theme-border)] my-1"></div> <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> <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 Project</button>
{/if} {/if}
</div> </div>
{/if} {/if}
@@ -427,8 +427,8 @@
<div class="w-px h-4 bg-gray-300 dark:bg-white/10"></div> <div class="w-px h-4 bg-gray-300 dark:bg-white/10"></div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<label for="space-font-select" class="text-[11px] font-semibold uppercase tracking-wider opacity-60">Font</label> <label for="project-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"> <select id="project-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="New Computer Modern">Default (New CM)</option>
<option value="Libertinus Serif">Libertinus Serif</option> <option value="Libertinus Serif">Libertinus Serif</option>
<option value="PT Sans">PT Sans</option> <option value="PT Sans">PT Sans</option>
@@ -478,10 +478,10 @@
<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="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"> <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" /> <Icon icon="mdi:folder-multiple-outline" class="text-xl text-blue-500" />
<h3 class="text-lg font-semibold flex-grow truncate">{spaceName}</h3> <h3 class="text-lg font-semibold flex-grow truncate">{projectName}</h3>
</div> </div>
<div class="p-6 space-y-4 text-sm"> <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">Type</p><p>Project (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">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><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>
@@ -496,7 +496,7 @@
<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="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"> <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"> <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> <h3 class="text-lg font-semibold mb-4 flex items-center gap-2"><Icon icon="mdi:pencil-outline" class="text-yellow-500" /> Rename Project</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" /> <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"> <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="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>
@@ -511,8 +511,8 @@
<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="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="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"> <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> <h3 class="text-lg font-semibold mb-4 flex items-center gap-2"><Icon icon="mdi:trash-can-outline" class="text-red-500" /> Delete Project</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> <p class="text-gray-600 dark:text-gray-300 text-sm mb-6">Delete this project and all its files? This cannot be undone.</p>
<div class="flex justify-end gap-3"> <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={() => 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> <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>
+4 -4
View File
@@ -20,20 +20,20 @@ export async function compileTypst(text: string, document_id?: string): Promise<
return await res.json(); return await res.json();
} }
export async function compileSpace(space_id: string, files: Record<string, string>): Promise<CompileResponse> { export async function compileProject(project_id: string, files: Record<string, string>): Promise<CompileResponse> {
const res = await fetch('/api/compile', { const res = await fetch('/api/compile', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ space_id, files }), body: JSON.stringify({ project_id, files }),
}); });
return await res.json(); return await res.json();
} }
export function exportSpace(space_id: string, files: Record<string, string>, format: 'pdf' | 'png' | 'svg', title: string = 'document') { export function exportProject(project_id: string, files: Record<string, string>, format: 'pdf' | 'png' | 'svg', title: string = 'document') {
return fetch(`/api/export/${format}`, { return fetch(`/api/export/${format}`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ space_id, files }), body: JSON.stringify({ project_id, files }),
}) })
.then((res) => { .then((res) => {
if (!res.ok) throw new Error('Export failed'); if (!res.ok) throw new Error('Export failed');
@@ -19,18 +19,18 @@ const userColors = [
]; ];
const open = new Map<string, OpenFile>(); const open = new Map<string, OpenFile>();
let spaceId: string | null = null; let projectId: string | null = null;
const TEXT_NAME = 'typst'; const TEXT_NAME = 'typst';
export function setSpace(id: string) { export function setProject(id: string) {
spaceId = id; projectId = id;
} }
export function openFile(fileId: string, path: string): OpenFile { export function openFile(fileId: string, path: string): OpenFile {
const existing = open.get(fileId); const existing = open.get(fileId);
if (existing) return existing; if (existing) return existing;
if (!spaceId) throw new Error('Space not set'); if (!projectId) throw new Error('Project not set');
const doc = new Y.Doc(); const doc = new Y.Doc();
const text = doc.getText(TEXT_NAME); const text = doc.getText(TEXT_NAME);
@@ -40,7 +40,7 @@ export function openFile(fileId: string, path: string): OpenFile {
connectionStatus.set('connecting'); connectionStatus.set('connecting');
const provider = new WebsocketProvider(`${protocol}//${host}/yjs`, `space:${spaceId}:${fileId}`, doc); const provider = new WebsocketProvider(`${protocol}//${host}/yjs`, `project:${projectId}:${fileId}`, doc);
const user = get(userStore); const user = get(userStore);
const color = userColors[Math.floor(Math.random() * userColors.length)]; const color = userColors[Math.floor(Math.random() * userColors.length)];
@@ -104,11 +104,11 @@ export function getAllText(): Record<string, string> {
return result; return result;
} }
export function cleanupSpace() { export function cleanupProject() {
for (const fileId of Array.from(open.keys())) { for (const fileId of Array.from(open.keys())) {
closeFile(fileId); closeFile(fileId);
} }
spaceId = null; projectId = null;
connectionStatus.set('disconnected'); connectionStatus.set('disconnected');
connectedUsers.set([]); connectedUsers.set([]);
} }
+13 -13
View File
@@ -14,7 +14,7 @@
import InfoModal from '$lib/components/dashboard/InfoModal.svelte'; import InfoModal from '$lib/components/dashboard/InfoModal.svelte';
import RenameModal from '$lib/components/dashboard/RenameModal.svelte'; import RenameModal from '$lib/components/dashboard/RenameModal.svelte';
import CreateDocModal from '$lib/components/dashboard/CreateDocModal.svelte'; import CreateDocModal from '$lib/components/dashboard/CreateDocModal.svelte';
import CreateSpaceModal from '$lib/components/dashboard/CreateSpaceModal.svelte'; import CreateProjectModal from '$lib/components/dashboard/CreateProjectModal.svelte';
import CreateFolderModal from '$lib/components/dashboard/CreateFolderModal.svelte'; import CreateFolderModal from '$lib/components/dashboard/CreateFolderModal.svelte';
import Footer from '$lib/components/Footer.svelte'; import Footer from '$lib/components/Footer.svelte';
@@ -27,7 +27,7 @@
let newFolderName = $state(''); let newFolderName = $state('');
let loading = $state(true); let loading = $state(true);
let showCreateModal = $state(false); let showCreateModal = $state(false);
let showCreateSpaceModal = $state(false); let showCreateProjectModal = $state(false);
let newDocTitle = $state(''); let newDocTitle = $state('');
let showPlusDropdown = $state(false); let showPlusDropdown = $state(false);
let dragOverFolderId = $state<string | null>(null); let dragOverFolderId = $state<string | null>(null);
@@ -200,24 +200,24 @@
} }
} }
function openCreateSpaceModal() { function openCreateProjectModal() {
showPlusDropdown = false; showPlusDropdown = false;
showCreateSpaceModal = true; showCreateProjectModal = true;
} }
async function createSpace(name: string) { async function createProject(name: string) {
if (!name.trim()) return; if (!name.trim()) return;
const res = await fetch('/api/spaces', { const res = await fetch('/api/projects', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name.trim(), folder_id: currentFolderId || undefined }) body: JSON.stringify({ name: name.trim(), folder_id: currentFolderId || undefined })
}); });
if (res.ok) { if (res.ok) {
const space = await res.json(); const project = await res.json();
showCreateSpaceModal = false; showCreateProjectModal = false;
goto(`/space/${space.id}`); goto(`/project/${project.id}`);
} }
} }
@@ -437,9 +437,9 @@
<Icon icon="mdi:file-document-plus" class="text-lg text-blue-500" /> <Icon icon="mdi:file-document-plus" class="text-lg text-blue-500" />
New Document New Document
</button> </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"> <button onclick={openCreateProjectModal} 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" /> <Icon icon="mdi:folder-multiple-plus" class="text-lg text-indigo-500" />
New Space New Project
</button> </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"> <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" /> <Icon icon="mdi:folder-plus" class="text-lg text-yellow-500" />
@@ -629,8 +629,8 @@
<CreateDocModal {createDoc} onClose={() => showCreateModal = false} /> <CreateDocModal {createDoc} onClose={() => showCreateModal = false} />
{/if} {/if}
{#if showCreateSpaceModal} {#if showCreateProjectModal}
<CreateSpaceModal {createSpace} onClose={() => showCreateSpaceModal = false} /> <CreateProjectModal {createProject} onClose={() => showCreateProjectModal = false} />
{/if} {/if}
{#if showCreateFolderModal} {#if showCreateFolderModal}
+2 -2
View File
@@ -60,7 +60,7 @@
Packages Packages
</h2> </h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> <p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
Instance-local Typst packages, published from Spaces and importable as Instance-local Typst packages, published from Projects and importable as
<code class="font-mono text-xs bg-gray-100 dark:bg-white/10 px-1.5 py-0.5 rounded">@typstdrive/&lt;name&gt;:&lt;version&gt;</code>. <code class="font-mono text-xs bg-gray-100 dark:bg-white/10 px-1.5 py-0.5 rounded">@typstdrive/&lt;name&gt;:&lt;version&gt;</code>.
</p> </p>
</div> </div>
@@ -70,7 +70,7 @@
{:else if packages.length === 0} {:else if packages.length === 0}
<div class="text-center py-16 text-gray-500 dark:text-gray-400"> <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" /> <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> <p>No packages published yet. Open a Project and use “Publish” to create one.</p>
</div> </div>
{:else} {:else}
<div class="space-y-3"> <div class="space-y-3">
@@ -5,26 +5,26 @@
import Preview from '$lib/components/Preview.svelte'; import Preview from '$lib/components/Preview.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte'; import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import DocFooter from '$lib/components/DocFooter.svelte'; import DocFooter from '$lib/components/DocFooter.svelte';
import FileTree from '$lib/components/space/FileTree.svelte'; import FileTree from '$lib/components/project/FileTree.svelte';
import SpaceToolbar from '$lib/components/space/SpaceToolbar.svelte'; import ProjectToolbar from '$lib/components/project/ProjectToolbar.svelte';
import PublishPackageModal from '$lib/components/PublishPackageModal.svelte'; import PublishPackageModal from '$lib/components/PublishPackageModal.svelte';
import { compileSpace } from '$lib/ts/typst-api'; import { compileProject } from '$lib/ts/typst-api';
import type { Diagnostic } from '$lib/ts/typst-api'; import type { Diagnostic } from '$lib/ts/typst-api';
import { editorErrors, documentStatsStore, previewOpenStore, editorViewStore } from '$lib/ts/store'; import { editorErrors, documentStatsStore, previewOpenStore, editorViewStore } from '$lib/ts/store';
import { setSpace, openFile, getOpenFile, closeFile, renameOpenFile, getAllText, cleanupSpace } from '$lib/ts/yjs-space'; import { setProject, openFile, getOpenFile, closeFile, renameOpenFile, getAllText, cleanupProject } from '$lib/ts/yjs-project';
interface SpaceFile { interface ProjectFile {
id: string; id: string;
path: string; path: string;
kind: string; kind: string;
} }
const spaceId = $page.params.id as string; const projectId = $page.params.id as string;
let spaceName = $state('Space'); let projectName = $state('Project');
let entrypoint = $state('main.typ'); let entrypoint = $state('main.typ');
let role = $state('owner'); let role = $state('owner');
let files = $state<SpaceFile[]>([]); let files = $state<ProjectFile[]>([]);
let activeFileId = $state(''); let activeFileId = $state('');
let svgs = $state<string[]>([]); let svgs = $state<string[]>([]);
let errors = $state<Diagnostic[]>([]); let errors = $state<Diagnostic[]>([]);
@@ -45,7 +45,7 @@
function triggerCompile() { function triggerCompile() {
if (!$previewOpenStore) return; if (!$previewOpenStore) return;
compileSpace(spaceId, getAllText()) compileProject(projectId, getAllText())
.then((res) => { .then((res) => {
if (res.stats) $documentStatsStore = res.stats; if (res.stats) $documentStatsStore = res.stats;
if (res.svgs) { if (res.svgs) {
@@ -58,12 +58,12 @@
} }
}) })
.catch(() => { .catch(() => {
errors = [{ message: 'Network or server error compiling space.', severity: 'error' }]; errors = [{ message: 'Network or server error compiling project.', severity: 'error' }];
}); });
} }
async function loadFiles() { async function loadFiles() {
const res = await fetch(`/api/spaces/${spaceId}/files`); const res = await fetch(`/api/projects/${projectId}/files`);
if (!res.ok) return; if (!res.ok) return;
files = await res.json(); files = await res.json();
@@ -80,13 +80,13 @@
} }
} }
function selectFile(file: SpaceFile) { function selectFile(file: ProjectFile) {
if (file.kind !== 'text') return; if (file.kind !== 'text') return;
activeFileId = file.id; activeFileId = file.id;
} }
async function createFile(path: string) { async function createFile(path: string) {
const res = await fetch(`/api/spaces/${spaceId}/files`, { const res = await fetch(`/api/projects/${projectId}/files`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path, kind: 'text', content: '' }) body: JSON.stringify({ path, kind: 'text', content: '' })
@@ -103,15 +103,15 @@
async function uploadFiles(fileList: FileList) { async function uploadFiles(fileList: FileList) {
const form = new FormData(); const form = new FormData();
for (const f of fileList) form.append('file', f); for (const f of fileList) form.append('file', f);
const res = await fetch(`/api/spaces/${spaceId}/files/upload`, { method: 'POST', body: form }); const res = await fetch(`/api/projects/${projectId}/files/upload`, { method: 'POST', body: form });
if (res.ok) { if (res.ok) {
await loadFiles(); await loadFiles();
triggerCompile(); triggerCompile();
} }
} }
async function renameFile(file: SpaceFile, path: string) { async function renameFile(file: ProjectFile, path: string) {
const res = await fetch(`/api/spaces/${spaceId}/files/${file.id}`, { const res = await fetch(`/api/projects/${projectId}/files/${file.id}`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path }) body: JSON.stringify({ path })
@@ -123,9 +123,9 @@
} }
} }
async function deleteFile(file: SpaceFile) { async function deleteFile(file: ProjectFile) {
if (!confirm(`Delete ${file.path}?`)) return; if (!confirm(`Delete ${file.path}?`)) return;
const res = await fetch(`/api/spaces/${spaceId}/files/${file.id}`, { method: 'DELETE' }); const res = await fetch(`/api/projects/${projectId}/files/${file.id}`, { method: 'DELETE' });
if (res.ok) { if (res.ok) {
closeFile(file.id); closeFile(file.id);
files = files.filter((f) => f.id !== file.id); files = files.filter((f) => f.id !== file.id);
@@ -136,8 +136,8 @@
} }
} }
async function setEntry(file: SpaceFile) { async function setEntry(file: ProjectFile) {
const res = await fetch(`/api/spaces/${spaceId}`, { const res = await fetch(`/api/projects/${projectId}`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ entrypoint: file.path }) body: JSON.stringify({ entrypoint: file.path })
@@ -166,38 +166,38 @@
} }
onMount(() => { onMount(() => {
setSpace(spaceId); setProject(projectId);
fetch(`/api/spaces/${spaceId}`) fetch(`/api/projects/${projectId}`)
.then((r) => r.json()) .then((r) => r.json())
.then((s) => { .then((p) => {
if (s && s.name) spaceName = s.name; if (p && p.name) projectName = p.name;
if (s && s.entrypoint) entrypoint = s.entrypoint; if (p && p.entrypoint) entrypoint = p.entrypoint;
if (s && s.effective_role) role = s.effective_role; if (p && p.effective_role) role = p.effective_role;
}) })
.then(loadFiles) .then(loadFiles)
.then(() => { .then(() => {
ready = true; ready = true;
triggerCompile(); triggerCompile();
}) })
.catch((e) => console.error('Failed to load space', e)); .catch((e) => console.error('Failed to load project', e));
return () => { return () => {
if (timeoutId) clearTimeout(timeoutId); if (timeoutId) clearTimeout(timeoutId);
cleanupSpace(); cleanupProject();
}; };
}); });
</script> </script>
<svelte:head> <svelte:head>
<title>{spaceName} - TypstDrive</title> <title>{projectName} - TypstDrive</title>
</svelte:head> </svelte:head>
<svelte:window onclick={closeContextMenu} /> <svelte:window onclick={closeContextMenu} />
<div class="flex flex-col h-screen relative"> <div class="flex flex-col h-screen relative">
<SpaceToolbar <ProjectToolbar
{spaceName} {projectName}
{spaceId} {projectId}
{entrypoint} {entrypoint}
{role} {role}
activeText={activeEntry?.text ?? null} activeText={activeEntry?.text ?? null}
@@ -254,5 +254,5 @@
{/if} {/if}
{#if showPublish} {#if showPublish}
<PublishPackageModal {spaceId} onClose={() => (showPublish = false)} /> <PublishPackageModal {projectId} onClose={() => (showPublish = false)} />
{/if} {/if}
@@ -3,9 +3,9 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import Icon from '@iconify/svelte'; import Icon from '@iconify/svelte';
import Navbar from '$lib/components/dashboard/Navbar.svelte'; import Navbar from '$lib/components/dashboard/Navbar.svelte';
import SpaceCard from '$lib/components/dashboard/SpaceCard.svelte'; import ProjectCard from '$lib/components/dashboard/ProjectCard.svelte';
interface Space { interface Project {
id: string; id: string;
name: string; name: string;
entrypoint: string; entrypoint: string;
@@ -14,8 +14,8 @@
effective_role?: string; effective_role?: string;
} }
let spaces = $state<Space[]>([]); let projects = $state<Project[]>([]);
let shared = $state<Space[]>([]); let shared = $state<Project[]>([]);
let loading = $state(true); let loading = $state(true);
let showCreate = $state(false); let showCreate = $state(false);
let newName = $state(''); let newName = $state('');
@@ -26,22 +26,22 @@
let renameId = $state(''); let renameId = $state('');
let renameName = $state(''); let renameName = $state('');
let showInfo = $state(false); let showInfo = $state(false);
let infoSpace = $state<Space | null>(null); let infoProject = $state<Project | null>(null);
function setActiveMenu(id: string | null) { activeMenu = id; } function setActiveMenu(id: string | null) { activeMenu = id; }
function openInfo(space: Space) { activeMenu = null; infoSpace = space; showInfo = true; } function openInfo(project: Project) { activeMenu = null; infoProject = project; showInfo = true; }
function openRename(id: string, name: string) { activeMenu = null; renameId = id; renameName = name; showRename = true; } function openRename(id: string, name: string) { activeMenu = null; renameId = id; renameName = name; showRename = true; }
async function submitRename(e: Event) { async function submitRename(e: Event) {
e.preventDefault(); e.preventDefault();
if (!renameName.trim()) return; if (!renameName.trim()) return;
const res = await fetch(`/api/spaces/${renameId}`, { const res = await fetch(`/api/projects/${renameId}`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: renameName.trim() }) body: JSON.stringify({ name: renameName.trim() })
}); });
if (res.ok) { if (res.ok) {
spaces = spaces.map((s) => (s.id === renameId ? { ...s, name: renameName.trim() } : s)); projects = projects.map((p) => (p.id === renameId ? { ...p, name: renameName.trim() } : p));
} }
showRename = false; showRename = false;
} }
@@ -54,10 +54,10 @@
async function load() { async function load() {
loading = true; loading = true;
const [own, sh] = await Promise.all([ const [own, sh] = await Promise.all([
fetch('/api/spaces').then((r) => (r.ok ? r.json() : [])), fetch('/api/projects').then((r) => (r.ok ? r.json() : [])),
fetch('/api/spaces/shared').then((r) => (r.ok ? r.json() : [])) fetch('/api/projects/shared').then((r) => (r.ok ? r.json() : []))
]); ]);
spaces = own; projects = own;
shared = sh; shared = sh;
loading = false; loading = false;
} }
@@ -65,29 +65,29 @@
async function create() { async function create() {
if (!newName.trim()) return; if (!newName.trim()) return;
creating = true; creating = true;
const res = await fetch('/api/spaces', { const res = await fetch('/api/projects', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newName.trim() }) body: JSON.stringify({ name: newName.trim() })
}); });
creating = false; creating = false;
if (res.ok) { if (res.ok) {
const space = await res.json(); const project = await res.json();
goto(`/space/${space.id}`); goto(`/project/${project.id}`);
} }
} }
async function remove(id: string, name: string) { async function remove(id: string, name: string) {
if (!confirm(`Delete space "${name}"? This cannot be undone.`)) return; if (!confirm(`Delete project "${name}"? This cannot be undone.`)) return;
const res = await fetch(`/api/spaces/${id}`, { method: 'DELETE' }); const res = await fetch(`/api/projects/${id}`, { method: 'DELETE' });
if (res.ok) spaces = spaces.filter((s) => s.id !== id); if (res.ok) projects = projects.filter((p) => p.id !== id);
} }
onMount(load); onMount(load);
</script> </script>
<svelte:head> <svelte:head>
<title>Spaces - TypstDrive</title> <title>Projects - TypstDrive</title>
</svelte:head> </svelte:head>
<svelte:window onclick={handleWindowClick} /> <svelte:window onclick={handleWindowClick} />
@@ -104,33 +104,33 @@
</button> </button>
<h2 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2"> <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" /> <Icon icon="mdi:folder-multiple-outline" class="text-blue-500" />
Spaces Projects
</h2> </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> <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> </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"> <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 <Icon icon="mdi:plus" class="text-lg" /> New Project
</button> </button>
</div> </div>
{#if loading} {#if loading}
<p class="text-gray-500 dark:text-gray-400">Loading…</p> <p class="text-gray-500 dark:text-gray-400">Loading…</p>
{:else} {:else}
{#if spaces.length === 0} {#if projects.length === 0}
<div class="text-center py-16 text-gray-500 dark:text-gray-400"> <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" /> <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> <p>No projects yet. Create one to start a multi-file project.</p>
</div> </div>
{:else} {:else}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{#each spaces as space (space.id)} {#each projects as project (project.id)}
<SpaceCard <ProjectCard
{space} {project}
{activeMenu} {activeMenu}
{setActiveMenu} {setActiveMenu}
{openInfo} {openInfo}
{openRename} {openRename}
deleteSpace={remove} deleteProject={remove}
/> />
{/each} {/each}
</div> </div>
@@ -141,18 +141,18 @@
<Icon icon="mdi:account-group-outline" class="text-blue-500" /> Shared with me <Icon icon="mdi:account-group-outline" class="text-blue-500" /> Shared with me
</h3> </h3>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{#each shared as space (space.id)} {#each shared as project (project.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"> <button onclick={() => goto(`/project/${project.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"> <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} {#if project.thumbnail_svg}
{@html space.thumbnail_svg} {@html project.thumbnail_svg}
{:else} {:else}
<Icon icon="mdi:folder-multiple-outline" class="text-4xl text-gray-300 dark:text-gray-600" /> <Icon icon="mdi:folder-multiple-outline" class="text-4xl text-gray-300 dark:text-gray-600" />
{/if} {/if}
</div> </div>
<div class="p-3"> <div class="p-3">
<p class="font-medium text-gray-900 dark:text-white truncate">{space.name}</p> <p class="font-medium text-gray-900 dark:text-white truncate">{project.name}</p>
<p class="text-xs text-gray-400 mt-0.5">{space.effective_role}</p> <p class="text-xs text-gray-400 mt-0.5">{project.effective_role}</p>
</div> </div>
</button> </button>
{/each} {/each}
@@ -165,8 +165,8 @@
{#if showCreate} {#if showCreate}
<div class="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50" onclick={() => (showCreate = false)} role="presentation"> <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"> <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> <h2 class="text-lg font-bold mb-4 flex items-center gap-2"><Icon icon="mdi:folder-plus-outline" class="text-blue-500" /> New Project</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" /> <input bind:value={newName} placeholder="Project 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"> <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={() => (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> <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>
@@ -178,7 +178,7 @@
{#if showRename} {#if showRename}
<div class="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50" onclick={() => (showRename = false)} role="presentation"> <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()}> <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> <h2 class="text-lg font-bold mb-4 flex items-center gap-2"><Icon icon="mdi:pencil-outline" class="text-yellow-500" /> Rename Project</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" /> <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"> <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="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>
@@ -188,16 +188,16 @@
</div> </div>
{/if} {/if}
{#if showInfo && infoSpace} {#if showInfo && infoProject}
<div class="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50" onclick={() => (showInfo = false)} role="presentation"> <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="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"> <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" /> <Icon icon="mdi:folder-multiple-outline" class="text-xl text-blue-500" />
<h3 class="text-lg font-semibold flex-grow truncate">{infoSpace.name}</h3> <h3 class="text-lg font-semibold flex-grow truncate">{infoProject.name}</h3>
</div> </div>
<div class="p-6 space-y-4 text-sm"> <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">Entrypoint</p><p class="font-mono">{infoProject.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><p class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1">Last Modified</p><p>{new Date(infoProject.updated_at.endsWith('Z') ? infoProject.updated_at : infoProject.updated_at + 'Z').toLocaleString()}</p></div>
</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"> <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> <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>