mirror of
https://github.com/PR0M3TH3AN/Artstr-Studio.git
synced 2026-11-09 15:10:09 +00:00
Layer panel parity, jewel/disc title cleanup, preview & welcome upgrades
Layers:
- Layers panel now shows in disc-sheet and jewel template modes and inside
the disc-design designer (was hidden in all three)
- Layer target dropdown is mode-aware: disc shows Disc 1/Disc 2, jewel shows
the two inserts, cover shows trim/back/spine/front
"Use" button (template browser):
- Cover/disc/jewel "Use" now switches the editor out of designer mode via
switchAppMode('template'), so the right tool is visible after loading
- Disc-design "Use" always opens the disc-design designer unless an explicit
slot import target was set
Disc-sheet title:
- Template browser title for 2-disc sheets concatenates per-slot titles as
"Disc 1 title + Disc 2 title" via new discSheetDisplayTitle() helper
Jewel case spine text:
- New jewelSpineLabels {left,right} mirrored from the disc spine pattern
- Inputs in jewel artwork panel, vertical-rl labels rendered over both tray
spines, persisted via jewelTemplate.spineLabels in save/publish payloads
Jewel preview:
- New renderJewelPreviewDOM() renders front + tray with spines, spine text,
and any jewel-target layers in the template browser preview pane
Broken image flagging:
- _feedCache.brokenImages tracks failed preview URLs
- Broken cards get a "MISSING IMAGE" badge, dimmed/hatched thumbnail, and
are de-ranked to the end of the sort on New and Popular tabs
Disc-design panel:
- Removed standalone "Design name" input; design.name now mirrors
state.meta.title automatically
Welcome modal:
- Redesigned to match the community browser: New/Popular tabs, feedWall grid,
buildFeedCard tiles, "Browse all designs" + "Start from scratch" footer
- Search input now searches inline using the same card layout
Docs:
- New spec at docs/COLOR_AND_FONT_LAYERS_FEATURE.md covering the upcoming
color-fill layer type and text font-family picker
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
1 parent
b02e826481
commit
88cf2bceab
2 files changed
+636
-74
No files matched your search
@@ -0,0 +1,222 @@
|
||||
# Color Fill Layers + Text Font Family Picker
|
||||
|
||||
## Goals
|
||||
Expand the layer system with **two complementary additions**:
|
||||
|
||||
1. A new `color` layer type — a solid-color rectangle, useful as a tint, background, or accent block.
|
||||
2. A **font family picker** on existing `text` layers so creators aren't locked into the browser default.
|
||||
|
||||
### Product goals
|
||||
1. Add visual range without adding a new editor surface — both changes live in the existing layer panel.
|
||||
2. Stay print-safe: no network-dependent fonts in v1.
|
||||
3. No migration burden on existing payloads.
|
||||
|
||||
### Non-goals (v1)
|
||||
- Gradient or pattern fills (color layers are flat fill only).
|
||||
- Strokes / borders on color layers.
|
||||
- Google Fonts or any web-fetched fonts.
|
||||
- Per-character font styling (text layer is one font for the whole block, same as today).
|
||||
- Drop shadows, blur, or other effects.
|
||||
|
||||
---
|
||||
|
||||
## Current state
|
||||
|
||||
### Existing layer types
|
||||
| Type | Fields beyond standard | Where rendered |
|
||||
|---|---|---|
|
||||
| `image` | `src`, `fit?` | main canvas, cover preview, disc-sheet preview, jewel preview, disc-design preview |
|
||||
| `text` | `html`, `fontSize`, `color`, `align`, `bold`, `italic` | same 5 places |
|
||||
|
||||
Standard fields shared by every layer: `id`, `type`, `name`, `target`, `x`, `y`, `w`, `h`, `rotate`, `opacity`, `z`.
|
||||
|
||||
### Existing UI structure
|
||||
- **Per-target panels** (`renderLayerList`): one collapsible panel per region (page / cover-trim / back / spine / front / disc-top / disc-bottom / jewel-front / jewel-tray / designer-disc). Each has an "Add image" URL input and an "Add text" button.
|
||||
- **Selected-layer controls** (`#layerControls`): one shared `<details>` block that conditionally shows `#layerImageControls` for image layers and `#textLayerControls` for text layers via display:block/none.
|
||||
- **Renderers** in 5 locations:
|
||||
- `renderLayers()` — main editor canvas
|
||||
- `renderTemplatePreviewDOM()` — cover preview in the template browser
|
||||
- `renderDiscSheetPreviewDOM()` — disc sheet preview
|
||||
- `renderJewelPreviewDOM()` — jewel preview
|
||||
- `renderDiscPreviewDOM()` — disc-design preview
|
||||
|
||||
Each renderer has the same `if (layer.type === 'text') { … } else if (layer.type === 'image' && layer.src) { … }` shape.
|
||||
|
||||
---
|
||||
|
||||
## Color layer
|
||||
|
||||
### Data model
|
||||
```js
|
||||
{
|
||||
id: 'layer-…',
|
||||
type: 'color',
|
||||
name: 'Color block',
|
||||
target: '<target key>',
|
||||
color: '#3b82f6', // hex string, accent blue default
|
||||
x, y, w, h, // inches, same as image/text
|
||||
rotate: 0,
|
||||
opacity: 0.6, // default less than 1 so it reads as a tint
|
||||
z: <nextLayerZ()>
|
||||
}
|
||||
```
|
||||
|
||||
### `addColorLayer(targetOverride)` helper
|
||||
Sits next to `addImageLayer` / `addTextLayer` in the script. Same pattern:
|
||||
- Resolve target from override or current `#layerTarget` value.
|
||||
- Get `bounds = targetBounds(target)`.
|
||||
- Insert centered, 70%×70% of the target bounds (same as image/text default sizing).
|
||||
|
||||
### Add-button placement
|
||||
Inside `renderLayerList`'s per-target panel, alongside the existing Add image / Add text controls. Markup roughly:
|
||||
```
|
||||
[ image url input ] [ + Image ] [ + Text ] [ + Color ]
|
||||
```
|
||||
Single inline button — no extra inputs needed at add time (color picker lives on the selected layer).
|
||||
|
||||
### Selected-layer controls
|
||||
Add a third sub-panel to `#layerControls`:
|
||||
```html
|
||||
<div id="colorLayerControls" style="display:none">
|
||||
<label for="colorLayerFill">Fill color</label>
|
||||
<input id="colorLayerFill" type="color" />
|
||||
</div>
|
||||
```
|
||||
Toggled in `updateSelectedLayerControls()` alongside the existing image/text panels.
|
||||
|
||||
The existing **opacity** input applies to all layer types, so users can fine-tune the tint there.
|
||||
|
||||
### Rendering
|
||||
Each of the 5 render sites gets a new branch:
|
||||
```js
|
||||
} else if (layer.type === 'color') {
|
||||
el.style.background = layer.color || '#000';
|
||||
}
|
||||
```
|
||||
Place after the `text` branch, before the `image` branch (or wherever it fits each renderer's existing structure).
|
||||
|
||||
The element already has `opacity`, `transform: rotate(…)`, position, and size set by the shared code. No extra work needed.
|
||||
|
||||
### Defaults (locked in)
|
||||
- Default color: `#3b82f6` (accent blue — obviously visible against most artwork).
|
||||
- Default opacity: `0.6` (reads as tint, not as a blocker).
|
||||
- Selectable / draggable / resizable just like image and text layers.
|
||||
|
||||
---
|
||||
|
||||
## Text font family picker
|
||||
|
||||
### Data model
|
||||
Add one field to existing text layers:
|
||||
```js
|
||||
{ … , fontFamily: 'Arial, sans-serif' }
|
||||
```
|
||||
|
||||
Existing text layers without `fontFamily` continue rendering with the browser default (no migration).
|
||||
|
||||
### Font list (locked in, web-safe only)
|
||||
|
||||
| Label | CSS value |
|
||||
|---|---|
|
||||
| System default | `inherit` |
|
||||
| Sans-serif (Arial) | `Arial, Helvetica, sans-serif` |
|
||||
| Sans-serif (Helvetica) | `Helvetica, Arial, sans-serif` |
|
||||
| Sans-serif (Verdana) | `Verdana, Geneva, sans-serif` |
|
||||
| Sans-serif (Trebuchet) | `"Trebuchet MS", sans-serif` |
|
||||
| Sans-serif (Tahoma) | `Tahoma, Geneva, sans-serif` |
|
||||
| Sans-serif (Arial Black) | `"Arial Black", sans-serif` |
|
||||
| Sans-serif (Impact) | `Impact, "Arial Narrow Bold", sans-serif` |
|
||||
| Serif (Times) | `"Times New Roman", Times, serif` |
|
||||
| Serif (Georgia) | `Georgia, serif` |
|
||||
| Serif (Palatino) | `"Palatino Linotype", "Book Antiqua", Palatino, serif` |
|
||||
| Monospace (Courier) | `"Courier New", Courier, monospace` |
|
||||
| Monospace (Consolas) | `Consolas, "Courier New", monospace` |
|
||||
| Cursive (Comic Sans) | `"Comic Sans MS", "Comic Sans", cursive` |
|
||||
| Cursive (Brush Script) | `"Brush Script MT", cursive` |
|
||||
|
||||
Each `<option>` previews itself via `style="font-family: <its-own-value>"` so the dropdown is its own preview surface.
|
||||
|
||||
### UI placement
|
||||
Inside the existing `#textLayerControls` block, before the existing text size input:
|
||||
```html
|
||||
<label for="textFontFamily">Font</label>
|
||||
<select id="textFontFamily"><!-- options --></select>
|
||||
```
|
||||
|
||||
### Wiring
|
||||
- `syncSelectedLayerControls`: read `selectedLayer.fontFamily` into the select (default to `'inherit'`).
|
||||
- Change handler: `updateSelectedLayer('fontFamily', e.target.value)` + `render()`.
|
||||
- Renderers (5 places): when `layer.type === 'text'`, apply `el.style.fontFamily = layer.fontFamily || 'inherit'`. One line added per renderer.
|
||||
|
||||
### Print-safety rationale
|
||||
All fonts are either built into the OS or universally fallback-mappable. No external network request → no FOIT/FOUT during print → reliable spacing and metrics in PDF output. A "missing font" outcome would silently fall back to a similar metric within the same generic family (`sans-serif`, `serif`, `monospace`, `cursive`).
|
||||
|
||||
---
|
||||
|
||||
## Backward compatibility
|
||||
|
||||
- Old text layers without `fontFamily`: render with browser default (effectively `inherit`). No data migration.
|
||||
- Old payloads with only `image`/`text` types: unchanged. The new `color` type only exists in payloads explicitly published after this lands.
|
||||
- Old clients receiving a payload that contains a `color` layer: skip it (their renderers don't match `type === 'color'`, so they fall through and render nothing). Not great but not catastrophic — the rest of the template still renders. A future shared schema-version bump could signal "this template uses color layers."
|
||||
|
||||
---
|
||||
|
||||
## Implementation slices
|
||||
|
||||
### Slice 1 — Color layer (data + add + render)
|
||||
1. `addColorLayer(targetOverride)` helper.
|
||||
2. Add "Color" button to per-target panel rows in `renderLayerList`.
|
||||
3. Renderer branches in all 5 places.
|
||||
4. `#colorLayerControls` block + show/hide wiring in `updateSelectedLayerControls`.
|
||||
5. Color-picker input handler.
|
||||
|
||||
### Slice 2 — Font family picker for text
|
||||
1. Build out the `<select id="textFontFamily">` markup (with self-previewing options).
|
||||
2. Read state in `syncSelectedLayerControls`.
|
||||
3. Change handler.
|
||||
4. Apply `style.fontFamily` in the 5 renderers.
|
||||
|
||||
Slices are independent — could ship in either order or together.
|
||||
|
||||
---
|
||||
|
||||
## Acceptance
|
||||
|
||||
### Color layer
|
||||
- "Color" button appears in each target panel (and only the ones valid for the current template mode).
|
||||
- Clicking it adds a 70%×70% rectangle filled with `#3b82f6` at 60% opacity in the relevant region.
|
||||
- Selecting it shows a color picker that updates the layer fill live.
|
||||
- Position/size/rotation/opacity controls all work (same as image/text).
|
||||
- Color layer round-trips through save/load and through publish/Nostr-import without data loss.
|
||||
- Color layer renders in all 4 preview surfaces (cover, disc-sheet, jewel, disc-design) and in the main editor canvas.
|
||||
|
||||
### Font family picker
|
||||
- Text-layer panel shows a "Font" dropdown with the locked-in list.
|
||||
- Selecting a font updates the selected text layer instantly (and the dropdown's options visibly preview their own font).
|
||||
- Saved/loaded text layers keep their font choice.
|
||||
- Text layer renders with the chosen font in all 4 preview surfaces and the main canvas.
|
||||
- Text layers from before this change render with the previous (default) font.
|
||||
|
||||
---
|
||||
|
||||
## Risks and tradeoffs
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| Color block at 60% opacity could be mistaken for an image-loading state on first add | Distinct "MISSING IMAGE" badge is reserved for the broken-image case; new layers default to accent blue which is unmistakably solid color |
|
||||
| Old clients see new `color` layers as no-ops | Acceptable for v1; bump schema version later if widespread |
|
||||
| Picked font doesn't exist on the printer/viewer OS | CSS fallback chain ends in a generic family; visually similar metrics; acceptable for v1 |
|
||||
| Color layer used as a full backdrop covers the artwork beneath | User controls opacity and z-order; same affordances as image layers |
|
||||
| Font dropdown clutters the text controls panel | Single labeled select, collapsible if needed in a future polish pass |
|
||||
|
||||
---
|
||||
|
||||
## Out of scope / future enhancements
|
||||
|
||||
- Custom CSS `font-family` text input for power users.
|
||||
- Gradient fills on color layers.
|
||||
- Stroke/border on color layers (and image layers).
|
||||
- Per-character or per-word formatting in text layers.
|
||||
- Drop shadow / outline effects.
|
||||
- Saved swatches / palette per project.
|
||||
- Eyedropper to pick a color from the artwork beneath.
|
||||
+414
-74
@@ -506,12 +506,9 @@
|
||||
body.dark .appModeTab.active { color: #fff; }
|
||||
|
||||
body.app-template .designer-only { display: none !important; }
|
||||
body.app-designer .template-only { display: none !important; }
|
||||
body.app-designer .template-only:not(#layersFieldset) { 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,
|
||||
body.app-template.template-jewel #layersFieldset { display: none !important; }
|
||||
|
||||
/* Designer single-disc preview */
|
||||
body.app-designer .sheetWrap { display: none !important; }
|
||||
@@ -1001,6 +998,29 @@
|
||||
display: var(--safe-display, block);
|
||||
}
|
||||
|
||||
.jewelSpineLabel {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 13;
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
.jewelSpineLabel span {
|
||||
writing-mode: vertical-rl;
|
||||
transform: rotate(180deg);
|
||||
font-size: 7pt;
|
||||
font-weight: 700;
|
||||
color: rgba(15, 23, 42, .82);
|
||||
letter-spacing: .04em;
|
||||
white-space: nowrap;
|
||||
max-height: 4.5in;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.designerLayerOverlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -1490,6 +1510,9 @@
|
||||
body.dark .feedTrustBadge{background:rgba(0,217,255,.1);border-color:rgba(0,217,255,.2)}
|
||||
.feedCard.feedCardBlurred .feedCardThumb img,
|
||||
.feedCard.feedCardBlurred .feedCardThumb canvas{filter:blur(14px) saturate(.5);transition:filter .15s}
|
||||
.feedCard.feedCardBroken{opacity:.55}
|
||||
.feedCard.feedCardBroken .feedCardThumb{background:repeating-linear-gradient(45deg,#f1f5f9,#f1f5f9 10px,#e2e8f0 10px,#e2e8f0 20px)}
|
||||
.feedCardBrokenBadge{position:absolute;top:6px;right:6px;background:rgba(220,38,38,.92);color:#fff;font-size:.62rem;font-weight:700;border-radius:4px;padding:2px 6px;z-index:6;letter-spacing:.03em}
|
||||
.feedCardSoftModOverlay{position:absolute;inset:0;z-index:5;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;background:rgba(15,23,42,.55);color:#fff;padding:12px;text-align:center;font-size:.78rem;line-height:1.3}
|
||||
.feedCardSoftModOverlay .softModLabel{font-weight:600;max-width:90%}
|
||||
.feedCardSoftModOverlay button{font-size:.74rem;padding:5px 12px;border-radius:6px;border:1px solid rgba(255,255,255,.5);background:rgba(255,255,255,.12);color:#fff;cursor:pointer}
|
||||
@@ -1499,7 +1522,16 @@
|
||||
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; }
|
||||
.welcomeCard { max-width: 900px; width: min(900px, 92vw); }
|
||||
.welcomeSearchRow { display:grid; grid-template-columns: 1fr 160px 160px auto; gap:8px; align-items:end; margin-bottom:12px; }
|
||||
.welcomeTabs { display:flex; gap:6px; border-bottom:1px solid var(--line); margin:4px 0 10px; flex-wrap:wrap; }
|
||||
.welcomeTab { background:transparent; border:0; padding:8px 14px; cursor:pointer; font-weight:700; color:var(--muted); border-bottom:2px solid transparent; font-size:.85rem; }
|
||||
.welcomeTab.active { color:var(--accent); border-bottom-color:var(--accent); }
|
||||
.welcomeFooter { margin-top:16px; padding-top:14px; border-top:1px solid var(--line); display:flex; justify-content:space-between; align-items:center; gap:10px; flex-wrap:wrap; }
|
||||
.welcomeFooter .hint { margin:0; }
|
||||
@media (max-width: 720px) {
|
||||
.welcomeSearchRow { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.app { grid-template-columns: 1fr; }
|
||||
@@ -1946,12 +1978,24 @@
|
||||
<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>
|
||||
<details style="margin-top:10px">
|
||||
<summary style="font-weight:700;font-size:.86rem;cursor:pointer">Spine text (optional)</summary>
|
||||
<div class="row" style="margin-top:8px">
|
||||
<div>
|
||||
<label for="jewelSpineTextLeft">Left spine text</label>
|
||||
<input id="jewelSpineTextLeft" type="text" placeholder="e.g. Movie Title" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="jewelSpineTextRight">Right spine text</label>
|
||||
<input id="jewelSpineTextRight" type="text" placeholder="e.g. Movie Title" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint">Text appears on the two narrow tray-insert spines that wrap the jewel case. Leave blank to skip.</p>
|
||||
</details>
|
||||
</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" />
|
||||
@@ -2215,6 +2259,8 @@
|
||||
<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 class="jewelSpineLabel" id="jewel-spine-label-left" aria-hidden="true"></div>
|
||||
<div class="jewelSpineLabel" id="jewel-spine-label-right" aria-hidden="true"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layerOverlay" id="layerOverlay"></div>
|
||||
@@ -2237,19 +2283,26 @@
|
||||
|
||||
<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>
|
||||
<h2 style="margin:0 0 4px">Welcome to CaseWrap Studio</h2>
|
||||
<p class="subhead" style="margin:0 0 14px">Browse community templates or start your own.</p>
|
||||
<div class="welcomeSearchRow">
|
||||
<div><label for="welcomeQuery" style="font-size:.78rem">Title or IMDb ID</label><input id="welcomeQuery" type="text" placeholder="e.g. The Matrix or tt0133093" /></div>
|
||||
<div><label for="welcomeType" style="font-size:.78rem">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><option value="disc-design">Disc designs</option></select></div>
|
||||
<div><label for="welcomeCategory" style="font-size:.78rem">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 class="welcomeTabs" role="tablist" aria-label="Browse">
|
||||
<button type="button" class="welcomeTab active" id="welcomeTabNew" data-welcome-tab="New" role="tab">New</button>
|
||||
<button type="button" class="welcomeTab" id="welcomeTabPopular" data-welcome-tab="Popular" role="tab">Popular</button>
|
||||
<button type="button" class="welcomeTab" id="welcomeTabSearch" data-welcome-tab="Search" role="tab" style="display:none">Search results</button>
|
||||
</div>
|
||||
<div class="feedWall" id="welcomeResults"></div>
|
||||
<div class="welcomeFooter">
|
||||
<p class="hint">Or start with a blank canvas.</p>
|
||||
<div class="buttonRow" style="margin:0">
|
||||
<button type="button" id="welcomeBrowseAllBtn">Browse all designs →</button>
|
||||
<button type="button" id="welcomeSkipBtn">Start from scratch →</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2483,6 +2536,20 @@
|
||||
{ key: 'front', label: 'Front cover' }
|
||||
];
|
||||
|
||||
function templateLayerTargetsForMode() {
|
||||
if (state.templateMode === 'disc') return [
|
||||
{ key: 'page', label: 'Whole page' },
|
||||
{ key: 'disc-top', label: 'Disc 1' },
|
||||
{ key: 'disc-bottom', label: 'Disc 2' }
|
||||
];
|
||||
if (state.templateMode === 'jewel') return [
|
||||
{ key: 'page', label: 'Whole page' },
|
||||
{ key: 'jewel-front', label: 'Jewel front insert' },
|
||||
{ key: 'jewel-tray', label: 'Jewel tray insert' }
|
||||
];
|
||||
return TEMPLATE_LAYER_TARGETS;
|
||||
}
|
||||
|
||||
const discTemplate = {
|
||||
sheetW: 8.5,
|
||||
sheetH: 11,
|
||||
@@ -2597,6 +2664,7 @@
|
||||
},
|
||||
discDesign: { name: '', src: '', fit: 'cover', zoom: 1, x: 0, y: 0, rotate: 0 },
|
||||
discSpineLabels: { top: '', bottom: '' },
|
||||
jewelSpineLabels: { left: '', right: '' },
|
||||
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 }
|
||||
@@ -2893,6 +2961,21 @@
|
||||
return 'disc';
|
||||
}
|
||||
|
||||
function discSheetDisplayTitle(payload = {}) {
|
||||
const meta = payload?.discTemplate?.meta || payload?.discMeta || {};
|
||||
const discs = payload?.discTemplate?.discs || payload?.discs || {};
|
||||
const slotOrder = ['top', 'bottom'];
|
||||
const titles = [];
|
||||
for (const key of slotOrder) {
|
||||
if (!discs?.[key]?.src) continue;
|
||||
const slotTitle = (meta?.[key]?.title || '').trim();
|
||||
if (slotTitle) titles.push(slotTitle);
|
||||
}
|
||||
if (!titles.length) return '';
|
||||
if (titles.length === 1) return titles[0];
|
||||
return titles.join(' + ');
|
||||
}
|
||||
|
||||
function templateTypeForMode(mode = state.templateMode) {
|
||||
if (mode === 'jewel') return 'cd-jewel-case';
|
||||
if (mode === 'disc') return 'disc-label-sheet';
|
||||
@@ -3225,6 +3308,7 @@
|
||||
...(state.jewelInserts[key] || {})
|
||||
};
|
||||
}
|
||||
state.jewelSpineLabels = { left: '', right: '', ...(state.jewelSpineLabels || {}) };
|
||||
}
|
||||
|
||||
function normalizeDiscMeta(meta = {}) {
|
||||
@@ -3384,6 +3468,8 @@
|
||||
$('printCrops').checked = state.printCrops;
|
||||
const stt = $('spineTextTop'); if (stt) stt.value = state.discSpineLabels?.top || '';
|
||||
const stb = $('spineTextBottom'); if (stb) stb.value = state.discSpineLabels?.bottom || '';
|
||||
const jsl = $('jewelSpineTextLeft'); if (jsl) jsl.value = state.jewelSpineLabels?.left || '';
|
||||
const jsr = $('jewelSpineTextRight'); if (jsr) jsr.value = state.jewelSpineLabels?.right || '';
|
||||
syncPublishReadout();
|
||||
syncModeTabsUI();
|
||||
}
|
||||
@@ -3409,6 +3495,7 @@
|
||||
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');
|
||||
updateLayerTargetOptions();
|
||||
}
|
||||
|
||||
function renderDimensions() {
|
||||
@@ -3833,8 +3920,7 @@
|
||||
|
||||
function syncDesignerInputs() {
|
||||
const design = state.discDesign;
|
||||
const nameEl = $('designerName');
|
||||
if (nameEl) nameEl.value = design.name || '';
|
||||
if (!design.name && state.meta?.title) design.name = state.meta.title;
|
||||
const urlEl = $('designerUrl');
|
||||
if (urlEl) urlEl.value = design.src || '';
|
||||
const fitEl = $('fit-designer');
|
||||
@@ -4256,11 +4342,26 @@
|
||||
applyRotationCover(img, slot, insert?.x ?? 0, insert?.y ?? 0, insert?.rotate ?? 0, insert?.zoom ?? 1, insert?.fit || 'cover');
|
||||
|
||||
if (slotDef.key === 'tray') {
|
||||
const spineW = slotDef.spineW || 0.25;
|
||||
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);
|
||||
leftGuide.style.left = inches(spineW);
|
||||
rightGuide.style.right = inches(spineW);
|
||||
}
|
||||
const leftLabel = $('jewel-spine-label-left');
|
||||
const rightLabel = $('jewel-spine-label-right');
|
||||
if (leftLabel) {
|
||||
leftLabel.style.left = '0';
|
||||
leftLabel.style.width = inches(spineW);
|
||||
const text = state.jewelSpineLabels?.left || '';
|
||||
leftLabel.innerHTML = text ? `<span>${escapeHtml(text)}</span>` : '';
|
||||
}
|
||||
if (rightLabel) {
|
||||
rightLabel.style.right = '0';
|
||||
rightLabel.style.width = inches(spineW);
|
||||
const text = state.jewelSpineLabels?.right || '';
|
||||
rightLabel.innerHTML = text ? `<span>${escapeHtml(text)}</span>` : '';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4487,16 +4588,8 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.templateMode !== 'cover') {
|
||||
const hint = document.createElement('p');
|
||||
hint.className = 'hint';
|
||||
hint.textContent = 'Layer panels are shown for case wraps.';
|
||||
list.appendChild(hint);
|
||||
return;
|
||||
}
|
||||
|
||||
const targetLayers = state.layers.filter((layer) => layer.target !== 'designer-disc' && !layer._generatedDiscLayer);
|
||||
for (const targetDef of TEMPLATE_LAYER_TARGETS) {
|
||||
for (const targetDef of templateLayerTargetsForMode()) {
|
||||
const panel = document.createElement('section');
|
||||
panel.className = 'templateLayerPanel';
|
||||
|
||||
@@ -5576,7 +5669,7 @@
|
||||
templateType,
|
||||
meta: state.meta,
|
||||
ui: { zoom: state.zoom, showGuides: state.showGuides, printGuides: state.printGuides },
|
||||
jewelTemplate: { templateType, preset: 'jewel8693', config: jewelTemplate, inserts: state.jewelInserts },
|
||||
jewelTemplate: { templateType, preset: 'jewel8693', config: jewelTemplate, inserts: state.jewelInserts, spineLabels: state.jewelSpineLabels },
|
||||
layers: nonGeneratedLayers().filter(l => String(l.target || '').startsWith('jewel'))
|
||||
};
|
||||
}
|
||||
@@ -5602,7 +5695,7 @@
|
||||
discMeta: state.discMeta,
|
||||
...(state.templateMode === 'disc-design' ? { discDesign: state.discDesign } : {}),
|
||||
discTemplate: { templateType: templateTypeForMode('disc'), config: discTemplate, discs: state.discs, spineLabels: state.discSpineLabels, meta: state.discMeta },
|
||||
jewelTemplate: { templateType: templateTypeForMode('jewel'), preset: 'jewel8693', config: jewelTemplate, inserts: state.jewelInserts },
|
||||
jewelTemplate: { templateType: templateTypeForMode('jewel'), preset: 'jewel8693', config: jewelTemplate, inserts: state.jewelInserts, spineLabels: state.jewelSpineLabels },
|
||||
layers: nonGeneratedLayers(),
|
||||
community: { relays: state.community.relays, query: state.community.query, kind: state.community.kind }
|
||||
};
|
||||
@@ -5789,9 +5882,12 @@
|
||||
function loadProjectFromText(text) {
|
||||
const parsed = JSON.parse(text);
|
||||
if (parsed.version && parsed.templateMode === 'disc-design' && parsed.discDesign) {
|
||||
importDiscDesignFromPayload(parsed, 'a');
|
||||
importDiscDesignFromPayload(parsed, 'a', { openDesigner: true });
|
||||
return;
|
||||
}
|
||||
if (state.appMode === 'designer') {
|
||||
switchAppMode('template');
|
||||
}
|
||||
const v = parsed.version || 0;
|
||||
const mode = parsed.templateMode || parsed.ui?.templateMode || '';
|
||||
|
||||
@@ -5851,6 +5947,7 @@
|
||||
}
|
||||
|
||||
if (v >= 5 && mode === 'jewel' && parsed.jewelTemplate) {
|
||||
const jewelSpineLabels = parsed.jewelTemplate?.spineLabels || parsed.jewelSpineLabels || null;
|
||||
Object.assign(state, {
|
||||
templateMode: 'jewel',
|
||||
meta: { ...state.meta, ...(parsed.meta || {}) },
|
||||
@@ -5858,6 +5955,7 @@
|
||||
showGuides: parsed.ui?.showGuides ?? state.showGuides,
|
||||
printGuides: parsed.ui?.printGuides ?? state.printGuides,
|
||||
jewelInserts: { ...state.jewelInserts, ...(parsed.jewelTemplate?.inserts || {}) },
|
||||
...(jewelSpineLabels ? { jewelSpineLabels: { ...state.jewelSpineLabels, ...jewelSpineLabels } } : {}),
|
||||
layers: [
|
||||
...state.layers.filter(l => !String(l.target || '').startsWith('jewel')),
|
||||
...(Array.isArray(parsed.layers) ? parsed.layers : [])
|
||||
@@ -5896,6 +5994,7 @@
|
||||
// backward compat: old discDesigns merged into discs below
|
||||
discSpineLabels: parsed.discTemplate?.spineLabels || parsed.discSpineLabels || state.discSpineLabels,
|
||||
jewelInserts: parsed.jewelTemplate?.inserts || parsed.jewelInserts || {},
|
||||
jewelSpineLabels: parsed.jewelTemplate?.spineLabels || parsed.jewelSpineLabels || state.jewelSpineLabels,
|
||||
community: parsed.community || {},
|
||||
layers: Array.isArray(parsed.layers) ? parsed.layers : []
|
||||
}
|
||||
@@ -5926,7 +6025,19 @@
|
||||
}
|
||||
|
||||
|
||||
const _feedCache = { events: null, reactions: {}, profiles: {}, fetchedAt: 0, loading: false, relayKey: '', relayProgress: { responded: 0, total: 0 }, phase: '', softMod: new Map(), softModRevealed: new Set() };
|
||||
const _feedCache = { events: null, reactions: {}, profiles: {}, fetchedAt: 0, loading: false, relayKey: '', relayProgress: { responded: 0, total: 0 }, phase: '', softMod: new Map(), softModRevealed: new Set(), brokenImages: new Set() };
|
||||
|
||||
function markImageBroken(url) {
|
||||
if (!url) return;
|
||||
_feedCache.brokenImages.add(url);
|
||||
if (typeof _currentFeedRerender === 'function') {
|
||||
clearTimeout(markImageBroken._t);
|
||||
markImageBroken._t = setTimeout(() => { try { _currentFeedRerender(); } catch {} }, 200);
|
||||
}
|
||||
}
|
||||
function isPreviewBroken(row) {
|
||||
return !!(row?.preview && _feedCache.brokenImages.has(row.preview));
|
||||
}
|
||||
let _currentFeedRerender = null;
|
||||
const FEED_PROGRESS_DEBOUNCE_MS = 150;
|
||||
|
||||
@@ -6369,7 +6480,11 @@
|
||||
const category = payload.meta?.category || payload.category || '';
|
||||
const language = payload.meta?.language || 'en';
|
||||
const mode = normalizeTemplateMode(payload);
|
||||
const displayTitle = mode === 'disc-design' ? (payload.discDesign?.name || title || '') : title;
|
||||
const displayTitle = mode === 'disc-design'
|
||||
? (payload.discDesign?.name || title || '')
|
||||
: mode === 'disc'
|
||||
? (discSheetDisplayTitle(payload) || title)
|
||||
: title;
|
||||
// Skip completely unidentifiable posts
|
||||
if (!displayTitle && !imdb) continue;
|
||||
if (typeFilter !== 'any' && mode !== typeFilter) continue;
|
||||
@@ -6439,9 +6554,17 @@
|
||||
if (row.preview && row.mode !== 'disc') {
|
||||
const img = document.createElement('img');
|
||||
img.alt = ''; img.loading = 'lazy'; img.src = row.preview;
|
||||
img.addEventListener('error', () => img.remove());
|
||||
img.addEventListener('error', () => { img.remove(); markImageBroken(row.preview); });
|
||||
thumb.insertBefore(img, lbl);
|
||||
}
|
||||
const isBroken = isPreviewBroken(row);
|
||||
if (isBroken) {
|
||||
el.classList.add('feedCardBroken');
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'feedCardBrokenBadge';
|
||||
badge.textContent = 'MISSING IMAGE';
|
||||
thumb.appendChild(badge);
|
||||
}
|
||||
if (showBlur) {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'feedCardSoftModOverlay';
|
||||
@@ -6590,10 +6713,12 @@
|
||||
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' });
|
||||
const slotTarget = state._discImportTargetSlot;
|
||||
if (slotTarget) {
|
||||
importDiscDesignFromPayload(row.payload, slotTarget, { openDesigner: false });
|
||||
} else {
|
||||
importDiscDesignFromPayload(row.payload, 'a', { openDesigner: true });
|
||||
}
|
||||
} else {
|
||||
loadProjectFromText(JSON.stringify(row.payload));
|
||||
}
|
||||
@@ -6641,10 +6766,12 @@
|
||||
nextRows.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' });
|
||||
const slotTarget = state._discImportTargetSlot;
|
||||
if (slotTarget) {
|
||||
importDiscDesignFromPayload(row.payload, slotTarget, { openDesigner: false });
|
||||
} else {
|
||||
importDiscDesignFromPayload(row.payload, 'a', { openDesigner: true });
|
||||
}
|
||||
} else {
|
||||
loadProjectFromText(JSON.stringify(row.payload));
|
||||
}
|
||||
@@ -6688,10 +6815,10 @@
|
||||
if (trustFilter === 'trusted') rows = rows.filter(r => r.trust === 'self' || r.trust === 'trusted');
|
||||
else if (trustFilter === 'extended') rows = rows.filter(r => r.trust === 'self' || r.trust === 'trusted' || r.trust === 'extended');
|
||||
if (tab === 'New') {
|
||||
rows.sort((a, b) => (TIER_WEIGHT[a.trust] - TIER_WEIGHT[b.trust]) || (b.createdAt - a.createdAt));
|
||||
rows.sort((a, b) => (Number(isPreviewBroken(a)) - Number(isPreviewBroken(b))) || (TIER_WEIGHT[a.trust] - TIER_WEIGHT[b.trust]) || (b.createdAt - a.createdAt));
|
||||
} else {
|
||||
rows = deduplicateRows(rows);
|
||||
rows.sort((a, b) => (b.reactions - a.reactions) || (TIER_WEIGHT[a.trust] - TIER_WEIGHT[b.trust]) || (b.createdAt - a.createdAt));
|
||||
rows.sort((a, b) => (Number(isPreviewBroken(a)) - Number(isPreviewBroken(b))) || (b.reactions - a.reactions) || (TIER_WEIGHT[a.trust] - TIER_WEIGHT[b.trust]) || (b.createdAt - a.createdAt));
|
||||
}
|
||||
updateFeedStatus(tab, rows.length);
|
||||
if (!rows.length) {
|
||||
@@ -7020,6 +7147,8 @@
|
||||
renderDiscPreviewDOM(content, p);
|
||||
} else if (mode === 'disc') {
|
||||
renderDiscSheetPreviewDOM(content, p);
|
||||
} else if (mode === 'jewel') {
|
||||
renderJewelPreviewDOM(content, p);
|
||||
} else {
|
||||
renderTemplatePreviewDOM(content, p);
|
||||
}
|
||||
@@ -7517,6 +7646,132 @@
|
||||
container.appendChild(sheet);
|
||||
}
|
||||
|
||||
function renderJewelPreviewDOM(container, payload) {
|
||||
const template = payload.jewelTemplate || {};
|
||||
const inserts = template.inserts || payload.jewelInserts || {};
|
||||
const spineLabels = template.spineLabels || payload.jewelSpineLabels || {};
|
||||
const cfg = template.config || jewelTemplate;
|
||||
const sheetW = Number(cfg.sheetW) || jewelTemplate.sheetW;
|
||||
const sheetH = Number(cfg.sheetH) || jewelTemplate.sheetH;
|
||||
const slots = Array.isArray(cfg.slots) && cfg.slots.length ? cfg.slots : jewelTemplate.slots;
|
||||
const allLayers = Array.isArray(payload.layers) ? payload.layers : [];
|
||||
|
||||
const renderArt = (wrap, art) => {
|
||||
if (!art?.src) return;
|
||||
const img = document.createElement('img');
|
||||
img.src = art.src;
|
||||
img.alt = '';
|
||||
img.style.cssText = 'position:absolute;left:50%;top:50%;transform-origin:center center;user-select:none;pointer-events:none;display:block;z-index:1;';
|
||||
wrap.appendChild(img);
|
||||
const update = () => {
|
||||
const cW = wrap.clientWidth;
|
||||
const cH = wrap.clientHeight;
|
||||
if (!cW || !cH) return;
|
||||
const fit = art.fit || 'cover';
|
||||
const nW = img.naturalWidth || cW;
|
||||
const nH = img.naturalHeight || cH;
|
||||
let baseW = cW, baseH = cH;
|
||||
if (fit === 'fill') { baseW = cW; baseH = cH; }
|
||||
else if (fit === 'none') { baseW = nW; baseH = nH; }
|
||||
else {
|
||||
const fitScale = fit === 'contain' ? Math.min(cW / nW, cH / nH) : Math.max(cW / nW, cH / nH);
|
||||
baseW = nW * fitScale; baseH = nH * fitScale;
|
||||
}
|
||||
const rotate = Number(art.rotate) || 0;
|
||||
const zoom = Number(art.zoom) || 1;
|
||||
const rad = rotate * Math.PI / 180;
|
||||
const ac = Math.abs(Math.cos(rad));
|
||||
const as = Math.abs(Math.sin(rad));
|
||||
const rotatedW = (baseW * ac) + (baseH * as);
|
||||
const rotatedH = (baseW * as) + (baseH * ac);
|
||||
const coverScale = fit === 'cover'
|
||||
? Math.max(cW / Math.max(rotatedW, 1), cH / Math.max(rotatedH, 1), 1)
|
||||
: 1;
|
||||
const finalScale = coverScale * zoom;
|
||||
const panRangeX = Math.max(0, (rotatedW * finalScale - cW) / 2);
|
||||
const panRangeY = Math.max(0, (rotatedH * finalScale - cH) / 2);
|
||||
const xPx = ((Number(art.x) || 0) / 100) * panRangeX;
|
||||
const yPx = ((Number(art.y) || 0) / 100) * panRangeY;
|
||||
img.style.width = `${baseW}px`;
|
||||
img.style.height = `${baseH}px`;
|
||||
img.style.transform = `translate(-50%, -50%) translate(${xPx}px, ${yPx}px) rotate(${rotate}deg) scale(${finalScale})`;
|
||||
};
|
||||
if (img.complete) requestAnimationFrame(update);
|
||||
img.addEventListener('load', update, { once: true });
|
||||
window.addEventListener('resize', update, { once: true });
|
||||
};
|
||||
|
||||
const renderLayer = (wrap, layer, slotW, slotH) => {
|
||||
const el = document.createElement('div');
|
||||
const lx = (Number(layer.x) || 0) / slotW * 100;
|
||||
const ly = (Number(layer.y) || 0) / slotH * 100;
|
||||
const lw = (Number(layer.w) || slotW) / slotW * 100;
|
||||
const lh = (Number(layer.h) || slotH) / slotH * 100;
|
||||
el.style.cssText = `position:absolute;left:${lx}%;top:${ly}%;width:${lw}%;height:${lh}%;transform:rotate(${Number(layer.rotate)||0}deg);opacity:${layer.opacity !== undefined ? layer.opacity : 1};overflow:hidden;pointer-events:none;box-sizing:border-box;z-index:3;`;
|
||||
if (layer.type === 'text') {
|
||||
const scale = wrap.clientWidth ? (wrap.clientWidth / (slotW * 96)) : 1;
|
||||
const align = layer.align || 'left';
|
||||
el.style.cssText += `color:${layer.color || '#111'};font-size:${((layer.fontSize || 14) * (96 / 72) * scale)}px;font-weight:${layer.bold ? '800' : '400'};font-style:${layer.italic ? 'italic' : 'normal'};text-align:${align};display:flex;align-items:flex-start;justify-content:${align === 'center' ? 'center' : align === 'right' ? 'flex-end' : 'flex-start'};`;
|
||||
el.innerHTML = layer.html || '';
|
||||
} else if (layer.type === 'image' && layer.src) {
|
||||
const img = document.createElement('img');
|
||||
img.src = layer.src;
|
||||
img.alt = layer.name || '';
|
||||
img.style.cssText = `width:100%;height:100%;object-fit:${layer.fit || 'contain'};`;
|
||||
el.appendChild(img);
|
||||
}
|
||||
wrap.appendChild(el);
|
||||
};
|
||||
|
||||
const sheet = document.createElement('div');
|
||||
sheet.style.cssText = `position:relative;width:min(100%, 720px);aspect-ratio:${sheetW} / ${sheetH};margin:0 auto;background:#fff;border:1px solid var(--line);overflow:hidden;box-shadow:0 2px 10px rgba(15,23,42,.12);`;
|
||||
|
||||
slots.forEach((slotDef) => {
|
||||
const insert = inserts[slotDef.key] || {};
|
||||
const wrap = document.createElement('div');
|
||||
wrap.style.cssText = `position:absolute;left:${(slotDef.x / sheetW) * 100}%;top:${(slotDef.y / sheetH) * 100}%;width:${(slotDef.w / sheetW) * 100}%;height:${(slotDef.h / sheetH) * 100}%;overflow:hidden;background:${insert.src ? '#111' : '#f8fafc'};border:1px solid rgba(17,24,39,.18);box-sizing:border-box;`;
|
||||
renderArt(wrap, insert);
|
||||
const slotTag = `jewel-${slotDef.key}`;
|
||||
const slotLayers = (Array.isArray(insert.layers) && insert.layers.length ? insert.layers : allLayers.filter(l => l.target === slotTag))
|
||||
.slice().sort((a, b) => (a.z || 0) - (b.z || 0));
|
||||
slotLayers.forEach(layer => renderLayer(wrap, layer, slotDef.w, slotDef.h));
|
||||
|
||||
if (slotDef.key === 'tray') {
|
||||
const spineW = slotDef.spineW || 0.25;
|
||||
const spineWPct = (spineW / slotDef.w) * 100;
|
||||
const buildSpine = (side, text) => {
|
||||
const spine = document.createElement('div');
|
||||
spine.style.cssText = `position:absolute;top:0;bottom:0;${side}:0;width:${spineWPct}%;display:flex;align-items:center;justify-content:center;pointer-events:none;z-index:4;`;
|
||||
const guide = document.createElement('div');
|
||||
guide.style.cssText = `position:absolute;top:0;bottom:0;${side === 'left' ? 'right' : 'left'}:0;width:0;border-${side === 'left' ? 'right' : 'left'}:1px dashed rgba(37,99,235,.65);`;
|
||||
spine.appendChild(guide);
|
||||
if (text) {
|
||||
const span = document.createElement('span');
|
||||
span.textContent = text;
|
||||
span.style.cssText = `writing-mode:vertical-rl;transform:rotate(180deg);font-size:7pt;font-weight:700;color:${insert.src ? 'rgba(255,255,255,.92)' : 'rgba(15,23,42,.82)'};letter-spacing:.04em;white-space:nowrap;max-height:90%;overflow:hidden;`;
|
||||
spine.appendChild(span);
|
||||
}
|
||||
wrap.appendChild(spine);
|
||||
};
|
||||
buildSpine('left', spineLabels.left || '');
|
||||
buildSpine('right', spineLabels.right || '');
|
||||
}
|
||||
|
||||
const label = document.createElement('div');
|
||||
label.style.cssText = `position:absolute;left:50%;bottom:-1.25rem;transform:translateX(-50%);font-size:.72rem;font-weight:700;color:#64748b;white-space:nowrap;`;
|
||||
label.textContent = slotDef.label || slotDef.key;
|
||||
wrap.appendChild(label);
|
||||
sheet.appendChild(wrap);
|
||||
});
|
||||
|
||||
const sheetLabel = document.createElement('div');
|
||||
sheetLabel.style.cssText = `position:absolute;left:0;right:0;top:8px;text-align:center;font-size:.72rem;font-weight:700;color:#94a3b8;pointer-events:none;z-index:3;`;
|
||||
sheetLabel.textContent = 'Jewel case (front + tray with spines)';
|
||||
sheet.appendChild(sheetLabel);
|
||||
|
||||
container.appendChild(sheet);
|
||||
}
|
||||
|
||||
async function runNostrSearch() {
|
||||
const q = $('nostrQuery').value.trim();
|
||||
const type = $('nostrType').value;
|
||||
@@ -7531,10 +7786,12 @@
|
||||
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' });
|
||||
const slotTarget = state._discImportTargetSlot;
|
||||
if (slotTarget) {
|
||||
importDiscDesignFromPayload(row.payload, slotTarget, { openDesigner: false });
|
||||
} else {
|
||||
importDiscDesignFromPayload(row.payload, 'a', { openDesigner: true });
|
||||
}
|
||||
} else {
|
||||
loadProjectFromText(JSON.stringify(row.payload));
|
||||
}
|
||||
@@ -7675,8 +7932,20 @@
|
||||
const select = $('layerTarget');
|
||||
if (!select) return;
|
||||
if (state.appMode === 'designer') {
|
||||
select.innerHTML = `<option value="designer-disc">Current disc design</option>`;
|
||||
return;
|
||||
}
|
||||
if (state.templateMode === 'disc') {
|
||||
select.innerHTML = `
|
||||
<option value="designer-disc">Current disc design</option>
|
||||
<option value="page">Whole page</option>
|
||||
<option value="disc-top">Disc 1</option>
|
||||
<option value="disc-bottom">Disc 2</option>
|
||||
`;
|
||||
} else if (state.templateMode === 'jewel') {
|
||||
select.innerHTML = `
|
||||
<option value="page">Whole page</option>
|
||||
<option value="jewel-front">Jewel front insert</option>
|
||||
<option value="jewel-tray">Jewel tray insert</option>
|
||||
`;
|
||||
} else {
|
||||
select.innerHTML = `
|
||||
@@ -7685,8 +7954,6 @@
|
||||
<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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -7717,23 +7984,85 @@
|
||||
localStorage.setItem('casewrap-welcomed', '1');
|
||||
}
|
||||
|
||||
let _welcomeTab = 'New';
|
||||
|
||||
function renderWelcomeRows(rows) {
|
||||
const out = $('welcomeResults');
|
||||
if (!out) return;
|
||||
out.innerHTML = '';
|
||||
if (!rows.length) {
|
||||
out.innerHTML = '<div class="hint" style="grid-column:1/-1">No templates found yet. Start from scratch below or browse all designs.</div>';
|
||||
return;
|
||||
}
|
||||
rows.slice(0, 24).forEach(row => {
|
||||
const card = buildFeedCard(row, () => {
|
||||
if (row.mode === 'disc-design') {
|
||||
importDiscDesignFromPayload(row.payload, 'a', { openDesigner: true });
|
||||
} else {
|
||||
loadProjectFromText(JSON.stringify(row.payload));
|
||||
}
|
||||
dismissWelcomeModal();
|
||||
});
|
||||
out.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadWelcomeTab(tab) {
|
||||
_welcomeTab = tab;
|
||||
['New', 'Popular', 'Search'].forEach(t => {
|
||||
const btn = $(`welcomeTab${t}`);
|
||||
if (btn) btn.classList.toggle('active', t === tab);
|
||||
});
|
||||
$('welcomeTabSearch').style.display = tab === 'Search' ? '' : 'none';
|
||||
const out = $('welcomeResults');
|
||||
if (!_feedCache.events && !_feedCache.loading) {
|
||||
out.innerHTML = '<div class="hint" style="grid-column:1/-1">Loading community designs…</div>';
|
||||
}
|
||||
const type = $('welcomeType').value;
|
||||
const category = $('welcomeCategory').value;
|
||||
const rerender = () => {
|
||||
let rows = buildRowsFromEvents(_feedCache.events || [], type, '', false, category);
|
||||
if (tab === 'Popular') {
|
||||
rows = deduplicateRows(rows);
|
||||
rows.sort((a, b) => (Number(isPreviewBroken(a)) - Number(isPreviewBroken(b))) || (b.reactions - a.reactions) || (TIER_WEIGHT[a.trust] - TIER_WEIGHT[b.trust]) || (b.createdAt - a.createdAt));
|
||||
} else {
|
||||
rows.sort((a, b) => (Number(isPreviewBroken(a)) - Number(isPreviewBroken(b))) || (TIER_WEIGHT[a.trust] - TIER_WEIGHT[b.trust]) || (b.createdAt - a.createdAt));
|
||||
}
|
||||
renderWelcomeRows(rows);
|
||||
};
|
||||
_currentFeedRerender = rerender;
|
||||
if (_feedCache.events && _feedCache.events.length) rerender();
|
||||
await fetchFeedAndReactions();
|
||||
if (_currentFeedRerender === rerender) rerender();
|
||||
}
|
||||
|
||||
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();
|
||||
}));
|
||||
_welcomeTab = 'Search';
|
||||
['New', 'Popular', 'Search'].forEach(t => {
|
||||
const btn = $(`welcomeTab${t}`);
|
||||
if (btn) btn.classList.toggle('active', t === 'Search');
|
||||
});
|
||||
$('welcomeTabSearch').style.display = '';
|
||||
const out = $('welcomeResults');
|
||||
out.innerHTML = '<div class="hint" style="grid-column:1/-1">Searching relays…</div>';
|
||||
const rerender = () => {
|
||||
let rows = buildRowsFromEvents(_feedCache.events || [], type, q, false, category);
|
||||
if (q) rows = deduplicateRows(rows);
|
||||
rows.sort((a, b) => (Number(isPreviewBroken(a)) - Number(isPreviewBroken(b))) || (b.reactions - a.reactions));
|
||||
renderWelcomeRows(rows);
|
||||
};
|
||||
_currentFeedRerender = rerender;
|
||||
await fetchFeedAndReactions();
|
||||
if (_currentFeedRerender === rerender) rerender();
|
||||
}
|
||||
|
||||
function openWelcomeBrowseAll() {
|
||||
dismissWelcomeModal();
|
||||
$('nostrModal').classList.add('open');
|
||||
if (typeof switchModalTab === 'function') switchModalTab(_welcomeTab === 'Popular' ? 'Popular' : 'New');
|
||||
}
|
||||
|
||||
function attachEvents() {
|
||||
@@ -7773,11 +8102,11 @@
|
||||
});
|
||||
}
|
||||
|
||||
$("titleField").addEventListener("input", (e)=>{state.meta.title=e.target.value; render();});
|
||||
$("titleField").addEventListener("input", (e)=>{state.meta.title=e.target.value; if (state.discDesign) state.discDesign.name = 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();});
|
||||
$("languageField").addEventListener("change", (e)=>{state.meta.language=e.target.value; render();});
|
||||
$("designerTitleField").addEventListener("input", (e)=>{state.meta.title=e.target.value; render();});
|
||||
$("designerTitleField").addEventListener("input", (e)=>{state.meta.title=e.target.value; if (state.discDesign) state.discDesign.name = 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();});
|
||||
$("designerLanguageField").addEventListener("change", (e)=>{state.meta.language=e.target.value; render();});
|
||||
@@ -7955,12 +8284,6 @@
|
||||
$('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;
|
||||
@@ -8007,6 +8330,16 @@
|
||||
state.discSpineLabels = { ...state.discSpineLabels, bottom: e.target.value };
|
||||
render();
|
||||
});
|
||||
const jslEl = $('jewelSpineTextLeft');
|
||||
if (jslEl) jslEl.addEventListener('input', e => {
|
||||
state.jewelSpineLabels = { ...state.jewelSpineLabels, left: e.target.value };
|
||||
render();
|
||||
});
|
||||
const jsrEl = $('jewelSpineTextRight');
|
||||
if (jsrEl) jsrEl.addEventListener('input', e => {
|
||||
state.jewelSpineLabels = { ...state.jewelSpineLabels, right: e.target.value };
|
||||
render();
|
||||
});
|
||||
|
||||
$('designerPublishBtn').addEventListener('click', async () => {
|
||||
if (!state.community.myPubkey) {
|
||||
@@ -8044,6 +8377,12 @@
|
||||
});
|
||||
$('welcomeSearchBtn').addEventListener('click', runWelcomeSearch);
|
||||
$('welcomeSkipBtn').addEventListener('click', dismissWelcomeModal);
|
||||
$('welcomeBrowseAllBtn').addEventListener('click', openWelcomeBrowseAll);
|
||||
$('welcomeTabNew').addEventListener('click', () => loadWelcomeTab('New'));
|
||||
$('welcomeTabPopular').addEventListener('click', () => loadWelcomeTab('Popular'));
|
||||
$('welcomeQuery').addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); runWelcomeSearch(); } });
|
||||
$('welcomeType').addEventListener('change', () => { if (_welcomeTab !== 'Search') loadWelcomeTab(_welcomeTab); });
|
||||
$('welcomeCategory').addEventListener('change', () => { if (_welcomeTab !== 'Search') loadWelcomeTab(_welcomeTab); });
|
||||
$('loadProjectBtn').addEventListener('click', () => $('projectFile').click());
|
||||
$('projectFile').addEventListener('change', (event) => {
|
||||
const file = event.target.files && event.target.files[0];
|
||||
@@ -8310,6 +8649,7 @@
|
||||
populateCategorySelects();
|
||||
if (!localStorage.getItem('casewrap-welcomed')) {
|
||||
$('welcomeModal').classList.add('open');
|
||||
setTimeout(() => loadWelcomeTab('New'), 0);
|
||||
}
|
||||
buildSlotControls();
|
||||
buildDiscControls();
|
||||
|
||||
Reference in new issue
Block a user