From d39b672bf5ea55df8a8ace3c1e3d9735a6faa548 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 15 May 2026 21:13:49 -0400 Subject: [PATCH] fixes and improvements --- docs/BADGES_FEATURE.md | 211 ++++++++++ docs/COLLECTIONS_FEATURE.md | 214 ++++++++++ docs/QR_CODE_LAYER_FEATURE.md | 213 ++++++++++ docs/TEMPLATE_FORKING_FEATURE.md | 162 ++++++++ docs/TMDB_AUTOFILL_FEATURE.md | 244 +++++++++++ docs/TRUST_AND_BLOCK_FEATURE.md | 247 ++++++++++++ docs/ZAPS_NWC_FEATURE.md | 365 +++++++++++++++++ docs/ZAP_GATED_TEMPLATES_FEATURE.md | 254 ++++++++++++ src/favicon.ico | Bin 0 -> 15406 bytes src/index.html | 604 +++++++++++++++++++++++----- 10 files changed, 2412 insertions(+), 102 deletions(-) create mode 100644 docs/BADGES_FEATURE.md create mode 100644 docs/COLLECTIONS_FEATURE.md create mode 100644 docs/QR_CODE_LAYER_FEATURE.md create mode 100644 docs/TEMPLATE_FORKING_FEATURE.md create mode 100644 docs/TMDB_AUTOFILL_FEATURE.md create mode 100644 docs/TRUST_AND_BLOCK_FEATURE.md create mode 100644 docs/ZAPS_NWC_FEATURE.md create mode 100644 docs/ZAP_GATED_TEMPLATES_FEATURE.md create mode 100644 src/favicon.ico diff --git a/docs/BADGES_FEATURE.md b/docs/BADGES_FEATURE.md new file mode 100644 index 0000000..554b861 --- /dev/null +++ b/docs/BADGES_FEATURE.md @@ -0,0 +1,211 @@ +# NIP-58 Badges Feature Plan + +## Overview +Award and display Nostr badges on creator profiles in the community feed. Badges like +"Top Creator", "100 Zaps Received", and "Verified Designer" appear on template cards and +profile views. Because badges are a Nostr standard (NIP-58), they automatically sync with +and appear in other Nostr clients (Damus, Primal, Snort) — no proprietary profile system needed. + +## Complexity +**Low–Moderate** — Estimated 3–5 hours +- Badge display (reading + rendering): ~2 hours +- Badge awarding (publishing badge events): ~2 hours +- Admin/auto-award logic: ~1 hour + +--- + +## NIP-58 Primer + +Three event kinds make up the badge system: + +| Kind | Name | Purpose | +|------|------|---------| +| 30009 | Badge Definition | Defines a badge: name, description, image (addressable, created by issuer) | +| 8 | Badge Award | Issues a badge to specific pubkeys | +| 30008 | Profile Badges | User's curated list of badges they want displayed on their profile | + +### Badge Definition (kind 30009) +```json +{ + "kind": 30009, + "pubkey": "", + "tags": [ + ["d", "top-creator"], + ["name", "Top Creator"], + ["description", "Awarded to creators with 10+ published templates"], + ["image", "https://casewrap.app/badges/top-creator.png", "512x512"], + ["thumb", "https://casewrap.app/badges/top-creator-64.png", "64x64"] + ] +} +``` + +### Badge Award (kind 8) +```json +{ + "kind": 8, + "pubkey": "", + "tags": [ + ["a", "30009::top-creator"], + ["p", "", ""] + ] +} +``` + +### Profile Badges (kind 30008) +Users opt-in to displaying specific badges. The app reads this to know which badges +to show on a profile: +```json +{ + "kind": 30008, + "pubkey": "", + "tags": [ + ["d", "profile_badges"], + ["a", "30009::top-creator"], + ["e", ""] + ] +} +``` + +--- + +## Badge Catalog (Proposed) + +| Badge ID | Name | Criteria | Auto-awarded? | +|----------|------|----------|---------------| +| `first-publish` | First Publish | Published first template | Yes | +| `top-creator` | Top Creator | 10+ published templates | Yes | +| `prolific` | Prolific | 50+ published templates | Yes | +| `zap-magnet` | Zap Magnet | Received 100+ zaps | Yes (via kind 9735 scan) | +| `early-adopter` | Early Adopter | Joined before a cutoff date | Manual | +| `verified-designer` | Verified Designer | Manually awarded by app admins | Manual | + +App issues badges from a dedicated CaseWrap app keypair (not tied to any user's NIP-07 key). +The app keypair's pubkey and private key are stored in the app's admin config. + +--- + +## Key Components + +### 1. Badge Display on Template Cards and Profile Views + +Fetch the creator's kind 30008 profile badges event, cross-reference with known badge +definitions, and render badge thumbnails: + +```javascript +async function fetchProfileBadges(pubkey) { + // Query relays for kind 30008 authored by pubkey + // Returns array of badge definition a-tags the user has accepted +} + +async function resolveDisplayBadges(pubkey) { + const profileBadges = await fetchProfileBadges(pubkey); + // Filter to only CaseWrap-issued badges (by app issuer pubkey) + // Return array of { id, name, thumbUrl } for known badges +} +``` + +Render as small badge icons (32×32) in the author row of template cards: +``` +alice@getalby.com 🏆 🎨 ⚡ · 2 days ago +``` + +Hovering a badge shows a tooltip with the badge name and description. + +### 2. Badge Cache +Badge definitions rarely change — cache fetched definitions in `sessionStorage`: + +```javascript +const BADGE_CACHE_KEY = 'casewrap-badge-cache'; + +function cacheBadgeDefinition(def) { /* store in sessionStorage */ } +function getCachedBadge(dTag) { /* retrieve from sessionStorage */ } +``` + +### 3. Auto-Award Logic (client-side trigger) +When a user publishes a template, check if they've crossed a badge threshold and award +automatically. Award logic runs after publish: + +```javascript +async function checkAndAwardBadges(pubkey) { + const count = await fetchPublishedTemplateCount(pubkey); + if (count === 1) await awardBadge(pubkey, 'first-publish'); + if (count === 10) await awardBadge(pubkey, 'top-creator'); + if (count === 50) await awardBadge(pubkey, 'prolific'); +} +``` + +Note: Auto-awarding requires the app's issuer private key to sign the kind 8 event. +For a fully static app this means either: +- Keeping the issuer key client-side (acceptable for low-stakes community badges), or +- Using a small serverless function as the award endpoint (one POST, no database) + +### 4. Badge Award Function + +```javascript +async function awardBadge(recipientPubkey, badgeId) { + const aTag = `30009:${APP_ISSUER_PUBKEY}:${badgeId}`; + const event = { + kind: 8, + pubkey: APP_ISSUER_PUBKEY, + tags: [ + ['a', aTag], + ['p', recipientPubkey], + ], + content: '', + created_at: Math.floor(Date.now() / 1000), + }; + // Sign with APP_ISSUER_PRIVKEY (noble-secp256k1), publish to relays + await publishToRelays(signEvent(event, APP_ISSUER_PRIVKEY)); +} +``` + +### 5. Badge Accept UI (Optional) +After receiving a badge, the user can add it to their kind 30008 profile badges so it +shows across all Nostr clients. Show a prompt: "You earned Top Creator! Add it to your profile?" +Clicking yes publishes/updates their kind 30008 event via NIP-07. + +--- + +## UI Changes +- Author row on template feed cards: small badge icon row (max 3, +N overflow) +- Template detail panel: full badge list with names and descriptions on hover +- Profile section in settings: "Your Badges" list showing earned + pending badges +- Publish success toast: "🏆 You earned a new badge!" when a threshold is crossed + +--- + +## Performance Considerations +- Fetch kind 30008 lazily — only when a card is hovered or detail panel opens +- Cache badge definitions in `sessionStorage` — definitions rarely change +- Batch profile badge fetches with other kind 0 / kind 3 queries at feed load time +- Show badges from cache immediately; refresh in background + +--- + +## Limitations +- Badge display depends on the creator having accepted badges into their kind 30008 event + (users who never do this won't show badges even if awarded) +- Auto-award requires the app's issuer key to be available; keep it budget-appropriate + (not a treasury key) since it lives client-side +- No revocation mechanism in NIP-58 — badges cannot be taken back once awarded + +--- + +## Future Enhancements +- User-to-user badge awards ("Community Pick" badge that any user can nominate for) +- Badge showcase page listing all CaseWrap badges and current recipients +- Integration with zap receipts to auto-award "Zap Magnet" by scanning kind 9735 events + +--- + +## Testing Checklist +- [ ] Badge thumbnails render in author row on template cards +- [ ] Hovering badge shows tooltip with name and description +- [ ] Badge cache prevents redundant relay queries on repeated views +- [ ] `first-publish` badge is awarded on first successful template publish +- [ ] `top-creator` badge is awarded when published count reaches 10 +- [ ] Kind 8 award event contains correct `a` and `p` tags +- [ ] "Add to profile" prompt publishes/updates kind 30008 via NIP-07 +- [ ] Badge display respects user's kind 30008 accepted list (not all awarded badges shown) +- [ ] No badge icons appear for creators with no accepted badges +- [ ] Settings "Your Badges" panel shows earned badges correctly diff --git a/docs/COLLECTIONS_FEATURE.md b/docs/COLLECTIONS_FEATURE.md new file mode 100644 index 0000000..f5fb304 --- /dev/null +++ b/docs/COLLECTIONS_FEATURE.md @@ -0,0 +1,214 @@ +# Collections Feature Plan (NIP-51) + +## Overview +Let users organize their favorite community templates into named collections — "My Pixar Set", +"Horror Night", "Criterion Shelf" — and share them publicly via Nostr. Anyone can browse a +collection and import templates from it directly. Collections are standard NIP-51 bookmark +sets (kind 30001), so they sync across Nostr clients automatically. + +## Complexity +**Low–Moderate** — Estimated 3–5 hours + +--- + +## Nostr Primitive: NIP-51 Bookmark Sets (kind 30001) + +Kind 30001 is an addressable (replaceable) event that holds a named list of references. +For template collections, each list item is an `a` tag pointing to a kind 30078 template: + +```json +{ + "kind": 30001, + "pubkey": "", + "tags": [ + ["d", "my-pixar-set"], + ["title", "My Pixar Set"], + ["description", "All my Pixar case wraps"], + ["image", "https://...cover.jpg"], + ["a", "30078::", ""], + ["a", "30078::", ""] + ], + "content": "" +} +``` + +- The `d` tag is the collection's unique identifier (slug). Updating with the same `d` + replaces the previous version — no duplicate events accumulate. +- `title`, `description`, and `image` are optional display metadata. +- Each `a` tag references one template (kind 30078 addressable event). + +--- + +## Key Components + +### 1. Creating a Collection + +```javascript +async function createCollection(name, description) { + const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-'); + const event = { + kind: 30001, + tags: [ + ['d', slug], + ['title', name], + ['description', description], + ], + content: '', + created_at: Math.floor(Date.now() / 1000), + }; + await publishToRelays(await window.nostr.signEvent(event)); + return slug; +} +``` + +### 2. Adding/Removing a Template from a Collection + +Since kind 30001 is a replaceable event, adding an item means fetching the current +version, appending the new `a` tag, and re-publishing: + +```javascript +async function addToCollection(collectionSlug, templateEvent) { + const aTag = buildATag(templateEvent); // '30078::' + const current = await fetchMyCollection(collectionSlug); + const tags = current ? [...current.tags] : [['d', collectionSlug]]; + if (!tags.some(t => t[0] === 'a' && t[1] === aTag)) { + tags.push(['a', aTag, '']); + } + await publishToRelays(await window.nostr.signEvent({ + kind: 30001, tags, content: '', created_at: Math.floor(Date.now() / 1000), + })); +} + +async function removeFromCollection(collectionSlug, templateEvent) { + const aTag = buildATag(templateEvent); + const current = await fetchMyCollection(collectionSlug); + if (!current) return; + const tags = current.tags.filter(t => !(t[0] === 'a' && t[1] === aTag)); + await publishToRelays(await window.nostr.signEvent({ + kind: 30001, tags, content: '', created_at: Math.floor(Date.now() / 1000), + })); +} +``` + +### 3. Fetching Collections + +```javascript +async function fetchMyCollections() { + // Query kind 30001 authored by myPubkey + return await queryRelays(relays, { + kinds: [30001], + authors: [state.community.myPubkey], + }, { timeoutMs: 3000 }); +} + +async function fetchCollectionByAddress(pubkey, slug) { + // Fetch a specific collection by pubkey + d-tag for sharing/browsing + return await queryRelays(relays, { + kinds: [30001], + authors: [pubkey], + '#d': [slug], + }, { timeoutMs: 3000 }); +} +``` + +### 4. Browsing a Shared Collection + +Collections are shareable via a URL fragment or Nostr address (`naddr`): +``` +casewrap.app/#collection/naddr1... +``` + +When a collection address is opened: +1. Fetch the kind 30001 event by naddr +2. Extract all `a` tags → list of template addresses +3. Fetch each kind 30078 template event +4. Render them as a mini-feed with "Import All" option + +### 5. State Additions + +```javascript +// Add to state: +collections: { + mine: [], // user's own kind 30001 events + viewing: null, // currently browsed collection (own or external) +} +``` + +--- + +## UI + +### Template Card — "Save to Collection" Menu Item +Add to the card action menu (⋮): +``` +⑃ Remix this design +📁 Save to collection ▶ [My Pixar Set] + [Horror Night] + [+ New collection…] +``` + +### Collections Panel (in sidebar or settings) +``` +┌─────────────────────────────────────────┐ +│ 📁 My Collections [+ New] │ +│ │ +│ My Pixar Set 8 templates [→] │ +│ Horror Night 3 templates [→] │ +│ Criterion Shelf 12 templates [→] │ +└─────────────────────────────────────────┘ +``` + +### Collection Detail View +Clicking a collection opens a mini-feed inside the community panel: +``` +← Back My Pixar Set (8 templates) +───────────────────────────────────── +[Import All] [Share] [Edit] [Delete] + +[Card] [Card] [Card] ... +``` + +"Share" copies the `naddr` Nostr address to the clipboard for sharing. + +### Feed Cards — Collection Badge +If a template is in one of the user's collections, show a small 📁 icon on the card corner. + +--- + +## Performance Considerations +- Kind 30001 events are small (just tags) — fast to fetch and publish +- Fetch the user's collections once at login, cache in `state.collections.mine` +- Re-fetch lazily when the collections panel is opened +- Template events inside a collection are fetched only when the collection detail is opened + +--- + +## Limitations +- Requires NIP-07 login to create or modify collections +- Read-only browsing of other users' shared collections works without login +- Nostr clients that don't parse kind 30001 `a` tags (only `e` tags) may not display templates correctly — this is a client compatibility issue, not a bug here +- No private/encrypted collections — kind 30001 is a public event (NIP-51 has a draft for private lists but it is not widely supported) + +--- + +## Future Enhancements +- Collaborative collections: multiple pubkeys can contribute (needs a separate coordination event) +- Collection cover image: auto-generated from the first template's preview image +- "Import All" bulk-imports every template in a collection into a local queue +- Featured collections: curated by app admins on a discovery page +- RSS/Atom feed for a collection (static export) + +--- + +## Testing Checklist +- [ ] Creating a collection publishes a valid kind 30001 event with `d` and `title` tags +- [ ] Adding a template adds the correct `a` tag and re-publishes the event +- [ ] Removing a template removes the `a` tag without affecting others +- [ ] Duplicate adds are ignored (same `a` tag not added twice) +- [ ] Collections panel lists all user's kind 30001 events with correct counts +- [ ] Collection detail fetches and renders referenced template events +- [ ] "Share" copies a valid naddr to clipboard +- [ ] Shared collection URL loads and renders without login +- [ ] "Import All" imports each template in the collection +- [ ] 📁 badge appears on feed cards that are in a user collection +- [ ] Collections sync correctly after page reload (re-fetched from relays) diff --git a/docs/QR_CODE_LAYER_FEATURE.md b/docs/QR_CODE_LAYER_FEATURE.md new file mode 100644 index 0000000..f2de503 --- /dev/null +++ b/docs/QR_CODE_LAYER_FEATURE.md @@ -0,0 +1,213 @@ +# QR Code Layer Feature Plan + +## Overview +Add a QR code as a first-class layer type in the editor. Users can place a scannable QR +code anywhere on a case wrap or disc label — linking to the film's IMDb page, the Nostr +template event, a personal website, or any custom URL. The QR code renders live in the +editor canvas and is included in the print export. + +## Complexity +**Low** — Estimated 2–3 hours +QR generation is a solved problem with a small, dependency-free library. The main work +is wiring it into the existing layer system. + +--- + +## Library + +**qrcode-generator** (or `qrcode.js`) — pure JavaScript, no dependencies, ~15 KB: +```html + +``` + +Alternatively, use the browser's `canvas` API directly via **qr-creator** (5 KB ESM): +```html + +``` + +Both render directly to a `` element — a natural fit since the editor already +uses canvas for layout rendering. + +--- + +## Layer Type: `qr` + +Extend the existing layer system with a new `type: 'qr'` alongside the existing image +and text layer types: + +```javascript +// New layer schema +{ + type: 'qr', + url: 'https://imdb.com/title/tt0145487', // content to encode + size: 200, // px — width and height (always square) + x: 50, // position offset + y: 50, + fgColor: '#000000', + bgColor: '#ffffff', + bgTransparent: false, +} +``` + +--- + +## Key Components + +### 1. QR Rendering Function + +Generate a QR canvas and draw it into the main layout canvas at the layer's position: + +```javascript +function renderQRLayer(ctx, layer) { + const offscreen = document.createElement('canvas'); + offscreen.width = layer.size; + offscreen.height = layer.size; + + QrCreator.render({ + text: layer.url, + radius: 0, // square modules + ecLevel: 'M', // medium error correction (good balance for print) + fill: layer.fgColor, + background: layer.bgTransparent ? null : layer.bgColor, + size: layer.size, + }, offscreen); + + ctx.drawImage(offscreen, layer.x, layer.y, layer.size, layer.size); +} +``` + +### 2. Add QR Layer UI + +Add "Add QR Code" to the existing "Add Layer" menu (alongside "Add Image" and "Add Text"): + +``` ++ Add layer + ├ Image layer + ├ Text layer + └ QR Code layer ← new +``` + +Clicking it opens a small setup modal: + +``` +┌──────────────────────────────────────┐ +│ Add QR Code │ +│ │ +│ URL to encode: │ +│ [https://imdb.com/title/tt0145487 ] │ +│ │ +│ Quick links: │ +│ [IMDb page] [This template] [Custom] │ +│ │ +│ Size: [200] px │ +│ Colors: ■ Dark [#000000] □ Light [#ffffff] │ +│ ☐ Transparent background │ +│ │ +│ [Cancel] [Add to design] │ +└──────────────────────────────────────┘ +``` + +### 3. Quick Link Presets + +Pre-populate the URL field from context: + +```javascript +const QR_PRESETS = { + imdb: () => `https://imdb.com/title/${state.metadata.imdbId}`, + template: () => `https://njump.me/${buildNaddr(state.community.myPubkey, state.publishedEventDTag)}`, + letterboxd: () => `https://letterboxd.com/film/${slugify(state.metadata.title)}`, +}; +``` + +"This template" preset is only enabled after the user has published the template (so there +is an event address to link to). + +### 4. Layer Edit Panel + +When a QR layer is selected in the editor, show its properties in the layer panel: +- URL input (live re-renders QR on change) +- Size slider (64px–512px) +- Foreground / background color pickers +- Transparent background toggle (useful for dark case backgrounds) + +### 5. Undo/Redo Integration + +QR layer add/edit/delete should trigger `saveUndoSnapshot()` — same as all other layer +operations (already planned in UNDO_REDO_FEATURE.md). + +--- + +## Rendering in Print Export + +The QR layer renders identically in both the live preview and the print/PDF export since +both paths use the same canvas drawing code. No special print handling needed. + +Recommended minimum size for print legibility: +- Disc label (small area): 80px minimum at 150 DPI → ~13mm physical +- Case wrap: 120px+ for comfortable scanning + +Show a warning if the user sets size below 64px: "QR code may be too small to scan when printed." + +--- + +## Error Handling + +Some URLs produce QR codes too dense to scan reliably at small sizes: +- `qr-creator` will throw if the content is too long for the selected error correction level +- Fall back to lower error correction (`'L'`) automatically, warn the user if it still fails + +```javascript +function safeRenderQR(canvas, layer) { + for (const ecLevel of ['M', 'L']) { + try { + QrCreator.render({ ...options, ecLevel }, canvas); + return; + } catch { + continue; + } + } + renderQRErrorPlaceholder(canvas, 'URL too long for QR at this size'); +} +``` + +--- + +## Performance Considerations +- QR generation is synchronous and fast (<5ms for typical URLs) +- Offscreen canvas is created and discarded per render — no memory leak +- Live re-render on URL change is instant; no debounce needed + +--- + +## Limitations +- QR codes only encode text/URLs — no binary data +- Very long URLs (>200 chars) produce dense QR codes that may not scan reliably at small print sizes; recommend URL shorteners for long links +- Transparent background only works if the printing surface is light-colored + +--- + +## Future Enhancements +- Rounded module style (aesthetic option for modern-looking QR codes) +- Center logo/image inside QR code (common branding pattern — works with high EC level) +- NFC tag companion: generate NFC URI payload alongside QR (informational only) +- Batch QR: auto-place IMDb QR on all designs in a session + +--- + +## Testing Checklist +- [ ] "Add QR Code" option appears in the Add Layer menu +- [ ] QR setup modal opens and pre-populates IMDb quick link when imdbId is set +- [ ] QR code renders correctly in the editor canvas at added position +- [ ] Live re-render triggers when URL is edited in the layer panel +- [ ] Size slider updates QR dimensions in real time +- [ ] Foreground/background color changes reflect immediately +- [ ] Transparent background renders correctly over a dark case wrap +- [ ] QR layer is included in the print/PDF export +- [ ] Sub-64px size shows legibility warning +- [ ] Overly long URL falls back to lower EC level gracefully +- [ ] QR add/edit/delete triggers undo snapshot +- [ ] QR layer saves and restores correctly in project JSON export/import +- [ ] Rendered QR code is actually scannable (test with phone camera) diff --git a/docs/TEMPLATE_FORKING_FEATURE.md b/docs/TEMPLATE_FORKING_FEATURE.md new file mode 100644 index 0000000..5259b9f --- /dev/null +++ b/docs/TEMPLATE_FORKING_FEATURE.md @@ -0,0 +1,162 @@ +# Template Forking / Remix Feature Plan + +## Overview +Allow users to import any community template as a starting point for their own design, +with automatic attribution to the original creator baked into the Nostr event. Forks are +discoverable — anyone can see a template's remix lineage — and the original creator gets +notified via a Nostr mention when someone forks their work. + +## Complexity +**Low–Moderate** — Estimated 2–4 hours + +--- + +## How It Works + +### Forking Flow +1. User clicks "Remix this design" on a community template card +2. App imports the template payload into the editor (same as existing "Import" flow) +3. When the user publishes their remixed version, the new event includes a reference + to the original template event ID in its tags +4. Original creator receives a Nostr notification (kind 1 mention) that their template + was remixed + +### Nostr Event Tags for a Fork +Add two tags to the published kind 30078 event: + +```json +{ + "tags": [ + ["e", "", "", "fork"], + ["p", ""] + ] +} +``` + +- `["e", ..., "fork"]` — marks this as a derivative work; the marker `"fork"` is a + custom convention (Nostr allows arbitrary `e` tag markers) +- `["p", ...]` — notifies the original creator; their client will surface this as a mention + +### Attribution Display +In the template detail view, show a "Forked from" row when a `fork` e-tag is present: + +``` +Forked from: [Alice's Spider-Man Wrap] by alice@getalby.com → +``` + +Clicking the arrow navigates to the original template in the feed. + +--- + +## Key Components + +### 1. Remix Button +Add "Remix this design" to the template card action menu alongside existing Import option. +Visually distinguish it (⑃ fork icon) so users understand it's different from a plain import. + +```javascript +function remixTemplate(event) { + importDiscDesignFromPayload(event.content); // existing import function + state.community.forkSourceEventId = event.id; + state.community.forkSourcePubkey = event.pubkey; + showNotification('Template loaded — your remix will credit the original creator.'); +} +``` + +### 2. Fork State +Track pending fork metadata in state so it can be attached at publish time: + +```javascript +// Add to state: +fork: { + sourceEventId: null, // event ID of the template being remixed + sourcePubkey: null, // pubkey of the original creator +} +``` + +Clear `state.fork` when the user starts a fresh project or imports without remixing. + +### 3. Publish Hook +In the existing publish function, inject fork tags when `state.fork.sourceEventId` is set: + +```javascript +function buildPublishTags(existingTags) { + const tags = [...existingTags]; + if (state.fork.sourceEventId) { + tags.push(['e', state.fork.sourceEventId, '', 'fork']); + tags.push(['p', state.fork.sourcePubkey]); + } + return tags; +} +``` + +### 4. Fork Detection on Render +When rendering a template detail card, check for a `fork` e-tag and fetch + display the +source template title and author: + +```javascript +function getForkSource(event) { + const tag = event.tags?.find(t => t[0] === 'e' && t[3] === 'fork'); + return tag ? { eventId: tag[1], relayHint: tag[2] } : null; +} +``` + +### 5. Remix Count +In the community feed, a template's remix count = number of kind 30078 events that +reference it with a `fork` e-tag. This can be fetched lazily: + +```javascript +async function fetchRemixCount(eventId) { + const events = await queryRelays(relays, { + kinds: [30078], + '#e': [eventId], + }, { timeoutMs: 2000 }); + return events.filter(e => e.tags.some(t => t[0] === 'e' && t[3] === 'fork')).length; +} +``` + +--- + +## UI Changes +- Template card action menu: add "⑃ Remix this design" below "Import" +- Editor header: show "Remixing: [original title]" badge while fork state is active +- Editor header: "Clear remix" link to discard attribution and start fresh +- Template detail panel: "Forked from" row (when fork e-tag present) +- Template detail panel: "X remixes" count below reaction row + +--- + +## Performance Considerations +- Fork source lookup is a single relay query — run it lazily when the detail panel opens +- Remix count query can be skipped on initial feed load and fetched on hover/click +- No new relay subscriptions needed — reuses existing `queryRelays()` helper + +--- + +## Limitations +- Fork attribution is opt-in convention — anyone can publish a derivative without tagging; + this feature makes attribution easy but cannot enforce it +- Relay hints in the `e` tag are best-effort; original event may not be on all relays +- No fork graph visualization (full lineage tree) — out of scope for initial version + +--- + +## Future Enhancements +- Fork tree visualization: show the full remix lineage of popular templates +- "See all remixes" panel on template detail +- Notify original creator via kind 1 DM or push notification service +- Attribution in exported PDF metadata + +--- + +## Testing Checklist +- [ ] "Remix this design" imports template content into editor correctly +- [ ] Fork state (`sourceEventId`, `sourcePubkey`) is set after remix +- [ ] Published event contains `["e", ..., "fork"]` and `["p", ...]` tags +- [ ] Fork state clears on new project / plain import +- [ ] "Remixing: [title]" badge shows in editor while fork is active +- [ ] "Clear remix" removes fork state and badge +- [ ] Template detail shows "Forked from" row when fork e-tag is present +- [ ] "Forked from" link navigates to source template in feed +- [ ] Remix count displays on template detail (fetched lazily) +- [ ] Publishing without remix produces no fork tags diff --git a/docs/TMDB_AUTOFILL_FEATURE.md b/docs/TMDB_AUTOFILL_FEATURE.md new file mode 100644 index 0000000..8924182 --- /dev/null +++ b/docs/TMDB_AUTOFILL_FEATURE.md @@ -0,0 +1,244 @@ +# TMDB Auto-Fill Feature Plan + +## Overview +When a user enters an IMDb ID (or searches by title), automatically populate the editor +with high-quality artwork and metadata from The Movie Database (TMDB): front cover poster, +backdrop image, title, year, and genre. This replaces the current manual "paste a URL" +workflow for the most common case — making a case wrap for a specific film. + +## Complexity +**Low** — Estimated 2–3 hours +The app already stores `imdbId` in state. TMDB has a free API with IMDb ID lookup. +No new dependencies needed — vanilla `fetch()` is sufficient. + +--- + +## Why TMDB Over OMDB + +The app already has an `omdbApiKey` field. OMDB is text-only — it returns metadata but +no image URLs. TMDB provides: +- High-resolution poster images (up to 4K) +- Wide backdrop images (ideal for back cover / full wrap) +- Official logo PNGs with transparent backgrounds (via fanart.tv or TMDB images) +- Cast photos, disc art stills + +TMDB API is free for non-commercial use with a v3 API key (takes ~1 minute to register). + +--- + +## API Overview + +### Step 1 — Resolve IMDb ID to TMDB ID +``` +GET https://api.themoviedb.org/3/find/{imdb_id}?api_key={key}&external_source=imdb_id +``` +Response includes `movie_results[0].id` (TMDB ID). + +### Step 2 — Fetch Movie Details + Images +``` +GET https://api.themoviedb.org/3/movie/{tmdb_id}?api_key={key}&append_to_response=images +``` + +Key image fields: +```json +{ + "poster_path": "/abc123.jpg", + "backdrop_path": "/xyz789.jpg", + "images": { + "posters": [{ "file_path": "...", "width": 2000, "height": 3000 }], + "backdrops": [{ "file_path": "...", "width": 3840, "height": 2160 }], + "logos": [{ "file_path": "...", "width": 1000, "height": 200 }] + } +} +``` + +### Image URL Construction +```javascript +// TMDB image CDN — choose size: w500, w780, w1280, original +function tmdbImageUrl(path, size = 'original') { + return `https://image.tmdb.org/t/p/${size}${path}`; +} +``` + +For case wraps, `original` size is best. The CDN serves CORS-friendly URLs. + +--- + +## Key Components + +### 1. API Key Storage + +```javascript +// Add to state (alongside existing omdbApiKey): +state.tmdbApiKey = localStorage.getItem('casewrap-tmdb-api-key') || ''; +``` + +Add a TMDB API key field to the settings panel next to the existing OMDB key field. + +### 2. Core Fetch Functions + +```javascript +async function tmdbFindByImdbId(imdbId, apiKey) { + const res = await fetch( + `https://api.themoviedb.org/3/find/${imdbId}?api_key=${apiKey}&external_source=imdb_id` + ); + const data = await res.json(); + return data.movie_results?.[0] ?? null; +} + +async function tmdbFetchImages(tmdbId, apiKey) { + const res = await fetch( + `https://api.themoviedb.org/3/movie/${tmdbId}?api_key=${apiKey}&append_to_response=images&include_image_language=en,null` + ); + return await res.json(); +} +``` + +### 3. Auto-Fill Function + +Called when the user clicks "Auto-fill from TMDB" or when `imdbId` changes and an API +key is present: + +```javascript +async function autoFillFromTMDB() { + const imdbId = state.metadata.imdbId?.trim(); + if (!imdbId || !state.tmdbApiKey) return; + + showStatus('Fetching artwork from TMDB…'); + + const match = await tmdbFindByImdbId(imdbId, state.tmdbApiKey); + if (!match) { showStatus('No TMDB match found for this IMDb ID.'); return; } + + const details = await tmdbFetchImages(match.id, state.tmdbApiKey); + + // Best poster → front cover + const poster = bestImage(details.images.posters, 'portrait'); + if (poster) state.slots.front.src = tmdbImageUrl(poster.file_path); + + // Best backdrop → back cover + const backdrop = bestImage(details.images.backdrops, 'landscape'); + if (backdrop) state.slots.back.src = tmdbImageUrl(backdrop.file_path); + + // Metadata + state.metadata.title = details.title ?? state.metadata.title; + + renderAll(); + showStatus('Artwork loaded from TMDB.'); +} +``` + +### 4. Image Picker (Best Selection Logic) + +TMDB returns multiple images — pick the highest-resolution English-language one: + +```javascript +function bestImage(images, orientation) { + if (!images?.length) return null; + const aspect = orientation === 'portrait' ? (h, w) => h > w : (h, w) => w > h; + return images + .filter(img => aspect(img.height, img.width)) + .sort((a, b) => (b.width * b.height) - (a.width * a.height))[0] ?? images[0]; +} +``` + +### 5. Title Search (No IMDb ID) + +For users who don't have an IMDb ID handy, add a search-by-title flow: + +``` +GET https://api.themoviedb.org/3/search/movie?api_key={key}&query={title}&year={year} +``` + +Show a small result picker (poster thumbnail + title + year) before auto-filling. + +```javascript +async function searchTMDB(query, year) { + const params = new URLSearchParams({ api_key: state.tmdbApiKey, query }); + if (year) params.set('year', year); + const res = await fetch(`https://api.themoviedb.org/3/search/movie?${params}`); + const data = await res.json(); + return data.results?.slice(0, 5) ?? []; +} +``` + +--- + +## UI Changes + +### Settings Panel +Add TMDB API key field: +``` +TMDB API Key: [___________________________] [Get a free key ↗] +``` + +### Editor — IMDb ID Row +Add auto-fill button next to existing imdbId input: +``` +IMDb ID: [tt0145487] [⚡ Auto-fill from TMDB] +``` + +Or, if no IMDb ID is entered, show a search field: +``` +[ Search by title… ] [🔍 Search TMDB] +``` + +### Auto-Fill Preview Modal +Before applying, show a confirmation panel so users can pick which slots to fill: + +``` +┌──────────────────────────────────────────┐ +│ TMDB: Spider-Man (2002) │ +│ │ +│ [Poster 2000×3000] [Backdrop 3840×2160]│ +│ │ +│ Apply to: │ +│ ☑ Front cover (poster) │ +│ ☑ Back cover (backdrop) │ +│ ☐ Disc top (poster) │ +│ ☑ Title metadata │ +│ │ +│ [Cancel] [Apply Selected] │ +└──────────────────────────────────────────┘ +``` + +--- + +## Performance Considerations +- Two sequential TMDB API calls (~200–400ms each) — show spinner, non-blocking +- Image URLs are hotlinked from TMDB CDN — no download or storage needed +- No caching required; TMDB images are stable URLs that don't expire +- `include_image_language=en,null` filter reduces result size significantly + +--- + +## Limitations +- Requires user to register for a free TMDB API key (takes ~1 min at themoviedb.org) +- TMDB covers films and TV well but has sparse data for some older/obscure titles +- Backdrop images are widescreen (16:9); the back cover slot may need manual cropping +- No official disc art — for that, fanart.tv (separate API, free) provides disc art images +- TMDB API is rate-limited (40 requests/10 seconds) — fine for manual use, not bulk + +--- + +## Future Enhancements +- fanart.tv integration: fetch official disc art, transparent logo PNGs, and CD art +- TV series support: fetch season/episode artwork for TV show case wraps +- Auto-fill spine text from TMDB year + runtime +- Bulk fill: apply TMDB artwork to all slots in one click (front, back, disc top, disc bottom) +- Cache TMDB results in `sessionStorage` to avoid redundant API calls when user tweaks the design + +--- + +## Testing Checklist +- [ ] TMDB API key saves to localStorage and restores on reload +- [ ] `tmdbFindByImdbId` returns correct movie for a known IMDb ID (e.g. tt0145487) +- [ ] `tmdbFetchImages` returns poster and backdrop arrays +- [ ] Front cover slot is populated with best portrait image URL +- [ ] Back cover slot is populated with best landscape image URL +- [ ] Title metadata updates from TMDB response +- [ ] Confirmation modal shows correct poster and backdrop thumbnails +- [ ] Unchecked slots in the modal are not overwritten +- [ ] Title search returns results and clicking one auto-fills correctly +- [ ] Missing API key shows prompt to add one rather than a silent failure +- [ ] Invalid IMDb ID shows "no match found" message +- [ ] TMDB image URLs load correctly in the editor canvas diff --git a/docs/TRUST_AND_BLOCK_FEATURE.md b/docs/TRUST_AND_BLOCK_FEATURE.md new file mode 100644 index 0000000..3a29b73 --- /dev/null +++ b/docs/TRUST_AND_BLOCK_FEATURE.md @@ -0,0 +1,247 @@ +# Trust & Block Feature Plan + +## Overview +Add web-of-trust filtering and user block lists to the community template feed so users can +hide content from bad actors and prioritize templates from trusted sources. Because the app +already uses Nostr for identity and publishing, this feature can be built entirely on existing +Nostr protocol standards (NIPs) with no backend required. + +## Complexity +**Moderate–High** — Estimated 4–8 hours to implement (phased; Phase 1 alone is ~2 hours) + +--- + +## Background: Nostr Primitives Available + +| NIP | Kind | Purpose | +|-----|------|---------| +| NIP-02 | 3 | Contact / follow list — the source of truth for the social graph | +| NIP-51 | 10000 | Mute list — pubkeys, event IDs, keywords to suppress | +| NIP-56 | 1984 | Content report — flag a pubkey or event as spam/NSFW/illegal | + +These events are already signed with the user's key (via NIP-07 extension) and stored on +Nostr relays, so the block/mute list automatically syncs across any Nostr-compatible client +the user signs in to. + +--- + +## Phased Plan + +### Phase 1 — Local Block List (no login required) + +The simplest layer: any visitor can block a pubkey locally without signing in. + +#### Storage +- `localStorage` key: `casewrap-blocked-pubkeys` → JSON array of hex pubkeys +- Survives page refresh; cleared only if the user explicitly unblocks or clears browser data + +#### UI +- Add a "Block this author" option to each template card menu (⋮ or right-click) +- Blocked templates are hidden from the feed immediately (no reload needed) +- Settings panel shows blocked pubkey list with per-entry "Unblock" buttons +- Blocked count shown as a subtle badge: "3 authors hidden" + +#### Core Functions +```javascript +function blockPubkey(pubkey) + // Add to localStorage block list, re-filter visible templates + +function unblockPubkey(pubkey) + // Remove from localStorage block list, re-filter + +function isBlocked(pubkey) + // Returns true if pubkey is in the local block list + +function applyBlockFilter(templates) + // Filter template array, removing any whose author is blocked +``` + +--- + +### Phase 2 — Nostr Mute List Sync (requires NIP-07 login) + +Upgrade the local block list to a Nostr-native mute list (NIP-51 kind 10000) so it persists +across devices and syncs with other Nostr apps (Damus, Primal, Snort, etc.). + +#### On Login +1. Fetch the user's existing kind 10000 mute list from relays +2. Merge with any locally blocked pubkeys (union, no duplicates) +3. Save merged list back to the relay (signed via `window.nostr.signEvent`) + +#### On Block +1. Add pubkey to the in-memory mute list +2. Publish updated kind 10000 event to relays +3. Update `localStorage` as a local cache + +#### Event Schema +```json +{ + "kind": 10000, + "tags": [ + ["p", ""], + ["p", ""] + ], + "content": "" +} +``` + +#### Core Functions +```javascript +async function fetchMuteList(pubkey) + // Query relays for kind 10000 authored by pubkey, return p-tag array + +async function publishMuteList(mutedPubkeys) + // Sign and publish updated kind 10000 event + +async function syncMuteListOnLogin() + // Merge relay mute list with localStorage block list on sign-in +``` + +--- + +### Phase 3 — Web-of-Trust Feed Filter (requires NIP-07 login) + +Only show templates from pubkeys within your social graph — the most effective spam defense. + +#### How It Works +1. Fetch the signed-in user's follow list (NIP-02 kind 3) → set of followed pubkeys ("hop 1") +2. Optionally fetch follows-of-follows → "hop 2" (wider but noisier) +3. In the community feed, templates are ranked/filtered by trust tier: + +| Tier | Source | Display | +|------|--------|---------| +| Trusted | Pubkey is in your follow list (hop 1) | Full display, shown first | +| Extended | Pubkey is in follows-of-follows (hop 2) | Shown normally | +| Unknown | No social graph overlap | Shown with a "low trust" badge | +| Blocked | In mute list | Hidden entirely | + +#### Trust Score (optional enhancement) +For each template author, count how many of your follows also follow them. +This score (0–N) can be shown as a small indicator on the template card. + +```javascript +async function fetchFollowList(pubkey) + // Query relays for kind 3, return array of followed pubkeys + +function buildTrustSet(myFollows, followsOfFollows) + // Returns { hop1: Set, hop2: Set } + +function getTrustTier(authorPubkey, trustSet) + // Returns 'trusted' | 'extended' | 'unknown' | 'blocked' + +function sortByTrust(templates, trustSet) + // Put hop-1 templates first, then hop-2, then unknown +``` + +#### Caching +- Follow lists are large — cache in `sessionStorage` keyed by pubkey +- Re-fetch at most once per session to avoid relay hammering +- Show a loading indicator while the graph is being fetched + +#### UI Controls +- Toggle in settings: "Show only trusted authors" (default: off — non-breaking) +- Toggle: "Show low-trust badge on unknown authors" (default: on when logged in) +- Trust tier shown as a subtle icon on template cards (★ trusted, ~ extended, ? unknown) + +--- + +### Phase 4 — Content Reporting (NIP-56) + +Allow users to flag templates as spam, NSFW, or abusive so relay operators can act. + +#### Report Flow +1. User clicks "Report" in the template card menu +2. Modal asks for report type: `spam` | `nudity` | `illegal` | `impersonation` +3. App publishes a NIP-56 kind 1984 event referencing the template event ID and author pubkey + +#### Event Schema +```json +{ + "kind": 1984, + "tags": [ + ["e", "", "spam"], + ["p", ""] + ], + "content": "Optional description" +} +``` + +#### Notes +- Reports are published to relays but have no local effect by themselves +- Optionally: auto-hide locally after reporting (user already chose to flag it) +- Reports help relay operators and moderation tools (like Spam Scout) build block lists + +```javascript +async function reportTemplate(eventId, authorPubkey, reason, description) + // Sign and publish NIP-56 kind 1984 event +``` + +--- + +## Integration Points + +All four phases hook into the community feed render loop. The minimal change is: + +```javascript +// In the function that renders community template cards: +function renderCommunityFeed(templates) { + const filtered = applyBlockFilter(templates); // Phase 1 + const sorted = sortByTrust(filtered, trustSet); // Phase 3 + filtered.forEach(t => renderCard(t, getTrustTier(t.author, trustSet))); +} +``` + +Block/report actions live in the template card's action menu alongside existing +"Copy link" / "Import" options. + +--- + +## Performance Considerations +- Follow list fetches are async and cached — they don't block the initial feed render +- Local block filter runs synchronously on the already-fetched template array (~1ms) +- Follows-of-follows (hop 2) can be 10,000+ pubkeys — use a `Set` for O(1) lookups +- Only fetch hop-2 if the user enables the extended trust option (off by default) +- Relay queries use `REQ` filters with `kinds: [3]` — single-use subscriptions, close after `EOSE` + +--- + +## Limitations +- Web-of-trust only works when the user is signed in via NIP-07 +- Users with small follow lists get little benefit from WoT filtering +- Relay queries for follows-of-follows can be slow on large social graphs (~5–15s) +- NIP-56 reports are advisory only — relay operators decide whether to act +- No cross-relay mute list deduplication (if user uses multiple relay sets) + +--- + +## Future Enhancements +- Subscribe to community-maintained block lists (NIP-51 kind 30000 "follow sets" used as shared deny lists) +- Import block list from Nostr clients (Damus, Primal) via relay sync +- NSFW blur overlay instead of full hide (user can click to reveal) +- Relay-level WoT filtering: connect to WoT-curated relays (e.g. relay.nostr.band WoT endpoint) for pre-filtered feeds +- Keyword/hashtag muting (NIP-51 supports `["word", "badterm"]` tags in kind 10000) + +--- + +## Implementation Order (recommended) + +1. **Phase 1** — Local block list. Zero dependencies, works for logged-out users, shippable alone. +2. **Phase 3** — WoT feed sort/filter. High value for logged-in users; Phase 2 not required first. +3. **Phase 2** — Nostr mute list sync. Upgrades Phase 1 to be cross-device. +4. **Phase 4** — Reporting. Lowest urgency; depends on relay operator buy-in. + +--- + +## Testing Checklist +- [ ] Blocking a pubkey hides all their templates immediately +- [ ] Unblocking restores their templates +- [ ] Block list persists across page refresh (localStorage) +- [ ] Blocked pubkeys are excluded from all feed views (not just main) +- [ ] Mute list sync fetches existing kind 10000 on login (Phase 2) +- [ ] Publishing updated mute list signs correctly via NIP-07 (Phase 2) +- [ ] Follow list fetch correctly identifies hop-1 pubkeys (Phase 3) +- [ ] Trust tier badges display correctly on template cards (Phase 3) +- [ ] "Show only trusted" toggle hides unknown-tier templates (Phase 3) +- [ ] Report publishes a valid kind 1984 event to relays (Phase 4) +- [ ] No performance regression on feed load (follow list fetched async) +- [ ] Logged-out users see Phase 1 block UI only (no WoT controls) diff --git a/docs/ZAPS_NWC_FEATURE.md b/docs/ZAPS_NWC_FEATURE.md new file mode 100644 index 0000000..a94c79e --- /dev/null +++ b/docs/ZAPS_NWC_FEATURE.md @@ -0,0 +1,365 @@ +# Zaps via Nostr Wallet Connect (NWC) Feature Plan + +## Overview +Allow users to zap template creators (and specific templates) directly from the community +feed using Nostr Wallet Connect (NIP-47). The user connects their Lightning wallet once via +a connection string, then taps ⚡ on any template card to send sats to the creator. No +backend required — NWC runs entirely over WebSockets through the user's own wallet relay. + +## Complexity +**Moderate–High** — Estimated 6–10 hours +- NWC connection + pay_invoice: ~3 hours +- NIP-57 zap request + LNURL flow: ~3 hours +- UI (zap button, amount modal, settings): ~2 hours +- Crypto dependency setup: ~1 hour + +--- + +## Protocol Overview + +Two NIPs work together here: + +| NIP | Purpose | +|-----|---------| +| NIP-47 (NWC) | App requests a payment from the user's wallet over an encrypted relay channel | +| NIP-57 | Defines what a "zap" is — a Lightning payment linked to a Nostr event or profile | + +**The payment chain:** +``` +User clicks ⚡ + → app fetches creator's Lightning address from their cached kind 0 profile (lud16) + → app queries the creator's LNURL-pay endpoint for a callback URL + → app builds a NIP-57 zap request (kind 9734) and signs it via NIP-07 + → app POSTs to the LNURL callback → gets back a bolt11 invoice + → app sends pay_invoice to user's wallet via NWC (NIP-47) + → wallet pays, publishes kind 9735 zap receipt to relays +``` + +--- + +## Key Components + +### 1. NWC Wallet Connection (NIP-47) + +#### Connection String Format +Wallets (Alby, Mutiny, Zeus, Cashu) generate a URI the user pastes in once: +``` +nostr+walletconnect://?relay=wss%3A%2F%2Frelay.example.com&secret= +``` + +| Part | Meaning | +|------|---------| +| `walletPubkey` | Hex pubkey of the wallet service | +| `relay` | WebSocket URL of the relay the wallet listens on | +| `secret` | App-side private key (hex) — used to sign requests and decrypt responses | + +#### Storage +- Store connection string in `localStorage` key `casewrap-nwc-uri` +- **Never log or expose the secret** — treat it like a password +- Derive the app pubkey from the secret at runtime for signing + +#### Core parse function +```javascript +function parseNWCUri(uri) { + // Returns { walletPubkey, relay, secret } or null if invalid + const url = new URL(uri.replace('nostr+walletconnect://', 'https://')); + return { + walletPubkey: url.hostname, + relay: url.searchParams.get('relay'), + secret: url.searchParams.get('secret'), + }; +} +``` + +--- + +### 2. NWC pay_invoice Request (NIP-47) + +NWC uses encrypted Nostr events to pass commands to the wallet. The app encrypts a JSON +command as kind 23194, publishes it to the wallet relay, and waits for a kind 23195 +response from the wallet. + +#### Encryption +NWC uses **NIP-04 encryption** (ECDH shared secret → AES-128-CBC). +- Shared secret = ECDH(appSecret, walletPubkey).x +- This is the same cipher used elsewhere in Nostr but keyed with the NWC secret, NOT the + user's NIP-07 key — so `window.nostr.nip04` cannot be used here. +- Requires a small crypto library (see Crypto Dependencies section). + +#### Request event (kind 23194) +```json +{ + "kind": 23194, + "pubkey": "", + "tags": [["p", ""]], + "content": "", + "created_at": , + "id": "", + "sig": "" +} +``` + +Decrypted content: +```json +{ + "method": "pay_invoice", + "params": { "invoice": "lnbc..." } +} +``` + +#### Response event (kind 23195) +Wallet sends this back, encrypted to appPubkey: +```json +{ + "result_type": "pay_invoice", + "result": { "preimage": "" } +} +// OR on failure: +{ + "result_type": "pay_invoice", + "error": { "code": "INSUFFICIENT_BALANCE", "message": "..." } +} +``` + +#### Core send function +```javascript +async function sendNWCPayment(bolt11) { + // 1. Parse stored NWC URI + // 2. Encrypt pay_invoice request with NIP-04 using appSecret + // 3. Build, sign, and publish kind 23194 to wallet relay + // 4. Open subscription for kind 23195 from walletPubkey, tagged with request event ID + // 5. Wait up to 30s for response, decrypt, return result or throw error +} +``` + +Note: The app signs kind 23194 events with the NWC `secret` key — NOT the user's NIP-07 key. +All NWC signing is done with the noble-secp256k1 library directly. + +--- + +### 3. NIP-57 Zap Request + LNURL Flow + +Before paying, the app must obtain a bolt11 invoice from the creator's Lightning address. +This follows the LNURL-pay + NIP-57 protocol: + +#### Step A — Resolve the creator's Lightning address (lud16) +The creator's `lud16` field in their kind 0 profile (e.g. `alice@getalby.com`) maps to a URL: +```javascript +function lud16ToUrl(lud16) { + const [name, domain] = lud16.split('@'); + return `https://${domain}/.well-known/lnurlp/${name}`; +} +``` +If the creator only has `lud06` (bech32 LNURL), decode it: +```javascript +function decodeLNURL(lud06) { + // bech32-decode lud06 → UTF-8 URL string +} +``` + +#### Step B — Fetch LNURL-pay metadata +GET the resolved URL. Check `allowsNostr: true` to confirm zap support: +```json +{ + "callback": "https://getalby.com/lnurlp/alice/callback", + "minSendable": 1000, + "maxSendable": 100000000000, + "allowsNostr": true, + "nostrPubkey": "" +} +``` + +#### Step C — Build and sign the zap request (kind 9734) +This event is attached to the payment so the recipient sees who zapped them and what for: +```json +{ + "kind": 9734, + "pubkey": "", + "tags": [ + ["relays", "wss://relay.damus.io", "wss://nos.lol"], + ["amount", "21000"], + ["lnurl", ""], + ["p", ""], + ["e", ""] + ], + "content": "Love this design! ⚡" +} +``` +Sign via `window.nostr.signEvent()` (NIP-07) — this DOES use the user's NIP-07 key. + +#### Step D — Fetch the invoice +``` +GET {callback}?amount=21000&nostr={encodeURIComponent(JSON.stringify(signedZapRequest))}&lnurl={lnurl} +``` +Response: `{ "pr": "lnbc21n1p..." }` + +#### Step E — Pay via NWC +Pass the `pr` bolt11 string to `sendNWCPayment()`. + +```javascript +async function zapCreator(recipientPubkey, eventId, amountSats, message) { + const profile = state.community.profiles[recipientPubkey]; + if (!profile?.lud16 && !profile?.lud06) throw new Error('Creator has no Lightning address'); + const lnurlInfo = await fetchLNURLPayInfo(profile.lud16 ?? profile.lud06); + if (!lnurlInfo.allowsNostr) throw new Error('Creator wallet does not support zaps'); + const zapRequest = await buildAndSignZapRequest(recipientPubkey, eventId, amountSats * 1000, message); + const invoice = await fetchZapInvoice(lnurlInfo, zapRequest, amountSats * 1000); + return await sendNWCPayment(invoice); +} +``` + +--- + +### 4. Crypto Dependencies + +NWC requires: +- **Schnorr signing** (sign kind 23194 events with appSecret) +- **NIP-04 encryption** (ECDH + AES-128-CBC) +- **Event ID hashing** (SHA-256 of canonical serialization) + +The app has no build tools, so load from CDN via ES module imports or ` +``` + +These two packages together are ~20 KB minified and have no further dependencies. +They are widely used across the Nostr ecosystem. + +For bech32 decoding of `lud06` LNURL values, use: +```html + +``` + +--- + +### 5. UI + +#### Settings Panel — Wallet Connection +Add a "Lightning Wallet" section to the existing settings/profile panel: + +``` +┌─────────────────────────────────────────┐ +│ ⚡ Lightning Wallet │ +│ │ +│ [Paste NWC connection string… ] [Connect] │ +│ │ +│ Connected: Alby (alice@getalby.com) │ +│ Balance: 12,450 sats [Disconnect] │ +└─────────────────────────────────────────┘ +``` + +- "Connect" parses and stores the NWC URI; optionally fires a `get_balance` request +- Balance display is optional — costs one extra NWC round-trip; hide if user prefers +- "Disconnect" clears localStorage key and hides zap buttons site-wide + +#### Zap Button on Template Cards +Add to the `reactionRow` in both `renderCommunity()` feed cards and `renderCommunityDetail()`: + +``` +[👍 12] [💬 3] [🔁 5] [⚡ Zap] +``` + +- Show ⚡ button only when the creator's profile has `lud16` or `lud06` +- If NWC is not connected, clicking ⚡ opens the settings panel to prompt setup +- Show "Zap creator" tooltip on hover + +#### Zap Amount Modal +Opens on ⚡ click: + +``` +┌──────────────────────────────┐ +│ ⚡ Zap alice@getalby.com │ +│ │ +│ [ 21 ] [ 100 ] [ 500 ] │ +│ [1000 ] [ Custom… ] │ +│ │ +│ Message (optional): │ +│ [________________________] │ +│ │ +│ [Cancel] [Send Zap] │ +└──────────────────────────────┘ +``` + +- Preset amounts in sats (21 is the canonical "nice" zap amount in Nostr culture) +- Custom amount field with min/max validation against LNURL `minSendable`/`maxSendable` +- Message populates the `content` field of the kind 9734 zap request +- "Send Zap" button shows spinner while in flight, then ✅ success or ❌ error message + +--- + +## State Additions + +```javascript +// Add to state object: +nwc: { + uri: '', // raw connection string (from localStorage on load) + connected: false, // true after successful parse + optional balance check + balance: null, // sats, or null if not fetched / not supported +} +``` + +--- + +## Performance Considerations +- LNURL fetch and NWC round-trip each take ~1–3 seconds — always show a loading state +- NWC WebSocket opens on-demand per payment and closes immediately after — no persistent connection +- Creator profile `lud16` is already in `state.community.profiles` — no extra relay query needed +- `get_balance` is optional; skip it to save a round-trip on wallet connect +- bech32/secp256k1 libs load once on page init; zap operations after that are <5ms local work + +--- + +## Limitations +- Requires creator to have a Lightning address (`lud16` or `lud06`) in their Nostr profile +- Requires creator's wallet/service to support NIP-57 (`allowsNostr: true`) +- NWC secret gives partial spend authority — user should use a budget-limited NWC URI (Alby and Mutiny both support spending limits on NWC connections) +- `get_balance` and multi-pay are optional NWC methods — not all wallets implement them +- Zap receipts (kind 9735) are published by the recipient's wallet, not the sender — the app can listen for them but cannot guarantee delivery +- No offline support — both LNURL fetch and NWC require live network + +--- + +## Security Notes +- Store the NWC URI in `localStorage` only — never send it to any server +- Never log `uri` or `secret` to console +- The NWC `secret` grants payment authority up to the wallet's configured spending limit — document this clearly in the UI so users understand what they're connecting +- LNURL fetches go to third-party servers (the creator's wallet provider) — no PII is sent beyond the zap request event (which is a public Nostr event anyway) + +--- + +## Future Enhancements +- Zap splits: send a percentage to both template creator and original asset uploader (NIP-57 supports multiple `p` tags) +- Zap leaderboard on template detail: show top zappers (parse kind 9735 receipts) +- Recurring/scheduled zaps for favorite creators +- Show total sats zapped on each template card (sum kind 9735 receipts) +- Cashu/eCash zaps for sub-sat amounts (separate NIP, future consideration) +- WebLN fallback: if user has a WebLN browser extension instead of NWC, use `window.webln.sendPayment()` as a simpler alternative + +--- + +## Testing Checklist +- [ ] NWC URI parses correctly (walletPubkey, relay, secret extracted) +- [ ] Invalid / malformed NWC URI shows clear error in settings +- [ ] `get_balance` request succeeds and displays balance (if wallet supports it) +- [ ] Disconnect clears stored URI and hides zap buttons +- [ ] ⚡ button only shows on cards where creator has lud16/lud06 +- [ ] ⚡ click on card without NWC connected opens wallet setup flow +- [ ] LNURL fetch resolves correctly for standard lud16 addresses +- [ ] LNURL fetch resolves correctly for bech32 lud06 addresses +- [ ] `allowsNostr: false` shows friendly "creator wallet doesn't support zaps" message +- [ ] Zap request (kind 9734) is signed via NIP-07 and includes correct tags +- [ ] Invoice is fetched from LNURL callback with correct query params +- [ ] NWC pay_invoice request is encrypted and published to wallet relay +- [ ] Successful payment shows ✅ confirmation in modal +- [ ] NWC error codes (INSUFFICIENT_BALANCE, etc.) show readable messages +- [ ] Amount presets respect LNURL minSendable/maxSendable limits +- [ ] Custom amount validates against min/max before sending +- [ ] NWC connection secret is never logged to console +- [ ] Page reload restores NWC connected state from localStorage diff --git a/docs/ZAP_GATED_TEMPLATES_FEATURE.md b/docs/ZAP_GATED_TEMPLATES_FEATURE.md new file mode 100644 index 0000000..5a42236 --- /dev/null +++ b/docs/ZAP_GATED_TEMPLATES_FEATURE.md @@ -0,0 +1,254 @@ +# Zap-Gated Premium Templates Feature Plan + +## Overview +Creators can mark a template as "premium" and set a minimum zap amount. Other users +must send a Lightning zap (via NWC) before the import button unlocks. This creates a +voluntary monetization layer for high-effort designs, with no payment processor, no +platform fee, and no backend — just Nostr zap receipts (kind 9735) checked on the client. + +## Complexity +**Moderate** — Estimated 4–6 hours +Depends on ZAPS_NWC_FEATURE.md being implemented first. The zap payment flow reuses +that infrastructure; this feature adds the gating logic on top. + +## Prerequisite +**ZAPS_NWC_FEATURE.md must be implemented first.** Zap-gated templates require the +full NWC + NIP-57 zap flow to already work. + +--- + +## How It Works + +1. Creator publishes a template tagged as premium with a minimum zap amount +2. In the community feed, premium templates show a ⚡ lock icon instead of Import +3. User clicks the lock → zap modal opens pre-filled with the minimum amount +4. After payment, app listens for a kind 9735 zap receipt on relays +5. Receipt is verified (correct amount, correct template event, correct sender) +6. Import button unlocks for this session; receipt cached in `localStorage` + +--- + +## Nostr Event Tags for Premium Templates + +Creator adds two extra tags when publishing a kind 30078 template event: + +```json +{ + "tags": [ + ["zap-gate", "true"], + ["zap-min", "100"] + ] +} +``` + +| Tag | Value | Meaning | +|-----|-------|---------| +| `zap-gate` | `"true"` | This template requires a zap to unlock | +| `zap-min` | `"100"` | Minimum amount in sats | + +These are custom tags — no NIP defines them, but Nostr's open tagging allows this. +Any client that doesn't understand them will simply ignore them. + +--- + +## Key Components + +### 1. Premium Tag Detection + +```javascript +function isZapGated(event) { + return event.tags?.some(t => t[0] === 'zap-gate' && t[1] === 'true'); +} + +function getZapMin(event) { + const tag = event.tags?.find(t => t[0] === 'zap-min'); + return tag ? parseInt(tag[1], 10) : 21; +} +``` + +### 2. Local Unlock Cache + +After a successful zap, cache the unlock so the user doesn't need to re-pay on every +page load: + +```javascript +const UNLOCK_CACHE_KEY = 'casewrap-unlocked-templates'; + +function markUnlocked(eventId) { + const cache = JSON.parse(localStorage.getItem(UNLOCK_CACHE_KEY) || '{}'); + cache[eventId] = { unlockedAt: Date.now() }; + localStorage.setItem(UNLOCK_CACHE_KEY, JSON.stringify(cache)); +} + +function isUnlockedLocally(eventId) { + const cache = JSON.parse(localStorage.getItem(UNLOCK_CACHE_KEY) || '{}'); + return !!cache[eventId]; +} +``` + +### 3. Zap Receipt Verification (kind 9735) + +After payment, subscribe to kind 9735 events on relays and verify the receipt: + +```javascript +async function waitForZapReceipt(templateEventId, senderPubkey, minAmountSats, timeoutMs = 30000) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('Zap receipt timeout')), timeoutMs); + + const sub = subscribeRelays(relays, { + kinds: [9735], + '#e': [templateEventId], + }, (receiptEvent) => { + const bolt11Tag = receiptEvent.tags.find(t => t[0] === 'bolt11'); + const pTag = receiptEvent.tags.find(t => t[0] === 'P'); // sender pubkey + + // Verify sender matches (P tag in 9735 is the zapper's pubkey) + if (pTag?.[1] !== senderPubkey) return; + + // Decode amount from bolt11 (or from 'amount' tag if present) + const amountSats = decodeBolt11Amount(bolt11Tag?.[1]) / 1000; + if (amountSats < minAmountSats) return; + + clearTimeout(timer); + sub.close(); + resolve(receiptEvent); + }); + }); +} +``` + +Note: `decodeBolt11Amount` needs to parse the bolt11 invoice to extract the amount. +A minimal bolt11 parser (~2 KB) is available from the `light-bolt11-decoder` package +or can be inlined for just the amount field. + +### 4. Full Unlock Flow + +```javascript +async function unlockPremiumTemplate(event) { + const minSats = getZapMin(event); + + // Show zap modal pre-filled with min amount, locked to minimum + const zapResult = await showZapModal({ + recipientPubkey: event.pubkey, + eventId: event.id, + minAmount: minSats, + message: `Unlocking: ${templateTitle(event)}`, + }); + + if (!zapResult) return; // user cancelled + + showStatus('Waiting for zap receipt…'); + + try { + await waitForZapReceipt(event.id, state.community.myPubkey, minSats); + markUnlocked(event.id); + showStatus('Template unlocked! ✅'); + renderCommunityDetail(event); // re-render to show Import button + } catch (err) { + showStatus('Receipt not received — try again or contact the creator.'); + } +} +``` + +### 5. Creator UI — Marking a Template as Premium + +Add a "Premium template" toggle to the Publish panel: + +``` +┌──────────────────────────────────────┐ +│ Publish Template │ +│ │ +│ ☐ Premium template (zap to unlock) │ +│ Minimum: [100] sats │ +│ │ +│ [Publish] │ +└──────────────────────────────────────┘ +``` + +When checked, the `zap-gate` and `zap-min` tags are added to the published event. + +--- + +## UI Changes + +### Feed Card — Premium Lock Icon +Replace "Import" button with a locked state for gated templates: + +``` +[⚡ Unlock (100 sats)] ← not yet paid +[Import] ← after unlock +``` + +Show a small ⭐ or ⚡ badge on the card thumbnail to signal premium status. + +### Template Detail Panel +- Show "Premium template — zap to unlock" banner for gated templates +- Show minimum amount prominently: "⚡ 100 sats to unlock" +- After unlock: green "Unlocked ✅" badge replaces the banner + +### Own Templates +Creator sees their own premium templates as unlocked by default (no self-payment needed): +```javascript +function isUnlocked(event) { + return !isZapGated(event) + || isUnlockedLocally(event.id) + || event.pubkey === state.community.myPubkey; +} +``` + +--- + +## Edge Cases + +| Scenario | Handling | +|----------|----------| +| Creator's wallet doesn't publish kind 9735 receipt | Timeout after 30s; show "receipt not detected" message; user can retry | +| User zaps less than minimum | Amount check in receipt verification rejects it | +| User clears localStorage | Must re-pay; no cross-device unlock sync | +| Creator updates (replaces) the template event | Old unlock cache entry becomes stale; detect via changed event ID | + +--- + +## Performance Considerations +- `isUnlockedLocally()` is a synchronous localStorage read — runs on every card render +- Kind 9735 subscription is opened only after payment — no background polling +- Receipt subscription times out and closes after 30s regardless of outcome + +--- + +## Limitations +- Unlock is per-device / per-browser (localStorage). No cross-device sync in Phase 1. +- Receipt verification is client-side trust: a determined user could fake a cache entry + in localStorage. This is appropriate for a community platform — not for high-value goods. +- Creators must have a functional Lightning address (lud16) for zaps to work +- If the creator's wallet doesn't publish kind 9735 receipts, the unlock flow times out + (some wallets are inconsistent about this) + +--- + +## Future Enhancements +- Cross-device unlock sync: store verified receipt event ID in user's NIP-51 kind 10001 + (pin list) so it syncs across clients +- Time-limited access: `zap-expires` tag unlocks template for N days only +- Subscription model: zap a creator's profile once to unlock all their premium templates +- Revenue stats for creators: count kind 9735 receipts referencing their templates +- Escrow / refund: out of scope (no smart contracts on Lightning without additional infrastructure) + +--- + +## Testing Checklist +- [ ] `isZapGated()` correctly identifies events with `zap-gate: true` tag +- [ ] `getZapMin()` returns correct sat amount from `zap-min` tag +- [ ] Premium feed cards show "⚡ Unlock" button instead of "Import" +- [ ] Zap modal opens pre-filled with minimum amount +- [ ] Amount field rejects values below the minimum +- [ ] After successful zap, app subscribes to kind 9735 on relays +- [ ] Valid receipt with correct sender and amount triggers unlock +- [ ] Receipt with wrong sender is ignored +- [ ] Receipt with insufficient amount is ignored +- [ ] `markUnlocked()` persists to localStorage and survives page reload +- [ ] Unlocked template shows Import button on reload (from cache) +- [ ] Creator's own premium templates are pre-unlocked (no self-payment) +- [ ] 30-second timeout shows helpful error message +- [ ] Publish panel "Premium template" toggle adds correct tags to event +- [ ] Non-premium templates are unaffected by this feature diff --git a/src/favicon.ico b/src/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..e781fa9aaa708b7b8451c0ceb8a9f363c8180445 GIT binary patch literal 15406 zcmeHOeQ;LQnZH*I=FKOlK!qfvvq}+l#QIUN=yX43|J_-)+g)~T?T*rQtWwYluF4FZ z?Jm38*0r5>Rz@YFc4;#Mf&@YXfpN;Szu$B2x$k-Jy>H$( z;QrOU!^yetz31NN_dL(^_s64N{ytH&#d3l+Xp|CD5Gj&->sgxF{mZ4cW z+6512#b16Zm8u*!uDtU{k1rnX>)b1ShxVD$xlcYtY2R8e4}SMM!{x=rooq{aDiJk* zS5K|%YHMviH`v!HX9quZN}ryeKGq{$%^yg^%9?W(lO}c*hVSsoDVNWl8SIs_C)mDE z&YHUS6*HorRn;0dBTmJa4uvyu*|G{&I}`VD22*Q2VN! z88{;SyS7XF`dZoX$|~5i7&b1KKG?^7cG0|9vTBJhs~-1c<-#%Y+}&f%JMi9N*r9#H zTh8-kfqbL)WB>AyEV#p$-z@QD)}7x#`BQoJ?n~tE*<+#0gL3+_9%vw1<(8=ZU|_;HQ7j1p3o%8Tc2BQTb|v5P$Hz>JRzg z3T<=z;2zka7q(h0i|&~X9JJp>GJo!E#s{$PFz~k%zm;EFvW%P?>^&$)ckhIc4#1B` z#QKkzH>_Q)To@kch5ei1zcsQ@{a3gQ3L z`I{Ml;yZ)GNYTH6Kzk>sDZ2rsqpG5!d-KM%pYQv)N%o?!9_f5{bE7o;dZoPb(*em`|cef@7)+|W0Mdg0tGJ-K9s|{5C5gCH~lRfRyRsXKEy;oX~!-E8Qoo&5ZxM<@bl%rFZ*Q9XkfvTFv-QKg0LCE0bmOhBtt(CG0QO7uNo57^7GmKWGf4E}{GkzNPJM z^``7KKOQ5EKlilM|HP9w?@P&Zcb3Y`X_LkEq3w$&yZ0LoLumdpcdFCR;pmu;STt0;I--_DbjA68YDBtPJ#6!REKK)Ib zTfJBR>&0@#^a?FJZ~PfBEiRVV=M~GB%6xr>&(fx$e6_#nhm$czA@)T4pZ2%4g)9Gk z$fy3)`%92r_-um~;<%yOmpAWC%Xc~L%5M+&H`&Aim2cZ-_BZC8uKvF2Z{<^FDv(bs zH+9w;*XFdP5qe_*J4K8(W=|HsFgr2e-p=zpv! zyJ<;by#FCq*>O8+|6K8hapN>{?qS4wJFc02c#GV49pcZw1@Y$>p49&oy!(C-e^7Umx51vB5weGX6So$i!b?`=!QT#6A;$(|TKf=8)fE^Bu-_%2D}S z>g8x_iwu2wNOKz7ABll6mYl5$a!{VT@y530oQdx`*?qvt1I+ki@&Lbs<1q6*j)C?a zwExhj9nuf^hnpH@2y|MwO?f4VRgO~%DgSkM5>9Zf2ILvu9 z<~seKw5T0Eb9`Yr;mQin;Vb%m__M?MTnFZa(QyxPPxTIDvkLRCI^^24v2qs}PQea6 z@CErmPG-u9_m}jT*CvPRHbHi+>|_45(y_abJk;cXn)E`x&NocnT^ag;vh4fl$FuND zF8OT7{M*=^<4ur%GVh7x;g0_1)L@F|-(JlOOR{Uhq>OQK<|<{@H^)~ns-M9r+=eWzz5Vp->k*_ z>%$DbTkhNW7RQvj70+sJWpsWLa}L#+T+5hus02z$I?TUg2u%p0}OFQD7fpRlTVPWCzS5Ke5{Kl_de;P&3yWDWS%)EAn zTsQ41x$defPfwd#wVdy^a)6&QDf>bXzNZHEX+qspR8-VF|Gw|_yt8i29~$3XC+Az< z-?UcNuX<5l`}tGyKTrPWAO7jD|EecdSlCP%L8(Da*~v%5L6M)~Gq_H>H(6f;zpm2{ zVBIxi?9lumK5oa_1Y?X{n{QkHmh5_cwLI{Tb0t-HiQ8YO2W6kn5prhAlqq-3oOxZV z<;mA?{hIvh>7R)1Ka7Og84Huf=rhP+j_u!tF|tnfV~^uMe)yo=eC;*h68LoGRWjqs zKWUvZVZvPiPaWU~#Eoo5j%!H2DQAfN#pnNf$ns=M-FoTR+akk|AG7U9`b6&qa{;dB z97jI5bK_gG<`+-PtN-;gEl(|ec<37F037r~4GJ-3sF0|Rxo9|p_(8i9`vqZ*VvqcG zxny%v>Oo%sG7ELc#ic~fe0%PVQepSd0PsX8~mgS{%` z)tF1Jd*)Yi$6x%p>JaBrVq7wufvzrRh<)IQ+gIb5U2N`oHeGn#kK(*|=l(tOM-a!~D^bM|6DSKBil^FU@;w6Y3DW zmvc2@rViZi?EiSXu8|+yg!Oswr}3p1xmk0h~u5g@Yu3>SbX6`qHIC+{m0qw@I z$;S7b{W0JMZA*NYUw)a;hegFGtewJpY{Rxu+fWDkfqmKb3C?kee}Cl>IW?hatXhB?c`?AH(+~$17DPj*f1MG|b8Q&oMZGrBib1z*Bz760v z3HRJuAoJ!F$pY-HKMEa`OZY~?PTed#9 zQ2a5@e9v%(*!c|z*Pw}==e+jtw|rTNZ#t+$%M)Ye*?D7C2g@b)ha@hI7Qfqnqp`vL z?Z%lXXNa4&r`?I2KDo4%Iv773e9>1O=!Y1Wz(Y4Kh4}N~&nWoM_{zA>xX9STK1#eU zXV?es|7L-t%Fzae=e$NPwLDQE+ZL5b-49CfjZT3uE*0Us9KMt9wzm4c<X{EhG@8-89_`$N`+_uoAE{{_eY zSo{oPi|xbt#LotepQSn$UzRi$!%qJf_GfI%HGVM;wP7DWJ${*QD#P*X9%uaednXr) zP{i{}f+vGdIzda~rAiTE)VWXm7I@i&TJbE+dc-)4U0 z>M|0S$R`%f-~K(1{4L0#?fVV>_e|dGjA7aEXN#ZAX$O$g4jlLd-|ZZf;eqttb2cu8 zxifQUjBnBWIm~$?oH233%r!#$yZtxM_+xFrTIUoWY{ouigX$37tBmHN4w_(poj-yy3> zGu%hzx&-t8K_~xr=Z{>g=6bs`e_V?5rPJ;F9eI?QzngC(({rL7_@);Hb8LQdY;;J^ zE0s%_PcavE=k4yf=E)JxguX_sRjl-Q)~8)=yx5 zJ;L=Xw=XLvp##5L;Mxpz=!YN3rGW#j+NYEFl!P(cH9pVS(ErJfjPJ>^%(kJudC5ZE8{rt~#{ha$4nR_vb>*q!h^Ea_Cgc#`aBwI)L zlh`JBjxmz!Ja&!N+TGTXIBz%;E|t4b;}>xy!lh@Q zakdz!;q~w&R3YRNt~7Cs8_oXp`xjb+9}D9WuHgHD z)a~3Wz!(>PKfv6ZV_ss(_)dV|3S^dR^*6pBsH$4dJrv3a3b9jmaEyS^Hp#E%JC~v` z?+fSuv2(+LaD6FuKl54V`xCSb4;Qn09yZ8%VxDsUs!f*SLCDPbh9ss7p2D?kZ8E-< Ua`5MgGtXu@75x7Of7BNEU-Snyt^fc4 literal 0 HcmV?d00001 diff --git a/src/index.html b/src/index.html index 74fdb41..fb49234 100644 --- a/src/index.html +++ b/src/index.html @@ -4,6 +4,7 @@ CaseWrap Studio + @@ -446,6 +447,24 @@ #communityFieldset { order: 70; } aside > details { order: 90; } + .sidebarFooter { + order: 100; + margin-top: auto; + padding-top: 12px; + border-top: 1px solid var(--line); + font-size: .82rem; + } + + .sidebarFooter a { + color: var(--accent); + font-weight: 800; + text-decoration: none; + } + + .sidebarFooter a:hover { + text-decoration: underline; + } + .appModeTabs { display: grid; grid-template-columns: 1fr 1fr; @@ -722,6 +741,16 @@ pointer-events: none; } + /* Stationary image viewport. The slot clips this wrapper; rotation happens + on the image inside so the crop rectangle never rotates with the art. */ + .artOuter { + position: absolute; + inset: 0; + overflow: visible; + pointer-events: none; + z-index: 5; + } + .combinedLayer { position: absolute; z-index: 4; @@ -1627,6 +1656,19 @@ + +
@@ -1943,6 +1985,19 @@ + +

Title and category are required to save or publish.

@@ -1963,6 +2018,10 @@ + +
@@ -1987,15 +2046,21 @@
Enter back image URL
- +
+ +
Enter spine image URL
- +
+ +
Enter front image URL
- +
+ +
SPINE
@@ -2079,8 +2144,18 @@
@@ -2131,11 +2232,26 @@ +
-
+ + +
Relay settings @@ -2144,21 +2260,6 @@ -