Compare commits

..

16 Commits

Author SHA1 Message Date
abf350aa6b feat: added reject image and migrated to tailwindcss
feat: made it smartphone friendly
2026-04-14 00:16:59 +09:00
3582fca2d9 chore: bump up version 2026-03-31 16:23:10 +09:00
0305cc8d51 feat: allowed player edit and fix for css 2026-03-31 16:22:56 +09:00
af67ed6255 chore: ignore cfc.tar to prevent accidental commit
Made-with: Cursor
2026-03-31 16:11:26 +09:00
fd0f451e7c feat: some changes and admin 2026-03-31 16:09:03 +09:00
b5a1c08024 feat: modernized web application 2026-03-31 15:21:08 +09:00
f7019403c4 fix: some updates 2026-03-17 18:45:25 +09:00
f5b8b86105 changes for evojapan2023 2023-03-07 22:42:54 +09:00
a823fafc09 some fix 2021-08-29 20:18:54 +09:00
67c75578e5 finished creating dockerfile 2021-07-24 18:22:15 +09:00
c6cf7cc88b inital commit 2021-07-24 16:28:35 +09:00
aa71a7a3cc added cognito initial config 2020-09-12 15:04:39 +09:00
41c9bdafaf removed discord-commentator and added admin ui 2020-08-10 02:07:19 +09:00
2fa6740aac modified to auto upload to s3 2020-08-09 15:24:09 +09:00
9992386624 fixed linting 2020-08-09 14:51:55 +09:00
8e15699cd1 removed unnecessary space from readme 2020-08-09 14:50:11 +09:00
160 changed files with 4633 additions and 233 deletions

21
.dockerignore Normal file
View File

@@ -0,0 +1,21 @@
# Dependencies and build outputs (reinstalled / rebuilt in image)
node_modules
**/node_modules
.next
**/.next
# Git
.git
.gitignore
# Other projects in the repo (not needed for the Next.js image)
main-web
tools
twitch_chat_reader
infra
# Misc
*.md
.env
.env.*
!.env.example

1
.gitignore vendored
View File

@@ -4,3 +4,4 @@ util/*.json
build/
build.zip
build.tar.gz
cfc.tar

10
Dockerfile Normal file
View File

@@ -0,0 +1,10 @@
FROM arm64v8/node:25-alpine
WORKDIR /usr/src/app
COPY build .
RUN apk add mariadb-client
RUN npm install
EXPOSE 4000
CMD [ "node", "server/index.js" ]

View File

@@ -1,27 +1,2 @@
#!/bin/sh
mkdir build
cd main-web/client
npm run build
cd ../../
mv main-web/client/client build
cd tools/janken
npm run build
cd ../../
mv tools/janken/tool build
cd main-web/server
export catherine_db_user=$(aws --region=ap-northeast-1 ssm get-parameter --name "db-username" --with-decryption --output text --query Parameter.Value)
export catherine_db_pass=$(aws --region=ap-northeast-1 ssm get-parameter --name "db-password" --with-decryption --output text --query Parameter.Value)
cat > .env <<EOL
DB_USER=${catherine_db_user}
DB_PASS=${catherine_db_pass}
EOL
npm run build
cd ../../
cp main-web/server/.env build/
cp main-web/server/package.json build/
cp main-web/server/tsconfig.json build/
cp main-web/server/tslint.json build/
mv main-web/server/build build/server
tar czf build.tar.gz build/
rm -rf build/
APP_VERSION=1.0.3
docker buildx build --platform linux/arm64 -f nextjs/Dockerfile -t cfc-web:$APP_VERSION .

View File

@@ -7,5 +7,3 @@
1. assume $env is one of ~~nonprod, uat,~~ prod
2. $ cd packer
3. $ packer build -var-file=vars/${env}/vars.json catherine-fc.json

42
infra/db/admin_auth.sql Normal file
View File

@@ -0,0 +1,42 @@
-- Admin users and sessions for the Next.js admin area.
-- Run after catherine_league schema exists: mysql ... < admin_auth.sql
USE `catherine_league`;
-- Passwords stored as bcrypt hashes (e.g. from bcryptjs), never plaintext.
CREATE TABLE IF NOT EXISTS `admin_users` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`email` VARCHAR(255) NOT NULL,
`password_hash` VARCHAR(255) NOT NULL,
`is_approved` TINYINT(1) NOT NULL DEFAULT 0,
`is_admin` TINYINT(1) NOT NULL DEFAULT 0,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_admin_users_email` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Opaque session tokens: store SHA-256 hex of the cookie value; never store raw tokens.
CREATE TABLE IF NOT EXISTS `admin_sessions` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`user_id` INT UNSIGNED NOT NULL,
`token_hash` CHAR(64) NOT NULL,
`expires_at` DATETIME NOT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_admin_sessions_token_hash` (`token_hash`),
KEY `idx_admin_sessions_user_id` (`user_id`),
KEY `idx_admin_sessions_expires_at` (`expires_at`),
CONSTRAINT `fk_admin_sessions_user`
FOREIGN KEY (`user_id`) REFERENCES `admin_users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Seed: first admin (change password immediately in production).
-- Default password: ChangeMeOnFirstLogin!
INSERT INTO `admin_users` (`email`, `password_hash`, `is_approved`, `is_admin`)
SELECT
'admin@localhost',
'$2b$12$H6Y71zX/nmefp33e0MaMaOOBGSiVwxVE3L.Ie3Pfq1/6QZdLR7bTa',
1,
1
WHERE NOT EXISTS (SELECT 1 FROM `admin_users` WHERE `email` = 'admin@localhost' LIMIT 1);

View File

@@ -150,3 +150,14 @@ CREATE TABLE `catherine_league`.`archive` (
INSERT INTO `catherine_league`.`archive` (`title`, `youtube_id`) VALUES ('格ゲー大好き茜ちゃんと競う『キャサリン・フルボディ』 【VOICEROID紹介動画】', 'oE8Xk6pGtQc');
INSERT INTO `catherine_league`.`archive` (`title`, `youtube_id`) VALUES ('【PGW.TV】キャサリンフルボディ 「対戦時に使うテクニック紹介など」【はやお】', '7fZk2Agy0S4');
CREATE TABLE catherine_league.janken (
id BIGINT UNSIGNED auto_increment NOT NULL,
`match` varchar(100) NULL,
`result` json NULL,
created_date DATETIME DEFAULT NOW() NULL,
PRIMARY KEY (`id`)
)
ENGINE=InnoDB
DEFAULT CHARSET=utf8mb4
COLLATE=utf8mb4_general_ci;

View File

@@ -0,0 +1,23 @@
resource "aws_cognito_user_pool" "catherine_fc_admin_cognito_pool" {
name = "catherine-fc-admin"
admin_create_user_config {
allow_admin_create_user_only = true
}
}
resource "aws_cognito_user_pool_client" "catherine_fc_admin_cognito_pool_client" {
name = "catherine-fc-admin-client"
user_pool_id = aws_cognito_user_pool.catherine_fc_admin_cognito_pool.id
allowed_oauth_flows = ["code","implicit"]
allowed_oauth_scopes = ["email", "openid"]
callback_urls = ["https://www.catherine-fc.com","https://catherine-fc.com"]
allowed_oauth_flows_user_pool_client = true
generate_secret = true
explicit_auth_flows = ["USER_PASSWORD_AUTH"]
}
resource "aws_cognito_user_pool_domain" "catherine_fc_admin_cognito_pool_domain" {
domain = "catherine-fc-admin-domain"
user_pool_id = aws_cognito_user_pool.catherine_fc_admin_cognito_pool.id
}

View File

@@ -20,4 +20,58 @@ resource "aws_lb_listener" "catherine_fc_load_balancer_listener" {
type = "forward"
target_group_arn = aws_lb_target_group.target_group_web.arn
}
}
}
resource "aws_lb_listener_rule" "catherine_fc_alb_listener_admin" {
listener_arn = aws_lb_listener.catherine_fc_load_balancer_listener.arn
priority = 1
action {
type = "authenticate-cognito"
authenticate_cognito {
user_pool_arn = aws_cognito_user_pool.catherine_fc_admin_cognito_pool.arn
user_pool_client_id = aws_cognito_user_pool_client.catherine_fc_admin_cognito_pool_client.id
user_pool_domain = aws_cognito_user_pool_domain.catherine_fc_admin_cognito_pool_domain.domain
on_unauthenticated_request = "authenticate"
session_cookie_name = "CatherineFCAdmin"
session_timeout = 86400
}
}
action {
type = "forward"
target_group_arn = aws_lb_target_group.target_group_web.arn
}
condition {
path_pattern {
values = ["/admin*"]
}
}
}
resource "aws_lb_listener_rule" "catherine_fc_alb_listener_admin_api" {
listener_arn = aws_lb_listener.catherine_fc_load_balancer_listener.arn
priority = 2
action {
type = "authenticate-cognito"
authenticate_cognito {
user_pool_arn = aws_cognito_user_pool.catherine_fc_admin_cognito_pool.arn
user_pool_client_id = aws_cognito_user_pool_client.catherine_fc_admin_cognito_pool_client.id
user_pool_domain = aws_cognito_user_pool_domain.catherine_fc_admin_cognito_pool_domain.domain
on_unauthenticated_request = "deny"
session_cookie_name = "CatherineFCAdmin"
session_timeout = 86400
}
}
action {
type = "forward"
target_group_arn = aws_lb_target_group.target_group_web.arn
}
condition {
path_pattern {
values = ["/api/admin*"]
}
}
}

View File

@@ -1,13 +1,17 @@
{
"name": "discord-commentator",
"name": "catherine-fc-admin-ui",
"version": "0.1.0",
"homepage": "./",
"private": true,
"dependencies": {
"discord-rpc": "^3.1.3",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-scripts": "3.4.1",
"typescript": "^3.7.5"
"node-sass": "^4.13.1",
"semantic-ui-css": "^2.4.1",
"semantic-ui-react": "^0.88.2",
"i18next": "^19.6.3",
"typescript": "^4.9.5"
},
"devDependencies": {
"@testing-library/jest-dom": "^4.2.4",
@@ -15,9 +19,11 @@
"@testing-library/user-event": "^7.2.1",
"@types/jest": "^24.9.1",
"@types/node": "^12.12.54",
"@types/react": "^16.9.44",
"@types/react": "^16.9.45",
"@types/react-dom": "^16.9.8",
"@types/discord-rpc": "^3.0.4"
"tslint": "^5.18.0",
"tslint-config-airbnb": "^5.11.1",
"tslint-react": "^4.0.0"
},
"scripts": {
"start": "react-scripts start",

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

View File

@@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Admin UI for catherine-fc"
/>
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Catherine Faito Crab Admin UI</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

View File

@@ -0,0 +1,9 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View File

@@ -0,0 +1,11 @@
import React from 'react';
import './App.css';
function App(): JSX.Element {
return (
<div className="App">
</div>
);
}
export default App;

View File

@@ -3,7 +3,7 @@
"version": "0.1.0",
"private": true,
"dependencies": {
"axios": "^0.19.2",
"axios": "^0.21.1",
"dotenv": "^8.2.0",
"i18next": "^19.6.3",
"node-sass": "^4.13.1",
@@ -15,13 +15,13 @@
"react-youtube": "^7.11.3",
"semantic-ui-css": "^2.4.1",
"semantic-ui-react": "^0.88.2",
"typescript": "^3.7.5"
"typescript": "^4.9.5"
},
"devDependencies": {
"@testing-library/jest-dom": "^4.2.4",
"@testing-library/react": "^9.5.0",
"@testing-library/user-event": "^7.2.1",
"@types/react-router-dom": "^5.1.5",
"@types/react-router-dom": "^5.3.3",
"@types/jest": "^24.9.1",
"@types/node": "^12.12.35",
"@types/react": "^16.9.34",

View File

@@ -1,23 +1,7 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"icons": [],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",

View File

@@ -47,12 +47,20 @@ function App(): JSX.Element {
<li>
<Link to='/'>Home</Link>
</li>
<li>
<Link to='/players'>Players</Link>
</li>
{
/*
<li>
<Link to='/tournaments/stray_sheep_0/players'>Players</Link>
</li>
<li>
<Link to='/tournaments/stray_sheep_0/scoreboard'>Scoreboard</Link>
</li>
*/
}
<li>
<Link to='/guide'>Guide</Link>
</li>

View File

@@ -5,22 +5,24 @@ type ComponentProps = {
};
export const Home: FunctionComponent<ComponentProps> = (props): JSX.Element => {
return (
<div className={style.mainBody}>
<div className={style.chalice} />
<div className={style.titleImageStrayShip0} />
<div className={style.players} />
<div className={style.evojapan2025} />
<div className={style.padding} />
<div className={style.rule} />
<div className={style.twitchHome}>
<a href='https://www.twitch.tv/catherine_faito_crab' rel='noopener noreferrer' target='_blank'>&nbsp;</a>
<div className={style.evojapan2023kaisaikettei} />
<div className={style.padding} />
<div className={style.group}>
<div className={style.evojapan2023catherinetonamel}>
<a href='https://tonamel.com/competition/VD6y8' rel='noopener noreferrer' target='_blank'>&nbsp;</a>
</div>
<div className={style.evojapan2023catherinefullbodytonamel}>
<a href='https://tonamel.com/competition/dgdQ9' rel='noopener noreferrer' target='_blank'>&nbsp;</a>
</div>
</div>
<div className={style.padding} />
<div className={style.twitchHome}>
<a href='https://www.twitch.tv/catherine_faito_crab' rel='noopener noreferrer' target='_blank'>&nbsp;</a>
</div>
<div className={style.players0801} />
<div className={style.players0802} />
<div className={style.players0808} />
<div className={style.players0809} />
<div className={style.players0815} />
</div>
);
};

View File

@@ -12,6 +12,7 @@ type ComponentProps = {
export const Players: FunctionComponent<ComponentProps> = (props): JSX.Element => {
const { t } = useTranslation();
const [playerInfo, setPlayerInfo] = useState<IPlayerInfo[]>([]);
// @ts-ignore
const { tournament_key } = useParams();
useEffect(() => {
if (playerInfo.length === 0) {

View File

@@ -440,6 +440,69 @@
background-size: contain;
background-repeat: none;
}
.evojapan2023 {
width: 500px;
height: 471px;
margin: auto;
background-image: url('https://static.catherine-fc.com/media/evo_logo_shadow_drop.png');
background-size: contain;
background-repeat: none;
}
.evojapan2024 {
width: 500px;
height: 471px;
margin: auto;
background-image: url('https://static.catherine-fc.com/media/evo_2024_logo_shadow_drop.png');
background-size: contain;
background-repeat: none;
}
.evojapan2025 {
width: 500px;
height: 471px;
margin: auto;
background-image: url('https://static.catherine-fc.com/media/evo_logo_2025_shadow_drop.png');
background-size: contain;
background-repeat: none;
}
.evojapan2023kaisaikettei {
width: 846px;
height: 285px;
margin: auto;
background-image: url('https://static.catherine-fc.com/media/kaisaikettei3.png');
background-size: contain;
}
.evojapan2023catherinetonamel {
width: 412px;
height: 389px;
background-image: url('https://static.catherine-fc.com/media/catherine_logo_classic.png');
background-size: contain;
background-repeat: none;
&> a {
display:block;
width: 412px;
height: 389px;
}
}
.evojapan2023catherinefullbodytonamel {
width: 517px;
height: 319px;
background-image: url('https://static.catherine-fc.com/media/catherine_fullbody_logo.png');
background-size: contain;
background-repeat: none;
&> a {
display:block;
width: 517px;
height: 319px;
}
}
.group {
width: 900px;
height: 389px;
margin: auto;
display: flex;
background-size: contain;
background-repeat: none;
}
.players {
width: 900px;
height: 606px;
@@ -464,6 +527,14 @@
background-size: contain;
background-repeat: none;
}
.titleImageStrayShip4 {
width: 808px;
height: 119px;
margin: auto;
background-image: url('https://static.catherine-fc.com/media/straysheepcup4.png');
background-size: contain;
background-repeat: none;
}
.rule {
width: 900px;
height: 637px;
@@ -472,7 +543,19 @@
background-size: contain;
background-repeat: none;
}
.tonamel {
width: 900px;
height: 637px;
margin: auto;
background-image: url('https://static.catherine-fc.com/media/tonamel.png');
background-size: contain;
background-repeat: none;
&> a {
display:block;
width: 900px;
height: 637px;
}
}
.padding {
width: 900px;
height: 100px;

View File

@@ -1,4 +1,4 @@
admin/
client/
build/
.env
.env*

View File

@@ -4,7 +4,7 @@
"description": "",
"main": "src/index.ts",
"scripts": {
"start": "ts-node-dev --respawn --transpileOnly ./src/index.ts",
"start": "ts-node-dev --poll --respawn --transpile-only ./src/index.ts",
"build": "tsc --resolveJsonModule",
"test": "ts-node-dev src/test.ts",
"lint": "tslint -c tslint.json -p tsconfig.json"
@@ -24,7 +24,7 @@
"@types/express": "^4.17.6",
"@types/mysql": "^2.1.4",
"@types/socket.io": "^2.1.4",
"typescript": "^3.8.3",
"typescript": "^4.9.5",
"tslint": "^5.18.0",
"tslint-config-airbnb": "^5.11.1"
}

View File

@@ -7,21 +7,25 @@ import dotenv from 'dotenv';
import { v1 } from 'uuid';
import { IJankenSelect, IJankenResult } from './models/message';
import { IRoom } from './models';
dotenv.config();
const port: number = 8080;
const port: number = 4000;
const app: express.Express = express();
const httpServer: http.Server = http.createServer(app);
const io: socketIo.Server = socketIo(httpServer);
const tournaments: Record<string, number> = {};
const players: Record<string, number> = {};
console.log(process.env.DB_ENDPOINT);
const connection = mysql.createPool({
connectionLimit: 10,
host: 'catherine-fc.csmrqtei38qi.ap-northeast-1.rds.amazonaws.com',
port: (process.env.DB_PORT ? parseInt(process.env.DB_PORT, 10) : 3306),
host: process.env.DB_ENDPOINT,
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: 'catherine_league'
database: 'catherine_league',
charset: 'utf8mb4'
});
connection.query(
`SELECT id, tournament_key
@@ -139,6 +143,7 @@ app.get('/api/contact', (req: any, res: any) => {
);
});
app.use('/admin', express.static('admin'));
app.use('/players', express.static('client'));
app.use('/tournaments*', express.static('client'));
app.use('/about', express.static('client'));
@@ -154,16 +159,11 @@ httpServer.listen(port, () => {
// Janken Tool
let room: boolean = false;
let playerOne: string = '';
let playerTwo: string = '';
let playerOneOption: string = '';
let playerTwoOption: string = '';
let stages: string[] = [];
let currentIndex: number = 0;
let rooms: Record<string, IRoom> = {};
const defaultStages: string[] = ['cementery', 'prison', 'torture', 'inquisition', 'quadrangle', 'clock', 'spiral', 'empireo', 'cathedral', 'close', 'arrange'];
io.on('connection', (socket: socketIo.Socket) => {
socket.on('disconnect', () => {
console.log('disconnect');
@@ -172,46 +172,74 @@ io.on('connection', (socket: socketIo.Socket) => {
socket.on('exitGame', () => {
console.log('exitGame');
io.emit('exitGame');
room = false;
initRoom();
Object.keys(io.sockets.sockets).forEach((s) => {
rooms = {};
Object.keys(io.sockets.sockets).forEach((s: string) => {
io.sockets.sockets[s].disconnect(true);
});
});
socket.on('janken', (data: IJankenSelect) => {
console.log('janken', data);
if (playerOne === data.userName) {
playerOneOption = data.option;
} else if (playerTwo === data.userName) {
playerTwoOption = data.option;
}
console.log('playerOneOption',playerOneOption);
console.log('playerTwoOption',playerTwoOption);
if (playerOneOption !== '' && playerTwoOption !== '') {
const message: IJankenResult = {
playerOne,
playerOneOption,
playerTwo,
playerTwoOption
const currentRoom = rooms[data.room];
if (currentRoom) {
if (currentRoom.playerOne === data.userName) {
currentRoom.playerOneOption = data.option;
} else if (currentRoom.playerTwo === data.userName) {
currentRoom.playerTwoOption = data.option;
}
console.log('playerOneOption', currentRoom.playerOneOption);
console.log('playerTwoOption', currentRoom.playerTwoOption);
if (currentRoom.playerOneOption !== '' && currentRoom.playerTwoOption !== '') {
const message: IJankenResult = {
playerOne: currentRoom.playerOne,
playerOneOption: currentRoom.playerOneOption,
playerTwo: currentRoom.playerTwo,
playerTwoOption: currentRoom.playerTwoOption
};
io.to(data.room).emit('jankenResult', message);
const sqlToPrepare = 'INSERT INTO `janken` (`match`, `result`) VALUES(?, ?);';
const sql = mysql.format(sqlToPrepare, [data.room, JSON.stringify(message)]);
connection.query(sql,
(error: mysql.MysqlError | null, results: any) => {
if (error) {
console.error(error);
}
}
);
if (currentRoom.playerOneOption !== currentRoom.playerTwoOption) {
delete rooms[data.room];
}
}
io.emit('jankenResult', message);
}
});
socket.on('registerUser', (data: string) => {
socket.on('registerUser', (data: {username: string, room: string}) => {
console.log('registerUser', data);
if (playerOne === '') {
playerOne = data;
socket.emit('registerUser', data);
} else if (playerTwo === '') {
playerTwo = data;
socket.emit('registerUser', data);
let currentRoom: IRoom = {
playerOne: '',
playerTwo: '',
playerOneOption: '',
playerTwoOption: ''
};
if (rooms[data.room]) {
currentRoom = rooms[data.room];
} else {
rooms = {
...rooms,
[data.room]: currentRoom
};
}
socket.join(data.room);
if (currentRoom.playerOne === '') {
currentRoom.playerOne = data.username;
socket.emit('registerUser', data.username);
} else if (currentRoom.playerTwo === '') {
currentRoom.playerTwo = data.username;
socket.emit('registerUser', data.username);
}
const message = {
playerOne: playerOne,
playerTwo: playerTwo,
playerOne: currentRoom.playerOne,
playerTwo: currentRoom.playerTwo
};
console.log()
io.emit('login', message);
io.to(data.room).emit('login', message);
});
socket.on('moveToStage', () => {
console.log('moveToStage');
@@ -226,11 +254,14 @@ io.on('connection', (socket: socketIo.Socket) => {
io.emit('updateIndex', currentIndex);
}
});
socket.on('againGame', () => {
console.log('againGame');
io.emit('againGame');
playerOneOption = '';
playerTwoOption = '';
socket.on('againGame', (room: string) => {
const currentRoom = rooms[room];
if (currentRoom) {
console.log('againGame');
io.to(room).emit('againGame');
currentRoom.playerOneOption = '';
currentRoom.playerTwoOption = '';
}
});
socket.on('previousIndex', () => {
console.log('previousIndex');
@@ -239,6 +270,7 @@ io.on('connection', (socket: socketIo.Socket) => {
io.emit('updateIndex', currentIndex);
}
});
/*
if (!room) {
// create new room
initRoom();
@@ -248,31 +280,29 @@ io.on('connection', (socket: socketIo.Socket) => {
playerTwo: '',
};
socket.emit('login', message);
}
else {
} else {
if (playerOne === '' || playerTwo === '' || playerOneOption === '' || playerTwoOption === '') {
const message = {
playerOne: playerOne,
playerTwo: playerTwo,
playerOne,
playerTwo,
};
socket.emit('login', message);
} else {
const message = {
stages,
currentIndex
}
};
socket.emit('stages', message);
}
}
*/
const initialmessage = {
playerOne: '',
playerTwo: '',
};
socket.emit('login', initialmessage);
});
const shuffle = (array: string[]): string[] => {
return array.sort(() => Math.random() - 0.5);
};
const initRoom = (): void => {
playerOne = '';
playerTwo = '';
playerOneOption = '';
playerTwoOption = '';
};

View File

@@ -0,0 +1,6 @@
export interface IRoom {
playerOne: string;
playerTwo: string;
playerOneOption: string;
playerTwoOption: string;
}

View File

@@ -16,5 +16,6 @@ export interface ILogin {
export interface IJankenSelect {
userName: string;
room: string;
option: string;
}

1
main-web/serverless/go/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
pkg/

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,21 @@
# Serverless directories
.serverless
# golang output binary directory
bin
# golang vendor (dependencies) directory
vendor
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, build with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out

View File

@@ -0,0 +1,91 @@
# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'.
[[projects]]
digest = "1:1f2ee62e40b3b0ca8403e49a07afb9ae8c0980391be1986e9f6c4ac9de1b1a90"
name = "github.com/aws/aws-lambda-go"
packages = [
"events",
"lambda",
"lambda/handlertrace",
"lambda/messages",
"lambdacontext",
]
pruneopts = ""
revision = "55dc88be4cdfaaf4bb4d9f65debd8d4310e0a83c"
version = "v1.19.1"
[[projects]]
digest = "1:913aa8374d300095f235fe6762f74a9ca82bf1fb30d4f830540b2a5dd6f993e4"
name = "github.com/aws/aws-sdk-go"
packages = [
"aws",
"aws/awserr",
"aws/awsutil",
"aws/client",
"aws/client/metadata",
"aws/corehandlers",
"aws/credentials",
"aws/credentials/ec2rolecreds",
"aws/credentials/endpointcreds",
"aws/credentials/processcreds",
"aws/credentials/stscreds",
"aws/csm",
"aws/defaults",
"aws/ec2metadata",
"aws/endpoints",
"aws/request",
"aws/session",
"aws/signer/v4",
"internal/context",
"internal/ini",
"internal/sdkio",
"internal/sdkmath",
"internal/sdkrand",
"internal/sdkuri",
"internal/shareddefaults",
"internal/strings",
"internal/sync/singleflight",
"private/protocol",
"private/protocol/json/jsonutil",
"private/protocol/jsonrpc",
"private/protocol/query",
"private/protocol/query/queryutil",
"private/protocol/rest",
"private/protocol/xml/xmlutil",
"service/ssm",
"service/sts",
"service/sts/stsiface",
]
pruneopts = ""
revision = "2bec4e3ae9cb717c0082a489e75ab9db71c98b23"
version = "v1.34.22"
[[projects]]
digest = "1:a67f9275b42e8bb95ba38f705062175ef65538bcdc107e0771763e6cde80c3c3"
name = "github.com/go-sql-driver/mysql"
packages = ["."]
pruneopts = ""
revision = "17ef3dd9d98b69acec3e85878995ada9533a9370"
version = "v1.5.0"
[[projects]]
digest = "1:13fe471d0ed891e8544eddfeeb0471fd3c9f2015609a1c000aefdedf52a19d40"
name = "github.com/jmespath/go-jmespath"
packages = ["."]
pruneopts = ""
revision = "c2b33e84"
[solve-meta]
analyzer-name = "dep"
analyzer-version = 1
input-imports = [
"github.com/aws/aws-lambda-go/events",
"github.com/aws/aws-lambda-go/lambda",
"github.com/aws/aws-sdk-go/aws",
"github.com/aws/aws-sdk-go/aws/session",
"github.com/aws/aws-sdk-go/service/ssm",
"github.com/go-sql-driver/mysql",
]
solver-name = "gps-cdcl"
solver-version = 1

View File

@@ -0,0 +1,25 @@
# Gopkg.toml example
#
# Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md
# for detailed Gopkg.toml documentation.
#
# required = ["github.com/user/thing/cmd/thing"]
# ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"]
#
# [[constraint]]
# name = "github.com/user/project"
# version = "1.0.0"
#
# [[constraint]]
# name = "github.com/user/project2"
# branch = "dev"
# source = "github.com/myfork/project2"
#
# [[override]]
# name = "github.com/x/y"
# version = "2.4.0"
[[constraint]]
name = "github.com/aws/aws-lambda-go"
version = "1.x"

View File

@@ -0,0 +1,12 @@
.PHONY: build clean deploy
build:
dep ensure -v
env GOOS=linux go build -ldflags="-s -w" -o bin/archive archive/main.go
env GOOS=linux go build -ldflags="-s -w" -o bin/guide guide/main.go
clean:
rm -rf ./bin ./vendor Gopkg.lock
deploy: clean build
sls deploy --verbose

View File

@@ -0,0 +1,72 @@
package main
import (
"bytes"
"context"
"encoding/json"
_ "github.com/go-sql-driver/mysql"
"github.com/aws/aws-lambda-go/events"
"github.com/aws/aws-lambda-go/lambda"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/ssm"
)
// Response is of type APIGatewayProxyResponse since we're leveraging the
// AWS Lambda Proxy Request functionality (default behavior)
//
// https://serverless.com/framework/docs/providers/aws/events/apigateway/#lambda-proxy-integration
type Response events.APIGatewayProxyResponse
// Handler is our lambda handler invoked by the `lambda.Start` function call
func Handler(ctx context.Context) (Response, error) {
svc := ssm.New(session.New(), &aws.Config{
Region: aws.String("ap-northeast-1"),
})
res, _ := svc.GetParameter(&ssm.GetParameterInput{
Name: aws.String("db-endpoint"),
WithDecryption: aws.Bool(true),
})
dbendpoint := *res.Parameter.Value
/*
res, _ = svc.GetParameter(&ssm.GetParameterInput{
Name: aws.String("db-username"),
WithDecryption: aws.Bool(true),
})
dbusername := *res.Parameter.Value
res, _ = svc.GetParameter(&ssm.GetParameterInput{
Name: aws.String("db-password"),
WithDecryption: aws.Bool(true),
})
dbpassword := *res.Parameter.Value
*/
var buf bytes.Buffer
body, err := json.Marshal(map[string]interface{}{
"message": "Okay so your other function also executed successfully!",
"dbendpoint": dbendpoint,
})
if err != nil {
return Response{StatusCode: 404}, err
}
json.HTMLEscape(&buf, body)
resp := Response{
StatusCode: 200,
IsBase64Encoded: false,
Body: buf.String(),
Headers: map[string]string{
"Content-Type": "application/json",
"X-MyCompany-Func-Reply": "world-handler",
},
}
return resp, nil
}
func main() {
lambda.Start(Handler)
}

View File

@@ -0,0 +1,45 @@
package main
import (
"bytes"
"context"
"encoding/json"
"github.com/aws/aws-lambda-go/events"
"github.com/aws/aws-lambda-go/lambda"
)
// Response is of type APIGatewayProxyResponse since we're leveraging the
// AWS Lambda Proxy Request functionality (default behavior)
//
// https://serverless.com/framework/docs/providers/aws/events/apigateway/#lambda-proxy-integration
type Response events.APIGatewayProxyResponse
// Handler is our lambda handler invoked by the `lambda.Start` function call
func Handler(ctx context.Context) (Response, error) {
var buf bytes.Buffer
body, err := json.Marshal(map[string]interface{}{
"message": "Go Serverless v1.0! Your function executed successfully!",
})
if err != nil {
return Response{StatusCode: 404}, err
}
json.HTMLEscape(&buf, body)
resp := Response{
StatusCode: 200,
IsBase64Encoded: false,
Body: buf.String(),
Headers: map[string]string{
"Content-Type": "application/json",
"X-MyCompany-Func-Reply": "hello-handler",
},
}
return resp, nil
}
func main() {
lambda.Start(Handler)
}

View File

@@ -0,0 +1,152 @@
# Welcome to Serverless!
#
# This file is the main config file for your service.
# It's very minimal at this point and uses default values.
# You can always add more config options for more control.
# We've included some commented out config examples here.
# Just uncomment any of them to get that config option.
#
# For full config options, check the docs:
# docs.serverless.com
#
# Happy Coding!
service: catherine-fc-api
# app and org for use with dashboard.serverless.com
#app: your-app-name
#org: your-org-name
custom:
VpcId:
dev: "vpc-c54553a2"
sqa: "vpc-c54553a2"
int: "vpc-c54553a2"
uat: "vpc-c54553a2"
prd: "vpc-c54553a2"
Subnet1:
dev: "subnet-0d0fdf45"
sqa: "subnet-0d0fdf45"
int: "subnet-0d0fdf45"
uat: "subnet-0d0fdf45"
prd: "subnet-0d0fdf45"
Subnet2:
dev: "subnet-4dcecc16"
sqa: "subnet-4dcecc16"
int: "subnet-4dcecc16"
uat: "subnet-4dcecc16"
prd: "subnet-4dcecc16"
# You can pin your service to only deploy with a specific Serverless version
# Check out our docs for more details
# frameworkVersion: "=X.X.X"
frameworkVersion: '>=1.28.0 <=2.0.0'
provider:
name: aws
runtime: go1.x
region: ap-northeast-1
versionFunctions: false
logRetentionInDays: 14
memorySize: 128
deploymentBucket: catherine-fc-serverless-bucket
stage: dev
# you can overwrite defaults here
# stage: dev
# region: us-east-1
iamRoleStatements:
- Effect: "Allow"
Action:
- "lambda:InvokeFunction"
Resource:
- "arn:aws:lambda:*:*:function:*"
- Effect: "Allow"
Action:
- "logs:CreateLogGroup"
- "logs:CreateLogStream"
- "logs:PutLogEvents"
Resource:
- "arn:aws:logs:*:*:*"
- Effect: "Allow"
Action:
- "ssm:GetParameter"
Resource:
- "arn:aws:ssm:*:*:parameter/*"
# you can define service wide environment variables here
# environment:
# variable1: value1
package:
exclude:
- ./**
include:
- ./bin/**
functions:
guideAPI:
handler: bin/guide
timeout: 3
events:
- http:
path: guide
method: get
archiveAPI:
handler: bin/archive
timeout: 3
events:
- http:
path: archive
method: get
# The following are a few example events you can configure
# NOTE: Please make sure to change your handler code to work with those events
# Check the event documentation for details
# events:
# events:
# - http:
# path: users/create
# method: get
# - websocket: $connect
# - s3: ${env:BUCKET}
# - schedule: rate(10 minutes)
# - sns: greeter-topic
# - stream: arn:aws:dynamodb:region:XXXXXX:table/foo/stream/1970-01-01T00:00:00.000
# - alexaSkill: amzn1.ask.skill.xx-xx-xx-xx
# - alexaSmartHome: amzn1.ask.skill.xx-xx-xx-xx
# - iot:
# sql: "SELECT * FROM 'some_topic'"
# - cloudwatchEvent:
# event:
# source:
# - "aws.ec2"
# detail-type:
# - "EC2 Instance State-change Notification"
# detail:
# state:
# - pending
# - cloudwatchLog: '/aws/lambda/hello'
# - cognitoUserPool:
# pool: MyUserPool
# trigger: PreSignUp
# - alb:
# listenerArn: arn:aws:elasticloadbalancing:us-east-1:XXXXXX:listener/app/my-load-balancer/50dc6c495c0c9188/
# priority: 1
# conditions:
# host: example.com
# path: /hello
# Define function environment variables here
# environment:
# variable2: value2
# you can add CloudFormation resource templates here
#resources:
# Resources:
# NewResource:
# Type: AWS::S3::Bucket
# Properties:
# BucketName: my-new-bucket
# Outputs:
# NewOutput:
# Description: "Description for the output"
# Value: "Some output value"

7
nextjs/.env.example Normal file
View File

@@ -0,0 +1,7 @@
# MySQL (same as main-web/server)
DB_ENDPOINT=localhost
DB_PORT=3306
DB_USER=
DB_PASS=
# After DB is up, apply admin tables (see infra/db/admin_auth.sql)

3
nextjs/.eslintrc.json Normal file
View File

@@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}

7
nextjs/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
/node_modules
/.next/
/out/
.env
.env.local
.env.*.local
*.tsbuildinfo

66
nextjs/Dockerfile Normal file
View File

@@ -0,0 +1,66 @@
# syntax=docker/dockerfile:1
#
# Target: linux/arm64 (ARMv8 / aarch64). Build from the repository root:
# docker build -f nextjs/Dockerfile -t catherine-league-web .
# Cross-build from amd64 (optional, requires QEMU/binfmt, e.g. Docker Desktop / buildx):
# docker buildx build --platform linux/arm64 -f nextjs/Dockerfile -t catherine-league-web .
#
# Run (set DB_* env vars as needed):
# docker run --rm -p 3000:3000 \
# -e DB_ENDPOINT=host.docker.internal \
# -e DB_PORT=3306 \
# -e DB_USER=... \
# -e DB_PASS=... \
# catherine-league-web
FROM arm64v8/node:24-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json ./
COPY nextjs/package.json ./nextjs/
RUN npm ci
FROM arm64v8/node:24-alpine AS builder
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/package.json ./package.json
COPY --from=deps /app/package-lock.json ./package-lock.json
COPY nextjs ./nextjs
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build -w catherine-league-nextjs
FROM arm64v8/node:24-alpine AS runner
RUN apk add --no-cache libc6-compat
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs
COPY --from=builder /app/nextjs/public ./nextjs/public
COPY --from=builder /app/nextjs/.next/standalone ./
COPY --from=builder /app/nextjs/.next/static ./nextjs/.next/static
RUN chown -R nextjs:nodejs /app
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
WORKDIR /app/nextjs
CMD ["node", "server.js"]

6
nextjs/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

12
nextjs/next.config.mjs Normal file
View File

@@ -0,0 +1,12 @@
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
outputFileTracingRoot: path.join(__dirname, '..'),
};
export default nextConfig;

34
nextjs/package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "catherine-league-nextjs",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"bcryptjs": "^3.0.3",
"dotenv": "^16.4.5",
"i18next": "^23.10.1",
"mysql2": "^3.11.0",
"next": "^15.5.14",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^14.1.0",
"react-youtube": "^10.1.0"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/node": "^20.14.10",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"autoprefixer": "^10.4.27",
"eslint": "^8.57.0",
"eslint-config-next": "^15.5.14",
"postcss": "^8.4.31",
"tailwindcss": "^3.4.19",
"typescript": "^5.5.4"
}
}

6
nextjs/postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1,9 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [],
"start_url": ".",
"display": "standalone",
"theme_color": "#ff2d7c",
"background_color": "#ffffff"
}

3
nextjs/public/robots.txt Normal file
View File

@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

View File

@@ -0,0 +1,39 @@
import { guideBody } from '@/lib/siteContentClasses';
export default function AboutPage() {
return (
<div className={guideBody}>
<h2 className="text-xl sm:text-2xl">About</h2>
<hr />
<div className="relative mx-auto my-5 min-h-[400px] w-full max-w-[980px] bg-[url(https://static.catherine-fc.com/media/playerbg.png)] [background-repeat:no-repeat] [background-position:center] [background-size:100%_100%] p-6 font-mplus sm:p-10 md:h-[800px] md:min-h-0 md:bg-[length:980px_800px] md:p-[50px]">
<br className="hidden sm:block" />
<p>
Covid-19
</p>
<p>
Switch版0
</p>
<p>
使
</p>
<p></p>
<p>
</p>
<p></p>
<div className="relative mt-8 h-[200px] w-[120px] max-w-full bg-[url(https://static.catherine-fc.com/media/title_sheep.png)] bg-contain bg-left [background-repeat:no-repeat] sm:float-left sm:mt-[70px] sm:h-[280px] sm:w-[160px] md:h-[350px] md:w-[207px]" />
<div className="relative clear-both m-5 inline-block h-32 w-32 max-w-full bg-[url(https://static.catherine-fc.com/media/letterbox.png)] bg-[length:128px_128px] [background-repeat:no-repeat] sm:clear-none">
<a
className="block h-32 w-32 min-h-[44px] min-w-[44px]"
href="https://forms.gle/Dn4p7cFEPLK1zcTz5"
rel="noopener noreferrer"
target="_blank"
>
&nbsp;
</a>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,9 @@
import { ArchiveVideoList } from '@/components/ArchiveVideoList';
import { getArchiveItems } from '@/lib/data';
export const dynamic = 'force-dynamic';
export default async function ArchivePage() {
const items = await getArchiveItems();
return <ArchiveVideoList items={items} />;
}

View File

@@ -0,0 +1,9 @@
import { ContactList } from '@/components/ContactList';
import { getContactItems } from '@/lib/data';
export const dynamic = 'force-dynamic';
export default async function ContactPage() {
const items = await getContactItems();
return <ContactList items={items} />;
}

View File

@@ -0,0 +1,9 @@
import { GuideVideoList } from '@/components/GuideVideoList';
import { getGuideItems } from '@/lib/data';
export const dynamic = 'force-dynamic';
export default async function GuidePage() {
const items = await getGuideItems();
return <GuideVideoList items={items} />;
}

View File

@@ -0,0 +1,7 @@
import type { ReactNode } from 'react';
import { SiteLayout } from '@/components/SiteLayout';
export default function SiteChromeLayout({ children }: { children: ReactNode }) {
return <SiteLayout>{children}</SiteLayout>;
}

View File

@@ -0,0 +1,48 @@
export default function HomePage() {
return (
<div className="mx-auto my-5 flex w-full max-w-[980px] flex-col items-center justify-center px-4 sm:px-5 md:px-0">
<div className="mx-auto aspect-[500/320] w-full max-w-[500px] bg-[url(https://static.catherine-fc.com/media/reject_cfc.jpeg)] bg-contain bg-center [background-repeat:no-repeat]" />
<div className="mx-auto aspect-[500/471] w-full max-w-[500px] bg-[url(https://static.catherine-fc.com/media/evo_logo_2026_shadow_drop.png)] bg-contain bg-center [background-repeat:no-repeat]" />
<div className="mx-auto h-12 w-full max-w-[900px] sm:h-[100px]" />
<div className="mx-auto aspect-[846/285] w-full max-w-[846px] bg-[url(https://static.catherine-fc.com/media/kaisaikettei3.png)] bg-contain bg-center" />
<div className="mx-auto h-12 w-full max-w-[900px] sm:h-[100px]" />
<div className="mx-auto flex w-full max-w-[900px] flex-col items-stretch gap-4 bg-contain [background-repeat:no-repeat] md:flex-row md:gap-0">
<div className="flex w-full justify-center md:w-auto md:flex-1">
<div className="bg-[url(https://static.catherine-fc.com/media/catherine_logo_classic.png)] bg-contain bg-center [background-repeat:no-repeat]">
<a
className="mx-auto block aspect-[412/389] w-full max-w-[412px] min-h-[200px]"
href="https://tonamel.com/competition/u2GRP"
rel="noopener noreferrer"
target="_blank"
>
&nbsp;
</a>
</div>
</div>
<div className="flex w-full justify-center md:w-auto md:flex-1">
<div className="bg-[url(https://static.catherine-fc.com/media/catherine_fullbody_logo.png)] bg-contain bg-center [background-repeat:no-repeat]">
<a
className="mx-auto block aspect-[517/319] w-full max-w-[517px] min-h-[180px]"
href="https://tonamel.com/competition/BxuNE"
rel="noopener noreferrer"
target="_blank"
>
&nbsp;
</a>
</div>
</div>
</div>
<div className="mx-auto h-12 w-full max-w-[900px] sm:h-[100px]" />
<div className="mx-auto h-[70px] w-[113px] bg-[url(https://static.catherine-fc.com/media/twitch.png)] bg-contain [background-repeat:no-repeat]">
<a
className="block h-[70px] w-[113px]"
href="https://www.twitch.tv/catherine_faito_crab"
rel="noopener noreferrer"
target="_blank"
>
&nbsp;
</a>
</div>
</div>
);
}

View File

@@ -0,0 +1,35 @@
import { redirect } from 'next/navigation';
import { PlayersList } from '@/components/PlayersList';
import { getAllPlayersPaged, PLAYERS_PAGE_SIZE } from '@/lib/data';
export const dynamic = 'force-dynamic';
type PageProps = {
searchParams: Promise<{ page?: string }>;
};
export default async function PlayersPage({ searchParams }: PageProps) {
const { page: pageRaw } = await searchParams;
const parsed = parseInt(pageRaw ?? '1', 10);
const page = Number.isFinite(parsed) && parsed >= 1 ? Math.floor(parsed) : 1;
const { players, total } = await getAllPlayersPaged(page);
const totalPages = Math.max(1, Math.ceil(total / PLAYERS_PAGE_SIZE));
if (page > totalPages) {
redirect(totalPages <= 1 ? '/players' : `/players?page=${totalPages}`);
}
return (
<PlayersList
players={players}
pagination={{
page,
total,
pageSize: PLAYERS_PAGE_SIZE,
basePath: '/players',
}}
/>
);
}

View File

@@ -0,0 +1,43 @@
import { notFound, redirect } from 'next/navigation';
import { PlayersList } from '@/components/PlayersList';
import { getPlayersForTournamentPaged, PLAYERS_PAGE_SIZE } from '@/lib/data';
export const dynamic = 'force-dynamic';
type PageProps = {
params: Promise<{ tournament_key: string }>;
searchParams: Promise<{ page?: string }>;
};
export default async function TournamentPlayersPage({ params, searchParams }: PageProps) {
const { tournament_key } = await params;
const { page: pageRaw } = await searchParams;
const parsed = parseInt(pageRaw ?? '1', 10);
const page = Number.isFinite(parsed) && parsed >= 1 ? Math.floor(parsed) : 1;
const result = await getPlayersForTournamentPaged(tournament_key, page);
if (result === null) {
notFound();
}
const { players, total } = result;
const totalPages = Math.max(1, Math.ceil(total / PLAYERS_PAGE_SIZE));
const basePath = `/tournaments/${encodeURIComponent(tournament_key)}/players`;
if (page > totalPages) {
redirect(totalPages <= 1 ? basePath : `${basePath}?page=${totalPages}`);
}
return (
<PlayersList
players={players}
pagination={{
page,
total,
pageSize: PLAYERS_PAGE_SIZE,
basePath,
}}
/>
);
}

View File

@@ -0,0 +1,9 @@
import { mainBody } from '@/lib/siteContentClasses';
export default function ScoreboardPage() {
return (
<div className={mainBody}>
<div className="aspect-[567/485] w-full max-w-[567px] bg-[url(https://static.catherine-fc.com/media/scoreboard.png)] bg-contain bg-center [background-repeat:no-repeat]" />
</div>
);
}

View File

@@ -0,0 +1,7 @@
export default function TournamentsPage() {
return (
<div>
<p>Tournaments</p>
</div>
);
}

View File

@@ -0,0 +1,158 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import Link from 'next/link';
import {
btn,
btnDanger,
card,
error as errorClass,
formWide,
hr,
input,
label,
rowActions,
sectionBlock,
sectionTitle,
title,
} from '@/lib/adminUi';
type ArchiveRow = {
id: number;
title: string;
youtube_id: string;
};
export function ArchiveAdminClient() {
const [items, setItems] = useState<ArchiveRow[]>([]);
const [error, setError] = useState('');
const [loading, setLoading] = useState(true);
const [titleVal, setTitleVal] = useState('');
const [youtubeId, setYoutubeId] = useState('');
const load = useCallback(async () => {
setError('');
const res = await fetch('/api/admin/archive', { credentials: 'include' });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError((data as { error?: string }).error ?? 'Failed to load');
setLoading(false);
return;
}
setItems((data as { items: ArchiveRow[] }).items ?? []);
setLoading(false);
}, []);
useEffect(() => {
load();
}, [load]);
function updateLocal(id: number, patch: Partial<ArchiveRow>) {
setItems((prev) => prev.map((r) => (r.id === id ? { ...r, ...patch } : r)));
}
async function save(row: ArchiveRow) {
setError('');
const res = await fetch(`/api/admin/archive/${row.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ title: row.title, youtube_id: row.youtube_id }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError((data as { error?: string }).error ?? 'Save failed');
return;
}
await load();
}
async function remove(id: number) {
if (!window.confirm('Delete?')) return;
setError('');
const res = await fetch(`/api/admin/archive/${id}`, { method: 'DELETE', credentials: 'include' });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setError((data as { error?: string }).error ?? 'Delete failed');
return;
}
await load();
}
async function add(e: React.FormEvent) {
e.preventDefault();
setError('');
const res = await fetch('/api/admin/archive', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ title: titleVal, youtube_id: youtubeId }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError((data as { error?: string }).error ?? 'Create failed');
return;
}
setTitleVal('');
setYoutubeId('');
await load();
}
return (
<div className={card}>
<p>
<Link href="/admin"> Dashboard</Link>
</p>
<h1 className={title}>Archive</h1>
{error ? <p className={errorClass}>{error}</p> : null}
{loading ? <p>Loading</p> : null}
{!loading &&
items.map((row) => (
<div key={row.id} className={sectionBlock}>
<label className={label}>
title
<input
className={input}
value={row.title}
onChange={(e) => updateLocal(row.id, { title: e.target.value })}
/>
</label>
<label className={label}>
youtube_id
<input
className={input}
value={row.youtube_id}
onChange={(e) => updateLocal(row.id, { youtube_id: e.target.value })}
/>
</label>
<div className={rowActions}>
<button type="button" className={btn} onClick={() => save(row)}>
Save
</button>
<button type="button" className={btnDanger} onClick={() => remove(row.id)}>
Delete
</button>
</div>
</div>
))}
<hr className={hr} />
<h2 className={sectionTitle}>New entry</h2>
<form className={formWide} onSubmit={add}>
<label className={label}>
title
<input className={input} value={titleVal} onChange={(e) => setTitleVal(e.target.value)} required />
</label>
<label className={label}>
youtube_id
<input className={input} value={youtubeId} onChange={(e) => setYoutubeId(e.target.value)} required />
</label>
<button className={btn} type="submit">
Add
</button>
</form>
</div>
);
}

View File

@@ -0,0 +1,8 @@
import { requireAdmin } from '@/lib/auth/requireAdmin';
import { ArchiveAdminClient } from './ArchiveAdminClient';
export default async function AdminArchivePage() {
await requireAdmin();
return <ArchiveAdminClient />;
}

View File

@@ -0,0 +1,180 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import Link from 'next/link';
import {
btn,
btnDanger,
card,
error as errorClass,
formWide,
hr,
input,
label,
rowActions,
sectionBlock,
sectionTitle,
textarea,
title,
} from '@/lib/adminUi';
type GuideRow = {
id: number;
title: string;
description: string | null;
youtube_id: string;
};
const field = `${input} ${textarea}`;
export function GuideAdminClient() {
const [items, setItems] = useState<GuideRow[]>([]);
const [error, setError] = useState('');
const [loading, setLoading] = useState(true);
const [titleVal, setTitleVal] = useState('');
const [description, setDescription] = useState('');
const [youtubeId, setYoutubeId] = useState('');
const load = useCallback(async () => {
setError('');
const res = await fetch('/api/admin/guide', { credentials: 'include' });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError((data as { error?: string }).error ?? 'Failed to load');
setLoading(false);
return;
}
setItems((data as { items: GuideRow[] }).items ?? []);
setLoading(false);
}, []);
useEffect(() => {
load();
}, [load]);
function updateLocal(id: number, patch: Partial<GuideRow>) {
setItems((prev) => prev.map((r) => (r.id === id ? { ...r, ...patch } : r)));
}
async function save(row: GuideRow) {
setError('');
const res = await fetch(`/api/admin/guide/${row.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
title: row.title,
description: row.description ?? '',
youtube_id: row.youtube_id,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError((data as { error?: string }).error ?? 'Save failed');
return;
}
await load();
}
async function remove(id: number) {
if (!window.confirm('Delete this entry?')) return;
setError('');
const res = await fetch(`/api/admin/guide/${id}`, { method: 'DELETE', credentials: 'include' });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setError((data as { error?: string }).error ?? 'Delete failed');
return;
}
await load();
}
async function add(e: React.FormEvent) {
e.preventDefault();
setError('');
const res = await fetch('/api/admin/guide', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ title: titleVal, description, youtube_id: youtubeId }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError((data as { error?: string }).error ?? 'Create failed');
return;
}
setTitleVal('');
setDescription('');
setYoutubeId('');
await load();
}
return (
<div className={card}>
<p>
<Link href="/admin"> Dashboard</Link>
</p>
<h1 className={title}>Guide</h1>
{error ? <p className={errorClass}>{error}</p> : null}
{loading ? <p>Loading</p> : null}
{!loading &&
items.map((row) => (
<div key={row.id} className={sectionBlock}>
<label className={label}>
title
<input
className={input}
value={row.title}
onChange={(e) => updateLocal(row.id, { title: e.target.value })}
/>
</label>
<label className={label}>
description
<textarea
className={field}
value={row.description ?? ''}
onChange={(e) => updateLocal(row.id, { description: e.target.value })}
/>
</label>
<label className={label}>
youtube_id
<input
className={input}
value={row.youtube_id}
onChange={(e) => updateLocal(row.id, { youtube_id: e.target.value })}
/>
</label>
<div className={rowActions}>
<button type="button" className={btn} onClick={() => save(row)}>
Save
</button>
<button type="button" className={btnDanger} onClick={() => remove(row.id)}>
Delete
</button>
</div>
</div>
))}
<hr className={hr} />
<h2 className={sectionTitle}>New entry</h2>
<form className={formWide} onSubmit={add}>
<label className={label}>
title
<input className={input} value={titleVal} onChange={(e) => setTitleVal(e.target.value)} required />
</label>
<label className={label}>
description
<textarea className={field} value={description} onChange={(e) => setDescription(e.target.value)} />
</label>
<label className={label}>
youtube_id
<input className={input} value={youtubeId} onChange={(e) => setYoutubeId(e.target.value)} required />
</label>
<button className={btn} type="submit">
Add
</button>
</form>
</div>
);
}

View File

@@ -0,0 +1,8 @@
import { requireAdmin } from '@/lib/auth/requireAdmin';
import { GuideAdminClient } from './GuideAdminClient';
export default async function AdminGuidePage() {
await requireAdmin();
return <GuideAdminClient />;
}

View File

@@ -0,0 +1,8 @@
import type { ReactNode } from 'react';
import { requireLogin } from '@/lib/auth/requireAdmin';
export default async function AdminDashboardLayout({ children }: { children: ReactNode }) {
await requireLogin();
return <>{children}</>;
}

View File

@@ -0,0 +1,59 @@
import Link from 'next/link';
import { LogoutButton } from '@/components/admin/LogoutButton';
import { card, error, sub, title } from '@/lib/adminUi';
import { getSessionUser } from '@/lib/auth/session';
export default async function AdminDashboardPage() {
const user = await getSessionUser();
if (!user) {
return null;
}
return (
<div className={card}>
<h1 className={title}>Admin</h1>
<p className={sub}>
Signed in as <strong>{user.email}</strong>
{user.is_admin ? ' (administrator)' : ''}
</p>
{!user.is_admin && (
<p className={error}>
You do not have permission to edit content. Ask an administrator to grant admin access.
</p>
)}
{user.is_admin && (
<ul className="leading-relaxed">
<li>
<Link className="text-blue-600 hover:underline" href="/admin/users">
Users (approval &amp; roles)
</Link>
</li>
<li>
<Link className="text-blue-600 hover:underline" href="/admin/players">
Players
</Link>
</li>
<li>
<Link className="text-blue-600 hover:underline" href="/admin/guide">
Guide
</Link>
</li>
<li>
<Link className="text-blue-600 hover:underline" href="/admin/archive">
Archive
</Link>
</li>
<li>
<Link className="text-blue-600 hover:underline" href="/admin/qa">
Q&amp;A
</Link>
</li>
</ul>
)}
<div className="mt-4">
<LogoutButton />
</div>
</div>
);
}

View File

@@ -0,0 +1,266 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import Link from 'next/link';
import {
btn,
btnDanger,
card,
error as errorClass,
formWide,
hr,
input,
label,
metaLine,
rowActions,
sectionBlock,
sectionTitle,
textarea,
title,
} from '@/lib/adminUi';
type Char = { id: number; name_id: string; name: string; name_jp: string };
type PlayerRow = {
id: number;
player_key: string;
player_name: string;
description: string | null;
image: string | null;
character_id: string | null;
name_id?: string;
name_jp?: string;
};
const field = `${input} ${textarea}`;
export function PlayersAdminClient() {
const [players, setPlayers] = useState<PlayerRow[]>([]);
const [characters, setCharacters] = useState<Char[]>([]);
const [error, setError] = useState('');
const [loading, setLoading] = useState(true);
const [playerKey, setPlayerKey] = useState('');
const [playerName, setPlayerName] = useState('');
const [description, setDescription] = useState('');
const [image, setImage] = useState('');
const [characterId, setCharacterId] = useState('');
const load = useCallback(async () => {
setError('');
const [pr, cr] = await Promise.all([
fetch('/api/admin/players', { credentials: 'include' }),
fetch('/api/admin/characters', { credentials: 'include' }),
]);
const pj = await pr.json().catch(() => ({}));
const cj = await cr.json().catch(() => ({}));
if (!pr.ok) {
setError((pj as { error?: string }).error ?? 'Failed to load players');
setLoading(false);
return;
}
if (!cr.ok) {
setError((cj as { error?: string }).error ?? 'Failed to load characters');
setLoading(false);
return;
}
setPlayers((pj as { players: PlayerRow[] }).players ?? []);
setCharacters((cj as { characters: Char[] }).characters ?? []);
setLoading(false);
}, []);
useEffect(() => {
load();
}, [load]);
async function addPlayer(e: React.FormEvent) {
e.preventDefault();
setError('');
const res = await fetch('/api/admin/players', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
player_key: playerKey,
player_name: playerName,
description,
image: image || null,
character_id: characterId,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError((data as { error?: string }).error ?? 'Create failed');
return;
}
setPlayerKey('');
setPlayerName('');
setDescription('');
setImage('');
setCharacterId('');
await load();
}
function updateLocal(id: number, patch: Partial<PlayerRow>) {
setPlayers((prev) => prev.map((r) => (r.id === id ? { ...r, ...patch } : r)));
}
async function save(row: PlayerRow) {
setError('');
const cid = row.character_id != null && row.character_id !== '' ? String(row.character_id) : '';
if (!cid) {
setError('Character is required');
return;
}
const res = await fetch(`/api/admin/players/${row.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
player_key: row.player_key,
player_name: row.player_name,
description: row.description ?? '',
image: row.image === '' || row.image == null ? null : row.image,
character_id: cid,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError((data as { error?: string }).error ?? 'Save failed');
return;
}
await load();
}
async function remove(id: number) {
if (!window.confirm('Delete this player?')) return;
setError('');
const res = await fetch(`/api/admin/players/${id}`, { method: 'DELETE', credentials: 'include' });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError((data as { error?: string }).error ?? 'Delete failed');
return;
}
await load();
}
return (
<div className={card}>
<p>
<Link href="/admin"> Dashboard</Link>
</p>
<h1 className={title}>Players</h1>
{error ? <p className={errorClass}>{error}</p> : null}
{loading ? <p>Loading</p> : null}
{!loading && (
<>
{players.map((p) => (
<div key={p.id} className={sectionBlock}>
<p className={metaLine}>
id: {p.id}
{p.name_jp ? ` · ${p.name_jp}` : ''}
</p>
<label className={label}>
player_key
<input
className={input}
value={p.player_key}
onChange={(e) => updateLocal(p.id, { player_key: e.target.value })}
/>
</label>
<label className={label}>
player_name
<input
className={input}
value={p.player_name}
onChange={(e) => updateLocal(p.id, { player_name: e.target.value })}
/>
</label>
<label className={label}>
description
<textarea
className={field}
value={p.description ?? ''}
onChange={(e) => updateLocal(p.id, { description: e.target.value })}
/>
</label>
<label className={label}>
image URL (optional)
<input
className={input}
value={p.image ?? ''}
onChange={(e) => updateLocal(p.id, { image: e.target.value || null })}
/>
</label>
<label className={label}>
character
<select
className={input}
value={p.character_id != null && p.character_id !== '' ? String(p.character_id) : ''}
onChange={(e) => updateLocal(p.id, { character_id: e.target.value })}
required
>
<option value=""></option>
{characters.map((c) => (
<option key={c.id} value={c.id}>
{c.name_jp} ({c.name_id})
</option>
))}
</select>
</label>
<div className={rowActions}>
<button type="button" className={btn} onClick={() => save(p)}>
Save
</button>
<button type="button" className={btnDanger} onClick={() => remove(p.id)}>
Delete
</button>
</div>
</div>
))}
<hr className={hr} />
<h2 className={sectionTitle}>Add player</h2>
<form className={formWide} onSubmit={addPlayer}>
<label className={label}>
player_key
<input className={input} value={playerKey} onChange={(e) => setPlayerKey(e.target.value)} required />
</label>
<label className={label}>
player_name
<input className={input} value={playerName} onChange={(e) => setPlayerName(e.target.value)} required />
</label>
<label className={label}>
description
<textarea className={field} value={description} onChange={(e) => setDescription(e.target.value)} />
</label>
<label className={label}>
image URL (optional)
<input className={input} value={image} onChange={(e) => setImage(e.target.value)} />
</label>
<label className={label}>
character
<select
className={input}
value={characterId}
onChange={(e) => setCharacterId(e.target.value)}
required
>
<option value=""></option>
{characters.map((c) => (
<option key={c.id} value={c.id}>
{c.name_jp} ({c.name_id})
</option>
))}
</select>
</label>
<button className={btn} type="submit">
Add
</button>
</form>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,8 @@
import { requireAdmin } from '@/lib/auth/requireAdmin';
import { PlayersAdminClient } from './PlayersAdminClient';
export default async function AdminPlayersPage() {
await requireAdmin();
return <PlayersAdminClient />;
}

View File

@@ -0,0 +1,171 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import Link from 'next/link';
import {
btn,
btnDanger,
card,
error as errorClass,
formWide,
hr,
input,
label,
rowActions,
sectionBlock,
sectionTitle,
textarea,
title,
} from '@/lib/adminUi';
type ContactRow = {
id: number;
question: string;
answer: string;
};
const field = `${input} ${textarea}`;
export function QaAdminClient() {
const [items, setItems] = useState<ContactRow[]>([]);
const [error, setError] = useState('');
const [loading, setLoading] = useState(true);
const [question, setQuestion] = useState('');
const [answer, setAnswer] = useState('');
const load = useCallback(async () => {
setError('');
const res = await fetch('/api/admin/contact', { credentials: 'include' });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError((data as { error?: string }).error ?? 'Failed to load');
setLoading(false);
return;
}
setItems((data as { items: ContactRow[] }).items ?? []);
setLoading(false);
}, []);
useEffect(() => {
load();
}, [load]);
function updateLocal(id: number, patch: Partial<ContactRow>) {
setItems((prev) => prev.map((r) => (r.id === id ? { ...r, ...patch } : r)));
}
async function save(row: ContactRow) {
setError('');
const res = await fetch(`/api/admin/contact/${row.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ question: row.question, answer: row.answer }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError((data as { error?: string }).error ?? 'Save failed');
return;
}
await load();
}
async function remove(id: number) {
if (!window.confirm('Delete?')) return;
setError('');
const res = await fetch(`/api/admin/contact/${id}`, { method: 'DELETE', credentials: 'include' });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setError((data as { error?: string }).error ?? 'Delete failed');
return;
}
await load();
}
async function add(e: React.FormEvent) {
e.preventDefault();
setError('');
const res = await fetch('/api/admin/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ question, answer }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError((data as { error?: string }).error ?? 'Create failed');
return;
}
setQuestion('');
setAnswer('');
await load();
}
return (
<div className={card}>
<p>
<Link href="/admin"> Dashboard</Link>
</p>
<h1 className={title}>Q&amp;A</h1>
{error ? <p className={errorClass}>{error}</p> : null}
{loading ? <p>Loading</p> : null}
{!loading &&
items.map((row) => (
<div key={row.id} className={sectionBlock}>
<label className={label}>
question
<textarea
className={field}
value={row.question}
onChange={(e) => updateLocal(row.id, { question: e.target.value })}
/>
</label>
<label className={label}>
answer
<textarea
className={field}
value={row.answer}
onChange={(e) => updateLocal(row.id, { answer: e.target.value })}
/>
</label>
<div className={rowActions}>
<button type="button" className={btn} onClick={() => save(row)}>
Save
</button>
<button type="button" className={btnDanger} onClick={() => remove(row.id)}>
Delete
</button>
</div>
</div>
))}
<hr className={hr} />
<h2 className={sectionTitle}>New Q&amp;A</h2>
<form className={formWide} onSubmit={add}>
<label className={label}>
question
<textarea
className={field}
value={question}
onChange={(e) => setQuestion(e.target.value)}
required
/>
</label>
<label className={label}>
answer
<textarea
className={field}
value={answer}
onChange={(e) => setAnswer(e.target.value)}
required
/>
</label>
<button className={btn} type="submit">
Add
</button>
</form>
</div>
);
}

View File

@@ -0,0 +1,8 @@
import { requireAdmin } from '@/lib/auth/requireAdmin';
import { QaAdminClient } from './QaAdminClient';
export default async function AdminQaPage() {
await requireAdmin();
return <QaAdminClient />;
}

View File

@@ -0,0 +1,100 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import Link from 'next/link';
import { card, error as errorClass, sub, table, th, thTd, title } from '@/lib/adminUi';
type UserRow = {
id: number;
email: string;
is_approved: number | boolean;
is_admin: number | boolean;
created_at: string;
};
export function UsersAdminClient() {
const [users, setUsers] = useState<UserRow[]>([]);
const [error, setError] = useState('');
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
setError('');
const res = await fetch('/api/admin/users', { credentials: 'include' });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError((data as { error?: string }).error ?? 'Failed to load');
setLoading(false);
return;
}
setUsers((data as { users: UserRow[] }).users ?? []);
setLoading(false);
}, []);
useEffect(() => {
load();
}, [load]);
async function patch(id: number, body: { is_approved?: boolean; is_admin?: boolean }) {
setError('');
const res = await fetch(`/api/admin/users/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(body),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError((data as { error?: string }).error ?? 'Update failed');
return;
}
await load();
}
return (
<div className={card}>
<p>
<Link href="/admin"> Dashboard</Link>
</p>
<h1 className={title}>Users</h1>
<p className={sub}>Approve new accounts and grant admin role.</p>
{error ? <p className={errorClass}>{error}</p> : null}
{loading ? <p>Loading</p> : null}
{!loading && (
<table className={table}>
<thead>
<tr>
<th className={`${thTd} ${th}`}>Email</th>
<th className={`${thTd} ${th}`}>Approved</th>
<th className={`${thTd} ${th}`}>Admin</th>
<th className={`${thTd} ${th}`}>Created</th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id}>
<td className={thTd}>{u.email}</td>
<td className={thTd}>
<input
type="checkbox"
checked={Boolean(u.is_approved)}
onChange={(e) => patch(u.id, { is_approved: e.target.checked })}
/>
</td>
<td className={thTd}>
<input
type="checkbox"
checked={Boolean(u.is_admin)}
onChange={(e) => patch(u.id, { is_admin: e.target.checked })}
/>
</td>
<td className={thTd}>{String(u.created_at)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
}

View File

@@ -0,0 +1,8 @@
import { requireAdmin } from '@/lib/auth/requireAdmin';
import { UsersAdminClient } from './UsersAdminClient';
export default async function AdminUsersPage() {
await requireAdmin();
return <UsersAdminClient />;
}

View File

@@ -0,0 +1,33 @@
import type { Metadata } from 'next';
import type { ReactNode } from 'react';
import Link from 'next/link';
import { nav, navLink, wrap } from '@/lib/adminUi';
export const metadata: Metadata = {
title: 'Admin — Catherine League',
robots: 'noindex, nofollow',
};
export default function AdminRootLayout({ children }: { children: ReactNode }) {
return (
<div className={wrap}>
<nav className={nav}>
<Link className={navLink} href="/">
Site
</Link>
<Link className={navLink} href="/admin/login">
Login
</Link>
<Link className={navLink} href="/admin/register">
Register
</Link>
<Link className={navLink} href="/admin">
Dashboard
</Link>
</nav>
{children}
</div>
);
}

View File

@@ -0,0 +1,78 @@
'use client';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import Link from 'next/link';
import { btn, card, error as errorClass, form, input, label, sub, title } from '@/lib/adminUi';
export default function AdminLoginPage() {
const router = useRouter();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
setError('');
setLoading(true);
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
credentials: 'include',
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError((data as { error?: string }).error ?? 'Login failed');
setLoading(false);
return;
}
router.push('/admin');
router.refresh();
} catch {
setError('Network error');
}
setLoading(false);
}
return (
<div className={card}>
<h1 className={title}>Admin login</h1>
<p className={sub}>
No account? <Link href="/admin/register">Register</Link> an administrator must approve you before you can sign in.
</p>
<form className={form} onSubmit={onSubmit}>
<label className={label}>
Email
<input
className={input}
type="email"
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</label>
<label className={label}>
Password
<input
className={input}
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</label>
{error ? <p className={errorClass}>{error}</p> : null}
<button className={btn} type="submit" disabled={loading}>
{loading ? 'Signing in…' : 'Sign in'}
</button>
</form>
</div>
);
}

View File

@@ -0,0 +1,18 @@
import Link from 'next/link';
import { card, sub, title } from '@/lib/adminUi';
export default function AdminNoAccessPage() {
return (
<div className={card}>
<h1 className={title}>No admin access</h1>
<p className={sub}>
Your account is approved, but you do not have administrator privileges for content management. Ask an existing admin to
grant you the admin role.
</p>
<p>
<Link href="/admin">Dashboard</Link>
</p>
</div>
);
}

View File

@@ -0,0 +1,18 @@
import Link from 'next/link';
import { card, sub, title } from '@/lib/adminUi';
export default function AdminPendingPage() {
return (
<div className={card}>
<h1 className={title}>Account pending</h1>
<p className={sub}>
Your account has not been approved yet, or approval was revoked. You cannot use the admin area until an administrator
approves you.
</p>
<p>
<Link href="/admin/login">Back to login</Link>
</p>
</div>
);
}

View File

@@ -0,0 +1,81 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { btn, card, error as errorClass, form, input, label, sub, success as successClass, title } from '@/lib/adminUi';
export default function AdminRegisterPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [loading, setLoading] = useState(false);
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
setError('');
setSuccess('');
setLoading(true);
try {
const res = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError((data as { error?: string }).error ?? 'Registration failed');
setLoading(false);
return;
}
setSuccess((data as { message?: string }).message ?? 'Registered.');
setEmail('');
setPassword('');
} catch {
setError('Network error');
}
setLoading(false);
}
return (
<div className={card}>
<h1 className={title}>Register</h1>
<p className={sub}>
After registering, wait for an administrator to approve your account. Then you can{' '}
<Link href="/admin/login">sign in</Link>.
</p>
<form className={form} onSubmit={onSubmit}>
<label className={label}>
Email
<input
className={input}
type="email"
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</label>
<label className={label}>
Password (min. 8 characters)
<input
className={input}
type="password"
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
minLength={8}
required
/>
</label>
{error ? <p className={errorClass}>{error}</p> : null}
{success ? <p className={successClass}>{success}</p> : null}
<button className={btn} type="submit" disabled={loading}>
{loading ? 'Submitting…' : 'Register'}
</button>
</form>
</div>
);
}

View File

@@ -0,0 +1,63 @@
import { NextResponse } from 'next/server';
import type { RowDataPacket } from 'mysql2';
import { requireAdminApi } from '@/lib/auth/apiAuth';
import { getPool } from '@/lib/db';
export const dynamic = 'force-dynamic';
type Ctx = { params: Promise<{ id: string }> };
export async function PATCH(request: Request, context: Ctx) {
const auth = await requireAdminApi();
if (!auth.ok) {
return auth.response;
}
const { id: idParam } = await context.params;
const id = parseInt(idParam, 10);
if (Number.isNaN(id)) {
return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
}
const body = await request.json();
const updates: string[] = [];
const values: unknown[] = [];
if (body.title !== undefined) {
updates.push('title = ?');
values.push(String(body.title).trim());
}
if (body.youtube_id !== undefined) {
updates.push('youtube_id = ?');
values.push(String(body.youtube_id).trim());
}
if (updates.length === 0) {
return NextResponse.json({ error: 'No fields to update' }, { status: 400 });
}
values.push(id);
const pool = getPool();
await pool.query(`UPDATE archive SET ${updates.join(', ')} WHERE id = ?`, values);
const [rows] = await pool.query<RowDataPacket[]>('SELECT * FROM archive WHERE id = ?', [id]);
return NextResponse.json({ item: rows[0] });
}
export async function DELETE(_request: Request, context: Ctx) {
const auth = await requireAdminApi();
if (!auth.ok) {
return auth.response;
}
const { id: idParam } = await context.params;
const id = parseInt(idParam, 10);
if (Number.isNaN(id)) {
return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
}
const pool = getPool();
await pool.query('DELETE FROM archive WHERE id = ?', [id]);
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,37 @@
import { NextResponse } from 'next/server';
import type { RowDataPacket } from 'mysql2';
import { requireAdminApi } from '@/lib/auth/apiAuth';
import { getPool } from '@/lib/db';
export const dynamic = 'force-dynamic';
export async function GET() {
const auth = await requireAdminApi();
if (!auth.ok) {
return auth.response;
}
const pool = getPool();
const [rows] = await pool.query<RowDataPacket[]>('SELECT * FROM archive ORDER BY id ASC');
return NextResponse.json({ items: rows });
}
export async function POST(request: Request) {
const auth = await requireAdminApi();
if (!auth.ok) {
return auth.response;
}
const body = await request.json();
const title = String(body.title ?? '').trim();
const youtubeId = String(body.youtube_id ?? '').trim();
if (!title || !youtubeId) {
return NextResponse.json({ error: 'title and youtube_id required' }, { status: 400 });
}
const pool = getPool();
await pool.query('INSERT INTO archive (title, youtube_id) VALUES (?, ?)', [title, youtubeId]);
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,20 @@
import { NextResponse } from 'next/server';
import type { RowDataPacket } from 'mysql2';
import { requireAdminApi } from '@/lib/auth/apiAuth';
import { getPool } from '@/lib/db';
export const dynamic = 'force-dynamic';
export async function GET() {
const auth = await requireAdminApi();
if (!auth.ok) {
return auth.response;
}
const pool = getPool();
const [rows] = await pool.query<RowDataPacket[]>(
'SELECT id, name_id, name, name_jp FROM characters ORDER BY id ASC'
);
return NextResponse.json({ characters: rows });
}

View File

@@ -0,0 +1,63 @@
import { NextResponse } from 'next/server';
import type { RowDataPacket } from 'mysql2';
import { requireAdminApi } from '@/lib/auth/apiAuth';
import { getPool } from '@/lib/db';
export const dynamic = 'force-dynamic';
type Ctx = { params: Promise<{ id: string }> };
export async function PATCH(request: Request, context: Ctx) {
const auth = await requireAdminApi();
if (!auth.ok) {
return auth.response;
}
const { id: idParam } = await context.params;
const id = parseInt(idParam, 10);
if (Number.isNaN(id)) {
return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
}
const body = await request.json();
const updates: string[] = [];
const values: unknown[] = [];
if (body.question !== undefined) {
updates.push('question = ?');
values.push(String(body.question).trim());
}
if (body.answer !== undefined) {
updates.push('answer = ?');
values.push(String(body.answer).trim());
}
if (updates.length === 0) {
return NextResponse.json({ error: 'No fields to update' }, { status: 400 });
}
values.push(id);
const pool = getPool();
await pool.query(`UPDATE contact SET ${updates.join(', ')} WHERE id = ?`, values);
const [rows] = await pool.query<RowDataPacket[]>('SELECT * FROM contact WHERE id = ?', [id]);
return NextResponse.json({ item: rows[0] });
}
export async function DELETE(_request: Request, context: Ctx) {
const auth = await requireAdminApi();
if (!auth.ok) {
return auth.response;
}
const { id: idParam } = await context.params;
const id = parseInt(idParam, 10);
if (Number.isNaN(id)) {
return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
}
const pool = getPool();
await pool.query('DELETE FROM contact WHERE id = ?', [id]);
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,37 @@
import { NextResponse } from 'next/server';
import type { RowDataPacket } from 'mysql2';
import { requireAdminApi } from '@/lib/auth/apiAuth';
import { getPool } from '@/lib/db';
export const dynamic = 'force-dynamic';
export async function GET() {
const auth = await requireAdminApi();
if (!auth.ok) {
return auth.response;
}
const pool = getPool();
const [rows] = await pool.query<RowDataPacket[]>('SELECT * FROM contact ORDER BY id ASC');
return NextResponse.json({ items: rows });
}
export async function POST(request: Request) {
const auth = await requireAdminApi();
if (!auth.ok) {
return auth.response;
}
const body = await request.json();
const question = String(body.question ?? '').trim();
const answer = String(body.answer ?? '').trim();
if (!question || !answer) {
return NextResponse.json({ error: 'question and answer required' }, { status: 400 });
}
const pool = getPool();
await pool.query('INSERT INTO contact (question, answer) VALUES (?, ?)', [question, answer]);
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,67 @@
import { NextResponse } from 'next/server';
import type { RowDataPacket } from 'mysql2';
import { requireAdminApi } from '@/lib/auth/apiAuth';
import { getPool } from '@/lib/db';
export const dynamic = 'force-dynamic';
type Ctx = { params: Promise<{ id: string }> };
export async function PATCH(request: Request, context: Ctx) {
const auth = await requireAdminApi();
if (!auth.ok) {
return auth.response;
}
const { id: idParam } = await context.params;
const id = parseInt(idParam, 10);
if (Number.isNaN(id)) {
return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
}
const body = await request.json();
const updates: string[] = [];
const values: unknown[] = [];
if (body.title !== undefined) {
updates.push('title = ?');
values.push(String(body.title).trim());
}
if (body.description !== undefined) {
updates.push('description = ?');
values.push(String(body.description));
}
if (body.youtube_id !== undefined) {
updates.push('youtube_id = ?');
values.push(String(body.youtube_id).trim());
}
if (updates.length === 0) {
return NextResponse.json({ error: 'No fields to update' }, { status: 400 });
}
values.push(id);
const pool = getPool();
await pool.query(`UPDATE guide SET ${updates.join(', ')} WHERE id = ?`, values);
const [rows] = await pool.query<RowDataPacket[]>('SELECT * FROM guide WHERE id = ?', [id]);
return NextResponse.json({ item: rows[0] });
}
export async function DELETE(_request: Request, context: Ctx) {
const auth = await requireAdminApi();
if (!auth.ok) {
return auth.response;
}
const { id: idParam } = await context.params;
const id = parseInt(idParam, 10);
if (Number.isNaN(id)) {
return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
}
const pool = getPool();
await pool.query('DELETE FROM guide WHERE id = ?', [id]);
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,41 @@
import { NextResponse } from 'next/server';
import type { RowDataPacket } from 'mysql2';
import { requireAdminApi } from '@/lib/auth/apiAuth';
import { getPool } from '@/lib/db';
export const dynamic = 'force-dynamic';
export async function GET() {
const auth = await requireAdminApi();
if (!auth.ok) {
return auth.response;
}
const pool = getPool();
const [rows] = await pool.query<RowDataPacket[]>('SELECT * FROM guide ORDER BY id ASC');
return NextResponse.json({ items: rows });
}
export async function POST(request: Request) {
const auth = await requireAdminApi();
if (!auth.ok) {
return auth.response;
}
const body = await request.json();
const title = String(body.title ?? '').trim();
const description = String(body.description ?? '');
const youtubeId = String(body.youtube_id ?? '').trim();
if (!title || !youtubeId) {
return NextResponse.json({ error: 'title and youtube_id required' }, { status: 400 });
}
const pool = getPool();
await pool.query(
'INSERT INTO guide (title, description, youtube_id) VALUES (?, ?, ?)',
[title, description, youtubeId]
);
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,124 @@
import { NextResponse } from 'next/server';
import type { RowDataPacket } from 'mysql2';
import { requireAdminApi } from '@/lib/auth/apiAuth';
import { getPool } from '@/lib/db';
export const dynamic = 'force-dynamic';
type Ctx = { params: Promise<{ id: string }> };
export async function GET(_request: Request, context: Ctx) {
const auth = await requireAdminApi();
if (!auth.ok) {
return auth.response;
}
const { id: idParam } = await context.params;
const id = parseInt(idParam, 10);
if (Number.isNaN(id)) {
return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
}
const pool = getPool();
const [rows] = await pool.query<RowDataPacket[]>(
`SELECT p.id, p.player_key, p.player_name, p.description, p.image, p.character_id,
c.name_id, c.name, c.name_jp
FROM players p
LEFT JOIN characters c ON c.id = p.character_id
WHERE p.id = ?`,
[id]
);
if (rows.length === 0) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
return NextResponse.json({ player: rows[0] });
}
export async function PATCH(request: Request, context: Ctx) {
const auth = await requireAdminApi();
if (!auth.ok) {
return auth.response;
}
const { id: idParam } = await context.params;
const id = parseInt(idParam, 10);
if (Number.isNaN(id)) {
return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
}
const body = await request.json();
const updates: string[] = [];
const values: unknown[] = [];
if (body.player_key !== undefined) {
updates.push('player_key = ?');
values.push(String(body.player_key).trim());
}
if (body.player_name !== undefined) {
updates.push('player_name = ?');
values.push(String(body.player_name).trim());
}
if (body.description !== undefined) {
updates.push('description = ?');
values.push(String(body.description));
}
if (body.image !== undefined) {
updates.push('image = ?');
values.push(body.image == null ? null : String(body.image));
}
if (body.character_id !== undefined) {
updates.push('character_id = ?');
values.push(String(parseInt(String(body.character_id), 10)));
}
if (updates.length === 0) {
return NextResponse.json({ error: 'No fields to update' }, { status: 400 });
}
values.push(id);
const pool = getPool();
await pool.query(`UPDATE players SET ${updates.join(', ')} WHERE id = ?`, values);
const [rows] = await pool.query<RowDataPacket[]>(
`SELECT p.id, p.player_key, p.player_name, p.description, p.image, p.character_id,
c.name_id, c.name, c.name_jp
FROM players p
LEFT JOIN characters c ON c.id = p.character_id
WHERE p.id = ?`,
[id]
);
return NextResponse.json({ player: rows[0] });
}
export async function DELETE(_request: Request, context: Ctx) {
const auth = await requireAdminApi();
if (!auth.ok) {
return auth.response;
}
const { id: idParam } = await context.params;
const id = parseInt(idParam, 10);
if (Number.isNaN(id)) {
return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
}
const pool = getPool();
try {
await pool.query('DELETE FROM players WHERE id = ?', [id]);
} catch (e: unknown) {
const code = (e as { errno?: number })?.errno;
if (code === 1451) {
return NextResponse.json(
{ error: 'Cannot delete player: referenced by tournament or other records' },
{ status: 409 }
);
}
throw e;
}
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,52 @@
import { NextResponse } from 'next/server';
import type { RowDataPacket } from 'mysql2';
import { requireAdminApi } from '@/lib/auth/apiAuth';
import { getPool } from '@/lib/db';
export const dynamic = 'force-dynamic';
export async function GET() {
const auth = await requireAdminApi();
if (!auth.ok) {
return auth.response;
}
const pool = getPool();
const [rows] = await pool.query<RowDataPacket[]>(
`SELECT p.id, p.player_key, p.player_name, p.description, p.image, p.character_id,
c.name_id, c.name, c.name_jp
FROM players p
LEFT JOIN characters c ON c.id = p.character_id
ORDER BY p.id ASC`
);
return NextResponse.json({ players: rows });
}
export async function POST(request: Request) {
const auth = await requireAdminApi();
if (!auth.ok) {
return auth.response;
}
const body = await request.json();
const playerKey = String(body.player_key ?? '').trim();
const playerName = String(body.player_name ?? '').trim();
const description = String(body.description ?? '');
const image = body.image != null ? String(body.image) : null;
const characterId = parseInt(String(body.character_id), 10);
if (!playerKey || !playerName || Number.isNaN(characterId)) {
return NextResponse.json({ error: 'player_key, player_name, character_id required' }, { status: 400 });
}
const pool = getPool();
await pool.query(
`INSERT INTO players (player_key, player_name, description, image, character_id)
VALUES (?, ?, ?, ?, ?)`,
[playerKey, playerName, description, image, String(characterId)]
);
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,63 @@
import { NextResponse } from 'next/server';
import type { RowDataPacket } from 'mysql2';
import { requireAdminApi } from '@/lib/auth/apiAuth';
import { getPool } from '@/lib/db';
export const dynamic = 'force-dynamic';
type Ctx = { params: Promise<{ id: string }> };
export async function PATCH(request: Request, context: Ctx) {
const auth = await requireAdminApi();
if (!auth.ok) {
return auth.response;
}
const { id: idParam } = await context.params;
const id = parseInt(idParam, 10);
if (Number.isNaN(id)) {
return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
}
const body = await request.json();
const isApproved = typeof body.is_approved === 'boolean' ? body.is_approved : undefined;
const isAdmin = typeof body.is_admin === 'boolean' ? body.is_admin : undefined;
if (isApproved === undefined && isAdmin === undefined) {
return NextResponse.json({ error: 'No changes' }, { status: 400 });
}
const pool = getPool();
if (id === auth.user.id && isApproved === false) {
return NextResponse.json({ error: 'Cannot revoke your own approval' }, { status: 400 });
}
if (id === auth.user.id && isAdmin === false) {
return NextResponse.json({ error: 'Cannot remove your own admin role' }, { status: 400 });
}
const updates: string[] = [];
const values: unknown[] = [];
if (isApproved !== undefined) {
updates.push('is_approved = ?');
values.push(isApproved ? 1 : 0);
}
if (isAdmin !== undefined) {
updates.push('is_admin = ?');
values.push(isAdmin ? 1 : 0);
}
values.push(id);
await pool.query(
`UPDATE admin_users SET ${updates.join(', ')} WHERE id = ?`,
values
);
const [rows] = await pool.query<RowDataPacket[]>(
'SELECT id, email, is_approved, is_admin, created_at FROM admin_users WHERE id = ?',
[id]
);
return NextResponse.json({ user: rows[0] ?? null });
}

View File

@@ -0,0 +1,23 @@
import { NextResponse } from 'next/server';
import type { RowDataPacket } from 'mysql2';
import { requireAdminApi } from '@/lib/auth/apiAuth';
import { getPool } from '@/lib/db';
export const dynamic = 'force-dynamic';
export async function GET() {
const auth = await requireAdminApi();
if (!auth.ok) {
return auth.response;
}
const pool = getPool();
const [rows] = await pool.query<RowDataPacket[]>(
`SELECT id, email, is_approved, is_admin, created_at
FROM admin_users
ORDER BY created_at DESC`
);
return NextResponse.json({ users: rows });
}

View File

@@ -0,0 +1,15 @@
import { NextResponse } from 'next/server';
import { getArchiveItems } from '@/lib/data';
export const dynamic = 'force-dynamic';
export async function GET() {
try {
const data = await getArchiveItems();
return NextResponse.json(data);
} catch (e) {
console.error(e);
return NextResponse.json([], { status: 500 });
}
}

View File

@@ -0,0 +1,58 @@
import { NextResponse } from 'next/server';
import { verifyPassword } from '@/lib/auth/password';
import { createSession } from '@/lib/auth/session';
import { getPool } from '@/lib/db';
import type { RowDataPacket } from 'mysql2';
export const dynamic = 'force-dynamic';
export async function POST(request: Request) {
try {
const body = await request.json();
const email = typeof body.email === 'string' ? body.email.trim().toLowerCase() : '';
const password = typeof body.password === 'string' ? body.password : '';
if (!email || !password) {
return NextResponse.json({ error: 'Email and password required' }, { status: 400 });
}
const pool = getPool();
const [rows] = await pool.query<RowDataPacket[]>(
'SELECT id, email, password_hash, is_approved, is_admin FROM admin_users WHERE email = ?',
[email]
);
if (rows.length === 0) {
return NextResponse.json({ error: 'Invalid email or password' }, { status: 401 });
}
const row = rows[0];
const ok = verifyPassword(password, row.password_hash as string);
if (!ok) {
return NextResponse.json({ error: 'Invalid email or password' }, { status: 401 });
}
if (!row.is_approved) {
return NextResponse.json(
{ error: 'Your account is not approved yet.', code: 'PENDING_APPROVAL' },
{ status: 403 }
);
}
await createSession(row.id as number);
return NextResponse.json({
ok: true,
user: {
id: row.id,
email: row.email,
is_approved: Boolean(row.is_approved),
is_admin: Boolean(row.is_admin),
},
});
} catch (e) {
console.error(e);
return NextResponse.json({ error: 'Login failed' }, { status: 500 });
}
}

View File

@@ -0,0 +1,10 @@
import { NextResponse } from 'next/server';
import { destroySession } from '@/lib/auth/session';
export const dynamic = 'force-dynamic';
export async function POST() {
await destroySession();
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,20 @@
import { NextResponse } from 'next/server';
import { getSessionUser } from '@/lib/auth/session';
export const dynamic = 'force-dynamic';
export async function GET() {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ user: null }, { status: 401 });
}
return NextResponse.json({
user: {
id: user.id,
email: user.email,
is_approved: user.is_approved,
is_admin: user.is_admin,
},
});
}

View File

@@ -0,0 +1,37 @@
import { NextResponse } from 'next/server';
import { hashPassword } from '@/lib/auth/password';
import { getPool } from '@/lib/db';
export const dynamic = 'force-dynamic';
export async function POST(request: Request) {
try {
const body = await request.json();
const email = typeof body.email === 'string' ? body.email.trim().toLowerCase() : '';
const password = typeof body.password === 'string' ? body.password : '';
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return NextResponse.json({ error: 'Valid email is required' }, { status: 400 });
}
if (password.length < 8) {
return NextResponse.json({ error: 'Password must be at least 8 characters' }, { status: 400 });
}
const passwordHash = hashPassword(password);
const pool = getPool();
await pool.query(
`INSERT INTO admin_users (email, password_hash, is_approved, is_admin) VALUES (?, ?, 0, 0)`,
[email, passwordHash]
);
return NextResponse.json({ ok: true, message: 'Registration submitted. Wait for an administrator to approve your account.' });
} catch (e: unknown) {
const code = (e as { code?: string })?.code;
if (code === 'ER_DUP_ENTRY') {
return NextResponse.json({ error: 'Email already registered' }, { status: 409 });
}
console.error(e);
return NextResponse.json({ error: 'Registration failed' }, { status: 500 });
}
}

View File

@@ -0,0 +1,15 @@
import { NextResponse } from 'next/server';
import { getContactItems } from '@/lib/data';
export const dynamic = 'force-dynamic';
export async function GET() {
try {
const data = await getContactItems();
return NextResponse.json(data);
} catch (e) {
console.error(e);
return NextResponse.json([], { status: 500 });
}
}

Some files were not shown because too many files have changed in this diff Show More