Merge pull request #29 from Yiling-J/delete-functionality-and-ui-tweaks-5663815655053873953
Implement cascading deletion and UI tweaks
This commit is contained in:
21 files changed
+655
-107
No files matched your search
@@ -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
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -15,7 +15,7 @@ export class BackupService {
|
||||
gzip: false,
|
||||
cwd: 'data',
|
||||
},
|
||||
['.']
|
||||
['.'],
|
||||
).pipe(stream)
|
||||
|
||||
return stream
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -94,6 +94,9 @@ export class PoseService {
|
||||
await prisma.pose.delete({
|
||||
where: { id },
|
||||
})
|
||||
|
||||
// Delete image asset
|
||||
await assetService.deleteAsset(pose.imageId)
|
||||
}
|
||||
|
||||
getBuiltinPose(id: string) {
|
||||
|
||||
@@ -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...</>
|
||||
) : (
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user