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:
1 parent
fba32312ce
commit
cc024a0aea
14 files changed
+896
-12
No files matched your search
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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 },
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user