Files
Artstr-Studio/src/index.html
T
thePR0M3TH3ANandClaude Sonnet 4.6 4d2d4e7296 Update preview modal with design-type-specific buttons
- For disc designs: show 'Import into Disk 1' and 'Import into Disk 2' buttons
- For templates: show 'Print / Save PDF' button
- Buttons are built dynamically based on design type
- Disc imports automatically switch to template mode
- Both designs still have 'Save JSON' option

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-15 15:34:40 -04:00

5768 lines
211 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>CaseWrap Studio</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&family=VT323&display=block" rel="stylesheet">
<style id="dynamicPageStyle">
@page { size: 11in 8.5in; margin: 0; }
body.dark {
--bg:#0a0e27; --panel:#1a1f3a; --ink:#e0f7ff; --muted:#6ba3d0; --line:#2a3f5f; --accent:#00d9ff;
background-image: none;
}
body.dark aside, body.dark fieldset, body.dark details, body.dark .pill, body.dark .previewShell { background: var(--panel); color: var(--ink); border-color: var(--line); }
body.dark .previewShell { background:#0f1622; box-shadow: inset 0 1px 0 rgba(255,255,255,.03); }
body.dark input[type="number"], body.dark input[type="text"], body.dark input[type="url"], body.dark textarea, body.dark select, body.dark button { background:#0b1220; color:var(--ink); border-color:var(--line); }
body.dark .hint, body.dark label, body.dark .subhead, body.dark .status { color: var(--muted); }
body.dark .sheet{filter:contrast(1.08) saturate(1.15) brightness(.95);}
body.dark .layoutTab.active, body.dark button.primary { color:#fff; }
.sheetWrap{margin:0 auto;}
</style>
<style>
:root {
--bg: #e8f4f8;
--noise: none;
--panel: #f0fbff;
--ink: #0a2540;
--muted: #4a7ba7;
--line: #b8d9eb;
--accent: #00b8e6;
--warn: #ff006e;
--ok: #00d084;
--shadow: 0 4px 8px rgba(0, 88, 150, .15);
--sheet-w: 10.0in;
--sheet-h: 7.5in;
--panel-w: 5in;
--trim-h: 7in;
--spine-w: .5in;
--bleed: .125in;
--safe: .125in;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
min-height: 100%;
background: var(--bg);
color: var(--ink);
font-family: 'VT323', 'Courier New', monospace;
letter-spacing: 0.05em;
scrollbar-color: var(--accent) var(--panel);
scrollbar-width: 12px;
}
/* Webkit scrollbar styling */
::-webkit-scrollbar {
width: 12px;
height: 12px;
}
::-webkit-scrollbar-track {
background: var(--panel);
border: 2px solid var(--line);
}
::-webkit-scrollbar-thumb {
background: var(--accent);
border: 2px solid var(--accent);
box-shadow: inset 0 0 0 2px var(--panel);
}
::-webkit-scrollbar-thumb:hover {
background: #00ffff;
box-shadow: inset 0 0 0 2px var(--panel), 0 0 8px rgba(0, 217, 255, 0.6);
}
button, input, select, textarea {
font: inherit;
}
.app {
display: grid;
grid-template-columns: minmax(340px, 420px) 1fr;
min-height: 100vh;
}
aside {
background: var(--panel);
border-right: 1px solid var(--line);
padding: 20px;
overflow-y: auto;
max-height: 100vh;
position: sticky;
top: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
main {
min-width: 0;
padding: 26px;
}
.appHeader {
display: flex;
flex-direction: column;
gap: 4px;
}
.appLogoRow {
display: flex;
align-items: center;
gap: 8px;
}
.appLogo {
height: 28px;
width: auto;
}
h1 {
font-size: 2.2rem !important;
line-height: 1;
margin: 0;
font-family: 'Press Start 2P' !important;
font-weight: normal !important;
letter-spacing: 0.12em;
white-space: nowrap;
color: #00d9ff;
}
h2 {
font-size: 1rem;
line-height: 1.2;
margin: 0 0 6px;
font-family: 'VT323', 'Courier New', monospace;
font-weight: normal;
letter-spacing: 0.05em;
color: var(--ink);
}
.appTagline {
font-size: 0.55rem;
letter-spacing: 0.15em;
color: #00d9ff;
text-align: center;
font-family: 'VT323', 'Courier New', monospace;
font-weight: normal;
margin: 0;
line-height: 1;
}
.subhead {
color: var(--muted);
font-size: .9rem;
line-height: 1.4;
margin: 0;
}
fieldset {
border: 2px solid var(--line);
border-radius: 0px;
margin: 0;
padding: 14px;
background: #fff;
box-shadow: 2px 2px 0px rgba(0, 0, 0, 0.1);
}
legend {
padding: 0 6px;
font-weight: normal;
font-size: .9rem;
font-family: 'VT323', 'Courier New', monospace;
color: #1a1a1a;
letter-spacing: 0.05em;
}
label {
display: block;
font-size: .85rem;
color: #1a1a1a;
margin: 10px 0 5px;
font-weight: normal;
font-family: 'VT323', 'Courier New', monospace;
letter-spacing: 0.05em;
}
.row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
input[type="number"], input[type="text"], input[type="url"], textarea, select {
width: 100%;
border: 2px solid var(--line);
border-radius: 0px;
padding: 8px 10px;
background: #fff;
color: var(--ink);
}
input[type="range"] {
width: 100%;
height: 6px;
accent-color: #00d9ff;
cursor: pointer;
}
input[type="range"]::-webkit-slider-thumb {
appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: #00d9ff;
cursor: pointer;
border: 2px solid #00d9ff;
}
input[type="range"]::-webkit-slider-runnable-track {
background: var(--line);
height: 6px;
border-radius: 3px;
}
input[type="range"]::-moz-range-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
background: #00d9ff;
cursor: pointer;
border: 2px solid #00d9ff;
}
input[type="range"]::-moz-range-track {
background: var(--line);
height: 6px;
border-radius: 3px;
border: none;
}
input[type="checkbox"] {
width: 20px;
height: 20px;
cursor: pointer;
accent-color: #00d9ff;
}
input[type="radio"] {
width: 20px;
height: 20px;
cursor: pointer;
accent-color: #00d9ff;
}
textarea {
min-height: 84px;
resize: vertical;
}
.hint {
color: var(--muted);
font-size: .75rem;
line-height: 1.5;
margin-top: 7px;
font-family: 'VT323', 'Courier New', monospace;
letter-spacing: 0.05em;
}
.buttonRow {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
button {
border: 2px solid var(--line);
border-radius: 0px;
padding: 8px 11px;
background: #fff;
color: var(--ink);
cursor: pointer;
font-weight: normal;
font-size: .8rem;
font-family: 'VT323', 'Courier New', monospace;
letter-spacing: 0.05em;
box-shadow: 1px 1px 0px rgba(0, 0, 0, 0.1);
}
button.primary {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
button.danger {
color: var(--warn);
border-color: #fecaca;
background: #fffafa;
}
button:hover { filter: brightness(.98); }
.layoutTabs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.layoutTab {
border-radius: 0px;
padding: 10px;
}
.layoutTab.active {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.toggles {
display: grid;
gap: 8px;
margin-top: 8px;
}
.toggle {
display: flex;
align-items: center;
gap: 9px;
font-size: .85rem;
color: #374151;
font-weight: 600;
}
.toggle input { margin: 0; }
details {
border: 1px solid var(--line);
border-radius: 0px;
padding: 10px 12px;
margin-top: 10px;
background: #fafafa;
}
summary {
cursor: pointer;
font-weight: 800;
font-size: .86rem;
}
.appTitle {
display: grid;
gap: 5px;
margin-bottom: 2px;
}
.quickActions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin: 2px 0 4px;
}
.workflowStep legend::before {
content: attr(data-step);
display: inline-grid;
place-items: center;
width: 22px;
height: 22px;
margin-right: 7px;
border-radius: 0px;
background: #eef2ff;
color: var(--accent);
font-size: .74rem;
font-weight: 850;
}
.secondaryPanel summary[data-step]::before {
content: attr(data-step);
display: inline-grid;
place-items: center;
width: 22px;
height: 22px;
margin-right: 7px;
border-radius: 0px;
background: #eef2ff;
color: var(--accent);
font-size: .74rem;
font-weight: 850;
}
.secondaryPanel {
background: #fbfcfe;
}
.compactDetails {
margin: 0;
border: 0;
padding: 0;
background: transparent;
}
.compactDetails > summary {
list-style: none;
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
color: var(--ink);
}
.compactDetails > summary::-webkit-details-marker {
display: none;
}
.compactDetails > summary::after {
content: "Open";
color: var(--muted);
font-size: .72rem;
font-weight: 700;
}
.compactDetails[open] > summary::after {
content: "Close";
}
#layoutFieldset { order: 10; }
#coverSizeFieldset { order: 20; }
#coverArtworkFieldset,
#discArtworkFieldset,
#jewelArtworkFieldset { order: 30; }
#designerFieldset { order: 15; }
#layersFieldset { order: 40; }
#designerMetadataFieldset { order: 45; }
#exportFieldset { order: 50; }
#printSaveFieldset { order: 75; }
#designerPrintSaveFieldset { order: 50; }
#metadataFieldset { order: 70; }
#communityFieldset { order: 70; }
aside > details { order: 90; }
.appModeTabs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.appModeTab {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
font-size: 1.15rem;
line-height: 1.2;
padding: 12px 10px;
border-radius: 0px;
border: none;
background: transparent;
font-variant-numeric: tabular-nums;
text-shadow: 0 0.5px 0 rgba(0,0,0,.04);
}
.appModeTab.active {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.appModeTab svg, .appModeTab img {
width: 40px;
height: 40px;
opacity: .75;
vertical-align: middle;
}
.appModeTab.active svg, .appModeTab.active img {
opacity: 1;
}
body.dark .appModeTab.active { color: #fff; }
body.app-designer .template-only { display: none !important; }
body.app-template .designer-only { display: none !important; }
body.app-designer label[for="layerTarget"],
body.app-designer #layerTarget { display: none !important; }
/* Layers panel hides in Template tab when in disc-sheet mode — disc layers live in the Designer */
body.app-template.template-disc #layersFieldset { display: none !important; }
/* Designer single-disc preview */
body.app-designer .sheetWrap { display: none !important; }
#discDesignerView {
display: none;
flex-direction: column;
justify-content: center;
align-items: center;
min-height: calc(100vh - 112px);
gap: 16px;
padding: 24px;
}
body.app-designer #discDesignerView { display: flex; }
.designerDiscWrap {
position: relative;
width: 380px;
height: 380px;
border-radius: 50%;
background: #f8fafc;
border: 2px solid rgba(17,24,39,.18);
overflow: hidden;
box-shadow: 0 18px 50px rgba(15,23,42,.15);
flex: 0 0 auto;
}
.designerDiscHole {
position: absolute;
left: 50%; top: 50%;
width: 57px; height: 57px;
border-radius: 50%;
transform: translate(-50%,-50%);
background: rgba(255,255,255,.9);
border: 2px solid rgba(17,24,39,.25);
z-index: 12;
pointer-events: none;
}
body.hide-guides .designerDiscHole { border-color: transparent; }
/* Push placeholder text above the centre hole — both disc sheet slots and designer preview */
#designerPlaceholder,
.discSlot .placeholder { padding-bottom: 30%; }
/* Spine slot is too narrow for placeholder text — hide it, keep the checkered bg */
.slot.spine .placeholder { font-size: 0; }
.designerDiscLabel {
font-size: .86rem;
color: var(--muted);
font-weight: 700;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
body.dark .designerDiscWrap { background: #121821; border-color: #2c3443; }
body.dark .designerDiscHole { background: rgba(18,24,33,.9); border-color: #2c3443; }
.topbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 14px;
margin-bottom: 18px;
padding-right: 210px;
}
.status {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
color: var(--muted);
font-size: .86rem;
}
.pill {
border: 1px solid var(--line);
background: #fff;
border-radius: 0px;
padding: 7px 10px;
box-shadow: 0 1px 2px rgba(15, 23, 42, .04);
transition: opacity .15s;
}
.pill:disabled {
opacity: .7;
cursor: wait;
}
.pill .spinner {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid var(--line);
border-top-color: var(--accent);
border-radius: 50%;
margin-right: 6px;
animation: spin .55s linear infinite;
vertical-align: -2px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.previewShell {
background: #e5e7eb;
border: 1px solid #cbd5e1;
border-radius: 0px;
padding: 24px;
overflow: auto;
min-height: calc(100vh - 112px);
box-shadow: inset 0 1px 0 rgba(255,255,255,.7);
}
.sheetWrap {
transform-origin: top left;
transition: transform .12s ease;
}
.sheet {
position: relative;
width: var(--sheet-w);
height: var(--sheet-h);
background: #fff;
box-shadow: var(--shadow);
overflow: visible;
isolation: isolate;
}
.trimArea {
position: absolute;
left: var(--bleed);
top: var(--bleed);
width: calc((var(--panel-w) * 2) + var(--spine-w));
height: var(--trim-h);
outline: 1px solid rgba(220, 38, 38, .75);
pointer-events: none;
z-index: 20;
}
.slot {
position: absolute;
top: var(--bleed);
height: var(--trim-h);
overflow: hidden;
background: #f8fafc;
border: 1px solid rgba(17, 24, 39, .14);
z-index: 3;
}
.slot.back {
left: var(--bleed);
width: var(--panel-w);
}
.slot.spine {
left: calc(var(--bleed) + var(--panel-w));
width: var(--spine-w);
}
.slot.front {
left: calc(var(--bleed) + var(--panel-w) + var(--spine-w));
width: var(--panel-w);
}
.slot::before {
content: "";
position: absolute;
inset: var(--safe);
border: 1px dashed rgba(37, 99, 235, .85);
z-index: 12;
pointer-events: none;
display: var(--safe-display, block);
}
.slot::after {
content: attr(data-label);
position: absolute;
left: 8px;
top: 7px;
background: rgba(255,255,255,.85);
border: 1px solid rgba(17,24,39,.13);
border-radius: 0px;
padding: 3px 7px;
font-size: 10px;
color: #334155;
font-weight: 800;
z-index: 13;
display: var(--label-display, block);
}
.placeholder {
position: absolute;
inset: 0;
display: grid;
place-items: center;
padding: 12px;
text-align: center;
color: #94a3b8;
font-weight: 800;
font-size: 13px;
line-height: 1.2;
background-image:
linear-gradient(45deg, rgba(148,163,184,.16) 25%, transparent 25%),
linear-gradient(-45deg, rgba(148,163,184,.16) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, rgba(148,163,184,.16) 75%),
linear-gradient(-45deg, transparent 75%, rgba(148,163,184,.16) 75%);
background-size: 18px 18px;
background-position: 0 0, 0 9px, 9px -9px, -9px 0px;
}
.art {
position: absolute;
left: 50%;
top: 50%;
width: 100%;
height: 100%;
object-fit: cover;
object-position: center center;
transform-origin: center center;
z-index: 5;
user-select: none;
pointer-events: none;
}
.combinedLayer {
position: absolute;
z-index: 4;
overflow: hidden;
background: transparent;
}
.combinedArt {
position: absolute;
left: 50%;
top: 50%;
width: 100%;
height: 100%;
object-fit: cover;
object-position: center center;
transform-origin: center center;
z-index: 5;
pointer-events: none;
}
.combinedPlaceholder {
position: absolute;
inset: 0;
z-index: 6;
}
body.mode-separate .combinedLayer {
display: none;
}
body.mode-combined .combinedLayer {
display: block;
}
body.mode-combined .slot .art,
body.mode-combined .slot .placeholder {
display: none !important;
}
body.mode-combined .slot {
background: transparent;
}
.diskLayer {
position: absolute;
inset: 0;
display: none;
z-index: 10;
}
body.template-disc .diskLayer {
display: block;
}
body.template-disc .coverOnly,
body.template-disc #coverSizeFieldset,
body.template-disc #coverArtworkFieldset {
display: none !important;
}
body.template-jewel .coverOnly,
body.template-jewel #coverArtworkFieldset,
body.template-jewel #discArtworkFieldset {
display: none !important;
}
body.template-cover #discArtworkFieldset,
body.template-cover #jewelArtworkFieldset,
body.template-disc #jewelArtworkFieldset {
display: none !important;
}
/* Export toggles: fold/safe/trim/bleed/crops only apply to case covers */
body.template-disc .cover-toggle,
body.template-jewel .cover-toggle {
display: none !important;
}
/* Layers: disc position targeting only matters in disc mode */
body.template-jewel .layer-controls,
body.template-cover .layer-controls {
display: none !important;
}
.discSlot {
position: absolute;
overflow: hidden;
border-radius: 50%;
background: #f8fafc;
border: 1px solid rgba(17, 24, 39, .18);
z-index: 10;
}
.discSlot::before {
content: attr(data-label);
position: absolute;
left: 50%;
top: 12px;
transform: translateX(-50%);
background: rgba(255,255,255,.85);
border: 1px solid rgba(17,24,39,.13);
border-radius: 0px;
padding: 3px 7px;
font-size: 10px;
color: #334155;
font-weight: 800;
z-index: 14;
display: var(--label-display, block);
}
.discHole {
position: absolute;
left: 50%;
top: 50%;
width: .69in;
height: .69in;
border-radius: 50%;
transform: translate(-50%, -50%);
background: #fff;
border: 1px solid rgba(17, 24, 39, .28);
z-index: 12;
pointer-events: none;
}
body.hide-guides .discHole {
border-color: transparent;
}
.discSpineStrip {
position: absolute;
border: 1px solid rgba(17, 24, 39, .28);
border-radius: .08in;
background: transparent;
z-index: 11;
pointer-events: none;
display: var(--guide-display, block);
}
.discSpineLabel {
position: absolute;
z-index: 13;
pointer-events: none;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.discSpineLabel span {
writing-mode: vertical-rl;
transform: rotate(180deg);
font-size: 6pt;
font-weight: 700;
color: rgba(15, 23, 42, .82);
letter-spacing: .04em;
white-space: nowrap;
max-height: 4.2in;
overflow: hidden;
}
.jewelLayer {
position: absolute;
inset: 0;
display: none;
z-index: 10;
}
body.template-jewel .jewelLayer {
display: block;
}
.jewelSlot {
position: absolute;
overflow: hidden;
background: #f8fafc;
border: 1px solid rgba(17, 24, 39, .18);
z-index: 10;
}
.jewelSlot::before {
content: attr(data-label);
position: absolute;
left: 8px;
top: 7px;
background: rgba(255,255,255,.85);
border: 1px solid rgba(17,24,39,.13);
border-radius: 0px;
padding: 3px 7px;
font-size: 10px;
color: #334155;
font-weight: 800;
z-index: 14;
display: var(--label-display, block);
}
.jewelSlot::after {
content: "";
position: absolute;
inset: .125in;
border: 1px dashed rgba(37, 99, 235, .85);
z-index: 13;
pointer-events: none;
display: var(--safe-display, block);
}
.jewelSpineGuide {
position: absolute;
top: 0;
bottom: 0;
width: 0;
border-left: 1px dashed rgba(37, 99, 235, .85);
z-index: 14;
pointer-events: none;
display: var(--safe-display, block);
}
.designerLayerOverlay {
position: absolute;
inset: 0;
z-index: 11;
pointer-events: none;
}
.designerLayerOverlay .freeLayer {
pointer-events: auto;
}
.layerOverlay {
position: absolute;
inset: 0;
z-index: 18;
pointer-events: none;
}
.freeLayer {
position: absolute;
min-width: .25in;
min-height: .18in;
transform-origin: center center;
pointer-events: auto;
cursor: move;
user-select: none;
}
.freeLayer.selected {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.freeLayer img {
width: 100%;
height: 100%;
display: block;
object-fit: contain;
pointer-events: none;
}
.textLayer {
width: 100%;
height: 100%;
padding: .04in;
white-space: pre-wrap;
overflow: hidden;
cursor: text;
user-select: text;
}
.textLayer:focus {
outline: none;
}
.layerList {
display: grid;
gap: 6px;
margin-top: 10px;
}
.communityCard {
border: 1px solid var(--line);
border-radius: 0px;
padding: 12px;
background: rgba(127,127,127,.06);
display: grid;
gap: 10px;
}
.communityMeta {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 8px;
align-items: start;
}
.communityDetail strong,
.communityMeta strong {
display: block;
font-size: .9rem;
}
.feedList {
display: grid;
gap: 8px;
margin-top: 10px;
max-height: 360px;
overflow: auto;
}
.feedItem {
border: 1px solid var(--line);
border-radius: 0px;
background: #fff;
padding: 10px;
display: grid;
gap: 8px;
cursor: pointer;
}
.feedItem.active {
border-color: var(--accent);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 25%, transparent);
}
.feedHead {
display: flex;
justify-content: space-between;
gap: 10px;
align-items: start;
}
.feedPreview {
display: grid;
grid-template-columns: 92px 1fr;
gap: 10px;
align-items: start;
}
.feedPreview img {
width: 92px;
height: 68px;
object-fit: cover;
border-radius: 6px;
border: 1px solid var(--line);
background: #f8fafc;
}
.feedTags, .reactionRow, .authorRow {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.chip {
border: 1px solid var(--line);
border-radius: 0px;
padding: 3px 8px;
font-size: .72rem;
color: var(--muted);
background: rgba(127,127,127,.06);
}
.communityDetail {
border-top: 1px solid var(--line);
padding-top: 10px;
display: grid;
gap: 10px;
}
.threadList {
display: grid;
gap: 8px;
}
.threadItem {
border: 1px solid var(--line);
border-radius: 0px;
padding: 8px 10px;
background: rgba(127,127,127,.05);
display: grid;
gap: 6px;
}
.profileCard {
border: 1px solid var(--line);
border-radius: 0px;
padding: 10px;
background: rgba(127,127,127,.05);
display: grid;
gap: 8px;
}
.avatar {
width: 36px;
height: 36px;
border-radius: 0px;
object-fit: cover;
border: 1px solid var(--line);
background: #f8fafc;
flex: 0 0 auto;
}
.layerItem {
width: 100%;
border-radius: 0px;
display: flex;
justify-content: space-between;
gap: 8px;
text-align: left;
min-width: 0;
overflow: hidden;
}
.layerItem > span:first-child {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.layerItem > span:last-child {
flex-shrink: 0;
color: var(--muted);
font-size: .78rem;
}
.layerItem.active {
border-color: var(--accent);
color: var(--accent);
background: #eff6ff;
}
.textTools {
display: flex;
gap: 8px;
margin-top: 8px;
}
.textTools button {
width: 38px;
padding: 8px 0;
border-radius: 0px;
}
body.dragging-layer {
cursor: move;
}
.bleedFill {
position: absolute;
inset: 0;
background:
repeating-linear-gradient(45deg, rgba(220,38,38,.055) 0 8px, transparent 8px 16px);
z-index: 1;
pointer-events: none;
display: var(--bleed-display, block);
}
.spineText {
position: absolute;
left: calc(var(--bleed) + var(--panel-w));
top: var(--bleed);
width: var(--spine-w);
height: var(--trim-h);
z-index: 14;
pointer-events: none;
display: grid;
place-items: center;
color: rgba(15, 23, 42, .48);
font-weight: 900;
font-size: 10px;
letter-spacing: .08em;
writing-mode: vertical-rl;
text-orientation: mixed;
display: var(--label-display, grid);
}
.foldLine {
position: absolute;
top: var(--bleed);
height: var(--trim-h);
width: 0;
border-left: 1px dashed rgba(17, 24, 39, .42);
z-index: 16;
pointer-events: none;
display: var(--guide-display, block);
}
.foldLine.left { left: calc(var(--bleed) + var(--panel-w)); }
.foldLine.right { left: calc(var(--bleed) + var(--panel-w) + var(--spine-w)); }
.cropMarks {
display: var(--crop-display, block);
position: absolute;
inset: 0;
z-index: 30;
pointer-events: none;
}
.cropMarks span {
position: absolute;
background: #111827;
opacity: .85;
}
.cm-h { height: 1px; width: .18in; }
.cm-v { width: 1px; height: .18in; }
.cm-tl-h { left: calc(var(--bleed) - .24in); top: var(--bleed); }
.cm-tl-v { left: var(--bleed); top: calc(var(--bleed) - .24in); }
.cm-tr-h { left: calc(var(--bleed) + (var(--panel-w) * 2) + var(--spine-w) + .06in); top: var(--bleed); }
.cm-tr-v { left: calc(var(--bleed) + (var(--panel-w) * 2) + var(--spine-w)); top: calc(var(--bleed) - .24in); }
.cm-bl-h { left: calc(var(--bleed) - .24in); top: calc(var(--bleed) + var(--trim-h)); }
.cm-bl-v { left: var(--bleed); top: calc(var(--bleed) + var(--trim-h) + .06in); }
.cm-br-h { left: calc(var(--bleed) + (var(--panel-w) * 2) + var(--spine-w) + .06in); top: calc(var(--bleed) + var(--trim-h)); }
.cm-br-v { left: calc(var(--bleed) + (var(--panel-w) * 2) + var(--spine-w)); top: calc(var(--bleed) + var(--trim-h) + .06in); }
body.hide-guides .sheet { --guide-display: none; --safe-display: none; --label-display: none; }
body.hide-bleed .sheet { --bleed-display: none; }
body.hide-crops .sheet { --crop-display: none; }
.miniReadout {
margin-top: 8px;
padding: 10px;
border-radius: 0px;
background: #f8fafc;
border: 1px solid var(--line);
color: #475569;
font-size: .78rem;
line-height: 1.45;
}
.slotGrid {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
}
.urlRow {
display: flex;
gap: 6px;
align-items: center;
}
.urlRow input[type="url"] {
flex: 1;
min-width: 0;
}
.copyUrlBtn {
flex-shrink: 0;
padding: 8px 10px;
font-size: .78rem;
white-space: nowrap;
border: 1px solid var(--line);
border-radius: 0px;
background: #f1f5f9;
color: var(--muted);
cursor: pointer;
transition: background .15s;
}
.copyUrlBtn:hover {
background: #e2e8f0;
color: var(--ink);
}
body.dark .copyUrlBtn {
background: #1e293b;
border-color: var(--line);
color: var(--muted);
}
body.dark .copyUrlBtn:hover {
background: #334155;
color: var(--ink);
}
.rangeLine {
display: grid;
grid-template-columns: 1fr 48px;
gap: 8px;
align-items: center;
}
.rangeLine output {
font-size: .74rem;
color: var(--muted);
text-align: right;
font-variant-numeric: tabular-nums;
}
.kbd {
display: inline-block;
border: 1px solid #cbd5e1;
background: #f8fafc;
border-bottom-width: 2px;
border-radius: 6px;
padding: 0 5px;
font-size: .72rem;
font-weight: 800;
color: #475569;
}
.modal { position: fixed; inset: 0; background: rgba(0,0,0,.6); display:none; z-index:1000; }
.modal.open { display:block; }
.modalCard { width:min(900px,92vw); max-height:88vh; overflow:auto; margin:4vh auto; background:var(--panel); color:var(--ink); border:1px solid var(--line); border-radius:8px; padding:14px; }
.searchGrid{display:grid;grid-template-columns:1fr 140px 110px;gap:8px;align-items:end}
.results{display:grid;gap:8px;margin-top:10px}
.resultItem{display:grid;grid-template-columns:auto 1fr auto;gap:10px;align-items:center;border:1px solid var(--line);border-radius:8px;padding:8px;background:rgba(127,127,127,.07)}
.resultThumb{width:100px;height:70px;border-radius:6px;border:1px solid var(--line);background:#f1f5f9;overflow:hidden;position:relative;display:flex;align-items:center;justify-content:center;flex-shrink:0}
.resultThumb img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;z-index:1}
.resultThumbDisc{border-radius:50%;width:70px;height:70px}
.resultThumbFallback{font-size:.68rem;font-weight:700;color:var(--muted);text-align:center;padding:4px;line-height:1.3;position:relative;z-index:0}
body.dark .resultThumb{background:#0b1220}
/* Modal tabs */
.modalHeader{display:flex;justify-content:space-between;align-items:center;margin-bottom:10px}
.modalHeader h2{margin:0}
.modalTabs{display:flex;gap:6px;border-bottom:1px solid var(--line);padding-bottom:8px;margin-bottom:12px}
.modalTab{border:1px solid var(--line);border-radius:8px;padding:6px 16px;background:#fff;color:var(--ink);cursor:pointer;font-weight:700;font-size:.86rem}
.modalTab.active{background:var(--accent);border-color:var(--accent);color:#fff}
body.dark .modalTab{background:#0b1220;color:var(--ink);border-color:var(--line)}
body.dark .modalTab.active{color:#fff}
/* Feed wall */
.feedWall{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:12px;margin-top:4px;max-height:62vh;overflow-y:auto}
.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}
.feedCardThumb img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;z-index:1}
.feedCardThumb.feedCardThumbDisc img{inset:10%;width:80%;height:80%;border-radius:50%}
.feedCardThumbLabel{font-size:.68rem;font-weight:700;color:#94a3b8;text-align:center;padding:8px;position:relative;z-index:0}
.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}
.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}
.reactionBtn.reacted{background:#fef3c7;border-color:#f59e0b;color:#b45309}
body.dark .reactionBtn{background:#0b1220}
body.dark .reactionBtn:hover,.body.dark .reactionBtn.reacted{background:#1c1805;border-color:#f59e0b;color:#fcd34d}
.feedCardActions button{font-size:.76rem;padding:4px 10px}
body.dark .feedCard{background:var(--panel)}
body.dark .feedCardThumb{background:#0b1220}
/* Nostr user widget — fixed top-right */
.nostrWidget{position:fixed;top:12px;right:16px;z-index:500;display:flex;align-items:center;gap:6px;background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:5px 10px 5px 8px;box-shadow:0 2px 12px rgba(15,23,42,.12)}
.nostrProfileBtn{display:flex;align-items:center;gap:7px;border:1px solid var(--line);border-radius:8px;padding:5px 10px;background:#fff;cursor:pointer;font-size:.82rem;font-weight:600;color:var(--ink);max-width:200px}
.nostrAvatar{width:28px;height:28px;border-radius:50%;object-fit:cover;border:1px solid var(--line);background:#f1f5f9;flex:0 0 auto}
.nostrNameEl{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:120px}
body.dark .nostrWidget{background:var(--panel);border-color:var(--line)}
body.dark .nostrProfileBtn{background:transparent;border:none;padding:0}
/* Author info in feed cards */
.feedCardAuthor{display:flex;align-items:center;gap:5px;margin-top:4px}
.feedCardAvatar{width:18px;height:18px;border-radius:50%;object-fit:cover;border:1px solid var(--line);background:#f1f5f9;flex:0 0 auto}
.feedCardAuthorName{font-size:.66rem;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.resultAuthor{display:flex;align-items:center;gap:4px;margin-top:3px}
.resultAuthorAvatar{width:14px;height:14px;border-radius:50%;object-fit:cover;border:1px solid var(--line);background:#f1f5f9;flex:0 0 auto}
.resultAuthorName{font-size:.68rem;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
/* Feed type filter pills */
.feedTypeFilter{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px}
.feedTypeBtn{border:1px solid var(--line);border-radius:20px;padding:4px 13px;background:#fff;color:var(--muted);cursor:pointer;font-size:.78rem;font-weight:700}
.feedTypeBtn.active{background:var(--accent);border-color:var(--accent);color:#fff}
body.dark .feedTypeBtn{background:#0b1220;color:var(--muted);border-color:var(--line)}
body.dark .feedTypeBtn.active{color:#fff}
.feedCategorySelect{width:auto;min-width:130px;padding:5px 8px;font-size:.78rem}
.welcomeCard { max-width: 640px; }
@media (max-width: 900px) {
.app { grid-template-columns: 1fr; }
aside { position: relative; max-height: none; border-right: 0; border-bottom: 1px solid var(--line); }
main { padding: 16px; }
.previewShell { min-height: 420px; }
}
@media print {
html, body {
width: var(--sheet-w) !important;
height: var(--sheet-h) !important;
background: #fff !important;
overflow: hidden !important;
}
aside, .topbar, .printHelp, .nostrWidget {
display: none !important;
}
.app, main, .previewShell {
display: block !important;
padding: 0 !important;
margin: 0 !important;
border: 0 !important;
box-shadow: none !important;
background: #fff !important;
overflow: hidden !important;
transform: none !important;
width: var(--sheet-w) !important;
height: var(--sheet-h) !important;
min-height: 0 !important;
}
.sheetWrap {
display: block !important;
padding: 0 !important;
margin: 0 !important;
border: 0 !important;
box-shadow: none !important;
background: #fff !important;
overflow: hidden !important;
transform: none !important;
width: var(--sheet-w) !important;
height: var(--sheet-h) !important;
min-height: 0 !important;
page-break-after: avoid !important;
break-after: avoid !important;
}
.sheet {
box-shadow: none !important;
overflow: hidden !important;
width: var(--sheet-w) !important;
height: var(--sheet-h) !important;
page-break-before: avoid !important;
page-break-after: avoid;
break-after: avoid;
}
.slot, .discSlot, .jewelSlot {
border-color: transparent !important;
}
.placeholder, .slot::after, .slot::before, .discSlot::before, .jewelSlot::before, .jewelSlot::after, .jewelSpineGuide, .foldLine, .trimArea, .bleedFill, .spineText, .discSpineStrip {
display: none !important;
}
.freeLayer.selected {
outline: none !important;
}
body.print-guides .slot::before,
body.print-guides .jewelSlot::after,
body.print-guides .jewelSpineGuide,
body.print-guides .foldLine,
body.print-guides .trimArea,
body.print-guides .discSpineStrip {
display: block !important;
}
body.print-guides .discSlot,
body.print-guides .jewelSlot {
border-color: rgba(17, 24, 39, .6) !important;
}
body.print-guides .discHole {
border-color: rgba(17, 24, 39, .6) !important;
}
body:not(.print-guides) .discHole {
border-color: transparent !important;
}
body.print-guides .spineText {
display: grid !important;
}
body.print-crops .cropMarks {
display: block !important;
}
body:not(.print-crops) .cropMarks {
display: none !important;
}
}
body.dark {
--bg:#0b0f14; --panel:#121821; --ink:#e5e7eb; --muted:#9aa4b2; --line:#2c3443; --accent:#60a5fa;
background-image: none;
}
body.dark aside, body.dark fieldset, body.dark details, body.dark .pill, body.dark .previewShell, body.dark .feedItem { background: var(--panel); color: var(--ink); border-color: var(--line); }
body.dark .previewShell { background:#0f1622; box-shadow: inset 0 1px 0 rgba(255,255,255,.03); }
body.dark input[type="number"], body.dark input[type="text"], body.dark input[type="url"], body.dark textarea, body.dark select, body.dark button { background:#0b1220; color:var(--ink); border-color:var(--line); }
body.dark .hint, body.dark label, body.dark .subhead, body.dark .status { color: var(--muted); }
body.dark .sheet{filter:contrast(1.08) saturate(1.15) brightness(.95);}
body.dark .layoutTab.active, body.dark button.primary { color:#fff; }
.sheetWrap{margin:0 auto;}
</style>
</head>
<body>
<div class="nostrWidget" id="nostrWidget">
<button type="button" id="nostrLoginBtn" class="pill">Sign in with Nostr</button>
<button type="button" id="nostrProfileBtn" class="nostrProfileBtn" style="display:none">
<img class="nostrAvatar" id="nostrAvatarEl" alt="" />
<span id="nostrNameEl"></span>
</button>
</div>
<div class="app">
<aside>
<div class="appTitle">
<div class="appHeader">
<div class="appLogoRow">
<img src="logo.gif" alt="CaseWrap" class="appLogo" style="height: 48px;" />
<h1>CaseWrap</h1>
</div>
</div>
<p class="subhead">Design printable case wraps and disc labels, then save, print, or share them on Nostr.</p>
</div>
<div class="appModeTabs">
<button type="button" class="appModeTab active" id="modeTabTemplate"><img src="paper_icon_32px.png" alt="Template" style="height: 40px; width: auto;" />Template</button>
<button type="button" class="appModeTab" id="modeTabDesigner"><img src="disk_icon_32px.png" alt="Disc Designer" style="height: 40px; width: auto;" />Disc Designer</button>
</div>
<div class="quickActions template-only" aria-label="Primary actions">
<button type="button" id="browseCommunityBtn">Browse Templates</button>
<button type="button" id="loadProjectBtn">Load from file</button>
<input id="projectFile" type="file" accept="application/json,.json" hidden />
</div>
<div class="quickActions designer-only" aria-label="Designer actions">
<button type="button" id="designerBrowseCommunityBtn">Browse Templates</button>
<button type="button" id="designerLoadFileBtn">Load from file</button>
<input id="discProjectFile" type="file" accept="application/json,.json" hidden />
</div>
<fieldset class="workflowStep template-only" id="layoutFieldset">
<legend data-step="1">Layout</legend>
<div class="layoutTabs" role="tablist" aria-label="Template layout">
<button type="button" class="layoutTab" id="mode-cover" data-template-mode="cover" role="tab">Case cover</button>
<button type="button" class="layoutTab" id="mode-disc" data-template-mode="disc" role="tab">Disc labels</button>
</div>
</fieldset>
<fieldset class="workflowStep template-only" id="coverSizeFieldset">
<legend data-step="2">Template</legend>
<label for="preset">Template preset</label>
<select id="preset">
<option value="dvd">DVD case wrap — 10.80 × 7.50 in with bleed</option>
<option value="bluray">Blu-ray case wrap — 10.18 × 6.08 in with bleed</option>
<option value="jewel8693">CD jewel inserts — Avery 8693 / 8943</option>
<option value="custom">Custom</option>
</select>
<div id="customSizeFields" style="display:none">
<div class="row">
<div>
<label for="panelW">Front/back width, in</label>
<input id="panelW" type="number" min="1" max="12" step="0.001" />
</div>
<div>
<label for="trimH">Trim height, in</label>
<input id="trimH" type="number" min="1" max="12" step="0.001" />
</div>
</div>
<div class="row">
<div>
<label for="spineW">Spine width, in</label>
<input id="spineW" type="number" min="0.1" max="2" step="0.001" />
</div>
<div>
<label for="bleed">Bleed, in</label>
<input id="bleed" type="number" min="0" max="0.5" step="0.001" />
</div>
</div>
<div>
<label for="safe">Safe margin guide, in</label>
<input id="safe" type="number" min="0" max="0.5" step="0.001" />
</div>
</div>
<div class="miniReadout" id="sizeReadout"></div>
</fieldset>
<fieldset class="secondaryPanel template-only" id="metadataFieldset">
<details class="compactDetails">
<summary data-step="5">Project metadata</summary>
<label for="titleField">Title *</label><input id="titleField" type="text" placeholder="Movie title" required />
<label for="imdbField">IMDb ID</label><input id="imdbField" type="text" placeholder="tt1234567" />
<label for="categoryField">Category</label>
<select id="categoryField">
<option value="">Uncategorized</option>
<option value="blu-ray">Blu-ray</option>
<option value="dvd">DVD</option>
<option value="ps1">PS1</option>
<option value="ps2">PS2</option>
<option value="ps3">PS3</option>
<option value="xbox">Xbox</option>
<option value="xbox-360">Xbox 360</option>
<option value="pc">PC</option>
<option value="cd">CD</option>
<option value="dreamcast">Dreamcast</option>
<option value="gamecube">GameCube</option>
<option value="wii">Wii</option>
<option value="sega-saturn">Sega Saturn</option>
<option value="sega-cd">Sega CD</option>
<option value="audio-cd">Audio CD</option>
</select>
<div class="buttonRow">
<button type="button" id="imdbImportBtn">Import IMDb data</button>
</div>
<p class="hint">IMDb import uses OMDb-compatible endpoint + API key. Title and category are required to save or export.</p>
</details>
</fieldset>
<fieldset class="workflowStep template-only" id="exportFieldset">
<legend data-step="4">Display options</legend>
<label for="zoom">Preview zoom</label>
<div class="rangeLine">
<input id="zoom" type="range" min="20" max="125" step="1" value="60" />
<output id="zoomOut">60%</output>
</div>
<div class="toggles">
<label class="toggle cover-toggle"><input id="showGuides" type="checkbox" checked /> Show fold/safe/trim guides on screen</label>
<label class="toggle cover-toggle"><input id="showBleed" type="checkbox" checked /> Show bleed shading on screen</label>
<label class="toggle cover-toggle"><input id="showCrops" type="checkbox" checked /> Show crop marks on screen</label>
<label class="toggle cover-toggle"><input id="printGuides" type="checkbox" /> Include guides in PDF</label>
<label class="toggle cover-toggle"><input id="printCrops" type="checkbox" checked /> Include crop marks in PDF</label>
</div>
<p class="hint">PDF export opens your browser print dialog. Choose <b>Save as PDF</b>, use <b>100% scale</b>, and disable headers/footers.</p>
</fieldset>
<fieldset class="workflowStep template-only" id="printSaveFieldset">
<legend data-step="6">Print &amp; Save</legend>
<div class="buttonRow">
<button class="primary" id="printBtn">Print / Save PDF</button>
</div>
<div class="buttonRow">
<button id="saveProjectBtn">Save project (JSON)</button>
<button id="closeProjectBtn">Close project</button>
</div>
<details>
<summary>Nostr</summary>
<div class="buttonRow">
<button type="button" id="communityPublishBtn">Publish to Nostr</button>
<button type="button" id="nostrExportBtn">Copy Nostr event</button>
</div>
<p class="hint">Publishing requires a Nostr browser extension (Alby, nos2x).</p>
</details>
</fieldset>
<fieldset class="workflowStep template-only" id="coverArtworkFieldset">
<legend data-step="3">Artwork</legend>
<label for="artworkMode">Artwork mode</label>
<select id="artworkMode">
<option value="separate">Separate front / spine / back images</option>
<option value="combined">One combined wrap image</option>
</select>
<details id="combinedControls">
<summary>Combined wrap image</summary>
<label for="combinedUrl">Image URL</label>
<div class="urlRow">
<input id="combinedUrl" type="url" placeholder="https://example.com/wrap.jpg" />
<button type="button" class="copyUrlBtn" onclick="copyUrl('combinedUrl')">Copy</button>
</div>
<div class="buttonRow">
<button type="button" id="load-combined">Load URL</button>
<button type="button" class="danger" id="clear-combined">Clear combined image</button>
</div>
<label for="area-combined">Span area</label>
<select id="area-combined">
<option value="trim">Trim area only (back + spine + front)</option>
<option value="page">Full page including bleed</option>
</select>
<label for="fit-combined">Fit</label>
<select id="fit-combined">
<option value="cover">Cover / crop to fill</option>
<option value="contain">Contain / no crop</option>
<option value="fill">Stretch to fit</option>
<option value="none">Natural size</option>
</select>
<label for="zoom-combined">Image zoom</label>
<div class="rangeLine">
<input id="zoom-combined" type="range" min="0.1" max="4" step="0.01" />
<output id="zoom-combined-out"></output>
</div>
<label for="x-combined">Move X</label>
<div class="rangeLine">
<input id="x-combined" type="range" min="-100" max="100" step="1" />
<output id="x-combined-out"></output>
</div>
<label for="y-combined">Move Y</label>
<div class="rangeLine">
<input id="y-combined" type="range" min="-100" max="100" step="1" />
<output id="y-combined-out"></output>
</div>
<label for="rotate-combined">Rotate</label>
<div class="rangeLine">
<input id="rotate-combined" type="range" min="-180" max="180" step="1" />
<output id="rotate-combined-out"></output>
</div>
<div class="buttonRow">
<button type="button" id="reset-combined">Reset combined image</button>
</div>
<p class="hint">
Use this when you already have one full wrap image. "Trim area only" spans the visible case wrap.
"Full page including bleed" spans the printable page.
</p>
</details>
<div id="slotControls" class="slotGrid"></div>
<div class="buttonRow">
<button class="danger" id="clearBtn">Clear all images</button>
</div>
<p class="hint">
Tip: you can switch between separate-image mode and combined-image mode without losing what you loaded.
</p>
</fieldset>
<fieldset class="workflowStep template-only" id="discArtworkFieldset">
<legend data-step="3">Disc Layout</legend>
<div class="miniReadout" id="discSizeReadout"></div>
<div id="discControls" class="slotGrid"></div>
<p class="hint">Import saved disc designs into each print position from file or Nostr.</p>
<details style="margin-top:10px">
<summary style="font-weight:700;font-size:.86rem;cursor:pointer">Spine strip labels (optional)</summary>
<div class="row" style="margin-top:8px">
<div>
<label for="spineTextTop">Disc 1 spine text</label>
<input id="spineTextTop" type="text" placeholder="e.g. Movie Title" />
</div>
<div>
<label for="spineTextBottom">Disc 2 spine text</label>
<input id="spineTextBottom" type="text" placeholder="e.g. Movie Title" />
</div>
</div>
<p class="hint">Appears on the 4 narrow Avery 8960 spine sticker strips — Disc 1 text on the top pair, Disc 2 on the bottom pair. Leave blank to skip.</p>
</details>
</fieldset>
<fieldset class="workflowStep template-only" id="jewelArtworkFieldset">
<legend data-step="3">Jewel Inserts</legend>
<div class="miniReadout" id="jewelSizeReadout"></div>
<div id="jewelControls" class="slotGrid"></div>
<div class="buttonRow">
<button type="button" class="danger" id="clearJewelBtn">Clear jewel inserts</button>
</div>
<p class="hint">Avery 8693 prints two jewel-case inserts on one Letter sheet: a front insert and a tray insert.</p>
</fieldset>
<fieldset class="workflowStep designer-only" id="designerFieldset">
<legend data-step="1">Disc Design</legend>
<label for="designerName">Design name</label>
<input id="designerName" type="text" placeholder="e.g. Front of disc" />
<label for="designerUrl">Image URL</label>
<div class="urlRow">
<input id="designerUrl" type="url" placeholder="https://example.com/disc.jpg" />
<button type="button" class="copyUrlBtn" onclick="copyUrl('designerUrl')">Copy</button>
</div>
<div class="buttonRow">
<button type="button" id="designerLoadBtn" class="primary">Load image</button>
<button type="button" id="designerClearBtn" class="danger">Clear</button>
</div>
<label for="fit-designer">Fit</label>
<select id="fit-designer">
<option value="cover">Cover / crop to fill</option>
<option value="contain">Contain / no crop</option>
<option value="fill">Stretch to fit</option>
<option value="none">Natural size</option>
</select>
<details id="designerPositionDetails">
<summary>Position &amp; zoom</summary>
<label for="zoom-designer">Zoom</label>
<div class="rangeLine">
<input id="zoom-designer" type="range" min="0.1" max="4" step="0.01" />
<output id="zoom-designer-out"></output>
</div>
<label for="x-designer">Move X</label>
<div class="rangeLine">
<input id="x-designer" type="range" min="-100" max="100" step="1" />
<output id="x-designer-out"></output>
</div>
<label for="y-designer">Move Y</label>
<div class="rangeLine">
<input id="y-designer" type="range" min="-100" max="100" step="1" />
<output id="y-designer-out"></output>
</div>
<label for="rotate-designer">Rotate</label>
<div class="rangeLine">
<input id="rotate-designer" type="range" min="-180" max="180" step="1" />
<output id="rotate-designer-out"></output>
</div>
<div class="buttonRow">
<button type="button" id="designerResetBtn">Reset position</button>
</div>
</details>
<div class="buttonRow" style="margin-top:8px">
<button type="button" class="danger" id="clearDiscBtn">Clear all</button>
</div>
<p class="hint">Designs A–D created here can be assigned to print positions in the Template tab.</p>
</fieldset>
<fieldset class="workflowStep designer-only" id="layersFieldset">
<legend data-step="2">Layers</legend>
<label for="layerTarget" class="layer-controls">Add layer to</label>
<select id="layerTarget" class="layer-controls">
<option value="disc-top">Disc 1 position</option>
<option value="disc-bottom">Disc 2 position</option>
</select>
<div class="urlRow layer-controls">
<input id="newLayerUrl" type="url" placeholder="https://example.com/layer.png" />
<button type="button" class="copyUrlBtn" onclick="copyUrl('newLayerUrl')">Copy</button>
</div>
<div class="buttonRow layer-controls">
<button type="button" id="addImageLayerBtn">Add image layer</button>
<button type="button" id="addTextLayerBtn">Add text layer</button>
</div>
<p class="hint layer-controls">Add image or text layers on top of your disc layout.</p>
<div class="layerList" id="layerList"></div>
<details id="layerControls">
<summary>Selected layer</summary>
<label for="layerName">Name</label>
<input id="layerName" type="text" />
<div id="layerImageControls">
<label for="layerUrl">Image URL</label>
<div class="urlRow">
<input id="layerUrl" type="url" placeholder="https://example.com/layer.png" />
<button type="button" class="copyUrlBtn" onclick="copyUrl('layerUrl')">Copy</button>
</div>
</div>
<div class="row">
<div>
<label for="layerX">X, in</label>
<input id="layerX" type="number" step="0.01" />
</div>
<div>
<label for="layerY">Y, in</label>
<input id="layerY" type="number" step="0.01" />
</div>
</div>
<div class="row">
<div>
<label for="layerW">Width, in</label>
<input id="layerW" type="number" min="0.1" step="0.01" />
</div>
<div>
<label for="layerH">Height, in</label>
<input id="layerH" type="number" min="0.1" step="0.01" />
</div>
</div>
<div class="row">
<div>
<label for="layerRotate">Rotate</label>
<input id="layerRotate" type="number" step="1" />
</div>
<div>
<label for="layerOpacity">Opacity</label>
<input id="layerOpacity" type="number" min="0" max="1" step="0.05" />
</div>
</div>
<div id="textLayerControls">
<label for="textFontSize">Text size, pt</label>
<input id="textFontSize" type="number" min="4" max="200" step="1" />
<label for="textColor">Text color</label>
<input id="textColor" type="color" />
<label for="textAlign">Text alignment</label>
<select id="textAlign">
<option value="left">Left</option>
<option value="center">Center</option>
<option value="right">Right</option>
</select>
<div class="textTools">
<button type="button" id="textBold" title="Bold"><b>B</b></button>
<button type="button" id="textItalic" title="Italic"><i>I</i></button>
</div>
</div>
<div class="buttonRow">
<button type="button" id="layerForward">Move forward</button>
<button type="button" id="layerBackward">Move back</button>
<button type="button" class="danger" id="deleteLayer">Delete layer</button>
</div>
</details>
<p class="hint">Click a layer on the preview to select it. Drag selected image or text layers directly on the page. Text layers are editable in place.</p>
</fieldset>
<fieldset class="secondaryPanel designer-only" id="designerMetadataFieldset">
<details class="compactDetails">
<summary data-step="3">Project metadata</summary>
<label for="designerTitleField">Title *</label>
<input id="designerTitleField" type="text" placeholder="Movie, album, or game title" />
<label for="designerImdbField">IMDb ID</label>
<input id="designerImdbField" type="text" placeholder="tt1234567" />
<label for="designerCategoryField">Category *</label>
<select id="designerCategoryField" class="categorySelect"></select>
<p class="hint">Title and category are required to save or publish.</p>
</details>
</fieldset>
<fieldset class="workflowStep designer-only" id="designerPrintSaveFieldset">
<legend data-step="4">Save &amp; Export</legend>
<div class="buttonRow">
<button type="button" id="designerSaveProjectBtn">Save design (JSON)</button>
<button type="button" id="designerCloseProjectBtn">Close project</button>
</div>
<details>
<summary>Nostr</summary>
<div class="buttonRow">
<button type="button" id="designerPublishBtn">Publish to Nostr</button>
<button type="button" id="designerLoadProjectBtn">Search Nostr</button>
</div>
<p class="hint">Disc designs publish as reusable single-disc designs. Publishing requires a Nostr browser extension.</p>
</details>
</fieldset>
</aside>
<main>
<div class="topbar">
<div>
<h2 id="projectTitle">Cover preview</h2>
<p class="subhead printHelp">Left-to-right layout is back cover, spine, front cover.</p>
</div>
<div class="status">
<span class="pill" id="trimReadout"></span>
<span class="pill" id="sheetReadout"></span>
</div>
</div>
<div class="previewShell" id="previewShell">
<div class="sheetWrap" id="sheetWrap">
<section class="sheet" id="sheet" aria-label="Printable case cover layout">
<div class="bleedFill coverOnly" aria-hidden="true"></div>
<div class="combinedLayer coverOnly" id="combinedLayer">
<div class="placeholder combinedPlaceholder">Enter one combined wrap image URL</div>
<img class="art combinedArt" id="art-combined" alt="Combined wrap art" hidden />
</div>
<div class="slot back coverOnly" data-label="BACK" id="slot-back">
<div class="placeholder">Enter back image URL</div>
<img class="art" id="art-back" alt="Back cover art" hidden />
</div>
<div class="slot spine coverOnly" data-label="SPINE" id="slot-spine">
<div class="placeholder">Enter spine image URL</div>
<img class="art" id="art-spine" alt="Spine art" hidden />
</div>
<div class="slot front coverOnly" data-label="FRONT" id="slot-front">
<div class="placeholder">Enter front image URL</div>
<img class="art" id="art-front" alt="Front cover art" hidden />
</div>
<div class="spineText coverOnly">SPINE</div>
<div class="foldLine left coverOnly" aria-hidden="true"></div>
<div class="foldLine right coverOnly" aria-hidden="true"></div>
<div class="trimArea coverOnly" aria-hidden="true"></div>
<div class="cropMarks coverOnly" aria-hidden="true">
<span class="cm-h cm-tl-h"></span><span class="cm-v cm-tl-v"></span>
<span class="cm-h cm-tr-h"></span><span class="cm-v cm-tr-v"></span>
<span class="cm-h cm-bl-h"></span><span class="cm-v cm-bl-v"></span>
<span class="cm-h cm-br-h"></span><span class="cm-v cm-br-v"></span>
</div>
<div class="diskLayer" id="diskLayer" aria-hidden="true">
<div class="discSlot" data-label="DISC 1" id="disc-top">
<div class="placeholder">Assign a disc design</div>
<img class="art" id="art-disc-top" alt="Disc 1 art" hidden />
<div class="discHole" aria-hidden="true"></div>
</div>
<div class="discSlot" data-label="DISC 2" id="disc-bottom">
<div class="placeholder">Assign a disc design</div>
<img class="art" id="art-disc-bottom" alt="Disc 2 art" hidden />
<div class="discHole" aria-hidden="true"></div>
</div>
<div class="discSpineStrip" id="disc-spine-1" aria-hidden="true"></div>
<div class="discSpineStrip" id="disc-spine-2" aria-hidden="true"></div>
<div class="discSpineStrip" id="disc-spine-3" aria-hidden="true"></div>
<div class="discSpineStrip" id="disc-spine-4" aria-hidden="true"></div>
<div class="discSpineLabel" id="disc-spine-label-1" aria-hidden="true"></div>
<div class="discSpineLabel" id="disc-spine-label-2" aria-hidden="true"></div>
<div class="discSpineLabel" id="disc-spine-label-3" aria-hidden="true"></div>
<div class="discSpineLabel" id="disc-spine-label-4" aria-hidden="true"></div>
</div>
<div class="jewelLayer" id="jewelLayer" aria-hidden="true">
<div class="jewelSlot" data-label="FRONT INSERT" id="jewel-front">
<div class="placeholder">Enter front insert image URL</div>
<img class="art" id="art-jewel-front" alt="CD jewel front insert art" hidden />
</div>
<div class="jewelSlot" data-label="TRAY INSERT" id="jewel-tray">
<div class="placeholder">Enter tray insert image URL</div>
<img class="art" id="art-jewel-tray" alt="CD jewel tray insert art" hidden />
<div class="jewelSpineGuide" id="jewel-tray-spine-left" aria-hidden="true"></div>
<div class="jewelSpineGuide" id="jewel-tray-spine-right" aria-hidden="true"></div>
</div>
</div>
<div class="layerOverlay" id="layerOverlay"></div>
</section>
</div>
<div id="discDesignerView">
<div class="designerDiscWrap" id="designerDiscWrap">
<div class="placeholder" id="designerPlaceholder">Load an image URL in the sidebar</div>
<img class="art" id="designerDiscArt" alt="Disc design preview" hidden />
<div class="designerLayerOverlay" id="designerLayerOverlay"></div>
<div class="designerDiscHole"></div>
</div>
<div class="designerDiscLabel" id="designerDiscLabel">Design A</div>
</div>
</div>
</main>
</div>
<div class="modal" id="welcomeModal" aria-hidden="true">
<div class="modalCard welcomeCard">
<h2 style="margin:0 0 6px">Welcome to CaseWrap Studio</h2>
<p class="subhead" style="margin:0 0 16px">Design printable DVD and Blu-ray case wraps and disc labels.</p>
<p style="margin:0 0 12px; font-weight:600">Search first — a community template may already exist for your title:</p>
<div class="searchGrid">
<div><label for="welcomeQuery">Title or IMDb ID</label><input id="welcomeQuery" type="text" placeholder="e.g. The Matrix or tt0133093" /></div>
<div><label for="welcomeType">Type</label><select id="welcomeType"><option value="any">Any type</option><option value="cover">Case cover</option><option value="disc">Disc labels</option><option value="jewel">CD jewel inserts</option></select></div>
<div><label for="welcomeCategory">Category</label><select id="welcomeCategory" class="categorySelect"></select></div>
<div class="buttonRow" style="margin:0"><button type="button" id="welcomeSearchBtn" class="primary">Search</button></div>
</div>
<div class="results" id="welcomeResults"></div>
<div style="margin-top:18px; padding-top:14px; border-top:1px solid var(--line); text-align:center">
<p class="hint" style="margin:0 0 10px">No templates found for your title? Start designing your own.</p>
<button type="button" id="welcomeSkipBtn">Start from scratch →</button>
</div>
</div>
</div>
<div class="modal" id="nostrModal" aria-hidden="true">
<div class="modalCard">
<div class="modalHeader">
<h2>Browse Community Templates</h2>
<button type="button" id="nostrClose">Close</button>
</div>
<div class="modalTabs">
<button type="button" class="modalTab" id="tabNew">New</button>
<button type="button" class="modalTab" id="tabPopular">Popular</button>
<button type="button" class="modalTab" id="tabSearch">Search</button>
<button type="button" class="modalTab" id="tabMine">My Designs</button>
</div>
<div id="panelNew">
<div class="feedTypeFilter">
<button class="feedTypeBtn active" data-tab="New" data-type="any">All</button>
<button class="feedTypeBtn" data-tab="New" data-type="cover">Case Covers</button>
<button class="feedTypeBtn" data-tab="New" data-type="disc">Disc Sheets</button>
<button class="feedTypeBtn" data-tab="New" data-type="jewel">Jewel Inserts</button>
<button class="feedTypeBtn" data-tab="New" data-type="disc-design">Disc Designs</button>
<select id="feedCategoryNew" class="feedCategorySelect categorySelect" data-tab="New" aria-label="Category filter"></select>
</div>
<div class="feedWall" id="feedWallNew"></div>
</div>
<div id="panelPopular" style="display:none">
<div class="feedTypeFilter">
<button class="feedTypeBtn active" data-tab="Popular" data-type="any">All</button>
<button class="feedTypeBtn" data-tab="Popular" data-type="cover">Case Covers</button>
<button class="feedTypeBtn" data-tab="Popular" data-type="disc">Disc Sheets</button>
<button class="feedTypeBtn" data-tab="Popular" data-type="jewel">Jewel Inserts</button>
<button class="feedTypeBtn" data-tab="Popular" data-type="disc-design">Disc Designs</button>
<select id="feedCategoryPopular" class="feedCategorySelect categorySelect" data-tab="Popular" aria-label="Category filter"></select>
</div>
<div class="feedWall" id="feedWallPopular"></div>
</div>
<div id="panelSearch" style="display:none">
<div class="searchGrid">
<div><label for="nostrQuery">Title or IMDb ID</label><input id="nostrQuery" type="text" placeholder="e.g. The Matrix or tt0133093" /></div>
<div><label for="nostrType">Type</label><select id="nostrType"><option value="any">Any</option><option value="cover">Case cover</option><option value="disc">Disc sheet</option><option value="jewel">CD jewel inserts</option><option value="disc-design">Disc design</option></select></div>
<div><label for="nostrCategory">Category</label><select id="nostrCategory" class="categorySelect"></select></div>
<div class="buttonRow" style="margin:0"><button type="button" id="nostrRunSearch" class="primary">Search</button></div>
</div>
<div class="results" id="nostrResults" style="margin-top:10px"></div>
</div>
<div id="panelMine" style="display:none">
<div class="feedTypeFilter">
<button class="feedTypeBtn active" data-tab="Mine" data-type="any">All</button>
<button class="feedTypeBtn" data-tab="Mine" data-type="cover">Case Covers</button>
<button class="feedTypeBtn" data-tab="Mine" data-type="disc">Disc Sheets</button>
<button class="feedTypeBtn" data-tab="Mine" data-type="jewel">Jewel Inserts</button>
<button class="feedTypeBtn" data-tab="Mine" data-type="disc-design">Disc Designs</button>
<select id="feedCategoryMine" class="feedCategorySelect categorySelect" data-tab="Mine" aria-label="Category filter"></select>
</div>
<div class="feedWall" id="feedWallMine"></div>
</div>
<details style="margin-top:12px;border-top:1px solid var(--line);padding-top:10px">
<summary style="font-weight:700;font-size:.82rem;cursor:pointer;color:var(--muted)">Relay settings</summary>
<label for="relayList" style="margin-top:8px;display:block;font-size:.8rem;font-weight:650;color:#374151">Nostr relays</label>
<input id="relayList" type="text" placeholder="wss://relay.damus.io, wss://nos.lol" style="margin-top:4px" />
<p class="hint">Separate multiple relays with commas. Changing relays clears the template cache.</p>
</details>
</div>
</div>
<div class="modal" id="designPreviewModal" aria-hidden="true">
<div class="modalCard" style="max-width:min(860px,94vw)">
<div class="modalHeader">
<div>
<h2 id="previewModalTitle">Design Preview</h2>
<p id="previewModalMeta" class="subhead"></p>
</div>
<div id="previewModalActions" style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
<button type="button" id="previewSaveJsonBtn">Save JSON</button>
<button type="button" id="previewModalClose">Close</button>
</div>
</div>
<div id="previewModalContent" style="margin-top:12px;"></div>
</div>
</div>
<script>
const $ = (id) => document.getElementById(id);
const safe$ = (id) => { try { return $(id); } catch { return null; } };
const on = (id, type, fn) => { const el = $(id); if (el) el.addEventListener(type, fn); };
function copyUrl(id) {
const input = $(id);
if (!input) return;
const val = input.value.trim();
if (!val) return;
try {
navigator.clipboard.writeText(val);
} catch (err) {
input.select();
document.execCommand('copy');
}
}
const SCHEMA_VERSION = 5;
const MIN_SUPPORTED_SCHEMA_VERSION = 4;
const presets = {
dvd: {
name: 'DVD case wrap',
panelW: 5.125,
trimH: 7.25,
spineW: 0.55,
bleed: 0.125,
safe: 0.125
},
bluray: {
name: 'Blu-ray case wrap',
panelW: 4.961,
trimH: 5.827,
spineW: 0.472,
bleed: 0.125,
safe: 0.125
}
};
const slotNames = [
{ key: 'back', label: 'Back cover' },
{ key: 'spine', label: 'Spine' },
{ key: 'front', label: 'Front cover' }
];
const discTemplate = {
sheetW: 8.5,
sheetH: 11,
discD: 4.625,
holeD: 0.69,
slots: [
{ key: 'top', label: 'Disc 1', centerX: 4.25, centerY: 3 },
{ key: 'bottom', label: 'Disc 2', centerX: 4.25, centerY: 8 }
],
spineStrips: [
{ x: 0.49375, y: 0.733, w: 0.2188, h: 4.5875 },
{ x: 0.9632, y: 0.733, w: 0.2188, h: 4.5875 },
{ x: 0.49375, y: 5.5785, w: 0.2188, h: 4.5875 },
{ x: 0.9632, y: 5.5785, w: 0.2188, h: 4.5875 }
]
};
const jewelTemplate = {
sheetW: 8.5,
sheetH: 11,
product: 'Avery 8693 / 8943',
slots: [
{ key: 'front', label: 'Front insert', x: 1.875, y: 0.5, w: 4.75, h: 4.75 },
{ key: 'tray', label: 'Tray insert with spines', x: 1.29685, y: 5.9373, w: 5.9063, h: 4.6254, spineW: 0.25 }
]
};
const discSlotNames = [
{ key: 'top', label: 'Disc 1' },
{ key: 'bottom', label: 'Disc 2' }
];
const jewelSlotNames = [
{ key: 'front', label: 'Front insert' },
{ key: 'tray', label: 'Tray insert' }
];
const mediaCategories = [
{ value: '', label: 'Any category' },
{ value: 'blu-ray', label: 'Blu-ray' },
{ value: 'dvd', label: 'DVD' },
{ value: 'ps1', label: 'PS1' },
{ value: 'ps2', label: 'PS2' },
{ value: 'ps3', label: 'PS3' },
{ value: 'xbox', label: 'Xbox' },
{ value: 'xbox-360', label: 'Xbox 360' },
{ value: 'pc', label: 'PC' },
{ value: 'cd', label: 'CD' },
{ value: 'dreamcast', label: 'Dreamcast' },
{ value: 'gamecube', label: 'GameCube' },
{ value: 'wii', label: 'Wii' },
{ value: 'sega-saturn', label: 'Sega Saturn' },
{ value: 'sega-cd', label: 'Sega CD' },
{ value: 'audio-cd', label: 'Audio CD' }
];
const state = {
meta: { title: "", imdbId: "", omdbApiKey: "", category: "" },
darkMode: false,
appMode: 'template',
templateMode: 'cover',
preset: 'dvd',
panelW: presets.dvd.panelW,
trimH: presets.dvd.trimH,
spineW: presets.dvd.spineW,
bleed: presets.dvd.bleed,
safe: presets.dvd.safe,
zoom: 60,
showGuides: true,
showBleed: true,
showCrops: true,
printGuides: false,
printCrops: true,
artworkMode: 'separate',
combined: {
src: '',
area: 'trim',
fit: 'cover',
zoom: 1,
x: 0,
y: 0,
rotate: 0
},
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 }
},
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 }
},
discDesign: { name: '', src: '', fit: 'cover', zoom: 1, x: 0, y: 0, rotate: 0 },
discSpineLabels: { top: '', bottom: '' },
jewelInserts: {
front: { src: '', fit: 'cover', zoom: 1, x: 0, y: 0, rotate: 0 },
tray: { src: '', fit: 'cover', zoom: 1, x: 0, y: 0, rotate: 0 }
},
layers: [],
selectedLayerId: '',
community: {
relays: 'wss://relay.damus.io, wss://nos.lol',
query: '',
kind: 'templates',
feed: [],
selectedEventId: '',
selectedEvent: null,
comments: [],
reactions: [],
profiles: {},
myPubkey: '',
myProfile: null,
myFollows: [],
status: 'Refresh the feed when you want to browse Nostr templates.'
}
};
function inches(n) {
return `${Number(n).toFixed(3)}in`;
}
function mm(n) {
return n * 25.4;
}
function fmt(n, digits = 3) {
return Number(n).toFixed(digits).replace(/\.0+$/, '').replace(/(\.\d*?)0+$/, '$1');
}
function normalizeImageUrl(raw) {
const value = String(raw || '').trim();
if (!value) throw new Error('Enter an image URL.');
const parsed = new URL(value, window.location.href);
if (!['http:', 'https:'].includes(parsed.protocol)) {
throw new Error('Use an http or https image URL.');
}
return parsed.href;
}
function splitRelayList(raw) {
return String(raw || '')
.split(/[\s,]+/g)
.map((relay) => relay.trim())
.filter(Boolean);
}
function shortPubkey(pubkey) {
if (!pubkey) return '';
return `${pubkey.slice(0, 8)}…${pubkey.slice(-6)}`;
}
function safeJsonParse(text, fallback = null) {
try {
return JSON.parse(text);
} catch {
return fallback;
}
}
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function categoryLabel(value) {
return mediaCategories.find((item) => item.value === value)?.label || value || '';
}
function categoryOptions(emptyLabel = 'Any category') {
return mediaCategories
.map((item, index) => `<option value="${escapeHtml(item.value)}">${escapeHtml(index === 0 ? emptyLabel : item.label)}</option>`)
.join('');
}
function populateCategorySelects() {
const options = categoryOptions('Any category');
document.querySelectorAll('.categorySelect').forEach((select) => {
select.innerHTML = options;
});
const designerMetaSelect = $('designerCategoryField');
if (designerMetaSelect) designerMetaSelect.innerHTML = categoryOptions('Uncategorized');
const metaSelect = $('categoryField');
if (metaSelect) {
const current = metaSelect.value || state.meta?.category || '';
metaSelect.value = mediaCategories.some((item) => item.value === current) ? current : '';
}
if (designerMetaSelect) {
const current = designerMetaSelect.value || state.meta?.category || '';
designerMetaSelect.value = mediaCategories.some((item) => item.value === current) ? current : '';
}
}
function eventTemplateSummary(payload) {
if (!payload) return { title: 'Untitled', mode: 'template', image: '' };
const title = payload.meta?.title || payload.title || 'Untitled';
const mode = payload.templateMode || (payload.coverTemplate ? 'cover' : payload.jewelTemplate ? 'jewel' : 'disc');
const category = payload.meta?.category || payload.category || '';
return {
title,
mode,
category,
image: previewFromPayload(payload, mode)
};
}
function discDesignPayload() {
persistCurrentDiscDesign();
const design = state.discDesign;
if (!design?.src) throw new Error('Load an image URL before publishing this disc design.');
const layers = currentDesignerLayers();
return {
version: SCHEMA_VERSION,
meta: state.meta,
templateMode: 'disc-design',
discDesign: { ...design },
layers: layers.map(l => ({ ...l, target: 'designer-disc' })),
discTemplate: { config: discTemplate },
community: { relays: state.community.relays, query: state.community.query, kind: state.community.kind }
};
}
function getTemplateTags(payload, typeTag) {
const tags = [];
const title = payload?.meta?.title || payload?.title || '';
const imdb = payload?.meta?.imdbId || payload?.imdbId || '';
const category = payload?.meta?.category || payload?.category || '';
if (title) tags.push(['title', title]);
if (imdb) tags.push(['i', imdb]);
tags.push(['t', 'casewrap']);
if (typeTag) tags.push(['t', typeTag]);
if (category) {
tags.push(['category', category]);
tags.push(['t', `casewrap-${category}`]);
}
return tags;
}
function getCommunityProfile() {
return state.community.myProfile || null;
}
function getCommunityDisplayName(pubkey) {
const profile = state.community.myProfile?.pubkey === pubkey ? state.community.myProfile : state.community.profiles?.[pubkey];
if (profile?.name) return profile.name;
if (profile?.display_name) return profile.display_name;
if (profile?.nip05) return profile.nip05;
return shortPubkey(pubkey) || 'Unknown';
}
async function getNostrPublicKey() {
if (!window.nostr) return '';
try {
return await window.nostr.getPublicKey();
} catch {
return '';
}
}
async function signNostrEvent(evt) {
if (!window.nostr) throw new Error('No Nostr extension found');
const signed = await window.nostr.signEvent(evt);
return signed;
}
async function publishToRelays(event, relays = splitRelayList(state.community.relays)) {
const sockets = relays.map((relay) => {
try {
const ws = new WebSocket(relay);
ws.addEventListener('open', () => {
ws.send(JSON.stringify(['EVENT', event]));
ws.close();
});
return ws;
} catch {
return null;
}
}).filter(Boolean);
await Promise.allSettled(sockets.map((ws) => new Promise((resolve) => {
ws.addEventListener('close', resolve, { once: true });
ws.addEventListener('error', resolve, { once: true });
setTimeout(resolve, 1200);
})));
}
async function queryRelays(relays, filters, { timeoutMs = 3500 } = {}) {
const relayList = relays.length ? relays : ['wss://relay.damus.io'];
const events = new Map();
await Promise.all(relayList.map((relay) => new Promise((resolve) => {
let done = false;
let ws;
const finish = () => {
if (done) return;
done = true;
try { ws && ws.close(); } catch {}
resolve();
};
try {
ws = new WebSocket(relay);
} catch {
resolve();
return;
}
const timer = setTimeout(finish, timeoutMs);
const subId = `sub-${Date.now()}-${Math.floor(Math.random() * 100000)}`;
ws.addEventListener('open', () => {
try {
ws.send(JSON.stringify(['REQ', subId, filters]));
} catch {
clearTimeout(timer);
finish();
}
});
ws.addEventListener('message', (msg) => {
let data;
try {
data = JSON.parse(msg.data);
} catch {
return;
}
if (data[0] === 'EVENT' && data[1] === subId && data[2]?.id) {
events.set(data[2].id, data[2]);
}
if (data[0] === 'EOSE' && data[1] === subId) {
clearTimeout(timer);
finish();
}
});
ws.addEventListener('error', () => {
clearTimeout(timer);
finish();
});
})));
return [...events.values()].sort((a, b) => (b.created_at || 0) - (a.created_at || 0));
}
function eventAuthorProfile(event) {
if (!event?.pubkey) return null;
return state.community.profiles?.[event.pubkey] || null;
}
function formatNostrTime(unixTime) {
if (!unixTime) return '';
return new Date(unixTime * 1000).toLocaleString();
}
function eventSummaryText(event) {
if (!event) return '';
if (event.kind === 30078) {
const payload = safeJsonParse(event.content, {});
return eventTemplateSummary(payload).title;
}
if (event.kind === 0) {
const profile = safeJsonParse(event.content, {});
return profile.about || profile.display_name || profile.name || shortPubkey(event.pubkey) || '';
}
return (event.content || '').trim().replace(/\s+/g, ' ').slice(0, 140);
}
function eventTagsList(event) {
return (event?.tags || []).filter((tag) => tag && tag[0]).map((tag) => {
const key = tag[0];
const value = tag.slice(1).join(' ');
return `${key}:${value}`;
});
}
function normalizeDiscState() {
state.discDesign = {
name: '',
src: '',
fit: 'cover',
zoom: 1,
x: 0,
y: 0,
rotate: 0,
layers: [],
...state.discDesign
};
if (!Array.isArray(state.discDesign.layers)) state.discDesign.layers = [];
// Backward compat: if old discs.top has an image, load it into discDesign
if (state.discs?.top?.src && !state.discDesign.src) {
Object.assign(state.discDesign, { name: state.discs.top.name || '', ...state.discs.top });
}
// Backward compat: if old discDesigns.a has an image, load it
if (state.discDesigns?.a?.src && !state.discDesign.src) {
Object.assign(state.discDesign, { name: state.discDesign.name || '', ...state.discDesign });
}
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.discs
};
}
function currentDesignerLayers() {
return state.layers.filter(l => l.target === 'designer-disc');
}
function persistCurrentDiscDesign() {
state.discDesign.layers = currentDesignerLayers();
}
function loadCurrentDiscDesignLayers() {
const layers = Array.isArray(state.discDesign?.layers) ? state.discDesign.layers : [];
state.layers = [
...state.layers.filter(l => l.target !== 'designer-disc'),
...layers.map(layer => ({ ...layer, target: 'designer-disc' }))
];
}
function normalizeJewelState() {
state.jewelInserts = state.jewelInserts || {};
for (const { key } of jewelSlotNames) {
state.jewelInserts[key] = {
src: '',
fit: 'cover',
zoom: 1,
x: 0,
y: 0,
rotate: 0,
...(state.jewelInserts[key] || {})
};
}
}
function templatePayloadFromEvent(event) {
if (!event) return null;
if (event.kind === 30078) return safeJsonParse(event.content, null);
return null;
}
function totalTrimW() {
return state.panelW * 2 + state.spineW;
}
function sheetW() {
if (state.templateMode === 'disc') return discTemplate.sheetW;
if (state.templateMode === 'jewel') return jewelTemplate.sheetW;
return totalTrimW() + state.bleed * 2;
}
function sheetH() {
if (state.templateMode === 'disc') return discTemplate.sheetH;
if (state.templateMode === 'jewel') return jewelTemplate.sheetH;
return state.trimH + state.bleed * 2;
}
function setCustomWhenEdited() {
if ($('preset').value !== 'custom') {
$('preset').value = 'custom';
state.preset = 'custom';
state.templateMode = 'cover';
$('customSizeFields').style.display = '';
}
}
function applyPreset(name) {
state.preset = name;
if (name === 'jewel8693') {
state.templateMode = 'jewel';
$('customSizeFields').style.display = 'none';
syncInputs();
render();
return;
}
state.templateMode = 'cover';
if (presets[name]) {
Object.assign(state, presets[name]);
}
$('customSizeFields').style.display = name === 'custom' ? '' : 'none';
syncInputs();
render();
}
function syncInputs() {
for (const mode of ['cover', 'disc']) {
const button = $(`mode-${mode}`);
const active = state.templateMode === mode;
button.classList.toggle('active', active);
button.setAttribute('aria-selected', active ? 'true' : 'false');
}
$('preset').value = state.templateMode === 'jewel' ? 'jewel8693' : state.preset;
const csf = $('customSizeFields'); if (csf) csf.style.display = state.preset === 'custom' && state.templateMode !== 'jewel' ? '' : 'none';
$('panelW').value = state.panelW;
$('trimH').value = state.trimH;
$('spineW').value = state.spineW;
$('bleed').value = state.bleed;
$('safe').value = state.safe;
$('zoom').value = state.zoom;
$('zoomOut').textContent = `${state.zoom}%`;
$('artworkMode').value = state.artworkMode;
$('showGuides').checked = state.showGuides;
const dm=$('darkMode'); if (dm) dm.checked = !!state.darkMode;
$('titleField').value = state.meta?.title || "";
$('imdbField').value = state.meta?.imdbId || "";
const cf = $('categoryField'); if (cf) cf.value = state.meta?.category || '';
const dt = $('designerTitleField'); if (dt) dt.value = state.meta?.title || "";
const di = $('designerImdbField'); if (di) di.value = state.meta?.imdbId || "";
const dc = $('designerCategoryField'); if (dc) dc.value = state.meta?.category || '';
$('showBleed').checked = state.showBleed;
$('showCrops').checked = state.showCrops;
$('printGuides').checked = state.printGuides;
$('printCrops').checked = state.printCrops;
const stt = $('spineTextTop'); if (stt) stt.value = state.discSpineLabels?.top || '';
const stb = $('spineTextBottom'); if (stb) stb.value = state.discSpineLabels?.bottom || '';
syncModeTabsUI();
}
function syncUrlInputs() {
$('combinedUrl').value = state.combined.src || '';
$('newLayerUrl').value = '';
for (const { key } of slotNames) {
$(`url-${key}`).value = state.slots[key].src || '';
}
}
function updateBodyClasses() {
document.body.classList.toggle('template-cover', state.templateMode === 'cover');
document.body.classList.toggle('template-disc', state.templateMode === 'disc');
document.body.classList.toggle('template-jewel', state.templateMode === 'jewel');
document.body.classList.toggle('hide-guides', !state.showGuides);
document.body.classList.toggle('hide-bleed', !state.showBleed);
document.body.classList.toggle('hide-crops', !state.showCrops);
document.body.classList.toggle('print-guides', state.printGuides);
document.body.classList.toggle('print-crops', state.printCrops);
document.body.classList.toggle('dark', !!state.darkMode);
document.body.classList.toggle('app-designer', state.appMode === 'designer');
document.body.classList.toggle('app-template', state.appMode !== 'designer');
}
function renderDimensions() {
if (state.appMode === 'designer') {
const design = state.discDesign;
const showName = (design?.name?.trim()) || 'New Design';
$('projectTitle').textContent = `Disc Designer`;
document.querySelector('.printHelp').textContent = showName;
$('trimReadout').textContent = `${fmt(discTemplate.discD)} in disc`;
$('sheetReadout').textContent = `Hole: ${fmt(discTemplate.holeD)} in`;
return;
}
const root = document.documentElement;
root.style.setProperty('--sheet-w', inches(sheetW()));
root.style.setProperty('--sheet-h', inches(sheetH()));
root.style.setProperty('--panel-w', inches(state.panelW));
root.style.setProperty('--trim-h', inches(state.trimH));
root.style.setProperty('--spine-w', inches(state.spineW));
root.style.setProperty('--bleed', inches(state.bleed));
root.style.setProperty('--safe', inches(state.safe));
$('dynamicPageStyle').textContent = `@page { size: ${fmt(sheetW())}in ${fmt(sheetH())}in; margin: 0; }`;
if (state.templateMode === 'disc') {
$('projectTitle').textContent = 'Disc label preview';
document.querySelector('.printHelp').textContent = 'Avery 8960 / 8944 layout: two full-face disc labels on Letter paper.';
$('trimReadout').textContent = `Disc: ${fmt(discTemplate.discD)} in diameter`;
$('sheetReadout').textContent = `Letter: ${fmt(sheetW())} × ${fmt(sheetH())} in`;
$('discSizeReadout').innerHTML = `
<b>Avery 8960 / 8944:</b> 2 disc labels per Letter sheet<br>
<b>Disc diameter:</b> ${fmt(discTemplate.discD)} in (${fmt(mm(discTemplate.discD), 1)} mm)<br>
<b>Disc centers:</b> X ${fmt(4.25)} in; Y ${fmt(3)} in and ${fmt(8)} in from top<br>
<b>Center hole:</b> ${fmt(discTemplate.holeD)} in (${fmt(mm(discTemplate.holeD), 1)} mm)
`;
} else if (state.templateMode === 'jewel') {
$('projectTitle').textContent = 'CD jewel insert preview';
document.querySelector('.printHelp').textContent = 'Avery 8693 / 8943 layout: front and tray inserts on Letter paper.';
$('trimReadout').textContent = 'Avery 8693 jewel inserts';
$('sheetReadout').textContent = `Letter: ${fmt(sheetW())} × ${fmt(sheetH())} in`;
$('jewelSizeReadout').innerHTML = `
<b>Avery 8693 / 8943:</b> 2 jewel-case inserts per Letter sheet<br>
<b>Front insert:</b> ${fmt(jewelTemplate.slots[0].w)} × ${fmt(jewelTemplate.slots[0].h)} in<br>
<b>Tray insert with spines:</b> ${fmt(jewelTemplate.slots[1].w)} × ${fmt(jewelTemplate.slots[1].h)} in<br>
<b>Spine guides:</b> ${fmt(jewelTemplate.slots[1].spineW)} in from each tray edge<br>
<b>Sheet:</b> ${fmt(jewelTemplate.sheetW)} × ${fmt(jewelTemplate.sheetH)} in
`;
$('sizeReadout').innerHTML = `
<b>Preset:</b> Avery 8693 / 8943 CD jewel-case inserts<br>
<b>Sheet:</b> ${fmt(jewelTemplate.sheetW)} × ${fmt(jewelTemplate.sheetH)} in<br>
<b>Use:</b> clear standard or slimline jewel cases
`;
} else {
$('projectTitle').textContent = 'Cover preview';
document.querySelector('.printHelp').textContent = 'Left-to-right layout is back cover, spine, front cover.';
$('trimReadout').textContent = `Trim: ${fmt(totalTrimW())} × ${fmt(state.trimH)} in`;
$('sheetReadout').textContent = `PDF page: ${fmt(sheetW())} × ${fmt(sheetH())} in`;
$('sizeReadout').innerHTML = `
<b>Trim:</b> ${fmt(totalTrimW())} × ${fmt(state.trimH)} in<br>
<b>With bleed/PDF page:</b> ${fmt(sheetW())} × ${fmt(sheetH())} in<br>
<b>Metric trim:</b> ${fmt(mm(totalTrimW()), 1)} × ${fmt(mm(state.trimH), 1)} mm<br>
<b>Metric PDF page:</b> ${fmt(mm(sheetW()), 1)} × ${fmt(mm(sheetH()), 1)} mm
`;
}
const scale = state.zoom / 100;
$('sheetWrap').style.transform = `scale(${scale})`;
$('sheetWrap').style.width = `${sheetW() * 96 * scale}px`;
$('sheetWrap').style.height = `${sheetH() * 96 * scale}px`;
}
function renderSlots() {
for (const { key } of slotNames) {
const slot = state.slots[key];
const img = $(`art-${key}`);
const placeholder = $(`slot-${key}`).querySelector('.placeholder');
if (slot.src) {
img.src = slot.src;
img.hidden = false;
placeholder.style.display = 'none';
} else {
img.removeAttribute('src');
img.hidden = true;
placeholder.style.display = 'grid';
}
img.style.objectFit = slot.fit;
img.style.transform = `translate(-50%, -50%) translate(${slot.x}%, ${slot.y}%) rotate(${slot.rotate}deg) scale(${slot.zoom})`;
}
}
function render() {
renderDimensions();
renderSlots();
renderDiscSlots();
renderJewelSlots();
renderCombined();
renderLayers();
renderDesignerDisc();
syncLayerInputs();
syncDesignerInputs();
updateBodyClasses();
localStorage.setItem('case-cover-template-maker-autosave', JSON.stringify({ ...state, layers: nonGeneratedLayers() }));
}
function buildSlotControls() {
const wrap = $('slotControls');
wrap.innerHTML = '';
for (const { key, label } of slotNames) {
const details = document.createElement('details');
details.open = false;
details.innerHTML = `
<summary>${label}</summary>
<label for="url-${key}">Image URL</label>
<div class="urlRow">
<input id="url-${key}" type="url" placeholder="https://example.com/image.jpg" />
<button type="button" class="copyUrlBtn" onclick="copyUrl('url-${key}')">Copy</button>
</div>
<div class="buttonRow">
<button type="button" id="load-${key}">Load URL</button>
<button type="button" class="danger" id="clear-${key}">Clear image</button>
</div>
<label for="fit-${key}">Fit</label>
<select id="fit-${key}">
<option value="cover">Cover / crop to fill</option>
<option value="contain">Contain / no crop</option>
<option value="fill">Stretch to fit</option>
<option value="none">Natural size</option>
</select>
<label for="zoom-${key}">Image zoom</label>
<div class="rangeLine">
<input id="zoom-${key}" type="range" min="0.1" max="4" step="0.01" />
<output id="zoom-${key}-out"></output>
</div>
<label for="x-${key}">Move X</label>
<div class="rangeLine">
<input id="x-${key}" type="range" min="-100" max="100" step="1" />
<output id="x-${key}-out"></output>
</div>
<label for="y-${key}">Move Y</label>
<div class="rangeLine">
<input id="y-${key}" type="range" min="-100" max="100" step="1" />
<output id="y-${key}-out"></output>
</div>
<label for="rotate-${key}">Rotate</label>
<div class="rangeLine">
<input id="rotate-${key}" type="range" min="-180" max="180" step="1" />
<output id="rotate-${key}-out"></output>
</div>
<div class="buttonRow">
<button type="button" id="reset-${key}">Reset ${label.toLowerCase()}</button>
</div>
`;
wrap.appendChild(details);
$(`load-${key}`).addEventListener('click', () => {
const raw = $(`url-${key}`).value.trim();
if (!raw) return;
try {
state.templateMode = 'cover';
state.artworkMode = 'separate';
state.slots[key].src = normalizeImageUrl(raw);
syncInputs();
render();
} catch (error) {
alert(error.message || 'Invalid image URL.');
}
});
$(`fit-${key}`).addEventListener('change', (event) => {
state.slots[key].fit = event.target.value;
render();
});
for (const prop of ['zoom', 'x', 'y', 'rotate']) {
$(`${prop}-${key}`).addEventListener('input', (event) => {
state.slots[key][prop] = Number(event.target.value);
syncSlotInputs(key);
render();
});
}
$(`reset-${key}`).addEventListener('click', () => {
Object.assign(state.slots[key], { fit: 'cover', zoom: 1, x: 0, y: 0, rotate: 0 });
syncSlotInputs(key);
render();
});
$(`clear-${key}`).addEventListener('click', () => {
state.slots[key].src = '';
$(`url-${key}`).value = '';
render();
});
}
syncAllSlotInputs();
}
function buildDiscControls() {
buildDiscImportUI();
}
function buildJewelControls() {
const wrap = $('jewelControls');
wrap.innerHTML = '';
for (const { key, label } of jewelSlotNames) {
const details = document.createElement('details');
details.open = false;
details.innerHTML = `
<summary>${label}</summary>
<label for="jewel-url-${key}">Image URL</label>
<div class="urlRow">
<input id="jewel-url-${key}" type="url" placeholder="https://example.com/${key}.jpg" />
<button type="button" class="copyUrlBtn" onclick="copyUrl('jewel-url-${key}')">Copy</button>
</div>
<div class="buttonRow">
<button type="button" id="jewel-load-${key}">Load URL</button>
<button type="button" class="danger" id="jewel-clear-${key}">Clear image</button>
</div>
<label for="jewel-fit-${key}">Fit</label>
<select id="jewel-fit-${key}">
<option value="cover">Cover / crop to fill</option>
<option value="contain">Contain / no crop</option>
<option value="fill">Stretch to fit</option>
<option value="none">Natural size</option>
</select>
<label for="jewel-zoom-${key}">Image zoom</label>
<div class="rangeLine">
<input id="jewel-zoom-${key}" type="range" min="0.1" max="4" step="0.01" />
<output id="jewel-zoom-${key}-out"></output>
</div>
<label for="jewel-x-${key}">Move X</label>
<div class="rangeLine">
<input id="jewel-x-${key}" type="range" min="-100" max="100" step="1" />
<output id="jewel-x-${key}-out"></output>
</div>
<label for="jewel-y-${key}">Move Y</label>
<div class="rangeLine">
<input id="jewel-y-${key}" type="range" min="-100" max="100" step="1" />
<output id="jewel-y-${key}-out"></output>
</div>
<label for="jewel-rotate-${key}">Rotate</label>
<div class="rangeLine">
<input id="jewel-rotate-${key}" type="range" min="-180" max="180" step="1" />
<output id="jewel-rotate-${key}-out"></output>
</div>
<div class="buttonRow">
<button type="button" id="jewel-reset-${key}">Reset ${label.toLowerCase()}</button>
</div>
`;
wrap.appendChild(details);
$(`jewel-load-${key}`).addEventListener('click', () => {
const raw = $(`jewel-url-${key}`).value.trim();
if (!raw) return;
try {
state.templateMode = 'jewel';
state.jewelInserts[key].src = normalizeImageUrl(raw);
syncInputs();
render();
} catch (error) {
alert(error.message || 'Invalid image URL.');
}
});
$(`jewel-fit-${key}`).addEventListener('change', (event) => {
state.jewelInserts[key].fit = event.target.value;
render();
});
for (const prop of ['zoom', 'x', 'y', 'rotate']) {
$(`jewel-${prop}-${key}`).addEventListener('input', (event) => {
state.jewelInserts[key][prop] = Number(event.target.value);
syncJewelInputs(key);
render();
});
}
$(`jewel-reset-${key}`).addEventListener('click', () => {
Object.assign(state.jewelInserts[key], { fit: 'cover', zoom: 1, x: 0, y: 0, rotate: 0 });
syncJewelInputs(key);
render();
});
$(`jewel-clear-${key}`).addEventListener('click', () => {
state.jewelInserts[key].src = '';
$(`jewel-url-${key}`).value = '';
render();
});
}
syncAllJewelInputs();
}
function syncSlotInputs(key) {
const slot = state.slots[key];
$(`url-${key}`).value = slot.src || '';
$(`fit-${key}`).value = slot.fit;
$(`zoom-${key}`).value = slot.zoom;
$(`zoom-${key}-out`).textContent = `${Math.round(slot.zoom * 100)}%`;
$(`x-${key}`).value = slot.x;
$(`x-${key}-out`).textContent = `${slot.x}%`;
$(`y-${key}`).value = slot.y;
$(`y-${key}-out`).textContent = `${slot.y}%`;
$(`rotate-${key}`).value = slot.rotate;
$(`rotate-${key}-out`).textContent = `${slot.rotate}°`;
}
function syncAllSlotInputs() {
for (const { key } of slotNames) syncSlotInputs(key);
}
function syncDesignerInputs() {
const design = state.discDesign;
const nameEl = $('designerName');
if (nameEl) nameEl.value = design.name || '';
const urlEl = $('designerUrl');
if (urlEl) urlEl.value = design.src || '';
const fitEl = $('fit-designer');
if (fitEl) fitEl.value = design.fit || 'cover';
const zoomEl = $('zoom-designer');
if (zoomEl) { zoomEl.value = design.zoom ?? 1; $('zoom-designer-out').textContent = `${Math.round((design.zoom ?? 1) * 100)}%`; }
const xEl = $('x-designer');
if (xEl) { xEl.value = design.x ?? 0; $('x-designer-out').textContent = `${design.x ?? 0}%`; }
const yEl = $('y-designer');
if (yEl) { yEl.value = design.y ?? 0; $('y-designer-out').textContent = `${design.y ?? 0}%`; }
const rEl = $('rotate-designer');
if (rEl) { rEl.value = design.rotate ?? 0; $('rotate-designer-out').textContent = `${design.rotate ?? 0}°`; }
}
function syncAllDiscInputs() {
syncDiscTemplateInputs();
syncDesignerInputs();
}
function syncJewelInputs(key) {
const insert = state.jewelInserts[key];
if (!insert) return;
$(`jewel-url-${key}`).value = insert.src || '';
$(`jewel-fit-${key}`).value = insert.fit || 'cover';
$(`jewel-zoom-${key}`).value = insert.zoom ?? 1;
$(`jewel-zoom-${key}-out`).textContent = `${Math.round((insert.zoom ?? 1) * 100)}%`;
$(`jewel-x-${key}`).value = insert.x ?? 0;
$(`jewel-x-${key}-out`).textContent = `${insert.x ?? 0}%`;
$(`jewel-y-${key}`).value = insert.y ?? 0;
$(`jewel-y-${key}-out`).textContent = `${insert.y ?? 0}%`;
$(`jewel-rotate-${key}`).value = insert.rotate ?? 0;
$(`jewel-rotate-${key}-out`).textContent = `${insert.rotate ?? 0}°`;
}
function syncAllJewelInputs() {
for (const { key } of jewelSlotNames) syncJewelInputs(key);
}
function buildDiscImportUI() {
// Replaced dropdowns with import buttons
const wrap = $('discControls');
if (!wrap) return;
wrap.innerHTML = `
<p class="hint">Import saved disc designs into each print position from file or Nostr.</p>
`;
for (const { key, label } of discSlotNames) {
const details = document.createElement('details');
details.open = false;
details.innerHTML = `
<summary>${label}</summary>
<div class="buttonRow">
<button type="button" id="disc-import-${key}">Load from file</button>
<button type="button" id="disc-nostr-${key}">Search Nostr</button>
</div>
<div class="miniReadout" id="disc-preview-${key}">No design loaded</div>
<label>Fit</label>
<select id="disc-fit-${key}">
<option value="cover">Cover / crop to fill</option>
<option value="contain">Contain / no crop</option>
<option value="fill">Stretch to fit</option>
</select>
<label for="disc-zoom-${key}">Zoom</label>
<div class="rangeLine">
<input id="disc-zoom-${key}" type="range" min="0.1" max="4" step="0.01" />
<output id="disc-zoom-${key}-out"></output>
</div>
<label for="disc-x-${key}">Move X</label>
<div class="rangeLine">
<input id="disc-x-${key}" type="range" min="-100" max="100" step="1" />
<output id="disc-x-${key}-out"></output>
</div>
<label for="disc-y-${key}">Move Y</label>
<div class="rangeLine">
<input id="disc-y-${key}" type="range" min="-100" max="100" step="1" />
<output id="disc-y-${key}-out"></output>
</div>
<label for="disc-rotate-${key}">Rotate</label>
<div class="rangeLine">
<input id="disc-rotate-${key}" type="range" min="-180" max="180" step="1" />
<output id="disc-rotate-${key}-out"></output>
</div>
`;
wrap.appendChild(details);
}
attachDiscImportEvents();
syncDiscTemplateInputs();
}
function attachDiscImportEvents() {
for (const { key } of discSlotNames) {
const btn = $(`disc-import-${key}`);
if (btn) btn.addEventListener('click', () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'application/json,.json';
input.addEventListener('change', (e) => {
const file = e.target.files?.[0]; if (!file) return;
const reader = new FileReader();
reader.onload = () => {
try {
const parsed = JSON.parse(String(reader.result));
if (parsed?.discDesign) {
const { key: _k, layers: payloadLayers, ...design } = parsed.discDesign;
state.discs[key] = { ...state.discs[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();
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];
render();
const preview = $(`disc-preview-${key}`);
if (preview) preview.textContent = 'Loaded from project';
}
} catch { alert('Could not load disc design from this file.'); }
};
reader.readAsText(file);
});
input.click();
});
const nostrBtn = $(`disc-nostr-${key}`);
if (nostrBtn) nostrBtn.addEventListener('click', () => {
$('nostrType').value = 'disc-design';
_feedState.filterNew = 'disc-design';
_feedState.filterPopular = 'disc-design';
_feedState.filterSearch = 'disc-design';
_feedState.filterMine = 'disc-design';
$('nostrModal').classList.add('open');
state._discImportTargetSlot = key;
switchModalTab('New');
});
for (const prop of ['fit', 'zoom', 'x', 'y', 'rotate']) {
const el = $(`disc-${prop}-${key}`);
if (el) el.addEventListener('input', (e) => {
state.discs[key][prop] = prop === 'fit' ? e.target.value : Number(e.target.value);
const out = $(`disc-${prop}-${key}-out`);
if (out && prop !== 'fit') out.textContent = prop === 'rotate' ? `${e.target.value}°` : `${e.target.value}${prop === 'zoom' ? '%' : '%'}`;
render();
});
if (el && prop === 'fit') el.addEventListener('change', (e) => {
state.discs[key].fit = e.target.value;
render();
});
}
}
}
function syncDiscTemplateInputs() {
for (const { key } of discSlotNames) {
const disc = state.discs[key] || {};
const preview = $(`disc-preview-${key}`);
if (preview) preview.textContent = disc.src ? (disc.name || 'Design loaded') : 'No design loaded';
const fitEl = $(`disc-fit-${key}`);
if (fitEl) fitEl.value = disc.fit || 'cover';
const zOut = $(`disc-zoom-${key}-out`); if(zOut) zOut.textContent = `${Math.round((disc.zoom ?? 1) * 100)}%`;
const xOut = $(`disc-x-${key}-out`); if(xOut) xOut.textContent = `${disc.x ?? 0}%`;
const yOut = $(`disc-y-${key}-out`); if(yOut) yOut.textContent = `${disc.y ?? 0}%`;
const rOut = $(`disc-rotate-${key}-out`); if(rOut) rOut.textContent = `${disc.rotate ?? 0}°`;
}
}
function syncCombinedInputs() {
const c = state.combined;
$('artworkMode').value = state.artworkMode;
$('combinedUrl').value = c.src || '';
$('area-combined').value = c.area;
$('fit-combined').value = c.fit;
$('zoom-combined').value = c.zoom;
$('zoom-combined-out').textContent = `${Math.round(c.zoom * 100)}%`;
$('x-combined').value = c.x;
$('x-combined-out').textContent = `${c.x}%`;
$('y-combined').value = c.y;
$('y-combined-out').textContent = `${c.y}%`;
$('rotate-combined').value = c.rotate;
$('rotate-combined-out').textContent = `${c.rotate}°`;
}
function syncCommunityInputs() {
const rl = $('relayList');
if (rl) rl.value = state.community.relays;
}
function renderCombined() {
const layer = $('combinedLayer');
const img = $('art-combined');
const placeholder = layer.querySelector('.combinedPlaceholder');
const c = state.combined;
const usePage = c.area === 'page';
layer.style.left = usePage ? '0in' : inches(state.bleed);
layer.style.top = usePage ? '0in' : inches(state.bleed);
layer.style.width = usePage ? inches(sheetW()) : inches(totalTrimW());
layer.style.height = usePage ? inches(sheetH()) : inches(state.trimH);
if (c.src) {
img.src = c.src;
img.hidden = false;
placeholder.style.display = 'none';
} else {
img.removeAttribute('src');
img.hidden = true;
placeholder.style.display = state.artworkMode === 'combined' ? 'grid' : 'none';
}
img.style.objectFit = c.fit;
img.style.transform =
`translate(-50%, -50%) translate(${c.x}%, ${c.y}%) rotate(${c.rotate}deg) scale(${c.zoom})`;
document.body.classList.toggle('mode-combined', state.artworkMode === 'combined');
document.body.classList.toggle('mode-separate', state.artworkMode !== 'combined');
$('combinedControls').open = state.artworkMode === 'combined';
}
function renderDiscSlots() {
for (const layer of state.layers) {
if (String(layer.target || '').startsWith('disc-generated-')) {
layer._staleGenerated = true;
}
}
if (state.templateMode !== 'disc') {
state.layers = state.layers.filter(layer => !layer._staleGenerated);
}
for (const slotDef of discTemplate.slots) {
const disc = state.discs[slotDef.key] || {};
const slot = $(`disc-${slotDef.key}`);
const img = $(`art-disc-${slotDef.key}`);
const placeholder = slot.querySelector('.placeholder');
const left = slotDef.centerX - discTemplate.discD / 2;
const top = slotDef.centerY - discTemplate.discD / 2;
slot.style.left = inches(left);
slot.style.top = inches(top);
slot.style.width = inches(discTemplate.discD);
slot.style.height = inches(discTemplate.discD);
if (disc.src) {
img.src = disc.src;
img.hidden = false;
placeholder.style.display = 'none';
} else {
img.removeAttribute('src');
img.hidden = true;
placeholder.style.display = state.templateMode === 'disc' ? 'grid' : 'none';
}
img.style.objectFit = disc.fit || 'cover';
img.style.transform =
`translate(-50%, -50%) translate(${disc.x ?? 0}%, ${disc.y ?? 0}%) rotate(${disc.rotate ?? 0}deg) scale(${disc.zoom ?? 1})`;
if (state.templateMode === 'disc') syncGeneratedDiscLayers(slotDef, disc, slotDef.key);
}
state.layers = state.layers.filter(layer => !layer._staleGenerated);
discTemplate.spineStrips.forEach((strip, index) => {
const el = $(`disc-spine-${index + 1}`);
el.style.left = inches(strip.x);
el.style.top = inches(strip.y);
el.style.width = inches(strip.w);
el.style.height = inches(strip.h);
const labelEl = $(`disc-spine-label-${index + 1}`);
if (labelEl) {
labelEl.style.left = inches(strip.x);
labelEl.style.top = inches(strip.y);
labelEl.style.width = inches(strip.w);
labelEl.style.height = inches(strip.h);
const text = index < 2
? (state.discSpineLabels?.top || '')
: (state.discSpineLabels?.bottom || '');
labelEl.innerHTML = text ? `<span>${escapeHtml(text)}</span>` : '';
}
});
}
function syncGeneratedDiscLayers(slotDef, design, designKey) {
const sourceLayers = Array.isArray(design?.layers) ? design.layers : [];
const slotLeft = slotDef.centerX - discTemplate.discD / 2;
const slotTop = slotDef.centerY - discTemplate.discD / 2;
const baseZ = 2000 + (slotDef.key === 'top' ? 0 : 100);
sourceLayers.forEach((layer, index) => {
const id = `generated-${slotDef.key}-${designKey}-${layer.id || index}`;
const existing = state.layers.find(item => item.id === id);
const projected = {
...layer,
id,
name: layer.name || `${design?.name || designKey} layer`,
target: `disc-generated-${slotDef.key}`,
x: slotLeft + (Number(layer.x) || 0),
y: slotTop + (Number(layer.y) || 0),
w: Number(layer.w) || 1,
h: Number(layer.h) || 1,
z: baseZ + index,
_generatedDiscLayer: true,
_staleGenerated: false
};
if (existing) {
Object.assign(existing, projected);
} else {
state.layers.push(projected);
}
});
}
function nonGeneratedLayers() {
return state.layers.filter(layer => !layer._generatedDiscLayer);
}
function renderJewelSlots() {
for (const slotDef of jewelTemplate.slots) {
const insert = state.jewelInserts[slotDef.key];
const slot = $(`jewel-${slotDef.key}`);
const img = $(`art-jewel-${slotDef.key}`);
const placeholder = slot.querySelector('.placeholder');
slot.style.left = inches(slotDef.x);
slot.style.top = inches(slotDef.y);
slot.style.width = inches(slotDef.w);
slot.style.height = inches(slotDef.h);
if (insert?.src) {
img.src = insert.src;
img.hidden = false;
placeholder.style.display = 'none';
} else {
img.removeAttribute('src');
img.hidden = true;
placeholder.style.display = state.templateMode === 'jewel' ? 'grid' : 'none';
}
img.style.objectFit = insert?.fit || 'cover';
img.style.transform =
`translate(-50%, -50%) translate(${insert?.x ?? 0}%, ${insert?.y ?? 0}%) rotate(${insert?.rotate ?? 0}deg) scale(${insert?.zoom ?? 1})`;
if (slotDef.key === 'tray') {
const leftGuide = $('jewel-tray-spine-left');
const rightGuide = $('jewel-tray-spine-right');
if (leftGuide && rightGuide) {
leftGuide.style.left = inches(slotDef.spineW || 0.25);
rightGuide.style.right = inches(slotDef.spineW || 0.25);
}
}
}
}
function getLayer(id) {
return state.layers.find((layer) => layer.id === id) || null;
}
function nextLayerZ() {
return state.layers.reduce((max, layer) => Math.max(max, layer.z || 0), 0) + 1;
}
function makeLayerId() {
return `layer-${Date.now()}-${Math.floor(Math.random() * 10000)}`;
}
function targetBounds(target) {
const disc = (key) => {
const slot = discTemplate.slots.find((item) => item.key === key);
return {
x: slot.centerX - discTemplate.discD / 2,
y: slot.centerY - discTemplate.discD / 2,
w: discTemplate.discD,
h: discTemplate.discD
};
};
const jewel = (key) => {
const slot = jewelTemplate.slots.find((item) => item.key === key);
return { x: slot.x, y: slot.y, w: slot.w, h: slot.h };
};
switch (target) {
case 'designer-disc':
return { x: 0, y: 0, w: discTemplate.discD, h: discTemplate.discD };
case 'cover-trim':
return { x: state.bleed, y: state.bleed, w: totalTrimW(), h: state.trimH };
case 'back':
return { x: state.bleed, y: state.bleed, w: state.panelW, h: state.trimH };
case 'spine':
return { x: state.bleed + state.panelW, y: state.bleed, w: state.spineW, h: state.trimH };
case 'front':
return { x: state.bleed + state.panelW + state.spineW, y: state.bleed, w: state.panelW, h: state.trimH };
case 'disc-top':
return disc('top');
case 'disc-bottom':
return disc('bottom');
case 'jewel-front':
return jewel('front');
case 'jewel-tray':
return jewel('tray');
default:
return { x: 0, y: 0, w: sheetW(), h: sheetH() };
}
}
function applyTargetMode(target) {
if (target === 'designer-disc') {
state.appMode = 'designer';
syncModeTabsUI();
return;
}
if (target.startsWith('disc')) {
state.templateMode = 'disc';
} else if (target.startsWith('jewel')) {
state.templateMode = 'jewel';
} else if (target !== 'page') {
state.templateMode = 'cover';
}
syncInputs();
}
function addLayer(layer) {
state.layers.push(layer);
state.selectedLayerId = layer.id;
render();
}
function addImageLayer(src, name) {
const target = $('layerTarget').value;
const bounds = targetBounds(target);
applyTargetMode(target);
addLayer({
id: makeLayerId(),
type: 'image',
name,
src,
target,
x: bounds.x + bounds.w * 0.15,
y: bounds.y + bounds.h * 0.15,
w: bounds.w * 0.7,
h: bounds.h * 0.7,
rotate: 0,
opacity: 1,
z: nextLayerZ()
});
}
function addTextLayer() {
const target = $('layerTarget').value;
const bounds = targetBounds(target);
applyTargetMode(target);
addLayer({
id: makeLayerId(),
type: 'text',
name: 'Text layer',
target,
html: 'Text',
x: bounds.x + bounds.w * 0.18,
y: bounds.y + bounds.h * 0.18,
w: Math.min(2.5, bounds.w * 0.65),
h: Math.min(0.75, bounds.h * 0.25),
rotate: 0,
opacity: 1,
z: nextLayerZ(),
fontSize: 24,
color: '#111827',
align: 'center',
bold: false,
italic: false
});
}
function renderLayers() {
const overlay = $('layerOverlay');
const designerOverlay = $('designerLayerOverlay');
overlay.innerHTML = '';
if (designerOverlay) designerOverlay.innerHTML = '';
const ordered = [...state.layers].sort((a, b) => (a.z || 0) - (b.z || 0));
for (const layer of ordered) {
const isDesignerLayer = layer.target === 'designer-disc';
if (state.appMode === 'designer' && !isDesignerLayer) continue;
if (state.appMode !== 'designer' && isDesignerLayer) continue;
const el = document.createElement('div');
el.className = `freeLayer${layer.id === state.selectedLayerId ? ' selected' : ''}`;
el.dataset.layerId = layer.id;
if (isDesignerLayer) {
const discD = discTemplate.discD;
el.style.left = `${layer.x / discD * 100}%`;
el.style.top = `${layer.y / discD * 100}%`;
el.style.width = `${layer.w / discD * 100}%`;
el.style.height = `${layer.h / discD * 100}%`;
} else {
el.style.left = inches(layer.x);
el.style.top = inches(layer.y);
el.style.width = inches(layer.w);
el.style.height = inches(layer.h);
}
el.style.opacity = layer.opacity;
el.style.zIndex = layer.z || 1;
el.style.transform = `rotate(${layer.rotate || 0}deg)`;
if (layer.type === 'image') {
const img = document.createElement('img');
img.src = layer.src;
img.alt = layer.name || 'Image layer';
el.appendChild(img);
} else {
const text = document.createElement('div');
text.className = 'textLayer';
text.contentEditable = 'true';
text.spellcheck = false;
text.innerHTML = layer.html || '';
text.style.fontSize = `${layer.fontSize || 24}pt`;
text.style.color = layer.color || '#111827';
text.style.textAlign = layer.align || 'left';
text.style.fontWeight = layer.bold ? '800' : '400';
text.style.fontStyle = layer.italic ? 'italic' : 'normal';
text.addEventListener('input', (event) => {
layer.html = event.currentTarget.innerHTML;
localStorage.setItem('case-cover-template-maker-autosave', JSON.stringify(state));
});
el.appendChild(text);
}
el.addEventListener('pointerdown', startLayerDrag);
el.addEventListener('click', (event) => {
event.stopPropagation();
state.selectedLayerId = layer.id;
if (event.target.closest('.textLayer')) {
renderLayerList();
syncLayerInputs();
return;
}
render();
});
(isDesignerLayer ? designerOverlay : overlay).appendChild(el);
}
renderLayerList();
}
function renderLayerList() {
const list = $('layerList');
list.innerHTML = '';
const visibleLayers = state.layers.filter((layer) =>
state.appMode === 'designer'
? layer.target === 'designer-disc'
: layer.target !== 'designer-disc'
);
const listedLayers = visibleLayers.filter(layer => !layer._generatedDiscLayer);
if (!listedLayers.length) {
const empty = document.createElement('p');
empty.className = 'hint';
empty.textContent = 'No extra layers yet.';
list.appendChild(empty);
return;
}
const ordered = [...listedLayers].sort((a, b) => (b.z || 0) - (a.z || 0));
for (const layer of ordered) {
const item = document.createElement('button');
item.type = 'button';
item.className = `layerItem${layer.id === state.selectedLayerId ? ' active' : ''}`;
const rawName = layer.name || layer.type;
const displayName = rawName.length > 32 ? rawName.slice(0, 29) + '...' : rawName;
item.innerHTML = `<span title="${rawName}">${displayName}</span><span>${layer.type}</span>`;
item.addEventListener('click', () => {
state.selectedLayerId = layer.id;
render();
});
list.appendChild(item);
}
}
function communityKindKinds() {
switch (state.community.kind) {
case 'templates':
return [30078];
case 'disc-designs':
return [30078];
case 'notes':
return [1];
case 'profiles':
return [0];
default:
return [0, 1, 7, 30078];
}
}
function currentCommunityEvent() {
return state.community.feed.find((event) => event.id === state.community.selectedEventId) || null;
}
function communityEventProfile(event) {
if (!event?.pubkey) return null;
return state.community.profiles[event.pubkey] || null;
}
function communityEventTitle(event) {
if (!event) return 'Untitled';
if (event.kind === 30078) {
const payload = safeJsonParse(event.content, {});
return eventTemplateSummary(payload).title || 'Untitled';
}
if (event.kind === 0) {
const profile = safeJsonParse(event.content, {});
return profile.name || profile.display_name || shortPubkey(event.pubkey) || 'Profile';
}
return eventSummaryText(event) || 'Note';
}
function communityEventSubhead(event) {
if (!event) return '';
const profile = communityEventProfile(event);
if (event.kind === 30078) {
const payload = safeJsonParse(event.content, {});
const summary = eventTemplateSummary(payload);
return `${[summary.mode, categoryLabel(summary.category)].filter(Boolean).join(' • ')} • ${profile?.name || profile?.display_name || shortPubkey(event.pubkey) || 'Unknown'}`;
}
if (event.kind === 0) {
return profile?.about || 'Profile update';
}
return profile?.name || profile?.display_name || shortPubkey(event.pubkey) || 'Unknown';
}
function communityEventPreview(event) {
if (!event) return '';
if (event.kind === 30078) {
const payload = safeJsonParse(event.content, {});
return previewFromPayload(payload, payload.templateMode || 'cover');
}
const profile = safeJsonParse(event.content, {});
return profile.picture || profile.image || '';
}
function renderCommunityDetail(event) {
const body = $('communityDetailBody');
if (!event) {
body.innerHTML = '<div class="hint">Pick a template or note from the feed.</div>';
return;
}
const profile = communityEventProfile(event);
const preview = communityEventPreview(event);
const title = communityEventTitle(event);
const subhead = communityEventSubhead(event);
const tags = eventTagsList(event);
const payload = templatePayloadFromEvent(event);
const details = event.kind === 30078 ? eventTemplateSummary(payload) : null;
const commentItems = state.community.comments.slice(0, 12).map((item) => {
const author = communityEventProfile(item);
return `
<div class="threadItem">
<div class="authorRow">
<span class="chip">${escapeHtml(author?.name || author?.display_name || shortPubkey(item.pubkey) || 'unknown')}</span>
<span class="chip">${escapeHtml(formatNostrTime(item.created_at))}</span>
<span class="chip">reply</span>
</div>
<div>${escapeHtml(item.content || '')}</div>
</div>
`;
}).join('');
const reactionCount = state.community.reactions.length;
body.innerHTML = `
<div class="communityCard">
<div class="communityMeta">
<div>
<strong>${escapeHtml(title)}</strong>
<div class="hint">${escapeHtml(subhead)}</div>
</div>
<div class="pill">${escapeHtml(event.kind === 30078 ? 'Template' : event.kind === 0 ? 'Profile' : `Kind ${event.kind}`)}</div>
</div>
<div class="feedPreview">
${preview ? `<img src="${preview}" alt="" />` : '<div class="miniReadout">No preview image</div>'}
<div>
<div class="authorRow">
<span class="chip">${escapeHtml(shortPubkey(event.pubkey) || 'unknown')}</span>
<span class="chip">${escapeHtml(formatNostrTime(event.created_at))}</span>
${profile?.name ? `<span class="chip">${escapeHtml(profile.name)}</span>` : ''}
</div>
<p class="hint">${escapeHtml(event.kind === 30078 ? 'Case or disc template shared on Nostr.' : (event.content || '').slice(0, 360))}</p>
${details ? `<div class="hint"><b>Mode:</b> ${escapeHtml(details.mode)}<br><b>Category:</b> ${escapeHtml(categoryLabel(details.category) || 'Uncategorized')}<br><b>Title:</b> ${escapeHtml(details.title)}</div>` : ''}
</div>
</div>
<div class="feedTags">${tags.slice(0, 10).map((tag) => `<span class="chip">${escapeHtml(tag)}</span>`).join('')}</div>
<div class="reactionRow">
<span class="chip">${escapeHtml(`${reactionCount} reaction${reactionCount === 1 ? '' : 's'}`)}</span>
<span class="chip">${escapeHtml(`${state.community.comments.length} repl${state.community.comments.length === 1 ? 'y' : 'ies'}`)}</span>
</div>
<div class="communityDetail">
<strong>Replies</strong>
<div class="threadList">
${commentItems || '<div class="hint">No replies yet.</div>'}
</div>
</div>
</div>
`;
}
function renderCommunity() {
const feed = $('communityFeed');
feed.innerHTML = '';
const items = state.community.feed;
if (!items.length) {
const empty = document.createElement('p');
empty.className = 'hint';
empty.textContent = state.community.status || 'No posts loaded yet.';
feed.appendChild(empty);
renderCommunityDetail(currentCommunityEvent());
return;
}
for (const event of items) {
const card = document.createElement('div');
card.className = `feedItem${event.id === state.community.selectedEventId ? ' active' : ''}`;
card.innerHTML = `
<div class="feedHead">
<div>
<strong>${escapeHtml(communityEventTitle(event))}</strong>
<div class="hint">${escapeHtml(communityEventSubhead(event))}</div>
</div>
<div class="pill">${escapeHtml(event.kind === 30078 ? 'Template' : event.kind === 0 ? 'Profile' : `Kind ${event.kind}`)}</div>
</div>
<div class="feedPreview">
${communityEventPreview(event) ? `<img src="${communityEventPreview(event)}" alt="" />` : '<div class="miniReadout">No preview</div>'}
<div>
<div class="authorRow">
<span class="chip">${escapeHtml(shortPubkey(event.pubkey) || 'unknown')}</span>
<span class="chip">${escapeHtml(formatNostrTime(event.created_at))}</span>
</div>
<p class="hint">${escapeHtml(eventSummaryText(event))}</p>
</div>
</div>
<div class="feedTags">${eventTagsList(event).slice(0, 4).map((tag) => `<span class="chip">${escapeHtml(tag)}</span>`).join('')}</div>
`;
card.addEventListener('click', () => {
state.community.selectedEventId = event.id;
renderCommunity();
refreshCommunityThread();
});
feed.appendChild(card);
}
renderCommunityDetail(currentCommunityEvent());
}
async function populateCommunityProfiles(pubkeys) {
const relays = splitRelayList(state.community.relays);
const authors = [...new Set(pubkeys.filter(Boolean))];
if (!authors.length) return;
const events = await queryRelays(relays, { kinds: [0], authors }, { timeoutMs: 2500 });
for (const event of events) {
const profile = safeJsonParse(event.content, {});
state.community.profiles[event.pubkey] = {
pubkey: event.pubkey,
...profile,
created_at: event.created_at
};
}
if (state.community.myPubkey) {
state.community.myProfile = state.community.profiles[state.community.myPubkey] || state.community.myProfile;
}
}
async function refreshCommunityThread() {
const event = currentCommunityEvent();
const relays = splitRelayList(state.community.relays);
if (!event) {
state.community.comments = [];
state.community.reactions = [];
renderCommunity();
return;
}
const comments = await queryRelays(relays, { kinds: [1], '#e': [event.id] }, { timeoutMs: 2500 });
const reactions = await queryRelays(relays, { kinds: [7], '#e': [event.id] }, { timeoutMs: 2500 });
state.community.comments = comments;
state.community.reactions = reactions;
await populateCommunityProfiles([...comments, ...reactions].map((item) => item.pubkey));
renderCommunityDetail(event);
}
async function refreshCommunityFeed() {
const relays = splitRelayList(state.community.relays);
const kinds = communityKindKinds();
const filter = { kinds, limit: 120 };
if (kinds.length === 1 && kinds[0] === 30078) filter['#t'] = ['casewrap'];
const items = await queryRelays(relays, filter, { timeoutMs: 4000 });
const q = state.community.query.trim().toLowerCase();
const filtered = items.filter((event) => {
if (state.community.kind === 'disc-designs') {
const payload = event.kind === 30078 ? safeJsonParse(event.content, {}) : {};
if (payload.templateMode !== 'disc-design') return false;
}
if (!q) return true;
const profile = state.community.profiles[event.pubkey] || {};
const haystack = [
communityEventTitle(event),
communityEventSubhead(event),
eventSummaryText(event),
event.pubkey,
profile.name,
profile.display_name,
profile.about,
eventTagsList(event).join(' ')
].join(' ').toLowerCase();
return haystack.includes(q);
});
state.community.feed = filtered;
state.community.status = `Loaded ${filtered.length} post${filtered.length === 1 ? '' : 's'} from ${relays.length || 1} relay${(relays.length || 1) === 1 ? '' : 'ies'}.`;
await populateCommunityProfiles(filtered.map((event) => event.pubkey));
if (!state.community.selectedEventId || !filtered.some((event) => event.id === state.community.selectedEventId)) {
state.community.selectedEventId = filtered[0]?.id || '';
}
renderCommunity();
await refreshCommunityThread();
}
async function publishCommunityEvent(event) {
const signed = await signNostrEvent(event);
await publishToRelays(signed);
return signed;
}
async function publishCurrentTemplateToNostr() {
if (!state.meta.title?.trim()) {
throw new Error('Add a title in Project Metadata before publishing.');
}
if (!state.meta.category?.trim()) {
throw new Error('Select a category in Project Metadata before publishing.');
}
let payload, typeTag;
if (state.templateMode === 'disc') {
payload = discSheetPayload();
typeTag = 'casewrap-disc';
if (!previewFromPayload(payload, 'disc')) {
throw new Error('Assign at least one disc design before publishing.');
}
} else if (state.templateMode === 'jewel') {
payload = jewelTemplatePayload();
typeTag = 'casewrap-jewel';
if (!previewFromPayload(payload, 'jewel')) {
throw new Error('Load jewel insert artwork before publishing.');
}
} else {
payload = coverTemplatePayload();
typeTag = 'casewrap-cover';
if (!previewFromPayload(payload, 'cover')) {
throw new Error('Load cover artwork before publishing.');
}
}
const tags = [
['d', state.meta.imdbId || state.meta.title],
...getTemplateTags(payload, typeTag)
];
const evt = { kind: 30078, created_at: Math.floor(Date.now() / 1000), tags, content: JSON.stringify(payload) };
const signed = await publishCommunityEvent(evt);
cachePublishedTemplateEvent(signed);
state.community.status = `Published "${state.meta.title}" to Nostr.`;
return signed;
}
async function publishDiscDesignToNostr() {
const design = state.discDesign;
if (!design?.src) throw new Error('Load an image URL before publishing this disc design.');
if (!state.meta.category?.trim()) {
throw new Error('Select a category in Project Metadata before publishing.');
}
const payload = discDesignPayload();
const designName = (design.name || '').trim() || state.meta.title;
if (!designName?.trim()) {
throw new Error('Add a title in Project Metadata or a custom design name before publishing.');
}
const tags = [
['d', `${state.meta.imdbId || designName}`],
...getTemplateTags(payload, 'casewrap-disc-design'),
['title', designName]
];
if (state.meta.imdbId) tags.push(['i', state.meta.imdbId]);
const evt = {
kind: 30078,
created_at: Math.floor(Date.now() / 1000),
tags,
content: JSON.stringify(payload)
};
const signed = await publishCommunityEvent(evt);
cachePublishedTemplateEvent(signed);
state.community.status = `Published ${designName} to Nostr.`;
return signed;
}
function cachePublishedTemplateEvent(event) {
if (!event || event.kind !== 30078) return;
if (!_feedCache.events) _feedCache.events = [];
_feedCache.events = [event, ..._feedCache.events.filter(e => e.id !== event.id)];
_feedCache.fetchedAt = Date.now();
_feedCache.relayKey = splitRelayList(state.community.relays).join(',');
}
async function publishCommunityNote(text, replyToEvent = null) {
const content = String(text || '').trim();
if (!content) throw new Error('Enter a note first.');
if (!state.community.myPubkey) throw new Error('Sign in with Nostr to post notes or replies.');
const tags = [['t', 'casewrap']];
if (replyToEvent?.id) {
tags.push(['e', replyToEvent.id]);
if (replyToEvent.pubkey) tags.push(['p', replyToEvent.pubkey]);
}
const evt = {
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags,
content
};
const signed = await publishCommunityEvent(evt);
state.community.status = replyToEvent ? 'Reply sent.' : 'Note published.';
return signed;
}
async function likeSelectedCommunityEvent() {
if (!state.community.myPubkey) throw new Error('Sign in with Nostr to react to posts.');
const event = currentCommunityEvent();
if (!event) throw new Error('Select a post first.');
const evt = {
kind: 7,
created_at: Math.floor(Date.now() / 1000),
tags: [['e', event.id], ['p', event.pubkey]],
content: '+'
};
const signed = await publishCommunityEvent(evt);
state.community.status = 'Reaction sent.';
return signed;
}
async function followSelectedAuthor() {
const event = currentCommunityEvent();
if (!event) throw new Error('Select a post first.');
const pubkey = await getNostrPublicKey();
if (!pubkey) throw new Error('Sign in with NIP-07 first.');
const follows = new Set(state.community.myFollows.length ? state.community.myFollows : []);
follows.add(event.pubkey);
const evt = {
kind: 3,
created_at: Math.floor(Date.now() / 1000),
tags: [...follows].map((key) => ['p', key]),
content: ''
};
const signed = await publishCommunityEvent(evt);
state.community.myFollows = [...follows];
state.community.status = 'Contact list updated.';
return signed;
}
async function repostSelectedCommunityEvent() {
const event = currentCommunityEvent();
if (!event) throw new Error('Select a post first.');
const pubkey = await getNostrPublicKey();
if (!pubkey) throw new Error('Sign in with NIP-07 first.');
const evt = {
kind: 6,
created_at: Math.floor(Date.now() / 1000),
tags: [['e', event.id], ['p', event.pubkey]],
content: event.content || ''
};
const signed = await publishCommunityEvent(evt);
state.community.status = 'Reposted selected post.';
return signed;
}
async function loadCommunityIdentity() {
const pubkey = await getNostrPublicKey();
state.community.myPubkey = pubkey || '';
if (!pubkey) {
state.community.myProfile = null;
state.community.myFollows = [];
syncCommunityInputs();
return;
}
const relays = splitRelayList(state.community.relays);
const [profileEvents, contactEvents] = await Promise.all([
queryRelays(relays, { kinds: [0], authors: [pubkey] }, { timeoutMs: 2500 }),
queryRelays(relays, { kinds: [3], authors: [pubkey] }, { timeoutMs: 2500 })
]);
const profileEvent = profileEvents[0] || null;
if (profileEvent) {
state.community.myProfile = {
pubkey,
...safeJsonParse(profileEvent.content, {}),
created_at: profileEvent.created_at
};
state.community.profiles[pubkey] = state.community.myProfile;
}
const contactEvent = contactEvents[0] || null;
state.community.myFollows = contactEvent
? (contactEvent.tags || []).filter((tag) => tag[0] === 'p' && tag[1]).map((tag) => tag[1])
: [];
syncCommunityInputs();
updateNostrWidget();
}
function updateCommunityProfileReadout() {
syncCommunityInputs();
renderCommunityDetail(currentCommunityEvent());
}
function selectedLayer() {
return getLayer(state.selectedLayerId);
}
function syncLayerInputs() {
const layer = selectedLayer();
$('layerControls').open = Boolean(layer);
$('textLayerControls').style.display = layer && layer.type === 'text' ? 'block' : 'none';
$('layerImageControls').style.display = layer && layer.type === 'image' ? 'block' : 'none';
for (const id of ['layerName', 'layerX', 'layerY', 'layerW', 'layerH', 'layerRotate', 'layerOpacity']) {
$(id).disabled = !layer;
}
$('layerUrl').disabled = !layer || layer.type !== 'image';
for (const id of ['textFontSize', 'textColor', 'textAlign', 'textBold', 'textItalic']) {
$(id).disabled = !layer || layer.type !== 'text';
}
for (const id of ['layerForward', 'layerBackward', 'deleteLayer']) {
$(id).disabled = !layer;
}
if (!layer) return;
$('layerName').value = layer.name || '';
$('layerX').value = fmt(layer.x, 3);
$('layerY').value = fmt(layer.y, 3);
$('layerW').value = fmt(layer.w, 3);
$('layerH').value = fmt(layer.h, 3);
$('layerRotate').value = layer.rotate || 0;
$('layerOpacity').value = layer.opacity ?? 1;
if (layer.type === 'image') {
$('layerUrl').value = layer.src || '';
}
if (layer.type === 'text') {
$('textFontSize').value = layer.fontSize || 24;
$('textColor').value = layer.color || '#111827';
$('textAlign').value = layer.align || 'left';
}
}
function updateSelectedLayer(prop, value) {
const layer = selectedLayer();
if (!layer) return;
layer[prop] = value;
render();
}
function startLayerDrag(event) {
const el = event.currentTarget;
const layer = getLayer(el.dataset.layerId);
if (!layer) return;
state.selectedLayerId = layer.id;
const textTarget = event.target.closest('.textLayer');
if (textTarget && (document.activeElement === textTarget || event.detail > 1)) {
renderLayerList();
syncLayerInputs();
return;
}
event.preventDefault();
event.stopPropagation();
const isDesignerLayer = layer.target === 'designer-disc';
const dragHost = isDesignerLayer ? $('designerDiscWrap') : $('sheet');
const sheetRect = dragHost.getBoundingClientRect();
const startX = event.clientX;
const startY = event.clientY;
const originX = layer.x;
const originY = layer.y;
document.body.classList.add('dragging-layer');
el.setPointerCapture(event.pointerId);
const move = (moveEvent) => {
const dx = (moveEvent.clientX - startX) / sheetRect.width * (isDesignerLayer ? discTemplate.discD : sheetW());
const dy = (moveEvent.clientY - startY) / sheetRect.height * (isDesignerLayer ? discTemplate.discD : sheetH());
layer.x = originX + dx;
layer.y = originY + dy;
if (isDesignerLayer) {
el.style.left = `${layer.x / discTemplate.discD * 100}%`;
el.style.top = `${layer.y / discTemplate.discD * 100}%`;
} else {
el.style.left = inches(layer.x);
el.style.top = inches(layer.y);
}
syncLayerInputs();
};
const stop = () => {
document.body.classList.remove('dragging-layer');
el.removeEventListener('pointermove', move);
el.removeEventListener('pointerup', stop);
el.removeEventListener('pointercancel', stop);
localStorage.setItem('case-cover-template-maker-autosave', JSON.stringify(state));
};
el.addEventListener('pointermove', move);
el.addEventListener('pointerup', stop);
el.addEventListener('pointercancel', stop);
renderLayerList();
syncLayerInputs();
}
function readDimensionInputs() {
state.panelW = Number($('panelW').value) || state.panelW;
state.trimH = Number($('trimH').value) || state.trimH;
state.spineW = Number($('spineW').value) || state.spineW;
state.bleed = Number($('bleed').value) || 0;
state.safe = Number($('safe').value) || 0;
render();
}
function coverTemplatePayload() {
return {
version: SCHEMA_VERSION,
templateMode: 'cover',
meta: state.meta,
ui: { zoom: state.zoom, showGuides: state.showGuides, showBleed: state.showBleed, showCrops: state.showCrops, printGuides: state.printGuides, printCrops: state.printCrops },
coverTemplate: { preset: state.preset, panelW: state.panelW, trimH: state.trimH, spineW: state.spineW, bleed: state.bleed, safe: state.safe, artworkMode: state.artworkMode, slots: state.slots, combined: state.combined },
layers: nonGeneratedLayers().filter(l => {
const target = String(l.target || '');
return !target.startsWith('disc') && !target.startsWith('jewel');
})
};
}
function discSheetPayload() {
return {
version: SCHEMA_VERSION,
templateMode: 'disc',
meta: state.meta,
ui: { zoom: state.zoom },
discTemplate: { config: discTemplate, discs: state.discs, spineLabels: state.discSpineLabels },
layers: nonGeneratedLayers().filter(l => String(l.target || '').startsWith('disc'))
};
}
function jewelTemplatePayload() {
return {
version: SCHEMA_VERSION,
templateMode: 'jewel',
meta: state.meta,
ui: { zoom: state.zoom, showGuides: state.showGuides, printGuides: state.printGuides },
jewelTemplate: { config: jewelTemplate, inserts: state.jewelInserts },
layers: nonGeneratedLayers().filter(l => String(l.target || '').startsWith('jewel'))
};
}
function exportPayload() {
return {
version: SCHEMA_VERSION,
meta: state.meta,
ui: {
zoom: state.zoom,
showGuides: state.showGuides,
showBleed: state.showBleed,
showCrops: state.showCrops,
printGuides: state.printGuides,
printCrops: state.printCrops,
templateMode: state.templateMode,
appMode: state.appMode,
templateMode: state.templateMode
},
coverTemplate: { preset: state.preset, 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: { config: discTemplate, discs: state.discs, spineLabels: state.discSpineLabels },
jewelTemplate: { 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 triggerDownload(payload, filename) {
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
function downloadProject() {
const title = (state.meta.title || '').trim();
const category = (state.meta.category || '').trim();
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'));
} else if (state.templateMode === 'jewel') {
triggerDownload(jewelTemplatePayload(), projectFilename('jewel'));
} else {
triggerDownload(coverTemplatePayload(), projectFilename('case'));
}
}
function downloadSingleDesign() {
const title = (state.meta.title || '').trim();
const category = (state.meta.category || '').trim();
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; }
const design = state.discDesign;
if (!design?.src) return;
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 imdb = state.meta.imdbId ? state.meta.imdbId.replace(/[^a-z0-9]/gi, '').toLowerCase() : '';
const filename = [rawTitle || name, imdb, 'disc-design'].filter(Boolean).join('_') + '.json';
triggerDownload(payload, filename);
}
function closeDiscDesignerProject() {
const hasContent = state.discDesign?.src || (state.layers && state.layers.some(l => l.target === 'designer-disc'));
if (!hasContent) {
state.discDesign = { name: '', src: '', fit: 'cover', zoom: 1, x: 0, y: 0, rotate: 0 };
state.layers = state.layers.filter(l => l.target !== 'designer-disc');
state.meta = { title: "", imdbId: "", omdbApiKey: "", category: "" };
render();
return;
}
const action = confirm('Save this design before closing?\n\nOK = Save to file\nCancel = Close without saving');
if (action) {
try {
downloadSingleDesign();
} catch (e) {
const publishAnyway = confirm(`Could not save: ${e.message}\n\nPublish to Nostr instead?`);
if (publishAnyway) {
try {
publishDiscDesignToNostr();
} catch (e2) {
alert(`Error: ${e2.message}`);
return;
}
} else {
return;
}
}
}
state.discDesign = { name: '', src: '', fit: 'cover', zoom: 1, x: 0, y: 0, rotate: 0 };
state.layers = state.layers.filter(l => l.target !== 'designer-disc');
state.meta = { title: "", imdbId: "", omdbApiKey: "", category: "" };
render();
}
function closeTemplateProject() {
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.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: "" };
render();
return;
}
const action = confirm('Save this project before closing?\n\nOK = Save to file\nCancel = Close without saving');
if (action) {
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.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: "" };
render();
}
function loadProjectFromText(text) {
const parsed = JSON.parse(text);
if (parsed.version && parsed.templateMode === 'disc-design' && parsed.discDesign) {
importDiscDesignFromPayload(parsed, 'a');
return;
}
const v = parsed.version || 0;
const mode = parsed.templateMode || parsed.ui?.templateMode || '';
// v5+ cover-only: restore cover fields, leave disc and jewel designs untouched
if (v >= 4 && mode === 'cover' && !parsed.discTemplate) {
Object.assign(state, {
templateMode: 'cover',
meta: { ...state.meta, ...(parsed.meta || {}) },
zoom: parsed.ui?.zoom ?? state.zoom,
showGuides: parsed.ui?.showGuides ?? state.showGuides,
showBleed: parsed.ui?.showBleed ?? state.showBleed,
showCrops: parsed.ui?.showCrops ?? state.showCrops,
printGuides: parsed.ui?.printGuides ?? state.printGuides,
printCrops: parsed.ui?.printCrops ?? state.printCrops,
preset: parsed.coverTemplate?.preset || state.preset,
panelW: parsed.coverTemplate?.panelW ?? state.panelW,
trimH: parsed.coverTemplate?.trimH ?? state.trimH,
spineW: parsed.coverTemplate?.spineW ?? state.spineW,
bleed: parsed.coverTemplate?.bleed ?? state.bleed,
safe: parsed.coverTemplate?.safe ?? state.safe,
artworkMode: parsed.coverTemplate?.artworkMode || state.artworkMode,
slots: { ...state.slots, ...(parsed.coverTemplate?.slots || {}) },
combined: { ...state.combined, ...(parsed.coverTemplate?.combined || {}) },
layers: [
...state.layers.filter(l => {
const target = String(l.target || '');
return target.startsWith('disc') || target.startsWith('jewel');
}),
...(Array.isArray(parsed.layers) ? parsed.layers : [])
],
selectedLayerId: ''
});
syncInputs(); syncAllSlotInputs(); syncCombinedInputs(); render();
return;
}
// 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;
Object.assign(state, {
templateMode: 'disc',
meta: { ...state.meta, ...(parsed.meta || {}) },
discs: { ...state.discs }, // backward compat: old designs/sheet merged
...(spineLabels ? { discSpineLabels: { ...state.discSpineLabels, ...spineLabels } } : {}),
layers: [
...state.layers.filter(l => !String(l.target || '').startsWith('disc')),
...(Array.isArray(parsed.layers) ? parsed.layers : [])
],
selectedLayerId: ''
});
normalizeDiscState();
syncInputs(); syncAllDiscInputs(); render();
return;
}
if (v >= 5 && mode === 'jewel' && parsed.jewelTemplate) {
Object.assign(state, {
templateMode: 'jewel',
meta: { ...state.meta, ...(parsed.meta || {}) },
zoom: parsed.ui?.zoom ?? state.zoom,
showGuides: parsed.ui?.showGuides ?? state.showGuides,
printGuides: parsed.ui?.printGuides ?? state.printGuides,
jewelInserts: { ...state.jewelInserts, ...(parsed.jewelTemplate?.inserts || {}) },
layers: [
...state.layers.filter(l => !String(l.target || '').startsWith('jewel')),
...(Array.isArray(parsed.layers) ? parsed.layers : [])
],
selectedLayerId: ''
});
normalizeJewelState();
syncInputs(); syncAllJewelInputs(); render();
return;
}
// Legacy / full project file: restore everything
const source = parsed.version
? {
meta: parsed.meta || state.meta,
templateMode: parsed.ui?.templateMode || parsed.coverTemplate?.templateMode || state.templateMode,
appMode: parsed.ui?.appMode || state.appMode,
// activeDiscDesign removed in single-design model
zoom: parsed.ui?.zoom ?? state.zoom,
showGuides: parsed.ui?.showGuides ?? state.showGuides,
showBleed: parsed.ui?.showBleed ?? state.showBleed,
showCrops: parsed.ui?.showCrops ?? state.showCrops,
printGuides: parsed.ui?.printGuides ?? state.printGuides,
printCrops: parsed.ui?.printCrops ?? state.printCrops,
preset: parsed.coverTemplate?.preset || state.preset,
panelW: parsed.coverTemplate?.panelW ?? state.panelW,
trimH: parsed.coverTemplate?.trimH ?? state.trimH,
spineW: parsed.coverTemplate?.spineW ?? state.spineW,
bleed: parsed.coverTemplate?.bleed ?? state.bleed,
safe: parsed.coverTemplate?.safe ?? state.safe,
artworkMode: parsed.coverTemplate?.artworkMode || state.artworkMode,
slots: parsed.coverTemplate?.slots || {},
combined: parsed.coverTemplate?.combined || {},
discs: parsed.discTemplate?.discs || {},
// backward compat: old discDesigns merged into discs below
discSpineLabels: parsed.discTemplate?.spineLabels || parsed.discSpineLabels || state.discSpineLabels,
jewelInserts: parsed.jewelTemplate?.inserts || parsed.jewelInserts || {},
community: parsed.community || {},
layers: Array.isArray(parsed.layers) ? parsed.layers : []
}
: parsed;
const next = {
...state,
...source,
slots: { ...state.slots, ...(source.slots || {}) },
combined: { ...state.combined, ...(source.combined || {}) },
discs: { ...state.discs, ...(source.discs || {}) },
// backward compat handled in normalizeDiscState
jewelInserts: { ...state.jewelInserts, ...(source.jewelInserts || {}) },
community: { ...state.community, ...(source.community || {}) },
layers: Array.isArray(source.layers) ? source.layers : [],
selectedLayerId: ''
};
Object.assign(state, next);
normalizeDiscState();
normalizeJewelState();
syncInputs();
syncAllSlotInputs();
syncAllDiscInputs();
syncAllJewelInputs();
syncCombinedInputs();
render();
}
const _feedCache = { events: null, reactions: {}, profiles: {}, fetchedAt: 0, loading: false, relayKey: '' };
function invalidateFeedCache() {
_feedCache.events = null;
_feedCache.reactions = {};
_feedCache.profiles = {};
_feedCache.fetchedAt = 0;
}
async function fetchFeedAndReactions() {
const relays = splitRelayList(state.community.relays);
const relayKey = relays.join(',');
const age = Date.now() - _feedCache.fetchedAt;
if (_feedCache.loading) return;
if (_feedCache.events && age < 5 * 60 * 1000 && _feedCache.relayKey === relayKey) return;
_feedCache.loading = true;
_feedCache.relayKey = relayKey;
try {
const events = await queryRelays(relays, { kinds: [30078], '#t': ['casewrap'], limit: 200 }, { timeoutMs: 4500 });
_feedCache.events = events;
_feedCache.reactions = {};
_feedCache.profiles = {};
if (events.length) {
const ids = events.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 });
for (const rx of rxEvents) {
const tag = (rx.tags || []).find(t => t[0] === 'e' && t[1]);
if (tag) _feedCache.reactions[tag[1]] = (_feedCache.reactions[tag[1]] || 0) + 1;
}
}
// Fetch author profiles for all unique pubkeys
const pubkeys = [...new Set(events.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) {
const p = safeJsonParse(pe.content, {});
_feedCache.profiles[pe.pubkey] = { pubkey: pe.pubkey, ...p };
}
}
}
_feedCache.fetchedAt = Date.now();
} finally {
_feedCache.loading = false;
}
}
const _feedState = { filterNew: 'any', filterPopular: 'any', filterMine: 'any', categoryNew: '', categoryPopular: '', categoryMine: '' };
function buildRowsFromEvents(events, typeFilter = 'any', query = '', skipVersionFilter = false, categoryFilter = '') {
const q = query.trim().toLowerCase();
const rows = [];
for (const e of events) {
let payload = null;
try { payload = JSON.parse(e.content || '{}'); } catch { continue; }
if (!skipVersionFilter && (payload.version ?? 0) < MIN_SUPPORTED_SCHEMA_VERSION) continue;
const title = payload.meta?.title || payload.title || '';
const imdb = payload.meta?.imdbId || '';
const category = payload.meta?.category || payload.category || '';
const mode = payload.templateMode || (payload.coverTemplate ? 'cover' : payload.jewelTemplate ? 'jewel' : 'disc');
const displayTitle = mode === 'disc-design' ? (payload.discDesign?.name || title || '') : title;
// Skip completely unidentifiable posts
if (!displayTitle && !imdb) continue;
if (typeFilter !== 'any' && mode !== typeFilter) continue;
if (categoryFilter && category !== categoryFilter) continue;
if (q) {
const hay = `${displayTitle} ${title} ${imdb} ${category} ${(e.tags || []).map(t => t.join(' ')).join(' ')}`.toLowerCase();
if (!hay.includes(q)) continue;
}
rows.push({
e, payload,
title: displayTitle || `(${mode})`,
imdb,
category,
mode,
preview: previewFromPayload(payload, mode),
reactions: _feedCache.reactions[e.id] || 0,
createdAt: e.created_at || 0
});
}
return rows;
}
function deduplicateRows(rows) {
const seen = new Map();
for (const row of rows) {
const key = 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);
}
return [...seen.values()];
}
function buildFeedCard(row, onUse) {
const el = document.createElement('div');
el.className = 'feedCard';
const isDisc = row.mode === 'disc' || row.mode === 'disc-design';
const modeLabel = MODE_LABELS[row.mode] || row.mode;
const thumb = document.createElement('div');
thumb.className = 'feedCardThumb' + (isDisc ? ' feedCardThumbDisc' : '');
const lbl = document.createElement('span');
lbl.className = 'feedCardThumbLabel';
lbl.textContent = modeLabel;
thumb.appendChild(lbl);
if (row.preview) {
const img = document.createElement('img');
img.alt = ''; img.loading = 'lazy'; img.src = row.preview;
img.addEventListener('error', () => img.remove());
thumb.insertBefore(img, lbl);
}
const meta = document.createElement('div');
meta.className = 'feedCardMeta';
const authorProfile = _feedCache.profiles?.[row.e?.pubkey] || null;
const authorName = authorProfile?.name || authorProfile?.display_name || (row.e?.pubkey ? shortPubkey(row.e.pubkey) : '');
const authorPic = authorProfile?.picture || authorProfile?.image || '';
const authorHtml = authorName ? `<div class="feedCardAuthor">${authorPic ? `<img class="feedCardAvatar" src="${escapeHtml(authorPic)}" alt="" onerror="this.remove()" loading="lazy" />` : ''}<span class="feedCardAuthorName">${escapeHtml(authorName)}</span></div>` : '';
meta.innerHTML = `<div class="feedCardTitle" title="${escapeHtml(row.title || 'Untitled')}">${escapeHtml(row.title || 'Untitled')}</div><div class="feedCardSub">${escapeHtml([modeLabel, categoryLabel(row.category), row.imdb].filter(Boolean).join(' · '))}</div>${authorHtml}`;
const actions = document.createElement('div');
actions.className = 'feedCardActions';
const prevBtn = document.createElement('button');
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.addEventListener('click', e => { e.stopPropagation(); onUse(); });
actions.append(makeReactionBtn(row), prevBtn, btn);
el.append(thumb, meta, actions);
el.addEventListener('click', onUse);
return el;
}
function updateNostrWidget() {
const pubkey = state.community.myPubkey;
const profile = state.community.myProfile;
const loginBtn = $('nostrLoginBtn');
const profileBtn = $('nostrProfileBtn');
const avatarEl = $('nostrAvatarEl');
const nameEl = $('nostrNameEl');
if (pubkey) {
const name = profile?.name || profile?.display_name || shortPubkey(pubkey);
const pic = profile?.picture || profile?.image || '';
avatarEl.src = pic;
avatarEl.onerror = () => { avatarEl.src = ''; };
nameEl.textContent = name;
loginBtn.style.display = 'none';
profileBtn.style.display = '';
} else {
loginBtn.style.display = '';
profileBtn.style.display = 'none';
}
}
async function nostrLogin() {
const loginBtn = $('nostrLoginBtn');
const orig = loginBtn.innerHTML;
loginBtn.disabled = true;
loginBtn.innerHTML = '<span class="spinner"></span>Sign in with Nostr';
try {
const pubkey = await getNostrPublicKey();
if (!pubkey) {
alert('No Nostr extension found. Install Alby, nos2x, or another NIP-07 extension.');
return;
}
state.community.myPubkey = pubkey;
await loadCommunityIdentity();
} finally {
loginBtn.disabled = false;
loginBtn.innerHTML = orig;
}
}
async function loadMyDesigns() {
const wall = $('feedWallMine');
if (!state.community.myPubkey) {
wall.innerHTML = '<div class="hint" style="grid-column:1/-1">Sign in with Nostr (top right) to see your published designs.</div>';
return;
}
const typeFilter = _feedState.filterMine || 'any';
const categoryFilter = _feedState.categoryMine || '';
wall.innerHTML = '<div class="hint" style="grid-column:1/-1">Loading your designs…</div>';
const relays = splitRelayList(state.community.relays);
const events = await queryRelays(relays, {
kinds: [30078], '#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 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]);
if (tag) _feedCache.reactions[tag[1]] = (_feedCache.reactions[tag[1]] || 0) + 1;
}
// Cache user's own profile
if (!_feedCache.profiles[state.community.myPubkey] && state.community.myProfile) {
_feedCache.profiles[state.community.myPubkey] = state.community.myProfile;
}
}
const rows = buildRowsFromEvents(events, typeFilter, '', false, categoryFilter);
rows.sort((a, b) => b.createdAt - a.createdAt);
wall.innerHTML = '';
if (!rows.length) {
wall.innerHTML = '<div class="hint" style="grid-column:1/-1">No published designs found. Publish a case cover or disc design to see it here.</div>';
return;
}
rows.forEach(row => {
wall.appendChild(buildFeedCard(row, () => {
if (row.mode === 'disc-design') {
const target = state.appMode === 'designer'
? ('a')
: (state._discImportTargetSlot || 'top');
importDiscDesignFromPayload(row.payload, target, { openDesigner: state.appMode === 'designer' });
} else {
loadProjectFromText(JSON.stringify(row.payload));
}
$('nostrModal').classList.remove('open');
}));
});
}
function switchModalTab(tab) {
for (const t of ['New', 'Popular', 'Search', 'Mine']) {
const tabEl = $(`tab${t}`);
const panelEl = $(`panel${t}`);
if (tabEl) tabEl.classList.toggle('active', t === tab);
if (panelEl) panelEl.style.display = t === tab ? '' : 'none';
}
if (tab === 'New' || tab === 'Popular') loadFeedTab(tab);
if (tab === 'Mine') loadMyDesigns();
}
async function loadFeedTab(tab) {
const wall = $(`feedWall${tab}`);
wall.innerHTML = '<div class="hint" style="grid-column:1/-1">Loading…</div>';
await fetchFeedAndReactions();
const typeFilter = _feedState[`filter${tab}`] || 'any';
const categoryFilter = _feedState[`category${tab}`] || '';
let rows = buildRowsFromEvents(_feedCache.events || [], typeFilter, '', false, categoryFilter);
if (tab === 'New') {
rows.sort((a, b) => b.createdAt - a.createdAt);
} else {
rows = deduplicateRows(rows);
rows.sort((a, b) => b.reactions - a.reactions);
}
wall.innerHTML = '';
if (!rows.length) {
wall.innerHTML = '<div class="hint" style="grid-column:1/-1">No templates found on these relays yet.</div>';
return;
}
rows.slice(0, 60).forEach(row => {
wall.appendChild(buildFeedCard(row, () => {
if (row.mode === 'disc-design') {
const target = state.appMode === 'designer'
? ('a')
: (state._discImportTargetSlot || 'top');
importDiscDesignFromPayload(row.payload, target, { openDesigner: state.appMode === 'designer' });
} else {
loadProjectFromText(JSON.stringify(row.payload));
}
$('nostrModal').classList.remove('open');
}));
});
}
function previewFromPayload(payload, typeHint) {
try {
const mode = typeHint || payload.templateMode || 'cover';
if (mode === 'disc-design') {
return payload.discDesign?.src || '';
}
if (mode === 'disc') {
// backward compat: extract first design from old payload
const legacyDesigns = payload.discTemplate?.designs || payload.discDesigns || {};
// Prefer the assigned top-slot design, then try every design in order
const topKey = sheet.top;
if (topKey && designs[topKey]?.src) return designs[topKey].src;
const any = Object.values(designs).find(d => d?.src);
if (any) return any.src;
return payload.discs?.top?.src || payload.discTemplate?.discs?.top?.src || '';
}
if (mode === 'jewel') {
return (
payload.jewelTemplate?.inserts?.front?.src ||
payload.jewelInserts?.front?.src ||
payload.jewelTemplate?.inserts?.tray?.src ||
payload.jewelInserts?.tray?.src ||
''
);
}
// Cover: front → combined wrap → back (in that priority)
return (
payload.coverTemplate?.slots?.front?.src ||
payload.slots?.front?.src ||
payload.coverTemplate?.combined?.src ||
payload.combined?.src ||
payload.coverTemplate?.slots?.back?.src ||
payload.slots?.back?.src ||
''
);
} catch { return ''; }
}
async function reactToNostrEvent(eventId, pubkey) {
if (!window.nostr) {
alert('Install a Nostr extension (like Alby or nos2x) to react to templates.');
return false;
}
try {
const evt = {
kind: 7,
created_at: Math.floor(Date.now() / 1000),
tags: [['e', eventId], ['p', pubkey || '']],
content: '+'
};
const signed = await signNostrEvent(evt);
await publishToRelays(signed);
_feedCache.reactions[eventId] = (_feedCache.reactions[eventId] || 0) + 1;
return true;
} catch (e) {
alert(e.message || 'Could not send reaction.');
return false;
}
}
function makeReactionBtn(row) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'reactionBtn';
btn.textContent = row.reactions > 0 ? `▲ ${row.reactions}` : '▲';
btn.title = 'React to this template on Nostr';
btn.addEventListener('click', async e => {
e.stopPropagation();
const ok = await reactToNostrEvent(row.e.id, row.e.pubkey);
if (ok) {
row.reactions = _feedCache.reactions[row.e.id] || 1;
btn.textContent = `▲ ${row.reactions}`;
btn.classList.add('reacted');
}
});
return btn;
}
const MODE_LABELS = { cover: 'Case cover', disc: 'Disc sheet', jewel: 'CD jewel inserts', 'disc-design': 'Disc design' };
function buildResultCard(row, onUse) {
const el = document.createElement('div');
el.className = 'resultItem';
const isDisc = row.mode === 'disc' || row.mode === 'disc-design';
const modeLabel = MODE_LABELS[row.mode] || row.mode;
const thumb = document.createElement('div');
thumb.className = isDisc ? 'resultThumb resultThumbDisc' : 'resultThumb';
if (row.preview) {
const img = document.createElement('img');
img.alt = '';
img.loading = 'lazy';
img.src = row.preview;
img.addEventListener('error', () => img.remove());
thumb.appendChild(img);
}
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(' · ');
const rAuthor = _feedCache.profiles?.[row.e?.pubkey];
const rAuthorName = rAuthor?.name || rAuthor?.display_name || (row.e?.pubkey ? shortPubkey(row.e.pubkey) : '');
const rAuthorPic = rAuthor?.picture || rAuthor?.image || '';
const rAuthorHtml = rAuthorName ? `<div class="resultAuthor">${rAuthorPic ? `<img class="resultAuthorAvatar" src="${escapeHtml(rAuthorPic)}" alt="" onerror="this.remove()" loading="lazy" />` : ''}<span class="resultAuthorName">${escapeHtml(rAuthorName)}</span></div>` : '';
meta.innerHTML = `<b>${escapeHtml(row.title || 'Untitled')}</b><div class="hint">${escapeHtml(sub)}</div>${rAuthorHtml}`;
const actions = document.createElement('div');
actions.style.cssText = 'display:flex;flex-direction:column;gap:4px;align-items:stretch';
const prevBtn = document.createElement('button');
prevBtn.type = 'button';
prevBtn.textContent = 'Preview';
prevBtn.addEventListener('click', e => { e.stopPropagation(); openDesignPreview(row); });
const useBtn = document.createElement('button');
useBtn.type = 'button';
useBtn.textContent = 'Use this';
useBtn.addEventListener('click', onUse);
actions.append(makeReactionBtn(row), prevBtn, useBtn);
el.append(thumb, meta, actions);
return el;
}
function openDesignPreview(row) {
$('previewModalTitle').textContent = row.title || 'Untitled Design';
$('previewModalMeta').textContent = [row.category, row.imdb].filter(Boolean).join(' · ');
const content = $('previewModalContent');
content.innerHTML = '';
$('designPreviewModal')._row = row;
const p = row.payload || {};
const isDiscDesign = !!p.discDesign && !p.slots;
// Rebuild action buttons based on design type
const actionsDiv = $('previewModalActions');
const saveJsonBtn = $('previewSaveJsonBtn');
const closeBtn = $('previewModalClose');
// Clear dynamic buttons, keep Save JSON and Close
while (actionsDiv.children.length > 2) {
actionsDiv.removeChild(actionsDiv.children[0]);
}
if (isDiscDesign) {
// For disc designs, show import buttons
const import1Btn = document.createElement('button');
import1Btn.type = 'button';
import1Btn.textContent = 'Import into Disk 1';
import1Btn.id = 'previewImport1Btn';
import1Btn.addEventListener('click', () => {
importDiscDesignFromPayload(p, 'top', { openDesigner: false });
state.appMode = 'template';
syncInputs();
render();
$('designPreviewModal').classList.remove('open');
$('nostrModal').classList.remove('open');
});
const import2Btn = document.createElement('button');
import2Btn.type = 'button';
import2Btn.textContent = 'Import into Disk 2';
import2Btn.id = 'previewImport2Btn';
import2Btn.addEventListener('click', () => {
importDiscDesignFromPayload(p, 'bottom', { openDesigner: false });
state.appMode = 'template';
syncInputs();
render();
$('designPreviewModal').classList.remove('open');
$('nostrModal').classList.remove('open');
});
actionsDiv.insertBefore(import2Btn, saveJsonBtn);
actionsDiv.insertBefore(import1Btn, saveJsonBtn);
} else {
// For templates, show print button
const printBtn = document.createElement('button');
printBtn.type = 'button';
printBtn.textContent = 'Print / Save PDF';
printBtn.id = 'previewPrintBtn';
printBtn.addEventListener('click', () => {
const stateSnapshot = JSON.parse(JSON.stringify(state));
try { loadProjectFromText(JSON.stringify(p)); } catch(e) { console.error(e); }
$('designPreviewModal').classList.remove('open');
$('nostrModal').classList.remove('open');
function onAfterPrint() {
window.removeEventListener('afterprint', onAfterPrint);
Object.assign(state, stateSnapshot);
syncInputs();
render();
}
window.addEventListener('afterprint', onAfterPrint);
setTimeout(() => { render(); window.print(); }, 350);
});
actionsDiv.insertBefore(printBtn, saveJsonBtn);
}
if (p.discDesign) {
renderDiscPreviewDOM(content, p);
} else {
renderTemplatePreviewDOM(content, p);
}
$('designPreviewModal').classList.add('open');
}
function renderDiscPreviewDOM(container, payload) {
const disc = payload.discDesign || {};
const PREVIEW_SIZE = 420;
const wrap = document.createElement('div');
wrap.style.cssText = `position:relative;width:${PREVIEW_SIZE}px;height:${PREVIEW_SIZE}px;margin:0 auto;background:#111;border:2px solid var(--line);overflow:hidden;`;
if (disc.src) {
const img = document.createElement('img');
img.src = disc.src;
img.style.cssText = `position:absolute;inset:0;width:100%;height:100%;object-fit:${disc.fit || 'cover'};`;
wrap.appendChild(img);
}
const layers = payload.layers || [];
layers.forEach(layer => {
if (!layer.text && layer.type !== 'image') return;
const div = document.createElement('div');
div.style.cssText = `position:absolute;font-size:${(layer.size || 14) * (PREVIEW_SIZE / 400)}px;color:${layer.color || '#fff'};font-family:${layer.font || 'sans-serif'};font-weight:${layer.bold ? 'bold' : 'normal'};font-style:${layer.italic ? 'italic' : 'normal'};left:${(layer.x || 0) / 4}px;top:${(layer.y || 0) / 4}px;white-space:pre;pointer-events:none;`;
if (layer.type === 'text') div.textContent = layer.text;
if (layer.type === 'image' && layer.src) {
const imgEl = document.createElement('img');
imgEl.src = layer.src;
imgEl.style.cssText = `width:${(layer.w || 100) / 4}px;height:auto;`;
div.appendChild(imgEl);
}
wrap.appendChild(div);
});
container.appendChild(wrap);
if (layers.length) {
const info = document.createElement('p');
info.className = 'hint';
info.style.marginTop = '8px';
info.textContent = `${layers.length} layer${layers.length > 1 ? 's' : ''}: ` +
layers.map(l => l.text || `[${l.type}]`).join(', ');
container.appendChild(info);
}
}
function renderTemplatePreviewDOM(container, payload) {
const slots = payload.slots || {};
const row = document.createElement('div');
row.style.cssText = 'display:flex;gap:8px;align-items:stretch;justify-content:center;flex-wrap:wrap;';
['back','spine','front'].forEach(key => {
const slot = slots[key] || {};
if (!slot.src && !slot.img) return;
const img = document.createElement('img');
img.src = slot.src || slot.img;
img.style.cssText = `max-height:340px;max-width:${key === 'spine' ? '60px' : '260px'};object-fit:contain;border:1px solid var(--line);background:#111;`;
img.alt = key;
row.appendChild(img);
});
if (!row.children.length && payload.preview) {
const img = document.createElement('img');
img.src = payload.preview;
img.style.cssText = 'max-width:100%;max-height:400px;margin:0 auto;display:block;';
container.appendChild(img);
return;
}
container.appendChild(row);
}
async function runNostrSearch() {
const q = $('nostrQuery').value.trim();
const type = $('nostrType').value;
const category = $('nostrCategory').value;
const out = $('nostrResults');
out.innerHTML = '<div class="hint">Searching…</div>';
await fetchFeedAndReactions();
let rows = buildRowsFromEvents(_feedCache.events || [], type, q, false, category);
if (q) rows = deduplicateRows(rows);
rows.sort((a, b) => b.reactions - a.reactions);
out.innerHTML = rows.length ? '' : '<div class="hint">No templates found.</div>';
rows.slice(0, 40).forEach(row => {
out.appendChild(buildResultCard(row, () => {
if (row.mode === 'disc-design') {
const target = state.appMode === 'designer'
? ('a')
: (state._discImportTargetSlot || 'top');
importDiscDesignFromPayload(row.payload, target, { openDesigner: state.appMode === 'designer' });
} else {
loadProjectFromText(JSON.stringify(row.payload));
}
$('nostrModal').classList.remove('open');
}));
});
}
function renderDesignerDisc() {
const design = state.discDesign;
const img = $('designerDiscArt');
const placeholder = $('designerPlaceholder');
const label = $('designerDiscLabel');
if (label) {
const title = (state.meta.title || '').trim();
const name = (design?.name || '').trim();
const prefix = title ? `${title} / ` : '';
label.textContent = `${prefix}${name || 'New Design'}`;
}
if (design?.src) {
img.src = design.src;
img.hidden = false;
if (placeholder) placeholder.style.display = 'none';
img.style.objectFit = design.fit || 'cover';
img.style.transform = `translate(-50%,-50%) translate(${design.x ?? 0}%,${design.y ?? 0}%) rotate(${design.rotate ?? 0}deg) scale(${design.zoom ?? 1})`;
} else {
img.hidden = true;
img.removeAttribute('src');
if (placeholder) placeholder.style.display = 'grid';
}
}
function importDiscDesignFromPayload(payload, targetKey, options = {}) {
const openDesigner = options.openDesigner ?? true;
if (state.appMode === 'designer') persistCurrentDiscDesign();
// If we have a slot target (from per-slot import buttons), import directly into the slot
const slotKey = state._discImportTargetSlot || targetKey;
const isSlotImport = slotKey === 'top' || slotKey === 'bottom';
if (payload?.templateMode === 'disc-design' && payload.discDesign) {
const { key: _k, layers: payloadLayers, ...design } = payload.discDesign;
if (isSlotImport) {
state.discs[slotKey] = { ...state.discs[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 }));
}
const preview = $(`disc-preview-${slotKey}`);
if (preview) preview.textContent = design.name || 'Design loaded';
} else {
state.discDesign = { ...state.discDesign, ...design };
if (payload.meta) {
state.meta = { ...state.meta, ...payload.meta };
}
if (Array.isArray(payload.layers)) {
state.discDesign.layers = payload.layers.map(l => ({ ...l, target: 'designer-disc' }));
}
loadCurrentDiscDesignLayers();
}
} else if (payload?.discDesign) {
const { key: _k2, layers: payloadLayers2, ...design } = payload.discDesign;
if (isSlotImport) {
state.discs[slotKey] = { ...state.discs[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 }));
}
const preview = $(`disc-preview-${slotKey}`);
if (preview) preview.textContent = design.name || 'Design loaded';
} else {
state.discDesign = { ...state.discDesign, ...design };
if (payload.meta) {
state.meta = { ...state.meta, ...payload.meta };
}
loadCurrentDiscDesignLayers();
}
} else if (payload?.discTemplate?.discs?.[slotKey]) {
state.discs[slotKey] = payload.discTemplate.discs[slotKey];
const preview = $(`disc-preview-${slotKey}`);
if (preview) preview.textContent = 'Loaded from project';
}
state._discImportTargetSlot = null;
if (!isSlotImport && openDesigner) {
state.appMode = 'designer';
syncInputs();
syncDesignerInputs();
} else {
if (!isSlotImport) { state.templateMode = 'disc'; state.appMode = 'template'; }
buildDiscImportUI();
}
render();
}
function openDiscDesignNostrSearch() {
persistCurrentDiscDesign();
loadCurrentDiscDesignLayers();
$('nostrType').value = 'disc-design';
$('nostrQuery').value = '';
$('nostrCategory').value = state.meta.category || '';
$('nostrModal').classList.add('open');
switchModalTab('Search');
syncDesignerInputs();
render();
runNostrSearch();
}
function openTemplateDiscDesignNostrSearch() {
const target = state._discImportTargetSlot || 'top';
$('nostrType').value = 'disc-design';
$('nostrQuery').value = '';
$('nostrCategory').value = state.meta.category || '';
$('nostrModal').classList.add('open');
switchModalTab('Search');
runNostrSearch();
}
function syncModeTabsUI() {
const isDesigner = state.appMode === 'designer';
$('modeTabTemplate').classList.toggle('active', !isDesigner);
$('modeTabDesigner').classList.toggle('active', isDesigner);
}
function updateLayerTargetOptions() {
const select = $('layerTarget');
if (!select) return;
if (state.appMode === 'designer') {
select.innerHTML = `
<option value="designer-disc">Current disc design</option>
`;
} else {
select.innerHTML = `
<option value="page">Whole page</option>
<option value="cover-trim">Cover trim area</option>
<option value="back">Back cover</option>
<option value="spine">Spine</option>
<option value="front">Front cover</option>
<option value="jewel-front">Jewel front insert</option>
<option value="jewel-tray">Jewel tray insert</option>
`;
}
}
function switchAppMode(mode) {
if (state.appMode === 'designer') persistCurrentDiscDesign();
state.appMode = mode;
if (mode === 'designer') {
if (state.templateMode !== 'disc') {
state._prevTemplateMode = state.templateMode;
state.templateMode = 'disc';
}
loadCurrentDiscDesignLayers();
} else {
if (state._prevTemplateMode) {
state.templateMode = state._prevTemplateMode;
delete state._prevTemplateMode;
}
}
updateLayerTargetOptions();
syncModeTabsUI();
syncInputs();
render();
}
function dismissWelcomeModal() {
$('welcomeModal').classList.remove('open');
localStorage.setItem('casewrap-welcomed', '1');
}
async function runWelcomeSearch() {
const q = $('welcomeQuery').value.trim();
const type = $('welcomeType').value;
const category = $('welcomeCategory').value;
const out = $('welcomeResults');
out.innerHTML = '<div class="hint">Searching relays…</div>';
await fetchFeedAndReactions();
let rows = buildRowsFromEvents(_feedCache.events || [], type, q, false, category);
if (q) rows = deduplicateRows(rows);
rows.sort((a, b) => b.reactions - a.reactions);
out.innerHTML = rows.length ? '' : '<div class="hint">No templates found. Start from scratch below.</div>';
rows.slice(0, 40).forEach(row => {
out.appendChild(buildResultCard(row, () => {
loadProjectFromText(JSON.stringify(row.payload));
dismissWelcomeModal();
}));
});
}
function attachEvents() {
for (const mode of ['cover', 'disc']) {
$(`mode-${mode}`).addEventListener('click', () => {
state.templateMode = mode;
if (mode === 'jewel') {
state.preset = 'jewel8693';
} else if (mode === 'cover' && state.preset === 'jewel8693') {
state.preset = 'dvd';
Object.assign(state, presets.dvd);
}
syncInputs();
render();
});
}
$('preset').addEventListener('change', (event) => applyPreset(event.target.value));
for (const id of ['panelW', 'trimH', 'spineW', 'bleed', 'safe']) {
$(id).addEventListener('input', () => {
setCustomWhenEdited();
readDimensionInputs();
});
}
$('zoom').addEventListener('input', (event) => {
state.zoom = Number(event.target.value);
$('zoomOut').textContent = `${state.zoom}%`;
render();
});
for (const id of ['showGuides', 'showBleed', 'showCrops', 'printGuides', 'printCrops']) {
$(id).addEventListener('change', (event) => {
state[id] = event.target.checked;
render();
});
}
$("titleField").addEventListener("input", (e)=>{state.meta.title=e.target.value; render();});
$("imdbField").addEventListener("input", (e)=>{state.meta.imdbId=e.target.value; render();});
$("categoryField").addEventListener("change", (e)=>{state.meta.category=e.target.value; render();});
$("designerTitleField").addEventListener("input", (e)=>{state.meta.title=e.target.value; render();});
$("designerImdbField").addEventListener("input", (e)=>{state.meta.imdbId=e.target.value; render();});
$("designerCategoryField").addEventListener("change", (e)=>{state.meta.category=e.target.value; render();});
$("imdbImportBtn").addEventListener("click", async ()=>{
const imdb = state.meta.imdbId.trim();
const key = prompt("OMDb API key:", state.meta.omdbApiKey || "");
if (!imdb || !key) return;
state.meta.omdbApiKey = key;
const res = await fetch(`https://www.omdbapi.com/?i=${encodeURIComponent(imdb)}&apikey=${encodeURIComponent(key)}`);
const data = await res.json();
if (data && data.Title) { state.meta.title = data.Title; $("titleField").value = data.Title; render(); } else alert("IMDb import failed");
});
$("nostrExportBtn").addEventListener("click", async ()=>{
if (!window.nostr) return alert("No Nostr extension found");
const payload = state.templateMode === 'disc'
? discSheetPayload()
: state.templateMode === 'jewel'
? jewelTemplatePayload()
: coverTemplatePayload();
const typeTag = state.templateMode === 'disc'
? 'casewrap-disc'
: state.templateMode === 'jewel'
? 'casewrap-jewel'
: 'casewrap-cover';
const tags = [
['d', state.meta.imdbId || state.meta.title || 'template'],
...getTemplateTags(payload, typeTag)
];
const evt = { kind: 30078, created_at: Math.floor(Date.now()/1000), tags, content: JSON.stringify(payload) };
evt.pubkey = await window.nostr.getPublicKey();
const signed = await window.nostr.signEvent(evt);
await navigator.clipboard.writeText(JSON.stringify(signed));
alert("Signed Nostr event copied to clipboard");
});
$("nostrClose").addEventListener("click", () => $("nostrModal").classList.remove("open"));
$("nostrRunSearch").addEventListener("click", runNostrSearch);
$("tabNew").addEventListener("click", () => switchModalTab('New'));
$("tabPopular").addEventListener("click", () => switchModalTab('Popular'));
$("tabSearch").addEventListener("click", () => switchModalTab('Search'));
$("tabMine").addEventListener("click", () => switchModalTab('Mine'));
$("nostrLoginBtn").addEventListener("click", nostrLogin);
$("nostrProfileBtn").addEventListener("click", () => {
$('nostrModal').classList.add('open');
switchModalTab('Mine');
});
document.querySelectorAll('.feedTypeBtn').forEach(btn => {
btn.addEventListener('click', () => {
const tab = btn.dataset.tab;
const type = btn.dataset.type;
_feedState[`filter${tab}`] = type;
btn.closest('.feedTypeFilter').querySelectorAll('.feedTypeBtn').forEach(b => {
b.classList.toggle('active', b === btn);
});
if (tab === 'Mine') loadMyDesigns(); else loadFeedTab(tab);
});
});
document.querySelectorAll('.feedCategorySelect').forEach(select => {
select.addEventListener('change', () => {
const tab = select.dataset.tab;
_feedState[`category${tab}`] = select.value;
if (tab === 'Mine') loadMyDesigns(); else loadFeedTab(tab);
});
});
$('relayList').addEventListener('change', async (event) => {
state.community.relays = event.target.value.trim() || state.community.relays;
invalidateFeedCache();
await loadCommunityIdentity();
});
$('communityPublishBtn').addEventListener('click', async () => {
if (!state.community.myPubkey) {
alert('Please sign in with Nostr (top right) before publishing.');
return;
}
try {
await publishCurrentTemplateToNostr();
alert('Published to Nostr.');
} catch (error) {
alert(error.message || 'Could not publish the template.');
}
});
$('printBtn').addEventListener('click', () => {
render();
window.print();
});
// Preview modal event listeners
$('previewSaveJsonBtn').addEventListener('click', () => {
const row = $('designPreviewModal')._row;
if (!row) return;
const name = (row.title || 'design').replace(/[^a-z0-9]/gi, '_');
triggerDownload(row.payload, `${name}.json`);
});
$('previewModalClose').addEventListener('click', () => {
$('designPreviewModal').classList.remove('open');
});
$('saveProjectBtn').addEventListener('click', downloadProject);
$('closeProjectBtn').addEventListener('click', closeTemplateProject);
$('designerSaveProjectBtn').addEventListener('click', () => {
try {
downloadSingleDesign();
} catch (error) {
alert(error.message || 'Could not save this disc design.');
}
});
$('designerLoadProjectBtn').addEventListener('click', openDiscDesignNostrSearch);
$('designerCloseProjectBtn').addEventListener('click', closeDiscDesignerProject);
$('designerLoadFileBtn').addEventListener('click', () => {
persistCurrentDiscDesign();
loadCurrentDiscDesignLayers();
syncDesignerInputs();
render();
$('discProjectFile').click();
});
$('discProjectFile').addEventListener('change', (event) => {
const file = event.target.files && event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
try {
const payload = JSON.parse(String(reader.result));
importDiscDesignFromPayload(payload, 'a');
} catch {
alert('Could not load that disc project file.');
}
event.target.value = '';
};
reader.readAsText(file);
});
// App mode tabs
$('modeTabTemplate').addEventListener('click', () => switchAppMode('template'));
$('modeTabDesigner').addEventListener('click', () => switchAppMode('designer'));
// Disc designer: image/position controls
$('designerName').addEventListener('input', (e) => {
state.discDesign.name = e.target.value;
buildDiscImportUI();
render();
});
$('designerLoadBtn').addEventListener('click', () => {
const raw = $('designerUrl').value.trim();
if (!raw) return;
try {
state.discDesign.src = normalizeImageUrl(raw);
render();
} catch (error) {
alert(error.message || 'Invalid image URL.');
}
});
$('designerClearBtn').addEventListener('click', () => {
state.discDesign.src = '';
state.discDesign.layers = [];
state.layers = state.layers.filter(l => l.target !== 'designer-disc');
$('designerUrl').value = '';
render();
});
$('fit-designer').addEventListener('change', (e) => {
state.discDesign.fit = e.target.value;
render();
});
for (const prop of ['zoom', 'x', 'y', 'rotate']) {
$(`${prop}-designer`).addEventListener('input', (e) => {
state.discDesign[prop] = Number(e.target.value);
syncDesignerInputs();
render();
});
}
$('designerResetBtn').addEventListener('click', () => {
Object.assign(state.discDesign, { fit: 'cover', zoom: 1, x: 0, y: 0, rotate: 0 });
syncDesignerInputs();
render();
});
$('spineTextTop').addEventListener('input', e => {
state.discSpineLabels = { ...state.discSpineLabels, top: e.target.value };
render();
});
$('spineTextBottom').addEventListener('input', e => {
state.discSpineLabels = { ...state.discSpineLabels, bottom: e.target.value };
render();
});
$('designerPublishBtn').addEventListener('click', async () => {
if (!state.community.myPubkey) {
alert('Please sign in with Nostr (top right) before publishing.');
return;
}
try {
await publishDiscDesignToNostr();
alert(`${state.discDesign.name || 'Design'} published to Nostr.`);
} catch (error) {
alert(error.message || 'Could not publish.');
}
});
$('browseCommunityBtn').addEventListener('click', () => {
for (const t of ['New', 'Popular', 'Mine']) {
_feedState[`filter${t}`] = 'any';
document.querySelectorAll(`.feedTypeBtn[data-tab="${t}"]`).forEach(btn => {
btn.classList.toggle('active', btn.dataset.type === 'any');
});
}
$('nostrModal').classList.add('open');
switchModalTab('New');
});
$('designerBrowseCommunityBtn').addEventListener('click', () => {
for (const t of ['New', 'Popular', 'Mine']) {
_feedState[`filter${t}`] = 'disc-design';
// Update active button state
document.querySelectorAll(`.feedTypeBtn[data-tab="${t}"]`).forEach(btn => {
btn.classList.toggle('active', btn.dataset.type === 'disc-design');
});
}
$('nostrModal').classList.add('open');
switchModalTab('New');
});
$('welcomeSearchBtn').addEventListener('click', runWelcomeSearch);
$('welcomeSkipBtn').addEventListener('click', dismissWelcomeModal);
$('loadProjectBtn').addEventListener('click', () => $('projectFile').click());
$('projectFile').addEventListener('change', (event) => {
const file = event.target.files && event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
try {
loadProjectFromText(String(reader.result));
} catch (error) {
alert('Could not load that project JSON.');
console.error(error);
}
};
reader.readAsText(file);
});
$('artworkMode').addEventListener('change', (event) => {
state.artworkMode = event.target.value;
render();
});
$('load-combined').addEventListener('click', () => {
const raw = $('combinedUrl').value.trim();
if (!raw) return;
try {
state.templateMode = 'cover';
state.artworkMode = 'combined';
state.combined.src = normalizeImageUrl(raw);
syncInputs();
render();
} catch (error) {
alert(error.message || 'Invalid image URL.');
}
});
$('area-combined').addEventListener('change', (event) => {
state.combined.area = event.target.value;
render();
});
$('fit-combined').addEventListener('change', (event) => {
state.combined.fit = event.target.value;
render();
});
for (const prop of ['zoom', 'x', 'y', 'rotate']) {
$(`${prop}-combined`).addEventListener('input', (event) => {
state.combined[prop] = Number(event.target.value);
syncCombinedInputs();
render();
});
}
$('reset-combined').addEventListener('click', () => {
Object.assign(state.combined, {
area: 'trim',
fit: 'cover',
zoom: 1,
x: 0,
y: 0,
rotate: 0
});
syncCombinedInputs();
render();
});
$('clear-combined').addEventListener('click', () => {
state.combined.src = '';
$('combinedUrl').value = '';
render();
});
$('clearBtn').addEventListener('click', () => {
for (const { key } of slotNames) {
state.slots[key].src = '';
$(`url-${key}`).value = '';
}
state.combined.src = '';
$('combinedUrl').value = '';
render();
});
$('clearDiscBtn').addEventListener('click', () => {
state.discDesign.src = '';
state.discDesign.layers = [];
state.layers = state.layers.filter(l => l.target !== 'designer-disc');
$('designerUrl').value = '';
render();
});
$('clearJewelBtn').addEventListener('click', () => {
for (const { key } of jewelSlotNames) {
state.jewelInserts[key].src = '';
const input = $(`jewel-url-${key}`);
if (input) input.value = '';
}
render();
});
$('addImageLayerBtn').addEventListener('click', () => {
const raw = $('newLayerUrl').value.trim();
if (!raw) return;
try {
const url = normalizeImageUrl(raw);
addImageLayer(url, url.split('/').pop() || 'Image layer');
$('newLayerUrl').value = '';
} catch (error) {
alert(error.message || 'Invalid image URL.');
}
});
$('addTextLayerBtn').addEventListener('click', addTextLayer);
$('sheet').addEventListener('click', () => {
state.selectedLayerId = '';
render();
});
$('layerName').addEventListener('input', (event) => updateSelectedLayer('name', event.target.value));
$('layerUrl').addEventListener('change', (event) => {
const raw = event.target.value.trim();
if (!raw) {
updateSelectedLayer('src', '');
return;
}
try {
updateSelectedLayer('src', normalizeImageUrl(raw));
} catch (error) {
alert(error.message || 'Invalid image URL.');
syncLayerInputs();
}
});
for (const [id, prop] of [
['layerX', 'x'],
['layerY', 'y'],
['layerW', 'w'],
['layerH', 'h'],
['layerRotate', 'rotate'],
['layerOpacity', 'opacity']
]) {
$(id).addEventListener('input', (event) => updateSelectedLayer(prop, Number(event.target.value)));
}
$('textFontSize').addEventListener('input', (event) => updateSelectedLayer('fontSize', Number(event.target.value)));
$('textColor').addEventListener('input', (event) => updateSelectedLayer('color', event.target.value));
$('textAlign').addEventListener('change', (event) => updateSelectedLayer('align', event.target.value));
$('textBold').addEventListener('click', () => {
const layer = selectedLayer();
if (!layer || layer.type !== 'text') return;
layer.bold = !layer.bold;
render();
});
$('textItalic').addEventListener('click', () => {
const layer = selectedLayer();
if (!layer || layer.type !== 'text') return;
layer.italic = !layer.italic;
render();
});
$('layerForward').addEventListener('click', () => {
const layer = selectedLayer();
if (!layer) return;
layer.z = nextLayerZ();
render();
});
$('layerBackward').addEventListener('click', () => {
const layer = selectedLayer();
if (!layer) return;
const minZ = state.layers.reduce((min, item) => Math.min(min, item.z || 0), layer.z || 0);
layer.z = minZ - 1;
render();
});
$('deleteLayer').addEventListener('click', () => {
if (!state.selectedLayerId) return;
state.layers = state.layers.filter((layer) => layer.id !== state.selectedLayerId);
state.selectedLayerId = '';
render();
});
window.addEventListener('keydown', (event) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'p') {
render();
}
});
}
function restoreAutosave() {
const saved = localStorage.getItem('case-cover-template-maker-autosave');
if (!saved) return;
try {
const parsed = JSON.parse(saved);
Object.assign(state, parsed, {
slots: { ...state.slots, ...(parsed.slots || {}) },
combined: { ...state.combined, ...(parsed.combined || {}) },
discs: { ...state.discs, ...(parsed.discs || {}) },
// backward compat: old disc designs loaded via normalizeDiscState
jewelInserts: { ...state.jewelInserts, ...(parsed.jewelInserts || parsed.jewelTemplate?.inserts || {}) },
community: { ...state.community, ...(parsed.community || {}) },
layers: Array.isArray(parsed.layers) ? parsed.layers : [],
selectedLayerId: ''
});
normalizeDiscState();
normalizeJewelState();
if ((parsed.appMode || parsed.ui?.appMode) === 'designer') {
loadCurrentDiscDesignLayers();
}
} catch (error) {
console.warn('Autosave restore failed', error);
}
}
function init() {
restoreAutosave();
state.appMode = state.appMode || 'template';
normalizeDiscState();
normalizeJewelState();
populateCategorySelects();
if (!localStorage.getItem('casewrap-welcomed')) {
$('welcomeModal').classList.add('open');
}
buildSlotControls();
buildDiscControls();
buildJewelControls();
attachEvents();
updateLayerTargetOptions();
syncInputs();
syncAllSlotInputs();
syncAllDiscInputs();
syncAllJewelInputs();
syncCombinedInputs();
syncCommunityInputs();
syncDesignerInputs();
updateNostrWidget();
render();
}
init();
</script>
</body>
</html>