Merge pull request #23 from Yiling-J/infinite-scroll-9407279652325460799

Implement infinite scroll and pagination (limit 20)
This commit is contained in:
Yiling-J authored and GitHub committed 2026-02-15 09:17:56 +08:00
commit fd511c4af0
18 files changed
+519 -314

No files matched your search

+11
View File
@@ -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.
+3
View File
@@ -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=="],
+1
View File
@@ -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",
+1 -1
View File
@@ -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 () => {
+1 -1
View File
@@ -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({
+8 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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) {
+9 -1
View 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
View File
@@ -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) {
+80 -47
View 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>
)}
+22 -29
View File
@@ -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>
+81
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+31 -29
View File
@@ -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
View File
@@ -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>
)
}