v0.2.85
This commit is contained in:
1 parent
90e3c399ea
commit
57ad26878b
12 files changed
+477
-31
No files matched your search
Generated
+1
-1
@@ -2581,7 +2581,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustpad"
|
||||
version = "0.2.84"
|
||||
version = "0.2.85"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"aws-config",
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[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<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(¬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<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, ¬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<SharedState>,
|
||||
headers: HeaderMap,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -74,6 +74,19 @@ pub struct Note {
|
||||
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)]
|
||||
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<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(
|
||||
pool: &Database,
|
||||
workspace_id: i64,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+3
-3
@@ -272,9 +272,9 @@
|
||||
<p>Author, time, and version preview</p>
|
||||
</div><button id="close-history" class="icon-button">×</button>
|
||||
</div>
|
||||
<div class="history-view-switch" role="group" aria-label="History view">
|
||||
<button type="button" data-history-view="list" class="active" aria-pressed="true">List</button>
|
||||
<button type="button" data-history-view="archive" aria-pressed="false">Archive</button>
|
||||
<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" data-i18n="editor.historyListView">List</button>
|
||||
<button type="button" data-history-view="archive" aria-pressed="false" data-i18n="editor.historyArchiveView">Archive</button>
|
||||
</div>
|
||||
<div id="history-list" class="history-list"></div>
|
||||
</aside>
|
||||
|
||||
+26
-17
@@ -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 `<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 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 `<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() {
|
||||
historyList.classList.remove("history-list--archive");
|
||||
historyList.innerHTML = historyRevisions.length
|
||||
? 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) {
|
||||
@@ -2468,8 +2469,8 @@ export function startNoteEditor(adapter) {
|
||||
.filter(item => item.date);
|
||||
if (!dated.length) {
|
||||
historyList.innerHTML = historyRevisions.length
|
||||
? '<p class="empty">Archive view needs valid revision timestamps.</p>'
|
||||
: '<p class="empty">No history yet.</p>';
|
||||
? `<p class="empty">${escapeHtml(t("editor.historyArchiveInvalidDates", {}, "Archive view needs valid revision timestamps."))}</p>`
|
||||
: `<p class="empty">${escapeHtml(t("editor.historyEmpty", {}, "No history yet."))}</p>`;
|
||||
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 => `<button type="button" data-history-year="${year}" class="${year === historyArchiveYear ? "active" : ""}" aria-pressed="${year === historyArchiveYear}">${year}</button>`).join("");
|
||||
const histogram = months.map(month => {
|
||||
const count = monthCounts[month];
|
||||
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("");
|
||||
|
||||
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
|
||||
? `<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>`;
|
||||
}
|
||||
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("");
|
||||
|
||||
const selected = historyArchiveDay && byDay.get(historyArchiveDay);
|
||||
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>`
|
||||
: '<p class="history-archive__hint">Select a highlighted day to inspect saved versions.</p>';
|
||||
? `<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">${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() {
|
||||
@@ -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 = '<p class="empty">Loading…</p>';
|
||||
historyList.innerHTML = `<p class="empty">${escapeHtml(t("common.loading", {}, "Loading…"))}</p>`;
|
||||
try {
|
||||
historyRevisions = await adapter.loadHistory(accessToken);
|
||||
historyArchiveYear = null;
|
||||
@@ -2589,7 +2598,7 @@ export function startNoteEditor(adapter) {
|
||||
renderHistory();
|
||||
} catch (e) {
|
||||
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", () => {
|
||||
|
||||
+97
-5
@@ -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 = `<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) {
|
||||
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;
|
||||
}
|
||||
|
||||
+21
-4
@@ -32,7 +32,12 @@
|
||||
<div>
|
||||
<h2>Notes</h2>
|
||||
<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>
|
||||
<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
|
||||
@@ -44,10 +49,10 @@
|
||||
</form>
|
||||
<form id="workspace-settings-form" class="workspace-settings-card" hidden>
|
||||
<div class="workspace-settings-card__copy">
|
||||
<strong>Deleted notes</strong>
|
||||
<small>Workspace-wide setting. Archived notes disappear from the workspace but remain in the database with their history and files.</small>
|
||||
<strong data-i18n="workspace.settings.deletedNotes">Deleted notes</strong>
|
||||
<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>
|
||||
<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>
|
||||
<small id="workspace-settings-status" class="form-message" role="status"></small>
|
||||
</form>
|
||||
@@ -68,6 +73,18 @@
|
||||
<section id="notes-list" class="notes-grid" aria-live="polite"></section>
|
||||
<nav id="notes-pagination" class="pagination" aria-label="Notes pagination"></nav>
|
||||
</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">
|
||||
<form id="identity-form" autocomplete="on" class="dialog-panel identity-panel">
|
||||
<h2>Identify yourself</h2>
|
||||
|
||||
Reference in new issue
Block a user