0
0
Fork 0
This commit is contained in:
Xeltica 2022-06-09 12:20:13 +09:00
parent a77a4008e6
commit 2301fe5eff
32 changed files with 547 additions and 400 deletions

View file

@ -27,6 +27,7 @@
"dependencies": { "dependencies": {
"@babel/preset-react": "^7.14.5", "@babel/preset-react": "^7.14.5",
"@reduxjs/toolkit": "^1.6.1", "@reduxjs/toolkit": "^1.6.1",
"@types/deepmerge": "^2.2.0",
"@types/insert-text-at-cursor": "^0.3.0", "@types/insert-text-at-cursor": "^0.3.0",
"@types/koa-bodyparser": "^4.3.0", "@types/koa-bodyparser": "^4.3.0",
"@types/koa-multer": "^1.0.0", "@types/koa-multer": "^1.0.0",
@ -45,6 +46,7 @@
"class-validator": "^0.13.1", "class-validator": "^0.13.1",
"css-loader": "^6.2.0", "css-loader": "^6.2.0",
"dayjs": "^1.10.7", "dayjs": "^1.10.7",
"deepmerge": "^4.2.2",
"delay": "^4.4.0", "delay": "^4.4.0",
"fibers": "^5.0.0", "fibers": "^5.0.0",
"i18next": "^20.6.1", "i18next": "^20.6.1",

View file

@ -15,7 +15,7 @@ html
meta(name='twitter:site' content='@Xeltica') meta(name='twitter:site' content='@Xeltica')
meta(name='twitter:creator' content='@Xeltica') meta(name='twitter:creator' content='@Xeltica')
link(rel="stylesheet" href="https://koruri.chillout.chat/koruri.css") link(rel="stylesheet" href="https://koruri.chillout.chat/koruri.css")
link(rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css") script(src='https://kit.fontawesome.com/c7ab6eba70.js' crossorigin='anonymous')
style. style.
.loading { .loading {
display: flex; display: flex;

View file

@ -1,91 +1,64 @@
import React from 'react'; import React, { useEffect } from 'react';
import { BrowserRouter, Link, Route, Switch, useLocation } from 'react-router-dom'; import { BrowserRouter, useLocation } from 'react-router-dom';
import { Provider } from 'react-redux'; import { Provider, useDispatch } from 'react-redux';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
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 } from './store';
import { ModalComponent } from './Modal'; import { ModalComponent } from './Modal';
import { useTheme } from './misc/theme'; import { useTheme } from './misc/theme';
import { getBrowserLanguage, resources } from './langs'; import { BREAKPOINT_SM, XELTICA_STUDIO_URL } from './const';
import { LOCALSTORAGE_KEY_LANG, XELTICA_STUDIO_URL } from './const';
import { useGetSessionQuery } from './services/session'; import { useGetSessionQuery } from './services/session';
import { AnnouncementPage } from './pages/announcement'; import { Router } from './Router';
import { setMobile } from './store/slices/screen';
import 'xeltica-ui/dist/css/xeltica-ui.min.css'; import { GeneralLayout } from './GeneralLayout';
import './style.scss';
document.body.classList.add('dark');
if (!localStorage[LOCALSTORAGE_KEY_LANG]) {
localStorage[LOCALSTORAGE_KEY_LANG] = getBrowserLanguage();
}
i18n
.use(initReactI18next)
.init({
resources,
lng: localStorage[LOCALSTORAGE_KEY_LANG],
interpolation: {
escapeValue: false // react already safes from xss
}
});
const AppInner : React.VFC = () => { const AppInner : React.VFC = () => {
const { data: session } = useGetSessionQuery(undefined); const { data: session } = useGetSessionQuery(undefined);
const $location = useLocation(); const $location = useLocation();
const dispatch = useDispatch();
useTheme(); useTheme();
const {t} = useTranslation(); const {t} = useTranslation();
const error = (window as any).__misshaialert?.error; const error = (window as any).__misshaialert?.error;
return error ? ( useEffect(() => {
<div className="container"> const qMobile = window.matchMedia(`(max-width: ${BREAKPOINT_SM})`);
<Header hasTopLink className="xarticle mb-2" /> const syncMobile = (ev: MediaQueryListEvent) => dispatch(setMobile(ev.matches));
<div className="xarticle"> dispatch(setMobile(qMobile.matches));
qMobile.addEventListener('change', syncMobile);
return () => {
qMobile.removeEventListener('change', syncMobile);
};
}, []);
const TheLayout = session || $location.pathname !== '/' ? GeneralLayout : 'div';
return (
<TheLayout>
{error ? (
<div>
<h1>{t('error')}</h1> <h1>{t('error')}</h1>
<p>{t('_error.sorry')}</p> <p>{t('_error.sorry')}</p>
<p> <p>
{t('_error.additionalInfo')} {t('_error.additionalInfo')}
{t(`_error.${error}`)} {t(`_error.${error}`)}
</p> </p>
<Link to="/" onClick={() => (window as any).__misshaialert.error = null}>{t('retry')}</Link>
</div> </div>
</div> ) : <Router />}
) : (
<div className="container">
{$location.pathname !== '/' && <Header hasTopLink className="xarticle mb-2" />}
<Switch>
<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"> <footer className="text-center pa-5">
<p>(C)2020-2022 <a href={XELTICA_STUDIO_URL} target="_blank" rel="noopener noreferrer">Xeltica Studio</a></p> <p>(C)2020-2022 <a href={XELTICA_STUDIO_URL} target="_blank" rel="noopener noreferrer">Xeltica Studio</a></p>
<p dangerouslySetInnerHTML={{__html: t('disclaimerForMisskeyHq')}} />
<p><Link to="/term">{t('termsOfService')}</Link></p>
{session && (
<p> <p>
<a <a href="https://xeltica.notion.site/Misskey-Tools-688187fc85de4b7e901055326c7ffe74" target="_blank" rel="noreferrer noopener">
className="btn primary" {t('termsOfService')}
href={`https://${session.host}/share?text=${encodeURIComponent(t('shareMisskeyToolsNote') as string)}`}
target="_blank"
rel="noreferrer noopener">
{t('shareMisskeyTools')}
</a> </a>
</p> </p>
)}
</footer> </footer>
<ModalComponent /> <ModalComponent />
</div> </TheLayout>
); );
}; };

View file

@ -0,0 +1,118 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { NavLink } from 'react-router-dom';
import styled from 'styled-components';
import { useGetMetaQuery, useGetSessionQuery } from './services/session';
import { useSelector } from './store';
type IsMobileProp = { isMobile: boolean };
const Container = styled.div<IsMobileProp>`
padding: var(--margin);
position: relative;
`;
const Sidebar = styled.nav`
width: 320px;
position: fixed;
top: var(--margin);
left: var(--margin);
`;
const Main = styled.main<IsMobileProp>`
flex: 1;
margin-top: 80px;
margin-left: ${p => !p.isMobile ? `${320 + 16}px` : 0};
min-width: 0;
`;
const MobileHeader = styled.header`
position: fixed;
top: 0;
left: 0;
right: 0;
height: 64px;
background: var(--panel);
> h1 {
font-size: 1rem;
margin-bottom: 0;
}
`;
export const GeneralLayout: React.FC = ({children}) => {
const { data: session } = useGetSessionQuery(undefined);
const { data: meta } = useGetMetaQuery(undefined);
const { isMobile, title } = useSelector(state => state.screen);
const {t} = useTranslation();
const navLinkClassName = (isActive: boolean) => `item ${isActive ? 'active' : ''}`;
return (
<Container isMobile={isMobile}>
{isMobile && (
<MobileHeader className="navbar hstack f-middle shadow-2 pl-2">
<button className="btn flat">
<i className="fas fa-bars"></i>
</button>
<h1>{t(title ?? 'title')}</h1>
</MobileHeader>
)}
<div>
{!isMobile && (
<Sidebar className="pa-2">
<h1 className="text-175 text-primary mb-2">{t('title')}</h1>
<div className="menu">
<section>
<NavLink className={navLinkClassName} to="/" exact>
<i className="icon fas fa-home"></i>
{t('_sidebar.dashboard')}
</NavLink>
</section>
<section>
<h1>{t('_sidebar.tools')}</h1>
<NavLink className={navLinkClassName} to="/apps/miss-hai">
<i className="icon fas fa-tower-broadcast"></i>
{t('_sidebar.missHaiAlert')}
</NavLink>
<NavLink className={navLinkClassName} to="/apps/avatar-cropper">
<i className="icon fas fa-crop-simple"></i>
{t('_sidebar.cropper')}
</NavLink>
</section>
<section>
{session && <h1>{session.username}@{session.host}</h1>}
{session && (
<NavLink className={navLinkClassName} to="/account">
<i className="icon fas fa-circle-user"></i>
{t('_sidebar.accounts')}
</NavLink>
)}
<NavLink className={navLinkClassName} to="/settings">
<i className="icon fas fa-gear"></i>
{t('_sidebar.settings')}
</NavLink>
<NavLink className={navLinkClassName} to="/admin">
<i className="icon fas fa-lock"></i>
{t('_sidebar.admin')}
</NavLink>
</section>
</div>
</Sidebar>
)}
<Main isMobile={isMobile}>
{session && meta && meta.currentTokenVersion !== session.tokenVersion && (
<div className="alert bg-danger flex f-middle mb-2">
<i className="icon fas fa-circle-exclamation"></i>
{t('shouldUpdateToken')}
<a className="btn primary" href={`/login?host=${encodeURIComponent(session.host)}`}>
{t('update')}
</a>
</div>
)}
{children}
</Main>
</div>
</Container>
);
};

31
src/frontend/Header.tsx Normal file
View file

@ -0,0 +1,31 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useGetSessionQuery } from './services/session';
import { useSelector } from './store';
export type HeaderProps = {
title?: string;
};
export const Header: React.FC<HeaderProps> = ({title}) => {
const { t } = useTranslation();
const { data } = useGetSessionQuery(undefined);
const { isMobile } = useSelector(state => state.screen);
return (
<header className="navbar hstack shadow-2 bg-panel rounded _header">
<h1 className="navbar-title text-primary mb-0 text-100">
{<Link to="/">{t('title')}</Link>}
{title && <> / {title}</>}
</h1>
{data && (
<button className="btn flat ml-auto primary">
<i className="fas fa-circle-user"></i>
{!isMobile && <span className="ml-1">{data.username}<span className="text-dimmed">@{data.host}</span></span>}
</button>
)}
</header>
);
};

View file

@ -23,10 +23,10 @@ const getButtons = (button: DialogButtonType): DialogButton[] => {
}; };
const dialogIconPattern: Record<DialogIcon, string> = { const dialogIconPattern: Record<DialogIcon, string> = {
error: 'bi bi-x-circle-fill text-danger', error: 'fas fa-circle-xmark text-danger',
info: 'bi bi-info-circle-fill text-primary', info: 'fas fa-circle-info text-primary',
question: 'bi bi-question-circle-fill text-primary', question: 'fas fa-circle-question text-primary',
warning: 'bi bi-exclamation-circle-fill text-warning', warning: 'fas fa-circle-exclamation text-warning',
}; };
const Dialog: React.VFC<{modal: ModalTypeDialog}> = ({modal}) => { const Dialog: React.VFC<{modal: ModalTypeDialog}> = ({modal}) => {

25
src/frontend/Router.tsx Normal file
View file

@ -0,0 +1,25 @@
import React from 'react';
import { Route, Switch } from 'react-router-dom';
import { IndexPage } from './pages';
import { AnnouncementPage } from './pages/announcement';
import { RankingPage } from './pages/apps/misshai/ranking';
import { AdminPage } from './pages/admin';
import { AccountsPage } from './pages/account';
import { SettingPage } from './pages/settings';
import { MisshaiPage } from './pages/apps/misshai';
import { NekomimiPage } from './pages/apps/avatar-cropper';
export const Router: React.VFC = () => {
return (
<Switch>
<Route exact path="/" component={IndexPage} />
<Route exact path="/apps/avatar-cropper" component={NekomimiPage} />
<Route exact path="/apps/miss-hai" component={MisshaiPage} />
<Route exact path="/apps/miss-hai/ranking" component={RankingPage} />
<Route exact path="/announcements/:id" component={AnnouncementPage} />
<Route exact path="/account" component={AccountsPage} />
<Route exact path="/settings" component={SettingPage} />
<Route exact path="/admin" component={AdminPage} />
</Switch>
);
};

View file

@ -24,7 +24,7 @@ export const AnnouncementList: React.VFC = () => {
return ( return (
<> <>
<h1 className="mb-0"><i className="bi-bell"></i> {t('announcements')}</h1> <h1 className="mb-0"><i className="fas fa-bell"></i> {t('announcements')}</h1>
<div className="large menu fade"> <div className="large menu fade">
{announcements.map(a => ( {announcements.map(a => (
<Link className="item fluid" key={a.id} to={`/announcements/${a.id}`}> <Link className="item fluid" key={a.id} to={`/announcements/${a.id}`}>

View file

@ -5,6 +5,6 @@ import { Skeleton } from './Skeleton';
export const CurrentUser: React.VFC = () => { export const CurrentUser: React.VFC = () => {
const {data} = useGetSessionQuery(undefined); const {data} = useGetSessionQuery(undefined);
return data ? ( return data ? (
<h1 className="text-125"><i className="bi-person"></i> {data.username}<span className="text-dimmed">@{data.host}</span></h1> <h1 className="text-125"><i className="fas fa-users"></i> {data.username}<span className="text-dimmed">@{data.host}</span></h1>
) : <Skeleton height="1.5rem" />; ) : <Skeleton height="1.5rem" />;
}; };

View file

@ -5,19 +5,19 @@ export const DeveloperInfo: React.VFC = () => {
const {t} = useTranslation(); const {t} = useTranslation();
return ( return (
<> <>
<h1><i className="bi-question-circle"></i> {t('_developerInfo.title')}</h1> <h1><i className="fas fa-circle-question"></i> {t('_developerInfo.title')}</h1>
<p>{t('_developerInfo.description')}</p> <p>{t('_developerInfo.description')}</p>
<div className="menu large"> <div className="menu large">
<a className="item" href="http://groundpolis.app/@Lutica" target="_blank" rel="noopener noreferrer"> <a className="item" href="http://groundpolis.app/@Lutica" target="_blank" rel="noopener noreferrer">
<i className="icon bi-at"></i> <i className="icon fas fa-at"></i>
Lutica@groundpolis.app Lutica@groundpolis.app
</a> </a>
<a className="item" href="http://misskey.io/@le" target="_blank" rel="noopener noreferrer"> <a className="item" href="http://misskey.io/@le" target="_blank" rel="noopener noreferrer">
<i className="icon bi-at"></i> <i className="icon fas fa-at"></i>
le@misskey.io le@misskey.io
</a> </a>
<a className="item" href="http://twitter.com/@EbiseLutica" target="_blank" rel="noopener noreferrer"> <a className="item" href="http://twitter.com/@EbiseLutica" target="_blank" rel="noopener noreferrer">
<i className="icon bi-at"></i> <i className="icon fas fa-at"></i>
EbiseLutica@twitter.com EbiseLutica@twitter.com
</a> </a>
</div> </div>

View file

@ -1,54 +0,0 @@
import React, { HTMLProps, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useGetMetaQuery, useGetSessionQuery } from '../services/session';
import { CHANGELOG_URL } from '../const';
import { createGacha } from '../../common/functions/create-gacha';
export type HeaderProps = {
hasTopLink?: boolean;
className?: HTMLProps<HTMLElement>['className'],
style?: HTMLProps<HTMLElement>['style'],
};
export const Header: React.FC<HeaderProps> = ({hasTopLink, children, className, style}) => {
const {data: meta} = useGetMetaQuery(undefined);
const {data: session} = useGetSessionQuery(undefined);
const { t } = useTranslation();
const [generation, setGeneration] = useState(0);
const gacha = useMemo(() => createGacha(), [generation]);
return (
<header className={`card ${className ?? ''}`} style={style}>
<div className="body">
<h1 className="text-primary mb-0" style={{ fontSize: '2rem' }}>
{hasTopLink ? <Link to="/">{t('title')}</Link> : t('title')}
{meta && (
<a href={CHANGELOG_URL} target="_blank" rel="noopener noreferrer" className="text-125 text-dimmed ml-1">
v{meta?.version}
</a>
)}
</h1>
<h2 className="text-dimmed">
<button onClick={() => setGeneration(g => g + 1)} className="text-primary">
<i className="bi bi-dice-5-fill" />
</button>
{gacha}
{session && (
<a
href={`https://${session.host}/share?text=${encodeURIComponent(`${gacha} https://misskey.tools`)}`}
target="_blank"
rel="noreferrer noopener"
className="ml-1">
<i className="bi bi-share-fill" />
</a>
)}
</h2>
{children}
</div>
</header>
);
};

View file

@ -1,14 +0,0 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Ranking } from './Ranking';
export const RankingPage: React.VFC = () => {
const [limit, setLimit] = useState<number | undefined>(10);
const {t} = useTranslation();
return (
<div className="fade">
<Ranking limit={limit} />
{limit && <button className="btn link" onClick={() => setLimit(undefined)}>{t('_missHai.showAll')}</button>}
</div>
);
};

View file

@ -7,3 +7,5 @@ export const API_ENDPOINT = `//${location.host}/api/v1/`;
export const CHANGELOG_URL = 'https://github.com/Xeltica/MisskeyTools/blob/master/CHANGELOG.md'; export const CHANGELOG_URL = 'https://github.com/Xeltica/MisskeyTools/blob/master/CHANGELOG.md';
export const XELTICA_STUDIO_URL = 'https://xeltica.work'; export const XELTICA_STUDIO_URL = 'https://xeltica.work';
export const BREAKPOINT_SM = '800px';

View file

@ -0,0 +1,13 @@
import { useEffect } from 'react';
import { useDispatch } from 'react-redux';
import { setTitle } from '../store/slices/screen';
export const useTitle = (title: string) => {
const dispatch = useDispatch();
useEffect(() => {
dispatch(setTitle(title));
return () => {
dispatch(setTitle(null));
};
}, [title]);
};

View file

@ -2,10 +2,31 @@ import * as React from 'react';
import * as ReactDOM from 'react-dom'; import * as ReactDOM from 'react-dom';
import relativeTime from 'dayjs/plugin/relativeTime'; import relativeTime from 'dayjs/plugin/relativeTime';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import 'dayjs/locale/ja'; import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import { getBrowserLanguage, resources } from './langs';
import { App } from './App'; import { App } from './App';
import { LOCALSTORAGE_KEY_LANG } from './const';
import 'xeltica-ui/dist/css/xeltica-ui.min.css';
import './style.scss';
import 'dayjs/locale/ja';
dayjs.extend(relativeTime); dayjs.extend(relativeTime);
if (!localStorage[LOCALSTORAGE_KEY_LANG]) {
localStorage[LOCALSTORAGE_KEY_LANG] = getBrowserLanguage();
}
i18n
.use(initReactI18next)
.init({
resources,
lng: localStorage[LOCALSTORAGE_KEY_LANG],
interpolation: {
escapeValue: false // Reactは常にXSS対策をしてくれるので、i18next側では対応不要
}
});
ReactDOM.render(<App/>, document.getElementById('app')); ReactDOM.render(<App/>, document.getElementById('app'));

View file

@ -2,10 +2,18 @@ import jaJP from './ja-JP.json';
import enUS from './en-US.json'; import enUS from './en-US.json';
import koKR from './ko-KR.json'; import koKR from './ko-KR.json';
import deepmerge from 'deepmerge';
const merge = (baseData: Record<string, unknown>, newData: Record<string, unknown>) => {
return deepmerge(baseData, newData, {
isMergeableObject: obj => typeof obj === 'object'
});
};
export const resources = { export const resources = {
'ja_JP': { translation: jaJP }, 'ja_JP': { translation: jaJP },
'en_US': { translation: enUS }, 'en_US': { translation: merge(jaJP, enUS) },
'ko_KR': { translation: koKR }, 'ko_KR': { translation: merge(jaJP, koKR) },
}; };
export const languageName = { export const languageName = {

View file

@ -49,6 +49,17 @@
"update": "更新する", "update": "更新する",
"shareMisskeyTools": "#MisskeyTools をシェアする", "shareMisskeyTools": "#MisskeyTools をシェアする",
"shareMisskeyToolsNote": "#MisskeyTools はいいぞ\n\nhttps://misskey.tools", "shareMisskeyToolsNote": "#MisskeyTools はいいぞ\n\nhttps://misskey.tools",
"_sidebar": {
"dashboard": "ダッシュボード",
"tools": "ツール",
"manageTools": "ツールを管理",
"missHaiAlert": "ミス廃アラート",
"cropper": "ねこみみアジャスター",
"accounts": "アカウント",
"settings": "設定",
"admin": "管理画面",
"about": "Misskey Toolsについて"
},
"_welcomeMessage": { "_welcomeMessage": {
"pattern1": "ついついノートしすぎていませんか?", "pattern1": "ついついノートしすぎていませんか?",
"pattern2": "Misskey, しすぎていませんか?", "pattern2": "Misskey, しすぎていませんか?",

View file

@ -5,7 +5,7 @@ export interface ModalTypeMenu {
items: MenuItem[]; items: MenuItem[];
} }
export type MenuItemClassName = `bi bi-${string}`; export type MenuItemClassName = `fas fa-${string}`;
export interface MenuItem { export interface MenuItem {
icon?: MenuItemClassName; icon?: MenuItemClassName;

View file

@ -5,14 +5,17 @@ import { LOCALSTORAGE_KEY_ACCOUNTS, LOCALSTORAGE_KEY_TOKEN } from '../const';
import { useGetSessionQuery } from '../services/session'; import { useGetSessionQuery } from '../services/session';
import { useSelector } from '../store'; import { useSelector } from '../store';
import { setAccounts } from '../store/slices/screen'; import { setAccounts } from '../store/slices/screen';
import { LoginForm } from './LoginForm'; import { LoginForm } from '../components/LoginForm';
import { Skeleton } from './Skeleton'; import { Skeleton } from '../components/Skeleton';
import { useTitle } from '../hooks/useTitle';
export const AccountsPage: React.VFC = () => { export const AccountsPage: React.VFC = () => {
const {data} = useGetSessionQuery(undefined); const {data} = useGetSessionQuery(undefined);
const {t} = useTranslation(); const {t} = useTranslation();
const dispatch = useDispatch(); const dispatch = useDispatch();
useTitle('_sidebar.accounts');
const {accounts, accountTokens} = useSelector(state => state.screen); const {accounts, accountTokens} = useSelector(state => state.screen);
const switchAccount = (token: string) => { const switchAccount = (token: string) => {
@ -40,14 +43,14 @@ export const AccountsPage: React.VFC = () => {
accounts.length === accountTokens.length ? ( accounts.length === accountTokens.length ? (
accounts.map(account => ( accounts.map(account => (
<button className="item fluid" style={{display: 'flex', flexDirection: 'row', alignItems: 'center'}} onClick={() => switchAccount(account.misshaiToken)}> <button className="item fluid" style={{display: 'flex', flexDirection: 'row', alignItems: 'center'}} onClick={() => switchAccount(account.misshaiToken)}>
<i className="icon bi bi-chevron-right" /> <i className="icon fas fa-chevron-right" />
@{account.username}@{account.host} @{account.username}@{account.host}
<button className="btn flat text-danger" style={{marginLeft: 'auto'}} onClick={e => { <button className="btn flat text-danger" style={{marginLeft: 'auto'}} onClick={e => {
const filteredAccounts = accounts.filter(ac => ac.id !== account.id); const filteredAccounts = accounts.filter(ac => ac.id !== account.id);
dispatch(setAccounts(filteredAccounts)); dispatch(setAccounts(filteredAccounts));
e.stopPropagation(); e.stopPropagation();
}}> }}>
<i className="bi bi-trash"/> <i className="fas fa-trash-can"/>
</button> </button>
</button> </button>
)) ))

View file

@ -2,12 +2,12 @@ import React, { useEffect, useState } from 'react';
import { LOCALSTORAGE_KEY_TOKEN } from '../const'; import { LOCALSTORAGE_KEY_TOKEN } from '../const';
import { useGetSessionQuery } from '../services/session'; import { useGetSessionQuery } from '../services/session';
import { Skeleton } from './Skeleton'; import { Skeleton } from '../components/Skeleton';
import { IAnnouncement } from '../../common/types/announcement'; import { IAnnouncement } from '../../common/types/announcement';
import { $delete, $get, $post, $put } from '../misc/api'; import { $delete, $get, $post, $put } from '../misc/api';
import { Card } from './Card';
import { showModal } from '../store/slices/screen'; import { showModal } from '../store/slices/screen';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { useTitle } from '../hooks/useTitle';
export const AdminPage: React.VFC = () => { export const AdminPage: React.VFC = () => {
@ -15,6 +15,8 @@ export const AdminPage: React.VFC = () => {
const dispatch = useDispatch(); const dispatch = useDispatch();
useTitle('_sidebar.admin');
const [announcements, setAnnouncements] = useState<IAnnouncement[]>([]); const [announcements, setAnnouncements] = useState<IAnnouncement[]>([]);
const [selectedAnnouncement, selectAnnouncement] = useState<IAnnouncement | null>(null); const [selectedAnnouncement, selectAnnouncement] = useState<IAnnouncement | null>(null);
const [isAnnouncementsLoaded, setAnnouncementsLoaded] = useState(false); const [isAnnouncementsLoaded, setAnnouncementsLoaded] = useState(false);
@ -132,15 +134,16 @@ export const AdminPage: React.VFC = () => {
<p>You are not an administrator and cannot open this page.</p> <p>You are not an administrator and cannot open this page.</p>
) : ( ) : (
<> <>
<h2>Announcements</h2> <div className="card shadow-2">
<div className="body">
<h1>Announcements</h1>
{!isEditMode && ( {!isEditMode && (
<label className="input-switch mb-1"> <label className="input-switch mb-2">
<input type="checkbox" checked={isDeleteMode} onChange={e => setDeleteMode(e.target.checked)}/> <input type="checkbox" checked={isDeleteMode} onChange={e => setDeleteMode(e.target.checked)}/>
<div className="switch"></div> <div className="switch"></div>
<span>Delete Mode</span> <span>Delete Mode</span>
</label> </label>
)} )}
<Card bodyClassName={isEditMode ? '' : 'px-0'}>
{ !isEditMode ? ( { !isEditMode ? (
<> <>
{isDeleteMode && <div className="ml-2 text-danger">Click the item to delete.</div>} {isDeleteMode && <div className="ml-2 text-danger">Click the item to delete.</div>}
@ -154,13 +157,13 @@ export const AdminPage: React.VFC = () => {
setEditMode(true); setEditMode(true);
} }
}}> }}>
{isDeleteMode && <i className="icon bi bi-trash text-danger" />} {isDeleteMode && <i className="icon bi fas fa-trash-can text-danger" />}
{a.title} {a.title}
</button> </button>
))} ))}
{!isDeleteMode && ( {!isDeleteMode && (
<button className="item fluid" onClick={() => setEditMode(true)}> <button className="item fluid" onClick={() => setEditMode(true)}>
<i className="icon bi bi-plus"/ > <i className="icon fas fa-plus"/ >
Create New Create New
</button> </button>
)} )}
@ -189,7 +192,8 @@ export const AdminPage: React.VFC = () => {
</div> </div>
</div> </div>
)} )}
</Card> </div>
</div>
<h2>Misshai</h2> <h2>Misshai</h2>
<div className="vstack"> <div className="vstack">
<button className="btn danger" onClick={onClickStartMisshaiAlertWorkerButton}> <button className="btn danger" onClick={onClickStartMisshaiAlertWorkerButton}>

View file

@ -6,6 +6,7 @@ import { IAnnouncement } from '../../common/types/announcement';
import { Skeleton } from '../components/Skeleton'; import { Skeleton } from '../components/Skeleton';
import { $get } from '../misc/api'; import { $get } from '../misc/api';
import { useSelector } from '../store'; import { useSelector } from '../store';
import { useTitle } from '../hooks/useTitle';
export const AnnouncementPage: React.VFC = () => { export const AnnouncementPage: React.VFC = () => {
const { id } = useParams<{id: string}>(); const { id } = useParams<{id: string}>();
@ -15,6 +16,8 @@ export const AnnouncementPage: React.VFC = () => {
const lang = useSelector(state => state.screen.language); const lang = useSelector(state => state.screen.language);
useTitle('announcements');
useEffect(() => { useEffect(() => {
$get<IAnnouncement>('announcements/' + id).then(setAnnouncement); $get<IAnnouncement>('announcements/' + id).then(setAnnouncement);
}, [setAnnouncement]); }, [setAnnouncement]);
@ -25,7 +28,7 @@ export const AnnouncementPage: React.VFC = () => {
<h2> <h2>
{announcement.title} {announcement.title}
<aside className="inline ml-1 text-dimmed text-100"> <aside className="inline ml-1 text-dimmed text-100">
<i className="bi bi-clock" />&nbsp; <i className="fas fa-clock" />&nbsp;
{dayjs(announcement.createdAt).locale(lang.split('_')[0]).fromNow()} {dayjs(announcement.createdAt).locale(lang.split('_')[0]).fromNow()}
</aside> </aside>
</h2> </h2>

View file

@ -2,10 +2,12 @@ import React, { ChangeEventHandler, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import ReactCrop, { Crop } from 'react-image-crop'; import ReactCrop, { Crop } from 'react-image-crop';
import 'react-image-crop/dist/ReactCrop.css';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { useGetSessionQuery } from '../services/session'; import { useGetSessionQuery } from '../../services/session';
import { showModal } from '../store/slices/screen'; import { showModal } from '../../store/slices/screen';
import 'react-image-crop/dist/ReactCrop.css';
import { useTitle } from '../../hooks/useTitle';
export const NekomimiPage: React.VFC = () => { export const NekomimiPage: React.VFC = () => {
const {t} = useTranslation(); const {t} = useTranslation();
@ -19,6 +21,8 @@ export const NekomimiPage: React.VFC = () => {
const [crop, setCrop] = useState<Partial<Crop>>({unit: '%', width: 100, aspect: 1 / 1}); const [crop, setCrop] = useState<Partial<Crop>>({unit: '%', width: 100, aspect: 1 / 1});
const [completedCrop, setCompletedCrop] = useState<Crop>(); const [completedCrop, setCompletedCrop] = useState<Crop>();
useTitle('catAdjuster');
const previewCanvasRef = useRef<HTMLCanvasElement>(null); const previewCanvasRef = useRef<HTMLCanvasElement>(null);
const {data} = useGetSessionQuery(undefined); const {data} = useGetSessionQuery(undefined);

View file

@ -3,11 +3,11 @@
flex-wrap: wrap; flex-wrap: wrap;
gap: var(--margin); gap: var(--margin);
> .misshaiData { > .misshaiData {
flex: 3 0 300px;
}
> .developerInfo {
flex: 1 0 300px; flex: 1 0 300px;
} }
> .misshaiRanking {
flex: 4 0 300px;
}
> .alertModeSetting { > .alertModeSetting {
flex: 1 0 300px; flex: 1 0 300px;
} }

View file

@ -2,19 +2,18 @@ import insertTextAtCursor from 'insert-text-at-cursor';
import React, { useCallback, useEffect, useReducer, useRef, useState } from 'react'; import React, { useCallback, useEffect, useReducer, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { alertModes } 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 { Visibility } from '../../../common/types/visibility';
import { LOCALSTORAGE_KEY_ACCOUNTS, LOCALSTORAGE_KEY_TOKEN } from '../const'; import { LOCALSTORAGE_KEY_ACCOUNTS, LOCALSTORAGE_KEY_TOKEN } from '../../const';
import { $post, $put } from '../misc/api'; import { $post, $put } from '../../misc/api';
import { useGetScoreQuery, useGetSessionQuery } from '../services/session'; import { useGetScoreQuery, useGetSessionQuery } from '../../services/session';
import { showModal } from '../store/slices/screen'; import { showModal } from '../../store/slices/screen';
import { AnnouncementList } from './AnnouncementList'; import { Skeleton } from '../../components/Skeleton';
import { Ranking } from './Ranking';
import { Skeleton } from './Skeleton';
import './MisshaiPage.scss'; import './misshai.scss';
import { DeveloperInfo } from './DeveloperInfo'; import { Ranking } from '../../components/Ranking';
import { useTitle } from '../../hooks/useTitle';
const variables = [ const variables = [
'notesCount', 'notesCount',
@ -43,14 +42,15 @@ type DraftReducer = React.Reducer<SettingDraftType, Partial<SettingDraftType>>;
export const MisshaiPage: React.VFC = () => { export const MisshaiPage: React.VFC = () => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const [limit, setLimit] = useState<number | undefined>(10);
const session = useGetSessionQuery(undefined); const session = useGetSessionQuery(undefined);
const [limit, setLimit] = useState<number | undefined>(10);
const data = session.data; const data = session.data;
const score = useGetScoreQuery(undefined); const score = useGetScoreQuery(undefined);
const {t} = useTranslation(); const {t} = useTranslation();
useTitle('_sidebar.missHaiAlert');
const [draft, dispatchDraft] = useReducer<DraftReducer>((state, action) => { const [draft, dispatchDraft] = useReducer<DraftReducer>((state, action) => {
return { ...state, ...action }; return { ...state, ...action };
}, { }, {
@ -195,16 +195,10 @@ export const MisshaiPage: React.VFC = () => {
<Skeleton width="100%" height="160px" /> <Skeleton width="100%" height="160px" />
</div> </div>
) : ( ) : (
<div className="vstack"> <div className="vstack fade">
<div className="card announcement">
<div className="body">
<AnnouncementList />
</div>
</div>
<div className="misshaiPageLayout">
<div className="card misshaiData"> <div className="card misshaiData">
<div className="body"> <div className="body">
<h1><i className="bi bi-activity"></i> {t('_missHai.data')}</h1> <h1><i className="fas fa-chart-line"></i> {t('_missHai.data')}</h1>
<table className="table fluid"> <table className="table fluid">
<thead> <thead>
<tr> <tr>
@ -239,12 +233,6 @@ export const MisshaiPage: React.VFC = () => {
</p> </p>
</div> </div>
</div> </div>
<div className="card developerInfo">
<div className="body">
<DeveloperInfo />
</div>
</div>
</div>
<div className="card misshaiRanking"> <div className="card misshaiRanking">
<div className="body"> <div className="body">
<h1><i className="bi bi-bar-chart"></i> {t('_missHai.ranking')}</h1> <h1><i className="bi bi-bar-chart"></i> {t('_missHai.ranking')}</h1>
@ -261,8 +249,8 @@ export const MisshaiPage: React.VFC = () => {
<div className="misshaiPageLayout"> <div className="misshaiPageLayout">
<div className="card alertModeSetting"> <div className="card alertModeSetting">
<div className="body"> <div className="body">
<h1 className="mb-2"><i className="bi bi-gear"></i> {t('alertMode')}</h1> <h1><i className="fas fa-gear"></i> {t('alertMode')}</h1>
<div className="vstack"> <div className="vstack slim">
{ alertModes.map((mode) => ( { alertModes.map((mode) => (
<label key={mode} className="input-check"> <label key={mode} className="input-check">
<input type="radio" checked={mode === draft.alertMode} onChange={() => { <input type="radio" checked={mode === draft.alertMode} onChange={() => {
@ -274,14 +262,14 @@ export const MisshaiPage: React.VFC = () => {
</div> </div>
{ (draft.alertMode === 'notification' || draft.alertMode === 'both') && ( { (draft.alertMode === 'notification' || draft.alertMode === 'both') && (
<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 fas fa-circle-exclamation"></i>
{t('_alertMode.notificationWarning')} {t('_alertMode.notificationWarning')}
</div> </div>
)} )}
{ (draft.alertMode === 'note' || draft.alertMode === 'both') && ( { (draft.alertMode === 'note' || draft.alertMode === 'both') && (
<> <>
<h2 className="mt-2 mb-1">{t('visibility')}</h2> <h2 className="mt-2 mb-1">{t('visibility')}</h2>
<div className="vstack"> <div className="vstack slim">
{ {
availableVisibilities.map((visibility) => ( availableVisibilities.map((visibility) => (
<label key={visibility} className="input-check"> <label key={visibility} className="input-check">
@ -305,15 +293,15 @@ export const MisshaiPage: React.VFC = () => {
</div> </div>
<div className="card templateSetting"> <div className="card templateSetting">
<div className="body"> <div className="body">
<h1><i className="bi-card-text"></i> {t('template')}</h1> <h1><i className="fas fa-pen-to-square"></i> {t('template')}</h1>
<p>{t('_template.description')}</p> <p>{t('_template.description')}</p>
<div className="hstack dense mb-2"> <div className="hstack dense mb-2">
<button className="btn" onClick={onClickInsertVariables}> <button className="btn" onClick={onClickInsertVariables}>
<i className="bi bi-braces" />&nbsp; {'{ } '}
{t('_template.insertVariables')} {t('_template.insertVariables')}
</button> </button>
<button className="btn link text-info" onClick={onClickInsertVariablesHelp}> <button className="btn link text-info" onClick={onClickInsertVariablesHelp}>
<i className="bi bi-question-circle" /> <i className="fas fa-circle-question" />
</button> </button>
</div> </div>
<textarea ref={templateTextarea} className="input-field" value={draft.template ?? defaultTemplate} placeholder={defaultTemplate} style={{height: 228}} onChange={(e) => { <textarea ref={templateTextarea} className="input-field" value={draft.template ?? defaultTemplate} placeholder={defaultTemplate} style={{height: 228}} onChange={(e) => {
@ -331,7 +319,7 @@ export const MisshaiPage: React.VFC = () => {
</div> </div>
<div className="list-form mt-2"> <div className="list-form mt-2">
<button className="item" onClick={onClickSendAlert} disabled={draft.alertMode === 'nothing'}> <button className="item" onClick={onClickSendAlert} disabled={draft.alertMode === 'nothing'}>
<i className="icon bi bi-send" /> <i className="icon fas fa-paper-plane" />
<div className="body"> <div className="body">
<h1>{t('sendAlert')}</h1> <h1>{t('sendAlert')}</h1>
<p className="desc">{t(draft.alertMode === 'nothing' ? 'sendAlertDisabled' : 'sendAlertDescription')}</p> <p className="desc">{t(draft.alertMode === 'nothing' ? 'sendAlertDisabled' : 'sendAlertDescription')}</p>

View file

@ -1,10 +1,12 @@
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Ranking } from '../components/Ranking'; import { Ranking } from '../../../components/Ranking';
import { useTitle } from '../../../hooks/useTitle';
export const RankingPage: React.VFC = () => { export const RankingPage: React.VFC = () => {
const {t} = useTranslation(); const {t} = useTranslation();
useTitle('_missHai.ranking');
return ( return (
<article className="xarticle"> <article className="xarticle">

View file

@ -1,77 +1,77 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect } from 'react';
import { Header } from '../components/Header';
import { MisshaiPage } from '../components/MisshaiPage';
import { Tab, TabItem } from '../components/Tab';
import { SettingPage } from '../components/SettingPage';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { AccountsPage } from '../components/AccountsPage';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { LOCALSTORAGE_KEY_ACCOUNTS } from '../const'; import { LOCALSTORAGE_KEY_ACCOUNTS } from '../const';
import { IUser } from '../../common/types/user'; import { IUser } from '../../common/types/user';
import { setAccounts } from '../store/slices/screen'; import { setAccounts } from '../store/slices/screen';
import { useGetMetaQuery, useGetSessionQuery } from '../services/session'; import { useGetScoreQuery, useGetSessionQuery } from '../services/session';
import { AdminPage } from '../components/AdminPage';
import { $get } from '../misc/api'; import { $get } from '../misc/api';
import { NekomimiPage } from '../components/NekomimiPage'; import { AnnouncementList } from '../components/AnnouncementList';
import { Card } from '../components/Card'; import { DeveloperInfo } from '../components/DeveloperInfo';
import { CurrentUser } from '../components/CurrentUser';
export const IndexSessionPage: React.VFC = () => { export const IndexSessionPage: React.VFC = () => {
const [selectedTab, setSelectedTab] = useState<string>('misshai'); const {t} = useTranslation();
const {t, i18n} = useTranslation();
const dispatch = useDispatch(); const dispatch = useDispatch();
const { data: session } = useGetSessionQuery(undefined); const { data: session } = useGetSessionQuery(undefined);
const { data: meta } = useGetMetaQuery(undefined); const score = useGetScoreQuery(undefined);
useEffect(() => { useEffect(() => {
const accounts = JSON.parse(localStorage.getItem(LOCALSTORAGE_KEY_ACCOUNTS) || '[]') as string[]; const accounts = JSON.parse(localStorage.getItem(LOCALSTORAGE_KEY_ACCOUNTS) || '[]') as string[];
Promise.all(accounts.map(token => $get<IUser>('session', token))).then(a => dispatch(setAccounts(a as IUser[]))); Promise.all(accounts.map(token => $get<IUser>('session', token))).then(a => dispatch(setAccounts(a as IUser[])));
}, [dispatch]); }, [dispatch]);
const items = useMemo<TabItem[]>(() => {
const it: TabItem[] = [];
it.push({ label: t('_nav.misshai'), key: 'misshai' });
it.push({ label: t('_nav.accounts'), key: 'accounts' });
it.push({ label: t('_nav.catAdjuster'), key: 'nekomimi' });
if (session?.isAdmin) {
it.push({ label: 'Admin', key: 'admin' });
}
it.push({ label: t('_nav.settings'), key: 'settings' });
return it;
}, [i18n.language, session]);
const component = useMemo(() => {
switch (selectedTab) {
case 'misshai': return <MisshaiPage />;
case 'accounts': return <AccountsPage />;
case 'admin': return <AdminPage />;
case 'nekomimi': return <NekomimiPage />;
case 'settings': return <SettingPage/>;
default: return null;
}
}, [selectedTab]);
return ( return (
<> <div className="vstack fade">
<div className="xarticle vgroup shadow-4" style={{position: 'sticky', top: 0, zIndex: 100}}> <div className="card announcement">
<Header /> <div className="body">
<div className="card"> <AnnouncementList />
<Tab items={items} selected={selectedTab} onSelect={setSelectedTab}/> </div>
</div>
<div className="misshaiPageLayout">
<div className="card misshaiData">
<div className="body">
<h1><i className="fas fa-chart-line"></i> {t('_missHai.data')}</h1>
<table className="table fluid">
<thead>
<tr>
<th></th>
<th>{t('_missHai.dataScore')}</th>
<th>{t('_missHai.dataDelta')}</th>
</tr>
</thead>
<tbody>
<tr>
<td>{t('notes')}</td>
<td>{score.data?.notesCount ?? '...'}</td>
<td>{score.data?.notesDelta ?? '...'}</td>
</tr>
<tr>
<td>{t('following')}</td>
<td>{score.data?.followingCount ?? '...'}</td>
<td>{score.data?.followingDelta ?? '...'}</td>
</tr>
<tr>
<td>{t('followers')}</td>
<td>{score.data?.followersCount ?? '...'}</td>
<td>{score.data?.followersDelta ?? '...'}</td>
</tr>
</tbody>
</table>
<p>
<strong>
{t('_missHai.rating')}{': '}
</strong>
{session?.rating ?? '...'}
</p>
</div>
</div>
<div className="card developerInfo">
<div className="body">
<DeveloperInfo />
</div> </div>
</div> </div>
<div className="xarticle mt-2">
<Card className="mb-2">
<CurrentUser/>
{session && meta && meta.currentTokenVersion !== session.tokenVersion && (
<div className="text-danger mt-1">
{t('shouldUpdateToken')}
<a href={`/login?host=${encodeURIComponent(session.host)}`}>{t('update')}</a>
</div> </div>
)}
</Card>
{component}
</div> </div>
</>
); );
}; };

View file

@ -3,14 +3,14 @@ import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { LoginForm } from '../components/LoginForm'; import { LoginForm } from '../components/LoginForm';
import { Header } from '../components/Header'; import { Header } from '../Header';
import { AnnouncementList } from '../components/AnnouncementList'; import { AnnouncementList } from '../components/AnnouncementList';
export const IndexWelcomePage: React.VFC = () => { export const IndexWelcomePage: React.VFC = () => {
const {t} = useTranslation(); const {t} = useTranslation();
return ( return (
<> <>
<Header className="xarticle mb-4"> <Header>
<article className="mt-4"> <article className="mt-4">
<p>{t('description1')}</p> <p>{t('description1')}</p>
<p>{t('description2')}</p> <p>{t('description2')}</p>
@ -30,16 +30,16 @@ export const IndexWelcomePage: React.VFC = () => {
</header> </header>
<div className="row"> <div className="row">
<article className="col-4 col-12-sm"> <article className="col-4 col-12-sm">
<h3><i className="bi bi-megaphone-fill"/> {t('_welcome.misshaiAlertTitle')}</h3> <h3><i className="fas fa-bullhone"/> {t('_welcome.misshaiAlertTitle')}</h3>
<p>{t('_welcome.misshaiAlertDescription')}</p> <p>{t('_welcome.misshaiAlertDescription')}</p>
</article> </article>
<article className="col-4 col-12-sm"> <article className="col-4 col-12-sm">
<h3><i className="bi bi-bar-chart-fill"/> {t('_missHai.ranking')}</h3> <h3><i className="fas fa-ranking-star"/> {t('_missHai.ranking')}</h3>
<p>{t('_welcome.misshaiRankingDescription')}</p> <p>{t('_welcome.misshaiRankingDescription')}</p>
<Link to="/ranking">{t('_missHai.showRanking')}</Link> <Link to="/apps/miss-hai/ranking">{t('_missHai.showRanking')}</Link>
</article> </article>
<div className="col-4 col-12-sm"> <div className="col-4 col-12-sm">
<h3><i className="bi bi-crop"/> {t('catAdjuster')}</h3> <h3><i className="fas fa-crop-simple"/> {t('catAdjuster')}</h3>
<p>{t('_welcome.catAdjusterDescription')}</p> <p>{t('_welcome.catAdjusterDescription')}</p>
</div> </div>
</div> </div>

View file

@ -3,13 +3,14 @@ import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { useGetSessionQuery } from '../services/session'; import { useGetSessionQuery } from '../services/session';
import { Card } from './Card'; import { Card } from '../components/Card';
import { Theme, themes } from '../misc/theme'; import { Theme, themes } from '../misc/theme';
import { LOCALSTORAGE_KEY_TOKEN } from '../const'; import { LOCALSTORAGE_KEY_TOKEN } from '../const';
import { changeLang, changeTheme, showModal } from '../store/slices/screen'; import { changeLang, changeTheme, showModal } from '../store/slices/screen';
import { useSelector } from '../store'; import { useSelector } from '../store';
import { languageName } from '../langs'; import { languageName } from '../langs';
import { $delete } from '../misc/api'; import { $delete } from '../misc/api';
import { useTitle } from '../hooks/useTitle';
export const SettingPage: React.VFC = () => { export const SettingPage: React.VFC = () => {
const session = useGetSessionQuery(undefined); const session = useGetSessionQuery(undefined);
@ -18,6 +19,8 @@ export const SettingPage: React.VFC = () => {
const data = session.data; const data = session.data;
const {t} = useTranslation(); const {t} = useTranslation();
useTitle('_sidebar.settings');
const currentTheme = useSelector(state => state.screen.theme); const currentTheme = useSelector(state => state.screen.theme);
const currentLang = useSelector(state => state.screen.language); const currentLang = useSelector(state => state.screen.language);
@ -91,7 +94,7 @@ export const SettingPage: React.VFC = () => {
) : ( ) : (
<div className="vstack fade"> <div className="vstack fade">
<Card bodyClassName="vstack"> <Card bodyClassName="vstack">
<h1><i className="bi bi-palette"></i> {t('appearance')}</h1> <h1><i className="fas fa-palette"></i> {t('appearance')}</h1>
<h2>{t('theme')}</h2> <h2>{t('theme')}</h2>
<div className="vstack"> <div className="vstack">
{ {
@ -115,21 +118,21 @@ export const SettingPage: React.VFC = () => {
} }
</select> </select>
<div className="alert bg-info mt-2"> <div className="alert bg-info mt-2">
<i className="icon bi bi-translate" /> <i className="icon fas fa-language" />
{t('translatedByTheCommunity')}&nbsp; {t('translatedByTheCommunity')}&nbsp;
<a href="https://crowdin.com/project/misskey-tools" target="_blank" rel="noopener noreferrer">{t('helpTranslation')}</a> <a href="https://crowdin.com/project/misskey-tools" target="_blank" rel="noopener noreferrer">{t('helpTranslation')}</a>
</div> </div>
</Card> </Card>
<div className="list-form"> <div className="list-form">
<button className="item" onClick={onClickLogout}> <button className="item" onClick={onClickLogout}>
<i className="icon bi bi-box-arrow-right" /> <i className="icon fas fa-arrow-up-right-from-square" />
<div className="body"> <div className="body">
<h1>{t('logout')}</h1> <h1>{t('logout')}</h1>
<p className="desc">{t('logoutDescription')}</p> <p className="desc">{t('logoutDescription')}</p>
</div> </div>
</button> </button>
<button className="item text-danger" onClick={onClickDeleteAccount}> <button className="item text-danger" onClick={onClickDeleteAccount}>
<i className="icon bi bi-trash" /> <i className="icon fas fa-trash-can" />
<div className="body"> <div className="body">
<h1>{t('deleteAccount')}</h1> <h1>{t('deleteAccount')}</h1>
<p className="desc">{t('deleteAccountDescription')}</p> <p className="desc">{t('deleteAccountDescription')}</p>

View file

@ -1,35 +0,0 @@
import React from 'react';
export const TermPage: React.VFC = () => {
// TODO: 外部サイトに誘導する
return (
<article className="xarticle">
<h2></h2>
<ul>
<li>
</li>
<li>
Misskey
Misskey
</li>
<li>
使
稿使
</li>
<li>
</li>
<li>
</li>
<li></li>
<li></li>
<li></li>
</ul>
</article>
);
};

View file

@ -1,5 +1,6 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import i18n from 'i18next'; import i18n from 'i18next';
import { WritableDraft } from 'immer/dist/internal';
import { IUser } from '../../../common/types/user'; import { IUser } from '../../../common/types/user';
import { LOCALSTORAGE_KEY_ACCOUNTS, LOCALSTORAGE_KEY_LANG, LOCALSTORAGE_KEY_THEME } from '../../const'; import { LOCALSTORAGE_KEY_ACCOUNTS, LOCALSTORAGE_KEY_LANG, LOCALSTORAGE_KEY_THEME } from '../../const';
@ -10,9 +11,11 @@ interface ScreenState {
modal: Modal | null; modal: Modal | null;
modalShown: boolean; modalShown: boolean;
theme: Theme; theme: Theme;
title: string | null;
language: string; language: string;
accounts: IUser[]; accounts: IUser[];
accountTokens: string[]; accountTokens: string[];
isMobile: boolean;
} }
const initialState: ScreenState = { const initialState: ScreenState = {
@ -20,8 +23,20 @@ const initialState: ScreenState = {
modalShown: false, modalShown: false,
theme: localStorage[LOCALSTORAGE_KEY_THEME] ?? 'system', theme: localStorage[LOCALSTORAGE_KEY_THEME] ?? 'system',
language: localStorage[LOCALSTORAGE_KEY_LANG] ?? i18n.language ?? 'ja_JP', language: localStorage[LOCALSTORAGE_KEY_LANG] ?? i18n.language ?? 'ja_JP',
title: null,
accounts: [], accounts: [],
accountTokens: JSON.parse(localStorage.getItem(LOCALSTORAGE_KEY_ACCOUNTS) || '[]') as string[], accountTokens: JSON.parse(localStorage.getItem(LOCALSTORAGE_KEY_ACCOUNTS) || '[]') as string[],
isMobile: false,
};
/**
* Reducerを生成します
*/
const generateSetter = <T extends keyof WritableDraft<ScreenState>>(key: T, callback?: (state: WritableDraft<ScreenState>, action: PayloadAction<ScreenState[T]>) => void) => {
return (state: WritableDraft<ScreenState>, action: PayloadAction<ScreenState[T]>) => {
state[key] = action.payload;
if (callback) callback(state, action);
};
}; };
export const screenSlice = createSlice({ export const screenSlice = createSlice({
@ -36,23 +51,22 @@ export const screenSlice = createSlice({
state.modal = null; state.modal = null;
state.modalShown = false; state.modalShown = false;
}, },
changeTheme: (state, action: PayloadAction<ScreenState['theme']>) => { changeTheme: generateSetter('theme', (_, action) => {
state.theme = action.payload;
localStorage[LOCALSTORAGE_KEY_THEME] = action.payload; localStorage[LOCALSTORAGE_KEY_THEME] = action.payload;
}, }),
changeLang: (state, action: PayloadAction<ScreenState['language']>) => { changeLang: generateSetter('language', (_, action) => {
state.language = action.payload;
localStorage[LOCALSTORAGE_KEY_LANG] = action.payload; localStorage[LOCALSTORAGE_KEY_LANG] = action.payload;
i18n.changeLanguage(action.payload); i18n.changeLanguage(action.payload);
}, }),
setAccounts: (state, action: PayloadAction<ScreenState['accounts']>) => { setAccounts: generateSetter('accounts', (state, action) => {
state.accounts = action.payload;
state.accountTokens = action.payload.map(a => a.misshaiToken); state.accountTokens = action.payload.map(a => a.misshaiToken);
localStorage[LOCALSTORAGE_KEY_ACCOUNTS] = JSON.stringify(state.accountTokens); localStorage[LOCALSTORAGE_KEY_ACCOUNTS] = JSON.stringify(state.accountTokens);
}, }),
setMobile: generateSetter('isMobile'),
setTitle: generateSetter('title'),
}, },
}); });
export const { showModal, hideModal, changeTheme, changeLang, setAccounts } = screenSlice.actions; export const { showModal, hideModal, changeTheme, changeLang, setAccounts, setMobile, setTitle } = screenSlice.actions;
export default screenSlice.reducer; export default screenSlice.reducer;

View file

@ -19,6 +19,15 @@ hr {
max-width: var(--max-width); max-width: var(--max-width);
} }
._header {
position: sticky;
top: var(--container-padding);
@media (max-width: 600px) {
top: var(--container-padding-phone);
padding-right: 16px;
}
}
.fade { .fade {
animation: 0.3s ease-out 0s fadeIn; animation: 0.3s ease-out 0s fadeIn;
&.down { &.down {
@ -94,6 +103,10 @@ small {
max-width: min(100vw, 600px); max-width: min(100vw, 600px);
} }
.card > .body > h1 {
margin-bottom: var(--margin);
}
.modal-menu-wrapper { .modal-menu-wrapper {
display: flex; display: flex;
position: fixed; position: fixed;

View file

@ -355,6 +355,13 @@
dependencies: dependencies:
"@types/ms" "*" "@types/ms" "*"
"@types/deepmerge@^2.2.0":
version "2.2.0"
resolved "https://registry.yarnpkg.com/@types/deepmerge/-/deepmerge-2.2.0.tgz#6f63896c217f3164782f52d858d9f3a927139f64"
integrity sha512-FEQYDHh6+Q+QXKSrIY46m+/lAmAj/bk4KpLaam+hArmzaVpMBHLcfwOH2Q2UOkWM7XsdY9PmZpGyPAjh/JRGhQ==
dependencies:
deepmerge "*"
"@types/eslint-scope@^3.7.0": "@types/eslint-scope@^3.7.0":
version "3.7.1" version "3.7.1"
resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.1.tgz#8dc390a7b4f9dd9f1284629efce982e41612116e" resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.1.tgz#8dc390a7b4f9dd9f1284629efce982e41612116e"
@ -1737,6 +1744,11 @@ deep-is@^0.1.3:
resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831"
integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==
deepmerge@*, deepmerge@^4.2.2:
version "4.2.2"
resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.2.2.tgz#44d2ea3679b8f4d4ffba33f03d865fc1e7bf4955"
integrity sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==
defer-to-connect@^1.0.1: defer-to-connect@^1.0.1:
version "1.1.3" version "1.1.3"
resolved "https://registry.yarnpkg.com/defer-to-connect/-/defer-to-connect-1.1.3.tgz#331ae050c08dcf789f8c83a7b81f0ed94f4ac591" resolved "https://registry.yarnpkg.com/defer-to-connect/-/defer-to-connect-1.1.3.tgz#331ae050c08dcf789f8c83a7b81f0ed94f4ac591"
@ -6228,8 +6240,8 @@ xdg-basedir@^4.0.0:
integrity sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q== integrity sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==
xeltica-ui@xeltica/design-system: xeltica-ui@xeltica/design-system:
version "1.0.0-beta.7" version "1.0.0-beta.9"
resolved "https://codeload.github.com/xeltica/design-system/tar.gz/d0b49b6487c379a10f6e568c0f9e37b0035ea7f2" resolved "https://codeload.github.com/xeltica/design-system/tar.gz/83b7faede9b0a42a9a3bd8d462d95484cfa67294"
xml2js@^0.4.17: xml2js@^0.4.17:
version "0.4.23" version "0.4.23"