Files
Artstr-Studio/src/index.html
T

5196 lines
191 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>
<style id="dynamicPageStyle">
@page { size: 11in 8.5in; margin: 0; }
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 { 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: #f4f5f7;
--noise: none;
--panel: #ffffff;
--ink: #111827;
--muted: #6b7280;
--line: #d1d5db;
--accent: #2563eb;
--warn: #dc2626;
--ok: #15803d;
--shadow: 0 18px 50px rgba(15, 23, 42, .12);
--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: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
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;
}
h1 {
font-size: 1.25rem;
line-height: 1.2;
margin: 0 0 6px;
}
.subhead {
color: var(--muted);
font-size: .9rem;
line-height: 1.4;
margin: 0;
}
fieldset {
border: 1px solid var(--line);
border-radius: 8px;
margin: 0;
padding: 14px;
background: #fff;
}
legend {
padding: 0 6px;
font-weight: 750;
font-size: .9rem;
}
label {
display: block;
font-size: .8rem;
color: #374151;
margin: 10px 0 5px;
font-weight: 650;
}
.row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
input[type="number"], input[type="text"], input[type="url"], textarea, select {
width: 100%;
border: 1px solid var(--line);
border-radius: 8px;
padding: 8px 10px;
background: #fff;
color: var(--ink);
}
input[type="range"] {
width: 100%;
}
textarea {
min-height: 84px;
resize: vertical;
}
.hint {
color: var(--muted);
font-size: .75rem;
line-height: 1.35;
margin-top: 7px;
}
.buttonRow {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
button {
border: 1px solid var(--line);
border-radius: 8px;
padding: 8px 11px;
background: #fff;
color: var(--ink);
cursor: pointer;
font-weight: 700;
font-size: .86rem;
}
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: 8px;
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: 8px;
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;
}
.quickActions .primary {
grid-column: 1 / -1;
}
.workflowStep legend::before {
content: attr(data-step);
display: inline-grid;
place-items: center;
width: 22px;
height: 22px;
margin-right: 7px;
border-radius: 999px;
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: 999px;
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; }
#metadataFieldset { order: 70; }
#communityFieldset { order: 70; }
aside > details { order: 90; }
.appModeTabs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.appModeTab.active {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
body.dark .appModeTab.active { color: #fff; }
.designTabs {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 6px;
margin-top: 4px;
}
.designTab.active {
background: var(--accent);
border-color: var(--accent);
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;
}
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: 8px;
padding: 7px 10px;
box-shadow: 0 1px 2px rgba(15, 23, 42, .04);
}
.previewShell {
background: #e5e7eb;
border: 1px solid #cbd5e1;
border-radius: 8px;
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: 999px;
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;
}
.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: 999px;
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: 999px;
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: 8px;
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: 8px;
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: 8px;
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: 8px;
padding: 8px 10px;
background: rgba(127,127,127,.05);
display: grid;
gap: 6px;
}
.profileCard {
border: 1px solid var(--line);
border-radius: 8px;
padding: 10px;
background: rgba(127,127,127,.05);
display: grid;
gap: 8px;
}
.avatar {
width: 36px;
height: 36px;
border-radius: 999px;
object-fit: cover;
border: 1px solid var(--line);
background: #f8fafc;
flex: 0 0 auto;
}
.layerItem {
width: 100%;
border-radius: 8px;
display: flex;
justify-content: space-between;
gap: 8px;
text-align: left;
}
.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: 8px;
}
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: 8px;
background: #f8fafc;
border: 1px solid var(--line);
color: #475569;
font-size: .78rem;
line-height: 1.45;
}
.slotGrid {
display: grid;
gap: 8px;
}
.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">
<h1>CaseWrap Studio</h1>
<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">Template</button>
<button type="button" class="appModeTab" id="modeTabDesigner">Disc Designer</button>
</div>
<div class="quickActions designer-only" aria-label="Designer actions">
<button type="button" id="designerSaveProjectBtn">Save Design</button>
<button type="button" id="designerLoadProjectBtn">Load Design</button>
<button type="button" id="designerLoadFileBtn">Load File</button>
<input id="discProjectFile" type="file" accept="application/json,.json" hidden />
</div>
<div class="quickActions template-only" aria-label="Primary actions">
<button class="primary" type="button" id="quickPrintBtn">Print / Save PDF</button>
<button type="button" id="quickSaveBtn">Save Project</button>
<button type="button" id="browseCommunityBtn">Browse Templates</button>
</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" />
<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>
<button type="button" id="communityPublishBtn" class="primary">Publish to Nostr</button>
<button type="button" id="nostrExportBtn">Copy Nostr event</button>
<button type="button" id="nostrImportBtn">Import from Nostr</button>
</div>
<p class="hint">IMDb import uses OMDb-compatible endpoint + API key. Publishing requires a Nostr extension (Alby, nos2x).</p>
</details>
</fieldset>
<fieldset class="workflowStep template-only" id="exportFieldset">
<legend data-step="4">Export</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"><input id="showGuides" type="checkbox" checked /> Show fold/safe/trim guides on screen</label>
<label class="toggle"><input id="showBleed" type="checkbox" checked /> Show bleed shading on screen</label>
<label class="toggle"><input id="showCrops" type="checkbox" checked /> Show crop marks on screen</label>
<label class="toggle"><input id="printGuides" type="checkbox" /> Include guides in PDF</label>
<label class="toggle"><input id="printCrops" type="checkbox" checked /> Include crop marks in PDF</label>
</div>
<div class="buttonRow">
<button class="primary" id="printBtn">Print / Save PDF</button>
<button id="saveProjectBtn">Save project</button>
<button id="loadProjectBtn">Load project</button>
<input id="projectFile" type="file" accept="application/json,.json" hidden />
</div>
<p class="hint">Export PDF opens your print dialog. Choose <b>Save as PDF</b>, use <b>100% scale</b>, and disable browser headers/footers.</p>
</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>
<input id="combinedUrl" type="url" placeholder="https://example.com/wrap.jpg" />
<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">Create disc designs in the Disc Designer tab, then assign them to print positions here.</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>Active design</label>
<div class="designTabs">
<button type="button" class="designTab active" id="designTab-a">A</button>
<button type="button" class="designTab" id="designTab-b">B</button>
<button type="button" class="designTab" id="designTab-c">C</button>
<button type="button" class="designTab" id="designTab-d">D</button>
</div>
<label for="designerName">Design name</label>
<input id="designerName" type="text" placeholder="e.g. Front of disc" />
<label for="designerUrl">Image URL</label>
<input id="designerUrl" type="url" placeholder="https://example.com/disc.jpg" />
<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">Add layer to</label>
<select id="layerTarget">
<option value="disc-top">Disc 1 position</option>
<option value="disc-bottom">Disc 2 position</option>
</select>
<div class="buttonRow">
<input id="newLayerUrl" type="url" placeholder="https://example.com/layer.png" />
<button type="button" id="addImageLayerBtn">Add image layer</button>
<button type="button" id="addTextLayerBtn">Add text layer</button>
</div>
<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>
<input id="layerUrl" type="url" placeholder="https://example.com/layer.png" />
</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>
<div class="buttonRow">
<button type="button" id="designerPublishBtn" class="primary">Publish to Nostr</button>
<button type="button" id="designerNostrLoadBtn">Load from Nostr</button>
<select id="designerImportTarget" aria-label="Load into design slot">
<option value="a">into Design A</option>
<option value="b">into Design B</option>
<option value="c">into Design C</option>
<option value="d">into Design D</option>
</select>
</div>
<p class="hint">Disc designs publish as reusable single-disc designs. Load them into any A-D slot before assigning a print sheet.</p>
</details>
</fieldset>
</aside>
<main>
<div class="topbar">
<div>
<h1 id="projectTitle">Cover preview</h1>
<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>
<script>
const $ = (id) => document.getElementById(id);
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 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 discDesignNames = [
{ key: 'a', label: 'Design A' },
{ key: 'b', label: 'Design B' },
{ key: 'c', label: 'Design C' },
{ key: 'd', label: 'Design D' }
];
const state = {
meta: { title: "", imdbId: "", omdbApiKey: "", category: "" },
darkMode: false,
appMode: 'template',
activeDiscDesign: 'a',
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 }
},
discDesigns: {
a: { name: 'Design A', src: '', fit: 'cover', zoom: 1, x: 0, y: 0, rotate: 0 },
b: { name: 'Design B', src: '', fit: 'cover', zoom: 1, x: 0, y: 0, rotate: 0 },
c: { name: 'Design C', src: '', fit: 'cover', zoom: 1, x: 0, y: 0, rotate: 0 },
d: { name: 'Design D', src: '', fit: 'cover', zoom: 1, x: 0, y: 0, rotate: 0 }
},
discSheet: {
top: 'a',
bottom: 'b'
},
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(key) {
if (key === state.activeDiscDesign) persistDesignerLayers(key);
const design = state.discDesigns[key];
if (!design) throw new Error('Disc design not found.');
if (!design.src) throw new Error('Load an image URL before publishing this disc design.');
const { layers: _embeddedLayers, ...discDesign } = design;
return {
version: SCHEMA_VERSION,
meta: state.meta,
templateMode: 'disc-design',
discDesign: {
key,
...discDesign
},
layers: Array.isArray(design.layers) ? design.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() {
for (const { key, label } of discDesignNames) {
state.discDesigns[key] = {
name: label,
src: '',
fit: 'cover',
zoom: 1,
x: 0,
y: 0,
rotate: 0,
layers: [],
...(state.discDesigns[key] || {})
};
if (!Array.isArray(state.discDesigns[key].layers)) state.discDesigns[key].layers = [];
}
state.discSheet = {
top: state.discSheet?.top || 'a',
bottom: state.discSheet?.bottom || 'b'
};
if (state.discs?.top?.src && !state.discDesigns.a.src) {
Object.assign(state.discDesigns.a, { name: 'Design A', ...state.discs.top });
}
if (state.discs?.bottom?.src && !state.discDesigns.b.src) {
Object.assign(state.discDesigns.b, { name: 'Design B', ...state.discs.bottom });
}
}
function currentDesignerLayers() {
return state.layers.filter(l => l.target === 'designer-disc');
}
function persistDesignerLayers(key = state.activeDiscDesign || 'a') {
if (!state.discDesigns[key]) return;
state.discDesigns[key].layers = currentDesignerLayers().map(layer => ({ ...layer, target: 'designer-disc' }));
}
function loadDesignerLayers(key = state.activeDiscDesign || 'a') {
const layers = Array.isArray(state.discDesigns[key]?.layers) ? state.discDesigns[key].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 key = state.activeDiscDesign || 'a';
const design = state.discDesigns[key];
const name = design?.name && design.name !== `Design ${key.toUpperCase()}` ? design.name : `Design ${key.toUpperCase()}`;
$('projectTitle').textContent = `Disc Designer`;
document.querySelector('.printHelp').textContent = name;
$('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>
<input id="url-${key}" type="url" placeholder="https://example.com/image.jpg" />
<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() {
const wrap = $('discControls');
wrap.innerHTML = '';
const row = document.createElement('div');
row.className = 'row';
row.innerHTML = `
<div>
<label for="disc-sheet-top">Disc 1 design</label>
<select id="disc-sheet-top"></select>
</div>
<div>
<label for="disc-sheet-bottom">Disc 2 design</label>
<select id="disc-sheet-bottom"></select>
</div>
`;
wrap.appendChild(row);
for (const slot of discTemplate.slots) {
const select = $(`disc-sheet-${slot.key}`);
select.addEventListener('change', (event) => {
state.discSheet[slot.key] = event.target.value;
render();
});
}
const importRow = document.createElement('div');
importRow.style.marginTop = '12px';
importRow.innerHTML = `
<label style="font-size:.8rem;font-weight:650;color:#374151;display:block;margin-bottom:5px">Import a disc design</label>
<div style="display:grid;grid-template-columns:1fr auto;gap:8px;align-items:center">
<select id="importDiscTarget">
<option value="a">into Design A</option>
<option value="b">into Design B</option>
<option value="c">into Design C</option>
<option value="d">into Design D</option>
</select>
<div class="buttonRow" style="margin:0;flex-wrap:nowrap">
<button type="button" id="importDiscFileBtn">From file</button>
<button type="button" id="searchDiscNostrBtn">Search Nostr</button>
<input id="discDesignFile" type="file" accept="application/json,.json" hidden />
</div>
</div>
`;
wrap.appendChild(importRow);
syncDiscAssignmentInputs();
}
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>
<input id="jewel-url-${key}" type="url" placeholder="https://example.com/${key}.jpg" />
<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 key = state.activeDiscDesign || 'a';
const design = state.discDesigns[key];
if (!design) return;
for (const k of ['a', 'b', 'c', 'd']) {
const tab = $(`designTab-${k}`);
if (tab) tab.classList.toggle('active', k === key);
}
const nameEl = $('designerName');
if (nameEl) nameEl.value = design.name || `Design ${key.toUpperCase()}`;
const urlEl = $('designerUrl');
if (urlEl) urlEl.value = design.src || '';
const fitEl = $('fit-designer');
if (fitEl) fitEl.value = design.fit || 'cover';
const targetEl = $('designerImportTarget');
if (targetEl) targetEl.value = key;
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() {
syncDiscAssignmentInputs();
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 syncDiscAssignmentInputs() {
for (const { key } of discTemplate.slots) {
const select = $(`disc-sheet-${key}`);
if (!select) continue;
const current = state.discSheet[key] || (key === 'top' ? 'a' : 'b');
select.innerHTML = discDesignNames
.map((design) => {
const item = state.discDesigns[design.key];
const label = item?.name || design.label;
return `<option value="${design.key}">${escapeHtml(label)}</option>`;
})
.join('');
select.value = current;
}
}
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 designKey = state.discSheet[slotDef.key] || (slotDef.key === 'top' ? 'a' : 'b');
const disc = state.discDesigns[designKey] || state.discDesigns.a;
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;
img.style.transform =
`translate(-50%, -50%) translate(${disc.x}%, ${disc.y}%) rotate(${disc.rotate}deg) scale(${disc.zoom})`;
if (state.templateMode === 'disc') syncGeneratedDiscLayers(slotDef, disc, designKey);
}
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' : ''}`;
item.innerHTML = `<span>${layer.name || layer.type}</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.');
}
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(key) {
const design = state.discDesigns[key];
if (!design?.src) throw new Error('Load an image URL before publishing this disc design.');
const payload = discDesignPayload(key);
const designName = design.name && design.name !== `Design ${key.toUpperCase()}` ? design.name : 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}-${key}`],
...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.');
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() {
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, designs: state.discDesigns, sheet: state.discSheet, 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,
activeDiscDesign: state.activeDiscDesign
},
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 },
discTemplate: { config: discTemplate, designs: state.discDesigns, sheet: state.discSheet, 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() {
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 key = state.activeDiscDesign || 'a';
const design = state.discDesigns[key];
if (!design) return;
const payload = discDesignPayload(key);
const name = (design.name || `Design ${key.toUpperCase()}`)
.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
const title = (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 = [title || name, imdb, 'design', key].filter(Boolean).join('_') + '.json';
triggerDownload(payload, filename);
}
function loadProjectFromText(text) {
const parsed = JSON.parse(text);
if (parsed.version && parsed.templateMode === 'disc-design' && parsed.discDesign) {
const key = parsed.discDesign.key && state.discDesigns[parsed.discDesign.key] ? parsed.discDesign.key : (state.activeDiscDesign || 'a');
importDiscDesignFromPayload(parsed, key);
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 || {}) },
discDesigns: { ...state.discDesigns, ...designs },
discSheet: { ...state.discSheet, ...sheet },
...(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: parsed.ui?.activeDiscDesign || state.activeDiscDesign,
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 || {},
discDesigns: parsed.discTemplate?.designs || parsed.discDesigns || {},
discSheet: parsed.discTemplate?.sheet || parsed.discSheet || {},
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 || {}) },
discDesigns: { ...state.discDesigns, ...(source.discDesigns || {}) },
discSheet: { ...state.discSheet, ...(source.discSheet || {}) },
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 btn = document.createElement('button');
btn.type = 'button'; btn.textContent = 'Use';
btn.addEventListener('click', e => { e.stopPropagation(); onUse(); });
actions.append(makeReactionBtn(row), 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 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();
}
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'
? ($('designerImportTarget')?.value || state.activeDiscDesign || 'a')
: ($('importDiscTarget')?.value || 'a');
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'
? ($('designerImportTarget')?.value || state.activeDiscDesign || 'a')
: ($('importDiscTarget')?.value || 'a');
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') {
const designs = payload.discTemplate?.designs || payload.discDesigns || {};
const sheet = payload.discTemplate?.sheet || payload.discSheet || {};
// 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 useBtn = document.createElement('button');
useBtn.type = 'button';
useBtn.textContent = 'Use this';
useBtn.addEventListener('click', onUse);
actions.append(makeReactionBtn(row), useBtn);
el.append(thumb, meta, actions);
return el;
}
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'
? ($('designerImportTarget')?.value || state.activeDiscDesign || 'a')
: ($('importDiscTarget')?.value || 'a');
importDiscDesignFromPayload(row.payload, target, { openDesigner: state.appMode === 'designer' });
} else {
loadProjectFromText(JSON.stringify(row.payload));
}
$('nostrModal').classList.remove('open');
}));
});
}
function renderDesignerDisc() {
const key = state.activeDiscDesign || 'a';
const design = state.discDesigns[key];
const img = $('designerDiscArt');
const placeholder = $('designerPlaceholder');
const label = $('designerDiscLabel');
if (label) {
const custom = design?.name && design.name !== `Design ${key.toUpperCase()}` ? ` — ${design.name}` : '';
label.textContent = `Design ${key.toUpperCase()}${custom}`;
}
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;
const key = targetKey || state.activeDiscDesign || 'a';
if (state.appMode === 'designer') persistDesignerLayers(state.activeDiscDesign || 'a');
if (payload?.templateMode === 'disc-design' && payload.discDesign) {
const { key: _k, ...design } = payload.discDesign;
const importedLayers = Array.isArray(payload.layers)
? payload.layers
: Array.isArray(design.layers)
? design.layers
: [];
delete design.layers;
state.discDesigns[key] = {
...state.discDesigns[key],
...design,
layers: importedLayers.map(l => ({ ...l, target: 'designer-disc' }))
};
} else if (payload?.discTemplate?.designs) {
const first = Object.values(payload.discTemplate.designs).find(d => d?.src);
if (first) state.discDesigns[key] = { ...state.discDesigns[key], ...first };
} else if (payload?.discDesigns) {
const first = Object.values(payload.discDesigns).find(d => d?.src);
if (first) state.discDesigns[key] = { ...state.discDesigns[key], ...first };
}
if (openDesigner) {
state.activeDiscDesign = key;
state.appMode = 'designer';
loadDesignerLayers(key);
} else {
state.templateMode = 'disc';
state.appMode = 'template';
state.discSheet.top = state.discSheet.top || key;
state.discSheet.bottom = state.discSheet.bottom || key;
}
syncDiscAssignmentInputs();
syncDesignerInputs();
render();
}
function askDiscDesignSlot(defaultKey = state.activeDiscDesign || 'a') {
const select = $('designerImportTarget') || $('importDiscTarget');
const key = select?.value || defaultKey || 'a';
return state.discDesigns[key] ? key : 'a';
}
function openDiscDesignNostrSearch() {
const target = askDiscDesignSlot();
persistDesignerLayers(state.activeDiscDesign || 'a');
state.activeDiscDesign = target;
loadDesignerLayers(target);
const importTarget = $('importDiscTarget');
if (importTarget) importTarget.value = target;
const designerTarget = $('designerImportTarget');
if (designerTarget) designerTarget.value = target;
$('nostrType').value = 'disc-design';
$('nostrQuery').value = '';
$('nostrCategory').value = state.meta.category || '';
$('nostrModal').classList.add('open');
switchModalTab('Search');
syncDesignerInputs();
render();
runNostrSearch();
}
function openTemplateDiscDesignNostrSearch() {
const target = $('importDiscTarget')?.value || 'a';
const designerTarget = $('designerImportTarget');
if (designerTarget) designerTarget.value = target;
$('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') persistDesignerLayers();
state.appMode = mode;
if (mode === 'designer') {
if (state.templateMode !== 'disc') {
state._prevTemplateMode = state.templateMode;
state.templateMode = 'disc';
}
loadDesignerLayers(state.activeDiscDesign || 'a');
} else {
if (state._prevTemplateMode) {
state.templateMode = state._prevTemplateMode;
delete state._prevTemplateMode;
}
}
updateLayerTargetOptions();
syncModeTabsUI();
syncInputs();
render();
}
function switchActiveDesign(key) {
persistDesignerLayers();
state.activeDiscDesign = key;
loadDesignerLayers(key);
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', 'jewel']) {
$(`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);
});
});
$("nostrImportBtn").addEventListener("click", ()=>{
const raw = prompt("Paste Nostr event JSON"); if (!raw) return;
try { const evt = JSON.parse(raw); if (evt.content) loadProjectFromText(evt.content); } catch { alert("Bad event JSON"); }
});
$('relayList').addEventListener('change', async (event) => {
state.community.relays = event.target.value.trim() || state.community.relays;
invalidateFeedCache();
await loadCommunityIdentity();
});
$('communityPublishBtn').addEventListener('click', async () => {
try {
await publishCurrentTemplateToNostr();
alert('Published to Nostr.');
} catch (error) {
alert(error.message || 'Could not publish the template.');
}
});
$('printBtn').addEventListener('click', () => {
render();
window.print();
});
$('saveProjectBtn').addEventListener('click', downloadProject);
$('designerSaveProjectBtn').addEventListener('click', () => {
try {
downloadSingleDesign();
} catch (error) {
alert(error.message || 'Could not save this disc design.');
}
});
$('designerLoadProjectBtn').addEventListener('click', openDiscDesignNostrSearch);
$('designerLoadFileBtn').addEventListener('click', () => {
const target = askDiscDesignSlot();
persistDesignerLayers(state.activeDiscDesign || 'a');
state.activeDiscDesign = target;
loadDesignerLayers(target);
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, state.activeDiscDesign || '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: design A/B/C/D tabs
for (const key of ['a', 'b', 'c', 'd']) {
$(`designTab-${key}`).addEventListener('click', () => switchActiveDesign(key));
}
// Disc designer: image/position controls
$('designerName').addEventListener('input', (e) => {
state.discDesigns[state.activeDiscDesign].name = e.target.value;
syncDiscAssignmentInputs();
render();
});
$('designerLoadBtn').addEventListener('click', () => {
const raw = $('designerUrl').value.trim();
if (!raw) return;
try {
state.discDesigns[state.activeDiscDesign].src = normalizeImageUrl(raw);
render();
} catch (error) {
alert(error.message || 'Invalid image URL.');
}
});
$('designerClearBtn').addEventListener('click', () => {
state.discDesigns[state.activeDiscDesign].src = '';
state.discDesigns[state.activeDiscDesign].layers = [];
state.layers = state.layers.filter(l => l.target !== 'designer-disc');
$('designerUrl').value = '';
render();
});
$('fit-designer').addEventListener('change', (e) => {
state.discDesigns[state.activeDiscDesign].fit = e.target.value;
render();
});
for (const prop of ['zoom', 'x', 'y', 'rotate']) {
$(`${prop}-designer`).addEventListener('input', (e) => {
state.discDesigns[state.activeDiscDesign][prop] = Number(e.target.value);
syncDesignerInputs();
render();
});
}
$('designerResetBtn').addEventListener('click', () => {
Object.assign(state.discDesigns[state.activeDiscDesign], { 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 () => {
try {
await publishDiscDesignToNostr(state.activeDiscDesign);
alert(`${state.discDesigns[state.activeDiscDesign].name || 'Design'} published to Nostr.`);
} catch (error) {
alert(error.message || 'Could not publish.');
}
});
// Disc layout import (template tab)
$('importDiscFileBtn').addEventListener('click', () => $('discDesignFile').click());
$('discDesignFile').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));
const target = $('importDiscTarget').value || 'a';
importDiscDesignFromPayload(payload, target, { openDesigner: false });
event.target.value = '';
} catch {
alert('Could not read that file as a disc design.');
}
};
reader.readAsText(file);
});
$('searchDiscNostrBtn').addEventListener('click', () => {
openTemplateDiscDesignNostrSearch();
});
$('designerNostrLoadBtn').addEventListener('click', openDiscDesignNostrSearch);
$('quickPrintBtn').addEventListener('click', () => $('printBtn').click());
$('quickSaveBtn').addEventListener('click', () => $('saveProjectBtn').click());
$('browseCommunityBtn').addEventListener('click', () => { $('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', () => {
for (const { key } of discDesignNames) {
state.discDesigns[key].src = '';
state.discDesigns[key].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 || {}) },
discDesigns: { ...state.discDesigns, ...(parsed.discDesigns || parsed.discTemplate?.designs || {}) },
discSheet: { ...state.discSheet, ...(parsed.discSheet || parsed.discTemplate?.sheet || {}) },
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') {
loadDesignerLayers(parsed.activeDiscDesign || parsed.ui?.activeDiscDesign || state.activeDiscDesign || 'a');
}
} catch (error) {
console.warn('Autosave restore failed', error);
}
}
function init() {
restoreAutosave();
state.appMode = state.appMode || 'template';
state.activeDiscDesign = state.activeDiscDesign || 'a';
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>