Refactor project structure and dependencies; rename package to azaion-ui, update version to 0.0.1, and remove unused files. Introduce new routing and authentication features in App component.

This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-03-25 03:10:15 +02:00
parent e407308284
commit 157a33096a
112 changed files with 6530 additions and 17843 deletions
-108
View File
@@ -1,108 +0,0 @@
import React, { useState, useEffect } from 'react';
import AdminLogin from './AdminLogin.tsx';
import AdminDashboard from './AdminDashboardNew.tsx';
import { ServerInfo } from './types';
const Admin: React.FC = () => {
const [isLoggedIn, setIsLoggedIn] = useState<boolean>(false);
const [isLoading, setIsLoading] = useState<boolean>(true);
useEffect(() => {
// Check if user is already logged in
const checkAuth = async (): Promise<void> => {
const token = localStorage.getItem('authToken');
if (!token) {
setIsLoading(false);
return;
}
try {
// Verify token is still valid
let API_BASE = 'https://api.azaion.com';
try {
const res = await fetch('/__server-info', { method: 'GET' });
if (res.ok) {
const info: ServerInfo = await res.json();
if (info && info.proxyEnabled) {
API_BASE = '/proxy';
}
}
} catch (_) {
// ignore; fall back to direct API_BASE
}
const res = await fetch(`${API_BASE}/currentuser`, {
method: 'GET',
headers: {
'Authorization': token.startsWith('Bearer ') ? token : `Bearer ${token}`
}
});
if (res.ok) {
const data = await res.json();
const roleVal = (data && (data.role !== undefined ? data.role : (data.data && data.data.role))) ?? null;
const toNum = (r: any): number => {
if (typeof r === 'number') return r;
if (typeof r === 'string') {
const m = r.match(/-?\d+/);
if (m) return Number(m[0]);
}
return Number(r);
};
const roleNum = toNum(roleVal);
if (roleNum === 40 || roleNum === 1000) {
setIsLoggedIn(true);
} else {
localStorage.removeItem('authToken');
localStorage.removeItem('loginResponse');
}
} else {
localStorage.removeItem('authToken');
localStorage.removeItem('loginResponse');
}
} catch (error) {
console.error('Auth check failed:', error);
localStorage.removeItem('authToken');
localStorage.removeItem('loginResponse');
}
setIsLoading(false);
};
checkAuth();
}, []);
const handleLoginSuccess = (): void => {
setIsLoggedIn(true);
};
const handleLogout = (): void => {
setIsLoggedIn(false);
};
if (isLoading) {
return (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
background: 'linear-gradient(180deg, #0b1022 0%, #0f172a 100%)',
color: '#e5e7eb'
}}>
<div>Loading...</div>
</div>
);
}
if (!isLoggedIn) {
return <AdminLogin onLoginSuccess={handleLoginSuccess} />;
}
return <AdminDashboard onLogout={handleLogout} />;
};
export default Admin;
-154
View File
@@ -1,154 +0,0 @@
import React, { useEffect, useCallback } from 'react';
import AdminHeader from './components/AdminHeader.tsx';
import AdminSidebar from './components/AdminSidebar.tsx';
import AdminContent from './components/AdminContent.tsx';
import useAdminOperations from './hooks/useAdminOperations.ts';
import { extractToken } from './utils/parsers.ts';
interface AdminDashboardProps {
onLogout: () => void;
}
const AdminDashboard: React.FC<AdminDashboardProps> = ({ onLogout }) => {
const {
currentOpKey,
outputTitle,
outputMeta,
output,
status,
users,
searchValue,
operations,
api,
setStatusMessage,
setOutputMeta,
updateOutputTitle,
handleUserSearch
} = useAdminOperations();
const ensureAuth = useCallback((): void => {
const AUTH_TOKEN = localStorage.getItem('authToken');
if (!AUTH_TOKEN) {
const last = localStorage.getItem('loginResponse');
if (last) {
try {
const data = JSON.parse(last);
const token = extractToken(data);
if (token) localStorage.setItem('authToken', token);
} catch {
// ignore parse errors
}
}
}
if (!localStorage.getItem('authToken')) onLogout();
}, [onLogout]);
const handleOpClick = (opKey: string): void => {
if (!operations[opKey].hasForm) {
operations[opKey].run();
} else {
// For operations with forms, show the form
operations[opKey].run();
}
};
const handleLogout = (): void => {
localStorage.removeItem('authToken');
localStorage.removeItem('loginResponse');
onLogout();
};
const handleUserUpdate = (): void => {
// Refresh the current operation if it's user-related
if (currentOpKey === 'list-users') {
operations['list-users'].run(searchValue);
} else if (currentOpKey === 'current-user') {
operations['current-user'].run();
}
};
useEffect(() => {
ensureAuth();
// Default: run list users
operations['list-users'].run();
// Expose functions for inline onclick handlers (for forms that still use HTML)
(window as any).adminDashboard = {
submitCreateUser: () => {
const emailEl = document.getElementById('createUserEmail') as HTMLInputElement;
const passwordEl = document.getElementById('createUserPassword') as HTMLInputElement;
const roleEl = document.getElementById('createUserRole') as HTMLSelectElement;
const email = emailEl?.value?.trim() || '';
const password = passwordEl?.value || '';
const role = roleEl?.value || '';
if (!email || !password || !role) {
setStatusMessage('Please fill in all fields');
return;
}
operations['create-user'].run({
email,
password,
role: parseInt(role, 10)
});
},
openUploadModal: () => {
// This will be handled by the AdminContent component
// We'll need to pass a callback to open the modal
console.log('Upload modal should open');
},
openClearFolderModal: () => {
// This will be handled by the AdminContent component
// We'll need to pass a callback to open the modal
console.log('Clear folder modal should open');
}
};
return () => {
delete (window as any).adminDashboard;
};
}, [ensureAuth, operations, setStatusMessage]);
return (
<>
<div style={{ minHeight: '100vh', background: 'linear-gradient(180deg, #0b1022 0%, #0f172a 100%)', color: '#e5e7eb' }}>
<AdminHeader onLogout={handleLogout} />
<main style={{
display: 'grid',
gridTemplateColumns: '180px 1fr',
gap: '16px',
padding: '16px',
minHeight: 'calc(100vh - 80px)'
}}>
<AdminSidebar
operations={operations}
currentOpKey={currentOpKey}
onOpClick={handleOpClick}
/>
<AdminContent
currentOpKey={currentOpKey}
outputTitle={outputTitle}
outputMeta={outputMeta}
output={output}
status={status}
users={users}
searchValue={searchValue}
onUserSearch={handleUserSearch}
api={api}
setStatusMessage={setStatusMessage}
setOutputMeta={setOutputMeta}
updateOutputTitle={updateOutputTitle}
onUserUpdate={handleUserUpdate}
operations={operations}
/>
</main>
</div>
</>
);
};
export default AdminDashboard;
-165
View File
@@ -1,165 +0,0 @@
import React, { useState } from 'react';
interface AdminLoginProps {
onLoginSuccess: () => void;
}
const AdminLogin: React.FC<AdminLoginProps> = ({ onLoginSuccess }) => {
const [email, setEmail] = useState<string>('');
const [password, setPassword] = useState<string>('');
const [isLoading, setIsLoading] = useState<boolean>(false);
const [error, setError] = useState<string>('');
const handleSubmit = async (e: React.FormEvent): Promise<void> => {
e.preventDefault();
setIsLoading(true);
setError('');
try {
let API_BASE = 'https://api.azaion.com';
try {
const res = await fetch('/__server-info', { method: 'GET' });
if (res.ok) {
const info = await res.json();
if (info && info.proxyEnabled) {
API_BASE = '/proxy';
}
}
} catch (_) {
// ignore; fall back to direct API_BASE
}
const response = await fetch(`${API_BASE}/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, password }),
});
if (response.ok) {
const data = await response.json();
localStorage.setItem('authToken', data.token || data.data?.token);
localStorage.setItem('loginResponse', JSON.stringify(data));
onLoginSuccess();
} else {
const errorData = await response.json();
setError(errorData.message || 'Login failed');
}
} catch (err) {
setError('Network error. Please try again.');
} finally {
setIsLoading(false);
}
};
return (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '100vh',
background: 'linear-gradient(180deg, #0b1022 0%, #0f172a 100%)',
color: '#e5e7eb'
}}>
<div style={{
background: '#1e293b',
padding: '2rem',
borderRadius: '8px',
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
width: '100%',
maxWidth: '400px'
}}>
<h2 style={{
textAlign: 'center',
marginBottom: '1.5rem',
color: '#f8fafc'
}}>
Admin Login
</h2>
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: '1rem' }}>
<label style={{
display: 'block',
marginBottom: '0.5rem',
color: '#cbd5e1'
}}>
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
style={{
width: '100%',
padding: '0.75rem',
border: '1px solid #475569',
borderRadius: '4px',
background: '#334155',
color: '#f8fafc',
fontSize: '1rem'
}}
/>
</div>
<div style={{ marginBottom: '1.5rem' }}>
<label style={{
display: 'block',
marginBottom: '0.5rem',
color: '#cbd5e1'
}}>
Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
style={{
width: '100%',
padding: '0.75rem',
border: '1px solid #475569',
borderRadius: '4px',
background: '#334155',
color: '#f8fafc',
fontSize: '1rem'
}}
/>
</div>
{error && (
<div style={{
color: '#ef4444',
marginBottom: '1rem',
textAlign: 'center'
}}>
{error}
</div>
)}
<button
type="submit"
disabled={isLoading}
style={{
width: '100%',
padding: '0.75rem',
background: isLoading ? '#64748b' : '#3b82f6',
color: '#ffffff',
border: 'none',
borderRadius: '4px',
fontSize: '1rem',
cursor: isLoading ? 'not-allowed' : 'pointer',
transition: 'background-color 0.2s'
}}
>
{isLoading ? 'Logging in...' : 'Login'}
</button>
</form>
</div>
</div>
);
};
export default AdminLogin;
@@ -1,360 +0,0 @@
import React, { useState, useEffect } from 'react';
import UsersList from './UsersList.tsx';
import HardwareCharts from './HardwareCharts.tsx';
import ListResources from './ListResources.tsx';
import CreateUserModal from './CreateUserModal.tsx';
import UploadFileModal from './UploadFileModal.tsx';
import ClearFolderModal from './ClearFolderModal.tsx';
import NotificationBadge from './NotificationBadge.tsx';
import { User, ApiFunction, CreateUserFormData } from '../types';
interface AdminContentProps {
currentOpKey: string;
outputTitle: string;
outputMeta: string;
output: string;
status: string;
users: User[] | null;
searchValue: string;
onUserSearch: (searchEmail: string) => void;
api: ApiFunction;
setStatusMessage: (message: string) => void;
setOutputMeta: (meta: string) => void;
updateOutputTitle: (opKey: string, extra?: string) => void;
onUserUpdate?: () => void;
operations: any;
}
const AdminContent: React.FC<AdminContentProps> = ({
currentOpKey,
outputTitle,
outputMeta,
output,
status,
users,
searchValue,
onUserSearch,
api,
setStatusMessage,
setOutputMeta,
updateOutputTitle,
onUserUpdate,
operations
}) => {
const [isCreateUserModalOpen, setIsCreateUserModalOpen] = useState(false);
const [isUploadFileModalOpen, setIsUploadFileModalOpen] = useState(false);
const [isClearFolderModalOpen, setIsClearFolderModalOpen] = useState(false);
const [localSearchValue, setLocalSearchValue] = useState(searchValue);
const [notification, setNotification] = useState<{
message: string;
type: 'success' | 'error';
isVisible: boolean;
}>({
message: '',
type: 'success',
isVisible: false
});
// Ref to store the current timeout
const timeoutRef = React.useRef<NodeJS.Timeout | null>(null);
// Update local search value when prop changes
useEffect(() => {
setLocalSearchValue(searchValue);
}, [searchValue]);
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
// Handle input change with throttling
const handleSearchChange = (value: string) => {
setLocalSearchValue(value);
// Clear existing timeout
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
// Set new timeout
timeoutRef.current = setTimeout(() => {
onUserSearch(value);
}, 700);
};
// Handle Enter key press for immediate search
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
// Clear any pending timeout
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
onUserSearch(localSearchValue);
}
};
const handleCreateUser = async (formData: CreateUserFormData) => {
// This function is called after successful user creation
// Refresh the user list to show the new user
console.log('Refreshing user list after creation');
onUserSearch(searchValue);
// Show success notification
setNotification({
message: `User "${formData.email}" has been created successfully!`,
type: 'success',
isVisible: true
});
};
const handleCreateUserError = (error: string) => {
// Show error notification
setNotification({
message: error,
type: 'error',
isVisible: true
});
};
const closeNotification = () => {
setNotification(prev => ({ ...prev, isVisible: false }));
};
// Handle operation-specific modals
useEffect(() => {
if (currentOpKey === 'upload-file') {
setIsUploadFileModalOpen(true);
} else if (currentOpKey === 'clear-folder') {
setIsClearFolderModalOpen(true);
}
}, [currentOpKey]);
const handleUploadFileSuccess = (message: string) => {
setNotification({
message,
type: 'success',
isVisible: true
});
};
const handleUploadFileError = (error: string) => {
setNotification({
message: error,
type: 'error',
isVisible: true
});
};
const handleClearFolderSuccess = (message: string) => {
setNotification({
message,
type: 'success',
isVisible: true
});
};
const handleClearFolderError = (error: string) => {
setNotification({
message: error,
type: 'error',
isVisible: true
});
};
return (
<section style={{
border: '1px solid #1f2937',
borderRadius: '12px',
background: 'rgba(17, 24, 39, 0.7)',
padding: '12px'
}}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '12px'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
<h3 style={{ margin: 0, fontSize: '20px' }}>{outputTitle}</h3>
{currentOpKey === 'list-users' && (
<input
type="email"
placeholder="🔍 Search by email..."
value={localSearchValue}
onChange={(e) => handleSearchChange(e.target.value)}
onKeyDown={handleKeyDown}
style={{
height: '40px',
padding: '0 16px',
borderRadius: '8px',
border: '2px solid #374151',
background: '#1f2937',
color: '#e5e7eb',
fontSize: '14px',
width: '500px',
outline: 'none',
transition: 'all 0.2s ease',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
}}
onFocus={(e) => {
e.target.style.border = '2px solid #6366f1';
e.target.style.boxShadow = '0 0 0 3px rgba(99, 102, 241, 0.1)';
}}
onBlur={(e) => {
e.target.style.border = '2px solid #374151';
e.target.style.boxShadow = '0 2px 4px rgba(0,0,0,0.1)';
}}
/>
)}
</div>
{currentOpKey === 'list-users' && (
<button
onClick={() => setIsCreateUserModalOpen(true)}
style={{
height: '40px',
padding: '0 20px',
borderRadius: '8px',
border: '2px solid #059669',
background: 'linear-gradient(135deg, #10b981, #059669)',
color: '#ffffff',
fontSize: '14px',
fontWeight: '600',
cursor: 'pointer',
transition: 'all 0.2s ease',
boxShadow: '0 2px 4px rgba(16, 185, 129, 0.3)',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}
onMouseEnter={(e) => {
(e.target as HTMLElement).style.transform = 'translateY(-1px)';
(e.target as HTMLElement).style.boxShadow = '0 4px 8px rgba(16, 185, 129, 0.4)';
}}
onMouseLeave={(e) => {
(e.target as HTMLElement).style.transform = 'translateY(0)';
(e.target as HTMLElement).style.boxShadow = '0 2px 4px rgba(16, 185, 129, 0.3)';
}}
>
<span style={{ fontSize: '16px' }}>+</span>
Create User
</button>
)}
</div>
<div style={{ color: '#94a3b8', fontSize: '14px' }}>{outputMeta}</div>
<div
className={`human-output ${currentOpKey === 'list-users' || currentOpKey === 'current-user' ? 'users-grid' : ''}`}
style={{
display: 'grid',
gridTemplateColumns: currentOpKey === 'list-users' || currentOpKey === 'current-user'
? 'repeat(auto-fit, minmax(360px, 1fr))'
: 'repeat(auto-fill, minmax(360px, 1fr))',
gap: '24px',
marginTop: '16px'
}}
>
{/* Render React components based on current operation */}
{currentOpKey === 'show-chart' && users && (
<HardwareCharts users={users} />
)}
{(currentOpKey === 'list-users' || currentOpKey === 'current-user') && (
<UsersList
users={users}
onSearch={onUserSearch}
searchValue={localSearchValue}
api={api}
setStatusMessage={setStatusMessage}
onUserUpdate={onUserUpdate}
/>
)}
{currentOpKey === 'list-resources' && (
<ListResources
api={api}
setStatusMessage={setStatusMessage}
setOutputMeta={setOutputMeta}
updateOutputTitle={updateOutputTitle}
/>
)}
{/* Fallback for HTML content */}
{output && (
<div dangerouslySetInnerHTML={{ __html: output }} />
)}
</div>
{status && (
<div style={{
color: '#94a3b8',
fontSize: '12px',
marginTop: '8px',
padding: '8px',
background: '#0b1223',
borderRadius: '4px'
}}>
Status: {status}
</div>
)}
{/* Create User Modal */}
<CreateUserModal
isOpen={isCreateUserModalOpen}
onClose={() => {
setIsCreateUserModalOpen(false);
// Reset form when closing
}}
onSubmit={handleCreateUser}
onError={handleCreateUserError}
api={api}
setStatusMessage={setStatusMessage}
/>
{/* Upload File Modal */}
<UploadFileModal
isOpen={isUploadFileModalOpen}
onClose={() => {
setIsUploadFileModalOpen(false);
// Switch back to list-users when closing
if (currentOpKey === 'upload-file') {
operations['list-users'].run();
}
}}
onSuccess={handleUploadFileSuccess}
onError={handleUploadFileError}
api={api}
setStatusMessage={setStatusMessage}
/>
{/* Clear Folder Modal */}
<ClearFolderModal
isOpen={isClearFolderModalOpen}
onClose={() => {
setIsClearFolderModalOpen(false);
// Switch back to list-users when closing
if (currentOpKey === 'clear-folder') {
operations['list-users'].run();
}
}}
onSuccess={handleClearFolderSuccess}
onError={handleClearFolderError}
api={api}
setStatusMessage={setStatusMessage}
/>
{/* Notification Badge */}
<NotificationBadge
message={notification.message}
type={notification.type}
isVisible={notification.isVisible}
onClose={closeNotification}
/>
</section>
);
};
export default AdminContent;
@@ -1,46 +0,0 @@
import React from 'react';
interface AdminHeaderProps {
onLogout: () => void;
}
const AdminHeader: React.FC<AdminHeaderProps> = ({ onLogout }) => {
return (
<header style={{
padding: '16px',
borderBottom: '1px solid #1f2937',
position: 'sticky' as const,
top: 0,
background: 'rgba(15, 23, 42, 0.85)',
backdropFilter: 'blur(6px)'
}}>
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '12px'
}}>
<h1 style={{ margin: '0', fontSize: '20px' }}>Azaion Admin</h1>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<button
onClick={onLogout}
style={{
height: '36px',
padding: '0 12px',
borderRadius: '8px',
border: '1px solid #1f2937',
background: '#111827',
color: '#e5e7eb',
cursor: 'pointer'
}}
>
Logout
</button>
</div>
</div>
</header>
);
};
export default AdminHeader;
@@ -1,61 +0,0 @@
import React from 'react';
import { OPERATIONS_CONFIG } from '../config/constants.ts';
import { Operation } from '../types';
interface AdminSidebarProps {
operations: Record<string, Operation>;
currentOpKey: string;
onOpClick: (key: string) => void;
}
const AdminSidebar: React.FC<AdminSidebarProps> = ({ operations, currentOpKey, onOpClick }) => {
return (
<aside style={{
border: '1px solid #1f2937',
borderRadius: '12px',
background: 'rgba(17, 24, 39, 0.7)',
overflow: 'hidden'
}}>
<div style={{ maxHeight: 'calc(100vh - 260px)', overflow: 'auto' }}>
{Object.entries(operations).map(([key, op]) => {
const config = OPERATIONS_CONFIG[key] || {};
return (
<div
key={key}
onClick={() => onOpClick(key)}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '10px 12px',
cursor: 'pointer',
background: currentOpKey === key ? 'rgba(99, 102, 241, 0.15)' : 'transparent',
borderLeft: currentOpKey === key ? '3px solid #6366f1' : '3px solid transparent',
borderBottom: 'none'
}}
onMouseEnter={(e) => {
if (currentOpKey !== key) {
(e.target as HTMLElement).style.background = 'rgba(99, 102, 241, 0.08)';
}
}}
onMouseLeave={(e) => {
(e.target as HTMLElement).style.background = currentOpKey === key ? 'rgba(99, 102, 241, 0.15)' : 'transparent';
}}
>
<div style={{ flex: 1 }}>
<div style={{ color: '#cbd5e1', fontSize: '14px', fontWeight: '500' }}>
{config.title || op.title}
</div>
<div style={{ color: '#94a3b8', fontSize: '13px' }}>
{config.description || ''}
</div>
</div>
</div>
);
})}
</div>
</aside>
);
};
export default AdminSidebar;
@@ -1,313 +0,0 @@
import React, { useState } from 'react';
import { ApiFunction } from '../types';
interface ClearFolderModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: (message: string) => void;
onError: (error: string) => void;
api: ApiFunction;
setStatusMessage: (message: string) => void;
}
const ClearFolderModal: React.FC<ClearFolderModalProps> = ({
isOpen,
onClose,
onSuccess,
onError,
api,
setStatusMessage
}) => {
const [folderPath, setFolderPath] = useState<string>('');
const [isClearing, setIsClearing] = useState(false);
const [confirmText, setConfirmText] = useState<string>('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!folderPath.trim()) {
setStatusMessage('Please enter a folder path');
return;
}
if (confirmText !== 'CLEAR') {
setStatusMessage('Please type "CLEAR" to confirm');
return;
}
setIsClearing(true);
setStatusMessage('Clearing folder...');
try {
const result = await api('/clear-folder', {
method: 'POST',
json: { folderPath: folderPath.trim() }
});
console.log('Clear folder result:', result);
setStatusMessage('Folder cleared successfully!');
onSuccess(`Folder "${folderPath}" has been cleared successfully!`);
handleClose();
} catch (e: any) {
console.error('Clear folder error:', e);
const errorMessage = `Failed to clear folder: ${e.message}`;
setStatusMessage(errorMessage);
onError(errorMessage);
} finally {
setIsClearing(false);
}
};
const handleClose = () => {
setFolderPath('');
setConfirmText('');
onClose();
};
if (!isOpen) return null;
return (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.8)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000
}}>
<div style={{
backgroundColor: '#1f2937',
border: '1px solid #374151',
borderRadius: '12px',
padding: '24px',
width: '90%',
maxWidth: '500px',
maxHeight: '90vh',
overflow: 'auto'
}}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '20px'
}}>
<h3 style={{
margin: 0,
fontSize: '20px',
fontWeight: '600',
color: '#ef4444'
}}>
Clear Folder
</h3>
<button
onClick={handleClose}
style={{
background: 'none',
border: 'none',
color: '#94a3b8',
fontSize: '24px',
cursor: 'pointer',
padding: '4px',
borderRadius: '4px',
transition: 'all 0.2s ease'
}}
onMouseEnter={(e) => {
(e.target as HTMLElement).style.color = '#e5e7eb';
(e.target as HTMLElement).style.backgroundColor = '#374151';
}}
onMouseLeave={(e) => {
(e.target as HTMLElement).style.color = '#94a3b8';
(e.target as HTMLElement).style.backgroundColor = 'transparent';
}}
>
×
</button>
</div>
{/* Warning Message */}
<div style={{
backgroundColor: '#7f1d1d',
border: '1px solid #ef4444',
borderRadius: '8px',
padding: '12px',
marginBottom: '20px',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
<span style={{ fontSize: '20px' }}></span>
<div>
<div style={{ color: '#ef4444', fontWeight: '600', fontSize: '14px' }}>
Warning: This action cannot be undone!
</div>
<div style={{ color: '#fca5a5', fontSize: '12px' }}>
All files in the specified folder will be permanently deleted.
</div>
</div>
</div>
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: '16px' }}>
<label style={{
display: 'block',
marginBottom: '6px',
fontSize: '14px',
fontWeight: '500',
color: '#e5e7eb'
}}>
Folder Path *
</label>
<input
type="text"
value={folderPath}
onChange={(e) => setFolderPath(e.target.value)}
placeholder="/uploads/documents"
required
style={{
width: '100%',
height: '40px',
padding: '0 12px',
borderRadius: '8px',
border: '2px solid #374151',
background: '#111827',
color: '#e5e7eb',
fontSize: '14px',
outline: 'none',
transition: 'all 0.2s ease',
boxSizing: 'border-box'
}}
onFocus={(e) => {
e.target.style.border = '2px solid #ef4444';
e.target.style.boxShadow = '0 0 0 3px rgba(239, 68, 68, 0.1)';
}}
onBlur={(e) => {
e.target.style.border = '2px solid #374151';
e.target.style.boxShadow = 'none';
}}
/>
</div>
<div style={{ marginBottom: '24px' }}>
<label style={{
display: 'block',
marginBottom: '6px',
fontSize: '14px',
fontWeight: '500',
color: '#e5e7eb'
}}>
Type "CLEAR" to confirm *
</label>
<input
type="text"
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder="CLEAR"
required
style={{
width: '100%',
height: '40px',
padding: '0 12px',
borderRadius: '8px',
border: '2px solid #374151',
background: '#111827',
color: '#e5e7eb',
fontSize: '14px',
outline: 'none',
transition: 'all 0.2s ease',
boxSizing: 'border-box'
}}
onFocus={(e) => {
e.target.style.border = '2px solid #ef4444';
e.target.style.boxShadow = '0 0 0 3px rgba(239, 68, 68, 0.1)';
}}
onBlur={(e) => {
e.target.style.border = '2px solid #374151';
e.target.style.boxShadow = 'none';
}}
/>
</div>
<div style={{
display: 'flex',
gap: '12px',
justifyContent: 'flex-end'
}}>
<button
type="button"
onClick={handleClose}
disabled={isClearing}
style={{
height: '40px',
padding: '0 20px',
borderRadius: '8px',
border: '2px solid #374151',
background: 'transparent',
color: '#94a3b8',
fontSize: '14px',
fontWeight: '500',
cursor: isClearing ? 'not-allowed' : 'pointer',
transition: 'all 0.2s ease',
opacity: isClearing ? 0.6 : 1
}}
onMouseEnter={(e) => {
if (!isClearing) {
(e.target as HTMLElement).style.border = '2px solid #6b7280';
(e.target as HTMLElement).style.color = '#e5e7eb';
}
}}
onMouseLeave={(e) => {
if (!isClearing) {
(e.target as HTMLElement).style.border = '2px solid #374151';
(e.target as HTMLElement).style.color = '#94a3b8';
}
}}
>
Cancel
</button>
<button
type="submit"
disabled={isClearing || !folderPath.trim() || confirmText !== 'CLEAR'}
style={{
height: '40px',
padding: '0 20px',
borderRadius: '8px',
border: '2px solid #dc2626',
background: isClearing ? '#4b5563' : 'linear-gradient(135deg, #ef4444, #dc2626)',
color: '#ffffff',
fontSize: '14px',
fontWeight: '600',
cursor: (isClearing || !folderPath.trim() || confirmText !== 'CLEAR') ? 'not-allowed' : 'pointer',
transition: 'all 0.2s ease',
opacity: (isClearing || !folderPath.trim() || confirmText !== 'CLEAR') ? 0.6 : 1
}}
onMouseEnter={(e) => {
if (!isClearing && folderPath.trim() && confirmText === 'CLEAR') {
(e.target as HTMLElement).style.transform = 'translateY(-1px)';
(e.target as HTMLElement).style.boxShadow = '0 4px 8px rgba(239, 68, 68, 0.4)';
}
}}
onMouseLeave={(e) => {
if (!isClearing && folderPath.trim() && confirmText === 'CLEAR') {
(e.target as HTMLElement).style.transform = 'translateY(0)';
(e.target as HTMLElement).style.boxShadow = 'none';
}
}}
>
{isClearing ? 'Clearing...' : 'Clear Folder'}
</button>
</div>
</form>
</div>
</div>
);
};
export default ClearFolderModal;
@@ -1,346 +0,0 @@
import React, { useState } from 'react';
import { ROLE_OPTIONS } from '../config/constants.ts';
import { CreateUserFormData, ApiFunction } from '../types';
interface CreateUserModalProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (formData: CreateUserFormData) => void;
onError: (error: string) => void;
api: ApiFunction;
setStatusMessage: (message: string) => void;
}
const CreateUserModal: React.FC<CreateUserModalProps> = ({
isOpen,
onClose,
onSubmit,
onError,
api,
setStatusMessage
}) => {
const [formData, setFormData] = useState<CreateUserFormData>({
email: '',
password: '',
role: 0 // Default to 0 (None role)
});
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.email || !formData.password) {
setStatusMessage('Please fill in all required fields');
return;
}
setIsSubmitting(true);
setStatusMessage('Creating user...');
try {
// Convert role to number before sending
const userData = {
...formData,
role: typeof formData.role === 'string' ? parseInt(formData.role, 10) : formData.role
};
// Make the API call directly
const result = await api('/users', { method: 'POST', json: userData });
console.log('User creation result:', result);
setStatusMessage('User created successfully!');
// Call the parent's onSubmit for any additional handling
await onSubmit(formData);
// Close modal immediately
handleClose();
} catch (e: any) {
console.error('User creation error:', e);
const errorMessage = `Failed to create user: ${e.message}`;
setStatusMessage(errorMessage);
onError(errorMessage);
} finally {
setIsSubmitting(false);
}
};
const handleClose = () => {
setFormData({ email: '', password: '', role: 0 });
onClose();
};
const handleInputChange = (field: keyof CreateUserFormData, value: string) => {
if (field === 'role') {
setFormData(prev => ({ ...prev, [field]: parseInt(value, 10) || 0 }));
} else {
setFormData(prev => ({ ...prev, [field]: value }));
}
};
if (!isOpen) return null;
return (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.8)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000
}}>
<div style={{
backgroundColor: '#1f2937',
border: '1px solid #374151',
borderRadius: '12px',
padding: '24px',
width: '90%',
maxWidth: '500px',
maxHeight: '90vh',
overflow: 'auto'
}}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '20px'
}}>
<h3 style={{
margin: 0,
fontSize: '20px',
fontWeight: '600',
color: '#e5e7eb'
}}>
Create New User
</h3>
<button
onClick={handleClose}
style={{
background: 'none',
border: 'none',
color: '#94a3b8',
fontSize: '24px',
cursor: 'pointer',
padding: '4px',
borderRadius: '4px',
transition: 'all 0.2s ease'
}}
onMouseEnter={(e) => {
(e.target as HTMLElement).style.color = '#e5e7eb';
(e.target as HTMLElement).style.backgroundColor = '#374151';
}}
onMouseLeave={(e) => {
(e.target as HTMLElement).style.color = '#94a3b8';
(e.target as HTMLElement).style.backgroundColor = 'transparent';
}}
>
×
</button>
</div>
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: '16px' }}>
<label style={{
display: 'block',
marginBottom: '6px',
fontSize: '14px',
fontWeight: '500',
color: '#e5e7eb'
}}>
Email Address *
</label>
<input
type="email"
value={formData.email}
onChange={(e) => handleInputChange('email', e.target.value)}
placeholder="user@azaion.com"
required
style={{
width: '100%',
height: '40px',
padding: '0 12px',
borderRadius: '8px',
border: '2px solid #374151',
background: '#111827',
color: '#e5e7eb',
fontSize: '14px',
outline: 'none',
transition: 'all 0.2s ease',
boxSizing: 'border-box'
}}
onFocus={(e) => {
e.target.style.border = '2px solid #6366f1';
e.target.style.boxShadow = '0 0 0 3px rgba(99, 102, 241, 0.1)';
}}
onBlur={(e) => {
e.target.style.border = '2px solid #374151';
e.target.style.boxShadow = 'none';
}}
/>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{
display: 'block',
marginBottom: '6px',
fontSize: '14px',
fontWeight: '500',
color: '#e5e7eb'
}}>
Password *
</label>
<input
type="password"
value={formData.password}
onChange={(e) => handleInputChange('password', e.target.value)}
placeholder="••••••••"
required
style={{
width: '100%',
height: '40px',
padding: '0 12px',
borderRadius: '8px',
border: '2px solid #374151',
background: '#111827',
color: '#e5e7eb',
fontSize: '14px',
outline: 'none',
transition: 'all 0.2s ease',
boxSizing: 'border-box'
}}
onFocus={(e) => {
e.target.style.border = '2px solid #6366f1';
e.target.style.boxShadow = '0 0 0 3px rgba(99, 102, 241, 0.1)';
}}
onBlur={(e) => {
e.target.style.border = '2px solid #374151';
e.target.style.boxShadow = 'none';
}}
/>
</div>
<div style={{ marginBottom: '24px' }}>
<label style={{
display: 'block',
marginBottom: '6px',
fontSize: '14px',
fontWeight: '500',
color: '#e5e7eb'
}}>
Role
</label>
<select
value={formData.role}
onChange={(e) => handleInputChange('role', e.target.value)}
style={{
width: '100%',
height: '40px',
padding: '0 12px',
borderRadius: '8px',
border: '2px solid #374151',
background: '#111827',
color: '#e5e7eb',
fontSize: '14px',
outline: 'none',
transition: 'all 0.2s ease',
boxSizing: 'border-box',
cursor: 'pointer'
}}
onFocus={(e) => {
e.target.style.border = '2px solid #6366f1';
e.target.style.boxShadow = '0 0 0 3px rgba(99, 102, 241, 0.1)';
}}
onBlur={(e) => {
e.target.style.border = '2px solid #374151';
e.target.style.boxShadow = 'none';
}}
>
{ROLE_OPTIONS.map(option => (
<option key={option.value} value={option.value}>
{option.text}
</option>
))}
</select>
</div>
<div style={{
display: 'flex',
gap: '12px',
justifyContent: 'flex-end'
}}>
<button
type="button"
onClick={handleClose}
disabled={isSubmitting}
style={{
height: '40px',
padding: '0 20px',
borderRadius: '8px',
border: '2px solid #374151',
background: 'transparent',
color: '#94a3b8',
fontSize: '14px',
fontWeight: '500',
cursor: isSubmitting ? 'not-allowed' : 'pointer',
transition: 'all 0.2s ease',
opacity: isSubmitting ? 0.6 : 1
}}
onMouseEnter={(e) => {
if (!isSubmitting) {
(e.target as HTMLElement).style.border = '2px solid #6b7280';
(e.target as HTMLElement).style.color = '#e5e7eb';
}
}}
onMouseLeave={(e) => {
if (!isSubmitting) {
(e.target as HTMLElement).style.border = '2px solid #374151';
(e.target as HTMLElement).style.color = '#94a3b8';
}
}}
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting || !formData.email || !formData.password}
style={{
height: '40px',
padding: '0 20px',
borderRadius: '8px',
border: '2px solid #5154e6',
background: isSubmitting ? '#4b5563' : 'linear-gradient(135deg, #6366f1, #8b5cf6)',
color: '#ffffff',
fontSize: '14px',
fontWeight: '600',
cursor: (isSubmitting || !formData.email || !formData.password) ? 'not-allowed' : 'pointer',
transition: 'all 0.2s ease',
opacity: (isSubmitting || !formData.email || !formData.password) ? 0.6 : 1
}}
onMouseEnter={(e) => {
if (!isSubmitting && formData.email && formData.password) {
(e.target as HTMLElement).style.transform = 'translateY(-1px)';
(e.target as HTMLElement).style.boxShadow = '0 4px 8px rgba(99, 102, 241, 0.4)';
}
}}
onMouseLeave={(e) => {
if (!isSubmitting && formData.email && formData.password) {
(e.target as HTMLElement).style.transform = 'translateY(0)';
(e.target as HTMLElement).style.boxShadow = 'none';
}
}}
>
{isSubmitting ? 'Creating...' : 'Create User'}
</button>
</div>
</form>
</div>
</div>
);
};
export default CreateUserModal;
@@ -1,137 +0,0 @@
import React from 'react';
import { parseHardware } from '../utils/parsers.ts';
import { memoryToGBNumber } from '../utils/formatters.ts';
import { CHART_COLORS } from '../config/constants.ts';
import { User, ChartData, ChartSegment } from '../types';
interface HardwareChartsProps {
users: User[] | null;
}
interface PieChartProps {
counts: ChartData[];
title: string;
}
const HardwareCharts: React.FC<HardwareChartsProps> = ({ users }) => {
const arr = Array.isArray(users) ? users : (users ? [users] : []);
if (!arr.length) {
return <div style={{ color: '#94a3b8', fontSize: '12px' }}>No users to chart.</div>;
}
// Aggregate hardware data
const cpuMap = new Map<string, number>();
const gpuMap = new Map<string, number>();
const memMap = new Map<string, number>();
for (const u of arr) {
const hw = parseHardware(u.hardware);
const cpu = (hw && hw.cpu ? String(hw.cpu).trim() : 'Unknown');
const gpu = (hw && hw.gpu ? String(hw.gpu).trim() : 'Unknown');
const memGb = hw && hw.memory ? memoryToGBNumber(hw.memory) : 0;
const memLabel = memGb ? `${Math.ceil(memGb)} GB` : 'Unknown';
cpuMap.set(cpu, (cpuMap.get(cpu) || 0) + 1);
gpuMap.set(gpu, (gpuMap.get(gpu) || 0) + 1);
memMap.set(memLabel, (memMap.get(memLabel) || 0) + 1);
}
const getTopCounts = (map: Map<string, number>): ChartData[] => {
const arr = Array.from(map.entries())
.map(([label, value]) => ({ label, value }))
.sort((a, b) => b.value - a.value);
const maxItems = 7;
if (arr.length <= maxItems) return arr;
const head = arr.slice(0, maxItems);
const rest = arr.slice(maxItems);
const restSum = rest.reduce((s, x) => s + x.value, 0);
return [...head, { label: 'Other', value: restSum }];
};
const cpuCounts = getTopCounts(cpuMap);
const gpuCounts = getTopCounts(gpuMap);
const memCounts = getTopCounts(memMap);
const PieChart: React.FC<PieChartProps> = ({ counts, title }) => {
const total = counts.reduce((s, c) => s + c.value, 0) || 1;
let acc = 0;
const segments: ChartSegment[] = counts.map((c, i) => {
const frac = c.value / total;
const start = acc;
acc += frac;
const color = CHART_COLORS[i % CHART_COLORS.length];
const startDeg = Math.round(start * 360);
const endDeg = Math.round(acc * 360);
return {
color,
startDeg,
endDeg,
label: c.label,
value: c.value,
percent: Math.round(frac * 1000) / 10
};
});
const gradient = segments.map(s => `${s.color} ${s.startDeg}deg ${s.endDeg}deg`).join(', ');
return (
<div style={{
border: '1px solid #1b2536',
background: '#0b1223',
borderRadius: '10px',
padding: '12px'
}}>
<h4 style={{ margin: '0 0 10px', color: '#e5e7eb' }}>{title}</h4>
<div style={{
width: '200px',
height: '200px',
borderRadius: '50%',
margin: '10px auto',
background: `conic-gradient(${gradient})`
}}></div>
<ul style={{ listStyle: 'none', padding: 0, margin: '8px 0 0', fontSize: '12px' }}>
{segments.map((s, i) => (
<li key={i} style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '6px'
}}>
<span style={{
background: s.color,
width: '10px',
height: '10px',
borderRadius: '2px',
display: 'inline-block',
border: '1px solid #1f2937'
}}></span>
<span style={{ color: '#e5e7eb' }}>{s.label}</span>
<span style={{ color: '#94a3b8', fontSize: '12px' }}>
&nbsp; {s.value} ({s.percent}%)
</span>
</li>
))}
</ul>
</div>
);
};
return (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))',
gap: '12px',
gridColumn: '1 / -1'
}}>
<PieChart counts={cpuCounts} title="CPU Models" />
<PieChart counts={gpuCounts} title="GPU Models" />
<PieChart counts={memCounts} title="Memory (GB)" />
</div>
);
};
export default HardwareCharts;
@@ -1,327 +0,0 @@
import React, { useState, useEffect, useCallback } from 'react';
import { html, formatJSON } from '../utils/formatters.ts';
import { ApiFunction } from '../types';
interface ListResourcesProps {
api: ApiFunction;
setStatusMessage: (message: string) => void;
setOutputMeta: (meta: string) => void;
updateOutputTitle: (opKey: string, extra?: string) => void;
}
interface ResourceData {
error?: string;
data?: any;
}
const ListResources: React.FC<ListResourcesProps> = ({
api,
setStatusMessage,
setOutputMeta,
updateOutputTitle
}) => {
const [prodResources, setProdResources] = useState<any[] | ResourceData | null>(null);
const [stageResources, setStageResources] = useState<any[] | ResourceData | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(false);
const handleDownload = async (endpoint: string, envType: string) => {
try {
setStatusMessage(`Downloading installer for ${envType}...`);
setOutputMeta(`${new Date().toLocaleString()} — Downloading installer from ${endpoint}`);
// Get the API base URL to construct the full download URL
let API_BASE = 'https://api.azaion.com';
try {
const res = await fetch('/__server-info', { method: 'GET' });
if (res.ok) {
const info = await res.json();
if (info && info.proxyEnabled) {
API_BASE = '/proxy';
}
}
} catch (_) {
// ignore; fall back to direct API_BASE
}
// Get auth token
const AUTH_TOKEN = localStorage.getItem('authToken') || '';
const headers: Record<string, string> = {};
if (AUTH_TOKEN) {
headers['Authorization'] = AUTH_TOKEN.startsWith('Bearer ') ? AUTH_TOKEN : `Bearer ${AUTH_TOKEN}`;
}
// Add auth header via fetch and create blob URL for secure download
const downloadUrl = `${API_BASE}${endpoint}`;
const response = await fetch(downloadUrl, { headers });
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
// Extract filename from Content-Disposition header if available
let filename = '';
const contentDisposition = response.headers.get('content-disposition');
if (contentDisposition) {
const filenameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
if (filenameMatch && filenameMatch[1]) {
filename = filenameMatch[1].replace(/['"]/g, '');
}
}
const blob = await response.blob();
const blobUrl = window.URL.createObjectURL(blob);
// Create a download link
const link = document.createElement('a');
link.href = blobUrl;
link.style.display = 'none';
// Only set download attribute if we have a filename, otherwise let browser handle it
if (filename) {
link.download = filename;
}
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// Clean up blob URL
setTimeout(() => window.URL.revokeObjectURL(blobUrl), 100);
setStatusMessage(`Installer download started for ${envType}`);
setOutputMeta(`${new Date().toLocaleString()} — Installer download initiated from ${endpoint}`);
} catch (error: any) {
console.error('Download error:', error);
setStatusMessage(`Download failed: ${error.message}`);
setOutputMeta(`${new Date().toLocaleString()} — Download error: ${error.message}`);
}
};
const handleUpdateBoth = useCallback(async () => {
setIsLoading(true);
updateOutputTitle('list-resources');
setStatusMessage('Loading...');
setOutputMeta(`${new Date().toLocaleString()} — Loading resources from both environments...`);
try {
// Load both environments in parallel
const [prodData, stageData] = await Promise.all([
api('/resources/list/suite', { method: 'GET' }).catch((e: any) => ({ error: e.message, data: e.data })),
api('/resources/list/suite-stage', { method: 'GET' }).catch((e: any) => ({ error: e.message, data: e.data }))
]);
setProdResources(prodData);
setStageResources(stageData);
setStatusMessage('OK');
setOutputMeta(`${new Date().toLocaleString()} — Resources loaded from both environments`);
} catch (e: any) {
setStatusMessage(e.message);
setOutputMeta(`${new Date().toLocaleString()} — Error loading resources`);
setProdResources({ error: e.message, data: e.data });
setStageResources({ error: e.message, data: e.data });
} finally {
setIsLoading(false);
}
}, [api, updateOutputTitle, setStatusMessage, setOutputMeta, setProdResources, setStageResources]);
// Load resources on component mount
useEffect(() => {
handleUpdateBoth();
}, [handleUpdateBoth]);
const renderResourceSection = (resources: any[] | ResourceData | null, title: string, envType: string) => {
return (
<div style={{
border: '1px solid #1b2536',
background: '#0b1223',
borderRadius: '8px',
padding: '16px',
flex: '1'
}}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '12px'
}}>
<h4 style={{
margin: '0',
fontSize: '18px',
fontWeight: '600',
color: '#e5e7eb'
}}>
{title}
</h4>
<button
onClick={() => handleDownload(
envType === 'stage' ? '/resources/get-installer/stage' : '/resources/get-installer',
envType
)}
style={{
padding: '8px 16px',
borderRadius: '6px',
border: '1px solid #4f46e5',
background: 'linear-gradient(135deg, #4f46e5, #7c3aed)',
color: '#ffffff',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '500',
transition: 'all 0.2s ease',
boxShadow: '0 2px 4px rgba(79, 70, 229, 0.3)',
display: 'flex',
alignItems: 'center',
gap: '6px'
}}
onMouseEnter={(e) => {
(e.target as HTMLElement).style.transform = 'translateY(-1px)';
(e.target as HTMLElement).style.boxShadow = '0 4px 8px rgba(79, 70, 229, 0.4)';
}}
onMouseLeave={(e) => {
(e.target as HTMLElement).style.transform = 'translateY(0)';
(e.target as HTMLElement).style.boxShadow = '0 2px 4px rgba(79, 70, 229, 0.3)';
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21 15V19C21 19.5304 20.7893 20.0391 20.4142 20.4142C20.0391 20.7893 19.5304 21 19 21H5C4.46957 21 3.96086 20.7893 3.58579 20.4142C3.21071 20.0391 3 19.5304 3 19V15" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M7 10L12 15L17 10" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M12 15V3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
Download Installer
</button>
</div>
{resources ? (
(resources as ResourceData).error ? (
<pre style={{
background: '#0b1223',
border: '1px solid #0f1a33',
borderRadius: '8px',
padding: '10px',
maxHeight: '260px',
overflow: 'auto',
color: '#e5e7eb',
fontSize: '12px'
}}>
{html(formatJSON((resources as ResourceData).data || (resources as ResourceData).error))}
</pre>
) : (
<div>
{Array.isArray(resources) ? (
resources.length > 0 ? (
<ul style={{
listStyle: 'none',
padding: 0,
margin: 0,
maxWidth: '100%',
overflowWrap: 'anywhere' as const,
wordBreak: 'break-word' as const
}}>
{resources.map((item, index) => (
<li key={index} style={{
padding: '12px 16px',
border: '1px solid #1b2536',
background: '#111827',
borderRadius: '6px',
marginBottom: '8px',
maxWidth: '100%',
whiteSpace: 'normal' as const,
overflowWrap: 'anywhere' as const,
wordBreak: 'break-word' as const,
color: '#e5e7eb',
fontSize: '16px',
fontWeight: '500',
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
}}>
{typeof item === 'string' ? item : JSON.stringify(item)}
</li>
))}
</ul>
) : (
<div style={{
color: '#94a3b8',
fontSize: '16px',
textAlign: 'center' as const,
padding: '20px'
}}>
No resources found.
</div>
)
) : (
<div style={{
color: '#94a3b8',
fontSize: '14px',
textAlign: 'center' as const,
padding: '20px'
}}>
No resources found.
</div>
)}
</div>
)
) : (
<div style={{
color: '#94a3b8',
fontSize: '16px',
textAlign: 'center' as const,
padding: '20px'
}}>
{isLoading ? 'Loading...' : 'Click Reload to load resources'}
</div>
)}
</div>
);
};
return (
<div style={{ gridColumn: '1 / -1' }}>
{/* Two-column layout for environments */}
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '20px',
marginBottom: '24px'
}}>
{renderResourceSection(prodResources, 'Prod Env', 'prod')}
{renderResourceSection(stageResources, 'Stage Env', 'stage')}
</div>
{/* Reload Button underneath */}
<div style={{
display: 'flex',
justifyContent: 'center'
}}>
<button
onClick={handleUpdateBoth}
disabled={isLoading}
style={{
height: '48px',
padding: '0 32px',
borderRadius: '8px',
border: '2px solid #5154e6',
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
color: '#ffffff',
cursor: isLoading ? 'not-allowed' : 'pointer',
fontSize: '16px',
fontWeight: '600',
opacity: isLoading ? 0.6 : 1,
transition: 'all 0.2s ease',
boxShadow: '0 2px 4px rgba(99, 102, 241, 0.3)'
}}
onMouseEnter={(e) => {
if (!isLoading) {
(e.target as HTMLElement).style.transform = 'translateY(-1px)';
(e.target as HTMLElement).style.boxShadow = '0 4px 8px rgba(99, 102, 241, 0.4)';
}
}}
onMouseLeave={(e) => {
(e.target as HTMLElement).style.transform = 'translateY(0)';
(e.target as HTMLElement).style.boxShadow = '0 2px 4px rgba(99, 102, 241, 0.3)';
}}
>
{isLoading ? 'Reloading...' : 'Reload'}
</button>
</div>
</div>
);
};
export default ListResources;
@@ -1,113 +0,0 @@
import React, { useEffect, useState } from 'react';
interface NotificationBadgeProps {
message: string;
type: 'success' | 'error';
isVisible: boolean;
onClose: () => void;
duration?: number;
}
const NotificationBadge: React.FC<NotificationBadgeProps> = ({
message,
type,
isVisible,
onClose,
duration = 5000
}) => {
const [isAnimating, setIsAnimating] = useState(false);
useEffect(() => {
if (isVisible) {
setIsAnimating(true);
const timer = setTimeout(() => {
setIsAnimating(false);
setTimeout(onClose, 300); // Wait for animation to complete
}, duration);
return () => clearTimeout(timer);
}
}, [isVisible, duration, onClose]);
if (!isVisible) return null;
const isSuccess = type === 'success';
const bgColor = isSuccess ? '#065f46' : '#7f1d1d';
const borderColor = isSuccess ? '#10b981' : '#ef4444';
const textColor = isSuccess ? '#10b981' : '#ef4444';
const icon = isSuccess ? '✅' : '❌';
return (
<div
style={{
position: 'fixed',
top: '20px',
right: '20px',
backgroundColor: bgColor,
border: `2px solid ${borderColor}`,
borderRadius: '12px',
padding: '16px 20px',
minWidth: '300px',
maxWidth: '500px',
boxShadow: '0 10px 25px rgba(0, 0, 0, 0.3)',
zIndex: 10000,
transform: isAnimating ? 'translateX(0)' : 'translateX(100%)',
opacity: isAnimating ? 1 : 0,
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
display: 'flex',
alignItems: 'center',
gap: '12px',
cursor: 'pointer'
}}
onClick={onClose}
>
<span style={{ fontSize: '24px', flexShrink: 0 }}>{icon}</span>
<div style={{ flex: 1 }}>
<div style={{
color: textColor,
fontWeight: '600',
fontSize: '16px',
marginBottom: '4px'
}}>
{isSuccess ? 'Success!' : 'Error!'}
</div>
<div style={{
color: isSuccess ? '#a7f3d0' : '#fca5a5',
fontSize: '14px',
lineHeight: '1.4'
}}>
{message}
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation();
onClose();
}}
style={{
background: 'none',
border: 'none',
color: isSuccess ? '#a7f3d0' : '#fca5a5',
fontSize: '20px',
cursor: 'pointer',
padding: '4px',
borderRadius: '4px',
transition: 'all 0.2s ease',
flexShrink: 0
}}
onMouseEnter={(e) => {
(e.target as HTMLElement).style.backgroundColor = 'rgba(255, 255, 255, 0.1)';
}}
onMouseLeave={(e) => {
(e.target as HTMLElement).style.backgroundColor = 'transparent';
}}
>
×
</button>
</div>
);
};
export default NotificationBadge;
@@ -1,319 +0,0 @@
import React, { useState, useRef } from 'react';
import { ApiFunction } from '../types';
interface UploadFileModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: (message: string) => void;
onError: (error: string) => void;
api: ApiFunction;
setStatusMessage: (message: string) => void;
}
const UploadFileModal: React.FC<UploadFileModalProps> = ({
isOpen,
onClose,
onSuccess,
onError,
api,
setStatusMessage
}) => {
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [folderPath, setFolderPath] = useState<string>('');
const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setSelectedFile(file);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedFile) {
setStatusMessage('Please select a file to upload');
return;
}
if (!folderPath.trim()) {
setStatusMessage('Please enter a folder path');
return;
}
setIsUploading(true);
setStatusMessage('Uploading file...');
try {
const formData = new FormData();
formData.append('file', selectedFile);
formData.append('folderPath', folderPath);
const result = await api('/upload', {
method: 'POST',
formData: formData
});
console.log('Upload result:', result);
setStatusMessage('File uploaded successfully!');
onSuccess(`File "${selectedFile.name}" uploaded to "${folderPath}" successfully!`);
handleClose();
} catch (e: any) {
console.error('Upload error:', e);
const errorMessage = `Failed to upload file: ${e.message}`;
setStatusMessage(errorMessage);
onError(errorMessage);
} finally {
setIsUploading(false);
}
};
const handleClose = () => {
setSelectedFile(null);
setFolderPath('');
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
onClose();
};
if (!isOpen) return null;
return (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.8)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000
}}>
<div style={{
backgroundColor: '#1f2937',
border: '1px solid #374151',
borderRadius: '12px',
padding: '24px',
width: '90%',
maxWidth: '500px',
maxHeight: '90vh',
overflow: 'auto'
}}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '20px'
}}>
<h3 style={{
margin: 0,
fontSize: '20px',
fontWeight: '600',
color: '#e5e7eb'
}}>
Upload File
</h3>
<button
onClick={handleClose}
style={{
background: 'none',
border: 'none',
color: '#94a3b8',
fontSize: '24px',
cursor: 'pointer',
padding: '4px',
borderRadius: '4px',
transition: 'all 0.2s ease'
}}
onMouseEnter={(e) => {
(e.target as HTMLElement).style.color = '#e5e7eb';
(e.target as HTMLElement).style.backgroundColor = '#374151';
}}
onMouseLeave={(e) => {
(e.target as HTMLElement).style.color = '#94a3b8';
(e.target as HTMLElement).style.backgroundColor = 'transparent';
}}
>
×
</button>
</div>
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: '16px' }}>
<label style={{
display: 'block',
marginBottom: '6px',
fontSize: '14px',
fontWeight: '500',
color: '#e5e7eb'
}}>
Select File *
</label>
<input
ref={fileInputRef}
type="file"
onChange={handleFileSelect}
required
style={{
width: '100%',
height: '40px',
padding: '0 12px',
borderRadius: '8px',
border: '2px solid #374151',
background: '#111827',
color: '#e5e7eb',
fontSize: '14px',
outline: 'none',
transition: 'all 0.2s ease',
boxSizing: 'border-box',
cursor: 'pointer'
}}
onFocus={(e) => {
e.target.style.border = '2px solid #6366f1';
e.target.style.boxShadow = '0 0 0 3px rgba(99, 102, 241, 0.1)';
}}
onBlur={(e) => {
e.target.style.border = '2px solid #374151';
e.target.style.boxShadow = 'none';
}}
/>
{selectedFile && (
<div style={{
marginTop: '8px',
padding: '8px',
backgroundColor: '#065f46',
border: '1px solid #10b981',
borderRadius: '6px',
color: '#a7f3d0',
fontSize: '12px'
}}>
Selected: {selectedFile.name} ({(selectedFile.size / 1024 / 1024).toFixed(2)} MB)
</div>
)}
</div>
<div style={{ marginBottom: '24px' }}>
<label style={{
display: 'block',
marginBottom: '6px',
fontSize: '14px',
fontWeight: '500',
color: '#e5e7eb'
}}>
Folder Path *
</label>
<input
type="text"
value={folderPath}
onChange={(e) => setFolderPath(e.target.value)}
placeholder="/uploads/documents"
required
style={{
width: '100%',
height: '40px',
padding: '0 12px',
borderRadius: '8px',
border: '2px solid #374151',
background: '#111827',
color: '#e5e7eb',
fontSize: '14px',
outline: 'none',
transition: 'all 0.2s ease',
boxSizing: 'border-box'
}}
onFocus={(e) => {
e.target.style.border = '2px solid #6366f1';
e.target.style.boxShadow = '0 0 0 3px rgba(99, 102, 241, 0.1)';
}}
onBlur={(e) => {
e.target.style.border = '2px solid #374151';
e.target.style.boxShadow = 'none';
}}
/>
</div>
<div style={{
display: 'flex',
gap: '12px',
justifyContent: 'flex-end'
}}>
<button
type="button"
onClick={handleClose}
disabled={isUploading}
style={{
height: '40px',
padding: '0 20px',
borderRadius: '8px',
border: '2px solid #374151',
background: 'transparent',
color: '#94a3b8',
fontSize: '14px',
fontWeight: '500',
cursor: isUploading ? 'not-allowed' : 'pointer',
transition: 'all 0.2s ease',
opacity: isUploading ? 0.6 : 1
}}
onMouseEnter={(e) => {
if (!isUploading) {
(e.target as HTMLElement).style.border = '2px solid #6b7280';
(e.target as HTMLElement).style.color = '#e5e7eb';
}
}}
onMouseLeave={(e) => {
if (!isUploading) {
(e.target as HTMLElement).style.border = '2px solid #374151';
(e.target as HTMLElement).style.color = '#94a3b8';
}
}}
>
Cancel
</button>
<button
type="submit"
disabled={isUploading || !selectedFile || !folderPath.trim()}
style={{
height: '40px',
padding: '0 20px',
borderRadius: '8px',
border: '2px solid #5154e6',
background: isUploading ? '#4b5563' : 'linear-gradient(135deg, #6366f1, #8b5cf6)',
color: '#ffffff',
fontSize: '14px',
fontWeight: '600',
cursor: (isUploading || !selectedFile || !folderPath.trim()) ? 'not-allowed' : 'pointer',
transition: 'all 0.2s ease',
opacity: (isUploading || !selectedFile || !folderPath.trim()) ? 0.6 : 1
}}
onMouseEnter={(e) => {
if (!isUploading && selectedFile && folderPath.trim()) {
(e.target as HTMLElement).style.transform = 'translateY(-1px)';
(e.target as HTMLElement).style.boxShadow = '0 4px 8px rgba(99, 102, 241, 0.4)';
}
}}
onMouseLeave={(e) => {
if (!isUploading && selectedFile && folderPath.trim()) {
(e.target as HTMLElement).style.transform = 'translateY(0)';
(e.target as HTMLElement).style.boxShadow = 'none';
}
}}
>
{isUploading ? 'Uploading...' : 'Upload File'}
</button>
</div>
</form>
</div>
</div>
);
};
export default UploadFileModal;
@@ -1,294 +0,0 @@
import React from 'react';
import { parseHardware, roleCode, roleLabel, getLastLoginValue } from '../utils/parsers.ts';
import { formatMemoryGB, formatUTCDate } from '../utils/formatters.ts';
import { User, ApiFunction } from '../types';
interface UsersListProps {
users: User[] | null;
onSearch: (searchEmail: string) => void;
searchValue: string;
api: ApiFunction;
setStatusMessage: (message: string) => void;
onUserUpdate?: () => void;
}
const UsersList: React.FC<UsersListProps> = ({
users,
onSearch,
searchValue,
api,
setStatusMessage,
onUserUpdate
}) => {
const handleToggleUser = async (email: string, isEnabled: boolean) => {
const action = isEnabled ? 'disable' : 'enable';
setStatusMessage(`${action === 'enable' ? 'Enabling' : 'Disabling'} user...`);
try {
await api(`/users/${encodeURIComponent(email)}/${action}`, { method: 'PUT' });
setStatusMessage(`User ${action}d successfully`);
if (onUserUpdate) onUserUpdate();
} catch (e: any) {
setStatusMessage(`Failed to ${action} user: ${e.message}`);
}
};
const handleDeleteUser = async (email: string) => {
if (!window.confirm(`Are you sure you want to delete user "${email}"? This action cannot be undone.`)) {
return;
}
setStatusMessage('Deleting user...');
try {
await api(`/users/${encodeURIComponent(email)}`, { method: 'DELETE' });
setStatusMessage('User deleted successfully');
if (onUserUpdate) onUserUpdate();
} catch (e: any) {
setStatusMessage(`Failed to delete user: ${e.message}`);
}
};
const arr = Array.isArray(users) ? users : (users ? [users] : []);
if (!arr.length) {
return <div style={{ color: '#94a3b8', fontSize: '12px' }}>No users found.</div>;
}
// Sort so role 10 users go first
if (arr.length > 1) {
arr.sort((a, b) => {
const a10 = roleCode(a && a.role) === 10;
const b10 = roleCode(b && b.role) === 10;
if (a10 && !b10) return -1;
if (b10 && !a10) return 1;
return 0;
});
}
return (
<>
{arr.map(u => {
const { text: rText, cls: rCls } = roleLabel(u.role);
const hw = parseHardware(u.hardware);
const lastLoginRaw = getLastLoginValue(u);
const lastLoginDisplay = (lastLoginRaw != null && String(lastLoginRaw).trim() !== '')
? formatUTCDate(lastLoginRaw)
: 'Unknown';
// Get queue offset (only the first one)
const qo = (u.userConfig && u.userConfig.queueOffsets) || {};
const queueOffset = qo.annotationsOffset ?? '';
const isEnabled = u.isEnabled !== false; // Default to enabled if property is missing
return (
<div key={u.id} className="card" style={{
padding: '16px',
minHeight: '200px',
opacity: isEnabled ? 1 : 0.6,
filter: isEnabled ? 'none' : 'grayscale(0.3)',
border: isEnabled ? '1px solid #2a3b5f' : '1px solid #374151'
}}>
{/* Header with email and badges */}
<div style={{ marginBottom: '12px' }}>
<h4 style={{ margin: '0 0 8px', fontSize: '20px', fontWeight: 700, color: '#93c5fd' }}>
{u.email || 'User'}
</h4>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'wrap' as const
}}>
<div>
{rText && (
<span className={`badge ${rCls}`} style={{
display: 'inline-block',
padding: '6px 14px',
borderRadius: '999px',
fontSize: '14px',
border: '1px solid #2b3650',
background: 'rgba(99, 102, 241, 0.18)',
color: '#c7d2fe',
fontWeight: '500'
}}>
{rText}
</span>
)}
</div>
<span style={{
display: 'inline-block',
padding: '6px 10px',
borderRadius: '4px',
fontSize: '11px',
border: '1px solid #4b5563',
background: '#374151',
color: '#e5e7eb',
fontFamily: 'monospace',
whiteSpace: 'nowrap' as const,
fontWeight: '500'
}}>
Last Login: {lastLoginDisplay}
</span>
</div>
</div>
{/* Queue Panel */}
{queueOffset && (
<div style={{
background: '#0b1223',
border: '1px solid #1b2536',
borderRadius: '6px',
padding: '12px',
marginBottom: '8px',
minHeight: '60px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-end'
}}>
<div>
<div style={{ color: '#94a3b8', fontSize: '13px', marginBottom: '4px' }}>Queue Offset</div>
<div style={{ fontSize: '18px', fontWeight: '600', color: '#e5e7eb' }}>{queueOffset}</div>
</div>
<button
className="btn-small"
style={{
height: '48px',
fontSize: '14px',
padding: '10px 18px',
background: '#6366f1',
border: '1px solid #5154e6',
color: '#e5e7eb',
borderRadius: '6px',
cursor: 'pointer',
lineHeight: '1.3',
whiteSpace: 'normal' as const,
textAlign: 'center' as const,
width: '80px',
fontWeight: '500',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
Set Offset
</button>
</div>
)}
{/* Hardware Panel */}
{hw && (
<div style={{
background: '#0b1223',
border: '1px solid #1b2536',
borderRadius: '6px',
padding: '12px',
minHeight: '80px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-end'
}}>
<div style={{ flex: '1' }}>
<div style={{ color: '#94a3b8', fontSize: '13px', marginBottom: '6px' }}>Hardware</div>
<div style={{ fontSize: '13px', lineHeight: '1.4' }}>
{hw.cpu && <div style={{ marginBottom: '3px' }}>{hw.cpu}</div>}
{hw.gpu && (
<div style={{
color: hw.gpu.toLowerCase().includes('nvidia') ? '#86efac' :
hw.gpu.toLowerCase().includes('amd') || hw.gpu.toLowerCase().includes('radeon') ? '#fca5a5' : '#c7d2fe',
fontWeight: hw.gpu.toLowerCase().includes('nvidia') || hw.gpu.toLowerCase().includes('amd') ? 600 : 'normal',
marginBottom: '3px'
}}>
{hw.gpu}
</div>
)}
{hw.memory && <div>{formatMemoryGB(hw.memory)}</div>}
</div>
</div>
<button
onClick={() => {/* Reset Hardware functionality */}}
className="btn-small"
style={{
height: '48px',
fontSize: '14px',
padding: '10px 18px',
background: '#991b1b',
border: '1px solid #b91c1c',
color: '#e5e7eb',
borderRadius: '6px',
cursor: 'pointer',
marginLeft: '12px',
lineHeight: '1.3',
whiteSpace: 'normal' as const,
textAlign: 'center' as const,
width: '80px',
fontWeight: '500',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
Reset Hardware
</button>
</div>
)}
{/* User Action Buttons */}
<div style={{
display: 'flex',
gap: '10px',
marginTop: '4px',
paddingTop: '4px',
borderTop: '1px solid #1b2536',
justifyContent: 'flex-end'
}}>
<button
onClick={() => handleToggleUser(u.email, isEnabled)}
style={{
height: '30px',
fontSize: '12px',
padding: '6px 12px',
background: isEnabled ? '#92400e' : '#166534',
border: isEnabled ? '1px solid #a16207' : '1px solid #15803d',
color: '#e5e7eb',
borderRadius: '4px',
cursor: 'pointer',
lineHeight: '1.3',
fontWeight: '500',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
{isEnabled ? 'Disable' : 'Enable'}
</button>
<button
onClick={() => handleDeleteUser(u.email)}
style={{
height: '30px',
fontSize: '12px',
padding: '6px 12px',
background: '#7f1d1d',
border: '1px solid #991b1b',
color: '#e5e7eb',
borderRadius: '4px',
cursor: 'pointer',
lineHeight: '1.3',
fontWeight: '500',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
Delete
</button>
</div>
</div>
);
})}
</>
);
};
export default UsersList;
-81
View File
@@ -1,81 +0,0 @@
// Static configurations for the admin dashboard
import { RoleInfo, OperationConfig, RoleOption } from '../types';
export const ROLES: Record<number, RoleInfo> = {
1000: { text: 'ApiAdmin', cls: 'apiadmin' },
40: { text: 'Admin', cls: 'admin' },
50: { text: 'ResourceUploader', cls: 'uploader' },
30: { text: 'CompanionPC', cls: 'companion' },
20: { text: 'Validator', cls: 'validator' },
10: { text: 'Operator', cls: 'operator' },
0: { text: 'None', cls: 'none' }
};
export const ROLE_OPTIONS: RoleOption[] = [
{ value: '', text: 'Choose Role' },
{ value: '10', text: '10 (Operator)' },
{ value: '20', text: '20 (Validator)' },
{ value: '30', text: '30 (CompanionPC)' },
{ value: '40', text: '40 (Admin)' },
{ value: '50', text: '50 (ResourceUploader)' },
{ value: '1000', text: '1000 (ApiAdmin)' }
];
export const OPERATIONS_CONFIG: Record<string, OperationConfig> = {
'list-users': {
title: 'List Users',
description: 'Filter by email (optional)',
hasForm: false
},
'show-chart': {
title: 'Show Chart',
description: 'Pie charts by CPU / GPU / Memory',
hasForm: false
},
'current-user': {
title: 'Current User',
description: 'Get info about current user',
hasForm: false
},
'list-resources': {
title: 'List Resources',
description: 'List files in folder',
hasForm: false
},
'upload-file': {
title: 'Upload File',
description: 'To specific folder',
hasForm: true
},
'clear-folder': {
title: 'Clear Folder',
description: 'Remove all files',
hasForm: true
}
};
export const OUTPUT_TITLES: Record<string, string> = {
'list-users': 'Users',
'current-user': 'Current User',
'list-resources': 'Resources',
'show-chart': 'Users Hardware charts',
'upload-file': 'Upload File',
'clear-folder': 'Clear Folder',
};
export const CHART_COLORS: string[] = [
'#60a5fa', '#34d399', '#f472b6', '#fbbf24', '#a78bfa',
'#f87171', '#22d3ee', '#86efac', '#fca5a5', '#c084fc'
];
export const MEMORY_THRESHOLDS = {
VERY_LARGE: 1e7, // Likely KB
LARGE: 1e5, // Likely MB
MEDIUM: 256 // Likely already GB
} as const;
export const LOGIN_FIELDS: string[] = [
'lastLogin', 'last_login', 'lastLoginAt', 'last_login_at',
'lastSeen', 'last_seen', 'lastSeenAt', 'last_seen_at',
'last_activity', 'lastActivity'
];
@@ -1,235 +0,0 @@
import { useState, useCallback, useMemo } from 'react';
import { OUTPUT_TITLES, OPERATIONS_CONFIG } from '../config/constants.ts';
import {
User,
UseAdminOperationsReturn,
ApiFunction,
ServerInfo
} from '../types';
const useAdminOperations = (): UseAdminOperationsReturn => {
const [currentOpKey, setCurrentOpKey] = useState<string>('list-users');
const [outputTitle, setOutputTitle] = useState<string>('Users');
const [outputMeta, setOutputMeta] = useState<string>('');
const [output, setOutput] = useState<string>('');
const [status, setStatus] = useState<string>('');
const [users, setUsers] = useState<User[] | null>(null);
const [searchValue, setSearchValue] = useState<string>('');
let API_BASE = 'https://api.azaion.com';
let apiResolved = false;
let AUTH_TOKEN = localStorage.getItem('authToken') || '';
const resolveApiBase = async (): Promise<string> => {
if (apiResolved) return API_BASE;
try {
const res = await fetch('/__server-info', { method: 'GET' });
if (res.ok) {
const info: ServerInfo = await res.json();
if (info && info.proxyEnabled) API_BASE = '/proxy';
}
} catch {
// ignore; use default API_BASE
}
apiResolved = true;
return API_BASE;
};
const api: ApiFunction = async (path, { method = 'GET', json, formData, headers = {} } = {}) => {
await resolveApiBase();
const h: Record<string, string> = { ...headers };
if (AUTH_TOKEN) h['Authorization'] = AUTH_TOKEN.startsWith('Bearer ') ? AUTH_TOKEN : `Bearer ${AUTH_TOKEN}`;
let body: string | FormData | undefined;
if (json !== undefined) {
h['Content-Type'] = 'application/json';
body = JSON.stringify(json);
} else if (formData) {
body = formData;
}
const res = await fetch(`${API_BASE}${path}`, { method, headers: h, body });
const text = await res.text();
let data: any;
try {
data = JSON.parse(text);
} catch {
data = text;
}
if (!res.ok) {
const err = new Error(`HTTP ${res.status}`) as any;
err.data = data;
throw err;
}
return data;
};
const updateOutputTitle = useCallback((opKey: string, extra?: string) => {
let title = OUTPUT_TITLES[opKey] || 'Output';
if (extra) title = `${title}${extra}`;
setOutputTitle(title);
}, []);
const setStatusMessage = useCallback((text: string, type: string = '') => {
setStatus(text || '');
}, []);
// Operations definition
const operations = useMemo(() => ({
'list-users': {
...OPERATIONS_CONFIG['list-users'],
run: async (searchEmail: string = '') => {
setCurrentOpKey('list-users');
updateOutputTitle('list-users');
const qs = searchEmail ? `?searchEmail=${encodeURIComponent(searchEmail)}` : '';
setStatusMessage('Loading...');
try {
const data: User[] = await api(`/users${qs}`, { method: 'GET' });
setStatusMessage('OK');
setUsers(data);
setOutput('');
} catch (e: any) {
setStatusMessage(e.message);
setUsers([]);
setOutput(`<div class="small-muted">Error: ${e.message}</div>`);
}
}
},
'show-chart': {
...OPERATIONS_CONFIG['show-chart'],
run: async () => {
setCurrentOpKey('show-chart');
updateOutputTitle('show-chart');
setStatusMessage('Loading users...');
try {
const data: User[] = await api(`/users`, { method: 'GET' });
setStatusMessage('OK');
setUsers(data);
setOutput('');
} catch (e: any) {
setStatusMessage(e.message);
setUsers([]);
setOutput(`<div class="small-muted">Error: ${e.message}</div>`);
}
}
},
'current-user': {
...OPERATIONS_CONFIG['current-user'],
run: async () => {
setCurrentOpKey('current-user');
updateOutputTitle('current-user');
setStatusMessage('Loading...');
try {
const data: User = await api('/currentuser', { method: 'GET' });
setStatusMessage('OK');
setUsers([data]);
setOutput('');
} catch (e: any) {
setStatusMessage(e.message);
setUsers([]);
setOutput(`<div class="small-muted">Error: ${e.message}</div>`);
}
}
},
'list-resources': {
...OPERATIONS_CONFIG['list-resources'],
run: async () => {
setCurrentOpKey('list-resources');
updateOutputTitle('list-resources');
setUsers(null);
setOutput('');
setOutputMeta(`${new Date().toLocaleString()}`);
}
},
'upload-file': {
...OPERATIONS_CONFIG['upload-file'],
run: async () => {
setCurrentOpKey('upload-file');
updateOutputTitle('upload-file');
setUsers(null);
setOutput(`
<div class="card full-span">
<h4>Upload File</h4>
<div style="margin-top: 16px; text-align: center;">
<p style="color: #94a3b8; margin-bottom: 20px;">
Click the "Upload File" button to open the upload dialog.
</p>
<button onclick="window.adminDashboard.openUploadModal()" style="
height: 40px;
padding: 0 20px;
border-radius: 8px;
border: 2px solid #5154e6;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
color: #ffffff;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
">
📁 Upload File
</button>
</div>
</div>
`);
}
},
'clear-folder': {
...OPERATIONS_CONFIG['clear-folder'],
run: async () => {
setCurrentOpKey('clear-folder');
updateOutputTitle('clear-folder');
setUsers(null);
setOutput(`
<div class="card full-span">
<h4 style="color: #ef4444;">⚠️ Clear Folder</h4>
<div style="margin-top: 16px; text-align: center;">
<p style="color: #94a3b8; margin-bottom: 20px;">
Click the "Clear Folder" button to open the clear folder dialog.
<br><strong style="color: #ef4444;">Warning: This action cannot be undone!</strong>
</p>
<button onclick="window.adminDashboard.openClearFolderModal()" style="
height: 40px;
padding: 0 20px;
border-radius: 8px;
border: 2px solid #dc2626;
background: linear-gradient(135deg, #ef4444, #dc2626);
color: #ffffff;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
">
🗑️ Clear Folder
</button>
</div>
</div>
`);
}
}
}), [api, setCurrentOpKey, updateOutputTitle, setStatusMessage, setOutputMeta, setUsers, setSearchValue]);
const handleUserSearch = useCallback(async (searchEmail: string) => {
setSearchValue(searchEmail);
await operations['list-users'].run(searchEmail);
}, [operations, setSearchValue]);
return {
currentOpKey,
outputTitle,
outputMeta,
output,
status,
users,
searchValue,
operations,
api,
setStatusMessage,
setOutputMeta,
updateOutputTitle,
handleUserSearch
};
};
export default useAdminOperations;
-138
View File
@@ -1,138 +0,0 @@
// Type definitions for the Admin dashboard
export interface User {
id: string;
email: string;
role: number;
hardware?: string | HardwareParsed;
lastLogin?: string | number;
last_login?: string | number;
lastLoginAt?: string | number;
last_login_at?: string | number;
lastSeen?: string | number;
last_seen?: string | number;
lastSeenAt?: string | number;
last_seen_at?: string | number;
last_activity?: string | number;
lastActivity?: string | number;
userConfig?: {
queueOffsets?: {
annotationsOffset?: number;
annotationsConfirmOffset?: number;
annotationsCommandsOffset?: number;
};
};
isEnabled?: boolean;
}
export interface HardwareParsed {
cpu: string;
gpu: string;
memory: string;
drive: string;
}
export interface RoleInfo {
text: string;
cls: string;
code?: number;
}
export interface OperationConfig {
title: string;
description: string;
hasForm: boolean;
}
export interface ChartData {
label: string;
value: number;
}
export interface ChartSegment extends ChartData {
color: string;
startDeg: number;
endDeg: number;
percent: number;
}
export interface RoleOption {
value: string;
text: string;
}
export interface ApiResponse<T = any> {
data?: T;
error?: string;
message?: string;
id?: string;
userId?: string;
user?: {
id: string;
};
}
export interface LoginResponse {
token?: string;
accessToken?: string;
access_token?: string;
jwt?: string;
Authorization?: string;
authorization?: string;
authToken?: string;
data?: {
token?: string;
};
}
export interface ServerInfo {
proxyEnabled?: boolean;
}
export interface CreateUserFormData {
email: string;
password: string;
role: number;
}
export interface QueueOffsets {
annotationsOffset: number;
annotationsConfirmOffset: number;
annotationsCommandsOffset: number;
}
export interface Operation {
title: string;
hasForm: boolean;
run: (formData?: any) => Promise<void>;
}
export interface UseAdminOperationsReturn {
currentOpKey: string;
outputTitle: string;
outputMeta: string;
output: string;
status: string;
users: User[] | null;
searchValue: string;
operations: Record<string, Operation>;
api: ApiFunction;
setStatusMessage: (text: string, type?: string) => void;
setOutputMeta: (meta: string) => void;
updateOutputTitle: (opKey: string, extra?: string) => void;
handleUserSearch: (searchEmail: string) => Promise<void>;
}
export interface ApiFunction {
(path: string, options?: {
method?: string;
json?: any;
formData?: FormData;
headers?: Record<string, string>;
}): Promise<any>;
}
export type MemoryThreshold = 'VERY_LARGE' | 'LARGE' | 'MEDIUM';
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
-89
View File
@@ -1,89 +0,0 @@
// Formatting utilities for the admin dashboard
import { MEMORY_THRESHOLDS } from '../config/constants.ts';
export function html(str: string | number): string {
return String(str).replace(/[&<>]/g, s => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;'
}[s] || s));
}
export function formatJSON(obj: any): string {
try {
if (typeof obj === 'string') obj = JSON.parse(obj);
return JSON.stringify(obj, null, 2);
} catch {
return String(obj);
}
}
export function formatMemoryGB(mem: string | number | null | undefined): string {
if (mem == null) return '';
let raw = String(mem).trim();
// Extract digits if memory was part of a sentence
const digits = raw.match(/\d+(?:[.,]\d+)?/g);
if (digits && digits.length) raw = digits[0].replace(',', '.');
const n = Number(raw);
if (!isFinite(n) || n <= 0) return String(mem);
// Heuristics: typical API returns KB (e.g., 67037080 -> ~64 GB)
let gb: number;
if (n > MEMORY_THRESHOLDS.VERY_LARGE) {
gb = n / 1048576; // KB -> GiB
} else if (n > MEMORY_THRESHOLDS.LARGE) {
gb = n / 1024; // MB -> GiB
} else if (n > MEMORY_THRESHOLDS.MEDIUM) {
gb = n; // GB
} else {
// small numbers treat as GB already
gb = n;
}
const roundedUp = Math.ceil(gb); // round up to the next whole GB
return `${roundedUp} GB`;
}
export function formatUTCDate(val: string | number | null | undefined): string {
if (val === null || val === undefined) return '';
// try parse ISO or epoch
let d: Date;
if (typeof val === 'number') {
d = new Date(val > 1e12 ? val : val * 1000);
} else {
const s = String(val).trim();
// if numeric string
if (/^\d+$/.test(s)) {
const num = Number(s);
d = new Date(num > 1e12 ? num : num * 1000);
} else {
d = new Date(s);
}
}
if (isNaN(d.getTime())) return String(val);
const yyyy = d.getUTCFullYear();
const mm = String(d.getUTCMonth() + 1).padStart(2, '0');
const dd = String(d.getUTCDate()).padStart(2, '0');
const HH = String(d.getUTCHours()).padStart(2, '0');
const MM = String(d.getUTCMinutes()).padStart(2, '0');
const SS = String(d.getUTCSeconds()).padStart(2, '0');
return `${yyyy}-${mm}-${dd} ${HH}:${MM}:${SS} UTC`;
}
export function memoryToGBNumber(mem: string | number | null | undefined): number {
if (mem == null) return 0;
let raw = String(mem).trim();
const digits = raw.match(/\d+(?:[.,]\d+)?/g);
if (digits && digits.length) raw = digits[0].replace(',', '.');
const n = Number(raw);
if (!isFinite(n) || n <= 0) return 0;
if (n > MEMORY_THRESHOLDS.VERY_LARGE) return n / 1048576; // KB -> GiB
if (n > MEMORY_THRESHOLDS.LARGE) return n / 1024; // MB -> GiB
return n; // assume already GB otherwise
}
-66
View File
@@ -1,66 +0,0 @@
export const extractToken = (data: any): string | null => {
if (!data) return null;
if (typeof data === 'string') {
try {
const parsed = JSON.parse(data);
return parsed.token || parsed.data?.token || null;
} catch {
return data;
}
}
if (data.token) return data.token;
if (data.data?.token) return data.data.token;
return null;
};
export const parseHardware = (hardware: any): any => {
if (!hardware) return null;
try {
if (typeof hardware === 'string') {
return JSON.parse(hardware);
}
return hardware;
} catch {
return null;
}
};
export const roleCode = (role: any): number => {
if (typeof role === 'number') return role;
if (typeof role === 'string') {
const match = role.match(/-?\d+/);
if (match) return Number(match[0]);
}
return Number(role) || 0;
};
export const roleLabel = (role: any): string => {
const code = roleCode(role);
switch (code) {
case 0: return 'User';
case 10: return 'Moderator';
case 20: return 'Admin';
case 30: return 'Super Admin';
case 40: return 'System Admin';
case 1000: return 'Root';
default: return `Role ${code}`;
}
};
export const getLastLoginValue = (lastLogin: any): string => {
if (!lastLogin) return 'Never';
try {
const date = new Date(lastLogin);
if (isNaN(date.getTime())) return 'Invalid Date';
return date.toLocaleString();
} catch {
return 'Invalid Date';
}
};
@@ -1,47 +0,0 @@
.controls {
margin-top: 4px;
}
.input-group {
display: flex;
flex-direction: row;
background: #222531;
padding: 0 20px;
border-radius: 4px;
}
.time {
color: #fff;
}
.video-slider {
margin: 12px 26px;
}
.MuiSlider-root {
color: #fff !important;
}
.buttons-group {
display: flex;
flex-direction: row;
gap: 10px;
margin-top: 6px;
}
.control-btn {
width: 40px;
height: 40px;
display: flex;
justify-content: center;
align-items: center;
background: #222531;
padding: 4px;
border: 0;
border-radius: 4px;
cursor: pointer;
}
.control-btn:hover {
background: #535b77;
}
@@ -1,112 +0,0 @@
import { Slider } from '@mui/material';
import './AnnotationControls.css';
import PreviousIcon from '../../icons/PreviousIcon';
import PlayIcon from '../../icons/PlayIcon';
import PauseIcon from '../../icons/PauseIcon';
import NextIcon from '../../icons/NextIcon';
import StopIcon from '../../icons/StopIcon';
import SaveIcon from '../../icons/SaveIcon';
import CleanIcon from '../../icons/CleanIcon';
import DeleteIcon from '../../icons/DeleteIcon';
function AnnotationControls({
videoRef,
currentTime,
setCurrentTime,
onFrameBackward,
onPlayPause, isPlaying,
onFrameForward,
onSaveAnnotation,
onStop,
onDelete,
onDeleteAll
}) {
function formatDuration(value) {
if (Number.isNaN(value)) {
return '0:00'
}
const minute = Math.floor(value / 60);
const secondLeft = Math.floor(value - minute * 60);
return `${minute}:${secondLeft < 10 ? `0${secondLeft}` : secondLeft}`;
}
const handleSliderChange = (e) => {
setCurrentTime(e.target.value);
}
return (
<div className='controls'>
<div className='input-group'>
<p className='time'>{formatDuration(currentTime)}</p>
<Slider
aria-label='time-indicator'
value={currentTime}
onChange={handleSliderChange}
min={0}
max={videoRef.current === null ? 1 : videoRef.current.duration}
step={0.1}
className='video-slider'
/>
{videoRef.current !== null
? <p className='time'>{formatDuration(videoRef.current.duration - currentTime)}</p>
: <p className='time'>{formatDuration(0)}</p>
}
</div>
<div className='buttons-group' >
<button
className='control-btn arrow-btn'
onClick={onFrameBackward}
title="Previous Frame"
>
<PreviousIcon />
</button>
<button
className={isPlaying ? 'control-btn pause-btn' : 'control-btn play-btn'}
onClick={onPlayPause}
>
{isPlaying ? <PauseIcon /> : <PlayIcon />}
</button>
<button
className='control-btn arrow-btn'
onClick={onFrameForward}
title="Next Frame"
>
<NextIcon />
</button>
<button
className='control-btn stop-btn'
onClick={onStop}
title='Stop'
>
<StopIcon />
</button>
<button
className='control-btn save-btn'
onClick={onSaveAnnotation}
title='Save'
>
<SaveIcon />
</button>
<button
className='control-btn delete-btn'
onClick={onDelete}
title='Delete'
>
<DeleteIcon />
</button>
<button
className='control-btn clean-btn'
onClick={onDeleteAll}
title='DeleteAll'
>
<CleanIcon />
</button>
</div>
</div>
);
}
export default AnnotationControls;
@@ -1,28 +0,0 @@
.annotation-section {
background: #222531;
border-radius: 4px;
padding: 8px;
height: 80%;
}
.annotation-list {
display: flex;
flex-direction: column;
gap: 4px;
list-style-type: none;
padding: 0;
}
.annotation-list-item {
box-sizing: border-box;
display: flex;
align-items: center;
height: 22px;
background: #858CA2;
padding: 4px;
color: #fff;
font-size: 14px;
line-height: 1;
font-weight: 600;
border-radius: 4px ;
}
@@ -1,18 +0,0 @@
import './AnnotationList.css'
function AnnotationList({ annotations, onAnnotationClick }) {
return (
<div className='annotation-section'>
<h3 className='menu-title'>Annotations</h3>
<ul className='annotation-list'>
{annotations.map((annotation, index) => (
<li className='annotation-list-item' key={index} onClick={() => onAnnotationClick(index)}>
Frame {index + 1} - {annotation.detections.length} objects
</li>
))}
</ul>
</div>
);
}
export default AnnotationList;
@@ -1,55 +0,0 @@
.content-wrapper {
display: flex;
gap: 4px;
height: 100vh;
overflow: hidden;
background: #0D1421;
padding: 4px;
}
.side-menu {
display: flex;
flex-direction: column;
width: 228px;
height: 100vh;
}
.right-menu{
overflow-y: auto;
}
.player-wrapper {
width: calc(100% - 464px);
height: 100%;
display: flex;
flex-direction: column;
}
.error-message {
position: absolute;
background: #ffdddd;
color: #d8000c;
padding: 6px;
margin: 6px;
border-radius: 4px;
}
.player-container {
display: flex;
flex: 1;
flex-direction: column;
position: relative;
min-height: 0;
}
.player-block {
display: flex;
flex: 1;
position: relative;
background: #000;
justify-content: center;
align-items: center;
overflow: hidden;
border-radius: 8px;
border: 1px solid #222531;
}
@@ -1,260 +0,0 @@
import React, { useState, useEffect, useRef } from 'react';
import VideoPlayer from '../VideoPlayer/VideoPlayer';
import AnnotationList from '../AnnotationList/AnnotationList';
import MediaList from '../MediaList/MediaList';
import DetectionClassList from '../DetectionClassList/DetectionClassList';
import CanvasEditor from '../CanvasEditor/CanvasEditor';
import * as AnnotationService from '../../services/AnnotationService';
import AnnotationControls from '../AnnotationControls/AnnotationControls';
import saveAnnotation from '../../services/DataHandler';
import './AnnotationMain.css';
import { detectionTypes } from '../../constants/detectionTypes';
function AnnotationMain() {
const [files, setFiles] = useState([]);
const [selectedFile, setSelectedFile] = useState(null);
const [annotations, setAnnotations] = useState([]);
const [currentTime, setCurrentTime] = useState(0);
const [selectedClass, setSelectedClass] = useState(null);
const [detections, setDetections] = useState([]);
const [selectedDetectionIndices, setSelectedDetectionIndices] = useState([]);
const [isPlaying, setIsPlaying] = useState(false);
const [videoWidth, setVideoWidth] = useState(640);
const [videoHeight, setVideoHeight] = useState(480);
const [errorMessage, setErrorMessage] = useState("");
const [detectionType, setDetectionType] = useState(detectionTypes.day)
const videoRef = useRef(null);
const containerRef = useRef(null);
useEffect(() => {
const initialFiles = [];
setFiles(initialFiles);
}, []);
const handleFileSelect = (file) => {
if (!file) return;
setSelectedFile(file);
setAnnotations([]);
setDetections([]);
setSelectedDetectionIndices([]);
setCurrentTime(0);
setIsPlaying(false);
setErrorMessage("");
};
const handleDropNewFiles = (newFiles) => {
if (!newFiles || newFiles.length === 0) return;
const validFiles = [...newFiles];
setFiles(prevFiles => [...prevFiles, ...validFiles]);
if (!selectedFile && validFiles.length > 0) {
setSelectedFile(validFiles[0]);
}
};
const handleAnnotationSave = () => {
if (!videoRef.current) return;
if (!detections || detections.length === 0) {
setErrorMessage("Please create at least one detection before saving");
return;
}
const safeContainerRef = {
current: {
offsetWidth: videoWidth,
offsetHeight: videoHeight
}
};
const imageData = AnnotationService.createAnnotationImage(
videoRef,
detections,
safeContainerRef,
detectionType
);
if (imageData) {
const newAnnotations = {
time: currentTime,
detections: detections,
imageData: imageData
};
setAnnotations(prevAnnotation => [...prevAnnotation, newAnnotations]);
saveAnnotation(currentTime, detections, imageData);
setErrorMessage("");
}
};
const handleDelete = () => {
if (selectedDetectionIndices.length === 0) {
setErrorMessage("Please select a detection to delete");
return;
}
const newDetections = detections.filter((_, index) => !selectedDetectionIndices.includes(index));
setDetections(newDetections);
setSelectedDetectionIndices([]);
setErrorMessage("");
};
const handleDeleteAll = () => {
setDetections([]);
}
const handleAnnotationClick = (index) => {
const annotation = annotations[index];
if (annotation) {
setCurrentTime(annotation.time);
setDetections(annotation.detections || []);
setSelectedDetectionIndices([]);
}
if (videoRef.current) {
videoRef.current.currentTime = annotation.time;
}
setIsPlaying(false);
};
const handleClassSelect = (cls) => {
setSelectedClass(cls);
};
const handleDetectionsChange = (newDetections) => {
setDetections(newDetections);
};
const handleSelectionChange = (newSelection) => {
setSelectedDetectionIndices(newSelection);
};
const handlePlayPause = () => {
setIsPlaying(prev => !prev);
};
const handleStop = () => {
setIsPlaying(false);
setCurrentTime(0);
};
const handleFrameForward = () => {
if (videoRef.current) {
videoRef.current.currentTime += 1 / 30;
setCurrentTime(videoRef.current.currentTime);
}
};
const handleFrameBackward = () => {
if (videoRef.current) {
videoRef.current.currentTime -= 1 / 30;
setCurrentTime(videoRef.current.currentTime);
}
};
const handleSizeChanged = (width, height) => {
setVideoWidth(width);
setVideoHeight(height);
};
const handleSetCurrentTime = (time) => {
setCurrentTime(time);
};
// Toggle debug mode with Ctrl+D
useEffect(() => {
const handleKeyDown = (e) => {
switch (e.key) {
case 'Space':
// Handle space key if needed
break;
default:
// Handle other keys if needed
break;
}
if (e.ctrlKey && e.key === 'd') {
e.preventDefault();
}
};
window.addEventListener('keydown', handleKeyDown);
}, []);
return (
<div className='content-wrapper' >
<div className='side-menu left-menu' >
<MediaList
files={files}
selectedFile={selectedFile}
onFileSelect={handleFileSelect}
onDropNewFiles={handleDropNewFiles}
/>
<DetectionClassList
onClassSelect={handleClassSelect}
detectionType={detectionType}
setDetectionType={setDetectionType}
/>
</div>
<div className='player-wrapper' >
{errorMessage && (
<div className='error-message' >
{errorMessage}
</div>
)}
<div className='player-container' ref={containerRef}>
<div className='player-block' >
<VideoPlayer
videoFile={selectedFile}
currentTime={currentTime}
videoRef={videoRef}
isPlaying={isPlaying}
onSizeChanged={handleSizeChanged}
onSetCurrentTime={handleSetCurrentTime}
>
<CanvasEditor
width={videoWidth}
height={videoHeight}
detections={detections}
selectedDetectionIndices={selectedDetectionIndices}
onDetectionsChange={handleDetectionsChange}
onSelectionChange={handleSelectionChange}
detectionClass={selectedClass}
detectionType={detectionType}
/>
</VideoPlayer>
</div>
<AnnotationControls
videoRef={videoRef}
currentTime={currentTime}
setCurrentTime={setCurrentTime}
onFrameBackward={handleFrameBackward}
onPlayPause={handlePlayPause}
isPlaying={isPlaying}
onFrameForward={handleFrameForward}
onSaveAnnotation={handleAnnotationSave}
onStop={handleStop}
onDelete={handleDelete}
onDeleteAll={handleDeleteAll}
/>
</div>
</div>
<div className='side-menu right-menu'>
<AnnotationList
annotations={annotations}
onAnnotationClick={handleAnnotationClick}
/>
</div>
</div>
);
}
export default AnnotationMain;
@@ -1,15 +0,0 @@
.editor-container {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
pointer-events: auto;
}
.canvas-editor {
position: absolute;
width: 100%;
height: 100%;
pointer-events: auto;
}
-269
View File
@@ -1,269 +0,0 @@
import React, { useRef, useState, useEffect, useCallback } from 'react';
import * as AnnotationService from '../../services/AnnotationService';
import DetectionContainer from '../DetectionContainer';
import './CanvasEditor.css';
function CanvasEditor({
width,
height,
detections,
initialCurrentDetection = null,
selectedDetectionIndices,
onDetectionsChange,
onSelectionChange,
children,
detectionClass,
detectionType
}) {
const containerRef = useRef(null);
const [currentDetection, setCurrentDetection] = useState(initialCurrentDetection);
const [mouseDownPos, setMouseDownPos] = useState(null);
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
const [resizeData, setResizeData] = useState(null);
const [localDetections, setLocalDetections] = useState(detections || []);
const [localSelectedIndices, setLocalSelectedIndices] = useState(selectedDetectionIndices || []);
const [, setDimensions] = useState({ width: width || 640, height: height || 480 });
// Track if we're in a dragging operation
const [isDragging, setIsDragging] = useState(false);
useEffect(() => {
if (width && height) {
setDimensions({ width, height });
}
}, [width, height]);
useEffect(() => {
setLocalDetections(detections || []);
}, [detections]);
useEffect(() => {
setLocalSelectedIndices(selectedDetectionIndices || []);
}, [selectedDetectionIndices]);
const handleMouseDown = (e) => {
e.preventDefault();
if (!containerRef.current) return;
const { x: mouseX, y: mouseY } = AnnotationService.calculateRelativeCoordinates(e, containerRef);
setMouseDownPos({ mouseX, mouseY });
let detectionFound = false;
for (let i = localDetections.length - 1; i >= 0; i--) {
if (AnnotationService.isMouseOverDetection(e.clientX, e.clientY, localDetections[i], containerRef)) {
if (e.ctrlKey) {
const newSelectedIndices = localSelectedIndices.includes(i)
? localSelectedIndices.filter(index => index !== i)
: [...localSelectedIndices, i];
setLocalSelectedIndices(newSelectedIndices);
if (onSelectionChange) {
onSelectionChange(newSelectedIndices);
}
} else {
const newSelectedIndices = [i];
setLocalSelectedIndices(newSelectedIndices);
if (onSelectionChange) {
onSelectionChange(newSelectedIndices);
}
}
setDragOffset({
x: mouseX - localDetections[i].x1,
y: mouseY - localDetections[i].y1,
});
setIsDragging(true);
detectionFound = true;
break;
}
}
if (!detectionFound) {
if (!e.ctrlKey) {
setLocalSelectedIndices([]);
if (onSelectionChange) {
onSelectionChange([]);
}
}
if (detectionClass) {
setCurrentDetection({ x1: mouseX, y1: mouseY, x2: mouseX, y2: mouseY, class: detectionClass });
}
}
};
const handleMouseMove = useCallback((e) => {
if (!containerRef.current) return;
const { x: mouseX, y: mouseY } = AnnotationService.calculateRelativeCoordinates(e, containerRef);
if (localSelectedIndices.length > 0 && mouseDownPos && !resizeData) {
// Dragging logic
setIsDragging(true);
const newDetections = [...localDetections];
const firstSelectedIndex = localSelectedIndices[0];
if (firstSelectedIndex === undefined || !newDetections[firstSelectedIndex]) return;
const firstSelectedDetection = newDetections[firstSelectedIndex];
const { newX1, newY1 } = AnnotationService.calculateNewPosition(mouseX, mouseY, dragOffset, firstSelectedDetection, containerRef);
const deltaX = newX1 - firstSelectedDetection.x1;
const deltaY = newY1 - firstSelectedDetection.y1;
localSelectedIndices.forEach(index => {
if (newDetections[index] === undefined) return;
const detection = newDetections[index];
let updatedX1 = detection.x1 + deltaX;
let updatedY1 = detection.y1 + deltaY;
let updatedX2 = detection.x2 + deltaX;
let updatedY2 = detection.y2 + deltaY;
const bounds = AnnotationService.calculateNewPosition(updatedX1 + dragOffset.x, updatedY1 + dragOffset.y, dragOffset, { ...detection, x1: updatedX1, y1: updatedY1, x2: updatedX2, y2: updatedY2 }, containerRef);
detection.x1 = bounds.newX1;
detection.y1 = bounds.newY1;
detection.x2 = bounds.newX2;
detection.y2 = bounds.newY2;
});
setLocalDetections(newDetections);
if (onDetectionsChange) {
onDetectionsChange(newDetections);
}
} else if (currentDetection && !resizeData) {
setCurrentDetection(prev => ({ ...prev, x2: mouseX, y2: mouseY }));
} else if (resizeData) {
setIsDragging(true);
const { index, position } = resizeData;
if (localDetections[index] === undefined) return;
const newDetections = [...localDetections];
const detection = { ...newDetections[index] };
const updatedDetection = AnnotationService.calculateResizedPosition(mouseX, mouseY, position, detection, containerRef);
newDetections[index] = updatedDetection;
setLocalDetections(newDetections);
if (onDetectionsChange) {
onDetectionsChange(newDetections);
}
}
}, [localSelectedIndices, mouseDownPos, resizeData, localDetections, containerRef, onDetectionsChange]);
const handleMouseUp = useCallback((e) => {
// If we're dragging (or resizing), stop propagation to prevent other elements from reacting
if (isDragging || resizeData) {
e.stopPropagation();
}
if (currentDetection && mouseDownPos) {
const dx = Math.abs(currentDetection.x2 - currentDetection.x1);
const dy = Math.abs(currentDetection.y2 - currentDetection.y1);
if (dx > 5 && dy > 5) {
// Normalize coordinates so x1,y1 is always top-left and x2,y2 is bottom-right
const normalizedDetection = {
...currentDetection,
x1: Math.min(currentDetection.x1, currentDetection.x2),
y1: Math.min(currentDetection.y1, currentDetection.y2),
x2: Math.max(currentDetection.x1, currentDetection.x2),
y2: Math.max(currentDetection.y1, currentDetection.y2),
kw: width / containerRef.current.offsetWidth,
kh: height / containerRef.current.offsetHeight
};
const newDetections = [...localDetections, normalizedDetection];
setLocalDetections(newDetections);
if (onDetectionsChange) {
onDetectionsChange(newDetections);
}
}
}
setCurrentDetection(null);
setMouseDownPos(null);
setDragOffset({ x: 0, y: 0 });
setResizeData(null);
setIsDragging(false);
}, [isDragging, resizeData, localSelectedIndices, localDetections, onDetectionsChange, onSelectionChange]);
const handleDetectionMouseDown = (e, index) => {
e.stopPropagation();
if (!localSelectedIndices.includes(index)) {
if (!e.ctrlKey) {
const newSelectedIndices = [index];
setLocalSelectedIndices(newSelectedIndices);
onSelectionChange && onSelectionChange(newSelectedIndices);
} else {
const newSelectedIndices = [...localSelectedIndices, index];
setLocalSelectedIndices(newSelectedIndices);
onSelectionChange && onSelectionChange(newSelectedIndices);
}
}
const { x: mouseX, y: mouseY } = AnnotationService.calculateRelativeCoordinates(e, containerRef);
setDragOffset({
x: mouseX - localDetections[index].x1,
y: mouseY - localDetections[index].y1,
});
setMouseDownPos({ x: mouseX, y: mouseY });
setIsDragging(true);
};
const handleResize = (e, index, position) => {
e.stopPropagation();
setResizeData({ index, position });
if (!localSelectedIndices.includes(index)) {
if (!e.ctrlKey) {
setLocalSelectedIndices([index]);
onSelectionChange && onSelectionChange([index]);
}
else {
const newSelectedIndices = [...localSelectedIndices, index];
setLocalSelectedIndices(newSelectedIndices);
onSelectionChange && onSelectionChange(newSelectedIndices);
}
}
setIsDragging(true);
};
// Add a document-level mouse move and up handler for dragging outside container
useEffect(() => {
if (isDragging || resizeData) {
const handleDocumentMouseMove = (e) => {
handleMouseMove(e);
};
const handleDocumentMouseUp = (e) => {
handleMouseUp(e);
};
document.addEventListener('mousemove', handleDocumentMouseMove);
document.addEventListener('mouseup', handleDocumentMouseUp);
return () => {
document.removeEventListener('mousemove', handleDocumentMouseMove);
document.removeEventListener('mouseup', handleDocumentMouseUp);
};
}
}, [isDragging, resizeData, mouseDownPos, handleMouseMove, handleMouseUp]);
return (
<div className='editor-container' >
<div className='canvas-editor'
ref={containerRef}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
{children}
<DetectionContainer
detections={localDetections}
selectedDetectionIndices={localSelectedIndices}
onDetectionMouseDown={handleDetectionMouseDown}
currentDetection={currentDetection}
onResize={handleResize}
detectionType={detectionType}
/>
</div>
</div>
);
}
export default CanvasEditor;
+47
View File
@@ -0,0 +1,47 @@
import { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
interface Props {
open: boolean
title: string
message?: string
onConfirm: () => void
onCancel: () => void
}
export default function ConfirmDialog({ open, title, message, onConfirm, onCancel }: Props) {
const { t } = useTranslation()
const cancelRef = useRef<HTMLButtonElement>(null)
useEffect(() => {
if (open) cancelRef.current?.focus()
}, [open])
useEffect(() => {
if (!open) return
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onCancel()
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [open, onCancel])
if (!open) return null
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-[100]">
<div className="bg-az-panel border border-az-border rounded-lg p-4 w-80 shadow-xl">
<h3 className="text-white font-semibold mb-2">{title}</h3>
{message && <p className="text-az-text text-sm mb-4">{message}</p>}
<div className="flex justify-end gap-2">
<button ref={cancelRef} onClick={onCancel} className="px-3 py-1 text-sm border border-az-border rounded hover:bg-az-bg text-az-text">
{t('common.cancel')}
</button>
<button onClick={onConfirm} className="px-3 py-1 text-sm bg-az-red rounded hover:bg-red-600 text-white">
{t('common.confirm')}
</button>
</div>
</div>
</div>
)
}
-97
View File
@@ -1,97 +0,0 @@
import React from 'react';
import { detectionTypes } from '../constants/detectionTypes';
function Detection({ detection, isSelected, onDetectionMouseDown, onResize, detectionType }) {
if (!detection || !detection.class) {
return null;
}
const { Color: color } = detection.class;
if (!color) {
console.error("Color is undefined for detection class:", detection.class);
return null;
}
// Use startsWith to correctly handle RGBA and hex colors
const borderColor = color.startsWith('rgba')
? color.replace(/rgba\((\d+),\s*(\d+),\s*(\d+),\s*[\d.]+\)/, 'rgba($1, $2, $3, 1)')
: color;
const resizeHandleSize = 8;
const resizeHandles = [
{ position: 'top-left', cursor: 'nwse-resize', x: -resizeHandleSize, y: -resizeHandleSize, },
{ position: 'top-right', cursor: 'nesw-resize', x: detection.x2 - detection.x1 , y: -resizeHandleSize,},
{ position: 'bottom-left', cursor: 'nesw-resize', x: -resizeHandleSize, y: detection.y2 - detection.y1, },
{ position: 'bottom-right', cursor: 'nwse-resize', x: detection.x2 - detection.x1, y: detection.y2 - detection.y1 , },
{ position: 'top-middle', cursor: 'ns-resize', x: (detection.x2 - detection.x1) / 2 - resizeHandleSize / 2, y: -resizeHandleSize },
{ position: 'bottom-middle', cursor: 'ns-resize', x: (detection.x2 - detection.x1) / 2 - resizeHandleSize / 2, y: detection.y2 - detection.y1 },
{ position: 'left-middle', cursor: 'ew-resize', x: -resizeHandleSize, y: (detection.y2 - detection.y1) / 2 - resizeHandleSize / 2 },
{ position: 'right-middle', cursor: 'ew-resize', x: detection.x2 - detection.x1, y: (detection.y2 - detection.y1) / 2 - resizeHandleSize / 2 },
];
const style = {
position: 'absolute',
left: `${detection.x1}px`,
top: `${detection.y1}px`,
width: `${detection.x2 - detection.x1}px`,
height: `${detection.y2 - detection.y1}px`,
border: `2px solid ${borderColor}`,
boxSizing: 'border-box',
cursor: isSelected ? 'move' : 'default',
pointerEvents: 'auto',
zIndex: isSelected ? 2 : 1,
};
if (isSelected) {
style.border = `3px solid black`;
style.boxShadow = `0 0 4px 4px ${borderColor}`;
}
const handleMouseDown = (e) => {
e.stopPropagation();
onDetectionMouseDown(e);
};
const handleResizeMouseDown = (e, position) => {
e.stopPropagation();
e.preventDefault();
onResize(e, position);
};
return (
<div style={style} onMouseDown={handleMouseDown}>
{isSelected && resizeHandles.map((handle) => (
<div
key={handle.position}
style={{
position: 'absolute',
left: `${handle.x}px`,
top: `${handle.y}px`,
width: `${resizeHandleSize}px`,
height: `${resizeHandleSize}px`,
backgroundColor: 'black',
cursor: handle.cursor,
pointerEvents: 'auto',
zIndex: 3,
}}
onMouseDown={(e) => handleResizeMouseDown(e, handle.position)}
/>
))}
<span style={{
color: 'white',
fontSize: '12px',
position: "absolute",
top: "-18px",
left: "0px",
textShadow: '1px 1px 2px black',
pointerEvents: 'none'
}}>
{detection.class.Name} {detectionType !== detectionTypes.day && '(' + detectionType + ')'}
</span>
</div>
);
}
export default Detection;
@@ -1,80 +0,0 @@
.detection {
margin-top: 4px;
}
.class-list {
display: flex;
flex-direction: column;
background: #858CA2;
border-radius: 4px;
padding: 4px;
height: 48vh;
}
.menu-title {
margin-bottom: 6px;
}
.class-list-group {
display: flex;
flex-direction: column;
gap: 3px;
padding: 0;
margin: 0;
overflow: auto;
scrollbar-width: none;
list-style-type: none;
}
.class-list-group::-webkit-scrollbar {
display: none;
}
.class-list-item {
display: flex;
align-items: center;
height: 30px;
cursor: pointer;
padding: 8px;
font-size: 14px;
font-weight: 500;
border-radius: 4px;
}
.detection-type-group {
background: #222531;
display: flex;
flex-direction: row;
justify-content: space-between;
padding: 16px 9px;
margin-top: 4px;
border-radius: 4px;
}
.detection-type-btn {
width: 66px;
height: 40px;
display: flex;
justify-content: center;
align-items: center;
background: #3862fb41;
color: #3861FB;
font-size: 30px;
padding: 5px 17px;
border-radius: 4px;
border: 0;
}
.detection-type-btn:hover {
background: #0e2060;
}
.active-type {
color: white;
background: #3861FB;
}
.active-type:hover {
cursor: default;
background: #3861FB;
}
@@ -1,147 +0,0 @@
import React, { useEffect, useState } from 'react';
import DetectionClass from '../../models/DetectionClass';
import './DetectionClassList.css';
import { MdOutlineNightlightRound, MdOutlineWbSunny } from "react-icons/md";
import { FaRegSnowflake } from 'react-icons/fa';
import { detectionTypes } from '../../constants/detectionTypes';
function DetectionClassList({ onClassSelect, detectionType, setDetectionType }) {
const [detectionClasses, setDetectionClasses] = useState([]);
const [selectedClass, setSelectedClass] = useState(null);
const colors = [
"#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF", "#00FFFF", "#000000",
"#800000", "#008000", "#000080", "#808000", "#800080", "#008080", "#808080",
"#C00000", "#00C000", "#0000C0", "#C0C000", "#C000C0", "#00C0C0", "#C0C0C0",
"#400000", "#004000", "#000040", "#404000", "#400040", "#004040", "#404040",
"#200000", "#002000", "#000020", "#202000", "#200020", "#002020", "#202020",
"#600000", "#006000", "#000060", "#606000", "#600060", "#006060", "#606060",
"#A00000", "#00A000", "#0000A0", "#A0A000", "#A000A0", "#00A0A0", "#A0A0A0",
"#E00000", "#00E000", "#0000E0", "#E0E000", "#E000E0", "#00E0E0", "#E0E0E0"
];
const calculateColor = (id, opacity = '0.2') => {
const hexColor = colors[id % colors.length];
const r = parseInt(hexColor.slice(1, 3), 16);
const g = parseInt(hexColor.slice(3, 5), 16);
const b = parseInt(hexColor.slice(5, 7), 16);
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
};
useEffect(() => {
const defaultClasses = [
{ Id: 0, Name: "Car" },
{ Id: 1, Name: "Person" },
{ Id: 2, Name: "Truck" },
{ Id: 3, Name: "Bicycle" },
{ Id: 4, Name: "Motorcycle" },
{ Id: 5, Name: "Bus" }
];
try {
fetch('config.json')
.then(response => response.json())
.then(data => {
const classes = data.classes.map(cls => {
const color = calculateColor(cls.Id, '1');
return new DetectionClass(cls.Id, cls.Name, color);
});
setDetectionClasses(classes);
if (classes.length > 0 && onClassSelect && !selectedClass) {
setSelectedClass(classes[0]);
onClassSelect(classes[0]);
}
})
.catch(error => {
console.warn("Using default classes");
const classes = defaultClasses.map(cls => {
const color = calculateColor(cls.Id, '1');
return new DetectionClass(cls.Id, cls.Name, color);
});
setDetectionClasses(classes);
if (classes.length > 0 && onClassSelect && !selectedClass) {
setSelectedClass(classes[0]);
onClassSelect(classes[0]);
}
});
} catch (error) {
console.warn("Using default classes");
const classes = defaultClasses.map(cls => {
const color = calculateColor(cls.Id, '1');
return new DetectionClass(cls.Id, cls.Name, color);
});
setDetectionClasses(classes);
if (classes.length > 0 && onClassSelect && !selectedClass) {
setSelectedClass(classes[0]);
onClassSelect(classes[0]);
}
}
}, []);
const handleClassClick = (cls) => {
setSelectedClass(cls);
onClassSelect && onClassSelect(cls);
};
const handleTypeClick = (type) => {
setDetectionType(type);
}
return (
<div className='detection'>
<div className='class-list'>
<h3 className='menu-title'>Classes</h3>
<ul className='class-list-group' >
{detectionClasses.map((cls) => {
const backgroundColor = calculateColor(cls.Id);
const darkBg = calculateColor(cls.Id, '0.8');
const isSelected = selectedClass && selectedClass.Id === cls.Id;
return (
<li
key={cls.Id}
className='class-list-item'
style={{
border: `1px solid ${isSelected ? '#000' : '#eee0'}`,
backgroundColor: isSelected ? darkBg : backgroundColor,
}}
onClick={() => handleClassClick(cls)}
>
{cls.Name}
</li>
);
})}
</ul>
</div>
<div className='detection-type-group'>
<button className={detectionType == detectionTypes.day
? 'detection-type-btn active-type'
: 'detection-type-btn'} title='День'
onClick={() => handleTypeClick(detectionTypes.day)}>
<MdOutlineWbSunny />
</button>
<button className={detectionType == detectionTypes.night
? 'detection-type-btn active-type'
: 'detection-type-btn'} title='Ніч'
onClick={() => handleTypeClick(detectionTypes.night)}>
<MdOutlineNightlightRound />
</button>
<button className={detectionType == detectionTypes.winter
? 'detection-type-btn active-type'
: 'detection-type-btn'} title='Зима'
onClick={() => handleTypeClick(detectionTypes.winter)}>
<FaRegSnowflake />
</button>
</div>
</div>
);
}
export default DetectionClassList;
+72
View File
@@ -0,0 +1,72 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { api } from '../api/client'
import type { DetectionClass } from '../types'
interface Props {
selectedClassNum: number
onSelect: (classNum: number) => void
photoMode: number
onPhotoModeChange: (mode: number) => void
}
export default function DetectionClasses({ selectedClassNum, onSelect, photoMode, onPhotoModeChange }: Props) {
const { t } = useTranslation()
const [classes, setClasses] = useState<DetectionClass[]>([])
useEffect(() => {
api.get<DetectionClass[]>('/api/annotations/classes').then(setClasses).catch(() => {})
}, [])
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const num = parseInt(e.key)
if (num >= 1 && num <= 9) {
const idx = num - 1
const cls = classes[idx + photoMode]
if (cls) onSelect(cls.id)
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [classes, photoMode, onSelect])
const modes = [
{ value: 0, label: t('annotations.regular') },
{ value: 20, label: t('annotations.winter') },
{ value: 40, label: t('annotations.night') },
]
return (
<div className="border-t border-az-border p-2">
<div className="text-xs text-az-muted mb-1 font-semibold">{t('annotations.classes')}</div>
<div className="flex gap-1 mb-2">
{modes.map(m => (
<button
key={m.value}
onClick={() => onPhotoModeChange(m.value)}
className={`text-xs px-2 py-0.5 rounded ${photoMode === m.value ? 'bg-az-orange text-white' : 'bg-az-bg text-az-muted'}`}
>
{m.label}
</button>
))}
</div>
<div className="space-y-0.5 max-h-48 overflow-y-auto">
{classes.filter(c => c.photoMode === photoMode).map((c, i) => (
<button
key={c.id}
onClick={() => onSelect(c.id)}
className={`w-full flex items-center gap-1.5 px-1.5 py-0.5 rounded text-xs text-left ${
selectedClassNum === c.id ? 'bg-az-border text-white' : 'text-az-text hover:bg-az-bg'
}`}
>
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: c.color }} />
<span className="text-az-muted">{i + 1}.</span>
<span className="truncate">{c.name}</span>
<span className="text-az-muted ml-auto">{c.shortName}</span>
</button>
))}
</div>
</div>
)
}
-32
View File
@@ -1,32 +0,0 @@
// src/components/DetectionContainer.js
import React from 'react';
import Detection from './Detection';
function DetectionContainer({ detections, selectedDetectionIndices, onDetectionMouseDown, currentDetection, onResize, detectionType }) {
return (
<>
{detections.map((detection, index) => (
<Detection
key={index}
detection={detection}
isSelected={selectedDetectionIndices.includes(index)}
onDetectionMouseDown={(e) => onDetectionMouseDown(e, index)}
onResize={(e, position) => onResize(e, index, position)}
detectionType={detectionType}
/>
))}
{currentDetection && (
<Detection
detection={currentDetection}
isSelected={false}
onDetectionMouseDown={() => {}} // No-op handler for the current detection
onResize={() => {}} // No-op handler for the current detection
detectionType={detectionType}
/>
)}
</>
);
}
export default DetectionContainer;
+52
View File
@@ -0,0 +1,52 @@
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react'
import { api } from '../api/client'
import type { Flight, UserSettings } from '../types'
interface FlightState {
flights: Flight[]
selectedFlight: Flight | null
selectFlight: (f: Flight | null) => void
refreshFlights: () => Promise<void>
}
const FlightContext = createContext<FlightState>(null!)
export function useFlight() {
return useContext(FlightContext)
}
export function FlightProvider({ children }: { children: ReactNode }) {
const [flights, setFlights] = useState<Flight[]>([])
const [selectedFlight, setSelectedFlight] = useState<Flight | null>(null)
const refreshFlights = useCallback(async () => {
try {
const data = await api.get<{ items: Flight[] }>('/api/flights?pageSize=1000')
setFlights(data.items ?? [])
} catch {}
}, [])
useEffect(() => {
refreshFlights()
api.get<UserSettings>('/api/annotations/settings/user')
.then(settings => {
if (settings?.selectedFlightId) {
api.get<Flight>(`/api/flights/${settings.selectedFlightId}`)
.then(f => setSelectedFlight(f))
.catch(() => {})
}
})
.catch(() => {})
}, [refreshFlights])
const selectFlight = useCallback((f: Flight | null) => {
setSelectedFlight(f)
api.put('/api/annotations/settings/user', { selectedFlightId: f?.id ?? null }).catch(() => {})
}, [])
return (
<FlightContext.Provider value={{ flights, selectedFlight, selectFlight, refreshFlights }}>
{children}
</FlightContext.Provider>
)
}
+133
View File
@@ -0,0 +1,133 @@
import { NavLink, useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { useAuth } from '../auth/AuthContext'
import { useFlight } from './FlightContext'
import { useState, useRef, useEffect } from 'react'
import HelpModal from './HelpModal'
import type { Flight } from '../types'
export default function Header() {
const { t, i18n } = useTranslation()
const { user, logout, hasPermission } = useAuth()
const { flights, selectedFlight, selectFlight } = useFlight()
const navigate = useNavigate()
const [showDropdown, setShowDropdown] = useState(false)
const [filter, setFilter] = useState('')
const [showHelp, setShowHelp] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const handler = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node))
setShowDropdown(false)
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [])
const filtered = flights.filter(f => f.name.toLowerCase().includes(filter.toLowerCase()))
const handleLogout = async () => {
await logout()
navigate('/login')
}
const navItems = [
{ to: '/flights', label: t('nav.flights'), perm: 'FL' },
{ to: '/annotations', label: t('nav.annotations'), perm: 'ANN' },
{ to: '/dataset', label: t('nav.dataset'), perm: 'DATASET' },
{ to: '/admin', label: t('nav.admin'), perm: 'ADM' },
]
const toggleLang = () => {
i18n.changeLanguage(i18n.language === 'en' ? 'ua' : 'en')
}
return (
<header className="flex items-center h-10 bg-az-header border-b border-az-border px-3 gap-3 text-sm shrink-0">
<span className="font-bold text-az-orange tracking-wider">AZAION</span>
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setShowDropdown(!showDropdown)}
className="bg-az-panel border border-az-border rounded px-2 py-0.5 text-az-text hover:border-az-muted min-w-[160px] text-left truncate"
>
{selectedFlight?.name || '— Select Flight —'}
</button>
{showDropdown && (
<div className="absolute top-full left-0 mt-1 bg-az-panel border border-az-border rounded shadow-lg z-50 w-64">
<input
className="w-full bg-az-bg border-b border-az-border px-2 py-1 text-az-text text-sm outline-none"
placeholder="Filter..."
value={filter}
onChange={e => setFilter(e.target.value)}
autoFocus
/>
<div className="max-h-60 overflow-y-auto">
{filtered.map((f: Flight) => (
<button
key={f.id}
onClick={() => { selectFlight(f); setShowDropdown(false); setFilter('') }}
className={`w-full text-left px-2 py-1 hover:bg-az-bg text-az-text text-sm ${
selectedFlight?.id === f.id ? 'bg-az-bg font-semibold' : ''
}`}
>
<div>{f.name}</div>
<div className="text-xs text-az-muted">{new Date(f.createdDate).toLocaleDateString()}</div>
</button>
))}
{filtered.length === 0 && (
<div className="px-2 py-2 text-az-muted text-xs">No flights</div>
)}
</div>
</div>
)}
</div>
<nav className="hidden sm:flex items-center gap-1 ml-2">
{navItems.filter(n => hasPermission(n.perm)).map(n => (
<NavLink
key={n.to}
to={n.to}
className={({ isActive }) =>
`px-2 py-1 rounded text-sm ${isActive ? 'bg-az-bg font-semibold text-white' : 'text-az-text hover:text-white'}`
}
>
{n.label}
</NavLink>
))}
</nav>
<div className="flex-1" />
<span className="text-xs text-az-muted hidden sm:block">{user?.email}</span>
<button onClick={toggleLang} className="text-xs text-az-muted hover:text-white px-1">
{i18n.language === 'en' ? 'UA' : 'EN'}
</button>
<button onClick={() => setShowHelp(true)} className="text-az-muted hover:text-white text-xs">?</button>
<NavLink to="/settings" className="text-az-muted hover:text-white"></NavLink>
<button onClick={handleLogout} className="text-az-muted hover:text-az-red text-xs">
{t('nav.logout')}
</button>
{/* Mobile bottom nav */}
<nav className="sm:hidden fixed bottom-0 left-0 right-0 bg-az-header border-t border-az-border flex justify-around py-1.5 z-50">
{navItems.filter(n => hasPermission(n.perm)).map(n => (
<NavLink
key={n.to}
to={n.to}
className={({ isActive }) =>
`text-xs px-2 py-1 ${isActive ? 'text-az-orange font-semibold' : 'text-az-muted'}`
}
>
{n.label}
</NavLink>
))}
<NavLink to="/settings" className={({ isActive }) => `text-xs px-2 py-1 ${isActive ? 'text-az-orange' : 'text-az-muted'}`}>
</NavLink>
</nav>
<HelpModal open={showHelp} onClose={() => setShowHelp(false)} />
</header>
)
}
+61
View File
@@ -0,0 +1,61 @@
import { useTranslation } from 'react-i18next'
interface Props {
open: boolean
onClose: () => void
}
const GUIDELINES = [
{ en: 'Draw bounding boxes tightly around the target', ua: 'Малюйте рамки щільно навколо цілі' },
{ en: 'Do not include shadow in the box unless the target is the shadow itself', ua: 'Не включайте тінь у рамку, якщо ціль не є тінню' },
{ en: 'If the target is partially occluded, annotate the visible part', ua: 'Якщо ціль частково перекрита, анотуйте видиму частину' },
{ en: 'Choose the correct class for each detection', ua: 'Обирайте правильний клас для кожної детекції' },
{ en: 'Set the affiliation (Friendly/Hostile/Unknown) for military targets', ua: 'Встановіть приналежність (Свій/Ворожий/Невідомий) для військових цілей' },
{ en: 'Validate annotations before they are used for training', ua: 'Валідуйте анотації перед використанням для навчання' },
]
export default function HelpModal({ open, onClose }: Props) {
const { i18n } = useTranslation()
if (!open) return null
const lang = i18n.language === 'ua' ? 'ua' : 'en'
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-[100]" onClick={onClose}>
<div className="bg-az-panel border border-az-border rounded-lg p-5 w-[500px] max-h-[80vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
<h2 className="text-white font-semibold text-lg mb-4">How to Annotate</h2>
<ol className="space-y-2">
{GUIDELINES.map((g, i) => (
<li key={i} className="flex gap-2 text-sm text-az-text">
<span className="text-az-orange font-semibold shrink-0">{i + 1}.</span>
<span>{g[lang]}</span>
</li>
))}
</ol>
<h3 className="text-white font-semibold mt-5 mb-2">Keyboard Shortcuts</h3>
<div className="grid grid-cols-2 gap-1 text-xs text-az-text">
<span className="text-az-muted">Space</span><span>Play / Pause</span>
<span className="text-az-muted"> </span><span>Frame step</span>
<span className="text-az-muted">Ctrl + </span><span>5 second skip</span>
<span className="text-az-muted">Enter</span><span>Save annotation</span>
<span className="text-az-muted">Delete</span><span>Delete selected</span>
<span className="text-az-muted">X</span><span>Delete all detections</span>
<span className="text-az-muted">1-9</span><span>Select detection class</span>
<span className="text-az-muted">M</span><span>Mute / Unmute</span>
<span className="text-az-muted">Ctrl + Scroll</span><span>Zoom canvas</span>
<span className="text-az-muted">Esc</span><span>Close dialog / editor</span>
<span className="text-az-muted">V</span><span>Validate (Dataset)</span>
<span className="text-az-muted">PageUp/Down</span><span>Navigate media / pages</span>
</div>
<div className="mt-4 flex justify-end">
<button onClick={onClose} className="bg-az-border text-az-text text-xs px-3 py-1 rounded hover:bg-az-muted">
Close
</button>
</div>
</div>
</div>
)
}
-88
View File
@@ -1,88 +0,0 @@
.explorer{
height: 40vh ;
background: #222531;
padding: 8px;
border-radius: 4px;
min-height: 180px;
}
.explorer-head{
display: flex;
flex-direction: row;
justify-content: space-between;
margin-bottom: 6px;
}
.menu-title {
font-size: 18px;
line-height: 20px;
color: white;
margin: 0;
margin-right: 10px;
}
.open-btn{
width: 80px;
height: 20px;
background: #6188FF;
color: white;
border: 0;
border-radius: 4px;
padding: 0;
}
.open-btn:hover{
background: #295cf7;
}
.file-filter{
box-sizing: border-box;
width: 100%;
height: 26px;
background: white;
padding: 6px 12px;
border: 0;
border-radius: 2px;
font-size: 14px;
}
.file-list-group {
display: flex;
flex-direction: column;
gap: 4px;
padding: 0;
margin: 12px 0;
list-style-type: none;
overflow: auto;
scrollbar-width: none;
max-height: 36%;
}
.file-list-group::-webkit-scrollbar {
display: none;
}
.file-list-item {
padding: 7px 6px;
font-size: 12px;
color: white;
cursor: pointer;
border-radius: 2px;
}
.label {
font-size: 12px;
}
.file-input-block {
display: flex;
justify-content: center;
align-items: center;
height: 12%;
color: white;
border: 2px dashed #ccc;
border-radius: 4px;
padding: 8px;
text-align: center;
cursor: pointer;
}
-70
View File
@@ -1,70 +0,0 @@
import React, { useEffect, useState } from 'react';
import { useDropzone } from 'react-dropzone';
import './MediaList.css'
function MediaList({ files, selectedFile, onFileSelect, onDropNewFiles }) {
const { getRootProps, getInputProps, isDragActive, open: openFileDialog } = useDropzone({
onDrop: onDropNewFiles,
multiple: true,
});
const { getRootProps: getFolderRootProps, getInputProps: getFolderInputProps, open: openFolderDialog } = useDropzone({
onDrop: onDropNewFiles,
multiple: true
});
const [filteredFiles, setFilteredFiles] = useState(files);
useEffect(() => {
setFilteredFiles(files);
}, [files])
const handleInputChange = (e) => {
const value = e.target.value;
const filtered = files.filter((file) => file.name.toLowerCase().includes(value.toLowerCase()));
setFilteredFiles(filtered);
}
return (
<div className='explorer'>
<div className='explorer-head'>
<h3 className='menu-title' >Files</h3>
<button className='open-btn' type="button" onClick={openFileDialog}>
Open File
</button>
<button className='open-btn' type="button" onClick={openFolderDialog}>
Open Folder
</button>
</div>
<input className='file-filter' type='text' placeholder='Filename' onChange={handleInputChange} />
<ul className='file-list-group' >
{filteredFiles.map((file) => (
<li
className='file-list-item'
key={file.name}
style={{
backgroundColor: selectedFile === file ? '#474A52' : '#858CA2'
}}
onClick={() => onFileSelect(file)}
>
{file.name}
</li>
))}
</ul>
<div className='file-input-block' {...getRootProps()} >
<input {...getInputProps()} />
<div style={{ display: 'none' }}>
<input {...getFolderInputProps()} webkitdirectory="true" mozdirectory="true" />
</div>
{isDragActive ? (
<p className='label' >Drop here</p>
) : (
<p className='label' >Drag new files</p>
)}
</div>
</div>
);
}
export default MediaList;
@@ -1,40 +0,0 @@
.player {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: #000;
overflow: hidden;
}
.video {
width: 100%;
height: auto;
max-height: 100%;
display: block;
object-fit: contain;
pointer-events: none;
}
.player-error {
position: absolute;
top: 10px;
left: 10px;
background: rgba(255, 0, 0, 0.7);
color: white;
padding: 5px;
border-radius: 3px;
font-size: 12px;
z-index: 10;
}
.player-item{
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: auto;
}
-150
View File
@@ -1,150 +0,0 @@
import React, { useEffect, useRef, useState } from 'react';
import './VideoPlayer.css';
function VideoPlayer({ children, videoFile, currentTime, videoRef, isPlaying, onSizeChanged, onSetCurrentTime }) {
const containerRef = useRef(null);
const [playbackError, setPlaybackError] = useState(null);
const objectUrlRef = useRef(null);
// Flag to track if time update is coming from natural playback
const isPlaybackUpdateRef = useRef(false);
// Set up the video file when it changes
useEffect(() => {
if (!videoFile || !videoRef.current) return;
try {
// Clean up previous object URL
if (objectUrlRef.current) {
URL.revokeObjectURL(objectUrlRef.current);
}
// Create new object URL and set it directly
const objectUrl = URL.createObjectURL(videoFile);
objectUrlRef.current = objectUrl;
// Reset video and set new source
videoRef.current.pause();
videoRef.current.src = objectUrl;
videoRef.current.load();
setPlaybackError(null);
return () => {
URL.revokeObjectURL(objectUrl);
objectUrlRef.current = null;
};
} catch (err) {
console.error("Error loading video:", err);
setPlaybackError(`Error loading video: ${err.message}`);
}
}, [videoFile]);
// Handle metadata loading and size changes
useEffect(() => {
if (!videoRef.current) return;
const handleMetadata = () => {
if (!videoRef.current) return;
const width = videoRef.current.videoWidth || 640;
const height = videoRef.current.videoHeight || 480;
if (onSizeChanged) {
onSizeChanged(width, height);
}
};
videoRef.current.addEventListener('loadedmetadata', handleMetadata);
return () => {
if (videoRef.current) {
videoRef.current.removeEventListener('loadedmetadata', handleMetadata);
}
};
}, [onSizeChanged]);
// Handle play/pause state
useEffect(() => {
if (!videoRef.current) return;
const attemptPlay = async () => {
try {
if (isPlaying) {
isPlaybackUpdateRef.current = true;
await videoRef.current.play();
setPlaybackError(null);
} else {
videoRef.current.pause();
}
} catch (err) {
console.error("Playback error:", err);
setPlaybackError(`Playback error: ${err.message}`);
}
};
attemptPlay();
}, [isPlaying]);
// Handle current time changes
useEffect(() => {
if (!videoRef.current) return;
// Only update the video's time if it's not coming from natural playback
if (!isPlaybackUpdateRef.current) {
try {
if (videoRef.current.readyState > 0) {
videoRef.current.currentTime = currentTime;
}
} catch (err) {
console.warn("Error setting time:", err);
}
} else {
// Reset the flag after receiving the update
isPlaybackUpdateRef.current = false;
}
}, [currentTime]);
// Set up time update events
useEffect(() => {
if (!videoRef.current) return;
const handleTimeUpdate = () => {
if (videoRef.current && onSetCurrentTime && isPlaying) {
isPlaybackUpdateRef.current = true;
onSetCurrentTime(videoRef.current.currentTime);
}
};
const handleSeeked = () => {
if (videoRef.current && onSetCurrentTime) {
onSetCurrentTime(videoRef.current.currentTime);
}
};
videoRef.current.addEventListener('timeupdate', handleTimeUpdate);
videoRef.current.addEventListener('seeked', handleSeeked);
return () => {
if (videoRef.current) {
videoRef.current.removeEventListener('timeupdate', handleTimeUpdate);
videoRef.current.removeEventListener('seeked', handleSeeked);
}
};
}, [onSetCurrentTime, isPlaying]);
return (
<div className='player' ref={containerRef} >
<video className='video' ref={videoRef} preload="auto" playsInline muted />
{playbackError && (
<div className='player-error' >
{playbackError}
</div>
)}
<div className='player-item'>
{children}
</div>
</div>
);
}
export default VideoPlayer;