From 5df58f7a65b02cd2ee1069cd159ba5757ae8ff8f Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 17 May 2026 20:55:04 -0400 Subject: [PATCH] Fork Off! polish, sidebar order, flash fix, soft-mod styling, fork dedup - Rename Use button to "Fork off!" with tooltip; clean customArt state on fork to prevent leftover state from previous mode bleeding into the editor. - Filter layers by current mode in renderLayers via isLayerForCurrentMode so fork loads only show layers belonging to the active template type. - Introduce WORKFLOW_ORDER + renumberWorkflowSteps to fix sidebar panel order and visible step numbering across all five modes using CSS order. - Replace template-browser wipe-and-rebuild with diffWallCards DOM diff so cards no longer flash on progressive loads. - Style Block and Report buttons as .danger; gate Block behind a confirm dialog naming the author and pointing to the Blocked authors panel. - Widen feed cards (minmax 240px) and let action row wrap so Block doesn't clip on narrow viewports. - Add designFingerprint + dedupByFingerprint to collapse identical cross-author forks to the earliest post; preserve duplicates in Mine tab. - Update Custom Art mode doc with shipped status and Phase D wishlist. Co-Authored-By: Claude Opus 4.7 --- docs/CUSTOM_ART_MODE_FEATURE.md | 83 +++++++++++ src/index.html | 235 ++++++++++++++++++++++++++------ 2 files changed, 279 insertions(+), 39 deletions(-) diff --git a/docs/CUSTOM_ART_MODE_FEATURE.md b/docs/CUSTOM_ART_MODE_FEATURE.md index 3ea61a6..aa6ed66 100644 --- a/docs/CUSTOM_ART_MODE_FEATURE.md +++ b/docs/CUSTOM_ART_MODE_FEATURE.md @@ -1,5 +1,7 @@ # Custom Art Mode +> **Status as of last update**: Phases A, B, and C are **shipped**. Custom Art is end-to-end functional — you can create, save, publish, share, browse, and export to JPEG. See [Status & remaining work](#status--remaining-work) below for the detailed breakdown and the Phase D wishlist. + ## Goals Add a fifth template mode — **Custom Art** — alongside the existing cover, disc, jewel, and disc-design modes. The aim is to let CaseWrap Studio be used for general-purpose digital art at arbitrary aspect ratios (wallpapers, banners, social posts, posters, etc.) while keeping everything else about the app — Nostr publish/share, layer system, soft-moderation, mini-preview browser — intact. @@ -276,6 +278,87 @@ Allows the same title to have a custom-art design *plus* a cover/disc/disc-desig --- +## Status & remaining work + +### Phase A — Editor foundation — ✅ **SHIPPED** (commit `a49d0b0`) + +- ✅ `state.customArt = { width, height, background, preset }` default object +- ✅ `CUSTOM_ART_PRESETS` constant (10 entries) +- ✅ `customArtCanvasInches()` pixel→inch helper +- ✅ `normalizeTemplateMode` recognizes `customart` + `payload.customArt` fallback +- ✅ `sheetW()` / `sheetH()` return canvas dimensions in inches for customart mode +- ✅ `targetBounds('canvas')` for the layer target +- ✅ `applyTargetMode` routes `canvas` target → customart mode +- ✅ `updateLayerTargetOptions` shows the `Canvas` option in customart mode +- ✅ `templateLayerTargetsForMode` returns `[{ key: 'canvas', label: 'Canvas' }]` for customart +- ✅ Body class `template-customart` toggled by `updateBodyClasses` +- ✅ CSS hides bleed/trim/fold/slot UI when in customart mode +- ✅ Layout-tab button `Custom art` in sidebar mode switcher +- ✅ `customArtFieldset` HTML with preset dropdown, width/height inputs, background picker +- ✅ Width/height range enforcement (100–8192 px) +- ✅ Preset → snap to matching preset key, or `custom` when manual +- ✅ Background color applied to `.sheet` inline; reset on mode change +- ✅ `customArtPayload()` for JSON / publish payloads +- ✅ `exportPayload()` includes `customArt` blob +- ✅ `downloadProject` saves as `custom_art_.json` +- ✅ `loadProjectFromText` has a customart branch that restores state without clobbering other modes + +### Phase B — Publish & share-link round-trip — ✅ **SHIPPED** (commit `f67467c`) + +- ✅ `templateTypeForMode('customart') → 'customart'` +- ✅ `publishCurrentTemplateToNostr` customart branch with `casewrap-customart` type tag and `customart:` d-tag (with `WxH` fallback) +- ✅ Pre-publish guard rejects an empty white canvas with no layers (so the feed doesn't fill with blank squares) +- ✅ `previewFromPayload(payload, 'customart')` returns first image-layer src as the thumbnail/broken-image-detection target +- ✅ `shareIdForRow` generates a NIP-19 `naddr` for new-style customart events (mode-prefixed d-tag); falls back to `nevent` for legacy +- ✅ `loadSharedDesign` queries by `naddr` / `nevent` and re-renders correctly for shared customart URLs +- ✅ `getTemplateTags` emits the standard `template_type`, `category`, `language`, and identifier tags (`['i', 'imdb:...']`, `['i', 'upc:...']`, etc.) for customart designs same as the other modes + +### Phase C — Browser, preview, export — ✅ **SHIPPED** (commit `f67467c`) + +- ✅ `Custom art` option added to the welcome modal's `Type` select +- ✅ `Custom art` option added to the community modal's search `Type` select +- ✅ `Custom Art` button added to the New / Popular / Mine feed-type filter rows +- ✅ `renderCustomArtPreviewDOM(container, payload, opts)` — full + compact mode, sorted layers by z, explicit z-index, image / text / color layers, background color +- ✅ `openDesignPreview` dispatches to the customart renderer +- ✅ `buildFeedCard` mini-preview dispatches to the customart renderer in compact mode +- ✅ JPEG export: `enterExportPrintMode` preserves the canvas background color and uses the canvas dims as the `@page` size (no trim crop, no bleed margin); PDF.js converts the resulting PDF to a flat JPEG at native resolution + +--- + +### Phase D — Polish (not started) + +These are deliberately deferred. Some are easy add-ons; others are bigger UX changes worth a separate spec. + +#### Easy +- **Background: transparent** — checkbox in the canvas controls. Today background is always a solid color (default `#ffffff`). Useful for users who want a PNG with alpha (would need transparent-aware JPEG-or-PNG export though). +- **Aspect-ratio size filter** inside the browse view when `Custom Art` (or `Any`) is selected. Buckets: `16:9 · 9:16 · 1:1 · 4:5 · 4:3 · 3:1 · Letter · Other`. Was in the spec; skipped because shipping without it works fine. +- **Surface the canvas dimensions in the card metadata strip** (e.g., "Custom Art · 1920×1080"). Currently the card just shows the mode label. +- **Default to one of the preset names in the title field** when starting a fresh custom art design (e.g., "Untitled wallpaper" for 1920×1080). +- **Tooltip on the layout tab button** describing what Custom Art is good for, to onboard users from the existing case/disc community. + +#### Medium +- **Pixel-native layer position inputs** alongside the existing inch inputs in the layer controls panel — so users who think in pixels don't have to mentally convert. Inch inputs stay for compat. +- **Background image** (instead of solid color). Image fills the canvas, layers stack on top. Same `fit/zoom/x/y/rotate` controls as slot artwork on the other modes. +- **Starter templates / preset compositions** — e.g., "Instagram post starter", "Twitter header starter", "YouTube thumbnail starter". Click to populate the canvas with a few placeholder layers. +- **Larger preset library** — add common social/streaming sizes (TikTok 1080×1920 ✓ already, YouTube thumbnail 1280×720, Discord banner 600×240, Twitch panel 320×100, etc.). +- **Export at chosen pixel resolution** — currently the JPEG is rasterized at PDF native size (a 1080-px canvas → PDF page that's ~11.25 inch wide → rasterized at PDF.js scale defaulting to ~2400-px long edge). Users may want explicit "Export at 50%" / "Export at 200%". +- **Pixel-art mode** with snap-to-grid for small designs (e.g., 64×64 sprite). Today rendering would work but layer-drag has no grid snap. + +#### Larger +- **Shape primitives** — drawn rectangle / circle / line / arrow as new layer types (not just the color-fill rectangle). Probably needs a small toolbar in the editor. +- **Vector tools** — SVG path, freehand drawing, pen tool. Big lift; would require a new layer type with vector data and a renderer that converts SVG → canvas during export. +- **Pixel-native units everywhere** (not just at layer-position input) — switch the internal storage from inches to pixels for customart, with a one-time migration. Bigger code change but eliminates the implicit 96-DPI conversion. +- **Animated / multi-frame art** — render a sequence of canvases, export as GIF/APNG/MP4. Substantial. +- **Multi-page custom art** — a slidedeck-like flow where one design has multiple canvases. Would need significant payload schema changes. +- **Crop / trim tool on the canvas itself** — interactive bounding box that visually crops layers down to the new canvas bounds. +- **Layer effects** — drop shadow, blur, outline, color overlay. Per-layer styling that the canvas-export path would need to faithfully reproduce. + +#### Schema / portability +- **NIP-73 identifier tags consumed at search time** — Phase B emits these but the community feed filter doesn't yet match against them. A user-typed UPC into the search box already finds matches via the haystack; a structured "find by external ID" filter would be a separate feature. +- **Format negotiation** — a `payload.customArt.exportFormat` ("png" | "jpeg") so the publisher can hint how their design should be flattened. Today everything goes through JPEG via the PDF intermediate. + +--- + ## Risks / tradeoffs | Risk | Mitigation | diff --git a/src/index.html b/src/index.html index 7666f09..5e9f92a 100644 --- a/src/index.html +++ b/src/index.html @@ -1486,7 +1486,7 @@ /* Feed wall */ .feedLoadStatus{font-size:.78rem;color:var(--muted);padding:2px 2px 6px;font-style:italic;letter-spacing:.02em} .feedLoadStatus[hidden]{display:none} - .feedWall{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px;margin-top:4px;align-content:start} + .feedWall{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:14px;margin-top:4px;align-content:start} .feedCard{border:1px solid var(--line);border-radius:10px;overflow:hidden;background:#fff;cursor:pointer;display:flex;flex-direction:column;transition:box-shadow .12s} .feedCard:hover{box-shadow:0 4px 18px rgba(15,23,42,.13)} .feedCardThumb{width:100%;aspect-ratio:1;background:#f1f5f9;position:relative;display:flex;align-items:center;justify-content:center;overflow:hidden;flex-shrink:0} @@ -1496,7 +1496,7 @@ .feedCardMeta{padding:7px 8px;flex:1;display:flex;flex-direction:column;gap:2px} .feedCardTitle{font-weight:700;font-size:.78rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .feedCardSub{font-size:.68rem;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} - .feedCardActions{display:flex;justify-content:space-between;align-items:center;padding:5px 8px;border-top:1px solid var(--line);gap:6px} + .feedCardActions{display:flex;flex-wrap:wrap;justify-content:flex-start;align-items:center;padding:6px 8px;border-top:1px solid var(--line);gap:5px} .reactionBadge{font-size:.72rem;font-weight:700;color:var(--muted)} .reactionBtn{border:1px solid var(--line);border-radius:20px;padding:3px 10px;background:#fff;color:var(--muted);cursor:pointer;font-size:.72rem;font-weight:800;transition:background .1s,border-color .1s,color .1s;white-space:nowrap} .reactionBtn:hover{background:#fef9e7;border-color:#f59e0b;color:#92400e} @@ -2773,6 +2773,18 @@ { key: 'front', label: 'Front cover' } ]; + function isLayerForCurrentMode(layer) { + const t = String(layer?.target || ''); + // 'page' is shared / "whole page" — render in any template mode (legacy). + if (t === '' || t === 'page') return true; + const mode = state.templateMode; + if (mode === 'customart') return t === 'canvas'; + if (mode === 'disc') return t.startsWith('disc-'); + if (mode === 'jewel') return t.startsWith('jewel-'); + if (mode === 'cover') return t === 'cover-trim' || t === 'back' || t === 'spine' || t === 'front'; + return true; + } + function templateLayerTargetsForMode() { if (state.templateMode === 'disc') return [ { key: 'page', label: 'Whole page' }, @@ -3787,6 +3799,34 @@ } } + // Visual order of sidebar fieldsets per mode. Each list defines the + // workflow order shown to the user; the renumbering pass assigns 1, 2, + // 3… to visible fieldsets and CSS `order` re-flows them via flexbox so + // DOM order doesn't have to match. + const WORKFLOW_ORDER = { + cover: ['layoutFieldset', 'coverSizeFieldset', 'coverArtworkFieldset', 'layersFieldset', 'metadataFieldset', 'exportFieldset', 'printSaveFieldset'], + disc: ['layoutFieldset', 'discArtworkFieldset', 'layersFieldset', 'metadataFieldset', 'exportFieldset', 'printSaveFieldset'], + jewel: ['layoutFieldset', 'coverSizeFieldset', 'jewelArtworkFieldset', 'layersFieldset', 'metadataFieldset', 'exportFieldset', 'printSaveFieldset'], + customart: ['layoutFieldset', 'customArtFieldset', 'layersFieldset', 'metadataFieldset', 'exportFieldset', 'printSaveFieldset'], + designer: ['designerFieldset', 'layersFieldset', 'designerMetadataFieldset', 'designerPrintSaveFieldset'] + }; + + function renumberWorkflowSteps() { + const aside = document.querySelector('aside'); + if (!aside) return; + const mode = state.appMode === 'designer' ? 'designer' : (state.templateMode || 'cover'); + const order = WORKFLOW_ORDER[mode] || WORKFLOW_ORDER.cover; + let step = 1; + for (let i = 0; i < order.length; i++) { + const fs = document.getElementById(order[i]); + if (!fs) continue; + fs.style.order = String(i + 1); + if (getComputedStyle(fs).display === 'none') continue; + const target = fs.querySelector('legend[data-step], summary[data-step]'); + if (target) target.setAttribute('data-step', String(step++)); + } + } + function updateBodyClasses() { document.body.classList.toggle('template-cover', state.templateMode === 'cover'); document.body.classList.toggle('template-disc', state.templateMode === 'disc'); @@ -3801,6 +3841,7 @@ document.body.classList.toggle('app-designer', state.appMode === 'designer'); document.body.classList.toggle('app-template', state.appMode !== 'designer'); updateLayerTargetOptions(); + renumberWorkflowSteps(); } function renderDimensions() { @@ -4841,6 +4882,10 @@ const isDesignerLayer = layer.target === 'designer-disc'; if (state.appMode === 'designer' && !isDesignerLayer) continue; if (state.appMode !== 'designer' && isDesignerLayer) continue; + // Only render layers whose target belongs to the current template mode + // (prevents leftover cover/disc/jewel layers from leaking into custom-art + // canvases and vice versa). Page-level layers render everywhere. + if (state.appMode !== 'designer' && !isLayerForCurrentMode(layer)) continue; const el = document.createElement('div'); el.className = `freeLayer${layer.id === state.selectedLayerId ? ' selected' : ''}`; @@ -6796,12 +6841,16 @@ } if (mode === 'customart' || parsed.customArt) { + const customArtDefaults = { width: 1920, height: 1080, background: '#ffffff', preset: 'custom' }; Object.assign(state, { templateMode: 'customart', meta: { ...state.meta, ...(parsed.meta || {}) }, zoom: parsed.ui?.zoom ?? state.zoom, showGuides: parsed.ui?.showGuides ?? state.showGuides, - customArt: { ...state.customArt, ...(parsed.customArt || {}) }, + // Fork: replace customArt entirely (don't leak the user's prior canvas dims/bg). + customArt: { ...customArtDefaults, ...(parsed.customArt || {}) }, + // Keep layers from other modes (so cover/disc/jewel work-in-progress isn't lost), + // replace the canvas-target layers with the forked design's layers. layers: [ ...state.layers.filter(l => l.target !== 'canvas'), ...(Array.isArray(parsed.layers) ? parsed.layers : []) @@ -7393,19 +7442,6 @@ state.community.status = 'Author blocked locally, but mute-list sync failed.'; } await refreshCommunityView(); - const undo = confirm('Author blocked. Click OK to undo.'); - if (undo && _lastBlockedPubkey === normalized) { - _blockedAuthors.delete(normalized); - removeBlockMeta(normalized); - saveBlockedAuthors(); - renderBlockedAuthors(); - try { - await syncLocalBlocksToMuteList('undo'); - } catch (error) { - state.community.status = 'Undo applied locally, but mute-list sync failed.'; - } - await refreshCommunityView(); - } } let _reportTarget = null; @@ -7582,6 +7618,66 @@ return [...seen.values()]; } + // Content-based fingerprint that's stable across forks: + // ignores meta (title/imdb/etc.), event id, pubkey, created_at. + // Two events with the same fingerprint represent the exact same design. + function designFingerprint(payload) { + if (!payload) return ''; + const mode = normalizeTemplateMode(payload); + const j = (v) => JSON.stringify(v ?? null); + const fields = [mode]; + if (mode === 'cover') { + fields.push(j(payload.coverTemplate?.slots || payload.slots || {})); + fields.push(j(payload.coverTemplate?.combined || payload.combined || {})); + fields.push(j(payload.coverTemplate?.artworkMode || payload.artworkMode || '')); + } else if (mode === 'disc') { + fields.push(j(payload.discTemplate?.discs || payload.discs || {})); + fields.push(j(payload.discTemplate?.spineLabels || payload.discSpineLabels || {})); + } else if (mode === 'jewel') { + fields.push(j(payload.jewelTemplate?.inserts || payload.jewelInserts || {})); + fields.push(j(payload.jewelTemplate?.spineLabels || payload.jewelSpineLabels || {})); + } else if (mode === 'disc-design') { + const dd = payload.discDesign || {}; + // Strip the layer-internal `id` from the disc-design layers if present + // — those are random per-creation so don't break fingerprint matching. + const { name: _n, ...discNoName } = dd; + fields.push(j(discNoName)); + } else if (mode === 'customart') { + fields.push(j(payload.customArt || {})); + } + // Layers: strip the per-layer `id` and `_generatedDiscLayer` since + // those vary across fork sessions but the visual content is the same. + const layers = (payload.layers || []).map(l => { + const { id: _id, _generatedDiscLayer: _g, ...rest } = l; + return rest; + }); + fields.push(j(layers)); + return fields.join('|'); + } + + // Drop rows whose design content matches an earlier (lower created_at) row's. + // The earliest event for each fingerprint wins — that's "the original." + // When opts.preserveOwn is true, the current user's events are never + // dropped even if they duplicate someone else's original. + function dedupByFingerprint(rows, opts = {}) { + const myPubkey = normalizePubkey(state.community?.myPubkey || ''); + const earliestPerFp = new Map(); + for (const row of rows) { + const fp = designFingerprint(row.payload); + if (!fp) continue; + const prev = earliestPerFp.get(fp); + if (!prev || (row.createdAt || 0) < (prev.createdAt || 0)) { + earliestPerFp.set(fp, row); + } + } + return rows.filter(row => { + const fp = designFingerprint(row.payload); + if (!fp) return true; + if (opts.preserveOwn && row.e?.pubkey === myPubkey) return true; + return earliestPerFp.get(fp) === row; + }); + } + function buildFeedCard(row, onUse) { const el = document.createElement('div'); el.className = 'feedCard'; @@ -7672,18 +7768,27 @@ prevBtn.type = 'button'; prevBtn.textContent = 'Preview'; prevBtn.addEventListener('click', e => { e.stopPropagation(); openDesignPreview(row); }); const btn = document.createElement('button'); - btn.type = 'button'; btn.textContent = 'Use'; + btn.type = 'button'; btn.textContent = 'Fork off!'; + btn.title = 'Fork this design — opens it in the editor so you can save your own version'; btn.addEventListener('click', e => { e.stopPropagation(); onUse(); }); const blockBtn = document.createElement('button'); blockBtn.type = 'button'; + blockBtn.className = 'danger'; blockBtn.textContent = 'Block'; blockBtn.title = 'Block this author'; blockBtn.addEventListener('click', async e => { e.stopPropagation(); - await blockAuthor(row.e?.pubkey, 'card'); + const pk = row.e?.pubkey; + if (!pk) return; + const profile = _feedCache.profiles?.[pk] || null; + const name = profile?.name || profile?.display_name || shortPubkey(pk); + const ok = confirm(`Block ${name}?\n\nYou will no longer see designs from this author in your feed. You can unblock them later from the "Blocked authors" panel at the bottom of the Community modal.`); + if (!ok) return; + await blockAuthor(pk, 'card'); }); const reportBtn = document.createElement('button'); reportBtn.type = 'button'; + reportBtn.className = 'danger'; reportBtn.textContent = 'Report'; reportBtn.title = 'Report this template'; reportBtn.addEventListener('click', e => { @@ -7824,27 +7929,65 @@ await loadFeedTab('New'); } + // Card factory used by both the initial render and the streaming-update diff. + function buildFeedCardForRow(row) { + const card = buildFeedCard(row, () => { + if (row.mode === 'disc-design') { + const slotTarget = state._discImportTargetSlot; + if (slotTarget) { + importDiscDesignFromPayload(row.payload, slotTarget, { openDesigner: false }); + } else { + importDiscDesignFromPayload(row.payload, 'a', { openDesigner: true }); + } + } else { + loadProjectFromText(JSON.stringify(row.payload)); + } + $('nostrModal').classList.remove('open'); + }); + card.dataset.eventId = row.e?.id || ''; + return card; + } + + // Reconcile a wall's children against the desired list of rows. Cards + // already in the DOM with a matching event id are kept in place (and just + // re-ordered if needed); new rows produce new cards via buildCardFn; cards + // not present in the new list are removed. This is what stops the + // template browser from flashing every time a new event streams in. + function diffWallCards(wallEl, rows, buildCardFn) { + if (!wallEl) return; + const existing = new Map(); + for (const child of Array.from(wallEl.children)) { + const id = child.dataset?.eventId; + if (id) existing.set(id, child); + else child.remove(); // drop "Loading…" / "No templates" placeholder + } + let prevCard = null; + for (const row of rows) { + const id = row.e?.id; + if (!id) continue; + let card = existing.get(id); + if (card) { + existing.delete(id); + } else { + card = buildCardFn(row); + } + const expectedNext = prevCard ? prevCard.nextSibling : wallEl.firstChild; + if (card !== expectedNext) { + if (prevCard) prevCard.after(card); else wallEl.prepend(card); + } + prevCard = card; + } + // Anything left in `existing` is a card whose row dropped out — remove. + for (const card of existing.values()) card.remove(); + } + function appendFeedRows(tab) { const page = _feedPagination[tab]; const wall = $(`feedWall${tab}`); if (!page || !wall || page.rendered >= page.rows.length) return false; - const nextRows = page.rows.slice(page.rendered, page.rendered + page.chunk); - page.rendered += nextRows.length; - nextRows.forEach(row => { - wall.appendChild(buildFeedCard(row, () => { - if (row.mode === 'disc-design') { - const slotTarget = state._discImportTargetSlot; - if (slotTarget) { - importDiscDesignFromPayload(row.payload, slotTarget, { openDesigner: false }); - } else { - importDiscDesignFromPayload(row.payload, 'a', { openDesigner: true }); - } - } else { - loadProjectFromText(JSON.stringify(row.payload)); - } - $('nostrModal').classList.remove('open'); - })); - }); + page.rendered = Math.min(page.rows.length, page.rendered + page.chunk); + const visibleRows = page.rows.slice(0, page.rendered); + diffWallCards(wall, visibleRows, buildFeedCardForRow); const scroller = wall.closest('.modalCard'); if (scroller && page.rendered < page.rows.length && scroller.scrollHeight <= scroller.clientHeight + 40) { requestAnimationFrame(() => appendFeedRows(tab)); @@ -7881,6 +8024,9 @@ const trustFilter = _feedState[`trustFilter${tab}`] || 'all'; if (trustFilter === 'trusted') rows = rows.filter(r => r.trust === 'self' || r.trust === 'trusted'); else if (trustFilter === 'extended') rows = rows.filter(r => r.trust === 'self' || r.trust === 'trusted' || r.trust === 'extended'); + // Content-based dedup: collapse content-identical forks to the original. + // "Mine" tab keeps everything because users want to see their own posts. + if (tab !== 'Mine') rows = dedupByFingerprint(rows); if (tab === 'New') { rows.sort((a, b) => (Number(isPreviewBroken(a)) - Number(isPreviewBroken(b))) || (TIER_WEIGHT[a.trust] - TIER_WEIGHT[b.trust]) || (b.createdAt - a.createdAt)); } else { @@ -7891,14 +8037,21 @@ if (!rows.length) { if (!_feedCache.loading) { wallEl.innerHTML = `
No templates found on these relays yet${_blockedAuthors.size ? ' (some authors may be blocked)' : ''}.
`; + } else { + // Loading and still no rows — keep the wall empty (or the existing + // placeholder text). Don't wipe / re-paint. } + _feedPagination[tab].rows = []; + _feedPagination[tab].rendered = 0; return; } - wallEl.innerHTML = ''; _feedPagination[tab].rows = rows; - _feedPagination[tab].rendered = 0; + if (!_feedPagination[tab].rendered || _feedPagination[tab].rendered > rows.length) { + _feedPagination[tab].rendered = Math.min(rows.length, _feedPagination[tab].chunk); + } initInfiniteScroll(tab); - appendFeedRows(tab); + const visibleRows = rows.slice(0, _feedPagination[tab].rendered); + diffWallCards(wallEl, visibleRows, buildFeedCardForRow); }; _currentFeedRerender = rerender; @@ -8108,7 +8261,8 @@ prevBtn.addEventListener('click', e => { e.stopPropagation(); openDesignPreview(row); }); const useBtn = document.createElement('button'); useBtn.type = 'button'; - useBtn.textContent = 'Use this'; + useBtn.textContent = 'Fork off!'; + useBtn.title = 'Fork this design — opens it in the editor so you can save your own version'; useBtn.addEventListener('click', e => { e.stopPropagation(); onUse(); }); actions.append(makeReactionBtn(row), prevBtn, useBtn); @@ -9014,6 +9168,7 @@ out.innerHTML = '
Searching…
'; await fetchFeedAndReactions(); let rows = buildRowsFromEvents(_feedCache.events || [], type, q, false, category); + rows = dedupByFingerprint(rows); if (q) rows = deduplicateRows(rows); rows.sort((a, b) => b.reactions - a.reactions); out.innerHTML = rows.length ? '' : '
No templates found.
'; @@ -9258,6 +9413,7 @@ const category = $('welcomeCategory').value; const rerender = () => { let rows = buildRowsFromEvents(_feedCache.events || [], type, '', false, category); + rows = dedupByFingerprint(rows); if (tab === 'Popular') { rows = deduplicateRows(rows); rows.sort((a, b) => (Number(isPreviewBroken(a)) - Number(isPreviewBroken(b))) || (b.reactions - a.reactions) || (TIER_WEIGHT[a.trust] - TIER_WEIGHT[b.trust]) || (b.createdAt - a.createdAt)); @@ -9286,6 +9442,7 @@ out.innerHTML = '
Searching relays…
'; const rerender = () => { let rows = buildRowsFromEvents(_feedCache.events || [], type, q, false, category); + rows = dedupByFingerprint(rows); if (q) rows = deduplicateRows(rows); rows.sort((a, b) => (Number(isPreviewBroken(a)) - Number(isPreviewBroken(b))) || (b.reactions - a.reactions)); renderWelcomeRows(rows);