feat: add character creation and fitting room ui

- Implemented `Character Creation` flow with dialog and image upload.
- Implemented `Fitting Room` page with split view (Character Preview / Generations).
- Implemented `Create Outfit` dialog with equipment filtering and selection.
- Added `POST /api/generations` to handle outfit generation.
- Updated `AIService` to support complex prompt structure with interleaved text and images.
- Updated `CharacterService` to support fetching single character.
- Added frontend routes and components.
- Added relevant tests.
This commit is contained in:
google-labs-jules[bot] committed 2026-02-09 00:10:59 +00:00
1 parent fba32312ce
commit cc024a0aea
14 files changed
+896 -12

No files matched your search

+14
View File
@@ -6,6 +6,7 @@ const mockCharacterService = {
listCharacters: mock(),
updateCharacter: mock(),
deleteCharacter: mock(),
getCharacter: mock(),
}
mock.module('../service/character', () => ({
@@ -45,4 +46,17 @@ describe('Character API', () => {
expect(res.status).toBe(201)
expect(await res.json()).toEqual({ id: '1', name: 'Char' })
})
it('GET /:id should get character', async () => {
mockCharacterService.getCharacter.mockResolvedValue({ id: '1', name: 'Char' })
const req = new Request('http://localhost/1', {
method: 'GET',
})
const res = await character.fetch(req)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ id: '1', name: 'Char' })
expect(mockCharacterService.getCharacter).toHaveBeenCalledWith('1')
})
})
+9
View File
@@ -33,6 +33,15 @@ const route = app
const result = await characterService.createCharacter({ name, description, imageId })
return c.json(result, 201)
})
.get('/:id', async (c) => {
const id = c.req.param('id')
const character = await characterService.getCharacter(id)
if (!character) {
// @ts-ignore
return c.json({ error: 'Character not found' }, 404)
}
return c.json(character)
})
.put('/:id', zValidator('json', updateSchema), async (c) => {
const id = c.req.param('id')
const body = c.req.valid('json')
+16
View File
@@ -3,6 +3,7 @@ import generation from './generation'
const mockGenerationService = {
listGenerations: mock(),
createGeneration: mock(),
}
mock.module('../service/generation', () => ({
@@ -28,4 +29,19 @@ describe('Generation API', () => {
{ characterId: undefined, equipmentId: 'eq1' },
)
})
it('POST / should create generation', async () => {
mockGenerationService.createGeneration.mockResolvedValue({ id: '1' })
const req = new Request('http://localhost/', {
method: 'POST',
body: JSON.stringify({ characterId: 'char1', equipmentIds: ['eq1', 'eq2'] }),
headers: { 'Content-Type': 'application/json' },
})
const res = await generation.fetch(req)
expect(res.status).toBe(201)
expect(await res.json()).toEqual({ id: '1' })
expect(mockGenerationService.createGeneration).toHaveBeenCalledWith('char1', ['eq1', 'eq2'])
})
})
+24 -8
View File
@@ -12,14 +12,30 @@ const listSchema = z.object({
equipmentId: z.string().optional(),
})
const route = app.get('/', zValidator('query', listSchema), async (c) => {
const { page, limit, characterId, equipmentId } = c.req.valid('query')
const result = await generationService.listGenerations(
{ page, limit },
{ characterId, equipmentId },
)
return c.json(result)
const createSchema = z.object({
characterId: z.string().min(1),
equipmentIds: z.array(z.string()),
})
const route = app
.get('/', zValidator('query', listSchema), async (c) => {
const { page, limit, characterId, equipmentId } = c.req.valid('query')
const result = await generationService.listGenerations(
{ page, limit },
{ characterId, equipmentId },
)
return c.json(result)
})
.post('/', zValidator('json', createSchema), async (c) => {
const { characterId, equipmentIds } = c.req.valid('json')
try {
const result = await generationService.createGeneration(characterId, equipmentIds)
return c.json(result, 201)
} catch (e) {
console.error('Failed to create generation', e)
return c.json({ error: e instanceof Error ? e.message : 'Unknown error' }, 500)
}
})
export default route
+9
View File
@@ -5,6 +5,9 @@ import { z } from 'zod'
export interface AIService {
generateText<T>(prompt: string, images: File[], schema?: z.ZodType<T>): Promise<T>
generateImage(prompt: string, referenceImages?: File[]): Promise<string>
generateImageFromParts(
parts: Array<{ text: string } | { inlineData: { mimeType: string; data: string } }>,
): Promise<string>
}
export class GeminiService implements AIService {
@@ -87,6 +90,12 @@ export class GeminiService implements AIService {
parts.push({ text: prompt })
return this.generateImageFromParts(parts)
}
async generateImageFromParts(
parts: Array<{ text: string } | { inlineData: { mimeType: string; data: string } }>,
): Promise<string> {
const result = await this.genAI.models.generateContent({
model: 'gemini-2.5-flash-image',
contents: { parts },
+20
View File
@@ -25,6 +25,26 @@ export class AssetService {
return asset
}
async createAssetFromBuffer(buffer: Buffer | ArrayBuffer, meta: { name: string; type: string; ext?: string }) {
const id = ulid()
const ext = meta.ext || '.bin'
const filename = `${id}${ext}`
const path = join('data/files', filename)
await Bun.write(path, buffer)
const asset = await prisma.asset.create({
data: {
id,
name: meta.name,
type: meta.type,
path: filename,
},
})
return asset
}
// New method to handle already saved files
async createAssetRecord(params: { path: string; name: string; type: string }) {
const id = ulid()
+9
View File
@@ -34,6 +34,15 @@ export class CharacterService {
return { total, items, page, limit, totalPages: Math.ceil(total / limit) }
}
async getCharacter(id: string) {
return prisma.character.findUnique({
where: { id },
include: {
image: true,
},
})
}
async updateCharacter(
id: string,
data: { name?: string; description?: string; imageId?: string },
+131
View File
@@ -1,7 +1,138 @@
import { join } from 'path'
import { ulid } from 'ulidx'
import { prisma } from '../db'
import { Prisma } from '../generated/prisma/client'
import { aiService } from './ai'
import { assetService } from './asset'
export class GenerationService {
async createGeneration(characterId: string, equipmentIds: string[]) {
// 1. Fetch character and its image
const character = await prisma.character.findUnique({
where: { id: characterId },
include: { image: true },
})
if (!character) {
throw new Error('Character not found')
}
// 2. Fetch equipments and their images
const equipments = await prisma.equipment.findMany({
where: { id: { in: equipmentIds } },
include: { image: true },
})
// 3. Construct parts
const parts: Array<{ text: string } | { inlineData: { mimeType: string; data: string } }> = []
// Base Character
const charPath = join('data/files', character.image.path)
const charFile = Bun.file(charPath)
const charBuffer = await charFile.arrayBuffer()
const charBase64 = Buffer.from(charBuffer).toString('base64')
parts.push({
inlineData: {
mimeType: character.image.type,
data: charBase64,
},
})
parts.push({ text: 'Base Character Reference' })
// Equipments
for (const eq of equipments) {
const eqPath = join('data/files', eq.image.path)
const eqFile = Bun.file(eqPath)
const eqBuffer = await eqFile.arrayBuffer()
const eqBase64 = Buffer.from(eqBuffer).toString('base64')
parts.push({
inlineData: {
mimeType: eq.image.type,
data: eqBase64,
},
})
parts.push({
text: `
Equipment to equip:
name: ${eq.name}
description: ${eq.description}
category: ${eq.category}
`,
})
}
const prompt = `
Task: Character Synthesis and Equipment Modification.
1. Reference the 'Base Character' for pose, body type, facial features, and general art style.
2. Reference the 'Equipment to equip' images.
3. Generate a new image of the Base Character wearing the provided items.
4. The items should replace existing clothing/armor in that slot (e.g. new helmet replaces old hat, armor replaces shirt).
5. Seamlessly blend the items into the character's style.
6. Maintain the exact pose and angle of the Base Character.
7. High quality, detailed, game art style.
`
parts.push({ text: prompt })
// 4. Generate Image
const generatedImageBase64 = await aiService.generateImageFromParts(parts)
// 5. Save generated image as Asset
const matches = generatedImageBase64.match(/^data:(.+);base64,(.+)$/)
if (!matches || matches.length !== 3) {
throw new Error('Invalid generated image format')
}
const mimeType = matches[1]
const data = matches[2]
const buffer = Buffer.from(data, 'base64')
const ext = '.' + (mimeType.split('/')[1] || 'png')
const asset = await assetService.createAssetFromBuffer(buffer, {
name: `${character.name} - Outfit Generation`,
type: mimeType,
ext: ext,
})
// 6. Create Generation record
const generationId = ulid()
await prisma.generation.create({
data: {
id: generationId,
characterId: character.id,
imageId: asset.id,
},
})
// Create relation records
if (equipmentIds.length > 0) {
await prisma.generationEquipment.createMany({
data: equipmentIds.map((eqId) => ({
generationId: generationId,
equipmentId: eqId,
})),
})
}
// Return generation with included relations
return prisma.generation.findUnique({
where: { id: generationId },
include: {
image: true,
character: true,
equipments: {
include: {
equipment: true,
},
},
},
})
}
async listGenerations(
pagination: { page: number; limit: number },
filters?: { characterId?: string; equipmentId?: string },
+2
View File
@@ -3,6 +3,7 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import Layout from './Layout'
import Characters from './pages/Characters'
import Equipments from './pages/Equipments'
import FittingRoom from './pages/FittingRoom'
export default function App() {
return (
@@ -11,6 +12,7 @@ export default function App() {
<Route path="/" element={<Layout />}>
<Route index element={<Navigate to="/characters" replace />} />
<Route path="characters" element={<Characters />} />
<Route path="characters/:id/outfits" element={<FittingRoom />} />
<Route path="equipments" element={<Equipments />} />
</Route>
</Routes>
+176
View File
@@ -0,0 +1,176 @@
import { Loader2, Upload } from 'lucide-react'
import React, { useState } from 'react'
import { client } from '../client'
import { Button } from './ui/button'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from './ui/dialog'
import { Input } from './ui/input'
import { Label } from './ui/label'
import { Textarea } from './ui/textarea'
interface CreateCharacterDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onSuccess: () => void
}
export const CreateCharacterDialog: React.FC<CreateCharacterDialogProps> = ({
open,
onOpenChange,
onSuccess,
}) => {
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [file, setFile] = useState<File | null>(null)
const [preview, setPreview] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
const f = e.target.files[0]
setFile(f)
setPreview(URL.createObjectURL(f))
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!file || !name) return
setLoading(true)
setError(null)
try {
// 1. Upload Image
const uploadRes = await client.assets.upload.$post({
form: {
file: file,
name: file.name,
},
})
if (!uploadRes.ok) {
throw new Error('Failed to upload image')
}
const asset = await uploadRes.json()
// 2. Create Character
const createRes = await client.characters.$post({
json: {
name,
description,
imageId: asset.id,
},
})
if (!createRes.ok) {
throw new Error('Failed to create character')
}
onSuccess()
handleReset()
onOpenChange(false)
} catch (err) {
console.error(err)
setError(err instanceof Error ? err.message : 'Something went wrong')
} finally {
setLoading(false)
}
}
const handleReset = () => {
setName('')
setDescription('')
setFile(null)
if (preview) URL.revokeObjectURL(preview)
setPreview(null)
setError(null)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Create Character</DialogTitle>
<DialogDescription>
Add a new character to your collection.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="image">Character Image</Label>
<div className="flex items-center justify-center w-full">
{!preview ? (
<label
htmlFor="dropzone-file"
className="flex flex-col items-center justify-center w-full h-32 border-2 border-dashed rounded-lg cursor-pointer bg-stone-50 hover:bg-stone-100 border-stone-300 hover:border-stone-400"
>
<div className="flex flex-col items-center justify-center pt-5 pb-6">
<Upload className="w-8 h-8 mb-2 text-stone-400" />
<p className="text-sm text-stone-500">
<span className="font-semibold">Click to upload</span>
</p>
</div>
<input
id="dropzone-file"
type="file"
className="hidden"
accept="image/*"
onChange={handleFileChange}
required
/>
</label>
) : (
<div className="relative w-full h-32 bg-stone-100 rounded-lg overflow-hidden group">
<img src={preview} alt="Preview" className="w-full h-full object-contain" />
<button
type="button"
onClick={() => {
setFile(null)
URL.revokeObjectURL(preview)
setPreview(null)
}}
className="absolute inset-0 bg-black/40 text-white opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center font-medium"
>
Change Image
</button>
</div>
)}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Character Name"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Optional description"
/>
</div>
{error && <p className="text-red-500 text-sm">{error}</p>}
<div className="flex justify-end pt-4">
<Button type="submit" disabled={loading || !file || !name}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create Character
</Button>
</div>
</form>
</DialogContent>
</Dialog>
)
}
+255
View File
@@ -0,0 +1,255 @@
import React, { useState, useEffect } from 'react'
import { client } from '../client'
import { InferResponseType } from 'hono/client'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from './ui/dialog'
import { Button } from './ui/button'
import { Badge } from './ui/badge'
import { ScrollArea, ScrollBar } from './ui/scroll-area'
import { EQUIPMENT_CATEGORIES } from '../../lib/categories'
import { cn } from '../lib/utils'
import { Loader2, X, Check } from 'lucide-react'
type EquipmentResponse = InferResponseType<typeof client.equipments.$get>
type EquipmentItem = EquipmentResponse['items'][number]
interface CreateOutfitDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
characterId: string
onSuccess: () => void
}
export const CreateOutfitDialog: React.FC<CreateOutfitDialogProps> = ({
open,
onOpenChange,
characterId,
onSuccess,
}) => {
const [items, setItems] = useState<EquipmentItem[]>([])
const [loading, setLoading] = useState(true)
const [selectedCategories, setSelectedCategories] = useState<string[]>([])
const [selectedEquipments, setSelectedEquipments] = useState<EquipmentItem[]>([])
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (open) {
fetchItems()
}
}, [open, selectedCategories])
const fetchItems = async () => {
setLoading(true)
try {
const query: any = {
limit: '100',
}
if (selectedCategories.length > 0) {
query.category = selectedCategories
}
const res = await client.equipments.$get({ query })
if (res.ok) {
const data = await res.json()
setItems(data.items)
}
} catch (e) {
console.error('Failed to load equipment', e)
} finally {
setLoading(false)
}
}
const toggleCategory = (category: string) => {
setSelectedCategories((prev) =>
prev.includes(category)
? prev.filter((c) => c !== category)
: [...prev, category]
)
}
const toggleEquipment = (item: EquipmentItem) => {
setSelectedEquipments((prev) => {
const exists = prev.find((e) => e.id === item.id)
if (exists) {
return prev.filter((e) => e.id !== item.id)
}
return [...prev, item]
})
}
const handleCreate = async () => {
if (selectedEquipments.length === 0) return
setSubmitting(true)
setError(null)
try {
const res = await client.generations.$post({
json: {
characterId,
equipmentIds: selectedEquipments.map((e) => e.id),
},
})
if (!res.ok) {
const errData = await res.json() as { error?: string }
throw new Error(errData.error || 'Failed to create outfit')
}
onSuccess()
onOpenChange(false)
setSelectedEquipments([])
} catch (e) {
console.error(e)
setError(e instanceof Error ? e.message : 'Something went wrong')
} finally {
setSubmitting(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-5xl 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">Select Equipment</DialogTitle>
<DialogDescription>
Choose items to equip on your character.
</DialogDescription>
</DialogHeader>
{/* Categories */}
<div className="px-6 py-3 border-b border-stone-200 bg-white shrink-0">
<ScrollArea className="w-full whitespace-nowrap">
<div className="flex w-max space-x-2 pb-2">
{EQUIPMENT_CATEGORIES.map((cat) => {
const isSelected = selectedCategories.includes(cat.main_category)
return (
<Badge
key={cat.main_category}
variant={isSelected ? 'default' : 'outline'}
className={cn(
"cursor-pointer px-3 py-1.5 text-xs transition-all select-none hover:bg-stone-100",
isSelected
? "bg-stone-900 text-white border-stone-900 hover:bg-stone-800"
: "bg-white text-stone-600 border-stone-200"
)}
onClick={() => toggleCategory(cat.main_category)}
>
{cat.main_category}
</Badge>
)
})}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</div>
{/* Main Content Area */}
<div className="flex-1 overflow-y-auto p-6 bg-stone-50/50">
{loading ? (
<div className="flex justify-center items-center h-full">
<Loader2 className="w-8 h-8 animate-spin text-stone-400" />
</div>
) : items.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-stone-400">
<p>No equipment found.</p>
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{items.map((item) => {
const isSelected = selectedEquipments.some(e => e.id === item.id)
return (
<div
key={item.id}
className={cn(
"group relative bg-white rounded-xl border transition-all cursor-pointer overflow-hidden",
isSelected
? "border-amber-500 ring-2 ring-amber-500/20 shadow-lg"
: "border-stone-200 hover:border-stone-400 hover:shadow-md"
)}
onClick={() => toggleEquipment(item)}
>
<div className="aspect-square p-4 flex items-center justify-center relative">
<img
src={item.image?.path ? `/files/${item.image.path}` : ''}
className="max-w-full max-h-full object-contain"
alt={item.name}
/>
{isSelected && (
<div className="absolute top-2 right-2 bg-amber-500 text-white rounded-full p-1 shadow-sm">
<Check className="w-3 h-3" />
</div>
)}
</div>
<div className="p-2 border-t border-stone-100 bg-white">
<p className="text-xs font-bold truncate text-stone-800">{item.name}</p>
<p className="text-[10px] text-stone-400 uppercase truncate">{item.category}</p>
</div>
</div>
)
})}
</div>
)}
</div>
{/* Bottom Dock - Selected Items */}
<div className="border-t border-stone-200 bg-white shrink-0 shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.05)] z-10">
<div className="px-6 py-4">
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-bold text-stone-800 uppercase tracking-wide flex items-center gap-2">
Selected Items <span className="bg-amber-100 text-amber-700 px-2 py-0.5 rounded-full text-xs">{selectedEquipments.length}</span>
</h4>
{error && <span className="text-red-500 text-xs font-medium">{error}</span>}
</div>
<div className="flex gap-4">
<ScrollArea className="flex-1 whitespace-nowrap">
<div className="flex gap-3 pb-2 min-h-[100px] items-center">
{selectedEquipments.length === 0 ? (
<div className="text-stone-400 text-sm italic w-full text-center py-8 border-2 border-dashed border-stone-100 rounded-xl">
Select items from the list above to equip them.
</div>
) : (
selectedEquipments.map((item) => (
<div key={item.id} className="relative w-[100px] h-[100px] shrink-0 bg-stone-50 rounded-lg border border-stone-200 flex items-center justify-center group">
<img
src={item.image?.path ? `/files/${item.image.path}` : ''}
className="max-w-full max-h-full object-contain p-2"
alt={item.name}
/>
<button
onClick={(e) => {
e.stopPropagation()
toggleEquipment(item)
}}
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full p-1 shadow-md opacity-0 group-hover:opacity-100 transition-opacity hover:bg-red-600"
>
<X className="w-3 h-3" />
</button>
<div className="absolute bottom-0 left-0 right-0 bg-white/90 backdrop-blur-sm p-1 text-[10px] truncate text-center font-medium border-t border-stone-100">
{item.name}
</div>
</div>
))
)}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
<div className="flex items-center pl-4 border-l border-stone-100">
<Button
size="lg"
className="bg-gradient-to-r from-amber-500 to-orange-600 hover:from-amber-600 hover:to-orange-700 text-white shadow-lg border-0 h-full max-h-[100px] min-w-[120px]"
disabled={submitting || selectedEquipments.length === 0}
onClick={handleCreate}
>
{submitting ? <Loader2 className="w-5 h-5 animate-spin" /> : 'Generate'}
</Button>
</div>
</div>
</div>
</div>
</DialogContent>
</Dialog>
)
}
+24
View File
@@ -0,0 +1,24 @@
import * as React from "react"
import { cn } from "@/ui/lib/utils"
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Textarea.displayName = "Textarea"
export { Textarea }
+25 -4
View File
@@ -3,6 +3,10 @@ import { client } from '../client'
import { InferResponseType } from 'hono/client'
import { Avatar, AvatarFallback, AvatarImage } from '../components/ui/avatar'
import { Card, CardContent } from '../components/ui/card'
import { Button } from '../components/ui/button'
import { Plus } from 'lucide-react'
import { CreateCharacterDialog } from '../components/CreateCharacterDialog'
import { useNavigate } from 'react-router-dom'
type CharacterResponse = InferResponseType<typeof client.characters.$get>
type Character = CharacterResponse['items'][number]
@@ -10,6 +14,8 @@ type Character = CharacterResponse['items'][number]
export default function Characters() {
const [characters, setCharacters] = useState<Character[]>([])
const [loading, setLoading] = useState(true)
const [createOpen, setCreateOpen] = useState(false)
const navigate = useNavigate()
useEffect(() => {
fetchCharacters()
@@ -39,9 +45,14 @@ export default function Characters() {
return (
<div className="p-8 animate-fade-in-up">
<h1 className="text-3xl font-black text-stone-800 uppercase tracking-tighter mb-8">
Characters
</h1>
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-black text-stone-800 uppercase tracking-tighter">
Characters
</h1>
<Button onClick={() => setCreateOpen(true)}>
<Plus className="mr-2 h-4 w-4" /> Create Character
</Button>
</div>
{characters.length === 0 ? (
<div className="text-center p-12 bg-white rounded-xl border border-stone-200">
@@ -50,7 +61,11 @@ export default function Characters() {
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-6">
{characters.map((char) => (
<Card key={char.id} className="border-stone-200 hover:shadow-lg transition-all hover:-translate-y-1">
<Card
key={char.id}
className="border-stone-200 hover:shadow-lg transition-all hover:-translate-y-1 cursor-pointer"
onClick={() => navigate(`/characters/${char.id}/outfits`)}
>
<CardContent className="flex flex-col items-center p-6 gap-4">
<Avatar className="w-32 h-32 border-4 border-stone-100 shadow-inner">
<AvatarImage
@@ -73,6 +88,12 @@ export default function Characters() {
))}
</div>
)}
<CreateCharacterDialog
open={createOpen}
onOpenChange={setCreateOpen}
onSuccess={fetchCharacters}
/>
</div>
)
}
+182
View File
@@ -0,0 +1,182 @@
import React, { useState, useEffect } from 'react'
import { useParams } from 'react-router-dom'
import { client } from '../client'
import { InferResponseType } from 'hono/client'
import { Button } from '../components/ui/button'
import { Card } from '../components/ui/card'
import { Plus, ArrowLeft } from 'lucide-react'
import { CreateOutfitDialog } from '../components/CreateOutfitDialog'
import { useNavigate } from 'react-router-dom'
// Types - Define locally to avoid complex type extraction if client inference is tricky
// But we should try to use inference
type CharacterResponse = InferResponseType<typeof client.characters[':id']['$get']>
type GenerationResponse = InferResponseType<typeof client.generations.$get>
type GenerationItem = GenerationResponse['items'][number]
export default function FittingRoom() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
// @ts-ignore - Response type might be union with error
const [character, setCharacter] = useState<CharacterResponse | null>(null)
const [generations, setGenerations] = useState<GenerationItem[]>([])
const [loading, setLoading] = useState(true)
const [createOpen, setCreateOpen] = useState(false)
useEffect(() => {
if (id) {
fetchData(id)
}
}, [id])
const fetchData = async (charId: string) => {
setLoading(true)
try {
const [charRes, genRes] = await Promise.all([
client.characters[':id'].$get({ param: { id: charId } }),
client.generations.$get({ query: { characterId: charId, limit: '100' } })
])
if (charRes.ok) {
const data = await charRes.json()
setCharacter(data)
}
if (genRes.ok) {
const genData = await genRes.json()
setGenerations(genData.items)
}
} catch (e) {
console.error('Failed to load data', e)
} finally {
setLoading(false)
}
}
if (loading) {
return (
<div className="flex items-center justify-center h-screen bg-stone-50">
<div className="animate-pulse text-stone-400 font-medium">Loading Fitting Room...</div>
</div>
)
}
if (!character || 'error' in character) {
return (
<div className="flex flex-col items-center justify-center h-screen bg-stone-50 gap-4">
<h2 className="text-2xl font-bold text-stone-800">Character Not Found</h2>
<Button onClick={() => navigate('/characters')}>Return to Characters</Button>
</div>
)
}
return (
<div className="p-8 animate-fade-in-up h-screen flex flex-col overflow-hidden bg-stone-50/30">
{/* Header */}
<div className="flex justify-between items-center mb-6 shrink-0">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => navigate('/characters')} className="rounded-full hover:bg-stone-200">
<ArrowLeft className="w-5 h-5 text-stone-600" />
</Button>
<div>
<h1 className="text-3xl font-black text-stone-800 uppercase tracking-tighter">
Fitting Room
</h1>
<p className="text-stone-500 text-sm font-medium flex items-center gap-2">
Manage outfits for <span className="text-stone-900 font-bold bg-stone-200 px-2 py-0.5 rounded text-xs uppercase">{character.name}</span>
</p>
</div>
</div>
<Button onClick={() => setCreateOpen(true)} className="bg-stone-900 hover:bg-stone-800 text-white shadow-lg hover:shadow-xl transition-all">
<Plus className="mr-2 h-4 w-4" /> Create New Outfit
</Button>
</div>
<div className="flex flex-col lg:flex-row flex-1 gap-6 overflow-hidden pb-4">
{/* Left: Character Preview */}
<div className="w-full lg:w-1/3 flex flex-col gap-4 shrink-0 h-full">
<Card className="flex-1 border-stone-200 overflow-hidden bg-white flex items-center justify-center relative shadow-sm rounded-2xl p-8">
<div className="absolute inset-0 opacity-5 pointer-events-none"
style={{
backgroundImage: 'radial-gradient(#000 1px, transparent 1px)',
backgroundSize: '24px 24px',
}}
></div>
<img
src={character.image?.path ? `/files/${character.image.path}` : ''}
alt={character.name}
className="w-full h-full object-contain drop-shadow-2xl hover:scale-105 transition-transform duration-500"
/>
<div className="absolute bottom-4 left-4 right-4 bg-white/80 backdrop-blur-md p-4 rounded-xl border border-white/50 shadow-sm">
<h3 className="font-bold text-stone-800 text-lg">{character.name}</h3>
<p className="text-xs text-stone-500 mt-1 line-clamp-2">{character.description || 'No description provided.'}</p>
</div>
</Card>
</div>
{/* Right: Generations Grid */}
<div className="flex-1 flex flex-col bg-white rounded-2xl border border-stone-200 shadow-sm overflow-hidden h-full">
<div className="p-4 border-b border-stone-100 bg-stone-50/50 flex justify-between items-center shrink-0">
<h3 className="font-bold text-stone-700 uppercase tracking-wider text-xs flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-green-500"></span>
Generated Outfits
</h3>
<span className="text-xs font-mono text-stone-400">{generations.length} OUTFITS</span>
</div>
<div className="flex-1 overflow-y-auto p-4 custom-scrollbar">
{generations.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-stone-400 gap-4">
<div className="w-16 h-16 rounded-full bg-stone-100 flex items-center justify-center">
<Plus className="w-8 h-8 text-stone-300" />
</div>
<div className="text-center">
<p className="font-medium text-stone-600">No outfits generated yet.</p>
<p className="text-xs mt-1">Create your first outfit to get started.</p>
</div>
<Button variant="outline" onClick={() => setCreateOpen(true)}>Create Outfit</Button>
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-4">
{generations.map(gen => (
<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">
<img
src={gen.image?.path ? `/files/${gen.image.path}` : ''}
alt="Generation"
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-700"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity flex flex-col justify-end p-4">
<p className="text-white text-xs font-bold mb-2">
{new Date(gen.createdAt).toLocaleDateString()}
</p>
<div className="flex gap-1 flex-wrap content-start h-12 overflow-hidden">
{/* @ts-ignore - equipment structure might vary based on include */}
{gen.equipments?.slice(0, 3).map((eq: any) => (
<span key={eq.equipmentId} className="text-[10px] bg-white/20 text-white px-2 py-1 rounded-md backdrop-blur-md border border-white/10">
{eq.equipment.name}
</span>
))}
{/* @ts-ignore */}
{gen.equipments && gen.equipments.length > 3 && (
<span className="text-[10px] bg-white/20 text-white px-2 py-1 rounded-md backdrop-blur-md border border-white/10">
+{gen.equipments.length - 3}
</span>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
<CreateOutfitDialog
open={createOpen}
onOpenChange={setCreateOpen}
characterId={id || ''}
onSuccess={() => id && fetchData(id)}
/>
</div>
)
}