├── .env.production ├── .eslintignore ├── .eslintrc.json ├── .github └── workflows │ └── nextjs.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── README.md ├── eslint.config.js ├── img ├── portfolio01.png ├── portfolio02.png ├── portfolio03.png ├── portfolio04.png ├── portfolio05.png ├── portfolio06.png ├── portfolio07.png ├── portfolio08.png ├── portfolio09.png ├── portfolio10.png ├── portfolio11.png ├── portfolio12.png ├── portfolio13.png ├── portfolio14.jpeg ├── portfolio15.png ├── portfolio_gif01.gif ├── portfolio_gif02.gif └── portfolio_gif03.gif ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.mjs ├── public ├── app-ads.txt ├── favicon │ ├── apple-icon.png │ ├── favicon.ico │ ├── icon-16x16.png │ ├── icon-192x192.png │ ├── icon-32x32.png │ ├── icon-512x512.png │ └── site.webmanifest └── images │ ├── category-icon │ ├── ai.svg │ ├── backend.svg │ ├── frontend.svg │ ├── infra.svg │ └── other.svg │ ├── cats │ ├── coming_soon.png │ └── page_not_found.png │ ├── island.png │ ├── profile_01.jpg │ ├── profile_02.png │ ├── profile_03.jpg │ ├── profile_04.jpg │ ├── profile_icon.png │ └── works │ ├── hackathon_01.png │ ├── hackathon_02.png │ ├── hackathon_03.png │ ├── hobby_01.png │ ├── hobby_02.png │ ├── hobby_03.png │ ├── hobby_04.png │ ├── hobby_05.png │ ├── hobby_06.png │ ├── work_01.png │ ├── work_02.png │ ├── work_03.png │ └── work_04.png ├── src ├── app │ ├── (pages) │ │ ├── blog │ │ │ ├── [slug] │ │ │ │ ├── page.server.tsx │ │ │ │ └── page.tsx │ │ │ └── page.tsx │ │ ├── contact │ │ │ └── page.tsx │ │ ├── game │ │ │ ├── Game.css │ │ │ └── page.tsx │ │ ├── hugmi │ │ │ └── privacy │ │ │ │ └── page.tsx │ │ ├── profile │ │ │ ├── articles.tsx │ │ │ └── page.tsx │ │ ├── skills │ │ │ ├── page.tsx │ │ │ ├── skills.tsx │ │ │ └── works.tsx │ │ └── thankyou │ │ │ └── page.tsx │ ├── api │ │ ├── blog │ │ │ ├── [slug] │ │ │ │ └── route.tsx │ │ │ └── route.tsx │ │ ├── highscore │ │ │ └── route.tsx │ │ ├── likes │ │ │ └── route.tsx │ │ ├── og-fetch │ │ │ └── route.tsx │ │ └── utils │ │ │ └── getPostData.tsx │ ├── components │ │ ├── atoms │ │ │ ├── AnimatedLine.tsx │ │ │ ├── Chip.module.css │ │ │ ├── Chip.tsx │ │ │ ├── Highlight.tsx │ │ │ ├── HtmlLangUpdater.tsx │ │ │ ├── LoadingCircle.tsx │ │ │ ├── MainMessage.tsx │ │ │ ├── OpenGraphFetcher.tsx │ │ │ ├── ParticlesBackground.tsx │ │ │ ├── SketchCloud.tsx │ │ │ ├── SketchNight.tsx │ │ │ ├── SubmitButton.tsx │ │ │ ├── TitleAnimation.tsx │ │ │ └── TitleLinkButton.tsx │ │ ├── game │ │ │ ├── Car.tsx │ │ │ ├── CarLane.tsx │ │ │ ├── Controls.css │ │ │ ├── Controls.tsx │ │ │ ├── DirectionalLight.tsx │ │ │ ├── Forest.tsx │ │ │ ├── Grass.tsx │ │ │ ├── Map.tsx │ │ │ ├── Player.tsx │ │ │ ├── Result.css │ │ │ ├── Result.tsx │ │ │ ├── Road.tsx │ │ │ ├── Row.tsx │ │ │ ├── Scene.tsx │ │ │ ├── Score.css │ │ │ ├── Score.tsx │ │ │ ├── Tree.tsx │ │ │ ├── Truck.tsx │ │ │ ├── TruckLane.tsx │ │ │ ├── Wheel.tsx │ │ │ ├── const.ts │ │ │ ├── hooks │ │ │ │ ├── useEventListeners.ts │ │ │ │ ├── useHitDetection.ts │ │ │ │ ├── usePlayerAnimation.ts │ │ │ │ └── useVehicleAnimation.ts │ │ │ ├── metadata.ts │ │ │ ├── stores │ │ │ │ ├── game.ts │ │ │ │ ├── map.ts │ │ │ │ └── player.ts │ │ │ └── utilities │ │ │ │ ├── calculateFinalPosition.ts │ │ │ │ ├── endsUpInValidPosition.ts │ │ │ │ └── generateRows.ts │ │ ├── molecules │ │ │ ├── Article.tsx │ │ │ ├── BackgroundWrapper.tsx │ │ │ ├── BlogCard.tsx │ │ │ ├── ChartHeader.tsx │ │ │ ├── ChartRow.tsx │ │ │ ├── CodeBlock.tsx │ │ │ ├── EmbedArticle.module.css │ │ │ ├── EmbedArticle.tsx │ │ │ ├── GithubLinkButton.tsx │ │ │ ├── HeaderLinkButton.tsx │ │ │ ├── InputLabel.tsx │ │ │ ├── InputLongText.tsx │ │ │ ├── InputText.tsx │ │ │ ├── LanguageSwitcher.tsx │ │ │ ├── LikeButton.tsx │ │ │ ├── ProfileCard.tsx │ │ │ ├── Tags.tsx │ │ │ ├── TextArrowLinkButton.tsx │ │ │ ├── Toc.tsx │ │ │ └── WorkCard.tsx │ │ ├── organisms │ │ │ ├── BlogGrid.tsx │ │ │ ├── Form.tsx │ │ │ ├── MessageBoard.tsx │ │ │ ├── MessageData.ts │ │ │ ├── PageFace.tsx │ │ │ ├── SearchBar.tsx │ │ │ ├── SkillTimeline.tsx │ │ │ ├── ThemeSwitch.tsx │ │ │ └── WorkList.tsx │ │ └── templates │ │ │ ├── ArticleContent.module.css │ │ │ ├── ArticleContent.tsx │ │ │ ├── ArticleList.tsx │ │ │ ├── ClientWrapper.tsx │ │ │ ├── Footer.tsx │ │ │ ├── Header.tsx │ │ │ ├── MaintenanceTemplate.tsx │ │ │ └── Sidebar.tsx │ ├── globals.css │ ├── hooks │ │ └── useLikeCount.ts │ ├── layout.tsx │ ├── not-found.tsx │ ├── page.tsx │ └── sitemap.ts ├── i18n │ ├── config.ts │ ├── context.tsx │ ├── index.ts │ ├── translations.ts │ └── types.ts ├── posts │ ├── async_await_with_forEach.mdx │ ├── cakephp_install_for_mac_with_mamp.mdx │ ├── cakephp_install_mysql.mdx │ ├── create_my_site.mdx │ ├── create_my_site_2.mdx │ ├── create_my_site_3.mdx │ ├── create_my_site_4.mdx │ ├── goodbye_2024_welcome_2025.mdx │ ├── message_to_my_colleagues.mdx │ ├── monologue_20250113.mdx │ ├── novel_line.mdx │ ├── recaptcha_2_install.mdx │ ├── study_english_in_philippines_1.mdx │ ├── study_english_in_philippines_2.mdx │ ├── study_english_in_philippines_3.mdx │ └── study_english_in_philippines_4.mdx └── types │ ├── game-objects.ts │ └── posts.ts ├── tailwind.config.ts ├── tsconfig.json └── vercel.json /.env.production: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_API_URL=https://furugen-island.com/my_site/api 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .next/ 3 | .nuxt/ 4 | .astro/ 5 | build/ 6 | dist/ 7 | out/ 8 | public/ 9 | package-lock.json 10 | yarn.lock 11 | pnpm-lock.yaml 12 | vite.config.ts 13 | next.config.mjs 14 | tsconfig.json 15 | src/env.d.ts 16 | *.cjs 17 | *.mjs -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "next/core-web-vitals", 4 | "plugin:prettier/recommended", 5 | "plugin:tailwindcss/recommended" 6 | ], 7 | "plugins": ["prettier", "tailwindcss"], 8 | "rules": { 9 | "tailwindcss/migration-from-tailwind-2": "off", 10 | "@next/next/no-page-custom-font": "off", 11 | "prettier/prettier": "error", 12 | "tailwindcss/no-custom-classname": [ 13 | "warn", 14 | { 15 | "whitelist": [ 16 | "rightFooter", 17 | "leftFooter", 18 | "item-left", 19 | "items-top", 20 | "content-wrapper", 21 | "thank-you", 22 | "work-list", 23 | "border-main-black", 24 | "bg-main-white", 25 | "toc", 26 | "target-toc", 27 | "code-block-wrapper", 28 | "code-block-title", 29 | "code-block-container", 30 | "copy-button", 31 | "copy-message", 32 | "seagull", 33 | "seagull-1", 34 | "seagull-2", 35 | "wing", 36 | "left-wing", 37 | "right-wing", 38 | "circle", 39 | "night-circle" 40 | ] 41 | } 42 | ] 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /.github/workflows/nextjs.yml: -------------------------------------------------------------------------------- 1 | # Sample workflow for building and deploying a Next.js site to GitHub Pages 2 | # 3 | # To get started with Next.js see: https://nextjs.org/docs/getting-started 4 | # 5 | name: Deploy Next.js site to Pages 6 | 7 | on: 8 | # Runs on pushes targeting the default branch 9 | push: 10 | branches: ['main'] 11 | 12 | # Allows you to run this workflow manually from the Actions tab 13 | workflow_dispatch: 14 | 15 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 16 | permissions: 17 | contents: read 18 | pages: write 19 | id-token: write 20 | 21 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 22 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 23 | concurrency: 24 | group: 'pages' 25 | cancel-in-progress: false 26 | 27 | jobs: 28 | # Build job 29 | build: 30 | runs-on: ubuntu-latest 31 | steps: 32 | - name: Checkout 33 | uses: actions/checkout@v4 34 | - name: Detect package manager 35 | id: detect-package-manager 36 | run: | 37 | if [ -f "${{ github.workspace }}/yarn.lock" ]; then 38 | echo "manager=yarn" >> $GITHUB_OUTPUT 39 | echo "command=install" >> $GITHUB_OUTPUT 40 | echo "runner=yarn" >> $GITHUB_OUTPUT 41 | exit 0 42 | elif [ -f "${{ github.workspace }}/package.json" ]; then 43 | echo "manager=npm" >> $GITHUB_OUTPUT 44 | echo "command=ci" >> $GITHUB_OUTPUT 45 | echo "runner=npx --no-install" >> $GITHUB_OUTPUT 46 | exit 0 47 | else 48 | echo "Unable to determine package manager" 49 | exit 1 50 | fi 51 | - name: Setup Node 52 | uses: actions/setup-node@v4 53 | with: 54 | node-version: '20' 55 | cache: ${{ steps.detect-package-manager.outputs.manager }} 56 | - name: Setup Pages 57 | uses: actions/configure-pages@v5 58 | with: 59 | # Automatically inject basePath in your Next.js configuration file and disable 60 | # server side image optimization (https://nextjs.org/docs/api-reference/next/image#unoptimized). 61 | # 62 | # You may remove this line if you want to manage the configuration yourself. 63 | static_site_generator: next 64 | generator_config_file: next.config.mjs 65 | 66 | - name: Clear cache 67 | run: | 68 | rm -rf ~/.npm 69 | rm -rf .next/cache 70 | 71 | - name: Restore cache 72 | uses: actions/cache@v4 73 | with: 74 | path: | 75 | .next/cache 76 | # Generate a new cache whenever packages or source files change. 77 | key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json', '**/yarn.lock') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }} 78 | # If source files changed but packages didn't, rebuild from a prior cache. 79 | restore-keys: | 80 | ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json', '**/yarn.lock') }}- 81 | - name: Install dependencies 82 | run: ${{ steps.detect-package-manager.outputs.manager }} ${{ steps.detect-package-manager.outputs.command }} 83 | # - name: Start local API server 84 | # run: | 85 | # nohup npm run start:api & 86 | # sleep 5 87 | - name: Set environment variable for local API 88 | run: echo "NEXT_PUBLIC_API_URL=https://my-site-nine-opal.vercel.app/my_site/api" >> $GITHUB_ENV 89 | - name: Build with Next.js 90 | run: ${{ steps.detect-package-manager.outputs.runner }} next build 91 | - name: Upload artifact 92 | uses: actions/upload-pages-artifact@v3 93 | with: 94 | path: ./out 95 | 96 | # Deployment job 97 | deploy: 98 | environment: 99 | name: github-pages 100 | url: ${{ steps.deployment.outputs.page_url }} 101 | runs-on: ubuntu-latest 102 | needs: build 103 | steps: 104 | - name: Deploy to GitHub Pages 105 | id: deployment 106 | uses: actions/deploy-pages@v4 107 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | # /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | 38 | # .env.local 39 | # .env.production 40 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .next/ 3 | .nuxt/ 4 | .astro/ 5 | build/ 6 | dist/ 7 | out/ 8 | public/ 9 | package-lock.json 10 | yarn.lock 11 | pnpm-lock.yaml 12 | next.config.mjs -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": false, 6 | "singleQuote": true, 7 | "jsxSingleQuote": false, 8 | "arrowParens": "always", 9 | "printWidth": 80, 10 | "bracketSpacing": true, 11 | "plugins": ["prettier-plugin-organize-imports"], 12 | "overrides": [ 13 | { 14 | "files": "*.html", 15 | "options": { 16 | "printWidth": 360 17 | } 18 | }, 19 | { 20 | "files": ["*.css", "*.scss"], 21 | "options": { 22 | "singleQuote": false 23 | } 24 | } 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🌴 Furugen's Island - 技術ブログ付きポートフォリオサイト 2 | 3 | [![最終コミット](https://img.shields.io/github/last-commit/motoshifurugen/my_site?style=flat-square)](https://github.com/motoshifurugen/my_site/commits/main) 4 | [![Vercelでデプロイ](https://img.shields.io/badge/Deploy-Vercel-black?logo=vercel&style=flat-square)](https://vercel.com) 5 | [![サイト表示中](https://img.shields.io/website?url=https%3A%2F%2Ffurugen-island.com%2Fmy_site&style=flat-square)](https://furugen-island.com/my_site) 6 | [![Code Size](https://img.shields.io/github/languages/code-size/motoshifurugen/my_site?style=flat-square)](https://github.com/motoshifurugen/my_site) 7 | [![UI](https://img.shields.io/badge/UI-TailwindCSS-38bdf8?style=flat-square&logo=tailwindcss&logoColor=white)]() 8 | [![Language](https://img.shields.io/badge/Language-TypeScript-3178c6?logo=typescript&logoColor=white&style=flat-square)]() 9 | [![Design](https://img.shields.io/badge/Design-Atomic%20Design-4caf50?style=flat-square)]() 10 | 11 | Next.js × Tailwind CSS で作った、なんくるないさ系エンジニアの拠点です。 12 | 13 | MDX対応の技術ブログで日々の開発を記録しながら、ジェネラティブアートやクロッシーロードのゲーム画面など、訪問者がワクワクするようなサイトを目指しています。 14 | 15 | [🚀 サイトはこちら](https://furugen-island.com/my_site) 16 | 17 | Image from Gyazo 18 | 19 | ## 🛠️ 主な使用技術 20 | - **Next.js** 21 | - **Tailwind CSS** 22 | - **TypeScript** 23 | - **Vercel**(ホスティング) 24 | - **MDX**(ブログ) 25 | - **p5.js**(ジェネラティブアート) 26 | 27 | ## 🧑‍💻 ローカル起動手順 28 | ```bash 29 | # 1. リポジトリをクローン 30 | git clone https://github.com/your-username/furugen-island.git 31 | cd furugen-island 32 | 33 | # 2. パッケージをインストール 34 | npm install 35 | 36 | # 3. 開発サーバーを起動 37 | npm run dev 38 | ``` 39 | http://localhost:3000/my_site でポートフォリオサイトが表示されます。 40 | 41 | ## 🧱 コンポーネント設計:Atomic Design 採用 42 | ```cpp 43 | src/ 44 | └── components/ 45 | ├── atoms/ // ボタン・テキスト・アイコンなど最小単位 46 | ├── molecules/ // 入力フォーム・カードなどの複合要素 47 | ├── organisms/ // ヘッダー・記事リストなどのまとまり 48 | └── templates/ // ページの骨組み 49 | ``` 50 | 詳細なディレクトリ構成は [utihub(motoshifurugen/my_site)](https://uithub.com/motoshifurugen/my_site) を参照してください。 51 | 52 | ## 📘 開発ログ 53 | 54 | - [#1 Nextアプリ立ち上げ・TOP画面作成](https://furugen-island.com/my_site/blog/create_my_site) 55 | - [#2 ヘッダー・プロフィール画面作成](https://furugen-island.com/my_site/blog/create_my_site_2) 56 | - [#3 ポートフォリオ画面・コンタクト画面・フッター作成](https://furugen-island.com/my_site/blog/create_my_site_3) 57 | - [#4 デプロイ・レイアウト調整](https://furugen-island.com/my_site/blog/create_my_site_4) 58 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import nextjs from '@next/eslint-plugin-next'; 2 | import prettier from 'eslint-plugin-prettier'; 3 | import tailwindcss from 'eslint-plugin-tailwindcss'; 4 | 5 | export default [ 6 | { 7 | languageOptions: { 8 | parserOptions: { 9 | ecmaVersion: 'latest', 10 | sourceType: 'module', 11 | ecmaFeatures: { 12 | jsx: true 13 | } 14 | } 15 | }, 16 | plugins: { 17 | '@next/next': nextjs, 18 | prettier, 19 | tailwindcss 20 | }, 21 | extends: [ 22 | 'next/core-web-vitals', 23 | 'plugin:prettier/recommended', 24 | 'plugin:tailwindcss/recommended' 25 | ], 26 | rules: { 27 | 'tailwindcss/migration-from-tailwind-2': 'off', 28 | '@typescript-eslint/no-unused-vars': 'warn', 29 | '@next/next/no-page-custom-font': 'off', 30 | 'prettier/prettier': 'error', 31 | 'tailwindcss/no-custom-classname': [ 32 | 'warn', 33 | { 34 | whitelist: [ 35 | 'rightFooter', 36 | 'leftFooter', 37 | 'item-left', 38 | 'items-top', 39 | 'content-wrapper', 40 | 'thank-you', 41 | 'work-list', 42 | 'border-main-black', 43 | 'bg-main-white', 44 | 'toc', 45 | 'target-toc', 46 | 'code-block-wrapper', 47 | 'code-block-title', 48 | 'code-block-container', 49 | 'copy-button', 50 | 'copy-message', 51 | 'seagull', 52 | 'seagull-1', 53 | 'seagull-2', 54 | 'wing', 55 | 'left-wing', 56 | 'right-wing', 57 | 'circle', 58 | 'night-circle' 59 | ] 60 | } 61 | ] 62 | } 63 | } 64 | ]; 65 | -------------------------------------------------------------------------------- /img/portfolio01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/motoshifurugen/my_site/31ce1765aaeb68e6fded626f6acc2349646274f6/img/portfolio01.png -------------------------------------------------------------------------------- /img/portfolio02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/motoshifurugen/my_site/31ce1765aaeb68e6fded626f6acc2349646274f6/img/portfolio02.png -------------------------------------------------------------------------------- /img/portfolio03.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/motoshifurugen/my_site/31ce1765aaeb68e6fded626f6acc2349646274f6/img/portfolio03.png -------------------------------------------------------------------------------- /img/portfolio04.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/motoshifurugen/my_site/31ce1765aaeb68e6fded626f6acc2349646274f6/img/portfolio04.png -------------------------------------------------------------------------------- /img/portfolio05.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/motoshifurugen/my_site/31ce1765aaeb68e6fded626f6acc2349646274f6/img/portfolio05.png -------------------------------------------------------------------------------- /img/portfolio06.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/motoshifurugen/my_site/31ce1765aaeb68e6fded626f6acc2349646274f6/img/portfolio06.png -------------------------------------------------------------------------------- /img/portfolio07.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/motoshifurugen/my_site/31ce1765aaeb68e6fded626f6acc2349646274f6/img/portfolio07.png -------------------------------------------------------------------------------- /img/portfolio08.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/motoshifurugen/my_site/31ce1765aaeb68e6fded626f6acc2349646274f6/img/portfolio08.png -------------------------------------------------------------------------------- /img/portfolio09.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/motoshifurugen/my_site/31ce1765aaeb68e6fded626f6acc2349646274f6/img/portfolio09.png -------------------------------------------------------------------------------- /img/portfolio10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/motoshifurugen/my_site/31ce1765aaeb68e6fded626f6acc2349646274f6/img/portfolio10.png -------------------------------------------------------------------------------- /img/portfolio11.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/motoshifurugen/my_site/31ce1765aaeb68e6fded626f6acc2349646274f6/img/portfolio11.png -------------------------------------------------------------------------------- /img/portfolio12.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/motoshifurugen/my_site/31ce1765aaeb68e6fded626f6acc2349646274f6/img/portfolio12.png -------------------------------------------------------------------------------- /img/portfolio13.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/motoshifurugen/my_site/31ce1765aaeb68e6fded626f6acc2349646274f6/img/portfolio13.png -------------------------------------------------------------------------------- /img/portfolio14.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/motoshifurugen/my_site/31ce1765aaeb68e6fded626f6acc2349646274f6/img/portfolio14.jpeg -------------------------------------------------------------------------------- /img/portfolio15.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/motoshifurugen/my_site/31ce1765aaeb68e6fded626f6acc2349646274f6/img/portfolio15.png -------------------------------------------------------------------------------- /img/portfolio_gif01.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/motoshifurugen/my_site/31ce1765aaeb68e6fded626f6acc2349646274f6/img/portfolio_gif01.gif -------------------------------------------------------------------------------- /img/portfolio_gif02.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/motoshifurugen/my_site/31ce1765aaeb68e6fded626f6acc2349646274f6/img/portfolio_gif02.gif -------------------------------------------------------------------------------- /img/portfolio_gif03.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/motoshifurugen/my_site/31ce1765aaeb68e6fded626f6acc2349646274f6/img/portfolio_gif03.gif -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | // output: "export", 4 | images: { 5 | unoptimized: true, 6 | }, 7 | basePath: '/my_site', 8 | assetPrefix: process.env.NODE_ENV === 'production' ? '/my_site/' : '', 9 | staticPageGenerationTimeout: 60, 10 | }; 11 | 12 | export default nextConfig; 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "my-site", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "prettier --write \"src/**/*.{ts,tsx}\" && next build", 8 | "start": "next start", 9 | "start:api": "next dev -p 3001", 10 | "lint": "run-p -l -c --aggregate-output lint:*", 11 | "lint:eslint": "eslint .", 12 | "lint:prettier": "prettier --check .", 13 | "fix": "run-s fix:prettier fix:eslint", 14 | "fix:eslint": "npm run lint:eslint -- --fix", 15 | "fix:prettier": "npm run lint:prettier -- --write", 16 | "deploy": "npm run build && touch out/.nojekyll && git add -f out && git commit -m 'Deploy to GitHub Pages' && git push origin `git subtree split --prefix out master`:gh-pages --force" 17 | }, 18 | "dependencies": { 19 | "@fortawesome/fontawesome-svg-core": "^6.6.0", 20 | "@fortawesome/free-brands-svg-icons": "^6.6.0", 21 | "@fortawesome/free-regular-svg-icons": "^6.6.0", 22 | "@fortawesome/free-solid-svg-icons": "^6.6.0", 23 | "@fortawesome/react-fontawesome": "^0.2.2", 24 | "@next/third-parties": "^14.2.5", 25 | "@octokit/rest": "^21.1.1", 26 | "@p5-wrapper/react": "^4.4.2", 27 | "@react-three/drei": "^9.92.7", 28 | "@react-three/fiber": "^8.15.16", 29 | "@tsparticles/react": "^3.0.0", 30 | "@tsparticles/slim": "^3.5.0", 31 | "esprima": "^4.0.1", 32 | "gray-matter": "^4.0.3", 33 | "gsap": "^3.12.5", 34 | "iconv-lite": "^0.6.3", 35 | "lucide-react": "^0.479.0", 36 | "next": "^14.2.15", 37 | "next-mdx-remote": "^5.0.0", 38 | "next-themes": "^0.4.4", 39 | "open-graph-scraper": "^6.8.2", 40 | "prismjs": "^1.29.0", 41 | "react": "^18", 42 | "react-dom": "^18", 43 | "react-icons": "^5.3.0", 44 | "react-intersection-observer": "^9.13.1", 45 | "react-syntax-highlighter": "^15.5.0", 46 | "rehype-katex": "^7.0.1", 47 | "rehype-prism": "^2.3.2", 48 | "rehype-slug": "^6.0.0", 49 | "remark-gfm": "^4.0.0", 50 | "remark-math": "^6.0.0", 51 | "tocbot": "^4.29.0", 52 | "zustand": "^4.5.2" 53 | }, 54 | "devDependencies": { 55 | "@types/node": "^20.16.1", 56 | "@types/prismjs": "^1.26.4", 57 | "@types/react": "^18", 58 | "@types/react-dom": "^18", 59 | "@types/react-syntax-highlighter": "^15.5.13", 60 | "@types/three": "^0.175.0", 61 | "autoprefixer": "^10.4.20", 62 | "eslint": "^8.57.0", 63 | "eslint-config-next": "14.2.5", 64 | "eslint-config-prettier": "^9.1.0", 65 | "eslint-plugin-prettier": "^5.2.1", 66 | "eslint-plugin-tailwindcss": "^3.17.4", 67 | "npm-run-all": "^4.1.5", 68 | "postcss": "^8.4.41", 69 | "prettier": "^3.3.3", 70 | "prettier-plugin-organize-imports": "^4.0.0", 71 | "tailwindcss": "^3.4.10", 72 | "typescript": "^5" 73 | }, 74 | "type": "module" 75 | } 76 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | autoprefixer: {}, 6 | }, 7 | } 8 | 9 | export default config 10 | -------------------------------------------------------------------------------- /public/app-ads.txt: -------------------------------------------------------------------------------- 1 | google.com, pub-2530284268930026, DIRECT, f08c47fec0942fa0 2 | -------------------------------------------------------------------------------- /public/favicon/apple-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/motoshifurugen/my_site/31ce1765aaeb68e6fded626f6acc2349646274f6/public/favicon/apple-icon.png -------------------------------------------------------------------------------- /public/favicon/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/motoshifurugen/my_site/31ce1765aaeb68e6fded626f6acc2349646274f6/public/favicon/favicon.ico -------------------------------------------------------------------------------- /public/favicon/icon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/motoshifurugen/my_site/31ce1765aaeb68e6fded626f6acc2349646274f6/public/favicon/icon-16x16.png -------------------------------------------------------------------------------- /public/favicon/icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/motoshifurugen/my_site/31ce1765aaeb68e6fded626f6acc2349646274f6/public/favicon/icon-192x192.png -------------------------------------------------------------------------------- /public/favicon/icon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/motoshifurugen/my_site/31ce1765aaeb68e6fded626f6acc2349646274f6/public/favicon/icon-32x32.png -------------------------------------------------------------------------------- /public/favicon/icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/motoshifurugen/my_site/31ce1765aaeb68e6fded626f6acc2349646274f6/public/favicon/icon-512x512.png -------------------------------------------------------------------------------- /public/favicon/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "MyWebSite", 3 | "short_name": "MySite", 4 | "icons": [ 5 | { 6 | "src": "/icon-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png", 9 | "purpose": "maskable" 10 | }, 11 | { 12 | "src": "/icon-512x512.png", 13 | "sizes": "512x512", 14 | "type": "image/png", 15 | "purpose": "maskable" 16 | } 17 | ], 18 | "theme_color": "#ffffff", 19 | "background_color": "#ffffff", 20 | "display": "standalone" 21 | } 22 | -------------------------------------------------------------------------------- /public/images/category-icon/ai.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /public/images/category-icon/backend.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 10 | 11 | 12 | 13 | 35 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /public/images/category-icon/infra.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/images/category-icon/other.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /public/images/cats/coming_soon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/motoshifurugen/my_site/31ce1765aaeb68e6fded626f6acc2349646274f6/public/images/cats/coming_soon.png -------------------------------------------------------------------------------- /public/images/cats/page_not_found.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/motoshifurugen/my_site/31ce1765aaeb68e6fded626f6acc2349646274f6/public/images/cats/page_not_found.png -------------------------------------------------------------------------------- /public/images/island.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/motoshifurugen/my_site/31ce1765aaeb68e6fded626f6acc2349646274f6/public/images/island.png -------------------------------------------------------------------------------- /public/images/profile_01.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/motoshifurugen/my_site/31ce1765aaeb68e6fded626f6acc2349646274f6/public/images/profile_01.jpg -------------------------------------------------------------------------------- /public/images/profile_02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/motoshifurugen/my_site/31ce1765aaeb68e6fded626f6acc2349646274f6/public/images/profile_02.png -------------------------------------------------------------------------------- /public/images/profile_03.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/motoshifurugen/my_site/31ce1765aaeb68e6fded626f6acc2349646274f6/public/images/profile_03.jpg -------------------------------------------------------------------------------- /public/images/profile_04.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/motoshifurugen/my_site/31ce1765aaeb68e6fded626f6acc2349646274f6/public/images/profile_04.jpg -------------------------------------------------------------------------------- /public/images/profile_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/motoshifurugen/my_site/31ce1765aaeb68e6fded626f6acc2349646274f6/public/images/profile_icon.png -------------------------------------------------------------------------------- /public/images/works/hackathon_01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/motoshifurugen/my_site/31ce1765aaeb68e6fded626f6acc2349646274f6/public/images/works/hackathon_01.png -------------------------------------------------------------------------------- /public/images/works/hackathon_02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/motoshifurugen/my_site/31ce1765aaeb68e6fded626f6acc2349646274f6/public/images/works/hackathon_02.png -------------------------------------------------------------------------------- /public/images/works/hackathon_03.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/motoshifurugen/my_site/31ce1765aaeb68e6fded626f6acc2349646274f6/public/images/works/hackathon_03.png -------------------------------------------------------------------------------- /public/images/works/hobby_01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/motoshifurugen/my_site/31ce1765aaeb68e6fded626f6acc2349646274f6/public/images/works/hobby_01.png -------------------------------------------------------------------------------- /public/images/works/hobby_02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/motoshifurugen/my_site/31ce1765aaeb68e6fded626f6acc2349646274f6/public/images/works/hobby_02.png -------------------------------------------------------------------------------- /public/images/works/hobby_03.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/motoshifurugen/my_site/31ce1765aaeb68e6fded626f6acc2349646274f6/public/images/works/hobby_03.png -------------------------------------------------------------------------------- /public/images/works/hobby_04.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/motoshifurugen/my_site/31ce1765aaeb68e6fded626f6acc2349646274f6/public/images/works/hobby_04.png -------------------------------------------------------------------------------- /public/images/works/hobby_05.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/motoshifurugen/my_site/31ce1765aaeb68e6fded626f6acc2349646274f6/public/images/works/hobby_05.png -------------------------------------------------------------------------------- /public/images/works/hobby_06.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/motoshifurugen/my_site/31ce1765aaeb68e6fded626f6acc2349646274f6/public/images/works/hobby_06.png -------------------------------------------------------------------------------- /public/images/works/work_01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/motoshifurugen/my_site/31ce1765aaeb68e6fded626f6acc2349646274f6/public/images/works/work_01.png -------------------------------------------------------------------------------- /public/images/works/work_02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/motoshifurugen/my_site/31ce1765aaeb68e6fded626f6acc2349646274f6/public/images/works/work_02.png -------------------------------------------------------------------------------- /public/images/works/work_03.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/motoshifurugen/my_site/31ce1765aaeb68e6fded626f6acc2349646274f6/public/images/works/work_03.png -------------------------------------------------------------------------------- /public/images/works/work_04.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/motoshifurugen/my_site/31ce1765aaeb68e6fded626f6acc2349646274f6/public/images/works/work_04.png -------------------------------------------------------------------------------- /src/app/(pages)/blog/[slug]/page.server.tsx: -------------------------------------------------------------------------------- 1 | import { Post } from '@/types/posts' 2 | 3 | // SSG 4 | export async function generateStaticParams() { 5 | const apiUrl = 6 | process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/my_site/api' 7 | try { 8 | const res = await fetch(`${apiUrl}/blog/`, { 9 | cache: 'force-cache', 10 | }) 11 | if (!res.ok) { 12 | const errorText = await res.text() 13 | throw new Error(`Failed to fetch data from ${apiUrl}/blog/: ${errorText}`) 14 | } 15 | const blogData = await res.json() 16 | return blogData.map((blog: Post) => ({ 17 | slug: blog.slug, 18 | })) 19 | } catch (error) { 20 | console.error('Error fetching blog data:', error) 21 | throw new Error('Failed to fetch blog data') 22 | } 23 | } 24 | 25 | export const getBlogArticle = async (slug: string) => { 26 | const apiUrl = 27 | process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/my_site/api' 28 | try { 29 | const res = await fetch(`${apiUrl}/blog/${slug}`, { 30 | cache: 'force-cache', 31 | }) 32 | if (!res.ok) { 33 | const errorText = await res.text() 34 | throw new Error( 35 | `Failed to fetch data from ${apiUrl}/blog/${slug}: ${errorText}`, 36 | ) 37 | } 38 | const blogArticle = await res.json() 39 | return blogArticle 40 | } catch (error) { 41 | console.error('Error fetching blog article:', error) 42 | throw new Error('Failed to fetch blog article') 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/app/(pages)/blog/[slug]/page.tsx: -------------------------------------------------------------------------------- 1 | import Toc from '@/app/components/molecules/Toc' 2 | import ArticleContent from '@/app/components/templates/ArticleContent' 3 | import { Metadata } from 'next' 4 | import { getBlogArticle } from './page.server' 5 | 6 | export async function generateMetadata({ 7 | params, 8 | }: { 9 | params: { slug: string } 10 | }): Promise { 11 | const blogArticle = await getBlogArticle(params.slug) 12 | const description = blogArticle.content.slice(0, 50) 13 | 14 | // noindexタグが含まれている場合はrobotsにnoindexを追加 15 | const robots = 16 | blogArticle.tags && blogArticle.tags.includes('noindex') 17 | ? { index: false, follow: false } 18 | : undefined 19 | 20 | return { 21 | title: blogArticle.title, 22 | description: description, 23 | robots, 24 | } 25 | } 26 | 27 | export default async function BlogArticlePage({ 28 | params, 29 | }: { 30 | params: { slug: string } 31 | }) { 32 | const blogArticle = await getBlogArticle(params.slug) 33 | 34 | return ( 35 |
36 | ]} 39 | /> 40 |
41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /src/app/(pages)/blog/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import AnimatedLine from '@/app/components/atoms/AnimatedLine' 4 | import PageFace from '@/app/components/organisms/PageFace' 5 | import dynamic from 'next/dynamic' 6 | import nextConfig from '../../../../next.config.mjs' 7 | import MaintenanceTemplate from '../../components/templates/MaintenanceTemplate' 8 | import { useI18n } from '../../../i18n/context' 9 | 10 | const BASE_PATH = nextConfig.basePath || '' 11 | const public_flag = true 12 | 13 | const ArticleList = dynamic( 14 | () => import('@/app/components/templates/ArticleList'), 15 | { ssr: false }, 16 | ) 17 | 18 | export default function Blog() { 19 | const { t } = useI18n() 20 | 21 | return ( 22 | <> 23 | {public_flag ? ( 24 | <> 25 |
26 | } /> 27 |
28 | 29 | 30 | 31 |
32 | 33 |
34 | 35 | ) : ( 36 | 40 | )} 41 | 42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /src/app/(pages)/contact/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import AnimatedLine from '../../components/atoms/AnimatedLine' 4 | import Form from '../../components/organisms/Form' 5 | import PageFace from '../../components/organisms/PageFace' 6 | import { useI18n } from '../../../i18n/context' 7 | 8 | export default function Contact() { 9 | const { t } = useI18n() 10 | 11 | return ( 12 | <> 13 |
14 | } /> 15 |
16 | 17 | 18 | 19 |
20 |
21 |
22 | 23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /src/app/(pages)/game/Game.css: -------------------------------------------------------------------------------- 1 | @import url("https://fonts.googleapis.com/css?family=Press+Start+2P"); 2 | 3 | .game { 4 | position: relative; 5 | width: 100%; 6 | height: 100%; 7 | font-family: "Press Start 2P", cursive; 8 | } 9 | -------------------------------------------------------------------------------- /src/app/(pages)/game/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Controls } from '@/app/components/game/Controls' 4 | import { Map } from '@/app/components/game/Map' 5 | import { Player } from '@/app/components/game/Player' 6 | import { Result } from '@/app/components/game/Result' 7 | import { Scene } from '@/app/components/game/Scene' 8 | import { Score } from '@/app/components/game/Score' 9 | import './Game.css' 10 | 11 | export default function GamePage() { 12 | return ( 13 |
14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 |
24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /src/app/(pages)/hugmi/privacy/page.tsx: -------------------------------------------------------------------------------- 1 | export default function PrivacyPage() { 2 | return ( 3 |
4 |

Hugmi | プライバシーポリシー

5 |
6 |

7 | Hugmi(以下、「本アプリ」といいます)は、ユーザーのプライバシーを尊重し、個人情報の取り扱いに細心の注意を払っています。本プライバシーポリシーでは、本アプリにおける情報の取得、利用、および保護について説明します。 8 |

9 |

1. 取得する情報

10 |

11 | 本アプリは、以下の情報をユーザーの端末内に保存することがあります: 12 |

13 |
    14 |
  • ユーザーが登録したルーティンや設定情報
  • 15 |
  • 名言のコレクションおよびお気に入り情報
  • 16 |
  • 通知の時間設定
  • 17 |
18 |

19 | ※本アプリでは、これらの情報を外部のサーバーに送信することはありません。 20 |

21 |

2. 情報の利用目的

22 |

取得した情報は、以下の目的で利用されます:

23 |
    24 |
  • ユーザーが登録したルーティン情報の表示・管理
  • 25 |
  • 通知機能の実行(朝/夜のリマインダー)
  • 26 |
  • アプリ体験の向上(名言の記録、表示順の管理など)
  • 27 |
28 |

3. 第三者への提供

29 |

30 | 本アプリは、取得した情報を外部に送信したり、第三者に提供・共有することはありません。 31 |

32 |

4. 通知機能について

33 |

34 | 本アプリでは、ユーザーの端末に通知を送信するため、通知の権限が必要となる場合があります。通知の受信設定は、端末の「設定」よりいつでも変更できます。 35 |

36 |

5. 広告表示について

37 |

38 | 本アプリでは、一部画面において第三者提供のネイティブ広告(アプリ内に自然に表示される形式の広告)を掲載する場合があります。広告の表示に際して、ユーザーの個人情報を収集・送信することはありませんが、広告の最適化や表示回数の管理のために、広告配信事業者によって端末識別子などの情報が利用されることがあります。広告に関連するプライバシー情報の取り扱いについては、各広告配信事業者のプライバシーポリシーをご確認ください。 39 |

40 |

6. 改訂について

41 |

42 | 本ポリシーの内容は、ユーザーへの通知なく変更される場合があります。重要な変更がある場合は、アプリ内でお知らせします。 43 |

44 |

制定日: 2024年6月

45 |
46 |
47 | ) 48 | } 49 | -------------------------------------------------------------------------------- /src/app/(pages)/profile/articles.tsx: -------------------------------------------------------------------------------- 1 | import { useI18n } from '../../../i18n/context' 2 | 3 | const useHistory = () => { 4 | const { t } = useI18n() 5 | 6 | return [ 7 | { year: t.profile.career[1998], description: t.profile.career.desc1998 }, 8 | { year: t.profile.career[2017], description: t.profile.career.desc2017 }, 9 | { year: t.profile.career[2020], description: t.profile.career.desc2020 }, 10 | { year: t.profile.career[2021], description: t.profile.career.desc2021 }, 11 | { year: t.profile.career[2023], description: t.profile.career.desc2023 }, 12 | { year: t.profile.career.current, description: t.profile.career.descCurrent }, 13 | ] 14 | } 15 | 16 | export const useArticles = () => { 17 | const { t } = useI18n() 18 | const history = useHistory() 19 | 20 | return [ 21 | { 22 | title: t.profile.career.title, 23 | content: ( 24 |
25 | {history.map((item, index) => ( 26 |
27 |

{item.year}

28 |

{item.description}

29 |
30 | ))} 31 |
32 | ), 33 | imageSrc: '/images/profile_01.jpg', 34 | imageAlt: 'profile img 01', 35 | }, 36 | { 37 | title: t.profile.interest.title, 38 | content: ( 39 |

40 | {t.profile.interest.content} 41 |

42 | ), 43 | imageSrc: '/images/profile_02.png', 44 | imageAlt: 'profile img 02', 45 | }, 46 | { 47 | title: t.profile.passion.title, 48 | content: ( 49 |
    50 |
  • {t.profile.passion.reading}
  • 51 |
  • {t.profile.passion.tanka}
  • 52 |
  • {t.profile.passion.walking}
  • 53 |
  • {t.profile.passion.driving}
  • 54 |
  • {t.profile.passion.eisa}
  • 55 |
  • {t.profile.passion.guitar}
  • 56 |
  • {t.profile.passion.baseball}
  • 57 |
  • {t.profile.passion.darts}
  • 58 |
  • {t.profile.passion.bowling}
  • 59 |
60 | ), 61 | imageSrc: '/images/profile_03.jpg', 62 | imageAlt: 'profile img 03', 63 | }, 64 | { 65 | title: t.profile.mbti.title, 66 | content: ( 67 |

68 | {t.profile.mbti.type}{t.profile.mbti.typeName} 69 |
70 |
71 | {t.profile.mbti.introvert} 72 |
73 | {t.profile.mbti.intuitive} 74 |
75 | {t.profile.mbti.feeling} 76 |
77 | {t.profile.mbti.prospecting} 78 |
79 | {t.profile.mbti.assertive} 80 |

81 | ), 82 | imageSrc: '/images/profile_04.jpg', 83 | imageAlt: 'profile img 04', 84 | }, 85 | ] 86 | } 87 | -------------------------------------------------------------------------------- /src/app/(pages)/profile/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import AnimatedLine from '../../components/atoms/AnimatedLine' 4 | import MainMessage from '../../components/atoms/MainMessage' 5 | import Article from '../../components/molecules/Article' 6 | import PageFace from '../../components/organisms/PageFace' 7 | import { useArticles } from './articles' 8 | import { useI18n } from '../../../i18n/context' 9 | 10 | export default function Page() { 11 | const { t } = useI18n() 12 | const articles = useArticles() 13 | 14 | return ( 15 | <> 16 |
17 | } 21 | /> 22 |
23 | 24 | 25 | 26 |
27 | {articles.map((article, index) => ( 28 |
35 | ))} 36 |
37 | 38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /src/app/(pages)/skills/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import AnimatedLine from '../../components/atoms/AnimatedLine' 4 | import PageFace from '../../components/organisms/PageFace' 5 | import SkillTimeline from '../../components/organisms/SkillTimeline' 6 | import WorkList from '../../components/organisms/WorkList' 7 | import { useI18n } from '../../../i18n/context' 8 | 9 | export default function Page() { 10 | const { t } = useI18n() 11 | 12 | return ( 13 | <> 14 |
15 | } /> 16 |
17 | 18 | 19 | 20 |
21 |

{t.skills.projects}

22 | 23 |
24 | 25 |
26 |

{t.skills.skills}

27 | 28 |
29 | 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /src/app/(pages)/skills/skills.tsx: -------------------------------------------------------------------------------- 1 | import { useI18n } from '../../../i18n/context' 2 | 3 | // インターフェースの定義 4 | export interface Period { 5 | start: number 6 | end: number 7 | } 8 | 9 | export interface Skill { 10 | name: string 11 | periods: Period[] 12 | total: number 13 | } 14 | 15 | // 月と目盛りの対応メモ 16 | // 1月: 17 | // 2月:+0.1 18 | // 3月:+0.2 19 | // 4月:+0.3 20 | // 5月:+0.4 21 | // 6月:+0.5 22 | // 7月:+0.6 23 | // 8月:+0.6 24 | // 9月:+0.7 25 | // 10月:+0.8 26 | // 11月:+0.9 27 | // 12月:+1.0 28 | const max = 2025.6 29 | 30 | export const useSkills = () => { 31 | const { t } = useI18n() 32 | 33 | const skills: Skill[] = [ 34 | { 35 | name: t.skills.skillNames.php, 36 | periods: [{ start: 2020.4, end: 2024.6 }], 37 | total: 2024.6 - 2020.4, 38 | }, 39 | { 40 | name: t.skills.skillNames.react, 41 | periods: [ 42 | { start: 2020.7, end: 2020.8 }, 43 | { start: 2024.6, end: max }, 44 | ], 45 | total: max - 2024.6 + (2020.8 - 2020.7), 46 | }, 47 | { 48 | name: t.skills.skillNames.vue, 49 | periods: [ 50 | { start: 2020.8, end: 2023.6 }, 51 | { start: 2024.4, end: max }, 52 | ], 53 | total: max - 2024.4 + (2023.6 - 2020.8), 54 | }, 55 | { 56 | name: t.skills.skillNames.unity, 57 | periods: [{ start: 2021.6, end: 2021.7 }], 58 | total: 2021.7 - 2021.6, 59 | }, 60 | { 61 | name: t.skills.skillNames.python, 62 | periods: [{ start: 2022.8, end: max }], 63 | total: max - 2022.8, 64 | }, 65 | { 66 | name: t.skills.skillNames.flutter, 67 | periods: [{ start: 2022.8, end: 2024.6 }], 68 | total: 2024.6 - 2022.8, 69 | }, 70 | { 71 | name: t.skills.skillNames.reactNative, 72 | periods: [{ start: 2025.5, end: max }], 73 | total: max - 2025.5, 74 | }, 75 | ] 76 | 77 | return skills.sort((a, b) => b.total - a.total) 78 | } 79 | -------------------------------------------------------------------------------- /src/app/(pages)/thankyou/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { faEnvelopeOpen } from '@fortawesome/free-solid-svg-icons' 4 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 5 | import TextArrowLinkButton from '../../components/molecules/TextArrowLinkButton' 6 | import { useI18n } from '../../../i18n/context' 7 | 8 | const ThankYou = () => { 9 | const { t } = useI18n() 10 | 11 | return ( 12 |
13 |
14 |
15 | 19 |
20 |

{t.contact.thankYou.title}

21 |

22 | {t.contact.thankYou.message} 23 |

24 |
25 | 26 |
27 |
28 |
29 | ) 30 | } 31 | 32 | export default ThankYou 33 | -------------------------------------------------------------------------------- /src/app/api/blog/[slug]/route.tsx: -------------------------------------------------------------------------------- 1 | import { getAllSlugs, getPostBySlug } from '@/app/api/utils/getPostData' 2 | import { NextRequest, NextResponse } from 'next/server' 3 | 4 | const GET = async ( 5 | req: NextRequest, 6 | { params }: { params: { slug: string } }, 7 | ) => { 8 | const { slug } = params 9 | try { 10 | const blogArticle = await getPostBySlug(slug) 11 | 12 | if (!blogArticle) { 13 | return new NextResponse(null, { status: 404 }) 14 | } 15 | 16 | return new NextResponse(JSON.stringify(blogArticle), { status: 200 }) 17 | } catch (error) { 18 | return new NextResponse(null, { status: 500 }) 19 | } 20 | } 21 | 22 | // generateStaticParams 関数の追加 23 | export const generateStaticParams = async () => { 24 | const slugs = await getAllSlugs() 25 | return slugs.map((slug: string) => ({ slug })) 26 | } 27 | 28 | export { GET } 29 | -------------------------------------------------------------------------------- /src/app/api/blog/route.tsx: -------------------------------------------------------------------------------- 1 | import { getAllPosts } from '@/app/api/utils/getPostData' 2 | import { NextResponse } from 'next/server' 3 | 4 | const GET = async () => { 5 | try { 6 | const getAllPostsData = await getAllPosts() 7 | return NextResponse.json(getAllPostsData) 8 | } catch (error) { 9 | console.error('Error fetching posts:', error) 10 | return NextResponse.error() 11 | } 12 | } 13 | 14 | export { GET } 15 | -------------------------------------------------------------------------------- /src/app/api/highscore/route.tsx: -------------------------------------------------------------------------------- 1 | import { Octokit } from '@octokit/rest' 2 | import { NextRequest, NextResponse } from 'next/server' 3 | 4 | const octokit = new Octokit({ 5 | auth: process.env.GITHUB_TOKEN, 6 | }) 7 | 8 | const GITHUB_OWNER = process.env.GITHUB_OWNER || '' 9 | const GITHUB_REPO = process.env.GITHUB_REPO || '' 10 | const HIGHSCORE_ISSUE_TITLE = 'highscore-game' 11 | const HIGHSCORE_LABEL = 'highscore' 12 | 13 | export async function GET(request: NextRequest) { 14 | try { 15 | const highScoreData = await getHighScore() 16 | return NextResponse.json(highScoreData) 17 | } catch (error) { 18 | console.error('最高スコア取得エラー:', error) 19 | return NextResponse.json( 20 | { error: '最高スコアの取得に失敗しました' }, 21 | { status: 500 }, 22 | ) 23 | } 24 | } 25 | 26 | export async function POST(request: NextRequest) { 27 | const { score } = await request.json() 28 | if (typeof score !== 'number') { 29 | return NextResponse.json({ error: 'scoreが必要です' }, { status: 400 }) 30 | } 31 | try { 32 | const current = await getHighScore() 33 | if (score > current.highScore) { 34 | await setHighScore(score, current.issueNumber) 35 | return NextResponse.json({ highScore: score, updated: true }) 36 | } else { 37 | return NextResponse.json({ highScore: current.highScore, updated: false }) 38 | } 39 | } catch (error) { 40 | console.error('最高スコア更新エラー:', error) 41 | return NextResponse.json( 42 | { error: '最高スコアの更新に失敗しました' }, 43 | { status: 500 }, 44 | ) 45 | } 46 | } 47 | 48 | // 最高スコアを取得 49 | async function getHighScore() { 50 | const issues = await octokit.issues.listForRepo({ 51 | owner: GITHUB_OWNER, 52 | repo: GITHUB_REPO, 53 | labels: HIGHSCORE_LABEL, 54 | state: 'open', 55 | }) 56 | const highScoreIssue = issues.data.find( 57 | (issue) => issue.title === HIGHSCORE_ISSUE_TITLE, 58 | ) 59 | if (highScoreIssue) { 60 | const highScore = parseHighScore(highScoreIssue.body || '0') 61 | return { 62 | highScore, 63 | issueNumber: highScoreIssue.number, 64 | } 65 | } else { 66 | // なければ新規作成 67 | const newIssue = await octokit.issues.create({ 68 | owner: GITHUB_OWNER, 69 | repo: GITHUB_REPO, 70 | title: HIGHSCORE_ISSUE_TITLE, 71 | body: '0', 72 | labels: [HIGHSCORE_LABEL], 73 | }) 74 | return { 75 | highScore: 0, 76 | issueNumber: newIssue.data.number, 77 | } 78 | } 79 | } 80 | 81 | // 最高スコアを更新 82 | async function setHighScore(score: number, issueNumber: number) { 83 | await octokit.issues.update({ 84 | owner: GITHUB_OWNER, 85 | repo: GITHUB_REPO, 86 | issue_number: issueNumber, 87 | body: score.toString(), 88 | }) 89 | } 90 | 91 | function parseHighScore(body: string): number { 92 | const n = parseInt(body.trim(), 10) 93 | return isNaN(n) ? 0 : n 94 | } 95 | -------------------------------------------------------------------------------- /src/app/api/likes/route.tsx: -------------------------------------------------------------------------------- 1 | // app/api/likes/route.ts 2 | import { Octokit } from '@octokit/rest' 3 | import { NextRequest, NextResponse } from 'next/server' 4 | 5 | const octokit = new Octokit({ 6 | auth: process.env.GITHUB_TOKEN, 7 | }) 8 | 9 | const GITHUB_OWNER = process.env.GITHUB_OWNER || '' 10 | const GITHUB_REPO = process.env.GITHUB_REPO || '' 11 | const LIKES_ISSUE_LABEL = 'blog-likes' 12 | 13 | export async function GET(request: NextRequest) { 14 | try { 15 | const url = new URL(request.url) 16 | const articleId = url.searchParams.get('articleId') 17 | 18 | if (!articleId) { 19 | return NextResponse.json({ error: '記事IDが必要です' }, { status: 400 }) 20 | } 21 | 22 | // GitHub Issueからいいね数を取得 23 | const likeData = await getLikeCount(articleId) 24 | return NextResponse.json(likeData) 25 | } catch (error) { 26 | console.error('いいね取得エラー:', error) 27 | return NextResponse.json( 28 | { error: 'いいね数の取得に失敗しました' }, 29 | { status: 500 }, 30 | ) 31 | } 32 | } 33 | 34 | export async function POST(request: NextRequest) { 35 | const { articleId, liked } = await request.json() 36 | 37 | if (!articleId) { 38 | return NextResponse.json({ error: '記事IDが必要です' }, { status: 400 }) 39 | } 40 | 41 | try { 42 | // いいねを更新 43 | const likeData = await updateLike(articleId, liked) 44 | return NextResponse.json(likeData) 45 | } catch (error) { 46 | console.error('いいね更新エラー:', error) 47 | return NextResponse.json( 48 | { error: 'いいね数の更新に失敗しました' }, 49 | { status: 500 }, 50 | ) 51 | } 52 | } 53 | 54 | // いいね数を取得する関数 55 | async function getLikeCount(articleId: string) { 56 | // 当該記事のいいね管理用Issueを検索 57 | const issues = await octokit.issues.listForRepo({ 58 | owner: GITHUB_OWNER, 59 | repo: GITHUB_REPO, 60 | labels: LIKES_ISSUE_LABEL, 61 | state: 'open', 62 | }) 63 | 64 | // 記事IDに対応するIssueを探す 65 | const likeIssue = issues.data.find( 66 | (issue) => issue.title === `likes-${articleId}`, 67 | ) 68 | 69 | if (likeIssue) { 70 | // Issueのbodyからいいね数を取得 71 | const likeCount = parseLikeCount(likeIssue.body || '0') 72 | return { 73 | articleId, 74 | likeCount, 75 | issueNumber: likeIssue.number, 76 | } 77 | } else { 78 | // 記事のいいねIssueがまだない場合は新規作成 79 | const newIssue = await octokit.issues.create({ 80 | owner: GITHUB_OWNER, 81 | repo: GITHUB_REPO, 82 | title: `likes-${articleId}`, 83 | body: '0', 84 | labels: [LIKES_ISSUE_LABEL], 85 | }) 86 | 87 | return { 88 | articleId, 89 | likeCount: 0, 90 | issueNumber: newIssue.data.number, 91 | } 92 | } 93 | } 94 | 95 | // いいねを更新する関数 96 | async function updateLike(articleId: string, liked: boolean) { 97 | // 現在のいいね情報を取得 98 | const currentLike = await getLikeCount(articleId) 99 | const issueNumber = currentLike.issueNumber 100 | 101 | // いいね数を更新 102 | const newLikeCount = liked 103 | ? currentLike.likeCount + 1 104 | : Math.max(0, currentLike.likeCount - 1) 105 | 106 | // Issueを更新 107 | await octokit.issues.update({ 108 | owner: GITHUB_OWNER, 109 | repo: GITHUB_REPO, 110 | issue_number: issueNumber, 111 | body: newLikeCount.toString(), 112 | }) 113 | 114 | return { 115 | articleId, 116 | likeCount: newLikeCount, 117 | issueNumber, 118 | liked, 119 | } 120 | } 121 | 122 | // Issueのbodyからいいね数を解析 123 | function parseLikeCount(body: string): number { 124 | const count = parseInt(body.trim(), 10) 125 | return isNaN(count) ? 0 : count 126 | } 127 | -------------------------------------------------------------------------------- /src/app/api/og-fetch/route.tsx: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server' 2 | import ogs from 'open-graph-scraper' 3 | 4 | export const GET = async (req: NextRequest) => { 5 | try { 6 | const { searchParams } = new URL(req.url) 7 | const url = searchParams.get('url') 8 | 9 | if (!url) { 10 | return NextResponse.json({ error: 'Invalid URL' }, { status: 400 }) 11 | } 12 | 13 | const { result, error } = await ogs({ url }) 14 | if (error) { 15 | return NextResponse.json( 16 | { error: 'Failed to fetch Open Graph data' }, 17 | { status: 500 }, 18 | ) 19 | } 20 | return NextResponse.json(result) 21 | } catch (error) { 22 | console.error('Error fetching Open Graph data:', error) 23 | return NextResponse.json( 24 | { error: 'Failed to fetch Open Graph data' }, 25 | { status: 500 }, 26 | ) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/app/api/utils/getPostData.tsx: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import matter from 'gray-matter' 3 | import path from 'path' 4 | 5 | interface PostData { 6 | slug: string 7 | title?: string 8 | date?: string 9 | tags?: string[] 10 | content: string 11 | [key: string]: any // その他のメタデータに対応 12 | } 13 | 14 | // posts ディレクトリのパスを取得 15 | const postsDirectoryPath = path.join(process.cwd(), 'src', 'posts') 16 | 17 | // ディレクトリが存在しない場合に作成 18 | if (!fs.existsSync(postsDirectoryPath)) { 19 | fs.mkdirSync(postsDirectoryPath, { recursive: true }) 20 | } 21 | 22 | export async function getPostBySlug(slug: string): Promise { 23 | const realSlug = slug.replace(/\.mdx$/, '') 24 | const fullPath = path.join(postsDirectoryPath, `${realSlug}.mdx`) 25 | 26 | // ファイルが存在するか確認 27 | if (!fs.existsSync(fullPath)) { 28 | throw new Error(`File not found: ${fullPath}`) 29 | } 30 | 31 | const fileContents = fs.readFileSync(fullPath, 'utf8') 32 | 33 | // gray-matter を使用してメタデータを解析 34 | const matterResult = matter(fileContents) 35 | 36 | return { 37 | slug: realSlug, 38 | ...matterResult.data, 39 | content: matterResult.content, 40 | date: matterResult.data.date, 41 | } 42 | } 43 | 44 | export async function getAllPosts(): Promise { 45 | const slugs = fs.readdirSync(postsDirectoryPath) 46 | const posts = await Promise.all(slugs.map((slug) => getPostBySlug(slug))) 47 | // 限定公開タグを持つ記事を除外 48 | return posts.filter((post) => { 49 | const tags = post.tags || [] 50 | return !tags.includes('限定公開') 51 | }) 52 | } 53 | 54 | export async function getAllSlugs() { 55 | const slugs = fs.readdirSync(postsDirectoryPath) 56 | return slugs.map((slug) => slug.replace(/\.mdx$/, '')) // 拡張子を削除してスラッグに変換 57 | } 58 | -------------------------------------------------------------------------------- /src/app/components/atoms/AnimatedLine.tsx: -------------------------------------------------------------------------------- 1 | import { usePathname } from 'next/navigation' 2 | import React, { useEffect, useRef } from 'react' 3 | import nextConfig from '../../../../next.config.mjs' 4 | const BASE_PATH = nextConfig.basePath || '' 5 | 6 | const AnimatedLine: React.FC = () => { 7 | const pathname = usePathname() 8 | const isRootPath = pathname === `${BASE_PATH}/` || pathname === '/' 9 | const lineRef = useRef(null) 10 | 11 | useEffect(() => { 12 | const observer = new IntersectionObserver( 13 | (entries) => { 14 | entries.forEach((entry) => { 15 | if (entry.isIntersecting) { 16 | if (entry.target instanceof HTMLElement) { 17 | entry.target.classList.remove('w-0') 18 | entry.target.classList.add('w-full') 19 | } 20 | observer.unobserve(entry.target) // アニメーションが実行されたらオブザーバーを停止 21 | } 22 | }) 23 | }, 24 | { threshold: 0.1 }, // 要素が10%表示されたらコールバックを実行 25 | ) 26 | 27 | const currentLineRef = lineRef.current 28 | if (currentLineRef) { 29 | observer.observe(currentLineRef) 30 | } 31 | 32 | return () => { 33 | if (currentLineRef) { 34 | observer.unobserve(currentLineRef) 35 | } 36 | } 37 | }, []) 38 | 39 | return ( 40 |
43 |
47 |
48 | ) 49 | } 50 | 51 | export default AnimatedLine 52 | -------------------------------------------------------------------------------- /src/app/components/atoms/Chip.module.css: -------------------------------------------------------------------------------- 1 | .chip { 2 | font-family: "Noto Sans JP", sans-serif; 3 | font-size: 0.875rem; 4 | font-weight: 600; 5 | padding: 0.5rem 1rem; 6 | border-radius: 9999px; 7 | background-color: var(--bg-gray); 8 | color: var(--main-black); 9 | transition: all 0.3s; 10 | border: none; 11 | cursor: pointer; 12 | } 13 | 14 | .chip:hover { 15 | background-color: var(--teal); 16 | color: white; 17 | } 18 | 19 | .selected { 20 | background-color: var(--teal); 21 | color: white; 22 | } 23 | 24 | :global(.dark) .chip { 25 | background-color: var(--night-bg-gray); 26 | color: var(--night-white); 27 | } 28 | 29 | :global(.dark) .chip:hover { 30 | background-color: var(--night-teal); 31 | } 32 | 33 | :global(.dark) .selected { 34 | background-color: var(--night-teal); 35 | } -------------------------------------------------------------------------------- /src/app/components/atoms/Chip.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | interface ChipProps { 4 | children: React.ReactNode 5 | className?: string 6 | } 7 | 8 | const Chip: React.FC = ({ children, className = '' }) => { 9 | return ( 10 | 13 | {children} 14 | 15 | ) 16 | } 17 | 18 | export default Chip 19 | -------------------------------------------------------------------------------- /src/app/components/atoms/Highlight.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | interface HighlightProps { 4 | children: React.ReactNode 5 | } 6 | 7 | const Highlight: React.FC = ({ children }) => { 8 | return
{children}
9 | } 10 | 11 | export default Highlight 12 | -------------------------------------------------------------------------------- /src/app/components/atoms/HtmlLangUpdater.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useI18n } from '@/i18n'; 4 | import { useEffect } from 'react'; 5 | 6 | export default function HtmlLangUpdater() { 7 | const { locale } = useI18n(); 8 | 9 | useEffect(() => { 10 | if (typeof document !== 'undefined') { 11 | document.documentElement.lang = locale; 12 | } 13 | }, [locale]); 14 | 15 | return null; 16 | } -------------------------------------------------------------------------------- /src/app/components/atoms/LoadingCircle.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react' 2 | 3 | interface LoadingCircleProps { 4 | isLoading: boolean 5 | } 6 | 7 | const LoadingCircle: React.FC = ({ isLoading }) => { 8 | const [isVisible, setIsVisible] = useState(true) 9 | 10 | useEffect(() => { 11 | if (!isLoading) { 12 | const timer = setTimeout(() => { 13 | setIsVisible(false) 14 | }, 1000) // フェードアウトの時間と一致させる 15 | return () => clearTimeout(timer) 16 | } 17 | }, [isLoading]) 18 | 19 | return ( 20 | isVisible && ( 21 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 | 111 |
112 | ) 113 | ) 114 | } 115 | 116 | export default LoadingCircle 117 | -------------------------------------------------------------------------------- /src/app/components/atoms/MainMessage.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useI18n } from '@/i18n' 4 | 5 | export default function MainMessage() { 6 | const { t } = useI18n() 7 | 8 | // 改行文字で分割して配列にする 9 | const messageLines = t.home.mainMessage.split('\n') 10 | 11 | return ( 12 | <> 13 |

14 | {messageLines.map((line, index) => ( 15 | 16 | {line} 17 | {index < messageLines.length - 1 &&
} 18 |
19 | ))} 20 |

21 | 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /src/app/components/atoms/OpenGraphFetcher.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react' 2 | 3 | interface OpenGraphData { 4 | ogTitle?: string 5 | ogDescription?: string 6 | ogImage?: { url: string } 7 | ogUrl?: string 8 | requestUrl?: string 9 | favicon?: string 10 | } 11 | 12 | interface OpenGraphFetcherProps { 13 | url: string 14 | onFetch: (data: OpenGraphData | null) => void 15 | } 16 | 17 | const OpenGraphFetcher: React.FC = ({ 18 | url, 19 | onFetch, 20 | }) => { 21 | useEffect(() => { 22 | const fetchOgData = async () => { 23 | const apiUrl = 24 | process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/my_site/api' 25 | try { 26 | const response = await fetch( 27 | `${apiUrl}/og-fetch?url=${encodeURIComponent(url)}`, 28 | ) 29 | const data = await response.json() 30 | onFetch(data) 31 | } catch (error) { 32 | console.error('Failed to fetch Open Graph data', error) 33 | onFetch(null) 34 | } 35 | } 36 | 37 | fetchOgData() 38 | }, [url, onFetch]) 39 | 40 | return null 41 | } 42 | 43 | export default OpenGraphFetcher 44 | -------------------------------------------------------------------------------- /src/app/components/atoms/SketchCloud.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { P5CanvasInstance } from '@p5-wrapper/react' 4 | import dynamic from 'next/dynamic' 5 | import React, { useEffect, useState } from 'react' 6 | 7 | // ReactP5Wrapperを動的に読み込み、SSRを無効化 8 | const ReactP5Wrapper = dynamic( 9 | () => import('@p5-wrapper/react').then((mod) => mod.ReactP5Wrapper), 10 | { 11 | ssr: false, 12 | }, 13 | ) 14 | 15 | interface SketchCloudProps { 16 | mode: 'normal' | 'light' 17 | } 18 | 19 | const SketchCloud: React.FC = ({ mode }) => { 20 | const [isMounted, setIsMounted] = useState(false) 21 | useEffect(() => { 22 | setIsMounted(true) 23 | }, []) 24 | if (!isMounted) { 25 | return null 26 | } 27 | 28 | const sketch = (p: P5CanvasInstance) => { 29 | let cloud_box: { x: number; y: number; size: number; alpha: number }[] = [] 30 | let contrail = { x1: 0, y1: 0, x2: 0, y2: 0, alpha: 255 } 31 | let contrailTimer = 0 32 | let clicked = false 33 | 34 | p.setup = () => { 35 | p.createCanvas(p.windowWidth, p.windowHeight) 36 | p.noStroke() // 枠線を描かない 37 | 38 | for (let i = 0; i < 3; i++) { 39 | const x = p.random(p.windowWidth) 40 | const y = p.random(p.windowHeight) 41 | cloud_box.push({ x, y, size: 180, alpha: 255 }) 42 | } 43 | } 44 | 45 | p.draw = () => { 46 | // 描画の頻度をモードに応じて調整 47 | if (mode === 'light' && p.frameCount % 4 !== 0) { 48 | return 49 | } 50 | 51 | p.background(134, 179, 224) // 空色 52 | p.fill(246, 246, 246, 200) 53 | p.circle(p.mouseX, p.mouseY, 24) 54 | 55 | // ランダムなタイミングで飛行機雲を描画 56 | if (contrailTimer <= 0 && p.random() < 0.05) { 57 | if (p.random() < 0.5) { 58 | let position = p.random(100, p.windowHeight) 59 | contrail = { x1: 0, y1: position, x2: 0, y2: position, alpha: 255 } 60 | } else { 61 | let position = p.random(0, p.windowWidth - 100) 62 | contrail = { 63 | x1: position, 64 | y1: p.windowHeight, 65 | x2: position, 66 | y2: p.windowHeight, 67 | alpha: 255, 68 | } 69 | } 70 | contrailTimer = 500 // 飛行機雲の表示時間 71 | } 72 | 73 | if (contrailTimer > 0) { 74 | p.stroke(246, contrail.alpha) // 白色の線 75 | p.strokeWeight(4) 76 | p.line(contrail.x1, contrail.y1, contrail.x2, contrail.y2) 77 | contrail.x2 += 2 78 | contrail.y2 -= 2 79 | contrail.alpha -= 0.6 80 | contrailTimer-- 81 | } 82 | 83 | p.noStroke() 84 | 85 | if (p.mouseIsPressed || p.touches.length > 0) { 86 | clicked = true 87 | const x = p.mouseIsPressed ? p.mouseX : p.touches[0].x 88 | const y = p.mouseIsPressed ? p.mouseY : p.touches[0].y 89 | cloud_box.push({ x, y, size: 75, alpha: 255 }) 90 | } 91 | 92 | for (let i = 0; i < cloud_box.length; i++) { 93 | let cloud = cloud_box[i] 94 | p.fill(200, 200, 200, cloud.alpha * 0.25) 95 | p.circle(cloud.x, cloud.y + 5, cloud.size) 96 | p.fill(246, 246, 246, cloud.alpha) 97 | p.circle(cloud.x, cloud.y, cloud.size) 98 | cloud.x -= 0.5 99 | cloud.alpha -= 0.5 100 | cloud.size += 0.1 101 | } 102 | 103 | cloud_box = cloud_box.filter((cloud) => cloud.alpha > 0) 104 | 105 | p.filter(p.BLUR, 3) 106 | } 107 | 108 | p.windowResized = () => { 109 | p.resizeCanvas(p.windowWidth, p.windowHeight) 110 | } 111 | } 112 | 113 | return ( 114 | 115 | ) 116 | } 117 | 118 | export default SketchCloud 119 | -------------------------------------------------------------------------------- /src/app/components/atoms/SketchNight.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { P5CanvasInstance } from '@p5-wrapper/react' 4 | import dynamic from 'next/dynamic' 5 | import React, { useEffect, useState } from 'react' 6 | 7 | const ReactP5Wrapper = dynamic( 8 | () => import('@p5-wrapper/react').then((mod) => mod.ReactP5Wrapper), 9 | { 10 | ssr: false, 11 | }, 12 | ) 13 | 14 | interface SketchNightSkyProps { 15 | mode: 'normal' | 'light' 16 | } 17 | 18 | const SketchNightSky: React.FC = ({ mode }) => { 19 | const [isMounted, setIsMounted] = useState(false) 20 | useEffect(() => { 21 | setIsMounted(true) 22 | }, []) 23 | if (!isMounted) { 24 | return null 25 | } 26 | 27 | const sketch = (p: P5CanvasInstance) => { 28 | let stars: { x: number; y: number; size: number; alpha: number }[] = [] 29 | let shootingStars: { 30 | x: number 31 | y: number 32 | alpha: number 33 | vx: number 34 | vy: number 35 | }[] = [] 36 | let shootingStarTimer = 0 37 | 38 | p.setup = () => { 39 | p.createCanvas(p.windowWidth, p.windowHeight) 40 | for (let i = 0; i < 100; i++) { 41 | stars.push({ 42 | x: p.random(p.windowWidth), 43 | y: p.random(p.windowHeight), 44 | size: p.random(2, 10), 45 | alpha: p.random(100, 255), 46 | }) 47 | } 48 | } 49 | 50 | p.draw = () => { 51 | if (mode === 'light' && p.frameCount % 4 !== 0) { 52 | return 53 | } 54 | 55 | p.background(46, 46, 63) // 夜空の色 56 | 57 | // 星の描画 58 | for (let star of stars) { 59 | p.fill(255, 250, 200, star.alpha) 60 | p.noStroke() 61 | p.circle(star.x, star.y, star.size) 62 | star.alpha = 100 + 155 * p.sin(p.frameCount * 0.02 + star.x * 0.1) 63 | } 64 | 65 | // ランダムなタイミングで流れ星を発生 66 | if (shootingStarTimer <= 0 && p.random() < 0.02) { 67 | shootingStars.push({ 68 | x: p.random(p.windowWidth), 69 | y: p.random(p.windowHeight / 2), 70 | alpha: 255, 71 | vx: p.random(-5, -2), 72 | vy: p.random(2, 5), 73 | }) 74 | shootingStarTimer = 100 75 | } 76 | 77 | // 流れ星の描画 78 | for (let i = shootingStars.length - 1; i >= 0; i--) { 79 | let s = shootingStars[i] 80 | p.stroke(255, s.alpha) 81 | p.strokeWeight(2) 82 | p.line(s.x, s.y, s.x + s.vx * 5, s.y + s.vy * 5) 83 | s.x += s.vx 84 | s.y += s.vy 85 | s.alpha -= 5 86 | if (s.alpha <= 0) { 87 | shootingStars.splice(i, 1) 88 | } 89 | } 90 | shootingStarTimer-- 91 | 92 | // クリックで星を追加 93 | if (p.mouseIsPressed || p.touches.length > 0) { 94 | const x = p.mouseIsPressed ? p.mouseX : p.touches[0].x 95 | const y = p.mouseIsPressed ? p.mouseY : p.touches[0].y 96 | stars.push({ x, y, size: p.random(5, 20), alpha: 255 }) 97 | } 98 | } 99 | 100 | p.windowResized = () => { 101 | p.resizeCanvas(p.windowWidth, p.windowHeight) 102 | } 103 | } 104 | 105 | return ( 106 | 107 | ) 108 | } 109 | 110 | export default SketchNightSky 111 | -------------------------------------------------------------------------------- /src/app/components/atoms/SubmitButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | interface SubmitButtonProps { 4 | children: React.ReactNode 5 | } 6 | 7 | const SubmitButton: React.FC = ({ children }) => { 8 | return ( 9 | 23 | ) 24 | } 25 | 26 | export default SubmitButton 27 | -------------------------------------------------------------------------------- /src/app/components/atoms/TitleAnimation.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import gsap from 'gsap' 4 | import { TextPlugin } from 'gsap/TextPlugin' 5 | import { useEffect, useRef } from 'react' 6 | 7 | gsap.registerPlugin(TextPlugin) 8 | 9 | export default function TitleAnimation() { 10 | // アニメーション 11 | const textRef = useRef(null) 12 | const arrowRef = useRef(null) 13 | 14 | useEffect(() => { 15 | if (textRef.current) { 16 | gsap.fromTo( 17 | textRef.current, 18 | { opacity: 0 }, 19 | { 20 | duration: 1.5, 21 | text: "Furugen's
Island", 22 | ease: 'power4.inOut', 23 | parse: true, 24 | opacity: 1, 25 | }, 26 | ) 27 | } 28 | 29 | if (arrowRef.current) { 30 | gsap.to(arrowRef.current, { 31 | y: -10, 32 | repeat: -1, 33 | yoyo: true, 34 | ease: 'power1.inOut', 35 | duration: 0.8, 36 | }) 37 | } 38 | }, []) 39 | 40 | return ( 41 |
42 |
48 |
49 |
53 | scroll 54 |
55 |
59 | ↓ 60 |
61 |
62 |
63 | ) 64 | } 65 | -------------------------------------------------------------------------------- /src/app/components/atoms/TitleLinkButton.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link' 2 | import React from 'react' 3 | 4 | interface TitleLinkButtonProps { 5 | href: string 6 | text: string 7 | } 8 | 9 | const TitleLinkButton: React.FC = ({ href, text }) => { 10 | return ( 11 | 12 | 13 | {text} 14 | 15 | 16 | ) 17 | } 18 | 19 | export default TitleLinkButton 20 | -------------------------------------------------------------------------------- /src/app/components/game/Car.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { tileSize } from '@/app/components/game/const' 4 | import useHitDetection from '@/app/components/game/hooks/useHitDetection' 5 | import useVehicleAnimation from '@/app/components/game/hooks/useVehicleAnimation' 6 | import { Wheel } from '@/app/components/game/Wheel' 7 | import { useRef } from 'react' 8 | import * as THREE from 'three' 9 | 10 | type Props = { 11 | rowIndex: number 12 | initialTileIndex: number 13 | direction: boolean 14 | speed: number 15 | color: THREE.ColorRepresentation 16 | } 17 | 18 | export function Car({ 19 | rowIndex, 20 | initialTileIndex, 21 | direction, 22 | speed, 23 | color, 24 | }: Props) { 25 | const car = useRef(null) 26 | useVehicleAnimation(car, direction, speed) 27 | useHitDetection(car, rowIndex) 28 | return ( 29 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | ) 46 | } 47 | -------------------------------------------------------------------------------- /src/app/components/game/CarLane.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Car } from '@/app/components/game/Car' 4 | import { Road } from '@/app/components/game/Road' 5 | import type { Row } from '@/types/game-objects' 6 | 7 | type Props = { 8 | rowIndex: number 9 | rowData: Extract 10 | } 11 | 12 | export function CarLane({ rowIndex, rowData }: Props) { 13 | return ( 14 | 15 | {rowData.vehicles.map((vehicle, index) => ( 16 | 24 | ))} 25 | 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /src/app/components/game/Controls.css: -------------------------------------------------------------------------------- 1 | #controls { 2 | position: absolute; 3 | bottom: 20px; 4 | min-width: 100%; 5 | display: flex; 6 | align-items: flex-end; 7 | justify-content: center; 8 | } 9 | 10 | #controls div { 11 | display: grid; 12 | grid-template-columns: 50px 50px 50px; 13 | gap: 10px; 14 | } 15 | 16 | #controls button { 17 | width: 100%; 18 | height: 40px; 19 | background-color: #ffffff; 20 | border: 2px solid #333333; 21 | box-shadow: 3px 5px 0px 0px rgb(0, 0, 0); 22 | opacity: 0.9; 23 | cursor: pointer; 24 | outline: none; 25 | color: #333333; 26 | font-weight: bold; 27 | transition: all 0.2s ease; 28 | } 29 | 30 | #controls button:hover { 31 | background-color: #f0f0f0; 32 | transform: translateY(2px); 33 | box-shadow: 1px 3px 0px 0px rgb(0, 0, 0); 34 | } 35 | 36 | #controls button:active { 37 | transform: translateY(4px); 38 | box-shadow: 0px 1px 0px 0px rgb(0, 0, 0); 39 | } 40 | 41 | #controls button:first-of-type { 42 | grid-column: 1/-1; 43 | } 44 | 45 | @media (prefers-color-scheme: dark) { 46 | #controls button { 47 | background-color: #ffffff; 48 | border-color: #ffffff; 49 | color: #333333; 50 | } 51 | 52 | #controls button:hover { 53 | background-color: #f0f0f0; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/app/components/game/Controls.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import useEventListeners from '@/app/components/game/hooks/useEventListeners' 4 | import { queueMove } from '@/app/components/game/stores/player' 5 | import './Controls.css' 6 | 7 | export function Controls() { 8 | useEventListeners() 9 | 10 | return ( 11 |
12 |
13 | 14 | 15 | 16 | 17 |
18 |
19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /src/app/components/game/DirectionalLight.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { forwardRef } from 'react' 4 | import * as THREE from 'three' 5 | 6 | export const DirectionalLight = forwardRef((_, ref) => { 7 | return ( 8 | 21 | ) 22 | }) 23 | 24 | DirectionalLight.displayName = 'DirectionalLight' 25 | -------------------------------------------------------------------------------- /src/app/components/game/Forest.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Grass } from '@/app/components/game/Grass' 4 | import { Tree } from '@/app/components/game/Tree' 5 | import type { Row } from '@/types/game-objects' 6 | 7 | type Props = { 8 | rowIndex: number 9 | rowData: Extract 10 | } 11 | 12 | export function Forest({ rowIndex, rowData }: Props) { 13 | return ( 14 | 15 | {rowData.trees.map((tree, index) => ( 16 | 17 | ))} 18 | 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /src/app/components/game/Grass.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { tileSize, tilesPerRow } from '@/app/components/game/const' 4 | 5 | type Props = { 6 | rowIndex: number 7 | children?: React.ReactNode 8 | } 9 | 10 | export function Grass({ rowIndex, children }: Props) { 11 | return ( 12 | 13 | 14 | 15 | 16 | 17 | {children} 18 | 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /src/app/components/game/Map.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Grass } from '@/app/components/game/Grass' 4 | import { Row } from '@/app/components/game/Row' 5 | import useStore from '@/app/components/game/stores/map' 6 | 7 | export function Map() { 8 | const rows = useStore((state) => state.rows) 9 | 10 | return ( 11 | <> 12 | 13 | 14 | 15 | 16 | 17 | 18 | {rows.map((rowData, index) => ( 19 | 20 | ))} 21 | 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /src/app/components/game/Player.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { DirectionalLight } from '@/app/components/game/DirectionalLight' 4 | import { Bounds } from '@react-three/drei' 5 | import { useThree } from '@react-three/fiber' 6 | import { useEffect, useRef } from 'react' 7 | import * as THREE from 'three' 8 | import usePlayerAnimation from './hooks/usePlayerAnimation' 9 | import { setRef } from './stores/player' 10 | 11 | export function Player() { 12 | const player = useRef(null) 13 | const lightRef = useRef(null) 14 | const camera = useThree((state) => state.camera) 15 | 16 | usePlayerAnimation(player) 17 | 18 | useEffect(() => { 19 | if (!player.current) return 20 | if (!lightRef.current) return 21 | 22 | player.current.add(camera) 23 | lightRef.current.target = player.current 24 | 25 | setRef(player.current) 26 | }, []) 27 | 28 | return ( 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | ) 45 | } 46 | -------------------------------------------------------------------------------- /src/app/components/game/Result.css: -------------------------------------------------------------------------------- 1 | #result-container { 2 | position: fixed; 3 | width: 100%; 4 | height: calc(100vh - 80px); 5 | top: 80px; 6 | left: 0; 7 | display: flex; 8 | align-items: center; 9 | justify-content: center; 10 | background: linear-gradient(135deg, rgba(0, 0, 0, 0.8), rgba(0, 0, 0, 0.6)); 11 | backdrop-filter: blur(8px); 12 | animation: fadeIn 0.5s ease-out; 13 | z-index: 1; 14 | 15 | #result { 16 | display: flex; 17 | flex-direction: column; 18 | align-items: center; 19 | background: linear-gradient(135deg, rgba(255, 255, 255, 0.95), rgba(255, 255, 255, 0.9)); 20 | padding: 40px; 21 | border-radius: 20px; 22 | box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2); 23 | transform: translateY(0); 24 | animation: slideUp 0.5s ease-out; 25 | min-width: 300px; 26 | z-index: 2; 27 | 28 | h1 { 29 | color: #c41e3a; 30 | font-size: 2.5em; 31 | margin-bottom: 20px; 32 | text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.1); 33 | } 34 | 35 | p { 36 | color: #333; 37 | font-size: 1.2em; 38 | margin-bottom: 30px; 39 | } 40 | 41 | button { 42 | background: linear-gradient(135deg, #c41e3a, #e74c3c); 43 | color: white; 44 | padding: 15px 40px; 45 | border: none; 46 | border-radius: 25px; 47 | font-family: inherit; 48 | font-size: 1.1em; 49 | cursor: pointer; 50 | transition: all 0.3s ease; 51 | box-shadow: 0 4px 15px rgba(196, 30, 58, 0.3); 52 | text-transform: uppercase; 53 | letter-spacing: 1px; 54 | 55 | &:hover { 56 | transform: translateY(-2px); 57 | box-shadow: 0 6px 20px rgba(196, 30, 58, 0.4); 58 | } 59 | 60 | &:active { 61 | transform: translateY(0); 62 | } 63 | } 64 | } 65 | } 66 | 67 | @keyframes fadeIn { 68 | from { 69 | opacity: 0; 70 | } 71 | to { 72 | opacity: 1; 73 | } 74 | } 75 | 76 | @keyframes slideUp { 77 | from { 78 | opacity: 0; 79 | transform: translateY(20px); 80 | } 81 | to { 82 | opacity: 1; 83 | transform: translateY(0); 84 | } 85 | } 86 | 87 | .retry-hint-pc { 88 | color: #888; 89 | font-size: 1em; 90 | margin-top: 10px; 91 | letter-spacing: 0.05em; 92 | display: block; 93 | } 94 | 95 | @media (max-width: 768px) { 96 | .retry-hint-pc { 97 | display: none; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/app/components/game/Result.tsx: -------------------------------------------------------------------------------- 1 | import useStore from '@/app/components/game/stores/game' 2 | import { useEffect, useState } from 'react' 3 | import './Result.css' 4 | 5 | export function Result() { 6 | const status = useStore((state) => state.status) 7 | const score = useStore((state) => state.score) 8 | const reset = useStore((state) => state.reset) 9 | const [highScore, setHighScore] = useState(null) 10 | const [isNewRecord, setIsNewRecord] = useState(false) 11 | const [loading, setLoading] = useState(false) 12 | 13 | const apiBase = process.env.NEXT_PUBLIC_API_URL || '/my_site/api' 14 | 15 | useEffect(() => { 16 | if (status !== 'over') return 17 | const handleKeyDown = (event: KeyboardEvent) => { 18 | if (event.code === 'Space') { 19 | event.preventDefault() 20 | reset() 21 | } 22 | } 23 | window.addEventListener('keydown', handleKeyDown, { passive: false }) 24 | return () => { 25 | window.removeEventListener('keydown', handleKeyDown) 26 | } 27 | }, [status, reset]) 28 | 29 | useEffect(() => { 30 | if (status !== 'over') return 31 | setLoading(true) 32 | const fetchHighScore = async () => { 33 | try { 34 | const res = await fetch(`${apiBase}/highscore`) 35 | const data = await res.json() 36 | let high = data.highScore || 0 37 | if (score > high) { 38 | setIsNewRecord(true) 39 | const postRes = await fetch(`${apiBase}/highscore`, { 40 | method: 'POST', 41 | headers: { 'Content-Type': 'application/json' }, 42 | body: JSON.stringify({ score }), 43 | }) 44 | const postData = await postRes.json() 45 | high = postData.highScore || score 46 | } else { 47 | setIsNewRecord(false) 48 | } 49 | setHighScore(high) 50 | } catch (e) { 51 | setHighScore(null) 52 | } finally { 53 | setLoading(false) 54 | } 55 | } 56 | fetchHighScore() 57 | }, [status, score, apiBase]) 58 | 59 | if (status === 'running') return null 60 | 61 | return ( 62 |
63 |
64 |

Game Over

65 |

73 | Your score: {score} 74 |

75 |
84 | {loading ? ( 85 | 89 | 102 | 103 | 104 | ) : ( 105 | highScore !== null && ( 106 |

114 | High Score: {highScore} {isNewRecord && 🎉 New!} 115 |

116 | ) 117 | )} 118 |
119 | 120 |

Press Space to retry

121 |
122 |
123 | ) 124 | } 125 | -------------------------------------------------------------------------------- /src/app/components/game/Road.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { tileSize, tilesPerRow } from '@/app/components/game/const' 4 | 5 | type Props = { 6 | rowIndex: number 7 | children: React.ReactNode 8 | } 9 | 10 | export function Road({ rowIndex, children }: Props) { 11 | return ( 12 | 13 | 14 | 15 | 16 | 17 | {children} 18 | 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /src/app/components/game/Row.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { CarLane } from '@/app/components/game/CarLane' 4 | import { Forest } from '@/app/components/game/Forest' 5 | import { TruckLane } from '@/app/components/game/TruckLane' 6 | import type { Row } from '@/types/game-objects' 7 | 8 | type Props = { 9 | rowIndex: number 10 | rowData: Row 11 | } 12 | 13 | export function Row({ rowIndex, rowData }: Props) { 14 | switch (rowData.type) { 15 | case 'forest': 16 | return 17 | case 'car': 18 | return 19 | case 'truck': 20 | return 21 | default: 22 | return null 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/app/components/game/Scene.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Canvas } from '@react-three/fiber' 4 | 5 | export const Scene = ({ children }: { children: React.ReactNode }) => { 6 | return ( 7 | 23 | 24 | {children} 25 | 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /src/app/components/game/Score.css: -------------------------------------------------------------------------------- 1 | #score { 2 | position: absolute; 3 | top: 20px; 4 | left: 20px; 5 | 6 | font-size: 1.2em; 7 | color: white; 8 | text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5); 9 | background-color: rgba(255, 193, 7, 0.7); 10 | padding: 10px 20px; 11 | border-radius: 8px; 12 | backdrop-filter: blur(4px); 13 | } 14 | 15 | .score--high { 16 | background: linear-gradient(90deg, #ffe259, #ffa751 60%, #ffd700 100%); 17 | color: #fff8dc; 18 | box-shadow: 0 0 12px 2px #ffd700, 0 0 24px 4px #ffa751; 19 | animation: shine 2.5s linear infinite; 20 | } 21 | 22 | @keyframes shine { 23 | 0% { filter: brightness(1); } 24 | 50% { filter: brightness(1.15); } 25 | 100% { filter: brightness(1); } 26 | } 27 | -------------------------------------------------------------------------------- /src/app/components/game/Score.tsx: -------------------------------------------------------------------------------- 1 | import useStore from '@/app/components/game/stores/game' 2 | import './Score.css' 3 | 4 | export function Score() { 5 | const score = useStore((state) => state.score) 6 | const isHigh = score > 100 7 | 8 | return ( 9 |
10 | SCORE: {score} 11 |
12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /src/app/components/game/Tree.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { tileSize } from '@/app/components/game/const' 4 | 5 | type Props = { 6 | tileIndex: number 7 | height: number 8 | } 9 | 10 | export function Tree({ tileIndex, height }: Props) { 11 | return ( 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /src/app/components/game/Truck.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { tileSize } from '@/app/components/game/const' 4 | import useHitDetection from '@/app/components/game/hooks/useHitDetection' 5 | import useVehicleAnimation from '@/app/components/game/hooks/useVehicleAnimation' 6 | import { Wheel } from '@/app/components/game/Wheel' 7 | import { useRef } from 'react' 8 | import * as THREE from 'three' 9 | 10 | type Props = { 11 | rowIndex: number 12 | initialTileIndex: number 13 | direction: boolean 14 | speed: number 15 | color: THREE.ColorRepresentation 16 | } 17 | 18 | export function Truck({ 19 | rowIndex, 20 | initialTileIndex, 21 | direction, 22 | speed, 23 | color, 24 | }: Props) { 25 | const truck = useRef(null) 26 | useVehicleAnimation(truck, direction, speed) 27 | useHitDetection(truck, rowIndex) 28 | 29 | return ( 30 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | ) 48 | } 49 | -------------------------------------------------------------------------------- /src/app/components/game/TruckLane.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Road } from '@/app/components/game/Road' 4 | import { Truck } from '@/app/components/game/Truck' 5 | import type { Row } from '@/types/game-objects' 6 | 7 | type Props = { 8 | rowIndex: number 9 | rowData: Extract 10 | } 11 | 12 | export function TruckLane({ rowIndex, rowData }: Props) { 13 | return ( 14 | 15 | {rowData.vehicles.map((vehicle, index) => ( 16 | 24 | ))} 25 | 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /src/app/components/game/Wheel.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | export function Wheel({ x }: { x: number }) { 4 | return ( 5 | 6 | 7 | 8 | 9 | ) 10 | } 11 | -------------------------------------------------------------------------------- /src/app/components/game/const.ts: -------------------------------------------------------------------------------- 1 | export const minTileIndex = -8 2 | export const maxTileIndex = 8 3 | export const tilesPerRow = maxTileIndex - minTileIndex + 1 4 | export const tileSize = 42 5 | -------------------------------------------------------------------------------- /src/app/components/game/hooks/useEventListeners.ts: -------------------------------------------------------------------------------- 1 | import { queueMove } from '@/app/components/game/stores/player' 2 | import { useEffect } from 'react' 3 | 4 | export default function useEventListeners() { 5 | useEffect(() => { 6 | const handleKeyDown = (event: KeyboardEvent) => { 7 | if (event.key === 'ArrowUp') { 8 | event.preventDefault() 9 | queueMove('forward') 10 | } else if (event.key === 'ArrowDown') { 11 | event.preventDefault() 12 | queueMove('backward') 13 | } else if (event.key === 'ArrowLeft') { 14 | event.preventDefault() 15 | queueMove('left') 16 | } else if (event.key === 'ArrowRight') { 17 | event.preventDefault() 18 | queueMove('right') 19 | } 20 | } 21 | 22 | window.addEventListener('keydown', handleKeyDown) 23 | 24 | return () => { 25 | window.removeEventListener('keydown', handleKeyDown) 26 | } 27 | }, []) 28 | } 29 | -------------------------------------------------------------------------------- /src/app/components/game/hooks/useHitDetection.ts: -------------------------------------------------------------------------------- 1 | import useGameStore from '@/app/components/game/stores/game' 2 | import { state as player } from '@/app/components/game/stores/player' 3 | import { useFrame } from '@react-three/fiber' 4 | import * as THREE from 'three' 5 | 6 | export default function useHitDetection( 7 | vehicle: React.RefObject, 8 | rowIndex: number, 9 | ) { 10 | const endGame = useGameStore((state) => state.endGame) 11 | 12 | useFrame(() => { 13 | if (!vehicle.current) return 14 | if (!player.ref) return 15 | 16 | if ( 17 | rowIndex === player.currentRow || 18 | rowIndex === player.currentRow + 1 || 19 | rowIndex === player.currentRow - 1 20 | ) { 21 | const vehicleBoundingBox = new THREE.Box3() 22 | vehicleBoundingBox.setFromObject(vehicle.current) 23 | 24 | const playerBoundingBox = new THREE.Box3() 25 | playerBoundingBox.setFromObject(player.ref) 26 | 27 | if (vehicleBoundingBox.intersectsBox(playerBoundingBox)) { 28 | endGame() 29 | } 30 | } 31 | }) 32 | } 33 | -------------------------------------------------------------------------------- /src/app/components/game/hooks/usePlayerAnimation.ts: -------------------------------------------------------------------------------- 1 | import { tileSize } from '@/app/components/game/const' 2 | import { state, stepCompleted } from '@/app/components/game/stores/player' 3 | import { useFrame } from '@react-three/fiber' 4 | import * as THREE from 'three' 5 | 6 | export default function usePlayerAnimation( 7 | ref: React.RefObject, 8 | ) { 9 | const moveClock = new THREE.Clock(false) 10 | 11 | useFrame(() => { 12 | if (!ref.current) return 13 | if (!state.movesQueue.length) return 14 | const player = ref.current 15 | 16 | if (!moveClock.running) moveClock.start() 17 | 18 | const stepTime = 0.2 19 | const progress = Math.min(1, moveClock.getElapsedTime() / stepTime) 20 | 21 | setPosition(player, progress) 22 | setRotation(player, progress) 23 | 24 | if (progress >= 1) { 25 | stepCompleted() 26 | moveClock.stop() 27 | } 28 | }) 29 | } 30 | 31 | function setPosition(player: THREE.Group, progress: number) { 32 | const startX = state.currentTile * tileSize 33 | const startY = state.currentRow * tileSize 34 | let endX = startX 35 | let endY = startY 36 | 37 | if (state.movesQueue[0] == 'left') endX -= tileSize 38 | if (state.movesQueue[0] == 'right') endX += tileSize 39 | if (state.movesQueue[0] == 'forward') endY += tileSize 40 | if (state.movesQueue[0] == 'backward') endY -= tileSize 41 | 42 | player.position.x = THREE.MathUtils.lerp(startX, endX, progress) 43 | player.position.y = THREE.MathUtils.lerp(startY, endY, progress) 44 | player.children[0].position.z = Math.sin(progress * Math.PI) * 8 45 | } 46 | 47 | function setRotation(player: THREE.Group, progress: number) { 48 | let endRotation = 0 49 | if (state.movesQueue[0] == 'forward') endRotation = 0 50 | if (state.movesQueue[0] == 'left') endRotation = Math.PI / 2 51 | if (state.movesQueue[0] == 'backward') endRotation = Math.PI 52 | if (state.movesQueue[0] == 'right') endRotation = -Math.PI / 2 53 | 54 | player.children[0].rotation.z = THREE.MathUtils.lerp( 55 | player.children[0].rotation.z, 56 | endRotation, 57 | progress, 58 | ) 59 | } 60 | -------------------------------------------------------------------------------- /src/app/components/game/hooks/useVehicleAnimation.ts: -------------------------------------------------------------------------------- 1 | import { 2 | maxTileIndex, 3 | minTileIndex, 4 | tileSize, 5 | } from '@/app/components/game/const' 6 | import { useFrame } from '@react-three/fiber' 7 | import * as THREE from 'three' 8 | 9 | export default function useVehicleAnimation( 10 | ref: React.RefObject, 11 | direction: boolean, 12 | speed: number, 13 | ) { 14 | useFrame((state, delta) => { 15 | if (!ref.current) return 16 | const vehicle = ref.current 17 | 18 | const beginningOfRow = (minTileIndex - 2) * tileSize 19 | const endOfRow = (maxTileIndex + 2) * tileSize 20 | 21 | if (direction) { 22 | vehicle.position.x = 23 | vehicle.position.x > endOfRow 24 | ? beginningOfRow 25 | : vehicle.position.x + speed * delta 26 | } else { 27 | vehicle.position.x = 28 | vehicle.position.x < beginningOfRow 29 | ? endOfRow 30 | : vehicle.position.x - speed * delta 31 | } 32 | }) 33 | } 34 | -------------------------------------------------------------------------------- /src/app/components/game/metadata.ts: -------------------------------------------------------------------------------- 1 | import type { Row } from '../../../types/game-objects' 2 | 3 | export const rows: Row[] = [ 4 | { 5 | type: 'car', 6 | direction: false, 7 | speed: 188, 8 | vehicles: [ 9 | { initialTileIndex: -4, color: 0xbdb638 }, 10 | { initialTileIndex: -1, color: 0x78b14b }, 11 | { initialTileIndex: 4, color: 0xa52523 }, 12 | ], 13 | }, 14 | { 15 | type: 'forest', 16 | trees: [ 17 | { tileIndex: -5, height: 50 }, 18 | { tileIndex: 0, height: 30 }, 19 | { tileIndex: 3, height: 50 }, 20 | ], 21 | }, 22 | { 23 | type: 'truck', 24 | direction: true, 25 | speed: 125, 26 | vehicles: [ 27 | { initialTileIndex: -4, color: 0x78b14b }, 28 | { initialTileIndex: 0, color: 0xbdb638 }, 29 | ], 30 | }, 31 | { 32 | type: 'forest', 33 | trees: [ 34 | { tileIndex: -8, height: 30 }, 35 | { tileIndex: -3, height: 50 }, 36 | { tileIndex: 2, height: 30 }, 37 | ], 38 | }, 39 | ] 40 | -------------------------------------------------------------------------------- /src/app/components/game/stores/game.ts: -------------------------------------------------------------------------------- 1 | import useMapStore from '@/app/components/game/stores/map' 2 | import { reset as resetPlayerStore } from '@/app/components/game/stores/player' 3 | import { create } from 'zustand' 4 | 5 | interface StoreState { 6 | status: 'running' | 'over' 7 | score: number 8 | updateScore: (rowIndex: number) => void 9 | endGame: () => void 10 | reset: () => void 11 | } 12 | 13 | const useStore = create((set) => ({ 14 | status: 'running', 15 | score: 0, 16 | updateScore: (rowIndex: number) => { 17 | set((state) => ({ score: Math.max(rowIndex, state.score) })) 18 | }, 19 | endGame: () => { 20 | set({ status: 'over' }) 21 | }, 22 | reset: () => { 23 | // まずゲームを一時停止 24 | set({ status: 'over' }) 25 | 26 | // すべてのリセット処理を実行 27 | useMapStore.getState().reset() 28 | resetPlayerStore() 29 | 30 | // リセット完了後にゲームを開始 31 | requestAnimationFrame(() => { 32 | set({ status: 'running', score: 0 }) 33 | }) 34 | }, 35 | })) 36 | 37 | export default useStore 38 | -------------------------------------------------------------------------------- /src/app/components/game/stores/map.ts: -------------------------------------------------------------------------------- 1 | import type { Row } from '@/types/game-objects' 2 | import { create } from 'zustand' 3 | import { generateRows } from '../utilities/generateRows' 4 | 5 | interface StoreState { 6 | rows: Row[] 7 | addRows: () => void 8 | reset: () => void 9 | } 10 | 11 | const useStore = create((set) => ({ 12 | rows: generateRows(20), 13 | addRows: () => { 14 | const newRows = generateRows(20) 15 | set((state) => ({ rows: [...state.rows, ...newRows] })) 16 | }, 17 | reset: () => set({ rows: generateRows(20) }), 18 | })) 19 | 20 | export default useStore 21 | -------------------------------------------------------------------------------- /src/app/components/game/stores/player.ts: -------------------------------------------------------------------------------- 1 | import useGameStore from '@/app/components/game/stores/game' 2 | import useMapStore from '@/app/components/game/stores/map' 3 | import { endsUpInValidPosition } from '@/app/components/game/utilities/endsUpInValidPosition' 4 | import type { MoveDirection } from '@/types/game-objects' 5 | import * as THREE from 'three' 6 | 7 | export const state: { 8 | currentRow: number 9 | currentTile: number 10 | movesQueue: MoveDirection[] 11 | ref: THREE.Object3D | null 12 | } = { 13 | currentRow: 0, 14 | currentTile: 0, 15 | movesQueue: [], 16 | ref: null, 17 | } 18 | 19 | export function queueMove(direction: MoveDirection) { 20 | if (useGameStore.getState().status === 'over') return 21 | if ( 22 | !endsUpInValidPosition( 23 | { rowIndex: state.currentRow, tileIndex: state.currentTile }, 24 | [...state.movesQueue, direction], 25 | ) 26 | ) 27 | return 28 | state.movesQueue.push(direction) 29 | } 30 | 31 | export function stepCompleted() { 32 | const direction = state.movesQueue.shift() 33 | if (!direction) return 34 | 35 | if (direction === 'forward') state.currentRow += 1 36 | if (direction === 'backward') state.currentRow -= 1 37 | if (direction === 'left') state.currentTile -= 1 38 | if (direction === 'right') state.currentTile += 1 39 | 40 | if (state.currentRow === useMapStore.getState().rows.length - 10) { 41 | useMapStore.getState().addRows() 42 | } 43 | 44 | useGameStore.getState().updateScore(state.currentRow) 45 | } 46 | 47 | export function setRef(ref: THREE.Object3D) { 48 | state.ref = ref 49 | } 50 | 51 | export function reset() { 52 | state.currentRow = 0 53 | state.currentTile = 0 54 | state.movesQueue = [] 55 | 56 | if (!state.ref) return 57 | state.ref.position.x = 0 58 | state.ref.position.y = 0 59 | state.ref.children[0].rotation.z = 0 60 | } 61 | -------------------------------------------------------------------------------- /src/app/components/game/utilities/calculateFinalPosition.ts: -------------------------------------------------------------------------------- 1 | import type { MoveDirection } from '@/types/game-objects' 2 | 3 | export function calculateFinalPosition( 4 | currentPosition: { rowIndex: number; tileIndex: number }, 5 | moves: MoveDirection[], 6 | ) { 7 | return moves.reduce((position, direction) => { 8 | if (direction === 'forward') 9 | return { 10 | rowIndex: position.rowIndex + 1, 11 | tileIndex: position.tileIndex, 12 | } 13 | if (direction === 'backward') 14 | return { 15 | rowIndex: position.rowIndex - 1, 16 | tileIndex: position.tileIndex, 17 | } 18 | if (direction === 'left') 19 | return { 20 | rowIndex: position.rowIndex, 21 | tileIndex: position.tileIndex - 1, 22 | } 23 | if (direction === 'right') 24 | return { 25 | rowIndex: position.rowIndex, 26 | tileIndex: position.tileIndex + 1, 27 | } 28 | return position 29 | }, currentPosition) 30 | } 31 | -------------------------------------------------------------------------------- /src/app/components/game/utilities/endsUpInValidPosition.ts: -------------------------------------------------------------------------------- 1 | import { maxTileIndex, minTileIndex } from '@/app/components/game/const' 2 | import useMapStore from '@/app/components/game/stores/map' 3 | import { calculateFinalPosition } from '@/app/components/game/utilities/calculateFinalPosition' 4 | import type { MoveDirection } from '@/types/game-objects' 5 | 6 | type Position = { 7 | rowIndex: number 8 | tileIndex: number 9 | } 10 | 11 | export function endsUpInValidPosition( 12 | currentPosition: Position, 13 | moves: MoveDirection[], 14 | ): boolean { 15 | const finalPosition = calculateFinalPosition(currentPosition, moves) 16 | 17 | // マップの端に当たった場合 18 | if ( 19 | finalPosition.rowIndex === -1 || 20 | finalPosition.tileIndex === minTileIndex - 1 || 21 | finalPosition.tileIndex === maxTileIndex + 1 22 | ) { 23 | return false 24 | } 25 | 26 | // 木に当たった場合 27 | const finalRow = useMapStore.getState().rows[finalPosition.rowIndex - 1] 28 | if ( 29 | finalRow && 30 | finalRow.type === 'forest' && 31 | finalRow.trees.some((tree) => tree.tileIndex === finalPosition.tileIndex) 32 | ) { 33 | return false 34 | } 35 | 36 | return true 37 | } 38 | -------------------------------------------------------------------------------- /src/app/components/game/utilities/generateRows.ts: -------------------------------------------------------------------------------- 1 | import { maxTileIndex, minTileIndex } from '@/app/components/game/const' 2 | import { type Row, type RowType } from '@/types/game-objects' 3 | import * as THREE from 'three' 4 | 5 | export function generateRows(amount: number): Row[] { 6 | const rows: Row[] = [] 7 | for (let i = 0; i < amount; i++) { 8 | const rowData = generateRow() 9 | rows.push(rowData) 10 | } 11 | return rows 12 | } 13 | 14 | function generateRow(): Row { 15 | const type: RowType = randomElement([ 16 | 'car', 17 | 'forest', 18 | 'forest', 19 | 'truck', 20 | 'forest', 21 | 'forest', 22 | ]) 23 | if (type === 'car') return generateCarLaneMetadata() 24 | if (type === 'truck') return generateTruckLaneMetadata() 25 | return generateForestRowMetadata() 26 | } 27 | 28 | function randomElement(array: T[]): T { 29 | return array[Math.floor(Math.random() * array.length)] 30 | } 31 | 32 | function generateForestRowMetadata(): Row { 33 | const occupiedTiles = new Set() 34 | const trees = Array.from({ length: 4 }, () => { 35 | let tileIndex 36 | do { 37 | tileIndex = THREE.MathUtils.randInt(minTileIndex, maxTileIndex) 38 | } while (occupiedTiles.has(tileIndex)) 39 | occupiedTiles.add(tileIndex) 40 | 41 | const height = randomElement([20, 45, 60]) 42 | 43 | return { tileIndex, height } 44 | }) 45 | 46 | return { type: 'forest', trees } 47 | } 48 | 49 | function generateCarLaneMetadata(): Row { 50 | const direction = randomElement([true, false]) 51 | const speed = randomElement([125, 156, 188]) 52 | 53 | const occupiedTiles = new Set() 54 | 55 | const vehicles = Array.from({ length: 3 }, () => { 56 | let initialTileIndex 57 | do { 58 | initialTileIndex = THREE.MathUtils.randInt(minTileIndex, maxTileIndex - 1) 59 | } while (occupiedTiles.has(initialTileIndex)) 60 | occupiedTiles.add(initialTileIndex - 1) 61 | occupiedTiles.add(initialTileIndex) 62 | occupiedTiles.add(initialTileIndex + 1) 63 | 64 | const color: THREE.ColorRepresentation = randomElement([ 65 | 0xa52523, 0xbdb638, 0x78b14b, 66 | ]) 67 | 68 | return { initialTileIndex, color } 69 | }) 70 | 71 | return { type: 'car', direction, speed, vehicles } 72 | } 73 | 74 | function generateTruckLaneMetadata(): Row { 75 | const direction = randomElement([true, false]) 76 | const speed = randomElement([125, 156, 188]) 77 | 78 | const occupiedTiles = new Set() 79 | 80 | const vehicles = Array.from({ length: 2 }, () => { 81 | let initialTileIndex 82 | do { 83 | initialTileIndex = THREE.MathUtils.randInt(minTileIndex, maxTileIndex - 1) 84 | } while (occupiedTiles.has(initialTileIndex)) 85 | occupiedTiles.add(initialTileIndex - 2) 86 | occupiedTiles.add(initialTileIndex - 1) 87 | occupiedTiles.add(initialTileIndex) 88 | occupiedTiles.add(initialTileIndex + 1) 89 | occupiedTiles.add(initialTileIndex + 2) 90 | 91 | const color: THREE.ColorRepresentation = randomElement([ 92 | 0xa52523, 0xbdb638, 0x78b14b, 93 | ]) 94 | 95 | return { initialTileIndex, color } 96 | }) 97 | 98 | return { type: 'truck', direction, speed, vehicles } 99 | } 100 | -------------------------------------------------------------------------------- /src/app/components/molecules/Article.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image' 2 | import { ReactNode } from 'react' 3 | import nextConfig from '../../../../next.config.mjs' 4 | const BASE_PATH = nextConfig.basePath || '' 5 | 6 | interface ArticleProps { 7 | title: string 8 | content: ReactNode 9 | imageSrc: string 10 | imageAlt: string 11 | } 12 | 13 | const Article: React.FC = ({ 14 | title, 15 | content, 16 | imageSrc, 17 | imageAlt, 18 | }) => { 19 | return ( 20 |
21 |

{title}

22 |
23 |
24 | {content} 25 |
26 |
27 | {imageAlt} 34 |
35 |
36 |
37 | ) 38 | } 39 | 40 | export default Article 41 | -------------------------------------------------------------------------------- /src/app/components/molecules/BackgroundWrapper.tsx: -------------------------------------------------------------------------------- 1 | // import ParticleBackground from '@/app/components/atoms/ParticlesBackground' 2 | 'use client' 3 | 4 | import SketchCloud from '@/app/components/atoms/SketchCloud' 5 | import SketchNight from '@/app/components/atoms/SketchNight' 6 | import { usePathname } from 'next/navigation' 7 | import React from 'react' 8 | import nextConfig from '../../../../next.config.mjs' 9 | const BASE_PATH = nextConfig.basePath || '' 10 | 11 | const BackgroundWrapper: React.FC<{ children: React.ReactNode }> = ({ 12 | children, 13 | }) => { 14 | const pathname = usePathname() 15 | const isRootPath = pathname === `${BASE_PATH}/` || pathname === '/' 16 | 17 | return ( 18 |
19 | {isRootPath && ( 20 | <> 21 | {/* PC用の背景 */} 22 |
23 |
24 | 25 |
26 |
27 | 28 |
29 |
30 | 31 | {/* モバイル用の背景 */} 32 |
33 | {/* 昼モードの雲 */} 34 |
35 |
36 |
37 |
38 |
39 |
40 | 41 | {/* 夜モードの星 */} 42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 | 53 | )} 54 |
57 | {children} 58 |
59 |
60 | ) 61 | } 62 | 63 | export default BackgroundWrapper 64 | -------------------------------------------------------------------------------- /src/app/components/molecules/BlogCard.tsx: -------------------------------------------------------------------------------- 1 | import LoadingCircle from '@/app/components/atoms/LoadingCircle' 2 | import Tags from '@/app/components/molecules/Tags' 3 | import styles from '@/app/components/templates/ArticleContent.module.css' 4 | import { useLikeCount } from '@/app/hooks/useLikeCount' 5 | import { Heart } from 'lucide-react' 6 | import { useRouter } from 'next/navigation' 7 | import React, { useState } from 'react' 8 | 9 | interface BlogPostProps { 10 | post: any 11 | } 12 | 13 | const BlogPost: React.FC = ({ post }) => { 14 | const [isLoading, setIsLoading] = useState(false) 15 | const router = useRouter() 16 | const likeCount = useLikeCount(post.slug) 17 | 18 | const handleClick = (e: React.MouseEvent) => { 19 | e.preventDefault() 20 | setIsLoading(true) 21 | router.push(`/blog/${post.slug}`) 22 | } 23 | 24 | return ( 25 | <> 26 | {isLoading && } 27 | 46 | 47 | ) 48 | } 49 | 50 | export default BlogPost 51 | -------------------------------------------------------------------------------- /src/app/components/molecules/ChartHeader.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useI18n } from '../../../i18n/context' 3 | 4 | interface ChartHeaderProps { 5 | years: number[] 6 | totalYears: number 7 | } 8 | 9 | const ChartHeader: React.FC = ({ years, totalYears }) => { 10 | const { t } = useI18n() 11 | 12 | return ( 13 |
14 |
15 |
16 |

{t.skills.timeline.experiencePeriod}

17 |
18 |
19 | {years.map((year, index) => { 20 | const left = ((year - 2019) / totalYears) * 100 21 | return ( 22 |

27 | {year}{t.skills.timeline.year} 28 |

29 | ) 30 | })} 31 |
32 |
33 | ) 34 | } 35 | 36 | export default ChartHeader 37 | -------------------------------------------------------------------------------- /src/app/components/molecules/ChartRow.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useI18n } from '../../../i18n/context' 3 | 4 | interface Period { 5 | start: number 6 | end: number 7 | } 8 | 9 | interface Skill { 10 | name: string 11 | periods: Period[] 12 | total: number 13 | } 14 | 15 | interface ChartRowProps { 16 | skill: Skill 17 | totalYears: number 18 | } 19 | 20 | const ChartRow: React.FC = ({ skill, totalYears }) => { 21 | const { t } = useI18n() 22 | 23 | return ( 24 |
25 |
26 |
{skill.name}
27 |
28 | {skill.total.toFixed(1)}{t.skills.timeline.year} 29 | {skill.periods.map((period, i) => { 30 | const startOffset = ((period.start - 2019) / totalYears) * 100 31 | const width = ((period.end - period.start + 0.1) / totalYears) * 100 32 | return ( 33 |
41 | ) 42 | })} 43 |
44 |
45 |
46 | ) 47 | } 48 | 49 | export default ChartRow 50 | -------------------------------------------------------------------------------- /src/app/components/molecules/CodeBlock.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useState } from 'react' 4 | import { BiCheck, BiCopy } from 'react-icons/bi' 5 | import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter' 6 | import { oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism' 7 | 8 | type Props = { 9 | className?: string 10 | children?: React.ReactNode 11 | fileName?: string 12 | } 13 | 14 | const CodeBlock: React.FC = ({ 15 | className, 16 | children = '', 17 | fileName, 18 | }: Props) => { 19 | // コピー状態を管理するためのフック 20 | const [isCopied, setIsCopied] = useState(false) 21 | 22 | // クラス名から言語を抽出 23 | const match = className ? /language-(\w+)/.exec(className) : null 24 | const language = match ? match[1] : '' 25 | const code = String(children).replace(/\n$/, '') 26 | const syntaxHighlighterClass = fileName 27 | ? 'code-block-with-title' 28 | : 'code-block' 29 | 30 | // コードをクリップボードにコピーする関数 31 | const handleCopy = () => { 32 | navigator.clipboard.writeText(code).then(() => { 33 | setIsCopied(true) 34 | setTimeout(() => setIsCopied(false), 2000) 35 | }) 36 | } 37 | 38 | return ( 39 | <> 40 |
41 | {fileName &&
{fileName}
} 42 |
43 | 48 | {code} 49 | 50 | 53 | {isCopied &&
コピーしました
} 54 |
55 |
56 | 103 | 114 | 115 | ) 116 | } 117 | 118 | export default CodeBlock 119 | -------------------------------------------------------------------------------- /src/app/components/molecules/EmbedArticle.module.css: -------------------------------------------------------------------------------- 1 | .embedArticle_container { 2 | border: 0.5px solid rgba(92, 147, 187, 0.5); 3 | border-radius: 0.5rem; 4 | overflow: hidden; 5 | margin: 1em 0; 6 | position: relative; 7 | } 8 | 9 | .embedArticleCard_link { 10 | display: flex; 11 | aling-items: center; 12 | font-size: 1em; 13 | height: 140px; 14 | line-height: 1.5; 15 | transition-behavior: normal; 16 | transition-duration: 0.2s; 17 | transition-timing-function: ease; 18 | transition-delay: 0s; 19 | transition-property: all; 20 | color: rgba(0, 0, 0, 0.82); 21 | text-decoration: none; 22 | background: #fff; 23 | word-break: break-all; 24 | } 25 | 26 | .embedArticleCard_link:hover { 27 | background: rgba(246, 246, 246, 0.8); 28 | } 29 | 30 | .embedArticle_main { 31 | flex: 1 1; 32 | padding: 0.6em 1.2em; 33 | min-width: 0; 34 | } 35 | 36 | .embedArticle_title, 37 | .embedArticle_description { 38 | overflow: hidden; 39 | display: -webkit-box; 40 | -webkit-box-orient: vertical; 41 | } 42 | 43 | .embedArticle_title { 44 | margin: 0; 45 | font-size: 1em; 46 | -webkit-line-clamp: 2; 47 | max-height: 3em; 48 | user-select: none; 49 | word-break: break-word; 50 | margin-block-start: 0.67em; 51 | margin-block-end: 0.67em; 52 | margin-line-start: 0; 53 | margin-line-end: 0; 54 | font-weight: bold; 55 | unicode-bidi: isolate; 56 | } 57 | 58 | .embedArticle_description { 59 | margin-top: 0.5em; 60 | color: #77838c; 61 | font-size: 0.8em; 62 | -webkit-line-clamp: 1; 63 | max-height: 1.55em; 64 | } 65 | 66 | .embedArticle_meta { 67 | margin-top: 0.5em; 68 | font-size: 0.8em; 69 | display: flex; 70 | align-items: center; 71 | text-overflow: ellipsis; 72 | overflow: hidden; 73 | white-space: nowrap; 74 | position: absolute; /* 固定 */ 75 | bottom: 0; /* 親要素の下に固定 */ 76 | left: 0; 77 | right: 0; 78 | padding: 1.2em; 79 | } 80 | 81 | .embedArticle_favicon { 82 | margin-right: 6px; 83 | flex-shrink: 0; 84 | } 85 | 86 | .embedArticle_img img { 87 | width: 100%; 88 | height: 100%; 89 | object-fit: cover; 90 | } 91 | -------------------------------------------------------------------------------- /src/app/components/molecules/EmbedArticle.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import OpenGraphFetcher from '@/app/components/atoms/OpenGraphFetcher' 4 | import Image from 'next/image' 5 | import React, { useState } from 'react' 6 | 7 | import styles from './EmbedArticle.module.css' 8 | 9 | interface EmbedArticleProps { 10 | url: string 11 | } 12 | 13 | interface OpenGraphData { 14 | ogTitle?: string 15 | ogDescription?: string 16 | ogImage?: { url: string } 17 | ogUrl?: string 18 | requestUrl?: string 19 | favicon?: string 20 | } 21 | 22 | const EmbedArticle: React.FC = ({ url }) => { 23 | const [ogData, setOgData] = useState(null) 24 | 25 | const srcUrl = ogData?.ogUrl || ogData?.requestUrl 26 | const faviconUrl = ogData?.favicon && srcUrl 27 | ? (() => { 28 | try { 29 | return new URL(ogData.favicon, srcUrl).toString() 30 | } catch (error) { 31 | // Base URLが無効な場合、faviconが絶対URLかチェック 32 | try { 33 | return new URL(ogData.favicon).toString() 34 | } catch { 35 | // 両方とも失敗した場合はnullを返す 36 | return null 37 | } 38 | } 39 | })() 40 | : null 41 | const ogImageUrl = Array.isArray(ogData?.ogImage) 42 | ? ogData?.ogImage[0].url 43 | : ogData?.ogImage?.url 44 | 45 | return ( 46 | <> 47 | 48 | 88 | 89 | ) 90 | } 91 | 92 | export default EmbedArticle 93 | -------------------------------------------------------------------------------- /src/app/components/molecules/GithubLinkButton.tsx: -------------------------------------------------------------------------------- 1 | import { faGithub } from '@fortawesome/free-brands-svg-icons' 2 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 3 | import Link from 'next/link' 4 | 5 | const GithubLinkButton = () => { 6 | return ( 7 | 13 |
14 | 18 | 19 | Repository 20 | 21 |
22 | 23 | ) 24 | } 25 | 26 | export default GithubLinkButton 27 | -------------------------------------------------------------------------------- /src/app/components/molecules/HeaderLinkButton.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link' 2 | import React from 'react' 3 | 4 | interface HeaderLinkButtonProps { 5 | href: string 6 | text: string 7 | index: number 8 | } 9 | 10 | const HeaderLinkButton: React.FC = ({ 11 | href, 12 | text, 13 | index, 14 | }) => { 15 | return ( 16 | 21 | 22 | {text} 23 | 24 | 25 | ) 26 | } 27 | 28 | export default HeaderLinkButton 29 | -------------------------------------------------------------------------------- /src/app/components/molecules/InputLabel.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Chip from '../atoms/Chip' 3 | 4 | interface InputLabelProps { 5 | label: string 6 | id: string 7 | required?: boolean 8 | } 9 | 10 | const InputLabel: React.FC = ({ 11 | label, 12 | id, 13 | required = false, 14 | }) => { 15 | return ( 16 | 26 | ) 27 | } 28 | 29 | export default InputLabel 30 | -------------------------------------------------------------------------------- /src/app/components/molecules/InputLongText.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import InputLabel from './InputLabel' 3 | 4 | interface InputLongTextProps { 5 | label: string 6 | name: string 7 | id: string 8 | required?: boolean 9 | rows?: number 10 | } 11 | 12 | const InputLongText: React.FC = ({ 13 | label, 14 | name, 15 | id, 16 | required = false, 17 | rows = 3, 18 | }) => { 19 | return ( 20 |
21 | 22 | 33 |
34 | ) 35 | } 36 | 37 | export default InputLongText 38 | -------------------------------------------------------------------------------- /src/app/components/molecules/InputText.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import InputLabel from './InputLabel' 3 | 4 | interface InputTextProps { 5 | label: string 6 | name: string 7 | id: string 8 | required?: boolean 9 | } 10 | 11 | const InputText: React.FC = ({ 12 | label, 13 | name, 14 | id, 15 | required = false, 16 | }) => { 17 | return ( 18 |
19 | 20 | 27 |
28 | ) 29 | } 30 | 31 | export default InputText 32 | -------------------------------------------------------------------------------- /src/app/components/molecules/LanguageSwitcher.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useI18n } from '@/i18n'; 4 | import { Locale, locales, localeNames } from '@/i18n'; 5 | import { useState } from 'react'; 6 | import { FaGlobe } from 'react-icons/fa'; 7 | 8 | export default function LanguageSwitcher() { 9 | const { locale, setLocale, t } = useI18n(); 10 | const [isOpen, setIsOpen] = useState(false); 11 | 12 | const handleLanguageChange = (newLocale: Locale) => { 13 | setLocale(newLocale); 14 | setIsOpen(false); 15 | }; 16 | 17 | return ( 18 |
19 | 41 | 42 | {isOpen && ( 43 |
44 |
45 | {locales.map((lang) => ( 46 | 57 | ))} 58 |
59 |
60 | )} 61 | 62 | {/* クリック外でメニューを閉じるためのオーバーレイ */} 63 | {isOpen && ( 64 |
setIsOpen(false)} 67 | aria-hidden="true" 68 | /> 69 | )} 70 |
71 | ); 72 | } -------------------------------------------------------------------------------- /src/app/components/molecules/LikeButton.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Heart } from 'lucide-react' 4 | import { useEffect, useState } from 'react' 5 | 6 | interface LikeButtonProps { 7 | articleId: string 8 | initialLiked?: boolean 9 | } 10 | 11 | const LikeButton: React.FC = ({ 12 | articleId, 13 | initialLiked = false, 14 | }) => { 15 | const [isLiked, setIsLiked] = useState(initialLiked) 16 | const [likeCount, setLikeCount] = useState(0) 17 | const [isLoading, setIsLoading] = useState(false) 18 | const [isAnimating, setIsAnimating] = useState(false) 19 | 20 | useEffect(() => { 21 | const fetchLikeInfo = async () => { 22 | try { 23 | const userLikes = JSON.parse( 24 | localStorage.getItem('article_likes') || '{}', 25 | ) 26 | const hasLiked = !!userLikes[articleId] 27 | setIsLiked(hasLiked) 28 | 29 | // APIからいいね数を取得 30 | const response = await fetch( 31 | `${process.env.NEXT_PUBLIC_API_URL}/likes?articleId=${articleId}`, 32 | ) 33 | 34 | if (response.ok) { 35 | const data = await response.json() 36 | setLikeCount(data.likeCount) 37 | } 38 | } catch (error) { 39 | console.error(error) 40 | } 41 | } 42 | fetchLikeInfo() 43 | }, [articleId]) 44 | 45 | const handleToggleLike = async () => { 46 | if (isLoading) return 47 | 48 | try { 49 | setIsLoading(true) 50 | setIsAnimating(true) 51 | const newLikedState = !isLiked 52 | 53 | // ローカルストレージにいいね状態を保存 54 | const userLikes = JSON.parse( 55 | localStorage.getItem('article_likes') || '{}', 56 | ) 57 | if (newLikedState) { 58 | userLikes[articleId] = true 59 | } else { 60 | delete userLikes[articleId] 61 | } 62 | 63 | localStorage.setItem('article_likes', JSON.stringify(userLikes)) 64 | 65 | const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/likes`, { 66 | method: 'POST', 67 | headers: { 'Content-Type': 'application/json' }, 68 | body: JSON.stringify({ articleId, liked: newLikedState }), 69 | }) 70 | 71 | if (response.ok) { 72 | const data = await response.json() 73 | setLikeCount(data.likeCount) 74 | setIsLiked(newLikedState) 75 | } else { 76 | throw new Error('APIリクエストが失敗しました') 77 | } 78 | } catch (error) { 79 | console.error(error) 80 | 81 | // 失敗時にローカルストレージを元に戻す 82 | const userLikes = JSON.parse( 83 | localStorage.getItem('article_likes') || '{}', 84 | ) 85 | if (isLiked) { 86 | userLikes[articleId] = true 87 | } else { 88 | delete userLikes[articleId] 89 | } 90 | localStorage.setItem('article_likes', JSON.stringify(userLikes)) 91 | } finally { 92 | setIsLoading(false) 93 | // アニメーション終了後に状態をリセット 94 | setTimeout(() => { 95 | setIsAnimating(false) 96 | }, 300) 97 | } 98 | } 99 | 100 | return ( 101 | 132 | ) 133 | } 134 | 135 | export default LikeButton 136 | -------------------------------------------------------------------------------- /src/app/components/molecules/ProfileCard.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import Image from 'next/image' 4 | import Link from 'next/link' 5 | import { FaGithub } from 'react-icons/fa' 6 | import { FaXTwitter } from 'react-icons/fa6' 7 | import nextConfig from '../../../../next.config.mjs' 8 | import { useI18n } from '../../../i18n/context' 9 | 10 | const BASE_PATH = nextConfig.basePath || '' 11 | 12 | const ProfileCard = () => { 13 | const { t } = useI18n() 14 | 15 | return ( 16 |
17 |
18 | Profile 26 |
27 |

28 | motoshifurugen 29 |

30 |
31 | 36 |
37 | 38 |
39 | 40 | 45 |
46 | 47 |
48 | 49 |
50 |
51 |
52 |

53 | {t.profileCard.description} 54 |

55 |
56 | 57 |
58 | {t.profileCard.viewProfile} 59 |
60 | 61 |
62 |
63 | ) 64 | } 65 | 66 | export default ProfileCard 67 | -------------------------------------------------------------------------------- /src/app/components/molecules/Tags.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useRouter } from 'next/navigation' 4 | import React from 'react' 5 | 6 | interface TagsProps { 7 | tags: string[] 8 | } 9 | 10 | const Tags: React.FC = ({ tags }) => { 11 | const router = useRouter() 12 | 13 | const handleClickTag = (tag: string) => { 14 | router.push(`/blog?tag=${encodeURIComponent(tag)}`) 15 | } 16 | 17 | return ( 18 |
19 | {tags.map((tag: string, index: number) => ( 20 | handleClickTag(tag)} 28 | > 29 | {tag} 30 | 31 | ))} 32 |
33 | ) 34 | } 35 | 36 | export default Tags 37 | -------------------------------------------------------------------------------- /src/app/components/molecules/TextArrowLinkButton.tsx: -------------------------------------------------------------------------------- 1 | // ProfileLink.tsx 2 | import { faArrowRight } from '@fortawesome/free-solid-svg-icons' 3 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 4 | import React from 'react' 5 | import nextConfig from '../../../../next.config.mjs' 6 | 7 | const BASE_PATH = nextConfig.basePath || '' 8 | 9 | interface TextArrowLinkButtonProps { 10 | text: string 11 | href: string 12 | } 13 | 14 | const TextArrowLinkButton: React.FC = ({ 15 | text, 16 | href, 17 | }) => { 18 | return ( 19 | <> 20 | 24 |

{text}

25 | 26 | {/* テキストの右につける矢印 */} 27 | 42 |
43 | 44 | ) 45 | } 46 | 47 | export default TextArrowLinkButton 48 | -------------------------------------------------------------------------------- /src/app/components/molecules/Toc.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import React, { useEffect } from 'react' 4 | import { useInView } from 'react-intersection-observer' 5 | import tocbot from 'tocbot' 6 | 7 | const Toc: React.FC = () => { 8 | const { ref, inView } = useInView({ 9 | threshold: 0, 10 | triggerOnce: false, 11 | }) 12 | useEffect(() => { 13 | tocbot.init({ 14 | tocSelector: `.toc`, 15 | contentSelector: '.target-toc', 16 | headingSelector: 'h2, h3, h4', 17 | headingsOffset: 100, // ヘッダーの高さに応じて調整 18 | scrollSmoothOffset: -100, 19 | }) 20 | 21 | // 不要となった tocbot インスタンスを削除 22 | return () => tocbot.destroy() 23 | }, []) 24 | 25 | return ( 26 | <> 27 |
{/* スクロール監視用 */} 28 |
33 | 34 | 目次 35 | 36 |
38 | 39 | ) 40 | } 41 | 42 | export default Toc 43 | -------------------------------------------------------------------------------- /src/app/components/molecules/WorkCard.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image' 2 | import { ReactElement } from 'react' 3 | import nextConfig from '../../../../next.config.mjs' 4 | import Chip from '../atoms/Chip' 5 | const BASE_PATH = nextConfig.basePath || '' 6 | 7 | interface WorkCardProps { 8 | src: string 9 | alt: string 10 | title: string | ReactElement 11 | description: string 12 | tags: string[] 13 | date: string 14 | } 15 | 16 | const WorkCard: React.FC = ({ 17 | src, 18 | alt, 19 | title, 20 | description, 21 | tags, 22 | date, 23 | }) => { 24 | return ( 25 |
26 | {alt} 33 |
34 |
35 | {title} 36 |
37 |

{description}

38 |
39 |
40 | {tags.map((tag, index) => ( 41 | 42 | {tag} 43 | 44 | ))} 45 |
46 |
47 | {date} 48 |
49 |
50 | ) 51 | } 52 | 53 | export default WorkCard 54 | -------------------------------------------------------------------------------- /src/app/components/organisms/BlogGrid.tsx: -------------------------------------------------------------------------------- 1 | import BlogCard from '@/app/components/molecules/BlogCard' 2 | import { useEffect, useState } from 'react' 3 | import { useI18n } from '@/i18n' 4 | 5 | interface BlogGridProps { 6 | blogData: any 7 | } 8 | 9 | const BlogGrid: React.FC = ({ blogData }) => { 10 | const { t } = useI18n() 11 | const [loadIndex, setLoadIndex] = useState(10) 12 | const [isEmpty, setIsEmpty] = useState(false) 13 | const [currentPost, setCurrentPost] = useState([]) 14 | 15 | useEffect(() => { 16 | if (blogData.length <= 10) { 17 | setIsEmpty(true) 18 | } 19 | }, [blogData]) 20 | 21 | const displayMore = () => { 22 | const newLoadIndex = loadIndex + 10 23 | setLoadIndex(newLoadIndex) 24 | if (newLoadIndex >= blogData.length) { 25 | setIsEmpty(true) 26 | } 27 | } 28 | 29 | useEffect(() => { 30 | // 日付の新しい順にソート 31 | const sortedData = [...blogData].sort((a, b) => { 32 | const dateA = new Date(a.date).getTime() 33 | const dateB = new Date(b.date).getTime() 34 | return dateB - dateA 35 | }) 36 | 37 | // 現在の表示件数分のデータを取得 38 | const currentData = sortedData.slice(0, loadIndex) 39 | setCurrentPost(currentData) 40 | }, [blogData, loadIndex]) 41 | 42 | return ( 43 | <> 44 |
45 | {currentPost.map((post: any) => ( 46 |
47 | 48 |
49 | ))} 50 |
51 |
52 | {!isEmpty && ( 53 | 59 | )} 60 |
61 | 62 | ) 63 | } 64 | 65 | export default BlogGrid 66 | -------------------------------------------------------------------------------- /src/app/components/organisms/Form.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import SubmitButton from '../atoms/SubmitButton' 3 | import InputLongText from '../molecules/InputLongText' 4 | import InputText from '../molecules/InputText' 5 | import { useI18n } from '../../../i18n/context' 6 | 7 | const Form: React.FC = () => { 8 | const { t } = useI18n() 9 | 10 | return ( 11 | 16 |
17 | 18 |
19 | 20 | 26 | 32 |
33 | {t.contact.form.submit} 34 |
35 | 36 | ) 37 | } 38 | 39 | export default Form 40 | -------------------------------------------------------------------------------- /src/app/components/organisms/MessageBoard.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { announcementsData } from './MessageData'; 4 | import { useI18n } from '@/i18n'; 5 | 6 | const MessageBoard = () => { 7 | const { t } = useI18n(); 8 | 9 | // if (announcementsData.length > 5) { 10 | // throw new Error('お知らせは5件までです。') 11 | // } 12 | 13 | return ( 14 |
15 |

{t.announcements.title}

16 |
    17 | {announcementsData.map((announcement, index) => ( 18 |
  • 22 |
    23 |

    {announcement.date}

    24 |

    25 | 26 | {t.announcements.categories[announcement.categoryKey]} 27 | 28 |

    29 |

    30 | {(t.announcements.items as any)[announcement.titleKey]?.title} 31 | {announcement.link && ( 32 | <> 33 | {' '} 34 | 38 | {(t.announcements.items as any)[announcement.link.textKey]?.linkText} 39 | 40 | 41 | )} 42 |

    43 |
    44 |
  • 45 | ))} 46 |
47 |
48 | ) 49 | } 50 | 51 | export default MessageBoard 52 | -------------------------------------------------------------------------------- /src/app/components/organisms/MessageData.ts: -------------------------------------------------------------------------------- 1 | import { Locale } from '@/i18n/types'; 2 | 3 | export interface AnnouncementLink { 4 | url: string; 5 | textKey: string; 6 | } 7 | 8 | export interface AnnouncementData { 9 | date: string; 10 | categoryKey: 'blogUpdate' | 'notification'; 11 | titleKey: string; 12 | link?: AnnouncementLink; 13 | } 14 | 15 | export const announcementsData: AnnouncementData[] = [ 16 | { 17 | date: '2025/08/20', 18 | categoryKey: 'notification', 19 | titleKey: '2025-08-20', 20 | }, 21 | { 22 | date: '2025/04/22', 23 | categoryKey: 'blogUpdate', 24 | titleKey: '2025-04-22', 25 | link: { 26 | url: 'https://furugen-island.com/my_site/blog/create_my_site_4', 27 | textKey: '2025-04-22', 28 | }, 29 | }, 30 | { 31 | date: '2025/04/21', 32 | categoryKey: 'blogUpdate', 33 | titleKey: '2025-04-21', 34 | }, 35 | { 36 | date: '2025/04/01', 37 | categoryKey: 'notification', 38 | titleKey: '2025-04-01', 39 | link: { 40 | url: 'https://furugen-island.com/my_site/game', 41 | textKey: '2025-04-01', 42 | }, 43 | }, 44 | { 45 | date: '2025/03/08', 46 | categoryKey: 'blogUpdate', 47 | titleKey: '2025-03-08-blog', 48 | link: { 49 | url: 'https://furugen-island.com/my_site/blog/async_await_with_forEach', 50 | textKey: '2025-03-08-blog', 51 | }, 52 | }, 53 | // { 54 | // date: '2025/03/08', 55 | // categoryKey: 'notification', 56 | // titleKey: '2025-03-08-like', 57 | // link: { 58 | // url: 'https://furugen-island.com/my_site/blog', 59 | // textKey: '2025-03-08-like', 60 | // }, 61 | // }, 62 | // { 63 | // date: '2025/02/02', 64 | // category: 'お知らせ', 65 | // title: 'ダークモードを実装しました。', 66 | // }, 67 | // { 68 | // date: '2025/01/13', 69 | // category: 'ブログ更新', 70 | // title: '記事を追加しました。', 71 | // link: { 72 | // url: 'https://furugen-island.com/my_site/blog/create_my_site_2', 73 | // text: '『Reactでポートフォリオサイトを作成する 🚀(2)』', 74 | // }, 75 | // }, 76 | // { 77 | // date: '2025/01/01', 78 | // category: 'ブログ更新', 79 | // title: 'あけましておめでとうございます。記事を追加しました。', 80 | // link: { 81 | // url: 'https://furugen-island.com/my_site/blog/goodbye_2024_welcome_2025', 82 | // text: '『個人的な2024年の振り返りと2025年の抱負』', 83 | // }, 84 | // }, 85 | // { 86 | // date: '2024/11/11', 87 | // category: 'お知らせ', 88 | // title: '当サイトをリリースしました!', 89 | // }, 90 | ] 91 | -------------------------------------------------------------------------------- /src/app/components/organisms/PageFace.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | interface PageFaceProps { 4 | title: string 5 | subtitle: string 6 | mainMessage: React.ReactNode 7 | } 8 | 9 | const PageFace: React.FC = ({ 10 | title, 11 | subtitle, 12 | mainMessage, 13 | }) => { 14 | return ( 15 | <> 16 |
17 |
18 |

{title}

19 |

{subtitle}

20 |
21 |
22 | {mainMessage} 23 |
24 |
25 | 26 | ) 27 | } 28 | 29 | export default PageFace 30 | -------------------------------------------------------------------------------- /src/app/components/organisms/SearchBar.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image' 2 | 3 | const SearchBar: React.FC = () => { 4 | return ( 5 |
6 | 11 | 21 |
22 | ) 23 | } 24 | 25 | export default SearchBar 26 | -------------------------------------------------------------------------------- /src/app/components/organisms/SkillTimeline.tsx: -------------------------------------------------------------------------------- 1 | import { useSkills } from '../../(pages)/skills/skills' 2 | import ChartHeader from '../molecules/ChartHeader' 3 | import ChartRow from '../molecules/ChartRow' 4 | 5 | const SkillTimeline: React.FC = () => { 6 | const skills = useSkills() 7 | const max = 2024.9 8 | const totalYears = max - 2019 + 1 // グラフの長さ 9 | const years = Array.from( 10 | { length: Math.ceil(totalYears) }, 11 | (_, i) => 2019 + i, 12 | ) 13 | 14 | return ( 15 |
16 | 17 | {skills.map((skill, index) => ( 18 | 19 | ))} 20 |
21 | ) 22 | } 23 | 24 | export default SkillTimeline 25 | -------------------------------------------------------------------------------- /src/app/components/organisms/ThemeSwitch.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useEffect, useState } from 'react' 4 | import { FiMoon, FiSun } from 'react-icons/fi' 5 | 6 | const ThemeSwitch = () => { 7 | const [theme, setTheme] = useState('light') 8 | 9 | useEffect(() => { 10 | if ( 11 | localStorage.theme === 'dark' || 12 | (!('theme' in localStorage) && 13 | window.matchMedia('(prefers-color-scheme: dark)').matches) 14 | ) { 15 | document.documentElement.classList.add('dark') 16 | setTheme('dark') 17 | } else { 18 | document.documentElement.classList.remove('dark') 19 | setTheme('light') 20 | } 21 | }, []) 22 | 23 | const toggleTheme = () => { 24 | if (theme === 'light') { 25 | document.documentElement.classList.add('dark') 26 | localStorage.setItem('theme', 'dark') 27 | setTheme('dark') 28 | } else { 29 | document.documentElement.classList.remove('dark') 30 | localStorage.setItem('theme', 'light') 31 | setTheme('light') 32 | } 33 | } 34 | 35 | return ( 36 | 53 | ) 54 | } 55 | 56 | export default ThemeSwitch 57 | -------------------------------------------------------------------------------- /src/app/components/organisms/WorkList.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useWorks } from '../../(pages)/skills/works' 3 | import WorkCard from '../molecules/WorkCard' 4 | 5 | const WorkList: React.FC = () => { 6 | const works = useWorks() 7 | 8 | return ( 9 |
10 | {works.map((work, index) => ( 11 | 20 | ))} 21 |
22 | ) 23 | } 24 | 25 | export default WorkList 26 | -------------------------------------------------------------------------------- /src/app/components/templates/ArticleContent.module.css: -------------------------------------------------------------------------------- 1 | /* styles/ArticleContent.css */ 2 | 3 | .articleContent { 4 | margin-bottom: 2rem; 5 | font-family: "Noto Sans JP", sans-serif; 6 | font-optical-sizing: auto; 7 | white-space: pre-wrap; 8 | word-wrap: break-word; 9 | @media (min-width: 1024px) { 10 | width: calc(100% - 352px); 11 | } 12 | } 13 | 14 | /* ------------------------- */ 15 | /* 見出し */ 16 | /* ------------------------- */ 17 | .articleContent h2 { 18 | border-bottom: 2px solid teal; 19 | margin-bottom: 1.1rem !important; 20 | margin-top: 2.3em !important; 21 | padding-bottom: 0.3em !important; 22 | white-space: pre-wrap; 23 | word-wrap: break-word; 24 | } 25 | 26 | :global(.dark) .articleContent h2 { 27 | border-bottom: 2px solid #80CBC4; 28 | } 29 | 30 | .articleContent h3 { 31 | font-weight: bold; 32 | } 33 | 34 | /* ------------------------- */ 35 | /* 文字関係 */ 36 | /* ------------------------- */ 37 | .articleContent p { 38 | margin: 16px 0; 39 | font-size: 16px; 40 | line-height: 1.9; 41 | white-space: pre-wrap; 42 | word-wrap: break-word; 43 | } 44 | 45 | .truncate2Lines { 46 | display: -webkit-box; 47 | -webkit-line-clamp: 2; 48 | -webkit-box-orient: vertical; 49 | overflow: hidden; 50 | text-overflow: ellipsis; 51 | white-space: normal; 52 | } 53 | 54 | /* ------------------------- */ 55 | /* リスト */ 56 | /* ------------------------- */ 57 | .articleContent ol, 58 | .articleContent ul { 59 | list-style: initial; 60 | padding-left: 1rem; 61 | white-space: normal; 62 | } 63 | 64 | .articleContent ol { 65 | list-style-type: decimal; 66 | margin-left: 20px; 67 | } 68 | 69 | .articleContent ul { 70 | list-style-type: disc; 71 | margin-left: 20px; 72 | } 73 | 74 | /* ------------------------- */ 75 | /* 引用 */ 76 | /* ------------------------- */ 77 | .articleContent blockquote { 78 | border-left: 4px solid #ccc; 79 | padding-left: 1rem; 80 | margin: 0.5rem 0; 81 | color: #555; 82 | white-space: pre-wrap; 83 | word-wrap: break-word; 84 | } 85 | 86 | :global(.dark) .articleContent blockquote { 87 | border-left: 4px solid #80CBC4; 88 | color: #e0e0e0; 89 | } 90 | 91 | .articleContent blockquote blockquote { 92 | border-left: 4px solid #aaa; 93 | padding-left: 16px; 94 | margin-left: 0; 95 | color: #333; 96 | } 97 | 98 | :global(.dark) .articleContent blockquote blockquote { 99 | border-left: 4px solid #80CBC4; 100 | color: #e0e0e0; 101 | } 102 | 103 | /* ------------------------- */ 104 | /* コードブロック */ 105 | /* ------------------------- */ 106 | .articleContent code { 107 | border-radius: 0.25rem; 108 | white-space: pre; 109 | word-wrap: break-word; 110 | } 111 | 112 | /* ------------------------- */ 113 | /* 表 */ 114 | /* ------------------------- */ 115 | .articleContent table { 116 | width: 100%; 117 | border-collapse: collapse; 118 | margin: 1rem 0; 119 | white-space: pre-wrap; 120 | word-wrap: break-word; 121 | } 122 | 123 | .articleContent th, 124 | .articleContent td { 125 | border: 1px solid #ddd; 126 | padding: 0.5rem; 127 | text-align: left; 128 | } 129 | 130 | .articleContent th { 131 | background-color: #f3f4f6; 132 | color: #4b5563; 133 | } 134 | 135 | .articleContent td { 136 | background-color: white; 137 | color: #4b5563; 138 | } 139 | -------------------------------------------------------------------------------- /src/app/components/templates/ArticleContent.tsx: -------------------------------------------------------------------------------- 1 | import { MDXRemote } from 'next-mdx-remote/rsc' 2 | import rehypeKatex from 'rehype-katex' 3 | import rehypePrism from 'rehype-prism' 4 | import rehypeSlug from 'rehype-slug' 5 | import remarkGfm from 'remark-gfm' 6 | import remarkMath from 'remark-math' 7 | 8 | import Highlight from '@/app/components/atoms/Highlight' 9 | import CodeBlock from '@/app/components/molecules/CodeBlock' 10 | import LikeButton from '@/app/components/molecules/LikeButton' 11 | import Tags from '@/app/components/molecules/Tags' 12 | import Sidebar from '@/app/components/templates/Sidebar' 13 | 14 | import EmbedArticle from '@/app/components/molecules/EmbedArticle' 15 | import 'prismjs/components/prism-python.js' 16 | import 'prismjs/themes/prism-tomorrow.css' 17 | import React, { ReactNode } from 'react' 18 | 19 | import styles from './ArticleContent.module.css' 20 | 21 | interface ArticleContentProps { 22 | blogArticle: any 23 | SidebarComponents: React.ReactNode[] 24 | } 25 | 26 | const codeBlockComponents = { 27 | code: ( 28 | props: JSX.IntrinsicAttributes & { 29 | className?: string 30 | children?: ReactNode 31 | }, 32 | ) => { 33 | // インラインコードの場合(preタグでラップされていない場合) 34 | if (!props.className) { 35 | return ( 36 | 37 | {String(props.children)} 38 | 39 | ) 40 | } 41 | 42 | // コードブロックの場合 43 | const content = String(props.children || '') 44 | const [lang, file] = (props.className || '') 45 | .replace('language-', '') 46 | .split(':') 47 | 48 | return ( 49 | 50 | {content} 51 | 52 | ) 53 | }, 54 | pre: (props: JSX.IntrinsicAttributes & { children?: ReactNode }) => { 55 | return
56 | }, 57 | p: (props: JSX.IntrinsicAttributes & { children?: ReactNode }) => ( 58 |
59 | ), 60 | a: ( 61 | props: JSX.IntrinsicAttributes & { href?: string; children?: ReactNode }, 62 | ) => { 63 | const { href, children } = props 64 | if (href && href.startsWith('http')) { 65 | return 66 | } 67 | return {children} 68 | }, 69 | } 70 | 71 | const ArticleContent: React.FC = ({ 72 | blogArticle, 73 | SidebarComponents, 74 | }) => { 75 | return ( 76 |
77 |
80 |
81 |

82 | {blogArticle.date} 83 |

84 | 85 |
86 |

87 | {blogArticle.title} 88 |

89 | {blogArticle.tags && } 90 | 96 | {/* 目次表示に必要 */} 97 |
98 | 108 |
109 |
110 |
111 | 112 |
113 |
114 | ) 115 | } 116 | 117 | export default ArticleContent 118 | -------------------------------------------------------------------------------- /src/app/components/templates/ClientWrapper.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import LoadingCircle from '@/app/components/atoms/LoadingCircle' 4 | import HtmlLangUpdater from '@/app/components/atoms/HtmlLangUpdater' 5 | import BackgroundWrapper from '@/app/components/molecules/BackgroundWrapper' 6 | import Footer from '@/app/components/templates/Footer' 7 | import Header from '@/app/components/templates/Header' 8 | import { I18nProvider } from '@/i18n' 9 | import React, { useEffect, useState } from 'react' 10 | 11 | const ClientWrapper: React.FC<{ children: React.ReactNode }> = ({ 12 | children, 13 | }) => { 14 | const [isLoading, setIsLoading] = useState(true) 15 | 16 | useEffect(() => { 17 | setTimeout(() => { 18 | setIsLoading(false) 19 | }, 2000) // 2秒後にローディングを終了 20 | }, []) 21 | 22 | return ( 23 | 24 | 25 | 26 | 27 |
28 |
{children}
29 | 30 |