All checks were successful
Build Docker Image / build (push) Successful in 4m4s
- Replace v-show with transform: translateY() for smooth header collapse animation - Wrap MainNav + SubNav in fixed container with dynamic transform - Remove sticky positioning from MainNavigation and SubNavigation - Fix map to extend to screen edge (right-0, no rounded corners) - Add dynamic padding-top to main for fixed header compensation
355 lines
12 KiB
Vue
355 lines
12 KiB
Vue
<template>
|
|
<div class="flex flex-col flex-1 min-h-0">
|
|
<!-- Loading state -->
|
|
<div v-if="loading" class="flex-1 flex items-center justify-center">
|
|
<Card padding="lg">
|
|
<Stack align="center" justify="center" gap="3">
|
|
<Spinner />
|
|
<Text tone="muted">{{ $t('catalogLanding.states.loading') }}</Text>
|
|
</Stack>
|
|
</Card>
|
|
</div>
|
|
|
|
<!-- Content -->
|
|
<template v-else>
|
|
<!-- Search bar slot (sticky third bar - like navigation) -->
|
|
<div v-if="$slots.searchBar" class="sticky z-20 h-16 -mx-3 lg:-mx-6 px-3 lg:px-6 bg-base-100 border-b border-base-300" :style="searchBarStyle">
|
|
<div class="flex items-center gap-2 h-full">
|
|
<!-- Expand button - appears when header collapsed -->
|
|
<button
|
|
v-if="isCollapsed"
|
|
class="btn btn-ghost btn-sm gap-1 flex-shrink-0"
|
|
@click="expand"
|
|
>
|
|
<span class="font-bold text-primary text-lg">O</span>
|
|
<Icon name="lucide:chevron-up" size="14" />
|
|
</button>
|
|
<div class="flex-1">
|
|
<slot name="searchBar" :displayed-count="displayItems.length" :total-count="totalCount" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- With Map: Split Layout -->
|
|
<template v-if="withMap">
|
|
<!-- Desktop: side-by-side -->
|
|
<div class="hidden lg:flex flex-1 gap-4 min-h-0 py-4">
|
|
<!-- Left: List (scrollable) -->
|
|
<div class="w-2/5 overflow-y-auto pr-2">
|
|
<Stack gap="4">
|
|
<slot name="header" />
|
|
<slot name="filters" />
|
|
|
|
<Stack gap="3">
|
|
<div
|
|
v-for="item in displayItems"
|
|
:key="item.uuid"
|
|
:class="{ 'ring-2 ring-primary rounded-lg': item.uuid === selectedId }"
|
|
@click="onItemClick(item)"
|
|
@mouseenter="emit('update:hoveredId', item.uuid)"
|
|
@mouseleave="emit('update:hoveredId', undefined)"
|
|
>
|
|
<slot name="card" :item="item" />
|
|
</div>
|
|
</Stack>
|
|
|
|
<slot name="pagination" />
|
|
|
|
<Stack v-if="displayItems.length === 0" align="center" gap="2">
|
|
<slot name="empty">
|
|
<Text tone="muted">{{ $t('common.values.not_available') }}</Text>
|
|
</slot>
|
|
</Stack>
|
|
</Stack>
|
|
</div>
|
|
|
|
<!-- Right: Map (fixed position) -->
|
|
<div class="w-3/5 relative">
|
|
<div class="fixed right-0 w-3/5 overflow-hidden" :style="mapStyle">
|
|
<!-- Search with map checkbox -->
|
|
<label class="absolute top-4 left-4 z-10 bg-white/90 backdrop-blur px-3 py-2 rounded-lg shadow flex items-center gap-2 cursor-pointer">
|
|
<input type="checkbox" v-model="searchWithMap" class="checkbox checkbox-sm" />
|
|
<span class="text-sm">{{ $t('catalogMap.searchWithMap') }}</span>
|
|
</label>
|
|
<ClientOnly>
|
|
<CatalogMap
|
|
ref="mapRef"
|
|
:map-id="mapId"
|
|
:items="useServerClustering ? [] : itemsWithCoords"
|
|
:clustered-points="useServerClustering ? clusteredNodes : []"
|
|
:use-server-clustering="useServerClustering"
|
|
:point-color="pointColor"
|
|
:hovered-item-id="hoveredId"
|
|
:hovered-item="hoveredItem"
|
|
@select-item="onMapSelect"
|
|
@bounds-change="onBoundsChange"
|
|
/>
|
|
</ClientOnly>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Mobile: toggle between list and map -->
|
|
<div class="lg:hidden flex-1 flex flex-col min-h-0">
|
|
<div class="flex-1 overflow-y-auto py-4" v-show="mobileView === 'list'">
|
|
<Stack gap="4">
|
|
<slot name="header" />
|
|
<slot name="filters" />
|
|
|
|
<Stack gap="3">
|
|
<div
|
|
v-for="item in displayItems"
|
|
:key="item.uuid"
|
|
:class="{ 'ring-2 ring-primary rounded-lg': item.uuid === selectedId }"
|
|
@click="onItemClick(item)"
|
|
@mouseenter="emit('update:hoveredId', item.uuid)"
|
|
@mouseleave="emit('update:hoveredId', undefined)"
|
|
>
|
|
<slot name="card" :item="item" />
|
|
</div>
|
|
</Stack>
|
|
|
|
<slot name="pagination" />
|
|
|
|
<Stack v-if="displayItems.length === 0" align="center" gap="2">
|
|
<slot name="empty">
|
|
<Text tone="muted">{{ $t('common.values.not_available') }}</Text>
|
|
</slot>
|
|
</Stack>
|
|
</Stack>
|
|
</div>
|
|
|
|
<div class="flex-1 relative" v-show="mobileView === 'map'">
|
|
<!-- Search with map checkbox (mobile) -->
|
|
<label class="absolute top-4 left-4 z-10 bg-white/90 backdrop-blur px-3 py-2 rounded-lg shadow flex items-center gap-2 cursor-pointer">
|
|
<input type="checkbox" v-model="searchWithMap" class="checkbox checkbox-sm" />
|
|
<span class="text-sm">{{ $t('catalogMap.searchWithMap') }}</span>
|
|
</label>
|
|
<ClientOnly>
|
|
<CatalogMap
|
|
ref="mobileMapRef"
|
|
:map-id="`${mapId}-mobile`"
|
|
:items="useServerClustering ? [] : itemsWithCoords"
|
|
:clustered-points="useServerClustering ? clusteredNodes : []"
|
|
:use-server-clustering="useServerClustering"
|
|
:point-color="pointColor"
|
|
:hovered-item-id="hoveredId"
|
|
:hovered-item="hoveredItem"
|
|
@select-item="onMapSelect"
|
|
@bounds-change="onBoundsChange"
|
|
/>
|
|
</ClientOnly>
|
|
</div>
|
|
|
|
<!-- Mobile toggle -->
|
|
<div class="fixed bottom-4 left-1/2 -translate-x-1/2 z-30">
|
|
<div class="btn-group shadow-lg">
|
|
<button
|
|
class="btn btn-sm"
|
|
:class="{ 'btn-active': mobileView === 'list' }"
|
|
@click="mobileView = 'list'"
|
|
>
|
|
{{ $t('common.list') }}
|
|
</button>
|
|
<button
|
|
class="btn btn-sm"
|
|
:class="{ 'btn-active': mobileView === 'map' }"
|
|
@click="mobileView = 'map'"
|
|
>
|
|
{{ $t('common.map') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Without Map: Simple List -->
|
|
<div v-else class="flex-1 overflow-y-auto py-4">
|
|
<Stack gap="4">
|
|
<slot name="header" />
|
|
<slot name="filters" />
|
|
|
|
<Stack gap="3">
|
|
<div
|
|
v-for="item in items"
|
|
:key="item.uuid"
|
|
@click="onItemClick(item)"
|
|
>
|
|
<slot name="card" :item="item" />
|
|
</div>
|
|
</Stack>
|
|
|
|
<slot name="pagination" />
|
|
|
|
<Stack v-if="items.length === 0" align="center" gap="2">
|
|
<slot name="empty">
|
|
<Text tone="muted">{{ $t('common.values.not_available') }}</Text>
|
|
</slot>
|
|
</Stack>
|
|
</Stack>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import type { MapBounds } from '~/components/catalog/CatalogMap.vue'
|
|
|
|
interface MapItem {
|
|
uuid: string
|
|
latitude?: number | null
|
|
longitude?: number | null
|
|
name?: string
|
|
country?: string
|
|
[key: string]: any
|
|
}
|
|
|
|
const props = withDefaults(defineProps<{
|
|
items: MapItem[]
|
|
mapItems?: MapItem[] // Optional separate items for map (if different from list items)
|
|
loading?: boolean
|
|
withMap?: boolean
|
|
useServerClustering?: boolean // Use server-side h3 clustering for ALL points
|
|
mapId?: string
|
|
pointColor?: string
|
|
selectedId?: string
|
|
hoveredId?: string
|
|
hasSubNav?: boolean
|
|
totalCount?: number // Total count for search bar counter (can differ from items.length with pagination)
|
|
}>(), {
|
|
loading: false,
|
|
withMap: true,
|
|
useServerClustering: false,
|
|
mapId: 'catalog-map',
|
|
pointColor: '#3b82f6',
|
|
hasSubNav: true,
|
|
totalCount: 0
|
|
})
|
|
|
|
// Smooth scroll collapse - pixel values for smooth animation
|
|
// MainNav: 64px, SubNav: 54px, Header total: 118px, SearchBar: 64px
|
|
const { searchBarTop, mapTop, isCollapsed, expand } = useScrollCollapse(118, 64)
|
|
|
|
const slots = useSlots()
|
|
const hasSearchBar = computed(() => !!slots.searchBar)
|
|
|
|
// SearchBar style - smooth pixel positioning
|
|
const searchBarStyle = computed(() => ({
|
|
top: `${searchBarTop.value}px`
|
|
}))
|
|
|
|
// Map style - smooth pixel positioning
|
|
const mapStyle = computed(() => ({
|
|
top: hasSearchBar.value ? `${mapTop.value}px` : `${searchBarTop.value}px`,
|
|
height: `calc(100vh - ${hasSearchBar.value ? mapTop.value : searchBarTop.value}px)`
|
|
}))
|
|
|
|
const emit = defineEmits<{
|
|
'select': [item: MapItem]
|
|
'update:selectedId': [uuid: string]
|
|
'update:hoveredId': [uuid: string | undefined]
|
|
}>()
|
|
|
|
// Server-side clustering
|
|
const { clusteredNodes, fetchClusters } = useClusteredNodes()
|
|
|
|
// Search with map checkbox
|
|
const searchWithMap = ref(false)
|
|
const currentBounds = ref<MapBounds | null>(null)
|
|
|
|
const onBoundsChange = (bounds: MapBounds) => {
|
|
currentBounds.value = bounds
|
|
if (props.useServerClustering) {
|
|
fetchClusters(bounds)
|
|
}
|
|
}
|
|
|
|
// Filtered items when searchWithMap is enabled
|
|
const displayItems = computed(() => {
|
|
if (!searchWithMap.value || !currentBounds.value) return props.items
|
|
return props.items.filter(item => {
|
|
if (item.latitude == null || item.longitude == null) return false
|
|
const { west, east, north, south } = currentBounds.value!
|
|
const lng = Number(item.longitude)
|
|
const lat = Number(item.latitude)
|
|
return lng >= west && lng <= east && lat >= south && lat <= north
|
|
})
|
|
})
|
|
|
|
// Hovered item with coordinates for map highlight
|
|
const hoveredItem = computed(() => {
|
|
if (!props.hoveredId) return null
|
|
const item = props.items.find(i => i.uuid === props.hoveredId)
|
|
if (!item?.latitude || !item?.longitude) return null
|
|
return { latitude: Number(item.latitude), longitude: Number(item.longitude) }
|
|
})
|
|
|
|
// Use mapItems if provided, otherwise fall back to items
|
|
const itemsForMap = computed(() => props.mapItems || props.items)
|
|
|
|
// Filter items with valid coordinates for map (client-side mode only)
|
|
const itemsWithCoords = computed(() =>
|
|
itemsForMap.value.filter(item =>
|
|
item.latitude != null &&
|
|
item.longitude != null &&
|
|
!isNaN(Number(item.latitude)) &&
|
|
!isNaN(Number(item.longitude))
|
|
).map(item => ({
|
|
uuid: item.uuid,
|
|
name: item.name || '',
|
|
latitude: Number(item.latitude),
|
|
longitude: Number(item.longitude),
|
|
country: item.country,
|
|
orderUuid: item.orderUuid // Preserve orderUuid for hover matching
|
|
}))
|
|
)
|
|
|
|
// Mobile view toggle
|
|
const mobileView = ref<'list' | 'map'>('list')
|
|
|
|
// Map refs
|
|
const mapRef = ref<{ flyTo: (lat: number, lng: number, zoom?: number) => void } | null>(null)
|
|
const mobileMapRef = ref<{ flyTo: (lat: number, lng: number, zoom?: number) => void } | null>(null)
|
|
|
|
// Handle item click from list
|
|
const onItemClick = (item: MapItem) => {
|
|
emit('select', item)
|
|
emit('update:selectedId', item.uuid)
|
|
|
|
// Fly to item on map
|
|
if (props.withMap && item.latitude && item.longitude) {
|
|
mapRef.value?.flyTo(Number(item.latitude), Number(item.longitude), 8)
|
|
mobileMapRef.value?.flyTo(Number(item.latitude), Number(item.longitude), 8)
|
|
}
|
|
}
|
|
|
|
// Handle selection from map
|
|
const onMapSelect = (uuid: string) => {
|
|
const item = props.items.find(i => i.uuid === uuid)
|
|
if (item) {
|
|
emit('select', item)
|
|
emit('update:selectedId', uuid)
|
|
}
|
|
}
|
|
|
|
// Watch selectedId and fly to it
|
|
watch(() => props.selectedId, (uuid) => {
|
|
if (uuid && props.withMap) {
|
|
const item = itemsWithCoords.value.find(i => i.uuid === uuid)
|
|
if (item) {
|
|
mapRef.value?.flyTo(item.latitude, item.longitude, 8)
|
|
mobileMapRef.value?.flyTo(item.latitude, item.longitude, 8)
|
|
}
|
|
}
|
|
})
|
|
|
|
|
|
// Expose flyTo for external use
|
|
const flyTo = (lat: number, lng: number, zoom = 8) => {
|
|
mapRef.value?.flyTo(lat, lng, zoom)
|
|
mobileMapRef.value?.flyTo(lat, lng, zoom)
|
|
}
|
|
|
|
defineExpose({ flyTo })
|
|
</script>
|