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>
This commit is contained in:
giles
2026-02-09 23:11:36 +00:00
commit 668d9c7df8
446 changed files with 22741 additions and 0 deletions

BIN
static/errors/403.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

BIN
static/errors/404.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

BIN
static/errors/error.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 646 KiB

BIN
static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

9
static/fontawesome/css/all.min.css vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

2
static/img/filter.svg Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 KiB

4
static/img/search.svg Normal file
View 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
View 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
View 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
View 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
View 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

View 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
View 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"/>
<!-- AZ 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">AZ</text>
</svg>

After

Width:  |  Height:  |  Size: 536 B

10
static/order/h-l.svg Normal file
View 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"/>
<!-- AZ 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
View 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"/>
<!-- AZ 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
View 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"/>
<!-- AZ 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
View 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')
});
});

View 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

View 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

View 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

View 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

View 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
View 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
View 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;
}

View 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 Ghosts embed card wrapper specifically */
.kgg-embed-card iframe {
display: block;
width: 100% !important;
aspect-ratio: 16 / 9;
height: auto;
border: 0;
}

2637
static/styles/cards.css Normal file

File diff suppressed because it is too large Load Diff