diff --git a/package.json b/package.json index 48d8527..f90eba8 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "pug": "^3.0.0", "react": "^17.0.2", "react-dom": "^17.0.2", + "react-modal-hook": "^3.0.0", "react-redux": "^7.2.4", "react-router-dom": "^5.2.1", "reflect-metadata": "^0.1.13", diff --git a/src/backend/controllers/session.ts b/src/backend/controllers/session.ts index c58a1c0..28d1420 100644 --- a/src/backend/controllers/session.ts +++ b/src/backend/controllers/session.ts @@ -3,12 +3,12 @@ * @author Xeltica */ -import { IsEnum } from 'class-validator'; -import { Body, CurrentUser, Get, HttpCode, JsonController, OnUndefined, Post, Put } from 'routing-controllers'; +import { Body, CurrentUser, Get, JsonController, OnUndefined, Post, Put } from 'routing-controllers'; import { DeepPartial } from 'typeorm'; import { getScores } from '../functions/get-scores'; import { updateUser } from '../functions/users'; import { User } from '../models/entities/user'; +import { sendAlert } from '../services/send-alert'; import { UserSetting } from './UserSetting'; @JsonController('/session') @@ -25,14 +25,19 @@ export class SessionController { @OnUndefined(204) @Put() async updateSetting(@CurrentUser({ required: true }) user: User, @Body() setting: UserSetting) { const s: DeepPartial = {}; - if (setting.alertMode) s.alertMode = setting.alertMode; - if (setting.visibility) s.visibility = setting.visibility; - if (setting.localOnly) s.localOnly = setting.localOnly; - if (setting.remoteFollowersOnly) s.remoteFollowersOnly = setting.remoteFollowersOnly; - if (setting.template) s.template = setting.template; + if (setting.alertMode != null) s.alertMode = setting.alertMode; + if (setting.visibility != null) s.visibility = setting.visibility; + if (setting.localOnly != null) s.localOnly = setting.localOnly; + if (setting.remoteFollowersOnly != null) s.remoteFollowersOnly = setting.remoteFollowersOnly; + if (setting.template != null) s.template = setting.template; if (Object.keys(s).length === 0) return; await updateUser(user.username, user.host, s); } + + @OnUndefined(204) + @Post('/alert') async testAlert(@CurrentUser({ required: true }) user: User) { + await sendAlert(user); + } } diff --git a/src/frontend/App.tsx b/src/frontend/App.tsx index dbeebf1..3846ebe 100644 --- a/src/frontend/App.tsx +++ b/src/frontend/App.tsx @@ -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 { Provider } from 'react-redux'; @@ -6,14 +6,46 @@ import { IndexPage } from './pages'; import { RankingPage } from './pages/ranking'; import { Header } from './components/Header'; 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 './style.scss'; +import { ActualTheme } from './misc/theme'; const AppInner : React.VFC = () => { const $location = useLocation(); + const theme = useSelector(state => state.screen.theme); + + const [ osTheme, setOsTheme ] = useState('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 ( <>
@@ -27,6 +59,7 @@ const AppInner : React.VFC = () => {

(C)2020-2021 Xeltica

利用規約

+
); diff --git a/src/frontend/Modal.tsx b/src/frontend/Modal.tsx new file mode 100644 index 0000000..98d1284 --- /dev/null +++ b/src/frontend/Modal.tsx @@ -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 = { + 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 ( +
+
+ {modal.icon &&
} + {modal.title &&

{modal.title}

} +

{modal.message}

+
+ { + buttons.map((b, i) => ( + + )) + } +
+
+
+ ); +}; + +const ModalInner = (modal: Modal) => { + switch (modal.type) { + case 'dialog': return ; + case 'menu': return

Not Implemented

; + } +}; + +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 ( +
dispatch(hideModal())}> +
e.stopPropagation()}> + { ModalInner(modal) } +
+
+ ); +}; diff --git a/src/frontend/components/SettingPage.tsx b/src/frontend/components/SettingPage.tsx index 5b283b8..48c231c 100644 --- a/src/frontend/components/SettingPage.tsx +++ b/src/frontend/components/SettingPage.tsx @@ -1,25 +1,29 @@ -import React, { useCallback, useEffect, useMemo, useReducer, useState } from 'react'; -import { AlertMode } from '../../common/types/alert-mode'; +import React, { useCallback, useEffect, useReducer, useState } from 'react'; +import { alertModes } from '../../common/types/alert-mode'; 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 { defaultTemplate } from '../../common/default-template'; import { Card } from './Card'; import { Theme } from '../misc/theme'; 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; +>>; type DraftReducer = React.Reducer>; export const SettingPage: React.VFC = () => { const session = useGetSessionQuery(undefined); + const dispatch = useDispatch(); const data = session.data; @@ -48,23 +52,38 @@ export const SettingPage: React.VFC = () => { }, ]; - const [currentTheme, setCurrentTheme] = useState('light'); + const currentTheme = useSelector(state => state.screen.theme); const [currentLang, setCurrentLang] = useState('ja-JP'); - const updateSetting = useCallback(() => { - fetch(`${API_ENDPOINT}session`, { + const availableVisibilities: Visibility[] = [ + 'public', + 'home', + 'followers' + ]; + + const updateSetting = useCallback((obj: SettingDraftType) => { + dispatchDraft(obj); + return fetch(`${API_ENDPOINT}session`, { method: 'PUT', headers: { 'Authorization': `Bearer ${localStorage[LOCALSTORAGE_KEY_TOKEN]}`, 'Content-Type': 'application/json', }, - body: JSON.stringify(draft), - }) - .then(() => alert('設定を保存しました。')) + body: JSON.stringify(obj), + }); + }, []); + + const updateSettingWithDialog = useCallback((obj: SettingDraftType) => { + updateSetting(obj) + .then(() => dispatch(showModal({ + type: 'dialog', + icon: 'info', + message: '保存しました。' + }))) .catch(e => { alert(e.message); }); - }, [draft]); + }, [updateSetting]); useEffect(() => { if (data) { @@ -78,26 +97,70 @@ export const SettingPage: React.VFC = () => { } }, [data]); - const saveButton = useMemo(() => ( - - ), [updateSetting]); + const onClickSendAlert = useCallback(() => { + dispatch(showModal({ + type: 'dialog', + title: 'アラートをテスト送信しますか?', + 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 ? (
) : (
-

スコア通知方法

+

アラート送信方法

- + { + alertModes.map((mode) => ( + + )) + } {draft.alertMode === 'notification' && (
@@ -108,21 +171,24 @@ export const SettingPage: React.VFC = () => { { draft.alertMode === 'note' && (
- -
)} - {saveButton}

表示設定

@@ -131,7 +197,7 @@ export const SettingPage: React.VFC = () => { { themes.map(({ theme, name }) => ( )) @@ -147,8 +213,8 @@ export const SettingPage: React.VFC = () => {

テンプレート

-

アラートの自動投稿をカスタマイズできます。テンプレートに使える文字数は280文字です。空欄にすると、デフォルト値にリセットされます。

-