Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
25 changes: 21 additions & 4 deletions web-interface/src/components/Buttons.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<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 ${fontSizeStyles[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: 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 (
<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: '[email protected]',
}

return user
Expand Down
Loading