first proto of nice about me page :3
This commit is contained in:
parent
fd79df9ec1
commit
967bb2a2d2
|
@ -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 {
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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’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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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 />
|
||||
)}
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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}>●</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>
|
||||
);
|
||||
}
|
|
@ -13,7 +13,7 @@
|
|||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.formRow label {
|
||||
.label {
|
||||
display: block;
|
||||
font-variant: all-small-caps;
|
||||
font-size: 105%;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -1,7 +1,3 @@
|
|||
.form {
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.form :is(button, input)[type="submit"] {
|
||||
background: var(--bg-dark);
|
||||
border: 0;
|
||||
|
|
Loading…
Reference in New Issue