intermediate commit

This commit is contained in:
Michael Peters 2021-12-12 15:01:43 -06:00
parent 4b9052a3c5
commit b20943213c
13 changed files with 351 additions and 86 deletions

View File

@ -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<HTMLDivElement>;
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<ButtonProps> = React.forwardRef((props: ButtonProps, ref: Ref<HTMLDivElement>) => {
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(() => {

View File

@ -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<DisplayProps> = (props: DisplayProps) => {
if (errorMessage) {
return (
<DisplayPopup tip={errorMessage}>
<Button type={ButtonType.PERDU} onClick={resetChanges}>Reset</Button>
<Button colorType={ButtonColorType.PERDU} onClick={resetChanges}>Reset</Button>
</DisplayPopup>
);
} else if (infoMessage && infoMessage !== dismissedInfoMessage) {
return (
<DisplayPopup tip={infoMessage}>
<Button type={ButtonType.PERDU} onClick={dismissInfoMessage}>X</Button>
<Button colorType={ButtonColorType.PERDU} onClick={dismissInfoMessage}>X</Button>
</DisplayPopup>
);
} else if (changes) {
return (
<DisplayPopup tip="You have unsaved changes">
<Button type={ButtonType.PERDU} onClick={resetChanges}>Reset</Button>
<Button type={ButtonType.POSITIVE} onClick={saveChanges} shaking={saveButtonShaking}>{changesButtonText}</Button>
<Button colorType={ButtonColorType.PERDU} onClick={resetChanges}>Reset</Button>
<Button colorType={ButtonColorType.POSITIVE} onClick={saveChanges} shaking={saveButtonShaking}>{changesButtonText}</Button>
</DisplayPopup>
);
} else {

View File

@ -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<React.SetStateAction<string>>;
}
const DefaultDropdownInputProps = {
width: 150
}
const DropdownInput: FC<DropdownInputProps> = (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<HTMLDivElement>();
const [ display, setDisplay ] = useState<string>('');
const [ optionsOpen, setOptionsOpen ] = useState<boolean>(false);
useEffect(() => {
// only do this expensive version once
setDisplay(options.find(option => option.value === value)?.display ?? '');
}, []);
const optionElements = useMemo(() => {
return options.map(option => {
return <option key={option.value} value={option.value}>{option.display}</option>
const className = option.value === value ? 'option selected' : 'option';
const onClick = () => {
setValue(option.value);
setDisplay(option.display);
setOptionsOpen(false);
}
return <div className={className} key={option.value} onClick={onClick}>{option.display}</div>
});
}, [ options ]);
const onChange = (e: ChangeEvent<HTMLSelectElement>) => {
setValue(e.target.value);
}
const labelElement = useMemo(() => {
return label && <div className="label">{label}</div>
}, [ 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 (
<div className="dropdown-react">
<select onChange={onChange} value={value}>
<div className="dropdown-react" tabIndex={0} onBlur={onMainBlur}>
{labelElement}
<div
ref={displayRef} className="display"
onClick={onDisplayClick}
style={{ width: width }}
>{display}</div>
<div className={optionsClassName}>
{optionElements}
</select>
</div>
</div>
);
}

View File

@ -8,11 +8,9 @@ import React, { FC, Ref, useEffect, useMemo } from 'react';
export interface TextInputProps {
ref?: Ref<HTMLInputElement>;
label: string;
label?: string;
placeholder?: string;
noLabel?: boolean;
allowEmpty?: boolean;
maxLength: number;
@ -27,7 +25,7 @@ export interface TextInputProps {
}
const TextInput: FC<TextInputProps> = React.forwardRef((props: TextInputProps, ref: Ref<HTMLInputElement>) => {
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<TextInputProps> = React.forwardRef((props: TextInputProps, r
}, [ value ]);
const labelElement = useMemo(() => {
return label && !noLabel && <div className="label">{label}</div>
return label && <div className="label">{label}</div>
}, [ label ]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {

View File

@ -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<InvitesTableProps> = (props: InvitesTableProps) => {
const { guild } = props;
const fetchData = useCallback(async () => await guild.fetchTokens(), [ guild ]);
const header = (
<tr>
<th>Created</th>
<th>Expires</th>
<th>Actions</th>
</tr>
);
const mapToRow = useCallback((token: Token) => {
return (
<tr key={token.token}>
<td>{moment(token.created).fromNow()}</td>
<td>{moment(token.expires).fromNow()}</td>
<td className="actions">
<Button colorType={ButtonColorType.POSITIVE}>Save</Button>
<Button colorType={ButtonColorType.NEGATIVE}>Delete</Button>
</td>
</tr>
);
}, []);
return <Table header={header} fetchData={fetchData} mapToRow={mapToRow} />
}
export default InvitesTable;

View File

@ -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<T> {
header: JSX.Element;
fetchData: () => Promise<T[]>;
mapToRow: (data: T) => JSX.Element;
}
export default function Table<T>(props: TableProps<T>) {
const { header, fetchData, mapToRow } = props;
// TODO: Loading indicator + fetch failed indicator
const [ dataRows, setDataRows ] = useState<JSX.Element[]>([]);
const [ loading, setLoading ] = useState<boolean>(true);
const [ fetchFailed, setFetchFailed ] = useState<boolean>(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 (
<table>
<thead>{header}</thead>
<tbody>{dataRows}</tbody>
</table>
)
}

View File

@ -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<GuildInvitesDisplayProps> = (props: GuildInvitesDi
const [ guildMetaFailed, setGuildMetaFailed ] = useState<boolean>(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<GuildInvitesDisplayProps> = (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<GuildInvitesDisplayProps> = (props: GuildInvitesDi
<Display
infoMessage={null} errorMessage={errorMessage}
>
<div className="section-title">Create Invite</div>
<div className="create-invite">
<div className="actions">
<DropdownInput value={expiresFromNowText} setValue={setExpiresFromNowText} options={[
{ value: '1 day', display: 'Expires in 1 Day' },
{ value: '1 week', display: 'Expires in 1 Week' },
{ value: '1 month', display: 'Expires in 1 Month' },
{ value: '1 year', display: 'Expires in 1 Year' },
{ value: 'never', display: 'Never expires' },
]} />
<div className="invites">
<div className="create-invite">
<div className="title">Create Invite</div>
<div className="interface">
<div className="inputs">
<DropdownInput label="Expires In" value={expiresFromNowText} setValue={setExpiresFromNowText} options={[
{ value: '1 day', display: 'a Day' },
{ value: '1 week', display: 'a Week' },
{ value: '1 month', display: 'a Month' },
{ value: 'never', display: 'Never' },
]} />
<div><Button>Create Invite</Button></div>
</div>
<InvitePreview
name={guildName ?? ''} iconSrc={iconSrc}
url={url} expiresFromNow={expiresFromNow}
/>
</div>
</div>
<div className="divider" />
<div className="view-invites">
<div className="title">Active Invites</div>
<InvitesTable guild={guild} />
</div>
<InvitePreview
name={guildName ?? ''} iconSrc={iconSrc}
url={url} expiresFromNow={expiresFromNow}
/>
</div>
</Display>
)

View File

@ -15,10 +15,10 @@ import ReactHelper from '../require/react-helper';
export interface GuildOverviewDisplayProps {
guild: CombinedGuild;
setGuildName: React.Dispatch<React.SetStateAction<string>>; // to allow overlay title to update
setContainerGuildName: React.Dispatch<React.SetStateAction<string>>; // to allow overlay title to update
}
const GuildOverviewDisplay: FC<GuildOverviewDisplayProps> = (props: GuildOverviewDisplayProps) => {
const { guild } = props;
const { guild, setContainerGuildName } = props;
const [ iconResourceId, setIconResourceId ] = useState<string | null>(null);
@ -43,11 +43,15 @@ const GuildOverviewDisplay: FC<GuildOverviewDisplayProps> = (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('<unknown>');
}
})
ReactHelper.useNullableResourceEffect({
@ -95,6 +99,7 @@ const GuildOverviewDisplay: FC<GuildOverviewDisplayProps> = (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<GuildOverviewDisplayProps> = (props: GuildOvervie
saving={saving} saveFailed={saveFailed}
errorMessage={errorMessage} infoMessage={infoMessage}
>
<div className="metadata">
<div className="icon">
<ImageEditInput
maxSize={Globals.MAX_GUILD_ICON_SIZE} value={iconBuff} setValue={setIconBuff}
setValid={setIconInputValid} setMessage={setIconInputMessage}
/>
</div>
<div className="name">
<TextInput
label={'Guild Name'} placeholder={savedName ?? 'Guild Name'}
maxLength={Globals.MAX_GUILD_NAME_LENGTH}
value={name ?? ''} setValue={setName as Dispatch<SetStateAction<string>>}
setValid={setNameInputValid} setMessage={setNameInputMessage}
onEnterKeyDown={saveChanges}
/>
<div className="overview">
<div className="metadata">
<div className="icon">
<ImageEditInput
maxSize={Globals.MAX_GUILD_ICON_SIZE} value={iconBuff} setValue={setIconBuff}
setValid={setIconInputValid} setMessage={setIconInputMessage}
/>
</div>
<div className="name">
<TextInput
label={'Guild Name'} placeholder={savedName ?? 'Guild Name'}
maxLength={Globals.MAX_GUILD_NAME_LENGTH}
value={name ?? ''} setValue={setName as Dispatch<SetStateAction<string>>}
setValid={setNameInputValid} setMessage={setNameInputMessage}
onEnterKeyDown={saveChanges}
/>
</div>
</div>
</div>
</Display>

View File

@ -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<GuildSettingsOverlayProps> = (props: GuildSetting
const [ display, setDisplay ] = useState<JSX.Element>();
useEffect(() => {
if (selectedId === 'overview') setDisplay(<GuildOverviewDisplay guild={guild} setGuildName={setGuildName} />);
if (selectedId === 'overview') setDisplay(<GuildOverviewDisplay guild={guild} setContainerGuildName={setGuildName} />);
//if (selectedId === 'channels') setDisplay(<GuildOverviewDisplay guild={guild} guildMeta={guildMeta} />);
//if (selectedId === 'roles' ) setDisplay(<GuildOverviewDisplay guild={guild} guildMeta={guildMeta} />);
if (selectedId === 'invites' ) setDisplay(<GuildInvitesDisplay guild={guild} />);

View File

@ -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;
}
}

View File

@ -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 {

View File

@ -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;
}
}
}
}
}

View File

@ -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;