This commit is contained in:
phaichayon
2026-06-11 12:46:57 +07:00
parent 1993d88306
commit 7a390cf0df
720 changed files with 100919 additions and 0 deletions

View File

@@ -0,0 +1,24 @@
import { queryOptions } from '@tanstack/react-query';
export type Pokemon = {
id: number;
name: string;
sprites: {
front_shiny: string;
front_default: string;
};
types: { type: { name: string } }[];
stats: { base_stat: number; stat: { name: string } }[];
height: number;
weight: number;
};
export const pokemonOptions = (id: number = 25) =>
queryOptions({
queryKey: ['pokemon', id],
queryFn: async (): Promise<Pokemon> => {
const response = await fetch(`https://pokeapi.co/api/v2/pokemon/${id}`);
if (!response.ok) throw new Error('Failed to fetch pokemon');
return response.json();
}
});

View File

@@ -0,0 +1,99 @@
'use client';
import { useState } from 'react';
import { useSuspenseQuery } from '@tanstack/react-query';
import { pokemonOptions } from '../api/queries';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter
} from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
const POKEMON_IDS = [25, 1, 4, 7, 6, 150, 133, 39, 143, 94];
export function PokemonInfo() {
const [pokemonId, setPokemonId] = useState(25);
const { data } = useSuspenseQuery(pokemonOptions(pokemonId));
return (
<div className='space-y-6'>
{/* Pokemon selector */}
<Card>
<CardHeader>
<CardTitle>Pick a Pokemon</CardTitle>
<CardDescription>
Each selection triggers <code>useSuspenseQuery</code> cached results are instant, new
fetches show the Suspense fallback.
</CardDescription>
</CardHeader>
<CardContent>
<div className='flex flex-wrap gap-2'>
{POKEMON_IDS.map((id) => (
<Button
key={id}
variant={pokemonId === id ? 'default' : 'outline'}
size='sm'
onClick={() => setPokemonId(id)}
>
#{id}
</Button>
))}
</div>
</CardContent>
</Card>
{/* Pokemon card */}
<Card>
<CardHeader>
<div className='flex items-center gap-3'>
<CardTitle className='capitalize'>{data.name}</CardTitle>
<div className='flex gap-1'>
{data.types.map(({ type }) => (
<Badge key={type.name} variant='secondary'>
{type.name}
</Badge>
))}
</div>
</div>
<CardDescription>
Height: {data.height / 10}m &middot; Weight: {data.weight / 10}kg
</CardDescription>
</CardHeader>
<CardContent>
<div className='flex flex-col items-center gap-6 sm:flex-row'>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={data.sprites.front_shiny}
alt={data.name}
width={160}
height={160}
className='bg-muted/50 rounded-lg'
/>
<div className='flex-1 space-y-3'>
{data.stats.map((s) => (
<div key={s.stat.name} className='space-y-1'>
<div className='flex justify-between text-sm'>
<span className='text-muted-foreground capitalize'>{s.stat.name}</span>
<span className='font-medium'>{s.base_stat}</span>
</div>
<Progress value={Math.min(s.base_stat, 150) / 1.5} />
</div>
))}
</div>
</div>
</CardContent>
<CardFooter>
<p className='text-muted-foreground text-xs'>
Data from PokeAPI &middot; Prefetched on server, hydrated on client
</p>
</CardFooter>
</Card>
</div>
);
}

View File

@@ -0,0 +1,52 @@
import { Card, CardHeader, CardContent, CardFooter } from '@/components/ui/card';
export function PokemonSkeleton() {
return (
<div className='animate-pulse space-y-6'>
{/* Selector card skeleton */}
<Card>
<CardHeader>
<div className='bg-muted h-6 w-40 rounded' />
<div className='bg-muted mt-2 h-4 w-72 rounded' />
</CardHeader>
<CardContent>
<div className='flex flex-wrap gap-2'>
{Array.from({ length: 10 }).map((_, i) => (
<div key={i} className='bg-muted h-9 w-14 rounded-md' />
))}
</div>
</CardContent>
</Card>
{/* Pokemon card skeleton */}
<Card>
<CardHeader>
<div className='flex items-center gap-3'>
<div className='bg-muted h-7 w-32 rounded' />
<div className='bg-muted h-5 w-16 rounded-full' />
</div>
<div className='bg-muted mt-2 h-4 w-48 rounded' />
</CardHeader>
<CardContent>
<div className='flex flex-col items-center gap-6 sm:flex-row'>
<div className='bg-muted size-40 rounded-lg' />
<div className='w-full flex-1 space-y-3'>
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className='space-y-1'>
<div className='flex justify-between'>
<div className='bg-muted h-4 w-24 rounded' />
<div className='bg-muted h-4 w-8 rounded' />
</div>
<div className='bg-muted h-2 w-full rounded-full' />
</div>
))}
</div>
</div>
</CardContent>
<CardFooter>
<div className='bg-muted h-3 w-64 rounded' />
</CardFooter>
</Card>
</div>
);
}

View File

@@ -0,0 +1,46 @@
import type { InfobarContent } from '@/components/ui/infobar';
export const reactQueryInfoContent: InfobarContent = {
title: 'React Query Pattern',
sections: [
{
title: 'Server Prefetch',
description:
'Data is prefetched on the server using getQueryClient().prefetchQuery(). The dehydrated state is passed to HydrationBoundary so the client starts with cached data — no loading spinners on first load.',
links: [
{
title: 'TanStack Query SSR Docs',
url: 'https://tanstack.com/query/latest/docs/framework/react/guides/advanced-ssr'
}
]
},
{
title: 'Query Options',
description:
'Query keys and fetch functions are defined in a shared queryOptions() object. This is reused across server prefetch and client hooks, keeping them in sync.',
links: [
{
title: 'queryOptions API',
url: 'https://tanstack.com/query/latest/docs/framework/react/reference/queryOptions'
}
]
},
{
title: 'Suspense Query',
description:
'The client uses useSuspenseQuery() which integrates with React Suspense. Combined with server prefetch, data is available immediately — Suspense only shows the fallback on subsequent navigations if the cache is stale.',
links: []
},
{
title: 'Optimistic Mutations',
description:
'Mutations use onMutate to optimistically update the cache before the request completes. On error, the previous state is rolled back. On settle, the query is invalidated to refetch fresh data.',
links: [
{
title: 'Optimistic Updates Guide',
url: 'https://tanstack.com/query/latest/docs/framework/react/guides/optimistic-updates'
}
]
}
]
};