0
0
Fork 0

Merge branch 'feature/renewal' into l10n_master

This commit is contained in:
Ebise Lutica 2022-06-19 11:02:16 +09:00 committed by GitHub
commit c7fe03e263
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
37 changed files with 834 additions and 440 deletions

View file

@ -27,6 +27,7 @@
"dependencies": {
"@babel/preset-react": "^7.14.5",
"@reduxjs/toolkit": "^1.6.1",
"@types/deepmerge": "^2.2.0",
"@types/insert-text-at-cursor": "^0.3.0",
"@types/koa-bodyparser": "^4.3.0",
"@types/koa-multer": "^1.0.0",
@ -38,6 +39,7 @@
"@types/react": "^17.0.19",
"@types/react-dom": "^17.0.9",
"@types/react-router-dom": "^5.1.8",
"@types/react-twemoji": "^0.4.0",
"@types/styled-components": "^5.1.13",
"@types/uuid": "^8.0.0",
"axios": "^0.21.2",
@ -45,6 +47,7 @@
"class-validator": "^0.13.1",
"css-loader": "^6.2.0",
"dayjs": "^1.10.7",
"deepmerge": "^4.2.2",
"delay": "^4.4.0",
"fibers": "^5.0.0",
"i18next": "^20.6.1",
@ -73,6 +76,7 @@
"react-modal-hook": "^3.0.0",
"react-redux": "^7.2.4",
"react-router-dom": "^5.2.1",
"react-twemoji": "^0.5.0",
"reflect-metadata": "^0.1.13",
"rndstr": "^1.0.0",
"routing-controllers": "^0.9.0",

View file

@ -15,7 +15,7 @@ html
meta(name='twitter:site' content='@Xeltica')
meta(name='twitter:creator' content='@Xeltica')
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.
.loading {
display: flex;

View file

@ -1,91 +1,64 @@
import React from 'react';
import { BrowserRouter, Link, Route, Switch, useLocation } from 'react-router-dom';
import { Provider } from 'react-redux';
import React, { useEffect } from 'react';
import { BrowserRouter, useLocation } from 'react-router-dom';
import { Provider, useDispatch } from 'react-redux';
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 { ModalComponent } from './Modal';
import { useTheme } from './misc/theme';
import { getBrowserLanguage, resources } from './langs';
import { LOCALSTORAGE_KEY_LANG, XELTICA_STUDIO_URL } from './const';
import { BREAKPOINT_SM, XELTICA_STUDIO_URL } from './const';
import { useGetSessionQuery } from './services/session';
import { AnnouncementPage } from './pages/announcement';
import 'xeltica-ui/dist/css/xeltica-ui.min.css';
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
}
});
import { Router } from './Router';
import { setMobile } from './store/slices/screen';
import { GeneralLayout } from './GeneralLayout';
const AppInner : React.VFC = () => {
const { data: session } = useGetSessionQuery(undefined);
const $location = useLocation();
const dispatch = useDispatch();
useTheme();
const {t} = useTranslation();
const error = (window as any).__misshaialert?.error;
return error ? (
<div className="container">
<Header hasTopLink className="xarticle mb-2" />
<div className="xarticle">
<h1>{t('error')}</h1>
<p>{t('_error.sorry')}</p>
<p>
{t('_error.additionalInfo')}
{t(`_error.${error}`)}
</p>
<Link to="/" onClick={() => (window as any).__misshaialert.error = null}>{t('retry')}</Link>
</div>
</div>
) : (
<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>
useEffect(() => {
const qMobile = window.matchMedia(`(max-width: ${BREAKPOINT_SM})`);
const syncMobile = (ev: MediaQueryListEvent) => dispatch(setMobile(ev.matches));
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>
<p>{t('_error.sorry')}</p>
<p>
{t('_error.additionalInfo')}
{t(`_error.${error}`)}
</p>
</div>
) : <Router />}
<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 dangerouslySetInnerHTML={{__html: t('disclaimerForMisskeyHq')}} />
<p><Link to="/term">{t('termsOfService')}</Link></p>
{session && (
<p>
<a
className="btn primary"
href={`https://${session.host}/share?text=${encodeURIComponent(t('shareMisskeyToolsNote') as string)}`}
target="_blank"
rel="noreferrer noopener">
{t('shareMisskeyTools')}
</a>
</p>
)}
<p>
<a href="https://xeltica.notion.site/Misskey-Tools-688187fc85de4b7e901055326c7ffe74" target="_blank" rel="noreferrer noopener">
{t('termsOfService')}
</a>
</p>
</footer>
<ModalComponent />
</div>
</TheLayout>
);
};

View file

@ -0,0 +1,89 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import styled from 'styled-components';
import { NavigationMenu } from './components/NavigationMenu';
import { IsMobileProp } from './misc/is-mobile-prop';
import { useGetMetaQuery, useGetSessionQuery } from './services/session';
import { useSelector } from './store';
import { setDrawerShown } from './store/slices/screen';
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, isDrawerShown } = useSelector(state => state.screen);
const {t} = useTranslation();
const dispatch = useDispatch();
return (
<Container isMobile={isMobile}>
{isMobile && (
<MobileHeader className="navbar hstack f-middle shadow-2 pl-2">
<button className="btn flat" onClick={() => dispatch(setDrawerShown(true))}>
<i className="fas fa-bars"></i>
</button>
<h1>{t(title ?? 'title')}</h1>
</MobileHeader>
)}
<div>
{!isMobile && (
<Sidebar className="pa-2">
<NavigationMenu />
</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>
<div className={`drawer-container ${isDrawerShown ? 'active' : ''}`}>
<div className="backdrop" onClick={() => dispatch(setDrawerShown(false))}></div>
<div className="drawer pa-2" onClick={e => e.stopPropagation()}>
<NavigationMenu />
</div>
</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> = {
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',
error: 'fas fa-circle-xmark text-danger',
info: 'fas fa-circle-info text-primary',
question: 'fas fa-circle-question text-primary',
warning: 'fas fa-circle-exclamation text-warning',
};
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 (
<>
<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">
{announcements.map(a => (
<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 = () => {
const {data} = useGetSessionQuery(undefined);
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" />;
};

View file

@ -5,19 +5,19 @@ export const DeveloperInfo: React.VFC = () => {
const {t} = useTranslation();
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>
<div className="menu large">
<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
</a>
<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
</a>
<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
</a>
</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,5 +1,11 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import styled from 'styled-components';
const Input = styled.input`
width: auto;
flex: 1;
`;
export const LoginForm: React.VFC = () => {
const [host, setHost] = useState('');
@ -11,7 +17,7 @@ export const LoginForm: React.VFC = () => {
<strong>{t('instanceUrl')}</strong>
</div>
<div className="hgroup login-form">
<input
<Input
className="input-field"
type="text"
value={host}

View file

@ -0,0 +1,64 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { NavLink } from 'react-router-dom';
import { useGetSessionQuery } from '../services/session';
import { setDrawerShown } from '../store/slices/screen';
const navLinkClassName = (isActive: boolean) => `item ${isActive ? 'active' : ''}`;
export const NavigationMenu: React.VFC = () => {
const { data: session } = useGetSessionQuery(undefined);
const {t} = useTranslation();
const dispatch = useDispatch();
const onClickItem = () => {
dispatch(setDrawerShown(false));
};
return (
<>
<h1 className="text-175 text-dimmed mb-2">{t('title')}</h1>
<div className="menu">
<section>
<NavLink className={navLinkClassName} to="/" exact onClick={onClickItem}>
<i className={`icon fas fa-${session ? 'home' : 'arrow-left'}`}></i>
{t(session ? '_sidebar.dashboard' : '_sidebar.return')}
</NavLink>
</section>
{session && (
<section>
<h1>{t('_sidebar.tools')}</h1>
<NavLink className={navLinkClassName} to="/apps/miss-hai" onClick={onClickItem}>
<i className="icon fas fa-tower-broadcast"></i>
{t('_sidebar.missHaiAlert')}
</NavLink>
<NavLink className={navLinkClassName} to="/apps/avatar-cropper" onClick={onClickItem}>
<i className="icon fas fa-crop-simple"></i>
{t('_sidebar.cropper')}
</NavLink>
</section>
)}
{session && (
<section>
<h1>{session.username}@{session.host}</h1>
<NavLink className={navLinkClassName} to="/account" onClick={onClickItem}>
<i className="icon fas fa-circle-user"></i>
{t('_sidebar.accounts')}
</NavLink>
<NavLink className={navLinkClassName} to="/settings" onClick={onClickItem}>
<i className="icon fas fa-gear"></i>
{t('_sidebar.settings')}
</NavLink>
{session.isAdmin && (
<NavLink className={navLinkClassName} to="/admin" onClick={onClickItem}>
<i className="icon fas fa-lock"></i>
{t('_sidebar.admin')}
</NavLink>
)}
</section>
)}
</div>
</>
);
};

View file

@ -50,26 +50,37 @@ export const Ranking: React.VFC<RankingProps> = ({limit}) => {
{response.isCalculating ? (
<p>{t('isCalculating')}</p>
) : (
<table className="table mt-1 fluid">
<thead>
<tr>
<th>{t('_missHai.order')}</th>
<th>{t('name')}</th>
<th>{t('_missHai.rating')}</th>
</tr>
</thead>
<tbody>
{response.ranking.map((r, i) => (
<tr key={i}>
<td>{i + 1}</td>
<td>
{r.username}@{r.host}
</td>
<td>{r.rating}</td>
</tr>
))}
</tbody>
</table>
<div className="menu large">
{response.ranking.map((r, i) => (
<a href={`https://${r.host}/@${r.username}`} target="_blank" rel="noopener noreferrer nofollow" className="item flex" key={i}>
<div className="text-bold pr-2">{i + 1}</div>
<div>
{r.username}@{r.host}<br/>
<span className="text-dimmed text-75">{t('_missHai.rating')}: {r.rating}</span>
</div>
</a>
))}
</div>
// <table className="table mt-1 fluid">
// <thead>
// <tr>
// <th>{t('_missHai.order')}</th>
// <th>{t('name')}</th>
// <th>{t('_missHai.rating')}</th>
// </tr>
// </thead>
// <tbody>
// {response.ranking.map((r, i) => (
// <tr key={i}>
// <td>{i + 1}</td>
// <td>
// {r.username}@{r.host}
// </td>
// <td>{r.rating}</td>
// </tr>
// ))}
// </tbody>
// </table>
)}
</>
) : null

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 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 relativeTime from 'dayjs/plugin/relativeTime';
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 { LOCALSTORAGE_KEY_LANG } from './const';
import 'xeltica-ui/dist/css/xeltica-ui.min.css';
import './style.scss';
import 'dayjs/locale/ja';
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'));

View file

@ -1,17 +1,28 @@
import jaJP from './ja-JP.json';
import enUS from './en-US.json';
import koKR from './ko-KR.json';
import jaCR from './ja-cr.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 = {
'ja_JP': { translation: jaJP },
'en_US': { translation: enUS },
'ko_KR': { translation: koKR },
'en_US': { translation: merge(jaJP, enUS) },
'ko_KR': { translation: merge(jaJP, koKR) },
'ja_CR': { translation: merge(jaJP, jaCR) },
};
export const languageName = {
'ja_JP': '日本語',
'en_US': 'English',
'ko_KR': '한국어',
'ja_CR': '怪レい日本语',
} as const;
export type LanguageCode = keyof typeof resources;

View file

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

View file

@ -0,0 +1,2 @@
export type IsMobileProp = { isMobile: boolean; };

View file

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

View file

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

View file

@ -2,12 +2,12 @@ import React, { useEffect, useState } from 'react';
import { LOCALSTORAGE_KEY_TOKEN } from '../const';
import { useGetSessionQuery } from '../services/session';
import { Skeleton } from './Skeleton';
import { Skeleton } from '../components/Skeleton';
import { IAnnouncement } from '../../common/types/announcement';
import { $delete, $get, $post, $put } from '../misc/api';
import { Card } from './Card';
import { showModal } from '../store/slices/screen';
import { useDispatch } from 'react-redux';
import { useTitle } from '../hooks/useTitle';
export const AdminPage: React.VFC = () => {
@ -15,6 +15,8 @@ export const AdminPage: React.VFC = () => {
const dispatch = useDispatch();
useTitle('_sidebar.admin');
const [announcements, setAnnouncements] = useState<IAnnouncement[]>([]);
const [selectedAnnouncement, selectAnnouncement] = useState<IAnnouncement | null>(null);
const [isAnnouncementsLoaded, setAnnouncementsLoaded] = useState(false);
@ -132,64 +134,66 @@ export const AdminPage: React.VFC = () => {
<p>You are not an administrator and cannot open this page.</p>
) : (
<>
<h2>Announcements</h2>
{!isEditMode && (
<label className="input-switch mb-1">
<input type="checkbox" checked={isDeleteMode} onChange={e => setDeleteMode(e.target.checked)}/>
<div className="switch"></div>
<span>Delete Mode</span>
</label>
)}
<Card bodyClassName={isEditMode ? '' : 'px-0'}>
{ !isEditMode ? (
<>
{isDeleteMode && <div className="ml-2 text-danger">Click the item to delete.</div>}
<div className="large menu">
{announcements.map(a => (
<button className="item fluid" key={a.id} onClick={() => {
if (isDeleteMode) {
deleteAnnouncement(a);
} else {
selectAnnouncement(a);
setEditMode(true);
}
<div className="card shadow-2">
<div className="body">
<h1>Announcements</h1>
{!isEditMode && (
<label className="input-switch mb-2">
<input type="checkbox" checked={isDeleteMode} onChange={e => setDeleteMode(e.target.checked)}/>
<div className="switch"></div>
<span>Delete Mode</span>
</label>
)}
{ !isEditMode ? (
<>
{isDeleteMode && <div className="ml-2 text-danger">Click the item to delete.</div>}
<div className="large menu">
{announcements.map(a => (
<button className="item fluid" key={a.id} onClick={() => {
if (isDeleteMode) {
deleteAnnouncement(a);
} else {
selectAnnouncement(a);
setEditMode(true);
}
}}>
{isDeleteMode && <i className="icon bi fas fa-trash-can text-danger" />}
{a.title}
</button>
))}
{!isDeleteMode && (
<button className="item fluid" onClick={() => setEditMode(true)}>
<i className="icon fas fa-plus"/ >
Create New
</button>
)}
</div>
</>
) : (
<div className="vstack">
<label className="input-field">
Title
<input type="text" value={draftTitle} onChange={e => setDraftTitle(e.target.value)} />
</label>
<label className="input-field">
Body
<textarea className="input-field" value={draftBody} rows={10} onChange={e => setDraftBody(e.target.value)}/>
</label>
<div className="hstack" style={{justifyContent: 'flex-end'}}>
<button className="btn primary" onClick={submitAnnouncement} disabled={!draftTitle || !draftBody}>
Submit
</button>
<button className="btn" onClick={() => {
selectAnnouncement(null);
setEditMode(false);
}}>
{isDeleteMode && <i className="icon bi bi-trash text-danger" />}
{a.title}
Cancel
</button>
))}
{!isDeleteMode && (
<button className="item fluid" onClick={() => setEditMode(true)}>
<i className="icon bi bi-plus"/ >
Create New
</button>
)}
</div>
</div>
</>
) : (
<div className="vstack">
<label className="input-field">
Title
<input type="text" value={draftTitle} onChange={e => setDraftTitle(e.target.value)} />
</label>
<label className="input-field">
Body
<textarea className="input-field" value={draftBody} rows={10} onChange={e => setDraftBody(e.target.value)}/>
</label>
<div className="hstack" style={{justifyContent: 'flex-end'}}>
<button className="btn primary" onClick={submitAnnouncement} disabled={!draftTitle || !draftBody}>
Submit
</button>
<button className="btn" onClick={() => {
selectAnnouncement(null);
setEditMode(false);
}}>
Cancel
</button>
</div>
</div>
)}
</Card>
)}
</div>
</div>
<h2>Misshai</h2>
<div className="vstack">
<button className="btn danger" onClick={onClickStartMisshaiAlertWorkerButton}>

View file

@ -6,6 +6,7 @@ import { IAnnouncement } from '../../common/types/announcement';
import { Skeleton } from '../components/Skeleton';
import { $get } from '../misc/api';
import { useSelector } from '../store';
import { useTitle } from '../hooks/useTitle';
export const AnnouncementPage: React.VFC = () => {
const { id } = useParams<{id: string}>();
@ -15,6 +16,8 @@ export const AnnouncementPage: React.VFC = () => {
const lang = useSelector(state => state.screen.language);
useTitle('announcements');
useEffect(() => {
$get<IAnnouncement>('announcements/' + id).then(setAnnouncement);
}, [setAnnouncement]);
@ -25,7 +28,7 @@ export const AnnouncementPage: React.VFC = () => {
<h2>
{announcement.title}
<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()}
</aside>
</h2>

View file

@ -0,0 +1,11 @@
import React from 'react';
import { useTitle } from '../../hooks/useTitle';
export const AnnouncementsPage: React.VFC = () => {
useTitle('announcements');
return (
<div className="fade">
</div>
);
};

View file

@ -2,10 +2,12 @@ import React, { ChangeEventHandler, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import ReactCrop, { Crop } from 'react-image-crop';
import 'react-image-crop/dist/ReactCrop.css';
import { useDispatch } from 'react-redux';
import { useGetSessionQuery } from '../services/session';
import { showModal } from '../store/slices/screen';
import { useGetSessionQuery } from '../../services/session';
import { showModal } from '../../store/slices/screen';
import 'react-image-crop/dist/ReactCrop.css';
import { useTitle } from '../../hooks/useTitle';
export const NekomimiPage: React.VFC = () => {
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 [completedCrop, setCompletedCrop] = useState<Crop>();
useTitle('catAdjuster');
const previewCanvasRef = useRef<HTMLCanvasElement>(null);
const {data} = useGetSessionQuery(undefined);

View file

@ -3,11 +3,11 @@
flex-wrap: wrap;
gap: var(--margin);
> .misshaiData {
flex: 3 0 300px;
}
> .developerInfo {
flex: 1 0 300px;
}
> .misshaiRanking {
flex: 4 0 300px;
}
> .alertModeSetting {
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 { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { alertModes } from '../../common/types/alert-mode';
import { IUser } from '../../common/types/user';
import { Visibility } from '../../common/types/visibility';
import { LOCALSTORAGE_KEY_ACCOUNTS, LOCALSTORAGE_KEY_TOKEN } from '../const';
import { $post, $put } from '../misc/api';
import { useGetScoreQuery, useGetSessionQuery } from '../services/session';
import { showModal } from '../store/slices/screen';
import { AnnouncementList } from './AnnouncementList';
import { Ranking } from './Ranking';
import { Skeleton } from './Skeleton';
import { alertModes } from '../../../common/types/alert-mode';
import { IUser } from '../../../common/types/user';
import { Visibility } from '../../../common/types/visibility';
import { LOCALSTORAGE_KEY_ACCOUNTS, LOCALSTORAGE_KEY_TOKEN } from '../../const';
import { $post, $put } from '../../misc/api';
import { useGetScoreQuery, useGetSessionQuery } from '../../services/session';
import { showModal } from '../../store/slices/screen';
import { Skeleton } from '../../components/Skeleton';
import './MisshaiPage.scss';
import { DeveloperInfo } from './DeveloperInfo';
import './misshai.scss';
import { Ranking } from '../../components/Ranking';
import { useTitle } from '../../hooks/useTitle';
const variables = [
'notesCount',
@ -43,14 +42,15 @@ type DraftReducer = React.Reducer<SettingDraftType, Partial<SettingDraftType>>;
export const MisshaiPage: React.VFC = () => {
const dispatch = useDispatch();
const [limit, setLimit] = useState<number | undefined>(10);
const session = useGetSessionQuery(undefined);
const [limit, setLimit] = useState<number | undefined>(10);
const data = session.data;
const score = useGetScoreQuery(undefined);
const {t} = useTranslation();
useTitle('_sidebar.missHaiAlert');
const [draft, dispatchDraft] = useReducer<DraftReducer>((state, action) => {
return { ...state, ...action };
}, {
@ -195,54 +195,42 @@ export const MisshaiPage: React.VFC = () => {
<Skeleton width="100%" height="160px" />
</div>
) : (
<div className="vstack">
<div className="card announcement">
<div className="vstack fade">
<div className="card misshaiData">
<div className="body">
<AnnouncementList />
</div>
</div>
<div className="misshaiPageLayout">
<div className="card misshaiData">
<div className="body">
<h1><i className="bi bi-activity"></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.data.rating}
</p>
</div>
</div>
<div className="card developerInfo">
<div className="body">
<DeveloperInfo />
</div>
<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.data.rating}
</p>
</div>
</div>
<div className="card misshaiRanking">
@ -261,8 +249,8 @@ export const MisshaiPage: React.VFC = () => {
<div className="misshaiPageLayout">
<div className="card alertModeSetting">
<div className="body">
<h1 className="mb-2"><i className="bi bi-gear"></i> {t('alertMode')}</h1>
<div className="vstack">
<h1><i className="fas fa-gear"></i> {t('alertMode')}</h1>
<div className="vstack slim">
{ alertModes.map((mode) => (
<label key={mode} className="input-check">
<input type="radio" checked={mode === draft.alertMode} onChange={() => {
@ -274,14 +262,14 @@ export const MisshaiPage: React.VFC = () => {
</div>
{ (draft.alertMode === 'notification' || draft.alertMode === 'both') && (
<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')}
</div>
)}
{ (draft.alertMode === 'note' || draft.alertMode === 'both') && (
<>
<h2 className="mt-2 mb-1">{t('visibility')}</h2>
<div className="vstack">
<div className="vstack slim">
{
availableVisibilities.map((visibility) => (
<label key={visibility} className="input-check">
@ -305,15 +293,15 @@ export const MisshaiPage: React.VFC = () => {
</div>
<div className="card templateSetting">
<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>
<div className="hstack dense mb-2">
<button className="btn" onClick={onClickInsertVariables}>
<i className="bi bi-braces" />&nbsp;
{'{ } '}
{t('_template.insertVariables')}
</button>
<button className="btn link text-info" onClick={onClickInsertVariablesHelp}>
<i className="bi bi-question-circle" />
<i className="fas fa-circle-question" />
</button>
</div>
<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 className="list-form mt-2">
<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">
<h1>{t('sendAlert')}</h1>
<p className="desc">{t(draft.alertMode === 'nothing' ? 'sendAlertDisabled' : 'sendAlertDescription')}</p>

View file

@ -1,10 +1,12 @@
import React from 'react';
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 = () => {
const {t} = useTranslation();
useTitle('_missHai.ranking');
return (
<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 { AccountsPage } from '../components/AccountsPage';
import { useDispatch } from 'react-redux';
import { LOCALSTORAGE_KEY_ACCOUNTS } from '../const';
import { IUser } from '../../common/types/user';
import { setAccounts } from '../store/slices/screen';
import { useGetMetaQuery, useGetSessionQuery } from '../services/session';
import { AdminPage } from '../components/AdminPage';
import { useGetScoreQuery, useGetSessionQuery } from '../services/session';
import { $get } from '../misc/api';
import { NekomimiPage } from '../components/NekomimiPage';
import { Card } from '../components/Card';
import { CurrentUser } from '../components/CurrentUser';
import { AnnouncementList } from '../components/AnnouncementList';
import { DeveloperInfo } from '../components/DeveloperInfo';
export const IndexSessionPage: React.VFC = () => {
const [selectedTab, setSelectedTab] = useState<string>('misshai');
const {t, i18n} = useTranslation();
const {t} = useTranslation();
const dispatch = useDispatch();
const { data: session } = useGetSessionQuery(undefined);
const { data: meta } = useGetMetaQuery(undefined);
const score = useGetScoreQuery(undefined);
useEffect(() => {
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[])));
}, [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 (
<>
<div className="xarticle vgroup shadow-4" style={{position: 'sticky', top: 0, zIndex: 100}}>
<Header />
<div className="card">
<Tab items={items} selected={selectedTab} onSelect={setSelectedTab}/>
<div className="vstack fade">
<div className="card announcement">
<div className="body">
<AnnouncementList />
</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>
)}
</Card>
{component}
<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>
);
};

View file

@ -1,28 +1,122 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { LoginForm } from '../components/LoginForm';
import { Header } from '../components/Header';
import { AnnouncementList } from '../components/AnnouncementList';
import styled from 'styled-components';
import { useSelector } from '../store';
import { IsMobileProp } from '../misc/is-mobile-prop';
import { IAnnouncement } from '../../common/types/announcement';
import { $get } from '../misc/api';
import Twemoji from 'react-twemoji';
const Hero = styled.div<IsMobileProp>`
display: flex;
position: relative;
background: linear-gradient(-135deg, rgb(1, 169, 46), rgb(134, 179, 0) 35%);
color: var(--white);
padding: ${f => f.isMobile ? '16px' : '60px 90px'};
overflow: hidden;
gap: var(--margin);
> .hero {
flex: 2;
min-width: 0;
position: relative;
z-index: 1000;
p {
${f => f.isMobile ? 'font-size: 1rem;' : ''}
}
}
> .announcements {
flex: 1;
min-width: 0;
max-height: 512px;
overflow: auto;
padding: var(--margin);
border-radius: var(--radius);
background: var(--black-50);
backdrop-filter: blur(4px) brightness(120%);
z-index: 1000;
@media screen and (max-width: 800px) {
display: none;
}
}
> .rects {
position: absolute;
display: grid;
right: 160px;
bottom: -120px;
width: 400px;
height: 400px;
gap: 8px;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
transform-origin: center center;
transform: rotate(45deg);
opacity: 0.5;
> .rect {
border: 2px solid var(--white);
border-radius: 24px;
box-shadow: 0 2px 4px var(--shadow-color);
}
}
`;
const FormWrapper = styled.div`
max-width: 500px;
color: var(--fg);
`;
export const IndexWelcomePage: React.VFC = () => {
const [announcements, setAnnouncements] = useState<IAnnouncement[]>([]);
const {isMobile} = useSelector(state => state.screen);
const {t} = useTranslation();
const fetchAllAnnouncements = () => {
setAnnouncements([]);
$get<IAnnouncement[]>('announcements').then(announcements => {
setAnnouncements(announcements ?? []);
});
};
useEffect(() => {
fetchAllAnnouncements();
}, []);
return (
<>
<Header className="xarticle mb-4">
<article className="mt-4">
<p>{t('description1')}</p>
<p>{t('description2')}</p>
</article>
<LoginForm />
</Header>
<article className="xarticle card">
<div className="body">
<AnnouncementList />
<Hero className="fluid shadow-2" isMobile={isMobile}>
<div className="hero">
<h1 className="shadow-t">{t('title')}</h1>
<p className="shadow-t">{t('description1')}</p>
<p className="shadow-t">{t('description2')}</p>
<FormWrapper className="bg-panel pa-2 mt-4 rounded shadow-2">
<LoginForm />
</FormWrapper>
</div>
</article>
<hr />
<div className="announcements">
<h2></h2>
<div className="menu large">
{announcements.map(a => (
<Link className="item fluid" key={a.id} to={`/announcements/${a.id}`}>
{a.title}
</Link>
))}
</div>
</div>
<div className="rects">
<div className="rect"></div>
<div className="rect"></div>
<div className="rect"></div>
<div className="rect"></div>
</div>
</Hero>
<Twemoji options={{className: 'twemoji'}}>
<div className="py-4 text-125 text-center">
👍&emsp;&emsp;😆&emsp;🎉&emsp;🍮
</div>
</Twemoji>
<article className="xarticle vstack pa-2">
<header>
<h2>{t('_welcome.title')}</h2>
@ -30,18 +124,18 @@ export const IndexWelcomePage: React.VFC = () => {
</header>
<div className="row">
<article className="col-4 col-12-sm">
<h3><i className="bi bi-megaphone-fill"/> {t('_welcome.misshaiAlertTitle')}</h3>
<h3><i className="fas fa-bullhorn"/> {t('_welcome.misshaiAlertTitle')}</h3>
<p>{t('_welcome.misshaiAlertDescription')}</p>
</article>
<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>
<Link to="/ranking">{t('_missHai.showRanking')}</Link>
<Link to="/apps/miss-hai/ranking">{t('_missHai.showRanking')}</Link>
</article>
<div className="col-4 col-12-sm">
<h3><i className="bi bi-crop"/> {t('catAdjuster')}</h3>
<article className="col-4 col-12-sm">
<h3><i className="fas fa-crop-simple"/> {t('catAdjuster')}</h3>
<p>{t('_welcome.catAdjusterDescription')}</p>
</div>
</article>
</div>
<article className="mt-5">
<h3>{t('_welcome.nextFeaturesTitle')}</h3>

View file

@ -3,13 +3,14 @@ import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { useGetSessionQuery } from '../services/session';
import { Card } from './Card';
import { Card } from '../components/Card';
import { Theme, themes } from '../misc/theme';
import { LOCALSTORAGE_KEY_TOKEN } from '../const';
import { changeLang, changeTheme, showModal } from '../store/slices/screen';
import { useSelector } from '../store';
import { languageName } from '../langs';
import { $delete } from '../misc/api';
import { useTitle } from '../hooks/useTitle';
export const SettingPage: React.VFC = () => {
const session = useGetSessionQuery(undefined);
@ -18,6 +19,8 @@ export const SettingPage: React.VFC = () => {
const data = session.data;
const {t} = useTranslation();
useTitle('_sidebar.settings');
const currentTheme = useSelector(state => state.screen.theme);
const currentLang = useSelector(state => state.screen.language);
@ -39,7 +42,7 @@ export const SettingPage: React.VFC = () => {
onSelect(i) {
if (i === 0) {
localStorage.removeItem(LOCALSTORAGE_KEY_TOKEN);
location.reload();
location.href = '/';
}
},
}));
@ -69,7 +72,7 @@ export const SettingPage: React.VFC = () => {
message: t('_deactivate.success'),
icon: 'info',
onSelect() {
location.reload();
location.href = '/';
}
}));
}).catch((e) => {
@ -91,7 +94,7 @@ export const SettingPage: React.VFC = () => {
) : (
<div className="vstack fade">
<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>
<div className="vstack">
{
@ -115,21 +118,23 @@ export const SettingPage: React.VFC = () => {
}
</select>
<div className="alert bg-info mt-2">
<i className="icon bi bi-translate" />
{t('translatedByTheCommunity')}&nbsp;
<a href="https://crowdin.com/project/misskey-tools" target="_blank" rel="noopener noreferrer">{t('helpTranslation')}</a>
<i className="icon fas fa-language" />
<div>
{t('translatedByTheCommunity')}&nbsp;
<a href="https://crowdin.com/project/misskey-tools" target="_blank" rel="noopener noreferrer">{t('helpTranslation')}</a>
</div>
</div>
</Card>
<div className="list-form">
<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">
<h1>{t('logout')}</h1>
<p className="desc">{t('logoutDescription')}</p>
</div>
</button>
<button className="item text-danger" onClick={onClickDeleteAccount}>
<i className="icon bi bi-trash" />
<i className="icon fas fa-trash-can" />
<div className="body">
<h1>{t('deleteAccount')}</h1>
<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 i18n from 'i18next';
import { WritableDraft } from 'immer/dist/internal';
import { IUser } from '../../../common/types/user';
import { LOCALSTORAGE_KEY_ACCOUNTS, LOCALSTORAGE_KEY_LANG, LOCALSTORAGE_KEY_THEME } from '../../const';
@ -10,9 +11,12 @@ interface ScreenState {
modal: Modal | null;
modalShown: boolean;
theme: Theme;
title: string | null;
language: string;
accounts: IUser[];
accountTokens: string[];
isMobile: boolean;
isDrawerShown: boolean;
}
const initialState: ScreenState = {
@ -20,8 +24,21 @@ const initialState: ScreenState = {
modalShown: false,
theme: localStorage[LOCALSTORAGE_KEY_THEME] ?? 'system',
language: localStorage[LOCALSTORAGE_KEY_LANG] ?? i18n.language ?? 'ja_JP',
title: null,
accounts: [],
accountTokens: JSON.parse(localStorage.getItem(LOCALSTORAGE_KEY_ACCOUNTS) || '[]') as string[],
isMobile: false,
isDrawerShown: 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({
@ -36,23 +53,23 @@ export const screenSlice = createSlice({
state.modal = null;
state.modalShown = false;
},
changeTheme: (state, action: PayloadAction<ScreenState['theme']>) => {
state.theme = action.payload;
changeTheme: generateSetter('theme', (_, action) => {
localStorage[LOCALSTORAGE_KEY_THEME] = action.payload;
},
changeLang: (state, action: PayloadAction<ScreenState['language']>) => {
state.language = action.payload;
}),
changeLang: generateSetter('language', (_, action) => {
localStorage[LOCALSTORAGE_KEY_LANG] = action.payload;
i18n.changeLanguage(action.payload);
},
setAccounts: (state, action: PayloadAction<ScreenState['accounts']>) => {
state.accounts = action.payload;
}),
setAccounts: generateSetter('accounts', (state, action) => {
state.accountTokens = action.payload.map(a => a.misshaiToken);
localStorage[LOCALSTORAGE_KEY_ACCOUNTS] = JSON.stringify(state.accountTokens);
},
}),
setMobile: generateSetter('isMobile'),
setTitle: generateSetter('title'),
setDrawerShown: generateSetter('isDrawerShown'),
},
});
export const { showModal, hideModal, changeTheme, changeLang, setAccounts } = screenSlice.actions;
export const { showModal, hideModal, changeTheme, changeLang, setAccounts, setMobile, setTitle, setDrawerShown } = screenSlice.actions;
export default screenSlice.reducer;

View file

@ -19,6 +19,15 @@ hr {
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 {
animation: 0.3s ease-out 0s fadeIn;
&.down {
@ -94,6 +103,10 @@ small {
max-width: min(100vw, 600px);
}
.card > .body > h1 {
margin-bottom: var(--margin);
}
.modal-menu-wrapper {
display: flex;
position: fixed;
@ -130,4 +143,10 @@ small {
animation: earwiggleright 1s infinite;
}
}
}
.twemoji {
height: 1em;
width: 1em;
vertical-align: -0.1em;
}

View file

@ -355,6 +355,13 @@
dependencies:
"@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":
version "3.7.1"
resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.1.tgz#8dc390a7b4f9dd9f1284629efce982e41612116e"
@ -615,6 +622,13 @@
"@types/history" "*"
"@types/react" "*"
"@types/react-twemoji@^0.4.0":
version "0.4.0"
resolved "https://registry.yarnpkg.com/@types/react-twemoji/-/react-twemoji-0.4.0.tgz#3f3ce96e273ec0aa4b4ddca2913951b168fd72ee"
integrity sha512-OVkDaNTg9WqqM2MBqL68FNPsn+5aabQIbL9KY+ofK/Q4ENOuaHOWsg/jRD9zQ+GX5L+7LC1Ztgr4iK0/qZd17w==
dependencies:
"@types/react" "*"
"@types/react@*", "@types/react@^17.0.19":
version "17.0.27"
resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.27.tgz#6498ed9b3ad117e818deb5525fa1946c09f2e0e6"
@ -1737,6 +1751,11 @@ deep-is@^0.1.3:
resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831"
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:
version "1.1.3"
resolved "https://registry.yarnpkg.com/defer-to-connect/-/defer-to-connect-1.1.3.tgz#331ae050c08dcf789f8c83a7b81f0ed94f4ac591"
@ -2364,6 +2383,15 @@ fresh@0.5.2, fresh@~0.5.2:
resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7"
integrity sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=
fs-extra@^8.0.1:
version "8.1.0"
resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-8.1.0.tgz#49d43c45a88cd9677668cb7be1b46efdb8d2e1c0"
integrity sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==
dependencies:
graceful-fs "^4.2.0"
jsonfile "^4.0.0"
universalify "^0.1.0"
fs.realpath@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
@ -2534,6 +2562,11 @@ graceful-fs@^4.1.2, graceful-fs@^4.2.4:
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.8.tgz#e412b8d33f5e006593cbd3cee6df9f2cebbe802a"
integrity sha512-qkIilPUYcNhJpd33n0GBXTB1MMPp14TxEsEs0pTrsSVucApsYzW5V+Q8Qxhik6KU3evy+qkAAowTByymK0avdg==
graceful-fs@^4.1.6, graceful-fs@^4.2.0:
version "4.2.10"
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.10.tgz#147d3a006da4ca3ce14728c7aefc287c367d7a6c"
integrity sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==
has-ansi@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/has-ansi/-/has-ansi-2.0.0.tgz#34f5049ce1ecdf2b0649af3ef24e45ed35416d91"
@ -3183,6 +3216,22 @@ json5@^2.1.2, json5@^2.1.3:
dependencies:
minimist "^1.2.5"
jsonfile@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-4.0.0.tgz#8771aae0799b64076b76640fca058f9c10e33ecb"
integrity sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==
optionalDependencies:
graceful-fs "^4.1.6"
jsonfile@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-5.0.0.tgz#e6b718f73da420d612823996fdf14a03f6ff6922"
integrity sha512-NQRZ5CRo74MhMMC3/3r5g2k4fjodJ/wh8MxjFbCViWKFjxrnudWSY5vomh+23ZaXzAS7J3fBZIR2dV6WbmfM0w==
dependencies:
universalify "^0.1.2"
optionalDependencies:
graceful-fs "^4.1.6"
jstransformer@1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/jstransformer/-/jstransformer-1.0.0.tgz#ed8bf0921e2f3f1ed4d5c1a44f68709ed24722c3"
@ -3416,6 +3465,11 @@ lodash.clonedeep@^4.5.0:
resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef"
integrity sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=
lodash.isequal@^4.5.0:
version "4.5.0"
resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0"
integrity sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==
lodash.merge@^4.6.2:
version "4.6.2"
resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a"
@ -4810,6 +4864,15 @@ react-router@5.2.1:
tiny-invariant "^1.0.2"
tiny-warning "^1.0.0"
react-twemoji@^0.5.0:
version "0.5.0"
resolved "https://registry.yarnpkg.com/react-twemoji/-/react-twemoji-0.5.0.tgz#0565f8e427fc4c9ef3680977c4a88fbdef79f874"
integrity sha512-xz3NLWTFCfWOmPd559jcFX4f976ORIPpL9SwdBQO5BZwIYD1U1vpbY2E6k2vwPCVH78s2m1GbG5jpHKGUPZ+gw==
dependencies:
lodash.isequal "^4.5.0"
prop-types "^15.7.2"
twemoji "14.0.1"
react@^17.0.2:
version "17.0.2"
resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037"
@ -5750,6 +5813,21 @@ tsutils@^3.21.0:
dependencies:
tslib "^1.8.1"
twemoji-parser@14.0.0:
version "14.0.0"
resolved "https://registry.yarnpkg.com/twemoji-parser/-/twemoji-parser-14.0.0.tgz#13dabcb6d3a261d9efbf58a1666b182033bf2b62"
integrity sha512-9DUOTGLOWs0pFWnh1p6NF+C3CkQ96PWmEFwhOVmT3WbecRC+68AIqpsnJXygfkFcp4aXbOp8Dwbhh/HQgvoRxA==
twemoji@14.0.1:
version "14.0.1"
resolved "https://registry.yarnpkg.com/twemoji/-/twemoji-14.0.1.tgz#0640887ef149403ae577081cbc2480a026e55ed6"
integrity sha512-eoqhea0sUhmC10iTacksyp1v9O4BP1jKmVqtK+Nztw40/dzawSHkXL3/xCpyh+mukmEvJ0Gw9VLvwZfQ9HKXDw==
dependencies:
fs-extra "^8.0.1"
jsonfile "^5.0.0"
twemoji-parser "14.0.0"
universalify "^0.1.2"
type-check@^0.4.0, type-check@~0.4.0:
version "0.4.0"
resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1"
@ -5925,6 +6003,11 @@ unist-util-visit@^4.0.0:
unist-util-is "^5.0.0"
unist-util-visit-parents "^5.0.0"
universalify@^0.1.0, universalify@^0.1.2:
version "0.1.2"
resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66"
integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==
unpipe@1.0.0, unpipe@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"
@ -6228,8 +6311,8 @@ xdg-basedir@^4.0.0:
integrity sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==
xeltica-ui@xeltica/design-system:
version "1.0.0-beta.7"
resolved "https://codeload.github.com/xeltica/design-system/tar.gz/d0b49b6487c379a10f6e568c0f9e37b0035ea7f2"
version "1.0.0-beta.9"
resolved "https://codeload.github.com/xeltica/design-system/tar.gz/83b7faede9b0a42a9a3bd8d462d95484cfa67294"
xml2js@^0.4.17:
version "0.4.23"