diff --git a/package-lock.json b/package-lock.json index 888b8d5..cacab2c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index a837096..36be349 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e728ffa..a908d4a 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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({ + {/* todo: lmfao */} - + {children} diff --git a/src/app/login/LoginForm.tsx b/src/app/login/LoginForm.tsx index dd04a61..f12c8f8 100644 --- a/src/app/login/LoginForm.tsx +++ b/src/app/login/LoginForm.tsx @@ -54,7 +54,7 @@ export default function LoginForm() { onSubmit={handleFormSubmit} validationSchema={loginSchema} > - {() => ( + {({ isSubmitting }) => (
- +
)} diff --git a/src/app/me/AboutMe.module.css b/src/app/me/AboutMe.module.css index d07e1ff..71ebbf7 100644 --- a/src/app/me/AboutMe.module.css +++ b/src/app/me/AboutMe.module.css @@ -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; +} \ No newline at end of file diff --git a/src/app/me/AboutMe.tsx b/src/app/me/AboutMe.tsx index df3a668..4fce5b7 100644 --- a/src/app/me/AboutMe.tsx +++ b/src/app/me/AboutMe.tsx @@ -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; -const Input = React.forwardRef((props, ref) => { - // get console to shut up - const inputProps = { ...props }; - delete inputProps.displayImage; - - return ( -
- - - {props.displayImage && ( - {"Your - )} - -
- - - {props.error != null &&

{props.error}

} -
-
- ); -}); -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(null); - const emailRef = React.useRef(null); - const avatarRef = React.useRef(null); - const submitRef = React.useRef(null); + const [globalError, setGlobalError] = React.useState(null); + const initialValues: AboutMeFormValues = { + username: info.username, + displayName: info.displayName, + email: info.email, + avatar: info.avatar + }; - const [avatar, setAvatar] = React.useState( - info.avatar ?? null - ); + async function handleFormSubmit( + { displayName, email, avatar }: AboutMeFormValues, + { setSubmitting }: FormikHelpers + ) { + 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(null); - const newPasswordRef = React.useRef(null); - const confirmPasswordRef = React.useRef(null); - const submitPasswordRef = React.useRef(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(null); + const initialPasswordValues: PasswordUpdateFormValues = { + password: "", + newPassword: "", + confirmPassword: "" + }; + + async function handlePasswordSubmit( + { password, newPassword }: PasswordUpdateFormValues, + { setFieldError, setSubmitting }: FormikHelpers + ) { + 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 (
-

User information

-
{ - e.preventDefault(); +

{info.username}

+ + + {({ isSubmitting }) => ( + + + + - // turn the data uri into just base64 - const avatarChanged = avatar !== null && avatar !== info.avatar; - const avatarData = avatarChanged ? avatar?.split(",")[1] : null; + ( + + 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); - } - }} - > - - - - - {/* why, html gods, why? */} - - - { - 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} - /> - -
- -
- + + + )} +
+

-

Change password

-
{ - e.preventDefault(); - setIncorrectPassword(false); - setPasswordMismatch(false); + + + {({ isSubmitting }) => ( + + - if ( - newPasswordRef.current?.value !== confirmPasswordRef.current?.value - ) { - setPasswordMismatch(true); - return; - } + - 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; + - 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); - } - }} - > - - - - - - -
- -
- + + + )} +
+

- -
- { - document.cookie = - "ticket=; expires=" + new Date().toUTCString() + "; path=/"; - window.location.href = "/"; - }} - /> -
+ { + document.cookie = + "ticket=; expires=" + new Date().toUTCString() + "; path=/"; + window.location.href = "/"; + }} + />
); } diff --git a/src/app/me/page.tsx b/src/app/me/page.tsx index 70f611b..1b6731d 100644 --- a/src/app/me/page.tsx +++ b/src/app/me/page.tsx @@ -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 ; } diff --git a/src/app/register/RegisterForm.tsx b/src/app/register/RegisterForm.tsx index 0367110..bef3e61 100644 --- a/src/app/register/RegisterForm.tsx +++ b/src/app/register/RegisterForm.tsx @@ -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(null); const router = useRouter(); - const [initialValues, setInitialValues] = React.useState({ + 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({ { - const file = event.currentTarget.files?.[0]; - if (file != null) { - form.setFieldValue("avatar", file); - } - }} + customRender={(fieldProps) => ( + + fieldProps.form.setFieldValue("avatar", newBlob) + } + /> + )} />
diff --git a/src/app/register/page.tsx b/src/app/register/page.tsx index b7a6b8c..d609a98 100644 --- a/src/app/register/page.tsx +++ b/src/app/register/page.tsx @@ -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

fuck off

; } @@ -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); + } } } diff --git a/src/components/AvatarChanger.module.css b/src/components/AvatarChanger.module.css new file mode 100644 index 0000000..9f95934 --- /dev/null +++ b/src/components/AvatarChanger.module.css @@ -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; +} \ No newline at end of file diff --git a/src/components/AvatarChanger.tsx b/src/components/AvatarChanger.tsx new file mode 100644 index 0000000..d8b57ad --- /dev/null +++ b/src/components/AvatarChanger.tsx @@ -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(null); + + async function handleFileChange(event: ChangeEvent) { + 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 ( +
+ {currentAvatarBlob != null ? ( + Your avatar + ) : ( + + )} + + + +
+ ); +} diff --git a/src/components/ColorChanger.tsx b/src/components/ColorChanger.tsx index 2db31b4..6165c59 100644 --- a/src/components/ColorChanger.tsx +++ b/src/components/ColorChanger.tsx @@ -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! }; diff --git a/src/components/Input.tsx b/src/components/Input.tsx index c5c79c5..3ca4191 100644 --- a/src/components/Input.tsx +++ b/src/components/Input.tsx @@ -3,6 +3,8 @@ import React from "react"; import styles from "./Input.module.css"; type CustomInputProps = { + customRender?: (fieldProps: FieldProps) => React.ReactNode; + customOnChange?: ( event: React.ChangeEvent, form: FormikProps @@ -10,7 +12,8 @@ type CustomInputProps = { }; export default function Input( - props: CustomInputProps & FieldAttributes<{ hint?: string; label: string }> + props: CustomInputProps & + FieldAttributes<{ hint?: string; label: string; disabled?: boolean }> ) { const generatedId = React.useId(); @@ -18,7 +21,8 @@ export default function Input(
- {({ field, meta, form }: FieldProps) => { + {(fieldProps: FieldProps) => { + let { field, meta, form } = fieldProps; let textAfterField = meta.touched && meta.error ? (

{meta.error}

@@ -39,20 +43,26 @@ export default function Input( return ( <> - { - 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 ? ( + { + 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} ); diff --git a/src/components/PrettyForm.module.css b/src/components/PrettyForm.module.css index 763080b..1e47caf 100644 --- a/src/components/PrettyForm.module.css +++ b/src/components/PrettyForm.module.css @@ -14,4 +14,4 @@ color: var(--error); font-size: 80%; transition: color var(--theme-transition); -} \ No newline at end of file +} diff --git a/src/components/icons/UploadIcon.tsx b/src/components/icons/UploadIcon.tsx new file mode 100644 index 0000000..8b6044b --- /dev/null +++ b/src/components/icons/UploadIcon.tsx @@ -0,0 +1,39 @@ +import React from "react"; + +export default function UploadIcon() { + return ( + + + + + + + + ); +} diff --git a/src/components/icons/UserIcon.tsx b/src/components/icons/UserIcon.tsx new file mode 100644 index 0000000..44f56ce --- /dev/null +++ b/src/components/icons/UserIcon.tsx @@ -0,0 +1,20 @@ +import React from "react"; + +export default function UserIcon() { + return ( + + + + + + + ); +} diff --git a/src/schemas.ts b/src/schemas.ts index 0867682..16278fc 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -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 = 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() - .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 = 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 = + Yup.object().shape({ + password: PASSWORD, + newPassword: PASSWORD, + confirmPassword: CONFIRM_PASSWORD("newPassword") + }); + +export interface PasswordUpdateFormValues { + password: string; + newPassword: string; + confirmPassword: string; }