finish register and login flow + suspense fallback fix

Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
This commit is contained in:
mrjvs 2023-11-18 15:12:31 +01:00
parent 567c6a3894
commit 061c944034
14 changed files with 251 additions and 100 deletions

View file

@ -1,4 +1,4 @@
import { Icon, Icons } from "@/components/Icon";
import { UserIcon } from "@/components/UserIcon";
import { AccountProfile } from "@/pages/parts/auth/AccountCreatePart";
import { useAuthStore } from "@/stores/auth";
@ -6,13 +6,7 @@ export interface AvatarProps {
profile: AccountProfile["profile"];
}
const possibleIcons = ["bookmark"] as const;
const avatarIconMap: Record<(typeof possibleIcons)[number], Icons> = {
bookmark: Icons.BOOKMARK,
};
export function Avatar(props: AvatarProps) {
const icon = (avatarIconMap as any)[props.profile.icon] ?? Icons.X;
return (
<div
className="h-[2em] w-[2em] rounded-full overflow-hidden flex items-center justify-center text-white"
@ -20,7 +14,7 @@ export function Avatar(props: AvatarProps) {
background: `linear-gradient(to bottom right, ${props.profile.colorA}, ${props.profile.colorB})`,
}}
>
<Icon icon={icon} />
<UserIcon icon={props.profile.icon as any} />
</div>
);
}

View file

@ -1,8 +1,9 @@
import classNames from "classnames";
import { ReactNode } from "react";
import { ReactNode, useCallback } from "react";
import { useHistory } from "react-router-dom";
import { Icon, Icons } from "@/components/Icon";
import { Spinner } from "@/components/layout/Spinner";
interface Props {
icon?: Icons;
@ -14,18 +15,24 @@ interface Props {
href?: string;
disabled?: boolean;
download?: string;
loading?: boolean;
}
export function Button(props: Props) {
const history = useHistory();
const { onClick, href, loading } = props;
const cb = useCallback(() => {
if (loading) return;
if (href) history.push(href);
else onClick?.();
}, [onClick, href, history, loading]);
let colorClasses = "bg-white hover:bg-gray-200 text-black";
if (props.theme === "purple")
colorClasses =
"bg-video-buttons-purple hover:bg-video-buttons-purpleHover text-white";
colorClasses = "bg-buttons-purple hover:bg-buttons-purpleHover text-white";
if (props.theme === "secondary")
colorClasses =
"bg-video-buttons-cancel hover:bg-video-buttons-cancelHover transition-colors duration-100 text-white";
"bg-buttons-cancel hover:bg-buttons-cancelHover transition-colors duration-100 text-white";
if (props.theme === "danger")
colorClasses = "bg-buttons-danger hover:bg-buttons-dangerHover text-white";
@ -48,19 +55,20 @@ export function Button(props: Props) {
const content = (
<>
{props.icon ? (
{props.icon && !props.loading ? (
<span className="mr-3 hidden md:inline-block">
<Icon icon={props.icon} />
</span>
) : null}
{props.loading ? (
<span className="mr-3 inline-flex justify-center">
<Spinner className="text-lg" />
</span>
) : null}
{props.children}
</>
);
function goTo(href: string) {
history.push(href);
}
if (
props.href &&
(props.href.startsWith("https://") || props.href?.startsWith("data:"))
@ -79,13 +87,13 @@ export function Button(props: Props) {
if (props.href)
return (
<a className={classes} onClick={() => goTo(props.href || "")}>
<a className={classes} onClick={cb}>
{content}
</a>
);
return (
<button type="button" onClick={props.onClick} className={classes}>
<button type="button" onClick={cb} className={classes}>
{content}
</button>
);
@ -101,11 +109,10 @@ interface ButtonPlainProps {
export function ButtonPlain(props: ButtonPlainProps) {
let colorClasses = "bg-white hover:bg-gray-200 text-black";
if (props.theme === "purple")
colorClasses =
"bg-video-buttons-purple hover:bg-video-buttons-purpleHover text-white";
colorClasses = "bg-buttons-purple hover:bg-buttons-purpleHover text-white";
if (props.theme === "secondary")
colorClasses =
"bg-video-buttons-cancel hover:bg-video-buttons-cancelHover transition-colors duration-100 text-white";
"bg-buttons-cancel hover:bg-buttons-cancelHover transition-colors duration-100 text-white";
const classes = classNames(
"cursor-pointer inline-flex items-center justify-center rounded-lg font-medium transition-[transform,background-color] duration-100 active:scale-105 md:px-8",

View file

@ -0,0 +1,35 @@
import { memo } from "react";
import { Icon, Icons } from "@/components/Icon";
export enum UserIcons {
SEARCH = "search",
BOOKMARK = "bookmark",
CLOCK = "clock",
EYE_SLASH = "eyeSlash",
USER = "user",
}
export interface UserIconProps {
icon: UserIcons;
className?: string;
}
const iconList: Record<UserIcons, string> = {
search: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M500.3 443.7l-119.7-119.7c27.22-40.41 40.65-90.9 33.46-144.7C401.8 87.79 326.8 13.32 235.2 1.723C99.01-15.51-15.51 99.01 1.724 235.2c11.6 91.64 86.08 166.7 177.6 178.9c53.8 7.189 104.3-6.236 144.7-33.46l119.7 119.7c15.62 15.62 40.95 15.62 56.57 0C515.9 484.7 515.9 459.3 500.3 443.7zM79.1 208c0-70.58 57.42-128 128-128s128 57.42 128 128c0 70.58-57.42 128-128 128S79.1 278.6 79.1 208z"/></svg>`,
bookmark: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 384 512"><!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M384 48V512l-192-112L0 512V48C0 21.5 21.5 0 48 0h288C362.5 0 384 21.5 384 48z"/></svg>`,
clock: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M256 512C114.6 512 0 397.4 0 256C0 114.6 114.6 0 256 0C397.4 0 512 114.6 512 256C512 397.4 397.4 512 256 512zM232 256C232 264 236 271.5 242.7 275.1L338.7 339.1C349.7 347.3 364.6 344.3 371.1 333.3C379.3 322.3 376.3 307.4 365.3 300L280 243.2V120C280 106.7 269.3 96 255.1 96C242.7 96 231.1 106.7 231.1 120L232 256z"/></svg>`,
eyeSlash: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 640 512"><!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M150.7 92.77C195 58.27 251.8 32 320 32C400.8 32 465.5 68.84 512.6 112.6C559.4 156 590.7 207.1 605.5 243.7C608.8 251.6 608.8 260.4 605.5 268.3C592.1 300.6 565.2 346.1 525.6 386.7L630.8 469.1C641.2 477.3 643.1 492.4 634.9 502.8C626.7 513.2 611.6 515.1 601.2 506.9L9.196 42.89C-1.236 34.71-3.065 19.63 5.112 9.196C13.29-1.236 28.37-3.065 38.81 5.112L150.7 92.77zM223.1 149.5L313.4 220.3C317.6 211.8 320 202.2 320 191.1C320 180.5 316.1 169.7 311.6 160.4C314.4 160.1 317.2 159.1 320 159.1C373 159.1 416 202.1 416 255.1C416 269.7 413.1 282.7 407.1 294.5L446.6 324.7C457.7 304.3 464 280.9 464 255.1C464 176.5 399.5 111.1 320 111.1C282.7 111.1 248.6 126.2 223.1 149.5zM320 480C239.2 480 174.5 443.2 127.4 399.4C80.62 355.1 49.34 304 34.46 268.3C31.18 260.4 31.18 251.6 34.46 243.7C44 220.8 60.29 191.2 83.09 161.5L177.4 235.8C176.5 242.4 176 249.1 176 255.1C176 335.5 240.5 400 320 400C338.7 400 356.6 396.4 373 389.9L446.2 447.5C409.9 467.1 367.8 480 320 480H320z"/></svg>`,
user: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-user"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg>`,
};
export const UserIcon = memo((props: UserIconProps) => {
const icon = iconList[props.icon];
if (!icon) return <Icon icon={Icons.X} />;
return (
<span
dangerouslySetInnerHTML={{ __html: icon }} // eslint-disable-line react/no-danger
className={props.className}
/>
);
});

View file

@ -0,0 +1,39 @@
import classNames from "classnames";
import { Icon, Icons } from "../Icon";
export function ColorPicker(props: {
label: string;
value: string;
onInput: (v: string) => void;
}) {
// Migrate this to another file later
const colors = ["#2E65CF", "#7652DD", "#CF2E68", "#C2CF2E", "#2ECFA8"];
return (
<div className="space-y-3">
{props.label ? (
<p className="font-bold text-white">{props.label}</p>
) : null}
<div className="flex gap-3">
{colors.map((color) => {
return (
<button
type="button"
tabIndex={0}
className={classNames(
"w-full h-10 rounded flex justify-center items-center text-white pointer border-2 border-opacity-10 cursor-pointer",
props.value === color ? "border-white" : "border-transparent"
)}
onClick={() => props.onInput(color)}
style={{ backgroundColor: color }}
key={color}
>
{props.value === color ? <Icon icon={Icons.CHECKMARK} /> : null}
</button>
);
})}
</div>
</div>
);
}

View file

@ -0,0 +1,47 @@
import classNames from "classnames";
import { UserIcon, UserIcons } from "../UserIcon";
export function IconPicker(props: {
label: string;
value: UserIcons;
onInput: (v: UserIcons) => void;
}) {
// Migrate this to another file later
const icons = [
UserIcons.USER,
UserIcons.BOOKMARK,
UserIcons.CLOCK,
UserIcons.EYE_SLASH,
UserIcons.SEARCH,
];
return (
<div className="space-y-3">
{props.label ? (
<p className="font-bold text-white">{props.label}</p>
) : null}
<div className="flex gap-3">
{icons.map((icon) => {
return (
<button
type="button"
tabIndex={0}
className={classNames(
"w-full h-10 rounded flex justify-center items-center text-white pointer border-2 border-opacity-10 cursor-pointer",
props.value === icon
? "bg-buttons-purple border-white"
: "bg-authentication-inputBg border-transparent"
)}
onClick={() => props.onInput(icon)}
key={icon}
>
<UserIcon className="text-xl" icon={icon} />
</button>
);
})}
</div>
</div>
);
}

View file

@ -38,7 +38,7 @@ export function LargeCardText(props: {
export function LargeCardButtons(props: { children: React.ReactNode }) {
return (
<div className="flex justify-center mt-8">
<div className="flex justify-center mt-12">
<div className="mx-auto inline-grid grid-cols-1 gap-3 justify-center items-center">
{props.children}
</div>

View file

@ -93,14 +93,14 @@ export function NextEpisodeButton(props: {
])}
>
<Button
className="py-px box-content bg-video-buttons-secondary hover:bg-video-buttons-secondaryHover bg-opacity-90 text-video-buttons-secondaryText"
className="py-px box-content bg-buttons-secondary hover:bg-buttons-secondaryHover bg-opacity-90 text-buttons-secondaryText"
onClick={hideNextEpisodeButton}
>
Cancel
</Button>
<Button
onClick={() => loadNextEpisode()}
className="bg-video-buttons-primary hover:bg-video-buttons-primaryHover text-video-buttons-primaryText flex justify-center items-center"
className="bg-buttons-primary hover:bg-buttons-primaryHover text-buttons-primaryText flex justify-center items-center"
>
<Icon className="text-xl mr-1" icon={Icons.SKIP_EPISODE} />
Next episode

View file

@ -1,3 +1,4 @@
import { useRef } from "react";
import { useAsync, useInterval } from "react-use";
import { useAuth } from "@/hooks/auth/useAuth";
@ -6,11 +7,18 @@ const AUTH_CHECK_INTERVAL = 12 * 60 * 60 * 1000;
export function useAuthRestore() {
const { restore } = useAuth();
const hasRestored = useRef(false);
useInterval(() => {
restore();
}, AUTH_CHECK_INTERVAL);
const result = useAsync(() => restore(), [restore]);
const result = useAsync(async () => {
if (hasRestored.current) return;
await restore().finally(() => {
hasRestored.current = true;
});
}, []); // no deps because we don't want to it ever rerun after the first time
return result;
}

View file

@ -1,5 +1,5 @@
import "core-js/stable";
import React from "react";
import React, { Suspense } from "react";
import type { ReactNode } from "react";
import ReactDOM from "react-dom";
import { HelmetProvider } from "react-helmet-async";
@ -32,10 +32,15 @@ registerSW({
immediate: true,
});
function LoadingScreen(props: { type: "user" | "lazy" }) {
return <p>Loading: {props.type}</p>;
}
function AuthWrapper() {
const status = useAuthRestore();
if (status.loading) return <p>Fetching user data</p>;
// TODO what to do when failing to load user data?
if (status.loading) return <LoadingScreen type="user" />;
if (status.error) return <p>Failed to fetch user data</p>;
return <App />;
}
@ -62,9 +67,11 @@ ReactDOM.render(
<React.StrictMode>
<ErrorBoundary>
<HelmetProvider>
<TheRouter>
<MigrationRunner />
</TheRouter>
<Suspense fallback={<LoadingScreen type="lazy" />}>
<TheRouter>
<MigrationRunner />
</TheRouter>
</Suspense>
</HelmetProvider>
</ErrorBoundary>
</React.StrictMode>,

View file

@ -1,5 +1,6 @@
import { useState } from "react";
import { GoogleReCaptchaProvider } from "react-google-recaptcha-v3";
import { useHistory } from "react-router-dom";
import { MetaResponse } from "@/backend/accounts/meta";
import { SubPageLayout } from "@/pages/layouts/SubPageLayout";
@ -24,13 +25,12 @@ function CaptchaProvider(props: {
}
export function RegisterPage() {
const history = useHistory();
const [step, setStep] = useState(0);
const [mnemonic, setMnemonic] = useState<null | string>(null);
const [account, setAccount] = useState<null | AccountProfile>(null);
const [siteKey, setSiteKey] = useState<string | null>(null);
// TODO because of user data loading (in useAuthRestore()), the register page gets unmounted before finishing the register flow
return (
<CaptchaProvider siteKey={siteKey}>
<SubPageLayout>
@ -68,11 +68,10 @@ export function RegisterPage() {
mnemonic={mnemonic}
userData={account}
onNext={() => {
setStep(4);
history.push("/");
}}
/>
) : null}
{step === 4 ? <p>Success, account now exists</p> : null}
</SubPageLayout>
</CaptchaProvider>
);

View file

@ -1,6 +1,8 @@
import { useCallback, useState } from "react";
import { Button } from "@/components/Button";
import { ColorPicker } from "@/components/form/ColorPicker";
import { IconPicker } from "@/components/form/IconPicker";
import { Icon, Icons } from "@/components/Icon";
import {
LargeCard,
@ -8,6 +10,7 @@ import {
LargeCardText,
} from "@/components/layout/LargeCard";
import { AuthInputBox } from "@/components/text-inputs/AuthInputBox";
import { UserIcons } from "@/components/UserIcon";
export interface AccountProfile {
device: string;
@ -24,18 +27,21 @@ interface AccountCreatePartProps {
export function AccountCreatePart(props: AccountCreatePartProps) {
const [device, setDevice] = useState("");
const [colorA, setColorA] = useState("#2E65CF");
const [colorB, setColorB] = useState("#2E65CF");
const [userIcon, setUserIcon] = useState<UserIcons>(UserIcons.USER);
// TODO validate device and account before next step
const nextStep = useCallback(() => {
props.onNext?.({
device,
profile: {
colorA: "#fff",
colorB: "#000",
icon: "brush",
colorA,
colorB,
icon: userIcon,
},
});
}, [device, props]);
}, [device, props, colorA, colorB, userIcon]);
return (
<LargeCard>
@ -45,12 +51,17 @@ export function AccountCreatePart(props: AccountCreatePartProps) {
>
Set up your account.... OR ELSE!
</LargeCardText>
<AuthInputBox
label="Device name"
value={device}
onChange={setDevice}
placeholder="Muad'Dib's Nintendo Switch"
/>
<div className="space-y-6">
<AuthInputBox
label="Device name"
value={device}
onChange={setDevice}
placeholder="Muad'Dib's Nintendo Switch"
/>
<ColorPicker label="First color" value={colorA} onInput={setColorA} />
<ColorPicker label="Second color" value={colorB} onInput={setColorB} />
<IconPicker label="User icon" value={userIcon} onInput={setUserIcon} />
</div>
<LargeCardButtons>
<Button theme="purple" onClick={() => nextStep()}>
Next

View file

@ -27,7 +27,6 @@ export function LoginFormPart(props: LoginFormPartProps) {
if (!verifyValidMnemonic(inputMnemonic))
throw new Error("Invalid or incomplete passphrase");
// TODO captcha?
await login({
mnemonic: inputMnemonic,
userData: {
@ -64,7 +63,6 @@ export function LoginFormPart(props: LoginFormPartProps) {
onChange={setDevice}
placeholder="Device"
/>
{result.loading ? <p>Loading...</p> : null}
{result.error && !result.loading ? (
<p className="text-authentication-errorText">
{result.error.message}
@ -73,7 +71,11 @@ export function LoginFormPart(props: LoginFormPartProps) {
</div>
<LargeCardButtons>
<Button theme="purple" onClick={() => execute(mnemonic, device)}>
<Button
theme="purple"
loading={result.loading}
onClick={() => execute(mnemonic, device)}
>
LET ME IN!
</Button>
</LargeCardButtons>

View file

@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from "react";
import { GoogleReCaptcha, useGoogleReCaptcha } from "react-google-recaptcha-v3";
import { useState } from "react";
import { useGoogleReCaptcha } from "react-google-recaptcha-v3";
import { useAsyncFn } from "react-use";
import { Button } from "@/components/Button";
@ -77,7 +77,11 @@ export function VerifyPassphrase(props: VerifyPassphraseProps) {
</p>
) : null}
<LargeCardButtons>
<Button theme="purple" onClick={() => execute(mnemonic)}>
<Button
theme="purple"
loading={result.loading}
onClick={() => execute(mnemonic)}
>
Register
</Button>
</LargeCardButtons>

View file

@ -26,23 +26,23 @@ module.exports = {
"ash-400": "#3D394D",
"ash-300": "#2C293A",
"ash-200": "#2B2836",
"ash-100": "#1E1C26",
"ash-100": "#1E1C26"
},
/* fonts */
fontFamily: {
"open-sans": "'Open Sans'",
"open-sans": "'Open Sans'"
},
/* animations */
keyframes: {
"loading-pin": {
"0%, 40%, 100%": { height: "0.5em", "background-color": "#282336" },
"20%": { height: "1em", "background-color": "white" },
},
"20%": { height: "1em", "background-color": "white" }
}
},
animation: { "loading-pin": "loading-pin 1.8s ease-in-out infinite" },
},
animation: { "loading-pin": "loading-pin 1.8s ease-in-out infinite" }
}
},
plugins: [
require("tailwind-scrollbar"),
@ -52,18 +52,18 @@ module.exports = {
colors: {
// Branding
pill: {
background: "#1C1C36",
background: "#1C1C36"
},
// meta data for the theme itself
global: {
accentA: "#505DBD",
accentB: "#3440A1",
accentB: "#3440A1"
},
// light bar
lightBar: {
light: "#2A2A71",
light: "#2A2A71"
},
// Buttons
@ -72,13 +72,24 @@ module.exports = {
toggleDisabled: "#202836",
danger: "#792131",
dangerHover: "#8a293b",
secondary: "#161F25",
secondaryText: "#8EA3B0",
secondaryHover: "#1B262E",
primary: "#fff",
primaryText: "#000",
primaryHover: "#dedede",
purple: "#6b298a",
purpleHover: "#7f35a1",
cancel: "#252533",
cancelHover: "#3C3C4A"
},
// only used for body colors/textures
background: {
main: "#0A0A10",
accentA: "#6E3B80",
accentB: "#1F1F50",
accentB: "#1F1F50"
},
// typography
@ -87,7 +98,7 @@ module.exports = {
text: "#73739D",
dimmed: "#926CAD",
divider: "#262632",
secondary: "#64647B",
secondary: "#64647B"
},
// search bar
@ -96,7 +107,7 @@ module.exports = {
focused: "#24243C",
placeholder: "#4A4A71",
icon: "#545476",
text: "#FFFFFF",
text: "#FFFFFF"
},
// media cards
@ -108,13 +119,13 @@ module.exports = {
barColor: "#4B4B63",
barFillColor: "#BA7FD6",
badge: "#151522",
badgeText: "#5F5F7A",
badgeText: "#5F5F7A"
},
// Large card
largeCard: {
background: "#171728",
icon: "#6741A5",
icon: "#6741A5"
},
// Passphrase
@ -124,7 +135,7 @@ module.exports = {
wordBackground: "#171728",
copyText: "#58587A",
copyTextHover: "#8888AA",
errorText: "#DB3D62",
errorText: "#DB3D62"
},
// Settings page
@ -137,19 +148,19 @@ module.exports = {
inactive: "#8D68A9",
icon: "#926CAD",
iconActivated: "#6942A8",
activated: "#CBA1E8",
},
activated: "#CBA1E8"
}
},
card: {
border: "#2A243E",
background: "#29243D",
altBackground: "#29243D",
},
altBackground: "#29243D"
}
},
utils: {
divider: "#353549",
divider: "#353549"
},
// Error page
@ -158,20 +169,20 @@ module.exports = {
border: "#252534",
type: {
secondary: "#62627D",
},
secondary: "#62627D"
}
},
// About page
about: {
circle: "#262632",
circleText: "#9A9AC3",
circleText: "#9A9AC3"
},
progress: {
background: "#8787A8",
preloaded: "#8787A8",
filled: "#A75FC9",
filled: "#A75FC9"
},
// video player
@ -183,24 +194,11 @@ module.exports = {
error: "#E44F4F",
success: "#40B44B",
loading: "#B759D8",
noresult: "#64647B",
noresult: "#64647B"
},
audio: {
set: "#A75FC9",
},
buttons: {
secondary: "#161F25",
secondaryText: "#8EA3B0",
secondaryHover: "#1B262E",
primary: "#fff",
primaryText: "#000",
primaryHover: "#dedede",
purple: "#6b298a",
purpleHover: "#7f35a1",
cancel: "#252533",
cancelHover: "#3C3C4A",
set: "#A75FC9"
},
context: {
@ -220,19 +218,19 @@ module.exports = {
buttons: {
list: "#161C26",
active: "#0D1317",
active: "#0D1317"
},
type: {
main: "#617A8A",
secondary: "#374A56",
accent: "#A570FA",
},
},
},
},
},
},
}),
],
accent: "#A570FA"
}
}
}
}
}
}
})
]
};