| @@ -0,0 +1,24 @@ | |||
| # Logs | |||
| logs | |||
| *.log | |||
| npm-debug.log* | |||
| yarn-debug.log* | |||
| yarn-error.log* | |||
| pnpm-debug.log* | |||
| lerna-debug.log* | |||
| node_modules | |||
| dist | |||
| dist-ssr | |||
| *.local | |||
| # Editor directories and files | |||
| .vscode/* | |||
| !.vscode/extensions.json | |||
| .idea | |||
| .DS_Store | |||
| *.suo | |||
| *.ntvs* | |||
| *.njsproj | |||
| *.sln | |||
| *.sw? | |||
| @@ -0,0 +1,69 @@ | |||
| # React + TypeScript + Vite | |||
| This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. | |||
| Currently, two official plugins are available: | |||
| - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh | |||
| - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh | |||
| ## Expanding the ESLint configuration | |||
| If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: | |||
| ```js | |||
| export default tseslint.config([ | |||
| globalIgnores(['dist']), | |||
| { | |||
| files: ['**/*.{ts,tsx}'], | |||
| extends: [ | |||
| // Other configs... | |||
| // Remove tseslint.configs.recommended and replace with this | |||
| ...tseslint.configs.recommendedTypeChecked, | |||
| // Alternatively, use this for stricter rules | |||
| ...tseslint.configs.strictTypeChecked, | |||
| // Optionally, add this for stylistic rules | |||
| ...tseslint.configs.stylisticTypeChecked, | |||
| // Other configs... | |||
| ], | |||
| languageOptions: { | |||
| parserOptions: { | |||
| project: ['./tsconfig.node.json', './tsconfig.app.json'], | |||
| tsconfigRootDir: import.meta.dirname, | |||
| }, | |||
| // other options... | |||
| }, | |||
| }, | |||
| ]) | |||
| ``` | |||
| You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: | |||
| ```js | |||
| // eslint.config.js | |||
| import reactX from 'eslint-plugin-react-x' | |||
| import reactDom from 'eslint-plugin-react-dom' | |||
| export default tseslint.config([ | |||
| globalIgnores(['dist']), | |||
| { | |||
| files: ['**/*.{ts,tsx}'], | |||
| extends: [ | |||
| // Other configs... | |||
| // Enable lint rules for React | |||
| reactX.configs['recommended-typescript'], | |||
| // Enable lint rules for React DOM | |||
| reactDom.configs.recommended, | |||
| ], | |||
| languageOptions: { | |||
| parserOptions: { | |||
| project: ['./tsconfig.node.json', './tsconfig.app.json'], | |||
| tsconfigRootDir: import.meta.dirname, | |||
| }, | |||
| // other options... | |||
| }, | |||
| }, | |||
| ]) | |||
| ``` | |||
| @@ -0,0 +1,23 @@ | |||
| import js from '@eslint/js' | |||
| import globals from 'globals' | |||
| import reactHooks from 'eslint-plugin-react-hooks' | |||
| import reactRefresh from 'eslint-plugin-react-refresh' | |||
| import tseslint from 'typescript-eslint' | |||
| import { globalIgnores } from 'eslint/config' | |||
| export default tseslint.config([ | |||
| globalIgnores(['dist']), | |||
| { | |||
| files: ['**/*.{ts,tsx}'], | |||
| extends: [ | |||
| js.configs.recommended, | |||
| tseslint.configs.recommended, | |||
| reactHooks.configs['recommended-latest'], | |||
| reactRefresh.configs.vite, | |||
| ], | |||
| languageOptions: { | |||
| ecmaVersion: 2020, | |||
| globals: globals.browser, | |||
| }, | |||
| }, | |||
| ]) | |||
| @@ -0,0 +1,13 @@ | |||
| <!doctype html> | |||
| <html lang="en"> | |||
| <head> | |||
| <meta charset="UTF-8" /> | |||
| <link rel="icon" type="image/svg+xml" href="/vite.svg" /> | |||
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |||
| <title>キケッツチャンネル お絵描き掲示板</title> | |||
| </head> | |||
| <body> | |||
| <div id="root"></div> | |||
| <script type="module" src="/src/main.tsx"></script> | |||
| </body> | |||
| </html> | |||
| @@ -0,0 +1,38 @@ | |||
| { | |||
| "name": "frontend", | |||
| "private": true, | |||
| "version": "0.0.0", | |||
| "type": "module", | |||
| "scripts": { | |||
| "dev": "vite", | |||
| "build": "tsc -b && vite build", | |||
| "lint": "eslint .", | |||
| "preview": "vite preview" | |||
| }, | |||
| "dependencies": { | |||
| "axios": "^1.10.0", | |||
| "camelcase-keys": "^9.1.3", | |||
| "classnames": "^2.5.1", | |||
| "react": "^19.1.0", | |||
| "react-accessible-accordion": "^5.0.1", | |||
| "react-dom": "^19.1.0", | |||
| "react-icons": "^5.5.0", | |||
| "react-router-dom": "^7.7.0" | |||
| }, | |||
| "devDependencies": { | |||
| "@eslint/js": "^9.30.1", | |||
| "@types/react": "^19.1.8", | |||
| "@types/react-dom": "^19.1.6", | |||
| "@vitejs/plugin-react": "^4.6.0", | |||
| "autoprefixer": "^10.4.21", | |||
| "eslint": "^9.30.1", | |||
| "eslint-plugin-react-hooks": "^5.2.0", | |||
| "eslint-plugin-react-refresh": "^0.4.20", | |||
| "globals": "^16.3.0", | |||
| "postcss": "^8.5.6", | |||
| "tailwindcss": "^3.4.3", | |||
| "typescript": "~5.8.3", | |||
| "typescript-eslint": "^8.35.1", | |||
| "vite": "^7.0.4" | |||
| } | |||
| } | |||
| @@ -0,0 +1,6 @@ | |||
| export default { | |||
| plugins: { | |||
| tailwindcss: {}, | |||
| autoprefixer: {}, | |||
| }, | |||
| } | |||
| @@ -0,0 +1,42 @@ | |||
| #root { | |||
| max-width: 1280px; | |||
| margin: 0 auto; | |||
| padding: 2rem; | |||
| text-align: center; | |||
| } | |||
| .logo { | |||
| height: 6em; | |||
| padding: 1.5em; | |||
| will-change: filter; | |||
| transition: filter 300ms; | |||
| } | |||
| .logo:hover { | |||
| filter: drop-shadow(0 0 2em #646cffaa); | |||
| } | |||
| .logo.react:hover { | |||
| filter: drop-shadow(0 0 2em #61dafbaa); | |||
| } | |||
| @keyframes logo-spin { | |||
| from { | |||
| transform: rotate(0deg); | |||
| } | |||
| to { | |||
| transform: rotate(360deg); | |||
| } | |||
| } | |||
| @media (prefers-reduced-motion: no-preference) { | |||
| a:nth-of-type(2) .logo { | |||
| animation: logo-spin infinite 20s linear; | |||
| } | |||
| } | |||
| .card { | |||
| padding: 2em; | |||
| } | |||
| .read-the-docs { | |||
| color: #888; | |||
| } | |||
| @@ -0,0 +1,56 @@ | |||
| import { useEffect, useState } from 'react' | |||
| import { BrowserRouter, Link, Routes, Route, Navigate } from 'react-router-dom' | |||
| import bgmSrc from '@/assets/music.mp3' | |||
| import ThreadListPage from '@/pages/threads/ThreadListPage' | |||
| // import ThreadDetailPage from '@/pages/threads/ThreadDetailPage' | |||
| export default () => { | |||
| const [playing, setPlaying] = useState (false) | |||
| useEffect (() => { | |||
| const bgm = new Audio (bgmSrc) | |||
| bgm.loop = true | |||
| const playBGM = async () => { | |||
| if (!(playing)) | |||
| { | |||
| try | |||
| { | |||
| await bgm.play () | |||
| bgm.loop = true | |||
| setPlaying (true) | |||
| } | |||
| catch | |||
| { | |||
| setPlaying (false) | |||
| } | |||
| } | |||
| } | |||
| setInterval (playBGM, 1000) | |||
| document.addEventListener ('click', playBGM) | |||
| document.addEventListener ('touchstart', playBGM) | |||
| }, []) | |||
| return ( | |||
| <BrowserRouter> | |||
| <h1 className="text-center my-7"> | |||
| <Link to="/" | |||
| className="text-7xl text-transparent whitespace-nowrap | |||
| bg-[linear-gradient(90deg,#ff0000,#ff8800,#ffff00,#00ff00,#00ffff,#0000ff,#ff00ff,#ff0000)] | |||
| bg-clip-text [transform:skewX(-13.5deg)] | |||
| inline-block bg-[length:200%_100%] animate-rainbow-scroll drop-shadow-[0_0_6px_black] | |||
| font-serif"> | |||
| クソ掲示板 | |||
| </Link> | |||
| </h1> | |||
| <Routes> | |||
| <Route path="/" element={<Navigate to="/threads" replace />} /> | |||
| <Route path="/threads" element={<ThreadListPage />} /> | |||
| {/* <Route path="/threads/:id" element={<ThreadDetailPage />} /> */} | |||
| </Routes> | |||
| </BrowserRouter>) | |||
| } | |||
| @@ -0,0 +1,51 @@ | |||
| import axios from 'axios' | |||
| import { useState } from 'react' | |||
| import { API_BASE_URL } from '@/config' | |||
| export default () => { | |||
| const [threadName, setThreadName] = useState ('') | |||
| const [threadDescription, setThreadDescription] = useState ('') | |||
| const submit = async () => { | |||
| const formData = new FormData | |||
| formData.append ('title', threadName) | |||
| formData.append ('description', threadDescription) | |||
| try | |||
| { | |||
| await axios.post (`${ API_BASE_URL }/threads`, formData) | |||
| } | |||
| catch | |||
| { | |||
| ; | |||
| } | |||
| } | |||
| return ( | |||
| <form className="mb-2"> | |||
| {/* スレッド名 */} | |||
| <div> | |||
| <label>スレッド名:</label> | |||
| <input type="text" | |||
| className="border border-black" | |||
| value={threadName} | |||
| onChange={ev => setThreadName (ev.target.value)} /> | |||
| </div> | |||
| {/* スレッド説明 */} | |||
| <div> | |||
| <label>スレッド説明:</label> | |||
| <textarea className="border border-black" | |||
| value={threadDescription} | |||
| onChange={ev => setThreadDescription (ev.target.value)} /> | |||
| </div> | |||
| {/* 作成 */} | |||
| <button type="button" | |||
| onClick={submit}> | |||
| スレッド作成 | |||
| </button> | |||
| </form>) | |||
| } | |||
| @@ -0,0 +1,2 @@ | |||
| export const API_BASE_URL = 'http://localhost:3003' | |||
| export const SITE_NAME = 'キケッツチャンネル お絵描き掲示板' | |||
| @@ -0,0 +1,3 @@ | |||
| @tailwind base; | |||
| @tailwind components; | |||
| @tailwind utilities; | |||
| @@ -0,0 +1,10 @@ | |||
| import { StrictMode } from 'react' | |||
| import { createRoot } from 'react-dom/client' | |||
| import './index.css' | |||
| import App from './App.tsx' | |||
| createRoot(document.getElementById('root')!).render( | |||
| <StrictMode> | |||
| <App /> | |||
| </StrictMode>, | |||
| ) | |||
| @@ -0,0 +1,79 @@ | |||
| import axios from 'axios' | |||
| import toCamel from 'camelcase-keys' | |||
| import cn from 'classnames' | |||
| import { useEffect, useState } from 'react' | |||
| import { Accordion, | |||
| AccordionItem, | |||
| AccordionItemButton, | |||
| AccordionItemHeading, | |||
| AccordionItemPanel } from 'react-accessible-accordion' | |||
| import { FaChevronDown, FaChevronUp } from 'react-icons/fa' | |||
| import ThreadNewForm from '@/components/threads/ThreadNewForm' | |||
| import { API_BASE_URL } from '@/config' | |||
| export default () => { | |||
| const [loading, setLoading] = useState (true) | |||
| const [threads, setThreads] = useState<Thread[]> ([]) | |||
| const [formOpen, setFormOpen] = useState (false) | |||
| useEffect (() => { | |||
| const fetchThreads = async () => { | |||
| try | |||
| { | |||
| const res = await axios.get (`${ API_BASE_URL }/threads`) | |||
| const data = toCamel (res.data as any, { deep: true }) as { threads: Thread[] } | |||
| setThreads (data.threads) | |||
| } | |||
| catch | |||
| { | |||
| ; | |||
| } | |||
| } | |||
| setLoading (true) | |||
| fetchThreads () | |||
| setLoading (false) | |||
| }, []) | |||
| return ( | |||
| <> | |||
| <Accordion allowZeroExpanded | |||
| onClick={() => setFormOpen (!formOpen)} | |||
| className="mb-4"> | |||
| <AccordionItem> | |||
| <AccordionItemHeading> | |||
| <AccordionItemButton className="flex items-center"> | |||
| {formOpen ? <FaChevronUp /> : <FaChevronDown />} | |||
| <span className="ml-2">新規スレッド</span> | |||
| </AccordionItemButton> | |||
| </AccordionItemHeading> | |||
| <AccordionItemPanel> | |||
| <ThreadNewForm /> | |||
| </AccordionItemPanel> | |||
| </AccordionItem> | |||
| </Accordion> | |||
| <div> | |||
| {loading ? 'Loading...' : ( | |||
| threads.length | |||
| ? threads.map (thread => ( | |||
| <div className="bg-white p-2 mb-2 border border-gray-400 | |||
| rounded-xl"> | |||
| <div> | |||
| <Link to={`/threads/${ thread.id }`}> | |||
| <h3>{thread.title}</h3> | |||
| </Link> | |||
| <p>{thread.description}</p> | |||
| </div> | |||
| <div className="flex justify-between text-sm text-gray-600"> | |||
| <span>{thread.postCount} レス</span> | |||
| <span>{thread.updatedAt} 更新</span> | |||
| <span>{thread.createdAt} 作成</span> | |||
| </div> | |||
| </div>)) | |||
| : 'スレないよ(笑).')} | |||
| </div> | |||
| </>) | |||
| } | |||
| @@ -0,0 +1 @@ | |||
| /// <reference types="vite/client" /> | |||
| @@ -0,0 +1,20 @@ | |||
| /** @type {import('tailwindcss').Config} */ | |||
| export default { | |||
| darkMode: false, | |||
| content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], | |||
| theme: { | |||
| extend: { | |||
| keyframes: { | |||
| 'rainbow-scroll': { | |||
| '0%': { backgroundPosition: '0% 50%' }, | |||
| '100%': { backgroundPosition: '200% 50%' }, | |||
| }, | |||
| }, | |||
| animation: { | |||
| 'rainbow-scroll': 'rainbow-scroll .25s linear infinite', | |||
| }, | |||
| }, | |||
| }, | |||
| plugins: [], | |||
| } | |||
| @@ -0,0 +1,31 @@ | |||
| { | |||
| "compilerOptions": { | |||
| "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", | |||
| "target": "ES2022", | |||
| "useDefineForClassFields": true, | |||
| "lib": ["ES2022", "DOM", "DOM.Iterable"], | |||
| "module": "ESNext", | |||
| "skipLibCheck": true, | |||
| /* Bundler mode */ | |||
| "moduleResolution": "bundler", | |||
| "allowImportingTsExtensions": true, | |||
| "verbatimModuleSyntax": true, | |||
| "moduleDetection": "force", | |||
| "noEmit": true, | |||
| "jsx": "react-jsx", | |||
| /* Linting */ | |||
| "strict": true, | |||
| "noUnusedLocals": true, | |||
| "noUnusedParameters": true, | |||
| "erasableSyntaxOnly": true, | |||
| "noFallthroughCasesInSwitch": true, | |||
| "noUncheckedSideEffectImports": true, | |||
| "baseUrl": "./src", | |||
| "paths": { | |||
| "@/*": ["*"] | |||
| } | |||
| }, | |||
| "include": ["src"] | |||
| } | |||
| @@ -0,0 +1,7 @@ | |||
| { | |||
| "files": [], | |||
| "references": [ | |||
| { "path": "./tsconfig.app.json" }, | |||
| { "path": "./tsconfig.node.json" } | |||
| ] | |||
| } | |||
| @@ -0,0 +1,25 @@ | |||
| { | |||
| "compilerOptions": { | |||
| "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", | |||
| "target": "ES2023", | |||
| "lib": ["ES2023"], | |||
| "module": "ESNext", | |||
| "skipLibCheck": true, | |||
| /* Bundler mode */ | |||
| "moduleResolution": "bundler", | |||
| "allowImportingTsExtensions": true, | |||
| "verbatimModuleSyntax": true, | |||
| "moduleDetection": "force", | |||
| "noEmit": true, | |||
| /* Linting */ | |||
| "strict": true, | |||
| "noUnusedLocals": true, | |||
| "noUnusedParameters": true, | |||
| "erasableSyntaxOnly": true, | |||
| "noFallthroughCasesInSwitch": true, | |||
| "noUncheckedSideEffectImports": true | |||
| }, | |||
| "include": ["vite.config.ts"] | |||
| } | |||
| @@ -0,0 +1,9 @@ | |||
| import { defineConfig } from 'vite' | |||
| import react from '@vitejs/plugin-react' | |||
| import path from 'path' | |||
| // https://vite.dev/config/ | |||
| export default defineConfig({ | |||
| plugins: [react()], | |||
| resolve: { alias: { '@': path.resolve (__dirname, './src') } }, | |||
| }) | |||