diff --git a/webui/index.html b/webui/index.html index 93946fc..d1fb86e 100644 --- a/webui/index.html +++ b/webui/index.html @@ -4,9 +4,6 @@ Arkendro - - -
diff --git a/webui/src/App.jsx b/webui/src/App.jsx index 60bb13f..3062aed 100644 --- a/webui/src/App.jsx +++ b/webui/src/App.jsx @@ -20,6 +20,7 @@ const App = () => { { path: "/dashboard", element: }, { path: "/servers", element: }, { path: "/settings", element: }, + { path: "/admin/users", element: }, ], }, ]); diff --git a/webui/src/common/components/Button/Button.jsx b/webui/src/common/components/Button/Button.jsx index bf7a0e5..03be7e3 100644 --- a/webui/src/common/components/Button/Button.jsx +++ b/webui/src/common/components/Button/Button.jsx @@ -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 && } - {icon && {icon}} - {children} - {iconRight && {iconRight}} + {loading && } + {icon && {icon}} + {children} + {iconRight && {iconRight}} ); -}; - -export default Button; +}; \ No newline at end of file diff --git a/webui/src/common/components/Button/styles.sass b/webui/src/common/components/Button/styles.sass index e99ea7c..e7bf5c6 100644 --- a/webui/src/common/components/Button/styles.sass +++ b/webui/src/common/components/Button/styles.sass @@ -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) diff --git a/webui/src/common/components/Input/Input.jsx b/webui/src/common/components/Input/Input.jsx index 0e2ef30..548bcfd 100644 --- a/webui/src/common/components/Input/Input.jsx +++ b/webui/src/common/components/Input/Input.jsx @@ -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 (
- {label && } -
- {icon && {icon}} - + {label && } +
+ {icon && {icon}} +
- {error &&
{error}
} + {error &&
{error}
}
); -}; - -export default Input; +}; \ No newline at end of file diff --git a/webui/src/common/components/Input/styles.sass b/webui/src/common/components/Input/styles.sass index e446ebe..aaab3dd 100644 --- a/webui/src/common/components/Input/styles.sass +++ b/webui/src/common/components/Input/styles.sass @@ -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 diff --git a/webui/src/common/components/ProfileMenu/ProfileMenu.jsx b/webui/src/common/components/ProfileMenu/ProfileMenu.jsx new file mode 100644 index 0000000..71b94d4 --- /dev/null +++ b/webui/src/common/components/ProfileMenu/ProfileMenu.jsx @@ -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 ( +
+ + + {isOpen && ( +
+
+
+ +
+
+
Admin User
+
Administrator
+
+
+ +
+ +
+ +
+
+ )} +
+ ); +}; \ No newline at end of file diff --git a/webui/src/common/components/ProfileMenu/index.js b/webui/src/common/components/ProfileMenu/index.js new file mode 100644 index 0000000..f5ca9f3 --- /dev/null +++ b/webui/src/common/components/ProfileMenu/index.js @@ -0,0 +1 @@ +export { ProfileMenu as default } from './ProfileMenu'; \ No newline at end of file diff --git a/webui/src/common/components/ProfileMenu/styles.sass b/webui/src/common/components/ProfileMenu/styles.sass new file mode 100644 index 0000000..a6f6869 --- /dev/null +++ b/webui/src/common/components/ProfileMenu/styles.sass @@ -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) \ No newline at end of file diff --git a/webui/src/common/components/Sidebar/Sidebar.jsx b/webui/src/common/components/Sidebar/Sidebar.jsx new file mode 100644 index 0000000..3f77d81 --- /dev/null +++ b/webui/src/common/components/Sidebar/Sidebar.jsx @@ -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: }, + { to: '/servers', label: 'Servers', icon: }, + { to: '/settings', label: 'Settings', icon: }, +]; + +const adminNavItems = [ + { to: '/admin/users', label: 'User Management', icon: }, +]; + +export const Sidebar = () => { + const { user } = useContext(UserContext); + const isAdmin = user?.role === 'admin' || user?.is_admin === true; + + return ( + + ); +}; \ No newline at end of file diff --git a/webui/src/common/components/Sidebar/index.js b/webui/src/common/components/Sidebar/index.js new file mode 100644 index 0000000..86aee2c --- /dev/null +++ b/webui/src/common/components/Sidebar/index.js @@ -0,0 +1 @@ +export { Sidebar as default } from './Sidebar'; \ No newline at end of file diff --git a/webui/src/common/components/Sidebar/styles.sass b/webui/src/common/components/Sidebar/styles.sass new file mode 100644 index 0000000..ea39d6c --- /dev/null +++ b/webui/src/common/components/Sidebar/styles.sass @@ -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 \ No newline at end of file diff --git a/webui/src/common/layouts/Root.jsx b/webui/src/common/layouts/Root.jsx index 60f068b..4f403bc 100644 --- a/webui/src/common/layouts/Root.jsx +++ b/webui/src/common/layouts/Root.jsx @@ -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: }, - { to: '/servers', label: 'Servers', icon: }, - { to: '/settings', label: 'Settings', icon: }, -]; +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 (
- +
-

Test

+

Dashboard

+
+
diff --git a/webui/src/common/styles/main.sass b/webui/src/common/styles/main.sass index 1fa8afe..3180aa7 100644 --- a/webui/src/common/styles/main.sass +++ b/webui/src/common/styles/main.sass @@ -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 @@ -108,50 +66,4 @@ a padding: 1.4rem 1.3rem 2rem display: flex flex-direction: column - gap: 1.2rem - - -/* Auth page */ -.auth-wrapper - min-height: 100vh - display: flex - align-items: center - justify-content: center - padding: 2rem 1rem - -.auth-box - width: 100% - max-width: 380px - background: var(--bg-alt) - border: 1px solid var(--border) - border-radius: var(--radius-lg) - padding: 1.8rem 1.6rem 1.7rem - display: flex - flex-direction: column - gap: 1.2rem - box-shadow: 0 4px 14px -4px rgba(31,36,41,.08), 0 2px 4px -1px rgba(31,36,41,.06) - -.auth-title - font-size: 1.1rem - font-weight: 600 - letter-spacing: .5px - -.form - display: flex - flex-direction: column - gap: .95rem - -.sep - height: 1px - background: var(--border) - margin: .25rem 0 - -.text-center - text-align: center - -.small - font-size: .65rem - color: var(--text-dim) - -.inline - display: inline \ No newline at end of file + gap: 1.2rem \ No newline at end of file diff --git a/webui/src/pages/Login/Login.jsx b/webui/src/pages/Login/Login.jsx index 542c2af..b9f4233 100644 --- a/webui/src/pages/Login/Login.jsx +++ b/webui/src/pages/Login/Login.jsx @@ -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 ( -
-
-
{mode === 'login' ? 'Sign in' : 'Initial Setup'}
+
+
+
+
{mode === 'login' ? 'Welcome Back' : 'Initial Setup'}
+
+ {mode === 'login' + ? 'Sign in to your account to continue' + : 'Create your admin account to get started' + } +
+
- setUsername(e.target.value)} icon={} autoFocus required /> - setPassword(e.target.value)} icon={} required /> - {error &&
{error}
} - +
+ setUsername(e.target.value)} icon={} autoFocus required /> +
+
+ setPassword(e.target.value)} icon={} required /> +
+ {error &&
{error}
} +
+ +
diff --git a/webui/src/pages/Login/styles.sass b/webui/src/pages/Login/styles.sass index 6f090d7..a66d538 100644 --- a/webui/src/pages/Login/styles.sass +++ b/webui/src/pages/Login/styles.sass @@ -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) \ No newline at end of file + 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) \ No newline at end of file