chom
This commit is contained in:
parent
c0b8ee2427
commit
2acdeb8c95
17 changed files with 476 additions and 401 deletions
11
package-lock.json
generated
11
package-lock.json
generated
|
@ -14,6 +14,7 @@
|
|||
"@types/react": "18.0.38",
|
||||
"@types/react-dom": "18.0.11",
|
||||
"asn1": "^0.2.6",
|
||||
"classnames": "^2.3.2",
|
||||
"dotenv": "^16.0.3",
|
||||
"eslint": "8.39.0",
|
||||
"eslint-config-next": "13.3.1",
|
||||
|
@ -3138,6 +3139,11 @@
|
|||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
||||
"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": {
|
||||
"version": "2.2.0",
|
||||
"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",
|
||||
"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": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
|
||||
|
|
|
@ -18,6 +18,7 @@
|
|||
"@types/react": "18.0.38",
|
||||
"@types/react-dom": "18.0.11",
|
||||
"asn1": "^0.2.6",
|
||||
"classnames": "^2.3.2",
|
||||
"dotenv": "^16.0.3",
|
||||
"eslint": "8.39.0",
|
||||
"eslint-config-next": "13.3.1",
|
||||
|
|
|
@ -1,8 +1,5 @@
|
|||
import ColorChanger from "@/components/ColorChanger";
|
||||
import "./globals.css";
|
||||
import { Inter } from "next/font/google";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
export const metadata = {
|
||||
title: "gluestick",
|
||||
|
@ -19,10 +16,11 @@ export default function RootLayout({
|
|||
<html lang="en">
|
||||
<head>
|
||||
<link rel="icon" href="/icon.svg" />
|
||||
{/* todo: lmfao */}
|
||||
<meta property="og:image" content="/icon.svg" />
|
||||
</head>
|
||||
|
||||
<body className={inter.className}>
|
||||
<body>
|
||||
{children}
|
||||
|
||||
<ColorChanger />
|
||||
|
|
|
@ -54,7 +54,7 @@ export default function LoginForm() {
|
|||
onSubmit={handleFormSubmit}
|
||||
validationSchema={loginSchema}
|
||||
>
|
||||
{() => (
|
||||
{({ isSubmitting }) => (
|
||||
<Form>
|
||||
<Input
|
||||
type="text"
|
||||
|
@ -68,7 +68,7 @@ export default function LoginForm() {
|
|||
name="password"
|
||||
label="Password"
|
||||
/>
|
||||
<input type="submit" value="Login" />
|
||||
<input type="submit" value="Login" disabled={isSubmitting} />
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
|
|
|
@ -1,103 +1,26 @@
|
|||
.content {
|
||||
max-width: 500px;
|
||||
margin: auto;
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 1rem;
|
||||
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);
|
||||
.profileGrid {
|
||||
/* todo */
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 400px;
|
||||
margin: auto;
|
||||
|
||||
background-color: var(--fg-darker);
|
||||
height: 1px;
|
||||
border: none;
|
||||
|
||||
margin: 1rem auto;
|
||||
}
|
||||
|
||||
.logout {
|
||||
background: var(--bg-dark);
|
||||
border: 0;
|
||||
border-radius: 0.15rem;
|
||||
cursor: pointer;
|
||||
padding: 0.5em 1em;
|
||||
}
|
|
@ -4,8 +4,16 @@
|
|||
import { UserInfo } from "@/ldap";
|
||||
import React, { HTMLInputTypeAttribute, InputHTMLAttributes } from "react";
|
||||
import styles from "./AboutMe.module.css";
|
||||
|
||||
const fallbackAvatar = "https://i.clong.biz/i/oc4zjlqr.png";
|
||||
import AvatarChanger from "@/components/AvatarChanger";
|
||||
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 = {
|
||||
ok: boolean;
|
||||
|
@ -21,35 +29,6 @@ type InputProps = {
|
|||
displayImage?: string;
|
||||
} & 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) {
|
||||
const reader = new FileReader();
|
||||
reader.readAsArrayBuffer(f);
|
||||
|
@ -64,235 +43,200 @@ async function fileAsBase64(f: File) {
|
|||
}
|
||||
|
||||
export default function AboutMe({ info }: { info: UserInfo }) {
|
||||
const displayNameRef = React.useRef<HTMLInputElement>(null);
|
||||
const emailRef = React.useRef<HTMLInputElement>(null);
|
||||
const avatarRef = React.useRef<HTMLInputElement>(null);
|
||||
const submitRef = React.useRef<HTMLInputElement>(null);
|
||||
const [globalError, setGlobalError] = React.useState<string | null>(null);
|
||||
const initialValues: AboutMeFormValues = {
|
||||
username: info.username,
|
||||
displayName: info.displayName,
|
||||
email: info.email,
|
||||
avatar: info.avatar
|
||||
};
|
||||
|
||||
const [avatar, setAvatar] = React.useState<string | null>(
|
||||
info.avatar ?? null
|
||||
);
|
||||
async function handleFormSubmit(
|
||||
{ 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);
|
||||
const newPasswordRef = React.useRef<HTMLInputElement>(null);
|
||||
const confirmPasswordRef = React.useRef<HTMLInputElement>(null);
|
||||
const submitPasswordRef = React.useRef<HTMLInputElement>(null);
|
||||
try {
|
||||
const res: UpdateResponse = await req.json();
|
||||
|
||||
const [incorrectPassword, setIncorrectPassword] = React.useState(false);
|
||||
const [passwordMismatch, setPasswordMismatch] = React.useState(false);
|
||||
const [avatarBig, setAvatarBig] = React.useState(false);
|
||||
if (!res.ok && res.error !== null) {
|
||||
switch (res.error) {
|
||||
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 (
|
||||
<div className={styles.content}>
|
||||
<h2 className={styles.header}>User information</h2>
|
||||
<form
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
<h2 className={styles.userName}>{info.username}</h2>
|
||||
<PrettyForm globalError={globalError}>
|
||||
<Formik
|
||||
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
|
||||
const avatarChanged = avatar !== null && avatar !== info.avatar;
|
||||
const avatarData = avatarChanged ? avatar?.split(",")[1] : null;
|
||||
<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)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
submitRef.current!.disabled = true;
|
||||
const req = await fetch("/api/update", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
displayName: displayNameRef.current?.value,
|
||||
email: emailRef.current?.value,
|
||||
avatar: avatarData
|
||||
})
|
||||
});
|
||||
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>
|
||||
<input
|
||||
type="submit"
|
||||
value="Save"
|
||||
className={styles.fancyInput}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</PrettyForm>
|
||||
|
||||
<hr className={styles.divider} />
|
||||
|
||||
<h2 className={styles.header}>Change password</h2>
|
||||
<form
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
setIncorrectPassword(false);
|
||||
setPasswordMismatch(false);
|
||||
<PrettyForm globalError={passwordError}>
|
||||
<Formik
|
||||
initialValues={initialPasswordValues}
|
||||
onSubmit={handlePasswordSubmit}
|
||||
validationSchema={passwordUpdateSchema}
|
||||
>
|
||||
{({ isSubmitting }) => (
|
||||
<Form className={styles.profileGrid}>
|
||||
<Input
|
||||
type="password"
|
||||
name="password"
|
||||
label="Current"
|
||||
minLength={12}
|
||||
required
|
||||
/>
|
||||
|
||||
if (
|
||||
newPasswordRef.current?.value !== confirmPasswordRef.current?.value
|
||||
) {
|
||||
setPasswordMismatch(true);
|
||||
return;
|
||||
}
|
||||
<Input
|
||||
type="password"
|
||||
name="newPassword"
|
||||
label="New"
|
||||
minLength={12}
|
||||
required
|
||||
/>
|
||||
|
||||
submitPasswordRef.current!.disabled = true;
|
||||
const req = await fetch("/api/changePassword", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
currentPassword: currentPasswordRef.current?.value,
|
||||
newPassword: newPasswordRef.current?.value
|
||||
})
|
||||
});
|
||||
submitPasswordRef.current!.disabled = false;
|
||||
<Input
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
label="Confirm"
|
||||
minLength={12}
|
||||
required
|
||||
/>
|
||||
|
||||
try {
|
||||
const res: UpdateResponse = await req.json();
|
||||
|
||||
if (!res.ok && res.error !== null) {
|
||||
switch (res.error) {
|
||||
case "incorrectPassword":
|
||||
setIncorrectPassword(true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} 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>
|
||||
<input
|
||||
type="submit"
|
||||
value="Save"
|
||||
className={styles.fancyInput}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</PrettyForm>
|
||||
|
||||
<hr className={styles.divider} />
|
||||
|
||||
<div className={styles.formRow}>
|
||||
<input
|
||||
type="button"
|
||||
value="Log out"
|
||||
className={styles.fancyInput}
|
||||
onClick={async () => {
|
||||
document.cookie =
|
||||
"ticket=; expires=" + new Date().toUTCString() + "; path=/";
|
||||
window.location.href = "/";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="button"
|
||||
value="Log out"
|
||||
className={styles.logout}
|
||||
onClick={async () => {
|
||||
document.cookie =
|
||||
"ticket=; expires=" + new Date().toUTCString() + "; path=/";
|
||||
window.location.href = "/";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@ export default async function Page() {
|
|||
if (!user) redirect("/login");
|
||||
|
||||
const info = await getUserInfo(user);
|
||||
if (info === null) redirect("/login");
|
||||
if (info === null) redirect("/register");
|
||||
|
||||
return <AboutMe info={info} />;
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@ import { fileAsBase64 } from "@/forms";
|
|||
import Input from "@/components/Input";
|
||||
import PrettyForm from "@/components/PrettyForm";
|
||||
import HugeSubmit from "@/components/HugeSubmit";
|
||||
import AvatarChanger from "@/components/AvatarChanger";
|
||||
|
||||
type RegisterResponse = {
|
||||
ok: boolean;
|
||||
|
@ -27,14 +28,14 @@ export default function RegisterForm({
|
|||
const [globalError, setGlobalError] = React.useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
const [initialValues, setInitialValues] = React.useState<RegisterFormValues>({
|
||||
const initialValues: RegisterFormValues = {
|
||||
username: "",
|
||||
displayName: initialDisplayName ?? "",
|
||||
email: initialEmail ?? "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
avatar: undefined
|
||||
});
|
||||
avatar: initialAvatarBase64
|
||||
};
|
||||
|
||||
async function handleFormSubmit(
|
||||
{ avatar, username, displayName, email, password }: RegisterFormValues,
|
||||
|
@ -42,11 +43,6 @@ export default function RegisterForm({
|
|||
) {
|
||||
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`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
|
@ -57,7 +53,7 @@ export default function RegisterForm({
|
|||
displayName,
|
||||
email,
|
||||
password,
|
||||
avatarBase64
|
||||
avatarBase64: avatar != null ? avatar.split(",")[1] : undefined
|
||||
})
|
||||
});
|
||||
|
||||
|
@ -144,19 +140,20 @@ export default function RegisterForm({
|
|||
|
||||
<Input
|
||||
hint={
|
||||
"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."
|
||||
"This image will automatically be used as your avatar with supported services - maximum 1 MB. "
|
||||
}
|
||||
type="file"
|
||||
name="avatar"
|
||||
label="Avatar"
|
||||
accept="image/png, image/jpeg"
|
||||
customOnChange={(event, form) => {
|
||||
const file = event.currentTarget.files?.[0];
|
||||
if (file != null) {
|
||||
form.setFieldValue("avatar", file);
|
||||
}
|
||||
}}
|
||||
customRender={(fieldProps) => (
|
||||
<AvatarChanger
|
||||
currentAvatarBlob={fieldProps.field.value}
|
||||
onChange={(newBlob) =>
|
||||
fieldProps.form.setFieldValue("avatar", newBlob)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className={styles.buttonContainer}>
|
||||
|
|
|
@ -32,7 +32,7 @@ export default async function Page({
|
|||
if (searchParams.avatar != null && searchParams.avatar !== "") {
|
||||
const url = new URL(searchParams.avatar);
|
||||
|
||||
if (avatarUrlAllowed(url)) {
|
||||
if (!avatarUrlAllowed(url)) {
|
||||
return <p>fuck off</p>;
|
||||
}
|
||||
|
||||
|
@ -40,12 +40,14 @@ export default async function Page({
|
|||
const blob = await req.blob();
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
try {
|
||||
initialAvatarBase64 =
|
||||
"data:image/jpeg;base64," + (await ensureJpg(buffer));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
if (buffer.length <= 1_000_000) {
|
||||
// I hope you are doing well, you deserve the best of luck while working on this project -Ari
|
||||
try {
|
||||
const jpg = await ensureJpg(buffer);
|
||||
initialAvatarBase64 = "data:image/jpeg;base64," + jpg;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
33
src/components/AvatarChanger.module.css
Normal file
33
src/components/AvatarChanger.module.css
Normal 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;
|
||||
}
|
53
src/components/AvatarChanger.tsx
Normal file
53
src/components/AvatarChanger.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -9,14 +9,14 @@ type ColorScheme = {
|
|||
|
||||
bg: string;
|
||||
bgDark: string;
|
||||
bgDarker?: string;
|
||||
bgDarker: string;
|
||||
|
||||
fg: string;
|
||||
fgDark: string;
|
||||
fgDarker?: string;
|
||||
fgDarker: string;
|
||||
|
||||
error?: string;
|
||||
warning?: string;
|
||||
error: string;
|
||||
warning: string;
|
||||
};
|
||||
|
||||
const colors: ColorScheme[] = [
|
||||
|
@ -153,8 +153,10 @@ function set(colorScheme: ColorScheme) {
|
|||
const fixedColors = {
|
||||
"--bg": colorScheme.bg,
|
||||
"--bg-dark": colorScheme.bgDark,
|
||||
"--bg-darker": colorScheme.bgDarker,
|
||||
"--fg": colorScheme.fg,
|
||||
"--fg-dark": colorScheme.fgDark,
|
||||
"--fg-darker": colorScheme.fgDarker,
|
||||
"--error": colorScheme.error ?? fallback.error!,
|
||||
"--warning": colorScheme.warning ?? fallback.warning!
|
||||
};
|
||||
|
|
|
@ -3,6 +3,8 @@ import React from "react";
|
|||
import styles from "./Input.module.css";
|
||||
|
||||
type CustomInputProps<T> = {
|
||||
customRender?: (fieldProps: FieldProps) => React.ReactNode;
|
||||
|
||||
customOnChange?: (
|
||||
event: React.ChangeEvent<HTMLInputElement>,
|
||||
form: FormikProps<T>
|
||||
|
@ -10,7 +12,8 @@ type CustomInputProps<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();
|
||||
|
||||
|
@ -18,7 +21,8 @@ export default function Input<T>(
|
|||
<div className={styles.formRow}>
|
||||
<label htmlFor={generatedId}>{props.label}</label>
|
||||
<Field id={generatedId} {...props}>
|
||||
{({ field, meta, form }: FieldProps) => {
|
||||
{(fieldProps: FieldProps) => {
|
||||
let { field, meta, form } = fieldProps;
|
||||
let textAfterField =
|
||||
meta.touched && meta.error ? (
|
||||
<p className={styles.error}>{meta.error}</p>
|
||||
|
@ -39,20 +43,26 @@ export default function Input<T>(
|
|||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
type={props.type}
|
||||
placeholder={props.placeholder}
|
||||
{...inputFields}
|
||||
onChange={(event) => {
|
||||
console.log(event);
|
||||
if (props.customOnChange) {
|
||||
console.log("using custom on change");
|
||||
props.customOnChange(event, form);
|
||||
} else {
|
||||
form.setFieldValue(field.name, event.currentTarget.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{props.customRender == null ? (
|
||||
<input
|
||||
type={props.type}
|
||||
placeholder={props.placeholder}
|
||||
disabled={props.disabled}
|
||||
title={props.title}
|
||||
{...inputFields}
|
||||
onChange={(event) => {
|
||||
console.log(event);
|
||||
if (props.customOnChange) {
|
||||
console.log("using custom on change");
|
||||
props.customOnChange(event, form);
|
||||
} else {
|
||||
form.setFieldValue(field.name, event.currentTarget.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
props.customRender(fieldProps)
|
||||
)}
|
||||
{textAfterField}
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -14,4 +14,4 @@
|
|||
color: var(--error);
|
||||
font-size: 80%;
|
||||
transition: color var(--theme-transition);
|
||||
}
|
||||
}
|
||||
|
|
39
src/components/icons/UploadIcon.tsx
Normal file
39
src/components/icons/UploadIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
20
src/components/icons/UserIcon.tsx
Normal file
20
src/components/icons/UserIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -4,9 +4,33 @@ const REQUIRED = "Required.";
|
|||
const USERNAME = Yup.string()
|
||||
.required(REQUIRED)
|
||||
.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()
|
||||
.required(REQUIRED)
|
||||
.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({
|
||||
username: USERNAME,
|
||||
|
@ -21,20 +45,11 @@ export type LoginFormValues = {
|
|||
export const registerSchema: Yup.Schema<RegisterFormValues> =
|
||||
Yup.object().shape({
|
||||
username: USERNAME,
|
||||
displayName: Yup.string()
|
||||
.required(REQUIRED)
|
||||
.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."),
|
||||
displayName: DISPLAY_NAME,
|
||||
email: EMAIL,
|
||||
password: PASSWORD,
|
||||
avatar: Yup.mixed<File>()
|
||||
.test("fileSize", "File is larger than 1 MB.", (value) => {
|
||||
if (value == null) return true;
|
||||
return value.size <= 1_000_000;
|
||||
})
|
||||
.optional()
|
||||
confirmPassword: CONFIRM_PASSWORD("password"),
|
||||
avatar: AVATAR
|
||||
});
|
||||
|
||||
export interface RegisterFormValues {
|
||||
|
@ -43,5 +58,32 @@ export interface RegisterFormValues {
|
|||
email: string;
|
||||
password: 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;
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue