From 5f527a4e4d75a5e3fb58e2748ac5f35a1c8f7ea4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Dec 2025 15:08:47 +0000 Subject: [PATCH 01/18] Initial plan From b8a85dfc8525cbca2b1f70c874fdc8e69bf68b37 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Dec 2025 15:09:37 +0000 Subject: [PATCH 02/18] Initial plan From ac8c913ea315c18e989f84aa7f5f2086845553ce Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Dec 2025 15:12:39 +0000 Subject: [PATCH 03/18] Initial plan From 1a5f26852d0f61f56de1b355564f7a7a315f8299 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Dec 2025 15:16:21 +0000 Subject: [PATCH 04/18] Add blog filtering by author and tags Co-authored-by: naoki-00-ito <117070296+naoki-00-ito@users.noreply.github.com> --- src/components/BlogCard.tsx | 38 +++++++++++++++----------- src/lib/getTags.ts | 11 ++++++++ src/pages/author/[name].astro | 50 +++++++++++++++++++++++++++++++++++ src/pages/tag/[tag].astro | 50 +++++++++++++++++++++++++++++++++++ 4 files changed, 133 insertions(+), 16 deletions(-) create mode 100644 src/lib/getTags.ts create mode 100644 src/pages/author/[name].astro create mode 100644 src/pages/tag/[tag].astro diff --git a/src/components/BlogCard.tsx b/src/components/BlogCard.tsx index 3f97448..35a48e6 100644 --- a/src/components/BlogCard.tsx +++ b/src/components/BlogCard.tsx @@ -14,12 +14,14 @@ const BlogCard = ({ blog, color }: Props) => { return ( - - - {blog.data.title} - +
+ + + {blog.data.title} + +
- +
); }; diff --git a/src/lib/getTags.ts b/src/lib/getTags.ts new file mode 100644 index 0000000..41299e9 --- /dev/null +++ b/src/lib/getTags.ts @@ -0,0 +1,11 @@ +import { getCollection } from 'astro:content'; + +const getTags = async () => { + const blogs = await getCollection('blog'); + const allTags = blogs.flatMap(blog => blog.data.tags); + const uniqueTags = [...new Set(allTags)].sort(); + + return uniqueTags; +}; + +export default getTags; diff --git a/src/pages/author/[name].astro b/src/pages/author/[name].astro new file mode 100644 index 0000000..93c4437 --- /dev/null +++ b/src/pages/author/[name].astro @@ -0,0 +1,50 @@ +--- +import '@/styles/globals.css'; +import Layout from '@/layouts/Layout.astro'; +import PageTitle from '@/components/PageTitle.astro'; +import Section from '@/components/Section.astro'; +import BlogCard from '@/components/BlogCard'; +import Breadcrumb from '@/components/Breadcrumb.astro'; +import { getCollection } from 'astro:content'; +import member from '@/data/member'; + +export async function getStaticPaths() { + const authors = member.map(m => m.name); + + return authors.map(author => ({ + params: { name: author }, + })); +} + +const { name } = Astro.params; +const blogs = await getCollection('blog'); +const filteredBlogs = blogs + .filter(blog => blog.data.author === name) + .sort((a, b) => { + const dateA = new Date(a.data.pubDate).getTime(); + const dateB = new Date(b.data.pubDate).getTime(); + return dateB - dateA; + }); + +const title = `Blog - ${name}`; +const description = `${name}が執筆したBlogの一覧ページです。`; +--- + + + +
+ { + filteredBlogs.length > 0 ? ( +
+ {filteredBlogs.map(blog => ( + + ))} +
+ ) : ( +

記事がありません。

+ ) + } +
+ + +
diff --git a/src/pages/tag/[tag].astro b/src/pages/tag/[tag].astro new file mode 100644 index 0000000..565347f --- /dev/null +++ b/src/pages/tag/[tag].astro @@ -0,0 +1,50 @@ +--- +import '@/styles/globals.css'; +import Layout from '@/layouts/Layout.astro'; +import PageTitle from '@/components/PageTitle.astro'; +import Section from '@/components/Section.astro'; +import BlogCard from '@/components/BlogCard'; +import Breadcrumb from '@/components/Breadcrumb.astro'; +import { getCollection } from 'astro:content'; +import getTags from '@/lib/getTags'; + +export async function getStaticPaths() { + const tags = await getTags(); + + return tags.map(tag => ({ + params: { tag }, + })); +} + +const { tag } = Astro.params; +const blogs = await getCollection('blog'); +const filteredBlogs = blogs + .filter(blog => blog.data.tags.includes(tag)) + .sort((a, b) => { + const dateA = new Date(a.data.pubDate).getTime(); + const dateB = new Date(b.data.pubDate).getTime(); + return dateB - dateA; + }); + +const title = `Blog - #${tag}`; +const description = `#${tag}タグが付けられたBlogの一覧ページです。`; +--- + + + +
+ { + filteredBlogs.length > 0 ? ( +
+ {filteredBlogs.map(blog => ( + + ))} +
+ ) : ( +

記事がありません。

+ ) + } +
+ + +
From ce15192d2d1942b18c0efcd43a48c123d27a3ab2 Mon Sep 17 00:00:00 2001 From: Naoki <117070296+naoki-00-ito@users.noreply.github.com> Date: Tue, 9 Dec 2025 00:19:42 +0900 Subject: [PATCH 05/18] =?UTF-8?q?README.md=20=E3=82=92=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 ## コンポーネント追加 From 238414acbc95a10dc3dd7f435a1f737b7681b3bd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Dec 2025 15:19:50 +0000 Subject: [PATCH 06/18] Add support for external blog article links - Add optional externalUrl field to blog schema - Update BlogCard to handle external URLs with icon and badge - Add sample external blog entry for demonstration - External links open in new tab with proper security attributes Co-authored-by: naoki-00-ito <117070296+naoki-00-ito@users.noreply.github.com> --- src/components/BlogCard.tsx | 21 +++++++++++++++++++-- src/content/blog/external-sample.md | 10 ++++++++++ src/content/config.ts | 1 + 3 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 src/content/blog/external-sample.md diff --git a/src/components/BlogCard.tsx b/src/components/BlogCard.tsx index 3f97448..c6b7cf7 100644 --- a/src/components/BlogCard.tsx +++ b/src/components/BlogCard.tsx @@ -3,6 +3,7 @@ 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'; type Props = { blog: CollectionEntry<'blog'>; @@ -11,14 +12,22 @@ 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,6 +36,14 @@ const BlogCard = ({ blog, color }: Props) => {
+ {isExternal && ( + + 外部記事 + + )} {blog.data.tags.map(tag => { return ( Date: Mon, 8 Dec 2025 15:21:01 +0000 Subject: [PATCH 07/18] Update external sample URL to be more realistic Use a complete article path instead of root URL Co-authored-by: naoki-00-ito <117070296+naoki-00-ito@users.noreply.github.com> --- src/content/blog/external-sample.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/content/blog/external-sample.md b/src/content/blog/external-sample.md index 5b7efe3..cf8817c 100644 --- a/src/content/blog/external-sample.md +++ b/src/content/blog/external-sample.md @@ -4,7 +4,7 @@ pubDate: '2024-12-01' description: これは外部サイトで執筆した記事へのリンクのサンプルです。 author: Naoki tags: [Tech, Sample] -externalUrl: https://zenn.dev/ +externalUrl: https://zenn.dev/username/articles/example-article-slug --- この記事は外部リンクのサンプルです。実際にはZennなど外部サイトで執筆した記事のURLを設定します。 From 46639ea20cda28613294467547800a23403db6cb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Dec 2025 15:24:34 +0000 Subject: [PATCH 08/18] Add pagination to blog page using shadcn/ui pagination component Co-authored-by: naoki-00-ito <117070296+naoki-00-ito@users.noreply.github.com> --- src/components/BlogList.tsx | 63 ++++++++++++ src/components/BlogPagination.tsx | 158 ++++++++++++++++++++++++++++++ src/components/ui/pagination.tsx | 118 ++++++++++++++++++++++ src/pages/blog.astro | 12 +-- 4 files changed, 345 insertions(+), 6 deletions(-) create mode 100644 src/components/BlogList.tsx create mode 100644 src/components/BlogPagination.tsx create mode 100644 src/components/ui/pagination.tsx diff --git a/src/components/BlogList.tsx b/src/components/BlogList.tsx new file mode 100644 index 0000000..b929e75 --- /dev/null +++ b/src/components/BlogList.tsx @@ -0,0 +1,63 @@ +import { useState, useEffect } 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 = 10; + +const BlogList = ({ blogs }: Props) => { + const [currentPage, setCurrentPage] = useState(1); + + // Read page from URL on mount + useEffect(() => { + if (typeof window !== 'undefined') { + const params = new URLSearchParams(window.location.search); + const page = Number(params.get('page')) || 1; + setCurrentPage(page); + } + }, []); + + // Update URL when page changes + useEffect(() => { + if (typeof window !== 'undefined') { + 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 = Math.ceil(blogs.length / POSTS_PER_PAGE); + const startIndex = (currentPage - 1) * POSTS_PER_PAGE; + const endIndex = startIndex + POSTS_PER_PAGE; + const currentBlogs = blogs.slice(startIndex, endIndex); + + return ( + <> +
+ {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..8da68a8 --- /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} + className={ + currentPage === 1 ? 'pointer-events-none opacity-50' : '' + } + /> + + + {renderPageNumbers()} + + + e.preventDefault() + } + aria-disabled={currentPage === totalPages} + 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..f3d9f32 --- /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">) => ( +