いろいろ
This commit is contained in:
parent
f0604043a8
commit
d8664775cc
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/*"
|
||||
],
|
||||
"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 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')));
|
||||
|
||||
@ -17,4 +17,4 @@ module.exports = {
|
||||
cli: {
|
||||
migrationsDir: 'migration'
|
||||
}
|
||||
};
|
||||
};
|
||||
|
@ -8,7 +8,8 @@
|
||||
"scripts": {
|
||||
"build": "run-p build:*",
|
||||
"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:styles": "sass styles/:built/assets",
|
||||
"start": "node built/app.js",
|
||||
@ -41,7 +42,7 @@
|
||||
"class-transformer": "^0.4.0",
|
||||
"class-validator": "^0.13.1",
|
||||
"css-loader": "^6.2.0",
|
||||
"dayjs": "^1.10.2",
|
||||
"dayjs": "^1.10.7",
|
||||
"delay": "^4.4.0",
|
||||
"fibers": "^5.0.0",
|
||||
"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 { AlertMode, alertModes } from '../../common/types/alert-mode';
|
||||
import { visibilities, Visibility } from '../../common/types/visibility';
|
||||
|
||||
import { AlertMode, alertModes } from '../../../common/types/alert-mode';
|
||||
import { visibilities, Visibility } from '../../../common/types/visibility';
|
||||
|
||||
export class UserSetting {
|
||||
@IsIn(alertModes)
|
@ -9,7 +9,7 @@ import { getScores } from '../functions/get-scores';
|
||||
import { deleteUser, updateUser } from '../functions/users';
|
||||
import { User } from '../models/entities/user';
|
||||
import { sendAlert } from '../services/send-alert';
|
||||
import { UserSetting } from './UserSetting';
|
||||
import { UserSetting } from './body/user-setting';
|
||||
|
||||
@JsonController('/session')
|
||||
export class SessionController {
|
||||
|
@ -48,7 +48,7 @@ export const updateUsersToolsToken = async (user: User | User['id']): Promise<st
|
||||
* @param token ミス廃トークン
|
||||
* @returns ユーザー
|
||||
*/
|
||||
export const getUserByToolsToken = (token: string): Promise<User | undefined> => {
|
||||
export const getUserByToolsToken = (token: string): Promise<IUser | undefined> => {
|
||||
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 { UsedToken } from './entities/used-token';
|
||||
import { getRepository } from 'typeorm';
|
||||
import { Announcement } from './entities/announcement';
|
||||
|
||||
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 { User } from '../models/entities/user';
|
||||
import { UsedToken } from '../models/entities/used-token';
|
||||
import { Announcement } from '../models/entities/announcement';
|
||||
|
||||
export const entities = [
|
||||
User,
|
||||
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 './style.scss';
|
||||
import { AnnouncementPage } from './pages/announcement';
|
||||
|
||||
document.body.classList.add('dark');
|
||||
|
||||
@ -63,6 +64,7 @@ const AppInner : React.VFC = () => {
|
||||
<Route exact path="/" component={IndexPage} />
|
||||
<Route exact path="/ranking" component={RankingPage} />
|
||||
<Route exact path="/term" component={TermPage} />
|
||||
<Route exact path="/announcements/:id" component={AnnouncementPage} />
|
||||
</Switch>
|
||||
<footer className="text-center pa-5">
|
||||
<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 { IUser } from '../../common/types/user';
|
||||
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 { showModal } from '../store/slices/screen';
|
||||
import { AnnouncementList } from './AnnouncementList';
|
||||
import { Card } from './Card';
|
||||
import { Ranking } from './Ranking';
|
||||
import { Skeleton } from './Skeleton';
|
||||
@ -42,6 +44,7 @@ export const MisshaiPage: React.VFC = () => {
|
||||
const session = useGetSessionQuery(undefined);
|
||||
const data = session.data;
|
||||
const score = useGetScoreQuery(undefined);
|
||||
|
||||
const {t} = useTranslation();
|
||||
|
||||
const [draft, dispatchDraft] = useReducer<DraftReducer>((state, action) => {
|
||||
@ -65,14 +68,7 @@ export const MisshaiPage: React.VFC = () => {
|
||||
const updateSetting = useCallback((obj: SettingDraftType) => {
|
||||
const previousDraft = draft;
|
||||
dispatchDraft(obj);
|
||||
return fetch(`${API_ENDPOINT}session`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage[LOCALSTORAGE_KEY_TOKEN]}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(obj),
|
||||
})
|
||||
return $put('session', obj)
|
||||
.catch(e => {
|
||||
dispatch(showModal({
|
||||
type: 'dialog',
|
||||
@ -145,12 +141,7 @@ export const MisshaiPage: React.VFC = () => {
|
||||
],
|
||||
onSelect(i) {
|
||||
if (i === 0) {
|
||||
fetch(`${API_ENDPOINT}session/alert`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage[LOCALSTORAGE_KEY_TOKEN]}`,
|
||||
},
|
||||
}).then(() => {
|
||||
$post('session/alert').then(() => {
|
||||
dispatch(showModal({
|
||||
type: 'dialog',
|
||||
message: t('_sendTest.success'),
|
||||
@ -203,6 +194,7 @@ export const MisshaiPage: React.VFC = () => {
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
<AnnouncementList />
|
||||
{score.data && (
|
||||
<>
|
||||
<section>
|
||||
|
@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { $get } from '../misc/api';
|
||||
|
||||
interface RankingResponse {
|
||||
isCalculating: boolean;
|
||||
@ -27,8 +28,7 @@ export const Ranking: React.VFC<RankingProps> = ({limit}) => {
|
||||
// APIコール
|
||||
useEffect(() => {
|
||||
setIsFetching(true);
|
||||
fetch(`//${location.host}/api/v1/ranking?limit=${limit ?? ''}`)
|
||||
.then((r) => (r.json() as unknown) as RankingResponse)
|
||||
$get<RankingResponse>(`ranking?limit=${limit ?? ''}`)
|
||||
.then((result) => {
|
||||
setResponse(result);
|
||||
setIsFetching(false);
|
||||
|
@ -5,10 +5,11 @@ import { useDispatch } from 'react-redux';
|
||||
import { useGetSessionQuery } from '../services/session';
|
||||
import { Card } from './Card';
|
||||
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 { useSelector } from '../store';
|
||||
import { languageName } from '../langs';
|
||||
import { $delete } from '../misc/api';
|
||||
|
||||
export const SettingPage: React.VFC = () => {
|
||||
const session = useGetSessionQuery(undefined);
|
||||
@ -62,12 +63,7 @@ export const SettingPage: React.VFC = () => {
|
||||
primaryClassName: 'danger',
|
||||
onSelect(i) {
|
||||
if (i === 0) {
|
||||
fetch(`${API_ENDPOINT}session`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage[LOCALSTORAGE_KEY_TOKEN]}`,
|
||||
},
|
||||
}).then(() => {
|
||||
$delete('session').then(() => {
|
||||
dispatch(showModal({
|
||||
type: 'dialog',
|
||||
message: t('_deactivate.success'),
|
||||
|
@ -2,24 +2,25 @@ import React from 'react';
|
||||
|
||||
export type TabItem = {
|
||||
label: string;
|
||||
key: string;
|
||||
};
|
||||
|
||||
export type TabProps = {
|
||||
items: TabItem[];
|
||||
selected: number;
|
||||
onSelect: (index: number) => void;
|
||||
selected: string;
|
||||
onSelect: (key: string) => void;
|
||||
};
|
||||
|
||||
// タブコンポーネント
|
||||
export const Tab: React.VFC<TabProps> = (props) => {
|
||||
return (
|
||||
<div className="tab">
|
||||
{props.items.map((item, index) => {
|
||||
{props.items.map((item) => {
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
className={'item ' + (index === props.selected ? 'active' : '')}
|
||||
onClick={() => props.onSelect(index)}
|
||||
key={item.key}
|
||||
className={'item ' + (item.key === props.selected ? 'active' : '')}
|
||||
onClick={() => props.onSelect(item.key)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
|
@ -1,6 +1,11 @@
|
||||
import * as React from 'react';
|
||||
import * as ReactDOM from 'react-dom';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import dayjs from 'dayjs';
|
||||
import 'dayjs/locale/ja';
|
||||
|
||||
import { App } from './App';
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
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 { AccountsPage } from '../components/AccountsPage';
|
||||
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 { setAccounts } from '../store/slices/screen';
|
||||
|
||||
const getSession = (token: string) => {
|
||||
return fetch(`${API_ENDPOINT}session`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}).then(r => r.json()).then(r => r as IUser);
|
||||
};
|
||||
import { useGetSessionQuery } from '../services/session';
|
||||
import { AdminPage } from '../components/AdminPage';
|
||||
import { $get } from '../misc/api';
|
||||
|
||||
export const IndexSessionPage: React.VFC = () => {
|
||||
const [selectedTab, setSelectedTab] = useState<number>(0);
|
||||
const [selectedTab, setSelectedTab] = useState<string>('misshai');
|
||||
const {t, i18n} = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
const { data } = useGetSessionQuery(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
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]);
|
||||
|
||||
const items = useMemo<TabItem[]>(() => ([
|
||||
{ label: t('_nav.misshai') },
|
||||
{ label: t('_nav.accounts') },
|
||||
{ label: t('_nav.settings') },
|
||||
]), [i18n.language]);
|
||||
const items = useMemo<TabItem[]>(() => {
|
||||
const it: TabItem[] = [];
|
||||
it.push({ label: t('_nav.misshai'), key: 'misshai' });
|
||||
it.push({ label: t('_nav.accounts'), key: 'accounts' });
|
||||
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(() => {
|
||||
switch (selectedTab) {
|
||||
case 0: return <MisshaiPage />;
|
||||
case 1: return <AccountsPage />;
|
||||
case 2: return <SettingPage/>;
|
||||
case 'misshai': return <MisshaiPage />;
|
||||
case 'accounts': return <AccountsPage />;
|
||||
case 'admin': return <AdminPage />;
|
||||
case 'settings': return <SettingPage/>;
|
||||
default: return null;
|
||||
}
|
||||
}, [selectedTab]);
|
||||
|
@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { LoginForm } from '../components/LoginForm';
|
||||
import { Header } from '../components/Header';
|
||||
import { AnnouncementList } from '../components/AnnouncementList';
|
||||
export const IndexWelcomePage: React.VFC = () => {
|
||||
const {t} = useTranslation();
|
||||
|
||||
@ -27,6 +28,9 @@ export const IndexWelcomePage: React.VFC = () => {
|
||||
</article>
|
||||
<LoginForm />
|
||||
</Header>
|
||||
<article className="xarticle">
|
||||
<AnnouncementList />
|
||||
</article>
|
||||
<article className="xarticle vstack pa-2">
|
||||
<header>
|
||||
<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"
|
||||
integrity sha512-rpw6JPxK6Rfg1zLOYCSwle2GFOOsnjmDYDaBwEcwoOg4qlsIVCN789VkBZDJAGi4T07gI4YSutR43t9Zz4Lzuw==
|
||||
|
||||
dayjs@^1.10.2:
|
||||
dayjs@^1.10.7:
|
||||
version "1.10.7"
|
||||
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.10.7.tgz#2cf5f91add28116748440866a0a1d26f3a6ce468"
|
||||
integrity sha512-P6twpd70BcPK34K26uJ1KT3wlhpuOAPoMwJzpsIWUxHZ7wpmbdZL/hQqBDfz7hGurYSa5PhzdhDHtt319hL3ig==
|
||||
|
Loading…
Reference in New Issue
Block a user