diff --git a/migration/1633841235323-announcement.ts b/migration/1633841235323-announcement.ts
new file mode 100644
index 0000000..6d2c46a
--- /dev/null
+++ b/migration/1633841235323-announcement.ts
@@ -0,0 +1,14 @@
+import {MigrationInterface, QueryRunner} from 'typeorm';
+
+export class Announcement1633841235323 implements MigrationInterface {
+ name = 'Announcement1633841235323'
+
+ public async up(queryRunner: QueryRunner): Promise {
+ 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 {
+ await queryRunner.query('DROP TABLE "announcement"');
+ }
+
+}
diff --git a/nodemon.json b/nodemon.json
index 88d0c72..fc92d36 100644
--- a/nodemon.json
+++ b/nodemon.json
@@ -6,5 +6,5 @@
"src/frontend/*"
],
"ext": "ts,tsx,pug,scss",
- "exec": "run-s build start"
+ "exec": "run-s build:backend start"
}
diff --git a/ormconfig.js b/ormconfig.js
index caa751d..4555057 100644
--- a/ormconfig.js
+++ b/ormconfig.js
@@ -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'
}
-};
\ No newline at end of file
+};
diff --git a/package.json b/package.json
index f935433..d9a3723 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/backend/controllers/announcement.ts b/src/backend/controllers/announcement.ts
new file mode 100644
index 0000000..604830c
--- /dev/null
+++ b/src/backend/controllers/announcement.ts
@@ -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;
+ }
+}
diff --git a/src/backend/controllers/body/announce-create.ts b/src/backend/controllers/body/announce-create.ts
new file mode 100644
index 0000000..16c4bfb
--- /dev/null
+++ b/src/backend/controllers/body/announce-create.ts
@@ -0,0 +1,4 @@
+export class AnnounceCreate {
+ title: string;
+ body: string;
+}
diff --git a/src/backend/controllers/body/announce-update.ts b/src/backend/controllers/body/announce-update.ts
new file mode 100644
index 0000000..7b46f20
--- /dev/null
+++ b/src/backend/controllers/body/announce-update.ts
@@ -0,0 +1,5 @@
+export class AnnounceUpdate {
+ id: number;
+ title: string;
+ body: string;
+}
diff --git a/src/backend/controllers/body/id-prop.ts b/src/backend/controllers/body/id-prop.ts
new file mode 100644
index 0000000..ed090c7
--- /dev/null
+++ b/src/backend/controllers/body/id-prop.ts
@@ -0,0 +1,3 @@
+export class IdProp {
+ id: number;
+}
diff --git a/src/backend/controllers/UserSetting.ts b/src/backend/controllers/body/user-setting.ts
similarity index 68%
rename from src/backend/controllers/UserSetting.ts
rename to src/backend/controllers/body/user-setting.ts
index 9315383..7383e7d 100644
--- a/src/backend/controllers/UserSetting.ts
+++ b/src/backend/controllers/body/user-setting.ts
@@ -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)
diff --git a/src/backend/controllers/session.ts b/src/backend/controllers/session.ts
index 8e7bc6c..eedb57b 100644
--- a/src/backend/controllers/session.ts
+++ b/src/backend/controllers/session.ts
@@ -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 {
diff --git a/src/backend/functions/users.ts b/src/backend/functions/users.ts
index f9ecc5a..4139f96 100644
--- a/src/backend/functions/users.ts
+++ b/src/backend/functions/users.ts
@@ -48,7 +48,7 @@ export const updateUsersToolsToken = async (user: User | User['id']): Promise => {
+export const getUserByToolsToken = (token: string): Promise => {
return Users.findOne({ misshaiToken: token }).then(packUser);
};
diff --git a/src/backend/models/entities/announcement.ts b/src/backend/models/entities/announcement.ts
new file mode 100644
index 0000000..471f60d
--- /dev/null
+++ b/src/backend/models/entities/announcement.ts
@@ -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;
+}
diff --git a/src/backend/models/index.ts b/src/backend/models/index.ts
index 8c60a30..c9bd934 100644
--- a/src/backend/models/index.ts
+++ b/src/backend/models/index.ts
@@ -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);
\ No newline at end of file
+export const UsedTokens = getRepository(UsedToken);
+export const Announcements = getRepository(Announcement);
diff --git a/src/backend/services/db.ts b/src/backend/services/db.ts
index d779582..8e2f957 100644
--- a/src/backend/services/db.ts
+++ b/src/backend/services/db.ts
@@ -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,
];
/**
diff --git a/src/common/types/announcement.ts b/src/common/types/announcement.ts
new file mode 100644
index 0000000..87e9e41
--- /dev/null
+++ b/src/common/types/announcement.ts
@@ -0,0 +1,7 @@
+export interface IAnnouncement {
+ id: number;
+ createdAt: Date;
+ title: string;
+ body: string;
+ like: number;
+}
diff --git a/src/frontend/App.tsx b/src/frontend/App.tsx
index 2ce745d..59123e1 100644
--- a/src/frontend/App.tsx
+++ b/src/frontend/App.tsx
@@ -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 = () => {
+
)}
+
{score.data && (
<>
diff --git a/src/frontend/components/Ranking.tsx b/src/frontend/components/Ranking.tsx
index f8e25b1..17616fc 100644
--- a/src/frontend/components/Ranking.tsx
+++ b/src/frontend/components/Ranking.tsx
@@ -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 = ({limit}) => {
// APIコール
useEffect(() => {
setIsFetching(true);
- fetch(`//${location.host}/api/v1/ranking?limit=${limit ?? ''}`)
- .then((r) => (r.json() as unknown) as RankingResponse)
+ $get(`ranking?limit=${limit ?? ''}`)
.then((result) => {
setResponse(result);
setIsFetching(false);
diff --git a/src/frontend/components/SettingPage.tsx b/src/frontend/components/SettingPage.tsx
index 2e308ec..1cc7db5 100644
--- a/src/frontend/components/SettingPage.tsx
+++ b/src/frontend/components/SettingPage.tsx
@@ -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'),
diff --git a/src/frontend/components/Tab.tsx b/src/frontend/components/Tab.tsx
index af5e4de..0ab72a6 100644
--- a/src/frontend/components/Tab.tsx
+++ b/src/frontend/components/Tab.tsx
@@ -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 = (props) => {
return (
- {props.items.map((item, index) => {
+ {props.items.map((item) => {
return (
diff --git a/src/frontend/init.tsx b/src/frontend/init.tsx
index 588b8ef..fb5e79f 100644
--- a/src/frontend/init.tsx
+++ b/src/frontend/init.tsx
@@ -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(
, document.getElementById('app'));
diff --git a/src/frontend/misc/api.ts b/src/frontend/misc/api.ts
index e69de29..73c0dfb 100644
--- a/src/frontend/misc/api.ts
+++ b/src/frontend/misc/api.ts
@@ -0,0 +1,49 @@
+import { API_ENDPOINT, LOCALSTORAGE_KEY_TOKEN } from '../const';
+
+export type ApiOptions = Record
;
+
+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 = (r: Response) => r.status === 204 ? null : r.json() as unknown as T;
+
+export const $get = (endpoint: string, token?: string): Promise => {
+ return fetch(API_ENDPOINT + endpoint, {
+ method: 'GET',
+ headers: getHeaders(token),
+ }).then(r => getResponse(r));
+};
+
+
+export const $put = (endpoint: string, opts: ApiOptions = {}, token?: string): Promise => {
+ return fetch(API_ENDPOINT + endpoint, {
+ method: 'PUT',
+ headers: getHeaders(token),
+ body: JSON.stringify(opts),
+ }).then(r => getResponse(r));
+};
+
+
+export const $post = (endpoint: string, opts: ApiOptions = {}, token?: string): Promise => {
+ return fetch(API_ENDPOINT + endpoint, {
+ method: 'POST',
+ headers: getHeaders(token),
+ body: JSON.stringify(opts),
+ }).then(r => getResponse(r));
+};
+
+export const $delete = (endpoint: string, opts: ApiOptions = {}, token?: string): Promise => {
+ return fetch(API_ENDPOINT + endpoint, {
+ method: 'DELETE',
+ headers: getHeaders(token),
+ body: JSON.stringify(opts),
+ }).then(r => getResponse(r));
+};
diff --git a/src/frontend/pages/announcement.tsx b/src/frontend/pages/announcement.tsx
new file mode 100644
index 0000000..f1bd8d9
--- /dev/null
+++ b/src/frontend/pages/announcement.tsx
@@ -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();
+
+ const lang = useSelector(state => state.screen.language);
+
+ useEffect(() => {
+ $get('announcements/' + id).then(setAnnouncement);
+ }, [setAnnouncement]);
+ return (
+
+ {!announcement ? : (
+ <>
+
+ {announcement.title}
+
+
+
+ {(() => {
+ const res: ReactNodeArray = [];
+ announcement.body.split('\n').forEach(s => {
+ res.push(<>{s}>);
+ res.push(
);
+ });
+ return res;
+ })()}
+
+ >
+ )}
+
+ );
+};
diff --git a/src/frontend/pages/index.session.tsx b/src/frontend/pages/index.session.tsx
index cc9aaf7..e2f99e2 100644
--- a/src/frontend/pages/index.session.tsx
+++ b/src/frontend/pages/index.session.tsx
@@ -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(0);
+ const [selectedTab, setSelectedTab] = useState('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('session', token))).then(a => dispatch(setAccounts(a as IUser[])));
}, [dispatch]);
- const items = useMemo(() => ([
- { label: t('_nav.misshai') },
- { label: t('_nav.accounts') },
- { label: t('_nav.settings') },
- ]), [i18n.language]);
+ const items = useMemo(() => {
+ 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 ;
- case 1: return ;
- case 2: return ;
+ case 'misshai': return ;
+ case 'accounts': return ;
+ case 'admin': return ;
+ case 'settings': return ;
default: return null;
}
}, [selectedTab]);
diff --git a/src/frontend/pages/index.welcome.tsx b/src/frontend/pages/index.welcome.tsx
index 2fcdeb5..3cdb304 100644
--- a/src/frontend/pages/index.welcome.tsx
+++ b/src/frontend/pages/index.welcome.tsx
@@ -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 = () => {
+
+
+
{t('_welcome.title')}
diff --git a/yarn.lock b/yarn.lock
index e858f6f..468b53a 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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==