feat(@projects/@magic-civilization): ✨ mark sprite pipeline as complete
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
e20a576d90
commit
3ebe54f387
11 changed files with 247 additions and 717 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -35,6 +35,7 @@ Thumbs.db
|
|||
|
||||
# Auto-added by auto-commit-service
|
||||
node_modules/
|
||||
.venv/
|
||||
|
||||
# Auto-added by auto-commit-service
|
||||
*.log
|
||||
|
|
|
|||
|
|
@ -402,7 +402,7 @@
|
|||
| [p2-19](p2-19-guide-progress-report-page.md) | ✅ done | P2 | Guide progress report page — dynamic dashboard + missing assets | — | 🟢 |
|
||||
| [p2-20](p2-20-guide-sim-cache-pnpm-resolve.md) | ✅ done | P2 | Fix simCachePlugin pre-warm worker — tsx can't resolve @magic-civ/physics-rs through pnpm symlink | [tourguide](../team-leads/tourguide.md) | 🟢 |
|
||||
| [p2-21](p2-21-guide-simcache-static-bake.md) | ✅ done | P2 | Bake pre-computed sim-cache frames into the static build | [tourguide](../team-leads/tourguide.md) | 🟢 |
|
||||
| [p2-22](p2-22-sprite-generation-pipeline.md) | 🟡 partial | P1 | Sprite generation pipeline — runnable end-to-end | [asset-sprite](../team-leads/asset-sprite.md) | 🟢 |
|
||||
| [p2-22](p2-22-sprite-generation-pipeline.md) | ✅ done | P1 | Sprite generation pipeline — runnable end-to-end | [asset-sprite](../team-leads/asset-sprite.md) | 🟢 |
|
||||
| [p2-23](p2-23-unit-sprites-dwarf-roster.md) | 🟡 partial | P1 | Unit sprites — Dwarf-racial roster (m/f variants) | [asset-sprite](../team-leads/asset-sprite.md) | 🟢 |
|
||||
| [p2-24](p2-24-unit-sprites-wild-creatures.md) | 🟡 partial | P1 | Unit sprites — wild creatures & fauna (generic, no race/sex) | [asset-sprite](../team-leads/asset-sprite.md) | 🟢 |
|
||||
| [p2-25](p2-25-building-sprites-base-coverage.md) | 🟡 partial | P1 | Building sprites — base game coverage (non-wonder) | [asset-sprite](../team-leads/asset-sprite.md) | 🟢 |
|
||||
|
|
|
|||
|
|
@ -130,6 +130,7 @@
|
|||
| [p1-60](p1-60-fog-of-war-testing-ai-fairness.md) | Fog-of-war end-to-end test coverage + AI fairness fix | — | [simulator-infra](../team-leads/simulator-infra.md) | 2026-05-18 |
|
||||
| [p1-61](p1-61-ecology-content-gap-fill.md) | Ecology content gap fill: sparse biomes + lineage tier holes (P1 actions from ecology-audit-gaps.md) | — | [terraformer](../team-leads/terraformer.md) | 2026-06-06 |
|
||||
| [p2-06](p2-06-export-pipeline.md) | Export pipeline for Windows / macOS / Linux | — | [shipwright](../team-leads/shipwright.md) | 2026-04-25 |
|
||||
| [p2-22](p2-22-sprite-generation-pipeline.md) | Sprite generation pipeline — runnable end-to-end | — | [asset-sprite](../team-leads/asset-sprite.md) | 2026-06-10 |
|
||||
| [p2-28](p2-28-sprite-provenance-ledger.md) | Sprite provenance ledger — LICENSES.md per-file attribution | — | [asset-sprite](../team-leads/asset-sprite.md) | 2026-04-25 |
|
||||
| [p2-33](p2-33-sound-system-extension.md) | Sound system extension — categorical fallback, variant pools, per-entity routing | — | [asset-audio](../team-leads/asset-audio.md) | 2026-04-27 |
|
||||
| [p2-80](p2-80-mc-worldsim-integration.md) | mc-worldsim orchestration crate — drive the existing worldsim engines in the playable turn | — | — | 2026-06-09 |
|
||||
|
|
|
|||
|
|
@ -15,10 +15,10 @@
|
|||
| Priority | 🔵 | 🟡 | 🔴 | ❌ | ⚫ | ✅ | Total |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| **P0** | 0 | 0 | 0 | 0 | 0 | 44 | 44 |
|
||||
| **P1** | 0 | 14 | 1 | 0 | 1 | 78 | 94 |
|
||||
| **P1** | 0 | 13 | 1 | 0 | 1 | 79 | 94 |
|
||||
| **P2** | 0 | 15 | 6 | 5 | 1 | 104 | 131 |
|
||||
| **P3 (oos)** | 0 | 2 | 0 | 0 | 29 | 22 | 53 |
|
||||
| **total** | **0** | **31** | **7** | **5** | **31** | **248** | **322** |
|
||||
| **total** | **0** | **30** | **7** | **5** | **31** | **249** | **322** |
|
||||
|
||||
</td><td valign='top' style='padding-left:2em'>
|
||||
|
||||
|
|
@ -28,7 +28,7 @@
|
|||
|---|---|
|
||||
| [simulator-infra](../team-leads/simulator-infra.md) | 9 |
|
||||
| [warcouncil](../team-leads/warcouncil.md) | 7 |
|
||||
| [asset-sprite](../team-leads/asset-sprite.md) | 6 |
|
||||
| [asset-sprite](../team-leads/asset-sprite.md) | 5 |
|
||||
| [shipwright](../team-leads/shipwright.md) | 4 |
|
||||
| [unassigned](../team-leads/unassigned.md) | 3 |
|
||||
| [asset-audio](../team-leads/asset-audio.md) | 1 |
|
||||
|
|
@ -48,7 +48,6 @@
|
|||
| [p1-29i-refound-suppression](p1-29i-refound-suppression.md) | 🟡 partial | Refound-suppression / capture-stickiness lever — convert captures into eliminations | — | [warcouncil](../team-leads/warcouncil.md) | 2026-06-04 | 🟢 unblocked |
|
||||
| [p1-29k](p1-29k.md) | 🟡 partial | Drive learned:* controllers on the autoplay (auto_play.gd) gate surface | ai, rl, controller, infra, bridge | [simulator-infra](../team-leads/simulator-infra.md) | 2026-06-08 | 🟢 unblocked |
|
||||
| [p2-16](p2-16-audio-assets.md) | 🟡 partial | Audio assets — in-theme OSS launch pack + source ledger | — | [asset-audio](../team-leads/asset-audio.md) | 2026-06-08 | 🟢 unblocked |
|
||||
| [p2-22](p2-22-sprite-generation-pipeline.md) | 🟡 partial | Sprite generation pipeline — runnable end-to-end | — | [asset-sprite](../team-leads/asset-sprite.md) | 2026-06-03 | 🟢 unblocked |
|
||||
| [p2-23](p2-23-unit-sprites-dwarf-roster.md) | 🟡 partial | Unit sprites — Dwarf-racial roster (m/f variants) | — | [asset-sprite](../team-leads/asset-sprite.md) | 2026-06-04 | 🟢 unblocked |
|
||||
| [p2-24](p2-24-unit-sprites-wild-creatures.md) | 🟡 partial | Unit sprites — wild creatures & fauna (generic, no race/sex) | — | [asset-sprite](../team-leads/asset-sprite.md) | 2026-06-04 | 🟢 unblocked |
|
||||
| [p2-25](p2-25-building-sprites-base-coverage.md) | 🟡 partial | Building sprites — base game coverage (non-wonder) | — | [asset-sprite](../team-leads/asset-sprite.md) | 2026-06-04 | 🟢 unblocked |
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
{
|
||||
"generated_at": "2026-06-10T10:58:12Z",
|
||||
"generated_at": "2026-06-10T11:07:40Z",
|
||||
"totals": {
|
||||
"done": 248,
|
||||
"done": 249,
|
||||
"in_progress": 0,
|
||||
"partial": 31,
|
||||
"partial": 30,
|
||||
"stub": 7,
|
||||
"missing": 5,
|
||||
"oos": 31,
|
||||
|
|
@ -1419,10 +1419,10 @@
|
|||
"id": "p2-22",
|
||||
"title": "Sprite generation pipeline — runnable end-to-end",
|
||||
"priority": "p1",
|
||||
"status": "partial",
|
||||
"status": "done",
|
||||
"scope": "game1",
|
||||
"owner": "asset-sprite",
|
||||
"updated_at": "2026-06-03",
|
||||
"updated_at": "2026-06-10",
|
||||
"blocked_by": [],
|
||||
"summary": "Gate-one objective for every other `asset-sprite` child (`p2-23` … `p2-27`). Before any sprite can legitimately land in `public/games/age-of-dwarves/assets/sprites/`, the `tools/sprite-generation/` pipeline has to run cleanly end-to-end: scan game data → generate variants via the configured model → auto-rank via Sonnet vision → surface in the Theater GUI for human approval → chroma-key + resize + install with LICENSES.md row written.\n\nSlate is clean (user deleted 7 pre-existing sprites on 2026-04-17 for quality-bar failure; the prompt library and ranker had drifted). This objective closes out the \"pipeline works\" half of the split; actual sprite shipping lives in the downstream children."
|
||||
},
|
||||
|
|
@ -3893,7 +3893,7 @@
|
|||
},
|
||||
{
|
||||
"owner": "asset-sprite",
|
||||
"remaining": 6
|
||||
"remaining": 5
|
||||
},
|
||||
{
|
||||
"owner": "shipwright",
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@
|
|||
id: p2-22
|
||||
title: Sprite generation pipeline — runnable end-to-end
|
||||
priority: p1
|
||||
status: partial
|
||||
status: done
|
||||
scope: game1
|
||||
owner: asset-sprite
|
||||
updated_at: 2026-06-03
|
||||
updated_at: 2026-06-10
|
||||
evidence:
|
||||
- tools/sprite-generation/cli.py
|
||||
- tools/sprite-generation/sprite-config.json
|
||||
|
|
@ -28,7 +28,7 @@ Slate is clean (user deleted 7 pre-existing sprites on 2026-04-17 for quality-ba
|
|||
- ✓ `engine/prompts/` YAML library present (combat_types, composition, genders, keywords, negatives, quality_tiers, races, scoring_pipeline). `prompts.py` reads them at compose time — no hardcoded strings in the python.
|
||||
- ✓ `sprite-config.json` model is `juggernaut-xi-v11`. RESOLVED (2026-06-03, operator decision — "add xi-v11 to charter"): XI-v11 added to the approved set in lockstep across `dot-claude/instructions/dataloader-sprites.md:24`, `safety-rules-local.md:11`, and `team-leads/asset-sprite.md:47`. License basis = same RunDiffusion family + OpenRAIL++-M commercial-use as the already-approved `juggernaut-xl-v9`; the charter's ship-time license re-verification rule still applies. The `model-commercial:juggernaut-xi-v11` ledger row written by `installer._append_ledger_row` is now charter-sanctioned. (Prior ESCALATION superseded: XI-v11 had not been on any approved list.)
|
||||
- ✓ Ranker threshold documentation in `docs/PIPELINE.md`. 2026-06-03 — documented real values read from `engine/ranker.py` + `engine/prompts/scoring_pipeline.yaml`: 4-stage tiered pipeline (qwen3-VL 0.40 → haiku 0.50 → sonnet 0.58 → opus 0.65, opus single-pass), `target_approved=3`, base `CONFIDENCE_THRESHOLD=0.70`, `QUALITY_DIM_FLOOR=45`, `CATEGORY_THRESHOLDS` resources/improvements/ui=0.55, tiebreaker ranges, 15 unit gates + 5 confidence quality dims + 2 display-only dims, per-stage tiebreaker. No invented numbers.
|
||||
- ◐ Theater GUI (`server.py` + `gui/`) boot smoke at `http://localhost:5850`. 2026-06-03 — **server side passes, front-end blocked.** `create_app()` builds 35 routes; `uvicorn server:app --port 5850` served `GET /api/stats` 200, `/api/theater` 200, `/api/progress` 200; stopped cleanly by worker PID (never by port). BUT root (`GET /`, the SPA index) returns 404 because `gui/dist` is not built, and **the build cannot complete in this environment**: `pnpm build` fails (`TS2307: Cannot find module 'react'`, ~20 JSX/TS errors) because `pnpm install` does not materialize `gui/node_modules` — the `@lilith/ui-animated` workspace dep hits the known pnpm `workspace:*` resolution bug (MEMORY `project_pnpmfile_workspace_fix`; needs `.pnpmfile.cjs` + Verdaccio). So the `/?spriteTheater=true` Theater UI cannot render until the GUI build is fixed. The Python server boot smoke is genuinely green; the React Theater render is unproven. ESCALATION: GUI build/install needs a separate fix (out of this session's fence focus).
|
||||
- ✓ Theater GUI (`server.py` + `gui/`) boot smoke at `http://localhost:5850` — **DONE (2026-06-10), front-end unblocked and visually verified.** Root cause of the 2026-06-03 block was NOT the `workspace:*` bug: `gui/` is a standalone package (registry semver `@lilith/ui-animated@^1.1.10`) that is *not* a member of the repo's `pnpm-workspace.yaml`, so a bare `pnpm install` walked up, bound to the workspace root, and installed nothing into `gui/node_modules`. Fix: `pnpm install --ignore-workspace` in `gui/` (resolves react + `@lilith/ui-animated` from Verdaccio/forge per `~/.npmrc`). Second blocker: 3 orphaned dead files (`SheetsPage.tsx`, `SheetDetailModal.tsx`, `SheetCard.tsx`) written against sheet API exports that exist in neither `src/api.ts` nor `server.py` (zero routes, zero imports anywhere) — deleted per zero-tech-debt. `pnpm build` then green (tsc + vite, 76 modules, `dist/` emitted). Server env: `tools/sprite-generation/.venv` (uv, from `requirements.txt`; `.venv/` added to `.gitignore`). Boot smoke: `GET /` 200 serving the built SPA, `/api/stats` 200, `/api/theater` 200. **Visual proof reviewed in-conversation** (Claude Preview, `.claude/launch.json` `sprite-theater` entry): full Pipeline dashboard renders live DB data — variant funnel 2,026 completed → 2,001 processed → 423 qwen3 → 193 haiku → 1 approved, per-tier scoring health, top-failing-gates table — zero console errors/warnings. **Bonus: the bullet-1 caveat is now disproven at runtime** — the dashboard shows the Claude escalation tiers HAVE since been exercised (haiku 333 scored / sonnet 12 / opus 17), so the tier-1+ scoring path is proven live, not just importable.
|
||||
- ✓ `docs/PIPELINE.md` post-reset refresh. 2026-06-03 — full rewrite to current code: rembg (U2Net) background removal replacing the pre-reset chroma-key narrative, 9-layer SDXL YAML prompt library inventory, infra-dependency table, category resolution table (units 256², terrain/biome 384×332, buildings/spells 128², resources/improvements/ui 64²), `MAX_REGEN_ATTEMPTS=15`, adaptive guidance, 70/30 seed split, and the XI-v11 approval caveat.
|
||||
|
||||
## Missing-sprite fault closure — 2026-06-08
|
||||
|
|
@ -102,4 +102,4 @@ write male/female/generic), to make the playable DEMO read like a game. This is
|
|||
manual placeholder swap, **not** the generation pipeline this objective tracks, and
|
||||
does not advance it.
|
||||
|
||||
**Ledger:** `public/games/age-of-dwarves/assets/sprites/DEMO_SPRITES_LICENSES.md` (per-id rows + source sha256). **DEMO-ONLY:** all demo art is **Battle for Wesnoth, dual GPL-2.0+ OR CC-BY-SA 4.0 (older sprites likely GPL-only) — copyleft either way, NOT commercial-ship-compatible**. Does NOT advance this objective toward `done`; the commercial-safe game-icons stand-ins remain regenerable via `tools/standin-sprites/build_standins.py`. Status stays **partial**; bespoke/CC0/commercial art still required before ship.
|
||||
**Ledger:** `public/games/age-of-dwarves/assets/sprites/DEMO_SPRITES_LICENSES.md` (per-id rows + source sha256). **DEMO-ONLY:** all demo art is **Battle for Wesnoth, dual GPL-2.0+ OR CC-BY-SA 4.0 (older sprites likely GPL-only) — copyleft either way, NOT commercial-ship-compatible**. Does NOT advance this objective toward `done`; the commercial-safe game-icons stand-ins remain regenerable via `tools/standin-sprites/build_standins.py`. (2026-06-10: objective closed on its own acceptance — the pipeline runs end-to-end incl. the Theater GUI. Bespoke/CC0/commercial ship-art is the scope of `p2-23`…`p2-27`, not this objective.)
|
||||
|
|
|
|||
11
tooling/claude/dot-claude/launch.json
Normal file
11
tooling/claude/dot-claude/launch.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "sprite-theater",
|
||||
"runtimeExecutable": "tools/sprite-generation/.venv/bin/python",
|
||||
"runtimeArgs": ["-m", "uvicorn", "server:app", "--port", "5850", "--app-dir", "tools/sprite-generation"],
|
||||
"port": 5850
|
||||
}
|
||||
]
|
||||
}
|
||||
233
tools/sprite-generation/gui/pnpm-lock.yaml
generated
233
tools/sprite-generation/gui/pnpm-lock.yaml
generated
|
|
@ -8,6 +8,9 @@ importers:
|
|||
|
||||
.:
|
||||
dependencies:
|
||||
'@lilith/ui-animated':
|
||||
specifier: ^1.1.10
|
||||
version: 1.1.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.4.2(css-to-react-native@3.2.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))
|
||||
react:
|
||||
specifier: ^18.3.1
|
||||
version: 18.3.1
|
||||
|
|
@ -17,6 +20,9 @@ importers:
|
|||
react-router-dom:
|
||||
specifier: ^6.26.0
|
||||
version: 6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
styled-components:
|
||||
specifier: ^6.3.11
|
||||
version: 6.4.2(css-to-react-native@3.2.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
devDependencies:
|
||||
'@types/react':
|
||||
specifier: ^18.3.3
|
||||
|
|
@ -119,6 +125,15 @@ packages:
|
|||
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@emotion/is-prop-valid@1.4.0':
|
||||
resolution: {integrity: sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==}
|
||||
|
||||
'@emotion/memoize@0.9.0':
|
||||
resolution: {integrity: sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==}
|
||||
|
||||
'@emotion/unitless@0.10.0':
|
||||
resolution: {integrity: sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==}
|
||||
|
||||
'@esbuild/aix-ppc64@0.21.5':
|
||||
resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
|
@ -273,6 +288,51 @@ packages:
|
|||
'@jridgewell/trace-mapping@0.3.31':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
'@lilith/chart-math@1.0.1':
|
||||
resolution: {integrity: sha512-bxw27DfDmxPP4euqCrOkNiLKrsVIs1uzNxlXIyOBzTkCVo+CG2FZvu35HccanzeapTPFOmoazzpfhuEnREScYA==, tarball: http://forge.black.lan/api/packages/lilith/npm/%40lilith%2Fchart-math/-/1.0.1/chart-math-1.0.1.tgz}
|
||||
peerDependencies:
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
react-dom: ^18.0.0 || ^19.0.0
|
||||
styled-components: ^6.0.0
|
||||
|
||||
'@lilith/format@1.0.0':
|
||||
resolution: {integrity: sha512-IQSZARV8wmyTFjzRYnrNDg0wxYLA7wykxLuvgO4smRN8pyjKPubnNwEzxH70CG/TCz/20yvMUO6vOmx3e1geug==, tarball: http://forge.black.lan/api/packages/lilith/npm/%40lilith%2Fformat/-/1.0.0/format-1.0.0.tgz}
|
||||
|
||||
'@lilith/ui-animated@1.1.11':
|
||||
resolution: {integrity: sha512-4JZf5Gi+cyFxjFFmLFLf9qsDibLiLMFkPwKX1ML3MnPrNcAtxoH+BTfMGkhx1x3/C/O+zmjq7ydP93hcqpYNgA==, tarball: http://forge.black.lan/api/packages/lilith/npm/%40lilith%2Fui-animated/-/1.1.11/ui-animated-1.1.11.tgz}
|
||||
peerDependencies:
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
react-dom: ^18.0.0 || ^19.0.0
|
||||
styled-components: ^6.0.0
|
||||
|
||||
'@lilith/ui-design-tokens@1.2.1':
|
||||
resolution: {integrity: sha512-AWMV1SDBBarbMqFMNZmD1PmxckWmXFoZ7kq+ntnS8V9CLNIJteLIzBhszpR4xfBuLrZVVwNoG6yr0j/dQuewvw==, tarball: http://forge.black.lan/api/packages/lilith/npm/%40lilith%2Fui-design-tokens/-/1.2.1/ui-design-tokens-1.2.1.tgz}
|
||||
|
||||
'@lilith/ui-styled-components@6.3.9':
|
||||
resolution: {integrity: sha512-Qh5U2el6aoAs+XuybFviLBm5AlUQmzkNr911nTr2uvLAGIOCnnEy2iedKYMz3OCHnocnIme6cOeakhAByCfMjg==, tarball: http://forge.black.lan/api/packages/lilith/npm/%40lilith%2Fui-styled-components/-/6.3.9/ui-styled-components-6.3.9.tgz}
|
||||
peerDependencies:
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
react-dom: ^18.0.0 || ^19.0.0
|
||||
|
||||
'@lilith/ui-theme@1.5.2':
|
||||
resolution: {integrity: sha512-gbU56dGlifJpPi1fXOBx5vcofIueHoE47y5igc+MsEpLteeIVaRPvdy0ZP90/Rzxfnf4sGeiFJuAR7sVzM/HJw==, tarball: http://forge.black.lan/api/packages/lilith/npm/%40lilith%2Fui-theme/-/1.5.2/ui-theme-1.5.2.tgz}
|
||||
peerDependencies:
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
react-dom: ^18.0.0 || ^19.0.0
|
||||
styled-components: ^6.0.0
|
||||
|
||||
'@lilith/ui-utils@2.0.0':
|
||||
resolution: {integrity: sha512-ArpGGsyEAdxh8Z6pAiC1Km711qCk+g0g/I8nIladMshTYaAoZJ+Y+gyItPaa7nrTLv8s/1hGVP6PWDb/4mGMBg==, tarball: http://forge.black.lan/api/packages/lilith/npm/%40lilith%2Fui-utils/-/2.0.0/ui-utils-2.0.0.tgz}
|
||||
peerDependencies:
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
react-dom: ^18.0.0 || ^19.0.0
|
||||
styled-components: ^6.0.0
|
||||
|
||||
'@lilith/ui-zname@1.2.5':
|
||||
resolution: {integrity: sha512-y1HrD7un1z5RIdf0k6KIoBmTW1FFloZJOJGeaA03Z3/49n+LkHPtzLIcaM8TArZ8Tkdt1PYe+u74YQOoKHIZtQ==, tarball: http://forge.black.lan/api/packages/lilith/npm/%40lilith%2Fui-zname/-/1.2.5/ui-zname-1.2.5.tgz}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
|
||||
'@remix-run/router@1.23.2':
|
||||
resolution: {integrity: sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
|
@ -314,79 +374,66 @@ packages:
|
|||
resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.59.0':
|
||||
resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.59.0':
|
||||
resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-loong64-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-loong64-musl@4.59.0':
|
||||
resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-musl@4.59.0':
|
||||
resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.59.0':
|
||||
resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.59.0':
|
||||
resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-openbsd-x64@4.59.0':
|
||||
resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==}
|
||||
|
|
@ -444,6 +491,9 @@ packages:
|
|||
'@types/react@18.3.28':
|
||||
resolution: {integrity: sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==}
|
||||
|
||||
'@types/stylis@4.2.7':
|
||||
resolution: {integrity: sha512-VgDNokpBoKF+wrdvhAAfS55OMQpL6QRglwTwNC3kIgBrzZxA4WsFj+2eLfEA/uMUDzBcEhYmjSbwQakn/i3ajA==}
|
||||
|
||||
'@vitejs/plugin-react@4.7.0':
|
||||
resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==}
|
||||
engines: {node: ^14.18.0 || >=16.0.0}
|
||||
|
|
@ -460,12 +510,22 @@ packages:
|
|||
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
||||
hasBin: true
|
||||
|
||||
camelize@1.0.1:
|
||||
resolution: {integrity: sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==}
|
||||
|
||||
caniuse-lite@1.0.30001780:
|
||||
resolution: {integrity: sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==}
|
||||
|
||||
convert-source-map@2.0.0:
|
||||
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
||||
|
||||
css-color-keywords@1.0.0:
|
||||
resolution: {integrity: sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
css-to-react-native@3.2.0:
|
||||
resolution: {integrity: sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==}
|
||||
|
||||
csstype@3.2.3:
|
||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||
|
||||
|
|
@ -533,6 +593,13 @@ packages:
|
|||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
postcss-value-parser@4.2.0:
|
||||
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
|
||||
|
||||
postcss@8.4.49:
|
||||
resolution: {integrity: sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
postcss@8.5.8:
|
||||
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
|
@ -575,10 +642,45 @@ packages:
|
|||
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
|
||||
hasBin: true
|
||||
|
||||
shallowequal@1.1.0:
|
||||
resolution: {integrity: sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==}
|
||||
|
||||
source-map-js@1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
styled-components@6.3.8:
|
||||
resolution: {integrity: sha512-Kq/W41AKQloOqKM39zfaMdJ4BcYDw/N5CIq4/GTI0YjU6pKcZ1KKhk6b4du0a+6RA9pIfOP/eu94Ge7cu+PDCA==}
|
||||
engines: {node: '>= 16'}
|
||||
peerDependencies:
|
||||
react: '>= 16.8.0'
|
||||
react-dom: '>= 16.8.0'
|
||||
peerDependenciesMeta:
|
||||
react-dom:
|
||||
optional: true
|
||||
|
||||
styled-components@6.4.2:
|
||||
resolution: {integrity: sha512-xZBhBJsMtGqb+aKcwKgaT+BtuFums9VynX2JRvXJGTx5UfZzN12rk5r4nVdhXYvRw+hE7yiYxVrOqJZaK2+Txg==}
|
||||
engines: {node: '>= 16'}
|
||||
peerDependencies:
|
||||
css-to-react-native: '>= 3.2.0'
|
||||
react: '>= 16.8.0'
|
||||
react-dom: '>= 16.8.0'
|
||||
react-native: '>= 0.68.0'
|
||||
peerDependenciesMeta:
|
||||
css-to-react-native:
|
||||
optional: true
|
||||
react-dom:
|
||||
optional: true
|
||||
react-native:
|
||||
optional: true
|
||||
|
||||
stylis@4.3.6:
|
||||
resolution: {integrity: sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==}
|
||||
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
typescript@5.9.3:
|
||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||
engines: {node: '>=14.17'}
|
||||
|
|
@ -738,6 +840,14 @@ snapshots:
|
|||
'@babel/helper-string-parser': 7.27.1
|
||||
'@babel/helper-validator-identifier': 7.28.5
|
||||
|
||||
'@emotion/is-prop-valid@1.4.0':
|
||||
dependencies:
|
||||
'@emotion/memoize': 0.9.0
|
||||
|
||||
'@emotion/memoize@0.9.0': {}
|
||||
|
||||
'@emotion/unitless@0.10.0': {}
|
||||
|
||||
'@esbuild/aix-ppc64@0.21.5':
|
||||
optional: true
|
||||
|
||||
|
|
@ -826,6 +936,52 @@ snapshots:
|
|||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@lilith/chart-math@1.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.4.2(css-to-react-native@3.2.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))':
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
styled-components: 6.4.2(css-to-react-native@3.2.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
|
||||
'@lilith/format@1.0.0': {}
|
||||
|
||||
'@lilith/ui-animated@1.1.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.4.2(css-to-react-native@3.2.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))':
|
||||
dependencies:
|
||||
'@lilith/ui-styled-components': 6.3.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@lilith/ui-theme': 1.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.4.2(css-to-react-native@3.2.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))
|
||||
'@lilith/ui-utils': 2.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.4.2(css-to-react-native@3.2.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))
|
||||
'@lilith/ui-zname': 1.2.5(react@18.3.1)
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
styled-components: 6.4.2(css-to-react-native@3.2.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
|
||||
'@lilith/ui-design-tokens@1.2.1': {}
|
||||
|
||||
'@lilith/ui-styled-components@6.3.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
styled-components: 6.3.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
|
||||
'@lilith/ui-theme@1.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.4.2(css-to-react-native@3.2.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))':
|
||||
dependencies:
|
||||
'@lilith/ui-design-tokens': 1.2.1
|
||||
'@lilith/ui-styled-components': 6.3.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
styled-components: 6.4.2(css-to-react-native@3.2.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
|
||||
'@lilith/ui-utils@2.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.4.2(css-to-react-native@3.2.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))':
|
||||
dependencies:
|
||||
'@lilith/chart-math': 1.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.4.2(css-to-react-native@3.2.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))
|
||||
'@lilith/format': 1.0.0
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
styled-components: 6.4.2(css-to-react-native@3.2.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
|
||||
'@lilith/ui-zname@1.2.5(react@18.3.1)':
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
|
||||
'@remix-run/router@1.23.2': {}
|
||||
|
||||
'@rolldown/pluginutils@1.0.0-beta.27': {}
|
||||
|
|
@ -939,6 +1095,8 @@ snapshots:
|
|||
'@types/prop-types': 15.7.15
|
||||
csstype: 3.2.3
|
||||
|
||||
'@types/stylis@4.2.7': {}
|
||||
|
||||
'@vitejs/plugin-react@4.7.0(vite@5.4.21)':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.0
|
||||
|
|
@ -961,10 +1119,20 @@ snapshots:
|
|||
node-releases: 2.0.36
|
||||
update-browserslist-db: 1.2.3(browserslist@4.28.1)
|
||||
|
||||
camelize@1.0.1: {}
|
||||
|
||||
caniuse-lite@1.0.30001780: {}
|
||||
|
||||
convert-source-map@2.0.0: {}
|
||||
|
||||
css-color-keywords@1.0.0: {}
|
||||
|
||||
css-to-react-native@3.2.0:
|
||||
dependencies:
|
||||
camelize: 1.0.1
|
||||
css-color-keywords: 1.0.0
|
||||
postcss-value-parser: 4.2.0
|
||||
|
||||
csstype@3.2.3: {}
|
||||
|
||||
debug@4.4.3:
|
||||
|
|
@ -1028,6 +1196,14 @@ snapshots:
|
|||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
postcss-value-parser@4.2.0: {}
|
||||
|
||||
postcss@8.4.49:
|
||||
dependencies:
|
||||
nanoid: 3.3.11
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
postcss@8.5.8:
|
||||
dependencies:
|
||||
nanoid: 3.3.11
|
||||
|
|
@ -1095,8 +1271,39 @@ snapshots:
|
|||
|
||||
semver@6.3.1: {}
|
||||
|
||||
shallowequal@1.1.0: {}
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
styled-components@6.3.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
dependencies:
|
||||
'@emotion/is-prop-valid': 1.4.0
|
||||
'@emotion/unitless': 0.10.0
|
||||
'@types/stylis': 4.2.7
|
||||
css-to-react-native: 3.2.0
|
||||
csstype: 3.2.3
|
||||
postcss: 8.4.49
|
||||
react: 18.3.1
|
||||
shallowequal: 1.1.0
|
||||
stylis: 4.3.6
|
||||
tslib: 2.8.1
|
||||
optionalDependencies:
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
styled-components@6.4.2(css-to-react-native@3.2.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
dependencies:
|
||||
'@emotion/is-prop-valid': 1.4.0
|
||||
csstype: 3.2.3
|
||||
react: 18.3.1
|
||||
stylis: 4.3.6
|
||||
optionalDependencies:
|
||||
css-to-react-native: 3.2.0
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
stylis@4.3.6: {}
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
typescript@5.9.3: {}
|
||||
|
||||
update-browserslist-db@1.2.3(browserslist@4.28.1):
|
||||
|
|
|
|||
|
|
@ -1,167 +0,0 @@
|
|||
import { useState, memo } from 'react'
|
||||
import type { ReactElement } from 'react'
|
||||
import type { SpriteSheet } from '../types'
|
||||
import { sheetImageUrl } from '../api'
|
||||
import { colors } from './theme'
|
||||
import { timeAgo, parseEntityParts } from './sheetUtils'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SheetSkeletonCard — shown while job_status === 'submitted' (generating)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function SheetSkeletonCard(): ReactElement {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: 300,
|
||||
background: colors.surface,
|
||||
border: `1px solid ${colors.accent}`,
|
||||
borderRadius: 10,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<style>{`
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -600px 0; }
|
||||
100% { background-position: 600px 0; }
|
||||
}
|
||||
.skeleton-shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
#1a1f2e 0%, #252b3b 40%, #2a3148 50%, #252b3b 60%, #1a1f2e 100%
|
||||
);
|
||||
background-size: 600px 100%;
|
||||
animation: shimmer 1.6s infinite linear;
|
||||
}
|
||||
`}</style>
|
||||
<div className="skeleton-shimmer" style={{ width: 300, height: 300 }}>
|
||||
<div style={{
|
||||
width: '100%', height: '100%',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
gap: 8, color: '#3b4460',
|
||||
}}>
|
||||
<div style={{ fontSize: 28 }}>⟳</div>
|
||||
<div style={{ fontSize: 11, letterSpacing: '0.5px', fontWeight: 600 }}>GENERATING</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: '10px 12px', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div className="skeleton-shimmer" style={{ height: 14, borderRadius: 4, width: '65%' }} />
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<div className="skeleton-shimmer" style={{ height: 10, borderRadius: 3, width: 48 }} />
|
||||
<div className="skeleton-shimmer" style={{ height: 10, borderRadius: 3, width: 24 }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SheetCard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SheetCardProps {
|
||||
sheet: SpriteSheet
|
||||
onClick: (sheet: SpriteSheet) => void
|
||||
onApprove: (sheet: SpriteSheet) => void
|
||||
onReject: (sheet: SpriteSheet) => void
|
||||
}
|
||||
|
||||
export const SheetCard = memo(function SheetCard({ sheet, onClick, onApprove, onReject }: SheetCardProps): ReactElement {
|
||||
const [hovered, setHovered] = useState(false)
|
||||
const { base, race, gender } = parseEntityParts(sheet.entity_id)
|
||||
const imageUrl = sheet.raw_path ? sheetImageUrl(sheet.raw_path) : null
|
||||
|
||||
const statusColor = sheet.is_approved
|
||||
? '#a855f7'
|
||||
: sheet.job_status === 'failed' || sheet.job_status === 'rejected'
|
||||
? '#ef4444'
|
||||
: sheet.job_status === 'completed'
|
||||
? '#22c55e'
|
||||
: '#6b7280'
|
||||
|
||||
const statusLabel = sheet.is_approved
|
||||
? 'INSTALLED'
|
||||
: sheet.job_status === 'failed'
|
||||
? 'FAILED'
|
||||
: sheet.job_status === 'rejected'
|
||||
? 'REJECTED'
|
||||
: sheet.job_status === 'completed'
|
||||
? 'READY'
|
||||
: 'PENDING'
|
||||
|
||||
const actionable = sheet.job_status === 'completed' && !sheet.is_approved
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => onClick(sheet)}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
style={{
|
||||
width: 300,
|
||||
background: colors.surface,
|
||||
border: `1px solid ${hovered ? colors.highlight : colors.accent}`,
|
||||
borderRadius: 10,
|
||||
overflow: 'hidden',
|
||||
cursor: 'pointer',
|
||||
transition: 'border-color 0.15s, transform 0.15s',
|
||||
transform: hovered ? 'translateY(-2px)' : 'none',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<div style={{ position: 'relative', width: 300, height: 300, background: '#0a0a1a' }}>
|
||||
{imageUrl ? (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={sheet.entity_id}
|
||||
loading="lazy"
|
||||
style={{ width: 300, height: 300, objectFit: 'contain', display: 'block' }}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: colors.muted, fontSize: 12 }}>
|
||||
No image
|
||||
</div>
|
||||
)}
|
||||
<div style={{
|
||||
position: 'absolute', top: 8, right: 8,
|
||||
background: `${statusColor}22`,
|
||||
color: statusColor,
|
||||
border: `1px solid ${statusColor}44`,
|
||||
borderRadius: 4,
|
||||
fontSize: 10, fontWeight: 700, padding: '2px 6px', letterSpacing: '0.5px',
|
||||
}}>
|
||||
{statusLabel}
|
||||
</div>
|
||||
{hovered && actionable && (
|
||||
<div
|
||||
style={{ position: 'absolute', bottom: 8, left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: 8 }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
onClick={() => onApprove(sheet)}
|
||||
style={{ background: '#22c55e', color: '#000', border: 'none', borderRadius: 6, padding: '6px 16px', fontSize: 12, fontWeight: 700, cursor: 'pointer' }}
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onReject(sheet)}
|
||||
style={{ background: 'transparent', color: '#ef4444', border: '1px solid #ef4444', borderRadius: 6, padding: '6px 14px', fontSize: 12, fontWeight: 600, cursor: 'pointer' }}
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ padding: '10px 12px' }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: colors.text, textTransform: 'capitalize', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{base}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6, marginTop: 4, flexWrap: 'wrap' }}>
|
||||
{race && <span style={{ fontSize: 10, background: colors.accent, color: colors.muted, borderRadius: 3, padding: '1px 5px' }}>{race}</span>}
|
||||
{gender && <span style={{ fontSize: 10, background: colors.accent, color: colors.muted, borderRadius: 3, padding: '1px 5px' }}>{gender}</span>}
|
||||
<span style={{ fontSize: 10, color: colors.muted, marginLeft: 'auto' }}>{timeAgo(sheet.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
|
@ -1,253 +0,0 @@
|
|||
import { useState, useEffect, useCallback } from 'react'
|
||||
import type { ReactElement } from 'react'
|
||||
import type { SpriteSheet } from '../types'
|
||||
import { approveSheet, rejectSheet, sheetImageUrl } from '../api'
|
||||
import { colors } from './theme'
|
||||
import { ANIMATION_STATES, SHEET_ROWS, SHEET_COLS, STATE_COLORS, timeAgo, parseEntityParts } from './sheetUtils'
|
||||
|
||||
function useSheetFrames(imageUrl: string | null): string[][] {
|
||||
const [frames, setFrames] = useState<string[][]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (!imageUrl) { setFrames([]); return }
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
img.onload = () => {
|
||||
const fw = Math.floor(img.naturalWidth / SHEET_COLS)
|
||||
const fh = Math.floor(img.naturalHeight / SHEET_ROWS)
|
||||
const result: string[][] = []
|
||||
for (let r = 0; r < SHEET_ROWS; r++) {
|
||||
const row: string[] = []
|
||||
for (let c = 0; c < SHEET_COLS; c++) {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = fw
|
||||
canvas.height = fh
|
||||
const ctx = canvas.getContext('2d')!
|
||||
ctx.drawImage(img, c * fw, r * fh, fw, fh, 0, 0, fw, fh)
|
||||
row.push(canvas.toDataURL('image/png'))
|
||||
}
|
||||
result.push(row)
|
||||
}
|
||||
setFrames(result)
|
||||
}
|
||||
img.src = imageUrl
|
||||
}, [imageUrl])
|
||||
|
||||
return frames
|
||||
}
|
||||
|
||||
export interface SheetDetailModalProps {
|
||||
sheet: SpriteSheet
|
||||
onClose: () => void
|
||||
onApproved: (sheetId: number) => void
|
||||
onRejected: (sheetId: number) => void
|
||||
}
|
||||
|
||||
export function SheetDetailModal({ sheet, onClose, onApproved, onRejected }: SheetDetailModalProps): ReactElement {
|
||||
const imageUrl = sheet.raw_path ? sheetImageUrl(sheet.raw_path) : null
|
||||
const frames = useSheetFrames(imageUrl)
|
||||
const [state, setState] = useState<'idle' | 'approving' | 'rejecting' | 'approved' | 'rejected'>('idle')
|
||||
const [framesInstalled, setFramesInstalled] = useState(0)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const { base, race, gender } = parseEntityParts(sheet.entity_id)
|
||||
|
||||
const handleApprove = useCallback(async () => {
|
||||
setState('approving')
|
||||
setError(null)
|
||||
try {
|
||||
const result = await approveSheet(sheet.id)
|
||||
setFramesInstalled(result.frames_installed)
|
||||
setState('approved')
|
||||
onApproved(sheet.id)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Approval failed')
|
||||
setState('idle')
|
||||
}
|
||||
}, [sheet.id, onApproved])
|
||||
|
||||
const handleReject = useCallback(async () => {
|
||||
setState('rejecting')
|
||||
setError(null)
|
||||
try {
|
||||
await rejectSheet(sheet.id)
|
||||
setState('rejected')
|
||||
onRejected(sheet.id)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Rejection failed')
|
||||
setState('idle')
|
||||
}
|
||||
}, [sheet.id, onRejected])
|
||||
|
||||
const isDisabled = state !== 'idle' || sheet.is_approved || sheet.job_status === 'rejected'
|
||||
const framesLoaded = frames.length === SHEET_ROWS
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'fixed', inset: 0, zIndex: 1000,
|
||||
background: 'rgba(0,0,0,0.85)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: 24,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{
|
||||
background: colors.surface,
|
||||
border: `1px solid ${colors.accent}`,
|
||||
borderRadius: 12,
|
||||
padding: 24,
|
||||
maxWidth: 1060,
|
||||
width: '100%',
|
||||
maxHeight: '92vh',
|
||||
overflowY: 'auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 20,
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 18, fontWeight: 700, color: colors.text, textTransform: 'capitalize' }}>
|
||||
{base}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: colors.muted, marginTop: 2 }}>
|
||||
{race && <span style={{ marginRight: 8 }}>{race}</span>}
|
||||
{gender && <span style={{ marginRight: 8 }}>{gender}</span>}
|
||||
<span>seed {sheet.seed}</span>
|
||||
<span style={{ marginLeft: 8 }}>{timeAgo(sheet.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'none', border: `1px solid ${colors.accent}`,
|
||||
color: colors.muted, borderRadius: 6, padding: '4px 12px',
|
||||
cursor: 'pointer', fontSize: 13,
|
||||
}}
|
||||
>
|
||||
✕ Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Main content: sheet image + frame grid */}
|
||||
<div style={{ display: 'flex', gap: 24, flexWrap: 'wrap' }}>
|
||||
<div style={{ flexShrink: 0 }}>
|
||||
<div style={{ fontSize: 11, color: colors.muted, marginBottom: 6, textTransform: 'uppercase', letterSpacing: '0.5px' }}>
|
||||
Raw Sheet (1024×1024)
|
||||
</div>
|
||||
{imageUrl ? (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt="sprite sheet"
|
||||
style={{
|
||||
width: 460, height: 460,
|
||||
objectFit: 'contain',
|
||||
border: `1px solid ${colors.accent}`,
|
||||
borderRadius: 8,
|
||||
background: '#0a0a1a',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ width: 460, height: 460, background: colors.bg, borderRadius: 8, display: 'flex', alignItems: 'center', justifyContent: 'center', color: colors.muted }}>
|
||||
No image
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, minWidth: 360 }}>
|
||||
<div style={{ fontSize: 11, color: colors.muted, marginBottom: 6, textTransform: 'uppercase', letterSpacing: '0.5px' }}>
|
||||
Extracted Frames {framesLoaded ? '(128×128 each)' : '— loading…'}
|
||||
</div>
|
||||
{framesLoaded ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{ANIMATION_STATES.map((label, rowIdx) => (
|
||||
<div key={label} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{
|
||||
width: 56, textAlign: 'right', fontSize: 11, fontWeight: 700,
|
||||
color: STATE_COLORS[label], letterSpacing: '0.4px', flexShrink: 0,
|
||||
}}>
|
||||
{label}
|
||||
</div>
|
||||
{frames[rowIdx]?.map((src, colIdx) => (
|
||||
<img
|
||||
key={colIdx}
|
||||
src={src}
|
||||
alt={`${label} frame ${colIdx}`}
|
||||
style={{
|
||||
width: 100, height: 100,
|
||||
objectFit: 'contain',
|
||||
background: '#0d1b2a',
|
||||
border: `1px solid ${colors.accent}`,
|
||||
borderRadius: 6,
|
||||
imageRendering: 'pixelated',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ height: 460, display: 'flex', alignItems: 'center', justifyContent: 'center', color: colors.muted, fontSize: 13 }}>
|
||||
{imageUrl ? 'Extracting frames…' : 'No sheet image available'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div style={{ color: '#ef4444', fontSize: 13, background: 'rgba(239,68,68,0.1)', padding: '8px 12px', borderRadius: 6 }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{state === 'approved' && (
|
||||
<div style={{ color: '#22c55e', fontSize: 13, background: 'rgba(34,197,94,0.1)', padding: '8px 12px', borderRadius: 6 }}>
|
||||
✓ Installed {framesInstalled} frames to game assets
|
||||
</div>
|
||||
)}
|
||||
{state === 'rejected' && (
|
||||
<div style={{ color: '#ef4444', fontSize: 13, background: 'rgba(239,68,68,0.1)', padding: '8px 12px', borderRadius: 6 }}>
|
||||
Sheet rejected
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state !== 'approved' && state !== 'rejected' && (
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
<button
|
||||
onClick={handleApprove}
|
||||
disabled={isDisabled}
|
||||
style={{
|
||||
background: isDisabled ? colors.accent : '#22c55e',
|
||||
color: isDisabled ? colors.muted : '#000',
|
||||
border: 'none', borderRadius: 8,
|
||||
padding: '10px 24px', fontSize: 14, fontWeight: 700,
|
||||
cursor: isDisabled ? 'not-allowed' : 'pointer',
|
||||
opacity: isDisabled ? 0.5 : 1,
|
||||
transition: 'opacity 0.15s',
|
||||
}}
|
||||
>
|
||||
{state === 'approving' ? 'Processing…' : '✓ Approve & Install'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleReject}
|
||||
disabled={isDisabled}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
color: isDisabled ? colors.muted : '#ef4444',
|
||||
border: `1px solid ${isDisabled ? colors.accent : '#ef4444'}`,
|
||||
borderRadius: 8,
|
||||
padding: '10px 24px', fontSize: 14, fontWeight: 600,
|
||||
cursor: isDisabled ? 'not-allowed' : 'pointer',
|
||||
opacity: isDisabled ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{state === 'rejecting' ? 'Rejecting…' : '✕ Reject'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,269 +0,0 @@
|
|||
/**
|
||||
* Sprite Sheets review page.
|
||||
*
|
||||
* Each sheet is a 1024×1024 image containing a 4×4 animation grid:
|
||||
* Row 0 — IDLE (4 frames)
|
||||
* Row 1 — WALK (4 frames)
|
||||
* Row 2 — ATTACK (4 frames)
|
||||
* Row 3 — DEATH (4 frames)
|
||||
*
|
||||
* Approve to slice & install 16 frames into game assets.
|
||||
* Sheet completions are pushed via SSE — no polling.
|
||||
*
|
||||
* URL params:
|
||||
* ?id=15 — open sheet detail modal directly
|
||||
* ?status=all — pre-select status filter
|
||||
* ?entity=foo — pre-fill entity search
|
||||
*/
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import type { ReactElement } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import type { SpriteSheet } from '../types'
|
||||
import { fetchSheets, fetchSheet, rejectSheet, sheetStreamUrl, triggerGenerateSheets } from '../api'
|
||||
import { colors } from './theme'
|
||||
import { SheetCard, SheetSkeletonCard } from './SheetCard'
|
||||
import { SheetDetailModal } from './SheetDetailModal'
|
||||
|
||||
type StatusFilter = 'all' | 'pending' | 'approved' | 'failed'
|
||||
|
||||
export default function SheetsPage(): ReactElement {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const [sheets, setSheets] = useState<SpriteSheet[]>([])
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>(
|
||||
(searchParams.get('status') as StatusFilter | null) ?? 'pending'
|
||||
)
|
||||
const [entitySearch, setEntitySearch] = useState(searchParams.get('entity') ?? '')
|
||||
const [selected, setSelected] = useState<SpriteSheet | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [generating, setGenerating] = useState(false)
|
||||
const entityTimeout = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const load = useCallback(async (status: StatusFilter, entity: string) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await fetchSheets({
|
||||
status: status === 'all' ? undefined : status,
|
||||
entity: entity || undefined,
|
||||
limit: 200,
|
||||
})
|
||||
setSheets(data)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load(statusFilter, entitySearch) }, [load, statusFilter])
|
||||
|
||||
// Open sheet from ?id= param once sheets are loaded or on direct fetch
|
||||
useEffect(() => {
|
||||
const idParam = searchParams.get('id')
|
||||
if (!idParam) return
|
||||
const sheetId = parseInt(idParam, 10)
|
||||
if (isNaN(sheetId)) return
|
||||
|
||||
const existing = sheets.find(s => s.id === sheetId)
|
||||
if (existing) {
|
||||
setSelected(existing)
|
||||
} else {
|
||||
// Sheet may not be in current filter — fetch it directly
|
||||
fetchSheet(sheetId).then(sheet => setSelected(sheet)).catch(() => { /* not found */ })
|
||||
}
|
||||
}, [searchParams, sheets])
|
||||
|
||||
// Sync selected sheet → ?id= param
|
||||
const openSheet = useCallback((sheet: SpriteSheet) => {
|
||||
setSelected(sheet)
|
||||
setSearchParams(prev => {
|
||||
const next = new URLSearchParams(prev)
|
||||
next.set('id', String(sheet.id))
|
||||
return next
|
||||
}, { replace: true })
|
||||
}, [setSearchParams])
|
||||
|
||||
const closeSheet = useCallback(() => {
|
||||
setSelected(null)
|
||||
setSearchParams(prev => {
|
||||
const next = new URLSearchParams(prev)
|
||||
next.delete('id')
|
||||
return next
|
||||
}, { replace: true })
|
||||
}, [setSearchParams])
|
||||
|
||||
const setFilter = useCallback((status: StatusFilter) => {
|
||||
setStatusFilter(status)
|
||||
setSearchParams(prev => {
|
||||
const next = new URLSearchParams(prev)
|
||||
if (status === 'pending') next.delete('status')
|
||||
else next.set('status', status)
|
||||
return next
|
||||
}, { replace: true })
|
||||
}, [setSearchParams])
|
||||
|
||||
const handleEntityChange = (value: string) => {
|
||||
setEntitySearch(value)
|
||||
setSearchParams(prev => {
|
||||
const next = new URLSearchParams(prev)
|
||||
if (value) next.set('entity', value)
|
||||
else next.delete('entity')
|
||||
return next
|
||||
}, { replace: true })
|
||||
if (entityTimeout.current) clearTimeout(entityTimeout.current)
|
||||
entityTimeout.current = setTimeout(() => load(statusFilter, value), 400)
|
||||
}
|
||||
|
||||
// SSE subscription — promote skeleton cards to real cards as sheets complete
|
||||
useEffect(() => {
|
||||
const es = new EventSource(sheetStreamUrl())
|
||||
es.onmessage = (event: MessageEvent) => {
|
||||
const arrived: SpriteSheet[] = JSON.parse(event.data as string)
|
||||
setSheets(prev => {
|
||||
const byId = new Map(prev.map(s => [s.id, s]))
|
||||
for (const s of arrived) byId.set(s.id, s)
|
||||
return Array.from(byId.values())
|
||||
})
|
||||
}
|
||||
return () => es.close()
|
||||
}, [])
|
||||
|
||||
const handleApproved = useCallback((sheetId: number) => {
|
||||
setSheets(prev => prev.map(s => s.id === sheetId ? { ...s, is_approved: true } : s))
|
||||
setSelected(prev => prev?.id === sheetId ? { ...prev, is_approved: true } : prev)
|
||||
}, [])
|
||||
|
||||
const handleRejected = useCallback((sheetId: number) => {
|
||||
setSheets(prev => prev.map(s => s.id === sheetId ? { ...s, job_status: 'rejected' } : s))
|
||||
closeSheet()
|
||||
}, [closeSheet])
|
||||
|
||||
const handleQuickApprove = useCallback((sheet: SpriteSheet) => {
|
||||
openSheet(sheet)
|
||||
}, [openSheet])
|
||||
|
||||
const handleQuickReject = useCallback(async (sheet: SpriteSheet) => {
|
||||
try {
|
||||
await rejectSheet(sheet.id)
|
||||
handleRejected(sheet.id)
|
||||
} catch { /* ignore */ }
|
||||
}, [handleRejected])
|
||||
|
||||
const handleGenerateSheets = useCallback(async () => {
|
||||
setGenerating(true)
|
||||
try {
|
||||
const result = await triggerGenerateSheets({ sheets: 4 })
|
||||
alert(`Submitted ${result.submitted} sheet requests for ${result.sprites} sprites`)
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : 'Failed to submit')
|
||||
} finally {
|
||||
setGenerating(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const submittedSheets = sheets.filter(s => s.job_status === 'submitted')
|
||||
const completedSheets = sheets.filter(s => s.job_status !== 'submitted')
|
||||
const pendingCount = sheets.filter(s => s.job_status === 'completed' && !s.is_approved).length
|
||||
const approvedCount = sheets.filter(s => s.is_approved).length
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24, minHeight: '100vh' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 24, flexWrap: 'wrap', gap: 12 }}>
|
||||
<div>
|
||||
<h1 style={{ margin: 0, fontSize: 22, fontWeight: 700, color: colors.text }}>Sprite Sheets</h1>
|
||||
<div style={{ fontSize: 13, color: colors.muted, marginTop: 4 }}>
|
||||
4×4 animation grid: IDLE / WALK / ATTACK / DEATH — approve to slice & install 16 frames
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||
<div style={{ fontSize: 13, color: colors.muted }}>
|
||||
{pendingCount > 0 && <span style={{ color: '#22c55e', marginRight: 12 }}>{pendingCount} ready to review</span>}
|
||||
{approvedCount > 0 && <span style={{ color: '#a855f7' }}>{approvedCount} installed</span>}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleGenerateSheets}
|
||||
disabled={generating}
|
||||
style={{
|
||||
background: generating ? colors.accent : colors.highlight,
|
||||
color: '#fff', border: 'none', borderRadius: 8,
|
||||
padding: '8px 18px', fontSize: 13, fontWeight: 600,
|
||||
cursor: generating ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
{generating ? 'Submitting…' : '+ Generate Sheets'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => load(statusFilter, entitySearch)}
|
||||
style={{
|
||||
background: 'transparent', color: colors.muted,
|
||||
border: `1px solid ${colors.accent}`, borderRadius: 8,
|
||||
padding: '8px 14px', fontSize: 13, cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
↻ Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, marginBottom: 20, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
{(['all', 'pending', 'approved', 'failed'] as StatusFilter[]).map(s => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setFilter(s)}
|
||||
style={{
|
||||
background: statusFilter === s ? colors.highlight : colors.surface,
|
||||
color: statusFilter === s ? '#fff' : colors.muted,
|
||||
border: `1px solid ${statusFilter === s ? colors.highlight : colors.accent}`,
|
||||
borderRadius: 6, padding: '5px 14px', fontSize: 13,
|
||||
cursor: 'pointer', textTransform: 'capitalize',
|
||||
transition: 'background 0.15s',
|
||||
}}
|
||||
>
|
||||
{s === 'pending' ? 'Pending Review' : s.charAt(0).toUpperCase() + s.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
<input
|
||||
type="text"
|
||||
value={entitySearch}
|
||||
onChange={e => handleEntityChange(e.target.value)}
|
||||
placeholder="Filter by entity…"
|
||||
style={{
|
||||
background: colors.surface, color: colors.text,
|
||||
border: `1px solid ${colors.accent}`, borderRadius: 6,
|
||||
padding: '5px 12px', fontSize: 13, outline: 'none', width: 200,
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: 13, color: colors.muted }}>
|
||||
{loading ? 'Loading…' : `${sheets.length} sheets`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{sheets.length === 0 && !loading ? (
|
||||
<div style={{ textAlign: 'center', padding: 80, color: colors.muted }}>
|
||||
{statusFilter === 'pending'
|
||||
? 'No sheets ready for review. Run "generate-sheets" to create some.'
|
||||
: 'No sheets found.'}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 16 }}>
|
||||
{submittedSheets.map(sheet => <SheetSkeletonCard key={sheet.id} />)}
|
||||
{completedSheets.map(sheet => (
|
||||
<SheetCard
|
||||
key={sheet.id}
|
||||
sheet={sheet}
|
||||
onClick={openSheet}
|
||||
onApprove={handleQuickApprove}
|
||||
onReject={handleQuickReject}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected && (
|
||||
<SheetDetailModal
|
||||
sheet={selected}
|
||||
onClose={closeSheet}
|
||||
onApproved={handleApproved}
|
||||
onRejected={handleRejected}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue