From f71ebefa82d7143a5a4275e99aaea808a36c18b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Sun, 2 Aug 2026 14:26:32 +0200 Subject: [PATCH] pagnation --- src/api/mod.rs | 49 +++++++++++++++- src/auth/mod.rs | 85 +++++++++++++++++++++++---- static/css/styles.css | 128 ++++++++++++++++++++++++++++++++++++++++- static/home.html | 5 ++ static/js/home.js | 21 ++++++- static/js/workspace.js | 16 +++++- static/workspace.html | 6 +- 7 files changed, 290 insertions(+), 20 deletions(-) diff --git a/src/api/mod.rs b/src/api/mod.rs index 75de82d..da24495 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -19,7 +19,7 @@ pub use pads_public::*; use axum::{ Json, - extract::{Multipart, Path, State}, + extract::{Multipart, Path, Query, State}, http::{HeaderMap, HeaderName, HeaderValue, StatusCode, header}, response::{IntoResponse, Response}, }; @@ -283,6 +283,31 @@ pub struct WorkspaceInfo { pub struct WorkspaceOpenResponse { workspace: WorkspaceInfo, notes: Vec, + 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)] @@ -552,6 +577,7 @@ pub async fn open_workspace( State(state): State, headers: HeaderMap, Path(workspace_slug): Path, + Query(query): Query, Json(payload): Json, ) -> Result, ApiError> { let workspace = authorized_workspace( @@ -573,9 +599,16 @@ pub async fn open_workspace( .into_iter() .map(|stats| (stats.note_id, stats)) .collect::>(); - 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? .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| { let stats = stats.get(¬e.id); NoteListItem { @@ -592,11 +625,21 @@ pub async fn open_workspace( revision_count: stats.map_or(0, |value| value.revision_count), } }) - .collect(); + .collect::>(); + 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 { workspace: workspace_info_from(&workspace), notes, + pagination: ListPaginationMeta { page, per_page, total, total_pages }, })) } diff --git a/src/auth/mod.rs b/src/auth/mod.rs index c8fb905..5c49873 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -16,7 +16,7 @@ use argon2::{ }; use axum::{ Json, - extract::{Path as AxumPath, State}, + extract::{Path as AxumPath, Query, State}, http::{HeaderMap, StatusCode, header}, response::{IntoResponse, Response}, }; @@ -116,6 +116,8 @@ pub struct ResourceActionRequest { } #[derive(Serialize)] pub struct ResourceItem { + #[serde(default)] + kind: String, slug: String, title: String, protected: i64, @@ -201,6 +203,7 @@ impl<'r> sqlx::FromRow<'r, AnyRow> for User { impl<'r> sqlx::FromRow<'r, AnyRow> for ResourceItem { fn from_row(row: &'r AnyRow) -> Result { Ok(Self { + kind: String::new(), slug: crate::row_decode::text(row, "slug")?, title: crate::row_decode::text(row, "title")?, protected: row.try_get("protected")?, @@ -257,8 +260,32 @@ pub struct RevokeShareLinkRequest { #[derive(Serialize)] pub struct ResourceList { - workspaces: Vec, - pads: Vec, + items: Vec, + 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)] pub struct SessionResponse { @@ -1142,6 +1169,7 @@ async fn send_account_action( pub async fn resources( State(state): State, headers: HeaderMap, + Query(query): Query, ) -> Result, AuthError> { let user = require_user(&state, &headers).await?; let workspaces = sqlx::query_as::<_, ResourceItem>(queries::get( @@ -1153,14 +1181,49 @@ pub async fn resources( .fetch_all(state.db.pool()) .await .map_err(AuthError::database)?; - let pads = - sqlx::query_as::<_, ResourceItem>(queries::get(state.db.kind(), queries::USER_LIST_PADS)) - .bind(user.id) - .bind(user.id) - .fetch_all(state.db.pool()) - .await - .map_err(AuthError::database)?; - Ok(Json(ResourceList { workspaces, pads })) + let pads = sqlx::query_as::<_, ResourceItem>( + queries::get(state.db.kind(), queries::USER_LIST_PADS), + ) + .bind(user.id) + .bind(user.id) + .fetch_all(state.db.pool()) + .await + .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::>(); + 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( diff --git a/static/css/styles.css b/static/css/styles.css index 93c9d3f..6d459b5 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -6355,4 +6355,130 @@ dialog::backdrop { padding-left: 0; font-size: .68rem; } -} \ No newline at end of file +} +/* 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; + } +} diff --git a/static/home.html b/static/home.html index 44178f6..ed607c6 100644 --- a/static/home.html +++ b/static/home.html @@ -125,7 +125,12 @@ 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.

+
+ + +
+ diff --git a/static/js/home.js b/static/js/home.js index 92bc1b7..64c7919 100644 --- a/static/js/home.js +++ b/static/js/home.js @@ -107,6 +107,11 @@ const registrationEnabled = document.body.dataset.registrationEnabled === "true" const resourcesDialog = document.querySelector("#resources-dialog"); const resourcesList = document.querySelector("#resources-list"); 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 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 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 renderResourcesPagination(meta) { + resourcesPagination.innerHTML = meta.total ? `Page ${meta.page} of ${meta.total_pages} · ${meta.total} items` : ""; +} async function loadResources() { resourcesError.textContent = ""; resourcesList.innerHTML = "

Loading…

"; try { - const data = await api("/api/auth/resources", { headers: authHeaders() }); - 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 params = new URLSearchParams({ q: resourcesSearch.value.trim(), page: String(resourcesPage), per_page: resourcesPerPage.value }); + 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 ? "" : "

No assigned items yet.

"; for (const item of items) { const row = document.createElement("article"); @@ -256,9 +266,14 @@ async function loadResources() { }); 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) { currentSession = session; diff --git a/static/js/workspace.js b/static/js/workspace.js index 63fc134..d31d2df 100644 --- a/static/js/workspace.js +++ b/static/js/workspace.js @@ -29,6 +29,11 @@ const dialog = document.querySelector("#password-dialog"); const identityDialog = document.querySelector("#identity-dialog"); const workspaceContent = document.querySelector("#workspace-content"); 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`; let notesView = localStorage.getItem(notesViewKey) === "table" ? "table" : "grid"; let notesCache = []; @@ -115,15 +120,21 @@ async function showSystemNotFound() { document.body.textContent = "404 Not Found"; } } +function renderNotesPagination(meta) { + notesPagination.innerHTML = meta.total ? `Page ${meta.page} of ${meta.total_pages} · ${meta.total} notes` : ""; +} async function openWorkspace() { 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; document.querySelector("#workspace-title").textContent = info.title; document.querySelector("#workspace-url").textContent = location.pathname; document.title = `${info.title} · RustPad`; + notesPage = data.pagination.page; notesCache = data.notes; renderNotes(); + renderNotesPagination(data.pagination); if (dialog.open) dialog.close(); } catch (e) { 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; 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 () => { try { await copyText(new URL(location.pathname, location.origin).href); toast("Link copied"); } catch (e) { toast(e.message); } diff --git a/static/workspace.html b/static/workspace.html index 2e66ffa..894e878 100644 --- a/static/workspace.html +++ b/static/workspace.html @@ -34,13 +34,17 @@

Select a note or create a new one.

-
View +
+ + + View

+