diff --git a/package.json b/package.json index 2c56673..f68b147 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/backend/views/frontend.pug b/src/backend/views/frontend.pug index fd638fd..94f7f0c 100644 --- a/src/backend/views/frontend.pug +++ b/src/backend/views/frontend.pug @@ -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; diff --git a/src/frontend/App.tsx b/src/frontend/App.tsx index d906625..3b08b17 100644 --- a/src/frontend/App.tsx +++ b/src/frontend/App.tsx @@ -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 ? ( -
-
-
-

{t('error')}

-

{t('_error.sorry')}

-

- {t('_error.additionalInfo')} - {t(`_error.${error}`)} -

- (window as any).__misshaialert.error = null}>{t('retry')} -
-
- ) : ( -
- {$location.pathname !== '/' &&
} - - - - - - + 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 ( + + {error ? ( +
+

{t('error')}

+

{t('_error.sorry')}

+

+ {t('_error.additionalInfo')} + {t(`_error.${error}`)} +

+
+ ) : } -
+ ); }; diff --git a/src/frontend/GeneralLayout.tsx b/src/frontend/GeneralLayout.tsx new file mode 100644 index 0000000..7808c74 --- /dev/null +++ b/src/frontend/GeneralLayout.tsx @@ -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` + padding: var(--margin); + position: relative; +`; + +const Sidebar = styled.nav` + width: 320px; + position: fixed; + top: var(--margin); + left: var(--margin); +`; + +const Main = styled.main` + 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 ( + + {isMobile && ( + + +

{t(title ?? 'title')}

+
+ )} +
+ {!isMobile && ( + + + + )} +
+ {session && meta && meta.currentTokenVersion !== session.tokenVersion && ( +
+ + {t('shouldUpdateToken')} + + {t('update')} + +
+ )} + {children} +
+
+
+
dispatch(setDrawerShown(false))}>
+
e.stopPropagation()}> + +
+
+
+ ); +}; diff --git a/src/frontend/Header.tsx b/src/frontend/Header.tsx new file mode 100644 index 0000000..a122828 --- /dev/null +++ b/src/frontend/Header.tsx @@ -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 = ({title}) => { + const { t } = useTranslation(); + const { data } = useGetSessionQuery(undefined); + const { isMobile } = useSelector(state => state.screen); + + return ( +
+

+ {{t('title')}} + {title && <> / {title}} +

+ {data && ( + + )} +
+ ); +}; diff --git a/src/frontend/Modal.tsx b/src/frontend/Modal.tsx index 5cf3275..c6db066 100644 --- a/src/frontend/Modal.tsx +++ b/src/frontend/Modal.tsx @@ -23,10 +23,10 @@ const getButtons = (button: DialogButtonType): DialogButton[] => { }; const dialogIconPattern: Record = { - error: 'bi bi-x-circle-fill text-danger', - info: 'bi bi-info-circle-fill text-primary', - question: 'bi bi-question-circle-fill text-primary', - warning: 'bi bi-exclamation-circle-fill text-warning', + 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}) => { diff --git a/src/frontend/Router.tsx b/src/frontend/Router.tsx new file mode 100644 index 0000000..ed7692d --- /dev/null +++ b/src/frontend/Router.tsx @@ -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 ( + + + + + + + + + + + ); +}; diff --git a/src/frontend/components/AnnouncementList.tsx b/src/frontend/components/AnnouncementList.tsx index 93f3279..04f8b20 100644 --- a/src/frontend/components/AnnouncementList.tsx +++ b/src/frontend/components/AnnouncementList.tsx @@ -24,7 +24,7 @@ export const AnnouncementList: React.VFC = () => { return ( <> -

{t('announcements')}

+

{t('announcements')}

{announcements.map(a => ( diff --git a/src/frontend/components/CurrentUser.tsx b/src/frontend/components/CurrentUser.tsx index 0c5b00c..4d88e93 100644 --- a/src/frontend/components/CurrentUser.tsx +++ b/src/frontend/components/CurrentUser.tsx @@ -5,6 +5,6 @@ import { Skeleton } from './Skeleton'; export const CurrentUser: React.VFC = () => { const {data} = useGetSessionQuery(undefined); return data ? ( -

{data.username}@{data.host}

+

{data.username}@{data.host}

) : ; }; diff --git a/src/frontend/components/DeveloperInfo.tsx b/src/frontend/components/DeveloperInfo.tsx index 26fe6f9..f371f99 100644 --- a/src/frontend/components/DeveloperInfo.tsx +++ b/src/frontend/components/DeveloperInfo.tsx @@ -5,19 +5,19 @@ export const DeveloperInfo: React.VFC = () => { const {t} = useTranslation(); return ( <> -

{t('_developerInfo.title')}

+

{t('_developerInfo.title')}

{t('_developerInfo.description')}

diff --git a/src/frontend/components/Header.tsx b/src/frontend/components/Header.tsx deleted file mode 100644 index 05869d6..0000000 --- a/src/frontend/components/Header.tsx +++ /dev/null @@ -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['className'], - style?: HTMLProps['style'], -}; - -export const Header: React.FC = ({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 ( -
-
-

- {hasTopLink ? {t('title')} : t('title')} - {meta && ( - - v{meta?.version} - - )} -

-

- - {gacha} - {session && ( - - - - )} -

- {children} -
-
- ); -}; diff --git a/src/frontend/components/LoginForm.tsx b/src/frontend/components/LoginForm.tsx index 7a3aed2..250ba9b 100644 --- a/src/frontend/components/LoginForm.tsx +++ b/src/frontend/components/LoginForm.tsx @@ -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 = () => { {t('instanceUrl')}
- `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 ( + <> +

{t('title')}

+
+
+ + + {t(session ? '_sidebar.dashboard' : '_sidebar.return')} + +
+ {session && ( +
+

{t('_sidebar.tools')}

+ + + {t('_sidebar.missHaiAlert')} + + + + {t('_sidebar.cropper')} + +
+ )} + {session && ( +
+

{session.username}@{session.host}

+ + + {t('_sidebar.accounts')} + + + + {t('_sidebar.settings')} + + {session.isAdmin && ( + + + {t('_sidebar.admin')} + + )} +
+ )} +
+ + ); +}; diff --git a/src/frontend/components/Ranking.tsx b/src/frontend/components/Ranking.tsx index 17616fc..79bc5ab 100644 --- a/src/frontend/components/Ranking.tsx +++ b/src/frontend/components/Ranking.tsx @@ -50,26 +50,37 @@ export const Ranking: React.VFC = ({limit}) => { {response.isCalculating ? (

{t('isCalculating')}

) : ( - - - - - - - - - - {response.ranking.map((r, i) => ( - - - - - - ))} - -
{t('_missHai.order')}{t('name')}{t('_missHai.rating')}
{i + 1} - {r.username}@{r.host} - {r.rating}
+ + // + // + // + // + // + // + // + // + // + // {response.ranking.map((r, i) => ( + // + // + // + // + // + // ))} + // + //
{t('_missHai.order')}{t('name')}{t('_missHai.rating')}
{i + 1} + // {r.username}@{r.host} + // {r.rating}
)} ) : null diff --git a/src/frontend/components/RankingPage.tsx b/src/frontend/components/RankingPage.tsx deleted file mode 100644 index e9a9e63..0000000 --- a/src/frontend/components/RankingPage.tsx +++ /dev/null @@ -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(10); - const {t} = useTranslation(); - return ( -
- - {limit && } -
- ); -}; diff --git a/src/frontend/const.ts b/src/frontend/const.ts index 1983a42..e0ad2cb 100644 --- a/src/frontend/const.ts +++ b/src/frontend/const.ts @@ -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'; diff --git a/src/frontend/hooks/useTitle.ts b/src/frontend/hooks/useTitle.ts new file mode 100644 index 0000000..8a997bf --- /dev/null +++ b/src/frontend/hooks/useTitle.ts @@ -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]); +}; diff --git a/src/frontend/init.tsx b/src/frontend/init.tsx index fb5e79f..e1e2a77 100644 --- a/src/frontend/init.tsx +++ b/src/frontend/init.tsx @@ -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(, document.getElementById('app')); diff --git a/src/frontend/langs/index.ts b/src/frontend/langs/index.ts index ec8fa0d..514b284 100644 --- a/src/frontend/langs/index.ts +++ b/src/frontend/langs/index.ts @@ -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, newData: Record) => { + 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; diff --git a/src/frontend/langs/ja-JP.json b/src/frontend/langs/ja-JP.json index 92d45af..6f038ba 100644 --- a/src/frontend/langs/ja-JP.json +++ b/src/frontend/langs/ja-JP.json @@ -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, しすぎていませんか?", diff --git a/src/frontend/misc/is-mobile-prop.tsx b/src/frontend/misc/is-mobile-prop.tsx new file mode 100644 index 0000000..ca299fc --- /dev/null +++ b/src/frontend/misc/is-mobile-prop.tsx @@ -0,0 +1,2 @@ + +export type IsMobileProp = { isMobile: boolean; }; diff --git a/src/frontend/modal/menu.ts b/src/frontend/modal/menu.ts index bce02a6..e65e87a 100644 --- a/src/frontend/modal/menu.ts +++ b/src/frontend/modal/menu.ts @@ -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; diff --git a/src/frontend/components/AccountsPage.tsx b/src/frontend/pages/account.tsx similarity index 87% rename from src/frontend/components/AccountsPage.tsx rename to src/frontend/pages/account.tsx index 8b6e371..8660f31 100644 --- a/src/frontend/components/AccountsPage.tsx +++ b/src/frontend/pages/account.tsx @@ -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 => ( )) diff --git a/src/frontend/components/AdminPage.tsx b/src/frontend/pages/admin.tsx similarity index 64% rename from src/frontend/components/AdminPage.tsx rename to src/frontend/pages/admin.tsx index a2cd132..8a3efa2 100644 --- a/src/frontend/components/AdminPage.tsx +++ b/src/frontend/pages/admin.tsx @@ -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([]); const [selectedAnnouncement, selectAnnouncement] = useState(null); const [isAnnouncementsLoaded, setAnnouncementsLoaded] = useState(false); @@ -132,64 +134,66 @@ export const AdminPage: React.VFC = () => {

You are not an administrator and cannot open this page.

) : ( <> -

Announcements

- {!isEditMode && ( - - )} - - { !isEditMode ? ( - <> - {isDeleteMode &&
Click the item to delete.
} -
- {announcements.map(a => ( - + ))} + {!isDeleteMode && ( + + )} +
+ + ) : ( +
+ +