この
最近、
以前の
だが、
この
(と ヘッダ)
グローバルナビゲーションヘッダに
基本的には
コードで 生成する
サイトロゴをサイトロゴは
基本的な
import type { NextRequest } from 'next/server';import satori from 'satori';
export const runtime = 'edge';
export const GET = async (request: NextRequest) => { const interMedium = await fetch(new URL('../../../../assets/fonts/Inter-Medium.ttf', import.meta.url)).then((res) => res.arrayBuffer(), );
const searchParams = request.nextUrl.searchParams; const theme = searchParams.get('theme') ?? 'light';
const svg = await satori( <div style={{ fontSize: 130, width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', fontFamily: 'Inter', fontSmooth: 'antialiased', color: theme === 'dark' ? '#111113' : '#e6e5e5', backgroundColor: theme === 'dark' ? '#e6e5e5' : '#111113', }} > K </div>, { width: 256, height: 256, fonts: [ { name: 'Inter', data: interMedium, style: 'normal', weight: 500, }, ], }, );
return new Response(svg, { status: 200, headers: { 'Content-Type': 'image/svg+xml', 'X-Content-Type-Options': 'nosniff', 'cache-control': 'public, immutable, no-transform, max-age=31536000', }, });};
当初は
type IconProps = React.HTMLAttributes<SVGElement>;
export const Icons = { logo: (props: IconProps) => ( <svg xmlns='http://www.w3.org/2000/svg' width='256' height='256' viewBox='0 0 256 256' {...props}> <rect x='0' y='0' width='256' height='256' className='fill-foreground' /> <path className='fill-background-lighter' d='M108.7 170.8L94.4 170.8L94.4 76.3L108.7 76.3L108.7 121.4L109.8 121.4L149.5 76.3L167.4 76.3L129.3 118.9L167.6 170.8L150.4 170.8L119.8 128.6L108.7 141.4L108.7 170.8Z ' /> </svg> ),};
サイトロゴ自体は
カテゴリに アクセントカラーを
選択中の記事一覧ページに
Next.js ではuseSearchParams
を
export const MainNavigation = () => { const pathname = usePathname(); const searchParams = useSearchParams(); const categoryParam = searchParams.get('category');
return ( <div className='mr-4 hidden md:flex'> ... <NavigationMenu> <NavigationMenuList> <NavigationMenuItem> <NavigationMenuTrigger>Blog</NavigationMenuTrigger> <NavigationMenuContent className='min-w-[8rem] p-1'> <ListItem href='/posts' className={cn(pathname === '/posts' && !categoryParam && 'bg-accent')}> All Posts </ListItem> <Separator className='-mx-1 my-1 h-px w-[calc(100%_+_1rem)]' /> <div className='grid gap-1'> {categories.map((category, index) => ( <ListItem key={category.slug} href={`/posts?category=${category.slug}`} className={cn( categories.length === index + 1 && 'mb-0.5', categoryParam === category.slug && 'bg-accent', )} > {category.title} </ListItem> ))} </div> </NavigationMenuContent> </NavigationMenuItem> <NavigationMenuIndicator /> </NavigationMenuList> </NavigationMenu> </div> );};
もし pathname
が/posts
でuseSearchParams
をSuspense
で
export const Header = ({ className }: { className?: string }) => { return ( ... <Suspense fallback={<MainNavigationFallback />}> <MainNavigation /> </Suspense> ... );};
ページをBlog
とpathname
が/posts
で
応じて ヘッダを 表示・非 表示させる
スクロールにヘッダを
何か
この
動きに
まずは
export const useHeaderAnimation = () => { const [animationHeader, setAnimationHeader] = React.useState<boolean | null>(null); const [previousYPosition, setPreviousYPosition] = React.useState<number>( typeof window !== 'undefined' ? window.scrollY : 0, ); const [debouncePreviousYPosition] = useDebounce(previousYPosition, 50); const headerRef = React.useRef<HTMLElement | null>(null);
const headerFrom = () => ({ y: 0, });
const headerTo = (headerHeight: number) => ({ y: -headerHeight, });
const animationState = () => { if (animationHeader === null || headerRef.current === null) return; return animationHeader ? headerFrom() : headerTo(headerRef.current.offsetHeight); };
React.useEffect(() => { const handleScroll = () => { if (headerRef.current === null) return;
const currentYPos = window.scrollY; const headerHeight = headerRef.current.offsetHeight / 2;
if (currentYPos < previousYPosition) { setAnimationHeader(true); } else if (currentYPos > headerHeight && currentYPos > previousYPosition) { setAnimationHeader(false); }
setPreviousYPosition(currentYPos); };
window.addEventListener('scroll', handleScroll, false);
return () => window.removeEventListener('scroll', handleScroll, false); }, [debouncePreviousYPosition, previousYPosition]);
return { animationState, headerRef };};
animationState
はheaderFrom
またはheaderTo
であり、headerRef
は
続いて、
export const Header = ({ className }: { className?: string }) => { const { animationState, headerRef } = useHeaderAnimation();
return ( <motion.header transition={{ ease: [0.1, 0.25, 0.3, 1], duration: 0.6 }} animate={animationState()} ref={headerRef} className={cn('fixed top-0 w-[calc(100%-var(--removed-body-scroll-bar-size,0px))]', className)} > ... </motion.header> );};
ここでは[0.1, 0.25, 0.3, 1]
は
ヘッダに
ページネーション
次に
今までは
ページネーションの
ページネーションと...
)
// 前提としてページ数は 10
// 現在のページ = 1<Previous ① 2 3 ... 10 Next>
// 現在のページ = 4<Previous 1 ... 3 ④ 5 ... 10 Next>
// 現在のページ = 10<Previous 1 ... 8 9 ⑩ Next>
ロジック部分は
export const usePagination = (data: Post[], itemsPerPage = 10) => { const _searchParams = useSearchParams(); const searchParams = React.useMemo(() => new URLSearchParams(_searchParams), [_searchParams]);
const router = useRouter();
const [currentPage, setCurrentPage] = React.useState(1); const [currentCategory, setCurrentCategory] = React.useState('all');
const maxPage = React.useMemo(() => Math.ceil(data.length / itemsPerPage), [data, itemsPerPage]);
const currentData = () => { const begin = (currentPage - 1) * itemsPerPage; const end = begin + itemsPerPage; return data.slice(begin, end); };
const changePage = (newPage: number) => { const pageNumber = Math.max(1, newPage); setCurrentPage(() => Math.min(pageNumber, maxPage)); searchParams.set('page', String(pageNumber)); router.replace(`/posts?${searchParams.toString()}`); };
const next = () => changePage(currentPage + 1);
const prev = () => changePage(currentPage - 1);
const jump = (page: number) => changePage(page);
React.useEffect(() => { const category = searchParams.get('category') ?? 'all';
if (currentCategory !== category) { setCurrentCategory(category ?? 'all'); setCurrentPage(1); return; }
const page = Number(searchParams.get('page'));
if (page && page > 0 && page <= maxPage) { setCurrentPage(page); } }, [currentCategory, maxPage, searchParams]);
return { next, prev, jump, currentData, currentPage, maxPage };};
SNS でhttps://kkhys.me/posts?category=tech&page=2
のような
次に
export const Pagination = ({ className, next, prev, jump, currentPage, maxPage,}: { className?: string; next: () => void; prev: () => void; jump: (page: number) => void; currentPage: number; maxPage: number;}) => { const pages = [...Array(maxPage).keys()].map((i) => i + 1);
const hasLeftEllipsis = currentPage > 3; const hasRightEllipsis = currentPage < maxPage - 2;
const leftEdgePage = 1; const rightEdgePage = maxPage; let visiblePages: (number | '...')[];
if (hasLeftEllipsis && hasRightEllipsis) { visiblePages = [leftEdgePage, '...', currentPage - 1, currentPage, currentPage + 1, '...', rightEdgePage]; } else if (hasLeftEllipsis && !hasRightEllipsis) { visiblePages = [leftEdgePage, '...', ...pages.slice(-3)]; } else if (!hasLeftEllipsis && hasRightEllipsis) { visiblePages = [...pages.slice(0, 3), '...', rightEdgePage]; } else { visiblePages = pages; }
return ( <_Pagination className={className}> <PaginationContent> <PaginationItem> <PaginationPrevious onClick={prev} isDisabled={currentPage === 1} /> </PaginationItem> {visiblePages.map((page, i) => ( <PaginationItem key={i} className='hidden sm:block'> {typeof page === 'string' ? ( <PaginationEllipsis /> ) : ( <PaginationLink onClick={() => jump(page)} isActive={currentPage === page}> {page} </PaginationLink> )} </PaginationItem> ))} <PaginationItem> <PaginationNext onClick={next} isDisabled={currentPage === maxPage} /> </PaginationItem> </PaginationContent> </_Pagination> );};
表示する
最後に
export const ArticleList = ({ posts: _posts,}: { posts: Post[];}) => { const { next, prev, jump, currentData, currentPage, maxPage } = usePagination(_posts);
const posts = currentData();
return ( <> ... {maxPage > 1 && ( <Pagination className='mt-12' next={next} prev={prev} jump={jump} currentPage={currentPage} maxPage={maxPage} /> )} </> );};
ズーム機能
画像のv1.8.0 最後の

Framer Motion の
Next.js でも
一応
ちなみに
View Transition API が
ごに
さい今は
文中にも