pagnation
This commit is contained in:
+46
-3
@@ -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(¬e.id);
|
let stats = stats.get(¬e.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
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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); }
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user