This commit is contained in:
Skip R. 2023-04-26 19:59:17 -07:00
parent c0b8ee2427
commit 2acdeb8c95
17 changed files with 476 additions and 401 deletions

11
package-lock.json generated
View File

@ -14,6 +14,7 @@
"@types/react": "18.0.38", "@types/react": "18.0.38",
"@types/react-dom": "18.0.11", "@types/react-dom": "18.0.11",
"asn1": "^0.2.6", "asn1": "^0.2.6",
"classnames": "^2.3.2",
"dotenv": "^16.0.3", "dotenv": "^16.0.3",
"eslint": "8.39.0", "eslint": "8.39.0",
"eslint-config-next": "13.3.1", "eslint-config-next": "13.3.1",
@ -3138,6 +3139,11 @@
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==" "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="
}, },
"node_modules/classnames": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.2.tgz",
"integrity": "sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw=="
},
"node_modules/clean-stack": { "node_modules/clean-stack": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
@ -10555,6 +10561,11 @@
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==" "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="
}, },
"classnames": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.2.tgz",
"integrity": "sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw=="
},
"clean-stack": { "clean-stack": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",

View File

@ -18,6 +18,7 @@
"@types/react": "18.0.38", "@types/react": "18.0.38",
"@types/react-dom": "18.0.11", "@types/react-dom": "18.0.11",
"asn1": "^0.2.6", "asn1": "^0.2.6",
"classnames": "^2.3.2",
"dotenv": "^16.0.3", "dotenv": "^16.0.3",
"eslint": "8.39.0", "eslint": "8.39.0",
"eslint-config-next": "13.3.1", "eslint-config-next": "13.3.1",

View File

@ -1,8 +1,5 @@
import ColorChanger from "@/components/ColorChanger"; import ColorChanger from "@/components/ColorChanger";
import "./globals.css"; import "./globals.css";
import { Inter } from "next/font/google";
const inter = Inter({ subsets: ["latin"] });
export const metadata = { export const metadata = {
title: "gluestick", title: "gluestick",
@ -19,10 +16,11 @@ export default function RootLayout({
<html lang="en"> <html lang="en">
<head> <head>
<link rel="icon" href="/icon.svg" /> <link rel="icon" href="/icon.svg" />
{/* todo: lmfao */}
<meta property="og:image" content="/icon.svg" /> <meta property="og:image" content="/icon.svg" />
</head> </head>
<body className={inter.className}> <body>
{children} {children}
<ColorChanger /> <ColorChanger />

View File

@ -54,7 +54,7 @@ export default function LoginForm() {
onSubmit={handleFormSubmit} onSubmit={handleFormSubmit}
validationSchema={loginSchema} validationSchema={loginSchema}
> >
{() => ( {({ isSubmitting }) => (
<Form> <Form>
<Input <Input
type="text" type="text"
@ -68,7 +68,7 @@ export default function LoginForm() {
name="password" name="password"
label="Password" label="Password"
/> />
<input type="submit" value="Login" /> <input type="submit" value="Login" disabled={isSubmitting} />
</Form> </Form>
)} )}
</Formik> </Formik>

View File

@ -1,103 +1,26 @@
.content { .content {
max-width: 500px; max-width: 700px;
margin: auto; margin: 0 auto;
} }
.header { .profileGrid {
padding: 1rem; /* todo */
padding-bottom: 0;
}
.form {
max-width: 500px;
}
.form input[type="submit"] {
padding: 1rem 1.5rem;
font-size: 140%;
background: var(--bg-dark);
border: 0;
border-radius: 0.15rem;
cursor: pointer;
font-weight: 600;
}
.buttonContainer {
display: flex;
justify-content: center;
margin: 2rem 0;
}
.buttonContainer input:disabled {
cursor: not-allowed;
color: var(--fg-dark);
}
.formRow {
margin: 1rem 0;
display: flex;
flex-direction: row;
justify-content: center;
}
.formRow label {
font-variant: all-small-caps;
font-size: 105%;
width: 100px;
height: 50px;
/* center */
display: flex;
align-items: center;
}
.formVert {
flex-direction: column;
align-items: center;
}
.fancyInput {
padding: 0.5em 1em;
border: none;
border-radius: 0.15rem;
margin: 0.5rem 0;
width: 250px;
display: block;
background: var(--bg-dark);
}
.formRow input[name="avatar"] {
width: 190px;
}
.formRow .avatar {
margin-right: 10px;
border-radius: 10%;
}
.formRow input:disabled {
cursor: not-allowed;
background: var(--bg-darker);
color: var(--fg-darker);
}
.hint {
color: var(--fg-dark);
font-size: 80%;
transition: color var(--theme-transition);
}
.error {
color: var(--error);
font-size: 80%;
transition: color var(--theme-transition);
} }
.divider { .divider {
width: 400px; width: 400px;
margin: auto;
background-color: var(--fg-darker); background-color: var(--fg-darker);
height: 1px; height: 1px;
border: none; border: none;
margin: 1rem auto;
} }
.logout {
background: var(--bg-dark);
border: 0;
border-radius: 0.15rem;
cursor: pointer;
padding: 0.5em 1em;
}

View File

@ -4,8 +4,16 @@
import { UserInfo } from "@/ldap"; import { UserInfo } from "@/ldap";
import React, { HTMLInputTypeAttribute, InputHTMLAttributes } from "react"; import React, { HTMLInputTypeAttribute, InputHTMLAttributes } from "react";
import styles from "./AboutMe.module.css"; import styles from "./AboutMe.module.css";
import AvatarChanger from "@/components/AvatarChanger";
const fallbackAvatar = "https://i.clong.biz/i/oc4zjlqr.png"; import Input from "@/components/Input";
import { Form, Formik, FormikHelpers } from "formik";
import {
AboutMeFormValues,
PasswordUpdateFormValues,
aboutMeSchema,
passwordUpdateSchema
} from "@/schemas";
import PrettyForm from "@/components/PrettyForm";
type UpdateResponse = { type UpdateResponse = {
ok: boolean; ok: boolean;
@ -21,35 +29,6 @@ type InputProps = {
displayImage?: string; displayImage?: string;
} & InputHTMLAttributes<HTMLInputElement>; } & InputHTMLAttributes<HTMLInputElement>;
const Input = React.forwardRef<HTMLInputElement, InputProps>((props, ref) => {
// get console to shut up
const inputProps = { ...props };
delete inputProps.displayImage;
return (
<div className={styles.formRow}>
<label htmlFor={props.id}>{props.label}</label>
{props.displayImage && (
<img
src={props.displayImage}
className={styles.avatar}
alt={"Your avatar"}
width="50px"
height="50px"
/>
)}
<div className={styles.formVert}>
<input {...inputProps} ref={ref} className={styles.fancyInput} />
{props.error != null && <p className={styles.error}>{props.error}</p>}
</div>
</div>
);
});
Input.displayName = "Input";
async function fileAsBase64(f: File) { async function fileAsBase64(f: File) {
const reader = new FileReader(); const reader = new FileReader();
reader.readAsArrayBuffer(f); reader.readAsArrayBuffer(f);
@ -64,235 +43,200 @@ async function fileAsBase64(f: File) {
} }
export default function AboutMe({ info }: { info: UserInfo }) { export default function AboutMe({ info }: { info: UserInfo }) {
const displayNameRef = React.useRef<HTMLInputElement>(null); const [globalError, setGlobalError] = React.useState<string | null>(null);
const emailRef = React.useRef<HTMLInputElement>(null); const initialValues: AboutMeFormValues = {
const avatarRef = React.useRef<HTMLInputElement>(null); username: info.username,
const submitRef = React.useRef<HTMLInputElement>(null); displayName: info.displayName,
email: info.email,
avatar: info.avatar
};
const [avatar, setAvatar] = React.useState<string | null>( async function handleFormSubmit(
info.avatar ?? null { displayName, email, avatar }: AboutMeFormValues,
); { setSubmitting }: FormikHelpers<AboutMeFormValues>
) {
setSubmitting(true);
const req = await fetch("/api/update", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
displayName,
email,
avatar: avatar != null ? avatar.split(",")[1] : null
})
});
setSubmitting(false);
const currentPasswordRef = React.useRef<HTMLInputElement>(null); try {
const newPasswordRef = React.useRef<HTMLInputElement>(null); const res: UpdateResponse = await req.json();
const confirmPasswordRef = React.useRef<HTMLInputElement>(null);
const submitPasswordRef = React.useRef<HTMLInputElement>(null);
const [incorrectPassword, setIncorrectPassword] = React.useState(false); if (!res.ok && res.error !== null) {
const [passwordMismatch, setPasswordMismatch] = React.useState(false); switch (res.error) {
const [avatarBig, setAvatarBig] = React.useState(false); case "avatarBig":
break;
}
}
} catch {
console.error(req);
}
}
const [passwordError, setPasswordError] = React.useState<string | null>(null);
const initialPasswordValues: PasswordUpdateFormValues = {
password: "",
newPassword: "",
confirmPassword: ""
};
async function handlePasswordSubmit(
{ password, newPassword }: PasswordUpdateFormValues,
{ setFieldError, setSubmitting }: FormikHelpers<PasswordUpdateFormValues>
) {
console.log(password, newPassword);
setSubmitting(true);
const req = await fetch("/api/changePassword", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
currentPassword: password,
newPassword: newPassword
})
});
setSubmitting(false);
try {
const res: UpdateResponse = await req.json();
if (!res.ok && res.error !== null) {
switch (res.error) {
case "incorrectPassword":
setFieldError("password", "Incorrect password.");
break;
}
}
} catch {
console.error(req);
}
}
return ( return (
<div className={styles.content}> <div className={styles.content}>
<h2 className={styles.header}>User information</h2> <h2 className={styles.userName}>{info.username}</h2>
<form <PrettyForm globalError={globalError}>
onSubmit={async (e) => { <Formik
e.preventDefault(); initialValues={initialValues}
onSubmit={handleFormSubmit}
validationSchema={aboutMeSchema}
>
{({ isSubmitting }) => (
<Form className={styles.profileGrid}>
<Input
type="text"
name="username"
label="Username"
defaultValue={info.username}
disabled
title="You can't change your username."
/>
<Input
type="text"
name="displayName"
label="Display name"
defaultValue={info.displayName}
/>
<Input
type="email"
name="email"
label="Email"
defaultValue={info.email}
/>
// turn the data uri into just base64 <Input
const avatarChanged = avatar !== null && avatar !== info.avatar; type="file"
const avatarData = avatarChanged ? avatar?.split(",")[1] : null; name="avatar"
label="Avatar"
accept="image/png, image/jpeg"
customRender={(fieldProps) => (
<AvatarChanger
currentAvatarBlob={fieldProps.field.value}
onChange={(newBlob) =>
fieldProps.form.setFieldValue("avatar", newBlob)
}
/>
)}
/>
submitRef.current!.disabled = true; <input
const req = await fetch("/api/update", { type="submit"
method: "POST", value="Save"
headers: { className={styles.fancyInput}
"Content-Type": "application/json" disabled={isSubmitting}
}, />
body: JSON.stringify({ </Form>
displayName: displayNameRef.current?.value, )}
email: emailRef.current?.value, </Formik>
avatar: avatarData </PrettyForm>
})
});
submitRef.current!.disabled = false;
try {
const res: UpdateResponse = await req.json();
if (!res.ok && res.error !== null) {
switch (res.error) {
case "avatarBig":
setAvatarBig(true);
break;
}
}
} catch {
console.error(req);
}
}}
>
<Input
type="text"
name="username"
label="Username"
defaultValue={info.username}
disabled
title="You can't change your username."
/>
<Input
type="text"
name="display-name"
label="Display name"
defaultValue={info.displayName}
ref={displayNameRef}
/>
<Input
type="email"
name="email"
label="Email"
defaultValue={info.email}
ref={emailRef}
/>
{/* why, html gods, why? */}
<input
type="file"
name="avatar"
accept="image/png, image/jpeg"
ref={avatarRef}
style={{ display: "none" }}
/>
<Input
type="button"
value="Choose file"
name="avatar"
label="Avatar"
accept="image/png, image/jpeg"
error={avatarBig ? "Avatar is too big." : undefined}
onClick={() => {
avatarRef.current?.click();
const eventListener = async () => {
avatarRef.current?.removeEventListener("change", eventListener);
const file = avatarRef.current?.files?.[0];
if (file == null) return;
if (file.size > 1_000_000) {
setAvatarBig(true);
return;
} else {
setAvatarBig(false);
}
const b64 = await fileAsBase64(file);
setAvatar(`data:${file.type};base64,${b64}`);
};
avatarRef.current?.addEventListener("change", eventListener);
}}
displayImage={avatar ?? fallbackAvatar}
/>
<div className={styles.formRow}>
<input
type="submit"
value="Save"
ref={submitRef}
className={styles.fancyInput}
/>
</div>
</form>
<hr className={styles.divider} /> <hr className={styles.divider} />
<h2 className={styles.header}>Change password</h2> <h2 className={styles.header}>Change password</h2>
<form <PrettyForm globalError={passwordError}>
onSubmit={async (e) => { <Formik
e.preventDefault(); initialValues={initialPasswordValues}
setIncorrectPassword(false); onSubmit={handlePasswordSubmit}
setPasswordMismatch(false); validationSchema={passwordUpdateSchema}
>
{({ isSubmitting }) => (
<Form className={styles.profileGrid}>
<Input
type="password"
name="password"
label="Current"
minLength={12}
required
/>
if ( <Input
newPasswordRef.current?.value !== confirmPasswordRef.current?.value type="password"
) { name="newPassword"
setPasswordMismatch(true); label="New"
return; minLength={12}
} required
/>
submitPasswordRef.current!.disabled = true; <Input
const req = await fetch("/api/changePassword", { type="password"
method: "POST", name="confirmPassword"
headers: { label="Confirm"
"Content-Type": "application/json" minLength={12}
}, required
body: JSON.stringify({ />
currentPassword: currentPasswordRef.current?.value,
newPassword: newPasswordRef.current?.value
})
});
submitPasswordRef.current!.disabled = false;
try { <input
const res: UpdateResponse = await req.json(); type="submit"
value="Save"
if (!res.ok && res.error !== null) { className={styles.fancyInput}
switch (res.error) { disabled={isSubmitting}
case "incorrectPassword": />
setIncorrectPassword(true); </Form>
break; )}
} </Formik>
} </PrettyForm>
} catch {
console.error(req);
}
}}
>
<Input
type="password"
name="current-password"
label="Current"
minLength={12}
required
ref={currentPasswordRef}
error={incorrectPassword ? "Incorrect password." : undefined}
/>
<Input
type="password"
name="new-password"
label="New"
minLength={12}
required
ref={newPasswordRef}
/>
<Input
type="password"
name="confirm-password"
label="Confirm"
ref={confirmPasswordRef}
minLength={12}
required
error={passwordMismatch ? "Passwords do not match." : undefined}
/>
<div className={styles.formRow}>
<input
type="submit"
value="Change password"
ref={submitPasswordRef}
className={styles.fancyInput}
/>
</div>
</form>
<hr className={styles.divider} /> <hr className={styles.divider} />
<input
<div className={styles.formRow}> type="button"
<input value="Log out"
type="button" className={styles.logout}
value="Log out" onClick={async () => {
className={styles.fancyInput} document.cookie =
onClick={async () => { "ticket=; expires=" + new Date().toUTCString() + "; path=/";
document.cookie = window.location.href = "/";
"ticket=; expires=" + new Date().toUTCString() + "; path=/"; }}
window.location.href = "/"; />
}}
/>
</div>
</div> </div>
); );
} }

View File

@ -8,7 +8,7 @@ export default async function Page() {
if (!user) redirect("/login"); if (!user) redirect("/login");
const info = await getUserInfo(user); const info = await getUserInfo(user);
if (info === null) redirect("/login"); if (info === null) redirect("/register");
return <AboutMe info={info} />; return <AboutMe info={info} />;
} }

View File

@ -9,6 +9,7 @@ import { fileAsBase64 } from "@/forms";
import Input from "@/components/Input"; import Input from "@/components/Input";
import PrettyForm from "@/components/PrettyForm"; import PrettyForm from "@/components/PrettyForm";
import HugeSubmit from "@/components/HugeSubmit"; import HugeSubmit from "@/components/HugeSubmit";
import AvatarChanger from "@/components/AvatarChanger";
type RegisterResponse = { type RegisterResponse = {
ok: boolean; ok: boolean;
@ -27,14 +28,14 @@ export default function RegisterForm({
const [globalError, setGlobalError] = React.useState<string | null>(null); const [globalError, setGlobalError] = React.useState<string | null>(null);
const router = useRouter(); const router = useRouter();
const [initialValues, setInitialValues] = React.useState<RegisterFormValues>({ const initialValues: RegisterFormValues = {
username: "", username: "",
displayName: initialDisplayName ?? "", displayName: initialDisplayName ?? "",
email: initialEmail ?? "", email: initialEmail ?? "",
password: "", password: "",
confirmPassword: "", confirmPassword: "",
avatar: undefined avatar: initialAvatarBase64
}); };
async function handleFormSubmit( async function handleFormSubmit(
{ avatar, username, displayName, email, password }: RegisterFormValues, { avatar, username, displayName, email, password }: RegisterFormValues,
@ -42,11 +43,6 @@ export default function RegisterForm({
) { ) {
setSubmitting(true); setSubmitting(true);
let avatarBase64 = avatar != null ? await fileAsBase64(avatar) : null;
if (avatarBase64 == null && initialAvatarBase64 != null) {
avatarBase64 = initialAvatarBase64.split(",")[1];
}
const resp = await fetch(`/api/register`, { const resp = await fetch(`/api/register`, {
method: "POST", method: "POST",
headers: { headers: {
@ -57,7 +53,7 @@ export default function RegisterForm({
displayName, displayName,
email, email,
password, password,
avatarBase64 avatarBase64: avatar != null ? avatar.split(",")[1] : undefined
}) })
}); });
@ -144,19 +140,20 @@ export default function RegisterForm({
<Input <Input
hint={ hint={
"This image will automatically be used as your avatar with supported services - maximum 1 MB. " + "This image will automatically be used as your avatar with supported services - maximum 1 MB. "
"Will use the avatar of the service you signed up with if not provided."
} }
type="file" type="file"
name="avatar" name="avatar"
label="Avatar" label="Avatar"
accept="image/png, image/jpeg" accept="image/png, image/jpeg"
customOnChange={(event, form) => { customRender={(fieldProps) => (
const file = event.currentTarget.files?.[0]; <AvatarChanger
if (file != null) { currentAvatarBlob={fieldProps.field.value}
form.setFieldValue("avatar", file); onChange={(newBlob) =>
} fieldProps.form.setFieldValue("avatar", newBlob)
}} }
/>
)}
/> />
<div className={styles.buttonContainer}> <div className={styles.buttonContainer}>

View File

@ -32,7 +32,7 @@ export default async function Page({
if (searchParams.avatar != null && searchParams.avatar !== "") { if (searchParams.avatar != null && searchParams.avatar !== "") {
const url = new URL(searchParams.avatar); const url = new URL(searchParams.avatar);
if (avatarUrlAllowed(url)) { if (!avatarUrlAllowed(url)) {
return <p>fuck off</p>; return <p>fuck off</p>;
} }
@ -40,12 +40,14 @@ export default async function Page({
const blob = await req.blob(); const blob = await req.blob();
const arrayBuffer = await blob.arrayBuffer(); const arrayBuffer = await blob.arrayBuffer();
const buffer = Buffer.from(arrayBuffer); const buffer = Buffer.from(arrayBuffer);
if (buffer.length <= 1_000_000) {
try { // I hope you are doing well, you deserve the best of luck while working on this project -Ari
initialAvatarBase64 = try {
"data:image/jpeg;base64," + (await ensureJpg(buffer)); const jpg = await ensureJpg(buffer);
} catch (e) { initialAvatarBase64 = "data:image/jpeg;base64," + jpg;
console.error(e); } catch (e) {
console.error(e);
}
} }
} }

View File

@ -0,0 +1,33 @@
.avatarChanger {
display: flex;
flex-flow: row nowrap;
gap: 1rem;
margin: 0.5rem 0;
}
.avatarChanger :is(img, svg) {
width: 3em;
height: 3em;
border-radius: 0.25rem;
}
.avatarChanger button svg {
width: 1.5em;
height: 1.5em;
margin-right: 0.5em;
}
.avatarChanger input[type=file] {
display: none;
}
.uploadButton {
display: flex;
align-items: center;
background: var(--bg-dark);
border: 0;
border-radius: 0.15rem;
padding: 0.25em 1em;
cursor: pointer;
}

View File

@ -0,0 +1,53 @@
import React, { ChangeEvent } from "react";
import classnames from "classnames";
import styles from "./AvatarChanger.module.css";
import { fileAsBase64 } from "@/forms";
import UploadIcon from "./icons/UploadIcon";
import UserIcon from "./icons/UserIcon";
export default function AvatarChanger({
currentAvatarBlob,
onChange
}: {
currentAvatarBlob: string | null;
onChange: (newAvatar: string) => void;
}) {
const input = React.useRef<HTMLInputElement>(null);
async function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
const file = event.currentTarget.files?.[0];
if (file == null) return;
const base64 = await fileAsBase64(file);
onChange(`data:${file.type};base64,${base64}`);
}
// I give you the most support and well wishes while you work on this project -Ari
return (
<div className={classnames(styles.avatarChanger, "avatar-changer")}>
{currentAvatarBlob != null ? (
<img src={currentAvatarBlob!} alt="Your avatar" />
) : (
<UserIcon />
)}
<button
type="button"
className={styles.uploadButton}
onClick={() => {
input.current?.click();
}}
>
<UploadIcon />
Upload a new avatar
</button>
<input
type="file"
accept="image/png, image/jpeg"
ref={input}
onChange={handleFileChange}
/>
</div>
);
}

View File

@ -9,14 +9,14 @@ type ColorScheme = {
bg: string; bg: string;
bgDark: string; bgDark: string;
bgDarker?: string; bgDarker: string;
fg: string; fg: string;
fgDark: string; fgDark: string;
fgDarker?: string; fgDarker: string;
error?: string; error: string;
warning?: string; warning: string;
}; };
const colors: ColorScheme[] = [ const colors: ColorScheme[] = [
@ -153,8 +153,10 @@ function set(colorScheme: ColorScheme) {
const fixedColors = { const fixedColors = {
"--bg": colorScheme.bg, "--bg": colorScheme.bg,
"--bg-dark": colorScheme.bgDark, "--bg-dark": colorScheme.bgDark,
"--bg-darker": colorScheme.bgDarker,
"--fg": colorScheme.fg, "--fg": colorScheme.fg,
"--fg-dark": colorScheme.fgDark, "--fg-dark": colorScheme.fgDark,
"--fg-darker": colorScheme.fgDarker,
"--error": colorScheme.error ?? fallback.error!, "--error": colorScheme.error ?? fallback.error!,
"--warning": colorScheme.warning ?? fallback.warning! "--warning": colorScheme.warning ?? fallback.warning!
}; };

View File

@ -3,6 +3,8 @@ import React from "react";
import styles from "./Input.module.css"; import styles from "./Input.module.css";
type CustomInputProps<T> = { type CustomInputProps<T> = {
customRender?: (fieldProps: FieldProps) => React.ReactNode;
customOnChange?: ( customOnChange?: (
event: React.ChangeEvent<HTMLInputElement>, event: React.ChangeEvent<HTMLInputElement>,
form: FormikProps<T> form: FormikProps<T>
@ -10,7 +12,8 @@ type CustomInputProps<T> = {
}; };
export default function Input<T>( export default function Input<T>(
props: CustomInputProps<T> & FieldAttributes<{ hint?: string; label: string }> props: CustomInputProps<T> &
FieldAttributes<{ hint?: string; label: string; disabled?: boolean }>
) { ) {
const generatedId = React.useId(); const generatedId = React.useId();
@ -18,7 +21,8 @@ export default function Input<T>(
<div className={styles.formRow}> <div className={styles.formRow}>
<label htmlFor={generatedId}>{props.label}</label> <label htmlFor={generatedId}>{props.label}</label>
<Field id={generatedId} {...props}> <Field id={generatedId} {...props}>
{({ field, meta, form }: FieldProps) => { {(fieldProps: FieldProps) => {
let { field, meta, form } = fieldProps;
let textAfterField = let textAfterField =
meta.touched && meta.error ? ( meta.touched && meta.error ? (
<p className={styles.error}>{meta.error}</p> <p className={styles.error}>{meta.error}</p>
@ -39,20 +43,26 @@ export default function Input<T>(
return ( return (
<> <>
<input {props.customRender == null ? (
type={props.type} <input
placeholder={props.placeholder} type={props.type}
{...inputFields} placeholder={props.placeholder}
onChange={(event) => { disabled={props.disabled}
console.log(event); title={props.title}
if (props.customOnChange) { {...inputFields}
console.log("using custom on change"); onChange={(event) => {
props.customOnChange(event, form); console.log(event);
} else { if (props.customOnChange) {
form.setFieldValue(field.name, event.currentTarget.value); console.log("using custom on change");
} props.customOnChange(event, form);
}} } else {
/> form.setFieldValue(field.name, event.currentTarget.value);
}
}}
/>
) : (
props.customRender(fieldProps)
)}
{textAfterField} {textAfterField}
</> </>
); );

View File

@ -14,4 +14,4 @@
color: var(--error); color: var(--error);
font-size: 80%; font-size: 80%;
transition: color var(--theme-transition); transition: color var(--theme-transition);
} }

View File

@ -0,0 +1,39 @@
import React from "react";
export default function UploadIcon() {
return (
<svg
viewBox="0 0 128 128"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
>
<g
stroke="none"
strokeWidth="1"
fill="none"
fillRule="evenodd"
strokeLinecap="round"
>
<path
d="M16,64 L16,92 C16,103.045695 24.954305,112 36,112 L92,112 C103.045695,112 112,103.045695 112,92 L112,64 L112,64"
stroke="currentColor"
strokeWidth="13"
></path>
<line
x1="64"
y1="80"
x2="64"
y2="16"
stroke="currentColor"
strokeWidth="13"
></line>
<polyline
stroke="currentColor"
strokeWidth="13"
points="32 48 64 16 96 48"
></polyline>
</g>
</svg>
);
}

View File

@ -0,0 +1,20 @@
import React from "react";
export default function UserIcon() {
return (
<svg
viewBox="0 0 128 128"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
>
<g stroke="none" strokeWidth="1" fill="none" fillRule="evenodd">
<circle fill="currentColor" cx="64" cy="48" r="32"></circle>
<path
d="M112,128 C112,101.490332 90.509668,80 64,80 C37.490332,80 16,101.490332 16,128 C16,128 112,128 112,128 Z"
fill="currentColor"
></path>
</g>
</svg>
);
}

View File

@ -4,9 +4,33 @@ const REQUIRED = "Required.";
const USERNAME = Yup.string() const USERNAME = Yup.string()
.required(REQUIRED) .required(REQUIRED)
.min(1, "Username is too short."); .min(1, "Username is too short.");
const DISPLAY_NAME = Yup.string()
.required(REQUIRED)
.min(1, "Display name is too short.");
const EMAIL = Yup.string().required(REQUIRED).email("Not an email.");
const PASSWORD = Yup.string() const PASSWORD = Yup.string()
.required(REQUIRED) .required(REQUIRED)
.min(12, "Password must be at least 12 characters long."); .min(12, "Password must be at least 12 characters long.");
const CONFIRM_PASSWORD = (name: string) =>
Yup.string()
.required(REQUIRED)
.oneOf([Yup.ref(name, {})], "Passwords must match.");
const AVATAR = Yup.string().test(
"file-size",
"File is bigger than 1 MB.",
(value) => {
if (value == null) return true;
try {
const buf = Buffer.from(value, "base64");
return buf.length <= 1_000_000;
} catch (e) {
return false;
}
}
);
export const loginSchema = Yup.object().shape({ export const loginSchema = Yup.object().shape({
username: USERNAME, username: USERNAME,
@ -21,20 +45,11 @@ export type LoginFormValues = {
export const registerSchema: Yup.Schema<RegisterFormValues> = export const registerSchema: Yup.Schema<RegisterFormValues> =
Yup.object().shape({ Yup.object().shape({
username: USERNAME, username: USERNAME,
displayName: Yup.string() displayName: DISPLAY_NAME,
.required(REQUIRED) email: EMAIL,
.min(1, "Display name is too short."),
email: Yup.string().required(REQUIRED).email("Not an email."),
confirmPassword: Yup.string()
.required(REQUIRED)
.oneOf([Yup.ref("password", {})], "Passwords must match."),
password: PASSWORD, password: PASSWORD,
avatar: Yup.mixed<File>() confirmPassword: CONFIRM_PASSWORD("password"),
.test("fileSize", "File is larger than 1 MB.", (value) => { avatar: AVATAR
if (value == null) return true;
return value.size <= 1_000_000;
})
.optional()
}); });
export interface RegisterFormValues { export interface RegisterFormValues {
@ -43,5 +58,32 @@ export interface RegisterFormValues {
email: string; email: string;
password: string; password: string;
confirmPassword: string; confirmPassword: string;
avatar?: File; avatar?: string;
}
export const aboutMeSchema: Yup.Schema<AboutMeFormValues> = Yup.object().shape({
username: USERNAME,
displayName: DISPLAY_NAME,
email: EMAIL,
avatar: AVATAR
});
export interface AboutMeFormValues {
username: string;
displayName: string;
email: string;
avatar?: string;
}
export const passwordUpdateSchema: Yup.Schema<PasswordUpdateFormValues> =
Yup.object().shape({
password: PASSWORD,
newPassword: PASSWORD,
confirmPassword: CONFIRM_PASSWORD("newPassword")
});
export interface PasswordUpdateFormValues {
password: string;
newPassword: string;
confirmPassword: string;
} }