From ce3a1b13ffcc66a0a14ac468c1d3a43edbd0a9aa Mon Sep 17 00:00:00 2001 From: notjackl3 Date: Sat, 4 Apr 2026 16:16:22 -0400 Subject: [PATCH 1/8] Add profile page --- web-interface/src/routes/profile.tsx | 130 ++++++++++++++++++++++++++- 1 file changed, 127 insertions(+), 3 deletions(-) diff --git a/web-interface/src/routes/profile.tsx b/web-interface/src/routes/profile.tsx index 42f2225..1be5506 100644 --- a/web-interface/src/routes/profile.tsx +++ b/web-interface/src/routes/profile.tsx @@ -1,9 +1,133 @@ import { createFileRoute } from '@tanstack/react-router' +import { useState } from 'react' +import { SquarePen } from 'lucide-react' +import { getUser } from '#/util.ts' export const Route = createFileRoute('/profile')({ - component: RouteComponent, + component: ProfilePage, }) -function RouteComponent() { - return
Hello "/profile"!
+function ProfileField({ + label, + value, + type = 'text', + disabled, + onChange, + }: { + label: string + value: string + type?: string + disabled: boolean + onChange: (val: string) => void +}) { + return ( +
+ + onChange(e.target.value)} + className="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm disabled:bg-white disabled:text-gray-700" + /> +
+ ) +} + +function ProfilePage() { + const user = getUser() + const [editing, setEditing] = useState(false) + + const [name, setName] = useState('John Doe') + const [role, setRole] = useState('Software Developer') + const [email, setEmail] = useState('john@utmist.ca') + const [password, setPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + + function handleSave() { + // TODO: call API to update profile + console.log('Save profile:', { name, role, email, password }) + setEditing(false) + } + + return ( +
+
+ {/* Left: form fields */} +
+ + + + + + +
+ + +
+
+ + {/* Right: profile picture */} +
+
+ Profile Picture + +
+
+
+
+ ) } From 5d5275aa6fc248c22853dbae6ea2e4439682f40e Mon Sep 17 00:00:00 2001 From: TheArchons Date: Sat, 4 Apr 2026 17:57:49 -0400 Subject: [PATCH 2/8] Replace edit button with a cancel button --- web-interface/bun.lock | 7 +- web-interface/package.json | 3 +- web-interface/src/routes/profile.tsx | 106 +++++++++++++++++---------- web-interface/src/util.ts | 6 +- 4 files changed, 79 insertions(+), 43 deletions(-) diff --git a/web-interface/bun.lock b/web-interface/bun.lock index 2727e0e..b37b8a5 100644 --- a/web-interface/bun.lock +++ b/web-interface/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "web-interface", @@ -14,12 +13,12 @@ "react": "^19.2.0", "react-dom": "^19.2.0", "tailwindcss": "^4.1.18", + "use-immer": "^0.11.0", }, "devDependencies": { "@tailwindcss/typography": "^0.5.16", "@tanstack/devtools-vite": "latest", "@tanstack/eslint-config": "latest", - "@tanstack/router-plugin": "latest", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.0", "@types/node": "^22.10.2", @@ -618,6 +617,8 @@ "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + "immer": ["immer@11.1.4", "", {}, "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw=="], + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="], @@ -850,6 +851,8 @@ "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "use-immer": ["use-immer@0.11.0", "", { "peerDependencies": { "immer": ">=8.0.0", "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0" } }, "sha512-RNAqi3GqsWJ4bcCd4LMBgdzvPmTABam24DUaFiKfX9s3MSorNRz9RDZYJkllJoMHUxVLMDetwAuCDeyWNrp1yA=="], + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], diff --git a/web-interface/package.json b/web-interface/package.json index a4e366c..b9e0451 100644 --- a/web-interface/package.json +++ b/web-interface/package.json @@ -23,7 +23,8 @@ "lucide-react": "^0.545.0", "react": "^19.2.0", "react-dom": "^19.2.0", - "tailwindcss": "^4.1.18" + "tailwindcss": "^4.1.18", + "use-immer": "^0.11.0" }, "devDependencies": { "@tailwindcss/typography": "^0.5.16", diff --git a/web-interface/src/routes/profile.tsx b/web-interface/src/routes/profile.tsx index 1be5506..e506a02 100644 --- a/web-interface/src/routes/profile.tsx +++ b/web-interface/src/routes/profile.tsx @@ -1,23 +1,23 @@ import { createFileRoute } from '@tanstack/react-router' -import { useState } from 'react' import { SquarePen } from 'lucide-react' import { getUser } from '#/util.ts' +import type { User } from '#/util.ts' +import { useImmer } from 'use-immer' export const Route = createFileRoute('/profile')({ component: ProfilePage, + loader: getUser, }) function ProfileField({ - label, - value, - type = 'text', - disabled, - onChange, - }: { + label, + value, + type = 'text', + onChange, +}: { label: string value: string type?: string - disabled: boolean onChange: (val: string) => void }) { return ( @@ -26,7 +26,6 @@ function ProfileField({ onChange(e.target.value)} className="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm disabled:bg-white disabled:text-gray-700" /> @@ -35,19 +34,33 @@ function ProfileField({ } function ProfilePage() { - const user = getUser() - const [editing, setEditing] = useState(false) - - const [name, setName] = useState('John Doe') - const [role, setRole] = useState('Software Developer') - const [email, setEmail] = useState('john@utmist.ca') - const [password, setPassword] = useState('') - const [confirmPassword, setConfirmPassword] = useState('') + const loaderData = Route.useLoaderData() + const [user, setUser] = useImmer({ + ...loaderData, + password: '', + confirmPassword: '', + }) function handleSave() { // TODO: call API to update profile - console.log('Save profile:', { name, role, email, password }) - setEditing(false) + console.log('Save profile:', { + username: user.username, + role: user.role, + email: user.email, + password: user.password, + }) + } + + function handleCancel() { + const confirmCancel = confirm("Are you sure you want to cancel?"); + + if (confirmCancel) { + setUser({ + ...loaderData, + password: '', + confirmPassword: '', + }); + } } return ( @@ -56,37 +69,52 @@ function ProfilePage() { {/* Left: form fields */}
+ setUser((draft) => { + draft.username = username + }) + } /> + setUser((draft) => { + draft.role = role + }) + } /> + setUser((draft) => { + draft.email = email + }) + } /> + setUser((draft) => { + draft.password = password + }) + } /> + setUser((draft) => { + draft.confirmPassword = confirmPassword + }) + } />
@@ -98,11 +126,11 @@ function ProfilePage() { Save
diff --git a/web-interface/src/util.ts b/web-interface/src/util.ts index 567c79c..5d9dba1 100644 --- a/web-interface/src/util.ts +++ b/web-interface/src/util.ts @@ -1,6 +1,8 @@ -type User = { +export type User = { username: string + role: string profilePicture: string + email: string } export function getUser(): User { @@ -10,6 +12,8 @@ export function getUser(): User { const user = { username: 'TheArchons', profilePicture: '/sample-avatar.png', // real avatars should probably be stored in a bucket + role: 'Software Developer', + email: 'thearchons@utmist.ca', } return user From b3455610785f082263a153062d955dae5551800a Mon Sep 17 00:00:00 2001 From: TheArchons Date: Sat, 4 Apr 2026 18:15:58 -0400 Subject: [PATCH 3/8] Refactor profile buttons to use the Button component --- web-interface/src/components/Buttons.tsx | 13 ++++--- web-interface/src/routes/jobs.tsx | 16 ++++----- web-interface/src/routes/profile.tsx | 43 ++++++++++++------------ 3 files changed, 38 insertions(+), 34 deletions(-) diff --git a/web-interface/src/components/Buttons.tsx b/web-interface/src/components/Buttons.tsx index 6d99004..cf5836e 100644 --- a/web-interface/src/components/Buttons.tsx +++ b/web-interface/src/components/Buttons.tsx @@ -1,26 +1,31 @@ +import type {ReactNode} from "react"; + const variantStyles = { success: 'bg-green-200 text-green-800 hover:bg-green-300', warning: 'bg-yellow-200 text-yellow-800 hover:bg-yellow-300', danger: 'bg-red-200 text-red-800 hover:bg-red-300', + normal: 'bg-blue-200 text-blue-800 hover:bg-blue-300' } type ButtonVariant = keyof typeof variantStyles export function Button({ + children, onClick, - text, variant, + fontSize }: { + children: ReactNode onClick: () => void - text: string variant: ButtonVariant + fontSize: 'xs' | 'sm' | 'base' | 'lg' | 'xl' }) { return ( ) } diff --git a/web-interface/src/routes/jobs.tsx b/web-interface/src/routes/jobs.tsx index a9e1651..b6fbc69 100644 --- a/web-interface/src/routes/jobs.tsx +++ b/web-interface/src/routes/jobs.tsx @@ -164,24 +164,24 @@ function JobCard({ {/* Info grid */} diff --git a/web-interface/src/routes/profile.tsx b/web-interface/src/routes/profile.tsx index e506a02..2e20055 100644 --- a/web-interface/src/routes/profile.tsx +++ b/web-interface/src/routes/profile.tsx @@ -1,8 +1,8 @@ import { createFileRoute } from '@tanstack/react-router' import { SquarePen } from 'lucide-react' import { getUser } from '#/util.ts' -import type { User } from '#/util.ts' import { useImmer } from 'use-immer' +import {Button} from "#/components/Buttons.tsx"; export const Route = createFileRoute('/profile')({ component: ProfilePage, @@ -118,20 +118,17 @@ function ProfilePage() { />
- - + +
@@ -143,16 +140,18 @@ function ProfilePage() { alt="Profile Picture" className="w-48 h-48 rounded-full object-cover border-2 border-gray-200" /> - +
+ +
From 619872488410c93ecbd5bf9908a5c0db2751f121 Mon Sep 17 00:00:00 2001 From: TheArchons Date: Sun, 5 Apr 2026 08:57:38 -0400 Subject: [PATCH 4/8] Add error handling to profile fields and disable save button when errors exist --- web-interface/src/components/Buttons.tsx | 8 +-- web-interface/src/routes/jobs.tsx | 24 ++++----- web-interface/src/routes/profile.tsx | 66 ++++++++++++++++++++---- 3 files changed, 72 insertions(+), 26 deletions(-) diff --git a/web-interface/src/components/Buttons.tsx b/web-interface/src/components/Buttons.tsx index cf5836e..3ce73ae 100644 --- a/web-interface/src/components/Buttons.tsx +++ b/web-interface/src/components/Buttons.tsx @@ -1,10 +1,11 @@ -import type {ReactNode} from "react"; +import type { ReactNode } from 'react' const variantStyles = { success: 'bg-green-200 text-green-800 hover:bg-green-300', warning: 'bg-yellow-200 text-yellow-800 hover:bg-yellow-300', danger: 'bg-red-200 text-red-800 hover:bg-red-300', - normal: 'bg-blue-200 text-blue-800 hover:bg-blue-300' + normal: 'bg-blue-200 text-blue-800 hover:bg-blue-300', + disabled: 'bg-gray-200 text-gray-800', } type ButtonVariant = keyof typeof variantStyles @@ -13,7 +14,7 @@ export function Button({ children, onClick, variant, - fontSize + fontSize, }: { children: ReactNode onClick: () => void @@ -24,6 +25,7 @@ export function Button({ diff --git a/web-interface/src/routes/jobs.tsx b/web-interface/src/routes/jobs.tsx index b6fbc69..20292b1 100644 --- a/web-interface/src/routes/jobs.tsx +++ b/web-interface/src/routes/jobs.tsx @@ -162,26 +162,26 @@ function JobCard({ return ( - + + > + Shutdown + - + > + Restart + + {/* Info grid */} diff --git a/web-interface/src/routes/profile.tsx b/web-interface/src/routes/profile.tsx index 2e20055..3af1dea 100644 --- a/web-interface/src/routes/profile.tsx +++ b/web-interface/src/routes/profile.tsx @@ -2,7 +2,7 @@ import { createFileRoute } from '@tanstack/react-router' import { SquarePen } from 'lucide-react' import { getUser } from '#/util.ts' import { useImmer } from 'use-immer' -import {Button} from "#/components/Buttons.tsx"; +import { Button } from '#/components/Buttons.tsx' export const Route = createFileRoute('/profile')({ component: ProfilePage, @@ -14,11 +14,13 @@ function ProfileField({ value, type = 'text', onChange, + error, }: { label: string value: string type?: string onChange: (val: string) => void + error?: string }) { return (
@@ -27,8 +29,9 @@ function ProfileField({ type={type} value={value} onChange={(e) => onChange(e.target.value)} - className="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm disabled:bg-white disabled:text-gray-700" + className={`w-full border ${error ? 'border-red-600' : 'border-gray-300'} rounded-lg px-4 py-2 text-sm disabled:bg-white disabled:text-gray-700`} /> +

{error}

) } @@ -52,22 +55,59 @@ function ProfilePage() { } function handleCancel() { - const confirmCancel = confirm("Are you sure you want to cancel?"); + const confirmCancel = confirm('Are you sure you want to cancel?') if (confirmCancel) { setUser({ ...loaderData, password: '', confirmPassword: '', - }); + }) } } + function getUserErrors(field: string): string | undefined { + switch (field) { + case 'username': + case 'role': + case 'email': + if (user[field] === '') { + return `Field cannot be empty.` + } + break + case 'password': + if (user['password'].length !== 0) { + if (user['password'].length < 8) { + return 'Password must be at least 8 characters long' + } + } + break + case 'confirmPassword': + if ( + user['password'].length !== 0 && + user['confirmPassword'] !== user['password'] + ) { + return 'Passwords do not match.' + } + break + } + } + + function hasError(): boolean { + for (const field in user) { + if (getUserErrors(field)) { + return true + } + } + + return false + } + return (
{/* Left: form fields */} -
+
- +
From 78fe56220df96f9448c77b4a6a8e77321d8d2dc0 Mon Sep 17 00:00:00 2001 From: TheArchons Date: Sun, 5 Apr 2026 09:17:26 -0400 Subject: [PATCH 5/8] Add avatar upload functionality to profile page --- web-interface/src/routes/profile.tsx | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/web-interface/src/routes/profile.tsx b/web-interface/src/routes/profile.tsx index 3af1dea..91bc766 100644 --- a/web-interface/src/routes/profile.tsx +++ b/web-interface/src/routes/profile.tsx @@ -3,6 +3,8 @@ import { SquarePen } from 'lucide-react' import { getUser } from '#/util.ts' import { useImmer } from 'use-immer' import { Button } from '#/components/Buttons.tsx' +import { useRef } from 'react' +import type { ChangeEvent } from 'react' export const Route = createFileRoute('/profile')({ component: ProfilePage, @@ -43,6 +45,16 @@ function ProfilePage() { password: '', confirmPassword: '', }) + const avatarUploadRef = useRef(null) + + function handleAvatarUpload(event: ChangeEvent) { + const file = event.target.files?.[0] + + if (!file) return + + // TODO: call API to upload avatar + console.log(`Uploaded image ${file.name}`) + } function handleSave() { // TODO: call API to update profile @@ -186,15 +198,19 @@ function ProfilePage() { />
+
From 25a456bfc2f86270ef1d71ee041fe1e797bcef00 Mon Sep 17 00:00:00 2001 From: TheArchons Date: Sun, 5 Apr 2026 09:20:56 -0400 Subject: [PATCH 6/8] show dropdown above page content --- web-interface/src/components/Navbar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web-interface/src/components/Navbar.tsx b/web-interface/src/components/Navbar.tsx index 09e74d3..79f01d5 100644 --- a/web-interface/src/components/Navbar.tsx +++ b/web-interface/src/components/Navbar.tsx @@ -52,7 +52,7 @@ export default function Navbar() { >
{dropdown && ( -
+
  • Date: Sun, 5 Apr 2026 09:34:06 -0400 Subject: [PATCH 7/8] fix getUserErrors typing --- web-interface/src/routes/profile.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web-interface/src/routes/profile.tsx b/web-interface/src/routes/profile.tsx index 91bc766..990deed 100644 --- a/web-interface/src/routes/profile.tsx +++ b/web-interface/src/routes/profile.tsx @@ -78,7 +78,7 @@ function ProfilePage() { } } - function getUserErrors(field: string): string | undefined { + function getUserErrors(field: keyof typeof user): string | undefined { switch (field) { case 'username': case 'role': @@ -107,7 +107,7 @@ function ProfilePage() { function hasError(): boolean { for (const field in user) { - if (getUserErrors(field)) { + if (getUserErrors(field as keyof typeof user)) { return true } } From e6a5e83e392e5aa0e0f3d049e83a5c5541ec99c6 Mon Sep 17 00:00:00 2001 From: TheArchons Date: Sun, 5 Apr 2026 09:39:06 -0400 Subject: [PATCH 8/8] use a fontSizeStyles object instead of directly injecting javascript This is because tailwind css does not evaluate js at runtime --- web-interface/src/components/Buttons.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/web-interface/src/components/Buttons.tsx b/web-interface/src/components/Buttons.tsx index 3ce73ae..1225fc8 100644 --- a/web-interface/src/components/Buttons.tsx +++ b/web-interface/src/components/Buttons.tsx @@ -8,7 +8,17 @@ const variantStyles = { disabled: 'bg-gray-200 text-gray-800', } +const fontSizeStyles = { + xs: 'text-xs', + sm: 'text-sm', + base: 'text-base', + lg: 'text-lg', + xl: 'text-xl', +}; + + type ButtonVariant = keyof typeof variantStyles +type ButtonFontSize = keyof typeof fontSizeStyles export function Button({ children, @@ -19,12 +29,12 @@ export function Button({ children: ReactNode onClick: () => void variant: ButtonVariant - fontSize: 'xs' | 'sm' | 'base' | 'lg' | 'xl' + fontSize: ButtonFontSize }) { return (