/** * Unified font store * * Single source of truth for font data, powered by the proxy API. * Extends BaseFontStore for TanStack Query integration and reactivity. * * Key features: * - Provider-agnostic (proxy API handles provider logic) * - Reactive to filter changes * - Optimistic updates via TanStack Query * - Pagination support * - Provider-specific shortcuts for common operations */ import type { QueryObserverOptions } from '@tanstack/query-core'; import type { ProxyFontsParams } from '../../api'; import { fetchProxyFonts } from '../../api'; import type { UnifiedFont } from '../types'; import { BaseFontStore } from './baseFontStore.svelte'; /** * Unified font store wrapping TanStack Query with Svelte 5 runes * * Extends BaseFontStore to provide: * - Reactive state management * - TanStack Query integration for caching * - Dynamic parameter binding for filters * - Pagination support * * @example * ```ts * const store = new UnifiedFontStore({ * provider: 'google', * category: 'sans-serif', * limit: 50 * }); * * // Access reactive state * $effect(() => { * console.log(store.fonts); * console.log(store.isLoading); * console.log(store.pagination); * }); * * // Update parameters * store.setCategory('serif'); * store.nextPage(); * ``` */ export class UnifiedFontStore extends BaseFontStore { /** * Store pagination metadata separately from fonts * This is a workaround for TanStack Query's type system */ #paginationMetadata = $state< { total: number; limit: number; offset: number; } | null >(null); /** * Accumulated fonts from all pages (for infinite scroll) */ #accumulatedFonts = $state([]); /** * Pagination metadata (derived from proxy API response) */ readonly pagination = $derived.by(() => { if (this.#paginationMetadata) { const { total, limit, offset } = this.#paginationMetadata; return { total, limit, offset, hasMore: offset + limit < total, page: Math.floor(offset / limit) + 1, totalPages: Math.ceil(total / limit), }; } return { total: 0, limit: this.params.limit || 50, offset: this.params.offset || 0, hasMore: false, page: 1, totalPages: 0, }; }); /** * Track previous filter params to detect changes and reset pagination */ #previousFilterParams = $state(''); /** * Cleanup function for the filter tracking effect */ #filterCleanup: (() => void) | null = null; constructor(initialParams: ProxyFontsParams = {}) { super(initialParams); // Track filter params (excluding pagination params) // Wrapped in $effect.root() to prevent effect_orphan error this.#filterCleanup = $effect.root(() => { $effect(() => { const filterParams = JSON.stringify({ provider: this.params.provider, category: this.params.category, subset: this.params.subset, q: this.params.q, }); // If filters changed, reset offset to 0 if (filterParams !== this.#previousFilterParams) { if (this.#previousFilterParams && this.params.offset !== 0) { this.setParams({ offset: 0 }); } this.#previousFilterParams = filterParams; } }); // Effect: Sync state from Query result (Handles Cache Hits) $effect(() => { const data = this.result.data; const offset = this.params.offset || 0; // When we have data and we are at the start (offset 0), // we must ensure accumulatedFonts matches the fresh (or cached) data. // This fixes the issue where cache hits skip fetchFn side-effects. if (offset === 0 && data && data.length > 0) { this.#accumulatedFonts = data; } }); }); } /** * Clean up both parent and child effects */ destroy() { // Call parent cleanup (TanStack observer effect) super.destroy(); // Call filter tracking effect cleanup if (this.#filterCleanup) { this.#filterCleanup(); this.#filterCleanup = null; } } /** * Query key for TanStack Query caching * Normalizes params to treat empty arrays/strings as undefined */ protected getQueryKey(params: ProxyFontsParams) { // Normalize params to treat empty arrays/strings as undefined const normalized = Object.entries(params).reduce((acc, [key, value]) => { if (value === '' || value === undefined || (Array.isArray(value) && value.length === 0)) { return acc; } return { ...acc, [key]: value }; }, {}); // Return a consistent key return ['unifiedFonts', normalized] as const; } protected getOptions(params = this.params): QueryObserverOptions { const hasFilters = !!(params.q || params.provider || params.category || params.subset); return { queryKey: this.getQueryKey(params), queryFn: () => this.fetchFn(params), staleTime: hasFilters ? 0 : 5 * 60 * 1000, gcTime: 10 * 60 * 1000, }; } /** * Fetch function that calls the proxy API * Returns the full response including pagination metadata */ protected async fetchFn(params: ProxyFontsParams): Promise { const response = await fetchProxyFonts(params); // Validate response structure if (!response) { console.error('[UnifiedFontStore] fetchProxyFonts returned undefined', { params }); throw new Error('Proxy API returned undefined response'); } if (!response.fonts) { console.error('[UnifiedFontStore] response.fonts is undefined', { response }); throw new Error('Proxy API response missing fonts array'); } if (!Array.isArray(response.fonts)) { console.error('[UnifiedFontStore] response.fonts is not an array', { fonts: response.fonts, }); throw new Error('Proxy API fonts is not an array'); } // Store pagination metadata separately for derived values this.#paginationMetadata = { total: response.total ?? 0, limit: response.limit ?? this.params.limit ?? 50, offset: response.offset ?? this.params.offset ?? 0, }; // Accumulate fonts for infinite scroll // Note: For offset === 0, we rely on the $effect above to handle the reset/init // This prevents race conditions and double-setting. if (params.offset !== 0) { // Append new fonts to existing ones only for pagination this.#accumulatedFonts = [...this.#accumulatedFonts, ...response.fonts]; } return response.fonts; } // --- Getters (proxied from BaseFontStore) --- /** * Get all accumulated fonts (for infinite scroll) */ get fonts(): UnifiedFont[] { return this.#accumulatedFonts; } /** * Check if loading initial data */ get isLoading(): boolean { return this.result.isLoading; } /** * Check if fetching (including background refetches) */ get isFetching(): boolean { return this.result.isFetching; } /** * Check if error occurred */ get isError(): boolean { return this.result.isError; } /** * Check if result is empty (not loading and no fonts) */ get isEmpty(): boolean { return !this.isLoading && this.fonts.length === 0; } // --- Provider-specific shortcuts --- /** * Set provider filter */ setProvider(provider: 'google' | 'fontshare' | undefined) { this.setParams({ provider }); } /** * Set category filter */ setCategory(category: ProxyFontsParams['category']) { this.setParams({ category }); } /** * Set subset filter */ setSubset(subset: ProxyFontsParams['subset']) { this.setParams({ subset }); } /** * Set search query */ setSearch(search: string) { this.setParams({ q: search || undefined }); } /** * Set sort order */ setSort(sort: ProxyFontsParams['sort']) { this.setParams({ sort }); } // --- Pagination methods --- /** * Go to next page */ nextPage() { if (this.pagination.hasMore) { this.setParams({ offset: this.pagination.offset + this.pagination.limit, }); } } /** * Go to previous page */ prevPage() { if (this.pagination.page > 1) { this.setParams({ offset: this.pagination.offset - this.pagination.limit, }); } } /** * Go to specific page */ goToPage(page: number) { if (page >= 1 && page <= this.pagination.totalPages) { this.setParams({ offset: (page - 1) * this.pagination.limit, }); } } /** * Set limit (items per page) */ setLimit(limit: number) { this.setParams({ limit }); } // --- Category shortcuts (for convenience) --- get sansSerifFonts() { return this.fonts.filter(f => f.category === 'sans-serif'); } get serifFonts() { return this.fonts.filter(f => f.category === 'serif'); } get displayFonts() { return this.fonts.filter(f => f.category === 'display'); } get handwritingFonts() { return this.fonts.filter(f => f.category === 'handwriting'); } get monospaceFonts() { return this.fonts.filter(f => f.category === 'monospace'); } } /** * Factory function to create unified font store */ export function createUnifiedFontStore(params: ProxyFontsParams = {}) { return new UnifiedFontStore(params); } /** * Singleton instance for global use * Initialized with a default limit to prevent fetching all fonts at once */ export const unifiedFontStore = new UnifiedFontStore({ limit: 50, offset: 0, });