- Refactor stats handler to load_or_compute with cache fallback - Add donut charts for brewer and grinder usage (with XSS-safe labels) - Add bar charts for origins, flavours, grind weight, and brew time - Add consumption cards (total brewed, avg per brew, bags consumed) - Add roast summary section (top origins, processes, producers) - Register donut-chart.js static asset route - Add Recompute Stats button to admin page
150 lines
6 KiB
HTML
150 lines
6 KiB
HTML
<!doctype html>
|
|
<html lang="en" data-star-root>
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<meta
|
|
name="description"
|
|
content="{% block description %}Self-hosted coffee logging — track roasters, roasts, bags, brews, and gear.{% endblock %}"
|
|
/>
|
|
<title>{% block title %}Brewlog{% endblock %}</title>
|
|
<link rel="stylesheet" href="/styles.css" />
|
|
<link rel="icon" id="favicon" type="image/svg+xml" href="/favicon-light.svg" />
|
|
<script>
|
|
;(() => {
|
|
const stored = localStorage.getItem("theme")
|
|
const isDark =
|
|
stored === "dark" || (!stored && matchMedia("(prefers-color-scheme: dark)").matches)
|
|
if (isDark) {
|
|
document.documentElement.setAttribute("data-theme", "dark")
|
|
document.getElementById("favicon").href = "/favicon-dark.svg"
|
|
}
|
|
})()
|
|
</script>
|
|
<script
|
|
type="module"
|
|
src="https://cdn.jsdelivr.net/gh/starfederation/datastar@1.0.0-RC.6/bundles/datastar.js"
|
|
></script>
|
|
<script defer src="/components/photo-capture.js"></script>
|
|
<script defer src="/components/searchable-select.js"></script>
|
|
<script defer src="/components/chip-scroll.js"></script>
|
|
<script defer src="/components/world-map.js"></script>
|
|
<script defer src="/components/donut-chart.js"></script>
|
|
{% block head %}{% endblock %}
|
|
<script>
|
|
const toggleRow = (event) => {
|
|
if (event.target.closest(".card-detail")) return
|
|
const row = event.currentTarget
|
|
row.classList.toggle("expanded")
|
|
row.querySelector(".card-detail")?.classList.toggle("hidden")
|
|
const r = row.nextElementSibling
|
|
if (r?.classList.contains("detail-row")) r.classList.toggle("hidden")
|
|
row.querySelector(".icon-expand")?.classList.toggle("hidden")
|
|
row.querySelector(".icon-collapse")?.classList.toggle("hidden")
|
|
}
|
|
</script>
|
|
<script>
|
|
document.addEventListener("DOMContentLoaded", () => {
|
|
const mobileQuery = window.matchMedia("(max-width: 767px)")
|
|
if (!mobileQuery.matches) return
|
|
|
|
const setupInfiniteScroll = () => {
|
|
document.querySelectorAll("[data-infinite-scroll]").forEach((container) => {
|
|
const sentinel = container.querySelector(".infinite-scroll-sentinel")
|
|
if (!sentinel || sentinel.dataset.observed) return
|
|
sentinel.dataset.observed = "true"
|
|
|
|
const observer = new IntersectionObserver(
|
|
(entries) => {
|
|
entries.forEach((entry) => {
|
|
if (!entry.isIntersecting) return
|
|
|
|
const nextUrl = container.getAttribute("data-next-url")
|
|
const targetSelector = container.getAttribute("data-target")
|
|
if (!nextUrl || !targetSelector) {
|
|
observer.disconnect()
|
|
return
|
|
}
|
|
|
|
observer.disconnect()
|
|
sentinel.remove()
|
|
|
|
fetch(nextUrl, {
|
|
headers: { "datastar-request": "true" },
|
|
})
|
|
.then((res) => res.text())
|
|
.then((html) => {
|
|
const doc = new DOMParser().parseFromString(html, "text/html")
|
|
const newTarget = doc.querySelector(targetSelector)
|
|
if (!newTarget) return
|
|
|
|
// Append new rows to existing tbody
|
|
const existingTbody = container.querySelector("tbody")
|
|
const newTbody = newTarget.querySelector("tbody")
|
|
if (existingTbody && newTbody) {
|
|
Array.from(newTbody.children).forEach((row) => {
|
|
existingTbody.appendChild(document.adoptNode(row))
|
|
})
|
|
}
|
|
|
|
// Check if there are more pages
|
|
const newContainer = newTarget.querySelector("[data-infinite-scroll]")
|
|
const newNextUrl = newContainer && newContainer.getAttribute("data-next-url")
|
|
if (newNextUrl) {
|
|
container.setAttribute("data-next-url", newNextUrl)
|
|
const newSentinel = document.createElement("div")
|
|
newSentinel.className = "infinite-scroll-sentinel h-4 md:hidden"
|
|
newSentinel.setAttribute("aria-hidden", "true")
|
|
container.appendChild(newSentinel)
|
|
setupInfiniteScroll()
|
|
} else {
|
|
container.removeAttribute("data-next-url")
|
|
container.removeAttribute("data-infinite-scroll")
|
|
}
|
|
})
|
|
})
|
|
},
|
|
{ rootMargin: "200px" }
|
|
)
|
|
|
|
observer.observe(sentinel)
|
|
})
|
|
}
|
|
|
|
setupInfiniteScroll()
|
|
|
|
const bodyObserver = new MutationObserver(() => {
|
|
setupInfiniteScroll()
|
|
})
|
|
bodyObserver.observe(document.body, { childList: true, subtree: true })
|
|
})
|
|
</script>
|
|
</head>
|
|
<body class="min-h-screen bg-page text-text">
|
|
<main class="mx-auto flex w-full max-w-5xl flex-col gap-8 px-6 py-4 md:py-10">
|
|
{% include "partials/nav.html" %} {% block content %}{% endblock %}
|
|
<footer
|
|
class="mt-8 text-center text-xs tracking-widest text-text-muted small-caps"
|
|
>
|
|
<p>
|
|
B{rew}log by
|
|
<a
|
|
href="https://jnsgr.uk"
|
|
class="text-text-muted hover:text-accent transition"
|
|
target="_blank"
|
|
rel="noreferrer noopener"
|
|
>jnsgruk</a
|
|
>
|
|
·
|
|
<a
|
|
href="https://github.com/jnsgruk/brewlog/commit/{{ version_info.commit }}"
|
|
class="text-text-muted hover:text-accent transition"
|
|
target="_blank"
|
|
rel="noreferrer noopener"
|
|
>{{ version_info.commit }}</a
|
|
>
|
|
</p>
|
|
</footer>
|
|
</main>
|
|
</body>
|
|
</html>
|