いろいろ

This commit is contained in:
Xeltica 2021-10-11 18:12:53 +09:00
parent f0604043a8
commit d8664775cc
28 changed files with 510 additions and 63 deletions

View File

@ -0,0 +1,14 @@
import {MigrationInterface, QueryRunner} from 'typeorm';
export class Announcement1633841235323 implements MigrationInterface {
name = 'Announcement1633841235323'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('CREATE TABLE "announcement" ("id" SERIAL NOT NULL, "createdAt" TIMESTAMP NOT NULL, "title" character varying(128) NOT NULL, "body" character varying(8192) NOT NULL, "like" integer NOT NULL DEFAULT 0, CONSTRAINT "PK_e0ef0550174fd1099a308fd18a0" PRIMARY KEY ("id"))');
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP TABLE "announcement"');
}
}

View File

@ -6,5 +6,5 @@
"src/frontend/*"
],
"ext": "ts,tsx,pug,scss",
"exec": "run-s build start"
"exec": "run-s build:backend start"
}

View File

@ -1,6 +1,6 @@
const fs = require('fs');
const entities = require('./built/services/db').entities;
const entities = require('./built/backend/services/db').entities;
const config = Object.freeze(JSON.parse(fs.readFileSync(__dirname + '/config.json', 'utf-8')));
@ -17,4 +17,4 @@ module.exports = {
cli: {
migrationsDir: 'migration'
}
};
};

View File

@ -8,7 +8,8 @@
"scripts": {
"build": "run-p build:*",
"build:frontend": "webpack",
"build:backend": "tsc",
"build:backend": "run-p build:backend-source build:views build:styles",
"build:backend-source": "tsc",
"build:views": "copyfiles -u 1 src/backend/views/*.pug ./built/",
"build:styles": "sass styles/:built/assets",
"start": "node built/app.js",
@ -41,7 +42,7 @@
"class-transformer": "^0.4.0",
"class-validator": "^0.13.1",
"css-loader": "^6.2.0",
"dayjs": "^1.10.2",
"dayjs": "^1.10.7",
"delay": "^4.4.0",
"fibers": "^5.0.0",
"i18next": "^20.6.1",

View File

@ -0,0 +1,104 @@
/**
* API
* @author Xeltica
*/
import { BadRequestError, Body, CurrentUser, Delete, Get, JsonController, NotFoundError, OnUndefined, Param, Post, Put } from 'routing-controllers';
import { IUser } from '../../common/types/user';
import { Announcements } from '../models';
import { AnnounceCreate } from './body/announce-create';
import { AnnounceUpdate } from './body/announce-update';
import { IdProp } from './body/id-prop';
@JsonController('/announcements')
export class AdminController {
@Get() get() {
const query = Announcements.createQueryBuilder('announcement')
.orderBy('"announcement"."createdAt"', 'DESC');
return query.getMany();
}
@OnUndefined(204)
@Post() async post(@CurrentUser({ required: true }) user: IUser, @Body({required: true}) {title, body}: AnnounceCreate) {
if (!user.isAdmin) {
throw new BadRequestError('Not an Admin');
}
if (!title || !body) {
throw new BadRequestError();
}
await Announcements.insert({
createdAt: new Date(),
title,
body,
});
}
@OnUndefined(204)
@Put() async update(@CurrentUser({ required: true }) user: IUser, @Body() {id, title, body}: AnnounceUpdate) {
if (!user.isAdmin) {
throw new BadRequestError('Not an Admin');
}
if (!id || !title || !body) {
throw new BadRequestError();
}
if (!(await Announcements.findOne(id))) {
throw new NotFoundError();
}
await Announcements.update(id, {
title,
body,
});
}
@OnUndefined(204)
@Post('/like/:id') async like(@CurrentUser({ required: true }) user: IUser, @Param('id') id: string) {
if (!user.isAdmin) {
throw new BadRequestError('Not an Admin');
}
const idNumber = Number(id);
if (isNaN(idNumber)) {
throw new NotFoundError();
}
if (!id) {
throw new BadRequestError();
}
const announcement = await Announcements.findOne(Number(idNumber));
if (!announcement) {
throw new NotFoundError();
}
await Announcements.update(id, {
like: announcement.like + 1,
});
return announcement.like + 1;
}
@Delete() async delete(@CurrentUser({ required: true }) user: IUser, @Body() {id}: IdProp) {
if (!user.isAdmin) {
throw new BadRequestError('Not an Admin');
}
if (!id) {
throw new BadRequestError();
}
await Announcements.delete(id);
}
@Get('/:id') async getDetail(@Param('id') id: string) {
const idNumber = Number(id);
if (isNaN(idNumber)) {
throw new NotFoundError();
}
const announcement = await Announcements.findOne(idNumber);
if (!announcement) {
throw new NotFoundError();
}
return announcement;
}
}

View File

@ -0,0 +1,4 @@
export class AnnounceCreate {
title: string;
body: string;
}

View File

@ -0,0 +1,5 @@
export class AnnounceUpdate {
id: number;
title: string;
body: string;
}

View File

@ -0,0 +1,3 @@
export class IdProp {
id: number;
}

View File

@ -1,7 +1,6 @@
import { IsIn, IsOptional } from 'class-validator';
import { AlertMode, alertModes } from '../../common/types/alert-mode';
import { visibilities, Visibility } from '../../common/types/visibility';
import { AlertMode, alertModes } from '../../../common/types/alert-mode';
import { visibilities, Visibility } from '../../../common/types/visibility';
export class UserSetting {
@IsIn(alertModes)

View File

@ -9,7 +9,7 @@ import { getScores } from '../functions/get-scores';
import { deleteUser, updateUser } from '../functions/users';
import { User } from '../models/entities/user';
import { sendAlert } from '../services/send-alert';
import { UserSetting } from './UserSetting';
import { UserSetting } from './body/user-setting';
@JsonController('/session')
export class SessionController {

View File

@ -48,7 +48,7 @@ export const updateUsersToolsToken = async (user: User | User['id']): Promise<st
* @param token
* @returns
*/
export const getUserByToolsToken = (token: string): Promise<User | undefined> => {
export const getUserByToolsToken = (token: string): Promise<IUser | undefined> => {
return Users.findOne({ misshaiToken: token }).then(packUser);
};

View File

@ -0,0 +1,31 @@
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
import { IAnnouncement } from '../../../common/types/announcement';
@Entity()
export class Announcement implements IAnnouncement {
@PrimaryGeneratedColumn()
public id: number;
@Column({
type: 'timestamp without time zone',
})
public createdAt: Date;
@Column({
type: 'varchar',
length: 128,
})
public title: string;
@Column({
type: 'varchar',
length: 8192,
})
public body: string;
@Column({
type: 'integer',
default: 0,
})
public like: number;
}

View File

@ -1,6 +1,8 @@
import { User } from './entities/user';
import { UsedToken } from './entities/used-token';
import { getRepository } from 'typeorm';
import { Announcement } from './entities/announcement';
export const Users = getRepository(User);
export const UsedTokens = getRepository(UsedToken);
export const UsedTokens = getRepository(UsedToken);
export const Announcements = getRepository(Announcement);

View File

@ -2,10 +2,12 @@ import { getConnection, createConnection, Connection } from 'typeorm';
import { config } from '../../config';
import { User } from '../models/entities/user';
import { UsedToken } from '../models/entities/used-token';
import { Announcement } from '../models/entities/announcement';
export const entities = [
User,
UsedToken,
Announcement,
];
/**

View File

@ -0,0 +1,7 @@
export interface IAnnouncement {
id: number;
createdAt: Date;
title: string;
body: string;
like: number;
}

View File

@ -17,6 +17,7 @@ import { LOCALSTORAGE_KEY_LANG } from './const';
import 'xeltica-ui/dist/css/xeltica-ui.min.css';
import './style.scss';
import { AnnouncementPage } from './pages/announcement';
document.body.classList.add('dark');
@ -63,6 +64,7 @@ const AppInner : React.VFC = () => {
<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>
<footer className="text-center pa-5">
<p>(C)2020-2021 Xeltica</p>

View File

@ -0,0 +1,145 @@
import React, { useEffect, useState } from 'react';
import { LOCALSTORAGE_KEY_TOKEN } from '../const';
import { useGetSessionQuery } from '../services/session';
import { Skeleton } from './Skeleton';
import { IAnnouncement } from '../../common/types/announcement';
import { $get, $post, $put } from '../misc/api';
import { Card } from './Card';
export const AdminPage: React.VFC = () => {
const { data, error } = useGetSessionQuery(undefined);
const [announcements, setAnnouncements] = useState<IAnnouncement[]>([]);
const [selectedAnnouncement, selectAnnouncement] = useState<IAnnouncement | null>(null);
const [isAnnouncementsLoaded, setAnnouncementsLoaded] = useState(false);
const [isEditMode, setEditMode] = useState(false);
const [draftTitle, setDraftTitle] = useState('');
const [draftBody, setDraftBody] = useState('');
const submitAnnouncement = async () => {
try {
if (selectedAnnouncement) {
await $put('announcements', {
id: selectedAnnouncement.id,
title: draftTitle,
body: draftBody,
});
} else {
await $post('announcements', {
title: draftTitle,
body: draftBody,
});
}
selectAnnouncement(null);
setEditMode(false);
fetchAll();
} catch (e: unknown) {
// todo
}
};
const fetchAll = () => {
setAnnouncements([]);
setAnnouncementsLoaded(false);
$get<IAnnouncement[]>('announcements').then(announcements => {
setAnnouncements(announcements ?? []);
setAnnouncementsLoaded(true);
});
};
/**
* Session APIのエラーハンドリング
* APIがエラーを返した =
*/
useEffect(() => {
if (error) {
console.error(error);
localStorage.removeItem(LOCALSTORAGE_KEY_TOKEN);
location.reload();
}
}, [error]);
/**
*
*/
useEffect(() => {
fetchAll();
}, []);
useEffect(() => {
if (selectedAnnouncement) {
setDraftTitle(selectedAnnouncement.title);
setDraftBody(selectedAnnouncement.body);
} else {
setDraftTitle('');
setDraftBody('');
}
}, [selectedAnnouncement]);
return !data || !isAnnouncementsLoaded ? (
<div className="vstack">
<Skeleton width="100%" height="1rem" />
<Skeleton width="100%" height="1rem" />
<Skeleton width="100%" height="2rem" />
<Skeleton width="100%" height="160px" />
</div>
) : (
<div className="fade vstack">
{
!data.isAdmin ? (
<p>You are not an administrator and cannot open this page.</p>
) : (
<>
<article>
<h2>Announcements</h2>
<Card bodyClassName={isEditMode ? '' : 'px-0'}>
{ !isEditMode ? (
<div className="large menu">
{announcements.map(a => (
<button className="item fluid" key={a.id} onClick={() => {
selectAnnouncement(a);
setEditMode(true);
}}>
{a.title}
</button>
))}
<button className="item fluid" onClick={() => setEditMode(true)}>
<i className="icon bi bi-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 danger" onClick={() => {
selectAnnouncement(null);
setEditMode(false);
}}>
Cancel
</button>
</div>
</div>
)}
</Card>
</article>
</>
)
}
</div>
);
};

View File

@ -0,0 +1,36 @@
import React, { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { IAnnouncement } from '../../common/types/announcement';
import { $get } from '../misc/api';
import { Card } from './Card';
export const AnnouncementList: React.VFC = () => {
const [announcements, setAnnouncements] = useState<IAnnouncement[]>([]);
const fetchAllAnnouncements = () => {
setAnnouncements([]);
$get<IAnnouncement[]>('announcements').then(announcements => {
setAnnouncements(announcements ?? []);
});
};
useEffect(() => {
fetchAllAnnouncements();
}, []);
if (announcements.length === 0) return null;
return (
<Card>
<h1></h1>
<div className="large menu fade">
{announcements.map(a => (
<Link className="item fluid" key={a.id} to={`/announcements/${a.id}`}>
{a.title}
</Link>
))}
</div>
</Card>
);
};

View File

@ -5,9 +5,11 @@ 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 { API_ENDPOINT, LOCALSTORAGE_KEY_TOKEN } from '../const';
import { 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 { Card } from './Card';
import { Ranking } from './Ranking';
import { Skeleton } from './Skeleton';
@ -42,6 +44,7 @@ export const MisshaiPage: React.VFC = () => {
const session = useGetSessionQuery(undefined);
const data = session.data;
const score = useGetScoreQuery(undefined);
const {t} = useTranslation();
const [draft, dispatchDraft] = useReducer<DraftReducer>((state, action) => {
@ -65,14 +68,7 @@ export const MisshaiPage: React.VFC = () => {
const updateSetting = useCallback((obj: SettingDraftType) => {
const previousDraft = draft;
dispatchDraft(obj);
return fetch(`${API_ENDPOINT}session`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${localStorage[LOCALSTORAGE_KEY_TOKEN]}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(obj),
})
return $put('session', obj)
.catch(e => {
dispatch(showModal({
type: 'dialog',
@ -145,12 +141,7 @@ export const MisshaiPage: React.VFC = () => {
],
onSelect(i) {
if (i === 0) {
fetch(`${API_ENDPOINT}session/alert`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage[LOCALSTORAGE_KEY_TOKEN]}`,
},
}).then(() => {
$post('session/alert').then(() => {
dispatch(showModal({
type: 'dialog',
message: t('_sendTest.success'),
@ -203,6 +194,7 @@ export const MisshaiPage: React.VFC = () => {
</p>
</section>
)}
<AnnouncementList />
{score.data && (
<>
<section>

View File

@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { $get } from '../misc/api';
interface RankingResponse {
isCalculating: boolean;
@ -27,8 +28,7 @@ export const Ranking: React.VFC<RankingProps> = ({limit}) => {
// APIコール
useEffect(() => {
setIsFetching(true);
fetch(`//${location.host}/api/v1/ranking?limit=${limit ?? ''}`)
.then((r) => (r.json() as unknown) as RankingResponse)
$get<RankingResponse>(`ranking?limit=${limit ?? ''}`)
.then((result) => {
setResponse(result);
setIsFetching(false);

View File

@ -5,10 +5,11 @@ import { useDispatch } from 'react-redux';
import { useGetSessionQuery } from '../services/session';
import { Card } from './Card';
import { Theme, themes } from '../misc/theme';
import { API_ENDPOINT, LOCALSTORAGE_KEY_TOKEN } from '../const';
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';
export const SettingPage: React.VFC = () => {
const session = useGetSessionQuery(undefined);
@ -62,12 +63,7 @@ export const SettingPage: React.VFC = () => {
primaryClassName: 'danger',
onSelect(i) {
if (i === 0) {
fetch(`${API_ENDPOINT}session`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${localStorage[LOCALSTORAGE_KEY_TOKEN]}`,
},
}).then(() => {
$delete('session').then(() => {
dispatch(showModal({
type: 'dialog',
message: t('_deactivate.success'),

View File

@ -2,24 +2,25 @@ import React from 'react';
export type TabItem = {
label: string;
key: string;
};
export type TabProps = {
items: TabItem[];
selected: number;
onSelect: (index: number) => void;
selected: string;
onSelect: (key: string) => void;
};
// タブコンポーネント
export const Tab: React.VFC<TabProps> = (props) => {
return (
<div className="tab">
{props.items.map((item, index) => {
{props.items.map((item) => {
return (
<button
key={index}
className={'item ' + (index === props.selected ? 'active' : '')}
onClick={() => props.onSelect(index)}
key={item.key}
className={'item ' + (item.key === props.selected ? 'active' : '')}
onClick={() => props.onSelect(item.key)}
>
{item.label}
</button>

View File

@ -1,6 +1,11 @@
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 { App } from './App';
dayjs.extend(relativeTime);
ReactDOM.render(<App/>, document.getElementById('app'));

View File

@ -0,0 +1,49 @@
import { API_ENDPOINT, LOCALSTORAGE_KEY_TOKEN } from '../const';
export type ApiOptions = Record<string, any>;
const getHeaders = (token?: string) => {
const _token = token ?? localStorage.getItem(LOCALSTORAGE_KEY_TOKEN);
const headers: HeadersInit = {
'Content-Type': 'application/json',
};
if (_token) {
headers['Authorization'] = `Bearer ${_token}`;
}
return headers;
};
const getResponse = <T>(r: Response) => r.status === 204 ? null : r.json() as unknown as T;
export const $get = <T = any>(endpoint: string, token?: string): Promise<T | null> => {
return fetch(API_ENDPOINT + endpoint, {
method: 'GET',
headers: getHeaders(token),
}).then(r => getResponse<T>(r));
};
export const $put = <T = any>(endpoint: string, opts: ApiOptions = {}, token?: string): Promise<T | null> => {
return fetch(API_ENDPOINT + endpoint, {
method: 'PUT',
headers: getHeaders(token),
body: JSON.stringify(opts),
}).then(r => getResponse<T>(r));
};
export const $post = <T = any>(endpoint: string, opts: ApiOptions = {}, token?: string): Promise<T | null> => {
return fetch(API_ENDPOINT + endpoint, {
method: 'POST',
headers: getHeaders(token),
body: JSON.stringify(opts),
}).then(r => getResponse<T>(r));
};
export const $delete = <T = any>(endpoint: string, opts: ApiOptions = {}, token?: string): Promise<T | null> => {
return fetch(API_ENDPOINT + endpoint, {
method: 'DELETE',
headers: getHeaders(token),
body: JSON.stringify(opts),
}).then(r => getResponse<T>(r));
};

View File

@ -0,0 +1,45 @@
import React, { ReactNodeArray, useEffect, useState } from 'react';
import dayjs from 'dayjs';
import { useParams } from 'react-router';
import { IAnnouncement } from '../../common/types/announcement';
import { Skeleton } from '../components/Skeleton';
import { $get } from '../misc/api';
import { useSelector } from '../store';
export const AnnouncementPage: React.VFC = () => {
const { id } = useParams<{id: string}>();
if (!id) return null;
const [announcement, setAnnouncement] = useState<IAnnouncement | null>();
const lang = useSelector(state => state.screen.language);
useEffect(() => {
$get<IAnnouncement>('announcements/' + id).then(setAnnouncement);
}, [setAnnouncement]);
return (
<article className="xarticle">
{!announcement ? <Skeleton width="100%" height="10rem" /> : (
<>
<header className="mb-4">
<h2 className="mb-0">{announcement.title}</h2>
<aside className="text-dimmed">
<i className="bi bi-clock" />&nbsp;
{dayjs(announcement.createdAt).locale(lang.split('_')[0]).fromNow()}
</aside>
</header>
<section>
{(() => {
const res: ReactNodeArray = [];
announcement.body.split('\n').forEach(s => {
res.push(<>{s}</>);
res.push(<br />);
});
return res;
})()}
</section>
</>
)}
</article>
);
};

View File

@ -7,41 +7,41 @@ import { SettingPage } from '../components/SettingPage';
import { useTranslation } from 'react-i18next';
import { AccountsPage } from '../components/AccountsPage';
import { useDispatch } from 'react-redux';
import { API_ENDPOINT, LOCALSTORAGE_KEY_ACCOUNTS } from '../const';
import { LOCALSTORAGE_KEY_ACCOUNTS } from '../const';
import { IUser } from '../../common/types/user';
import { setAccounts } from '../store/slices/screen';
const getSession = (token: string) => {
return fetch(`${API_ENDPOINT}session`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
}).then(r => r.json()).then(r => r as IUser);
};
import { useGetSessionQuery } from '../services/session';
import { AdminPage } from '../components/AdminPage';
import { $get } from '../misc/api';
export const IndexSessionPage: React.VFC = () => {
const [selectedTab, setSelectedTab] = useState<number>(0);
const [selectedTab, setSelectedTab] = useState<string>('misshai');
const {t, i18n} = useTranslation();
const dispatch = useDispatch();
const { data } = useGetSessionQuery(undefined);
useEffect(() => {
const accounts = JSON.parse(localStorage.getItem(LOCALSTORAGE_KEY_ACCOUNTS) || '[]') as string[];
Promise.all(accounts.map(getSession)).then(a => dispatch(setAccounts(a)));
Promise.all(accounts.map(token => $get<IUser>('session', token))).then(a => dispatch(setAccounts(a as IUser[])));
}, [dispatch]);
const items = useMemo<TabItem[]>(() => ([
{ label: t('_nav.misshai') },
{ label: t('_nav.accounts') },
{ label: t('_nav.settings') },
]), [i18n.language]);
const items = useMemo<TabItem[]>(() => {
const it: TabItem[] = [];
it.push({ label: t('_nav.misshai'), key: 'misshai' });
it.push({ label: t('_nav.accounts'), key: 'accounts' });
if (data?.isAdmin) {
it.push({ label: 'Admin', key: 'admin' });
}
it.push({ label: t('_nav.settings'), key: 'settings' });
return it;
}, [i18n.language, data]);
const component = useMemo(() => {
switch (selectedTab) {
case 0: return <MisshaiPage />;
case 1: return <AccountsPage />;
case 2: return <SettingPage/>;
case 'misshai': return <MisshaiPage />;
case 'accounts': return <AccountsPage />;
case 'admin': return <AdminPage />;
case 'settings': return <SettingPage/>;
default: return null;
}
}, [selectedTab]);

View File

@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { LoginForm } from '../components/LoginForm';
import { Header } from '../components/Header';
import { AnnouncementList } from '../components/AnnouncementList';
export const IndexWelcomePage: React.VFC = () => {
const {t} = useTranslation();
@ -27,6 +28,9 @@ export const IndexWelcomePage: React.VFC = () => {
</article>
<LoginForm />
</Header>
<article className="xarticle">
<AnnouncementList />
</article>
<article className="xarticle vstack pa-2">
<header>
<h2>{t('_welcome.title')}</h2>

View File

@ -1608,7 +1608,7 @@ csstype@^3.0.2:
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.0.9.tgz#6410af31b26bd0520933d02cbc64fce9ce3fbf0b"
integrity sha512-rpw6JPxK6Rfg1zLOYCSwle2GFOOsnjmDYDaBwEcwoOg4qlsIVCN789VkBZDJAGi4T07gI4YSutR43t9Zz4Lzuw==
dayjs@^1.10.2:
dayjs@^1.10.7:
version "1.10.7"
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.10.7.tgz#2cf5f91add28116748440866a0a1d26f3a6ce468"
integrity sha512-P6twpd70BcPK34K26uJ1KT3wlhpuOAPoMwJzpsIWUxHZ7wpmbdZL/hQqBDfz7hGurYSa5PhzdhDHtt319hL3ig==