Compare commits
40 Commits
1ebab2d77b
...
8d1d1cd60f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d1d1cd60f | ||
|
|
fb5c15ec32 | ||
|
|
955cc66916 | ||
|
|
a9cdd15787 | ||
|
|
76172aaa6b | ||
|
|
7146328982 | ||
|
|
52ecb9e304 | ||
|
|
30cb9ada1a | ||
|
|
4eeb43fa34 | ||
|
|
ad6ba4f0a0 | ||
|
|
170c8546d3 | ||
|
|
2f15148cdb | ||
|
|
a29b80efbb | ||
|
|
91451f7886 | ||
|
|
99d4b4e29a | ||
|
|
d9d45bf9fb | ||
|
|
4810c2b228 | ||
|
|
4c9b9f631f | ||
|
|
5fcb381b11 | ||
|
|
e098da2dbb | ||
|
|
1a76e9387a | ||
|
|
0f1eb489ed | ||
|
|
6e8376b8fc | ||
|
|
d81af0a77b | ||
|
|
77de829b04 | ||
|
|
7630802363 | ||
|
|
43175fd52a | ||
|
|
9598d8c3e4 | ||
|
|
c863bea2dc | ||
|
|
ea1f46f780 | ||
|
|
bdb67157fd | ||
|
|
e198e967ab | ||
|
|
e1af950442 | ||
|
|
13509a4145 | ||
|
|
09111a7c61 | ||
|
|
b13c0d268b | ||
|
|
1990860717 | ||
|
|
6f7e863b13 | ||
|
|
8ad29fd3a8 | ||
|
|
de2688de5a |
@@ -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>
|
||||
|
||||
17
src/app/providers/QueryProvider.svelte
Normal file
17
src/app/providers/QueryProvider.svelte
Normal 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>
|
||||
1
src/app/providers/index.ts
Normal file
1
src/app/providers/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as QueryProvider } from './QueryProvider.svelte';
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -23,10 +23,3 @@ export type {
|
||||
FontshareParams,
|
||||
FontshareResponse,
|
||||
} from './fontshare/fontshare';
|
||||
|
||||
export {
|
||||
normalizeFontshareFont,
|
||||
normalizeFontshareFonts,
|
||||
normalizeGoogleFont,
|
||||
normalizeGoogleFonts,
|
||||
} from './normalize/normalize';
|
||||
|
||||
@@ -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';
|
||||
|
||||
6
src/entities/Font/lib/index.ts
Normal file
6
src/entities/Font/lib/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
normalizeFontshareFont,
|
||||
normalizeFontshareFonts,
|
||||
normalizeGoogleFont,
|
||||
normalizeGoogleFonts,
|
||||
} from './normalize/normalize';
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
43
src/entities/Font/model/index.ts
Normal file
43
src/entities/Font/model/index.ts
Normal 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';
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
274
src/entities/Font/model/services/fetchGoogleFonts.svelte.ts
Normal file
274
src/entities/Font/model/services/fetchGoogleFonts.svelte.ts
Normal 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);
|
||||
}
|
||||
2
src/entities/Font/model/services/index.ts
Normal file
2
src/entities/Font/model/services/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { fetchFontshareFontsQuery } from './fetchFontshareFonts.svelte';
|
||||
export { fetchGoogleFontsQuery } from './fetchGoogleFonts.svelte';
|
||||
156
src/entities/Font/model/store/baseFontStore.svelte.ts
Normal file
156
src/entities/Font/model/store/baseFontStore.svelte.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
32
src/entities/Font/model/store/fontshareStore.svelte.ts
Normal file
32
src/entities/Font/model/store/fontshareStore.svelte.ts
Normal 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();
|
||||
27
src/entities/Font/model/store/googleFontsStore.svelte.ts
Normal file
27
src/entities/Font/model/store/googleFontsStore.svelte.ts
Normal 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();
|
||||
19
src/entities/Font/model/store/index.ts
Normal file
19
src/entities/Font/model/store/index.ts
Normal 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';
|
||||
43
src/entities/Font/model/store/types.ts
Normal file
43
src/entities/Font/model/store/types.ts
Normal 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;
|
||||
29
src/entities/Font/model/store/unifiedFontStore.svelte.ts
Normal file
29
src/entities/Font/model/store/unifiedFontStore.svelte.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -55,5 +55,4 @@ export type {
|
||||
FontCollectionFilters,
|
||||
FontCollectionSort,
|
||||
FontCollectionState,
|
||||
FontCollectionStore,
|
||||
} from './store';
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
46
src/entities/Font/ui/FontList/FontList.svelte
Normal file
46
src/entities/Font/ui/FontList/FontList.svelte
Normal 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}
|
||||
3
src/entities/Font/ui/index.ts
Normal file
3
src/entities/Font/ui/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import FontList from './FontList/FontList.svelte';
|
||||
|
||||
export { FontList };
|
||||
@@ -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';
|
||||
@@ -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'],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import type { Property } from '$shared/lib';
|
||||
|
||||
export interface FilterGroupConfig {
|
||||
id: string;
|
||||
label: string;
|
||||
properties: Property[];
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
6
src/features/GetFonts/lib/index.ts
Normal file
6
src/features/GetFonts/lib/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
createFilterManager,
|
||||
type FilterManager,
|
||||
} from './filterManager/filterManager.svelte';
|
||||
|
||||
export { mapManagerToParams } from './mapper/mapManagerToParams';
|
||||
12
src/features/GetFonts/lib/mapper/mapManagerToParams.ts
Normal file
12
src/features/GetFonts/lib/mapper/mapManagerToParams.ts
Normal 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) ?? [],
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
6
src/features/GetFonts/model/index.ts
Normal file
6
src/features/GetFonts/model/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export type {
|
||||
FilterConfig,
|
||||
FilterGroupConfig,
|
||||
} from './types/filter';
|
||||
|
||||
export { filterManager } from './state/manager.svelte';
|
||||
29
src/features/GetFonts/model/state/manager.svelte.ts
Normal file
29
src/features/GetFonts/model/state/manager.svelte.ts
Normal 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);
|
||||
12
src/features/GetFonts/model/types/filter.ts
Normal file
12
src/features/GetFonts/model/types/filter.ts
Normal 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>[];
|
||||
}
|
||||
@@ -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)}
|
||||
@@ -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>
|
||||
38
src/features/GetFonts/ui/FontSearch/FontSearch.svelte
Normal file
38
src/features/GetFonts/ui/FontSearch/FontSearch.svelte
Normal 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>
|
||||
9
src/features/GetFonts/ui/index.ts
Normal file
9
src/features/GetFonts/ui/index.ts
Normal 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,
|
||||
};
|
||||
@@ -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 };
|
||||
|
||||
1
src/features/SetupFont/lib/index.ts
Normal file
1
src/features/SetupFont/lib/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { createTypographyControlManager } from './controlManager/controlManager.svelte';
|
||||
@@ -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;
|
||||
|
||||
16
src/features/SetupFont/model/index.ts
Normal file
16
src/features/SetupFont/model/index.ts
Normal 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';
|
||||
@@ -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,
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
26
src/shared/api/queryClient.ts
Normal file
26
src/shared/api/queryClient.ts
Normal 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),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
// },
|
||||
// };
|
||||
// }
|
||||
@@ -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 => ({
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,3 +18,5 @@ export {
|
||||
type Virtualizer,
|
||||
type VirtualizerOptions,
|
||||
} from './createVirtualizer/createVirtualizer.svelte';
|
||||
|
||||
export { createDebouncedState } from './createDebouncedState/createDebouncedState.svelte';
|
||||
|
||||
77
src/shared/lib/utils/debounce/debounce.test.ts
Normal file
77
src/shared/lib/utils/debounce/debounce.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
43
src/shared/lib/utils/debounce/debounce.ts
Normal file
43
src/shared/lib/utils/debounce/debounce.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
1
src/shared/lib/utils/debounce/index.ts
Normal file
1
src/shared/lib/utils/debounce/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { debounce } from './debounce';
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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, {
|
||||
|
||||
82
src/shared/ui/SearchBar/SearchBar.svelte
Normal file
82
src/shared/ui/SearchBar/SearchBar.svelte
Normal 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>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user