feat(fonts): implement Phase 1 - Create Proxy API Client

- Created src/entities/Font/api/proxy/proxyFonts.ts
- Implemented fetchProxyFonts function with full pagination support
- Implemented fetchProxyFontById convenience function
- Added TypeScript interfaces: ProxyFontsParams, ProxyFontsResponse
- Added comprehensive JSDoc documentation
- Updated src/entities/Font/api/index.ts to export proxy API

Phase 1/7: Proxy API Integration for GlyphDiff
This commit is contained in:
Ilia Mashkov
2026-01-29 14:33:12 +03:00
parent 0b0489fa26
commit 7078cb6f8c
8 changed files with 321 additions and 19 deletions

View File

@@ -41,7 +41,7 @@ let { children }: Props = $props();
<header></header>
<ScrollArea class="h-screen w-screen">
<main class="flex-1 w-full max-w-6xl mx-auto px-4 py-6 md:px-8 lg:py-10 relative">
<main class="flex-1 h-full w-full max-w-6xl mx-auto px-4 py-6 md:px-8 lg:py-10 relative">
<TooltipProvider>
<TypographyMenu />
{@render children?.()}

View File

@@ -4,6 +4,17 @@
* Exports API clients and normalization utilities
*/
// Proxy API (PRIMARY - NEW)
export {
fetchProxyFontById,
fetchProxyFonts,
} from './proxy/proxyFonts';
export type {
ProxyFontsParams,
ProxyFontsResponse,
} from './proxy/proxyFonts';
// Google Fonts API (DEPRECATED - kept for backward compatibility)
export {
fetchGoogleFontFamily,
fetchGoogleFonts,
@@ -14,6 +25,7 @@ export type {
GoogleFontsResponse,
} from './google/googleFonts';
// Fontshare API (DEPRECATED - kept for backward compatibility)
export {
fetchAllFontshareFonts,
fetchFontshareFontBySlug,

View File

@@ -0,0 +1,160 @@
/**
* Proxy API client
*
* Handles API requests to GlyphDiff proxy API for fetching font metadata.
* Provides error handling, pagination support, and type-safe responses.
*
* Proxy API normalizes font data from Google Fonts and Fontshare into a single
* unified format, eliminating the need for client-side normalization.
*
* @see https://api.glyphdiff.com/api/v1/fonts
*/
import { api } from '$shared/api/api';
import { buildQueryString } from '$shared/lib/utils';
import type { QueryParams } from '$shared/lib/utils';
import type { UnifiedFont } from '../../model/types';
import type {
FontCategory,
FontSubset,
} from '../../model/types';
/**
* Proxy API base URL
*/
const PROXY_API_URL = 'https://api.glyphdiff.com/api/v1/fonts' as const;
/**
* Proxy API parameters
*
* Maps directly to the proxy API query parameters
*/
export interface ProxyFontsParams extends QueryParams {
/**
* Font provider filter ("google" or "fontshare")
* Omit to fetch from both providers
*/
provider?: 'google' | 'fontshare';
/**
* Font category filter
*/
category?: FontCategory;
/**
* Character subset filter
*/
subset?: FontSubset;
/**
* Search query (e.g., "roboto", "satoshi")
*/
q?: string;
/**
* Sort order for results
* "name" - Alphabetical by font name
* "popularity" - Most popular first
* "lastModified" - Recently updated first
*/
sort?: 'name' | 'popularity' | 'lastModified';
/**
* Number of items to return (pagination)
*/
limit?: number;
/**
* Number of items to skip (pagination)
* Use for pagination: offset = (page - 1) * limit
*/
offset?: number;
}
/**
* Proxy API response
*
* Includes pagination metadata alongside font data
*/
export interface ProxyFontsResponse {
/** Array of unified font objects */
fonts: UnifiedFont[];
/** Total number of fonts matching the query */
total: number;
/** Limit used for this request */
limit: number;
/** Offset used for this request */
offset: number;
}
/**
* Fetch fonts from proxy API
*
* @param params - Query parameters for filtering and pagination
* @returns Promise resolving to proxy API response
* @throws ApiError when request fails
*
* @example
* ```ts
* // Fetch all sans-serif fonts from Google
* const response = await fetchProxyFonts({
* provider: 'google',
* category: 'sans-serif',
* limit: 50,
* offset: 0
* });
*
* // Search fonts across all providers
* const searchResponse = await fetchProxyFonts({
* q: 'roboto',
* limit: 20
* });
*
* // Fetch fonts with pagination
* const page1 = await fetchProxyFonts({ limit: 50, offset: 0 });
* const page2 = await fetchProxyFonts({ limit: 50, offset: 50 });
* ```
*/
export async function fetchProxyFonts(
params: ProxyFontsParams = {},
): Promise<ProxyFontsResponse> {
const queryString = buildQueryString(params);
const url = `${PROXY_API_URL}${queryString}`;
try {
const response = await api.get<ProxyFontsResponse>(url);
return response.data;
} catch (error) {
// Re-throw ApiError with context
if (error instanceof Error) {
throw error;
}
throw new Error(`Failed to fetch fonts from proxy API: ${String(error)}`);
}
}
/**
* Fetch font by ID
*
* Convenience function for fetching a single font by ID
* Note: This fetches a page and filters client-side, which is not ideal
* For production, consider adding a dedicated endpoint to the proxy API
*
* @param id - Font ID (family name for Google, slug for Fontshare)
* @returns Promise resolving to font or undefined
*
* @example
* ```ts
* const roboto = await fetchProxyFontById('Roboto');
* const satoshi = await fetchProxyFontById('satoshi');
* ```
*/
export async function fetchProxyFontById(
id: string,
): Promise<UnifiedFont | undefined> {
const response = await fetchProxyFonts({ limit: 1000, q: id });
return response.fonts.find(font => font.id === id);
}

View File

@@ -3,8 +3,15 @@ import { appliedFontsManager } from '$entities/Font';
import { displayedFontsStore } from '$features/DisplayFont';
import FontDisplay from '$features/DisplayFont/ui/FontDisplay/FontDisplay.svelte';
import { controlManager } from '$features/SetupFont';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import ComparisonSlider from '$widgets/ComparisonSlider/ui/ComparisonSlider/ComparisonSlider.svelte';
import { FontSearch } from '$widgets/FontSearch';
import { cubicOut } from 'svelte/easing';
import { Spring } from 'svelte/motion';
import type {
SlideParams,
TransitionConfig,
} from 'svelte/transition';
/**
* Page Component
@@ -13,6 +20,9 @@ import { FontSearch } from '$widgets/FontSearch';
let searchContainer: HTMLElement;
let isExpanded = $state(false);
let isOpen = $state(false);
let isEmptyScreen = $derived(!displayedFontsStore.hasAnyFonts && !isExpanded && !isOpen);
$effect(() => {
appliedFontsManager.touch(
@@ -22,19 +32,63 @@ $effect(() => {
</script>
<!-- Font List -->
<div class="p-2 will-change-[height]">
<div bind:this={searchContainer}>
<FontSearch bind:showFilters={isExpanded} />
<div class="p-2 h-full flex flex-col gap-3 overflow-hidden">
{#key isEmptyScreen}
<div
class={cn(
'flex flex-col transition-all duration-700 ease-[cubic-bezier(0.23,1,0.32,1)] mx-40',
'will-change-[flex-grow] transform-gpu',
isEmptyScreen
? 'grow justify-center'
: 'animate-search',
)}
>
<div
class={cn(
'transition-transform duration-700 ease-[cubic-bezier(0.23,1,0.32,1)]',
)}
>
<FontSearch bind:showFilters={isExpanded} bind:isOpen />
</div>
</div>
{/key}
<div class="my-2 mx-10">
<ComparisonSlider />
</div>
<ComparisonSlider />
<div class="will-change-tranform transition-transform content">
<div class="will-change-tranform transition-transform content my-2">
<FontDisplay />
</div>
</div>
<style>
@keyframes search {
0% {
opacity: 1;
transform: scale(1);
flex-grow: 1;
justify-content: center;
}
15% {
opacity: 0.5;
transform: scale(0.95);
}
30% {
opacity: 0;
}
100% {
opacity: 1;
transform: scale(1);
flex-grow: 0;
justify-content: flex-start;
}
}
.animate-search {
animation: search 0.5s cubic-bezier(0.165, 0.84, 0.44, 1) forwards;
}
.content {
/* Tells the browser to skip rendering off-screen content */
content-visibility: auto;
@@ -44,4 +98,10 @@ $effect(() => {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.will-change-[height] {
will-change: flex-grow, padding;
/* Forces GPU acceleration for the layout shift */
transform: translateZ(0);
}
</style>

View File

@@ -17,30 +17,46 @@ import { useId } from 'bits-ui';
import type { Snippet } from 'svelte';
interface Props {
/** Unique identifier for the input element */
/**
* Unique identifier for the input element
*/
id?: string;
/** Current search value (bindable) */
/**
* Current search value (bindable)
*/
value: string;
/** Additional CSS classes for the container */
/**
* Whether popover is open (bindable)
*/
isOpen?: boolean;
/**
* Additional CSS classes for the container
*/
class?: string;
/** Placeholder text for the input */
/**
* Placeholder text for the input
*/
placeholder?: string;
/** Optional label displayed above the input */
/**
* Optional label displayed above the input
*/
label?: string;
/** Content to render inside the popover (receives unique content ID) */
/**
* Content to render inside the popover (receives unique content ID)
*/
children: Snippet<[{ id: string }]> | undefined;
}
let {
id = 'search-bar',
value = $bindable(),
value = $bindable(''),
isOpen = $bindable(false),
class: className,
placeholder,
label,
children,
}: Props = $props();
let open = $state(false);
let triggerRef = $state<HTMLInputElement>(null!);
// svelte-ignore state_referenced_locally
const contentId = useId(id);
@@ -52,11 +68,11 @@ function handleKeyDown(event: KeyboardEvent) {
}
function handleInputClick() {
open = true;
isOpen = true;
}
</script>
<PopoverRoot bind:open>
<PopoverRoot bind:open={isOpen}>
<PopoverTrigger bind:ref={triggerRef}>
{#snippet child({ props })}
{@const { onclick, ...rest } = props}

View File

@@ -0,0 +1,52 @@
<script lang="ts">
import { cn } from '$shared/shadcn/utils/shadcn-utils';
interface Props {
text: string;
fontName: string;
isAnimating: boolean;
onAnimationComplete?: () => void;
}
let { text, fontName, isAnimating, onAnimationComplete }: Props = $props();
// Split text into characters, preserving spaces
const chars = $derived(text.split('').map(c => c === ' ' ? '\u00A0' : c));
let completedCount = 0;
function handleTransitionEnd() {
completedCount++;
if (completedCount === chars.length) {
onAnimationComplete?.();
completedCount = 0;
}
}
</script>
<div class="relative inline-flex flex-wrap leading-tight">
{#each chars as char, i}
<span
class={cn(
'inline-block transition-all duration-500 ease-[cubic-bezier(0.34,1.56,0.64,1)]',
isAnimating ? 'opacity-0 -translate-y-4 rotate-x-90' : 'opacity-100 translate-y-0 rotate-x-0',
)}
style:font-family={fontName}
style:transition-delay="{i * 25}ms"
ontransitionend={i === chars.length - 1 ? handleTransitionEnd : null}
>
{char}
</span>
{/each}
</div>
<style>
/* Necessary for the "Flip" feel */
div {
perspective: 1000px;
}
span {
transform-style: preserve-3d;
backface-visibility: hidden;
}
</style>

View File

@@ -27,9 +27,10 @@ import { type SlideParams } from 'svelte/transition';
interface Props {
showFilters?: boolean;
isOpen?: boolean;
}
let { showFilters = $bindable(false) }: Props = $props();
let { showFilters = $bindable(false), isOpen = $bindable(false) }: Props = $props();
onMount(() => {
/**
@@ -68,6 +69,7 @@ function toggleFilters() {
class="w-full"
placeholder="Search fonts by name..."
bind:value={filterManager.queryValue}
bind:isOpen
>
<SuggestedFonts />
</SearchBar>

View File

@@ -24,7 +24,7 @@ const [send, receive] = crossfade({
{#if displayedFontsStore.hasAnyFonts}
<div
class="w-auto fixed bottom-5 left-1/2 translate-x-[-50%] max-w-max z-10"
class="w-auto fixed bottom-5 inset-x-0 max-screen z-10 flex justify-center"
in:receive={{ key: 'panel' }}
out:send={{ key: 'panel' }}
>