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