This commit is contained in:
Xeltica 2021-09-14 04:11:43 +09:00
parent 587136a82d
commit 7eeb90eddc
13 changed files with 392 additions and 54 deletions

View File

@ -59,6 +59,7 @@
"pug": "^3.0.0", "pug": "^3.0.0",
"react": "^17.0.2", "react": "^17.0.2",
"react-dom": "^17.0.2", "react-dom": "^17.0.2",
"react-modal-hook": "^3.0.0",
"react-redux": "^7.2.4", "react-redux": "^7.2.4",
"react-router-dom": "^5.2.1", "react-router-dom": "^5.2.1",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",

View File

@ -3,12 +3,12 @@
* @author Xeltica * @author Xeltica
*/ */
import { IsEnum } from 'class-validator'; import { Body, CurrentUser, Get, JsonController, OnUndefined, Post, Put } from 'routing-controllers';
import { Body, CurrentUser, Get, HttpCode, JsonController, OnUndefined, Post, Put } from 'routing-controllers';
import { DeepPartial } from 'typeorm'; import { DeepPartial } from 'typeorm';
import { getScores } from '../functions/get-scores'; import { getScores } from '../functions/get-scores';
import { updateUser } from '../functions/users'; import { updateUser } from '../functions/users';
import { User } from '../models/entities/user'; import { User } from '../models/entities/user';
import { sendAlert } from '../services/send-alert';
import { UserSetting } from './UserSetting'; import { UserSetting } from './UserSetting';
@JsonController('/session') @JsonController('/session')
@ -25,14 +25,19 @@ export class SessionController {
@OnUndefined(204) @OnUndefined(204)
@Put() async updateSetting(@CurrentUser({ required: true }) user: User, @Body() setting: UserSetting) { @Put() async updateSetting(@CurrentUser({ required: true }) user: User, @Body() setting: UserSetting) {
const s: DeepPartial<User> = {}; const s: DeepPartial<User> = {};
if (setting.alertMode) s.alertMode = setting.alertMode; if (setting.alertMode != null) s.alertMode = setting.alertMode;
if (setting.visibility) s.visibility = setting.visibility; if (setting.visibility != null) s.visibility = setting.visibility;
if (setting.localOnly) s.localOnly = setting.localOnly; if (setting.localOnly != null) s.localOnly = setting.localOnly;
if (setting.remoteFollowersOnly) s.remoteFollowersOnly = setting.remoteFollowersOnly; if (setting.remoteFollowersOnly != null) s.remoteFollowersOnly = setting.remoteFollowersOnly;
if (setting.template) s.template = setting.template; if (setting.template != null) s.template = setting.template;
if (Object.keys(s).length === 0) return; if (Object.keys(s).length === 0) return;
await updateUser(user.username, user.host, s); await updateUser(user.username, user.host, s);
} }
@OnUndefined(204)
@Post('/alert') async testAlert(@CurrentUser({ required: true }) user: User) {
await sendAlert(user);
}
} }

View File

@ -1,4 +1,4 @@
import * as React from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { BrowserRouter, Link, Route, Switch, useLocation } from 'react-router-dom'; import { BrowserRouter, Link, Route, Switch, useLocation } from 'react-router-dom';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
@ -6,14 +6,46 @@ import { IndexPage } from './pages';
import { RankingPage } from './pages/ranking'; import { RankingPage } from './pages/ranking';
import { Header } from './components/Header'; import { Header } from './components/Header';
import { TermPage } from './pages/term'; import { TermPage } from './pages/term';
import { store } from './store'; import { store, useSelector } from './store';
import { ModalComponent } from './Modal';
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 { ActualTheme } from './misc/theme';
const AppInner : React.VFC = () => { const AppInner : React.VFC = () => {
const $location = useLocation(); const $location = useLocation();
const theme = useSelector(state => state.screen.theme);
const [ osTheme, setOsTheme ] = useState<ActualTheme>('dark');
const applyTheme = useCallback(() => {
const actualTheme = theme === 'system' ? osTheme : theme;
if (actualTheme === 'dark') {
document.body.classList.add('dark');
} else {
document.body.classList.remove('dark');
}
}, [theme, osTheme]);
// テーマ変更に追従する
useEffect(() => {
applyTheme();
}, [theme, osTheme]);
// システムテーマ変更に追従する
useEffect(() => {
const q = window.matchMedia('(prefers-color-scheme: dark)');
setOsTheme(q.matches ? 'dark' : 'light');
const listener = () => setOsTheme(q.matches ? 'dark' : 'light');
q.addEventListener('change', listener);
return () => {
q.removeEventListener('change', listener);
};
}, [osTheme, setOsTheme]);
return ( return (
<> <>
<div className="container"> <div className="container">
@ -27,6 +59,7 @@ const AppInner : React.VFC = () => {
<p>(C)2020-2021 Xeltica</p> <p>(C)2020-2021 Xeltica</p>
<p><Link to="/term"></Link></p> <p><Link to="/term"></Link></p>
</footer> </footer>
<ModalComponent />
</div> </div>
</> </>
); );

82
src/frontend/Modal.tsx Normal file
View File

@ -0,0 +1,82 @@
import React, { useCallback } from 'react';
import { useSelector } from './store';
import {
builtinDialogButtonNo,
builtinDialogButtonOk,
builtinDialogButtonYes,
DialogButton,
DialogButtonType,
DialogIcon,
ModalTypeDialog
} from './modal/dialog';
import { Modal } from './modal/modal';
import { useDispatch } from 'react-redux';
import { hideModal } from './store/slices/screen';
const getButtons = (button: DialogButtonType): DialogButton[] => {
if (typeof button === 'object') return button;
switch (button) {
case 'ok': return [builtinDialogButtonOk];
case 'yesNo': return [builtinDialogButtonYes, builtinDialogButtonNo];
}
};
const dialogIconPattern: Record<DialogIcon, string> = {
error: 'bi bi-x-circle-fill text-danger',
info: 'bi bi-info-circle-fill text-primary',
question: 'bi bi-question-circle-fill text-primary',
warning: 'bi bi-exclamation-circle-fill text-warning',
};
const Dialog: React.VFC<{modal: ModalTypeDialog}> = ({modal}) => {
const buttons = getButtons(modal.buttons ?? 'ok');
const dispatch = useDispatch();
const onClickButton = useCallback((i: number) => {
dispatch(hideModal());
if (modal.onSelect) {
modal.onSelect(i);
}
}, [dispatch, modal]);
return (
<div className="card dialog text-center">
<div className="body">
{modal.icon && <div style={{fontSize: '2rem'}} className={dialogIconPattern[modal.icon]} />}
{modal.title && <h1>{modal.title}</h1>}
<p>{modal.message}</p>
<div className="hstack" style={{justifyContent: 'center'}}>
{
buttons.map((b, i) => (
<button className={`btn ${b.style}`} onClick={() => onClickButton(i)} key={i}>
{b.text}
</button>
))
}
</div>
</div>
</div>
);
};
const ModalInner = (modal: Modal) => {
switch (modal.type) {
case 'dialog': return <Dialog modal={modal} />;
case 'menu': return <p>Not Implemented</p>;
}
};
export const ModalComponent: React.VFC = () => {
const shown = useSelector(state => state.screen.modalShown);
const modal = useSelector(state => state.screen.modal);
const dispatch = useDispatch();
if (!shown || !modal) return null;
return (
<div className="modal" onClick={() => dispatch(hideModal())}>
<div className="fade up" onClick={(e) => e.stopPropagation()}>
{ ModalInner(modal) }
</div>
</div>
);
};

View File

@ -1,25 +1,29 @@
import React, { useCallback, useEffect, useMemo, useReducer, useState } from 'react'; import React, { useCallback, useEffect, useReducer, useState } from 'react';
import { AlertMode } 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 { visibilities, Visibility } from '../../common/types/visibility';
import { useGetSessionQuery } from '../services/session'; import { useGetSessionQuery } from '../services/session';
import { defaultTemplate } from '../../common/default-template'; import { defaultTemplate } from '../../common/default-template';
import { Card } from './Card'; import { Card } from './Card';
import { Theme } from '../misc/theme'; import { Theme } from '../misc/theme';
import { API_ENDPOINT, LOCALSTORAGE_KEY_TOKEN } from '../const'; import { API_ENDPOINT, LOCALSTORAGE_KEY_TOKEN } from '../const';
import { useDispatch } from 'react-redux';
import { changeTheme, showModal } from '../store/slices/screen';
import { useSelector } from '../store';
type SettingDraftType = Pick<IUser, type SettingDraftType = Partial<Pick<IUser,
| 'alertMode' | 'alertMode'
| 'visibility' | 'visibility'
| 'localOnly' | 'localOnly'
| 'remoteFollowersOnly' | 'remoteFollowersOnly'
| 'template' | 'template'
>; >>;
type DraftReducer = React.Reducer<SettingDraftType, Partial<SettingDraftType>>; type DraftReducer = React.Reducer<SettingDraftType, Partial<SettingDraftType>>;
export const SettingPage: React.VFC = () => { export const SettingPage: React.VFC = () => {
const session = useGetSessionQuery(undefined); const session = useGetSessionQuery(undefined);
const dispatch = useDispatch();
const data = session.data; const data = session.data;
@ -48,23 +52,38 @@ export const SettingPage: React.VFC = () => {
}, },
]; ];
const [currentTheme, setCurrentTheme] = useState<Theme>('light'); const currentTheme = useSelector(state => state.screen.theme);
const [currentLang, setCurrentLang] = useState<string>('ja-JP'); const [currentLang, setCurrentLang] = useState<string>('ja-JP');
const updateSetting = useCallback(() => { const availableVisibilities: Visibility[] = [
fetch(`${API_ENDPOINT}session`, { 'public',
'home',
'followers'
];
const updateSetting = useCallback((obj: SettingDraftType) => {
dispatchDraft(obj);
return fetch(`${API_ENDPOINT}session`, {
method: 'PUT', method: 'PUT',
headers: { headers: {
'Authorization': `Bearer ${localStorage[LOCALSTORAGE_KEY_TOKEN]}`, 'Authorization': `Bearer ${localStorage[LOCALSTORAGE_KEY_TOKEN]}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify(draft), body: JSON.stringify(obj),
}) });
.then(() => alert('設定を保存しました。')) }, []);
const updateSettingWithDialog = useCallback((obj: SettingDraftType) => {
updateSetting(obj)
.then(() => dispatch(showModal({
type: 'dialog',
icon: 'info',
message: '保存しました。'
})))
.catch(e => { .catch(e => {
alert(e.message); alert(e.message);
}); });
}, [draft]); }, [updateSetting]);
useEffect(() => { useEffect(() => {
if (data) { if (data) {
@ -78,26 +97,70 @@ export const SettingPage: React.VFC = () => {
} }
}, [data]); }, [data]);
const saveButton = useMemo(() => ( const onClickSendAlert = useCallback(() => {
<button className="btn primary" style={{alignSelf: 'flex-end'}} onClick={updateSetting}> dispatch(showModal({
type: 'dialog',
</button> title: 'アラートをテスト送信しますか?',
), [updateSetting]); message: '現在の設定でアラートを送信します。設定が保存済みであるかどうか、実行前に必ずご確認ください。',
icon: 'question',
buttons: 'yesNo',
onSelect(i) {
if (i === 0) {
fetch(`${API_ENDPOINT}session/alert`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage[LOCALSTORAGE_KEY_TOKEN]}`,
},
}).then(() => {
dispatch(showModal({
type: 'dialog',
message: '送信しました。',
icon: 'info',
}));
}).catch((e) => {
console.error(e);
dispatch(showModal({
type: 'dialog',
message: '送信に失敗しました。',
icon: 'error',
}));
});
}
},
}));
}, [dispatch]);
const onClickLogout = useCallback(() => {
dispatch(showModal({
type: 'dialog',
message: 'WIP',
}));
}, [dispatch]);
const onClickDeleteAccount = useCallback(() => {
dispatch(showModal({
type: 'dialog',
message: 'WIP',
}));
}, [dispatch]);
return session.isLoading || !data ? ( return session.isLoading || !data ? (
<div className="skeleton" style={{width: '100%', height: '128px'}}></div> <div className="skeleton" style={{width: '100%', height: '128px'}}></div>
) : ( ) : (
<div className="vstack fade"> <div className="vstack fade">
<Card bodyClassName="vstack"> <Card bodyClassName="vstack">
<h1></h1> <h1></h1>
<div> <div>
<select name="alertMode" className="input-field" value={draft.alertMode} onChange={(e) => { {
dispatchDraft({ alertMode: e.target.value as AlertMode }); alertModes.map((mode) => (
}}> <label key={mode} className="input-check">
<option value="note">稿</option> <input type="radio" checked={mode === draft.alertMode} onChange={() => {
<option value="notification">Misskeyに通知()</option> updateSetting({ alertMode: mode });
<option value="nothing"></option> }} />
</select> <span>{mode}</span>
</label>
))
}
{draft.alertMode === 'notification' && ( {draft.alertMode === 'notification' && (
<div className="alert bg-danger mt-2"> <div className="alert bg-danger mt-2">
<i className="icon bi bi-exclamation-circle"></i> <i className="icon bi bi-exclamation-circle"></i>
@ -108,21 +171,24 @@ export const SettingPage: React.VFC = () => {
{ draft.alertMode === 'note' && ( { draft.alertMode === 'note' && (
<div> <div>
<label htmlFor="visibility" className="input-field"></label> <label htmlFor="visibility" className="input-field"></label>
<select name="visibility" className="input-field" value={draft.visibility} onChange={(e) => { {
dispatchDraft({ visibility: e.target.value as Visibility }); availableVisibilities.map((visibility) => (
}}> <label key={visibility} className="input-check">
<option value="public"></option> <input type="radio" checked={visibility === draft.visibility} onChange={() => {
<option value="home"></option> updateSetting({ visibility });
<option value="followers"></option> }} />
</select> <span>{visibility}</span>
<label className="input-switch mt-2"> </label>
<input type="checkbox" /> ))
<div className="switch"></div> }
<label className="input-check mt-2">
<input type="checkbox" checked={draft.localOnly} onChange={(e) => {
updateSetting({ localOnly: e.target.checked });
}} />
<span></span> <span></span>
</label> </label>
</div> </div>
)} )}
{saveButton}
</Card> </Card>
<Card bodyClassName="vstack"> <Card bodyClassName="vstack">
<h1></h1> <h1></h1>
@ -131,7 +197,7 @@ export const SettingPage: React.VFC = () => {
{ {
themes.map(({ theme, name }) => ( themes.map(({ theme, name }) => (
<label key={theme} className="input-check"> <label key={theme} className="input-check">
<input type="radio" name={theme} value={theme} checked={theme === currentTheme} onChange={(e) => setCurrentTheme(e.target.value as Theme)} /> <input type="radio" value={theme} checked={theme === currentTheme} onChange={(e) => dispatch(changeTheme(e.target.value as Theme))} />
<span>{name}</span> <span>{name}</span>
</label> </label>
)) ))
@ -147,8 +213,8 @@ export const SettingPage: React.VFC = () => {
<Card bodyClassName="vstack"> <Card bodyClassName="vstack">
<h1></h1> <h1></h1>
<p>稿使280</p> <p>稿</p>
<textarea className="input-field" value={draft.template ?? defaultTemplate} style={{height: 228}} onChange={(e) => { <textarea className="input-field" value={draft.template ?? defaultTemplate} placeholder={defaultTemplate} style={{height: 228}} onChange={(e) => {
dispatchDraft({ template: e.target.value }); dispatchDraft({ template: e.target.value });
}} /> }} />
<small className="text-dimmed"> <small className="text-dimmed">
@ -160,24 +226,29 @@ export const SettingPage: React.VFC = () => {
<li><code>{'{'}notesCount{'}'}</code>稿</li> <li><code>{'{'}notesCount{'}'}</code>稿</li>
</ul> </ul>
</details> </details>
{saveButton} <div className="hstack" style={{justifyContent: 'flex-end'}}>
<button className="btn danger" onClick={() => dispatchDraft({ template: null })}></button>
<button className="btn primary" onClick={() => {
updateSettingWithDialog({ template: draft.template === '' ? null : draft.template });
}}></button>
</div>
</Card> </Card>
<Card bodyClassName="vstack"> <Card bodyClassName="vstack">
<button className="btn block"></button> <button className="btn block" onClick={onClickSendAlert}></button>
<p className="text-dimmed"> <p className="text-dimmed">
</p> </p>
</Card> </Card>
<Card bodyClassName="vstack"> <Card bodyClassName="vstack">
<button className="btn block"></button> <button className="btn block" onClick={onClickLogout}></button>
<p className="text-dimmed"> <p className="text-dimmed">
</p> </p>
</Card> </Card>
<Card bodyClassName="vstack"> <Card bodyClassName="vstack">
<button className="btn danger block"></button> <button className="btn danger block" onClick={onClickDeleteAccount}></button>
<p className="text-dimmed"> <p className="text-dimmed">
Misskeyとの連携設定を含むみす廃アラートのアカウントを削除します Misskeyとの連携設定を含むみす廃アラートのアカウントを削除します
</p> </p>
</Card> </Card>
</div> </div>

View File

@ -1,3 +1,4 @@
export const LOCALSTORAGE_KEY_TOKEN = 'token'; export const LOCALSTORAGE_KEY_TOKEN = 'token';
export const LOCALSTORAGE_KEY_THEME = 'THEME';
export const API_ENDPOINT = `//${location.host}/api/v1/`; export const API_ENDPOINT = `//${location.host}/api/v1/`;

View File

@ -0,0 +1,33 @@
export interface ModalTypeDialog {
type: 'dialog';
title?: string;
message: string;
icon?: DialogIcon;
buttons?: DialogButtonType;
onSelect?: (clickedButtonIndex: number) => void;
}
export type DialogIcon = 'info' | 'warning' | 'error' | 'question';
export type DialogButtonType = 'ok' | 'yesNo' | DialogButton[];
export type DialogButtonStyle = 'primary' | 'danger';
export interface DialogButton {
text: string;
style?: DialogButtonStyle;
}
export const builtinDialogButtonOk: DialogButton = {
text: 'OK',
style: 'primary',
};
export const builtinDialogButtonYes: DialogButton = {
text: 'はい',
style: 'primary',
};
export const builtinDialogButtonNo: DialogButton = {
text: 'いいえ',
};

View File

@ -0,0 +1,16 @@
export interface ModalTypeMenu {
type: 'menu';
screenX: number;
screenY: number;
items: MenuItem[];
}
export type MenuItemClassName = `bi bi-${string}`;
export interface MenuItem {
icon?: MenuItemClassName;
name: string;
onClick: VoidFunction;
disabled?: boolean;
danger?: boolean;
}

View File

@ -0,0 +1,7 @@
import { ModalTypeDialog } from './dialog';
import { ModalTypeMenu } from './menu';
export type Modal =
| ModalTypeMenu
| ModalTypeDialog;

View File

@ -2,10 +2,12 @@ import { configureStore } from '@reduxjs/toolkit';
import { setupListeners } from '@reduxjs/toolkit/dist/query'; import { setupListeners } from '@reduxjs/toolkit/dist/query';
import { useSelector as useSelectorBase, TypedUseSelectorHook } from 'react-redux'; import { useSelector as useSelectorBase, TypedUseSelectorHook } from 'react-redux';
import { sessionApi } from '../services/session'; import { sessionApi } from '../services/session';
import ScreenReducer from './slices/screen';
export const store = configureStore({ export const store = configureStore({
reducer: { reducer: {
[sessionApi.reducerPath]: sessionApi.reducer, [sessionApi.reducerPath]: sessionApi.reducer,
screen: ScreenReducer,
}, },
middleware: (defaultMiddleware) => defaultMiddleware() middleware: (defaultMiddleware) => defaultMiddleware()

View File

@ -0,0 +1,39 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { LOCALSTORAGE_KEY_THEME } from '../../const';
import { Theme } from '../../misc/theme';
import { Modal } from '../../modal/modal';
interface ScreenState {
modal: Modal | null;
modalShown: boolean;
theme: Theme;
}
const initialState: ScreenState = {
modal: null,
modalShown: false,
theme: localStorage[LOCALSTORAGE_KEY_THEME] ?? 'system',
};
export const screenSlice = createSlice({
name: 'screen',
initialState,
reducers: {
showModal: (state, action: PayloadAction<Modal>) => {
state.modal = action.payload;
state.modalShown = true;
},
hideModal: (state) => {
state.modal = null;
state.modalShown = false;
},
changeTheme: (state, action: PayloadAction<Theme>) => {
state.theme = action.payload;
localStorage[LOCALSTORAGE_KEY_THEME] = action.payload;
},
},
});
export const { showModal, hideModal, changeTheme } = screenSlice.actions;
export default screenSlice.reducer;

View File

@ -1,5 +1,6 @@
body { body {
--primary: rgb(134, 179, 0); --primary: rgb(134, 179, 0);
font-feature-settings: "pkna";
} }
.xarticle { .xarticle {
@ -9,9 +10,24 @@ body {
.fade { .fade {
animation: 0.3s ease-out 0s fadeIn; animation: 0.3s ease-out 0s fadeIn;
&.down {
animation-name: fadeInUp;
}
&.up {
animation-name: fadeInUp;
}
} }
@keyframes fadeIn { @keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fadeInDown {
from { from {
opacity: 0; opacity: 0;
transform: translateY(-8px); transform: translateY(-8px);
@ -22,6 +38,17 @@ body {
} }
} }
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: none;
}
}
.block { .block {
display: block !important; display: block !important;
} }
@ -33,3 +60,19 @@ body {
small { small {
font-size: 0.8rem; font-size: 0.8rem;
} }
.modal {
position: fixed;
inset: 0;
display: flex;
justify-content: center;
align-items: center;
background: #000000c0;
z-index: 40000;
}
.dark .card.dialog {
background: var(--tone-2);
min-width: min(100vw, 320px);
max-width: min(100vw, 600px);
}

View File

@ -4275,6 +4275,11 @@ react-is@^16.13.1, react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.1:
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
react-modal-hook@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/react-modal-hook/-/react-modal-hook-3.0.0.tgz#24b2ff2acf288ef25c11e4fb11b329458a823ea3"
integrity sha512-mNkJwgEtOoIabuILlWnGAto993WVkimZASBWk/rAGZ6tNIqjx4faQtNdr/X31vw+QGfKhspXc4vPi1jbfkk2Yg==
react-redux@^7.2.4: react-redux@^7.2.4:
version "7.2.4" version "7.2.4"
resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.2.4.tgz#1ebb474032b72d806de2e0519cd07761e222e225" resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.2.4.tgz#1ebb474032b72d806de2e0519cd07761e222e225"