first proto of nice about me page :3

This commit is contained in:
Skip R. 2023-04-28 08:53:58 -07:00
parent fd79df9ec1
commit 967bb2a2d2
10 changed files with 298 additions and 155 deletions

View File

@ -67,24 +67,41 @@ body {
sans-serif; sans-serif;
} }
h2 {
font-size: 2rem;
font-stretch: expanded;
font-weight: 500;
}
html, html,
body, body,
input, input,
button,
label { label {
transition: background-color var(--theme-transition), transition: background-color var(--theme-transition),
color var(--theme-transition); color var(--theme-transition);
} }
input:disabled,
button:disabled {
opacity: 0.5;
cursor: not-allowed !important;
}
input, input,
button { button {
font: inherit; font: inherit;
color: inherit; color: inherit;
background-color: inherit;
}
button,
input[type="submit"] {
padding: 0.5em 1em;
background-color: var(--bg-dark);
border-radius: 0.25rem;
border: none;
cursor: pointer;
}
input:disabled,
button:disabled {
opacity: 0.5;
cursor: not-allowed !important;
} }
input::placeholder { input::placeholder {

View File

@ -1,28 +1,36 @@
.content { .content {
max-width: 700px; width: min-content;
margin: 0 auto; margin: 2rem auto;
} }
.profileGrid { .profileGrid {
/* todo */ display: grid;
grid-template-columns: 300px 1fr;
column-gap: 2rem;
} }
.divider { .profileTower *:first-child {
width: 400px; margin-top: 0 !important;
background-color: var(--fg-darker);
height: 1px;
border: none;
margin: 1rem auto;
} }
.logout { .connections {
background: var(--bg-dark); margin-top: 1rem;
border: 0; }
border-radius: 0.15rem;
cursor: pointer; .connections > *:nth-child(2) {
padding: 0.5em 1em; margin-top: 0.5rem;
}
.rightGrid {
display: grid;
grid-template-columns: repeat(2, 1fr);
column-gap: 2rem;
}
.userName {
font-size: 3rem;
text-transform: uppercase;
margin: 0;
} }
/* stolen from prettyform */ /* stolen from prettyform */

View File

@ -5,7 +5,7 @@ import { UserInfo } from "@/ldap";
import React from "react"; import React from "react";
import styles from "./AboutMe.module.css"; import styles from "./AboutMe.module.css";
import AvatarChanger from "@/components/AvatarChanger"; import AvatarChanger from "@/components/AvatarChanger";
import Input from "@/components/Input"; import Input, { Label } from "@/components/Input";
import { Form, Formik, FormikHelpers } from "formik"; import { Form, Formik, FormikHelpers } from "formik";
import { import {
AboutMeFormValues, AboutMeFormValues,
@ -16,84 +16,22 @@ import {
import PrettyForm from "@/components/PrettyForm"; import PrettyForm from "@/components/PrettyForm";
import Toast from "@/components/Toast"; import Toast from "@/components/Toast";
import { AuthProviderState } from "@/auth/AuthProvider"; import { AuthProviderState } from "@/auth/AuthProvider";
import { exec } from "child_process"; import inputStyles from "@/components/Input.module.css";
import Connection from "@/components/Connection";
type UpdateResponse = { type UpdateResponse = {
ok: boolean; ok: boolean;
error?: string; error?: string;
}; };
async function fileAsBase64(f: File) {
const reader = new FileReader();
reader.readAsArrayBuffer(f);
return new Promise<string>((resolve, reject) => {
reader.onload = () => {
const result = reader.result as ArrayBuffer;
const buffer = Buffer.from(result);
resolve(buffer.toString("base64"));
};
reader.onerror = () => reject(reader.error);
});
}
function AuthProviderButton({ provider }: { provider: AuthProviderState }) {
// bullshit hack
const holdTime = provider.connected ? 3000 : 0;
const interval = React.useRef<NodeJS.Timeout | null>();
const inputRef = React.useRef<HTMLInputElement>(null);
const execute = async () => {
const name = provider.name.toLowerCase();
if (!provider.connected) {
window.location.href = `/oauth/${name}/login`;
} else {
await fetch(`/api/unlink?provider=${name}`, { method: "POST" });
window.location.reload();
}
};
const mouseDown = () => {
interval.current = setTimeout(execute, holdTime);
};
const mouseUp = () => {
if (interval.current) clearTimeout(interval.current);
};
return (
<input
type="submit"
className={
styles.fancyInput + " " + (provider.connected ? styles.progress : "")
}
onMouseDown={mouseDown}
onMouseUp={mouseUp}
value={provider.connected ? "Disconnect" : "Connect"}
ref={inputRef}
/>
);
}
function AuthProviderEntry({ provider }: { provider: AuthProviderState }) {
return (
<>
<p>
{provider.name}:{" "}
{provider.connected ? provider.username : "Not connected"}
</p>
<AuthProviderButton provider={provider} />
</>
);
}
export default function AboutMe({ export default function AboutMe({
info, info,
providers providers: [discordState, githubState]
}: { }: {
info: UserInfo; info: UserInfo;
providers: AuthProviderState[]; providers: AuthProviderState[];
}) { }) {
// TODO: Reimplement password changing.
const [globalError, setGlobalError] = React.useState<string | null>(null); const [globalError, setGlobalError] = React.useState<string | null>(null);
const [madeProfileChanges, setMadeChanges] = React.useState(false); const [madeProfileChanges, setMadeChanges] = React.useState(false);
const [madePasswordChanges, setMadePasswordChanges] = React.useState(false); const [madePasswordChanges, setMadePasswordChanges] = React.useState(false);
@ -182,7 +120,6 @@ export default function AboutMe({
return ( return (
<div className={styles.content}> <div className={styles.content}>
<h2 className={styles.userName}>{info.username}</h2>
<PrettyForm globalError={globalError}> <PrettyForm globalError={globalError}>
<Formik <Formik
initialValues={initialValues} initialValues={initialValues}
@ -191,56 +128,92 @@ export default function AboutMe({
> >
{({ isSubmitting }) => ( {({ isSubmitting }) => (
<Form className={styles.profileGrid}> <Form className={styles.profileGrid}>
{madeProfileChanges ? <Toast>Saved your changes.</Toast> : null} <div className={styles.profileTower}>
<Input <Input
type="text" type="file"
name="username" name="avatar"
label="Username" customRender={(fieldProps) => (
defaultValue={info.username} <AvatarChanger
disabled currentAvatarBlob={fieldProps.field.value}
title="You can't change your username." onChange={(newBlob) =>
/> fieldProps.form.setFieldValue("avatar", newBlob)
<Input }
type="text" vertical
name="displayName" />
label="Display name" )}
defaultValue={info.displayName} />
/> </div>
<Input
type="email"
name="email"
label="Email"
defaultValue={info.email}
/>
<Input <div>
type="file" <h2 className={styles.userName}>{info.username}</h2>
name="avatar" <div className={styles.rightGrid}>
label="Avatar" <div>
accept="image/png, image/jpeg" {madeProfileChanges ? (
customRender={(fieldProps) => ( <Toast>Saved your changes.</Toast>
<AvatarChanger ) : null}
currentAvatarBlob={fieldProps.field.value} <Input
onChange={(newBlob) => type="text"
fieldProps.form.setFieldValue("avatar", newBlob) name="username"
} label="Username"
/> defaultValue={info.username}
)} disabled
/> hint="This can&rsquo;t be changed."
/>
<Input
type="text"
name="displayName"
label="Display name"
defaultValue={info.displayName}
/>
<input <Input
type="submit" type="email"
value="Save" name="email"
className={styles.fancyInput} label="Email"
disabled={isSubmitting} defaultValue={info.email}
/> />
<div className={inputStyles.formRow}>
<button type="button">Change Password</button>
</div>
<div className={inputStyles.formRow}>
<button
type="button"
onClick={async () => {
document.cookie =
"ticket=; expires=" +
new Date().toUTCString() +
"; path=/";
window.location.href = "/";
}}
>
Log out
</button>
</div>
<input
type="submit"
value="Save"
className={styles.fancyInput}
disabled={isSubmitting}
/>
</div>
<div className={styles.connections}>
<Label>Connections</Label>
<Connection service="Discord" authState={discordState} />
<Connection service="GitHub" authState={githubState} />
<Connection service="Tailscale" unavailable />
<Connection service="Migadu" unavailable />
</div>
</div>
</div>
</Form> </Form>
)} )}
</Formik> </Formik>
</PrettyForm> </PrettyForm>
<hr className={styles.divider} /> {/*<PrettyForm globalError={passwordError}>
<h2 className={styles.header}>Change password</h2>
<PrettyForm globalError={passwordError}>
<Formik <Formik
initialValues={initialPasswordValues} initialValues={initialPasswordValues}
onSubmit={handlePasswordSubmit} onSubmit={handlePasswordSubmit}
@ -286,7 +259,6 @@ export default function AboutMe({
</Formik> </Formik>
</PrettyForm> </PrettyForm>
<hr className={styles.divider} />
<h2 className={styles.header}>Connections</h2> <h2 className={styles.header}>Connections</h2>
<div className={styles.authProviderList}> <div className={styles.authProviderList}>
{providers.map((provider) => ( {providers.map((provider) => (
@ -294,7 +266,6 @@ export default function AboutMe({
))} ))}
</div> </div>
<hr className={styles.divider} />
<input <input
type="button" type="button"
value="Log out" value="Log out"
@ -304,7 +275,7 @@ export default function AboutMe({
"ticket=; expires=" + new Date().toUTCString() + "; path=/"; "ticket=; expires=" + new Date().toUTCString() + "; path=/";
window.location.href = "/"; window.location.href = "/";
}} }}
/> />*/}
</div> </div>
); );
} }

View File

@ -2,32 +2,44 @@
display: flex; display: flex;
flex-flow: row nowrap; flex-flow: row nowrap;
gap: 1rem; gap: 1rem;
margin: 0.5rem 0; margin: 0.5rem 0;
} }
.avatarChanger :is(img, svg) { .vertical {
flex-direction: column;
}
.avatarChanger .current-avatar,
.avatarChanger svg {
width: 3em; width: 3em;
height: 3em; height: 3em;
border-radius: 0.25rem; border-radius: 0.25rem;
} }
.vertical.vertical .current-avatar {
display: block;
width: 100%;
height: inherit;
aspect-ratio: 1/1;
}
.avatarChanger button svg { .avatarChanger button svg {
width: 1.2em; width: 1.2em;
height: 1.2em; height: 1.2em;
margin-right: 0.5em; margin-right: 0.5em;
} }
.avatarChanger input[type=file] { .avatarChanger input[type="file"] {
display: none; display: none;
} }
.uploadButton { .uploadButton {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center;
background: var(--bg-dark); background: var(--bg-dark);
border: 0; border: 0;
border-radius: 0.15rem; border-radius: 0.15rem;
padding: 0.25em 1em; padding: 0.5em 1em;
cursor: pointer; cursor: pointer;
} }

View File

@ -8,10 +8,12 @@ import UserIcon from "./icons/UserIcon";
export default function AvatarChanger({ export default function AvatarChanger({
currentAvatarBlob, currentAvatarBlob,
onChange onChange,
vertical = false
}: { }: {
currentAvatarBlob: string | null; currentAvatarBlob: string | null;
onChange: (newAvatar: string) => void; onChange: (newAvatar: string) => void;
vertical?: boolean;
}) { }) {
const input = React.useRef<HTMLInputElement>(null); const input = React.useRef<HTMLInputElement>(null);
@ -25,9 +27,19 @@ export default function AvatarChanger({
// I give you the most support and well wishes while you work on this project -Ari // I give you the most support and well wishes while you work on this project -Ari
return ( return (
<div className={classnames(styles.avatarChanger, "avatar-changer")}> <div
className={classnames(
styles.avatarChanger,
"avatar-changer",
vertical ? styles.vertical : null
)}
>
{currentAvatarBlob != null ? ( {currentAvatarBlob != null ? (
<img src={currentAvatarBlob!} alt="Your avatar" /> <img
className="current-avatar"
src={currentAvatarBlob!}
alt="Your avatar"
/>
) : ( ) : (
<UserIcon /> <UserIcon />
)} )}

View File

@ -0,0 +1,45 @@
.connection {
padding: 1rem 2rem;
background-color: var(--bg-dark);
margin: 1rem 0;
border-radius: 0.25rem;
width: 300px;
display: flex;
flex-flow: row nowrap;
column-gap: 1rem;
align-items: center;
text-align: left;
}
.connection svg {
height: 1.5rem;
margin-left: auto;
}
.iconContainer {
font-size: 2.5em;
margin-right: 0.75rem;
}
.info {
display: flex;
flex-flow: column nowrap;
row-gap: 0.25rem;
}
.serviceName {
font-weight: 500;
font-stretch: expanded;
font-size: 1.3em;
}
.linkedIdentity {
opacity: 0.7;
}
.unavailable.unavailable {
background-color: var(--bg-darker);
color: var(--fg-dark);
opacity: 0.5;
cursor: not-allowed;
}

View File

@ -0,0 +1,70 @@
import React from "react";
import styles from "./Connection.module.css";
import classnames from "classnames";
import CheckIcon from "./icons/CheckIcon";
import { type AuthProviderState } from "@/auth/AuthProvider";
import { useRouter } from "next/navigation";
export default function Connection({
service,
unavailable = false,
authState
}: {
service: string;
unavailable?: boolean;
authState?: AuthProviderState;
}) {
const router = useRouter();
const [changing, setChanging] = React.useState(false);
// TODO: Reimplement hold-to-unlink.
async function handleClick(event: React.MouseEvent<HTMLButtonElement>) {
event.preventDefault();
if (unavailable) return;
const provider = service.toLowerCase();
if (authState?.connected === false) {
setChanging(true);
router.push(`/oauth/${provider}/login`);
} else {
setChanging(true);
if (confirm(`Unlink your ${service} account?`)) {
await fetch(`/api/unlink?provider=${provider}`, { method: "POST" });
window.location.reload();
} else {
setChanging(false);
}
}
}
return (
<button
className={classnames(
styles.connection,
unavailable ? styles.unavailable : null,
!authState?.connected ? styles.disconnected : null
)}
onClick={handleClick}
disabled={changing}
>
<div className={styles.iconContainer}>&#9679;</div>
<div className={styles.info}>
<div className={styles.serviceName}>{service}</div>
{authState?.connected !== false ? (
<div
className={styles.linkedIdentity}
title={
authState?.id ?? "This integration is currently unavailable."
}
>
{unavailable ? "Unavailable" : authState!.username}
</div>
) : null}
</div>
{authState?.connected && !unavailable ? <CheckIcon /> : null}
</button>
);
}

View File

@ -13,7 +13,7 @@
margin: 1rem 0; margin: 1rem 0;
} }
.formRow label { .label {
display: block; display: block;
font-variant: all-small-caps; font-variant: all-small-caps;
font-size: 105%; font-size: 105%;

View File

@ -1,6 +1,7 @@
import { Field, FieldProps, FieldAttributes, FormikProps } from "formik"; import { Field, FieldProps, FieldAttributes, FormikProps } from "formik";
import React from "react"; import React, { LabelHTMLAttributes } from "react";
import styles from "./Input.module.css"; import styles from "./Input.module.css";
import classnames from "classnames";
type CustomInputProps<T> = { type CustomInputProps<T> = {
customRender?: (fieldProps: FieldProps) => React.ReactNode; customRender?: (fieldProps: FieldProps) => React.ReactNode;
@ -11,15 +12,26 @@ type CustomInputProps<T> = {
) => void; ) => void;
}; };
export function Label({
children,
...props
}: LabelHTMLAttributes<HTMLLabelElement>) {
return (
<label className={classnames(styles.label, props.className)} {...props}>
{children}
</label>
);
}
export default function Input<T>( export default function Input<T>(
props: CustomInputProps<T> & props: CustomInputProps<T> &
FieldAttributes<{ hint?: string; label: string; disabled?: boolean }> FieldAttributes<{ hint?: string; label?: string; disabled?: boolean }>
) { ) {
const generatedId = React.useId(); const generatedId = React.useId();
return ( return (
<div className={styles.formRow}> <div className={classnames("form-row", styles.formRow)}>
<label htmlFor={generatedId}>{props.label}</label> {props.label ? <Label htmlFor={generatedId}>{props.label}</Label> : null}
<Field id={generatedId} {...props}> <Field id={generatedId} {...props}>
{(fieldProps: FieldProps) => { {(fieldProps: FieldProps) => {
let { field, meta, form } = fieldProps; let { field, meta, form } = fieldProps;

View File

@ -1,7 +1,3 @@
.form {
max-width: 500px;
}
.form :is(button, input)[type="submit"] { .form :is(button, input)[type="submit"] {
background: var(--bg-dark); background: var(--bg-dark);
border: 0; border: 0;