mirror of
https://github.com/roleypoly/roleypoly.git
synced 2025-04-24 11:29:12 +00:00
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:
parent
a37d481b18
commit
7d681d69d6
48 changed files with 927 additions and 1100 deletions
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
|
@ -10,5 +10,6 @@
|
|||
"**/.yarn": true,
|
||||
"**/.pnp.*": true
|
||||
},
|
||||
"typescript.enablePromptUseWorkspaceTsdk": true
|
||||
"typescript.enablePromptUseWorkspaceTsdk": true,
|
||||
"typescript.preferences.importModuleSpecifier": "non-relative"
|
||||
}
|
||||
|
|
|
@ -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": {
|
||||
|
|
39
packages/api/handlers/update-guild.ts
Normal file
39
packages/api/handlers/update-guild.ts
Normal 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();
|
||||
}
|
||||
);
|
|
@ -15,6 +15,6 @@
|
|||
"level": "^7.0.0",
|
||||
"minimist": "^1.2.5",
|
||||
"node-fetch": "^2.6.1",
|
||||
"webpack": "^4.x"
|
||||
"webpack": "4.44.2"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
|
|
@ -1,8 +0,0 @@
|
|||
import * as React from 'react';
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
const Link = (props: Props) => <>{props.children}</>;
|
||||
|
||||
export default Link;
|
|
@ -31,6 +31,7 @@ export const BreakpointsProvider = (props: { children: React.ReactNode }) => {
|
|||
};
|
||||
|
||||
updateScreenSize();
|
||||
setImmediate(() => updateScreenSize());
|
||||
|
||||
mediaQueries.onDesktop.addEventListener('change', updateScreenSize);
|
||||
mediaQueries.onTablet.addEventListener('change', updateScreenSize);
|
||||
|
|
|
@ -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;
|
||||
`}
|
||||
`;
|
||||
|
|
|
@ -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};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
`;
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
23
packages/design-system/atoms/toggle/Toggle.stories.tsx
Normal file
23
packages/design-system/atoms/toggle/Toggle.stories.tsx
Normal 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>
|
||||
);
|
||||
};
|
40
packages/design-system/atoms/toggle/Toggle.styled.tsx
Normal file
40
packages/design-system/atoms/toggle/Toggle.styled.tsx
Normal 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);
|
||||
`}
|
||||
}
|
||||
`;
|
20
packages/design-system/atoms/toggle/Toggle.tsx
Normal file
20
packages/design-system/atoms/toggle/Toggle.tsx
Normal 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>
|
||||
);
|
1
packages/design-system/atoms/toggle/index.ts
Normal file
1
packages/design-system/atoms/toggle/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from './Toggle';
|
|
@ -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();
|
||||
});
|
|
@ -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 },
|
||||
};
|
|
@ -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;
|
||||
`;
|
|
@ -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>
|
||||
);
|
|
@ -0,0 +1 @@
|
|||
export * from './EditorCategoryShort';
|
|
@ -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;
|
||||
}
|
||||
`;
|
||||
|
|
|
@ -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 />
|
||||
|
|
|
@ -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>
|
||||
);
|
|
@ -0,0 +1 @@
|
|||
export * from './EditorMasthead';
|
|
@ -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} />;
|
||||
|
|
|
@ -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;
|
||||
`;
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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} />;
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -0,0 +1 @@
|
|||
export * from './EditorCategoriesTab';
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -0,0 +1 @@
|
|||
export * from './EditorDetailsTab';
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
|
@ -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",
|
||||
|
|
13
packages/design-system/templates/editor/Editor.stories.tsx
Normal file
13
packages/design-system/templates/editor/Editor.stories.tsx
Normal 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} />;
|
17
packages/design-system/templates/editor/Editor.tsx
Normal file
17
packages/design-system/templates/editor/Editor.tsx
Normal 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>
|
||||
);
|
||||
};
|
1
packages/design-system/templates/editor/index.ts
Normal file
1
packages/design-system/templates/editor/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from './Editor';
|
|
@ -22,6 +22,8 @@ export type GuildData = {
|
|||
features: Features;
|
||||
};
|
||||
|
||||
export type GuildDataUpdate = Omit<Omit<GuildData, 'features'>, 'id'>;
|
||||
|
||||
export type PresentableGuild = {
|
||||
id: string;
|
||||
guild: GuildSlug;
|
||||
|
|
|
@ -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" />
|
||||
|
|
100
packages/web/src/pages/editor.tsx
Normal file
100
packages/web/src/pages/editor.tsx
Normal 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;
|
|
@ -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"]
|
||||
|
|
Loading…
Add table
Reference in a new issue