@djangocfg/ui-tools 2.1.430 → 2.1.432
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/tree/index.cjs +43 -8
- package/dist/tree/index.cjs.map +1 -1
- package/dist/tree/index.d.cts +31 -1
- package/dist/tree/index.d.ts +31 -1
- package/dist/tree/index.mjs +43 -8
- package/dist/tree/index.mjs.map +1 -1
- package/package.json +28 -14
- package/src/common/CodePanelHeader/CodePanelHeader.tsx +121 -0
- package/src/common/CodePanelHeader/index.ts +1 -0
- package/src/common/blocks/BlockError.tsx +46 -0
- package/src/common/blocks/index.ts +34 -0
- package/src/common/blocks/schemas.ts +143 -0
- package/src/common/blocks/types.ts +76 -0
- package/src/common/chips/README.md +67 -0
- package/src/common/chips/UrlChip.tsx +105 -0
- package/src/common/chips/index.ts +20 -0
- package/src/common/link-preview/LinkPreviewCard.tsx +178 -0
- package/src/common/link-preview/index.ts +12 -0
- package/src/common/link-preview/types.ts +44 -0
- package/src/common/tiptap/index.ts +10 -0
- package/src/common/tiptap/useNodeAttrs.ts +128 -0
- package/src/lib/page-snapshot/react/PageSnapshotChip.tsx +1 -1
- package/src/tools/chat/README.md +109 -2
- package/src/tools/chat/composer/Composer.tsx +45 -12
- package/src/tools/chat/composer/ComposerRichTextarea.tsx +16 -4
- package/src/tools/chat/composer/PastedTextDialog.tsx +88 -0
- package/src/tools/chat/composer/index.ts +9 -0
- package/src/tools/chat/composer/textToAttachment.ts +69 -0
- package/src/tools/chat/composer/types.ts +13 -0
- package/src/tools/chat/composer/useComposerAttach.tsx +47 -0
- package/src/tools/chat/constants.ts +9 -2
- package/src/tools/chat/core/extractFirstUrl.ts +45 -0
- package/src/tools/chat/core/reducer.ts +32 -8
- package/src/tools/chat/core/transport/mappers/pydantic-ai.ts +1 -1
- package/src/tools/chat/core/transport/mock.ts +4 -1
- package/src/tools/chat/hooks/useChat.ts +20 -20
- package/src/tools/chat/lazy.tsx +10 -0
- package/src/tools/chat/messages/Attachments.tsx +335 -46
- package/src/tools/chat/messages/BubbleContextMenu.tsx +203 -0
- package/src/tools/chat/messages/MessageBubble.tsx +175 -31
- package/src/tools/chat/messages/MessageList.tsx +107 -21
- package/src/tools/chat/messages/ToolCalls.tsx +28 -13
- package/src/tools/chat/messages/blocks/MessageBlocks.tsx +70 -17
- package/src/tools/chat/messages/blocks/builtin.tsx +6 -3
- package/src/tools/chat/messages/blocks/renderers/AudioBlock.tsx +6 -0
- package/src/tools/chat/messages/blocks/renderers/ChatImageAlbum.tsx +154 -0
- package/src/tools/chat/messages/blocks/renderers/CodeBlock.tsx +94 -6
- package/src/tools/chat/messages/blocks/renderers/DiffBlock.tsx +29 -0
- package/src/tools/chat/messages/blocks/renderers/GalleryBlock.tsx +17 -10
- package/src/tools/chat/messages/blocks/renderers/ImageBlock.tsx +37 -16
- package/src/tools/chat/messages/blocks/renderers/JsonBlock.tsx +11 -6
- package/src/tools/chat/messages/blocks/renderers/LinkBlock.tsx +32 -0
- package/src/tools/chat/messages/blocks/renderers/MapBlock.tsx +181 -19
- package/src/tools/chat/messages/blocks/renderers/VideoBlock.tsx +2 -5
- package/src/tools/chat/messages/index.ts +13 -0
- package/src/tools/chat/public.ts +5 -1
- package/src/tools/chat/shell/ChatRoot.tsx +29 -0
- package/src/tools/chat/styles/bubbleTokens.ts +18 -0
- package/src/tools/chat/styles/index.ts +1 -0
- package/src/tools/chat/types/attachment.ts +38 -3
- package/src/tools/chat/types/block.ts +56 -9
- package/src/tools/chat/types/blockSchemas.ts +197 -0
- package/src/tools/chat/types/config.ts +33 -0
- package/src/tools/chat/types/events.ts +14 -7
- package/src/tools/chat/types/index.ts +4 -2
- package/src/tools/chat/types/message.ts +7 -0
- package/src/tools/data/JsonTree/JsonViewer.tsx +7 -7
- package/src/tools/data/Tree/README.md +30 -0
- package/src/tools/data/Tree/components/TreeRow.tsx +11 -2
- package/src/tools/data/Tree/context/menu/render.tsx +56 -27
- package/src/tools/data/Tree/data/appearance.ts +65 -3
- package/src/tools/data/Tree/data/index.ts +2 -0
- package/src/tools/data/Tree/index.tsx +2 -0
- package/src/tools/dev/Map/README.md +30 -345
- package/src/tools/dev/Map/cards/index.ts +6 -0
- package/src/tools/dev/Map/cards/types.ts +67 -0
- package/src/tools/dev/Map/components/CompassChip.tsx +67 -0
- package/src/tools/dev/Map/components/DraggableMarkers.tsx +79 -0
- package/src/tools/dev/Map/components/GeocoderControl.tsx +148 -63
- package/src/tools/dev/Map/components/GeocoderInput.tsx +181 -0
- package/src/tools/dev/Map/components/MapContainer/FullscreenDialog.tsx +140 -0
- package/src/tools/dev/Map/components/MapContainer/GeolocateHandle.tsx +70 -0
- package/src/tools/dev/Map/components/MapContainer/MapInner.tsx +326 -0
- package/src/tools/dev/Map/components/MapContainer/MapToolbar.tsx +286 -0
- package/src/tools/dev/Map/components/MapContainer/RotateHint.tsx +25 -0
- package/src/tools/dev/Map/components/MapContainer/ScrollLockOverlay.tsx +52 -0
- package/src/tools/dev/Map/components/MapContainer/constants.ts +15 -0
- package/src/tools/dev/Map/components/MapContainer/hooks/useAutoReset.ts +51 -0
- package/src/tools/dev/Map/components/MapContainer/hooks/useBasemapState.ts +48 -0
- package/src/tools/dev/Map/components/MapContainer/hooks/useExternalMapsUrl.ts +29 -0
- package/src/tools/dev/Map/components/MapContainer/index.tsx +29 -0
- package/src/tools/dev/Map/components/MapContainer/types.ts +198 -0
- package/src/tools/dev/Map/components/MapControls.tsx +27 -0
- package/src/tools/dev/Map/components/MapMarkers.tsx +94 -0
- package/src/tools/dev/Map/components/MarkerCard.tsx +102 -0
- package/src/tools/dev/Map/components/MeasureControl.tsx +219 -0
- package/src/tools/dev/Map/components/index.ts +35 -1
- package/src/tools/dev/Map/geocode/index.ts +2 -0
- package/src/tools/dev/Map/geocode/photon.ts +265 -0
- package/src/tools/dev/Map/hooks/index.ts +37 -0
- package/src/tools/dev/Map/hooks/useCompass.ts +44 -0
- package/src/tools/dev/Map/hooks/useDraggableMarkers.ts +112 -0
- package/src/tools/dev/Map/hooks/useGeocoder.ts +155 -0
- package/src/tools/dev/Map/hooks/useGeolocation.ts +174 -0
- package/src/tools/dev/Map/hooks/useMapControl.ts +5 -1
- package/src/tools/dev/Map/hooks/useMapImages.ts +112 -0
- package/src/tools/dev/Map/hooks/useMapScrollProtection.ts +155 -0
- package/src/tools/dev/Map/hooks/useMapTouchMode.ts +21 -0
- package/src/tools/dev/Map/hooks/useMarkerCard.ts +39 -0
- package/src/tools/dev/Map/hooks/useMarkers.ts +9 -3
- package/src/tools/dev/Map/hooks/useMeasure.ts +169 -0
- package/src/tools/dev/Map/hooks/useTerrain.ts +123 -0
- package/src/tools/dev/Map/index.ts +59 -2
- package/src/tools/dev/Map/layers/index.ts +1 -0
- package/src/tools/dev/Map/layers/symbol.ts +93 -0
- package/src/tools/dev/Map/lazy.tsx +120 -0
- package/src/tools/dev/Map/protocols/pmtiles.ts +73 -0
- package/src/tools/dev/Map/styles/index.ts +62 -0
- package/src/tools/dev/Map/styles/terrain.ts +29 -0
- package/src/tools/dev/Map/types.ts +31 -3
- package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/EndpointDoc/Header/MetaActions.tsx +1 -1
- package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/EndpointDoc/Responses/StatusTag.tsx +5 -12
- package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/EndpointDoc/Section/SectionHeader.tsx +1 -1
- package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/SchemaCopyMenu.tsx +1 -1
- package/src/tools/dev/api/OpenapiViewer/components/shared/SendButton.tsx +1 -1
- package/src/tools/dev/api/OpenapiViewer/components/shared/http-colors.ts +61 -0
- package/src/tools/dev/api/OpenapiViewer/components/shared/ui.tsx +4 -21
- package/src/tools/dev/code/MarkdownMessage/MarkdownMessage.tsx +150 -58
- package/src/tools/dev/code/MarkdownMessage/README.md +38 -0
- package/src/tools/dev/code/MarkdownMessage/builtinRules.tsx +92 -0
- package/src/tools/dev/code/MarkdownMessage/index.ts +3 -0
- package/src/tools/dev/code/MarkdownMessage/streamingBlocks.ts +164 -0
- package/src/tools/dev/code/MarkdownMessage/types.ts +20 -0
- package/src/tools/dev/code/PrettyCode/PrettyCode.client.tsx +27 -30
- package/src/tools/dev/code/PrettyCode/index.tsx +4 -1
- package/src/tools/dev/code/PrettyCode/lazy.tsx +3 -1
- package/src/tools/forms/MarkdownEditor/MarkdownEditor.tsx +168 -3
- package/src/tools/forms/MarkdownEditor/README.md +84 -0
- package/src/tools/forms/MarkdownEditor/chip/ChipNode.ts +454 -0
- package/src/tools/forms/MarkdownEditor/chip/syncChips.ts +80 -0
- package/src/tools/forms/MarkdownEditor/filePath/FilePathChip.tsx +68 -0
- package/src/tools/forms/MarkdownEditor/filePath/__tests__/detect.test.ts +161 -0
- package/src/tools/forms/MarkdownEditor/filePath/detect.ts +289 -0
- package/src/tools/forms/MarkdownEditor/filePath/index.ts +15 -0
- package/src/tools/forms/MarkdownEditor/index.ts +19 -0
- package/src/tools/forms/MarkdownEditor/lazy.tsx +10 -0
- package/src/tools/forms/MarkdownEditor/styles.css +134 -2
- package/src/tools/forms/MarkdownEditor/url/__tests__/detect.test.ts +121 -0
- package/src/tools/forms/MarkdownEditor/url/detect.ts +227 -0
- package/src/tools/forms/NotionEditor/BookmarkNode.ts +158 -0
- package/src/tools/forms/NotionEditor/BookmarkView.tsx +45 -0
- package/src/tools/forms/NotionEditor/MapEditLayer.tsx +199 -0
- package/src/tools/forms/NotionEditor/MapNode.ts +218 -0
- package/src/tools/forms/NotionEditor/MapView.tsx +103 -0
- package/src/tools/forms/NotionEditor/NotionEditor.tsx +7 -2
- package/src/tools/forms/NotionEditor/README.md +109 -188
- package/src/tools/forms/NotionEditor/extensions.ts +15 -0
- package/src/tools/forms/NotionEditor/slashItems.ts +19 -0
- package/src/tools/forms/NotionEditor/styles.css +25 -0
- package/src/tools/forms/NotionEditor/types.ts +9 -0
- package/src/tools/input/SpeechRecognition/core/engine/external.ts +5 -0
- package/src/tools/input/SpeechRecognition/types.ts +10 -0
- package/src/tools/media/Gallery/components/lightbox/GalleryLightbox.tsx +39 -22
- package/src/tools/media/ImageViewer/README.md +9 -0
- package/src/tools/media/ImageViewer/components/ImageViewer.tsx +165 -79
- package/src/tools/media/ImageViewer/components/LightboxChrome.tsx +137 -0
- package/src/tools/media/ImageViewer/components/MiddleEllipsis.tsx +46 -0
- package/src/tools/media/ImageViewer/components/index.ts +2 -0
- package/src/tools/media/ImageViewer/hooks/index.ts +3 -0
- package/src/tools/media/ImageViewer/hooks/useIdleChrome.ts +73 -0
- package/src/tools/media/ImageViewer/types.ts +10 -0
- package/src/tools/media/ImageViewer/utils/constants.ts +28 -0
- package/src/tools/media/ImageViewer/utils/index.ts +4 -0
- package/src/tools/media/VideoPlayer/VideoPlayer.tsx +34 -40
- package/src/tools/media/VideoPlayer/canvas/youtube-canvas.tsx +13 -13
- package/src/tools/media/VideoPlayer/parts/center-play.tsx +26 -0
- package/src/tools/media/VideoPlayer/parts/index.ts +2 -0
- package/src/tools/media/VideoPlayer/styles/video-player.css +102 -53
- package/src/tools/visual/charts/SmoothLine/README.md +97 -0
- package/src/tools/visual/charts/SmoothLine/SmoothLine.tsx +177 -0
- package/src/tools/visual/charts/SmoothLine/index.ts +21 -0
- package/src/tools/visual/charts/SmoothLine/lazy.tsx +23 -0
- package/src/tools/visual/charts/SmoothLine/smoothPath.ts +209 -0
- package/src/tools/visual/charts/SmoothLine/types.ts +98 -0
- package/src/tools/visual/charts/SmoothLine/useSmoothSeries.ts +429 -0
- package/src/tools/visual/indicators/Fps/types.ts +1 -1
- package/src/tools/chat/messages/blocks/renderers/LottieBlock.tsx +0 -11
- package/src/tools/dev/Map/components/MapContainer.tsx +0 -198
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { cn } from '@djangocfg/ui-core/lib'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Controls overlaid on the map. The map tiles are always a light basemap,
|
|
5
|
+
* but per ui-core's style guide we use semantic tokens (never raw color
|
|
6
|
+
* scales). `popover` is the floating-chrome surface — the same token
|
|
7
|
+
* tooltips/menus use — so these chips read like maplibre's own controls
|
|
8
|
+
* and stay legible over the map in both light and dark themes.
|
|
9
|
+
*/
|
|
10
|
+
export const MAP_CONTROL_CLASS = cn(
|
|
11
|
+
'inline-flex items-center justify-center rounded-lg',
|
|
12
|
+
'bg-popover/95 text-popover-foreground backdrop-blur-sm shadow-sm border border-border',
|
|
13
|
+
'hover:bg-popover transition-colors',
|
|
14
|
+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
|
|
15
|
+
)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef } from 'react'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Auto-reset timer. After `autoResetDelay` ms of inactivity the map eases back
|
|
7
|
+
* to its initial viewport. Interaction (move-start) cancels any pending reset;
|
|
8
|
+
* move-end (re)arms the timer when the feature is enabled.
|
|
9
|
+
*/
|
|
10
|
+
export function useAutoReset({
|
|
11
|
+
autoResetDelay,
|
|
12
|
+
resetToInitial,
|
|
13
|
+
}: {
|
|
14
|
+
autoResetDelay: number
|
|
15
|
+
resetToInitial: () => void
|
|
16
|
+
}) {
|
|
17
|
+
const resetTimerRef = useRef<NodeJS.Timeout | null>(null)
|
|
18
|
+
const isInteractingRef = useRef(false)
|
|
19
|
+
|
|
20
|
+
const handleMoveStart = useCallback(() => {
|
|
21
|
+
isInteractingRef.current = true
|
|
22
|
+
// Clear any pending reset timer
|
|
23
|
+
if (resetTimerRef.current) {
|
|
24
|
+
clearTimeout(resetTimerRef.current)
|
|
25
|
+
resetTimerRef.current = null
|
|
26
|
+
}
|
|
27
|
+
}, [])
|
|
28
|
+
|
|
29
|
+
const handleMoveEnd = useCallback(() => {
|
|
30
|
+
isInteractingRef.current = false
|
|
31
|
+
// Start auto-reset timer if enabled
|
|
32
|
+
if (autoResetDelay > 0) {
|
|
33
|
+
resetTimerRef.current = setTimeout(() => {
|
|
34
|
+
if (!isInteractingRef.current) {
|
|
35
|
+
resetToInitial()
|
|
36
|
+
}
|
|
37
|
+
}, autoResetDelay)
|
|
38
|
+
}
|
|
39
|
+
}, [autoResetDelay, resetToInitial])
|
|
40
|
+
|
|
41
|
+
// Cleanup timer on unmount
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
return () => {
|
|
44
|
+
if (resetTimerRef.current) {
|
|
45
|
+
clearTimeout(resetTimerRef.current)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}, [])
|
|
49
|
+
|
|
50
|
+
return { handleMoveStart, handleMoveEnd }
|
|
51
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
4
|
+
|
|
5
|
+
import { MAP_STYLES, resolveBasemapOptions } from '../../../styles'
|
|
6
|
+
import type { MapStyleKey } from '../../../types'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Active basemap state. Seeded from `mapStyle`, then driven by the switcher.
|
|
10
|
+
* If `mapStyle` is a built-in KEY we track it as the active key; a raw URL is
|
|
11
|
+
* kept as-is (the switcher still works, just starts "unselected").
|
|
12
|
+
*/
|
|
13
|
+
export function useBasemapState({
|
|
14
|
+
mapStyle,
|
|
15
|
+
basemaps,
|
|
16
|
+
onBasemapChange,
|
|
17
|
+
}: {
|
|
18
|
+
mapStyle: MapStyleKey | string
|
|
19
|
+
basemaps: MapStyleKey[] | undefined
|
|
20
|
+
onBasemapChange: ((key: MapStyleKey) => void) | undefined
|
|
21
|
+
}) {
|
|
22
|
+
const initialKey = (mapStyle in MAP_STYLES ? mapStyle : undefined) as MapStyleKey | undefined
|
|
23
|
+
const [activeKey, setActiveKey] = useState<MapStyleKey | undefined>(initialKey)
|
|
24
|
+
// Follow controlled `mapStyle` changes from the host.
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
setActiveKey(mapStyle in MAP_STYLES ? (mapStyle as MapStyleKey) : undefined)
|
|
27
|
+
}, [mapStyle])
|
|
28
|
+
// The style actually rendered: the chosen key's URL, else the raw prop.
|
|
29
|
+
const effectiveStyle = activeKey ?? mapStyle
|
|
30
|
+
const basemapOptions = useMemo(
|
|
31
|
+
() => resolveBasemapOptions(basemaps, activeKey ?? (mapStyle as MapStyleKey | string)),
|
|
32
|
+
[basemaps, activeKey, mapStyle],
|
|
33
|
+
)
|
|
34
|
+
const handleBasemapPick = useCallback(
|
|
35
|
+
(key: string) => {
|
|
36
|
+
setActiveKey(key as MapStyleKey)
|
|
37
|
+
onBasemapChange?.(key as MapStyleKey)
|
|
38
|
+
},
|
|
39
|
+
[onBasemapChange],
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
// Resolve the EFFECTIVE style (switcher-aware) to a URL/object.
|
|
43
|
+
const resolvedStyle = effectiveStyle in MAP_STYLES
|
|
44
|
+
? MAP_STYLES[effectiveStyle as MapStyleKey]
|
|
45
|
+
: effectiveStyle
|
|
46
|
+
|
|
47
|
+
return { activeKey, effectiveStyle, resolvedStyle, basemapOptions, handleBasemapPick }
|
|
48
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import type { MapViewport } from '../../../types'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* External-maps link. A host-supplied `openInMapsUrl` wins (Apple Maps, a
|
|
7
|
+
* custom deep link); otherwise deep-link to Google Maps at the current center
|
|
8
|
+
* so the button always works without configuration.
|
|
9
|
+
*/
|
|
10
|
+
export function useExternalMapsUrl({
|
|
11
|
+
openInMapsUrl,
|
|
12
|
+
openInMapsLabel,
|
|
13
|
+
googleMapsButton,
|
|
14
|
+
viewport,
|
|
15
|
+
}: {
|
|
16
|
+
openInMapsUrl: string | undefined
|
|
17
|
+
openInMapsLabel: string
|
|
18
|
+
googleMapsButton: boolean
|
|
19
|
+
viewport: MapViewport
|
|
20
|
+
}) {
|
|
21
|
+
const externalMapsUrl =
|
|
22
|
+
openInMapsUrl ??
|
|
23
|
+
(googleMapsButton
|
|
24
|
+
? `https://www.google.com/maps/search/?api=1&query=${viewport.latitude},${viewport.longitude}`
|
|
25
|
+
: undefined)
|
|
26
|
+
const externalMapsLabel = openInMapsUrl ? openInMapsLabel : 'Open in Google Maps'
|
|
27
|
+
|
|
28
|
+
return { externalMapsUrl, externalMapsLabel }
|
|
29
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { MapProvider } from '../../context'
|
|
4
|
+
import { MapInner } from './MapInner'
|
|
5
|
+
import type { MapContainerProps } from './types'
|
|
6
|
+
|
|
7
|
+
export type { MapContainerProps, GeolocateOptions, GeolocatePosition } from './types'
|
|
8
|
+
|
|
9
|
+
export function MapContainer({
|
|
10
|
+
children,
|
|
11
|
+
initialViewport,
|
|
12
|
+
className,
|
|
13
|
+
...props
|
|
14
|
+
}: MapContainerProps) {
|
|
15
|
+
return (
|
|
16
|
+
<div className={className} style={{ width: '100%', height: '100%', position: 'relative' }}>
|
|
17
|
+
<MapProvider initialViewport={initialViewport}>
|
|
18
|
+
<MapInner {...props} MapContainerComponent={MapContainer}>{children}</MapInner>
|
|
19
|
+
</MapProvider>
|
|
20
|
+
</div>
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Use this when you need the map inside an existing MapProvider
|
|
26
|
+
*/
|
|
27
|
+
export function MapView(props: Omit<MapContainerProps, 'initialViewport'>) {
|
|
28
|
+
return <MapInner {...props} MapContainerComponent={MapContainer} />
|
|
29
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
import type { MapViewport, MapStyleKey } from '../../types'
|
|
4
|
+
import type { GeocodeResult, ResolveGeocode } from '../../geocode'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Tunables forwarded to the maplibre `GeolocateControl` that backs the
|
|
8
|
+
* locate-me chip. All optional; sensible privacy-friendly defaults.
|
|
9
|
+
*/
|
|
10
|
+
export interface GeolocateOptions {
|
|
11
|
+
/**
|
|
12
|
+
* Use the device's high-accuracy provider (GPS). More precise but slower
|
|
13
|
+
* and battery-hungry — off by default.
|
|
14
|
+
* @default false
|
|
15
|
+
*/
|
|
16
|
+
highAccuracy?: boolean
|
|
17
|
+
/**
|
|
18
|
+
* Show a heading arrow on the location dot when a compass heading is
|
|
19
|
+
* available. (No-op on maplibre versions without heading support.)
|
|
20
|
+
* @default true
|
|
21
|
+
*/
|
|
22
|
+
showHeading?: boolean
|
|
23
|
+
/**
|
|
24
|
+
* Draw the translucent accuracy circle (95% confidence) around the dot.
|
|
25
|
+
* @default true
|
|
26
|
+
*/
|
|
27
|
+
showAccuracyCircle?: boolean
|
|
28
|
+
/**
|
|
29
|
+
* Cap how far the camera zooms in when easing to the user (very accurate
|
|
30
|
+
* fixes can otherwise zoom street-level).
|
|
31
|
+
*/
|
|
32
|
+
fitBoundsMaxZoom?: number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A single successful position fix surfaced to `onGeolocate`. */
|
|
36
|
+
export interface GeolocatePosition {
|
|
37
|
+
lat: number
|
|
38
|
+
lng: number
|
|
39
|
+
/** Accuracy radius in meters. */
|
|
40
|
+
accuracy: number
|
|
41
|
+
/** Compass heading in degrees (0 = north), or null when unknown. */
|
|
42
|
+
heading: number | null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface MapContainerProps {
|
|
46
|
+
children?: ReactNode
|
|
47
|
+
initialViewport?: Partial<MapViewport>
|
|
48
|
+
mapStyle?: MapStyleKey | string
|
|
49
|
+
interactiveLayerIds?: string[]
|
|
50
|
+
className?: string
|
|
51
|
+
style?: React.CSSProperties
|
|
52
|
+
cursor?: string
|
|
53
|
+
/**
|
|
54
|
+
* Show the CARTO / OpenStreetMap attribution pill. Off by default for a
|
|
55
|
+
* clean embedded look. Note: CARTO & OSM licenses require attribution to
|
|
56
|
+
* appear somewhere — if you hide it here, surface it elsewhere in your UI.
|
|
57
|
+
* @default false
|
|
58
|
+
*/
|
|
59
|
+
attributionControl?: boolean
|
|
60
|
+
/**
|
|
61
|
+
* Pool and reuse maplibre instances across mounts. Off by default — a
|
|
62
|
+
* reused instance can come back style-less (blank map) under Storybook
|
|
63
|
+
* HMR / story switches / chat lists. Enable only for a single, stable,
|
|
64
|
+
* long-lived full-page map.
|
|
65
|
+
* @default false
|
|
66
|
+
*/
|
|
67
|
+
reuseMaps?: boolean
|
|
68
|
+
/**
|
|
69
|
+
* URL to open in an external maps app. When omitted, a Google Maps URL
|
|
70
|
+
* is generated from the current center (see `googleMapsButton`). Pass
|
|
71
|
+
* this to point at Apple Maps / a custom deep link instead.
|
|
72
|
+
*/
|
|
73
|
+
openInMapsUrl?: string
|
|
74
|
+
/** Label for the open in maps button */
|
|
75
|
+
openInMapsLabel?: string
|
|
76
|
+
/**
|
|
77
|
+
* Show an "Open in Google Maps" button that deep-links to the current
|
|
78
|
+
* center. Suppressed when `openInMapsUrl` is provided (the host link
|
|
79
|
+
* takes over). Set `false` to hide it.
|
|
80
|
+
* @default true
|
|
81
|
+
*/
|
|
82
|
+
googleMapsButton?: boolean
|
|
83
|
+
/** Auto-reset to initial viewport after N ms of inactivity (0 = disabled) */
|
|
84
|
+
autoResetDelay?: number
|
|
85
|
+
/** Show reset button */
|
|
86
|
+
showResetButton?: boolean
|
|
87
|
+
/**
|
|
88
|
+
* Block wheel/trackpad zoom until the user clicks the map (Google Maps
|
|
89
|
+
* style). Stops an inline map from hijacking the page scroll. Drag-pan,
|
|
90
|
+
* touch and double-click zoom stay live. Click outside or move the
|
|
91
|
+
* pointer off the map to re-lock.
|
|
92
|
+
* @default true
|
|
93
|
+
*/
|
|
94
|
+
scrollProtection?: boolean
|
|
95
|
+
/** Hint shown on the lock overlay while scroll-zoom is blocked. */
|
|
96
|
+
scrollProtectionHint?: string
|
|
97
|
+
/**
|
|
98
|
+
* Show a button that opens the map full-screen in a large dialog
|
|
99
|
+
* (Google-Maps-style "expand"). Scroll protection is off inside the
|
|
100
|
+
* dialog since the map already fills the screen.
|
|
101
|
+
* @default true
|
|
102
|
+
*/
|
|
103
|
+
fullscreenButton?: boolean
|
|
104
|
+
/** Accessible / tooltip label for the fullscreen button. */
|
|
105
|
+
fullscreenLabel?: string
|
|
106
|
+
/** Title for the fullscreen dialog (screen readers). */
|
|
107
|
+
fullscreenTitle?: string
|
|
108
|
+
/**
|
|
109
|
+
* Show a basemap switcher (a Layers button → dropdown) in the toolbar,
|
|
110
|
+
* Google-Maps style. Lets the user pick the map style at runtime.
|
|
111
|
+
* @default true
|
|
112
|
+
*/
|
|
113
|
+
basemapSwitcher?: boolean
|
|
114
|
+
/**
|
|
115
|
+
* Explicit list of basemap keys to offer in the switcher. When omitted,
|
|
116
|
+
* the switcher shows all key-free built-ins (CARTO + OpenFreeMap) plus
|
|
117
|
+
* `satellite` only if it's actually usable (host-keyed). See
|
|
118
|
+
* `resolveBasemapOptions`.
|
|
119
|
+
*/
|
|
120
|
+
basemaps?: MapStyleKey[]
|
|
121
|
+
/** Accessible / tooltip label for the basemap switcher button. */
|
|
122
|
+
basemapLabel?: string
|
|
123
|
+
/** Notified when the user picks a basemap from the switcher. */
|
|
124
|
+
onBasemapChange?: (key: MapStyleKey) => void
|
|
125
|
+
/**
|
|
126
|
+
* Render 3D terrain + hillshade from the free AWS Terrarium DEM (no API
|
|
127
|
+
* key). Eases the camera pitch in so it reads as 3D; toggles off cleanly.
|
|
128
|
+
* @default false
|
|
129
|
+
*/
|
|
130
|
+
terrain?: boolean
|
|
131
|
+
/**
|
|
132
|
+
* Show a toolbar chip (a Mountain icon) that toggles 3D terrain at
|
|
133
|
+
* runtime, in the same top-right control column as the other chips.
|
|
134
|
+
* @default true
|
|
135
|
+
*/
|
|
136
|
+
terrainButton?: boolean
|
|
137
|
+
/** Accessible / tooltip label for the terrain toggle button. */
|
|
138
|
+
terrainLabel?: string
|
|
139
|
+
/** Notified when the user toggles terrain from the chip. */
|
|
140
|
+
onTerrainChange?: (enabled: boolean) => void
|
|
141
|
+
/**
|
|
142
|
+
* Show a compass chip that appears only when the map is rotated or
|
|
143
|
+
* tilted (Google-style); its needle reflects the current bearing and a
|
|
144
|
+
* click eases the camera back to a north-up, top-down view. Rotate/tilt
|
|
145
|
+
* the map with right-drag, Ctrl+drag, or two fingers.
|
|
146
|
+
* @default true
|
|
147
|
+
*/
|
|
148
|
+
compassButton?: boolean
|
|
149
|
+
/** Accessible / tooltip label for the compass (reset-north) button. */
|
|
150
|
+
compassLabel?: string
|
|
151
|
+
/**
|
|
152
|
+
* Show a Google-style "locate me" chip that drives maplibre's built-in
|
|
153
|
+
* `GeolocateControl` — centers + follows the user (track mode), with a
|
|
154
|
+
* location dot + accuracy circle. Privacy: **off by default**; nothing
|
|
155
|
+
* requests the OS permission prompt unless this is `true` AND the user
|
|
156
|
+
* presses the chip. Requires a secure context (HTTPS / localhost).
|
|
157
|
+
* @default false
|
|
158
|
+
*/
|
|
159
|
+
geolocate?: boolean
|
|
160
|
+
/** Accessible / tooltip label for the locate-me chip. */
|
|
161
|
+
geolocateLabel?: string
|
|
162
|
+
/** Tunables forwarded to the underlying `GeolocateControl`. */
|
|
163
|
+
geolocateOptions?: GeolocateOptions
|
|
164
|
+
/** Notified on each successful position fix while geolocation is active. */
|
|
165
|
+
onGeolocate?: (pos: GeolocatePosition) => void
|
|
166
|
+
/** Notified when geolocation fails (permission denied / unavailable / timeout). */
|
|
167
|
+
onGeolocateError?: (err: GeolocationPositionError) => void
|
|
168
|
+
/**
|
|
169
|
+
* Show a Google-Maps-style search chip (a magnifier) in the toolbar that
|
|
170
|
+
* toggles an on-map address/place autocomplete. Collapsed by default:
|
|
171
|
+
* clicking the chip reveals an autofocused input (top-left), and clicking
|
|
172
|
+
* it again / pressing Esc / picking a result hides it. Picking a result
|
|
173
|
+
* flies the camera there and drops a pin. **Off by default** (opt-in).
|
|
174
|
+
* @default false
|
|
175
|
+
*/
|
|
176
|
+
geocoder?: boolean
|
|
177
|
+
/**
|
|
178
|
+
* Injectable geocode resolver for the search chip. Defaults to the
|
|
179
|
+
* free, key-free public Photon endpoint. Pass a backend/route proxy in
|
|
180
|
+
* production to dodge CORS + throttling.
|
|
181
|
+
*/
|
|
182
|
+
geocoderResolve?: ResolveGeocode
|
|
183
|
+
/** Placeholder for the search input. */
|
|
184
|
+
geocoderPlaceholder?: string
|
|
185
|
+
/** Notified with the chosen result after the camera flies to it. */
|
|
186
|
+
onGeocoderResult?: (result: GeocodeResult) => void
|
|
187
|
+
/**
|
|
188
|
+
* When set, a close button is rendered as the FIRST chip in the
|
|
189
|
+
* top-right control toolbar (used by the fullscreen dialog so the
|
|
190
|
+
* close / Google / reset controls share one tidy column instead of
|
|
191
|
+
* scattering the dialog's own X into a different corner).
|
|
192
|
+
*/
|
|
193
|
+
onClose?: () => void
|
|
194
|
+
/** Accessible label for the map region (screen readers) */
|
|
195
|
+
ariaLabel?: string
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export interface MapInnerProps extends Omit<MapContainerProps, 'initialViewport'> {}
|
|
@@ -7,13 +7,30 @@ import {
|
|
|
7
7
|
ScaleControl,
|
|
8
8
|
} from 'react-map-gl/maplibre'
|
|
9
9
|
|
|
10
|
+
import type { FitBoundsOptions } from 'maplibre-gl'
|
|
11
|
+
|
|
10
12
|
type ControlPosition = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'
|
|
11
13
|
|
|
12
14
|
export interface MapControlsProps {
|
|
13
15
|
position?: ControlPosition
|
|
14
16
|
showNavigation?: boolean
|
|
15
17
|
showFullscreen?: boolean
|
|
18
|
+
/** Show maplibre's raw `GeolocateControl` (track mode). @default false */
|
|
16
19
|
showGeolocate?: boolean
|
|
20
|
+
/**
|
|
21
|
+
* Draw a heading arrow on the location dot. Forwarded to the control; a
|
|
22
|
+
* no-op on maplibre versions without heading support.
|
|
23
|
+
*/
|
|
24
|
+
showUserHeading?: boolean
|
|
25
|
+
/**
|
|
26
|
+
* Draw the translucent accuracy circle around the dot.
|
|
27
|
+
* @default true (maplibre default)
|
|
28
|
+
*/
|
|
29
|
+
showAccuracyCircle?: boolean
|
|
30
|
+
/** Browser Geolocation API `PositionOptions` (enableHighAccuracy, timeout…). */
|
|
31
|
+
positionOptions?: PositionOptions
|
|
32
|
+
/** Options used when the control eases the camera to the user's location. */
|
|
33
|
+
fitBoundsOptions?: FitBoundsOptions
|
|
17
34
|
showScale?: boolean
|
|
18
35
|
scaleUnit?: 'imperial' | 'metric' | 'nautical'
|
|
19
36
|
}
|
|
@@ -23,6 +40,10 @@ export function MapControls({
|
|
|
23
40
|
showNavigation = true,
|
|
24
41
|
showFullscreen = false,
|
|
25
42
|
showGeolocate = false,
|
|
43
|
+
showUserHeading,
|
|
44
|
+
showAccuracyCircle,
|
|
45
|
+
positionOptions,
|
|
46
|
+
fitBoundsOptions,
|
|
26
47
|
showScale = false,
|
|
27
48
|
scaleUnit = 'metric',
|
|
28
49
|
}: MapControlsProps) {
|
|
@@ -36,6 +57,12 @@ export function MapControls({
|
|
|
36
57
|
<GeolocateControl
|
|
37
58
|
position={position}
|
|
38
59
|
trackUserLocation
|
|
60
|
+
// All optional — undefined falls back to maplibre's own defaults,
|
|
61
|
+
// so existing `showGeolocate` callers are unaffected.
|
|
62
|
+
{...(showUserHeading !== undefined ? { showUserHeading } : {})}
|
|
63
|
+
{...(showAccuracyCircle !== undefined ? { showAccuracyCircle } : {})}
|
|
64
|
+
{...(positionOptions !== undefined ? { positionOptions } : {})}
|
|
65
|
+
{...(fitBoundsOptions !== undefined ? { fitBoundsOptions } : {})}
|
|
39
66
|
/>
|
|
40
67
|
)}
|
|
41
68
|
{showScale && <ScaleControl unit={scaleUnit} />}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { memo, useCallback, type ReactNode } from 'react'
|
|
4
|
+
import { MapMarker } from './MapMarker'
|
|
5
|
+
import { MapPopup, type MapPopupProps } from './MapPopup'
|
|
6
|
+
import { MarkerCard } from './MarkerCard'
|
|
7
|
+
import { useMarkerCard } from '../hooks/useMarkerCard'
|
|
8
|
+
import type { MarkerData } from '../types'
|
|
9
|
+
import type { MarkerCard as MarkerCardType } from '../cards/types'
|
|
10
|
+
|
|
11
|
+
/** A marker plus its optional info-card data. */
|
|
12
|
+
export type MarkerWithCard = MarkerData & { card?: MarkerCardType }
|
|
13
|
+
|
|
14
|
+
export interface MapMarkersProps {
|
|
15
|
+
/** Markers to render; any with a `card` (or when `renderCard` is set) opens a popup on click. */
|
|
16
|
+
markers: MarkerWithCard[]
|
|
17
|
+
/**
|
|
18
|
+
* Custom card content. When provided it overrides the default `MarkerCard`
|
|
19
|
+
* for the open marker. `close` dismisses the popup.
|
|
20
|
+
*/
|
|
21
|
+
renderCard?: (marker: MarkerWithCard, close: () => void) => ReactNode
|
|
22
|
+
/** Popup anchor — default `'bottom'` so the card sits ABOVE the pin. */
|
|
23
|
+
cardAnchor?: MapPopupProps['anchor']
|
|
24
|
+
/** Notified on every marker click (in addition to opening its card). */
|
|
25
|
+
onMarkerClick?: (marker: MarkerWithCard) => void
|
|
26
|
+
/** Default-pin color (ignored when a marker supplies `children`-style icon — N/A here). */
|
|
27
|
+
color?: string
|
|
28
|
+
/** Default-pin size in px. */
|
|
29
|
+
size?: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* MapMarkers — the "just works" marker layer. Renders one `MapMarker` per
|
|
34
|
+
* entry and, on click, opens a `MapPopup` ANCHORED ABOVE the pin showing the
|
|
35
|
+
* marker's `card` (default `MarkerCard`) or `renderCard` output. Selection is
|
|
36
|
+
* driven by `useMarkerCard`, so only ONE card is open at a time and clicking
|
|
37
|
+
* the same pin toggles it. Outside-click / Esc / the popup close button all
|
|
38
|
+
* dismiss. Must live inside a `<MapProvider>` (a child of MapContainer/MapView,
|
|
39
|
+
* like `MapLegend`).
|
|
40
|
+
*/
|
|
41
|
+
export const MapMarkers = memo(function MapMarkers({
|
|
42
|
+
markers,
|
|
43
|
+
renderCard,
|
|
44
|
+
cardAnchor = 'bottom',
|
|
45
|
+
onMarkerClick,
|
|
46
|
+
color,
|
|
47
|
+
size,
|
|
48
|
+
}: MapMarkersProps) {
|
|
49
|
+
const { openId, toggle, close } = useMarkerCard()
|
|
50
|
+
|
|
51
|
+
const handleClick = useCallback(
|
|
52
|
+
(marker: MarkerData) => {
|
|
53
|
+
toggle(marker.id)
|
|
54
|
+
onMarkerClick?.(marker as MarkerWithCard)
|
|
55
|
+
},
|
|
56
|
+
[toggle, onMarkerClick]
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
const openMarker = openId ? markers.find((m) => m.id === openId) : undefined
|
|
60
|
+
const hasCard = openMarker && (Boolean(openMarker.card) || Boolean(renderCard))
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<>
|
|
64
|
+
{markers.map((marker) => (
|
|
65
|
+
<MapMarker
|
|
66
|
+
key={marker.id}
|
|
67
|
+
marker={marker}
|
|
68
|
+
onClick={handleClick}
|
|
69
|
+
color={color}
|
|
70
|
+
size={size}
|
|
71
|
+
ariaLabel={
|
|
72
|
+
(marker.card?.title as string | undefined) ??
|
|
73
|
+
(marker.data?.label as string | undefined) ??
|
|
74
|
+
`Map marker ${marker.id}`
|
|
75
|
+
}
|
|
76
|
+
/>
|
|
77
|
+
))}
|
|
78
|
+
|
|
79
|
+
{openMarker && hasCard && (
|
|
80
|
+
<MapPopup
|
|
81
|
+
longitude={openMarker.longitude}
|
|
82
|
+
latitude={openMarker.latitude}
|
|
83
|
+
anchor={cardAnchor}
|
|
84
|
+
onClose={close}
|
|
85
|
+
maxWidth="none"
|
|
86
|
+
>
|
|
87
|
+
{renderCard
|
|
88
|
+
? renderCard(openMarker, close)
|
|
89
|
+
: openMarker.card && <MarkerCard {...openMarker.card} />}
|
|
90
|
+
</MapPopup>
|
|
91
|
+
)}
|
|
92
|
+
</>
|
|
93
|
+
)
|
|
94
|
+
})
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { memo } from 'react'
|
|
4
|
+
import { Button, ButtonLink } from '@djangocfg/ui-core/components'
|
|
5
|
+
import { cn } from '@djangocfg/ui-core/lib'
|
|
6
|
+
import type { MarkerCard as MarkerCardData, MarkerCardAction } from '../cards/types'
|
|
7
|
+
|
|
8
|
+
export interface MarkerCardProps extends MarkerCardData {
|
|
9
|
+
/** Extra classes on the card root. */
|
|
10
|
+
className?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const VARIANT_MAP = {
|
|
14
|
+
default: 'default',
|
|
15
|
+
outline: 'outline',
|
|
16
|
+
ghost: 'ghost',
|
|
17
|
+
} as const
|
|
18
|
+
|
|
19
|
+
function CardAction({ action }: { action: MarkerCardAction }) {
|
|
20
|
+
const variant = VARIANT_MAP[action.variant ?? 'outline']
|
|
21
|
+
|
|
22
|
+
if (action.href) {
|
|
23
|
+
return (
|
|
24
|
+
<ButtonLink
|
|
25
|
+
href={action.href}
|
|
26
|
+
size="sm"
|
|
27
|
+
variant={variant}
|
|
28
|
+
elevated={false}
|
|
29
|
+
target="_blank"
|
|
30
|
+
rel="noopener noreferrer"
|
|
31
|
+
>
|
|
32
|
+
{action.icon}
|
|
33
|
+
{action.label}
|
|
34
|
+
</ButtonLink>
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<Button size="sm" variant={variant} elevated={false} onClick={action.onClick}>
|
|
40
|
+
{action.icon}
|
|
41
|
+
{action.label}
|
|
42
|
+
</Button>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* MarkerCard — the DEFAULT presentational info-card for a map marker
|
|
48
|
+
* (Google-Maps-desktop style). Renders from plain data: an optional image
|
|
49
|
+
* header, a badge pill, a title, a clamped description, and an action row of
|
|
50
|
+
* links/buttons. Standalone-usable (no map dependency) and built on semantic
|
|
51
|
+
* tokens only. `MapMarkers` renders this inside a `MapPopup` above the pin.
|
|
52
|
+
*/
|
|
53
|
+
export const MarkerCard = memo(function MarkerCard({
|
|
54
|
+
title,
|
|
55
|
+
description,
|
|
56
|
+
image,
|
|
57
|
+
badge,
|
|
58
|
+
actions,
|
|
59
|
+
className,
|
|
60
|
+
}: MarkerCardProps) {
|
|
61
|
+
return (
|
|
62
|
+
<div
|
|
63
|
+
className={cn(
|
|
64
|
+
'flex max-w-[260px] flex-col overflow-hidden rounded-lg border border-border bg-card text-card-foreground shadow-md',
|
|
65
|
+
className
|
|
66
|
+
)}
|
|
67
|
+
>
|
|
68
|
+
{image && (
|
|
69
|
+
<img
|
|
70
|
+
src={image}
|
|
71
|
+
alt={title}
|
|
72
|
+
className="h-28 w-full object-cover"
|
|
73
|
+
loading="lazy"
|
|
74
|
+
/>
|
|
75
|
+
)}
|
|
76
|
+
|
|
77
|
+
<div className="flex flex-col gap-1.5 p-3">
|
|
78
|
+
{badge && (
|
|
79
|
+
<span className="inline-flex w-fit items-center rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
|
|
80
|
+
{badge}
|
|
81
|
+
</span>
|
|
82
|
+
)}
|
|
83
|
+
|
|
84
|
+
<h3 className="text-sm font-medium leading-snug text-foreground">{title}</h3>
|
|
85
|
+
|
|
86
|
+
{description && (
|
|
87
|
+
<p className="line-clamp-3 text-xs leading-relaxed text-muted-foreground">
|
|
88
|
+
{description}
|
|
89
|
+
</p>
|
|
90
|
+
)}
|
|
91
|
+
|
|
92
|
+
{actions && actions.length > 0 && (
|
|
93
|
+
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
|
94
|
+
{actions.map((action, i) => (
|
|
95
|
+
<CardAction key={`${action.label}-${i}`} action={action} />
|
|
96
|
+
))}
|
|
97
|
+
</div>
|
|
98
|
+
)}
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
)
|
|
102
|
+
})
|