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)
|
||||
):
|
||||
"""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
|
||||
|
||||
@@ -223,7 +223,7 @@ def get_share_links(db: Session, map_id: UUID, current_user: User) -> List[MapSh
|
||||
return links
|
||||
|
||||
|
||||
def delete_share_link(
|
||||
async def delete_share_link(
|
||||
db: Session,
|
||||
map_id: UUID,
|
||||
link_id: UUID,
|
||||
@@ -252,6 +252,10 @@ def delete_share_link(
|
||||
db.delete(link)
|
||||
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]:
|
||||
"""Get map by share token (for guest access)."""
|
||||
|
||||
@@ -82,6 +82,38 @@ class ConnectionManager:
|
||||
if not 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):
|
||||
"""Broadcast a message to all clients connected to a specific map."""
|
||||
map_key = str(map_id)
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import { authService } from '../services/authService';
|
||||
|
||||
interface WebSocketMessage {
|
||||
type: 'connected' | 'item_created' | 'item_updated' | 'item_deleted';
|
||||
type: 'connected' | 'item_created' | 'item_updated' | 'item_deleted' | 'access_revoked';
|
||||
data: any;
|
||||
}
|
||||
|
||||
@@ -104,6 +104,11 @@ export function useMapWebSocket({
|
||||
onItemDeletedRef.current?.(message.data.id);
|
||||
break;
|
||||
|
||||
case 'access_revoked':
|
||||
console.log('Access revoked, closing connection');
|
||||
ws.close();
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log('Unknown message type:', message.type);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
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 'leaflet/dist/leaflet.css';
|
||||
import { LayerSwitcher } from '../components/map/LayerSwitcher';
|
||||
import { DrawingHandler } from '../components/map/DrawingHandler';
|
||||
import { MapItemsLayer } from '../components/map/MapItemsLayer';
|
||||
import { Toolbar } from '../components/map/Toolbar';
|
||||
import { MapView } from '../components/map/MapView';
|
||||
import { useMapWebSocket } from '../hooks/useMapWebSocket';
|
||||
import { apiClient } from '../services/api';
|
||||
import { useUIStore } from '../stores/uiStore';
|
||||
|
||||
type MapLayer = 'osm' | 'google' | 'esri';
|
||||
|
||||
@@ -47,11 +49,14 @@ function MapController() {
|
||||
|
||||
export function SharedMap() {
|
||||
const { token } = useParams<{ token: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { darkMode, toggleDarkMode } = useUIStore();
|
||||
const [activeLayer, setActiveLayer] = useState<MapLayer>('osm');
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
||||
const [mapData, setMapData] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||
|
||||
// Load map data using share token
|
||||
useEffect(() => {
|
||||
@@ -59,7 +64,6 @@ export function SharedMap() {
|
||||
if (!token) return;
|
||||
|
||||
try {
|
||||
// Make an unauthenticated request with share token
|
||||
const response = await apiClient.get(`/api/maps/shared/${token}`);
|
||||
setMapData(response.data);
|
||||
setLoading(false);
|
||||
@@ -99,9 +103,9 @@ export function SharedMap() {
|
||||
|
||||
if (loading) {
|
||||
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-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>
|
||||
);
|
||||
@@ -109,10 +113,10 @@ export function SharedMap() {
|
||||
|
||||
if (error) {
|
||||
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-red-600 text-lg mb-2">Error</div>
|
||||
<div className="text-gray-600">{error}</div>
|
||||
<div className="text-red-600 dark:text-red-400 text-lg mb-2">Error</div>
|
||||
<div className="text-gray-600 dark:text-gray-400">{error}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -120,57 +124,99 @@ export function SharedMap() {
|
||||
|
||||
if (!mapData) {
|
||||
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-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>
|
||||
);
|
||||
}
|
||||
|
||||
const layer = MAP_LAYERS[activeLayer];
|
||||
const isReadOnly = permission === 'read';
|
||||
|
||||
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 */}
|
||||
<div className="bg-white shadow-sm border-b border-gray-200 px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<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="flex items-center gap-3">
|
||||
{/* Mobile hamburger button */}
|
||||
<button
|
||||
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
|
||||
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 text-gray-800">{mapData.name}</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
{isReadOnly ? 'View-only access' : 'Edit access'} • Shared map
|
||||
<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 className="flex items-center gap-2">
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{isConnected && (
|
||||
<div className="flex items-center gap-2 text-sm text-green-600">
|
||||
<div className="w-2 h-2 bg-green-600 rounded-full"></div>
|
||||
Live
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Map */}
|
||||
<div className="flex-1 relative">
|
||||
{/* Toolbar */}
|
||||
{!isReadOnly && (
|
||||
<div style={{ position: 'fixed', left: '20px', top: '90px', zIndex: 9999 }}>
|
||||
<Toolbar mapId={mapData.id} onShare={() => {}} />
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<div className="w-2 h-2 bg-green-400 rounded-full"></div>
|
||||
<span className="hidden sm:inline">Live</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Layer switcher */}
|
||||
<div style={{ position: 'fixed', right: '20px', top: '90px', zIndex: 9999 }}>
|
||||
<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>
|
||||
</header>
|
||||
|
||||
<div className="flex h-full relative overflow-hidden">
|
||||
{/* Mobile overlay */}
|
||||
{isSidebarOpen && (
|
||||
<div
|
||||
className="md:hidden fixed inset-0 bg-black/20 transition-opacity"
|
||||
style={{ zIndex: 9998 }}
|
||||
onClick={() => setIsSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Left sidebar with toolbar */}
|
||||
<div
|
||||
className={`
|
||||
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
|
||||
fixed md:relative h-full w-80 overflow-y-auto
|
||||
${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>
|
||||
|
||||
{/* Main map view */}
|
||||
<div className="flex-1 relative">
|
||||
<MapContainer
|
||||
center={[0, 0]}
|
||||
zoom={2}
|
||||
@@ -180,10 +226,10 @@ export function SharedMap() {
|
||||
<MapController />
|
||||
<TileLayer
|
||||
key={activeLayer}
|
||||
url={layer.url}
|
||||
attribution={layer.attribution}
|
||||
maxZoom={layer.maxZoom}
|
||||
maxNativeZoom={layer.maxNativeZoom}
|
||||
url={MAP_LAYERS[activeLayer].url}
|
||||
attribution={MAP_LAYERS[activeLayer].attribution}
|
||||
maxZoom={MAP_LAYERS[activeLayer].maxZoom}
|
||||
maxNativeZoom={MAP_LAYERS[activeLayer].maxNativeZoom}
|
||||
/>
|
||||
|
||||
{/* Drawing handler for edit access */}
|
||||
@@ -192,9 +238,10 @@ export function SharedMap() {
|
||||
)}
|
||||
|
||||
{/* Render existing map items */}
|
||||
<MapItemsLayer mapId={mapData.id} refreshTrigger={refreshTrigger} />
|
||||
<MapItemsLayer mapId={mapData.id} refreshTrigger={refreshTrigger} readOnly={isReadOnly} />
|
||||
</MapContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user