Merge pull request #17 from Yiling-J/feature-look-equipment-dialogs-7528901223240659226

feat: add clickable details dialogs for looks and equipment
This commit is contained in:
Yiling-J authored and GitHub committed 2026-02-13 16:01:47 +08:00
commit 01cb28202d
9 files changed
+375 -3

No files matched your search

+18
View File
@@ -3,6 +3,7 @@ import equipment from './equipment'
const mockEquipmentService = {
listEquipments: mock(),
updateEquipment: mock(),
}
mock.module('../service/equipment', () => ({
@@ -12,6 +13,7 @@ mock.module('../service/equipment', () => ({
describe('Equipment API', () => {
afterEach(() => {
mockEquipmentService.listEquipments.mockClear()
mockEquipmentService.updateEquipment.mockClear()
})
it('GET / should list equipments with filters', async () => {
@@ -28,4 +30,20 @@ describe('Equipment API', () => {
{ category: ['Headwear'], subCategory: undefined },
)
})
it('PATCH /:id should update equipment', async () => {
mockEquipmentService.updateEquipment.mockResolvedValue({ id: '1', name: 'Updated' })
const req = new Request('http://localhost/1', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Updated' }),
})
const res = await equipment.fetch(req)
expect(res.status).toBe(200)
expect(mockEquipmentService.updateEquipment).toHaveBeenCalledWith('1', {
name: 'Updated',
})
})
})
+19 -1
View File
@@ -24,4 +24,22 @@ const route = app.get('/', zValidator('query', listSchema), async (c) => {
return c.json(result)
})
export default route
const patchSchema = z.object({
name: z.string().optional(),
description: z.string().optional(),
})
const routeWithPatch = route.patch('/:id', zValidator('json', patchSchema), async (c) => {
const { id } = c.req.param()
const { name, description } = c.req.valid('json')
try {
const result = await equipmentService.updateEquipment(id, { name, description })
return c.json(result)
} catch (e) {
console.error('Failed to update equipment', e)
return c.json({ error: 'Failed to update equipment' }, 500)
}
})
export default routeWithPatch
+24
View File
@@ -4,6 +4,7 @@ const mockPrisma = {
equipment: {
findMany: mock(),
count: mock(),
update: mock(),
},
}
@@ -15,6 +16,7 @@ describe('EquipmentService', () => {
afterEach(() => {
mockPrisma.equipment.findMany.mockClear()
mockPrisma.equipment.count.mockClear()
mockPrisma.equipment.update.mockClear()
})
it('listEquipments should filter by category', async () => {
@@ -33,4 +35,26 @@ describe('EquipmentService', () => {
}),
)
})
it('updateEquipment should update equipment details', async () => {
// @ts-ignore
const { equipmentService } = await import(`./equipment?v=${Date.now()}`)
mockPrisma.equipment.update.mockResolvedValue({
id: '1',
name: 'New Name',
description: 'New Description',
})
await equipmentService.updateEquipment('1', { name: 'New Name', description: 'New Description' })
expect(mockPrisma.equipment.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: '1' },
data: expect.objectContaining({
name: 'New Name',
description: 'New Description',
}),
}),
)
})
})
+22
View File
@@ -65,6 +65,28 @@ export class EquipmentService {
},
})
}
/**
* Updates an existing equipment item.
*/
async updateEquipment(
id: string,
data: {
name?: string
description?: string
},
) {
return prisma.equipment.update({
where: { id },
data: {
name: data.name,
description: data.description,
},
include: {
image: true,
},
})
}
}
export const equipmentService = new EquipmentService()
+10 -2
View File
@@ -139,7 +139,11 @@ category: ${eq.category}
character: true,
equipments: {
include: {
equipment: true,
equipment: {
include: {
image: true,
},
},
},
},
},
@@ -177,7 +181,11 @@ category: ${eq.category}
character: true,
equipments: {
include: {
equipment: true,
equipment: {
include: {
image: true,
},
},
},
},
},
@@ -0,0 +1,136 @@
import React, { useEffect, useState } from 'react'
import { InferResponseType } from 'hono/client'
import { client } from '../client'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from './ui/dialog'
import { Button } from './ui/button'
import { Input } from './ui/input'
import { Textarea } from './ui/textarea'
import { Label } from './ui/label'
import { toast } from 'sonner'
import { Loader2 } from 'lucide-react'
type EquipmentResponse = InferResponseType<typeof client.equipments.$get>
type EquipmentItem = EquipmentResponse['items'][number]
interface EquipmentDetailsDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
equipment: EquipmentItem | null
onUpdate: () => void
}
export const EquipmentDetailsDialog: React.FC<EquipmentDetailsDialogProps> = ({
open,
onOpenChange,
equipment,
onUpdate,
}) => {
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [saving, setSaving] = useState(false)
useEffect(() => {
if (equipment) {
setName(equipment.name)
setDescription(equipment.description || '')
}
}, [equipment])
const handleSave = async () => {
if (!equipment) return
setSaving(true)
try {
const res = await client.equipments[':id'].$patch({
param: { id: equipment.id },
json: { name, description },
})
if (res.ok) {
toast.success('Equipment updated')
onUpdate()
onOpenChange(false)
} else {
toast.error('Failed to update equipment')
}
} catch (e) {
console.error(e)
toast.error('An error occurred')
} finally {
setSaving(false)
}
}
if (!equipment) return null
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl h-[85vh] flex flex-col p-0 gap-0 bg-stone-50 overflow-hidden">
<DialogHeader className="px-6 py-4 border-b border-stone-200 bg-white shrink-0">
<DialogTitle className="text-xl font-black uppercase tracking-tighter text-stone-800">
Equipment Details
</DialogTitle>
<DialogDescription>
{equipment.category} {equipment.subCategory && `> ${equipment.subCategory}`}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-hidden flex flex-col">
{/* Top: Full Image */}
<div className="h-[60%] bg-stone-900 flex items-center justify-center p-4 overflow-hidden relative shrink-0">
<div
className="absolute inset-0 opacity-20 pointer-events-none"
style={{
backgroundImage: 'radial-gradient(#fff 1px, transparent 1px)',
backgroundSize: '24px 24px',
}}
></div>
<img
src={equipment.image?.path ? `/files/${equipment.image.path}` : ''}
alt={equipment.name}
className="max-w-full max-h-full object-contain drop-shadow-2xl"
/>
</div>
{/* Bottom: Edit Form */}
<div className="flex-1 bg-white border-t border-stone-200 flex flex-col overflow-y-auto shrink-0 p-6 gap-6">
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Equipment Name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Describe the equipment..."
rows={8}
className="resize-none"
/>
</div>
</div>
<div className="mt-auto pt-6 border-t border-stone-100">
<Button onClick={handleSave} disabled={saving} className="w-full">
{saving && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
Save Changes
</Button>
</div>
</div>
</div>
</DialogContent>
</Dialog>
)
}
+119
View File
@@ -0,0 +1,119 @@
import React from 'react'
import { InferResponseType } from 'hono/client'
import { client } from '../client'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from './ui/dialog'
import { ScrollArea } from './ui/scroll-area'
type GenerationResponse = InferResponseType<typeof client.generations.$get>
type GenerationItem = GenerationResponse['items'][number]
interface LookDetailsDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
generation: GenerationItem | null
}
export const LookDetailsDialog: React.FC<LookDetailsDialogProps> = ({
open,
onOpenChange,
generation,
}) => {
if (!generation) return null
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl h-[85vh] flex flex-col p-0 gap-0 bg-stone-50 overflow-hidden">
<DialogHeader className="px-6 py-4 border-b border-stone-200 bg-white shrink-0">
<DialogTitle className="text-xl font-black uppercase tracking-tighter text-stone-800">
Look Details
</DialogTitle>
<DialogDescription>
Generated on {new Date(generation.createdAt).toLocaleString()}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-hidden flex flex-col">
{/* Top: Full Image */}
<div className="h-[60%] bg-stone-900 flex items-center justify-center p-4 overflow-hidden relative shrink-0">
<div
className="absolute inset-0 opacity-20 pointer-events-none"
style={{
backgroundImage: 'radial-gradient(#fff 1px, transparent 1px)',
backgroundSize: '24px 24px',
}}
></div>
<img
src={generation.image?.path ? `/files/${generation.image.path}` : ''}
alt="Generated Look"
className="max-w-full max-h-full object-contain drop-shadow-2xl"
/>
</div>
{/* Bottom: Details */}
<div className="flex-1 bg-white border-t border-stone-200 flex flex-col overflow-hidden shrink-0">
<ScrollArea className="flex-1">
<div className="p-6 flex flex-col gap-6">
{/* Equipments Used */}
<div>
<h4 className="text-xs font-bold text-stone-500 uppercase tracking-wider mb-3">
Equipments Used
</h4>
<div className="flex flex-col gap-3">
{/* @ts-ignore - type inference might be tricky with nested includes */}
{generation.equipments?.map((relation) => {
const eq = relation.equipment
return (
<div
key={eq.id}
className="flex items-center gap-3 p-2 rounded-lg border border-stone-100 bg-stone-50 hover:border-stone-200 transition-colors"
>
<div className="w-10 h-10 rounded bg-white border border-stone-200 shrink-0 overflow-hidden flex items-center justify-center">
{eq.image ? (
<img
src={`/files/${eq.image.path}`}
alt={eq.name}
className="w-full h-full object-contain p-1"
/>
) : (
<div className="w-full h-full bg-stone-100" />
)}
</div>
<div className="min-w-0">
<p className="text-sm font-bold text-stone-800 truncate">{eq.name}</p>
<p className="text-[10px] text-stone-500 uppercase">{eq.category}</p>
</div>
</div>
)
})}
{/* @ts-ignore */}
{(!generation.equipments || generation.equipments.length === 0) && (
<p className="text-sm text-stone-400 italic">No equipment recorded.</p>
)}
</div>
</div>
{/* Prompt */}
{generation.userPrompt && (
<div>
<h4 className="text-xs font-bold text-stone-500 uppercase tracking-wider mb-2">
Prompt
</h4>
<div className="p-3 bg-stone-50 rounded-lg border border-stone-100 text-sm text-stone-600 italic">
"{generation.userPrompt}"
</div>
</div>
)}
</div>
</ScrollArea>
</div>
</div>
</DialogContent>
</Dialog>
)
}
+14
View File
@@ -5,6 +5,7 @@ import { toast } from 'sonner'
import { EQUIPMENT_CATEGORIES } from '../../lib/categories'
import { client } from '../client'
import { CreateOutfitDialog } from '../components/CreateOutfitDialog'
import { EquipmentDetailsDialog } from '../components/EquipmentDetailsDialog'
import { ExtractorDialog } from '../components/ExtractorDialog'
import { PageHeader } from '../components/PageHeader'
import { VibeCard } from '../components/VibeCard'
@@ -39,6 +40,8 @@ export default function Equipments() {
const [extractorOpen, setExtractorOpen] = useState(false)
const [createOutfitOpen, setCreateOutfitOpen] = useState(false)
const [viewMode, setViewMode] = useState<'equipments' | 'outfits'>('equipments')
const [selectedEquipment, setSelectedEquipment] = useState<EquipmentItem | null>(null)
const [equipmentDetailsOpen, setEquipmentDetailsOpen] = useState(false)
useEffect(() => {
if (viewMode === 'equipments') {
@@ -196,6 +199,10 @@ export default function Equipments() {
subtitle={item.category}
image={item.image?.path ? `/files/${item.image.path}` : ''}
color={getEquipmentColor(item.name)}
onClick={() => {
setSelectedEquipment(item)
setEquipmentDetailsOpen(true)
}}
/>
))}
</div>
@@ -265,6 +272,13 @@ export default function Equipments() {
fetchOutfits()
}}
/>
<EquipmentDetailsDialog
open={equipmentDetailsOpen}
onOpenChange={setEquipmentDetailsOpen}
equipment={selectedEquipment}
onUpdate={fetchItems}
/>
<style>{`@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }`}</style>
</div>
)
+13
View File
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { client } from '../client'
import { CreateLookDialog } from '../components/CreateLookDialog'
import { LookDetailsDialog } from '../components/LookDetailsDialog'
import { Button } from '../components/ui/button'
import { Card } from '../components/ui/card'
@@ -21,6 +22,8 @@ export default function FittingRoom() {
const [generations, setGenerations] = useState<GenerationItem[]>([])
const [loading, setLoading] = useState(true)
const [createLookOpen, setCreateLookOpen] = useState(false)
const [selectedLook, setSelectedLook] = useState<GenerationItem | null>(null)
const [lookDetailsOpen, setLookDetailsOpen] = useState(false)
useEffect(() => {
if (id) {
@@ -156,6 +159,10 @@ export default function FittingRoom() {
<div
key={gen.id}
className="group relative aspect-[3/4] bg-stone-100 rounded-xl overflow-hidden border border-stone-200 hover:border-stone-900 hover:shadow-xl transition-all cursor-pointer"
onClick={() => {
setSelectedLook(gen)
setLookDetailsOpen(true)
}}
>
<img
src={gen.image?.path ? `/files/${gen.image.path}` : ''}
@@ -198,6 +205,12 @@ export default function FittingRoom() {
characterId={id || ''}
onSuccess={() => id && fetchData(id)}
/>
<LookDetailsDialog
open={lookDetailsOpen}
onOpenChange={setLookDetailsOpen}
generation={selectedLook}
/>
</div>
)
}