Merge pull request #29 from Yiling-J/delete-functionality-and-ui-tweaks-5663815655053873953

Implement cascading deletion and UI tweaks
This commit is contained in:
Yiling-J authored and GitHub committed 2026-02-16 10:06:08 +08:00
commit d8cc081023
21 files changed
+655 -107

No files matched your search

+5
View File
@@ -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
+19 -5
View File
@@ -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)
})
+16
View File
@@ -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()
+2 -2
View File
@@ -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
+1 -1
View File
@@ -15,7 +15,7 @@ export class BackupService {
gzip: false,
cwd: 'data',
},
['.']
['.'],
).pipe(stream)
return stream
+22 -2
View File
@@ -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)
}
}
+3
View File
@@ -98,6 +98,9 @@ export class ExpressionService {
await prisma.expression.delete({
where: { id },
})
// Delete image asset
await assetService.deleteAsset(expression.imageId)
}
getBuiltinExpression(id: string) {
+17 -13
View File
@@ -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()
})
})
+20 -1
View File
@@ -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<void> {
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()
+21
View File
@@ -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()
+3 -1
View File
@@ -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')
})
})
+3
View File
@@ -94,6 +94,9 @@ export class PoseService {
await prisma.pose.delete({
where: { id },
})
// Delete image asset
await assetService.deleteAsset(pose.imageId)
}
getBuiltinPose(id: string) {
+11 -18
View File
@@ -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
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Backup & Restore</DialogTitle>
<DialogDescription>
Manage your data backup and restoration.
</DialogDescription>
<DialogDescription>Manage your data backup and restoration.</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-6 py-4">
@@ -96,7 +90,10 @@ export function BackupRestoreDialog({ open, onOpenChange }: BackupRestoreDialogP
<span>Restore</span>
</div>
<p className="text-sm text-muted-foreground">
Restore data from a backup file. <span className="font-bold text-destructive">Warning: This will overwrite all current data.</span>
Restore data from a backup file.{' '}
<span className="font-bold text-destructive">
Warning: This will overwrite all current data.
</span>
</p>
<div className="relative">
<input
@@ -106,11 +103,7 @@ export function BackupRestoreDialog({ open, onOpenChange }: BackupRestoreDialogP
disabled={isRestoring}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed"
/>
<Button
variant="destructive"
className="w-full"
disabled={isRestoring}
>
<Button variant="destructive" className="w-full" disabled={isRestoring}>
{isRestoring ? (
<>Restoring...</>
) : (
+1 -3
View File
@@ -127,9 +127,7 @@ export const CreateLookDialog: React.FC<CreateLookDialogProps> = ({
}, [selectedCategories, resetEquipment])
const toggleCategory = (category: string) => {
setSelectedCategories((prev) =>
prev.includes(category) ? prev.filter((c) => c !== category) : [...prev, category],
)
setSelectedCategories((prev) => (prev.includes(category) ? [] : [category]))
}
const toggleEquipment = (item: EquipmentItem) => {
+51
View File
@@ -0,0 +1,51 @@
import React from 'react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './ui/dialog'
import { Button } from './ui/button'
interface DeleteConfirmDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onConfirm: () => void
title: string
description: string
}
export const DeleteConfirmDialog: React.FC<DeleteConfirmDialogProps> = ({
open,
onOpenChange,
onConfirm,
title,
description,
}) => {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => {
onConfirm()
onOpenChange(false)
}}
>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
+54
View File
@@ -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<VibeCardProps> = ({
@@ -16,6 +33,7 @@ export const VibeCard: React.FC<VibeCardProps> = ({
color,
onClick,
index,
actions = [],
}) => {
return (
<div
@@ -34,6 +52,42 @@ export const VibeCard: React.FC<VibeCardProps> = ({
{/* 3. Content Layer - Stays fixed size relative to parent, creating the cutout effect */}
<div className="absolute inset-[1px] bg-slate-100 clip-path-slant overflow-hidden">
{actions.length > 0 && (
<div
className="absolute top-2 right-2 z-20 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-white bg-black/10 hover:bg-black/20 hover:text-white"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{actions.map((action, i) => (
<DropdownMenuItem
key={i}
onClick={(e) => {
e.stopPropagation()
action.onClick()
}}
className={cn(
action.variant === 'destructive' &&
'text-red-600 focus:text-red-600 focus:bg-red-50',
)}
>
{action.icon && <span className="mr-2 h-4 w-4">{action.icon}</span>}
{action.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
<div className="absolute inset-0 bg-slate-100">
<img
src={image}
+235
View File
@@ -0,0 +1,235 @@
"use client"
import * as React from "react"
import { Check, ChevronRight, Circle } from "lucide-react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { cn } from "@/ui/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
)
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuItem({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
inset && "pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest opacity-60",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
}
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
+40 -2
View File
@@ -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<string | null>(null)
const navigate = useNavigate()
const { items, loading, ref, reset } = useInfiniteScroll<Character>({
const {
items,
loading,
ref,
reset,
setItems: setCharacters,
} = useInfiniteScroll<Character>({
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: <Trash2 className="w-4 h-4" />,
},
]}
/>
))}
</div>
@@ -88,6 +105,27 @@ export default function Characters() {
</div>
<CreateCharacterDialog open={createOpen} onOpenChange={setCreateOpen} onSuccess={reset} />
<DeleteConfirmDialog
open={!!deleteId}
onOpenChange={(open) => !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')
}
}}
/>
<style>{`@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }`}</style>
</div>
)
+36 -29
View File
@@ -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<string | null>(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 (
<div className="w-full h-full p-4 pt-2 flex flex-col text-slate-900 relative">
<PageHeader
@@ -83,18 +67,19 @@ export default function Expressions() {
image={expression.imageUrl}
color={expression.type === 'builtin' ? '#10b981' : '#f59e0b'}
onClick={() => {}}
actions={
expression.type === 'custom'
? [
{
name: 'Delete',
onClick: () => setDeleteId(expression.id),
variant: 'destructive',
icon: <Trash2 className="w-4 h-4" />,
},
]
: []
}
/>
{expression.type === 'custom' && (
<button
onClick={(e) => {
e.stopPropagation()
handleDelete(expression.id)
}}
className="absolute top-2 right-2 p-2 bg-red-500 text-white rounded-full opacity-0 group-hover/container:opacity-100 transition-opacity z-10 hover:bg-red-600 shadow-md"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
))}
</div>
@@ -108,6 +93,28 @@ export default function Expressions() {
</div>
<CreateExpressionDialog open={createOpen} onOpenChange={setCreateOpen} onSuccess={reset} />
<DeleteConfirmDialog
open={!!deleteId}
onOpenChange={(open) => !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')
}
}}
/>
</div>
)
}
+59 -1
View File
@@ -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<GenerationItem | null>(null)
const [lookDetailsOpen, setLookDetailsOpen] = useState(false)
const [deleteLookId, setDeleteLookId] = useState<string | null>(null)
useEffect(() => {
if (id) {
@@ -158,6 +167,33 @@ export default function FittingRoom() {
setLookDetailsOpen(true)
}}
>
<div className="absolute top-2 right-2 z-20 opacity-0 group-hover:opacity-100 transition-opacity">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-white hover:bg-white/20 hover:text-white"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
setDeleteLookId(gen.id)
}}
className="text-red-600 focus:text-red-600 focus:bg-red-50"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<img
src={gen.image?.path ? `/files/${gen.image.path}` : ''}
alt="Generation"
@@ -205,6 +241,28 @@ export default function FittingRoom() {
onOpenChange={setLookDetailsOpen}
generation={selectedLook}
/>
<DeleteConfirmDialog
open={!!deleteLookId}
onOpenChange={(open) => !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')
}
}}
/>
</div>
)
}
+36 -29
View File
@@ -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<string | null>(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 (
<div className="w-full h-full p-4 pt-2 flex flex-col text-slate-900 relative">
<PageHeader
@@ -83,18 +67,19 @@ export default function Poses() {
image={pose.imageUrl}
color={pose.type === 'builtin' ? '#3b82f6' : '#f59e0b'}
onClick={() => {}}
actions={
pose.type === 'custom'
? [
{
name: 'Delete',
onClick: () => setDeleteId(pose.id),
variant: 'destructive',
icon: <Trash2 className="w-4 h-4" />,
},
]
: []
}
/>
{pose.type === 'custom' && (
<button
onClick={(e) => {
e.stopPropagation()
handleDelete(pose.id)
}}
className="absolute top-2 right-2 p-2 bg-red-500 text-white rounded-full opacity-0 group-hover/container:opacity-100 transition-opacity z-10 hover:bg-red-600 shadow-md"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
))}
</div>
@@ -108,6 +93,28 @@ export default function Poses() {
</div>
<CreatePoseDialog open={createOpen} onOpenChange={setCreateOpen} onSuccess={reset} />
<DeleteConfirmDialog
open={!!deleteId}
onOpenChange={(open) => !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')
}
}}
/>
</div>
)
}