diff --git a/config.yml b/config.yml index e2d47d3..ef43be8 100644 --- a/config.yml +++ b/config.yml @@ -29,16 +29,12 @@ slogan: "As long as the code or the developer is able to run, it's all good." # https://react-icons.github.io/react-icons/icons/fa6/ postCategories: - name: '前端' # Category name, same ad frontmatter category, will show in frontend. - slug: 'frontend' # (Optional) Category slug, if not set, will be same as name. icon: 'FaHtml5' # (Optional) Font Awesome 6 icon name. If not set, no icon. - name: '全栈' - slug: 'fullstack' icon: 'FaCode' - name: '教程' - slug: 'tutorial' icon: 'FaGraduationCap' - name: '随笔' - slug: 'essay' icon: 'FaPen' ####################### diff --git a/eslint.config.mjs b/eslint.config.mjs index 18c6991..46e620f 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,5 +1,6 @@ import { zlAsicaTsReactConfig } from 'eslint-config-zl-asica'; import nextPlugin from '@next/eslint-plugin-next'; +import { PassThrough } from 'stream'; export default [ ...zlAsicaTsReactConfig, @@ -29,4 +30,13 @@ export default [ 'unicorn/filename-case': ['error', { cases: { camelCase: true } }], }, }, + { + files: ['src/app/**/*.ts', 'src/app/**/*.tsx'], + rules: { + 'unicorn/filename-case': [ + 'error', + { cases: { kebabCase: true, pascalCase: true } }, + ], + }, + }, ]; diff --git a/package.json b/package.json index c75faad..61762a0 100644 --- a/package.json +++ b/package.json @@ -24,11 +24,12 @@ "prepare": "husky" }, "dependencies": { + "clsx": "^2.1.1", + "es-toolkit": "^1.27.0", "gray-matter": "^4.0.3", "highlight.js": "^11.10.0", - "marked": "^14.1.3", + "marked": "^15", "next": "15.0.3", - "pinyin": "^4.0.0-alpha.2", "react": "19.0.0-rc-66855b96-20241106", "react-dom": "19.0.0-rc-66855b96-20241106", "react-icons": "^5.3.0", @@ -45,7 +46,7 @@ "eslint-config-zl-asica": "^1", "husky": "^9.1.6", "lint-staged": "^15.2.10", - "postcss": "^8", + "postcss": "^8.4.49", "prettier": "^3", "prettier-plugin-tailwindcss": "^0.6.8", "tailwindcss": "^3.4.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 511ad4e..b568576 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,12 @@ importers: .: dependencies: + clsx: + specifier: ^2.1.1 + version: 2.1.1 + es-toolkit: + specifier: ^1.27.0 + version: 1.27.0 gray-matter: specifier: ^4.0.3 version: 4.0.3 @@ -15,14 +21,11 @@ importers: specifier: ^11.10.0 version: 11.10.0 marked: - specifier: ^14.1.3 - version: 14.1.4 + specifier: ^15 + version: 15.0.0 next: specifier: 15.0.3 version: 15.0.3(react-dom@19.0.0-rc-66855b96-20241106(react@19.0.0-rc-66855b96-20241106))(react@19.0.0-rc-66855b96-20241106) - pinyin: - specifier: ^4.0.0-alpha.2 - version: 4.0.0-alpha.2 react: specifier: 19.0.0-rc-66855b96-20241106 version: 19.0.0-rc-66855b96-20241106 @@ -67,8 +70,8 @@ importers: specifier: ^15.2.10 version: 15.2.10 postcss: - specifier: ^8 - version: 8.4.47 + specifier: ^8.4.49 + version: 8.4.49 prettier: specifier: ^3 version: 3.3.3 @@ -592,6 +595,9 @@ packages: caniuse-lite@1.0.30001679: resolution: {integrity: sha512-j2YqID/YwpLnKzCmBOS4tlZdWprXm3ZmQLBH9ZBXFOhoxLA46fwyBvx6toCBWBmnuwUY/qB3kEU6gFx8qgCroA==} + caniuse-lite@1.0.30001680: + resolution: {integrity: sha512-rPQy70G6AGUMnbwS1z6Xg+RkHYPAi18ihs47GH0jcxIG7wArmPgY3XbS2sRdBbxJljp3thdT8BIqv9ccCypiPA==} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -623,6 +629,10 @@ packages: client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -640,10 +650,6 @@ packages: colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} - commander@1.1.1: - resolution: {integrity: sha512-71Rod2AhcH3JhkBikVpNd0pA+fWsmAaVoti6OR38T76chA7vE3pSerS0Jor4wDw+tOueD2zLVvFOw5H0Rcj7rA==} - engines: {node: '>= 0.6.x'} - commander@12.1.0: resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} engines: {node: '>=18'} @@ -730,8 +736,8 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} - electron-to-chromium@1.5.55: - resolution: {integrity: sha512-6maZ2ASDOTBtjt9FhqYPRnbvKU5tjG0IN9SztUOWYw2AzNDNpKJYLJmlK0/En4Hs/aiWnB+JZ+gW19PIGszgKg==} + electron-to-chromium@1.5.56: + resolution: {integrity: sha512-7lXb9dAvimCFdvUMTyucD4mnIndt/xhRKFAlky0CyFogdnNmdPQNoHI23msF/2V4mpTxMzgMdjK4+YRlFlRQZw==} emoji-regex@10.4.0: resolution: {integrity: sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==} @@ -780,6 +786,9 @@ packages: resolution: {integrity: sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==} engines: {node: '>= 0.4'} + es-toolkit@1.27.0: + resolution: {integrity: sha512-ETSFA+ZJArcuSCpzD2TjAy6UHpx4E4uqFsoDg9F/nTLogrLmVVZQ+zNxco5h7cWnA1nNak07IXsLcaSMih+ZPQ==} + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -1300,9 +1309,6 @@ packages: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} - keypress@0.1.0: - resolution: {integrity: sha512-x0yf9PL/nx9Nw9oLL8ZVErFAk85/lslwEP7Vz7s5SI1ODXZIgit3C5qyWjw4DxOuO/3Hb4866SQh28a1V1d+WA==} - keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -1369,8 +1375,8 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - marked@14.1.4: - resolution: {integrity: sha512-vkVZ8ONmUdPnjCKc5uTRvmkRbx4EAi2OkTOXmfTDhZz3OFqMNBM1oTTWwTr4HY4uAEojhzPf+Fy8F1DWa3Sndg==} + marked@15.0.0: + resolution: {integrity: sha512-0mouKmBROJv/WSHJBPZZyYofUgawMChnD5je/g+aOBXsHDjb/IsnTQj7mnhQZu+qPJmRQ0ecX3mLGEUm3BgwYA==} engines: {node: '>= 18'} hasBin: true @@ -1574,22 +1580,6 @@ packages: resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} engines: {node: '>=0.10.0'} - pinyin@4.0.0-alpha.2: - resolution: {integrity: sha512-SED2wWr1X0QwH6rXIDgg20zS1mAk0AVMO8eM3KomUlOYzC8mNMWZnspZWhhI0M8MBIbF2xwa+5r30jTSjAqNsg==} - engines: {install-node: ^18.0.0} - hasBin: true - peerDependencies: - '@node-rs/jieba': ^1.6.0 - nodejieba: 2.5.2 - segmentit: ^2.0.3 - peerDependenciesMeta: - '@node-rs/jieba': - optional: true - nodejieba: - optional: true - segmentit: - optional: true - pirates@4.0.6: resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==} engines: {node: '>= 6'} @@ -1647,8 +1637,8 @@ packages: resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} engines: {node: ^10 || ^12 || >=14} - postcss@8.4.47: - resolution: {integrity: sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==} + postcss@8.4.49: + resolution: {integrity: sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==} engines: {node: ^10 || ^12 || >=14} prelude-ls@1.2.1: @@ -2617,8 +2607,8 @@ snapshots: browserslist@4.24.2: dependencies: - caniuse-lite: 1.0.30001679 - electron-to-chromium: 1.5.55 + caniuse-lite: 1.0.30001680 + electron-to-chromium: 1.5.56 node-releases: 2.0.18 update-browserslist-db: 1.1.1(browserslist@4.24.2) @@ -2642,6 +2632,8 @@ snapshots: caniuse-lite@1.0.30001679: {} + caniuse-lite@1.0.30001680: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -2678,6 +2670,8 @@ snapshots: client-only@0.0.1: {} + clsx@2.1.1: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -2698,10 +2692,6 @@ snapshots: colorette@2.0.20: {} - commander@1.1.1: - dependencies: - keypress: 0.1.0 - commander@12.1.0: {} commander@4.1.1: {} @@ -2777,7 +2767,7 @@ snapshots: eastasianwidth@0.2.0: {} - electron-to-chromium@1.5.55: {} + electron-to-chromium@1.5.56: {} emoji-regex@10.4.0: {} @@ -2884,6 +2874,8 @@ snapshots: is-date-object: 1.0.5 is-symbol: 1.0.4 + es-toolkit@1.27.0: {} + escalade@3.2.0: {} escape-string-regexp@1.0.5: {} @@ -3459,8 +3451,6 @@ snapshots: object.assign: 4.1.5 object.values: 1.2.0 - keypress@0.1.0: {} - keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -3536,7 +3526,7 @@ snapshots: lru-cache@10.4.3: {} - marked@14.1.4: {} + marked@15.0.0: {} merge-stream@2.0.0: {} @@ -3726,38 +3716,34 @@ snapshots: pify@2.3.0: {} - pinyin@4.0.0-alpha.2: - dependencies: - commander: 1.1.1 - pirates@4.0.6: {} pluralize@8.0.0: {} possible-typed-array-names@1.0.0: {} - postcss-import@15.1.0(postcss@8.4.47): + postcss-import@15.1.0(postcss@8.4.49): dependencies: - postcss: 8.4.47 + postcss: 8.4.49 postcss-value-parser: 4.2.0 read-cache: 1.0.0 resolve: 1.22.8 - postcss-js@4.0.1(postcss@8.4.47): + postcss-js@4.0.1(postcss@8.4.49): dependencies: camelcase-css: 2.0.1 - postcss: 8.4.47 + postcss: 8.4.49 - postcss-load-config@4.0.2(postcss@8.4.47): + postcss-load-config@4.0.2(postcss@8.4.49): dependencies: lilconfig: 3.1.2 yaml: 2.6.0 optionalDependencies: - postcss: 8.4.47 + postcss: 8.4.49 - postcss-nested@6.2.0(postcss@8.4.47): + postcss-nested@6.2.0(postcss@8.4.49): dependencies: - postcss: 8.4.47 + postcss: 8.4.49 postcss-selector-parser: 6.1.2 postcss-selector-parser@6.0.10: @@ -3778,7 +3764,7 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - postcss@8.4.47: + postcss@8.4.49: dependencies: nanoid: 3.3.7 picocolors: 1.1.1 @@ -4135,11 +4121,11 @@ snapshots: normalize-path: 3.0.0 object-hash: 3.0.0 picocolors: 1.1.1 - postcss: 8.4.47 - postcss-import: 15.1.0(postcss@8.4.47) - postcss-js: 4.0.1(postcss@8.4.47) - postcss-load-config: 4.0.2(postcss@8.4.47) - postcss-nested: 6.2.0(postcss@8.4.47) + postcss: 8.4.49 + postcss-import: 15.1.0(postcss@8.4.49) + postcss-js: 4.0.1(postcss@8.4.49) + postcss-load-config: 4.0.2(postcss@8.4.49) + postcss-nested: 6.2.0(postcss@8.4.49) postcss-selector-parser: 6.1.2 resolve: 1.22.8 sucrase: 3.35.0 diff --git a/public/custom.js b/public/custom.js index 5d7b72c..03110b6 100644 --- a/public/custom.js +++ b/public/custom.js @@ -1,48 +1,6 @@ -'use client'; - -// Get the current date -function getCurrentDate() { - const date = new Date(); - const formatNumber = (num) => String(num).padStart(2, '0'); - - const year = date.getFullYear(); - const month = formatNumber(date.getMonth() + 1); - const day = formatNumber(date.getDate()); - const hours = formatNumber(date.getHours()); - const minutes = formatNumber(date.getMinutes()); - const seconds = formatNumber(date.getSeconds()); - - return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; -} - -// Gray scale filter (only for /) on specific dates -function grayScale() { - // Set the date you want to gray scale - const grayScaleDates = ['04-04', '05-12', '09-18', '11-20', '12-13']; - - // Get the current date - const currentDate = getCurrentDate(); - const currentMonthDay = currentDate - .split(' ')[0] - .split('-') - .slice(1) - .join('-'); - - // Check if the current date is in the gray scale date list - if (grayScaleDates.includes(currentMonthDay)) { - // If is, set the gray scale filter - document.body.style.filter = 'grayscale(100%)'; - } -} - // Custom console log // eslint-disable-next-line no-console console.info( '%c由ZL Asica制作搭建与运行\nBuilt and Operated by ZL Asica\nhttps://www.zla.app', 'background:#fff;color:#000' ); - -// Check if the current URL path is "/" -if (window.location.pathname === '/') { - grayScale(); -} diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx index 6b6cb78..f2e0eec 100644 --- a/src/app/about/page.tsx +++ b/src/app/about/page.tsx @@ -1,13 +1,11 @@ import type { Metadata } from 'next'; -import { Suspense } from 'react'; -import Loading from '@/app/loading'; import { getConfig } from '@/services/config'; import { getPostData } from '@/services/content'; import PostLayout from '@/components/layout/PostLayout'; -export async function generateMetadata(): Promise { +function generateMetadata(): Metadata { const config = getConfig(); return { title: `About - ${config.title}`, @@ -25,16 +23,16 @@ export async function generateMetadata(): Promise { }; } -export default async function AboutPage() { +async function AboutPage() { const post: PostData = await getPostData('About', 'About'); const config = getConfig(); return ( - }> - - + ); } + +export { generateMetadata, AboutPage as default }; diff --git a/src/app/categories/[categorySlug]/page.tsx b/src/app/categories/[categorySlug]/page.tsx deleted file mode 100644 index 43d9f3d..0000000 --- a/src/app/categories/[categorySlug]/page.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import type { Metadata } from 'next'; -import { notFound } from 'next/navigation'; -import { Suspense } from 'react'; - -import { getConfig } from '@/services/config'; -import Loading from '@/app/loading'; -import { getAllPosts } from '@/services/content'; - -import PostListLayout from '@/components/layout/PostListLayout'; - -async function generateStaticParams() { - const config = getConfig(); - return config.postCategories.map((category) => ({ - categorySlug: category.slug, - })); -} - -type Properties = { - params: Promise<{ categorySlug: string }>; -}; - -async function generateMetadata({ params }: Properties): Promise { - // read post slug - const { categorySlug: category } = await params; - - const config = getConfig(); - - // Find the category based on the slug from params - const categoryData = config.postCategories.find( - (cat) => cat.slug === category - ) || { name: 'Not Found' }; - return { - title: `分类:${categoryData.name} - ${config.title}`, - openGraph: { - siteName: config.title, - title: `分类:${categoryData.name} - ${config.title}`, - description: `分类:${categoryData.name} - ${config.description}`, - url: `/categories/${category}`, - images: config.avatar, - type: 'website', - locale: config.lang, - }, - }; -} - -async function CategoryPage(props: { - params: Promise<{ categorySlug: string }>; -}) { - const parameters = await props.params; - const posts = await getAllPosts(); - const config = getConfig(); - - // Find the category based on the slug from params - const category = config.postCategories.find( - (cat) => cat.slug === parameters.categorySlug - ); - - if (!category) { - // If the category doesn't exist, show 404 - notFound(); - } - - // Filter posts by the category name - const filteredPosts = posts.filter((post) => - post.frontmatter.categories?.includes(category.name) - ); - - return ( -
-

{category.name}

- }> - - -
- ); -} - -export { generateStaticParams, generateMetadata, CategoryPage as default }; diff --git a/src/app/friends/page.tsx b/src/app/friends/page.tsx index 07decc3..78d019c 100644 --- a/src/app/friends/page.tsx +++ b/src/app/friends/page.tsx @@ -1,14 +1,12 @@ import type { Metadata } from 'next'; -import { Suspense } from 'react'; -import Loading from '@/app/loading'; import { getConfig } from '@/services/config'; import { getPostData } from '@/services/content'; import PostLayout from '@/components/layout/PostLayout'; import '@/styles/friendsLinks.css'; -export async function generateMetadata(): Promise { +function generateMetadata(): Metadata { const config = getConfig(); return { title: `Friends - ${config.title}`, @@ -25,16 +23,16 @@ export async function generateMetadata(): Promise { }; } -export default async function FriendsPage() { +async function FriendsPage() { const post: PostData = await getPostData('Friends', 'Friends'); const config = getConfig(); return ( - }> - - + ); } + +export { generateMetadata, FriendsPage as default }; diff --git a/src/app/globals.css b/src/app/globals.css index ff02b62..51798ee 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -32,6 +32,14 @@ body { } } +form, +input, +select { + font-family: inherit; + color: inherit; + background: inherit; +} + /**************************************************** * Post content styles ****************************************************/ @@ -115,19 +123,27 @@ a:focus-visible { /* Buttons */ button { - color: var(--foreground); + color: var(--background); + background-color: var(--skyblue); border: none; + text-align: center; padding: 0.5rem 1rem; border-radius: 0.25rem; transition: background-color 0.3s; } button:hover, +select:checked, button:focus-visible { background-color: var(--sakuraPink); color: var(--background); } +.header-container button { + color: var(--background); + background-color: var(--skyblue); +} + /**************************************************** * ACCESSIBILITY ****************************************************/ @@ -145,6 +161,7 @@ button:focus-visible { /* Enhance focus for form inputs */ input:focus-visible, +select:focus-visible, textarea:focus-visible, button:focus-visible { outline: 2px solid var(--sakuraPink); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 757ccb4..d35c274 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,6 @@ import type { Metadata } from 'next'; import React from 'react'; -import { Noto_Sans_SC, Roboto } from 'next/font/google'; +import { Noto_Sans_SC } from 'next/font/google'; import Script from 'next/script'; import { getConfig } from '@/services/config'; @@ -12,13 +12,6 @@ import './globals.css'; const config = getConfig(); -const roboto = Roboto({ - subsets: ['latin'], - weight: ['100', '400', '700', '900'], - variable: '--font-roboto', - style: ['normal', 'italic'], -}); - const notoSansSC = Noto_Sans_SC({ subsets: ['latin'], weight: ['100', '400', '700', '900'], @@ -26,7 +19,7 @@ const notoSansSC = Noto_Sans_SC({ style: ['normal'], }); -export const metadata: Metadata = { +const metadata: Metadata = { metadataBase: new URL(config.siteUrl), title: `${config.title} - ${config.subTitle}`, description: config.description, @@ -43,7 +36,7 @@ export const metadata: Metadata = { }, }; -export default function RootLayout({ +function RootLayout({ children, }: Readonly<{ children: React.ReactNode; @@ -56,7 +49,7 @@ export default function RootLayout({ src='/custom.js' strategy='lazyOnload' /> - +
); } + +export { metadata, RootLayout as default }; diff --git a/src/app/loading.tsx b/src/app/loading.tsx index a38e962..e8d30ea 100644 --- a/src/app/loading.tsx +++ b/src/app/loading.tsx @@ -1,7 +1,9 @@ -export default function Loading() { +function Loading() { return (
); } + +export default Loading; diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx index 153afc0..94384ed 100644 --- a/src/app/not-found.tsx +++ b/src/app/not-found.tsx @@ -3,7 +3,7 @@ import Link from 'next/link'; import { useEffect, useState } from 'react'; -export default function Custom404() { +function Custom404() { const [countdown, setCountdown] = useState(10); useEffect(() => { @@ -49,3 +49,5 @@ export default function Custom404() { ); } + +export default Custom404; diff --git a/src/app/page.tsx b/src/app/page.tsx index 163ee13..60f98c1 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -7,7 +7,7 @@ import { getAllPosts } from '@/services/content'; import PostListLayout from '@/components/layout/PostListLayout'; -export default async function Home() { +async function Home() { const config = getConfig(); const posts: PostData[] = await getAllPosts(); @@ -45,3 +45,5 @@ export default async function Home() { ); } + +export default Home; diff --git a/src/app/posts/PostsClient.tsx b/src/app/posts/PostsClient.tsx new file mode 100644 index 0000000..3f0d832 --- /dev/null +++ b/src/app/posts/PostsClient.tsx @@ -0,0 +1,86 @@ +'use client'; + +import { useState } from 'react'; +import { useSearchParams } from 'next/navigation'; +import { defaultTo, flatMap } from 'es-toolkit/compat'; + +import getFilteredPosts from '@/services/utils/getFilteredPosts'; + +import PostListLayout from '@/components/layout/PostListLayout'; +import Pagination from '@/components/common/Pagination'; +import SearchInput from '@/components/common/SearchInput'; + +interface PostsClientProperties { + posts: PostData[]; +} + +function PostsClient({ posts }: PostsClientProperties) { + const searchParameters = useSearchParams(); + const categoryParameter = defaultTo(searchParameters.get('category'), ''); + const tagParameter = defaultTo(searchParameters.get('tag'), ''); + const searchQuery = defaultTo(searchParameters.get('query'), ''); + + const [currentPage, setCurrentPage] = useState(1); + const postsPerPage = 5; + + const categories = defaultTo( + [ + ...new Set( + flatMap(posts, (post) => + defaultTo(post.frontmatter.categories, []) + ) as string[] + ), + ], + [] + ); + + const tags = defaultTo( + [ + ...new Set( + flatMap(posts, (post) => + defaultTo(post.frontmatter.tags, []) + ) as string[] + ), + ], + [] + ); + + // Filter posts based on search, category, and tag + const filteredPosts = getFilteredPosts( + posts, + searchQuery, + categoryParameter, + tagParameter + ); + + // Pagination logic + const indexOfLastPost = currentPage * postsPerPage; + const indexOfFirstPost = indexOfLastPost - postsPerPage; + const currentPosts = filteredPosts.slice(indexOfFirstPost, indexOfLastPost); + + const paginate = (pageNumber: number) => setCurrentPage(pageNumber); + + return ( +
+ {/* Centered Search Input */} + + + {/* Post List */} + + + {/* Pagination */} + +
+ ); +} + +export default PostsClient; diff --git a/src/app/posts/[slug]/page.tsx b/src/app/posts/[slug]/page.tsx index d5f5e16..75d3d86 100644 --- a/src/app/posts/[slug]/page.tsx +++ b/src/app/posts/[slug]/page.tsx @@ -1,7 +1,5 @@ import type { Metadata } from 'next'; -import { Suspense } from 'react'; -import Loading from '@/app/loading'; import { getConfig } from '@/services/config'; import { getAllPosts, getPostData } from '@/services/content'; @@ -20,25 +18,18 @@ type Properties = { }; async function generateMetadata({ params }: Properties): Promise { - // read post slug - const { slug } = await params; - // get post data - const postData = await getPostData(slug); - - // thumbnail image - const thumbnail = postData.frontmatter.thumbnail; + const { slug } = await params; + const postData: PostData = await getPostData(slug); const config = getConfig(); + const metaKeywords = [ + ...(postData.frontmatter.tags || []), + ...(postData.frontmatter.categories || []), + postData.frontmatter.author, + 'blog', + ].join(', '); - const metaKeywords = - postData.frontmatter.tags?.join(', ') + - ', ' + - postData.frontmatter.categories?.join(', ') + - ', ' + - postData.frontmatter.author + - ', ' + - 'blog'; return { title: `${postData.frontmatter.title} - ${config.title}`, description: postData.postAbstract, @@ -51,7 +42,7 @@ async function generateMetadata({ params }: Properties): Promise { modifiedTime: postData.frontmatter.date, title: postData.frontmatter.title, description: postData.postAbstract, - images: thumbnail, + images: postData.frontmatter.thumbnail, url: `/posts/${slug}`, locale: config.lang, }, @@ -63,11 +54,7 @@ async function PostPage(props: { params: Promise<{ slug: string }> }) { const parameters = await props.params; const post: PostData = await getPostData(parameters.slug); - return ( - }> - - - ); + return ; } export { generateStaticParams, generateMetadata, PostPage as default }; diff --git a/src/app/posts/page.tsx b/src/app/posts/page.tsx index 95480f9..42d6dbd 100644 --- a/src/app/posts/page.tsx +++ b/src/app/posts/page.tsx @@ -1,13 +1,11 @@ -import type { Metadata } from 'next/types'; -import { Suspense } from 'react'; +import type { Metadata } from 'next'; + +import PostsClient from './PostsClient'; -import Loading from '@/app/loading'; import { getConfig } from '@/services/config'; import { getAllPosts } from '@/services/content'; -import PostListLayout from '@/components/layout/PostListLayout'; - -export async function generateMetadata(): Promise { +export function generateMetadata(): Metadata { const config = getConfig(); return { title: `Posts - ${config.title}`, @@ -27,12 +25,5 @@ export async function generateMetadata(): Promise { export default async function PostsPage() { const posts: PostData[] = await getAllPosts(); - return ( -
-

All Posts

- }> - - -
- ); + return ; } diff --git a/src/app/robots.ts b/src/app/robots.ts index 5ac1fcd..75e5e5e 100644 --- a/src/app/robots.ts +++ b/src/app/robots.ts @@ -20,8 +20,6 @@ export default async function robots(): Promise { '/about', '/friends', '/posts', - '/categories/*', - '/tags/*', ...postUrls, // Dynamic post URLs ], }, diff --git a/src/app/tags/[tagSlug]/page.tsx b/src/app/tags/[tagSlug]/page.tsx deleted file mode 100644 index 95400b9..0000000 --- a/src/app/tags/[tagSlug]/page.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { notFound } from 'next/navigation'; -import type { Metadata } from 'next/types'; -import { Suspense } from 'react'; - -import { getConfig } from '@/services/config'; -import { - getAllPosts, - convertToPinyin, - getUniqueTags, -} from '@/services/content'; -import Loading from '@/app/loading'; - -import PostListLayout from '@/components/layout/PostListLayout'; - -// Generate static paths for all unique tags -async function generateStaticParams() { - const uniqueTags = await getUniqueTags(); - return uniqueTags.map((tag) => ({ - // Convert only Chinese tags to pinyin slug - tagSlug: convertToPinyin(tag), - })); -} - -type Properties = { - params: Promise<{ tagSlug: string }>; -}; - -async function generateMetadata({ params }: Properties): Promise { - // read post slug - const { tagSlug: tag } = await params; - - const config = getConfig(); - - // Find the tag based on the slug from params - const uniqueTags = await getUniqueTags(); - const tagData = - uniqueTags.find((t) => convertToPinyin(t) === tag) || 'Not Found'; - return { - title: `标签:${tagData} - ${config.title}`, - openGraph: { - siteName: config.title, - title: `标签:${tagData} - ${config.title}`, - url: `${config.siteUrl}/tags/${tag}`, - images: config.avatar, - type: 'website', - locale: config.lang, - }, - }; -} - -async function TagPage(props: { params: Promise<{ tagSlug: string }> }) { - const parameters = await props.params; - const posts = await getAllPosts(); - - // Retrieve all unique tags from the posts - const uniqueTags = await getUniqueTags(); - - // Find the tag based on the slug from params - const tag = uniqueTags.find( - (tag) => convertToPinyin(tag) === parameters.tagSlug - ); - - if (!tag) { - // If the tag doesn't exist, show 404 - notFound(); - } - - // Filter posts by the tag name - const filteredPosts = posts.filter((post) => - post.frontmatter.tags?.includes(tag) - ); - - return ( -
-

{tag}

- }> - - -
- ); -} - -export { generateStaticParams, generateMetadata, TagPage as default }; diff --git a/src/components/common/Header.tsx b/src/components/common/Header.tsx index b6cc01d..0dd5235 100644 --- a/src/components/common/Header.tsx +++ b/src/components/common/Header.tsx @@ -48,7 +48,7 @@ export default function Header({ return (
, isDropdown: true, dropdownItems: postCategories.map((category: Category) => ({ - href: `/categories/${category.slug}`, + href: `/posts/?category=${category.name}`, label: category.name, icon: category.icon && Fa6[category.icon as keyof typeof Fa6] diff --git a/src/components/common/Pagination.tsx b/src/components/common/Pagination.tsx new file mode 100644 index 0000000..679d32b --- /dev/null +++ b/src/components/common/Pagination.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { ceil } from 'es-toolkit/compat'; + +interface PaginationProperties { + postsPerPage: number; + totalPosts: number; + paginate: (pageNumber: number) => void; + currentPage: number; +} + +const Pagination = ({ + postsPerPage, + totalPosts, + paginate, + currentPage, +}: PaginationProperties) => { + const pageNumbers = Array.from( + { length: ceil(totalPosts / postsPerPage) }, + (_, index) => index + 1 + ); + + // If there is only one page, don't show pagination + if (pageNumbers.length === 1) return null; + + return ( + + ); +}; + +export default Pagination; diff --git a/src/components/common/SearchInput.tsx b/src/components/common/SearchInput.tsx new file mode 100644 index 0000000..51b8fb3 --- /dev/null +++ b/src/components/common/SearchInput.tsx @@ -0,0 +1,199 @@ +'use client'; + +import { useState, useEffect, useRef } from 'react'; +import { useSearchParams, usePathname } from 'next/navigation'; +import { useFormStatus } from 'react-dom'; +import { defaultTo } from 'es-toolkit/compat'; +import { clsx } from 'clsx'; + +interface SearchInputProperties { + initialValue?: string; + categories: string[]; + tags: string[]; +} + +const SearchInput = ({ + initialValue = '', + categories, + tags, +}: SearchInputProperties) => { + const pathname = usePathname(); + const searchParameters = useSearchParams(); + const { pending } = useFormStatus(); + + const [searchQuery, setSearchQuery] = useState(initialValue); + const [selectedCategory, setSelectedCategory] = useState(''); + const [selectedTag, setSelectedTag] = useState(''); + const [expanded, setExpanded] = useState(false); + + const formReference = useRef(null); + + useEffect(() => { + setSearchQuery(defaultTo(searchParameters.get('query'), initialValue)); + setSelectedCategory(searchParameters.get('category') || ''); + setSelectedTag(searchParameters.get('tag') || ''); + }, [searchParameters, initialValue]); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + formReference.current && + !formReference.current.contains(event.target as Node) + ) { + setExpanded(false); + } + }; + + document.addEventListener('click', handleClickOutside); + return () => document.removeEventListener('click', handleClickOutside); + }, []); + + const updateURL = (newQuery: string, category: string, tag: string) => { + const params = new URLSearchParams(searchParameters); + if (newQuery) params.set('query', newQuery); + else params.delete('query'); + if (category) params.set('category', category); + else params.delete('category'); + if (tag) params.set('tag', tag); + else params.delete('tag'); + globalThis.history.pushState(null, '', `${pathname}?${params.toString()}`); + }; + + const handleSearchChange = (event: React.ChangeEvent) => { + const newQuery = event.target.value; + setSearchQuery(newQuery); + setExpanded(true); // Let the user see the filters when searching + updateURL(newQuery, selectedCategory, selectedTag); + }; + + const handleCategoryChange = ( + event: React.ChangeEvent + ) => { + setSelectedCategory(event.target.value); + updateURL(searchQuery, event.target.value, selectedTag); + }; + + const handleTagChange = (event: React.ChangeEvent) => { + setSelectedTag(event.target.value); + updateURL(searchQuery, selectedCategory, event.target.value); + }; + + const clearFilters = () => { + setSearchQuery(''); + setSelectedCategory(''); + setSelectedTag(''); + updateURL('', '', ''); + }; + + return ( +
event.preventDefault()} + className='mb-6 w-full max-w-lg space-y-4 rounded-lg p-4' + > + {/* Search Input */} +
+ setExpanded(true)} + disabled={pending} + className='w-full rounded-full border border-gray-300 px-4 py-2 transition-all duration-300 focus:ring-2' + /> +
+ + {/* Expandable Filters */} +
+
+
+ + {/* Custom down arrow */} + + ▼ + +
+ +
+ + {/* Custom down arrow */} + + ▼ + +
+
+ + {/* Clear Filters Button */} + + + {pending &&

Loading results...

} +
+
+ ); +}; + +export default SearchInput; diff --git a/src/components/layout/ItemLinks.tsx b/src/components/layout/ItemLinks.tsx index 4d88a38..ed612c5 100644 --- a/src/components/layout/ItemLinks.tsx +++ b/src/components/layout/ItemLinks.tsx @@ -1,57 +1,46 @@ -import Link from 'next/link'; +'use client'; -import { getConfig } from '@/services/config'; -import { convertToPinyin, getUniqueTags } from '@/services/content'; +import { defaultTo } from 'es-toolkit/compat'; +import Link from 'next/link'; +import { useSearchParams } from 'next/navigation'; interface ItemLinksProperties { - items: string[] | undefined; + items?: string[]; type: 'category' | 'tag'; } -export default async function ItemLinks({ items, type }: ItemLinksProperties) { - if (!items || items.length === 0) { - return <>{type === 'category' ? '未分类' : '无标签'}; - } +const getLink = ( + item: string, + type: 'category' | 'tag', + searchParameters: URLSearchParams +) => { + const newParameters = new URLSearchParams(searchParameters); + newParameters.set(type, item); + return `/posts?${newParameters.toString()}`; +}; - const config = type === 'category' ? getConfig() : null; - const uniqueTags = type === 'tag' ? await getUniqueTags() : null; +export default function ItemLinks({ items, type }: ItemLinksProperties) { + const searchParameters = useSearchParams(); + const displayItems = defaultTo(items, []); - const getLink = (item: string) => { - if (type === 'category' && config) { - const categoryLink = config.postCategories.find( - (cat) => cat.name === item && cat.slug - ); - return categoryLink ? `/categories/${categoryLink.slug}` : null; - } - if (type === 'tag' && uniqueTags) { - return uniqueTags.includes(item) - ? `/tags/${convertToPinyin(item)}` - : null; - } - return null; - }; + if (displayItems.length === 0) { + return <>{type === 'category' ? '未分类' : '无标签'}; + } return ( <> - {items.map((item, index) => { - const link = getLink(item); - return ( - - {link ? ( - - {item} - - ) : ( - {item} - )} - {index < items.length - 1 && ', '} - - ); - })} + {displayItems.map((item, index) => ( + + + {item} + + {index < displayItems.length - 1 && ', '} + + ))} ); } diff --git a/src/components/layout/PostLayout.tsx b/src/components/layout/PostLayout.tsx index 0d4e362..4ad34ba 100644 --- a/src/components/layout/PostLayout.tsx +++ b/src/components/layout/PostLayout.tsx @@ -1,12 +1,12 @@ import Image from 'next/image'; +import dynamic from 'next/dynamic'; import { FaFolder, FaTags } from 'react-icons/fa6'; +import { includes, lowerCase } from 'es-toolkit/compat'; import ItemLinks from './ItemLinks'; import { getConfig } from '@/services/config'; -import DisqusComments from '@/components/common/DisqusComments'; - import '@/styles/codeblock.css'; import '@/styles/postContent.css'; import 'highlight.js/styles/an-old-hope.css'; @@ -16,6 +16,10 @@ interface PostLayoutProperties { showThumbnail?: boolean; } +const DisqusComments = dynamic( + () => import('@/components/common/DisqusComments') +); + function PostLayout({ post, showThumbnail = true }: PostLayoutProperties) { return (
@@ -91,7 +95,7 @@ function TitleHeader({ return (

{title}

- {title !== 'About' && title !== 'Friends' && ( + {includes(['about', 'friends'], lowerCase(title)) || ( { - const { data, content: markdownContent } = matter(content); + const { data, content: markdownContent } = matter(fileContents); const frontmatterData: Frontmatter = { title: (data.title as string)?.slice(0, 100) || slug, author: (data.author as string)?.slice(0, 30) || config.author.name, thumbnail: await resolveThumbnail(data.thumbnail), date: await resolveDate(data.date, filePath), - tags: data.tags || undefined, - categories: data.categories || undefined, - redirect: data.redirect || undefined, - showComments: data.showComments ?? true, + tags: defaultTo(data.tags), + categories: defaultTo(data.categories), + redirect: defaultTo(data.redirect), + showComments: defaultTo(data.showComments, true), }; // Clean up HTML comments diff --git a/src/services/content/index.ts b/src/services/content/index.ts index 3e58ed1..3becd5a 100644 --- a/src/services/content/index.ts +++ b/src/services/content/index.ts @@ -1,7 +1,9 @@ +'use server'; + import { promises as fsPromise } from 'node:fs'; import path from 'node:path'; -import pinyin from 'pinyin'; +import { filter } from 'es-toolkit/compat'; import { getPostFromFile } from '@/services/content/getPostFromFile'; @@ -9,7 +11,7 @@ const postsDirectory = path.join(process.cwd(), 'posts'); async function getAllPosts(): Promise { const fileNames = await fsPromise.readdir(postsDirectory); - const markdownFiles = fileNames.filter((fileName) => + const markdownFiles = filter(fileNames, (fileName) => fileName.endsWith('.md') ); @@ -36,21 +38,4 @@ async function getPostData(slug: string, page?: string): Promise { return getPostFromFile(filePath, slug); } -// Helper function to convert Chinese tags to pinyin with underscores -function convertToPinyin(tag: string): string { - if (/[\u4E00-\u9FA5]/.test(tag)) { - return pinyin(tag, { style: pinyin.STYLE_NORMAL }).flat().join('_'); - } - return tag; // If not Chinese, return the original tag -} - -// Helper function to get unique tags from local tags -async function getUniqueTags() { - const posts = await getAllPosts(); - // Get all tags from all posts and remove duplicates - const allTags = posts.flatMap((post) => post.frontmatter.tags || []); - - return [...new Set(allTags)]; -} - -export { convertToPinyin, getAllPosts, getPostData, getUniqueTags }; +export { getAllPosts, getPostData }; diff --git a/src/services/utils/getFilteredPosts.ts b/src/services/utils/getFilteredPosts.ts new file mode 100644 index 0000000..97ffadd --- /dev/null +++ b/src/services/utils/getFilteredPosts.ts @@ -0,0 +1,61 @@ +import { + defaultTo, + filter, + includes, + replace, + some, + words, +} from 'es-toolkit/compat'; +import { lowerCase } from 'es-toolkit/string'; + +function getFilteredPosts( + posts: PostData[], + searchQuery: string, + category?: string, + tag?: string +): PostData[] { + // Preprocess search query + const queryKeywords = words(lowerCase(searchQuery)); + const normalizedCategory = lowerCase(defaultTo(category, '')); + const normalizedTag = lowerCase(defaultTo(tag, '')); + + return filter(posts, (post) => { + const { contentHtml = '' } = post; + const { title, categories = [], tags = [] } = post.frontmatter; + + // Preprocess post fields + const normalizedTitle = lowerCase(title); + const normalizedAbstract = lowerCase(defaultTo(post.postAbstract, '')); + const normalizedContent = lowerCase(replace(contentHtml, /<[^>]*>/g, '')); + const normalizedTags = tags.map((tag) => lowerCase(tag)); + const normalizedCategories = categories.map((category) => + lowerCase(category) + ); + + // Perform search query + const matchesQuery = + queryKeywords.length === 0 || + some(queryKeywords, (keyword) => + [ + normalizedTitle, + normalizedAbstract, + normalizedContent, + ...normalizedTags, + ...normalizedCategories, + ].some((field) => includes(field, keyword)) + ); + + // Perform category and tag filtering + const matchesCategory = normalizedCategory + ? some(normalizedCategories, (cat) => includes(cat, normalizedCategory)) + : true; + + const matchesTag = normalizedTag + ? some(normalizedTags, (tag) => includes(tag, normalizedTag)) + : true; + + return matchesQuery && matchesCategory && matchesTag; + }); +} + +export default getFilteredPosts; diff --git a/src/styles/postContent.css b/src/styles/postContent.css index d4a3bf1..fba62ba 100644 --- a/src/styles/postContent.css +++ b/src/styles/postContent.css @@ -66,15 +66,15 @@ /* Blockquote */ blockquote { - padding: 0.5em 1em; - margin: 0.5em 0; + padding: 0.3em 0.5em; + margin: 1em; font-size: 1.02em; font-style: italic; border-left: 4px solid var(--sakuraPink); border-radius: 0.5rem; background-color: #fcfcf7; color: var(--foreground); - max-width: 85%; /* Limit blockquote width */ + max-width: 95%; /* Limit blockquote width */ } @media (prefers-color-scheme: dark) { diff --git a/src/types.d.ts b/src/types.d.ts index 5858bcc..5075f9b 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -22,7 +22,6 @@ interface PostData { // Category data in config.yml type Category = { name: string; - slug?: string; icon?: string; };