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