2026-01-29 14:38:07 +03:00
|
|
|
/**
|
|
|
|
|
* 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
|
|
|
|
|
*/
|
|
|
|
|
|
2026-02-05 11:45:36 +03:00
|
|
|
import type { QueryObserverOptions } from '@tanstack/query-core';
|
2026-01-29 14:38:07 +03:00
|
|
|
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<ProxyFontsParams> {
|
|
|
|
|
/**
|
|
|
|
|
* 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);
|
|
|
|
|
|
2026-01-31 11:48:14 +03:00
|
|
|
/**
|
|
|
|
|
* Accumulated fonts from all pages (for infinite scroll)
|
|
|
|
|
*/
|
|
|
|
|
#accumulatedFonts = $state<UnifiedFont[]>([]);
|
|
|
|
|
|
2026-01-29 14:38:07 +03:00
|
|
|
/**
|
|
|
|
|
* 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,
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-31 11:48:14 +03:00
|
|
|
/**
|
|
|
|
|
* Track previous filter params to detect changes and reset pagination
|
|
|
|
|
*/
|
|
|
|
|
#previousFilterParams = $state<string>('');
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Cleanup function for the filter tracking effect
|
|
|
|
|
*/
|
|
|
|
|
#filterCleanup: (() => void) | null = null;
|
|
|
|
|
|
2026-01-29 14:38:07 +03:00
|
|
|
constructor(initialParams: ProxyFontsParams = {}) {
|
|
|
|
|
super(initialParams);
|
2026-01-31 11:48:14 +03:00
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-02-05 11:45:36 +03:00
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-01-31 11:48:14 +03:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
}
|
2026-01-29 14:38:07 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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]) => {
|
2026-02-05 11:45:36 +03:00
|
|
|
if (value === '' || value === undefined || (Array.isArray(value) && value.length === 0)) {
|
2026-01-29 14:38:07 +03:00
|
|
|
return acc;
|
|
|
|
|
}
|
|
|
|
|
return { ...acc, [key]: value };
|
|
|
|
|
}, {});
|
|
|
|
|
|
2026-02-05 11:45:36 +03:00
|
|
|
// Return a consistent key
|
2026-01-29 14:38:07 +03:00
|
|
|
return ['unifiedFonts', normalized] as const;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-05 11:45:36 +03:00
|
|
|
protected getOptions(params = this.params): QueryObserverOptions<UnifiedFont[], Error> {
|
|
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-29 14:38:07 +03:00
|
|
|
/**
|
|
|
|
|
* Fetch function that calls the proxy API
|
|
|
|
|
* Returns the full response including pagination metadata
|
|
|
|
|
*/
|
|
|
|
|
protected async fetchFn(params: ProxyFontsParams): Promise<UnifiedFont[]> {
|
|
|
|
|
const response = await fetchProxyFonts(params);
|
|
|
|
|
|
2026-01-29 15:20:51 +03:00
|
|
|
// 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');
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-29 14:38:07 +03:00
|
|
|
// Store pagination metadata separately for derived values
|
|
|
|
|
this.#paginationMetadata = {
|
2026-01-29 15:20:51 +03:00
|
|
|
total: response.total ?? 0,
|
|
|
|
|
limit: response.limit ?? this.params.limit ?? 50,
|
|
|
|
|
offset: response.offset ?? this.params.offset ?? 0,
|
2026-01-11 14:49:21 +03:00
|
|
|
};
|
2026-01-29 14:38:07 +03:00
|
|
|
|
2026-01-31 11:48:14 +03:00
|
|
|
// Accumulate fonts for infinite scroll
|
2026-02-05 11:45:36 +03:00
|
|
|
// 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
|
2026-01-31 11:48:14 +03:00
|
|
|
this.#accumulatedFonts = [...this.#accumulatedFonts, ...response.fonts];
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-29 14:38:07 +03:00
|
|
|
return response.fonts;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- Getters (proxied from BaseFontStore) ---
|
|
|
|
|
|
|
|
|
|
/**
|
2026-01-31 11:48:14 +03:00
|
|
|
* Get all accumulated fonts (for infinite scroll)
|
2026-01-29 14:38:07 +03:00
|
|
|
*/
|
|
|
|
|
get fonts(): UnifiedFont[] {
|
2026-01-31 11:48:14 +03:00
|
|
|
return this.#accumulatedFonts;
|
2026-01-29 14:38:07 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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 });
|
2026-01-11 14:49:21 +03:00
|
|
|
}
|
|
|
|
|
|
2026-01-29 14:38:07 +03:00
|
|
|
/**
|
|
|
|
|
* 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');
|
2026-01-11 14:49:21 +03:00
|
|
|
}
|
|
|
|
|
}
|
2026-01-29 14:38:07 +03:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Factory function to create unified font store
|
|
|
|
|
*/
|
|
|
|
|
export function createUnifiedFontStore(params: ProxyFontsParams = {}) {
|
|
|
|
|
return new UnifiedFontStore(params);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Singleton instance for global use
|
2026-01-31 11:48:14 +03:00
|
|
|
* Initialized with a default limit to prevent fetching all fonts at once
|
2026-01-29 14:38:07 +03:00
|
|
|
*/
|
2026-01-31 11:48:14 +03:00
|
|
|
export const unifiedFontStore = new UnifiedFontStore({
|
|
|
|
|
limit: 50,
|
|
|
|
|
offset: 0,
|
|
|
|
|
});
|