Create test design
This commit is contained in:
@@ -3,7 +3,10 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
@@ -10,7 +10,9 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fontsource/plus-jakarta-sans": "^5.2.6",
|
||||||
"@phosphor-icons/react": "^2.1.10",
|
"@phosphor-icons/react": "^2.1.10",
|
||||||
|
"classnames": "^2.5.1",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"react-router-dom": "^7.8.2",
|
"react-router-dom": "^7.8.2",
|
||||||
|
16
webui/pnpm-lock.yaml
generated
16
webui/pnpm-lock.yaml
generated
@@ -8,9 +8,15 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@fontsource/plus-jakarta-sans':
|
||||||
|
specifier: ^5.2.6
|
||||||
|
version: 5.2.6
|
||||||
'@phosphor-icons/react':
|
'@phosphor-icons/react':
|
||||||
specifier: ^2.1.10
|
specifier: ^2.1.10
|
||||||
version: 2.1.10(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
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:
|
react:
|
||||||
specifier: ^19.1.1
|
specifier: ^19.1.1
|
||||||
version: 19.1.1
|
version: 19.1.1
|
||||||
@@ -334,6 +340,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==}
|
resolution: {integrity: sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
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':
|
'@humanfs/core@0.19.1':
|
||||||
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
|
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
|
||||||
engines: {node: '>=18.18.0'}
|
engines: {node: '>=18.18.0'}
|
||||||
@@ -648,6 +657,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
|
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
|
||||||
engines: {node: '>= 14.16.0'}
|
engines: {node: '>= 14.16.0'}
|
||||||
|
|
||||||
|
classnames@2.5.1:
|
||||||
|
resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==}
|
||||||
|
|
||||||
color-convert@2.0.1:
|
color-convert@2.0.1:
|
||||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||||
engines: {node: '>=7.0.0'}
|
engines: {node: '>=7.0.0'}
|
||||||
@@ -1490,6 +1502,8 @@ snapshots:
|
|||||||
'@eslint/core': 0.15.2
|
'@eslint/core': 0.15.2
|
||||||
levn: 0.4.1
|
levn: 0.4.1
|
||||||
|
|
||||||
|
'@fontsource/plus-jakarta-sans@5.2.6': {}
|
||||||
|
|
||||||
'@humanfs/core@0.19.1': {}
|
'@humanfs/core@0.19.1': {}
|
||||||
|
|
||||||
'@humanfs/node@0.16.7':
|
'@humanfs/node@0.16.7':
|
||||||
@@ -1750,6 +1764,8 @@ snapshots:
|
|||||||
readdirp: 4.1.2
|
readdirp: 4.1.2
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
classnames@2.5.1: {}
|
||||||
|
|
||||||
color-convert@2.0.1:
|
color-convert@2.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
color-name: 1.1.4
|
color-name: 1.1.4
|
||||||
|
@@ -1,18 +1,30 @@
|
|||||||
import { createBrowserRouter, Navigate, RouterProvider } from "react-router-dom";
|
import { createBrowserRouter, Navigate, RouterProvider } from "react-router-dom";
|
||||||
|
import { UserProvider } from '@/common/contexts/UserContext.jsx';
|
||||||
import "@/common/styles/main.sass";
|
import "@/common/styles/main.sass";
|
||||||
import Root from "@/common/layouts/Root.jsx";
|
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 App = () => {
|
||||||
const router = createBrowserRouter([
|
const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
path: "/",
|
path: "/",
|
||||||
element: <Root />,
|
element: <Root />,
|
||||||
children: [
|
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;
|
export default App;
|
50
webui/src/common/components/Button/Button.jsx
Normal file
50
webui/src/common/components/Button/Button.jsx
Normal 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;
|
1
webui/src/common/components/Button/index.js
Normal file
1
webui/src/common/components/Button/index.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { Button as default } from "./Button.jsx";
|
84
webui/src/common/components/Button/styles.sass
Normal file
84
webui/src/common/components/Button/styles.sass
Normal 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)
|
27
webui/src/common/components/Input/Input.jsx
Normal file
27
webui/src/common/components/Input/Input.jsx
Normal 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;
|
1
webui/src/common/components/Input/index.js
Normal file
1
webui/src/common/components/Input/index.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { Input as default } from "./Input.jsx";
|
58
webui/src/common/components/Input/styles.sass
Normal file
58
webui/src/common/components/Input/styles.sass
Normal 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
|
@@ -7,7 +7,7 @@ export const UserContext = createContext({});
|
|||||||
export const UserProvider = ({ children }) => {
|
export const UserProvider = ({ children }) => {
|
||||||
|
|
||||||
const [sessionToken, setSessionToken] = useState(localStorage.getItem("sessionToken"));
|
const [sessionToken, setSessionToken] = useState(localStorage.getItem("sessionToken"));
|
||||||
const [firstTimeSetup, setFirstTimeSetup] = useState(false);
|
const [isSetupCompleted, setIsSetupCompleted] = useState(false);
|
||||||
const [user, setUser] = useState(null);
|
const [user, setUser] = useState(null);
|
||||||
|
|
||||||
const updateSessionToken = (sessionToken) => {
|
const updateSessionToken = (sessionToken) => {
|
||||||
@@ -19,7 +19,7 @@ export const UserProvider = ({ children }) => {
|
|||||||
const checkFirstTimeSetup = async () => {
|
const checkFirstTimeSetup = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await getRequest("setup/status");
|
const response = await getRequest("setup/status");
|
||||||
setFirstTimeSetup(response?.first_user_exists);
|
setIsSetupCompleted(response?.first_user_exists);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
@@ -38,8 +38,11 @@ export const UserProvider = ({ children }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
|
try {
|
||||||
await postRequest("auth/logout", { token: sessionToken });
|
await postRequest("auth/logout", { token: sessionToken });
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -60,7 +63,7 @@ export const UserProvider = ({ children }) => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UserContext.Provider value={{ updateSessionToken, user, sessionToken, firstTimeSetup, login, logout }}>
|
<UserContext.Provider value={{ updateSessionToken, user, sessionToken, isSetupCompleted, login, logout }}>
|
||||||
{user == null && <Login />}
|
{user == null && <Login />}
|
||||||
{user !== null && children}
|
{user !== null && children}
|
||||||
</UserContext.Provider>
|
</UserContext.Provider>
|
||||||
|
@@ -1,7 +1,35 @@
|
|||||||
export default () => {
|
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 (
|
return (
|
||||||
<>
|
<div className="layout">
|
||||||
Root
|
<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;
|
@@ -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
|
@@ -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 = () => {
|
export const Login = () => {
|
||||||
|
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 (
|
return (
|
||||||
<div>Login Page</div>
|
<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>
|
||||||
|
);
|
||||||
|
};
|
@@ -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)
|
Reference in New Issue
Block a user