| @@ -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') } }, | |||||
| }) | |||||