Compare commits

..

No commits in common. "967bb2a2d20b9061358d77b3bbeff3fc3b4edf44" and "cbcb8268b0baf843a88bbd0a52fad238d31c95ec" have entirely different histories.

20 changed files with 156 additions and 355 deletions

View File

@ -1,53 +0,0 @@
@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,46 +62,27 @@ body {
overflow-x: hidden; overflow-x: hidden;
color: var(--fg); color: var(--fg);
background-color: var(--bg); background-color: var(--bg);
font-family: lunchtype, system-ui, -apple-system, BlinkMacSystemFont, font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", 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,6 +1,5 @@
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,36 +1,28 @@
.content { .content {
width: min-content; max-width: 700px;
margin: 2rem auto; margin: 0 auto;
} }
.profileGrid { .profileGrid {
display: grid; /* todo */
grid-template-columns: 300px 1fr;
column-gap: 2rem;
} }
.profileTower *:first-child { .divider {
margin-top: 0 !important; width: 400px;
background-color: var(--fg-darker);
height: 1px;
border: none;
margin: 1rem auto;
} }
.connections { .logout {
margin-top: 1rem; background: var(--bg-dark);
} border: 0;
border-radius: 0.15rem;
.connections > *:nth-child(2) { cursor: pointer;
margin-top: 0.5rem; padding: 0.5em 1em;
}
.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, { Label } from "@/components/Input"; import Input from "@/components/Input";
import { Form, Formik, FormikHelpers } from "formik"; import { Form, Formik, FormikHelpers } from "formik";
import { import {
AboutMeFormValues, AboutMeFormValues,
@ -16,22 +16,84 @@ 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 inputStyles from "@/components/Input.module.css"; import { exec } from "child_process";
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: [discordState, githubState] providers
}: { }: {
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);
@ -120,6 +182,7 @@ 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}
@ -128,36 +191,14 @@ export default function AboutMe({
> >
{({ isSubmitting }) => ( {({ isSubmitting }) => (
<Form className={styles.profileGrid}> <Form className={styles.profileGrid}>
<div className={styles.profileTower}> {madeProfileChanges ? <Toast>Saved your changes.</Toast> : null}
<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
hint="This can&rsquo;t be changed." title="You can't change your username."
/> />
<Input <Input
type="text" type="text"
@ -165,7 +206,6 @@ 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"
@ -173,24 +213,20 @@ export default function AboutMe({
defaultValue={info.email} defaultValue={info.email}
/> />
<div className={inputStyles.formRow}> <Input
<button type="button">Change Password</button> type="file"
</div> name="avatar"
label="Avatar"
<div className={inputStyles.formRow}> accept="image/png, image/jpeg"
<button customRender={(fieldProps) => (
type="button" <AvatarChanger
onClick={async () => { currentAvatarBlob={fieldProps.field.value}
document.cookie = onChange={(newBlob) =>
"ticket=; expires=" + fieldProps.form.setFieldValue("avatar", newBlob)
new Date().toUTCString() + }
"; path=/"; />
window.location.href = "/"; )}
}} />
>
Log out
</button>
</div>
<input <input
type="submit" type="submit"
@ -198,22 +234,13 @@ 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>
{/*<PrettyForm globalError={passwordError}> <hr className={styles.divider} />
<h2 className={styles.header}>Change password</h2>
<PrettyForm globalError={passwordError}>
<Formik <Formik
initialValues={initialPasswordValues} initialValues={initialPasswordValues}
onSubmit={handlePasswordSubmit} onSubmit={handlePasswordSubmit}
@ -259,6 +286,7 @@ 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) => (
@ -266,6 +294,7 @@ export default function AboutMe({
))} ))}
</div> </div>
<hr className={styles.divider} />
<input <input
type="button" type="button"
value="Log out" value="Log out"
@ -275,7 +304,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,44 +2,32 @@
display: flex; display: flex;
flex-flow: row nowrap; flex-flow: row nowrap;
gap: 1rem; gap: 1rem;
margin: 0.5rem 0; margin: 0.5rem 0;
} }
.vertical { .avatarChanger :is(img, svg) {
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.5em 1em; padding: 0.25em 1em;
cursor: pointer; cursor: pointer;
} }

View File

@ -8,12 +8,10 @@ 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);
@ -27,19 +25,9 @@ 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 <div className={classnames(styles.avatarChanger, "avatar-changer")}>
className={classnames(
styles.avatarChanger,
"avatar-changer",
vertical ? styles.vertical : null
)}
>
{currentAvatarBlob != null ? ( {currentAvatarBlob != null ? (
<img <img src={currentAvatarBlob!} alt="Your avatar" />
className="current-avatar"
src={currentAvatarBlob!}
alt="Your avatar"
/>
) : ( ) : (
<UserIcon /> <UserIcon />
)} )}

View File

@ -1,45 +0,0 @@
.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

@ -1,70 +0,0 @@
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;
} }
.label { .formRow label {
display: block; display: block;
font-variant: all-small-caps; font-variant: all-small-caps;
font-size: 105%; font-size: 105%;

View File

@ -1,7 +1,6 @@
import { Field, FieldProps, FieldAttributes, FormikProps } from "formik"; import { Field, FieldProps, FieldAttributes, FormikProps } from "formik";
import React, { LabelHTMLAttributes } from "react"; import React 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;
@ -12,26 +11,15 @@ 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={classnames("form-row", styles.formRow)}> <div className={styles.formRow}>
{props.label ? <Label htmlFor={generatedId}>{props.label}</Label> : null} <label htmlFor={generatedId}>{props.label}</label>
<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,3 +1,7 @@
.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;