From 2274cf57c911ce6c53dd07357f37517f3c055ba4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Thu, 30 Jul 2026 00:12:48 +0200 Subject: [PATCH] new finctions and fixes --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 7 +- .../mysql/0022_user_editor_preferences.sql | 25 ++ .../0023_split_resource_editor_settings.sql | 40 ++ .../postgres/0022_user_editor_preferences.sql | 23 ++ .../0023_split_resource_editor_settings.sql | 40 ++ .../sqlite/0022_user_editor_preferences.sql | 26 ++ .../0023_split_resource_editor_settings.sql | 67 ++++ src/api/files.rs | 46 ++- src/api/mod.rs | 346 +++++++++++++----- src/api/pads_public.rs | 88 ++++- src/app/mod.rs | 7 +- src/app/pages.rs | 94 ++--- src/auth/mod.rs | 26 ++ src/cache.rs | 18 + src/db/editor_preferences.rs | 193 ++++++++++ src/db/mod.rs | 2 + src/main.rs | 1 + src/queries/mod.rs | 18 +- src/queries/mysql.rs | 34 +- src/queries/postgres.rs | 32 +- src/queries/sqlite.rs | 32 +- static/css/styles.css | 121 +++++- static/{pad.html => editor.html} | 58 ++- static/js/markdown.js | 29 ++ static/js/note-api.js | 4 +- static/js/note-editor.js | 183 +++++++-- static/js/note-files.js | 26 +- static/js/public.js | 4 +- static/note.html | 259 ------------- 31 files changed, 1348 insertions(+), 505 deletions(-) create mode 100644 migrations/mysql/0022_user_editor_preferences.sql create mode 100644 migrations/mysql/0023_split_resource_editor_settings.sql create mode 100644 migrations/postgres/0022_user_editor_preferences.sql create mode 100644 migrations/postgres/0023_split_resource_editor_settings.sql create mode 100644 migrations/sqlite/0022_user_editor_preferences.sql create mode 100644 migrations/sqlite/0023_split_resource_editor_settings.sql create mode 100644 src/cache.rs create mode 100644 src/db/editor_preferences.rs rename static/{pad.html => editor.html} (85%) delete mode 100644 static/note.html diff --git a/Cargo.lock b/Cargo.lock index 2ce0fb5..5b56c77 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2581,7 +2581,7 @@ dependencies = [ [[package]] name = "rustpad" -version = "0.2.3" +version = "0.2.4" dependencies = [ "argon2", "aws-config", diff --git a/Cargo.toml b/Cargo.toml index 564591b..fa41ea9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustpad" -version = "0.2.3" +version = "0.2.4" edition = "2024" rust-version = "1.94" description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" diff --git a/README.md b/README.md index c7325e1..c59112c 100644 --- a/README.md +++ b/README.md @@ -15,10 +15,11 @@ The script creates `data/db` and `data/files`, builds the project, and starts it - Real-time collaborative editing over WebSocket. - Nicknames stored in `localStorage`. - Change authors shown in history. -- Line numbering enabled by default, with a persistent toggle. +- Line numbering enabled by default, with per-account preferences stored separately for each note or pad. +- Signed-in users with read/write access can save personal compact view, line, font, size, authorship, and color preferences; resource-linked rows are removed with the note, pad, or account. - Owner color displayed next to each line. - Image and file uploads to `data/files/pads/_/` or `data/files/notes/_/`. -- Automatic Markdown link insertion after upload. +- Compact attachment aliases are inserted after upload: `[file=name.ext,label]` and `[image=name.ext,alt]`. The file dialog also provides standard Markdown for compatibility. - Markdown and Mermaid diagram rendering. - History with snippets, previews, and version restore. - Alert blocks: `success`, `info`, `warning`, and `danger`. @@ -150,6 +151,8 @@ RustPad supports two interchangeable attachment backends selected in `.env`: Public application URLs remain `/f/{token}/{filename}` for both backends. RustPad validates access and streams objects through the API, so the bucket does not need to be public and existing database records do not require migration. +Set `ASSET_CACHE_MAX_AGE_SECONDS=0` or `FILE_CACHE_MAX_AGE_SECONDS=0` to disable browser caching. RustPad then sends `Cache-Control: no-cache, no-store, must-revalidate`; positive values use `public, max-age=`. + For the optional Docker Garage service, configure the S3 variables shown in `.env.example`, use strong unique credentials, and run: ```sh diff --git a/migrations/mysql/0022_user_editor_preferences.sql b/migrations/mysql/0022_user_editor_preferences.sql new file mode 100644 index 0000000..a947117 --- /dev/null +++ b/migrations/mysql/0022_user_editor_preferences.sql @@ -0,0 +1,25 @@ +CREATE TABLE user_editor_preferences ( + user_id BIGINT NOT NULL, + pad_id BIGINT NULL, + note_id BIGINT NULL, + authorship_mode VARCHAR(16) NOT NULL DEFAULT 'simple', + colors_enabled BOOLEAN NOT NULL DEFAULT TRUE, + compact_view BOOLEAN NOT NULL DEFAULT TRUE, + editor_line_numbers BOOLEAN NOT NULL DEFAULT TRUE, + preview_line_numbers BOOLEAN NOT NULL DEFAULT FALSE, + line_links BOOLEAN NOT NULL DEFAULT FALSE, + font_family VARCHAR(16) NOT NULL DEFAULT 'mono', + font_size BIGINT NOT NULL DEFAULT 14, + updated_at VARCHAR(64) NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT fk_user_editor_preferences_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT fk_user_editor_preferences_pad FOREIGN KEY (pad_id) REFERENCES pads(id) ON DELETE CASCADE, + CONSTRAINT fk_user_editor_preferences_note FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE, + CONSTRAINT chk_user_editor_preferences_resource CHECK ((pad_id IS NOT NULL AND note_id IS NULL) OR (pad_id IS NULL AND note_id IS NOT NULL)), + UNIQUE KEY uq_user_editor_preferences_pad (user_id, pad_id), + UNIQUE KEY uq_user_editor_preferences_note (user_id, note_id), + INDEX idx_user_editor_preferences_user (user_id), + INDEX idx_user_editor_preferences_pad (pad_id), + INDEX idx_user_editor_preferences_note (note_id) +) ENGINE=InnoDB; + +DROP TABLE resource_editor_settings; diff --git a/migrations/mysql/0023_split_resource_editor_settings.sql b/migrations/mysql/0023_split_resource_editor_settings.sql new file mode 100644 index 0000000..e11985d --- /dev/null +++ b/migrations/mysql/0023_split_resource_editor_settings.sql @@ -0,0 +1,40 @@ +CREATE TABLE resource_editor_settings ( + resource_kind VARCHAR(32) NOT NULL, + resource_slug VARCHAR(512) NOT NULL, + authorship_mode VARCHAR(16) NOT NULL DEFAULT 'simple', + colors_enabled BOOLEAN NOT NULL DEFAULT TRUE, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (resource_kind, resource_slug) +); + +INSERT INTO resource_editor_settings ( + resource_kind, resource_slug, authorship_mode, colors_enabled, updated_at +) +SELECT 'pad', p.slug, preferences.authorship_mode, preferences.colors_enabled, + CURRENT_TIMESTAMP +FROM user_editor_preferences preferences +JOIN pads p ON p.id = preferences.pad_id +JOIN user_pads ownership + ON ownership.pad_id = p.id + AND ownership.user_id = preferences.user_id +WHERE preferences.pad_id IS NOT NULL +ON DUPLICATE KEY UPDATE resource_slug = VALUES(resource_slug); + +INSERT INTO resource_editor_settings ( + resource_kind, resource_slug, authorship_mode, colors_enabled, updated_at +) +SELECT 'note', CONCAT(workspace.slug, '/', note.slug), + preferences.authorship_mode, preferences.colors_enabled, + CURRENT_TIMESTAMP +FROM user_editor_preferences preferences +JOIN notes note ON note.id = preferences.note_id +JOIN workspaces workspace ON workspace.id = note.workspace_id +JOIN user_workspaces ownership + ON ownership.workspace_id = workspace.id + AND ownership.user_id = preferences.user_id +WHERE preferences.note_id IS NOT NULL +ON DUPLICATE KEY UPDATE resource_slug = VALUES(resource_slug); + +ALTER TABLE user_editor_preferences + DROP COLUMN authorship_mode, + DROP COLUMN colors_enabled; diff --git a/migrations/postgres/0022_user_editor_preferences.sql b/migrations/postgres/0022_user_editor_preferences.sql new file mode 100644 index 0000000..91480be --- /dev/null +++ b/migrations/postgres/0022_user_editor_preferences.sql @@ -0,0 +1,23 @@ +CREATE TABLE user_editor_preferences ( + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + pad_id BIGINT REFERENCES pads(id) ON DELETE CASCADE, + note_id BIGINT REFERENCES notes(id) ON DELETE CASCADE, + authorship_mode TEXT NOT NULL DEFAULT 'simple', + colors_enabled BOOLEAN NOT NULL DEFAULT TRUE, + compact_view BOOLEAN NOT NULL DEFAULT TRUE, + editor_line_numbers BOOLEAN NOT NULL DEFAULT TRUE, + preview_line_numbers BOOLEAN NOT NULL DEFAULT FALSE, + line_links BOOLEAN NOT NULL DEFAULT FALSE, + font_family TEXT NOT NULL DEFAULT 'mono', + font_size BIGINT NOT NULL DEFAULT 14, + updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text), + CHECK ((pad_id IS NOT NULL AND note_id IS NULL) OR (pad_id IS NULL AND note_id IS NOT NULL)), + UNIQUE (user_id, pad_id), + UNIQUE (user_id, note_id) +); + +CREATE INDEX idx_user_editor_preferences_user ON user_editor_preferences(user_id); +CREATE INDEX idx_user_editor_preferences_pad ON user_editor_preferences(pad_id); +CREATE INDEX idx_user_editor_preferences_note ON user_editor_preferences(note_id); + +DROP TABLE resource_editor_settings; diff --git a/migrations/postgres/0023_split_resource_editor_settings.sql b/migrations/postgres/0023_split_resource_editor_settings.sql new file mode 100644 index 0000000..ae0b655 --- /dev/null +++ b/migrations/postgres/0023_split_resource_editor_settings.sql @@ -0,0 +1,40 @@ +CREATE TABLE resource_editor_settings ( + resource_kind TEXT NOT NULL, + resource_slug TEXT NOT NULL, + authorship_mode TEXT NOT NULL DEFAULT 'simple', + colors_enabled BOOLEAN NOT NULL DEFAULT TRUE, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (resource_kind, resource_slug) +); + +INSERT INTO resource_editor_settings ( + resource_kind, resource_slug, authorship_mode, colors_enabled, updated_at +) +SELECT 'pad', p.slug, preferences.authorship_mode, preferences.colors_enabled, + preferences.updated_at::timestamptz +FROM user_editor_preferences preferences +JOIN pads p ON p.id = preferences.pad_id +JOIN user_pads ownership + ON ownership.pad_id = p.id + AND ownership.user_id = preferences.user_id +WHERE preferences.pad_id IS NOT NULL +ON CONFLICT (resource_kind, resource_slug) DO NOTHING; + +INSERT INTO resource_editor_settings ( + resource_kind, resource_slug, authorship_mode, colors_enabled, updated_at +) +SELECT 'note', workspace.slug || '/' || note.slug, + preferences.authorship_mode, preferences.colors_enabled, + preferences.updated_at::timestamptz +FROM user_editor_preferences preferences +JOIN notes note ON note.id = preferences.note_id +JOIN workspaces workspace ON workspace.id = note.workspace_id +JOIN user_workspaces ownership + ON ownership.workspace_id = workspace.id + AND ownership.user_id = preferences.user_id +WHERE preferences.note_id IS NOT NULL +ON CONFLICT (resource_kind, resource_slug) DO NOTHING; + +ALTER TABLE user_editor_preferences + DROP COLUMN authorship_mode, + DROP COLUMN colors_enabled; diff --git a/migrations/sqlite/0022_user_editor_preferences.sql b/migrations/sqlite/0022_user_editor_preferences.sql new file mode 100644 index 0000000..f67c414 --- /dev/null +++ b/migrations/sqlite/0022_user_editor_preferences.sql @@ -0,0 +1,26 @@ +CREATE TABLE user_editor_preferences ( + user_id INTEGER NOT NULL, + pad_id INTEGER, + note_id INTEGER, + authorship_mode TEXT NOT NULL DEFAULT 'simple', + colors_enabled INTEGER NOT NULL DEFAULT 1, + compact_view INTEGER NOT NULL DEFAULT 1, + editor_line_numbers INTEGER NOT NULL DEFAULT 1, + preview_line_numbers INTEGER NOT NULL DEFAULT 0, + line_links INTEGER NOT NULL DEFAULT 0, + font_family TEXT NOT NULL DEFAULT 'mono', + font_size INTEGER NOT NULL DEFAULT 14, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + 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)), + UNIQUE (user_id, pad_id), + UNIQUE (user_id, note_id) +); + +CREATE INDEX idx_user_editor_preferences_user ON user_editor_preferences(user_id); +CREATE INDEX idx_user_editor_preferences_pad ON user_editor_preferences(pad_id); +CREATE INDEX idx_user_editor_preferences_note ON user_editor_preferences(note_id); + +DROP TABLE resource_editor_settings; diff --git a/migrations/sqlite/0023_split_resource_editor_settings.sql b/migrations/sqlite/0023_split_resource_editor_settings.sql new file mode 100644 index 0000000..976f18b --- /dev/null +++ b/migrations/sqlite/0023_split_resource_editor_settings.sql @@ -0,0 +1,67 @@ +CREATE TABLE resource_editor_settings ( + resource_kind TEXT NOT NULL, + resource_slug TEXT NOT NULL, + authorship_mode TEXT NOT NULL DEFAULT 'simple', + colors_enabled INTEGER NOT NULL DEFAULT 1, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (resource_kind, resource_slug) +); + +INSERT OR IGNORE INTO resource_editor_settings ( + resource_kind, resource_slug, authorship_mode, colors_enabled, updated_at +) +SELECT 'pad', p.slug, preferences.authorship_mode, preferences.colors_enabled, preferences.updated_at +FROM user_editor_preferences preferences +JOIN pads p ON p.id = preferences.pad_id +JOIN user_pads ownership + ON ownership.pad_id = p.id + AND ownership.user_id = preferences.user_id +WHERE preferences.pad_id IS NOT NULL; + +INSERT OR IGNORE INTO resource_editor_settings ( + resource_kind, resource_slug, authorship_mode, colors_enabled, updated_at +) +SELECT 'note', workspace.slug || '/' || note.slug, + preferences.authorship_mode, preferences.colors_enabled, preferences.updated_at +FROM user_editor_preferences preferences +JOIN notes note ON note.id = preferences.note_id +JOIN workspaces workspace ON workspace.id = note.workspace_id +JOIN user_workspaces ownership + ON ownership.workspace_id = workspace.id + AND ownership.user_id = preferences.user_id +WHERE preferences.note_id IS NOT NULL; + +ALTER TABLE user_editor_preferences RENAME TO user_editor_preferences_legacy; + +CREATE TABLE user_editor_preferences ( + user_id INTEGER NOT NULL, + pad_id INTEGER, + note_id INTEGER, + compact_view INTEGER NOT NULL DEFAULT 1, + editor_line_numbers INTEGER NOT NULL DEFAULT 1, + preview_line_numbers INTEGER NOT NULL DEFAULT 0, + line_links INTEGER NOT NULL DEFAULT 0, + font_family TEXT NOT NULL DEFAULT 'mono', + font_size INTEGER NOT NULL DEFAULT 14, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + 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)), + UNIQUE (user_id, pad_id), + UNIQUE (user_id, note_id) +); + +INSERT INTO user_editor_preferences ( + user_id, pad_id, note_id, compact_view, editor_line_numbers, + preview_line_numbers, line_links, font_family, font_size, updated_at +) +SELECT user_id, pad_id, note_id, compact_view, editor_line_numbers, + preview_line_numbers, line_links, font_family, font_size, updated_at +FROM user_editor_preferences_legacy; + +DROP TABLE user_editor_preferences_legacy; + +CREATE INDEX idx_user_editor_preferences_user ON user_editor_preferences(user_id); +CREATE INDEX idx_user_editor_preferences_pad ON user_editor_preferences(pad_id); +CREATE INDEX idx_user_editor_preferences_note ON user_editor_preferences(note_id); diff --git a/src/api/files.rs b/src/api/files.rs index f4562cf..76c60fe 100644 --- a/src/api/files.rs +++ b/src/api/files.rs @@ -101,14 +101,37 @@ pub async fn upload_pad_file( let mime = mime_guess::from_path(&stored) .first_or_octet_stream() .to_string(); - let cache_control = format!("public, max-age={}", state.file_cache_max_age_seconds); + let cache_control = crate::cache::cache_control(state.file_cache_max_age_seconds); state .storage .put(&key, bytes.clone().into(), &mime, &cache_control) .await .map_err(|_| ApiError::internal("Failed to save the file"))?; 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}))) + Ok(Json(serde_json::json!({"name": stored, "url": url, "mime_type": mime}))) +} + +pub(super) fn content_references_file(content: &str, filename: &str, url: &str) -> bool { + if content.contains(url) { + return true; + } + for marker in ["[file=", "[image=", "[img="] { + let mut remaining = content; + while let Some(index) = remaining.find(marker) { + let after = &remaining[index + marker.len()..]; + let end = after + .find(|character| character == ',' || character == ']') + .unwrap_or(after.len()); + if after[..end].trim() == filename { + return true; + } + if end >= after.len() { + break; + } + remaining = &after[end + 1..]; + } + } + false } pub async fn pad_files( @@ -127,7 +150,7 @@ pub async fn pad_files( .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); + let attached = content_references_file(&pad.content, &file.filename, &file.url); if attached != file.is_attached { db::set_pad_file_attached(&state.db, file.id, attached).await?; file.is_attached = attached; @@ -265,14 +288,14 @@ pub async fn upload_note_file( let mime = mime_guess::from_path(&stored) .first_or_octet_stream() .to_string(); - let cache_control = format!("public, max-age={}", state.file_cache_max_age_seconds); + let cache_control = crate::cache::cache_control(state.file_cache_max_age_seconds); state .storage .put(&key, bytes.clone().into(), &mime, &cache_control) .await .map_err(|_| ApiError::internal("Failed to save the file"))?; 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}))) + Ok(Json(serde_json::json!({"name": stored, "url": url, "mime_type": mime}))) } pub async fn delete_note( @@ -316,6 +339,12 @@ pub async fn delete_note( .await .map_err(|_| ApiError::internal("Failed to delete note files"))?; } + db::delete_resource_editor_state( + &state.db, + "note", + &format!("{workspace_slug}/{note_slug}"), + ) + .await?; db::delete_note(&state.db, note.id).await?; Ok(Json(serde_json::json!({"ok": true}))) } @@ -337,7 +366,7 @@ pub async fn note_files( .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); + let attached = content_references_file(¬e.content, &file.filename, &file.url); if attached != file.is_attached { db::set_note_file_attached(&state.db, file.id, attached).await?; file.is_attached = attached; @@ -464,9 +493,8 @@ async fn serve_token_file( ); response.headers_mut().insert( header::CACHE_CONTROL, - HeaderValue::from_str(&format!( - "public, max-age={}", - state.file_cache_max_age_seconds + HeaderValue::from_str(&crate::cache::cache_control( + state.file_cache_max_age_seconds, )) .expect("valid file cache-control header"), ); diff --git a/src/api/mod.rs b/src/api/mod.rs index 3ac645b..fc3ebc6 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -48,18 +48,99 @@ fn bearer_token(headers: &HeaderMap) -> Option<&str> { .filter(|value| !value.is_empty()) } +fn user_session_token(headers: &HeaderMap) -> Option<&str> { + headers + .get("x-rustpad-user-token") + .and_then(|value| value.to_str().ok()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .or_else(|| bearer_token(headers)) +} + +async fn session_user( + state: &SharedState, + headers: &HeaderMap, +) -> Result, ApiError> { + let Some(token) = user_session_token(headers) else { + return Ok(None); + }; + crate::auth::user_from_token(state, token) + .await + .map_err(|error| ApiError::forbidden(&error.message)) +} + +async fn has_write_permission( + state: &SharedState, + headers: &HeaderMap, + kind: &str, + slug: &str, +) -> Result { + let bearer = bearer_token(headers); + if token_access_level(state, kind, slug, bearer).await? >= AccessLevel::Write { + return Ok(true); + } + + let session = user_session_token(headers); + if session.is_some() && session != bearer { + return Ok(token_access_level(state, kind, slug, session).await? >= AccessLevel::Write); + } + Ok(false) +} + #[derive(Debug, Serialize)] pub struct PublishResponse { url: Option, enabled: bool, } +#[derive(Debug, Clone, Serialize)] +pub struct MarkdownFileReference { + filename: String, + url: String, + mime_type: String, +} + +impl From for MarkdownFileReference { + fn from(file: db::NoteFile) -> Self { + Self { + filename: file.filename, + url: file.url, + mime_type: file.mime_type, + } + } +} + +pub(crate) async fn markdown_file_references( + state: &SharedState, + pad_id: Option, + note_id: Option, + content: Option<&str>, +) -> Result, ApiError> { + let file_rows = if let Some(id) = pad_id { + db::list_pad_files(&state.db, id).await? + } else if let Some(id) = note_id { + db::list_note_files(&state.db, id).await? + } else { + Vec::new() + }; + Ok(file_rows + .into_iter() + .filter(|file| { + content + .map(|value| files::content_references_file(value, &file.filename, &file.url)) + .unwrap_or(true) + }) + .map(Into::into) + .collect()) +} + #[derive(Debug, Serialize)] pub struct PublicPageResponse { title: String, content: String, updated_at: String, allow_task_updates: bool, + files: Vec, } #[derive(Debug, Deserialize)] @@ -173,7 +254,16 @@ pub struct NoteInfo { note_color: Option, authorship_mode: String, colors_enabled: bool, + compact_view: bool, + editor_line_numbers: bool, + preview_line_numbers: bool, + line_links: bool, + font_family: String, + font_size: i64, + personal_editor_settings: bool, can_save_editor_settings: bool, + can_manage_authorship: bool, + files: Vec, } #[derive(Debug, Deserialize)] @@ -183,35 +273,38 @@ pub struct EditorColorRequest { #[derive(Debug, Deserialize)] pub struct EditorSettingsRequest { - authorship_mode: String, - colors_enabled: bool, + #[serde(default)] + authorship_mode: Option, + #[serde(default)] + colors_enabled: Option, + #[serde(default)] + compact_view: Option, + #[serde(default)] + editor_line_numbers: Option, + #[serde(default)] + preview_line_numbers: Option, + #[serde(default)] + line_links: Option, + #[serde(default)] + font_family: Option, + #[serde(default)] + font_size: Option, } -async fn editor_settings( +async fn user_editor_preferences( state: &SharedState, - kind: &str, - slug: &str, -) -> Result<(String, bool), ApiError> { - let row: Option<(String, i64)> = sqlx::query_as(queries::get( - state.db.kind(), - queries::RESOURCE_EDITOR_SETTINGS_SELECT, + headers: &HeaderMap, + resource: db::EditorPreferenceResource, +) -> Result<(db::EditorPreferences, bool), ApiError> { + let Some(user) = session_user(state, headers).await? else { + return Ok((db::EditorPreferences::default(), false)); + }; + Ok(( + db::load_editor_preferences(&state.db, user.id, resource) + .await? + .unwrap_or_default(), + true, )) - .bind(kind) - .bind(slug) - .fetch_optional(state.db.pool()) - .await?; - Ok(row - .map(|(mode, colors)| { - ( - if mode == "full" { - "full".into() - } else { - "simple".into() - }, - colors != 0, - ) - }) - .unwrap_or_else(|| ("simple".into(), true))) } async fn save_editor_settings( @@ -221,49 +314,123 @@ async fn save_editor_settings( permission_slug: &str, settings_kind: &str, settings_slug: &str, + resource: db::EditorPreferenceResource, payload: EditorSettingsRequest, ) -> Result, ApiError> { - let permission = crate::auth::resource_permission( + if !has_write_permission( state, + headers, permission_kind, permission_slug, - bearer_token(headers), ) - .await - .map_err(|e| ApiError::forbidden(&e.message))?; - if permission.as_deref() != Some("rw") { + .await? + { return Err(ApiError::forbidden( - "Read and write access is required to save editor settings", + "Read and write access is required to save editor preferences", )); } - let mode = match payload.authorship_mode.as_str() { - "simple" => "simple", - "full" | "advanced" => "full", - _ => return Err(ApiError::bad_request("Invalid authorship mode")), + let user = session_user(state, headers) + .await? + .ok_or_else(|| ApiError::forbidden("Log in to save personal editor preferences"))?; + + let wants_personal_update = payload.compact_view.is_some() + || payload.editor_line_numbers.is_some() + || payload.preview_line_numbers.is_some() + || payload.line_links.is_some() + || payload.font_family.is_some() + || payload.font_size.is_some(); + let wants_global_update = payload.authorship_mode.is_some() || payload.colors_enabled.is_some(); + if !wants_personal_update && !wants_global_update { + return Err(ApiError::bad_request("No editor settings were provided")); + } + let can_manage_authorship = if wants_global_update { + crate::auth::is_resource_owner( + state, + permission_kind, + permission_slug, + user_session_token(headers), + ) + .await + .unwrap_or(false) + } else { + false }; - let mut tx = state.db.pool().begin().await?; - sqlx::query(queries::get( - state.db.kind(), - queries::RESOURCE_EDITOR_SETTINGS_DELETE, - )) - .bind(settings_kind) - .bind(settings_slug) - .execute(&mut *tx) + if wants_global_update && !can_manage_authorship { + return Err(ApiError::forbidden( + "Only the resource owner can change authorship settings", + )); + } + + let preferences = if wants_personal_update { + let mut preferences = db::load_editor_preferences(&state.db, user.id, resource) + .await? + .unwrap_or_default(); + if let Some(value) = payload.compact_view { + preferences.compact_view = value; + } + if let Some(value) = payload.editor_line_numbers { + preferences.editor_line_numbers = value; + } + if let Some(value) = payload.preview_line_numbers { + preferences.preview_line_numbers = value; + } + if let Some(value) = payload.line_links { + preferences.line_links = value; + } + if let Some(value) = payload.font_family { + preferences.font_family = match value.as_str() { + "mono" | "system" | "serif" | "arial" | "georgia" => value, + _ => return Err(ApiError::bad_request("Invalid editor font")), + }; + } + if let Some(value) = payload.font_size { + if !matches!(value, 14 | 16 | 18 | 20 | 22) { + return Err(ApiError::bad_request("Invalid editor font size")); + } + preferences.font_size = value; + } + Some(preferences) + } else { + None + }; + + let resource_settings = if wants_global_update { + let mut settings = db::load_resource_editor_settings( + &state.db, + settings_kind, + settings_slug, + ) + .await?; + if let Some(mode) = payload.authorship_mode { + settings.authorship_mode = match mode.as_str() { + "simple" => "simple".into(), + "full" | "advanced" => "full".into(), + _ => return Err(ApiError::bad_request("Invalid authorship mode")), + }; + } + if let Some(value) = payload.colors_enabled { + settings.colors_enabled = value; + } + Some(settings) + } else { + None + }; + + db::save_editor_configuration( + &state.db, + user.id, + resource, + preferences.as_ref(), + resource_settings + .as_ref() + .map(|settings| (settings_kind, settings_slug, settings)), + ) .await?; - sqlx::query(queries::get( - state.db.kind(), - queries::RESOURCE_EDITOR_SETTINGS_INSERT, - )) - .bind(settings_kind) - .bind(settings_slug) - .bind(mode) - .bind(payload.colors_enabled) - .execute(&mut *tx) - .await?; - tx.commit().await?; - Ok(Json( - serde_json::json!({"authorship_mode": mode, "colors_enabled": payload.colors_enabled}), - )) + + Ok(Json(serde_json::json!({ + "preferences": preferences, + "resource_settings": resource_settings, + }))) } pub async fn create_workspace( @@ -456,10 +623,7 @@ async fn editor_colors( kind: &str, slug: &str, ) -> Result<(Option, Option), ApiError> { - let Some(user) = crate::auth::optional_user(state, headers) - .await - .map_err(|e| ApiError::forbidden(&e.message))? - else { + let Some(user) = session_user(state, headers).await? else { return Ok((None, None)); }; let global: Option = sqlx::query_scalar(queries::get( @@ -488,9 +652,8 @@ async fn save_editor_color( slug: &str, color: Option<&str>, ) -> Result, ApiError> { - let user = crate::auth::optional_user(state, headers) - .await - .map_err(|e| ApiError::forbidden(&e.message))? + let user = session_user(state, headers) + .await? .ok_or_else(|| ApiError::forbidden("Log in to save note colors"))?; let color = clean_editor_color(color)?; let mut tx = state.db.pool().begin().await?; @@ -540,18 +703,24 @@ pub async fn note_info( .ok_or_else(ApiError::not_found_note)?; let color_slug = format!("{}/{}", workspace_slug, note_slug); let (global_color, note_color) = editor_colors(&state, &headers, "note", &color_slug).await?; - let (authorship_mode, colors_enabled) = editor_settings(&state, "note", &color_slug).await?; - let can_save_editor_settings = crate::auth::resource_permission( + let (editor_preferences, personal_editor_settings) = user_editor_preferences( + &state, + &headers, + db::EditorPreferenceResource::Note(note.id), + ) + .await?; + let resource_editor_settings = + db::load_resource_editor_settings(&state.db, "note", &color_slug).await?; + let can_save_editor_settings = personal_editor_settings + && has_write_permission(&state, &headers, "workspace", &workspace_slug).await?; + let can_manage_authorship = crate::auth::is_resource_owner( &state, "workspace", &workspace_slug, - bearer_token(&headers), + user_session_token(&headers), ) .await - .ok() - .flatten() - .as_deref() - == Some("rw"); + .unwrap_or(false); if workspace.is_private == 0 && !db::note_public_page_disabled(&state.db, note.id).await? @@ -573,15 +742,7 @@ pub async fn note_info( created_at: db::normalize_timestamp(¬e.created_at), updated_at: db::normalize_timestamp(¬e.updated_at), can_delete_files: { - let workspace_owner = crate::auth::is_resource_owner( - &state, - "workspace", - &workspace_slug, - bearer_token(&headers), - ) - .await - .unwrap_or(false); - let note_owner = crate::auth::optional_user(&state, &headers) + let note_owner = session_user(&state, &headers) .await .ok() .flatten() @@ -591,13 +752,22 @@ pub async fn note_info( .map(|creator| creator == user.nickname) }) .unwrap_or(false); - workspace_owner || note_owner + can_manage_authorship || note_owner }, global_color, note_color, - authorship_mode, - colors_enabled, + authorship_mode: resource_editor_settings.authorship_mode, + colors_enabled: resource_editor_settings.colors_enabled, + compact_view: editor_preferences.compact_view, + editor_line_numbers: editor_preferences.editor_line_numbers, + preview_line_numbers: editor_preferences.preview_line_numbers, + line_links: editor_preferences.line_links, + font_family: editor_preferences.font_family, + font_size: editor_preferences.font_size, + personal_editor_settings, can_save_editor_settings, + can_manage_authorship, + files: markdown_file_references(&state, None, Some(note.id), None).await?, })) } @@ -607,14 +777,20 @@ pub async fn set_note_editor_settings( Path((workspace_slug, note_slug)): Path<(String, String)>, Json(payload): Json, ) -> Result, ApiError> { - let settings_slug = format!("{}/{}", workspace_slug, note_slug); + let workspace = db::find_workspace(&state.db, &workspace_slug) + .await? + .ok_or_else(ApiError::not_found_workspace)?; + let note = db::find_note(&state.db, workspace.id, ¬e_slug) + .await? + .ok_or_else(ApiError::not_found_note)?; save_editor_settings( &state, &headers, "workspace", &workspace_slug, "note", - &settings_slug, + &format!("{workspace_slug}/{note_slug}"), + db::EditorPreferenceResource::Note(note.id), payload, ) .await diff --git a/src/api/pads_public.rs b/src/api/pads_public.rs index 3c28f2f..c2694ff 100644 --- a/src/api/pads_public.rs +++ b/src/api/pads_public.rs @@ -38,7 +38,16 @@ pub struct PadInfo { note_color: Option, authorship_mode: String, colors_enabled: bool, + compact_view: bool, + editor_line_numbers: bool, + preview_line_numbers: bool, + line_links: bool, + font_family: String, + font_size: i64, + personal_editor_settings: bool, can_save_editor_settings: bool, + can_manage_authorship: bool, + files: Vec, } pub async fn create_pad( @@ -92,14 +101,24 @@ pub async fn pad_info( ) .await?; let (global_color, note_color) = editor_colors(&state, &headers, "pad", &slug).await?; - let (authorship_mode, colors_enabled) = editor_settings(&state, "pad", &slug).await?; - let can_save_editor_settings = - crate::auth::resource_permission(&state, "pad", &slug, bearer_token(&headers)) - .await - .ok() - .flatten() - .as_deref() - == Some("rw"); + let (editor_preferences, personal_editor_settings) = user_editor_preferences( + &state, + &headers, + db::EditorPreferenceResource::Pad(pad.id), + ) + .await?; + let resource_editor_settings = + db::load_resource_editor_settings(&state.db, "pad", &slug).await?; + let can_manage_authorship = crate::auth::is_resource_owner( + &state, + "pad", + &slug, + user_session_token(&headers), + ) + .await + .unwrap_or(false); + let can_save_editor_settings = personal_editor_settings + && has_write_permission(&state, &headers, "pad", &slug).await?; if pad.is_private == 0 && !db::pad_public_page_disabled(&state.db, pad.id).await? && !db::pad_public_page_enabled(&state.db, pad.id).await? @@ -116,19 +135,21 @@ pub async fn pad_info( private: pad.is_private != 0, created_at: db::normalize_timestamp(&pad.created_at), updated_at: db::normalize_timestamp(&pad.updated_at), - can_delete_files: crate::auth::is_resource_owner( - &state, - "pad", - &slug, - bearer_token(&headers), - ) - .await - .unwrap_or(false), + can_delete_files: can_manage_authorship, global_color, note_color, - authorship_mode, - colors_enabled, + authorship_mode: resource_editor_settings.authorship_mode, + colors_enabled: resource_editor_settings.colors_enabled, + compact_view: editor_preferences.compact_view, + editor_line_numbers: editor_preferences.editor_line_numbers, + preview_line_numbers: editor_preferences.preview_line_numbers, + line_links: editor_preferences.line_links, + font_family: editor_preferences.font_family, + font_size: editor_preferences.font_size, + personal_editor_settings, can_save_editor_settings, + can_manage_authorship, + files: markdown_file_references(&state, Some(pad.id), None, None).await?, })) } @@ -138,7 +159,20 @@ pub async fn set_pad_editor_settings( Path(slug): Path, Json(payload): Json, ) -> Result, ApiError> { - save_editor_settings(&state, &headers, "pad", &slug, "pad", &slug, payload).await + let pad = db::find_pad(&state.db, &slug) + .await? + .ok_or_else(ApiError::not_found_note)?; + save_editor_settings( + &state, + &headers, + "pad", + &slug, + "pad", + &slug, + db::EditorPreferenceResource::Pad(pad.id), + payload, + ) + .await } pub async fn pad_editor_color( @@ -382,11 +416,19 @@ pub async fn public_page( .await? .ok_or_else(ApiError::not_found_note)?; ensure_public_page_access(&state, &headers, &page).await?; + let files = markdown_file_references( + &state, + page.pad_id, + page.note_id, + Some(&page.content), + ) + .await?; Ok(Json(PublicPageResponse { title: page.title, content: page.content, updated_at: db::normalize_timestamp(&page.updated_at), allow_task_updates: page.allow_task_updates, + files, })) } @@ -408,11 +450,19 @@ pub async fn update_public_task( let page = db::update_public_task(&state.db, &token, payload.source_line, payload.checked) .await? .ok_or_else(ApiError::not_found_note)?; + let files = markdown_file_references( + &state, + page.pad_id, + page.note_id, + Some(&page.content), + ) + .await?; Ok(Json(PublicPageResponse { title: page.title, content: page.content, updated_at: db::normalize_timestamp(&page.updated_at), allow_task_updates: page.allow_task_updates, + files, })) } diff --git a/src/app/mod.rs b/src/app/mod.rs index 80ba31b..8d03fcd 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -46,9 +46,10 @@ pub fn router( } }); - let asset_cache_control = - HeaderValue::from_str(&format!("public, max-age={asset_cache_max_age_seconds}")) - .expect("valid asset cache-control header"); + let asset_cache_control = HeaderValue::from_str(&crate::cache::cache_control( + asset_cache_max_age_seconds, + )) + .expect("valid asset cache-control header"); Router::new() .route("/", get(home)) diff --git a/src/app/pages.rs b/src/app/pages.rs index f0d9cab..806dc4a 100644 --- a/src/app/pages.rs +++ b/src/app/pages.rs @@ -15,6 +15,36 @@ use axum::{ use crate::{assets, db, state::SharedState}; +fn render_editor_page( + state: &SharedState, + entrypoint: &str, + resource_kind: &str, + document_title: &str, + parent_title: &str, + parent_url: &str, + parent_class: &str, + protected_resource_label: &str, + extra_shortcuts: &str, +) -> Response { + let html = include_str!("../../static/editor.html") + .replace("__RESOURCE_KIND__", resource_kind) + .replace("__DOCUMENT_TITLE__", &escape_html(document_title)) + .replace("__PARENT_TITLE__", &escape_html(parent_title)) + .replace("__PARENT_URL__", &escape_html(parent_url)) + .replace("__PARENT_CLASS__", parent_class) + .replace("__PROTECTED_RESOURCE_LABEL__", protected_resource_label) + .replace("__EXTRA_SHORTCUTS__", extra_shortcuts); + assets::render_html( + &html, + &state.asset_version, + state.registration_enabled, + state.ldap.is_some(), + &state.frontend_log_level, + state.upload_max_size_bytes, + entrypoint, + ) +} + pub(super) async fn health() -> &'static str { "ok" } @@ -46,19 +76,17 @@ pub(super) async fn home(State(state): State) -> Response { pub(super) async fn pad(State(state): State, Path(slug): Path) -> Response { match db::find_pad(&state.db, &slug).await { - Ok(Some(pad)) => { - let html = include_str!("../../static/pad.html") - .replace("__PAD_TITLE__", &escape_html(&pad.title)); - assets::render_html( - &html, - &state.asset_version, - state.registration_enabled, - state.ldap.is_some(), - &state.frontend_log_level, - state.upload_max_size_bytes, - "pad", - ) - } + Ok(Some(pad)) => render_editor_page( + &state, + "pad", + "pad", + &pad.title, + "RustPad", + "/", + "home-brand", + "note", + "Alt+EnterNew line while editing PreviewEscEdit raw Markdown of current Preview line", + ), Ok(None) => error_response( StatusCode::NOT_FOUND, "404", @@ -169,35 +197,17 @@ pub(super) async fn note( }; match db::find_note(&state.db, workspace.id, ¬e_slug).await { - Ok(Some(note)) => { - let html = include_str!("../../static/note.html") - .replace( - "__NOTE_TITLE__", - &escape_html(if workspace.is_private != 0 { - "Note" - } else { - ¬e.title - }), - ) - .replace( - "__WORKSPACE_TITLE__", - &escape_html(if workspace.is_private != 0 { - "Workspace" - } else { - &workspace.title - }), - ) - .replace("__WORKSPACE_SLUG__", &escape_html(&workspace_slug)); - assets::render_html( - &html, - &state.asset_version, - state.registration_enabled, - state.ldap.is_some(), - &state.frontend_log_level, - state.upload_max_size_bytes, - "note", - ) - } + Ok(Some(note)) => render_editor_page( + &state, + "note", + "note", + if workspace.is_private != 0 { "Note" } else { ¬e.title }, + if workspace.is_private != 0 { "Workspace" } else { &workspace.title }, + &format!("/w/{workspace_slug}"), + "", + "workspace", + "", + ), Ok(None) => error_response( StatusCode::NOT_FOUND, "404", diff --git a/src/auth/mod.rs b/src/auth/mod.rs index 7bd4a99..fc1c2fa 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -944,6 +944,22 @@ pub async fn confirm_account_action( .execute(&mut *tx) .await .map_err(AuthError::database)?; + sqlx::query(queries::get( + state.db.kind(), + queries::RESOURCE_COLORS_DELETE_BY_USER, + )) + .bind(user_id) + .execute(&mut *tx) + .await + .map_err(AuthError::database)?; + sqlx::query(queries::get( + state.db.kind(), + queries::EDITOR_PREFERENCES_DELETE_BY_USER, + )) + .bind(user_id) + .execute(&mut *tx) + .await + .map_err(AuthError::database)?; sqlx::query(queries::get(state.db.kind(), queries::AUTH_ANONYMIZE_USER)) .bind(&deleted_nickname) .bind(normalize(&deleted_nickname)) @@ -1143,6 +1159,13 @@ pub async fn delete_resource( .await .map_err(AuthError::database)?; for note in notes { + crate::db::delete_resource_editor_state( + &state.db, + "note", + &format!("{}/{}", req.slug.trim(), note.slug), + ) + .await + .map_err(AuthError::database)?; let files = crate::db::list_note_files(&state.db, note.id) .await .map_err(AuthError::database)?; @@ -1161,6 +1184,9 @@ pub async fn delete_resource( queries::USER_DELETE_WORKSPACE } "pad" => { + crate::db::delete_resource_editor_state(&state.db, "pad", req.slug.trim()) + .await + .map_err(AuthError::database)?; if let Some(pad) = crate::db::find_pad(&state.db, req.slug.trim()) .await .map_err(AuthError::database)? diff --git a/src/cache.rs b/src/cache.rs new file mode 100644 index 0000000..172ffe3 --- /dev/null +++ b/src/cache.rs @@ -0,0 +1,18 @@ +/* + * Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl + * Source-Available Code / Dual-Licensed. + * + * Free for non-commercial and evaluation use under terms of BSL/GPLv3. + * Commercial or production use requires a valid paid license. + * See LICENSE file in repository root for details. + */ + +pub const DISABLED_CACHE_CONTROL: &str = "no-cache, no-store, must-revalidate"; + +pub fn cache_control(max_age_seconds: u64) -> String { + if max_age_seconds == 0 { + DISABLED_CACHE_CONTROL.into() + } else { + format!("public, max-age={max_age_seconds}") + } +} diff --git a/src/db/editor_preferences.rs b/src/db/editor_preferences.rs new file mode 100644 index 0000000..55c5e83 --- /dev/null +++ b/src/db/editor_preferences.rs @@ -0,0 +1,193 @@ +/* + * Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl + * Source-Available Code / Dual-Licensed. + * + * Free for non-commercial and evaluation use under terms of BSL/GPLv3. + * Commercial or production use requires a valid paid license. + * See LICENSE file in repository root for details. + */ + +use super::*; + +#[derive(Debug, Clone, Serialize)] +pub struct EditorPreferences { + pub compact_view: bool, + pub editor_line_numbers: bool, + pub preview_line_numbers: bool, + pub line_links: bool, + pub font_family: String, + pub font_size: i64, +} + +impl Default for EditorPreferences { + fn default() -> Self { + Self { + compact_view: true, + editor_line_numbers: true, + preview_line_numbers: false, + line_links: false, + font_family: "mono".into(), + font_size: 14, + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct ResourceEditorSettings { + pub authorship_mode: String, + pub colors_enabled: bool, +} + +impl Default for ResourceEditorSettings { + fn default() -> Self { + Self { + authorship_mode: "simple".into(), + colors_enabled: true, + } + } +} + +#[derive(Debug, Clone, Copy)] +pub enum EditorPreferenceResource { + Pad(i64), + Note(i64), +} + +fn preference_select_query(resource: EditorPreferenceResource) -> queries::Query { + match resource { + EditorPreferenceResource::Pad(_) => queries::EDITOR_PREFERENCES_SELECT_PAD, + EditorPreferenceResource::Note(_) => queries::EDITOR_PREFERENCES_SELECT_NOTE, + } +} + +fn preference_upsert_query(resource: EditorPreferenceResource) -> queries::Query { + match resource { + EditorPreferenceResource::Pad(_) => queries::EDITOR_PREFERENCES_UPSERT_PAD, + EditorPreferenceResource::Note(_) => queries::EDITOR_PREFERENCES_UPSERT_NOTE, + } +} + +fn resource_id(resource: EditorPreferenceResource) -> i64 { + match resource { + EditorPreferenceResource::Pad(id) | EditorPreferenceResource::Note(id) => id, + } +} + +pub async fn load_editor_preferences( + pool: &Database, + user_id: i64, + resource: EditorPreferenceResource, +) -> Result, sqlx::Error> { + let Some(row) = sqlx::query(queries::get( + pool.kind(), + preference_select_query(resource), + )) + .bind(user_id) + .bind(resource_id(resource)) + .fetch_optional(pool.pool()) + .await? + else { + return Ok(None); + }; + + Ok(Some(EditorPreferences { + compact_view: row.try_get::(0)? != 0, + editor_line_numbers: row.try_get::(1)? != 0, + preview_line_numbers: row.try_get::(2)? != 0, + line_links: row.try_get::(3)? != 0, + font_family: crate::row_decode::text(&row, 4)?, + font_size: row.try_get(5)?, + })) +} + +pub async fn save_editor_configuration( + pool: &Database, + user_id: i64, + resource: EditorPreferenceResource, + preferences: Option<&EditorPreferences>, + resource_settings: Option<(&str, &str, &ResourceEditorSettings)>, +) -> Result<(), sqlx::Error> { + let mut tx = pool.pool().begin().await?; + + if let Some(preferences) = preferences { + sqlx::query(queries::get( + pool.kind(), + preference_upsert_query(resource), + )) + .bind(user_id) + .bind(resource_id(resource)) + .bind(preferences.compact_view) + .bind(preferences.editor_line_numbers) + .bind(preferences.preview_line_numbers) + .bind(preferences.line_links) + .bind(&preferences.font_family) + .bind(preferences.font_size) + .execute(&mut *tx) + .await?; + } + + if let Some((resource_kind, resource_slug, settings)) = resource_settings { + sqlx::query(queries::get( + pool.kind(), + queries::RESOURCE_EDITOR_SETTINGS_UPSERT, + )) + .bind(resource_kind) + .bind(resource_slug) + .bind(&settings.authorship_mode) + .bind(settings.colors_enabled) + .execute(&mut *tx) + .await?; + } + + tx.commit().await?; + Ok(()) +} + +pub async fn load_resource_editor_settings( + pool: &Database, + resource_kind: &str, + resource_slug: &str, +) -> Result { + let Some(row) = sqlx::query(queries::get( + pool.kind(), + queries::RESOURCE_EDITOR_SETTINGS_SELECT, + )) + .bind(resource_kind) + .bind(resource_slug) + .fetch_optional(pool.pool()) + .await? + else { + return Ok(ResourceEditorSettings::default()); + }; + + Ok(ResourceEditorSettings { + authorship_mode: crate::row_decode::text(&row, 0)?, + colors_enabled: row.try_get::(1)? != 0, + }) +} + +pub async fn delete_resource_editor_state( + pool: &Database, + resource_kind: &str, + resource_slug: &str, +) -> Result<(), sqlx::Error> { + let mut tx = pool.pool().begin().await?; + sqlx::query(queries::get( + pool.kind(), + queries::RESOURCE_COLORS_DELETE_BY_RESOURCE, + )) + .bind(resource_kind) + .bind(resource_slug) + .execute(&mut *tx) + .await?; + sqlx::query(queries::get( + pool.kind(), + queries::RESOURCE_EDITOR_SETTINGS_DELETE, + )) + .bind(resource_kind) + .bind(resource_slug) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(()) +} diff --git a/src/db/mod.rs b/src/db/mod.rs index 5b7edab..0f7f284 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -18,9 +18,11 @@ use serde::Serialize; use sqlx::FromRow; use sqlx::{Any, Row, Transaction, any::AnyRow}; +mod editor_preferences; mod files; mod public_pages; +pub use editor_preferences::*; pub use files::*; pub use public_pages::*; diff --git a/src/main.rs b/src/main.rs index 5ba66d3..4332f20 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,6 +11,7 @@ mod api; mod app; mod assets; mod auth; +mod cache; mod config; mod database; mod db; diff --git a/src/queries/mod.rs b/src/queries/mod.rs index 202115f..a26d28f 100644 --- a/src/queries/mod.rs +++ b/src/queries/mod.rs @@ -30,9 +30,16 @@ pub enum Query { RESOURCE_COLOR_BY_USER, RESOURCE_COLOR_DELETE, RESOURCE_COLOR_INSERT, + RESOURCE_COLORS_DELETE_BY_RESOURCE, + RESOURCE_COLORS_DELETE_BY_USER, RESOURCE_EDITOR_SETTINGS_SELECT, + RESOURCE_EDITOR_SETTINGS_UPSERT, RESOURCE_EDITOR_SETTINGS_DELETE, - RESOURCE_EDITOR_SETTINGS_INSERT, + EDITOR_PREFERENCES_SELECT_PAD, + EDITOR_PREFERENCES_SELECT_NOTE, + EDITOR_PREFERENCES_UPSERT_PAD, + EDITOR_PREFERENCES_UPSERT_NOTE, + EDITOR_PREFERENCES_DELETE_BY_USER, AUTH_ACCOUNT_ACTION_BY_TOKEN, AUTH_CONSUME_ACCOUNT_ACTION, AUTH_UPDATE_EMAIL, @@ -173,9 +180,16 @@ pub const AUTH_EDITOR_COLOR_BY_USER: Query = Query::AUTH_EDITOR_COLOR_BY_USER; pub const RESOURCE_COLOR_BY_USER: Query = Query::RESOURCE_COLOR_BY_USER; pub const RESOURCE_COLOR_DELETE: Query = Query::RESOURCE_COLOR_DELETE; pub const RESOURCE_COLOR_INSERT: Query = Query::RESOURCE_COLOR_INSERT; +pub const RESOURCE_COLORS_DELETE_BY_RESOURCE: Query = Query::RESOURCE_COLORS_DELETE_BY_RESOURCE; +pub const RESOURCE_COLORS_DELETE_BY_USER: Query = Query::RESOURCE_COLORS_DELETE_BY_USER; pub const RESOURCE_EDITOR_SETTINGS_SELECT: Query = Query::RESOURCE_EDITOR_SETTINGS_SELECT; +pub const RESOURCE_EDITOR_SETTINGS_UPSERT: Query = Query::RESOURCE_EDITOR_SETTINGS_UPSERT; pub const RESOURCE_EDITOR_SETTINGS_DELETE: Query = Query::RESOURCE_EDITOR_SETTINGS_DELETE; -pub const RESOURCE_EDITOR_SETTINGS_INSERT: Query = Query::RESOURCE_EDITOR_SETTINGS_INSERT; +pub const EDITOR_PREFERENCES_SELECT_PAD: Query = Query::EDITOR_PREFERENCES_SELECT_PAD; +pub const EDITOR_PREFERENCES_SELECT_NOTE: Query = Query::EDITOR_PREFERENCES_SELECT_NOTE; +pub const EDITOR_PREFERENCES_UPSERT_PAD: Query = Query::EDITOR_PREFERENCES_UPSERT_PAD; +pub const EDITOR_PREFERENCES_UPSERT_NOTE: Query = Query::EDITOR_PREFERENCES_UPSERT_NOTE; +pub const EDITOR_PREFERENCES_DELETE_BY_USER: Query = Query::EDITOR_PREFERENCES_DELETE_BY_USER; pub const AUTH_ACCOUNT_ACTION_BY_TOKEN: Query = Query::AUTH_ACCOUNT_ACTION_BY_TOKEN; pub const AUTH_CONSUME_ACCOUNT_ACTION: Query = Query::AUTH_CONSUME_ACCOUNT_ACTION; pub const AUTH_UPDATE_EMAIL: Query = Query::AUTH_UPDATE_EMAIL; diff --git a/src/queries/mysql.rs b/src/queries/mysql.rs index 228b622..45b96b3 100644 --- a/src/queries/mysql.rs +++ b/src/queries/mysql.rs @@ -38,17 +38,39 @@ pub fn get(query: Query) -> &'static str { Query::RESOURCE_COLOR_DELETE => { r#"DELETE FROM user_resource_colors WHERE user_id = ? AND resource_kind = ? AND resource_slug = ?"# } + + Query::RESOURCE_COLOR_INSERT => { + r#"INSERT INTO user_resource_colors (user_id, resource_kind, resource_slug, color) VALUES (?, ?, ?, ?)"# + } + Query::RESOURCE_COLORS_DELETE_BY_RESOURCE => { + r#"DELETE FROM user_resource_colors WHERE resource_kind = ? AND resource_slug = ?"# + } + Query::RESOURCE_COLORS_DELETE_BY_USER => { + r#"DELETE FROM user_resource_colors WHERE user_id = ?"# + } Query::RESOURCE_EDITOR_SETTINGS_SELECT => { - r#"SELECT authorship_mode, CASE WHEN colors_enabled THEN 1 ELSE 0 END FROM resource_editor_settings WHERE resource_kind = ? AND resource_slug = ?"# + r#"SELECT CAST(authorship_mode AS CHAR CHARACTER SET utf8mb4), CAST(CASE WHEN colors_enabled THEN 1 ELSE 0 END AS SIGNED) FROM resource_editor_settings WHERE resource_kind = ? AND resource_slug = ?"# + } + Query::RESOURCE_EDITOR_SETTINGS_UPSERT => { + r#"INSERT INTO resource_editor_settings (resource_kind, resource_slug, authorship_mode, colors_enabled, updated_at) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) ON DUPLICATE KEY UPDATE authorship_mode = VALUES(authorship_mode), colors_enabled = VALUES(colors_enabled), updated_at = CURRENT_TIMESTAMP"# } Query::RESOURCE_EDITOR_SETTINGS_DELETE => { r#"DELETE FROM resource_editor_settings WHERE resource_kind = ? AND resource_slug = ?"# } - Query::RESOURCE_EDITOR_SETTINGS_INSERT => { - r#"INSERT INTO resource_editor_settings (resource_kind, resource_slug, authorship_mode, colors_enabled) VALUES (?, ?, ?, ?)"# + Query::EDITOR_PREFERENCES_SELECT_PAD => { + r#"SELECT CAST(CASE WHEN compact_view THEN 1 ELSE 0 END AS SIGNED), CAST(CASE WHEN editor_line_numbers THEN 1 ELSE 0 END AS SIGNED), CAST(CASE WHEN preview_line_numbers THEN 1 ELSE 0 END AS SIGNED), CAST(CASE WHEN line_links THEN 1 ELSE 0 END AS SIGNED), CAST(font_family AS CHAR CHARACTER SET utf8mb4), font_size FROM user_editor_preferences WHERE user_id = ? AND pad_id = ?"# } - Query::RESOURCE_COLOR_INSERT => { - r#"INSERT INTO user_resource_colors (user_id, resource_kind, resource_slug, color) VALUES (?, ?, ?, ?)"# + Query::EDITOR_PREFERENCES_SELECT_NOTE => { + r#"SELECT CAST(CASE WHEN compact_view THEN 1 ELSE 0 END AS SIGNED), CAST(CASE WHEN editor_line_numbers THEN 1 ELSE 0 END AS SIGNED), CAST(CASE WHEN preview_line_numbers THEN 1 ELSE 0 END AS SIGNED), CAST(CASE WHEN line_links THEN 1 ELSE 0 END AS SIGNED), CAST(font_family AS CHAR CHARACTER SET utf8mb4), font_size FROM user_editor_preferences WHERE user_id = ? AND note_id = ?"# + } + Query::EDITOR_PREFERENCES_UPSERT_PAD => { + r#"INSERT INTO user_editor_preferences (user_id, pad_id, note_id, compact_view, editor_line_numbers, preview_line_numbers, line_links, font_family, font_size, updated_at) VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON DUPLICATE KEY UPDATE compact_view = VALUES(compact_view), editor_line_numbers = VALUES(editor_line_numbers), preview_line_numbers = VALUES(preview_line_numbers), line_links = VALUES(line_links), font_family = VALUES(font_family), font_size = VALUES(font_size), updated_at = CURRENT_TIMESTAMP"# + } + Query::EDITOR_PREFERENCES_UPSERT_NOTE => { + r#"INSERT INTO user_editor_preferences (user_id, pad_id, note_id, compact_view, editor_line_numbers, preview_line_numbers, line_links, font_family, font_size, updated_at) VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON DUPLICATE KEY UPDATE compact_view = VALUES(compact_view), editor_line_numbers = VALUES(editor_line_numbers), preview_line_numbers = VALUES(preview_line_numbers), line_links = VALUES(line_links), font_family = VALUES(font_family), font_size = VALUES(font_size), updated_at = CURRENT_TIMESTAMP"# + } + Query::EDITOR_PREFERENCES_DELETE_BY_USER => { + r#"DELETE FROM user_editor_preferences WHERE user_id = ?"# } Query::AUTH_ACCOUNT_ACTION_BY_TOKEN => { r#"SELECT user_id, action, CAST(payload AS CHAR CHARACTER SET utf8mb4) AS payload, expires_at, used_at FROM account_action_tokens WHERE token = ?"# @@ -85,7 +107,7 @@ pub fn get(query: Query) -> &'static str { } Query::AUTH_DELETE_USER => r#"DELETE FROM users WHERE id = ?"#, Query::AUTH_ANONYMIZE_USER => { - r#"UPDATE users SET nickname = ?, nickname_key = ?, email = ?, email_key = ?, password_hash = ?, is_active = 0, auth_provider = 'deleted', external_id = NULL, external_dn = NULL, directory_display_name = NULL, directory_username = NULL, updated_at = ? WHERE id = ?"# + r#"UPDATE users SET nickname = ?, nickname_key = ?, email = ?, email_key = ?, password_hash = ?, is_active = 0, auth_provider = 'deleted', external_id = NULL, external_dn = NULL, directory_display_name = NULL, directory_username = NULL, editor_color = NULL, updated_at = ? WHERE id = ?"# } Query::AUTH_NICKNAME_BY_ID => r#"SELECT nickname FROM users WHERE id = ?"#, Query::AUTH_ANONYMIZE_NOTE_CREATORS => { diff --git a/src/queries/postgres.rs b/src/queries/postgres.rs index 4128b55..b7ef6b2 100644 --- a/src/queries/postgres.rs +++ b/src/queries/postgres.rs @@ -38,17 +38,39 @@ pub fn get(query: Query) -> &'static str { Query::RESOURCE_COLOR_DELETE => { r#"DELETE FROM user_resource_colors WHERE user_id = $1 AND resource_kind = $2 AND resource_slug = $3"# } + + Query::RESOURCE_COLOR_INSERT => { + r#"INSERT INTO user_resource_colors (user_id, resource_kind, resource_slug, color) VALUES ($1, $2, $3, $4)"# + } + Query::RESOURCE_COLORS_DELETE_BY_RESOURCE => { + r#"DELETE FROM user_resource_colors WHERE resource_kind = $1 AND resource_slug = $2"# + } + Query::RESOURCE_COLORS_DELETE_BY_USER => { + r#"DELETE FROM user_resource_colors WHERE user_id = $1"# + } Query::RESOURCE_EDITOR_SETTINGS_SELECT => { r#"SELECT authorship_mode, (CASE WHEN colors_enabled THEN 1 ELSE 0 END)::BIGINT FROM resource_editor_settings WHERE resource_kind = $1 AND resource_slug = $2"# } + Query::RESOURCE_EDITOR_SETTINGS_UPSERT => { + r#"INSERT INTO resource_editor_settings (resource_kind, resource_slug, authorship_mode, colors_enabled, updated_at) VALUES ($1, $2, $3, $4, CURRENT_TIMESTAMP) ON CONFLICT (resource_kind, resource_slug) DO UPDATE SET authorship_mode = EXCLUDED.authorship_mode, colors_enabled = EXCLUDED.colors_enabled, updated_at = CURRENT_TIMESTAMP"# + } Query::RESOURCE_EDITOR_SETTINGS_DELETE => { r#"DELETE FROM resource_editor_settings WHERE resource_kind = $1 AND resource_slug = $2"# } - Query::RESOURCE_EDITOR_SETTINGS_INSERT => { - r#"INSERT INTO resource_editor_settings (resource_kind, resource_slug, authorship_mode, colors_enabled) VALUES ($1, $2, $3, $4)"# + Query::EDITOR_PREFERENCES_SELECT_PAD => { + r#"SELECT (CASE WHEN compact_view THEN 1 ELSE 0 END)::BIGINT, (CASE WHEN editor_line_numbers THEN 1 ELSE 0 END)::BIGINT, (CASE WHEN preview_line_numbers THEN 1 ELSE 0 END)::BIGINT, (CASE WHEN line_links THEN 1 ELSE 0 END)::BIGINT, font_family, font_size FROM user_editor_preferences WHERE user_id = $1 AND pad_id = $2"# } - Query::RESOURCE_COLOR_INSERT => { - r#"INSERT INTO user_resource_colors (user_id, resource_kind, resource_slug, color) VALUES ($1, $2, $3, $4)"# + Query::EDITOR_PREFERENCES_SELECT_NOTE => { + r#"SELECT (CASE WHEN compact_view THEN 1 ELSE 0 END)::BIGINT, (CASE WHEN editor_line_numbers THEN 1 ELSE 0 END)::BIGINT, (CASE WHEN preview_line_numbers THEN 1 ELSE 0 END)::BIGINT, (CASE WHEN line_links THEN 1 ELSE 0 END)::BIGINT, font_family, font_size FROM user_editor_preferences WHERE user_id = $1 AND note_id = $2"# + } + Query::EDITOR_PREFERENCES_UPSERT_PAD => { + r#"INSERT INTO user_editor_preferences (user_id, pad_id, note_id, compact_view, editor_line_numbers, preview_line_numbers, line_links, font_family, font_size, updated_at) VALUES ($1, $2, NULL, $3, $4, $5, $6, $7, $8, CURRENT_TIMESTAMP::text) ON CONFLICT (user_id, pad_id) DO UPDATE SET compact_view = EXCLUDED.compact_view, editor_line_numbers = EXCLUDED.editor_line_numbers, preview_line_numbers = EXCLUDED.preview_line_numbers, line_links = EXCLUDED.line_links, font_family = EXCLUDED.font_family, font_size = EXCLUDED.font_size, updated_at = CURRENT_TIMESTAMP::text"# + } + Query::EDITOR_PREFERENCES_UPSERT_NOTE => { + r#"INSERT INTO user_editor_preferences (user_id, pad_id, note_id, compact_view, editor_line_numbers, preview_line_numbers, line_links, font_family, font_size, updated_at) VALUES ($1, NULL, $2, $3, $4, $5, $6, $7, $8, CURRENT_TIMESTAMP::text) ON CONFLICT (user_id, note_id) DO UPDATE SET compact_view = EXCLUDED.compact_view, editor_line_numbers = EXCLUDED.editor_line_numbers, preview_line_numbers = EXCLUDED.preview_line_numbers, line_links = EXCLUDED.line_links, font_family = EXCLUDED.font_family, font_size = EXCLUDED.font_size, updated_at = CURRENT_TIMESTAMP::text"# + } + Query::EDITOR_PREFERENCES_DELETE_BY_USER => { + r#"DELETE FROM user_editor_preferences WHERE user_id = $1"# } Query::AUTH_ACCOUNT_ACTION_BY_TOKEN => { r#"SELECT user_id, action, payload, expires_at, used_at FROM account_action_tokens WHERE token = $1"# @@ -85,7 +107,7 @@ pub fn get(query: Query) -> &'static str { } Query::AUTH_DELETE_USER => r#"DELETE FROM users WHERE id = $1"#, Query::AUTH_ANONYMIZE_USER => { - r#"UPDATE users SET nickname = $1, nickname_key = $2, email = $3, email_key = $4, password_hash = $5, is_active = FALSE, auth_provider = 'deleted', external_id = NULL, external_dn = NULL, directory_display_name = NULL, directory_username = NULL, updated_at = $6 WHERE id = $7"# + r#"UPDATE users SET nickname = $1, nickname_key = $2, email = $3, email_key = $4, password_hash = $5, is_active = FALSE, auth_provider = 'deleted', external_id = NULL, external_dn = NULL, directory_display_name = NULL, directory_username = NULL, editor_color = NULL, updated_at = $6 WHERE id = $7"# } Query::AUTH_NICKNAME_BY_ID => r#"SELECT nickname FROM users WHERE id = $1"#, Query::AUTH_ANONYMIZE_NOTE_CREATORS => { diff --git a/src/queries/sqlite.rs b/src/queries/sqlite.rs index 91b8530..b3d4156 100644 --- a/src/queries/sqlite.rs +++ b/src/queries/sqlite.rs @@ -38,17 +38,39 @@ pub fn get(query: Query) -> &'static str { Query::RESOURCE_COLOR_DELETE => { r#"DELETE FROM user_resource_colors WHERE user_id = ? AND resource_kind = ? AND resource_slug = ?"# } + + Query::RESOURCE_COLOR_INSERT => { + r#"INSERT INTO user_resource_colors (user_id, resource_kind, resource_slug, color) VALUES (?, ?, ?, ?)"# + } + Query::RESOURCE_COLORS_DELETE_BY_RESOURCE => { + r#"DELETE FROM user_resource_colors WHERE resource_kind = ? AND resource_slug = ?"# + } + Query::RESOURCE_COLORS_DELETE_BY_USER => { + r#"DELETE FROM user_resource_colors WHERE user_id = ?"# + } Query::RESOURCE_EDITOR_SETTINGS_SELECT => { r#"SELECT authorship_mode, CASE WHEN colors_enabled THEN 1 ELSE 0 END FROM resource_editor_settings WHERE resource_kind = ? AND resource_slug = ?"# } + Query::RESOURCE_EDITOR_SETTINGS_UPSERT => { + r#"INSERT INTO resource_editor_settings (resource_kind, resource_slug, authorship_mode, colors_enabled, updated_at) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT (resource_kind, resource_slug) DO UPDATE SET authorship_mode = excluded.authorship_mode, colors_enabled = excluded.colors_enabled, updated_at = CURRENT_TIMESTAMP"# + } Query::RESOURCE_EDITOR_SETTINGS_DELETE => { r#"DELETE FROM resource_editor_settings WHERE resource_kind = ? AND resource_slug = ?"# } - Query::RESOURCE_EDITOR_SETTINGS_INSERT => { - r#"INSERT INTO resource_editor_settings (resource_kind, resource_slug, authorship_mode, colors_enabled) VALUES (?, ?, ?, ?)"# + Query::EDITOR_PREFERENCES_SELECT_PAD => { + r#"SELECT CASE WHEN compact_view THEN 1 ELSE 0 END, CASE WHEN editor_line_numbers THEN 1 ELSE 0 END, CASE WHEN preview_line_numbers THEN 1 ELSE 0 END, CASE WHEN line_links THEN 1 ELSE 0 END, font_family, font_size FROM user_editor_preferences WHERE user_id = ? AND pad_id = ?"# } - Query::RESOURCE_COLOR_INSERT => { - r#"INSERT INTO user_resource_colors (user_id, resource_kind, resource_slug, color) VALUES (?, ?, ?, ?)"# + Query::EDITOR_PREFERENCES_SELECT_NOTE => { + r#"SELECT CASE WHEN compact_view THEN 1 ELSE 0 END, CASE WHEN editor_line_numbers THEN 1 ELSE 0 END, CASE WHEN preview_line_numbers THEN 1 ELSE 0 END, CASE WHEN line_links THEN 1 ELSE 0 END, font_family, font_size FROM user_editor_preferences WHERE user_id = ? AND note_id = ?"# + } + Query::EDITOR_PREFERENCES_UPSERT_PAD => { + r#"INSERT INTO user_editor_preferences (user_id, pad_id, note_id, compact_view, editor_line_numbers, preview_line_numbers, line_links, font_family, font_size, updated_at) VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT (user_id, pad_id) DO UPDATE SET compact_view = excluded.compact_view, editor_line_numbers = excluded.editor_line_numbers, preview_line_numbers = excluded.preview_line_numbers, line_links = excluded.line_links, font_family = excluded.font_family, font_size = excluded.font_size, updated_at = CURRENT_TIMESTAMP"# + } + Query::EDITOR_PREFERENCES_UPSERT_NOTE => { + r#"INSERT INTO user_editor_preferences (user_id, pad_id, note_id, compact_view, editor_line_numbers, preview_line_numbers, line_links, font_family, font_size, updated_at) VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT (user_id, note_id) DO UPDATE SET compact_view = excluded.compact_view, editor_line_numbers = excluded.editor_line_numbers, preview_line_numbers = excluded.preview_line_numbers, line_links = excluded.line_links, font_family = excluded.font_family, font_size = excluded.font_size, updated_at = CURRENT_TIMESTAMP"# + } + Query::EDITOR_PREFERENCES_DELETE_BY_USER => { + r#"DELETE FROM user_editor_preferences WHERE user_id = ?"# } Query::AUTH_ACCOUNT_ACTION_BY_TOKEN => { r#"SELECT user_id, action, payload, expires_at, used_at FROM account_action_tokens WHERE token = ?"# @@ -85,7 +107,7 @@ pub fn get(query: Query) -> &'static str { } Query::AUTH_DELETE_USER => r#"DELETE FROM users WHERE id = ?"#, Query::AUTH_ANONYMIZE_USER => { - r#"UPDATE users SET nickname = ?, nickname_key = ?, email = ?, email_key = ?, password_hash = ?, is_active = 0, auth_provider = 'deleted', external_id = NULL, external_dn = NULL, directory_display_name = NULL, directory_username = NULL, updated_at = ? WHERE id = ?"# + r#"UPDATE users SET nickname = ?, nickname_key = ?, email = ?, email_key = ?, password_hash = ?, is_active = 0, auth_provider = 'deleted', external_id = NULL, external_dn = NULL, directory_display_name = NULL, directory_username = NULL, editor_color = NULL, updated_at = ? WHERE id = ?"# } Query::AUTH_NICKNAME_BY_ID => r#"SELECT nickname FROM users WHERE id = ?"#, Query::AUTH_ANONYMIZE_NOTE_CREATORS => { diff --git a/static/css/styles.css b/static/css/styles.css index 337820c..ccd5fb8 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -300,6 +300,18 @@ textarea:focus { min-width: 0; } +.document-heading--copy { + border-radius: 6px; + cursor: copy; + outline: none; +} + +.document-heading--copy:hover, +.document-heading--copy:focus-visible { + background: color-mix(in srgb, var(--accent) 10%, transparent); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 10%, transparent); +} + .document-heading h1 { overflow: hidden; margin: 0; @@ -4532,11 +4544,22 @@ dialog::backdrop { gap: 5px; max-width: calc(100vw - 20px); padding: 5px; + isolation: isolate; border: 1px solid rgba(255, 255, 255, .12); border-radius: 999px; - background: rgba(17, 21, 28, .88); + background: transparent; box-shadow: 0 8px 24px rgba(0, 0, 0, .28); + } + + .pad-page .mobile-editor-bubble::before { + position: absolute; + z-index: -1; + inset: 0; + border-radius: inherit; + background: rgba(17, 21, 28, .88); backdrop-filter: blur(10px); + content: ""; + pointer-events: none; } .mobile-editor-bubble>button, @@ -5059,6 +5082,12 @@ dialog::backdrop { flex-wrap: wrap; } +.authorship-controls button:disabled, +.authorship-controls input:disabled+.switch-control__track { + cursor: not-allowed; + opacity: .55; +} + .switch-control { display: inline-flex; align-items: center; @@ -5107,17 +5136,6 @@ dialog::backdrop { background: var(--accent); } -.editor-settings-save { - min-height: 22px; - height: 22px; - padding: 0 8px; - font-size: 11px; -} - -.editor-settings-save:disabled { - opacity: .45; - cursor: not-allowed; -} .share-link-row .share-link-info { margin-top: 8px; @@ -5320,6 +5338,7 @@ dialog::backdrop { width: auto; } } + /* Optional links to exact editor lines. */ .line-number-button { display: block; @@ -5357,3 +5376,81 @@ dialog::backdrop { background: color-mix(in srgb, var(--success) 18%, transparent); color: var(--text); } + +/* Personal editor preferences in the compact mobile action bar. */ +@media (max-width: 1499px) { + .mobile-editor-options { + position: relative; + flex: 0 0 auto; + } + + .mobile-editor-options>summary { + display: inline-grid; + width: 34px; + height: 34px; + padding: 0; + place-items: center; + border: 0; + border-radius: 50%; + background: rgba(255, 255, 255, .07); + color: var(--text); + cursor: pointer; + list-style: none; + } + + .mobile-editor-options>summary::-webkit-details-marker { + display: none; + } + + .mobile-editor-options[open]>summary { + background: color-mix(in srgb, var(--accent) 24%, rgba(255, 255, 255, .07)); + } + + .mobile-editor-options__panel { + position: fixed; + right: 12px; + bottom: calc(64px + env(safe-area-inset-bottom, 0px)); + left: auto; + display: grid; + width: min(360px, calc(100vw - 24px)); + max-height: calc(100dvh - 88px); + gap: 5px; + padding: 12px; + overflow-y: auto; + border: 1px solid var(--border-strong); + border-radius: 10px; + background: var(--surface-strong, #11161e); + box-shadow: 0 16px 40px rgb(0 0 0 / 45%); + } + + .mobile-editor-options__panel>label:not(.mobile-option-check) { + display: grid; + grid-template-columns: 70px minmax(0, 1fr); + align-items: center; + gap: 10px; + color: var(--muted); + font-size: .78rem; + } + + .mobile-editor-options__panel select { + width: 100%; + min-height: 34px; + } + + .mobile-option-check { + display: flex; + min-height: 24px; + align-items: center; + gap: 7px; + color: var(--text); + font-size: .8rem; + line-height: 1.2; + } + + .mobile-option-check input { + width: 17px; + height: 17px; + margin: 0; + } + +} \ No newline at end of file diff --git a/static/pad.html b/static/editor.html similarity index 85% rename from static/pad.html rename to static/editor.html index de36a39..e1b6216 100644 --- a/static/pad.html +++ b/static/editor.html @@ -5,19 +5,20 @@ - __PAD_TITLE__ · RustPad + __DOCUMENT_TITLE__ · __PARENT_TITLE__ __APP_STYLESHEET__ __APP_IMPORT_MAP__ __APP_ENTRYPOINT__ - +
-
RustPad -
-

__PAD_TITLE__

-

+
__PARENT_TITLE__ +
-
@@ -119,14 +121,12 @@
Editor
+ type="button" data-authorship-mode="simple" class="active" disabled>Simple
@@ -193,8 +193,7 @@
Ctrl/Cmd+ZUndoCtrl/Cmd+BBoldCtrl/Cmd+IItalicCtrl/Cmd+Shift+XStrikethroughCtrl/Cmd+KLinkCtrl/Cmd+Shift+7Numbered listCtrl/Cmd+Shift+8Bullet listCtrl/Cmd+Shift+9Task - listAlt+1…4Headings H1–H4Alt+EnterNew line while - editing PreviewEscEdit raw Markdown of current Preview line + listAlt+1…4Headings H1–H4__EXTRA_SHORTCUTS__
@@ -219,9 +218,9 @@ guest