Skip to main content

max / makenotwork

v0.6.4: split item page into store (/i/) and library (/l/) views /i/{id} is now always the store page (marketing, price, buy CTA) for every viewer; /l/{id} is the consumption surface (player, downloads, full article body) gated on access. Per-type library templates: library_text, library_audio, library_video, library_downloads, plus library_locked for 403s. Store templates carry only excerpt/cover + a CTA that flips to "View in library" when the viewer has access. View tracking moved to /l/. Stripe single-item success URL embeds item_id so buyers land on /l/{id}?purchase=success; cart purchases still land on /library?purchase=success. Library index rows, receipt "View item", and project "View Content" point at /l/. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-17 19:45 UTC
Commit: ccf23453f8ed15a5f592869a45c68fb661f1e61b
Parent: aedfc5a
26 files changed, +1584 insertions, -408 deletions
@@ -3551,7 +3551,7 @@ dependencies = [
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 @@ Items requiring manual action, external accounts, legal engagement, design decis
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 @@ Feature map generated from full codebase walk. Every user-facing feature enumera
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 @@ Features documented in public docs that don't exist in code yet:
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 @@ mod tests {
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 @@ The platform currently uses Cloudflare as a thin DNS+CDN layer for static and fr
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 @@ Generic mechanism so any "Log in with MNW" implementer (MT first, future service
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
@@ -8,7 +8,6 @@ use tower_sessions::Session;
8 8
9 9 use crate::{
10 10 auth::{MaybeUser, SessionUser},
11 - constants,
12 11 db::{self, ContentData, ItemId, ItemType},
13 12 error::{AppError, Result},
14 13 helpers::{fetch_discussion_info, get_csrf_token, get_initials},
@@ -23,7 +22,6 @@ use crate::{
23 22 pub(in crate::routes::pages::public) async fn item_page(
24 23 State(state): State<AppState>,
25 24 session: Session,
26 - headers: axum::http::HeaderMap,
27 25 MaybeUser(maybe_user): MaybeUser,
28 26 Path(item_id): Path<String>,
29 27 ) -> Result<Response> {
@@ -41,17 +39,9 @@ pub(in crate::routes::pages::public) async fn item_page(
41 39 if db_user.is_sandbox {
42 40 return Err(AppError::NotFound);
43 41 }
44 - let response = render_item_page(
45 - &state, &db_item, &db_project, &db_user, csrf_token, maybe_user,
46 - )
47 - .await?;
48 - let ua = headers.get(axum::http::header::USER_AGENT)
49 - .and_then(|v| v.to_str().ok())
50 - .unwrap_or("");
51 - if !super::is_bot(ua) {
52 - super::track_view(state.db.clone(), "item", *db_item.id);
53 - }
54 - Ok(response)
42 + // View tracking moved to /l/{id} — consumption is the meaningful signal,
43 + // not store-page traffic.
44 + render_item_page(&state, &db_item, &db_project, &db_user, csrf_token, maybe_user).await
55 45 }
56 46
57 47 /// Shared item page renderer, used by both named routes and custom domain fallback.
@@ -75,16 +65,16 @@ pub(crate) async fn render_item_page(
75 65 return Err(AppError::NotFound);
76 66 }
77 67
78 - let db_versions = db::versions::get_versions_by_item(&state.db, db_item.id).await?;
79 -
80 68 let cdn_base = state.config.cdn_base_url.as_deref().unwrap_or("https://cdn.makenot.work");
81 - let (body_html, reading_time) = match db_item.content() {
69 + // Store page never renders the full article body — that lives on /l/{id}.
70 + // Compute a short plain-text excerpt from the raw markdown for the deck.
71 + let (excerpt, reading_time) = match db_item.content() {
82 72 ContentData::Text {
83 73 body,
84 74 reading_time_minutes,
85 75 ..
86 76 } => (
87 - body.as_ref().map(|b| crate::markdown::render_creator_markdown(b, db_project.user_id, cdn_base)),
77 + body.as_ref().map(|b| make_excerpt(b, 280)),
88 78 reading_time_minutes.map(|m| format!("{} min read", m)),
89 79 ),
90 80 _ => (None, None),
@@ -146,13 +136,13 @@ pub(crate) async fn render_item_page(
146 136 let item = Item::from_db_detail(
147 137 db_item,
148 138 &item_tags,
149 - body_html.clone(),
139 + None,
150 140 reading_time.clone(),
151 141 is_free,
152 142 has_access,
153 143 );
154 144
155 - if db_item.item_type == ItemType::Text && body_html.is_some() {
145 + if db_item.item_type == ItemType::Text {
156 146 let avatar_initials =
157 147 get_initials(db_user.display_name.as_deref().unwrap_or(&db_user.username));
158 148 let project_slug_str = db_project.slug.to_string();
@@ -170,6 +160,8 @@ pub(crate) async fn render_item_page(
170 160 is_free,
171 161 in_library,
172 162 has_access,
163 + reading_time,
164 + excerpt,
173 165 host_url: state.config.host_url.clone(),
174 166 discussion_url,
175 167 discussion_count,
@@ -180,46 +172,6 @@ pub(crate) async fn render_item_page(
180 172 if db_item.item_type == ItemType::Audio {
181 173 let avatar_initials =
182 174 get_initials(db_user.display_name.as_deref().unwrap_or(&db_user.username));
183 - let db_chapters = db::chapters::get_chapters_by_item(&state.db, db_item.id).await?;
184 - let chapters: Vec<Chapter> = db_chapters.iter().map(Chapter::from).collect();
185 -
186 - let audio_url = if has_access {
187 - match db_item.content() {
188 - ContentData::Audio {
189 - audio_s3_key,
190 - duration_seconds,
191 - audio_url,
192 - ..
193 - } => {
194 - if let (Some(s3_key), Some(s3)) = (&audio_s3_key, &state.s3) {
195 - let expiry_secs = match duration_seconds {
196 - Some(duration) => ((duration as u64) * 2)
197 - .clamp(3600, constants::STREAMING_CACHE_MAX_SECS),
198 - None => 3600,
199 - };
200 - match s3.presign_download(s3_key, Some(expiry_secs)).await {
201 - Ok(url) => Some(url),
202 - Err(e) => {
203 - tracing::warn!(s3_key = %s3_key, error = ?e, "failed to generate presigned url");
204 - audio_url
205 - }
206 - }
207 - } else {
208 - audio_url
209 - }
210 - }
211 - _ => None,
212 - }
213 - } else {
214 - None
215 - };
216 -
217 - let segments_json = if has_access {
218 - build_segments_json(state, db_item.id, &audio_url, db_item).await
219 - } else {
220 - "null".to_string()
221 - };
222 -
223 175 let project_slug_str = db_project.slug.to_string();
224 176 let (discussion_url, discussion_count) =
225 177 fetch_discussion_info(state, db_item.mt_thread_id, &project_slug_str, "items").await;
@@ -232,12 +184,9 @@ pub(crate) async fn render_item_page(
232 184 creator_avatar_initials: avatar_initials,
233 185 project_title: Some(db_project.title.clone()),
234 186 project_slug: project_slug_str,
235 - audio_url,
236 - chapters,
237 187 is_free,
238 188 in_library,
239 189 has_access,
240 - segments_json,
241 190 host_url: state.config.host_url.clone(),
242 191 discussion_url,
243 192 discussion_count,
@@ -248,45 +197,6 @@ pub(crate) async fn render_item_page(
248 197 if db_item.item_type == ItemType::Video {
249 198 let avatar_initials =
250 199 get_initials(db_user.display_name.as_deref().unwrap_or(&db_user.username));
251 - let db_chapters = db::chapters::get_chapters_by_item(&state.db, db_item.id).await?;
252 - let chapters: Vec<Chapter> = db_chapters.iter().map(Chapter::from).collect();
253 -
254 - let video_url = if has_access {
255 - match db_item.content() {
256 - ContentData::Video {
257 - video_s3_key,
258 - duration_seconds,
259 - ..
260 - } => {
261 - if let (Some(s3_key), Some(s3)) = (&video_s3_key, &state.s3) {
262 - let expiry_secs = match duration_seconds {
263 - Some(duration) => ((duration as u64) * 2)
264 - .clamp(3600, constants::STREAMING_CACHE_MAX_SECS),
265 - None => 3600,
266 - };
267 - match s3.presign_download(s3_key, Some(expiry_secs)).await {
268 - Ok(url) => Some(url),
269 - Err(e) => {
270 - tracing::warn!(s3_key = %s3_key, error = ?e, "failed to generate presigned video url");
271 - None
272 - }
273 - }
274 - } else {
275 - None
276 - }
277 - }
278 - _ => None,
279 - }
280 - } else {
281 - None
282 - };
283 -
284 - let segments_json = if has_access {
285 - build_segments_json(state, db_item.id, &video_url, db_item).await
286 - } else {
287 - "null".to_string()
288 - };
289 -
290 200 let project_slug_str = db_project.slug.to_string();
291 201 let (discussion_url, discussion_count) =
292 202 fetch_discussion_info(state, db_item.mt_thread_id, &project_slug_str, "items").await;
@@ -299,12 +209,9 @@ pub(crate) async fn render_item_page(
299 209 creator_avatar_initials: avatar_initials,
300 210 project_title: Some(db_project.title.clone()),
301 211 project_slug: project_slug_str,
302 - video_url,
303 - chapters,
304 212 is_free,
305 213 in_library,
306 214 has_access,
307 - segments_json,
308 215 host_url: state.config.host_url.clone(),
309 216 discussion_url,
310 217 discussion_count,
@@ -312,8 +219,6 @@ pub(crate) async fn render_item_page(
312 219 .into_response());
313 220 }
314 221
315 - let versions: Vec<Version> = db_versions.iter().map(Version::from_db).collect();
316 -
317 222 let project_slug_str = db_project.slug.to_string();
318 223 let (discussion_url, discussion_count) =
319 224 fetch_discussion_info(state, db_item.mt_thread_id, &project_slug_str, "items").await;
@@ -366,7 +271,6 @@ pub(crate) async fn render_item_page(
366 271 creator_username: db_user.username.to_string(),
367 272 project_title: db_project.title.clone(),
368 273 project_slug: project_slug_str,
369 - versions,
370 274 host_url: state.config.host_url.clone(),
371 275 project_cover_image_url: db_project.cover_image_url.clone(),
372 276 discussion_url,
@@ -378,6 +282,7 @@ pub(crate) async fn render_item_page(
378 282 is_wishlisted,
379 283 in_cart,
380 284 collection_count,
285 + has_access,
381 286 }
382 287 .into_response())
383 288 }
@@ -396,7 +301,7 @@ struct PlayerSegment {
396 301 }
397 302
398 303 /// Build the segments JSON for the media player. Returns "null" if no insertions.
399 - async fn build_segments_json(
304 + pub(super) async fn build_segments_json(
400 305 state: &AppState,
401 306 item_id: ItemId,
402 307 media_url: &Option<String>,
@@ -527,3 +432,68 @@ async fn build_segments_json(
527 432 // <script> tag via Askama's |safe filter.
528 433 json.replace("</", "<\\/")
529 434 }
435 +
436 + /// Build a short plain-text excerpt from raw markdown.
437 + ///
438 + /// Takes the first paragraph (up to the first blank line), strips obvious
439 + /// markdown syntax, collapses whitespace, and truncates at `max_chars` with a
440 + /// trailing ellipsis. Used for the store-page deck so visitors can preview a
441 + /// paid article without unlocking the full body.
442 + fn make_excerpt(body: &str, max_chars: usize) -> String {
443 + let first_para = body.split("\n\n").find(|p| !p.trim().is_empty()).unwrap_or("");
444 + let stripped: String = first_para
445 + .lines()
446 + .map(|line| line.trim_start_matches(['#', '>', '-', '*', ' ']))
447 + .collect::<Vec<_>>()
448 + .join(" ");
449 + let plain: String = stripped
450 + .replace(['*', '_', '`', '[', ']'], "")
451 + .split_whitespace()
452 + .collect::<Vec<_>>()
453 + .join(" ");
454 + if plain.chars().count() <= max_chars {
455 + plain
456 + } else {
457 + let truncated: String = plain.chars().take(max_chars).collect();
458 + format!("{}…", truncated.trim_end())
459 + }
460 + }
461 +
462 + #[cfg(test)]
463 + mod tests {
464 + use super::make_excerpt;
465 +
466 + #[test]
467 + fn excerpt_short_passes_through() {
468 + assert_eq!(make_excerpt("Hello world", 100), "Hello world");
469 + }
470 +
471 + #[test]
472 + fn excerpt_first_paragraph_only() {
473 + let body = "First paragraph.\n\nSecond paragraph should be ignored.";
474 + assert_eq!(make_excerpt(body, 100), "First paragraph.");
475 + }
476 +
477 + #[test]
478 + fn excerpt_strips_markdown_markers() {
479 + let body = "# Heading\n**bold** and *italic* and `code` and [link](url)";
480 + let out = make_excerpt(body, 100);
481 + assert!(out.contains("Heading"));
482 + assert!(out.contains("bold"));
483 + assert!(!out.contains("**"));
484 + assert!(!out.contains('`'));
485 + }
486 +
487 + #[test]
488 + fn excerpt_truncates_with_ellipsis() {
489 + let body = "a".repeat(500);
490 + let out = make_excerpt(&body, 50);
491 + assert_eq!(out.chars().count(), 51); // 50 chars + ellipsis
492 + assert!(out.ends_with('…'));
493 + }
494 +
495 + #[test]
496 + fn excerpt_empty_body() {
497 + assert_eq!(make_excerpt("", 100), "");
498 + }
499 + }
@@ -0,0 +1,425 @@
1 + //! `/l/{item_id}` — library (consumption) view for items the viewer has access to.
2 + //!
3 + //! Separate from `/i/{id}` (store page). 403 if the viewer doesn't have access;
4 + //! 404 if the item is missing, unpublished/deleted, or owned by a sandbox seller.
5 +
6 + use axum::{
7 + extract::{Path, State},
8 + response::{IntoResponse, Response},
9 + };
10 + use tower_sessions::Session;
11 +
12 + use crate::{
13 + auth::MaybeUser,
14 + constants,
15 + db::{self, ContentData, ItemId, ItemType},
16 + error::{AppError, Result},
17 + helpers::{fetch_discussion_info, get_csrf_token, get_initials},
18 + pricing,
19 + templates::*,
20 + types::*,
21 + AppState,
22 + };
23 +
24 + /// `GET /l/{item_id}` — render the library (consumption) view.
25 + #[tracing::instrument(skip_all, name = "content::library_page")]
26 + pub(in crate::routes::pages::public) async fn library_page(
27 + State(state): State<AppState>,
28 + session: Session,
29 + headers: axum::http::HeaderMap,
30 + MaybeUser(maybe_user): MaybeUser,
31 + Path(item_id): Path<String>,
32 + ) -> Result<Response> {
33 + let csrf_token = get_csrf_token(&session).await;
34 + let id: ItemId = item_id.parse().map_err(|_| AppError::NotFound)?;
35 +
36 + let db_item = db::items::get_item_by_id(&state.db, id)
37 + .await?
38 + .ok_or(AppError::NotFound)?;
39 + let db_project = db::projects::get_project_by_id(&state.db, db_item.project_id)
40 + .await?
41 + .ok_or(AppError::NotFound)?;
42 + let db_user = db::users::get_user_by_id(&state.db, db_project.user_id)
43 + .await?
44 + .ok_or(AppError::NotFound)?;
45 + if db_user.is_sandbox {
46 + return Err(AppError::NotFound);
47 + }
48 +
49 + let is_owner = maybe_user
50 + .as_ref()
51 + .map(|u| u.id == db_project.user_id)
52 + .unwrap_or(false);
53 +
54 + // Unpublished or soft-deleted items: hide from non-owners (don't leak draft existence).
55 + if (!db_item.is_public || db_item.deleted_at.is_some()) && !is_owner {
56 + return Err(AppError::NotFound);
57 + }
58 +
59 + // Compute access using the same logic as item_page.
60 + let item_pricing = pricing::for_item(&db_item);
61 + let in_library = if let Some(ref user) = maybe_user {
62 + db::transactions::has_purchased_item(&state.db, user.id, db_item.id).await?
63 + } else {
64 + false
65 + };
66 + let has_item_sub = if let Some(ref user) = maybe_user {
67 + db::subscriptions::has_active_subscription_to_item(&state.db, user.id, db_item.id).await?
68 + } else {
69 + false
70 + };
71 + let ctx = pricing::AccessContext {
72 + is_creator: is_owner,
73 + has_purchased: in_library,
74 + has_active_subscription: has_item_sub,
75 + };
76 + let mut has_access = item_pricing.can_access(&ctx);
77 + if !has_access
78 + && let Some(ref user) = maybe_user
79 + && db::bundles::has_access_via_bundle(&state.db, user.id, db_item.id).await?
80 + {
81 + has_access = true;
82 + }
83 +
84 + let item_tags = db::tags::get_tags_for_item(&state.db, db_item.id).await?;
85 + let is_free = item_pricing.is_free();
86 + let item = Item::from_db_detail(&db_item, &item_tags, None, None, is_free, has_access);
87 +
88 + if !has_access {
89 + // Render 403 with link back to /i/{id}. For unlisted items, list containing bundles.
90 + let containing_bundles: Vec<Item> = if !db_item.listed {
91 + let bundle_ids =
92 + db::bundles::get_bundles_containing_item(&state.db, db_item.id).await?;
93 + let mut bundles = Vec::new();
94 + for bid in &bundle_ids {
95 + if let Some(b) = db::items::get_item_by_id(&state.db, *bid).await?
96 + && b.is_public
97 + {
98 + let tags = Vec::new();
99 + bundles.push(Item::from_db_list(&b, &tags, b.price_cents == 0, false));
100 + }
101 + }
102 + bundles
103 + } else {
104 + Vec::new()
105 + };
106 +
107 + let is_logged_in = maybe_user.is_some();
108 + return Ok((
109 + axum::http::StatusCode::FORBIDDEN,
110 + LibraryLockedTemplate {
111 + csrf_token,
112 + session_user: maybe_user,
113 + item,
114 + creator_username: db_user.username.to_string(),
115 + host_url: state.config.host_url.clone(),
116 + containing_bundles,
117 + is_logged_in,
118 + },
119 + )
120 + .into_response());
121 + }
122 +
123 + // View tracking belongs on /l/ (consumption signal), not /i/.
124 + let ua = headers
125 + .get(axum::http::header::USER_AGENT)
126 + .and_then(|v| v.to_str().ok())
127 + .unwrap_or("");
128 + if !super::is_bot(ua) {
129 + super::track_view(state.db.clone(), "item", *db_item.id);
130 + }
131 +
132 + let db_versions = db::versions::get_versions_by_item(&state.db, db_item.id).await?;
133 + let versions: Vec<Version> = db_versions.iter().map(Version::from_db).collect();
134 +
135 + let project_slug_str = db_project.slug.to_string();
136 + let (discussion_url, discussion_count) =
137 + fetch_discussion_info(&state, db_item.mt_thread_id, &project_slug_str, "items").await;
138 +
139 + // Phase 2: audio items get their own player template.
140 + if db_item.item_type == ItemType::Audio {
141 + return render_audio_library(
142 + &state,
143 + &db_item,
144 + &db_user,
145 + &db_project,
146 + csrf_token,
147 + maybe_user,
148 + item,
149 + versions,
150 + discussion_url,
151 + discussion_count,
152 + is_owner,
153 + )
154 + .await;
155 + }
156 +
157 + // Phase 4: text items get their own reader template.
158 + if db_item.item_type == ItemType::Text {
159 + return render_text_library(
160 + &state,
161 + &db_item,
162 + &db_user,
163 + &db_project,
164 + csrf_token,
165 + maybe_user,
166 + item,
167 + discussion_url,
168 + discussion_count,
169 + is_owner,
170 + )
171 + .await;
172 + }
173 +
174 + // Phase 3: video items get their own player template.
175 + if db_item.item_type == ItemType::Video {
176 + return render_video_library(
177 + &state,
178 + &db_item,
179 + &db_user,
180 + &db_project,
181 + csrf_token,
182 + maybe_user,
183 + item,
184 + versions,
185 + discussion_url,
186 + discussion_count,
187 + is_owner,
188 + )
189 + .await;
190 + }
191 +
192 + // Phase 1: downloads / bundle / other items render here. Audio, video, and
193 + // text branches above handle their own templates.
194 + let bundle_child_items = if db_item.item_type == ItemType::Bundle {
195 + db::bundles::get_bundle_items(&state.db, db_item.id).await?
196 + } else {
197 + Vec::new()
198 + };
199 + let bundle_items: Vec<Item> = bundle_child_items
200 + .iter()
201 + .map(|child| {
202 + let child_tags = Vec::new();
203 + Item::from_db_list(child, &child_tags, child.price_cents == 0, false)
204 + })
205 + .collect();
206 +
207 + let cdn_base = state
208 + .config
209 + .cdn_base_url
210 + .as_deref()
211 + .unwrap_or("https://cdn.makenot.work");
212 + let db_sections = db::item_sections::list_by_item(&state.db, db_item.id).await?;
213 + let sections: Vec<ItemSection> = db_sections
214 + .iter()
215 + .map(|s| ItemSection::from_db(s, db_project.user_id, cdn_base))
216 + .collect();
217 +
218 + Ok(LibraryDownloadsTemplate {
219 + csrf_token,
220 + session_user: maybe_user,
221 + item,
222 + creator_username: db_user.username.to_string(),
223 + project_title: db_project.title.clone(),
224 + project_slug: project_slug_str,
225 + host_url: state.config.host_url.clone(),
226 + versions,
227 + bundle_items,
228 + sections,
229 + discussion_url,
230 + discussion_count,
231 + is_owner,
232 + }
233 + .into_response())
234 + }
235 +
236 + #[allow(clippy::too_many_arguments)]
237 + async fn render_audio_library(
238 + state: &AppState,
239 + db_item: &db::DbItem,
240 + db_user: &db::DbUser,
241 + db_project: &db::DbProject,
242 + csrf_token: Option<String>,
243 + maybe_user: Option<crate::auth::SessionUser>,
244 + item: Item,
245 + versions: Vec<Version>,
246 + discussion_url: Option<String>,
247 + discussion_count: Option<i64>,
248 + is_owner: bool,
249 + ) -> Result<Response> {
250 + let avatar_initials =
251 + get_initials(db_user.display_name.as_deref().unwrap_or(&db_user.username));
252 + let db_chapters = db::chapters::get_chapters_by_item(&state.db, db_item.id).await?;
253 + let chapters: Vec<Chapter> = db_chapters.iter().map(Chapter::from).collect();
254 +
255 + let audio_url = match db_item.content() {
256 + ContentData::Audio {
257 + audio_s3_key,
258 + duration_seconds,
259 + audio_url,
260 + ..
261 + } => {
262 + if let (Some(s3_key), Some(s3)) = (&audio_s3_key, &state.s3) {
263 + let expiry_secs = match duration_seconds {
264 + Some(duration) => ((duration as u64) * 2)
265 + .clamp(3600, constants::STREAMING_CACHE_MAX_SECS),
266 + None => 3600,
267 + };
268 + match s3.presign_download(s3_key, Some(expiry_secs)).await {
269 + Ok(url) => Some(url),
270 + Err(e) => {
271 + tracing::warn!(s3_key = %s3_key, error = ?e, "failed to generate presigned url");
272 + audio_url
273 + }
274 + }
275 + } else {
276 + audio_url
277 + }
278 + }
279 + _ => None,
280 + };
281 +
282 + let segments_json =
283 + super::item::build_segments_json(state, db_item.id, &audio_url, db_item).await;
284 +
285 + Ok(LibraryAudioTemplate {
286 + csrf_token,
287 + session_user: maybe_user,
288 + item,
289 + creator_username: db_user.username.to_string(),
290 + creator_display_name: db_user.display_name.clone(),
291 + creator_avatar_initials: avatar_initials,
292 + project_title: Some(db_project.title.clone()),
293 + project_slug: db_project.slug.to_string(),
294 + audio_url,
295 + chapters,
296 + segments_json,
297 + versions,
298 + host_url: state.config.host_url.clone(),
299 + discussion_url,
300 + discussion_count,
301 + is_owner,
302 + }
303 + .into_response())
304 + }
305 +
306 + #[allow(clippy::too_many_arguments)]
307 + async fn render_video_library(
308 + state: &AppState,
309 + db_item: &db::DbItem,
310 + db_user: &db::DbUser,
311 + db_project: &db::DbProject,
312 + csrf_token: Option<String>,
313 + maybe_user: Option<crate::auth::SessionUser>,
314 + item: Item,
315 + versions: Vec<Version>,
316 + discussion_url: Option<String>,
317 + discussion_count: Option<i64>,
318 + is_owner: bool,
319 + ) -> Result<Response> {
320 + let avatar_initials =
321 + get_initials(db_user.display_name.as_deref().unwrap_or(&db_user.username));
322 + let db_chapters = db::chapters::get_chapters_by_item(&state.db, db_item.id).await?;
323 + let chapters: Vec<Chapter> = db_chapters.iter().map(Chapter::from).collect();
324 +
325 + let video_url = match db_item.content() {
326 + ContentData::Video {
327 + video_s3_key,
328 + duration_seconds,
329 + ..
330 + } => {
331 + if let (Some(s3_key), Some(s3)) = (&video_s3_key, &state.s3) {
332 + let expiry_secs = match duration_seconds {
333 + Some(duration) => ((duration as u64) * 2)
334 + .clamp(3600, constants::STREAMING_CACHE_MAX_SECS),
335 + None => 3600,
336 + };
337 + match s3.presign_download(s3_key, Some(expiry_secs)).await {
338 + Ok(url) => Some(url),
339 + Err(e) => {
340 + tracing::warn!(s3_key = %s3_key, error = ?e, "failed to generate presigned video url");
341 + None
342 + }
343 + }
344 + } else {
345 + None
346 + }
347 + }
348 + _ => None,
349 + };
350 +
351 + let segments_json =
352 + super::item::build_segments_json(state, db_item.id, &video_url, db_item).await;
353 +
354 + Ok(LibraryVideoTemplate {
355 + csrf_token,
356 + session_user: maybe_user,
357 + item,
358 + creator_username: db_user.username.to_string(),
359 + creator_display_name: db_user.display_name.clone(),
360 + creator_avatar_initials: avatar_initials,
361 + project_title: Some(db_project.title.clone()),
362 + project_slug: db_project.slug.to_string(),
363 + video_url,
364 + chapters,
365 + segments_json,
366 + versions,
367 + host_url: state.config.host_url.clone(),
368 + discussion_url,
369 + discussion_count,
370 + is_owner,
371 + }
372 + .into_response())
373 + }
374 +
375 + #[allow(clippy::too_many_arguments)]
376 + async fn render_text_library(
377 + state: &AppState,
378 + db_item: &db::DbItem,
379 + db_user: &db::DbUser,
380 + db_project: &db::DbProject,
381 + csrf_token: Option<String>,
382 + maybe_user: Option<crate::auth::SessionUser>,
383 + item: Item,
384 + discussion_url: Option<String>,
385 + discussion_count: Option<i64>,
386 + is_owner: bool,
387 + ) -> Result<Response> {
388 + let avatar_initials =
389 + get_initials(db_user.display_name.as_deref().unwrap_or(&db_user.username));
390 + let cdn_base = state
391 + .config
392 + .cdn_base_url
393 + .as_deref()
394 + .unwrap_or("https://cdn.makenot.work");
395 + let (body_html, reading_time) = match db_item.content() {
396 + ContentData::Text {
397 + body,
398 + reading_time_minutes,
399 + ..
400 + } => (
401 + body.as_ref()
402 + .map(|b| crate::markdown::render_creator_markdown(b, db_project.user_id, cdn_base)),
403 + reading_time_minutes.map(|m| format!("{} min read", m)),
404 + ),
405 + _ => (None, None),
406 + };
407 +
408 + Ok(LibraryTextTemplate {
409 + csrf_token,
410 + session_user: maybe_user,
411 + item,
412 + creator_username: db_user.username.to_string(),
413 + creator_display_name: db_user.display_name.clone(),
414 + creator_avatar_initials: avatar_initials,
415 + project_title: db_project.title.clone(),
416 + project_slug: db_project.slug.to_string(),
417 + body_html,
418 + reading_time,
419 + host_url: state.config.host_url.clone(),
420 + discussion_url,
421 + discussion_count,
422 + is_owner,
423 + }
424 + .into_response())
425 + }
@@ -1,10 +1,12 @@
1 1 //! Public user, project, and item detail pages.
2 2
3 3 mod item;
4 + mod library;
4 5 mod project;
5 6
6 7 pub(crate) use item::render_item_page;
7 8 pub(in crate::routes::pages::public) use item::item_page;
9 + pub(in crate::routes::pages::public) use library::library_page;
8 10 pub(crate) use project::render_project_page;
9 11 pub(in crate::routes::pages::public) use project::project_page;
10 12
@@ -68,6 +68,7 @@ pub fn public_routes() -> Router<AppState> {
68 68 .route("/c/{username}/{slug}", get(content::collection_page))
69 69 .route("/p/{slug}", get(content::project_page))
70 70 .route("/i/{item_id}", get(content::item_page))
71 + .route("/l/{item_id}", get(content::library_page))
71 72 .route("/purchase/{item_id}", get(content::purchase_page))
72 73 .route("/receipt/{transaction_id}", get(content::receipt_page))
73 74 .route("/buy/{item_id}", get(content::buy_page))
@@ -58,7 +58,7 @@ pub(in crate::routes::stripe) async fn create_checkout(
58 58 if db::transactions::has_purchased_item(&state.db, user.id, item_uuid)
59 59 .await
60 60 .context("check existing purchase")? {
61 - return Ok(Redirect::to(&format!("/i/{}", item_id)).into_response());
61 + return Ok(Redirect::to(&format!("/l/{}", item_id)).into_response());
62 62 }
63 63
64 64 // Get the seller (creator)
@@ -281,7 +281,7 @@ pub(in crate::routes::stripe) async fn create_checkout(
281 281 }
282 282 }
283 283
284 - return Ok(Redirect::to("/library?purchase=success").into_response());
284 + return Ok(Redirect::to(&format!("/l/{}?purchase=success", item_id)).into_response());
285 285 }
286 286
287 287 // Reserve promo code use_count at checkout time (not webhook time) to prevent
@@ -308,7 +308,7 @@ pub(in crate::routes::stripe) async fn create_checkout(
308 308 .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?;
309 309
310 310 // Build URLs
311 - let success_url = format!("{}/stripe/success?session_id={{CHECKOUT_SESSION_ID}}", state.config.host_url);
311 + let success_url = format!("{}/stripe/success?session_id={{CHECKOUT_SESSION_ID}}&item_id={}", state.config.host_url, item_id);
312 312 let cancel_url = format!("{}/stripe/cancel?item_id={}", state.config.host_url, item_id);
313 313
314 314 // Create the checkout session with the (possibly discounted) price.
@@ -39,6 +39,9 @@ pub(super) struct CheckoutForm {
39 39 #[derive(Debug, Deserialize)]
40 40 pub struct SuccessQuery {
41 41 pub session_id: Option<String>,
42 + /// Single-item checkout sets this so the success redirect lands on `/l/{id}`
43 + /// instead of the library index. Cart checkouts leave it unset.
44 + pub item_id: Option<String>,
42 45 }
43 46
44 47 /// Query parameters for the checkout cancellation redirect.
@@ -93,7 +96,15 @@ pub(super) async fn checkout_success(
93 96 session.remove::<Vec<String>>("cart_queue").await.ok();
94 97 session.remove::<bool>("cart_share_contact").await.ok();
95 98
96 - Redirect::to("/library?purchase=success")
99 + // Single-item purchase: land on the item's library view so the buyer sees
100 + // their downloads/player immediately. Cart purchases land on the library
101 + // index (no single item to deep-link to).
102 + match query.item_id.as_deref() {
103 + Some(id) if uuid::Uuid::parse_str(id).is_ok() => {
104 + Redirect::to(&format!("/l/{}?purchase=success", id))
105 + }
106 + _ => Redirect::to("/library?purchase=success"),
107 + }
97 108 }
98 109
99 110 /// GET /stripe/cancel - Handle cancelled payment
@@ -64,6 +64,11 @@ impl_into_response!(
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,
@@ -289,7 +289,6 @@ pub struct ItemTemplate {
289 289 pub creator_username: String,
290 290 pub project_title: String,
291 291 pub project_slug: String,
292 - pub versions: Vec<Version>,
293 292 /// Base URL for OG meta tags.
294 293 pub host_url: Arc<str>,
295 294 /// URL to the MT discussion thread (None if no linked thread or MT unavailable).
@@ -312,6 +311,70 @@ pub struct ItemTemplate {
312 311 pub in_cart: bool,
313 312 /// How many of the current user's collections contain this item.
314 313 pub collection_count: u32,
314 + /// Whether the current user can consume this item (purchased, free, subscribed, creator, bundle).
315 + /// Drives the store-page CTA swap: true → "View in library", false → Buy/PWYW.
316 + pub has_access: bool,
317 + }
318 +
319 + /// Library (consumption) view for download / bundle / other items.
320 + /// Audio + video items currently render this too; dedicated templates land in
321 + /// Phases 2–3.
322 + #[derive(Template)]
323 + #[template(path = "pages/library_downloads.html")]
324 + #[allow(dead_code)]
325 + pub struct LibraryDownloadsTemplate {
326 + pub csrf_token: CsrfTokenOption,
327 + pub session_user: Option<SessionUser>,
328 + pub item: Item,
329 + pub creator_username: String,
330 + pub project_title: String,
331 + pub project_slug: String,
332 + pub host_url: Arc<str>,
333 + pub versions: Vec<Version>,
334 + /// Child items if this is a bundle; otherwise empty. Children get `/l/` links
335 + /// because the viewer (by being on this page) has access via the bundle.
336 + pub bundle_items: Vec<Item>,
337 + pub sections: Vec<ItemSection>,
338 + pub discussion_url: Option<String>,
339 + pub discussion_count: Option<i64>,
340 + pub is_owner: bool,
341 + }
342 +
343 + /// 403 page shown when a viewer hits /l/{id} but lacks access.
344 + #[derive(Template)]
345 + #[template(path = "pages/library_locked.html")]
346 + #[allow(dead_code)]
347 + pub struct LibraryLockedTemplate {
348 + pub csrf_token: CsrfTokenOption,
349 + pub session_user: Option<SessionUser>,
350 + pub item: Item,
351 + pub creator_username: String,
352 + pub host_url: Arc<str>,
353 + /// For unlisted items: bundles that contain this item.
354 + pub containing_bundles: Vec<Item>,
355 + pub is_logged_in: bool,
356 + }
357 +
358 + /// Library (consumption) view for text items — full article body, discussion.
359 + #[derive(Template)]
360 + #[template(path = "pages/library_text.html")]
361 + #[allow(dead_code)]
362 + pub struct LibraryTextTemplate {
363 + pub csrf_token: CsrfTokenOption,
364 + pub session_user: Option<SessionUser>,
365 + pub item: Item,
366 + pub creator_username: String,
367 + pub creator_display_name: Option<String>,
368 + pub creator_avatar_initials: String,
369 + pub project_title: String,
370 + pub project_slug: String,
371 + /// Fully rendered article body HTML.
372 + pub body_html: Option<String>,
373 + pub reading_time: Option<String>,
374 + pub host_url: Arc<str>,
375 + pub discussion_url: Option<String>,
376 + pub discussion_count: Option<i64>,
377 + pub is_owner: bool,
315 378 }
316 379
317 380 /// Blog/article reader view.
@@ -332,8 +395,11 @@ pub struct TextReaderTemplate {
332 395 pub is_free: bool,
333 396 /// Whether the current user already has this item in their library.
334 397 pub in_library: bool,
335 - /// Whether the current user can view the full content (purchased, free, or is the creator).
398 + /// Drives the CTA swap: true → "Read in library", false → Buy/PWYW/Add-to-Library.
336 399 pub has_access: bool,
400 + pub reading_time: Option<String>,
401 + /// Short plain-text preview of the article body, shown on the store page.
402 + pub excerpt: Option<String>,
337 403 /// Base URL for OG meta tags.
338 404 pub host_url: Arc<str>,
339 405 /// URL to the MT discussion thread (None if no linked thread or MT unavailable).
@@ -342,6 +408,31 @@ pub struct TextReaderTemplate {
342 408 pub discussion_count: Option<i64>,
343 409 }
344 410
411 + /// Library (consumption) view for audio items — full player, chapters,
412 + /// description, optional source-file downloads, discussion.
413 + #[derive(Template)]
414 + #[template(path = "pages/library_audio.html")]
415 + #[allow(dead_code)]
416 + pub struct LibraryAudioTemplate {
417 + pub csrf_token: CsrfTokenOption,
418 + pub session_user: Option<SessionUser>,
419 + pub item: Item,
420 + pub creator_username: String,
421 + pub creator_display_name: Option<String>,
422 + pub creator_avatar_initials: String,
423 + pub project_title: Option<String>,
424 + pub project_slug: String,
425 + pub audio_url: Option<String>,
426 + pub chapters: Vec<Chapter>,
427 + pub segments_json: String,
428 + /// Source-file downloads if the creator offers them alongside the stream.
429 + pub versions: Vec<Version>,
430 + pub host_url: Arc<str>,
431 + pub discussion_url: Option<String>,
432 + pub discussion_count: Option<i64>,
433 + pub is_owner: bool,
434 + }
435 +
345 436 /// Audio streaming player view.
346 437 #[derive(Template)]
347 438 #[template(path = "pages/audio_player.html")]
@@ -355,18 +446,12 @@ pub struct AudioPlayerTemplate {
355 446 pub creator_avatar_initials: String,
356 447 pub project_title: Option<String>,
357 448 pub project_slug: String,
358 - /// Pre-signed S3 URL for the audio file; `None` if no audio uploaded yet.
359 - pub audio_url: Option<String>,
360 - /// Timestamp-based chapter markers for the player seek bar.
361 - pub chapters: Vec<Chapter>,
362 449 /// Whether the item has a zero price.
363 450 pub is_free: bool,
364 451 /// Whether the current user already has this item in their library.
365 452 pub in_library: bool,
366 - /// Whether the current user can stream the audio (purchased, free, or is the creator).
453 + /// Drives the CTA swap: true → "View in library", false → Buy/PWYW or Add-to-Library.
367 454 pub has_access: bool,
368 - /// JSON-encoded segment list for insertion playback. Empty string means no insertions.
369 - pub segments_json: String,
370 455 /// Base URL for OG meta tags.
371 456 pub host_url: Arc<str>,
372 457 /// URL to the MT discussion thread (None if no linked thread or MT unavailable).
@@ -375,6 +460,30 @@ pub struct AudioPlayerTemplate {
375 460 pub discussion_count: Option<i64>,
376 461 }
377 462
463 + /// Library (consumption) view for video items — full player, chapters,
464 + /// description, optional source-file downloads, discussion.
465 + #[derive(Template)]
466 + #[template(path = "pages/library_video.html")]
467 + #[allow(dead_code)]
468 + pub struct LibraryVideoTemplate {
469 + pub csrf_token: CsrfTokenOption,
470 + pub session_user: Option<SessionUser>,
471 + pub item: Item,
472 + pub creator_username: String,
473 + pub creator_display_name: Option<String>,
474 + pub creator_avatar_initials: String,
475 + pub project_title: Option<String>,
476 + pub project_slug: String,
477 + pub video_url: Option<String>,
478 + pub chapters: Vec<Chapter>,
479 + pub segments_json: String,
480 + pub versions: Vec<Version>,
481 + pub host_url: Arc<str>,
482 + pub discussion_url: Option<String>,
483 + pub discussion_count: Option<i64>,
484 + pub is_owner: bool,
485 + }
486 +
378 487 /// Video player page with custom controls, insertions, chapters.
379 488 #[derive(Template)]
380 489 #[template(path = "pages/video_player.html")]
@@ -387,13 +496,9 @@ pub struct VideoPlayerTemplate {
387 496 pub creator_avatar_initials: String,
388 497 pub project_title: Option<String>,
389 498 pub project_slug: String,
390 - /// Pre-signed S3 URL for the video file.
391 - pub video_url: Option<String>,
392 - pub chapters: Vec<Chapter>,
393 499 pub is_free: bool,
394 500 pub in_library: bool,
395 501 pub has_access: bool,
396 - pub segments_json: String,
397 502 pub host_url: Arc<str>,
398 503 pub discussion_url: Option<String>,
399 504 pub discussion_count: Option<i64>,
@@ -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 &#8250;</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 &rarr;</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 &middot; <a href="/l/{{ item.id }}">Listen now &rarr;</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 &mdash; 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 %}
@@ -219,7 +219,9 @@
219 219 </ul>
220 220 </div>
221 221
222 - {% if item.is_free %}
222 + {% if has_access %}
223 + <a href="/l/{{ item.id }}"><button class="primary">View in library &rarr;</button></a>
224 + {% else if item.is_free %}
223 225 <button class="primary"
224 226 hx-post="/api/library/add/{{ item.id }}"
225 227 hx-swap="outerHTML">Add to Library - Free</button>
@@ -364,37 +366,6 @@
364 366 </section>
365 367 {% endif %}
366 368
367 - {% if !versions.is_empty() %}
368 - <section class="item-versions">
369 - <h2>Downloads</h2>
370 - {% for version in versions %}
371 - {% if version.has_file %}
372 - <div style="display: flex; align-items: center; gap: 1rem; padding: 0.6rem 0; border-bottom: 1px solid var(--border);">
373 - <span style="min-width: 4em; font-family: var(--font-mono); font-size: 0.9rem;">v{{ version.number }}</span>
374 - {% if let Some(label) = version.label %}
375 - <span style="flex: 1;">{{ label }}</span>
376 - {% else %}
377 - <span style="flex: 1; opacity: 0.5;">{% match version.file_name %}{% when Some with (name) %}{{ name }}{% when None %}Download{% endmatch %}</span>
378 - {% endif %}
379 - <span style="font-size: 0.85rem; opacity: 0.6; min-width: 5em; text-align: right;">{{ version.size }}</span>
380 - <button class="secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;"
381 - onclick="downloadVersion('{{ version.id }}')">Download</button>
382 - </div>
383 - {% endif %}
384 - {% endfor %}
385 - </section>
386 - {% endif %}
387 -
388 - {% if !versions.is_empty() %}
389 - <section class="item-description" style="font-size: 0.85rem; opacity: 0.7;">
390 - <h2 style="font-size: 1.1rem;">Download Notice</h2>
391 - <p>Software downloads are provided by third-party creators. Makenot.work does
392 - not guarantee downloads are free of malware. Use antivirus software and
393 - download at your own risk. Report concerns to
394 - <strong>reports@makenot.work</strong>.</p>
395 - </section>
396 - {% endif %}
397 -
398 369 {% include "partials/discussion_section.html" %}
399 370
400 371 <footer class="item-footer">
@@ -443,20 +414,6 @@ function switchSectionTab(btn, panelId) {
443 414 }
444 415 })();
445 416
446 - function downloadVersion(versionId) {
447 - fetch('/api/versions/' + versionId + '/download')
448 - .then(function(res) {
449 - if (!res.ok) throw new Error('Failed to get download URL');
450 - return res.json();
451 - })
452 - .then(function(data) {
453 - window.location.href = data.download_url;
454 - })
455 - .catch(function(err) {
456 - showToast(err.message || 'Download failed');
457 - });
458 - }
459 -
460 417 (function() {
461 418 var player = document.getElementById('item-player');
462 419 if (player) {
@@ -0,0 +1,180 @@
1 + {% extends "base.html" %}
2 +
3 + {% block title %}{{ item.title }} - Library - Makenotwork{% endblock %}
4 +
5 + {% block head %}
6 + <meta name="robots" content="noindex">
7 + <link rel="stylesheet" href="/static/media-player.css">
8 + <style>
9 + .library-back { font-size: 0.85rem; opacity: 0.7; margin: 1rem 0; }
10 + .library-back a { color: var(--detail); }
11 + .library-downloads { background: var(--light-background); padding: 1.5rem; margin-top: 2rem; }
12 + .library-downloads h3 { font-size: 1.1rem; margin-bottom: 1rem; }
13 + .download-row { display: flex; align-items: center; gap: 1rem; padding: 0.5rem 0; border-bottom: 1px solid var(--border); }
14 + .download-row:last-child { border-bottom: none; }
15 + </style>
16 + {% endblock %}
17 +
18 + {% block content %}
19 + {% include "partials/site_header.html" %}
20 +
21 + <article class="media-container">
22 + <p class="library-back">
23 + <a href="/i/{{ item.id }}">&larr; Store page</a> &middot;
24 + <a href="/library">Your library</a>
25 + </p>
26 +
27 + <header class="author-header">
28 + <div class="author-avatar">{{ creator_avatar_initials }}</div>
29 + <div class="author-info">
30 + <div class="author-name"><a href="/u/{{ creator_username }}">{% if let Some(name) = creator_display_name %}{{ name }}{% else %}{{ creator_username }}{% endif %}</a></div>
31 + <div class="media-meta">{{ item.release_date }}{% match item.content %}{% when crate::types::ItemContent::Audio with { duration, .. } %}{% if let Some(dur) = duration %} | {{ dur }}{% endif %}{% when _ %}{% endmatch %}</div>
32 + </div>
33 + </header>
34 +
35 + <h1 class="media-title">{{ item.title }}</h1>
36 +
37 + {% if let Some(project_title) = project_title %}
38 + <p class="media-series">
39 + <a href="/p/{{ project_slug }}">{{ project_title }}</a>{% match item.content %}{% when crate::types::ItemContent::Audio with { episode_number, .. } %}{% if let Some(episode) = episode_number %} | Episode {{ episode }}{% endif %}{% when _ %}{% endmatch %}
40 + </p>
41 + {% endif %}
42 +
43 + <div class="cover-art">
44 + {% match item.content %}
45 + {% when crate::types::ItemContent::Audio with { cover_url, .. } %}
46 + {% if let Some(url) = cover_url %}
47 + <img src="{{ url }}" alt="{{ item.title }} cover art" style="width: 100%; aspect-ratio: 1 / 1; object-fit: cover;">
48 + {% endif %}
49 + {% when _ %}
50 + {% endmatch %}
51 + </div>
52 +
53 + <div class="media-player">
54 + <audio id="media-a" preload="metadata">
55 + {% if let Some(audio_url) = audio_url %}
56 + <source src="{{ audio_url }}" type="audio/mpeg">
57 + {% endif %}
58 + </audio>
59 + <audio id="media-b" preload="metadata"></audio>
60 +
61 + <div class="player-controls">
62 + <button class="play-button" id="play-btn" aria-label="Play">
63 + <svg id="play-icon" width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
64 + <path d="M8 5v14l11-7z"/>
65 + </svg>
66 + <svg id="pause-icon" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" style="display: none;">
67 + <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
68 + </svg>
69 + </button>
70 + <div class="progress-container">
71 + <div class="progress-bar" id="progress-bar">
72 + <div class="progress-fill" id="progress-fill"></div>
73 + </div>
74 + <div class="time-display">
75 + <span id="current-time">0:00</span>
76 + <span id="duration">0:00</span>
77 + </div>
78 + </div>
79 + </div>
80 +
81 + <div class="insertion-label" id="insertion-label"></div>
82 + <button class="skip-insertion" id="skip-btn" type="button">Skip &#8250;</button>
83 +
84 + <div class="player-secondary">
85 + <div class="speed-control">
86 + <span>Speed:</span>
87 + <button class="speed-button" data-speed="0.5">0.5x</button>
88 + <button class="speed-button active" data-speed="1">1x</button>
89 + <button class="speed-button" data-speed="1.5">1.5x</button>
90 + <button class="speed-button" data-speed="2">2x</button>
91 + </div>
92 + <div class="volume-control">
93 + <span class="volume-icon">
94 + <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
95 + <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"/>
96 + </svg>
97 + </span>
98 + <input type="range" class="volume-slider" id="volume-slider" min="0" max="100" value="75" />
99 + </div>
100 + </div>
101 + </div>
102 +
103 + {% if !chapters.is_empty() %}
104 + <div class="chapters">
105 + <h3>Chapters</h3>
106 + <ul class="chapter-list">
107 + {% for chapter in chapters %}
108 + <li class="chapter-item" data-time="{{ chapter.start_seconds }}">
109 + <span class="chapter-title">{{ chapter.title }}</span>
110 + <span class="chapter-time">{{ chapter.timestamp }}</span>
111 + </li>
112 + {% endfor %}
113 + </ul>
114 + </div>
115 + {% endif %}
116 +
117 + {% if !item.description.is_empty() %}
118 + <div class="media-description">
119 + <p>{{ item.description }}</p>
120 + </div>
121 + {% endif %}
122 +
123 + {% if !versions.is_empty() %}
124 + <section class="library-downloads">
125 + <h3>Source files</h3>
126 + {% for version in versions %}
127 + {% if version.has_file %}
128 + <div class="download-row">
129 + <span style="min-width: 4em; font-family: var(--font-mono); font-size: 0.9rem;">v{{ version.number }}</span>
130 + {% if let Some(label) = version.label %}
131 + <span style="flex: 1;">{{ label }}</span>
132 + {% else %}
133 + <span style="flex: 1; opacity: 0.5;">{% match version.file_name %}{% when Some with (name) %}{{ name }}{% when None %}Download{% endmatch %}</span>
134 + {% endif %}
135 + <span style="font-size: 0.85rem; opacity: 0.6; min-width: 5em; text-align: right;">{{ version.size }}</span>
136 + <button class="secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;"
137 + onclick="downloadVersion('{{ version.id }}')">Download</button>
138 + </div>
139 + {% endif %}
140 + {% endfor %}
141 + </section>
142 + {% endif %}
143 + </article>
144 +
145 + {% include "partials/discussion_section.html" %}
146 +
147 + <footer class="media-player-footer">
148 + {% if is_owner %}
149 + <a href="/dashboard/item/{{ item.id }}">Edit</a> &middot;
150 + {% endif %}
151 + <a href="/i/{{ item.id }}">Store page</a> &middot;
152 + <a href="/library">Your library</a>
153 + </footer>
154 + {% endblock %}
155 +
156 + {% block scripts %}
157 + <script id="media-player-data" type="application/json">
158 + {
159 + "segments": {{ segments_json|safe }},
160 + "mediaType": "audio",
161 + "itemId": "{{ item.id }}"
162 + }
163 + </script>
164 + <script src="/static/media-player.js"></script>
165 + <script>
166 + function downloadVersion(versionId) {
167 + fetch('/api/versions/' + versionId + '/download')
168 + .then(function(res) {
169 + if (!res.ok) throw new Error('Failed to get download URL');
170 + return res.json();
171 + })
172 + .then(function(data) {
173 + window.location.href = data.download_url;
174 + })
175 + .catch(function(err) {
176 + showToast(err.message || 'Download failed');
177 + });
178 + }
179 + </script>
180 + {% endblock %}
@@ -0,0 +1,216 @@
1 + {% extends "base.html" %}
2 +
3 + {% block title %}{{ item.title }} - Library - Makenotwork{% endblock %}
4 + {% block body_attrs %} class="padded-page"{% endblock %}
5 +
6 + {% block head %}
7 + <meta name="robots" content="noindex">
8 + <style>
9 + .library-header { display: grid; grid-template-columns: 200px 1fr; gap: 1.5rem; margin-bottom: 2rem; align-items: start; }
10 + .library-header.no-cover { grid-template-columns: 1fr; }
11 + .library-cover img { width: 100%; aspect-ratio: 1 / 1; object-fit: cover; display: block; }
12 + .library-title { font-size: 2rem; margin-bottom: 0.25rem; }
13 + .library-creator { font-size: 0.95rem; opacity: 0.7; margin-bottom: 1rem; }
14 + .library-creator a { color: var(--detail); }
15 + .library-back { font-size: 0.85rem; opacity: 0.7; }
16 + .library-back a { color: var(--detail); }
17 + .downloads-hero { background: var(--light-background); padding: 2rem; margin-bottom: 2rem; }
18 + .downloads-hero h2 { font-size: 1.5rem; margin-bottom: 1rem; padding-bottom: 0.5rem; border-bottom: 1px solid var(--border); }
19 + .download-row { display: flex; align-items: center; gap: 1rem; padding: 0.6rem 0; border-bottom: 1px solid var(--border); }
20 + .download-row:last-child { border-bottom: none; }
21 + .download-version { min-width: 4em; font-family: var(--font-mono); font-size: 0.9rem; }
22 + .download-label { flex: 1; }
23 + .download-size { font-size: 0.85rem; opacity: 0.6; min-width: 5em; text-align: right; }
24 + .download-notice { background: var(--surface-muted); padding: 1.25rem; margin-bottom: 2rem; font-size: 0.85rem; opacity: 0.8; }
25 + .download-notice strong { font-family: var(--font-mono); }
26 + .library-section { background: var(--light-background); padding: 2rem; margin-bottom: 2rem; }
27 + .library-section h2 { font-size: 1.3rem; margin-bottom: 1rem; padding-bottom: 0.5rem; border-bottom: 1px solid var(--border); }
28 + .bundle-child { display: flex; align-items: center; gap: 1rem; padding: 0.75rem 0; border-bottom: 1px solid var(--border); }
29 + .bundle-child:last-child { border-bottom: none; }
30 + .bundle-child-type { font-size: 0.8rem; padding: 0.2rem 0.6rem; background: var(--surface-muted); white-space: nowrap; }
31 + .bundle-child-title { flex: 1; }
32 + .bundle-child-title a { color: var(--detail); }
33 + .section-tabs { display: flex; gap: 0; border-bottom: 1px solid var(--border); margin-bottom: 1.5rem; }
34 + .section-tab { background: none; border: none; padding: 0.6rem 1.2rem; cursor: pointer; font-size: 0.95rem; opacity: 0.6; border-bottom: 2px solid transparent; font-family: var(--font-body); color: var(--text); }
35 + .section-tab.active { opacity: 1; border-bottom-color: var(--detail); }
36 + .section-tab:hover { opacity: 0.9; }
37 + .section-panel { display: none; }
38 + .section-panel.active { display: block; }
39 + .section-panel p { margin-bottom: 1rem; }
40 + .section-panel ul, .section-panel ol { margin-left: 1.5rem; margin-bottom: 1rem; }
41 + .section-panel li { margin-bottom: 0.5rem; }
42 + .section-panel pre { background: var(--surface-muted); padding: 1rem; overflow-x: auto; margin-bottom: 1rem; }
43 + .item-footer { margin-top: 3rem; padding-top: 2rem; border-top: 1px solid var(--border); text-align: center; opacity: 0.6; font-size: 0.85rem; }
44 + .item-footer a { color: var(--detail); }
45 + @media (max-width: 600px) {
46 + .library-header { grid-template-columns: 1fr; }
47 + .library-cover img { max-width: 200px; }
48 + }
49 + </style>
50 + {% endblock %}
51 +
52 + {% block content %}
53 + {% include "partials/site_header.html" %}
54 +
55 + <div class="container">
56 + <p class="library-back">
57 + <a href="/i/{{ item.id }}">&larr; Store page</a> &middot;
58 + <a href="/library">Your library</a>
59 + </p>
60 +
61 + <div class="library-header{% if item.cover_image_url.is_none() %} no-cover{% endif %}">
62 + {% if let Some(img) = item.cover_image_url %}
63 + <div class="library-cover">
64 + <img src="{{ img }}" alt="{{ item.title }}">
65 + </div>
66 + {% endif %}
67 + <div>
68 + <h1 class="library-title">{{ item.title }}</h1>
69 + <p class="library-creator">
70 + by <a href="/u/{{ creator_username }}">{{ creator_username }}</a>
71 + &middot; <a href="/p/{{ project_slug }}">{{ project_title }}</a>
72 + </p>
73 + </div>
74 + </div>
75 +
76 + {% if !versions.is_empty() %}
77 + <section class="downloads-hero">
78 + <h2>Downloads</h2>
79 + {% for version in versions %}
80 + {% if version.has_file %}
81 + <div class="download-row">
82 + <span class="download-version">v{{ version.number }}</span>
83 + {% if let Some(label) = version.label %}
84 + <span class="download-label">{{ label }}</span>
85 + {% else %}
86 + <span class="download-label" style="opacity: 0.5;">{% match version.file_name %}{% when Some with (name) %}{{ name }}{% when None %}Download{% endmatch %}</span>
87 + {% endif %}
88 + <span class="download-size">{{ version.size }}</span>
89 + <button class="secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;"
90 + onclick="downloadVersion('{{ version.id }}')">Download</button>
91 + </div>
92 + {% endif %}
93 + {% endfor %}
94 + </section>
95 +
96 + <div class="download-notice">
97 + Software downloads are provided by third-party creators. Makenot.work does
98 + not guarantee downloads are free of malware. Use antivirus software and
99 + download at your own risk. Report concerns to
100 + <strong>reports@makenot.work</strong>.
101 + </div>
102 + {% endif %}
103 +
104 + {% if !bundle_items.is_empty() %}
105 + <section class="library-section">
106 + <h2>Included in this bundle ({{ bundle_items.len() }})</h2>
107 + {% for child in bundle_items %}
108 + <div class="bundle-child">
109 + <span class="bundle-child-type">{{ child.item_type }}</span>
110 + <span class="bundle-child-title">
111 + <a href="/l/{{ child.id }}">{{ child.title }}</a>
112 + </span>
113 + </div>
114 + {% endfor %}
115 + </section>
116 + {% endif %}
117 +
118 + {% if !item.description.is_empty() %}
119 + <section class="library-section">
120 + <details>
121 + <summary style="cursor: pointer; font-family: var(--font-heading); font-size: 1.3rem;">Description</summary>
122 + <div style="margin-top: 1rem;">
123 + <p>{{ item.description }}</p>
124 + </div>
125 + </details>
126 + </section>
127 + {% endif %}
128 +
129 + {% if !sections.is_empty() %}
130 + <section class="library-section">
131 + <div class="section-tabs">
132 + {% for section in sections %}
133 + <button class="section-tab{% if loop.first %} active{% endif %}"
134 + data-tab="section-{{ section.slug }}"
135 + onclick="switchSectionTab(this, 'section-{{ section.slug }}')">{{ section.title }}</button>
136 + {% endfor %}
137 + </div>
138 + {% for section in sections %}
139 + <div class="section-panel{% if loop.first %} active{% endif %}" id="section-{{ section.slug }}">
140 + {{ section.body_html|safe }}
141 + </div>
142 + {% endfor %}
143 + </section>
144 + {% endif %}
145 +
146 + {% if item.license_preset.is_some() %}
147 + <section class="library-section">
148 + <h2>License</h2>
149 + <p style="margin-bottom: 0.75rem;">
150 + {% if item.license_preset.as_deref() == Some("personal_use") %}Personal Use Only
151 + {% else if item.license_preset.as_deref() == Some("royalty_free") %}Royalty-Free Commercial
152 + {% else if item.license_preset.as_deref() == Some("mit") %}MIT License
153 + {% else if item.license_preset.as_deref() == Some("apache2") %}Apache License 2.0
154 + {% else if item.license_preset.as_deref() == Some("cc_by_4") %}CC BY 4.0
155 + {% else if item.license_preset.as_deref() == Some("cc_by_nc_4") %}CC BY-NC 4.0
156 + {% else if item.license_preset.as_deref() == Some("cc0") %}Public Domain (CC0)
157 + {% else if item.license_preset.as_deref() == Some("custom") %}Custom License
158 + {% else %}License
159 + {% endif %}
160 + </p>
161 + <p>
162 + <a href="/api/items/{{ item.id }}/license.txt" download="LICENSE.txt" style="font-size: 0.9rem; color: var(--detail);">Download LICENSE.txt</a>
163 + </p>
164 + </section>
165 + {% endif %}
166 +
167 + {% include "partials/discussion_section.html" %}
168 +
169 + <footer class="item-footer">
170 + <p>Powered by <a href="/">Makenot<span class="dot">.</span>work</a></p>
171 + <p style="margin-top: 0.5rem;">
172 + {% if is_owner %}
173 + <a href="/dashboard/item/{{ item.id }}">Edit</a> &middot;
174 + {% endif %}
175 + <a href="/i/{{ item.id }}">Store page</a> &middot;
176 + <a href="/library">Your library</a>
177 + </p>
178 + </footer>
179 + </div>
180 + {% endblock %}
181 +
182 + {% block scripts %}
183 + <script>
184 + function switchSectionTab(btn, panelId) {
185 + document.querySelectorAll('.section-tab').forEach(function(t) { t.classList.remove('active'); });
186 + document.querySelectorAll('.section-panel').forEach(function(p) { p.classList.remove('active'); });
187 + btn.classList.add('active');
188 + var panel = document.getElementById(panelId);
189 + if (panel) panel.classList.add('active');
190 + history.replaceState(null, '', '#' + panelId);
191 + }
192 +
193 + (function() {
194 + var hash = window.location.hash.replace('#', '');
195 + if (hash) {
196 + var panel = document.getElementById(hash);
197 + var tab = document.querySelector('[data-tab="' + hash + '"]');
198 + if (panel && tab) switchSectionTab(tab, hash);
199 + }
200 + })();
201 +
202 + function downloadVersion(versionId) {
203 + fetch('/api/versions/' + versionId + '/download')
204 + .then(function(res) {
205 + if (!res.ok) throw new Error('Failed to get download URL');
206 + return res.json();
207 + })
208 + .then(function(data) {
209 + window.location.href = data.download_url;
210 + })
211 + .catch(function(err) {
212 + showToast(err.message || 'Download failed');
213 + });
214 + }
215 + </script>
216 + {% endblock %}
@@ -0,0 +1,42 @@
1 + {% extends "base.html" %}
2 +
3 + {% block title %}{{ item.title }} - Library - Makenotwork{% endblock %}
4 + {% block body_attrs %} class="padded-page"{% endblock %}
5 +
6 + {% block head %}
7 + <meta name="robots" content="noindex">
8 + {% endblock %}
9 +
10 + {% block content %}
11 + {% include "partials/site_header.html" %}
12 +
13 + <div class="container">
14 + <h1>{{ item.title }}</h1>
15 + <p style="opacity: 0.7;">by <a href="/u/{{ creator_username }}">{{ creator_username }}</a></p>
16 +
17 + {% if let Some(cover) = item.cover_image_url %}
18 + <img src="{{ cover }}" alt="{{ item.title }}" style="max-width: 320px; margin: 1rem 0;">
19 + {% endif %}
20 +
21 + <div class="purchase-box" style="margin-top: 2rem;">
22 + <h2>You don't have access to this yet</h2>
23 + <p style="opacity: 0.85;">This is the library view, available to people who own this item. Visit the store page to purchase or learn more.</p>
24 +
25 + <div style="margin-top: 1.5rem; display: flex; gap: 0.75rem; flex-wrap: wrap;">
26 + <a href="/i/{{ item.id }}"><button class="primary">View store page</button></a>
27 + {% if !is_logged_in %}
28 + <a href="/login?redirect=/l/{{ item.id }}"><button class="secondary">Log in</button></a>
29 + {% endif %}
30 + </div>
31 +
32 + {% if !containing_bundles.is_empty() %}
33 + <div style="margin-top: 1.5rem;">
34 + <p style="font-weight: bold;">Available in:</p>
35 + {% for bundle in containing_bundles %}
36 + <p style="margin-top: 0.5rem;"><a href="/i/{{ bundle.id }}">{{ bundle.title }}</a> <span style="opacity: 0.7;">{{ bundle.price }}</span></p>
37 + {% endfor %}
38 + </div>
39 + {% endif %}
40 + </div>
41 + </div>
42 + {% endblock %}
@@ -0,0 +1,93 @@
1 + {% extends "base.html" %}
2 +
3 + {% block title %}{{ item.title }} - Library - Makenotwork{% endblock %}
4 +
5 + {% block head %}
6 + <meta name="robots" content="noindex">
7 + <style>
8 + .article-container { max-width: 680px; margin: 0 auto; padding: 3rem 1.5rem 5rem; }
9 + .library-back { font-size: 0.85rem; opacity: 0.7; margin-bottom: 1.5rem; }
10 + .library-back a { color: var(--detail); }
11 + .author-header { display: flex; align-items: center; gap: 1rem; margin-bottom: 2rem; }
12 + .author-avatar { width: 48px; height: 48px; background: var(--surface-muted); border-radius: 50%; display: flex; align-items: center; justify-content: center; font-family: var(--font-heading); font-size: 1.25rem; color: var(--detail); }
13 + .author-name { font-family: var(--font-mono); font-size: 1rem; color: var(--detail); }
14 + .author-name a { color: inherit; text-decoration: none; }
15 + .author-name a:hover { text-decoration: underline; }
16 + .article-meta { font-family: var(--font-mono); font-size: 0.875rem; color: var(--text-muted); }
17 + .article-title { font-family: var(--font-heading); font-size: 2.5rem; font-weight: normal; line-height: 1.2; margin-bottom: 1.5rem; color: var(--detail); }
18 + .article-deck { font-family: var(--font-mono); font-size: 1.25rem; color: var(--text-muted); margin-bottom: 2.5rem; line-height: 1.6; }
19 + .article-body { font-family: var(--font-body); font-size: 1.125rem; line-height: 1.9; }
20 + .article-body p { margin-bottom: 1.5rem; }
21 + .article-body h1 { font-family: var(--font-heading); font-size: 2rem; font-weight: normal; margin-top: 3rem; margin-bottom: 1rem; color: var(--detail); }
22 + .article-body h2 { font-family: var(--font-mono); font-size: 1.75rem; font-weight: normal; margin-top: 3rem; margin-bottom: 1rem; color: var(--detail); }
23 + .article-body h3 { font-family: var(--font-mono); font-size: 1.375rem; font-weight: normal; margin-top: 2.5rem; margin-bottom: 0.75rem; color: var(--detail); }
24 + .article-body blockquote { border-left: 3px solid var(--highlight); padding-left: 1.5rem; margin: 2rem 0; font-style: italic; color: var(--text-muted); }
25 + .article-body ul, .article-body ol { margin-bottom: 1.5rem; padding-left: 1.5rem; }
26 + .article-body li { margin-bottom: 0.5rem; }
27 + .article-body a { color: var(--highlight); text-decoration: underline; }
28 + .article-body code { font-family: var(--font-mono); background: var(--surface-muted); padding: 0.125rem 0.375rem; font-size: 0.9em; }
29 + .article-body pre { background: var(--surface-muted); padding: 1rem; overflow-x: auto; margin-bottom: 1.5rem; }
30 + .article-body pre code { background: none; padding: 0; }
31 + .article-body hr { border: none; text-align: center; margin: 3rem 0; }
32 + .article-body hr::before { content: "* * *"; color: var(--text-muted); letter-spacing: 1rem; }
33 + .article-footer { margin-top: 4rem; padding-top: 2rem; border-top: 1px solid var(--border); }
34 + .article-tags { margin-top: 1.5rem; display: flex; gap: 0.5rem; flex-wrap: wrap; }
35 + .article-tag { font-family: var(--font-mono); background: var(--surface-muted); color: var(--detail); padding: 0.375rem 0.75rem; font-size: 0.8125rem; text-decoration: none; }
36 + @media (max-width: 600px) {
37 + .article-title { font-size: 2rem; }
38 + .article-deck { font-size: 1.125rem; }
39 + .article-body { font-size: 1rem; }
40 + }
41 + </style>
42 + {% endblock %}
43 +
44 + {% block content %}
45 + {% include "partials/site_header.html" %}
46 +
47 + <article class="article-container">
48 + <p class="library-back">
49 + <a href="/i/{{ item.id }}">&larr; Store page</a> &middot;
50 + <a href="/library">Your library</a>
51 + </p>
52 +
53 + <header class="author-header">
54 + <div class="author-avatar">{{ creator_avatar_initials }}</div>
55 + <div class="author-info">
56 + <div class="author-name"><a href="/u/{{ creator_username }}">{% if let Some(name) = creator_display_name %}{{ name }}{% else %}{{ creator_username }}{% endif %}</a></div>
57 + <div class="article-meta">{{ item.release_date }}{% if let Some(rt) = reading_time %} | {{ rt }}{% endif %}</div>
58 + </div>
59 + </header>
60 +
61 + <h1 class="article-title">{{ item.title }}</h1>
62 +
63 + {% if !item.description.is_empty() %}
64 + <p class="article-deck">{{ item.description }}</p>
65 + {% endif %}
66 +
67 + {% if let Some(html) = body_html %}
68 + <div class="article-body">
69 + {{ html|safe }}
70 + </div>
71 + {% endif %}
72 +
73 + <footer class="article-footer">
74 + {% if !item.tags.is_empty() %}
75 + <div class="article-tags">
76 + {% for tag in item.tags %}
77 + <a href="/discover?tag={{ tag.slug }}" class="article-tag">{{ tag.name }}</a>
78 + {% endfor %}
79 + </div>
80 + {% endif %}
81 + </footer>
82 + </article>
83 +
84 + {% include "partials/discussion_section.html" %}
85 +
86 + <footer style="text-align: center; padding: 2rem; font-size: 0.85rem; opacity: 0.6; border-top: 1px solid var(--border);">
87 + {% if is_owner %}
88 + <a href="/dashboard/item/{{ item.id }}" style="color: var(--detail);">Edit</a> &middot;
89 + {% endif %}
90 + <a href="/i/{{ item.id }}" style="color: var(--detail);">Store page</a> &middot;
91 + <a href="/library" style="color: var(--detail);">Your library</a>
92 + </footer>
93 + {% endblock %}
@@ -0,0 +1,173 @@
1 + {% extends "base.html" %}
2 +
3 + {% block title %}{{ item.title }} - Library - Makenotwork{% endblock %}
4 +
5 + {% block head %}
6 + <meta name="robots" content="noindex">
7 + <link rel="stylesheet" href="/static/media-player.css">
8 + <style>
9 + .library-back { font-size: 0.85rem; opacity: 0.7; margin: 1rem 0; }
10 + .library-back a { color: var(--detail); }
11 + .library-downloads { background: var(--light-background); padding: 1.5rem; margin-top: 2rem; }
12 + .library-downloads h3 { font-size: 1.1rem; margin-bottom: 1rem; }
13 + .download-row { display: flex; align-items: center; gap: 1rem; padding: 0.5rem 0; border-bottom: 1px solid var(--border); }
14 + .download-row:last-child { border-bottom: none; }
15 + </style>
16 + {% endblock %}
17 +
18 + {% block content %}
19 + {% include "partials/site_header.html" %}
20 +
21 + <article class="media-container">
22 + <p class="library-back">
23 + <a href="/i/{{ item.id }}">&larr; Store page</a> &middot;
24 + <a href="/library">Your library</a>
25 + </p>
26 +
27 + <header class="author-header">
28 + <div class="author-avatar">{{ creator_avatar_initials }}</div>
29 + <div class="author-info">
30 + <div class="author-name"><a href="/u/{{ creator_username }}">{% if let Some(name) = creator_display_name %}{{ name }}{% else %}{{ creator_username }}{% endif %}</a></div>
31 + <div class="media-meta">{{ item.release_date }}{% match item.content %}{% when crate::types::ItemContent::Video with { duration, .. } %}{% if let Some(dur) = duration %} | {{ dur }}{% endif %}{% when _ %}{% endmatch %}</div>
32 + </div>
33 + </header>
34 +
35 + <h1 class="media-title">{{ item.title }}</h1>
36 +
37 + {% if let Some(project_title) = project_title %}
38 + <p class="media-series">
39 + <a href="/p/{{ project_slug }}">{{ project_title }}</a>
40 + </p>
41 + {% endif %}
42 +
43 + <div class="video-display">
44 + <video id="media-a" preload="metadata"
45 + {% match item.content %}{% when crate::types::ItemContent::Video with { cover_url, .. } %}{% if let Some(url) = cover_url %}poster="{{ url }}"{% endif %}{% when _ %}{% endmatch %}
46 + style="width: 100%; background: #000;">
47 + {% if let Some(vurl) = video_url %}
48 + <source src="{{ vurl }}">
49 + {% endif %}
50 + </video>
51 + </div>
52 +
53 + <div class="media-player">
54 + <div class="player-controls">
55 + <button class="play-button" id="play-btn" aria-label="Play">
56 + <svg id="play-icon" width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
57 + <path d="M8 5v14l11-7z"/>
58 + </svg>
59 + <svg id="pause-icon" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" style="display: none;">
60 + <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
61 + </svg>
62 + </button>
63 + <div class="progress-container">
64 + <div class="progress-bar" id="progress-bar">
65 + <div class="progress-fill" id="progress-fill"></div>
66 + </div>
67 + <div class="time-display">
68 + <span id="current-time">0:00</span>
69 + <span id="duration">0:00</span>
70 + </div>
71 + </div>
72 + </div>
73 +
74 + <div class="insertion-label" id="insertion-label"></div>
75 + <button class="skip-insertion" id="skip-btn" type="button">Skip &#8250;</button>
76 +
77 + <div class="player-secondary">
78 + <div class="speed-control">
79 + <span>Speed:</span>
80 + <button class="speed-button" data-speed="0.5">0.5x</button>
81 + <button class="speed-button active" data-speed="1">1x</button>
82 + <button class="speed-button" data-speed="1.5">1.5x</button>
83 + <button class="speed-button" data-speed="2">2x</button>
84 + </div>
85 + <div class="volume-control">
86 + <span class="volume-icon">
87 + <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
88 + <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"/>
89 + </svg>
90 + </span>
91 + <input type="range" class="volume-slider" id="volume-slider" min="0" max="100" value="75" />
92 + </div>
93 + </div>
94 + </div>
95 +
96 + {% if !chapters.is_empty() %}
97 + <div class="chapters">
98 + <h3>Chapters</h3>
99 + <ul class="chapter-list">
100 + {% for chapter in chapters %}
101 + <li class="chapter-item" data-time="{{ chapter.start_seconds }}">
102 + <span class="chapter-title">{{ chapter.title }}</span>
103 + <span class="chapter-time">{{ chapter.timestamp }}</span>
104 + </li>
105 + {% endfor %}
106 + </ul>
107 + </div>
108 + {% endif %}
109 +
110 + {% if !item.description.is_empty() %}
111 + <div class="media-description">
112 + <p>{{ item.description }}</p>
113 + </div>
114 + {% endif %}
115 +
116 + {% if !versions.is_empty() %}
117 + <section class="library-downloads">
118 + <h3>Source files</h3>
119 + {% for version in versions %}
120 + {% if version.has_file %}
121 + <div class="download-row">
122 + <span style="min-width: 4em; font-family: var(--font-mono); font-size: 0.9rem;">v{{ version.number }}</span>
123 + {% if let Some(label) = version.label %}
124 + <span style="flex: 1;">{{ label }}</span>
125 + {% else %}
126 + <span style="flex: 1; opacity: 0.5;">{% match version.file_name %}{% when Some with (name) %}{{ name }}{% when None %}Download{% endmatch %}</span>
127 + {% endif %}
128 + <span style="font-size: 0.85rem; opacity: 0.6; min-width: 5em; text-align: right;">{{ version.size }}</span>
129 + <button class="secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;"
130 + onclick="downloadVersion('{{ version.id }}')">Download</button>
131 + </div>
132 + {% endif %}
133 + {% endfor %}
134 + </section>
135 + {% endif %}
136 + </article>
137 +
138 + {% include "partials/discussion_section.html" %}
139 +
140 + <footer class="media-player-footer">
141 + {% if is_owner %}
142 + <a href="/dashboard/item/{{ item.id }}">Edit</a> &middot;
143 + {% endif %}
144 + <a href="/i/{{ item.id }}">Store page</a> &middot;
145 + <a href="/library">Your library</a>
146 + </footer>
147 + {% endblock %}
148 +
149 + {% block scripts %}
150 + <script id="media-player-data" type="application/json">
151 + {
152 + "segments": {{ segments_json|safe }},
153 + "mediaType": "video",
154 + "itemId": "{{ item.id }}"
155 + }
156 + </script>
157 + <script src="/static/media-player.js"></script>
158 + <script>
159 + function downloadVersion(versionId) {
160 + fetch('/api/versions/' + versionId + '/download')
161 + .then(function(res) {
162 + if (!res.ok) throw new Error('Failed to get download URL');
163 + return res.json();
164 + })
165 + .then(function(data) {
166 + window.location.href = data.download_url;
167 + })
168 + .catch(function(err) {
169 + showToast(err.message || 'Download failed');
170 + });
171 + }
172 + </script>
173 + {% endblock %}