From 6b69225bbf36a62f599fed1e5cb2ad450274beee Mon Sep 17 00:00:00 2001 From: Johnpaul Date: Wed, 30 Jul 2025 01:15:02 +0100 Subject: [PATCH] feat: add useIntersectionObserver, useLazyPagination, useTemplateFiltering, and mediaCacheService for improved component functionality --- src/composables/useIntersectionObserver.ts | 60 ++++++ src/composables/useLazyPagination.ts | 107 ++++++++++ src/composables/useTemplateFiltering.ts | 56 ++++++ src/services/mediaCacheService.ts | 221 +++++++++++++++++++++ 4 files changed, 444 insertions(+) create mode 100644 src/composables/useIntersectionObserver.ts create mode 100644 src/composables/useLazyPagination.ts create mode 100644 src/composables/useTemplateFiltering.ts create mode 100644 src/services/mediaCacheService.ts diff --git a/src/composables/useIntersectionObserver.ts b/src/composables/useIntersectionObserver.ts new file mode 100644 index 000000000..a369e9506 --- /dev/null +++ b/src/composables/useIntersectionObserver.ts @@ -0,0 +1,60 @@ +import { type Ref, onBeforeUnmount, ref, watch } from 'vue' + +export interface UseIntersectionObserverOptions + extends IntersectionObserverInit { + immediate?: boolean +} + +export function useIntersectionObserver( + target: Ref, + 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 + } +} diff --git a/src/composables/useLazyPagination.ts b/src/composables/useLazyPagination.ts new file mode 100644 index 000000000..474ccc8eb --- /dev/null +++ b/src/composables/useLazyPagination.ts @@ -0,0 +1,107 @@ +import { type Ref, computed, ref, shallowRef, watch } from 'vue' + +export interface LazyPaginationOptions { + itemsPerPage?: number + initialPage?: number +} + +export function useLazyPagination( + items: Ref | T[], + options: LazyPaginationOptions = {} +) { + const { itemsPerPage = 12, initialPage = 1 } = options + + const currentPage = ref(initialPage) + const isLoading = ref(false) + const loadedPages = shallowRef(new Set([])) + + // 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 + } +} diff --git a/src/composables/useTemplateFiltering.ts b/src/composables/useTemplateFiltering.ts new file mode 100644 index 000000000..14e5c6768 --- /dev/null +++ b/src/composables/useTemplateFiltering.ts @@ -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[] +) { + 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 + } +} diff --git a/src/services/mediaCacheService.ts b/src/services/mediaCacheService.ts new file mode 100644 index 000000000..e661f186d --- /dev/null +++ b/src/services/mediaCacheService.ts @@ -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()) + 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 { + 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() + } + }) +}