wip
This commit is contained in:
parent
587136a82d
commit
7eeb90eddc
@ -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",
|
||||||
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -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
82
src/frontend/Modal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
@ -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>
|
||||||
|
@ -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/`;
|
||||||
|
33
src/frontend/modal/dialog.ts
Normal file
33
src/frontend/modal/dialog.ts
Normal 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: 'いいえ',
|
||||||
|
};
|
16
src/frontend/modal/menu.ts
Normal file
16
src/frontend/modal/menu.ts
Normal 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;
|
||||||
|
}
|
7
src/frontend/modal/modal.ts
Normal file
7
src/frontend/modal/modal.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { ModalTypeDialog } from './dialog';
|
||||||
|
import { ModalTypeMenu } from './menu';
|
||||||
|
|
||||||
|
export type Modal =
|
||||||
|
| ModalTypeMenu
|
||||||
|
| ModalTypeDialog;
|
||||||
|
|
@ -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()
|
||||||
|
39
src/frontend/store/slices/screen.ts
Normal file
39
src/frontend/store/slices/screen.ts
Normal 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;
|
@ -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);
|
||||||
|
}
|
@ -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"
|
||||||
|
Loading…
Reference in New Issue
Block a user