feat(UI): add role picker, auth helpers, refactor for viability

This commit is contained in:
41666 2020-12-18 00:14:30 -05:00
parent 3fe3cfc21f
commit e4e4bb9024
32 changed files with 408 additions and 136 deletions

View file

@ -41,7 +41,8 @@
"react-icons": "^4.1.0",
"react-is": "^17.0.1",
"react-tooltip": "^4.2.11",
"styled-components": "^5.2.1"
"styled-components": "^5.2.1",
"swr": "^0.3.9"
},
"devDependencies": {
"@babel/core": "^7.12.10",

View file

@ -0,0 +1,80 @@
import KSUID from 'ksuid';
import { CategoryType, Features, GuildData as GuildDataT } from 'roleypoly/common/types';
import { respond } from '../utils/api-tools';
import { GuildData } from '../utils/kv';
// Temporary use.
export const CreateRoleypolyData = async (request: Request): Promise<Response> => {
const data: GuildDataT = {
id: '386659935687147521',
message:
'Hey, this is kind of a demo setup so features/use cases can be shown off.\n\nThanks for using Roleypoly <3',
features: Features.Preview,
categories: [
{
id: KSUID.randomSync().string,
name: 'Demo Roles',
type: CategoryType.Multi,
hidden: false,
position: 0,
roles: [
'557825026406088717',
'557824994269200384',
'557824893241131029',
'557812915386843170',
'557812901717737472',
'557812805546541066',
],
},
{
id: KSUID.randomSync().string,
name: 'Colors',
type: CategoryType.Single,
hidden: false,
position: 1,
roles: ['394060232893923349', '394060145799331851', '394060192846839809'],
},
{
id: KSUID.randomSync().string,
name: 'Test Roles',
type: CategoryType.Multi,
hidden: false,
position: 5,
roles: ['558104828216213505', '558103534453653514', '558297233582194728'],
},
{
id: KSUID.randomSync().string,
name: 'Region',
type: CategoryType.Multi,
hidden: false,
position: 3,
roles: [
'397296181803483136',
'397296137066774529',
'397296218809827329',
'397296267283267605',
],
},
{
id: KSUID.randomSync().string,
name: 'Opt-in Channels',
type: CategoryType.Multi,
hidden: false,
position: 4,
roles: ['414514823959674890', '764230661904007219'],
},
{
id: KSUID.randomSync().string,
name: 'Pronouns',
type: CategoryType.Multi,
hidden: false,
position: 2,
roles: ['485916566790340608', '485916566941335583', '485916566311927808'],
},
],
};
await GuildData.put(data.id, data);
return respond({ ok: true });
};

View file

@ -1,4 +1,5 @@
import { BotJoin } from './handlers/bot-join';
import { CreateRoleypolyData } from './handlers/create-roleypoly-data';
import { GetPickerData } from './handlers/get-picker-data';
import { GetSession } from './handlers/get-session';
import { GetSlug } from './handlers/get-slug';
@ -29,6 +30,7 @@ router.add('GET', 'x-headers', (request) => {
return new Response(JSON.stringify(headers));
});
router.add('GET', 'x-create-roleypoly-data', CreateRoleypolyData);
addEventListener('fetch', (event: FetchEvent) => {
event.respondWith(router.handle(event.request));

View file

@ -1,3 +1,6 @@
import { addCORS } from './utils/api-tools';
import { uiPublicURI } from './utils/config';
export type Handler = (request: Request) => Promise<Response> | Response;
type RoutingTree = {
@ -20,6 +23,8 @@ export class Router {
500: this.serverError,
};
private uiURL = new URL(uiPublicURI);
addFallback(which: keyof Fallbacks, handler: Handler) {
this.fallbacks[which] = handler;
}
@ -46,13 +51,21 @@ export class Router {
if (handler) {
try {
return handler(request);
const response = await handler(request);
// this.wrapCORS(request, response);
return response;
} catch (e) {
console.error(e);
return this.fallbacks[500](request);
}
}
if (lowerMethod === 'options') {
return new Response(null, addCORS({}));
}
return this.fallbacks[404](request);
}

View file

@ -4,6 +4,7 @@ import {
permissions as Permissions,
} from '../../common/utils/hasPermission';
import { Handler } from '../router';
import { uiPublicURI } from './config';
import { Sessions, WrappedKVNamespace } from './kv';
export const formData = (obj: Record<string, any>): string => {
@ -12,8 +13,18 @@ export const formData = (obj: Record<string, any>): string => {
.join('&');
};
export const respond = (obj: Record<string, any>, init?: ResponseInit) =>
new Response(JSON.stringify(obj), init);
export const addCORS = (init: ResponseInit = {}) => ({
...init,
headers: {
...(init.headers || {}),
'access-control-allow-origin': uiPublicURI,
'access-control-allow-method': '*',
'access-control-allow-headers': '*',
},
});
export const respond = (obj: Record<string, any>, init: ResponseInit = {}) =>
new Response(JSON.stringify(obj), addCORS(init));
export const resolveFailures = (
handleWith: () => Response,

View file

@ -0,0 +1,56 @@
import { NextPageContext } from 'next';
import getConfig from 'next/config';
import nookies from 'nookies';
import useSWR from 'swr';
export const getPublicURI = (context?: NextPageContext) => {
if (context?.req) {
const { publicRuntimeConfig } = getConfig();
return publicRuntimeConfig.apiPublicURI;
} else {
return typeof localStorage !== 'undefined' && localStorage.getItem('api_uri');
}
};
export const getSessionKey = (context?: NextPageContext) => {
if (context?.req) {
return nookies.get(context)['rp_session_key'];
} else {
return (
typeof sessionStorage !== 'undefined' && sessionStorage.getItem('session_key')
);
}
};
export const apiFetch = async <T>(
path: string,
init?: RequestInit,
context?: NextPageContext
): Promise<T | null> => {
const sessionKey = getSessionKey(context);
if (!sessionKey) {
return null;
}
const authorizedInit: RequestInit = {
...(init || {}),
headers: {
...(init?.headers || {}),
authorization: `Bearer ${sessionKey}`,
},
};
const response = await fetch(`${getPublicURI(context)}${path}`, authorizedInit);
if (response.status >= 400) {
const reason = (await response.json())['error'];
throw new Error(`Fetch failed: ${reason}`);
}
return response.json() as Promise<T>;
};
export const swrFetch = <T>(path: string, context?: NextPageContext) =>
useSWR<T>(path, (url: string): Promise<any> => apiFetch<T>(url, undefined, context), {
revalidateOnFocus: false,
});

View file

@ -1,7 +1,7 @@
export const sortBy = <T>(
export const sortBy = <T, Key extends keyof T>(
array: T[],
key: keyof T,
predicate?: (a: T[keyof T], b: T[keyof T]) => number
key: Key,
predicate?: (a: T[typeof key], b: T[typeof key]) => number
) => {
return array.sort((a, b) => {
if (predicate) {

View file

@ -9,6 +9,7 @@ export default {
},
args: {
initials: 'KR',
hash: 'aa',
},
};

View file

@ -5,13 +5,14 @@ export type AvatarProps = {
src?: string;
children?: string | React.ReactNode;
size?: number;
hash?: string;
deliberatelyEmpty?: boolean;
};
/** Chuldren is recommended to not be larger than 2 uppercase letters. */
export const Avatar = (props: AvatarProps) => (
<Container size={props.size} deliberatelyEmpty={props.deliberatelyEmpty}>
{props.src && (
{props.src && props.hash && (
<Image
style={{
backgroundImage: `url(${props.src})`,

View file

@ -27,16 +27,18 @@ export const PopoverBase = styled.div<PopoverStyledProps>`
opacity: 0;
pointer-events: none;
`}
${onSmallScreen(css`
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
min-width: unset;
width: 100vw;
height: 100vh;
`)};
${onSmallScreen(
css`
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
min-width: unset;
width: 100vw;
height: 100vh;
`
)};
`;
export const DefocusHandler = styled.div<PopoverStyledProps>`
@ -84,4 +86,5 @@ export const PopoverHeadCloser = styled.div`
export const PopoverContent = styled.div`
padding: 5px;
overflow-y: hidden;
`;

View file

@ -16,13 +16,14 @@ type PopoverProps = {
canDefocus?: boolean;
onExit?: (type: 'escape' | 'defocus' | 'explicit') => void;
headContent: React.ReactNode;
preferredWidth?: number;
};
export const Popover = (props: PopoverProps) => {
globalOnKeyUp(['Escape'], () => props.onExit?.('escape'), props.active);
return (
<>
<PopoverBase active={props.active}>
<PopoverBase active={props.active} preferredWidth={props.preferredWidth}>
<PopoverHead>
<PopoverHeadCloser onClick={() => props.onExit?.('explicit')}>
<IoMdClose />

View file

@ -32,8 +32,7 @@ export const Circle = styled.div<StyledProps>`
svg {
width: 10px;
height: 10px;
fill-opacity: ${(props) =>
props.selected || props.disabled || props.type !== 'delete' ? 1 : 0};
fill-opacity: ${(props) => (props.selected || props.disabled ? 1 : 0)};
transition: fill-opacity ${transitions.in2in}s ease-in-out;
fill: ${(props) =>
props.disabled && props.defaultColor

View file

@ -1,5 +1,6 @@
import Link from 'next/link';
import * as React from 'react';
import Scrollbars from 'react-custom-scrollbars';
import { GoStar, GoZap } from 'react-icons/go';
import ReactTooltip from 'react-tooltip';
import { GuildSlug, UserGuildPermissions } from 'roleypoly/common/types';
@ -29,14 +30,23 @@ const Badges = (props: { guild: GuildSlug }) => {
export const GuildNav = (props: Props) => (
<div>
{sortBy(props.guilds, 'id').map((guild) => (
<Link href={`/s/${guild.id}`} passHref>
<GuildNavItem>
<NavSlug guild={guild || null} key={guild.id} />
<Badges guild={guild} />
</GuildNavItem>
</Link>
))}
<ReactTooltip id={tooltipId} />
<Scrollbars
universal
autoHide
// autoHeight
style={{ height: 'calc(100vh - 45px - 1.4em)', overflowX: 'hidden' }}
>
{sortBy(props.guilds, 'name', (a: string, b: string) =>
a.toLowerCase() > b.toLowerCase() ? 1 : -1
).map((guild) => (
<Link href={`/s/${guild.id}`} passHref key={guild.id}>
<GuildNavItem>
<NavSlug guild={guild || null} key={guild.id} />
<Badges guild={guild} />
</GuildNavItem>
</Link>
))}
<ReactTooltip id={tooltipId} />
</Scrollbars>
</div>
);

View file

@ -11,9 +11,11 @@ type Props = {
export const NavSlug = (props: Props) => (
<SlugContainer>
<Avatar
hash={props.guild ? props.guild.icon : undefined}
src={
(props.guild && utils.avatarHash(props.guild.id, props.guild.icon)) ||
undefined
props.guild
? utils.avatarHash(props.guild.id, props.guild.icon)
: undefined
}
deliberatelyEmpty={!props.guild}
size={35}

View file

@ -27,6 +27,7 @@ export const PreauthGreeting = (props: GreetingProps) => (
'icons',
512
)}
hash={props.guildSlug.icon}
>
{avatarUtils.initialsFromName(props.guildSlug.name)}
</Avatar>

View file

@ -1,14 +1,13 @@
import Link from 'next/link';
import * as React from 'react';
import { GoPencil } from 'react-icons/go';
import { Guild } from 'roleypoly/common/types';
import { guild } from 'roleypoly/common/types/storyData';
import { GuildSlug } from 'roleypoly/common/types';
import { Avatar, utils } from 'roleypoly/design-system/atoms/avatar';
import { AccentTitle, AmbientLarge } from 'roleypoly/design-system/atoms/typography';
import { Editable, Icon, Name, Wrapper } from './ServerMasthead.styled';
export type ServerMastheadProps = {
guild: Guild;
guild: GuildSlug;
editable: boolean;
};
@ -17,8 +16,9 @@ export const ServerMasthead = (props: ServerMastheadProps) => {
<Wrapper>
<Icon>
<Avatar
hash={props.guild.icon}
size={props.editable ? 60 : 48}
src={utils.avatarHash(guild.id, guild.icon, 'icons', 512)}
src={utils.avatarHash(props.guild.id, props.guild.icon, 'icons', 512)}
>
{utils.initialsFromName(props.guild.name)}
</Avatar>

View file

@ -19,6 +19,7 @@ export const UserAvatarGroup = (props: Props) => (
</Collapse>
<Avatar
size={34}
hash={props.user.avatar}
src={utils.avatarHash(props.user.id, props.user.avatar, 'avatars')}
>
{utils.initialsFromName(props.user.username)}

View file

@ -62,6 +62,7 @@ export const Authed = (props: Props) => {
canDefocus
position="bottom left"
active={serverPopoverState}
preferredWidth={560}
onExit={() => setServerPopoverState(false)}
>
{() => <GuildNav guilds={props.guilds} />}

View file

@ -1,16 +1,19 @@
import NextLink from 'next/link';
import * as React from 'react';
import { GoInfo } from 'react-icons/go';
import {
Category,
CategoryType,
Guild,
GuildData,
GuildSlug,
Member,
Role,
} from 'roleypoly/common/types';
import { ReactifyNewlines } from 'roleypoly/common/utils/ReactifyNewlines';
import { sortBy } from 'roleypoly/common/utils/sortBy';
import { FaderOpacity } from 'roleypoly/design-system/atoms/fader';
import { Space } from 'roleypoly/design-system/atoms/space';
import { Link } from 'roleypoly/design-system/atoms/typography';
import { PickerCategory } from 'roleypoly/design-system/molecules/picker-category';
import { ResetSubmit } from 'roleypoly/design-system/molecules/reset-submit';
import { ServerMasthead } from 'roleypoly/design-system/molecules/server-masthead';
@ -23,7 +26,7 @@ import {
} from './RolePicker.styled';
export type RolePickerProps = {
guild: Guild;
guild: GuildSlug;
guildData: GuildData;
member: Member;
roles: Role[];
@ -81,32 +84,34 @@ export const RolePicker = (props: RolePickerProps) => {
{props.guildData.categories.length !== 0 ? (
<>
<div>
{props.guildData.categories.map((category, idx) => (
<CategoryContainer key={idx}>
<PickerCategory
key={idx}
category={category}
title={category.name}
selectedRoles={selectedRoles.filter((roleId) =>
category.roles.includes(roleId)
)}
roles={
category.roles
.map((role) =>
props.roles.find((r) => r.id === role)
)
.filter((r) => r !== undefined) as Role[]
}
onChange={handleChange(category)}
wikiMode={false}
type={
category.type === CategoryType.Single
? 'single'
: 'multi'
}
/>
</CategoryContainer>
))}
{sortBy(props.guildData.categories, 'position').map(
(category, idx) => (
<CategoryContainer key={idx}>
<PickerCategory
key={idx}
category={category}
title={category.name}
selectedRoles={selectedRoles.filter((roleId) =>
category.roles.includes(roleId)
)}
roles={
category.roles
.map((role) =>
props.roles.find((r) => r.id === role)
)
.filter((r) => r !== undefined) as Role[]
}
onChange={handleChange(category)}
wikiMode={false}
type={
category.type === CategoryType.Single
? 'single'
: 'multi'
}
/>
</CategoryContainer>
)
)}
</div>
<FaderOpacity
isVisible={!arrayMatches(selectedRoles, props.member.roles)}
@ -126,6 +131,18 @@ export const RolePicker = (props: RolePickerProps) => {
</InfoIcon>
<div>
There are currently no roles available for you to choose from.
{props.editable && (
<>
{' '}
<NextLink
passHref
href={`/s/[id]/edit`}
as={`/s/${props.guild.id}/edit`}
>
<Link>Add some roles!</Link>
</NextLink>
</>
)}
</div>
</InfoBox>
)}

View file

@ -1,9 +1,10 @@
import * as React from 'react';
import { AppShell } from 'roleypoly/design-system/organisms/app-shell';
import { Landing } from 'roleypoly/design-system/organisms/landing';
import { ProvidableAppShellProps } from 'roleypoly/providers/appShellData';
export const LandingTemplate = () => (
<AppShell showFooter>
export const LandingTemplate = (props: ProvidableAppShellProps) => (
<AppShell showFooter {...props}>
<Landing />
</AppShell>
);

View file

@ -5,7 +5,7 @@ import {
RolePickerProps,
} from 'roleypoly/design-system/organisms/role-picker';
export type RolePickerTemplateProps = RolePickerProps & AppShellProps;
export type RolePickerTemplateProps = RolePickerProps & Omit<AppShellProps, 'children'>;
export const RolePickerTemplate = (props: RolePickerTemplateProps) => {
const { user, guilds, activeGuildId, ...pickerProps } = props;

View file

@ -1,8 +1,6 @@
import NextApp, { AppContext, AppProps } from 'next/app';
import nookies from 'nookies';
import { AppProps } from 'next/app';
import * as React from 'react';
import { InjectTypekitFont } from 'roleypoly/design-system/atoms/fonts';
import { AuthProvider } from 'roleypoly/providers/auth/AuthContext';
type Props = AppProps & {
sessionKey: string | null;
@ -11,26 +9,7 @@ type Props = AppProps & {
const App = (props: Props) => (
<>
<InjectTypekitFont />
<AuthProvider sessionKey={props.sessionKey}>
<props.Component {...props.pageProps} />
</AuthProvider>
<props.Component {...props.pageProps} />
</>
);
export default App;
export const getInitialProps = async (context: AppContext) => {
let sessionKey: string | null = null;
if (context.ctx.req) {
const key = nookies.get(context.ctx)['rp_session_key'];
if (key) {
sessionKey = key;
}
} else {
sessionKey = sessionStorage.getItem('session_key');
}
const pageProps = await NextApp.getInitialProps(context);
return { ...pageProps, sessionKey };
};

View file

@ -1,5 +1,20 @@
import { useRouter } from 'next/router';
import * as React from 'react';
import { LandingTemplate } from 'roleypoly/design-system/templates/landing';
import { useAppShellProps } from 'roleypoly/providers/appShellData';
const Index = () => <LandingTemplate />;
const Index = () => {
const {
appShellProps: { guilds, user },
} = useAppShellProps();
const router = useRouter();
React.useEffect(() => {
if (user || guilds) {
router.replace('/servers');
}
}, [guilds, user]);
return <LandingTemplate user={user} guilds={guilds} />;
};
export default Index;

View file

@ -14,6 +14,7 @@ type Props = {
const Logout = (props: Props) => {
React.useEffect(() => {
sessionStorage.removeItem('session_key');
sessionStorage.removeItem('session_data');
location.href = '/';
}, []);

View file

@ -1,4 +1,5 @@
import { NextPageContext } from 'next';
import getConfig from 'next/config';
import nookies from 'nookies';
import * as React from 'react';
import { Hero } from 'roleypoly/design-system/atoms/hero';
@ -7,15 +8,18 @@ import { AppShell } from 'roleypoly/design-system/organisms/app-shell';
type Props = {
sessionID: string;
apiURI: string;
};
const NewSession = (props: Props) => {
const { sessionID } = props;
const { sessionID, apiURI } = props;
React.useEffect(() => {
sessionStorage.setItem('session_key', sessionID);
localStorage.setItem('api_uri', apiURI); // TODO: set better
location.href = '/';
}, [sessionID]);
}, [sessionID, apiURI]);
return (
<AppShell>
@ -26,7 +30,11 @@ const NewSession = (props: Props) => {
);
};
export const getServerSideProps = (context: NextPageContext): { props: Props } => {
export const getServerSideProps = async (
context: NextPageContext
): Promise<{ props: Props }> => {
const { publicRuntimeConfig } = getConfig();
const apiURI = publicRuntimeConfig.apiPublicURI;
const sessionID = context.query.session_id as string;
if (!sessionID) {
throw new Error("I shouldn't be here today.");
@ -39,7 +47,7 @@ export const getServerSideProps = (context: NextPageContext): { props: Props } =
sameSite: 'strict',
});
return { props: { sessionID } };
return { props: { sessionID, apiURI } };
};
export default NewSession;

59
src/pages/s/[id].tsx Normal file
View file

@ -0,0 +1,59 @@
import { NextPage, NextPageContext } from 'next';
import Head from 'next/head';
import * as React from 'react';
import { PresentableGuild, UserGuildPermissions } from 'roleypoly/common/types';
import { apiFetch } from 'roleypoly/common/utils/isomorphicFetch';
import { RolePickerTemplate } from 'roleypoly/design-system/templates/role-picker';
import { useAppShellProps } from 'roleypoly/providers/appShellData';
type Props = {
data: PresentableGuild;
};
const RolePickerPage: NextPage<Props> = (props) => {
const { appShellProps } = useAppShellProps();
return (
<>
<Head>
<title>Picking roles on {props.data.guild.name} - Roleypoly</title>
</Head>
<RolePickerTemplate
user={appShellProps.user}
guilds={appShellProps.guilds}
guild={props.data.guild}
roles={props.data.roles}
guildData={props.data.data}
member={props.data.member}
editable={props.data.guild.permissionLevel !== UserGuildPermissions.User}
activeGuildId={props.data.id}
onSubmit={(i) => {
console.log(i);
}}
/>
</>
);
};
RolePickerPage.getInitialProps = async (context: NextPageContext): Promise<Props> => {
const serverID = context.query.id;
if (!serverID) {
throw new Error('serverID missing');
}
const pickerData = await apiFetch<PresentableGuild>(
`/get-picker-data/${serverID}`,
undefined,
context
);
if (!pickerData) {
throw new Error('TODO: picker fetch failed');
}
return {
data: pickerData,
};
};
export default RolePickerPage;

10
src/pages/s/[id]/edit.tsx Normal file
View file

@ -0,0 +1,10 @@
import Link from 'next/link';
import * as React from 'react';
const ServerEditor = () => (
<div>
<Link href="/">Go back</Link>
</div>
);
export default ServerEditor;

1
src/pages/servers.tsx Normal file
View file

@ -0,0 +1 @@
export default () => <div></div>;

View file

@ -0,0 +1,27 @@
import { NextPageContext } from 'next';
import { SessionData } from 'roleypoly/common/types';
import { swrFetch } from 'roleypoly/common/utils/isomorphicFetch';
import { AppShellProps } from 'roleypoly/design-system/organisms/app-shell';
export type ProvidableAppShellProps = {
user: AppShellProps['user'];
guilds: AppShellProps['guilds'];
};
export const useAppShellProps = (context?: NextPageContext) => {
const { data, error } = swrFetch<Omit<SessionData, 'tokens'>>(
'/get-session',
context
);
const props: ProvidableAppShellProps = {
user: data?.user,
guilds: data?.guilds,
};
return {
appShellProps: props,
isLoading: !error && !data,
isError: error,
};
};

View file

@ -1,40 +0,0 @@
import * as React from 'react';
type AuthContextType = {
sessionKey: string | null;
setSessionKey: (value: string | null) => void;
};
type Props = {
sessionKey: string | null;
children: React.ReactNode;
};
const AuthContext = React.createContext<AuthContextType>({
sessionKey: null,
setSessionKey: () => {},
});
export const AuthProvider = (props: Props) => {
const [sessionKey, setSessionKey] = React.useState(props.sessionKey);
return (
<AuthContext.Provider value={{ sessionKey, setSessionKey }}>
{props.children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const authCtx = React.useContext(AuthContext);
if (!authCtx) {
throw new Error('useAuth used without AuthProvider');
}
return authCtx;
};
export const isAuthenticated = () => {
const authCtx = useAuth();
return authCtx.sessionKey !== null;
};

View file

@ -1 +0,0 @@
export * from './AuthContext';

View file

@ -5603,6 +5603,11 @@ depd@~1.1.2:
resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9"
integrity sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=
dequal@2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.2.tgz#85ca22025e3a87e65ef75a7a437b35284a7e319d"
integrity sha512-q9K8BlJVxK7hQYqa6XISGmBZbtQQWVXSrRrWreHC94rMt1QL/Impruc+7p2CYSYuVIUr+YCt6hjrs1kkdJRTug==
des.js@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/des.js/-/des.js-1.0.1.tgz#5382142e1bdc53f85d86d53e5f4aa7deb91e0843"
@ -13282,6 +13287,13 @@ svg-tags@^1.0.0:
resolved "https://registry.yarnpkg.com/svg-tags/-/svg-tags-1.0.0.tgz#58f71cee3bd519b59d4b2a843b6c7de64ac04764"
integrity sha1-WPcc7jvVGbWdSyqEO2x95krAR2Q=
swr@^0.3.9:
version "0.3.9"
resolved "https://registry.yarnpkg.com/swr/-/swr-0.3.9.tgz#a179a795244c7b68684af6a632f1ad579e6a69e0"
integrity sha512-lyN4SjBzpoW4+v3ebT7JUtpzf9XyzrFwXIFv+E8ZblvMa5enSNaUBs4EPkL8gGA/GDMLngEmB53o5LaNboAPfg==
dependencies:
dequal "2.0.2"
symbol-tree@^3.2.2, symbol-tree@^3.2.4:
version "3.2.4"
resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2"