mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 11:21:10 +00:00
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:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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' }}>
|
||||
— {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;
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
|
||||
@@ -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 => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>'
|
||||
}[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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user