mirror of
https://github.com/roleypoly/roleypoly.git
synced 2025-06-15 17:19:10 +00:00
Refactor node packages to yarn workspaces & ditch next.js for CRA. (#161)
* chore: restructure project into yarn workspaces, remove next * fix tests, remove webapp from terraform * remove more ui deployment bits * remove pages, fix FUNDING.yml * remove isomorphism * remove next providers * fix linting issues * feat: start basis of new web ui system on CRA * chore: move types to @roleypoly/types package * chore: move src/common/utils to @roleypoly/misc-utils * chore: remove roleypoly/ path remappers * chore: renmove vercel config * chore: re-add worker-types to api package * chore: fix type linting scope for api * fix(web): craco should include all of packages dir * fix(ci): change api webpack path for wrangler * chore: remove GAR actions from CI * chore: update codeql job * chore: test better github dar matcher in lint-staged
This commit is contained in:
parent
49e308507e
commit
2ff6588030
328 changed files with 16624 additions and 3525 deletions
|
@ -0,0 +1,20 @@
|
|||
import * as React from 'react';
|
||||
import { mastheadSlugs, user } from '../../fixtures/storyData';
|
||||
import { AppShell } from './AppShell';
|
||||
|
||||
export default {
|
||||
title: 'Organisms/App Shell',
|
||||
component: AppShell,
|
||||
};
|
||||
|
||||
export const Guest = () => (
|
||||
<AppShell showFooter user={null}>
|
||||
<h1>Hello World</h1>
|
||||
</AppShell>
|
||||
);
|
||||
|
||||
export const LoggedIn = () => (
|
||||
<AppShell user={user} guilds={mastheadSlugs}>
|
||||
<h1>Hello World</h1>
|
||||
</AppShell>
|
||||
);
|
|
@ -0,0 +1,24 @@
|
|||
import { palette } from '@roleypoly/design-system/atoms/colors';
|
||||
import { fontCSS } from '@roleypoly/design-system/atoms/fonts';
|
||||
import styled, { createGlobalStyle } from 'styled-components';
|
||||
|
||||
export const Content = styled.div<{ small?: boolean }>`
|
||||
margin: 0 auto;
|
||||
margin-top: 50px;
|
||||
width: ${(props) => (props.small ? '960px' : '1024px')};
|
||||
max-width: 98vw;
|
||||
max-height: calc(100vh - 50px);
|
||||
`;
|
||||
|
||||
export const GlobalStyles = createGlobalStyle`
|
||||
body {
|
||||
background-color: ${palette.taupe200};
|
||||
color: ${palette.grey600};
|
||||
overflow-y: hidden;
|
||||
scroll-behavior: smooth;
|
||||
${fontCSS}
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
`;
|
42
packages/design-system/organisms/app-shell/AppShell.tsx
Normal file
42
packages/design-system/organisms/app-shell/AppShell.tsx
Normal file
|
@ -0,0 +1,42 @@
|
|||
import { GlobalStyleColors } from '@roleypoly/design-system/atoms/colors';
|
||||
import { Footer } from '@roleypoly/design-system/molecules/footer';
|
||||
import * as Masthead from '@roleypoly/design-system/organisms/masthead';
|
||||
import { DiscordUser, GuildSlug } from '@roleypoly/types';
|
||||
import * as React from 'react';
|
||||
import { Scrollbars } from 'react-custom-scrollbars';
|
||||
import { Content, GlobalStyles } from './AppShell.styled';
|
||||
|
||||
export type AppShellProps = {
|
||||
children: React.ReactNode;
|
||||
user?: DiscordUser;
|
||||
showFooter?: boolean;
|
||||
small?: boolean;
|
||||
activeGuildId?: string | null;
|
||||
guilds?: GuildSlug[];
|
||||
disableGuildPicker?: boolean;
|
||||
};
|
||||
|
||||
export const AppShell = (props: AppShellProps) => (
|
||||
<>
|
||||
<GlobalStyles />
|
||||
<GlobalStyleColors />
|
||||
{props.user ? (
|
||||
<Masthead.Authed
|
||||
disableGuildPicker={props.disableGuildPicker}
|
||||
guilds={props.guilds || []}
|
||||
activeGuildId={props.activeGuildId || null}
|
||||
user={props.user}
|
||||
/>
|
||||
) : (
|
||||
<Masthead.Guest />
|
||||
)}
|
||||
<Scrollbars
|
||||
style={{ height: 'calc(100vh - 25px)', margin: 0, padding: 0 }}
|
||||
autoHide
|
||||
universal
|
||||
>
|
||||
<Content small={props.small}>{props.children}</Content>
|
||||
{props.showFooter && <Footer />}
|
||||
</Scrollbars>
|
||||
</>
|
||||
);
|
1
packages/design-system/organisms/app-shell/index.ts
Normal file
1
packages/design-system/organisms/app-shell/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from './AppShell';
|
|
@ -0,0 +1,10 @@
|
|||
import * as React from 'react';
|
||||
import { guildEnum } from '../../fixtures/storyData';
|
||||
import { EditorShell } from './EditorShell';
|
||||
|
||||
export default {
|
||||
title: 'Organisms/Editor',
|
||||
component: EditorShell,
|
||||
};
|
||||
|
||||
export const Shell = () => <EditorShell guild={guildEnum.guildsList[0]} />;
|
|
@ -0,0 +1,8 @@
|
|||
import { palette } from '@roleypoly/design-system/atoms/colors';
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const CategoryContainer = styled.div`
|
||||
background-color: ${palette.taupe100};
|
||||
padding: 10px;
|
||||
margin: 15px 0;
|
||||
`;
|
31
packages/design-system/organisms/editor/EditorShell.tsx
Normal file
31
packages/design-system/organisms/editor/EditorShell.tsx
Normal file
|
@ -0,0 +1,31 @@
|
|||
import { Tab, TabView } from '@roleypoly/design-system/atoms/tab-view';
|
||||
import { PresentableGuild } from '@roleypoly/types';
|
||||
import * as React from 'react';
|
||||
import { EditorCategory } from '../../molecules/editor-category';
|
||||
import { CategoryContainer } from './EditorShell.styled';
|
||||
|
||||
type Props = {
|
||||
guild: PresentableGuild;
|
||||
};
|
||||
|
||||
export const EditorShell = (props: Props) => (
|
||||
<TabView initialTab={0}>
|
||||
<Tab title="Roles">{() => <RolesTab {...props} />}</Tab>
|
||||
<Tab title="Server Details">{() => <div>hi2!</div>}</Tab>
|
||||
</TabView>
|
||||
);
|
||||
|
||||
const RolesTab = (props: Props) => (
|
||||
<div>
|
||||
{props.guild.data.categories.map((category, idx) => (
|
||||
<CategoryContainer key={idx}>
|
||||
<EditorCategory
|
||||
category={category}
|
||||
uncategorizedRoles={[]}
|
||||
guildRoles={props.guild.roles}
|
||||
onChange={(x) => console.log(x)}
|
||||
/>
|
||||
</CategoryContainer>
|
||||
))}
|
||||
</div>
|
||||
);
|
1
packages/design-system/organisms/editor/index.ts
Normal file
1
packages/design-system/organisms/editor/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from './EditorShell';
|
|
@ -0,0 +1,10 @@
|
|||
import * as React from 'react';
|
||||
import { HelpStoryWrapper } from '../../molecules/help-page-base/storyDecorator';
|
||||
import { WhyNoRoles } from './WhyNoRoles';
|
||||
|
||||
export default {
|
||||
title: 'Organisms/Help Pages',
|
||||
decorators: [HelpStoryWrapper],
|
||||
};
|
||||
|
||||
export const WhyNoRoles_ = () => <WhyNoRoles />;
|
|
@ -0,0 +1,39 @@
|
|||
import { numberToChroma, palette } from '@roleypoly/design-system/atoms/colors';
|
||||
import { Role } from '@roleypoly/types';
|
||||
import styled, { css } from 'styled-components';
|
||||
|
||||
export const DiscordBase = styled.div`
|
||||
background-color: ${palette.discord100};
|
||||
border: solid 1px rgba(0, 0, 0, 0.15);
|
||||
border-radius: 3px;
|
||||
padding: 10px;
|
||||
user-select: none;
|
||||
width: 250px;
|
||||
`;
|
||||
|
||||
const hover = (roleColor: string) => css`
|
||||
color: #efefef;
|
||||
background-color: ${roleColor};
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
export const DiscordRole = styled.div<{
|
||||
discordRole: Role;
|
||||
isRoleypoly: boolean;
|
||||
}>`
|
||||
/* stylelint-disable function-name-case, function-whitespace-after */
|
||||
|
||||
/* Disabled due to postcss bug parsing the below functions as CSS and not a JS interpolation */
|
||||
|
||||
padding: 6px 10px;
|
||||
color: ${(props) => numberToChroma(props.discordRole.color).css()};
|
||||
border-radius: 3px;
|
||||
|
||||
:hover {
|
||||
${(props) => hover(numberToChroma(props.discordRole.color).alpha(0.5).css())}
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.isRoleypoly &&
|
||||
hover(numberToChroma(props.discordRole.color).alpha(0.5).css())}
|
||||
`;
|
|
@ -0,0 +1,83 @@
|
|||
import { palette } from '@roleypoly/design-system/atoms/colors';
|
||||
import { HalfsiesContainer, HalfsiesItem } from '@roleypoly/design-system/atoms/halfsies';
|
||||
import { SparkleOverlay } from '@roleypoly/design-system/atoms/sparkle';
|
||||
import { Role } from '@roleypoly/types';
|
||||
import { demoData } from '@roleypoly/types/demoData';
|
||||
import chroma from 'chroma-js';
|
||||
import * as React from 'react';
|
||||
import { FaCheck, FaTimes } from 'react-icons/fa';
|
||||
import { DiscordBase, DiscordRole } from './WhyNoRoles.styled';
|
||||
|
||||
const adminRoles: Role[] = [
|
||||
{
|
||||
id: 'roley2',
|
||||
name: 'Admin',
|
||||
permissions: '0',
|
||||
color: chroma('hotpink').num(),
|
||||
position: -1,
|
||||
managed: true,
|
||||
safety: 0,
|
||||
},
|
||||
{
|
||||
id: 'roley3',
|
||||
name: 'Moderator',
|
||||
permissions: '0',
|
||||
color: chroma('lime').num(),
|
||||
position: -1,
|
||||
managed: true,
|
||||
safety: 0,
|
||||
},
|
||||
];
|
||||
|
||||
const roleypolyRole: Role = {
|
||||
id: 'roley',
|
||||
name: 'Roleypoly',
|
||||
permissions: '0',
|
||||
color: chroma(palette.taupe500).num(),
|
||||
position: -1,
|
||||
managed: true,
|
||||
safety: 0,
|
||||
};
|
||||
|
||||
const goodRoles = [...adminRoles, roleypolyRole, ...demoData];
|
||||
|
||||
const badRoles = [...adminRoles, ...demoData, roleypolyRole];
|
||||
|
||||
const MaybeWithOverlay = (props: { children: React.ReactNode; withOverlay: boolean }) => {
|
||||
if (props.withOverlay) {
|
||||
return (
|
||||
<SparkleOverlay size={-5} repeatCount={10}>
|
||||
{props.children}
|
||||
</SparkleOverlay>
|
||||
);
|
||||
} else {
|
||||
return <>{props.children}</>;
|
||||
}
|
||||
};
|
||||
|
||||
const Example = (props: { roles: Role[]; isGood: boolean }) => (
|
||||
<div>
|
||||
<DiscordBase>
|
||||
{props.roles.map((r) => (
|
||||
<MaybeWithOverlay withOverlay={props.isGood && r.name === 'Roleypoly'}>
|
||||
<DiscordRole discordRole={r} isRoleypoly={r.name === 'Roleypoly'}>
|
||||
{r.name}
|
||||
</DiscordRole>
|
||||
</MaybeWithOverlay>
|
||||
))}
|
||||
</DiscordBase>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const WhyNoRoles = () => (
|
||||
<HalfsiesContainer>
|
||||
<HalfsiesItem>
|
||||
<FaCheck /> Good
|
||||
<Example isGood roles={goodRoles} />
|
||||
</HalfsiesItem>
|
||||
<HalfsiesItem>
|
||||
<FaTimes /> Baddd
|
||||
<Example isGood={false} roles={badRoles} />
|
||||
</HalfsiesItem>
|
||||
</HalfsiesContainer>
|
||||
);
|
|
@ -0,0 +1 @@
|
|||
export * from './WhyNoRoles';
|
|
@ -0,0 +1,8 @@
|
|||
import * as React from 'react';
|
||||
import { Landing } from './Landing';
|
||||
|
||||
export default {
|
||||
title: 'Organisms/Landing',
|
||||
};
|
||||
|
||||
export const Landing_ = () => <Landing />;
|
32
packages/design-system/organisms/landing/Landing.styled.ts
Normal file
32
packages/design-system/organisms/landing/Landing.styled.ts
Normal file
|
@ -0,0 +1,32 @@
|
|||
import { onTablet } from '@roleypoly/design-system/atoms/breakpoints';
|
||||
import { text400 } from '@roleypoly/design-system/atoms/typography';
|
||||
import styled, { css } from 'styled-components';
|
||||
|
||||
export const HeroText = styled.div`
|
||||
${onTablet(css`
|
||||
text-align: center;
|
||||
`)}
|
||||
`;
|
||||
|
||||
export const DemoSubtitle = styled.p`
|
||||
${text400}
|
||||
|
||||
text-align: center;
|
||||
margin-top: 0.4em;
|
||||
`;
|
||||
|
||||
export const DemoAlignment = styled.div`
|
||||
min-height: 125px;
|
||||
${onTablet(css`
|
||||
min-height: 95px;
|
||||
`)}
|
||||
`;
|
||||
|
||||
export const HeroCentering = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: calc(100vh - 200px);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 2em;
|
||||
`;
|
38
packages/design-system/organisms/landing/Landing.tsx
Normal file
38
packages/design-system/organisms/landing/Landing.tsx
Normal file
|
@ -0,0 +1,38 @@
|
|||
import { palette } from '@roleypoly/design-system/atoms/colors';
|
||||
import { HalfsiesContainer, HalfsiesItem } from '@roleypoly/design-system/atoms/halfsies';
|
||||
import { Space } from '@roleypoly/design-system/atoms/space';
|
||||
import { LargeText, LargeTitle } from '@roleypoly/design-system/atoms/typography';
|
||||
import { DemoDiscord } from '@roleypoly/design-system/molecules/demo-discord';
|
||||
import { DemoPicker } from '@roleypoly/design-system/molecules/demo-picker';
|
||||
import * as React from 'react';
|
||||
import { DemoAlignment, DemoSubtitle, HeroCentering, HeroText } from './Landing.styled';
|
||||
|
||||
export const Landing = () => (
|
||||
<HeroCentering>
|
||||
<HeroText>
|
||||
<div>
|
||||
<LargeTitle>Discord roles for humans.</LargeTitle>
|
||||
</div>
|
||||
<div style={{ color: palette.taupe500 }}>
|
||||
<LargeText>
|
||||
Ditch the bot commands. It's {new Date().getFullYear()}.
|
||||
</LargeText>
|
||||
</div>
|
||||
</HeroText>
|
||||
<Space />
|
||||
<HalfsiesContainer>
|
||||
<HalfsiesItem style={{ marginTop: '2em' }}>
|
||||
<DemoAlignment>
|
||||
<DemoDiscord />
|
||||
</DemoAlignment>
|
||||
<DemoSubtitle>Why are you okay with antiques?</DemoSubtitle>
|
||||
</HalfsiesItem>
|
||||
<HalfsiesItem style={{ marginTop: '2em' }}>
|
||||
<DemoAlignment>
|
||||
<DemoPicker />
|
||||
</DemoAlignment>
|
||||
<DemoSubtitle>Just click or tap.</DemoSubtitle>
|
||||
</HalfsiesItem>
|
||||
</HalfsiesContainer>
|
||||
</HeroCentering>
|
||||
);
|
1
packages/design-system/organisms/landing/index.ts
Normal file
1
packages/design-system/organisms/landing/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from './Landing';
|
96
packages/design-system/organisms/masthead/Authed.tsx
Normal file
96
packages/design-system/organisms/masthead/Authed.tsx
Normal file
|
@ -0,0 +1,96 @@
|
|||
import { DynamicLogomark } from '@roleypoly/design-system/atoms/branding';
|
||||
import { Popover } from '@roleypoly/design-system/atoms/popover';
|
||||
import { GuildNav } from '@roleypoly/design-system/molecules/guild-nav';
|
||||
import { NavSlug } from '@roleypoly/design-system/molecules/nav-slug';
|
||||
import { UserAvatarGroup } from '@roleypoly/design-system/molecules/user-avatar-group';
|
||||
import { UserPopover } from '@roleypoly/design-system/molecules/user-popover';
|
||||
import { DiscordUser, GuildSlug } from '@roleypoly/types';
|
||||
import * as React from 'react';
|
||||
import { GoOrganization } from 'react-icons/go';
|
||||
import {
|
||||
GuildPopoverHead,
|
||||
InteractionBase,
|
||||
MastheadA,
|
||||
MastheadAlignment,
|
||||
MastheadBase,
|
||||
MastheadLeft,
|
||||
MastheadRight,
|
||||
} from './Masthead.styled';
|
||||
|
||||
type Props = {
|
||||
user?: DiscordUser;
|
||||
activeGuildId: string | null;
|
||||
guilds: GuildSlug[];
|
||||
disableGuildPicker?: boolean;
|
||||
};
|
||||
|
||||
export const Authed = (props: Props) => {
|
||||
const [userPopoverState, setUserPopoverState] = React.useState(false);
|
||||
const [serverPopoverState, setServerPopoverState] = React.useState(false);
|
||||
|
||||
return (
|
||||
<MastheadBase>
|
||||
<MastheadAlignment>
|
||||
<MastheadLeft>
|
||||
<MastheadA href="/servers">
|
||||
<DynamicLogomark height={35} />
|
||||
</MastheadA>
|
||||
<InteractionBase
|
||||
onClick={() => {
|
||||
if (!props.disableGuildPicker) {
|
||||
setServerPopoverState(true);
|
||||
setUserPopoverState(false);
|
||||
}
|
||||
}}
|
||||
hide={!serverPopoverState}
|
||||
>
|
||||
<NavSlug
|
||||
guild={
|
||||
props.guilds.find(
|
||||
(guild) => guild.id === props.activeGuildId
|
||||
) || null
|
||||
}
|
||||
/>
|
||||
</InteractionBase>
|
||||
<Popover
|
||||
headContent={
|
||||
<GuildPopoverHead>
|
||||
<GoOrganization />
|
||||
My Guilds
|
||||
</GuildPopoverHead>
|
||||
}
|
||||
canDefocus
|
||||
position="bottom left"
|
||||
active={serverPopoverState}
|
||||
preferredWidth={560}
|
||||
onExit={() => setServerPopoverState(false)}
|
||||
>
|
||||
{() => <GuildNav guilds={props.guilds} />}
|
||||
</Popover>
|
||||
</MastheadLeft>
|
||||
<MastheadRight>
|
||||
<InteractionBase
|
||||
onClick={() => {
|
||||
setUserPopoverState(true);
|
||||
setServerPopoverState(false);
|
||||
}}
|
||||
hide={!userPopoverState}
|
||||
>
|
||||
{props.user !== undefined && (
|
||||
<UserAvatarGroup user={props.user} />
|
||||
)}
|
||||
</InteractionBase>
|
||||
<Popover
|
||||
headContent={<></>}
|
||||
canDefocus
|
||||
position="top right"
|
||||
active={userPopoverState}
|
||||
onExit={() => setUserPopoverState(false)}
|
||||
>
|
||||
{() => props.user && <UserPopover user={props.user} />}
|
||||
</Popover>
|
||||
</MastheadRight>
|
||||
</MastheadAlignment>
|
||||
</MastheadBase>
|
||||
);
|
||||
};
|
34
packages/design-system/organisms/masthead/Guest.tsx
Normal file
34
packages/design-system/organisms/masthead/Guest.tsx
Normal file
|
@ -0,0 +1,34 @@
|
|||
import { DynamicLogotype } from '@roleypoly/design-system/atoms/branding';
|
||||
import { Button } from '@roleypoly/design-system/atoms/button';
|
||||
import * as React from 'react';
|
||||
import { FaSignInAlt } from 'react-icons/fa';
|
||||
import {
|
||||
MastheadA,
|
||||
MastheadAlignment,
|
||||
MastheadBase,
|
||||
MastheadLeft,
|
||||
MastheadRight,
|
||||
} from './Masthead.styled';
|
||||
|
||||
export const Guest = () => (
|
||||
<MastheadBase>
|
||||
<MastheadAlignment>
|
||||
<MastheadLeft>
|
||||
<MastheadA href="/">
|
||||
<DynamicLogotype height={30} />
|
||||
</MastheadA>
|
||||
</MastheadLeft>
|
||||
<MastheadRight>
|
||||
<MastheadA href="/auth/login">
|
||||
<Button size="small">
|
||||
Login{' '}
|
||||
<FaSignInAlt
|
||||
size="1em"
|
||||
style={{ transform: 'translateY(1px)' }}
|
||||
/>
|
||||
</Button>
|
||||
</MastheadA>
|
||||
</MastheadRight>
|
||||
</MastheadAlignment>
|
||||
</MastheadBase>
|
||||
);
|
|
@ -0,0 +1,16 @@
|
|||
import * as React from 'react';
|
||||
import { guild, mastheadSlugs, user } from '../../fixtures/storyData';
|
||||
import { Authed } from './Authed';
|
||||
import { Guest } from './Guest';
|
||||
|
||||
export default {
|
||||
title: 'Organisms/Masthead',
|
||||
};
|
||||
|
||||
export const HasGuilds = () => (
|
||||
<Authed guilds={mastheadSlugs} activeGuildId={guild.id} user={user} />
|
||||
);
|
||||
|
||||
export const NoGuilds = () => <Authed guilds={[]} activeGuildId={null} user={user} />;
|
||||
|
||||
export const Guest_ = () => <Guest />;
|
|
@ -0,0 +1,92 @@
|
|||
import { onSmallScreen } from '@roleypoly/design-system/atoms/breakpoints';
|
||||
import { palette } from '@roleypoly/design-system/atoms/colors';
|
||||
import { transitions } from '@roleypoly/design-system/atoms/timings';
|
||||
import styled, { css } from 'styled-components';
|
||||
|
||||
export const MastheadBase = styled.div`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 50px;
|
||||
background-color: ${palette.taupe100};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 3px;
|
||||
z-index: 100;
|
||||
`;
|
||||
|
||||
export const MastheadAlignment = styled.div`
|
||||
max-width: 98vw;
|
||||
width: 1024px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const sideBase = styled.div`
|
||||
flex: 1;
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
export const MastheadLeft = styled(sideBase)``;
|
||||
|
||||
export const MastheadRight = styled(sideBase)`
|
||||
flex: 0;
|
||||
justify-content: flex-end;
|
||||
`;
|
||||
|
||||
export const MastheadCollapse = styled.div`
|
||||
${onSmallScreen(css`
|
||||
display: none;
|
||||
`)}
|
||||
`;
|
||||
|
||||
export const MastheadInner = styled.div`
|
||||
/* height: 30px; */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
type InteractionBaseProps = {
|
||||
hide: boolean;
|
||||
};
|
||||
export const InteractionBase = styled.div<InteractionBaseProps>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 50px;
|
||||
padding: 0 5px;
|
||||
transition: opacity ${transitions.actionable}s ease-in-out,
|
||||
background-color ${transitions.actionable}s ease-in-out;
|
||||
opacity: ${(props) => (props.hide ? 1 : 0)};
|
||||
|
||||
:hover {
|
||||
background-color: rgba(0, 0, 0, 0.15);
|
||||
cursor: pointer;
|
||||
}
|
||||
`;
|
||||
|
||||
export const MastheadA = styled.a`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: unset;
|
||||
text-decoration: unset;
|
||||
`;
|
||||
|
||||
export const GuildPopoverHead = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
svg {
|
||||
color: ${palette.taupe500};
|
||||
padding: 5px;
|
||||
height: 1.4em;
|
||||
font-size: 2em;
|
||||
margin-right: 10px;
|
||||
margin-left: 16px;
|
||||
${onSmallScreen(css`
|
||||
margin-left: 0;
|
||||
`)}
|
||||
}
|
||||
`;
|
2
packages/design-system/organisms/masthead/index.ts
Normal file
2
packages/design-system/organisms/masthead/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
export * from './Authed';
|
||||
export * from './Guest';
|
33
packages/design-system/organisms/preauth/Preauth.stories.tsx
Normal file
33
packages/design-system/organisms/preauth/Preauth.stories.tsx
Normal file
|
@ -0,0 +1,33 @@
|
|||
import * as React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { guild } from '../../fixtures/storyData';
|
||||
import { Preauth } from './Preauth';
|
||||
|
||||
export default {
|
||||
title: 'Organisms/Preauth',
|
||||
component: Preauth,
|
||||
};
|
||||
|
||||
const Center = styled.div`
|
||||
margin: 0 auto;
|
||||
`;
|
||||
|
||||
export const NoSlug = ({ onSendSecretCode }) => {
|
||||
return (
|
||||
<Center>
|
||||
<Preauth botName="roleypoly#3266" onSendSecretCode={onSendSecretCode} />
|
||||
</Center>
|
||||
);
|
||||
};
|
||||
|
||||
export const WithSlug = ({ onSendSecretCode }) => {
|
||||
return (
|
||||
<Center>
|
||||
<Preauth
|
||||
botName="roleypoly#3266"
|
||||
guildSlug={guild}
|
||||
onSendSecretCode={onSendSecretCode}
|
||||
/>
|
||||
</Center>
|
||||
);
|
||||
};
|
50
packages/design-system/organisms/preauth/Preauth.tsx
Normal file
50
packages/design-system/organisms/preauth/Preauth.tsx
Normal file
|
@ -0,0 +1,50 @@
|
|||
import { Button } from '@roleypoly/design-system/atoms/button';
|
||||
import { PreauthGreeting } from '@roleypoly/design-system/molecules/preauth-greeting';
|
||||
import { GuildSlug } from '@roleypoly/types';
|
||||
import * as React from 'react';
|
||||
import { FaDiscord } from 'react-icons/fa';
|
||||
import styled from 'styled-components';
|
||||
|
||||
export type PreauthProps = {
|
||||
guildSlug?: GuildSlug;
|
||||
onSendSecretCode: (code: string) => void;
|
||||
botName?: string;
|
||||
discordOAuthLink?: string;
|
||||
};
|
||||
|
||||
const Centered = styled.div`
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
max-width: 90vw;
|
||||
margin: 0 auto;
|
||||
`;
|
||||
|
||||
const WidthContainer = styled.div`
|
||||
width: 20em;
|
||||
max-width: 90vw;
|
||||
`;
|
||||
|
||||
export const Preauth = (props: PreauthProps) => {
|
||||
return (
|
||||
<Centered>
|
||||
{props.guildSlug && <PreauthGreeting guildSlug={props.guildSlug} />}
|
||||
<WidthContainer>
|
||||
<a href={props.discordOAuthLink || '#'}>
|
||||
<Button
|
||||
color="discord"
|
||||
icon={
|
||||
<div style={{ position: 'relative', top: 3 }}>
|
||||
<FaDiscord />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
Sign in with Discord
|
||||
</Button>
|
||||
</a>
|
||||
</WidthContainer>
|
||||
</Centered>
|
||||
);
|
||||
};
|
1
packages/design-system/organisms/preauth/index.ts
Normal file
1
packages/design-system/organisms/preauth/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from './Preauth';
|
|
@ -0,0 +1,44 @@
|
|||
jest.unmock('@roleypoly/design-system/atoms/role')
|
||||
.unmock('@roleypoly/design-system/atoms/button')
|
||||
.unmock('@roleypoly/design-system/molecules/picker-category')
|
||||
.unmock('@roleypoly/design-system/organisms/role-picker');
|
||||
|
||||
import { Role } from '@roleypoly/design-system/atoms/role';
|
||||
import { PickerCategory } from '@roleypoly/design-system/molecules/picker-category';
|
||||
import { ResetSubmit } from '@roleypoly/design-system/molecules/reset-submit';
|
||||
import { shallow } from 'enzyme';
|
||||
import * as React from 'react';
|
||||
import {
|
||||
guildData,
|
||||
mastheadSlugs,
|
||||
member,
|
||||
mockCategorySingle,
|
||||
roleCategory,
|
||||
roleCategory2,
|
||||
} from '../../fixtures/storyData';
|
||||
import { RolePicker, RolePickerProps } from './RolePicker';
|
||||
|
||||
it('unselects the rest of a category in single mode', () => {
|
||||
const props: RolePickerProps = {
|
||||
guildData: { ...guildData, categories: [mockCategorySingle] },
|
||||
member: { ...member, roles: [] },
|
||||
roles: [...roleCategory, ...roleCategory2],
|
||||
guild: mastheadSlugs[0],
|
||||
onSubmit: jest.fn(),
|
||||
editable: false,
|
||||
};
|
||||
|
||||
const view = shallow(<RolePicker {...props} />);
|
||||
|
||||
const roles = view.find(PickerCategory).dive().find(Role);
|
||||
|
||||
roles.first().props().onClick?.(true);
|
||||
|
||||
view.find(ResetSubmit).props().onSubmit();
|
||||
expect(props.onSubmit).toBeCalledWith([mockCategorySingle.roles[0]]);
|
||||
|
||||
roles.at(1).props().onClick?.(true);
|
||||
|
||||
view.find(ResetSubmit).props().onSubmit();
|
||||
expect(props.onSubmit).toBeCalledWith([mockCategorySingle.roles[1]]);
|
||||
});
|
|
@ -0,0 +1,60 @@
|
|||
import * as React from 'react';
|
||||
import {
|
||||
guild,
|
||||
guildData,
|
||||
member,
|
||||
roleCategory,
|
||||
roleCategory2,
|
||||
} from '../../fixtures/storyData';
|
||||
import { RolePicker, RolePickerProps } from './RolePicker';
|
||||
|
||||
const props: Partial<RolePickerProps> = {
|
||||
guildData: guildData,
|
||||
member: member,
|
||||
guild: guild,
|
||||
roles: [...roleCategory, ...roleCategory2],
|
||||
editable: false,
|
||||
};
|
||||
|
||||
const noMessageArgs: Partial<RolePickerProps> = {
|
||||
...props,
|
||||
guildData: {
|
||||
...guildData,
|
||||
message: '',
|
||||
},
|
||||
};
|
||||
|
||||
const noCategoriesArgs: Partial<RolePickerProps> = {
|
||||
...props,
|
||||
guildData: {
|
||||
...guildData,
|
||||
categoriesList: [],
|
||||
},
|
||||
};
|
||||
|
||||
const emptyArgs = {
|
||||
...props,
|
||||
guildData: {
|
||||
...guildData,
|
||||
categoriesList: [],
|
||||
message: '',
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
title: 'Organisms/Role Picker',
|
||||
args: props,
|
||||
component: RolePicker,
|
||||
};
|
||||
|
||||
export const Full = (args) => <RolePicker {...args} />;
|
||||
export const EditableFull = (args) => <RolePicker {...args} />;
|
||||
EditableFull.args = {
|
||||
editable: true,
|
||||
};
|
||||
export const NoMessage = (args) => <RolePicker {...args} />;
|
||||
NoMessage.args = noMessageArgs;
|
||||
export const NoCategories = (args) => <RolePicker {...args} />;
|
||||
NoCategories.args = noCategoriesArgs;
|
||||
export const Empty = (args) => <RolePicker {...args} />;
|
||||
Empty.args = emptyArgs;
|
|
@ -0,0 +1,34 @@
|
|||
import { palette } from '@roleypoly/design-system/atoms/colors';
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const Container = styled.div``;
|
||||
|
||||
export const Box = styled.div`
|
||||
background-color: ${palette.taupe300};
|
||||
padding: 5px;
|
||||
margin: 5px 0;
|
||||
`;
|
||||
|
||||
export const MessageBox = styled(Box)`
|
||||
padding: 10px;
|
||||
`;
|
||||
|
||||
export const CategoryContainer = styled(Box)``;
|
||||
|
||||
export const InfoBox = styled(MessageBox)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
export const InfoIcon = styled.div`
|
||||
flex-shrink: 0;
|
||||
font-size: 1.75em;
|
||||
margin-right: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: ${palette.taupe500};
|
||||
`;
|
||||
|
||||
export const Buttons = styled.div`
|
||||
display: flex;
|
||||
`;
|
151
packages/design-system/organisms/role-picker/RolePicker.tsx
Normal file
151
packages/design-system/organisms/role-picker/RolePicker.tsx
Normal file
|
@ -0,0 +1,151 @@
|
|||
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';
|
||||
import { ReactifyNewlines } from '@roleypoly/misc-utils/ReactifyNewlines';
|
||||
import { sortBy } from '@roleypoly/misc-utils/sortBy';
|
||||
import {
|
||||
Category,
|
||||
CategoryType,
|
||||
GuildData,
|
||||
GuildSlug,
|
||||
Member,
|
||||
Role,
|
||||
} from '@roleypoly/types';
|
||||
import { isEqual, xor } from 'lodash';
|
||||
import * as React from 'react';
|
||||
import { GoInfo } from 'react-icons/go';
|
||||
import {
|
||||
CategoryContainer,
|
||||
Container,
|
||||
InfoBox,
|
||||
InfoIcon,
|
||||
MessageBox,
|
||||
} from './RolePicker.styled';
|
||||
|
||||
export type RolePickerProps = {
|
||||
guild: GuildSlug;
|
||||
guildData: GuildData;
|
||||
member: Member;
|
||||
roles: Role[];
|
||||
onSubmit: (selectedRoles: string[]) => void;
|
||||
editable: boolean;
|
||||
};
|
||||
|
||||
export const RolePicker = (props: RolePickerProps) => {
|
||||
const [selectedRoles, updateSelectedRoles] = React.useState<string[]>(
|
||||
props.member.roles
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isEqual(props.member.roles, selectedRoles)) {
|
||||
updateSelectedRoles(props.member.roles);
|
||||
}
|
||||
}, [props.member.roles]);
|
||||
|
||||
const handleChange = (category: Category) => (role: Role) => (newState: boolean) => {
|
||||
if (category.type === CategoryType.Single) {
|
||||
updateSelectedRoles(
|
||||
newState === true
|
||||
? [
|
||||
...selectedRoles.filter((x) => !category.roles.includes(x)),
|
||||
role.id,
|
||||
]
|
||||
: selectedRoles.filter((x) => x !== role.id)
|
||||
);
|
||||
} else {
|
||||
updateSelectedRoles(
|
||||
newState === true
|
||||
? [...selectedRoles, role.id]
|
||||
: selectedRoles.filter((x) => x !== role.id)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Space />
|
||||
<ServerMasthead guild={props.guild} editable={props.editable} />
|
||||
<Space />
|
||||
{props.guildData.message && (
|
||||
<>
|
||||
<MessageBox>
|
||||
<ReactifyNewlines>{props.guildData.message}</ReactifyNewlines>
|
||||
</MessageBox>
|
||||
<Space />
|
||||
</>
|
||||
)}
|
||||
|
||||
{props.guildData.categories.length !== 0 ? (
|
||||
<>
|
||||
<div>
|
||||
{sortBy(
|
||||
props.guildData.categories.filter(
|
||||
(category) => !category.hidden
|
||||
),
|
||||
'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>
|
||||
<div>
|
||||
<FaderOpacity
|
||||
isVisible={
|
||||
xor(selectedRoles, props.member.roles).length !== 0
|
||||
}
|
||||
>
|
||||
<ResetSubmit
|
||||
onSubmit={() => props.onSubmit(selectedRoles)}
|
||||
onReset={() => {
|
||||
updateSelectedRoles(props.member.roles);
|
||||
}}
|
||||
/>
|
||||
</FaderOpacity>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<InfoBox>
|
||||
<InfoIcon>
|
||||
<GoInfo />
|
||||
</InfoIcon>
|
||||
<div>
|
||||
There are currently no roles available for you to choose from.
|
||||
{props.editable && (
|
||||
<>
|
||||
{' '}
|
||||
<a href={`/s/${props.guild.id}/edit`}>
|
||||
<Link>Add some roles!</Link>
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</InfoBox>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
};
|
1
packages/design-system/organisms/role-picker/index.ts
Normal file
1
packages/design-system/organisms/role-picker/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from './RolePicker';
|
|
@ -0,0 +1,25 @@
|
|||
import { UserGuildPermissions } from '@roleypoly/types';
|
||||
import { mastheadSlugs } from '../../fixtures/storyData';
|
||||
import { ServerSetup } from './ServerSetup';
|
||||
|
||||
export default {
|
||||
title: 'Organisms/Server Setup',
|
||||
component: ServerSetup,
|
||||
};
|
||||
|
||||
export const asAdmin = () => (
|
||||
<ServerSetup
|
||||
guildSlug={{ ...mastheadSlugs[1], permissionLevel: UserGuildPermissions.Admin }}
|
||||
/>
|
||||
);
|
||||
export const asManager = () => (
|
||||
<ServerSetup
|
||||
guildSlug={{
|
||||
...mastheadSlugs[1],
|
||||
permissionLevel: UserGuildPermissions.Manager,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
export const asUser = () => (
|
||||
<ServerSetup guildSlug={{ ...mastheadSlugs[1], permissionLevel: 0 }} />
|
||||
);
|
|
@ -0,0 +1,14 @@
|
|||
import styled from 'styled-components';
|
||||
|
||||
export const FlexLine = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 15px;
|
||||
`;
|
||||
|
||||
export const FlexWrap = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-wrap: wrap;
|
||||
`;
|
117
packages/design-system/organisms/server-setup/ServerSetup.tsx
Normal file
117
packages/design-system/organisms/server-setup/ServerSetup.tsx
Normal file
|
@ -0,0 +1,117 @@
|
|||
import { Avatar, utils } from '@roleypoly/design-system/atoms/avatar';
|
||||
import { Button } from '@roleypoly/design-system/atoms/button';
|
||||
import { DotOverlay } from '@roleypoly/design-system/atoms/dot-overlay';
|
||||
import { Hero } from '@roleypoly/design-system/atoms/hero';
|
||||
import { AccentTitle, SmallTitle } from '@roleypoly/design-system/atoms/typography';
|
||||
import { evaluatePermission } from '@roleypoly/misc-utils/hasPermission';
|
||||
import { GuildSlug, UserGuildPermissions } from '@roleypoly/types';
|
||||
import * as React from 'react';
|
||||
import { FaDiscord } from 'react-icons/fa';
|
||||
import { GoArrowLeft } from 'react-icons/go';
|
||||
import { FlexLine, FlexWrap } from './ServerSetup.styled';
|
||||
|
||||
export type ServerSetupProps = {
|
||||
guildSlug: GuildSlug;
|
||||
};
|
||||
|
||||
export const ServerSetup = (props: ServerSetupProps) => (
|
||||
<>
|
||||
<DotOverlay />
|
||||
<Hero>
|
||||
<FlexWrap>
|
||||
<FlexLine>
|
||||
<div>
|
||||
<Avatar
|
||||
hash={props.guildSlug.icon}
|
||||
src={utils.avatarHash(
|
||||
props.guildSlug.id,
|
||||
props.guildSlug.icon,
|
||||
'icons'
|
||||
)}
|
||||
>
|
||||
{utils.initialsFromName(props.guildSlug.name)}
|
||||
</Avatar>
|
||||
</div>
|
||||
<div>
|
||||
<SmallTitle>
|
||||
Roleypoly isn't in {props.guildSlug.name}
|
||||
</SmallTitle>
|
||||
</div>
|
||||
</FlexLine>
|
||||
{renderMessage(props.guildSlug)}
|
||||
</FlexWrap>
|
||||
</Hero>
|
||||
</>
|
||||
);
|
||||
|
||||
const renderMessage = ({ id, permissionLevel, name }: GuildSlug) => {
|
||||
if (evaluatePermission(permissionLevel, UserGuildPermissions.Admin)) {
|
||||
return adminMessage(id);
|
||||
} else if (evaluatePermission(permissionLevel, UserGuildPermissions.Manager)) {
|
||||
return managerMessage(id);
|
||||
} else {
|
||||
return userMessage(name);
|
||||
}
|
||||
};
|
||||
|
||||
const adminMessage = (id: string) => (
|
||||
<>
|
||||
<FlexLine>
|
||||
<AccentTitle>
|
||||
You're an admin of this server, click the button to get started!
|
||||
</AccentTitle>
|
||||
</FlexLine>
|
||||
<FlexLine>
|
||||
<div>
|
||||
<a href={`/machinery/bot-join?id=${id}`}>
|
||||
<Button color="discord" icon={<FaDiscord />}>
|
||||
Add Roleypoly
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
</FlexLine>
|
||||
</>
|
||||
);
|
||||
|
||||
const managerMessage = (id: string) => (
|
||||
<>
|
||||
<FlexLine>
|
||||
<AccentTitle>
|
||||
You might have the permissions to add it to the server.
|
||||
</AccentTitle>
|
||||
</FlexLine>
|
||||
<FlexLine>
|
||||
<div>
|
||||
<a href={`/machinery/bot-join?id=${id}`}>
|
||||
<Button color="discord" icon={<FaDiscord />}>
|
||||
Add Roleypoly
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
</FlexLine>
|
||||
</>
|
||||
);
|
||||
|
||||
const userMessage = (name: string) => {
|
||||
return (
|
||||
<>
|
||||
<FlexLine>
|
||||
<AccentTitle>
|
||||
If you think this is a mistake, please contact staff for {name}.
|
||||
</AccentTitle>
|
||||
</FlexLine>
|
||||
<FlexLine>
|
||||
<Button
|
||||
onClick={() => {
|
||||
// void router.push('/');
|
||||
}}
|
||||
color="muted"
|
||||
size="small"
|
||||
icon={<GoArrowLeft />}
|
||||
>
|
||||
Go back
|
||||
</Button>
|
||||
</FlexLine>
|
||||
</>
|
||||
);
|
||||
};
|
1
packages/design-system/organisms/server-setup/index.ts
Normal file
1
packages/design-system/organisms/server-setup/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from './ServerSetup';
|
|
@ -0,0 +1,12 @@
|
|||
import { mastheadSlugs } from '../../fixtures/storyData';
|
||||
import { ServersListing } from './ServersListing';
|
||||
|
||||
export default {
|
||||
title: 'Organisms/Servers Listing',
|
||||
component: ServersListing,
|
||||
args: {
|
||||
guilds: mastheadSlugs,
|
||||
},
|
||||
};
|
||||
|
||||
export const serversListing = (args) => <ServersListing {...args} />;
|
|
@ -0,0 +1,23 @@
|
|||
import { onTablet } from '@roleypoly/design-system/atoms/breakpoints';
|
||||
import styled, { css } from 'styled-components';
|
||||
|
||||
export const ContentContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: calc(98vw - 15px);
|
||||
padding-bottom: 25px;
|
||||
${onTablet(css`
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
`)}
|
||||
`;
|
||||
|
||||
export const CardContainer = styled.div`
|
||||
box-sizing: border-box;
|
||||
margin-bottom: 5px;
|
||||
${onTablet(css`
|
||||
margin: 5px;
|
||||
flex-basis: 30%;
|
||||
max-width: 30%;
|
||||
`)}
|
||||
`;
|
|
@ -0,0 +1,27 @@
|
|||
import { CompletelyStylelessLink } from '@roleypoly/design-system/atoms/typography';
|
||||
import { ServerListingCard } from '@roleypoly/design-system/molecules/server-listing-card';
|
||||
import { sortBy } from '@roleypoly/misc-utils/sortBy';
|
||||
import { GuildSlug } from '@roleypoly/types';
|
||||
import * as React from 'react';
|
||||
import { CardContainer, ContentContainer } from './ServersListing.styled';
|
||||
|
||||
type ServersListingProps = {
|
||||
guilds: GuildSlug[];
|
||||
};
|
||||
|
||||
export const ServersListing = (props: ServersListingProps) => (
|
||||
<ContentContainer>
|
||||
{props.guilds &&
|
||||
sortBy(props.guilds, 'name', (a: string, b: string) =>
|
||||
a.toLowerCase() > b.toLowerCase() ? 1 : -1
|
||||
).map((guild, idx) => (
|
||||
<CardContainer key={idx}>
|
||||
<a href={`/s/${guild.id}`}>
|
||||
<CompletelyStylelessLink>
|
||||
<ServerListingCard guild={guild} />
|
||||
</CompletelyStylelessLink>
|
||||
</a>
|
||||
</CardContainer>
|
||||
))}
|
||||
</ContentContainer>
|
||||
);
|
|
@ -0,0 +1 @@
|
|||
export * from './ServersListing';
|
Loading…
Add table
Add a link
Reference in a new issue