0
0
Fork 0
This commit is contained in:
Xeltica 2022-01-27 19:55:33 +09:00
parent 11f185bf72
commit 7c833c3568
8 changed files with 161 additions and 9 deletions

View file

@ -66,6 +66,7 @@
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-i18next": "^11.12.0",
"react-image-crop": "^9.0.5",
"react-modal-hook": "^3.0.0",
"react-redux": "^7.2.4",
"react-router-dom": "^5.2.1",

View file

@ -31,7 +31,6 @@ export const AccountsPage: React.VFC = () => {
</div>
) : (
<article className="fade">
<h1 className="text-125 text-bold">{data.username}<span className="text-dimmed">@{data.host}</span></h1>
<div>
<strong>{t('_accounts.switchAccount')}</strong>
</div>

View file

@ -0,0 +1,10 @@
import React from 'react';
import { useGetSessionQuery } from '../services/session';
import { Skeleton } from './Skeleton';
export const CurrentUser: React.VFC = () => {
const {data} = useGetSessionQuery(undefined);
return data ? (
<h1 className="text-125">{data.username}<span className="text-dimmed">@{data.host}</span></h1>
) : <Skeleton height="1.5rem" />;
};

View file

@ -1,18 +1,20 @@
import React from 'react';
import React, { HTMLProps } from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
export type HeaderProps = {
hasTopLink?: boolean;
className?: HTMLProps<HTMLElement>['className'],
style?: HTMLProps<HTMLElement>['style'],
};
const messageNumber = Math.floor(Math.random() * 6) + 1;
export const Header: React.FC<HeaderProps> = ({hasTopLink, children}) => {
export const Header: React.FC<HeaderProps> = ({hasTopLink, children, className, style}) => {
const { t } = useTranslation();
return (
<header className={'xarticle card mt-5 mb-3'}>
<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')}

View file

@ -0,0 +1,92 @@
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';
export const NekomimiPage: React.VFC = () => {
const {t} = useTranslation();
const [blobUrl, setBlobUrl] = useState<string | null>(null);
const [image, setImage] = useState<HTMLImageElement | null>(null);
const [crop, setCrop] = useState<Partial<Crop>>({unit: '%', width: 100, aspect: 1 / 1});
const [completedCrop, setCompletedCrop] = useState<Crop>();
const previewCanvasRef = useRef<HTMLCanvasElement>(null);
const onChangeFile: ChangeEventHandler<HTMLInputElement> = (e) => {
if (e.target.files === null || e.target.files.length === 0) return;
const reader = new FileReader();
reader.addEventListener('load', () => setBlobUrl(reader.result as string));
reader.readAsDataURL(e.target.files[0]);
setCrop({unit: '%', width: 100, aspect: 1 / 1});
};
useEffect(() => {
if (!completedCrop || !previewCanvasRef.current || !image) {
return;
}
const canvas = previewCanvasRef.current;
const crop = completedCrop;
const scaleX = image.naturalWidth / image.width;
const scaleY = image.naturalHeight / image.height;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const pixelRatio = window.devicePixelRatio;
canvas.width = crop.width * pixelRatio * scaleX;
canvas.height = crop.height * pixelRatio * scaleY;
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(
image,
crop.x * scaleX,
crop.y * scaleY,
crop.width * scaleX,
crop.height * scaleY,
0,
0,
crop.width * scaleX,
crop.height * scaleY
);
}, [completedCrop]);
return (
<div className="fade">
<h2></h2>
<input type="file" className="input-field" accept="image/*" onChange={onChangeFile} />
{blobUrl && (
<div className="row mt-2">
<div className="col-8 col-12-sm">
<ReactCrop src={blobUrl} crop={crop}
onImageLoaded={(i) => setImage(i)}
onChange={(c) => setCrop(c)}
onComplete={(c) => setCompletedCrop(c)}
/>
</div>
<div className="col-4 col-12-sm">
<h3 className="text-100 text-bold"></h3>
<div className="cat mt-4 mb-2" style={{position: 'relative', width: 96, height: 96}}>
<canvas
ref={previewCanvasRef}
className="circle"
style={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
zIndex: 100,
}}
/>
</div>
<button className="btn primary"></button>
</div>
</div>
)}
</div>
);
};

View file

@ -13,6 +13,9 @@ import { setAccounts } from '../store/slices/screen';
import { useGetSessionQuery } from '../services/session';
import { AdminPage } from '../components/AdminPage';
import { $get } from '../misc/api';
import { NekomimiPage } from '../components/NekomimiPage';
import { CurrentUser } from '../components/CurrentUser';
import { Card } from '../components/Card';
export const IndexSessionPage: React.VFC = () => {
const [selectedTab, setSelectedTab] = useState<string>('misshai');
@ -29,6 +32,7 @@ export const IndexSessionPage: React.VFC = () => {
const it: TabItem[] = [];
it.push({ label: t('_nav.misshai'), key: 'misshai' });
it.push({ label: t('_nav.accounts'), key: 'accounts' });
it.push({ label: 'ネコミミ', key: 'nekomimi' });
if (data?.isAdmin) {
it.push({ label: 'Admin', key: 'admin' });
}
@ -41,6 +45,7 @@ export const IndexSessionPage: React.VFC = () => {
case 'misshai': return <MisshaiPage />;
case 'accounts': return <AccountsPage />;
case 'admin': return <AdminPage />;
case 'nekomimi': return <NekomimiPage />;
case 'settings': return <SettingPage/>;
default: return null;
}
@ -48,11 +53,13 @@ export const IndexSessionPage: React.VFC = () => {
return (
<>
<div className="xarticle vgroup shadow-4" style={{position: 'sticky', top: 0, zIndex: 100}}>
<Header />
<div className="xarticle card" style={{borderRadius: 'var(--radius)'}}>
<div className="card">
<Tab items={items} selected={selectedTab} onSelect={setSelectedTab}/>
</div>
<article className="xarticle mt-4">
</div>
<article className="xarticle mt-2">
{component}
</article>
</>

View file

@ -6,7 +6,7 @@ body {
.xarticle {
margin: auto;
max-width: 720px;
max-width: 1024px;
}
.fade {
@ -96,3 +96,32 @@ small {
.login-form {
}
.cat {
&:before, &:after {
background: #df548f;
border: solid 4px currentColor;
box-sizing: border-box;
content: '';
display: inline-block;
height: 50%;
width: 50%;
}
&:before {
border-radius: 0 75% 75%;
transform: rotate(37.5deg) skew(30deg);
}
&:after {
border-radius: 75% 0 75% 75%;
transform: rotate(-37.5deg) skew(-30deg);
}
&.animated:hover {
&:before {
animation: earwiggleleft 1s infinite;
}
&:after {
animation: earwiggleright 1s infinite;
}
}
}

View file

@ -1346,6 +1346,11 @@ clone-response@^1.0.2:
dependencies:
mimic-response "^1.0.0"
clsx@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.1.1.tgz#98b3134f9abbdf23b2663491ace13c5c03a73188"
integrity sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA==
co-body@^6.0.0:
version "6.1.0"
resolved "https://registry.yarnpkg.com/co-body/-/co-body-6.1.0.tgz#d87a8efc3564f9bfe3aced8ef5cd04c7a8766547"
@ -4335,6 +4340,13 @@ react-i18next@^11.12.0:
"@babel/runtime" "^7.14.5"
html-parse-stringify "^3.0.1"
react-image-crop@^9.0.5:
version "9.0.5"
resolved "https://registry.yarnpkg.com/react-image-crop/-/react-image-crop-9.0.5.tgz#a6dfe5411156f1dd1e435b128424ccf175a86948"
integrity sha512-J5lbsBMI36GsbkRHXNEo95OjwpxfkUBrEl9Oxb7z5mDC7iamySXYKhkv9QoidkfmI9e8Vj7q3SgNCVfuZHQMQw==
dependencies:
clsx "^1.1.1"
react-is@^16.13.1, react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.1:
version "16.13.1"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"