Show pet details in modal on pets page

This commit is contained in:
Alvin Unreal 2026-05-24 02:22:48 +02:00
parent ba11e95ae6
commit cdadedcb2e
2 changed files with 118 additions and 91 deletions

View file

@ -1825,7 +1825,8 @@ function App() {
setState(nextState); setCatalog(nextCatalog); setCodex(nextCodex);
setCatalogPage(nextCatalog.page ?? 0);
setCatalogPages({ [nextCatalog.page ?? 0]: nextCatalog.pets });
setSelectedId((current) => current || nextState.preferences.defaultPetId || nextState.pets.installed[0]?.id || nextCatalog.pets[0]?.id || "");
const visiblePetIds = new Set<string>([...nextState.pets.installed.map((pet) => pet.id), ...nextCatalog.pets.map((pet) => pet.id), ...nextCodex.pets.map((pet) => pet.id)]);
setSelectedId((current) => current && visiblePetIds.has(current) ? current : "");
}
useEffect(() => {
if (currentRoute !== "pets") return;
@ -1897,7 +1898,7 @@ function App() {
});
}, [state, catalogPages, catalogSearch, codex, filter, query]);
const selected = pets.find((p) => p.id === selectedId) ?? pets[0];
const selected = selectedId ? pets.find((p) => p.id === selectedId) ?? null : null;
const defaultId = state?.preferences.defaultPetId;
useEffect(() => {
@ -2047,7 +2048,12 @@ function App() {
const hasDistinctPreview = pet.preview && pet.preview !== pet.spritesheet;
const useSpritesheetFrame = !isBuiltIn && !hasDistinctPreview && !!pet.spritesheet;
return (
<button key={`${pet.sourceKind}-${pet.id}`} className={`pet-card ${selected?.id === pet.id ? "selected" : ""}`} onClick={() => setSelectedId(pet.id)}>
<button
key={`${pet.sourceKind}-${pet.id}`}
className={`pet-card ${selected?.id === pet.id ? "selected" : ""}`}
onClick={() => setSelectedId(pet.id)}
aria-label={`Open ${pet.displayName} details`}
>
<span className="thumb">
{useSpritesheetFrame ? (
<SpriteFrame src={pet.spritesheet} label={`${pet.displayName} thumbnail`} size="thumb" />
@ -2092,103 +2098,123 @@ function App() {
) : <span />}
</div>
</GlassCard>
<GlassCard className="detail">
{selected ? <><p className="eyebrow">Pet detail</p><h2>{selected.displayName}</h2><p className="desc">{selected.description || selected.id}</p>
<div className="stage">
{safePetImage(selected.spritesheet) ? (
<SpriteFrame src={selected.spritesheet} label={`${selected.displayName} animated preview`} />
) : (
<PetImage src={selected.preview} debugLabel={`${selected.id}:detail-fallback`} />
)}
</div>
<div className="meta">
{selected.broken && <StatusPill tone="red">Broken</StatusPill>}
{selected.installed && !selected.broken && <StatusPill tone="green">Ready</StatusPill>}
{selected.builtIn && <StatusPill tone="orange">Originals</StatusPill>}
{selected.original && !selected.builtIn && <StatusPill tone="yellow">Original</StatusPill>}
{selected.featured && !selected.original && <StatusPill tone="purple">Featured</StatusPill>}
{selected.category === "western" && !selected.original && !selected.featured && <StatusPill tone="slate">Western</StatusPill>}
{selected.category === "asian" && !selected.original && !selected.featured && <StatusPill tone="slate">Asian</StatusPill>}
</div>
{statusText && <p className="text-sm text-slatecopy mt-3 mb-0 font-medium">{statusText}</p>}
{safePetImage(selected.spritesheet) && (
<>
<h3 className="text-xs font-bold uppercase tracking-wider text-slatecopy mt-6 mb-3">Preview Animations</h3>
<div className="grid grid-cols-3 gap-3 mb-2">
{[
{ label: "Thinking", state: "thinking" as const },
{ label: "Happy", state: "happy" as const },
{ label: "Wave", state: "wave" as const }
].map((preview) => (
<div key={preview.label} className="flex flex-col items-center gap-2 rounded-2xl border border-blue-100 bg-white/50 p-3 shadow-sm">
<SpriteFrame src={selected.spritesheet} label={`${selected.displayName} ${preview.label} preview`} state={preview.state} size="mini" />
<span className="text-xs font-bold text-slatecopy">{preview.label}</span>
</div>
))}
{selected ? (
<div className="plugin-config-overlay" role="dialog" aria-modal="true" aria-label={`${selected.displayName} pet details`}>
<button className="plugin-config-backdrop" type="button" aria-label="Close pet details" onClick={() => setSelectedId("")} />
<GlassCard className="plugin-inspector pet-detail-inspector">
<div className="plugin-inspector-head">
<span className="plugin-inspector-icon">
{safePetImage(selected.spritesheet) ? (
<SpriteFrame src={selected.spritesheet} label={`${selected.displayName} thumb`} size="thumb" />
) : (
<PetImage src={selected.preview} debugLabel={`${selected.id}:thumb`} />
)}
</span>
<div className="flex-1 min-w-0">
<p className="eyebrow">Pet detail</p>
<h2>{selected.displayName}</h2>
</div>
</>
)}
<Button variant="secondary" size="compact" icon={<CloseIcon />} onClick={() => setSelectedId("")}>Close</Button>
</div>
<div className="actions-container mt-6 flex flex-col gap-3">
{/* Main Action (Install, Import, Set Default) */}
{!selected.installed && selected.sourceKind === "catalog" && (
<Button
variant="primary"
fullWidth
icon={<InstallIcon />}
disabled={!!busy}
onClick={() => act("Installing", () => api.installPet(selected.id))}
>
{busy || "Install Pet"}
</Button>
)}
{!selected.installed && selected.sourceKind === "codex" && (
<Button
variant="warning"
fullWidth
icon={<ImportIcon />}
disabled={!!busy}
onClick={() => act("Importing", () => api.importCodexPet(selected.id))}
>
{busy || "Import Codex Pet"}
</Button>
)}
{selected.installed && selected.id !== defaultId && !selected.broken && (
<Button
variant="primary"
fullWidth
icon={<SetDefaultIcon />}
disabled={!!busy}
onClick={() => act("Setting default", () => api.setDefaultPet(selected.id))}
>
{busy || "Set Default Pet"}
</Button>
<p className="desc">{selected.description || selected.id}</p>
<div className="stage">
{safePetImage(selected.spritesheet) ? (
<SpriteFrame src={selected.spritesheet} label={`${selected.displayName} animated preview`} />
) : (
<PetImage src={selected.preview} debugLabel={`${selected.id}:detail-fallback`} />
)}
</div>
<div className="meta">
{selected.broken && <StatusPill tone="red">Broken</StatusPill>}
{selected.installed && !selected.broken && <StatusPill tone="green">Ready</StatusPill>}
{selected.builtIn && <StatusPill tone="orange">Originals</StatusPill>}
{selected.original && !selected.builtIn && <StatusPill tone="yellow">Original</StatusPill>}
{selected.featured && !selected.original && <StatusPill tone="purple">Featured</StatusPill>}
{selected.category === "western" && !selected.original && !selected.featured && <StatusPill tone="slate">Western</StatusPill>}
{selected.category === "asian" && !selected.original && !selected.featured && <StatusPill tone="slate">Asian</StatusPill>}
</div>
{statusText && <p className="text-sm text-slatecopy mt-3 mb-0 font-medium">{statusText}</p>}
{safePetImage(selected.spritesheet) && (
<>
<h3 className="text-xs font-bold uppercase tracking-wider text-slatecopy mt-6 mb-3">Preview Animations</h3>
<div className="grid grid-cols-3 gap-3 mb-2">
{[
{ label: "Thinking", state: "thinking" as const },
{ label: "Happy", state: "happy" as const },
{ label: "Wave", state: "wave" as const },
].map((previewState) => (
<div key={previewState.label} className="flex flex-col items-center gap-2 rounded-2xl border border-blue-100 bg-white/50 p-3 shadow-sm transition-[border-color,background-color,box-shadow] duration-150 hover:border-blue-100 hover:bg-white">
<SpriteFrame src={selected.spritesheet} label={`${selected.displayName} ${previewState.label} preview`} state={previewState.state} size="mini" />
<span className="text-xs font-bold text-slatecopy">{previewState.label}</span>
</div>
))}
</div>
</>
)}
{/* Secondary Actions (Remove, Refresh) */}
<div className={`grid gap-3 ${selected.installed && !selected.builtIn && !selected.protected ? "grid-cols-2" : "grid-cols-1"}`}>
{selected.installed && !selected.builtIn && !selected.protected && (
<div className="actions-container mt-6 flex flex-col gap-3">
{/* Main Action (Install, Import, Set Default) */}
{!selected.installed && selected.sourceKind === "catalog" && (
<Button
variant="danger"
icon={<RemoveIcon />}
variant="primary"
fullWidth
icon={<InstallIcon />}
disabled={!!busy}
onClick={() => act("Removing", () => api.removePet(selected.id))}
onClick={() => act("Installing", () => api.installPet(selected.id))}
>
Remove
{busy || "Install Pet"}
</Button>
)}
<Button
variant="secondary"
icon={<RefreshIcon />}
disabled={!!busy}
onClick={() => void loadPetsData()}
>
Refresh
</Button>
{!selected.installed && selected.sourceKind === "codex" && (
<Button
variant="warning"
fullWidth
icon={<ImportIcon />}
disabled={!!busy}
onClick={() => act("Importing", () => api.importCodexPet(selected.id))}
>
{busy || "Import Codex Pet"}
</Button>
)}
{selected.installed && selected.id !== defaultId && !selected.broken && (
<Button
variant="primary"
fullWidth
icon={<SetDefaultIcon />}
disabled={!!busy}
onClick={() => act("Setting default", () => api.setDefaultPet(selected.id))}
>
{busy || "Set Default Pet"}
</Button>
)}
<div className={`grid gap-3 ${selected.installed && !selected.builtIn && !selected.protected ? "grid-cols-2" : "grid-cols-1"}`}>
{selected.installed && !selected.builtIn && !selected.protected && (
<Button
variant="danger"
icon={<RemoveIcon />}
disabled={!!busy}
onClick={() => act("Removing", () => api.removePet(selected.id))}
>
Remove
</Button>
)}
<Button
variant="secondary"
icon={<RefreshIcon />}
disabled={!!busy}
onClick={() => void loadPetsData()}
>
Refresh
</Button>
</div>
</div>
</div></> : <p>No pets available.</p>}
</GlassCard>
</GlassCard>
</div>
) : null}
</div>
)}
</main>

View file

@ -18,7 +18,7 @@
.hero-logo-container { @apply flex shrink-0 items-center justify-end; }
.hero-brand-logo { @apply h-28 w-auto max-w-[480px] object-contain select-none; filter: drop-shadow(0 6px 12px rgba(61, 99, 160, 0.10)); }
.eyebrow { @apply mb-1.5 font-monoDisplay text-xs font-black uppercase tracking-[.18em] text-brand; }
.layout { @apply grid min-h-0 flex-1 grid-cols-[1.25fr_.75fr] gap-5 max-[900px]:grid-cols-1; }
.layout { @apply grid min-h-0 flex-1 grid-cols-1 gap-5; }
.glass { @apply rounded-[28px] border border-[rgba(126,161,210,.48)] p-5 shadow-glass backdrop-blur-xl; background: rgba(255, 255, 255, 0.76); box-shadow: inset 0 1px 0 rgba(255,255,255,.96), 0 24px 60px rgba(61,99,160,.15); }
.gallery { @apply flex min-h-0 flex-col overflow-hidden; }
.toolbar { @apply flex items-center gap-3; } .search { @apply min-w-0 flex-1 rounded-2xl border border-blue-300/80 bg-white/80 px-4 py-3 text-navy outline-none shadow-inner transition-shadow duration-150 focus:border-brand focus:bg-white focus:ring-4 focus:ring-brand/15; }
@ -37,7 +37,7 @@
.filter.active .filter-icon-wrapper { @apply opacity-100; }
.filter-icon { @apply h-3.5 w-3.5 stroke-[2.5]; }
.pager { @apply mt-3 flex items-center justify-between gap-2.5 rounded-xl border border-blue-200/40 bg-blue-50/20 p-1.5 text-xs font-bold text-slatecopy; }
.pets-grid { @apply grid min-h-0 flex-1 grid-cols-2 gap-3 overflow-y-auto p-1.5 pr-2.5 pb-3 max-[680px]:grid-cols-1; overscroll-behavior: contain; scrollbar-color: rgba(96, 165, 250, 0.42) transparent; }
.pets-grid { @apply grid min-h-0 flex-1 grid-cols-1 gap-3 overflow-y-auto p-1.5 pr-2.5 pb-3; overscroll-behavior: contain; scrollbar-color: rgba(96, 165, 250, 0.42) transparent; }
.pet-card { @apply flex flex-row items-center gap-3.5 rounded-2xl border border-slate-200 bg-white/80 p-3 text-left shadow-sm transition-[transform,border-color,background-color,box-shadow] hover:-translate-y-0.5 hover:border-brand hover:bg-white active:scale-[0.96] min-h-[92px] w-full cursor-pointer; }
.pet-card.selected { @apply border-brand bg-blue-50/90 ring-4 ring-blue-200/50 shadow-md; }
.thumb { @apply grid h-14 w-14 shrink-0 place-items-center overflow-hidden rounded-2xl bg-gradient-to-br from-white to-blue-100 border border-blue-100/50; }
@ -168,6 +168,7 @@
.plugin-config-overlay { @apply fixed inset-0 z-30 flex items-center justify-center p-6; animation: plugin-fade-in 0.2s ease-out; }
.plugin-config-backdrop { @apply absolute inset-0 cursor-default border-0 bg-navy/[0.12] p-0 backdrop-blur-[4px]; }
.plugin-inspector { @apply relative z-10 flex max-h-[calc(100vh-4rem)] w-[min(760px,calc(100vw-3rem))] flex-col gap-4 overflow-y-auto pr-3 shadow-[0_32px_80px_rgba(16,33,73,0.25)]; overscroll-behavior: contain; scrollbar-gutter: stable; animation: plugin-pop-in 0.25s cubic-bezier(0.16, 1, 0.3, 1); }
.pet-detail-inspector { @apply w-[min(900px,calc(100vw-3rem))]; }
@keyframes plugin-fade-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes plugin-pop-in { from { opacity: 0; transform: scale(0.96) translateY(10px); } to { opacity: 1; transform: scale(1) translateY(0); } }
.plugin-inspector-head { @apply flex items-center gap-4; }