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;
}
h2 {
font-size: 2rem;
font-stretch: expanded;
font-weight: 500;
}
html,
body,
input,
button,
label {
transition: background-color var(--theme-transition),
color var(--theme-transition);
}
input:disabled,
button:disabled {
opacity: 0.5;
cursor: not-allowed !important;
}
input,
button {
font: 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 {

View File

@ -1,28 +1,36 @@
.content {
max-width: 700px;
margin: 0 auto;
width: min-content;
margin: 2rem auto;
}
.profileGrid {
/* todo */
display: grid;
grid-template-columns: 300px 1fr;
column-gap: 2rem;
}
.divider {
width: 400px;
background-color: var(--fg-darker);
height: 1px;
border: none;
margin: 1rem auto;
.profileTower *:first-child {
margin-top: 0 !important;
}
.logout {
background: var(--bg-dark);
border: 0;
border-radius: 0.15rem;
cursor: pointer;
padding: 0.5em 1em;
.connections {
margin-top: 1rem;
}
.connections > *:nth-child(2) {
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 */

View File

@ -5,7 +5,7 @@ import { UserInfo } from "@/ldap";
import React from "react";
import styles from "./AboutMe.module.css";
import AvatarChanger from "@/components/AvatarChanger";
import Input from "@/components/Input";
import Input, { Label } from "@/components/Input";
import { Form, Formik, FormikHelpers } from "formik";
import {
AboutMeFormValues,
@ -16,84 +16,22 @@ import {
import PrettyForm from "@/components/PrettyForm";
import Toast from "@/components/Toast";
import { AuthProviderState } from "@/auth/AuthProvider";
import { exec } from "child_process";
import inputStyles from "@/components/Input.module.css";
import Connection from "@/components/Connection";
type UpdateResponse = {
ok: boolean;
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({
info,
providers
providers: [discordState, githubState]
}: {
info: UserInfo;
providers: AuthProviderState[];
}) {
// TODO: Reimplement password changing.
const [globalError, setGlobalError] = React.useState<string | null>(null);
const [madeProfileChanges, setMadeChanges] = React.useState(false);
const [madePasswordChanges, setMadePasswordChanges] = React.useState(false);
@ -182,7 +120,6 @@ export default function AboutMe({
return (
<div className={styles.content}>
<h2 className={styles.userName}>{info.username}</h2>
<PrettyForm globalError={globalError}>
<Formik
initialValues={initialValues}
@ -191,14 +128,36 @@ export default function AboutMe({
>
{({ isSubmitting }) => (
<Form className={styles.profileGrid}>
{madeProfileChanges ? <Toast>Saved your changes.</Toast> : null}
<div className={styles.profileTower}>
<Input
type="file"
name="avatar"
customRender={(fieldProps) => (
<AvatarChanger
currentAvatarBlob={fieldProps.field.value}
onChange={(newBlob) =>
fieldProps.form.setFieldValue("avatar", newBlob)
}
vertical
/>
)}
/>
</div>
<div>
<h2 className={styles.userName}>{info.username}</h2>
<div className={styles.rightGrid}>
<div>
{madeProfileChanges ? (
<Toast>Saved your changes.</Toast>
) : null}
<Input
type="text"
name="username"
label="Username"
defaultValue={info.username}
disabled
title="You can't change your username."
hint="This can&rsquo;t be changed."
/>
<Input
type="text"
@ -206,6 +165,7 @@ export default function AboutMe({
label="Display name"
defaultValue={info.displayName}
/>
<Input
type="email"
name="email"
@ -213,20 +173,24 @@ export default function AboutMe({
defaultValue={info.email}
/>
<Input
type="file"
name="avatar"
label="Avatar"
accept="image/png, image/jpeg"
customRender={(fieldProps) => (
<AvatarChanger
currentAvatarBlob={fieldProps.field.value}
onChange={(newBlob) =>
fieldProps.form.setFieldValue("avatar", newBlob)
}
/>
)}
/>
<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"
@ -234,13 +198,22 @@ export default function AboutMe({
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>
)}
</Formik>
</PrettyForm>
<hr className={styles.divider} />
<h2 className={styles.header}>Change password</h2>
<PrettyForm globalError={passwordError}>
{/*<PrettyForm globalError={passwordError}>
<Formik
initialValues={initialPasswordValues}
onSubmit={handlePasswordSubmit}
@ -286,7 +259,6 @@ export default function AboutMe({
</Formik>
</PrettyForm>
<hr className={styles.divider} />
<h2 className={styles.header}>Connections</h2>
<div className={styles.authProviderList}>
{providers.map((provider) => (
@ -294,7 +266,6 @@ export default function AboutMe({
))}
</div>
<hr className={styles.divider} />
<input
type="button"
value="Log out"
@ -304,7 +275,7 @@ export default function AboutMe({
"ticket=; expires=" + new Date().toUTCString() + "; path=/";
window.location.href = "/";
}}
/>
/>*/}
</div>
);
}

View File

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

View File

@ -8,10 +8,12 @@ import UserIcon from "./icons/UserIcon";
export default function AvatarChanger({
currentAvatarBlob,
onChange
onChange,
vertical = false
}: {
currentAvatarBlob: string | null;
onChange: (newAvatar: string) => void;
vertical?: boolean;
}) {
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
return (
<div className={classnames(styles.avatarChanger, "avatar-changer")}>
<div
className={classnames(
styles.avatarChanger,
"avatar-changer",
vertical ? styles.vertical : null
)}
>
{currentAvatarBlob != null ? (
<img src={currentAvatarBlob!} alt="Your avatar" />
<img
className="current-avatar"
src={currentAvatarBlob!}
alt="Your avatar"
/>
) : (
<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;
}
.formRow label {
.label {
display: block;
font-variant: all-small-caps;
font-size: 105%;

View File

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

View File

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