feat(timeline): remove sidebar nav, include sticky headers on scroll
- Simplify timeline layout to single-column without sidebar - Update tests to explicitly pass page_size for pagination testing - Include sticky headers and a neater alternating, side-by-side timeline design
This commit is contained in:
parent
a0e2c2a1e9
commit
267ef2bf17
7 changed files with 249 additions and 120 deletions
|
|
@ -2,12 +2,13 @@ use axum::extract::{Query, State};
|
||||||
use axum::http::HeaderMap;
|
use axum::http::HeaderMap;
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
use crate::application::errors::{AppError, map_app_error};
|
use crate::application::errors::{AppError, map_app_error};
|
||||||
use crate::application::routes::render_html;
|
use crate::application::routes::render_html;
|
||||||
use crate::application::routes::support::{ListQuery, is_datastar_request, normalize_request};
|
use crate::application::routes::support::{is_datastar_request, normalize_request};
|
||||||
use crate::application::server::AppState;
|
use crate::application::server::AppState;
|
||||||
use crate::domain::listing::ListRequest;
|
use crate::domain::listing::{ListRequest, PageSize, SortDirection, SortKey};
|
||||||
use crate::domain::timeline::{TimelineEvent, TimelineSortKey};
|
use crate::domain::timeline::{TimelineEvent, TimelineSortKey};
|
||||||
use crate::presentation::web::templates::{TimelineChunkTemplate, TimelineTemplate};
|
use crate::presentation::web::templates::{TimelineChunkTemplate, TimelineTemplate};
|
||||||
use crate::presentation::web::views::{
|
use crate::presentation::web::views::{
|
||||||
|
|
@ -16,16 +17,67 @@ use crate::presentation::web::views::{
|
||||||
|
|
||||||
const TIMELINE_PAGE_PATH: &str = "/timeline";
|
const TIMELINE_PAGE_PATH: &str = "/timeline";
|
||||||
const TIMELINE_FRAGMENT_PATH: &str = "/timeline";
|
const TIMELINE_FRAGMENT_PATH: &str = "/timeline";
|
||||||
const TIMELINE_DEFAULT_PAGE_SIZE: u32 = 5;
|
const TIMELINE_DEFAULT_PAGE_SIZE: u32 = 20;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
enum PageSizeParam {
|
||||||
|
Number(u32),
|
||||||
|
Text(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct TimelineQuery {
|
||||||
|
page: Option<u32>,
|
||||||
|
#[serde(default)]
|
||||||
|
page_size: Option<PageSizeParam>,
|
||||||
|
#[serde(default, rename = "sort")]
|
||||||
|
sort_key: Option<String>,
|
||||||
|
#[serde(default, rename = "dir")]
|
||||||
|
sort_dir: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TimelineQuery {
|
||||||
|
fn to_request(&self) -> ListRequest<TimelineSortKey> {
|
||||||
|
let page = self.page.unwrap_or(1);
|
||||||
|
let page_size = match &self.page_size {
|
||||||
|
Some(PageSizeParam::Number(value)) => PageSize::limited(*value),
|
||||||
|
Some(PageSizeParam::Text(text)) if text.eq_ignore_ascii_case("all") => PageSize::All,
|
||||||
|
Some(PageSizeParam::Text(text)) => text
|
||||||
|
.parse::<u32>()
|
||||||
|
.map(PageSize::limited)
|
||||||
|
.unwrap_or(PageSize::limited(TIMELINE_DEFAULT_PAGE_SIZE)),
|
||||||
|
None => PageSize::limited(TIMELINE_DEFAULT_PAGE_SIZE),
|
||||||
|
};
|
||||||
|
|
||||||
|
let sort_key = self
|
||||||
|
.sort_key
|
||||||
|
.as_deref()
|
||||||
|
.and_then(TimelineSortKey::from_query)
|
||||||
|
.unwrap_or_else(TimelineSortKey::default);
|
||||||
|
|
||||||
|
let sort_direction = self
|
||||||
|
.sort_dir
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|dir| match dir.to_ascii_lowercase().as_str() {
|
||||||
|
"asc" => Some(SortDirection::Asc),
|
||||||
|
"desc" => Some(SortDirection::Desc),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| sort_key.default_direction());
|
||||||
|
|
||||||
|
ListRequest::new(page, page_size, sort_key, sort_direction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tracing::instrument(skip(state, cookies, headers, query))]
|
#[tracing::instrument(skip(state, cookies, headers, query))]
|
||||||
pub(crate) async fn timeline_page(
|
pub(crate) async fn timeline_page(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
cookies: tower_cookies::Cookies,
|
cookies: tower_cookies::Cookies,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
Query(query): Query<ListQuery>,
|
Query(query): Query<TimelineQuery>,
|
||||||
) -> Result<Response, StatusCode> {
|
) -> Result<Response, StatusCode> {
|
||||||
let request = query.into_request_with_default::<TimelineSortKey>(TIMELINE_DEFAULT_PAGE_SIZE);
|
let request = query.to_request();
|
||||||
let is_authenticated = super::is_authenticated(&state, &cookies).await;
|
let is_authenticated = super::is_authenticated(&state, &cookies).await;
|
||||||
|
|
||||||
if is_datastar_request(&headers) {
|
if is_datastar_request(&headers) {
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,8 @@ impl TimelineEventRepository for SqlTimelineEventRepository {
|
||||||
};
|
};
|
||||||
|
|
||||||
let order_clause = format!("t.occurred_at {direction_sql}, t.id DESC");
|
let order_clause = format!("t.occurred_at {direction_sql}, t.id DESC");
|
||||||
let base_query = "SELECT
|
|
||||||
|
let base_query = r"SELECT
|
||||||
t.id, t.entity_type, t.entity_id, t.action, t.occurred_at, t.title, t.details_json, t.tasting_notes_json,
|
t.id, t.entity_type, t.entity_id, t.action, t.occurred_at, t.title, t.details_json, t.tasting_notes_json,
|
||||||
CASE
|
CASE
|
||||||
WHEN t.entity_type = 'roaster' THEN r.slug
|
WHEN t.entity_type = 'roaster' THEN r.slug
|
||||||
|
|
@ -99,7 +100,8 @@ impl TimelineEventRepository for SqlTimelineEventRepository {
|
||||||
LEFT JOIN bags brew_bag ON brew.bag_id = brew_bag.id
|
LEFT JOIN bags brew_bag ON brew.bag_id = brew_bag.id
|
||||||
LEFT JOIN roasts brew_roast ON brew_bag.roast_id = brew_roast.id
|
LEFT JOIN roasts brew_roast ON brew_bag.roast_id = brew_roast.id
|
||||||
LEFT JOIN roasters brew_roaster ON brew_roast.roaster_id = brew_roaster.id";
|
LEFT JOIN roasters brew_roaster ON brew_roast.roaster_id = brew_roaster.id";
|
||||||
let count_query = "SELECT COUNT(*) FROM timeline_events";
|
|
||||||
|
let count_query = "SELECT COUNT(*) FROM timeline_events t";
|
||||||
|
|
||||||
crate::infrastructure::repositories::pagination::paginate(
|
crate::infrastructure::repositories::pagination::paginate(
|
||||||
&self.pool,
|
&self.pool,
|
||||||
|
|
|
||||||
|
|
@ -6,15 +6,4 @@
|
||||||
<div data-chunk-months>
|
<div data-chunk-months>
|
||||||
{% for month in months %} {% include "partials/timeline_month.html" %} {% endfor %}
|
{% for month in months %} {% include "partials/timeline_month.html" %} {% endfor %}
|
||||||
</div>
|
</div>
|
||||||
<ul data-chunk-nav-items class="hidden">
|
|
||||||
{% for month in months %}
|
|
||||||
<li data-timeline-nav-item>
|
|
||||||
<a
|
|
||||||
href="#{{ month.anchor }}"
|
|
||||||
class="block rounded-md border border-transparent px-3 py-2 transition hover:border-amber-400 hover:bg-amber-100/70 hover:text-amber-700"
|
|
||||||
>{{ month.heading }}</a
|
|
||||||
>
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,23 @@
|
||||||
<div id="{{ month.anchor }}" class="scroll-mt-24" data-timeline-month>
|
<div id="{{ month.anchor }}" class="scroll-mt-24" data-timeline-month>
|
||||||
<h2 class="text-2xl font-semibold text-amber-800">{{ month.heading }}</h2>
|
<h2 class="timeline-heading mb-6 text-2xl font-semibold text-amber-800">
|
||||||
<ol class="mt-6 space-y-8 border-l-2 border-amber-200 pl-4 sm:pl-8">
|
{{ month.heading }}
|
||||||
|
<button type="button" class="timeline-top-btn" onclick="window.scrollTo({top: 0, behavior: 'smooth'})" aria-label="Back to top">
|
||||||
|
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
|
<path fill-rule="evenodd" d="M10 17a.75.75 0 0 1-.75-.75V5.612L5.29 9.77a.75.75 0 0 1-1.08-1.04l5.25-5.5a.75.75 0 0 1 1.08 0l5.25 5.5a.75.75 0 1 1-1.08 1.04l-3.96-4.158V16.25A.75.75 0 0 1 10 17Z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</h2>
|
||||||
|
<ol class="timeline-list relative">
|
||||||
|
{# Central timeline line #}
|
||||||
|
<div class="timeline-line absolute top-0 bottom-0 w-0.5 bg-amber-200" aria-hidden="true"></div>
|
||||||
{% for event in month.events %}
|
{% for event in month.events %}
|
||||||
<li class="relative pl-6 sm:pl-10" data-timeline-event>
|
<li class="timeline-item {% if loop.index % 2 == 1 %}timeline-item-left{% else %}timeline-item-right{% endif %} relative mb-8 last:mb-0" data-timeline-event>
|
||||||
|
{# Timeline node/bullet #}
|
||||||
<span
|
<span
|
||||||
class="absolute left-[-10px] top-2 h-5 w-5 rounded-full border-[6px] border-amber-50 bg-amber-600 sm:left-[-14px]"
|
class="timeline-node absolute h-5 w-5 rounded-full border-4 border-amber-50 bg-amber-600"
|
||||||
|
aria-hidden="true"
|
||||||
></span>
|
></span>
|
||||||
<div class="rounded-lg border border-amber-200 bg-amber-50/80 p-5 shadow-sm">
|
<div class="rounded-lg border border-amber-200 bg-amber-50/80 p-5 shadow-sm text-left">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
<span
|
<span
|
||||||
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold bg-amber-200 text-amber-800"
|
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold bg-amber-200 text-amber-800"
|
||||||
|
|
|
||||||
|
|
@ -42,3 +42,105 @@ a {
|
||||||
.btn-adjust:hover {
|
.btn-adjust:hover {
|
||||||
background-color: rgba(245, 245, 244, 1);
|
background-color: rgba(245, 245, 244, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Timeline layout */
|
||||||
|
|
||||||
|
/* Mobile: single column with line on left */
|
||||||
|
.timeline-line {
|
||||||
|
left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item {
|
||||||
|
padding-left: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-node {
|
||||||
|
left: 0.5rem;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-heading {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
background-color: #fefce8; /* amber-50, matches page background */
|
||||||
|
padding-top: 1rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
transition: border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-heading.is-stuck {
|
||||||
|
border-bottom-color: #b45309; /* amber-700 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-top-btn {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
padding: 0.25rem;
|
||||||
|
color: #b45309; /* amber-700 */
|
||||||
|
opacity: 0.7;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-top-btn:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-heading.is-stuck .timeline-top-btn {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Desktop: alternating cards with central line */
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.timeline-heading {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-line {
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item {
|
||||||
|
width: 50%;
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Left side items (odd) */
|
||||||
|
.timeline-item-left {
|
||||||
|
align-self: flex-start;
|
||||||
|
padding-right: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item-left .timeline-node {
|
||||||
|
right: -10px;
|
||||||
|
left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Right side items (even) */
|
||||||
|
.timeline-item-right {
|
||||||
|
align-self: flex-end;
|
||||||
|
padding-left: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item-right .timeline-node {
|
||||||
|
left: -10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Allow vertical overlap between alternating cards (skip first item) */
|
||||||
|
.timeline-item + .timeline-item {
|
||||||
|
margin-top: -4rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="mt-8 grid gap-12 lg:grid-cols-[minmax(0,1fr),14rem]">
|
<div class="mt-8">
|
||||||
<section
|
<section
|
||||||
class="space-y-12"
|
class="space-y-12"
|
||||||
id="timeline-events"
|
id="timeline-events"
|
||||||
|
|
@ -18,7 +18,7 @@
|
||||||
class="rounded-lg border border-dashed border-amber-300 bg-amber-100/60 p-6 text-sm text-stone-600"
|
class="rounded-lg border border-dashed border-amber-300 bg-amber-100/60 p-6 text-sm text-stone-600"
|
||||||
data-role="timeline-empty-state"
|
data-role="timeline-empty-state"
|
||||||
>
|
>
|
||||||
No events yet. Create roasters or roasts to populate the timeline.
|
No events yet.
|
||||||
</p>
|
</p>
|
||||||
{% else %} {% for month in months %} {% include "partials/timeline_month.html" %} {% endfor %}
|
{% else %} {% for month in months %} {% include "partials/timeline_month.html" %} {% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
@ -34,69 +34,22 @@
|
||||||
id="timeline-load-more"
|
id="timeline-load-more"
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex items-center gap-2 rounded-full border border-amber-500 px-4 py-2 text-sm font-semibold text-amber-700 transition hover:border-amber-400 hover:text-amber-600 disabled:cursor-not-allowed disabled:border-amber-200 disabled:text-amber-300"
|
class="inline-flex items-center gap-2 rounded-full border border-amber-500 px-4 py-2 text-sm font-semibold text-amber-700 transition hover:border-amber-400 hover:text-amber-600 disabled:cursor-not-allowed disabled:border-amber-200 disabled:text-amber-300"
|
||||||
{%
|
style="{% if months.is_empty() || !events.has_next() %}display: none{% endif %}"
|
||||||
if
|
|
||||||
months.is_empty()
|
|
||||||
%}hidden{%
|
|
||||||
endif
|
|
||||||
%}
|
|
||||||
{%
|
|
||||||
if
|
|
||||||
!events.has_next()
|
|
||||||
%}disabled{%
|
|
||||||
endif
|
|
||||||
%}
|
|
||||||
>
|
>
|
||||||
<span aria-hidden="true">↓</span>
|
<span aria-hidden="true">↓</span>
|
||||||
<span>Load more</span>
|
Load more
|
||||||
</button>
|
</button>
|
||||||
<p id="timeline-status" class="text-xs text-stone-500" hidden>Loading…</p>
|
<p id="timeline-status" class="text-xs text-stone-500" hidden>Loading…</p>
|
||||||
<p
|
<p id="timeline-end" class="text-sm text-stone-500" style="{% if events.has_next() || months.is_empty() %}display: none{% endif %}">No more events.</p>
|
||||||
id="timeline-end"
|
|
||||||
class="text-sm text-stone-500"
|
|
||||||
{%
|
|
||||||
if
|
|
||||||
events.has_next()
|
|
||||||
%}hidden{%
|
|
||||||
endif
|
|
||||||
%}
|
|
||||||
>
|
|
||||||
No more events.
|
|
||||||
</p>
|
|
||||||
<p id="timeline-error" class="text-sm text-red-600" hidden></p>
|
<p id="timeline-error" class="text-sm text-red-600" hidden></p>
|
||||||
</div>
|
</div>
|
||||||
<div id="timeline-sentinel" class="h-1"></div>
|
<div id="timeline-sentinel" class="h-1"></div>
|
||||||
</section>
|
</section>
|
||||||
<aside class="sticky top-24 self-start lg:top-28">
|
|
||||||
<div class="rounded-lg border border-amber-200 bg-amber-50/80 p-4 shadow-sm">
|
|
||||||
<h2 class="text-sm font-semibold uppercase tracking-wide text-stone-500">Jump to</h2>
|
|
||||||
{% if months.is_empty() %}
|
|
||||||
<p class="mt-3 text-sm text-stone-600">
|
|
||||||
Timeline navigation will appear once events are available.
|
|
||||||
</p>
|
|
||||||
{% else %}
|
|
||||||
<nav class="mt-3 text-sm">
|
|
||||||
<ul class="flex flex-col gap-2" data-role="timeline-nav-list">
|
|
||||||
{% for month in months %}
|
|
||||||
<li data-timeline-nav-item>
|
|
||||||
<a
|
|
||||||
href="#{{ month.anchor }}"
|
|
||||||
class="block rounded-md border border-transparent px-3 py-2 transition hover:border-amber-400 hover:bg-amber-100/70 hover:text-amber-700"
|
|
||||||
>{{ month.heading }}</a
|
|
||||||
>
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script type="module">
|
<script type="module">
|
||||||
const loader = document.getElementById("timeline-loader")
|
const loader = document.getElementById("timeline-loader")
|
||||||
const monthsContainer = document.getElementById("timeline-events")
|
const monthsContainer = document.getElementById("timeline-events")
|
||||||
const navList = document.querySelector('[data-role="timeline-nav-list"]')
|
|
||||||
const loadMoreButton = document.getElementById("timeline-load-more")
|
const loadMoreButton = document.getElementById("timeline-load-more")
|
||||||
const statusLine = document.getElementById("timeline-status")
|
const statusLine = document.getElementById("timeline-status")
|
||||||
const endLine = document.getElementById("timeline-end")
|
const endLine = document.getElementById("timeline-end")
|
||||||
|
|
@ -106,11 +59,12 @@
|
||||||
|
|
||||||
if (loader && monthsContainer && sentinel) {
|
if (loader && monthsContainer && sentinel) {
|
||||||
let nextUrl = loader.dataset.nextUrl || ""
|
let nextUrl = loader.dataset.nextUrl || ""
|
||||||
|
let hasMorePages = loader.dataset.hasMore === "true"
|
||||||
let loading = false
|
let loading = false
|
||||||
|
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
if (entries.some((entry) => entry.isIntersecting)) {
|
if (entries.some((entry) => entry.isIntersecting) && hasMorePages && nextUrl) {
|
||||||
void loadMore()
|
void loadMore()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -118,18 +72,14 @@
|
||||||
)
|
)
|
||||||
|
|
||||||
const setHasMore = (hasMore, url) => {
|
const setHasMore = (hasMore, url) => {
|
||||||
loader.dataset.hasMore = hasMore ? "true" : "false"
|
hasMorePages = hasMore
|
||||||
nextUrl = url || ""
|
nextUrl = url || ""
|
||||||
|
loader.dataset.hasMore = hasMore ? "true" : "false"
|
||||||
loader.dataset.nextUrl = nextUrl
|
loader.dataset.nextUrl = nextUrl
|
||||||
if (loadMoreButton) {
|
|
||||||
loadMoreButton.disabled = !hasMore
|
if (!hasMore) {
|
||||||
}
|
if (loadMoreButton) loadMoreButton.style.display = "none"
|
||||||
if (endLine) {
|
if (endLine) endLine.style.display = ""
|
||||||
endLine.hidden = hasMore || loader.dataset.empty === "true"
|
|
||||||
}
|
|
||||||
if (hasMore) {
|
|
||||||
observer.observe(sentinel)
|
|
||||||
} else {
|
|
||||||
observer.disconnect()
|
observer.disconnect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -149,33 +99,23 @@
|
||||||
const existingList = existing.querySelector("ol")
|
const existingList = existing.querySelector("ol")
|
||||||
const newList = monthNode.querySelector("ol")
|
const newList = monthNode.querySelector("ol")
|
||||||
if (existingList && newList) {
|
if (existingList && newList) {
|
||||||
existingList.append(...Array.from(newList.children))
|
// Count existing items to continue the alternating pattern
|
||||||
|
const existingCount = existingList.querySelectorAll(".timeline-item").length
|
||||||
|
// Filter to only timeline-items (exclude the timeline-line div)
|
||||||
|
const newItems = Array.from(newList.querySelectorAll(".timeline-item"))
|
||||||
|
|
||||||
|
// Adjust left/right classes to continue the pattern
|
||||||
|
newItems.forEach((item, i) => {
|
||||||
|
const newIndex = existingCount + i + 1 // 1-based
|
||||||
|
const shouldBeLeft = newIndex % 2 === 1
|
||||||
|
item.classList.remove("timeline-item-left", "timeline-item-right")
|
||||||
|
item.classList.add(shouldBeLeft ? "timeline-item-left" : "timeline-item-right")
|
||||||
|
})
|
||||||
|
|
||||||
|
existingList.append(...newItems)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
monthsContainer.appendChild(monthNode)
|
monthsContainer.insertBefore(monthNode, loader)
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const appendNavItems = (chunk) => {
|
|
||||||
if (!navList) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const navItemsContainer = chunk.querySelector("[data-chunk-nav-items]")
|
|
||||||
if (!navItemsContainer) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for (const item of Array.from(navItemsContainer.children)) {
|
|
||||||
const link = item.querySelector("a")
|
|
||||||
if (!link) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const href = link.getAttribute("href")
|
|
||||||
if (!href) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (!navList.querySelector(`a[href="${href}"]`)) {
|
|
||||||
navList.appendChild(item)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -188,9 +128,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadMore = async () => {
|
const loadMore = async () => {
|
||||||
if (!nextUrl || loading) {
|
if (loading || !hasMorePages || !nextUrl) return
|
||||||
return
|
|
||||||
}
|
|
||||||
loading = true
|
loading = true
|
||||||
if (statusLine) {
|
if (statusLine) {
|
||||||
statusLine.hidden = false
|
statusLine.hidden = false
|
||||||
|
|
@ -198,6 +137,7 @@
|
||||||
if (errorLine) {
|
if (errorLine) {
|
||||||
errorLine.hidden = true
|
errorLine.hidden = true
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(nextUrl, {
|
const response = await fetch(nextUrl, {
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -215,8 +155,8 @@
|
||||||
if (!chunk) {
|
if (!chunk) {
|
||||||
throw new Error("Invalid timeline chunk payload")
|
throw new Error("Invalid timeline chunk payload")
|
||||||
}
|
}
|
||||||
|
|
||||||
appendMonths(chunk)
|
appendMonths(chunk)
|
||||||
appendNavItems(chunk)
|
|
||||||
clearEmptyState()
|
clearEmptyState()
|
||||||
|
|
||||||
const hasMore = chunk.dataset.hasMore === "true"
|
const hasMore = chunk.dataset.hasMore === "true"
|
||||||
|
|
@ -228,9 +168,6 @@
|
||||||
errorLine.textContent = "Failed to load more events. Please try again."
|
errorLine.textContent = "Failed to load more events. Please try again."
|
||||||
errorLine.hidden = false
|
errorLine.hidden = false
|
||||||
}
|
}
|
||||||
if (loadMoreButton) {
|
|
||||||
loadMoreButton.disabled = false
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
loading = false
|
loading = false
|
||||||
if (statusLine) {
|
if (statusLine) {
|
||||||
|
|
@ -245,9 +182,43 @@
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loader.dataset.empty !== "true" && nextUrl) {
|
if (hasMorePages && loader.dataset.empty !== "true") {
|
||||||
observer.observe(sentinel)
|
observer.observe(sentinel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sticky header detection - observe all current and future headings
|
||||||
|
const stickyObserver = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
entry.target.classList.toggle("is-stuck", !entry.isIntersecting)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{ rootMargin: "-1px 0px 0px 0px", threshold: 1 }
|
||||||
|
)
|
||||||
|
|
||||||
|
// Use MutationObserver to watch for new month headings being added
|
||||||
|
const mutationObserver = new MutationObserver((mutations) => {
|
||||||
|
mutations.forEach((mutation) => {
|
||||||
|
mutation.addedNodes.forEach((node) => {
|
||||||
|
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||||
|
const heading = node.querySelector?.(".timeline-heading")
|
||||||
|
if (heading) {
|
||||||
|
stickyObserver.observe(heading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const timelineContainer = document.getElementById("timeline-events")
|
||||||
|
if (timelineContainer) {
|
||||||
|
mutationObserver.observe(timelineContainer, { childList: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Observe existing headings
|
||||||
|
document.querySelectorAll(".timeline-heading").forEach((heading) => {
|
||||||
|
stickyObserver.observe(heading)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -249,8 +249,9 @@ async fn timeline_page_signals_more_results_when_over_page_size() {
|
||||||
|
|
||||||
let client = Client::new();
|
let client = Client::new();
|
||||||
|
|
||||||
|
// Explicitly request page_size=5 to test pagination with 6 events
|
||||||
let response = client
|
let response = client
|
||||||
.get(format!("{}/timeline", app.address))
|
.get(format!("{}/timeline?page_size=5", app.address))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.expect("failed to fetch timeline");
|
.expect("failed to fetch timeline");
|
||||||
|
|
@ -293,6 +294,7 @@ async fn timeline_chunk_endpoint_serves_remaining_events() {
|
||||||
.clone();
|
.clone();
|
||||||
let client = Client::new();
|
let client = Client::new();
|
||||||
|
|
||||||
|
// page_size=5 to test pagination with 6 events
|
||||||
let chunk_url = format!(
|
let chunk_url = format!(
|
||||||
"{}/timeline?page=2&page_size=5&sort=occurred-at&dir=desc",
|
"{}/timeline?page=2&page_size=5&sort=occurred-at&dir=desc",
|
||||||
app.address
|
app.address
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue