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.
|
* layout shell. This is the root component mounted by the application.
|
||||||
*
|
*
|
||||||
* Structure:
|
* Structure:
|
||||||
|
* - QueryProvider provides TanStack Query client for data fetching
|
||||||
* - Layout provides sidebar, header/footer, and page container
|
* - Layout provides sidebar, header/footer, and page container
|
||||||
* - Page renders the current route content
|
* - Page renders the current route content
|
||||||
*/
|
*/
|
||||||
import Page from '$routes/Page.svelte';
|
import Page from '$routes/Page.svelte';
|
||||||
|
import { QueryProvider } from './providers';
|
||||||
import Layout from './ui/Layout.svelte';
|
import Layout from './ui/Layout.svelte';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Layout>
|
<QueryProvider>
|
||||||
|
<Layout>
|
||||||
<Page />
|
<Page />
|
||||||
</Layout>
|
</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.
|
* Handles API requests to Fontshare API for fetching font metadata.
|
||||||
* Provides error handling, pagination support, and type-safe responses.
|
* 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
|
* @see https://fontshare.com
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -38,7 +42,7 @@ export interface FontshareParams extends QueryParams {
|
|||||||
/**
|
/**
|
||||||
* Search query to filter fonts
|
* Search query to filter fonts
|
||||||
*/
|
*/
|
||||||
search?: string;
|
q?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -77,7 +81,7 @@ export async function fetchFontshareFonts(
|
|||||||
params: FontshareParams = {},
|
params: FontshareParams = {},
|
||||||
): Promise<FontshareResponse> {
|
): Promise<FontshareResponse> {
|
||||||
const queryString = buildQueryString(params);
|
const queryString = buildQueryString(params);
|
||||||
const url = `https://api.fontshare.com/v2${queryString}`;
|
const url = `https://api.fontshare.com/v2/fonts${queryString}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await api.get<FontshareResponse>(url);
|
const response = await api.get<FontshareResponse>(url);
|
||||||
@@ -107,7 +111,7 @@ export async function fetchFontshareFontBySlug(
|
|||||||
slug: string,
|
slug: string,
|
||||||
): Promise<FontshareFont | undefined> {
|
): Promise<FontshareFont | undefined> {
|
||||||
const response = await fetchFontshareFonts();
|
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
|
* @example
|
||||||
* ```ts
|
* ```ts
|
||||||
* const allFonts = await fetchAllFontshareFonts();
|
* const allFonts = await fetchAllFontshareFonts();
|
||||||
* console.log(`Found ${allFonts.items.length} fonts`);
|
* console.log(`Found ${allFonts.fonts.length} fonts`);
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export async function fetchAllFontshareFonts(
|
export async function fetchAllFontshareFonts(
|
||||||
@@ -137,10 +141,10 @@ export async function fetchAllFontshareFonts(
|
|||||||
limit,
|
limit,
|
||||||
});
|
});
|
||||||
|
|
||||||
allFonts.push(...response.items);
|
allFonts.push(...response.fonts);
|
||||||
|
|
||||||
// Check if we've fetched all items
|
// Check if we've fetched all items
|
||||||
if (response.items.length < limit) {
|
if (response.fonts.length < limit) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,6 +156,6 @@ export async function fetchAllFontshareFonts(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...firstResponse,
|
...firstResponse,
|
||||||
items: allFonts,
|
fonts: allFonts,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,10 @@
|
|||||||
* Handles API requests to Google Fonts API for fetching font metadata.
|
* Handles API requests to Google Fonts API for fetching font metadata.
|
||||||
* Provides error handling, retry logic, and type-safe responses.
|
* 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
|
* @see https://developers.google.com/fonts/docs/developer_api
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -20,7 +24,7 @@ import type {
|
|||||||
*/
|
*/
|
||||||
export interface GoogleFontsParams extends QueryParams {
|
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;
|
key?: string;
|
||||||
/**
|
/**
|
||||||
@@ -38,11 +42,11 @@ export interface GoogleFontsParams extends QueryParams {
|
|||||||
/**
|
/**
|
||||||
* Sort order for results
|
* Sort order for results
|
||||||
*/
|
*/
|
||||||
sort?: 'popularity' | 'alpha' | 'date' | 'style';
|
sort?: 'alpha' | 'date' | 'popularity' | 'style' | 'trending';
|
||||||
/**
|
/**
|
||||||
* Cap the number of fonts returned
|
* Cap the number of fonts returned
|
||||||
*/
|
*/
|
||||||
capability?: string;
|
capability?: 'VF' | 'WOFF2';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -59,8 +63,10 @@ export type GoogleFontItem = FontItem;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Google Fonts API base URL
|
* 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
|
* Fetch fonts from Google Fonts API
|
||||||
|
|||||||
@@ -23,10 +23,3 @@ export type {
|
|||||||
FontshareParams,
|
FontshareParams,
|
||||||
FontshareResponse,
|
FontshareResponse,
|
||||||
} from './fontshare/fontshare';
|
} from './fontshare/fontshare';
|
||||||
|
|
||||||
export {
|
|
||||||
normalizeFontshareFont,
|
|
||||||
normalizeFontshareFonts,
|
|
||||||
normalizeGoogleFont,
|
|
||||||
normalizeGoogleFonts,
|
|
||||||
} from './normalize/normalize';
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export {
|
|||||||
normalizeFontshareFonts,
|
normalizeFontshareFonts,
|
||||||
normalizeGoogleFont,
|
normalizeGoogleFont,
|
||||||
normalizeGoogleFonts,
|
normalizeGoogleFonts,
|
||||||
} from './api/normalize/normalize';
|
} from './lib/normalize/normalize';
|
||||||
export type {
|
export type {
|
||||||
// Domain types
|
// Domain types
|
||||||
FontCategory,
|
FontCategory,
|
||||||
@@ -29,7 +29,6 @@ export type {
|
|||||||
FontCollectionSort,
|
FontCollectionSort,
|
||||||
// Store types
|
// Store types
|
||||||
FontCollectionState,
|
FontCollectionState,
|
||||||
FontCollectionStore,
|
|
||||||
FontFeatures,
|
FontFeatures,
|
||||||
FontFiles,
|
FontFiles,
|
||||||
FontItem,
|
FontItem,
|
||||||
@@ -43,6 +42,7 @@ export type {
|
|||||||
FontshareFont,
|
FontshareFont,
|
||||||
FontshareLink,
|
FontshareLink,
|
||||||
FontsharePublisher,
|
FontsharePublisher,
|
||||||
|
FontshareStore,
|
||||||
FontshareStyle,
|
FontshareStyle,
|
||||||
FontshareStyleProperties,
|
FontshareStyleProperties,
|
||||||
FontshareTag,
|
FontshareTag,
|
||||||
@@ -57,4 +57,19 @@ export type {
|
|||||||
// Normalization types
|
// Normalization types
|
||||||
UnifiedFont,
|
UnifiedFont,
|
||||||
UnifiedFontVariant,
|
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,
|
it,
|
||||||
} from 'vitest';
|
} from 'vitest';
|
||||||
import type {
|
import type {
|
||||||
|
FontItem,
|
||||||
FontshareFont,
|
FontshareFont,
|
||||||
GoogleFontItem,
|
GoogleFontItem,
|
||||||
} from '../../model/types';
|
} from '../../model/types';
|
||||||
@@ -82,42 +83,42 @@ describe('Font Normalization', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('handles sans-serif category', () => {
|
it('handles sans-serif category', () => {
|
||||||
const font = { ...mockGoogleFont, category: 'sans-serif' };
|
const font: FontItem = { ...mockGoogleFont, category: 'sans-serif' };
|
||||||
const result = normalizeGoogleFont(font);
|
const result = normalizeGoogleFont(font);
|
||||||
|
|
||||||
expect(result.category).toBe('sans-serif');
|
expect(result.category).toBe('sans-serif');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles serif category', () => {
|
it('handles serif category', () => {
|
||||||
const font = { ...mockGoogleFont, category: 'serif' };
|
const font: FontItem = { ...mockGoogleFont, category: 'serif' };
|
||||||
const result = normalizeGoogleFont(font);
|
const result = normalizeGoogleFont(font);
|
||||||
|
|
||||||
expect(result.category).toBe('serif');
|
expect(result.category).toBe('serif');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles display category', () => {
|
it('handles display category', () => {
|
||||||
const font = { ...mockGoogleFont, category: 'display' };
|
const font: FontItem = { ...mockGoogleFont, category: 'display' };
|
||||||
const result = normalizeGoogleFont(font);
|
const result = normalizeGoogleFont(font);
|
||||||
|
|
||||||
expect(result.category).toBe('display');
|
expect(result.category).toBe('display');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles handwriting category', () => {
|
it('handles handwriting category', () => {
|
||||||
const font = { ...mockGoogleFont, category: 'handwriting' };
|
const font: FontItem = { ...mockGoogleFont, category: 'handwriting' };
|
||||||
const result = normalizeGoogleFont(font);
|
const result = normalizeGoogleFont(font);
|
||||||
|
|
||||||
expect(result.category).toBe('handwriting');
|
expect(result.category).toBe('handwriting');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles cursive category (maps to 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);
|
const result = normalizeGoogleFont(font);
|
||||||
|
|
||||||
expect(result.category).toBe('handwriting');
|
expect(result.category).toBe('handwriting');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles monospace category', () => {
|
it('handles monospace category', () => {
|
||||||
const font = { ...mockGoogleFont, category: 'monospace' };
|
const font: FontItem = { ...mockGoogleFont, category: 'monospace' };
|
||||||
const result = normalizeGoogleFont(font);
|
const result = normalizeGoogleFont(font);
|
||||||
|
|
||||||
expect(result.category).toBe('monospace');
|
expect(result.category).toBe('monospace');
|
||||||
@@ -522,22 +523,6 @@ describe('Font Normalization', () => {
|
|||||||
|
|
||||||
expect(result.category).toBe('sans-serif'); // fallback
|
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
|
* DOMAIN TYPES
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
*/
|
*/
|
||||||
|
import type { FontCategory as FontshareFontCategory } from './fontshare';
|
||||||
|
import type { FontCategory as GoogleFontCategory } from './google';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Font category
|
* Font category
|
||||||
*/
|
*/
|
||||||
export type FontCategory = 'sans-serif' | 'serif' | 'display' | 'handwriting' | 'monospace';
|
export type FontCategory = GoogleFontCategory | FontshareFontCategory;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Font provider
|
* Font provider
|
||||||
@@ -18,3 +20,15 @@ export type FontProvider = 'google' | 'fontshare';
|
|||||||
* Font subset
|
* Font subset
|
||||||
*/
|
*/
|
||||||
export type FontSubset = 'latin' | 'latin-ext' | 'cyrillic' | 'greek' | 'arabic' | 'devanagari';
|
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
|
* FONTHARE API TYPES
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
*/
|
*/
|
||||||
|
export const FONTSHARE_API_URL = 'https://api.fontshare.com/v2/fonts' as const;
|
||||||
|
|
||||||
import type { CollectionApiModel } from '$shared/types/collection';
|
export type FontCategory = 'sans' | 'serif' | 'slab' | 'display' | 'handwritten' | 'script';
|
||||||
|
|
||||||
export const FONTSHARE_API_URL = 'https://api.fontshare.com/v2' as const;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Model of Fontshare API response
|
* Model of Fontshare API response
|
||||||
* @see https://fontshare.com
|
* @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
|
* 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
|
* Model of google fonts api response
|
||||||
*/
|
*/
|
||||||
@@ -29,7 +31,7 @@ export interface FontItem {
|
|||||||
* Font category classification (e.g., "sans-serif", "serif", "display", "handwriting", "monospace")
|
* Font category classification (e.g., "sans-serif", "serif", "display", "handwriting", "monospace")
|
||||||
* Useful for grouping and filtering fonts by style
|
* Useful for grouping and filtering fonts by style
|
||||||
*/
|
*/
|
||||||
category: string;
|
category: FontCategory;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Available font variants for this font family
|
* Available font variants for this font family
|
||||||
|
|||||||
@@ -55,5 +55,4 @@ export type {
|
|||||||
FontCollectionFilters,
|
FontCollectionFilters,
|
||||||
FontCollectionSort,
|
FontCollectionSort,
|
||||||
FontCollectionState,
|
FontCollectionState,
|
||||||
FontCollectionStore,
|
|
||||||
} from './store';
|
} from './store';
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import type {
|
import type {
|
||||||
FontCategory,
|
FontCategory,
|
||||||
FontProvider,
|
FontProvider,
|
||||||
|
FontSubset,
|
||||||
} from './common';
|
} from './common';
|
||||||
import type { UnifiedFont } from './normalize';
|
import type { UnifiedFont } from './normalize';
|
||||||
|
|
||||||
@@ -27,13 +28,13 @@ export interface FontCollectionState {
|
|||||||
*/
|
*/
|
||||||
export interface FontCollectionFilters {
|
export interface FontCollectionFilters {
|
||||||
/** Search query */
|
/** Search query */
|
||||||
searchQuery?: string;
|
searchQuery: string;
|
||||||
/** Filter by provider */
|
/** Filter by providers */
|
||||||
provider?: FontProvider;
|
providers?: FontProvider[];
|
||||||
/** Filter by category */
|
/** Filter by categories */
|
||||||
category?: FontCategory;
|
categories?: FontCategory[];
|
||||||
/** Filter by subsets */
|
/** Filter by subsets */
|
||||||
subsets?: string[];
|
subsets?: FontSubset[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -45,41 +46,3 @@ export interface FontCollectionSort {
|
|||||||
/** Sort direction */
|
/** Sort direction */
|
||||||
direction: 'asc' | 'desc';
|
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 {
|
export {
|
||||||
createFilterManager,
|
createFilterManager,
|
||||||
type FilterManager,
|
type FilterManager,
|
||||||
} from './lib/filterManager/filterManager.svelte';
|
mapManagerToParams,
|
||||||
|
} from './lib';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
FONT_CATEGORIES,
|
FONT_CATEGORIES,
|
||||||
FONT_PROVIDERS,
|
FONT_PROVIDERS,
|
||||||
FONT_SUBSETS,
|
FONT_SUBSETS,
|
||||||
} from './model/const/const';
|
} from './model/const/const';
|
||||||
export type { FilterGroupConfig } from './model/const/types/common';
|
|
||||||
export { filterManager } from './model/state/manager.svelte';
|
export { filterManager } from './model/state/manager.svelte';
|
||||||
|
|
||||||
|
export {
|
||||||
|
FilterControls,
|
||||||
|
Filters,
|
||||||
|
FontSearch,
|
||||||
|
} from './ui';
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
import { createFilter } from '$shared/lib';
|
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.
|
* 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
|
// Create filter instances upfront
|
||||||
const groups = $state(
|
const groups = $state(
|
||||||
configs.map(config => ({
|
config.groups.map(config => ({
|
||||||
id: config.id,
|
id: config.id,
|
||||||
label: config.label,
|
label: config.label,
|
||||||
instance: createFilter({ properties: config.properties }),
|
instance: createFilter({ properties: config.properties }),
|
||||||
@@ -20,6 +23,21 @@ export function createFilterManager(configs: FilterGroupConfig[]) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return {
|
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)
|
// Direct array reference (reactive)
|
||||||
get groups() {
|
get groups() {
|
||||||
return 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';
|
import type { Property } from '$shared/lib';
|
||||||
|
|
||||||
export const FONT_CATEGORIES: Property[] = [
|
export const FONT_CATEGORIES: Property<FontCategory>[] = [
|
||||||
{
|
{
|
||||||
id: 'serif',
|
id: 'serif',
|
||||||
name: 'Serif',
|
name: 'Serif',
|
||||||
|
value: 'serif',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'sans-serif',
|
id: 'sans-serif',
|
||||||
name: 'Sans-serif',
|
name: 'Sans-serif',
|
||||||
|
value: 'sans-serif',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'display',
|
id: 'display',
|
||||||
name: 'Display',
|
name: 'Display',
|
||||||
|
value: 'display',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'handwriting',
|
id: 'handwriting',
|
||||||
name: 'Handwriting',
|
name: 'Handwriting',
|
||||||
|
value: 'handwriting',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'monospace',
|
id: 'monospace',
|
||||||
name: 'Monospace',
|
name: 'Monospace',
|
||||||
|
value: 'monospace',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'script',
|
id: 'script',
|
||||||
name: 'Script',
|
name: 'Script',
|
||||||
|
value: 'script',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'slab',
|
id: 'slab',
|
||||||
name: 'Slab',
|
name: 'Slab',
|
||||||
|
value: 'slab',
|
||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const FONT_PROVIDERS: Property[] = [
|
export const FONT_PROVIDERS: Property<FontProvider>[] = [
|
||||||
{
|
{
|
||||||
id: 'google',
|
id: 'google',
|
||||||
name: 'Google Fonts',
|
name: 'Google Fonts',
|
||||||
|
value: 'google',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'fontshare',
|
id: 'fontshare',
|
||||||
name: 'Fontshare',
|
name: 'Fontshare',
|
||||||
|
value: 'fontshare',
|
||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const FONT_SUBSETS: Property[] = [
|
export const FONT_SUBSETS: Property<FontSubset>[] = [
|
||||||
{
|
{
|
||||||
id: 'latin',
|
id: 'latin',
|
||||||
name: 'Latin',
|
name: 'Latin',
|
||||||
|
value: 'latin',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'latin-ext',
|
id: 'latin-ext',
|
||||||
name: 'Latin Extended',
|
name: 'Latin Extended',
|
||||||
|
value: 'latin-ext',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'cyrillic',
|
id: 'cyrillic',
|
||||||
name: 'Cyrillic',
|
name: 'Cyrillic',
|
||||||
|
value: 'cyrillic',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'greek',
|
id: 'greek',
|
||||||
name: 'Greek',
|
name: 'Greek',
|
||||||
|
value: 'greek',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'arabic',
|
id: 'arabic',
|
||||||
name: 'Arabic',
|
name: 'Arabic',
|
||||||
|
value: 'arabic',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'devanagari',
|
id: 'devanagari',
|
||||||
name: 'Devanagari',
|
name: 'Devanagari',
|
||||||
|
value: 'devanagari',
|
||||||
},
|
},
|
||||||
] as const;
|
] 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 subset: Character subsets available (Latin, Latin Extended, etc.)
|
||||||
* - Font category: Serif, Sans-serif, Display, etc.
|
* - Font category: Serif, Sans-serif, Display, etc.
|
||||||
*
|
*
|
||||||
* Uses $derived for reactive access to filter states, ensuring UI updates
|
* This component handles reactive sync between filterManager selections
|
||||||
* when selections change through any means (sidebar, programmatically, etc.).
|
* 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';
|
import { CheckboxFilter } from '$shared/ui';
|
||||||
|
import { filterManager } from '../../model';
|
||||||
$inspect(filterManager.groups).with(console.trace);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#each filterManager.groups as group (group.id)}
|
{#each filterManager.groups as group (group.id)}
|
||||||
@@ -1,17 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { Button } from '$shared/shadcn/ui/button';
|
||||||
|
import { filterManager } from '../../model';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Controls Component
|
* Controls Component
|
||||||
*
|
*
|
||||||
* Action button group for filter operations. Provides two buttons:
|
* Action button group for filter operations. Provides two buttons:
|
||||||
*
|
*
|
||||||
* - Reset: Clears all active filters (outline variant for secondary action)
|
* - 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>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-row gap-2">
|
<div class="flex flex-row gap-2">
|
||||||
@@ -22,7 +19,4 @@ import { Button } from '$shared/shadcn/ui/button';
|
|||||||
>
|
>
|
||||||
Reset
|
Reset
|
||||||
</Button>
|
</Button>
|
||||||
<Button class="flex-1 cursor-pointer">
|
|
||||||
Apply
|
|
||||||
</Button>
|
|
||||||
</div>
|
</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';
|
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 };
|
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 DEFAULT_FONT_SIZE = 16;
|
||||||
export const MIN_FONT_SIZE = 8;
|
export const MIN_FONT_SIZE = 8;
|
||||||
export const MAX_FONT_SIZE = 100;
|
export const MAX_FONT_SIZE = 100;
|
||||||
export const FONT_SIZE_STEP = 1;
|
export const FONT_SIZE_STEP = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Font weight constants
|
||||||
|
*/
|
||||||
export const DEFAULT_FONT_WEIGHT = 400;
|
export const DEFAULT_FONT_WEIGHT = 400;
|
||||||
export const MIN_FONT_WEIGHT = 100;
|
export const MIN_FONT_WEIGHT = 100;
|
||||||
export const MAX_FONT_WEIGHT = 900;
|
export const MAX_FONT_WEIGHT = 900;
|
||||||
export const FONT_WEIGHT_STEP = 100;
|
export const FONT_WEIGHT_STEP = 100;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Line height constants
|
||||||
|
*/
|
||||||
export const DEFAULT_LINE_HEIGHT = 1.5;
|
export const DEFAULT_LINE_HEIGHT = 1.5;
|
||||||
export const MIN_LINE_HEIGHT = 1;
|
export const MIN_LINE_HEIGHT = 1;
|
||||||
export const MAX_LINE_HEIGHT = 2;
|
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 type { ControlModel } from '$shared/lib';
|
||||||
|
import { createTypographyControlManager } from '../../lib';
|
||||||
import {
|
import {
|
||||||
DEFAULT_FONT_SIZE,
|
DEFAULT_FONT_SIZE,
|
||||||
DEFAULT_FONT_WEIGHT,
|
DEFAULT_FONT_WEIGHT,
|
||||||
@@ -1,14 +1,19 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* Component containing controls for setting up font properties.
|
||||||
|
*/
|
||||||
import { Separator } from '$shared/shadcn/ui/separator/index';
|
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 { ComboControl } from '$shared/ui';
|
||||||
import { controlManager } from '../model/state/manager.svetle';
|
import { controlManager } from '../model';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="w-full p-2 flex flex-row items-center gap-2">
|
<div class="p-2 flex flex-row items-center gap-2">
|
||||||
<Sidebar.Trigger />
|
<SidebarTrigger />
|
||||||
<Separator orientation="vertical" class="h-full" />
|
<Separator orientation="vertical" class="h-full" />
|
||||||
|
<div class="flex flex-row gap-2">
|
||||||
{#each controlManager.controls as control (control.id)}
|
{#each controlManager.controls as control (control.id)}
|
||||||
<ComboControl control={control.instance} />
|
<ComboControl control={control.instance} />
|
||||||
{/each}
|
{/each}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,16 +1,27 @@
|
|||||||
<script>
|
<script lang="ts">
|
||||||
/**
|
/**
|
||||||
* Page Component
|
* Page Component
|
||||||
*
|
*
|
||||||
* Main page route component. This is the default route that users see when
|
* Main page route component. Displays the font list and allows testing
|
||||||
* accessing the application. Currently displays a welcome message.
|
* 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
|
* Receives unifiedFontStore from context created in Layout.svelte.
|
||||||
* as the font comparison and filtering features are implemented.
|
|
||||||
*/
|
*/
|
||||||
|
// 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>
|
</script>
|
||||||
|
|
||||||
<h1>Welcome to Svelte + Vite</h1>
|
<!-- Font List -->
|
||||||
<p>
|
<FontList showEmpty={true} />
|
||||||
Visit <a href="https://svelte.dev/docs">svelte.dev/docs</a> to read the documentation
|
|
||||||
</p>
|
|
||||||
|
|||||||
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
|
* Property identifier
|
||||||
*/
|
*/
|
||||||
@@ -7,29 +7,29 @@ export interface Property {
|
|||||||
* Property name
|
* Property name
|
||||||
*/
|
*/
|
||||||
name: string;
|
name: string;
|
||||||
|
/**
|
||||||
|
* Property value
|
||||||
|
*/
|
||||||
|
value: TValue;
|
||||||
/**
|
/**
|
||||||
* Property selected state
|
* Property selected state
|
||||||
*/
|
*/
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FilterModel {
|
export interface FilterModel<TValue extends string> {
|
||||||
/**
|
|
||||||
* Search query
|
|
||||||
*/
|
|
||||||
searchQuery?: string;
|
|
||||||
/**
|
/**
|
||||||
* Properties
|
* Properties
|
||||||
*/
|
*/
|
||||||
properties: Property[];
|
properties: Property<TValue>[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a filter store.
|
* Create a filter store.
|
||||||
* @param initialState - Initial state of the filter store
|
* @param initialState - Initial state of the filter store
|
||||||
*/
|
*/
|
||||||
export function createFilter<T extends FilterModel>(
|
export function createFilter<TValue extends string>(
|
||||||
initialState: T,
|
initialState: FilterModel<TValue>,
|
||||||
) {
|
) {
|
||||||
let properties = $state(
|
let properties = $state(
|
||||||
initialState.properties.map(p => ({
|
initialState.properties.map(p => ({
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ describe('createFilter - Filter Logic', () => {
|
|||||||
return Array.from({ length: count }, (_, i) => ({
|
return Array.from({ length: count }, (_, i) => ({
|
||||||
id: `prop-${i}`,
|
id: `prop-${i}`,
|
||||||
name: `Property ${i}`,
|
name: `Property ${i}`,
|
||||||
|
value: `Value ${i}`,
|
||||||
selected: selectedIndices.includes(i),
|
selected: selectedIndices.includes(i),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,16 +4,40 @@ import {
|
|||||||
} from '$shared/lib/utils';
|
} from '$shared/lib/utils';
|
||||||
|
|
||||||
export interface ControlDataModel {
|
export interface ControlDataModel {
|
||||||
|
/**
|
||||||
|
* Control value
|
||||||
|
*/
|
||||||
value: number;
|
value: number;
|
||||||
|
/**
|
||||||
|
* Minimal possible value
|
||||||
|
*/
|
||||||
min: number;
|
min: number;
|
||||||
|
/**
|
||||||
|
* Maximal possible value
|
||||||
|
*/
|
||||||
max: number;
|
max: number;
|
||||||
|
/**
|
||||||
|
* Step size for increase/decrease
|
||||||
|
*/
|
||||||
step: number;
|
step: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ControlModel extends ControlDataModel {
|
export interface ControlModel extends ControlDataModel {
|
||||||
|
/**
|
||||||
|
* Control identifier
|
||||||
|
*/
|
||||||
id: string;
|
id: string;
|
||||||
|
/**
|
||||||
|
* Area label for increase button
|
||||||
|
*/
|
||||||
increaseLabel: string;
|
increaseLabel: string;
|
||||||
|
/**
|
||||||
|
* Area label for decrease button
|
||||||
|
*/
|
||||||
decreaseLabel: string;
|
decreaseLabel: string;
|
||||||
|
/**
|
||||||
|
* Control area label
|
||||||
|
*/
|
||||||
controlLabel: string;
|
controlLabel: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,3 +18,5 @@ export {
|
|||||||
type Virtualizer,
|
type Virtualizer,
|
||||||
type VirtualizerOptions,
|
type VirtualizerOptions,
|
||||||
} from './createVirtualizer/createVirtualizer.svelte';
|
} 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,
|
type QueryParamValue,
|
||||||
} from './buildQueryString/buildQueryString';
|
} from './buildQueryString/buildQueryString';
|
||||||
export { clampNumber } from './clampNumber/clampNumber';
|
export { clampNumber } from './clampNumber/clampNumber';
|
||||||
|
export { debounce } from './debounce/debounce';
|
||||||
export { getDecimalPlaces } from './getDecimalPlaces/getDecimalPlaces';
|
export { getDecimalPlaces } from './getDecimalPlaces/getDecimalPlaces';
|
||||||
export { roundToStepPrecision } from './roundToStepPrecision/roundToStepPrecision';
|
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 { Badge } from '$shared/shadcn/ui/badge';
|
||||||
import { buttonVariants } from '$shared/shadcn/ui/button';
|
import { buttonVariants } from '$shared/shadcn/ui/button';
|
||||||
import { Checkbox } from '$shared/shadcn/ui/checkbox';
|
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 { Label } from '$shared/shadcn/ui/label';
|
||||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
|
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
@@ -66,13 +69,13 @@ const hasSelection = $derived(selectedCount > 0);
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Collapsible card wrapper with subtle hover state for affordance -->
|
<!-- Collapsible card wrapper with subtle hover state for affordance -->
|
||||||
<Collapsible.Root
|
<CollapsibleRoot
|
||||||
bind:open={isOpen}
|
bind:open={isOpen}
|
||||||
class="w-full rounded-lg border bg-card transition-colors hover:bg-accent/5"
|
class="w-full rounded-lg border bg-card transition-colors hover:bg-accent/5"
|
||||||
>
|
>
|
||||||
<!-- Trigger row: title, expand indicator, and optional count badge -->
|
<!-- Trigger row: title, expand indicator, and optional count badge -->
|
||||||
<div class="flex items-center justify-between px-4 py-2">
|
<div class="flex items-center justify-between px-4 py-2">
|
||||||
<Collapsible.Trigger
|
<CollapsibleTrigger
|
||||||
class={buttonVariants({
|
class={buttonVariants({
|
||||||
variant: 'ghost',
|
variant: 'ghost',
|
||||||
size: 'sm',
|
size: 'sm',
|
||||||
@@ -101,7 +104,7 @@ const hasSelection = $derived(selectedCount > 0);
|
|||||||
>
|
>
|
||||||
<ChevronDownIcon class="h-4 w-4" />
|
<ChevronDownIcon class="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
</Collapsible.Trigger>
|
</CollapsibleTrigger>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Expandable content with slide animation -->
|
<!-- Expandable content with slide animation -->
|
||||||
@@ -154,4 +157,4 @@ const hasSelection = $derived(selectedCount > 0);
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</Collapsible.Root>
|
</CollapsibleRoot>
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ describe('CheckboxFilter Component', () => {
|
|||||||
/**
|
/**
|
||||||
* Helper function to create a filter for testing
|
* Helper function to create a filter for testing
|
||||||
*/
|
*/
|
||||||
function createTestFilter(properties: Property[]) {
|
function createTestFilter<T extends string>(properties: Property<T>[]) {
|
||||||
return createFilter({ properties });
|
return createFilter({ properties });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,6 +44,7 @@ describe('CheckboxFilter Component', () => {
|
|||||||
return Array.from({ length: count }, (_, i) => ({
|
return Array.from({ length: count }, (_, i) => ({
|
||||||
id: `prop-${i}`,
|
id: `prop-${i}`,
|
||||||
name: `Property ${i}`,
|
name: `Property ${i}`,
|
||||||
|
value: `Value ${i}`,
|
||||||
selected: selectedIndices.includes(i),
|
selected: selectedIndices.includes(i),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -437,10 +438,11 @@ describe('CheckboxFilter Component', () => {
|
|||||||
|
|
||||||
describe('Edge Cases', () => {
|
describe('Edge Cases', () => {
|
||||||
it('handles long property names', () => {
|
it('handles long property names', () => {
|
||||||
const properties: Property[] = [
|
const properties: Property<string>[] = [
|
||||||
{
|
{
|
||||||
id: '1',
|
id: '1',
|
||||||
name: 'This is a very long property name that might wrap to multiple lines',
|
name: 'This is a very long property name that might wrap to multiple lines',
|
||||||
|
value: '1',
|
||||||
selected: false,
|
selected: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -458,10 +460,10 @@ describe('CheckboxFilter Component', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('handles special characters in property names', () => {
|
it('handles special characters in property names', () => {
|
||||||
const properties: Property[] = [
|
const properties: Property<string>[] = [
|
||||||
{ id: '1', name: 'Café & Restaurant', selected: true },
|
{ id: '1', name: 'Café & Restaurant', value: '1', selected: true },
|
||||||
{ id: '2', name: '100% Organic', selected: false },
|
{ id: '2', name: '100% Organic', value: '2', selected: false },
|
||||||
{ id: '3', name: '(Special) <Characters>', selected: false },
|
{ id: '3', name: '(Special) <Characters>', value: '3', selected: false },
|
||||||
];
|
];
|
||||||
const filter = createTestFilter(properties);
|
const filter = createTestFilter(properties);
|
||||||
render(CheckboxFilter, {
|
render(CheckboxFilter, {
|
||||||
@@ -475,8 +477,8 @@ describe('CheckboxFilter Component', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('handles single property filter', () => {
|
it('handles single property filter', () => {
|
||||||
const properties: Property[] = [
|
const properties: Property<string>[] = [
|
||||||
{ id: '1', name: 'Only One', selected: true },
|
{ id: '1', name: 'Only One', value: '1', selected: true },
|
||||||
];
|
];
|
||||||
const filter = createTestFilter(properties);
|
const filter = createTestFilter(properties);
|
||||||
render(CheckboxFilter, {
|
render(CheckboxFilter, {
|
||||||
@@ -527,12 +529,12 @@ describe('CheckboxFilter Component', () => {
|
|||||||
|
|
||||||
describe('Component Integration', () => {
|
describe('Component Integration', () => {
|
||||||
it('works correctly with real filter data', async () => {
|
it('works correctly with real filter data', async () => {
|
||||||
const realProperties: Property[] = [
|
const realProperties: Property<string>[] = [
|
||||||
{ id: 'sans-serif', name: 'Sans-serif', selected: true },
|
{ id: 'sans-serif', name: 'Sans-serif', value: 'sans-serif', selected: true },
|
||||||
{ id: 'serif', name: 'Serif', selected: false },
|
{ id: 'serif', name: 'Serif', value: 'serif', selected: false },
|
||||||
{ id: 'display', name: 'Display', selected: false },
|
{ id: 'display', name: 'Display', value: 'display', selected: false },
|
||||||
{ id: 'handwriting', name: 'Handwriting', selected: true },
|
{ id: 'handwriting', name: 'Handwriting', value: 'handwriting', selected: true },
|
||||||
{ id: 'monospace', name: 'Monospace', selected: false },
|
{ id: 'monospace', name: 'Monospace', value: 'monospace', selected: false },
|
||||||
];
|
];
|
||||||
const filter = createTestFilter(realProperties);
|
const filter = createTestFilter(realProperties);
|
||||||
render(CheckboxFilter, {
|
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
|
* @default 80
|
||||||
*/
|
*/
|
||||||
itemHeight?: number | ((index: number) => number);
|
itemHeight?: number | ((index: number) => number);
|
||||||
|
/**
|
||||||
|
* Optional overscan value for the virtual list.
|
||||||
|
* @default 5
|
||||||
|
*/
|
||||||
|
overscan?: number;
|
||||||
/**
|
/**
|
||||||
* Optional CSS class string for styling the container
|
* Optional CSS class string for styling the container
|
||||||
* (follows shadcn convention for className prop)
|
* (follows shadcn convention for className prop)
|
||||||
@@ -48,7 +53,7 @@ interface Props {
|
|||||||
children: Snippet<[{ item: T; index: number }]>;
|
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);
|
let activeIndex = $state(0);
|
||||||
const itemRefs = new Map<number, HTMLElement>();
|
const itemRefs = new Map<number, HTMLElement>();
|
||||||
@@ -56,6 +61,7 @@ const itemRefs = new Map<number, HTMLElement>();
|
|||||||
const virtual = createVirtualizer(() => ({
|
const virtual = createVirtualizer(() => ({
|
||||||
count: items.length,
|
count: items.length,
|
||||||
estimateSize: typeof itemHeight === 'function' ? itemHeight : () => itemHeight,
|
estimateSize: typeof itemHeight === 'function' ? itemHeight : () => itemHeight,
|
||||||
|
overscan,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function registerItem(node: HTMLElement, index: number) {
|
function registerItem(node: HTMLElement, index: number) {
|
||||||
|
|||||||
@@ -6,10 +6,12 @@
|
|||||||
|
|
||||||
import CheckboxFilter from './CheckboxFilter/CheckboxFilter.svelte';
|
import CheckboxFilter from './CheckboxFilter/CheckboxFilter.svelte';
|
||||||
import ComboControl from './ComboControl/ComboControl.svelte';
|
import ComboControl from './ComboControl/ComboControl.svelte';
|
||||||
|
import SearchBar from './SearchBar/SearchBar.svelte';
|
||||||
import VirtualList from './VirtualList/VirtualList.svelte';
|
import VirtualList from './VirtualList/VirtualList.svelte';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
CheckboxFilter,
|
CheckboxFilter,
|
||||||
ComboControl,
|
ComboControl,
|
||||||
|
SearchBar,
|
||||||
VirtualList,
|
VirtualList,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
FilterControls,
|
||||||
|
Filters,
|
||||||
|
} from '$features/GetFonts';
|
||||||
|
import {
|
||||||
|
Content as SidebarContent,
|
||||||
|
Root as SidebarRoot,
|
||||||
|
} from '$shared/shadcn/ui/sidebar/index';
|
||||||
/**
|
/**
|
||||||
* FiltersSidebar Component
|
* FiltersSidebar Component
|
||||||
*
|
*
|
||||||
@@ -6,19 +14,25 @@
|
|||||||
* for font filtering operations. Organized into two sections:
|
* for font filtering operations. Organized into two sections:
|
||||||
*
|
*
|
||||||
* - Filters: Category-based filter groups (providers, subsets, categories)
|
* - 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
|
* Uses Sidebar.Root from shadcn for responsive sidebar behavior including
|
||||||
* mobile drawer and desktop persistent sidebar modes.
|
* 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>
|
</script>
|
||||||
|
|
||||||
<Sidebar.Root>
|
<SidebarRoot>
|
||||||
<Sidebar.Content class="p-2">
|
<SidebarContent class="p-2">
|
||||||
|
<!-- Filter groups -->
|
||||||
<Filters />
|
<Filters />
|
||||||
<Controls />
|
|
||||||
</Sidebar.Content>
|
<!-- Action buttons -->
|
||||||
</Sidebar.Root>
|
<FilterControls />
|
||||||
|
</SidebarContent>
|
||||||
|
</SidebarRoot>
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { FontSearch } from '$features/GetFonts';
|
||||||
import SetupFontMenu from '$features/SetupFont/ui/SetupFontMenu.svelte';
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="w-full p-2">
|
<div class="w-full p-2">
|
||||||
<Item.Root variant="outline" class="w-full p-2.5">
|
<ItemRoot variant="outline" class="w-full p-2.5">
|
||||||
<Item.Content>
|
<ItemContent class="flex flex-row justify-center items-center">
|
||||||
<SetupFontMenu />
|
<SetupFontMenu />
|
||||||
</Item.Content>
|
<FontSearch />
|
||||||
</Item.Root>
|
</ItemContent>
|
||||||
|
</ItemRoot>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user