diff --git a/Cargo.lock b/Cargo.lock index be8e75c..a375ae0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2581,7 +2581,7 @@ dependencies = [ [[package]] name = "rustpad" -version = "0.2.66" +version = "0.2.69" dependencies = [ "argon2", "aws-config", diff --git a/Cargo.toml b/Cargo.toml index f936f6f..cb42325 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustpad" -version = "0.2.66" +version = "0.2.69" 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 23489ab..c7a06a8 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ The selected interface language is stored in the browser and can be changed from - Line numbering enabled by default, with per-account preferences stored separately for each note or pad. - The formatting toolbar can be collapsed; the state is saved per account and per 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. +- Signed-in users can favorite standalone notes and workspace notes; favorites are shown only while current account, share-link, or password access still permits the resource. - Owner color displayed next to each line. - Image and file uploads to `data/files/pads/_/` or `data/files/notes/_/`. - Compact attachment aliases are inserted after upload: `[file=name.ext,label]`, `[image=name.ext,alt]`, and `[video=name.ext,label]`. Video uploads can be inserted as an embedded player or a forced-download link. diff --git a/lang/en.json b/lang/en.json index 478f9a7..1cadf90 100644 --- a/lang/en.json +++ b/lang/en.json @@ -739,6 +739,14 @@ "resource.visibilityChanged": "{title} is now {visibility}.", "resource.visibilityFailed": "Could not update visibility", "resource.visibilityTitle": "Visibility updated", + "favorites.add": "Add to favorites", + "favorites.added": "Added to favorites.", + "favorites.copy": "Favorite notes are shown only while your current account, share link, or password access can still open them. If access is lost, they are hidden and return automatically when access is restored.", + "favorites.empty": "No favorites yet.", + "favorites.remove": "Remove from favorites", + "favorites.removed": "Removed from favorites.", + "favorites.title": "Favorites", + "favorites.updateFailed": "Could not update favorites", "resources.accessRules": "Access rules:", "resources.accessRules.copy": "Public items open from their link; a password adds link-based protection. Private items are visible only to their owner and explicitly shared accounts or valid share links. Unauthorized visitors receive a not-found response.", "resources.anotherUser": "another user", @@ -751,6 +759,7 @@ "resources.search": "Search notes and workspaces", "resources.search.placeholder": "Search notes and workspaces…", "resources.sharedBy": "Shared by {user}", + "resources.ownedShared": "Owned and shared", "resources.title": "My notes and workspaces", "share.accessGranted": "Access granted.", "share.accessRemoved": "Access removed.", diff --git a/lang/pl.json b/lang/pl.json index 1c1110c..8ba3252 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -739,6 +739,14 @@ "resource.visibilityChanged": "{title}: widoczność ustawiono na {visibility}.", "resource.visibilityFailed": "Nie udało się zmienić widoczności", "resource.visibilityTitle": "Zmieniono widoczność", + "favorites.add": "Dodaj do ulubionych", + "favorites.added": "Dodano do ulubionych.", + "favorites.copy": "Ulubione notatki są widoczne tylko wtedy, gdy bieżące konto, link udostępnienia lub hasło nadal daje dostęp. Po utracie dostępu znikają z listy i wracają automatycznie po jego odzyskaniu.", + "favorites.empty": "Brak ulubionych.", + "favorites.remove": "Usuń z ulubionych", + "favorites.removed": "Usunięto z ulubionych.", + "favorites.title": "Ulubione", + "favorites.updateFailed": "Nie udało się zmienić ulubionych", "resources.accessRules": "Zasady dostępu:", "resources.accessRules.copy": "Elementy publiczne otwierają się z linku; hasło dodaje ochronę dostępu przez link. Elementy prywatne są widoczne tylko dla właściciela, wskazanych kont i użytkowników z ważnym linkiem udostępniania. Nieuprawnieni użytkownicy otrzymują odpowiedź o braku zasobu.", "resources.anotherUser": "innego użytkownika", @@ -751,6 +759,7 @@ "resources.search": "Szukaj notatek i obszarów roboczych", "resources.search.placeholder": "Szukaj notatek i obszarów roboczych…", "resources.sharedBy": "Udostępnione przez: {user}", + "resources.ownedShared": "Własne i udostępnione", "resources.title": "Moje notatki i obszary robocze", "share.accessGranted": "Dostęp nadany.", "share.accessRemoved": "Dostęp został odebrany.", diff --git a/migrations/mysql/0034_user_favorites.sql b/migrations/mysql/0034_user_favorites.sql new file mode 100644 index 0000000..9a21fa3 --- /dev/null +++ b/migrations/mysql/0034_user_favorites.sql @@ -0,0 +1,19 @@ +CREATE TABLE user_pad_favorites ( + user_id BIGINT NOT NULL, + pad_id BIGINT NOT NULL, + created_at VARCHAR(64) NOT NULL DEFAULT (CURRENT_TIMESTAMP), + PRIMARY KEY (user_id, pad_id), + CONSTRAINT fk_user_pad_favorites_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT fk_user_pad_favorites_pad FOREIGN KEY (pad_id) REFERENCES pads(id) ON DELETE CASCADE, + INDEX idx_user_pad_favorites_user_created (user_id, created_at) +) ENGINE=InnoDB; + +CREATE TABLE user_note_favorites ( + user_id BIGINT NOT NULL, + note_id BIGINT NOT NULL, + created_at VARCHAR(64) NOT NULL DEFAULT (CURRENT_TIMESTAMP), + PRIMARY KEY (user_id, note_id), + CONSTRAINT fk_user_note_favorites_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT fk_user_note_favorites_note FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE, + INDEX idx_user_note_favorites_user_created (user_id, created_at) +) ENGINE=InnoDB; diff --git a/migrations/postgres/0034_user_favorites.sql b/migrations/postgres/0034_user_favorites.sql new file mode 100644 index 0000000..393e953 --- /dev/null +++ b/migrations/postgres/0034_user_favorites.sql @@ -0,0 +1,15 @@ +CREATE TABLE user_pad_favorites ( + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + pad_id BIGINT NOT NULL REFERENCES pads(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text), + PRIMARY KEY (user_id, pad_id) +); +CREATE INDEX idx_user_pad_favorites_user_created ON user_pad_favorites(user_id, created_at DESC); + +CREATE TABLE user_note_favorites ( + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + note_id BIGINT NOT NULL REFERENCES notes(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text), + PRIMARY KEY (user_id, note_id) +); +CREATE INDEX idx_user_note_favorites_user_created ON user_note_favorites(user_id, created_at DESC); diff --git a/migrations/sqlite/0034_user_favorites.sql b/migrations/sqlite/0034_user_favorites.sql new file mode 100644 index 0000000..131c94b --- /dev/null +++ b/migrations/sqlite/0034_user_favorites.sql @@ -0,0 +1,15 @@ +CREATE TABLE user_pad_favorites ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + pad_id INTEGER NOT NULL REFERENCES pads(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, pad_id) +); +CREATE INDEX idx_user_pad_favorites_user_created ON user_pad_favorites(user_id, created_at DESC); + +CREATE TABLE user_note_favorites ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + note_id INTEGER NOT NULL REFERENCES notes(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, note_id) +); +CREATE INDEX idx_user_note_favorites_user_created ON user_note_favorites(user_id, created_at DESC); diff --git a/src/api/favorites.rs b/src/api/favorites.rs new file mode 100644 index 0000000..fbbc7e7 --- /dev/null +++ b/src/api/favorites.rs @@ -0,0 +1,883 @@ +/* + * 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::*; +use sqlx::{Row, any::AnyRow}; + +#[derive(Debug, Deserialize)] +pub struct FavoriteTarget { + kind: String, + slug: String, + #[serde(default)] + workspace_slug: Option, +} + +#[derive(Debug, Deserialize)] +pub struct FavoriteStatusQuery { + kind: String, + slug: String, + #[serde(default)] + workspace_slug: Option, +} + +#[derive(Debug, Deserialize)] +pub struct FavoriteListQuery { + #[serde(default)] + q: String, +} + +#[derive(Debug, Serialize)] +pub struct FavoriteStatus { + favorite: bool, +} + +#[derive(Debug, Serialize)] +pub struct FavoriteList { + items: Vec, +} + +#[derive(Debug, Serialize)] +pub struct FavoriteItem { + kind: String, + slug: String, + workspace_slug: Option, + workspace_title: Option, + title: String, + url: String, + updated_at: String, + favorited_at: String, +} + +#[derive(Debug)] +struct FavoritePadRow { + slug: String, + title: String, + updated_at: String, + private_resource: i64, + protected_resource: i64, + favorited_at: String, +} + +impl<'r> sqlx::FromRow<'r, AnyRow> for FavoritePadRow { + fn from_row(row: &'r AnyRow) -> Result { + Ok(Self { + slug: crate::row_decode::text(row, "slug")?, + title: crate::row_decode::text(row, "title")?, + updated_at: crate::row_decode::text(row, "updated_at")?, + private_resource: row.try_get("private")?, + protected_resource: row.try_get("protected")?, + favorited_at: crate::row_decode::text(row, "favorited_at")?, + }) + } +} + +#[derive(Debug)] +struct FavoriteNoteRow { + slug: String, + title: String, + updated_at: String, + workspace_slug: String, + workspace_title: String, + private_resource: i64, + protected_resource: i64, + favorited_at: String, +} + +impl<'r> sqlx::FromRow<'r, AnyRow> for FavoriteNoteRow { + fn from_row(row: &'r AnyRow) -> Result { + Ok(Self { + slug: crate::row_decode::text(row, "slug")?, + title: crate::row_decode::text(row, "title")?, + updated_at: crate::row_decode::text(row, "updated_at")?, + workspace_slug: crate::row_decode::text(row, "workspace_slug")?, + workspace_title: crate::row_decode::text(row, "workspace_title")?, + private_resource: row.try_get("private")?, + protected_resource: row.try_get("protected")?, + favorited_at: crate::row_decode::text(row, "favorited_at")?, + }) + } +} + +#[derive(Clone, Copy)] +enum FavoriteResource { + Pad(i64), + Note(i64), +} + +async fn favorite_user( + state: &SharedState, + headers: &HeaderMap, +) -> Result { + session_user(state, headers) + .await? + .ok_or_else(|| ApiError::forbidden("Log in to use favorites")) +} + +async fn resolve_favorite_target( + state: &SharedState, + headers: &HeaderMap, + target: &FavoriteTarget, +) -> Result { + let kind = target.kind.trim(); + let slug = target.slug.trim(); + if slug.is_empty() { + return Err(ApiError::bad_request("Favorite slug is required")); + } + + match kind { + "pad" => { + let pad = db::find_pad(&state.db, slug) + .await? + .ok_or_else(ApiError::not_found_note)?; + if (pad.is_private != 0 || pad.password_hash.is_some()) + && !has_header_resource_access(state, headers, "pad", &pad.slug).await? + { + return Err(ApiError::not_found_note()); + } + Ok(FavoriteResource::Pad(pad.id)) + } + "note" => { + let workspace_slug = target + .workspace_slug + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| ApiError::bad_request("Workspace slug is required for a note"))?; + let workspace = db::find_workspace(&state.db, workspace_slug) + .await? + .ok_or_else(ApiError::not_found_workspace)?; + if (workspace.is_private != 0 || workspace.password_hash.is_some()) + && !has_header_resource_access(state, headers, "workspace", &workspace.slug).await? + { + return Err(ApiError::not_found_workspace()); + } + let note = db::find_note(&state.db, workspace.id, slug) + .await? + .ok_or_else(ApiError::not_found_note)?; + Ok(FavoriteResource::Note(note.id)) + } + _ => Err(ApiError::bad_request("Invalid favorite resource kind")), + } +} + +async fn favorite_exists( + state: &SharedState, + user_id: i64, + resource: FavoriteResource, +) -> Result { + let (query, resource_id) = match resource { + FavoriteResource::Pad(id) => (queries::USER_FAVORITE_PAD_EXISTS, id), + FavoriteResource::Note(id) => (queries::USER_FAVORITE_NOTE_EXISTS, id), + }; + let count: i64 = sqlx::query_scalar(queries::get(state.db.kind(), query)) + .bind(user_id) + .bind(resource_id) + .fetch_one(state.db.pool()) + .await?; + Ok(count > 0) +} + +pub async fn favorite_status( + State(state): State, + headers: HeaderMap, + Query(query): Query, +) -> Result, ApiError> { + let user = favorite_user(&state, &headers).await?; + let target = FavoriteTarget { + kind: query.kind, + slug: query.slug, + workspace_slug: query.workspace_slug, + }; + let resource = resolve_favorite_target(&state, &headers, &target).await?; + Ok(Json(FavoriteStatus { + favorite: favorite_exists(&state, user.id, resource).await?, + })) +} + +pub async fn add_favorite( + State(state): State, + headers: HeaderMap, + Json(target): Json, +) -> Result, ApiError> { + let user = favorite_user(&state, &headers).await?; + let resource = resolve_favorite_target(&state, &headers, &target).await?; + let (query, resource_id) = match resource { + FavoriteResource::Pad(id) => (queries::USER_FAVORITE_PAD_INSERT, id), + FavoriteResource::Note(id) => (queries::USER_FAVORITE_NOTE_INSERT, id), + }; + sqlx::query(queries::get(state.db.kind(), query)) + .bind(user.id) + .bind(resource_id) + .execute(state.db.pool()) + .await?; + Ok(Json(FavoriteStatus { favorite: true })) +} + +pub async fn remove_favorite( + State(state): State, + headers: HeaderMap, + Json(target): Json, +) -> Result, ApiError> { + let user = favorite_user(&state, &headers).await?; + let resource = resolve_favorite_target(&state, &headers, &target).await?; + let (query, resource_id) = match resource { + FavoriteResource::Pad(id) => (queries::USER_FAVORITE_PAD_DELETE, id), + FavoriteResource::Note(id) => (queries::USER_FAVORITE_NOTE_DELETE, id), + }; + sqlx::query(queries::get(state.db.kind(), query)) + .bind(user.id) + .bind(resource_id) + .execute(state.db.pool()) + .await?; + Ok(Json(FavoriteStatus { favorite: false })) +} + +pub async fn list_favorites( + State(state): State, + headers: HeaderMap, + Query(query): Query, +) -> Result, ApiError> { + let user = favorite_user(&state, &headers).await?; + let pads = sqlx::query_as::<_, FavoritePadRow>(queries::get( + state.db.kind(), + queries::USER_FAVORITE_PAD_LIST, + )) + .bind(user.id) + .fetch_all(state.db.pool()) + .await?; + let notes = sqlx::query_as::<_, FavoriteNoteRow>(queries::get( + state.db.kind(), + queries::USER_FAVORITE_NOTE_LIST, + )) + .bind(user.id) + .fetch_all(state.db.pool()) + .await?; + + let search = query.q.trim().to_lowercase(); + let mut items = Vec::with_capacity(pads.len() + notes.len()); + + for pad in pads { + if (pad.private_resource != 0 || pad.protected_resource != 0) + && !has_header_resource_access(&state, &headers, "pad", &pad.slug).await? + { + continue; + } + if !search.is_empty() + && !pad.title.to_lowercase().contains(&search) + && !pad.slug.to_lowercase().contains(&search) + { + continue; + } + items.push(FavoriteItem { + kind: "pad".into(), + url: format!("/p/{}", pad.slug), + slug: pad.slug, + workspace_slug: None, + workspace_title: None, + title: pad.title, + updated_at: db::normalize_timestamp(&pad.updated_at), + favorited_at: db::normalize_timestamp(&pad.favorited_at), + }); + } + + for note in notes { + if (note.private_resource != 0 || note.protected_resource != 0) + && !has_header_resource_access( + &state, + &headers, + "workspace", + ¬e.workspace_slug, + ) + .await? + { + continue; + } + if !search.is_empty() + && !note.title.to_lowercase().contains(&search) + && !note.slug.to_lowercase().contains(&search) + && !note.workspace_title.to_lowercase().contains(&search) + && !note.workspace_slug.to_lowercase().contains(&search) + { + continue; + } + items.push(FavoriteItem { + kind: "note".into(), + url: format!("/w/{}/n/{}", note.workspace_slug, note.slug), + slug: note.slug, + workspace_slug: Some(note.workspace_slug), + workspace_title: Some(note.workspace_title), + title: note.title, + updated_at: db::normalize_timestamp(¬e.updated_at), + favorited_at: db::normalize_timestamp(¬e.favorited_at), + }); + } + + items.sort_by(|left, right| right.favorited_at.cmp(&left.favorited_at)); + Ok(Json(FavoriteList { items })) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{database::Database, state::AppState, storage::Storage}; + use sha2::{Digest, Sha256}; + use std::sync::Arc; + + async fn test_state() -> SharedState { + let db = Database::connect("sqlite::memory:", 1) + .await + .expect("test database"); + crate::run_migrations(&db).await.expect("test migrations"); + Arc::new(AppState::new( + db, + "test".into(), + Storage::Local { + root: std::env::temp_dir().join("rustpad-favorite-tests"), + }, + 1_000_000, + true, + 1_000_000, + 0, + None, + None, + true, + false, + false, + "error".into(), + 7, + 7, + 7, + None, + )) + } + + async fn logged_in_headers(state: &SharedState, suffix: &str) -> (HeaderMap, i64) { + let nickname = format!("Favorite {suffix}"); + let nickname_key = nickname.to_lowercase(); + let email = format!("favorite-{suffix}@example.test"); + sqlx::query( + "INSERT INTO users (nickname, nickname_key, email, email_key, password_hash) VALUES (?, ?, ?, ?, ?)", + ) + .bind(&nickname) + .bind(&nickname_key) + .bind(&email) + .bind(&email) + .bind("password-hash") + .execute(state.db.pool()) + .await + .unwrap(); + let user_id: i64 = sqlx::query_scalar("SELECT id FROM users WHERE email_key = ?") + .bind(&email) + .fetch_one(state.db.pool()) + .await + .unwrap(); + let token = format!("favorite-session-{suffix}"); + let expires_at = (Utc::now() + Duration::days(7)).to_rfc3339(); + sqlx::query("INSERT INTO user_sessions (token, user_id, expires_at) VALUES (?, ?, ?)") + .bind(&token) + .bind(user_id) + .bind(expires_at) + .execute(state.db.pool()) + .await + .unwrap(); + let mut headers = HeaderMap::new(); + headers.insert( + header::COOKIE, + HeaderValue::from_str(&format!( + "{}={token}", + crate::security::SESSION_COOKIE + )) + .unwrap(), + ); + (headers, user_id) + } + + #[tokio::test] + async fn favorite_pad_is_idempotent_listed_and_deleted_with_pad() { + let state = test_state().await; + let (headers, user_id) = logged_in_headers(&state, "pad").await; + let pad = db::create_pad(&state.db, "favorite-pad", "Favorite Pad", None, None) + .await + .unwrap(); + let target = || FavoriteTarget { + kind: "pad".into(), + slug: pad.slug.clone(), + workspace_slug: None, + }; + + for _ in 0..2 { + let result = add_favorite( + State(state.clone()), + headers.clone(), + Json(target()), + ) + .await + .unwrap(); + assert!(result.0.favorite); + } + + let stored: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM user_pad_favorites WHERE user_id = ? AND pad_id = ?", + ) + .bind(user_id) + .bind(pad.id) + .fetch_one(state.db.pool()) + .await + .unwrap(); + assert_eq!(stored, 1); + + let listed = list_favorites( + State(state.clone()), + headers.clone(), + Query(FavoriteListQuery { q: String::new() }), + ) + .await + .unwrap(); + assert_eq!(listed.0.items.len(), 1); + assert_eq!(listed.0.items[0].kind, "pad"); + assert_eq!(listed.0.items[0].url, "/p/favorite-pad"); + + sqlx::query("DELETE FROM pads WHERE id = ?") + .bind(pad.id) + .execute(state.db.pool()) + .await + .unwrap(); + let stored_after_delete: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM user_pad_favorites WHERE user_id = ?") + .bind(user_id) + .fetch_one(state.db.pool()) + .await + .unwrap(); + assert_eq!(stored_after_delete, 0); + } + + #[tokio::test] + async fn private_note_favorite_is_hidden_after_permission_revocation() { + let state = test_state().await; + let (headers, user_id) = logged_in_headers(&state, "private-note").await; + let workspace = db::create_workspace( + &state.db, + "private-workspace", + "Private Workspace", + None, + None, + ) + .await + .unwrap(); + sqlx::query("UPDATE workspaces SET is_private = 1 WHERE id = ?") + .bind(workspace.id) + .execute(state.db.pool()) + .await + .unwrap(); + let note = db::create_note( + &state.db, + workspace.id, + "shared-note", + "Shared Note", + false, + None, + None, + ) + .await + .unwrap(); + sqlx::query(queries::get( + state.db.kind(), + queries::RESOURCE_PERMISSION_INSERT, + )) + .bind("workspace") + .bind(&workspace.slug) + .bind(user_id) + .bind("ro") + .execute(state.db.pool()) + .await + .unwrap(); + + add_favorite( + State(state.clone()), + headers.clone(), + Json(FavoriteTarget { + kind: "note".into(), + slug: note.slug.clone(), + workspace_slug: Some(workspace.slug.clone()), + }), + ) + .await + .unwrap(); + let before = list_favorites( + State(state.clone()), + headers.clone(), + Query(FavoriteListQuery { q: String::new() }), + ) + .await + .unwrap(); + assert_eq!(before.0.items.len(), 1); + + sqlx::query(queries::get( + state.db.kind(), + queries::RESOURCE_PERMISSION_DELETE_USER, + )) + .bind("workspace") + .bind(&workspace.slug) + .bind(user_id) + .execute(state.db.pool()) + .await + .unwrap(); + + let after = list_favorites( + State(state.clone()), + headers.clone(), + Query(FavoriteListQuery { q: String::new() }), + ) + .await + .unwrap(); + assert!(after.0.items.is_empty()); + let stored: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM user_note_favorites WHERE user_id = ? AND note_id = ?", + ) + .bind(user_id) + .bind(note.id) + .fetch_one(state.db.pool()) + .await + .unwrap(); + assert_eq!(stored, 1); + + sqlx::query(queries::get( + state.db.kind(), + queries::RESOURCE_PERMISSION_INSERT, + )) + .bind("workspace") + .bind(&workspace.slug) + .bind(user_id) + .bind("ro") + .execute(state.db.pool()) + .await + .unwrap(); + let restored = list_favorites( + State(state.clone()), + headers, + Query(FavoriteListQuery { q: String::new() }), + ) + .await + .unwrap(); + assert_eq!(restored.0.items.len(), 1); + } + + #[tokio::test] + async fn public_note_favorite_hides_when_workspace_becomes_private_and_returns_when_public() { + let state = test_state().await; + let (headers, user_id) = logged_in_headers(&state, "privacy-toggle").await; + let workspace = db::create_workspace( + &state.db, + "privacy-toggle-workspace", + "Privacy Toggle Workspace", + None, + None, + ) + .await + .unwrap(); + let note = db::create_note( + &state.db, + workspace.id, + "privacy-toggle-note", + "Privacy Toggle Note", + false, + None, + None, + ) + .await + .unwrap(); + + add_favorite( + State(state.clone()), + headers.clone(), + Json(FavoriteTarget { + kind: "note".into(), + slug: note.slug.clone(), + workspace_slug: Some(workspace.slug.clone()), + }), + ) + .await + .unwrap(); + + sqlx::query("UPDATE workspaces SET is_private = 1 WHERE id = ?") + .bind(workspace.id) + .execute(state.db.pool()) + .await + .unwrap(); + let hidden = list_favorites( + State(state.clone()), + headers.clone(), + Query(FavoriteListQuery { q: String::new() }), + ) + .await + .unwrap(); + assert!(hidden.0.items.is_empty()); + let stored: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM user_note_favorites WHERE user_id = ? AND note_id = ?", + ) + .bind(user_id) + .bind(note.id) + .fetch_one(state.db.pool()) + .await + .unwrap(); + assert_eq!(stored, 1); + + sqlx::query("UPDATE workspaces SET is_private = 0 WHERE id = ?") + .bind(workspace.id) + .execute(state.db.pool()) + .await + .unwrap(); + let visible_again = list_favorites( + State(state), + headers, + Query(FavoriteListQuery { q: String::new() }), + ) + .await + .unwrap(); + assert_eq!(visible_again.0.items.len(), 1); + } + + #[tokio::test] + async fn revoked_share_link_hides_favorite_but_keeps_marker() { + let state = test_state().await; + let (mut headers, user_id) = logged_in_headers(&state, "share-revoke").await; + let workspace = db::create_workspace( + &state.db, + "share-revoke-workspace", + "Share Revoke Workspace", + None, + None, + ) + .await + .unwrap(); + sqlx::query("UPDATE workspaces SET is_private = 1 WHERE id = ?") + .bind(workspace.id) + .execute(state.db.pool()) + .await + .unwrap(); + let note = db::create_note( + &state.db, + workspace.id, + "share-revoke-note", + "Share Revoke Note", + false, + None, + None, + ) + .await + .unwrap(); + let share_token = "a".repeat(64); + let token_hash = hex::encode(Sha256::digest(share_token.as_bytes())); + sqlx::query(queries::get(state.db.kind(), queries::SHARE_LINK_INSERT)) + .bind(&token_hash) + .bind(Some("favorite test".to_owned())) + .bind("workspace") + .bind(&workspace.slug) + .bind("ro") + .bind(Option::::None) + .bind(user_id) + .execute(state.db.pool()) + .await + .unwrap(); + headers.insert( + header::AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {share_token}")).unwrap(), + ); + + add_favorite( + State(state.clone()), + headers.clone(), + Json(FavoriteTarget { + kind: "note".into(), + slug: note.slug.clone(), + workspace_slug: Some(workspace.slug.clone()), + }), + ) + .await + .unwrap(); + let visible = list_favorites( + State(state.clone()), + headers.clone(), + Query(FavoriteListQuery { q: String::new() }), + ) + .await + .unwrap(); + assert_eq!(visible.0.items.len(), 1); + + sqlx::query(queries::get(state.db.kind(), queries::SHARE_LINK_REVOKE)) + .bind(Utc::now().to_rfc3339()) + .bind(&token_hash) + .bind("workspace") + .bind(&workspace.slug) + .execute(state.db.pool()) + .await + .unwrap(); + let hidden = list_favorites( + State(state.clone()), + headers, + Query(FavoriteListQuery { q: String::new() }), + ) + .await + .unwrap(); + assert!(hidden.0.items.is_empty()); + let stored: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM user_note_favorites WHERE user_id = ? AND note_id = ?", + ) + .bind(user_id) + .bind(note.id) + .fetch_one(state.db.pool()) + .await + .unwrap(); + assert_eq!(stored, 1); + } + + #[tokio::test] + async fn revoked_password_token_hides_protected_pad_favorite() { + let state = test_state().await; + let (mut headers, user_id) = logged_in_headers(&state, "password-revoke").await; + let pad = db::create_pad( + &state.db, + "password-revoke-pad", + "Password Revoke Pad", + Some("password123"), + None, + ) + .await + .unwrap(); + let access_token = "b".repeat(64); + let token_hash = hex::encode(Sha256::digest(access_token.as_bytes())); + sqlx::query(queries::get( + state.db.kind(), + queries::RESOURCE_ACCESS_TOKENS_INSERT, + )) + .bind(token_hash) + .bind("pad") + .bind(&pad.slug) + .bind((Utc::now() + Duration::days(7)).to_rfc3339()) + .execute(state.db.pool()) + .await + .unwrap(); + headers.insert( + header::AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {access_token}")).unwrap(), + ); + + add_favorite( + State(state.clone()), + headers.clone(), + Json(FavoriteTarget { + kind: "pad".into(), + slug: pad.slug.clone(), + workspace_slug: None, + }), + ) + .await + .unwrap(); + let visible = list_favorites( + State(state.clone()), + headers.clone(), + Query(FavoriteListQuery { q: String::new() }), + ) + .await + .unwrap(); + assert_eq!(visible.0.items.len(), 1); + + sqlx::query(queries::get( + state.db.kind(), + queries::RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE, + )) + .bind("pad") + .bind(&pad.slug) + .execute(state.db.pool()) + .await + .unwrap(); + let hidden = list_favorites( + State(state.clone()), + headers, + Query(FavoriteListQuery { q: String::new() }), + ) + .await + .unwrap(); + assert!(hidden.0.items.is_empty()); + let stored: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM user_pad_favorites WHERE user_id = ? AND pad_id = ?", + ) + .bind(user_id) + .bind(pad.id) + .fetch_one(state.db.pool()) + .await + .unwrap(); + assert_eq!(stored, 1); + } + + #[tokio::test] + async fn favorites_require_an_active_login() { + let state = test_state().await; + db::create_pad(&state.db, "login-only-pad", "Login only", None, None) + .await + .unwrap(); + let target = || FavoriteTarget { + kind: "pad".into(), + slug: "login-only-pad".into(), + workspace_slug: None, + }; + + 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; + sqlx::query("UPDATE users SET is_active = 0 WHERE id = ?") + .bind(user_id) + .execute(state.db.pool()) + .await + .unwrap(); + 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); + } + + #[tokio::test] + async fn password_protected_pad_requires_current_resource_access() { + let state = test_state().await; + let (headers, _) = logged_in_headers(&state, "protected").await; + db::create_pad( + &state.db, + "protected-pad", + "Protected Pad", + Some("password123"), + None, + ) + .await + .unwrap(); + + let error = add_favorite( + State(state), + headers, + Json(FavoriteTarget { + kind: "pad".into(), + slug: "protected-pad".into(), + workspace_slug: None, + }), + ) + .await + .expect_err("protected favorite without resource access must fail"); + assert_eq!(error.into_response().status(), StatusCode::NOT_FOUND); + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs index a73bb38..747e3ba 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -9,11 +9,13 @@ mod access_tokens; mod error; +mod favorites; mod files; mod pads_public; pub use access_tokens::*; pub use error::ApiError; +pub use favorites::*; pub use files::*; pub use pads_public::*; @@ -419,6 +421,7 @@ pub struct NoteListItem { url: String, protected: bool, can_delete: bool, + favorite: bool, created_by: Option, participant_count: i64, file_count: i64, @@ -794,6 +797,20 @@ pub async fn open_workspace( let requester_user = session_user(&state, &headers).await?; let requester_nickname = requester_user.as_ref().map(|user| user.nickname.as_str()); let requester_guest = requester_guest_id(&headers); + let favorite_note_ids = if let Some(user) = requester_user.as_ref() { + sqlx::query_scalar::<_, i64>(queries::get( + state.db.kind(), + queries::USER_FAVORITE_NOTE_IDS_BY_WORKSPACE, + )) + .bind(user.id) + .bind(workspace.id) + .fetch_all(state.db.pool()) + .await? + .into_iter() + .collect::>() + } else { + std::collections::HashSet::new() + }; let stats = db::list_note_stats(&state.db, workspace.id) .await? @@ -838,6 +855,7 @@ pub async fn open_workspace( updated_at: db::normalize_timestamp(¬e.updated_at), protected: note.protected, can_delete, + favorite: favorite_note_ids.contains(¬e.id), created_by: note.created_by, participant_count: stats.map_or(0, |value| value.participant_count), file_count: stats.map_or(0, |value| value.file_count), @@ -968,6 +986,7 @@ pub async fn create_note( updated_at: db::normalize_timestamp(¬e.updated_at), protected: note.protected, can_delete: true, + favorite: false, created_by: note.created_by, participant_count: 0, file_count: 0, diff --git a/src/app/mod.rs b/src/app/mod.rs index 7d83c0c..832636a 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -137,6 +137,13 @@ pub fn router( .put(auth::update_share_link) .delete(auth::revoke_share_link), ) + .route( + "/api/auth/favorites", + get(api::list_favorites) + .put(api::add_favorite) + .delete(api::remove_favorite), + ) + .route("/api/auth/favorites/status", get(api::favorite_status)) .route( "/share-invitations/{token}/accept", get(home).post(auth::accept_share_invitation), diff --git a/src/auth/mod.rs b/src/auth/mod.rs index b996eb0..7b1503c 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -1135,6 +1135,22 @@ pub async fn confirm_account_action( .execute(&mut *tx) .await .map_err(AuthError::database)?; + sqlx::query(queries::get( + state.db.kind(), + queries::USER_FAVORITE_PADS_DELETE_BY_USER, + )) + .bind(user_id) + .execute(&mut *tx) + .await + .map_err(AuthError::database)?; + sqlx::query(queries::get( + state.db.kind(), + queries::USER_FAVORITE_NOTES_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)) diff --git a/src/queries/mod.rs b/src/queries/mod.rs index 4066c63..9824ade 100644 --- a/src/queries/mod.rs +++ b/src/queries/mod.rs @@ -80,6 +80,17 @@ pub enum Query { USER_ATTACH_PAD, USER_LIST_WORKSPACES, USER_LIST_PADS, + USER_FAVORITE_PAD_INSERT, + USER_FAVORITE_PAD_DELETE, + USER_FAVORITE_PAD_EXISTS, + USER_FAVORITE_PAD_LIST, + USER_FAVORITE_NOTE_INSERT, + USER_FAVORITE_NOTE_DELETE, + USER_FAVORITE_NOTE_EXISTS, + USER_FAVORITE_NOTE_LIST, + USER_FAVORITE_NOTE_IDS_BY_WORKSPACE, + USER_FAVORITE_PADS_DELETE_BY_USER, + USER_FAVORITE_NOTES_DELETE_BY_USER, USER_OWNS_WORKSPACE, USER_OWNS_PAD, USER_DELETE_WORKSPACE, @@ -246,6 +257,17 @@ pub const USER_ATTACH_WORKSPACE: Query = Query::USER_ATTACH_WORKSPACE; pub const USER_ATTACH_PAD: Query = Query::USER_ATTACH_PAD; pub const USER_LIST_WORKSPACES: Query = Query::USER_LIST_WORKSPACES; pub const USER_LIST_PADS: Query = Query::USER_LIST_PADS; +pub const USER_FAVORITE_PAD_INSERT: Query = Query::USER_FAVORITE_PAD_INSERT; +pub const USER_FAVORITE_PAD_DELETE: Query = Query::USER_FAVORITE_PAD_DELETE; +pub const USER_FAVORITE_PAD_EXISTS: Query = Query::USER_FAVORITE_PAD_EXISTS; +pub const USER_FAVORITE_PAD_LIST: Query = Query::USER_FAVORITE_PAD_LIST; +pub const USER_FAVORITE_NOTE_INSERT: Query = Query::USER_FAVORITE_NOTE_INSERT; +pub const USER_FAVORITE_NOTE_DELETE: Query = Query::USER_FAVORITE_NOTE_DELETE; +pub const USER_FAVORITE_NOTE_EXISTS: Query = Query::USER_FAVORITE_NOTE_EXISTS; +pub const USER_FAVORITE_NOTE_LIST: Query = Query::USER_FAVORITE_NOTE_LIST; +pub const USER_FAVORITE_NOTE_IDS_BY_WORKSPACE: Query = Query::USER_FAVORITE_NOTE_IDS_BY_WORKSPACE; +pub const USER_FAVORITE_PADS_DELETE_BY_USER: Query = Query::USER_FAVORITE_PADS_DELETE_BY_USER; +pub const USER_FAVORITE_NOTES_DELETE_BY_USER: Query = Query::USER_FAVORITE_NOTES_DELETE_BY_USER; pub const USER_OWNS_WORKSPACE: Query = Query::USER_OWNS_WORKSPACE; pub const USER_OWNS_PAD: Query = Query::USER_OWNS_PAD; pub const USER_DELETE_WORKSPACE: Query = Query::USER_DELETE_WORKSPACE; diff --git a/src/queries/mysql.rs b/src/queries/mysql.rs index 20d0d62..0996631 100644 --- a/src/queries/mysql.rs +++ b/src/queries/mysql.rs @@ -178,6 +178,39 @@ pub fn get(query: Query) -> &'static str { Query::USER_LIST_PADS => { r#"SELECT p.slug, CAST(p.title AS CHAR CHARACTER SET utf8mb4) AS title, CAST(CASE WHEN p.password_hash IS NULL THEN 0 ELSE 1 END AS SIGNED) AS protected, p.updated_at, CAST(CASE WHEN p.is_private THEN 1 ELSE 0 END AS SIGNED) AS private, CAST(1 AS SIGNED) AS owned, 'rw' AS permission, '' AS shared_by FROM user_pads up JOIN pads p ON p.id = up.pad_id WHERE up.user_id = ? UNION SELECT p.slug, CAST(p.title AS CHAR CHARACTER SET utf8mb4) AS title, CAST(CASE WHEN p.password_hash IS NULL THEN 0 ELSE 1 END AS SIGNED), p.updated_at, CAST(CASE WHEN p.is_private THEN 1 ELSE 0 END AS SIGNED), CAST(0 AS SIGNED), rp.permission, COALESCE((SELECT u.nickname FROM user_pads owner_up JOIN users u ON u.id = owner_up.user_id WHERE owner_up.pad_id = p.id LIMIT 1), 'Unknown user') AS shared_by FROM resource_permissions rp JOIN pads p ON p.slug = rp.resource_slug WHERE rp.resource_kind = 'pad' AND rp.user_id = ? ORDER BY updated_at DESC"# } + Query::USER_FAVORITE_PAD_INSERT => { + r#"INSERT IGNORE INTO user_pad_favorites (user_id, pad_id) VALUES (?, ?)"# + } + Query::USER_FAVORITE_PAD_DELETE => { + r#"DELETE FROM user_pad_favorites WHERE user_id = ? AND pad_id = ?"# + } + Query::USER_FAVORITE_PAD_EXISTS => { + r#"SELECT COUNT(*) FROM user_pad_favorites WHERE user_id = ? AND pad_id = ?"# + } + Query::USER_FAVORITE_PAD_LIST => { + r#"SELECT p.id, p.slug, CAST(p.title AS CHAR CHARACTER SET utf8mb4) AS title, p.updated_at, CAST(CASE WHEN p.is_private THEN 1 ELSE 0 END AS SIGNED) AS private, CAST(CASE WHEN p.password_hash IS NULL THEN 0 ELSE 1 END AS SIGNED) AS protected, f.created_at AS favorited_at FROM user_pad_favorites f JOIN pads p ON p.id = f.pad_id WHERE f.user_id = ? ORDER BY f.created_at DESC, p.id DESC"# + } + Query::USER_FAVORITE_NOTE_INSERT => { + r#"INSERT IGNORE INTO user_note_favorites (user_id, note_id) VALUES (?, ?)"# + } + Query::USER_FAVORITE_NOTE_DELETE => { + r#"DELETE FROM user_note_favorites WHERE user_id = ? AND note_id = ?"# + } + Query::USER_FAVORITE_NOTE_EXISTS => { + r#"SELECT COUNT(*) FROM user_note_favorites WHERE user_id = ? AND note_id = ?"# + } + Query::USER_FAVORITE_NOTE_LIST => { + r#"SELECT n.id, n.slug, CAST(n.title AS CHAR CHARACTER SET utf8mb4) AS title, n.updated_at, w.slug AS workspace_slug, CAST(w.title AS CHAR CHARACTER SET utf8mb4) AS workspace_title, CAST(CASE WHEN w.is_private THEN 1 ELSE 0 END AS SIGNED) AS private, CAST(CASE WHEN w.password_hash IS NULL THEN 0 ELSE 1 END AS SIGNED) AS protected, f.created_at AS favorited_at FROM user_note_favorites f JOIN notes n ON n.id = f.note_id JOIN workspaces w ON w.id = n.workspace_id WHERE f.user_id = ? ORDER BY f.created_at DESC, n.id DESC"# + } + Query::USER_FAVORITE_NOTE_IDS_BY_WORKSPACE => { + r#"SELECT f.note_id FROM user_note_favorites f JOIN notes n ON n.id = f.note_id WHERE f.user_id = ? AND n.workspace_id = ?"# + } + Query::USER_FAVORITE_PADS_DELETE_BY_USER => { + r#"DELETE FROM user_pad_favorites WHERE user_id = ?"# + } + Query::USER_FAVORITE_NOTES_DELETE_BY_USER => { + r#"DELETE FROM user_note_favorites WHERE user_id = ?"# + } Query::USER_OWNS_WORKSPACE => { r#"SELECT COUNT(*) FROM user_workspaces uw JOIN workspaces w ON w.id = uw.workspace_id WHERE uw.user_id = ? AND w.slug = ?"# } diff --git a/src/queries/postgres.rs b/src/queries/postgres.rs index 03ab76f..2459d9d 100644 --- a/src/queries/postgres.rs +++ b/src/queries/postgres.rs @@ -180,6 +180,39 @@ pub fn get(query: Query) -> &'static str { Query::USER_LIST_PADS => { r#"SELECT p.slug, p.title, CAST(CASE WHEN p.password_hash IS NULL THEN 0 ELSE 1 END AS BIGINT) AS protected, p.updated_at, CAST(CASE WHEN p.is_private THEN 1 ELSE 0 END AS BIGINT) AS private, CAST(1 AS BIGINT) AS owned, 'rw' AS permission, '' AS shared_by FROM user_pads up JOIN pads p ON p.id = up.pad_id WHERE up.user_id = $1 UNION SELECT p.slug, p.title, CAST(CASE WHEN p.password_hash IS NULL THEN 0 ELSE 1 END AS BIGINT), p.updated_at, CAST(CASE WHEN p.is_private THEN 1 ELSE 0 END AS BIGINT), CAST(0 AS BIGINT), rp.permission, COALESCE((SELECT u.nickname FROM user_pads owner_up JOIN users u ON u.id = owner_up.user_id WHERE owner_up.pad_id = p.id LIMIT 1), 'Unknown user') AS shared_by FROM resource_permissions rp JOIN pads p ON p.slug = rp.resource_slug WHERE rp.resource_kind = 'pad' AND rp.user_id = $2 ORDER BY updated_at DESC"# } + Query::USER_FAVORITE_PAD_INSERT => { + r#"INSERT INTO user_pad_favorites (user_id, pad_id) VALUES ($1, $2) ON CONFLICT (user_id, pad_id) DO NOTHING"# + } + Query::USER_FAVORITE_PAD_DELETE => { + r#"DELETE FROM user_pad_favorites WHERE user_id = $1 AND pad_id = $2"# + } + Query::USER_FAVORITE_PAD_EXISTS => { + r#"SELECT COUNT(*) FROM user_pad_favorites WHERE user_id = $1 AND pad_id = $2"# + } + Query::USER_FAVORITE_PAD_LIST => { + r#"SELECT p.id, p.slug, p.title, p.updated_at, CAST(CASE WHEN p.is_private THEN 1 ELSE 0 END AS BIGINT) AS private, CAST(CASE WHEN p.password_hash IS NULL THEN 0 ELSE 1 END AS BIGINT) AS protected, f.created_at AS favorited_at FROM user_pad_favorites f JOIN pads p ON p.id = f.pad_id WHERE f.user_id = $1 ORDER BY f.created_at DESC, p.id DESC"# + } + Query::USER_FAVORITE_NOTE_INSERT => { + r#"INSERT INTO user_note_favorites (user_id, note_id) VALUES ($1, $2) ON CONFLICT (user_id, note_id) DO NOTHING"# + } + Query::USER_FAVORITE_NOTE_DELETE => { + r#"DELETE FROM user_note_favorites WHERE user_id = $1 AND note_id = $2"# + } + Query::USER_FAVORITE_NOTE_EXISTS => { + r#"SELECT COUNT(*) FROM user_note_favorites WHERE user_id = $1 AND note_id = $2"# + } + Query::USER_FAVORITE_NOTE_LIST => { + r#"SELECT n.id, n.slug, n.title, n.updated_at, w.slug AS workspace_slug, w.title AS workspace_title, CAST(CASE WHEN w.is_private THEN 1 ELSE 0 END AS BIGINT) AS private, CAST(CASE WHEN w.password_hash IS NULL THEN 0 ELSE 1 END AS BIGINT) AS protected, f.created_at AS favorited_at FROM user_note_favorites f JOIN notes n ON n.id = f.note_id JOIN workspaces w ON w.id = n.workspace_id WHERE f.user_id = $1 ORDER BY f.created_at DESC, n.id DESC"# + } + Query::USER_FAVORITE_NOTE_IDS_BY_WORKSPACE => { + r#"SELECT f.note_id FROM user_note_favorites f JOIN notes n ON n.id = f.note_id WHERE f.user_id = $1 AND n.workspace_id = $2"# + } + Query::USER_FAVORITE_PADS_DELETE_BY_USER => { + r#"DELETE FROM user_pad_favorites WHERE user_id = $1"# + } + Query::USER_FAVORITE_NOTES_DELETE_BY_USER => { + r#"DELETE FROM user_note_favorites WHERE user_id = $1"# + } Query::USER_OWNS_WORKSPACE => { r#"SELECT COUNT(*) FROM user_workspaces uw JOIN workspaces w ON w.id = uw.workspace_id WHERE uw.user_id = $1 AND w.slug = $2"# } diff --git a/src/queries/sqlite.rs b/src/queries/sqlite.rs index 6cacf4d..f6201d9 100644 --- a/src/queries/sqlite.rs +++ b/src/queries/sqlite.rs @@ -178,6 +178,39 @@ pub fn get(query: Query) -> &'static str { Query::USER_LIST_PADS => { r#"SELECT p.slug, p.title, CASE WHEN p.password_hash IS NULL THEN 0 ELSE 1 END AS protected, p.updated_at, CASE WHEN p.is_private THEN 1 ELSE 0 END AS private, 1 AS owned, 'rw' AS permission, '' AS shared_by FROM user_pads up JOIN pads p ON p.id = up.pad_id WHERE up.user_id = ? UNION SELECT p.slug, p.title, CASE WHEN p.password_hash IS NULL THEN 0 ELSE 1 END, p.updated_at, CASE WHEN p.is_private THEN 1 ELSE 0 END, 0, rp.permission, COALESCE((SELECT u.nickname FROM user_pads owner_up JOIN users u ON u.id = owner_up.user_id WHERE owner_up.pad_id = p.id LIMIT 1), 'Unknown user') AS shared_by FROM resource_permissions rp JOIN pads p ON p.slug = rp.resource_slug WHERE rp.resource_kind = 'pad' AND rp.user_id = ? ORDER BY updated_at DESC"# } + Query::USER_FAVORITE_PAD_INSERT => { + r#"INSERT OR IGNORE INTO user_pad_favorites (user_id, pad_id) VALUES (?, ?)"# + } + Query::USER_FAVORITE_PAD_DELETE => { + r#"DELETE FROM user_pad_favorites WHERE user_id = ? AND pad_id = ?"# + } + Query::USER_FAVORITE_PAD_EXISTS => { + r#"SELECT COUNT(*) FROM user_pad_favorites WHERE user_id = ? AND pad_id = ?"# + } + Query::USER_FAVORITE_PAD_LIST => { + r#"SELECT p.id, p.slug, p.title, p.updated_at, CASE WHEN p.is_private THEN 1 ELSE 0 END AS private, CASE WHEN p.password_hash IS NULL THEN 0 ELSE 1 END AS protected, f.created_at AS favorited_at FROM user_pad_favorites f JOIN pads p ON p.id = f.pad_id WHERE f.user_id = ? ORDER BY f.created_at DESC, p.id DESC"# + } + Query::USER_FAVORITE_NOTE_INSERT => { + r#"INSERT OR IGNORE INTO user_note_favorites (user_id, note_id) VALUES (?, ?)"# + } + Query::USER_FAVORITE_NOTE_DELETE => { + r#"DELETE FROM user_note_favorites WHERE user_id = ? AND note_id = ?"# + } + Query::USER_FAVORITE_NOTE_EXISTS => { + r#"SELECT COUNT(*) FROM user_note_favorites WHERE user_id = ? AND note_id = ?"# + } + Query::USER_FAVORITE_NOTE_LIST => { + r#"SELECT n.id, n.slug, n.title, n.updated_at, w.slug AS workspace_slug, w.title AS workspace_title, CASE WHEN w.is_private THEN 1 ELSE 0 END AS private, CASE WHEN w.password_hash IS NULL THEN 0 ELSE 1 END AS protected, f.created_at AS favorited_at FROM user_note_favorites f JOIN notes n ON n.id = f.note_id JOIN workspaces w ON w.id = n.workspace_id WHERE f.user_id = ? ORDER BY f.created_at DESC, n.id DESC"# + } + Query::USER_FAVORITE_NOTE_IDS_BY_WORKSPACE => { + r#"SELECT f.note_id FROM user_note_favorites f JOIN notes n ON n.id = f.note_id WHERE f.user_id = ? AND n.workspace_id = ?"# + } + Query::USER_FAVORITE_PADS_DELETE_BY_USER => { + r#"DELETE FROM user_pad_favorites WHERE user_id = ?"# + } + Query::USER_FAVORITE_NOTES_DELETE_BY_USER => { + r#"DELETE FROM user_note_favorites WHERE user_id = ?"# + } Query::USER_OWNS_WORKSPACE => { r#"SELECT COUNT(*) FROM user_workspaces uw JOIN workspaces w ON w.id = uw.workspace_id WHERE uw.user_id = ? AND w.slug = ?"# } diff --git a/src/tests/queries.rs b/src/tests/queries.rs index 6a9a070..ba06e36 100644 --- a/src/tests/queries.rs +++ b/src/tests/queries.rs @@ -19,6 +19,17 @@ fn every_backend_has_explicit_queries() { Q033, USER_LIST_WORKSPACES, USER_LIST_PADS, + USER_FAVORITE_PAD_INSERT, + USER_FAVORITE_PAD_DELETE, + USER_FAVORITE_PAD_EXISTS, + USER_FAVORITE_PAD_LIST, + USER_FAVORITE_NOTE_INSERT, + USER_FAVORITE_NOTE_DELETE, + USER_FAVORITE_NOTE_EXISTS, + USER_FAVORITE_NOTE_LIST, + USER_FAVORITE_NOTE_IDS_BY_WORKSPACE, + USER_FAVORITE_PADS_DELETE_BY_USER, + USER_FAVORITE_NOTES_DELETE_BY_USER, RESOURCE_ACCESS_TOKENS_DELETE_BY_TOKEN_HASH, SHARE_LINK_SESSION_SOURCE, SHARE_SESSION_INSERT, diff --git a/src/tests/queries_mysql.rs b/src/tests/queries_mysql.rs index 0da70c1..f0e0f0e 100644 --- a/src/tests/queries_mysql.rs +++ b/src/tests/queries_mysql.rs @@ -23,6 +23,8 @@ fn boolean_projections_are_normalized_for_sqlx_any() { (Query::AUTH_USER_BY_SHARE_IDENTIFIER, 1), (Query::USER_LIST_WORKSPACES, 6), (Query::USER_LIST_PADS, 6), + (Query::USER_FAVORITE_PAD_LIST, 2), + (Query::USER_FAVORITE_NOTE_LIST, 2), (Query::PAD_PUBLIC_PAGE_DISABLED, 1), (Query::NOTE_PUBLIC_PAGE_DISABLED, 1), (Query::Q001, 1), diff --git a/static/css/styles.css b/static/css/styles.css index 39f8fbb..3870e5c 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -594,6 +594,56 @@ textarea:focus { min-width: 0; } +.document-heading-group { + display: flex; + min-width: 0; + align-items: center; + gap: 8px; +} + +.document-heading-group .document-heading { + min-width: 0; +} + +.favorite-toggle { + display: inline-grid; + flex: 0 0 auto; + width: 34px; + min-width: 34px; + height: 34px; + min-height: 34px; + padding: 0; + place-items: center; + border: 0; + border-radius: 0; + outline: 0; + background: transparent; + color: var(--muted); + font-size: 1.2rem; + line-height: 1; + transition: color .15s ease, text-shadow .15s ease, transform .15s ease; +} + +.favorite-toggle:not([aria-pressed="true"]):hover { + color: var(--text); +} + +.favorite-toggle:focus-visible { + color: var(--accent); + text-shadow: 0 0 8px color-mix(in srgb, var(--accent) 45%, transparent); +} + +.favorite-toggle[aria-pressed="true"] { + color: var(--accent); + text-shadow: 0 0 8px color-mix(in srgb, var(--accent) 55%, transparent); + transform: scale(1.08); +} + +.favorite-toggle:disabled { + opacity: .6; + cursor: wait; +} + .document-heading--copy { border-radius: 6px; cursor: copy; @@ -2633,6 +2683,55 @@ dialog::backdrop { border-radius: 0; } +.note-card-wrap--favorite-enabled .note-card { + padding-right: 58px; +} + +.note-card-favorite { + position: absolute; + z-index: 3; + top: 10px; + right: 10px; +} + +.note-favorite-button { + display: inline-grid; + width: 34px; + min-width: 34px; + height: 34px; + min-height: 34px; + padding: 0; + place-items: center; + border: 0; + border-radius: 0; + outline: 0; + background: transparent; + color: var(--muted); + font-size: 1.15rem; + line-height: 1; + transition: color .15s ease, text-shadow .15s ease, transform .15s ease; +} + +.note-favorite-button:not([aria-pressed="true"]):hover { + color: var(--text); +} + +.note-favorite-button:focus-visible { + color: var(--accent); + text-shadow: 0 0 8px color-mix(in srgb, var(--accent) 45%, transparent); +} + +.note-favorite-button[aria-pressed="true"] { + color: var(--accent); + text-shadow: 0 0 8px color-mix(in srgb, var(--accent) 55%, transparent); + transform: scale(1.08); +} + +.note-favorite-button:disabled { + opacity: .6; + cursor: wait; +} + .protect-badge, .file-flag { display: inline-flex; @@ -3133,6 +3232,22 @@ dialog::backdrop { font-size: .75rem; } +.notes-table-favorite { + width: 44px; + min-width: 44px; + padding-right: 8px !important; + padding-left: 8px !important; + text-align: center !important; + white-space: nowrap; +} + +.note-favorite-button--inline { + width: 30px; + min-width: 30px; + height: 30px; + min-height: 30px; +} + .notes-table-actions { width: 1%; white-space: nowrap; @@ -4148,6 +4263,79 @@ dialog::backdrop { margin-top: 18px; } +.resources-category { + margin-top: 20px; + min-width: 0; +} + +.resources-category--favorites { + padding-bottom: 18px; + border-bottom: 1px solid var(--border); +} + +.resources-category__header h3, +.resources-category__header p { + margin: 0; +} + +.resources-category__header h3 { + font-size: .92rem; +} + +.resources-category__header p { + margin-top: 4px; + color: var(--muted); + font-size: .78rem; +} + +.resources-category .resources-list { + margin-top: 10px; +} + +.resource-row.resource-row--favorite { + align-items: center; + flex-direction: row; + border-color: color-mix(in srgb, var(--accent) 32%, var(--border)); + background: color-mix(in srgb, var(--accent) 5%, transparent); +} + +.resource-row--favorite .resource-copy { + flex: 1 1 auto; +} + +.resource-favorite-remove { + display: inline-grid; + flex: 0 0 auto; + width: 34px; + min-width: 34px; + height: 34px; + min-height: 34px; + padding: 0; + place-items: center; + border: 0; + border-radius: 0; + outline: 0; + background: transparent; + color: var(--accent); + font-size: 1.1rem; + line-height: 1; + text-shadow: 0 0 8px color-mix(in srgb, var(--accent) 55%, transparent); + transition: color .15s ease, text-shadow .15s ease, transform .15s ease; +} + +.resource-favorite-remove:hover, +.resource-favorite-remove:focus-visible { + background: transparent; + color: var(--accent); + text-shadow: 0 0 10px color-mix(in srgb, var(--accent) 70%, transparent); + transform: scale(1.08); +} + +.resource-favorite-remove:disabled { + opacity: .6; + cursor: wait; +} + .resource-row { display: flex; min-width: 0; @@ -5940,6 +6128,11 @@ dialog::backdrop { flex: 1; } + .pad-page .document-heading-group { + min-width: 0; + flex: 1 1 auto; + } + .pad-page .header-user-control { margin-left: auto; } diff --git a/static/editor.html b/static/editor.html index 10196f6..a66d9c1 100644 --- a/static/editor.html +++ b/static/editor.html @@ -20,10 +20,14 @@ - `; + row.querySelector("[data-unfavorite]")?.addEventListener("click", async event => { + const button = event.currentTarget; + button.disabled = true; + try { + const target = { kind: item.kind, slug: item.slug }; + if (item.workspace_slug) target.workspace_slug = item.workspace_slug; + await api("/api/auth/favorites", { method: "DELETE", headers: authHeaders(), body: JSON.stringify(target) }); + toast.success(t("favorites.removed", {}, "Removed from favorites."), { title: t("favorites.title", {}, "Favorites") }); + await loadResources(); + } catch (error) { + button.disabled = false; + resourcesError.textContent = error.message; + toast.danger(error.message, { title: t("favorites.updateFailed", {}, "Could not update favorites") }); + } + }); + favoritesList.append(row); + } +} + async function loadResources() { - resourcesError.textContent = ""; resourcesList.innerHTML = "

Loading…

"; + resourcesError.textContent = ""; resourcesList.innerHTML = "

Loading…

"; if (favoritesList) favoritesList.innerHTML = "

Loading…

"; try { const params = new URLSearchParams({ q: resourcesSearch.value.trim(), page: String(resourcesPage), per_page: resourcesPerPage.value }); - const data = await api(`/api/auth/resources?${params}`, { headers: authHeaders() }); + const favoriteParams = new URLSearchParams({ q: resourcesSearch.value.trim() }); + const [data, favorites] = await Promise.all([ + api(`/api/auth/resources?${params}`, { headers: authHeaders() }), + api(`/api/auth/favorites?${favoriteParams}`, { headers: authHeaders() }), + ]); + renderFavorites(favorites.items || []); resourcesPage = data.pagination.page; const items = data.items.map(item => ({ ...item, url: item.kind === "workspace" ? `/w/${item.slug}` : `/p/${item.slug}` })); resourcesList.innerHTML = items.length ? "" : "

No assigned items yet.

"; @@ -395,7 +431,7 @@ async function loadResources() { resourcesList.append(row); } renderResourcesPagination(data.pagination); - } catch (e) { resourcesList.innerHTML = ""; resourcesPagination.innerHTML = ""; resourcesError.textContent = e.message; toast.danger(e.message, { title: "Could not load your items" }); } + } catch (e) { resourcesList.innerHTML = ""; if (favoritesList) favoritesList.innerHTML = ""; resourcesPagination.innerHTML = ""; resourcesError.textContent = e.message; toast.danger(e.message, { title: "Could not load your items" }); } } resourcesSearch?.addEventListener("input", () => { clearTimeout(resourcesSearchTimer); resourcesSearchTimer = setTimeout(() => { resourcesPage = 1; loadResources(); }, 250); }); diff --git a/static/js/note-api.js b/static/js/note-api.js index da091e1..338bce1 100644 --- a/static/js/note-api.js +++ b/static/js/note-api.js @@ -22,6 +22,7 @@ export function createPadAdapter() { return { access: { kind: "pad", key: slug }, + favorite: { kind: "pad", slug }, passwordScope: "note", addressSelector: "#document-url", title: info => `${info.title} · RustPad`, @@ -68,6 +69,7 @@ export function createWorkspaceNoteAdapter() { return { access: { kind: "workspace", key: workspaceSlug }, + favorite: { kind: "note", slug: noteSlug, workspace_slug: workspaceSlug }, passwordScope: "workspace", addressSelector: "#document-url", title: info => `${info.title} · ${info.workspace_title}`, diff --git a/static/js/note-editor.js b/static/js/note-editor.js index 66850bc..8c35808 100644 --- a/static/js/note-editor.js +++ b/static/js/note-editor.js @@ -8,6 +8,7 @@ */ import { installGlobalDiagnostics, logInfo } from "@rustpad/logger"; +import { api } from "@rustpad/api"; installGlobalDiagnostics(); import { applyAuthorshipEdit, authorshipOwners, lineAuthors, mapSelectionThroughEdit, parseAuthorship, renderAuthorshipLayer, replaceAuthorshipOwner, serializeAuthorship, syncAuthorshipLayer } from "@rustpad/authorship"; @@ -36,7 +37,7 @@ export function startNoteEditor(adapter) { const editor = document.querySelector("#editor"), preview = document.querySelector("#preview"), editorWorkspace = document.querySelector("#editor-workspace"), gutter = document.querySelector("#line-gutter"), ownerLabels = document.querySelector("#owner-labels"), authorshipLayer = document.querySelector("#authorship-layer"); const modeToggle = document.querySelector("#mode-toggle"), toolbarCollapseToggle = document.querySelector("#toolbar-collapse-toggle"), navbarCollapseToggle = document.querySelector("#navbar-collapse-toggle"), passwordDialog = document.querySelector("#password-dialog"), identityDialog = document.querySelector("#identity-dialog"); const accessLevel = document.querySelector("#access-level"), roomDetails = document.querySelector("#room-details"), roomUsers = document.querySelector("#room-users"), roomCount = document.querySelector("#room-count"), socketLatency = document.querySelector("#socket-latency"), mobileConnectionDetails = document.querySelector("#mobile-connection-details"), chatMessages = document.querySelector("#chat-messages"), chatForm = document.querySelector("#chat-form"), chatInput = document.querySelector("#chat-input"), chatUnread = document.querySelector("#chat-unread"), mobileChatUnread = document.querySelector("#mobile-chat-unread"), connectionNotice = document.querySelector("#connection-notice"), connectionNoticeTitle = document.querySelector("#connection-notice-title"), connectionNoticeMessage = document.querySelector("#connection-notice-message"); - const saveState = document.querySelector("#save-state"); + const saveState = document.querySelector("#save-state"), favoriteToggle = document.querySelector("#favorite-toggle"); editor.readOnly = true; let unreadChat = 0; const compactToggle = document.querySelector("#compact-toggle"), lineLinksToggle = document.querySelector("#line-links-toggle"), authorshipColorsToggle = document.querySelector("#authorship-colors-toggle"), authorshipColorsLabel = document.querySelector("#authorship-colors-label"), publicPageEnabled = document.querySelector("#public-page-enabled"), publicTaskUpdates = document.querySelector("#public-task-updates"), unprotectPublicPage = document.querySelector("#unprotect-public-page"), participantBadges = document.querySelector("#participant-badges"), fontFamily = document.querySelector("#font-family"), fontSize = document.querySelector("#font-size"), currentUser = document.querySelector("#current-user"), userColorPicker = document.querySelector("#user-color-picker"), mobileColorPicker = document.querySelector("#mobile-color-picker"), useGlobalColorButton = document.querySelector("#use-global-color"); @@ -49,7 +50,7 @@ export function startNoteEditor(adapter) { ? crypto.randomUUID().replaceAll("-", "") : `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`; const collaboration = new CollaborationSession(collaborationClientId); - let accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key), password = "", nickname = getNickname(), info, socket, saveTimer, applyingRemote = false, applyingHistory = false, resourceUnlocked = false, uiState = readEditorState(), authorship = parseAuthorship("", "[]"), previousContent = "", globalColor = "", noteColor = "", presenceUsers = [], authorshipMode = "simple", authorshipColorsEnabled = true, lastRevealedLineHash = "", flushRequested = false; + let accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key), password = "", nickname = getNickname(), info, socket, saveTimer, applyingRemote = false, applyingHistory = false, resourceUnlocked = false, uiState = readEditorState(), authorship = parseAuthorship("", "[]"), previousContent = "", globalColor = "", noteColor = "", presenceUsers = [], authorshipMode = "simple", authorshipColorsEnabled = true, lastRevealedLineHash = "", flushRequested = false, accountSession = null; let editorSettingsSaveTimer, editorSettingsSaveInFlight = false, pendingPersonalSettingsSave = false, pendingAuthorshipSettingsSave = false, connectionNoticeTimer = 0, connectionWasInterrupted = false; let pendingPreviewViewport = null; const editHistory = { @@ -225,6 +226,41 @@ export function startNoteEditor(adapter) { : {}; } function accountHeaders() { return {}; } + function favoriteParams() { + const target = adapter.favorite; + if (!target) return ""; + const params = new URLSearchParams({ kind: target.kind, slug: target.slug }); + if (target.workspace_slug) params.set("workspace_slug", target.workspace_slug); + return params.toString(); + } + function renderFavoriteButton(active) { + if (!favoriteToggle) return; + const favorite = Boolean(active); + favoriteToggle.hidden = !accountSession || !adapter.favorite; + favoriteToggle.setAttribute("aria-pressed", String(favorite)); + favoriteToggle.querySelector("span").textContent = favorite ? "★" : "☆"; + const label = favorite + ? t("favorites.remove", {}, "Remove from favorites") + : t("favorites.add", {}, "Add to favorites"); + favoriteToggle.title = label; + favoriteToggle.setAttribute("aria-label", label); + } + async function syncFavoriteButton() { + if (!favoriteToggle || !adapter.favorite || !accountSession) { + renderFavoriteButton(false); + return; + } + try { + const status = await api(`/api/auth/favorites/status?${favoriteParams()}`); + renderFavoriteButton(Boolean(status.favorite)); + } catch (error) { + if (error.status === 401 || error.status === 403 || error.status === 404) { + favoriteToggle.hidden = true; + return; + } + renderFavoriteButton(false); + } + } async function loadNoteInfo() { info = await adapter.loadInfo(sessionHeaders()); const documentTitle = document.querySelector("#document-title"); @@ -1574,7 +1610,7 @@ export function startNoteEditor(adapter) { }); socket.connect(); } - bindIdentityDialog({ dialog: identityDialog, onIdentity: async value => { nickname = value; accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key); identityDialog.close(); updateCurrentUser(); await loadNoteInfo(); if (info.protected && info.access_level === "none") passwordDialog.showModal(); else { loadFiles(); connect(); } } }); + bindIdentityDialog({ dialog: identityDialog, onIdentity: async (value, session) => { nickname = value; accountSession = session || await validateCurrentSession(); accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key); identityDialog.close(); updateCurrentUser(); await loadNoteInfo(); await syncFavoriteButton(); if (info.protected && info.access_level === "none") passwordDialog.showModal(); else { loadFiles(); connect(); } } }); identityDialog.addEventListener("close", () => { if (!nickname) queueMicrotask(() => { if (!identityDialog.open) identityDialog.showModal(); }); }); async function showSystemNotFound() { try { @@ -1594,8 +1630,8 @@ export function startNoteEditor(adapter) { async function initialize() { applyUi(); try { - const session = await validateCurrentSession(); - nickname = session?.nickname || getNickname(); + accountSession = await validateCurrentSession(); + nickname = accountSession?.nickname || getNickname(); if (!nickname) { if (!identityDialog.open) identityDialog.showModal(); return; @@ -1608,6 +1644,7 @@ export function startNoteEditor(adapter) { adapter.configureView?.(info); applyUi({ write: true, replace: true }); updateCurrentUser(); + await syncFavoriteButton(); if (info.protected && info.access_level === "none") passwordDialog.showModal(); else { loadFiles(); connect(); } } catch (e) { @@ -1943,6 +1980,37 @@ export function startNoteEditor(adapter) { event.preventDefault(); copyCurrentLink(); }); + favoriteToggle?.addEventListener("click", async () => { + if (!accountSession || !adapter.favorite) return; + const currentlyFavorite = favoriteToggle.getAttribute("aria-pressed") === "true"; + favoriteToggle.disabled = true; + try { + const result = await api("/api/auth/favorites", { + method: currentlyFavorite ? "DELETE" : "PUT", + body: JSON.stringify(adapter.favorite), + }); + renderFavoriteButton(Boolean(result.favorite)); + toast.success( + result.favorite + ? t("favorites.added", {}, "Added to favorites.") + : t("favorites.removed", {}, "Removed from favorites."), + { title: t("favorites.title", {}, "Favorites") }, + ); + } catch (error) { + toast.danger(error.message, { title: t("favorites.updateFailed", {}, "Could not update favorites") }); + await syncFavoriteButton(); + } finally { + favoriteToggle.disabled = false; + } + }); + window.addEventListener("rustpad:session-change", event => { + accountSession = event.detail?.session || null; + syncFavoriteButton(); + }); + window.addEventListener("rustpad:session-expired", () => { + accountSession = null; + renderFavoriteButton(false); + }); let pendingPreviewFormatRange = null; document.querySelectorAll("[data-format]").forEach(button => { @@ -2161,6 +2229,7 @@ export function startNoteEditor(adapter) { password = ""; setPagePasswordInput.value = ""; await loadNoteInfo(); + await syncFavoriteButton(); resourceUnlocked = false; socket?.stop(); loadFiles(); @@ -2258,7 +2327,7 @@ export function startNoteEditor(adapter) { document.querySelector("#open-password")?.focus(); } }); - document.querySelector("#password-form").addEventListener("submit", async e => { e.preventDefault(); try { password = document.querySelector("#open-password").value; const result = await adapter.requestAccess(password); setAccessToken(adapter.access.kind, adapter.access.key, result.granted); accessToken = getAccessToken(adapter.access.kind, adapter.access.key); password = ""; document.querySelector("#open-password").value = ""; document.querySelector("#password-error").textContent = ""; await loadNoteInfo(); loadFiles(); connect(); toast.success("Editing access has been unlocked.", { title: "Note unlocked" }); } catch (error) { document.querySelector("#password-error").textContent = error.message; toast.danger(error.message, { title: "Could not unlock note" }); } }); + document.querySelector("#password-form").addEventListener("submit", async e => { e.preventDefault(); try { password = document.querySelector("#open-password").value; const result = await adapter.requestAccess(password); setAccessToken(adapter.access.kind, adapter.access.key, result.granted); accessToken = getAccessToken(adapter.access.kind, adapter.access.key); password = ""; document.querySelector("#open-password").value = ""; document.querySelector("#password-error").textContent = ""; await loadNoteInfo(); await syncFavoriteButton(); loadFiles(); connect(); toast.success("Editing access has been unlocked.", { title: "Note unlocked" }); } catch (error) { document.querySelector("#password-error").textContent = error.message; toast.danger(error.message, { title: "Could not unlock note" }); } }); const historyPanel = document.querySelector("#history-panel"); document.querySelector("#history-button").addEventListener("click", async () => { if (info?.protected && !resourceUnlocked) { if (!passwordDialog.open) passwordDialog.showModal(); document.querySelector("#open-password")?.focus(); return; } historyPanel.classList.add("open"); historyPanel.setAttribute("aria-hidden", "false"); document.body.classList.add("history-open"); const list = document.querySelector("#history-list"); list.innerHTML = '

Loading…

'; try { const revisions = await adapter.loadHistory(accessToken); list.innerHTML = revisions.length ? revisions.map((r, i) => { const snippet = escapeHtml(r.content.trim().split("\n").slice(0, 3).join(" · ").slice(0, 150) || "Empty note"); const author = r.author || "Unknown author"; return `
${escapeHtml(author)}

${snippet}

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

No history yet.

'; for (const r of revisions) { list.querySelector(`[data-preview="${r.id}"]`)?.addEventListener("click", () => { const el = list.querySelector(`#preview-${r.id}`); el.hidden = !el.hidden; el.textContent = r.content; }); list.querySelector(`[data-revision="${r.id}"]`)?.addEventListener("click", async () => { await adapter.restoreRevision(r.id, accessToken); toast.success("The selected revision is now the current version.", { title: "Version restored" }); }); } } catch (e) { list.innerHTML = `

${escapeHtml(e.message)}

`; toast.danger(e.message, { title: "Could not load version history" }); } }); document.querySelector("#close-history").addEventListener("click", () => { historyPanel.classList.remove("open"); historyPanel.setAttribute("aria-hidden", "true"); document.body.classList.remove("history-open"); }); const deleteNoteButton = document.querySelector("#delete-note"); if (deleteNoteButton && adapter.deleteNote) deleteNoteButton.addEventListener("click", async () => { try { await adapter.deleteNote(info, accessToken); } catch (error) { toast.danger(error.message, { title: "Could not delete note" }); } }); diff --git a/static/js/workspace.js b/static/js/workspace.js index e8ee7a4..c0b6c7c 100644 --- a/static/js/workspace.js +++ b/static/js/workspace.js @@ -12,12 +12,12 @@ installGlobalDiagnostics(); import { api } from "@rustpad/api"; import { copyText } from "@rustpad/clipboard"; -import { getNickname, getAccessToken, getAuthToken, getGuestId, setAccessToken } from "@rustpad/session"; +import { getNickname, getAccessToken, getGuestId, setAccessToken } from "@rustpad/session"; import { bindIdentityDialog, validateCurrentSession } from "@rustpad/auth-ui"; import { askConfirm } from "@rustpad/modal"; import { isResourceAccessError, safeAppUrl } from "@rustpad/security"; import { consumeQueuedToast, toast } from "@rustpad/toast"; -import { formatDateTime, formatNumber } from "@rustpad/i18n"; +import { formatDateTime, formatNumber, t } from "@rustpad/i18n"; consumeQueuedToast(); @@ -27,6 +27,7 @@ let info; const shareToken = new URLSearchParams(location.search).get("share"); let accessToken = shareToken || getAccessToken("workspace", slug); let nickname = getNickname(); +let accountSession = null; getGuestId(); const workspaceWatchClientId = `workspace_watch_${crypto.randomUUID()}`; let workspaceWatchSocket; @@ -177,6 +178,57 @@ function deleteButton(note, inline = false) { : "Read-write access is required to delete this note"; return ``; } +function favoriteButton(note, inline = false) { + if (!accountSession) return ""; + const active = Boolean(note.favorite); + const label = active + ? t("favorites.remove", {}, "Remove from favorites") + : t("favorites.add", {}, "Add to favorites"); + return ``; +} +function updateFavoriteButton(button, active) { + const favorite = Boolean(active); + const label = favorite + ? t("favorites.remove", {}, "Remove from favorites") + : t("favorites.add", {}, "Add to favorites"); + button.setAttribute("aria-pressed", String(favorite)); + button.title = label; + button.setAttribute("aria-label", label); + const icon = button.querySelector("span"); + if (icon) icon.textContent = favorite ? "★" : "☆"; +} +async function toggleNoteFavorite(button) { + if (!accountSession) return; + const note = notesCache.find(item => item.slug === button.dataset.favoriteNote); + if (!note) return; + const current = Boolean(note.favorite); + button.disabled = true; + try { + const result = await api("/api/auth/favorites", { + method: current ? "DELETE" : "PUT", + body: JSON.stringify({ kind: "note", slug: note.slug, workspace_slug: slug }), + }); + note.favorite = Boolean(result.favorite); + document.querySelectorAll(`[data-favorite-note="${CSS.escape(note.slug)}"]`).forEach(target => updateFavoriteButton(target, note.favorite)); + toast.success( + note.favorite + ? t("favorites.added", {}, "Added to favorites.") + : t("favorites.removed", {}, "Removed from favorites."), + { title: t("favorites.title", {}, "Favorites") }, + ); + } catch (error) { + if (error.status === 401 || error.status === 403) { + const session = await validateCurrentSession(); + if (!session) { + accountSession = null; + renderNotes(); + } + } + toast.danger(error.message, { title: t("favorites.updateFailed", {}, "Could not update favorites") }); + } finally { + if (button.isConnected) button.disabled = false; + } +} function renderNotes(notes = notesCache) { notesCache = notes; setNotesView(notesView); @@ -185,8 +237,10 @@ function renderNotes(notes = notesCache) { return; } if (notesView === "table") { - notesList.innerHTML = `
${notes.map(note => ` + const favoriteHeader = accountSession ? '' : ""; + notesList.innerHTML = `
NameCreated byParticipantsFilesRevisionsStatusUpdatedActions
Favorites
${favoriteHeader}${notes.map(note => ` + ${accountSession ? `` : ""} @@ -199,11 +253,12 @@ function renderNotes(notes = notesCache) { return; } notesList.innerHTML = notes.map(note => ` -
+ `).join(""); } @@ -323,6 +378,12 @@ document.querySelector("#note-form").addEventListener("submit", async e => { } catch (err) { error.textContent = err.message; toast.danger(err.message, { title: "Could not create note" }); } }); notesList.addEventListener("click", async event => { + const favorite = event.target.closest("[data-favorite-note]"); + if (favorite) { + event.preventDefault(); + await toggleNoteFavorite(favorite); + return; + } const button = event.target.closest("[data-delete-note]"); if (!button) return; const title = button.dataset.noteTitle; @@ -350,6 +411,7 @@ document.querySelector("#copy-workspace-link").addEventListener("click", async ( }); async function startAuthorizedWorkspace() { const session = await validateCurrentSession(); + accountSession = session; nickname = session?.nickname || getNickname(); if (!nickname) { @@ -365,8 +427,9 @@ async function startAuthorizedWorkspace() { bindIdentityDialog({ dialog: identityDialog, - onIdentity: async value => { + onIdentity: async (value, session) => { nickname = value; + accountSession = session; accessToken = shareToken || getAccessToken("workspace", slug); workspaceContent.hidden = false; await init(); @@ -386,6 +449,12 @@ dialog.addEventListener("cancel", event => { } }); +window.addEventListener("rustpad:session-change", event => { + if (event.detail?.session) return; + if (!accountSession) return; + accountSession = null; + renderNotes(); +}); window.addEventListener("beforeunload", stopWorkspaceWatch); setNotesView(notesView);
NameCreated byParticipantsFilesRevisionsStatusUpdatedActions
${favoriteButton(note, true)}${escapeHtml(note.title)} ${escapeHtml(note.created_by || "Unknown")} ${Number(note.participant_count) || 0}