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::{
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<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)]
@@ -552,6 +577,7 @@ pub async fn open_workspace(
State(state): State<SharedState>,
headers: HeaderMap,
Path(workspace_slug): Path<String>,
Query(query): Query<WorkspaceNotesQuery>,
Json(payload): Json<PasswordRequest>,
) -> Result<Json<WorkspaceOpenResponse>, ApiError> {
let workspace = authorized_workspace(
@@ -573,9 +599,16 @@ pub async fn open_workspace(
.into_iter()
.map(|stats| (stats.note_id, stats))
.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?
.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(&note.id);
NoteListItem {
@@ -592,11 +625,21 @@ pub async fn open_workspace(
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 {
workspace: workspace_info_from(&workspace),
notes,
pagination: ListPaginationMeta { page, per_page, total, total_pages },
}))
}
+69 -6
View File
@@ -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<Self, sqlx::Error> {
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<ResourceItem>,
pads: Vec<ResourceItem>,
items: 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)]
pub struct SessionResponse {
@@ -1142,6 +1169,7 @@ async fn send_account_action(
pub async fn resources(
State(state): State<SharedState>,
headers: HeaderMap,
Query(query): Query<ResourceListQuery>,
) -> Result<Json<ResourceList>, 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))
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 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(
+126
View File
@@ -6356,3 +6356,129 @@ dialog::backdrop {
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
valid share links. Unauthorized visitors receive a not-found response.</p>
</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>
<nav id="resources-pagination" class="pagination" aria-label="Resources pagination"></nav>
<p id="resources-error" class="form-message error" role="alert"></p>
</div>
</dialog>
+18 -3
View File
@@ -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 ? `<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() {
resourcesError.textContent = ""; resourcesList.innerHTML = "<p>Loading…</p>";
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 ? "" : "<p>No assigned items yet.</p>";
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;
+15 -1
View File
@@ -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 ? `<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() {
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); }
+5 -1
View File
@@ -34,13 +34,17 @@
<p>Select a note or create a new one.</p>
</div><button id="new-note-button" class="primary-button inline-button">New note</button>
</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"
data-notes-view="grid" class="active" aria-pressed="true">Cards</button><button type="button"
data-notes-view="table" aria-pressed="false">Table</button></div>
</div>
<p id="workspace-error" class="form-message error"></p>
<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="identity-dialog">
<form id="identity-form" autocomplete="on" class="dialog-panel identity-panel">