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:
9 files changed
+375
-3
No files matched your search
@@ -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
@@ -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
|
||||
@@ -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',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user