public share UI fix

This commit is contained in:
2025-12-13 14:29:08 +05:00
parent 378a8727e2
commit 62a13a9f45
5 changed files with 149 additions and 61 deletions

View File

@@ -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

View File

@@ -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)."""

View File

@@ -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)

View File

@@ -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);
}

View File

@@ -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>
);
}