private shares and revokation works

This commit is contained in:
2025-12-13 00:34:38 +05:00
parent f5370aa7f9
commit 4007445396
13 changed files with 307 additions and 96 deletions

View File

@@ -63,7 +63,7 @@ async def revoke_map_share(
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""Revoke map share from a user.""" """Revoke map share from a user."""
map_share_service.delete_map_share(db, map_id, share_id, current_user) await map_share_service.delete_map_share(db, map_id, share_id, current_user)
return None return None

View File

@@ -60,9 +60,20 @@ async def websocket_endpoint(
# CRITICAL: Close the DB session immediately after authentication # CRITICAL: Close the DB session immediately after authentication
db.close() db.close()
# Add to connection manager (don't call accept again) # Add to connection manager (don't call accept again, connect method will accept)
manager.active_connections.setdefault(str(map_id), set()).add(websocket) # Note: We need to call connect but it will try to accept again, so we skip it
logger.info(f"Client connected to map {map_id}. Total connections: {len(manager.active_connections[str(map_id)])}") # Instead, manually add the connection
user_id = user.id if user else None
map_key = str(map_id)
if map_key not in manager.active_connections:
manager.active_connections[map_key] = set()
manager.active_connections[map_key].add((websocket, user_id))
manager.websocket_to_map[websocket] = map_key
user_info = f"user {user_id}" if user_id else "guest"
logger.info(f"Client ({user_info}) connected to map {map_id}. Total connections: {len(manager.active_connections[map_key])}")
try: try:
# Send initial connection message # Send initial connection message

View File

@@ -8,7 +8,7 @@ from app.models.map_share import SharePermission
class MapShareCreate(BaseModel): class MapShareCreate(BaseModel):
"""Schema for creating a map share with a specific user.""" """Schema for creating a map share with a specific user."""
user_id: UUID user_identifier: str # Can be username, email, or UUID
permission: SharePermission = SharePermission.READ permission: SharePermission = SharePermission.READ

View File

@@ -8,11 +8,53 @@ from shapely.geometry import shape, Point, LineString
import json import json
from app.models.map_item import MapItem from app.models.map_item import MapItem
from app.models.map import Map
from app.models.map_share import MapShare, SharePermission
from app.models.user import User from app.models.user import User
from app.schemas.map_item import MapItemCreate, MapItemUpdate from app.schemas.map_item import MapItemCreate, MapItemUpdate
from app.services.map_service import get_map_by_id from app.services.map_service import get_map_by_id
def check_edit_permission(db: Session, map_id: UUID, user: User) -> None:
"""Check if user has edit permission on a map. Raises exception if not."""
map_obj = db.query(Map).filter(Map.id == map_id).first()
if not map_obj:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Map not found"
)
# Owner always has edit permission
if map_obj.owner_id == user.id:
return
# Admin always has edit permission
if user.is_admin:
return
# Check if user has share access
share = db.query(MapShare).filter(
MapShare.map_id == map_id,
MapShare.user_id == user.id
).first()
if not share:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have access to this map"
)
# Check if share permission is EDIT
if share.permission != SharePermission.EDIT:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You only have read-only access to this map"
)
return
def get_map_items(db: Session, map_id: UUID, user: Optional[User] = None) -> List[MapItem]: def get_map_items(db: Session, map_id: UUID, user: Optional[User] = None) -> List[MapItem]:
"""Get all items for a map.""" """Get all items for a map."""
# Verify user has access to the map # Verify user has access to the map
@@ -60,8 +102,8 @@ def geography_to_geojson(geography) -> dict:
def create_map_item(db: Session, map_id: UUID, item_data: MapItemCreate, user: User) -> MapItem: def create_map_item(db: Session, map_id: UUID, item_data: MapItemCreate, user: User) -> MapItem:
"""Create a new map item.""" """Create a new map item."""
# Verify user has access to the map # Verify user has edit permission on the map
get_map_by_id(db, map_id, user) check_edit_permission(db, map_id, user)
# Convert GeoJSON to PostGIS geography # Convert GeoJSON to PostGIS geography
geometry_wkt = geojson_to_geography(item_data.geometry) geometry_wkt = geojson_to_geography(item_data.geometry)
@@ -142,6 +184,9 @@ def update_map_item(db: Session, item_id: UUID, item_data: MapItemUpdate, user:
"""Update a map item.""" """Update a map item."""
item = get_map_item_by_id(db, item_id, user) item = get_map_item_by_id(db, item_id, user)
# Verify user has edit permission on the map
check_edit_permission(db, item.map_id, user)
# Update fields if provided # Update fields if provided
if item_data.type is not None: if item_data.type is not None:
item.type = item_data.type item.type = item_data.type
@@ -162,6 +207,9 @@ def delete_map_item(db: Session, item_id: UUID, user: User) -> None:
"""Delete a map item.""" """Delete a map item."""
item = get_map_item_by_id(db, item_id, user) item = get_map_item_by_id(db, item_id, user)
# Verify user has edit permission on the map
check_edit_permission(db, item.map_id, user)
# Capture map_id and item_id before deletion for WebSocket broadcast # Capture map_id and item_id before deletion for WebSocket broadcast
map_id = item.map_id map_id = item.map_id
deleted_item_id = str(item.id) deleted_item_id = str(item.id)

View File

@@ -6,12 +6,28 @@ from fastapi import HTTPException, status
from app.models.map import Map from app.models.map import Map
from app.models.user import User from app.models.user import User
from app.models.map_share import MapShare
from app.schemas.map import MapCreate, MapUpdate from app.schemas.map import MapCreate, MapUpdate
def get_user_maps(db: Session, user_id: UUID) -> List[Map]: def get_user_maps(db: Session, user_id: UUID) -> List[Map]:
"""Get all maps owned by a user.""" """Get all maps owned by or shared with a user."""
return db.query(Map).filter(Map.owner_id == user_id).order_by(Map.updated_at.desc()).all() # Get owned maps
owned_maps = db.query(Map).filter(Map.owner_id == user_id).all()
# Get shared maps
shared_map_ids = db.query(MapShare.map_id).filter(MapShare.user_id == user_id).all()
shared_map_ids = [share.map_id for share in shared_map_ids]
shared_maps = []
if shared_map_ids:
shared_maps = db.query(Map).filter(Map.id.in_(shared_map_ids)).all()
# Combine and sort by updated_at
all_maps = owned_maps + shared_maps
all_maps.sort(key=lambda m: m.updated_at, reverse=True)
return all_maps
def get_map_by_id(db: Session, map_id: UUID, user: Optional[User] = None) -> Map: def get_map_by_id(db: Session, map_id: UUID, user: Optional[User] = None) -> Map:
@@ -26,7 +42,15 @@ def get_map_by_id(db: Session, map_id: UUID, user: Optional[User] = None) -> Map
# If user is provided, check authorization # If user is provided, check authorization
if user: if user:
if map_obj.owner_id != user.id and not user.is_admin: # Check if user is owner, admin, or has been granted access via share
is_owner = map_obj.owner_id == user.id
is_admin = user.is_admin
has_share_access = db.query(MapShare).filter(
MapShare.map_id == map_id,
MapShare.user_id == user.id
).first() is not None
if not (is_owner or is_admin or has_share_access):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have permission to access this map" detail="You don't have permission to access this map"

View File

@@ -33,18 +33,37 @@ def create_map_share(
detail="Only the map owner can share it" detail="Only the map owner can share it"
) )
# Check if user exists # Look up user by username, email, or UUID
target_user = db.query(User).filter(User.id == share_data.user_id).first() target_user = None
user_identifier = share_data.user_identifier.strip()
# Try UUID first
try:
user_uuid = UUID(user_identifier)
target_user = db.query(User).filter(User.id == user_uuid).first()
except ValueError:
# Not a valid UUID, try username or email
target_user = db.query(User).filter(
(User.username == user_identifier) | (User.email == user_identifier)
).first()
if not target_user: if not target_user:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail="User not found" detail=f"User not found with identifier: {user_identifier}"
)
# Prevent sharing with self
if target_user.id == current_user.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot share map with yourself"
) )
# Check if already shared # Check if already shared
existing_share = db.query(MapShare).filter( existing_share = db.query(MapShare).filter(
MapShare.map_id == map_id, MapShare.map_id == map_id,
MapShare.user_id == share_data.user_id MapShare.user_id == target_user.id
).first() ).first()
if existing_share: if existing_share:
@@ -58,7 +77,7 @@ def create_map_share(
# Create new share # Create new share
share = MapShare( share = MapShare(
map_id=map_id, map_id=map_id,
user_id=share_data.user_id, user_id=target_user.id,
permission=share_data.permission, permission=share_data.permission,
shared_by=current_user.id shared_by=current_user.id
) )
@@ -120,7 +139,7 @@ def update_map_share(
return share return share
def delete_map_share( async def delete_map_share(
db: Session, db: Session,
map_id: UUID, map_id: UUID,
share_id: UUID, share_id: UUID,
@@ -146,9 +165,16 @@ def delete_map_share(
detail="Share not found" detail="Share not found"
) )
# Get user_id before deleting the share
revoked_user_id = share.user_id
db.delete(share) db.delete(share)
db.commit() db.commit()
# Disconnect the user's WebSocket connections
from app.websocket.connection_manager import manager
await manager.disconnect_user(map_id, revoked_user_id)
def create_share_link( def create_share_link(
db: Session, db: Session,

View File

@@ -1,6 +1,6 @@
"""WebSocket connection manager for real-time map updates.""" """WebSocket connection manager for real-time map updates."""
from fastapi import WebSocket from fastapi import WebSocket
from typing import Dict, List, Set from typing import Dict, List, Set, Optional, Tuple
from uuid import UUID from uuid import UUID
import json import json
import logging import logging
@@ -12,10 +12,12 @@ class ConnectionManager:
"""Manages WebSocket connections for real-time map updates.""" """Manages WebSocket connections for real-time map updates."""
def __init__(self): def __init__(self):
# map_id -> Set of WebSocket connections # map_id -> Set of (WebSocket, user_id) tuples
self.active_connections: Dict[str, Set[WebSocket]] = {} self.active_connections: Dict[str, Set[Tuple[WebSocket, Optional[UUID]]]] = {}
# websocket -> map_id mapping for quick lookup
self.websocket_to_map: Dict[WebSocket, str] = {}
async def connect(self, websocket: WebSocket, map_id: UUID): async def connect(self, websocket: WebSocket, map_id: UUID, user_id: Optional[UUID] = None):
"""Accept a new WebSocket connection for a map.""" """Accept a new WebSocket connection for a map."""
await websocket.accept() await websocket.accept()
map_key = str(map_id) map_key = str(map_id)
@@ -23,20 +25,63 @@ class ConnectionManager:
if map_key not in self.active_connections: if map_key not in self.active_connections:
self.active_connections[map_key] = set() self.active_connections[map_key] = set()
self.active_connections[map_key].add(websocket) # Store websocket with user_id
logger.info(f"Client connected to map {map_id}. Total connections: {len(self.active_connections[map_key])}") self.active_connections[map_key].add((websocket, user_id))
self.websocket_to_map[websocket] = map_key
user_info = f"user {user_id}" if user_id else "guest"
logger.info(f"Client ({user_info}) connected to map {map_id}. Total connections: {len(self.active_connections[map_key])}")
def disconnect(self, websocket: WebSocket, map_id: UUID): def disconnect(self, websocket: WebSocket, map_id: UUID):
"""Remove a WebSocket connection.""" """Remove a WebSocket connection."""
map_key = str(map_id) map_key = str(map_id)
if map_key in self.active_connections: if map_key in self.active_connections:
self.active_connections[map_key].discard(websocket) # Find and remove the tuple containing this websocket
self.active_connections[map_key] = {
conn for conn in self.active_connections[map_key]
if conn[0] != websocket
}
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]
# Remove from websocket_to_map
self.websocket_to_map.pop(websocket, None)
logger.info(f"Client disconnected from map {map_id}") logger.info(f"Client disconnected from map {map_id}")
async def disconnect_user(self, map_id: UUID, user_id: UUID):
"""Disconnect all connections for a specific user on a specific map."""
map_key = str(map_id)
if map_key not in self.active_connections:
return
# Find all websockets for this user
connections_to_close = [
websocket for websocket, uid in self.active_connections[map_key]
if uid == user_id
]
# Close each connection
for websocket in connections_to_close:
try:
await websocket.close(code=1008, reason="Access revoked")
logger.info(f"Closed WebSocket for user {user_id} on map {map_id}")
except Exception as e:
logger.error(f"Error closing WebSocket for user {user_id}: {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)
@@ -48,16 +93,16 @@ class ConnectionManager:
connections = self.active_connections[map_key].copy() connections = self.active_connections[map_key].copy()
disconnected = [] disconnected = []
for connection in connections: for websocket, user_id in connections:
try: try:
await connection.send_json(message) await websocket.send_json(message)
except Exception as e: except Exception as e:
logger.error(f"Error sending message to client: {e}") logger.error(f"Error sending message to client: {e}")
disconnected.append(connection) disconnected.append(websocket)
# Remove disconnected clients # Remove disconnected clients
for connection in disconnected: for websocket in disconnected:
self.disconnect(connection, map_id) self.disconnect(websocket, map_id)
async def send_item_created(self, map_id: UUID, item_data: dict): async def send_item_created(self, map_id: UUID, item_data: dict):
"""Notify clients that a new item was created.""" """Notify clients that a new item was created."""

View File

@@ -31,6 +31,16 @@ export function Layout({ children }: LayoutProps) {
</span> </span>
)} )}
</span> </span>
<button
onClick={() => {
navigator.clipboard.writeText(user.id);
alert('Your User ID has been copied to clipboard!');
}}
className="px-2 py-1 bg-blue-700 hover:bg-blue-800 rounded text-xs"
title="Copy your User ID for sharing"
>
Copy My ID
</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 bg-blue-700 hover:bg-blue-800 rounded text-sm"

View File

@@ -10,6 +10,7 @@ import { useDrawingStore } from '../../stores/drawingStore';
interface MapItemsLayerProps { interface MapItemsLayerProps {
mapId: string; mapId: string;
refreshTrigger: number; refreshTrigger: number;
readOnly?: boolean;
} }
// Custom marker icons for devices using CSS // Custom marker icons for devices using CSS
@@ -58,7 +59,7 @@ const outdoorApIcon = new L.DivIcon({
iconAnchor: [20, 40], iconAnchor: [20, 40],
}); });
export function MapItemsLayer({ mapId, refreshTrigger }: MapItemsLayerProps) { export function MapItemsLayer({ mapId, refreshTrigger, readOnly = false }: MapItemsLayerProps) {
const [items, setItems] = useState<MapItem[]>([]); const [items, setItems] = useState<MapItem[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [contextMenu, setContextMenu] = useState<{ const [contextMenu, setContextMenu] = useState<{
@@ -145,10 +146,12 @@ export function MapItemsLayer({ mapId, refreshTrigger }: MapItemsLayerProps) {
eventHandlers={{ eventHandlers={{
contextmenu: (e) => { contextmenu: (e) => {
L.DomEvent.stopPropagation(e); L.DomEvent.stopPropagation(e);
if (!readOnly) {
setContextMenu({ setContextMenu({
item, item,
position: { x: e.originalEvent.clientX, y: e.originalEvent.clientY } position: { x: e.originalEvent.clientX, y: e.originalEvent.clientY }
}); });
}
}, },
}} }}
> >
@@ -230,10 +233,12 @@ export function MapItemsLayer({ mapId, refreshTrigger }: MapItemsLayerProps) {
eventHandlers={{ eventHandlers={{
contextmenu: (e) => { contextmenu: (e) => {
L.DomEvent.stopPropagation(e); L.DomEvent.stopPropagation(e);
if (!readOnly) {
setContextMenu({ setContextMenu({
item, item,
position: { x: e.originalEvent.clientX, y: e.originalEvent.clientY } position: { x: e.originalEvent.clientX, y: e.originalEvent.clientY }
}); });
}
}, },
}} }}
> >
@@ -290,10 +295,12 @@ export function MapItemsLayer({ mapId, refreshTrigger }: MapItemsLayerProps) {
eventHandlers={{ eventHandlers={{
contextmenu: (e) => { contextmenu: (e) => {
L.DomEvent.stopPropagation(e); L.DomEvent.stopPropagation(e);
if (!readOnly) {
setContextMenu({ setContextMenu({
item, item,
position: { x: e.originalEvent.clientX, y: e.originalEvent.clientY } position: { x: e.originalEvent.clientX, y: e.originalEvent.clientY }
}); });
}
}, },
}} }}
> >

View File

@@ -1,5 +1,6 @@
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 { mapService } from '../../services/mapService'; import { mapService } from '../../services/mapService';
interface MapListSidebarProps { interface MapListSidebarProps {
@@ -9,6 +10,7 @@ interface MapListSidebarProps {
export function MapListSidebar({ onSelectMap, selectedMapId }: MapListSidebarProps) { export function MapListSidebar({ onSelectMap, selectedMapId }: MapListSidebarProps) {
const { maps, setMaps, addMap, removeMap, setLoading, setError } = useMapStore(); const { maps, setMaps, addMap, removeMap, setLoading, setError } = useMapStore();
const { user } = useAuthStore();
const [isCreating, setIsCreating] = useState(false); const [isCreating, setIsCreating] = useState(false);
const [newMapName, setNewMapName] = useState(''); const [newMapName, setNewMapName] = useState('');
@@ -104,7 +106,11 @@ export function MapListSidebar({ onSelectMap, selectedMapId }: MapListSidebarPro
</div> </div>
) : ( ) : (
<div className="divide-y divide-gray-200"> <div className="divide-y divide-gray-200">
{maps.map((map) => ( {maps.map((map) => {
const isOwner = user && map.owner_id === user.id;
const isShared = !isOwner;
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 ${
@@ -114,7 +120,14 @@ export function MapListSidebar({ onSelectMap, selectedMapId }: MapListSidebarPro
> >
<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">
<h3 className="font-medium text-gray-900">{map.name}</h3> <h3 className="font-medium text-gray-900">{map.name}</h3>
{isShared && (
<span className="px-2 py-0.5 bg-green-100 text-green-700 text-xs rounded">
Shared
</span>
)}
</div>
{map.description && ( {map.description && (
<p className="text-sm text-gray-600 mt-1">{map.description}</p> <p className="text-sm text-gray-600 mt-1">{map.description}</p>
)} )}
@@ -122,6 +135,7 @@ export function MapListSidebar({ onSelectMap, selectedMapId }: MapListSidebarPro
{new Date(map.updated_at).toLocaleDateString()} {new Date(map.updated_at).toLocaleDateString()}
</p> </p>
</div> </div>
{isOwner && (
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
@@ -131,9 +145,11 @@ export function MapListSidebar({ onSelectMap, selectedMapId }: MapListSidebarPro
> >
Delete Delete
</button> </button>
)}
</div> </div>
</div> </div>
))} );
})}
</div> </div>
)} )}
</div> </div>

View File

@@ -96,7 +96,11 @@ export function MapView({ mapId }: MapViewProps) {
<> <>
{/* Toolbar for drawing tools */} {/* Toolbar for drawing tools */}
<div style={{ position: 'fixed', left: '220px', top: '70px', zIndex: 9999 }}> <div style={{ position: 'fixed', left: '220px', top: '70px', zIndex: 9999 }}>
<Toolbar mapId={mapId} onShare={() => setShowShareDialog(true)} /> <Toolbar
mapId={mapId}
onShare={() => setShowShareDialog(true)}
readOnly={permission === 'read'}
/>
</div> </div>
{/* Layer switcher */} {/* Layer switcher */}
@@ -124,11 +128,13 @@ export function MapView({ mapId }: MapViewProps) {
maxNativeZoom={layer.maxNativeZoom} maxNativeZoom={layer.maxNativeZoom}
/> />
{/* Drawing handler for creating new items */} {/* Drawing handler for creating new items - disabled for read-only */}
{permission !== 'read' && (
<DrawingHandler mapId={mapId} onItemCreated={handleItemCreated} /> <DrawingHandler mapId={mapId} onItemCreated={handleItemCreated} />
)}
{/* Render existing map items */} {/* Render existing map items */}
<MapItemsLayer mapId={mapId} refreshTrigger={refreshTrigger} /> <MapItemsLayer mapId={mapId} refreshTrigger={refreshTrigger} readOnly={permission === 'read'} />
</MapContainer> </MapContainer>
</div> </div>

View File

@@ -49,7 +49,7 @@ export function ShareDialog({ mapId, onClose }: ShareDialogProps) {
setLoading(true); setLoading(true);
try { try {
await mapShareService.shareWithUser(mapId, { await mapShareService.shareWithUser(mapId, {
user_id: newUserId, user_identifier: newUserId.trim(),
permission: newUserPermission, permission: newUserPermission,
}); });
setNewUserId(''); setNewUserId('');
@@ -57,6 +57,7 @@ export function ShareDialog({ mapId, onClose }: ShareDialogProps) {
alert('Map shared successfully!'); alert('Map shared successfully!');
} 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); alert(message);
} finally { } finally {
@@ -158,15 +159,18 @@ export function ShareDialog({ mapId, onClose }: ShareDialogProps) {
{/* 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 mb-2">
Share with User (by User ID) Share with User
</label> </label>
<p className="text-xs text-gray-500 mb-2">
Enter a username, email, or user ID
</p>
<div className="flex gap-2"> <div className="flex gap-2">
<input <input
type="text" type="text"
value={newUserId} value={newUserId}
onChange={(e) => setNewUserId(e.target.value)} onChange={(e) => setNewUserId(e.target.value)}
placeholder="Enter user ID" placeholder="Enter username, email, or user ID"
className="flex-1 px-3 py-2 border border-gray-300 rounded-md" className="flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm"
disabled={loading} disabled={loading}
/> />
<select <select

View File

@@ -5,6 +5,7 @@ import { CABLE_COLORS, CABLE_LABELS } from '../../types/mapItem';
interface ToolbarProps { interface ToolbarProps {
mapId: string; mapId: string;
onShare: () => void; onShare: () => void;
readOnly?: boolean;
} }
interface ToolButton { interface ToolButton {
@@ -70,11 +71,18 @@ const TOOLS: ToolButton[] = [
}, },
]; ];
export function Toolbar({ mapId, onShare }: ToolbarProps) { export function Toolbar({ mapId, onShare, readOnly = false }: ToolbarProps) {
const { activeTool, setActiveTool } = useDrawingStore(); const { activeTool, setActiveTool } = useDrawingStore();
return ( return (
<div className="bg-white shadow-lg rounded-lg p-2 space-y-1" style={{ minWidth: '150px' }}> <div className="bg-white shadow-lg rounded-lg p-2 space-y-1" style={{ minWidth: '150px' }}>
{/* Read-only indicator */}
{readOnly && (
<div className="w-full px-3 py-2 rounded bg-yellow-100 text-yellow-800 text-xs font-medium mb-2 text-center">
Read-Only Mode
</div>
)}
{/* Share button */} {/* Share button */}
<button <button
onClick={onShare} onClick={onShare}
@@ -87,26 +95,32 @@ export function Toolbar({ mapId, onShare }: ToolbarProps) {
<div className="border-t border-gray-200 my-2"></div> <div className="border-t border-gray-200 my-2"></div>
{TOOLS.map((tool) => ( {TOOLS.map((tool) => {
const isDisabled = readOnly && tool.id !== 'select';
return (
<button <button
key={tool.id} key={tool.id}
onClick={() => setActiveTool(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 ${ className={`w-full px-3 py-2 rounded text-left flex items-center gap-2 transition-colors ${
activeTool === tool.id isDisabled
? 'opacity-50 cursor-not-allowed text-gray-400'
: activeTool === tool.id
? 'bg-blue-100 text-blue-700 font-medium' ? 'bg-blue-100 text-blue-700 font-medium'
: 'hover:bg-gray-100 text-gray-700' : 'hover:bg-gray-100 text-gray-700'
}`} }`}
title={tool.description} title={isDisabled ? 'Not available in read-only mode' : tool.description}
> >
<span <span
className="text-lg" className="text-lg"
style={tool.color ? { color: tool.color } : undefined} style={tool.color && !isDisabled ? { color: tool.color } : undefined}
> >
{tool.icon} {tool.icon}
</span> </span>
<span className="text-sm">{tool.label}</span> <span className="text-sm">{tool.label}</span>
</button> </button>
))} );
})}
</div> </div>
); );
} }