reactify guild overview display and guild settings overlay

This commit is contained in:
Michael Peters 2021-12-07 23:52:32 -06:00
parent c12873d929
commit 392a8cf4bc
17 changed files with 568 additions and 194 deletions

View File

@ -29,6 +29,7 @@ move:
clean:
mkdir -p ./dist
rm -r ./dist
rm -r ./db
reset-server:
psql postgres postgres < ./src/server/sql/init.sql

View File

@ -1,14 +1,41 @@
import React, { FC } from 'react';
import React, { FC, useCallback, useMemo } from 'react';
export enum ButtonType {
BRAND = '',
POSITIVE = 'positive',
NEGATIVE = 'negative',
PERDU = 'perdu',
}
interface ButtonProps {
onClick: () => void
type?: ButtonType;
onClick?: () => void;
shaking?: boolean;
children: React.ReactNode
children?: React.ReactNode;
}
const DefaultButtonProps: ButtonProps = {
type: ButtonType.BRAND
}
const Button: FC<ButtonProps> = (props: ButtonProps) => {
const { onClick, shaking, children } = props;
return <div className={'button ' + (shaking ? 'shaking-horizontal' : '')} onClick={onClick}>{children}</div>
const { type, onClick, shaking, children } = { ...DefaultButtonProps, ...props };
const className = useMemo(
() => [
'button',
type,
shaking && 'shaking-horizontal',
].filter(c => typeof c === 'string').join(' '),
[ type, shaking ]
);
const clickHandler = useCallback(() => {
if (shaking) return; // ignore clicks while shaking
if (onClick) onClick();
}, [ shaking, onClick ]);
return <div className={className} onClick={clickHandler}>{children}</div>
}
export default Button;

View File

@ -0,0 +1,23 @@
import React, { FC } from 'react';
export interface DisplayPopupProps {
tip: string | null;
children?: React.ReactNode; // buttons
}
const DisplayPopup: FC<DisplayPopupProps> = (props: DisplayPopupProps) => {
const { tip, children } = props;
return (
<div className="popup changes">
<div className="content">
<div className="tip">{tip}</div>
<div className="actions">
{children}
</div>
</div>
</div>
);
};
export default DisplayPopup;

View File

@ -0,0 +1,75 @@
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, useMemo, useState } from "react";
import ElementsUtil from "../require/elements-util";
import Button, { ButtonType } from "./button";
import DisplayPopup from "./display-popup";
interface DisplayProps {
children: React.ReactNode;
changes: boolean;
resetChanges: () => void;
saveChanges: () => void;
saving: boolean;
saveFailed: boolean;
errorMessage: string | null;
}
const Display: FC<DisplayProps> = (props: DisplayProps) => {
const { children, changes, resetChanges, saveChanges, saving, saveFailed, errorMessage } = props;
const [ saveButtonShaking, setSaveButtonShaking ] = useState<boolean>(false);
useEffect(() => {
(async () => {
if (saveFailed) {
await ElementsUtil.delayToggleState(setSaveButtonShaking, 400);
}
})();
}, [ saveFailed ]);
const changesButtonText = useMemo(() => {
if (saving) {
return 'Saving...';
} else if (saveFailed) {
return 'Try Again';
} else {
return 'Save Changes';
}
}, [ saving, saveFailed ]);
const popup = useMemo(() => {
if (errorMessage) {
return (
<DisplayPopup tip={errorMessage}>
<Button type={ButtonType.PERDU} onClick={resetChanges}>Reset</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>
</DisplayPopup>
);
} else {
return null;
}
}, [ errorMessage, changes, resetChanges, saveChanges ]);
return (
<div className="display">
<div className="scroll">
{children}
</div>
{popup}
</div>
);
}
export default Display;

View File

@ -0,0 +1,93 @@
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, useRef, useState } from 'react';
import ElementsUtil from '../require/elements-util';
import * as FileType from 'file-type';
import * as crypto from 'crypto';
interface ImageEditInputProps {
alt?: string;
maxSize: number;
value: Buffer | null;
setValue: React.Dispatch<React.SetStateAction<Buffer | null>>;
setErrorMessage: React.Dispatch<React.SetStateAction<string | null>>;
}
const ImageEditInput: FC<ImageEditInputProps> = (props: ImageEditInputProps) => {
const { alt, maxSize, value, setValue, setErrorMessage } = props;
const acceptedExtTypes = [ 'png', 'jpg', 'jpeg' ];
const imgRef = useRef<HTMLImageElement>(null);
const [ imgSrc, setImgSrc ] = useState<string>('./img/loading.svg');
useEffect(() => {
(async () => {
if (value) {
try {
const src = await ElementsUtil.getImageBufferSrc(value);
setImgSrc(src);
} catch (e: unknown) {
LOG.error('unable to get image buffer src', e);
setImgSrc('./img/error.png');
setErrorMessage('Unable to get image src');
}
} else {
setImgSrc('./img/loading.svg');
}
})();
}, [ value ]);
useEffect(() => {
if (imgRef.current) {
imgRef.current.src = imgSrc;
}
}, [ imgSrc ])
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) {
LOG.debug('no files');
return;
}
const file = files[0] as File;
if (file.size > maxSize) {
e.target.value = '';
LOG.debug('image too large');
setErrorMessage(`Image too large. (${ElementsUtil.humanSize(file.size)}>${ElementsUtil.humanSize(maxSize)})`);
return;
}
const buff = Buffer.from(await file.arrayBuffer());
const typeResult = await FileType.fromBuffer(buff);
if (!typeResult || !acceptedExtTypes.includes(typeResult.ext)) {
e.target.value = '';
setErrorMessage(`Invalid image type. Accepted types: ${acceptedExtTypes.join(', ')}`);
return;
}
setErrorMessage(null);
setValue(buff);
}
return (
<div className="image-edit-input-react">
<label className="label">
<div className="image">
<img ref={imgRef} className="value" src={imgSrc} alt={alt ?? 'icon'}></img>
<div className="modify"><img className="pencil" src="./img/pencil-icon.png" alt="modify"></img></div>
</div>
<input type="file" accept={acceptedExtTypes.map(t => `.${t}`).join(',')} onChange={handleFileChange}></input>
</label>
</div>
);
}
export default ImageEditInput;

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, useMemo, useState } from 'react';
export interface TextInputProps {
label?: string;
placeholder?: string;
value: string;
setValue: React.Dispatch<React.SetStateAction<string>>;
onEnterKeyDown?: () => void;
}
const TextInput: FC<TextInputProps> = (props: TextInputProps) => {
const { label, placeholder, value, setValue, onEnterKeyDown } = props;
const labelElement = useMemo(() => {
return label && <div className="label">{label}</div>
}, [ label ]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value);
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
if (onEnterKeyDown) onEnterKeyDown();
}
}
return (
<div className="text-input-react">{/* TODO: remove -react */}
{labelElement}
<input type="text" placeholder={placeholder} onChange={handleChange} onKeyDown={handleKeyDown} value={value} />
</div>
)
}
export default TextInput;

View File

@ -26,22 +26,12 @@ const Overlay: FC<OverlayProps> = (props: OverlayProps) => {
if (node.current) {
ReactDOM.unmountComponentAtNode(node.current.parentElement as Element);
}
if (keyDownEventHandler) {
window.removeEventListener('keydown', keyDownEventHandler);
if (keyDownHandler) {
window.removeEventListener('keydown', keyDownHandler);
}
// otherwise, this isn't in the DOM anyway
};
const keyDownEventHandler = useMemo(() => {
const eventHandler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
removeSelf();
}
};
window.addEventListener('keydown', eventHandler);
return eventHandler;
}, []);
const checkMouseDown = (e: React.MouseEvent) => {
if (e.target === node.current) {
setMouseDownInChild(false);
@ -58,7 +48,23 @@ const Overlay: FC<OverlayProps> = (props: OverlayProps) => {
}
};
return <div className="overlay" ref={node} onMouseDown={checkMouseDown} onMouseUp={checkMouseUp} onClick={removeSelf}>{children}</div>
const keyDownHandler = useMemo(() => {
const eventHandler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
removeSelf();
}
};
window.addEventListener('keydown', eventHandler);
return eventHandler;
}, []);
const clickHandler = (e: React.MouseEvent) => {
if (e.target === node.current) {
removeSelf();
}
}
return <div className="overlay" ref={node} onMouseDown={checkMouseDown} onMouseUp={checkMouseUp} onClick={clickHandler}>{children}</div>
};
export default Overlay;

View File

@ -10,7 +10,6 @@ import Q from '../q-module';
import UI from '../ui';
import createErrorMessageOverlay from './overlay-error-message';
import createGuildSettingsOverlay from './overlay-guild-settings';
import createCreateInviteTokenOverlay from './overlay-create-invite-token';
import createCreateChannelOverlay from './overlay-create-channel';
import createTokenLogOverlay from './overlay-token-log';
@ -18,6 +17,7 @@ import CombinedGuild from '../guild-combined';
import React from 'react';
import ReactHelper from './require/react-helper';
import GuildSettingsOverlay from './overlays/overlay-guild-settings';
export default function createGuildTitleContextMenu(document: Document, q: Q, ui: UI, guild: CombinedGuild): Element {
if (ui.activeConnection === null) {
@ -25,7 +25,6 @@ export default function createGuildTitleContextMenu(document: Document, q: Q, ui
return ReactHelper.createElementFromJSX(<div></div>);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const menuItems: JSX.Element[] = [];
if (ui.activeConnection.privileges.includes('modify_profile')) {
@ -87,8 +86,9 @@ export default function createGuildTitleContextMenu(document: Document, q: Q, ui
const overlay = createErrorMessageOverlay(document, 'Error Opening Settings', 'Could not load guild information');
document.body.appendChild(overlay);
} else {
const overlay = createGuildSettingsOverlay(document, q, guild, guildMeta);
document.body.appendChild(overlay);
ElementsUtil.presentReactOverlay(document, <GuildSettingsOverlay guild={guild} guildMeta={guildMeta} />);
// const overlay = createGuildSettingsOverlay(document, q, guild, guildMeta);
// document.body.appendChild(overlay);
}
});
}

View File

@ -0,0 +1,129 @@
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, useCallback, useEffect, useMemo, useState } from 'react';
import { GuildMetadata } from '../../data-types';
import Globals from '../../globals';
import CombinedGuild from '../../guild-combined';
import Display from '../components/display';
import ElementsUtil from '../require/elements-util';
import TextInput from '../components/input-text';
import ImageEditInput from '../components/input-image-edit';
export interface GuildOverviewDisplayProps {
guild: CombinedGuild;
guildMeta: GuildMetadata;
}
const GuildOverviewDisplay: FC<GuildOverviewDisplayProps> = (props: GuildOverviewDisplayProps) => {
const { guild, guildMeta } = props;
const [ savedName, setSavedName ] = useState<string>(guildMeta.name);
const [ savedIconBuff, setSavedIconBuff ] = useState<Buffer | null>(null);
const [ name, setName ] = useState<string>(guildMeta.name);
const [ iconBuff, setIconBuff ] = useState<Buffer | null>(null);
const [ saving, setSaving ] = useState<boolean>(false);
const [ iconFailed, setIconFailed ] = useState<boolean>(false);
const [ saveFailed, setSaveFailed ] = useState<boolean>(false);
const [ imageInputErrorMessage, setImageInputErrorMessage ] = useState<string | null>(null);
useEffect(() => {
(async () => {
try {
const iconResource = await guild.fetchResource(guildMeta.iconResourceId);
setSavedIconBuff(iconResource.data);
setIconBuff(iconResource.data);
} catch (e: unknown) {
LOG.error('Error loading icon resource', e);
setIconFailed(true);
}
})();
}, []);
const changes = useMemo(() => {
return name !== savedName || iconBuff?.toString('hex') !== savedIconBuff?.toString('hex')
}, [ name, savedName, iconBuff, savedIconBuff ]);
const errorMessage = useMemo(() => {
if (iconFailed) {
return 'Unable to load icon';
} else if (imageInputErrorMessage) {
return imageInputErrorMessage;
} else if (iconBuff && iconBuff.length > Globals.MAX_GUILD_ICON_SIZE) {
return `Icon is too large. (${ElementsUtil.humanSize(iconBuff.length)}>${ElementsUtil.humanSize(Globals.MAX_GUILD_ICON_SIZE)}) Try a 512x512 icon.`;
} else if (name.length === 0) {
return 'Name is empty';
} else if (name.length > Globals.MAX_GUILD_NAME_LENGTH) {
return `Name is too long. (${name.length}>${Globals.MAX_GUILD_NAME_LENGTH} characters)`;
} else {
return null;
}
}, [ iconFailed, imageInputErrorMessage, iconBuff, name ]);
const resetChanges = () => {
setImageInputErrorMessage(null);
setName(savedName);
setIconBuff(savedIconBuff);
}
const saveChanges = useCallback(async () => {
if (errorMessage) return;
if (saving) return;
setSaving(true);
if (name !== savedName) {
// Save name
try {
await guild.requestSetGuildName(name);
setSavedName(name);
} catch (e: unknown) {
LOG.error('error setting guild name', e);
setSaveFailed(true);
setSaving(false);
return;
}
}
if (iconBuff && iconBuff?.toString('hex') !== savedIconBuff?.toString('hex')) {
// Save icon
try {
LOG.debug('saving icon');
await guild.requestSetGuildIcon(iconBuff);
setSavedIconBuff(iconBuff);
} catch (e: unknown) {
LOG.error('error setting guild icon', e);
setSaveFailed(true);
setSaving(false);
return;
}
}
setSaving(false);
}, [ errorMessage, saving, name, savedName, iconBuff ]);
return (
<Display
changes={changes}
resetChanges={resetChanges} saveChanges={saveChanges}
saving={saving} saveFailed={saveFailed} errorMessage={errorMessage}
>
<div className="metadata">
<div className="icon">
<ImageEditInput maxSize={Globals.MAX_GUILD_ICON_SIZE} value={iconBuff} setValue={setIconBuff} setErrorMessage={setImageInputErrorMessage} />
</div>
<div className="name">
<TextInput label={'Guild Name'} placeholder={savedName} value={name} setValue={setName} onEnterKeyDown={saveChanges} />
</div>
</div>
</Display>
);
}
export default GuildOverviewDisplay;

View File

@ -107,7 +107,7 @@ export default function createGuildSettingsOverlay(document: Document, q: Q, gui
});
BaseElements.bindImageUploadEvents(q.$$$(element, '.image-input-upload') as HTMLInputElement, {
maxSize: Globals.MAX_ICON_SIZE,
maxSize: Globals.MAX_GUILD_ICON_SIZE,
acceptedMimeTypes: [ 'image/png', 'image/jpeg', 'image/jpg' ],
onChangeStart: async () => await updatePopups(),
onCleared: () => {},

View File

@ -1,78 +0,0 @@
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 * as FileType from 'file-type'
import BaseElements, { HTMLElementWithRemoveSelf } from './require/base-elements';
import ElementsUtil from './require/elements-util';
import Q from '../q-module';
import createImageContextMenu from './context-menu-img';
import CombinedGuild from '../guild-combined';
import React from 'react';
export default function createImageOverlay(document: Document, q: Q, guild: CombinedGuild, resourceId: string, resourceName: string): HTMLElementWithRemoveSelf {
const element = BaseElements.createOverlay(document, (
<div className="content popup-image">
<img src="./img/loading.svg" alt={resourceName} title={resourceName}></img>
<div className="download">
<div className="info">
<div className="name">{resourceName}</div>
<div className="size">Loading Size...</div>
</div>
<div className="button">Loading...</div>
</div>
</div>
));
(async () => {
try {
const resource = await guild.fetchResource(resourceId);
const src = await ElementsUtil.getImageBufferSrc(resource.data);
(q.$$$(element, '.content img') as HTMLImageElement).src = src;
q.$$$(element, '.download .size').innerText = ElementsUtil.humanSize(resource.data.length);
const { mime, ext } = (await FileType.fromBuffer(resource.data)) ?? { mime: null, ext: null };
if (mime === null || ext === null) throw new Error('unable to get mime/ext');
q.$$$(element, '.content img').addEventListener('contextmenu', (e) => {
const contextMenu = createImageContextMenu(document, q, guild, resourceName, resource.data, mime as string, ext as string, false);
document.body.appendChild(contextMenu);
const relativeTo = { x: e.pageX, y: e.pageY };
ElementsUtil.alignContextElement(contextMenu, relativeTo, { top: 'centerY', left: 'right' });
});
q.$$$(element, '.button').innerText = 'Save';
q.$$$(element, '.button').addEventListener('click', ElementsUtil.createDownloadListener({
downloadBuff: resource.data,
resourceName: resourceName,
downloadStartFunc: () => {
q.$$$(element, '.button').innerText = 'Downloading...';
},
downloadFailFunc: async () => {
q.$$$(element, '.button').innerText = 'Try Again';
await ElementsUtil.shakeElement(q.$$$(element, '.button'), 400);
},
writeStartFunc: () => {
q.$$$(element, '.button').innerText = 'Writing...';
},
writeFailFunc: async () => {
q.$$$(element, '.button').innerText = 'Try Again';
await ElementsUtil.shakeElement(q.$$$(element, '.button'), 400);
},
successFunc: (_downloadPath: string) => {
q.$$$(element, '.button').innerText = 'Reveal in Explorer';
}
}));
} catch (e) {
LOG.error('error loading overlay image', e);
(q.$$$(element, '.content img') as HTMLImageElement).src = './img/error.png';
q.$$$(element, '.download .size').innerText = 'err';
q.$$$(element, '.button').innerText = 'Error';
}
})();
return element;
}

View File

@ -0,0 +1,27 @@
import React, { FC, useState } from "react";
import { GuildMetadata } from "../../data-types";
import CombinedGuild from "../../guild-combined";
import GuildOverviewDisplay from "../displays/display-guild-overview";
export interface GuildSettingsOverlayProps {
guild: CombinedGuild;
guildMeta: GuildMetadata;
}
const GuildSettingsOverlay: FC<GuildSettingsOverlayProps> = (props: GuildSettingsOverlayProps) => {
const { guild, guildMeta } = props;
return (
<div className="content display-swapper guild-settings">
<div className="options">
<div className="title">{guildMeta.name}</div>
<div className="choosable chosen">Overview</div>
<div className="choosable">Channels</div>
<div className="choosable">Roles</div>
<div className="choosable">Invites</div>
</div>
<GuildOverviewDisplay guild={guild} guildMeta={guildMeta} />
</div>
);
}
export default GuildSettingsOverlay;

View File

@ -47,7 +47,7 @@ const ImageOverlay: FC<ImageOverlayProps> = (props: ImageOverlayProps) => {
return;
}
})();
});
}, []);
const onImageContextMenu = (e: React.MouseEvent) => {
// TODO: This should be in react!

View File

@ -13,7 +13,7 @@ export default class Globals {
static MAX_AVATAR_SIZE = 1024 * 128; // 128 KB max avatar size
static MAX_DISPLAY_NAME_LENGTH = 32; // 32 char max display name length
static MAX_ICON_SIZE = 1024 * 128; // 128 KB max guild icon size
static MAX_GUILD_ICON_SIZE = 1024 * 128; // 128 KB max guild icon size
static MAX_GUILD_NAME_LENGTH = 64; // 64 char max guild name length
static MAX_CHANNEL_NAME_LENGTH = 32; // 32 char max channel name length

View File

@ -0,0 +1,116 @@
@import "theme.scss";
.text-input-react {
.label {
font-size: 0.75em;
font-weight: bold;
color: $interactive-normal;
text-transform: uppercase;
margin-bottom: 2px;
}
input {
font-size: inherit;
font-family: inherit;
color: $text-normal;
background-color: $background-input;
border: 1px solid $border-input;
border-radius: 3px;
max-height: 100px;
overflow-y: scroll;
padding: 8px;
&:hover {
border-color: $border-input-hover;
}
&:focus {
outline: none;
border-color: $border-input-focus;
}
}
}
.image-edit-input-react {
position: relative;
label {
cursor: pointer;
}
img.value {
width: 64px;
height: 64px;
border-radius: 8px;
}
.modify {
position: absolute;
background-color: $brand;
padding: 6px;
border-radius: 16px;
right: -4px;
bottom: -4px;
img.pencil {
display: block;
width: 10px;
height: 10px;
}
}
input {
display: none;
}
}
.display {
$content-border-radius: 4px;
flex: 1;
border-top-right-radius: $content-border-radius;
border-bottom-right-radius: $content-border-radius;
background-color: $background-primary;
position: relative;
> .scroll {
margin: 32px;
}
> .popup {
position: absolute;
bottom: 0;
left: 0;
box-sizing: border-box;
width: 100%;
.content {
margin: 16px;
padding: 12px 12px 12px 16px;
display: flex;
justify-content: space-between;
align-items: center;
color: $interactive-active;
background-color: $background-popup-message;
border-radius: 8px;
}
.tip {
font-weight: 500;
}
.actions {
display: flex;
.button {
padding: 8px 12px;
font-size: 0.85em;
font-weight: bold;
}
:not(:last-child) {
margin-right: 4px;
}
}
}
}

View File

@ -16,7 +16,7 @@ body > .overlay,
background-color: $background-overlay;
/* General Controls */
input[type=text], .text-input {
.text-input {
font-size: inherit;
font-family: inherit;
color: $text-normal;
@ -37,14 +37,6 @@ body > .overlay,
}
}
.label {
font-size: 0.75em;
font-weight: bold;
color: $interactive-normal;
text-transform: uppercase;
margin-bottom: 2px;
}
/* Popup Image */
> .content.popup-image {
@ -370,59 +362,6 @@ body > .overlay,
}
}
}
> .display {
flex: 1;
border-top-right-radius: $content-border-radius;
border-bottom-right-radius: $content-border-radius;
background-color: $background-primary;
position: relative;
> .scroll {
margin: 32px;
}
> .popup {
position: absolute;
bottom: 0;
left: 0;
box-sizing: border-box;
width: 100%;
&:not(.enabled) {
display: none;
}
.content {
margin: 16px;
padding: 12px 12px 12px 16px;
display: flex;
justify-content: space-between;
align-items: center;
color: $interactive-active;
background-color: $background-popup-message;
border-radius: 8px;
}
.tip {
font-weight: 500;
}
.actions {
display: flex;
.button {
padding: 8px 12px;
font-size: 0.85em;
font-weight: bold;
}
:not(:last-child) {
margin-right: 4px;
}
}
}
}
}
/* guild Settings Overlay */
@ -434,35 +373,6 @@ body > .overlay,
display: flex;
margin-bottom: 12px;
.image-input-label {
cursor: pointer;
}
.icon {
position: relative;
> img {
width: 64px;
height: 64px;
border-radius: 8px;
}
.modify {
position: absolute;
background-color: $brand;
padding: 6px;
border-radius: 16px;
right: -4px;
bottom: -4px;
> img {
display: block;
width: 10px;
height: 10px;
}
}
}
.name {
margin-left: 16px;
}

View File

@ -1,6 +1,8 @@
@import "theme.scss";
@import "fonts.scss";
@import "components.scss";
@import "buttons.scss";
@import "channel-feed.scss";
@import "channel-list.scss";