From b20943213c07393c23dc4c40eaf26da1a424d772 Mon Sep 17 00:00:00 2001 From: Michael Peters Date: Sun, 12 Dec 2021 15:01:43 -0600 Subject: [PATCH] intermediate commit --- .../webapp/elements/components/button.tsx | 15 ++-- .../webapp/elements/components/display.tsx | 10 +-- .../elements/components/input-dropdown.tsx | 65 ++++++++++++++--- .../webapp/elements/components/input-text.tsx | 8 +-- .../elements/components/table-invites.tsx | 39 +++++++++++ .../webapp/elements/components/table.tsx | 43 ++++++++++++ .../displays/display-guild-invites.tsx | 59 +++++++++------- .../displays/display-guild-overview.tsx | 43 +++++++----- .../overlays/overlay-guild-settings.tsx | 3 +- src/client/webapp/styles/buttons.scss | 17 ++++- src/client/webapp/styles/components.scss | 60 ++++++++++++++++ src/client/webapp/styles/overlays.scss | 70 +++++++++++++++---- src/client/webapp/styles/theme.scss | 5 ++ 13 files changed, 351 insertions(+), 86 deletions(-) create mode 100644 src/client/webapp/elements/components/table-invites.tsx create mode 100644 src/client/webapp/elements/components/table.tsx diff --git a/src/client/webapp/elements/components/button.tsx b/src/client/webapp/elements/components/button.tsx index 1006847..ae211e4 100644 --- a/src/client/webapp/elements/components/button.tsx +++ b/src/client/webapp/elements/components/button.tsx @@ -1,6 +1,6 @@ import React, { FC, Ref, useCallback, useMemo } from 'react'; -export enum ButtonType { +export enum ButtonColorType { BRAND = '', POSITIVE = 'positive', NEGATIVE = 'negative', @@ -10,26 +10,27 @@ export enum ButtonType { interface ButtonProps { ref?: Ref; - type?: ButtonType; + colorType?: ButtonColorType; + onClick?: () => void; shaking?: boolean; children?: React.ReactNode; } -const DefaultButtonProps: ButtonProps = { - type: ButtonType.BRAND +const DefaultButtonProps = { + colorType: ButtonColorType.BRAND, } const Button: FC = React.forwardRef((props: ButtonProps, ref: Ref) => { - const { type, onClick, shaking, children } = { ...DefaultButtonProps, ...props }; + const { colorType, onClick, shaking, children } = { ...DefaultButtonProps, ...props }; const className = useMemo( () => [ 'button', - type, + colorType, shaking && 'shaking-horizontal', ].filter(c => typeof c === 'string').join(' '), - [ type, shaking ] + [ colorType, shaking ] ); const clickHandler = useCallback(() => { diff --git a/src/client/webapp/elements/components/display.tsx b/src/client/webapp/elements/components/display.tsx index 418fa53..6a1103d 100644 --- a/src/client/webapp/elements/components/display.tsx +++ b/src/client/webapp/elements/components/display.tsx @@ -5,7 +5,7 @@ const LOG = Logger.create(__filename, electronConsole); import React, { FC, useEffect, useMemo, useState } from "react"; import ElementsUtil from "../require/elements-util"; -import Button, { ButtonType } from "./button"; +import Button, { ButtonColorType } from "./button"; import DisplayPopup from "./display-popup"; interface DisplayProps { @@ -53,20 +53,20 @@ const Display: FC = (props: DisplayProps) => { if (errorMessage) { return ( - + ); } else if (infoMessage && infoMessage !== dismissedInfoMessage) { return ( - + ); } else if (changes) { return ( - - + + ); } else { diff --git a/src/client/webapp/elements/components/input-dropdown.tsx b/src/client/webapp/elements/components/input-dropdown.tsx index 8e465b1..f17ab0b 100644 --- a/src/client/webapp/elements/components/input-dropdown.tsx +++ b/src/client/webapp/elements/components/input-dropdown.tsx @@ -1,28 +1,75 @@ -import React, { ChangeEvent, createRef, FC, useMemo } from 'react'; +import React, { ChangeEvent, createRef, FC, MouseEvent, MouseEventHandler, useCallback, useEffect, useMemo, useState } from 'react'; export interface DropdownInputProps { + label?: string; options: { value: string, display: string }[]; + width?: number; value: string; setValue: React.Dispatch>; } + +const DefaultDropdownInputProps = { + width: 150 +} + const DropdownInput: FC = (props: DropdownInputProps) => { - const { options, value, setValue } = props; + const { label, options, width, value, setValue } = { ...DefaultDropdownInputProps, ...props }; + + // A dropdown input that mimicks the "select" input + // Supports mouse-click selects + // TODO: Has a \/ dropdown indicator + // TODO: Supports up+down arrows to change input (both while dropdown is active and when closed) + // TODO: Supports max dropdown size + scrolling dropdown + // TODO: Supports opening above if at the bottom of a page + + const displayRef = createRef(); + + const [ display, setDisplay ] = useState(''); + const [ optionsOpen, setOptionsOpen ] = useState(false); + + useEffect(() => { + // only do this expensive version once + setDisplay(options.find(option => option.value === value)?.display ?? ''); + }, []); const optionElements = useMemo(() => { return options.map(option => { - return + const className = option.value === value ? 'option selected' : 'option'; + const onClick = () => { + setValue(option.value); + setDisplay(option.display); + setOptionsOpen(false); + } + return
{option.display}
}); }, [ options ]); - const onChange = (e: ChangeEvent) => { - setValue(e.target.value); - } + const labelElement = useMemo(() => { + return label &&
{label}
+ }, [ label ]); + + const onDisplayClick = useCallback((e: MouseEvent) => { + if (e.target !== displayRef.current) return; + setOptionsOpen(prev => !prev); + }, [ displayRef ]); + + const onMainBlur = useCallback(() => { + setOptionsOpen(false); + }, []); + + const optionsClassName = useMemo(() => optionsOpen ? 'options open' : 'options', [ optionsOpen ]); return ( -
- +
); } diff --git a/src/client/webapp/elements/components/input-text.tsx b/src/client/webapp/elements/components/input-text.tsx index 5be7c20..1559082 100644 --- a/src/client/webapp/elements/components/input-text.tsx +++ b/src/client/webapp/elements/components/input-text.tsx @@ -8,11 +8,9 @@ import React, { FC, Ref, useEffect, useMemo } from 'react'; export interface TextInputProps { ref?: Ref; - label: string; + label?: string; placeholder?: string; - noLabel?: boolean; - allowEmpty?: boolean; maxLength: number; @@ -27,7 +25,7 @@ export interface TextInputProps { } const TextInput: FC = React.forwardRef((props: TextInputProps, ref: Ref) => { - const { label, placeholder, noLabel, allowEmpty, maxLength, value, setValue, setValid, setMessage, onEnterKeyDown, valueMap } = props; + const { label, placeholder, allowEmpty, maxLength, value, setValue, setValid, setMessage, onEnterKeyDown, valueMap } = props; useEffect(() => { if (maxLength !== undefined && value.length > maxLength) { @@ -45,7 +43,7 @@ const TextInput: FC = React.forwardRef((props: TextInputProps, r }, [ value ]); const labelElement = useMemo(() => { - return label && !noLabel &&
{label}
+ return label &&
{label}
}, [ label ]); const handleChange = (e: React.ChangeEvent) => { diff --git a/src/client/webapp/elements/components/table-invites.tsx b/src/client/webapp/elements/components/table-invites.tsx new file mode 100644 index 0000000..ed10c71 --- /dev/null +++ b/src/client/webapp/elements/components/table-invites.tsx @@ -0,0 +1,39 @@ +import moment from 'moment'; +import React, { FC, useCallback } from 'react'; +import { Token } from '../../data-types'; +import CombinedGuild from '../../guild-combined'; +import Button, { ButtonColorType } from './button'; +import Table from './table'; + +export interface InvitesTableProps { + guild: CombinedGuild +} + +const InvitesTable: FC = (props: InvitesTableProps) => { + const { guild } = props; + + const fetchData = useCallback(async () => await guild.fetchTokens(), [ guild ]); + const header = ( + + Created + Expires + Actions + + ); + const mapToRow = useCallback((token: Token) => { + return ( + + {moment(token.created).fromNow()} + {moment(token.expires).fromNow()} + + + + + + ); + }, []); + + return +} + +export default InvitesTable; diff --git a/src/client/webapp/elements/components/table.tsx b/src/client/webapp/elements/components/table.tsx new file mode 100644 index 0000000..2105c47 --- /dev/null +++ b/src/client/webapp/elements/components/table.tsx @@ -0,0 +1,43 @@ +import * as electronRemote from '@electron/remote'; +const electronConsole = electronRemote.getGlobal('console') as Console; +import Logger from '../../../../logger/logger'; +const LOG = Logger.create(__filename, electronConsole); + +import React, { FC, useEffect, useState } from 'react'; + +export interface TableProps { + header: JSX.Element; + fetchData: () => Promise; + mapToRow: (data: T) => JSX.Element; +} + +export default function Table(props: TableProps) { + const { header, fetchData, mapToRow } = props; + + // TODO: Loading indicator + fetch failed indicator + + const [ dataRows, setDataRows ] = useState([]); + const [ loading, setLoading ] = useState(true); + const [ fetchFailed, setFetchFailed ] = useState(false); + + useEffect(() => { + (async () => { + setLoading(true); + try { + const data = await fetchData(); + setDataRows(data.map(mapToRow)); + } catch (e: unknown) { + LOG.error('error fetching data for table', e); + setFetchFailed(true); + } + setLoading(false); + })(); + }, []); + + return ( +
+ {header} + {dataRows} +
+ ) +} diff --git a/src/client/webapp/elements/displays/display-guild-invites.tsx b/src/client/webapp/elements/displays/display-guild-invites.tsx index 27968dc..138a90d 100644 --- a/src/client/webapp/elements/displays/display-guild-invites.tsx +++ b/src/client/webapp/elements/displays/display-guild-invites.tsx @@ -13,6 +13,8 @@ import ReactHelper from '../require/react-helper'; import { Duration } from 'moment'; import moment from 'moment'; import DropdownInput from '../components/input-dropdown'; +import Button from '../components/button'; +import InvitesTable from '../components/table-invites'; export interface GuildInvitesDisplayProps { @@ -33,16 +35,6 @@ const GuildInvitesDisplay: FC = (props: GuildInvitesDi const [ guildMetaFailed, setGuildMetaFailed ] = useState(false); - useEffect(() => { - if (expiresFromNowText === 'never') { - setExpiresFromNow(null); - return; - } else { - const splt = expiresFromNowText.split(' '); - setExpiresFromNow(moment.duration(splt[0], splt[1] as moment.unitOfTime.DurationConstructor)); - } - }, [ expiresFromNowText ]); - ReactHelper.useGuildMetadataEffect({ guild, onSuccess: (guildMeta: GuildMetadata) => { @@ -57,6 +49,16 @@ const GuildInvitesDisplay: FC = (props: GuildInvitesDi onSuccess: setIconSrc }); + useEffect(() => { + if (expiresFromNowText === 'never') { + setExpiresFromNow(null); + return; + } else { + const splt = expiresFromNowText.split(' '); + setExpiresFromNow(moment.duration(splt[0], splt[1] as moment.unitOfTime.DurationConstructor)); + } + }, [ expiresFromNowText ]); + const errorMessage = useMemo(() => { if (guildMetaFailed) return 'Unable to load guild metadata'; return null; @@ -66,21 +68,30 @@ const GuildInvitesDisplay: FC = (props: GuildInvitesDi -
Create Invite
-
-
- +
+
+
Create Invite
+
+
+ +
+
+ +
+
+
+
+
Active Invites
+
-
) diff --git a/src/client/webapp/elements/displays/display-guild-overview.tsx b/src/client/webapp/elements/displays/display-guild-overview.tsx index 6d77584..89858fa 100644 --- a/src/client/webapp/elements/displays/display-guild-overview.tsx +++ b/src/client/webapp/elements/displays/display-guild-overview.tsx @@ -15,10 +15,10 @@ import ReactHelper from '../require/react-helper'; export interface GuildOverviewDisplayProps { guild: CombinedGuild; - setGuildName: React.Dispatch>; // to allow overlay title to update + setContainerGuildName: React.Dispatch>; // to allow overlay title to update } const GuildOverviewDisplay: FC = (props: GuildOverviewDisplayProps) => { - const { guild } = props; + const { guild, setContainerGuildName } = props; const [ iconResourceId, setIconResourceId ] = useState(null); @@ -43,11 +43,15 @@ const GuildOverviewDisplay: FC = (props: GuildOvervie ReactHelper.useGuildMetadataEffect({ guild, onSuccess: (guildMeta: GuildMetadata) => { + setContainerGuildName(guildMeta.name); setSavedName(guildMeta.name); setName(guildMeta.name); setIconResourceId(guildMeta.iconResourceId); }, - onError: () => setGuildMetaFailed(true) + onError: () => { + setGuildMetaFailed(true); + setContainerGuildName(''); + } }) ReactHelper.useNullableResourceEffect({ @@ -95,6 +99,7 @@ const GuildOverviewDisplay: FC = (props: GuildOvervie // Save name try { await guild.requestSetGuildName(name); + setContainerGuildName(name); setSavedName(name); } catch (e: unknown) { LOG.error('error setting guild name', e); @@ -128,21 +133,23 @@ const GuildOverviewDisplay: FC = (props: GuildOvervie saving={saving} saveFailed={saveFailed} errorMessage={errorMessage} infoMessage={infoMessage} > -
-
- -
-
- >} - setValid={setNameInputValid} setMessage={setNameInputMessage} - onEnterKeyDown={saveChanges} - /> +
+
+
+ +
+
+ >} + setValid={setNameInputValid} setMessage={setNameInputMessage} + onEnterKeyDown={saveChanges} + /> +
diff --git a/src/client/webapp/elements/overlays/overlay-guild-settings.tsx b/src/client/webapp/elements/overlays/overlay-guild-settings.tsx index 0c7de6e..e97776f 100644 --- a/src/client/webapp/elements/overlays/overlay-guild-settings.tsx +++ b/src/client/webapp/elements/overlays/overlay-guild-settings.tsx @@ -1,5 +1,4 @@ import React, { FC, useEffect, useState } from "react"; -import { GuildMetadata } from "../../data-types"; import CombinedGuild from "../../guild-combined"; import ChoicesControl from "../components/control-choices"; import GuildInvitesDisplay from "../displays/display-guild-invites"; @@ -17,7 +16,7 @@ const GuildSettingsOverlay: FC = (props: GuildSetting const [ display, setDisplay ] = useState(); useEffect(() => { - if (selectedId === 'overview') setDisplay(); + if (selectedId === 'overview') setDisplay(); //if (selectedId === 'channels') setDisplay(); //if (selectedId === 'roles' ) setDisplay(); if (selectedId === 'invites' ) setDisplay(); diff --git a/src/client/webapp/styles/buttons.scss b/src/client/webapp/styles/buttons.scss index e6789ca..ad42dab 100644 --- a/src/client/webapp/styles/buttons.scss +++ b/src/client/webapp/styles/buttons.scss @@ -2,11 +2,12 @@ /* Buttons */ .button { + display: inline-block; background-color: $brand; color: $header-primary; + cursor: pointer; border-radius: 4px; padding: 8px 24px; - cursor: pointer; &:hover { background-color: $brand-hover; @@ -23,7 +24,7 @@ &.negative { background-color: $background-button-negative; - &:negative { + &:hover { background-color: $background-button-negative-hover; } } @@ -35,4 +36,16 @@ background-color: $background-button-perdu-hover; } } + + &.lower-submit { + padding: 8px 24px; + } + + &.display-popup { + padding: 6px 12px; + } + + &.table-icon { + padding: 4px; + } } diff --git a/src/client/webapp/styles/components.scss b/src/client/webapp/styles/components.scss index b906a5f..72d62e4 100644 --- a/src/client/webapp/styles/components.scss +++ b/src/client/webapp/styles/components.scss @@ -65,6 +65,60 @@ } } +.dropdown-react { + position: relative; + display: inline-block; + + .label { + font-size: 0.75em; + font-weight: bold; + color: $interactive-normal; + text-transform: uppercase; + margin-bottom: 2px; + } + + .display { + background-color: $background-input; + color: $text-normal; + padding: 8px; + border-radius: 3px; + cursor: pointer; + + border: 1px solid $border-input; + } + + &:hover .display { + border-color: $border-input-hover; + } + + &:focus .display { + border-color: $border-input-focus; + } + + .options { + position: absolute; + left: 0; + bottom: 0; + width: 100%; + height: 0; + cursor: pointer; + + &:not(.open) { + display: none; + } + + .option { + padding: 8px; + color: $text-normal; + background-color: $background-dropdown-option; + + &:hover { + background-color: $background-dropdown-option-hover; + } + } + } +} + .display { $content-border-radius: 4px; @@ -76,6 +130,12 @@ > .scroll { margin: 32px; + + .divider { + margin: 24px 0; + height: 1px; + background-color: $background-primary-divider; + } } > .popup { diff --git a/src/client/webapp/styles/overlays.scss b/src/client/webapp/styles/overlays.scss index 73f0027..e3c42e0 100644 --- a/src/client/webapp/styles/overlays.scss +++ b/src/client/webapp/styles/overlays.scss @@ -303,30 +303,72 @@ body > .overlay, } } } + + .display { + .title { + font-weight: 600; + color: $text-normal; + font-size: 1.1em; + margin-bottom: 4px; + } + } } - /* guild Settings Overlay */ + /* guild Settings Overlay*/ > .content.display-swapper.guild-settings { min-width: 350px; - .metadata { - display: flex; - margin-bottom: 12px; - - .name { - margin-left: 16px; - } + .overview { + .metadata { + display: flex; + margin-bottom: 12px; - .guild-name.text-input { - color: $header-primary; - font-weight: 500; - width: 150px; + .name { + margin-left: 16px; + } + + .guild-name.text-input { + color: $header-primary; + font-weight: 500; + width: 150px; + } } } - .button.metadata-submit { - display: inline-block; + .invites { + .create-invite { + .interface { + display: flex; + align-items: flex-start; + + .inputs > :not(:last-child) { + margin-bottom: 8px; + } + } + + .guild-preview { + margin-left: 16px; + } + } + + .view-invites { + color: $text-normal; + + th { + text-transform: uppercase; + text-align: left; + font-size: 0.75em; + } + + td.actions { + display: flex; + + :not(:last-child) { + margin-right: 8px; + } + } + } } } diff --git a/src/client/webapp/styles/theme.scss b/src/client/webapp/styles/theme.scss index 360372e..356ac68 100644 --- a/src/client/webapp/styles/theme.scss +++ b/src/client/webapp/styles/theme.scss @@ -26,11 +26,16 @@ $background-message-hover: rgba(4, 4, 5, 0.07); $background-overlay: rgba(12, 13, 14, 0.75); $background-popup-message: rgba(30, 31, 34, 0.75); +$background-primary-divider: #3f4149; + $background-input: #2f3136; $border-input: #1d1e22; $border-input-hover: #0b0c0e; $border-input-focus: #0099ff; +$background-dropdown-option: #272a2e; +$background-dropdown-option-hover: #1a1c1f; + $channels-default: #8e9297; $channeltextarea-background: #40444b;