diff --git a/src/index.html b/src/index.html index fd55f58..7acc23c 100644 --- a/src/index.html +++ b/src/index.html @@ -2290,7 +2290,7 @@
Relay settings - +

Separate multiple relays with commas. Changing relays clears the template cache.

@@ -2399,6 +2399,15 @@ { value: 'audio-cd', label: 'Audio CD' } ]; + const DEFAULT_NOSTR_RELAYS = [ + 'wss://relay.damus.io', + 'wss://nos.lol', + 'wss://relay.primal.net', + 'wss://relay.snort.social', + 'wss://relay.nostr.band', + 'wss://relay.nos.social' + ]; + const state = { meta: { title: "", imdbId: "", omdbApiKey: "", category: "", language: "en" }, darkMode: false, @@ -2435,6 +2444,10 @@ top: { src: '', fit: 'cover', zoom: 1, x: 0, y: 0, rotate: 0 }, bottom: { src: '', fit: 'cover', zoom: 1, x: 0, y: 0, rotate: 0 } }, + discMeta: { + top: { title: '', imdbId: '', category: '', language: 'en' }, + bottom: { title: '', imdbId: '', category: '', language: 'en' } + }, discDesign: { name: '', src: '', fit: 'cover', zoom: 1, x: 0, y: 0, rotate: 0 }, discSpineLabels: { top: '', bottom: '' }, jewelInserts: { @@ -2444,7 +2457,7 @@ layers: [], selectedLayerId: '', community: { - relays: 'wss://relay.damus.io, wss://nos.lol', + relays: DEFAULT_NOSTR_RELAYS.join(', '), query: '', kind: 'templates', feed: [], @@ -2472,6 +2485,26 @@ return Number(n).toFixed(digits).replace(/\.0+$/, '').replace(/(\.\d*?)0+$/, '$1'); } + function filenamePart(value, fallback = '') { + const slug = String(value || '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, ''); + return slug || fallback; + } + + function projectFileStem(kind, variant = '') { + const title = filenamePart(state.meta.title, 'untitled'); + const imdb = state.meta.imdbId + ? state.meta.imdbId.replace(/[^a-z0-9]/gi, '').toLowerCase() + : ''; + const fileCategory = filenamePart(state.meta.category); + const fileKind = filenamePart(kind || templateTypeForMode()); + const fileVariant = filenamePart(variant); + return [title, fileCategory, fileKind, fileVariant, imdb].filter(Boolean).join('_'); + } + function normalizeImageUrl(raw) { const value = String(raw || '').trim(); if (!value) throw new Error('Enter an image URL.'); @@ -2541,6 +2574,14 @@ function eventTemplateSummary(payload) { if (!payload) return { title: 'Untitled', mode: 'template', image: '' }; + if (isDeletedTemplatePayload(payload)) { + return { + title: 'Deleted template', + mode: normalizeTemplateMode(payload), + category: payload.meta?.category || payload.category || '', + image: '' + }; + } const title = payload.meta?.title || payload.title || 'Untitled'; const mode = normalizeTemplateMode(payload); const category = payload.meta?.category || payload.category || ''; @@ -2599,7 +2640,8 @@ if (raw === 'jewel-inserts') return 'jewel'; if (raw === 'disc-design') return 'disc-design'; if (raw === 'cover' || raw === 'disc' || raw === 'jewel') return raw; - if (payload.discDesign) return 'disc-design'; + if (payload.discTemplate || payload.discMeta) return 'disc'; + if (payload.discDesign && !payload.discTemplate) return 'disc-design'; if (payload.coverTemplate || payload.slots || payload.combined) return 'cover'; if (payload.jewelTemplate || payload.jewelInserts) return 'jewel'; return 'disc'; @@ -2613,6 +2655,51 @@ return 'custom-case-cover'; } + function templateTypeForPayload(payload = {}) { + return payload?.templateType || templateTypeForMode(normalizeTemplateMode(payload)); + } + + function templateEventDValue(event, payload = {}) { + const tags = Array.isArray(event?.tags) ? event.tags : []; + const dTag = tags.find((tag) => tag[0] === 'd' && tag[1]); + return dTag?.[1] || payload?.meta?.imdbId || payload?.meta?.title || payload?.title || ''; + } + + function templateEventAddress(event, payload = {}) { + const dValue = templateEventDValue(event, payload); + if (!dValue || !event?.pubkey) return event?.id || ''; + return `${event.kind || 30078}:${event.pubkey}:${dValue}`; + } + + function isDeletedTemplatePayload(payload = {}) { + return payload?.status === 'deleted' + || payload?.deleted === true + || payload?.tombstone === true; + } + + function templateDeleteTargets(event) { + if (!event || event.kind !== 5) return []; + const pubkey = event.pubkey || ''; + const tags = Array.isArray(event.tags) ? event.tags : []; + const kTags = tags.filter((tag) => tag[0] === 'k' && tag[1]).map((tag) => tag[1]); + const targets = []; + for (const tag of tags) { + if (tag[0] === 'e' && tag[1]) { + targets.push({ type: 'event', value: tag[1], pubkey }); + } + if (tag[0] === 'a' && tag[1]) { + targets.push({ type: 'address', value: tag[1], pubkey }); + } + } + return targets.filter((target) => { + if (target.type === 'address') { + const [kind, targetPubkey] = String(target.value).split(':'); + return targetPubkey && targetPubkey === pubkey && (kTags.length === 0 || kTags.includes(kind)); + } + return target.pubkey && target.value; + }); + } + function getCommunityProfile() { return state.community.myProfile || null; } @@ -2661,7 +2748,7 @@ } async function queryRelays(relays, filters, { timeoutMs = 3500 } = {}) { - const relayList = relays.length ? relays : ['wss://relay.damus.io']; + const relayList = relays.length ? relays : DEFAULT_NOSTR_RELAYS; const events = new Map(); await Promise.all(relayList.map((relay) => new Promise((resolve) => { @@ -2772,6 +2859,7 @@ bottom: { src: '', fit: 'cover', zoom: 1, x: 0, y: 0, rotate: 0 }, ...state.discs }; + state.discMeta = normalizeDiscMeta(state.discMeta); } function currentDesignerLayers() { @@ -2805,6 +2893,78 @@ } } + function normalizeDiscMeta(meta = {}) { + return { + top: { title: '', imdbId: '', category: '', language: 'en', ...(meta.top || {}) }, + bottom: { title: '', imdbId: '', category: '', language: 'en', ...(meta.bottom || {}) } + }; + } + + function discSlotMetaFromPayload(payload, slotKey, design = {}) { + const templateMeta = payload?.discTemplate?.meta || payload?.discMeta || {}; + const slotMeta = templateMeta?.[slotKey] || {}; + const designMeta = design?.meta || payload?.discDesign?.meta || {}; + const sharedMeta = payload?.meta || {}; + return { + title: slotMeta.title || designMeta.title || sharedMeta.title || design.name || '', + imdbId: slotMeta.imdbId || designMeta.imdbId || sharedMeta.imdbId || '', + category: slotMeta.category || designMeta.category || sharedMeta.category || '', + language: slotMeta.language || designMeta.language || sharedMeta.language || 'en' + }; + } + + function preferredDiscProjectTitle() { + const topTitle = state.discMeta?.top?.title?.trim(); + const bottomTitle = state.discMeta?.bottom?.title?.trim(); + return topTitle || bottomTitle || ''; + } + + function preferredDiscProjectImdb() { + const topImdb = state.discMeta?.top?.imdbId?.trim(); + const bottomImdb = state.discMeta?.bottom?.imdbId?.trim(); + return topImdb || bottomImdb || ''; + } + + function preferredDiscProjectCategory() { + const topCategory = state.discMeta?.top?.category?.trim(); + const bottomCategory = state.discMeta?.bottom?.category?.trim(); + return topCategory || bottomCategory || ''; + } + + function preferredDiscProjectLanguage() { + const topLanguage = state.discMeta?.top?.language?.trim(); + const bottomLanguage = state.discMeta?.bottom?.language?.trim(); + return topLanguage || bottomLanguage || 'en'; + } + + function syncProjectMetaFromDiscMeta() { + if (state.templateMode !== 'disc') return; + const nextTitle = preferredDiscProjectTitle(); + const nextImdb = preferredDiscProjectImdb(); + const nextCategory = preferredDiscProjectCategory(); + const nextLanguage = preferredDiscProjectLanguage(); + state.meta.title = nextTitle; + state.meta.imdbId = nextImdb; + state.meta.category = nextCategory; + state.meta.language = nextLanguage; + const titleField = $('titleField'); + if (titleField) titleField.value = nextTitle; + const imdbField = $('imdbField'); + if (imdbField) imdbField.value = nextImdb; + const categoryField = $('categoryField'); + if (categoryField) categoryField.value = nextCategory; + const languageField = $('languageField'); + if (languageField) languageField.value = nextLanguage; + const designerTitleField = $('designerTitleField'); + if (designerTitleField) designerTitleField.value = nextTitle; + const designerImdbField = $('designerImdbField'); + if (designerImdbField) designerImdbField.value = nextImdb; + const designerCategoryField = $('designerCategoryField'); + if (designerCategoryField) designerCategoryField.value = nextCategory; + const designerLanguageField = $('designerLanguageField'); + if (designerLanguageField) designerLanguageField.value = nextLanguage; + } + function templatePayloadFromEvent(event) { if (!event) return null; if (event.kind === 30078) return safeJsonParse(event.content, null); @@ -2881,6 +3041,9 @@ const di = $('designerImdbField'); if (di) di.value = state.meta?.imdbId || ""; const dc = $('designerCategoryField'); if (dc) dc.value = state.meta?.category || ''; const dl = $('designerLanguageField'); if (dl) dl.value = state.meta?.language || 'en'; + const metadataFieldset = $('metadataFieldset'); + if (metadataFieldset) metadataFieldset.style.display = state.templateMode === 'disc' ? 'none' : ''; + if (state.templateMode === 'disc') syncProjectMetaFromDiscMeta(); $('showBleed').checked = state.showBleed; $('showCrops').checked = state.showCrops; $('printGuides').checked = state.printGuides; @@ -3366,56 +3529,109 @@ for (const { key } of jewelSlotNames) syncJewelInputs(key); } - function buildDiscImportUI() { + function buildDiscImportUI() { // Replaced dropdowns with import buttons const wrap = $('discControls'); if (!wrap) return; - wrap.innerHTML = ` -

Import saved disc designs into each print position from file or Nostr.

- `; + wrap.innerHTML = `

Import saved disc designs into each print position from file or Nostr.

`; for (const { key, label } of discSlotNames) { - const details = document.createElement('details'); - details.open = false; - details.innerHTML = ` - ${label} -
- - -
-
No design loaded
- - - -
- - -
- -
- - -
- -
- - -
- -
- - -
+ const block = document.createElement('div'); + block.className = 'discSlotGroup'; + block.innerHTML = ` +
+ ${label} image +
+ + +
+
No design loaded
+ + + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ ${label} metadata + + + + + + + + +
+
`; - wrap.appendChild(details); + wrap.appendChild(block); } + attachDiscMetaEvents(); attachDiscImportEvents(); syncDiscTemplateInputs(); } + function attachDiscMetaEvents() { + for (const { key } of discSlotNames) { + const titleEl = $(`disc-title-${key}`); + if (titleEl) titleEl.addEventListener('input', (e) => { + state.discMeta[key].title = e.target.value; + syncProjectMetaFromDiscMeta(); + syncDiscTemplateInputs(); + }); + const imdbEl = $(`disc-imdb-${key}`); + if (imdbEl) imdbEl.addEventListener('input', (e) => { + state.discMeta[key].imdbId = e.target.value; + syncProjectMetaFromDiscMeta(); + syncDiscTemplateInputs(); + }); + const categoryEl = $(`disc-category-${key}`); + if (categoryEl) categoryEl.addEventListener('input', (e) => { + state.discMeta[key].category = e.target.value; + syncDiscTemplateInputs(); + syncProjectMetaFromDiscMeta(); + }); + const languageEl = $(`disc-language-${key}`); + if (languageEl) languageEl.addEventListener('change', (e) => { + state.discMeta[key].language = e.target.value; + syncProjectMetaFromDiscMeta(); + syncDiscTemplateInputs(); + }); + } + } + function attachDiscImportEvents() { for (const { key } of discSlotNames) { const btn = $(`disc-import-${key}`); @@ -3432,16 +3648,22 @@ if (parsed?.discDesign) { const { key: _k, layers: payloadLayers, ...design } = parsed.discDesign; state.discs[key] = { ...state.discs[key], ...design }; + state.discMeta[key] = { ...state.discMeta[key], ...discSlotMetaFromPayload(parsed, key, design) }; const layersToUse = Array.isArray(payloadLayers) ? payloadLayers : (Array.isArray(parsed.layers) ? parsed.layers : []); if (layersToUse.length > 0) { state.discs[key].layers = layersToUse.map(l => ({ ...l })); } if (state.templateMode !== 'disc') state.templateMode = 'disc'; - syncInputs(); render(); + syncInputs(); + syncAllDiscInputs(); + render(); const preview = $(`disc-preview-${key}`); if (preview) preview.textContent = design.name || 'Design loaded'; } else if (parsed?.discTemplate?.discs?.[key]) { state.discs[key] = parsed.discTemplate.discs[key]; + state.discMeta[key] = { ...state.discMeta[key], ...discSlotMetaFromPayload(parsed, key, parsed.discTemplate.discs[key]) }; + syncProjectMetaFromDiscMeta(); + syncAllDiscInputs(); render(); const preview = $(`disc-preview-${key}`); if (preview) preview.textContent = 'Loaded from project'; @@ -3482,6 +3704,17 @@ function syncDiscTemplateInputs() { for (const { key } of discSlotNames) { const disc = state.discs[key] || {}; + const meta = state.discMeta?.[key] || {}; + const titleEl = $(`disc-title-${key}`); + if (titleEl) titleEl.value = meta.title || ''; + const imdbEl = $(`disc-imdb-${key}`); + if (imdbEl) imdbEl.value = meta.imdbId || ''; + const categoryEl = $(`disc-category-${key}`); + if (categoryEl) categoryEl.value = meta.category || ''; + const languageEl = $(`disc-language-${key}`); + if (languageEl) languageEl.value = meta.language || 'en'; + const metaPreview = $(`disc-meta-preview-${key}`); + if (metaPreview) metaPreview.textContent = `${meta.title || 'Untitled'} · ${meta.imdbId || 'no IMDb'} · ${meta.category || 'Uncategorized'} · ${meta.language || 'en'}`; const preview = $(`disc-preview-${key}`); if (preview) preview.textContent = disc.src ? (disc.name || 'Design loaded') : 'No design loaded'; const fitEl = $(`disc-fit-${key}`); @@ -3899,9 +4132,9 @@ function communityKindKinds() { switch (state.community.kind) { case 'templates': - return [30078]; + return [30078, 5]; case 'disc-designs': - return [30078]; + return [30078, 5]; case 'notes': return [1]; case 'profiles': @@ -3924,6 +4157,7 @@ if (!event) return 'Untitled'; if (event.kind === 30078) { const payload = safeJsonParse(event.content, {}); + if (isDeletedTemplatePayload(payload)) return 'Deleted template'; return eventTemplateSummary(payload).title || 'Untitled'; } if (event.kind === 0) { @@ -3938,6 +4172,9 @@ const profile = communityEventProfile(event); if (event.kind === 30078) { const payload = safeJsonParse(event.content, {}); + if (isDeletedTemplatePayload(payload)) { + return `Deleted • ${profile?.name || profile?.display_name || shortPubkey(event.pubkey) || 'Unknown'}`; + } const summary = eventTemplateSummary(payload); return `${[summary.mode, categoryLabel(summary.category)].filter(Boolean).join(' • ')} • ${profile?.name || profile?.display_name || shortPubkey(event.pubkey) || 'Unknown'}`; } @@ -3951,6 +4188,7 @@ if (!event) return ''; if (event.kind === 30078) { const payload = safeJsonParse(event.content, {}); + if (isDeletedTemplatePayload(payload)) return ''; return previewFromPayload(payload, normalizeTemplateMode(payload)); } const profile = safeJsonParse(event.content, {}); @@ -4109,7 +4347,7 @@ const relays = splitRelayList(state.community.relays); const kinds = communityKindKinds(); const filter = { kinds, limit: 120 }; - if (kinds.length === 1 && kinds[0] === 30078) filter['#t'] = ['casewrap']; + if (kinds.includes(30078)) filter['#t'] = ['casewrap']; const items = await queryRelays(relays, filter, { timeoutMs: 4000 }); const q = state.community.query.trim().toLowerCase(); const filtered = items.filter((event) => { @@ -4148,6 +4386,122 @@ return signed; } + function buildTemplateTombstonePayload(payload, { reason = '', deletedAt = Math.floor(Date.now() / 1000), deletedBy = '' } = {}) { + const next = JSON.parse(JSON.stringify(payload || {})); + const meta = { ...(next.meta || {}) }; + next.meta = meta; + next.deleted = true; + next.status = 'deleted'; + next.deletedAt = deletedAt; + next.deletedBy = deletedBy || next.deletedBy || ''; + next.deletedReason = reason || next.deletedReason || ''; + next.templateType = templateTypeForPayload(next); + return next; + } + + function buildTemplateDeletionTags(row, payload, reason = '') { + const evt = row?.e || {}; + const templateType = templateTypeForPayload(payload); + const dValue = templateEventDValue(evt, payload); + const typeTag = payload?.templateMode === 'disc-design' + ? 'casewrap-disc-design' + : payload?.templateMode === 'disc' + ? 'casewrap-disc' + : payload?.templateMode === 'jewel' + ? 'casewrap-jewel' + : 'casewrap-cover'; + const tags = [ + ['t', 'casewrap'], + ['t', typeTag], + ['template_type', templateType] + ]; + if (payload?.meta?.title) tags.push(['title', payload.meta.title]); + if (payload?.meta?.imdbId) tags.push(['i', payload.meta.imdbId]); + if (payload?.meta?.category) tags.push(['category', payload.meta.category]); + if (payload?.meta?.language) tags.push(['language', payload.meta.language]); + if (dValue) { + tags.push(['d', dValue]); + tags.push(['a', `${evt.kind || 30078}:${evt.pubkey || ''}:${dValue}`]); + } + if (evt?.id) tags.push(['e', evt.id]); + tags.push(['k', String(evt.kind || 30078)]); + if (reason) tags.push(['reason', reason]); + return tags; + } + + async function publishTemplateDeletion(row, reason = '') { + const payload = row?.payload || {}; + const evt = row?.e || {}; + if (!evt?.id || evt.kind !== 30078) { + throw new Error('Only published templates can be tombstoned.'); + } + const pubkey = await getNostrPublicKey(); + if (!pubkey) { + throw new Error('Sign in with Nostr before deleting a template.'); + } + if (evt.pubkey !== pubkey) { + throw new Error('You can only delete your own published templates.'); + } + const deletedAt = Math.floor(Date.now() / 1000); + const tombstonePayload = buildTemplateTombstonePayload(payload, { reason, deletedAt, deletedBy: pubkey }); + const deleteTags = buildTemplateDeletionTags(row, payload, reason); + const deleteEvent = { + kind: 5, + created_at: deletedAt, + tags: deleteTags, + content: reason || 'Deleted by author.' + }; + const tombstoneEvent = { + kind: 30078, + created_at: deletedAt, + tags: evt.tags ? evt.tags.map((tag) => [...tag]) : deleteTags.filter((tag) => tag[0] !== 'e' && tag[0] !== 'a' && tag[0] !== 'k' && tag[0] !== 'reason'), + content: JSON.stringify(tombstonePayload) + }; + + if (!tombstoneEvent.tags.some((tag) => tag[0] === 'd' && tag[1])) { + const dValue = templateEventDValue(evt, payload); + if (dValue) tombstoneEvent.tags.unshift(['d', dValue]); + } + + const signedDelete = await publishCommunityEvent(deleteEvent); + const signedTombstone = await publishCommunityEvent(tombstoneEvent); + cachePublishedTemplateEvent(signedDelete); + cachePublishedTemplateEvent(signedTombstone); + state.community.status = 'Template deleted and tombstoned.'; + return { signedDelete, signedTombstone }; + } + + function addPreviewDeleteButton(actionsDiv, row, insertBeforeNode) { + if (!actionsDiv || !row?.e || row.e.kind !== 30078) return; + if (!state.community.myPubkey || row.e.pubkey !== state.community.myPubkey) return; + const deleteBtn = document.createElement('button'); + deleteBtn.type = 'button'; + deleteBtn.className = 'danger'; + deleteBtn.textContent = 'Delete / Tombstone'; + deleteBtn.addEventListener('click', async () => { + const reason = prompt('Optional deletion reason:', '') || ''; + if (!confirm('Publish a deletion request and tombstone this template?')) return; + const activeTabId = document.querySelector('.modalTab.active')?.id || ''; + try { + await publishTemplateDeletion(row, reason.trim()); + closeNostrPreview(); + $('nostrModal').classList.remove('open'); + if (activeTabId === 'tabMine') { + await loadMyDesigns(); + } else if (activeTabId === 'tabPopular') { + await loadFeedTab('Popular'); + } else if (activeTabId === 'tabSearch') { + await runNostrSearch(); + } else { + await loadFeedTab('New'); + } + } catch (error) { + alert(error.message || 'Could not delete this template.'); + } + }); + actionsDiv.insertBefore(deleteBtn, insertBeforeNode); + } + async function publishCurrentTemplateToNostr() { if (!state.meta.title?.trim()) { throw new Error('Add a title in Project Metadata before publishing.'); @@ -4216,7 +4570,7 @@ } function cachePublishedTemplateEvent(event) { - if (!event || event.kind !== 30078) return; + if (!event || (event.kind !== 30078 && event.kind !== 5)) return; if (!_feedCache.events) _feedCache.events = []; _feedCache.events = [event, ..._feedCache.events.filter(e => e.id !== event.id)]; _feedCache.fetchedAt = Date.now(); @@ -4478,7 +4832,8 @@ templateType, meta: state.meta, ui: { zoom: state.zoom }, - discTemplate: { templateType, config: discTemplate, discs: state.discs, spineLabels: state.discSpineLabels }, + discMeta: state.discMeta, + discTemplate: { templateType, config: discTemplate, discs: state.discs, spineLabels: state.discSpineLabels, meta: state.discMeta }, layers: nonGeneratedLayers().filter(l => String(l.target || '').startsWith('disc')) }; } @@ -4514,22 +4869,92 @@ }, coverTemplate: { preset: state.preset, templateType: templateTypeForMode('cover'), panelW: state.panelW, trimH: state.trimH, spineW: state.spineW, bleed: state.bleed, safe: state.safe, artworkMode: state.artworkMode, slots: state.slots, combined: state.combined }, discs: state.discs, - discDesign: state.discDesign, - discTemplate: { templateType: templateTypeForMode('disc'), config: discTemplate, discs: state.discs, spineLabels: state.discSpineLabels }, + discMeta: state.discMeta, + ...(state.templateMode === 'disc-design' ? { discDesign: state.discDesign } : {}), + discTemplate: { templateType: templateTypeForMode('disc'), config: discTemplate, discs: state.discs, spineLabels: state.discSpineLabels, meta: state.discMeta }, jewelTemplate: { templateType: templateTypeForMode('jewel'), preset: 'jewel8693', config: jewelTemplate, inserts: state.jewelInserts }, layers: nonGeneratedLayers(), community: { relays: state.community.relays, query: state.community.query, kind: state.community.kind } }; } - function projectFilename(type) { - const title = (state.meta.title || 'untitled') - .trim().toLowerCase() - .replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, ''); - const imdb = state.meta.imdbId - ? state.meta.imdbId.replace(/[^a-z0-9]/gi, '').toLowerCase() - : ''; - return [title, imdb, type].filter(Boolean).join('_') + '.json'; + function projectFilename(kind, variant = '') { + return `${projectFileStem(kind, variant)}.json`; + } + + function discProjectFilename() { + return `${discProjectStem()}.json`; + } + + function discProjectStem() { + const top = state.discMeta?.top || {}; + const bottom = state.discMeta?.bottom || {}; + const projectTitle = filenamePart(state.meta.title, 'untitled'); + const projectCategory = filenamePart(state.meta.category, 'uncategorized'); + const disc1Title = filenamePart(top.title, 'disc1'); + const disc1Category = filenamePart(top.category, 'uncategorized'); + const disc2Title = filenamePart(bottom.title, 'disc2'); + const disc2Category = filenamePart(bottom.category, 'uncategorized'); + const imdb = state.meta.imdbId ? state.meta.imdbId.replace(/[^a-z0-9]/gi, '').toLowerCase() : ''; + return [projectTitle, projectCategory, 'disc_label_sheet', disc1Title, disc1Category, disc2Title, disc2Category, imdb] + .filter(Boolean) + .join('_'); + } + + function currentSaveStem() { + if (state.templateMode === 'disc') return discProjectStem(); + if (state.templateMode === 'jewel') return projectFileStem('cd_jewel_case', 'jewel8693'); + return projectFileStem('case_cover', state.preset); + } + + function previewSaveStem(payload = {}, row = null) { + const mode = row?.mode || normalizeTemplateMode(payload); + if (mode === 'disc') { + const meta = payload.discMeta || payload.discTemplate?.meta || {}; + const top = meta.top || {}; + const bottom = meta.bottom || {}; + const projectTitle = filenamePart(payload.meta?.title || row?.title || 'untitled', 'untitled'); + const projectCategory = filenamePart(payload.meta?.category || row?.category || 'uncategorized', 'uncategorized'); + const disc1Title = filenamePart(top.title, 'disc1'); + const disc1Category = filenamePart(top.category, 'uncategorized'); + const disc2Title = filenamePart(bottom.title, 'disc2'); + const disc2Category = filenamePart(bottom.category, 'uncategorized'); + const imdb = filenamePart(payload.meta?.imdbId || row?.imdb || ''); + return [projectTitle, projectCategory, 'disc_label_sheet', disc1Title, disc1Category, disc2Title, disc2Category, imdb] + .filter(Boolean) + .join('_'); + } + if (mode === 'jewel') { + const title = filenamePart(payload.meta?.title || row?.title || 'untitled', 'untitled'); + const category = filenamePart(payload.meta?.category || row?.category || 'uncategorized', 'uncategorized'); + const imdb = payload.meta?.imdbId ? payload.meta.imdbId.replace(/[^a-z0-9]/gi, '').toLowerCase() : ''; + return [title, category, 'cd_jewel_case', 'jewel8693', imdb].filter(Boolean).join('_'); + } + if (mode === 'cover') { + const title = filenamePart(payload.meta?.title || row?.title || 'untitled', 'untitled'); + const category = filenamePart(payload.meta?.category || row?.category || 'uncategorized', 'uncategorized'); + const preset = payload.coverTemplate?.preset || row?.payload?.coverTemplate?.preset || payload.templateType || state.preset; + const imdb = payload.meta?.imdbId ? payload.meta.imdbId.replace(/[^a-z0-9]/gi, '').toLowerCase() : ''; + return [title, category, 'case_cover', filenamePart(preset), imdb].filter(Boolean).join('_'); + } + return projectFileStem(row?.payload?.templateType || mode || 'template'); + } + + function currentPdfTitle() { + return currentSaveStem(); + } + + function printWithCurrentFilename() { + const previousTitle = document.title; + const nextTitle = currentPdfTitle(); + const restoreTitle = () => { + document.title = previousTitle; + window.removeEventListener('afterprint', restoreTitle); + }; + window.addEventListener('afterprint', restoreTitle); + document.title = nextTitle; + render(); + setTimeout(() => window.print(), 50); } function triggerDownload(payload, filename) { @@ -4550,11 +4975,11 @@ if (!title) { alert('Please enter a title in Project Metadata before saving.'); return; } if (!category) { alert('Please select a category in Project Metadata before saving.'); return; } if (state.templateMode === 'disc') { - triggerDownload(discSheetPayload(), projectFilename('disk')); + triggerDownload(discSheetPayload(), discProjectFilename()); } else if (state.templateMode === 'jewel') { - triggerDownload(jewelTemplatePayload(), projectFilename('jewel')); + triggerDownload(jewelTemplatePayload(), projectFilename('cd_jewel_case', 'jewel8693')); } else { - triggerDownload(coverTemplatePayload(), projectFilename('case')); + triggerDownload(coverTemplatePayload(), projectFilename('case_cover', state.preset)); } } @@ -4568,9 +4993,10 @@ const payload = discDesignPayload(); const name = (design.name || 'disc_design') .toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, ''); - const rawTitle = (state.meta.title || '').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, ''); + const rawTitle = filenamePart(state.meta.title); + const fileCategory = filenamePart(state.meta.category); const imdb = state.meta.imdbId ? state.meta.imdbId.replace(/[^a-z0-9]/gi, '').toLowerCase() : ''; - const filename = [rawTitle || name, imdb, 'disc-design'].filter(Boolean).join('_') + '.json'; + const filename = [rawTitle || name, fileCategory, 'disc_design', imdb].filter(Boolean).join('_') + '.json'; triggerDownload(payload, filename); } @@ -4611,6 +5037,7 @@ const hasContent = state.discs.top?.src || state.discs.bottom?.src || state.combined?.src || Object.values(state.slots || {}).some(s => s?.src); if (!hasContent) { state.discs = { top: { src: '', fit: 'cover', zoom: 1, x: 0, y: 0, rotate: 0 }, bottom: { src: '', fit: 'cover', zoom: 1, x: 0, y: 0, rotate: 0 } }; + state.discMeta = normalizeDiscMeta(); state.slots = { back: { src: '', fit: 'cover', zoom: 1, x: 0, y: 0, rotate: 0 }, spine: { src: '', fit: 'cover', zoom: 1, x: 0, y: 0, rotate: 0 }, front: { src: '', fit: 'cover', zoom: 1, x: 0, y: 0, rotate: 0 } }; state.combined = { src: '' }; state.meta = { title: "", imdbId: "", omdbApiKey: "", category: "", language: "en" }; @@ -4622,6 +5049,7 @@ downloadProject(); } state.discs = { top: { src: '', fit: 'cover', zoom: 1, x: 0, y: 0, rotate: 0 }, bottom: { src: '', fit: 'cover', zoom: 1, x: 0, y: 0, rotate: 0 } }; + state.discMeta = normalizeDiscMeta(); state.slots = { back: { src: '', fit: 'cover', zoom: 1, x: 0, y: 0, rotate: 0 }, spine: { src: '', fit: 'cover', zoom: 1, x: 0, y: 0, rotate: 0 }, front: { src: '', fit: 'cover', zoom: 1, x: 0, y: 0, rotate: 0 } }; state.combined = { src: '' }; state.meta = { title: "", imdbId: "", omdbApiKey: "", category: "", language: "en" }; @@ -4672,13 +5100,13 @@ // v5+ disc-only: restore disc fields, leave cover and jewel data untouched if (v >= 4 && mode === 'disc' && !parsed.coverTemplate) { - const designs = parsed.discTemplate?.designs || {}; - const sheet = parsed.discTemplate?.sheet || {}; const spineLabels = parsed.discTemplate?.spineLabels || parsed.discSpineLabels || null; + const discMeta = parsed.discTemplate?.meta || parsed.discMeta || null; Object.assign(state, { templateMode: 'disc', meta: { ...state.meta, ...(parsed.meta || {}) }, - discs: { ...state.discs }, // backward compat: old designs/sheet merged + discs: { ...state.discs, ...(parsed.discTemplate?.discs || {}) }, + ...(discMeta ? { discMeta: normalizeDiscMeta(discMeta) } : {}), ...(spineLabels ? { discSpineLabels: { ...state.discSpineLabels, ...spineLabels } } : {}), layers: [ ...state.layers.filter(l => !String(l.target || '').startsWith('disc')), @@ -4687,6 +5115,7 @@ selectedLayerId: '' }); normalizeDiscState(); + syncProjectMetaFromDiscMeta(); syncInputs(); syncAllDiscInputs(); render(); return; } @@ -4733,6 +5162,7 @@ slots: parsed.coverTemplate?.slots || {}, combined: parsed.coverTemplate?.combined || {}, discs: parsed.discTemplate?.discs || {}, + discMeta: parsed.discTemplate?.meta || parsed.discMeta || state.discMeta, // backward compat: old discDesigns merged into discs below discSpineLabels: parsed.discTemplate?.spineLabels || parsed.discSpineLabels || state.discSpineLabels, jewelInserts: parsed.jewelTemplate?.inserts || parsed.jewelInserts || {}, @@ -4746,6 +5176,7 @@ slots: { ...state.slots, ...(source.slots || {}) }, combined: { ...state.combined, ...(source.combined || {}) }, discs: { ...state.discs, ...(source.discs || {}) }, + discMeta: normalizeDiscMeta(source.discMeta || state.discMeta), // backward compat handled in normalizeDiscState jewelInserts: { ...state.jewelInserts, ...(source.jewelInserts || {}) }, community: { ...state.community, ...(source.community || {}) }, @@ -4755,6 +5186,7 @@ Object.assign(state, next); normalizeDiscState(); normalizeJewelState(); + syncProjectMetaFromDiscMeta(); syncInputs(); syncAllSlotInputs(); syncAllDiscInputs(); @@ -4782,12 +5214,16 @@ _feedCache.loading = true; _feedCache.relayKey = relayKey; try { - const events = await queryRelays(relays, { kinds: [30078], '#t': ['casewrap'], limit: 200 }, { timeoutMs: 4500 }); + const [templateEvents, deletionEvents] = await Promise.all([ + queryRelays(relays, { kinds: [30078], '#t': ['casewrap'], limit: 200 }, { timeoutMs: 4500 }), + queryRelays(relays, { kinds: [5], '#t': ['casewrap'], limit: 100 }, { timeoutMs: 3500 }) + ]); + const events = [...templateEvents, ...deletionEvents]; _feedCache.events = events; _feedCache.reactions = {}; _feedCache.profiles = {}; - if (events.length) { - const ids = events.map(e => e.id); + if (templateEvents.length) { + const ids = templateEvents.map(e => e.id); const BATCH = 50; for (let i = 0; i < ids.length; i += BATCH) { const rxEvents = await queryRelays(relays, { kinds: [7], '#e': ids.slice(i, i + BATCH) }, { timeoutMs: 2500 }); @@ -4797,7 +5233,7 @@ } } // Fetch author profiles for all unique pubkeys - const pubkeys = [...new Set(events.map(e => e.pubkey).filter(Boolean))].slice(0, 100); + const pubkeys = [...new Set(templateEvents.map(e => e.pubkey).filter(Boolean))].slice(0, 100); for (let i = 0; i < pubkeys.length; i += BATCH) { const profileEvs = await queryRelays(relays, { kinds: [0], authors: pubkeys.slice(i, i + BATCH) }, { timeoutMs: 2500 }); for (const pe of profileEvs) { @@ -4817,10 +5253,43 @@ function buildRowsFromEvents(events, typeFilter = 'any', query = '', skipVersionFilter = false, categoryFilter = '', languageFilter = '') { const q = query.trim().toLowerCase(); const rows = []; - for (const e of events) { + const deletedEventIds = new Set(); + const deletedAddresses = new Set(); + const ordered = [...(events || [])].sort((a, b) => (b.created_at || 0) - (a.created_at || 0) || String(b.id || '').localeCompare(String(a.id || ''))); + const addressByEventId = new Map(); + + for (const e of ordered) { + if (e?.kind === 5) continue; let payload = null; try { payload = JSON.parse(e.content || '{}'); } catch { continue; } if (!skipVersionFilter && (payload.version ?? 0) < MIN_SUPPORTED_SCHEMA_VERSION) continue; + const address = templateEventAddress(e, payload); + if (address) addressByEventId.set(e.id, address); + } + + for (const e of ordered) { + if (e?.kind !== 5) continue; + for (const target of templateDeleteTargets(e)) { + if (target.type === 'event') { + deletedEventIds.add(target.value); + const inferredAddress = addressByEventId.get(target.value); + if (inferredAddress) deletedAddresses.add(inferredAddress); + } + if (target.type === 'address') deletedAddresses.add(target.value); + } + } + + const latestByAddress = new Map(); + for (const e of ordered) { + if (e.kind === 5) continue; + let payload = null; + try { payload = JSON.parse(e.content || '{}'); } catch { continue; } + if (!skipVersionFilter && (payload.version ?? 0) < MIN_SUPPORTED_SCHEMA_VERSION) continue; + const address = templateEventAddress(e, payload); + if (deletedEventIds.has(e.id) || (address && deletedAddresses.has(address)) || isDeletedTemplatePayload(payload)) { + if (address) deletedAddresses.add(address); + continue; + } const title = payload.meta?.title || payload.title || ''; const imdb = payload.meta?.imdbId || ''; const category = payload.meta?.category || payload.category || ''; @@ -4836,25 +5305,34 @@ const hay = `${displayTitle} ${title} ${imdb} ${category} ${language} ${(e.tags || []).map(t => t.join(' ')).join(' ')}`.toLowerCase(); if (!hay.includes(q)) continue; } - rows.push({ + const existing = address ? latestByAddress.get(address) : null; + const row = { e, payload, title: displayTitle || `(${mode})`, imdb, category, language, mode, + address, preview: previewFromPayload(payload, mode), reactions: _feedCache.reactions[e.id] || 0, createdAt: e.created_at || 0 - }); + }; + if (address) { + if (!existing) { + latestByAddress.set(address, row); + } + } else { + rows.push(row); + } } - return rows; + return [...latestByAddress.values(), ...rows]; } function deduplicateRows(rows) { const seen = new Map(); for (const row of rows) { - const key = row.imdb || row.title?.toLowerCase().trim() || ''; + const key = row.address || row.imdb || row.title?.toLowerCase().trim() || ''; if (!key) { seen.set(row.e.id, row); continue; } const existing = seen.get(key); if (!existing || row.reactions > existing.reactions) seen.set(key, row); @@ -4872,8 +5350,12 @@ const lbl = document.createElement('span'); lbl.className = 'feedCardThumbLabel'; lbl.textContent = modeLabel; - thumb.appendChild(lbl); - if (row.preview) { + if (row.mode === 'disc') { + buildDiscSheetThumb(thumb, row.payload, false); + } else { + thumb.appendChild(lbl); + } + if (row.preview && row.mode !== 'disc') { const img = document.createElement('img'); img.alt = ''; img.loading = 'lazy'; img.src = row.preview; img.addEventListener('error', () => img.remove()); @@ -4952,12 +5434,13 @@ wall.innerHTML = '
Loading your designs…
'; const relays = splitRelayList(state.community.relays); const events = await queryRelays(relays, { - kinds: [30078], '#t': ['casewrap'], + kinds: [30078, 5], '#t': ['casewrap'], authors: [state.community.myPubkey], limit: 100 }, { timeoutMs: 4500 }); // Fetch reactions for user's own events - if (events.length) { - const ids = events.map(e => e.id); + const templateEvents = events.filter((event) => event.kind === 30078); + if (templateEvents.length) { + const ids = templateEvents.map(e => e.id); const rxEvents = await queryRelays(relays, { kinds: [7], '#e': ids }, { timeoutMs: 2500 }); for (const rx of rxEvents) { const tag = (rx.tags || []).find(t => t[0] === 'e' && t[1]); @@ -5122,6 +5605,64 @@ const MODE_LABELS = { cover: 'Case cover', disc: 'Disc sheet', jewel: 'CD jewel inserts', 'disc-design': 'Disc design' }; + function buildDiscSheetThumb(thumb, payload, compact = false) { + const slots = payload?.discTemplate?.discs || payload?.discs || {}; + const meta = payload?.discTemplate?.meta || payload?.discMeta || {}; + const slotDefs = [ + { key: 'top', fallback: 'Disk 1' }, + { key: 'bottom', fallback: 'Disk 2' } + ]; + + thumb.innerHTML = ''; + thumb.style.display = 'grid'; + thumb.style.gridTemplateColumns = '1fr 1fr'; + thumb.style.gap = compact ? '4px' : '6px'; + thumb.style.alignItems = 'center'; + thumb.style.boxSizing = 'border-box'; + thumb.style.padding = compact ? '6px' : '8px'; + thumb.style.position = 'relative'; + + for (const slotDef of slotDefs) { + const wrap = document.createElement('div'); + wrap.style.cssText = 'display:flex;flex-direction:column;align-items:center;gap:4px;min-width:0;justify-content:center;'; + + const disc = document.createElement('div'); + disc.style.cssText = [ + 'width:100%', + 'aspect-ratio:1', + 'border-radius:50%', + 'overflow:hidden', + 'position:relative', + 'background:#e2e8f0', + 'border:1px solid rgba(148,163,184,.65)' + ].join(';'); + + const img = document.createElement('img'); + img.alt = ''; + img.loading = 'lazy'; + img.src = slots[slotDef.key]?.src || ''; + img.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;object-fit:cover;'; + img.addEventListener('error', () => img.remove()); + if (img.src) disc.appendChild(img); + + const label = document.createElement('div'); + label.style.cssText = 'font-size:.64rem;font-weight:700;line-height:1.1;text-align:center;color:#64748b;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;width:100%;'; + label.textContent = meta?.[slotDef.key]?.title || slotDef.fallback; + + wrap.append(disc, label); + thumb.appendChild(wrap); + } + + const badge = document.createElement('span'); + badge.className = 'feedCardThumbLabel'; + badge.textContent = 'Disc sheet'; + badge.style.position = 'absolute'; + badge.style.left = '8px'; + badge.style.top = '8px'; + badge.style.zIndex = '2'; + thumb.appendChild(badge); + } + function buildResultCard(row, onUse) { const el = document.createElement('div'); el.className = 'resultItem'; @@ -5132,7 +5673,13 @@ const thumb = document.createElement('div'); thumb.className = isDisc ? 'resultThumb resultThumbDisc' : 'resultThumb'; - if (row.preview) { + if (row.mode === 'disc') { + thumb.className = 'resultThumb resultThumbDisc'; + thumb.style.width = '110px'; + thumb.style.height = '78px'; + thumb.style.borderRadius = '6px'; + buildDiscSheetThumb(thumb, row.payload, true); + } else if (row.preview) { const img = document.createElement('img'); img.alt = ''; img.loading = 'lazy'; @@ -5141,10 +5688,12 @@ thumb.appendChild(img); } - const fallback = document.createElement('span'); - fallback.className = 'resultThumbFallback'; - fallback.textContent = modeLabel; - thumb.appendChild(fallback); + if (row.mode !== 'disc') { + const fallback = document.createElement('span'); + fallback.className = 'resultThumbFallback'; + fallback.textContent = modeLabel; + thumb.appendChild(fallback); + } const meta = document.createElement('div'); const sub = [row.imdb, modeLabel, categoryLabel(row.category)].filter(Boolean).join(' · '); @@ -5179,7 +5728,8 @@ content._row = row; const p = row.payload || {}; - const isDiscDesign = !!p.discDesign && !p.slots; + const mode = normalizeTemplateMode(p); + const isDiscDesign = mode === 'disc-design'; // Clear previously injected dynamic buttons, keep Save JSON const actionsDiv = $('nostrPreviewActions'); @@ -5217,25 +5767,31 @@ actionsDiv.insertBefore(import1Btn, saveJsonBtn); actionsDiv.insertBefore(import2Btn, saveJsonBtn); + addPreviewDeleteButton(actionsDiv, row, saveJsonBtn); } else { const printBtn = document.createElement('button'); printBtn.type = 'button'; printBtn.textContent = 'Print / Save PDF'; printBtn.addEventListener('click', () => { const stateSnapshot = JSON.parse(JSON.stringify(state)); + const previousTitle = document.title; + const pdfStem = previewSaveStem(p, row); try { loadProjectFromText(JSON.stringify(p)); } catch(e) { console.error(e); } closeNostrPreview(); $('nostrModal').classList.remove('open'); function onAfterPrint() { window.removeEventListener('afterprint', onAfterPrint); + document.title = previousTitle; Object.assign(state, stateSnapshot); syncInputs(); render(); } window.addEventListener('afterprint', onAfterPrint); + document.title = pdfStem || currentPdfTitle(); setTimeout(() => { render(); window.print(); }, 350); }); actionsDiv.insertBefore(printBtn, saveJsonBtn); + addPreviewDeleteButton(actionsDiv, row, saveJsonBtn); } // Switch modal into preview mode @@ -5252,8 +5808,10 @@ if (rd) rd.style.display = 'none'; content.style.display = ''; - if (p.discDesign) { + if (mode === 'disc-design') { renderDiscPreviewDOM(content, p); + } else if (mode === 'disc') { + renderDiscSheetPreviewDOM(content, p); } else { renderTemplatePreviewDOM(content, p); } @@ -5504,6 +6062,253 @@ container.appendChild(row); } + function renderDiscSheetPreviewDOM(container, payload) { + const template = payload.discTemplate || {}; + const slots = template.discs || payload.discs || {}; + const meta = template.meta || payload.discMeta || {}; + const sheetW = Number(template.config?.sheetW) || discTemplate.sheetW; + const sheetH = Number(template.config?.sheetH) || discTemplate.sheetH; + const discD = Number(template.config?.discD) || discTemplate.discD; + const holeD = Number(template.config?.holeD) || discTemplate.holeD; + const slotLayers = (slotKey) => { + const slot = slots[slotKey] || {}; + if (Array.isArray(slot.layers) && slot.layers.length) return slot.layers; + const tag = slotKey === 'top' ? 'disc-top' : 'disc-bottom'; + return Array.isArray(payload.layers) + ? payload.layers.filter((layer) => layer.target === tag || layer.target === 'designer-disc') + : []; + }; + + const renderDiscLayer = (wrap, disc, layer, layerScale) => { + const el = document.createElement('div'); + const lx = (Number(layer.x) || 0) / layerScale * 100; + const ly = (Number(layer.y) || 0) / layerScale * 100; + const lw = (Number(layer.w) || layerScale) / layerScale * 100; + const lh = (Number(layer.h) || layerScale) / layerScale * 100; + el.style.cssText = [ + 'position:absolute', + `left:${lx}%`, + `top:${ly}%`, + `width:${lw}%`, + `height:${lh}%`, + `transform:rotate(${Number(layer.rotate) || 0}deg)`, + `opacity:${layer.opacity !== undefined ? layer.opacity : 1}`, + 'overflow:hidden', + 'pointer-events:none', + 'box-sizing:border-box', + 'z-index:3' + ].join(';'); + + if (layer.type === 'text') { + const scale = wrap.clientWidth && layerScale ? (wrap.clientWidth / (layerScale * 96)) : 1; + const align = layer.align || 'left'; + el.style.cssText += `color:${layer.color || '#fff'};font-size:${((layer.fontSize || 14) * (96 / 72) * scale)}px;font-weight:${layer.bold ? '800' : '400'};font-style:${layer.italic ? 'italic' : 'normal'};text-align:${align};display:flex;align-items:flex-start;justify-content:${align === 'center' ? 'center' : align === 'right' ? 'flex-end' : 'flex-start'};`; + el.innerHTML = layer.html || ''; + } else if (layer.type === 'image' && layer.src) { + const img = document.createElement('img'); + img.src = layer.src; + img.alt = layer.name || ''; + img.style.cssText = 'position:absolute;left:50%;top:50%;transform-origin:center center;user-select:none;pointer-events:none;display:block;'; + el.appendChild(img); + + const update = () => { + const cW = el.clientWidth; + const cH = el.clientHeight; + if (!cW || !cH) return; + const fit = layer.fit || 'contain'; + const nW = img.naturalWidth || cW; + const nH = img.naturalHeight || cH; + let baseW = cW; + let baseH = cH; + if (fit === 'fill') { + baseW = cW; + baseH = cH; + } else if (fit === 'none') { + baseW = nW; + baseH = nH; + } else { + const fitScale = fit === 'contain' + ? Math.min(cW / nW, cH / nH) + : Math.max(cW / nW, cH / nH); + baseW = nW * fitScale; + baseH = nH * fitScale; + } + const rotate = Number(layer.rotate) || 0; + const zoom = Number(layer.zoom) || 1; + const rad = rotate * Math.PI / 180; + const ac = Math.abs(Math.cos(rad)); + const as = Math.abs(Math.sin(rad)); + const rotatedW = (baseW * ac) + (baseH * as); + const rotatedH = (baseW * as) + (baseH * ac); + const coverScale = fit === 'cover' + ? Math.max(cW / Math.max(rotatedW, 1), cH / Math.max(rotatedH, 1), 1) + : 1; + const finalScale = coverScale * zoom; + const panRangeX = Math.max(0, (rotatedW * finalScale - cW) / 2); + const panRangeY = Math.max(0, (rotatedH * finalScale - cH) / 2); + const xPx = ((Number(layer.x) || 0) / 100) * panRangeX; + const yPx = ((Number(layer.y) || 0) / 100) * panRangeY; + img.style.width = `${baseW}px`; + img.style.height = `${baseH}px`; + img.style.transform = `translate(-50%, -50%) translate(${xPx}px, ${yPx}px) rotate(${rotate}deg) scale(${finalScale})`; + }; + + if (img.complete) requestAnimationFrame(update); + img.addEventListener('load', update, { once: true }); + window.addEventListener('resize', update, { once: true }); + } + + wrap.appendChild(el); + }; + + const renderDiscArt = (wrap, disc, layers) => { + if (disc.src) { + const img = document.createElement('img'); + img.src = disc.src; + img.alt = ''; + img.style.cssText = 'position:absolute;left:50%;top:50%;width:100%;height:100%;object-fit:fill;transform-origin:center center;user-select:none;pointer-events:none;z-index:1;'; + wrap.appendChild(img); + + const update = () => { + const fit = disc.fit || 'cover'; + const cW = wrap.clientWidth; + const cH = wrap.clientHeight; + if (!cW || !cH) return; + const nW = img.naturalWidth || cW; + const nH = img.naturalHeight || cH; + let baseW = cW; + let baseH = cH; + if (fit === 'fill') { + baseW = cW; + baseH = cH; + } else if (fit === 'none') { + baseW = nW; + baseH = nH; + } else { + const fitScale = fit === 'contain' + ? Math.min(cW / nW, cH / nH) + : Math.max(cW / nW, cH / nH); + baseW = nW * fitScale; + baseH = nH * fitScale; + } + const rotate = Number(disc.rotate) || 0; + const zoom = Number(disc.zoom) || 1; + const rad = rotate * Math.PI / 180; + const ac = Math.abs(Math.cos(rad)); + const as = Math.abs(Math.sin(rad)); + const rotatedW = (baseW * ac) + (baseH * as); + const rotatedH = (baseW * as) + (baseH * ac); + const coverScale = fit === 'cover' + ? Math.max(cW / Math.max(rotatedW, 1), cH / Math.max(rotatedH, 1), 1) + : 1; + const finalScale = coverScale * zoom; + const panRangeX = Math.max(0, (rotatedW * finalScale - cW) / 2); + const panRangeY = Math.max(0, (rotatedH * finalScale - cH) / 2); + const xPx = ((Number(disc.x) || 0) / 100) * panRangeX; + const yPx = ((Number(disc.y) || 0) / 100) * panRangeY; + img.style.width = `${baseW}px`; + img.style.height = `${baseH}px`; + img.style.transform = `translate(-50%, -50%) translate(${xPx}px, ${yPx}px) rotate(${rotate}deg) scale(${finalScale})`; + }; + + if (img.complete) requestAnimationFrame(update); + img.addEventListener('load', update, { once: true }); + window.addEventListener('resize', update, { once: true }); + } + + layers + .slice() + .sort((a, b) => (a.z || 0) - (b.z || 0)) + .forEach((layer) => renderDiscLayer(wrap, disc, layer, discD)); + }; + + const sheet = document.createElement('div'); + sheet.style.cssText = [ + 'position:relative', + 'width:min(100%, 720px)', + `aspect-ratio:${sheetW} / ${sheetH}`, + 'margin:0 auto', + 'background:#fff', + 'border:1px solid var(--line)', + 'overflow:hidden', + 'box-shadow:0 2px 10px rgba(15,23,42,.12)' + ].join(';'); + + const addDisc = (slotKey) => { + const slot = discTemplate.slots.find((item) => item.key === slotKey); + if (!slot) return; + const wrap = document.createElement('div'); + wrap.style.cssText = [ + 'position:absolute', + `left:${((slot.centerX - (discD / 2)) / sheetW) * 100}%`, + `top:${((slot.centerY - (discD / 2)) / sheetH) * 100}%`, + `width:${(discD / sheetW) * 100}%`, + `height:${(discD / sheetH) * 100}%`, + 'border-radius:50%', + 'overflow:hidden', + 'background:#111827', + 'border:1px solid rgba(17,24,39,.2)', + 'box-sizing:border-box' + ].join(';'); + + const disc = slots[slotKey] || {}; + renderDiscArt(wrap, disc, slotLayers(slotKey)); + + const hole = document.createElement('div'); + hole.style.cssText = [ + 'position:absolute', + 'left:50%', + 'top:50%', + `width:${((holeD / discD) * 100).toFixed(4)}%`, + `height:${((holeD / discD) * 100).toFixed(4)}%`, + 'border-radius:50%', + 'background:#fff', + 'border:1px solid rgba(17,24,39,.24)', + 'transform:translate(-50%,-50%)', + 'z-index:12', + 'pointer-events:none' + ].join(';'); + wrap.appendChild(hole); + + const label = document.createElement('div'); + label.style.cssText = [ + 'position:absolute', + 'left:50%', + 'bottom:-1.25rem', + 'transform:translateX(-50%)', + 'font-size:.72rem', + 'font-weight:700', + 'color:#64748b', + 'white-space:nowrap' + ].join(';'); + label.textContent = meta?.[slotKey]?.title || (slotKey === 'top' ? 'Disk 1' : 'Disk 2'); + wrap.appendChild(label); + + sheet.appendChild(wrap); + }; + + addDisc('top'); + addDisc('bottom'); + + const sheetLabel = document.createElement('div'); + sheetLabel.style.cssText = [ + 'position:absolute', + 'left:0', + 'right:0', + 'top:8px', + 'text-align:center', + 'font-size:.72rem', + 'font-weight:700', + 'color:#94a3b8', + 'pointer-events:none', + 'z-index:3' + ].join(';'); + sheetLabel.textContent = 'Disc sheet'; + sheet.appendChild(sheetLabel); + + container.appendChild(sheet); + } + async function runNostrSearch() { const q = $('nostrQuery').value.trim(); const type = $('nostrType').value; @@ -5564,6 +6369,7 @@ const { key: _k, layers: payloadLayers, ...design } = payload.discDesign; if (isSlotImport) { state.discs[slotKey] = { ...state.discs[slotKey], ...design }; + state.discMeta[slotKey] = { ...state.discMeta[slotKey], ...discSlotMetaFromPayload(payload, slotKey, design) }; const layersToUse = Array.isArray(payloadLayers) ? payloadLayers : (Array.isArray(payload.layers) ? payload.layers : []); if (layersToUse.length > 0) { state.discs[slotKey].layers = layersToUse.map(l => ({ ...l })); @@ -5584,6 +6390,7 @@ const { key: _k2, layers: payloadLayers2, ...design } = payload.discDesign; if (isSlotImport) { state.discs[slotKey] = { ...state.discs[slotKey], ...design }; + state.discMeta[slotKey] = { ...state.discMeta[slotKey], ...discSlotMetaFromPayload(payload, slotKey, design) }; const layersToUse2 = Array.isArray(payloadLayers2) ? payloadLayers2 : (Array.isArray(payload.layers) ? payload.layers : []); if (layersToUse2.length > 0) { state.discs[slotKey].layers = layersToUse2.map(l => ({ ...l })); @@ -5599,6 +6406,7 @@ } } else if (payload?.discTemplate?.discs?.[slotKey]) { state.discs[slotKey] = payload.discTemplate.discs[slotKey]; + state.discMeta[slotKey] = { ...state.discMeta[slotKey], ...discSlotMetaFromPayload(payload, slotKey, payload.discTemplate.discs[slotKey]) }; const preview = $(`disc-preview-${slotKey}`); if (preview) preview.textContent = 'Loaded from project'; } @@ -5612,6 +6420,7 @@ if (!isSlotImport) { state.templateMode = 'disc'; state.appMode = 'template'; } buildDiscImportUI(); } + syncAllDiscInputs(); render(); } @@ -5849,16 +6658,14 @@ }); $('printBtn').addEventListener('click', () => { - render(); - window.print(); + printWithCurrentFilename(); }); // Preview modal event listeners $('previewSaveJsonBtn').addEventListener('click', () => { const row = $('nostrPreviewPanel')._row; if (!row) return; - const name = (row.title || 'design').replace(/[^a-z0-9]/gi, '_'); - triggerDownload(row.payload, `${name}.json`); + triggerDownload(row.payload, `${previewSaveStem(row.payload, row)}.json`); }); $('saveProjectBtn').addEventListener('click', downloadProject);