feat: initial shared library extraction
Contains shared infrastructure for all coop services: - shared/ (factory, urls, user_loader, context, internal_api, jinja_setup) - models/ (User, Order, Calendar, Ticket, Product, Ghost CMS) - db/ (SQLAlchemy async session, base) - suma_browser/app/ (csrf, middleware, errors, authz, redis_cacher, payments, filters, utils) - suma_browser/templates/ (shared base layouts, macros, error pages) - static/ (CSS, JS, fonts, images) - alembic/ (database migrations) - config/ (app-config.yaml) - editor/ (Lexical editor Node.js build) - requirements.txt Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
BIN
static/errors/403.gif
Normal file
|
After Width: | Height: | Size: 101 KiB |
BIN
static/errors/404.gif
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
static/errors/error.gif
Normal file
|
After Width: | Height: | Size: 646 KiB |
BIN
static/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
9
static/fontawesome/css/all.min.css
vendored
Normal file
6
static/fontawesome/css/v4-shims.min.css
vendored
Normal file
BIN
static/fontawesome/webfonts/fa-brands-400.ttf
Normal file
BIN
static/fontawesome/webfonts/fa-brands-400.woff2
Normal file
BIN
static/fontawesome/webfonts/fa-regular-400.ttf
Normal file
BIN
static/fontawesome/webfonts/fa-regular-400.woff2
Normal file
BIN
static/fontawesome/webfonts/fa-solid-900.ttf
Normal file
BIN
static/fontawesome/webfonts/fa-solid-900.woff2
Normal file
BIN
static/fontawesome/webfonts/fa-v4compatibility.ttf
Normal file
BIN
static/fontawesome/webfonts/fa-v4compatibility.woff2
Normal file
2
static/img/filter.svg
Normal file
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" ?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M18 7H17M17 7H16M17 7V6M17 7V8M12.5 5H6C5.5286 5 5.29289 5 5.14645 5.14645C5 5.29289 5 5.5286 5 6V7.96482C5 8.2268 5 8.35779 5.05916 8.46834C5.11833 8.57888 5.22732 8.65154 5.4453 8.79687L8.4688 10.8125C9.34073 11.3938 9.7767 11.6845 10.0133 12.1267C10.25 12.5688 10.25 13.0928 10.25 14.1407V19L13.75 17.25V14.1407C13.75 13.0928 13.75 12.5688 13.9867 12.1267C14.1205 11.8765 14.3182 11.6748 14.6226 11.4415M20 7C20 8.65685 18.6569 10 17 10C15.3431 10 14 8.65685 14 7C14 5.34315 15.3431 4 17 4C18.6569 4 20 5.34315 20 7Z" stroke="#464455" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
|
After Width: | Height: | Size: 806 B |
BIN
static/img/logo.jpg
Normal file
|
After Width: | Height: | Size: 358 KiB |
4
static/img/search.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M15.7955 15.8111L21 21M18 10.5C18 14.6421 14.6421 18 10.5 18C6.35786 18 3 14.6421 3 10.5C3 6.35786 6.35786 3 10.5 3C14.6421 3 18 6.35786 18 10.5Z" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 469 B |
17
static/labels/_blank.svg
Normal file
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 200 200" width="200" height="200" role="img" aria-labelledby="title">
|
||||
<title id="title">Offer ribbon (top-right)</title>
|
||||
|
||||
<!-- Ribbon group: move origin to top-right, then rotate 45° -->
|
||||
<g transform="translate(200 0) rotate(45)">
|
||||
<!-- The stripe -->
|
||||
<rect x="-160" y="50" width="320" height="25" rx="4" fill="#22c55e"/>
|
||||
<!-- Label on the stripe (centered) -->
|
||||
<text x="0" y="65"
|
||||
font-family="system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif"
|
||||
font-size="18" font-weight="800" fill="#ffffff"
|
||||
text-anchor="middle" dominant-baseline="middle" letter-spacing=".15em">
|
||||
NEW
|
||||
</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 734 B |
17
static/labels/new.svg
Normal file
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 200 200" width="200" height="200" role="img" aria-labelledby="title">
|
||||
<title id="title">Offer ribbon (top-right)</title>
|
||||
|
||||
<!-- Ribbon group: move origin to top-right, then rotate 45° -->
|
||||
<g transform=" rotate(-45 0 0)">
|
||||
<!-- The stripe -->
|
||||
<rect x="-160" y="25" width="320" height="25" rx="4" fill="#22c55e"/>
|
||||
<!-- Label on the stripe (centered) -->
|
||||
<text x="0" y="40"
|
||||
font-family="system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif"
|
||||
font-size="18" font-weight="800" fill="#ffffff"
|
||||
text-anchor="middle" dominant-baseline="middle" letter-spacing=".15em">
|
||||
NEW
|
||||
</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 723 B |
19
static/labels/offer.svg
Normal file
@@ -0,0 +1,19 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 200 200" width="200" height="200" role="img" aria-labelledby="title">
|
||||
<title id="title">Offer ribbon</title>
|
||||
|
||||
<!-- Transparent background (nothing drawn) -->
|
||||
|
||||
<!-- Ribbon group rotated -45° around the top-left corner (0,0) -->
|
||||
<g transform="rotate(-45 0 0)">
|
||||
<!-- The stripe itself -->
|
||||
<rect x="-80" y="50" width="260" height="25" rx="4" fill="#ef4444"/>
|
||||
<!-- Centered label on the stripe -->
|
||||
<text x="0" y="65"
|
||||
font-family="system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif"
|
||||
font-size="18" font-weight="800" fill="#ffffff"
|
||||
text-anchor="middle" dominant-baseline="middle" letter-spacing=".15em">
|
||||
OFFER
|
||||
</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 770 B |
14
static/nav-labels/new.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 100 100" width="100" height="100" role="img" aria-labelledby="title">
|
||||
<title id="title">New</title>
|
||||
|
||||
<!-- The stripe -->
|
||||
<rect x="0" y="30" width="100" height="40" rx="4" fill="#22c55e"/>
|
||||
<!-- Label on the stripe (centered) -->
|
||||
<text x="52" y="52"
|
||||
font-family="system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif"
|
||||
font-size="25" font-weight="800" fill="#ffffff"
|
||||
text-anchor="middle" dominant-baseline="middle" letter-spacing=".15em">
|
||||
NEW
|
||||
</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 590 B |
16
static/nav-labels/offer.svg
Normal file
@@ -0,0 +1,16 @@
|
||||
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 100 100" width="100" height="100" role="img" aria-labelledby="title">
|
||||
<title id="title">Offer</title>
|
||||
|
||||
<!-- The stripe -->
|
||||
<rect x="0" y="30" width="100" height="40" rx="4" fill="#22c55e"/>
|
||||
<!-- Label on the stripe (centered) -->
|
||||
<text x="52" y="52"
|
||||
font-family="system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif"
|
||||
font-size="25" font-weight="800" fill="#ffffff"
|
||||
text-anchor="middle" dominant-baseline="middle" letter-spacing=".15em">
|
||||
OFFER
|
||||
</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 596 B |
10
static/order/a-z.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Sort A to Z">
|
||||
<!-- Orange rounded square -->
|
||||
<rect x="1" y="1" width="22" height="22" rx="5" fill="#F97316"/>
|
||||
<!-- A–Z label -->
|
||||
<text x="12" y="12" text-anchor="middle" dominant-baseline="middle"
|
||||
font-family="system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Arial, sans-serif"
|
||||
font-size="9" font-weight="700" fill="#FFFFFF">A–Z</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 536 B |
10
static/order/h-l.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Sort £ hi to Lo">
|
||||
<!-- Orange rounded square -->
|
||||
<rect x="1" y="1" width="22" height="22" rx="5" fill="#F97316"/>
|
||||
<!-- A–Z label -->
|
||||
<text x="12" y="12" text-anchor="middle" dominant-baseline="middle"
|
||||
font-family="system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Arial, sans-serif"
|
||||
font-size="9" font-weight="700" fill="#FFFFFF">£ ↓</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 542 B |
10
static/order/l-h.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Sort £ lo to hi">
|
||||
<!-- Orange rounded square -->
|
||||
<rect x="1" y="1" width="22" height="22" rx="5" fill="#F97316"/>
|
||||
<!-- A–Z label -->
|
||||
<text x="12" y="12" text-anchor="middle" dominant-baseline="middle"
|
||||
font-family="system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Arial, sans-serif"
|
||||
font-size="9" font-weight="700" fill="#FFFFFF">£ ↑</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 542 B |
10
static/order/z-a.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Sort Z to A">
|
||||
<!-- Orange rounded square -->
|
||||
<rect x="1" y="1" width="22" height="22" rx="5" fill="#F97316"/>
|
||||
<!-- A–Z label -->
|
||||
<text x="12" y="12" text-anchor="middle" dominant-baseline="middle"
|
||||
font-family="system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Arial, sans-serif"
|
||||
font-size="9" font-weight="700" fill="#FFFFFF">Z-A</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 534 B |
842
static/scripts/body.js
Normal file
@@ -0,0 +1,842 @@
|
||||
// ============================================================================
|
||||
// 1. Mobile navigation toggle
|
||||
// - Handles opening/closing the mobile nav panel
|
||||
// - Updates ARIA attributes for accessibility
|
||||
// - Closes panel when a link inside it is clicked
|
||||
// ============================================================================
|
||||
|
||||
(function () {
|
||||
const btn = document.getElementById('nav-toggle');
|
||||
const panel = document.getElementById('mobile-nav');
|
||||
if (!btn || !panel) return; // No mobile nav in this layout, abort
|
||||
|
||||
btn.addEventListener('click', () => {
|
||||
// Toggle the "hidden" class on the panel.
|
||||
// classList.toggle returns true if the class is present AFTER the call.
|
||||
const isHidden = panel.classList.toggle('hidden');
|
||||
const expanded = !isHidden; // aria-expanded = true when the panel is visible
|
||||
|
||||
btn.setAttribute('aria-expanded', String(expanded));
|
||||
btn.setAttribute('aria-label', expanded ? 'Close menu' : 'Open menu');
|
||||
});
|
||||
|
||||
// Close panel when clicking any link inside the mobile nav
|
||||
panel.addEventListener('click', (e) => {
|
||||
const a = e.target.closest('a');
|
||||
if (!a) return;
|
||||
|
||||
panel.classList.add('hidden');
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
btn.setAttribute('aria-label', 'Open menu');
|
||||
});
|
||||
})();
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// 2. Image gallery
|
||||
// - Supports multiple galleries via [data-gallery-root]
|
||||
// - Thumbnail navigation, prev/next arrows, keyboard arrows, touch swipe
|
||||
// - HTMX-aware: runs on initial load and after HTMX swaps
|
||||
// ============================================================================
|
||||
|
||||
(() => {
|
||||
/**
|
||||
* Initialize any galleries found within a given DOM subtree.
|
||||
* @param {ParentNode} root - Root element to search in (defaults to document).
|
||||
*/
|
||||
function initGallery(root) {
|
||||
if (!root) return;
|
||||
|
||||
// Find all nested gallery roots
|
||||
const galleries = root.querySelectorAll('[data-gallery-root]');
|
||||
|
||||
// If root itself is a gallery and no nested galleries exist,
|
||||
// initialize just the root.
|
||||
if (!galleries.length && root.matches?.('[data-gallery-root]')) {
|
||||
initOneGallery(root);
|
||||
return;
|
||||
}
|
||||
|
||||
galleries.forEach(initOneGallery);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize a single gallery instance.
|
||||
* This attaches handlers only once, even if HTMX re-inserts the fragment.
|
||||
* @param {Element} root - Element with [data-gallery-root].
|
||||
*/
|
||||
function initOneGallery(root) {
|
||||
// Prevent double-initialization (HTMX may re-insert the same fragment)
|
||||
if (root.dataset.galleryInitialized === 'true') return;
|
||||
root.dataset.galleryInitialized = 'true';
|
||||
|
||||
let index = 0;
|
||||
|
||||
// Collect all image URLs from [data-image-src] attributes
|
||||
const imgs = Array.from(root.querySelectorAll('[data-image-src]'))
|
||||
.map(el => el.getAttribute('data-image-src') || el.dataset.imageSrc)
|
||||
.filter(Boolean);
|
||||
|
||||
const main = root.querySelector('[data-main-img]');
|
||||
const prevBtn = root.querySelector('[data-prev]');
|
||||
const nextBtn = root.querySelector('[data-next]');
|
||||
const thumbs = Array.from(root.querySelectorAll('[data-thumb]'));
|
||||
const titleEl = root.querySelector('[data-title]');
|
||||
const total = imgs.length;
|
||||
|
||||
// Without a main image or any sources, the gallery is not usable
|
||||
if (!main || !total) return;
|
||||
|
||||
/**
|
||||
* Render the gallery to reflect the current `index`:
|
||||
* - Update main image src/alt
|
||||
* - Update active thumbnail highlight
|
||||
* - Keep prev/next button ARIA labels consistent
|
||||
*/
|
||||
function render() {
|
||||
main.setAttribute('src', imgs[index]);
|
||||
|
||||
// Highlight active thumbnail
|
||||
thumbs.forEach((t, i) => {
|
||||
if (i === index) t.classList.add('ring-2', 'ring-stone-900');
|
||||
else t.classList.remove('ring-2', 'ring-stone-900');
|
||||
});
|
||||
|
||||
// Basic ARIA labels for navigation buttons
|
||||
if (prevBtn && nextBtn) {
|
||||
prevBtn.setAttribute('aria-label', 'Previous image');
|
||||
nextBtn.setAttribute('aria-label', 'Next image');
|
||||
}
|
||||
|
||||
// Alt text uses base title + position (e.g. "Product image (1/4)")
|
||||
const baseTitle = (titleEl?.textContent || 'Product image').trim();
|
||||
main.setAttribute('alt', `${baseTitle} (${index + 1}/${total})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move to a specific index, wrapping around at bounds.
|
||||
* @param {number} n - Desired index (can be out-of-bounds; we mod it).
|
||||
*/
|
||||
function go(n) {
|
||||
index = (n + imgs.length) % imgs.length;
|
||||
render();
|
||||
}
|
||||
|
||||
// --- Button handlers ----------------------------------------------------
|
||||
|
||||
prevBtn?.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
go(index - 1);
|
||||
});
|
||||
|
||||
nextBtn?.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
go(index + 1);
|
||||
});
|
||||
|
||||
// --- Thumbnail handlers -------------------------------------------------
|
||||
|
||||
thumbs.forEach((t, i) => {
|
||||
t.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
go(i);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Keyboard navigation (left/right arrows) ---------------------------
|
||||
// Note: we only act if `root` is still attached to the DOM.
|
||||
const keyHandler = (e) => {
|
||||
if (!root.isConnected) return;
|
||||
if (e.key === 'ArrowLeft') go(index - 1);
|
||||
if (e.key === 'ArrowRight') go(index + 1);
|
||||
};
|
||||
document.addEventListener('keydown', keyHandler);
|
||||
|
||||
// --- Touch swipe on main image (horizontal only) -----------------------
|
||||
|
||||
let touchStartX = null;
|
||||
let touchStartY = null;
|
||||
const SWIPE_MIN = 30; // px
|
||||
|
||||
main.addEventListener('touchstart', (e) => {
|
||||
const t = e.changedTouches[0];
|
||||
touchStartX = t.clientX;
|
||||
touchStartY = t.clientY;
|
||||
}, { passive: true });
|
||||
|
||||
main.addEventListener('touchend', (e) => {
|
||||
if (touchStartX === null) return;
|
||||
|
||||
const t = e.changedTouches[0];
|
||||
const dx = t.clientX - touchStartX;
|
||||
const dy = t.clientY - touchStartY;
|
||||
|
||||
// Horizontal swipe: dx large, dy relatively small
|
||||
if (Math.abs(dx) > SWIPE_MIN && Math.abs(dy) < 0.6 * Math.abs(dx)) {
|
||||
if (dx < 0) go(index + 1);
|
||||
else go(index - 1);
|
||||
}
|
||||
|
||||
touchStartX = touchStartY = null;
|
||||
}, { passive: true });
|
||||
|
||||
// Initial UI state
|
||||
render();
|
||||
}
|
||||
|
||||
// Initialize all galleries on initial page load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initGallery(document);
|
||||
});
|
||||
|
||||
// Re-initialize galleries inside new fragments from HTMX
|
||||
if (window.htmx) {
|
||||
// htmx.onLoad runs on initial load and after each swap
|
||||
htmx.onLoad((content) => {
|
||||
initGallery(content);
|
||||
});
|
||||
|
||||
// Alternative:
|
||||
// htmx.on('htmx:afterSwap', (evt) => {
|
||||
// initGallery(evt.detail.target);
|
||||
// });
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// 3. "Peek" scroll viewport
|
||||
// - Adds a clipped/peek effect to scrollable containers
|
||||
// - Uses negative margins and optional CSS mask fade
|
||||
// - Automatically updates on resize and DOM mutations
|
||||
// ============================================================================
|
||||
|
||||
(() => {
|
||||
/**
|
||||
* Safely parse a numeric value or fall back to a default.
|
||||
*/
|
||||
function px(val, def) {
|
||||
const n = Number(val);
|
||||
return Number.isFinite(n) ? n : def;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the peek effect to a viewport and its inner content.
|
||||
* @param {HTMLElement} vp - The viewport (with data-peek-viewport).
|
||||
* @param {HTMLElement} inner - Inner content wrapper.
|
||||
*/
|
||||
function applyPeek(vp, inner) {
|
||||
const edge = (vp.dataset.peekEdge || 'bottom').toLowerCase();
|
||||
const useMask = vp.dataset.peekMask === 'true';
|
||||
|
||||
// Compute peek size in pixels:
|
||||
// - data-peek-size-px: direct px value
|
||||
// - data-peek-size: "units" that are scaled by root font size * 0.25
|
||||
// - default: 24px
|
||||
const sizePx =
|
||||
px(vp.dataset.peekSizePx, NaN) ||
|
||||
px(vp.dataset.peekSize, NaN) *
|
||||
(parseFloat(getComputedStyle(document.documentElement).fontSize) || 16) *
|
||||
0.25 ||
|
||||
24;
|
||||
|
||||
const overflowing = vp.scrollHeight > vp.clientHeight;
|
||||
|
||||
// Reset any previous modifications
|
||||
inner.style.marginTop = '';
|
||||
inner.style.marginBottom = '';
|
||||
vp.style.webkitMaskImage = vp.style.maskImage = '';
|
||||
|
||||
// Reset last child's margin in case we changed it previously
|
||||
const last = inner.lastElementChild;
|
||||
if (last) last.style.marginBottom = '';
|
||||
|
||||
if (!overflowing) return;
|
||||
|
||||
// NOTE: For clipping to look right, we want the viewport's own bottom padding
|
||||
// to be minimal. Consider also using pb-0 in CSS if needed.
|
||||
|
||||
// Apply negative margins to "cut" off content at top/bottom, creating peek
|
||||
if (edge === 'bottom' || edge === 'both') inner.style.marginBottom = `-${sizePx}px`;
|
||||
if (edge === 'top' || edge === 'both') inner.style.marginTop = `-${sizePx}px`;
|
||||
|
||||
// Prevent the very last child from cancelling the visual clip
|
||||
if (edge === 'bottom' || edge === 'both') {
|
||||
if (last) last.style.marginBottom = '0px';
|
||||
}
|
||||
|
||||
// Optional fade in/out mask on top/bottom
|
||||
if (useMask) {
|
||||
const topStop = (edge === 'top' || edge === 'both') ? `${sizePx}px` : '0px';
|
||||
const bottomStop = (edge === 'bottom' || edge === 'both') ? `${sizePx}px` : '0px';
|
||||
const mask = `linear-gradient(
|
||||
180deg,
|
||||
transparent 0,
|
||||
black ${topStop},
|
||||
black calc(100% - ${bottomStop}),
|
||||
transparent 100%
|
||||
)`;
|
||||
vp.style.webkitMaskImage = vp.style.maskImage = mask;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up one viewport with peek behavior.
|
||||
* @param {HTMLElement} vp - Element with [data-peek-viewport].
|
||||
*/
|
||||
function setupViewport(vp) {
|
||||
const inner = vp.querySelector('[data-peek-inner]') || vp.firstElementChild;
|
||||
if (!inner) return;
|
||||
|
||||
const update = () => applyPeek(vp, inner);
|
||||
|
||||
// Observe size changes (viewport & inner)
|
||||
const ro = 'ResizeObserver' in window ? new ResizeObserver(update) : null;
|
||||
ro?.observe(vp);
|
||||
ro?.observe(inner);
|
||||
|
||||
// Observe DOM changes inside the inner container
|
||||
const mo = new MutationObserver(update);
|
||||
mo.observe(inner, { childList: true, subtree: true });
|
||||
|
||||
// Run once on window load and once immediately
|
||||
window.addEventListener('load', update, { once: true });
|
||||
update();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize peek behavior for all [data-peek-viewport] elements
|
||||
* inside the given root.
|
||||
*/
|
||||
function initPeek(root = document) {
|
||||
root.querySelectorAll('[data-peek-viewport]').forEach(setupViewport);
|
||||
}
|
||||
|
||||
// Run on initial DOM readiness
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => initPeek());
|
||||
} else {
|
||||
initPeek();
|
||||
}
|
||||
|
||||
// Expose for dynamic inserts (e.g., from HTMX or other JS)
|
||||
window.initPeekScroll = initPeek;
|
||||
})();
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// 4. Exclusive <details> behavior
|
||||
// - Only one <details> with the same [data-toggle-group] is open at a time
|
||||
// - Respects HTMX swaps by re-attaching afterSwap
|
||||
// - Scrolls to top when opening a panel
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Attach behavior so that only one <details> in each data-toggle-group is open.
|
||||
* @param {ParentNode} root - Limit binding to within this node (defaults to document).
|
||||
*/
|
||||
function attachExclusiveDetailsBehavior(root = document) {
|
||||
const detailsList = root.querySelectorAll('details[data-toggle-group]');
|
||||
|
||||
detailsList.forEach((el) => {
|
||||
// Prevent double-binding on the same element
|
||||
if (el.__exclusiveBound) return;
|
||||
el.__exclusiveBound = true;
|
||||
|
||||
el.addEventListener('toggle', function () {
|
||||
// Only act when this <details> was just opened
|
||||
if (!el.open) return;
|
||||
|
||||
const group = el.getAttribute('data-toggle-group');
|
||||
if (!group) return;
|
||||
|
||||
// Close all other <details> with the same data-toggle-group
|
||||
document
|
||||
.querySelectorAll('details[data-toggle-group="' + group + '"]')
|
||||
.forEach((other) => {
|
||||
if (other === el) return;
|
||||
if (other.open) {
|
||||
other.open = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Scroll to top when a panel is opened
|
||||
window.scrollTo(0, 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Initial binding on page load
|
||||
attachExclusiveDetailsBehavior();
|
||||
|
||||
// Re-bind for new content after HTMX swaps
|
||||
document.body.addEventListener('htmx:afterSwap', function (evt) {
|
||||
attachExclusiveDetailsBehavior(evt.target);
|
||||
});
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// 5. Close <details> panels before HTMX requests
|
||||
// - When a link/button inside a <details[data-toggle-group]> triggers HTMX,
|
||||
// we close that panel and scroll to top.
|
||||
// ============================================================================
|
||||
|
||||
document.body.addEventListener('htmx:beforeRequest', function (evt) {
|
||||
const triggerEl = evt.target;
|
||||
|
||||
// Find the closest <details> panel (e.g., mobile panel, filters, etc.)
|
||||
const panel = triggerEl.closest('details[data-toggle-group]');
|
||||
if (!panel) return;
|
||||
|
||||
panel.open = false;
|
||||
window.scrollTo(0, 0);
|
||||
});
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// 6. Ghost / Koenig video card fix
|
||||
// - Ghost/Koenig editors may output <figure class="kg-video-card"><video>...</video></figure>
|
||||
// - This replaces the <figure> with just the <video>, and enforces some defaults.
|
||||
// - Works on initial load, HTMX swaps, and general DOM inserts.
|
||||
// ============================================================================
|
||||
|
||||
(function () {
|
||||
/**
|
||||
* Replace Ghost/Koenig video figures with their contained <video> element.
|
||||
* @param {ParentNode} root
|
||||
*/
|
||||
function replaceKgFigures(root = document) {
|
||||
const figures = root.querySelectorAll('figure.kg-video-card');
|
||||
figures.forEach(fig => {
|
||||
const video = fig.querySelector('video');
|
||||
if (!video) return;
|
||||
|
||||
// Ensure native UI & sensible defaults
|
||||
video.controls = true;
|
||||
video.preload = video.preload || 'metadata';
|
||||
video.playsInline = true; // iOS inline playback
|
||||
|
||||
// Optional utility classes (Tailwind-esque)
|
||||
video.classList.add('max-w-full', 'h-auto', 'rounded-lg', 'shadow');
|
||||
|
||||
// Replace the entire figure with the <video>
|
||||
const parent = fig.parentNode;
|
||||
if (!parent) return;
|
||||
parent.replaceChild(video, fig);
|
||||
});
|
||||
}
|
||||
|
||||
// Initial run on DOM ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => replaceKgFigures());
|
||||
} else {
|
||||
replaceKgFigures();
|
||||
}
|
||||
|
||||
// After HTMX swaps, fix any new figures
|
||||
document.addEventListener('htmx:afterSwap', e =>
|
||||
replaceKgFigures(e.target || document)
|
||||
);
|
||||
|
||||
// Fallback: MutationObserver for other dynamic content inserts
|
||||
const mo = new MutationObserver(muts => {
|
||||
for (const m of muts) {
|
||||
for (const node of m.addedNodes) {
|
||||
if (node.nodeType !== 1) continue;
|
||||
|
||||
if (node.matches?.('figure.kg-video-card')) {
|
||||
replaceKgFigures(node.parentNode || document);
|
||||
} else if (node.querySelector?.('figure.kg-video-card')) {
|
||||
replaceKgFigures(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
mo.observe(document.documentElement, { childList: true, subtree: true });
|
||||
})();
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// 7. Ghost / Koenig audio card fix
|
||||
// - Ghost may output <div class="kg-card kg-audio-card"><audio>...</audio></div>
|
||||
// - Replace wrapper with the raw <audio> tag and keep a class for styling.
|
||||
// ============================================================================
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document
|
||||
.querySelectorAll('div.kg-card.kg-audio-card')
|
||||
.forEach(card => {
|
||||
const audio = card.querySelector('audio');
|
||||
if (!audio) return;
|
||||
|
||||
// Ensure native audio controls are visible
|
||||
audio.setAttribute('controls', '');
|
||||
|
||||
// Preserve a class for custom styling
|
||||
audio.classList.add('ghost-audio');
|
||||
|
||||
// Replace the entire card element with the <audio>
|
||||
card.replaceWith(audio);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// 8. Responsive YouTube iframes
|
||||
// - Targets iframes from youtube.com / youtube-nocookie.com
|
||||
// - Removes width/height attributes so CSS can take over
|
||||
// - Applies aspect-ratio if supported, else JS resize fallback
|
||||
// - Works on initial load + after HTMX swaps
|
||||
// ============================================================================
|
||||
|
||||
(function () {
|
||||
/**
|
||||
* Normalize and make YouTube iframes responsive.
|
||||
* @param {ParentNode} root
|
||||
*/
|
||||
function fixYouTubeIframes(root = document) {
|
||||
const iframes = root.querySelectorAll(
|
||||
'iframe[src*="youtube.com"], iframe[src*="youtube-nocookie.com"]'
|
||||
);
|
||||
|
||||
iframes.forEach(ifr => {
|
||||
const w = parseFloat(ifr.getAttribute('width')) || 560;
|
||||
const h = parseFloat(ifr.getAttribute('height')) || 315;
|
||||
|
||||
// Remove intrinsic sizes so CSS can control them
|
||||
ifr.removeAttribute('width');
|
||||
ifr.removeAttribute('height');
|
||||
|
||||
// Make full-width by default
|
||||
ifr.style.display = 'block';
|
||||
ifr.style.width = '100%';
|
||||
ifr.style.border = '0';
|
||||
|
||||
// Prefer modern CSS aspect-ratio if supported
|
||||
if (CSS.supports('aspect-ratio', '1 / 1')) {
|
||||
ifr.style.aspectRatio = `${w} / ${h}`;
|
||||
ifr.style.height = 'auto';
|
||||
} else {
|
||||
// Legacy fallback: compute height on window resize
|
||||
const setH = () => {
|
||||
ifr.style.height = (ifr.clientWidth * h / w) + 'px';
|
||||
};
|
||||
setH();
|
||||
window.addEventListener('resize', setH, { passive: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Run on initial load
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => fixYouTubeIframes());
|
||||
} else {
|
||||
fixYouTubeIframes();
|
||||
}
|
||||
|
||||
// Run after HTMX swaps
|
||||
document.addEventListener('htmx:afterSwap', e =>
|
||||
fixYouTubeIframes(e.target || document)
|
||||
);
|
||||
})();
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// 9. HTMX global error handler (SweetAlert2)
|
||||
// - Listens for htmx:responseError events
|
||||
// - Extracts error info from JSON or HTML responses
|
||||
// - Shows a SweetAlert error modal with details
|
||||
// ============================================================================
|
||||
|
||||
document.body.addEventListener("htmx:responseError", function (event) {
|
||||
const xhr = event.detail.xhr;
|
||||
const status = xhr.status;
|
||||
const contentType = xhr.getResponseHeader("Content-Type") || "";
|
||||
const triggerEl = event.detail.elt; // element that fired the request
|
||||
const form = triggerEl ? triggerEl.closest("form") : null;
|
||||
|
||||
let title = "Something went wrong";
|
||||
if (status >= 500) {
|
||||
title = "Server error";
|
||||
} else if (status >= 400) {
|
||||
title = "There was a problem with your request";
|
||||
}
|
||||
|
||||
let message = "";
|
||||
let fieldErrors = null;
|
||||
let html = "";
|
||||
|
||||
if (contentType.includes("application/json")) {
|
||||
try {
|
||||
const data = JSON.parse(xhr.responseText || "{}");
|
||||
message = data.message || "";
|
||||
|
||||
// We expect errors as an object: { field: [msg, ...], ... }
|
||||
if (data.errors && typeof data.errors === "object" && !Array.isArray(data.errors)) {
|
||||
fieldErrors = data.errors;
|
||||
|
||||
// Build a bullet list for SweetAlert
|
||||
const allMessages = [];
|
||||
for (const [field, msgs] of Object.entries(data.errors)) {
|
||||
const arr = Array.isArray(msgs) ? msgs : [msgs];
|
||||
allMessages.push(...arr);
|
||||
}
|
||||
|
||||
if (allMessages.length) {
|
||||
html =
|
||||
"<ul style='text-align:left;margin:0;padding-left:1.25rem;'>" +
|
||||
allMessages.map((e) => `<li>${e}</li>`).join("") +
|
||||
"</ul>";
|
||||
}
|
||||
} else if (Array.isArray(data.errors)) {
|
||||
// Legacy shape: errors: ["msg1", "msg2"]
|
||||
html =
|
||||
"<ul style='text-align:left;margin:0;padding-left:1.25rem;'>" +
|
||||
data.errors.map((e) => `<li>${e}</li>`).join("") +
|
||||
"</ul>";
|
||||
} else if (data.error) {
|
||||
html = data.error;
|
||||
}
|
||||
} catch (e) {
|
||||
html = xhr.responseText;
|
||||
}
|
||||
} else {
|
||||
// HTML or plain text
|
||||
html = xhr.responseText;
|
||||
}
|
||||
|
||||
// Apply field-level highlighting + scroll
|
||||
if (form && fieldErrors) {
|
||||
// Clear previous error state
|
||||
form.querySelectorAll(".field-error").forEach((el) => {
|
||||
el.classList.remove(
|
||||
"field-error",
|
||||
"border-red-500",
|
||||
"ring-1",
|
||||
"ring-red-500"
|
||||
);
|
||||
el.removeAttribute("aria-invalid");
|
||||
});
|
||||
|
||||
let firstErrorInput = null;
|
||||
|
||||
for (const [field, msgs] of Object.entries(fieldErrors)) {
|
||||
if (field === "__all__" || field === "_global") continue;
|
||||
|
||||
// Special case: days group
|
||||
if (field === "days") {
|
||||
const group = form.querySelector("[data-days-group]");
|
||||
if (group) {
|
||||
group.classList.add(
|
||||
"field-error",
|
||||
"border",
|
||||
"border-red-500",
|
||||
"rounded",
|
||||
"ring-1",
|
||||
"ring-red-500"
|
||||
);
|
||||
// Focus the first checkbox in the group
|
||||
if (!firstErrorInput) {
|
||||
const cb = group.querySelector('input[type="checkbox"]');
|
||||
if (cb) {
|
||||
firstErrorInput = cb;
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Normal fields: find by name
|
||||
const input = form.querySelector(`[name="${field}"]`);
|
||||
if (!input) continue;
|
||||
|
||||
input.classList.add(
|
||||
"field-error",
|
||||
"border-red-500",
|
||||
"ring-1",
|
||||
"ring-red-500"
|
||||
);
|
||||
input.setAttribute("aria-invalid", "true");
|
||||
|
||||
if (!firstErrorInput) {
|
||||
firstErrorInput = input;
|
||||
}
|
||||
}
|
||||
|
||||
if (firstErrorInput) {
|
||||
firstErrorInput.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
firstErrorInput.focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Swal.fire({
|
||||
icon: "error",
|
||||
title: message || title,
|
||||
html: html || "Please correct the highlighted fields and try again.",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
document.addEventListener('toggle', function (event) {
|
||||
const details = event.target;
|
||||
console.log('toggle fired on', details.tagName, 'open=', details.open);
|
||||
|
||||
// Only act on <details> elements that were just opened
|
||||
if (details.tagName !== 'DETAILS' || !details.open) return;
|
||||
|
||||
console.log('details opened – closing children');
|
||||
|
||||
// Close any child <details> that are open
|
||||
details.querySelectorAll('details[open]').forEach(function (child) {
|
||||
if (child !== details) {
|
||||
child.open = false;
|
||||
}
|
||||
});
|
||||
}, true); // <-- capture phase
|
||||
|
||||
|
||||
document.body.addEventListener('click', function (e) {
|
||||
const btn = e.target.closest('[data-confirm]');
|
||||
if (!btn) return;
|
||||
|
||||
const form = btn.closest('form');
|
||||
|
||||
// If this is a submit button inside a form, run HTML5 validation *first*.
|
||||
// If invalid, let the browser show messages and DO NOT open SweetAlert.
|
||||
if (btn.type === 'submit' && form) {
|
||||
if (!form.checkValidity()) {
|
||||
// This shows the native validation bubbles and focuses the first invalid field
|
||||
form.reportValidity();
|
||||
return; // stop here – no preventDefault, no Swal
|
||||
}
|
||||
}
|
||||
|
||||
// At this point the form is valid (or this isn't a submit button).
|
||||
// Now we show the confirmation dialog.
|
||||
e.preventDefault();
|
||||
|
||||
const title =
|
||||
btn.getAttribute('data-confirm-title') || 'Are you sure?';
|
||||
const text =
|
||||
btn.getAttribute('data-confirm-text') || '';
|
||||
const icon =
|
||||
btn.getAttribute('data-confirm-icon') || 'warning';
|
||||
const confirmButtonText =
|
||||
btn.getAttribute('data-confirm-confirm-text') || 'Yes';
|
||||
const cancelButtonText =
|
||||
btn.getAttribute('data-confirm-cancel-text') || 'Cancel';
|
||||
|
||||
Swal.fire({
|
||||
title,
|
||||
text,
|
||||
icon,
|
||||
showCancelButton: true,
|
||||
confirmButtonText,
|
||||
cancelButtonText,
|
||||
}).then((result) => {
|
||||
if (!result.isConfirmed) return;
|
||||
|
||||
const eventName = btn.getAttribute('data-confirm-event');
|
||||
|
||||
if (eventName) {
|
||||
// HTMX-style: fire a custom event (e.g. "confirmed") for hx-trigger
|
||||
btn.dispatchEvent(new CustomEvent(eventName, { bubbles: true }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (btn.type === 'submit') {
|
||||
const form = btn.closest('form');
|
||||
if (form) {
|
||||
if (typeof form.requestSubmit === 'function') {
|
||||
form.requestSubmit(btn); // proper HTMX-visible submit
|
||||
} else {
|
||||
const ev = new Event('submit', { bubbles: true, cancelable: true });
|
||||
form.dispatchEvent(ev);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// If it's not a submit button, allow original action
|
||||
btn.dispatchEvent(new Event('confirmed', { bubbles: true }));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
document.addEventListener('change', function (event) {
|
||||
const target = event.target;
|
||||
|
||||
// Only care about checkboxes
|
||||
if (target.type !== 'checkbox') return;
|
||||
|
||||
// Find nearest days group
|
||||
const group = target.closest('[data-days-group]');
|
||||
if (!group) return;
|
||||
|
||||
const allToggle = group.querySelector('[data-day-all]');
|
||||
const dayCheckboxes = group.querySelectorAll('input[type="checkbox"][data-day]');
|
||||
|
||||
// If the "All" checkbox was toggled
|
||||
if (target === allToggle) {
|
||||
const checked = allToggle.checked;
|
||||
dayCheckboxes.forEach(cb => {
|
||||
cb.checked = checked;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise an individual day changed:
|
||||
// if *all* days are checked, tick "All", else untick it
|
||||
const allChecked = Array.from(dayCheckboxes).every(cb => cb.checked);
|
||||
if (allToggle) {
|
||||
allToggle.checked = allChecked;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
document.body.addEventListener('htmx:beforeSwap', function (event) {
|
||||
const xhr = event.detail.xhr;
|
||||
if (!xhr) return;
|
||||
|
||||
// Server can send: HX-Preserve-Search: keep | replace
|
||||
const mode = xhr.getResponseHeader('HX-Preserve-Search');
|
||||
|
||||
// Only remove if no preserve header AND incoming response contains the element
|
||||
if (!mode) {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(event.detail.serverResponse, 'text/html');
|
||||
|
||||
const el = document.getElementById('search-desktop');
|
||||
if (el && doc.getElementById('search-desktop')) {
|
||||
el.parentElement.removeChild(el);
|
||||
}
|
||||
|
||||
const el2 = document.getElementById('search-mobile');
|
||||
if (el2 && doc.getElementById('search-mobile')) {
|
||||
el2.parentElement.removeChild(el2);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
document.body.addEventListener('htmx:beforeSwap', function(evt) {
|
||||
console.log('HTMX beforeSwap:', {
|
||||
hasSearch: !!document.getElementById('search-desktop'),
|
||||
target: evt.detail.target,
|
||||
requestURL: evt.detail.xhr?.responseURL,
|
||||
swapStyle: evt.detail.swapStyle,
|
||||
serverResponse: evt.detail.serverResponse.substring(0, 200) + '...'
|
||||
});
|
||||
});
|
||||
|
||||
document.body.addEventListener('htmx:afterSwap', function(evt) {
|
||||
console.log('HTMX afterSwap:', {
|
||||
target: evt.detail.target,
|
||||
hasSearch: !!document.getElementById('search-desktop')
|
||||
});
|
||||
});
|
||||
13
static/stickers/biodynamic.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: Adobe Illustrator 25.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 24 24" style="enable-background:new 0 0 24 24;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#009A82;}
|
||||
.st1{enable-background:new ;}
|
||||
.st2{fill:#F9F4E0;}
|
||||
</style>
|
||||
<circle class="st0" cx="12" cy="12" r="12"/>
|
||||
<g class="st1">
|
||||
<path class="st2" d="M15.3,12.6c0.3,0.4,0.4,0.8,0.4,1.3c0,0.7-0.3,1.3-0.8,1.7c-0.6,0.4-1.4,0.6-2.4,0.6H8.3V7.8h3.9 c1,0,1.8,0.2,2.3,0.6c0.5,0.4,0.8,0.9,0.8,1.6c0,0.4-0.1,0.8-0.3,1.1c-0.2,0.3-0.5,0.6-0.8,0.7C14.7,12,15.1,12.2,15.3,12.6z M9.8,9v2.3H12c0.5,0,1-0.1,1.3-0.3c0.3-0.2,0.4-0.5,0.4-0.9c0-0.4-0.1-0.7-0.4-0.9S12.6,9,12,9H9.8z M14.2,13.8 c0-0.8-0.6-1.2-1.8-1.2H9.8V15h2.5C13.6,15,14.2,14.6,14.2,13.8z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 942 B |
13
static/stickers/fairtrade.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: Adobe Illustrator 25.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 24 24" style="enable-background:new 0 0 24 24;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#009A82;}
|
||||
.st1{enable-background:new ;}
|
||||
.st2{fill:#F9F4E0;}
|
||||
</style>
|
||||
<circle class="st0" cx="12" cy="12" r="12"/>
|
||||
<g class="st1">
|
||||
<path class="st2" d="M10.5,9.1v2.6h4.1V13h-4.1v3.2H8.9V7.8h6.1v1.3H10.5z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 601 B |
14
static/stickers/glutenfree.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: Adobe Illustrator 25.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 24 24" style="enable-background:new 0 0 24 24;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#009A82;}
|
||||
.st1{enable-background:new ;}
|
||||
.st2{fill:#F9F4E0;}
|
||||
</style>
|
||||
<circle class="st0" cx="12" cy="12" r="12"/>
|
||||
<g class="st1">
|
||||
<path class="st2" d="M10.3,11.9h1.5v3.3c-0.4,0.3-0.9,0.6-1.5,0.8c-0.6,0.2-1.2,0.3-1.8,0.3c-0.9,0-1.6-0.2-2.3-0.6 c-0.7-0.4-1.2-0.9-1.6-1.5C4.2,13.6,4,12.8,4,12c0-0.8,0.2-1.6,0.6-2.2C5,9.1,5.5,8.6,6.2,8.2c0.7-0.4,1.5-0.6,2.3-0.6 c0.7,0,1.3,0.1,1.9,0.3c0.6,0.2,1.1,0.6,1.5,1l-1,1c-0.6-0.6-1.4-1-2.3-1C8,9,7.5,9.2,7,9.4S6.2,10,6,10.5c-0.3,0.4-0.4,1-0.4,1.5 c0,0.6,0.1,1.1,0.4,1.5C6.2,14,6.6,14.3,7,14.6S8,15,8.6,15c0.7,0,1.2-0.1,1.7-0.4V11.9z"/>
|
||||
<path class="st2" d="M15.4,9.1v2.6h4.1V13h-4.1v3.2h-1.6V7.8H20v1.3H15.4z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
13
static/stickers/organic.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: Adobe Illustrator 25.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 24 24" style="enable-background:new 0 0 24 24;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#009A82;}
|
||||
.st1{enable-background:new ;}
|
||||
.st2{fill:#F9F4E0;}
|
||||
</style>
|
||||
<circle class="st0" cx="12" cy="12" r="12"/>
|
||||
<g class="st1">
|
||||
<path class="st2" d="M9.7,15.8c-0.7-0.4-1.2-0.9-1.6-1.5S7.5,12.8,7.5,12c0-0.8,0.2-1.6,0.6-2.2S9,8.6,9.7,8.2 c0.7-0.4,1.5-0.6,2.3-0.6c0.9,0,1.6,0.2,2.3,0.6s1.2,0.9,1.6,1.5c0.4,0.7,0.6,1.4,0.6,2.2c0,0.8-0.2,1.6-0.6,2.2 c-0.4,0.7-0.9,1.2-1.6,1.5c-0.7,0.4-1.5,0.6-2.3,0.6C11.1,16.3,10.4,16.1,9.7,15.8z M13.5,14.6c0.4-0.3,0.8-0.6,1.1-1.1 s0.4-1,0.4-1.5c0-0.6-0.1-1.1-0.4-1.5S14,9.7,13.5,9.4C13.1,9.2,12.6,9,12,9c-0.6,0-1.1,0.1-1.5,0.4S9.7,10,9.4,10.5S9,11.4,9,12 c0,0.6,0.1,1.1,0.4,1.5s0.6,0.8,1.1,1.1s1,0.4,1.5,0.4C12.6,15,13.1,14.8,13.5,14.6z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
13
static/stickers/sugarfree.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: Adobe Illustrator 25.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 24 24" style="enable-background:new 0 0 24 24;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#009A82;}
|
||||
.st1{enable-background:new ;}
|
||||
.st2{fill:#F9F4E0;}
|
||||
</style>
|
||||
<circle class="st0" cx="12" cy="12" r="12"/>
|
||||
<g class="st1">
|
||||
<path class="st2" d="M10.1,16c-0.6-0.2-1.1-0.4-1.4-0.7l0.5-1.2c0.3,0.3,0.8,0.5,1.3,0.7s1,0.3,1.5,0.3c0.6,0,1.1-0.1,1.4-0.3 c0.3-0.2,0.5-0.5,0.5-0.8c0-0.2-0.1-0.4-0.3-0.6c-0.2-0.2-0.4-0.3-0.7-0.4c-0.3-0.1-0.6-0.2-1.1-0.3c-0.6-0.2-1.2-0.3-1.6-0.5 c-0.4-0.2-0.7-0.4-1-0.7s-0.4-0.8-0.4-1.3c0-0.5,0.1-0.9,0.4-1.3C9.5,8.5,9.8,8.2,10.3,8c0.5-0.2,1.1-0.3,1.9-0.3 c0.5,0,1,0.1,1.5,0.2C14.2,8,14.6,8.2,15,8.4l-0.5,1.2c-0.4-0.2-0.8-0.4-1.2-0.5C13,9,12.6,9,12.2,9c-0.6,0-1.1,0.1-1.4,0.3 s-0.5,0.5-0.5,0.8c0,0.2,0.1,0.4,0.3,0.6c0.2,0.2,0.4,0.3,0.7,0.4c0.3,0.1,0.6,0.2,1.1,0.3c0.6,0.1,1.1,0.3,1.5,0.5 c0.4,0.2,0.7,0.4,1,0.7c0.3,0.3,0.4,0.8,0.4,1.3c0,0.5-0.1,0.9-0.4,1.3s-0.6,0.7-1.1,0.9s-1.1,0.3-1.9,0.3 C11.3,16.3,10.7,16.2,10.1,16z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
14
static/stickers/vegan.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: Adobe Illustrator 25.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 24 24" style="enable-background:new 0 0 24 24;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#009A82;}
|
||||
.st1{enable-background:new ;}
|
||||
.st2{fill:#F9F4E0;}
|
||||
</style>
|
||||
<circle class="st0" cx="12" cy="12" r="12"/>
|
||||
<g class="st1">
|
||||
<path class="st2" d="M12.8,7.8l-3.7,8.4H7.6L4,7.8h1.7l2.8,6.5l2.8-6.5H12.8z"/>
|
||||
<path class="st2" d="M20,14.9v1.3h-6.3V7.8h6.1v1.3h-4.6v2.2h4.1v1.3h-4.1v2.3H20z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 689 B |
32
static/styles/basics.css
Normal file
@@ -0,0 +1,32 @@
|
||||
:root {
|
||||
--brand-color: var(--ghost-accent-color, #ff572f);
|
||||
--primary-text-color: #333;
|
||||
--secondary-text-color: #999;
|
||||
--white-color: #fff;
|
||||
--lighter-gray-color: #f6f6f6;
|
||||
--light-gray-color: #e6e6e6;
|
||||
--mid-gray-color: #ccc;
|
||||
--dark-gray-color: #444;
|
||||
--darker-gray-color: #1a1a1a;
|
||||
--black-color: #000;
|
||||
--green-color: #28a745;
|
||||
--orange-color: #ffc107;
|
||||
--red-color: #dc3545;
|
||||
--facebook-color: #3b5998;
|
||||
--twitter-color: #1da1f2;
|
||||
--rss-color: #f26522;
|
||||
--animation-base: ease-in-out;
|
||||
--font-sans: Mulish, -apple-system, BlinkMacSystemFont, Segoe UI, Helvetica, Arial, sans-serif;
|
||||
--font-serif: Lora, Times, serif;
|
||||
--navbar-height: 80px;
|
||||
--content-font-size: 1.7rem;
|
||||
--header-spacing: 6vmin;
|
||||
}
|
||||
|
||||
.is-head-brand {
|
||||
--header-spacing: 8vmin;
|
||||
}
|
||||
|
||||
:where(h1, h2, h3, h4, h5, h6) {
|
||||
font-weight: 800;
|
||||
}
|
||||
165
static/styles/blog-content.css
Normal file
@@ -0,0 +1,165 @@
|
||||
|
||||
|
||||
|
||||
.blog-content {
|
||||
margin: 0 auto;
|
||||
color: #222;
|
||||
font-family: var(--font-sans);
|
||||
line-height: 1.6;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.blog-content pre {
|
||||
background-color: #f8f9fa; /* very light gray */
|
||||
color: #1f2937; /* dark gray */
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 1.2rem;
|
||||
line-height: 1.5;
|
||||
|
||||
padding: 1rem 1.25rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid #d1d5db; /* gray-300-ish */
|
||||
|
||||
overflow-x: auto;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.blog-content code {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
|
||||
.blog-content video,
|
||||
.blog-content iframe,
|
||||
.blog-content img {
|
||||
display: block;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
|
||||
/* Paragraphs */
|
||||
.blog-content p {
|
||||
text-align: justify;
|
||||
overflow-wrap: anywhere; /* modern, nicer than break-word */
|
||||
word-break: normal; /* avoid aggressive breaking */
|
||||
hyphens: auto;
|
||||
-webkit-hyphens: auto;
|
||||
-ms-hyphens: auto;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.blog-content .kg-card {
|
||||
margin-top:.5rem;
|
||||
margin-bottom:1rem;
|
||||
}
|
||||
|
||||
|
||||
/* Headings */
|
||||
.blog-content h1,
|
||||
.blog-content h2,
|
||||
.blog-content h3,
|
||||
.blog-content h4,
|
||||
.blog-content h5,
|
||||
.blog-content h6 {
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
color: #000;
|
||||
margin-top: 1em;
|
||||
margin-bottom: 0.6em;
|
||||
}
|
||||
|
||||
.blog-content h1 { font-size: 4rem; }
|
||||
.blog-content h2 { font-size: 3rem; }
|
||||
.blog-content h3 { font-size: 2.5rem; }
|
||||
.blog-content h4 { font-size: 2rem; }
|
||||
.blog-content h5 { font-size: 1.5rem; }
|
||||
.blog-content h6 { font-size: 1rem; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
|
||||
/* Lists */
|
||||
.blog-content ul,
|
||||
.blog-content ol {
|
||||
margin: 0 0 1em 1.5em;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.blog-content li {
|
||||
margin: 0.4em 0;
|
||||
}
|
||||
|
||||
/* Blockquotes */
|
||||
.blog-content blockquote {
|
||||
border-left: 4px solid #ddd;
|
||||
padding-left: 1em;
|
||||
color: #555;
|
||||
font-style: italic;
|
||||
margin: 1.5em 0;
|
||||
}
|
||||
|
||||
/* Images */
|
||||
.blog-content img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
margin: 1.5em auto;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Figures & captions */
|
||||
.blog-content figure {
|
||||
margin: 1.5em 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.blog-content figcaption {
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
|
||||
/* Links */
|
||||
.blog-content a {
|
||||
color: #0055cc;
|
||||
text-decoration: underline;
|
||||
text-decoration-thickness: 1px;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.blog-content a:hover,
|
||||
.blog-content a:focus {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Horizontal rule */
|
||||
.blog-content hr {
|
||||
border: 0;
|
||||
border-top: 1px solid #ddd;
|
||||
margin: 2em 0;
|
||||
}
|
||||
|
||||
|
||||
/* Generic YouTube embeds */
|
||||
iframe[src*="youtube.com"], iframe[src*="youtube-nocookie.com"] {
|
||||
display: block;
|
||||
width: 50% !important;
|
||||
max-width: 50%;
|
||||
aspect-ratio: 16 / 9;
|
||||
height: auto;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* (Optional) target Ghost’s embed card wrapper specifically */
|
||||
.kgg-embed-card iframe {
|
||||
display: block;
|
||||
width: 100% !important;
|
||||
aspect-ratio: 16 / 9;
|
||||
height: auto;
|
||||
border: 0;
|
||||
}
|
||||