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": {
"@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",
@ -45,6 +46,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",

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">
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>
<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>
) : <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 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,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> = {
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,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

@ -2,10 +2,18 @@ import jaJP from './ja-JP.json';
import enUS from './en-US.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 = {
'ja_JP': { translation: jaJP },
'en_US': { translation: enUS },
'ko_KR': { translation: koKR },
'en_US': { translation: merge(jaJP, enUS) },
'ko_KR': { translation: merge(jaJP, koKR) },
};
export const languageName = {

View file

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

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,15 +134,16 @@ export const AdminPage: React.VFC = () => {
<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 && (
<label className="input-switch mb-1">
<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>
)}
<Card bodyClassName={isEditMode ? '' : 'px-0'}>
{ !isEditMode ? (
<>
{isDeleteMode && <div className="ml-2 text-danger">Click the item to delete.</div>}
@ -154,13 +157,13 @@ export const AdminPage: React.VFC = () => {
setEditMode(true);
}
}}>
{isDeleteMode && <i className="icon bi bi-trash text-danger" />}
{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 bi bi-plus"/ >
<i className="icon fas fa-plus"/ >
Create New
</button>
)}
@ -189,7 +192,8 @@ export const AdminPage: React.VFC = () => {
</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

@ -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,16 +195,10 @@ export const MisshaiPage: React.VFC = () => {
<Skeleton width="100%" height="160px" />
</div>
) : (
<div className="vstack">
<div className="card announcement">
<div className="body">
<AnnouncementList />
</div>
</div>
<div className="misshaiPageLayout">
<div className="vstack fade">
<div className="card misshaiData">
<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">
<thead>
<tr>
@ -239,12 +233,6 @@ export const MisshaiPage: React.VFC = () => {
</p>
</div>
</div>
<div className="card developerInfo">
<div className="body">
<DeveloperInfo />
</div>
</div>
</div>
<div className="card misshaiRanking">
<div className="body">
<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="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="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 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>
</>
);
};

View file

@ -3,14 +3,14 @@ import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { LoginForm } from '../components/LoginForm';
import { Header } from '../components/Header';
import { Header } from '../Header';
import { AnnouncementList } from '../components/AnnouncementList';
export const IndexWelcomePage: React.VFC = () => {
const {t} = useTranslation();
return (
<>
<Header className="xarticle mb-4">
<Header>
<article className="mt-4">
<p>{t('description1')}</p>
<p>{t('description2')}</p>
@ -30,16 +30,16 @@ 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-bullhone"/> {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>
<h3><i className="fas fa-crop-simple"/> {t('catAdjuster')}</h3>
<p>{t('_welcome.catAdjusterDescription')}</p>
</div>
</div>

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);
@ -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,21 @@ export const SettingPage: React.VFC = () => {
}
</select>
<div className="alert bg-info mt-2">
<i className="icon bi bi-translate" />
<i className="icon fas fa-language" />
{t('translatedByTheCommunity')}&nbsp;
<a href="https://crowdin.com/project/misskey-tools" target="_blank" rel="noopener noreferrer">{t('helpTranslation')}</a>
</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,11 @@ interface ScreenState {
modal: Modal | null;
modalShown: boolean;
theme: Theme;
title: string | null;
language: string;
accounts: IUser[];
accountTokens: string[];
isMobile: boolean;
}
const initialState: ScreenState = {
@ -20,8 +23,20 @@ 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,
};
/**
* 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 +51,22 @@ 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'),
},
});
export const { showModal, hideModal, changeTheme, changeLang, setAccounts } = screenSlice.actions;
export const { showModal, hideModal, changeTheme, changeLang, setAccounts, setMobile, setTitle } = 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;

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"
@ -1737,6 +1744,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"
@ -6228,8 +6240,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"