public share UI fix
This commit is contained in:
@@ -98,5 +98,5 @@ async def delete_map_share_link(
|
|||||||
current_user: User = Depends(get_current_user)
|
current_user: User = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""Delete a share link."""
|
"""Delete a share link."""
|
||||||
map_share_service.delete_share_link(db, map_id, link_id, current_user)
|
await map_share_service.delete_share_link(db, map_id, link_id, current_user)
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ def get_share_links(db: Session, map_id: UUID, current_user: User) -> List[MapSh
|
|||||||
return links
|
return links
|
||||||
|
|
||||||
|
|
||||||
def delete_share_link(
|
async def delete_share_link(
|
||||||
db: Session,
|
db: Session,
|
||||||
map_id: UUID,
|
map_id: UUID,
|
||||||
link_id: UUID,
|
link_id: UUID,
|
||||||
@@ -252,6 +252,10 @@ def delete_share_link(
|
|||||||
db.delete(link)
|
db.delete(link)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
# Disconnect all guest users from this map
|
||||||
|
from app.websocket.connection_manager import manager
|
||||||
|
await manager.disconnect_guests(map_id)
|
||||||
|
|
||||||
|
|
||||||
def get_map_by_share_token(db: Session, token: str) -> tuple[Map, SharePermission]:
|
def get_map_by_share_token(db: Session, token: str) -> tuple[Map, SharePermission]:
|
||||||
"""Get map by share token (for guest access)."""
|
"""Get map by share token (for guest access)."""
|
||||||
|
|||||||
@@ -82,6 +82,38 @@ class ConnectionManager:
|
|||||||
if not self.active_connections[map_key]:
|
if not self.active_connections[map_key]:
|
||||||
del self.active_connections[map_key]
|
del self.active_connections[map_key]
|
||||||
|
|
||||||
|
async def disconnect_guests(self, map_id: UUID):
|
||||||
|
"""Disconnect all guest connections (no user_id) on a specific map."""
|
||||||
|
map_key = str(map_id)
|
||||||
|
|
||||||
|
if map_key not in self.active_connections:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Find all guest websockets (uid is None)
|
||||||
|
connections_to_close = [
|
||||||
|
websocket for websocket, uid in self.active_connections[map_key]
|
||||||
|
if uid is None
|
||||||
|
]
|
||||||
|
|
||||||
|
# Close each connection
|
||||||
|
for websocket in connections_to_close:
|
||||||
|
try:
|
||||||
|
await websocket.close(code=1008, reason="Share link revoked")
|
||||||
|
logger.info(f"Closed WebSocket for guest on map {map_id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error closing WebSocket for guest: {e}")
|
||||||
|
|
||||||
|
# Remove from active connections
|
||||||
|
self.active_connections[map_key] = {
|
||||||
|
conn for conn in self.active_connections[map_key]
|
||||||
|
if conn[0] != websocket
|
||||||
|
}
|
||||||
|
self.websocket_to_map.pop(websocket, None)
|
||||||
|
|
||||||
|
# Clean up empty map entry
|
||||||
|
if not self.active_connections[map_key]:
|
||||||
|
del self.active_connections[map_key]
|
||||||
|
|
||||||
async def broadcast_to_map(self, map_id: UUID, message: dict):
|
async def broadcast_to_map(self, map_id: UUID, message: dict):
|
||||||
"""Broadcast a message to all clients connected to a specific map."""
|
"""Broadcast a message to all clients connected to a specific map."""
|
||||||
map_key = str(map_id)
|
map_key = str(map_id)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react';
|
|||||||
import { authService } from '../services/authService';
|
import { authService } from '../services/authService';
|
||||||
|
|
||||||
interface WebSocketMessage {
|
interface WebSocketMessage {
|
||||||
type: 'connected' | 'item_created' | 'item_updated' | 'item_deleted';
|
type: 'connected' | 'item_created' | 'item_updated' | 'item_deleted' | 'access_revoked';
|
||||||
data: any;
|
data: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +104,11 @@ export function useMapWebSocket({
|
|||||||
onItemDeletedRef.current?.(message.data.id);
|
onItemDeletedRef.current?.(message.data.id);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'access_revoked':
|
||||||
|
console.log('Access revoked, closing connection');
|
||||||
|
ws.close();
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
console.log('Unknown message type:', message.type);
|
console.log('Unknown message type:', message.type);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
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 { LayerSwitcher } from '../components/map/LayerSwitcher';
|
import { LayerSwitcher } from '../components/map/LayerSwitcher';
|
||||||
import { DrawingHandler } from '../components/map/DrawingHandler';
|
import { DrawingHandler } from '../components/map/DrawingHandler';
|
||||||
import { MapItemsLayer } from '../components/map/MapItemsLayer';
|
import { MapItemsLayer } from '../components/map/MapItemsLayer';
|
||||||
import { Toolbar } from '../components/map/Toolbar';
|
import { Toolbar } from '../components/map/Toolbar';
|
||||||
|
import { MapView } from '../components/map/MapView';
|
||||||
import { useMapWebSocket } from '../hooks/useMapWebSocket';
|
import { useMapWebSocket } from '../hooks/useMapWebSocket';
|
||||||
import { apiClient } from '../services/api';
|
import { apiClient } from '../services/api';
|
||||||
|
import { useUIStore } from '../stores/uiStore';
|
||||||
|
|
||||||
type MapLayer = 'osm' | 'google' | 'esri';
|
type MapLayer = 'osm' | 'google' | 'esri';
|
||||||
|
|
||||||
@@ -47,11 +49,14 @@ function MapController() {
|
|||||||
|
|
||||||
export function SharedMap() {
|
export function SharedMap() {
|
||||||
const { token } = useParams<{ token: string }>();
|
const { token } = useParams<{ token: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { darkMode, toggleDarkMode } = useUIStore();
|
||||||
const [activeLayer, setActiveLayer] = useState<MapLayer>('osm');
|
const [activeLayer, setActiveLayer] = useState<MapLayer>('osm');
|
||||||
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
||||||
const [mapData, setMapData] = useState<any>(null);
|
const [mapData, setMapData] = useState<any>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||||
|
|
||||||
// Load map data using share token
|
// Load map data using share token
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -59,7 +64,6 @@ export function SharedMap() {
|
|||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Make an unauthenticated request with share token
|
|
||||||
const response = await apiClient.get(`/api/maps/shared/${token}`);
|
const response = await apiClient.get(`/api/maps/shared/${token}`);
|
||||||
setMapData(response.data);
|
setMapData(response.data);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -99,9 +103,9 @@ export function SharedMap() {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
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-gray-50 dark:bg-gray-900 transition-colors">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-gray-500 text-lg">Loading shared map...</div>
|
<div className="text-gray-500 dark:text-gray-400 text-lg">Loading shared map...</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -109,10 +113,10 @@ export function SharedMap() {
|
|||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
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-gray-50 dark:bg-gray-900 transition-colors">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-red-600 text-lg mb-2">Error</div>
|
<div className="text-red-600 dark:text-red-400 text-lg mb-2">Error</div>
|
||||||
<div className="text-gray-600">{error}</div>
|
<div className="text-gray-600 dark:text-gray-400">{error}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -120,80 +124,123 @@ export function SharedMap() {
|
|||||||
|
|
||||||
if (!mapData) {
|
if (!mapData) {
|
||||||
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-gray-50 dark:bg-gray-900 transition-colors">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-gray-500 text-lg">Map not found</div>
|
<div className="text-gray-500 dark:text-gray-400 text-lg">Map not found</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const layer = MAP_LAYERS[activeLayer];
|
|
||||||
const isReadOnly = permission === 'read';
|
const isReadOnly = permission === 'read';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-screen">
|
<div className="h-screen flex flex-col bg-gray-50 dark:bg-gray-900 transition-colors">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="bg-white shadow-sm border-b border-gray-200 px-6 py-4">
|
<header className="bg-blue-600 dark:bg-gray-800 text-white shadow-md">
|
||||||
<div className="flex items-center justify-between">
|
<div className="px-4 py-3 flex items-center justify-between">
|
||||||
<div>
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-xl font-bold text-gray-800">{mapData.name}</h1>
|
{/* Mobile hamburger button */}
|
||||||
<p className="text-sm text-gray-500">
|
<button
|
||||||
{isReadOnly ? 'View-only access' : 'Edit access'} • Shared map
|
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
|
||||||
</p>
|
className="md:hidden p-2 hover:bg-blue-700 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||||
|
aria-label="Toggle sidebar"
|
||||||
|
>
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
{isSidebarOpen ? (
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
) : (
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold">{mapData.name}</h1>
|
||||||
|
<p className="text-xs text-blue-200 dark:text-gray-400">
|
||||||
|
{isReadOnly ? 'View-only' : 'Edit access'} • Shared map
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
{isConnected && (
|
{isConnected && (
|
||||||
<div className="flex items-center gap-2 text-sm text-green-600">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
<div className="w-2 h-2 bg-green-600 rounded-full"></div>
|
<div className="w-2 h-2 bg-green-400 rounded-full"></div>
|
||||||
Live
|
<span className="hidden sm:inline">Live</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={toggleDarkMode}
|
||||||
|
className="p-2 bg-blue-700 dark:bg-gray-700 hover:bg-blue-800 dark:hover:bg-gray-600 rounded-lg transition-colors"
|
||||||
|
title={darkMode ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||||
|
>
|
||||||
|
{darkMode ? '☀️' : '🌙'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</header>
|
||||||
|
|
||||||
{/* Map */}
|
<div className="flex h-full relative overflow-hidden">
|
||||||
<div className="flex-1 relative">
|
{/* Mobile overlay */}
|
||||||
{/* Toolbar */}
|
{isSidebarOpen && (
|
||||||
{!isReadOnly && (
|
<div
|
||||||
<div style={{ position: 'fixed', left: '20px', top: '90px', zIndex: 9999 }}>
|
className="md:hidden fixed inset-0 bg-black/20 transition-opacity"
|
||||||
<Toolbar mapId={mapData.id} onShare={() => {}} />
|
style={{ zIndex: 9998 }}
|
||||||
</div>
|
onClick={() => setIsSidebarOpen(false)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Layer switcher */}
|
{/* Left sidebar with toolbar */}
|
||||||
<div style={{ position: 'fixed', right: '20px', top: '90px', zIndex: 9999 }}>
|
<div
|
||||||
<LayerSwitcher
|
className={`
|
||||||
activeLayer={activeLayer}
|
flex flex-col bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 transition-all duration-300 ease-in-out
|
||||||
onLayerChange={setActiveLayer}
|
fixed md:relative h-full w-80 overflow-y-auto
|
||||||
layers={MAP_LAYERS}
|
${isSidebarOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0'}
|
||||||
/>
|
`}
|
||||||
|
style={{ zIndex: 9999 }}
|
||||||
|
>
|
||||||
|
<div className="border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<Toolbar mapId={mapData.id} readOnly={isReadOnly} />
|
||||||
|
</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>
|
</div>
|
||||||
|
|
||||||
<MapContainer
|
{/* Main map view */}
|
||||||
center={[0, 0]}
|
<div className="flex-1 relative">
|
||||||
zoom={2}
|
<MapContainer
|
||||||
className="h-full w-full"
|
center={[0, 0]}
|
||||||
style={{ background: '#f0f0f0' }}
|
zoom={2}
|
||||||
>
|
className="h-full w-full"
|
||||||
<MapController />
|
style={{ background: '#f0f0f0' }}
|
||||||
<TileLayer
|
>
|
||||||
key={activeLayer}
|
<MapController />
|
||||||
url={layer.url}
|
<TileLayer
|
||||||
attribution={layer.attribution}
|
key={activeLayer}
|
||||||
maxZoom={layer.maxZoom}
|
url={MAP_LAYERS[activeLayer].url}
|
||||||
maxNativeZoom={layer.maxNativeZoom}
|
attribution={MAP_LAYERS[activeLayer].attribution}
|
||||||
/>
|
maxZoom={MAP_LAYERS[activeLayer].maxZoom}
|
||||||
|
maxNativeZoom={MAP_LAYERS[activeLayer].maxNativeZoom}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Drawing handler for edit access */}
|
{/* Drawing handler for edit access */}
|
||||||
{!isReadOnly && (
|
{!isReadOnly && (
|
||||||
<DrawingHandler mapId={mapData.id} onItemCreated={handleItemCreated} />
|
<DrawingHandler mapId={mapData.id} onItemCreated={handleItemCreated} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Render existing map items */}
|
{/* Render existing map items */}
|
||||||
<MapItemsLayer mapId={mapData.id} refreshTrigger={refreshTrigger} />
|
<MapItemsLayer mapId={mapData.id} refreshTrigger={refreshTrigger} readOnly={isReadOnly} />
|
||||||
</MapContainer>
|
</MapContainer>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user