Add UI components
This commit is contained in:
@@ -4,9 +4,6 @@
|
|||||||
<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>Arkendro</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>
|
||||||
|
@@ -20,6 +20,7 @@ const App = () => {
|
|||||||
{ path: "/dashboard", element: <Placeholder title="Dashboard" /> },
|
{ path: "/dashboard", element: <Placeholder title="Dashboard" /> },
|
||||||
{ path: "/servers", element: <Placeholder title="Servers" /> },
|
{ path: "/servers", element: <Placeholder title="Servers" /> },
|
||||||
{ path: "/settings", element: <Placeholder title="Settings" /> },
|
{ path: "/settings", element: <Placeholder title="Settings" /> },
|
||||||
|
{ path: "/admin/users", element: <Placeholder title="User Management" /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
@@ -2,16 +2,6 @@ import React from "react";
|
|||||||
import cn from "classnames";
|
import cn from "classnames";
|
||||||
import "./styles.sass";
|
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 = ({
|
export const Button = ({
|
||||||
as: Component = "button",
|
as: Component = "button",
|
||||||
variant = "primary",
|
variant = "primary",
|
||||||
@@ -39,12 +29,10 @@ export const Button = ({
|
|||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
{...rest}
|
{...rest}
|
||||||
>
|
>
|
||||||
{loading && <span className="btn__spinner" aria-hidden />}
|
{loading && <span className="btn-spinner" aria-hidden />}
|
||||||
{icon && <span className="btn__icon btn__icon--left">{icon}</span>}
|
{icon && <span className="btn-icon btn-icon--left">{icon}</span>}
|
||||||
<span className="btn__label">{children}</span>
|
<span className="btn-label">{children}</span>
|
||||||
{iconRight && <span className="btn__icon btn__icon--right">{iconRight}</span>}
|
{iconRight && <span className="btn-icon btn-icon--right">{iconRight}</span>}
|
||||||
</Component>
|
</Component>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Button;
|
|
||||||
|
@@ -2,8 +2,8 @@
|
|||||||
--c-bg: #ffffff
|
--c-bg: #ffffff
|
||||||
--c-bg-hover: #f2f5f8
|
--c-bg-hover: #f2f5f8
|
||||||
--c-bg-active: #e6ebf0
|
--c-bg-active: #e6ebf0
|
||||||
--c-border: #d0d7de
|
--c-border: #dfe3e8
|
||||||
--c-border-hover: #c2cbd3
|
--c-border-hover: #c7ced6
|
||||||
--c-text: #1f2429
|
--c-text: #1f2429
|
||||||
--c-accent: #0f62fe
|
--c-accent: #0f62fe
|
||||||
--c-danger: #d93025
|
--c-danger: #d93025
|
||||||
@@ -11,16 +11,16 @@
|
|||||||
display: inline-flex
|
display: inline-flex
|
||||||
align-items: center
|
align-items: center
|
||||||
justify-content: center
|
justify-content: center
|
||||||
gap: .5rem
|
gap: .6rem
|
||||||
font-family: inherit
|
font-family: inherit
|
||||||
font-weight: 500
|
font-weight: 600
|
||||||
line-height: 1.2
|
line-height: 1.2
|
||||||
cursor: pointer
|
cursor: pointer
|
||||||
border: 1px solid var(--c-border)
|
border: 1px solid var(--c-border)
|
||||||
background: var(--c-bg)
|
background: var(--c-bg)
|
||||||
color: var(--c-text)
|
color: var(--c-text)
|
||||||
border-radius: 6px
|
border-radius: 12px
|
||||||
transition: background .15s ease, border-color .15s ease, color .15s ease, box-shadow .15s ease
|
transition: all .2s ease
|
||||||
user-select: none
|
user-select: none
|
||||||
text-decoration: none
|
text-decoration: none
|
||||||
&:hover:not(:disabled)
|
&:hover:not(:disabled)
|
||||||
@@ -28,6 +28,7 @@
|
|||||||
border-color: var(--c-border-hover)
|
border-color: var(--c-border-hover)
|
||||||
&:active:not(:disabled)
|
&:active:not(:disabled)
|
||||||
background: var(--c-bg-active)
|
background: var(--c-bg-active)
|
||||||
|
transform: translateY(1px)
|
||||||
&:focus-visible
|
&:focus-visible
|
||||||
outline: 2px solid var(--c-accent)
|
outline: 2px solid var(--c-accent)
|
||||||
outline-offset: 2px
|
outline-offset: 2px
|
||||||
@@ -37,33 +38,39 @@
|
|||||||
&.btn--full
|
&.btn--full
|
||||||
width: 100%
|
width: 100%
|
||||||
&.btn--sm
|
&.btn--sm
|
||||||
font-size: .75rem
|
|
||||||
padding: .4rem .7rem
|
|
||||||
&.btn--md
|
|
||||||
font-size: .85rem
|
font-size: .85rem
|
||||||
padding: .6rem 1rem
|
padding: .7rem 1rem
|
||||||
|
&.btn--md
|
||||||
|
font-size: .95rem
|
||||||
|
padding: .85rem 1.25rem
|
||||||
&.btn--lg
|
&.btn--lg
|
||||||
font-size: 1rem
|
font-size: 1.05rem
|
||||||
padding: .8rem 1.2rem
|
padding: 1rem 1.5rem
|
||||||
&.btn--primary
|
&.btn--primary
|
||||||
--c-bg: #0f62fe
|
--c-bg: #1f2429
|
||||||
--c-bg-hover: #0d55dd
|
--c-bg-hover: #374048
|
||||||
--c-bg-active: #0b47b8
|
--c-bg-active: #2a3038
|
||||||
--c-border: #0f62fe
|
--c-border: #1f2429
|
||||||
--c-text: #ffffff
|
--c-text: #ffffff
|
||||||
|
background: var(--c-bg)
|
||||||
|
border-color: var(--c-border)
|
||||||
|
&:hover:not(:disabled)
|
||||||
|
background: var(--c-bg-hover)
|
||||||
&.btn--subtle
|
&.btn--subtle
|
||||||
--c-bg: #f3f6f9
|
--c-bg: #f0f3f6
|
||||||
--c-bg-hover: #e8edf2
|
--c-bg-hover: #e6ebf0
|
||||||
--c-bg-active: #dfe5eb
|
--c-bg-active: #dfe3e8
|
||||||
--c-border: #e1e6eb
|
--c-border: #dfe3e8
|
||||||
&.btn--danger
|
&.btn--danger
|
||||||
--c-bg: #d93025
|
--c-bg: #d93025
|
||||||
--c-bg-hover: #c22b21
|
--c-bg-hover: #c22b21
|
||||||
--c-bg-active: #a9241b
|
--c-bg-active: #a9241b
|
||||||
--c-border: #d93025
|
--c-border: #d93025
|
||||||
--c-text: #ffffff
|
--c-text: #ffffff
|
||||||
|
background: var(--c-bg)
|
||||||
|
border-color: var(--c-border)
|
||||||
|
|
||||||
.btn__icon
|
.btn-icon
|
||||||
display: inline-flex
|
display: inline-flex
|
||||||
align-items: center
|
align-items: center
|
||||||
&--left
|
&--left
|
||||||
@@ -71,7 +78,7 @@
|
|||||||
&--right
|
&--right
|
||||||
margin-left: .25rem
|
margin-left: .25rem
|
||||||
|
|
||||||
.btn__spinner
|
.btn-spinner
|
||||||
width: 14px
|
width: 14px
|
||||||
height: 14px
|
height: 14px
|
||||||
border: 2px solid rgba(0,0,0,.15)
|
border: 2px solid rgba(0,0,0,.15)
|
||||||
|
@@ -1,8 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import cn from "classnames";
|
import cn from "classnames";
|
||||||
import "./styles.sass";
|
import "./styles.sass";
|
||||||
|
|
||||||
/* Minimal input wrapper with label, error text, and optional icon */
|
|
||||||
export const Input = ({
|
export const Input = ({
|
||||||
label,
|
label,
|
||||||
error,
|
error,
|
||||||
@@ -14,14 +12,12 @@ export const Input = ({
|
|||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className={cn("field", containerClassName)}>
|
<div className={cn("field", containerClassName)}>
|
||||||
{label && <label className="field__label">{label}</label>}
|
{label && <label className="field-label">{label}</label>}
|
||||||
<div className={cn("field__control", error && "has-error", icon && "has-icon", className)}>
|
<div className={cn("field-control", error && "has-error", icon && "has-icon", className)}>
|
||||||
{icon && <span className="field__icon">{icon}</span>}
|
{icon && <span className="field-icon">{icon}</span>}
|
||||||
<input type={type} className="field__input" {...rest} />
|
<input type={type} className="field-input" {...rest} />
|
||||||
</div>
|
</div>
|
||||||
{error && <div className="field__error">{error}</div>}
|
{error && <div className="field-error">{error}</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Input;
|
|
||||||
|
@@ -1,57 +1,62 @@
|
|||||||
.field
|
.field
|
||||||
display: flex
|
display: flex
|
||||||
flex-direction: column
|
flex-direction: column
|
||||||
gap: .35rem
|
gap: .5rem
|
||||||
font-size: .8rem
|
font-size: .9rem
|
||||||
font-weight: 500
|
font-weight: 600
|
||||||
color: #374048
|
color: #374048
|
||||||
|
|
||||||
.field__label
|
.field-label
|
||||||
letter-spacing: .5px
|
letter-spacing: .3px
|
||||||
|
margin-bottom: .2rem
|
||||||
|
|
||||||
.field__control
|
.field-control
|
||||||
position: relative
|
position: relative
|
||||||
display: flex
|
display: flex
|
||||||
align-items: center
|
align-items: center
|
||||||
background: #ffffff
|
background: #ffffff
|
||||||
border: 1px solid #d0d7de
|
border: 2px solid #e1e8f0
|
||||||
border-radius: 6px
|
border-radius: 16px
|
||||||
padding: .55rem .7rem
|
padding: 1rem 1.2rem
|
||||||
transition: border-color .15s ease, background .15s ease, box-shadow .15s ease
|
transition: all .2s ease
|
||||||
&.has-icon .field__input
|
&.has-icon .field-input
|
||||||
padding-left: 1.6rem
|
padding-left: 2.2rem
|
||||||
&.has-error
|
&.has-error
|
||||||
border-color: #d93025
|
border-color: #d93025
|
||||||
box-shadow: 0 0 0 1px #d93025
|
box-shadow: 0 0 0 4px rgba(217, 48, 37, 0.1)
|
||||||
&:focus-within
|
&:focus-within
|
||||||
border-color: #0f62fe
|
border-color: #0f62fe
|
||||||
box-shadow: 0 0 0 1px #0f62fe20
|
box-shadow: 0 0 0 4px rgba(15, 98, 254, 0.1)
|
||||||
|
transform: translateY(-1px)
|
||||||
|
|
||||||
.field__icon
|
.field-icon
|
||||||
position: absolute
|
position: absolute
|
||||||
left: .55rem
|
left: 1rem
|
||||||
top: 50%
|
top: 50%
|
||||||
transform: translateY(-50%)
|
transform: translateY(-50%)
|
||||||
display: inline-flex
|
display: inline-flex
|
||||||
font-size: 1rem
|
font-size: 1.1rem
|
||||||
color: #6b7781
|
color: #6b7781
|
||||||
pointer-events: none
|
pointer-events: none
|
||||||
|
|
||||||
.field__input
|
.field-input
|
||||||
appearance: none
|
appearance: none
|
||||||
outline: none
|
outline: none
|
||||||
background: transparent
|
background: transparent
|
||||||
border: 0
|
border: 0
|
||||||
color: #1f2429
|
color: #1f2429
|
||||||
font: inherit
|
font: inherit
|
||||||
|
font-size: 1rem
|
||||||
|
font-weight: 500
|
||||||
width: 100%
|
width: 100%
|
||||||
line-height: 1.2
|
line-height: 1.3
|
||||||
&::placeholder
|
&::placeholder
|
||||||
color: #a0abb4
|
color: #a0abb4
|
||||||
|
font-weight: 400
|
||||||
&:focus
|
&:focus
|
||||||
outline: none
|
outline: none
|
||||||
|
|
||||||
.field__error
|
.field-error
|
||||||
font-size: .65rem
|
font-size: .65rem
|
||||||
font-weight: 600
|
font-weight: 600
|
||||||
color: #d93025
|
color: #d93025
|
||||||
|
95
webui/src/common/components/ProfileMenu/ProfileMenu.jsx
Normal file
95
webui/src/common/components/ProfileMenu/ProfileMenu.jsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import React, {useState, useRef, useEffect, useContext} from 'react';
|
||||||
|
import {UserCircleIcon, SignOutIcon, CaretDownIcon} from '@phosphor-icons/react';
|
||||||
|
import {UserContext} from '@/common/contexts/UserContext.jsx';
|
||||||
|
import './styles.sass';
|
||||||
|
|
||||||
|
export const ProfileMenu = () => {
|
||||||
|
const {logout} = useContext(UserContext);
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const menuRef = useRef(null);
|
||||||
|
|
||||||
|
// Close menu when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event) => {
|
||||||
|
if (menuRef.current && !menuRef.current.contains(event.target)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Close menu on escape key
|
||||||
|
useEffect(() => {
|
||||||
|
const handleEscapeKey = (event) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener('keydown', handleEscapeKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleEscapeKey);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
setIsOpen(false);
|
||||||
|
logout();
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleMenu = () => {
|
||||||
|
setIsOpen(!isOpen);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="profile-menu" ref={menuRef}>
|
||||||
|
<button
|
||||||
|
className={`profile-menu-trigger ${isOpen ? 'active' : ''}`}
|
||||||
|
onClick={toggleMenu}
|
||||||
|
aria-label="User menu"
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
>
|
||||||
|
<div className="profile-menu-avatar">
|
||||||
|
<UserCircleIcon size={16} weight="fill"/>
|
||||||
|
</div>
|
||||||
|
<span className="profile-menu-name">Admin</span>
|
||||||
|
<CaretDownIcon
|
||||||
|
size={14}
|
||||||
|
className={`profile-menu-caret ${isOpen ? 'rotated' : ''}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="profile-menu-dropdown">
|
||||||
|
<div className="profile-menu-header">
|
||||||
|
<div className="profile-menu-avatar profile-menu-avatar--large">
|
||||||
|
<UserCircleIcon size={20} weight="fill"/>
|
||||||
|
</div>
|
||||||
|
<div className="profile-menu-info">
|
||||||
|
<div className="profile-menu-name-large">Admin User</div>
|
||||||
|
<div className="profile-menu-role">Administrator</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="profile-menu-divider"></div>
|
||||||
|
|
||||||
|
<div className="profile-menu-actions">
|
||||||
|
<button
|
||||||
|
className="profile-menu-item"
|
||||||
|
onClick={handleLogout}
|
||||||
|
>
|
||||||
|
<SignOutIcon size={16}/>
|
||||||
|
<span>Sign Out</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
1
webui/src/common/components/ProfileMenu/index.js
Normal file
1
webui/src/common/components/ProfileMenu/index.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { ProfileMenu as default } from './ProfileMenu';
|
123
webui/src/common/components/ProfileMenu/styles.sass
Normal file
123
webui/src/common/components/ProfileMenu/styles.sass
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
.profile-menu
|
||||||
|
position: relative
|
||||||
|
display: inline-block
|
||||||
|
|
||||||
|
.profile-menu-trigger
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
gap: .5rem
|
||||||
|
padding: .5rem .75rem
|
||||||
|
background: transparent
|
||||||
|
border: 1px solid var(--border)
|
||||||
|
border-radius: 10px
|
||||||
|
color: var(--text)
|
||||||
|
font-size: .85rem
|
||||||
|
font-weight: 500
|
||||||
|
cursor: pointer
|
||||||
|
transition: all .2s ease
|
||||||
|
&:hover
|
||||||
|
background: var(--bg-elev)
|
||||||
|
border-color: var(--border-strong)
|
||||||
|
&.active
|
||||||
|
background: var(--bg-elev)
|
||||||
|
border-color: var(--border-strong)
|
||||||
|
.profile-menu-caret
|
||||||
|
transform: rotate(180deg)
|
||||||
|
|
||||||
|
.profile-menu-avatar
|
||||||
|
width: 28px
|
||||||
|
height: 28px
|
||||||
|
background: rgba(15, 98, 254, 0.15)
|
||||||
|
border: 1px solid rgba(15, 98, 254, 0.25)
|
||||||
|
border-radius: 50%
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
justify-content: center
|
||||||
|
color: var(--accent)
|
||||||
|
flex-shrink: 0
|
||||||
|
backdrop-filter: blur(8px)
|
||||||
|
&--large
|
||||||
|
width: 36px
|
||||||
|
height: 36px
|
||||||
|
background: rgba(15, 98, 254, 0.2)
|
||||||
|
border: 1px solid rgba(15, 98, 254, 0.3)
|
||||||
|
|
||||||
|
.profile-menu-name
|
||||||
|
font-weight: 600
|
||||||
|
white-space: nowrap
|
||||||
|
|
||||||
|
.profile-menu-caret
|
||||||
|
transition: transform .2s ease
|
||||||
|
color: var(--text-dim)
|
||||||
|
&.rotated
|
||||||
|
transform: rotate(180deg)
|
||||||
|
|
||||||
|
.profile-menu-dropdown
|
||||||
|
position: absolute
|
||||||
|
top: calc(100% + .5rem)
|
||||||
|
right: 0
|
||||||
|
min-width: 200px
|
||||||
|
background: var(--bg-alt)
|
||||||
|
border: 1px solid var(--border)
|
||||||
|
border-radius: 12px
|
||||||
|
box-shadow: 0 8px 32px -8px rgba(31,36,41,.15), 0 4px 16px -4px rgba(31,36,41,.1)
|
||||||
|
z-index: 1000
|
||||||
|
animation: dropdownFadeIn .2s ease-out
|
||||||
|
|
||||||
|
.profile-menu-header
|
||||||
|
padding: 1rem
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
gap: .75rem
|
||||||
|
|
||||||
|
.profile-menu-info
|
||||||
|
flex: 1
|
||||||
|
min-width: 0
|
||||||
|
|
||||||
|
.profile-menu-name-large
|
||||||
|
font-size: .9rem
|
||||||
|
font-weight: 600
|
||||||
|
color: var(--text)
|
||||||
|
margin-bottom: .1rem
|
||||||
|
|
||||||
|
.profile-menu-role
|
||||||
|
font-size: .75rem
|
||||||
|
color: var(--text-dim)
|
||||||
|
font-weight: 500
|
||||||
|
|
||||||
|
.profile-menu-divider
|
||||||
|
height: 1px
|
||||||
|
background: var(--border)
|
||||||
|
margin: 0 .5rem
|
||||||
|
|
||||||
|
.profile-menu-actions
|
||||||
|
padding: .5rem
|
||||||
|
|
||||||
|
.profile-menu-item
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
gap: .75rem
|
||||||
|
width: 100%
|
||||||
|
padding: .75rem
|
||||||
|
background: transparent
|
||||||
|
border: none
|
||||||
|
border-radius: 8px
|
||||||
|
color: var(--text)
|
||||||
|
font-size: .85rem
|
||||||
|
font-weight: 500
|
||||||
|
cursor: pointer
|
||||||
|
transition: all .15s ease
|
||||||
|
text-align: left
|
||||||
|
&:hover
|
||||||
|
background: var(--bg-elev)
|
||||||
|
color: var(--danger)
|
||||||
|
svg
|
||||||
|
color: var(--danger)
|
||||||
|
|
||||||
|
@keyframes dropdownFadeIn
|
||||||
|
from
|
||||||
|
opacity: 0
|
||||||
|
transform: translateY(-4px) scale(0.95)
|
||||||
|
to
|
||||||
|
opacity: 1
|
||||||
|
transform: translateY(0) scale(1)
|
60
webui/src/common/components/Sidebar/Sidebar.jsx
Normal file
60
webui/src/common/components/Sidebar/Sidebar.jsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import React, { useContext } from 'react';
|
||||||
|
import { NavLink } from 'react-router-dom';
|
||||||
|
import { HouseIcon, GearSixIcon, SquaresFourIcon, CubeIcon, UsersIcon } from '@phosphor-icons/react';
|
||||||
|
import { UserContext } from '@/common/contexts/UserContext.jsx';
|
||||||
|
import './styles.sass';
|
||||||
|
|
||||||
|
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 adminNavItems = [
|
||||||
|
{ to: '/admin/users', label: 'User Management', icon: <UsersIcon weight="duotone" /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const Sidebar = () => {
|
||||||
|
const { user } = useContext(UserContext);
|
||||||
|
const isAdmin = user?.role === 'admin' || user?.is_admin === true;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="sidebar">
|
||||||
|
<div className="sidebar-brand">
|
||||||
|
<CubeIcon size={24} weight="duotone" />
|
||||||
|
<span>Arkendro</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="sidebar-nav">
|
||||||
|
<div className="nav-section">
|
||||||
|
{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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isAdmin && (
|
||||||
|
<div className="nav-section">
|
||||||
|
<div className="nav-section-title">Admin</div>
|
||||||
|
{adminNavItems.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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
};
|
1
webui/src/common/components/Sidebar/index.js
Normal file
1
webui/src/common/components/Sidebar/index.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { Sidebar as default } from './Sidebar';
|
69
webui/src/common/components/Sidebar/styles.sass
Normal file
69
webui/src/common/components/Sidebar/styles.sass
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
.sidebar
|
||||||
|
width: var(--sidebar-width)
|
||||||
|
background: var(--bg-alt)
|
||||||
|
border-right: 1px solid var(--border)
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
padding: 1.5rem 1.2rem
|
||||||
|
gap: 1.8rem
|
||||||
|
|
||||||
|
.sidebar-brand
|
||||||
|
font-size: 1.1rem
|
||||||
|
font-weight: 700
|
||||||
|
letter-spacing: .3px
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
gap: .75rem
|
||||||
|
color: var(--text)
|
||||||
|
padding-bottom: .5rem
|
||||||
|
svg
|
||||||
|
color: var(--accent)
|
||||||
|
flex-shrink: 0
|
||||||
|
|
||||||
|
.sidebar-nav
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
gap: 1.2rem
|
||||||
|
|
||||||
|
.nav-section
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
gap: .4rem
|
||||||
|
|
||||||
|
.nav-section-title
|
||||||
|
font-size: .75rem
|
||||||
|
font-weight: 700
|
||||||
|
text-transform: uppercase
|
||||||
|
letter-spacing: .8px
|
||||||
|
color: var(--text-dim)
|
||||||
|
margin-bottom: .3rem
|
||||||
|
padding: 0 .9rem
|
||||||
|
|
||||||
|
.nav-item
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
gap: .75rem
|
||||||
|
padding: .75rem .9rem
|
||||||
|
color: var(--text-dim)
|
||||||
|
font-size: .85rem
|
||||||
|
font-weight: 600
|
||||||
|
letter-spacing: .3px
|
||||||
|
border-radius: 10px
|
||||||
|
transition: all .2s ease
|
||||||
|
cursor: pointer
|
||||||
|
text-decoration: none
|
||||||
|
&:hover
|
||||||
|
background: var(--bg-elev)
|
||||||
|
color: var(--text)
|
||||||
|
transform: translateX(2px)
|
||||||
|
&.active
|
||||||
|
background: var(--text)
|
||||||
|
color: #ffffff
|
||||||
|
font-weight: 700
|
||||||
|
box-shadow: 0 2px 8px -2px rgba(31, 36, 41, 0.2)
|
||||||
|
|
||||||
|
.nav-item-icon
|
||||||
|
font-size: 1.2rem
|
||||||
|
display: inline-flex
|
||||||
|
color: inherit
|
||||||
|
min-width: 1.2rem
|
@@ -1,30 +1,17 @@
|
|||||||
import React, { useContext } from 'react';
|
import React from 'react';
|
||||||
import { NavLink, Outlet } from 'react-router-dom';
|
import { Outlet } from 'react-router-dom';
|
||||||
import { HouseIcon, GearSixIcon, SquaresFourIcon } from '@phosphor-icons/react';
|
import Sidebar from '@/common/components/Sidebar';
|
||||||
|
import ProfileMenu from '@/common/components/ProfileMenu';
|
||||||
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 = () => {
|
const Root = () => {
|
||||||
return (
|
return (
|
||||||
<div className="layout">
|
<div className="layout">
|
||||||
<aside className="sidebar">
|
<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">
|
<div className="main">
|
||||||
<header className="topbar">
|
<header className="topbar">
|
||||||
<h3>Test</h3>
|
<h3>Dashboard</h3>
|
||||||
|
<div className="grow"></div>
|
||||||
|
<ProfileMenu />
|
||||||
</header>
|
</header>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
|
@@ -43,48 +43,6 @@ a
|
|||||||
display: flex
|
display: flex
|
||||||
min-height: 100vh
|
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
|
.main
|
||||||
flex: 1
|
flex: 1
|
||||||
display: flex
|
display: flex
|
||||||
@@ -109,49 +67,3 @@ a
|
|||||||
display: flex
|
display: flex
|
||||||
flex-direction: column
|
flex-direction: column
|
||||||
gap: 1.2rem
|
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
|
|
@@ -13,6 +13,7 @@ export const Login = () => {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [mode, setMode] = useState(isSetupCompleted ? 'login' : 'setup');
|
const [mode, setMode] = useState(isSetupCompleted ? 'login' : 'setup');
|
||||||
|
const [isExiting, setIsExiting] = useState(false);
|
||||||
|
|
||||||
React.useEffect(()=>{
|
React.useEffect(()=>{
|
||||||
setMode(isSetupCompleted ? 'login' : 'setup');
|
setMode(isSetupCompleted ? 'login' : 'setup');
|
||||||
@@ -25,28 +26,48 @@ export const Login = () => {
|
|||||||
try {
|
try {
|
||||||
if (mode === 'login') {
|
if (mode === 'login') {
|
||||||
const data = await request('/auth/login','POST',{username, password});
|
const data = await request('/auth/login','POST',{username, password});
|
||||||
|
// Trigger fade-out animation before updating token
|
||||||
|
setIsExiting(true);
|
||||||
|
setTimeout(() => {
|
||||||
updateSessionToken(data.token);
|
updateSessionToken(data.token);
|
||||||
|
}, 300); // Match the fade-out animation duration
|
||||||
} else { // setup
|
} else { // setup
|
||||||
await request('/setup/init','POST',{username, password});
|
await request('/setup/init','POST',{username, password});
|
||||||
const data = await request('/auth/login','POST',{username, password});
|
const data = await request('/auth/login','POST',{username, password});
|
||||||
|
setIsExiting(true);
|
||||||
|
setTimeout(() => {
|
||||||
updateSessionToken(data.token);
|
updateSessionToken(data.token);
|
||||||
|
}, 300);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.error || err.message || 'Error');
|
setError(err.error || err.message || 'Error');
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="auth-wrapper">
|
<div className={`auth-wrapper ${isExiting ? 'fade-out' : ''}`}>
|
||||||
<div className="auth-box">
|
<div className={`auth-box ${isExiting ? 'fade-out' : ''}`}>
|
||||||
<div className="auth-title">{mode === 'login' ? 'Sign in' : 'Initial Setup'}</div>
|
<div className="auth-header">
|
||||||
|
<div className="auth-title">{mode === 'login' ? 'Welcome Back' : 'Initial Setup'}</div>
|
||||||
|
<div className="auth-subtitle">
|
||||||
|
{mode === 'login'
|
||||||
|
? 'Sign in to your account to continue'
|
||||||
|
: 'Create your admin account to get started'
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<form className="form" onSubmit={submit}>
|
<form className="form" onSubmit={submit}>
|
||||||
|
<div className="form-field form-field--1">
|
||||||
<Input label="Username" placeholder="your name" value={username} onChange={e=>setUsername(e.target.value)} icon={<UserIcon size={16} />} autoFocus required />
|
<Input label="Username" placeholder="your name" value={username} onChange={e=>setUsername(e.target.value)} icon={<UserIcon size={16} />} autoFocus required />
|
||||||
|
</div>
|
||||||
|
<div className="form-field form-field--2">
|
||||||
<Input type="password" label="Password" placeholder="••••••••" value={password} onChange={e=>setPassword(e.target.value)} icon={<LockIcon size={16} />} 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>}
|
</div>
|
||||||
|
{error && <div className="auth-error form-field form-field--3">{error}</div>}
|
||||||
|
<div className="form-field form-field--4">
|
||||||
<Button type="submit" loading={loading} variant="primary" full>{mode === 'login' ? 'Login' : 'Create Admin & Continue'}</Button>
|
<Button type="submit" loading={loading} variant="primary" full>{mode === 'login' ? 'Login' : 'Create Admin & Continue'}</Button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -1,11 +1,130 @@
|
|||||||
.auth-box
|
.auth-wrapper
|
||||||
box-shadow: 0 0 0 1px rgba(255,255,255,.02), 0 4px 16px -6px rgba(0,0,0,.6)
|
min-height: 100vh
|
||||||
animation: fadeIn .3s ease
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
justify-content: center
|
||||||
|
padding: 2rem 1rem
|
||||||
|
background: var(--bg)
|
||||||
|
animation: fadeInWrapper .4s ease-out
|
||||||
|
|
||||||
@keyframes fadeIn
|
.auth-box
|
||||||
|
width: 100%
|
||||||
|
max-width: 480px
|
||||||
|
background: var(--bg-alt)
|
||||||
|
border: 2px solid var(--border)
|
||||||
|
border-radius: 24px
|
||||||
|
padding: 3rem 2.5rem 2.8rem
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
gap: 1.8rem
|
||||||
|
box-shadow: 0 8px 32px -8px rgba(31,36,41,.12), 0 4px 16px -4px rgba(31,36,41,.08)
|
||||||
|
position: relative
|
||||||
|
overflow: hidden
|
||||||
|
animation: fadeInUp .6s ease-out
|
||||||
|
|
||||||
|
.auth-title
|
||||||
|
font-size: 1.75rem
|
||||||
|
font-weight: 700
|
||||||
|
letter-spacing: -0.5px
|
||||||
|
text-align: center
|
||||||
|
color: var(--text)
|
||||||
|
margin-bottom: 0.5rem
|
||||||
|
|
||||||
|
.auth-header
|
||||||
|
text-align: center
|
||||||
|
margin-bottom: 1rem
|
||||||
|
|
||||||
|
.auth-subtitle
|
||||||
|
font-size: 1rem
|
||||||
|
font-weight: 400
|
||||||
|
color: var(--text-dim)
|
||||||
|
text-align: center
|
||||||
|
line-height: 1.4
|
||||||
|
|
||||||
|
.auth-error
|
||||||
|
background: rgba(217, 48, 37, 0.1)
|
||||||
|
border: 1px solid rgba(217, 48, 37, 0.2)
|
||||||
|
border-radius: 12px
|
||||||
|
padding: 0.75rem 1rem
|
||||||
|
color: #d93025
|
||||||
|
font-size: 0.9rem
|
||||||
|
font-weight: 600
|
||||||
|
text-align: center
|
||||||
|
|
||||||
|
.form
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
gap: 1.5rem
|
||||||
|
|
||||||
|
@keyframes fadeInWrapper
|
||||||
from
|
from
|
||||||
opacity: 0
|
opacity: 0
|
||||||
transform: translateY(4px)
|
backdrop-filter: blur(0px)
|
||||||
to
|
to
|
||||||
opacity: 1
|
opacity: 1
|
||||||
transform: translateY(0)
|
backdrop-filter: blur(2px)
|
||||||
|
|
||||||
|
@keyframes fadeInUp
|
||||||
|
from
|
||||||
|
opacity: 0
|
||||||
|
transform: translateY(40px) scale(0.9)
|
||||||
|
filter: blur(4px)
|
||||||
|
50%
|
||||||
|
opacity: 0.8
|
||||||
|
transform: translateY(20px) scale(0.95)
|
||||||
|
filter: blur(2px)
|
||||||
|
to
|
||||||
|
opacity: 1
|
||||||
|
transform: translateY(0) scale(1)
|
||||||
|
filter: blur(0px)
|
||||||
|
|
||||||
|
// Fade out animations for when dialog closes
|
||||||
|
.auth-wrapper.fade-out
|
||||||
|
animation: fadeOutWrapper .3s ease-in forwards
|
||||||
|
|
||||||
|
.auth-box.fade-out
|
||||||
|
animation: fadeOutDown .3s ease-in forwards
|
||||||
|
|
||||||
|
@keyframes fadeOutWrapper
|
||||||
|
from
|
||||||
|
opacity: 1
|
||||||
|
backdrop-filter: blur(2px)
|
||||||
|
to
|
||||||
|
opacity: 0
|
||||||
|
backdrop-filter: blur(0px)
|
||||||
|
|
||||||
|
@keyframes fadeOutDown
|
||||||
|
from
|
||||||
|
opacity: 1
|
||||||
|
transform: translateY(0) scale(1)
|
||||||
|
filter: blur(0px)
|
||||||
|
to
|
||||||
|
opacity: 0
|
||||||
|
transform: translateY(20px) scale(0.95)
|
||||||
|
filter: blur(2px)
|
||||||
|
|
||||||
|
// Staggered animations for form elements
|
||||||
|
.form-field
|
||||||
|
animation: slideInLeft .5s ease-out both
|
||||||
|
|
||||||
|
.form-field--1
|
||||||
|
animation-delay: .1s
|
||||||
|
|
||||||
|
.form-field--2
|
||||||
|
animation-delay: .2s
|
||||||
|
|
||||||
|
.form-field--3
|
||||||
|
animation-delay: .3s
|
||||||
|
|
||||||
|
.form-field--4
|
||||||
|
animation-delay: .4s
|
||||||
|
|
||||||
|
@keyframes slideInLeft
|
||||||
|
from
|
||||||
|
opacity: 0
|
||||||
|
transform: translateX(-20px)
|
||||||
|
filter: blur(2px)
|
||||||
|
to
|
||||||
|
opacity: 1
|
||||||
|
transform: translateX(0)
|
||||||
|
filter: blur(0px)
|
Reference in New Issue
Block a user