Create test design

This commit is contained in:
Mathias Wagner
2025-09-09 12:39:03 +02:00
parent 0eb7e9d4ca
commit 0ce3751d08
15 changed files with 521 additions and 20 deletions

View File

@@ -3,7 +3,10 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React</title>
<title>Arkendro</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400..700&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>

View File

@@ -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",

16
webui/pnpm-lock.yaml generated
View File

@@ -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

View File

@@ -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}) => <div className="content"><h2 style={{fontSize:'1rem'}}>{title}</h2><p className="muted">Content coming soon.</p></div>;
const App = () => {
const router = createBrowserRouter([
{
path: "/",
element: <Root />,
children: [
{ path: "/", element: <Navigate to="/servers" /> },
{ path: "/", element: <Navigate to="/dashboard" /> },
{ path: "/dashboard", element: <Placeholder title="Dashboard" /> },
{ path: "/servers", element: <Placeholder title="Servers" /> },
{ path: "/settings", element: <Placeholder title="Settings" /> },
],
},
]);
return <RouterProvider router={router}/>;
return <UserProvider><RouterProvider router={router}/></UserProvider>;
};
export default App;

View File

@@ -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 (
<Component
className={cn(
"btn",
`btn--${variant}`,
`btn--${size}`,
full && "btn--full",
loading && "is-loading",
className
)}
disabled={isDisabled}
{...rest}
>
{loading && <span className="btn__spinner" aria-hidden />}
{icon && <span className="btn__icon btn__icon--left">{icon}</span>}
<span className="btn__label">{children}</span>
{iconRight && <span className="btn__icon btn__icon--right">{iconRight}</span>}
</Component>
);
};
export default Button;

View File

@@ -0,0 +1 @@
export { Button as default } from "./Button.jsx";

View File

@@ -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)

View File

@@ -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 (
<div className={cn("field", containerClassName)}>
{label && <label className="field__label">{label}</label>}
<div className={cn("field__control", error && "has-error", icon && "has-icon", className)}>
{icon && <span className="field__icon">{icon}</span>}
<input type={type} className="field__input" {...rest} />
</div>
{error && <div className="field__error">{error}</div>}
</div>
);
};
export default Input;

View File

@@ -0,0 +1 @@
export { Input as default } from "./Input.jsx";

View File

@@ -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

View File

@@ -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 (
<UserContext.Provider value={{ updateSessionToken, user, sessionToken, firstTimeSetup, login, logout }}>
<UserContext.Provider value={{ updateSessionToken, user, sessionToken, isSetupCompleted, login, logout }}>
{user == null && <Login />}
{user !== null && children}
</UserContext.Provider>

View File

@@ -1,7 +1,35 @@
export default () => {
return (
<>
Root
</>
)
}
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: <HouseIcon weight="duotone" /> },
{ to: '/servers', label: 'Servers', icon: <SquaresFourIcon weight="duotone" /> },
{ to: '/settings', label: 'Settings', icon: <GearSixIcon weight="duotone" /> },
];
const Root = () => {
return (
<div className="layout">
<aside className="sidebar">
<div className="sidebar__brand">Arkendro</div>
<nav className="sidebar__nav">
{navItems.map(item => (
<NavLink key={item.to} to={item.to} className={({isActive}) => `nav-item ${isActive ? 'active' : ''}`}>
<span className="nav-item__icon">{item.icon}</span>
<span>{item.label}</span>
</NavLink>
))}
</nav>
</aside>
<div className="main">
<header className="topbar">
<h3>Test</h3>
</header>
<Outlet />
</div>
</div>
);
};
export default Root;

View File

@@ -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

View File

@@ -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 (
<div>Login Page</div>
)
}
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 (
<div className="auth-wrapper">
<div className="auth-box">
<div className="auth-title">{mode === 'login' ? 'Sign in' : 'Initial Setup'}</div>
<form className="form" onSubmit={submit}>
<Input label="Username" placeholder="your name" value={username} onChange={e=>setUsername(e.target.value)} icon={<UserIcon size={16} />} autoFocus required />
<Input type="password" label="Password" placeholder="••••••••" value={password} onChange={e=>setPassword(e.target.value)} icon={<LockIcon size={16} />} required />
{error && <div style={{color:'#dc3545', fontSize:'.65rem', fontWeight:600}}>{error}</div>}
<Button type="submit" loading={loading} variant="primary" full>{mode === 'login' ? 'Login' : 'Create Admin & Continue'}</Button>
</form>
</div>
</div>
);
};

View File

@@ -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)