diff --git a/src/entities/Font/api/fontshare/fontshare.ts b/src/entities/Font/api/fontshare/fontshare.ts deleted file mode 100644 index 295afd1..0000000 --- a/src/entities/Font/api/fontshare/fontshare.ts +++ /dev/null @@ -1,161 +0,0 @@ -/** - * Fontshare API client - * - * Handles API requests to Fontshare API for fetching font metadata. - * Provides error handling, pagination support, and type-safe responses. - * - * Pagination: The Fontshare API DOES support pagination via `page` and `limit` parameters. - * However, the current implementation uses `fetchAllFontshareFonts()` to fetch all fonts upfront. - * For future optimization, consider implementing incremental pagination for large datasets. - * - * @see https://fontshare.com - */ - -import { api } from '$shared/api/api'; -import { buildQueryString } from '$shared/lib/utils'; -import type { QueryParams } from '$shared/lib/utils'; -import type { - FontshareApiModel, - FontshareFont, -} from '../../model/types/fontshare'; - -/** - * Fontshare API parameters - */ -export interface FontshareParams extends QueryParams { - /** - * Filter by categories (e.g., ["Sans", "Serif", "Display"]) - */ - categories?: string[]; - /** - * Filter by tags (e.g., ["Magazines", "Branding", "Logos"]) - */ - tags?: string[]; - /** - * Page number for pagination (1-indexed) - */ - page?: number; - /** - * Number of items per page - */ - limit?: number; - /** - * Search query to filter fonts - */ - q?: string; -} - -/** - * Fontshare API response wrapper - * Re-exported from model/types/fontshare for backward compatibility - */ -export type FontshareResponse = FontshareApiModel; - -/** - * Fetch fonts from Fontshare API - * - * @param params - Query parameters for filtering fonts - * @returns Promise resolving to Fontshare API response - * @throws ApiError when request fails - * - * @example - * ```ts - * // Fetch all Sans category fonts - * const response = await fetchFontshareFonts({ - * categories: ['Sans'], - * limit: 50 - * }); - * - * // Fetch fonts with specific tags - * const response = await fetchFontshareFonts({ - * tags: ['Branding', 'Logos'] - * }); - * - * // Search fonts - * const response = await fetchFontshareFonts({ - * search: 'Satoshi' - * }); - * ``` - */ -export async function fetchFontshareFonts( - params: FontshareParams = {}, -): Promise { - const queryString = buildQueryString(params); - const url = `https://api.fontshare.com/v2/fonts${queryString}`; - - try { - const response = await api.get(url); - return response.data; - } catch (error) { - // Re-throw ApiError with context - if (error instanceof Error) { - throw error; - } - throw new Error(`Failed to fetch Fontshare fonts: ${String(error)}`); - } -} - -/** - * Fetch font by slug - * Convenience function for fetching a single font - * - * @param slug - Font slug (e.g., "satoshi", "general-sans") - * @returns Promise resolving to Fontshare font item - * - * @example - * ```ts - * const satoshi = await fetchFontshareFontBySlug('satoshi'); - * ``` - */ -export async function fetchFontshareFontBySlug( - slug: string, -): Promise { - const response = await fetchFontshareFonts(); - return response.fonts.find(font => font.slug === slug); -} - -/** - * Fetch all fonts from Fontshare - * Convenience function for fetching all available fonts - * Uses pagination to get all items - * - * @returns Promise resolving to all Fontshare fonts - * - * @example - * ```ts - * const allFonts = await fetchAllFontshareFonts(); - * console.log(`Found ${allFonts.fonts.length} fonts`); - * ``` - */ -export async function fetchAllFontshareFonts( - params: FontshareParams = {}, -): Promise { - const allFonts: FontshareFont[] = []; - let page = 1; - const limit = 100; // Max items per page - - while (true) { - const response = await fetchFontshareFonts({ - ...params, - page, - limit, - }); - - allFonts.push(...response.fonts); - - // Check if we've fetched all items - if (response.fonts.length < limit) { - break; - } - - page++; - } - - // Return first response with all items combined - const firstResponse = await fetchFontshareFonts({ ...params, page: 1, limit }); - - return { - ...firstResponse, - fonts: allFonts, - }; -} diff --git a/src/entities/Font/api/google/googleFonts.ts b/src/entities/Font/api/google/googleFonts.ts deleted file mode 100644 index f8a5822..0000000 --- a/src/entities/Font/api/google/googleFonts.ts +++ /dev/null @@ -1,127 +0,0 @@ -/** - * Google Fonts API client - * - * Handles API requests to Google Fonts API for fetching font metadata. - * Provides error handling, retry logic, and type-safe responses. - * - * Pagination: The Google Fonts API does NOT support pagination parameters. - * All fonts matching the query are returned in a single response. - * Use category, subset, or sort filters to reduce the result set if needed. - * - * @see https://developers.google.com/fonts/docs/developer_api - */ - -import { api } from '$shared/api/api'; -import { buildQueryString } from '$shared/lib/utils'; -import type { QueryParams } from '$shared/lib/utils'; -import type { - FontItem, - GoogleFontsApiModel, -} from '../../model/types/google'; - -/** - * Google Fonts API parameters - */ -export interface GoogleFontsParams extends QueryParams { - /** - * Google Fonts API key (required for Google Fonts API v1) - */ - key?: string; - /** - * Font family name (to fetch specific font) - */ - family?: string; - /** - * Font category filter (e.g., "sans-serif", "serif", "display") - */ - category?: string; - /** - * Character subset filter (e.g., "latin", "latin-ext", "cyrillic") - */ - subset?: string; - /** - * Sort order for results - */ - sort?: 'alpha' | 'date' | 'popularity' | 'style' | 'trending'; - /** - * Cap the number of fonts returned - */ - capability?: 'VF' | 'WOFF2'; -} - -/** - * Google Fonts API response wrapper - * Re-exported from model/types/google for backward compatibility - */ -export type GoogleFontsResponse = GoogleFontsApiModel; - -/** - * Simplified font item from Google Fonts API - * Re-exported from model/types/google for backward compatibility - */ -export type GoogleFontItem = FontItem; - -/** - * Google Fonts API base URL - * Note: Google Fonts API v1 requires an API key. For development/testing without a key, - * fonts may not load properly. - */ -const GOOGLE_FONTS_API_URL = 'https://www.googleapis.com/webfonts/v1/webfonts' as const; - -/** - * Fetch fonts from Google Fonts API - * - * @param params - Query parameters for filtering fonts - * @returns Promise resolving to Google Fonts API response - * @throws ApiError when request fails - * - * @example - * ```ts - * // Fetch all sans-serif fonts sorted by popularity - * const response = await fetchGoogleFonts({ - * category: 'sans-serif', - * sort: 'popularity' - * }); - * - * // Fetch specific font family - * const robotoResponse = await fetchGoogleFonts({ - * family: 'Roboto' - * }); - * ``` - */ -export async function fetchGoogleFonts( - params: GoogleFontsParams = {}, -): Promise { - const queryString = buildQueryString(params); - const url = `${GOOGLE_FONTS_API_URL}${queryString}`; - - try { - const response = await api.get(url); - return response.data; - } catch (error) { - // Re-throw ApiError with context - if (error instanceof Error) { - throw error; - } - throw new Error(`Failed to fetch Google Fonts: ${String(error)}`); - } -} - -/** - * Fetch font by family name - * Convenience function for fetching a single font - * - * @param family - Font family name (e.g., "Roboto") - * @returns Promise resolving to Google Font item - * - * @example - * ```ts - * const roboto = await fetchGoogleFontFamily('Roboto'); - * ``` - */ -export async function fetchGoogleFontFamily( - family: string, -): Promise { - const response = await fetchGoogleFonts({ family }); - return response.items.find(item => item.family === family); -} diff --git a/src/entities/Font/api/index.ts b/src/entities/Font/api/index.ts index 9b3fb8d..0a1dde1 100644 --- a/src/entities/Font/api/index.ts +++ b/src/entities/Font/api/index.ts @@ -4,7 +4,7 @@ * Exports API clients and normalization utilities */ -// Proxy API (PRIMARY - NEW) +// Proxy API (primary) export { fetchFontsByIds, fetchProxyFontById, @@ -14,25 +14,3 @@ export type { ProxyFontsParams, ProxyFontsResponse, } from './proxy/proxyFonts'; - -// Google Fonts API (DEPRECATED - kept for backward compatibility) -export { - fetchGoogleFontFamily, - fetchGoogleFonts, -} from './google/googleFonts'; -export type { - GoogleFontItem, - GoogleFontsParams, - GoogleFontsResponse, -} from './google/googleFonts'; - -// Fontshare API (DEPRECATED - kept for backward compatibility) -export { - fetchAllFontshareFonts, - fetchFontshareFontBySlug, - fetchFontshareFonts, -} from './fontshare/fontshare'; -export type { - FontshareParams, - FontshareResponse, -} from './fontshare/fontshare'; diff --git a/src/entities/Font/api/proxy/filters.ts b/src/entities/Font/api/proxy/filters.ts deleted file mode 100644 index 94229c8..0000000 --- a/src/entities/Font/api/proxy/filters.ts +++ /dev/null @@ -1,83 +0,0 @@ -/** - * Proxy API filters - * - * Fetches filter metadata from GlyphDiff proxy API. - * Provides type-safe response handling. - * - * @see https://api.glyphdiff.com/api/v1/filters - */ - -import { api } from '$shared/api/api'; - -/** - * Filter metadata type from backend - */ -export interface FilterMetadata { - /** Filter ID (e.g., "providers", "categories", "subsets") */ - id: string; - - /** Display name (e.g., "Font Providers", "Categories", "Character Subsets") */ - name: string; - - /** Filter description */ - description: string; - - /** Filter type */ - type: 'enum' | 'string' | 'array'; - - /** Available filter options */ - options: FilterOption[]; -} - -/** - * Filter option type - */ -export interface FilterOption { - /** Option ID (e.g., "google", "serif", "latin") */ - id: string; - - /** Display name (e.g., "Google Fonts", "Serif", "Latin") */ - name: string; - - /** Option value (e.g., "google", "serif", "latin") */ - value: string; - - /** Number of fonts with this value */ - count: number; -} - -/** - * Proxy filters API response - */ -export interface ProxyFiltersResponse { - /** Array of filter metadata */ - filters: FilterMetadata[]; -} - -/** - * Fetch filters from proxy API - * - * @returns Promise resolving to array of filter metadata - * @throws ApiError when request fails - * - * @example - * ```ts - * // Fetch all filters - * const filters = await fetchProxyFilters(); - * - * console.log(filters); // [ - * // { id: "providers", name: "Font Providers", options: [...] }, - * // { id: "categories", name: "Categories", options: [...] }, - * // { id: "subsets", name: "Character Subsets", options: [...] } - * // ] - * ``` - */ -export async function fetchProxyFilters(): Promise { - const response = await api.get('/api/v1/filters'); - - if (!response.data || !Array.isArray(response.data)) { - throw new Error('Proxy API returned invalid response'); - } - - return response.data; -} diff --git a/src/entities/Font/api/proxy/proxyFonts.test.ts b/src/entities/Font/api/proxy/proxyFonts.test.ts new file mode 100644 index 0000000..da9e3a7 --- /dev/null +++ b/src/entities/Font/api/proxy/proxyFonts.test.ts @@ -0,0 +1,171 @@ +/** + * Tests for proxy API client + */ + +import { + beforeEach, + describe, + expect, + test, + vi, +} from 'vitest'; +import type { UnifiedFont } from '../../model/types'; +import type { ProxyFontsResponse } from './proxyFonts'; + +vi.mock('$shared/api/api', () => ({ + api: { + get: vi.fn(), + }, +})); + +import { api } from '$shared/api/api'; +import { + fetchFontsByIds, + fetchProxyFontById, + fetchProxyFonts, +} from './proxyFonts'; + +const PROXY_API_URL = 'https://api.glyphdiff.com/api/v1/fonts'; + +function createMockFont(overrides: Partial = {}): UnifiedFont { + return { + id: 'roboto', + family: 'Roboto', + provider: 'google', + category: 'sans-serif', + variants: [], + subsets: [], + ...overrides, + } as UnifiedFont; +} + +function mockApiGet(data: T) { + vi.mocked(api.get).mockResolvedValueOnce({ data, status: 200 }); +} + +describe('proxyFonts', () => { + beforeEach(() => { + vi.mocked(api.get).mockReset(); + }); + + describe('fetchProxyFonts', () => { + test('should fetch fonts with no params', async () => { + const mockResponse: ProxyFontsResponse = { + fonts: [createMockFont()], + total: 1, + limit: 50, + offset: 0, + }; + mockApiGet(mockResponse); + + const result = await fetchProxyFonts(); + + expect(api.get).toHaveBeenCalledWith(PROXY_API_URL); + expect(result).toEqual(mockResponse); + }); + + test('should build URL with query params', async () => { + const mockResponse: ProxyFontsResponse = { + fonts: [createMockFont()], + total: 1, + limit: 20, + offset: 0, + }; + mockApiGet(mockResponse); + + await fetchProxyFonts({ provider: 'google', category: 'sans-serif', limit: 20, offset: 0 }); + + const calledUrl = vi.mocked(api.get).mock.calls[0][0]; + expect(calledUrl).toContain('provider=google'); + expect(calledUrl).toContain('category=sans-serif'); + expect(calledUrl).toContain('limit=20'); + expect(calledUrl).toContain('offset=0'); + }); + + test('should throw on invalid response (missing fonts array)', async () => { + mockApiGet({ total: 0 }); + + await expect(fetchProxyFonts()).rejects.toThrow('Proxy API returned invalid response'); + }); + + test('should throw on null response data', async () => { + vi.mocked(api.get).mockResolvedValueOnce({ data: null, status: 200 }); + + await expect(fetchProxyFonts()).rejects.toThrow('Proxy API returned invalid response'); + }); + }); + + describe('fetchProxyFontById', () => { + test('should return font matching the ID', async () => { + const targetFont = createMockFont({ id: 'satoshi', name: 'Satoshi' }); + const mockResponse: ProxyFontsResponse = { + fonts: [createMockFont(), targetFont], + total: 2, + limit: 1000, + offset: 0, + }; + mockApiGet(mockResponse); + + const result = await fetchProxyFontById('satoshi'); + + expect(result).toEqual(targetFont); + }); + + test('should return undefined when font not found', async () => { + const mockResponse: ProxyFontsResponse = { + fonts: [createMockFont()], + total: 1, + limit: 1000, + offset: 0, + }; + mockApiGet(mockResponse); + + const result = await fetchProxyFontById('nonexistent'); + + expect(result).toBeUndefined(); + }); + + test('should search with the ID as query param', async () => { + const mockResponse: ProxyFontsResponse = { + fonts: [], + total: 0, + limit: 1000, + offset: 0, + }; + mockApiGet(mockResponse); + + await fetchProxyFontById('Roboto'); + + const calledUrl = vi.mocked(api.get).mock.calls[0][0]; + expect(calledUrl).toContain('limit=1000'); + expect(calledUrl).toContain('q=Roboto'); + }); + }); + + describe('fetchFontsByIds', () => { + test('should return empty array for empty input', async () => { + const result = await fetchFontsByIds([]); + + expect(result).toEqual([]); + expect(api.get).not.toHaveBeenCalled(); + }); + + test('should call batch endpoint with comma-separated IDs', async () => { + const fonts = [createMockFont({ id: 'roboto' }), createMockFont({ id: 'satoshi' })]; + mockApiGet(fonts); + + const result = await fetchFontsByIds(['roboto', 'satoshi']); + + expect(api.get).toHaveBeenCalledWith(`${PROXY_API_URL}/batch?ids=roboto,satoshi`); + expect(result).toEqual(fonts); + }); + + test('should return empty array when response data is nullish', async () => { + vi.mocked(api.get).mockResolvedValueOnce({ data: null, status: 200 }); + + const result = await fetchFontsByIds(['roboto']); + + expect(result).toEqual([]); + }); + }); +}); diff --git a/src/entities/Font/index.ts b/src/entities/Font/index.ts index f073adf..f23ed08 100644 --- a/src/entities/Font/index.ts +++ b/src/entities/Font/index.ts @@ -1,4 +1,4 @@ -// Proxy API (PRIMARY) +// Proxy API (primary) export { fetchFontsByIds, fetchProxyFontById, @@ -9,32 +9,9 @@ export type { ProxyFontsResponse, } from './api/proxy/proxyFonts'; -// Fontshare API (DEPRECATED) -export { - fetchAllFontshareFonts, - fetchFontshareFontBySlug, - fetchFontshareFonts, -} from './api/fontshare/fontshare'; -export type { - FontshareParams, - FontshareResponse, -} from './api/fontshare/fontshare'; - -// Google Fonts API (DEPRECATED) -export { - fetchGoogleFontFamily, - fetchGoogleFonts, -} from './api/google/googleFonts'; -export type { - GoogleFontItem, - GoogleFontsParams, - GoogleFontsResponse, -} from './api/google/googleFonts'; export { normalizeFontshareFont, normalizeFontshareFonts, - normalizeGoogleFont, - normalizeGoogleFonts, } from './lib/normalize/normalize'; export type { // Domain types @@ -65,8 +42,6 @@ export type { FontVariant, FontWeight, FontWeightItalic, - // Google Fonts API types - GoogleFontsApiModel, // Normalization types UnifiedFont, UnifiedFontVariant, diff --git a/src/entities/Font/lib/getFontUrl/getFontUrl.ts b/src/entities/Font/lib/getFontUrl/getFontUrl.ts index 667cf37..13f697e 100644 --- a/src/entities/Font/lib/getFontUrl/getFontUrl.ts +++ b/src/entities/Font/lib/getFontUrl/getFontUrl.ts @@ -3,13 +3,31 @@ import type { UnifiedFont, } from '../../model'; +/** Valid font weight values (100-900 in increments of 100) */ const SIZES = [100, 200, 300, 400, 500, 600, 700, 800, 900]; /** - * Constructs a URL for a font based on the provided font and weight. - * @param font - The font object. - * @param weight - The weight of the font. - * @returns The URL for the font. + * Gets the URL for a font file at a specific weight + * + * Constructs the appropriate URL for loading a font file based on + * the font object and requested weight. Handles variable fonts and + * provides fallbacks for static fonts. + * + * @param font - Unified font object containing style URLs + * @param weight - Font weight (100-900) + * @returns URL string for the font file, or undefined if not found + * @throws Error if weight is not a valid value (100-900) + * + * @example + * ```ts + * const url = getFontUrl(roboto, 700); // Returns URL for Roboto Bold + * + * // Variable fonts: backend maps weight to VF URL + * const vfUrl = getFontUrl(inter, 450); // Returns variable font URL + * + * // Fallback for missing weights + * const fallback = getFontUrl(font, 900); // Falls back to regular/400 if 900 missing + * ``` */ export function getFontUrl(font: UnifiedFont, weight: number): string | undefined { if (!SIZES.includes(weight)) { @@ -18,12 +36,11 @@ export function getFontUrl(font: UnifiedFont, weight: number): string | undefine const weightKey = weight.toString() as FontWeight; - // 1. Try exact match (Backend now maps "100".."900" to VF URL if variable) + // Try exact match (backend maps weight to VF URL for variable fonts) if (font.styles.variants?.[weightKey]) { return font.styles.variants[weightKey]; } - // 2. Fallbacks for Static Fonts (if exact weight missing) - // Try 'regular' or '400' as safe defaults + // Fallbacks for static fonts when exact weight is missing return font.styles.regular || font.styles.variants?.['400'] || font.styles.variants?.['regular']; } diff --git a/src/entities/Font/lib/mocks/filters.mock.ts b/src/entities/Font/lib/mocks/filters.mock.ts index 35f1e31..3d4f3e0 100644 --- a/src/entities/Font/lib/mocks/filters.mock.ts +++ b/src/entities/Font/lib/mocks/filters.mock.ts @@ -1,7 +1,5 @@ /** - * ============================================================================ - * MOCK FONT FILTER DATA - * ============================================================================ + * Mock font filter data * * Factory functions and preset mock data for font-related filters. * Used in Storybook stories for font filtering components. @@ -36,9 +34,7 @@ import type { import type { Property } from '$shared/lib'; import { createFilter } from '$shared/lib'; -// ============================================================================ // TYPE DEFINITIONS -// ============================================================================ /** * Options for creating a mock filter @@ -60,9 +56,7 @@ export interface MockFilters { subsets: ReturnType>; } -// ============================================================================ // FONT CATEGORIES -// ============================================================================ /** * Google Fonts categories @@ -98,9 +92,7 @@ export const UNIFIED_CATEGORIES: Property[] = [ { id: 'monospace', name: 'Monospace', value: 'monospace' }, ]; -// ============================================================================ // FONT SUBSETS -// ============================================================================ /** * Common font subsets @@ -114,9 +106,7 @@ export const FONT_SUBSETS: Property[] = [ { id: 'devanagari', name: 'Devanagari', value: 'devanagari' }, ]; -// ============================================================================ // FONT PROVIDERS -// ============================================================================ /** * Font providers @@ -126,9 +116,7 @@ export const FONT_PROVIDERS: Property[] = [ { id: 'fontshare', name: 'Fontshare', value: 'fontshare' }, ]; -// ============================================================================ // FILTER FACTORIES -// ============================================================================ /** * Create a mock filter from properties @@ -172,9 +160,7 @@ export function createProvidersFilter(options?: { selected?: FontProvider[] }) { return createFilter({ properties }); } -// ============================================================================ // PRESET FILTERS -// ============================================================================ /** * Preset mock filters - use these directly in stories @@ -251,9 +237,7 @@ export const MOCK_FILTERS_ALL_SELECTED: MockFilters = { }), }; -// ============================================================================ // GENERIC FILTER MOCKS -// ============================================================================ /** * Create a mock filter with generic string properties diff --git a/src/entities/Font/lib/mocks/fonts.mock.ts b/src/entities/Font/lib/mocks/fonts.mock.ts index 2ee8868..4a034b6 100644 --- a/src/entities/Font/lib/mocks/fonts.mock.ts +++ b/src/entities/Font/lib/mocks/fonts.mock.ts @@ -50,9 +50,7 @@ import type { UnifiedFont, } from '$entities/Font/model/types'; -// ============================================================================ // GOOGLE FONTS MOCKS -// ============================================================================ /** * Options for creating a mock Google Font @@ -186,9 +184,7 @@ export const GOOGLE_FONTS: Record = { }), }; -// ============================================================================ // FONTHARE MOCKS -// ============================================================================ /** * Options for creating a mock Fontshare font @@ -399,9 +395,7 @@ export const FONTHARE_FONTS: Record = { }), }; -// ============================================================================ // UNIFIED FONT MOCKS -// ============================================================================ /** * Options for creating a mock UnifiedFont diff --git a/src/entities/Font/lib/mocks/stores.mock.ts b/src/entities/Font/lib/mocks/stores.mock.ts index f6610c7..545af44 100644 --- a/src/entities/Font/lib/mocks/stores.mock.ts +++ b/src/entities/Font/lib/mocks/stores.mock.ts @@ -35,9 +35,7 @@ import { generateMockFonts, } from './fonts.mock'; -// ============================================================================ // TANSTACK QUERY MOCK TYPES -// ============================================================================ /** * Mock TanStack Query state @@ -83,9 +81,7 @@ export interface MockQueryObserverResult { isPaused?: boolean; } -// ============================================================================ // TANSTACK QUERY MOCK FACTORIES -// ============================================================================ /** * Create a mock query state for TanStack Query @@ -142,9 +138,7 @@ export function createSuccessState(data: TData): MockQueryObserverResult< return createMockQueryState({ status: 'success', data, error: undefined }); } -// ============================================================================ // FONT STORE MOCKS -// ============================================================================ /** * Mock UnifiedFontStore state @@ -332,9 +326,7 @@ export const MOCK_FONT_STORE_STATES = { }), }; -// ============================================================================ // MOCK STORE OBJECT -// ============================================================================ /** * Create a mock store object that mimics TanStack Query behavior @@ -469,9 +461,7 @@ export const MOCK_STORES = { }, }; -// ============================================================================ // REACTIVE STATE MOCKS -// ============================================================================ /** * Create a reactive state object using Svelte 5 runes pattern @@ -525,9 +515,7 @@ export function createMockComparisonStore(config: { }; } -// ============================================================================ // MOCK DATA GENERATORS -// ============================================================================ /** * Generate paginated font data diff --git a/src/entities/Font/model/store/baseFontStore.svelte.ts b/src/entities/Font/model/store/baseFontStore.svelte.ts index 6f38f97..07c9d82 100644 --- a/src/entities/Font/model/store/baseFontStore.svelte.ts +++ b/src/entities/Font/model/store/baseFontStore.svelte.ts @@ -7,41 +7,64 @@ import { } from '@tanstack/query-core'; import type { UnifiedFont } from '../types'; -/** */ +/** + * Base class for font stores using TanStack Query + * + * Provides reactive font data fetching with caching, automatic refetching, + * and parameter binding. Extended by UnifiedFontStore for provider-agnostic + * font fetching. + * + * @template TParams - Type of query parameters + */ export abstract class BaseFontStore> { + /** + * Cleanup function for effects + * Call destroy() to remove effects and prevent memory leaks + */ cleanup: () => void; + /** Reactive parameter bindings from external sources */ #bindings = $state<(() => Partial)[]>([]); + /** Internal parameter state */ #internalParams = $state({} as TParams); + /** + * Merged params from internal state and all bindings + * Automatically updates when bindings or internal params change + */ params = $derived.by(() => { let merged = { ...this.#internalParams }; - // Loop through every "Cable" plugged into the store - // Loop through every "Cable" plugged into the store + // Merge all binding results into params for (const getter of this.#bindings) { const bindingResult = getter(); merged = { ...merged, ...bindingResult }; } - return merged as TParams; }); + /** TanStack Query result state */ protected result = $state>({} as any); + /** TanStack Query observer instance */ protected observer: QueryObserver; + /** Shared query client */ protected qc = queryClient; + /** + * Creates a new base font store + * @param initialParams - Initial query parameters + */ constructor(initialParams: TParams) { this.#internalParams = initialParams; this.observer = new QueryObserver(this.qc, this.getOptions()); - // Sync TanStack -> Svelte State + // Sync TanStack Query state -> Svelte state this.observer.subscribe(r => { this.result = r; }); - // Sync Svelte State -> TanStack Options + // Sync Svelte state changes -> TanStack Query options this.cleanup = $effect.root(() => { $effect(() => { this.observer.setOptions(this.getOptions()); @@ -50,11 +73,21 @@ export abstract class BaseFontStore> { } /** - * Mandatory: Child must define how to fetch data and what the key is. + * Must be implemented by child class + * Returns the query key for TanStack Query caching */ protected abstract getQueryKey(params: TParams): QueryKey; + + /** + * Must be implemented by child class + * Fetches font data from API + */ protected abstract fetchFn(params: TParams): Promise; + /** + * Gets TanStack Query options + * @param params - Query parameters (defaults to current params) + */ protected getOptions(params = this.params): QueryObserverOptions { return { queryKey: this.getQueryKey(params), @@ -64,25 +97,36 @@ export abstract class BaseFontStore> { }; } - // --- Common Getters --- + /** Array of fonts (empty array if loading/error) */ get fonts() { return this.result.data ?? []; } + + /** Whether currently fetching initial data */ get isLoading() { return this.result.isLoading; } + + /** Whether any fetch is in progress (including refetches) */ get isFetching() { return this.result.isFetching; } + + /** Whether last fetch resulted in an error */ get isError() { return this.result.isError; } + + /** Whether no fonts are loaded (not loading and empty array) */ get isEmpty() { return !this.isLoading && this.fonts.length === 0; } - // --- Common Actions --- - + /** + * Add a reactive parameter binding + * @param getter - Function that returns partial params to merge + * @returns Unbind function to remove the binding + */ addBinding(getter: () => Partial) { this.#bindings.push(getter); @@ -91,9 +135,14 @@ export abstract class BaseFontStore> { }; } + /** + * Update query parameters + * @param newParams - Partial params to merge with existing + */ setParams(newParams: Partial) { this.#internalParams = { ...this.params, ...newParams }; } + /** * Invalidate cache and refetch */ @@ -101,19 +150,22 @@ export abstract class BaseFontStore> { this.qc.invalidateQueries({ queryKey: this.getQueryKey(this.params) }); } + /** + * Clean up effects and observers + */ destroy() { this.cleanup(); } /** - * Manually refetch + * Manually trigger a refetch */ async refetch() { await this.observer.refetch(); } /** - * Prefetch with different params (for hover states, pagination, etc.) + * Prefetch data with different parameters */ async prefetch(params: TParams) { await this.qc.prefetchQuery(this.getOptions(params)); diff --git a/src/entities/Font/model/store/types.ts b/src/entities/Font/model/store/types.ts deleted file mode 100644 index 45c7b5c..0000000 --- a/src/entities/Font/model/store/types.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * ============================================================================ - * UNIFIED FONT STORE TYPES - * ============================================================================ - * - * Type definitions for the unified font store infrastructure. - * Provides types for filters, sorting, and fetch parameters. - */ - -import type { - FontshareParams, - GoogleFontsParams, -} from '$entities/Font/api'; -import type { - FontCategory, - FontProvider, - FontSubset, -} from '$entities/Font/model/types/common'; - -/** - * Sort configuration - */ -export interface FontSort { - field: 'name' | 'popularity' | 'category' | 'date'; - direction: 'asc' | 'desc'; -} - -/** - * Fetch params for unified API - */ -export interface FetchFontsParams { - providers?: FontProvider[]; - categories?: FontCategory[]; - subsets?: FontSubset[]; - search?: string; - sort?: FontSort; - forceRefetch?: boolean; -} - -/** - * Provider-specific params union - */ -export type ProviderParams = GoogleFontsParams | FontshareParams; diff --git a/src/entities/Font/model/store/unifiedFontStore.svelte.ts b/src/entities/Font/model/store/unifiedFontStore.svelte.ts index 78b9536..33c7f07 100644 --- a/src/entities/Font/model/store/unifiedFontStore.svelte.ts +++ b/src/entities/Font/model/store/unifiedFontStore.svelte.ts @@ -43,7 +43,7 @@ import { BaseFontStore } from './baseFontStore.svelte'; * }); * * // Update parameters - * store.setCategory('serif'); + * store.setCategories(['serif']); * store.nextPage(); * ``` */ @@ -108,16 +108,20 @@ export class UnifiedFontStore extends BaseFontStore { this.#filterCleanup = $effect.root(() => { $effect(() => { const filterParams = JSON.stringify({ - provider: this.params.provider, - category: this.params.category, - subset: this.params.subset, + providers: this.params.providers, + categories: this.params.categories, + subsets: this.params.subsets, q: this.params.q, }); - // If filters changed, reset offset to 0 + // If filters changed, reset offset and invalidate cache if (filterParams !== this.#previousFilterParams) { - if (this.#previousFilterParams && this.params.offset !== 0) { - this.setParams({ offset: 0 }); + if (this.#previousFilterParams) { + if (this.params.offset !== 0) { + this.setParams({ offset: 0 }); + } + this.#accumulatedFonts = []; + this.invalidate(); } this.#previousFilterParams = filterParams; } @@ -170,7 +174,7 @@ export class UnifiedFontStore extends BaseFontStore { } protected getOptions(params = this.params): QueryObserverOptions { - const hasFilters = !!(params.q || params.provider || params.category || params.subset); + const hasFilters = !!(params.q || params.providers || params.categories || params.subsets); return { queryKey: this.getQueryKey(params), queryFn: () => this.fetchFn(params), @@ -221,8 +225,6 @@ export class UnifiedFontStore extends BaseFontStore { return response.fonts; } - // --- Getters (proxied from BaseFontStore) --- - /** * Get all accumulated fonts (for infinite scroll) */ @@ -258,27 +260,25 @@ export class UnifiedFontStore extends BaseFontStore { return !this.isLoading && this.fonts.length === 0; } - // --- Provider-specific shortcuts --- - /** - * Set provider filter + * Set providers filter */ - setProvider(provider: 'google' | 'fontshare' | undefined) { - this.setParams({ provider }); + setProviders(providers: ProxyFontsParams['providers']) { + this.setParams({ providers }); } /** - * Set category filter + * Set categories filter */ - setCategory(category: ProxyFontsParams['category']) { - this.setParams({ category }); + setCategories(categories: ProxyFontsParams['categories']) { + this.setParams({ categories }); } /** - * Set subset filter + * Set subsets filter */ - setSubset(subset: ProxyFontsParams['subset']) { - this.setParams({ subset }); + setSubsets(subsets: ProxyFontsParams['subsets']) { + this.setParams({ subsets }); } /** @@ -295,8 +295,6 @@ export class UnifiedFontStore extends BaseFontStore { this.setParams({ sort }); } - // --- Pagination methods --- - /** * Go to next page */ @@ -337,8 +335,6 @@ export class UnifiedFontStore extends BaseFontStore { this.setParams({ limit }); } - // --- Category shortcuts (for convenience) --- - get sansSerifFonts() { return this.fonts.filter(f => f.category === 'sans-serif'); } diff --git a/src/entities/Font/model/types/common.ts b/src/entities/Font/model/types/common.ts index 0e225cc..40c0d64 100644 --- a/src/entities/Font/model/types/common.ts +++ b/src/entities/Font/model/types/common.ts @@ -1,50 +1,60 @@ /** - * ============================================================================ - * DOMAIN TYPES - * ============================================================================ + * Common font domain types + * + * Shared types for font entities across providers (Google, Fontshare). + * Includes categories, subsets, weights, and filter types. */ + import type { FontCategory as FontshareFontCategory } from './fontshare'; import type { FontCategory as GoogleFontCategory } from './google'; /** - * Font category + * Unified font category across all providers */ export type FontCategory = GoogleFontCategory | FontshareFontCategory; /** - * Font provider + * Font provider identifier */ export type FontProvider = 'google' | 'fontshare'; /** - * Font subset + * Character subset support */ export type FontSubset = 'latin' | 'latin-ext' | 'cyrillic' | 'greek' | 'arabic' | 'devanagari'; /** - * Filter state + * Combined filter state for font queries */ export interface FontFilters { + /** Selected font providers */ providers: FontProvider[]; + /** Selected font categories */ categories: FontCategory[]; + /** Selected character subsets */ subsets: FontSubset[]; } +/** Filter group identifier */ export type FilterGroup = 'providers' | 'categories' | 'subsets'; + +/** Filter type including search query */ export type FilterType = FilterGroup | 'searchQuery'; /** - * Standard font weights + * Numeric font weights (100-900) */ export type FontWeight = '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900'; /** - * Italic variant format: e.g., "100italic", "400italic", "700italic" + * Italic variant with weight: "100italic", "400italic", "700italic", etc. */ export type FontWeightItalic = `${FontWeight}italic`; /** - * All possible font variants + * All possible font variant identifiers + * + * Includes: * - Numeric weights: "400", "700", etc. * - Italic variants: "400italic", "700italic", etc. * - Legacy names: "regular", "italic", "bold", "bolditalic" diff --git a/src/entities/Font/model/types/normalize.ts b/src/entities/Font/model/types/normalize.ts index 954b8ae..05ca582 100644 --- a/src/entities/Font/model/types/normalize.ts +++ b/src/entities/Font/model/types/normalize.ts @@ -46,6 +46,13 @@ export interface FontMetadata { lastModified?: string; /** Popularity rank (if available from provider) */ popularity?: number; + /** + * Normalized popularity score (0-100) + * + * Normalized across all fonts for consistent ranking + * Higher values indicate more popular fonts + */ + popularityScore?: number; } /** @@ -79,6 +86,13 @@ export interface UnifiedFont { name: string; /** Font provider (google | fontshare) */ provider: FontProvider; + /** + * Provider badge display name + * + * Human-readable provider name for UI display + * e.g., "Google Fonts" or "Fontshare" + */ + providerBadge?: string; /** Font category classification */ category: FontCategory; /** Supported character subsets */ diff --git a/src/entities/Font/ui/FontApplicator/FontApplicator.svelte b/src/entities/Font/ui/FontApplicator/FontApplicator.svelte index 7c0fb0b..8482e49 100644 --- a/src/entities/Font/ui/FontApplicator/FontApplicator.svelte +++ b/src/entities/Font/ui/FontApplicator/FontApplicator.svelte @@ -16,19 +16,20 @@ import { interface Props { /** - * Applied font + * Font to apply */ font: UnifiedFont; /** * Font weight + * @default 400 */ weight?: number; /** - * Additional classes + * CSS classes */ className?: string; /** - * Children + * Content snippet */ children?: Snippet; } @@ -44,7 +45,7 @@ const status = $derived( appliedFontsManager.getFontStatus( font.id, weight, - font.features.isVariable, + font.features?.isVariable, ), ); diff --git a/src/entities/Font/ui/FontVirtualList/FontVirtualList.svelte b/src/entities/Font/ui/FontVirtualList/FontVirtualList.svelte index c0b6f45..ba32dbd 100644 --- a/src/entities/Font/ui/FontVirtualList/FontVirtualList.svelte +++ b/src/entities/Font/ui/FontVirtualList/FontVirtualList.svelte @@ -28,11 +28,11 @@ interface Props extends > { /** - * Callback for when visible items change + * Visible items callback */ onVisibleItemsChange?: (items: UnifiedFont[]) => void; /** - * Weight of the font + * Font weight */ weight: number; /** @@ -69,6 +69,7 @@ function handleInternalVisibleChange(visibleItems: UnifiedFont[]) { }); } }); + // Auto-register fonts with the manager appliedFontsManager.touch(configs);