feat: add useIntersectionObserver, useLazyPagination, useTemplateFiltering, and mediaCacheService for improved component functionality

This commit is contained in:
Johnpaul
2025-07-30 01:15:02 +01:00
parent 0b2d985fbf
commit 6b69225bbf
4 changed files with 444 additions and 0 deletions

View File

@@ -0,0 +1,60 @@
import { type Ref, onBeforeUnmount, ref, watch } from 'vue'
export interface UseIntersectionObserverOptions
extends IntersectionObserverInit {
immediate?: boolean
}
export function useIntersectionObserver(
target: Ref<Element | null>,
callback: IntersectionObserverCallback,
options: UseIntersectionObserverOptions = {}
) {
const { immediate = true, ...observerOptions } = options
const isSupported =
typeof window !== 'undefined' && 'IntersectionObserver' in window
const isIntersecting = ref(false)
let observer: IntersectionObserver | null = null
const cleanup = () => {
if (observer) {
observer.disconnect()
observer = null
}
}
const observe = () => {
cleanup()
if (!isSupported || !target.value) return
observer = new IntersectionObserver((entries) => {
isIntersecting.value = entries.some((entry) => entry.isIntersecting)
callback(entries, observer!)
}, observerOptions)
observer.observe(target.value)
}
const unobserve = () => {
if (observer && target.value) {
observer.unobserve(target.value)
}
}
if (immediate) {
watch(target, observe, { immediate: true, flush: 'post' })
}
onBeforeUnmount(cleanup)
return {
isSupported,
isIntersecting,
observe,
unobserve,
cleanup
}
}

View File

@@ -0,0 +1,107 @@
import { type Ref, computed, ref, shallowRef, watch } from 'vue'
export interface LazyPaginationOptions {
itemsPerPage?: number
initialPage?: number
}
export function useLazyPagination<T>(
items: Ref<T[]> | T[],
options: LazyPaginationOptions = {}
) {
const { itemsPerPage = 12, initialPage = 1 } = options
const currentPage = ref(initialPage)
const isLoading = ref(false)
const loadedPages = shallowRef(new Set<number>([]))
// Get reactive items array
const itemsArray = computed(() => {
const itemData = 'value' in items ? items.value : items
return Array.isArray(itemData) ? itemData : []
})
// Simulate pagination by slicing the items
const paginatedItems = computed(() => {
const itemData = itemsArray.value
if (itemData.length === 0) {
return []
}
const loadedPageNumbers = Array.from(loadedPages.value).sort(
(a, b) => a - b
)
const maxLoadedPage = Math.max(...loadedPageNumbers, 0)
const endIndex = maxLoadedPage * itemsPerPage
return itemData.slice(0, endIndex)
})
const hasMoreItems = computed(() => {
const itemData = itemsArray.value
if (itemData.length === 0) {
return false
}
const loadedPagesArray = Array.from(loadedPages.value)
const maxLoadedPage = Math.max(...loadedPagesArray, 0)
return maxLoadedPage * itemsPerPage < itemData.length
})
const totalPages = computed(() => {
const itemData = itemsArray.value
if (itemData.length === 0) {
return 0
}
return Math.ceil(itemData.length / itemsPerPage)
})
const loadNextPage = async () => {
if (isLoading.value || !hasMoreItems.value) return
isLoading.value = true
const loadedPagesArray = Array.from(loadedPages.value)
const nextPage = Math.max(...loadedPagesArray, 0) + 1
// Simulate network delay
// await new Promise((resolve) => setTimeout(resolve, 5000))
const newLoadedPages = new Set(loadedPages.value)
newLoadedPages.add(nextPage)
loadedPages.value = newLoadedPages
currentPage.value = nextPage
isLoading.value = false
}
// Initialize with first page
watch(
() => itemsArray.value.length,
(length) => {
if (length > 0 && loadedPages.value.size === 0) {
loadedPages.value = new Set([1])
}
},
{ immediate: true }
)
const reset = () => {
currentPage.value = initialPage
loadedPages.value = new Set([])
isLoading.value = false
// Immediately load first page if we have items
const itemData = itemsArray.value
if (itemData.length > 0) {
loadedPages.value = new Set([1])
}
}
return {
paginatedItems,
isLoading,
hasMoreItems,
currentPage,
totalPages,
loadNextPage,
reset
}
}

View File

@@ -0,0 +1,56 @@
import { type Ref, computed, ref } from 'vue'
import type { TemplateInfo } from '@/types/workflowTemplateTypes'
export interface TemplateFilterOptions {
searchQuery?: string
}
export function useTemplateFiltering(
templates: Ref<TemplateInfo[]> | TemplateInfo[]
) {
const searchQuery = ref('')
const templatesArray = computed(() => {
const templateData = 'value' in templates ? templates.value : templates
return Array.isArray(templateData) ? templateData : []
})
const filteredTemplates = computed(() => {
const templateData = templatesArray.value
if (templateData.length === 0) {
return []
}
if (!searchQuery.value.trim()) {
return templateData
}
const query = searchQuery.value.toLowerCase().trim()
return templateData.filter((template) => {
const searchableText = [
template.name,
template.description,
template.sourceModule
]
.filter(Boolean)
.join(' ')
.toLowerCase()
return searchableText.includes(query)
})
})
const resetFilters = () => {
searchQuery.value = ''
}
const filteredCount = computed(() => filteredTemplates.value.length)
return {
searchQuery,
filteredTemplates,
filteredCount,
resetFilters
}
}

View File

@@ -0,0 +1,221 @@
import { shallowRef } from 'vue'
export interface CachedMedia {
src: string
blob?: Blob
objectUrl?: string
error?: boolean
isLoading: boolean
lastAccessed: number
}
export interface MediaCacheOptions {
maxSize?: number
maxAge?: number // in milliseconds
preloadDistance?: number // pixels from viewport
}
class MediaCacheService {
public cache = shallowRef(new Map<string, CachedMedia>())
private readonly maxSize: number
private readonly maxAge: number
private cleanupInterval: number | null = null
constructor(options: MediaCacheOptions = {}) {
this.maxSize = options.maxSize ?? 100
this.maxAge = options.maxAge ?? 30 * 60 * 1000 // 30 minutes
// Start cleanup interval
this.startCleanupInterval()
}
private startCleanupInterval() {
// Clean up every 5 minutes
this.cleanupInterval = window.setInterval(
() => {
this.cleanup()
},
5 * 60 * 1000
)
}
private cleanup() {
const now = Date.now()
const cacheMap = this.cache.value
const keysToDelete: string[] = []
// Find expired entries
for (const [key, entry] of Array.from(cacheMap.entries())) {
if (now - entry.lastAccessed > this.maxAge) {
keysToDelete.push(key)
// Revoke object URL to free memory
if (entry.objectUrl) {
URL.revokeObjectURL(entry.objectUrl)
}
}
}
// Remove expired entries
if (keysToDelete.length > 0) {
const newCache = new Map(cacheMap)
keysToDelete.forEach((key) => newCache.delete(key))
this.cache.value = newCache
}
// If still over size limit, remove oldest entries
if (cacheMap.size > this.maxSize) {
const entries = Array.from(cacheMap.entries())
entries.sort((a, b) => a[1].lastAccessed - b[1].lastAccessed)
const toRemove = entries.slice(0, cacheMap.size - this.maxSize)
const newCache = new Map(cacheMap)
toRemove.forEach(([key, entry]) => {
if (entry.objectUrl) {
URL.revokeObjectURL(entry.objectUrl)
}
newCache.delete(key)
})
this.cache.value = newCache
}
}
async getCachedMedia(src: string): Promise<CachedMedia> {
const cacheMap = this.cache.value
let entry = cacheMap.get(src)
if (entry) {
// Update last accessed time
entry.lastAccessed = Date.now()
return entry
}
// Create new entry
entry = {
src,
isLoading: true,
lastAccessed: Date.now()
}
// Update cache with loading entry
const newCache = new Map(cacheMap)
newCache.set(src, entry)
this.cache.value = newCache
try {
// Fetch the media
const response = await fetch(src)
if (!response.ok) {
throw new Error(`Failed to fetch: ${response.status}`)
}
const blob = await response.blob()
const objectUrl = URL.createObjectURL(blob)
// Update entry with successful result
const updatedEntry: CachedMedia = {
src,
blob,
objectUrl,
isLoading: false,
lastAccessed: Date.now()
}
const finalCache = new Map(this.cache.value)
finalCache.set(src, updatedEntry)
this.cache.value = finalCache
return updatedEntry
} catch (error) {
console.warn('Failed to cache media:', src, error)
// Update entry with error
const errorEntry: CachedMedia = {
src,
error: true,
isLoading: false,
lastAccessed: Date.now()
}
const errorCache = new Map(this.cache.value)
errorCache.set(src, errorEntry)
this.cache.value = errorCache
return errorEntry
}
}
getCacheStats() {
const cacheMap = this.cache.value
return {
size: cacheMap.size,
maxSize: this.maxSize,
entries: Array.from(cacheMap.keys())
}
}
clearCache() {
const cacheMap = this.cache.value
// Revoke all object URLs
for (const entry of Array.from(cacheMap.values())) {
if (entry.objectUrl) {
URL.revokeObjectURL(entry.objectUrl)
}
}
this.cache.value = new Map()
}
preloadMedia(urls: string[]) {
// Preload media in the background without blocking
urls.forEach((url) => {
if (!this.cache.value.has(url)) {
// Don't await - fire and forget
this.getCachedMedia(url).catch(() => {
// Ignore preload errors
})
}
})
}
destroy() {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval)
this.cleanupInterval = null
}
this.clearCache()
}
}
// Global instance
let mediaCacheInstance: MediaCacheService | null = null
export function useMediaCache(options?: MediaCacheOptions) {
if (!mediaCacheInstance) {
mediaCacheInstance = new MediaCacheService(options)
}
const getCachedMedia = (src: string) =>
mediaCacheInstance!.getCachedMedia(src)
const getCacheStats = () => mediaCacheInstance!.getCacheStats()
const clearCache = () => mediaCacheInstance!.clearCache()
const preloadMedia = (urls: string[]) =>
mediaCacheInstance!.preloadMedia(urls)
return {
getCachedMedia,
getCacheStats,
clearCache,
preloadMedia,
cache: mediaCacheInstance.cache
}
}
// Cleanup on page unload
if (typeof window !== 'undefined') {
window.addEventListener('beforeunload', () => {
if (mediaCacheInstance) {
mediaCacheInstance.destroy()
}
})
}