- Remove dead CSS color property in .tab-mobile - Fix design token violations (text-red-500, fallback colors) - Add disconnectedCallback to chip-scroll, image-upload, searchable-select - Replace innerHTML with safe DOM APIs in world-map screen reader table - Add credentials: same-origin to WebAuthn fetch calls - Move page-specific scripts (donut-chart, location) out of base.html - Add client-side image resizing (1920px max dimension) - Resize app-icon-512.png from 2048x2048 to 512x512 - Document Datastar unsafe-eval CSP requirement - Add static asset serving tests (16 routes) - Add e2e tests for world-map, donut-chart, chip-scroll presence - Add cache-busting query params to all static asset URLs
257 lines
8.8 KiB
HTML
257 lines
8.8 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, viewport-fit=cover"
|
|
/>
|
|
<meta
|
|
name="description"
|
|
content="{% block description %}
|
|
Self-hosted coffee logging — track roasters, roasts, bags, brews, and
|
|
gear.
|
|
{% endblock %}"
|
|
/>
|
|
<meta property="og:type" content="website" />
|
|
<meta property="og:site_name" content="Brewlog" />
|
|
<meta
|
|
property="og:title"
|
|
content="{% block og_title %}Brewlog{% endblock %}"
|
|
/>
|
|
<meta
|
|
property="og:description"
|
|
content="{% block og_description %}
|
|
Self-hosted coffee logging — track roasters, roasts, bags, brews, and
|
|
gear.
|
|
{% endblock %}"
|
|
/>
|
|
<meta name="twitter:card" content="summary_large_image" />
|
|
<title>{% block title %}Brewlog{% endblock %}</title>
|
|
<link
|
|
rel="stylesheet"
|
|
href="/static/css/styles.css?v={{ version_info.commit }}"
|
|
/>
|
|
<link
|
|
rel="icon"
|
|
id="favicon"
|
|
type="image/svg+xml"
|
|
href="/static/favicon-light.svg?v={{ version_info.commit }}"
|
|
/>
|
|
<link
|
|
rel="apple-touch-icon"
|
|
href="/static/app-icon-192.png?v={{ version_info.commit }}"
|
|
/>
|
|
<link rel="manifest" href="/static/site.webmanifest" />
|
|
<meta name="apple-mobile-web-app-title" content="Brewlog" />
|
|
<meta name="theme-color" content="#c2410c" />
|
|
<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 =
|
|
"/static/favicon-dark.svg?v={{ version_info.commit }}";
|
|
}
|
|
})();
|
|
</script>
|
|
<script
|
|
type="module"
|
|
src="https://cdn.jsdelivr.net/gh/starfederation/datastar@1.0.0-RC.6/bundles/datastar.js"
|
|
></script>
|
|
<script
|
|
defer
|
|
src="/static/js/image-utils.js?v={{ version_info.commit }}"
|
|
></script>
|
|
<script
|
|
defer
|
|
src="/static/js/components/photo-capture.js?v={{ version_info.commit }}"
|
|
></script>
|
|
<script
|
|
defer
|
|
src="/static/js/components/searchable-select.js?v={{ version_info.commit }}"
|
|
></script>
|
|
<script
|
|
defer
|
|
src="/static/js/components/chip-scroll.js?v={{ version_info.commit }}"
|
|
></script>
|
|
<script
|
|
defer
|
|
src="/static/js/components/world-map.js?v={{ version_info.commit }}"
|
|
></script>
|
|
<script
|
|
defer
|
|
src="/static/js/components/image-upload.js?v={{ version_info.commit }}"
|
|
></script>
|
|
{% block head %}{% endblock %}
|
|
<script>
|
|
const showToast = (msg, ms = 3000) => {
|
|
const el = document.getElementById("toast-message");
|
|
if (!el) return;
|
|
el.textContent = msg;
|
|
el.parentElement.classList.remove("hidden");
|
|
clearTimeout(showToast._t);
|
|
showToast._t = setTimeout(
|
|
() => el.parentElement.classList.add("hidden"),
|
|
ms,
|
|
);
|
|
};
|
|
document.addEventListener("DOMContentLoaded", () => {
|
|
const t = sessionStorage.getItem("toast");
|
|
if (t) {
|
|
sessionStorage.removeItem("toast");
|
|
showToast(t);
|
|
}
|
|
});
|
|
</script>
|
|
<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 mainEl = document.querySelector("main");
|
|
if (mainEl) {
|
|
const mainObserver = new MutationObserver(() => {
|
|
setupInfiniteScroll();
|
|
});
|
|
mainObserver.observe(mainEl, { 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 pb-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>
|
|
|
|
<div
|
|
id="toast-container"
|
|
class="fixed bottom-4 left-1/2 -translate-x-1/2 z-50"
|
|
>
|
|
<div
|
|
class="hidden rounded-lg border border-success-border bg-surface px-4 py-3 text-sm font-medium text-success-text shadow-lg"
|
|
role="status"
|
|
aria-live="polite"
|
|
>
|
|
<span id="toast-message"></span>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>
|