diff --git a/.gitignore b/.gitignore index 4c55f13..9bafa4d 100644 --- a/.gitignore +++ b/.gitignore @@ -39,4 +39,9 @@ next-env.d.ts /.vscode/ # serena -/.serena/ \ No newline at end of file +/.serena/ + +# codex +/.codex/ + +skills-lock.json \ No newline at end of file diff --git a/README.md b/README.md index 30e42ba..9b065a8 100644 --- a/README.md +++ b/README.md @@ -17,45 +17,68 @@

-Next.jsの学習用としてこのプロジェクトを作成しました。プロジェクトの構成としては以下になります。 +Next.js App Router と microCMS を使った技術ブログプロジェクトです。 +記事一覧、記事詳細、検索ページを備えたシンプルな構成で、Tailwind CSS を使って UI を実装しています。 -Next.js + directory + microCMS + TailWind CSS +## Tech Stack -このプロジェクトでは以下の機能を実装しています。 -* microCMSに投稿した記事の検索 -* microCMSに投稿した記事のカテゴリ絞り込み -* microCMSに投稿した記事の内容取得 +- Next.js +- React +- TypeScript +- microCMS +- Tailwind CSS +- ESLint +## Features -## Usage +- microCMS から取得した記事一覧の表示 +- 記事詳細ページの表示 +- キーワード検索とカテゴリ絞り込み +- sitemap / robots.txt の生成 + +## Setup + +依存パッケージをインストールします。 -パッケージのインストール。 ```bash npm ci ``` -プロジェクトのルート配下に「.env.local」を新規作成して、以下を追加します。 +プロジェクトルートに `.env.local` を作成し、microCMS の設定を追加します。 + ```bash -# microCMSのAPIキーを記載します -API_KEY=XXXXXXXXXXXXXXXXXX -# microCMSのサービス名を記載します -SERVICE_DOMAIN=hoge +API_KEY=YOUR_MICROCMS_API_KEY +SERVICE_DOMAIN=YOUR_MICROCMS_SERVICE_DOMAIN ``` -ローカルサーバーの起動。 +## Development + +ローカルサーバーを起動します。 + ```bash npm run dev ``` -サーバー起動後は以下のURLより、アプリの動作確認が可能です。 +起動後、以下の URL で確認できます。 + +```text http://localhost:3000 +``` + +## Build + +本番ビルドを作成します。 +```bash +npm run build +``` ## Demo -* 記事検索 + +### 記事一覧 -* 記事閲覧 +### 記事詳細 - \ No newline at end of file + diff --git a/package-lock.json b/package-lock.json index 5d146e8..3f3ea18 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,24 +12,18 @@ "@types/node": "^25.5.0", "@types/react-dom": "19.2.3", "@vercel/analytics": "^2.0.1", - "axios": "^1.13.6", "clsx": "^2.1.1", "eslint": "^9.39.3", "html-react-parser": "^5.2.17", - "lucide-react": "^0.577.0", "microcms-js-sdk": "^3.3.0", "next": "^16.1.7", "next-sitemap": "^4.2.3", "react": "^19.2.4", - "react-device-detect": "^2.2.3", "react-dom": "^19.2.4", "server-only": "^0.0.1", - "simplebar-react": "^3.3.2", - "swr": "^2.4.1", "tailwindcss": "^4.2.1", "typescript": "^5.9.3", - "vercel": "^50.32.5", - "zod": "^4.3.6" + "vercel": "^50.32.5" }, "devDependencies": { "@stylistic/eslint-plugin": "^5.10.0", @@ -4788,17 +4782,6 @@ "node": ">=4" } }, - "node_modules/axios": { - "version": "1.13.6", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", - "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.15.11", - "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" - } - }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -5312,15 +5295,6 @@ "node": ">= 0.6" } }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -6430,26 +6404,6 @@ "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", "license": "ISC" }, - "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -7975,18 +7929,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", - "license": "MIT" - }, - "node_modules/lodash-es": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz", - "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", - "license": "MIT" - }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -8014,15 +7956,6 @@ "node": "20 || >=22" } }, - "node_modules/lucide-react": { - "version": "0.577.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.577.0.tgz", - "integrity": "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A==", - "license": "ISC", - "peerDependencies": { - "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, "node_modules/luxon": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", @@ -9032,18 +8965,6 @@ "node": ">=0.10.0" } }, - "node_modules/react-device-detect": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/react-device-detect/-/react-device-detect-2.2.3.tgz", - "integrity": "sha512-buYY3qrCnQVlIFHrC5UcUoAj7iANs/+srdkwsnNjI7anr3Tt7UY6MqNxtMLlr0tMBied0O49UZVK8XKs3ZIiPw==", - "dependencies": { - "ua-parser-js": "^1.0.33" - }, - "peerDependencies": { - "react": ">= 0.14.0", - "react-dom": ">= 0.14.0" - } - }, "node_modules/react-dom": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", @@ -9543,28 +9464,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/simplebar-core": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/simplebar-core/-/simplebar-core-1.3.2.tgz", - "integrity": "sha512-qKgTTuTqapjsFGkNhCjyPhysnbZGpQqNmjk0nOYjFN5ordC/Wjvg+RbYCyMSnW60l/Z0ZS82GbNltly6PMUH1w==", - "license": "MIT", - "dependencies": { - "lodash": "^4.17.21", - "lodash-es": "^4.17.21" - } - }, - "node_modules/simplebar-react": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/simplebar-react/-/simplebar-react-3.3.2.tgz", - "integrity": "sha512-ZsgcQhKLtt5ra0BRIJeApfkTBQCa1vUPA/WXI4HcYReFt+oCEOvdVz6rR/XsGJcKxTlCRPmdGx1uJIUChupo+A==", - "license": "MIT", - "dependencies": { - "simplebar-core": "^1.3.2" - }, - "peerDependencies": { - "react": ">=16.8.0" - } - }, "node_modules/smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", @@ -9930,19 +9829,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/swr": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/swr/-/swr-2.4.1.tgz", - "integrity": "sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA==", - "license": "MIT", - "dependencies": { - "dequal": "^2.0.3", - "use-sync-external-store": "^1.6.0" - }, - "peerDependencies": { - "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, "node_modules/tailwindcss": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", @@ -10288,28 +10174,6 @@ "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/ua-parser-js": { - "version": "1.0.37", - "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.37.tgz", - "integrity": "sha512-bhTyI94tZofjo+Dn8SN6Zv8nBDvyXTymAdM3LDI/0IboIUwTu1rEhW7v2TfiVsoYWgkQ4kOVqnI8APUFbIQIFQ==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/ua-parser-js" - }, - { - "type": "paypal", - "url": "https://paypal.me/faisalman" - }, - { - "type": "github", - "url": "https://github.com/sponsors/faisalman" - } - ], - "engines": { - "node": "*" - } - }, "node_modules/uid-promise": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/uid-promise/-/uid-promise-1.0.0.tgz", @@ -10449,15 +10313,6 @@ "punycode": "^2.1.0" } }, - "node_modules/use-sync-external-store": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", - "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", - "license": "MIT", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, "node_modules/vercel": { "version": "50.32.5", "resolved": "https://registry.npmjs.org/vercel/-/vercel-50.32.5.tgz", @@ -10713,6 +10568,7 @@ "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index 3f8131a..025ae82 100644 --- a/package.json +++ b/package.json @@ -15,24 +15,18 @@ "@types/node": "^25.5.0", "@types/react-dom": "19.2.3", "@vercel/analytics": "^2.0.1", - "axios": "^1.13.6", "clsx": "^2.1.1", "eslint": "^9.39.3", "html-react-parser": "^5.2.17", - "lucide-react": "^0.577.0", "microcms-js-sdk": "^3.3.0", "next": "^16.1.7", "next-sitemap": "^4.2.3", "react": "^19.2.4", - "react-device-detect": "^2.2.3", "react-dom": "^19.2.4", "server-only": "^0.0.1", - "simplebar-react": "^3.3.2", - "swr": "^2.4.1", "tailwindcss": "^4.2.1", "typescript": "^5.9.3", - "vercel": "^50.32.5", - "zod": "^4.3.6" + "vercel": "^50.32.5" }, "devDependencies": { "@stylistic/eslint-plugin": "^5.10.0", diff --git a/src/app/[others]/page.tsx b/src/app/[others]/page.tsx index 0a601f6..4e0c2e7 100644 --- a/src/app/[others]/page.tsx +++ b/src/app/[others]/page.tsx @@ -2,7 +2,7 @@ import Link from 'next/link' export const metadata = { title: 'Not Found | N-LAB', - description: 'ページが見つかりません。', + description: 'ページが見つかりませんでした。', } export default function NonexistentPage() { @@ -14,18 +14,20 @@ export default function NonexistentPage() { 404 -

Page Not Found.

+

+ ページが見つかりません +

- お探しのページは削除されたか、 + お探しのページは見つかりませんでした。
- URLが変更された可能性があります。 + URL が変更されたか、ページが削除された可能性があります。

- Back to Top + トップへ戻る diff --git a/src/app/api/list/route.ts b/src/app/api/list/route.ts deleted file mode 100644 index 860831a..0000000 --- a/src/app/api/list/route.ts +++ /dev/null @@ -1,43 +0,0 @@ -import 'server-only' -import { htmlspecialchars } from '@/features/common/sanitize' -import { NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' -import { Article } from '@/types' -import { getArticleList } from '@/libs/microcms/client' - -export type ArticleListResponse = { - articleList: Article[] -} - -const userSchema = z.object({ - keyword: z.string(), -}) - -type RequestBody = { - keyword: string -} - -export const POST = async (request: NextRequest) => { - const body = (await request.json()) as RequestBody - const result = userSchema.safeParse(body) - - if (!result.success) { - return NextResponse.json([], { status: 400 }) - } - - const keyword = htmlspecialchars(result.data.keyword.trim()) - const articleList = ( - await getArticleList('id,title,overview,svgPath,category,createdDate', keyword) - )?.contents - - if (!articleList) { - return NextResponse.json([], { status: 500 }) - } - - return NextResponse.json( - { articleList }, - { - status: 200, - }, - ) -} diff --git a/src/app/articles/[id]/page.tsx b/src/app/articles/[id]/page.tsx index 724cacc..6a4cd95 100644 --- a/src/app/articles/[id]/page.tsx +++ b/src/app/articles/[id]/page.tsx @@ -5,11 +5,12 @@ import { htmlspecialchars } from '@/features/common/sanitize' import { ArticleHeader } from '@/features/articles/components/ArticleHeader' import { ArticleBody } from '@/features/articles/components/ArticleBody' +export const revalidate = 3600 + type Props = { params: Promise<{ id: string }> } -// Dynamic Route使用時にSSGでビルドする export async function generateStaticParams() { try { const response = await getArticleList('id') @@ -47,7 +48,7 @@ export async function generateMetadata(props: Props): Promise { catch { return { title: 'Not Found | N-LAB', - description: '記事が見つかりません', + description: '記事が見つかりませんでした。', } } } diff --git a/src/app/error/page.tsx b/src/app/error/page.tsx index 9037bf6..8ca6305 100644 --- a/src/app/error/page.tsx +++ b/src/app/error/page.tsx @@ -13,12 +13,12 @@ export default function Error() {

- Something went wrong. + エラーが発生しました

- 予期せぬエラーが発生しました。 + 予期しないエラーが発生しました。
- しばらく時間をおいてから、再度お試しください。 + 時間をおいて再度お試しいただくか、トップページへお戻りください。

@@ -26,7 +26,7 @@ export default function Error() { href="/" className="group inline-flex items-center gap-3 px-8 py-3 rounded-full border border-border bg-white/5 text-[#e2e8f0] font-semibold transition-all duration-300 hover:border-white hover:bg-white/10" > - Back to Top + トップへ戻る
diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 77921ef..bc5d60e 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,17 +2,15 @@ import './globals.css' import { Inter, Fira_Code } from 'next/font/google' import Link from 'next/link' import type { Metadata } from 'next' -import { Analytics } from '@vercel/analytics/react' +import AnalyticsProvider from '@/features/common/components/AnalyticsProvider' const inter = Inter({ subsets: ['latin'], variable: '--font-inter' }) const firaCode = Fira_Code({ subsets: ['latin'], variable: '--font-fira' }) export const metadata: Metadata = { title: 'N-Laboratory | Engineering the Modern Web', - description: - 'Next.jsやNuxt.js、Spring BootなどのフレームワークやAWSを中心とした技術のナレッジを発信するサイトです', - + 'Next.js、Nuxt.js、Spring Boot、AWS などを中心に、モダンな Web 開発の知見を発信するサイトです。', icons: { icon: '/favicon.ico', shortcut: '/favicon.ico', @@ -33,7 +31,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
- + N-LAB . @@ -42,13 +40,14 @@ export default function RootLayout({ children }: { children: React.ReactNode })
    {itemList.map(item => (
  • - {item.title} - +
  • ))}
@@ -60,7 +59,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })

© 2025 N-Laboratory. All Rights Reserved.

- + ) @@ -69,4 +68,3 @@ export default function RootLayout({ children }: { children: React.ReactNode }) export const viewport = { themeColor: '#000000', } - diff --git a/src/app/loading.tsx b/src/app/loading.tsx index f91ff10..57c60f2 100644 --- a/src/app/loading.tsx +++ b/src/app/loading.tsx @@ -9,9 +9,9 @@ export default function Loading() {

- LOADING... + 読み込み中...

-

データを取得中です。少々お待ちください。

+

データを読み込んでいます。少々お待ちください。

) diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx index 5d9bd1c..75eb9b9 100644 --- a/src/app/not-found.tsx +++ b/src/app/not-found.tsx @@ -2,7 +2,7 @@ import Link from 'next/link' export const metadata = { title: 'Not Found | N-LAB', - description: 'ページが見つかりません。', + description: 'ページが見つかりませんでした。', } export default function NotFound() { @@ -15,18 +15,20 @@ export default function NotFound() { 404 -

Page Not Found.

+

+ ページが見つかりません +

- お探しのページは削除されたか、 + お探しのページは見つかりませんでした。
- URLが変更された可能性があります。 + URL が変更されたか、ページが削除された可能性があります。

- Back to Top + トップへ戻る diff --git a/src/app/page.tsx b/src/app/page.tsx index 6f01319..010a991 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,10 +1,12 @@ -import { getArticleList } from '@/libs/microcms/client' import { notFound } from 'next/navigation' +import { getArticleList } from '@/libs/microcms/client' import TopPage from '@/features/home/components/TopPage' +export const revalidate = 300 + export const metadata = { title: 'Home | N-LAB', - description: 'このウェブサイトは日々の業務を通じて学習したIT技術を備忘録も兼ねて掲載しています。', + description: '技術記事と実装メモを掲載している N-LAB のトップページです。', } const Home = async () => { diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx new file mode 100644 index 0000000..2c12024 --- /dev/null +++ b/src/app/search/page.tsx @@ -0,0 +1,45 @@ +import { notFound } from 'next/navigation' +import { getArticleList } from '@/libs/microcms/client' +import TopPage from '@/features/home/components/TopPage' + +export const metadata = { + title: 'Search | N-LAB', + description: 'N-LAB の記事検索ページです。', +} + +type SearchPageProps = { + searchParams: Promise<{ + keyword?: string | string[] + category?: string | string[] + }> +} + +const normalizeSearchParam = (value?: string | string[]) => { + if (Array.isArray(value)) { + return value[0]?.trim() ?? '' + } + + return value?.trim() ?? '' +} + +const SearchPage = async ({ searchParams }: SearchPageProps) => { + const resolvedSearchParams = await searchParams + const keyword = normalizeSearchParam(resolvedSearchParams.keyword) + const category = normalizeSearchParam(resolvedSearchParams.category) || 'All' + + const articleList = await getArticleList( + 'id,title,overview,svgPath,category,createdDate', + keyword || undefined, + ).catch(() => notFound()) + + return ( + + ) +} + +export default SearchPage diff --git a/src/features/articles/components/ArticleHeader.tsx b/src/features/articles/components/ArticleHeader.tsx index 4defc7d..37cfa07 100644 --- a/src/features/articles/components/ArticleHeader.tsx +++ b/src/features/articles/components/ArticleHeader.tsx @@ -46,7 +46,8 @@ export const ArticleHeader = ({ title, dateString }: Props) => {
- 投稿日: + 公開日: + {' '} {formatDate(dateString)}
diff --git a/src/features/articles/components/Code.module.css b/src/features/articles/components/Code.module.css index 9282782..8cd9cf4 100644 --- a/src/features/articles/components/Code.module.css +++ b/src/features/articles/components/Code.module.css @@ -1,6 +1,8 @@ .scrollArea { - --sb-track-color: #232e33 / 20%; - --sb-thumb-color: #3fd195; + overflow-x: auto; + overflow-y: hidden; + scrollbar-color: #3fd195 rgb(35 46 51 / 20%); + scrollbar-width: thin; } .code { @@ -12,20 +14,16 @@ color: #000000; } -.scrollArea :global(.simplebar-track.simplebar-horizontal) { +.scrollArea::-webkit-scrollbar { height: 9px; - background: var(--sb-track-color); - border-radius: 10px; } -.scrollArea :global(.simplebar-track.simplebar-vertical) { - width: 9px; - background: var(--sb-track-color); +.scrollArea::-webkit-scrollbar-track { + background: rgb(35 46 51 / 20%); border-radius: 10px; } -.scrollArea :global(.simplebar-scrollbar:before) { - background: var(--sb-thumb-color); +.scrollArea::-webkit-scrollbar-thumb { + background: #3fd195; border-radius: 10px; - opacity: 1; } diff --git a/src/features/articles/components/Code.tsx b/src/features/articles/components/Code.tsx index a94cd96..ce535a7 100644 --- a/src/features/articles/components/Code.tsx +++ b/src/features/articles/components/Code.tsx @@ -1,7 +1,4 @@ -'use client' import styles from './Code.module.css' -import SimpleBar from 'simplebar-react' -import 'simplebar-react/dist/simplebar.min.css' import type { JSX } from 'react' @@ -16,11 +13,11 @@ type Props = { const Code = ({ props, jsx }: Props) => { return ( - +
         {jsx}
       
- +
) } diff --git a/src/features/articles/utils/parser.tsx b/src/features/articles/utils/parser.tsx index 84a6ad1..9fa8752 100644 --- a/src/features/articles/utils/parser.tsx +++ b/src/features/articles/utils/parser.tsx @@ -8,7 +8,6 @@ import { } from 'html-react-parser' import Code from '@/features/articles/components/Code' -// 不要な背景色や特定の色指定を削除 const stylesToRemove = [ 'background-color:#ffffff', 'background-color:#f3f2f2', @@ -25,9 +24,6 @@ const fileNameStyles = [ 'background-color:#dddddd', ] -/** - * HTML属性のスタイル調整を行うヘルパー関数 - */ const sanitizeNodeAttributes = (node: Element) => { const { name, attribs } = node @@ -38,22 +34,17 @@ const sanitizeNodeAttributes = (node: Element) => { node.attribs.style = '' } - // ファイル名表示のような特定のスタイルを置換 if (isTextElement && fileNameStyles.some(s => style?.includes(s))) { node.attribs.style = 'font-family: var(--font-mono); background: rgba(255, 255, 255, 0.05); padding: 2px 6px; border-radius: 4px; color: #e2e8f0; font-size: 0.85em;' } - // リンクのスタイル置換 if (name === 'a') { node.attribs.style = 'color: rgb(63 209 149); overflow-wrap: anywhere; word-break: normal; line-break: strict;' } } -/** - * html-react-parser用のオプション設定 - */ export const articleParseOptions: HTMLReactParserOptions = { replace: (domNode) => { if (!(domNode instanceof Element && domNode.attribs)) return @@ -63,7 +54,7 @@ export const articleParseOptions: HTMLReactParserOptions = { switch (domNode.name) { case 'h1': - return <> // H1は別途表示するため除外 + return <> case 'h2': return ( @@ -85,7 +76,6 @@ export const articleParseOptions: HTMLReactParserOptions = { case 'p': domNode.children.forEach((childNode) => { if (childNode instanceof Element && childNode.attribs) { - // Tailwind CSSで上書きできない場合のスタイル上書き処理 sanitizeNodeAttributes(childNode) } }) @@ -159,7 +149,6 @@ export const articleParseOptions: HTMLReactParserOptions = { ) default: - // ファイル名クラスの特別処理 if (domNode.attribs.class?.includes('filename')) { return ( diff --git a/src/features/common/components/AnalyticsProvider.tsx b/src/features/common/components/AnalyticsProvider.tsx new file mode 100644 index 0000000..c873520 --- /dev/null +++ b/src/features/common/components/AnalyticsProvider.tsx @@ -0,0 +1,14 @@ +'use client' + +import dynamic from 'next/dynamic' + +const Analytics = dynamic( + () => import('@vercel/analytics/react').then(mod => mod.Analytics), + { ssr: false }, +) + +const AnalyticsProvider = () => { + return +} + +export default AnalyticsProvider diff --git a/src/features/home/components/ArticleCard.tsx b/src/features/home/components/ArticleCard.tsx index 8fa08c2..6c2d8aa 100644 --- a/src/features/home/components/ArticleCard.tsx +++ b/src/features/home/components/ArticleCard.tsx @@ -1,4 +1,3 @@ -import { memo } from 'react' import Link from 'next/link' import { Article } from '@/types' @@ -6,7 +5,7 @@ type Props = { article: Article } -const ArticleCard = memo(({ article }: Props) => { +const ArticleCard = ({ article }: Props) => { return ( {

{article.overview}

- Read Article + 記事を読む
) -}) -ArticleCard.displayName = 'ArticleCard' +} export default ArticleCard diff --git a/src/features/home/components/TopPage.tsx b/src/features/home/components/TopPage.tsx index f96f62b..10501f8 100644 --- a/src/features/home/components/TopPage.tsx +++ b/src/features/home/components/TopPage.tsx @@ -1,32 +1,63 @@ -'use client' - -import React from 'react' +import Link from 'next/link' +import { clsx } from 'clsx' import { Article } from '@/types' -import { useArticleSearch } from './useArticleSearch' import ArticleCard from './ArticleCard' -import { clsx } from 'clsx' type Props = { articleList: Article[] + currentKeyword?: string + currentCategory?: string + searchPath?: string } -const TopPage = ({ articleList }: Props) => { - const { - displayedArticles, - categories, - activeCategory, - isLoading, - handleCategorySelect, - handleSearch, - } = useArticleSearch(articleList) - - const onSearchSubmit = (e: React.FormEvent) => { - e.preventDefault() - const formData = new FormData(e.currentTarget) - const keyword = formData.get('keyword') as string - handleSearch(keyword || '') +const DEFAULT_SEARCH_PATH = '/search' + +const buildSearchHref = (searchPath: string, keyword?: string, category?: string) => { + const searchParams = new URLSearchParams() + + if (keyword) { + searchParams.set('keyword', keyword) } + if (category && category !== 'All') { + searchParams.set('category', category) + } + + const queryString = searchParams.toString() + return queryString ? `${searchPath}?${queryString}` : searchPath +} + +const getCategories = (articleList: Article[]) => { + const categorySet = new Set() + + articleList.forEach((article) => { + if (article.category) { + categorySet.add(article.category) + } + }) + + const sortedCategories = Array.from(categorySet).toSorted((a, b) => { + if (a === 'Others') return 1 + if (b === 'Others') return -1 + + return a.localeCompare(b, 'ja') + }) + + return ['All', ...sortedCategories] +} + +const TopPage = ({ + articleList, + currentKeyword = '', + currentCategory = 'All', + searchPath = DEFAULT_SEARCH_PATH, +}: Props) => { + const categories = getCategories(articleList) + const activeCategory = categories.includes(currentCategory) ? currentCategory : 'All' + const displayedArticles = activeCategory === 'All' + ? articleList + : articleList.filter(article => article.category === activeCategory) + return (
@@ -39,15 +70,16 @@ const TopPage = ({ articleList }: Props) => {
-
+ -
@@ -56,9 +88,9 @@ const TopPage = ({ articleList }: Props) => {
{categories.map(cat => ( - + ))}
@@ -77,21 +109,17 @@ const TopPage = ({ articleList }: Props) => { displayedArticles.length > 3 && 'max-h-160 overflow-y-auto', )} > - {isLoading + {displayedArticles.length > 0 ? ( -
検索中...
+
+ {displayedArticles.map(article => ( + + ))} +
) - : displayedArticles.length > 0 - ? ( -
- {displayedArticles.map(article => ( - - ))} -
- ) - : ( -
記事が見つかりませんでした。
- )} + : ( +
該当する記事は見つかりませんでした。
+ )}
diff --git a/src/features/home/components/useArticleSearch.ts b/src/features/home/components/useArticleSearch.ts deleted file mode 100644 index 572bad1..0000000 --- a/src/features/home/components/useArticleSearch.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { useState, useMemo, useCallback } from 'react' -import useSWR from 'swr' -import axios from 'axios' -import { Article } from '@/types' -import { ArticleListResponse } from '@/app/api/list/route' - -const fetcher = async (url: string, keyword: string): Promise => { - const { data } = await axios.post(url, { keyword }) - return data -} - -export const useArticleSearch = (initialArticles: Article[]) => { - const [activeCategory, setActiveCategory] = useState('All') - const [searchKeyword, setSearchKeyword] = useState('') - - const { data, isLoading } = useSWR( - searchKeyword ? ['/api/list', searchKeyword] : null, - ([url, keyword]: [string, string]) => fetcher(url, keyword), - { - shouldRetryOnError: false, - revalidateOnFocus: false, - keepPreviousData: true, - }, - ) - - const sourceArticles = useMemo(() => { - if (searchKeyword && data?.articleList) { - return data.articleList - } - return initialArticles - }, [searchKeyword, data, initialArticles]) - - const categories = useMemo(() => { - const categorySet = new Set() - - sourceArticles.forEach((article) => { - if (article.category) { - categorySet.add(article.category) - } - }) - - const sortedCategories = Array.from(categorySet).sort((a, b) => { - if (a === 'Others') return 1 - if (b === 'Others') return -1 - - return a.localeCompare(b, 'ja') - }) - - return ['All', ...sortedCategories] - }, [sourceArticles]) - - const displayedArticles = useMemo(() => { - if (activeCategory === 'All') return sourceArticles - return sourceArticles.filter(article => article.category === activeCategory) - }, [activeCategory, sourceArticles]) - - const handleCategorySelect = useCallback((category: string) => { - setActiveCategory(category) - }, []) - - const handleSearch = useCallback((keyword: string) => { - if (!keyword.trim()) { - setSearchKeyword('') - return - } - setSearchKeyword(keyword) - setActiveCategory('All') - }, []) - - return { - displayedArticles, - categories, - activeCategory, - isLoading: isLoading && !!searchKeyword, - handleCategorySelect, - handleSearch, - } -} diff --git a/src/libs/microcms/client.ts b/src/libs/microcms/client.ts index 5777735..63ffd09 100644 --- a/src/libs/microcms/client.ts +++ b/src/libs/microcms/client.ts @@ -1,16 +1,14 @@ import 'server-only' +import { cache } from 'react' import { createClient, MicroCMSQueries } from 'microcms-js-sdk' -import { Article, ArticleList } from '@/types' +import { Article } from '@/types' -// vercel上でmicroCMSからデータフェッチした場合、なぜか最新のデータを取得できないので -// URLにclearCacheを付加することで上記事象を解消する(ローカルでは最新のデータを取得できる) -// 数日たつとvercel上でも最新のデータを取得できる(vercelのキャッシュが効いている?) -// 以下のvercelが推奨するキャッシュ対策を実装したが変化はなし -// https://nextjs.org/docs/app/building-your-application/data-fetching/caching -// https://vercel.com/docs/concepts/edge-network/caching -type CustomMicroCMSQueries = MicroCMSQueries & { - clearCache?: string -} +// microCMS への取得は Next.js の Data Cache と React.cache() を前提に管理する。 +// - 記事一覧は比較的短い間隔で再検証する +// - 記事詳細は更新頻度が低い前提で長めにキャッシュする +// - 同一リクエスト内の重複 fetch は React.cache() で抑制する +const ARTICLE_LIST_REVALIDATE = 300 +const ARTICLE_DETAIL_REVALIDATE = 3600 if (!process.env.SERVICE_DOMAIN) { throw new Error('SERVICE_DOMAIN is required') @@ -25,29 +23,49 @@ export const client = createClient({ apiKey: process.env.API_KEY, }) -export const getArticle = async (id: string) => { - const article = await client - .getListDetail
({ endpoint: 'article', contentId: id }) - .then(res => res) - .catch(err => console.error(err)) - return article -} +export const getArticle = cache(async (id: string) => { + try { + return await client.getListDetail
({ + endpoint: 'article', + contentId: id, + customRequestInit: { + next: { + revalidate: ARTICLE_DETAIL_REVALIDATE, + tags: ['articles', `article:${id}`], + }, + }, + }) + } + catch (error) { + console.error(error) + throw error + } +}) -export const getArticleList = async (filedNames?: string, keyword?: string) => { - const articleList = await client - .get({ +export const getArticleList = cache(async (fieldNames?: string, keyword?: string) => { + const normalizedKeyword = keyword?.trim() + + try { + return await client.getList
({ endpoint: 'article', queries: { - ...(keyword && { q: keyword }), + ...(normalizedKeyword && { q: normalizedKeyword }), limit: 100, - clearCache: 'true', - fields: filedNames ?? '', + fields: fieldNames ?? '', orders: '-publishedAt', - } as CustomMicroCMSQueries, - }) - .then(res => res) - .catch((err) => { - console.error(err) + } satisfies MicroCMSQueries, + customRequestInit: { + next: { + revalidate: normalizedKeyword ? 60 : ARTICLE_LIST_REVALIDATE, + tags: normalizedKeyword + ? ['articles', `article-search:${normalizedKeyword}`] + : ['articles'], + }, + }, }) - return articleList -} + } + catch (error) { + console.error(error) + throw error + } +}) diff --git a/src/types/index.ts b/src/types/index.ts index 72ccf4a..850eaef 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -12,4 +12,6 @@ export type Article = { overview: string category: string createdDate: string + createdAt: string + publishedAt: string }