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