- Make nav bar fixed on mobile with safe-area-inset-top support for notched devices (10% reduction) and 1rem minimum for non-notched - Adjust timeline sticky month headings to sit flush below the fixed nav with matching top/bottom padding - Update IntersectionObserver rootMargin to account for fixed nav height so stuck-state accent border triggers correctly - Increase main content top padding on mobile for balanced spacing on detail pages - Add remaining field to bag edit form with +/- stepper buttons - Make admin page passkey/token forms responsive with icon-only mobile delete/revoke buttons - Make detail page action buttons stack vertically on mobile with equal width
252 lines
8.3 KiB
HTML
252 lines
8.3 KiB
HTML
{% extends "base.html" %} {% block title %}Brewlog · Timeline{% endblock %}
|
|
{% block content %}
|
|
<div>
|
|
<section
|
|
id="timeline-events"
|
|
data-signals:_expanded-card="''"
|
|
data-signals:_expanded-months="''"
|
|
data-signals:_collapsed-cards="''"
|
|
data-empty="{{ months.is_empty() }}"
|
|
>
|
|
{% if months.is_empty() %}
|
|
<p
|
|
class="rounded-lg border border-dashed p-6 text-sm text-text-secondary"
|
|
data-role="timeline-empty-state"
|
|
>
|
|
No events yet.
|
|
</p>
|
|
{% 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-1 bg-border"
|
|
aria-hidden="true"
|
|
></div>
|
|
{% for month in months %}{% include "partials/timeline_month.html" %}{% endfor %}
|
|
</div>
|
|
{% endif %}
|
|
|
|
<div
|
|
id="timeline-loader"
|
|
class="mt-8 flex flex-col items-center gap-3"
|
|
data-next-url="{% if events.has_next() %}{{ navigator.fragment_page_href(events.next_page().unwrap()) }}{% else %}{% endif %}"
|
|
data-has-more="{{ events.has_next() }}"
|
|
data-empty="{{ months.is_empty() }}"
|
|
>
|
|
<button
|
|
id="timeline-load-more"
|
|
type="button"
|
|
class="inline-flex items-center gap-2 rounded-full border px-4 py-2 text-sm font-semibold text-accent transition hover:border-accent hover:text-accent disabled:cursor-not-allowed disabled:opacity-40"
|
|
style="{% if months.is_empty() || !events.has_next() %}display: none{% endif %}"
|
|
>
|
|
<span aria-hidden="true">↓</span>
|
|
Load more
|
|
</button>
|
|
<p id="timeline-status" class="text-xs text-text-muted" hidden>
|
|
Loading…
|
|
</p>
|
|
<p
|
|
id="timeline-end"
|
|
class="text-sm text-text-muted"
|
|
style="{% if events.has_next() || months.is_empty() %}display: none{% endif %}"
|
|
>
|
|
No more events.
|
|
</p>
|
|
<p
|
|
id="timeline-error"
|
|
class="text-sm text-error"
|
|
role="alert"
|
|
hidden
|
|
></p>
|
|
</div>
|
|
<div id="timeline-sentinel" class="h-1"></div>
|
|
</section>
|
|
</div>
|
|
|
|
<script type="module">
|
|
const loader = document.getElementById("timeline-loader");
|
|
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 && sentinel) {
|
|
let nextUrl = loader.dataset.nextUrl || "";
|
|
let hasMorePages = loader.dataset.hasMore === "true";
|
|
let loading = false;
|
|
|
|
const observer = new IntersectionObserver(
|
|
(entries) => {
|
|
if (
|
|
entries.some((entry) => entry.isIntersecting) &&
|
|
hasMorePages &&
|
|
nextUrl
|
|
) {
|
|
void loadMore();
|
|
}
|
|
},
|
|
{ rootMargin: "200px" },
|
|
);
|
|
|
|
const setHasMore = (hasMore, url) => {
|
|
hasMorePages = hasMore;
|
|
nextUrl = url || "";
|
|
loader.dataset.hasMore = hasMore ? "true" : "false";
|
|
loader.dataset.nextUrl = nextUrl;
|
|
|
|
if (!hasMore) {
|
|
if (loadMoreButton) loadMoreButton.style.display = "none";
|
|
if (endLine) endLine.style.display = "";
|
|
observer.disconnect();
|
|
}
|
|
};
|
|
|
|
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-1 bg-border";
|
|
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;
|
|
|
|
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;
|
|
}
|
|
container.appendChild(node);
|
|
}
|
|
};
|
|
|
|
const clearEmptyState = () => {
|
|
if (emptyState && !emptyState.hidden) {
|
|
emptyState.hidden = true;
|
|
loader.dataset.empty = "false";
|
|
}
|
|
};
|
|
|
|
const loadMore = async () => {
|
|
if (loading || !hasMorePages || !nextUrl) return;
|
|
|
|
loading = true;
|
|
if (statusLine) {
|
|
statusLine.hidden = false;
|
|
}
|
|
if (errorLine) {
|
|
errorLine.hidden = true;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(nextUrl, {
|
|
headers: {
|
|
"X-Requested-With": "fetch",
|
|
"datastar-request": "true",
|
|
},
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`Unexpected response: ${response.status}`);
|
|
}
|
|
const html = await response.text();
|
|
const template = document.createElement("template");
|
|
template.innerHTML = html.trim();
|
|
const chunk = template.content.querySelector("[data-timeline-chunk]");
|
|
if (!chunk) {
|
|
throw new Error("Invalid timeline chunk payload");
|
|
}
|
|
|
|
appendMonths(chunk);
|
|
clearEmptyState();
|
|
|
|
const hasMore = chunk.dataset.hasMore === "true";
|
|
const url = chunk.dataset.nextUrl || "";
|
|
setHasMore(hasMore, url);
|
|
} catch (error) {
|
|
console.error(error);
|
|
if (errorLine) {
|
|
errorLine.textContent =
|
|
"Failed to load more events. Please try again.";
|
|
errorLine.hidden = false;
|
|
}
|
|
} finally {
|
|
loading = false;
|
|
if (statusLine) {
|
|
statusLine.hidden = true;
|
|
}
|
|
}
|
|
};
|
|
|
|
if (loadMoreButton) {
|
|
loadMoreButton.addEventListener("click", () => {
|
|
void loadMore();
|
|
});
|
|
}
|
|
|
|
if (hasMorePages && loader.dataset.empty !== "true") {
|
|
observer.observe(sentinel);
|
|
}
|
|
}
|
|
|
|
// Sticky header detection - observe all current and future headings
|
|
// On mobile the nav is fixed, so shrink the observer root by its height
|
|
const navBar = document.querySelector(".nav-bar");
|
|
const navH =
|
|
navBar && matchMedia("(max-width: 767px)").matches
|
|
? navBar.getBoundingClientRect().height
|
|
: 0;
|
|
const stickyObserver = new IntersectionObserver(
|
|
(entries) => {
|
|
entries.forEach((entry) => {
|
|
entry.target.classList.toggle("is-stuck", !entry.isIntersecting);
|
|
});
|
|
},
|
|
{ rootMargin: `-${navH + 1}px 0px 0px 0px`, threshold: 1 },
|
|
);
|
|
|
|
// Use MutationObserver to watch for new month headings being added
|
|
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) {
|
|
if (node.matches?.(".timeline-heading")) {
|
|
stickyObserver.observe(node);
|
|
}
|
|
const heading = node.querySelector?.(".timeline-heading");
|
|
if (heading) {
|
|
stickyObserver.observe(heading);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
});
|
|
mutationObserver.observe(timelineItems, { childList: true });
|
|
}
|
|
|
|
// Observe existing headings
|
|
document.querySelectorAll(".timeline-heading").forEach((heading) => {
|
|
stickyObserver.observe(heading);
|
|
});
|
|
</script>
|
|
{% endblock %}
|