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 { ProtectedRoute } from './components/auth/ProtectedRoute';
|
||||||
import { Dashboard } from './pages/Dashboard';
|
import { Dashboard } from './pages/Dashboard';
|
||||||
import { SharedMap } from './pages/SharedMap';
|
import { SharedMap } from './pages/SharedMap';
|
||||||
|
import { ToastContainer } from './components/common/ToastContainer';
|
||||||
|
import { ConfirmContainer } from './components/common/ConfirmContainer';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@@ -22,6 +24,8 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
<ToastContainer />
|
||||||
|
<ConfirmContainer />
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { useState, FormEvent } from 'react';
|
import { useState, FormEvent } from 'react';
|
||||||
import { useNavigate, Link } from 'react-router-dom';
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
|
|
||||||
export function Login() {
|
export function Login() {
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const { login, isLoading, error, clearError } = useAuthStore();
|
const { login, isLoading, error, clearError } = useAuthStore();
|
||||||
|
const { darkMode, toggleDarkMode } = useUIStore();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const handleSubmit = async (e: FormEvent) => {
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
@@ -21,21 +23,41 @@ export function Login() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
<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="bg-white p-8 rounded-lg shadow-md w-full max-w-md">
|
<div className="absolute top-4 right-4">
|
||||||
<h1 className="text-2xl font-bold mb-6 text-center text-gray-800">
|
<button
|
||||||
ISP Wiremap
|
onClick={toggleDarkMode}
|
||||||
</h1>
|
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 && (
|
{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}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<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
|
Username
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -44,13 +66,14 @@ export function Login() {
|
|||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
required
|
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}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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
|
Password
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -59,7 +82,8 @@ export function Login() {
|
|||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
required
|
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}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -67,15 +91,25 @@ export function Login() {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isLoading}
|
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>
|
</button>
|
||||||
|
|
||||||
<div className="text-center mt-4">
|
<div className="text-center mt-6">
|
||||||
<span className="text-gray-600">Don't have an account? </span>
|
<span className="text-gray-600 dark:text-gray-400">Don't have an account? </span>
|
||||||
<Link to="/register" className="text-blue-600 hover:text-blue-700 font-medium">
|
<Link to="/register" className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-semibold transition-colors">
|
||||||
Register
|
Create one
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,26 +1,30 @@
|
|||||||
import { useState, FormEvent } from 'react';
|
import { useState, FormEvent } from 'react';
|
||||||
import { useNavigate, Link } from 'react-router-dom';
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
|
|
||||||
export function Register() {
|
export function Register() {
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [confirmPassword, setConfirmPassword] = useState('');
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [validationError, setValidationError] = useState('');
|
||||||
const { register, isLoading, error, clearError } = useAuthStore();
|
const { register, isLoading, error, clearError } = useAuthStore();
|
||||||
|
const { darkMode, toggleDarkMode } = useUIStore();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const handleSubmit = async (e: FormEvent) => {
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
clearError();
|
clearError();
|
||||||
|
setValidationError('');
|
||||||
|
|
||||||
if (password !== confirmPassword) {
|
if (password !== confirmPassword) {
|
||||||
alert('Passwords do not match');
|
setValidationError('Passwords do not match');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (password.length < 6) {
|
if (password.length < 6) {
|
||||||
alert('Password must be at least 6 characters');
|
setValidationError('Password must be at least 6 characters');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,24 +37,41 @@ export function Register() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
<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="bg-white p-8 rounded-lg shadow-md w-full max-w-md">
|
<div className="absolute top-4 right-4">
|
||||||
<h1 className="text-2xl font-bold mb-2 text-center text-gray-800">
|
<button
|
||||||
Create Account
|
onClick={toggleDarkMode}
|
||||||
</h1>
|
className="p-3 bg-white dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-full shadow-lg transition-colors"
|
||||||
<p className="text-center text-gray-600 mb-6">
|
title={darkMode ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||||
Join ISP Wiremap
|
>
|
||||||
</p>
|
<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">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
{error && (
|
{(error || validationError) && (
|
||||||
<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}
|
{error || validationError}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<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
|
Username
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -60,13 +81,14 @@ export function Register() {
|
|||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
required
|
required
|
||||||
minLength={3}
|
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}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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
|
Email
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -75,13 +97,14 @@ export function Register() {
|
|||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
required
|
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}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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
|
Password
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -91,13 +114,14 @@ export function Register() {
|
|||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
required
|
required
|
||||||
minLength={6}
|
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}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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
|
Confirm Password
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -107,7 +131,8 @@ export function Register() {
|
|||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
required
|
required
|
||||||
minLength={6}
|
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}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -115,15 +140,25 @@ export function Register() {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isLoading}
|
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>
|
</button>
|
||||||
|
|
||||||
<div className="text-center mt-4">
|
<div className="text-center mt-6">
|
||||||
<span className="text-gray-600">Already have an account? </span>
|
<span className="text-gray-600 dark:text-gray-400">Already have an account? </span>
|
||||||
<Link to="/login" className="text-blue-600 hover:text-blue-700 font-medium">
|
<Link to="/login" className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-semibold transition-colors">
|
||||||
Login
|
Sign in
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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 { ReactNode } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
|
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -8,6 +9,7 @@ interface LayoutProps {
|
|||||||
|
|
||||||
export function Layout({ children }: LayoutProps) {
|
export function Layout({ children }: LayoutProps) {
|
||||||
const { user, logout } = useAuthStore();
|
const { user, logout } = useAuthStore();
|
||||||
|
const { darkMode, toggleDarkMode, showToast } = useUIStore();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
@@ -15,35 +17,49 @@ export function Layout({ children }: LayoutProps) {
|
|||||||
navigate('/login');
|
navigate('/login');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCopyId = () => {
|
||||||
|
if (user) {
|
||||||
|
navigator.clipboard.writeText(user.id);
|
||||||
|
showToast('User ID copied to clipboard!', 'success');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex flex-col">
|
<div className="h-screen flex flex-col bg-gray-50 dark:bg-gray-900 transition-colors">
|
||||||
<header className="bg-blue-600 text-white shadow-md">
|
<header className="bg-blue-600 dark:bg-gray-800 text-white shadow-md">
|
||||||
<div className="px-4 py-3 flex items-center justify-between">
|
<div className="px-4 py-3 flex items-center justify-between">
|
||||||
<h1 className="text-xl font-bold">ISP Wiremap</h1>
|
<h1 className="text-xl font-bold">ISP Wiremap</h1>
|
||||||
|
|
||||||
{user && (
|
{user && (
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-sm">
|
<span className="text-sm">
|
||||||
{user.username}
|
{user.username}
|
||||||
{user.is_admin && (
|
{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
|
Admin
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={toggleDarkMode}
|
||||||
navigator.clipboard.writeText(user.id);
|
className="p-2 bg-blue-700 dark:bg-gray-700 hover:bg-blue-800 dark:hover:bg-gray-600 rounded-lg transition-colors"
|
||||||
alert('Your User ID has been copied to clipboard!');
|
title={darkMode ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||||
}}
|
>
|
||||||
className="px-2 py-1 bg-blue-700 hover:bg-blue-800 rounded text-xs"
|
{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"
|
title="Copy your User ID for sharing"
|
||||||
>
|
>
|
||||||
Copy My ID
|
Copy My ID
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
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
|
Logout
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { mapItemService } from '../../services/mapItemService';
|
import { mapItemService } from '../../services/mapItemService';
|
||||||
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
|
|
||||||
interface ItemContextMenuProps {
|
interface ItemContextMenuProps {
|
||||||
item: any;
|
item: any;
|
||||||
@@ -9,15 +10,16 @@ interface ItemContextMenuProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ItemContextMenu({ item, position, onClose, onUpdate }: ItemContextMenuProps) {
|
export function ItemContextMenu({ item, position, onClose, onUpdate }: ItemContextMenuProps) {
|
||||||
|
const { showToast } = useUIStore();
|
||||||
const [showRenameDialog, setShowRenameDialog] = useState(false);
|
const [showRenameDialog, setShowRenameDialog] = useState(false);
|
||||||
const [showNotesDialog, setShowNotesDialog] = useState(false);
|
const [showNotesDialog, setShowNotesDialog] = useState(false);
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
|
||||||
const [showPortConfigDialog, setShowPortConfigDialog] = 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 [newName, setNewName] = useState(item.properties.name || '');
|
||||||
const [notes, setNotes] = useState(item.properties.notes || '');
|
const [notes, setNotes] = useState(item.properties.notes || '');
|
||||||
const [imageData, setImageData] = useState<string | null>(item.properties.image || null);
|
const [imageData, setImageData] = useState<string | null>(item.properties.image || null);
|
||||||
const [portCount, setPortCount] = useState(item.properties.port_count || 5);
|
const [portCount, setPortCount] = useState(item.properties.port_count || 5);
|
||||||
const [deleteConnectedCables, setDeleteConnectedCables] = useState(false);
|
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -72,11 +74,12 @@ export function ItemContextMenu({ item, position, onClose, onUpdate }: ItemConte
|
|||||||
|
|
||||||
onUpdate();
|
onUpdate();
|
||||||
onClose();
|
onClose();
|
||||||
|
showToast('Item deleted successfully', 'success');
|
||||||
console.log('Delete process completed successfully');
|
console.log('Delete process completed successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to delete item:', error);
|
console.error('Failed to delete item:', error);
|
||||||
console.error('Error details:', { error, item_id: item.id, map_id: item.map_id });
|
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();
|
onUpdate();
|
||||||
setShowRenameDialog(false);
|
setShowRenameDialog(false);
|
||||||
onClose();
|
onClose();
|
||||||
|
showToast('Item renamed successfully', 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to rename item:', 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)
|
// Check file size (max 2MB)
|
||||||
if (file.size > 2 * 1024 * 1024) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check file type
|
// Check file type
|
||||||
if (!file.type.startsWith('image/')) {
|
if (!file.type.startsWith('image/')) {
|
||||||
alert('Please select an image file.');
|
showToast('Please select an image file.', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,9 +143,10 @@ export function ItemContextMenu({ item, position, onClose, onUpdate }: ItemConte
|
|||||||
onUpdate();
|
onUpdate();
|
||||||
setShowNotesDialog(false);
|
setShowNotesDialog(false);
|
||||||
onClose();
|
onClose();
|
||||||
|
showToast('Notes saved successfully', 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save notes:', 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();
|
onUpdate();
|
||||||
setShowPortConfigDialog(false);
|
setShowPortConfigDialog(false);
|
||||||
onClose();
|
onClose();
|
||||||
|
showToast('Port configuration saved', 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save port configuration:', 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={menuRef}
|
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' }}
|
style={{ left: position.x, top: position.y, zIndex: 10000, minWidth: '250px' }}
|
||||||
>
|
>
|
||||||
<h3 className="font-semibold mb-2">Configure Ports</h3>
|
<h3 className="font-semibold mb-2 text-gray-800 dark:text-white">Configure Ports</h3>
|
||||||
<label className="block text-sm text-gray-600 mb-2">
|
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||||
Total number of ports:
|
Total number of ports:
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -179,26 +185,26 @@ export function ItemContextMenu({ item, position, onClose, onUpdate }: ItemConte
|
|||||||
max="96"
|
max="96"
|
||||||
value={portCount}
|
value={portCount}
|
||||||
onChange={(e) => setPortCount(parseInt(e.target.value) || 1)}
|
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
|
autoFocus
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') handleSavePortConfig();
|
if (e.key === 'Enter') handleSavePortConfig();
|
||||||
if (e.key === 'Escape') { setShowPortConfigDialog(false); onClose(); }
|
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
|
Currently used: {item.properties.connections?.length || 0} ports
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={handleSavePortConfig}
|
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
|
Save
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setShowPortConfigDialog(false); onClose(); }}
|
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
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
@@ -211,12 +217,12 @@ export function ItemContextMenu({ item, position, onClose, onUpdate }: ItemConte
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={menuRef}
|
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' }}
|
style={{ left: position.x, top: position.y, zIndex: 10000, minWidth: '300px' }}
|
||||||
>
|
>
|
||||||
<h3 className="font-semibold text-lg mb-2">Delete Item</h3>
|
<h3 className="font-semibold text-lg mb-2 text-gray-800 dark:text-white">Delete Item</h3>
|
||||||
<p className="text-gray-600 mb-4">
|
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||||
Are you sure you want to delete <span className="font-semibold">{item.properties.name || item.type}</span>?
|
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 && (
|
{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>
|
<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"
|
type="checkbox"
|
||||||
checked={deleteConnectedCables}
|
checked={deleteConnectedCables}
|
||||||
onChange={(e) => setDeleteConnectedCables(e.target.checked)}
|
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' : ''}
|
Also delete {item.properties.connections.length} connected cable{item.properties.connections.length !== 1 ? 's' : ''}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -240,13 +246,13 @@ export function ItemContextMenu({ item, position, onClose, onUpdate }: ItemConte
|
|||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={handleDelete}
|
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
|
Delete
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setShowDeleteDialog(false); setDeleteConnectedCables(false); onClose(); }}
|
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
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
@@ -259,15 +265,15 @@ export function ItemContextMenu({ item, position, onClose, onUpdate }: ItemConte
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={menuRef}
|
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' }}
|
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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={newName}
|
value={newName}
|
||||||
onChange={(e) => setNewName(e.target.value)}
|
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"
|
placeholder="Enter new name"
|
||||||
autoFocus
|
autoFocus
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
@@ -278,13 +284,13 @@ export function ItemContextMenu({ item, position, onClose, onUpdate }: ItemConte
|
|||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={handleRename}
|
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
|
Save
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setShowRenameDialog(false); onClose(); }}
|
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
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
@@ -297,14 +303,14 @@ export function ItemContextMenu({ item, position, onClose, onUpdate }: ItemConte
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={menuRef}
|
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' }}
|
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
|
<textarea
|
||||||
value={notes}
|
value={notes}
|
||||||
onChange={(e) => setNotes(e.target.value)}
|
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"
|
placeholder="Enter notes"
|
||||||
rows={4}
|
rows={4}
|
||||||
autoFocus
|
autoFocus
|
||||||
@@ -315,7 +321,7 @@ export function ItemContextMenu({ item, position, onClose, onUpdate }: ItemConte
|
|||||||
|
|
||||||
{/* Image upload section */}
|
{/* Image upload section */}
|
||||||
<div className="mb-3">
|
<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)
|
Attach Image (optional)
|
||||||
</label>
|
</label>
|
||||||
{imageData ? (
|
{imageData ? (
|
||||||
@@ -323,12 +329,12 @@ export function ItemContextMenu({ item, position, onClose, onUpdate }: ItemConte
|
|||||||
<img
|
<img
|
||||||
src={imageData}
|
src={imageData}
|
||||||
alt="Attached"
|
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' }}
|
style={{ maxHeight: '200px', objectFit: 'contain' }}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={handleRemoveImage}
|
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>
|
</button>
|
||||||
@@ -339,22 +345,22 @@ export function ItemContextMenu({ item, position, onClose, onUpdate }: ItemConte
|
|||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
onChange={handleImageUpload}
|
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>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={handleSaveNotes}
|
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
|
Save
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setShowNotesDialog(false); onClose(); }}
|
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
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
@@ -366,33 +372,33 @@ export function ItemContextMenu({ item, position, onClose, onUpdate }: ItemConte
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={menuRef}
|
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' }}
|
style={{ left: position.x, top: position.y, zIndex: 10000, minWidth: '180px' }}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowRenameDialog(true)}
|
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
|
Rename
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowNotesDialog(true)}
|
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'}
|
{item.properties.notes ? 'Edit Notes' : 'Add Notes'}
|
||||||
</button>
|
</button>
|
||||||
{isSwitch && (
|
{isSwitch && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowPortConfigDialog(true)}
|
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
|
Configure Ports
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={() => setShowDeleteDialog(true)}
|
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
|
Delete
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -15,57 +15,24 @@ interface LayerSwitcherProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function LayerSwitcher({ activeLayer, onLayerChange, layers }: LayerSwitcherProps) {
|
export function LayerSwitcher({ activeLayer, onLayerChange, layers }: LayerSwitcherProps) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="space-y-1">
|
||||||
<button
|
{Object.entries(layers).map(([key, layer]) => (
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
<button
|
||||||
className="bg-white shadow-md rounded-lg px-4 py-2 hover:bg-gray-50 flex items-center gap-2"
|
key={key}
|
||||||
>
|
onClick={() => onLayerChange(key)}
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
className={`w-full px-3 py-2 rounded text-left flex items-center gap-2 transition-colors ${
|
||||||
<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" />
|
activeLayer === key
|
||||||
</svg>
|
? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 font-medium shadow-sm'
|
||||||
<span className="text-sm font-medium">
|
: 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300'
|
||||||
{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"
|
|
||||||
>
|
>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
</svg>
|
<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" />
|
||||||
</button>
|
</svg>
|
||||||
|
<span className="text-sm">{layer.name}</span>
|
||||||
{isOpen && (
|
</button>
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -157,21 +157,21 @@ export function MapItemsLayer({ mapId, refreshTrigger, readOnly = false }: MapIt
|
|||||||
>
|
>
|
||||||
{!shouldSuppressPopups && (
|
{!shouldSuppressPopups && (
|
||||||
<Popup>
|
<Popup>
|
||||||
<div className="text-sm" style={{ minWidth: '200px' }}>
|
<div className="text-sm dark:bg-gray-800 dark:text-white" style={{ minWidth: '200px' }}>
|
||||||
<div className="font-semibold">{item.properties.name || 'Cable'}</div>
|
<div className="font-semibold text-gray-900 dark:text-white">{item.properties.name || 'Cable'}</div>
|
||||||
<div className="text-gray-600">Type: {cableType}</div>
|
<div className="text-gray-600 dark:text-gray-400">Type: {cableType}</div>
|
||||||
{startDevice && (
|
{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}
|
From: {startDevice.properties.name || startDevice.type}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{endDevice && (
|
{endDevice && (
|
||||||
<div className="text-gray-600">
|
<div className="text-gray-600 dark:text-gray-400">
|
||||||
To: {endDevice.properties.name || endDevice.type}
|
To: {endDevice.properties.name || endDevice.type}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{item.properties.notes && (
|
{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}
|
{item.properties.notes}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -180,7 +180,7 @@ export function MapItemsLayer({ mapId, refreshTrigger, readOnly = false }: MapIt
|
|||||||
<img
|
<img
|
||||||
src={item.properties.image}
|
src={item.properties.image}
|
||||||
alt="Attachment"
|
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' }}
|
style={{ maxHeight: '150px', objectFit: 'contain' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -244,20 +244,20 @@ export function MapItemsLayer({ mapId, refreshTrigger, readOnly = false }: MapIt
|
|||||||
>
|
>
|
||||||
{!shouldSuppressPopups && (
|
{!shouldSuppressPopups && (
|
||||||
<Popup>
|
<Popup>
|
||||||
<div className="text-sm" style={{ minWidth: '200px' }}>
|
<div className="text-sm dark:bg-gray-800 dark:text-white" style={{ minWidth: '200px' }}>
|
||||||
<div className="font-semibold">{item.properties.name || 'Wireless Mesh'}</div>
|
<div className="font-semibold text-gray-900 dark:text-white">{item.properties.name || 'Wireless Mesh'}</div>
|
||||||
{startAp && (
|
{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}
|
From: {startAp.properties.name || startAp.type}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{endAp && (
|
{endAp && (
|
||||||
<div className="text-gray-600">
|
<div className="text-gray-600 dark:text-gray-400">
|
||||||
To: {endAp.properties.name || endAp.type}
|
To: {endAp.properties.name || endAp.type}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{item.properties.notes && (
|
{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}
|
{item.properties.notes}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -266,7 +266,7 @@ export function MapItemsLayer({ mapId, refreshTrigger, readOnly = false }: MapIt
|
|||||||
<img
|
<img
|
||||||
src={item.properties.image}
|
src={item.properties.image}
|
||||||
alt="Attachment"
|
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' }}
|
style={{ maxHeight: '150px', objectFit: 'contain' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -306,19 +306,19 @@ export function MapItemsLayer({ mapId, refreshTrigger, readOnly = false }: MapIt
|
|||||||
>
|
>
|
||||||
{!shouldSuppressPopups && (
|
{!shouldSuppressPopups && (
|
||||||
<Popup>
|
<Popup>
|
||||||
<div className="text-sm" style={{ minWidth: '200px' }}>
|
<div className="text-sm dark:bg-gray-800 dark:text-white" style={{ minWidth: '200px' }}>
|
||||||
<div className="font-semibold">{item.properties.name || item.type}</div>
|
<div className="font-semibold text-gray-900 dark:text-white">{item.properties.name || item.type}</div>
|
||||||
<div className="text-gray-600">Type: {item.type}</div>
|
<div className="text-gray-600 dark:text-gray-400">Type: {item.type}</div>
|
||||||
{item.properties.port_count && (
|
{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}
|
Ports: {item.properties.connections?.length || 0} / {item.properties.port_count}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Show port connections details */}
|
{/* Show port connections details */}
|
||||||
{item.properties.connections && item.properties.connections.length > 0 && (
|
{item.properties.connections && item.properties.connections.length > 0 && (
|
||||||
<div className="mt-2 pt-2 border-t border-gray-200">
|
<div className="mt-2 pt-2 border-t border-gray-200 dark:border-gray-700">
|
||||||
<div className="font-semibold text-gray-700 mb-1">Port Connections:</div>
|
<div className="font-semibold text-gray-700 dark:text-gray-300 mb-1">Port Connections:</div>
|
||||||
{item.properties.connections
|
{item.properties.connections
|
||||||
.sort((a: any, b: any) => a.port_number - b.port_number)
|
.sort((a: any, b: any) => a.port_number - b.port_number)
|
||||||
.map((conn: any) => {
|
.map((conn: any) => {
|
||||||
@@ -337,7 +337,7 @@ export function MapItemsLayer({ mapId, refreshTrigger, readOnly = false }: MapIt
|
|||||||
const cableType = cable.properties.cable_type;
|
const cableType = cable.properties.cable_type;
|
||||||
|
|
||||||
return (
|
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
|
Port {conn.port_number} → {otherDevice
|
||||||
? `${otherDevice.properties.name || otherDevice.type} (${cableType})`
|
? `${otherDevice.properties.name || otherDevice.type} (${cableType})`
|
||||||
: `${cableType} cable`}
|
: `${cableType} cable`}
|
||||||
@@ -349,7 +349,7 @@ export function MapItemsLayer({ mapId, refreshTrigger, readOnly = false }: MapIt
|
|||||||
|
|
||||||
{/* Show wireless mesh count for APs */}
|
{/* Show wireless mesh count for APs */}
|
||||||
{['indoor_ap', 'outdoor_ap'].includes(item.type) && (
|
{['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 =>
|
Wireless mesh: {items.filter(i =>
|
||||||
i.type === 'wireless_mesh' &&
|
i.type === 'wireless_mesh' &&
|
||||||
(i.properties.start_ap_id === item.id || i.properties.end_ap_id === item.id)
|
(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) {
|
if (meshLinks.length > 0) {
|
||||||
return (
|
return (
|
||||||
<div className="mt-2 pt-2 border-t border-gray-200">
|
<div className="mt-2 pt-2 border-t border-gray-200 dark:border-gray-700">
|
||||||
<div className="font-semibold text-gray-700 mb-1">Mesh Connections:</div>
|
<div className="font-semibold text-gray-700 dark:text-gray-300 mb-1">Mesh Connections:</div>
|
||||||
{meshLinks.map((mesh) => {
|
{meshLinks.map((mesh) => {
|
||||||
const otherApId = mesh.properties.start_ap_id === item.id
|
const otherApId = mesh.properties.start_ap_id === item.id
|
||||||
? mesh.properties.end_ap_id
|
? mesh.properties.end_ap_id
|
||||||
@@ -378,7 +378,7 @@ export function MapItemsLayer({ mapId, refreshTrigger, readOnly = false }: MapIt
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
return (
|
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'}
|
→ {otherAp ? (otherAp.properties.name || otherAp.type) : 'Unknown AP'}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -390,7 +390,7 @@ export function MapItemsLayer({ mapId, refreshTrigger, readOnly = false }: MapIt
|
|||||||
})()}
|
})()}
|
||||||
|
|
||||||
{item.properties.notes && (
|
{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}
|
{item.properties.notes}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -399,7 +399,7 @@ export function MapItemsLayer({ mapId, refreshTrigger, readOnly = false }: MapIt
|
|||||||
<img
|
<img
|
||||||
src={item.properties.image}
|
src={item.properties.image}
|
||||||
alt="Attachment"
|
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' }}
|
style={{ maxHeight: '150px', objectFit: 'contain' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useMapStore } from '../../stores/mapStore';
|
import { useMapStore } from '../../stores/mapStore';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
import { mapService } from '../../services/mapService';
|
import { mapService } from '../../services/mapService';
|
||||||
|
|
||||||
interface MapListSidebarProps {
|
interface MapListSidebarProps {
|
||||||
onSelectMap: (mapId: string) => void;
|
onSelectMap: (mapId: string) => void;
|
||||||
selectedMapId: string | null;
|
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 { maps, setMaps, addMap, removeMap, setLoading, setError } = useMapStore();
|
||||||
const { user } = useAuthStore();
|
const { user } = useAuthStore();
|
||||||
|
const { showToast, showConfirm } = useUIStore();
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
const [newMapName, setNewMapName] = useState('');
|
const [newMapName, setNewMapName] = useState('');
|
||||||
|
|
||||||
@@ -39,31 +42,43 @@ export function MapListSidebar({ onSelectMap, selectedMapId }: MapListSidebarPro
|
|||||||
setNewMapName('');
|
setNewMapName('');
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
onSelectMap(newMap.id);
|
onSelectMap(newMap.id);
|
||||||
|
showToast('Map created successfully!', 'success');
|
||||||
} catch (error: any) {
|
} 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) => {
|
const handleDeleteMap = async (mapId: string) => {
|
||||||
if (!confirm('Are you sure you want to delete this map?')) return;
|
showConfirm({
|
||||||
|
title: 'Delete Map',
|
||||||
try {
|
message: 'Are you sure you want to delete this map? This action cannot be undone.',
|
||||||
await mapService.deleteMap(mapId);
|
confirmText: 'Delete',
|
||||||
removeMap(mapId);
|
type: 'danger',
|
||||||
} catch (error: any) {
|
onConfirm: async () => {
|
||||||
setError(error.response?.data?.detail || 'Failed to delete map');
|
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 (
|
return (
|
||||||
<div className="w-80 bg-white border-r border-gray-200 flex flex-col">
|
<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">
|
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h2 className="text-lg font-semibold mb-3">My Maps</h2>
|
<h2 className="text-lg font-semibold mb-3 text-gray-900 dark:text-white">My Maps</h2>
|
||||||
|
|
||||||
{!isCreating ? (
|
{!isCreating ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsCreating(true)}
|
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
|
+ New Map
|
||||||
</button>
|
</button>
|
||||||
@@ -74,14 +89,14 @@ export function MapListSidebar({ onSelectMap, selectedMapId }: MapListSidebarPro
|
|||||||
value={newMapName}
|
value={newMapName}
|
||||||
onChange={(e) => setNewMapName(e.target.value)}
|
onChange={(e) => setNewMapName(e.target.value)}
|
||||||
placeholder="Map name"
|
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
|
autoFocus
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleCreateMap()}
|
onKeyDown={(e) => e.key === 'Enter' && handleCreateMap()}
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={handleCreateMap}
|
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
|
Create
|
||||||
</button>
|
</button>
|
||||||
@@ -90,7 +105,7 @@ export function MapListSidebar({ onSelectMap, selectedMapId }: MapListSidebarPro
|
|||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
setNewMapName('');
|
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
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
@@ -101,11 +116,11 @@ export function MapListSidebar({ onSelectMap, selectedMapId }: MapListSidebarPro
|
|||||||
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{maps.length === 0 ? (
|
{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!
|
No maps yet. Create your first map!
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y divide-gray-200">
|
<div className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
{maps.map((map) => {
|
{maps.map((map) => {
|
||||||
const isOwner = user && map.owner_id === user.id;
|
const isOwner = user && map.owner_id === user.id;
|
||||||
const isShared = !isOwner;
|
const isShared = !isOwner;
|
||||||
@@ -113,38 +128,51 @@ export function MapListSidebar({ onSelectMap, selectedMapId }: MapListSidebarPro
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={map.id}
|
key={map.id}
|
||||||
className={`p-4 cursor-pointer hover:bg-gray-50 ${
|
className={`p-4 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors ${
|
||||||
selectedMapId === map.id ? 'bg-blue-50 border-l-4 border-blue-600' : ''
|
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)}
|
onClick={() => onSelectMap(map.id)}
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-start">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<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 && (
|
{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
|
Shared
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{map.description && (
|
{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()}
|
{new Date(map.updated_at).toLocaleDateString()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{isOwner && (
|
{isOwner && (
|
||||||
<button
|
<div className="flex flex-col gap-1 ml-2">
|
||||||
onClick={(e) => {
|
<button
|
||||||
e.stopPropagation();
|
onClick={(e) => {
|
||||||
handleDeleteMap(map.id);
|
e.stopPropagation();
|
||||||
}}
|
onShareMap?.(map.id);
|
||||||
className="text-red-600 hover:text-red-800 text-sm ml-2"
|
}}
|
||||||
>
|
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"
|
||||||
Delete
|
>
|
||||||
</button>
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { MapContainer, TileLayer, useMap } from 'react-leaflet';
|
import { MapContainer, TileLayer, useMap } from 'react-leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import { Toolbar } from './Toolbar';
|
|
||||||
import { LayerSwitcher } from './LayerSwitcher';
|
|
||||||
import { DrawingHandler } from './DrawingHandler';
|
import { DrawingHandler } from './DrawingHandler';
|
||||||
import { MapItemsLayer } from './MapItemsLayer';
|
import { MapItemsLayer } from './MapItemsLayer';
|
||||||
import { ShareDialog } from './ShareDialog';
|
import { ShareDialog } from './ShareDialog';
|
||||||
@@ -10,32 +8,13 @@ import { useMapWebSocket } from '../../hooks/useMapWebSocket';
|
|||||||
|
|
||||||
interface MapViewProps {
|
interface MapViewProps {
|
||||||
mapId: string | null;
|
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() {
|
function MapController() {
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
|
|
||||||
@@ -49,10 +28,8 @@ function MapController() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MapView({ mapId }: MapViewProps) {
|
export function MapView({ mapId, activeLayer, mapLayers, showShareDialog = false, shareMapId, onCloseShareDialog }: MapViewProps) {
|
||||||
const [activeLayer, setActiveLayer] = useState<MapLayer>('osm');
|
|
||||||
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
||||||
const [showShareDialog, setShowShareDialog] = useState(false);
|
|
||||||
|
|
||||||
const handleItemCreated = () => {
|
const handleItemCreated = () => {
|
||||||
// Trigger refresh of map items
|
// Trigger refresh of map items
|
||||||
@@ -81,37 +58,19 @@ export function MapView({ mapId }: MapViewProps) {
|
|||||||
|
|
||||||
if (!mapId) {
|
if (!mapId) {
|
||||||
return (
|
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">
|
<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-500 dark:text-gray-400 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-400 dark:text-gray-500 text-sm mt-2">or create a new one</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const layer = MAP_LAYERS[activeLayer];
|
const layer = mapLayers[activeLayer];
|
||||||
|
|
||||||
return (
|
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">
|
<div className="flex-1 relative">
|
||||||
<MapContainer
|
<MapContainer
|
||||||
center={[0, 0]}
|
center={[0, 0]}
|
||||||
@@ -139,10 +98,10 @@ export function MapView({ mapId }: MapViewProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Share dialog */}
|
{/* Share dialog */}
|
||||||
{showShareDialog && (
|
{showShareDialog && onCloseShareDialog && shareMapId && (
|
||||||
<ShareDialog
|
<ShareDialog
|
||||||
mapId={mapId}
|
mapId={shareMapId}
|
||||||
onClose={() => setShowShareDialog(false)}
|
onClose={onCloseShareDialog}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { mapShareService, type MapShare, type MapShareLink } from '../../services/mapShareService';
|
import { mapShareService, type MapShare, type MapShareLink } from '../../services/mapShareService';
|
||||||
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
|
|
||||||
interface ShareDialogProps {
|
interface ShareDialogProps {
|
||||||
mapId: string;
|
mapId: string;
|
||||||
@@ -7,6 +8,7 @@ interface ShareDialogProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ShareDialog({ mapId, onClose }: ShareDialogProps) {
|
export function ShareDialog({ mapId, onClose }: ShareDialogProps) {
|
||||||
|
const { showToast, showConfirm } = useUIStore();
|
||||||
const [activeTab, setActiveTab] = useState<'users' | 'links'>('users');
|
const [activeTab, setActiveTab] = useState<'users' | 'links'>('users');
|
||||||
const [userShares, setUserShares] = useState<MapShare[]>([]);
|
const [userShares, setUserShares] = useState<MapShare[]>([]);
|
||||||
const [shareLinks, setShareLinks] = useState<MapShareLink[]>([]);
|
const [shareLinks, setShareLinks] = useState<MapShareLink[]>([]);
|
||||||
@@ -54,12 +56,11 @@ export function ShareDialog({ mapId, onClose }: ShareDialogProps) {
|
|||||||
});
|
});
|
||||||
setNewUserId('');
|
setNewUserId('');
|
||||||
await loadShares();
|
await loadShares();
|
||||||
alert('Map shared successfully!');
|
showToast('Map shared successfully!', 'success');
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Share error:', error);
|
console.error('Share error:', error);
|
||||||
// Show detailed error message
|
|
||||||
const message = error.response?.data?.detail || error.message || 'Failed to share map';
|
const message = error.response?.data?.detail || error.message || 'Failed to share map';
|
||||||
alert(message);
|
showToast(message, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -72,80 +73,94 @@ export function ShareDialog({ mapId, onClose }: ShareDialogProps) {
|
|||||||
permission: newLinkPermission,
|
permission: newLinkPermission,
|
||||||
});
|
});
|
||||||
await loadLinks();
|
await loadLinks();
|
||||||
alert('Share link created successfully!');
|
showToast('Share link created successfully!', 'success');
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Create link error:', error);
|
console.error('Create link error:', error);
|
||||||
const message = error.response?.data?.detail || error.message || 'Failed to create share link';
|
const message = error.response?.data?.detail || error.message || 'Failed to create share link';
|
||||||
alert(message);
|
showToast(message, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRevokeShare = async (shareId: string) => {
|
const handleRevokeShare = async (shareId: string) => {
|
||||||
if (!confirm('Are you sure you want to revoke this share?')) return;
|
showConfirm({
|
||||||
|
title: 'Revoke Access',
|
||||||
try {
|
message: 'Are you sure you want to revoke this user\'s access? They will be immediately disconnected.',
|
||||||
await mapShareService.revokeShare(mapId, shareId);
|
confirmText: 'Revoke',
|
||||||
await loadShares();
|
type: 'danger',
|
||||||
} catch (error: any) {
|
onConfirm: async () => {
|
||||||
console.error('Revoke share error:', error);
|
try {
|
||||||
const message = error.response?.data?.detail || error.message || 'Failed to revoke share';
|
await mapShareService.revokeShare(mapId, shareId);
|
||||||
alert(message);
|
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) => {
|
const handleDeleteLink = async (linkId: string) => {
|
||||||
if (!confirm('Are you sure you want to delete this share link?')) return;
|
showConfirm({
|
||||||
|
title: 'Delete Share Link',
|
||||||
try {
|
message: 'Are you sure you want to delete this share link? Anyone with this link will lose access.',
|
||||||
await mapShareService.deleteShareLink(mapId, linkId);
|
confirmText: 'Delete',
|
||||||
await loadLinks();
|
type: 'danger',
|
||||||
} catch (error: any) {
|
onConfirm: async () => {
|
||||||
console.error('Delete link error:', error);
|
try {
|
||||||
const message = error.response?.data?.detail || error.message || 'Failed to delete link';
|
await mapShareService.deleteShareLink(mapId, linkId);
|
||||||
alert(message);
|
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 copyLinkToClipboard = (token: string) => {
|
||||||
const url = `${window.location.origin}/shared/${token}`;
|
const url = `${window.location.origin}/shared/${token}`;
|
||||||
navigator.clipboard.writeText(url);
|
navigator.clipboard.writeText(url);
|
||||||
alert('Link copied to clipboard!');
|
showToast('Link copied to clipboard!', 'success');
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center" style={{ zIndex: 10001 }}>
|
<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 rounded-lg shadow-xl w-full max-w-2xl max-h-[80vh] overflow-hidden">
|
<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 */}
|
{/* Header */}
|
||||||
<div className="px-6 py-4 border-b border-gray-200 flex justify-between items-center">
|
<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">Share Map</h2>
|
<h2 className="text-xl font-bold text-gray-800 dark:text-white">Share Map</h2>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="flex border-b border-gray-200">
|
<div className="flex border-b border-gray-200 dark:border-gray-700">
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('users')}
|
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'
|
activeTab === 'users'
|
||||||
? 'border-b-2 border-blue-600 text-blue-600'
|
? 'border-b-2 border-blue-600 text-blue-600 dark:text-blue-400'
|
||||||
: 'text-gray-600 hover:text-gray-800'
|
: 'text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Share with Users
|
Share with Users
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('links')}
|
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'
|
activeTab === 'links'
|
||||||
? 'border-b-2 border-blue-600 text-blue-600'
|
? 'border-b-2 border-blue-600 text-blue-600 dark:text-blue-400'
|
||||||
: 'text-gray-600 hover:text-gray-800'
|
: 'text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Public Links
|
Public Links
|
||||||
@@ -158,10 +173,10 @@ export function ShareDialog({ mapId, onClose }: ShareDialogProps) {
|
|||||||
<div>
|
<div>
|
||||||
{/* Add user form */}
|
{/* Add user form */}
|
||||||
<form onSubmit={handleShareWithUser} className="mb-6">
|
<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
|
Share with User
|
||||||
</label>
|
</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
|
Enter a username, email, or user ID
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -170,13 +185,13 @@ export function ShareDialog({ mapId, onClose }: ShareDialogProps) {
|
|||||||
value={newUserId}
|
value={newUserId}
|
||||||
onChange={(e) => setNewUserId(e.target.value)}
|
onChange={(e) => setNewUserId(e.target.value)}
|
||||||
placeholder="Enter username, email, or user ID"
|
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}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
<select
|
<select
|
||||||
value={newUserPermission}
|
value={newUserPermission}
|
||||||
onChange={(e) => setNewUserPermission(e.target.value as 'read' | 'edit')}
|
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}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
<option value="read">Read-only</option>
|
<option value="read">Read-only</option>
|
||||||
@@ -185,7 +200,7 @@ export function ShareDialog({ mapId, onClose }: ShareDialogProps) {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading || !newUserId.trim()}
|
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
|
Share
|
||||||
</button>
|
</button>
|
||||||
@@ -194,25 +209,25 @@ export function ShareDialog({ mapId, onClose }: ShareDialogProps) {
|
|||||||
|
|
||||||
{/* Existing shares */}
|
{/* Existing shares */}
|
||||||
<div>
|
<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 ? (
|
{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">
|
<div className="space-y-2">
|
||||||
{userShares.map((share) => (
|
{userShares.map((share) => (
|
||||||
<div
|
<div
|
||||||
key={share.id}
|
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>
|
||||||
<div className="font-medium text-sm">{share.user_id}</div>
|
<div className="font-medium text-sm text-gray-900 dark:text-white">{share.user_id}</div>
|
||||||
<div className="text-xs text-gray-500">
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
{share.permission === 'read' ? 'Read-only' : 'Can edit'}
|
{share.permission === 'read' ? 'Read-only' : 'Can edit'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleRevokeShare(share.id)}
|
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
|
Revoke
|
||||||
</button>
|
</button>
|
||||||
@@ -228,14 +243,14 @@ export function ShareDialog({ mapId, onClose }: ShareDialogProps) {
|
|||||||
<div>
|
<div>
|
||||||
{/* Create link form */}
|
{/* Create link form */}
|
||||||
<div className="mb-6">
|
<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
|
Create Public Share Link
|
||||||
</label>
|
</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<select
|
<select
|
||||||
value={newLinkPermission}
|
value={newLinkPermission}
|
||||||
onChange={(e) => setNewLinkPermission(e.target.value as 'read' | 'edit')}
|
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}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
<option value="read">Read-only</option>
|
<option value="read">Read-only</option>
|
||||||
@@ -244,7 +259,7 @@ export function ShareDialog({ mapId, onClose }: ShareDialogProps) {
|
|||||||
<button
|
<button
|
||||||
onClick={handleCreateLink}
|
onClick={handleCreateLink}
|
||||||
disabled={loading}
|
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
|
Create Link
|
||||||
</button>
|
</button>
|
||||||
@@ -253,36 +268,36 @@ export function ShareDialog({ mapId, onClose }: ShareDialogProps) {
|
|||||||
|
|
||||||
{/* Existing links */}
|
{/* Existing links */}
|
||||||
<div>
|
<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 ? (
|
{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">
|
<div className="space-y-3">
|
||||||
{shareLinks.map((link) => (
|
{shareLinks.map((link) => (
|
||||||
<div
|
<div
|
||||||
key={link.id}
|
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 items-start justify-between mb-2">
|
||||||
<div className="flex-1">
|
<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'} •
|
{link.permission === 'read' ? 'Read-only' : 'Can edit'} •
|
||||||
Created {new Date(link.created_at).toLocaleDateString()}
|
Created {new Date(link.created_at).toLocaleDateString()}
|
||||||
</div>
|
</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}
|
{window.location.origin}/shared/{link.token}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDeleteLink(link.id)}
|
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
|
Delete
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => copyLinkToClipboard(link.token)}
|
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
|
Copy Link
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { CABLE_COLORS, CABLE_LABELS } from '../../types/mapItem';
|
|||||||
|
|
||||||
interface ToolbarProps {
|
interface ToolbarProps {
|
||||||
mapId: string;
|
mapId: string;
|
||||||
onShare: () => void;
|
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,12 +15,20 @@ interface ToolButton {
|
|||||||
description: string;
|
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',
|
id: 'wireless_mesh',
|
||||||
label: 'Select',
|
label: 'Wireless',
|
||||||
icon: '👆',
|
icon: 'wireless',
|
||||||
description: 'Select and edit items',
|
color: '#10B981',
|
||||||
|
description: 'Wireless Mesh Link',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'fiber',
|
id: 'fiber',
|
||||||
@@ -44,83 +51,135 @@ const TOOLS: ToolButton[] = [
|
|||||||
color: CABLE_COLORS.cat6_poe,
|
color: CABLE_COLORS.cat6_poe,
|
||||||
description: CABLE_LABELS.cat6_poe,
|
description: CABLE_LABELS.cat6_poe,
|
||||||
},
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const DEVICE_TOOLS: ToolButton[] = [
|
||||||
{
|
{
|
||||||
id: 'switch',
|
id: 'switch',
|
||||||
label: 'Switch',
|
label: 'Switch',
|
||||||
icon: '⚡',
|
icon: 'switch',
|
||||||
description: 'Network Switch',
|
description: 'Network Switch',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'indoor_ap',
|
id: 'indoor_ap',
|
||||||
label: 'Indoor AP',
|
label: 'Indoor AP',
|
||||||
icon: '📡',
|
icon: 'indoor_ap',
|
||||||
description: 'Indoor Access Point',
|
description: 'Indoor Access Point',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'outdoor_ap',
|
id: 'outdoor_ap',
|
||||||
label: 'Outdoor AP',
|
label: 'Outdoor AP',
|
||||||
icon: '📶',
|
icon: 'outdoor_ap',
|
||||||
description: 'Outdoor Access Point',
|
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 { 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 (
|
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 */}
|
{/* Read-only indicator */}
|
||||||
{readOnly && (
|
{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
|
Read-Only Mode
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Share button */}
|
{/* Tools section header */}
|
||||||
<button
|
<h2 className="text-lg font-semibold mb-3 text-gray-900 dark:text-white">Tools</h2>
|
||||||
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>
|
|
||||||
|
|
||||||
<div className="border-t border-gray-200 my-2"></div>
|
{/* Select tool */}
|
||||||
|
{renderToolButton(SELECT_TOOL)}
|
||||||
|
|
||||||
{TOOLS.map((tool) => {
|
<div className="border-t border-gray-200 dark:border-gray-700 my-2"></div>
|
||||||
const isDisabled = readOnly && tool.id !== 'select';
|
|
||||||
return (
|
{/* Connections section */}
|
||||||
<button
|
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 px-1 mb-2">Connections</h3>
|
||||||
key={tool.id}
|
{CONNECTION_TOOLS.map(renderToolButton)}
|
||||||
onClick={() => !isDisabled && setActiveTool(tool.id)}
|
|
||||||
disabled={isDisabled}
|
<div className="border-t border-gray-200 dark:border-gray-700 my-2"></div>
|
||||||
className={`w-full px-3 py-2 rounded text-left flex items-center gap-2 transition-colors ${
|
|
||||||
isDisabled
|
{/* Devices section */}
|
||||||
? 'opacity-50 cursor-not-allowed text-gray-400'
|
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 px-1 mb-2">Devices</h3>
|
||||||
: activeTool === tool.id
|
{DEVICE_TOOLS.map(renderToolButton)}
|
||||||
? '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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,52 @@
|
|||||||
@tailwind base;
|
@import "tailwindcss";
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
@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 */
|
/* Leaflet CSS will be imported in main.tsx */
|
||||||
|
|
||||||
@@ -35,6 +81,30 @@
|
|||||||
border: 3px solid;
|
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 {
|
.device-icon svg {
|
||||||
width: 24px;
|
width: 24px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
|
|||||||
@@ -2,18 +2,85 @@ import { useState } from 'react';
|
|||||||
import { Layout } from '../components/layout/Layout';
|
import { Layout } from '../components/layout/Layout';
|
||||||
import { MapListSidebar } from '../components/map/MapListSidebar';
|
import { MapListSidebar } from '../components/map/MapListSidebar';
|
||||||
import { MapView } from '../components/map/MapView';
|
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() {
|
export function Dashboard() {
|
||||||
const [selectedMapId, setSelectedMapId] = useState<string | null>(null);
|
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 (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<div className="flex h-full">
|
<div className="flex h-full">
|
||||||
<MapListSidebar
|
{/* Left sidebar with map list and toolbar */}
|
||||||
onSelectMap={setSelectedMapId}
|
<div className="flex flex-col bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 transition-colors">
|
||||||
selectedMapId={selectedMapId}
|
<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>
|
</div>
|
||||||
</Layout>
|
</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