Feat/editor category pass2 (#290)

* feat(design-system): add editor skeletons

* use css media queries rather than JS media queries

* init remake

* feat: add basis of toggle atom

* finish toggle

* use pointer cursor with toggle

* sync

* feat: add server message in editor

* cleanup storybook

* add short editor item and data model for categories

* chore: fix build by moving jest version downward

* chore: remove old category editor

* chore: fix EditorCategoryShort index

* add editor wiring and styling updates

* fix linting issues
This commit is contained in:
41666 2021-07-05 12:18:40 -05:00 committed by GitHub
parent a37d481b18
commit 7d681d69d6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
48 changed files with 927 additions and 1100 deletions

View file

@ -10,5 +10,6 @@
"**/.yarn": true,
"**/.pnp.*": true
},
"typescript.enablePromptUseWorkspaceTsdk": true
"typescript.enablePromptUseWorkspaceTsdk": true,
"typescript.preferences.importModuleSpecifier": "non-relative"
}

View file

@ -43,7 +43,7 @@
"enzyme-adapter-react-16": "^1.15.6",
"husky": "^6.0.0",
"is-ci": "^3.0.0",
"jest": "^27.0.6",
"jest": "26.6.0",
"jest-enzyme": "^7.1.2",
"jest-react-hooks-shallow": "^1.5.1",
"jest-styled-components": "^7.0.4",
@ -58,7 +58,7 @@
"stylelint-config-standard": "^22.0.0",
"stylelint-config-styled-components": "^0.1.1",
"stylelint-prettier": "^1.2.0",
"ts-jest": "^27.0.3",
"ts-jest": "^26.0.0",
"typescript": "^4.3.4"
},
"resolutions": {

View file

@ -0,0 +1,39 @@
import { GuildDataUpdate, SessionData, UserGuildPermissions } from '@roleypoly/types';
import { withSession } from '../utils/api-tools';
import { getGuildData } from '../utils/guild';
import { GuildData } from '../utils/kv';
import { lowPermissions, missingParameters, notFound, ok } from '../utils/responses';
export const UpdateGuild = withSession(
(session: SessionData) =>
async (request: Request): Promise<Response> => {
const url = new URL(request.url);
const [, , guildID] = url.pathname.split('/');
if (!guildID) {
return missingParameters();
}
const guildUpdate = (await request.json()) as GuildDataUpdate;
const guild = session.guilds.find((guild) => guild.id === guildID);
if (!guild) {
return notFound();
}
if (
guild?.permissionLevel !== UserGuildPermissions.Manager &&
guild?.permissionLevel !== UserGuildPermissions.Admin
) {
return lowPermissions();
}
const newGuildData = {
...(await getGuildData(guildID)),
...guildUpdate,
};
await GuildData.put(guildID, newGuildData);
return ok();
}
);

View file

@ -15,6 +15,6 @@
"level": "^7.0.0",
"minimist": "^1.2.5",
"node-fetch": "^2.6.1",
"webpack": "^4.x"
"webpack": "4.44.2"
}
}

View file

@ -1,4 +1,12 @@
module.exports = {
stories: ['../**/*.stories.mdx', '../**/*.stories.@(js|jsx|ts|tsx)'],
stories: [
'../*.stories.mdx',
...['atoms', 'molecules', 'organisms', 'templates'].map(
(dir) => `../${dir}/**/*.stories.@(tsx|mdx)`
),
],
addons: ['@storybook/addon-links', '@storybook/addon-essentials'],
reactOptions: {
fastRefresh: true,
},
};

View file

@ -1,8 +0,0 @@
import * as React from 'react';
type Props = {
children: React.ReactNode;
};
const Link = (props: Props) => <>{props.children}</>;
export default Link;

View file

@ -31,6 +31,7 @@ export const BreakpointsProvider = (props: { children: React.ReactNode }) => {
};
updateScreenSize();
setImmediate(() => updateScreenSize());
mediaQueries.onDesktop.addEventListener('change', updateScreenSize);
mediaQueries.onTablet.addEventListener('change', updateScreenSize);

View file

@ -1,13 +1,6 @@
import { onTablet } from '@roleypoly/design-system/atoms/breakpoints';
import styled, { css } from 'styled-components';
export const HalfsiesContainer = styled.div`
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
`;
export const HalfsiesItem = styled.div`
box-sizing: border-box;
flex: 1 1 100%;
@ -15,3 +8,16 @@ export const HalfsiesItem = styled.div`
flex: 1 2 50%;
`)}
`;
export const HalfsiesContainer = styled.div<{ center?: boolean }>`
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
${({ center }) =>
center &&
css`
align-content: center;
`}
`;

View file

@ -1,6 +1,22 @@
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';
import { palette } from '../colors';
import { transitions } from '../timings';
export const HideIfSmall = styled.div`
display: initial;
${onSmallScreen(css`
display: none;
`)}
`;
export const HideIfNotSmall = styled.div`
display: none;
${onSmallScreen(css`
display: initial;
`)}
`;
export const NavItem = styled.div<{ selected: boolean }>`
padding: 7px;
@ -35,6 +51,7 @@ export const DropdownNavOpener = styled.div`
cursor: pointer;
border-radius: 2px;
transition: background-color ${transitions.actionable}s ease-in-out;
width: 98vw;
&:hover {
background-color: ${palette.taupe300};

View file

@ -1,6 +1,5 @@
// thing should be everything visible on desktop/tablet and a popover when small
import { useBreakpointContext } from '@roleypoly/design-system/atoms/breakpoints';
import { Popover } from '@roleypoly/design-system/atoms/popover';
import { useState } from 'react';
import { GoChevronDown } from 'react-icons/go';
@ -8,6 +7,8 @@ import {
DropdownNavCurrent,
DropdownNavIcon,
DropdownNavOpener,
HideIfNotSmall,
HideIfSmall,
NavItem,
} from './QuickNav.styled';
@ -18,18 +19,17 @@ export type QuickNavProps = {
};
export const QuickNav = (props: QuickNavProps) => {
const breakpoints = useBreakpointContext();
if (breakpoints.screenSize.onSmallScreen) {
return <QuickNavCollapsed {...props} />;
}
return <QuickNavExpanded {...props} />;
return (
<>
<QuickNavExpanded {...props} />
<QuickNavCollapsed {...props} />
</>
);
};
export const QuickNavExpanded = (props: QuickNavProps) => {
return (
<div>
<HideIfSmall>
{props.navItems.map((navItem) => (
<NavItem
onClick={() => props.onNavChange?.(navItem)}
@ -39,7 +39,7 @@ export const QuickNavExpanded = (props: QuickNavProps) => {
{navItem}
</NavItem>
))}
</div>
</HideIfSmall>
);
};
@ -47,7 +47,7 @@ export const QuickNavCollapsed = (props: QuickNavProps) => {
const [popoverState, setPopoverState] = useState(false);
return (
<div>
<HideIfNotSmall>
{popoverState ? (
<Popover
headContent={<>Server Editor</>}
@ -80,6 +80,6 @@ export const QuickNavCollapsed = (props: QuickNavProps) => {
<DropdownNavCurrent>{props.currentNavItem}</DropdownNavCurrent>
</DropdownNavOpener>
)}
</div>
</HideIfNotSmall>
);
};

View file

@ -22,8 +22,9 @@ export const TabTitleRow = styled.div`
position: fixed;
${onSmallScreen(
css`
width: fit-content;
position: unset;
max-width: 100vw;
max-width: 98vw;
`
)}
`;
@ -65,5 +66,9 @@ export const TabContentTitle = styled.div`
${text500}
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
padding: 10px;
padding: 0.5em 7px;
`;
export const TabDepth = styled.div`
margin-left: 7px;
`;

View file

@ -10,6 +10,7 @@ import {
export type TabViewProps = {
children: React.ReactNode[];
initialTab?: number;
masthead?: React.ReactNode;
};
type TabProps = {
@ -39,6 +40,7 @@ export const TabView = (props: TabViewProps) => {
return (
<TabViewStyled>
<TabTitleRow>
{props.masthead && props.masthead}
<QuickNav
currentNavItem={tabNames[currentTab]}
navItems={tabNames}

View file

@ -1,8 +1,9 @@
import { palette } from '@roleypoly/design-system/atoms/colors';
import { fontCSS } from '@roleypoly/design-system/atoms/fonts';
import * as React from 'react';
import styled from 'styled-components';
import styled, { css } from 'styled-components';
const StyledTextInput = styled.input`
const common = css`
appearance: none;
border: 1px solid ${palette.taupe200};
border-radius: 3px;
@ -39,11 +40,14 @@ const StyledTextInput = styled.input`
}
`;
type TextInputProps = React.InputHTMLAttributes<HTMLInputElement> & {
_override?: React.Component;
};
const StyledTextInput = styled.input`
${common};
`;
export const TextInput = (props: TextInputProps) => {
type TextInputProps<T extends HTMLInputElement | HTMLTextAreaElement> =
React.InputHTMLAttributes<T>;
export const TextInput = (props: TextInputProps<HTMLInputElement>) => {
const { ...rest } = props;
return <StyledTextInput {...rest} />;
};
@ -68,7 +72,7 @@ const IconInputContainer = styled.div`
width: 100%;
`;
type TextInputWithIconProps = TextInputProps & {
type TextInputWithIconProps = TextInputProps<HTMLInputElement> & {
icon: React.ReactNode;
};
@ -81,3 +85,27 @@ export const TextInputWithIcon = (props: TextInputWithIconProps) => {
</IconInputContainer>
);
};
const StyledTextarea = styled.textarea`
${common};
${fontCSS};
`;
export const MultilineTextInput = (
props: TextInputProps<HTMLTextAreaElement> & { rows?: number }
) => {
const { ...rest } = props;
const [value, setValue] = React.useState(String(props.value));
const rows = Math.min(10, Math.max(props.rows || 2, value.split(/\r?\n/).length));
return (
<StyledTextarea
{...rest}
rows={rows}
value={value}
onChange={(eventData) => {
setValue(eventData.target.value);
props.onChange?.(eventData);
}}
/>
);
};

View file

@ -0,0 +1,23 @@
import * as React from 'react';
import { Toggle } from './Toggle';
export default {
title: 'Atoms/Toggle',
component: Toggle,
};
export const toggle = (args) => <Toggle {...args}>Turn a cool thing on</Toggle>;
export const interactive = (args) => {
const [state, setState] = React.useState(true);
return (
<Toggle
{...args}
state={state}
onChange={(val) => {
setState(val);
args.onChange(val);
}}
>
Turn a cool thing on
</Toggle>
);
};

View file

@ -0,0 +1,40 @@
import { palette } from '@roleypoly/design-system/atoms/colors';
import styled, { css } from 'styled-components';
import { transitions } from '../timings';
export const ToggleState = styled.div`
height: 1em;
width: 1em;
border-radius: 1em;
background-color: ${palette.grey600};
position: absolute;
top: 0.15em;
left: 0.15em;
transform: translateX(0);
@media (prefers-reduced-motion: no-preference) {
transition: transform ${transitions.actionable}s ease-in-out;
}
`;
export const ToggleSwitch = styled.div<{ state: boolean }>`
display: inline-block;
background-color: ${(props) => (props.state ? palette.green200 : 'rgba(0,0,0,0.45)')};
height: 1.3em;
width: 2.6em;
border-radius: 1.3em;
position: relative;
border: 1px solid rgba(0, 0, 0, 0.1);
top: 0.23em;
transition: background-color ${transitions.in2in}s ease-in-out;
cursor: pointer;
margin-right: 0.5em;
${ToggleState} {
${(props) =>
props.state === true &&
css`
transform: translateX(1.3em);
`}
}
`;

View file

@ -0,0 +1,20 @@
import { ToggleState, ToggleSwitch } from './Toggle.styled';
type ToggleProps = {
onChange?: (newState: boolean) => void;
children: React.ReactNode;
state: boolean;
};
export const Toggle = (props: ToggleProps) => (
<div
onClick={() => {
props.onChange?.(!props.state);
}}
>
<ToggleSwitch state={props.state}>
<ToggleState />
</ToggleSwitch>
{props.children}
</div>
);

View file

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

View file

@ -0,0 +1,12 @@
import { mockCategory } from '@roleypoly/design-system/fixtures/storyData';
import { render } from '@testing-library/react';
import { EditorCategoryShort } from './EditorCategoryShort';
it('triggers onOpen when clicked', async () => {
const onOpen = jest.fn();
const view = render(<EditorCategoryShort category={mockCategory} onOpen={onOpen} />);
view.getByRole('menuitem')?.click();
expect(onOpen).toHaveBeenCalled();
});

View file

@ -0,0 +1,30 @@
import { mockCategory } from '@roleypoly/design-system/fixtures/storyData';
import ReactTooltip from 'react-tooltip';
import styled from 'styled-components';
import { EditorCategoryShort } from './EditorCategoryShort';
const decorator = (story) => (
<Wrapper>
{story()}
<ReactTooltip />
</Wrapper>
);
export default {
title: 'Molecules/Short Category',
component: EditorCategoryShort,
args: {
category: mockCategory,
},
decorators: [decorator],
};
const Wrapper = styled.div`
box-shadow: 0 0 10px #000;
`;
export const shortEditor = (args) => <EditorCategoryShort {...args} />;
export const shortEditorHidden = (args) => <EditorCategoryShort {...args} />;
shortEditorHidden.args = {
category: { ...mockCategory, hidden: true },
};

View file

@ -0,0 +1,60 @@
import { transitions } from '@roleypoly/design-system/atoms/timings';
import styled from 'styled-components';
export const GrabberBox = styled.div`
display: flex;
align-items: center;
justify-content: center;
margin-right: 1em;
flex: 0;
cursor: grab;
position: relative;
::before {
content: '';
position: absolute;
top: -5px;
left: -8px;
width: 32px;
height: 32px;
background-color: rgba(0, 0, 0, 0.25);
opacity: 0;
border-radius: 50%;
transition: opacity ${transitions.actionable}s ease-in-out;
}
&:hover {
::before {
opacity: 1;
}
}
`;
export const Opener = styled.div`
opacity: 0;
transition: opacity ${transitions.actionable}s ease-in-out;
`;
export const Container = styled.div`
display: flex;
padding: 1em;
cursor: pointer;
&:hover {
${Opener} {
opacity: 1;
}
}
`;
export const Name = styled.div`
position: relative;
top: -2px;
margin-right: 1em;
`;
export const Flags = styled.div``;
export const Void = styled.div`
flex: 1;
`;

View file

@ -0,0 +1,31 @@
import { Category } from '@roleypoly/types';
import { DragEventHandler, MouseEventHandler } from 'react';
import { GoEyeClosed, GoGrabber, GoKebabHorizontal } from 'react-icons/go';
import {
Container,
Flags,
GrabberBox,
Name,
Opener,
Void,
} from './EditorCategoryShort.styled';
type ShortProps = {
category: Category;
onDrag?: DragEventHandler;
onOpen?: MouseEventHandler;
};
export const EditorCategoryShort = (props: ShortProps) => (
<Container onClick={props.onOpen} role="menuitem">
<GrabberBox onDrag={props.onDrag} role="button">
<GoGrabber />
</GrabberBox>
<Name>{props.category.name}</Name>
<Flags>{props.category.hidden && <GoEyeClosed data-tip="Hidden to Members" />}</Flags>
<Void />
<Opener role="button">
<GoKebabHorizontal />
</Opener>
</Container>
);

View file

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

View file

@ -3,4 +3,11 @@ import styled from 'styled-components';
export const RoleContainer = styled.div`
display: flex;
margin: 10px;
flex-wrap: wrap;
& > div {
/* This should be a Role element */
border: 1px solid rgba(0, 0, 0, 0.15);
margin: 1px;
}
`;

View file

@ -1,9 +1,9 @@
import { FaderOpacity } from '@roleypoly/design-system/atoms/fader';
import { HorizontalSwitch } from '@roleypoly/design-system/atoms/horizontal-switch';
import { Popover } from '@roleypoly/design-system/atoms/popover';
import { Role } from '@roleypoly/design-system/atoms/role';
import { Space } from '@roleypoly/design-system/atoms/space';
import { TextInput, TextInputWithIcon } from '@roleypoly/design-system/atoms/text-input';
import { Toggle } from '@roleypoly/design-system/atoms/toggle';
import { Text } from '@roleypoly/design-system/atoms/typography';
import { RoleSearch } from '@roleypoly/design-system/molecules/role-search';
import { Category, CategoryType, Role as RoleType } from '@roleypoly/types';
@ -74,24 +74,22 @@ export const EditorCategory = (props: Props) => {
<Space />
<Text>Selection Type</Text>
<div>
<HorizontalSwitch
items={['Multiple', 'Single']}
value={typeEnumToSwitch(props.category.type)}
onChange={onUpdate('type', switchToTypeEnum)}
/>
<Toggle
state={props.category.type === CategoryType.Multi}
onChange={onUpdate('type', (x) =>
x ? CategoryType.Multi : CategoryType.Single
)}
>
Allow users to pick multiple roles
</Toggle>
</div>
<Space />
<Text>Visiblity</Text>
<div>
<HorizontalSwitch
items={['Visible', 'Hidden']}
value={props.category.hidden ? 'Hidden' : 'Visible'}
onChange={onUpdate('hidden', (a) => a === 'Hidden')}
/>
<Toggle state={props.category.hidden} onChange={onUpdate('hidden')}>
Hide category from users
</Toggle>
</div>
<Space />

View file

@ -0,0 +1,43 @@
import { Avatar, utils as avatarUtils } from '@roleypoly/design-system/atoms/avatar';
import { Text } from '@roleypoly/design-system/atoms/typography';
import { PresentableGuild } from '@roleypoly/types';
import styled, { css } from 'styled-components';
import { onSmallScreen } from '../../atoms/breakpoints';
type EditorMastheadProps = {
guild: PresentableGuild;
onSubmit: () => void;
onReset: () => void;
showSaveReset: boolean;
};
const MastheadContainer = styled.div`
display: flex;
flex: 1;
align-items: center;
justify-content: start;
padding-bottom: 0.5em;
${Text} {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
margin-left: 0.5em;
}
${onSmallScreen(css`
display: none;
`)}
`;
export const EditorMasthead = (props: EditorMastheadProps) => (
<MastheadContainer>
<Avatar
size={34}
hash={props.guild.guild.icon}
src={avatarUtils.avatarHash(props.guild.id, props.guild.guild.icon, 'icons')}
>
{avatarUtils.initialsFromName(props.guild.guild.name)}
</Avatar>
<Text>Server Editor</Text>
</MastheadContainer>
);

View file

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

View file

@ -1,9 +1,10 @@
import * as React from 'react';
import { ResetSubmit } from './ResetSubmit';
import { InlineResetSubmit, ResetSubmit } from './ResetSubmit';
export default {
title: 'Molecules',
title: 'Molecules/Reset and Submit',
component: ResetSubmit,
};
export const ResetAndSubmit = (args) => <ResetSubmit {...args} />;
export const normal = (args) => <ResetSubmit {...args} />;
export const inline = (args) => <InlineResetSubmit {...args} />;

View file

@ -1,19 +0,0 @@
import { onSmallScreen } from '@roleypoly/design-system/atoms/breakpoints';
import styled from 'styled-components';
export const Buttons = styled.div`
display: flex;
flex-wrap: wrap;
`;
export const Left = styled.div`
flex: 0;
${onSmallScreen`
flex: 1 1 100%;
order: 2;
`}
`;
export const Right = styled.div`
flex: 1;
`;

View file

@ -2,7 +2,7 @@ import { onSmallScreen } from '@roleypoly/design-system/atoms/breakpoints';
import { Button } from '@roleypoly/design-system/atoms/button';
import * as React from 'react';
import { MdRestore } from 'react-icons/md';
import styled from 'styled-components';
import styled, { css } from 'styled-components';
type Props = {
onSubmit: () => void;
@ -22,8 +22,14 @@ const Left = styled.div`
`}
`;
const Right = styled.div`
const Right = styled.div<{ inline?: boolean }>`
flex: 1;
${(props) =>
props.inline &&
css`
padding-left: 0.2em;
`}
`;
export const ResetSubmit = (props: Props) => {
@ -40,3 +46,20 @@ export const ResetSubmit = (props: Props) => {
</Buttons>
);
};
export const InlineResetSubmit = (props: Props) => {
return (
<Buttons>
<Left>
<Button color="muted" size="small" icon={<MdRestore />} onClick={props.onReset}>
Reset
</Button>
</Left>
<Right inline>
<Button onClick={props.onSubmit} size="small">
Submit
</Button>
</Right>
</Buttons>
);
};

View file

@ -0,0 +1,12 @@
import { guildEnum } from '@roleypoly/design-system/fixtures/storyData';
import { EditorCategoriesTab } from './EditorCategoriesTab';
export default {
title: 'Organisms/Editor/Categories Tab',
component: EditorCategoriesTab,
args: {
guild: guildEnum.guilds[0],
},
};
export const categoriesTab = (args) => <EditorCategoriesTab {...args} />;

View file

@ -0,0 +1,39 @@
import { TabDepth } from '@roleypoly/design-system/atoms/tab-view/TabView.styled';
import { EditorCategory } from '@roleypoly/design-system/molecules/editor-category';
import { EditorCategoryShort } from '@roleypoly/design-system/molecules/editor-category-short/EditorCategoryShort';
import { EditorShellProps } from '@roleypoly/design-system/organisms/editor-shell';
import { Category } from '@roleypoly/types';
import { sortBy } from 'lodash';
import * as React from 'react';
import { CategoryContainer } from './EditorCategoriesTab.styled';
export const EditorCategoriesTab = (props: EditorShellProps) => {
const [openStates, setOpenStates] = React.useState<Category['id'][]>([]);
const onCategoryOpen = (id: Category['id']) => () => {
setOpenStates([...new Set(openStates).add(id)]);
};
return (
<TabDepth>
{sortBy(props.guild.data.categories, ['position', 'id']).map((category, idx) =>
openStates.includes(category.id) ? (
<CategoryContainer key={idx}>
<EditorCategory
category={category}
uncategorizedRoles={[]}
guildRoles={props.guild.roles}
onChange={(category) => props.onCategoryChange?.(category)}
/>
</CategoryContainer>
) : (
<EditorCategoryShort
key={idx}
category={category}
onOpen={onCategoryOpen(category.id)}
/>
)
)}
</TabDepth>
);
};

View file

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

View file

@ -0,0 +1,20 @@
import { Space } from '@roleypoly/design-system/atoms/space';
import { TabDepth } from '@roleypoly/design-system/atoms/tab-view/TabView.styled';
import { MultilineTextInput } from '@roleypoly/design-system/atoms/text-input';
import { Text } from '@roleypoly/design-system/atoms/typography';
import { EditorShellProps } from '@roleypoly/design-system/organisms/editor-shell';
import * as React from 'react';
export const EditorDetailsTab = (props: EditorShellProps) => {
return (
<TabDepth>
<Space />
<Text>Server Message</Text>
<MultilineTextInput
value={props.guild.data.message}
onChange={(eventData) => props.onMessageChange?.(eventData.target.value)}
/>
<Space />
</TabDepth>
);
};

View file

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

View file

@ -0,0 +1,81 @@
import { Space } from '@roleypoly/design-system/atoms/space';
import { Tab, TabView } from '@roleypoly/design-system/atoms/tab-view';
import { EditorMasthead } from '@roleypoly/design-system/molecules/editor-masthead';
import { EditorCategoriesTab } from '@roleypoly/design-system/organisms/editor-categories-tab';
import { EditorDetailsTab } from '@roleypoly/design-system/organisms/editor-details-tab';
import { Category, PresentableGuild } from '@roleypoly/types';
import deepEqual from 'deep-equal';
import React from 'react';
export type EditorShellProps = {
guild: PresentableGuild;
onGuildChange?: (guild: PresentableGuild) => void;
onCategoryChange?: (category: Category) => void;
onMessageChange?: (message: PresentableGuild['data']['message']) => void;
};
export const EditorShell = (props: EditorShellProps) => {
const [guild, setGuild] = React.useState<PresentableGuild>(props.guild);
const reset = () => {
setGuild(props.guild);
};
const onCategoryChange = (category: Category) => {
setGuild((currentGuild) => {
const categories = [
...currentGuild.data.categories.filter((x) => x.id !== category.id),
category,
];
return { ...currentGuild, data: { ...currentGuild.data, categories } };
});
};
const onMessageChange = (message: PresentableGuild['data']['message']) => {
setGuild((currentGuild) => {
return { ...currentGuild, data: { ...guild.data, message } };
});
};
const hasChanges = React.useMemo(
() => !deepEqual(guild.data, props.guild.data),
[guild.data, props.guild.data]
);
return (
<div>
<Space />
<TabView
initialTab={0}
masthead={
<EditorMasthead
guild={guild}
onReset={reset}
onSubmit={() => props.onGuildChange?.(guild)}
showSaveReset={hasChanges}
/>
}
>
<Tab title="Guild Details">
{() => (
<EditorDetailsTab
{...props}
guild={guild}
onMessageChange={onMessageChange}
/>
)}
</Tab>
<Tab title="Categories & Roles">
{() => (
<EditorCategoriesTab
{...props}
guild={guild}
onCategoryChange={onCategoryChange}
/>
)}
</Tab>
<Tab title="Utilities">{() => <div>hi2!</div>}</Tab>
</TabView>
</div>
);
};

View file

@ -1,30 +0,0 @@
import { Tab, TabView } from '@roleypoly/design-system/atoms/tab-view';
import { EditorCategory } from '@roleypoly/design-system/molecules/editor-category';
import { PresentableGuild } from '@roleypoly/types';
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>
);

View file

@ -8,6 +8,7 @@
"dependencies": {
"@roleypoly/types": "*",
"chroma-js": "^2.1.2",
"deep-equal": "^2.0.5",
"isomorphic-unfetch": "^3.1.0",
"ksuid": "^2.0.0",
"lodash": "^4.17.21",
@ -30,6 +31,7 @@
"@storybook/react": "^6.3.2",
"@storybook/theming": "^6.3.2",
"@types/chroma-js": "^2.1.3",
"@types/deep-equal": "^1.0.1",
"@types/node": "^15.12.5",
"@types/react": "^17.0.11",
"@types/react-custom-scrollbars": "^4.0.7",

View file

@ -0,0 +1,13 @@
import { guildEnum, mastheadSlugs, user } from '../../fixtures/storyData';
import { EditorTemplate } from './Editor';
export default {
title: 'Templates/Server Editor',
component: EditorTemplate,
args: {
guilds: mastheadSlugs,
user: user,
guild: guildEnum.guilds[0],
},
};
export const serverEditor = (args) => <EditorTemplate {...args} />;

View file

@ -0,0 +1,17 @@
import { AppShell, AppShellProps } from '@roleypoly/design-system/organisms/app-shell';
import {
EditorShell,
EditorShellProps,
} from '@roleypoly/design-system/organisms/editor-shell';
export const EditorTemplate = (
props: EditorShellProps & Omit<AppShellProps, 'children'>
) => {
const { guild, onCategoryChange, onMessageChange, onGuildChange, ...appShellProps } =
props;
return (
<AppShell {...appShellProps} activeGuildId={guild.id}>
<EditorShell guild={guild} onGuildChange={onGuildChange} />
</AppShell>
);
};

View file

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

View file

@ -22,6 +22,8 @@ export type GuildData = {
features: Features;
};
export type GuildDataUpdate = Omit<Omit<GuildData, 'features'>, 'id'>;
export type PresentableGuild = {
id: string;
guild: GuildSlug;

View file

@ -7,6 +7,7 @@ import LandingPage from '../pages/landing';
import PickerPage from '../pages/picker';
const ServersPage = React.lazy(() => import('../pages/servers'));
const EditorPage = React.lazy(() => import('../pages/editor'));
const MachineryNewSession = React.lazy(() => import('../pages/machinery/new-session'));
const MachineryLogout = React.lazy(() => import('../pages/machinery/logout'));
@ -32,6 +33,7 @@ export const AppRouter = () => {
<RouteWrapper component={LandingPage} path="/" />
<RouteWrapper component={ServersPage} path="/servers" />
<RouteWrapper component={PickerPage} path="/s/:serverID" />
<RouteWrapper component={EditorPage} path="/s/:serverID/edit" />
<RouteWrapper component={ErrorPage} path="/error" />
<RouteWrapper component={ErrorPage} path="/error/:identity" />

View file

@ -0,0 +1,100 @@
import { Redirect } from '@reach/router';
import { EditorTemplate } from '@roleypoly/design-system/templates/editor';
import { GenericLoadingTemplate } from '@roleypoly/design-system/templates/generic-loading';
import {
GuildDataUpdate,
PresentableGuild,
UserGuildPermissions,
} from '@roleypoly/types';
import * as React from 'react';
import { useAppShellProps } from '../contexts/app-shell/AppShellContext';
import { useRecentGuilds } from '../contexts/recent-guilds/RecentGuildsContext';
import { useSessionContext } from '../contexts/session/SessionContext';
import { Title } from '../utils/metaTitle';
type EditorProps = {
serverID: string;
path: string;
};
const Editor = (props: EditorProps) => {
const { serverID } = props;
const { session, authedFetch, isAuthenticated } = useSessionContext();
const { pushRecentGuild } = useRecentGuilds();
const appShellProps = useAppShellProps();
const [guild, setGuild] = React.useState<PresentableGuild | null | false>(null);
const [pending, setPending] = React.useState(false);
React.useEffect(() => {
const fetchGuild = async () => {
const response = await authedFetch(`/get-picker-data/${serverID}`);
const data = await response.json();
if (response.status !== 200) {
setGuild(false);
return;
}
setGuild(data);
};
fetchGuild();
}, [serverID, authedFetch]);
React.useCallback((serverID) => pushRecentGuild(serverID), [pushRecentGuild])(serverID);
// If the user is not authenticated, redirect to the login page.
if (!isAuthenticated) {
return <Redirect to={`/auth/login?r=${props.serverID}`} replace />;
}
// If the user is not an admin, they can't edit the guild
// so we redirect them to the picker
const guildSlug = session?.guilds?.find((guild) => guild.id === serverID);
if (guildSlug && guildSlug?.permissionLevel === UserGuildPermissions.User) {
return <Redirect to={`/s/${props.serverID}`} replace />;
}
// If the guild isn't loaded, render a loading placeholder
if (guild === null) {
return <GenericLoadingTemplate />;
}
// If the guild is not found, redirect to the picker page
if (guild === false) {
return <Redirect to={`/s/${props.serverID}`} replace />;
}
const onGuildChange = async (guild: PresentableGuild) => {
if (pending) {
return;
}
setPending(true);
const updatePayload: GuildDataUpdate = {
message: guild.data.message,
categories: guild.data.categories,
};
const response = await authedFetch(`/update-guild/${serverID}`, {
method: 'PATCH',
body: JSON.stringify(updatePayload),
});
if (response.status === 200) {
setGuild(guild);
setPending(false);
}
};
return (
<>
<Title title={`Editing ${guild.guild.name} - Roleypoly`} />
<EditorTemplate {...appShellProps} guild={guild} onGuildChange={onGuildChange} />
</>
);
};
export default Editor;

View file

@ -22,7 +22,10 @@
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"resolveJsonModule": true,
"isolatedModules": true
"isolatedModules": true,
"paths": {
"@roleypoly/*": ["./packages/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules", "**/*.stories.tsx", "packages/api"]

1168
yarn.lock

File diff suppressed because it is too large Load diff