my personal site
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Remove gymtracker-ads-admin.html and orbyt-icon.png: Delete the admin UI HTML file and the associated icon image to streamline the project and update links to the new admin interface.

+3277 -417
-330
gymtracker-ads-admin.html
··· 1 - <!DOCTYPE html> 2 - <html lang="en"> 3 - <head> 4 - <meta charset="utf-8"> 5 - <meta name="viewport" content="width=device-width, initial-scale=1"> 6 - <meta name="color-scheme" content="light dark"> 7 - <link rel="stylesheet" href="style.css"> 8 - <title>Gym Tracker Ads Admin</title> 9 - </head> 10 - <body> 11 - <main class="admin-main"> 12 - <article class="admin-card"> 13 - <h1 class="admin-title">Gym Tracker Ads</h1> 14 - <p class="admin-subtitle">Manage sponsor ad config for VT Gym Tracker</p> 15 - 16 - <section class="admin-section"> 17 - <h2>API Key</h2> 18 - <p class="admin-hint">Required for saving. Stored in sessionStorage for this tab.</p> 19 - <input type="password" id="apiKey" placeholder="Enter ADMIN_API_KEY" class="admin-input" autocomplete="off"> 20 - <button type="button" id="saveKey" class="admin-btn">Save key for session</button> 21 - </section> 22 - 23 - <section class="admin-section"> 24 - <h2>Schedule</h2> 25 - <div class="admin-row"> 26 - <button type="button" id="fetchScheduleBtn" class="admin-btn">Fetch schedule</button> 27 - <span id="fetchStatus" class="admin-status"></span> 28 - </div> 29 - <div id="scheduleList" class="admin-schedule-list" hidden> 30 - <label class="admin-label">Edit ad</label> 31 - <select id="adSelector" class="admin-input"> 32 - <option value="">— New ad —</option> 33 - </select> 34 - </div> 35 - </section> 36 - 37 - <form id="adForm" class="admin-form"> 38 - <section class="admin-section"> 39 - <h2>Ad Config</h2> 40 - <label class="admin-label">ID (unique, e.g. sponsor-2025-q1)</label> 41 - <input type="text" id="id" name="id" class="admin-input" required> 42 - 43 - <label class="admin-label">Tier</label> 44 - <select id="tier" name="tier" class="admin-input"> 45 - <option value="text">text</option> 46 - <option value="banner">banner</option> 47 - <option value="feature">feature</option> 48 - </select> 49 - 50 - <label class="admin-label">Active</label> 51 - <input type="checkbox" id="active" name="active" class="admin-checkbox"> 52 - 53 - <label class="admin-label">Sponsor</label> 54 - <input type="text" id="sponsor" name="sponsor" class="admin-input" required> 55 - 56 - <label class="admin-label">Headline</label> 57 - <input type="text" id="headline" name="headline" class="admin-input" required> 58 - 59 - <label class="admin-label">Subline (optional)</label> 60 - <input type="text" id="subline" name="subline" class="admin-input"> 61 - 62 - <label class="admin-label">CTA</label> 63 - <input type="text" id="cta" name="cta" class="admin-input" required> 64 - 65 - <label class="admin-label">Destination URL (HTTPS)</label> 66 - <input type="url" id="destination_url" name="destination_url" class="admin-input" required placeholder="https://"> 67 - 68 - <label class="admin-label">Image URL (required for banner/feature)</label> 69 - <input type="url" id="image_url" name="image_url" class="admin-input" placeholder="https://"> 70 - 71 - <label class="admin-label">Logo URL (optional)</label> 72 - <input type="url" id="logo_url" name="logo_url" class="admin-input" placeholder="https://"> 73 - 74 - <label class="admin-label">Placement (default: home_feed)</label> 75 - <input type="text" id="placement" name="placement" class="admin-input" value="home_feed"> 76 - 77 - <label class="admin-label">Creative version (optional)</label> 78 - <input type="text" id="creative_version" name="creative_version" class="admin-input"> 79 - 80 - <label class="admin-label">Start at (optional)</label> 81 - <input type="datetime-local" id="start_at" name="start_at" class="admin-input"> 82 - 83 - <label class="admin-label">End at (optional)</label> 84 - <input type="datetime-local" id="end_at" name="end_at" class="admin-input"> 85 - </section> 86 - 87 - <div class="admin-form-actions"> 88 - <button type="submit" id="saveBtn" class="admin-btn admin-btn-primary">Save config</button> 89 - <span id="saveStatus" class="admin-status"></span> 90 - </div> 91 - </form> 92 - 93 - <p class="admin-footer"><a href="index.html">Back to jackhannon.net</a></p> 94 - </article> 95 - </main> 96 - 97 - <script> 98 - const API_URL = "https://gymtracker.jackhannon.net/api/ads"; 99 - const KEY_STORAGE = "gymtracker_ads_api_key"; 100 - 101 - const apiKeyInput = document.getElementById("apiKey"); 102 - const saveKeyBtn = document.getElementById("saveKey"); 103 - const fetchScheduleBtn = document.getElementById("fetchScheduleBtn"); 104 - const fetchStatus = document.getElementById("fetchStatus"); 105 - const scheduleList = document.getElementById("scheduleList"); 106 - const adSelector = document.getElementById("adSelector"); 107 - const saveBtn = document.getElementById("saveBtn"); 108 - const saveStatus = document.getElementById("saveStatus"); 109 - const form = document.getElementById("adForm"); 110 - 111 - let scheduledAds = []; 112 - 113 - if (sessionStorage.getItem(KEY_STORAGE)) { 114 - apiKeyInput.placeholder = "•••••••••••• (saved)"; 115 - } 116 - 117 - saveKeyBtn.addEventListener("click", () => { 118 - const key = apiKeyInput.value.trim(); 119 - if (key) { 120 - sessionStorage.setItem(KEY_STORAGE, key); 121 - apiKeyInput.value = ""; 122 - apiKeyInput.placeholder = "•••••••••••• (saved)"; 123 - } 124 - }); 125 - 126 - function getApiKey() { 127 - return sessionStorage.getItem(KEY_STORAGE); 128 - } 129 - 130 - function setStatus(el, msg, ok) { 131 - el.textContent = msg; 132 - el.className = "admin-status " + (ok ? "admin-status-ok" : "admin-status-err"); 133 - } 134 - 135 - function adStatus(ad) { 136 - const now = new Date(); 137 - const start = ad.start_at ? new Date(ad.start_at) : null; 138 - const end = ad.end_at ? new Date(ad.end_at) : null; 139 - if (!ad.active) return "paused"; 140 - if (start && now < start) return "scheduled"; 141 - if (end && now > end) return "ended"; 142 - return "live"; 143 - } 144 - 145 - function formatAdOption(ad) { 146 - const status = adStatus(ad); 147 - const label = status === "live" ? "●" : status === "scheduled" ? "○" : status === "ended" ? "—" : "‖"; 148 - const startStr = ad.start_at ? new Date(ad.start_at).toLocaleDateString() : "…"; 149 - const endStr = ad.end_at ? new Date(ad.end_at).toLocaleDateString() : "…"; 150 - const range = ad.start_at || ad.end_at ? ` (${startStr}–${endStr})` : ""; 151 - return `${label} ${ad.id}${range} [${status}]`; 152 - } 153 - 154 - fetchScheduleBtn.addEventListener("click", async () => { 155 - const key = getApiKey(); 156 - if (!key) { 157 - setStatus(fetchStatus, "Set API key first", false); 158 - return; 159 - } 160 - setStatus(fetchStatus, "Fetching…", true); 161 - try { 162 - const res = await fetch(API_URL + "?schedule=1", { 163 - headers: { "X-API-Key": key }, 164 - }); 165 - const data = await res.json(); 166 - if (!res.ok) { 167 - setStatus(fetchStatus, data.error || res.statusText, false); 168 - return; 169 - } 170 - scheduledAds = data.ads || []; 171 - adSelector.innerHTML = '<option value="">— New ad —</option>'; 172 - scheduledAds.forEach((ad, i) => { 173 - const opt = document.createElement("option"); 174 - opt.value = String(i); 175 - opt.textContent = formatAdOption(ad); 176 - adSelector.appendChild(opt); 177 - }); 178 - scheduleList.hidden = scheduledAds.length === 0; 179 - if (scheduledAds.length > 0) { 180 - adSelector.hidden = false; 181 - adSelector.value = "0"; 182 - populateForm(scheduledAds[0]); 183 - } else { 184 - clearForm(); 185 - } 186 - setStatus(fetchStatus, `Loaded ${scheduledAds.length} ad(s)`, true); 187 - } catch (err) { 188 - setStatus(fetchStatus, err.message || "Network error", false); 189 - } 190 - }); 191 - 192 - adSelector.addEventListener("change", () => { 193 - const val = adSelector.value; 194 - if (val === "") { 195 - clearForm(); 196 - } else { 197 - const ad = scheduledAds[parseInt(val, 10)]; 198 - if (ad) populateForm(ad); 199 - } 200 - }); 201 - 202 - function clearForm() { 203 - document.getElementById("id").value = ""; 204 - document.getElementById("tier").value = "banner"; 205 - document.getElementById("active").checked = true; 206 - document.getElementById("sponsor").value = ""; 207 - document.getElementById("headline").value = ""; 208 - document.getElementById("subline").value = ""; 209 - document.getElementById("cta").value = ""; 210 - document.getElementById("destination_url").value = ""; 211 - document.getElementById("image_url").value = ""; 212 - document.getElementById("logo_url").value = ""; 213 - document.getElementById("placement").value = "home_feed"; 214 - document.getElementById("creative_version").value = ""; 215 - document.getElementById("start_at").value = ""; 216 - document.getElementById("end_at").value = ""; 217 - } 218 - 219 - function isoToDatetimeLocal(iso) { 220 - if (!iso) return ""; 221 - const d = new Date(iso); 222 - if (Number.isNaN(d.getTime())) return ""; 223 - const pad = (n) => String(n).padStart(2, "0"); 224 - return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; 225 - } 226 - 227 - function datetimeLocalToIso(value) { 228 - if (!value) return undefined; 229 - const d = new Date(value); 230 - return Number.isNaN(d.getTime()) ? undefined : d.toISOString(); 231 - } 232 - 233 - function populateForm(data) { 234 - document.getElementById("id").value = data.id || ""; 235 - document.getElementById("tier").value = (data.tier || "banner").toLowerCase(); 236 - document.getElementById("active").checked = !!data.active; 237 - document.getElementById("sponsor").value = data.sponsor || ""; 238 - document.getElementById("headline").value = data.headline || ""; 239 - document.getElementById("subline").value = data.subline || ""; 240 - document.getElementById("cta").value = data.cta || ""; 241 - document.getElementById("destination_url").value = data.destination_url || ""; 242 - document.getElementById("image_url").value = data.image_url || ""; 243 - document.getElementById("logo_url").value = data.logo_url || ""; 244 - document.getElementById("placement").value = data.placement || "home_feed"; 245 - document.getElementById("creative_version").value = data.creative_version || ""; 246 - document.getElementById("start_at").value = isoToDatetimeLocal(data.start_at); 247 - document.getElementById("end_at").value = isoToDatetimeLocal(data.end_at); 248 - } 249 - 250 - form.addEventListener("submit", async (e) => { 251 - e.preventDefault(); 252 - const key = getApiKey(); 253 - if (!key) { 254 - setStatus(saveStatus, "Set API key first", false); 255 - return; 256 - } 257 - 258 - const tier = document.getElementById("tier").value; 259 - const payload = { 260 - id: document.getElementById("id").value.trim(), 261 - tier, 262 - active: document.getElementById("active").checked, 263 - sponsor: document.getElementById("sponsor").value.trim(), 264 - headline: document.getElementById("headline").value.trim(), 265 - subline: document.getElementById("subline").value.trim() || null, 266 - cta: document.getElementById("cta").value.trim(), 267 - destination_url: document.getElementById("destination_url").value.trim(), 268 - image_url: document.getElementById("image_url").value.trim() || null, 269 - logo_url: document.getElementById("logo_url").value.trim() || null, 270 - placement: document.getElementById("placement").value.trim() || "home_feed", 271 - creative_version: document.getElementById("creative_version").value.trim() || "", 272 - start_at: datetimeLocalToIso(document.getElementById("start_at").value.trim()), 273 - end_at: datetimeLocalToIso(document.getElementById("end_at").value.trim()), 274 - }; 275 - 276 - if ((tier === "banner" || tier === "feature") && !payload.image_url) { 277 - setStatus(saveStatus, "Banner/feature tier requires image_url", false); 278 - return; 279 - } 280 - 281 - setStatus(saveStatus, "Saving…", true); 282 - try { 283 - const res = await fetch(API_URL, { 284 - method: "PUT", 285 - headers: { 286 - "Content-Type": "application/json", 287 - "X-API-Key": key, 288 - }, 289 - body: JSON.stringify(payload), 290 - }); 291 - const data = await res.json(); 292 - if (!res.ok) { 293 - setStatus(saveStatus, data.error || res.statusText, false); 294 - return; 295 - } 296 - setStatus(saveStatus, "Saved", true); 297 - if (scheduledAds.length > 0) fetchScheduleBtn.click(); 298 - } catch (err) { 299 - setStatus(saveStatus, err.message || "Network error", false); 300 - } 301 - }); 302 - </script> 303 - 304 - <style> 305 - .admin-main { min-height: 100dvh; padding: 2rem; display: flex; justify-content: center; align-items: flex-start; } 306 - .admin-card { max-width: 32rem; width: 100%; display: flex; flex-direction: column; gap: 1.5rem; } 307 - .admin-title { font-size: 1.5rem; font-weight: 600; } 308 - .admin-subtitle { color: var(--color-text-muted); font-size: 0.9rem; } 309 - .admin-section { display: flex; flex-direction: column; gap: 0.5rem; } 310 - .admin-section h2 { font-size: 1rem; font-weight: 600; margin-bottom: 0.25rem; } 311 - .admin-label { font-size: 0.875rem; color: var(--color-text-muted); } 312 - .admin-input { padding: 0.5rem 0.75rem; border: 1px solid var(--color-border); border-radius: 0.5rem; background: var(--color-bg); color: var(--color-text); font: inherit; } 313 - .admin-input:focus { outline: 2px solid var(--color-vt-maroon); outline-offset: 2px; } 314 - .admin-checkbox { width: 1.25rem; height: 1.25rem; } 315 - .admin-btn { padding: 0.5rem 1rem; border: 1px solid var(--color-border); border-radius: 0.5rem; background: var(--color-bg); color: var(--color-text); font: inherit; cursor: pointer; } 316 - .admin-btn:hover { background: var(--color-border); } 317 - .admin-btn-primary { background: var(--color-vt-maroon); color: white; border-color: var(--color-vt-maroon); } 318 - .admin-btn-primary:hover { opacity: 0.9; } 319 - .admin-row { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; } 320 - .admin-form-actions { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; margin-top: 1.5rem; padding-top: 1.5rem; border-top: 1px solid var(--color-border); } 321 - .admin-status { font-size: 0.875rem; color: var(--color-text-muted); } 322 - .admin-status-ok { color: oklch(50% 0.15 145); } 323 - .admin-status-err { color: oklch(55% 0.2 25); } 324 - .admin-hint { font-size: 0.8rem; color: var(--color-text-muted); } 325 - .admin-schedule-list { display: flex; flex-direction: column; gap: 0.5rem; } 326 - .admin-footer { margin-top: 1rem; font-size: 0.875rem; } 327 - .admin-footer a { text-decoration: underline; } 328 - </style> 329 - </body> 330 - </html>
images/orbyt-icon.png

This is a binary file and will not be displayed.

+1 -1
index.html
··· 127 127 </a> 128 128 </li> 129 129 <li> 130 - <a class="social-link" href="gymtracker-ads-admin.html"> 130 + <a class="social-link" href="https://gymtracker.jackhannon.net/admin"> 131 131 <svg class="icon" viewBox="0 0 24 24" aria-hidden="true"> 132 132 <path fill="currentColor" d="M12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6m-5 9a1 1 0 0 1 1-1h8a1 1 0 0 1 1 1v1H7z"/> 133 133 </svg>
+111
workers/gymtracker-ads-api/ACCESS_SETUP.md
··· 1 + # Cloudflare Access Setup for Gym Tracker Ads Admin 2 + 3 + This guide configures Cloudflare Access so you sign in with Google (or another provider) instead of using an API key. 4 + 5 + ## Prerequisites 6 + 7 + - Cloudflare Zero Trust (free tier is fine) 8 + - Your domain `jackhannon.net` on Cloudflare with the zone active 9 + 10 + ## Step 1: Enable Zero Trust 11 + 12 + 1. Go to [Cloudflare Dashboard](https://dash.cloudflare.com) → **Zero Trust** 13 + 2. If prompted, create a team (e.g. `jackhannon` — you'll get a `*.cloudflareaccess.com` subdomain) 14 + 15 + ## Step 2: Create an Access Application for the Admin 16 + 17 + **Important:** Both `/admin` and `/api/admin` must be protected. If only `/admin` is protected, unauthenticated requests can hit `/api/admin/ads` directly. 18 + 19 + 1. In Zero Trust: **Access** → **Applications** → **Add an application** 20 + 2. Choose **Self-hosted** 21 + 3. Configure: 22 + - **Application name**: `Gym Tracker Ads Admin` 23 + - **Session Duration**: 24 hours (or your preference) 24 + - **Application domain**: 25 + - **Subdomain**: `gymtracker` 26 + - **Domain**: `jackhannon.net` 27 + - **Path**: `/admin` (you will add `/api/admin` in Step 3) 28 + - Click **Next** 29 + 30 + 4. Add a **Policy**: 31 + - **Policy name**: `Require Google login` 32 + - **Action**: **Allow** 33 + - **Configure rules** → **Add include**: 34 + - **Selector**: Emails ending in → `@jackhannon.net` (or use "Emails" and add your email) 35 + - Or: **Login methods** → Add **Google** 36 + - Click **Next** 37 + 38 + 5. **Protect the API** — You must also protect `/api/admin` (required, not optional): 39 + - The admin UI at `/admin` fetches and saves ads via `/api/admin/ads`. If `/api/admin` is not protected, anyone can list and modify ads without signing in. 40 + 41 + ## Step 3: Protect `/api/admin` (required) 42 + 43 + To protect the admin API so only signed-in users can fetch/save ads: 44 + 45 + **Option A — Same application, broader path** 46 + 47 + Edit the application you created. Change the **Path** to include both: 48 + - Path: `/admin` 49 + - Add another path: `/api/admin` 50 + 51 + (Cloudflare Access lets you add multiple paths in one application.) 52 + 53 + **Option B — Second application** 54 + 55 + 1. **Add an application** → Self-hosted 56 + 2. **Application domain**: 57 + - Subdomain: `gymtracker` 58 + - Domain: `jackhannon.net` 59 + - Path: `/api/admin` 60 + 3. Use the same policy as above (e.g. Google login). 61 + 62 + ## Step 4: Ensure Public API Stays Open 63 + 64 + The public endpoint `GET https://gymtracker.jackhannon.net/api/ads` must **not** require Access. The VT Gym Tracker app fetches ads without auth. 65 + 66 + - If you only protect `/admin` and `/api/admin`, `/api/ads` stays public. 67 + - If you protect `/api/*`, add a **Bypass** policy that runs first: 68 + - Policy: **Bypass** 69 + - Include: **Everyone** 70 + - Path: `/api/ads` (exact) 71 + 72 + ## Step 5: Add an Identity Provider (Google) 73 + 74 + 1. Zero Trust → **Settings** → **Authentication** 75 + 2. **Login methods** → **Add new** 76 + 3. Choose **Google** 77 + 4. Follow the prompts (create OAuth credentials in Google Cloud Console if needed) 78 + 79 + ## Step 6: Verify Access Path Coverage 80 + 81 + Before testing, confirm in Zero Trust that both paths are protected: 82 + 83 + - [ ] `/admin` — Admin UI page 84 + - [ ] `/api/admin` — Admin API (includes `/api/admin/ads`, `/api/admin/stats`) 85 + 86 + If you use a single application with multiple paths, ensure both are included. If you use separate applications, ensure each has the correct path. 87 + 88 + ## Step 7: Test 89 + 90 + 1. Visit **https://gymtracker.jackhannon.net/admin** 91 + 2. You should see the Cloudflare Access login page 92 + 3. Sign in with Google 93 + 4. You should land on the admin UI with no API key needed 94 + 5. Click **Refresh** — it should load your ads 95 + 6. Edit and save — it should work without an API key 96 + 97 + ## Troubleshooting 98 + 99 + - **401 Unauthorized** on `/admin`: Access may not be protecting that path yet, or the JWT isn’t being sent. Confirm the Access application path matches `/admin` and `/api/admin`. 100 + - **CORS errors**: The Worker allows `gymtracker.jackhannon.net`. If you use another origin, add it to `ALLOWED_ORIGINS` in the Worker. 101 + - **Public API blocked**: Ensure `/api/ads` is not covered by a “Require” policy, or add a Bypass policy for it. 102 + 103 + ## Deploy After Setup 104 + 105 + After configuring Access: 106 + 107 + ```bash 108 + npm run deploy 109 + ``` 110 + 111 + The Worker serves `/admin` and `/api/admin/ads` and checks for the `Cf-Access-Jwt-Assertion` header. Cloudflare adds this header when the request has passed Access, so no API key is required.
+54 -7
workers/gymtracker-ads-api/README.md
··· 6 6 7 7 1. **Install dependencies:** `npm install` 8 8 9 - 2. **Set admin API key** (required for PUT): 9 + 2. **Set admin API key** (required for PUT and schedule GET): 10 10 ```bash 11 11 npx wrangler secret put ADMIN_API_KEY 12 12 ``` ··· 14 14 15 15 3. **Deploy:** `npm run deploy` 16 16 17 + 4. **(Optional) PostHog analytics** — To show ad impressions, clicks, and CTR in the Overview: 18 + - Create a [Personal API key](https://us.posthog.com/settings/user-api-keys#personal-api-keys) with `query:read` scope 19 + - `npx wrangler secret put POSTHOG_PERSONAL_API_KEY` 20 + - Project ID and host are in `wrangler.jsonc` vars; override via `POSTHOG_PROJECT_ID` / `POSTHOG_HOST` if needed 21 + - The app must send `ad_impression` and `ad_tap` events to PostHog 22 + 23 + ## Initial KV State 24 + 25 + Before any ad has been PUT, GET returns `404` with `{ "error": "No active ad" }`. The app handles this gracefully (shows nothing). After deploy, use the Admin UI or `curl` to add your first ad. 26 + 17 27 ## Endpoints 18 28 19 29 | Method | Path | Auth | Description | ··· 23 33 | PUT | /api/ads | X-API-Key header | Upsert ad config (by id) | 24 34 | OPTIONS | /api/ads | None | CORS preflight | 25 35 26 - ## Scheduling 36 + The API filters ads by `start_at` and `end_at` on the server. Multiple ads can be scheduled; the public GET returns the one active "now". Legacy single-ad config is auto-migrated on first request. 37 + 38 + ## Schema (AdConfig) 39 + 40 + | Field | Type | Required | Notes | 41 + |-------|------|----------|-------| 42 + | `id` | string | yes | Unique; e.g. `sponsor-2025-q1` | 43 + | `active` | boolean | yes | Whether ad can be shown | 44 + | `sponsor` | string | yes | Non-empty | 45 + | `headline` | string | yes | Non-empty | 46 + | `subline` | string \| null | no | Optional | 47 + | `cta` | string | yes | Call-to-action text | 48 + | `destination_url` | string | yes | Must be HTTPS | 49 + | `image_url` | string \| null | tier-dependent | Required for `banner` and `feature`; must be HTTPS | 50 + | `logo_url` | string \| null | no | Optional; if set, must be HTTPS | 51 + | `creative_version` | string | no | Defaults to `""` | 52 + | `placement` | string | no | Defaults to `"home_feed"` | 53 + | `start_at` | string | no | ISO date string; start of active window | 54 + | `end_at` | string | no | ISO date string; end of active window | 55 + | `tier` | `"text" \| "banner" \| "feature"` | no | Defaults to `"banner"` (normalized to lowercase) | 56 + 57 + **Tier rules:** `text` — `image_url` optional. `banner` / `feature` — `image_url` required, valid HTTPS. 58 + 59 + **Active logic:** An ad is active only if `active` is true, `now >= start_at` (if set), and `now <= end_at` (if set). If multiple ads are active, the one with the latest `start_at` is returned. 60 + 61 + Also see [ad-config-schema.md](https://github.com/Hann8n/VTGymTracker/blob/main/docs/ad-config-schema.md) in the VT Gym Tracker repo. 27 62 28 - - Use `start_at` and `end_at` (ISO8601) to schedule when an ad is live. 29 - - Multiple ads can be scheduled; the API returns the one active "now" on public GET. 30 - - Ads without dates are always eligible (if active). Overlapping windows: most recently started wins. 63 + ## Seed First Config 31 64 32 - ## Admin UI 65 + After deploy, add your first ad via Admin UI or `curl`: 33 66 34 - Manage ads at https://jackhannon.net/gymtracker-ads-admin.html 67 + ```bash 68 + # Placeholder (inactive) — GET returns 404 until you activate 69 + curl -X PUT https://gymtracker.jackhannon.net/api/ads \ 70 + -H "Content-Type: application/json" \ 71 + -H "X-API-Key: YOUR_ADMIN_API_KEY" \ 72 + -d @seed-ad.json 73 + 74 + # Test ad (active) — use for end-to-end verification 75 + curl -X PUT https://gymtracker.jackhannon.net/api/ads \ 76 + -H "Content-Type: application/json" \ 77 + -H "X-API-Key: YOUR_ADMIN_API_KEY" \ 78 + -d @seed-ad-active.json 79 + ``` 80 + 81 + **Admin UI:** Visit [https://gymtracker.jackhannon.net/admin](https://gymtracker.jackhannon.net/admin) — sign in with Cloudflare Access (Google or your configured provider). See [ACCESS_SETUP.md](ACCESS_SETUP.md) to configure.
+1598
workers/gymtracker-ads-api/package-lock.json
··· 1 + { 2 + "name": "gymtracker-ads-api", 3 + "version": "1.0.0", 4 + "lockfileVersion": 3, 5 + "requires": true, 6 + "packages": { 7 + "": { 8 + "name": "gymtracker-ads-api", 9 + "version": "1.0.0", 10 + "devDependencies": { 11 + "typescript": "^5.0.0", 12 + "wrangler": "^3.0.0" 13 + } 14 + }, 15 + "node_modules/@cloudflare/kv-asset-handler": { 16 + "version": "0.3.4", 17 + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.3.4.tgz", 18 + "integrity": "sha512-YLPHc8yASwjNkmcDMQMY35yiWjoKAKnhUbPRszBRS0YgH+IXtsMp61j+yTcnCE3oO2DgP0U3iejLC8FTtKDC8Q==", 19 + "dev": true, 20 + "license": "MIT OR Apache-2.0", 21 + "dependencies": { 22 + "mime": "^3.0.0" 23 + }, 24 + "engines": { 25 + "node": ">=16.13" 26 + } 27 + }, 28 + "node_modules/@cloudflare/unenv-preset": { 29 + "version": "2.0.2", 30 + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.0.2.tgz", 31 + "integrity": "sha512-nyzYnlZjjV5xT3LizahG1Iu6mnrCaxglJ04rZLpDwlDVDZ7v46lNsfxhV3A/xtfgQuSHmLnc6SVI+KwBpc3Lwg==", 32 + "dev": true, 33 + "license": "MIT OR Apache-2.0", 34 + "peerDependencies": { 35 + "unenv": "2.0.0-rc.14", 36 + "workerd": "^1.20250124.0" 37 + }, 38 + "peerDependenciesMeta": { 39 + "workerd": { 40 + "optional": true 41 + } 42 + } 43 + }, 44 + "node_modules/@cloudflare/workerd-darwin-64": { 45 + "version": "1.20250718.0", 46 + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20250718.0.tgz", 47 + "integrity": "sha512-FHf4t7zbVN8yyXgQ/r/GqLPaYZSGUVzeR7RnL28Mwj2djyw2ZergvytVc7fdGcczl6PQh+VKGfZCfUqpJlbi9g==", 48 + "cpu": [ 49 + "x64" 50 + ], 51 + "dev": true, 52 + "license": "Apache-2.0", 53 + "optional": true, 54 + "os": [ 55 + "darwin" 56 + ], 57 + "engines": { 58 + "node": ">=16" 59 + } 60 + }, 61 + "node_modules/@cloudflare/workerd-darwin-arm64": { 62 + "version": "1.20250718.0", 63 + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20250718.0.tgz", 64 + "integrity": "sha512-fUiyUJYyqqp4NqJ0YgGtp4WJh/II/YZsUnEb6vVy5Oeas8lUOxnN+ZOJ8N/6/5LQCVAtYCChRiIrBbfhTn5Z8Q==", 65 + "cpu": [ 66 + "arm64" 67 + ], 68 + "dev": true, 69 + "license": "Apache-2.0", 70 + "optional": true, 71 + "os": [ 72 + "darwin" 73 + ], 74 + "engines": { 75 + "node": ">=16" 76 + } 77 + }, 78 + "node_modules/@cloudflare/workerd-linux-64": { 79 + "version": "1.20250718.0", 80 + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20250718.0.tgz", 81 + "integrity": "sha512-5+eb3rtJMiEwp08Kryqzzu8d1rUcK+gdE442auo5eniMpT170Dz0QxBrqkg2Z48SFUPYbj+6uknuA5tzdRSUSg==", 82 + "cpu": [ 83 + "x64" 84 + ], 85 + "dev": true, 86 + "license": "Apache-2.0", 87 + "optional": true, 88 + "os": [ 89 + "linux" 90 + ], 91 + "engines": { 92 + "node": ">=16" 93 + } 94 + }, 95 + "node_modules/@cloudflare/workerd-linux-arm64": { 96 + "version": "1.20250718.0", 97 + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20250718.0.tgz", 98 + "integrity": "sha512-Aa2M/DVBEBQDdATMbn217zCSFKE+ud/teS+fFS+OQqKABLn0azO2qq6ANAHYOIE6Q3Sq4CxDIQr8lGdaJHwUog==", 99 + "cpu": [ 100 + "arm64" 101 + ], 102 + "dev": true, 103 + "license": "Apache-2.0", 104 + "optional": true, 105 + "os": [ 106 + "linux" 107 + ], 108 + "engines": { 109 + "node": ">=16" 110 + } 111 + }, 112 + "node_modules/@cloudflare/workerd-windows-64": { 113 + "version": "1.20250718.0", 114 + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20250718.0.tgz", 115 + "integrity": "sha512-dY16RXKffmugnc67LTbyjdDHZn5NoTF1yHEf2fN4+OaOnoGSp3N1x77QubTDwqZ9zECWxgQfDLjddcH8dWeFhg==", 116 + "cpu": [ 117 + "x64" 118 + ], 119 + "dev": true, 120 + "license": "Apache-2.0", 121 + "optional": true, 122 + "os": [ 123 + "win32" 124 + ], 125 + "engines": { 126 + "node": ">=16" 127 + } 128 + }, 129 + "node_modules/@cspotcode/source-map-support": { 130 + "version": "0.8.1", 131 + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", 132 + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", 133 + "dev": true, 134 + "license": "MIT", 135 + "dependencies": { 136 + "@jridgewell/trace-mapping": "0.3.9" 137 + }, 138 + "engines": { 139 + "node": ">=12" 140 + } 141 + }, 142 + "node_modules/@emnapi/runtime": { 143 + "version": "1.9.1", 144 + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", 145 + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", 146 + "dev": true, 147 + "license": "MIT", 148 + "optional": true, 149 + "dependencies": { 150 + "tslib": "^2.4.0" 151 + } 152 + }, 153 + "node_modules/@esbuild-plugins/node-globals-polyfill": { 154 + "version": "0.2.3", 155 + "resolved": "https://registry.npmjs.org/@esbuild-plugins/node-globals-polyfill/-/node-globals-polyfill-0.2.3.tgz", 156 + "integrity": "sha512-r3MIryXDeXDOZh7ih1l/yE9ZLORCd5e8vWg02azWRGj5SPTuoh69A2AIyn0Z31V/kHBfZ4HgWJ+OK3GTTwLmnw==", 157 + "dev": true, 158 + "license": "ISC", 159 + "peerDependencies": { 160 + "esbuild": "*" 161 + } 162 + }, 163 + "node_modules/@esbuild-plugins/node-modules-polyfill": { 164 + "version": "0.2.2", 165 + "resolved": "https://registry.npmjs.org/@esbuild-plugins/node-modules-polyfill/-/node-modules-polyfill-0.2.2.tgz", 166 + "integrity": "sha512-LXV7QsWJxRuMYvKbiznh+U1ilIop3g2TeKRzUxOG5X3YITc8JyyTa90BmLwqqv0YnX4v32CSlG+vsziZp9dMvA==", 167 + "dev": true, 168 + "license": "ISC", 169 + "dependencies": { 170 + "escape-string-regexp": "^4.0.0", 171 + "rollup-plugin-node-polyfills": "^0.2.1" 172 + }, 173 + "peerDependencies": { 174 + "esbuild": "*" 175 + } 176 + }, 177 + "node_modules/@esbuild/android-arm": { 178 + "version": "0.17.19", 179 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.17.19.tgz", 180 + "integrity": "sha512-rIKddzqhmav7MSmoFCmDIb6e2W57geRsM94gV2l38fzhXMwq7hZoClug9USI2pFRGL06f4IOPHHpFNOkWieR8A==", 181 + "cpu": [ 182 + "arm" 183 + ], 184 + "dev": true, 185 + "license": "MIT", 186 + "optional": true, 187 + "os": [ 188 + "android" 189 + ], 190 + "engines": { 191 + "node": ">=12" 192 + } 193 + }, 194 + "node_modules/@esbuild/android-arm64": { 195 + "version": "0.17.19", 196 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.17.19.tgz", 197 + "integrity": "sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA==", 198 + "cpu": [ 199 + "arm64" 200 + ], 201 + "dev": true, 202 + "license": "MIT", 203 + "optional": true, 204 + "os": [ 205 + "android" 206 + ], 207 + "engines": { 208 + "node": ">=12" 209 + } 210 + }, 211 + "node_modules/@esbuild/android-x64": { 212 + "version": "0.17.19", 213 + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.17.19.tgz", 214 + "integrity": "sha512-uUTTc4xGNDT7YSArp/zbtmbhO0uEEK9/ETW29Wk1thYUJBz3IVnvgEiEwEa9IeLyvnpKrWK64Utw2bgUmDveww==", 215 + "cpu": [ 216 + "x64" 217 + ], 218 + "dev": true, 219 + "license": "MIT", 220 + "optional": true, 221 + "os": [ 222 + "android" 223 + ], 224 + "engines": { 225 + "node": ">=12" 226 + } 227 + }, 228 + "node_modules/@esbuild/darwin-arm64": { 229 + "version": "0.17.19", 230 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.17.19.tgz", 231 + "integrity": "sha512-80wEoCfF/hFKM6WE1FyBHc9SfUblloAWx6FJkFWTWiCoht9Mc0ARGEM47e67W9rI09YoUxJL68WHfDRYEAvOhg==", 232 + "cpu": [ 233 + "arm64" 234 + ], 235 + "dev": true, 236 + "license": "MIT", 237 + "optional": true, 238 + "os": [ 239 + "darwin" 240 + ], 241 + "engines": { 242 + "node": ">=12" 243 + } 244 + }, 245 + "node_modules/@esbuild/darwin-x64": { 246 + "version": "0.17.19", 247 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.17.19.tgz", 248 + "integrity": "sha512-IJM4JJsLhRYr9xdtLytPLSH9k/oxR3boaUIYiHkAawtwNOXKE8KoU8tMvryogdcT8AU+Bflmh81Xn6Q0vTZbQw==", 249 + "cpu": [ 250 + "x64" 251 + ], 252 + "dev": true, 253 + "license": "MIT", 254 + "optional": true, 255 + "os": [ 256 + "darwin" 257 + ], 258 + "engines": { 259 + "node": ">=12" 260 + } 261 + }, 262 + "node_modules/@esbuild/freebsd-arm64": { 263 + "version": "0.17.19", 264 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.17.19.tgz", 265 + "integrity": "sha512-pBwbc7DufluUeGdjSU5Si+P3SoMF5DQ/F/UmTSb8HXO80ZEAJmrykPyzo1IfNbAoaqw48YRpv8shwd1NoI0jcQ==", 266 + "cpu": [ 267 + "arm64" 268 + ], 269 + "dev": true, 270 + "license": "MIT", 271 + "optional": true, 272 + "os": [ 273 + "freebsd" 274 + ], 275 + "engines": { 276 + "node": ">=12" 277 + } 278 + }, 279 + "node_modules/@esbuild/freebsd-x64": { 280 + "version": "0.17.19", 281 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.17.19.tgz", 282 + "integrity": "sha512-4lu+n8Wk0XlajEhbEffdy2xy53dpR06SlzvhGByyg36qJw6Kpfk7cp45DR/62aPH9mtJRmIyrXAS5UWBrJT6TQ==", 283 + "cpu": [ 284 + "x64" 285 + ], 286 + "dev": true, 287 + "license": "MIT", 288 + "optional": true, 289 + "os": [ 290 + "freebsd" 291 + ], 292 + "engines": { 293 + "node": ">=12" 294 + } 295 + }, 296 + "node_modules/@esbuild/linux-arm": { 297 + "version": "0.17.19", 298 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.17.19.tgz", 299 + "integrity": "sha512-cdmT3KxjlOQ/gZ2cjfrQOtmhG4HJs6hhvm3mWSRDPtZ/lP5oe8FWceS10JaSJC13GBd4eH/haHnqf7hhGNLerA==", 300 + "cpu": [ 301 + "arm" 302 + ], 303 + "dev": true, 304 + "license": "MIT", 305 + "optional": true, 306 + "os": [ 307 + "linux" 308 + ], 309 + "engines": { 310 + "node": ">=12" 311 + } 312 + }, 313 + "node_modules/@esbuild/linux-arm64": { 314 + "version": "0.17.19", 315 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.17.19.tgz", 316 + "integrity": "sha512-ct1Tg3WGwd3P+oZYqic+YZF4snNl2bsnMKRkb3ozHmnM0dGWuxcPTTntAF6bOP0Sp4x0PjSF+4uHQ1xvxfRKqg==", 317 + "cpu": [ 318 + "arm64" 319 + ], 320 + "dev": true, 321 + "license": "MIT", 322 + "optional": true, 323 + "os": [ 324 + "linux" 325 + ], 326 + "engines": { 327 + "node": ">=12" 328 + } 329 + }, 330 + "node_modules/@esbuild/linux-ia32": { 331 + "version": "0.17.19", 332 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.17.19.tgz", 333 + "integrity": "sha512-w4IRhSy1VbsNxHRQpeGCHEmibqdTUx61Vc38APcsRbuVgK0OPEnQ0YD39Brymn96mOx48Y2laBQGqgZ0j9w6SQ==", 334 + "cpu": [ 335 + "ia32" 336 + ], 337 + "dev": true, 338 + "license": "MIT", 339 + "optional": true, 340 + "os": [ 341 + "linux" 342 + ], 343 + "engines": { 344 + "node": ">=12" 345 + } 346 + }, 347 + "node_modules/@esbuild/linux-loong64": { 348 + "version": "0.17.19", 349 + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.17.19.tgz", 350 + "integrity": "sha512-2iAngUbBPMq439a+z//gE+9WBldoMp1s5GWsUSgqHLzLJ9WoZLZhpwWuym0u0u/4XmZ3gpHmzV84PonE+9IIdQ==", 351 + "cpu": [ 352 + "loong64" 353 + ], 354 + "dev": true, 355 + "license": "MIT", 356 + "optional": true, 357 + "os": [ 358 + "linux" 359 + ], 360 + "engines": { 361 + "node": ">=12" 362 + } 363 + }, 364 + "node_modules/@esbuild/linux-mips64el": { 365 + "version": "0.17.19", 366 + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.17.19.tgz", 367 + "integrity": "sha512-LKJltc4LVdMKHsrFe4MGNPp0hqDFA1Wpt3jE1gEyM3nKUvOiO//9PheZZHfYRfYl6AwdTH4aTcXSqBerX0ml4A==", 368 + "cpu": [ 369 + "mips64el" 370 + ], 371 + "dev": true, 372 + "license": "MIT", 373 + "optional": true, 374 + "os": [ 375 + "linux" 376 + ], 377 + "engines": { 378 + "node": ">=12" 379 + } 380 + }, 381 + "node_modules/@esbuild/linux-ppc64": { 382 + "version": "0.17.19", 383 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.17.19.tgz", 384 + "integrity": "sha512-/c/DGybs95WXNS8y3Ti/ytqETiW7EU44MEKuCAcpPto3YjQbyK3IQVKfF6nbghD7EcLUGl0NbiL5Rt5DMhn5tg==", 385 + "cpu": [ 386 + "ppc64" 387 + ], 388 + "dev": true, 389 + "license": "MIT", 390 + "optional": true, 391 + "os": [ 392 + "linux" 393 + ], 394 + "engines": { 395 + "node": ">=12" 396 + } 397 + }, 398 + "node_modules/@esbuild/linux-riscv64": { 399 + "version": "0.17.19", 400 + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.17.19.tgz", 401 + "integrity": "sha512-FC3nUAWhvFoutlhAkgHf8f5HwFWUL6bYdvLc/TTuxKlvLi3+pPzdZiFKSWz/PF30TB1K19SuCxDTI5KcqASJqA==", 402 + "cpu": [ 403 + "riscv64" 404 + ], 405 + "dev": true, 406 + "license": "MIT", 407 + "optional": true, 408 + "os": [ 409 + "linux" 410 + ], 411 + "engines": { 412 + "node": ">=12" 413 + } 414 + }, 415 + "node_modules/@esbuild/linux-s390x": { 416 + "version": "0.17.19", 417 + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.17.19.tgz", 418 + "integrity": "sha512-IbFsFbxMWLuKEbH+7sTkKzL6NJmG2vRyy6K7JJo55w+8xDk7RElYn6xvXtDW8HCfoKBFK69f3pgBJSUSQPr+4Q==", 419 + "cpu": [ 420 + "s390x" 421 + ], 422 + "dev": true, 423 + "license": "MIT", 424 + "optional": true, 425 + "os": [ 426 + "linux" 427 + ], 428 + "engines": { 429 + "node": ">=12" 430 + } 431 + }, 432 + "node_modules/@esbuild/linux-x64": { 433 + "version": "0.17.19", 434 + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.17.19.tgz", 435 + "integrity": "sha512-68ngA9lg2H6zkZcyp22tsVt38mlhWde8l3eJLWkyLrp4HwMUr3c1s/M2t7+kHIhvMjglIBrFpncX1SzMckomGw==", 436 + "cpu": [ 437 + "x64" 438 + ], 439 + "dev": true, 440 + "license": "MIT", 441 + "optional": true, 442 + "os": [ 443 + "linux" 444 + ], 445 + "engines": { 446 + "node": ">=12" 447 + } 448 + }, 449 + "node_modules/@esbuild/netbsd-x64": { 450 + "version": "0.17.19", 451 + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.17.19.tgz", 452 + "integrity": "sha512-CwFq42rXCR8TYIjIfpXCbRX0rp1jo6cPIUPSaWwzbVI4aOfX96OXY8M6KNmtPcg7QjYeDmN+DD0Wp3LaBOLf4Q==", 453 + "cpu": [ 454 + "x64" 455 + ], 456 + "dev": true, 457 + "license": "MIT", 458 + "optional": true, 459 + "os": [ 460 + "netbsd" 461 + ], 462 + "engines": { 463 + "node": ">=12" 464 + } 465 + }, 466 + "node_modules/@esbuild/openbsd-x64": { 467 + "version": "0.17.19", 468 + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.17.19.tgz", 469 + "integrity": "sha512-cnq5brJYrSZ2CF6c35eCmviIN3k3RczmHz8eYaVlNasVqsNY+JKohZU5MKmaOI+KkllCdzOKKdPs762VCPC20g==", 470 + "cpu": [ 471 + "x64" 472 + ], 473 + "dev": true, 474 + "license": "MIT", 475 + "optional": true, 476 + "os": [ 477 + "openbsd" 478 + ], 479 + "engines": { 480 + "node": ">=12" 481 + } 482 + }, 483 + "node_modules/@esbuild/sunos-x64": { 484 + "version": "0.17.19", 485 + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.17.19.tgz", 486 + "integrity": "sha512-vCRT7yP3zX+bKWFeP/zdS6SqdWB8OIpaRq/mbXQxTGHnIxspRtigpkUcDMlSCOejlHowLqII7K2JKevwyRP2rg==", 487 + "cpu": [ 488 + "x64" 489 + ], 490 + "dev": true, 491 + "license": "MIT", 492 + "optional": true, 493 + "os": [ 494 + "sunos" 495 + ], 496 + "engines": { 497 + "node": ">=12" 498 + } 499 + }, 500 + "node_modules/@esbuild/win32-arm64": { 501 + "version": "0.17.19", 502 + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.17.19.tgz", 503 + "integrity": "sha512-yYx+8jwowUstVdorcMdNlzklLYhPxjniHWFKgRqH7IFlUEa0Umu3KuYplf1HUZZ422e3NU9F4LGb+4O0Kdcaag==", 504 + "cpu": [ 505 + "arm64" 506 + ], 507 + "dev": true, 508 + "license": "MIT", 509 + "optional": true, 510 + "os": [ 511 + "win32" 512 + ], 513 + "engines": { 514 + "node": ">=12" 515 + } 516 + }, 517 + "node_modules/@esbuild/win32-ia32": { 518 + "version": "0.17.19", 519 + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.17.19.tgz", 520 + "integrity": "sha512-eggDKanJszUtCdlVs0RB+h35wNlb5v4TWEkq4vZcmVt5u/HiDZrTXe2bWFQUez3RgNHwx/x4sk5++4NSSicKkw==", 521 + "cpu": [ 522 + "ia32" 523 + ], 524 + "dev": true, 525 + "license": "MIT", 526 + "optional": true, 527 + "os": [ 528 + "win32" 529 + ], 530 + "engines": { 531 + "node": ">=12" 532 + } 533 + }, 534 + "node_modules/@esbuild/win32-x64": { 535 + "version": "0.17.19", 536 + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.17.19.tgz", 537 + "integrity": "sha512-lAhycmKnVOuRYNtRtatQR1LPQf2oYCkRGkSFnseDAKPl8lu5SOsK/e1sXe5a0Pc5kHIHe6P2I/ilntNv2xf3cA==", 538 + "cpu": [ 539 + "x64" 540 + ], 541 + "dev": true, 542 + "license": "MIT", 543 + "optional": true, 544 + "os": [ 545 + "win32" 546 + ], 547 + "engines": { 548 + "node": ">=12" 549 + } 550 + }, 551 + "node_modules/@fastify/busboy": { 552 + "version": "2.1.1", 553 + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", 554 + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", 555 + "dev": true, 556 + "license": "MIT", 557 + "engines": { 558 + "node": ">=14" 559 + } 560 + }, 561 + "node_modules/@img/sharp-darwin-arm64": { 562 + "version": "0.33.5", 563 + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", 564 + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", 565 + "cpu": [ 566 + "arm64" 567 + ], 568 + "dev": true, 569 + "license": "Apache-2.0", 570 + "optional": true, 571 + "os": [ 572 + "darwin" 573 + ], 574 + "engines": { 575 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 576 + }, 577 + "funding": { 578 + "url": "https://opencollective.com/libvips" 579 + }, 580 + "optionalDependencies": { 581 + "@img/sharp-libvips-darwin-arm64": "1.0.4" 582 + } 583 + }, 584 + "node_modules/@img/sharp-darwin-x64": { 585 + "version": "0.33.5", 586 + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", 587 + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", 588 + "cpu": [ 589 + "x64" 590 + ], 591 + "dev": true, 592 + "license": "Apache-2.0", 593 + "optional": true, 594 + "os": [ 595 + "darwin" 596 + ], 597 + "engines": { 598 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 599 + }, 600 + "funding": { 601 + "url": "https://opencollective.com/libvips" 602 + }, 603 + "optionalDependencies": { 604 + "@img/sharp-libvips-darwin-x64": "1.0.4" 605 + } 606 + }, 607 + "node_modules/@img/sharp-libvips-darwin-arm64": { 608 + "version": "1.0.4", 609 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", 610 + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", 611 + "cpu": [ 612 + "arm64" 613 + ], 614 + "dev": true, 615 + "license": "LGPL-3.0-or-later", 616 + "optional": true, 617 + "os": [ 618 + "darwin" 619 + ], 620 + "funding": { 621 + "url": "https://opencollective.com/libvips" 622 + } 623 + }, 624 + "node_modules/@img/sharp-libvips-darwin-x64": { 625 + "version": "1.0.4", 626 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", 627 + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", 628 + "cpu": [ 629 + "x64" 630 + ], 631 + "dev": true, 632 + "license": "LGPL-3.0-or-later", 633 + "optional": true, 634 + "os": [ 635 + "darwin" 636 + ], 637 + "funding": { 638 + "url": "https://opencollective.com/libvips" 639 + } 640 + }, 641 + "node_modules/@img/sharp-libvips-linux-arm": { 642 + "version": "1.0.5", 643 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", 644 + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", 645 + "cpu": [ 646 + "arm" 647 + ], 648 + "dev": true, 649 + "license": "LGPL-3.0-or-later", 650 + "optional": true, 651 + "os": [ 652 + "linux" 653 + ], 654 + "funding": { 655 + "url": "https://opencollective.com/libvips" 656 + } 657 + }, 658 + "node_modules/@img/sharp-libvips-linux-arm64": { 659 + "version": "1.0.4", 660 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", 661 + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", 662 + "cpu": [ 663 + "arm64" 664 + ], 665 + "dev": true, 666 + "license": "LGPL-3.0-or-later", 667 + "optional": true, 668 + "os": [ 669 + "linux" 670 + ], 671 + "funding": { 672 + "url": "https://opencollective.com/libvips" 673 + } 674 + }, 675 + "node_modules/@img/sharp-libvips-linux-s390x": { 676 + "version": "1.0.4", 677 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", 678 + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", 679 + "cpu": [ 680 + "s390x" 681 + ], 682 + "dev": true, 683 + "license": "LGPL-3.0-or-later", 684 + "optional": true, 685 + "os": [ 686 + "linux" 687 + ], 688 + "funding": { 689 + "url": "https://opencollective.com/libvips" 690 + } 691 + }, 692 + "node_modules/@img/sharp-libvips-linux-x64": { 693 + "version": "1.0.4", 694 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", 695 + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", 696 + "cpu": [ 697 + "x64" 698 + ], 699 + "dev": true, 700 + "license": "LGPL-3.0-or-later", 701 + "optional": true, 702 + "os": [ 703 + "linux" 704 + ], 705 + "funding": { 706 + "url": "https://opencollective.com/libvips" 707 + } 708 + }, 709 + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { 710 + "version": "1.0.4", 711 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", 712 + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", 713 + "cpu": [ 714 + "arm64" 715 + ], 716 + "dev": true, 717 + "license": "LGPL-3.0-or-later", 718 + "optional": true, 719 + "os": [ 720 + "linux" 721 + ], 722 + "funding": { 723 + "url": "https://opencollective.com/libvips" 724 + } 725 + }, 726 + "node_modules/@img/sharp-libvips-linuxmusl-x64": { 727 + "version": "1.0.4", 728 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", 729 + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", 730 + "cpu": [ 731 + "x64" 732 + ], 733 + "dev": true, 734 + "license": "LGPL-3.0-or-later", 735 + "optional": true, 736 + "os": [ 737 + "linux" 738 + ], 739 + "funding": { 740 + "url": "https://opencollective.com/libvips" 741 + } 742 + }, 743 + "node_modules/@img/sharp-linux-arm": { 744 + "version": "0.33.5", 745 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", 746 + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", 747 + "cpu": [ 748 + "arm" 749 + ], 750 + "dev": true, 751 + "license": "Apache-2.0", 752 + "optional": true, 753 + "os": [ 754 + "linux" 755 + ], 756 + "engines": { 757 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 758 + }, 759 + "funding": { 760 + "url": "https://opencollective.com/libvips" 761 + }, 762 + "optionalDependencies": { 763 + "@img/sharp-libvips-linux-arm": "1.0.5" 764 + } 765 + }, 766 + "node_modules/@img/sharp-linux-arm64": { 767 + "version": "0.33.5", 768 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", 769 + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", 770 + "cpu": [ 771 + "arm64" 772 + ], 773 + "dev": true, 774 + "license": "Apache-2.0", 775 + "optional": true, 776 + "os": [ 777 + "linux" 778 + ], 779 + "engines": { 780 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 781 + }, 782 + "funding": { 783 + "url": "https://opencollective.com/libvips" 784 + }, 785 + "optionalDependencies": { 786 + "@img/sharp-libvips-linux-arm64": "1.0.4" 787 + } 788 + }, 789 + "node_modules/@img/sharp-linux-s390x": { 790 + "version": "0.33.5", 791 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", 792 + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", 793 + "cpu": [ 794 + "s390x" 795 + ], 796 + "dev": true, 797 + "license": "Apache-2.0", 798 + "optional": true, 799 + "os": [ 800 + "linux" 801 + ], 802 + "engines": { 803 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 804 + }, 805 + "funding": { 806 + "url": "https://opencollective.com/libvips" 807 + }, 808 + "optionalDependencies": { 809 + "@img/sharp-libvips-linux-s390x": "1.0.4" 810 + } 811 + }, 812 + "node_modules/@img/sharp-linux-x64": { 813 + "version": "0.33.5", 814 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", 815 + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", 816 + "cpu": [ 817 + "x64" 818 + ], 819 + "dev": true, 820 + "license": "Apache-2.0", 821 + "optional": true, 822 + "os": [ 823 + "linux" 824 + ], 825 + "engines": { 826 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 827 + }, 828 + "funding": { 829 + "url": "https://opencollective.com/libvips" 830 + }, 831 + "optionalDependencies": { 832 + "@img/sharp-libvips-linux-x64": "1.0.4" 833 + } 834 + }, 835 + "node_modules/@img/sharp-linuxmusl-arm64": { 836 + "version": "0.33.5", 837 + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", 838 + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", 839 + "cpu": [ 840 + "arm64" 841 + ], 842 + "dev": true, 843 + "license": "Apache-2.0", 844 + "optional": true, 845 + "os": [ 846 + "linux" 847 + ], 848 + "engines": { 849 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 850 + }, 851 + "funding": { 852 + "url": "https://opencollective.com/libvips" 853 + }, 854 + "optionalDependencies": { 855 + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" 856 + } 857 + }, 858 + "node_modules/@img/sharp-linuxmusl-x64": { 859 + "version": "0.33.5", 860 + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", 861 + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", 862 + "cpu": [ 863 + "x64" 864 + ], 865 + "dev": true, 866 + "license": "Apache-2.0", 867 + "optional": true, 868 + "os": [ 869 + "linux" 870 + ], 871 + "engines": { 872 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 873 + }, 874 + "funding": { 875 + "url": "https://opencollective.com/libvips" 876 + }, 877 + "optionalDependencies": { 878 + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" 879 + } 880 + }, 881 + "node_modules/@img/sharp-wasm32": { 882 + "version": "0.33.5", 883 + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", 884 + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", 885 + "cpu": [ 886 + "wasm32" 887 + ], 888 + "dev": true, 889 + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", 890 + "optional": true, 891 + "dependencies": { 892 + "@emnapi/runtime": "^1.2.0" 893 + }, 894 + "engines": { 895 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 896 + }, 897 + "funding": { 898 + "url": "https://opencollective.com/libvips" 899 + } 900 + }, 901 + "node_modules/@img/sharp-win32-ia32": { 902 + "version": "0.33.5", 903 + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", 904 + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", 905 + "cpu": [ 906 + "ia32" 907 + ], 908 + "dev": true, 909 + "license": "Apache-2.0 AND LGPL-3.0-or-later", 910 + "optional": true, 911 + "os": [ 912 + "win32" 913 + ], 914 + "engines": { 915 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 916 + }, 917 + "funding": { 918 + "url": "https://opencollective.com/libvips" 919 + } 920 + }, 921 + "node_modules/@img/sharp-win32-x64": { 922 + "version": "0.33.5", 923 + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", 924 + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", 925 + "cpu": [ 926 + "x64" 927 + ], 928 + "dev": true, 929 + "license": "Apache-2.0 AND LGPL-3.0-or-later", 930 + "optional": true, 931 + "os": [ 932 + "win32" 933 + ], 934 + "engines": { 935 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 936 + }, 937 + "funding": { 938 + "url": "https://opencollective.com/libvips" 939 + } 940 + }, 941 + "node_modules/@jridgewell/resolve-uri": { 942 + "version": "3.1.2", 943 + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", 944 + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", 945 + "dev": true, 946 + "license": "MIT", 947 + "engines": { 948 + "node": ">=6.0.0" 949 + } 950 + }, 951 + "node_modules/@jridgewell/sourcemap-codec": { 952 + "version": "1.5.5", 953 + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", 954 + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", 955 + "dev": true, 956 + "license": "MIT" 957 + }, 958 + "node_modules/@jridgewell/trace-mapping": { 959 + "version": "0.3.9", 960 + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", 961 + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", 962 + "dev": true, 963 + "license": "MIT", 964 + "dependencies": { 965 + "@jridgewell/resolve-uri": "^3.0.3", 966 + "@jridgewell/sourcemap-codec": "^1.4.10" 967 + } 968 + }, 969 + "node_modules/acorn": { 970 + "version": "8.14.0", 971 + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", 972 + "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", 973 + "dev": true, 974 + "license": "MIT", 975 + "bin": { 976 + "acorn": "bin/acorn" 977 + }, 978 + "engines": { 979 + "node": ">=0.4.0" 980 + } 981 + }, 982 + "node_modules/acorn-walk": { 983 + "version": "8.3.2", 984 + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", 985 + "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", 986 + "dev": true, 987 + "license": "MIT", 988 + "engines": { 989 + "node": ">=0.4.0" 990 + } 991 + }, 992 + "node_modules/as-table": { 993 + "version": "1.0.55", 994 + "resolved": "https://registry.npmjs.org/as-table/-/as-table-1.0.55.tgz", 995 + "integrity": "sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==", 996 + "dev": true, 997 + "license": "MIT", 998 + "dependencies": { 999 + "printable-characters": "^1.0.42" 1000 + } 1001 + }, 1002 + "node_modules/blake3-wasm": { 1003 + "version": "2.1.5", 1004 + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", 1005 + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", 1006 + "dev": true, 1007 + "license": "MIT" 1008 + }, 1009 + "node_modules/color": { 1010 + "version": "4.2.3", 1011 + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", 1012 + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", 1013 + "dev": true, 1014 + "license": "MIT", 1015 + "optional": true, 1016 + "dependencies": { 1017 + "color-convert": "^2.0.1", 1018 + "color-string": "^1.9.0" 1019 + }, 1020 + "engines": { 1021 + "node": ">=12.5.0" 1022 + } 1023 + }, 1024 + "node_modules/color-convert": { 1025 + "version": "2.0.1", 1026 + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", 1027 + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", 1028 + "dev": true, 1029 + "license": "MIT", 1030 + "optional": true, 1031 + "dependencies": { 1032 + "color-name": "~1.1.4" 1033 + }, 1034 + "engines": { 1035 + "node": ">=7.0.0" 1036 + } 1037 + }, 1038 + "node_modules/color-name": { 1039 + "version": "1.1.4", 1040 + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", 1041 + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", 1042 + "dev": true, 1043 + "license": "MIT", 1044 + "optional": true 1045 + }, 1046 + "node_modules/color-string": { 1047 + "version": "1.9.1", 1048 + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", 1049 + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", 1050 + "dev": true, 1051 + "license": "MIT", 1052 + "optional": true, 1053 + "dependencies": { 1054 + "color-name": "^1.0.0", 1055 + "simple-swizzle": "^0.2.2" 1056 + } 1057 + }, 1058 + "node_modules/cookie": { 1059 + "version": "0.7.2", 1060 + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", 1061 + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", 1062 + "dev": true, 1063 + "license": "MIT", 1064 + "engines": { 1065 + "node": ">= 0.6" 1066 + } 1067 + }, 1068 + "node_modules/data-uri-to-buffer": { 1069 + "version": "2.0.2", 1070 + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-2.0.2.tgz", 1071 + "integrity": "sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==", 1072 + "dev": true, 1073 + "license": "MIT" 1074 + }, 1075 + "node_modules/defu": { 1076 + "version": "6.1.4", 1077 + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", 1078 + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", 1079 + "dev": true, 1080 + "license": "MIT" 1081 + }, 1082 + "node_modules/detect-libc": { 1083 + "version": "2.1.2", 1084 + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", 1085 + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", 1086 + "dev": true, 1087 + "license": "Apache-2.0", 1088 + "optional": true, 1089 + "engines": { 1090 + "node": ">=8" 1091 + } 1092 + }, 1093 + "node_modules/esbuild": { 1094 + "version": "0.17.19", 1095 + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.17.19.tgz", 1096 + "integrity": "sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw==", 1097 + "dev": true, 1098 + "hasInstallScript": true, 1099 + "license": "MIT", 1100 + "bin": { 1101 + "esbuild": "bin/esbuild" 1102 + }, 1103 + "engines": { 1104 + "node": ">=12" 1105 + }, 1106 + "optionalDependencies": { 1107 + "@esbuild/android-arm": "0.17.19", 1108 + "@esbuild/android-arm64": "0.17.19", 1109 + "@esbuild/android-x64": "0.17.19", 1110 + "@esbuild/darwin-arm64": "0.17.19", 1111 + "@esbuild/darwin-x64": "0.17.19", 1112 + "@esbuild/freebsd-arm64": "0.17.19", 1113 + "@esbuild/freebsd-x64": "0.17.19", 1114 + "@esbuild/linux-arm": "0.17.19", 1115 + "@esbuild/linux-arm64": "0.17.19", 1116 + "@esbuild/linux-ia32": "0.17.19", 1117 + "@esbuild/linux-loong64": "0.17.19", 1118 + "@esbuild/linux-mips64el": "0.17.19", 1119 + "@esbuild/linux-ppc64": "0.17.19", 1120 + "@esbuild/linux-riscv64": "0.17.19", 1121 + "@esbuild/linux-s390x": "0.17.19", 1122 + "@esbuild/linux-x64": "0.17.19", 1123 + "@esbuild/netbsd-x64": "0.17.19", 1124 + "@esbuild/openbsd-x64": "0.17.19", 1125 + "@esbuild/sunos-x64": "0.17.19", 1126 + "@esbuild/win32-arm64": "0.17.19", 1127 + "@esbuild/win32-ia32": "0.17.19", 1128 + "@esbuild/win32-x64": "0.17.19" 1129 + } 1130 + }, 1131 + "node_modules/escape-string-regexp": { 1132 + "version": "4.0.0", 1133 + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", 1134 + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", 1135 + "dev": true, 1136 + "license": "MIT", 1137 + "engines": { 1138 + "node": ">=10" 1139 + }, 1140 + "funding": { 1141 + "url": "https://github.com/sponsors/sindresorhus" 1142 + } 1143 + }, 1144 + "node_modules/estree-walker": { 1145 + "version": "0.6.1", 1146 + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-0.6.1.tgz", 1147 + "integrity": "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==", 1148 + "dev": true, 1149 + "license": "MIT" 1150 + }, 1151 + "node_modules/exit-hook": { 1152 + "version": "2.2.1", 1153 + "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-2.2.1.tgz", 1154 + "integrity": "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==", 1155 + "dev": true, 1156 + "license": "MIT", 1157 + "engines": { 1158 + "node": ">=6" 1159 + }, 1160 + "funding": { 1161 + "url": "https://github.com/sponsors/sindresorhus" 1162 + } 1163 + }, 1164 + "node_modules/exsolve": { 1165 + "version": "1.0.8", 1166 + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", 1167 + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", 1168 + "dev": true, 1169 + "license": "MIT" 1170 + }, 1171 + "node_modules/fsevents": { 1172 + "version": "2.3.3", 1173 + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", 1174 + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", 1175 + "dev": true, 1176 + "hasInstallScript": true, 1177 + "license": "MIT", 1178 + "optional": true, 1179 + "os": [ 1180 + "darwin" 1181 + ], 1182 + "engines": { 1183 + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" 1184 + } 1185 + }, 1186 + "node_modules/get-source": { 1187 + "version": "2.0.12", 1188 + "resolved": "https://registry.npmjs.org/get-source/-/get-source-2.0.12.tgz", 1189 + "integrity": "sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w==", 1190 + "dev": true, 1191 + "license": "Unlicense", 1192 + "dependencies": { 1193 + "data-uri-to-buffer": "^2.0.0", 1194 + "source-map": "^0.6.1" 1195 + } 1196 + }, 1197 + "node_modules/glob-to-regexp": { 1198 + "version": "0.4.1", 1199 + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", 1200 + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", 1201 + "dev": true, 1202 + "license": "BSD-2-Clause" 1203 + }, 1204 + "node_modules/is-arrayish": { 1205 + "version": "0.3.4", 1206 + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", 1207 + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", 1208 + "dev": true, 1209 + "license": "MIT", 1210 + "optional": true 1211 + }, 1212 + "node_modules/magic-string": { 1213 + "version": "0.25.9", 1214 + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", 1215 + "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==", 1216 + "dev": true, 1217 + "license": "MIT", 1218 + "dependencies": { 1219 + "sourcemap-codec": "^1.4.8" 1220 + } 1221 + }, 1222 + "node_modules/mime": { 1223 + "version": "3.0.0", 1224 + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", 1225 + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", 1226 + "dev": true, 1227 + "license": "MIT", 1228 + "bin": { 1229 + "mime": "cli.js" 1230 + }, 1231 + "engines": { 1232 + "node": ">=10.0.0" 1233 + } 1234 + }, 1235 + "node_modules/miniflare": { 1236 + "version": "3.20250718.3", 1237 + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-3.20250718.3.tgz", 1238 + "integrity": "sha512-JuPrDJhwLrNLEJiNLWO7ZzJrv/Vv9kZuwMYCfv0LskQDM6Eonw4OvywO3CH/wCGjgHzha/qyjUh8JQ068TjDgQ==", 1239 + "dev": true, 1240 + "license": "MIT", 1241 + "dependencies": { 1242 + "@cspotcode/source-map-support": "0.8.1", 1243 + "acorn": "8.14.0", 1244 + "acorn-walk": "8.3.2", 1245 + "exit-hook": "2.2.1", 1246 + "glob-to-regexp": "0.4.1", 1247 + "stoppable": "1.1.0", 1248 + "undici": "^5.28.5", 1249 + "workerd": "1.20250718.0", 1250 + "ws": "8.18.0", 1251 + "youch": "3.3.4", 1252 + "zod": "3.22.3" 1253 + }, 1254 + "bin": { 1255 + "miniflare": "bootstrap.js" 1256 + }, 1257 + "engines": { 1258 + "node": ">=16.13" 1259 + } 1260 + }, 1261 + "node_modules/mustache": { 1262 + "version": "4.2.0", 1263 + "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", 1264 + "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", 1265 + "dev": true, 1266 + "license": "MIT", 1267 + "bin": { 1268 + "mustache": "bin/mustache" 1269 + } 1270 + }, 1271 + "node_modules/ohash": { 1272 + "version": "2.0.11", 1273 + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", 1274 + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", 1275 + "dev": true, 1276 + "license": "MIT" 1277 + }, 1278 + "node_modules/path-to-regexp": { 1279 + "version": "6.3.0", 1280 + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", 1281 + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", 1282 + "dev": true, 1283 + "license": "MIT" 1284 + }, 1285 + "node_modules/pathe": { 1286 + "version": "2.0.3", 1287 + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", 1288 + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", 1289 + "dev": true, 1290 + "license": "MIT" 1291 + }, 1292 + "node_modules/printable-characters": { 1293 + "version": "1.0.42", 1294 + "resolved": "https://registry.npmjs.org/printable-characters/-/printable-characters-1.0.42.tgz", 1295 + "integrity": "sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==", 1296 + "dev": true, 1297 + "license": "Unlicense" 1298 + }, 1299 + "node_modules/rollup-plugin-inject": { 1300 + "version": "3.0.2", 1301 + "resolved": "https://registry.npmjs.org/rollup-plugin-inject/-/rollup-plugin-inject-3.0.2.tgz", 1302 + "integrity": "sha512-ptg9PQwzs3orn4jkgXJ74bfs5vYz1NCZlSQMBUA0wKcGp5i5pA1AO3fOUEte8enhGUC+iapTCzEWw2jEFFUO/w==", 1303 + "deprecated": "This package has been deprecated and is no longer maintained. Please use @rollup/plugin-inject.", 1304 + "dev": true, 1305 + "license": "MIT", 1306 + "dependencies": { 1307 + "estree-walker": "^0.6.1", 1308 + "magic-string": "^0.25.3", 1309 + "rollup-pluginutils": "^2.8.1" 1310 + } 1311 + }, 1312 + "node_modules/rollup-plugin-node-polyfills": { 1313 + "version": "0.2.1", 1314 + "resolved": "https://registry.npmjs.org/rollup-plugin-node-polyfills/-/rollup-plugin-node-polyfills-0.2.1.tgz", 1315 + "integrity": "sha512-4kCrKPTJ6sK4/gLL/U5QzVT8cxJcofO0OU74tnB19F40cmuAKSzH5/siithxlofFEjwvw1YAhPmbvGNA6jEroA==", 1316 + "dev": true, 1317 + "license": "MIT", 1318 + "dependencies": { 1319 + "rollup-plugin-inject": "^3.0.0" 1320 + } 1321 + }, 1322 + "node_modules/rollup-pluginutils": { 1323 + "version": "2.8.2", 1324 + "resolved": "https://registry.npmjs.org/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz", 1325 + "integrity": "sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==", 1326 + "dev": true, 1327 + "license": "MIT", 1328 + "dependencies": { 1329 + "estree-walker": "^0.6.1" 1330 + } 1331 + }, 1332 + "node_modules/semver": { 1333 + "version": "7.7.4", 1334 + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", 1335 + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", 1336 + "dev": true, 1337 + "license": "ISC", 1338 + "optional": true, 1339 + "bin": { 1340 + "semver": "bin/semver.js" 1341 + }, 1342 + "engines": { 1343 + "node": ">=10" 1344 + } 1345 + }, 1346 + "node_modules/sharp": { 1347 + "version": "0.33.5", 1348 + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", 1349 + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", 1350 + "dev": true, 1351 + "hasInstallScript": true, 1352 + "license": "Apache-2.0", 1353 + "optional": true, 1354 + "dependencies": { 1355 + "color": "^4.2.3", 1356 + "detect-libc": "^2.0.3", 1357 + "semver": "^7.6.3" 1358 + }, 1359 + "engines": { 1360 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 1361 + }, 1362 + "funding": { 1363 + "url": "https://opencollective.com/libvips" 1364 + }, 1365 + "optionalDependencies": { 1366 + "@img/sharp-darwin-arm64": "0.33.5", 1367 + "@img/sharp-darwin-x64": "0.33.5", 1368 + "@img/sharp-libvips-darwin-arm64": "1.0.4", 1369 + "@img/sharp-libvips-darwin-x64": "1.0.4", 1370 + "@img/sharp-libvips-linux-arm": "1.0.5", 1371 + "@img/sharp-libvips-linux-arm64": "1.0.4", 1372 + "@img/sharp-libvips-linux-s390x": "1.0.4", 1373 + "@img/sharp-libvips-linux-x64": "1.0.4", 1374 + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", 1375 + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", 1376 + "@img/sharp-linux-arm": "0.33.5", 1377 + "@img/sharp-linux-arm64": "0.33.5", 1378 + "@img/sharp-linux-s390x": "0.33.5", 1379 + "@img/sharp-linux-x64": "0.33.5", 1380 + "@img/sharp-linuxmusl-arm64": "0.33.5", 1381 + "@img/sharp-linuxmusl-x64": "0.33.5", 1382 + "@img/sharp-wasm32": "0.33.5", 1383 + "@img/sharp-win32-ia32": "0.33.5", 1384 + "@img/sharp-win32-x64": "0.33.5" 1385 + } 1386 + }, 1387 + "node_modules/simple-swizzle": { 1388 + "version": "0.2.4", 1389 + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", 1390 + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", 1391 + "dev": true, 1392 + "license": "MIT", 1393 + "optional": true, 1394 + "dependencies": { 1395 + "is-arrayish": "^0.3.1" 1396 + } 1397 + }, 1398 + "node_modules/source-map": { 1399 + "version": "0.6.1", 1400 + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", 1401 + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", 1402 + "dev": true, 1403 + "license": "BSD-3-Clause", 1404 + "engines": { 1405 + "node": ">=0.10.0" 1406 + } 1407 + }, 1408 + "node_modules/sourcemap-codec": { 1409 + "version": "1.4.8", 1410 + "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", 1411 + "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", 1412 + "deprecated": "Please use @jridgewell/sourcemap-codec instead", 1413 + "dev": true, 1414 + "license": "MIT" 1415 + }, 1416 + "node_modules/stacktracey": { 1417 + "version": "2.2.0", 1418 + "resolved": "https://registry.npmjs.org/stacktracey/-/stacktracey-2.2.0.tgz", 1419 + "integrity": "sha512-ETyQEz+CzXiLjEbyJqpbp+/T79RQD/6wqFucRBIlVNZfYq2Ay7wbretD4cxpbymZlaPWx58aIhPEY1Cr8DlVvg==", 1420 + "dev": true, 1421 + "license": "Unlicense", 1422 + "dependencies": { 1423 + "as-table": "^1.0.36", 1424 + "get-source": "^2.0.12" 1425 + } 1426 + }, 1427 + "node_modules/stoppable": { 1428 + "version": "1.1.0", 1429 + "resolved": "https://registry.npmjs.org/stoppable/-/stoppable-1.1.0.tgz", 1430 + "integrity": "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==", 1431 + "dev": true, 1432 + "license": "MIT", 1433 + "engines": { 1434 + "node": ">=4", 1435 + "npm": ">=6" 1436 + } 1437 + }, 1438 + "node_modules/tslib": { 1439 + "version": "2.8.1", 1440 + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", 1441 + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", 1442 + "dev": true, 1443 + "license": "0BSD", 1444 + "optional": true 1445 + }, 1446 + "node_modules/typescript": { 1447 + "version": "5.9.3", 1448 + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", 1449 + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", 1450 + "dev": true, 1451 + "license": "Apache-2.0", 1452 + "bin": { 1453 + "tsc": "bin/tsc", 1454 + "tsserver": "bin/tsserver" 1455 + }, 1456 + "engines": { 1457 + "node": ">=14.17" 1458 + } 1459 + }, 1460 + "node_modules/ufo": { 1461 + "version": "1.6.3", 1462 + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", 1463 + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", 1464 + "dev": true, 1465 + "license": "MIT" 1466 + }, 1467 + "node_modules/undici": { 1468 + "version": "5.29.0", 1469 + "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", 1470 + "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", 1471 + "dev": true, 1472 + "license": "MIT", 1473 + "dependencies": { 1474 + "@fastify/busboy": "^2.0.0" 1475 + }, 1476 + "engines": { 1477 + "node": ">=14.0" 1478 + } 1479 + }, 1480 + "node_modules/unenv": { 1481 + "version": "2.0.0-rc.14", 1482 + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.14.tgz", 1483 + "integrity": "sha512-od496pShMen7nOy5VmVJCnq8rptd45vh6Nx/r2iPbrba6pa6p+tS2ywuIHRZ/OBvSbQZB0kWvpO9XBNVFXHD3Q==", 1484 + "dev": true, 1485 + "license": "MIT", 1486 + "dependencies": { 1487 + "defu": "^6.1.4", 1488 + "exsolve": "^1.0.1", 1489 + "ohash": "^2.0.10", 1490 + "pathe": "^2.0.3", 1491 + "ufo": "^1.5.4" 1492 + } 1493 + }, 1494 + "node_modules/workerd": { 1495 + "version": "1.20250718.0", 1496 + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20250718.0.tgz", 1497 + "integrity": "sha512-kqkIJP/eOfDlUyBzU7joBg+tl8aB25gEAGqDap+nFWb+WHhnooxjGHgxPBy3ipw2hnShPFNOQt5lFRxbwALirg==", 1498 + "dev": true, 1499 + "hasInstallScript": true, 1500 + "license": "Apache-2.0", 1501 + "bin": { 1502 + "workerd": "bin/workerd" 1503 + }, 1504 + "engines": { 1505 + "node": ">=16" 1506 + }, 1507 + "optionalDependencies": { 1508 + "@cloudflare/workerd-darwin-64": "1.20250718.0", 1509 + "@cloudflare/workerd-darwin-arm64": "1.20250718.0", 1510 + "@cloudflare/workerd-linux-64": "1.20250718.0", 1511 + "@cloudflare/workerd-linux-arm64": "1.20250718.0", 1512 + "@cloudflare/workerd-windows-64": "1.20250718.0" 1513 + } 1514 + }, 1515 + "node_modules/wrangler": { 1516 + "version": "3.114.17", 1517 + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-3.114.17.tgz", 1518 + "integrity": "sha512-tAvf7ly+tB+zwwrmjsCyJ2pJnnc7SZhbnNwXbH+OIdVas3zTSmjcZOjmLKcGGptssAA3RyTKhcF9BvKZzMUycA==", 1519 + "dev": true, 1520 + "license": "MIT OR Apache-2.0", 1521 + "dependencies": { 1522 + "@cloudflare/kv-asset-handler": "0.3.4", 1523 + "@cloudflare/unenv-preset": "2.0.2", 1524 + "@esbuild-plugins/node-globals-polyfill": "0.2.3", 1525 + "@esbuild-plugins/node-modules-polyfill": "0.2.2", 1526 + "blake3-wasm": "2.1.5", 1527 + "esbuild": "0.17.19", 1528 + "miniflare": "3.20250718.3", 1529 + "path-to-regexp": "6.3.0", 1530 + "unenv": "2.0.0-rc.14", 1531 + "workerd": "1.20250718.0" 1532 + }, 1533 + "bin": { 1534 + "wrangler": "bin/wrangler.js", 1535 + "wrangler2": "bin/wrangler.js" 1536 + }, 1537 + "engines": { 1538 + "node": ">=16.17.0" 1539 + }, 1540 + "optionalDependencies": { 1541 + "fsevents": "~2.3.2", 1542 + "sharp": "^0.33.5" 1543 + }, 1544 + "peerDependencies": { 1545 + "@cloudflare/workers-types": "^4.20250408.0" 1546 + }, 1547 + "peerDependenciesMeta": { 1548 + "@cloudflare/workers-types": { 1549 + "optional": true 1550 + } 1551 + } 1552 + }, 1553 + "node_modules/ws": { 1554 + "version": "8.18.0", 1555 + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", 1556 + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", 1557 + "dev": true, 1558 + "license": "MIT", 1559 + "engines": { 1560 + "node": ">=10.0.0" 1561 + }, 1562 + "peerDependencies": { 1563 + "bufferutil": "^4.0.1", 1564 + "utf-8-validate": ">=5.0.2" 1565 + }, 1566 + "peerDependenciesMeta": { 1567 + "bufferutil": { 1568 + "optional": true 1569 + }, 1570 + "utf-8-validate": { 1571 + "optional": true 1572 + } 1573 + } 1574 + }, 1575 + "node_modules/youch": { 1576 + "version": "3.3.4", 1577 + "resolved": "https://registry.npmjs.org/youch/-/youch-3.3.4.tgz", 1578 + "integrity": "sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg==", 1579 + "dev": true, 1580 + "license": "MIT", 1581 + "dependencies": { 1582 + "cookie": "^0.7.1", 1583 + "mustache": "^4.2.0", 1584 + "stacktracey": "^2.1.8" 1585 + } 1586 + }, 1587 + "node_modules/zod": { 1588 + "version": "3.22.3", 1589 + "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.3.tgz", 1590 + "integrity": "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==", 1591 + "dev": true, 1592 + "license": "MIT", 1593 + "funding": { 1594 + "url": "https://github.com/sponsors/colinhacks" 1595 + } 1596 + } 1597 + } 1598 + }
+12
workers/gymtracker-ads-api/seed-ad-active.json
··· 1 + { 2 + "id": "test-active-001", 3 + "tier": "text", 4 + "sponsor": "Test Sponsor", 5 + "headline": "Test ad for end-to-end verification.", 6 + "subline": "Use this to confirm the app displays ads correctly.", 7 + "cta": "Learn more", 8 + "destination_url": "https://gymtracker.jackhannon.net/docs/privacy-policy.html", 9 + "active": true, 10 + "placement": "home_feed", 11 + "creative_version": "1" 12 + }
+1 -1
workers/gymtracker-ads-api/seed-ad.json
··· 3 3 "tier": "text", 4 4 "sponsor": "Placeholder", 5 5 "headline": "Configure your first sponsor ad in the admin.", 6 - "subline": "Visit jackhannon.net/gymtracker-ads-admin.html", 6 + "subline": "Manage ads at gymtracker.jackhannon.net/admin", 7 7 "cta": "Get started", 8 8 "destination_url": "https://gymtracker.jackhannon.net/docs/privacy-policy.html", 9 9 "active": false,
+1059
workers/gymtracker-ads-api/src/admin-html.ts
··· 1 + /** Admin UI HTML — served at /admin, uses Cloudflare Access for auth. */ 2 + export const ADMIN_HTML = `<!DOCTYPE html> 3 + <html lang="en"> 4 + <head> 5 + <meta charset="utf-8"> 6 + <meta name="viewport" content="width=device-width, initial-scale=1"> 7 + <title>Gym Tracker Ads Admin</title> 8 + <style>@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&display=swap');</style> 9 + </head> 10 + <body> 11 + <div class="dashboard-wrap"> 12 + <div class="topbar"> 13 + <span class="topbar-title">Gym Tracker</span> 14 + <span class="topbar-sep">/</span> 15 + <span class="topbar-sub">ads admin</span> 16 + <div class="status"> 17 + <div class="dot" id="statusDot"></div> 18 + <span class="status-text" id="fetchStatus">—</span> 19 + </div> 20 + </div> 21 + <div class="sidebar"> 22 + <div class="group"> 23 + <div class="group-label">status</div> 24 + <div class="kv"><span class="kv-k">total</span><span class="kv-v" id="sidebarTotal">0</span></div> 25 + <div class="kv"><span class="kv-k">live</span><span class="kv-v" id="sidebarLive">0</span></div> 26 + <div class="kv"><span class="kv-k">scheduled</span><span class="kv-v" id="sidebarScheduled">0</span></div> 27 + <div class="kv"><span class="kv-k">ended</span><span class="kv-v" id="sidebarEnded">0</span></div> 28 + </div> 29 + <button type="button" id="refreshBtn" class="primary">Refresh</button> 30 + <button type="button" id="newAdBtn" class="primary">New ad</button> 31 + </div> 32 + <div class="main"> 33 + <div class="tabs"> 34 + <button type="button" class="tab active" id="tabBtnOverview">overview</button> 35 + <button type="button" class="tab" id="tabBtnSchedule">schedule <span class="badge" id="tabScheduleBadge">0</span></button> 36 + </div> 37 + <div class="panel active" id="tab-overview"> 38 + <div class="overview-status-banner" id="overviewStatusBanner"> 39 + <div class="status-banner-dot pending" id="overviewStatusDot"></div> 40 + <div> 41 + <div class="status-banner-text" id="overviewStatusText">Loading…</div> 42 + <div class="status-banner-sub" id="overviewStatusSub">Fetching ads</div> 43 + </div> 44 + </div> 45 + <div id="calendarWrap" class="calendar-wrap" hidden> 46 + <div class="calendar-header"> 47 + <button type="button" id="calPrev" class="cal-nav" title="Previous month">◀</button> 48 + <h3 class="cal-month-label" id="calMonthLabel"></h3> 49 + <button type="button" id="calNext" class="cal-nav" title="Next month">▶</button> 50 + </div> 51 + <div class="calendar-grid"> 52 + <div class="cal-weekdays"> 53 + <span>Sun</span><span>Mon</span><span>Tue</span><span>Wed</span><span>Thu</span><span>Fri</span><span>Sat</span> 54 + </div> 55 + <div id="calDays" class="cal-days"></div> 56 + </div> 57 + <div class="cal-legend"> 58 + <span class="cal-legend-item"><span class="cal-legend-dot live"></span> live</span> 59 + <span class="cal-legend-item"><span class="cal-legend-dot scheduled"></span> scheduled</span> 60 + <span class="cal-legend-item"><span class="cal-legend-dot ended"></span> ended</span> 61 + <span class="cal-legend-item"><span class="cal-legend-dot paused"></span> paused</span> 62 + </div> 63 + </div> 64 + <div class="overview-actions"> 65 + <button type="button" id="overviewRefreshBtn">Refresh</button> 66 + <button type="button" id="overviewNewAdBtn" class="btn-as-link">New ad</button> 67 + </div> 68 + </div> 69 + <div class="panel" id="tab-schedule"> 70 + <section class="admin-section"> 71 + <div class="group-label" id="adsHeader">Ads</div> 72 + <div id="adCards" class="ad-cards"></div> 73 + </section> 74 + 75 + <div class="admin-editor-grid"> 76 + <form id="adForm" class="admin-form"> 77 + <div class="group"> 78 + <div class="group-label">Identity</div> 79 + <div class="field-label">ID (unique, e.g. sponsor-2025-q1)</div> 80 + <input type="text" id="id" name="id" required> 81 + <div class="field-label">Tier</div> 82 + <select id="tier" name="tier"> 83 + <option value="text">text</option> 84 + <option value="banner">banner</option> 85 + <option value="feature">feature</option> 86 + </select> 87 + <label class="checkbox-label"><input type="checkbox" id="active" name="active" checked> Active</label> 88 + </div> 89 + <div class="group"> 90 + <div class="group-label">Creative</div> 91 + <div class="field-label">Sponsor</div> 92 + <input type="text" id="sponsor" name="sponsor" required> 93 + <div class="field-label">Headline</div> 94 + <input type="text" id="headline" name="headline" required> 95 + <div class="field-label">Subline (optional)</div> 96 + <input type="text" id="subline" name="subline"> 97 + <div class="field-label">CTA</div> 98 + <input type="text" id="cta" name="cta" required> 99 + <div class="field-label">Image URL (required for banner/feature)</div> 100 + <input type="url" id="image_url" name="image_url" placeholder="https://"> 101 + <div class="field-label">Logo URL (optional)</div> 102 + <input type="url" id="logo_url" name="logo_url" placeholder="https://"> 103 + </div> 104 + <div class="group"> 105 + <div class="group-label">Schedule</div> 106 + <div class="date-presets"> 107 + <button type="button" data-preset="7d">Next 7 days</button> 108 + <button type="button" data-preset="30d">Next 30 days</button> 109 + </div> 110 + <div class="field-label">Start at (optional)</div> 111 + <input type="datetime-local" id="start_at" name="start_at"> 112 + <div class="field-label">End at (optional)</div> 113 + <input type="datetime-local" id="end_at" name="end_at"> 114 + <div id="clearDatesWrap" class="clear-dates-wrap" hidden> 115 + <button type="button" id="clearDatesBtn" class="clear-dates-btn">Clear</button> 116 + </div> 117 + </div> 118 + <div class="group"> 119 + <div class="group-label">Advanced</div> 120 + <div class="field-label">Destination URL (HTTPS)</div> 121 + <input type="url" id="destination_url" name="destination_url" required placeholder="https://"> 122 + <div class="field-label">Placement</div> 123 + <input type="text" id="placement" name="placement" value="home_feed"> 124 + <div class="field-label">Creative version (optional)</div> 125 + <input type="text" id="creative_version" name="creative_version"> 126 + </div> 127 + <div class="form-actions"> 128 + <button type="submit" id="saveBtn" class="primary">Save</button> 129 + <button type="button" id="cloneBtn">Clone</button> 130 + <button type="button" id="deleteBtn" class="delete-btn" disabled>Delete</button> 131 + <span id="saveStatus" class="save-status"></span> 132 + </div> 133 + </form> 134 + <aside class="preview-pane"> 135 + <div id="adKpiBox" class="ad-kpi-box" hidden> 136 + <div class="group-label">KPI (last 7d)</div> 137 + <div class="kpi-grid" id="adKpiGrid"></div> 138 + </div> 139 + <div class="group-label">Preview</div> 140 + <div id="adPreview" class="ad-preview"></div> 141 + </aside> 142 + </div> 143 + 144 + <p class="footer"><a href="https://jackhannon.net/">Back to jackhannon.net</a></p> 145 + </div> 146 + </div> 147 + </div> 148 + 149 + <script> 150 + const API_URL = '/api/admin/ads'; 151 + 152 + const refreshBtn = document.getElementById('refreshBtn'); 153 + const fetchStatus = document.getElementById('fetchStatus'); 154 + const adCards = document.getElementById('adCards'); 155 + const calendarWrap = document.getElementById('calendarWrap'); 156 + const calDays = document.getElementById('calDays'); 157 + const calMonthLabel = document.getElementById('calMonthLabel'); 158 + 159 + let calViewYear = new Date().getFullYear(); 160 + let calViewMonth = new Date().getMonth(); 161 + const saveBtn = document.getElementById('saveBtn'); 162 + const saveStatus = document.getElementById('saveStatus'); 163 + const cloneBtn = document.getElementById('cloneBtn'); 164 + const deleteBtn = document.getElementById('deleteBtn'); 165 + const form = document.getElementById('adForm'); 166 + const adPreview = document.getElementById('adPreview'); 167 + 168 + let scheduledAds = []; 169 + let selectedIndex = -1; 170 + let formDirty = false; 171 + let previewDebounce = null; 172 + let perAdStats = []; 173 + 174 + function setStatus(el, msg, ok) { 175 + el.textContent = msg; 176 + const base = el.id === 'saveStatus' ? 'save-status' : 'status-text'; 177 + el.className = base + (ok === false ? ' status-err' : ok === true ? ' status-ok' : ''); 178 + if (el.id === 'fetchStatus') { 179 + const dot = document.getElementById('statusDot'); 180 + if (dot) dot.className = 'dot' + (ok === true ? ' on' : ok === false ? ' err' : ''); 181 + } 182 + } 183 + 184 + function adStatus(ad) { 185 + const now = new Date(); 186 + const start = ad.start_at ? new Date(ad.start_at) : null; 187 + const end = ad.end_at ? new Date(ad.end_at) : null; 188 + if (!ad.active) return 'paused'; 189 + if (start && now < start) return 'scheduled'; 190 + if (end && now > end) return 'ended'; 191 + return 'live'; 192 + } 193 + 194 + function statusClass(status) { 195 + return { live: 'chip-live', scheduled: 'chip-scheduled', ended: 'chip-ended', paused: 'chip-paused' }[status] || ''; 196 + } 197 + 198 + function formatDateRange(ad) { 199 + const startStr = ad.start_at ? new Date(ad.start_at).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : null; 200 + const endStr = ad.end_at ? new Date(ad.end_at).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : null; 201 + if (startStr && endStr) return startStr + ' – ' + endStr; 202 + if (startStr) return 'From ' + startStr; 203 + if (endStr) return 'Until ' + endStr; 204 + return 'No dates'; 205 + } 206 + 207 + function countByStatus() { 208 + let live = 0, scheduled = 0, ended = 0, paused = 0; 209 + scheduledAds.forEach(ad => { 210 + const s = adStatus(ad); 211 + if (s === 'live') live++; 212 + else if (s === 'scheduled') scheduled++; 213 + else if (s === 'ended') ended++; 214 + else paused++; 215 + }); 216 + return { total: scheduledAds.length, live, scheduled, ended, paused }; 217 + } 218 + 219 + function formatStatusSummary(c) { 220 + if (c.total === 0) return 'No ads'; 221 + const parts = []; 222 + if (c.live) parts.push(c.live + ' live'); 223 + if (c.scheduled) parts.push(c.scheduled + ' scheduled'); 224 + if (c.ended) parts.push(c.ended + ' ended'); 225 + if (c.paused) parts.push(c.paused + ' paused'); 226 + return parts.join(' · ') || 'No ads'; 227 + } 228 + 229 + function renderOverview() { 230 + const c = countByStatus(); 231 + const totalEl = document.getElementById('sidebarTotal'); 232 + const liveEl = document.getElementById('sidebarLive'); 233 + const scheduledEl = document.getElementById('sidebarScheduled'); 234 + const endedEl = document.getElementById('sidebarEnded'); 235 + if (totalEl) totalEl.textContent = String(c.total); 236 + if (liveEl) liveEl.textContent = String(c.live); 237 + if (scheduledEl) scheduledEl.textContent = String(c.scheduled); 238 + if (endedEl) endedEl.textContent = String(c.ended); 239 + const badge = document.getElementById('tabScheduleBadge'); 240 + if (badge) badge.textContent = String(c.total); 241 + const banner = document.getElementById('overviewStatusBanner'); 242 + const bannerText = document.getElementById('overviewStatusText'); 243 + const bannerSub = document.getElementById('overviewStatusSub'); 244 + const bannerDot = document.getElementById('overviewStatusDot'); 245 + if (banner && bannerText && bannerSub && bannerDot) { 246 + banner.className = 'overview-status-banner operational'; 247 + bannerText.textContent = formatStatusSummary(c); 248 + bannerSub.textContent = ''; 249 + bannerSub.hidden = true; 250 + bannerDot.className = 'status-banner-dot ok'; 251 + } 252 + } 253 + 254 + function formatCompact(n) { 255 + if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M'; 256 + if (n >= 1000) return (n / 1000).toFixed(1) + 'k'; 257 + return String(n); 258 + } 259 + 260 + function getAdStats(adId) { 261 + return perAdStats.find((s) => s.ad_id === adId) || null; 262 + } 263 + 264 + function updateAdKpiBox() { 265 + const box = document.getElementById('adKpiBox'); 266 + const grid = document.getElementById('adKpiGrid'); 267 + if (!box || !grid) return; 268 + if (selectedIndex < 0 || selectedIndex >= scheduledAds.length) { 269 + box.hidden = true; 270 + return; 271 + } 272 + const ad = scheduledAds[selectedIndex]; 273 + const stats = getAdStats(ad.id); 274 + if (!stats) { 275 + box.hidden = true; 276 + return; 277 + } 278 + box.hidden = false; 279 + grid.innerHTML = '<div class="kpi-item"><span class="kpi-label">Impressions</span><span class="kpi-value">' + formatCompact(stats.impressions) + '</span></div>' + 280 + '<div class="kpi-item"><span class="kpi-label">Clicks</span><span class="kpi-value">' + formatCompact(stats.clicks) + '</span></div>' + 281 + '<div class="kpi-item"><span class="kpi-label">CTR</span><span class="kpi-value">' + stats.ctr_percent.toFixed(1) + '%</span></div>'; 282 + } 283 + 284 + async function loadPostHogStats() { 285 + const bannerText = document.getElementById('overviewStatusText'); 286 + const bannerSub = document.getElementById('overviewStatusSub'); 287 + if (!bannerText || !bannerSub) return; 288 + try { 289 + const res = await fetch('/api/admin/stats', { credentials: 'include' }); 290 + const data = await res.json(); 291 + perAdStats = Array.isArray(data.per_ad) ? data.per_ad : []; 292 + if (data.configured && typeof data.impressions === 'number' && typeof data.clicks === 'number') { 293 + const ctr = typeof data.ctr_percent === 'number' ? data.ctr_percent.toFixed(1) : '0'; 294 + bannerText.textContent = 'Last 7d: ' + data.impressions + ' impressions · ' + data.clicks + ' clicks · ' + ctr + '% CTR'; 295 + bannerSub.textContent = ''; 296 + bannerSub.hidden = true; 297 + } 298 + renderAdCards(); 299 + updateAdKpiBox(); 300 + } catch (_) { 301 + perAdStats = []; 302 + bannerSub.textContent = 'Analytics unavailable'; 303 + bannerSub.hidden = false; 304 + } 305 + } 306 + 307 + function switchTab(name, btn) { 308 + document.querySelectorAll('.tabs > .tab').forEach(t => t.classList.remove('active')); 309 + document.querySelectorAll('.main > .panel').forEach(p => p.classList.remove('active')); 310 + if (btn) btn.classList.add('active'); 311 + const panel = document.getElementById('tab-' + name); 312 + if (panel) panel.classList.add('active'); 313 + } 314 + 315 + function goToNewAd() { 316 + if (formDirty && !confirm('Discard unsaved changes?')) return; 317 + selectAd(-1); 318 + switchTab('schedule', document.getElementById('tabBtnSchedule')); 319 + } 320 + 321 + function renderAdCards() { 322 + const adsHeader = document.getElementById('adsHeader'); 323 + if (adsHeader) adsHeader.hidden = scheduledAds.length === 0; 324 + adCards.innerHTML = ''; 325 + scheduledAds.forEach((ad, i) => { 326 + const status = adStatus(ad); 327 + const stats = getAdStats(ad.id); 328 + const statsLine = stats 329 + ? '<span class="ad-card-stats">' + formatCompact(stats.impressions) + ' imp · ' + formatCompact(stats.clicks) + ' clk · ' + stats.ctr_percent.toFixed(1) + '% CTR</span>' 330 + : ''; 331 + const card = document.createElement('button'); 332 + card.type = 'button'; 333 + card.className = 'ad-card' + (selectedIndex === i ? ' selected' : ''); 334 + card.innerHTML = '<span class="ad-card-head">' + escapeHtml(ad.sponsor) + ' — ' + escapeHtml(ad.id) + '</span>' + 335 + '<span class="chip ' + statusClass(status) + '">' + status + '</span>' + 336 + '<span class="ad-card-dates">' + formatDateRange(ad) + '</span>' + 337 + (statsLine ? statsLine : '') + 338 + '<span class="ad-card-tier">' + (ad.tier || 'banner') + '</span>'; 339 + card.addEventListener('click', () => selectAd(i)); 340 + adCards.appendChild(card); 341 + }); 342 + } 343 + 344 + function escapeHtml(s) { 345 + const div = document.createElement('div'); 346 + div.textContent = s || ''; 347 + return div.innerHTML; 348 + } 349 + 350 + function getAdsForDay(year, month, day) { 351 + const dayStart = new Date(year, month, day, 0, 0, 0); 352 + const dayEnd = new Date(year, month, day, 23, 59, 59); 353 + return scheduledAds.filter(ad => { 354 + const start = ad.start_at ? new Date(ad.start_at) : null; 355 + const end = ad.end_at ? new Date(ad.end_at) : null; 356 + if (!start && !end) return false; 357 + const adStart = start || new Date(0); 358 + const adEnd = end || new Date(9999, 11, 31); 359 + return dayStart <= adEnd && dayEnd >= adStart; 360 + }); 361 + } 362 + 363 + function renderCalendar() { 364 + calendarWrap.hidden = false; 365 + calMonthLabel.textContent = new Date(calViewYear, calViewMonth).toLocaleDateString(undefined, { month: 'long', year: 'numeric' }); 366 + 367 + const first = new Date(calViewYear, calViewMonth, 1); 368 + const daysInMonth = new Date(calViewYear, calViewMonth + 1, 0).getDate(); 369 + let startDow = first.getDay(); 370 + 371 + const prevMonth = calViewMonth === 0 ? 11 : calViewMonth - 1; 372 + const prevYear = calViewMonth === 0 ? calViewYear - 1 : calViewYear; 373 + const prevMonthDays = new Date(prevYear, prevMonth + 1, 0).getDate(); 374 + 375 + const now = new Date(); 376 + const todayStr = now.getFullYear() + '-' + now.getMonth() + '-' + now.getDate(); 377 + 378 + let html = ''; 379 + const totalCells = Math.ceil((startDow + daysInMonth) / 7) * 7; 380 + 381 + for (let i = 0; i < totalCells; i++) { 382 + let dayNum, y, m; 383 + if (i < startDow) { 384 + dayNum = prevMonthDays - startDow + i + 1; 385 + y = prevYear; 386 + m = prevMonth; 387 + } else if (i < startDow + daysInMonth) { 388 + dayNum = i - startDow + 1; 389 + y = calViewYear; 390 + m = calViewMonth; 391 + } else { 392 + dayNum = i - startDow - daysInMonth + 1; 393 + y = calViewMonth === 11 ? calViewYear + 1 : calViewYear; 394 + m = calViewMonth === 11 ? 0 : calViewMonth + 1; 395 + } 396 + 397 + const isCurrentMonth = (y === calViewYear && m === calViewMonth); 398 + const isToday = (y === now.getFullYear() && m === now.getMonth() && dayNum === now.getDate()); 399 + 400 + const ads = getAdsForDay(y, m, dayNum); 401 + const cellClass = 'cal-cell' + (isCurrentMonth ? '' : ' other-month') + (isToday ? ' today' : ''); 402 + 403 + html += '<div class="' + cellClass + '" data-year="' + y + '" data-month="' + m + '" data-day="' + dayNum + '">'; 404 + html += '<div class="cal-cell-num">' + dayNum + '</div>'; 405 + html += '<div class="cal-cell-ads">'; 406 + ads.forEach((ad) => { 407 + const status = adStatus(ad); 408 + const idxInList = scheduledAds.findIndex(a => a.id === ad.id); 409 + if (idxInList >= 0) { 410 + const sel = selectedIndex === idxInList ? ' selected' : ''; 411 + html += '<button type="button" class="cal-ad-pill ' + statusClass(status) + sel + '" data-ad-index="' + idxInList + '" title="' + escapeHtml(ad.sponsor + ' – ' + ad.id + ' (' + formatDateRange(ad) + ')') + '">' + escapeHtml(ad.sponsor) + '</button>'; 412 + } 413 + }); 414 + html += '</div></div>'; 415 + } 416 + 417 + calDays.innerHTML = html; 418 + 419 + calDays.querySelectorAll('.cal-ad-pill').forEach(btn => { 420 + btn.addEventListener('click', (e) => { 421 + e.stopPropagation(); 422 + selectAd(parseInt(btn.dataset.adIndex, 10)); 423 + switchTab('schedule', document.getElementById('tabBtnSchedule')); 424 + }); 425 + }); 426 + } 427 + 428 + function selectAd(index) { 429 + if (formDirty && !confirm('Discard unsaved changes?')) return; 430 + formDirty = false; 431 + selectedIndex = index; 432 + renderAdCards(); 433 + renderCalendar(); 434 + if (index === -1) { 435 + clearForm(); 436 + setStatus(saveStatus, '', undefined); 437 + } else { 438 + populateForm(scheduledAds[index]); 439 + } 440 + updateClearDatesVisibility(); 441 + updateDeleteButton(); 442 + updatePreview(); 443 + updateAdKpiBox(); 444 + } 445 + 446 + function markDirty() { formDirty = true; } 447 + 448 + function clearForm() { 449 + document.getElementById('id').value = ''; 450 + document.getElementById('tier').value = 'banner'; 451 + document.getElementById('active').checked = true; 452 + document.getElementById('sponsor').value = ''; 453 + document.getElementById('headline').value = ''; 454 + document.getElementById('subline').value = ''; 455 + document.getElementById('cta').value = ''; 456 + document.getElementById('destination_url').value = ''; 457 + document.getElementById('image_url').value = ''; 458 + document.getElementById('logo_url').value = ''; 459 + document.getElementById('placement').value = 'home_feed'; 460 + document.getElementById('creative_version').value = ''; 461 + document.getElementById('start_at').value = ''; 462 + document.getElementById('end_at').value = ''; 463 + } 464 + 465 + function isoToDatetimeLocal(iso) { 466 + if (!iso) return ''; 467 + const d = new Date(iso); 468 + if (Number.isNaN(d.getTime())) return ''; 469 + const pad = (n) => String(n).padStart(2, '0'); 470 + return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + 'T' + pad(d.getHours()) + ':' + pad(d.getMinutes()); 471 + } 472 + 473 + function datetimeLocalToIso(value) { 474 + if (!value) return undefined; 475 + const d = new Date(value); 476 + return Number.isNaN(d.getTime()) ? undefined : d.toISOString(); 477 + } 478 + 479 + function populateForm(data) { 480 + document.getElementById('id').value = data.id || ''; 481 + document.getElementById('tier').value = (data.tier || 'banner').toLowerCase(); 482 + document.getElementById('active').checked = !!data.active; 483 + document.getElementById('sponsor').value = data.sponsor || ''; 484 + document.getElementById('headline').value = data.headline || ''; 485 + document.getElementById('subline').value = data.subline || ''; 486 + document.getElementById('cta').value = data.cta || ''; 487 + document.getElementById('destination_url').value = data.destination_url || ''; 488 + document.getElementById('image_url').value = data.image_url || ''; 489 + document.getElementById('logo_url').value = data.logo_url || ''; 490 + document.getElementById('placement').value = data.placement || 'home_feed'; 491 + document.getElementById('creative_version').value = data.creative_version || ''; 492 + document.getElementById('start_at').value = isoToDatetimeLocal(data.start_at); 493 + document.getElementById('end_at').value = isoToDatetimeLocal(data.end_at); 494 + } 495 + 496 + function getFormData() { 497 + return { 498 + sponsor: document.getElementById('sponsor').value.trim(), 499 + headline: document.getElementById('headline').value.trim(), 500 + subline: document.getElementById('subline').value.trim() || null, 501 + cta: document.getElementById('cta').value.trim(), 502 + tier: document.getElementById('tier').value, 503 + image_url: document.getElementById('image_url').value.trim() || null, 504 + logo_url: document.getElementById('logo_url').value.trim() || null, 505 + }; 506 + } 507 + 508 + function updatePreview() { 509 + const d = getFormData(); 510 + const tier = d.tier || 'banner'; 511 + let html = '<div class="preview preview-' + tier + '">'; 512 + if (tier === 'text') { 513 + html += '<span class="preview-sponsor">' + escapeHtml(d.sponsor) + '</span>'; 514 + html += '<strong class="preview-headline">' + escapeHtml(d.headline) + '</strong>'; 515 + if (d.subline) html += '<span class="preview-subline">' + escapeHtml(d.subline) + '</span>'; 516 + html += '<button type="button" class="preview-cta" disabled>' + escapeHtml(d.cta || 'CTA') + '</button>'; 517 + } else if (tier === 'banner') { 518 + if (d.image_url) html += '<img src="' + escapeHtml(d.image_url) + '" alt="" class="preview-img" onerror="this.style.display=\\'none\\'">'; 519 + else html += '<div class="preview-img-placeholder">Image</div>'; 520 + html += '<div class="preview-content"><strong>' + escapeHtml(d.headline) + '</strong><button type="button" class="preview-cta preview-cta-sm" disabled>' + escapeHtml(d.cta || 'CTA') + '</button></div>'; 521 + } else { 522 + if (d.image_url) html += '<img src="' + escapeHtml(d.image_url) + '" alt="" class="preview-img preview-img-lg" onerror="this.style.display=\\'none\\'">'; 523 + else html += '<div class="preview-img-placeholder preview-img-placeholder-lg">Image</div>'; 524 + if (d.logo_url) html += '<img src="' + escapeHtml(d.logo_url) + '" alt="" class="preview-logo" onerror="this.style.display=\\'none\\'">'; 525 + html += '<span class="preview-sponsor">' + escapeHtml(d.sponsor) + '</span>'; 526 + html += '<strong class="preview-headline">' + escapeHtml(d.headline) + '</strong>'; 527 + if (d.subline) html += '<span class="preview-subline">' + escapeHtml(d.subline) + '</span>'; 528 + html += '<button type="button" class="preview-cta" disabled>' + escapeHtml(d.cta || 'CTA') + '</button>'; 529 + } 530 + html += '</div>'; 531 + adPreview.innerHTML = html; 532 + } 533 + 534 + function debouncePreview() { 535 + clearTimeout(previewDebounce); 536 + previewDebounce = setTimeout(updatePreview, 300); 537 + } 538 + 539 + function updateClearDatesVisibility() { 540 + const startVal = document.getElementById('start_at').value; 541 + const endVal = document.getElementById('end_at').value; 542 + const wrap = document.getElementById('clearDatesWrap'); 543 + wrap.hidden = !startVal && !endVal; 544 + } 545 + 546 + function updateDeleteButton() { 547 + deleteBtn.disabled = selectedIndex === -1; 548 + } 549 + 550 + function clearDates() { 551 + document.getElementById('start_at').value = ''; 552 + document.getElementById('end_at').value = ''; 553 + updateClearDatesVisibility(); 554 + markDirty(); 555 + updatePreview(); 556 + } 557 + 558 + document.querySelectorAll('[data-preset]').forEach(btn => { 559 + btn.addEventListener('click', () => { 560 + const preset = btn.dataset.preset; 561 + const now = new Date(); 562 + if (preset === '7d') { 563 + document.getElementById('start_at').value = isoToDatetimeLocal(now.toISOString()); 564 + const end = new Date(now); 565 + end.setDate(end.getDate() + 7); 566 + document.getElementById('end_at').value = isoToDatetimeLocal(end.toISOString()); 567 + } else if (preset === '30d') { 568 + document.getElementById('start_at').value = isoToDatetimeLocal(now.toISOString()); 569 + const end = new Date(now); 570 + end.setDate(end.getDate() + 30); 571 + document.getElementById('end_at').value = isoToDatetimeLocal(end.toISOString()); 572 + } 573 + updateClearDatesVisibility(); 574 + markDirty(); 575 + updatePreview(); 576 + }); 577 + }); 578 + 579 + document.getElementById('clearDatesBtn').addEventListener('click', clearDates); 580 + 581 + document.getElementById('start_at').addEventListener('input', updateClearDatesVisibility); 582 + document.getElementById('start_at').addEventListener('change', updateClearDatesVisibility); 583 + document.getElementById('end_at').addEventListener('input', updateClearDatesVisibility); 584 + document.getElementById('end_at').addEventListener('change', updateClearDatesVisibility); 585 + 586 + cloneBtn.addEventListener('click', () => { 587 + const idEl = document.getElementById('id'); 588 + const base = idEl.value.trim() || 'ad'; 589 + const match = base.match(/-(\d+)$/); 590 + const next = match ? base.replace(/-(\d+)$/, '-' + (parseInt(match[1], 10) + 1)) : base + '-copy'; 591 + idEl.value = next; 592 + const now = new Date(); 593 + const start = new Date(now); 594 + start.setMonth(start.getMonth() + 1); 595 + const end = new Date(start); 596 + end.setMonth(end.getMonth() + 1); 597 + document.getElementById('start_at').value = isoToDatetimeLocal(start.toISOString()); 598 + document.getElementById('end_at').value = isoToDatetimeLocal(end.toISOString()); 599 + document.getElementById('active').checked = false; 600 + selectedIndex = -1; 601 + renderAdCards(); 602 + updateClearDatesVisibility(); 603 + updateDeleteButton(); 604 + formDirty = true; 605 + updatePreview(); 606 + }); 607 + 608 + deleteBtn.addEventListener('click', async () => { 609 + if (selectedIndex < 0) return; 610 + const ad = scheduledAds[selectedIndex]; 611 + if (!ad || !confirm('Delete ad "' + (ad.sponsor || ad.id) + '"?')) return; 612 + setStatus(saveStatus, 'Deleting…', true); 613 + try { 614 + const res = await fetch(API_URL + '?id=' + encodeURIComponent(ad.id), { 615 + method: 'DELETE', 616 + credentials: 'include', 617 + }); 618 + const data = await res.json(); 619 + if (!res.ok) { 620 + setStatus(saveStatus, data.error || res.statusText, false); 621 + return; 622 + } 623 + setStatus(saveStatus, 'Deleted', true); 624 + formDirty = false; 625 + selectedIndex = -1; 626 + loadSchedule(); 627 + } catch (err) { 628 + setStatus(saveStatus, err.message || 'Network error', false); 629 + } 630 + }); 631 + 632 + form.querySelectorAll('input, select').forEach(el => { 633 + el.addEventListener('input', () => { markDirty(); debouncePreview(); }); 634 + el.addEventListener('change', () => { markDirty(); debouncePreview(); }); 635 + }); 636 + 637 + window.addEventListener('beforeunload', (e) => { if (formDirty) e.preventDefault(); }); 638 + 639 + refreshBtn.addEventListener('click', loadSchedule); 640 + document.getElementById('overviewRefreshBtn').addEventListener('click', loadSchedule); 641 + document.getElementById('newAdBtn').addEventListener('click', goToNewAd); 642 + document.getElementById('overviewNewAdBtn').addEventListener('click', goToNewAd); 643 + document.getElementById('tabBtnOverview').addEventListener('click', () => switchTab('overview', document.getElementById('tabBtnOverview'))); 644 + document.getElementById('tabBtnSchedule').addEventListener('click', () => switchTab('schedule', document.getElementById('tabBtnSchedule'))); 645 + 646 + document.getElementById('calPrev').addEventListener('click', () => { 647 + if (calViewMonth === 0) { calViewMonth = 11; calViewYear--; } else calViewMonth--; 648 + renderCalendar(); 649 + }); 650 + document.getElementById('calNext').addEventListener('click', () => { 651 + if (calViewMonth === 11) { calViewMonth = 0; calViewYear++; } else calViewMonth++; 652 + renderCalendar(); 653 + }); 654 + 655 + async function loadSchedule() { 656 + setStatus(fetchStatus, 'Connecting…', true); 657 + const banner = document.getElementById('overviewStatusBanner'); 658 + const bannerText = document.getElementById('overviewStatusText'); 659 + const bannerSub = document.getElementById('overviewStatusSub'); 660 + const bannerDot = document.getElementById('overviewStatusDot'); 661 + if (banner && bannerText && bannerSub && bannerDot) { 662 + banner.className = 'overview-status-banner loading'; 663 + bannerText.textContent = 'Loading…'; 664 + bannerSub.textContent = 'Fetching ads'; 665 + bannerDot.className = 'status-banner-dot pending'; 666 + } 667 + try { 668 + const res = await fetch(API_URL, { credentials: 'include' }); 669 + const data = await res.json(); 670 + if (!res.ok) { 671 + setStatus(fetchStatus, 'Not connected', false); 672 + return; 673 + } 674 + scheduledAds = Array.isArray(data.ads) ? data.ads : data.id != null ? [data] : []; 675 + renderAdCards(); 676 + renderCalendar(); 677 + renderOverview(); 678 + if (scheduledAds.length > 0 && selectedIndex >= 0 && selectedIndex < scheduledAds.length) { 679 + populateForm(scheduledAds[selectedIndex]); 680 + } else { 681 + selectAd(-1); 682 + } 683 + setStatus(fetchStatus, 'Connected', true); 684 + loadPostHogStats(); 685 + } catch (err) { 686 + setStatus(fetchStatus, 'Not connected', false); 687 + const banner = document.getElementById('overviewStatusBanner'); 688 + const bannerText = document.getElementById('overviewStatusText'); 689 + const bannerSub = document.getElementById('overviewStatusSub'); 690 + const bannerDot = document.getElementById('overviewStatusDot'); 691 + if (banner && bannerText && bannerSub && bannerDot) { 692 + banner.className = 'overview-status-banner error'; 693 + bannerText.textContent = 'Failed to load'; 694 + bannerSub.textContent = err.message || 'Network error'; 695 + bannerDot.className = 'status-banner-dot err'; 696 + } 697 + } 698 + } 699 + 700 + form.addEventListener('submit', async (e) => { 701 + e.preventDefault(); 702 + const tier = document.getElementById('tier').value; 703 + const payload = { 704 + id: document.getElementById('id').value.trim(), 705 + tier, 706 + active: document.getElementById('active').checked, 707 + sponsor: document.getElementById('sponsor').value.trim(), 708 + headline: document.getElementById('headline').value.trim(), 709 + subline: document.getElementById('subline').value.trim() || null, 710 + cta: document.getElementById('cta').value.trim(), 711 + destination_url: document.getElementById('destination_url').value.trim(), 712 + image_url: document.getElementById('image_url').value.trim() || null, 713 + logo_url: document.getElementById('logo_url').value.trim() || null, 714 + placement: document.getElementById('placement').value.trim() || 'home_feed', 715 + creative_version: document.getElementById('creative_version').value.trim() || '', 716 + start_at: datetimeLocalToIso(document.getElementById('start_at').value.trim()), 717 + end_at: datetimeLocalToIso(document.getElementById('end_at').value.trim()), 718 + }; 719 + if ((tier === 'banner' || tier === 'feature') && !payload.image_url) { 720 + setStatus(saveStatus, 'Banner/feature tier requires image_url', false); 721 + return; 722 + } 723 + setStatus(saveStatus, 'Saving…', true); 724 + try { 725 + const res = await fetch(API_URL, { 726 + method: 'PUT', 727 + credentials: 'include', 728 + headers: { 'Content-Type': 'application/json' }, 729 + body: JSON.stringify(payload), 730 + }); 731 + const data = await res.json(); 732 + if (!res.ok) { 733 + setStatus(saveStatus, data.error || res.statusText, false); 734 + return; 735 + } 736 + setStatus(saveStatus, 'Saved', true); 737 + formDirty = false; 738 + loadSchedule(); 739 + } catch (err) { 740 + setStatus(saveStatus, err.message || 'Network error', false); 741 + } 742 + }); 743 + 744 + loadSchedule(); 745 + </script> 746 + 747 + <style> 748 + *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } 749 + :root { 750 + --bg: #0f0f0f; 751 + --surface: #161616; 752 + --border: #2a2a2a; 753 + --text: #e0e0e0; 754 + --muted: #666; 755 + --accent: #7c6bff; 756 + --green: #4ade80; 757 + --red: #f87171; 758 + --yellow: #fbbf24; 759 + --font: 'IBM Plex Mono', monospace; 760 + } 761 + html { background: var(--bg); color: var(--text); font-family: var(--font); font-size: 12px; line-height: 1.5; -webkit-text-size-adjust: 100%; } 762 + body { height: 100vh; margin: 0; overflow: hidden; min-width: 0; } 763 + 764 + .dashboard-wrap { 765 + display: grid; 766 + grid-template-columns: minmax(0, 240px) 1fr; 767 + grid-template-rows: 40px 1fr; 768 + height: 100vh; 769 + min-height: 0; 770 + } 771 + .topbar { 772 + grid-column: 1 / -1; 773 + display: flex; 774 + align-items: center; 775 + gap: 8px; 776 + padding: 0 12px; 777 + min-height: 40px; 778 + height: 40px; 779 + border-bottom: 1px solid var(--border); 780 + background: var(--surface); 781 + min-width: 0; 782 + } 783 + .topbar-title { font-size: 13px; font-weight: 500; white-space: nowrap; } 784 + .topbar-sep { color: var(--border); flex-shrink: 0; } 785 + .topbar-sub { color: var(--muted); font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } 786 + .status { display: flex; align-items: center; gap: 6px; margin-left: auto; flex-shrink: 0; } 787 + .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--border); flex-shrink: 0; } 788 + .dot.on { background: var(--green); } 789 + .dot.err { background: var(--red); } 790 + .status-text { color: var(--muted); font-size: 11px; } 791 + .status-text.status-ok { color: var(--green); } 792 + .status-text.status-err { color: var(--red); } 793 + 794 + .sidebar { 795 + border-right: 1px solid var(--border); 796 + overflow-y: auto; 797 + overflow-x: hidden; 798 + padding: 12px 16px; 799 + display: flex; 800 + flex-direction: column; 801 + gap: 12px; 802 + background: var(--surface); 803 + min-width: 0; 804 + } 805 + .sidebar button { width: 100%; min-height: 36px; } 806 + .kv { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; } 807 + .kv-k { color: var(--muted); white-space: nowrap; } 808 + .kv-v { color: var(--accent); font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: right; } 809 + 810 + .main { display: grid; grid-template-rows: auto 1fr; overflow: hidden; min-width: 0; min-height: 0; } 811 + .tabs { 812 + display: flex; 813 + border-bottom: 1px solid var(--border); 814 + background: var(--surface); 815 + padding: 0 12px; 816 + gap: 2px; 817 + align-items: stretch; 818 + } 819 + .tab { 820 + padding: 10px 14px; 821 + font-family: var(--font); 822 + font-size: 11px; 823 + color: var(--muted); 824 + cursor: pointer; 825 + border: none; 826 + background: none; 827 + border-bottom: 2px solid transparent; 828 + margin-bottom: -1px; 829 + width: auto; 830 + transition: color 0.1s, border-color 0.1s; 831 + display: flex; 832 + align-items: center; 833 + gap: 6px; 834 + } 835 + .tab:hover:not(:disabled) { color: var(--text); } 836 + .tab.active { color: var(--text); border-bottom-color: var(--accent); } 837 + .badge { 838 + background: var(--border); 839 + color: var(--muted); 840 + font-size: 9px; 841 + padding: 1px 5px; 842 + min-width: 16px; 843 + text-align: center; 844 + } 845 + .panel { display: none; overflow-y: auto; overflow-x: hidden; height: 100%; padding: 16px 20px; min-width: 0; } 846 + .panel.active { display: block; } 847 + 848 + .overview-status-banner { 849 + padding: 12px 16px; 850 + margin: 0 0 16px; 851 + border: 1px solid var(--border); 852 + display: flex; 853 + align-items: center; 854 + gap: 12px; 855 + background: var(--surface); 856 + } 857 + .overview-status-banner.operational { border-left: 4px solid var(--green); } 858 + .overview-status-banner.loading { border-left: 4px solid var(--muted); } 859 + .overview-status-banner.error { border-left: 4px solid var(--red); } 860 + .status-banner-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } 861 + .status-banner-dot.ok { background: var(--green); } 862 + .status-banner-dot.pending { background: var(--muted); } 863 + .status-banner-dot.err { background: var(--red); } 864 + .status-banner-text { font-size: 13px; font-weight: 500; } 865 + .status-banner-sub { color: var(--muted); font-size: 11px; margin-top: 2px; } 866 + .overview-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; } 867 + .btn-as-link { 868 + padding: 7px 12px; 869 + font-size: 12px; 870 + font-family: var(--font); 871 + border: 1px solid var(--accent); 872 + color: var(--accent); 873 + background: transparent; 874 + cursor: pointer; 875 + width: auto; 876 + transition: color 0.1s, border-color 0.1s, background 0.1s; 877 + } 878 + .btn-as-link:hover { background: var(--accent); color: #fff; } 879 + 880 + .admin-section { display: flex; flex-direction: column; gap: 12px; margin-bottom: 20px; min-width: 0; } 881 + 882 + .group { display: flex; flex-direction: column; gap: 6px; margin-bottom: 16px; } 883 + .group-label { font-size: 10px; color: var(--muted); letter-spacing: 0.08em; text-transform: uppercase; } 884 + .field-label { color: var(--muted); font-size: 10px; margin-bottom: 3px; } 885 + .checkbox-label { display: flex; align-items: center; gap: 8px; color: var(--text); font-size: 12px; cursor: pointer; } 886 + .checkbox-label input { width: auto; margin: 0; } 887 + 888 + input, select { 889 + width: 100%; 890 + min-width: 0; 891 + background: var(--bg); 892 + border: 1px solid var(--border); 893 + color: var(--text); 894 + font-family: var(--font); 895 + font-size: 12px; 896 + padding: 7px 10px; 897 + outline: none; 898 + transition: border-color 0.15s; 899 + margin-bottom: 4px; 900 + } 901 + input:focus, select:focus { border-color: var(--accent); } 902 + 903 + button { 904 + font-family: var(--font); 905 + font-size: 12px; 906 + padding: 7px 12px; 907 + cursor: pointer; 908 + border: 1px solid var(--border); 909 + background: transparent; 910 + color: var(--muted); 911 + transition: color 0.1s, border-color 0.1s, background 0.1s; 912 + } 913 + button:hover:not(:disabled) { color: var(--text); border-color: var(--text); } 914 + button.primary { border-color: var(--accent); color: var(--accent); } 915 + button.primary:hover:not(:disabled) { background: var(--accent); color: #fff; } 916 + button.delete-btn { border-color: var(--red); color: var(--red); margin-left: auto; } 917 + button.delete-btn:hover:not(:disabled) { background: var(--red); color: #fff; } 918 + .date-presets { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 6px; } 919 + .date-presets button { width: auto; } 920 + .clear-dates-wrap { margin-top: 6px; } 921 + .clear-dates-btn { padding: 2px 0; font-size: 10px; color: var(--muted); border: none; background: none; cursor: pointer; text-decoration: underline; } 922 + .clear-dates-btn:hover { color: var(--text); } 923 + 924 + .form-actions { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 20px; padding-top: 20px; border-top: 1px solid var(--border); } 925 + .save-status { font-size: 11px; color: var(--muted); } 926 + .save-status.status-ok { color: var(--green); } 927 + .save-status.status-err { color: var(--red); } 928 + 929 + .ad-cards { display: flex; flex-wrap: wrap; gap: 8px; min-width: 0; } 930 + .ad-card { 931 + display: flex; flex-direction: column; align-items: flex-start; gap: 4px; 932 + padding: 10px 12px; border: 1px solid var(--border); background: var(--surface); 933 + color: var(--text); font: inherit; text-align: left; cursor: pointer; 934 + transition: border-color 0.1s, background 0.1s; 935 + } 936 + .ad-card:hover { border-color: var(--text); } 937 + .ad-card.selected { border-color: var(--accent); background: rgba(124,107,255,0.08); } 938 + .ad-card-head { font-weight: 500; font-size: 11px; } 939 + .ad-card-dates { font-size: 10px; color: var(--muted); } 940 + .ad-card-stats { font-size: 10px; color: var(--green); display: block; margin-top: 2px; } 941 + .ad-card-tier { font-size: 9px; color: var(--muted); text-transform: uppercase; } 942 + .ad-kpi-box { margin-bottom: 16px; padding: 10px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; } 943 + .ad-kpi-box .group-label { margin-bottom: 8px; } 944 + .kpi-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px 16px; } 945 + .kpi-item { display: flex; flex-direction: column; gap: 2px; } 946 + .kpi-label { font-size: 9px; color: var(--muted); text-transform: uppercase; } 947 + .kpi-value { font-size: 13px; font-weight: 500; color: var(--green); } 948 + .chip { font-size: 9px; padding: 1px 5px; border: 1px solid var(--border); color: var(--muted); line-height: 1.6; } 949 + .chip-live { color: var(--green); border-color: rgba(74,222,128,0.4); } 950 + .chip-scheduled { color: var(--accent); border-color: rgba(124,107,255,0.4); } 951 + .chip-ended { color: var(--muted); opacity: 0.8; } 952 + .chip-paused { color: var(--yellow); border-color: rgba(251,191,36,0.4); } 953 + 954 + .calendar-wrap { margin-top: 16px; border: 1px solid var(--border); background: var(--surface); padding: 12px 16px; width: 100%; max-width: 420px; } 955 + .calendar-header { display: flex; align-items: center; justify-content: center; gap: 16px; margin-bottom: 12px; } 956 + .cal-month-label { font-size: 13px; font-weight: 500; color: var(--text); margin: 0; } 957 + .cal-nav { padding: 4px 10px; font-size: 11px; background: transparent; border: 1px solid var(--border); color: var(--muted); cursor: pointer; } 958 + .cal-nav:hover { color: var(--text); border-color: var(--text); } 959 + .calendar-grid { display: flex; flex-direction: column; gap: 0; } 960 + .cal-weekdays { display: grid; grid-template-columns: repeat(7, 1fr); font-size: 9px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; padding-bottom: 6px; border-bottom: 1px solid var(--border); } 961 + .cal-weekdays span { text-align: center; } 962 + .cal-days { display: grid; grid-template-columns: repeat(7, 1fr); gap: 1px; background: var(--border); } 963 + .cal-cell { height: 52px; min-height: 52px; padding: 4px; background: var(--surface); display: flex; flex-direction: column; font-size: 10px; } 964 + .cal-cell.other-month { background: var(--bg); } 965 + .cal-cell.other-month .cal-cell-num { color: var(--muted); opacity: 0.6; } 966 + .cal-cell.today { outline: 1px solid var(--accent); outline-offset: -1px; z-index: 1; } 967 + .cal-cell.today .cal-cell-num { color: var(--accent); font-weight: 500; } 968 + .cal-cell-num { font-size: 11px; color: var(--text); margin-bottom: 4px; } 969 + .cal-cell-ads { display: flex; flex-wrap: wrap; gap: 2px; align-content: flex-start; overflow: hidden; } 970 + .cal-ad-pill { font-size: 9px; padding: 1px 4px; border: none; border-radius: 2px; cursor: pointer; font-family: var(--font); text-align: left; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; } 971 + .cal-ad-pill.chip-live { background: rgba(74,222,128,0.2); color: var(--green); } 972 + .cal-ad-pill.chip-scheduled { background: rgba(124,107,255,0.2); color: var(--accent); } 973 + .cal-ad-pill.chip-ended { background: rgba(102,102,102,0.2); color: var(--muted); } 974 + .cal-ad-pill.chip-paused { background: rgba(251,191,36,0.2); color: var(--yellow); } 975 + .cal-ad-pill:hover { opacity: 0.9; } 976 + .cal-ad-pill.selected { outline: 1px solid var(--text); outline-offset: 1px; } 977 + .cal-legend { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); font-size: 10px; color: var(--muted); } 978 + .cal-legend-dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; margin-right: 4px; vertical-align: middle; } 979 + .cal-legend-dot.live { background: var(--green); } 980 + .cal-legend-dot.scheduled { background: var(--accent); } 981 + .cal-legend-dot.ended { background: var(--muted); } 982 + .cal-legend-dot.paused { background: var(--yellow); } 983 + 984 + .admin-editor-grid { display: grid; grid-template-columns: 1fr minmax(200px, 18rem); gap: 20px; min-width: 0; } 985 + .preview-pane { position: sticky; top: 20px; align-self: start; } 986 + .ad-preview { border: 1px solid var(--border); padding: 12px; background: var(--surface); min-height: 8rem; } 987 + .preview { display: flex; flex-direction: column; gap: 6px; font-size: 11px; } 988 + .preview-sponsor { font-size: 9px; text-transform: uppercase; color: var(--muted); } 989 + .preview-headline { font-weight: 500; } 990 + .preview-subline { font-size: 10px; color: var(--muted); } 991 + .preview-cta { padding: 4px 8px; background: var(--accent); color: #fff; border: none; font-size: 10px; cursor: default; width: auto; } 992 + .preview-cta-sm { padding: 2px 6px; font-size: 9px; } 993 + .preview-img { max-width: 100%; height: auto; max-height: 4rem; object-fit: cover; } 994 + .preview-img-lg { max-height: 6rem; } 995 + .preview-img-placeholder { width: 100%; height: 3rem; background: var(--border); display: flex; align-items: center; justify-content: center; font-size: 10px; color: var(--muted); } 996 + .preview-img-placeholder-lg { height: 5rem; } 997 + .preview-logo { width: 24px; height: 24px; object-fit: contain; } 998 + .preview-content { display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap; } 999 + 1000 + .footer { margin-top: 20px; font-size: 11px; color: var(--muted); } 1001 + .footer a { color: var(--accent); text-decoration: none; } 1002 + .footer a:hover { text-decoration: underline; } 1003 + 1004 + ::-webkit-scrollbar { width: 4px; height: 4px; } 1005 + ::-webkit-scrollbar-thumb { background: var(--border); } 1006 + 1007 + @media (min-width: 900px) { 1008 + .dashboard-wrap { grid-template-columns: 260px 1fr; } 1009 + } 1010 + @media (min-width: 1200px) { 1011 + .dashboard-wrap { grid-template-columns: 280px 1fr; } 1012 + .calendar-wrap { max-width: 480px; } 1013 + } 1014 + 1015 + @media (max-width: 768px) { 1016 + .admin-editor-grid { grid-template-columns: 1fr; } 1017 + .preview-pane { position: static; } 1018 + .calendar-wrap { max-width: 100%; padding: 10px 12px; } 1019 + .cal-cell { height: 48px; min-height: 48px; padding: 3px; } 1020 + .cal-cell-num { font-size: 10px; } 1021 + .cal-ad-pill { font-size: 8px; padding: 0 3px; } 1022 + } 1023 + @media (max-width: 640px) { 1024 + .dashboard-wrap { grid-template-columns: 1fr; grid-template-rows: 40px auto 1fr; } 1025 + .sidebar { 1026 + border-right: none; 1027 + border-bottom: 1px solid var(--border); 1028 + flex-direction: column; 1029 + gap: 10px; 1030 + padding: 10px 12px; 1031 + } 1032 + .sidebar .group { min-width: 0; } 1033 + .sidebar .kv { font-size: 11px; } 1034 + .sidebar button { width: 100%; } 1035 + .panel { padding: 12px 16px; } 1036 + .tabs { padding: 0 8px; overflow-x: auto; } 1037 + .tab { padding: 8px 10px; font-size: 11px; } 1038 + .overview-status-banner { padding: 10px 12px; margin-bottom: 12px; } 1039 + .status-banner-text { font-size: 12px; } 1040 + .status-banner-sub { font-size: 10px; } 1041 + .calendar-wrap { margin-top: 12px; } 1042 + .cal-cell { height: 44px; min-height: 44px; padding: 2px; font-size: 9px; } 1043 + .cal-legend { margin-top: 10px; padding-top: 10px; gap: 8px; font-size: 9px; } 1044 + .ad-card { padding: 8px 10px; min-width: 0; } 1045 + .ad-card-head { font-size: 10px; } 1046 + .form-actions { margin-top: 16px; padding-top: 16px; gap: 8px; } 1047 + } 1048 + @media (max-width: 400px) { 1049 + .topbar { padding: 0 8px; gap: 6px; } 1050 + .topbar-sub { display: none; } 1051 + .topbar-sep { display: none; } 1052 + } 1053 + </style> 1054 + </body> 1055 + </html>`; 1056 + 1057 + export function getAdminHtml(): string { 1058 + return ADMIN_HTML; 1059 + }
+429 -78
workers/gymtracker-ads-api/src/index.ts
··· 1 1 /** 2 2 * Gym Tracker Ads API 3 - * GET /api/ads — return active ad config from KV 4 - * PUT /api/ads — update config (requires X-API-Key) 5 - * OPTIONS /api/ads — CORS preflight 3 + * 4 + * Public: 5 + * GET /api/ads — return current active ad (filtered by start_at/end_at) 6 + * 7 + * Admin (Cloudflare Access protected — Cf-Access-Jwt-Assertion required): 8 + * GET /api/admin/ads — return all scheduled ads 9 + * PUT /api/admin/ads — upsert ad by id 10 + * DELETE /api/admin/ads?id=... — delete ad by id 11 + * 12 + * Legacy (API key, for backward compat): 13 + * GET /api/ads?schedule=1 — return all ads (X-API-Key) 14 + * PUT /api/ads — upsert ad (X-API-Key) 15 + * 16 + * Admin UI: 17 + * GET /admin — serve admin page (Access protected) 6 18 */ 7 19 8 - const KV_KEY = "active_ad"; 9 - 10 - const ALLOWED_ORIGINS = [ 11 - "https://jackhannon.net", 12 - "https://www.jackhannon.net", 13 - "https://jackhannon.me", 14 - "https://www.jackhannon.me", 15 - ]; 16 - 17 - function corsHeaders(request: Request): Record<string, string> { 18 - const origin = request.headers.get("Origin"); 19 - const allowOrigin = origin && ALLOWED_ORIGINS.includes(origin) ? origin : ALLOWED_ORIGINS[0]; 20 - return { 21 - "Access-Control-Allow-Origin": allowOrigin, 22 - "Access-Control-Allow-Methods": "GET, PUT, OPTIONS", 23 - "Access-Control-Allow-Headers": "Content-Type, X-API-Key", 24 - "Access-Control-Max-Age": "86400", 25 - }; 20 + interface Env { 21 + AD_CONFIG: KVNamespace; 22 + ADMIN_API_KEY?: string; 23 + POSTHOG_PERSONAL_API_KEY?: string; 24 + POSTHOG_PROJECT_ID?: string; 25 + POSTHOG_HOST?: string; 26 26 } 27 - 28 - const NO_CACHE_HEADERS = { 29 - "Cache-Control": "no-store, no-cache, must-revalidate", 30 - "Pragma": "no-cache", 31 - }; 32 27 33 28 interface AdConfig { 34 29 id: string; ··· 47 42 tier?: string; 48 43 } 49 44 45 + const KV_KEY_LEGACY = "active_ad"; 46 + const KV_KEY_ADS = "ads"; 47 + 48 + const ALLOWED_ORIGINS = [ 49 + "https://gymtracker.jackhannon.net", 50 + "https://jackhannon.net", 51 + "https://www.jackhannon.net", 52 + "https://jackhannon.me", 53 + "https://www.jackhannon.me", 54 + "http://localhost:8788", 55 + "http://127.0.0.1:8788", 56 + ]; 57 + 58 + const NO_CACHE_HEADERS: Record<string, string> = { 59 + "Cache-Control": "no-store, no-cache, must-revalidate", 60 + Pragma: "no-cache", 61 + }; 62 + 63 + function corsHeaders(origin: string | null): Record<string, string> { 64 + const allow = 65 + origin && ALLOWED_ORIGINS.includes(origin) ? origin : ALLOWED_ORIGINS[0]; 66 + return { 67 + "Access-Control-Allow-Origin": allow, 68 + "Access-Control-Allow-Methods": "GET, PUT, DELETE, OPTIONS", 69 + "Access-Control-Allow-Headers": "Content-Type, X-API-Key", 70 + "Access-Control-Max-Age": "86400", 71 + }; 72 + } 73 + 50 74 function jsonResponse( 51 75 body: object, 52 - status = 200, 53 - headers: Record<string, string> = {}, 54 - request?: Request 76 + status: number, 77 + request: Request, 78 + extraHeaders: Record<string, string> = {} 55 79 ): Response { 56 - const cors = request ? corsHeaders(request) : corsHeaders(new Request("https://jackhannon.net")); 80 + const origin = request.headers.get("Origin"); 57 81 return new Response(JSON.stringify(body), { 58 82 status, 59 83 headers: { 60 84 "Content-Type": "application/json", 61 85 ...NO_CACHE_HEADERS, 62 - ...cors, 63 - ...headers, 64 - }, 65 - }); 66 - } 67 - 68 - function corsPreflight(request: Request): Response { 69 - return new Response(null, { 70 - status: 204, 71 - headers: { 72 - ...corsHeaders(request), 73 - "Content-Length": "0", 86 + ...corsHeaders(origin), 87 + ...extraHeaders, 74 88 }, 75 89 }); 76 90 } 77 91 78 92 function isValidUrl(s: string): boolean { 79 93 try { 80 - const u = new URL(s); 81 - return u.protocol === "https:"; 94 + return new URL(s).protocol === "https:"; 82 95 } catch { 83 96 return false; 84 97 } 85 98 } 86 99 87 - function validateAdConfig(obj: unknown): { valid: true; config: AdConfig } | { valid: false; error: string } { 100 + function validateAdConfig( 101 + obj: unknown 102 + ): { valid: true; config: AdConfig } | { valid: false; error: string } { 88 103 if (!obj || typeof obj !== "object" || Array.isArray(obj)) { 89 104 return { valid: false, error: "Invalid JSON: expected object" }; 90 105 } ··· 97 112 } 98 113 99 114 if (typeof o.active !== "boolean") { 100 - return { valid: false, error: "Missing or invalid required field: active (must be boolean)" }; 115 + return { 116 + valid: false, 117 + error: "Missing or invalid required field: active (must be boolean)", 118 + }; 101 119 } 102 120 103 121 const sponsor = o.sponsor; ··· 117 135 118 136 const destination_url = o.destination_url; 119 137 if (typeof destination_url !== "string" || !destination_url.trim()) { 120 - return { valid: false, error: "Missing or invalid required field: destination_url" }; 138 + return { 139 + valid: false, 140 + error: "Missing or invalid required field: destination_url", 141 + }; 121 142 } 122 143 if (!isValidUrl(destination_url)) { 123 144 return { valid: false, error: "destination_url must be a valid HTTPS URL" }; 124 145 } 125 146 126 - const tier = typeof o.tier === "string" ? o.tier.toLowerCase() : "banner"; 147 + const tier = 148 + typeof o.tier === "string" ? o.tier.toLowerCase() : "banner"; 127 149 if (tier !== "text" && tier !== "banner" && tier !== "feature") { 128 - return { valid: false, error: "tier must be 'text', 'banner', or 'feature'" }; 150 + return { 151 + valid: false, 152 + error: "tier must be 'text', 'banner', or 'feature'", 153 + }; 129 154 } 130 155 131 156 if (tier === "banner" || tier === "feature") { 132 157 const image_url = o.image_url; 133 158 if (image_url != null && typeof image_url !== "string") { 134 - return { valid: false, error: "image_url must be string or null for banner/feature tier" }; 159 + return { 160 + valid: false, 161 + error: "image_url must be string or null for banner/feature tier", 162 + }; 135 163 } 136 164 if (typeof image_url !== "string" || !image_url.trim()) { 137 165 return { valid: false, error: `tier '${tier}' requires image_url` }; ··· 141 169 } 142 170 } 143 171 144 - if (o.logo_url != null && typeof o.logo_url === "string" && o.logo_url.trim() && !isValidUrl(o.logo_url)) { 145 - return { valid: false, error: "logo_url must be a valid HTTPS URL or empty" }; 172 + if ( 173 + o.logo_url != null && 174 + typeof o.logo_url === "string" && 175 + o.logo_url.trim() && 176 + !isValidUrl(o.logo_url) 177 + ) { 178 + return { 179 + valid: false, 180 + error: "logo_url must be a valid HTTPS URL or empty", 181 + }; 146 182 } 147 183 148 184 const config: AdConfig = { ··· 150 186 active: Boolean(o.active), 151 187 sponsor: String(sponsor).trim(), 152 188 headline: String(headline).trim(), 153 - subline: o.subline != null && o.subline !== "" ? String(o.subline).trim() : null, 189 + subline: 190 + o.subline != null && o.subline !== "" 191 + ? String(o.subline).trim() 192 + : null, 154 193 cta: String(cta).trim(), 155 194 destination_url: String(destination_url).trim(), 156 - image_url: o.image_url != null && o.image_url !== "" ? String(o.image_url).trim() : null, 157 - logo_url: o.logo_url != null && o.logo_url !== "" ? String(o.logo_url).trim() : null, 158 - creative_version: typeof o.creative_version === "string" ? o.creative_version : "", 159 - placement: typeof o.placement === "string" && o.placement.trim() ? o.placement.trim() : "home_feed", 160 - start_at: typeof o.start_at === "string" && o.start_at.trim() ? o.start_at.trim() : undefined, 161 - end_at: typeof o.end_at === "string" && o.end_at.trim() ? o.end_at.trim() : undefined, 195 + image_url: 196 + o.image_url != null && o.image_url !== "" 197 + ? String(o.image_url).trim() 198 + : null, 199 + logo_url: 200 + o.logo_url != null && o.logo_url !== "" 201 + ? String(o.logo_url).trim() 202 + : null, 203 + creative_version: 204 + typeof o.creative_version === "string" ? o.creative_version : "", 205 + placement: 206 + typeof o.placement === "string" && o.placement.trim() 207 + ? o.placement.trim() 208 + : "home_feed", 209 + start_at: 210 + typeof o.start_at === "string" && o.start_at.trim() 211 + ? o.start_at.trim() 212 + : undefined, 213 + end_at: 214 + typeof o.end_at === "string" && o.end_at.trim() 215 + ? o.end_at.trim() 216 + : undefined, 162 217 tier, 163 218 }; 164 219 165 220 return { valid: true, config }; 166 221 } 167 222 223 + function isAdActive(config: AdConfig, now: Date): boolean { 224 + if (!config.active) return false; 225 + if (config.start_at) { 226 + const start = new Date(config.start_at); 227 + if (Number.isNaN(start.getTime()) || now < start) return false; 228 + } 229 + if (config.end_at) { 230 + const end = new Date(config.end_at); 231 + if (Number.isNaN(end.getTime()) || now > end) return false; 232 + } 233 + return true; 234 + } 235 + 236 + async function getAdsArray(kv: KVNamespace): Promise<AdConfig[]> { 237 + const legacy = await kv.get(KV_KEY_LEGACY); 238 + if (legacy) { 239 + try { 240 + const migrated = [JSON.parse(legacy) as AdConfig]; 241 + await kv.put(KV_KEY_ADS, JSON.stringify(migrated)); 242 + await kv.delete(KV_KEY_LEGACY); 243 + return migrated; 244 + } catch (err) { 245 + console.error("Migration failed:", err); 246 + return []; 247 + } 248 + } 249 + const value = await kv.get(KV_KEY_ADS); 250 + if (!value) return []; 251 + try { 252 + return JSON.parse(value) as AdConfig[]; 253 + } catch (err) { 254 + console.error("Failed to parse ads:", err); 255 + return []; 256 + } 257 + } 258 + 259 + import { getAdminHtml } from "./admin-html"; 260 + 261 + interface PerAdStats { 262 + ad_id: string; 263 + impressions: number; 264 + clicks: number; 265 + ctr_percent: number; 266 + } 267 + 268 + async function fetchPostHogStats(env: Env): Promise< 269 + | { 270 + configured: true; 271 + impressions: number; 272 + clicks: number; 273 + ctr_percent: number; 274 + per_ad: PerAdStats[]; 275 + } 276 + | { configured: false } 277 + > { 278 + const apiKey = env.POSTHOG_PERSONAL_API_KEY; 279 + const projectId = env.POSTHOG_PROJECT_ID; 280 + const host = (env.POSTHOG_HOST || "https://us.posthog.com").replace(/\/$/, ""); 281 + 282 + if (!apiKey || !projectId) { 283 + return { configured: false }; 284 + } 285 + 286 + const headers: Record<string, string> = { 287 + "Content-Type": "application/json", 288 + Authorization: `Bearer ${apiKey}`, 289 + }; 290 + 291 + const runQuery = async (query: string, name: string): Promise<number> => { 292 + const res = await fetch(`${host}/api/projects/${projectId}/query/`, { 293 + method: "POST", 294 + headers, 295 + body: JSON.stringify({ 296 + query: { kind: "HogQLQuery", query }, 297 + name, 298 + }), 299 + }); 300 + if (!res.ok) return 0; 301 + const data = (await res.json()) as { results?: unknown[][] }; 302 + const rows = data.results; 303 + if (!Array.isArray(rows) || rows.length === 0) return 0; 304 + const first = rows[0]; 305 + if (!Array.isArray(first) || first.length === 0) return 0; 306 + const val = first[0]; 307 + return typeof val === "number" ? val : parseInt(String(val), 10) || 0; 308 + }; 309 + 310 + const runQueryRows = async ( 311 + query: string, 312 + name: string 313 + ): Promise<PerAdStats[]> => { 314 + try { 315 + const res = await fetch(`${host}/api/projects/${projectId}/query/`, { 316 + method: "POST", 317 + headers, 318 + body: JSON.stringify({ 319 + query: { kind: "HogQLQuery", query }, 320 + name, 321 + }), 322 + }); 323 + if (!res.ok) return []; 324 + const data = (await res.json()) as { 325 + results?: unknown[][]; 326 + columns?: string[]; 327 + }; 328 + const rows = data.results; 329 + if (!Array.isArray(rows) || rows.length === 0) return []; 330 + const parsed: PerAdStats[] = []; 331 + for (const row of rows) { 332 + if (!Array.isArray(row) || row.length < 3) continue; 333 + const adId = String(row[0] ?? "").trim(); 334 + if (!adId) continue; 335 + const imp = typeof row[1] === "number" ? row[1] : parseInt(String(row[1]), 10) || 0; 336 + const clk = typeof row[2] === "number" ? row[2] : parseInt(String(row[2]), 10) || 0; 337 + const ctr = imp > 0 ? (clk / imp) * 100 : 0; 338 + parsed.push({ ad_id: adId, impressions: imp, clicks: clk, ctr_percent: ctr }); 339 + } 340 + return parsed; 341 + } catch { 342 + return []; 343 + } 344 + }; 345 + 346 + const perAdQuery = ` 347 + SELECT 348 + coalesce(properties.ad_id, '') AS ad_id, 349 + countIf(event = 'ad_impression') AS impressions, 350 + countIf(event = 'ad_tap') AS clicks 351 + FROM events 352 + WHERE event IN ('ad_impression', 'ad_tap') 353 + AND timestamp >= now() - INTERVAL 7 DAY 354 + AND coalesce(properties.placement, 'home_feed') = 'home_feed' 355 + GROUP BY properties.ad_id 356 + HAVING ad_id != '' 357 + ORDER BY impressions DESC 358 + `.trim(); 359 + 360 + const [aggResult, perAd] = await Promise.all([ 361 + Promise.all([ 362 + runQuery( 363 + "SELECT count() FROM events WHERE event = 'ad_impression' AND timestamp >= now() - INTERVAL 7 DAY", 364 + "ads-impressions-7d" 365 + ), 366 + runQuery( 367 + "SELECT count() FROM events WHERE event = 'ad_tap' AND timestamp >= now() - INTERVAL 7 DAY", 368 + "ads-clicks-7d" 369 + ), 370 + ]), 371 + runQueryRows(perAdQuery, "ads-per-ad-7d"), 372 + ]); 373 + 374 + const [impressions, clicks] = aggResult; 375 + const ctr_percent = impressions > 0 ? (clicks / impressions) * 100 : 0; 376 + 377 + return { 378 + configured: true, 379 + impressions, 380 + clicks, 381 + ctr_percent, 382 + per_ad: perAd, 383 + }; 384 + } 385 + 386 + /** True if request is from local dev (host=localhost; prod uses gymtracker.jackhannon.net). */ 387 + function isLocalRequest(request: Request): boolean { 388 + try { 389 + const h = new URL(request.url).hostname.toLowerCase(); 390 + return h === "localhost" || h === "127.0.0.1"; 391 + } catch { 392 + return false; 393 + } 394 + } 395 + 396 + /** True if request passed Cloudflare Access (JWT header present) or is localhost. */ 397 + function hasAccessAuth(request: Request): boolean { 398 + return isLocalRequest(request) || !!request.headers.get("Cf-Access-Jwt-Assertion"); 399 + } 400 + 168 401 export default { 169 - async fetch(request: Request, env: { AD_CONFIG: KVNamespace; ADMIN_API_KEY?: string }): Promise<Response> { 402 + async fetch( 403 + request: Request, 404 + env: Env, 405 + _ctx: ExecutionContext 406 + ): Promise<Response> { 170 407 const url = new URL(request.url); 408 + 409 + if (url.pathname === "/admin" || url.pathname === "/admin/") { 410 + if (!hasAccessAuth(request)) { 411 + const helpHtml = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>Sign in required</title></head><body style="font-family:system-ui;max-width:32rem;margin:4rem auto;padding:2rem;"> 412 + <h1>Sign in required</h1> 413 + <p>This admin is protected by <a href="https://developers.cloudflare.com/cloudflare-one/">Cloudflare Access</a>. Configure Access to protect <code>gymtracker.jackhannon.net/admin</code> and <code>/api/admin</code>.</p> 414 + <p>See <code>workers/gymtracker-ads-api/ACCESS_SETUP.md</code> for setup.</p> 415 + </body></html>`; 416 + return new Response(helpHtml, { 417 + status: 401, 418 + headers: { "Content-Type": "text/html; charset=utf-8" }, 419 + }); 420 + } 421 + return new Response(getAdminHtml(), { 422 + headers: { 423 + "Content-Type": "text/html; charset=utf-8", 424 + "Cache-Control": "no-store", 425 + }, 426 + }); 427 + } 428 + 429 + if (url.pathname === "/api/admin/stats") { 430 + if (!hasAccessAuth(request)) { 431 + return jsonResponse({ error: "Unauthorized" }, 401, request); 432 + } 433 + if (request.method !== "GET") { 434 + return jsonResponse({ error: "Method not allowed" }, 405, request); 435 + } 436 + const stats = await fetchPostHogStats(env); 437 + return jsonResponse(stats, 200, request); 438 + } 439 + 440 + if (url.pathname === "/api/admin/ads") { 441 + if (request.method === "OPTIONS") { 442 + const origin = request.headers.get("Origin"); 443 + return new Response(null, { 444 + status: 204, 445 + headers: { 446 + ...corsHeaders(origin), 447 + "Content-Length": "0", 448 + }, 449 + }); 450 + } 451 + if (!hasAccessAuth(request)) { 452 + return jsonResponse({ error: "Unauthorized" }, 401, request); 453 + } 454 + if (request.method === "GET") { 455 + const ads = await getAdsArray(env.AD_CONFIG); 456 + return jsonResponse({ ads }, 200, request); 457 + } 458 + if (request.method === "PUT") { 459 + let body: unknown; 460 + try { 461 + body = await request.json(); 462 + } catch { 463 + return jsonResponse({ error: "Invalid JSON body" }, 400, request); 464 + } 465 + const result = validateAdConfig(body); 466 + if (!result.valid) { 467 + return jsonResponse({ error: result.error }, 400, request); 468 + } 469 + const ads = await getAdsArray(env.AD_CONFIG); 470 + const idx = ads.findIndex((a) => a.id === result.config.id); 471 + if (idx >= 0) ads[idx] = result.config; 472 + else ads.push(result.config); 473 + await env.AD_CONFIG.put(KV_KEY_ADS, JSON.stringify(ads)); 474 + return jsonResponse(result.config, 200, request); 475 + } 476 + if (request.method === "DELETE") { 477 + const id = url.searchParams.get("id"); 478 + if (!id || !id.trim()) { 479 + return jsonResponse({ error: "Missing required query param: id" }, 400, request); 480 + } 481 + const ads = await getAdsArray(env.AD_CONFIG); 482 + const filtered = ads.filter((a) => a.id !== id.trim()); 483 + if (filtered.length === ads.length) { 484 + return jsonResponse({ error: "Ad not found" }, 404, request); 485 + } 486 + await env.AD_CONFIG.put(KV_KEY_ADS, JSON.stringify(filtered)); 487 + return jsonResponse({ deleted: id.trim() }, 200, request); 488 + } 489 + return jsonResponse({ error: "Method not allowed" }, 405, request); 490 + } 491 + 171 492 if (url.pathname !== "/api/ads") { 172 - return jsonResponse({ error: "Not found" }, 404, {}, request); 493 + return jsonResponse({ error: "Not found" }, 404, request); 173 494 } 174 495 175 496 if (request.method === "OPTIONS") { 176 - return corsPreflight(request); 497 + const origin = request.headers.get("Origin"); 498 + return new Response(null, { 499 + status: 204, 500 + headers: { 501 + ...corsHeaders(origin), 502 + "Content-Length": "0", 503 + }, 504 + }); 177 505 } 178 506 179 507 if (request.method === "GET") { 180 - const value = await env.AD_CONFIG.get(KV_KEY); 181 - if (!value) { 182 - return jsonResponse({ error: "No ad config found" }, 404, {}, request); 508 + const schedule = url.searchParams.get("schedule") === "1"; 509 + const apiKey = request.headers.get("X-API-Key"); 510 + const hasAdminKey = 511 + !!env.ADMIN_API_KEY && apiKey === env.ADMIN_API_KEY; 512 + 513 + if (schedule) { 514 + if (!hasAdminKey) { 515 + return jsonResponse({ error: "Unauthorized" }, 401, request); 516 + } 517 + const ads = await getAdsArray(env.AD_CONFIG); 518 + return jsonResponse({ ads }, 200, request); 183 519 } 184 - try { 185 - const config = JSON.parse(value) as AdConfig; 186 - return jsonResponse(config, 200, {}, request); 187 - } catch { 188 - return jsonResponse({ error: "Invalid stored config" }, 500, {}, request); 520 + 521 + const ads = await getAdsArray(env.AD_CONFIG); 522 + const now = new Date(); 523 + const active = ads 524 + .filter((a) => isAdActive(a, now)) 525 + .sort((a, b) => { 526 + const aStart = a.start_at ? new Date(a.start_at).getTime() : 0; 527 + const bStart = b.start_at ? new Date(b.start_at).getTime() : 0; 528 + return bStart - aStart; 529 + })[0]; 530 + 531 + if (!active) { 532 + return jsonResponse({ error: "No active ad" }, 404, request); 189 533 } 534 + return jsonResponse(active, 200, request); 190 535 } 191 536 192 537 if (request.method === "PUT") { 193 538 const apiKey = request.headers.get("X-API-Key"); 194 - const expectedKey = env.ADMIN_API_KEY; 195 - if (!expectedKey || apiKey !== expectedKey) { 196 - return jsonResponse({ error: "Unauthorized" }, 401, {}, request); 539 + if (!env.ADMIN_API_KEY || apiKey !== env.ADMIN_API_KEY) { 540 + return jsonResponse({ error: "Unauthorized" }, 401, request); 197 541 } 198 542 199 543 let body: unknown; 200 544 try { 201 545 body = await request.json(); 202 546 } catch { 203 - return jsonResponse({ error: "Invalid JSON body" }, 400, {}, request); 547 + return jsonResponse({ error: "Invalid JSON body" }, 400, request); 204 548 } 205 549 206 550 const result = validateAdConfig(body); 207 551 if (!result.valid) { 208 - return jsonResponse({ error: result.error }, 400, {}, request); 552 + return jsonResponse({ error: result.error }, 400, request); 209 553 } 210 554 211 - await env.AD_CONFIG.put(KV_KEY, JSON.stringify(result.config)); 212 - return jsonResponse(result.config, 200, {}, request); 555 + const ads = await getAdsArray(env.AD_CONFIG); 556 + const idx = ads.findIndex((a) => a.id === result.config.id); 557 + if (idx >= 0) { 558 + ads[idx] = result.config; 559 + } else { 560 + ads.push(result.config); 561 + } 562 + await env.AD_CONFIG.put(KV_KEY_ADS, JSON.stringify(ads)); 563 + return jsonResponse(result.config, 200, request); 213 564 } 214 565 215 - return jsonResponse({ error: "Method not allowed" }, 405, {}, request); 566 + return jsonResponse({ error: "Method not allowed" }, 405, request); 216 567 }, 217 568 };
+12
workers/gymtracker-ads-api/wrangler.jsonc
··· 1 1 { 2 + // Gym Tracker Ads API — schedule sponsor ads for VT Gym Tracker 2 3 "name": "gymtracker-ads-api", 4 + "vars": { 5 + "POSTHOG_PROJECT_ID": "352692", 6 + "POSTHOG_HOST": "https://us.posthog.com" 7 + }, 8 + "dev": { 9 + "host": "localhost" 10 + }, 3 11 "main": "src/index.ts", 4 12 "compatibility_date": "2025-03-07", 5 13 "compatibility_flags": ["nodejs_compat"], ··· 17 25 "routes": [ 18 26 { 19 27 "pattern": "gymtracker.jackhannon.net/api/*", 28 + "zone_name": "jackhannon.net" 29 + }, 30 + { 31 + "pattern": "gymtracker.jackhannon.net/admin*", 20 32 "zone_name": "jackhannon.net" 21 33 } 22 34 ]