From 261ba90ca49b5169a4b09d0e06823d156da0780e Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sat, 14 Feb 2026 15:18:02 +0000 Subject: [PATCH] feat: implement infinite scroll for all lists and set api limit to 20 - Added `useInfiniteScroll` hook using `react-intersection-observer`. - Updated `PoseService` and `ExpressionService` to support pagination with mixed built-in and DB items. - Updated `OutfitService` to support pagination. - Updated `src/api/pose.ts`, `src/api/expression.ts`, `src/api/outfit.ts`, `src/api/character.ts` to accept `page`/`limit` (default limit 20). - Refactored `Equipments`, `Characters`, `Poses`, `Expressions` pages to use infinite scroll. - Refactored `CreateLookDialog` and `LoadOutfitDialog` to use infinite scroll. - Updated `AGENTS.md` with new guidelines. --- AGENTS.md | 11 ++ bun.lock | 3 + package.json | 1 + src/api/character.test.ts | 2 +- src/api/character.ts | 2 +- src/api/expression.ts | 10 +- src/api/outfit.ts | 6 +- src/api/pose.ts | 10 +- src/service/expression.ts | 46 +++-- src/service/outfit.ts | 10 +- src/service/pose.ts | 46 +++-- src/ui/components/CreateLookDialog.tsx | 127 +++++++----- src/ui/components/LoadOutfitDialog.tsx | 51 +++-- src/ui/hooks/use-infinite-scroll.ts | 81 ++++++++ src/ui/pages/Characters.tsx | 49 ++--- src/ui/pages/Equipments.tsx | 258 +++++++++++++------------ src/ui/pages/Expressions.tsx | 60 +++--- src/ui/pages/Poses.tsx | 60 +++--- 18 files changed, 519 insertions(+), 314 deletions(-) create mode 100644 src/ui/hooks/use-infinite-scroll.ts diff --git a/AGENTS.md b/AGENTS.md index 638c678..b0ff9b9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/bun.lock b/bun.lock index 279025d..bc4f284 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/package.json b/package.json index d11b873..e47bc7e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/api/character.test.ts b/src/api/character.test.ts index 74154f7..33914d1 100644 --- a/src/api/character.test.ts +++ b/src/api/character.test.ts @@ -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 () => { diff --git a/src/api/character.ts b/src/api/character.ts index 3306fae..2ffc3a1 100644 --- a/src/api/character.ts +++ b/src/api/character.ts @@ -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({ diff --git a/src/api/expression.ts b/src/api/expression.ts index 97bc961..8430c7b 100644 --- a/src/api/expression.ts +++ b/src/api/expression.ts @@ -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) => { diff --git a/src/api/outfit.ts b/src/api/outfit.ts index 14369c3..bb3d79c 100644 --- a/src/api/outfit.ts +++ b/src/api/outfit.ts @@ -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) => { diff --git a/src/api/pose.ts b/src/api/pose.ts index 3356058..675213e 100644 --- a/src/api/pose.ts +++ b/src/api/pose.ts @@ -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) => { diff --git a/src/service/expression.ts b/src/service/expression.ts index 42e9553..906027c 100644 --- a/src/service/expression.ts +++ b/src/service/expression.ts @@ -19,20 +19,42 @@ export const BUILTIN_EXPRESSIONS: ExpressionItem[] = Array.from({ length: 9 }, ( ) export class ExpressionService { - async listExpressions(): Promise { - const customExpressions = await prisma.expression.findMany({ - include: { image: true }, - orderBy: { createdAt: 'desc' }, - }) + async listExpressions(options: { page?: number; limit?: number } = {}): Promise { + 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) { diff --git a/src/service/outfit.ts b/src/service/outfit.ts index a5827fa..aac8d27 100644 --- a/src/service/outfit.ts +++ b/src/service/outfit.ts @@ -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, }) } diff --git a/src/service/pose.ts b/src/service/pose.ts index 56fb43d..b33f718 100644 --- a/src/service/pose.ts +++ b/src/service/pose.ts @@ -17,20 +17,42 @@ export const BUILTIN_POSES: PoseItem[] = Array.from({ length: 9 }, (_, i) => i + })) export class PoseService { - async listPoses(): Promise { - const customPoses = await prisma.pose.findMany({ - include: { image: true }, - orderBy: { createdAt: 'desc' }, - }) + async listPoses(options: { page?: number; limit?: number } = {}): Promise { + 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) { diff --git a/src/ui/components/CreateLookDialog.tsx b/src/ui/components/CreateLookDialog.tsx index 58e238b..4b01e62 100644 --- a/src/ui/components/CreateLookDialog.tsx +++ b/src/ui/components/CreateLookDialog.tsx @@ -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 = ({ onSuccess, }) => { const [activeTab, setActiveTab] = useState('equipment') - const [items, setItems] = useState([]) - const [loading, setLoading] = useState(true) const [selectedCategories, setSelectedCategories] = useState([]) const [selectedEquipments, setSelectedEquipments] = useState([]) - const [poses, setPoses] = useState([]) const [selectedPoseId, setSelectedPoseId] = useState(null) - const [expressions, setExpressions] = useState([]) const [selectedExpressionId, setSelectedExpressionId] = useState(null) const [userPrompt, setUserPrompt] = useState('') const [submitting, setSubmitting] = useState(false) @@ -60,19 +57,16 @@ export const CreateLookDialog: React.FC = ({ 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({ + 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 = ({ 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({ + 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({ + 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 = ({ {/* Equipment Grid */} - {loading ? ( -
- -
- ) : items.length === 0 ? ( + {items.length === 0 && !equipmentLoading ? (

No equipment found.

@@ -317,6 +325,15 @@ export const CreateLookDialog: React.FC = ({ })} )} + {/* Loading Indicator */} +
+ {equipmentLoading && ( + + )} +
{/* Selected Items (Persistent in Equipment Tab) */} @@ -410,6 +427,13 @@ export const CreateLookDialog: React.FC = ({ ))} + {/* Loading Indicator */} +
+ {posesLoading && } +
)} @@ -458,6 +482,15 @@ export const CreateLookDialog: React.FC = ({ ))} + {/* Loading Indicator */} +
+ {expressionsLoading && ( + + )} +
)} diff --git a/src/ui/components/LoadOutfitDialog.tsx b/src/ui/components/LoadOutfitDialog.tsx index a2db6a5..b862ddb 100644 --- a/src/ui/components/LoadOutfitDialog.tsx +++ b/src/ui/components/LoadOutfitDialog.tsx @@ -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 type OutfitItem = OutfitResponse[number] @@ -18,29 +19,21 @@ export const LoadOutfitDialog: React.FC = ({ onOpenChange, onSelect, }) => { - const [outfits, setOutfits] = useState([]) - 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({ + 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 ( @@ -53,11 +46,7 @@ export const LoadOutfitDialog: React.FC = ({
- {loading ? ( -
- -
- ) : outfits.length === 0 ? ( + {outfits.length === 0 && !loading ? (

No saved outfits found.

@@ -107,6 +96,10 @@ export const LoadOutfitDialog: React.FC = ({ ))}
)} + {/* Loading Indicator */} +
+ {loading && } +
diff --git a/src/ui/hooks/use-infinite-scroll.ts b/src/ui/hooks/use-infinite-scroll.ts new file mode 100644 index 0000000..d53ccb4 --- /dev/null +++ b/src/ui/hooks/use-infinite-scroll.ts @@ -0,0 +1,81 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { useInView } from 'react-intersection-observer' + +interface UseInfiniteScrollOptions { + fetchData: (page: number, limit: number) => Promise + initialPage?: number + limit?: number +} + +export function useInfiniteScroll({ + fetchData, + initialPage = 1, + limit = 20, +}: UseInfiniteScrollOptions) { + const [items, setItems] = useState([]) + 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) + } +} diff --git a/src/ui/pages/Characters.tsx b/src/ui/pages/Characters.tsx index 26e60fb..2196172 100644 --- a/src/ui/pages/Characters.tsx +++ b/src/ui/pages/Characters.tsx @@ -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 type Character = CharacterResponse['items'][number] @@ -25,40 +26,25 @@ const getCharacterColor = (name: string) => { } export default function Characters() { - const [characters, setCharacters] = useState([]) - 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({ + 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 ( -
- INITIALIZING... -
- ) - } + return [] + }, + limit: 20, + }) return (
@@ -73,13 +59,13 @@ export default function Characters() { className="mb-8" /> - {characters.length === 0 ? ( + {items.length === 0 && !loading ? (

No characters found.

) : (
- {characters.map((char, index) => ( + {items.map((char, index) => ( )} + {/* Loading Indicator */} +
+ {loading && ( +
LOADING...
+ )} +
+
diff --git a/src/ui/pages/Equipments.tsx b/src/ui/pages/Equipments.tsx index 5d4b9ec..2899528 100644 --- a/src/ui/pages/Equipments.tsx +++ b/src/ui/pages/Equipments.tsx @@ -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 @@ -33,9 +34,6 @@ const getEquipmentColor = (name: string) => { } export default function Equipments() { - const [items, setItems] = useState([]) - const [outfits, setOutfits] = useState([]) - const [loading, setLoading] = useState(true) const [selectedCategory, setSelectedCategory] = useState(null) const [extractorOpen, setExtractorOpen] = useState(false) const [createOutfitOpen, setCreateOutfitOpen] = useState(false) @@ -43,53 +41,55 @@ export default function Equipments() { const [selectedEquipment, setSelectedEquipment] = useState(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({ + 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({ + 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() { )} - {loading ? ( -
- INITIALIZING... -
- ) : viewMode === 'equipments' ? ( - items.length === 0 ? ( -
-

No equipment found.

-
- ) : ( -
- {items.map((item, index) => ( - { - setSelectedEquipment(item) - setEquipmentDetailsOpen(true) - }} - /> - ))} -
- ) - ) : outfits.length === 0 ? ( -
-

No outfits found.

-
- ) : ( -
- {outfits.map((outfit) => ( -
-
-

{outfit.name}

-
- {outfit.equipments.map((eq) => ( -
- {eq.equipment.name} -
- ))} - {outfit.prompt && ( - - + Prompt - - )} -
-
- -
- -
+ {viewMode === 'equipments' ? ( + <> + {items.length === 0 && !equipmentLoading ? ( +
+

No equipment found.

- ))} -
+ ) : ( +
+ {items.map((item, index) => ( + { + setSelectedEquipment(item) + setEquipmentDetailsOpen(true) + }} + /> + ))} +
+ )} + {/* Loading Indicator */} +
+ {equipmentLoading && ( +
+ LOADING... +
+ )} +
+ + ) : ( + <> + {outfits.length === 0 && !outfitLoading ? ( +
+

No outfits found.

+
+ ) : ( +
+ {outfits.map((outfit) => ( +
+
+

{outfit.name}

+
+ {outfit.equipments.map((eq) => ( +
+ {eq.equipment.name} +
+ ))} + {outfit.prompt && ( + + + Prompt + + )} +
+
+ +
+ +
+
+ ))} +
+ )} + {/* Loading Indicator */} +
+ {outfitLoading && ( +
+ LOADING... +
+ )} +
+ )} { - 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} />
diff --git a/src/ui/pages/Expressions.tsx b/src/ui/pages/Expressions.tsx index 9aa55e5..71d9a6a 100644 --- a/src/ui/pages/Expressions.tsx +++ b/src/ui/pages/Expressions.tsx @@ -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 type ExpressionItem = ExpressionResponse[number] export default function Expressions() { - const [expressions, setExpressions] = useState([]) - 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({ + 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 ( -
- INITIALIZING... -
- ) - } - return (
- {expressions.length === 0 ? ( + {expressions.length === 0 && !loading ? (

No expressions found.

@@ -105,10 +100,17 @@ export default function Expressions() {
)} + {/* Loading Indicator */} +
+ {loading && ( +
LOADING...
+ )} +
+
) diff --git a/src/ui/pages/Poses.tsx b/src/ui/pages/Poses.tsx index fc33bd2..73eb883 100644 --- a/src/ui/pages/Poses.tsx +++ b/src/ui/pages/Poses.tsx @@ -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 type PoseItem = PoseResponse[number] export default function Poses() { - const [poses, setPoses] = useState([]) - 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({ + 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 ( -
- INITIALIZING... -
- ) - } - return (
- {poses.length === 0 ? ( + {poses.length === 0 && !loading ? (

No poses found.

@@ -105,7 +100,14 @@ export default function Poses() {
)} - + {/* Loading Indicator */} +
+ {loading && ( +
LOADING...
+ )} +
+ + ) }