Merge pull request #23 from Yiling-J/infinite-scroll-9407279652325460799
Implement infinite scroll and pagination (limit 20)
This commit is contained in:
18 files changed
+519
-314
No files matched your search
@@ -140,3 +140,14 @@ Instead, follow this workflow:
|
||||
## Known Issues
|
||||
|
||||
- `bun test` module mocking can be sticky across test files running in the same process/worker. Use the dynamic import workaround in Service tests.
|
||||
|
||||
## Infinite Scroll & Pagination
|
||||
|
||||
- **Frontend**: Use the `useInfiniteScroll` hook for all list UIs.
|
||||
- Located in `src/ui/hooks/use-infinite-scroll.ts`.
|
||||
- Pass a `fetchData` function that accepts `page` and `limit`.
|
||||
- **Backend**:
|
||||
- All list APIs must support pagination via `page` and `limit` query parameters.
|
||||
- The default `limit` should be **20**.
|
||||
- Services should implement pagination logic (Prisma `skip`/`take`).
|
||||
- For mixed lists (built-in + DB), implement custom pagination logic in the Service layer.
|
||||
@@ -29,6 +29,7 @@
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-intersection-observer": "^10.0.2",
|
||||
"react-router-dom": "^7.13.0",
|
||||
"sharp": "^0.34.5",
|
||||
"sonner": "^2.0.7",
|
||||
@@ -862,6 +863,8 @@
|
||||
|
||||
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
|
||||
|
||||
"react-intersection-observer": ["react-intersection-observer@10.0.2", "", { "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["react-dom"] }, "sha512-lAMzxVWrBko6SLd1jx6l84fVrzJu91hpxHlvD2as2Wec9mDCjdYXwc5xNOFBchpeBir0Y7AGBW+C/AYMa7CSFg=="],
|
||||
|
||||
"react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
|
||||
|
||||
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-intersection-observer": "^10.0.2",
|
||||
"react-router-dom": "^7.13.0",
|
||||
"sharp": "^0.34.5",
|
||||
"sonner": "^2.0.7",
|
||||
|
||||
@@ -30,7 +30,7 @@ describe('Character API', () => {
|
||||
|
||||
const res = await character.fetch(req)
|
||||
expect(res.status).toBe(200)
|
||||
expect(mockCharacterService.listCharacters).toHaveBeenCalledWith({ page: 1, limit: 10 })
|
||||
expect(mockCharacterService.listCharacters).toHaveBeenCalledWith({ page: 1, limit: 20 })
|
||||
})
|
||||
|
||||
it('POST / should create character', async () => {
|
||||
|
||||
@@ -7,7 +7,7 @@ const app = new Hono()
|
||||
|
||||
const listSchema = z.object({
|
||||
page: z.coerce.number().optional().default(1),
|
||||
limit: z.coerce.number().optional().default(10),
|
||||
limit: z.coerce.number().optional().default(20),
|
||||
})
|
||||
|
||||
const createSchema = z.object({
|
||||
|
||||
@@ -6,14 +6,20 @@ import { expressionService } from '../service/expression'
|
||||
const app = new Hono()
|
||||
|
||||
// Zod schema for validation
|
||||
const listSchema = z.object({
|
||||
page: z.coerce.number().optional().default(1),
|
||||
limit: z.coerce.number().optional().default(20),
|
||||
})
|
||||
|
||||
const createSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
image: z.instanceof(File),
|
||||
})
|
||||
|
||||
const route = app
|
||||
.get('/', async (c) => {
|
||||
const expressions = await expressionService.listExpressions()
|
||||
.get('/', zValidator('query', listSchema), async (c) => {
|
||||
const { page, limit } = c.req.valid('query')
|
||||
const expressions = await expressionService.listExpressions({ page, limit })
|
||||
return c.json(expressions)
|
||||
})
|
||||
.post('/', zValidator('form', createSchema), async (c) => {
|
||||
|
||||
+4
-2
@@ -7,6 +7,8 @@ const app = new Hono()
|
||||
|
||||
// Define schemas for validation
|
||||
const listSchema = z.object({
|
||||
page: z.coerce.number().optional().default(1),
|
||||
limit: z.coerce.number().optional().default(20),
|
||||
equipmentId: z.string().optional(),
|
||||
})
|
||||
|
||||
@@ -24,8 +26,8 @@ const updateSchema = z.object({
|
||||
|
||||
const route = app
|
||||
.get('/', zValidator('query', listSchema), async (c) => {
|
||||
const { equipmentId } = c.req.valid('query')
|
||||
const result = await outfitService.listOutfits(equipmentId)
|
||||
const { page, limit, equipmentId } = c.req.valid('query')
|
||||
const result = await outfitService.listOutfits({ page, limit }, equipmentId)
|
||||
return c.json(result)
|
||||
})
|
||||
.post('/', zValidator('json', createSchema), async (c) => {
|
||||
|
||||
+8
-2
@@ -6,14 +6,20 @@ import { poseService } from '../service/pose'
|
||||
const app = new Hono()
|
||||
|
||||
// Zod schema for validation
|
||||
const listSchema = z.object({
|
||||
page: z.coerce.number().optional().default(1),
|
||||
limit: z.coerce.number().optional().default(20),
|
||||
})
|
||||
|
||||
const createSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
image: z.instanceof(File),
|
||||
})
|
||||
|
||||
const route = app
|
||||
.get('/', async (c) => {
|
||||
const poses = await poseService.listPoses()
|
||||
.get('/', zValidator('query', listSchema), async (c) => {
|
||||
const { page, limit } = c.req.valid('query')
|
||||
const poses = await poseService.listPoses({ page, limit })
|
||||
return c.json(poses)
|
||||
})
|
||||
.post('/', zValidator('form', createSchema), async (c) => {
|
||||
|
||||
+34
-12
@@ -19,20 +19,42 @@ export const BUILTIN_EXPRESSIONS: ExpressionItem[] = Array.from({ length: 9 }, (
|
||||
)
|
||||
|
||||
export class ExpressionService {
|
||||
async listExpressions(): Promise<ExpressionItem[]> {
|
||||
const customExpressions = await prisma.expression.findMany({
|
||||
include: { image: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
async listExpressions(options: { page?: number; limit?: number } = {}): Promise<ExpressionItem[]> {
|
||||
const { page = 1, limit = 20 } = options
|
||||
const skip = (page - 1) * limit
|
||||
|
||||
const formattedCustomExpressions: ExpressionItem[] = customExpressions.map((e) => ({
|
||||
id: e.id,
|
||||
name: e.name,
|
||||
type: 'custom',
|
||||
imageUrl: `/files/${e.image.path}`,
|
||||
}))
|
||||
// Logic for mixed pagination
|
||||
const builtinCount = BUILTIN_EXPRESSIONS.length
|
||||
|
||||
return [...BUILTIN_EXPRESSIONS, ...formattedCustomExpressions]
|
||||
// Calculate how many builtin items to include
|
||||
const builtinStart = skip
|
||||
const builtinEnd = Math.min(builtinCount, skip + limit)
|
||||
const builtinSlice =
|
||||
builtinStart < builtinCount ? BUILTIN_EXPRESSIONS.slice(builtinStart, builtinEnd) : []
|
||||
|
||||
// Calculate how many db items to fetch
|
||||
const remainingLimit = limit - builtinSlice.length
|
||||
const dbSkip = Math.max(0, skip - builtinCount)
|
||||
|
||||
let formattedCustomExpressions: ExpressionItem[] = []
|
||||
|
||||
if (remainingLimit > 0) {
|
||||
const customExpressions = await prisma.expression.findMany({
|
||||
include: { image: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: dbSkip,
|
||||
take: remainingLimit,
|
||||
})
|
||||
|
||||
formattedCustomExpressions = customExpressions.map((e) => ({
|
||||
id: e.id,
|
||||
name: e.name,
|
||||
type: 'custom',
|
||||
imageUrl: `/files/${e.image.path}`,
|
||||
}))
|
||||
}
|
||||
|
||||
return [...builtinSlice, ...formattedCustomExpressions]
|
||||
}
|
||||
|
||||
async createExpression(name: string, file: File) {
|
||||
|
||||
@@ -3,7 +3,13 @@ import { ulid } from 'ulidx'
|
||||
import { Prisma } from '../generated/prisma/client'
|
||||
|
||||
export class OutfitService {
|
||||
async listOutfits(equipmentId?: string) {
|
||||
async listOutfits(
|
||||
options: { page?: number; limit?: number } = {},
|
||||
equipmentId?: string,
|
||||
) {
|
||||
const { page = 1, limit = 20 } = options
|
||||
const skip = (page - 1) * limit
|
||||
|
||||
const where: Prisma.OutfitWhereInput = {}
|
||||
if (equipmentId) {
|
||||
where.equipments = {
|
||||
@@ -29,6 +35,8 @@ export class OutfitService {
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
skip,
|
||||
take: limit,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+34
-12
@@ -17,20 +17,42 @@ export const BUILTIN_POSES: PoseItem[] = Array.from({ length: 9 }, (_, i) => i +
|
||||
}))
|
||||
|
||||
export class PoseService {
|
||||
async listPoses(): Promise<PoseItem[]> {
|
||||
const customPoses = await prisma.pose.findMany({
|
||||
include: { image: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
async listPoses(options: { page?: number; limit?: number } = {}): Promise<PoseItem[]> {
|
||||
const { page = 1, limit = 20 } = options
|
||||
const skip = (page - 1) * limit
|
||||
|
||||
const formattedCustomPoses: PoseItem[] = customPoses.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
type: 'custom',
|
||||
imageUrl: `/files/${p.image.path}`,
|
||||
}))
|
||||
// Logic for mixed pagination
|
||||
const builtinCount = BUILTIN_POSES.length
|
||||
|
||||
return [...BUILTIN_POSES, ...formattedCustomPoses]
|
||||
// Calculate how many builtin items to include
|
||||
const builtinStart = skip
|
||||
const builtinEnd = Math.min(builtinCount, skip + limit)
|
||||
const builtinSlice =
|
||||
builtinStart < builtinCount ? BUILTIN_POSES.slice(builtinStart, builtinEnd) : []
|
||||
|
||||
// Calculate how many db items to fetch
|
||||
const remainingLimit = limit - builtinSlice.length
|
||||
const dbSkip = Math.max(0, skip - builtinCount)
|
||||
|
||||
let formattedCustomPoses: PoseItem[] = []
|
||||
|
||||
if (remainingLimit > 0) {
|
||||
const customPoses = await prisma.pose.findMany({
|
||||
include: { image: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: dbSkip,
|
||||
take: remainingLimit,
|
||||
})
|
||||
|
||||
formattedCustomPoses = customPoses.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
type: 'custom',
|
||||
imageUrl: `/files/${p.image.path}`,
|
||||
}))
|
||||
}
|
||||
|
||||
return [...builtinSlice, ...formattedCustomPoses]
|
||||
}
|
||||
|
||||
async createPose(name: string, file: File) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import React, { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { EQUIPMENT_CATEGORIES } from '../../lib/categories'
|
||||
import { client } from '../client'
|
||||
import { useInfiniteScroll } from '../hooks/use-infinite-scroll'
|
||||
import { cn } from '../lib/utils'
|
||||
import { LoadOutfitDialog } from './LoadOutfitDialog'
|
||||
import { SaveOutfitDialog } from './SaveOutfitDialog'
|
||||
@@ -45,13 +46,9 @@ export const CreateLookDialog: React.FC<CreateLookDialogProps> = ({
|
||||
onSuccess,
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('equipment')
|
||||
const [items, setItems] = useState<EquipmentItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [selectedCategories, setSelectedCategories] = useState<string[]>([])
|
||||
const [selectedEquipments, setSelectedEquipments] = useState<EquipmentItem[]>([])
|
||||
const [poses, setPoses] = useState<PoseItem[]>([])
|
||||
const [selectedPoseId, setSelectedPoseId] = useState<string | null>(null)
|
||||
const [expressions, setExpressions] = useState<ExpressionItem[]>([])
|
||||
const [selectedExpressionId, setSelectedExpressionId] = useState<string | null>(null)
|
||||
const [userPrompt, setUserPrompt] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
@@ -60,19 +57,16 @@ export const CreateLookDialog: React.FC<CreateLookDialogProps> = ({
|
||||
const [loadOutfitOpen, setLoadOutfitOpen] = useState(false)
|
||||
const [saveOutfitOpen, setSaveOutfitOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
fetchItems()
|
||||
fetchPoses()
|
||||
fetchExpressions()
|
||||
}
|
||||
}, [open, selectedCategories])
|
||||
|
||||
const fetchItems = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const query: { limit: string; category?: string[] } = {
|
||||
limit: '100',
|
||||
const {
|
||||
items: items,
|
||||
loading: equipmentLoading,
|
||||
ref: equipmentRef,
|
||||
reset: resetEquipment,
|
||||
} = useInfiniteScroll<EquipmentItem>({
|
||||
fetchData: async (page, limit) => {
|
||||
const query: { limit: string; page: string; category?: string[] } = {
|
||||
limit: limit.toString(),
|
||||
page: page.toString(),
|
||||
}
|
||||
if (selectedCategories.length > 0) {
|
||||
query.category = selectedCategories
|
||||
@@ -81,38 +75,56 @@ export const CreateLookDialog: React.FC<CreateLookDialogProps> = ({
|
||||
const res = await client.equipments.$get({ query })
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setItems(data.items)
|
||||
return data.items
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load equipment', e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
return []
|
||||
},
|
||||
limit: 20,
|
||||
})
|
||||
|
||||
const fetchPoses = async () => {
|
||||
try {
|
||||
const res = await client.poses.$get()
|
||||
const {
|
||||
items: poses,
|
||||
loading: posesLoading,
|
||||
ref: poseRef,
|
||||
} = useInfiniteScroll<PoseItem>({
|
||||
fetchData: async (page, limit) => {
|
||||
const res = await client.poses.$get({
|
||||
query: {
|
||||
page: page.toString(),
|
||||
limit: limit.toString(),
|
||||
},
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setPoses(data)
|
||||
return await res.json()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load poses', e)
|
||||
}
|
||||
}
|
||||
return []
|
||||
},
|
||||
limit: 20,
|
||||
})
|
||||
|
||||
const fetchExpressions = async () => {
|
||||
try {
|
||||
const res = await client.expressions.$get()
|
||||
const {
|
||||
items: expressions,
|
||||
loading: expressionsLoading,
|
||||
ref: expressionRef,
|
||||
} = useInfiniteScroll<ExpressionItem>({
|
||||
fetchData: async (page, limit) => {
|
||||
const res = await client.expressions.$get({
|
||||
query: {
|
||||
page: page.toString(),
|
||||
limit: limit.toString(),
|
||||
},
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setExpressions(data)
|
||||
return await res.json()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load expressions', e)
|
||||
}
|
||||
}
|
||||
return []
|
||||
},
|
||||
limit: 20,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
resetEquipment()
|
||||
}, [selectedCategories, resetEquipment])
|
||||
|
||||
const toggleCategory = (category: string) => {
|
||||
setSelectedCategories((prev) =>
|
||||
@@ -269,11 +281,7 @@ export const CreateLookDialog: React.FC<CreateLookDialogProps> = ({
|
||||
|
||||
{/* Equipment Grid */}
|
||||
<ScrollArea className="flex-1 p-6 h-full">
|
||||
{loading ? (
|
||||
<div className="flex justify-center items-center h-full min-h-[200px]">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-stone-400" />
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
{items.length === 0 && !equipmentLoading ? (
|
||||
<div className="flex flex-col items-center justify-center h-full min-h-[200px] text-stone-400">
|
||||
<p>No equipment found.</p>
|
||||
</div>
|
||||
@@ -317,6 +325,15 @@ export const CreateLookDialog: React.FC<CreateLookDialogProps> = ({
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{/* Loading Indicator */}
|
||||
<div
|
||||
ref={equipmentRef}
|
||||
className="h-10 w-full flex items-center justify-center p-4"
|
||||
>
|
||||
{equipmentLoading && (
|
||||
<Loader2 className="w-8 h-8 animate-spin text-stone-400" />
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Selected Items (Persistent in Equipment Tab) */}
|
||||
@@ -410,6 +427,13 @@ export const CreateLookDialog: React.FC<CreateLookDialogProps> = ({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Loading Indicator */}
|
||||
<div
|
||||
ref={poseRef}
|
||||
className="h-10 w-full flex items-center justify-center p-4"
|
||||
>
|
||||
{posesLoading && <Loader2 className="w-8 h-8 animate-spin text-stone-400" />}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
|
||||
@@ -458,6 +482,15 @@ export const CreateLookDialog: React.FC<CreateLookDialogProps> = ({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Loading Indicator */}
|
||||
<div
|
||||
ref={expressionRef}
|
||||
className="h-10 w-full flex items-center justify-center p-4"
|
||||
>
|
||||
{expressionsLoading && (
|
||||
<Loader2 className="w-8 h-8 animate-spin text-stone-400" />
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { client } from '../client'
|
||||
import { InferResponseType } from 'hono/client'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from './ui/dialog'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import React from 'react'
|
||||
import { client } from '../client'
|
||||
import { useInfiniteScroll } from '../hooks/use-infinite-scroll'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from './ui/dialog'
|
||||
|
||||
type OutfitResponse = InferResponseType<typeof client.outfits.$get>
|
||||
type OutfitItem = OutfitResponse[number]
|
||||
@@ -18,29 +19,21 @@ export const LoadOutfitDialog: React.FC<LoadOutfitDialogProps> = ({
|
||||
onOpenChange,
|
||||
onSelect,
|
||||
}) => {
|
||||
const [outfits, setOutfits] = useState<OutfitItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
fetchOutfits()
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const fetchOutfits = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await client.outfits.$get({ query: {} })
|
||||
const { items: outfits, loading, ref } = useInfiniteScroll<OutfitItem>({
|
||||
fetchData: async (page, limit) => {
|
||||
const res = await client.outfits.$get({
|
||||
query: {
|
||||
page: page.toString(),
|
||||
limit: limit.toString(),
|
||||
},
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setOutfits(data)
|
||||
return await res.json()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load outfits', e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
return []
|
||||
},
|
||||
limit: 20,
|
||||
})
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
@@ -53,11 +46,7 @@ export const LoadOutfitDialog: React.FC<LoadOutfitDialogProps> = ({
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6 bg-stone-50/50">
|
||||
{loading ? (
|
||||
<div className="flex justify-center items-center h-full">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-stone-400" />
|
||||
</div>
|
||||
) : outfits.length === 0 ? (
|
||||
{outfits.length === 0 && !loading ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-stone-400">
|
||||
<p>No saved outfits found.</p>
|
||||
</div>
|
||||
@@ -107,6 +96,10 @@ export const LoadOutfitDialog: React.FC<LoadOutfitDialogProps> = ({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Loading Indicator */}
|
||||
<div ref={ref} className="h-10 w-full flex items-center justify-center p-4">
|
||||
{loading && <Loader2 className="w-8 h-8 animate-spin text-stone-400" />}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useInView } from 'react-intersection-observer'
|
||||
|
||||
interface UseInfiniteScrollOptions<T> {
|
||||
fetchData: (page: number, limit: number) => Promise<T[]>
|
||||
initialPage?: number
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export function useInfiniteScroll<T>({
|
||||
fetchData,
|
||||
initialPage = 1,
|
||||
limit = 20,
|
||||
}: UseInfiniteScrollOptions<T>) {
|
||||
const [items, setItems] = useState<T[]>([])
|
||||
const [page, setPage] = useState(initialPage)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const { ref, inView } = useInView()
|
||||
|
||||
// Use a ref for fetchData to avoid re-creating loadMore when fetchData changes reference
|
||||
// (unless the caller memoizes it, which they might forget)
|
||||
const fetchDataRef = useRef(fetchData)
|
||||
useEffect(() => {
|
||||
fetchDataRef.current = fetchData
|
||||
}, [fetchData])
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (loading || !hasMore) return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const newItems = await fetchDataRef.current(page, limit)
|
||||
|
||||
// If we got fewer items than limit, we've reached the end
|
||||
if (newItems.length < limit) {
|
||||
setHasMore(false)
|
||||
}
|
||||
|
||||
setItems((prev) => {
|
||||
// Optional: deduplicate based on ID if T has an id property?
|
||||
// For now, simple append is safer and faster.
|
||||
return [...prev, ...newItems]
|
||||
})
|
||||
|
||||
if (newItems.length > 0) {
|
||||
setPage((prev) => prev + 1)
|
||||
} else {
|
||||
setHasMore(false)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load more items', e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, limit, loading, hasMore])
|
||||
|
||||
// Trigger load when in view
|
||||
useEffect(() => {
|
||||
if (inView && !loading && hasMore) {
|
||||
loadMore()
|
||||
}
|
||||
}, [inView, loading, hasMore, loadMore])
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setItems([])
|
||||
setPage(initialPage)
|
||||
setHasMore(true)
|
||||
// We don't manually call loadMore here.
|
||||
// Clearing items should make the observer visible, triggering the effect.
|
||||
}, [initialPage])
|
||||
|
||||
return {
|
||||
items,
|
||||
loading,
|
||||
hasMore,
|
||||
ref, // Attach this ref to the loading indicator at the bottom of the list
|
||||
reset,
|
||||
setItems, // Expose for manual updates (e.g. delete item)
|
||||
}
|
||||
}
|
||||
+21
-28
@@ -1,12 +1,13 @@
|
||||
import { InferResponseType } from 'hono/client'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { client } from '../client'
|
||||
import { CreateCharacterDialog } from '../components/CreateCharacterDialog'
|
||||
import { PageHeader } from '../components/PageHeader'
|
||||
import { VibeCard } from '../components/VibeCard'
|
||||
import { Button } from '../components/ui/button'
|
||||
import { useInfiniteScroll } from '../hooks/use-infinite-scroll'
|
||||
|
||||
type CharacterResponse = InferResponseType<typeof client.characters.$get>
|
||||
type Character = CharacterResponse['items'][number]
|
||||
@@ -25,40 +26,25 @@ const getCharacterColor = (name: string) => {
|
||||
}
|
||||
|
||||
export default function Characters() {
|
||||
const [characters, setCharacters] = useState<Character[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
fetchCharacters()
|
||||
}, [])
|
||||
|
||||
const fetchCharacters = async () => {
|
||||
try {
|
||||
const { items, loading, ref, reset } = useInfiniteScroll<Character>({
|
||||
fetchData: async (page, limit) => {
|
||||
const res = await client.characters.$get({
|
||||
query: {
|
||||
limit: '100',
|
||||
page: page.toString(),
|
||||
limit: limit.toString(),
|
||||
},
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setCharacters(data.items)
|
||||
return data.items
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load characters', e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-8 text-slate-400 font-mono tracking-widest animate-pulse">
|
||||
INITIALIZING...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return []
|
||||
},
|
||||
limit: 20,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="w-full h-full p-4 pt-2 flex flex-col text-slate-900 relative">
|
||||
@@ -73,13 +59,13 @@ export default function Characters() {
|
||||
className="mb-8"
|
||||
/>
|
||||
|
||||
{characters.length === 0 ? (
|
||||
{items.length === 0 && !loading ? (
|
||||
<div className="text-center p-12 bg-white clip-path-slant border border-slate-200">
|
||||
<p className="text-slate-500 font-mono">No characters found.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-6 max-w-[1920px] mx-auto w-full pb-10">
|
||||
{characters.map((char, index) => (
|
||||
{items.map((char, index) => (
|
||||
<VibeCard
|
||||
key={char.id}
|
||||
index={index}
|
||||
@@ -94,10 +80,17 @@ export default function Characters() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading Indicator */}
|
||||
<div ref={ref} className="h-10 w-full flex items-center justify-center p-4">
|
||||
{loading && (
|
||||
<div className="text-slate-400 font-mono tracking-widest animate-pulse">LOADING...</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CreateCharacterDialog
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
onSuccess={fetchCharacters}
|
||||
onSuccess={reset}
|
||||
/>
|
||||
<style>{`@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }`}</style>
|
||||
</div>
|
||||
|
||||
+139
-119
@@ -12,6 +12,7 @@ import { VibeCard } from '../components/VibeCard'
|
||||
import { Badge } from '../components/ui/badge'
|
||||
import { Button } from '../components/ui/button'
|
||||
import { ScrollArea, ScrollBar } from '../components/ui/scroll-area'
|
||||
import { useInfiniteScroll } from '../hooks/use-infinite-scroll'
|
||||
import { cn } from '../lib/utils'
|
||||
|
||||
type EquipmentResponse = InferResponseType<typeof client.equipments.$get>
|
||||
@@ -33,9 +34,6 @@ const getEquipmentColor = (name: string) => {
|
||||
}
|
||||
|
||||
export default function Equipments() {
|
||||
const [items, setItems] = useState<EquipmentItem[]>([])
|
||||
const [outfits, setOutfits] = useState<OutfitItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null)
|
||||
const [extractorOpen, setExtractorOpen] = useState(false)
|
||||
const [createOutfitOpen, setCreateOutfitOpen] = useState(false)
|
||||
@@ -43,53 +41,55 @@ export default function Equipments() {
|
||||
const [selectedEquipment, setSelectedEquipment] = useState<EquipmentItem | null>(null)
|
||||
const [equipmentDetailsOpen, setEquipmentDetailsOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (viewMode === 'equipments') {
|
||||
fetchItems()
|
||||
} else {
|
||||
fetchOutfits()
|
||||
}
|
||||
}, [viewMode, selectedCategory])
|
||||
|
||||
const fetchItems = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const query: { limit: string; category?: string[] } = {
|
||||
limit: '100',
|
||||
const {
|
||||
items: items,
|
||||
loading: equipmentLoading,
|
||||
ref: equipmentRef,
|
||||
reset: resetEquipment,
|
||||
} = useInfiniteScroll<EquipmentItem>({
|
||||
fetchData: async (page, limit) => {
|
||||
const query: { limit: string; page: string; category?: string[] } = {
|
||||
limit: limit.toString(),
|
||||
page: page.toString(),
|
||||
}
|
||||
|
||||
if (selectedCategory) {
|
||||
query.category = [selectedCategory]
|
||||
}
|
||||
const res = await client.equipments.$get({ query })
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
return data.items
|
||||
}
|
||||
return []
|
||||
},
|
||||
limit: 20,
|
||||
})
|
||||
|
||||
const res = await client.equipments.$get({
|
||||
query,
|
||||
const {
|
||||
items: outfits,
|
||||
loading: outfitLoading,
|
||||
ref: outfitRef,
|
||||
reset: resetOutfits,
|
||||
setItems: setOutfits,
|
||||
} = useInfiniteScroll<OutfitItem>({
|
||||
fetchData: async (page, limit) => {
|
||||
const res = await client.outfits.$get({
|
||||
query: {
|
||||
page: page.toString(),
|
||||
limit: limit.toString(),
|
||||
},
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setItems(data.items)
|
||||
return await res.json()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load equipment', e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
return []
|
||||
},
|
||||
limit: 20,
|
||||
})
|
||||
|
||||
const fetchOutfits = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await client.outfits.$get({ query: {} })
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setOutfits(data)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load outfits', e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
resetEquipment()
|
||||
}, [selectedCategory, resetEquipment])
|
||||
|
||||
const handleDeleteOutfit = async (id: string) => {
|
||||
if (!confirm('Are you sure you want to delete this outfit?')) return
|
||||
@@ -97,7 +97,7 @@ export default function Equipments() {
|
||||
const res = await client.outfits[':id'].$delete({ param: { id } })
|
||||
if (res.ok) {
|
||||
toast.success('Outfit deleted')
|
||||
fetchOutfits()
|
||||
setOutfits((prev) => prev.filter((o) => o.id !== id))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
@@ -178,88 +178,108 @@ export default function Equipments() {
|
||||
)}
|
||||
</PageHeader>
|
||||
|
||||
{loading ? (
|
||||
<div className="p-12 text-center text-slate-400 font-mono tracking-widest animate-pulse">
|
||||
INITIALIZING...
|
||||
</div>
|
||||
) : viewMode === 'equipments' ? (
|
||||
items.length === 0 ? (
|
||||
<div className="text-center p-12 bg-white clip-path-slant border border-slate-200">
|
||||
<p className="text-slate-500 font-mono">No equipment found.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-6 max-w-[1920px] mx-auto w-full pb-10">
|
||||
{items.map((item, index) => (
|
||||
<VibeCard
|
||||
key={item.id}
|
||||
index={index}
|
||||
name={item.name}
|
||||
subtitle={item.category}
|
||||
image={item.image?.path ? `/files/${item.image.path}` : ''}
|
||||
color={getEquipmentColor(item.name)}
|
||||
onClick={() => {
|
||||
setSelectedEquipment(item)
|
||||
setEquipmentDetailsOpen(true)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : outfits.length === 0 ? (
|
||||
<div className="text-center p-12 bg-white clip-path-slant border border-slate-200">
|
||||
<p className="text-slate-500 font-mono">No outfits found.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 max-w-[1200px] mx-auto w-full pb-10">
|
||||
{outfits.map((outfit) => (
|
||||
<div
|
||||
key={outfit.id}
|
||||
className="bg-white rounded-xl border border-slate-200 p-4 flex items-center justify-between shadow-sm hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-bold text-slate-800 text-lg mb-2">{outfit.name}</h3>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{outfit.equipments.map((eq) => (
|
||||
<div
|
||||
key={eq.equipment.id}
|
||||
className="w-10 h-10 rounded-md border border-slate-200 bg-slate-50 overflow-hidden relative group"
|
||||
title={eq.equipment.name}
|
||||
>
|
||||
<img
|
||||
src={eq.equipment.image?.path ? `/files/${eq.equipment.image.path}` : ''}
|
||||
alt={eq.equipment.name}
|
||||
className="w-full h-full object-contain p-1"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{outfit.prompt && (
|
||||
<Badge variant="outline" className="ml-2 h-auto self-center">
|
||||
+ Prompt
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 pl-4 border-l border-slate-100">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDeleteOutfit(outfit.id)}
|
||||
className="text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
{viewMode === 'equipments' ? (
|
||||
<>
|
||||
{items.length === 0 && !equipmentLoading ? (
|
||||
<div className="text-center p-12 bg-white clip-path-slant border border-slate-200">
|
||||
<p className="text-slate-500 font-mono">No equipment found.</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-6 max-w-[1920px] mx-auto w-full pb-10">
|
||||
{items.map((item, index) => (
|
||||
<VibeCard
|
||||
key={item.id}
|
||||
index={index}
|
||||
name={item.name}
|
||||
subtitle={item.category}
|
||||
image={item.image?.path ? `/files/${item.image.path}` : ''}
|
||||
color={getEquipmentColor(item.name)}
|
||||
onClick={() => {
|
||||
setSelectedEquipment(item)
|
||||
setEquipmentDetailsOpen(true)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Loading Indicator */}
|
||||
<div ref={equipmentRef} className="h-10 w-full flex items-center justify-center p-4">
|
||||
{equipmentLoading && (
|
||||
<div className="text-slate-400 font-mono tracking-widest animate-pulse">
|
||||
LOADING...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{outfits.length === 0 && !outfitLoading ? (
|
||||
<div className="text-center p-12 bg-white clip-path-slant border border-slate-200">
|
||||
<p className="text-slate-500 font-mono">No outfits found.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 max-w-[1200px] mx-auto w-full pb-10">
|
||||
{outfits.map((outfit) => (
|
||||
<div
|
||||
key={outfit.id}
|
||||
className="bg-white rounded-xl border border-slate-200 p-4 flex items-center justify-between shadow-sm hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-bold text-slate-800 text-lg mb-2">{outfit.name}</h3>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{outfit.equipments.map((eq) => (
|
||||
<div
|
||||
key={eq.equipment.id}
|
||||
className="w-10 h-10 rounded-md border border-slate-200 bg-slate-50 overflow-hidden relative group"
|
||||
title={eq.equipment.name}
|
||||
>
|
||||
<img
|
||||
src={
|
||||
eq.equipment.image?.path ? `/files/${eq.equipment.image.path}` : ''
|
||||
}
|
||||
alt={eq.equipment.name}
|
||||
className="w-full h-full object-contain p-1"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{outfit.prompt && (
|
||||
<Badge variant="outline" className="ml-2 h-auto self-center">
|
||||
+ Prompt
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 pl-4 border-l border-slate-100">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDeleteOutfit(outfit.id)}
|
||||
className="text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Loading Indicator */}
|
||||
<div ref={outfitRef} className="h-10 w-full flex items-center justify-center p-4">
|
||||
{outfitLoading && (
|
||||
<div className="text-slate-400 font-mono tracking-widest animate-pulse">
|
||||
LOADING...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ExtractorDialog
|
||||
open={extractorOpen}
|
||||
onOpenChange={setExtractorOpen}
|
||||
onSuccess={() => {
|
||||
fetchItems() // Refresh list on success
|
||||
resetEquipment() // Refresh list on success
|
||||
setExtractorOpen(false)
|
||||
}}
|
||||
/>
|
||||
@@ -267,7 +287,7 @@ export default function Equipments() {
|
||||
open={createOutfitOpen}
|
||||
onOpenChange={setCreateOutfitOpen}
|
||||
onSuccess={() => {
|
||||
fetchOutfits()
|
||||
resetOutfits()
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -275,7 +295,7 @@ export default function Equipments() {
|
||||
open={equipmentDetailsOpen}
|
||||
onOpenChange={setEquipmentDetailsOpen}
|
||||
equipment={selectedEquipment}
|
||||
onUpdate={fetchItems}
|
||||
onUpdate={resetEquipment}
|
||||
/>
|
||||
<style>{`@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }`}</style>
|
||||
</div>
|
||||
|
||||
@@ -1,38 +1,41 @@
|
||||
import { InferResponseType } from 'hono/client'
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { client } from '../client'
|
||||
import { CreateExpressionDialog } from '../components/CreateExpressionDialog'
|
||||
import { PageHeader } from '../components/PageHeader'
|
||||
import { VibeCard } from '../components/VibeCard'
|
||||
import { Button } from '../components/ui/button'
|
||||
import { useInfiniteScroll } from '../hooks/use-infinite-scroll'
|
||||
|
||||
type ExpressionResponse = InferResponseType<typeof client.expressions.$get>
|
||||
type ExpressionItem = ExpressionResponse[number]
|
||||
|
||||
export default function Expressions() {
|
||||
const [expressions, setExpressions] = useState<ExpressionItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
|
||||
const fetchExpressions = async () => {
|
||||
try {
|
||||
const res = await client.expressions.$get()
|
||||
const {
|
||||
items: expressions,
|
||||
loading,
|
||||
ref,
|
||||
reset,
|
||||
setItems: setExpressions,
|
||||
} = useInfiniteScroll<ExpressionItem>({
|
||||
fetchData: async (page, limit) => {
|
||||
const res = await client.expressions.$get({
|
||||
query: {
|
||||
page: page.toString(),
|
||||
limit: limit.toString(),
|
||||
},
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setExpressions(data)
|
||||
return await res.json()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load expressions', e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchExpressions()
|
||||
}, [])
|
||||
return []
|
||||
},
|
||||
limit: 20,
|
||||
})
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Are you sure you want to delete this expression?')) return
|
||||
@@ -41,7 +44,7 @@ export default function Expressions() {
|
||||
const res = await client.expressions[':id'].$delete({ param: { id } })
|
||||
if (res.ok) {
|
||||
toast.success('Expression deleted')
|
||||
fetchExpressions()
|
||||
setExpressions((prev) => prev.filter((e) => e.id !== id))
|
||||
} else {
|
||||
const err = await res.json()
|
||||
// @ts-ignore
|
||||
@@ -52,14 +55,6 @@ export default function Expressions() {
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-8 text-slate-400 font-mono tracking-widest animate-pulse">
|
||||
INITIALIZING...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-full p-4 pt-2 flex flex-col text-slate-900 relative">
|
||||
<PageHeader
|
||||
@@ -73,7 +68,7 @@ export default function Expressions() {
|
||||
className="mb-8"
|
||||
/>
|
||||
|
||||
{expressions.length === 0 ? (
|
||||
{expressions.length === 0 && !loading ? (
|
||||
<div className="text-center p-12 bg-white clip-path-slant border border-slate-200">
|
||||
<p className="text-slate-500 font-mono">No expressions found.</p>
|
||||
</div>
|
||||
@@ -105,10 +100,17 @@ export default function Expressions() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading Indicator */}
|
||||
<div ref={ref} className="h-10 w-full flex items-center justify-center p-4">
|
||||
{loading && (
|
||||
<div className="text-slate-400 font-mono tracking-widest animate-pulse">LOADING...</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CreateExpressionDialog
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
onSuccess={fetchExpressions}
|
||||
onSuccess={reset}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
+31
-29
@@ -1,38 +1,41 @@
|
||||
import { InferResponseType } from 'hono/client'
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { client } from '../client'
|
||||
import { CreatePoseDialog } from '../components/CreatePoseDialog'
|
||||
import { PageHeader } from '../components/PageHeader'
|
||||
import { VibeCard } from '../components/VibeCard'
|
||||
import { Button } from '../components/ui/button'
|
||||
import { useInfiniteScroll } from '../hooks/use-infinite-scroll'
|
||||
|
||||
type PoseResponse = InferResponseType<typeof client.poses.$get>
|
||||
type PoseItem = PoseResponse[number]
|
||||
|
||||
export default function Poses() {
|
||||
const [poses, setPoses] = useState<PoseItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
|
||||
const fetchPoses = async () => {
|
||||
try {
|
||||
const res = await client.poses.$get()
|
||||
const {
|
||||
items: poses,
|
||||
loading,
|
||||
ref,
|
||||
reset,
|
||||
setItems: setPoses,
|
||||
} = useInfiniteScroll<PoseItem>({
|
||||
fetchData: async (page, limit) => {
|
||||
const res = await client.poses.$get({
|
||||
query: {
|
||||
page: page.toString(),
|
||||
limit: limit.toString(),
|
||||
},
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setPoses(data)
|
||||
return await res.json()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load poses', e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchPoses()
|
||||
}, [])
|
||||
return []
|
||||
},
|
||||
limit: 20,
|
||||
})
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Are you sure you want to delete this pose?')) return
|
||||
@@ -41,7 +44,7 @@ export default function Poses() {
|
||||
const res = await client.poses[':id'].$delete({ param: { id } })
|
||||
if (res.ok) {
|
||||
toast.success('Pose deleted')
|
||||
fetchPoses()
|
||||
setPoses((prev) => prev.filter((p) => p.id !== id))
|
||||
} else {
|
||||
const err = await res.json()
|
||||
// @ts-ignore
|
||||
@@ -52,14 +55,6 @@ export default function Poses() {
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-8 text-slate-400 font-mono tracking-widest animate-pulse">
|
||||
INITIALIZING...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-full p-4 pt-2 flex flex-col text-slate-900 relative">
|
||||
<PageHeader
|
||||
@@ -73,7 +68,7 @@ export default function Poses() {
|
||||
className="mb-8"
|
||||
/>
|
||||
|
||||
{poses.length === 0 ? (
|
||||
{poses.length === 0 && !loading ? (
|
||||
<div className="text-center p-12 bg-white clip-path-slant border border-slate-200">
|
||||
<p className="text-slate-500 font-mono">No poses found.</p>
|
||||
</div>
|
||||
@@ -105,7 +100,14 @@ export default function Poses() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CreatePoseDialog open={createOpen} onOpenChange={setCreateOpen} onSuccess={fetchPoses} />
|
||||
{/* Loading Indicator */}
|
||||
<div ref={ref} className="h-10 w-full flex items-center justify-center p-4">
|
||||
{loading && (
|
||||
<div className="text-slate-400 font-mono tracking-widest animate-pulse">LOADING...</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CreatePoseDialog open={createOpen} onOpenChange={setCreateOpen} onSuccess={reset} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user