diff --git a/src/api/generation.ts b/src/api/generation.ts index 58c7581..84ac796 100644 --- a/src/api/generation.ts +++ b/src/api/generation.ts @@ -46,5 +46,10 @@ const route = app return c.json({ error: e instanceof Error ? e.message : 'Unknown error' }, 500) } }) + .delete('/:id', async (c) => { + const id = c.req.param('id') + await generationService.deleteGeneration(id) + return c.json({ success: true }, 200) + }) export default route diff --git a/src/service/asset.test.ts b/src/service/asset.test.ts index 965ac21..753ebd9 100644 --- a/src/service/asset.test.ts +++ b/src/service/asset.test.ts @@ -16,8 +16,20 @@ mock.module('../db', () => ({ })) const mockFileService = { - saveFile: mock(() => Promise.resolve({ path: 'data/files/mock.webp', filename: 'mock.webp', mimeType: 'image/webp' })), - saveBuffer: mock(() => Promise.resolve({ path: 'data/files/mock.webp', filename: 'mock.webp', mimeType: 'image/webp' })), + saveFile: mock(() => + Promise.resolve({ + path: 'data/files/mock.webp', + filename: 'mock.webp', + mimeType: 'image/webp', + }), + ), + saveBuffer: mock(() => + Promise.resolve({ + path: 'data/files/mock.webp', + filename: 'mock.webp', + mimeType: 'image/webp', + }), + ), } mock.module('./file', () => ({ @@ -55,11 +67,13 @@ describe('AssetService', () => { // Check call arguments // Use expect.objectContaining for partial match const createCall = mockPrisma.asset.create.mock.calls[0][0] - expect(createCall.data).toEqual(expect.objectContaining({ + expect(createCall.data).toEqual( + expect.objectContaining({ type: 'image/webp', path: 'mock.webp', - id: 'mocked-ulid' - })) + id: 'mocked-ulid', + }), + ) expect(result.name).toBe(meta.name) }) diff --git a/src/service/asset.ts b/src/service/asset.ts index 378be40..cc4e9bf 100644 --- a/src/service/asset.ts +++ b/src/service/asset.ts @@ -56,6 +56,22 @@ export class AssetService { where: { id }, }) } + + async deleteAsset(id: string) { + const asset = await prisma.asset.findUnique({ + where: { id }, + }) + + if (!asset) return + + // Delete file + await fileService.deleteFile(asset.path) + + // Delete record + await prisma.asset.delete({ + where: { id }, + }) + } } export const assetService = new AssetService() diff --git a/src/service/backup.test.ts b/src/service/backup.test.ts index 2211fb5..29ea49f 100644 --- a/src/service/backup.test.ts +++ b/src/service/backup.test.ts @@ -38,7 +38,7 @@ mock.module('tar', () => ({ // Simulate async completion setTimeout(() => stream.emit('finish'), 10) return stream - } + }, })) // Import service after mocking @@ -86,7 +86,7 @@ describe('BackupService', () => { const stream = new PassThrough() setTimeout(() => stream.emit('error', new Error('Extraction failed')), 10) return stream - } + }, })) // Re-import service to pick up new mock diff --git a/src/service/backup.ts b/src/service/backup.ts index d115c5d..db13366 100644 --- a/src/service/backup.ts +++ b/src/service/backup.ts @@ -15,7 +15,7 @@ export class BackupService { gzip: false, cwd: 'data', }, - ['.'] + ['.'], ).pipe(stream) return stream diff --git a/src/service/character.ts b/src/service/character.ts index 09c5ac0..106dfc2 100644 --- a/src/service/character.ts +++ b/src/service/character.ts @@ -1,5 +1,7 @@ -import { prisma } from '../db' import { ulid } from 'ulidx' +import { prisma } from '../db' +import { assetService } from './asset' +import { generationService } from './generation' export class CharacterService { async createCharacter(data: { name: string; description?: string; imageId: string }) { @@ -60,9 +62,27 @@ export class CharacterService { } async deleteCharacter(id: string) { - return prisma.character.delete({ + const character = await prisma.character.findUnique({ + where: { id }, + include: { + generations: true, + }, + }) + + if (!character) return + + // 1. Delete all generations (looks) + for (const gen of character.generations) { + await generationService.deleteGeneration(gen.id) + } + + // 2. Delete character record + await prisma.character.delete({ where: { id }, }) + + // 3. Delete character image asset + await assetService.deleteAsset(character.imageId) } } diff --git a/src/service/expression.ts b/src/service/expression.ts index 650d663..a8819dd 100644 --- a/src/service/expression.ts +++ b/src/service/expression.ts @@ -98,6 +98,9 @@ export class ExpressionService { await prisma.expression.delete({ where: { id }, }) + + // Delete image asset + await assetService.deleteAsset(expression.imageId) } getBuiltinExpression(id: string) { diff --git a/src/service/file.test.ts b/src/service/file.test.ts index df34d5d..e0f6acf 100644 --- a/src/service/file.test.ts +++ b/src/service/file.test.ts @@ -56,10 +56,10 @@ describe('FileService', () => { // Check if sharp resize was called expect(sharpChain.resize).toHaveBeenCalled() expect(sharpChain.resize).toHaveBeenCalledWith({ - width: 2048, - height: 2048, - fit: 'outside', - withoutEnlargement: true, + width: 2048, + height: 2048, + fit: 'outside', + withoutEnlargement: true, }) }) @@ -73,19 +73,23 @@ describe('FileService', () => { }) test('saveBuffer saves a buffer and converts to webp if mimeType is image', async () => { - const result = await fileService.saveBuffer(VALID_PNG_BUFFER, 'test.png', 'image/png') + const result = await fileService.saveBuffer(VALID_PNG_BUFFER, 'test.png', 'image/png') - expect(result.filename).toBe('test-ulid.webp') - expect(result.mimeType).toBe('image/webp') - expect(mockWrite).toHaveBeenCalled() - expect(sharpChain.resize).toHaveBeenCalled() + expect(result.filename).toBe('test-ulid.webp') + expect(result.mimeType).toBe('image/webp') + expect(mockWrite).toHaveBeenCalled() + expect(sharpChain.resize).toHaveBeenCalled() }) test('saveBuffer saves original if not image', async () => { - const result = await fileService.saveBuffer(VALID_PNG_BUFFER, 'test.bin', 'application/octet-stream') + const result = await fileService.saveBuffer( + VALID_PNG_BUFFER, + 'test.bin', + 'application/octet-stream', + ) - expect(result.filename).toBe('test-ulid.bin') - expect(result.mimeType).toBe('application/octet-stream') - expect(mockWrite).toHaveBeenCalled() + expect(result.filename).toBe('test-ulid.bin') + expect(result.mimeType).toBe('application/octet-stream') + expect(mockWrite).toHaveBeenCalled() }) }) diff --git a/src/service/file.ts b/src/service/file.ts index 96232c6..405f49a 100644 --- a/src/service/file.ts +++ b/src/service/file.ts @@ -1,6 +1,7 @@ +import { unlink } from 'node:fs/promises' import { join } from 'path' -import { ulid } from 'ulidx' import sharp from 'sharp' +import { ulid } from 'ulidx' // import { writeFile as bunWriteFile } from 'bun' export class FileService { @@ -129,6 +130,24 @@ export class FileService { return { path, filename } } + + /** + * Deletes a file from the data/files directory. + */ + async deleteFile(filename: string): Promise { + const path = join('data/files', filename) + try { + // Check if file exists + const file = Bun.file(path) + if (await file.exists()) { + await unlink(path) + } + } catch (error) { + console.warn(`Failed to delete file: ${path}`, error) + // We don't throw here to allow the database deletion to proceed + // even if file deletion fails (e.g. file already gone) + } + } } export const fileService = new FileService() diff --git a/src/service/generation.ts b/src/service/generation.ts index ba02f15..325d409 100644 --- a/src/service/generation.ts +++ b/src/service/generation.ts @@ -292,6 +292,27 @@ category: ${eq.category} return { total, items, page, limit, totalPages: Math.ceil(total / limit) } } + + async deleteGeneration(id: string) { + const generation = await prisma.generation.findUnique({ + where: { id }, + }) + + if (!generation) return + + // Delete relation records first (not strictly needed with cascading deletes, but safe) + await prisma.generationEquipment.deleteMany({ + where: { generationId: id }, + }) + + // Delete generation record + await prisma.generation.delete({ + where: { id }, + }) + + // Delete image asset (this also deletes the file) + await assetService.deleteAsset(generation.imageId) + } } export const generationService = new GenerationService() diff --git a/src/service/pose.test.ts b/src/service/pose.test.ts index 1facd99..6d431a1 100644 --- a/src/service/pose.test.ts +++ b/src/service/pose.test.ts @@ -18,6 +18,7 @@ mock.module('../db', () => ({ mock.module('./asset', () => ({ assetService: { createAsset: mock(), + deleteAsset: mock(), }, })) @@ -75,11 +76,12 @@ describe('PoseService', () => { const { poseService } = await import(`./pose?v=${Date.now()}`) const customId = 'custom1' - ;(prisma.pose.findUnique as any).mockResolvedValue({ id: customId }) + ;(prisma.pose.findUnique as any).mockResolvedValue({ id: customId, imageId: 'asset1' }) ;(prisma.pose.delete as any).mockResolvedValue({ id: customId }) await poseService.deletePose(customId) expect(prisma.pose.delete).toHaveBeenCalledWith({ where: { id: customId } }) + expect(assetService.deleteAsset).toHaveBeenCalledWith('asset1') }) }) diff --git a/src/service/pose.ts b/src/service/pose.ts index b33f718..20129c1 100644 --- a/src/service/pose.ts +++ b/src/service/pose.ts @@ -94,6 +94,9 @@ export class PoseService { await prisma.pose.delete({ where: { id }, }) + + // Delete image asset + await assetService.deleteAsset(pose.imageId) } getBuiltinPose(id: string) { diff --git a/src/ui/components/BackupRestoreDialog.tsx b/src/ui/components/BackupRestoreDialog.tsx index f0bf883..c5a6b90 100644 --- a/src/ui/components/BackupRestoreDialog.tsx +++ b/src/ui/components/BackupRestoreDialog.tsx @@ -2,13 +2,7 @@ import { Archive, Download, RefreshCw, Upload } from 'lucide-react' import { useState } from 'react' import { toast } from 'sonner' import { Button } from './ui/button' -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from './ui/dialog' +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from './ui/dialog' import { Separator } from './ui/separator' interface BackupRestoreDialogProps { @@ -27,7 +21,9 @@ export function BackupRestoreDialog({ open, onOpenChange }: BackupRestoreDialogP const file = event.target.files?.[0] if (!file) return - if (!confirm('Warning: This will overwrite all existing data. Are you sure you want to proceed?')) { + if ( + !confirm('Warning: This will overwrite all existing data. Are you sure you want to proceed?') + ) { event.target.value = '' // Reset input return } @@ -39,7 +35,7 @@ export function BackupRestoreDialog({ open, onOpenChange }: BackupRestoreDialogP const response = await fetch('/api/restore', { method: 'POST', headers: { - 'Content-Type': 'application/x-tar', + 'Content-Type': 'application/x-tar', }, body: file, }) @@ -66,9 +62,7 @@ export function BackupRestoreDialog({ open, onOpenChange }: BackupRestoreDialogP Backup & Restore - - Manage your data backup and restoration. - + Manage your data backup and restoration.
@@ -96,7 +90,10 @@ export function BackupRestoreDialog({ open, onOpenChange }: BackupRestoreDialogP Restore

- Restore data from a backup file. Warning: This will overwrite all current data. + Restore data from a backup file.{' '} + + Warning: This will overwrite all current data. +

- + + + + + ) +} diff --git a/src/ui/components/VibeCard.tsx b/src/ui/components/VibeCard.tsx index d6fde65..2cfb74c 100644 --- a/src/ui/components/VibeCard.tsx +++ b/src/ui/components/VibeCard.tsx @@ -1,4 +1,20 @@ +import { MoreHorizontal } from 'lucide-react' import React from 'react' +import { cn } from '../lib/utils' +import { Button } from './ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from './ui/dropdown-menu' + +export interface VibeCardAction { + name: string + onClick: () => void + variant?: 'default' | 'destructive' + icon?: React.ReactNode +} interface VibeCardProps { image?: string @@ -7,6 +23,7 @@ interface VibeCardProps { color: string onClick?: () => void index: number + actions?: VibeCardAction[] } export const VibeCard: React.FC = ({ @@ -16,6 +33,7 @@ export const VibeCard: React.FC = ({ color, onClick, index, + actions = [], }) => { return (
= ({ {/* 3. Content Layer - Stays fixed size relative to parent, creating the cutout effect */}
+ {actions.length > 0 && ( +
e.stopPropagation()} + > + + + + + + {actions.map((action, i) => ( + { + e.stopPropagation() + action.onClick() + }} + className={cn( + action.variant === 'destructive' && + 'text-red-600 focus:text-red-600 focus:bg-red-50', + )} + > + {action.icon && {action.icon}} + {action.name} + + ))} + + +
+ )}
) { + return +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function DropdownMenuItem({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +function DropdownMenuSub({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuPortal, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} + +const DropdownMenuGroup = DropdownMenuPrimitive.Group +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup diff --git a/src/ui/pages/Characters.tsx b/src/ui/pages/Characters.tsx index 60bb0db..d8a561e 100644 --- a/src/ui/pages/Characters.tsx +++ b/src/ui/pages/Characters.tsx @@ -1,9 +1,11 @@ import { InferResponseType } from 'hono/client' -import { Plus } from 'lucide-react' +import { Plus, Trash2 } from 'lucide-react' import { useState } from 'react' import { useNavigate } from 'react-router-dom' +import { toast } from 'sonner' import { client } from '../client' import { CreateCharacterDialog } from '../components/CreateCharacterDialog' +import { DeleteConfirmDialog } from '../components/DeleteConfirmDialog' import { PageHeader } from '../components/PageHeader' import { VibeCard } from '../components/VibeCard' import { Button } from '../components/ui/button' @@ -27,9 +29,16 @@ const getCharacterColor = (name: string) => { export default function Characters() { const [createOpen, setCreateOpen] = useState(false) + const [deleteId, setDeleteId] = useState(null) const navigate = useNavigate() - const { items, loading, ref, reset } = useInfiniteScroll({ + const { + items, + loading, + ref, + reset, + setItems: setCharacters, + } = useInfiniteScroll({ fetchData: async (page, limit) => { const res = await client.characters.$get({ query: { @@ -75,6 +84,14 @@ export default function Characters() { image={char.image?.path ? `/files/${char.image.path}` : ''} color={getCharacterColor(char.name)} onClick={() => navigate(`/characters/${char.id}/looks`)} + actions={[ + { + name: 'Delete', + onClick: () => setDeleteId(char.id), + variant: 'destructive', + icon: , + }, + ]} /> ))}
@@ -88,6 +105,27 @@ export default function Characters() {
+ !open && setDeleteId(null)} + title="Delete Character" + description="Are you sure you want to delete this character? This action cannot be undone and will delete all associated looks and files." + onConfirm={async () => { + if (!deleteId) return + try { + const res = await client.characters[':id'].$delete({ param: { id: deleteId } }) + if (res.ok) { + toast.success('Character deleted') + setCharacters((prev) => prev.filter((c) => c.id !== deleteId)) + } else { + toast.error('Failed to delete character') + } + } catch (e) { + console.error(e) + toast.error('Failed to delete character') + } + }} + />
) diff --git a/src/ui/pages/Expressions.tsx b/src/ui/pages/Expressions.tsx index 42e5b09..8cd078f 100644 --- a/src/ui/pages/Expressions.tsx +++ b/src/ui/pages/Expressions.tsx @@ -4,6 +4,7 @@ import { useState } from 'react' import { toast } from 'sonner' import { client } from '../client' import { CreateExpressionDialog } from '../components/CreateExpressionDialog' +import { DeleteConfirmDialog } from '../components/DeleteConfirmDialog' import { PageHeader } from '../components/PageHeader' import { VibeCard } from '../components/VibeCard' import { Button } from '../components/ui/button' @@ -14,6 +15,7 @@ type ExpressionItem = ExpressionResponse[number] export default function Expressions() { const [createOpen, setCreateOpen] = useState(false) + const [deleteId, setDeleteId] = useState(null) const { items: expressions, @@ -37,24 +39,6 @@ export default function Expressions() { limit: 20, }) - const handleDelete = async (id: string) => { - if (!confirm('Are you sure you want to delete this expression?')) return - - try { - const res = await client.expressions[':id'].$delete({ param: { id } }) - if (res.ok) { - toast.success('Expression deleted') - setExpressions((prev) => prev.filter((e) => e.id !== id)) - } else { - const err = await res.json() - // @ts-ignore - toast.error('Failed to delete: ' + (err.error || 'Unknown error')) - } - } catch { - toast.error('Failed to delete expression') - } - } - return (
{}} + actions={ + expression.type === 'custom' + ? [ + { + name: 'Delete', + onClick: () => setDeleteId(expression.id), + variant: 'destructive', + icon: , + }, + ] + : [] + } /> - {expression.type === 'custom' && ( - - )}
))}
@@ -108,6 +93,28 @@ export default function Expressions() { + !open && setDeleteId(null)} + title="Delete Expression" + description="Are you sure you want to delete this expression? This action cannot be undone." + onConfirm={async () => { + if (!deleteId) return + try { + const res = await client.expressions[':id'].$delete({ param: { id: deleteId } }) + if (res.ok) { + toast.success('Expression deleted') + setExpressions((prev) => prev.filter((e) => e.id !== deleteId)) + } else { + const err = await res.json() + // @ts-ignore + toast.error('Failed to delete: ' + (err.error || 'Unknown error')) + } + } catch { + toast.error('Failed to delete expression') + } + }} + /> ) } diff --git a/src/ui/pages/FittingRoom.tsx b/src/ui/pages/FittingRoom.tsx index 7330db6..d3ea4cd 100644 --- a/src/ui/pages/FittingRoom.tsx +++ b/src/ui/pages/FittingRoom.tsx @@ -1,13 +1,21 @@ import { InferResponseType } from 'hono/client' -import { ArrowLeft, Plus } from 'lucide-react' +import { ArrowLeft, MoreHorizontal, Plus, Trash2 } from 'lucide-react' import { useEffect, useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' +import { toast } from 'sonner' import { client } from '../client' import { CreateLookDialog } from '../components/CreateLookDialog' +import { DeleteConfirmDialog } from '../components/DeleteConfirmDialog' import { LookDetailsDialog } from '../components/LookDetailsDialog' import { PageHeader } from '../components/PageHeader' import { Button } from '../components/ui/button' import { Card } from '../components/ui/card' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '../components/ui/dropdown-menu' // Types - Define locally to avoid complex type extraction if client inference is tricky // But we should try to use inference @@ -25,6 +33,7 @@ export default function FittingRoom() { const [createLookOpen, setCreateLookOpen] = useState(false) const [selectedLook, setSelectedLook] = useState(null) const [lookDetailsOpen, setLookDetailsOpen] = useState(false) + const [deleteLookId, setDeleteLookId] = useState(null) useEffect(() => { if (id) { @@ -158,6 +167,33 @@ export default function FittingRoom() { setLookDetailsOpen(true) }} > +
+ + + + + + { + e.stopPropagation() + setDeleteLookId(gen.id) + }} + className="text-red-600 focus:text-red-600 focus:bg-red-50" + > + + Delete + + + +
+ Generation + + !open && setDeleteLookId(null)} + title="Delete Look" + description="Are you sure you want to delete this look? This action cannot be undone and will delete the generated image." + onConfirm={async () => { + if (!deleteLookId) return + try { + const res = await client.generations[':id'].$delete({ param: { id: deleteLookId } }) + if (res.ok) { + toast.success('Look deleted') + setGenerations((prev) => prev.filter((g) => g.id !== deleteLookId)) + } else { + toast.error('Failed to delete look') + } + } catch (e) { + console.error(e) + toast.error('Failed to delete look') + } + }} + /> ) } diff --git a/src/ui/pages/Poses.tsx b/src/ui/pages/Poses.tsx index 73eb883..6e1e92d 100644 --- a/src/ui/pages/Poses.tsx +++ b/src/ui/pages/Poses.tsx @@ -4,6 +4,7 @@ import { useState } from 'react' import { toast } from 'sonner' import { client } from '../client' import { CreatePoseDialog } from '../components/CreatePoseDialog' +import { DeleteConfirmDialog } from '../components/DeleteConfirmDialog' import { PageHeader } from '../components/PageHeader' import { VibeCard } from '../components/VibeCard' import { Button } from '../components/ui/button' @@ -14,6 +15,7 @@ type PoseItem = PoseResponse[number] export default function Poses() { const [createOpen, setCreateOpen] = useState(false) + const [deleteId, setDeleteId] = useState(null) const { items: poses, @@ -37,24 +39,6 @@ export default function Poses() { limit: 20, }) - const handleDelete = async (id: string) => { - if (!confirm('Are you sure you want to delete this pose?')) return - - try { - const res = await client.poses[':id'].$delete({ param: { id } }) - if (res.ok) { - toast.success('Pose deleted') - setPoses((prev) => prev.filter((p) => p.id !== id)) - } else { - const err = await res.json() - // @ts-ignore - toast.error('Failed to delete: ' + (err.error || 'Unknown error')) - } - } catch { - toast.error('Failed to delete pose') - } - } - return (
{}} + actions={ + pose.type === 'custom' + ? [ + { + name: 'Delete', + onClick: () => setDeleteId(pose.id), + variant: 'destructive', + icon: , + }, + ] + : [] + } /> - {pose.type === 'custom' && ( - - )}
))} @@ -108,6 +93,28 @@ export default function Poses() { + !open && setDeleteId(null)} + title="Delete Pose" + description="Are you sure you want to delete this pose? This action cannot be undone." + onConfirm={async () => { + if (!deleteId) return + try { + const res = await client.poses[':id'].$delete({ param: { id: deleteId } }) + if (res.ok) { + toast.success('Pose deleted') + setPoses((prev) => prev.filter((p) => p.id !== deleteId)) + } else { + const err = await res.json() + // @ts-ignore + toast.error('Failed to delete: ' + (err.error || 'Unknown error')) + } + } catch { + toast.error('Failed to delete pose') + } + }} + /> ) }