brewlog/templates/base.html
Jon Seager fbf5a288c1
feat: support HEIF/HEIC image uploads via client-side Canvas conversion
Convert all image files to JPEG on the client before uploading, allowing
iPhone HEIF/HEIC photos to work without server-side libheif dependency.
2026-02-10 17:56:48 +00:00

224 lines
8.2 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 %}"
/>
<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" />
<link
rel="icon"
id="favicon"
type="image/svg+xml"
href="/static/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 = "/static/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="/static/js/location.js"></script>
<script defer src="/static/js/image-utils.js"></script>
<script defer src="/static/js/components/photo-capture.js"></script>
<script defer src="/static/js/components/searchable-select.js"></script>
<script defer src="/static/js/components/chip-scroll.js"></script>
<script defer src="/static/js/components/world-map.js"></script>
<script defer src="/static/js/components/donut-chart.js"></script>
<script defer src="/static/js/components/image-upload.js"></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 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>
<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>