Compare commits

...

20 Commits

Author SHA1 Message Date
Mathias Wagner
8fe30668e0 Fix UserManagement.jsx page 2025-09-09 13:46:17 +02:00
Mathias Wagner
804b3e577d Update App to integrate ToastProvider & UserManagement 2025-09-09 13:44:14 +02:00
Mathias Wagner
42a036a84c Create PageHeader component 2025-09-09 13:43:59 +02:00
Mathias Wagner
17bc9d3f0c Create DetailItem component 2025-09-09 13:43:16 +02:00
Mathias Wagner
a5f3ed1634 Create Avatar component 2025-09-09 13:43:07 +02:00
Mathias Wagner
29b32ec317 Craete UserManagement page 2025-09-09 13:42:49 +02:00
Mathias Wagner
5908ee0f99 Add ModalActions to Modal component 2025-09-09 13:42:09 +02:00
Mathias Wagner
54be320dc1 Add muted to main.sass 2025-09-09 13:33:50 +02:00
Mathias Wagner
7ef4d8b8b2 Update page title in Root.jsx 2025-09-09 13:33:05 +02:00
Mathias Wagner
0ddfc36eb8 Create ToastContext.jsx 2025-09-09 13:32:48 +02:00
Mathias Wagner
0e82a40d66 Fix bug in UserContext.jsx 2025-09-09 13:32:24 +02:00
Mathias Wagner
19e0407dbd Create Toast component 2025-09-09 13:32:16 +02:00
Mathias Wagner
676a2ac869 Create Select component 2025-09-09 13:31:55 +02:00
Mathias Wagner
4d0722d282 Create Modal component 2025-09-09 13:31:45 +02:00
Mathias Wagner
8d97de06fd Add isIconOnly to Button.jsx 2025-09-09 13:31:19 +02:00
Mathias Wagner
da6fe42d30 Create LoadingSpinner component 2025-09-09 13:31:08 +02:00
Mathias Wagner
16f5162541 Create Grid component 2025-09-09 13:30:59 +02:00
Mathias Wagner
2f8b301a61 Create EmptyState component 2025-09-09 13:30:49 +02:00
Mathias Wagner
61418fb072 Create Card component 2025-09-09 13:30:38 +02:00
Mathias Wagner
d3d7a10351 Create Badge component 2025-09-09 13:30:27 +02:00
43 changed files with 1562 additions and 30 deletions

View File

@@ -1,31 +1,34 @@
import { createBrowserRouter, Navigate, RouterProvider } from "react-router-dom"; import {createBrowserRouter, Navigate, RouterProvider} from "react-router-dom";
import { UserProvider } from '@/common/contexts/UserContext.jsx'; import {UserProvider} from '@/common/contexts/UserContext.jsx';
import {ToastProvider} from '@/common/contexts/ToastContext.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 UserManagement from "@/pages/UserManagement";
import "@fontsource/plus-jakarta-sans/300.css"; import "@fontsource/plus-jakarta-sans/300.css";
import "@fontsource/plus-jakarta-sans/400.css"; import "@fontsource/plus-jakarta-sans/400.css";
import "@fontsource/plus-jakarta-sans/600.css"; import "@fontsource/plus-jakarta-sans/600.css";
import "@fontsource/plus-jakarta-sans/700.css"; import "@fontsource/plus-jakarta-sans/700.css";
import "@fontsource/plus-jakarta-sans/800.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 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="/dashboard" /> }, {path: "/", element: <Navigate to="/dashboard"/>},
{ 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" /> }, {path: "/admin/users", element: <UserManagement/>},
], ],
}, },
]); ]);
return <UserProvider><RouterProvider router={router}/></UserProvider>; return <UserProvider><ToastProvider><RouterProvider router={router}/></ToastProvider></UserProvider>;
}; };
export default App; export default App;

View File

@@ -0,0 +1,23 @@
import React from 'react';
import './styles.sass';
export const Avatar = ({
children,
size = 'md',
variant = 'default',
className = '',
...rest
}) => {
const avatarClasses = [
'avatar',
`avatar--${size}`,
`avatar--${variant}`,
className
].filter(Boolean).join(' ');
return (
<div className={avatarClasses} {...rest}>
{children}
</div>
);
};

View File

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

View File

@@ -0,0 +1,35 @@
.avatar
background: var(--bg-elev)
border: 1px solid var(--border)
border-radius: 50%
display: flex
align-items: center
justify-content: center
color: var(--text-dim)
flex-shrink: 0
&--sm
width: 32px
height: 32px
&--md
width: 48px
height: 48px
&--lg
width: 64px
height: 64px
&--xl
width: 80px
height: 80px
&--primary
background: var(--accent)
color: white
border-color: var(--accent)
&--success
background: #16a34a
color: white
border-color: #16a34a

View File

@@ -0,0 +1,23 @@
import React from 'react';
import './styles.sass';
export const Badge = ({
children,
variant = 'default',
size = 'md',
className = '',
...rest
}) => {
const badgeClasses = [
'badge',
`badge--${variant}`,
`badge--${size}`,
className
].filter(Boolean).join(' ');
return (
<span className={badgeClasses} {...rest}>
{children}
</span>
);
};

View File

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

View File

@@ -0,0 +1,49 @@
.badge
display: inline-flex
align-items: center
justify-content: center
border-radius: 12px
font-weight: 600
text-transform: uppercase
letter-spacing: 0.5px
white-space: nowrap
&--sm
padding: 0.125rem 0.5rem
font-size: 0.65rem
&--md
padding: 0.25rem 0.75rem
font-size: 0.75rem
&--lg
padding: 0.375rem 1rem
font-size: 0.85rem
&--default
background: var(--bg-elev)
color: var(--text-dim)
&--primary
background: rgba(15, 98, 254, 0.1)
color: #0f62fe
&--success
background: rgba(22, 163, 74, 0.1)
color: #16a34a
&--warning
background: rgba(245, 158, 11, 0.1)
color: #f59e0b
&--danger
background: rgba(217, 48, 37, 0.1)
color: #d93025
&--admin
background: #e3f2fd
color: #1976d2
&--user
background: var(--bg-elev)
color: var(--text-dim)

View File

@@ -16,6 +16,8 @@ export const Button = ({
...rest ...rest
}) => { }) => {
const isDisabled = disabled || loading; const isDisabled = disabled || loading;
const isIconOnly = (icon || iconRight) && !children;
return ( return (
<Component <Component
className={cn( className={cn(
@@ -24,6 +26,7 @@ export const Button = ({
`btn--${size}`, `btn--${size}`,
full && "btn--full", full && "btn--full",
loading && "is-loading", loading && "is-loading",
isIconOnly && "btn--icon-only",
className className
)} )}
disabled={isDisabled} disabled={isDisabled}
@@ -31,7 +34,7 @@ export const Button = ({
> >
{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> {children && <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>
); );

View File

@@ -69,6 +69,20 @@
--c-text: #ffffff --c-text: #ffffff
background: var(--c-bg) background: var(--c-bg)
border-color: var(--c-border) border-color: var(--c-border)
&.btn--icon-only
padding: 0.75rem
aspect-ratio: 1
justify-content: center
&.btn--sm
padding: 0.6rem
&.btn--lg
padding: 0.9rem
.btn-icon
margin: 0
.btn-icon .btn-icon
display: inline-flex display: inline-flex

View File

@@ -0,0 +1,43 @@
import React from 'react';
import './styles.sass';
export const Card = ({
children,
className = '',
hover = false,
padding = 'md',
variant = 'default',
...rest
}) => {
const cardClasses = [
'card',
`card--${variant}`,
`card--padding-${padding}`,
hover && 'card--hover',
className
].filter(Boolean).join(' ');
return (
<div className={cardClasses} {...rest}>
{children}
</div>
);
};
export const CardHeader = ({ children, className = '' }) => (
<div className={`card-header ${className}`}>
{children}
</div>
);
export const CardBody = ({ children, className = '' }) => (
<div className={`card-body ${className}`}>
{children}
</div>
);
export const CardFooter = ({ children, className = '' }) => (
<div className={`card-footer ${className}`}>
{children}
</div>
);

View File

@@ -0,0 +1 @@
export { Card as default, CardHeader, CardBody, CardFooter } from './Card.jsx';

View File

@@ -0,0 +1,43 @@
.card
background: var(--bg-alt)
border: 1px solid var(--border)
border-radius: var(--radius-lg)
transition: all 0.2s ease
&--hover:hover
border-color: var(--border-strong)
transform: translateY(-2px)
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1)
&--padding-none
padding: 0
&--padding-sm
padding: 1rem
&--padding-md
padding: 1.5rem
&--padding-lg
padding: 2rem
&--variant-elevated
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1)
&--variant-outlined
border-width: 2px
.card-header
margin-bottom: 1rem
&:last-child
margin-bottom: 0
.card-body
flex: 1
.card-footer
margin-top: 1rem
&:first-child
margin-top: 0

View File

@@ -0,0 +1,28 @@
import React from 'react';
import './styles.sass';
export const DetailItem = ({
icon,
children,
className = '',
...rest
}) => {
return (
<div className={`detail-item ${className}`} {...rest}>
{icon && <span className="detail-item-icon">{icon}</span>}
<span className="detail-item-content">{children}</span>
</div>
);
};
export const DetailList = ({
children,
className = '',
...rest
}) => {
return (
<div className={`detail-list ${className}`} {...rest}>
{children}
</div>
);
};

View File

@@ -0,0 +1 @@
export { DetailItem as default, DetailList } from './DetailItem.jsx';

View File

@@ -0,0 +1,23 @@
.detail-list
display: flex
flex-direction: column
gap: 0.75rem
.detail-item
display: flex
align-items: center
gap: 0.75rem
color: var(--text-dim)
font-size: 0.9rem
.detail-item-icon
color: var(--text-dim)
display: inline-flex
flex-shrink: 0
svg
color: inherit
.detail-item-content
flex: 1
min-width: 0

View File

@@ -0,0 +1,19 @@
import React from 'react';
import './styles.sass';
export const EmptyState = ({
icon,
title,
description,
action,
className = ''
}) => {
return (
<div className={`empty-state ${className}`}>
{icon && <div className="empty-state-icon">{icon}</div>}
{title && <h3 className="empty-state-title">{title}</h3>}
{description && <p className="empty-state-description">{description}</p>}
{action && <div className="empty-state-action">{action}</div>}
</div>
);
};

View File

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

View File

@@ -0,0 +1,32 @@
.empty-state
text-align: center
padding: 3rem 2rem
color: var(--text-dim)
display: flex
flex-direction: column
align-items: center
gap: 1rem
.empty-state-icon
color: var(--text-dim)
display: flex
justify-content: center
svg
width: 48px
height: 48px
.empty-state-title
font-size: 1.2rem
font-weight: 600
color: var(--text)
margin: 0
.empty-state-description
font-size: 0.95rem
margin: 0
max-width: 400px
line-height: 1.5
.empty-state-action
margin-top: 0.5rem

View File

@@ -0,0 +1,26 @@
import React from 'react';
import './styles.sass';
export const Grid = ({
children,
columns = 'auto-fill',
minWidth = '300px',
gap = '1.5rem',
className = '',
...rest
}) => {
const gridStyle = {
'--grid-columns': columns === 'auto-fill' ? `repeat(auto-fill, minmax(${minWidth}, 1fr))` : `repeat(${columns}, 1fr)`,
'--grid-gap': gap
};
return (
<div
className={`grid ${className}`}
style={gridStyle}
{...rest}
>
{children}
</div>
);
};

View File

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

View File

@@ -0,0 +1,8 @@
.grid
display: grid
grid-template-columns: var(--grid-columns)
gap: var(--grid-gap)
@media (max-width: 768px)
.grid
grid-template-columns: 1fr

View File

@@ -0,0 +1,22 @@
import React from 'react';
import './styles.sass';
export const LoadingSpinner = ({
size = 'md',
className = '',
text,
centered = false
}) => {
const containerClasses = [
'loading-container',
centered && 'loading-container--centered',
className
].filter(Boolean).join(' ');
return (
<div className={containerClasses}>
<div className={`loading-spinner loading-spinner--${size}`} />
{text && <p className="loading-text">{text}</p>}
</div>
);
};

View File

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

View File

@@ -0,0 +1,40 @@
.loading-container
display: flex
flex-direction: column
align-items: center
gap: 1rem
&--centered
justify-content: center
padding: 3rem 2rem
.loading-spinner
border-radius: 50%
border-style: solid
border-color: var(--bg-elev)
border-top-color: var(--accent)
animation: spin 1s linear infinite
&--sm
width: 16px
height: 16px
border-width: 2px
&--md
width: 32px
height: 32px
border-width: 3px
&--lg
width: 48px
height: 48px
border-width: 4px
.loading-text
color: var(--text-dim)
font-size: 0.95rem
margin: 0
@keyframes spin
to
transform: rotate(360deg)

View File

@@ -0,0 +1,82 @@
import React, { useEffect } from 'react';
import { XIcon } from '@phosphor-icons/react';
import Button from '@/common/components/Button';
import './styles.sass';
export const Modal = ({
isOpen,
onClose,
title,
children,
size = 'md',
closeOnOverlayClick = true,
showCloseButton = true,
className = ''
}) => {
useEffect(() => {
const handleEsc = (event) => {
if (event.keyCode === 27) {
onClose();
}
};
if (isOpen) {
document.addEventListener('keydown', handleEsc, false);
document.body.style.overflow = 'hidden';
}
return () => {
document.removeEventListener('keydown', handleEsc, false);
document.body.style.overflow = 'unset';
};
}, [isOpen, onClose]);
if (!isOpen) return null;
const handleOverlayClick = (e) => {
if (closeOnOverlayClick && e.target === e.currentTarget) {
onClose();
}
};
return (
<div className="modal-overlay" onClick={handleOverlayClick}>
<div className={`modal modal--${size} ${className}`} onClick={e => e.stopPropagation()}>
{(title || showCloseButton) && (
<div className="modal-header">
{title && <h2 className="modal-title">{title}</h2>}
{showCloseButton && (
<Button
variant="subtle"
size="sm"
icon={<XIcon size={16} />}
onClick={onClose}
className="modal-close"
/>
)}
</div>
)}
<div className="modal-content">
{children}
</div>
</div>
</div>
);
};
export const ModalActions = ({
children,
className = '',
align = 'right',
...rest
}) => {
const alignClass = align === 'left' ? 'modal-actions--left' :
align === 'center' ? 'modal-actions--center' :
'modal-actions--right';
return (
<div className={`modal-actions ${alignClass} ${className}`} {...rest}>
{children}
</div>
);
};

View File

@@ -0,0 +1 @@
export { Modal as default, ModalActions } from './Modal.jsx';

View File

@@ -0,0 +1,94 @@
.modal-overlay
position: fixed
top: 0
left: 0
right: 0
bottom: 0
background: rgba(0, 0, 0, 0.5)
display: flex
align-items: center
justify-content: center
z-index: 1000
padding: 2rem
backdrop-filter: blur(4px)
.modal
background: var(--bg-alt)
border-radius: var(--radius-lg)
border: 1px solid var(--border)
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2)
width: 100%
max-height: 90vh
overflow: hidden
display: flex
flex-direction: column
animation: modalIn 0.2s ease-out
&--sm
max-width: 400px
&--md
max-width: 500px
&--lg
max-width: 700px
&--xl
max-width: 900px
.modal-header
display: flex
align-items: center
justify-content: space-between
padding: 1.5rem 1.5rem 0
flex-shrink: 0
.modal-title
font-size: 1.3rem
font-weight: 600
color: var(--text)
margin: 0
.modal-close
margin-left: auto
.modal-content
padding: 1.5rem
overflow-y: auto
flex: 1
.modal-actions
display: flex
gap: 0.75rem
margin-top: 1.5rem
&--left
justify-content: flex-start
&--center
justify-content: center
&--right
justify-content: flex-end
@keyframes modalIn
from
opacity: 0
transform: scale(0.95) translateY(-10px)
to
opacity: 1
transform: scale(1) translateY(0)
@media (max-width: 768px)
.modal-overlay
padding: 1rem
.modal
max-width: none
margin: 0
.modal-header
padding: 1rem 1rem 0
.modal-content
padding: 1rem

View File

@@ -0,0 +1,20 @@
import React from 'react';
import './styles.sass';
export const PageHeader = ({
title,
subtitle,
actions,
className = '',
...rest
}) => {
return (
<div className={`page-header ${className}`} {...rest}>
<div className="page-header-content">
{title && <h1 className="page-title">{title}</h1>}
{subtitle && <p className="page-subtitle">{subtitle}</p>}
</div>
{actions && <div className="page-header-actions">{actions}</div>}
</div>
);
};

View File

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

View File

@@ -0,0 +1,37 @@
.page-header
display: flex
align-items: flex-start
justify-content: space-between
gap: 1rem
margin-bottom: 2rem
.page-header-content
flex: 1
min-width: 0
.page-title
font-size: 1.8rem
font-weight: 700
color: var(--text)
margin-bottom: 0.25rem
.page-subtitle
font-size: 0.9rem
color: var(--text-dim)
margin: 0
.page-header-actions
flex-shrink: 0
display: flex
gap: 0.75rem
align-items: flex-start
@media (max-width: 768px)
.page-header
flex-direction: column
align-items: stretch
text-align: center
gap: 1.5rem
.page-header-actions
justify-content: center

View File

@@ -0,0 +1,37 @@
import React from 'react';
import { CaretDownIcon } from '@phosphor-icons/react';
import './styles.sass';
export const Select = ({
label,
error,
options = [],
placeholder,
className,
containerClassName,
...rest
}) => {
return (
<div className={`field ${containerClassName || ''}`}>
{label && <label className="field-label">{label}</label>}
<div className={`field-control has-icon ${error ? 'has-error' : ''} ${className || ''}`}>
<select className="field-select" {...rest}>
{placeholder && (
<option value="" disabled>
{placeholder}
</option>
)}
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<span className="field-icon field-icon--right">
<CaretDownIcon size={16} />
</span>
</div>
{error && <div className="field-error">{error}</div>}
</div>
);
};

View File

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

View File

@@ -0,0 +1,30 @@
.field-select
appearance: none
background: transparent
border: 0
color: var(--text)
font: inherit
font-size: 1rem
font-weight: 500
width: 100%
line-height: 1.3
outline: none
cursor: pointer
padding-right: 2.5rem
&:focus
outline: none
&:disabled
opacity: 0.6
cursor: not-allowed
.field-control.has-icon .field-icon--right
position: absolute
right: 1rem
top: 50%
transform: translateY(-50%)
pointer-events: none
color: var(--text-dim)
display: inline-flex
z-index: 1

View File

@@ -0,0 +1,85 @@
import React, { useEffect, useState } from 'react';
import {
XIcon,
CheckCircleIcon,
XCircleIcon,
WarningIcon,
InfoIcon
} from '@phosphor-icons/react';
import Button from '@/common/components/Button';
import './styles.sass';
const TOAST_ICONS = {
success: <CheckCircleIcon size={20} weight="fill" />,
error: <XCircleIcon size={20} weight="fill" />,
warning: <WarningIcon size={20} weight="fill" />,
info: <InfoIcon size={20} weight="fill" />
};
export const Toast = ({
id,
type = 'info',
message,
title,
duration = 5000,
onClose,
actions
}) => {
const [isVisible, setIsVisible] = useState(false);
const [isRemoving, setIsRemoving] = useState(false);
useEffect(() => {
const timer = setTimeout(() => setIsVisible(true), 10);
return () => clearTimeout(timer);
}, []);
const handleClose = () => {
setIsRemoving(true);
setTimeout(() => {
onClose?.();
}, 150);
};
const toastClasses = [
'toast',
`toast--${type}`,
isVisible && 'toast--visible',
isRemoving && 'toast--removing'
].filter(Boolean).join(' ');
return (
<div className={toastClasses}>
<div className="toast-icon">
{TOAST_ICONS[type]}
</div>
<div className="toast-content">
{title && <div className="toast-title">{title}</div>}
<div className="toast-message">{message}</div>
{actions && (
<div className="toast-actions">
{actions.map((action, index) => (
<Button
key={index}
size="sm"
variant="subtle"
onClick={action.onClick}
>
{action.label}
</Button>
))}
</div>
)}
</div>
<Button
variant="subtle"
size="sm"
icon={<XIcon size={14} />}
onClick={handleClose}
className="toast-close"
/>
</div>
);
};

View File

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

View File

@@ -0,0 +1,92 @@
.toast-container
position: fixed
top: 1rem
right: 1rem
z-index: 2000
display: flex
flex-direction: column
gap: 0.75rem
max-width: 400px
pointer-events: none
.toast
background: var(--bg-alt)
border: 1px solid var(--border)
border-radius: var(--radius-lg)
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15)
padding: 1rem
display: flex
gap: 0.75rem
align-items: flex-start
min-width: 300px
pointer-events: auto
transform: translateX(100%)
opacity: 0
transition: all 0.15s ease-out
&--visible
transform: translateX(0)
opacity: 1
&--removing
transform: translateX(100%)
opacity: 0
&--success
border-left: 4px solid #16a34a
.toast-icon
color: #16a34a
&--error
border-left: 4px solid #d93025
.toast-icon
color: #d93025
&--warning
border-left: 4px solid #f59e0b
.toast-icon
color: #f59e0b
&--info
border-left: 4px solid #0f62fe
.toast-icon
color: #0f62fe
.toast-icon
flex-shrink: 0
margin-top: 0.125rem
.toast-content
flex: 1
min-width: 0
.toast-title
font-weight: 600
color: var(--text)
margin-bottom: 0.25rem
font-size: 0.9rem
.toast-message
color: var(--text-dim)
font-size: 0.85rem
line-height: 1.4
word-wrap: break-word
.toast-actions
display: flex
gap: 0.5rem
margin-top: 0.75rem
.toast-close
flex-shrink: 0
margin-left: 0.5rem
@media (max-width: 768px)
.toast-container
left: 1rem
right: 1rem
top: 1rem
max-width: none
.toast
min-width: auto

View File

@@ -0,0 +1,97 @@
import React, { createContext, useContext, useState, useCallback } from 'react';
import Toast from '@/common/components/Toast';
const ToastContext = createContext();
export const useToast = () => {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within a ToastProvider');
}
return context;
};
export const ToastProvider = ({ children }) => {
const [toasts, setToasts] = useState([]);
const addToast = useCallback((toast) => {
const id = Date.now() + Math.random();
const newToast = {
id,
duration: 5000,
...toast
};
setToasts(prev => [...prev, newToast]);
if (newToast.duration > 0) {
setTimeout(() => {
removeToast(id);
}, newToast.duration);
}
return id;
}, []);
const removeToast = useCallback((id) => {
setToasts(prev => prev.filter(toast => toast.id !== id));
}, []);
const success = useCallback((message, options = {}) => {
return addToast({
type: 'success',
message,
...options
});
}, [addToast]);
const error = useCallback((message, options = {}) => {
return addToast({
type: 'error',
message,
duration: 7000,
...options
});
}, [addToast]);
const warning = useCallback((message, options = {}) => {
return addToast({
type: 'warning',
message,
...options
});
}, [addToast]);
const info = useCallback((message, options = {}) => {
return addToast({
type: 'info',
message,
...options
});
}, [addToast]);
const value = {
toasts,
addToast,
removeToast,
success,
error,
warning,
info
};
return (
<ToastContext.Provider value={value}>
{children}
<div className="toast-container">
{toasts.map(toast => (
<Toast
key={toast.id}
{...toast}
onClose={() => removeToast(toast.id)}
/>
))}
</div>
</ToastContext.Provider>
);
};

View File

@@ -7,8 +7,9 @@ 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 [isSetupCompleted, setIsSetupCompleted] = useState(false); const [isSetupCompleted, setIsSetupCompleted] = useState(null); // null = unknown, true/false = known
const [user, setUser] = useState(null); const [user, setUser] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const updateSessionToken = (sessionToken) => { const updateSessionToken = (sessionToken) => {
setSessionToken(sessionToken); setSessionToken(sessionToken);
@@ -20,8 +21,11 @@ export const UserProvider = ({ children }) => {
try { try {
const response = await getRequest("setup/status"); const response = await getRequest("setup/status");
setIsSetupCompleted(response?.first_user_exists); setIsSetupCompleted(response?.first_user_exists);
return response?.first_user_exists;
} catch (error) { } catch (error) {
console.error(error); console.error(error);
setIsSetupCompleted(false); // Default to setup mode if we can't check
return false;
} }
}; };
@@ -29,11 +33,20 @@ export const UserProvider = ({ children }) => {
try { try {
const userObj = await getRequest("accounts/me"); const userObj = await getRequest("accounts/me");
setUser(userObj); setUser(userObj);
// If login is successful, setup must be completed
if (isSetupCompleted === null) {
setIsSetupCompleted(true);
}
} catch (error) { } catch (error) {
if (error.message === "Unauthorized") { if (error.message === "Unauthorized") {
setSessionToken(null); setSessionToken(null);
localStorage.removeItem("sessionToken"); localStorage.removeItem("sessionToken");
setUser(null);
// Check setup status when unauthorized
await checkFirstTimeSetup();
} }
} finally {
setIsLoading(false);
} }
}; };
@@ -43,7 +56,16 @@ export const UserProvider = ({ children }) => {
} catch (e) { } catch (e) {
// ignore // ignore
} }
window.location.reload();
// Clear user state
setUser(null);
setSessionToken(null);
localStorage.removeItem("sessionToken");
// Re-check setup status after logout
setIsLoading(true);
await checkFirstTimeSetup();
setIsLoading(false);
}; };
useEffect(() => { useEffect(() => {
@@ -59,8 +81,44 @@ export const UserProvider = ({ children }) => {
}, [location]); }, [location]);
useEffect(() => { useEffect(() => {
sessionToken ? login() : checkFirstTimeSetup(); const initializeAuth = async () => {
}, []); setIsLoading(true);
if (sessionToken) {
// Try to login with existing token
await login();
} else {
// No token, check setup status
await checkFirstTimeSetup();
setIsLoading(false);
}
};
initializeAuth();
}, []); // Only run once on mount
// Handle session token changes
useEffect(() => {
if (sessionToken && user === null && !isLoading) {
login();
}
}, [sessionToken]);
// Show loading state while determining auth status
if (isLoading || isSetupCompleted === null) {
return (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
background: 'var(--bg)',
color: 'var(--text-dim)'
}}>
Loading...
</div>
);
}
return ( return (
<UserContext.Provider value={{ updateSessionToken, user, sessionToken, isSetupCompleted, login, logout }}> <UserContext.Provider value={{ updateSessionToken, user, sessionToken, isSetupCompleted, login, logout }}>

View File

@@ -1,22 +1,40 @@
import React from 'react'; import React from 'react';
import { Outlet } from 'react-router-dom'; import {Outlet, useLocation} from 'react-router-dom';
import Sidebar from '@/common/components/Sidebar'; import Sidebar from '@/common/components/Sidebar';
import ProfileMenu from '@/common/components/ProfileMenu'; import ProfileMenu from '@/common/components/ProfileMenu';
const getPageTitle = (pathname) => {
switch (pathname) {
case '/dashboard':
return 'Dashboard';
case '/servers':
return 'Servers';
case '/settings':
return 'Settings';
case '/admin/users':
return 'User Management';
default:
return 'Dashboard';
}
};
const Root = () => { const Root = () => {
return ( const location = useLocation();
<div className="layout"> const pageTitle = getPageTitle(location.pathname);
<Sidebar />
<div className="main"> return (
<header className="topbar"> <div className="layout">
<h3>Dashboard</h3> <Sidebar/>
<div className="grow"></div> <div className="main">
<ProfileMenu /> <header className="topbar">
</header> <h3>{pageTitle}</h3>
<Outlet /> <div className="grow"></div>
</div> <ProfileMenu/>
</div> </header>
); <Outlet/>
</div>
</div>
);
}; };
export default Root; export default Root;

View File

@@ -66,4 +66,7 @@ a
padding: 1.4rem 1.3rem 2rem padding: 1.4rem 1.3rem 2rem
display: flex display: flex
flex-direction: column flex-direction: column
gap: 1.2rem gap: 1.2rem
.muted
color: var(--text-dim)

View File

@@ -0,0 +1,385 @@
import React, {useState, useEffect, useContext} from 'react';
import {UserContext} from '@/common/contexts/UserContext.jsx';
import {useToast} from '@/common/contexts/ToastContext.jsx';
import {getRequest, postRequest, putRequest, deleteRequest} from '@/common/utils/RequestUtil.js';
import Button from '@/common/components/Button';
import Input from '@/common/components/Input';
import Select from '@/common/components/Select';
import Modal, {ModalActions} from '@/common/components/Modal';
import Card, {CardHeader, CardBody} from '@/common/components/Card';
import Badge from '@/common/components/Badge';
import Grid from '@/common/components/Grid';
import LoadingSpinner from '@/common/components/LoadingSpinner';
import EmptyState from '@/common/components/EmptyState';
import PageHeader from '@/common/components/PageHeader';
import Avatar from '@/common/components/Avatar';
import DetailItem, {DetailList} from '@/common/components/DetailItem';
import {
PlusIcon,
PencilIcon,
TrashIcon,
UserIcon,
ShieldCheckIcon,
CalendarIcon,
HardDriveIcon
} from '@phosphor-icons/react';
import './styles.sass';
const UserManagement = () => {
const {user: currentUser} = useContext(UserContext);
const toast = useToast();
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [editingUser, setEditingUser] = useState(null);
const [formData, setFormData] = useState({
username: '',
password: '',
role: 'user',
storage_limit_gb: 10
});
const [formErrors, setFormErrors] = useState({});
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
fetchUsers();
}, []);
const fetchUsers = async () => {
try {
setLoading(true);
const response = await getRequest('admin/users');
setUsers(response);
} catch (error) {
console.error('Failed to fetch users:', error);
toast.error('Failed to load users. Please try again.');
} finally {
setLoading(false);
}
};
const formatDate = (dateString) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
const openCreateModal = () => {
setEditingUser(null);
setFormData({
username: '',
password: '',
role: 'user',
storage_limit_gb: 10
});
setFormErrors({});
setShowModal(true);
};
const openEditModal = (user) => {
setEditingUser(user);
setFormData({
username: user.username,
password: '',
role: user.role,
storage_limit_gb: user.storage_limit_gb
});
setFormErrors({});
setShowModal(true);
};
const closeModal = () => {
setShowModal(false);
setEditingUser(null);
setFormData({
username: '',
password: '',
role: 'user',
storage_limit_gb: 10
});
setFormErrors({});
};
const validateForm = () => {
const errors = {};
if (!formData.username.trim()) {
errors.username = 'Username is required';
} else if (formData.username.length < 3) {
errors.username = 'Username must be at least 3 characters';
}
if (!editingUser && !formData.password) {
errors.password = 'Password is required';
} else if (formData.password && formData.password.length < 6) {
errors.password = 'Password must be at least 6 characters';
}
if (formData.storage_limit_gb < 0) {
errors.storage_limit_gb = 'Storage limit cannot be negative';
}
setFormErrors(errors);
return Object.keys(errors).length === 0;
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!validateForm()) {
return;
}
setSubmitting(true);
try {
if (editingUser) {
const updateData = {
username: formData.username,
role: formData.role,
storage_limit_gb: parseInt(formData.storage_limit_gb)
};
if (formData.password) {
updateData.password = formData.password;
}
await putRequest(`admin/users/${editingUser.id}`, updateData);
toast.success(`User "${formData.username}" updated successfully`);
} else {
await postRequest('admin/users', {
username: formData.username,
password: formData.password,
role: formData.role,
storage_limit_gb: parseInt(formData.storage_limit_gb)
});
toast.success(`User "${formData.username}" created successfully`);
}
closeModal();
fetchUsers();
} catch (error) {
console.error('Failed to save user:', error);
const errorMessage = error.error || 'Failed to save user. Please try again.';
toast.error(errorMessage);
if (error.error) {
setFormErrors({general: error.error});
}
} finally {
setSubmitting(false);
}
};
const handleDelete = async (userId, username) => {
if (userId === currentUser.id) {
toast.warning('You cannot delete your own account');
return;
}
if (!window.confirm(`Are you sure you want to delete user "${username}"? This action cannot be undone.`)) {
return;
}
try {
await deleteRequest(`admin/users/${userId}`);
toast.success(`User "${username}" deleted successfully`);
fetchUsers();
} catch (error) {
console.error('Failed to delete user:', error);
toast.error('Failed to delete user. Please try again.');
}
};
const handleInputChange = (e) => {
const {name, value} = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
if (formErrors[name]) {
setFormErrors(prev => ({
...prev,
[name]: ''
}));
}
};
if (loading) {
return (
<div className="content">
<LoadingSpinner centered text="Loading users..."/>
</div>
);
}
return (
<div className="content">
<PageHeader
title="User Management"
subtitle="Manage system users and their permissions"
actions={
<Button
variant="primary"
icon={<PlusIcon size={16}/>}
onClick={openCreateModal}
>
Add User
</Button>
}
/>
<Grid minWidth="400px">
{users.map(user => (
<Card key={user.id} hover>
<CardHeader>
<div className="user-card-header">
<Avatar>
<UserIcon size={24} weight="duotone"/>
</Avatar>
<div className="user-info">
<h3 className="user-name">{user.username}</h3>
<div className="user-role">
<ShieldCheckIcon size={14}/>
<Badge variant={user.role}>
{user.role}
</Badge>
</div>
</div>
<div className="user-actions">
<Button
variant="subtle"
size="sm"
icon={<PencilIcon size={14}/>}
onClick={() => openEditModal(user)}
>
Edit
</Button>
{user.id !== currentUser.id && (
<Button
variant="danger"
size="sm"
icon={<TrashIcon size={14}/>}
onClick={() => handleDelete(user.id, user.username)}
>
Delete
</Button>
)}
</div>
</div>
</CardHeader>
<CardBody>
<DetailList>
<DetailItem icon={<HardDriveIcon size={16}/>}>
Storage Limit: {user.storage_limit_gb}GB
</DetailItem>
<DetailItem icon={<CalendarIcon size={16}/>}>
Created: {formatDate(user.created_at)}
</DetailItem>
</DetailList>
</CardBody>
</Card>
))}
</Grid>
{users.length === 0 && (
<EmptyState
icon={<UserIcon size={48} weight="duotone"/>}
title="No users found"
description="Get started by creating your first user"
action={
<Button
variant="primary"
icon={<PlusIcon size={16}/>}
onClick={openCreateModal}
>
Add User
</Button>
}
/>
)}
<Modal
isOpen={showModal}
onClose={closeModal}
title={editingUser ? 'Edit User' : 'Create New User'}
size="md"
>
<form onSubmit={handleSubmit} className="modal-form">
{formErrors.general && (
<div className="form-error">{formErrors.general}</div>
)} <Input
label="Username"
name="username"
value={formData.username}
onChange={handleInputChange}
error={formErrors.username}
placeholder="Enter username"
disabled={submitting}
/>
<Input
label={editingUser ? "New Password (leave empty to keep current)" : "Password"}
name="password"
type="password"
value={formData.password}
onChange={handleInputChange}
error={formErrors.password}
placeholder={editingUser ? "Leave empty to keep current password" : "Enter password"}
disabled={submitting}
/>
<Select
label="Role"
name="role"
value={formData.role}
onChange={handleInputChange}
options={[
{value: 'user', label: 'User'},
{value: 'admin', label: 'Admin'}
]}
disabled={submitting}
/>
<Input
label="Storage Limit (GB)"
name="storage_limit_gb"
type="number"
value={formData.storage_limit_gb}
onChange={handleInputChange}
error={formErrors.storage_limit_gb}
placeholder="10"
min="0"
disabled={submitting}
/>
<ModalActions>
<Button
type="button"
variant="subtle"
onClick={closeModal}
disabled={submitting}
>
Cancel
</Button>
<Button
type="submit"
variant="primary"
loading={submitting}
disabled={submitting}
>
{editingUser ? 'Update User' : 'Create User'}
</Button>
</ModalActions>
</form>
</Modal>
</div>
);
};
export default UserManagement;

View File

@@ -0,0 +1 @@
export { default } from './UserManagement.jsx';

View File

@@ -0,0 +1,48 @@
.user-card-header
display: flex
align-items: flex-start
gap: 1rem
.user-info
flex: 1
min-width: 0
.user-name
font-size: 1.1rem
font-weight: 600
color: var(--text)
margin-bottom: 0.5rem
.user-role
display: flex
align-items: center
gap: 0.5rem
color: var(--text-dim)
font-size: 0.85rem
.user-actions
display: flex
gap: 0.5rem
flex-shrink: 0
.modal-form
display: flex
flex-direction: column
gap: 1.25rem
.form-error
background: #fdeaea
border: 1px solid #f5c6cb
color: #721c24
padding: 0.75rem 1rem
border-radius: var(--radius)
font-size: 0.9rem
font-weight: 500
@media (max-width: 768px)
.user-card-header
flex-direction: column
text-align: center
.user-actions
justify-content: center