diff --git a/webui/index.html b/webui/index.html
index 86fa92c..93946fc 100644
--- a/webui/index.html
+++ b/webui/index.html
@@ -3,7 +3,10 @@
- Vite + React
+ Arkendro
+
+
+
diff --git a/webui/package.json b/webui/package.json
index 0f1631e..33c6c52 100644
--- a/webui/package.json
+++ b/webui/package.json
@@ -10,7 +10,9 @@
"preview": "vite preview"
},
"dependencies": {
+ "@fontsource/plus-jakarta-sans": "^5.2.6",
"@phosphor-icons/react": "^2.1.10",
+ "classnames": "^2.5.1",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-router-dom": "^7.8.2",
diff --git a/webui/pnpm-lock.yaml b/webui/pnpm-lock.yaml
index a26bbd0..84af155 100644
--- a/webui/pnpm-lock.yaml
+++ b/webui/pnpm-lock.yaml
@@ -8,9 +8,15 @@ importers:
.:
dependencies:
+ '@fontsource/plus-jakarta-sans':
+ specifier: ^5.2.6
+ version: 5.2.6
'@phosphor-icons/react':
specifier: ^2.1.10
version: 2.1.10(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ classnames:
+ specifier: ^2.5.1
+ version: 2.5.1
react:
specifier: ^19.1.1
version: 19.1.1
@@ -334,6 +340,9 @@ packages:
resolution: {integrity: sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ '@fontsource/plus-jakarta-sans@5.2.6':
+ resolution: {integrity: sha512-mvUiz1ta3bCVhP/DPmAOmuzhHQi6ddOo1GgaW58rpojr510Rx9BkqXqcnMhGEOMZZB3+84frvfFmw/jKCctHLw==}
+
'@humanfs/core@0.19.1':
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
engines: {node: '>=18.18.0'}
@@ -648,6 +657,9 @@ packages:
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
engines: {node: '>= 14.16.0'}
+ classnames@2.5.1:
+ resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==}
+
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
@@ -1490,6 +1502,8 @@ snapshots:
'@eslint/core': 0.15.2
levn: 0.4.1
+ '@fontsource/plus-jakarta-sans@5.2.6': {}
+
'@humanfs/core@0.19.1': {}
'@humanfs/node@0.16.7':
@@ -1750,6 +1764,8 @@ snapshots:
readdirp: 4.1.2
optional: true
+ classnames@2.5.1: {}
+
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
diff --git a/webui/src/App.jsx b/webui/src/App.jsx
index 6e62a88..60bb13f 100644
--- a/webui/src/App.jsx
+++ b/webui/src/App.jsx
@@ -1,18 +1,30 @@
import { createBrowserRouter, Navigate, RouterProvider } from "react-router-dom";
+import { UserProvider } from '@/common/contexts/UserContext.jsx';
import "@/common/styles/main.sass";
import Root from "@/common/layouts/Root.jsx";
+import "@fontsource/plus-jakarta-sans/300.css";
+import "@fontsource/plus-jakarta-sans/400.css";
+import "@fontsource/plus-jakarta-sans/600.css";
+import "@fontsource/plus-jakarta-sans/700.css";
+import "@fontsource/plus-jakarta-sans/800.css";
+
+const Placeholder = ({title}) => {title}
Content coming soon.
;
+
const App = () => {
const router = createBrowserRouter([
{
path: "/",
element: ,
children: [
- { path: "/", element: },
+ { path: "/", element: },
+ { path: "/dashboard", element: },
+ { path: "/servers", element: },
+ { path: "/settings", element: },
],
},
]);
- return ;
+ return ;
};
export default App;
\ No newline at end of file
diff --git a/webui/src/common/components/Button/Button.jsx b/webui/src/common/components/Button/Button.jsx
new file mode 100644
index 0000000..bf7a0e5
--- /dev/null
+++ b/webui/src/common/components/Button/Button.jsx
@@ -0,0 +1,50 @@
+import React from "react";
+import cn from "classnames";
+import "./styles.sass";
+
+/*
+Props:
+- variant: primary | subtle | danger
+- size: sm | md | lg
+- full: boolean (full width)
+- icon: ReactNode (left icon)
+- iconRight: ReactNode (right icon)
+- loading: boolean
+*/
+
+export const Button = ({
+ as: Component = "button",
+ variant = "primary",
+ size = "md",
+ full = false,
+ icon,
+ iconRight,
+ loading = false,
+ disabled,
+ className,
+ children,
+ ...rest
+}) => {
+ const isDisabled = disabled || loading;
+ return (
+
+ {loading && }
+ {icon && {icon}}
+ {children}
+ {iconRight && {iconRight}}
+
+ );
+};
+
+export default Button;
diff --git a/webui/src/common/components/Button/index.js b/webui/src/common/components/Button/index.js
new file mode 100644
index 0000000..5ccf887
--- /dev/null
+++ b/webui/src/common/components/Button/index.js
@@ -0,0 +1 @@
+export { Button as default } from "./Button.jsx";
\ No newline at end of file
diff --git a/webui/src/common/components/Button/styles.sass b/webui/src/common/components/Button/styles.sass
new file mode 100644
index 0000000..e99ea7c
--- /dev/null
+++ b/webui/src/common/components/Button/styles.sass
@@ -0,0 +1,84 @@
+.btn
+ --c-bg: #ffffff
+ --c-bg-hover: #f2f5f8
+ --c-bg-active: #e6ebf0
+ --c-border: #d0d7de
+ --c-border-hover: #c2cbd3
+ --c-text: #1f2429
+ --c-accent: #0f62fe
+ --c-danger: #d93025
+ position: relative
+ display: inline-flex
+ align-items: center
+ justify-content: center
+ gap: .5rem
+ font-family: inherit
+ font-weight: 500
+ line-height: 1.2
+ cursor: pointer
+ border: 1px solid var(--c-border)
+ background: var(--c-bg)
+ color: var(--c-text)
+ border-radius: 6px
+ transition: background .15s ease, border-color .15s ease, color .15s ease, box-shadow .15s ease
+ user-select: none
+ text-decoration: none
+ &:hover:not(:disabled)
+ background: var(--c-bg-hover)
+ border-color: var(--c-border-hover)
+ &:active:not(:disabled)
+ background: var(--c-bg-active)
+ &:focus-visible
+ outline: 2px solid var(--c-accent)
+ outline-offset: 2px
+ &:disabled
+ opacity: .55
+ cursor: not-allowed
+ &.btn--full
+ width: 100%
+ &.btn--sm
+ font-size: .75rem
+ padding: .4rem .7rem
+ &.btn--md
+ font-size: .85rem
+ padding: .6rem 1rem
+ &.btn--lg
+ font-size: 1rem
+ padding: .8rem 1.2rem
+ &.btn--primary
+ --c-bg: #0f62fe
+ --c-bg-hover: #0d55dd
+ --c-bg-active: #0b47b8
+ --c-border: #0f62fe
+ --c-text: #ffffff
+ &.btn--subtle
+ --c-bg: #f3f6f9
+ --c-bg-hover: #e8edf2
+ --c-bg-active: #dfe5eb
+ --c-border: #e1e6eb
+ &.btn--danger
+ --c-bg: #d93025
+ --c-bg-hover: #c22b21
+ --c-bg-active: #a9241b
+ --c-border: #d93025
+ --c-text: #ffffff
+
+.btn__icon
+ display: inline-flex
+ align-items: center
+ &--left
+ margin-right: .25rem
+ &--right
+ margin-left: .25rem
+
+.btn__spinner
+ width: 14px
+ height: 14px
+ border: 2px solid rgba(0,0,0,.15)
+ border-top-color: var(--c-text)
+ border-radius: 50%
+ animation: spin .7s linear infinite
+
+@keyframes spin
+ to
+ transform: rotate(360deg)
diff --git a/webui/src/common/components/Input/Input.jsx b/webui/src/common/components/Input/Input.jsx
new file mode 100644
index 0000000..0e2ef30
--- /dev/null
+++ b/webui/src/common/components/Input/Input.jsx
@@ -0,0 +1,27 @@
+import React from "react";
+import cn from "classnames";
+import "./styles.sass";
+
+/* Minimal input wrapper with label, error text, and optional icon */
+export const Input = ({
+ label,
+ error,
+ icon,
+ className,
+ containerClassName,
+ type = "text",
+ ...rest
+}) => {
+ return (
+
+ {label &&
}
+
+ {icon && {icon}}
+
+
+ {error &&
{error}
}
+
+ );
+};
+
+export default Input;
diff --git a/webui/src/common/components/Input/index.js b/webui/src/common/components/Input/index.js
new file mode 100644
index 0000000..8a20e7b
--- /dev/null
+++ b/webui/src/common/components/Input/index.js
@@ -0,0 +1 @@
+export { Input as default } from "./Input.jsx";
\ No newline at end of file
diff --git a/webui/src/common/components/Input/styles.sass b/webui/src/common/components/Input/styles.sass
new file mode 100644
index 0000000..e446ebe
--- /dev/null
+++ b/webui/src/common/components/Input/styles.sass
@@ -0,0 +1,58 @@
+.field
+ display: flex
+ flex-direction: column
+ gap: .35rem
+ font-size: .8rem
+ font-weight: 500
+ color: #374048
+
+.field__label
+ letter-spacing: .5px
+
+.field__control
+ position: relative
+ display: flex
+ align-items: center
+ background: #ffffff
+ border: 1px solid #d0d7de
+ border-radius: 6px
+ padding: .55rem .7rem
+ transition: border-color .15s ease, background .15s ease, box-shadow .15s ease
+ &.has-icon .field__input
+ padding-left: 1.6rem
+ &.has-error
+ border-color: #d93025
+ box-shadow: 0 0 0 1px #d93025
+ &:focus-within
+ border-color: #0f62fe
+ box-shadow: 0 0 0 1px #0f62fe20
+
+.field__icon
+ position: absolute
+ left: .55rem
+ top: 50%
+ transform: translateY(-50%)
+ display: inline-flex
+ font-size: 1rem
+ color: #6b7781
+ pointer-events: none
+
+.field__input
+ appearance: none
+ outline: none
+ background: transparent
+ border: 0
+ color: #1f2429
+ font: inherit
+ width: 100%
+ line-height: 1.2
+ &::placeholder
+ color: #a0abb4
+ &:focus
+ outline: none
+
+.field__error
+ font-size: .65rem
+ font-weight: 600
+ color: #d93025
+ letter-spacing: .5px
diff --git a/webui/src/common/contexts/UserContext.jsx b/webui/src/common/contexts/UserContext.jsx
index 2f9fa6b..9fd293c 100644
--- a/webui/src/common/contexts/UserContext.jsx
+++ b/webui/src/common/contexts/UserContext.jsx
@@ -7,7 +7,7 @@ export const UserContext = createContext({});
export const UserProvider = ({ children }) => {
const [sessionToken, setSessionToken] = useState(localStorage.getItem("sessionToken"));
- const [firstTimeSetup, setFirstTimeSetup] = useState(false);
+ const [isSetupCompleted, setIsSetupCompleted] = useState(false);
const [user, setUser] = useState(null);
const updateSessionToken = (sessionToken) => {
@@ -19,7 +19,7 @@ export const UserProvider = ({ children }) => {
const checkFirstTimeSetup = async () => {
try {
const response = await getRequest("setup/status");
- setFirstTimeSetup(response?.first_user_exists);
+ setIsSetupCompleted(response?.first_user_exists);
} catch (error) {
console.error(error);
}
@@ -38,8 +38,11 @@ export const UserProvider = ({ children }) => {
};
const logout = async () => {
- await postRequest("auth/logout", { token: sessionToken });
-
+ try {
+ await postRequest("auth/logout", { token: sessionToken });
+ } catch (e) {
+ // ignore
+ }
window.location.reload();
};
@@ -60,7 +63,7 @@ export const UserProvider = ({ children }) => {
}, []);
return (
-
+
{user == null && }
{user !== null && children}
diff --git a/webui/src/common/layouts/Root.jsx b/webui/src/common/layouts/Root.jsx
index 29da720..60f068b 100644
--- a/webui/src/common/layouts/Root.jsx
+++ b/webui/src/common/layouts/Root.jsx
@@ -1,7 +1,35 @@
-export default () => {
- return (
- <>
- Root
- >
- )
-}
\ No newline at end of file
+import React, { useContext } from 'react';
+import { NavLink, Outlet } from 'react-router-dom';
+import { HouseIcon, GearSixIcon, SquaresFourIcon } from '@phosphor-icons/react';
+
+const navItems = [
+ { to: '/dashboard', label: 'Dashboard', icon: },
+ { to: '/servers', label: 'Servers', icon: },
+ { to: '/settings', label: 'Settings', icon: },
+];
+
+const Root = () => {
+ return (
+
+
+
+
+
+
+
+ );
+};
+
+export default Root;
\ No newline at end of file
diff --git a/webui/src/common/styles/main.sass b/webui/src/common/styles/main.sass
index e69de29..1fa8afe 100644
--- a/webui/src/common/styles/main.sass
+++ b/webui/src/common/styles/main.sass
@@ -0,0 +1,157 @@
+:root
+ --bg: #f7f9fb
+ --bg-alt: #ffffff
+ --bg-elev: #f0f3f6
+ --border: #dfe3e8
+ --border-strong: #c7ced6
+ --text: #1f2429
+ --text-dim: #5c6a78
+ --accent: #0f62fe
+ --danger: #d93025
+ --radius-sm: 4px
+ --radius: 6px
+ --radius-lg: 10px
+ --sidebar-width: 240px
+
+*, *::before, *::after
+ box-sizing: border-box
+
+html, body, #root
+ height: 100%
+
+body
+ margin: 0
+ font-family: "Plus Jakarta Sans", sans-serif
+ background: var(--bg)
+ color: var(--text)
+ -webkit-font-smoothing: antialiased
+ font-size: 14px
+
+h1, h2, h3, h4, h5, h6, p
+ margin: 0
+
+ul
+ margin: 0
+ padding: 0
+ list-style: none
+
+a
+ color: var(--accent)
+ text-decoration: none
+
+.layout
+ display: flex
+ min-height: 100vh
+
+.sidebar
+ width: var(--sidebar-width)
+ background: var(--bg-alt)
+ border-right: 1px solid var(--border)
+ display: flex
+ flex-direction: column
+ padding: 1rem .9rem
+ gap: 1.2rem
+
+.sidebar__brand
+ font-size: .95rem
+ font-weight: 600
+ letter-spacing: .5px
+ display: flex
+ align-items: center
+ gap: .55rem
+
+.sidebar__nav
+ display: flex
+ flex-direction: column
+ gap: .2rem
+
+.nav-item
+ display: flex
+ align-items: center
+ gap: .65rem
+ padding: .6rem .7rem
+ color: var(--text-dim)
+ font-size: .75rem
+ font-weight: 500
+ letter-spacing: .5px
+ border-radius: var(--radius-sm)
+ transition: background .15s ease, color .15s ease
+ &.active, &:hover
+ background: var(--bg-elev)
+ color: var(--text)
+
+.nav-item__icon
+ font-size: 1.1rem
+ display: inline-flex
+ color: inherit
+
+.main
+ flex: 1
+ display: flex
+ flex-direction: column
+ min-width: 0
+
+.topbar
+ height: 54px
+ display: flex
+ align-items: center
+ padding: 0 1.1rem
+ border-bottom: 1px solid var(--border)
+ background: var(--bg-alt)
+ gap: 1rem
+
+.grow
+ flex: 1
+
+.content
+ flex: 1
+ padding: 1.4rem 1.3rem 2rem
+ display: flex
+ flex-direction: column
+ gap: 1.2rem
+
+
+/* Auth page */
+.auth-wrapper
+ min-height: 100vh
+ display: flex
+ align-items: center
+ justify-content: center
+ padding: 2rem 1rem
+
+.auth-box
+ width: 100%
+ max-width: 380px
+ background: var(--bg-alt)
+ border: 1px solid var(--border)
+ border-radius: var(--radius-lg)
+ padding: 1.8rem 1.6rem 1.7rem
+ display: flex
+ flex-direction: column
+ gap: 1.2rem
+ box-shadow: 0 4px 14px -4px rgba(31,36,41,.08), 0 2px 4px -1px rgba(31,36,41,.06)
+
+.auth-title
+ font-size: 1.1rem
+ font-weight: 600
+ letter-spacing: .5px
+
+.form
+ display: flex
+ flex-direction: column
+ gap: .95rem
+
+.sep
+ height: 1px
+ background: var(--border)
+ margin: .25rem 0
+
+.text-center
+ text-align: center
+
+.small
+ font-size: .65rem
+ color: var(--text-dim)
+
+.inline
+ display: inline
\ No newline at end of file
diff --git a/webui/src/pages/Login/Login.jsx b/webui/src/pages/Login/Login.jsx
index 87d316e..542c2af 100644
--- a/webui/src/pages/Login/Login.jsx
+++ b/webui/src/pages/Login/Login.jsx
@@ -1,6 +1,54 @@
-import "./styles.sass";
+import React, { useContext, useState } from 'react';
+import './styles.sass';
+import Input from '@/common/components/Input';
+import Button from '@/common/components/Button';
+import { UserContext } from '@/common/contexts/UserContext.jsx';
+import { request } from '@/common/utils/RequestUtil.js';
+import { LockIcon, UserIcon } from '@phosphor-icons/react';
+
export const Login = () => {
- return (
- Login Page
- )
-}
\ No newline at end of file
+ const { updateSessionToken, isSetupCompleted } = useContext(UserContext);
+ const [username, setUsername] = useState('');
+ const [password, setPassword] = useState('');
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [mode, setMode] = useState(isSetupCompleted ? 'login' : 'setup');
+
+ React.useEffect(()=>{
+ setMode(isSetupCompleted ? 'login' : 'setup');
+ }, [isSetupCompleted]);
+
+ const submit = async (e) => {
+ e.preventDefault();
+ setError(null);
+ setLoading(true);
+ try {
+ if (mode === 'login') {
+ const data = await request('/auth/login','POST',{username, password});
+ updateSessionToken(data.token);
+ } else { // setup
+ await request('/setup/init','POST',{username, password});
+ const data = await request('/auth/login','POST',{username, password});
+ updateSessionToken(data.token);
+ }
+ } catch (err) {
+ setError(err.error || err.message || 'Error');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
{mode === 'login' ? 'Sign in' : 'Initial Setup'}
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/webui/src/pages/Login/styles.sass b/webui/src/pages/Login/styles.sass
index e69de29..6f090d7 100644
--- a/webui/src/pages/Login/styles.sass
+++ b/webui/src/pages/Login/styles.sass
@@ -0,0 +1,11 @@
+.auth-box
+ box-shadow: 0 0 0 1px rgba(255,255,255,.02), 0 4px 16px -6px rgba(0,0,0,.6)
+ animation: fadeIn .3s ease
+
+@keyframes fadeIn
+ from
+ opacity: 0
+ transform: translateY(4px)
+ to
+ opacity: 1
+ transform: translateY(0)
\ No newline at end of file