feat: Add Outfit system and update docs

- Added `Outfit` model and migration.
- Implemented backend service and API for outfits.
- Added frontend support for outfits management (create, list, delete, load, save).
- Updated `AGENTS.md` with Prisma migration guidelines.
This commit is contained in:
google-labs-jules[bot] committed 2026-02-11 09:20:40 +00:00
1 parent d31e849e96
commit a33545be6e
22 files changed
+4331 -207

No files matched your search

+20 -3
View File
@@ -99,18 +99,35 @@ We use `bun:test` for testing.
- Fixes must be iterated until **no check causes any other check to fail**.
- Do **not** submit intermediate states where some checks pass and others fail, even temporarily.
## Prisma Configuration
## Prisma Configuration & Migrations
- Schema: `prisma/schema.prisma`
- Generated Client: `src/generated/prisma`
- Config: `prisma.config.ts` (Required for Prisma 7+)
- Adapter: We use `@prisma/adapter-libsql` for SQLite compatibility with Bun.
- Migration Requirement: Whenever the Prisma schema is modified, you must also generate a corresponding migration file using Prisma Migrate (e.g., prisma migrate dev) and ensure the migration is committed.
### Migration Workflow
Whenever you modify the `prisma/schema.prisma` file, you **must** generate a corresponding migration file.
**Do not create migration SQL files manually.**
Instead, follow this workflow:
1. Ensure you have a local SQLite database configured (e.g., via `.env` setting `DATABASE_URL="file:./dev.db"`).
2. Run the migration command:
```bash
bun x prisma migrate dev --name <migration_name>
```
This command will automatically:
- Detect changes in `schema.prisma`.
- Generate the SQL migration file in `prisma/migrations/`.
- Apply the changes to your local development database.
- Regenerate the Prisma Client.
### Commands
- Generate Client: `bun --bun run prisma generate`
- Push Schema to DB: `bun --bun run prisma db push`
- Push Schema to DB (Prototyping only): `bun --bun run prisma db push`
## Environment Variables
@@ -0,0 +1,18 @@
-- CreateTable
CREATE TABLE "outfits" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"prompt" TEXT,
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "outfit_equipments" (
"outfit_id" TEXT NOT NULL,
"equipment_id" TEXT NOT NULL,
PRIMARY KEY ("outfit_id", "equipment_id"),
CONSTRAINT "outfit_equipments_outfit_id_fkey" FOREIGN KEY ("outfit_id") REFERENCES "outfits" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "outfit_equipments_equipment_id_fkey" FOREIGN KEY ("equipment_id") REFERENCES "equipments" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
+25
View File
@@ -56,6 +56,7 @@ model Equipment {
// Relations
image Asset @relation(fields: [imageId], references: [id])
generations GenerationEquipment[]
outfits OutfitEquipment[]
@@index([category, subCategory])
@@map("equipments")
@@ -88,6 +89,30 @@ model GenerationEquipment {
@@map("generation_equipments")
}
model Outfit {
id String @id
name String
prompt String?
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
equipments OutfitEquipment[]
@@map("outfits")
}
model OutfitEquipment {
outfitId String @map("outfit_id")
equipmentId String @map("equipment_id")
outfit Outfit @relation(fields: [outfitId], references: [id], onDelete: Cascade)
equipment Equipment @relation(fields: [equipmentId], references: [id], onDelete: Cascade)
@@id([outfitId, equipmentId])
@@map("outfit_equipments")
}
model Setting {
key String @id
value String
+88
View File
@@ -0,0 +1,88 @@
import { describe, it, expect, mock, afterEach } from 'bun:test'
const mockOutfitService = {
listOutfits: mock(),
createOutfit: mock(),
updateOutfit: mock(),
deleteOutfit: mock(),
}
mock.module('../service/outfit', () => ({
outfitService: mockOutfitService,
}))
describe('Outfit API', () => {
afterEach(() => {
mockOutfitService.listOutfits.mockClear()
mockOutfitService.createOutfit.mockClear()
mockOutfitService.updateOutfit.mockClear()
mockOutfitService.deleteOutfit.mockClear()
})
it('GET / should list outfits', async () => {
const { default: outfit } = await import(`./outfit?v=${Date.now()}`)
mockOutfitService.listOutfits.mockResolvedValue([])
const req = new Request('http://localhost/', {
method: 'GET',
})
const res = await outfit.fetch(req)
expect(res.status).toBe(200)
expect(mockOutfitService.listOutfits).toHaveBeenCalled()
})
it('POST / should create outfit', async () => {
const { default: outfit } = await import(`./outfit?v=${Date.now()}`)
mockOutfitService.createOutfit.mockResolvedValue({ id: '1' })
const req = new Request('http://localhost/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'New Outfit',
equipmentIds: ['eq1'],
}),
})
const res = await outfit.fetch(req)
expect(res.status).toBe(200)
expect(mockOutfitService.createOutfit).toHaveBeenCalledWith({
name: 'New Outfit',
equipmentIds: ['eq1'],
prompt: undefined,
})
})
it('PUT /:id should update outfit', async () => {
const { default: outfit } = await import(`./outfit?v=${Date.now()}`)
mockOutfitService.updateOutfit.mockResolvedValue({ id: '1' })
const req = new Request('http://localhost/1', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'Updated Outfit',
}),
})
const res = await outfit.fetch(req)
expect(res.status).toBe(200)
expect(mockOutfitService.updateOutfit).toHaveBeenCalledWith('1', {
name: 'Updated Outfit',
})
})
it('DELETE /:id should delete outfit', async () => {
const { default: outfit } = await import(`./outfit?v=${Date.now()}`)
mockOutfitService.deleteOutfit.mockResolvedValue({ id: '1' })
const req = new Request('http://localhost/1', {
method: 'DELETE',
})
const res = await outfit.fetch(req)
expect(res.status).toBe(200)
expect(mockOutfitService.deleteOutfit).toHaveBeenCalledWith('1')
})
})
+48
View File
@@ -0,0 +1,48 @@
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
import { outfitService } from '../service/outfit'
const app = new Hono()
// Define schemas for validation
const listSchema = z.object({
equipmentId: z.string().optional(),
})
const createSchema = z.object({
name: z.string().min(1),
prompt: z.string().optional(),
equipmentIds: z.array(z.string()),
})
const updateSchema = z.object({
name: z.string().optional(),
prompt: z.string().optional(),
equipmentIds: z.array(z.string()).optional(),
})
const route = app
.get('/', zValidator('query', listSchema), async (c) => {
const { equipmentId } = c.req.valid('query')
const result = await outfitService.listOutfits(equipmentId)
return c.json(result)
})
.post('/', zValidator('json', createSchema), async (c) => {
const data = c.req.valid('json')
const result = await outfitService.createOutfit(data)
return c.json(result)
})
.put('/:id', zValidator('json', updateSchema), async (c) => {
const id = c.req.param('id')
const data = c.req.valid('json')
const result = await outfitService.updateOutfit(id, data)
return c.json(result)
})
.delete('/:id', async (c) => {
const id = c.req.param('id')
await outfitService.deleteOutfit(id)
return c.json({ success: true })
})
export default route
+10
View File
@@ -42,6 +42,16 @@ export type Generation = Prisma.GenerationModel
*
*/
export type GenerationEquipment = Prisma.GenerationEquipmentModel
/**
* Model Outfit
*
*/
export type Outfit = Prisma.OutfitModel
/**
* Model OutfitEquipment
*
*/
export type OutfitEquipment = Prisma.OutfitEquipmentModel
/**
* Model Setting
*
+10
View File
@@ -64,6 +64,16 @@ export type Generation = Prisma.GenerationModel
*
*/
export type GenerationEquipment = Prisma.GenerationEquipmentModel
/**
* Model Outfit
*
*/
export type Outfit = Prisma.OutfitModel
/**
* Model OutfitEquipment
*
*/
export type OutfitEquipment = Prisma.OutfitEquipmentModel
/**
* Model Setting
*
+22 -2
View File
@@ -20,7 +20,7 @@ const config: runtime.GetPrismaClientConfig = {
"clientVersion": "7.3.0",
"engineVersion": "9d6ad21cbbceab97458517b147a6a09ff43aa735",
"activeProvider": "sqlite",
"inlineSchema": "// This is your Prisma schema file,\n// learn more about it in the docs: https://pris.ly/d/prisma-schema\n\ngenerator client {\n provider = \"prisma-client\"\n output = \"../src/generated/prisma\"\n engineType = \"client\"\n runtime = \"bun\"\n}\n\ndatasource db {\n provider = \"sqlite\"\n}\n\nmodel Asset {\n id String @id\n name String\n type String\n path String\n createdAt DateTime @default(now()) @map(\"created_at\")\n updatedAt DateTime @updatedAt @map(\"updated_at\")\n\n // Relations\n characters Character[]\n equipments Equipment[]\n generations Generation[]\n\n @@map(\"assets\")\n}\n\nmodel Character {\n id String @id\n name String\n description String?\n imageId String @map(\"image_id\")\n createdAt DateTime @default(now()) @map(\"created_at\")\n updatedAt DateTime @updatedAt @map(\"updated_at\")\n\n // Relations\n image Asset @relation(fields: [imageId], references: [id])\n generations Generation[]\n\n @@map(\"characters\")\n}\n\nmodel Equipment {\n id String @id\n name String\n description String\n category String\n subCategory String? @map(\"sub_category\")\n imageId String @map(\"image_id\")\n createdAt DateTime @default(now()) @map(\"created_at\")\n updatedAt DateTime @updatedAt @map(\"updated_at\")\n\n // Relations\n image Asset @relation(fields: [imageId], references: [id])\n generations GenerationEquipment[]\n\n @@index([category, subCategory])\n @@map(\"equipments\")\n}\n\nmodel Generation {\n id String @id\n characterId String @map(\"character_id\")\n imageId String @map(\"image_id\")\n userPrompt String? @map(\"user_prompt\")\n createdAt DateTime @default(now()) @map(\"created_at\")\n updatedAt DateTime @updatedAt @map(\"updated_at\")\n\n // Relations\n character Character @relation(fields: [characterId], references: [id])\n image Asset @relation(fields: [imageId], references: [id])\n equipments GenerationEquipment[]\n\n @@map(\"generations\")\n}\n\nmodel GenerationEquipment {\n generationId String @map(\"generation_id\")\n equipmentId String @map(\"equipment_id\")\n\n generation Generation @relation(fields: [generationId], references: [id])\n equipment Equipment @relation(fields: [equipmentId], references: [id])\n\n @@id([generationId, equipmentId])\n @@map(\"generation_equipments\")\n}\n\nmodel Setting {\n key String @id\n value String\n\n createdAt DateTime @default(now()) @map(\"created_at\")\n updatedAt DateTime @updatedAt @map(\"updated_at\")\n\n @@map(\"settings\")\n}\n",
"inlineSchema": "// This is your Prisma schema file,\n// learn more about it in the docs: https://pris.ly/d/prisma-schema\n\ngenerator client {\n provider = \"prisma-client\"\n output = \"../src/generated/prisma\"\n engineType = \"client\"\n runtime = \"bun\"\n}\n\ndatasource db {\n provider = \"sqlite\"\n}\n\nmodel Asset {\n id String @id\n name String\n type String\n path String\n createdAt DateTime @default(now()) @map(\"created_at\")\n updatedAt DateTime @updatedAt @map(\"updated_at\")\n\n // Relations\n characters Character[]\n equipments Equipment[]\n generations Generation[]\n\n @@map(\"assets\")\n}\n\nmodel Character {\n id String @id\n name String\n description String?\n imageId String @map(\"image_id\")\n createdAt DateTime @default(now()) @map(\"created_at\")\n updatedAt DateTime @updatedAt @map(\"updated_at\")\n\n // Relations\n image Asset @relation(fields: [imageId], references: [id])\n generations Generation[]\n\n @@map(\"characters\")\n}\n\nmodel Equipment {\n id String @id\n name String\n description String\n category String\n subCategory String? @map(\"sub_category\")\n imageId String @map(\"image_id\")\n createdAt DateTime @default(now()) @map(\"created_at\")\n updatedAt DateTime @updatedAt @map(\"updated_at\")\n\n // Relations\n image Asset @relation(fields: [imageId], references: [id])\n generations GenerationEquipment[]\n outfits OutfitEquipment[]\n\n @@index([category, subCategory])\n @@map(\"equipments\")\n}\n\nmodel Generation {\n id String @id\n characterId String @map(\"character_id\")\n imageId String @map(\"image_id\")\n userPrompt String? @map(\"user_prompt\")\n createdAt DateTime @default(now()) @map(\"created_at\")\n updatedAt DateTime @updatedAt @map(\"updated_at\")\n\n // Relations\n character Character @relation(fields: [characterId], references: [id])\n image Asset @relation(fields: [imageId], references: [id])\n equipments GenerationEquipment[]\n\n @@map(\"generations\")\n}\n\nmodel GenerationEquipment {\n generationId String @map(\"generation_id\")\n equipmentId String @map(\"equipment_id\")\n\n generation Generation @relation(fields: [generationId], references: [id])\n equipment Equipment @relation(fields: [equipmentId], references: [id])\n\n @@id([generationId, equipmentId])\n @@map(\"generation_equipments\")\n}\n\nmodel Outfit {\n id String @id\n name String\n prompt String?\n createdAt DateTime @default(now()) @map(\"created_at\")\n updatedAt DateTime @updatedAt @map(\"updated_at\")\n\n // Relations\n equipments OutfitEquipment[]\n\n @@map(\"outfits\")\n}\n\nmodel OutfitEquipment {\n outfitId String @map(\"outfit_id\")\n equipmentId String @map(\"equipment_id\")\n\n outfit Outfit @relation(fields: [outfitId], references: [id], onDelete: Cascade)\n equipment Equipment @relation(fields: [equipmentId], references: [id], onDelete: Cascade)\n\n @@id([outfitId, equipmentId])\n @@map(\"outfit_equipments\")\n}\n\nmodel Setting {\n key String @id\n value String\n\n createdAt DateTime @default(now()) @map(\"created_at\")\n updatedAt DateTime @updatedAt @map(\"updated_at\")\n\n @@map(\"settings\")\n}\n",
"runtimeDataModel": {
"models": {},
"enums": {},
@@ -28,7 +28,7 @@ const config: runtime.GetPrismaClientConfig = {
}
}
config.runtimeDataModel = JSON.parse("{\"models\":{\"Asset\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"type\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"path\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\",\"dbName\":\"created_at\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\",\"dbName\":\"updated_at\"},{\"name\":\"characters\",\"kind\":\"object\",\"type\":\"Character\",\"relationName\":\"AssetToCharacter\"},{\"name\":\"equipments\",\"kind\":\"object\",\"type\":\"Equipment\",\"relationName\":\"AssetToEquipment\"},{\"name\":\"generations\",\"kind\":\"object\",\"type\":\"Generation\",\"relationName\":\"AssetToGeneration\"}],\"dbName\":\"assets\"},\"Character\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"description\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"imageId\",\"kind\":\"scalar\",\"type\":\"String\",\"dbName\":\"image_id\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\",\"dbName\":\"created_at\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\",\"dbName\":\"updated_at\"},{\"name\":\"image\",\"kind\":\"object\",\"type\":\"Asset\",\"relationName\":\"AssetToCharacter\"},{\"name\":\"generations\",\"kind\":\"object\",\"type\":\"Generation\",\"relationName\":\"CharacterToGeneration\"}],\"dbName\":\"characters\"},\"Equipment\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"description\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"category\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"subCategory\",\"kind\":\"scalar\",\"type\":\"String\",\"dbName\":\"sub_category\"},{\"name\":\"imageId\",\"kind\":\"scalar\",\"type\":\"String\",\"dbName\":\"image_id\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\",\"dbName\":\"created_at\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\",\"dbName\":\"updated_at\"},{\"name\":\"image\",\"kind\":\"object\",\"type\":\"Asset\",\"relationName\":\"AssetToEquipment\"},{\"name\":\"generations\",\"kind\":\"object\",\"type\":\"GenerationEquipment\",\"relationName\":\"EquipmentToGenerationEquipment\"}],\"dbName\":\"equipments\"},\"Generation\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"characterId\",\"kind\":\"scalar\",\"type\":\"String\",\"dbName\":\"character_id\"},{\"name\":\"imageId\",\"kind\":\"scalar\",\"type\":\"String\",\"dbName\":\"image_id\"},{\"name\":\"userPrompt\",\"kind\":\"scalar\",\"type\":\"String\",\"dbName\":\"user_prompt\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\",\"dbName\":\"created_at\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\",\"dbName\":\"updated_at\"},{\"name\":\"character\",\"kind\":\"object\",\"type\":\"Character\",\"relationName\":\"CharacterToGeneration\"},{\"name\":\"image\",\"kind\":\"object\",\"type\":\"Asset\",\"relationName\":\"AssetToGeneration\"},{\"name\":\"equipments\",\"kind\":\"object\",\"type\":\"GenerationEquipment\",\"relationName\":\"GenerationToGenerationEquipment\"}],\"dbName\":\"generations\"},\"GenerationEquipment\":{\"fields\":[{\"name\":\"generationId\",\"kind\":\"scalar\",\"type\":\"String\",\"dbName\":\"generation_id\"},{\"name\":\"equipmentId\",\"kind\":\"scalar\",\"type\":\"String\",\"dbName\":\"equipment_id\"},{\"name\":\"generation\",\"kind\":\"object\",\"type\":\"Generation\",\"relationName\":\"GenerationToGenerationEquipment\"},{\"name\":\"equipment\",\"kind\":\"object\",\"type\":\"Equipment\",\"relationName\":\"EquipmentToGenerationEquipment\"}],\"dbName\":\"generation_equipments\"},\"Setting\":{\"fields\":[{\"name\":\"key\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"value\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\",\"dbName\":\"created_at\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\",\"dbName\":\"updated_at\"}],\"dbName\":\"settings\"}},\"enums\":{},\"types\":{}}")
config.runtimeDataModel = JSON.parse("{\"models\":{\"Asset\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"type\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"path\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\",\"dbName\":\"created_at\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\",\"dbName\":\"updated_at\"},{\"name\":\"characters\",\"kind\":\"object\",\"type\":\"Character\",\"relationName\":\"AssetToCharacter\"},{\"name\":\"equipments\",\"kind\":\"object\",\"type\":\"Equipment\",\"relationName\":\"AssetToEquipment\"},{\"name\":\"generations\",\"kind\":\"object\",\"type\":\"Generation\",\"relationName\":\"AssetToGeneration\"}],\"dbName\":\"assets\"},\"Character\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"description\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"imageId\",\"kind\":\"scalar\",\"type\":\"String\",\"dbName\":\"image_id\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\",\"dbName\":\"created_at\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\",\"dbName\":\"updated_at\"},{\"name\":\"image\",\"kind\":\"object\",\"type\":\"Asset\",\"relationName\":\"AssetToCharacter\"},{\"name\":\"generations\",\"kind\":\"object\",\"type\":\"Generation\",\"relationName\":\"CharacterToGeneration\"}],\"dbName\":\"characters\"},\"Equipment\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"description\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"category\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"subCategory\",\"kind\":\"scalar\",\"type\":\"String\",\"dbName\":\"sub_category\"},{\"name\":\"imageId\",\"kind\":\"scalar\",\"type\":\"String\",\"dbName\":\"image_id\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\",\"dbName\":\"created_at\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\",\"dbName\":\"updated_at\"},{\"name\":\"image\",\"kind\":\"object\",\"type\":\"Asset\",\"relationName\":\"AssetToEquipment\"},{\"name\":\"generations\",\"kind\":\"object\",\"type\":\"GenerationEquipment\",\"relationName\":\"EquipmentToGenerationEquipment\"},{\"name\":\"outfits\",\"kind\":\"object\",\"type\":\"OutfitEquipment\",\"relationName\":\"EquipmentToOutfitEquipment\"}],\"dbName\":\"equipments\"},\"Generation\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"characterId\",\"kind\":\"scalar\",\"type\":\"String\",\"dbName\":\"character_id\"},{\"name\":\"imageId\",\"kind\":\"scalar\",\"type\":\"String\",\"dbName\":\"image_id\"},{\"name\":\"userPrompt\",\"kind\":\"scalar\",\"type\":\"String\",\"dbName\":\"user_prompt\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\",\"dbName\":\"created_at\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\",\"dbName\":\"updated_at\"},{\"name\":\"character\",\"kind\":\"object\",\"type\":\"Character\",\"relationName\":\"CharacterToGeneration\"},{\"name\":\"image\",\"kind\":\"object\",\"type\":\"Asset\",\"relationName\":\"AssetToGeneration\"},{\"name\":\"equipments\",\"kind\":\"object\",\"type\":\"GenerationEquipment\",\"relationName\":\"GenerationToGenerationEquipment\"}],\"dbName\":\"generations\"},\"GenerationEquipment\":{\"fields\":[{\"name\":\"generationId\",\"kind\":\"scalar\",\"type\":\"String\",\"dbName\":\"generation_id\"},{\"name\":\"equipmentId\",\"kind\":\"scalar\",\"type\":\"String\",\"dbName\":\"equipment_id\"},{\"name\":\"generation\",\"kind\":\"object\",\"type\":\"Generation\",\"relationName\":\"GenerationToGenerationEquipment\"},{\"name\":\"equipment\",\"kind\":\"object\",\"type\":\"Equipment\",\"relationName\":\"EquipmentToGenerationEquipment\"}],\"dbName\":\"generation_equipments\"},\"Outfit\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"prompt\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\",\"dbName\":\"created_at\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\",\"dbName\":\"updated_at\"},{\"name\":\"equipments\",\"kind\":\"object\",\"type\":\"OutfitEquipment\",\"relationName\":\"OutfitToOutfitEquipment\"}],\"dbName\":\"outfits\"},\"OutfitEquipment\":{\"fields\":[{\"name\":\"outfitId\",\"kind\":\"scalar\",\"type\":\"String\",\"dbName\":\"outfit_id\"},{\"name\":\"equipmentId\",\"kind\":\"scalar\",\"type\":\"String\",\"dbName\":\"equipment_id\"},{\"name\":\"outfit\",\"kind\":\"object\",\"type\":\"Outfit\",\"relationName\":\"OutfitToOutfitEquipment\"},{\"name\":\"equipment\",\"kind\":\"object\",\"type\":\"Equipment\",\"relationName\":\"EquipmentToOutfitEquipment\"}],\Line truncated
async function decodeBase64AsWasm(wasmBase64: string): Promise<WebAssembly.Module> {
const { Buffer } = await import('node:buffer')
@@ -226,6 +226,26 @@ export interface PrismaClient<
*/
get generationEquipment(): Prisma.GenerationEquipmentDelegate<ExtArgs, { omit: OmitOpts }>;
/**
* `prisma.outfit`: Exposes CRUD operations for the **Outfit** model.
* Example usage:
* ```ts
* // Fetch zero or more Outfits
* const outfits = await prisma.outfit.findMany()
* ```
*/
get outfit(): Prisma.OutfitDelegate<ExtArgs, { omit: OmitOpts }>;
/**
* `prisma.outfitEquipment`: Exposes CRUD operations for the **OutfitEquipment** model.
* Example usage:
* ```ts
* // Fetch zero or more OutfitEquipments
* const outfitEquipments = await prisma.outfitEquipment.findMany()
* ```
*/
get outfitEquipment(): Prisma.OutfitEquipmentDelegate<ExtArgs, { omit: OmitOpts }>;
/**
* `prisma.setting`: Exposes CRUD operations for the **Setting** model.
* Example usage:
@@ -389,6 +389,8 @@ export const ModelName = {
Equipment: 'Equipment',
Generation: 'Generation',
GenerationEquipment: 'GenerationEquipment',
Outfit: 'Outfit',
OutfitEquipment: 'OutfitEquipment',
Setting: 'Setting'
} as const
@@ -405,7 +407,7 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
omit: GlobalOmitOptions
}
meta: {
modelProps: "asset" | "character" | "equipment" | "generation" | "generationEquipment" | "setting"
modelProps: "asset" | "character" | "equipment" | "generation" | "generationEquipment" | "outfit" | "outfitEquipment" | "setting"
txIsolationLevel: TransactionIsolationLevel
}
model: {
@@ -779,6 +781,154 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
}
}
}
Outfit: {
payload: Prisma.$OutfitPayload<ExtArgs>
fields: Prisma.OutfitFieldRefs
operations: {
findUnique: {
args: Prisma.OutfitFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$OutfitPayload> | null
}
findUniqueOrThrow: {
args: Prisma.OutfitFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$OutfitPayload>
}
findFirst: {
args: Prisma.OutfitFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$OutfitPayload> | null
}
findFirstOrThrow: {
args: Prisma.OutfitFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$OutfitPayload>
}
findMany: {
args: Prisma.OutfitFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$OutfitPayload>[]
}
create: {
args: Prisma.OutfitCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$OutfitPayload>
}
createMany: {
args: Prisma.OutfitCreateManyArgs<ExtArgs>
result: BatchPayload
}
createManyAndReturn: {
args: Prisma.OutfitCreateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$OutfitPayload>[]
}
delete: {
args: Prisma.OutfitDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$OutfitPayload>
}
update: {
args: Prisma.OutfitUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$OutfitPayload>
}
deleteMany: {
args: Prisma.OutfitDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.OutfitUpdateManyArgs<ExtArgs>
result: BatchPayload
}
updateManyAndReturn: {
args: Prisma.OutfitUpdateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$OutfitPayload>[]
}
upsert: {
args: Prisma.OutfitUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$OutfitPayload>
}
aggregate: {
args: Prisma.OutfitAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateOutfit>
}
groupBy: {
args: Prisma.OutfitGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.OutfitGroupByOutputType>[]
}
count: {
args: Prisma.OutfitCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.OutfitCountAggregateOutputType> | number
}
}
}
OutfitEquipment: {
payload: Prisma.$OutfitEquipmentPayload<ExtArgs>
fields: Prisma.OutfitEquipmentFieldRefs
operations: {
findUnique: {
args: Prisma.OutfitEquipmentFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$OutfitEquipmentPayload> | null
}
findUniqueOrThrow: {
args: Prisma.OutfitEquipmentFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$OutfitEquipmentPayload>
}
findFirst: {
args: Prisma.OutfitEquipmentFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$OutfitEquipmentPayload> | null
}
findFirstOrThrow: {
args: Prisma.OutfitEquipmentFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$OutfitEquipmentPayload>
}
findMany: {
args: Prisma.OutfitEquipmentFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$OutfitEquipmentPayload>[]
}
create: {
args: Prisma.OutfitEquipmentCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$OutfitEquipmentPayload>
}
createMany: {
args: Prisma.OutfitEquipmentCreateManyArgs<ExtArgs>
result: BatchPayload
}
createManyAndReturn: {
args: Prisma.OutfitEquipmentCreateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$OutfitEquipmentPayload>[]
}
delete: {
args: Prisma.OutfitEquipmentDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$OutfitEquipmentPayload>
}
update: {
args: Prisma.OutfitEquipmentUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$OutfitEquipmentPayload>
}
deleteMany: {
args: Prisma.OutfitEquipmentDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.OutfitEquipmentUpdateManyArgs<ExtArgs>
result: BatchPayload
}
updateManyAndReturn: {
args: Prisma.OutfitEquipmentUpdateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$OutfitEquipmentPayload>[]
}
upsert: {
args: Prisma.OutfitEquipmentUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$OutfitEquipmentPayload>
}
aggregate: {
args: Prisma.OutfitEquipmentAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateOutfitEquipment>
}
groupBy: {
args: Prisma.OutfitEquipmentGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.OutfitEquipmentGroupByOutputType>[]
}
count: {
args: Prisma.OutfitEquipmentCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.OutfitEquipmentCountAggregateOutputType> | number
}
}
}
Setting: {
payload: Prisma.$SettingPayload<ExtArgs>
fields: Prisma.SettingFieldRefs
@@ -947,6 +1097,25 @@ export const GenerationEquipmentScalarFieldEnum = {
export type GenerationEquipmentScalarFieldEnum = (typeof GenerationEquipmentScalarFieldEnum)[keyof typeof GenerationEquipmentScalarFieldEnum]
export const OutfitScalarFieldEnum = {
id: 'id',
name: 'name',
prompt: 'prompt',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type OutfitScalarFieldEnum = (typeof OutfitScalarFieldEnum)[keyof typeof OutfitScalarFieldEnum]
export const OutfitEquipmentScalarFieldEnum = {
outfitId: 'outfitId',
equipmentId: 'equipmentId'
} as const
export type OutfitEquipmentScalarFieldEnum = (typeof OutfitEquipmentScalarFieldEnum)[keyof typeof OutfitEquipmentScalarFieldEnum]
export const SettingScalarFieldEnum = {
key: 'key',
value: 'value',
@@ -1099,6 +1268,8 @@ export type GlobalOmitConfig = {
equipment?: Prisma.EquipmentOmit
generation?: Prisma.GenerationOmit
generationEquipment?: Prisma.GenerationEquipmentOmit
outfit?: Prisma.OutfitOmit
outfitEquipment?: Prisma.OutfitEquipmentOmit
setting?: Prisma.SettingOmit
}
@@ -56,6 +56,8 @@ export const ModelName = {
Equipment: 'Equipment',
Generation: 'Generation',
GenerationEquipment: 'GenerationEquipment',
Outfit: 'Outfit',
OutfitEquipment: 'OutfitEquipment',
Setting: 'Setting'
} as const
@@ -130,6 +132,25 @@ export const GenerationEquipmentScalarFieldEnum = {
export type GenerationEquipmentScalarFieldEnum = (typeof GenerationEquipmentScalarFieldEnum)[keyof typeof GenerationEquipmentScalarFieldEnum]
export const OutfitScalarFieldEnum = {
id: 'id',
name: 'name',
prompt: 'prompt',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type OutfitScalarFieldEnum = (typeof OutfitScalarFieldEnum)[keyof typeof OutfitScalarFieldEnum]
export const OutfitEquipmentScalarFieldEnum = {
outfitId: 'outfitId',
equipmentId: 'equipmentId'
} as const
export type OutfitEquipmentScalarFieldEnum = (typeof OutfitEquipmentScalarFieldEnum)[keyof typeof OutfitEquipmentScalarFieldEnum]
export const SettingScalarFieldEnum = {
key: 'key',
value: 'value',
+2
View File
@@ -13,5 +13,7 @@ export type * from './models/Character.ts'
export type * from './models/Equipment.ts'
export type * from './models/Generation.ts'
export type * from './models/GenerationEquipment.ts'
export type * from './models/Outfit.ts'
export type * from './models/OutfitEquipment.ts'
export type * from './models/Setting.ts'
export type * from './commonInputTypes.ts'
+130
View File
@@ -208,6 +208,7 @@ export type EquipmentWhereInput = {
updatedAt?: Prisma.DateTimeFilter<"Equipment"> | Date | string
image?: Prisma.XOR<Prisma.AssetScalarRelationFilter, Prisma.AssetWhereInput>
generations?: Prisma.GenerationEquipmentListRelationFilter
outfits?: Prisma.OutfitEquipmentListRelationFilter
}
export type EquipmentOrderByWithRelationInput = {
@@ -221,6 +222,7 @@ export type EquipmentOrderByWithRelationInput = {
updatedAt?: Prisma.SortOrder
image?: Prisma.AssetOrderByWithRelationInput
generations?: Prisma.GenerationEquipmentOrderByRelationAggregateInput
outfits?: Prisma.OutfitEquipmentOrderByRelationAggregateInput
}
export type EquipmentWhereUniqueInput = Prisma.AtLeast<{
@@ -237,6 +239,7 @@ export type EquipmentWhereUniqueInput = Prisma.AtLeast<{
updatedAt?: Prisma.DateTimeFilter<"Equipment"> | Date | string
image?: Prisma.XOR<Prisma.AssetScalarRelationFilter, Prisma.AssetWhereInput>
generations?: Prisma.GenerationEquipmentListRelationFilter
outfits?: Prisma.OutfitEquipmentListRelationFilter
}, "id">
export type EquipmentOrderByWithAggregationInput = {
@@ -277,6 +280,7 @@ export type EquipmentCreateInput = {
updatedAt?: Date | string
image: Prisma.AssetCreateNestedOneWithoutEquipmentsInput
generations?: Prisma.GenerationEquipmentCreateNestedManyWithoutEquipmentInput
outfits?: Prisma.OutfitEquipmentCreateNestedManyWithoutEquipmentInput
}
export type EquipmentUncheckedCreateInput = {
@@ -289,6 +293,7 @@ export type EquipmentUncheckedCreateInput = {
createdAt?: Date | string
updatedAt?: Date | string
generations?: Prisma.GenerationEquipmentUncheckedCreateNestedManyWithoutEquipmentInput
outfits?: Prisma.OutfitEquipmentUncheckedCreateNestedManyWithoutEquipmentInput
}
export type EquipmentUpdateInput = {
@@ -301,6 +306,7 @@ export type EquipmentUpdateInput = {
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
image?: Prisma.AssetUpdateOneRequiredWithoutEquipmentsNestedInput
generations?: Prisma.GenerationEquipmentUpdateManyWithoutEquipmentNestedInput
outfits?: Prisma.OutfitEquipmentUpdateManyWithoutEquipmentNestedInput
}
export type EquipmentUncheckedUpdateInput = {
@@ -313,6 +319,7 @@ export type EquipmentUncheckedUpdateInput = {
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
generations?: Prisma.GenerationEquipmentUncheckedUpdateManyWithoutEquipmentNestedInput
outfits?: Prisma.OutfitEquipmentUncheckedUpdateManyWithoutEquipmentNestedInput
}
export type EquipmentCreateManyInput = {
@@ -451,6 +458,20 @@ export type EquipmentUpdateOneRequiredWithoutGenerationsNestedInput = {
update?: Prisma.XOR<Prisma.XOR<Prisma.EquipmentUpdateToOneWithWhereWithoutGenerationsInput, Prisma.EquipmentUpdateWithoutGenerationsInput>, Prisma.EquipmentUncheckedUpdateWithoutGenerationsInput>
}
export type EquipmentCreateNestedOneWithoutOutfitsInput = {
create?: Prisma.XOR<Prisma.EquipmentCreateWithoutOutfitsInput, Prisma.EquipmentUncheckedCreateWithoutOutfitsInput>
connectOrCreate?: Prisma.EquipmentCreateOrConnectWithoutOutfitsInput
connect?: Prisma.EquipmentWhereUniqueInput
}
export type EquipmentUpdateOneRequiredWithoutOutfitsNestedInput = {
create?: Prisma.XOR<Prisma.EquipmentCreateWithoutOutfitsInput, Prisma.EquipmentUncheckedCreateWithoutOutfitsInput>
connectOrCreate?: Prisma.EquipmentCreateOrConnectWithoutOutfitsInput
upsert?: Prisma.EquipmentUpsertWithoutOutfitsInput
connect?: Prisma.EquipmentWhereUniqueInput
update?: Prisma.XOR<Prisma.XOR<Prisma.EquipmentUpdateToOneWithWhereWithoutOutfitsInput, Prisma.EquipmentUpdateWithoutOutfitsInput>, Prisma.EquipmentUncheckedUpdateWithoutOutfitsInput>
}
export type EquipmentCreateWithoutImageInput = {
id: string
name: string
@@ -460,6 +481,7 @@ export type EquipmentCreateWithoutImageInput = {
createdAt?: Date | string
updatedAt?: Date | string
generations?: Prisma.GenerationEquipmentCreateNestedManyWithoutEquipmentInput
outfits?: Prisma.OutfitEquipmentCreateNestedManyWithoutEquipmentInput
}
export type EquipmentUncheckedCreateWithoutImageInput = {
@@ -471,6 +493,7 @@ export type EquipmentUncheckedCreateWithoutImageInput = {
createdAt?: Date | string
updatedAt?: Date | string
generations?: Prisma.GenerationEquipmentUncheckedCreateNestedManyWithoutEquipmentInput
outfits?: Prisma.OutfitEquipmentUncheckedCreateNestedManyWithoutEquipmentInput
}
export type EquipmentCreateOrConnectWithoutImageInput = {
@@ -521,6 +544,7 @@ export type EquipmentCreateWithoutGenerationsInput = {
createdAt?: Date | string
updatedAt?: Date | string
image: Prisma.AssetCreateNestedOneWithoutEquipmentsInput
outfits?: Prisma.OutfitEquipmentCreateNestedManyWithoutEquipmentInput
}
export type EquipmentUncheckedCreateWithoutGenerationsInput = {
@@ -532,6 +556,7 @@ export type EquipmentUncheckedCreateWithoutGenerationsInput = {
imageId: string
createdAt?: Date | string
updatedAt?: Date | string
outfits?: Prisma.OutfitEquipmentUncheckedCreateNestedManyWithoutEquipmentInput
}
export type EquipmentCreateOrConnectWithoutGenerationsInput = {
@@ -559,6 +584,7 @@ export type EquipmentUpdateWithoutGenerationsInput = {
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
image?: Prisma.AssetUpdateOneRequiredWithoutEquipmentsNestedInput
outfits?: Prisma.OutfitEquipmentUpdateManyWithoutEquipmentNestedInput
}
export type EquipmentUncheckedUpdateWithoutGenerationsInput = {
@@ -570,6 +596,71 @@ export type EquipmentUncheckedUpdateWithoutGenerationsInput = {
imageId?: Prisma.StringFieldUpdateOperationsInput | string
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
outfits?: Prisma.OutfitEquipmentUncheckedUpdateManyWithoutEquipmentNestedInput
}
export type EquipmentCreateWithoutOutfitsInput = {
id: string
name: string
description: string
category: string
subCategory?: string | null
createdAt?: Date | string
updatedAt?: Date | string
image: Prisma.AssetCreateNestedOneWithoutEquipmentsInput
generations?: Prisma.GenerationEquipmentCreateNestedManyWithoutEquipmentInput
}
export type EquipmentUncheckedCreateWithoutOutfitsInput = {
id: string
name: string
description: string
category: string
subCategory?: string | null
imageId: string
createdAt?: Date | string
updatedAt?: Date | string
generations?: Prisma.GenerationEquipmentUncheckedCreateNestedManyWithoutEquipmentInput
}
export type EquipmentCreateOrConnectWithoutOutfitsInput = {
where: Prisma.EquipmentWhereUniqueInput
create: Prisma.XOR<Prisma.EquipmentCreateWithoutOutfitsInput, Prisma.EquipmentUncheckedCreateWithoutOutfitsInput>
}
export type EquipmentUpsertWithoutOutfitsInput = {
update: Prisma.XOR<Prisma.EquipmentUpdateWithoutOutfitsInput, Prisma.EquipmentUncheckedUpdateWithoutOutfitsInput>
create: Prisma.XOR<Prisma.EquipmentCreateWithoutOutfitsInput, Prisma.EquipmentUncheckedCreateWithoutOutfitsInput>
where?: Prisma.EquipmentWhereInput
}
export type EquipmentUpdateToOneWithWhereWithoutOutfitsInput = {
where?: Prisma.EquipmentWhereInput
data: Prisma.XOR<Prisma.EquipmentUpdateWithoutOutfitsInput, Prisma.EquipmentUncheckedUpdateWithoutOutfitsInput>
}
export type EquipmentUpdateWithoutOutfitsInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.StringFieldUpdateOperationsInput | string
description?: Prisma.StringFieldUpdateOperationsInput | string
category?: Prisma.StringFieldUpdateOperationsInput | string
subCategory?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
image?: Prisma.AssetUpdateOneRequiredWithoutEquipmentsNestedInput
generations?: Prisma.GenerationEquipmentUpdateManyWithoutEquipmentNestedInput
}
export type EquipmentUncheckedUpdateWithoutOutfitsInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.StringFieldUpdateOperationsInput | string
description?: Prisma.StringFieldUpdateOperationsInput | string
category?: Prisma.StringFieldUpdateOperationsInput | string
subCategory?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
imageId?: Prisma.StringFieldUpdateOperationsInput | string
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
generations?: Prisma.GenerationEquipmentUncheckedUpdateManyWithoutEquipmentNestedInput
}
export type EquipmentCreateManyImageInput = {
@@ -591,6 +682,7 @@ export type EquipmentUpdateWithoutImageInput = {
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
generations?: Prisma.GenerationEquipmentUpdateManyWithoutEquipmentNestedInput
outfits?: Prisma.OutfitEquipmentUpdateManyWithoutEquipmentNestedInput
}
export type EquipmentUncheckedUpdateWithoutImageInput = {
@@ -602,6 +694,7 @@ export type EquipmentUncheckedUpdateWithoutImageInput = {
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
generations?: Prisma.GenerationEquipmentUncheckedUpdateManyWithoutEquipmentNestedInput
outfits?: Prisma.OutfitEquipmentUncheckedUpdateManyWithoutEquipmentNestedInput
}
export type EquipmentUncheckedUpdateManyWithoutImageInput = {
@@ -621,10 +714,12 @@ export type EquipmentUncheckedUpdateManyWithoutImageInput = {
export type EquipmentCountOutputType = {
generations: number
outfits: number
}
export type EquipmentCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
generations?: boolean | EquipmentCountOutputTypeCountGenerationsArgs
outfits?: boolean | EquipmentCountOutputTypeCountOutfitsArgs
}
/**
@@ -644,6 +739,13 @@ export type EquipmentCountOutputTypeCountGenerationsArgs<ExtArgs extends runtime
where?: Prisma.GenerationEquipmentWhereInput
}
/**
* EquipmentCountOutputType without action
*/
export type EquipmentCountOutputTypeCountOutfitsArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
where?: Prisma.OutfitEquipmentWhereInput
}
export type EquipmentSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
id?: boolean
@@ -656,6 +758,7 @@ export type EquipmentSelect<ExtArgs extends runtime.Types.Extensions.InternalArg
updatedAt?: boolean
image?: boolean | Prisma.AssetDefaultArgs<ExtArgs>
generations?: boolean | Prisma.Equipment$generationsArgs<ExtArgs>
outfits?: boolean | Prisma.Equipment$outfitsArgs<ExtArgs>
_count?: boolean | Prisma.EquipmentCountOutputTypeDefaultArgs<ExtArgs>
}, ExtArgs["result"]["equipment"]>
@@ -698,6 +801,7 @@ export type EquipmentOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs
export type EquipmentInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
image?: boolean | Prisma.AssetDefaultArgs<ExtArgs>
generations?: boolean | Prisma.Equipment$generationsArgs<ExtArgs>
outfits?: boolean | Prisma.Equipment$outfitsArgs<ExtArgs>
_count?: boolean | Prisma.EquipmentCountOutputTypeDefaultArgs<ExtArgs>
}
export type EquipmentIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
@@ -712,6 +816,7 @@ export type $EquipmentPayload<ExtArgs extends runtime.Types.Extensions.InternalA
objects: {
image: Prisma.$AssetPayload<ExtArgs>
generations: Prisma.$GenerationEquipmentPayload<ExtArgs>[]
outfits: Prisma.$OutfitEquipmentPayload<ExtArgs>[]
}
scalars: runtime.Types.Extensions.GetPayloadResult<{
id: string
@@ -1118,6 +1223,7 @@ export interface Prisma__EquipmentClient<T, Null = never, ExtArgs extends runtim
readonly [Symbol.toStringTag]: "PrismaPromise"
image<T extends Prisma.AssetDefaultArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.AssetDefaultArgs<ExtArgs>>): Prisma.Prisma__AssetClient<runtime.Types.Result.GetResult<Prisma.$AssetPayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | Null, Null, ExtArgs, GlobalOmitOptions>
generations<T extends Prisma.Equipment$generationsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Equipment$generationsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$GenerationEquipmentPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
outfits<T extends Prisma.Equipment$outfitsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Equipment$outfitsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$OutfitEquipmentPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
/**
* Attaches callbacks for the resolution and/or rejection of the Promise.
* @param onfulfilled The callback to execute when the Promise is resolved.
@@ -1572,6 +1678,30 @@ export type Equipment$generationsArgs<ExtArgs extends runtime.Types.Extensions.I
distinct?: Prisma.GenerationEquipmentScalarFieldEnum | Prisma.GenerationEquipmentScalarFieldEnum[]
}
/**
* Equipment.outfits
*/
export type Equipment$outfitsArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the OutfitEquipment
*/
select?: Prisma.OutfitEquipmentSelect<ExtArgs> | null
/**
* Omit specific fields from the OutfitEquipment
*/
omit?: Prisma.OutfitEquipmentOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.OutfitEquipmentInclude<ExtArgs> | null
where?: Prisma.OutfitEquipmentWhereInput
orderBy?: Prisma.OutfitEquipmentOrderByWithRelationInput | Prisma.OutfitEquipmentOrderByWithRelationInput[]
cursor?: Prisma.OutfitEquipmentWhereUniqueInput
take?: number
skip?: number
distinct?: Prisma.OutfitEquipmentScalarFieldEnum | Prisma.OutfitEquipmentScalarFieldEnum[]
}
/**
* Equipment without action
*/
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+2
View File
@@ -7,6 +7,7 @@ import character from './api/character'
import equipment from './api/equipment'
import extract from './api/extract'
import generation from './api/generation'
import outfit from './api/outfit'
import setting from './api/setting'
import index from './ui/index.html'
@@ -23,6 +24,7 @@ const route = api
.route('/equipments', equipment)
.route('/generations', generation)
.route('/extract', extract)
.route('/outfits', outfit)
.route('/settings', setting)
// Mount API
+96
View File
@@ -0,0 +1,96 @@
import { describe, it, expect, mock, afterEach } from 'bun:test'
const mockPrisma = {
outfit: {
findMany: mock(),
create: mock(),
update: mock(),
delete: mock(),
},
}
mock.module('../db', () => ({
prisma: mockPrisma,
}))
describe('OutfitService', () => {
afterEach(() => {
mockPrisma.outfit.findMany.mockClear()
mockPrisma.outfit.create.mockClear()
mockPrisma.outfit.update.mockClear()
mockPrisma.outfit.delete.mockClear()
})
it('listOutfits should return outfits', async () => {
// @ts-ignore
const { outfitService } = await import(`./outfit?v=${Date.now()}`)
mockPrisma.outfit.findMany.mockResolvedValue([{ id: '1', name: 'Test Outfit', equipments: [] }])
const result = await outfitService.listOutfits()
expect(result).toHaveLength(1)
expect(mockPrisma.outfit.findMany).toHaveBeenCalled()
})
it('createOutfit should create an outfit', async () => {
// @ts-ignore
const { outfitService } = await import(`./outfit?v=${Date.now()}`)
const mockOutfit = { id: '1', name: 'New Outfit', equipments: [] }
mockPrisma.outfit.create.mockResolvedValue(mockOutfit)
const result = await outfitService.createOutfit({
name: 'New Outfit',
prompt: 'Test prompt',
equipmentIds: ['eq1', 'eq2'],
})
expect(result).toEqual(mockOutfit)
expect(mockPrisma.outfit.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
name: 'New Outfit',
prompt: 'Test prompt',
equipments: {
create: [{ equipmentId: 'eq1' }, { equipmentId: 'eq2' }],
},
}),
}),
)
})
it('updateOutfit should update an outfit', async () => {
// @ts-ignore
const { outfitService } = await import(`./outfit?v=${Date.now()}`)
const mockOutfit = { id: '1', name: 'Updated Outfit', equipments: [] }
mockPrisma.outfit.update.mockResolvedValue(mockOutfit)
const result = await outfitService.updateOutfit('1', {
name: 'Updated Outfit',
equipmentIds: ['eq3'],
})
expect(result).toEqual(mockOutfit)
expect(mockPrisma.outfit.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: '1' },
data: expect.objectContaining({
name: 'Updated Outfit',
equipments: {
deleteMany: {},
create: [{ equipmentId: 'eq3' }],
},
}),
}),
)
})
it('deleteOutfit should delete an outfit', async () => {
// @ts-ignore
const { outfitService } = await import(`./outfit?v=${Date.now()}`)
mockPrisma.outfit.delete.mockResolvedValue({ id: '1' })
await outfitService.deleteOutfit('1')
expect(mockPrisma.outfit.delete).toHaveBeenCalledWith({
where: { id: '1' },
})
})
})
+97
View File
@@ -0,0 +1,97 @@
import { prisma } from '../db'
import { ulid } from 'ulidx'
import { Prisma } from '../generated/prisma/client'
export class OutfitService {
async listOutfits(equipmentId?: string) {
const where: Prisma.OutfitWhereInput = {}
if (equipmentId) {
where.equipments = {
some: {
equipmentId,
},
}
}
return prisma.outfit.findMany({
where,
include: {
equipments: {
include: {
equipment: {
include: {
image: true,
},
},
},
},
},
orderBy: {
createdAt: 'desc',
},
})
}
async createOutfit(data: { name: string; prompt?: string; equipmentIds: string[] }) {
const id = ulid()
return prisma.outfit.create({
data: {
id,
name: data.name,
prompt: data.prompt,
equipments: {
create: data.equipmentIds.map((eid) => ({
equipmentId: eid,
})),
},
},
include: {
equipments: {
include: {
equipment: true,
},
},
},
})
}
async updateOutfit(
id: string,
data: { name?: string; prompt?: string; equipmentIds?: string[] },
) {
const updateData: Prisma.OutfitUpdateInput = {}
if (data.name !== undefined) updateData.name = data.name
if (data.prompt !== undefined) updateData.prompt = data.prompt
if (data.equipmentIds) {
updateData.equipments = {
deleteMany: {},
create: data.equipmentIds.map((eid) => ({
equipmentId: eid,
})),
}
}
return prisma.outfit.update({
where: { id },
data: updateData,
include: {
equipments: {
include: {
equipment: true,
},
},
},
})
}
async deleteOutfit(id: string) {
return prisma.outfit.delete({
where: { id },
})
}
}
export const outfitService = new OutfitService()
+203 -157
View File
@@ -9,10 +9,14 @@ import { Label } from './ui/label'
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'
import { Loader2, X, Check, Download, Save } from 'lucide-react'
import { LoadOutfitDialog } from './LoadOutfitDialog'
import { SaveOutfitDialog } from './SaveOutfitDialog'
import { toast } from 'sonner'
type EquipmentResponse = InferResponseType<typeof client.equipments.$get>
type EquipmentItem = EquipmentResponse['items'][number]
type OutfitItem = InferResponseType<typeof client.outfits.$get>[number]
interface CreateLookDialogProps {
open: boolean
@@ -35,6 +39,9 @@ export const CreateLookDialog: React.FC<CreateLookDialogProps> = ({
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [loadOutfitOpen, setLoadOutfitOpen] = useState(false)
const [saveOutfitOpen, setSaveOutfitOpen] = useState(false)
useEffect(() => {
if (open) {
fetchItems()
@@ -110,174 +117,213 @@ export const CreateLookDialog: React.FC<CreateLookDialogProps> = ({
}
}
const handleOutfitSelected = (outfit: OutfitItem) => {
const equipments = outfit.equipments.map((oe) => oe.equipment)
setSelectedEquipments(equipments)
if (outfit.prompt) {
setUserPrompt(outfit.prompt)
}
toast.success(`Loaded outfit: ${outfit.name}`)
}
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>
<>
<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 flex flex-row items-center justify-between space-y-0">
<div>
<DialogTitle className="text-xl font-black uppercase tracking-tighter text-stone-800">
Select Equipment
</DialogTitle>
<DialogDescription>Choose items to equip on your character.</DialogDescription>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => setLoadOutfitOpen(true)}>
<Download className="w-4 h-4 mr-2" /> Load Outfit
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setSaveOutfitOpen(true)}
disabled={selectedEquipments.length === 0}
>
<Save className="w-4 h-4 mr-2" /> Save Outfit
</Button>
</div>
</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>
{/* 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',
)}
</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>
onClick={() => toggleCategory(cat.main_category)}
>
{cat.main_category}
</Badge>
)
})}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</div>
{/* User Prompt Area */}
<div className="px-6 py-4 border-t border-stone-200 bg-white shrink-0">
<Label
htmlFor="user-prompt"
className="text-xs font-bold text-stone-700 uppercase mb-2 block"
>
Extra Instructions (Optional)
</Label>
<Textarea
id="user-prompt"
placeholder="E.g. The sword should be worn on the back..."
className="resize-none bg-stone-50 border-stone-200 focus:border-stone-400"
value={userPrompt}
onChange={(e) => setUserPrompt(e.target.value)}
/>
</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-[150px] 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-[150px] h-[150px] shrink-0 bg-stone-50 rounded-lg border border-stone-200 flex items-center justify-center group"
>
{/* 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 p-2"
className="max-w-full max-h-full object-contain"
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>
{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>
<ScrollBar orientation="horizontal" />
</ScrollArea>
<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>
<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-[150px] min-w-[120px]"
disabled={submitting || selectedEquipments.length === 0}
onClick={handleCreate}
>
{submitting ? <Loader2 className="w-5 h-5 animate-spin" /> : 'Generate'}
</Button>
{/* User Prompt Area */}
<div className="px-6 py-4 border-t border-stone-200 bg-white shrink-0">
<Label
htmlFor="user-prompt"
className="text-xs font-bold text-stone-700 uppercase mb-2 block"
>
Extra Instructions (Optional)
</Label>
<Textarea
id="user-prompt"
placeholder="E.g. The sword should be worn on the back..."
className="resize-none bg-stone-50 border-stone-200 focus:border-stone-400"
value={userPrompt}
onChange={(e) => setUserPrompt(e.target.value)}
/>
</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-[150px] 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-[150px] h-[150px] 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-[150px] 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>
</div>
</DialogContent>
</Dialog>
</DialogContent>
</Dialog>
<LoadOutfitDialog
open={loadOutfitOpen}
onOpenChange={setLoadOutfitOpen}
onSelect={handleOutfitSelected}
/>
<SaveOutfitDialog
open={saveOutfitOpen}
onOpenChange={setSaveOutfitOpen}
selectedEquipments={selectedEquipments}
prompt={userPrompt}
onSuccess={() => toast.success('Outfit saved successfully')}
/>
</>
)
}
+313
View File
@@ -0,0 +1,313 @@
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 { Input } from './ui/input'
import { Label } from './ui/label'
import { ScrollArea, ScrollBar } from './ui/scroll-area'
import { EQUIPMENT_CATEGORIES } from '../../lib/categories'
import { cn } from '../lib/utils'
import { Loader2, X, Check, Save } from 'lucide-react'
type EquipmentResponse = InferResponseType<typeof client.equipments.$get>
type EquipmentItem = EquipmentResponse['items'][number]
interface CreateOutfitDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onSuccess: () => void
}
export const CreateOutfitDialog: React.FC<CreateOutfitDialogProps> = ({
open,
onOpenChange,
onSuccess,
}) => {
const [items, setItems] = useState<EquipmentItem[]>([])
const [loading, setLoading] = useState(true)
const [selectedCategories, setSelectedCategories] = useState<string[]>([])
const [selectedEquipments, setSelectedEquipments] = useState<EquipmentItem[]>([])
const [outfitName, setOutfitName] = useState('')
const [outfitPrompt, setOutfitPrompt] = useState('')
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (open) {
fetchItems()
setSelectedEquipments([])
setOutfitName('')
setOutfitPrompt('')
setError(null)
}
}, [open])
useEffect(() => {
if (open) fetchItems()
}, [selectedCategories])
const fetchItems = async () => {
setLoading(true)
try {
const query: { limit: string; category?: string[] } = {
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 handleSave = async () => {
if (selectedEquipments.length === 0) {
setError('Please select at least one equipment.')
return
}
if (!outfitName.trim()) {
setError('Please enter an outfit name.')
return
}
setSubmitting(true)
setError(null)
try {
const res = await client.outfits.$post({
json: {
name: outfitName.trim(),
prompt: outfitPrompt.trim() || undefined,
equipmentIds: selectedEquipments.map((e) => e.id),
},
})
if (!res.ok) {
throw new Error('Failed to create outfit')
}
onSuccess()
onOpenChange(false)
} 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">
Create Outfit
</DialogTitle>
<DialogDescription>Select equipment and save as a reusable outfit.</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 - Grid */}
<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>
{/* Outfit Details Form */}
<div className="px-6 py-4 border-t border-stone-200 bg-white shrink-0 grid grid-cols-2 gap-4">
<div>
<Label
htmlFor="outfit-name"
className="text-xs font-bold text-stone-700 uppercase mb-2 block"
>
Name
</Label>
<Input
id="outfit-name"
value={outfitName}
onChange={(e) => setOutfitName(e.target.value)}
placeholder="e.g. Summer Beach Set"
/>
</div>
<div>
<Label
htmlFor="outfit-prompt"
className="text-xs font-bold text-stone-700 uppercase mb-2 block"
>
Prompt (Optional)
</Label>
<Input
id="outfit-prompt"
value={outfitPrompt}
onChange={(e) => setOutfitPrompt(e.target.value)}
placeholder="Extra prompt details..."
/>
</div>
</div>
{/* Bottom Dock */}
<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-4 border-2 border-dashed border-stone-100 rounded-xl">
Select items above.
</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>
))
)}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
<div className="flex items-center pl-4 border-l border-stone-100">
<Button
size="lg"
className="bg-stone-900 hover:bg-stone-800 text-white shadow-lg border-0 h-full max-h-[100px] min-w-[120px]"
disabled={submitting || selectedEquipments.length === 0 || !outfitName.trim()}
onClick={handleSave}
>
{submitting ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<span className="flex items-center gap-2">
<Save className="w-4 h-4" /> Save
</span>
)}
</Button>
</div>
</div>
</div>
</div>
</DialogContent>
</Dialog>
)
}
+114
View File
@@ -0,0 +1,114 @@
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 { Loader2 } from 'lucide-react'
type OutfitResponse = InferResponseType<typeof client.outfits.$get>
type OutfitItem = OutfitResponse[number]
interface LoadOutfitDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onSelect: (outfit: OutfitItem) => void
}
export const LoadOutfitDialog: React.FC<LoadOutfitDialogProps> = ({
open,
onOpenChange,
onSelect,
}) => {
const [outfits, setOutfits] = useState<OutfitItem[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
if (open) {
fetchOutfits()
}
}, [open])
const fetchOutfits = async () => {
setLoading(true)
try {
const res = await client.outfits.$get({ query: {} })
if (res.ok) {
const data = await res.json()
setOutfits(data)
}
} catch (e) {
console.error('Failed to load outfits', e)
} finally {
setLoading(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl h-[70vh] 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">
Load Outfit
</DialogTitle>
<DialogDescription>Select a saved outfit to load its configuration.</DialogDescription>
</DialogHeader>
<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>
) : outfits.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-stone-400">
<p>No saved outfits found.</p>
</div>
) : (
<div className="flex flex-col gap-3">
{outfits.map((outfit) => (
<div
key={outfit.id}
className="bg-white rounded-xl border border-stone-200 p-4 flex items-center justify-between cursor-pointer hover:border-amber-500 hover:shadow-md transition-all"
onClick={() => {
onSelect(outfit)
onOpenChange(false)
}}
>
<div className="flex-1">
<h3 className="font-bold text-stone-800 mb-1">{outfit.name}</h3>
<div className="flex gap-2 items-center">
<div className="flex -space-x-2 overflow-hidden">
{outfit.equipments.slice(0, 5).map((eq) => (
<div
key={eq.equipment.id}
className="relative inline-block w-6 h-6 rounded-full border border-white bg-stone-100 overflow-hidden"
>
<img
src={
eq.equipment.image?.path ? `/files/${eq.equipment.image.path}` : ''
}
className="w-full h-full object-cover"
alt={eq.equipment.name}
/>
</div>
))}
{outfit.equipments.length > 5 && (
<div className="relative inline-block w-6 h-6 rounded-full border border-white bg-stone-100 flex items-center justify-center text-[8px] font-bold text-stone-500">
+{outfit.equipments.length - 5}
</div>
)}
</div>
{outfit.prompt && (
<span className="text-xs text-stone-400 truncate max-w-[200px] border-l border-stone-200 pl-2 ml-2">
{outfit.prompt}
</span>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
</DialogContent>
</Dialog>
)
}
+106
View File
@@ -0,0 +1,106 @@
import React, { useState } 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 { Input } from './ui/input'
import { Label } from './ui/label'
import { Loader2 } from 'lucide-react'
type EquipmentResponse = InferResponseType<typeof client.equipments.$get>
type EquipmentItem = EquipmentResponse['items'][number]
interface SaveOutfitDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
selectedEquipments: EquipmentItem[]
prompt: string
onSuccess: () => void
}
export const SaveOutfitDialog: React.FC<SaveOutfitDialogProps> = ({
open,
onOpenChange,
selectedEquipments,
prompt,
onSuccess,
}) => {
const [outfitName, setOutfitName] = useState('')
const [outfitPrompt, setOutfitPrompt] = useState(prompt)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSave = async () => {
if (!outfitName.trim()) {
setError('Please enter an outfit name.')
return
}
setSubmitting(true)
setError(null)
try {
const res = await client.outfits.$post({
json: {
name: outfitName.trim(),
prompt: outfitPrompt.trim() || undefined,
equipmentIds: selectedEquipments.map((e) => e.id),
},
})
if (!res.ok) {
throw new Error('Failed to create outfit')
}
onSuccess()
onOpenChange(false)
} 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-md bg-white">
<DialogHeader>
<DialogTitle>Save Outfit</DialogTitle>
<DialogDescription>Save current selection as a reusable outfit.</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 py-4">
<div>
<Label htmlFor="save-outfit-name">Name</Label>
<Input
id="save-outfit-name"
value={outfitName}
onChange={(e) => setOutfitName(e.target.value)}
placeholder="e.g. Combat Gear"
/>
</div>
<div>
<Label htmlFor="save-outfit-prompt">Prompt (Optional)</Label>
<Input
id="save-outfit-prompt"
value={outfitPrompt}
onChange={(e) => setOutfitPrompt(e.target.value)}
placeholder="Extra prompt details..."
/>
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSave} disabled={submitting || !outfitName.trim()}>
{submitting ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Save'}
</Button>
</div>
</DialogContent>
</Dialog>
)
}
+174 -44
View File
@@ -1,8 +1,10 @@
import { InferResponseType } from 'hono/client'
import { Hexagon, Plus } from 'lucide-react'
import { Hexagon, Plus, Trash2 } from 'lucide-react'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { EQUIPMENT_CATEGORIES } from '../../lib/categories'
import { client } from '../client'
import { CreateOutfitDialog } from '../components/CreateOutfitDialog'
import { ExtractorDialog } from '../components/ExtractorDialog'
import { VibeCard } from '../components/VibeCard'
import { Badge } from '../components/ui/badge'
@@ -12,6 +14,8 @@ import { cn } from '../lib/utils'
type EquipmentResponse = InferResponseType<typeof client.equipments.$get>
type EquipmentItem = EquipmentResponse['items'][number]
type OutfitResponse = InferResponseType<typeof client.outfits.$get>
type OutfitItem = OutfitResponse[number]
const getEquipmentColor = (name: string) => {
const colors = [
@@ -28,13 +32,20 @@ const getEquipmentColor = (name: string) => {
export default function Equipments() {
const [items, setItems] = useState<EquipmentItem[]>([])
const [outfits, setOutfits] = useState<OutfitItem[]>([])
const [loading, setLoading] = useState(true)
const [selectedCategories, setSelectedCategories] = useState<string[]>([])
const [extractorOpen, setExtractorOpen] = useState(false)
const [createOutfitOpen, setCreateOutfitOpen] = useState(false)
const [viewMode, setViewMode] = useState<'equipments' | 'outfits'>('equipments')
useEffect(() => {
fetchItems()
}, [selectedCategories])
if (viewMode === 'equipments') {
fetchItems()
} else {
fetchOutfits()
}
}, [viewMode, selectedCategories])
const fetchItems = async () => {
setLoading(true)
@@ -61,6 +72,35 @@ export default function Equipments() {
}
}
const fetchOutfits = async () => {
setLoading(true)
try {
const res = await client.outfits.$get({ query: {} })
if (res.ok) {
const data = await res.json()
setOutfits(data)
}
} catch (e) {
console.error('Failed to load outfits', e)
} finally {
setLoading(false)
}
}
const handleDeleteOutfit = async (id: string) => {
if (!confirm('Are you sure you want to delete this outfit?')) return
try {
const res = await client.outfits[':id'].$delete({ param: { id } })
if (res.ok) {
toast.success('Outfit deleted')
fetchOutfits()
}
} catch (e) {
console.error(e)
toast.error('Failed to delete outfit')
}
}
const toggleCategory = (category: string) => {
setSelectedCategories((prev) =>
prev.includes(category) ? prev.filter((c) => c !== category) : [...prev, category],
@@ -77,63 +117,146 @@ export default function Equipments() {
</div>
<h1 className="text-3xl md:text-5xl font-display font-black uppercase text-slate-900 tracking-tighter">
<span className="text-transparent bg-clip-text bg-gradient-to-r from-cyan-600 to-blue-600">
Equipments
{viewMode === 'equipments' ? 'Equipments' : 'Outfits'}
</span>
</h1>
</div>
<div className="flex items-center gap-4">
<Button onClick={() => setExtractorOpen(true)}>
<Plus className="mr-2 h-4 w-4" /> Extract Equipment
</Button>
<div className="bg-slate-100 p-1 rounded-lg flex gap-1">
<button
onClick={() => setViewMode('equipments')}
className={cn(
'px-3 py-1.5 text-xs font-bold uppercase rounded-md transition-all',
viewMode === 'equipments'
? 'bg-white text-cyan-600 shadow-sm'
: 'text-slate-400 hover:text-slate-600',
)}
>
Equipments
</button>
<button
onClick={() => setViewMode('outfits')}
className={cn(
'px-3 py-1.5 text-xs font-bold uppercase rounded-md transition-all',
viewMode === 'outfits'
? 'bg-white text-cyan-600 shadow-sm'
: 'text-slate-400 hover:text-slate-600',
)}
>
Outfits
</button>
</div>
{viewMode === 'equipments' ? (
<Button onClick={() => setExtractorOpen(true)}>
<Plus className="mr-2 h-4 w-4" /> Extract Equipment
</Button>
) : (
<Button onClick={() => setCreateOutfitOpen(true)}>
<Plus className="mr-2 h-4 w-4" /> Create Outfit
</Button>
)}
</div>
</div>
{/* Category Filter */}
<div className="mb-8 animate-fade-in-down delay-100">
<ScrollArea className="w-full whitespace-nowrap">
<div className="flex w-max space-x-2 p-1">
{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-4 py-2 text-sm transition-all hover:scale-105 active:scale-95 select-none rounded-none clip-path-slant',
isSelected
? 'bg-cyan-600 text-white border-cyan-600 hover:bg-cyan-700'
: 'bg-white text-slate-600 border-slate-200 hover:bg-slate-50 hover:border-slate-300',
)}
onClick={() => toggleCategory(cat.main_category)}
>
{cat.main_category}
</Badge>
)
})}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</div>
{viewMode === 'equipments' && (
<div className="mb-8 animate-fade-in-down delay-100">
<ScrollArea className="w-full whitespace-nowrap">
<div className="flex w-max space-x-2 p-1">
{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-4 py-2 text-sm transition-all hover:scale-105 active:scale-95 select-none rounded-none clip-path-slant',
isSelected
? 'bg-cyan-600 text-white border-cyan-600 hover:bg-cyan-700'
: 'bg-white text-slate-600 border-slate-200 hover:bg-slate-50 hover:border-slate-300',
)}
onClick={() => toggleCategory(cat.main_category)}
>
{cat.main_category}
</Badge>
)
})}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</div>
)}
{loading ? (
<div className="p-12 text-center text-slate-400 font-mono tracking-widest animate-pulse">
INITIALIZING...
</div>
) : items.length === 0 ? (
) : viewMode === 'equipments' ? (
items.length === 0 ? (
<div className="text-center p-12 bg-white clip-path-slant border border-slate-200">
<p className="text-slate-500 font-mono">No equipment found.</p>
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-6 max-w-[1920px] mx-auto w-full pb-10">
{items.map((item, index) => (
<VibeCard
key={item.id}
index={index}
name={item.name}
subtitle={item.category}
image={item.image?.path ? `/files/${item.image.path}` : ''}
color={getEquipmentColor(item.name)}
/>
))}
</div>
)
) : outfits.length === 0 ? (
<div className="text-center p-12 bg-white clip-path-slant border border-slate-200">
<p className="text-slate-500 font-mono">No equipment found.</p>
<p className="text-slate-500 font-mono">No outfits found.</p>
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-6 max-w-[1920px] mx-auto w-full pb-10">
{items.map((item, index) => (
<VibeCard
key={item.id}
index={index}
name={item.name}
subtitle={item.category}
image={item.image?.path ? `/files/${item.image.path}` : ''}
color={getEquipmentColor(item.name)}
/>
<div className="flex flex-col gap-4 max-w-[1200px] mx-auto w-full pb-10">
{outfits.map((outfit) => (
<div
key={outfit.id}
className="bg-white rounded-xl border border-slate-200 p-4 flex items-center justify-between shadow-sm hover:shadow-md transition-shadow"
>
<div className="flex-1">
<h3 className="font-bold text-slate-800 text-lg mb-2">{outfit.name}</h3>
<div className="flex gap-2 flex-wrap">
{outfit.equipments.map((eq) => (
<div
key={eq.equipment.id}
className="w-10 h-10 rounded-md border border-slate-200 bg-slate-50 overflow-hidden relative group"
title={eq.equipment.name}
>
<img
src={eq.equipment.image?.path ? `/files/${eq.equipment.image.path}` : ''}
alt={eq.equipment.name}
className="w-full h-full object-contain p-1"
/>
</div>
))}
{outfit.prompt && (
<Badge variant="outline" className="ml-2 h-auto self-center">
+ Prompt
</Badge>
)}
</div>
</div>
<div className="flex items-center gap-4 pl-4 border-l border-slate-100">
<Button
variant="ghost"
size="icon"
onClick={() => handleDeleteOutfit(outfit.id)}
className="text-red-500 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="w-5 h-5" />
</Button>
</div>
</div>
))}
</div>
)}
@@ -146,6 +269,13 @@ export default function Equipments() {
setExtractorOpen(false)
}}
/>
<CreateOutfitDialog
open={createOutfitOpen}
onOpenChange={setCreateOutfitOpen}
onSuccess={() => {
fetchOutfits()
}}
/>
<style>{`@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }`}</style>
</div>
)