pagnation

This commit is contained in:
Mateusz Gruszczyński
2026-08-02 14:26:32 +02:00
parent 464660a274
commit f71ebefa82
7 changed files with 290 additions and 20 deletions
+46 -3
View File
@@ -19,7 +19,7 @@ pub use pads_public::*;
use axum::{ use axum::{
Json, Json,
extract::{Multipart, Path, State}, extract::{Multipart, Path, Query, State},
http::{HeaderMap, HeaderName, HeaderValue, StatusCode, header}, http::{HeaderMap, HeaderName, HeaderValue, StatusCode, header},
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
@@ -283,6 +283,31 @@ pub struct WorkspaceInfo {
pub struct WorkspaceOpenResponse { pub struct WorkspaceOpenResponse {
workspace: WorkspaceInfo, workspace: WorkspaceInfo,
notes: Vec<NoteListItem>, notes: Vec<NoteListItem>,
pagination: ListPaginationMeta,
}
#[derive(Debug, Deserialize)]
pub struct WorkspaceNotesQuery {
#[serde(default)]
q: String,
#[serde(default = "default_list_page")]
page: usize,
#[serde(default = "default_list_per_page")]
per_page: usize,
}
#[derive(Debug, Serialize)]
pub struct ListPaginationMeta {
page: usize,
per_page: usize,
total: usize,
total_pages: usize,
}
fn default_list_page() -> usize { 1 }
fn default_list_per_page() -> usize { 25 }
fn normalize_list_per_page(value: usize) -> usize {
match value { 25 | 50 | 100 => value, _ => 25 }
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
@@ -552,6 +577,7 @@ pub async fn open_workspace(
State(state): State<SharedState>, State(state): State<SharedState>,
headers: HeaderMap, headers: HeaderMap,
Path(workspace_slug): Path<String>, Path(workspace_slug): Path<String>,
Query(query): Query<WorkspaceNotesQuery>,
Json(payload): Json<PasswordRequest>, Json(payload): Json<PasswordRequest>,
) -> Result<Json<WorkspaceOpenResponse>, ApiError> { ) -> Result<Json<WorkspaceOpenResponse>, ApiError> {
let workspace = authorized_workspace( let workspace = authorized_workspace(
@@ -573,9 +599,16 @@ pub async fn open_workspace(
.into_iter() .into_iter()
.map(|stats| (stats.note_id, stats)) .map(|stats| (stats.note_id, stats))
.collect::<std::collections::HashMap<_, _>>(); .collect::<std::collections::HashMap<_, _>>();
let notes = db::list_notes(&state.db, workspace.id) let search = query.q.trim().to_lowercase();
let mut notes = db::list_notes(&state.db, workspace.id)
.await? .await?
.into_iter() .into_iter()
.filter(|note| {
search.is_empty()
|| note.title.to_lowercase().contains(&search)
|| note.slug.to_lowercase().contains(&search)
|| note.created_by.as_deref().unwrap_or_default().to_lowercase().contains(&search)
})
.map(|note| { .map(|note| {
let stats = stats.get(&note.id); let stats = stats.get(&note.id);
NoteListItem { NoteListItem {
@@ -592,11 +625,21 @@ pub async fn open_workspace(
revision_count: stats.map_or(0, |value| value.revision_count), revision_count: stats.map_or(0, |value| value.revision_count),
} }
}) })
.collect(); .collect::<Vec<_>>();
notes.sort_by(|left, right| right.updated_at.cmp(&left.updated_at));
let page = query.page.max(1);
let per_page = normalize_list_per_page(query.per_page);
let total = notes.len();
let total_pages = ((total + per_page - 1) / per_page).max(1);
let page = page.min(total_pages);
let start = (page - 1) * per_page;
let notes = notes.into_iter().skip(start).take(per_page).collect();
Ok(Json(WorkspaceOpenResponse { Ok(Json(WorkspaceOpenResponse {
workspace: workspace_info_from(&workspace), workspace: workspace_info_from(&workspace),
notes, notes,
pagination: ListPaginationMeta { page, per_page, total, total_pages },
})) }))
} }
+74 -11
View File
@@ -16,7 +16,7 @@ use argon2::{
}; };
use axum::{ use axum::{
Json, Json,
extract::{Path as AxumPath, State}, extract::{Path as AxumPath, Query, State},
http::{HeaderMap, StatusCode, header}, http::{HeaderMap, StatusCode, header},
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
@@ -116,6 +116,8 @@ pub struct ResourceActionRequest {
} }
#[derive(Serialize)] #[derive(Serialize)]
pub struct ResourceItem { pub struct ResourceItem {
#[serde(default)]
kind: String,
slug: String, slug: String,
title: String, title: String,
protected: i64, protected: i64,
@@ -201,6 +203,7 @@ impl<'r> sqlx::FromRow<'r, AnyRow> for User {
impl<'r> sqlx::FromRow<'r, AnyRow> for ResourceItem { impl<'r> sqlx::FromRow<'r, AnyRow> for ResourceItem {
fn from_row(row: &'r AnyRow) -> Result<Self, sqlx::Error> { fn from_row(row: &'r AnyRow) -> Result<Self, sqlx::Error> {
Ok(Self { Ok(Self {
kind: String::new(),
slug: crate::row_decode::text(row, "slug")?, slug: crate::row_decode::text(row, "slug")?,
title: crate::row_decode::text(row, "title")?, title: crate::row_decode::text(row, "title")?,
protected: row.try_get("protected")?, protected: row.try_get("protected")?,
@@ -257,8 +260,32 @@ pub struct RevokeShareLinkRequest {
#[derive(Serialize)] #[derive(Serialize)]
pub struct ResourceList { pub struct ResourceList {
workspaces: Vec<ResourceItem>, items: Vec<ResourceItem>,
pads: Vec<ResourceItem>, pagination: PaginationMeta,
}
#[derive(Debug, Deserialize)]
pub struct ResourceListQuery {
#[serde(default)]
q: String,
#[serde(default = "default_page")]
page: usize,
#[serde(default = "default_per_page")]
per_page: usize,
}
#[derive(Debug, Serialize)]
pub struct PaginationMeta {
page: usize,
per_page: usize,
total: usize,
total_pages: usize,
}
fn default_page() -> usize { 1 }
fn default_per_page() -> usize { 25 }
fn normalized_per_page(value: usize) -> usize {
match value { 25 | 50 | 100 => value, _ => 25 }
} }
#[derive(Serialize)] #[derive(Serialize)]
pub struct SessionResponse { pub struct SessionResponse {
@@ -1142,6 +1169,7 @@ async fn send_account_action(
pub async fn resources( pub async fn resources(
State(state): State<SharedState>, State(state): State<SharedState>,
headers: HeaderMap, headers: HeaderMap,
Query(query): Query<ResourceListQuery>,
) -> Result<Json<ResourceList>, AuthError> { ) -> Result<Json<ResourceList>, AuthError> {
let user = require_user(&state, &headers).await?; let user = require_user(&state, &headers).await?;
let workspaces = sqlx::query_as::<_, ResourceItem>(queries::get( let workspaces = sqlx::query_as::<_, ResourceItem>(queries::get(
@@ -1153,14 +1181,49 @@ pub async fn resources(
.fetch_all(state.db.pool()) .fetch_all(state.db.pool())
.await .await
.map_err(AuthError::database)?; .map_err(AuthError::database)?;
let pads = let pads = sqlx::query_as::<_, ResourceItem>(
sqlx::query_as::<_, ResourceItem>(queries::get(state.db.kind(), queries::USER_LIST_PADS)) queries::get(state.db.kind(), queries::USER_LIST_PADS),
.bind(user.id) )
.bind(user.id) .bind(user.id)
.fetch_all(state.db.pool()) .bind(user.id)
.await .fetch_all(state.db.pool())
.map_err(AuthError::database)?; .await
Ok(Json(ResourceList { workspaces, pads })) .map_err(AuthError::database)?;
let search = query.q.trim().to_lowercase();
let mut items = workspaces
.into_iter()
.map(|item| (item, "workspace"))
.chain(pads.into_iter().map(|item| (item, "pad")))
.filter(|(item, kind)| {
search.is_empty()
|| item.title.to_lowercase().contains(&search)
|| item.slug.to_lowercase().contains(&search)
|| kind.contains(&search)
})
.collect::<Vec<_>>();
items.sort_by(|(left, _), (right, _)| right.updated_at.cmp(&left.updated_at));
let page = query.page.max(1);
let per_page = normalized_per_page(query.per_page);
let total = items.len();
let total_pages = ((total + per_page - 1) / per_page).max(1);
let page = page.min(total_pages);
let start = (page - 1) * per_page;
let items = items
.into_iter()
.skip(start)
.take(per_page)
.map(|(mut item, kind)| {
item.kind = kind.to_string();
item
})
.collect();
Ok(Json(ResourceList {
items,
pagination: PaginationMeta { page, per_page, total, total_pages },
}))
} }
pub async fn update_resource( pub async fn update_resource(
+126
View File
@@ -6356,3 +6356,129 @@ dialog::backdrop {
font-size: .68rem; font-size: .68rem;
} }
} }
/* Search, pagination and theme-aware scrollbars. */
* {
scrollbar-width: thin;
scrollbar-color: var(--border-strong) var(--surface-inset);
}
*::-webkit-scrollbar {
width: 10px;
height: 10px;
}
*::-webkit-scrollbar-track {
background: var(--surface-inset);
}
*::-webkit-scrollbar-thumb {
border: 2px solid var(--surface-inset);
border-radius: 999px;
background: var(--border-strong);
}
*::-webkit-scrollbar-thumb:hover {
background: var(--muted-2);
}
.list-controls,
.notes-toolbar {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.resources-controls {
margin-top: 18px;
}
.list-search {
flex: 1 1 240px;
}
.list-search input {
width: 100%;
min-height: 40px;
}
.page-size-label {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--muted);
font-size: .82rem;
font-weight: 600;
white-space: nowrap;
}
.page-size-label select {
min-width: 78px;
min-height: 40px;
padding: 0 34px 0 12px;
border: 1px solid var(--border-strong);
border-radius: 10px;
background-color: var(--surface-toolbar);
background-image: linear-gradient(45deg, transparent 50%, var(--select-arrow) 50%),
linear-gradient(135deg, var(--select-arrow) 50%, transparent 50%);
background-position: calc(100% - 16px) 16px, calc(100% - 11px) 16px;
background-repeat: no-repeat;
background-size: 5px 5px, 5px 5px;
box-shadow: inset 0 1px 0 var(--wash-soft);
color: var(--text);
font-weight: 700;
appearance: none;
-webkit-appearance: none;
cursor: pointer;
transition: border-color .15s ease, background-color .15s ease, box-shadow .15s ease;
}
.page-size-label select:hover {
border-color: var(--select-border);
background-color: var(--surface-hover);
}
.page-size-label select:focus-visible {
border-color: var(--focus);
outline: none;
box-shadow: 0 0 0 3px var(--focus-ring), inset 0 1px 0 var(--wash-soft);
}
.page-size-label:focus-within {
color: var(--text-secondary);
}
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-top: 16px;
color: var(--muted);
font-size: .88rem;
}
.pagination button {
min-height: 36px;
padding: 0 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface-2);
color: var(--text);
cursor: pointer;
}
.pagination button:disabled {
opacity: .45;
cursor: not-allowed;
}
@media (max-width: 620px) {
.pagination {
justify-content: space-between;
}
.pagination span {
text-align: center;
}
}
+5
View File
@@ -125,7 +125,12 @@
adds link-based protection. Private items are visible only to their owner and explicitly shared accounts or adds link-based protection. Private items are visible only to their owner and explicitly shared accounts or
valid share links. Unauthorized visitors receive a not-found response.</p> valid share links. Unauthorized visitors receive a not-found response.</p>
</header> </header>
<div class="list-controls resources-controls">
<label class="list-search"><span class="sr-only">Search notes and workspaces</span><input id="resources-search" type="search" placeholder="Search notes and workspaces…" autocomplete="off"></label>
<label class="page-size-label">Per page<select id="resources-per-page"><option value="25">25</option><option value="50">50</option><option value="100">100</option></select></label>
</div>
<div id="resources-list" class="resources-list"></div> <div id="resources-list" class="resources-list"></div>
<nav id="resources-pagination" class="pagination" aria-label="Resources pagination"></nav>
<p id="resources-error" class="form-message error" role="alert"></p> <p id="resources-error" class="form-message error" role="alert"></p>
</div> </div>
</dialog> </dialog>
+18 -3
View File
@@ -107,6 +107,11 @@ const registrationEnabled = document.body.dataset.registrationEnabled === "true"
const resourcesDialog = document.querySelector("#resources-dialog"); const resourcesDialog = document.querySelector("#resources-dialog");
const resourcesList = document.querySelector("#resources-list"); const resourcesList = document.querySelector("#resources-list");
const resourcesError = document.querySelector("#resources-error"); const resourcesError = document.querySelector("#resources-error");
const resourcesSearch = document.querySelector("#resources-search");
const resourcesPerPage = document.querySelector("#resources-per-page");
const resourcesPagination = document.querySelector("#resources-pagination");
let resourcesPage = 1;
let resourcesSearchTimer;
const profileDialog = document.querySelector("#profile-dialog"); const profileDialog = document.querySelector("#profile-dialog");
const profileForm = document.querySelector("#profile-form"); const profileForm = document.querySelector("#profile-form");
@@ -116,11 +121,16 @@ function authHeaders() { return {}; }
function escapeHtml(value) { const node = document.createElement("div"); node.textContent = String(value ?? ""); return node.innerHTML; } function escapeHtml(value) { const node = document.createElement("div"); node.textContent = String(value ?? ""); return node.innerHTML; }
function shareExpiry(hours, forever) { if (forever) return null; const value = Number(hours); if (!Number.isFinite(value) || value <= 0 || value > 87600) throw new Error("Enter a validity between 1 and 87600 hours."); return new Date(Date.now() + value * 3600000).toISOString(); } function shareExpiry(hours, forever) { if (forever) return null; const value = Number(hours); if (!Number.isFinite(value) || value <= 0 || value > 87600) throw new Error("Enter a validity between 1 and 87600 hours."); return new Date(Date.now() + value * 3600000).toISOString(); }
function formatShareExpiry(value) { if (!value) return "Never expires"; const date = new Date(value); return Number.isNaN(date.getTime()) ? value : `Expires ${date.toLocaleString()}`; } function formatShareExpiry(value) { if (!value) return "Never expires"; const date = new Date(value); return Number.isNaN(date.getTime()) ? value : `Expires ${date.toLocaleString()}`; }
function renderResourcesPagination(meta) {
resourcesPagination.innerHTML = meta.total ? `<button type="button" data-page="${meta.page - 1}" ${meta.page <= 1 ? "disabled" : ""}>Previous</button><span>Page ${meta.page} of ${meta.total_pages} · ${meta.total} items</span><button type="button" data-page="${meta.page + 1}" ${meta.page >= meta.total_pages ? "disabled" : ""}>Next</button>` : "";
}
async function loadResources() { async function loadResources() {
resourcesError.textContent = ""; resourcesList.innerHTML = "<p>Loading…</p>"; resourcesError.textContent = ""; resourcesList.innerHTML = "<p>Loading…</p>";
try { try {
const data = await api("/api/auth/resources", { headers: authHeaders() }); const params = new URLSearchParams({ q: resourcesSearch.value.trim(), page: String(resourcesPage), per_page: resourcesPerPage.value });
const items = [...data.workspaces.map(item => ({ ...item, kind: "workspace", url: `/w/${item.slug}` })), ...data.pads.map(item => ({ ...item, kind: "pad", url: `/p/${item.slug}` }))]; const data = await api(`/api/auth/resources?${params}`, { headers: authHeaders() });
resourcesPage = data.pagination.page;
const items = data.items.map(item => ({ ...item, url: item.kind === "workspace" ? `/w/${item.slug}` : `/p/${item.slug}` }));
resourcesList.innerHTML = items.length ? "" : "<p>No assigned items yet.</p>"; resourcesList.innerHTML = items.length ? "" : "<p>No assigned items yet.</p>";
for (const item of items) { for (const item of items) {
const row = document.createElement("article"); const row = document.createElement("article");
@@ -256,9 +266,14 @@ async function loadResources() {
}); });
resourcesList.append(row); resourcesList.append(row);
} }
} catch (e) { resourcesList.innerHTML = ""; resourcesError.textContent = e.message; } renderResourcesPagination(data.pagination);
} catch (e) { resourcesList.innerHTML = ""; resourcesPagination.innerHTML = ""; resourcesError.textContent = e.message; }
} }
resourcesSearch?.addEventListener("input", () => { clearTimeout(resourcesSearchTimer); resourcesSearchTimer = setTimeout(() => { resourcesPage = 1; loadResources(); }, 250); });
resourcesPerPage?.addEventListener("change", () => { resourcesPage = 1; loadResources(); });
resourcesPagination?.addEventListener("click", event => { const button = event.target.closest("[data-page]"); if (!button || button.disabled) return; resourcesPage = Number(button.dataset.page) || 1; loadResources(); });
function renderAccount(session) { function renderAccount(session) {
currentSession = session; currentSession = session;
+15 -1
View File
@@ -29,6 +29,11 @@ const dialog = document.querySelector("#password-dialog");
const identityDialog = document.querySelector("#identity-dialog"); const identityDialog = document.querySelector("#identity-dialog");
const workspaceContent = document.querySelector("#workspace-content"); const workspaceContent = document.querySelector("#workspace-content");
const notesList = document.querySelector("#notes-list"); const notesList = document.querySelector("#notes-list");
const notesSearch = document.querySelector("#notes-search");
const notesPerPage = document.querySelector("#notes-per-page");
const notesPagination = document.querySelector("#notes-pagination");
let notesPage = 1;
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 = [];
@@ -115,15 +120,21 @@ async function showSystemNotFound() {
document.body.textContent = "404 Not Found"; document.body.textContent = "404 Not Found";
} }
} }
function renderNotesPagination(meta) {
notesPagination.innerHTML = meta.total ? `<button type="button" data-page="${meta.page - 1}" ${meta.page <= 1 ? "disabled" : ""}>Previous</button><span>Page ${meta.page} of ${meta.total_pages} · ${meta.total} notes</span><button type="button" data-page="${meta.page + 1}" ${meta.page >= meta.total_pages ? "disabled" : ""}>Next</button>` : "";
}
async function openWorkspace() { async function openWorkspace() {
try { try {
const data = await api(`/api/workspaces/${encodeURIComponent(slug)}/open`, { method: "POST", body: JSON.stringify({ access_token: accessToken || null }) }); const params = new URLSearchParams({ q: notesSearch.value.trim(), page: String(notesPage), per_page: notesPerPage.value });
const data = await api(`/api/workspaces/${encodeURIComponent(slug)}/open?${params}`, { method: "POST", body: JSON.stringify({ access_token: accessToken || null }) });
info = data.workspace; info = data.workspace;
document.querySelector("#workspace-title").textContent = info.title; document.querySelector("#workspace-title").textContent = info.title;
document.querySelector("#workspace-url").textContent = location.pathname; document.querySelector("#workspace-url").textContent = location.pathname;
document.title = `${info.title} · RustPad`; document.title = `${info.title} · RustPad`;
notesPage = data.pagination.page;
notesCache = data.notes; notesCache = data.notes;
renderNotes(); renderNotes();
renderNotesPagination(data.pagination);
if (dialog.open) dialog.close(); if (dialog.open) dialog.close();
} catch (e) { } catch (e) {
if (info?.protected || e.message.toLowerCase().includes("password")) { if (info?.protected || e.message.toLowerCase().includes("password")) {
@@ -190,6 +201,9 @@ document.querySelectorAll("[data-notes-view]").forEach(button => button.addEvent
notesView = button.dataset.notesView; notesView = button.dataset.notesView;
renderNotes(); renderNotes();
})); }));
notesSearch.addEventListener("input", () => { clearTimeout(notesSearchTimer); notesSearchTimer = setTimeout(() => { notesPage = 1; openWorkspace(); }, 250); });
notesPerPage.addEventListener("change", () => { notesPage = 1; openWorkspace(); });
notesPagination.addEventListener("click", event => { const button = event.target.closest("[data-page]"); if (!button || button.disabled) return; notesPage = Number(button.dataset.page) || 1; openWorkspace(); });
document.querySelector("#copy-workspace-link").addEventListener("click", async () => { document.querySelector("#copy-workspace-link").addEventListener("click", async () => {
try { await copyText(new URL(location.pathname, location.origin).href); toast("Link copied"); } try { await copyText(new URL(location.pathname, location.origin).href); toast("Link copied"); }
catch (e) { toast(e.message); } catch (e) { toast(e.message); }
+5 -1
View File
@@ -34,13 +34,17 @@
<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><button id="new-note-button" class="primary-button inline-button">New note</button>
</section> </section>
<div class="notes-toolbar"><span class="notes-toolbar__label">View</span> <div class="notes-toolbar">
<label class="list-search"><span class="sr-only">Search notes</span><input id="notes-search" type="search" placeholder="Search notes…" autocomplete="off"></label>
<label class="page-size-label">Per page<select id="notes-per-page"><option value="25">25</option><option value="50">50</option><option value="100">100</option></select></label>
<span class="notes-toolbar__label">View</span>
<div class="notes-view-switch" role="group" aria-label="Notes view"><button type="button" <div class="notes-view-switch" role="group" aria-label="Notes view"><button type="button"
data-notes-view="grid" class="active" aria-pressed="true">Cards</button><button type="button" data-notes-view="grid" class="active" aria-pressed="true">Cards</button><button type="button"
data-notes-view="table" aria-pressed="false">Table</button></div> data-notes-view="table" aria-pressed="false">Table</button></div>
</div> </div>
<p id="workspace-error" class="form-message error"></p> <p id="workspace-error" class="form-message error"></p>
<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>
</main> </main>
<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">