Add UI components
This commit is contained in:
@@ -4,9 +4,6 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<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>
|
||||
|
@@ -20,6 +20,7 @@ const App = () => {
|
||||
{ path: "/dashboard", element: <Placeholder title="Dashboard" /> },
|
||||
{ path: "/servers", element: <Placeholder title="Servers" /> },
|
||||
{ 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 "./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",
|
||||
@@ -39,12 +29,10 @@ export const Button = ({
|
||||
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>}
|
||||
{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;
|
||||
|
@@ -2,8 +2,8 @@
|
||||
--c-bg: #ffffff
|
||||
--c-bg-hover: #f2f5f8
|
||||
--c-bg-active: #e6ebf0
|
||||
--c-border: #d0d7de
|
||||
--c-border-hover: #c2cbd3
|
||||
--c-border: #dfe3e8
|
||||
--c-border-hover: #c7ced6
|
||||
--c-text: #1f2429
|
||||
--c-accent: #0f62fe
|
||||
--c-danger: #d93025
|
||||
@@ -11,16 +11,16 @@
|
||||
display: inline-flex
|
||||
align-items: center
|
||||
justify-content: center
|
||||
gap: .5rem
|
||||
gap: .6rem
|
||||
font-family: inherit
|
||||
font-weight: 500
|
||||
font-weight: 600
|
||||
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
|
||||
border-radius: 12px
|
||||
transition: all .2s ease
|
||||
user-select: none
|
||||
text-decoration: none
|
||||
&:hover:not(:disabled)
|
||||
@@ -28,6 +28,7 @@
|
||||
border-color: var(--c-border-hover)
|
||||
&:active:not(:disabled)
|
||||
background: var(--c-bg-active)
|
||||
transform: translateY(1px)
|
||||
&:focus-visible
|
||||
outline: 2px solid var(--c-accent)
|
||||
outline-offset: 2px
|
||||
@@ -37,33 +38,39 @@
|
||||
&.btn--full
|
||||
width: 100%
|
||||
&.btn--sm
|
||||
font-size: .75rem
|
||||
padding: .4rem .7rem
|
||||
&.btn--md
|
||||
font-size: .85rem
|
||||
padding: .6rem 1rem
|
||||
padding: .7rem 1rem
|
||||
&.btn--md
|
||||
font-size: .95rem
|
||||
padding: .85rem 1.25rem
|
||||
&.btn--lg
|
||||
font-size: 1rem
|
||||
padding: .8rem 1.2rem
|
||||
font-size: 1.05rem
|
||||
padding: 1rem 1.5rem
|
||||
&.btn--primary
|
||||
--c-bg: #0f62fe
|
||||
--c-bg-hover: #0d55dd
|
||||
--c-bg-active: #0b47b8
|
||||
--c-border: #0f62fe
|
||||
--c-bg: #1f2429
|
||||
--c-bg-hover: #374048
|
||||
--c-bg-active: #2a3038
|
||||
--c-border: #1f2429
|
||||
--c-text: #ffffff
|
||||
background: var(--c-bg)
|
||||
border-color: var(--c-border)
|
||||
&:hover:not(:disabled)
|
||||
background: var(--c-bg-hover)
|
||||
&.btn--subtle
|
||||
--c-bg: #f3f6f9
|
||||
--c-bg-hover: #e8edf2
|
||||
--c-bg-active: #dfe5eb
|
||||
--c-border: #e1e6eb
|
||||
--c-bg: #f0f3f6
|
||||
--c-bg-hover: #e6ebf0
|
||||
--c-bg-active: #dfe3e8
|
||||
--c-border: #dfe3e8
|
||||
&.btn--danger
|
||||
--c-bg: #d93025
|
||||
--c-bg-hover: #c22b21
|
||||
--c-bg-active: #a9241b
|
||||
--c-border: #d93025
|
||||
--c-text: #ffffff
|
||||
background: var(--c-bg)
|
||||
border-color: var(--c-border)
|
||||
|
||||
.btn__icon
|
||||
.btn-icon
|
||||
display: inline-flex
|
||||
align-items: center
|
||||
&--left
|
||||
@@ -71,7 +78,7 @@
|
||||
&--right
|
||||
margin-left: .25rem
|
||||
|
||||
.btn__spinner
|
||||
.btn-spinner
|
||||
width: 14px
|
||||
height: 14px
|
||||
border: 2px solid rgba(0,0,0,.15)
|
||||
|
@@ -1,8 +1,6 @@
|
||||
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,
|
||||
@@ -14,14 +12,12 @@ export const Input = ({
|
||||
}) => {
|
||||
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} />
|
||||
{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>}
|
||||
{error && <div className="field-error">{error}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Input;
|
||||
|
@@ -1,57 +1,62 @@
|
||||
.field
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: .35rem
|
||||
font-size: .8rem
|
||||
font-weight: 500
|
||||
gap: .5rem
|
||||
font-size: .9rem
|
||||
font-weight: 600
|
||||
color: #374048
|
||||
|
||||
.field__label
|
||||
letter-spacing: .5px
|
||||
.field-label
|
||||
letter-spacing: .3px
|
||||
margin-bottom: .2rem
|
||||
|
||||
.field__control
|
||||
.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
|
||||
border: 2px solid #e1e8f0
|
||||
border-radius: 16px
|
||||
padding: 1rem 1.2rem
|
||||
transition: all .2s ease
|
||||
&.has-icon .field-input
|
||||
padding-left: 2.2rem
|
||||
&.has-error
|
||||
border-color: #d93025
|
||||
box-shadow: 0 0 0 1px #d93025
|
||||
box-shadow: 0 0 0 4px rgba(217, 48, 37, 0.1)
|
||||
&:focus-within
|
||||
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
|
||||
left: .55rem
|
||||
left: 1rem
|
||||
top: 50%
|
||||
transform: translateY(-50%)
|
||||
display: inline-flex
|
||||
font-size: 1rem
|
||||
font-size: 1.1rem
|
||||
color: #6b7781
|
||||
pointer-events: none
|
||||
|
||||
.field__input
|
||||
.field-input
|
||||
appearance: none
|
||||
outline: none
|
||||
background: transparent
|
||||
border: 0
|
||||
color: #1f2429
|
||||
font: inherit
|
||||
font-size: 1rem
|
||||
font-weight: 500
|
||||
width: 100%
|
||||
line-height: 1.2
|
||||
line-height: 1.3
|
||||
&::placeholder
|
||||
color: #a0abb4
|
||||
font-weight: 400
|
||||
&:focus
|
||||
outline: none
|
||||
|
||||
.field__error
|
||||
.field-error
|
||||
font-size: .65rem
|
||||
font-weight: 600
|
||||
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 { 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" /> },
|
||||
];
|
||||
import React from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import Sidebar from '@/common/components/Sidebar';
|
||||
import ProfileMenu from '@/common/components/ProfileMenu';
|
||||
|
||||
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>
|
||||
<Sidebar />
|
||||
<div className="main">
|
||||
<header className="topbar">
|
||||
<h3>Test</h3>
|
||||
<h3>Dashboard</h3>
|
||||
<div className="grow"></div>
|
||||
<ProfileMenu />
|
||||
</header>
|
||||
<Outlet />
|
||||
</div>
|
||||
|
@@ -43,48 +43,6 @@ a
|
||||
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
|
||||
@@ -109,49 +67,3 @@ a
|
||||
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
|
@@ -13,6 +13,7 @@ export const Login = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [mode, setMode] = useState(isSetupCompleted ? 'login' : 'setup');
|
||||
const [isExiting, setIsExiting] = useState(false);
|
||||
|
||||
React.useEffect(()=>{
|
||||
setMode(isSetupCompleted ? 'login' : 'setup');
|
||||
@@ -25,28 +26,48 @@ export const Login = () => {
|
||||
try {
|
||||
if (mode === 'login') {
|
||||
const data = await request('/auth/login','POST',{username, password});
|
||||
updateSessionToken(data.token);
|
||||
// Trigger fade-out animation before updating token
|
||||
setIsExiting(true);
|
||||
setTimeout(() => {
|
||||
updateSessionToken(data.token);
|
||||
}, 300); // Match the fade-out animation duration
|
||||
} else { // setup
|
||||
await request('/setup/init','POST',{username, password});
|
||||
const data = await request('/auth/login','POST',{username, password});
|
||||
updateSessionToken(data.token);
|
||||
setIsExiting(true);
|
||||
setTimeout(() => {
|
||||
updateSessionToken(data.token);
|
||||
}, 300);
|
||||
}
|
||||
} 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>
|
||||
<div className={`auth-wrapper ${isExiting ? 'fade-out' : ''}`}>
|
||||
<div className={`auth-box ${isExiting ? 'fade-out' : ''}`}>
|
||||
<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}>
|
||||
<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>
|
||||
<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 />
|
||||
</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 />
|
||||
</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>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -1,11 +1,130 @@
|
||||
.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
|
||||
.auth-wrapper
|
||||
min-height: 100vh
|
||||
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
|
||||
opacity: 0
|
||||
transform: translateY(4px)
|
||||
backdrop-filter: blur(0px)
|
||||
to
|
||||
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