diff --git a/README.md b/README.md index c166eea..8a77ee2 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,8 @@ ## 環境情報 - pnpm -- Node.js 20.9.0 -- Astro 4.8.2 +- Node.js +- Astro - shadcn/ui ## コンポーネント追加 diff --git a/src/components/BlogCard.tsx b/src/components/BlogCard.tsx index 3f97448..933e7cf 100644 --- a/src/components/BlogCard.tsx +++ b/src/components/BlogCard.tsx @@ -3,6 +3,8 @@ import { Badge } from '@/components/ui/badge'; import member from '@/data/member'; import type { CollectionEntry } from 'astro:content'; import MemberIcon from '@/components/MemberIcon'; +import { ExternalLink } from 'lucide-react'; +import { cn } from '@/lib/utils'; type Props = { blog: CollectionEntry<'blog'>; @@ -11,14 +13,36 @@ type Props = { const BlogCard = ({ blog, color }: Props) => { const author = member.find(m => m.name === blog.data.author); + const isExternal = !!blog.data.externalUrl; + const href = isExternal ? blog.data.externalUrl : `/blog/${blog.slug}`; + const linkProps = isExternal + ? { target: '_blank', rel: 'noopener noreferrer' } + : {}; return ( - - + + +   + +
{blog.data.title} + {isExternal && ( + + )}
@@ -27,29 +51,48 @@ const BlogCard = ({ blog, color }: Props) => {
+ {isExternal && ( + + 外部記事 + + )} {blog.data.tags.map(tag => { return ( - - #{tag} - + + #{tag} + + ); })}
{author && ( Author:  - + {author.name} - + )}
- +
); }; diff --git a/src/components/BlogFilter.astro b/src/components/BlogFilter.astro new file mode 100644 index 0000000..f969332 --- /dev/null +++ b/src/components/BlogFilter.astro @@ -0,0 +1,30 @@ +--- +import getBlog from '@/lib/getBlog'; +import { Badge } from '@/components/ui/badge'; +const blogs = await getBlog(); +--- + +
+ Author: +
+ { + Array.from(new Set(blogs.map(blog => blog.data.author))).map(author => ( + + {author} + + )) + } +
+
+
+ Tag: +
+ { + Array.from(new Set(blogs.map(blog => blog.data.tags).flat())).map(tag => ( + + #{tag} + + )) + } +
+
diff --git a/src/components/BlogList.tsx b/src/components/BlogList.tsx new file mode 100644 index 0000000..d286d62 --- /dev/null +++ b/src/components/BlogList.tsx @@ -0,0 +1,77 @@ +import { useState, useEffect, useMemo, useCallback } from 'react'; +import BlogCard from '@/components/BlogCard'; +import BlogPagination from '@/components/BlogPagination'; +import type { CollectionEntry } from 'astro:content'; + +type Props = { + blogs: CollectionEntry<'blog'>[]; +}; + +const POSTS_PER_PAGE = 20; + +const BlogList = ({ blogs }: Props) => { + const [currentPage, setCurrentPage] = useState(1); + + // Read page from URL on mount + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const page = Number(params.get('page')) || 1; + setCurrentPage(page); + }, []); + + // Update URL when page changes + useEffect(() => { + const url = new URL(window.location.href); + if (currentPage === 1) { + url.searchParams.delete('page'); + } else { + url.searchParams.set('page', String(currentPage)); + } + window.history.pushState({}, '', url); + }, [currentPage]); + + const totalPages = useMemo( + () => Math.ceil(blogs.length / POSTS_PER_PAGE), + [blogs] + ); + + const currentBlogs = useMemo(() => { + const startIndex = (currentPage - 1) * POSTS_PER_PAGE; + const endIndex = startIndex + POSTS_PER_PAGE; + return blogs.slice(startIndex, endIndex); + }, [blogs, currentPage]); + + const handlePageChange = useCallback((page: number) => { + setCurrentPage(page); + + window.scrollTo({ top: 0, behavior: 'smooth' }); + }, []); + + return ( +
+ {totalPages > 1 && ( + + )} + +
+ {currentBlogs.map(blog => ( + + ))} +
+ + {totalPages > 1 && ( + + )} +
+ ); +}; + +export default BlogList; diff --git a/src/components/BlogPagination.tsx b/src/components/BlogPagination.tsx new file mode 100644 index 0000000..6574acf --- /dev/null +++ b/src/components/BlogPagination.tsx @@ -0,0 +1,158 @@ +import { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from '@/components/ui/pagination'; + +type Props = { + currentPage: number; + totalPages: number; + onPageChange?: (page: number) => void; +}; + +const BlogPagination = ({ currentPage, totalPages, onPageChange }: Props) => { + const handlePageClick = (page: number) => (e: React.MouseEvent) => { + e.preventDefault(); + if (onPageChange) { + onPageChange(page); + } else { + // Fallback: navigate using href + const url = page === 1 ? '/blog' : `/blog?page=${page}`; + window.location.href = url; + } + }; + + const getPageUrl = (page: number) => { + return page === 1 ? '/blog' : `/blog?page=${page}`; + }; + + const renderPageNumbers = () => { + const pages = []; + const maxVisiblePages = 5; + + if (totalPages <= maxVisiblePages) { + // Show all pages if total pages is less than or equal to max visible pages + for (let i = 1; i <= totalPages; i++) { + pages.push( + + + {i} + + + ); + } + } else { + // Always show first page + pages.push( + + + 1 + + + ); + + // Show ellipsis if current page is far from start + if (currentPage > 3) { + pages.push( + + + + ); + } + + // Show pages around current page + const startPage = Math.max(2, currentPage - 1); + const endPage = Math.min(totalPages - 1, currentPage + 1); + + for (let i = startPage; i <= endPage; i++) { + pages.push( + + + {i} + + + ); + } + + // Show ellipsis if current page is far from end + if (currentPage < totalPages - 2) { + pages.push( + + + + ); + } + + // Always show last page + pages.push( + + + {totalPages} + + + ); + } + + return pages; + }; + + return ( + + + + 1 ? getPageUrl(currentPage - 1) : '#'} + onClick={ + currentPage > 1 + ? handlePageClick(currentPage - 1) + : e => e.preventDefault() + } + aria-disabled={currentPage === 1 ? 'true' : 'false'} + className={ + currentPage === 1 ? 'pointer-events-none opacity-50' : '' + } + /> + + + {renderPageNumbers()} + + + e.preventDefault() + } + aria-disabled={currentPage === totalPages ? 'true' : 'false'} + className={ + currentPage === totalPages ? 'pointer-events-none opacity-50' : '' + } + /> + + + + ); +}; + +export default BlogPagination; diff --git a/src/components/ui/pagination.tsx b/src/components/ui/pagination.tsx new file mode 100644 index 0000000..cb5023f --- /dev/null +++ b/src/components/ui/pagination.tsx @@ -0,0 +1,118 @@ +import * as React from "react" +import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" +import type { ButtonProps } from "@/components/ui/button" + +const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => ( +