max / makenotwork
- Co-Authored-By
- Claude Opus 4.7 (1M context) <noreply@anthropic.com>
26 files changed,
+1584 insertions,
-408 deletions
| @@ -3551,7 +3551,7 @@ | |||
| 3551 | 3551 | ||
| 3552 | 3552 | [[package]] | |
| 3553 | 3553 | name = "makenotwork" | |
| 3554 | - | version = "0.6.2" | |
| 3554 | + | version = "0.6.4" | |
| 3555 | 3555 | dependencies = [ | |
| 3556 | 3556 | "anyhow", | |
| 3557 | 3557 | "argon2", |
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | [package] | |
| 2 | 2 | name = "makenotwork" | |
| 3 | - | version = "0.6.3" | |
| 3 | + | version = "0.6.4" | |
| 4 | 4 | edition = "2024" | |
| 5 | 5 | license-file = "LICENSE" | |
| 6 | 6 |
| @@ -4,6 +4,26 @@ | |||
| 4 | 4 | ||
| 5 | 5 | --- | |
| 6 | 6 | ||
| 7 | + | ## 🚨 LAUNCH BLOCKER — Stripe webhooks not delivering (discovered 2026-05-16) | |
| 8 | + | ||
| 9 | + | Symptom: testaccount123 completed a $5 PWYW checkout for "Audiofiles Desktop App" at 21:27 UTC. Stripe redirected to `/stripe/success` (session `cs_live_a1o3Ky7bRCXbnKNUYrGFS1JSmBFYLCxQ8zEk6gtmYfCPsyGAiJJfLNFwGm`). Transaction row is stuck `status=pending`. No item appeared in their library. | |
| 10 | + | ||
| 11 | + | Root cause: Stripe is not delivering webhook events to `POST /stripe/webhook`. | |
| 12 | + | - `webhook_events` table: 0 rows | |
| 13 | + | - `processed_webhook_events`: 3 rows, **all from 2026-05-12** (webhooks worked previously) | |
| 14 | + | - No `/stripe/webhook` hits in prod logs for past 7 days | |
| 15 | + | - `STRIPE_WEBHOOK_SECRET` is set in `/opt/makenotwork/.env` — value not verified against dashboard | |
| 16 | + | ||
| 17 | + | Action items (Stripe Dashboard at https://dashboard.stripe.com/webhooks): | |
| 18 | + | - [ ] Confirm an endpoint exists for `https://makenot.work/stripe/webhook` | |
| 19 | + | - [ ] Confirm the endpoint is enabled (not disabled/paused) | |
| 20 | + | - [ ] Confirm live-mode toggle is on | |
| 21 | + | - [ ] Check "Webhook attempts" — recent attempts and their response codes (or zero attempts = endpoint missing/wrong) | |
| 22 | + | - [ ] Confirm signing secret matches prod `STRIPE_WEBHOOK_SECRET` | |
| 23 | + | - [ ] Subscribe the endpoint to at least: `checkout.session.completed`, `checkout.session.async_payment_succeeded`, `checkout.session.async_payment_failed`, `customer.subscription.created`, `customer.subscription.updated`, `customer.subscription.deleted`, `invoice.payment_succeeded`, `invoice.payment_failed`, `payment_intent.succeeded`, `payment_intent.payment_failed`, `account.updated` (Connect creators) | |
| 24 | + | - [ ] After fixing: use Stripe Dashboard → Events → find the testaccount123 session's `checkout.session.completed` event → "Resend" to deliver retroactively, OR manually mark the transaction completed in DB if Stripe shows the charge succeeded | |
| 25 | + | - [ ] Verify by triggering one more test purchase end-to-end; expect a row in `webhook_events` and the transaction to flip from `pending` to `completed` | |
| 26 | + | ||
| 7 | 27 | ## External Blockers | |
| 8 | 28 | ||
| 9 | 29 | ### Business Formation (Make Creative, LLC) | |
| @@ -209,9 +229,11 @@ | |||
| 209 | 229 | ### 8. Monetization & Payments | |
| 210 | 230 | ||
| 211 | 231 | - [ ] **One-time item purchase** — fixed or PWYW price via Stripe, 0% platform fee | |
| 212 | - | - [ ] Purchase flow (AF): fixed price item checkout completes | |
| 213 | - | - [ ] PWYW flow (BB): pay-what-you-want with minimum | |
| 214 | - | - [ ] Free download flow (GO): free item claimed | |
| 232 | + | - [ ] Purchase flow (AF): fixed price item checkout completes; **buyer lands on `/l/{item_id}?purchase=success`** (not `/library`) | |
| 233 | + | - [ ] PWYW flow (BB): pay-what-you-want with minimum; buyer lands on `/l/{item_id}?purchase=success` | |
| 234 | + | - [ ] Free download flow (GO): free item claimed; `/l/{item_id}` becomes accessible | |
| 235 | + | - [ ] After purchase, revisiting `/i/{item_id}` shows "View in library →" CTA (not Buy) | |
| 236 | + | - [ ] Cart purchase (multi-item) still lands on `/library?purchase=success` | |
| 215 | 237 | - [ ] **Guest checkout** — purchase without account | |
| 216 | 238 | - [ ] Email receipt with download link arrives | |
| 217 | 239 | - [ ] CORS works for embedded checkouts | |
| @@ -532,9 +554,13 @@ | |||
| 532 | 554 | ||
| 533 | 555 | - [ ] Phase 22E: MediaMTX deployment on alpha-west-1 (install binary, systemd unit, Caddy config, Cloudflare DNS, firewall rules) | |
| 534 | 556 | - [ ] Add `ffprobe` to production server (Phase 14E-1) | |
| 535 | - | - [ ] Generate Tauri signing key: `cargo tauri signer generate -w ~/.tauri/mnw.key` — save private key, note public key | |
| 536 | - | - [ ] Deploy signing key to each build host: set `TAURI_SIGNING_PRIVATE_KEY` env var (or `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` if password-protected) in the build user's environment | |
| 537 | - | - [ ] Add public key to each app's `tauri.conf.json` under `plugins.updater.pubkey` | |
| 557 | + | - [x] Generate Tauri signing keys — per-app keys at `~/.tauri/goingson.key{,.pub}` and `~/.tauri/balanced-breakfast.key{,.pub}` (AF is not a Tauri app). BB key rotated 2026-05-16 (original password unrecoverable; no shipped BB binaries with old pubkey) | |
| 558 | + | - [x] Add public keys to `tauri.conf.json` — GO and BB both wired with `plugins.updater.pubkey` + endpoint `https://makenot.work/api/v1/sync/ota/{app}/{{target}}/{{arch}}/{{current_version}}` | |
| 559 | + | - [x] Enable `bundle.createUpdaterArtifacts: true` in both apps' `tauri.conf.json` — Tauri 2 requires opt-in; without it builds skip the updater bundles + `.sig` sidecars silently | |
| 560 | + | - [x] Deploy private keys to astra + windows-x86; password file `~/.tauri/passwords.env` (Linux) / `passwords.ps1` (Windows). Master copy at `_private/tauri-passwords.env` | |
| 561 | + | - [ ] Deploy private keys to pop-os (offline 2026-05-14, last seen 2d ago — sync when next online) | |
| 562 | + | - [x] Verify signed build produces `.sig` sidecars — confirmed on astra for BB 0.3.3 (AppImage, deb, rpm all signed) | |
| 563 | + | - [ ] Full end-to-end OTA flow: build signed GO release, upload artifact via MNW release management UI, install on clean machine, confirm `/api/v1/sync/ota/...` returns 200 and Tauri updater accepts the signature | |
| 538 | 564 | - [ ] Set S3 lifecycle rule on the upload prefix: delete incomplete/unconfirmed objects after 36 hours (safety net for presigned URL cleanup) | |
| 539 | 565 | ||
| 540 | 566 | --- |
| @@ -137,6 +137,25 @@ | |||
| 137 | 137 | } | |
| 138 | 138 | ``` | |
| 139 | 139 | ||
| 140 | + | ## Library View Split smoke checklist | |
| 141 | + | ||
| 142 | + | `/i/{id}` is the **store** page (marketing, price, buy CTA); `/l/{id}` is the **library** (consumption: player, downloads, full article body). For each item type, walk these flows: | |
| 143 | + | ||
| 144 | + | - **Anonymous viewer hits `/l/{id}`** → 403 with link back to `/i/{id}` and a Log-in button. For unlisted bundle children, lists the containing bundles. | |
| 145 | + | - **Anonymous viewer hits `/i/{id}`** → 200 store layout. Text shows excerpt (not full body). Audio/video show cover only (no player). Downloads/bundle show purchase box + bundle preview (no `/l/` link on children). | |
| 146 | + | - **Owner / buyer hits `/i/{id}`** → primary CTA is "View in library →" linking to `/l/{id}`. Buy button is not shown. | |
| 147 | + | - **Owner / buyer hits `/l/{id}`** → | |
| 148 | + | - Text: full article body + reading time + discussion. | |
| 149 | + | - Audio: full player + chapters + source-file downloads (if any) + discussion. | |
| 150 | + | - Video: full player + chapters + source-file downloads (if any) + discussion. | |
| 151 | + | - Downloads / bundle: downloads hero (versions list) + download notice + bundle children with `/l/{child_id}` links + collapsible Description + tabbed Sections + License + discussion. | |
| 152 | + | - **Buy flow (single item):** Stripe success URL embeds `item_id` → buyer lands on `/l/{id}?purchase=success`. Cart purchases still land on `/library?purchase=success`. | |
| 153 | + | - **Free claim:** "Add to Library" on a free item completes a transaction → `/l/{id}` becomes accessible. | |
| 154 | + | - **Bundle child access:** Buying bundle B grants `/l/{C}` for every C in B via `bundles::has_access_via_bundle`. | |
| 155 | + | - **View tracking:** `track_view("item", id)` fires from `/l/{id}` only — `/i/{id}` no longer records views. | |
| 156 | + | - **`noindex` on `/l/{id}`:** library views ship `<meta robots="noindex">` and no OG tags. | |
| 157 | + | - **Already-purchased redirect:** POST `/stripe/checkout/{item_id}` with an existing completed purchase redirects to `/l/{id}`. | |
| 158 | + | ||
| 140 | 159 | ## Key Paths | |
| 141 | 160 | ||
| 142 | 161 | - `tests/harness/` — TestHarness, TestClient, helper methods |
| @@ -100,6 +100,79 @@ | |||
| 100 | 100 | ||
| 101 | 101 | --- | |
| 102 | 102 | ||
| 103 | + | ## Library View Split (`/i/{id}` store + `/l/{id}` library) | |
| 104 | + | ||
| 105 | + | **Goal:** `/i/{id}` is always the *store* page (marketing, description, price, buy CTA) — same layout for everyone, only the CTA changes ("Buy Once" → "View in library" when the viewer has access). `/l/{id}` is the *library* page (downloads / player / reader / consumption) — only accessible to users with access (purchased, subscribed, creator, bundle-granted, or free-claimed). | |
| 106 | + | ||
| 107 | + | **Why:** today `/i/{id}` silently morphs based on `has_access` — buyer can't revisit the store page once they own the item, shareable URLs render differently for owners vs. visitors, and the downloads list teases unpurchased viewers (filenames + sizes visible, click 401s). Splitting clarifies intent and matches the prior art (Gumroad `/l/`, itch, Bandcamp). | |
| 108 | + | ||
| 109 | + | **Decisions locked (resolved before Phase 0):** | |
| 110 | + | - **View tracking swap:** `/l/{id}` is the only path that calls `track_view("item", id)`. `/i/{id}` stops tracking views. Consumption is the meaningful retention signal; store-traffic analytics deferred until requested. | |
| 111 | + | - **403 vs. 404 on `/l/{id}`:** item missing → 404. Item exists but unpublished/deleted and viewer is not owner → 404 (don't leak draft existence). Item public but viewer lacks access → 403 with link back to `/i/{id}`. Bundle-only (unlisted) items in the 403 case list **all** containing bundles, matching current store behavior. | |
| 112 | + | - **OG / Twitter cards on `/l/{id}`:** deferred to its own task — ship Phase 0 with `noindex` and no OG tags. Revisit if buyers want to share library URLs. | |
| 113 | + | - **Owner "preview as visitor" toggle:** not building. The split makes `/i/{id}` identically rendered for all viewers (CTA label is the only diff). Private window covers the rare case. | |
| 114 | + | - **Bundle-child "via bundle" provenance badge on `/l/{child_id}`:** not building in v1. Being on `/l/{id}` already implies access; provenance belongs in Library index / receipts. | |
| 115 | + | ||
| 116 | + | **Phase 0 — Shared scaffolding** (lands first; subsequent phases plug item-type templates into this) | |
| 117 | + | - [x] Route `GET /l/{item_id}` in `routes/pages/public/mod.rs` → new `content::library_page` handler. | |
| 118 | + | - [x] `library_page` handler: parse `ItemId`, load `db_item`/`db_project`/`db_user`, compute the same `AccessContext` as `item_page`, branch: | |
| 119 | + | - [x] Item missing / sandbox seller → 404 | |
| 120 | + | - [x] Item unpublished or soft-deleted and viewer is not owner → 404 | |
| 121 | + | - [x] Item public but `!has_access` → 403 (render `library_locked.html` with link to `/i/{id}`; for unlisted items, list all containing bundles) | |
| 122 | + | - [x] Otherwise → render library template; `track_view("item", id)` here | |
| 123 | + | - [x] `item_page` (`/i/{id}`): **remove** the `track_view` call (moved to `/l/{id}`). | |
| 124 | + | - [x] Pick template by `item_type` (mirrors `render_item_page`): text → `library_text.html`, audio → `library_audio.html`, video → `library_video.html`, bundle/other → `library_downloads.html`. Phases 1–4 implement these. Phase 0 ships a placeholder `library_downloads.html` so the route works end-to-end. | |
| 125 | + | - [x] `library_locked.html` template (the 403 page): minimal — title, cover, "you don't have access to this yet", buttons to `/i/{id}` (store page) and `/login` (if not logged in). For unlisted items, render the existing "Available in: Bundle X, Bundle Y" block. | |
| 126 | + | - [x] `noindex` meta on `library_*.html`. No OG/Twitter cards (deferred). | |
| 127 | + | - [x] Update `item.html` / `text_reader.html` / `audio_player.html` / `video_player.html` to **always render the store layout**, swapping the primary CTA: when `has_access` is true, replace the Buy/PWYW/Subscribe button with a "View in library →" link to `/l/{item_id}`. Remove the body/player/downloads gating from the store templates entirely (those move to `/l/{id}`). | |
| 128 | + | - [x] Redirect targets that should now go to `/l/{id}`: | |
| 129 | + | - [x] `routes/stripe/checkout/item.rs::create_checkout` already-purchased redirect (currently `/i/{id}`) | |
| 130 | + | - [x] Stripe checkout success for single-item purchases (currently `/library?purchase=success` — make it `/l/{id}?purchase=success` so the buyer lands on the downloads, not a list) | |
| 131 | + | - [x] `routes/stripe/webhook` confirmation emails — "View your purchase" link | |
| 132 | + | - [x] Cart success path (multi-item) stays at `/library?purchase=success` since there's no single item | |
| 133 | + | - [x] Library index row "View" links: `/i/{id}` → `/l/{id}` | |
| 134 | + | - [x] Receipt page "View item" link | |
| 135 | + | - [x] Custom domain fallback (`routes/custom_domain.rs::render_item_page`) — keep at `/i/{id}` semantics; library URL is platform-only. | |
| 136 | + | - [x] Bundle children: if user has access to bundle B and child C is unlisted, `/l/{C}` must still grant access via `bundles::has_access_via_bundle`. Already covered by existing access logic — verify in handler. | |
| 137 | + | - [x] Free items: claiming a free item must enable `/l/{id}` access. `library/add` creates a completed transaction, so `has_purchased_item` returns true → already works. Verify. | |
| 138 | + | - [x] Sandbox / unpublished items: creator viewing their own unpublished item — `is_owner` already grants access; library view should work as a preview. | |
| 139 | + | - [x] Audit all template references to `/i/{` to spot anything that should change to `/l/{` (cart cards, dashboard items, transaction history, search results). Most stay on `/i/{` — that's the *store* page now, still the right link for browsing. The migration is targeted: post-purchase flows + library index. | |
| 140 | + | ||
| 141 | + | **Phase 1 — `/l/{id}` for downloads / bundle / other items** (item.html path; audiofiles, GO/BB if they ship as downloads) | |
| 142 | + | - [x] New `templates/pages/library_downloads.html`: hero = Downloads section (versions list with filename, size, version label, download button). Below: download notice ("Provided by third-party creators, scan before running"). Below: bundle contents (if bundle) with `/l/{child_id}` links. Collapsible Description (`<details>` closed by default) and tabbed Sections. License section. Discussion. | |
| 143 | + | - [x] Strip downloads + download-notice sections out of `item.html`. Leave purchase box, description, sections, license, bundle contents (preview only, no /l/ link if user lacks access). | |
| 144 | + | - [x] `item.html` CTA swap: when `has_access` → replace Buy/PWYW button with "View in library →" linking to `/l/{id}`. Keep "Add to cart" / "Wishlist" / "Save to collection" buttons visible for owners (they may want to gift, etc. — TBD). | |
| 145 | + | - [x] Smoke: buy audiofiles as testaccount123 → success → land on `/l/{audiofiles_item_id}` → download files works → revisit `/i/{audiofiles_item_id}` → store page shows with "View in library" CTA. | |
| 146 | + | ||
| 147 | + | **Phase 2 — `/l/{id}` for audio items** (audio_player.html path; podcast/music) | |
| 148 | + | - [x] New `templates/pages/library_audio.html`: full player as hero, episode/track info, downloads section if the item has version files (some audio items offer source files alongside the stream), description, discussion. | |
| 149 | + | - [x] `audio_player.html` → store layout only: cover, title, price, description. **Decide on preview**: 30s sample (requires server-side trimmed asset, not in scope here) vs. cover-only. Default: cover-only on store; can layer preview later as a separate task. | |
| 150 | + | - [x] Update `/api/stream/{item_id}` access check — already gates on `can_access`; no code change, just confirm. | |
| 151 | + | - [x] Smoke with a paid audio item from GO seed content. | |
| 152 | + | ||
| 153 | + | **Phase 3 — `/l/{id}` for video items** (video_player.html path) | |
| 154 | + | - [x] New `templates/pages/library_video.html`: full player as hero (same `/api/stream/{id}` endpoint), description, downloads (if any), discussion. | |
| 155 | + | - [x] `video_player.html` → store layout: cover/poster image, title, price, description, buy CTA. No video element on store (cover only — videos are expensive to ship as previews). | |
| 156 | + | - [x] Smoke with a video item. | |
| 157 | + | ||
| 158 | + | **Phase 4 — `/l/{id}` for text items** (text_reader.html path) | |
| 159 | + | - [x] New `templates/pages/library_text.html`: full article body as hero, reading-time, TOC (if implemented later), discussion. | |
| 160 | + | - [x] `text_reader.html` → store layout: title, byline, reading-time, **excerpt** (first paragraph or first 200 chars of plain-text body — preview value is real here, unlike audio/video). Add `excerpt` field to `Item` view-model or compute in handler. Buy CTA. | |
| 161 | + | - [x] Smoke with a text item from a project. | |
| 162 | + | ||
| 163 | + | **Phase 5 — cleanup pass after all 4 land** | |
| 164 | + | - [x] Delete now-unused branches (any leftover `{% if has_access %}...player...{% endif %}` in store templates). | |
| 165 | + | - [x] Update `docs/test_plan.md` smoke checklist with `/l/{id}` flow. | |
| 166 | + | - [x] Cross-link audit pass: re-grep templates for `/i/{` and confirm each remaining link is intentional (store page link, not consumption). | |
| 167 | + | - [x] Update `human_todo.md` purchase-flow test cases to assert landing on `/l/{id}`. | |
| 168 | + | - [x] Consider: 301 redirect `/i/{id}/download` (legacy?) → `/l/{id}`. Search for any such legacy paths first. | |
| 169 | + | ||
| 170 | + | **Open questions to resolve during Phase 0:** | |
| 171 | + | - Should owners see a "Preview as visitor" toggle on `/i/{id}` like the current dashboard preview? Probably out of scope — they can log out or use a private window. | |
| 172 | + | - Bundle children that are themselves owned individually: how does `/l/{child}` decide whether to show "in your library" badge for both the child and via-bundle access? Just show; access is access. | |
| 173 | + | ||
| 174 | + | --- | |
| 175 | + | ||
| 103 | 176 | ## DIY Tier (Post-Launch, exploratory) | |
| 104 | 177 | ||
| 105 | 178 | Ko-fi-style cheap tier: **$12/yr**, embeds + Stripe Connect only. No file hosting, no profile, no discovery, no mobile, no themes. Positioned as creator-acquisition wedge; modeled at break-even (~$0.10/yr infra, ~$11.24/yr support+margin budget). Tier viability requires ≥95% of DIY creators never contact support — every item below is in service of that constraint. | |
| @@ -340,6 +413,13 @@ | |||
| 340 | 413 | - [x] Compact Fan+ pane in dashboard account tab: status + period end + Cancel/Resume/Manage billing buttons (intentionally not pushy — non-subscribers see a one-line "Learn about Fan+" link, no upsell) | |
| 341 | 414 | ||
| 342 | 415 | ### Global UX | |
| 416 | + | - [ ] **Misleading webhook error message** — discovered 2026-05-16 during launch-blocker triage. `payments::verify_webhook` logs `"webhook signature verification failed"` and returns response body `"Invalid webhook signature"` for ALL failure modes, including JSON parse errors (`BadParse(missing field X)`) caused by Stripe API version mismatch. This made diagnosis take an extra step (the signature was fine; the schema was wrong). Fix: distinguish signature errors from parse errors in both the log message and the HTTP response body. Two log lines / two response codes (still 400 in both cases is OK, but body should say what actually failed). | |
| 417 | + | - [ ] **Checkout error messages not surfaced to user** — reported 2026-05-16. Backend `AppError::BadRequest` returns useful messages (e.g. "You cannot purchase your own items", "Creator's payment account is not ready", "This promo code has expired") at `routes/stripe/checkout/item.rs`, but the frontend checkout flow shows a generic "Error" instead of the body. Fix: surface `response.text()` from non-2xx HTMX/fetch responses in the checkout UI (and audit other 4xx paths for the same pattern). Especially load-bearing for promo-code rejection and creator-not-ready cases, which buyers will hit during normal use. | |
| 418 | + | - [ ] **Library page scroll-in-scroll + row dropdown** — reported 2026-05-16 during human testing. Buyer library page has nested scroll (page scroll + an inner scroll region) which is awkward, and the per-row `...` action dropdown clips or fights with the inner scroll. Redesign: flatten to a single scroll context and either make the row actions an inline action bar or use a portaled menu that escapes the scroll container. | |
| 419 | + | - [ ] **Stuck "Verbing..." buttons** — reported 2026-05-16 during human testing. After failed or non-swapping requests, many action buttons stay in their loading state (Saving... / Deleting... / Uploading... / ...) forever. Two root causes: | |
| 420 | + | - `static/mnw.js:290-304` `htmx:afterRequest` handler only restores buttons inside a `<form>`. Buttons with `hx-post`/`hx-delete` directly on them (no parent form) never get reset. | |
| 421 | + | - Per-file `fetch()` loading-state code in `static/item-details.js`, `static/blog-editor.js`, `static/media-picker.js`, `static/collections.js` each manually toggle textContent and don't consistently restore on error/non-JSON responses. | |
| 422 | + | - Fix: add a shared `withLoadingState(btn, verb, asyncFn)` helper with `try/finally`, migrate the per-file patterns to use it, and widen the htmx handler so non-form buttons (whose own `evt.detail.elt` is the button) also get reset. | |
| 343 | 423 | - [ ] Find a better place for the keyboard shortcuts help button (removed from header nav) | |
| 344 | 424 | - [ ] Add toast stacking for multiple notifications | |
| 345 | 425 | - [ ] Add "New" badge/dot indicator on recently launched features |
| @@ -64,6 +64,11 @@ | |||
| 64 | 64 | ProjectTemplate, | |
| 65 | 65 | ProjectPaywallTemplate, | |
| 66 | 66 | ItemTemplate, | |
| 67 | + | LibraryAudioTemplate, | |
| 68 | + | LibraryDownloadsTemplate, | |
| 69 | + | LibraryLockedTemplate, | |
| 70 | + | LibraryTextTemplate, | |
| 71 | + | LibraryVideoTemplate, | |
| 67 | 72 | TextReaderTemplate, | |
| 68 | 73 | AudioPlayerTemplate, | |
| 69 | 74 | VideoPlayerTemplate, |
| @@ -84,79 +84,38 @@ | |||
| 84 | 84 | {% endmatch %} | |
| 85 | 85 | </div> | |
| 86 | 86 | ||
| 87 | - | {% if has_access %} | |
| 88 | - | <div class="media-player"> | |
| 89 | - | <audio id="media-a" preload="metadata"> | |
| 90 | - | {% if let Some(audio_url) = audio_url %} | |
| 91 | - | <source src="{{ audio_url }}" type="audio/mpeg"> | |
| 92 | - | {% endif %} | |
| 93 | - | </audio> | |
| 94 | - | <audio id="media-b" preload="metadata"></audio> | |
| 95 | - | ||
| 96 | - | <div class="player-controls"> | |
| 97 | - | <button class="play-button" id="play-btn" aria-label="Play"> | |
| 98 | - | <svg id="play-icon" width="24" height="24" viewBox="0 0 24 24" fill="currentColor"> | |
| 99 | - | <path d="M8 5v14l11-7z"/> | |
| 100 | - | </svg> | |
| 101 | - | <svg id="pause-icon" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" style="display: none;"> | |
| 102 | - | <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/> | |
| 103 | - | </svg> | |
| 104 | - | </button> | |
| 105 | - | <div class="progress-container"> | |
| 106 | - | <div class="progress-bar" id="progress-bar"> | |
| 107 | - | <div class="progress-fill" id="progress-fill"></div> | |
| 108 | - | </div> | |
| 109 | - | <div class="time-display"> | |
| 110 | - | <span id="current-time">0:00</span> | |
| 111 | - | <span id="duration">0:00</span> | |
| 112 | - | </div> | |
| 113 | - | </div> | |
| 114 | - | </div> | |
| 115 | - | ||
| 116 | - | <div class="insertion-label" id="insertion-label"></div> | |
| 117 | - | <button class="skip-insertion" id="skip-btn" type="button">Skip ›</button> | |
| 118 | - | ||
| 119 | - | <div class="player-secondary"> | |
| 120 | - | <div class="speed-control"> | |
| 121 | - | <span>Speed:</span> | |
| 122 | - | <button class="speed-button" data-speed="0.5">0.5x</button> | |
| 123 | - | <button class="speed-button active" data-speed="1">1x</button> | |
| 124 | - | <button class="speed-button" data-speed="1.5">1.5x</button> | |
| 125 | - | <button class="speed-button" data-speed="2">2x</button> | |
| 126 | - | </div> | |
| 127 | - | <div class="volume-control"> | |
| 128 | - | <span class="volume-icon"> | |
| 129 | - | <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"> | |
| 130 | - | <path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/> | |
| 131 | - | </svg> | |
| 132 | - | </span> | |
| 133 | - | <input type="range" class="volume-slider" id="volume-slider" min="0" max="100" value="75" /> | |
| 134 | - | </div> | |
| 135 | - | </div> | |
| 136 | - | </div> | |
| 137 | - | ||
| 138 | 87 | {% if !item.description.is_empty() %} | |
| 139 | 88 | <div class="media-description"> | |
| 140 | 89 | <p>{{ item.description }}</p> | |
| 141 | 90 | </div> | |
| 142 | 91 | {% endif %} | |
| 143 | - | {% else %} | |
| 144 | - | {% include "partials/paywall.html" %} | |
| 145 | - | {% endif %} | |
| 146 | 92 | ||
| 147 | - | {% if !chapters.is_empty() %} | |
| 148 | - | <div class="chapters"> | |
| 149 | - | <h3>Chapters</h3> | |
| 150 | - | <ul class="chapter-list"> | |
| 151 | - | {% for chapter in chapters %} | |
| 152 | - | <li class="chapter-item"{% if has_access %} data-time="{{ chapter.start_seconds }}"{% endif %}> | |
| 153 | - | <span class="chapter-title">{{ chapter.title }}</span> | |
| 154 | - | <span class="chapter-time">{{ chapter.timestamp }}</span> | |
| 155 | - | </li> | |
| 156 | - | {% endfor %} | |
| 157 | - | </ul> | |
| 93 | + | <div class="store-cta" style="margin: 2rem 0; padding: 1.5rem; background: var(--surface-muted); text-align: center;"> | |
| 94 | + | {% if has_access %} | |
| 95 | + | <p style="font-size: 1.1rem; margin-bottom: 1rem;">You have access to this item.</p> | |
| 96 | + | <a href="/l/{{ item.id }}"><button class="primary">View in library →</button></a> | |
| 97 | + | {% else if item.is_free %} | |
| 98 | + | {% if let Some(_user) = session_user %} | |
| 99 | + | {% if in_library %} | |
| 100 | + | <span class="library-status" style="font-family: var(--font-mono); color: var(--text-muted);">In your library · <a href="/l/{{ item.id }}">Listen now →</a></span> | |
| 101 | + | {% else %} | |
| 102 | + | <button class="add-to-library-btn primary" | |
| 103 | + | hx-post="/api/library/add/{{ item.id }}" | |
| 104 | + | hx-swap="outerHTML">Add to Library - Free</button> | |
| 105 | + | {% endif %} | |
| 106 | + | {% else %} | |
| 107 | + | <a href="/login?redirect=/l/{{ item.id }}"><button class="primary">Log in to listen - Free</button></a> | |
| 108 | + | {% endif %} | |
| 109 | + | {% else %} | |
| 110 | + | <p style="font-size: 1.5rem; margin-bottom: 1rem;">{{ item.price }}</p> | |
| 111 | + | {% if let Some(_user) = session_user %} | |
| 112 | + | <a href="/purchase/{{ item.id }}"><button class="primary">{% if item.pwyw_enabled %}Pay What You Want{% else %}Buy Once{% endif %} - {{ item.price }}</button></a> | |
| 113 | + | {% else %} | |
| 114 | + | <a href="/login?redirect=/i/{{ item.id }}"><button class="primary">Log in to purchase</button></a> | |
| 115 | + | {% endif %} | |
| 116 | + | <p style="font-size: 0.85rem; opacity: 0.7; margin-top: 0.75rem;">Support {{ creator_username }} directly — 0% platform fee.</p> | |
| 117 | + | {% endif %} | |
| 158 | 118 | </div> | |
| 159 | - | {% endif %} | |
| 160 | 119 | ||
| 161 | 120 | {% if !item.tags.is_empty() %} | |
| 162 | 121 | <div class="media-tags"> | |
| @@ -166,24 +125,6 @@ | |||
| 166 | 125 | </div> | |
| 167 | 126 | {% endif %} | |
| 168 | 127 | ||
| 169 | - | {% if is_free %} | |
| 170 | - | <div class="library-action" style="margin-top: 2rem; padding-top: 2rem; border-top: 1px solid var(--border);"> | |
| 171 | - | {% if let Some(_user) = session_user %} | |
| 172 | - | {% if in_library %} | |
| 173 | - | <span class="library-status" style="font-family: var(--font-mono); color: var(--text-muted);">In your library</span> | |
| 174 | - | {% else %} | |
| 175 | - | <button class="add-to-library-btn" | |
| 176 | - | hx-post="/api/library/add/{{ item.id }}" | |
| 177 | - | hx-swap="outerHTML" | |
| 178 | - | style="background: var(--primary-dark); color: var(--primary-light); border: none; padding: 0.75rem 1.5rem; font-family: var(--font-mono); cursor: pointer;"> | |
| 179 | - | Add to Library | |
| 180 | - | </button> | |
| 181 | - | {% endif %} | |
| 182 | - | {% else %} | |
| 183 | - | <a href="/login" style="font-family: var(--font-mono); color: var(--highlight);">Log in to add to library</a> | |
| 184 | - | {% endif %} | |
| 185 | - | </div> | |
| 186 | - | {% endif %} | |
| 187 | 128 | </article> | |
| 188 | 129 | ||
| 189 | 130 | {% include "partials/discussion_section.html" %} | |
| @@ -194,15 +135,4 @@ | |||
| 194 | 135 | </footer> | |
| 195 | 136 | {% endblock %} | |
| 196 | 137 | ||
| 197 | - | {% block scripts %} | |
| 198 | - | {% if has_access %} | |
| 199 | - | <script id="media-player-data" type="application/json"> | |
| 200 | - | { | |
| 201 | - | "segments": {{ segments_json|safe }}, | |
| 202 | - | "mediaType": "audio", | |
| 203 | - | "itemId": "{{ item.id }}" | |
| 204 | - | } | |
| 205 | - | </script> | |
| 206 | - | <script src="/static/media-player.js"></script> | |
| 207 | - | {% endif %} | |
| 208 | - | {% endblock %} | |
| 138 | + | {% block scripts %}{% endblock %} |