いろいろ
This commit is contained in:
parent
f0604043a8
commit
d8664775cc
28 changed files with 510 additions and 63 deletions
14
migration/1633841235323-announcement.ts
Normal file
14
migration/1633841235323-announcement.ts
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
import {MigrationInterface, QueryRunner} from 'typeorm';
|
||||||
|
|
||||||
|
export class Announcement1633841235323 implements MigrationInterface {
|
||||||
|
name = 'Announcement1633841235323'
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query('CREATE TABLE "announcement" ("id" SERIAL NOT NULL, "createdAt" TIMESTAMP NOT NULL, "title" character varying(128) NOT NULL, "body" character varying(8192) NOT NULL, "like" integer NOT NULL DEFAULT 0, CONSTRAINT "PK_e0ef0550174fd1099a308fd18a0" PRIMARY KEY ("id"))');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query('DROP TABLE "announcement"');
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -6,5 +6,5 @@
|
||||||
"src/frontend/*"
|
"src/frontend/*"
|
||||||
],
|
],
|
||||||
"ext": "ts,tsx,pug,scss",
|
"ext": "ts,tsx,pug,scss",
|
||||||
"exec": "run-s build start"
|
"exec": "run-s build:backend start"
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
|
||||||
const entities = require('./built/services/db').entities;
|
const entities = require('./built/backend/services/db').entities;
|
||||||
|
|
||||||
const config = Object.freeze(JSON.parse(fs.readFileSync(__dirname + '/config.json', 'utf-8')));
|
const config = Object.freeze(JSON.parse(fs.readFileSync(__dirname + '/config.json', 'utf-8')));
|
||||||
|
|
||||||
|
|
|
@ -8,7 +8,8 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "run-p build:*",
|
"build": "run-p build:*",
|
||||||
"build:frontend": "webpack",
|
"build:frontend": "webpack",
|
||||||
"build:backend": "tsc",
|
"build:backend": "run-p build:backend-source build:views build:styles",
|
||||||
|
"build:backend-source": "tsc",
|
||||||
"build:views": "copyfiles -u 1 src/backend/views/*.pug ./built/",
|
"build:views": "copyfiles -u 1 src/backend/views/*.pug ./built/",
|
||||||
"build:styles": "sass styles/:built/assets",
|
"build:styles": "sass styles/:built/assets",
|
||||||
"start": "node built/app.js",
|
"start": "node built/app.js",
|
||||||
|
@ -41,7 +42,7 @@
|
||||||
"class-transformer": "^0.4.0",
|
"class-transformer": "^0.4.0",
|
||||||
"class-validator": "^0.13.1",
|
"class-validator": "^0.13.1",
|
||||||
"css-loader": "^6.2.0",
|
"css-loader": "^6.2.0",
|
||||||
"dayjs": "^1.10.2",
|
"dayjs": "^1.10.7",
|
||||||
"delay": "^4.4.0",
|
"delay": "^4.4.0",
|
||||||
"fibers": "^5.0.0",
|
"fibers": "^5.0.0",
|
||||||
"i18next": "^20.6.1",
|
"i18next": "^20.6.1",
|
||||||
|
|
104
src/backend/controllers/announcement.ts
Normal file
104
src/backend/controllers/announcement.ts
Normal file
|
@ -0,0 +1,104 @@
|
||||||
|
/**
|
||||||
|
* バージョン情報など、サーバーのメタデータを返すAPI
|
||||||
|
* @author Xeltica
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { BadRequestError, Body, CurrentUser, Delete, Get, JsonController, NotFoundError, OnUndefined, Param, Post, Put } from 'routing-controllers';
|
||||||
|
import { IUser } from '../../common/types/user';
|
||||||
|
import { Announcements } from '../models';
|
||||||
|
import { AnnounceCreate } from './body/announce-create';
|
||||||
|
import { AnnounceUpdate } from './body/announce-update';
|
||||||
|
import { IdProp } from './body/id-prop';
|
||||||
|
|
||||||
|
@JsonController('/announcements')
|
||||||
|
export class AdminController {
|
||||||
|
@Get() get() {
|
||||||
|
const query = Announcements.createQueryBuilder('announcement')
|
||||||
|
.orderBy('"announcement"."createdAt"', 'DESC');
|
||||||
|
|
||||||
|
return query.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnUndefined(204)
|
||||||
|
@Post() async post(@CurrentUser({ required: true }) user: IUser, @Body({required: true}) {title, body}: AnnounceCreate) {
|
||||||
|
if (!user.isAdmin) {
|
||||||
|
throw new BadRequestError('Not an Admin');
|
||||||
|
}
|
||||||
|
if (!title || !body) {
|
||||||
|
throw new BadRequestError();
|
||||||
|
}
|
||||||
|
await Announcements.insert({
|
||||||
|
createdAt: new Date(),
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnUndefined(204)
|
||||||
|
@Put() async update(@CurrentUser({ required: true }) user: IUser, @Body() {id, title, body}: AnnounceUpdate) {
|
||||||
|
if (!user.isAdmin) {
|
||||||
|
throw new BadRequestError('Not an Admin');
|
||||||
|
}
|
||||||
|
if (!id || !title || !body) {
|
||||||
|
throw new BadRequestError();
|
||||||
|
}
|
||||||
|
if (!(await Announcements.findOne(id))) {
|
||||||
|
throw new NotFoundError();
|
||||||
|
}
|
||||||
|
|
||||||
|
await Announcements.update(id, {
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnUndefined(204)
|
||||||
|
@Post('/like/:id') async like(@CurrentUser({ required: true }) user: IUser, @Param('id') id: string) {
|
||||||
|
if (!user.isAdmin) {
|
||||||
|
throw new BadRequestError('Not an Admin');
|
||||||
|
}
|
||||||
|
const idNumber = Number(id);
|
||||||
|
if (isNaN(idNumber)) {
|
||||||
|
throw new NotFoundError();
|
||||||
|
}
|
||||||
|
if (!id) {
|
||||||
|
throw new BadRequestError();
|
||||||
|
}
|
||||||
|
|
||||||
|
const announcement = await Announcements.findOne(Number(idNumber));
|
||||||
|
|
||||||
|
if (!announcement) {
|
||||||
|
throw new NotFoundError();
|
||||||
|
}
|
||||||
|
|
||||||
|
await Announcements.update(id, {
|
||||||
|
like: announcement.like + 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
return announcement.like + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete() async delete(@CurrentUser({ required: true }) user: IUser, @Body() {id}: IdProp) {
|
||||||
|
if (!user.isAdmin) {
|
||||||
|
throw new BadRequestError('Not an Admin');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
throw new BadRequestError();
|
||||||
|
}
|
||||||
|
|
||||||
|
await Announcements.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/:id') async getDetail(@Param('id') id: string) {
|
||||||
|
const idNumber = Number(id);
|
||||||
|
if (isNaN(idNumber)) {
|
||||||
|
throw new NotFoundError();
|
||||||
|
}
|
||||||
|
const announcement = await Announcements.findOne(idNumber);
|
||||||
|
if (!announcement) {
|
||||||
|
throw new NotFoundError();
|
||||||
|
}
|
||||||
|
return announcement;
|
||||||
|
}
|
||||||
|
}
|
4
src/backend/controllers/body/announce-create.ts
Normal file
4
src/backend/controllers/body/announce-create.ts
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
export class AnnounceCreate {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
}
|
5
src/backend/controllers/body/announce-update.ts
Normal file
5
src/backend/controllers/body/announce-update.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
export class AnnounceUpdate {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
}
|
3
src/backend/controllers/body/id-prop.ts
Normal file
3
src/backend/controllers/body/id-prop.ts
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
export class IdProp {
|
||||||
|
id: number;
|
||||||
|
}
|
|
@ -1,7 +1,6 @@
|
||||||
import { IsIn, IsOptional } from 'class-validator';
|
import { IsIn, IsOptional } from 'class-validator';
|
||||||
import { AlertMode, alertModes } from '../../common/types/alert-mode';
|
import { AlertMode, alertModes } from '../../../common/types/alert-mode';
|
||||||
import { visibilities, Visibility } from '../../common/types/visibility';
|
import { visibilities, Visibility } from '../../../common/types/visibility';
|
||||||
|
|
||||||
|
|
||||||
export class UserSetting {
|
export class UserSetting {
|
||||||
@IsIn(alertModes)
|
@IsIn(alertModes)
|
|
@ -9,7 +9,7 @@ import { getScores } from '../functions/get-scores';
|
||||||
import { deleteUser, updateUser } from '../functions/users';
|
import { deleteUser, updateUser } from '../functions/users';
|
||||||
import { User } from '../models/entities/user';
|
import { User } from '../models/entities/user';
|
||||||
import { sendAlert } from '../services/send-alert';
|
import { sendAlert } from '../services/send-alert';
|
||||||
import { UserSetting } from './UserSetting';
|
import { UserSetting } from './body/user-setting';
|
||||||
|
|
||||||
@JsonController('/session')
|
@JsonController('/session')
|
||||||
export class SessionController {
|
export class SessionController {
|
||||||
|
|
|
@ -48,7 +48,7 @@ export const updateUsersToolsToken = async (user: User | User['id']): Promise<st
|
||||||
* @param token ミス廃トークン
|
* @param token ミス廃トークン
|
||||||
* @returns ユーザー
|
* @returns ユーザー
|
||||||
*/
|
*/
|
||||||
export const getUserByToolsToken = (token: string): Promise<User | undefined> => {
|
export const getUserByToolsToken = (token: string): Promise<IUser | undefined> => {
|
||||||
return Users.findOne({ misshaiToken: token }).then(packUser);
|
return Users.findOne({ misshaiToken: token }).then(packUser);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
31
src/backend/models/entities/announcement.ts
Normal file
31
src/backend/models/entities/announcement.ts
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
|
||||||
|
import { IAnnouncement } from '../../../common/types/announcement';
|
||||||
|
|
||||||
|
@Entity()
|
||||||
|
export class Announcement implements IAnnouncement {
|
||||||
|
@PrimaryGeneratedColumn()
|
||||||
|
public id: number;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'timestamp without time zone',
|
||||||
|
})
|
||||||
|
public createdAt: Date;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'varchar',
|
||||||
|
length: 128,
|
||||||
|
})
|
||||||
|
public title: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'varchar',
|
||||||
|
length: 8192,
|
||||||
|
})
|
||||||
|
public body: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'integer',
|
||||||
|
default: 0,
|
||||||
|
})
|
||||||
|
public like: number;
|
||||||
|
}
|
|
@ -1,6 +1,8 @@
|
||||||
import { User } from './entities/user';
|
import { User } from './entities/user';
|
||||||
import { UsedToken } from './entities/used-token';
|
import { UsedToken } from './entities/used-token';
|
||||||
import { getRepository } from 'typeorm';
|
import { getRepository } from 'typeorm';
|
||||||
|
import { Announcement } from './entities/announcement';
|
||||||
|
|
||||||
export const Users = getRepository(User);
|
export const Users = getRepository(User);
|
||||||
export const UsedTokens = getRepository(UsedToken);
|
export const UsedTokens = getRepository(UsedToken);
|
||||||
|
export const Announcements = getRepository(Announcement);
|
||||||
|
|
|
@ -2,10 +2,12 @@ import { getConnection, createConnection, Connection } from 'typeorm';
|
||||||
import { config } from '../../config';
|
import { config } from '../../config';
|
||||||
import { User } from '../models/entities/user';
|
import { User } from '../models/entities/user';
|
||||||
import { UsedToken } from '../models/entities/used-token';
|
import { UsedToken } from '../models/entities/used-token';
|
||||||
|
import { Announcement } from '../models/entities/announcement';
|
||||||
|
|
||||||
export const entities = [
|
export const entities = [
|
||||||
User,
|
User,
|
||||||
UsedToken,
|
UsedToken,
|
||||||
|
Announcement,
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
7
src/common/types/announcement.ts
Normal file
7
src/common/types/announcement.ts
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
export interface IAnnouncement {
|
||||||
|
id: number;
|
||||||
|
createdAt: Date;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
like: number;
|
||||||
|
}
|
|
@ -17,6 +17,7 @@ import { LOCALSTORAGE_KEY_LANG } from './const';
|
||||||
|
|
||||||
import 'xeltica-ui/dist/css/xeltica-ui.min.css';
|
import 'xeltica-ui/dist/css/xeltica-ui.min.css';
|
||||||
import './style.scss';
|
import './style.scss';
|
||||||
|
import { AnnouncementPage } from './pages/announcement';
|
||||||
|
|
||||||
document.body.classList.add('dark');
|
document.body.classList.add('dark');
|
||||||
|
|
||||||
|
@ -63,6 +64,7 @@ const AppInner : React.VFC = () => {
|
||||||
<Route exact path="/" component={IndexPage} />
|
<Route exact path="/" component={IndexPage} />
|
||||||
<Route exact path="/ranking" component={RankingPage} />
|
<Route exact path="/ranking" component={RankingPage} />
|
||||||
<Route exact path="/term" component={TermPage} />
|
<Route exact path="/term" component={TermPage} />
|
||||||
|
<Route exact path="/announcements/:id" component={AnnouncementPage} />
|
||||||
</Switch>
|
</Switch>
|
||||||
<footer className="text-center pa-5">
|
<footer className="text-center pa-5">
|
||||||
<p>(C)2020-2021 Xeltica</p>
|
<p>(C)2020-2021 Xeltica</p>
|
||||||
|
|
145
src/frontend/components/AdminPage.tsx
Normal file
145
src/frontend/components/AdminPage.tsx
Normal file
|
@ -0,0 +1,145 @@
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { LOCALSTORAGE_KEY_TOKEN } from '../const';
|
||||||
|
import { useGetSessionQuery } from '../services/session';
|
||||||
|
import { Skeleton } from './Skeleton';
|
||||||
|
import { IAnnouncement } from '../../common/types/announcement';
|
||||||
|
import { $get, $post, $put } from '../misc/api';
|
||||||
|
import { Card } from './Card';
|
||||||
|
|
||||||
|
|
||||||
|
export const AdminPage: React.VFC = () => {
|
||||||
|
const { data, error } = useGetSessionQuery(undefined);
|
||||||
|
|
||||||
|
const [announcements, setAnnouncements] = useState<IAnnouncement[]>([]);
|
||||||
|
const [selectedAnnouncement, selectAnnouncement] = useState<IAnnouncement | null>(null);
|
||||||
|
const [isAnnouncementsLoaded, setAnnouncementsLoaded] = useState(false);
|
||||||
|
const [isEditMode, setEditMode] = useState(false);
|
||||||
|
const [draftTitle, setDraftTitle] = useState('');
|
||||||
|
const [draftBody, setDraftBody] = useState('');
|
||||||
|
|
||||||
|
const submitAnnouncement = async () => {
|
||||||
|
try {
|
||||||
|
if (selectedAnnouncement) {
|
||||||
|
await $put('announcements', {
|
||||||
|
id: selectedAnnouncement.id,
|
||||||
|
title: draftTitle,
|
||||||
|
body: draftBody,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await $post('announcements', {
|
||||||
|
title: draftTitle,
|
||||||
|
body: draftBody,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
selectAnnouncement(null);
|
||||||
|
setEditMode(false);
|
||||||
|
fetchAll();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
// todo
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchAll = () => {
|
||||||
|
setAnnouncements([]);
|
||||||
|
setAnnouncementsLoaded(false);
|
||||||
|
$get<IAnnouncement[]>('announcements').then(announcements => {
|
||||||
|
setAnnouncements(announcements ?? []);
|
||||||
|
setAnnouncementsLoaded(true);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session APIのエラーハンドリング
|
||||||
|
* このAPIがエラーを返した = トークンが無効 なのでトークンを削除してログアウトする
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (error) {
|
||||||
|
console.error(error);
|
||||||
|
localStorage.removeItem(LOCALSTORAGE_KEY_TOKEN);
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* お知らせ取得
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAll();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedAnnouncement) {
|
||||||
|
setDraftTitle(selectedAnnouncement.title);
|
||||||
|
setDraftBody(selectedAnnouncement.body);
|
||||||
|
} else {
|
||||||
|
setDraftTitle('');
|
||||||
|
setDraftBody('');
|
||||||
|
}
|
||||||
|
}, [selectedAnnouncement]);
|
||||||
|
|
||||||
|
return !data || !isAnnouncementsLoaded ? (
|
||||||
|
<div className="vstack">
|
||||||
|
<Skeleton width="100%" height="1rem" />
|
||||||
|
<Skeleton width="100%" height="1rem" />
|
||||||
|
<Skeleton width="100%" height="2rem" />
|
||||||
|
<Skeleton width="100%" height="160px" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="fade vstack">
|
||||||
|
{
|
||||||
|
!data.isAdmin ? (
|
||||||
|
<p>You are not an administrator and cannot open this page.</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<article>
|
||||||
|
<h2>Announcements</h2>
|
||||||
|
<Card bodyClassName={isEditMode ? '' : 'px-0'}>
|
||||||
|
{ !isEditMode ? (
|
||||||
|
<div className="large menu">
|
||||||
|
{announcements.map(a => (
|
||||||
|
<button className="item fluid" key={a.id} onClick={() => {
|
||||||
|
selectAnnouncement(a);
|
||||||
|
setEditMode(true);
|
||||||
|
}}>
|
||||||
|
{a.title}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button className="item fluid" onClick={() => setEditMode(true)}>
|
||||||
|
<i className="icon bi bi-plus"/ >
|
||||||
|
Create New
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="vstack">
|
||||||
|
<label className="input-field">
|
||||||
|
Title
|
||||||
|
<input type="text" value={draftTitle} onChange={e => setDraftTitle(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="input-field">
|
||||||
|
Body
|
||||||
|
<textarea className="input-field" value={draftBody} rows={10} onChange={e => setDraftBody(e.target.value)}/>
|
||||||
|
</label>
|
||||||
|
<div className="hstack" style={{justifyContent: 'flex-end'}}>
|
||||||
|
<button className="btn primary" onClick={() => submitAnnouncement()} disabled={!draftTitle || !draftBody}>
|
||||||
|
Submit
|
||||||
|
</button>
|
||||||
|
<button className="btn danger" onClick={() => {
|
||||||
|
selectAnnouncement(null);
|
||||||
|
setEditMode(false);
|
||||||
|
}}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</article>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
36
src/frontend/components/AnnouncementList.tsx
Normal file
36
src/frontend/components/AnnouncementList.tsx
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { IAnnouncement } from '../../common/types/announcement';
|
||||||
|
import { $get } from '../misc/api';
|
||||||
|
import { Card } from './Card';
|
||||||
|
|
||||||
|
export const AnnouncementList: React.VFC = () => {
|
||||||
|
const [announcements, setAnnouncements] = useState<IAnnouncement[]>([]);
|
||||||
|
|
||||||
|
const fetchAllAnnouncements = () => {
|
||||||
|
setAnnouncements([]);
|
||||||
|
$get<IAnnouncement[]>('announcements').then(announcements => {
|
||||||
|
setAnnouncements(announcements ?? []);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAllAnnouncements();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (announcements.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<h1>お知らせ</h1>
|
||||||
|
<div className="large menu fade">
|
||||||
|
{announcements.map(a => (
|
||||||
|
<Link className="item fluid" key={a.id} to={`/announcements/${a.id}`}>
|
||||||
|
{a.title}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
|
@ -5,9 +5,11 @@ import { useDispatch } from 'react-redux';
|
||||||
import { alertModes } from '../../common/types/alert-mode';
|
import { alertModes } from '../../common/types/alert-mode';
|
||||||
import { IUser } from '../../common/types/user';
|
import { IUser } from '../../common/types/user';
|
||||||
import { Visibility } from '../../common/types/visibility';
|
import { Visibility } from '../../common/types/visibility';
|
||||||
import { API_ENDPOINT, LOCALSTORAGE_KEY_TOKEN } from '../const';
|
import { LOCALSTORAGE_KEY_TOKEN } from '../const';
|
||||||
|
import { $post, $put } from '../misc/api';
|
||||||
import { useGetScoreQuery, useGetSessionQuery } from '../services/session';
|
import { useGetScoreQuery, useGetSessionQuery } from '../services/session';
|
||||||
import { showModal } from '../store/slices/screen';
|
import { showModal } from '../store/slices/screen';
|
||||||
|
import { AnnouncementList } from './AnnouncementList';
|
||||||
import { Card } from './Card';
|
import { Card } from './Card';
|
||||||
import { Ranking } from './Ranking';
|
import { Ranking } from './Ranking';
|
||||||
import { Skeleton } from './Skeleton';
|
import { Skeleton } from './Skeleton';
|
||||||
|
@ -42,6 +44,7 @@ export const MisshaiPage: React.VFC = () => {
|
||||||
const session = useGetSessionQuery(undefined);
|
const session = useGetSessionQuery(undefined);
|
||||||
const data = session.data;
|
const data = session.data;
|
||||||
const score = useGetScoreQuery(undefined);
|
const score = useGetScoreQuery(undefined);
|
||||||
|
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
|
|
||||||
const [draft, dispatchDraft] = useReducer<DraftReducer>((state, action) => {
|
const [draft, dispatchDraft] = useReducer<DraftReducer>((state, action) => {
|
||||||
|
@ -65,14 +68,7 @@ export const MisshaiPage: React.VFC = () => {
|
||||||
const updateSetting = useCallback((obj: SettingDraftType) => {
|
const updateSetting = useCallback((obj: SettingDraftType) => {
|
||||||
const previousDraft = draft;
|
const previousDraft = draft;
|
||||||
dispatchDraft(obj);
|
dispatchDraft(obj);
|
||||||
return fetch(`${API_ENDPOINT}session`, {
|
return $put('session', obj)
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${localStorage[LOCALSTORAGE_KEY_TOKEN]}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(obj),
|
|
||||||
})
|
|
||||||
.catch(e => {
|
.catch(e => {
|
||||||
dispatch(showModal({
|
dispatch(showModal({
|
||||||
type: 'dialog',
|
type: 'dialog',
|
||||||
|
@ -145,12 +141,7 @@ export const MisshaiPage: React.VFC = () => {
|
||||||
],
|
],
|
||||||
onSelect(i) {
|
onSelect(i) {
|
||||||
if (i === 0) {
|
if (i === 0) {
|
||||||
fetch(`${API_ENDPOINT}session/alert`, {
|
$post('session/alert').then(() => {
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${localStorage[LOCALSTORAGE_KEY_TOKEN]}`,
|
|
||||||
},
|
|
||||||
}).then(() => {
|
|
||||||
dispatch(showModal({
|
dispatch(showModal({
|
||||||
type: 'dialog',
|
type: 'dialog',
|
||||||
message: t('_sendTest.success'),
|
message: t('_sendTest.success'),
|
||||||
|
@ -203,6 +194,7 @@ export const MisshaiPage: React.VFC = () => {
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
<AnnouncementList />
|
||||||
{score.data && (
|
{score.data && (
|
||||||
<>
|
<>
|
||||||
<section>
|
<section>
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { $get } from '../misc/api';
|
||||||
|
|
||||||
interface RankingResponse {
|
interface RankingResponse {
|
||||||
isCalculating: boolean;
|
isCalculating: boolean;
|
||||||
|
@ -27,8 +28,7 @@ export const Ranking: React.VFC<RankingProps> = ({limit}) => {
|
||||||
// APIコール
|
// APIコール
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsFetching(true);
|
setIsFetching(true);
|
||||||
fetch(`//${location.host}/api/v1/ranking?limit=${limit ?? ''}`)
|
$get<RankingResponse>(`ranking?limit=${limit ?? ''}`)
|
||||||
.then((r) => (r.json() as unknown) as RankingResponse)
|
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
setResponse(result);
|
setResponse(result);
|
||||||
setIsFetching(false);
|
setIsFetching(false);
|
||||||
|
|
|
@ -5,10 +5,11 @@ import { useDispatch } from 'react-redux';
|
||||||
import { useGetSessionQuery } from '../services/session';
|
import { useGetSessionQuery } from '../services/session';
|
||||||
import { Card } from './Card';
|
import { Card } from './Card';
|
||||||
import { Theme, themes } from '../misc/theme';
|
import { Theme, themes } from '../misc/theme';
|
||||||
import { API_ENDPOINT, LOCALSTORAGE_KEY_TOKEN } from '../const';
|
import { LOCALSTORAGE_KEY_TOKEN } from '../const';
|
||||||
import { changeLang, changeTheme, showModal } from '../store/slices/screen';
|
import { changeLang, changeTheme, showModal } from '../store/slices/screen';
|
||||||
import { useSelector } from '../store';
|
import { useSelector } from '../store';
|
||||||
import { languageName } from '../langs';
|
import { languageName } from '../langs';
|
||||||
|
import { $delete } from '../misc/api';
|
||||||
|
|
||||||
export const SettingPage: React.VFC = () => {
|
export const SettingPage: React.VFC = () => {
|
||||||
const session = useGetSessionQuery(undefined);
|
const session = useGetSessionQuery(undefined);
|
||||||
|
@ -62,12 +63,7 @@ export const SettingPage: React.VFC = () => {
|
||||||
primaryClassName: 'danger',
|
primaryClassName: 'danger',
|
||||||
onSelect(i) {
|
onSelect(i) {
|
||||||
if (i === 0) {
|
if (i === 0) {
|
||||||
fetch(`${API_ENDPOINT}session`, {
|
$delete('session').then(() => {
|
||||||
method: 'DELETE',
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${localStorage[LOCALSTORAGE_KEY_TOKEN]}`,
|
|
||||||
},
|
|
||||||
}).then(() => {
|
|
||||||
dispatch(showModal({
|
dispatch(showModal({
|
||||||
type: 'dialog',
|
type: 'dialog',
|
||||||
message: t('_deactivate.success'),
|
message: t('_deactivate.success'),
|
||||||
|
|
|
@ -2,24 +2,25 @@ import React from 'react';
|
||||||
|
|
||||||
export type TabItem = {
|
export type TabItem = {
|
||||||
label: string;
|
label: string;
|
||||||
|
key: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TabProps = {
|
export type TabProps = {
|
||||||
items: TabItem[];
|
items: TabItem[];
|
||||||
selected: number;
|
selected: string;
|
||||||
onSelect: (index: number) => void;
|
onSelect: (key: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
// タブコンポーネント
|
// タブコンポーネント
|
||||||
export const Tab: React.VFC<TabProps> = (props) => {
|
export const Tab: React.VFC<TabProps> = (props) => {
|
||||||
return (
|
return (
|
||||||
<div className="tab">
|
<div className="tab">
|
||||||
{props.items.map((item, index) => {
|
{props.items.map((item) => {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={index}
|
key={item.key}
|
||||||
className={'item ' + (index === props.selected ? 'active' : '')}
|
className={'item ' + (item.key === props.selected ? 'active' : '')}
|
||||||
onClick={() => props.onSelect(index)}
|
onClick={() => props.onSelect(item.key)}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
@ -1,6 +1,11 @@
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import * as ReactDOM from 'react-dom';
|
import * as ReactDOM from 'react-dom';
|
||||||
|
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import 'dayjs/locale/ja';
|
||||||
|
|
||||||
import { App } from './App';
|
import { App } from './App';
|
||||||
|
|
||||||
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
ReactDOM.render(<App/>, document.getElementById('app'));
|
ReactDOM.render(<App/>, document.getElementById('app'));
|
||||||
|
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { API_ENDPOINT, LOCALSTORAGE_KEY_TOKEN } from '../const';
|
||||||
|
|
||||||
|
export type ApiOptions = Record<string, any>;
|
||||||
|
|
||||||
|
const getHeaders = (token?: string) => {
|
||||||
|
const _token = token ?? localStorage.getItem(LOCALSTORAGE_KEY_TOKEN);
|
||||||
|
const headers: HeadersInit = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
if (_token) {
|
||||||
|
headers['Authorization'] = `Bearer ${_token}`;
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getResponse = <T>(r: Response) => r.status === 204 ? null : r.json() as unknown as T;
|
||||||
|
|
||||||
|
export const $get = <T = any>(endpoint: string, token?: string): Promise<T | null> => {
|
||||||
|
return fetch(API_ENDPOINT + endpoint, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: getHeaders(token),
|
||||||
|
}).then(r => getResponse<T>(r));
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export const $put = <T = any>(endpoint: string, opts: ApiOptions = {}, token?: string): Promise<T | null> => {
|
||||||
|
return fetch(API_ENDPOINT + endpoint, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: getHeaders(token),
|
||||||
|
body: JSON.stringify(opts),
|
||||||
|
}).then(r => getResponse<T>(r));
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export const $post = <T = any>(endpoint: string, opts: ApiOptions = {}, token?: string): Promise<T | null> => {
|
||||||
|
return fetch(API_ENDPOINT + endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getHeaders(token),
|
||||||
|
body: JSON.stringify(opts),
|
||||||
|
}).then(r => getResponse<T>(r));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const $delete = <T = any>(endpoint: string, opts: ApiOptions = {}, token?: string): Promise<T | null> => {
|
||||||
|
return fetch(API_ENDPOINT + endpoint, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: getHeaders(token),
|
||||||
|
body: JSON.stringify(opts),
|
||||||
|
}).then(r => getResponse<T>(r));
|
||||||
|
};
|
45
src/frontend/pages/announcement.tsx
Normal file
45
src/frontend/pages/announcement.tsx
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
import React, { ReactNodeArray, useEffect, useState } from 'react';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { useParams } from 'react-router';
|
||||||
|
import { IAnnouncement } from '../../common/types/announcement';
|
||||||
|
import { Skeleton } from '../components/Skeleton';
|
||||||
|
import { $get } from '../misc/api';
|
||||||
|
import { useSelector } from '../store';
|
||||||
|
|
||||||
|
export const AnnouncementPage: React.VFC = () => {
|
||||||
|
const { id } = useParams<{id: string}>();
|
||||||
|
if (!id) return null;
|
||||||
|
|
||||||
|
const [announcement, setAnnouncement] = useState<IAnnouncement | null>();
|
||||||
|
|
||||||
|
const lang = useSelector(state => state.screen.language);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
$get<IAnnouncement>('announcements/' + id).then(setAnnouncement);
|
||||||
|
}, [setAnnouncement]);
|
||||||
|
return (
|
||||||
|
<article className="xarticle">
|
||||||
|
{!announcement ? <Skeleton width="100%" height="10rem" /> : (
|
||||||
|
<>
|
||||||
|
<header className="mb-4">
|
||||||
|
<h2 className="mb-0">{announcement.title}</h2>
|
||||||
|
<aside className="text-dimmed">
|
||||||
|
<i className="bi bi-clock" />
|
||||||
|
{dayjs(announcement.createdAt).locale(lang.split('_')[0]).fromNow()}
|
||||||
|
</aside>
|
||||||
|
</header>
|
||||||
|
<section>
|
||||||
|
{(() => {
|
||||||
|
const res: ReactNodeArray = [];
|
||||||
|
announcement.body.split('\n').forEach(s => {
|
||||||
|
res.push(<>{s}</>);
|
||||||
|
res.push(<br />);
|
||||||
|
});
|
||||||
|
return res;
|
||||||
|
})()}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
};
|
|
@ -7,41 +7,41 @@ import { SettingPage } from '../components/SettingPage';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { AccountsPage } from '../components/AccountsPage';
|
import { AccountsPage } from '../components/AccountsPage';
|
||||||
import { useDispatch } from 'react-redux';
|
import { useDispatch } from 'react-redux';
|
||||||
import { API_ENDPOINT, LOCALSTORAGE_KEY_ACCOUNTS } from '../const';
|
import { LOCALSTORAGE_KEY_ACCOUNTS } from '../const';
|
||||||
import { IUser } from '../../common/types/user';
|
import { IUser } from '../../common/types/user';
|
||||||
import { setAccounts } from '../store/slices/screen';
|
import { setAccounts } from '../store/slices/screen';
|
||||||
|
import { useGetSessionQuery } from '../services/session';
|
||||||
const getSession = (token: string) => {
|
import { AdminPage } from '../components/AdminPage';
|
||||||
return fetch(`${API_ENDPOINT}session`, {
|
import { $get } from '../misc/api';
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
}).then(r => r.json()).then(r => r as IUser);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const IndexSessionPage: React.VFC = () => {
|
export const IndexSessionPage: React.VFC = () => {
|
||||||
const [selectedTab, setSelectedTab] = useState<number>(0);
|
const [selectedTab, setSelectedTab] = useState<string>('misshai');
|
||||||
const {t, i18n} = useTranslation();
|
const {t, i18n} = useTranslation();
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
const { data } = useGetSessionQuery(undefined);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const accounts = JSON.parse(localStorage.getItem(LOCALSTORAGE_KEY_ACCOUNTS) || '[]') as string[];
|
const accounts = JSON.parse(localStorage.getItem(LOCALSTORAGE_KEY_ACCOUNTS) || '[]') as string[];
|
||||||
Promise.all(accounts.map(getSession)).then(a => dispatch(setAccounts(a)));
|
Promise.all(accounts.map(token => $get<IUser>('session', token))).then(a => dispatch(setAccounts(a as IUser[])));
|
||||||
}, [dispatch]);
|
}, [dispatch]);
|
||||||
|
|
||||||
const items = useMemo<TabItem[]>(() => ([
|
const items = useMemo<TabItem[]>(() => {
|
||||||
{ label: t('_nav.misshai') },
|
const it: TabItem[] = [];
|
||||||
{ label: t('_nav.accounts') },
|
it.push({ label: t('_nav.misshai'), key: 'misshai' });
|
||||||
{ label: t('_nav.settings') },
|
it.push({ label: t('_nav.accounts'), key: 'accounts' });
|
||||||
]), [i18n.language]);
|
if (data?.isAdmin) {
|
||||||
|
it.push({ label: 'Admin', key: 'admin' });
|
||||||
|
}
|
||||||
|
it.push({ label: t('_nav.settings'), key: 'settings' });
|
||||||
|
return it;
|
||||||
|
}, [i18n.language, data]);
|
||||||
|
|
||||||
const component = useMemo(() => {
|
const component = useMemo(() => {
|
||||||
switch (selectedTab) {
|
switch (selectedTab) {
|
||||||
case 0: return <MisshaiPage />;
|
case 'misshai': return <MisshaiPage />;
|
||||||
case 1: return <AccountsPage />;
|
case 'accounts': return <AccountsPage />;
|
||||||
case 2: return <SettingPage/>;
|
case 'admin': return <AdminPage />;
|
||||||
|
case 'settings': return <SettingPage/>;
|
||||||
default: return null;
|
default: return null;
|
||||||
}
|
}
|
||||||
}, [selectedTab]);
|
}, [selectedTab]);
|
||||||
|
|
|
@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { LoginForm } from '../components/LoginForm';
|
import { LoginForm } from '../components/LoginForm';
|
||||||
import { Header } from '../components/Header';
|
import { Header } from '../components/Header';
|
||||||
|
import { AnnouncementList } from '../components/AnnouncementList';
|
||||||
export const IndexWelcomePage: React.VFC = () => {
|
export const IndexWelcomePage: React.VFC = () => {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
|
|
||||||
|
@ -27,6 +28,9 @@ export const IndexWelcomePage: React.VFC = () => {
|
||||||
</article>
|
</article>
|
||||||
<LoginForm />
|
<LoginForm />
|
||||||
</Header>
|
</Header>
|
||||||
|
<article className="xarticle">
|
||||||
|
<AnnouncementList />
|
||||||
|
</article>
|
||||||
<article className="xarticle vstack pa-2">
|
<article className="xarticle vstack pa-2">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('_welcome.title')}</h2>
|
<h2>{t('_welcome.title')}</h2>
|
||||||
|
|
|
@ -1608,7 +1608,7 @@ csstype@^3.0.2:
|
||||||
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.0.9.tgz#6410af31b26bd0520933d02cbc64fce9ce3fbf0b"
|
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.0.9.tgz#6410af31b26bd0520933d02cbc64fce9ce3fbf0b"
|
||||||
integrity sha512-rpw6JPxK6Rfg1zLOYCSwle2GFOOsnjmDYDaBwEcwoOg4qlsIVCN789VkBZDJAGi4T07gI4YSutR43t9Zz4Lzuw==
|
integrity sha512-rpw6JPxK6Rfg1zLOYCSwle2GFOOsnjmDYDaBwEcwoOg4qlsIVCN789VkBZDJAGi4T07gI4YSutR43t9Zz4Lzuw==
|
||||||
|
|
||||||
dayjs@^1.10.2:
|
dayjs@^1.10.7:
|
||||||
version "1.10.7"
|
version "1.10.7"
|
||||||
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.10.7.tgz#2cf5f91add28116748440866a0a1d26f3a6ce468"
|
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.10.7.tgz#2cf5f91add28116748440866a0a1d26f3a6ce468"
|
||||||
integrity sha512-P6twpd70BcPK34K26uJ1KT3wlhpuOAPoMwJzpsIWUxHZ7wpmbdZL/hQqBDfz7hGurYSa5PhzdhDHtt319hL3ig==
|
integrity sha512-P6twpd70BcPK34K26uJ1KT3wlhpuOAPoMwJzpsIWUxHZ7wpmbdZL/hQqBDfz7hGurYSa5PhzdhDHtt319hL3ig==
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue