UI redesign
This commit is contained in:
5
.build/prod/nginx.Dockerfile
Normal file
5
.build/prod/nginx.Dockerfile
Normal file
@@ -0,0 +1,5 @@
|
||||
FROM nginx
|
||||
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
WORKDIR /etc/nginx
|
||||
44
.build/prod/nginx.conf
Normal file
44
.build/prod/nginx.conf
Normal file
@@ -0,0 +1,44 @@
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
server_name _;
|
||||
access_log /dev/stdout;
|
||||
error_log /dev/stdout info;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://api:8000/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /ws/ {
|
||||
proxy_pass http://api:8000/ws/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# WebSocket timeout settings
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
proxy_connect_timeout 60s;
|
||||
}
|
||||
|
||||
location / {
|
||||
# In development, proxy to Vite dev server
|
||||
proxy_pass http://node:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
5
.build/prod/node.Dockerfile
Normal file
5
.build/prod/node.Dockerfile
Normal file
@@ -0,0 +1,5 @@
|
||||
FROM node:24-bookworm-slim
|
||||
|
||||
WORKDIR /var/www/html/public
|
||||
|
||||
CMD npm run dev -- --host 0.0.0.0
|
||||
7
.build/prod/python.Dockerfile
Normal file
7
.build/prod/python.Dockerfile
Normal file
@@ -0,0 +1,7 @@
|
||||
FROM python:3.11.4-slim-buster
|
||||
|
||||
WORKDIR /var/www/html/
|
||||
|
||||
COPY requirements.txt .
|
||||
|
||||
RUN pip install -r requirements.txt
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
20
public/src/components/common/ConfirmContainer.tsx
Normal file
20
public/src/components/common/ConfirmContainer.tsx
Normal 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 || (() => {})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
54
public/src/components/common/ConfirmDialog.tsx
Normal file
54
public/src/components/common/ConfirmDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
public/src/components/common/Toast.tsx
Normal file
45
public/src/components/common/Toast.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
public/src/components/common/ToastContainer.tsx
Normal file
19
public/src/components/common/ToastContainer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: '© <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: '© 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 © 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: '© <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: '© 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 © 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>
|
||||
);
|
||||
|
||||
108
public/src/stores/uiStore.ts
Normal file
108
public/src/stores/uiStore.ts
Normal 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');
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
Reference in New Issue
Block a user