max / makenotwork
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 ›</button> | |
| 118 | - | ||
| 119 | - | <div class="player-secondary"> | |
| 120 | - | <div class="speed-control"> | |
| 121 | - | <span>Speed:</span> | |
| 122 | - | <button class="speed-button" data-speed="0.5">0.5x</button> | |
| 123 | - | <button class="speed-button active" data-speed="1">1x</button> | |
| 124 | - | <button class="speed-button" data-speed="1.5">1.5x</button> | |
| 125 | - | <button class="speed-button" data-speed="2">2x</button> | |
| 126 | - | </div> | |
| 127 | - | <div class="volume-control"> | |
| 128 | - | <span class="volume-icon"> | |
| 129 | - | <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"> | |
| 130 | - | <path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/> | |
| 131 | - | </svg> | |
| 132 | - | </span> | |
| 133 | - | <input type="range" class="volume-slider" id="volume-slider" min="0" max="100" value="75" /> | |
| 134 | - | </div> | |
| 135 | - | </div> | |
| 136 | - | </div> | |
| 137 | - | ||
| 138 | 87 | {% if !item.description.is_empty() %} | |
| 139 | 88 | <div class="media-description"> | |
| 140 | 89 | <p>{{ item.description }}</p> | |
| 141 | 90 | </div> | |
| 142 | 91 | {% endif %} | |
| 143 | - | {% else %} | |
| 144 | - | {% include "partials/paywall.html" %} | |
| 145 | - | {% endif %} | |
| 146 | 92 | ||
| 147 | - | {% if !chapters.is_empty() %} | |
| 148 | - | <div class="chapters"> | |
| 149 | - | <h3>Chapters</h3> | |
| 150 | - | <ul class="chapter-list"> | |
| 151 | - | {% for chapter in chapters %} | |
| 152 | - | <li class="chapter-item"{% if has_access %} data-time="{{ chapter.start_seconds }}"{% endif %}> | |
| 153 | - | <span class="chapter-title">{{ chapter.title }}</span> | |
| 154 | - | <span class="chapter-time">{{ chapter.timestamp }}</span> | |
| 155 | - | </li> | |
| 156 | - | {% endfor %} | |
| 157 | - | </ul> | |
| 93 | + | <div class="store-cta" style="margin: 2rem 0; padding: 1.5rem; background: var(--surface-muted); text-align: center;"> | |
| 94 | + | {% if has_access %} | |
| 95 | + | <p style="font-size: 1.1rem; margin-bottom: 1rem;">You have access to this item.</p> | |
| 96 | + | <a href="/l/{{ item.id }}"><button class="primary">View in library →</button></a> | |
| 97 | + | {% else if item.is_free %} | |
| 98 | + | {% if let Some(_user) = session_user %} | |
| 99 | + | {% if in_library %} | |
| 100 | + | <span class="library-status" style="font-family: var(--font-mono); color: var(--text-muted);">In your library · <a href="/l/{{ item.id }}">Listen now →</a></span> | |
| 101 | + | {% else %} | |
| 102 | + | <button class="add-to-library-btn primary" | |
| 103 | + | hx-post="/api/library/add/{{ item.id }}" | |
| 104 | + | hx-swap="outerHTML">Add to Library - Free</button> | |
| 105 | + | {% endif %} | |
| 106 | + | {% else %} | |
| 107 | + | <a href="/login?redirect=/l/{{ item.id }}"><button class="primary">Log in to listen - Free</button></a> | |
| 108 | + | {% endif %} | |
| 109 | + | {% else %} | |
| 110 | + | <p style="font-size: 1.5rem; margin-bottom: 1rem;">{{ item.price }}</p> | |
| 111 | + | {% if let Some(_user) = session_user %} | |
| 112 | + | <a href="/purchase/{{ item.id }}"><button class="primary">{% if item.pwyw_enabled %}Pay What You Want{% else %}Buy Once{% endif %} - {{ item.price }}</button></a> | |
| 113 | + | {% else %} | |
| 114 | + | <a href="/login?redirect=/i/{{ item.id }}"><button class="primary">Log in to purchase</button></a> | |
| 115 | + | {% endif %} | |
| 116 | + | <p style="font-size: 0.85rem; opacity: 0.7; margin-top: 0.75rem;">Support {{ creator_username }} directly — 0% platform fee.</p> | |
| 117 | + | {% endif %} | |
| 158 | 118 | </div> | |
| 159 | - | {% endif %} | |
| 160 | 119 | ||
| 161 | 120 | {% if !item.tags.is_empty() %} | |
| 162 | 121 | <div class="media-tags"> | |
| @@ -166,24 +125,6 @@ | |||
| 166 | 125 | </div> | |
| 167 | 126 | {% endif %} | |
| 168 | 127 | ||
| 169 | - | {% if is_free %} | |
| 170 | - | <div class="library-action" style="margin-top: 2rem; padding-top: 2rem; border-top: 1px solid var(--border);"> | |
| 171 | - | {% if let Some(_user) = session_user %} | |
| 172 | - | {% if in_library %} | |
| 173 | - | <span class="library-status" style="font-family: var(--font-mono); color: var(--text-muted);">In your library</span> | |
| 174 | - | {% else %} | |
| 175 | - | <button class="add-to-library-btn" | |
| 176 | - | hx-post="/api/library/add/{{ item.id }}" | |
| 177 | - | hx-swap="outerHTML" | |
| 178 | - | style="background: var(--primary-dark); color: var(--primary-light); border: none; padding: 0.75rem 1.5rem; font-family: var(--font-mono); cursor: pointer;"> | |
| 179 | - | Add to Library | |
| 180 | - | </button> | |
| 181 | - | {% endif %} | |
| 182 | - | {% else %} | |
| 183 | - | <a href="/login" style="font-family: var(--font-mono); color: var(--highlight);">Log in to add to library</a> | |
| 184 | - | {% endif %} | |
| 185 | - | </div> | |
| 186 | - | {% endif %} | |
| 187 | 128 | </article> | |
| 188 | 129 | ||
| 189 | 130 | {% include "partials/discussion_section.html" %} | |
| @@ -194,15 +135,4 @@ | |||
| 194 | 135 | </footer> | |
| 195 | 136 | {% endblock %} | |
| 196 | 137 | ||
| 197 | - | {% block scripts %} | |
| 198 | - | {% if has_access %} | |
| 199 | - | <script id="media-player-data" type="application/json"> | |
| 200 | - | { | |
| 201 | - | "segments": {{ segments_json|safe }}, | |
| 202 | - | "mediaType": "audio", | |
| 203 | - | "itemId": "{{ item.id }}" | |
| 204 | - | } | |
| 205 | - | </script> | |
| 206 | - | <script src="/static/media-player.js"></script> | |
| 207 | - | {% endif %} | |
| 208 | - | {% endblock %} | |
| 138 | + | {% block scripts %}{% endblock %} |
| @@ -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 →</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 }}">← Store page</a> · | |
| 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 ›</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> · | |
| 150 | + | {% endif %} | |
| 151 | + | <a href="/i/{{ item.id }}">Store page</a> · | |
| 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 }}">← Store page</a> · | |
| 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 | + | · <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> · | |
| 174 | + | {% endif %} | |
| 175 | + | <a href="/i/{{ item.id }}">Store page</a> · | |
| 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 }}">← Store page</a> · | |
| 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> · | |
| 89 | + | {% endif %} | |
| 90 | + | <a href="/i/{{ item.id }}" style="color: var(--detail);">Store page</a> · | |
| 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 }}">← Store page</a> · | |
| 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 ›</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> · | |
| 143 | + | {% endif %} | |
| 144 | + | <a href="/i/{{ item.id }}">Store page</a> · | |
| 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 %} |