diff --git a/src/hooks/useAllMaps.ts b/src/hooks/useAllMaps.ts new file mode 100644 index 00000000..481a1132 --- /dev/null +++ b/src/hooks/useAllMaps.ts @@ -0,0 +1,39 @@ +import { useQuery } from '@tanstack/react-query'; + +import type { Project, SavedMap } from '@/lib/db'; +import { getDb } from '@/lib/db'; + +export const ALL_MAPS_QUERY_KEY = ['maps', 'all'] as const; + +export interface SavedMapWithProject extends SavedMap { + /** Origin project name, or `null` if the project no longer exists. */ + originProjectName: string | null; +} + +/** + * Query every saved map across all projects, sorted by `updatedAt` desc. + * Each map is joined with its origin project name for cross-project display. + * + * Used by the "All projects" view in MapScreen (#16). + */ +export function useAllMaps() { + return useQuery({ + queryKey: ALL_MAPS_QUERY_KEY, + queryFn: async (): Promise => { + const db = getDb(); + const [maps, projects] = await Promise.all([ + db.maps.toArray(), + db.projects.toArray(), + ]); + const projectMap = new Map(); + for (const project of projects) { + projectMap.set(project.localId, project); + } + const enriched = maps.map((map) => ({ + ...map, + originProjectName: projectMap.get(map.projectLocalId)?.name ?? null, + })); + return enriched.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); + }, + }); +} diff --git a/src/hooks/useMaps.ts b/src/hooks/useMaps.ts index 1e6ff0ae..f9043789 100644 --- a/src/hooks/useMaps.ts +++ b/src/hooks/useMaps.ts @@ -1,5 +1,6 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { ALL_MAPS_QUERY_KEY } from '@/hooks/useAllMaps'; import type { SavedMap } from '@/lib/db'; import { getDb } from '@/lib/db'; import type { DownloadProgress } from '@/lib/map/smp-download'; @@ -39,6 +40,7 @@ export function useCreateMap() { void queryClient.invalidateQueries({ queryKey: mapsQueryKey(map.projectLocalId), }); + void queryClient.invalidateQueries({ queryKey: ALL_MAPS_QUERY_KEY }); }, }); } @@ -56,6 +58,7 @@ export function useRenameMap(projectLocalId: string | null) { queryKey: mapsQueryKey(projectLocalId), }); void queryClient.invalidateQueries({ queryKey: ['map', mapId] }); + void queryClient.invalidateQueries({ queryKey: ALL_MAPS_QUERY_KEY }); }, }); } @@ -92,6 +95,7 @@ export function useDeleteMap(projectLocalId: string | null) { }); void queryClient.invalidateQueries({ queryKey: ['map', mapId] }); void queryClient.invalidateQueries({ queryKey: ['projects'] }); + void queryClient.invalidateQueries({ queryKey: ALL_MAPS_QUERY_KEY }); }, }); } @@ -143,11 +147,13 @@ export function useDownloadMap() { queryKey: mapsQueryKey(map.projectLocalId), }); void queryClient.invalidateQueries({ queryKey: ['map', map.id] }); + void queryClient.invalidateQueries({ queryKey: ALL_MAPS_QUERY_KEY }); }, onError: (_error, { map }) => { void queryClient.invalidateQueries({ queryKey: mapsQueryKey(map.projectLocalId), }); + void queryClient.invalidateQueries({ queryKey: ALL_MAPS_QUERY_KEY }); }, }); } diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index b3e8998b..e6ca725f 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1217,6 +1217,27 @@ "map.download.unknownError": { "defaultMessage": "Unknown error" }, + "map.history.downloaded": { + "defaultMessage": "Downloaded" + }, + "map.history.empty": { + "defaultMessage": "No downloads yet" + }, + "map.history.retry": { + "defaultMessage": "Retry" + }, + "map.history.size": { + "defaultMessage": "Size: {size}" + }, + "map.history.statusError": { + "defaultMessage": "Failed" + }, + "map.history.statusReady": { + "defaultMessage": "Ready" + }, + "map.history.title": { + "defaultMessage": "Download history" + }, "map.nameDialog.cancel": { "defaultMessage": "Cancel" }, @@ -1241,6 +1262,33 @@ "map.nameDialog.title": { "defaultMessage": "Save map" }, + "map.overlay.clear": { + "defaultMessage": "Clear all overlays" + }, + "map.overlay.drop": { + "defaultMessage": "Drop a .geojson file to show it as a reference layer" + }, + "map.overlay.dropActive": { + "defaultMessage": "Drop the file to add the overlay" + }, + "map.overlay.empty": { + "defaultMessage": "No reference layers added" + }, + "map.overlay.invalid": { + "defaultMessage": "Could not read “{name}”. Not valid GeoJSON." + }, + "map.overlay.remove": { + "defaultMessage": "Remove overlay" + }, + "map.overlay.title": { + "defaultMessage": "Reference layers" + }, + "map.overlay.toggle": { + "defaultMessage": "Toggle overlay visibility" + }, + "map.overlay.tooLarge": { + "defaultMessage": "“{name}” is {size}. Large files may slow down the editor." + }, "map.saved.activeError": { "defaultMessage": "Could not update active map. Please try again." }, @@ -1274,6 +1322,9 @@ "map.saved.noProjectLink": { "defaultMessage": "Go to Home" }, + "map.saved.originProject": { + "defaultMessage": "Origin project" + }, "map.saved.removeActive": { "defaultMessage": "Remove active" }, @@ -1319,6 +1370,12 @@ "map.saved.untitledProject": { "defaultMessage": "Untitled Project" }, + "map.scope.allProjects": { + "defaultMessage": "All projects" + }, + "map.scope.thisProject": { + "defaultMessage": "This project" + }, "map.stylePicker.customMode": { "defaultMessage": "Custom URL" }, diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 3e00bb67..fbed73ea 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -374,6 +374,13 @@ "map.download.storageWarning": "Espacio de almacenamiento insuficiente. {available} disponibles, {estimated} estimados.", "map.download.tryAnyway": "Intentar de todos modos", "map.download.unknownError": "Error desconocido", + "map.history.downloaded": "Descargado", + "map.history.empty": "Aún no hay descargas", + "map.history.retry": "Reintentar", + "map.history.size": "Tamaño: {size}", + "map.history.statusError": "Fallido", + "map.history.statusReady": "Listo", + "map.history.title": "Historial de descargas", "map.nameDialog.cancel": "Cancelar", "map.nameDialog.description": "Guarde esta configuración de mapa como borrador. Luego puede descargarlo como un archivo .smp.", "map.nameDialog.nameLabel": "Nombre del mapa", @@ -382,6 +389,15 @@ "map.nameDialog.saveDraft": "Guardar borrador", "map.nameDialog.saveError": "No se pudo guardar el mapa. Inténtelo de nuevo.", "map.nameDialog.title": "Guardar mapa", + "map.overlay.clear": "Limpiar todas las capas", + "map.overlay.drop": "Suelte un archivo .geojson para mostrarlo como capa de referencia", + "map.overlay.dropActive": "Suelte el archivo para añadir la capa", + "map.overlay.empty": "No hay capas de referencia añadidas", + "map.overlay.invalid": "No se pudo leer “{name}”. GeoJSON no válido.", + "map.overlay.remove": "Quitar capa", + "map.overlay.title": "Capas de referencia", + "map.overlay.tooLarge": "“{name}” es {size}. Los archivos grandes pueden ralentizar el editor.", + "map.overlay.toggle": "Alternar visibilidad de la capa", "map.saved.activeError": "No se pudo actualizar el mapa activo. Inténtelo de nuevo.", "map.saved.canvasAria": "Lienzo de autoría de mapa", "map.saved.closeSettings": "Cerrar configuración del mapa", @@ -393,6 +409,7 @@ "map.saved.empty": "Aún no hay mapas guardados", "map.saved.noProject": "Seleccione un proyecto desde Inicio para crear mapas", "map.saved.noProjectLink": "Ir a Inicio", + "map.saved.originProject": "Proyecto de origen", "map.saved.removeActive": "Quitar activo", "map.saved.rename": "Renombrar", "map.saved.renameDialog.description": "Ingrese un nuevo nombre para este mapa guardado.", @@ -408,6 +425,8 @@ "map.saved.statusReady": "Listo", "map.saved.title": "Mapas guardados", "map.saved.untitledProject": "Proyecto sin título", + "map.scope.allProjects": "Todos los proyectos", + "map.scope.thisProject": "Este proyecto", "map.stylePicker.customMode": "URL personalizada", "map.stylePicker.customUrlHostnameWarning": "Este proveedor de teselas puede no estar disponible para descargas offline. Solo los proveedores compatibles funcionan con la generación de paquetes de mapas.", "map.stylePicker.customUrlLabel": "URL personalizada", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index b1e47b2e..2f202dc5 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -374,6 +374,13 @@ "map.download.storageWarning": "Espaço de armazenamento insuficiente. {available} disponível, {estimated} estimado.", "map.download.tryAnyway": "Tentar mesmo assim", "map.download.unknownError": "Erro desconhecido", + "map.history.downloaded": "Baixado", + "map.history.empty": "Ainda não há downloads", + "map.history.retry": "Tentar novamente", + "map.history.size": "Tamanho: {size}", + "map.history.statusError": "Falhou", + "map.history.statusReady": "Pronto", + "map.history.title": "Histórico de downloads", "map.nameDialog.cancel": "Cancelar", "map.nameDialog.description": "Salve esta configuração de mapa como rascunho. Depois você pode baixá-lo como um arquivo .smp.", "map.nameDialog.nameLabel": "Nome do mapa", @@ -382,6 +389,15 @@ "map.nameDialog.saveDraft": "Salvar rascunho", "map.nameDialog.saveError": "Não foi possível salvar o mapa. Tente novamente.", "map.nameDialog.title": "Salvar mapa", + "map.overlay.clear": "Limpar todas as camadas", + "map.overlay.drop": "Solte um arquivo .geojson para mostrá-lo como camada de referência", + "map.overlay.dropActive": "Solte o arquivo para adicionar a camada", + "map.overlay.empty": "Nenhuma camada de referência adicionada", + "map.overlay.invalid": "Não foi possível ler “{name}”. GeoJSON inválido.", + "map.overlay.remove": "Remover camada", + "map.overlay.title": "Camadas de referência", + "map.overlay.tooLarge": "“{name}” tem {size}. Arquivos grandes podem lentear o editor.", + "map.overlay.toggle": "Alternar visibilidade da camada", "map.saved.activeError": "Não foi possível atualizar o mapa ativo. Tente novamente.", "map.saved.canvasAria": "Tela de autoria de mapa", "map.saved.closeSettings": "Fechar configurações do mapa", @@ -393,6 +409,7 @@ "map.saved.empty": "Ainda não há mapas salvos", "map.saved.noProject": "Selecione um projeto no Início para criar mapas", "map.saved.noProjectLink": "Ir para o Início", + "map.saved.originProject": "Projeto de origem", "map.saved.removeActive": "Remover ativo", "map.saved.rename": "Renomear", "map.saved.renameDialog.description": "Insira um novo nome para este mapa salvo.", @@ -408,6 +425,8 @@ "map.saved.statusReady": "Pronto", "map.saved.title": "Mapas salvos", "map.saved.untitledProject": "Projeto sem título", + "map.scope.allProjects": "Todos os projetos", + "map.scope.thisProject": "Este projeto", "map.stylePicker.customMode": "URL personalizada", "map.stylePicker.customUrlHostnameWarning": "Este provedor de mosaicos pode não estar disponível para downloads offline. Somente provedores compatíveis funcionam com a geração de pacotes de mapas.", "map.stylePicker.customUrlLabel": "URL personalizada", diff --git a/src/screens/MapScreen/DownloadHistory.tsx b/src/screens/MapScreen/DownloadHistory.tsx new file mode 100644 index 00000000..c84ec8df --- /dev/null +++ b/src/screens/MapScreen/DownloadHistory.tsx @@ -0,0 +1,151 @@ +import { useIntl } from 'react-intl'; + +import { Button } from '@/components/ui/button'; +import { Skeleton } from '@/components/ui/skeleton'; +import { useToast } from '@/components/ui/toast'; +import { useDownloadMap } from '@/hooks/useMaps'; +import type { SavedMap } from '@/lib/db'; +import { formatBytes } from '@/lib/map/smp-download'; + +import { mapMessages } from './messages'; + +interface DownloadHistoryProps { + /** + * Maps to display in the history list. Each must have a `status` of + * `ready` or `error` to be relevant as a completed/failed download. + * The parent (MapScreen) is responsible for filtering. + */ + maps: SavedMap[]; + /** Whether the maps query is still loading (for skeleton state). */ + isLoading?: boolean; +} + +/** + * Displays a chronological list of past downloads (ready + error maps). + * + * Each entry shows the map name, size, status, and a retry button for + * failed downloads. This is the "download history" view from issue #17 + * (Phase 1 'Should Have'). + */ +export function DownloadHistory({ maps, isLoading }: DownloadHistoryProps) { + const intl = useIntl(); + const downloadMap = useDownloadMap(); + const { addToast } = useToast(); + + const completed = maps.filter( + (m) => m.status === 'ready' || m.status === 'error', + ); + + function handleRetry(map: SavedMap) { + downloadMap.mutate( + { map }, + { + onSuccess: () => { + addToast({ + variant: 'success', + title: intl.formatMessage(mapMessages.downloadReady, { + size: formatBytes(map.smpSize ?? 0), + }), + }); + }, + onError: (error) => { + addToast({ + variant: 'error', + title: intl.formatMessage(mapMessages.downloadFailed, { + error: error instanceof Error ? error.message : 'Unknown error', + }), + }); + }, + }, + ); + } + + if (isLoading) { + return ( +
+

+ {intl.formatMessage(mapMessages.historyTitle)} +

+
+ + +
+
+ ); + } + + return ( +
+

+ {intl.formatMessage(mapMessages.historyTitle)} +

+ + {completed.length === 0 ? ( +

+ {intl.formatMessage(mapMessages.historyEmpty)} +

+ ) : null} + +
+ {completed.map((map) => { + const isError = map.status === 'error'; + return ( +
+
+
+

+ {map.name} +

+ {map.smpSize !== undefined ? ( +

+ {intl.formatMessage(mapMessages.historySize, { + size: formatBytes(map.smpSize), + })} +

+ ) : null} + + {intl.formatMessage( + isError + ? mapMessages.historyStatusError + : mapMessages.historyStatusReady, + )} + + {isError && map.errorMessage ? ( +

+ {map.errorMessage} +

+ ) : null} +
+ {isError ? ( + + ) : null} +
+
+ ); + })} +
+
+ ); +} + +export type { DownloadHistoryProps }; diff --git a/src/screens/MapScreen/GeoJsonOverlay.tsx b/src/screens/MapScreen/GeoJsonOverlay.tsx new file mode 100644 index 00000000..646a807a --- /dev/null +++ b/src/screens/MapScreen/GeoJsonOverlay.tsx @@ -0,0 +1,371 @@ +import type { Feature, FeatureCollection, Geometry } from 'geojson'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useIntl } from 'react-intl'; + +import { formatBytes } from '@/lib/map/smp-download'; +import { uuid } from '@/lib/uuid'; + +import { mapMessages } from './messages'; + +/** + * A single reference-layer overlay loaded from a dropped GeoJSON file. + */ +export interface OverlayEntry { + id: string; + name: string; + data: FeatureCollection; + visible: boolean; + color: string; + size: number; +} + +/** Distinct outline colors cycled per file so layers are visually separable. */ +const OVERLAY_COLORS = [ + '#E0457B', + '#2D8A4E', + '#B85C00', + '#7B2D8A', + '#006B8A', + '#8A4D2D', +]; + +/** Warn for files larger than this (5 MB) — parsing blocks the main thread. */ +const LARGE_FILE_THRESHOLD = 5 * 1024 * 1024; + +/** + * Validate that parsed JSON is a usable GeoJSON shape for an overlay. + * Accepts FeatureCollection, Feature, or a bare Geometry, then normalises + * to a FeatureCollection so MapLibre can render it as a single source. + */ +function normalizeGeoJson(value: unknown): FeatureCollection | null { + if (!value || typeof value !== 'object') return null; + const obj = value as { type?: unknown }; + + if (obj.type === 'FeatureCollection') { + const fc = value as FeatureCollection; + if (!Array.isArray(fc.features)) return null; + return fc; + } + + if (obj.type === 'Feature') { + const feature = value as Feature; + if (!feature.geometry) return null; + return { type: 'FeatureCollection', features: [feature] }; + } + + // Bare geometry (Point, Polygon, LineString, etc.) + const geometry = value as Geometry; + if ( + typeof geometry.type === 'string' && + geometry.type !== 'GeometryCollection' + ) { + return { + type: 'FeatureCollection', + features: [{ type: 'Feature', properties: {}, geometry }], + }; + } + + return null; +} + +interface GeoJsonOverlayProps { + /** + * Called with the current overlay entries whenever they change. + * The parent (MapAuthoringCanvas) uses this to render ``/`` + * pairs inside the MapLibre `` — overlays cannot be rendered outside + * the map tree. + */ + onOverlaysChange: (overlays: OverlayEntry[]) => void; +} + +/** + * Manages drag-and-drop GeoJSON reference layers on the map authoring canvas. + * + * The dropzone is an invisible overlay that captures HTML5 drag-drop events + * without intercepting map pointer events (pan/zoom). It only activates when + * a file is being dragged (`onDragEnter`/`onDragOver`), so normal map + * interaction is unaffected. + * + * Overlays are transient — they are not persisted and are cleared on unmount. + */ +export function GeoJsonOverlay({ onOverlaysChange }: GeoJsonOverlayProps) { + const intl = useIntl(); + const [overlays, setOverlays] = useState([]); + const [isDragActive, setIsDragActive] = useState(false); + const [error, setError] = useState(null); + const dragDepthRef = useRef(0); + + // Sync overlay state up to the parent for map-layer rendering. + useEffect(() => { + onOverlaysChange(overlays); + }, [overlays, onOverlaysChange]); + + // Clear all overlays on unmount (transient authoring aid). + useEffect(() => { + return () => { + onOverlaysChange([]); + }; + }, [onOverlaysChange]); + + const handleDragEnter = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + dragDepthRef.current += 1; + setIsDragActive(true); + }, []); + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); + if (dragDepthRef.current === 0) { + setIsDragActive(false); + } + }, []); + + const handleDragOver = useCallback((e: React.DragEvent) => { + // Must preventDefault on dragOver to allow the drop. + e.preventDefault(); + e.stopPropagation(); + }, []); + + const handleDrop = useCallback( + async (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + dragDepthRef.current = 0; + setIsDragActive(false); + setError(null); + + const files = Array.from(e.dataTransfer.files).filter( + (f) => + f.name.toLowerCase().endsWith('.geojson') || + f.name.toLowerCase().endsWith('.json'), + ); + + if (files.length === 0) { + setError( + intl.formatMessage(mapMessages.overlayInvalid, { + name: e.dataTransfer.files[0]?.name ?? 'file', + }), + ); + return; + } + + for (const file of files) { + try { + const text = await file.text(); + const parsed: unknown = JSON.parse(text); + const normalized = normalizeGeoJson(parsed); + if (!normalized) { + setError( + intl.formatMessage(mapMessages.overlayInvalid, { + name: file.name, + }), + ); + continue; + } + setOverlays((prev) => { + const color = OVERLAY_COLORS[prev.length % OVERLAY_COLORS.length]!; + return [ + ...prev, + { + id: uuid(), + name: file.name, + data: normalized, + visible: true, + color, + size: file.size, + }, + ]; + }); + } catch { + setError( + intl.formatMessage(mapMessages.overlayInvalid, { + name: file.name, + }), + ); + } + } + }, + [intl], + ); + + const toggleVisible = useCallback((id: string) => { + setOverlays((prev) => + prev.map((o) => (o.id === id ? { ...o, visible: !o.visible } : o)), + ); + }, []); + + const removeOverlay = useCallback((id: string) => { + setOverlays((prev) => prev.filter((o) => o.id !== id)); + }, []); + + const clearAll = useCallback(() => { + setOverlays([]); + }, []); + + const largeOverlay = overlays.find((o) => o.size > LARGE_FILE_THRESHOLD); + + return ( + <> + {/* Dropzone — invisible until a file drag is active */} +
+ {isDragActive ? ( +
+

+ {intl.formatMessage(mapMessages.overlayDropActive)} +

+
+ ) : null} +
+ + {/* Overlay management panel */} + {overlays.length > 0 || error ? ( +
+
+

+ {intl.formatMessage(mapMessages.overlayTitle)} +

+ {overlays.length > 0 ? ( + + ) : null} +
+ + {error ? ( +

+ {error} +

+ ) : null} + + {largeOverlay ? ( +

+ {intl.formatMessage(mapMessages.overlayTooLarge, { + name: largeOverlay.name, + size: formatBytes(largeOverlay.size), + })} +

+ ) : null} + + {overlays.length === 0 && !error ? ( +

+ {intl.formatMessage(mapMessages.overlayEmpty)} +

+ ) : null} + + {overlays.map((overlay) => ( +
+
+ ))} +
+ ) : null} + + ); +} + +export type { GeoJsonOverlayProps }; diff --git a/src/screens/MapScreen/MapAuthoringCanvas.tsx b/src/screens/MapScreen/MapAuthoringCanvas.tsx index 83819bbd..37a57ea8 100644 --- a/src/screens/MapScreen/MapAuthoringCanvas.tsx +++ b/src/screens/MapScreen/MapAuthoringCanvas.tsx @@ -16,6 +16,8 @@ import { basemapToMapStyle } from '@/lib/map/basemap-utils'; import { crossesAntimeridian } from '@/lib/map/bbox-utils'; import type { ImageryBasemap } from '@/lib/schemas/imagery-source'; +import { GeoJsonOverlay } from './GeoJsonOverlay'; +import type { OverlayEntry } from './GeoJsonOverlay'; import { mapMessages } from './messages'; interface MapAuthoringCanvasProps { @@ -125,6 +127,9 @@ export function MapAuthoringCanvas({ [bbox], ); + // GeoJSON reference-layer overlays (#15) + const [overlays, setOverlays] = useState([]); + // Drag‑to‑draw state const [dragStart, setDragStart] = useState<{ lng: number; @@ -386,7 +391,50 @@ export function MapAuthoringCanvas({ /> )} + + {/* GeoJSON reference-layer overlays (#15) */} + {overlays.map((overlay) => + overlay.visible ? ( + + + + + + ) : null, + )}
+ + {/* Drag-and-drop GeoJSON overlay manager (#15) */} + + {drawError && (

(null); + const [viewMode, setViewMode] = useState('author'); + const [historyScope, setHistoryScope] = useState('thisProject'); const [selectedStyle, setSelectedStyle] = useState(() => findBasemap(DEFAULT_BASEMAP_ID), ); @@ -307,38 +315,134 @@ export function MapScreen() { function renderControls() { return (

- - - -

- {intl.formatMessage(mapMessages.zoomDownloadNote)} -

- - {(() => { - const maps = mapsQuery.data ?? []; - const downloadableMaps = maps.filter( - (m) => - m.status === 'draft' || - m.status === 'downloading' || - m.status === 'error' || - m.status === 'ready', - ); - return downloadableMaps.map((m) => ( - - )); - })()} - + + +
+ + {viewMode === 'author' ? ( + <> + + + +

+ {intl.formatMessage(mapMessages.zoomDownloadNote)} +

+ + {(() => { + const maps = mapsQuery.data ?? []; + const downloadableMaps = maps.filter( + (m) => + m.status === 'draft' || + m.status === 'downloading' || + m.status === 'error' || + m.status === 'ready', + ); + return downloadableMaps.map((m) => ( + + )); + })()} + + + ) : ( + <> + {/* History scope toggle (#16) */} +
+ + +
+ + {historyScope === 'thisProject' ? ( + + ) : ( + + )} + + {/* Cross-project saved maps list (#16) */} + {historyScope === 'allProjects' ? ( + + ) : null} + + )} ); } diff --git a/src/screens/MapScreen/SavedMapsList.tsx b/src/screens/MapScreen/SavedMapsList.tsx index b7d4ef69..352c1cd2 100644 --- a/src/screens/MapScreen/SavedMapsList.tsx +++ b/src/screens/MapScreen/SavedMapsList.tsx @@ -18,6 +18,26 @@ import { mapMessages } from './messages'; interface SavedMapsListProps { projectLocalId: string | null; + /** + * Pre-resolved maps to display instead of querying by `projectLocalId`. + * Used by the cross-project "All projects" view (#16), which passes + * maps from `useAllMaps()`. + * + * When provided, the internal `useMaps` query is skipped and these + * maps are rendered directly. + */ + maps?: SavedMap[]; + /** + * Whether the maps query is still loading (for skeleton state). + * Only relevant when `maps` is provided. + */ + isLoading?: boolean; + /** + * Show the origin project name for each map (cross-project view, #16). + * When `true`, each row displays the `originProjectName` field from + * the `SavedMapWithProject` type. + */ + showOriginProject?: boolean; } type PendingAction = @@ -35,7 +55,12 @@ const STATUS_MESSAGE_BY_STATUS: Record< error: 'statusError', }; -export function SavedMapsList({ projectLocalId }: SavedMapsListProps) { +export function SavedMapsList({ + projectLocalId, + maps: externalMaps, + isLoading, + showOriginProject, +}: SavedMapsListProps) { const intl = useIntl(); const activeMapId = useMapStore((state) => state.activeMapId); const mapsQuery = useMaps(projectLocalId); @@ -52,7 +77,10 @@ export function SavedMapsList({ projectLocalId }: SavedMapsListProps) { const [deleteTarget, setDeleteTarget] = useState(null); const [deleteError, setDeleteError] = useState(null); - const maps = mapsQuery.data ?? []; + const maps = externalMaps ?? mapsQuery.data ?? []; + const isLoadingState = externalMaps + ? Boolean(isLoading) + : mapsQuery.isPending; const hasPendingAction = pendingAction !== null; async function runPendingAction( @@ -145,14 +173,14 @@ export function SavedMapsList({ projectLocalId }: SavedMapsListProps) {

) : null} - {mapsQuery.isPending ? ( + {isLoadingState ? (
) : null} - {!mapsQuery.isPending && maps.length === 0 ? ( + {!isLoadingState && maps.length === 0 ? (

{intl.formatMessage(mapMessages.savedMapsEmpty)}

@@ -172,6 +200,15 @@ export function SavedMapsList({ projectLocalId }: SavedMapsListProps) {

{map.name}

+ {showOriginProject && + 'originProjectName' in map && + typeof (map as { originProjectName?: unknown }) + .originProjectName === 'string' ? ( +

+ {intl.formatMessage(mapMessages.originProject)}:{' '} + {(map as { originProjectName: string }).originProjectName} +

+ ) : null} {intl.formatMessage( mapMessages[STATUS_MESSAGE_BY_STATUS[map.status]], diff --git a/src/screens/MapScreen/messages.ts b/src/screens/MapScreen/messages.ts index 62b72c89..71007275 100644 --- a/src/screens/MapScreen/messages.ts +++ b/src/screens/MapScreen/messages.ts @@ -398,4 +398,83 @@ export const mapMessages = defineMessages({ defaultMessage: '{n} tiles could not be downloaded. The package may be incomplete.', }, + // ── GeoJSON overlay (#15) ───────────────────────────────────────────── + overlayDrop: { + id: 'map.overlay.drop', + defaultMessage: 'Drop a .geojson file to show it as a reference layer', + }, + overlayDropActive: { + id: 'map.overlay.dropActive', + defaultMessage: 'Drop the file to add the overlay', + }, + overlayInvalid: { + id: 'map.overlay.invalid', + defaultMessage: 'Could not read “{name}”. Not valid GeoJSON.', + }, + overlayTooLarge: { + id: 'map.overlay.tooLarge', + defaultMessage: '“{name}” is {size}. Large files may slow down the editor.', + }, + overlayToggle: { + id: 'map.overlay.toggle', + defaultMessage: 'Toggle overlay visibility', + }, + overlayRemove: { + id: 'map.overlay.remove', + defaultMessage: 'Remove overlay', + }, + overlayClear: { + id: 'map.overlay.clear', + defaultMessage: 'Clear all overlays', + }, + overlayTitle: { + id: 'map.overlay.title', + defaultMessage: 'Reference layers', + }, + overlayEmpty: { + id: 'map.overlay.empty', + defaultMessage: 'No reference layers added', + }, + // ── Cross-project map scope (#16) ──────────────────────────────────── + scopeThisProject: { + id: 'map.scope.thisProject', + defaultMessage: 'This project', + }, + scopeAllProjects: { + id: 'map.scope.allProjects', + defaultMessage: 'All projects', + }, + originProject: { + id: 'map.saved.originProject', + defaultMessage: 'Origin project', + }, + // ── Download history (#17) ─────────────────────────────────────────── + historyTitle: { + id: 'map.history.title', + defaultMessage: 'Download history', + }, + historyRetry: { + id: 'map.history.retry', + defaultMessage: 'Retry', + }, + historyEmpty: { + id: 'map.history.empty', + defaultMessage: 'No downloads yet', + }, + historySize: { + id: 'map.history.size', + defaultMessage: 'Size: {size}', + }, + historyDownloaded: { + id: 'map.history.downloaded', + defaultMessage: 'Downloaded', + }, + historyStatusReady: { + id: 'map.history.statusReady', + defaultMessage: 'Ready', + }, + historyStatusError: { + id: 'map.history.statusError', + defaultMessage: 'Failed', + }, }); diff --git a/tests/unit/screens/MapScreen/DownloadHistory.test.tsx b/tests/unit/screens/MapScreen/DownloadHistory.test.tsx new file mode 100644 index 00000000..e59dec3f --- /dev/null +++ b/tests/unit/screens/MapScreen/DownloadHistory.test.tsx @@ -0,0 +1,151 @@ +import { fireEvent, screen } from '@testing-library/react'; +import { render } from '@tests/mocks/test-utils'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useToast } from '@/components/ui/toast'; +import { useDownloadMap } from '@/hooks/useMaps'; +import type { SavedMap } from '@/lib/db'; +import { formatBytes } from '@/lib/map/smp-download'; +import { DownloadHistory } from '@/screens/MapScreen/DownloadHistory'; + +type ToastReturn = ReturnType; +type MutateOpts = { + onSuccess?: (d: unknown) => void; + onError?: (e: unknown) => void; +}; + +const { mutate, addToast } = vi.hoisted(() => ({ + mutate: vi.fn<(vars: { map: SavedMap }, opts?: MutateOpts) => void>(), + addToast: vi.fn(), +})); + +vi.mock('@/hooks/useMaps', () => ({ useDownloadMap: vi.fn() })); +vi.mock('@/components/ui/toast', () => ({ useToast: vi.fn() })); + +const makeMap = (over: Partial = {}): SavedMap => + ({ + id: 'm1', + projectLocalId: 'p1', + name: 'Test Map', + type: 'style', + styleUrl: 'https://example.com/style', + bbox: [0, 0, 0, 0], + minZoom: 0, + maxZoom: 14, + status: 'ready', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + ...over, + }) as SavedMap; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(useDownloadMap).mockReturnValue({ + mutate, + isPending: false, + } as unknown as ReturnType); + vi.mocked(useToast).mockReturnValue({ addToast } as unknown as ToastReturn); +}); + +describe('DownloadHistory', () => { + it('renders the title and two skeletons while loading', () => { + render(); + expect(screen.getByText(/download history/i)).toBeInTheDocument(); + expect(screen.getAllByTestId('skeleton')).toHaveLength(2); + }); + + it('renders the empty message when there are no completed downloads', () => { + render(); + expect(screen.getByText(/no downloads yet/i)).toBeInTheDocument(); + }); + + it('renders a ready map with its size and Ready status', () => { + render( + , + ); + expect(screen.getByText('Test Map')).toBeInTheDocument(); + const sizeText = screen.getByText(/size:/i); + expect(sizeText.textContent).toContain(formatBytes(1048576)); + expect(screen.getByText(/^ready$/i)).toBeInTheDocument(); + }); + + it('renders an error map with its message and a retry button', () => { + render( + , + ); + expect(screen.getByText(/failed/i)).toBeInTheDocument(); + expect(screen.getByText('Network failure')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument(); + }); + + it('calls downloadMap.mutate with the map when retry is clicked', () => { + render( + , + ); + fireEvent.click(screen.getByRole('button', { name: /retry/i })); + expect(mutate).toHaveBeenCalledWith( + { map: expect.objectContaining({ id: 'err1' }) }, + expect.anything(), + ); + }); + + it('fires a success toast via onSuccess', () => { + mutate.mockImplementation((_vars, opts) => { + opts?.onSuccess?.(undefined); + }); + render( + , + ); + fireEvent.click(screen.getByRole('button', { name: /retry/i })); + expect(addToast).toHaveBeenCalledWith( + expect.objectContaining({ variant: 'success' }), + ); + }); + + it('fires an error toast via onError', () => { + mutate.mockImplementation((_vars, opts) => { + opts?.onError?.(new Error('boom')); + }); + render( + , + ); + fireEvent.click(screen.getByRole('button', { name: /retry/i })); + expect(addToast).toHaveBeenCalledWith( + expect.objectContaining({ + variant: 'error', + title: expect.stringContaining('boom'), + }), + ); + }); + + it('does not render a Size line for a ready map without smpSize', () => { + render( + , + ); + expect(screen.queryByText(/size:/i)).toBeNull(); + expect(screen.getByText(/^ready$/i)).toBeInTheDocument(); + }); +}); diff --git a/tests/unit/screens/MapScreen/GeoJsonOverlay.test.tsx b/tests/unit/screens/MapScreen/GeoJsonOverlay.test.tsx new file mode 100644 index 00000000..079757ec --- /dev/null +++ b/tests/unit/screens/MapScreen/GeoJsonOverlay.test.tsx @@ -0,0 +1,189 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import { render } from '@tests/mocks/test-utils'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { GeoJsonOverlay } from '@/screens/MapScreen/GeoJsonOverlay'; + +const { uuid } = vi.hoisted(() => { + let n = 0; + return { uuid: () => `uuid-${n++}` }; +}); +vi.mock('@/lib/uuid', () => ({ uuid })); + +const dropGeoJson = (zone: HTMLElement, files: File[]) => { + fireEvent.drop(zone, { + dataTransfer: { files, items: [], types: ['Files'] }, + }); +}; + +const makeFile = (name: string, content: string, size?: number): File => { + const f = new File([content], name, { type: 'application/json' }); + if (size !== undefined) Object.defineProperty(f, 'size', { value: size }); + return f; +}; + +const featureCollection = JSON.stringify({ + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + geometry: { type: 'Point', coordinates: [0, 0] }, + properties: {}, + }, + ], +}); +const feature = JSON.stringify({ + type: 'Feature', + geometry: { type: 'Point', coordinates: [1, 1] }, + properties: {}, +}); +const bareGeometry = JSON.stringify({ type: 'Point', coordinates: [2, 2] }); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('GeoJsonOverlay', () => { + it('renders the dropzone with the correct label and no panel initially', () => { + render(); + expect(screen.getByTestId('geojson-dropzone')).toHaveAttribute( + 'aria-label', + expect.stringMatching(/drop a \.geojson/i), + ); + expect(screen.queryByTestId('geojson-overlay-panel')).toBeNull(); + }); + + it('shows the active drop hint on drag enter and hides it on drag leave', () => { + render(); + const zone = screen.getByTestId('geojson-dropzone'); + fireEvent.dragEnter(zone); + expect( + screen.getByText(/drop the file to add the overlay/i), + ).toBeInTheDocument(); + expect(zone.className).toContain('border-dashed'); + fireEvent.dragLeave(zone); + expect(screen.queryByText(/drop the file to add the overlay/i)).toBeNull(); + }); + + it('adds an overlay from a valid FeatureCollection file', async () => { + const onOverlaysChange = vi.fn(); + render(); + dropGeoJson(screen.getByTestId('geojson-dropzone'), [ + makeFile('layer.geojson', featureCollection), + ]); + await waitFor(() => { + expect(screen.getByTestId('geojson-overlay-row')).toBeInTheDocument(); + }); + expect(onOverlaysChange).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ name: 'layer.geojson' }), + ]), + ); + }); + + it('adds an overlay from a single Feature file', async () => { + render(); + dropGeoJson(screen.getByTestId('geojson-dropzone'), [ + makeFile('feat.geojson', feature), + ]); + await waitFor(() => { + expect(screen.getByTestId('geojson-overlay-row')).toBeInTheDocument(); + }); + }); + + it('adds an overlay from a bare Geometry object', async () => { + render(); + dropGeoJson(screen.getByTestId('geojson-dropzone'), [ + makeFile('geom.geojson', bareGeometry), + ]); + await waitFor(() => { + expect(screen.getByTestId('geojson-overlay-row')).toBeInTheDocument(); + }); + }); + + it('rejects an invalid GeoJSON object with an alert and no overlay', async () => { + render(); + dropGeoJson(screen.getByTestId('geojson-dropzone'), [ + makeFile('bad.geojson', JSON.stringify({ foo: 'bar' })), + ]); + await waitFor(() => { + expect(screen.getByRole('alert')).toHaveTextContent(/not valid geojson/i); + }); + expect(screen.queryByTestId('geojson-overlay-row')).toBeNull(); + }); + + it('rejects a non-geojson filename extension', async () => { + render(); + dropGeoJson(screen.getByTestId('geojson-dropzone'), [ + makeFile('layer.txt', featureCollection), + ]); + await waitFor(() => { + expect(screen.getByRole('alert')).toHaveTextContent(/not valid geojson/i); + }); + expect(screen.queryByTestId('geojson-overlay-row')).toBeNull(); + }); + + it('rejects unparseable JSON content', async () => { + render(); + dropGeoJson(screen.getByTestId('geojson-dropzone'), [ + makeFile('broken.geojson', 'not json'), + ]); + await waitFor(() => { + expect(screen.getByRole('alert')).toHaveTextContent(/not valid geojson/i); + }); + expect(screen.queryByTestId('geojson-overlay-row')).toBeNull(); + }); + + it('warns when a large file is dropped', async () => { + const big = makeFile('big.geojson', featureCollection, 6 * 1024 * 1024); + render(); + dropGeoJson(screen.getByTestId('geojson-dropzone'), [big]); + await waitFor(() => { + expect(screen.getByText(/large files may slow/i)).toBeInTheDocument(); + }); + }); + + it('toggles an overlay visibility', async () => { + render(); + dropGeoJson(screen.getByTestId('geojson-dropzone'), [ + makeFile('layer.geojson', featureCollection), + ]); + await waitFor(() => { + expect(screen.getByTestId('geojson-overlay-row')).toBeInTheDocument(); + }); + const toggle = screen.getByLabelText(/toggle overlay visibility/i); + expect(toggle).toHaveAttribute('aria-pressed', 'true'); + fireEvent.click(toggle); + expect(screen.getByLabelText(/toggle overlay visibility/i)).toHaveAttribute( + 'aria-pressed', + 'false', + ); + }); + + it('removes an overlay', async () => { + render(); + dropGeoJson(screen.getByTestId('geojson-dropzone'), [ + makeFile('layer.geojson', featureCollection), + ]); + await waitFor(() => { + expect(screen.getByTestId('geojson-overlay-row')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByLabelText(/remove overlay/i)); + expect(screen.queryByTestId('geojson-overlay-row')).toBeNull(); + }); + + it('clears all overlays', async () => { + render(); + const zone = screen.getByTestId('geojson-dropzone'); + dropGeoJson(zone, [makeFile('a.geojson', featureCollection)]); + dropGeoJson(zone, [makeFile('b.geojson', feature)]); + await waitFor(() => { + expect(screen.getAllByTestId('geojson-overlay-row')).toHaveLength(2); + }); + fireEvent.click(screen.getByText(/clear all overlays/i)); + await waitFor(() => { + expect(screen.queryByTestId('geojson-overlay-panel')).toBeNull(); + }); + expect(screen.queryByTestId('geojson-overlay-row')).toBeNull(); + }); +});