Rename Space to Project, add desktop document creation
This commit is contained in:
+1
-1
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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));
|
||||||
|
|||||||
@@ -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>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)))?;
|
||||||
|
|
||||||
|
|||||||
@@ -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/<name>:<version></code>.
|
<code class="font-mono text-xs bg-gray-100 dark:bg-white/10 px-1.5 py-0.5 rounded">@typstdrive/<name>:<version></code>.
|
||||||
The name, version and entrypoint come from your <code class="font-mono text-xs bg-gray-100 dark:bg-white/10 px-1.5 py-0.5 rounded">typst.toml</code>.
|
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>
|
||||||
|
|||||||
+11
-11
@@ -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>
|
||||||
@@ -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">
|
||||||
|
|||||||
+18
-18
@@ -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>
|
||||||
+7
-7
@@ -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());
|
||||||
}
|
}
|
||||||
+27
-27
@@ -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>
|
||||||
@@ -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([]);
|
||||||
}
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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/<name>:<version></code>.
|
<code class="font-mono text-xs bg-gray-100 dark:bg-white/10 px-1.5 py-0.5 rounded">@typstdrive/<name>:<version></code>.
|
||||||
</p>
|
</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>
|
||||||
Reference in New Issue
Block a user