diff --git a/Cargo.toml b/Cargo.toml index 2e3e292..e531df2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ name = "rustpad" version = "0.0.1-dev" edition = "2024" -rust-version = "1.97" +rust-version = "1.94" description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" license = "MIT" diff --git a/__pycache__/make_zip.cpython-313.pyc b/__pycache__/make_zip.cpython-313.pyc new file mode 100644 index 0000000..278d7b6 Binary files /dev/null and b/__pycache__/make_zip.cpython-313.pyc differ diff --git a/migrations/mysql/0002_note_protection_files.sql b/migrations/mysql/0002_note_protection_files.sql new file mode 100644 index 0000000..89acab8 --- /dev/null +++ b/migrations/mysql/0002_note_protection_files.sql @@ -0,0 +1,8 @@ +ALTER TABLE notes ADD COLUMN protected BOOLEAN NOT NULL DEFAULT FALSE; +CREATE TABLE note_files ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, note_id BIGINT NOT NULL, filename TEXT NOT NULL, url VARCHAR(512) NOT NULL UNIQUE, + mime_type VARCHAR(255) NOT NULL, size_bytes BIGINT NOT NULL, created_at VARCHAR(64) NOT NULL DEFAULT (CURRENT_TIMESTAMP), + is_attached BOOLEAN NOT NULL DEFAULT TRUE, detached_at VARCHAR(64), + CONSTRAINT fk_note_files_note FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE, + INDEX idx_note_files_note (note_id, id DESC) +) ENGINE=InnoDB; diff --git a/migrations/mysql/0003_pad_files.sql b/migrations/mysql/0003_pad_files.sql new file mode 100644 index 0000000..0ecc606 --- /dev/null +++ b/migrations/mysql/0003_pad_files.sql @@ -0,0 +1,13 @@ +CREATE TABLE pad_files ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + pad_id BIGINT NOT NULL, + filename TEXT NOT NULL, + url VARCHAR(512) NOT NULL UNIQUE, + mime_type VARCHAR(255) NOT NULL, + size_bytes BIGINT NOT NULL, + created_at VARCHAR(64) NOT NULL DEFAULT (CURRENT_TIMESTAMP), + is_attached BOOLEAN NOT NULL DEFAULT TRUE, + detached_at VARCHAR(64), + CONSTRAINT fk_pad_files_pad FOREIGN KEY (pad_id) REFERENCES pads(id) ON DELETE CASCADE, + INDEX idx_pad_files_pad (pad_id, id DESC) +) ENGINE=InnoDB; diff --git a/migrations/mysql/0004_note_creator.sql b/migrations/mysql/0004_note_creator.sql new file mode 100644 index 0000000..94a8892 --- /dev/null +++ b/migrations/mysql/0004_note_creator.sql @@ -0,0 +1 @@ +ALTER TABLE notes ADD COLUMN created_by TEXT; diff --git a/migrations/postgres/0002_note_protection_files.sql b/migrations/postgres/0002_note_protection_files.sql new file mode 100644 index 0000000..4138755 --- /dev/null +++ b/migrations/postgres/0002_note_protection_files.sql @@ -0,0 +1,7 @@ +ALTER TABLE notes ADD COLUMN protected BOOLEAN NOT NULL DEFAULT FALSE; +CREATE TABLE note_files ( + id BIGSERIAL PRIMARY KEY, note_id BIGINT NOT NULL REFERENCES notes(id) ON DELETE CASCADE, filename TEXT NOT NULL, url TEXT NOT NULL UNIQUE, + mime_type TEXT NOT NULL, size_bytes BIGINT NOT NULL, created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text), + is_attached BOOLEAN NOT NULL DEFAULT TRUE, detached_at TEXT +); +CREATE INDEX idx_note_files_note ON note_files(note_id, id DESC); diff --git a/migrations/postgres/0003_pad_files.sql b/migrations/postgres/0003_pad_files.sql new file mode 100644 index 0000000..67df97e --- /dev/null +++ b/migrations/postgres/0003_pad_files.sql @@ -0,0 +1,12 @@ +CREATE TABLE pad_files ( + id BIGSERIAL PRIMARY KEY, + pad_id BIGINT NOT NULL REFERENCES pads(id) ON DELETE CASCADE, + filename TEXT NOT NULL, + url TEXT NOT NULL UNIQUE, + mime_type TEXT NOT NULL, + size_bytes BIGINT NOT NULL, + created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text), + is_attached BOOLEAN NOT NULL DEFAULT TRUE, + detached_at TEXT +); +CREATE INDEX idx_pad_files_pad ON pad_files(pad_id, id DESC); diff --git a/migrations/postgres/0004_note_creator.sql b/migrations/postgres/0004_note_creator.sql new file mode 100644 index 0000000..94a8892 --- /dev/null +++ b/migrations/postgres/0004_note_creator.sql @@ -0,0 +1 @@ +ALTER TABLE notes ADD COLUMN created_by TEXT; diff --git a/migrations/sqlite/0002_workspace_note_files.sql b/migrations/sqlite/0002_workspace_note_files.sql new file mode 100644 index 0000000..6437a27 --- /dev/null +++ b/migrations/sqlite/0002_workspace_note_files.sql @@ -0,0 +1,32 @@ +ALTER TABLE pads ADD COLUMN owner_map TEXT NOT NULL DEFAULT '[]'; +ALTER TABLE pads ADD COLUMN file_token TEXT; +CREATE UNIQUE INDEX IF NOT EXISTS idx_pads_file_token ON pads(file_token); +ALTER TABLE revisions ADD COLUMN author TEXT; +ALTER TABLE revisions ADD COLUMN owner_map TEXT NOT NULL DEFAULT '[]'; + +CREATE TABLE IF NOT EXISTS workspaces ( + id INTEGER PRIMARY KEY AUTOINCREMENT, slug TEXT NOT NULL UNIQUE, title TEXT NOT NULL, password_hash TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE TABLE IF NOT EXISTS notes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, workspace_id INTEGER NOT NULL, slug TEXT NOT NULL, title TEXT NOT NULL, content TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, owner_map TEXT NOT NULL DEFAULT '[]', file_token TEXT UNIQUE, + protected INTEGER NOT NULL DEFAULT 0, FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE, UNIQUE(workspace_id, slug) +); +CREATE INDEX IF NOT EXISTS idx_notes_workspace ON notes(workspace_id, updated_at DESC); +CREATE TABLE IF NOT EXISTS note_revisions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, note_id INTEGER NOT NULL, content TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + author TEXT, owner_map TEXT NOT NULL DEFAULT '[]', FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_note_revisions_note ON note_revisions(note_id, id DESC); +CREATE TABLE IF NOT EXISTS published_pages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, token TEXT NOT NULL UNIQUE, pad_id INTEGER UNIQUE, note_id INTEGER UNIQUE, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (pad_id) REFERENCES pads(id) ON DELETE CASCADE, FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE, + CHECK ((pad_id IS NOT NULL AND note_id IS NULL) OR (pad_id IS NULL AND note_id IS NOT NULL)) +); +CREATE TABLE IF NOT EXISTS note_files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, note_id INTEGER NOT NULL, filename TEXT NOT NULL, url TEXT NOT NULL UNIQUE, mime_type TEXT NOT NULL, + size_bytes INTEGER NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, is_attached INTEGER NOT NULL DEFAULT 1, detached_at TEXT, + FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_note_files_note ON note_files(note_id, id DESC); diff --git a/migrations/sqlite/0003_pad_files.sql b/migrations/sqlite/0003_pad_files.sql new file mode 100644 index 0000000..ece356c --- /dev/null +++ b/migrations/sqlite/0003_pad_files.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS pad_files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + pad_id INTEGER NOT NULL, + filename TEXT NOT NULL, + url TEXT NOT NULL UNIQUE, + mime_type TEXT NOT NULL, + size_bytes INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + is_attached INTEGER NOT NULL DEFAULT 1, + detached_at TEXT, + FOREIGN KEY (pad_id) REFERENCES pads(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_pad_files_pad ON pad_files(pad_id, id DESC); diff --git a/migrations/sqlite/0004_note_creator.sql b/migrations/sqlite/0004_note_creator.sql new file mode 100644 index 0000000..94a8892 --- /dev/null +++ b/migrations/sqlite/0004_note_creator.sql @@ -0,0 +1 @@ +ALTER TABLE notes ADD COLUMN created_by TEXT; diff --git a/src/api.rs b/src/api.rs index 38620e2..9d68bdb 100644 --- a/src/api.rs +++ b/src/api.rs @@ -53,6 +53,10 @@ pub struct CreateNoteRequest { name: String, #[serde(default)] password: Option, + #[serde(default)] + protect: bool, + #[serde(default)] + created_by: Option, } #[derive(Debug, Deserialize)] @@ -84,6 +88,8 @@ pub struct NoteListItem { created_at: String, updated_at: String, url: String, + protected: bool, + created_by: Option, } #[derive(Debug, Serialize)] @@ -93,6 +99,7 @@ pub struct NoteInfo { slug: String, title: String, protected: bool, + note_protected: bool, created_at: String, updated_at: String, } @@ -141,6 +148,8 @@ pub async fn open_workspace( title: note.title, created_at: db::normalize_timestamp(¬e.created_at), updated_at: db::normalize_timestamp(¬e.updated_at), + protected: note.protected, + created_by: note.created_by, }) .collect(); @@ -163,7 +172,8 @@ pub async fn create_note( } let slug = unique_note_slug(&state, workspace.id, &base).await?; - let note = db::create_note(&state.db, workspace.id, &slug, title).await?; + let created_by = payload.created_by.as_deref().map(str::trim).filter(|v| !v.is_empty()).map(|v| v.chars().take(40).collect::()); + let note = db::create_note(&state.db, workspace.id, &slug, title, payload.protect, created_by.as_deref()).await?; Ok(( StatusCode::CREATED, Json(NoteListItem { @@ -172,6 +182,8 @@ pub async fn create_note( title: note.title, created_at: db::normalize_timestamp(¬e.created_at), updated_at: db::normalize_timestamp(¬e.updated_at), + protected: note.protected, + created_by: note.created_by, }), )) } @@ -193,6 +205,7 @@ pub async fn note_info( slug: note.slug, title: note.title, protected: workspace.password_hash.is_some(), + note_protected: note.protected, created_at: db::normalize_timestamp(¬e.created_at), updated_at: db::normalize_timestamp(¬e.updated_at), })) @@ -547,8 +560,30 @@ pub async fn upload_pad_file( stored = format!("{stem}-{}{}", db::random_suffix(6), ext); path = dir.join(&stored); } - tokio::fs::write(&path, bytes).await.map_err(|_| ApiError::internal("Failed to save the file"))?; - Ok(Json(serde_json::json!({"name": stored, "url": format!("/f/{}/{}", file_token, stored)}))) + tokio::fs::write(&path, &bytes).await.map_err(|_| ApiError::internal("Failed to save the file"))?; + let url = format!("/f/{}/{}", file_token, stored); + let mime = mime_guess::from_path(&stored).first_or_octet_stream().to_string(); + db::register_pad_file(&state.db, pad.id, &stored, &url, &mime, bytes.len() as i64).await?; + Ok(Json(serde_json::json!({"name": stored, "url": url}))) +} + +pub async fn pad_files( + State(state): State, + Path(slug): Path, + Json(payload): Json, +) -> Result>, ApiError> { + let pad = authorized_pad(&state, &slug, payload.password.as_deref()).await?; + let mut files = db::list_pad_files(&state.db, pad.id).await?; + for file in &mut files { + let attached = pad.content.contains(&file.url); + if attached != file.is_attached { + db::set_pad_file_attached(&state.db, file.id, attached).await?; + file.is_attached = attached; + file.detached_at = if attached { None } else { Some(chrono::Utc::now().to_rfc3339()) }; + } + file.created_at = db::normalize_timestamp(&file.created_at); + } + Ok(Json(files)) } pub async fn upload_note_file( @@ -586,11 +621,69 @@ pub async fn upload_note_file( stored = format!("{stem}-{}{}", db::random_suffix(6), ext); path = dir.join(&stored); } - tokio::fs::write(&path, bytes).await.map_err(|_| ApiError::internal("Failed to save the file"))?; - Ok(Json(serde_json::json!({"name": stored, "url": format!("/f/{}/{}", file_token, stored)}))) + tokio::fs::write(&path, &bytes).await.map_err(|_| ApiError::internal("Failed to save the file"))?; + let url = format!("/f/{}/{}", file_token, stored); + let mime = mime_guess::from_path(&stored).first_or_octet_stream().to_string(); + db::register_note_file(&state.db, note.id, &stored, &url, &mime, bytes.len() as i64).await?; + Ok(Json(serde_json::json!({"name": stored, "url": url}))) } +pub async fn delete_note( + State(state): State, + Path((workspace_slug, note_slug)): Path<(String, String)>, + Json(payload): Json, +) -> Result, ApiError> { + let (_workspace, note) = authorized_note(&state, &workspace_slug, ¬e_slug, payload.password.as_deref()).await?; + if note.protected { return Err(ApiError::bad_request("This note is protected and cannot be deleted")); } + db::delete_note(&state.db, note.id).await?; + Ok(Json(serde_json::json!({"ok": true}))) +} + +pub async fn note_files( + State(state): State, + Path((workspace_slug, note_slug)): Path<(String, String)>, + Json(payload): Json, +) -> Result>, ApiError> { + let (_workspace, note) = authorized_note(&state, &workspace_slug, ¬e_slug, payload.password.as_deref()).await?; + let mut files = db::list_note_files(&state.db, note.id).await?; + for file in &mut files { + let attached = note.content.contains(&file.url); + if attached != file.is_attached { + db::set_note_file_attached(&state.db, file.id, attached).await?; + file.is_attached = attached; + file.detached_at = if attached { None } else { Some(chrono::Utc::now().to_rfc3339()) }; + } + file.created_at = db::normalize_timestamp(&file.created_at); + } + Ok(Json(files)) +} + +pub async fn delete_note_file( + State(state): State, + Path((workspace_slug, note_slug, file_id)): Path<(String, String, i64)>, + Json(payload): Json, +) -> Result, ApiError> { + let (workspace, note) = authorized_note(&state, &workspace_slug, ¬e_slug, payload.password.as_deref()).await?; + if workspace.password_hash.is_none() || payload.password.as_deref().unwrap_or_default().is_empty() { + return Err(ApiError::unauthorized()); + } + let file = db::find_note_file(&state.db, note.id, file_id).await? + .ok_or_else(ApiError::not_found_file)?; + let relative = file.url.trim_start_matches('/').split('/').collect::>(); + if relative.len() == 3 && relative[0] == "f" { + let directory = format!("{}_{}", note.id, relative[1]); + let path = std::path::Path::new(&state.files_dir).join("notes").join(directory).join(sanitize_filename(relative[2])); + match tokio::fs::remove_file(&path).await { + Ok(()) => {}, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}, + Err(_) => return Err(ApiError::internal("Failed to delete the file")), + } + } + db::delete_note_file(&state.db, note.id, file_id).await?; + Ok(Json(serde_json::json!({"ok": true}))) +} + pub async fn download_file( State(state): State, Path((token, filename)): Path<(String, String)>, diff --git a/src/app.rs b/src/app.rs index dfefda3..fb3c291 100644 --- a/src/app.rs +++ b/src/app.rs @@ -25,14 +25,14 @@ pub fn router(state: SharedState, static_dir: &str, upload_max_size_bytes: usize .route("/api/pads/{slug}/history", post(api::pad_history)) .route("/api/pads/{slug}/publish", post(api::publish_pad_page)) .route("/api/pads/{slug}/restore", post(api::pad_restore)) - .route("/api/pads/{slug}/files", post(api::upload_pad_file)) + .route("/api/pads/{slug}/files", post(api::upload_pad_file).put(api::pad_files)) .route("/api/workspaces", post(api::create_workspace)) .route("/api/workspaces/{workspace_slug}", get(api::workspace_info)) .route("/api/workspaces/{workspace_slug}/open", post(api::open_workspace)) .route("/api/workspaces/{workspace_slug}/notes", post(api::create_note)) .route( "/api/workspaces/{workspace_slug}/notes/{note_slug}", - get(api::note_info), + get(api::note_info).delete(api::delete_note), ) .route( "/api/workspaces/{workspace_slug}/notes/{note_slug}/publish", @@ -48,7 +48,11 @@ pub fn router(state: SharedState, static_dir: &str, upload_max_size_bytes: usize ) .route( "/api/workspaces/{workspace_slug}/notes/{note_slug}/files", - post(api::upload_note_file), + post(api::upload_note_file).put(api::note_files), + ) + .route( + "/api/workspaces/{workspace_slug}/notes/{note_slug}/files/{file_id}", + axum::routing::delete(api::delete_note_file), ) .route("/ws/p/{slug}", get(websocket::upgrade_pad)) .route( diff --git a/src/db.rs b/src/db.rs index 1e11e27..dcec1a9 100644 --- a/src/db.rs +++ b/src/db.rs @@ -44,6 +44,39 @@ pub struct Note { pub created_at: String, pub updated_at: String, pub owner_map: String, + pub protected: bool, + pub created_by: Option, +} + +#[derive(Debug, Clone, FromRow)] +struct SqliteNote { + id: i64, + workspace_id: i64, + slug: String, + title: String, + content: String, + created_at: String, + updated_at: String, + owner_map: String, + protected: i64, + created_by: Option, +} + +impl From for Note { + fn from(value: SqliteNote) -> Self { + Self { + id: value.id, + workspace_id: value.workspace_id, + slug: value.slug, + title: value.title, + content: value.content, + created_at: value.created_at, + updated_at: value.updated_at, + owner_map: value.owner_map, + protected: value.protected != 0, + created_by: value.created_by, + } + } } #[derive(Debug, Serialize, FromRow)] @@ -98,10 +131,19 @@ pub fn verify_workspace_password(workspace: &Workspace, password: Option<&str>) } pub async fn list_notes(pool: &Database, workspace_id: i64) -> Result, sqlx::Error> { + if pool.kind() == DatabaseKind::Sqlite { + return Ok(sqlx::query_as::<_, SqliteNote>(queries::Q003) + .bind(workspace_id) + .fetch_all(pool.pool()) + .await? + .into_iter() + .map(Note::from) + .collect()); + } sqlx::query_as::<_, Note>(queries::get(pool.kind(), queries::Q003)) - .bind(workspace_id) - .fetch_all(pool.pool()) - .await + .bind(workspace_id) + .fetch_all(pool.pool()) + .await } pub async fn find_note( @@ -109,11 +151,19 @@ pub async fn find_note( workspace_id: i64, slug: &str, ) -> Result, sqlx::Error> { + if pool.kind() == DatabaseKind::Sqlite { + return Ok(sqlx::query_as::<_, SqliteNote>(queries::Q004) + .bind(workspace_id) + .bind(slug) + .fetch_optional(pool.pool()) + .await? + .map(Note::from)); + } sqlx::query_as::<_, Note>(queries::get(pool.kind(), queries::Q004)) - .bind(workspace_id) - .bind(slug) - .fetch_optional(pool.pool()) - .await + .bind(workspace_id) + .bind(slug) + .fetch_optional(pool.pool()) + .await } pub async fn create_note( @@ -121,19 +171,21 @@ pub async fn create_note( workspace_id: i64, slug: &str, title: &str, + protected: bool, + created_by: Option<&str>, ) -> Result { sqlx::query(queries::get(pool.kind(), queries::Q005)) .bind(workspace_id) .bind(slug) .bind(title) + .bind(protected) + .bind(created_by) .execute(pool.pool()) .await?; - sqlx::query_as::<_, Note>(queries::get(pool.kind(), queries::Q004)) - .bind(workspace_id) - .bind(slug) - .fetch_one(pool.pool()) - .await + find_note(pool, workspace_id, slug) + .await? + .ok_or(sqlx::Error::RowNotFound) } pub async fn save_revision( @@ -453,3 +505,125 @@ pub async fn find_file_owner(pool: &Database, token: &str) -> Result, +} + +#[derive(Debug, Clone, FromRow)] +struct SqliteNoteFile { + id: i64, + filename: String, + url: String, + mime_type: String, + size_bytes: i64, + created_at: String, + is_attached: i64, + detached_at: Option, +} + +impl From for NoteFile { + fn from(value: SqliteNoteFile) -> Self { + Self { + id: value.id, + filename: value.filename, + url: value.url, + mime_type: value.mime_type, + size_bytes: value.size_bytes, + created_at: value.created_at, + is_attached: value.is_attached != 0, + detached_at: value.detached_at, + } + } +} + +pub async fn delete_note(pool: &Database, note_id: i64) -> Result<(), sqlx::Error> { + sqlx::query(queries::get(pool.kind(), queries::Q031)).bind(note_id).execute(pool.pool()).await?; + Ok(()) +} + +pub async fn register_note_file(pool: &Database, note_id: i64, filename: &str, url: &str, mime_type: &str, size_bytes: i64) -> Result<(), sqlx::Error> { + sqlx::query(queries::get(pool.kind(), queries::Q032)) + .bind(note_id).bind(filename).bind(url).bind(mime_type).bind(size_bytes) + .execute(pool.pool()).await?; + Ok(()) +} + +pub async fn list_note_files(pool: &Database, note_id: i64) -> Result, sqlx::Error> { + list_files(pool, queries::Q033, note_id).await +} + +async fn list_files(pool: &Database, query: &'static str, owner_id: i64) -> Result, sqlx::Error> { + if pool.kind() == DatabaseKind::Sqlite { + return Ok(sqlx::query_as::<_, SqliteNoteFile>(query) + .bind(owner_id) + .fetch_all(pool.pool()) + .await? + .into_iter() + .map(NoteFile::from) + .collect()); + } + sqlx::query_as::<_, NoteFile>(queries::get(pool.kind(), query)) + .bind(owner_id) + .fetch_all(pool.pool()) + .await +} + +pub async fn set_note_file_attached(pool: &Database, file_id: i64, attached: bool) -> Result<(), sqlx::Error> { + let detached_at: Option = if attached { None } else { Some(chrono::Utc::now().to_rfc3339()) }; + sqlx::query(queries::get(pool.kind(), queries::Q034)).bind(attached).bind(detached_at).bind(file_id).execute(pool.pool()).await?; + Ok(()) +} + + +pub async fn register_pad_file(pool: &Database, pad_id: i64, filename: &str, url: &str, mime_type: &str, size_bytes: i64) -> Result<(), sqlx::Error> { + sqlx::query(queries::get(pool.kind(), queries::Q035)) + .bind(pad_id).bind(filename).bind(url).bind(mime_type).bind(size_bytes) + .execute(pool.pool()).await?; + Ok(()) +} + +pub async fn list_pad_files(pool: &Database, pad_id: i64) -> Result, sqlx::Error> { + list_files(pool, queries::Q036, pad_id).await +} + +pub async fn set_pad_file_attached(pool: &Database, file_id: i64, attached: bool) -> Result<(), sqlx::Error> { + let detached_at: Option = if attached { None } else { Some(chrono::Utc::now().to_rfc3339()) }; + sqlx::query(queries::get(pool.kind(), queries::Q037)).bind(attached).bind(detached_at).bind(file_id).execute(pool.pool()).await?; + Ok(()) +} + + +pub async fn find_note_file(pool: &Database, note_id: i64, file_id: i64) -> Result, sqlx::Error> { + if pool.kind() == DatabaseKind::Sqlite { + return Ok(sqlx::query_as::<_, SqliteNoteFile>(queries::Q038) + .bind(file_id) + .bind(note_id) + .fetch_optional(pool.pool()) + .await? + .map(NoteFile::from)); + } + sqlx::query_as::<_, NoteFile>(queries::get(pool.kind(), queries::Q038)) + .bind(file_id) + .bind(note_id) + .fetch_optional(pool.pool()) + .await +} + +pub async fn delete_note_file(pool: &Database, note_id: i64, file_id: i64) -> Result<(), sqlx::Error> { + sqlx::query(queries::get(pool.kind(), queries::Q039)) + .bind(file_id) + .bind(note_id) + .execute(pool.pool()) + .await?; + Ok(()) +} diff --git a/src/queries.rs b/src/queries.rs index 3d2d82f..952400f 100644 --- a/src/queries.rs +++ b/src/queries.rs @@ -3,9 +3,9 @@ use crate::database::DatabaseKind; pub const Q001: &str = "SELECT id, slug, title, password_hash, created_at, updated_at FROM workspaces WHERE slug = ?"; pub const Q002: &str = "INSERT INTO workspaces (slug, title, password_hash) VALUES (?, ?, ?)"; -pub const Q003: &str = "SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map FROM notes WHERE workspace_id = ? ORDER BY updated_at DESC, id DESC"; -pub const Q004: &str = "SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map FROM notes WHERE workspace_id = ? AND slug = ?"; -pub const Q005: &str = "INSERT INTO notes (workspace_id, slug, title) VALUES (?, ?, ?)"; +pub const Q003: &str = "SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map, protected, created_by FROM notes WHERE workspace_id = ? ORDER BY updated_at DESC, id DESC"; +pub const Q004: &str = "SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map, protected, created_by FROM notes WHERE workspace_id = ? AND slug = ?"; +pub const Q005: &str = "INSERT INTO notes (workspace_id, slug, title, protected, created_by) VALUES (?, ?, ?, ?, ?)"; pub const Q006: &str = "UPDATE notes SET content = ?, owner_map = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"; pub const Q007: &str = "UPDATE workspaces SET updated_at = CURRENT_TIMESTAMP WHERE id = ?"; pub const Q008: &str = "INSERT INTO note_revisions (note_id, content, author, owner_map) VALUES (?, ?, ?, ?)"; @@ -31,6 +31,15 @@ pub const Q027: &str = "SELECT id FROM notes WHERE file_token = ?"; pub const Q028: &str = "SELECT content FROM note_revisions WHERE id = ? AND note_id = ?"; pub const Q029: &str = "SELECT content FROM revisions WHERE id = ? AND pad_id = ?"; pub const Q030: &str = "SELECT owner_map FROM revisions WHERE id = ? AND pad_id = ?"; +pub const Q031: &str = "DELETE FROM notes WHERE id = ?"; +pub const Q032: &str = "INSERT INTO note_files (note_id, filename, url, mime_type, size_bytes) VALUES (?, ?, ?, ?, ?)"; +pub const Q033: &str = "SELECT id, filename, url, mime_type, size_bytes, created_at, is_attached, detached_at FROM note_files WHERE note_id = ? ORDER BY id DESC"; +pub const Q034: &str = "UPDATE note_files SET is_attached = ?, detached_at = ? WHERE id = ?"; +pub const Q035: &str = "INSERT INTO pad_files (pad_id, filename, url, mime_type, size_bytes) VALUES (?, ?, ?, ?, ?)"; +pub const Q036: &str = "SELECT id, filename, url, mime_type, size_bytes, created_at, is_attached, detached_at FROM pad_files WHERE pad_id = ? ORDER BY id DESC"; +pub const Q037: &str = "UPDATE pad_files SET is_attached = ?, detached_at = ? WHERE id = ?"; +pub const Q038: &str = "SELECT id, filename, url, mime_type, size_bytes, created_at, is_attached, detached_at FROM note_files WHERE id = ? AND note_id = ?"; +pub const Q039: &str = "DELETE FROM note_files WHERE id = ? AND note_id = ?"; static POSTGRES_QUERIES: OnceLock>> = OnceLock::new(); diff --git a/static/js/image-upload.js b/static/js/image-upload.js index 2453ab9..a8c1bdd 100644 --- a/static/js/image-upload.js +++ b/static/js/image-upload.js @@ -16,7 +16,7 @@ export async function prepareImageFile(file){
- +
`; diff --git a/static/js/note.js b/static/js/note.js index e8327e2..e9e4df2 100644 --- a/static/js/note.js +++ b/static/js/note.js @@ -44,15 +44,48 @@ function render(){if(uiState.mode==="markdown"){preview.classList.remove("previe function applyUi({write=false,replace=false}={}){editorWorkspace.className=`workspace view-${uiState.view} editor-workspace-font-${fontFamily.value}`;editorWorkspace.style.setProperty("--editor-font-size",`${fontSize.value}px`);document.body.classList.toggle("compact-editor",compactToggle.checked);document.querySelectorAll("[data-view]").forEach(b=>{const a=b.dataset.view===uiState.view;b.classList.toggle("active",a);b.setAttribute("aria-pressed",String(a));});const markdown=uiState.mode==="markdown";modeToggle.classList.toggle("active",markdown);modeToggle.textContent=markdown?"Markdown":"Text";render();if(write)writeEditorState(uiState,{replace});updateAddressLabel();} function applyRemote(content,ownerMap){if(content===editor.value&&ownerMap==null)return;const start=editor.selectionStart,end=editor.selectionEnd;applyingRemote=true;editor.value=content;try{owners=JSON.parse(ownerMap||"[]");}catch{owners=[];}editor.setSelectionRange(Math.min(start,content.length),Math.min(end,content.length));applyingRemote=false;render();} function connect(){socket?.stop();socket=new NoteSocket({workspaceSlug,noteSlug,password,nickname,onStatus:s=>setStatus(s==="online"?"online":s==="offline"?"offline":null,s==="online"?"Connected":s==="offline"?"Reconnecting…":"Connecting…"),onAuthenticated:m=>{if(passwordDialog.open)passwordDialog.close();applyRemote(m.content,m.owner_map);editor.focus();},onDocument:m=>{applyRemote(m.content,m.owner_map);document.querySelector("#save-state").textContent=`${m.author?`${m.author} · `:""}${new Date(m.updated_at).toLocaleTimeString("en-US",{hour:"2-digit",minute:"2-digit"})}`;},onError:m=>{document.querySelector("#password-error").textContent=m;if(info?.protected&&!passwordDialog.open)passwordDialog.showModal();}});socket.connect();} -async function initialize(){try{info=await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}`);document.querySelector("#back-workspace").href=`/w/${encodeURIComponent(workspaceSlug)}`;document.title=`${info.title} · ${info.workspace_title}`;applyUi({write:true,replace:true});if(!nickname){identityDialog.showModal();return;}document.querySelector("#current-user").textContent=nickname;document.querySelector("#current-user").style.setProperty("--owner",colorFor(nickname));if(info.protected&&!password)passwordDialog.showModal();else connect();}catch(e){document.body.innerHTML=`

Note not found

${escapeHtml(e.message)}

`;}} -document.querySelector("#identity-form").addEventListener("submit",e=>{e.preventDefault();nickname=document.querySelector("#nickname").value.trim();setNickname(nickname);identityDialog.close();document.querySelector("#current-user").textContent=nickname;document.querySelector("#current-user").style.setProperty("--owner",colorFor(nickname));if(info.protected&&!password)passwordDialog.showModal();else connect();}); + +function formatBytes(bytes){const value=Number(bytes)||0;if(value<1024)return `${value} B`;if(value<1024*1024)return `${(value/1024).toFixed(1)} KB`;return `${(value/1024/1024).toFixed(1)} MB`;} +async function loadFiles({open=false}={}){ + try{ + const files=await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/files`,{method:"PUT",body:JSON.stringify({password:password||null})}); + document.querySelector("#footer-files").textContent=`${files.length} ${files.length===1?"file":"files"}`; + const list=document.querySelector("#files-list"); + list.innerHTML=files.length?files.map(file=>`
${escapeHtml(file.filename)}
${formatBytes(file.size_bytes)} · ${escapeHtml(file.mime_type)} · ${file.is_attached?"in note":"removed from content"}
${info?.protected&&password?``:""}
`).join(""):'

No files uploaded.

'; + if(open&&!document.querySelector("#files-dialog").open)document.querySelector("#files-dialog").showModal(); + }catch(error){toast(error.message);} +} +async function initialize(){try{info=await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}`);document.querySelector("#back-workspace").href=`/w/${encodeURIComponent(workspaceSlug)}`;document.title=`${info.title} · ${info.workspace_title}`;applyUi({write:true,replace:true});if(!nickname){identityDialog.showModal();return;}document.querySelector("#current-user").textContent=nickname;document.querySelector("#current-user").style.setProperty("--owner",colorFor(nickname));document.querySelector("#delete-note").hidden=info.note_protected;if(info.protected&&!password)passwordDialog.showModal();else{loadFiles();connect();}}catch(e){document.body.innerHTML=`

Note not found

${escapeHtml(e.message)}

`;}} +document.querySelector("#identity-form").addEventListener("submit",e=>{e.preventDefault();nickname=document.querySelector("#nickname").value.trim();setNickname(nickname);identityDialog.close();document.querySelector("#current-user").textContent=nickname;document.querySelector("#current-user").style.setProperty("--owner",colorFor(nickname));document.querySelector("#delete-note").hidden=info.note_protected;if(info.protected&&!password)passwordDialog.showModal();else{loadFiles();connect();}}); document.querySelectorAll("[data-view]").forEach(b=>b.addEventListener("click",()=>{uiState={...uiState,view:b.dataset.view};applyUi({write:true});}));modeToggle.addEventListener("click",()=>{uiState={...uiState,mode:uiState.mode==="markdown"?"text":"markdown"};applyUi({write:true});});lineToggle.addEventListener("change",()=>{localStorage.setItem("rustpad:line-numbers",lineToggle.checked?"on":"off");renderGutter();});compactToggle.addEventListener("change",()=>{localStorage.setItem("rustpad:compact",compactToggle.checked?"on":"off");applyUi();});fontFamily.addEventListener("change",()=>{localStorage.setItem("rustpad:font-family",fontFamily.value);applyUi();});fontSize.addEventListener("change",()=>{localStorage.setItem("rustpad:font-size",fontSize.value);applyUi();}); window.addEventListener("popstate",()=>{uiState=readEditorState();applyUi();});window.addEventListener("rustpad:urlchange",updateAddressLabel);document.querySelector("#copy-link").addEventListener("click",async()=>{try{await copyText(currentShareUrl(uiState));toast("Link copied");}catch(e){toast(e.message);}});document.querySelectorAll("[data-format]").forEach(b=>b.addEventListener("click",()=>applyFormat(editor,b.dataset.format))); document.querySelector("#publish-page").addEventListener("click",async()=>{try{const result=await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/publish`,{method:"POST",body:JSON.stringify({password:password||null})});const url=new URL(result.url,location.origin).href;await copyText(url);toast("Page link copied");window.open(url,"_blank","noopener");}catch(error){toast(error.message);}}); editor.addEventListener("scroll",()=>{gutter.scrollTop=editor.scrollTop;renderGutter();});editor.addEventListener("input",()=>{const newLines=editor.value.split("\n").length;const cursorLine=editor.value.slice(0,editor.selectionStart).split("\n").length-1;while(owners.lengthsocket?.update(editor.value,JSON.stringify(owners)),250);}); -document.querySelector("#password-form").addEventListener("submit",e=>{e.preventDefault();password=document.querySelector("#open-password").value;setPassword(workspaceSlug,password);document.querySelector("#password-error").textContent="";connect();}); +document.querySelector("#password-form").addEventListener("submit",e=>{e.preventDefault();password=document.querySelector("#open-password").value;setPassword(workspaceSlug,password);document.querySelector("#password-error").textContent="";loadFiles();connect();}); const historyPanel=document.querySelector("#history-panel");document.querySelector("#history-button").addEventListener("click",async()=>{historyPanel.classList.add("open");historyPanel.setAttribute("aria-hidden","false");document.body.classList.add("history-open");const list=document.querySelector("#history-list");list.innerHTML='

Loading…

';try{const revisions=await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/history`,{method:"POST",body:JSON.stringify({password:password||null})});list.innerHTML=revisions.length?revisions.map((r,i)=>{const snippet=escapeHtml(r.content.trim().split("\n").slice(0,3).join(" · ").slice(0,150)||"Empty note");const author=r.author||"Unknown author";return `
${escapeHtml(author)}

${snippet}

`;}).join(""):'

No history yet.

';for(const r of revisions){list.querySelector(`[data-preview="${r.id}"]`)?.addEventListener("click",()=>{const el=list.querySelector(`#preview-${r.id}`);el.hidden=!el.hidden;el.textContent=r.content;});list.querySelector(`[data-revision="${r.id}"]`)?.addEventListener("click",async()=>{await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/restore`,{method:"POST",body:JSON.stringify({password:password||null,revision_id:r.id})});toast("Version restored");});}}catch(e){list.innerHTML=`

${escapeHtml(e.message)}

`;}});document.querySelector("#close-history").addEventListener("click",()=>{historyPanel.classList.remove("open");historyPanel.setAttribute("aria-hidden","true");document.body.classList.remove("history-open");}); -document.querySelector("#upload-button").addEventListener("click",()=>document.querySelector("#file-input").click());document.querySelector("#file-input").addEventListener("change",async e=>{let file=e.target.files[0];if(!file)return;if(file.type.startsWith("image/")){file=await prepareImageFile(file);if(!file){e.target.value="";return;}}const form=new FormData();form.append("password",password||"");form.append("file",file);try{const result=await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/files`,{method:"POST",body:form,headers:{}});const image=file.type.startsWith("image/");const text=image?`![${file.name}](${result.url})`:`[${file.name}](${result.url})`;editor.setRangeText(text,editor.selectionStart,editor.selectionEnd,"end");editor.dispatchEvent(new Event("input"));toast("File uploaded");}catch(err){toast(err.message);}e.target.value="";}); +document.querySelector("#upload-button").addEventListener("click",()=>document.querySelector("#file-input").click());document.querySelector("#file-input").addEventListener("change",async e=>{let file=e.target.files[0];if(!file)return;if(file.type.startsWith("image/")){file=await prepareImageFile(file);if(!file){e.target.value="";return;}}const form=new FormData();form.append("password",password||"");form.append("file",file);try{const result=await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/files`,{method:"POST",body:form,headers:{}});const image=file.type.startsWith("image/");const text=image?`![${file.name}](${result.url})`:`[${file.name}](${result.url})`;editor.setRangeText(text,editor.selectionStart,editor.selectionEnd,"end");editor.dispatchEvent(new Event("input"));toast("File uploaded");loadFiles();}catch(err){toast(err.message);}e.target.value="";}); +document.querySelector("#files-button").addEventListener("click",()=>loadFiles({open:true})); +document.querySelector("#footer-files").addEventListener("click",()=>loadFiles({open:true})); +document.querySelector("#close-files").addEventListener("click",()=>document.querySelector("#files-dialog").close()); +document.querySelector("#files-list").addEventListener("click",async event=>{ + const showButton=event.target.closest("[data-show-file-code]"); + if(showButton){ + const row=showButton.closest(".file-row"), panel=row.querySelector(".file-code"), output=panel.querySelector("textarea"); + const absolute=new URL(showButton.dataset.url,location.origin).href; + let text=absolute; + if(showButton.dataset.showFileCode==="markdown")text=showButton.dataset.mime?.startsWith("image/")?`![${showButton.dataset.name}](${absolute})`:`[${showButton.dataset.name}](${absolute})`; + if(showButton.dataset.showFileCode==="html")text=(showButton.dataset.mime||"").startsWith("image/")?`${showButton.dataset.name}`:`${showButton.dataset.name}`; + output.value=text;panel.hidden=false;output.focus();output.select();return; + } + const copyButton=event.target.closest("[data-copy-generated]"); + if(copyButton){try{await copyText(copyButton.closest(".file-code").querySelector("textarea").value);toast("Copied");}catch(error){toast(error.message);}return;} + const deleteButton=event.target.closest("[data-delete-file]"); + if(deleteButton){ + if(!confirm(`Delete file "${deleteButton.dataset.fileName}" permanently?`))return; + try{await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/files/${encodeURIComponent(deleteButton.dataset.deleteFile)}`,{method:"DELETE",body:JSON.stringify({password:password||null})});toast("File deleted");await loadFiles();}catch(error){toast(error.message);}return; + } +}); +document.querySelector("#delete-note").addEventListener("click",async()=>{if(!confirm(`Delete note “${info.title}”? This cannot be undone.`))return;try{await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}`,{method:"DELETE",body:JSON.stringify({password:password||null})});location.assign(`/w/${encodeURIComponent(workspaceSlug)}`);}catch(error){toast(error.message);}}); window.addEventListener("error",event=>{setStatus("offline","Application error");console.error(event.error||event.message);}); window.addEventListener("unhandledrejection",event=>{setStatus("offline","Application error");console.error(event.reason);}); initialize(); diff --git a/static/js/pad.js b/static/js/pad.js index 1cd587e..780c8c7 100644 --- a/static/js/pad.js +++ b/static/js/pad.js @@ -43,14 +43,40 @@ function escapeHtml(v){return String(v).replace(/[&<>"']/g,c=>({"&":"&","<": function render(){if(uiState.mode==="markdown"){preview.classList.remove("preview--raw");preview.innerHTML=renderMarkdown(editor.value);document.querySelector("#preview-label").textContent="Markdown + Mermaid preview";renderMermaid();}else{preview.classList.add("preview--raw");preview.textContent=editor.value;document.querySelector("#preview-label").textContent="Source text";}document.querySelector("#characters").textContent=`${editor.value.length} characters`;document.querySelector("#words").textContent=`${editor.value.trim()?editor.value.trim().split(/\s+/).length:0} words`;renderGutter();} function applyUi({write=false,replace=false}={}){editorWorkspace.className=`workspace view-${uiState.view} editor-workspace-font-${fontFamily.value}`;editorWorkspace.style.setProperty("--editor-font-size",`${fontSize.value}px`);document.body.classList.toggle("compact-editor",compactToggle.checked);document.querySelectorAll("[data-view]").forEach(b=>{const a=b.dataset.view===uiState.view;b.classList.toggle("active",a);b.setAttribute("aria-pressed",String(a));});const markdown=uiState.mode==="markdown";modeToggle.classList.toggle("active",markdown);modeToggle.textContent=markdown?"Markdown":"Text";render();if(write)writeEditorState(uiState,{replace});updateAddressLabel();} function applyRemote(content,ownerMap){if(content===editor.value&&ownerMap==null)return;const start=editor.selectionStart,end=editor.selectionEnd;applyingRemote=true;editor.value=content;try{owners=JSON.parse(ownerMap||"[]");}catch{owners=[];}editor.setSelectionRange(Math.min(start,content.length),Math.min(end,content.length));applyingRemote=false;render();} + +async function loadFiles({open=false}={}){ + try{ + const files=await api(`/api/pads/${encodeURIComponent(slug)}/files`,{method:"PUT",body:JSON.stringify({password:password||null})}); + document.querySelector("#footer-files").textContent=`${files.length} ${files.length===1?"file":"files"}`; + document.querySelector("#files-list").innerHTML=files.length?files.map(file=>`
${escapeHtml(file.filename)}
${escapeHtml(file.mime_type)} · ${Math.max(1,Math.round(file.size_bytes/1024))} KB · ${formatDate(file.created_at)} · ${file.is_attached?"in note":"removed from content"}
`).join(""):'

No files uploaded.

'; + if(open)document.querySelector("#files-dialog").showModal(); + }catch(error){if(open)toast(error.message);} +} function connect(){socket?.stop();socket=new PadSocket({slug,password,nickname,onStatus:s=>setStatus(s==="online"?"online":s==="offline"?"offline":null,s==="online"?"Connected":s==="offline"?"Reconnecting…":"Connecting…"),onAuthenticated:m=>{if(passwordDialog.open)passwordDialog.close();applyRemote(m.content,m.owner_map);editor.focus();},onDocument:m=>{applyRemote(m.content,m.owner_map);document.querySelector("#save-state").textContent=`${m.author?`${m.author} · `:""}${new Date(m.updated_at).toLocaleTimeString("en-US",{hour:"2-digit",minute:"2-digit"})}`;},onError:m=>{document.querySelector("#password-error").textContent=m;if(info?.protected&&!passwordDialog.open)passwordDialog.showModal();}});socket.connect();} -async function initialize(){try{info=await api(`/api/pads/${encodeURIComponent(slug)}`);document.title=`${info.title} · RustPad`;applyUi({write:true,replace:true});if(!nickname){identityDialog.showModal();return;}document.querySelector("#current-user").textContent=nickname;document.querySelector("#current-user").style.setProperty("--owner",colorFor(nickname));if(info.protected&&!password)passwordDialog.showModal();else connect();}catch(e){document.body.innerHTML=`

Note not found

${escapeHtml(e.message)}

`;}} -document.querySelector("#identity-form").addEventListener("submit",e=>{e.preventDefault();nickname=document.querySelector("#nickname").value.trim();setNickname(nickname);identityDialog.close();document.querySelector("#current-user").textContent=nickname;document.querySelector("#current-user").style.setProperty("--owner",colorFor(nickname));if(info.protected&&!password)passwordDialog.showModal();else connect();}); +async function initialize(){try{info=await api(`/api/pads/${encodeURIComponent(slug)}`);document.title=`${info.title} · RustPad`;applyUi({write:true,replace:true});if(!nickname){identityDialog.showModal();return;}document.querySelector("#current-user").textContent=nickname;document.querySelector("#current-user").style.setProperty("--owner",colorFor(nickname));if(info.protected&&!password)passwordDialog.showModal();else{loadFiles();connect();}}catch(e){document.body.innerHTML=`

Note not found

${escapeHtml(e.message)}

`;}} +document.querySelector("#identity-form").addEventListener("submit",e=>{e.preventDefault();nickname=document.querySelector("#nickname").value.trim();setNickname(nickname);identityDialog.close();document.querySelector("#current-user").textContent=nickname;document.querySelector("#current-user").style.setProperty("--owner",colorFor(nickname));if(info.protected&&!password)passwordDialog.showModal();else{loadFiles();connect();}}); document.querySelectorAll("[data-view]").forEach(b=>b.addEventListener("click",()=>{uiState={...uiState,view:b.dataset.view};applyUi({write:true});}));modeToggle.addEventListener("click",()=>{uiState={...uiState,mode:uiState.mode==="markdown"?"text":"markdown"};applyUi({write:true});});lineToggle.addEventListener("change",()=>{localStorage.setItem("rustpad:line-numbers",lineToggle.checked?"on":"off");renderGutter();});compactToggle.addEventListener("change",()=>{localStorage.setItem("rustpad:compact",compactToggle.checked?"on":"off");applyUi();});fontFamily.addEventListener("change",()=>{localStorage.setItem("rustpad:font-family",fontFamily.value);applyUi();});fontSize.addEventListener("change",()=>{localStorage.setItem("rustpad:font-size",fontSize.value);applyUi();}); window.addEventListener("popstate",()=>{uiState=readEditorState();applyUi();});window.addEventListener("rustpad:urlchange",updateAddressLabel);document.querySelector("#copy-link").addEventListener("click",async()=>{try{await copyText(currentShareUrl(uiState));toast("Link copied");}catch(e){toast(e.message);}});document.querySelectorAll("[data-format]").forEach(b=>b.addEventListener("click",()=>applyFormat(editor,b.dataset.format))); document.querySelector("#publish-page").addEventListener("click",async()=>{try{const result=await api(`/api/pads/${encodeURIComponent(slug)}/publish`,{method:"POST",body:JSON.stringify({password:password||null})});const url=new URL(result.url,location.origin).href;await copyText(url);toast("Page link copied");window.open(url,"_blank","noopener");}catch(error){toast(error.message);}}); editor.addEventListener("scroll",()=>{gutter.scrollTop=editor.scrollTop;renderGutter();});editor.addEventListener("input",()=>{const newLines=editor.value.split("\n").length;const cursorLine=editor.value.slice(0,editor.selectionStart).split("\n").length-1;while(owners.lengthsocket?.update(editor.value,JSON.stringify(owners)),250);}); document.querySelector("#password-form").addEventListener("submit",e=>{e.preventDefault();password=document.querySelector("#open-password").value;sessionStorage.setItem(`rustpad:pad:${slug}:password`,password);document.querySelector("#password-error").textContent="";connect();}); const historyPanel=document.querySelector("#history-panel");document.querySelector("#history-button").addEventListener("click",async()=>{historyPanel.classList.add("open");historyPanel.setAttribute("aria-hidden","false");document.body.classList.add("history-open");const list=document.querySelector("#history-list");list.innerHTML='

Loading…

';try{const revisions=await api(`/api/pads/${encodeURIComponent(slug)}/history`,{method:"POST",body:JSON.stringify({password:password||null})});list.innerHTML=revisions.length?revisions.map((r,i)=>{const snippet=escapeHtml(r.content.trim().split("\n").slice(0,3).join(" · ").slice(0,150)||"Empty note");const author=r.author||"Unknown author";return `
${escapeHtml(author)}

${snippet}

`;}).join(""):'

No history yet.

';for(const r of revisions){list.querySelector(`[data-preview="${r.id}"]`)?.addEventListener("click",()=>{const el=list.querySelector(`#preview-${r.id}`);el.hidden=!el.hidden;el.textContent=r.content;});list.querySelector(`[data-revision="${r.id}"]`)?.addEventListener("click",async()=>{await api(`/api/pads/${encodeURIComponent(slug)}/restore`,{method:"POST",body:JSON.stringify({password:password||null,revision_id:r.id})});toast("Version restored");});}}catch(e){list.innerHTML=`

${escapeHtml(e.message)}

`;}});document.querySelector("#close-history").addEventListener("click",()=>{historyPanel.classList.remove("open");historyPanel.setAttribute("aria-hidden","true");document.body.classList.remove("history-open");}); -document.querySelector("#upload-button").addEventListener("click",()=>document.querySelector("#file-input").click());document.querySelector("#file-input").addEventListener("change",async e=>{let file=e.target.files[0];if(!file)return;if(file.type.startsWith("image/")){file=await prepareImageFile(file);if(!file){e.target.value="";return;}}const form=new FormData();form.append("password",password||"");form.append("file",file);try{const result=await api(`/api/pads/${encodeURIComponent(slug)}/files`,{method:"POST",body:form,headers:{}});const image=file.type.startsWith("image/");const text=image?`![${file.name}](${result.url})`:`[${file.name}](${result.url})`;editor.setRangeText(text,editor.selectionStart,editor.selectionEnd,"end");editor.dispatchEvent(new Event("input"));toast("File uploaded");}catch(err){toast(err.message);}e.target.value="";}); +document.querySelector("#upload-button").addEventListener("click",()=>document.querySelector("#file-input").click());document.querySelector("#file-input").addEventListener("change",async e=>{let file=e.target.files[0];if(!file)return;if(file.type.startsWith("image/")){file=await prepareImageFile(file);if(!file){e.target.value="";return;}}const form=new FormData();form.append("password",password||"");form.append("file",file);try{const result=await api(`/api/pads/${encodeURIComponent(slug)}/files`,{method:"POST",body:form,headers:{}});const image=file.type.startsWith("image/");const text=image?`![${file.name}](${result.url})`:`[${file.name}](${result.url})`;editor.setRangeText(text,editor.selectionStart,editor.selectionEnd,"end");editor.dispatchEvent(new Event("input"));toast("File uploaded");loadFiles();}catch(err){toast(err.message);}e.target.value="";}); + +document.querySelector("#files-button").addEventListener("click",()=>loadFiles({open:true})); +document.querySelector("#footer-files").addEventListener("click",()=>loadFiles({open:true})); +document.querySelector("#close-files").addEventListener("click",()=>document.querySelector("#files-dialog").close()); +document.querySelector("#files-list").addEventListener("click",async event=>{ + const showButton=event.target.closest("[data-show-file-code]"); + if(showButton){ + const row=showButton.closest(".file-row"), panel=row.querySelector(".file-code"), output=panel.querySelector("textarea"); + const absolute=new URL(showButton.dataset.url,location.origin).href; + let text=absolute; + if(showButton.dataset.showFileCode==="markdown")text=showButton.dataset.mime?.startsWith("image/")?`![${showButton.dataset.name}](${absolute})`:`[${showButton.dataset.name}](${absolute})`; + if(showButton.dataset.showFileCode==="html")text=(showButton.dataset.mime||"").startsWith("image/")?`${showButton.dataset.name}`:`${showButton.dataset.name}`; + output.value=text;panel.hidden=false;output.focus();output.select();return; + } + const copyButton=event.target.closest("[data-copy-generated]"); + if(copyButton){try{await copyText(copyButton.closest(".file-code").querySelector("textarea").value);toast("Copied");}catch(error){toast(error.message);}return;} +}); initialize(); diff --git a/static/js/workspace.js b/static/js/workspace.js index 0511080..52707ec 100644 --- a/static/js/workspace.js +++ b/static/js/workspace.js @@ -1,12 +1,139 @@ -import { api } from "@rustpad/api"; import { copyText } from "@rustpad/clipboard"; import { getPassword, setPassword } from "@rustpad/session"; -const parts = location.pathname.split("/").filter(Boolean), slug = parts[1]; let info, password = getPassword(slug); const dialog = document.querySelector("#password-dialog"), notesList = document.querySelector("#notes-list"); -function toast(text) { const el = document.querySelector("#toast"); el.textContent = text; el.classList.add("visible"); setTimeout(() => el.classList.remove("visible"), 1600); } -function renderNotes(notes) { notesList.innerHTML = notes.length ? notes.map(note => `

${escapeHtml(note.title)}

Updated: ${formatDate(note.updated_at)}

`).join("") : '

No notes yet.

'; } +import { api } from "@rustpad/api"; +import { copyText } from "@rustpad/clipboard"; +import { getNickname, getPassword, setPassword } from "@rustpad/session"; + +const parts = location.pathname.split("/").filter(Boolean); +const slug = parts[1]; +let info; +let password = getPassword(slug); +const dialog = document.querySelector("#password-dialog"); +const notesList = document.querySelector("#notes-list"); +const notesViewKey = `rustpad:workspace:${slug}:notes-view`; +let notesView = localStorage.getItem(notesViewKey) === "table" ? "table" : "grid"; +let notesCache = []; + +function toast(text) { + const el = document.querySelector("#toast"); + el.textContent = text; + el.classList.add("visible"); + setTimeout(() => el.classList.remove("visible"), 1600); +} function escapeHtml(v) { const e = document.createElement("div"); e.textContent = v; return e.innerHTML; } -function formatDate(value) { if (value == null || value === "") return "—"; let raw = String(value).trim(); if (/^\d+$/.test(raw)) { const number = Number(raw); raw = raw.length <= 10 ? number * 1000 : number; } else if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?$/.test(raw)) { raw = raw.replace(" ", "T") + "Z"; } const date = new Date(raw); return Number.isNaN(date.getTime()) ? "—" : date.toLocaleString("en-US"); } -async function openWorkspace() { try { const data = await api(`/api/workspaces/${encodeURIComponent(slug)}/open`, { method: "POST", body: JSON.stringify({ password: password || null }) }); info = data.workspace; document.querySelector("#workspace-title").textContent = info.title; document.querySelector("#workspace-url").textContent = location.pathname; document.title = `${info.title} · RustPad`; renderNotes(data.notes); if (dialog.open) dialog.close(); } catch (e) { if (info?.protected || e.message.toLowerCase().includes("password")) { document.querySelector("#password-error").textContent = e.message; if (!dialog.open) dialog.showModal(); } else document.querySelector("#workspace-error").textContent = e.message; } } -async function init() { try { info = await api(`/api/workspaces/${encodeURIComponent(slug)}`); document.querySelector("#workspace-title").textContent = info.title; document.querySelector("#workspace-url").textContent = location.pathname; if (info.protected && !password) dialog.showModal(); else openWorkspace(); } catch (e) { document.querySelector("#workspace-error").textContent = e.message; } } -document.querySelector("#password-form").addEventListener("submit", e => { e.preventDefault(); password = document.querySelector("#open-password").value; setPassword(slug, password); openWorkspace(); }); -document.querySelector("#new-note-button").addEventListener("click", () => document.querySelector("#note-dialog").showModal()); document.querySelector("#cancel-note").addEventListener("click", () => document.querySelector("#note-dialog").close()); -document.querySelector("#note-form").addEventListener("submit", async e => { e.preventDefault(); const error = document.querySelector("#note-error"); error.textContent = ""; try { const note = await api(`/api/workspaces/${encodeURIComponent(slug)}/notes`, { method: "POST", body: JSON.stringify({ name: document.querySelector("#note-name").value, password: password || null }) }); location.assign(`${note.url}?view=split&mode=markdown`); } catch (err) { error.textContent = err.message; } }); -document.querySelector("#copy-workspace-link").addEventListener("click", async () => { try { await copyText(new URL(location.pathname, location.origin).href); toast("Link copied"); } catch (e) { toast(e.message); } }); init(); +function formatDate(value) { + if (value == null || value === "") return "—"; + let raw = String(value).trim(); + if (/^\d+$/.test(raw)) { const number = Number(raw); raw = raw.length <= 10 ? number * 1000 : number; } + else if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?$/.test(raw)) raw = raw.replace(" ", "T") + "Z"; + const date = new Date(raw); + return Number.isNaN(date.getTime()) ? "—" : date.toLocaleString("pl-PL"); +} +function setNotesView(view) { + notesView = view === "table" ? "table" : "grid"; + localStorage.setItem(notesViewKey, notesView); + notesList.classList.toggle("notes-grid", notesView === "grid"); + notesList.classList.toggle("notes-table", notesView === "table"); + document.querySelectorAll("[data-notes-view]").forEach(button => { + const active = button.dataset.notesView === notesView; + button.classList.toggle("active", active); + button.setAttribute("aria-pressed", String(active)); + }); +} +function deleteButton(note, inline = false) { + const disabled = note.protected; + const classes = `note-delete-button${inline ? " note-delete-button--inline" : ""}`; + const reason = disabled ? "Protected notes cannot be deleted" : `Delete ${note.title}`; + return ``; +} +function renderNotes(notes = notesCache) { + notesCache = notes; + setNotesView(notesView); + if (!notes.length) { + notesList.innerHTML = '

No notes yet.

'; + return; + } + if (notesView === "table") { + notesList.innerHTML = `
${notes.map(note => ` + + + + + + + `).join("")}
NameCreated byStatusUpdatedActions
${escapeHtml(note.title)}${escapeHtml(note.created_by || "Unknown")}${note.protected ? 'Protected' : 'Editable'}${formatDate(note.updated_at)}${deleteButton(note, true)}
`; + return; + } + notesList.innerHTML = notes.map(note => ` + `).join(""); +} +async function openWorkspace() { + try { + const data = await api(`/api/workspaces/${encodeURIComponent(slug)}/open`, { method: "POST", body: JSON.stringify({ password: password || null }) }); + info = data.workspace; + document.querySelector("#workspace-title").textContent = info.title; + document.querySelector("#workspace-url").textContent = location.pathname; + document.title = `${info.title} · RustPad`; + notesCache = data.notes; + renderNotes(); + if (dialog.open) dialog.close(); + } catch (e) { + if (info?.protected || e.message.toLowerCase().includes("password")) { + document.querySelector("#password-error").textContent = e.message; + if (!dialog.open) dialog.showModal(); + } else document.querySelector("#workspace-error").textContent = e.message; + } +} +async function init() { + try { + info = await api(`/api/workspaces/${encodeURIComponent(slug)}`); + document.querySelector("#workspace-title").textContent = info.title; + document.querySelector("#workspace-url").textContent = location.pathname; + if (info.protected && !password) dialog.showModal(); else openWorkspace(); + } catch (e) { document.querySelector("#workspace-error").textContent = e.message; } +} +document.querySelector("#password-form").addEventListener("submit", e => { + e.preventDefault(); password = document.querySelector("#open-password").value; setPassword(slug, password); openWorkspace(); +}); +document.querySelector("#new-note-button").addEventListener("click", () => document.querySelector("#note-dialog").showModal()); +document.querySelector("#cancel-note").addEventListener("click", () => document.querySelector("#note-dialog").close()); +document.querySelector("#note-form").addEventListener("submit", async e => { + e.preventDefault(); + const error = document.querySelector("#note-error"); error.textContent = ""; + try { + const note = await api(`/api/workspaces/${encodeURIComponent(slug)}/notes`, { + method: "POST", + body: JSON.stringify({ name: document.querySelector("#note-name").value, password: password || null, protect: document.querySelector("#note-protect").checked, created_by: getNickname() || null }) + }); + location.assign(`${note.url}?view=split&mode=markdown`); + } catch (err) { error.textContent = err.message; } +}); +notesList.addEventListener("click", async event => { + const button = event.target.closest("[data-delete-note]"); + if (!button) return; + const title = button.dataset.noteTitle; + if (!confirm(`Delete note “${title}”? This cannot be undone.`)) return; + button.disabled = true; + try { + await api(`/api/workspaces/${encodeURIComponent(slug)}/notes/${encodeURIComponent(button.dataset.deleteNote)}`, { + method: "DELETE", body: JSON.stringify({ password: password || null }) + }); + toast("Note deleted"); + await openWorkspace(); + } catch (error) { toast(error.message); button.disabled = false; } +}); +document.querySelectorAll("[data-notes-view]").forEach(button => button.addEventListener("click", () => { + if (button.dataset.notesView === notesView) return; + notesView = button.dataset.notesView; + renderNotes(); +})); +document.querySelector("#copy-workspace-link").addEventListener("click", async () => { + try { await copyText(new URL(location.pathname, location.origin).href); toast("Link copied"); } + catch (e) { toast(e.message); } +}); +setNotesView(notesView); +init(); diff --git a/static/note.html b/static/note.html index d31ab02..e0d697c 100644 --- a/static/note.html +++ b/static/note.html @@ -1,5 +1,5 @@ __NOTE_TITLE__ · RustPad -
__WORKSPACE_TITLE__

__NOTE_TITLE__

Connecting…
-
Editor
Markdown preview
0 characters · 0 words
Changes are saved automatically
-

What should we call you?

Your name will be shown next to changes and remembered on this device.

+
__WORKSPACE_TITLE__

__NOTE_TITLE__

Connecting…
+
Editor
Markdown preview
0 characters · 0 words
· Changes are saved automatically
+

Note files

Copy a direct link or ready Markdown/HTML code.

What should we call you?

Your name will be shown next to changes and remembered on this device.

Protected workspace

Back
diff --git a/static/pad.html b/static/pad.html index 14e6b44..722bbc9 100644 --- a/static/pad.html +++ b/static/pad.html @@ -1,5 +1,5 @@ __PAD_TITLE__ · RustPad -
RustPad

__PAD_TITLE__

Connecting…
-
Editor
Markdown preview
0 characters · 0 words
Changes are saved automatically
-

What should we call you?

Your name will be shown next to changes and remembered on this device.

+
RustPad

__PAD_TITLE__

Connecting…
+
Editor
Markdown preview
0 characters · 0 words
· Changes are saved automatically
+

Note files

Copy a direct link or ready Markdown/HTML code.

What should we call you?

Your name will be shown next to changes and remembered on this device.

Protected note

Back
diff --git a/static/styles.css b/static/styles.css index a75ea27..613bbc5 100644 --- a/static/styles.css +++ b/static/styles.css @@ -319,3 +319,88 @@ dialog::backdrop { background: rgba(4,6,9,.82); } .public-header { padding-inline: max(20px, calc((100vw - 1180px) / 2)); } .public-document { width: min(1180px, calc(100% - 32px)); } .public-content { padding: clamp(24px, 4vw, 52px); } + +/* Preview and attachment management */ +.preview { overscroll-behavior: contain; scrollbar-gutter: stable; } +.markdown-body img { display: block; width: auto; max-width: 100%; height: auto; object-fit: contain; border-radius: 8px; } +.workspace.view-preview .markdown-body img { max-height: calc(100vh - 210px); } +.note-card-wrap { position: relative; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); overflow: hidden; } +.note-card-wrap .note-card { border: 0; border-radius: 0; } +.note-card-title { display: flex; align-items: center; justify-content: space-between; gap: 10px; } +.protect-badge, .file-flag { display: inline-flex; padding: 3px 7px; border: 1px solid var(--border-strong); border-radius: 999px; color: var(--muted); font-size: .68rem; white-space: nowrap; } +.file-flag.detached { border-color: rgba(255,123,145,.45); color: var(--danger); } +.note-delete-button { width: 100%; min-height: 36px; border: 0; border-top: 1px solid var(--border); background: transparent; color: var(--danger); } +.danger-button { color: var(--danger); } +.dialog-check { display: flex; align-items: center; gap: 9px; color: var(--muted); font-size: .82rem; } +.dialog-check input { width: auto; min-height: auto; } +.footer-link { border: 0; background: transparent; color: var(--muted); padding: 0; font-size: inherit; text-decoration: underline; text-underline-offset: 2px; } +.files-dialog { overflow: hidden; } +.files-panel { grid-template-rows: auto minmax(0, 1fr); min-height: min(590px, calc(100vh - 28px)); max-height: calc(100vh - 28px); } +.files-list { min-height: 0; overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; } +.files-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; } +.files-head h2, .files-head p { margin: 0; } +.files-head p { margin-top: 5px; color: var(--muted); font-size: .8rem; } +.files-list { display: grid; gap: 9px; overflow: auto; min-height: 80px; } +.file-row { display: grid; grid-template-columns: minmax(0,1fr) auto; gap: 12px; padding: 12px; border: 1px solid var(--border); border-radius: 9px; background: #0e1116; } +.file-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 650; } +.file-meta { margin-top: 4px; color: var(--muted-2); font-size: .72rem; } +.file-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 6px; } +.file-actions button { min-height: 30px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface-2); color: var(--text); padding: 0 8px; font-size: .72rem; } +@media (max-width: 620px) { .file-row { grid-template-columns: 1fr; } .file-actions { justify-content: flex-start; } } + + +/* Attachment modal layout fixes. */ +.files-panel { min-height: 0; height: min(590px, calc(100vh - 28px)); } +.files-list { align-content: start; } +.file-row { align-items: start; } +.file-row-main { min-width: 0; } +.file-actions { align-items: center; align-self: start; flex-wrap: nowrap; } +.file-actions button { min-height: 34px; height: 34px; white-space: nowrap; } +.file-code { grid-column: 1 / -1; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: stretch; } +.file-code[hidden] { display: none; } +.file-code textarea { width: 100%; min-height: 76px; max-height: 150px; resize: vertical; box-sizing: border-box; font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } +.file-code button { align-self: stretch; min-width: 74px; } +.file-delete { color: var(--danger) !important; border-color: color-mix(in srgb, var(--danger) 45%, var(--border)) !important; } +@media (max-width: 760px) { + .file-actions { flex-wrap: wrap; justify-content: flex-start; } + .file-code { grid-template-columns: 1fr; } +} + + +/* Workspace notes: resilient cards and optional table view. */ + +.notes-toolbar { display: flex; align-items: center; gap: 10px; margin-top: 18px; padding-bottom: 14px; border-bottom: 1px solid var(--border); } +.notes-toolbar__label { color: var(--muted); font-size: .82rem; font-weight: 600; } +.workspace-actions { display: flex; align-items: center; gap: 10px; } +.notes-view-switch { display: inline-flex; padding: 3px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); } +.notes-view-switch button { min-height: 32px; padding: 0 11px; border: 0; border-radius: 6px; background: transparent; color: var(--muted); } +.notes-view-switch button.active { background: var(--surface-3); color: var(--text); } +.note-card { display: block; min-width: 0; } +.note-card-title { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; min-width: 0; } +.note-card-title h3 { min-width: 0; overflow-wrap: anywhere; word-break: break-word; line-height: 1.4; } +.note-card p { overflow-wrap: anywhere; } +.notes-table { display: block; padding-top: 20px; } +.notes-table-scroll { width: 100%; overflow-x: auto; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); } +.notes-table table { width: 100%; min-width: 680px; border-collapse: collapse; } +.notes-table th, .notes-table td { padding: 13px 15px; border-bottom: 1px solid var(--border); text-align: left; vertical-align: middle; } +.notes-table th { color: var(--muted); font-size: .72rem; text-transform: uppercase; letter-spacing: .05em; } +.notes-table tbody tr:last-child td { border-bottom: 0; } +.notes-table tbody tr:hover { background: var(--surface-2); } +.note-table-link { display: block; max-width: 52ch; color: var(--text); font-weight: 650; text-decoration: none; overflow-wrap: anywhere; } +.note-table-link:hover { text-decoration: underline; } +.note-status { color: var(--muted); font-size: .75rem; } +.notes-table-actions { width: 1%; white-space: nowrap; text-align: right !important; } +.note-delete-button--inline { width: auto; min-height: 32px; padding: 0 10px; border: 1px solid var(--border); border-radius: 6px; } +@media (max-width: 700px) { + .workspace-top { align-items: flex-start; } + .workspace-actions { align-items: stretch; flex-direction: column-reverse; } + .notes-view-switch button { flex: 1; } +} + +/* Workspace note ownership and immediate view switching. */ +.note-card-meta { display: grid; gap: 5px; margin-top: 28px; color: var(--muted); font-size: .75rem; } +.note-card p { margin-top: 0; } +.note-author { color: var(--muted); overflow-wrap: anywhere; } +.note-delete-button:disabled { cursor: not-allowed; color: var(--muted-2); opacity: .55; } +.notes-view-switch button { cursor: pointer; } +.notes-view-switch button.active { cursor: default; } diff --git a/static/workspace.html b/static/workspace.html index 5522b18..ad2a048 100644 --- a/static/workspace.html +++ b/static/workspace.html @@ -1,5 +1,5 @@ __WORKSPACE_TITLE__ · RustPad
RustPad

__WORKSPACE_TITLE__

-

Notes

Select a note or create a new one.

+

Notes

Select a note or create a new one.

View

Protected workspace

Cancel
-

New note

+

New note