useSubscription techniques for react
This commit is contained in:
parent
b20943213c
commit
2ef1af3eff
@ -1,9 +1,14 @@
|
||||
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 moment from 'moment';
|
||||
import React, { FC, useCallback } from 'react';
|
||||
import { Token } from '../../data-types';
|
||||
import React, { FC, useEffect, useMemo, useState } from 'react';
|
||||
import { Member, Token } from '../../data-types';
|
||||
import CombinedGuild from '../../guild-combined';
|
||||
import BaseElements from '../require/base-elements';
|
||||
import Button, { ButtonColorType } from './button';
|
||||
import Table from './table';
|
||||
|
||||
export interface InvitesTableProps {
|
||||
guild: CombinedGuild
|
||||
@ -12,28 +17,47 @@ export interface InvitesTableProps {
|
||||
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>
|
||||
);
|
||||
}, []);
|
||||
const [ tokens, setTokens ] = useState<Token[] | null>(null);
|
||||
const [ tokensFailed, setTokensFailed ] = useState<boolean>(false);
|
||||
|
||||
return <Table header={header} fetchData={fetchData} mapToRow={mapToRow} />
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const tokens = await guild.fetchTokens();
|
||||
setTokens(tokens);
|
||||
} catch (e: unknown) {
|
||||
LOG.error('unable to fetch tokens', e);
|
||||
setTokensFailed(true);
|
||||
}
|
||||
})();
|
||||
}, [])
|
||||
|
||||
const tokenElements = useMemo(() => {
|
||||
if (tokensFailed) {
|
||||
return <div className="tokens-failed">Unable to load tokens</div>;
|
||||
}
|
||||
return tokens?.map((token: Token) => {
|
||||
const userText = (token.member instanceof Member ? 'Used by ' + token.member.displayName : token.member?.id) ?? 'Unused Token';
|
||||
return (
|
||||
<div key={token.token} className="token-row">
|
||||
<div className="user-token">
|
||||
<div className="user">{userText}</div>
|
||||
<div className="token">{token.token}</div>
|
||||
</div>
|
||||
<div className="created-expires">
|
||||
<div className="created">Created {moment(token.created).fromNow()}</div>
|
||||
<div className="expires">Expires {moment(token.expires).fromNow()}</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<Button colorType={ButtonColorType.BRAND}>{BaseElements.DOWNLOAD}</Button>
|
||||
<Button colorType={ButtonColorType.NEGATIVE}>{BaseElements.TRASHCAN}</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}, [ tokens, tokensFailed ]);
|
||||
|
||||
return <div className="invites-table">{tokenElements}</div>
|
||||
}
|
||||
|
||||
export default InvitesTable;
|
||||
|
@ -15,6 +15,8 @@ import moment from 'moment';
|
||||
import DropdownInput from '../components/input-dropdown';
|
||||
import Button from '../components/button';
|
||||
import InvitesTable from '../components/table-invites';
|
||||
import GuildSubscriptions from '../require/guild-subscriptions';
|
||||
import ElementsUtil from '../require/elements-util';
|
||||
|
||||
|
||||
export interface GuildInvitesDisplayProps {
|
||||
@ -25,30 +27,16 @@ const GuildInvitesDisplay: FC<GuildInvitesDisplayProps> = (props: GuildInvitesDi
|
||||
|
||||
const url = 'https://localhost:3030'; // TODO: this will likely be a dropdown list at some point
|
||||
|
||||
const [ guildName, setGuildName ] = useState<string | null>(null);
|
||||
const [ iconResourceId, setIconResourceId ] = useState<string | null>(null);
|
||||
|
||||
const [ iconSrc, setIconSrc ] = useState<string | null>(null);
|
||||
const [ guildMeta, guildMetaError ] = GuildSubscriptions.useGuildMetadataSubscription(guild);
|
||||
const [ iconSrc ] = ReactHelper.useAsyncActionSubscription(
|
||||
async () => await ElementsUtil.getImageSrcFromResourceFailSoftly(guild, guildMeta?.iconResourceId ?? null),
|
||||
'./img/loading.svg',
|
||||
[ guild, guildMeta?.iconResourceId ]
|
||||
);
|
||||
|
||||
const [ expiresFromNow, setExpiresFromNow ] = useState<Duration | null>(moment.duration(1, 'day'));
|
||||
const [ expiresFromNowText, setExpiresFromNowText ] = useState<string>('1 day');
|
||||
|
||||
const [ guildMetaFailed, setGuildMetaFailed ] = useState<boolean>(false);
|
||||
|
||||
ReactHelper.useGuildMetadataEffect({
|
||||
guild,
|
||||
onSuccess: (guildMeta: GuildMetadata) => {
|
||||
setGuildName(guildMeta.name);
|
||||
setIconResourceId(guildMeta.iconResourceId);
|
||||
},
|
||||
onError: () => setGuildMetaFailed(true)
|
||||
});
|
||||
|
||||
ReactHelper.useSoftImageSrcResourceEffect({
|
||||
guild, resourceId: iconResourceId,
|
||||
onSuccess: setIconSrc
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (expiresFromNowText === 'never') {
|
||||
setExpiresFromNow(null);
|
||||
@ -60,9 +48,9 @@ const GuildInvitesDisplay: FC<GuildInvitesDisplayProps> = (props: GuildInvitesDi
|
||||
}, [ expiresFromNowText ]);
|
||||
|
||||
const errorMessage = useMemo(() => {
|
||||
if (guildMetaFailed) return 'Unable to load guild metadata';
|
||||
if (guildMetaError) return 'Unable to load guild metadata';
|
||||
return null;
|
||||
}, [ guildMetaFailed ]);
|
||||
}, [ guildMetaError ]);
|
||||
|
||||
return (
|
||||
<Display
|
||||
@ -82,14 +70,14 @@ const GuildInvitesDisplay: FC<GuildInvitesDisplayProps> = (props: GuildInvitesDi
|
||||
<div><Button>Create Invite</Button></div>
|
||||
</div>
|
||||
<InvitePreview
|
||||
name={guildName ?? ''} iconSrc={iconSrc}
|
||||
name={guildMeta?.name ?? ''} iconSrc={iconSrc}
|
||||
url={url} expiresFromNow={expiresFromNow}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="divider" />
|
||||
<div className="view-invites">
|
||||
<div className="title">Active Invites</div>
|
||||
<div className="title">Invite History</div>
|
||||
<InvitesTable guild={guild} />
|
||||
</div>
|
||||
</div>
|
||||
|
@ -12,15 +12,16 @@ import Display from '../components/display';
|
||||
import TextInput from '../components/input-text';
|
||||
import ImageEditInput from '../components/input-image-edit';
|
||||
import ReactHelper from '../require/react-helper';
|
||||
import GuildSubscriptions from '../require/guild-subscriptions';
|
||||
|
||||
export interface GuildOverviewDisplayProps {
|
||||
guild: CombinedGuild;
|
||||
setContainerGuildName: React.Dispatch<React.SetStateAction<string>>; // to allow overlay title to update
|
||||
}
|
||||
const GuildOverviewDisplay: FC<GuildOverviewDisplayProps> = (props: GuildOverviewDisplayProps) => {
|
||||
const { guild, setContainerGuildName } = props;
|
||||
const { guild } = props;
|
||||
|
||||
const [ iconResourceId, setIconResourceId ] = useState<string | null>(null);
|
||||
const [ guildMeta, guildMetaError ] = GuildSubscriptions.useGuildMetadataSubscription(guild);
|
||||
const [ iconResource, iconResourceError ] = GuildSubscriptions.useResourceSubscription(guild, guildMeta?.iconResourceId ?? null);
|
||||
|
||||
const [ savedName, setSavedName ] = useState<string | null>(null);
|
||||
const [ savedIconBuff, setSavedIconBuff ] = useState<Buffer | null>(null);
|
||||
@ -29,9 +30,6 @@ const GuildOverviewDisplay: FC<GuildOverviewDisplayProps> = (props: GuildOvervie
|
||||
const [ iconBuff, setIconBuff ] = useState<Buffer | null>(null);
|
||||
|
||||
const [ saving, setSaving ] = useState<boolean>(false);
|
||||
|
||||
const [ guildMetaFailed, setGuildMetaFailed ] = useState<boolean>(false);
|
||||
const [ iconFailed, setIconFailed ] = useState<boolean>(false);
|
||||
const [ saveFailed, setSaveFailed ] = useState<boolean>(false);
|
||||
|
||||
const [ nameInputValid, setNameInputValid ] = useState<boolean>(false);
|
||||
@ -40,42 +38,31 @@ const GuildOverviewDisplay: FC<GuildOverviewDisplayProps> = (props: GuildOvervie
|
||||
const [ iconInputValid, setIconInputValid ] = useState<boolean>(false);
|
||||
const [ iconInputMessage, setIconInputMessage ] = useState<string | null>(null);
|
||||
|
||||
ReactHelper.useGuildMetadataEffect({
|
||||
guild,
|
||||
onSuccess: (guildMeta: GuildMetadata) => {
|
||||
setContainerGuildName(guildMeta.name);
|
||||
useEffect(() => {
|
||||
if (guildMeta) {
|
||||
if (name === savedName) setName(guildMeta.name);
|
||||
setSavedName(guildMeta.name);
|
||||
setName(guildMeta.name);
|
||||
setIconResourceId(guildMeta.iconResourceId);
|
||||
},
|
||||
onError: () => {
|
||||
setGuildMetaFailed(true);
|
||||
setContainerGuildName('<unknown>');
|
||||
}
|
||||
})
|
||||
}, [ guildMeta ]);
|
||||
|
||||
ReactHelper.useNullableResourceEffect({
|
||||
guild, resourceId: iconResourceId,
|
||||
onSuccess: (resource) => {
|
||||
setSavedIconBuff(resource?.data ?? null);
|
||||
setIconBuff(resource?.data ?? null);
|
||||
},
|
||||
onError: () => {
|
||||
setIconFailed(true);
|
||||
useEffect(() => {
|
||||
if (iconResource) {
|
||||
if (iconBuff === savedIconBuff) setIconBuff(iconResource.data);
|
||||
setSavedIconBuff(iconResource.data);
|
||||
}
|
||||
});
|
||||
}, [ iconResource ]);
|
||||
|
||||
const changes = useMemo(() => {
|
||||
return name !== savedName || iconBuff?.toString('hex') !== savedIconBuff?.toString('hex')
|
||||
}, [ name, savedName, iconBuff, savedIconBuff ]);
|
||||
|
||||
const errorMessage = useMemo(() => {
|
||||
if (guildMetaFailed) return 'Unable to load guild metadata';
|
||||
if (iconFailed) return 'Unable to load icon';
|
||||
if (guildMetaError) return 'Unable to load guild metadata';
|
||||
if (iconResourceError) return 'Unable to load icon';
|
||||
if (!iconInputValid && iconInputMessage) return iconInputMessage;
|
||||
if (!nameInputValid && nameInputMessage) return nameInputMessage;
|
||||
return null;
|
||||
}, [ iconFailed, iconInputValid, iconInputMessage, nameInputValid, nameInputMessage ]);
|
||||
}, [ guildMetaError, iconResourceError, iconInputValid, iconInputMessage, nameInputValid, nameInputMessage ]);
|
||||
|
||||
const infoMessage = useMemo(() => {
|
||||
if (iconInputValid && iconInputMessage) return iconInputMessage;
|
||||
@ -99,7 +86,6 @@ 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);
|
||||
|
@ -16,6 +16,7 @@ import CombinedGuild from '../../guild-combined';
|
||||
import ElementsUtil from '../require/elements-util';
|
||||
import InvitePreview from '../components/invite-preview';
|
||||
import ReactHelper from '../require/react-helper';
|
||||
import * as fs from 'fs/promises';
|
||||
|
||||
export interface IAddGuildData {
|
||||
name: string,
|
||||
@ -71,7 +72,6 @@ const AddGuildOverlay: FC<AddGuildOverlayProps> = (props: AddGuildOverlayProps)
|
||||
const [ displayName, setDisplayName ] = useState<string>(exampleDisplayName);
|
||||
const [ avatarBuff, setAvatarBuff ] = useState<Buffer | null>(null);
|
||||
|
||||
const [ exampleAvatarFailed, setExampleAvatarFailed ] = useState<boolean>(false);
|
||||
const [ addGuildFailedMessage, setAddGuildFailedMessage ] = useState<string | null>(null);
|
||||
|
||||
const [ displayNameInputMessage, setDisplayNameInputMessage ] = useState<string | null>(null);
|
||||
@ -80,19 +80,27 @@ const AddGuildOverlay: FC<AddGuildOverlayProps> = (props: AddGuildOverlayProps)
|
||||
const [ avatarInputMessage, setAvatarInputMessage ] = useState<string | null>(null);
|
||||
const [ avatarInputValid, setAvatarInputValid ] = useState<boolean>(false);
|
||||
|
||||
ReactHelper.useBufferFileEffect({
|
||||
filePath: exampleAvatarPath,
|
||||
onSuccess: (buff) => { setAvatarBuff(buff); setExampleAvatarFailed(false); },
|
||||
onError: () => { setExampleAvatarFailed(true); }
|
||||
});
|
||||
const [ exampleAvatarBuff, exampleAvatarBuffError ] = ReactHelper.useAsyncActionSubscription(
|
||||
async () => {
|
||||
return await fs.readFile(exampleAvatarPath);
|
||||
},
|
||||
null,
|
||||
[ exampleAvatarPath ]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (exampleAvatarBuff) {
|
||||
if (avatarBuff === null) setAvatarBuff(exampleAvatarBuff);
|
||||
}
|
||||
}, [ exampleAvatarBuff ]);
|
||||
|
||||
const errorMessage = useMemo(() => {
|
||||
if (exampleAvatarFailed && !avatarBuff) return 'Unable to load example avatar';
|
||||
if (exampleAvatarBuffError && !avatarBuff) return 'Unable to load example avatar';
|
||||
if ( !avatarInputValid && avatarInputMessage) return avatarInputMessage;
|
||||
if (!displayNameInputValid && displayNameInputMessage) return displayNameInputMessage;
|
||||
if (addGuildFailedMessage !== null) return addGuildFailedMessage;
|
||||
return null;
|
||||
}, [ exampleAvatarFailed, avatarBuff, displayNameInputValid, displayNameInputMessage, avatarInputValid, avatarInputMessage, addGuildFailedMessage ]);
|
||||
}, [ exampleAvatarBuffError, avatarBuff, displayNameInputValid, displayNameInputMessage, avatarInputValid, avatarInputMessage, addGuildFailedMessage ]);
|
||||
|
||||
const doSubmit = useCallback(async (): Promise<boolean> => {
|
||||
if (!displayNameInputValid || !avatarInputValid || avatarBuff === null) {
|
||||
|
@ -1,8 +1,9 @@
|
||||
import React, { FC, useEffect, useState } from "react";
|
||||
import React, { FC, useEffect, useMemo, useState } from "react";
|
||||
import CombinedGuild from "../../guild-combined";
|
||||
import ChoicesControl from "../components/control-choices";
|
||||
import GuildInvitesDisplay from "../displays/display-guild-invites";
|
||||
import GuildOverviewDisplay from "../displays/display-guild-overview";
|
||||
import GuildSubscriptions from "../require/guild-subscriptions";
|
||||
|
||||
export interface GuildSettingsOverlayProps {
|
||||
guild: CombinedGuild;
|
||||
@ -10,21 +11,25 @@ export interface GuildSettingsOverlayProps {
|
||||
const GuildSettingsOverlay: FC<GuildSettingsOverlayProps> = (props: GuildSettingsOverlayProps) => {
|
||||
const { guild } = props;
|
||||
|
||||
const [ guildName, setGuildName ] = useState<string>('');
|
||||
const [ guildMeta, guildMetaError ] = GuildSubscriptions.useGuildMetadataSubscription(guild);
|
||||
|
||||
const [ selectedId, setSelectedId ] = useState<string>('overview');
|
||||
const [ display, setDisplay ] = useState<JSX.Element>();
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedId === 'overview') setDisplay(<GuildOverviewDisplay guild={guild} setContainerGuildName={setGuildName} />);
|
||||
if (selectedId === 'overview') setDisplay(<GuildOverviewDisplay guild={guild} />);
|
||||
//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} />);
|
||||
}, [ selectedId ]);
|
||||
|
||||
const guildNameText = useMemo(() => {
|
||||
return guildMetaError ? 'metadata error' : guildMeta?.name ?? 'loading...';
|
||||
}, [ guildMeta, guildMetaError ])
|
||||
|
||||
return (
|
||||
<div className="content display-swapper guild-settings">
|
||||
<ChoicesControl title={guildName} selectedId={selectedId} setSelectedId={setSelectedId} choices={[
|
||||
<ChoicesControl title={guildNameText} selectedId={selectedId} setSelectedId={setSelectedId} choices={[
|
||||
{ id: 'overview', display: 'Overview' },
|
||||
{ id: 'channels', display: 'Channels' },
|
||||
{ id: 'roles', display: 'Roles' },
|
||||
|
@ -13,6 +13,7 @@ import DownloadButton from '../components/button-download';
|
||||
import createImageContextMenu from '../context-menu-img';
|
||||
import Q from '../../q-module';
|
||||
import ReactHelper from '../require/react-helper';
|
||||
import GuildSubscriptions from '../require/guild-subscriptions';
|
||||
|
||||
export interface ImageOverlayProps {
|
||||
guild: CombinedGuild
|
||||
@ -23,44 +24,28 @@ export interface ImageOverlayProps {
|
||||
const ImageOverlay: FC<ImageOverlayProps> = (props: ImageOverlayProps) => {
|
||||
const { guild, resourceId, resourceName } = props;
|
||||
|
||||
const [ resource, setResource ] = useState<Resource | null>(null);
|
||||
const [ resourceImgSrc, setResourceImgSrc ] = useState<string>('./img/loading.svg');
|
||||
const [ resourceErr, setResourceErr ] = useState<boolean>(false);
|
||||
const [ mime, setMime ] = useState<string | null>(null);
|
||||
const [ ext, setExt ] = useState<string | null>(null);
|
||||
|
||||
ReactHelper.useResourceEffect({
|
||||
guild, resourceId,
|
||||
onSuccess: async (newResource: Resource) => {
|
||||
setResource(newResource);
|
||||
const { mime, ext } = (await FileType.fromBuffer(newResource.data)) ?? { mime: null, ext: null };
|
||||
if (mime === null || ext === null) throw new Error('unable to get mime/ext');
|
||||
if (resource === newResource) {
|
||||
setMime(mime);
|
||||
setExt(ext);
|
||||
}
|
||||
const [ resource, resourceError ] = GuildSubscriptions.useResourceSubscription(guild, resourceId);
|
||||
const [ resourceImgSrc, resourceImgSrcError ] = ReactHelper.useAsyncActionSubscription(
|
||||
async () => await ElementsUtil.getImageSrcFromBufferFailSoftly(resource?.data ?? null),
|
||||
'./img/loading.svg',
|
||||
[ guild, resource ]
|
||||
)
|
||||
const [ resourceFileTypeInfo, resourceFileTypeInfoError ] = ReactHelper.useAsyncActionSubscription(
|
||||
async () => {
|
||||
if (!resource) return null;
|
||||
const fileTypeInfo = (await FileType.fromBuffer(resource.data)) ?? null;
|
||||
if (fileTypeInfo === null) throw new Error('unable to get mime/ext');
|
||||
return fileTypeInfo;
|
||||
},
|
||||
onError: () => {
|
||||
setResource(null);
|
||||
setResourceImgSrc('./img/error.png');
|
||||
setResourceErr(true);
|
||||
}
|
||||
});
|
||||
|
||||
ReactHelper.useImageSrcBufferEffect({
|
||||
buffer: resource?.data ?? null,
|
||||
onSuccess: setResourceImgSrc,
|
||||
onError: () => {
|
||||
setResource(null);
|
||||
setResourceImgSrc('./img/error.png');
|
||||
setResourceErr(true);
|
||||
}
|
||||
});
|
||||
null,
|
||||
[ resource ]
|
||||
);
|
||||
|
||||
const onImageContextMenu = (e: React.MouseEvent) => {
|
||||
// TODO: This should be in react!
|
||||
if (!resource) return;
|
||||
const contextMenu = createImageContextMenu(document, new Q(document), guild, resourceName, resource.data, mime as string, ext as string, false);
|
||||
if (!resourceFileTypeInfo) return;
|
||||
const contextMenu = createImageContextMenu(document, new Q(document), guild, resourceName, resource.data, resourceFileTypeInfo.mime, resourceFileTypeInfo.ext, false);
|
||||
document.body.appendChild(contextMenu);
|
||||
const relativeTo = { x: e.pageX, y: e.pageY };
|
||||
ElementsUtil.alignContextElement(contextMenu, relativeTo, { top: 'centerY', left: 'right' });
|
||||
@ -77,7 +62,7 @@ const ImageOverlay: FC<ImageOverlayProps> = (props: ImageOverlayProps) => {
|
||||
<div className="size">{sizeText}</div>
|
||||
</div>
|
||||
<DownloadButton
|
||||
downloadBuff={resource?.data} downloadBuffErr={resourceErr}
|
||||
downloadBuff={resource?.data} downloadBuffErr={!!resourceError}
|
||||
resourceName={resourceName}></DownloadButton>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -13,6 +13,7 @@ import TextInput from '../components/input-text';
|
||||
import SubmitOverlayLower from '../components/submit-overlay-lower';
|
||||
import ElementsUtil from '../require/elements-util';
|
||||
import ReactHelper from '../require/react-helper';
|
||||
import GuildSubscriptions from '../require/guild-subscriptions';
|
||||
|
||||
export interface PersonalizeOverlayProps {
|
||||
document: Document;
|
||||
@ -26,7 +27,7 @@ const PersonalizeOverlay: FC<PersonalizeOverlayProps> = (props: PersonalizeOverl
|
||||
throw new Error('bad avatar');
|
||||
}
|
||||
|
||||
const avatarResourceId = connection.avatarResourceId;
|
||||
const [ avatarResource, avatarResourceError ] = GuildSubscriptions.useResourceSubscription(guild, connection.avatarResourceId)
|
||||
|
||||
const displayNameInputRef = createRef<HTMLInputElement>();
|
||||
|
||||
@ -40,7 +41,6 @@ const PersonalizeOverlay: FC<PersonalizeOverlayProps> = (props: PersonalizeOverl
|
||||
const [ displayName, setDisplayName ] = useState<string>(connection.displayName);
|
||||
const [ avatarBuff, setAvatarBuff ] = useState<Buffer | null>(null);
|
||||
|
||||
const [ loadAvatarFailed, setLoadAvatarFailed ] = useState<boolean>(false);
|
||||
const [ saveFailed, setSaveFailed ] = useState<boolean>(false);
|
||||
|
||||
const [ displayNameInputValid, setDisplayNameInputValid ] = useState<boolean>(false);
|
||||
@ -49,27 +49,24 @@ const PersonalizeOverlay: FC<PersonalizeOverlayProps> = (props: PersonalizeOverl
|
||||
const [ avatarInputValid, setAvatarInputValid ] = useState<boolean>(false);
|
||||
const [ avatarInputMessage, setAvatarInputMessage ] = useState<string | null>(null);
|
||||
|
||||
ReactHelper.useResourceEffect({
|
||||
guild, resourceId: avatarResourceId,
|
||||
onSuccess: (resource) => {
|
||||
setSavedAvatarBuff(resource.data);
|
||||
setAvatarBuff(resource.data);
|
||||
setLoadAvatarFailed(false);
|
||||
},
|
||||
onError: () => { setLoadAvatarFailed(true); }
|
||||
});
|
||||
useEffect(() => {
|
||||
if (avatarResource) {
|
||||
if (avatarBuff === savedAvatarBuff) setAvatarBuff(avatarResource.data);
|
||||
setSavedAvatarBuff(avatarResource.data);
|
||||
}
|
||||
}, [ avatarResource ]);
|
||||
|
||||
useEffect(() => {
|
||||
displayNameInputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const errorMessage = useMemo(() => {
|
||||
if (loadAvatarFailed) return 'Unable to load avatar';
|
||||
if (avatarResourceError) return 'Unable to load avatar';
|
||||
if ( !avatarInputValid && avatarInputMessage) return avatarInputMessage;
|
||||
if (!displayNameInputValid && displayNameInputMessage) return displayNameInputMessage;
|
||||
if (saveFailed) return 'Unable to save personalization';
|
||||
return null;
|
||||
}, [ saveFailed, loadAvatarFailed, displayNameInputValid, displayNameInputMessage, avatarInputValid, avatarInputMessage ]);
|
||||
}, [ saveFailed, avatarResourceError, displayNameInputValid, displayNameInputMessage, avatarInputValid, avatarInputMessage ]);
|
||||
|
||||
const infoMessage = useMemo(() => {
|
||||
if (avatarInputValid && avatarInputMessage) return avatarInputMessage;
|
||||
@ -98,7 +95,7 @@ const PersonalizeOverlay: FC<PersonalizeOverlayProps> = (props: PersonalizeOverl
|
||||
await guild.requestSetAvatar(avatarBuff);
|
||||
setSavedAvatarBuff(avatarBuff);
|
||||
} catch (e: unknown) {
|
||||
LOG.error('error setting guild icon', e);
|
||||
LOG.error('error setting avatar', e);
|
||||
setSaveFailed(true);
|
||||
return false;
|
||||
}
|
||||
|
@ -143,6 +143,15 @@ export default class BaseElements {
|
||||
4.00,7.10 4.90,8.00 6.00,8.00 Z` }
|
||||
};
|
||||
|
||||
static TRASHCAN = (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 64 64">
|
||||
<path fill="currentColor" fillRule="evenodd" clipRule="evenodd"
|
||||
d="M 11 12 C 9.338 12 8 13.338 8 15 L 8 61 C 8 62.662 9.338 64 11 64 L 53 64 C 54.662 64 56 62.662 56 61 L 56 15 C 56 13.338 54.662 12 53 12 L 11 12 z M 17 18 C 18.662 18 20 19.338 20 21 L 20 55 C 20 56.662 18.662 58 17 58 C 15.338 58 14 56.662 14 55 L 14 21 C 14 19.338 15.338 18 17 18 z M 32 18 C 33.662 18 35 19.338 35 21 L 35 55 C 35 56.662 33.662 58 32 58 C 30.338 58 29 56.662 29 55 L 29 21 C 29 19.338 30.338 18 32 18 z M 47 18 C 48.662 18 50 19.338 50 21 L 50 55 C 50 56.662 48.662 58 47 58 C 45.338 58 44 56.662 44 55 L 44 21 C 44 19.338 45.338 18 47 18 z"
|
||||
></path>
|
||||
<rect fill="currentColor" fillRule="evenodd" clipRule="evenodd" width="56" height="6" x="4" y="3" rx="3" ry="3" />
|
||||
<rect fill="currentColor" fillRule="evenodd" clipRule="evenodd" width="16" height="6" x="24" y="0" rx="3" ry="3" />
|
||||
</svg>
|
||||
);
|
||||
static Q_TRASHCAN = {
|
||||
ns: 'http://www.w3.org/2000/svg', tag: 'svg', width: 16, height: 16, viewBox: '0 0 64 64', content: [
|
||||
{ ns: 'http://www.w3.org/2000/svg', tag: 'path', fill: 'currentColor', 'fill-rule': 'evenodd', 'clip-rule': 'evenodd',
|
||||
@ -154,6 +163,13 @@ export default class BaseElements {
|
||||
]
|
||||
}
|
||||
|
||||
static DOWNLOAD = (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 64 64">
|
||||
<path fill="currentColor" fillRule="evenodd" clipRule="evenodd"
|
||||
d="M 32 0 A 32 32 0 0 0 0 32 A 32 32 0 0 0 32 64 A 32 32 0 0 0 64 32 A 32 32 0 0 0 32 0 z M 27 11.5 L 37 11.5 L 37 39.5 L 46 39.5 L 32 52.5 L 18 39.5 L 27 39.5 L 27 11.5 z"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
static Q_DOWNLOAD = {
|
||||
ns: 'http://www.w3.org/2000/svg', tag: 'svg', width: 16, height: 16, viewBox: '0 0 64 64', content: [
|
||||
{ ns: 'http://www.w3.org/2000/svg', tag: 'path', fill: 'currentColor', 'fill-rule': 'evenodd', 'clip-rule': 'evenodd',
|
||||
|
@ -135,7 +135,10 @@ export default class ElementsUtil {
|
||||
}
|
||||
}
|
||||
|
||||
static async getImageSrcFromBufferFailSoftly(buff: Buffer): Promise<string> {
|
||||
static async getImageSrcFromBufferFailSoftly(buff: Buffer | null): Promise<string> {
|
||||
if (buff === null) {
|
||||
return './img/loading.svg';
|
||||
}
|
||||
try {
|
||||
const src = await ElementsUtil.getImageBufferSrc(buff);
|
||||
return src;
|
||||
@ -146,9 +149,8 @@ export default class ElementsUtil {
|
||||
}
|
||||
|
||||
static async getImageSrcFromResourceFailSoftly(guild: CombinedGuild, resourceId: string | null): Promise<string> {
|
||||
if (!resourceId) {
|
||||
LOG.warn('no guild resource specified, showing error instead', new Error());
|
||||
return './img/error.png';
|
||||
if (resourceId === null) {
|
||||
return './img/loading.svg';
|
||||
}
|
||||
try {
|
||||
const resource = await guild.fetchResource(resourceId);
|
||||
|
158
src/client/webapp/elements/require/guild-subscriptions.ts
Normal file
158
src/client/webapp/elements/require/guild-subscriptions.ts
Normal file
@ -0,0 +1,158 @@
|
||||
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 { GuildMetadata, Resource } from "../../data-types";
|
||||
import CombinedGuild from "../../guild-combined";
|
||||
|
||||
import React, { DependencyList, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { AutoVerifierChangesType } from "../../auto-verifier";
|
||||
import { Conflictable, Connectable } from "../../guild-types";
|
||||
import { EventEmitter } from 'tsee';
|
||||
import { IDQuery } from '../../auto-verifier-with-args';
|
||||
|
||||
export type SubscriptionEvents = {
|
||||
'fetch': () => void;
|
||||
'update': () => void;
|
||||
'conflict': () => void;
|
||||
'fetch-error': () => void;
|
||||
}
|
||||
|
||||
interface EffectParams<T> {
|
||||
guild: CombinedGuild;
|
||||
onFetch: (value: T | null) => void;
|
||||
onUpdate: (value: T) => void;
|
||||
onConflict: (value: T) => void;
|
||||
onFetchError: (e: unknown) => void;
|
||||
}
|
||||
|
||||
type Arguments<T> = T extends (...args: infer A) => unknown ? A : never;
|
||||
|
||||
interface EventMappingParams<T, UE extends keyof Connectable, CE extends keyof Conflictable> {
|
||||
updateEventName: UE;
|
||||
updateEventArgsMap: (...args: Arguments<Connectable[UE]>) => T; // should be same as the params list from Connectable
|
||||
conflictEventName: CE;
|
||||
conflictEventArgsMap: (...args: Arguments<Conflictable[CE]>) => T; // should be the same as the params list from Conflictable
|
||||
fetchDeps: DependencyList;
|
||||
}
|
||||
|
||||
export default class GuildSubscriptions {
|
||||
private static useGuildSubscriptionEffect<T, UE extends keyof Connectable, CE extends keyof Conflictable>(
|
||||
subscriptionParams: EffectParams<T>, eventMappingParams: EventMappingParams<T, UE, CE>, fetchFunc: (() => Promise<T>) | (() => Promise<T | null>)
|
||||
) {
|
||||
const { guild, onFetch, onUpdate, onConflict, onFetchError } = subscriptionParams;
|
||||
const { updateEventName, updateEventArgsMap, conflictEventName, conflictEventArgsMap, fetchDeps } = eventMappingParams;
|
||||
|
||||
const isMounted = useRef(false);
|
||||
|
||||
const fetchManagerFunc = useCallback(async () => {
|
||||
if (!isMounted.current) return;
|
||||
try {
|
||||
const value = await fetchFunc();
|
||||
if (!isMounted.current) return;
|
||||
onFetch(value);
|
||||
} catch (e: unknown) {
|
||||
LOG.error('error fetching for subscription', e);
|
||||
if (!isMounted.current) return;
|
||||
onFetchError(e);
|
||||
}
|
||||
}, [ ...fetchDeps, fetchFunc ]);
|
||||
|
||||
const boundUpdateFunc = useCallback((...args: Arguments<Connectable[UE]>): void => {
|
||||
if (!isMounted.current) return;
|
||||
const value = updateEventArgsMap(...args);
|
||||
onUpdate(value);
|
||||
}, []) as (Connectable & Conflictable)[UE]; // I think the typed EventEmitter class isn't ready for this level of type safety
|
||||
const boundConflictFunc = useCallback((...args: Arguments<Conflictable[CE]>): void => {
|
||||
if (!isMounted.current) return;
|
||||
const value = conflictEventArgsMap(...args); // otherwise, I may have done this wrong. Using never to force it to work
|
||||
onConflict(value);
|
||||
}, []) as (Connectable & Conflictable)[CE];
|
||||
|
||||
useEffect(() => {
|
||||
isMounted.current = true;
|
||||
|
||||
// Bind guild events to make sure we have the most up to date information
|
||||
guild.on('connect', fetchManagerFunc);
|
||||
guild.on(updateEventName, boundUpdateFunc);
|
||||
guild.on(conflictEventName, boundConflictFunc);
|
||||
|
||||
// Fetch the data once
|
||||
fetchManagerFunc();
|
||||
|
||||
return () => {
|
||||
isMounted.current = false;
|
||||
|
||||
// Unbind the events so that we don't have any memory leaks
|
||||
guild.off('connect', fetchManagerFunc);
|
||||
guild.off(updateEventName, boundUpdateFunc);
|
||||
guild.off(conflictEventName, boundConflictFunc);
|
||||
}
|
||||
}, [ fetchManagerFunc ]);
|
||||
}
|
||||
|
||||
private static useGuildSubscription<T, UE extends keyof Connectable, CE extends keyof Conflictable>(
|
||||
guild: CombinedGuild, eventMappingParams: EventMappingParams<T, UE, CE>, fetchFunc: (() => Promise<T>) | (() => Promise<T | null>)
|
||||
): [value: T | null, fetchError: unknown | null, events: EventEmitter<SubscriptionEvents>] {
|
||||
const [ fetchError, setFetchError ] = useState<unknown | null>(null);
|
||||
const [ value, setValue ] = useState<T | null>(null);
|
||||
|
||||
const events = useMemo(() => new EventEmitter<SubscriptionEvents>(), []);
|
||||
|
||||
const onFetch = useCallback((fetchValue: T | null) => {
|
||||
setValue(fetchValue);
|
||||
setFetchError(null);
|
||||
events.emit('fetch');
|
||||
}, []);
|
||||
|
||||
const onFetchError = useCallback((e: unknown) => {
|
||||
setFetchError(e);
|
||||
setValue(null);
|
||||
events.emit('fetch-error');
|
||||
}, []);
|
||||
|
||||
const onUpdate = useCallback((updateValue: T) => {
|
||||
setValue(updateValue);
|
||||
events.emit('update');
|
||||
}, []);
|
||||
|
||||
const onConflict = useCallback((conflictValue: T) => {
|
||||
setValue(conflictValue);
|
||||
events.emit('conflict');
|
||||
}, []);
|
||||
|
||||
GuildSubscriptions.useGuildSubscriptionEffect({
|
||||
guild,
|
||||
onFetch,
|
||||
onUpdate,
|
||||
onConflict,
|
||||
onFetchError
|
||||
}, eventMappingParams, fetchFunc);
|
||||
|
||||
return [ value, fetchError, events ];
|
||||
}
|
||||
|
||||
static useGuildMetadataSubscription(guild: CombinedGuild) {
|
||||
return GuildSubscriptions.useGuildSubscription<GuildMetadata, 'update-metadata', 'conflict-metadata'>(guild, {
|
||||
updateEventName: 'update-metadata',
|
||||
updateEventArgsMap: (guildMeta: GuildMetadata) => guildMeta,
|
||||
conflictEventName: 'conflict-metadata',
|
||||
conflictEventArgsMap: (changesType: AutoVerifierChangesType, oldGuildMeta: GuildMetadata, newGuildMeta: GuildMetadata) => newGuildMeta,
|
||||
fetchDeps: [ guild ]
|
||||
}, async () => await guild.fetchMetadata());
|
||||
}
|
||||
|
||||
static useResourceSubscription(guild: CombinedGuild, resourceId: string | null) {
|
||||
return GuildSubscriptions.useGuildSubscription<Resource, 'update-resource', 'conflict-resource'>(guild, {
|
||||
updateEventName: 'update-resource',
|
||||
updateEventArgsMap: (resource: Resource) => resource,
|
||||
conflictEventName: 'conflict-resource',
|
||||
conflictEventArgsMap: (query: IDQuery, changesType: AutoVerifierChangesType, oldResource: Resource, newResource: Resource) => newResource,
|
||||
fetchDeps: [ guild, resourceId ]
|
||||
}, async () => {
|
||||
if (resourceId === null) return null;
|
||||
return await guild.fetchResource(resourceId);
|
||||
});
|
||||
}
|
||||
}
|
@ -3,12 +3,9 @@ const electronConsole = electronRemote.getGlobal('console') as Console;
|
||||
import Logger from '../../../../logger/logger';
|
||||
const LOG = Logger.create(__filename, electronConsole);
|
||||
|
||||
import { DependencyList, useEffect } from "react";
|
||||
import { DependencyList, useEffect, useRef, useState } from "react";
|
||||
import ReactDOMServer from "react-dom/server";
|
||||
import { GuildMetadata, Resource, ShouldNeverHappenError } from "../../data-types";
|
||||
import CombinedGuild from "../../guild-combined";
|
||||
import ElementsUtil from './elements-util';
|
||||
import * as fs from 'fs/promises';
|
||||
import { ShouldNeverHappenError } from "../../data-types";
|
||||
|
||||
// Helper function so we can use JSX before fully committing to React
|
||||
|
||||
@ -23,146 +20,35 @@ export default class ReactHelper {
|
||||
return document.body.firstElementChild;
|
||||
}
|
||||
|
||||
private static useCustomAsyncEffect<T>(params: {
|
||||
static useAsyncActionSubscription<T, V>(
|
||||
actionFunc: () => Promise<T>,
|
||||
onSuccess: (value: T) => void,
|
||||
onError: (e: unknown) => void,
|
||||
initialValue: V,
|
||||
deps: DependencyList
|
||||
}) {
|
||||
const { actionFunc, onSuccess, onError, deps } = params;
|
||||
): [ value: T | V, error: unknown | null ] {
|
||||
const isMounted = useRef(false);
|
||||
|
||||
const [ value, setValue ] = useState<T | V>(initialValue);
|
||||
const [ error, setError ] = useState<unknown | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
isMounted.current = true;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const value = await actionFunc();
|
||||
onSuccess(value);
|
||||
} catch (e) {
|
||||
onError(e);
|
||||
if (!isMounted.current) return;
|
||||
setValue(value);
|
||||
setError(null);
|
||||
} catch (e: unknown) {
|
||||
LOG.error('unable to perform async action subscription', e);
|
||||
if (!isMounted.current) return;
|
||||
setError(e);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => { isMounted.current = false; };
|
||||
}, deps);
|
||||
}
|
||||
|
||||
static useGuildMetadataEffect(params: {
|
||||
guild: CombinedGuild,
|
||||
onSuccess: (guildMeta: GuildMetadata) => void,
|
||||
onError: () => void,
|
||||
}): void {
|
||||
const { guild, onSuccess, onError } = params;
|
||||
|
||||
ReactHelper.useCustomAsyncEffect({
|
||||
actionFunc: async () => {
|
||||
return await guild.fetchMetadata();
|
||||
},
|
||||
onSuccess: (value) => onSuccess(value),
|
||||
onError: (e) => { LOG.error('unable to load guild metadata', e); onError() },
|
||||
deps: [ guild ]
|
||||
});
|
||||
}
|
||||
|
||||
static useBufferFileEffect(params: {
|
||||
filePath: string,
|
||||
onSuccess: (buff: Buffer) => void,
|
||||
onError: () => void
|
||||
}): void {
|
||||
const { filePath, onSuccess, onError } = params;
|
||||
|
||||
ReactHelper.useCustomAsyncEffect({
|
||||
actionFunc: async () => {
|
||||
const buff = await fs.readFile(filePath);
|
||||
return buff;
|
||||
},
|
||||
onSuccess: (value) => onSuccess(value),
|
||||
onError: (e) => { LOG.error('unable to load file', e); onError() },
|
||||
deps: [ filePath ]
|
||||
});
|
||||
}
|
||||
|
||||
static useNullableResourceEffect(params: {
|
||||
guild: CombinedGuild, resourceId: string | null,
|
||||
onSuccess: (resource: Resource | null) => void,
|
||||
onError: () => void
|
||||
}): void {
|
||||
const { guild, resourceId, onSuccess, onError } = params;
|
||||
|
||||
ReactHelper.useCustomAsyncEffect({
|
||||
actionFunc: async () => {
|
||||
if (resourceId === null) return null;
|
||||
const resource = await guild.fetchResource(resourceId);
|
||||
return resource;
|
||||
},
|
||||
onSuccess: (value) => onSuccess(value),
|
||||
onError: (e) => { LOG.error('unable to fetch resource', e); onError(); },
|
||||
deps: [ guild, resourceId ]
|
||||
});
|
||||
}
|
||||
|
||||
static useResourceEffect(params: {
|
||||
guild: CombinedGuild, resourceId: string,
|
||||
onSuccess: (resource: Resource) => void,
|
||||
onError: () => void
|
||||
}): void {
|
||||
const { guild, resourceId, onSuccess, onError } = params;
|
||||
|
||||
ReactHelper.useCustomAsyncEffect({
|
||||
actionFunc: async () => {
|
||||
const resource = await guild.fetchResource(resourceId);
|
||||
return resource;
|
||||
},
|
||||
onSuccess: (value) => onSuccess(value),
|
||||
onError: (e) => { LOG.error('unable to fetch resource', e); onError(); },
|
||||
deps: [ guild, resourceId ]
|
||||
});
|
||||
}
|
||||
|
||||
static useImageSrcResourceEffect(params: {
|
||||
guild: CombinedGuild, resourceId: string | null,
|
||||
onSuccess: (imgSrc: string) => void,
|
||||
onError: () => void
|
||||
}): void {
|
||||
const { guild, resourceId, onSuccess, onError } = params;
|
||||
|
||||
ReactHelper.useCustomAsyncEffect({
|
||||
actionFunc: async () => {
|
||||
if (resourceId === null) return './img/loading.svg';
|
||||
const resource = await guild.fetchResource(resourceId);
|
||||
const imgSrc = await ElementsUtil.getImageBufferSrc(resource.data);
|
||||
return imgSrc;
|
||||
},
|
||||
onSuccess: (value) => onSuccess(value),
|
||||
onError: (e) => { LOG.error('unable to fetch and convert resource', e); onError(); },
|
||||
deps: [ guild, resourceId ]
|
||||
});
|
||||
}
|
||||
|
||||
static useSoftImageSrcResourceEffect(params: {
|
||||
guild: CombinedGuild, resourceId: string | null,
|
||||
onSuccess: (imgSrc: string) => void
|
||||
}): void {
|
||||
const { guild, resourceId, onSuccess } = params;
|
||||
|
||||
this.useImageSrcResourceEffect({
|
||||
guild, resourceId,
|
||||
onSuccess,
|
||||
onError: () => { onSuccess('./img/error.png'); }
|
||||
});
|
||||
}
|
||||
|
||||
static useImageSrcBufferEffect(params: {
|
||||
buffer: Buffer | null,
|
||||
onSuccess: (imgSrc: string) => void,
|
||||
onError: () => void
|
||||
}): void {
|
||||
const { buffer, onSuccess, onError } = params;
|
||||
|
||||
ReactHelper.useCustomAsyncEffect({
|
||||
actionFunc: async () => {
|
||||
if (buffer === null) return './img/loading.svg';
|
||||
const imgSrc = await ElementsUtil.getImageBufferSrc(buffer);
|
||||
return imgSrc;
|
||||
},
|
||||
onSuccess: (value) => onSuccess(value),
|
||||
onError: (e) => { LOG.error('unable to convert buffer', e); onError(); },
|
||||
deps: [ buffer ]
|
||||
});
|
||||
return [ value, error ];
|
||||
}
|
||||
}
|
||||
|
@ -319,7 +319,12 @@ export default class CombinedGuild extends EventEmitter<Connectable & Conflictab
|
||||
return await this.fetchable.fetchResource(resourceId);
|
||||
}
|
||||
async fetchTokens(): Promise<Token[]> {
|
||||
return await this.fetchable.fetchTokens();
|
||||
const members = await this.grabRAMMembersMap();
|
||||
const tokens = await this.fetchable.fetchTokens();
|
||||
for (const token of tokens) {
|
||||
token.fill(members);
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
// Simply forwarded to the socket guild
|
||||
|
@ -128,6 +128,10 @@ export type Connectable = {
|
||||
'new-messages': (messages: Message[]) => void;
|
||||
'update-messages': (updatedMessages: Message[]) => void;
|
||||
'remove-messages': (removedMessages: Message[]) => void;
|
||||
|
||||
// TODO: Implement these in server/combined guild
|
||||
'update-resource': (updatedResource: Resource) => void;
|
||||
'remove-resource': (removedResource: Resource) => void;
|
||||
}
|
||||
|
||||
// A Conflictable could emit conflict-based events if data changed based on verification
|
||||
|
@ -126,13 +126,14 @@
|
||||
border-top-right-radius: $content-border-radius;
|
||||
border-bottom-right-radius: $content-border-radius;
|
||||
background-color: $background-primary;
|
||||
overflow-y: scroll;
|
||||
position: relative;
|
||||
|
||||
> .scroll {
|
||||
margin: 32px;
|
||||
|
||||
.divider {
|
||||
margin: 24px 0;
|
||||
margin: 16px 0;
|
||||
height: 1px;
|
||||
background-color: $background-primary-divider;
|
||||
}
|
||||
|
@ -309,7 +309,8 @@ body > .overlay,
|
||||
font-weight: 600;
|
||||
color: $text-normal;
|
||||
font-size: 1.1em;
|
||||
margin-bottom: 4px;
|
||||
line-height: 1;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -355,10 +356,61 @@ body > .overlay,
|
||||
.view-invites {
|
||||
color: $text-normal;
|
||||
|
||||
th {
|
||||
text-transform: uppercase;
|
||||
text-align: left;
|
||||
font-size: 0.75em;
|
||||
.invites-table {
|
||||
.token-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
line-height: 1;
|
||||
background-color: $background-secondary;
|
||||
padding: 8px;
|
||||
margin: 0 -8px;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background-color: $background-secondary-alt;
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.user-token {
|
||||
flex: 1;
|
||||
|
||||
.user {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.token {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 0.85em;
|
||||
color: $text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
.created-expires {
|
||||
text-align: right;
|
||||
font-size: 0.85em;
|
||||
margin-right: 8px;
|
||||
|
||||
> :not(:last-child) {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
|
||||
.button {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
> :not(:last-child) {
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
td.actions {
|
||||
@ -367,6 +419,10 @@ body > .overlay,
|
||||
:not(:last-child) {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user