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/components/Buttons.tsx b/web-interface/src/components/Buttons.tsx
index 6d99004..1225fc8 100644
--- a/web-interface/src/components/Buttons.tsx
+++ b/web-interface/src/components/Buttons.tsx
@@ -1,26 +1,43 @@
+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',
+ 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,
onClick,
- text,
variant,
+ fontSize,
}: {
+ children: ReactNode
onClick: () => void
- text: string
variant: ButtonVariant
+ fontSize: ButtonFontSize
}) {
return (
)
}
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 && (
-
+
-
-
{/* Info grid */}
diff --git a/web-interface/src/routes/profile.tsx b/web-interface/src/routes/profile.tsx
index 42f2225..990deed 100644
--- a/web-interface/src/routes/profile.tsx
+++ b/web-interface/src/routes/profile.tsx
@@ -1,9 +1,220 @@
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 { useRef } from 'react'
+import type { ChangeEvent } from 'react'
export const Route = createFileRoute('/profile')({
- component: RouteComponent,
+ component: ProfilePage,
+ loader: getUser,
})
-function RouteComponent() {
- return
Hello "/profile"!
+function ProfileField({
+ label,
+ value,
+ type = 'text',
+ onChange,
+ error,
+}: {
+ label: string
+ value: string
+ type?: string
+ onChange: (val: string) => void
+ error?: string
+}) {
+ return (
+
+
+
onChange(e.target.value)}
+ 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}
+
+ )
+}
+
+function ProfilePage() {
+ const loaderData = Route.useLoaderData()
+ const [user, setUser] = useImmer({
+ ...loaderData,
+ 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
+ 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: '',
+ })
+ }
+ }
+
+ function getUserErrors(field: keyof typeof user): 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 as keyof typeof user)) {
+ return true
+ }
+ }
+
+ return false
+ }
+
+ return (
+
+
+ {/* Left: form fields */}
+
+
+ setUser((draft) => {
+ draft.username = username
+ })
+ }
+ error={getUserErrors('username')}
+ />
+
+ setUser((draft) => {
+ draft.role = role
+ })
+ }
+ error={getUserErrors('role')}
+ />
+
+ setUser((draft) => {
+ draft.email = email
+ })
+ }
+ error={getUserErrors('email')}
+ />
+
+ setUser((draft) => {
+ draft.password = password
+ })
+ }
+ error={getUserErrors('password')}
+ />
+
+ setUser((draft) => {
+ draft.confirmPassword = confirmPassword
+ })
+ }
+ error={getUserErrors('confirmPassword')}
+ />
+
+
+
+ Save
+
+
+ Cancel
+
+
+
+
+ {/* Right: profile picture */}
+
+
+

+
+ avatarUploadRef.current?.click()}
+ variant="normal"
+ fontSize="base"
+ >
+ Edit
+
+
+
+
+
+
+
+ )
}
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