Compare commits

..

2 Commits

Author SHA1 Message Date
Skip R. 967bb2a2d2 first proto of nice about me page :3 2023-04-28 08:53:58 -07:00
Skip R. fd79df9ec1 add lunchtype fonts 2023-04-28 07:39:04 -07:00
20 changed files with 355 additions and 156 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

53
src/app/fonts.css Normal file
View File

@ -0,0 +1,53 @@
@font-face {
font-family: lunchtype;
font-weight: 400;
src: url("/fonts/lunchtype22-regular.woff2") format("woff2");
}
@font-face {
font-family: lunchtype;
font-weight: 400;
font-style: italic;
src: url("/fonts/lunchtype22-regular-italic.woff2") format("woff2");
}
@font-face {
font-family: lunchtype;
font-weight: 500;
src: url("/fonts/lunchtype22-medium.woff2") format("woff2");
}
@font-face {
font-family: lunchtype;
font-weight: 500;
font-style: italic;
src: url("/fonts/lunchtype22-medium.woff2") format("woff2");
}
@font-face {
font-family: lunchtype;
font-weight: 400;
font-stretch: condensed;
src: url("/fonts/lunchtype25-regular-condensed.woff2") format("woff2");
}
@font-face {
font-family: lunchtype;
font-weight: 500;
font-stretch: condensed;
src: url("/fonts/lunchtype25-medium-condensed.woff2") format("woff2");
}
@font-face {
font-family: lunchtype;
font-weight: 400;
font-stretch: expanded;
src: url("/fonts/lunchtype24-regular-expanded.woff2") format("woff2");
}
@font-face {
font-family: lunchtype;
font-weight: 500;
font-stretch: expanded;
src: url("/fonts/lunchtype24-medium-expanded.woff2") format("woff2");
}

View File

@ -62,27 +62,46 @@ body {
overflow-x: hidden; overflow-x: hidden;
color: var(--fg); color: var(--fg);
background-color: var(--bg); background-color: var(--bg);
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, font-family: lunchtype, system-ui, -apple-system, BlinkMacSystemFont,
Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue",
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,5 +1,6 @@
import ColorChanger from "@/components/ColorChanger"; import ColorChanger from "@/components/ColorChanger";
import "./globals.css"; import "./globals.css";
import "./fonts.css";
export const metadata = { export const metadata = {
title: "gluestick", title: "gluestick",

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,14 +128,36 @@ 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
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 <Input
type="text" type="text"
name="username" name="username"
label="Username" label="Username"
defaultValue={info.username} defaultValue={info.username}
disabled disabled
title="You can't change your username." hint="This can&rsquo;t be changed."
/> />
<Input <Input
type="text" type="text"
@ -206,6 +165,7 @@ export default function AboutMe({
label="Display name" label="Display name"
defaultValue={info.displayName} defaultValue={info.displayName}
/> />
<Input <Input
type="email" type="email"
name="email" name="email"
@ -213,20 +173,24 @@ export default function AboutMe({
defaultValue={info.email} defaultValue={info.email}
/> />
<Input <div className={inputStyles.formRow}>
type="file" <button type="button">Change Password</button>
name="avatar" </div>
label="Avatar"
accept="image/png, image/jpeg" <div className={inputStyles.formRow}>
customRender={(fieldProps) => ( <button
<AvatarChanger type="button"
currentAvatarBlob={fieldProps.field.value} onClick={async () => {
onChange={(newBlob) => document.cookie =
fieldProps.form.setFieldValue("avatar", newBlob) "ticket=; expires=" +
} new Date().toUTCString() +
/> "; path=/";
)} window.location.href = "/";
/> }}
>
Log out
</button>
</div>
<input <input
type="submit" type="submit"
@ -234,13 +198,22 @@ export default function AboutMe({
className={styles.fancyInput} className={styles.fancyInput}
disabled={isSubmitting} 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;