Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions web-interface/bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion web-interface/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
15 changes: 11 additions & 4 deletions web-interface/src/components/Buttons.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,33 @@
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',
}

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 (
<button
onClick={onClick}
className={`px-3 py-1 text-xs font-semibold rounded ${variantStyles[variant]}`}
className={`flex items-center gap-1.5 px-3 py-1 text-${fontSize} rounded ${variantStyles[variant]}`}
disabled={variant === 'disabled'}
>
{text}
{children}
</button>
)
}
2 changes: 1 addition & 1 deletion web-interface/src/components/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export default function Navbar() {
></img>
</div>
{dropdown && (
<div className="absolute top-full right-0 border rounded-b m-2 p-1 pr-3 pl-3 text-xl bg-white">
<div className="absolute top-full right-0 border rounded-b m-2 p-1 pr-3 pl-3 text-xl bg-white z-100">
<ul>
<li>
<Link
Expand Down
28 changes: 14 additions & 14 deletions web-interface/src/routes/jobs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -162,26 +162,26 @@ function JobCard({
return (
<Card>
<CardHeader header={job.name}>
<Button
onClick={() => onStart(job.id)}
text="Start"
variant="success"
/>
<Button onClick={() => onStart(job.id)} variant="success" fontSize="xs">
Start
</Button>
<Button
onClick={() => onShutdown(job.id)}
text="Shutdown"
variant="warning"
/>
fontSize="xs"
>
Shutdown
</Button>
<Button
onClick={() => onRestart(job.id)}
text="Restart"
variant="warning"
/>
<Button
onClick={() => onDelete(job.id)}
text="Delete"
variant="danger"
/>
fontSize="xs"
>
Restart
</Button>
<Button onClick={() => onDelete(job.id)} variant="danger" fontSize="xs">
Delete
</Button>
</CardHeader>

{/* Info grid */}
Expand Down
217 changes: 214 additions & 3 deletions web-interface/src/routes/profile.tsx
Original file line number Diff line number Diff line change
@@ -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 <div>Hello "/profile"!</div>
function ProfileField({
label,
value,
type = 'text',
onChange,
error,
}: {
label: string
value: string
type?: string
onChange: (val: string) => void
error?: string
}) {
return (
<div>
<label className="block text-base font-medium mb-1">{label}</label>
<input
type={type}
value={value}
onChange={(e) => 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`}
/>
<p className="text-red-600 min-h-6">{error}</p>
</div>
)
}

function ProfilePage() {
const loaderData = Route.useLoaderData()
const [user, setUser] = useImmer({
...loaderData,
password: '',
confirmPassword: '',
})
const avatarUploadRef = useRef<HTMLInputElement>(null)

function handleAvatarUpload(event: ChangeEvent<HTMLInputElement>) {
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: 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 (
<div className="w-fit mx-auto py-8 px-8">
<div className="flex gap-16 items-start">
{/* Left: form fields */}
<div className="flex-1 min-w-80 flex flex-col">
<ProfileField
label="Username"
value={user.username}
onChange={(username) =>
setUser((draft) => {
draft.username = username
})
}
error={getUserErrors('username')}
/>
<ProfileField
label="Role"
value={user.role}
onChange={(role) =>
setUser((draft) => {
draft.role = role
})
}
error={getUserErrors('role')}
/>
<ProfileField
label="Email"
value={user.email}
type="email"
onChange={(email) =>
setUser((draft) => {
draft.email = email
})
}
error={getUserErrors('email')}
/>
<ProfileField
label="Password"
value={user.password}
type="password"
onChange={(password) =>
setUser((draft) => {
draft.password = password
})
}
error={getUserErrors('password')}
/>
<ProfileField
label="Confirm Password"
value={user.confirmPassword}
type="password"
onChange={(confirmPassword) =>
setUser((draft) => {
draft.confirmPassword = confirmPassword
})
}
error={getUserErrors('confirmPassword')}
/>

<div className="flex gap-3 mt-2">
<Button
onClick={handleSave}
variant={hasError() ? 'disabled' : 'normal'}
fontSize="base"
>
Save
</Button>
<Button onClick={handleCancel} variant="danger" fontSize="base">
Cancel
</Button>
</div>
</div>

{/* Right: profile picture */}
<div className="flex flex-col items-center gap-3 pt-6">
<div className="relative">
<img
src={user.profilePicture}
alt="Profile Picture"
className="w-48 h-48 rounded-full object-cover border-2 border-gray-200"
/>
<div className="absolute bottom-3 left-3">
<Button
onClick={() => avatarUploadRef.current?.click()}
variant="normal"
fontSize="base"
>
Edit <SquarePen size={16} />
</Button>
<input
type="file"
className="hidden"
accept="image/*"
ref={avatarUploadRef}
onChange={handleAvatarUpload}
/>
</div>
</div>
</div>
</div>
</div>
)
}
6 changes: 5 additions & 1 deletion web-interface/src/util.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
type User = {
export type User = {
username: string
role: string
profilePicture: string
email: string
}

export function getUser(): User {
Expand All @@ -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
Expand Down
Loading