UI redesign

This commit is contained in:
2025-12-13 01:53:24 +05:00
parent 4007445396
commit af5e3b6a3f
23 changed files with 949 additions and 393 deletions

View File

@@ -4,6 +4,8 @@ import { Register } from './components/auth/Register';
import { ProtectedRoute } from './components/auth/ProtectedRoute';
import { Dashboard } from './pages/Dashboard';
import { SharedMap } from './pages/SharedMap';
import { ToastContainer } from './components/common/ToastContainer';
import { ConfirmContainer } from './components/common/ConfirmContainer';
function App() {
return (
@@ -22,6 +24,8 @@ function App() {
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
<ToastContainer />
<ConfirmContainer />
</BrowserRouter>
);
}

View File

@@ -1,11 +1,13 @@
import { useState, FormEvent } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useAuthStore } from '../../stores/authStore';
import { useUIStore } from '../../stores/uiStore';
export function Login() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const { login, isLoading, error, clearError } = useAuthStore();
const { darkMode, toggleDarkMode } = useUIStore();
const navigate = useNavigate();
const handleSubmit = async (e: FormEvent) => {
@@ -21,21 +23,41 @@ export function Login() {
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<div className="bg-white p-8 rounded-lg shadow-md w-full max-w-md">
<h1 className="text-2xl font-bold mb-6 text-center text-gray-800">
ISP Wiremap
</h1>
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-gray-900 dark:to-gray-800 transition-colors">
<div className="absolute top-4 right-4">
<button
onClick={toggleDarkMode}
className="p-3 bg-white dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-full shadow-lg transition-colors"
title={darkMode ? 'Switch to light mode' : 'Switch to dark mode'}
>
<span className="text-2xl">{darkMode ? '☀️' : '🌙'}</span>
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="bg-white dark:bg-gray-800 p-8 rounded-2xl shadow-2xl w-full max-w-md border border-gray-200 dark:border-gray-700 transition-colors">
<div className="text-center mb-8">
<div className="inline-block p-3 bg-blue-100 dark:bg-blue-900/30 rounded-full mb-4">
<svg className="w-12 h-12 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
</svg>
</div>
<h1 className="text-3xl font-bold text-gray-800 dark:text-white">
ISP Wiremap
</h1>
<p className="text-gray-500 dark:text-gray-400 mt-2">
Sign in to your account
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-5">
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-400 px-4 py-3 rounded-lg text-sm">
{error}
</div>
)}
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-1">
<label htmlFor="username" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Username
</label>
<input
@@ -44,13 +66,14 @@ export function Login() {
value={username}
onChange={(e) => setUsername(e.target.value)}
required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 transition-colors"
placeholder="Enter your username"
disabled={isLoading}
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
<label htmlFor="password" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Password
</label>
<input
@@ -59,7 +82,8 @@ export function Login() {
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 transition-colors"
placeholder="Enter your password"
disabled={isLoading}
/>
</div>
@@ -67,15 +91,25 @@ export function Login() {
<button
type="submit"
disabled={isLoading}
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
className="w-full bg-gradient-to-r from-blue-600 to-indigo-600 dark:from-blue-700 dark:to-indigo-700 text-white py-3 px-4 rounded-lg hover:from-blue-700 hover:to-indigo-700 dark:hover:from-blue-600 dark:hover:to-indigo-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 disabled:opacity-50 disabled:cursor-not-allowed font-medium transition-all shadow-lg hover:shadow-xl"
>
{isLoading ? 'Logging in...' : 'Login'}
{isLoading ? (
<span className="flex items-center justify-center">
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Signing in...
</span>
) : (
'Sign In'
)}
</button>
<div className="text-center mt-4">
<span className="text-gray-600">Don't have an account? </span>
<Link to="/register" className="text-blue-600 hover:text-blue-700 font-medium">
Register
<div className="text-center mt-6">
<span className="text-gray-600 dark:text-gray-400">Don't have an account? </span>
<Link to="/register" className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-semibold transition-colors">
Create one
</Link>
</div>
</form>

View File

@@ -1,26 +1,30 @@
import { useState, FormEvent } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useAuthStore } from '../../stores/authStore';
import { useUIStore } from '../../stores/uiStore';
export function Register() {
const [username, setUsername] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [validationError, setValidationError] = useState('');
const { register, isLoading, error, clearError } = useAuthStore();
const { darkMode, toggleDarkMode } = useUIStore();
const navigate = useNavigate();
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
clearError();
setValidationError('');
if (password !== confirmPassword) {
alert('Passwords do not match');
setValidationError('Passwords do not match');
return;
}
if (password.length < 6) {
alert('Password must be at least 6 characters');
setValidationError('Password must be at least 6 characters');
return;
}
@@ -33,24 +37,41 @@ export function Register() {
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<div className="bg-white p-8 rounded-lg shadow-md w-full max-w-md">
<h1 className="text-2xl font-bold mb-2 text-center text-gray-800">
Create Account
</h1>
<p className="text-center text-gray-600 mb-6">
Join ISP Wiremap
</p>
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-gray-900 dark:to-gray-800 transition-colors py-12">
<div className="absolute top-4 right-4">
<button
onClick={toggleDarkMode}
className="p-3 bg-white dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-full shadow-lg transition-colors"
title={darkMode ? 'Switch to light mode' : 'Switch to dark mode'}
>
<span className="text-2xl">{darkMode ? '☀️' : '🌙'}</span>
</button>
</div>
<div className="bg-white dark:bg-gray-800 p-8 rounded-2xl shadow-2xl w-full max-w-md border border-gray-200 dark:border-gray-700 transition-colors">
<div className="text-center mb-8">
<div className="inline-block p-3 bg-blue-100 dark:bg-blue-900/30 rounded-full mb-4">
<svg className="w-12 h-12 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
</svg>
</div>
<h1 className="text-3xl font-bold text-gray-800 dark:text-white">
Create Account
</h1>
<p className="text-gray-500 dark:text-gray-400 mt-2">
Join ISP Wiremap today
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
{error}
{(error || validationError) && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-400 px-4 py-3 rounded-lg text-sm">
{error || validationError}
</div>
)}
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-1">
<label htmlFor="username" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Username
</label>
<input
@@ -60,13 +81,14 @@ export function Register() {
onChange={(e) => setUsername(e.target.value)}
required
minLength={3}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 transition-colors"
placeholder="Choose a username"
disabled={isLoading}
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
<label htmlFor="email" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Email
</label>
<input
@@ -75,13 +97,14 @@ export function Register() {
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 transition-colors"
placeholder="your@email.com"
disabled={isLoading}
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
<label htmlFor="password" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Password
</label>
<input
@@ -91,13 +114,14 @@ export function Register() {
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 transition-colors"
placeholder="At least 6 characters"
disabled={isLoading}
/>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 mb-1">
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Confirm Password
</label>
<input
@@ -107,7 +131,8 @@ export function Register() {
onChange={(e) => setConfirmPassword(e.target.value)}
required
minLength={6}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 transition-colors"
placeholder="Repeat password"
disabled={isLoading}
/>
</div>
@@ -115,15 +140,25 @@ export function Register() {
<button
type="submit"
disabled={isLoading}
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
className="w-full bg-gradient-to-r from-blue-600 to-indigo-600 dark:from-blue-700 dark:to-indigo-700 text-white py-3 px-4 rounded-lg hover:from-blue-700 hover:to-indigo-700 dark:hover:from-blue-600 dark:hover:to-indigo-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 disabled:opacity-50 disabled:cursor-not-allowed font-medium transition-all shadow-lg hover:shadow-xl"
>
{isLoading ? 'Creating account...' : 'Register'}
{isLoading ? (
<span className="flex items-center justify-center">
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Creating account...
</span>
) : (
'Create Account'
)}
</button>
<div className="text-center mt-4">
<span className="text-gray-600">Already have an account? </span>
<Link to="/login" className="text-blue-600 hover:text-blue-700 font-medium">
Login
<div className="text-center mt-6">
<span className="text-gray-600 dark:text-gray-400">Already have an account? </span>
<Link to="/login" className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-semibold transition-colors">
Sign in
</Link>
</div>
</form>

View File

@@ -0,0 +1,20 @@
import { useUIStore } from '../../stores/uiStore';
import { ConfirmDialog } from './ConfirmDialog';
export function ConfirmContainer() {
const { confirm } = useUIStore();
if (!confirm) return null;
return (
<ConfirmDialog
title={confirm.title}
message={confirm.message}
confirmText={confirm.confirmText}
cancelText={confirm.cancelText}
type={confirm.type}
onConfirm={confirm.onConfirm}
onCancel={confirm.onCancel || (() => {})}
/>
);
}

View File

@@ -0,0 +1,54 @@
interface ConfirmDialogProps {
title: string;
message: string;
confirmText?: string;
cancelText?: string;
onConfirm: () => void;
onCancel: () => void;
type?: 'danger' | 'warning' | 'info';
}
export function ConfirmDialog({
title,
message,
confirmText = 'Confirm',
cancelText = 'Cancel',
onConfirm,
onCancel,
type = 'warning',
}: ConfirmDialogProps) {
const confirmColors = {
danger: 'bg-red-600 hover:bg-red-700 dark:bg-red-700 dark:hover:bg-red-800',
warning: 'bg-yellow-600 hover:bg-yellow-700 dark:bg-yellow-700 dark:hover:bg-yellow-800',
info: 'bg-blue-600 hover:bg-blue-700 dark:bg-blue-700 dark:hover:bg-blue-800',
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 dark:bg-opacity-70 flex items-center justify-center z-[10001]">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full mx-4 animate-scale-in">
<div className="p-6">
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-2">
{title}
</h3>
<p className="text-gray-600 dark:text-gray-300 text-sm mb-6">
{message}
</p>
<div className="flex gap-3 justify-end">
<button
onClick={onCancel}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors"
>
{cancelText}
</button>
<button
onClick={onConfirm}
className={`px-4 py-2 text-sm font-medium text-white ${confirmColors[type]} rounded-lg transition-colors`}
>
{confirmText}
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,45 @@
import { useEffect } from 'react';
import type { ToastType } from '../../stores/uiStore';
interface ToastProps {
message: string;
type: ToastType;
onClose: () => void;
duration?: number;
}
export function Toast({ message, type, onClose, duration = 4000 }: ToastProps) {
useEffect(() => {
const timer = setTimeout(onClose, duration);
return () => clearTimeout(timer);
}, [duration, onClose]);
const bgColors = {
success: 'bg-green-500 dark:bg-green-600',
error: 'bg-red-500 dark:bg-red-600',
info: 'bg-blue-500 dark:bg-blue-600',
warning: 'bg-yellow-500 dark:bg-yellow-600',
};
const icons = {
success: '✓',
error: '✕',
info: '',
warning: '⚠',
};
return (
<div
className={`${bgColors[type]} text-white px-6 py-4 rounded-lg shadow-lg flex items-center gap-3 min-w-[300px] max-w-md animate-slide-in`}
>
<div className="text-2xl">{icons[type]}</div>
<div className="flex-1 text-sm font-medium">{message}</div>
<button
onClick={onClose}
className="text-white hover:text-gray-200 text-xl font-bold leading-none"
>
×
</button>
</div>
);
}

View File

@@ -0,0 +1,19 @@
import { useUIStore } from '../../stores/uiStore';
import { Toast } from './Toast';
export function ToastContainer() {
const { toasts, removeToast } = useUIStore();
return (
<div className="fixed top-4 right-4 z-[10002] flex flex-col gap-2">
{toasts.map((toast) => (
<Toast
key={toast.id}
message={toast.message}
type={toast.type}
onClose={() => removeToast(toast.id)}
/>
))}
</div>
);
}

View File

@@ -1,6 +1,7 @@
import { ReactNode } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../../stores/authStore';
import { useUIStore } from '../../stores/uiStore';
interface LayoutProps {
children: ReactNode;
@@ -8,6 +9,7 @@ interface LayoutProps {
export function Layout({ children }: LayoutProps) {
const { user, logout } = useAuthStore();
const { darkMode, toggleDarkMode, showToast } = useUIStore();
const navigate = useNavigate();
const handleLogout = () => {
@@ -15,35 +17,49 @@ export function Layout({ children }: LayoutProps) {
navigate('/login');
};
const handleCopyId = () => {
if (user) {
navigator.clipboard.writeText(user.id);
showToast('User ID copied to clipboard!', 'success');
}
};
return (
<div className="h-screen flex flex-col">
<header className="bg-blue-600 text-white shadow-md">
<div className="h-screen flex flex-col bg-gray-50 dark:bg-gray-900 transition-colors">
<header className="bg-blue-600 dark:bg-gray-800 text-white shadow-md">
<div className="px-4 py-3 flex items-center justify-between">
<h1 className="text-xl font-bold">ISP Wiremap</h1>
{user && (
<div className="flex items-center gap-4">
<div className="flex items-center gap-3">
<span className="text-sm">
{user.username}
{user.is_admin && (
<span className="ml-2 px-2 py-0.5 bg-blue-500 rounded text-xs">
<span className="ml-2 px-2 py-0.5 bg-blue-500 dark:bg-blue-600 rounded text-xs">
Admin
</span>
)}
</span>
<button
onClick={() => {
navigator.clipboard.writeText(user.id);
alert('Your User ID has been copied to clipboard!');
}}
className="px-2 py-1 bg-blue-700 hover:bg-blue-800 rounded text-xs"
onClick={toggleDarkMode}
className="p-2 bg-blue-700 dark:bg-gray-700 hover:bg-blue-800 dark:hover:bg-gray-600 rounded-lg transition-colors"
title={darkMode ? 'Switch to light mode' : 'Switch to dark mode'}
>
{darkMode ? '☀️' : '🌙'}
</button>
<button
onClick={handleCopyId}
className="px-3 py-1.5 bg-blue-700 dark:bg-gray-700 hover:bg-blue-800 dark:hover:bg-gray-600 rounded-lg text-xs transition-colors"
title="Copy your User ID for sharing"
>
Copy My ID
</button>
<button
onClick={handleLogout}
className="px-3 py-1 bg-blue-700 hover:bg-blue-800 rounded text-sm"
className="px-3 py-1.5 bg-blue-700 dark:bg-gray-700 hover:bg-blue-800 dark:hover:bg-gray-600 rounded-lg text-sm transition-colors"
>
Logout
</button>

View File

@@ -1,5 +1,6 @@
import { useState, useEffect, useRef } from 'react';
import { mapItemService } from '../../services/mapItemService';
import { useUIStore } from '../../stores/uiStore';
interface ItemContextMenuProps {
item: any;
@@ -9,15 +10,16 @@ interface ItemContextMenuProps {
}
export function ItemContextMenu({ item, position, onClose, onUpdate }: ItemContextMenuProps) {
const { showToast } = useUIStore();
const [showRenameDialog, setShowRenameDialog] = useState(false);
const [showNotesDialog, setShowNotesDialog] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [showPortConfigDialog, setShowPortConfigDialog] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [deleteConnectedCables, setDeleteConnectedCables] = useState(false);
const [newName, setNewName] = useState(item.properties.name || '');
const [notes, setNotes] = useState(item.properties.notes || '');
const [imageData, setImageData] = useState<string | null>(item.properties.image || null);
const [portCount, setPortCount] = useState(item.properties.port_count || 5);
const [deleteConnectedCables, setDeleteConnectedCables] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -72,11 +74,12 @@ export function ItemContextMenu({ item, position, onClose, onUpdate }: ItemConte
onUpdate();
onClose();
showToast('Item deleted successfully', 'success');
console.log('Delete process completed successfully');
} catch (error) {
console.error('Failed to delete item:', error);
console.error('Error details:', { error, item_id: item.id, map_id: item.map_id });
alert('Failed to delete item');
showToast('Failed to delete item', 'error');
}
};
@@ -91,9 +94,10 @@ export function ItemContextMenu({ item, position, onClose, onUpdate }: ItemConte
onUpdate();
setShowRenameDialog(false);
onClose();
showToast('Item renamed successfully', 'success');
} catch (error) {
console.error('Failed to rename item:', error);
alert('Failed to rename item');
showToast('Failed to rename item', 'error');
}
};
@@ -103,13 +107,13 @@ export function ItemContextMenu({ item, position, onClose, onUpdate }: ItemConte
// Check file size (max 2MB)
if (file.size > 2 * 1024 * 1024) {
alert('Image too large. Please use an image smaller than 2MB.');
showToast('Image too large. Please use an image smaller than 2MB.', 'error');
return;
}
// Check file type
if (!file.type.startsWith('image/')) {
alert('Please select an image file.');
showToast('Please select an image file.', 'error');
return;
}
@@ -139,9 +143,10 @@ export function ItemContextMenu({ item, position, onClose, onUpdate }: ItemConte
onUpdate();
setShowNotesDialog(false);
onClose();
showToast('Notes saved successfully', 'success');
} catch (error) {
console.error('Failed to save notes:', error);
alert('Failed to save notes');
showToast('Failed to save notes', 'error');
}
};
@@ -156,9 +161,10 @@ export function ItemContextMenu({ item, position, onClose, onUpdate }: ItemConte
onUpdate();
setShowPortConfigDialog(false);
onClose();
showToast('Port configuration saved', 'success');
} catch (error) {
console.error('Failed to save port configuration:', error);
alert('Failed to save port configuration');
showToast('Failed to save port configuration', 'error');
}
};
@@ -166,11 +172,11 @@ export function ItemContextMenu({ item, position, onClose, onUpdate }: ItemConte
return (
<div
ref={menuRef}
className="fixed bg-white rounded-lg shadow-xl border border-gray-200 p-4"
className="fixed bg-white dark:bg-gray-800 rounded-lg shadow-xl border border-gray-200 dark:border-gray-700 p-4 transition-colors"
style={{ left: position.x, top: position.y, zIndex: 10000, minWidth: '250px' }}
>
<h3 className="font-semibold mb-2">Configure Ports</h3>
<label className="block text-sm text-gray-600 mb-2">
<h3 className="font-semibold mb-2 text-gray-800 dark:text-white">Configure Ports</h3>
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-2">
Total number of ports:
</label>
<input
@@ -179,26 +185,26 @@ export function ItemContextMenu({ item, position, onClose, onUpdate }: ItemConte
max="96"
value={portCount}
onChange={(e) => setPortCount(parseInt(e.target.value) || 1)}
className="w-full px-3 py-2 border border-gray-300 rounded mb-3"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded mb-3 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 transition-colors"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') handleSavePortConfig();
if (e.key === 'Escape') { setShowPortConfigDialog(false); onClose(); }
}}
/>
<div className="text-xs text-gray-500 mb-3">
<div className="text-xs text-gray-500 dark:text-gray-400 mb-3">
Currently used: {item.properties.connections?.length || 0} ports
</div>
<div className="flex gap-2">
<button
onClick={handleSavePortConfig}
className="flex-1 px-3 py-1.5 bg-blue-600 text-white rounded hover:bg-blue-700"
className="flex-1 px-3 py-1.5 bg-blue-600 dark:bg-blue-700 text-white rounded hover:bg-blue-700 dark:hover:bg-blue-600 transition-colors shadow-md"
>
Save
</button>
<button
onClick={() => { setShowPortConfigDialog(false); onClose(); }}
className="flex-1 px-3 py-1.5 bg-gray-300 text-gray-700 rounded hover:bg-gray-400"
className="flex-1 px-3 py-1.5 bg-gray-300 dark:bg-gray-600 text-gray-700 dark:text-gray-200 rounded hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors"
>
Cancel
</button>
@@ -211,12 +217,12 @@ export function ItemContextMenu({ item, position, onClose, onUpdate }: ItemConte
return (
<div
ref={menuRef}
className="fixed bg-white rounded-lg shadow-xl border border-gray-200 p-4"
className="fixed bg-white dark:bg-gray-800 rounded-lg shadow-xl border border-gray-200 dark:border-gray-700 p-4 transition-colors"
style={{ left: position.x, top: position.y, zIndex: 10000, minWidth: '300px' }}
>
<h3 className="font-semibold text-lg mb-2">Delete Item</h3>
<p className="text-gray-600 mb-4">
Are you sure you want to delete <span className="font-semibold">{item.properties.name || item.type}</span>?
<h3 className="font-semibold text-lg mb-2 text-gray-800 dark:text-white">Delete Item</h3>
<p className="text-gray-600 dark:text-gray-400 mb-4">
Are you sure you want to delete <span className="font-semibold text-gray-800 dark:text-white">{item.properties.name || item.type}</span>?
{item.type === 'cable' && item.properties.start_device_id && (
<span className="block mt-2 text-sm">This will also remove the connection from the connected devices.</span>
)}
@@ -229,9 +235,9 @@ export function ItemContextMenu({ item, position, onClose, onUpdate }: ItemConte
type="checkbox"
checked={deleteConnectedCables}
onChange={(e) => setDeleteConnectedCables(e.target.checked)}
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
className="w-4 h-4 text-blue-600 dark:text-blue-500 border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 dark:focus:ring-blue-400 bg-white dark:bg-gray-700"
/>
<span className="text-sm text-gray-700">
<span className="text-sm text-gray-700 dark:text-gray-300">
Also delete {item.properties.connections.length} connected cable{item.properties.connections.length !== 1 ? 's' : ''}
</span>
</label>
@@ -240,13 +246,13 @@ export function ItemContextMenu({ item, position, onClose, onUpdate }: ItemConte
<div className="flex gap-2">
<button
onClick={handleDelete}
className="flex-1 px-3 py-2 bg-red-600 text-white rounded hover:bg-red-700 font-medium"
className="flex-1 px-3 py-2 bg-red-600 dark:bg-red-700 text-white rounded hover:bg-red-700 dark:hover:bg-red-600 font-medium transition-colors shadow-md"
>
Delete
</button>
<button
onClick={() => { setShowDeleteDialog(false); setDeleteConnectedCables(false); onClose(); }}
className="flex-1 px-3 py-2 bg-gray-300 text-gray-700 rounded hover:bg-gray-400"
className="flex-1 px-3 py-2 bg-gray-300 dark:bg-gray-600 text-gray-700 dark:text-gray-200 rounded hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors"
>
Cancel
</button>
@@ -259,15 +265,15 @@ export function ItemContextMenu({ item, position, onClose, onUpdate }: ItemConte
return (
<div
ref={menuRef}
className="fixed bg-white rounded-lg shadow-xl border border-gray-200 p-4"
className="fixed bg-white dark:bg-gray-800 rounded-lg shadow-xl border border-gray-200 dark:border-gray-700 p-4 transition-colors"
style={{ left: position.x, top: position.y, zIndex: 10000, minWidth: '250px' }}
>
<h3 className="font-semibold mb-2">Rename Item</h3>
<h3 className="font-semibold mb-2 text-gray-800 dark:text-white">Rename Item</h3>
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded mb-3"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded mb-3 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 transition-colors"
placeholder="Enter new name"
autoFocus
onKeyDown={(e) => {
@@ -278,13 +284,13 @@ export function ItemContextMenu({ item, position, onClose, onUpdate }: ItemConte
<div className="flex gap-2">
<button
onClick={handleRename}
className="flex-1 px-3 py-1.5 bg-blue-600 text-white rounded hover:bg-blue-700"
className="flex-1 px-3 py-1.5 bg-blue-600 dark:bg-blue-700 text-white rounded hover:bg-blue-700 dark:hover:bg-blue-600 transition-colors shadow-md"
>
Save
</button>
<button
onClick={() => { setShowRenameDialog(false); onClose(); }}
className="flex-1 px-3 py-1.5 bg-gray-300 text-gray-700 rounded hover:bg-gray-400"
className="flex-1 px-3 py-1.5 bg-gray-300 dark:bg-gray-600 text-gray-700 dark:text-gray-200 rounded hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors"
>
Cancel
</button>
@@ -297,14 +303,14 @@ export function ItemContextMenu({ item, position, onClose, onUpdate }: ItemConte
return (
<div
ref={menuRef}
className="fixed bg-white rounded-lg shadow-xl border border-gray-200 p-4"
className="fixed bg-white dark:bg-gray-800 rounded-lg shadow-xl border border-gray-200 dark:border-gray-700 p-4 transition-colors"
style={{ left: position.x, top: position.y, zIndex: 10000, minWidth: '350px', maxWidth: '400px' }}
>
<h3 className="font-semibold mb-2">Edit Notes</h3>
<h3 className="font-semibold mb-2 text-gray-800 dark:text-white">Edit Notes</h3>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded mb-3"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded mb-3 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 transition-colors"
placeholder="Enter notes"
rows={4}
autoFocus
@@ -315,7 +321,7 @@ export function ItemContextMenu({ item, position, onClose, onUpdate }: ItemConte
{/* Image upload section */}
<div className="mb-3">
<label className="block text-sm font-medium text-gray-700 mb-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Attach Image (optional)
</label>
{imageData ? (
@@ -323,12 +329,12 @@ export function ItemContextMenu({ item, position, onClose, onUpdate }: ItemConte
<img
src={imageData}
alt="Attached"
className="w-full rounded border border-gray-300 mb-2"
className="w-full rounded border border-gray-300 dark:border-gray-600 mb-2 bg-gray-50 dark:bg-gray-900"
style={{ maxHeight: '200px', objectFit: 'contain' }}
/>
<button
onClick={handleRemoveImage}
className="absolute top-2 right-2 bg-red-600 text-white rounded-full w-6 h-6 flex items-center justify-center hover:bg-red-700"
className="absolute top-2 right-2 bg-red-600 dark:bg-red-700 text-white rounded-full w-6 h-6 flex items-center justify-center hover:bg-red-700 dark:hover:bg-red-600 transition-colors shadow-lg"
>
×
</button>
@@ -339,22 +345,22 @@ export function ItemContextMenu({ item, position, onClose, onUpdate }: ItemConte
type="file"
accept="image/*"
onChange={handleImageUpload}
className="w-full px-3 py-2 border border-gray-300 rounded text-sm"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white file:mr-4 file:py-2 file:px-4 file:rounded file:border-0 file:text-sm file:font-semibold file:bg-blue-50 dark:file:bg-blue-900/30 file:text-blue-700 dark:file:text-blue-300 hover:file:bg-blue-100 dark:hover:file:bg-blue-900/50 transition-colors"
/>
)}
<p className="text-xs text-gray-500 mt-1">Max size: 2MB</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Max size: 2MB</p>
</div>
<div className="flex gap-2">
<button
onClick={handleSaveNotes}
className="flex-1 px-3 py-1.5 bg-blue-600 text-white rounded hover:bg-blue-700"
className="flex-1 px-3 py-1.5 bg-blue-600 dark:bg-blue-700 text-white rounded hover:bg-blue-700 dark:hover:bg-blue-600 transition-colors shadow-md"
>
Save
</button>
<button
onClick={() => { setShowNotesDialog(false); onClose(); }}
className="flex-1 px-3 py-1.5 bg-gray-300 text-gray-700 rounded hover:bg-gray-400"
className="flex-1 px-3 py-1.5 bg-gray-300 dark:bg-gray-600 text-gray-700 dark:text-gray-200 rounded hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors"
>
Cancel
</button>
@@ -366,33 +372,33 @@ export function ItemContextMenu({ item, position, onClose, onUpdate }: ItemConte
return (
<div
ref={menuRef}
className="fixed bg-white rounded-lg shadow-xl border border-gray-200 py-1"
className="fixed bg-white dark:bg-gray-800 rounded-lg shadow-xl border border-gray-200 dark:border-gray-700 py-1 transition-colors"
style={{ left: position.x, top: position.y, zIndex: 10000, minWidth: '180px' }}
>
<button
onClick={() => setShowRenameDialog(true)}
className="w-full px-4 py-2 text-left text-sm hover:bg-gray-100"
className="w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
>
Rename
</button>
<button
onClick={() => setShowNotesDialog(true)}
className="w-full px-4 py-2 text-left text-sm hover:bg-gray-100"
className="w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
>
{item.properties.notes ? 'Edit Notes' : 'Add Notes'}
</button>
{isSwitch && (
<button
onClick={() => setShowPortConfigDialog(true)}
className="w-full px-4 py-2 text-left text-sm hover:bg-gray-100"
className="w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
>
Configure Ports
</button>
)}
<div className="border-t border-gray-200 my-1"></div>
<div className="border-t border-gray-200 dark:border-gray-600 my-1"></div>
<button
onClick={() => setShowDeleteDialog(true)}
className="w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-red-50"
className="w-full px-4 py-2 text-left text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
>
Delete
</button>

View File

@@ -15,57 +15,24 @@ interface LayerSwitcherProps {
}
export function LayerSwitcher({ activeLayer, onLayerChange, layers }: LayerSwitcherProps) {
const [isOpen, setIsOpen] = useState(false);
return (
<div className="relative">
<button
onClick={() => setIsOpen(!isOpen)}
className="bg-white shadow-md rounded-lg px-4 py-2 hover:bg-gray-50 flex items-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
</svg>
<span className="text-sm font-medium">
{layers[activeLayer]?.name || 'Map Layer'}
</span>
<svg
className={`w-4 h-4 transition-transform ${isOpen ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
<div className="space-y-1">
{Object.entries(layers).map(([key, layer]) => (
<button
key={key}
onClick={() => onLayerChange(key)}
className={`w-full px-3 py-2 rounded text-left flex items-center gap-2 transition-colors ${
activeLayer === key
? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 font-medium shadow-sm'
: 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300'
}`}
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{isOpen && (
<div className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-200 py-1">
{Object.entries(layers).map(([key, layer]) => (
<button
key={key}
onClick={() => {
onLayerChange(key);
setIsOpen(false);
}}
className={`w-full px-4 py-2 text-left text-sm hover:bg-gray-50 flex items-center justify-between ${
activeLayer === key ? 'bg-blue-50 text-blue-700' : 'text-gray-700'
}`}
>
<span>{layer.name}</span>
{activeLayer === key && (
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
)}
</button>
))}
</div>
)}
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
</svg>
<span className="text-sm">{layer.name}</span>
</button>
))}
</div>
);
}

View File

@@ -157,21 +157,21 @@ export function MapItemsLayer({ mapId, refreshTrigger, readOnly = false }: MapIt
>
{!shouldSuppressPopups && (
<Popup>
<div className="text-sm" style={{ minWidth: '200px' }}>
<div className="font-semibold">{item.properties.name || 'Cable'}</div>
<div className="text-gray-600">Type: {cableType}</div>
<div className="text-sm dark:bg-gray-800 dark:text-white" style={{ minWidth: '200px' }}>
<div className="font-semibold text-gray-900 dark:text-white">{item.properties.name || 'Cable'}</div>
<div className="text-gray-600 dark:text-gray-400">Type: {cableType}</div>
{startDevice && (
<div className="text-gray-600 mt-1">
<div className="text-gray-600 dark:text-gray-400 mt-1">
From: {startDevice.properties.name || startDevice.type}
</div>
)}
{endDevice && (
<div className="text-gray-600">
<div className="text-gray-600 dark:text-gray-400">
To: {endDevice.properties.name || endDevice.type}
</div>
)}
{item.properties.notes && (
<div className="text-gray-600 mt-2 pt-2 border-t border-gray-200">
<div className="text-gray-600 dark:text-gray-400 mt-2 pt-2 border-t border-gray-200 dark:border-gray-700">
{item.properties.notes}
</div>
)}
@@ -180,7 +180,7 @@ export function MapItemsLayer({ mapId, refreshTrigger, readOnly = false }: MapIt
<img
src={item.properties.image}
alt="Attachment"
className="w-full rounded border border-gray-200"
className="w-full rounded border border-gray-200 dark:border-gray-700"
style={{ maxHeight: '150px', objectFit: 'contain' }}
/>
</div>
@@ -244,20 +244,20 @@ export function MapItemsLayer({ mapId, refreshTrigger, readOnly = false }: MapIt
>
{!shouldSuppressPopups && (
<Popup>
<div className="text-sm" style={{ minWidth: '200px' }}>
<div className="font-semibold">{item.properties.name || 'Wireless Mesh'}</div>
<div className="text-sm dark:bg-gray-800 dark:text-white" style={{ minWidth: '200px' }}>
<div className="font-semibold text-gray-900 dark:text-white">{item.properties.name || 'Wireless Mesh'}</div>
{startAp && (
<div className="text-gray-600 mt-1">
<div className="text-gray-600 dark:text-gray-400 mt-1">
From: {startAp.properties.name || startAp.type}
</div>
)}
{endAp && (
<div className="text-gray-600">
<div className="text-gray-600 dark:text-gray-400">
To: {endAp.properties.name || endAp.type}
</div>
)}
{item.properties.notes && (
<div className="text-gray-600 mt-2 pt-2 border-t border-gray-200">
<div className="text-gray-600 dark:text-gray-400 mt-2 pt-2 border-t border-gray-200 dark:border-gray-700">
{item.properties.notes}
</div>
)}
@@ -266,7 +266,7 @@ export function MapItemsLayer({ mapId, refreshTrigger, readOnly = false }: MapIt
<img
src={item.properties.image}
alt="Attachment"
className="w-full rounded border border-gray-200"
className="w-full rounded border border-gray-200 dark:border-gray-700"
style={{ maxHeight: '150px', objectFit: 'contain' }}
/>
</div>
@@ -306,19 +306,19 @@ export function MapItemsLayer({ mapId, refreshTrigger, readOnly = false }: MapIt
>
{!shouldSuppressPopups && (
<Popup>
<div className="text-sm" style={{ minWidth: '200px' }}>
<div className="font-semibold">{item.properties.name || item.type}</div>
<div className="text-gray-600">Type: {item.type}</div>
<div className="text-sm dark:bg-gray-800 dark:text-white" style={{ minWidth: '200px' }}>
<div className="font-semibold text-gray-900 dark:text-white">{item.properties.name || item.type}</div>
<div className="text-gray-600 dark:text-gray-400">Type: {item.type}</div>
{item.properties.port_count && (
<div className="text-gray-600">
<div className="text-gray-600 dark:text-gray-400">
Ports: {item.properties.connections?.length || 0} / {item.properties.port_count}
</div>
)}
{/* Show port connections details */}
{item.properties.connections && item.properties.connections.length > 0 && (
<div className="mt-2 pt-2 border-t border-gray-200">
<div className="font-semibold text-gray-700 mb-1">Port Connections:</div>
<div className="mt-2 pt-2 border-t border-gray-200 dark:border-gray-700">
<div className="font-semibold text-gray-700 dark:text-gray-300 mb-1">Port Connections:</div>
{item.properties.connections
.sort((a: any, b: any) => a.port_number - b.port_number)
.map((conn: any) => {
@@ -337,7 +337,7 @@ export function MapItemsLayer({ mapId, refreshTrigger, readOnly = false }: MapIt
const cableType = cable.properties.cable_type;
return (
<div key={conn.cable_id} className="text-xs text-gray-600 ml-2">
<div key={conn.cable_id} className="text-xs text-gray-600 dark:text-gray-400 ml-2">
Port {conn.port_number} {otherDevice
? `${otherDevice.properties.name || otherDevice.type} (${cableType})`
: `${cableType} cable`}
@@ -349,7 +349,7 @@ export function MapItemsLayer({ mapId, refreshTrigger, readOnly = false }: MapIt
{/* Show wireless mesh count for APs */}
{['indoor_ap', 'outdoor_ap'].includes(item.type) && (
<div className="text-gray-600">
<div className="text-gray-600 dark:text-gray-400">
Wireless mesh: {items.filter(i =>
i.type === 'wireless_mesh' &&
(i.properties.start_ap_id === item.id || i.properties.end_ap_id === item.id)
@@ -366,8 +366,8 @@ export function MapItemsLayer({ mapId, refreshTrigger, readOnly = false }: MapIt
if (meshLinks.length > 0) {
return (
<div className="mt-2 pt-2 border-t border-gray-200">
<div className="font-semibold text-gray-700 mb-1">Mesh Connections:</div>
<div className="mt-2 pt-2 border-t border-gray-200 dark:border-gray-700">
<div className="font-semibold text-gray-700 dark:text-gray-300 mb-1">Mesh Connections:</div>
{meshLinks.map((mesh) => {
const otherApId = mesh.properties.start_ap_id === item.id
? mesh.properties.end_ap_id
@@ -378,7 +378,7 @@ export function MapItemsLayer({ mapId, refreshTrigger, readOnly = false }: MapIt
: null;
return (
<div key={mesh.id} className="text-xs text-gray-600 ml-2">
<div key={mesh.id} className="text-xs text-gray-600 dark:text-gray-400 ml-2">
{otherAp ? (otherAp.properties.name || otherAp.type) : 'Unknown AP'}
</div>
);
@@ -390,7 +390,7 @@ export function MapItemsLayer({ mapId, refreshTrigger, readOnly = false }: MapIt
})()}
{item.properties.notes && (
<div className="text-gray-600 mt-2 pt-2 border-t border-gray-200">
<div className="text-gray-600 dark:text-gray-400 mt-2 pt-2 border-t border-gray-200 dark:border-gray-700">
{item.properties.notes}
</div>
)}
@@ -399,7 +399,7 @@ export function MapItemsLayer({ mapId, refreshTrigger, readOnly = false }: MapIt
<img
src={item.properties.image}
alt="Attachment"
className="w-full rounded border border-gray-200"
className="w-full rounded border border-gray-200 dark:border-gray-700"
style={{ maxHeight: '150px', objectFit: 'contain' }}
/>
</div>

View File

@@ -1,16 +1,19 @@
import { useState, useEffect } from 'react';
import { useMapStore } from '../../stores/mapStore';
import { useAuthStore } from '../../stores/authStore';
import { useUIStore } from '../../stores/uiStore';
import { mapService } from '../../services/mapService';
interface MapListSidebarProps {
onSelectMap: (mapId: string) => void;
selectedMapId: string | null;
onShareMap?: (mapId: string) => void;
}
export function MapListSidebar({ onSelectMap, selectedMapId }: MapListSidebarProps) {
export function MapListSidebar({ onSelectMap, selectedMapId, onShareMap }: MapListSidebarProps) {
const { maps, setMaps, addMap, removeMap, setLoading, setError } = useMapStore();
const { user } = useAuthStore();
const { showToast, showConfirm } = useUIStore();
const [isCreating, setIsCreating] = useState(false);
const [newMapName, setNewMapName] = useState('');
@@ -39,31 +42,43 @@ export function MapListSidebar({ onSelectMap, selectedMapId }: MapListSidebarPro
setNewMapName('');
setIsCreating(false);
onSelectMap(newMap.id);
showToast('Map created successfully!', 'success');
} catch (error: any) {
setError(error.response?.data?.detail || 'Failed to create map');
const message = error.response?.data?.detail || 'Failed to create map';
setError(message);
showToast(message, 'error');
}
};
const handleDeleteMap = async (mapId: string) => {
if (!confirm('Are you sure you want to delete this map?')) return;
try {
await mapService.deleteMap(mapId);
removeMap(mapId);
} catch (error: any) {
setError(error.response?.data?.detail || 'Failed to delete map');
}
showConfirm({
title: 'Delete Map',
message: 'Are you sure you want to delete this map? This action cannot be undone.',
confirmText: 'Delete',
type: 'danger',
onConfirm: async () => {
try {
await mapService.deleteMap(mapId);
removeMap(mapId);
showToast('Map deleted successfully', 'success');
} catch (error: any) {
const message = error.response?.data?.detail || 'Failed to delete map';
setError(message);
showToast(message, 'error');
}
},
});
};
return (
<div className="w-80 bg-white border-r border-gray-200 flex flex-col">
<div className="p-4 border-b border-gray-200">
<h2 className="text-lg font-semibold mb-3">My Maps</h2>
<div className="w-80 bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 flex flex-col transition-colors">
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold mb-3 text-gray-900 dark:text-white">My Maps</h2>
{!isCreating ? (
<button
onClick={() => setIsCreating(true)}
className="w-full px-3 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
className="w-full px-3 py-2 bg-blue-600 dark:bg-blue-700 text-white rounded hover:bg-blue-700 dark:hover:bg-blue-600 transition-colors"
>
+ New Map
</button>
@@ -74,14 +89,14 @@ export function MapListSidebar({ onSelectMap, selectedMapId }: MapListSidebarPro
value={newMapName}
onChange={(e) => setNewMapName(e.target.value)}
placeholder="Map name"
className="w-full px-3 py-2 border border-gray-300 rounded"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500"
autoFocus
onKeyDown={(e) => e.key === 'Enter' && handleCreateMap()}
/>
<div className="flex gap-2">
<button
onClick={handleCreateMap}
className="flex-1 px-3 py-1 bg-green-600 text-white rounded hover:bg-green-700 text-sm"
className="flex-1 px-3 py-1 bg-green-600 dark:bg-green-700 text-white rounded hover:bg-green-700 dark:hover:bg-green-600 text-sm transition-colors"
>
Create
</button>
@@ -90,7 +105,7 @@ export function MapListSidebar({ onSelectMap, selectedMapId }: MapListSidebarPro
setIsCreating(false);
setNewMapName('');
}}
className="flex-1 px-3 py-1 bg-gray-300 text-gray-700 rounded hover:bg-gray-400 text-sm"
className="flex-1 px-3 py-1 bg-gray-300 dark:bg-gray-600 text-gray-700 dark:text-gray-200 rounded hover:bg-gray-400 dark:hover:bg-gray-500 text-sm transition-colors"
>
Cancel
</button>
@@ -101,11 +116,11 @@ export function MapListSidebar({ onSelectMap, selectedMapId }: MapListSidebarPro
<div className="flex-1 overflow-y-auto">
{maps.length === 0 ? (
<div className="p-4 text-center text-gray-500 text-sm">
<div className="p-4 text-center text-gray-500 dark:text-gray-400 text-sm">
No maps yet. Create your first map!
</div>
) : (
<div className="divide-y divide-gray-200">
<div className="divide-y divide-gray-200 dark:divide-gray-700">
{maps.map((map) => {
const isOwner = user && map.owner_id === user.id;
const isShared = !isOwner;
@@ -113,38 +128,51 @@ export function MapListSidebar({ onSelectMap, selectedMapId }: MapListSidebarPro
return (
<div
key={map.id}
className={`p-4 cursor-pointer hover:bg-gray-50 ${
selectedMapId === map.id ? 'bg-blue-50 border-l-4 border-blue-600' : ''
className={`p-4 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors ${
selectedMapId === map.id
? 'bg-blue-50 dark:bg-blue-900/30 border-l-4 border-blue-600 dark:border-blue-500'
: ''
}`}
onClick={() => onSelectMap(map.id)}
>
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center gap-2">
<h3 className="font-medium text-gray-900">{map.name}</h3>
<h3 className="font-medium text-gray-900 dark:text-white">{map.name}</h3>
{isShared && (
<span className="px-2 py-0.5 bg-green-100 text-green-700 text-xs rounded">
<span className="px-2 py-0.5 bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-400 text-xs rounded font-medium">
Shared
</span>
)}
</div>
{map.description && (
<p className="text-sm text-gray-600 mt-1">{map.description}</p>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">{map.description}</p>
)}
<p className="text-xs text-gray-400 mt-1">
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
{new Date(map.updated_at).toLocaleDateString()}
</p>
</div>
{isOwner && (
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteMap(map.id);
}}
className="text-red-600 hover:text-red-800 text-sm ml-2"
>
Delete
</button>
<div className="flex flex-col gap-1 ml-2">
<button
onClick={(e) => {
e.stopPropagation();
onShareMap?.(map.id);
}}
className="px-2 py-1 text-green-600 dark:text-green-400 hover:bg-green-50 dark:hover:bg-green-900/30 rounded text-sm transition-colors font-medium"
>
Share
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteMap(map.id);
}}
className="px-2 py-1 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30 rounded text-sm transition-colors font-medium"
>
Delete
</button>
</div>
)}
</div>
</div>

View File

@@ -1,8 +1,6 @@
import { useState, useEffect } from 'react';
import { MapContainer, TileLayer, useMap } from 'react-leaflet';
import 'leaflet/dist/leaflet.css';
import { Toolbar } from './Toolbar';
import { LayerSwitcher } from './LayerSwitcher';
import { DrawingHandler } from './DrawingHandler';
import { MapItemsLayer } from './MapItemsLayer';
import { ShareDialog } from './ShareDialog';
@@ -10,32 +8,13 @@ import { useMapWebSocket } from '../../hooks/useMapWebSocket';
interface MapViewProps {
mapId: string | null;
activeLayer: string;
mapLayers: Record<string, any>;
showShareDialog?: boolean;
shareMapId?: string | null;
onCloseShareDialog?: () => void;
}
type MapLayer = 'osm' | 'google' | 'esri';
const MAP_LAYERS = {
osm: {
name: 'OpenStreetMap',
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 25,
},
google: {
name: 'Google Satellite',
url: 'https://mt1.google.com/vt/lyrs=s&x={x}&y={y}&z={z}',
attribution: '&copy; Google',
maxZoom: 25,
maxNativeZoom: 22,
},
esri: {
name: 'ESRI Satellite',
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
attribution: 'Tiles &copy; Esri',
maxZoom: 25,
},
};
function MapController() {
const map = useMap();
@@ -49,10 +28,8 @@ function MapController() {
return null;
}
export function MapView({ mapId }: MapViewProps) {
const [activeLayer, setActiveLayer] = useState<MapLayer>('osm');
export function MapView({ mapId, activeLayer, mapLayers, showShareDialog = false, shareMapId, onCloseShareDialog }: MapViewProps) {
const [refreshTrigger, setRefreshTrigger] = useState(0);
const [showShareDialog, setShowShareDialog] = useState(false);
const handleItemCreated = () => {
// Trigger refresh of map items
@@ -81,37 +58,19 @@ export function MapView({ mapId }: MapViewProps) {
if (!mapId) {
return (
<div className="flex-1 flex items-center justify-center bg-gray-100">
<div className="flex-1 flex items-center justify-center bg-gray-100 dark:bg-gray-900 transition-colors">
<div className="text-center">
<p className="text-gray-500 text-lg">Select a map from the sidebar to get started</p>
<p className="text-gray-400 text-sm mt-2">or create a new one</p>
<p className="text-gray-500 dark:text-gray-400 text-lg">Select a map from the sidebar to get started</p>
<p className="text-gray-400 dark:text-gray-500 text-sm mt-2">or create a new one</p>
</div>
</div>
);
}
const layer = MAP_LAYERS[activeLayer];
const layer = mapLayers[activeLayer];
return (
<>
{/* Toolbar for drawing tools */}
<div style={{ position: 'fixed', left: '220px', top: '70px', zIndex: 9999 }}>
<Toolbar
mapId={mapId}
onShare={() => setShowShareDialog(true)}
readOnly={permission === 'read'}
/>
</div>
{/* Layer switcher */}
<div style={{ position: 'fixed', right: '20px', top: '70px', zIndex: 9999 }}>
<LayerSwitcher
activeLayer={activeLayer}
onLayerChange={setActiveLayer}
layers={MAP_LAYERS}
/>
</div>
<div className="flex-1 relative">
<MapContainer
center={[0, 0]}
@@ -139,10 +98,10 @@ export function MapView({ mapId }: MapViewProps) {
</div>
{/* Share dialog */}
{showShareDialog && (
{showShareDialog && onCloseShareDialog && shareMapId && (
<ShareDialog
mapId={mapId}
onClose={() => setShowShareDialog(false)}
mapId={shareMapId}
onClose={onCloseShareDialog}
/>
)}
</>

View File

@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react';
import { mapShareService, type MapShare, type MapShareLink } from '../../services/mapShareService';
import { useUIStore } from '../../stores/uiStore';
interface ShareDialogProps {
mapId: string;
@@ -7,6 +8,7 @@ interface ShareDialogProps {
}
export function ShareDialog({ mapId, onClose }: ShareDialogProps) {
const { showToast, showConfirm } = useUIStore();
const [activeTab, setActiveTab] = useState<'users' | 'links'>('users');
const [userShares, setUserShares] = useState<MapShare[]>([]);
const [shareLinks, setShareLinks] = useState<MapShareLink[]>([]);
@@ -54,12 +56,11 @@ export function ShareDialog({ mapId, onClose }: ShareDialogProps) {
});
setNewUserId('');
await loadShares();
alert('Map shared successfully!');
showToast('Map shared successfully!', 'success');
} catch (error: any) {
console.error('Share error:', error);
// Show detailed error message
const message = error.response?.data?.detail || error.message || 'Failed to share map';
alert(message);
showToast(message, 'error');
} finally {
setLoading(false);
}
@@ -72,80 +73,94 @@ export function ShareDialog({ mapId, onClose }: ShareDialogProps) {
permission: newLinkPermission,
});
await loadLinks();
alert('Share link created successfully!');
showToast('Share link created successfully!', 'success');
} catch (error: any) {
console.error('Create link error:', error);
const message = error.response?.data?.detail || error.message || 'Failed to create share link';
alert(message);
showToast(message, 'error');
} finally {
setLoading(false);
}
};
const handleRevokeShare = async (shareId: string) => {
if (!confirm('Are you sure you want to revoke this share?')) return;
try {
await mapShareService.revokeShare(mapId, shareId);
await loadShares();
} catch (error: any) {
console.error('Revoke share error:', error);
const message = error.response?.data?.detail || error.message || 'Failed to revoke share';
alert(message);
}
showConfirm({
title: 'Revoke Access',
message: 'Are you sure you want to revoke this user\'s access? They will be immediately disconnected.',
confirmText: 'Revoke',
type: 'danger',
onConfirm: async () => {
try {
await mapShareService.revokeShare(mapId, shareId);
await loadShares();
showToast('Access revoked successfully', 'success');
} catch (error: any) {
console.error('Revoke share error:', error);
const message = error.response?.data?.detail || error.message || 'Failed to revoke share';
showToast(message, 'error');
}
},
});
};
const handleDeleteLink = async (linkId: string) => {
if (!confirm('Are you sure you want to delete this share link?')) return;
try {
await mapShareService.deleteShareLink(mapId, linkId);
await loadLinks();
} catch (error: any) {
console.error('Delete link error:', error);
const message = error.response?.data?.detail || error.message || 'Failed to delete link';
alert(message);
}
showConfirm({
title: 'Delete Share Link',
message: 'Are you sure you want to delete this share link? Anyone with this link will lose access.',
confirmText: 'Delete',
type: 'danger',
onConfirm: async () => {
try {
await mapShareService.deleteShareLink(mapId, linkId);
await loadLinks();
showToast('Share link deleted successfully', 'success');
} catch (error: any) {
console.error('Delete link error:', error);
const message = error.response?.data?.detail || error.message || 'Failed to delete link';
showToast(message, 'error');
}
},
});
};
const copyLinkToClipboard = (token: string) => {
const url = `${window.location.origin}/shared/${token}`;
navigator.clipboard.writeText(url);
alert('Link copied to clipboard!');
showToast('Link copied to clipboard!', 'success');
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center" style={{ zIndex: 10001 }}>
<div className="bg-white rounded-lg shadow-xl w-full max-w-2xl max-h-[80vh] overflow-hidden">
<div className="fixed inset-0 bg-black bg-opacity-30 dark:bg-opacity-50 flex items-center justify-center transition-colors" style={{ zIndex: 10001 }}>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-2xl w-full max-w-2xl max-h-[80vh] overflow-hidden border border-gray-200 dark:border-gray-700 transition-colors animate-scale-in">
{/* Header */}
<div className="px-6 py-4 border-b border-gray-200 flex justify-between items-center">
<h2 className="text-xl font-bold text-gray-800">Share Map</h2>
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-gray-800 dark:to-gray-800">
<h2 className="text-xl font-bold text-gray-800 dark:text-white">Share Map</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 text-2xl"
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl transition-colors"
>
×
</button>
</div>
{/* Tabs */}
<div className="flex border-b border-gray-200">
<div className="flex border-b border-gray-200 dark:border-gray-700">
<button
onClick={() => setActiveTab('users')}
className={`flex-1 px-6 py-3 font-medium ${
className={`flex-1 px-6 py-3 font-medium transition-colors ${
activeTab === 'users'
? 'border-b-2 border-blue-600 text-blue-600'
: 'text-gray-600 hover:text-gray-800'
? 'border-b-2 border-blue-600 text-blue-600 dark:text-blue-400'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200'
}`}
>
Share with Users
</button>
<button
onClick={() => setActiveTab('links')}
className={`flex-1 px-6 py-3 font-medium ${
className={`flex-1 px-6 py-3 font-medium transition-colors ${
activeTab === 'links'
? 'border-b-2 border-blue-600 text-blue-600'
: 'text-gray-600 hover:text-gray-800'
? 'border-b-2 border-blue-600 text-blue-600 dark:text-blue-400'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200'
}`}
>
Public Links
@@ -158,10 +173,10 @@ export function ShareDialog({ mapId, onClose }: ShareDialogProps) {
<div>
{/* Add user form */}
<form onSubmit={handleShareWithUser} className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Share with User
</label>
<p className="text-xs text-gray-500 mb-2">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-2">
Enter a username, email, or user ID
</p>
<div className="flex gap-2">
@@ -170,13 +185,13 @@ export function ShareDialog({ mapId, onClose }: ShareDialogProps) {
value={newUserId}
onChange={(e) => setNewUserId(e.target.value)}
placeholder="Enter username, email, or user ID"
className="flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm"
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 transition-colors"
disabled={loading}
/>
<select
value={newUserPermission}
onChange={(e) => setNewUserPermission(e.target.value as 'read' | 'edit')}
className="px-3 py-2 border border-gray-300 rounded-md"
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 transition-colors"
disabled={loading}
>
<option value="read">Read-only</option>
@@ -185,7 +200,7 @@ export function ShareDialog({ mapId, onClose }: ShareDialogProps) {
<button
type="submit"
disabled={loading || !newUserId.trim()}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 dark:bg-blue-700 dark:hover:bg-blue-600 text-white rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed shadow-md font-medium"
>
Share
</button>
@@ -194,25 +209,25 @@ export function ShareDialog({ mapId, onClose }: ShareDialogProps) {
{/* Existing shares */}
<div>
<h3 className="font-medium text-gray-700 mb-3">Current Shares</h3>
<h3 className="font-medium text-gray-700 dark:text-gray-300 mb-3">Current Shares</h3>
{userShares.length === 0 ? (
<p className="text-gray-500 text-sm">No users have access to this map yet.</p>
<p className="text-gray-500 dark:text-gray-400 text-sm">No users have access to this map yet.</p>
) : (
<div className="space-y-2">
{userShares.map((share) => (
<div
key={share.id}
className="flex items-center justify-between p-3 border border-gray-200 rounded-md"
className="flex items-center justify-between p-3 border border-gray-200 dark:border-gray-700 rounded-md bg-gray-50 dark:bg-gray-700 transition-colors"
>
<div>
<div className="font-medium text-sm">{share.user_id}</div>
<div className="text-xs text-gray-500">
<div className="font-medium text-sm text-gray-900 dark:text-white">{share.user_id}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{share.permission === 'read' ? 'Read-only' : 'Can edit'}
</div>
</div>
<button
onClick={() => handleRevokeShare(share.id)}
className="px-3 py-1 text-sm text-red-600 hover:bg-red-50 rounded"
className="px-3 py-1 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30 rounded transition-colors font-medium"
>
Revoke
</button>
@@ -228,14 +243,14 @@ export function ShareDialog({ mapId, onClose }: ShareDialogProps) {
<div>
{/* Create link form */}
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Create Public Share Link
</label>
<div className="flex gap-2">
<select
value={newLinkPermission}
onChange={(e) => setNewLinkPermission(e.target.value as 'read' | 'edit')}
className="flex-1 px-3 py-2 border border-gray-300 rounded-md"
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 transition-colors"
disabled={loading}
>
<option value="read">Read-only</option>
@@ -244,7 +259,7 @@ export function ShareDialog({ mapId, onClose }: ShareDialogProps) {
<button
onClick={handleCreateLink}
disabled={loading}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 dark:bg-blue-700 dark:hover:bg-blue-600 text-white rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed shadow-md font-medium"
>
Create Link
</button>
@@ -253,36 +268,36 @@ export function ShareDialog({ mapId, onClose }: ShareDialogProps) {
{/* Existing links */}
<div>
<h3 className="font-medium text-gray-700 mb-3">Active Share Links</h3>
<h3 className="font-medium text-gray-700 dark:text-gray-300 mb-3">Active Share Links</h3>
{shareLinks.length === 0 ? (
<p className="text-gray-500 text-sm">No public share links created yet.</p>
<p className="text-gray-500 dark:text-gray-400 text-sm">No public share links created yet.</p>
) : (
<div className="space-y-3">
{shareLinks.map((link) => (
<div
key={link.id}
className="p-3 border border-gray-200 rounded-md"
className="p-3 border border-gray-200 dark:border-gray-700 rounded-md bg-gray-50 dark:bg-gray-700 transition-colors"
>
<div className="flex items-start justify-between mb-2">
<div className="flex-1">
<div className="text-xs text-gray-500 mb-1">
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">
{link.permission === 'read' ? 'Read-only' : 'Can edit'}
Created {new Date(link.created_at).toLocaleDateString()}
</div>
<div className="font-mono text-xs bg-gray-100 p-2 rounded break-all">
<div className="font-mono text-xs bg-gray-100 dark:bg-gray-900 text-gray-800 dark:text-gray-200 p-2 rounded break-all border border-gray-200 dark:border-gray-700">
{window.location.origin}/shared/{link.token}
</div>
</div>
<button
onClick={() => handleDeleteLink(link.id)}
className="ml-2 text-red-600 hover:bg-red-50 p-1 rounded text-sm"
className="ml-2 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30 p-1 rounded text-sm transition-colors font-medium"
>
Delete
</button>
</div>
<button
onClick={() => copyLinkToClipboard(link.token)}
className="text-sm text-blue-600 hover:text-blue-700"
className="text-sm text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors font-medium"
>
Copy Link
</button>

View File

@@ -4,7 +4,6 @@ import { CABLE_COLORS, CABLE_LABELS } from '../../types/mapItem';
interface ToolbarProps {
mapId: string;
onShare: () => void;
readOnly?: boolean;
}
@@ -16,12 +15,20 @@ interface ToolButton {
description: string;
}
const TOOLS: ToolButton[] = [
const SELECT_TOOL: ToolButton = {
id: 'select',
label: 'Select',
icon: '👆',
description: 'Select and edit items',
};
const CONNECTION_TOOLS: ToolButton[] = [
{
id: 'select',
label: 'Select',
icon: '👆',
description: 'Select and edit items',
id: 'wireless_mesh',
label: 'Wireless',
icon: 'wireless',
color: '#10B981',
description: 'Wireless Mesh Link',
},
{
id: 'fiber',
@@ -44,83 +51,135 @@ const TOOLS: ToolButton[] = [
color: CABLE_COLORS.cat6_poe,
description: CABLE_LABELS.cat6_poe,
},
];
const DEVICE_TOOLS: ToolButton[] = [
{
id: 'switch',
label: 'Switch',
icon: '',
icon: 'switch',
description: 'Network Switch',
},
{
id: 'indoor_ap',
label: 'Indoor AP',
icon: '📡',
icon: 'indoor_ap',
description: 'Indoor Access Point',
},
{
id: 'outdoor_ap',
label: 'Outdoor AP',
icon: '📶',
icon: 'outdoor_ap',
description: 'Outdoor Access Point',
},
{
id: 'wireless_mesh',
label: 'Wireless',
icon: '⚡',
color: '#10B981',
description: 'Wireless Mesh Link',
},
];
export function Toolbar({ mapId, onShare, readOnly = false }: ToolbarProps) {
export function Toolbar({ mapId, readOnly = false }: ToolbarProps) {
const { activeTool, setActiveTool } = useDrawingStore();
const renderIcon = (tool: ToolButton, isDisabled: boolean) => {
if (tool.icon === 'wireless') {
return (
<svg width="20" height="20" viewBox="0 0 20 20" style={{ color: isDisabled ? undefined : tool.color }}>
<line x1="2" y1="10" x2="18" y2="10" stroke="currentColor" strokeWidth="2" strokeDasharray="3,2" />
</svg>
);
}
if (tool.icon === 'switch') {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" style={{ color: '#8B5CF6' }}>
<rect x="2" y="4" width="20" height="16" rx="2" fill="none" stroke="currentColor" strokeWidth="2"/>
<circle cx="6" cy="10" r="1.5"/>
<circle cx="10" cy="10" r="1.5"/>
<circle cx="14" cy="10" r="1.5"/>
<circle cx="18" cy="10" r="1.5"/>
<circle cx="6" cy="14" r="1.5"/>
<circle cx="10" cy="14" r="1.5"/>
<circle cx="14" cy="14" r="1.5"/>
<circle cx="18" cy="14" r="1.5"/>
</svg>
);
}
if (tool.icon === 'indoor_ap') {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" style={{ color: '#10B981' }}>
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z" opacity="0.3"/>
<circle cx="12" cy="12" r="3"/>
<path d="M12 6c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6-2.69-6-6-6zm0 10c-2.21 0-4-1.79-4-4s1.79-4 4-4 4 1.79 4 4-1.79 4-4 4z"/>
</svg>
);
}
if (tool.icon === 'outdoor_ap') {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" style={{ color: '#F59E0B' }}>
<path d="M1 9l2 2c4.97-4.97 13.03-4.97 18 0l2-2C16.93 2.93 7.08 2.93 1 9z"/>
<path d="M9 17l3 3 3-3c-1.65-1.66-4.34-1.66-6 0z"/>
<path d="M5 13l2 2c2.76-2.76 7.24-2.76 10 0l2-2C15.14 9.14 8.87 9.14 5 13z"/>
</svg>
);
}
return (
<span
className="text-lg"
style={tool.color && !isDisabled ? { color: tool.color } : undefined}
>
{tool.icon}
</span>
);
};
const renderToolButton = (tool: ToolButton) => {
const isDisabled = readOnly && tool.id !== 'select';
return (
<button
key={tool.id}
onClick={() => !isDisabled && setActiveTool(tool.id)}
disabled={isDisabled}
className={`w-full px-3 py-2 rounded text-left flex items-center gap-2 transition-colors ${
isDisabled
? 'opacity-50 cursor-not-allowed text-gray-400 dark:text-gray-600'
: activeTool === tool.id
? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 font-medium shadow-sm'
: 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300'
}`}
title={isDisabled ? 'Not available in read-only mode' : tool.description}
>
{renderIcon(tool, isDisabled)}
<span className="text-sm">{tool.label}</span>
</button>
);
};
return (
<div className="bg-white shadow-lg rounded-lg p-2 space-y-1" style={{ minWidth: '150px' }}>
<div className="bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 p-3 space-y-1 transition-colors" style={{ minWidth: '150px' }}>
{/* Read-only indicator */}
{readOnly && (
<div className="w-full px-3 py-2 rounded bg-yellow-100 text-yellow-800 text-xs font-medium mb-2 text-center">
<div className="w-full px-3 py-2 rounded bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-400 text-xs font-medium mb-2 text-center border border-yellow-200 dark:border-yellow-800">
Read-Only Mode
</div>
)}
{/* Share button */}
<button
onClick={onShare}
className="w-full px-3 py-2 rounded text-left flex items-center gap-2 transition-colors bg-green-100 text-green-700 hover:bg-green-200 font-medium mb-2"
title="Share this map"
>
<span className="text-lg">🔗</span>
<span className="text-sm">Share</span>
</button>
{/* Tools section header */}
<h2 className="text-lg font-semibold mb-3 text-gray-900 dark:text-white">Tools</h2>
<div className="border-t border-gray-200 my-2"></div>
{/* Select tool */}
{renderToolButton(SELECT_TOOL)}
{TOOLS.map((tool) => {
const isDisabled = readOnly && tool.id !== 'select';
return (
<button
key={tool.id}
onClick={() => !isDisabled && setActiveTool(tool.id)}
disabled={isDisabled}
className={`w-full px-3 py-2 rounded text-left flex items-center gap-2 transition-colors ${
isDisabled
? 'opacity-50 cursor-not-allowed text-gray-400'
: activeTool === tool.id
? 'bg-blue-100 text-blue-700 font-medium'
: 'hover:bg-gray-100 text-gray-700'
}`}
title={isDisabled ? 'Not available in read-only mode' : tool.description}
>
<span
className="text-lg"
style={tool.color && !isDisabled ? { color: tool.color } : undefined}
>
{tool.icon}
</span>
<span className="text-sm">{tool.label}</span>
</button>
);
})}
<div className="border-t border-gray-200 dark:border-gray-700 my-2"></div>
{/* Connections section */}
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 px-1 mb-2">Connections</h3>
{CONNECTION_TOOLS.map(renderToolButton)}
<div className="border-t border-gray-200 dark:border-gray-700 my-2"></div>
{/* Devices section */}
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 px-1 mb-2">Devices</h3>
{DEVICE_TOOLS.map(renderToolButton)}
</div>
);
}

View File

@@ -1,6 +1,52 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import "tailwindcss";
@layer theme {
:root {
--color-primary: #3B82F6;
}
}
@layer base {
:root {
color-scheme: light;
}
.dark {
color-scheme: dark;
}
}
@layer utilities {
.animate-slide-in {
animation: slideIn 0.3s ease-out;
}
.animate-scale-in {
animation: scaleIn 0.2s ease-out;
}
@keyframes slideIn {
0% {
transform: translateX(100%);
opacity: 0;
}
100% {
transform: translateX(0);
opacity: 1;
}
}
@keyframes scaleIn {
0% {
transform: scale(0.9);
opacity: 0;
}
100% {
transform: scale(1);
opacity: 1;
}
}
}
/* Leaflet CSS will be imported in main.tsx */
@@ -35,6 +81,30 @@
border: 3px solid;
}
/* Dark mode support for device icons */
.dark .device-icon {
background: rgb(31, 41, 55);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.6);
}
/* Leaflet popup dark mode support */
.dark .leaflet-popup-content-wrapper {
background-color: rgb(31, 41, 55);
color: white;
}
.dark .leaflet-popup-tip {
background-color: rgb(31, 41, 55);
}
.dark .leaflet-popup-close-button {
color: rgb(209, 213, 219) !important;
}
.dark .leaflet-popup-close-button:hover {
color: white !important;
}
.device-icon svg {
width: 24px;
height: 24px;

View File

@@ -2,18 +2,85 @@ import { useState } from 'react';
import { Layout } from '../components/layout/Layout';
import { MapListSidebar } from '../components/map/MapListSidebar';
import { MapView } from '../components/map/MapView';
import { Toolbar } from '../components/map/Toolbar';
import { LayerSwitcher } from '../components/map/LayerSwitcher';
type MapLayer = 'osm' | 'google' | 'esri';
const MAP_LAYERS = {
osm: {
name: 'OpenStreetMap',
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 25,
},
google: {
name: 'Google Satellite',
url: 'https://mt1.google.com/vt/lyrs=s&x={x}&y={y}&z={z}',
attribution: '&copy; Google',
maxZoom: 25,
maxNativeZoom: 22,
},
esri: {
name: 'ESRI Satellite',
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
attribution: 'Tiles &copy; Esri',
maxZoom: 25,
},
};
export function Dashboard() {
const [selectedMapId, setSelectedMapId] = useState<string | null>(null);
const [showShareDialog, setShowShareDialog] = useState(false);
const [shareMapId, setShareMapId] = useState<string | null>(null);
const [activeLayer, setActiveLayer] = useState<MapLayer>('osm');
const handleShareMap = (mapId: string) => {
setShareMapId(mapId);
setShowShareDialog(true);
};
return (
<Layout>
<div className="flex h-full">
<MapListSidebar
onSelectMap={setSelectedMapId}
selectedMapId={selectedMapId}
{/* Left sidebar with map list and toolbar */}
<div className="flex flex-col bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 transition-colors">
<MapListSidebar
onSelectMap={setSelectedMapId}
selectedMapId={selectedMapId}
onShareMap={handleShareMap}
/>
{selectedMapId && (
<>
<div className="border-t border-gray-200 dark:border-gray-700">
<Toolbar mapId={selectedMapId} />
</div>
{/* Map Style section */}
<div className="border-t border-gray-200 dark:border-gray-700 p-3">
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 px-1 mb-2">Map Style</h3>
<LayerSwitcher
activeLayer={activeLayer}
onLayerChange={setActiveLayer}
layers={MAP_LAYERS}
/>
</div>
</>
)}
</div>
{/* Main map view */}
<MapView
mapId={selectedMapId}
activeLayer={activeLayer}
mapLayers={MAP_LAYERS}
showShareDialog={showShareDialog}
shareMapId={shareMapId}
onCloseShareDialog={() => {
setShowShareDialog(false);
setShareMapId(null);
}}
/>
<MapView mapId={selectedMapId} />
</div>
</Layout>
);

View File

@@ -0,0 +1,108 @@
import { create } from 'zustand';
export type ToastType = 'success' | 'error' | 'info' | 'warning';
interface ToastData {
id: string;
message: string;
type: ToastType;
}
interface ConfirmData {
title: string;
message: string;
confirmText?: string;
cancelText?: string;
type?: 'danger' | 'warning' | 'info';
onConfirm: () => void;
onCancel?: () => void;
}
interface UIStore {
toasts: ToastData[];
confirm: ConfirmData | null;
darkMode: boolean;
showToast: (message: string, type: ToastType) => void;
removeToast: (id: string) => void;
showConfirm: (data: Omit<ConfirmData, 'onCancel'>) => Promise<boolean>;
hideConfirm: () => void;
toggleDarkMode: () => void;
setDarkMode: (enabled: boolean) => void;
}
export const useUIStore = create<UIStore>((set, get) => ({
toasts: [],
confirm: null,
darkMode: localStorage.getItem('darkMode') === 'true',
showToast: (message: string, type: ToastType) => {
const id = Math.random().toString(36).substring(7);
set((state) => ({
toasts: [...state.toasts, { id, message, type }],
}));
},
removeToast: (id: string) => {
set((state) => ({
toasts: state.toasts.filter((toast) => toast.id !== id),
}));
},
showConfirm: (data: Omit<ConfirmData, 'onCancel'>) => {
return new Promise<boolean>((resolve) => {
set({
confirm: {
...data,
onCancel: () => {
set({ confirm: null });
resolve(false);
},
},
});
// Override onConfirm to resolve promise
const originalOnConfirm = data.onConfirm;
set((state) => ({
confirm: state.confirm ? {
...state.confirm,
onConfirm: () => {
originalOnConfirm();
set({ confirm: null });
resolve(true);
},
} : null,
}));
});
},
hideConfirm: () => {
set({ confirm: null });
},
toggleDarkMode: () => {
const newMode = !get().darkMode;
set({ darkMode: newMode });
localStorage.setItem('darkMode', String(newMode));
if (newMode) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
},
setDarkMode: (enabled: boolean) => {
set({ darkMode: enabled });
localStorage.setItem('darkMode', String(enabled));
if (enabled) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
},
}));
// Initialize dark mode on load
if (localStorage.getItem('darkMode') === 'true') {
document.documentElement.classList.add('dark');
}

View File

@@ -1,11 +0,0 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}