Compare commits

...

40 Commits

Author SHA1 Message Date
Ilia Mashkov
8d1d1cd60f chore: import/export changes due to code move
Some checks failed
Test / Svelte Checks (push) Failing after 5s
Lint / Lint Code (push) Failing after 1m48s
2026-01-13 20:11:58 +03:00
Ilia Mashkov
fb5c15ec32 fix: minor changes 2026-01-13 20:11:18 +03:00
Ilia Mashkov
955cc66916 feat: new version of unifiedFontStore 2026-01-13 20:10:44 +03:00
Ilia Mashkov
a9cdd15787 feat(GetFonts): separated types for filters 2026-01-13 20:10:20 +03:00
Ilia Mashkov
76172aaa6b fix: minor changes 2026-01-13 20:09:30 +03:00
Ilia Mashkov
7146328982 feat(mapManagerToParams): create mapper to transform filter values to query param values 2026-01-13 20:08:46 +03:00
Ilia Mashkov
52ecb9e304 fix: remove searchQuery from FilterModel 2026-01-13 20:07:42 +03:00
Ilia Mashkov
30cb9ada1a fix(Font): refresh types 2026-01-13 20:06:58 +03:00
Ilia Mashkov
4eeb43fa34 chore: delete unused code 2026-01-13 20:05:33 +03:00
Ilia Mashkov
ad6ba4f0a0 feat: add query provider to App.svelte 2026-01-13 20:04:39 +03:00
Ilia Mashkov
170c8546d3 chore: import/export changes due to code move 2026-01-13 20:04:02 +03:00
Ilia Mashkov
2f15148cdb feat(VirtualList): add overscan support 2026-01-13 20:02:50 +03:00
Ilia Mashkov
a29b80efbb feature: Create BaseFontStore class with Tanstack query logic and FontshareStore, GoogleFontsStore based on it 2026-01-13 20:02:20 +03:00
Ilia Mashkov
91451f7886 chore: import/export fixes due to code move 2026-01-13 20:00:36 +03:00
Ilia Mashkov
99d4b4e29a chore: rename FetchFonts to GetFonts 2026-01-13 19:59:07 +03:00
Ilia Mashkov
d9d45bf9fb chore: move Filters and Controls to GetFont feature 2026-01-13 19:57:22 +03:00
Ilia Mashkov
4810c2b228 chore: delete unused code 2026-01-13 19:56:20 +03:00
Ilia Mashkov
4c9b9f631f fix: minor type changes for fonts 2026-01-13 19:54:56 +03:00
Ilia Mashkov
5fcb381b11 chore(normalize): move font api responce normalization functions to lib 2026-01-13 19:53:26 +03:00
Ilia Mashkov
e098da2dbb feat(filterManager): add debouced state support and move manager 2026-01-13 19:52:36 +03:00
Ilia Mashkov
1a76e9387a feat(createDebouncedState): create helper for managing state with debounce 2026-01-13 19:51:41 +03:00
Ilia Mashkov
0f1eb489ed feat: add query provider for Tanstack 2026-01-13 19:49:51 +03:00
Ilia Mashkov
6e8376b8fc fix(arch): move unifiedFontStore context creation to Layout.svelte
- Moved unifiedFontStore creation from Page.svelte to Layout.svelte
- Layout now creates store instance and provides it via setContext()
- Page.svelte now receives store via getContext() instead of creating it
- Fixes context accessibility issue where FiltersSidebar and FontSearch
  (siblings of Page) could not access the store
- All child components now share the same store instance at Layout level

This resolves the architectural issue where context only flows downward,
not sideways. All components (FiltersSidebar, FontSearch, Page) are now
children of Layout and can access the unifiedFontStore context.
2026-01-12 08:51:36 +03:00
Ilia Mashkov
d81af0a77b feat: implement P0/P1 performance and code quality optimizations
P0 Performance Optimizations:
- Add debounced search (300ms) to reduce re-renders during typing
- Implement single-pass filter function for O(n) complexity
- Add TanStack Query cancellation before new requests

P1 Code Quality Optimizations:
- Add runtime type guards for filter validation
- Implement two derived values (filteredFonts + sortedFilteredFonts)
- Remove all 'as any[]' casts from filter bridge
- Add fast-path for default sorting (skip unnecessary operations)

New Utilities:
- debounce utility with 4 tests (all pass)
- filterUtils with 15 tests (all pass)
- typeGuards with 20 tests (all pass)
- Total: 39 new tests

Modified Files:
- unifiedFontStore.svelte.ts: Add debouncing, use filter/sort utilities
- filterBridge.svelte.ts: Type-safe validation with type guards
- unifiedFontStore.test.ts: Fix pre-existing bugs (missing async, duplicate imports)

Code Quality:
- 0 linting warnings/errors (oxlint)
- FSD compliant architecture (entity lib layer)
- Backward compatible store API
2026-01-11 14:49:21 +03:00
Ilia Mashkov
77de829b04 fix: types 2026-01-09 16:48:26 +03:00
Ilia Mashkov
7630802363 fix: minor changes in types 2026-01-09 16:20:25 +03:00
Ilia Mashkov
43175fd52a feat(FontSearch): create FontSearch component with SearchBar and FontList with list virtualization 2026-01-09 16:20:00 +03:00
Ilia Mashkov
9598d8c3e4 feat(SearchBar): create SearchBar component with input and popover that contains search results 2026-01-09 16:19:22 +03:00
Ilia Mashkov
c863bea2dc feat: create FontList component with use of VirtualList 2026-01-09 16:18:16 +03:00
Ilia Mashkov
ea1f46f780 feat(fontCollection): create font collection state manager 2026-01-09 16:17:49 +03:00
Ilia Mashkov
bdb67157fd fix: rename file 2026-01-09 16:17:09 +03:00
Ilia Mashkov
e198e967ab fix: minor changes in shadcn components import 2026-01-09 16:16:32 +03:00
Ilia Mashkov
e1af950442 chore: create index files for better import/export api 2026-01-09 16:14:38 +03:00
Ilia Mashkov
13509a4145 chore: add comments for types and constants 2026-01-09 16:13:47 +03:00
Ilia Mashkov
09111a7c61 fix: import/export 2026-01-09 16:13:02 +03:00
Ilia Mashkov
b13c0d268b fix: import/export 2026-01-09 16:12:51 +03:00
Ilia Mashkov
1990860717 feat: add generic type for property value 2026-01-09 16:11:35 +03:00
Ilia Mashkov
6f7e863b13 fix: use proper types for fetching fonts 2026-01-09 16:09:56 +03:00
Ilia Mashkov
8ad29fd3a8 feat(FontCategory): separate types for font categories from different providers 2026-01-09 16:09:18 +03:00
Ilia Mashkov
de2688de5a delete: delete stores 2026-01-09 16:08:19 +03:00
72 changed files with 1469 additions and 1037 deletions

View File

@@ -6,13 +6,17 @@
* layout shell. This is the root component mounted by the application.
*
* Structure:
* - QueryProvider provides TanStack Query client for data fetching
* - Layout provides sidebar, header/footer, and page container
* - Page renders the current route content
*/
import Page from '$routes/Page.svelte';
import { QueryProvider } from './providers';
import Layout from './ui/Layout.svelte';
</script>
<Layout>
<Page />
</Layout>
<QueryProvider>
<Layout>
<Page />
</Layout>
</QueryProvider>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
/**
* Query Provider Component
*
* All components that use useQueryClient() or createQuery() must be
* descendants of this provider.
*/
import { queryClient } from '$shared/api/queryClient';
import { QueryClientProvider } from '@tanstack/svelte-query';
/** Slot content for child components */
let { children } = $props();
</script>
<QueryClientProvider client={queryClient}>
{@render children?.()}
</QueryClientProvider>

View File

@@ -0,0 +1 @@
export { default as QueryProvider } from './QueryProvider.svelte';

View File

@@ -4,6 +4,10 @@
* 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
*/
@@ -38,7 +42,7 @@ export interface FontshareParams extends QueryParams {
/**
* Search query to filter fonts
*/
search?: string;
q?: string;
}
/**
@@ -77,7 +81,7 @@ export async function fetchFontshareFonts(
params: FontshareParams = {},
): Promise<FontshareResponse> {
const queryString = buildQueryString(params);
const url = `https://api.fontshare.com/v2${queryString}`;
const url = `https://api.fontshare.com/v2/fonts${queryString}`;
try {
const response = await api.get<FontshareResponse>(url);
@@ -107,7 +111,7 @@ export async function fetchFontshareFontBySlug(
slug: string,
): Promise<FontshareFont | undefined> {
const response = await fetchFontshareFonts();
return response.items.find(font => font.slug === slug);
return response.fonts.find(font => font.slug === slug);
}
/**
@@ -120,7 +124,7 @@ export async function fetchFontshareFontBySlug(
* @example
* ```ts
* const allFonts = await fetchAllFontshareFonts();
* console.log(`Found ${allFonts.items.length} fonts`);
* console.log(`Found ${allFonts.fonts.length} fonts`);
* ```
*/
export async function fetchAllFontshareFonts(
@@ -137,10 +141,10 @@ export async function fetchAllFontshareFonts(
limit,
});
allFonts.push(...response.items);
allFonts.push(...response.fonts);
// Check if we've fetched all items
if (response.items.length < limit) {
if (response.fonts.length < limit) {
break;
}
@@ -152,6 +156,6 @@ export async function fetchAllFontshareFonts(
return {
...firstResponse,
items: allFonts,
fonts: allFonts,
};
}

View File

@@ -4,6 +4,10 @@
* 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
*/
@@ -20,7 +24,7 @@ import type {
*/
export interface GoogleFontsParams extends QueryParams {
/**
* Google Fonts API key (optional for public endpoints)
* Google Fonts API key (required for Google Fonts API v1)
*/
key?: string;
/**
@@ -38,11 +42,11 @@ export interface GoogleFontsParams extends QueryParams {
/**
* Sort order for results
*/
sort?: 'popularity' | 'alpha' | 'date' | 'style';
sort?: 'alpha' | 'date' | 'popularity' | 'style' | 'trending';
/**
* Cap the number of fonts returned
*/
capability?: string;
capability?: 'VF' | 'WOFF2';
}
/**
@@ -59,8 +63,10 @@ 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://fonts.googleapis.com/v2/fonts' as const;
const GOOGLE_FONTS_API_URL = 'https://www.googleapis.com/webfonts/v1/webfonts' as const;
/**
* Fetch fonts from Google Fonts API

View File

@@ -23,10 +23,3 @@ export type {
FontshareParams,
FontshareResponse,
} from './fontshare/fontshare';
export {
normalizeFontshareFont,
normalizeFontshareFonts,
normalizeGoogleFont,
normalizeGoogleFonts,
} from './normalize/normalize';

View File

@@ -21,7 +21,7 @@ export {
normalizeFontshareFonts,
normalizeGoogleFont,
normalizeGoogleFonts,
} from './api/normalize/normalize';
} from './lib/normalize/normalize';
export type {
// Domain types
FontCategory,
@@ -29,7 +29,6 @@ export type {
FontCollectionSort,
// Store types
FontCollectionState,
FontCollectionStore,
FontFeatures,
FontFiles,
FontItem,
@@ -43,6 +42,7 @@ export type {
FontshareFont,
FontshareLink,
FontsharePublisher,
FontshareStore,
FontshareStyle,
FontshareStyleProperties,
FontshareTag,
@@ -57,4 +57,19 @@ export type {
// Normalization types
UnifiedFont,
UnifiedFontVariant,
} from './model/types';
} from './model';
export {
createFontshareStore,
fetchFontshareFontsQuery,
fontshareStore,
} from './model';
// Stores
export {
createGoogleFontsStore,
GoogleFontsStore,
} from './model/services/fetchGoogleFonts.svelte';
// UI elements
export { FontList } from './ui';

View File

@@ -0,0 +1,6 @@
export {
normalizeFontshareFont,
normalizeFontshareFonts,
normalizeGoogleFont,
normalizeGoogleFonts,
} from './normalize/normalize';

View File

@@ -4,6 +4,7 @@ import {
it,
} from 'vitest';
import type {
FontItem,
FontshareFont,
GoogleFontItem,
} from '../../model/types';
@@ -82,42 +83,42 @@ describe('Font Normalization', () => {
});
it('handles sans-serif category', () => {
const font = { ...mockGoogleFont, category: 'sans-serif' };
const font: FontItem = { ...mockGoogleFont, category: 'sans-serif' };
const result = normalizeGoogleFont(font);
expect(result.category).toBe('sans-serif');
});
it('handles serif category', () => {
const font = { ...mockGoogleFont, category: 'serif' };
const font: FontItem = { ...mockGoogleFont, category: 'serif' };
const result = normalizeGoogleFont(font);
expect(result.category).toBe('serif');
});
it('handles display category', () => {
const font = { ...mockGoogleFont, category: 'display' };
const font: FontItem = { ...mockGoogleFont, category: 'display' };
const result = normalizeGoogleFont(font);
expect(result.category).toBe('display');
});
it('handles handwriting category', () => {
const font = { ...mockGoogleFont, category: 'handwriting' };
const font: FontItem = { ...mockGoogleFont, category: 'handwriting' };
const result = normalizeGoogleFont(font);
expect(result.category).toBe('handwriting');
});
it('handles cursive category (maps to handwriting)', () => {
const font = { ...mockGoogleFont, category: 'cursive' };
const font: FontItem = { ...mockGoogleFont, category: 'cursive' as any };
const result = normalizeGoogleFont(font);
expect(result.category).toBe('handwriting');
});
it('handles monospace category', () => {
const font = { ...mockGoogleFont, category: 'monospace' };
const font: FontItem = { ...mockGoogleFont, category: 'monospace' };
const result = normalizeGoogleFont(font);
expect(result.category).toBe('monospace');
@@ -522,22 +523,6 @@ describe('Font Normalization', () => {
expect(result.category).toBe('sans-serif'); // fallback
});
it('handles unknown Google Font category', () => {
const font: GoogleFontItem = {
family: 'Test',
category: 'unknown',
variants: ['regular'],
subsets: ['latin'],
files: { regular: 'url' },
version: 'v1',
lastModified: '2022-01-01',
menu: 'https://fonts.googleapis.com/css2?family=Test',
};
const result = normalizeGoogleFont(font);
expect(result.category).toBe('sans-serif'); // fallback
});
});
});

View File

@@ -0,0 +1,43 @@
export type {
// Domain types
FontCategory,
FontCollectionFilters,
FontCollectionSort,
// Store types
FontCollectionState,
FontFeatures,
FontFiles,
FontItem,
FontMetadata,
FontProvider,
// Fontshare API types
FontshareApiModel,
FontshareAxis,
FontshareDesigner,
FontshareFeature,
FontshareFont,
FontshareLink,
FontsharePublisher,
FontshareStyle,
FontshareStyleProperties,
FontshareTag,
FontshareWeight,
FontStyleUrls,
FontSubset,
FontVariant,
FontWeight,
FontWeightItalic,
// Google Fonts API types
GoogleFontsApiModel,
// Normalization types
UnifiedFont,
UnifiedFontVariant,
} from './types';
export { fetchFontshareFontsQuery } from './services';
export {
createFontshareStore,
type FontshareStore,
fontshareStore,
} from './store';

View File

@@ -0,0 +1,31 @@
import {
type FontshareParams,
fetchFontshareFonts,
} from '../../api';
import { normalizeFontshareFonts } from '../../lib';
import type { UnifiedFont } from '../types';
/**
* Query function for fetching fonts from Fontshare.
*
* @param params - The parameters for fetching fonts from Fontshare (E.g. search query, page number, etc.).
* @returns A promise that resolves with an array of UnifiedFont objects representing the fonts found in Fontshare.
*/
export async function fetchFontshareFontsQuery(params: FontshareParams): Promise<UnifiedFont[]> {
try {
const response = await fetchFontshareFonts(params);
return normalizeFontshareFonts(response.fonts);
} catch (error) {
if (error instanceof Error) {
if (error.message.includes('Failed to fetch')) {
throw new Error(
'Unable to connect to Fontshare. Please check your internet connection.',
);
}
if (error.message.includes('404')) {
throw new Error('Font not found in Fontshare catalog.');
}
}
throw new Error('Failed to load fonts from Fontshare.');
}
}

View File

@@ -0,0 +1,274 @@
/**
* Service for fetching Google Fonts with Svelte 5 runes + TanStack Query
*/
import {
type CreateQueryResult,
createQuery,
useQueryClient,
} from '@tanstack/svelte-query';
import {
type GoogleFontsParams,
fetchGoogleFonts,
} from '../../api';
import { normalizeGoogleFonts } from '../../lib';
import type {
FontCategory,
FontSubset,
} from '../types';
import type { UnifiedFont } from '../types/normalize';
/**
* Query key factory
*/
function getGoogleFontsQueryKey(params: GoogleFontsParams) {
return ['googleFonts', params] as const;
}
/**
* Query function
*/
export async function fetchGoogleFontsQuery(params: GoogleFontsParams): Promise<UnifiedFont[]> {
try {
const response = await fetchGoogleFonts({
category: params.category,
subset: params.subset,
sort: params.sort,
});
return normalizeGoogleFonts(response.items);
} catch (error) {
if (error instanceof Error) {
if (error.message.includes('Failed to fetch')) {
throw new Error(
'Unable to connect to Google Fonts. Please check your internet connection.',
);
}
if (error.message.includes('404')) {
throw new Error('Font not found in Google Fonts catalog.');
}
}
throw new Error('Failed to load fonts from Google Fonts.');
}
}
/**
* Google Fonts store wrapping TanStack Query with runes
*/
export class GoogleFontsStore {
params = $state<GoogleFontsParams>({});
private query: CreateQueryResult<UnifiedFont[], Error>;
private queryClient = useQueryClient();
constructor(initialParams: GoogleFontsParams = {}) {
this.params = initialParams;
// Create the query - automatically reactive
this.query = createQuery(() => ({
queryKey: getGoogleFontsQueryKey(this.params),
queryFn: () => fetchGoogleFontsQuery(this.params),
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes
}));
}
// Proxy TanStack Query's reactive state
get fonts() {
return this.query.data ?? [];
}
get isLoading() {
return this.query.isLoading;
}
get isFetching() {
return this.query.isFetching;
}
get isRefetching() {
return this.query.isRefetching;
}
get error() {
return this.query.error;
}
get isError() {
return this.query.isError;
}
get isSuccess() {
return this.query.isSuccess;
}
get status() {
return this.query.status;
}
// Derived helpers
get hasData() {
return this.fonts.length > 0;
}
get isEmpty() {
return !this.isLoading && this.fonts.length === 0;
}
get fontCount() {
return this.fonts.length;
}
// Filtered fonts by category (if you need additional client-side filtering)
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');
}
/**
* Update parameters - TanStack Query will automatically refetch
*/
setParams(newParams: Partial<GoogleFontsParams>) {
this.params = { ...this.params, ...newParams };
}
setCategory(category: FontCategory | undefined) {
this.setParams({ category });
}
setSubset(subset: FontSubset | undefined) {
this.setParams({ subset });
}
setSort(sort: 'popularity' | 'alpha' | 'date' | undefined) {
this.setParams({ sort });
}
setSearch(search: string) {
this.setParams({ search });
}
clearSearch() {
this.setParams({ search: undefined });
}
clearFilters() {
this.params = {};
}
/**
* Manually refetch
*/
async refetch() {
await this.query.refetch();
}
/**
* Invalidate cache and refetch
*/
invalidate() {
this.queryClient.invalidateQueries({
queryKey: getGoogleFontsQueryKey(this.params),
});
}
/**
* Invalidate all Google Fonts queries
*/
invalidateAll() {
this.queryClient.invalidateQueries({
queryKey: ['googleFonts'],
});
}
/**
* Prefetch with different params (for hover states, pagination, etc.)
*/
async prefetch(params: GoogleFontsParams) {
await this.queryClient.prefetchQuery({
queryKey: getGoogleFontsQueryKey(params),
queryFn: () => fetchGoogleFontsQuery(params),
staleTime: 5 * 60 * 1000,
});
}
/**
* Prefetch next category (useful for tab switching)
*/
async prefetchCategory(category: FontCategory) {
await this.prefetch({ ...this.params, category });
}
/**
* Cancel ongoing queries
*/
cancel() {
this.queryClient.cancelQueries({
queryKey: getGoogleFontsQueryKey(this.params),
});
}
/**
* Clear cache for current params
*/
clearCache() {
this.queryClient.removeQueries({
queryKey: getGoogleFontsQueryKey(this.params),
});
}
/**
* Get cached data without triggering fetch
*/
getCachedData() {
return this.queryClient.getQueryData<UnifiedFont[]>(
getGoogleFontsQueryKey(this.params),
);
}
/**
* Check if data exists in cache
*/
hasCache(params?: GoogleFontsParams) {
const key = params ? getGoogleFontsQueryKey(params) : getGoogleFontsQueryKey(this.params);
return this.queryClient.getQueryData(key) !== undefined;
}
/**
* Set data manually (optimistic updates)
*/
setQueryData(updater: (old: UnifiedFont[] | undefined) => UnifiedFont[]) {
this.queryClient.setQueryData(
getGoogleFontsQueryKey(this.params),
updater,
);
}
/**
* Get query state for debugging
*/
getQueryState() {
return this.queryClient.getQueryState(
getGoogleFontsQueryKey(this.params),
);
}
}
/**
* Factory function to create Google Fonts store
*/
export function createGoogleFontsStore(params: GoogleFontsParams = {}) {
return new GoogleFontsStore(params);
}

View File

@@ -0,0 +1,2 @@
export { fetchFontshareFontsQuery } from './fetchFontshareFonts.svelte';
export { fetchGoogleFontsQuery } from './fetchGoogleFonts.svelte';

View File

@@ -0,0 +1,156 @@
import { queryClient } from '$shared/api/queryClient';
import {
type QueryKey,
QueryObserver,
type QueryObserverOptions,
type QueryObserverResult,
} from '@tanstack/query-core';
import type { UnifiedFont } from '../types';
/** */
export abstract class BaseFontStore<TParams extends Record<string, any>> {
// params = $state<TParams>({} as TParams);
cleanup: () => void;
#bindings = $state<(() => Partial<TParams>)[]>([]);
#internalParams = $state<TParams>({} as TParams);
params = $derived.by(() => {
let merged = { ...this.#internalParams };
// Loop through every "Cable" plugged into the store
for (const getter of this.#bindings) {
merged = { ...merged, ...getter() };
}
return merged as TParams;
});
protected result = $state<QueryObserverResult<UnifiedFont[], Error>>({} as any);
protected observer: QueryObserver<UnifiedFont[], Error>;
protected qc = queryClient;
constructor(initialParams: TParams) {
this.#internalParams = initialParams;
this.observer = new QueryObserver(this.qc, this.getOptions());
// Sync TanStack -> Svelte State
this.observer.subscribe(r => {
this.result = r;
});
// Sync Svelte State -> TanStack Options
this.cleanup = $effect.root(() => {
$effect(() => {
this.observer.setOptions(this.getOptions());
});
});
}
/**
* Mandatory: Child must define how to fetch data and what the key is.
*/
protected abstract getQueryKey(params: TParams): QueryKey;
protected abstract fetchFn(params: TParams): Promise<UnifiedFont[]>;
private getOptions(params = this.params): QueryObserverOptions<UnifiedFont[], Error> {
return {
queryKey: this.getQueryKey(params),
queryFn: () => this.fetchFn(params),
staleTime: 5 * 60 * 1000,
};
}
// --- Common Getters ---
get fonts() {
return this.result.data ?? [];
}
get isLoading() {
return this.result.isLoading;
}
get isFetching() {
return this.result.isFetching;
}
get isError() {
return this.result.isError;
}
get isEmpty() {
return !this.isLoading && this.fonts.length === 0;
}
// --- Common Actions ---
addBinding(getter: () => Partial<TParams>) {
this.#bindings.push(getter);
return () => {
this.#bindings = this.#bindings.filter(b => b !== getter);
};
}
setParams(newParams: Partial<TParams>) {
this.#internalParams = { ...this.params, ...newParams };
}
/**
* Invalidate cache and refetch
*/
invalidate() {
this.qc.invalidateQueries({ queryKey: this.getQueryKey(this.params) });
}
destroy() {
this.cleanup();
}
/**
* Manually refetch
*/
async refetch() {
await this.observer.refetch();
}
/**
* Prefetch with different params (for hover states, pagination, etc.)
*/
async prefetch(params: TParams) {
await this.qc.prefetchQuery(this.getOptions(params));
}
/**
* Cancel ongoing queries
*/
cancel() {
this.qc.cancelQueries({
queryKey: this.getQueryKey(this.params),
});
}
/**
* Clear cache for current params
*/
clearCache() {
this.qc.removeQueries({
queryKey: this.getQueryKey(this.params),
});
}
/**
* Get cached data without triggering fetch
*/
getCachedData() {
return this.qc.getQueryData<UnifiedFont[]>(
this.getQueryKey(this.params),
);
}
/**
* Set data manually (optimistic updates)
*/
setQueryData(updater: (old: UnifiedFont[] | undefined) => UnifiedFont[]) {
this.qc.setQueryData(
this.getQueryKey(this.params),
updater,
);
}
}

View File

@@ -0,0 +1,32 @@
import type { FontshareParams } from '../../api';
import { fetchFontshareFontsQuery } from '../services';
import type { UnifiedFont } from '../types';
import { BaseFontStore } from './baseFontStore.svelte';
/**
* Fontshare store wrapping TanStack Query with runes
*/
export class FontshareStore extends BaseFontStore<FontshareParams> {
constructor(initialParams: FontshareParams = {}) {
super(initialParams);
}
protected getQueryKey(params: FontshareParams) {
return ['fontshare', params] as const;
}
protected async fetchFn(params: FontshareParams): Promise<UnifiedFont[]> {
return fetchFontshareFontsQuery(params);
}
// Provider-specific methods (shortcuts)
setSearch(search: string) {
this.setParams({ q: search } as any);
}
}
export function createFontshareStore(params: FontshareParams = {}) {
return new FontshareStore(params);
}
export const fontshareStore = new FontshareStore();

View File

@@ -0,0 +1,27 @@
import type { GoogleFontsParams } from '../../api';
import { fetchGoogleFontsQuery } from '../services';
import type { UnifiedFont } from '../types';
import { BaseFontStore } from './baseFontStore.svelte';
/**
* Google Fonts store wrapping TanStack Query with runes
*/
export class GoogleFontsStore extends BaseFontStore<GoogleFontsParams> {
constructor(initialParams: GoogleFontsParams = {}) {
super(initialParams);
}
protected getQueryKey(params: GoogleFontsParams) {
return ['googleFonts', params] as const;
}
protected async fetchFn(params: GoogleFontsParams): Promise<UnifiedFont[]> {
return fetchGoogleFontsQuery(params);
}
}
export function createFontshareStore(params: GoogleFontsParams = {}) {
return new GoogleFontsStore(params);
}
export const googleFontsStore = new GoogleFontsStore();

View File

@@ -0,0 +1,19 @@
/**
* ============================================================================
* UNIFIED FONT STORE EXPORTS
* ============================================================================
*
* Single export point for the unified font store infrastructure.
*/
// export {
// createUnifiedFontStore,
// UNIFIED_FONT_STORE_KEY,
// type UnifiedFontStore,
// } from './unifiedFontStore.svelte';
export {
createFontshareStore,
type FontshareStore,
fontshareStore,
} from './fontshareStore.svelte';

View File

@@ -0,0 +1,43 @@
/**
* ============================================================================
* 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;

View File

@@ -0,0 +1,29 @@
import {
type Filter,
type FilterModel,
createFilter,
} from '$shared/lib';
import { SvelteMap } from 'svelte/reactivity';
import type { FontProvider } from '../types';
import type { CheckboxFilter } from '../types/common';
import type { BaseFontStore } from './baseFontStore.svelte';
import { createFontshareStore } from './fontshareStore.svelte';
import type { ProviderParams } from './types';
export class UnitedFontStore {
private sources: Partial<Record<FontProvider, BaseFontStore<ProviderParams>>>;
filters: SvelteMap<CheckboxFilter, Filter>;
queryValue = $state('');
constructor(initialConfig: Partial<Record<FontProvider, ProviderParams>> = {}) {
this.sources = {
fontshare: createFontshareStore(initialConfig?.fontshare),
};
this.filters = new SvelteMap();
}
get fonts() {
return Object.values(this.sources).map(store => store.fonts).flat();
}
}

View File

@@ -1,263 +0,0 @@
/**
* Font collection store
*
* Main font collection cache using Svelte stores.
* Integrates with TanStack Query for advanced caching and deduplication.
*
* Provides derived stores for filtered/sorted fonts.
*/
import type {
FontCategory,
FontCollectionFilters,
FontCollectionSort,
FontCollectionState,
FontCollectionStore,
FontProvider,
FontSubset,
UnifiedFont,
} from '$entities/Font/model/types';
import { createCollectionCache } from '$shared/lib/fetch/collectionCache';
import type { Writable } from 'svelte/store';
import {
derived,
get,
writable,
} from 'svelte/store';
/**
* Create font collection store
*
* @param initialState - Initial state for collection
* @returns Font collection store instance
*
* @example
* ```ts
* const fontCollection = createFontCollectionStore({
* fonts: {},
* filters: {},
* sort: { field: 'name', direction: 'asc' }
* });
*
* // Add fonts to collection
* fontCollection.addFonts([font1, font2]);
*
* // Use in component
* $fontCollection.filteredFonts
* ```
*/
export function createFontCollectionStore(
initialState?: Partial<FontCollectionState>,
): FontCollectionStore {
const cache = createCollectionCache<UnifiedFont>({
defaultTTL: 5 * 60 * 1000, // 5 minutes
maxSize: 1000,
});
const defaultState: FontCollectionState = {
fonts: {},
filters: {},
sort: { field: 'name', direction: 'asc' },
};
const state: Writable<FontCollectionState> = writable({
...defaultState,
...initialState,
});
const isLoading = writable(false);
const error = writable<string | undefined>();
// Derived store for fonts as array
const fonts = derived(state, $state => {
return Object.values($state.fonts);
});
// Derived store for filtered fonts
const filteredFonts = derived([state, fonts], ([$state, $fonts]) => {
let filtered = [...$fonts];
// Apply search filter
if ($state.filters.searchQuery) {
const query = $state.filters.searchQuery.toLowerCase();
filtered = filtered.filter(font => font.name.toLowerCase().includes(query));
}
// Apply provider filter
if ($state.filters.provider) {
filtered = filtered.filter(
font => font.provider === $state.filters.provider,
);
}
// Apply category filter
if ($state.filters.category) {
filtered = filtered.filter(
font => font.category === $state.filters.category,
);
}
// Apply subset filter
if ($state.filters.subsets?.length) {
filtered = filtered.filter(font =>
$state.filters.subsets!.some(subset => font.subsets.includes(subset as FontSubset))
);
}
// Apply sort
const { field, direction } = $state.sort;
const multiplier = direction === 'asc' ? 1 : -1;
filtered.sort((a, b) => {
let comparison = 0;
if (field === 'name') {
comparison = a.name.localeCompare(b.name);
} else if (field === 'popularity') {
const aPop = a.metadata.popularity ?? 0;
const bPop = b.metadata.popularity ?? 0;
comparison = aPop - bPop;
} else if (field === 'category') {
comparison = a.category.localeCompare(b.category);
}
return comparison * multiplier;
});
return filtered;
});
// Derived store for count
const count = derived(fonts, $fonts => $fonts.length);
return {
// Expose main state
state,
// Expose derived stores
fonts,
filteredFonts,
count,
isLoading,
error,
/**
* Add multiple fonts to collection
*/
addFonts: (newFonts: UnifiedFont[]) => {
state.update($state => {
const fontsMap = { ...$state.fonts };
for (const font of newFonts) {
fontsMap[font.id] = font;
cache.set(font.id, font);
}
return {
...$state,
fonts: fontsMap,
};
});
},
/**
* Add single font to collection
*/
addFont: (font: UnifiedFont) => {
state.update($state => ({
...$state,
fonts: {
...$state.fonts,
[font.id]: font,
},
}));
cache.set(font.id, font);
},
/**
* Remove font from collection
*/
removeFont: (fontId: string) => {
state.update($state => {
const { [fontId]: _, ...rest } = $state.fonts;
return {
...$state,
fonts: rest,
};
});
cache.remove(fontId);
},
/**
* Clear all fonts
*/
clear: () => {
state.set({
...get(state),
fonts: {},
});
cache.clear();
},
/**
* Update filters
*/
setFilters: (filters: Partial<FontCollectionFilters>) => {
state.update($state => ({
...$state,
filters: {
...$state.filters,
...filters,
},
}));
},
/**
* Clear filters
*/
clearFilters: () => {
state.update($state => ({
...$state,
filters: {},
}));
},
/**
* Update sort configuration
*/
setSort: (sort: FontCollectionSort) => {
state.update($state => ({
...$state,
sort,
}));
},
/**
* Get font by ID
*/
getFont: (fontId: string) => {
const currentState = get(state);
return currentState.fonts[fontId];
},
/**
* Get fonts by provider
*/
getFontsByProvider: (provider: FontProvider) => {
const currentState = get(state);
return Object.values(currentState.fonts).filter(
font => font.provider === provider,
);
},
/**
* Get fonts by category
*/
getFontsByCategory: (category: FontCategory) => {
const currentState = get(state);
return Object.values(currentState.fonts).filter(
font => font.category === category,
);
},
};
}

View File

@@ -1,13 +0,0 @@
/**
* Font collection store exports
*
* Exports font collection store types and factory function
*/
export type {
FontCollectionFilters,
FontCollectionSort,
FontCollectionState,
FontCollectionStore,
} from '../types';
export { createFontCollectionStore } from './fontCollectionStore';

View File

@@ -3,11 +3,13 @@
* DOMAIN TYPES
* ============================================================================
*/
import type { FontCategory as FontshareFontCategory } from './fontshare';
import type { FontCategory as GoogleFontCategory } from './google';
/**
* Font category
*/
export type FontCategory = 'sans-serif' | 'serif' | 'display' | 'handwriting' | 'monospace';
export type FontCategory = GoogleFontCategory | FontshareFontCategory;
/**
* Font provider
@@ -18,3 +20,15 @@ export type FontProvider = 'google' | 'fontshare';
* Font subset
*/
export type FontSubset = 'latin' | 'latin-ext' | 'cyrillic' | 'greek' | 'arabic' | 'devanagari';
/**
* Filter state
*/
export interface FontFilters {
providers: FontProvider[];
categories: FontCategory[];
subsets: FontSubset[];
}
export type CheckboxFilter = 'providers' | 'categories' | 'subsets';
export type FilterType = CheckboxFilter | 'searchQuery';

View File

@@ -3,16 +3,37 @@
* FONTHARE API TYPES
* ============================================================================
*/
export const FONTSHARE_API_URL = 'https://api.fontshare.com/v2/fonts' as const;
import type { CollectionApiModel } from '$shared/types/collection';
export const FONTSHARE_API_URL = 'https://api.fontshare.com/v2' as const;
export type FontCategory = 'sans' | 'serif' | 'slab' | 'display' | 'handwritten' | 'script';
/**
* Model of Fontshare API response
* @see https://fontshare.com
*
* Fontshare API uses 'fonts' key instead of 'items' for the array
*/
export type FontshareApiModel = CollectionApiModel<FontshareFont>;
export interface FontshareApiModel {
/**
* Number of items returned in current page/response
*/
count: number;
/**
* Total number of items available across all pages
*/
count_total: number;
/**
* Indicates if there are more items available beyond this page
*/
has_more: boolean;
/**
* Array of fonts (Fontshare uses 'fonts' key, not 'items')
*/
fonts: FontshareFont[];
}
/**
* Individual font metadata from Fontshare API

View File

@@ -4,6 +4,8 @@
* ============================================================================
*/
export type FontCategory = 'sans-serif' | 'serif' | 'display' | 'handwriting' | 'monospace';
/**
* Model of google fonts api response
*/
@@ -29,7 +31,7 @@ export interface FontItem {
* Font category classification (e.g., "sans-serif", "serif", "display", "handwriting", "monospace")
* Useful for grouping and filtering fonts by style
*/
category: string;
category: FontCategory;
/**
* Available font variants for this font family

View File

@@ -55,5 +55,4 @@ export type {
FontCollectionFilters,
FontCollectionSort,
FontCollectionState,
FontCollectionStore,
} from './store';

View File

@@ -7,6 +7,7 @@
import type {
FontCategory,
FontProvider,
FontSubset,
} from './common';
import type { UnifiedFont } from './normalize';
@@ -27,13 +28,13 @@ export interface FontCollectionState {
*/
export interface FontCollectionFilters {
/** Search query */
searchQuery?: string;
/** Filter by provider */
provider?: FontProvider;
/** Filter by category */
category?: FontCategory;
searchQuery: string;
/** Filter by providers */
providers?: FontProvider[];
/** Filter by categories */
categories?: FontCategory[];
/** Filter by subsets */
subsets?: string[];
subsets?: FontSubset[];
}
/**
@@ -45,41 +46,3 @@ export interface FontCollectionSort {
/** Sort direction */
direction: 'asc' | 'desc';
}
/**
* Font collection store interface
*/
export interface FontCollectionStore {
/** Main state store */
state: import('svelte/store').Writable<FontCollectionState>;
/** All fonts as array */
fonts: import('svelte/store').Readable<UnifiedFont[]>;
/** Filtered fonts as array */
filteredFonts: import('svelte/store').Readable<UnifiedFont[]>;
/** Number of fonts in collection */
count: import('svelte/store').Readable<number>;
/** Loading state */
isLoading: import('svelte/store').Readable<boolean>;
/** Error state */
error: import('svelte/store').Readable<string | undefined>;
/** Add fonts to collection */
addFonts: (fonts: UnifiedFont[]) => void;
/** Add single font to collection */
addFont: (font: UnifiedFont) => void;
/** Remove font from collection */
removeFont: (fontId: string) => void;
/** Clear all fonts */
clear: () => void;
/** Update filters */
setFilters: (filters: Partial<FontCollectionFilters>) => void;
/** Clear filters */
clearFilters: () => void;
/** Update sort configuration */
setSort: (sort: FontCollectionSort) => void;
/** Get font by ID */
getFont: (fontId: string) => UnifiedFont | undefined;
/** Get fonts by provider */
getFontsByProvider: (provider: FontProvider) => UnifiedFont[];
/** Get fonts by category */
getFontsByCategory: (category: FontCategory) => UnifiedFont[];
}

View File

@@ -0,0 +1,46 @@
<script lang="ts">
import { fontshareStore } from '$entities/Font/model/store/fontshareStore.svelte';
import type { UnifiedFont } from '$entities/Font/model/types/normalize';
import {
Content as ItemContent,
Root as ItemRoot,
Title as ItemTitle,
} from '$shared/shadcn/ui/item';
import { VirtualList } from '$shared/ui';
/**
* FontList
*
* Displays a virtualized list of fonts with loading, empty, and error states.
* Uses unifiedFontStore from context for data, but can accept explicit fonts via props.
*/
interface FontListProps {
/** Font items to display (defaults to filtered fonts from store) */
fonts?: UnifiedFont[];
/** Show loading state */
loading?: boolean;
/** Show empty state when no results */
showEmpty?: boolean;
/** Custom error message to display */
errorMessage?: string;
}
let {
fonts,
loading,
showEmpty = true,
errorMessage,
}: FontListProps = $props();
// const fontshareStore = getFontshareContext();
</script>
{#each fontshareStore.fonts as font (font.id)}
<ItemRoot>
<ItemContent>
<ItemTitle>{font.name}</ItemTitle>
<span class="text-xs text-muted-foreground">
{font.category}{font.provider}
</span>
</ItemContent>
</ItemRoot>
{/each}

View File

@@ -0,0 +1,3 @@
import FontList from './FontList/FontList.svelte';
export { FontList };

View File

@@ -1,25 +0,0 @@
/**
* Fetch fonts feature exports
*
* Exports service functions for fetching fonts from Google Fonts and Fontshare
*/
export {
cancelGoogleFontsQueries,
fetchGoogleFontsQuery,
getGoogleFontsQueryKey,
invalidateGoogleFonts,
prefetchGoogleFonts,
useGoogleFontsQuery,
} from './model/services/fetchGoogleFonts';
export type { GoogleFontsQueryParams } from './model/services/fetchGoogleFonts';
export {
cancelFontshareFontsQueries,
fetchFontshareFontsQuery,
getFontshareQueryKey,
invalidateFontshareFonts,
prefetchFontshareFonts,
useFontshareFontsQuery,
} from './model/services/fetchFontshareFonts';
export type { FontshareQueryParams } from './model/services/fetchFontshareFonts';

View File

@@ -1,211 +0,0 @@
/**
* Service for fetching Fontshare fonts
*
* Integrates with TanStack Query for caching, deduplication,
* and automatic refetching.
*/
import { fetchFontshareFonts } from '$entities/Font/api/fontshare/fontshare';
import { normalizeFontshareFonts } from '$entities/Font/api/normalize/normalize';
import type { UnifiedFont } from '$entities/Font/model/types/normalize';
import type { QueryFunction } from '@tanstack/svelte-query';
import {
createQuery,
useQueryClient,
} from '@tanstack/svelte-query';
/**
* Fontshare query parameters
*/
export interface FontshareQueryParams {
/** Filter by categories (e.g., ["Sans", "Serif"]) */
categories?: string[];
/** Filter by tags (e.g., ["Branding", "Logos"]) */
tags?: string[];
/** Page number for pagination */
page?: number;
/** Number of items per page */
limit?: number;
/** Search query */
search?: string;
}
/**
* Query key factory for Fontshare
* Generates consistent query keys for cache management
*/
export function getFontshareQueryKey(
params: FontshareQueryParams,
): readonly unknown[] {
return ['fontshare', params];
}
/**
* Query function for fetching Fontshare fonts
* Handles caching, loading states, and errors
*/
export const fetchFontshareFontsQuery: QueryFunction<
UnifiedFont[],
readonly unknown[]
> = async ({ queryKey }) => {
const params = queryKey[1] as FontshareQueryParams;
try {
const response = await fetchFontshareFonts({
categories: params.categories,
tags: params.tags,
page: params.page,
limit: params.limit,
search: params.search,
});
const normalizedFonts = normalizeFontshareFonts(response.items);
return normalizedFonts;
} catch (error) {
// User-friendly error messages
if (error instanceof Error) {
if (error.message.includes('Failed to fetch')) {
throw new Error(
'Unable to connect to Fontshare. Please check your internet connection and try again.',
);
}
if (error.message.includes('404')) {
throw new Error('Font not found in Fontshare catalog.');
}
throw new Error(
'Failed to load fonts from Fontshare. Please try again later.',
);
}
throw new Error('An unexpected error occurred while fetching fonts.');
}
};
/**
* Create a Fontshare query hook
* Use this in Svelte components to fetch Fontshare fonts with caching
*
* @param params - Query parameters
* @returns Query result with data, loading state, and error
*
* @example
* ```svelte
* <script lang="ts">
* let { categories }: { categories?: string[] } = $props();
*
* const query = useFontshareFontsQuery({ categories });
*
* if ($query.isLoading) {
* return <LoadingSpinner />;
* }
*
* if ($query.error) {
* return <ErrorMessage message={$query.error.message} />;
* }
*
* const fonts = $query.data ?? [];
* </script>
*
* {#each fonts as font}
* <FontCard {font} />
* {/each}
* ```
*/
export function useFontshareFontsQuery(
params: FontshareQueryParams = {},
) {
useQueryClient();
const query = createQuery(() => ({
queryKey: getFontshareQueryKey(params),
queryFn: fetchFontshareFontsQuery,
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes
}));
return query;
}
/**
* Prefetch Fontshare fonts
* Fetch fonts in background without showing loading state
*
* @param params - Query parameters for prefetch
*
* @example
* ```ts
* // Prefetch fonts when user hovers over button
* function onMouseEnter() {
* prefetchFontshareFonts({ categories: ['Sans'] });
* }
* ```
*/
export async function prefetchFontshareFonts(
params: FontshareQueryParams = {},
): Promise<void> {
const queryClient = useQueryClient();
await queryClient.prefetchQuery({
queryKey: getFontshareQueryKey(params),
queryFn: fetchFontshareFontsQuery,
staleTime: 5 * 60 * 1000,
});
}
/**
* Invalidate Fontshare cache
* Forces refetch on next query
*
* @param params - Query parameters to invalidate (all if not provided)
*
* @example
* ```ts
* // Invalidate all Fontshare cache
* invalidateFontshareFonts();
*
* // Invalidate specific category cache
* invalidateFontshareFonts({ categories: ['Sans'] });
* ```
*/
export function invalidateFontshareFonts(
params?: FontshareQueryParams,
): void {
const queryClient = useQueryClient();
if (params) {
queryClient.invalidateQueries({
queryKey: getFontshareQueryKey(params),
});
} else {
queryClient.invalidateQueries({
queryKey: ['fontshare'],
});
}
}
/**
* Cancel Fontshare queries
* Abort in-flight requests
*
* @param params - Query parameters to cancel (all if not provided)
*
* @example
* ```ts
* // Cancel all Fontshare queries
* cancelFontshareFontsQueries();
* ```
*/
export function cancelFontshareFontsQueries(
params?: FontshareQueryParams,
): void {
const queryClient = useQueryClient();
if (params) {
queryClient.cancelQueries({
queryKey: getFontshareQueryKey(params),
});
} else {
queryClient.cancelQueries({
queryKey: ['fontshare'],
});
}
}

View File

@@ -1,213 +0,0 @@
/**
* Service for fetching Google Fonts
*
* Integrates with TanStack Query for caching, deduplication,
* and automatic refetching.
*
* Uses reactive query args pattern for Svelte 5 compatibility.
*/
import { fetchGoogleFonts } from '$entities/Font/api/google/googleFonts';
import { normalizeGoogleFonts } from '$entities/Font/api/normalize/normalize';
import type {
FontCategory,
FontSubset,
} from '$entities/Font/model/types';
import type { UnifiedFont } from '$entities/Font/model/types/normalize';
import type { QueryFunction } from '@tanstack/svelte-query';
import {
createQuery,
useQueryClient,
} from '@tanstack/svelte-query';
/**
* Google Fonts query parameters
*/
export interface GoogleFontsQueryParams {
/** Font category filter */
category?: FontCategory;
/** Character subset filter */
subset?: FontSubset;
/** Sort order */
sort?: 'popularity' | 'alpha' | 'date';
/** Search query (for specific font) */
search?: string;
/** Force refetch even if cached */
forceRefetch?: boolean;
}
/**
* Query key factory for Google Fonts
* Generates consistent query keys for cache management
*/
export function getGoogleFontsQueryKey(
params: GoogleFontsQueryParams,
): readonly unknown[] {
return ['googleFonts', params];
}
/**
* Query function for fetching Google Fonts
* Handles caching, loading states, and errors
*/
export const fetchGoogleFontsQuery: QueryFunction<
UnifiedFont[],
readonly unknown[]
> = async ({ queryKey }) => {
const params = queryKey[1] as GoogleFontsQueryParams;
try {
const response = await fetchGoogleFonts({
category: params.category,
subset: params.subset,
sort: params.sort,
});
const normalizedFonts = normalizeGoogleFonts(response.items);
return normalizedFonts;
} catch (error) {
// User-friendly error messages
if (error instanceof Error) {
if (error.message.includes('Failed to fetch')) {
throw new Error(
'Unable to connect to Google Fonts. Please check your internet connection and try again.',
);
}
if (error.message.includes('404')) {
throw new Error('Font not found in Google Fonts catalog.');
}
throw new Error(
'Failed to load fonts from Google Fonts. Please try again later.',
);
}
throw new Error('An unexpected error occurred while fetching fonts.');
}
};
/**
* Create a Google Fonts query hook
* Use this in Svelte components to fetch Google Fonts with caching
*
* @param params - Query parameters
* @returns Query result with data, loading state, and error
*
* @example
* ```svelte
* <script lang="ts">
* let { category }: { category?: FontCategory } = $props();
*
* const query = useGoogleFontsQuery({ category });
*
* if ($query.isLoading) {
* return <LoadingSpinner />;
* }
*
* if ($query.error) {
* return <ErrorMessage message={$query.error.message} />;
* }
*
* const fonts = $query.data ?? [];
* </script>
*
* {#each fonts as font}
* <FontCard {font} />
* {/each}
* ```
*/
export function useGoogleFontsQuery(params: GoogleFontsQueryParams = {}) {
useQueryClient();
const query = createQuery(() => ({
queryKey: getGoogleFontsQueryKey(params),
queryFn: fetchGoogleFontsQuery,
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes
}));
return query;
}
/**
* Prefetch Google Fonts
* Fetch fonts in background without showing loading state
*
* @param params - Query parameters for prefetch
*
* @example
* ```ts
* // Prefetch fonts when user hovers over button
* function onMouseEnter() {
* prefetchGoogleFonts({ category: 'sans-serif' });
* }
* ```
*/
export async function prefetchGoogleFonts(
params: GoogleFontsQueryParams = {},
): Promise<void> {
const queryClient = useQueryClient();
await queryClient.prefetchQuery({
queryKey: getGoogleFontsQueryKey(params),
queryFn: fetchGoogleFontsQuery,
staleTime: 5 * 60 * 1000,
});
}
/**
* Invalidate Google Fonts cache
* Forces refetch on next query
*
* @param params - Query parameters to invalidate (all if not provided)
*
* @example
* ```ts
* // Invalidate all Google Fonts cache
* invalidateGoogleFonts();
*
* // Invalidate specific category cache
* invalidateGoogleFonts({ category: 'sans-serif' });
* ```
*/
export function invalidateGoogleFonts(
params?: GoogleFontsQueryParams,
): void {
const queryClient = useQueryClient();
if (params) {
queryClient.invalidateQueries({
queryKey: getGoogleFontsQueryKey(params),
});
} else {
queryClient.invalidateQueries({
queryKey: ['googleFonts'],
});
}
}
/**
* Cancel Google Fonts queries
* Abort in-flight requests
*
* @param params - Query parameters to cancel (all if not provided)
*
* @example
* ```ts
* // Cancel all Google Fonts queries
* cancelGoogleFontsQueries();
* ```
*/
export function cancelGoogleFontsQueries(
params?: GoogleFontsQueryParams,
): void {
const queryClient = useQueryClient();
if (params) {
queryClient.cancelQueries({
queryKey: getGoogleFontsQueryKey(params),
});
} else {
queryClient.cancelQueries({
queryKey: ['googleFonts'],
});
}
}

View File

@@ -1,76 +0,0 @@
/**
* Fetch Fonts feature types
*
* Type definitions for font fetching feature
*/
import type {
FontCategory,
FontProvider,
FontSubset,
} from '$entities/Font/model/types';
import type { UnifiedFont } from '$entities/Font/model/types/normalize';
/**
* Combined query parameters for fetching from any provider
*/
export interface FetchFontsParams {
/** Font provider to fetch from */
provider?: FontProvider;
/** Category filter */
category?: FontCategory;
/** Subset filter */
subset?: FontSubset;
/** Search query */
search?: string;
/** Page number (for Fontshare) */
page?: number;
/** Limit (for Fontshare) */
limit?: number;
/** Force refetch even if cached */
forceRefetch?: boolean;
}
/**
* Font fetching result
*/
export interface FetchFontsResult {
/** Fetched fonts */
fonts: UnifiedFont[];
/** Total count (for pagination) */
total?: number;
/** Whether more fonts are available */
hasMore?: boolean;
/** Page number (for pagination) */
page?: number;
}
/**
* Font fetching error
*/
export interface FetchFontsError {
/** Error message */
message: string;
/** Provider that failed */
provider: FontProvider | 'all';
/** HTTP status code (if applicable) */
status?: number;
/** Original error */
originalError?: unknown;
}
/**
* Font fetching state
*/
export interface FetchFontsState {
/** Currently fetching */
isFetching: boolean;
/** Currently loading initial data */
isLoading: boolean;
/** Error state */
error: FetchFontsError | null;
/** Cached fonts */
fonts: UnifiedFont[];
/** Last fetch timestamp */
lastFetchedAt: number | null;
}

View File

@@ -1,7 +0,0 @@
import type { Property } from '$shared/lib';
export interface FilterGroupConfig {
id: string;
label: string;
properties: Property[];
}

View File

@@ -1,27 +0,0 @@
import { createFilterManager } from '../../lib/filterManager/filterManager.svelte';
import {
FONT_CATEGORIES,
FONT_PROVIDERS,
FONT_SUBSETS,
} from '../const/const';
import type { FilterGroupConfig } from '../const/types/common';
const filtersData: FilterGroupConfig[] = [
{
id: 'providers',
label: 'Font provider',
properties: FONT_PROVIDERS,
},
{
id: 'subsets',
label: 'Font subset',
properties: FONT_SUBSETS,
},
{
id: 'categories',
label: 'Font category',
properties: FONT_CATEGORIES,
},
];
export const filterManager = createFilterManager(filtersData);

View File

@@ -1,11 +1,19 @@
export {
createFilterManager,
type FilterManager,
} from './lib/filterManager/filterManager.svelte';
mapManagerToParams,
} from './lib';
export {
FONT_CATEGORIES,
FONT_PROVIDERS,
FONT_SUBSETS,
} from './model/const/const';
export type { FilterGroupConfig } from './model/const/types/common';
export { filterManager } from './model/state/manager.svelte';
export {
FilterControls,
Filters,
FontSearch,
} from './ui';

View File

@@ -1,13 +1,16 @@
import { createFilter } from '$shared/lib';
import type { FilterGroupConfig } from '../../model/const/types/common';
import { createDebouncedState } from '$shared/lib/helpers';
import type { FilterConfig } from '../../model';
/**
* Create a filter manager instance.
*/
export function createFilterManager(configs: FilterGroupConfig[]) {
export function createFilterManager<TValue extends string>(config: FilterConfig<TValue>) {
const search = createDebouncedState(config.queryValue ?? '');
// Create filter instances upfront
const groups = $state(
configs.map(config => ({
config.groups.map(config => ({
id: config.id,
label: config.label,
instance: createFilter({ properties: config.properties }),
@@ -20,6 +23,21 @@ export function createFilterManager(configs: FilterGroupConfig[]) {
);
return {
// Getter for queryValue (immediate value for UI)
get queryValue() {
return search.immediate;
},
// Setter for queryValue
set queryValue(value) {
search.immediate = value;
},
// Getter for queryValue (debounced value for logic)
get debouncedQueryValue() {
return search.debounced;
},
// Direct array reference (reactive)
get groups() {
return groups;

View File

@@ -0,0 +1,6 @@
export {
createFilterManager,
type FilterManager,
} from './filterManager/filterManager.svelte';
export { mapManagerToParams } from './mapper/mapManagerToParams';

View File

@@ -0,0 +1,12 @@
import type { FontshareParams } from '$entities/Font';
import type { FilterManager } from '../filterManager/filterManager.svelte';
export function mapManagerToParams(manager: FilterManager): Partial<FontshareParams> {
return {
q: manager.debouncedQueryValue,
// Map groups to specific API keys
categories: manager.getGroup('categories')?.instance.selectedProperties.map(p => p.value)
?? [],
tags: manager.getGroup('tags')?.instance.selectedProperties.map(p => p.value) ?? [],
};
}

View File

@@ -1,70 +1,90 @@
import type {
FontCategory,
FontProvider,
FontSubset,
} from '$entities/Font';
import type { Property } from '$shared/lib';
export const FONT_CATEGORIES: Property[] = [
export const FONT_CATEGORIES: Property<FontCategory>[] = [
{
id: 'serif',
name: 'Serif',
value: 'serif',
},
{
id: 'sans-serif',
name: 'Sans-serif',
value: 'sans-serif',
},
{
id: 'display',
name: 'Display',
value: 'display',
},
{
id: 'handwriting',
name: 'Handwriting',
value: 'handwriting',
},
{
id: 'monospace',
name: 'Monospace',
value: 'monospace',
},
{
id: 'script',
name: 'Script',
value: 'script',
},
{
id: 'slab',
name: 'Slab',
value: 'slab',
},
] as const;
export const FONT_PROVIDERS: Property[] = [
export const FONT_PROVIDERS: Property<FontProvider>[] = [
{
id: 'google',
name: 'Google Fonts',
value: 'google',
},
{
id: 'fontshare',
name: 'Fontshare',
value: 'fontshare',
},
] as const;
export const FONT_SUBSETS: Property[] = [
export const FONT_SUBSETS: Property<FontSubset>[] = [
{
id: 'latin',
name: 'Latin',
value: 'latin',
},
{
id: 'latin-ext',
name: 'Latin Extended',
value: 'latin-ext',
},
{
id: 'cyrillic',
name: 'Cyrillic',
value: 'cyrillic',
},
{
id: 'greek',
name: 'Greek',
value: 'greek',
},
{
id: 'arabic',
name: 'Arabic',
value: 'arabic',
},
{
id: 'devanagari',
name: 'Devanagari',
value: 'devanagari',
},
] as const;

View File

@@ -0,0 +1,6 @@
export type {
FilterConfig,
FilterGroupConfig,
} from './types/filter';
export { filterManager } from './state/manager.svelte';

View File

@@ -0,0 +1,29 @@
import { createFilterManager } from '../../lib/filterManager/filterManager.svelte';
import {
FONT_CATEGORIES,
FONT_PROVIDERS,
FONT_SUBSETS,
} from '../const/const';
const initialConfig = {
queryValue: '',
groups: [
{
id: 'providers',
label: 'Font provider',
properties: FONT_PROVIDERS,
},
{
id: 'subsets',
label: 'Font subset',
properties: FONT_SUBSETS,
},
{
id: 'categories',
label: 'Font category',
properties: FONT_CATEGORIES,
},
],
};
export const filterManager = createFilterManager(initialConfig);

View File

@@ -0,0 +1,12 @@
import type { Property } from '$shared/lib';
export interface FilterGroupConfig<TValue extends string> {
id: string;
label: string;
properties: Property<TValue>[];
}
export interface FilterConfig<TValue extends string> {
queryValue?: string;
groups: FilterGroupConfig<TValue>[];
}

View File

@@ -9,13 +9,12 @@
* - Font subset: Character subsets available (Latin, Latin Extended, etc.)
* - Font category: Serif, Sans-serif, Display, etc.
*
* Uses $derived for reactive access to filter states, ensuring UI updates
* when selections change through any means (sidebar, programmatically, etc.).
* This component handles reactive sync between filterManager selections
* and the unifiedFontStore using an $effect block to ensure filters are
* automatically synchronized whenever selections change.
*/
import { filterManager } from '$features/FilterFonts';
import { CheckboxFilter } from '$shared/ui';
$inspect(filterManager.groups).with(console.trace);
import { filterManager } from '../../model';
</script>
{#each filterManager.groups as group (group.id)}

View File

@@ -1,17 +1,14 @@
<script lang="ts">
import { Button } from '$shared/shadcn/ui/button';
import { filterManager } from '../../model';
/**
* Controls Component
*
* Action button group for filter operations. Provides two buttons:
*
* - Reset: Clears all active filters (outline variant for secondary action)
* - Apply: Applies selected filters (primary variant for main action)
*
* Buttons are equally sized (flex-1) for balanced layout. Note:
* Functionality not yet implemented - wire up to filter stores.
*/
import { filterManager } from '$features/FilterFonts';
import { Button } from '$shared/shadcn/ui/button';
</script>
<div class="flex flex-row gap-2">
@@ -22,7 +19,4 @@ import { Button } from '$shared/shadcn/ui/button';
>
Reset
</Button>
<Button class="flex-1 cursor-pointer">
Apply
</Button>
</div>

View File

@@ -0,0 +1,38 @@
<script lang="ts">
import {
FontList,
fontshareStore,
} from '$entities/Font';
import { SearchBar } from '$shared/ui';
import { onMount } from 'svelte';
import { mapManagerToParams } from '../../lib';
import { filterManager } from '../../model';
/**
* FontSearch
*
* Font search component with search input and font list display.
* Uses unifiedFontStore for all font operations and search state.
*/
onMount(() => {
/**
* The Pairing:
* We "plug" this manager into the global store.
* addBinding returns a function that removes this binding when the component unmounts.
*/
const unbind = fontshareStore.addBinding(() => mapManagerToParams(filterManager));
return unbind;
});
$inspect(filterManager.queryValue, filterManager.debouncedQueryValue);
</script>
<SearchBar
id="font-search"
class="w-full"
placeholder="Search fonts by name..."
bind:value={filterManager.queryValue}
>
<FontList />
</SearchBar>

View File

@@ -0,0 +1,9 @@
import Filters from './Filters/Filters.svelte';
import FilterControls from './FiltersControl/FilterControls.svelte';
import FontSearch from './FontSearch/FontSearch.svelte';
export {
FilterControls,
Filters,
FontSearch,
};

View File

@@ -1,3 +1,18 @@
import SetupFontMenu from './ui/SetupFontMenu.svelte';
export {
controlManager,
DEFAULT_FONT_SIZE,
DEFAULT_FONT_WEIGHT,
DEFAULT_LINE_HEIGHT,
FONT_SIZE_STEP,
FONT_WEIGHT_STEP,
LINE_HEIGHT_STEP,
MAX_FONT_SIZE,
MAX_FONT_WEIGHT,
MAX_LINE_HEIGHT,
MIN_FONT_SIZE,
MIN_FONT_WEIGHT,
MIN_LINE_HEIGHT,
} from './model';
export { SetupFontMenu };

View File

@@ -0,0 +1 @@
export { createTypographyControlManager } from './controlManager/controlManager.svelte';

View File

@@ -1,13 +1,22 @@
/**
* Font size constants
*/
export const DEFAULT_FONT_SIZE = 16;
export const MIN_FONT_SIZE = 8;
export const MAX_FONT_SIZE = 100;
export const FONT_SIZE_STEP = 1;
/**
* Font weight constants
*/
export const DEFAULT_FONT_WEIGHT = 400;
export const MIN_FONT_WEIGHT = 100;
export const MAX_FONT_WEIGHT = 900;
export const FONT_WEIGHT_STEP = 100;
/**
* Line height constants
*/
export const DEFAULT_LINE_HEIGHT = 1.5;
export const MIN_LINE_HEIGHT = 1;
export const MAX_LINE_HEIGHT = 2;

View File

@@ -0,0 +1,16 @@
export {
DEFAULT_FONT_SIZE,
DEFAULT_FONT_WEIGHT,
DEFAULT_LINE_HEIGHT,
FONT_SIZE_STEP,
FONT_WEIGHT_STEP,
LINE_HEIGHT_STEP,
MAX_FONT_SIZE,
MAX_FONT_WEIGHT,
MAX_LINE_HEIGHT,
MIN_FONT_SIZE,
MIN_FONT_WEIGHT,
MIN_LINE_HEIGHT,
} from './const/const';
export { controlManager } from './state/manager.svelte';

View File

@@ -1,7 +1,5 @@
import {
createTypographyControlManager,
} from '$features/SetupFont/lib/controlManager/controlManager.svelte';
import type { ControlModel } from '$shared/lib';
import { createTypographyControlManager } from '../../lib';
import {
DEFAULT_FONT_SIZE,
DEFAULT_FONT_WEIGHT,

View File

@@ -1,14 +1,19 @@
<script lang="ts">
/**
* Component containing controls for setting up font properties.
*/
import { Separator } from '$shared/shadcn/ui/separator/index';
import * as Sidebar from '$shared/shadcn/ui/sidebar/index';
import { Trigger as SidebarTrigger } from '$shared/shadcn/ui/sidebar';
import { ComboControl } from '$shared/ui';
import { controlManager } from '../model/state/manager.svetle';
import { controlManager } from '../model';
</script>
<div class="w-full p-2 flex flex-row items-center gap-2">
<Sidebar.Trigger />
<div class="p-2 flex flex-row items-center gap-2">
<SidebarTrigger />
<Separator orientation="vertical" class="h-full" />
{#each controlManager.controls as control (control.id)}
<ComboControl control={control.instance} />
{/each}
<div class="flex flex-row gap-2">
{#each controlManager.controls as control (control.id)}
<ComboControl control={control.instance} />
{/each}
</div>
</div>

View File

@@ -1,16 +1,27 @@
<script>
<script lang="ts">
/**
* Page Component
*
* Main page route component. This is the default route that users see when
* accessing the application. Currently displays a welcome message.
* Main page route component. Displays the font list and allows testing
* the unified font store functionality. Fetches fonts on mount and displays
* them using the FontList component.
*
* Note: This is a placeholder component. Replace with actual application content
* as the font comparison and filtering features are implemented.
* Receives unifiedFontStore from context created in Layout.svelte.
*/
// import {
// UNIFIED_FONT_STORE_KEY,
// type UnifiedFontStore,
// } from '$entities/Font/model/store/unifiedFontStore.svelte';
import FontList from '$entities/Font/ui/FontList/FontList.svelte';
// import { applyFilters } from '$features/FontManagement';
import {
getContext,
onMount,
} from 'svelte';
// Receive store from context (created in Layout.svelte)
// const unifiedFontStore: UnifiedFontStore = getContext(UNIFIED_FONT_STORE_KEY);
</script>
<h1>Welcome to Svelte + Vite</h1>
<p>
Visit <a href="https://svelte.dev/docs">svelte.dev/docs</a> to read the documentation
</p>
<!-- Font List -->
<FontList showEmpty={true} />

View File

@@ -0,0 +1,26 @@
import { QueryClient } from '@tanstack/query-core';
/**
* Query client instance
*/
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
/**
* Default staleTime: 5 minutes
*/
staleTime: 5 * 60 * 1000,
/**
* Default gcTime: 10 minutes
*/
gcTime: 10 * 60 * 1000,
refetchOnWindowFocus: false,
refetchOnMount: true,
retry: 3,
/**
* Exponential backoff
*/
retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000),
},
},
});

View File

@@ -0,0 +1,58 @@
import { debounce } from '$shared/lib/utils';
export function createDebouncedState<T>(initialValue: T, wait: number = 300) {
let immediate = $state(initialValue);
let debounced = $state(initialValue);
const updateDebounced = debounce((value: T) => {
debounced = value;
}, wait);
return {
get immediate() {
return immediate;
},
set immediate(value: T) {
immediate = value;
updateDebounced(value); // Manually trigger the debounce on write
},
get debounced() {
return debounced;
},
reset(value?: T) {
const resetValue = value ?? initialValue;
immediate = resetValue;
debounced = resetValue;
},
};
}
// export function createDebouncedState<T>(initialValue: T, wait: number = 300) {
// let immediate = $state(initialValue);
// let debounced = $state(initialValue);
// const updateDebounced = debounce((value: T) => {
// debounced = value;
// }, wait);
// $effect(() => {
// updateDebounced(immediate);
// });
// return {
// get immediate() {
// return immediate;
// },
// set immediate(value: T) {
// immediate = value;
// },
// get debounced() {
// return debounced;
// },
// reset(value?: T) {
// const resetValue = value ?? initialValue;
// immediate = resetValue;
// debounced = resetValue;
// },
// };
// }

View File

@@ -1,4 +1,4 @@
export interface Property {
export interface Property<TValue extends string> {
/**
* Property identifier
*/
@@ -7,29 +7,29 @@ export interface Property {
* Property name
*/
name: string;
/**
* Property value
*/
value: TValue;
/**
* Property selected state
*/
selected?: boolean;
}
export interface FilterModel {
/**
* Search query
*/
searchQuery?: string;
export interface FilterModel<TValue extends string> {
/**
* Properties
*/
properties: Property[];
properties: Property<TValue>[];
}
/**
* Create a filter store.
* @param initialState - Initial state of the filter store
*/
export function createFilter<T extends FilterModel>(
initialState: T,
export function createFilter<TValue extends string>(
initialState: FilterModel<TValue>,
) {
let properties = $state(
initialState.properties.map(p => ({

View File

@@ -22,6 +22,7 @@ describe('createFilter - Filter Logic', () => {
return Array.from({ length: count }, (_, i) => ({
id: `prop-${i}`,
name: `Property ${i}`,
value: `Value ${i}`,
selected: selectedIndices.includes(i),
}));
}

View File

@@ -4,16 +4,40 @@ import {
} from '$shared/lib/utils';
export interface ControlDataModel {
/**
* Control value
*/
value: number;
/**
* Minimal possible value
*/
min: number;
/**
* Maximal possible value
*/
max: number;
/**
* Step size for increase/decrease
*/
step: number;
}
export interface ControlModel extends ControlDataModel {
/**
* Control identifier
*/
id: string;
/**
* Area label for increase button
*/
increaseLabel: string;
/**
* Area label for decrease button
*/
decreaseLabel: string;
/**
* Control area label
*/
controlLabel: string;
}

View File

@@ -18,3 +18,5 @@ export {
type Virtualizer,
type VirtualizerOptions,
} from './createVirtualizer/createVirtualizer.svelte';
export { createDebouncedState } from './createDebouncedState/createDebouncedState.svelte';

View File

@@ -0,0 +1,77 @@
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi,
} from 'vitest';
import { debounce } from './debounce';
describe('debounce', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should delay execution by the specified wait time', () => {
const mockFn = vi.fn();
const debounced = debounce(mockFn, 300);
debounced('arg1', 'arg2');
expect(mockFn).not.toHaveBeenCalled();
vi.advanceTimersByTime(300);
expect(mockFn).toHaveBeenCalledTimes(1);
expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2');
});
it('should cancel previous invocation and restart timer on subsequent calls', () => {
const mockFn = vi.fn();
const debounced = debounce(mockFn, 300);
debounced('first');
vi.advanceTimersByTime(100);
debounced('second');
vi.advanceTimersByTime(100);
debounced('third');
vi.advanceTimersByTime(300);
expect(mockFn).toHaveBeenCalledTimes(1);
expect(mockFn).toHaveBeenCalledWith('third');
});
it('should handle rapid calls correctly', () => {
const mockFn = vi.fn();
const debounced = debounce(mockFn, 300);
debounced('1');
vi.advanceTimersByTime(50);
debounced('2');
vi.advanceTimersByTime(50);
debounced('3');
vi.advanceTimersByTime(50);
debounced('4');
vi.advanceTimersByTime(300);
expect(mockFn).toHaveBeenCalledTimes(1);
expect(mockFn).toHaveBeenCalledWith('4');
});
it('should not execute if timer is cleared before wait time', () => {
const mockFn = vi.fn();
const debounced = debounce(mockFn, 300);
debounced('test');
vi.advanceTimersByTime(200);
expect(mockFn).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,43 @@
/**
* ============================================================================
* DEBOUNCE UTILITY
* ============================================================================
*
* Creates a debounced function that delays execution until after wait milliseconds
* have elapsed since the last time it was invoked.
*
* @example
* ```typescript
* const debouncedSearch = debounce((query: string) => {
* console.log('Searching for:', query);
* }, 300);
*
* debouncedSearch('hello');
* debouncedSearch('hello world'); // Only this will execute after 300ms
* ```
*/
/**
* Creates a debounced version of a function
*
* @param fn - The function to debounce
* @param wait - The delay in milliseconds
* @returns A debounced function that will execute after the specified delay
*/
export function debounce<T extends (...args: any[]) => any>(
fn: T,
wait: number,
): (...args: Parameters<T>) => void {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
return (...args: Parameters<T>) => {
if (timeoutId) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => {
fn(...args);
timeoutId = null;
}, wait);
};
}

View File

@@ -0,0 +1 @@
export { debounce } from './debounce';

View File

@@ -8,5 +8,6 @@ export {
type QueryParamValue,
} from './buildQueryString/buildQueryString';
export { clampNumber } from './clampNumber/clampNumber';
export { debounce } from './debounce/debounce';
export { getDecimalPlaces } from './getDecimalPlaces/getDecimalPlaces';
export { roundToStepPrecision } from './roundToStepPrecision/roundToStepPrecision';

View File

@@ -1,21 +0,0 @@
/**
* Generic collection API response model
* Use this for APIs that return collections of items
*
* @template T - The type of items in the collection array
* @template K - The key used to access the collection array in the response
*/
export type CollectionApiModel<T, K extends string = 'items'> = Record<K, T[]> & {
/**
* Number of items returned in the current page/response
*/
count: number;
/**
* Total number of items available across all pages
*/
count_total: number;
/**
* Indicates if there are more items available beyond this page
*/
has_more: boolean;
};

View File

@@ -3,7 +3,10 @@ import type { Filter } from '$shared/lib';
import { Badge } from '$shared/shadcn/ui/badge';
import { buttonVariants } from '$shared/shadcn/ui/button';
import { Checkbox } from '$shared/shadcn/ui/checkbox';
import * as Collapsible from '$shared/shadcn/ui/collapsible';
import {
Root as CollapsibleRoot,
Trigger as CollapsibleTrigger,
} from '$shared/shadcn/ui/collapsible';
import { Label } from '$shared/shadcn/ui/label';
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import { onMount } from 'svelte';
@@ -66,13 +69,13 @@ const hasSelection = $derived(selectedCount > 0);
</script>
<!-- Collapsible card wrapper with subtle hover state for affordance -->
<Collapsible.Root
<CollapsibleRoot
bind:open={isOpen}
class="w-full rounded-lg border bg-card transition-colors hover:bg-accent/5"
>
<!-- Trigger row: title, expand indicator, and optional count badge -->
<div class="flex items-center justify-between px-4 py-2">
<Collapsible.Trigger
<CollapsibleTrigger
class={buttonVariants({
variant: 'ghost',
size: 'sm',
@@ -101,7 +104,7 @@ const hasSelection = $derived(selectedCount > 0);
>
<ChevronDownIcon class="h-4 w-4" />
</div>
</Collapsible.Trigger>
</CollapsibleTrigger>
</div>
<!-- Expandable content with slide animation -->
@@ -154,4 +157,4 @@ const hasSelection = $derived(selectedCount > 0);
</div>
</div>
{/if}
</Collapsible.Root>
</CollapsibleRoot>

View File

@@ -33,7 +33,7 @@ describe('CheckboxFilter Component', () => {
/**
* Helper function to create a filter for testing
*/
function createTestFilter(properties: Property[]) {
function createTestFilter<T extends string>(properties: Property<T>[]) {
return createFilter({ properties });
}
@@ -44,6 +44,7 @@ describe('CheckboxFilter Component', () => {
return Array.from({ length: count }, (_, i) => ({
id: `prop-${i}`,
name: `Property ${i}`,
value: `Value ${i}`,
selected: selectedIndices.includes(i),
}));
}
@@ -437,10 +438,11 @@ describe('CheckboxFilter Component', () => {
describe('Edge Cases', () => {
it('handles long property names', () => {
const properties: Property[] = [
const properties: Property<string>[] = [
{
id: '1',
name: 'This is a very long property name that might wrap to multiple lines',
value: '1',
selected: false,
},
];
@@ -458,10 +460,10 @@ describe('CheckboxFilter Component', () => {
});
it('handles special characters in property names', () => {
const properties: Property[] = [
{ id: '1', name: 'Café & Restaurant', selected: true },
{ id: '2', name: '100% Organic', selected: false },
{ id: '3', name: '(Special) <Characters>', selected: false },
const properties: Property<string>[] = [
{ id: '1', name: 'Café & Restaurant', value: '1', selected: true },
{ id: '2', name: '100% Organic', value: '2', selected: false },
{ id: '3', name: '(Special) <Characters>', value: '3', selected: false },
];
const filter = createTestFilter(properties);
render(CheckboxFilter, {
@@ -475,8 +477,8 @@ describe('CheckboxFilter Component', () => {
});
it('handles single property filter', () => {
const properties: Property[] = [
{ id: '1', name: 'Only One', selected: true },
const properties: Property<string>[] = [
{ id: '1', name: 'Only One', value: '1', selected: true },
];
const filter = createTestFilter(properties);
render(CheckboxFilter, {
@@ -527,12 +529,12 @@ describe('CheckboxFilter Component', () => {
describe('Component Integration', () => {
it('works correctly with real filter data', async () => {
const realProperties: Property[] = [
{ id: 'sans-serif', name: 'Sans-serif', selected: true },
{ id: 'serif', name: 'Serif', selected: false },
{ id: 'display', name: 'Display', selected: false },
{ id: 'handwriting', name: 'Handwriting', selected: true },
{ id: 'monospace', name: 'Monospace', selected: false },
const realProperties: Property<string>[] = [
{ id: 'sans-serif', name: 'Sans-serif', value: 'sans-serif', selected: true },
{ id: 'serif', name: 'Serif', value: 'serif', selected: false },
{ id: 'display', name: 'Display', value: 'display', selected: false },
{ id: 'handwriting', name: 'Handwriting', value: 'handwriting', selected: true },
{ id: 'monospace', name: 'Monospace', value: 'monospace', selected: false },
];
const filter = createTestFilter(realProperties);
render(CheckboxFilter, {

View File

@@ -0,0 +1,82 @@
<script lang="ts">
import { Input } from '$shared/shadcn/ui/input';
import { Label } from '$shared/shadcn/ui/label';
import {
Content as PopoverContent,
Root as PopoverRoot,
Trigger as PopoverTrigger,
} from '$shared/shadcn/ui/popover';
import { useId } from 'bits-ui';
import {
type Snippet,
tick,
} from 'svelte';
interface Props {
id: string;
value: string;
class?: string;
placeholder?: string;
label?: string;
children: Snippet<[{ id: string }]> | undefined;
}
let {
id = 'search-bar',
value = $bindable(),
class: className,
placeholder,
label,
children,
}: Props = $props();
let open = $state(false);
let triggerRef = $state<HTMLInputElement>(null!);
const contentId = useId(id);
function closeAndFocusTrigger() {
open = false;
tick().then(() => {
triggerRef?.focus();
});
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === 'Enter') {
event.preventDefault();
}
}
function handleInputClick() {
open = true;
tick().then(() => {
triggerRef?.focus();
});
}
</script>
<PopoverRoot>
<PopoverTrigger bind:ref={triggerRef}>
{#snippet child({ props })}
<div {...props} class="flex flex-row flex-1 w-full">
{#if label}
<Label for={id}>{label}</Label>
{/if}
<Input
id={id}
placeholder={placeholder}
bind:value={value}
onkeydown={handleKeyDown}
class="flex flex-row flex-1"
/>
</div>
{/snippet}
</PopoverTrigger>
<PopoverContent
onOpenAutoFocus={e => e.preventDefault()}
class="w-max"
>
{@render children?.({ id: contentId })}
</PopoverContent>
</PopoverRoot>

View File

@@ -28,6 +28,11 @@ interface Props {
* @default 80
*/
itemHeight?: number | ((index: number) => number);
/**
* Optional overscan value for the virtual list.
* @default 5
*/
overscan?: number;
/**
* Optional CSS class string for styling the container
* (follows shadcn convention for className prop)
@@ -48,7 +53,7 @@ interface Props {
children: Snippet<[{ item: T; index: number }]>;
}
let { items, itemHeight = 80, class: className, children }: Props = $props();
let { items, itemHeight = 80, overscan = 5, class: className, children }: Props = $props();
let activeIndex = $state(0);
const itemRefs = new Map<number, HTMLElement>();
@@ -56,6 +61,7 @@ const itemRefs = new Map<number, HTMLElement>();
const virtual = createVirtualizer(() => ({
count: items.length,
estimateSize: typeof itemHeight === 'function' ? itemHeight : () => itemHeight,
overscan,
}));
function registerItem(node: HTMLElement, index: number) {

View File

@@ -6,10 +6,12 @@
import CheckboxFilter from './CheckboxFilter/CheckboxFilter.svelte';
import ComboControl from './ComboControl/ComboControl.svelte';
import SearchBar from './SearchBar/SearchBar.svelte';
import VirtualList from './VirtualList/VirtualList.svelte';
export {
CheckboxFilter,
ComboControl,
SearchBar,
VirtualList,
};

View File

@@ -1,4 +1,12 @@
<script lang="ts">
import {
FilterControls,
Filters,
} from '$features/GetFonts';
import {
Content as SidebarContent,
Root as SidebarRoot,
} from '$shared/shadcn/ui/sidebar/index';
/**
* FiltersSidebar Component
*
@@ -6,19 +14,25 @@
* for font filtering operations. Organized into two sections:
*
* - Filters: Category-based filter groups (providers, subsets, categories)
* - Controls: Apply/Reset buttons for filter actions
* - Controls: Reset button for filter actions
*
* Features:
* - Loading indicator during font fetch operations
* - Empty state message when no fonts match filters
* - Error display for failed font operations
* - Responsive sidebar behavior via shadcn Sidebar component
*
* Uses Sidebar.Root from shadcn for responsive sidebar behavior including
* mobile drawer and desktop persistent sidebar modes.
*/
import * as Sidebar from '$shared/shadcn/ui/sidebar/index';
import Controls from './Controls.svelte';
import Filters from './Filters.svelte';
</script>
<Sidebar.Root>
<Sidebar.Content class="p-2">
<SidebarRoot>
<SidebarContent class="p-2">
<!-- Filter groups -->
<Filters />
<Controls />
</Sidebar.Content>
</Sidebar.Root>
<!-- Action buttons -->
<FilterControls />
</SidebarContent>
</SidebarRoot>

View File

@@ -1,12 +1,17 @@
<script lang="ts">
import { FontSearch } from '$features/GetFonts';
import SetupFontMenu from '$features/SetupFont/ui/SetupFontMenu.svelte';
import * as Item from '$shared/shadcn/ui/item';
import {
Content as ItemContent,
Root as ItemRoot,
} from '$shared/shadcn/ui/item';
</script>
<div class="w-full p-2">
<Item.Root variant="outline" class="w-full p-2.5">
<Item.Content>
<ItemRoot variant="outline" class="w-full p-2.5">
<ItemContent class="flex flex-row justify-center items-center">
<SetupFontMenu />
</Item.Content>
</Item.Root>
<FontSearch />
</ItemContent>
</ItemRoot>
</div>