233 lines
7.0 KiB
Vue
233 lines
7.0 KiB
Vue
<template>
|
|
<div class="flex flex-col h-full">
|
|
<!-- Header -->
|
|
<div class="flex-shrink-0 p-4 border-b border-white/10">
|
|
<div class="flex items-center justify-between mb-3">
|
|
<h3 class="font-semibold text-base text-white">{{ title }}</h3>
|
|
<button class="btn btn-ghost btn-xs btn-circle text-white/60 hover:text-white" @click="emit('close')">
|
|
<Icon name="lucide:x" size="16" />
|
|
</button>
|
|
</div>
|
|
<div
|
|
v-if="pinnedItems.length"
|
|
class="input input-sm w-full bg-white/10 border-white/20 text-white/80 flex flex-wrap items-center gap-1 py-1"
|
|
>
|
|
<span
|
|
v-for="item in pinnedItems"
|
|
:key="item.uuid"
|
|
class="badge badge-neutral badge-sm flex items-center gap-1"
|
|
>
|
|
{{ item.name }}
|
|
<button class="text-white/70 hover:text-white" @click.stop="togglePin(item)">
|
|
<Icon name="lucide:x" size="12" />
|
|
</button>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Content (scrollable) -->
|
|
<div class="flex-1 overflow-y-auto p-4">
|
|
<div v-if="loading" class="flex items-center justify-center py-8">
|
|
<span class="loading loading-spinner loading-md text-white" />
|
|
</div>
|
|
|
|
<div v-else-if="displayItems.length === 0" class="text-center py-8 text-white/60">
|
|
<Icon name="lucide:search-x" size="32" class="mb-2" />
|
|
<p>{{ $t('catalog.empty.noResults') }}</p>
|
|
</div>
|
|
|
|
<div v-else class="flex flex-col gap-2">
|
|
<!-- Products -->
|
|
<template v-if="selectMode === 'product'">
|
|
<div
|
|
v-for="(item, index) in displayItems"
|
|
:key="item.uuid ?? index"
|
|
class="relative group"
|
|
@mouseenter="emit('hover', item.uuid ?? null)"
|
|
@mouseleave="emit('hover', null)"
|
|
>
|
|
<button
|
|
v-if="item.uuid"
|
|
class="absolute top-2 right-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity bg-black/40 hover:bg-black/60 text-white rounded-full w-6 h-6 flex items-center justify-center"
|
|
@click.stop="togglePin(item)"
|
|
>
|
|
<Icon :name="isPinned(item.uuid) ? 'lucide:pin-off' : 'lucide:pin'" size="12" />
|
|
</button>
|
|
<ProductCard
|
|
:product="item"
|
|
selectable
|
|
compact
|
|
@select="onSelect(item)"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Hubs -->
|
|
<template v-else-if="selectMode === 'hub'">
|
|
<div
|
|
v-for="(item, index) in displayItems"
|
|
:key="item.uuid ?? index"
|
|
class="relative group"
|
|
@mouseenter="emit('hover', item.uuid ?? null)"
|
|
@mouseleave="emit('hover', null)"
|
|
>
|
|
<button
|
|
v-if="item.uuid"
|
|
class="absolute top-2 right-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity bg-black/40 hover:bg-black/60 text-white rounded-full w-6 h-6 flex items-center justify-center"
|
|
@click.stop="togglePin(item)"
|
|
>
|
|
<Icon :name="isPinned(item.uuid) ? 'lucide:pin-off' : 'lucide:pin'" size="12" />
|
|
</button>
|
|
<HubCard
|
|
:hub="item"
|
|
selectable
|
|
@select="onSelect(item)"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Suppliers -->
|
|
<template v-else-if="selectMode === 'supplier'">
|
|
<div
|
|
v-for="(item, index) in displayItems"
|
|
:key="item.uuid ?? index"
|
|
class="relative group"
|
|
@mouseenter="emit('hover', item.uuid ?? null)"
|
|
@mouseleave="emit('hover', null)"
|
|
>
|
|
<button
|
|
v-if="item.uuid"
|
|
class="absolute top-2 right-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity bg-black/40 hover:bg-black/60 text-white rounded-full w-6 h-6 flex items-center justify-center"
|
|
@click.stop="togglePin(item)"
|
|
>
|
|
<Icon :name="isPinned(item.uuid) ? 'lucide:pin-off' : 'lucide:pin'" size="12" />
|
|
</button>
|
|
<SupplierCard
|
|
:supplier="item"
|
|
selectable
|
|
@select="onSelect(item)"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Infinite scroll sentinel -->
|
|
<div
|
|
v-if="hasMore"
|
|
ref="loadMoreSentinel"
|
|
class="flex items-center justify-center py-4"
|
|
>
|
|
<span v-if="loadingMore" class="loading loading-spinner loading-sm text-white/60" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import type { SelectMode } from '~/composables/useCatalogSearch'
|
|
|
|
interface Item {
|
|
uuid?: string | null
|
|
name?: string | null
|
|
country?: string | null
|
|
}
|
|
|
|
const props = defineProps<{
|
|
selectMode: SelectMode
|
|
products?: Item[]
|
|
hubs?: Item[]
|
|
suppliers?: Item[]
|
|
loading?: boolean
|
|
loadingMore?: boolean
|
|
hasMore?: boolean
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
'select': [type: string, item: Item]
|
|
'close': []
|
|
'load-more': []
|
|
'hover': [uuid: string | null]
|
|
}>()
|
|
|
|
const { t } = useI18n()
|
|
|
|
const loadMoreSentinel = ref<HTMLElement | null>(null)
|
|
const pinnedIds = ref<string[]>([])
|
|
|
|
// Infinite scroll using IntersectionObserver
|
|
let observer: IntersectionObserver | null = null
|
|
|
|
onMounted(() => {
|
|
observer = new IntersectionObserver(
|
|
(entries) => {
|
|
const entry = entries[0]
|
|
if (entry?.isIntersecting && props.hasMore && !props.loadingMore) {
|
|
emit('load-more')
|
|
}
|
|
},
|
|
{ threshold: 0.1 }
|
|
)
|
|
})
|
|
|
|
watch(loadMoreSentinel, (el) => {
|
|
if (el && observer) {
|
|
observer.observe(el)
|
|
}
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
if (observer) {
|
|
observer.disconnect()
|
|
observer = null
|
|
}
|
|
})
|
|
|
|
const title = computed(() => {
|
|
switch (props.selectMode) {
|
|
case 'product': return t('catalog.headers.selectProduct')
|
|
case 'hub': return t('catalog.headers.selectHub')
|
|
case 'supplier': return t('catalog.headers.selectSupplier')
|
|
default: return ''
|
|
}
|
|
})
|
|
|
|
const items = computed(() => {
|
|
switch (props.selectMode) {
|
|
case 'product': return props.products || []
|
|
case 'hub': return props.hubs || []
|
|
case 'supplier': return props.suppliers || []
|
|
default: return []
|
|
}
|
|
})
|
|
|
|
const isPinned = (uuid: string) => pinnedIds.value.includes(uuid)
|
|
|
|
const togglePin = (item: Item) => {
|
|
if (!item.uuid) return
|
|
if (isPinned(item.uuid)) {
|
|
pinnedIds.value = pinnedIds.value.filter(id => id !== item.uuid)
|
|
} else {
|
|
pinnedIds.value = [...pinnedIds.value, item.uuid]
|
|
}
|
|
}
|
|
|
|
const pinnedItems = computed(() =>
|
|
items.value.filter(item => item.uuid && isPinned(item.uuid))
|
|
)
|
|
|
|
const displayItems = computed(() => {
|
|
if (pinnedIds.value.length === 0) return items.value
|
|
const pinned = pinnedItems.value
|
|
const pinnedSet = new Set(pinned.map(p => p.uuid).filter(Boolean) as string[])
|
|
const rest = items.value.filter(item => !item.uuid || !pinnedSet.has(item.uuid))
|
|
return [...pinned, ...rest]
|
|
})
|
|
|
|
// Select item and emit
|
|
const onSelect = (item: Item) => {
|
|
if (props.selectMode && item.uuid) {
|
|
emit('select', props.selectMode, item)
|
|
}
|
|
}
|
|
</script>
|