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

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;