diff --git a/Cargo.lock b/Cargo.lock index 313db91..109485e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2581,7 +2581,7 @@ dependencies = [ [[package]] name = "rustpad" -version = "0.2.86" +version = "0.2.87" dependencies = [ "argon2", "aws-config", diff --git a/Cargo.toml b/Cargo.toml index 05f5f46..7ab8bcb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustpad" -version = "0.2.86" +version = "0.2.87" 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 2bbcce8..93214de 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,10 @@ The selected interface language is stored in the browser and can be changed from - History with snippets, previews, and version restore. - Alert blocks: `success`, `info`, `warning`, and `danger`. - Table of contents generated with `[TOC]`, using headings after the marker and nesting them by level. +- Permalinks to Markdown headings, including custom heading IDs and duplicate heading names. +- Inline `@nickname` colored mentions for active account owners and users directly granted access to a note/pad. +- Mentioned users are listed below the participant/author badges with a count of matching mentions; repeated clicks jump to the next occurrence and cycle through them in editor, split, or preview views. +- Indented fenced code blocks shift their whole preview block rather than just the code text. - Optional line numbers in fenced code blocks. - PDF export with selectable A5/A4/A3, Letter, 16:9, and 4:3 page formats; optional color/background preservation keeps syntax highlighting, alerts, quotes, highlights, and table styling; `[PAGEBREAK]` creates an explicit hard PDF page break while `---` remains a normal Markdown horizontal rule. @@ -103,6 +107,25 @@ print("Hello") The `=number` suffix is a RustPad extension and may not be supported by other Markdown renderers. +Indent the opening and closing backtick fences and their content to move the entire displayed code block right by the same number of columns; RustPad strips the common indentation from the code itself. + +## Heading links and member mentions + +In Markdown preview, each heading includes a `#` link. Use regular Markdown links to jump to the section, for example: + +```markdown +- [Run the upgrade](#run-the-upgrade) + +## Run the upgrade +Step-by-step instructions here. +``` + +Heading IDs are created from the heading text (for example, `## Run the upgrade` becomes `#run-the-upgrade`). Repeated headings receive `-2`, `-3`, etc. You can also specify a stable ID with `## Instructions {#upgrade-steps}` and link to it using `[Instructions](#upgrade-steps)`. Opening a heading link scrolls the preview to that heading or the editor to its source line when in edit view. The existing `[TOC]` also links to these anchors. + +A typed `@nickname` is shown with a colored badge only when the nickname belongs to an active registered owner or user with an accepted direct share of this pad or workspace. The list is retrieved only for logged-in owners and directly shared accounts; guests and users without a direct grant do not receive a member directory. In the Markdown source editor, typing `@` or a partial nickname such as `@do` opens an autocomplete menu of these authorized members. Use Up/Down to choose, Enter or Tab to insert the complete `@nickname`, Esc to dismiss, or click/tap a name. The list is refreshed after login or a resource reload and is never shown to guests. Mentions are visual only and do not send notifications. + +The **Mentioned** row below the author/participant badges lists the recognized users currently mentioned in the note, with counts. Click `@nickname` to jump to the first mention, then click it again to cycle through the next occurrences (returning to the start). The editor selects the mention, the preview highlights it, and collapsed Markdown details sections open for a matching mention. Mentions in fenced/inline code, Markdown links, URLs, and emails are ignored. Only members returned by the resource access API are shown; the row disappears when none are mentioned or access is lost. + ## Data - SQLite database: `data/db/rustpad.db` diff --git a/lang/en.json b/lang/en.json index f243532..d009f0b 100644 --- a/lang/en.json +++ b/lang/en.json @@ -466,6 +466,9 @@ "editor.markdown": "Markdown", "editor.markdownPreview": "Markdown preview", "editor.mermaid": "Mermaid diagram", + "editor.mentionMembers": "Mention an authorized member", + "editor.mentionedUsers": "Mentioned", + "editor.mentionJump": "@{name}: go to mention {position} of {count}", "editor.minimum8": "Minimum 8 characters.", "editor.missedMerged": "A missed update was merged with your local edits.", "editor.mono": "Mono", @@ -697,6 +700,7 @@ "markdown.download": "Download {label}", "markdown.open": "Open {title}", "markdown.playbackUnavailable": "Playback is unavailable.", + "markdown.headingPermalink": "Link to this heading", "markdown.toc": "Table of contents", "markdown.youtubeVideo": "YouTube video", "pagination.items": "Page {page} of {pages} · {count} items", @@ -1011,4 +1015,4 @@ "files.link": "Link", "files.markdown": "Markdown" } -} +} \ No newline at end of file diff --git a/lang/pl.json b/lang/pl.json index a4de065..21e4f16 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -466,6 +466,9 @@ "editor.markdown": "Markdown", "editor.markdownPreview": "Podgląd Markdown", "editor.mermaid": "Diagram Mermaid", + "editor.mentionMembers": "Wspomnij użytkownika z dostępem", + "editor.mentionedUsers": "Wspomniani", + "editor.mentionJump": "@{name}: przejdź do wzmianki {position} z {count}", "editor.minimum8": "Minimum 8 znaków.", "editor.missedMerged": "Pominięta aktualizacja została scalona z lokalnymi zmianami.", "editor.mono": "Monospace", @@ -697,6 +700,7 @@ "markdown.download": "Pobierz {label}", "markdown.open": "Otwórz {title}", "markdown.playbackUnavailable": "Odtwarzanie jest niedostępne.", + "markdown.headingPermalink": "Link do nagłówka", "markdown.toc": "Spis treści", "markdown.youtubeVideo": "Film YouTube", "pagination.items": "Strona {page} z {pages} · elementów: {count}", @@ -1011,4 +1015,4 @@ "files.link": "Link", "files.markdown": "Markdown" } -} +} \ No newline at end of file diff --git a/src/api/favorites.rs b/src/api/favorites.rs index fbbc7e7..cb449ee 100644 --- a/src/api/favorites.rs +++ b/src/api/favorites.rs @@ -288,13 +288,8 @@ pub async fn list_favorites( for note in notes { if (note.private_resource != 0 || note.protected_resource != 0) - && !has_header_resource_access( - &state, - &headers, - "workspace", - ¬e.workspace_slug, - ) - .await? + && !has_header_resource_access(&state, &headers, "workspace", ¬e.workspace_slug) + .await? { continue; } @@ -389,11 +384,7 @@ mod tests { let mut headers = HeaderMap::new(); headers.insert( header::COOKIE, - HeaderValue::from_str(&format!( - "{}={token}", - crate::security::SESSION_COOKIE - )) - .unwrap(), + HeaderValue::from_str(&format!("{}={token}", crate::security::SESSION_COOKIE)).unwrap(), ); (headers, user_id) } @@ -412,13 +403,9 @@ mod tests { }; for _ in 0..2 { - let result = add_favorite( - State(state.clone()), - headers.clone(), - Json(target()), - ) - .await - .unwrap(); + let result = add_favorite(State(state.clone()), headers.clone(), Json(target())) + .await + .unwrap(); assert!(result.0.favorite); } @@ -828,13 +815,9 @@ mod tests { workspace_slug: None, }; - let anonymous = add_favorite( - State(state.clone()), - HeaderMap::new(), - Json(target()), - ) - .await - .expect_err("anonymous favorite must fail"); + let anonymous = add_favorite(State(state.clone()), HeaderMap::new(), Json(target())) + .await + .expect_err("anonymous favorite must fail"); assert_eq!(anonymous.into_response().status(), StatusCode::FORBIDDEN); let (inactive_headers, user_id) = logged_in_headers(&state, "inactive").await; @@ -843,13 +826,9 @@ mod tests { .execute(state.db.pool()) .await .unwrap(); - let inactive = add_favorite( - State(state), - inactive_headers, - Json(target()), - ) - .await - .expect_err("inactive account favorite must fail"); + let inactive = add_favorite(State(state), inactive_headers, Json(target())) + .await + .expect_err("inactive account favorite must fail"); assert_eq!(inactive.into_response().status(), StatusCode::FORBIDDEN); } diff --git a/src/app/mod.rs b/src/app/mod.rs index 67b8ae1..1271921 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -125,6 +125,7 @@ pub fn router( "/api/auth/resources/privacy", post(auth::set_resource_privacy), ) + .route("/api/auth/resources/mentions", get(auth::resource_mentions)) .route( "/api/auth/resources/sharing", get(auth::resource_sharing) @@ -261,7 +262,6 @@ pub fn router( .with_state(state) } - async fn language_catalog() -> Response { let mut response = Json(crate::i18n::metadata()).into_response(); response.headers_mut().insert( @@ -375,7 +375,10 @@ fn is_file_path(path: &str) -> bool { } fn is_asset_path(path: &str) -> bool { - path == "/assets" || path.starts_with("/assets/") || path == "/lang" || path.starts_with("/lang/") + path == "/assets" + || path.starts_with("/assets/") + || path == "/lang" + || path.starts_with("/lang/") } fn is_icon_path(path: &str) -> bool { diff --git a/src/app/pages.rs b/src/app/pages.rs index 4e42d3e..1aa6484 100644 --- a/src/app/pages.rs +++ b/src/app/pages.rs @@ -81,27 +81,21 @@ async fn share_session_redirect( ) -> Response { let client_key = crate::security::client_key(headers); let cookie = match share.map(str::trim).filter(|value| !value.is_empty()) { - Some(share) => match auth::create_share_session( - state, - kind, - slug, - share, - &client_key, - ) - .await - { - Ok(Some(session)) => Some(crate::security::share_session_cookie( - kind, - slug, - &session.token, - session.max_age_seconds, - )), - Ok(None) => None, - Err(error) => { - tracing::warn!(error = %error.message, kind, slug, "failed to exchange share link for guest session"); - None + Some(share) => { + match auth::create_share_session(state, kind, slug, share, &client_key).await { + Ok(Some(session)) => Some(crate::security::share_session_cookie( + kind, + slug, + &session.token, + session.max_age_seconds, + )), + Ok(None) => None, + Err(error) => { + tracing::warn!(error = %error.message, kind, slug, "failed to exchange share link for guest session"); + None + } } - }, + } None => None, }; @@ -118,10 +112,9 @@ async fn share_session_redirect( header::CACHE_CONTROL, HeaderValue::from_static("no-store, max-age=0"), ); - response.headers_mut().insert( - "referrer-policy", - HeaderValue::from_static("no-referrer"), - ); + response + .headers_mut() + .insert("referrer-policy", HeaderValue::from_static("no-referrer")); response } @@ -383,7 +376,11 @@ pub(super) async fn workspace( ) .replace( "__WORKSPACE_TITLE_I18N__", - if workspace.is_private != 0 { r#"data-i18n="common.workspace""# } else { "" }, + if workspace.is_private != 0 { + r#"data-i18n="common.workspace""# + } else { + "" + }, ); assets::render_html( &html, @@ -460,7 +457,11 @@ pub(super) async fn note( } else { ¬e.title }, - if workspace.is_private != 0 { Some("common.note") } else { None }, + if workspace.is_private != 0 { + Some("common.note") + } else { + None + }, if workspace.is_private != 0 { "Workspace" } else { @@ -548,7 +549,11 @@ pub(super) fn error_response( let html = include_str!("../../static/error.html") .replace( "__APP_THEME_BOOTSTRAP__", - &format!("{}{}", assets::theme_bootstrap(), assets::language_bootstrap()), + &format!( + "{}{}", + assets::theme_bootstrap(), + assets::language_bootstrap() + ), ) .replace( "__APP_STYLESHEET__", diff --git a/src/assets.rs b/src/assets.rs index aff7bea..5013996 100644 --- a/src/assets.rs +++ b/src/assets.rs @@ -31,6 +31,8 @@ const MODULES: &[&str] = &[ "logger", "line-links", "markdown", + "mention-autocomplete", + "mention-navigation", "modal", "note-api", "note-editor", diff --git a/src/auth/mod.rs b/src/auth/mod.rs index 7b1503c..057d705 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -1791,6 +1791,69 @@ pub async fn remove_resource_user( Ok(Json(serde_json::json!({"ok":true}))) } +/// Return only explicitly authorized account names; never expose e-mail addresses. +/// The requester must themselves own the resource or have an accepted direct share. +pub async fn resource_mentions( + State(state): State, + headers: HeaderMap, + Query(params): Query>, +) -> Result, AuthError> { + let user = require_user(&state, &headers).await?; + let kind = params.get("kind").map(String::as_str).unwrap_or(""); + let slug = params.get("slug").map(String::as_str).unwrap_or("").trim(); + let (query, owner_query) = match kind { + "workspace" => ( + queries::RESOURCE_MENTION_USERS_WORKSPACE, + queries::USER_OWNS_WORKSPACE, + ), + "pad" => (queries::RESOURCE_MENTION_USERS_PAD, queries::USER_OWNS_PAD), + _ => return Err(AuthError::bad_request("Unknown resource type.")), + }; + if slug.is_empty() { + return Err(AuthError::bad_request("Missing slug.")); + } + let owned: i64 = sqlx::query_scalar(queries::get(state.db.kind(), owner_query)) + .bind(user.id) + .bind(slug) + .fetch_one(state.db.pool()) + .await + .map_err(AuthError::database)?; + if owned == 0 { + let access: Option = sqlx::query_scalar(queries::get( + state.db.kind(), + queries::RESOURCE_PERMISSION_BY_USER, + )) + .bind(kind) + .bind(slug) + .bind(user.id) + .fetch_optional(state.db.pool()) + .await + .map_err(AuthError::database)?; + if access.is_none() { + return Err(AuthError::forbidden( + "This resource was not shared with your account.", + )); + } + } + let rows = sqlx::query(queries::get(state.db.kind(), query)) + .bind(slug) + .bind(kind) + .bind(slug) + .fetch_all(state.db.pool()) + .await + .map_err(AuthError::database)?; + let users: Vec<_> = rows + .iter() + .map(|row| -> Result { + Ok(serde_json::json!({ + "nickname": crate::row_decode::text(row, 0).map_err(AuthError::database)?, + "color": crate::row_decode::optional_text(row, 1).map_err(AuthError::database)?, + })) + }) + .collect::>()?; + Ok(Json(serde_json::json!({ "users": users }))) +} + pub async fn resource_sharing( State(state): State, headers: HeaderMap, @@ -2262,12 +2325,7 @@ pub async fn logout( let mut revoked_access_tokens = 0_u64; if session_token.is_some() || has_access_tokens { - let mut tx = state - .db - .pool() - .begin() - .await - .map_err(AuthError::database)?; + let mut tx = state.db.pool().begin().await.map_err(AuthError::database)?; if let Some(token) = session_token.as_deref() { let result = sqlx::query(queries::get( diff --git a/src/db/mod.rs b/src/db/mod.rs index fc97b45..0fb69bf 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -76,8 +76,6 @@ pub struct Note { #[derive(Debug, Clone, Serialize)] pub struct ArchivedNote { - #[serde(skip_serializing)] - pub id: i64, pub slug: String, pub title: String, pub created_at: String, @@ -345,20 +343,20 @@ pub async fn list_archived_notes( ) -> Result, sqlx::Error> { let query = match pool.kind() { DatabaseKind::Sqlite => { - r#"SELECT id, slug, title, created_at, updated_at, deleted_at, protected, created_by + r#"SELECT slug, title, created_at, updated_at, deleted_at, protected, created_by FROM notes WHERE workspace_id = ? AND deleted_at IS NOT NULL ORDER BY deleted_at DESC, id DESC"# } DatabaseKind::Postgres => { - r#"SELECT id, slug, title, created_at, updated_at, deleted_at, + r#"SELECT slug, title, created_at, updated_at, deleted_at, CAST(CASE WHEN protected THEN 1 ELSE 0 END AS BIGINT) AS protected, created_by FROM notes WHERE workspace_id = $1 AND deleted_at IS NOT NULL ORDER BY deleted_at DESC, id DESC"# } DatabaseKind::MySql => { - r#"SELECT id, CAST(slug AS CHAR CHARACTER SET utf8mb4) AS slug, + r#"SELECT CAST(slug AS CHAR CHARACTER SET utf8mb4) AS slug, CAST(title AS CHAR CHARACTER SET utf8mb4) AS title, created_at, updated_at, deleted_at, CAST(CASE WHEN protected THEN 1 ELSE 0 END AS SIGNED) AS protected, CAST(created_by AS CHAR CHARACTER SET utf8mb4) AS created_by @@ -374,7 +372,6 @@ pub async fn list_archived_notes( rows.into_iter() .map(|row| { Ok(ArchivedNote { - id: row.try_get("id")?, slug: crate::row_decode::text(&row, "slug")?, title: crate::row_decode::text(&row, "title")?, created_at: crate::row_decode::text(&row, "created_at")?, diff --git a/src/queries/mod.rs b/src/queries/mod.rs index 7c045ae..c77de85 100644 --- a/src/queries/mod.rs +++ b/src/queries/mod.rs @@ -114,6 +114,8 @@ pub enum Query { SHARE_INVITATION_FIND_TOKEN, SHARE_INVITATION_ACCEPT, RESOURCE_SHARING_USERS, + RESOURCE_MENTION_USERS_WORKSPACE, + RESOURCE_MENTION_USERS_PAD, RESOURCE_SHARING_LINKS, RESOURCE_SHARING_PENDING, SHARE_LINK_INSERT, @@ -296,6 +298,8 @@ pub const SHARE_INVITATION_DELETE_TOKEN: Query = Query::SHARE_INVITATION_DELETE_ pub const SHARE_INVITATION_FIND_TOKEN: Query = Query::SHARE_INVITATION_FIND_TOKEN; pub const SHARE_INVITATION_ACCEPT: Query = Query::SHARE_INVITATION_ACCEPT; pub const RESOURCE_SHARING_USERS: Query = Query::RESOURCE_SHARING_USERS; +pub const RESOURCE_MENTION_USERS_WORKSPACE: Query = Query::RESOURCE_MENTION_USERS_WORKSPACE; +pub const RESOURCE_MENTION_USERS_PAD: Query = Query::RESOURCE_MENTION_USERS_PAD; pub const RESOURCE_SHARING_LINKS: Query = Query::RESOURCE_SHARING_LINKS; pub const RESOURCE_SHARING_PENDING: Query = Query::RESOURCE_SHARING_PENDING; pub const SHARE_LINK_INSERT: Query = Query::SHARE_LINK_INSERT; diff --git a/src/queries/mysql.rs b/src/queries/mysql.rs index e0cc8d1..617cce5 100644 --- a/src/queries/mysql.rs +++ b/src/queries/mysql.rs @@ -32,7 +32,9 @@ pub fn get(query: Query) -> &'static str { r#"UPDATE users SET editor_color = ?, updated_at = ? WHERE id = ?"# } Query::AUTH_UPDATE_THEME => r#"UPDATE users SET theme = ?, updated_at = ? WHERE id = ?"#, - Query::AUTH_UPDATE_LANGUAGE => r#"UPDATE users SET language = ?, updated_at = ? WHERE id = ?"#, + Query::AUTH_UPDATE_LANGUAGE => { + r#"UPDATE users SET language = ?, updated_at = ? WHERE id = ?"# + } Query::AUTH_EDITOR_COLOR_BY_USER => r#"SELECT editor_color FROM users WHERE id = ?"#, Query::RESOURCE_COLOR_BY_USER => { r#"SELECT color FROM user_resource_colors WHERE user_id = ? AND resource_kind = ? AND resource_slug = ?"# @@ -276,6 +278,12 @@ pub fn get(query: Query) -> &'static str { Query::RESOURCE_SHARING_USERS => { r#"SELECT u.email, u.nickname, rp.permission FROM resource_permissions rp JOIN users u ON u.id = rp.user_id WHERE rp.resource_kind = ? AND rp.resource_slug = ? ORDER BY u.email"# } + Query::RESOURCE_MENTION_USERS_WORKSPACE => { + r#"SELECT u.nickname, u.editor_color FROM users u WHERE u.is_active = 1 AND (EXISTS (SELECT 1 FROM user_workspaces own JOIN workspaces r ON r.id = own.workspace_id WHERE r.slug = ? AND own.user_id = u.id) OR EXISTS (SELECT 1 FROM resource_permissions rp WHERE rp.resource_kind = ? AND rp.resource_slug = ? AND rp.user_id = u.id)) ORDER BY LOWER(u.nickname), u.nickname"# + } + Query::RESOURCE_MENTION_USERS_PAD => { + r#"SELECT u.nickname, u.editor_color FROM users u WHERE u.is_active = 1 AND (EXISTS (SELECT 1 FROM user_pads own JOIN pads r ON r.id = own.pad_id WHERE r.slug = ? AND own.user_id = u.id) OR EXISTS (SELECT 1 FROM resource_permissions rp WHERE rp.resource_kind = ? AND rp.resource_slug = ? AND rp.user_id = u.id)) ORDER BY LOWER(u.nickname), u.nickname"# + } Query::RESOURCE_SHARING_LINKS => { r#"SELECT token_hash, CAST(label AS CHAR CHARACTER SET utf8mb4) AS label, permission, CAST(expires_at AS CHAR CHARACTER SET utf8mb4) AS expires_at, CAST(created_at AS CHAR CHARACTER SET utf8mb4) AS created_at FROM resource_share_links WHERE resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL ORDER BY created_at DESC"# } @@ -322,7 +330,9 @@ pub fn get(query: Query) -> &'static str { Query::Q001 => { r#"SELECT id, slug, CAST(title AS CHAR CHARACTER SET utf8mb4) AS title, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, created_at, updated_at, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS SIGNED) AS is_private, CAST(CASE WHEN archive_deleted_notes THEN 1 ELSE 0 END AS SIGNED) AS archive_deleted_notes, CAST(created_by_guest_id AS CHAR CHARACTER SET utf8mb4) AS created_by_guest_id FROM workspaces WHERE slug = ?"# } - Query::Q002 => r#"INSERT INTO workspaces (slug, title, password_hash, created_by_guest_id) VALUES (?, ?, ?, ?)"#, + Query::Q002 => { + r#"INSERT INTO workspaces (slug, title, password_hash, created_by_guest_id) VALUES (?, ?, ?, ?)"# + } Query::Q003 => { r#"SELECT id, workspace_id, slug, CAST(title AS CHAR CHARACTER SET utf8mb4) AS title, CAST(content AS CHAR CHARACTER SET utf8mb4) AS content, created_at, updated_at, CAST(owner_map AS CHAR CHARACTER SET utf8mb4) AS owner_map, CAST(CASE WHEN protected THEN 1 ELSE 0 END AS SIGNED) AS protected, CAST(created_by AS CHAR CHARACTER SET utf8mb4) AS created_by, CAST(created_by_guest_id AS CHAR CHARACTER SET utf8mb4) AS created_by_guest_id FROM notes WHERE workspace_id = ? AND deleted_at IS NULL ORDER BY updated_at DESC, id DESC"# } diff --git a/src/queries/postgres.rs b/src/queries/postgres.rs index 08d07c7..97898bf 100644 --- a/src/queries/postgres.rs +++ b/src/queries/postgres.rs @@ -32,7 +32,9 @@ pub fn get(query: Query) -> &'static str { r#"UPDATE users SET editor_color = $1, updated_at = $2 WHERE id = $3"# } Query::AUTH_UPDATE_THEME => r#"UPDATE users SET theme = $1, updated_at = $2 WHERE id = $3"#, - Query::AUTH_UPDATE_LANGUAGE => r#"UPDATE users SET language = $1, updated_at = $2 WHERE id = $3"#, + Query::AUTH_UPDATE_LANGUAGE => { + r#"UPDATE users SET language = $1, updated_at = $2 WHERE id = $3"# + } Query::AUTH_EDITOR_COLOR_BY_USER => r#"SELECT editor_color FROM users WHERE id = $1"#, Query::RESOURCE_COLOR_BY_USER => { r#"SELECT color FROM user_resource_colors WHERE user_id = $1 AND resource_kind = $2 AND resource_slug = $3"# @@ -278,6 +280,12 @@ pub fn get(query: Query) -> &'static str { Query::RESOURCE_SHARING_USERS => { r#"SELECT u.email, u.nickname, rp.permission FROM resource_permissions rp JOIN users u ON u.id = rp.user_id WHERE rp.resource_kind = $1 AND rp.resource_slug = $2 ORDER BY u.email"# } + Query::RESOURCE_MENTION_USERS_WORKSPACE => { + r#"SELECT u.nickname, u.editor_color FROM users u WHERE u.is_active = TRUE AND (EXISTS (SELECT 1 FROM user_workspaces own JOIN workspaces r ON r.id = own.workspace_id WHERE r.slug = $1 AND own.user_id = u.id) OR EXISTS (SELECT 1 FROM resource_permissions rp WHERE rp.resource_kind = $2 AND rp.resource_slug = $3 AND rp.user_id = u.id)) ORDER BY LOWER(u.nickname), u.nickname"# + } + Query::RESOURCE_MENTION_USERS_PAD => { + r#"SELECT u.nickname, u.editor_color FROM users u WHERE u.is_active = TRUE AND (EXISTS (SELECT 1 FROM user_pads own JOIN pads r ON r.id = own.pad_id WHERE r.slug = $1 AND own.user_id = u.id) OR EXISTS (SELECT 1 FROM resource_permissions rp WHERE rp.resource_kind = $2 AND rp.resource_slug = $3 AND rp.user_id = u.id)) ORDER BY LOWER(u.nickname), u.nickname"# + } Query::RESOURCE_SHARING_LINKS => { r#"SELECT token_hash, label, permission, expires_at, created_at FROM resource_share_links WHERE resource_kind = $1 AND resource_slug = $2 AND revoked_at IS NULL ORDER BY created_at DESC"# } @@ -322,7 +330,9 @@ pub fn get(query: Query) -> &'static str { Query::Q001 => { r#"SELECT id, slug, title, password_hash, created_at, updated_at, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS BIGINT) AS is_private, CAST(CASE WHEN archive_deleted_notes THEN 1 ELSE 0 END AS BIGINT) AS archive_deleted_notes, created_by_guest_id FROM workspaces WHERE slug = $1"# } - Query::Q002 => r#"INSERT INTO workspaces (slug, title, password_hash, created_by_guest_id) VALUES ($1, $2, $3, $4)"#, + Query::Q002 => { + r#"INSERT INTO workspaces (slug, title, password_hash, created_by_guest_id) VALUES ($1, $2, $3, $4)"# + } Query::Q003 => { r#"SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map, CAST(CASE WHEN protected THEN 1 ELSE 0 END AS BIGINT) AS protected, created_by, created_by_guest_id FROM notes WHERE workspace_id = $1 AND deleted_at IS NULL ORDER BY updated_at DESC, id DESC"# } diff --git a/src/queries/sqlite.rs b/src/queries/sqlite.rs index 1b64e0e..f42f9cf 100644 --- a/src/queries/sqlite.rs +++ b/src/queries/sqlite.rs @@ -32,7 +32,9 @@ pub fn get(query: Query) -> &'static str { r#"UPDATE users SET editor_color = ?, updated_at = ? WHERE id = ?"# } Query::AUTH_UPDATE_THEME => r#"UPDATE users SET theme = ?, updated_at = ? WHERE id = ?"#, - Query::AUTH_UPDATE_LANGUAGE => r#"UPDATE users SET language = ?, updated_at = ? WHERE id = ?"#, + Query::AUTH_UPDATE_LANGUAGE => { + r#"UPDATE users SET language = ?, updated_at = ? WHERE id = ?"# + } Query::AUTH_EDITOR_COLOR_BY_USER => r#"SELECT editor_color FROM users WHERE id = ?"#, Query::RESOURCE_COLOR_BY_USER => { r#"SELECT color FROM user_resource_colors WHERE user_id = ? AND resource_kind = ? AND resource_slug = ?"# @@ -276,6 +278,12 @@ pub fn get(query: Query) -> &'static str { Query::RESOURCE_SHARING_USERS => { r#"SELECT u.email, u.nickname, rp.permission FROM resource_permissions rp JOIN users u ON u.id = rp.user_id WHERE rp.resource_kind = ? AND rp.resource_slug = ? ORDER BY u.email"# } + Query::RESOURCE_MENTION_USERS_WORKSPACE => { + r#"SELECT u.nickname, u.editor_color FROM users u WHERE u.is_active = 1 AND (EXISTS (SELECT 1 FROM user_workspaces own JOIN workspaces r ON r.id = own.workspace_id WHERE r.slug = ? AND own.user_id = u.id) OR EXISTS (SELECT 1 FROM resource_permissions rp WHERE rp.resource_kind = ? AND rp.resource_slug = ? AND rp.user_id = u.id)) ORDER BY LOWER(u.nickname), u.nickname"# + } + Query::RESOURCE_MENTION_USERS_PAD => { + r#"SELECT u.nickname, u.editor_color FROM users u WHERE u.is_active = 1 AND (EXISTS (SELECT 1 FROM user_pads own JOIN pads r ON r.id = own.pad_id WHERE r.slug = ? AND own.user_id = u.id) OR EXISTS (SELECT 1 FROM resource_permissions rp WHERE rp.resource_kind = ? AND rp.resource_slug = ? AND rp.user_id = u.id)) ORDER BY LOWER(u.nickname), u.nickname"# + } Query::RESOURCE_SHARING_LINKS => { r#"SELECT token_hash, label, permission, expires_at, created_at FROM resource_share_links WHERE resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL ORDER BY created_at DESC"# } @@ -322,7 +330,9 @@ pub fn get(query: Query) -> &'static str { Query::Q001 => { r#"SELECT id, slug, title, password_hash, created_at, updated_at, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS INTEGER) AS is_private, archive_deleted_notes, created_by_guest_id FROM workspaces WHERE slug = ?"# } - Query::Q002 => r#"INSERT INTO workspaces (slug, title, password_hash, created_by_guest_id) VALUES (?, ?, ?, ?)"#, + Query::Q002 => { + r#"INSERT INTO workspaces (slug, title, password_hash, created_by_guest_id) VALUES (?, ?, ?, ?)"# + } Query::Q003 => { r#"SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map, protected, created_by, created_by_guest_id FROM notes WHERE workspace_id = ? AND deleted_at IS NULL ORDER BY updated_at DESC, id DESC"# } diff --git a/static/css/styles.css b/static/css/styles.css index 4661cf4..982d23e 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -3171,7 +3171,7 @@ dialog::backdrop { font-size: .7rem; } -.file-meta > span:not(.file-flag) + span:not(.file-flag)::before { +.file-meta>span:not(.file-flag)+span:not(.file-flag)::before { content: "·"; margin-right: 8px; color: var(--border-strong); @@ -3360,8 +3360,13 @@ dialog::backdrop { } @keyframes file-skeleton-pulse { - from { background-position: 100% 0; } - to { background-position: -100% 0; } + from { + background-position: 100% 0; + } + + to { + background-position: -100% 0; + } } .ui-spinner { @@ -3382,10 +3387,13 @@ dialog::backdrop { } @keyframes ui-spinner-rotate { - to { transform: rotate(360deg); } + to { + transform: rotate(360deg); + } } @media (prefers-reduced-motion: reduce) { + .file-row, .file-skeleton, .ui-spinner { @@ -9091,7 +9099,6 @@ dialog::backdrop { } } -/* PDF export */ #pdf-export-dialog { width: min(520px, calc(100% - 28px)); max-width: 520px; @@ -9164,7 +9171,200 @@ dialog::backdrop { align-items: stretch; } - #pdf-export-dialog .dialog-actions > button { + #pdf-export-dialog .dialog-actions>button { flex: 1 1 0; } } + +.markdown-body .markdown-mention { + display: inline-block; + max-width: 100%; + padding: .1em .48em; + border: 1px solid color-mix(in srgb, var(--owner) 62%, transparent); + border-radius: 999px; + background: color-mix(in srgb, var(--owner) 18%, var(--surface-inset)); + color: var(--text-on-owner); + font: 600 .85em/1.25 system-ui, sans-serif; + vertical-align: baseline; + overflow-wrap: anywhere; +} + +.markdown-body .heading-permalink { + margin-left: .45em; + color: var(--text-muted-strong); + font: 600 .64em/1 system-ui, sans-serif; + opacity: .35; + text-decoration: none; + vertical-align: middle; +} + +.markdown-body :is(h1, h2, h3, h4, h5, h6):hover>.heading-permalink, +.markdown-body .heading-permalink:hover, +.markdown-body .heading-permalink:focus-visible { + opacity: 1; + color: var(--accent); +} + +.mention-suggestions { + position: absolute; + z-index: 30; + box-sizing: border-box; + width: min(290px, calc(100% - 12px)); + max-height: min(300px, 75%); + overflow: auto; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--surface-card); + box-shadow: 0 14px 36px rgb(0 0 0 / .25); + color: var(--text); + font: 500 13px/1.4 system-ui, sans-serif; + overscroll-behavior: contain; +} + +.mention-suggestions[hidden] { + display: none; +} + +.mention-suggestions__option { + display: flex; + align-items: center; + gap: 9px; + box-sizing: border-box; + width: 100%; + min-height: 38px; + margin: 0; + padding: 9px 12px; + border: 0; + border-radius: 0; + background: transparent; + color: inherit; + font: inherit; + text-align: left; + overflow-wrap: anywhere; + cursor: pointer; +} + +.mention-suggestions__option:hover, +.mention-suggestions__option[aria-selected="true"] { + background: var(--accent-a15, color-mix(in srgb, var(--accent) 15%, transparent)); +} + +.mention-suggestions__option:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; +} + +.mention-suggestions__dot { + flex: 0 0 10px; + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--owner); +} + +.mention-caret-mirror { + position: absolute; + top: -9999px; + left: -9999px; + width: max-content; + border: 0; + padding: 0; + white-space: pre; + visibility: hidden; + pointer-events: none; +} + +.mentioned-users { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 7px 10px; + padding: 8px 12px; + border-bottom: 1px solid var(--border); +} + +.mentioned-users[hidden] { + display: none; +} + +.mentioned-users__label { + color: var(--muted); + font: 600 11px/1.4 system-ui, sans-serif; +} + +.mentioned-users__list { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + min-width: 0; +} + +.mentioned-users__badge { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; + max-width: 100%; + padding: 3px 8px; + border: 1px solid color-mix(in srgb, var(--owner) 65%, transparent); + border-radius: 999px; + background: color-mix(in srgb, var(--owner) 18%, transparent); + color: var(--text); + font: 600 11px/1.3 system-ui, sans-serif; + overflow-wrap: anywhere; + cursor: pointer; +} + +.mentioned-users__badge:hover { + background: color-mix(in srgb, var(--owner) 30%, transparent); +} + +.mentioned-users__badge:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.mentioned-users__count { + border-radius: 999px; + padding: 1px 5px; + background: color-mix(in srgb, var(--owner) 22%, transparent); + font-size: 10px; + white-space: nowrap; +} + +.markdown-body .markdown-mention.is-mention-target { + outline: 2px solid var(--accent); + outline-offset: 3px; + background: color-mix(in srgb, var(--owner) 32%, var(--surface-inset)); +} + +@media (max-width: 720px) { + .mentioned-users { + padding: 7px 8px; + } +} + +.pad-page .editor-column>.mentioned-users { + flex: 0 0 auto; +} + +.pad-page .preview-column { + grid-template-rows: 30px auto minmax(0, 1fr); +} + +.pad-page .preview-column>#preview-label { + grid-row: 1; +} + +.pad-page .preview-column>#mentioned-users-preview { + grid-row: 2; +} + +.pad-page .preview-column>#preview { + grid-row: 3; +} + +.workspace:not(.view-preview) .mentioned-users--preview { + display: none; +} \ No newline at end of file diff --git a/static/editor.html b/static/editor.html index 88919c2..a5c1b83 100644 --- a/static/editor.html +++ b/static/editor.html @@ -27,7 +27,8 @@

+ title="Add to favorites" aria-label="Add to favorites" hidden>
+ link
PageDefinition
+ data-format="superscript">Superscript - +