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:
1 parent
d31e849e96
commit
a33545be6e
22 files changed
+4331
-207
No files matched your search
@@ -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
|
||||
);
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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' },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
@@ -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')}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user