fix(timeline): ensure cards always alternate sides across month boundaries
Per-month <ol> containers caused :nth-of-type to reset at each month, putting the first card of consecutive months on the same side. Flatten all items into a single container and use :nth-child(odd of .timeline-item) for continuous left/right alternation across months and infinite scroll.
This commit is contained in:
parent
80849f2bf5
commit
4ed1544d66
3 changed files with 149 additions and 147 deletions
|
|
@ -7,109 +7,105 @@
|
|||
</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 %}
|
||||
<li class="timeline-item relative mb-8 last:mb-0" data-timeline-event>
|
||||
{# Timeline node/bullet #}
|
||||
<span
|
||||
class="timeline-node absolute h-5 w-5 rounded-full border-4 border-amber-50 bg-amber-600"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<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">
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold bg-amber-200 text-amber-800"
|
||||
>{{ event.kind_label }}</span
|
||||
>
|
||||
<time
|
||||
datetime="{{ event.iso_timestamp }}"
|
||||
class="text-xs uppercase tracking-wide text-stone-500"
|
||||
>
|
||||
{{ event.date_label }}{% if let Some(label) = event.time_label %} · {{ label }}{% endif
|
||||
%}
|
||||
</time>
|
||||
</div>
|
||||
<h3 class="mt-3 flex items-center gap-2 text-lg font-semibold text-amber-800">
|
||||
<a href="{{ event.link }}" class="hover:text-amber-600">{{ event.title }}</a>
|
||||
{% if let Some(url) = event.external_link %}
|
||||
<a
|
||||
href="{{ url }}"
|
||||
class="inline-flex h-7 w-7 items-center justify-center text-amber-700 transition hover:text-amber-500"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
aria-label="Open external link"
|
||||
>
|
||||
<svg class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M11.3 2a.7.7 0 0 0 0 1.4h3.3l-8.1 8.1a.7.7 0 1 0 1 1l8.1-8.1v3.3a.7.7 0 1 0 1.4 0V2.7A.7.7 0 0 0 16.3 2h-5Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
<path
|
||||
d="M4.7 5.2a1.5 1.5 0 0 1 1.5-1.5h2.1a.7.7 0 1 0 0-1.4H6.2a2.9 2.9 0 0 0-2.9 2.9v7.6a2.9 2.9 0 0 0 2.9 2.9h7.6a2.9 2.9 0 0 0 2.9-2.9v-2.1a.7.7 0 1 0-1.4 0v2.1a1.5 1.5 0 0 1-1.5 1.5H6.1a1.5 1.5 0 0 1-1.5-1.5V5.2Z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="sr-only">Open external link</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if is_authenticated %}
|
||||
{% if let Some(brew) = event.brew_data %}
|
||||
<form
|
||||
class="inline ml-auto"
|
||||
data-on:submit="@post('/api/v1/brews', {contentType: 'form'})"
|
||||
>
|
||||
<input type="hidden" name="bag_id" value="{{ brew.bag_id }}" />
|
||||
<input type="hidden" name="coffee_weight" value="{{ brew.coffee_weight }}" />
|
||||
<input type="hidden" name="grinder_id" value="{{ brew.grinder_id }}" />
|
||||
<input type="hidden" name="grind_setting" value="{{ brew.grind_setting }}" />
|
||||
<input type="hidden" name="brewer_id" value="{{ brew.brewer_id }}" />
|
||||
<input type="hidden" name="water_volume" value="{{ brew.water_volume }}" />
|
||||
<input type="hidden" name="water_temp" value="{{ brew.water_temp }}" />
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex h-7 w-7 items-center justify-center rounded-md text-amber-700 transition hover:text-amber-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500"
|
||||
title="Brew again"
|
||||
>
|
||||
<span class="sr-only">Brew again</span>
|
||||
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M15.312 11.424a5.5 5.5 0 0 1-9.201 2.466l-.312-.311h2.433a.75.75 0 0 0 0-1.5H3.989a.75.75 0 0 0-.75.75v4.242a.75.75 0 0 0 1.5 0v-2.43l.31.31a7 7 0 0 0 11.712-3.138.75.75 0 0 0-1.449-.39Zm1.23-3.723a.75.75 0 0 0 .219-.53V2.929a.75.75 0 0 0-1.5 0v2.43l-.31-.31A7 7 0 0 0 3.239 8.188a.75.75 0 1 0 1.448.389 5.5 5.5 0 0 1 9.2-2.466l.312.311h-2.433a.75.75 0 0 0 0 1.5h4.243a.75.75 0 0 0 .53-.22Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</h3>
|
||||
{% if event.details.len() > 0 %}
|
||||
<dl class="mt-4 flex flex-col gap-2 text-sm text-stone-600">
|
||||
{% for detail in event.details %}
|
||||
<div class="flex justify-between gap-2">
|
||||
<dt class="font-medium text-stone-500">{{ detail.label }}</dt>
|
||||
<dd class="text-right">{{ detail.value }}</dd>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</dl>
|
||||
{% endif %} {% if let Some(notes) = event.tasting_notes %} {% if notes.is_empty() %}
|
||||
<p class="mt-4 text-sm text-stone-600">No tasting notes yet.</p>
|
||||
{% else %}
|
||||
<ul class="mt-4 flex flex-wrap gap-2">
|
||||
{% for note in notes %}
|
||||
<li>
|
||||
<span
|
||||
class="inline-flex items-center rounded-full border border-amber-500/60 bg-amber-500/10 px-3 py-1 text-xs font-semibold text-amber-700"
|
||||
>{{ note }}</span
|
||||
>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %} {% endif %}
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
</div>
|
||||
{% for event in month.events %}
|
||||
<div class="timeline-item relative mb-8" data-timeline-event>
|
||||
{# Timeline node/bullet #}
|
||||
<span
|
||||
class="timeline-node absolute h-5 w-5 rounded-full border-4 border-amber-50 bg-amber-600"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<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">
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold bg-amber-200 text-amber-800"
|
||||
>{{ event.kind_label }}</span
|
||||
>
|
||||
<time
|
||||
datetime="{{ event.iso_timestamp }}"
|
||||
class="text-xs uppercase tracking-wide text-stone-500"
|
||||
>
|
||||
{{ event.date_label }}{% if let Some(label) = event.time_label %} · {{ label }}{% endif
|
||||
%}
|
||||
</time>
|
||||
</div>
|
||||
<h3 class="mt-3 flex items-center gap-2 text-lg font-semibold text-amber-800">
|
||||
<a href="{{ event.link }}" class="hover:text-amber-600">{{ event.title }}</a>
|
||||
{% if let Some(url) = event.external_link %}
|
||||
<a
|
||||
href="{{ url }}"
|
||||
class="inline-flex h-7 w-7 items-center justify-center text-amber-700 transition hover:text-amber-500"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
aria-label="Open external link"
|
||||
>
|
||||
<svg class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M11.3 2a.7.7 0 0 0 0 1.4h3.3l-8.1 8.1a.7.7 0 1 0 1 1l8.1-8.1v3.3a.7.7 0 1 0 1.4 0V2.7A.7.7 0 0 0 16.3 2h-5Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
<path
|
||||
d="M4.7 5.2a1.5 1.5 0 0 1 1.5-1.5h2.1a.7.7 0 1 0 0-1.4H6.2a2.9 2.9 0 0 0-2.9 2.9v7.6a2.9 2.9 0 0 0 2.9 2.9h7.6a2.9 2.9 0 0 0 2.9-2.9v-2.1a.7.7 0 1 0-1.4 0v2.1a1.5 1.5 0 0 1-1.5 1.5H6.1a1.5 1.5 0 0 1-1.5-1.5V5.2Z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="sr-only">Open external link</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if is_authenticated %}
|
||||
{% if let Some(brew) = event.brew_data %}
|
||||
<form
|
||||
class="inline ml-auto"
|
||||
data-on:submit="@post('/api/v1/brews', {contentType: 'form'})"
|
||||
>
|
||||
<input type="hidden" name="bag_id" value="{{ brew.bag_id }}" />
|
||||
<input type="hidden" name="coffee_weight" value="{{ brew.coffee_weight }}" />
|
||||
<input type="hidden" name="grinder_id" value="{{ brew.grinder_id }}" />
|
||||
<input type="hidden" name="grind_setting" value="{{ brew.grind_setting }}" />
|
||||
<input type="hidden" name="brewer_id" value="{{ brew.brewer_id }}" />
|
||||
<input type="hidden" name="water_volume" value="{{ brew.water_volume }}" />
|
||||
<input type="hidden" name="water_temp" value="{{ brew.water_temp }}" />
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex h-7 w-7 items-center justify-center rounded-md text-amber-700 transition hover:text-amber-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500"
|
||||
title="Brew again"
|
||||
>
|
||||
<span class="sr-only">Brew again</span>
|
||||
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M15.312 11.424a5.5 5.5 0 0 1-9.201 2.466l-.312-.311h2.433a.75.75 0 0 0 0-1.5H3.989a.75.75 0 0 0-.75.75v4.242a.75.75 0 0 0 1.5 0v-2.43l.31.31a7 7 0 0 0 11.712-3.138.75.75 0 0 0-1.449-.39Zm1.23-3.723a.75.75 0 0 0 .219-.53V2.929a.75.75 0 0 0-1.5 0v2.43l-.31-.31A7 7 0 0 0 3.239 8.188a.75.75 0 1 0 1.448.389 5.5 5.5 0 0 1 9.2-2.466l.312.311h-2.433a.75.75 0 0 0 0 1.5h4.243a.75.75 0 0 0 .53-.22Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</h3>
|
||||
{% if event.details.len() > 0 %}
|
||||
<dl class="mt-4 flex flex-col gap-2 text-sm text-stone-600">
|
||||
{% for detail in event.details %}
|
||||
<div class="flex justify-between gap-2">
|
||||
<dt class="font-medium text-stone-500">{{ detail.label }}</dt>
|
||||
<dd class="text-right">{{ detail.value }}</dd>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</dl>
|
||||
{% endif %} {% if let Some(notes) = event.tasting_notes %} {% if notes.is_empty() %}
|
||||
<p class="mt-4 text-sm text-stone-600">No tasting notes yet.</p>
|
||||
{% else %}
|
||||
<ul class="mt-4 flex flex-wrap gap-2">
|
||||
{% for note in notes %}
|
||||
<li>
|
||||
<span
|
||||
class="inline-flex items-center rounded-full border border-amber-500/60 bg-amber-500/10 px-3 py-1 text-xs font-semibold text-amber-700"
|
||||
>{{ note }}</span
|
||||
>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %} {% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
|
|
|||
|
|
@ -118,24 +118,24 @@ a {
|
|||
padding-right: 0;
|
||||
}
|
||||
|
||||
/* Left side items (odd) - uses nth-of-type to skip the timeline-line div */
|
||||
.timeline-item:nth-of-type(odd) {
|
||||
/* Left side items (odd) - counts only .timeline-item children, skipping month headings */
|
||||
.timeline-item:nth-child(odd of .timeline-item) {
|
||||
align-self: flex-start;
|
||||
padding-right: 2rem;
|
||||
}
|
||||
|
||||
.timeline-item:nth-of-type(odd) .timeline-node {
|
||||
.timeline-item:nth-child(odd of .timeline-item) .timeline-node {
|
||||
right: -10px;
|
||||
left: auto;
|
||||
}
|
||||
|
||||
/* Right side items (even) */
|
||||
.timeline-item:nth-of-type(even) {
|
||||
.timeline-item:nth-child(even of .timeline-item) {
|
||||
align-self: flex-end;
|
||||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
.timeline-item:nth-of-type(even) .timeline-node {
|
||||
.timeline-item:nth-child(even of .timeline-item) .timeline-node {
|
||||
left: -10px;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,9 +8,7 @@
|
|||
|
||||
<div class="mt-8">
|
||||
<section
|
||||
class="space-y-12"
|
||||
id="timeline-events"
|
||||
data-role="timeline-months"
|
||||
data-empty="{{ months.is_empty() }}"
|
||||
>
|
||||
{% if months.is_empty() %}
|
||||
|
|
@ -20,7 +18,12 @@
|
|||
>
|
||||
No events yet.
|
||||
</p>
|
||||
{% else %} {% for month in months %} {% include "partials/timeline_month.html" %} {% endfor %}
|
||||
{% else %}
|
||||
<div class="timeline-list relative" id="timeline-items">
|
||||
{# Single central timeline line for all months #}
|
||||
<div class="timeline-line absolute top-0 bottom-0 w-0.5 bg-amber-200" aria-hidden="true"></div>
|
||||
{% for month in months %} {% include "partials/timeline_month.html" %} {% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div
|
||||
|
|
@ -49,15 +52,16 @@
|
|||
|
||||
<script type="module">
|
||||
const loader = document.getElementById("timeline-loader")
|
||||
const monthsContainer = document.getElementById("timeline-events")
|
||||
const itemsContainer = document.getElementById("timeline-items")
|
||||
const loadMoreButton = document.getElementById("timeline-load-more")
|
||||
const statusLine = document.getElementById("timeline-status")
|
||||
const endLine = document.getElementById("timeline-end")
|
||||
const errorLine = document.getElementById("timeline-error")
|
||||
const sentinel = document.getElementById("timeline-sentinel")
|
||||
const emptyState = document.querySelector('[data-role="timeline-empty-state"]')
|
||||
const section = document.getElementById("timeline-events")
|
||||
|
||||
if (loader && monthsContainer && sentinel) {
|
||||
if (loader && sentinel) {
|
||||
let nextUrl = loader.dataset.nextUrl || ""
|
||||
let hasMorePages = loader.dataset.hasMore === "true"
|
||||
let loading = false
|
||||
|
|
@ -84,29 +88,32 @@
|
|||
}
|
||||
}
|
||||
|
||||
const ensureItemsContainer = () => {
|
||||
if (itemsContainer) return itemsContainer
|
||||
// First load on a previously empty timeline: create the container
|
||||
const container = document.createElement("div")
|
||||
container.className = "timeline-list relative"
|
||||
container.id = "timeline-items"
|
||||
const line = document.createElement("div")
|
||||
line.className = "timeline-line absolute top-0 bottom-0 w-0.5 bg-amber-200"
|
||||
line.setAttribute("aria-hidden", "true")
|
||||
container.appendChild(line)
|
||||
section.insertBefore(container, loader)
|
||||
return container
|
||||
}
|
||||
|
||||
const appendMonths = (chunk) => {
|
||||
const monthsFragment = chunk.querySelector("[data-chunk-months]")
|
||||
if (!monthsFragment) {
|
||||
return
|
||||
}
|
||||
for (const monthNode of Array.from(monthsFragment.children)) {
|
||||
const anchor = monthNode.id
|
||||
if (!anchor) {
|
||||
if (!monthsFragment) return
|
||||
|
||||
const container = ensureItemsContainer()
|
||||
|
||||
for (const node of Array.from(monthsFragment.children)) {
|
||||
// Skip duplicate month headings (same month spanning a page boundary)
|
||||
if (node.hasAttribute("data-timeline-month") && document.getElementById(node.id)) {
|
||||
continue
|
||||
}
|
||||
const existing = document.getElementById(anchor)
|
||||
if (existing) {
|
||||
// Merge new events into existing month
|
||||
const existingList = existing.querySelector("ol")
|
||||
const newList = monthNode.querySelector("ol")
|
||||
if (existingList && newList) {
|
||||
// CSS :nth-of-type handles alternating pattern automatically
|
||||
const newItems = Array.from(newList.querySelectorAll(".timeline-item"))
|
||||
existingList.append(...newItems)
|
||||
}
|
||||
} else {
|
||||
monthsContainer.insertBefore(monthNode, loader)
|
||||
}
|
||||
container.appendChild(node)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -188,22 +195,21 @@
|
|||
)
|
||||
|
||||
// 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 timelineItems = document.getElementById("timeline-items")
|
||||
if (timelineItems) {
|
||||
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 })
|
||||
mutationObserver.observe(timelineItems, { childList: true })
|
||||
}
|
||||
|
||||
// Observe existing headings
|
||||
|
|
|
|||
Loading…
Reference in a new issue