This commit is contained in:
Mateusz Gruszczyński committed 2026-09-25 15:31:55 +02:00
1 parent 90e3c399ea
commit 57ad26878b
12 files changed
+477 -31

No files matched your search

Generated
+1 -1
View File
@@ -2581,7 +2581,7 @@ dependencies = [
[[package]] [[package]]
name = "rustpad" name = "rustpad"
version = "0.2.84" version = "0.2.85"
dependencies = [ dependencies = [
"argon2", "argon2",
"aws-config", "aws-config",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "rustpad" name = "rustpad"
version = "0.2.84" version = "0.2.85"
edition = "2024" edition = "2024"
rust-version = "1.94" rust-version = "1.94"
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
+34
View File
@@ -422,10 +422,24 @@
"editor.hideNavigation": "Hide navigation bar", "editor.hideNavigation": "Hide navigation bar",
"editor.hideToolbar": "Hide editor toolbar", "editor.hideToolbar": "Hide editor toolbar",
"editor.highlight": "Highlight", "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.historyCopy": "Author, time, and version preview",
"editor.historyEmpty": "No history yet.", "editor.historyEmpty": "No history yet.",
"editor.historyEmptyNote": "Empty note",
"editor.historyListView": "List",
"editor.historyLoadFailed": "Could not load version history", "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.historyTitle": "Change history",
"editor.historyUnknownAuthor": "Unknown author",
"editor.historyView": "History view",
"editor.home": "RustPad home", "editor.home": "RustPad home",
"editor.horizontalRule": "Horizontal rule", "editor.horizontalRule": "Horizontal rule",
"editor.imageSizeError": "Enter a width and height between 1 and 10,000 px.", "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.globalMissing": "{name} did not register a browser global.",
"vendor.loadFailed": "Failed to load {path}.", "vendor.loadFailed": "Failed to load {path}.",
"words.zero": "0 words", "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.aria": "RustPad Workspace",
"workspace.cards": "Cards", "workspace.cards": "Cards",
"workspace.connectionError": "Workspace connection error", "workspace.connectionError": "Workspace connection error",
@@ -916,6 +942,14 @@
"workspace.protectedBadge": "Protected", "workspace.protectedBadge": "Protected",
"workspace.search": "Search notes", "workspace.search": "Search notes",
"workspace.search.placeholder": "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.files": "Files: {count} ({size})",
"workspace.stats.participants": "Participants: {count}", "workspace.stats.participants": "Participants: {count}",
"workspace.stats.revisions": "Revisions: {count}", "workspace.stats.revisions": "Revisions: {count}",
+38
View File
@@ -422,10 +422,28 @@
"editor.hideNavigation": "Ukryj pasek nawigacji", "editor.hideNavigation": "Ukryj pasek nawigacji",
"editor.hideToolbar": "Ukryj pasek narzędzi edytora", "editor.hideToolbar": "Ukryj pasek narzędzi edytora",
"editor.highlight": "Wyróżnienie", "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.historyCopy": "Autor, czas i podgląd wersji",
"editor.historyEmpty": "Brak historii.", "editor.historyEmpty": "Brak historii.",
"editor.historyEmptyNote": "Pusta notatka",
"editor.historyListView": "Lista",
"editor.historyLoadFailed": "Nie udało się wczytać historii wersji", "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.historyTitle": "Historia zmian",
"editor.historyUnknownAuthor": "Nieznany autor",
"editor.historyView": "Widok historii",
"editor.home": "Strona główna RustPad", "editor.home": "Strona główna RustPad",
"editor.horizontalRule": "Linia pozioma", "editor.horizontalRule": "Linia pozioma",
"editor.imageSizeError": "Podaj szerokość i wysokość od 1 do 10 000 px.", "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.globalMissing": "{name} nie zarejestrował globalnego obiektu przeglądarki.",
"vendor.loadFailed": "Nie udało się wczytać {path}.", "vendor.loadFailed": "Nie udało się wczytać {path}.",
"words.zero": "0 słów", "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.aria": "Obszar roboczy RustPad",
"workspace.cards": "Karty", "workspace.cards": "Karty",
"workspace.connectionError": "Błąd połączenia z obszarem roboczym", "workspace.connectionError": "Błąd połączenia z obszarem roboczym",
@@ -916,6 +946,14 @@
"workspace.protectedBadge": "Chroniona", "workspace.protectedBadge": "Chroniona",
"workspace.search": "Szukaj notatek", "workspace.search": "Szukaj notatek",
"workspace.search.placeholder": "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.files": "Pliki: {count} ({size})",
"workspace.stats.participants": "Uczestnicy: {count}", "workspace.stats.participants": "Uczestnicy: {count}",
"workspace.stats.revisions": "Wersje: {count}", "workspace.stats.revisions": "Wersje: {count}",
+63
View File
@@ -452,6 +452,17 @@ pub struct NoteListItem {
revision_count: i64, 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<String>,
}
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct NoteInfo { pub struct NoteInfo {
workspace_slug: String, workspace_slug: String,
@@ -788,6 +799,58 @@ pub async fn set_workspace_settings(
))) )))
} }
pub async fn archived_notes(
State(state): State<SharedState>,
headers: HeaderMap,
Path(workspace_slug): Path<String>,
) -> Result<Json<Vec<ArchivedNoteListItem>>, 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(&note.created_at),
updated_at: db::normalize_timestamp(&note.updated_at),
deleted_at: db::normalize_timestamp(&note.deleted_at),
protected: note.protected,
created_by: note.created_by,
})
.collect();
Ok(Json(notes))
}
pub async fn restore_archived_note(
State(state): State<SharedState>,
headers: HeaderMap,
Path((workspace_slug, note_slug)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>, 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, &note_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( pub async fn set_workspace_password(
State(state): State<SharedState>, State(state): State<SharedState>,
headers: HeaderMap, headers: HeaderMap,
+8
View File
@@ -187,6 +187,14 @@ pub fn router(
"/api/workspaces/{workspace_slug}/settings", "/api/workspaces/{workspace_slug}/settings",
post(api::set_workspace_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( .route(
"/api/workspaces/{workspace_slug}/open", "/api/workspaces/{workspace_slug}/open",
post(api::open_workspace), post(api::open_workspace),
+96
View File
@@ -74,6 +74,19 @@ pub struct Note {
pub created_by_guest_id: Option<String>, pub created_by_guest_id: Option<String>,
} }
#[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<String>,
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct CollaborationSnapshot { pub struct CollaborationSnapshot {
pub content: String, pub content: String,
@@ -325,6 +338,89 @@ pub async fn archive_note(pool: &Database, note_id: i64) -> Result<(), sqlx::Err
Ok(()) Ok(())
} }
/// Return notes that were soft-deleted from a workspace, newest first.
pub async fn list_archived_notes(
pool: &Database,
workspace_id: i64,
) -> Result<Vec<ArchivedNote>, 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::<i64, _>("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<bool, sqlx::Error> {
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( pub async fn create_note(
pool: &Database, pool: &Database,
workspace_id: i64, workspace_id: i64,
+89
View File
@@ -1901,6 +1901,18 @@ dialog::backdrop {
color: var(--muted); 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 { .notes-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
@@ -3638,6 +3650,68 @@ dialog::backdrop {
display: none; 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 { .notes-view-switch {
display: inline-flex; display: inline-flex;
padding: 3px; padding: 3px;
@@ -3777,6 +3851,21 @@ dialog::backdrop {
@media (max-width: 700px) { @media (max-width: 700px) {
.workspace-top { .workspace-top {
align-items: flex-start; 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 { .workspace-password-card {
+3 -3
View File
@@ -272,9 +272,9 @@
<p>Author, time, and version preview</p> <p>Author, time, and version preview</p>
</div><button id="close-history" class="icon-button">×</button> </div><button id="close-history" class="icon-button">×</button>
</div> </div>
<div class="history-view-switch" role="group" aria-label="History view"> <div class="history-view-switch" role="group" aria-label="History view" data-i18n-aria-label="editor.historyView">
<button type="button" data-history-view="list" class="active" aria-pressed="true">List</button> <button type="button" data-history-view="list" class="active" aria-pressed="true" data-i18n="editor.historyListView">List</button>
<button type="button" data-history-view="archive" aria-pressed="false">Archive</button> <button type="button" data-history-view="archive" aria-pressed="false" data-i18n="editor.historyArchiveView">Archive</button>
</div> </div>
<div id="history-list" class="history-list"></div> <div id="history-list" class="history-list"></div>
</aside> </aside>
+26 -17
View File
@@ -26,7 +26,7 @@ import { bindIdentityDialog, validateCurrentSession } from "@rustpad/auth-ui";
import { bindNoteFiles } from "@rustpad/note-files"; import { bindNoteFiles } from "@rustpad/note-files";
import { currentShareUrl, readEditorState, writeEditorState } from "@rustpad/url-state"; import { currentShareUrl, readEditorState, writeEditorState } from "@rustpad/url-state";
import { toast } from "@rustpad/toast"; 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 { getTheme } from "@rustpad/theme";
import { isResourceAccessError } from "@rustpad/security"; import { isResourceAccessError } from "@rustpad/security";
import { syncSelectControl } from "@rustpad/select"; import { syncSelectControl } from "@rustpad/select";
@@ -2442,16 +2442,17 @@ export function startNoteEditor(adapter) {
} }
function historyRevisionCard(revision, compact = false) { 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 emptyNote = t("editor.historyEmptyNote", {}, "Empty note");
const author = revision.author || "Unknown author"; const snippet = escapeHtml(revision.content.trim().split("\n").slice(0, compact ? 2 : 3).join(" · ").slice(0, compact ? 110 : 150) || emptyNote);
return `<article class="revision${compact ? " revision--compact" : ""}" data-history-revision="${revision.id}"><span class="revision__marker" style="--owner:${colorFor(author)}"></span><div><div class="revision__meta"><strong>${escapeHtml(author)}</strong><time>${formatDate(revision.created_at)}</time></div><p class="revision__snippet">${snippet}</p><button type="button" data-preview="${revision.id}">Preview</button><button type="button" data-revision="${revision.id}">Restore</button><div class="revision__preview" hidden></div></div></article>`; const author = revision.author || t("editor.historyUnknownAuthor", {}, "Unknown author");
return `<article class="revision${compact ? " revision--compact" : ""}" data-history-revision="${revision.id}"><span class="revision__marker" style="--owner:${colorFor(author)}"></span><div><div class="revision__meta"><strong>${escapeHtml(author)}</strong><time>${formatDate(revision.created_at)}</time></div><p class="revision__snippet">${snippet}</p><button type="button" data-preview="${revision.id}">${escapeHtml(t("common.preview", {}, "Preview"))}</button><button type="button" data-revision="${revision.id}">${escapeHtml(t("editor.historyRestore", {}, "Restore"))}</button><div class="revision__preview" hidden></div></div></article>`;
} }
function renderHistoryListView() { function renderHistoryListView() {
historyList.classList.remove("history-list--archive"); historyList.classList.remove("history-list--archive");
historyList.innerHTML = historyRevisions.length historyList.innerHTML = historyRevisions.length
? historyRevisions.map(revision => historyRevisionCard(revision)).join("") ? historyRevisions.map(revision => historyRevisionCard(revision)).join("")
: '<p class="empty">No history yet.</p>'; : `<p class="empty">${escapeHtml(t("editor.historyEmpty", {}, "No history yet."))}</p>`;
} }
function dayKey(date) { function dayKey(date) {
@@ -2468,8 +2469,8 @@ export function startNoteEditor(adapter) {
.filter(item => item.date); .filter(item => item.date);
if (!dated.length) { if (!dated.length) {
historyList.innerHTML = historyRevisions.length historyList.innerHTML = historyRevisions.length
? '<p class="empty">Archive view needs valid revision timestamps.</p>' ? `<p class="empty">${escapeHtml(t("editor.historyArchiveInvalidDates", {}, "Archive view needs valid revision timestamps."))}</p>`
: '<p class="empty">No history yet.</p>'; : `<p class="empty">${escapeHtml(t("editor.historyEmpty", {}, "No history yet."))}</p>`;
return; return;
} }
@@ -2486,13 +2487,18 @@ export function startNoteEditor(adapter) {
}); });
const maxMonthCount = Math.max(1, ...monthCounts); const maxMonthCount = Math.max(1, ...monthCounts);
const months = Array.from({ length: 12 }, (_, month) => month); const months = Array.from({ length: 12 }, (_, month) => month);
const monthNames = new Intl.DateTimeFormat(undefined, { month: "short" }); const locale = getLocale();
const monthNamesLong = new Intl.DateTimeFormat(undefined, { month: "long" }); 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 => `<button type="button" data-history-year="${year}" class="${year === historyArchiveYear ? "active" : ""}" aria-pressed="${year === historyArchiveYear}">${year}</button>`).join(""); const yearButtons = years.map(year => `<button type="button" data-history-year="${year}" class="${year === historyArchiveYear ? "active" : ""}" aria-pressed="${year === historyArchiveYear}">${year}</button>`).join("");
const histogram = months.map(month => { const histogram = months.map(month => {
const count = monthCounts[month]; const count = monthCounts[month];
const height = count ? Math.max(12, Math.round((count / maxMonthCount) * 72)) : 3; const height = count ? Math.max(12, Math.round((count / maxMonthCount) * 72)) : 3;
return `<div class="history-archive__bar-cell" title="${escapeHtml(monthNamesLong.format(new Date(historyArchiveYear, month, 1)))}: ${count} revisions"><span class="history-archive__bar" style="--history-bar-height:${height}px"></span><small>${escapeHtml(monthNames.format(new Date(historyArchiveYear, month, 1)))}</small></div>`; const revisionLabel = tp("editor.historyRevisionCount", count, {}, "{count} revisions");
return `<div class="history-archive__bar-cell" title="${escapeHtml(monthNamesLong.format(new Date(historyArchiveYear, month, 1)))}: ${escapeHtml(revisionLabel)}"><span class="history-archive__bar" style="--history-bar-height:${height}px"></span><small>${escapeHtml(monthNames.format(new Date(historyArchiveYear, month, 1)))}</small></div>`;
}).join(""); }).join("");
const calendars = months.filter(month => monthCounts[month] > 0).map(month => { 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 key = `${historyArchiveYear}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
const revisions = byDay.get(key) || []; const revisions = byDay.get(key) || [];
const active = historyArchiveDay === key; const active = historyArchiveDay === key;
const revisionLabel = tp("editor.historyRevisionCount", revisions.length, {}, "{count} revisions");
days += revisions.length days += revisions.length
? `<button type="button" class="history-calendar__day${active ? " active" : ""}" data-history-day="${key}" title="${revisions.length} revision${revisions.length === 1 ? "" : "s"}"><span>${day}</span><small>${revisions.length}</small></button>` ? `<button type="button" class="history-calendar__day${active ? " active" : ""}" data-history-day="${key}" title="${escapeHtml(revisionLabel)}"><span>${day}</span><small>${revisions.length}</small></button>`
: `<span class="history-calendar__day history-calendar__day--empty"><span>${day}</span></span>`; : `<span class="history-calendar__day history-calendar__day--empty"><span>${day}</span></span>`;
} }
return `<section class="history-calendar__month"><div class="history-calendar__month-head"><strong>${escapeHtml(monthNamesLong.format(first))}</strong><span>${monthCounts[month]} revisions</span></div><div class="history-calendar__weekdays"><span>M</span><span>T</span><span>W</span><span>T</span><span>F</span><span>S</span><span>S</span></div><div class="history-calendar__days">${blanks}${days}</div></section>`; const monthRevisionLabel = tp("editor.historyRevisionCount", monthCounts[month], {}, "{count} revisions");
const weekdayHtml = weekdayNames.map(name => `<span>${escapeHtml(name)}</span>`).join("");
return `<section class="history-calendar__month"><div class="history-calendar__month-head"><strong>${escapeHtml(monthNamesLong.format(first))}</strong><span>${escapeHtml(monthRevisionLabel)}</span></div><div class="history-calendar__weekdays">${weekdayHtml}</div><div class="history-calendar__days">${blanks}${days}</div></section>`;
}).join(""); }).join("");
const selected = historyArchiveDay && byDay.get(historyArchiveDay); const selected = historyArchiveDay && byDay.get(historyArchiveDay);
const selectedHtml = selected?.length const selectedHtml = selected?.length
? `<section class="history-archive__selected"><div class="history-archive__selected-head"><strong>${escapeHtml(historyArchiveDay)}</strong><span>${selected.length} saved version${selected.length === 1 ? "" : "s"}</span></div>${selected.map(revision => historyRevisionCard(revision, true)).join("")}</section>` ? `<section class="history-archive__selected"><div class="history-archive__selected-head"><strong>${escapeHtml(historyArchiveDay)}</strong><span>${escapeHtml(tp("editor.historySavedVersionCount", selected.length, {}, "{count} saved versions"))}</span></div>${selected.map(revision => historyRevisionCard(revision, true)).join("")}</section>`
: '<p class="history-archive__hint">Select a highlighted day to inspect saved versions.</p>'; : `<p class="history-archive__hint">${escapeHtml(t("editor.historyArchiveHint", {}, "Select a highlighted day to inspect saved versions."))}</p>`;
historyList.innerHTML = `<div class="history-archive"><div class="history-archive__years" aria-label="Archive year">${yearButtons}</div><div class="history-archive__histogram" aria-label="Revision activity by month">${histogram}</div><div class="history-calendar">${calendars}</div>${selectedHtml}</div>`; historyList.innerHTML = `<div class="history-archive"><div class="history-archive__years" aria-label="${escapeHtml(t("editor.historyArchiveYear", {}, "Archive year"))}">${yearButtons}</div><div class="history-archive__histogram" aria-label="${escapeHtml(t("editor.historyArchiveActivity", {}, "Revision activity by month"))}">${histogram}</div><div class="history-calendar">${calendars}</div>${selectedHtml}</div>`;
} }
function renderHistory() { function renderHistory() {
@@ -2581,7 +2590,7 @@ export function startNoteEditor(adapter) {
historyPanel.classList.add("open"); historyPanel.classList.add("open");
historyPanel.setAttribute("aria-hidden", "false"); historyPanel.setAttribute("aria-hidden", "false");
document.body.classList.add("history-open"); document.body.classList.add("history-open");
historyList.innerHTML = '<p class="empty">Loading…</p>'; historyList.innerHTML = `<p class="empty">${escapeHtml(t("common.loading", {}, "Loading…"))}</p>`;
try { try {
historyRevisions = await adapter.loadHistory(accessToken); historyRevisions = await adapter.loadHistory(accessToken);
historyArchiveYear = null; historyArchiveYear = null;
@@ -2589,7 +2598,7 @@ export function startNoteEditor(adapter) {
renderHistory(); renderHistory();
} catch (e) { } catch (e) {
historyList.innerHTML = `<p class="error">${escapeHtml(e.message)}</p>`; historyList.innerHTML = `<p class="error">${escapeHtml(e.message)}</p>`;
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", () => { document.querySelector("#close-history").addEventListener("click", () => {
+97 -5
View File
@@ -46,20 +46,31 @@ const workspacePasswordForm = document.querySelector("#workspace-password-form")
const workspacePasswordInput = document.querySelector("#workspace-set-password"); const workspacePasswordInput = document.querySelector("#workspace-set-password");
const workspacePasswordError = document.querySelector("#workspace-password-error"); const workspacePasswordError = document.querySelector("#workspace-password-error");
const workspaceSettingsForm = document.querySelector("#workspace-settings-form"); const workspaceSettingsForm = document.querySelector("#workspace-settings-form");
const workspaceSettingsButton = document.querySelector("#workspace-settings-button");
const archiveDeletedNotesToggle = document.querySelector("#archive-deleted-notes"); const archiveDeletedNotesToggle = document.querySelector("#archive-deleted-notes");
const workspaceSettingsStatus = document.querySelector("#workspace-settings-status"); 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 notesPage = 1;
let notesSearchTimer; let notesSearchTimer;
const notesViewKey = `rustpad:workspace:${slug}:notes-view`; const notesViewKey = `rustpad:workspace:${slug}:notes-view`;
let notesView = localStorage.getItem(notesViewKey) === "table" ? "table" : "grid"; let notesView = localStorage.getItem(notesViewKey) === "table" ? "table" : "grid";
let notesCache = []; let notesCache = [];
let archivedNotesCache = [];
function updateWorkspacePasswordControl() { function updateWorkspacePasswordControl() {
workspacePasswordForm.hidden = Boolean(info?.protected || !info?.can_set_password); workspacePasswordForm.hidden = Boolean(info?.protected || !info?.can_set_password);
} }
function updateWorkspaceSettingsControl() { 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); archiveDeletedNotesToggle.checked = Boolean(info?.archive_deleted_notes);
} }
@@ -165,6 +176,36 @@ function formatDate(value) {
const date = new Date(raw); const date = new Date(raw);
return Number.isNaN(date.getTime()) ? "—" : formatDateTime(date); return Number.isNaN(date.getTime()) ? "—" : formatDateTime(date);
} }
function renderArchivedNotes(notes) {
archivedNotesCache = Array.isArray(notes) ? notes : [];
if (!archivedNotesCache.length) {
archivedNotesList.innerHTML = `<p class="empty">${escapeHtml(t("workspace.archive.empty", {}, "No archived notes."))}</p>`;
return;
}
archivedNotesList.innerHTML = archivedNotesCache.map(note => {
const author = note.created_by || t("workspace.unknown", {}, "Unknown");
return `<article class="archived-note-card">
<div class="archived-note-card__copy">
<strong>${escapeHtml(note.title)}</strong>
<span>${escapeHtml(t("workspace.archive.archivedAt", { date: formatDate(note.deleted_at) }, `Archived: ${formatDate(note.deleted_at)}`))}</span>
<small>${escapeHtml(t("workspace.archive.createdBy", { author }, `Created by: ${author}`))}</small>
</div>
<button class="secondary-button inline-button" type="button" data-restore-archived-note="${escapeHtml(note.slug)}">${escapeHtml(t("workspace.archive.restore", {}, "Restore"))}</button>
</article>`;
}).join("");
}
async function loadArchivedNotes() {
archivedNotesList.innerHTML = `<p class="empty">${escapeHtml(t("common.loading", {}, "Loading…"))}</p>`;
try {
const notes = await api(`/api/workspaces/${encodeURIComponent(slug)}/archived-notes`);
renderArchivedNotes(notes);
} catch (error) {
archivedNotesList.innerHTML = `<p class="form-message error">${escapeHtml(error.message)}</p>`;
toast.danger(error.message, { title: t("workspace.archive.loadFailed", {}, "Could not load archived notes") });
}
}
function setNotesView(view) { function setNotesView(view) {
notesView = view === "table" ? "table" : "grid"; notesView = view === "table" ? "table" : "grid";
localStorage.setItem(notesViewKey, notesView); localStorage.setItem(notesViewKey, notesView);
@@ -374,6 +415,52 @@ workspacePasswordForm.addEventListener("submit", async event => {
submit.disabled = false; 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 => { workspaceSettingsForm.addEventListener("submit", async event => {
event.preventDefault(); event.preventDefault();
workspaceSettingsStatus.textContent = ""; workspaceSettingsStatus.textContent = "";
@@ -387,12 +474,17 @@ workspaceSettingsForm.addEventListener("submit", async event => {
info = { ...info, ...updated }; info = { ...info, ...updated };
updateWorkspaceSettingsControl(); updateWorkspaceSettingsControl();
workspaceSettingsStatus.textContent = info.archive_deleted_notes workspaceSettingsStatus.textContent = info.archive_deleted_notes
? "Deleted notes will be archived in the database." ? t("workspace.settings.archiveEnabled", {}, "Deleted notes will be archived in the database.")
: "Deleted notes will be permanently removed."; : t("workspace.settings.archiveDisabled", {}, "Deleted notes will be permanently removed.");
toast.success("Workspace deletion policy saved.", { title: "Workspace settings" }); 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) { } catch (error) {
workspaceSettingsStatus.textContent = error.message; 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 { } finally {
submit.disabled = false; submit.disabled = false;
} }
+21 -4
View File
@@ -32,7 +32,12 @@
<div> <div>
<h2>Notes</h2> <h2>Notes</h2>
<p>Select a note or create a new one.</p> <p>Select a note or create a new one.</p>
</div><button id="new-note-button" class="primary-button inline-button">New note</button> </div>
<div class="workspace-top__actions">
<button id="archived-notes-button" class="secondary-button inline-button" type="button" data-i18n="workspace.archive.title" hidden>Archived notes</button>
<button id="workspace-settings-button" class="secondary-button inline-button" type="button" aria-expanded="false" data-i18n="workspace.settings.title" hidden>Workspace settings</button>
<button id="new-note-button" class="primary-button inline-button" type="button">New note</button>
</div>
</section> </section>
<form id="workspace-password-form" class="workspace-password-card" hidden> <form id="workspace-password-form" class="workspace-password-card" hidden>
<div class="workspace-password-card__copy"><strong>Protect workspace</strong><small>One password secures the <div class="workspace-password-card__copy"><strong>Protect workspace</strong><small>One password secures the
@@ -44,10 +49,10 @@
</form> </form>
<form id="workspace-settings-form" class="workspace-settings-card" hidden> <form id="workspace-settings-form" class="workspace-settings-card" hidden>
<div class="workspace-settings-card__copy"> <div class="workspace-settings-card__copy">
<strong>Deleted notes</strong> <strong data-i18n="workspace.settings.deletedNotes">Deleted notes</strong>
<small>Workspace-wide setting. Archived notes disappear from the workspace but remain in the database with their history and files.</small> <small data-i18n="workspace.settings.copy">Workspace-wide setting. Archived notes disappear from the workspace but remain in the database with their history and files.</small>
</div> </div>
<label class="workspace-archive-toggle"><input id="archive-deleted-notes" type="checkbox"> Archive deleted notes instead of permanently deleting them</label> <label class="workspace-archive-toggle"><input id="archive-deleted-notes" type="checkbox"> <span data-i18n="workspace.settings.archiveToggle">Archive deleted notes instead of permanently deleting them</span></label>
<button class="secondary-button" type="submit">Save</button> <button class="secondary-button" type="submit">Save</button>
<small id="workspace-settings-status" class="form-message" role="status"></small> <small id="workspace-settings-status" class="form-message" role="status"></small>
</form> </form>
@@ -68,6 +73,18 @@
<section id="notes-list" class="notes-grid" aria-live="polite"></section> <section id="notes-list" class="notes-grid" aria-live="polite"></section>
<nav id="notes-pagination" class="pagination" aria-label="Notes pagination"></nav> <nav id="notes-pagination" class="pagination" aria-label="Notes pagination"></nav>
</main> </main>
<dialog id="archived-notes-dialog" class="archived-notes-dialog">
<section class="dialog-panel archived-notes-panel">
<div class="archived-notes-panel__header">
<div>
<h2 data-i18n="workspace.archive.title">Archived notes</h2>
<p class="dialog-copy" data-i18n="workspace.archive.copy">Restore notes that were archived instead of permanently deleted.</p>
</div>
<button id="close-archived-notes" class="icon-button" type="button" aria-label="Close dialog">×</button>
</div>
<div id="archived-notes-list" class="archived-notes-list" aria-live="polite"></div>
</section>
</dialog>
<dialog id="identity-dialog"> <dialog id="identity-dialog">
<form id="identity-form" autocomplete="on" class="dialog-panel identity-panel"> <form id="identity-form" autocomplete="on" class="dialog-panel identity-panel">
<h2>Identify yourself</h2> <h2>Identify yourself</h2>