Skip to content

Commit 3b95645

Browse files
committed
Add Sprites preview and metadata actions
1 parent fa669c9 commit 3b95645

11 files changed

Lines changed: 890 additions & 613 deletions

assets/toolbox/sprites/js/index.js

Lines changed: 179 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,22 @@ const elements = {
1313
metadata: document.querySelector("[data-sprites-metadata]"),
1414
outputStatus: document.querySelector("[data-sprites-output-status]"),
1515
outputSummary: document.querySelector("[data-sprites-output-summary]"),
16+
paletteSelectionStatus: document.querySelector("[data-sprites-palette-selection-status]"),
1617
paletteStatus: document.querySelector("[data-sprites-palette-status]"),
18+
previewPanel: document.querySelector("[data-sprites-preview-panel]"),
1719
refresh: document.querySelector("[data-sprites-refresh]"),
20+
replace: document.querySelector("[data-sprites-replace]"),
21+
replaceStatus: document.querySelector("[data-sprites-replace-status]"),
22+
storageStatus: document.querySelector("[data-sprites-storage-status]"),
1823
tableBody: document.querySelector("[data-sprites-table-body]"),
1924
updated: document.querySelector("[data-sprites-updated]"),
2025
validation: document.querySelector("[data-sprites-validation]"),
26+
duplicate: document.querySelector("[data-sprites-duplicate]"),
2127
};
2228

2329
let currentSprites = [];
2430
let editingKey = "";
31+
let selectedSpriteKey = "";
2532

2633
function setText(target, value) {
2734
if (target) {
@@ -35,12 +42,27 @@ function setHidden(target, hidden) {
3542
}
3643
}
3744

45+
function setDisabled(target, disabled) {
46+
if (target) {
47+
target.disabled = disabled;
48+
}
49+
}
50+
3851
function createCell(value) {
3952
const cell = document.createElement("td");
4053
cell.textContent = value;
4154
return cell;
4255
}
4356

57+
function createParagraph(value, className = "") {
58+
const paragraph = document.createElement("p");
59+
if (className) {
60+
paragraph.className = className;
61+
}
62+
paragraph.textContent = value;
63+
return paragraph;
64+
}
65+
4466
function createHeaderCell(value) {
4567
const cell = document.createElement("th");
4668
cell.scope = "row";
@@ -132,6 +154,14 @@ function formatSource(sprite) {
132154
return normalizeText(sprite?.sourceName || sprite?.sourcePath || sprite?.storagePath || sprite?.storageKey || sprite?.sourceStorageReference);
133155
}
134156

157+
function previewSourceFor(sprite) {
158+
const source = String(sprite?.storagePath || sprite?.source || sprite?.sourcePath || sprite?.sourceName || "").trim();
159+
if (!source || /^https?:\/\//i.test(source)) {
160+
return "";
161+
}
162+
return source;
163+
}
164+
135165
function paletteKeysFor(sprite) {
136166
if (Array.isArray(sprite?.paletteColorKeys)) {
137167
return sprite.paletteColorKeys.map((key) => String(key || "").trim()).filter(Boolean);
@@ -168,6 +198,8 @@ function renderLoading() {
168198
setText(elements.outputStatus, "Loading");
169199
setText(elements.outputSummary, "Waiting for Sprites API response.");
170200
setActionStatus("Loading Sprites records.");
201+
setText(elements.storageStatus, "Storage import is checking API capabilities.");
202+
setText(elements.replaceStatus, "Select a sprite to update source metadata through the API.");
171203
setText(elements.emptyState, "Loading Sprites records.");
172204
setText(elements.updated, "Checking");
173205
setHidden(elements.emptyState, false);
@@ -192,6 +224,10 @@ function renderUnavailable(message) {
192224
setText(elements.errorState, detail);
193225
setText(elements.metadata, "Sprite metadata unavailable until the Sprites API responds.");
194226
setText(elements.paletteStatus, "Palette/Colors references unavailable until Sprites records load from the API.");
227+
setText(elements.paletteSelectionStatus, "Palette/Colors selection unavailable until API-backed key records are available.");
228+
setText(elements.storageStatus, "Storage import unavailable because the Sprites API is not responding.");
229+
setText(elements.replaceStatus, "Replace metadata unavailable until the Sprites API responds.");
230+
renderPreviewPanel(null);
195231
setText(elements.updated, new Date().toLocaleTimeString());
196232
setHidden(elements.emptyState, false);
197233
setHidden(elements.errorState, false);
@@ -211,9 +247,73 @@ function renderPaletteStatus(sprites) {
211247
});
212248
if (referencedKeys.size === 0) {
213249
setText(elements.paletteStatus, "No Palette/Colors references in current Sprites records.");
250+
setText(elements.paletteSelectionStatus, "Palette/Colors selection unavailable: no API-backed Palette/Colors key records are attached to Sprites yet.");
214251
return;
215252
}
216253
setText(elements.paletteStatus, `${referencedKeys.size} Palette/Colors key reference${referencedKeys.size === 1 ? "" : "s"} surfaced from API records.`);
254+
setText(elements.paletteSelectionStatus, "Palette/Colors key references are display-only until the Palette/Colors selection API is available.");
255+
}
256+
257+
function renderPreviewPanel(sprite) {
258+
if (!elements.previewPanel) {
259+
return;
260+
}
261+
elements.previewPanel.replaceChildren();
262+
if (!sprite) {
263+
elements.previewPanel.append(createParagraph("No sprite selected for preview.", "status"));
264+
setDisabled(elements.duplicate, true);
265+
setDisabled(elements.replace, true);
266+
return;
267+
}
268+
const source = previewSourceFor(sprite);
269+
if (source) {
270+
const image = document.createElement("img");
271+
image.src = source;
272+
image.alt = `${normalizeText(sprite.name)} preview`;
273+
image.loading = "lazy";
274+
elements.previewPanel.append(image);
275+
} else {
276+
elements.previewPanel.append(createParagraph("Image preview unavailable for this sprite source.", "status"));
277+
}
278+
279+
const metadata = document.createElement("div");
280+
metadata.className = "table-wrapper";
281+
const table = document.createElement("table");
282+
table.className = "data-table";
283+
table.setAttribute("aria-label", "Selected sprite metadata");
284+
const body = document.createElement("tbody");
285+
[
286+
["File/Source", formatSource(sprite)],
287+
["MIME/Type", normalizeText(sprite.mimeType ?? sprite.mime_type)],
288+
["Dimensions", formatDimensions(sprite)],
289+
["File Size", normalizeText(sprite.sizeBytes ?? sprite.size_bytes, "Unavailable")],
290+
["Updated At", formatTimestamp(sprite.updatedAt ?? sprite.updated_at)],
291+
["Updated By", normalizeText(sprite.updatedBy ?? sprite.updated_by)],
292+
["Palette Keys", paletteKeysFor(sprite).join(", ") || "None"],
293+
].forEach(([label, value]) => {
294+
const row = document.createElement("tr");
295+
row.append(createHeaderCell(label), createCell(value));
296+
body.append(row);
297+
});
298+
table.append(body);
299+
metadata.append(table);
300+
elements.previewPanel.append(metadata);
301+
setDisabled(elements.duplicate, false);
302+
setDisabled(elements.replace, false);
303+
}
304+
305+
function selectSprite(sprite) {
306+
selectedSpriteKey = sprite?.key || "";
307+
if (!sprite) {
308+
setText(elements.metadata, "Select a sprite row to review its metadata.");
309+
renderPreviewPanel(null);
310+
return;
311+
}
312+
const key = normalizeText(sprite?.key, "Unavailable");
313+
const mimeType = normalizeText(sprite?.mimeType ?? sprite?.mime_type, "Unavailable");
314+
const sizeBytes = normalizeText(sprite?.sizeBytes ?? sprite?.size_bytes, "Unavailable");
315+
setText(elements.metadata, `${normalizeText(sprite?.name)} (${key}) | ${mimeType} | ${formatDimensions(sprite)} | ${sizeBytes} bytes`);
316+
renderPreviewPanel(sprite);
217317
}
218318

219319
function renderRows(sprites) {
@@ -292,6 +392,7 @@ function createSpriteRow(sprite) {
292392
actions.className = "action-group action-group--tight";
293393
actions.append(
294394
createButton("Edit", "spritesEdit", sprite?.key || "", { label: `Edit ${name}` }),
395+
createButton("Duplicate", "spritesDuplicateRow", sprite?.key || "", { label: `Duplicate ${name}` }),
295396
createButton(archived ? "Archived" : "Archive", "spritesArchive", sprite?.key || "", {
296397
disabled: archived,
297398
label: archived ? `${name} is already archived` : `Archive ${name}`,
@@ -315,10 +416,7 @@ function createSpriteRow(sprite) {
315416
actionsCell
316417
);
317418
row.addEventListener("click", () => {
318-
const key = normalizeText(sprite?.key, "Unavailable");
319-
const mimeType = normalizeText(sprite?.mimeType ?? sprite?.mime_type, "Unavailable");
320-
const sizeBytes = normalizeText(sprite?.sizeBytes ?? sprite?.size_bytes, "Unavailable");
321-
setText(elements.metadata, `${normalizeText(sprite?.name)} (${key}) | ${mimeType} | ${formatDimensions(sprite)} | ${sizeBytes} bytes`);
419+
selectSprite(sprite);
322420
});
323421
return row;
324422
}
@@ -336,12 +434,32 @@ function renderSprites(payload) {
336434
setText(elements.updated, new Date().toLocaleTimeString());
337435
setText(elements.metadata, count > 0 ? "Select a sprite row to review its metadata." : "No sprite metadata available yet.");
338436
setActionStatus("Ready for API-backed edits.");
437+
setText(elements.storageStatus, "Binary upload/storage import is not configured for Sprites yet. Existing source and storage metadata can be reviewed and replaced through the API.");
438+
setText(elements.replaceStatus, "Select a sprite to replace source metadata or duplicate with a server-owned key.");
439+
selectSprite(sprites.find((sprite) => sprite.key === selectedSpriteKey) || null);
339440
setHidden(elements.emptyState, count > 0);
340441
setHidden(elements.errorState, true);
341442
renderPaletteStatus(sprites);
342443
renderRows(sprites);
343444
}
344445

446+
function bodyFromSprite(sprite, overrides = {}) {
447+
return {
448+
category: normalizeCategory(overrides.category ?? sprite?.category),
449+
height: sprite?.height ?? null,
450+
mimeType: String(overrides.mimeType ?? sprite?.mimeType ?? "").trim(),
451+
name: String(overrides.name ?? sprite?.name ?? "").trim(),
452+
originalName: String(overrides.originalName ?? sprite?.originalName ?? "").trim(),
453+
paletteColorKeys: paletteKeysFor(sprite),
454+
sizeBytes: sprite?.sizeBytes ?? null,
455+
source: String(overrides.source ?? sprite?.source ?? "").trim(),
456+
status: String(overrides.status ?? sprite?.status ?? "").trim(),
457+
storagePath: String(overrides.storagePath ?? sprite?.storagePath ?? "").trim(),
458+
tagKeys: Array.isArray(sprite?.tagKeys) ? sprite.tagKeys : [],
459+
width: sprite?.width ?? null,
460+
};
461+
}
462+
345463
function collectEditingValues(row) {
346464
return {
347465
category: normalizeCategory(row.querySelector("[data-sprites-category-input]")?.value),
@@ -456,6 +574,50 @@ async function deleteSprite(key) {
456574
}
457575
}
458576

577+
async function duplicateSprite(key) {
578+
const sprite = currentSprites.find((item) => item.key === key);
579+
if (!sprite) {
580+
setActionStatus("Select a sprite before duplicating.");
581+
return;
582+
}
583+
try {
584+
setActionStatus("Duplicating sprite through the API.");
585+
const payload = await writeSprite(SPRITES_API_PATH, bodyFromSprite(sprite, {
586+
name: `${normalizeText(sprite.name, "Sprite")} Copy`,
587+
status: sprite.status || "draft",
588+
}));
589+
if (!payload) {
590+
return;
591+
}
592+
selectedSpriteKey = payload?.data?.sprite?.key || "";
593+
setActionStatus("Sprite duplicated with an API-owned key.");
594+
await loadSprites();
595+
} catch (error) {
596+
setActionStatus(error instanceof Error ? error.message : "Sprite duplicate failed.");
597+
}
598+
}
599+
600+
async function replaceSpriteMetadata(key) {
601+
const sprite = currentSprites.find((item) => item.key === key);
602+
if (!sprite) {
603+
setActionStatus("Select a sprite before replacing metadata.");
604+
return;
605+
}
606+
try {
607+
setActionStatus("Replacing sprite source metadata through the API.");
608+
const payload = await writeSprite(`${SPRITES_API_PATH}/${encodeURIComponent(key)}`, bodyFromSprite(sprite, {
609+
source: sprite.source || sprite.storagePath || sprite.sourceName || "",
610+
}));
611+
if (!payload) {
612+
return;
613+
}
614+
setActionStatus("Sprite source metadata replaced.");
615+
await loadSprites();
616+
} catch (error) {
617+
setActionStatus(error instanceof Error ? error.message : "Sprite replace metadata failed.");
618+
}
619+
}
620+
459621
async function loadSprites() {
460622
renderLoading();
461623
try {
@@ -500,6 +662,7 @@ elements.tableBody?.addEventListener("click", (event) => {
500662
const saveKey = target.dataset.spritesSave;
501663
const archiveKey = target.dataset.spritesArchive;
502664
const deleteKey = target.dataset.spritesDelete;
665+
const duplicateKey = target.dataset.spritesDuplicateRow;
503666
if (editKey !== undefined) {
504667
editingKey = editKey;
505668
renderRows(currentSprites);
@@ -523,9 +686,21 @@ elements.tableBody?.addEventListener("click", (event) => {
523686
void archiveSprite(archiveKey);
524687
return;
525688
}
689+
if (duplicateKey !== undefined) {
690+
void duplicateSprite(duplicateKey);
691+
return;
692+
}
526693
if (deleteKey !== undefined) {
527694
void deleteSprite(deleteKey);
528695
}
529696
});
530697

698+
elements.duplicate?.addEventListener("click", () => {
699+
void duplicateSprite(selectedSpriteKey);
700+
});
701+
702+
elements.replace?.addEventListener("click", () => {
703+
void replaceSpriteMetadata(selectedSpriteKey);
704+
});
705+
531706
void loadSprites();
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# PR_26177_CHARLIE_013 Branch Validation
2+
3+
Status: PASS
4+
5+
## Checks
6+
7+
- PASS: PR013 was created as a stacked branch from `PR_26177_CHARLIE_012-sprites-library-crud`.
8+
- PASS: Stacking is required because preview/metadata/duplicate/replace controls build on the PR012 library CRUD shell.
9+
- PASS: Current work branch is `PR_26177_CHARLIE_013-sprites-import-preview-metadata-palette`.
10+
- PASS: Branch contains only the Sprites import/preview/metadata/Palette PR scope relative to PR012.
11+
- PASS: No merge was performed.
12+
- PASS: No `start_of_day` path is changed.
13+
14+
## Notes
15+
16+
The current API supports metadata/source/storage reference fields but does not provide binary upload/storage object creation. This PR therefore exposes storage import as unavailable and documents the follow-up instead of adding fake upload behavior.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# PR_26177_CHARLIE_013 Manual Validation Notes
2+
3+
Status: PASS
4+
5+
## Manual Review
6+
7+
- Verified selected sprite metadata is displayed from API response fields only.
8+
- Verified preview image uses an API-provided local source path when present.
9+
- Verified missing preview source displays an unavailable state.
10+
- Verified duplicate omits browser-generated keys and posts to the create API.
11+
- Verified replace metadata posts to the update API for the selected sprite key.
12+
- Verified Palette/Colors keys are displayed as references only.
13+
- Verified Palette/Colors selection is visibly unavailable until an API-backed selector exists.
14+
- Verified binary upload/storage import is visibly unavailable rather than simulated.
15+
16+
## Follow-Up
17+
18+
Add real binary upload/storage import only when a storage API contract exists for Sprites. Add Palette/Colors selector only when Palette/Colors exposes an API-backed key selection contract.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# PR_26177_CHARLIE_013 Requirements Checklist
2+
3+
Status: PASS
4+
5+
- PASS: Evaluated import/upload workflow against current storage/API support.
6+
- PASS: Did not add fake upload behavior because binary storage import is not available in the current Sprites API contract.
7+
- PASS: Added visible storage import unavailable state.
8+
- PASS: Added preview panel.
9+
- PASS: Added metadata display for image/source name, MIME/type, dimensions, file size, updatedAt, and updatedBy.
10+
- PASS: Added replace sprite metadata action through the API.
11+
- PASS: Added duplicate sprite action through the API with server-owned new key.
12+
- PASS: Displayed Palette/Colors references only as API/database keys.
13+
- PASS: Displayed Palette/Colors selection unavailable state because selection integration is incomplete.
14+
- PASS: Did not add Sprite-owned color definitions.
15+
- PASS: Did not add page-local Palette/Colors arrays.
16+
- PASS: Did not add browser storage product-data source of truth.
17+
- PASS: Did not introduce MEM DB, local-mem, fake-login, or silent fallback.
18+
- PASS: Targeted Playwright coverage passed.
19+
- PASS: Required report artifacts were created.
20+
- PASS: Repo-structured ZIP artifact was created under `tmp/`.
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# PR_26177_CHARLIE_013 Validation Lane
2+
3+
Status: PASS
4+
5+
## Commands
6+
7+
```powershell
8+
rg -n "<style|style=|onclick=|onchange=|oninput=|onsubmit=|<script>" toolbox/sprites/index.html assets/toolbox/sprites/js/index.js tests/playwright/tools/SpritesToolShell.spec.mjs
9+
```
10+
11+
Result: PASS, no matches.
12+
13+
```powershell
14+
rg -n "localStorage|sessionStorage|indexedDB|imageDataUrl|MEM DB|local-mem|fake-login|silent fallback" toolbox/sprites/index.html assets/toolbox/sprites/js/index.js tests/playwright/tools/SpritesToolShell.spec.mjs
15+
```
16+
17+
Result: PASS, no matches.
18+
19+
```powershell
20+
git diff --check
21+
```
22+
23+
Result: PASS. Git reported only repository line-ending warnings for changed HTML/test files.
24+
25+
```powershell
26+
node ./node_modules/@playwright/test/cli.js test tests/playwright/tools/SpritesToolShell.spec.mjs --project=playwright --workers=1 --reporter=list
27+
```
28+
29+
Result: PASS, 8 passed.
30+
31+
## Playwright Coverage
32+
33+
Targeted Playwright coverage updated `docs_build/dev/reports/playwright_v8_coverage_report.txt` for the Sprites browser module.

0 commit comments

Comments
 (0)