diff --git a/Cargo.lock b/Cargo.lock index 93fc71c..1d57390 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2581,7 +2581,7 @@ dependencies = [ [[package]] name = "rustpad" -version = "0.2.84" +version = "0.2.85" dependencies = [ "argon2", "aws-config", diff --git a/Cargo.toml b/Cargo.toml index 8bb61c5..7e2be48 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustpad" -version = "0.2.84" +version = "0.2.85" edition = "2024" rust-version = "1.94" description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" diff --git a/lang/en.json b/lang/en.json index e340606..d71466f 100644 --- a/lang/en.json +++ b/lang/en.json @@ -422,10 +422,24 @@ "editor.hideNavigation": "Hide navigation bar", "editor.hideToolbar": "Hide editor toolbar", "editor.highlight": "Highlight", + "editor.historyArchiveActivity": "Revision activity by month", + "editor.historyArchiveHint": "Select a highlighted day to inspect saved versions.", + "editor.historyArchiveInvalidDates": "Archive view needs valid revision timestamps.", + "editor.historyArchiveView": "Archive", + "editor.historyArchiveYear": "Archive year", "editor.historyCopy": "Author, time, and version preview", "editor.historyEmpty": "No history yet.", + "editor.historyEmptyNote": "Empty note", + "editor.historyListView": "List", "editor.historyLoadFailed": "Could not load version history", + "editor.historyRestore": "Restore", + "editor.historyRevisionCount.one": "{count} revision", + "editor.historyRevisionCount.other": "{count} revisions", + "editor.historySavedVersionCount.one": "{count} saved version", + "editor.historySavedVersionCount.other": "{count} saved versions", "editor.historyTitle": "Change history", + "editor.historyUnknownAuthor": "Unknown author", + "editor.historyView": "History view", "editor.home": "RustPad home", "editor.horizontalRule": "Horizontal rule", "editor.imageSizeError": "Enter a width and height between 1 and 10,000 px.", @@ -882,6 +896,18 @@ "vendor.globalMissing": "{name} did not register a browser global.", "vendor.loadFailed": "Failed to load {path}.", "words.zero": "0 words", + "workspace.archive.archivedAt": "Archived: {date}", + "workspace.archive.copy": "Restore notes that were archived instead of permanently deleted.", + "workspace.archive.createdBy": "Created by: {author}", + "workspace.archive.empty": "No archived notes.", + "workspace.archive.loadFailed": "Could not load archived notes", + "workspace.archive.restore": "Restore", + "workspace.archive.restoreAsk": "Restore note “{title}” to this workspace?", + "workspace.archive.restoreFailed": "Could not restore note", + "workspace.archive.restoreTitle": "Restore archived note", + "workspace.archive.restored": "The note “{title}” was restored.", + "workspace.archive.restoredTitle": "Note restored", + "workspace.archive.title": "Archived notes", "workspace.aria": "RustPad Workspace", "workspace.cards": "Cards", "workspace.connectionError": "Workspace connection error", @@ -916,6 +942,14 @@ "workspace.protectedBadge": "Protected", "workspace.search": "Search notes", "workspace.search.placeholder": "Search notes…", + "workspace.settings.archiveDisabled": "Deleted notes will be permanently removed.", + "workspace.settings.archiveEnabled": "Deleted notes will be archived in the database.", + "workspace.settings.archiveToggle": "Archive deleted notes instead of permanently deleting them", + "workspace.settings.copy": "Workspace-wide setting. Archived notes disappear from the workspace but remain in the database with their history and files.", + "workspace.settings.deletedNotes": "Deleted notes", + "workspace.settings.saveFailed": "Could not save workspace settings", + "workspace.settings.saved": "Workspace deletion policy saved.", + "workspace.settings.title": "Workspace settings", "workspace.stats.files": "Files: {count} ({size})", "workspace.stats.participants": "Participants: {count}", "workspace.stats.revisions": "Revisions: {count}", diff --git a/lang/pl.json b/lang/pl.json index 9554d12..a4de065 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -422,10 +422,28 @@ "editor.hideNavigation": "Ukryj pasek nawigacji", "editor.hideToolbar": "Ukryj pasek narzędzi edytora", "editor.highlight": "Wyróżnienie", + "editor.historyArchiveActivity": "Aktywność wersji według miesiąca", + "editor.historyArchiveHint": "Wybierz wyróżniony dzień, aby sprawdzić zapisane wersje.", + "editor.historyArchiveInvalidDates": "Widok archiwum wymaga prawidłowych znaczników czasu wersji.", + "editor.historyArchiveView": "Archiwum", + "editor.historyArchiveYear": "Rok archiwum", "editor.historyCopy": "Autor, czas i podgląd wersji", "editor.historyEmpty": "Brak historii.", + "editor.historyEmptyNote": "Pusta notatka", + "editor.historyListView": "Lista", "editor.historyLoadFailed": "Nie udało się wczytać historii wersji", + "editor.historyRestore": "Przywróć", + "editor.historyRevisionCount.one": "{count} wersja", + "editor.historyRevisionCount.few": "{count} wersje", + "editor.historyRevisionCount.many": "{count} wersji", + "editor.historyRevisionCount.other": "{count} wersji", + "editor.historySavedVersionCount.one": "{count} zapisana wersja", + "editor.historySavedVersionCount.few": "{count} zapisane wersje", + "editor.historySavedVersionCount.many": "{count} zapisanych wersji", + "editor.historySavedVersionCount.other": "{count} zapisanych wersji", "editor.historyTitle": "Historia zmian", + "editor.historyUnknownAuthor": "Nieznany autor", + "editor.historyView": "Widok historii", "editor.home": "Strona główna RustPad", "editor.horizontalRule": "Linia pozioma", "editor.imageSizeError": "Podaj szerokość i wysokość od 1 do 10 000 px.", @@ -882,6 +900,18 @@ "vendor.globalMissing": "{name} nie zarejestrował globalnego obiektu przeglądarki.", "vendor.loadFailed": "Nie udało się wczytać {path}.", "words.zero": "0 słów", + "workspace.archive.archivedAt": "Zarchiwizowano: {date}", + "workspace.archive.copy": "Przywracaj notatki, które zostały zarchiwizowane zamiast trwale usunięte.", + "workspace.archive.createdBy": "Utworzona przez: {author}", + "workspace.archive.empty": "Brak zarchiwizowanych notatek.", + "workspace.archive.loadFailed": "Nie udało się wczytać zarchiwizowanych notatek", + "workspace.archive.restore": "Przywróć", + "workspace.archive.restoreAsk": "Przywrócić notatkę „{title}” do tego obszaru roboczego?", + "workspace.archive.restoreFailed": "Nie udało się przywrócić notatki", + "workspace.archive.restoreTitle": "Przywróć zarchiwizowaną notatkę", + "workspace.archive.restored": "Notatka „{title}” została przywrócona.", + "workspace.archive.restoredTitle": "Notatka przywrócona", + "workspace.archive.title": "Archiwizowane notatki", "workspace.aria": "Obszar roboczy RustPad", "workspace.cards": "Karty", "workspace.connectionError": "Błąd połączenia z obszarem roboczym", @@ -916,6 +946,14 @@ "workspace.protectedBadge": "Chroniona", "workspace.search": "Szukaj notatek", "workspace.search.placeholder": "Szukaj notatek…", + "workspace.settings.archiveDisabled": "Usunięte notatki będą trwale usuwane.", + "workspace.settings.archiveEnabled": "Usunięte notatki będą archiwizowane w bazie danych.", + "workspace.settings.archiveToggle": "Archiwizuj usuwane notatki zamiast trwale je usuwać", + "workspace.settings.copy": "Ustawienie dla całego obszaru roboczego. Zarchiwizowane notatki znikają z obszaru, ale pozostają w bazie wraz z historią i plikami.", + "workspace.settings.deletedNotes": "Usuwane notatki", + "workspace.settings.saveFailed": "Nie udało się zapisać ustawień obszaru roboczego", + "workspace.settings.saved": "Zapisano politykę usuwania notatek.", + "workspace.settings.title": "Ustawienia obszaru roboczego", "workspace.stats.files": "Pliki: {count} ({size})", "workspace.stats.participants": "Uczestnicy: {count}", "workspace.stats.revisions": "Wersje: {count}", diff --git a/src/api/mod.rs b/src/api/mod.rs index e5c9b0c..f9f4ce5 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -452,6 +452,17 @@ pub struct NoteListItem { revision_count: i64, } +#[derive(Debug, Serialize)] +pub struct ArchivedNoteListItem { + slug: String, + title: String, + created_at: String, + updated_at: String, + deleted_at: String, + protected: bool, + created_by: Option, +} + #[derive(Debug, Serialize)] pub struct NoteInfo { workspace_slug: String, @@ -788,6 +799,58 @@ pub async fn set_workspace_settings( ))) } +pub async fn archived_notes( + State(state): State, + headers: HeaderMap, + Path(workspace_slug): Path, +) -> Result>, ApiError> { + let workspace = db::find_workspace(&state.db, &workspace_slug) + .await? + .ok_or_else(ApiError::not_found_workspace)?; + if !can_manage_workspace_settings(&state, &headers, &workspace).await { + return Err(ApiError::forbidden( + "Only the workspace owner can view archived notes.", + )); + } + let notes = db::list_archived_notes(&state.db, workspace.id) + .await? + .into_iter() + .map(|note| ArchivedNoteListItem { + slug: note.slug, + title: note.title, + created_at: db::normalize_timestamp(¬e.created_at), + updated_at: db::normalize_timestamp(¬e.updated_at), + deleted_at: db::normalize_timestamp(¬e.deleted_at), + protected: note.protected, + created_by: note.created_by, + }) + .collect(); + Ok(Json(notes)) +} + +pub async fn restore_archived_note( + State(state): State, + headers: HeaderMap, + Path((workspace_slug, note_slug)): Path<(String, String)>, +) -> Result, ApiError> { + let workspace = db::find_workspace(&state.db, &workspace_slug) + .await? + .ok_or_else(ApiError::not_found_workspace)?; + if !can_manage_workspace_settings(&state, &headers, &workspace).await { + return Err(ApiError::forbidden( + "Only the workspace owner can restore archived notes.", + )); + } + if !db::restore_archived_note(&state.db, workspace.id, ¬e_slug).await? { + return Err(ApiError::not_found_note()); + } + Ok(Json(serde_json::json!({ + "ok": true, + "slug": note_slug, + "url": format!("/w/{workspace_slug}/n/{note_slug}") + }))) +} + pub async fn set_workspace_password( State(state): State, headers: HeaderMap, diff --git a/src/app/mod.rs b/src/app/mod.rs index 3fc3f16..67b8ae1 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -187,6 +187,14 @@ pub fn router( "/api/workspaces/{workspace_slug}/settings", post(api::set_workspace_settings), ) + .route( + "/api/workspaces/{workspace_slug}/archived-notes", + get(api::archived_notes), + ) + .route( + "/api/workspaces/{workspace_slug}/archived-notes/{note_slug}/restore", + post(api::restore_archived_note), + ) .route( "/api/workspaces/{workspace_slug}/open", post(api::open_workspace), diff --git a/src/db/mod.rs b/src/db/mod.rs index 3e458fb..81e1f10 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -74,6 +74,19 @@ pub struct Note { pub created_by_guest_id: Option, } +#[derive(Debug, Clone, Serialize)] +pub struct ArchivedNote { + #[serde(skip_serializing)] + pub id: i64, + pub slug: String, + pub title: String, + pub created_at: String, + pub updated_at: String, + pub deleted_at: String, + pub protected: bool, + pub created_by: Option, +} + #[derive(Debug, Clone)] pub struct CollaborationSnapshot { pub content: String, @@ -325,6 +338,89 @@ pub async fn archive_note(pool: &Database, note_id: i64) -> Result<(), sqlx::Err Ok(()) } +/// Return notes that were soft-deleted from a workspace, newest first. +pub async fn list_archived_notes( + pool: &Database, + workspace_id: i64, +) -> Result, sqlx::Error> { + let query = match pool.kind() { + DatabaseKind::Sqlite => { + r#"SELECT id, 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, + 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, + 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 + FROM notes + WHERE workspace_id = ? AND deleted_at IS NOT NULL + ORDER BY deleted_at DESC, id DESC"# + } + }; + let rows = sqlx::query(query) + .bind(workspace_id) + .fetch_all(pool.pool()) + .await?; + rows.into_iter() + .map(|row| { + Ok(ArchivedNote { + id: row.try_get("id")?, + slug: row.try_get("slug")?, + title: row.try_get("title")?, + created_at: row.try_get("created_at")?, + updated_at: row.try_get("updated_at")?, + deleted_at: row.try_get("deleted_at")?, + protected: row.try_get::("protected")? != 0, + created_by: row.try_get("created_by")?, + }) + }) + .collect() +} + +/// Restore a soft-deleted note. Returns false when the archived note no longer exists. +pub async fn restore_archived_note( + pool: &Database, + workspace_id: i64, + slug: &str, +) -> Result { + let query = match pool.kind() { + DatabaseKind::Postgres => { + r#"UPDATE notes SET deleted_at = NULL, updated_at = (CURRENT_TIMESTAMP::text) + WHERE workspace_id = $1 AND slug = $2 AND deleted_at IS NOT NULL"# + } + DatabaseKind::Sqlite | DatabaseKind::MySql => { + r#"UPDATE notes SET deleted_at = NULL, updated_at = CURRENT_TIMESTAMP + WHERE workspace_id = ? AND slug = ? AND deleted_at IS NOT NULL"# + } + }; + let mut tx = pool.pool().begin().await?; + let result = sqlx::query(query) + .bind(workspace_id) + .bind(slug) + .execute(&mut *tx) + .await?; + if result.rows_affected() == 0 { + tx.rollback().await?; + return Ok(false); + } + sqlx::query(queries::get(pool.kind(), queries::Q007)) + .bind(workspace_id) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(true) +} + pub async fn create_note( pool: &Database, workspace_id: i64, diff --git a/static/css/styles.css b/static/css/styles.css index 3ede1ae..4661cf4 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -1901,6 +1901,18 @@ dialog::backdrop { color: var(--muted); } +.workspace-top__actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + flex-wrap: wrap; +} + +.workspace-top__actions [hidden] { + display: none; +} + .notes-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); @@ -3638,6 +3650,68 @@ dialog::backdrop { display: none; } +.archived-notes-dialog { + width: min(620px, calc(100% - 24px)); +} + +.archived-notes-panel { + grid-template-rows: auto minmax(0, 1fr); + max-height: min(78dvh, 720px); + overflow: hidden; +} + +.archived-notes-panel__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.archived-notes-panel__header h2 { + margin: 0; +} + +.archived-notes-panel__header .dialog-copy { + margin: 5px 0 0; +} + +.archived-notes-list { + display: grid; + gap: 8px; + min-height: 0; + overflow: auto; + padding-right: 2px; +} + +.archived-note-card { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + padding: 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface-card); +} + +.archived-note-card__copy { + display: grid; + min-width: 0; + gap: 3px; +} + +.archived-note-card__copy strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.archived-note-card__copy span, +.archived-note-card__copy small { + color: var(--muted); + font-size: .74rem; +} + .notes-view-switch { display: inline-flex; padding: 3px; @@ -3777,6 +3851,21 @@ dialog::backdrop { @media (max-width: 700px) { .workspace-top { align-items: flex-start; + flex-direction: column; + } + + .workspace-top__actions { + width: 100%; + justify-content: flex-start; + } + + .archived-note-card { + grid-template-columns: 1fr; + align-items: stretch; + } + + .archived-note-card .secondary-button { + width: 100%; } .workspace-password-card { diff --git a/static/editor.html b/static/editor.html index 0212a27..88919c2 100644 --- a/static/editor.html +++ b/static/editor.html @@ -272,9 +272,9 @@

Author, time, and version preview

-
- - +
+ +
diff --git a/static/js/note-editor.js b/static/js/note-editor.js index eb6507e..98ef35c 100644 --- a/static/js/note-editor.js +++ b/static/js/note-editor.js @@ -26,7 +26,7 @@ import { bindIdentityDialog, validateCurrentSession } from "@rustpad/auth-ui"; import { bindNoteFiles } from "@rustpad/note-files"; import { currentShareUrl, readEditorState, writeEditorState } from "@rustpad/url-state"; import { toast } from "@rustpad/toast"; -import { formatDateTime, formatNumber, formatTime, t, translateSource } from "@rustpad/i18n"; +import { formatDateTime, formatNumber, formatTime, getLocale, t, tp, translateSource } from "@rustpad/i18n"; import { getTheme } from "@rustpad/theme"; import { isResourceAccessError } from "@rustpad/security"; import { syncSelectControl } from "@rustpad/select"; @@ -2442,16 +2442,17 @@ export function startNoteEditor(adapter) { } function historyRevisionCard(revision, compact = false) { - const snippet = escapeHtml(revision.content.trim().split("\n").slice(0, compact ? 2 : 3).join(" · ").slice(0, compact ? 110 : 150) || "Empty note"); - const author = revision.author || "Unknown author"; - return `
${escapeHtml(author)}

${snippet}

`; + const emptyNote = t("editor.historyEmptyNote", {}, "Empty note"); + const snippet = escapeHtml(revision.content.trim().split("\n").slice(0, compact ? 2 : 3).join(" · ").slice(0, compact ? 110 : 150) || emptyNote); + const author = revision.author || t("editor.historyUnknownAuthor", {}, "Unknown author"); + return `
${escapeHtml(author)}

${snippet}

`; } function renderHistoryListView() { historyList.classList.remove("history-list--archive"); historyList.innerHTML = historyRevisions.length ? historyRevisions.map(revision => historyRevisionCard(revision)).join("") - : '

No history yet.

'; + : `

${escapeHtml(t("editor.historyEmpty", {}, "No history yet."))}

`; } function dayKey(date) { @@ -2468,8 +2469,8 @@ export function startNoteEditor(adapter) { .filter(item => item.date); if (!dated.length) { historyList.innerHTML = historyRevisions.length - ? '

Archive view needs valid revision timestamps.

' - : '

No history yet.

'; + ? `

${escapeHtml(t("editor.historyArchiveInvalidDates", {}, "Archive view needs valid revision timestamps."))}

` + : `

${escapeHtml(t("editor.historyEmpty", {}, "No history yet."))}

`; return; } @@ -2486,13 +2487,18 @@ export function startNoteEditor(adapter) { }); const maxMonthCount = Math.max(1, ...monthCounts); const months = Array.from({ length: 12 }, (_, month) => month); - const monthNames = new Intl.DateTimeFormat(undefined, { month: "short" }); - const monthNamesLong = new Intl.DateTimeFormat(undefined, { month: "long" }); + const locale = getLocale(); + const monthNames = new Intl.DateTimeFormat(locale, { month: "short" }); + const monthNamesLong = new Intl.DateTimeFormat(locale, { month: "long" }); + const weekdayNames = Array.from({ length: 7 }, (_, index) => + new Intl.DateTimeFormat(locale, { weekday: "narrow" }).format(new Date(2024, 0, 1 + index)) + ); const yearButtons = years.map(year => ``).join(""); const histogram = months.map(month => { const count = monthCounts[month]; const height = count ? Math.max(12, Math.round((count / maxMonthCount) * 72)) : 3; - return `
${escapeHtml(monthNames.format(new Date(historyArchiveYear, month, 1)))}
`; + const revisionLabel = tp("editor.historyRevisionCount", count, {}, "{count} revisions"); + return `
${escapeHtml(monthNames.format(new Date(historyArchiveYear, month, 1)))}
`; }).join(""); const calendars = months.filter(month => monthCounts[month] > 0).map(month => { @@ -2505,19 +2511,22 @@ export function startNoteEditor(adapter) { const key = `${historyArchiveYear}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`; const revisions = byDay.get(key) || []; const active = historyArchiveDay === key; + const revisionLabel = tp("editor.historyRevisionCount", revisions.length, {}, "{count} revisions"); days += revisions.length - ? `` + ? `` : `${day}`; } - return `
${escapeHtml(monthNamesLong.format(first))}${monthCounts[month]} revisions
MTWTFSS
${blanks}${days}
`; + const monthRevisionLabel = tp("editor.historyRevisionCount", monthCounts[month], {}, "{count} revisions"); + const weekdayHtml = weekdayNames.map(name => `${escapeHtml(name)}`).join(""); + return `
${escapeHtml(monthNamesLong.format(first))}${escapeHtml(monthRevisionLabel)}
${weekdayHtml}
${blanks}${days}
`; }).join(""); const selected = historyArchiveDay && byDay.get(historyArchiveDay); const selectedHtml = selected?.length - ? `
${escapeHtml(historyArchiveDay)}${selected.length} saved version${selected.length === 1 ? "" : "s"}
${selected.map(revision => historyRevisionCard(revision, true)).join("")}
` - : '

Select a highlighted day to inspect saved versions.

'; + ? `
${escapeHtml(historyArchiveDay)}${escapeHtml(tp("editor.historySavedVersionCount", selected.length, {}, "{count} saved versions"))}
${selected.map(revision => historyRevisionCard(revision, true)).join("")}
` + : `

${escapeHtml(t("editor.historyArchiveHint", {}, "Select a highlighted day to inspect saved versions."))}

`; - historyList.innerHTML = `
${yearButtons}
${histogram}
${calendars}
${selectedHtml}
`; + historyList.innerHTML = `
${yearButtons}
${histogram}
${calendars}
${selectedHtml}
`; } function renderHistory() { @@ -2581,7 +2590,7 @@ export function startNoteEditor(adapter) { historyPanel.classList.add("open"); historyPanel.setAttribute("aria-hidden", "false"); document.body.classList.add("history-open"); - historyList.innerHTML = '

Loading…

'; + historyList.innerHTML = `

${escapeHtml(t("common.loading", {}, "Loading…"))}

`; try { historyRevisions = await adapter.loadHistory(accessToken); historyArchiveYear = null; @@ -2589,7 +2598,7 @@ export function startNoteEditor(adapter) { renderHistory(); } catch (e) { historyList.innerHTML = `

${escapeHtml(e.message)}

`; - toast.danger(e.message, { title: "Could not load version history" }); + toast.danger(e.message, { title: t("editor.historyLoadFailed", {}, "Could not load version history") }); } }); document.querySelector("#close-history").addEventListener("click", () => { diff --git a/static/js/workspace.js b/static/js/workspace.js index 189b80d..d26b139 100644 --- a/static/js/workspace.js +++ b/static/js/workspace.js @@ -46,20 +46,31 @@ const workspacePasswordForm = document.querySelector("#workspace-password-form") const workspacePasswordInput = document.querySelector("#workspace-set-password"); const workspacePasswordError = document.querySelector("#workspace-password-error"); const workspaceSettingsForm = document.querySelector("#workspace-settings-form"); +const workspaceSettingsButton = document.querySelector("#workspace-settings-button"); const archiveDeletedNotesToggle = document.querySelector("#archive-deleted-notes"); const workspaceSettingsStatus = document.querySelector("#workspace-settings-status"); +const archivedNotesButton = document.querySelector("#archived-notes-button"); +const archivedNotesDialog = document.querySelector("#archived-notes-dialog"); +const archivedNotesList = document.querySelector("#archived-notes-list"); let notesPage = 1; let notesSearchTimer; const notesViewKey = `rustpad:workspace:${slug}:notes-view`; let notesView = localStorage.getItem(notesViewKey) === "table" ? "table" : "grid"; let notesCache = []; +let archivedNotesCache = []; function updateWorkspacePasswordControl() { workspacePasswordForm.hidden = Boolean(info?.protected || !info?.can_set_password); } function updateWorkspaceSettingsControl() { - workspaceSettingsForm.hidden = !info?.can_manage_settings; + const canManage = Boolean(info?.can_manage_settings); + workspaceSettingsButton.hidden = !canManage; + archivedNotesButton.hidden = !canManage; + if (!canManage) { + workspaceSettingsForm.hidden = true; + workspaceSettingsButton.setAttribute("aria-expanded", "false"); + } archiveDeletedNotesToggle.checked = Boolean(info?.archive_deleted_notes); } @@ -165,6 +176,36 @@ function formatDate(value) { const date = new Date(raw); return Number.isNaN(date.getTime()) ? "—" : formatDateTime(date); } + +function renderArchivedNotes(notes) { + archivedNotesCache = Array.isArray(notes) ? notes : []; + if (!archivedNotesCache.length) { + archivedNotesList.innerHTML = `

${escapeHtml(t("workspace.archive.empty", {}, "No archived notes."))}

`; + return; + } + archivedNotesList.innerHTML = archivedNotesCache.map(note => { + const author = note.created_by || t("workspace.unknown", {}, "Unknown"); + return `
+
+ ${escapeHtml(note.title)} + ${escapeHtml(t("workspace.archive.archivedAt", { date: formatDate(note.deleted_at) }, `Archived: ${formatDate(note.deleted_at)}`))} + ${escapeHtml(t("workspace.archive.createdBy", { author }, `Created by: ${author}`))} +
+ +
`; + }).join(""); +} + +async function loadArchivedNotes() { + archivedNotesList.innerHTML = `

${escapeHtml(t("common.loading", {}, "Loading…"))}

`; + try { + const notes = await api(`/api/workspaces/${encodeURIComponent(slug)}/archived-notes`); + renderArchivedNotes(notes); + } catch (error) { + archivedNotesList.innerHTML = `

${escapeHtml(error.message)}

`; + toast.danger(error.message, { title: t("workspace.archive.loadFailed", {}, "Could not load archived notes") }); + } +} function setNotesView(view) { notesView = view === "table" ? "table" : "grid"; localStorage.setItem(notesViewKey, notesView); @@ -374,6 +415,52 @@ workspacePasswordForm.addEventListener("submit", async event => { submit.disabled = false; } }); +workspaceSettingsButton.addEventListener("click", () => { + if (workspaceSettingsButton.hidden) return; + const willOpen = workspaceSettingsForm.hidden; + workspaceSettingsForm.hidden = !willOpen; + workspaceSettingsButton.setAttribute("aria-expanded", String(willOpen)); + if (willOpen) { + workspaceSettingsStatus.textContent = ""; + archiveDeletedNotesToggle.checked = Boolean(info?.archive_deleted_notes); + } +}); +archivedNotesButton.addEventListener("click", () => { + if (!archivedNotesDialog.open) archivedNotesDialog.showModal(); + void loadArchivedNotes(); +}); +document.querySelector("#close-archived-notes").addEventListener("click", () => archivedNotesDialog.close()); +archivedNotesDialog.addEventListener("click", event => { + if (event.target === archivedNotesDialog) archivedNotesDialog.close(); +}); +archivedNotesList.addEventListener("click", async event => { + const button = event.target.closest("[data-restore-archived-note]"); + if (!button) return; + const noteSlug = button.dataset.restoreArchivedNote; + const archivedNote = archivedNotesCache.find(note => note.slug === noteSlug); + const title = archivedNote?.title || noteSlug; + const confirmed = await askConfirm( + t("workspace.archive.restoreAsk", { title }, `Restore note “${title}” to this workspace?`), + { + title: t("workspace.archive.restoreTitle", {}, "Restore archived note"), + confirmText: t("workspace.archive.restore", {}, "Restore"), + }, + ); + if (!confirmed) return; + button.disabled = true; + try { + await api(`/api/workspaces/${encodeURIComponent(slug)}/archived-notes/${encodeURIComponent(noteSlug)}/restore`, { method: "POST" }); + toast.success( + t("workspace.archive.restored", { title }, `The note "${title}" was restored.`), + { title: t("workspace.archive.restoredTitle", {}, "Note restored") }, + ); + await Promise.all([loadArchivedNotes(), openWorkspace()]); + } catch (error) { + toast.danger(error.message, { title: t("workspace.archive.restoreFailed", {}, "Could not restore note") }); + if (button.isConnected) button.disabled = false; + } +}); + workspaceSettingsForm.addEventListener("submit", async event => { event.preventDefault(); workspaceSettingsStatus.textContent = ""; @@ -387,12 +474,17 @@ workspaceSettingsForm.addEventListener("submit", async event => { info = { ...info, ...updated }; updateWorkspaceSettingsControl(); workspaceSettingsStatus.textContent = info.archive_deleted_notes - ? "Deleted notes will be archived in the database." - : "Deleted notes will be permanently removed."; - toast.success("Workspace deletion policy saved.", { title: "Workspace settings" }); + ? t("workspace.settings.archiveEnabled", {}, "Deleted notes will be archived in the database.") + : t("workspace.settings.archiveDisabled", {}, "Deleted notes will be permanently removed."); + workspaceSettingsForm.hidden = true; + workspaceSettingsButton.setAttribute("aria-expanded", "false"); + toast.success( + t("workspace.settings.saved", {}, "Workspace deletion policy saved."), + { title: t("workspace.settings.title", {}, "Workspace settings") }, + ); } catch (error) { workspaceSettingsStatus.textContent = error.message; - toast.danger(error.message, { title: "Could not save workspace settings" }); + toast.danger(error.message, { title: t("workspace.settings.saveFailed", {}, "Could not save workspace settings") }); } finally { submit.disabled = false; } diff --git a/static/workspace.html b/static/workspace.html index 56f2a1e..b690dfb 100644 --- a/static/workspace.html +++ b/static/workspace.html @@ -32,7 +32,12 @@

Notes

Select a note or create a new one.

-
+
+
+ + + +