diff --git a/package.json b/package.json index 8cfd49f..d304834 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/frontend/components/AccountsPage.tsx b/src/frontend/components/AccountsPage.tsx index 0314833..b5fac6e 100644 --- a/src/frontend/components/AccountsPage.tsx +++ b/src/frontend/components/AccountsPage.tsx @@ -31,7 +31,6 @@ export const AccountsPage: React.VFC = () => { ) : (
-

{data.username}@{data.host}

{t('_accounts.switchAccount')}
diff --git a/src/frontend/components/CurrentUser.tsx b/src/frontend/components/CurrentUser.tsx new file mode 100644 index 0000000..dfb87e1 --- /dev/null +++ b/src/frontend/components/CurrentUser.tsx @@ -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 ? ( +

{data.username}@{data.host}

+ ) : ; +}; diff --git a/src/frontend/components/Header.tsx b/src/frontend/components/Header.tsx index 8c7fcd7..7e9ded6 100644 --- a/src/frontend/components/Header.tsx +++ b/src/frontend/components/Header.tsx @@ -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['className'], + style?: HTMLProps['style'], }; const messageNumber = Math.floor(Math.random() * 6) + 1; -export const Header: React.FC = ({hasTopLink, children}) => { +export const Header: React.FC = ({hasTopLink, children, className, style}) => { const { t } = useTranslation(); return ( -
+

{hasTopLink ? {t('title')} : t('title')} diff --git a/src/frontend/components/NekomimiPage.tsx b/src/frontend/components/NekomimiPage.tsx new file mode 100644 index 0000000..fd27116 --- /dev/null +++ b/src/frontend/components/NekomimiPage.tsx @@ -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(null); + const [image, setImage] = useState(null); + const [crop, setCrop] = useState>({unit: '%', width: 100, aspect: 1 / 1}); + const [completedCrop, setCompletedCrop] = useState(); + + const previewCanvasRef = useRef(null); + + const onChangeFile: ChangeEventHandler = (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 ( +
+

ネコミミアジャスター

+ + {blobUrl && ( +
+
+ setImage(i)} + onChange={(c) => setCrop(c)} + onComplete={(c) => setCompletedCrop(c)} + /> +
+
+

プレビュー

+
+ +
+ +
+
+ )} +
+ ); +}; + diff --git a/src/frontend/pages/index.session.tsx b/src/frontend/pages/index.session.tsx index e2f99e2..b805595 100644 --- a/src/frontend/pages/index.session.tsx +++ b/src/frontend/pages/index.session.tsx @@ -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('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 ; case 'accounts': return ; case 'admin': return ; + case 'nekomimi': return ; case 'settings': return ; default: return null; } @@ -48,11 +53,13 @@ export const IndexSessionPage: React.VFC = () => { return ( <> -
-
- +
+
+
+ +
-
+
{component}
diff --git a/src/frontend/style.scss b/src/frontend/style.scss index 46feafc..9725105 100644 --- a/src/frontend/style.scss +++ b/src/frontend/style.scss @@ -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; + } + } +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index e4108ee..4d5d936 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"