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:
@@ -41,7 +41,7 @@ let { children }: Props = $props();
|
|||||||
<header></header>
|
<header></header>
|
||||||
|
|
||||||
<ScrollArea class="h-screen w-screen">
|
<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>
|
<TooltipProvider>
|
||||||
<TypographyMenu />
|
<TypographyMenu />
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
|
|||||||
@@ -4,6 +4,17 @@
|
|||||||
* Exports API clients and normalization utilities
|
* 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 {
|
export {
|
||||||
fetchGoogleFontFamily,
|
fetchGoogleFontFamily,
|
||||||
fetchGoogleFonts,
|
fetchGoogleFonts,
|
||||||
@@ -14,6 +25,7 @@ export type {
|
|||||||
GoogleFontsResponse,
|
GoogleFontsResponse,
|
||||||
} from './google/googleFonts';
|
} from './google/googleFonts';
|
||||||
|
|
||||||
|
// Fontshare API (DEPRECATED - kept for backward compatibility)
|
||||||
export {
|
export {
|
||||||
fetchAllFontshareFonts,
|
fetchAllFontshareFonts,
|
||||||
fetchFontshareFontBySlug,
|
fetchFontshareFontBySlug,
|
||||||
|
|||||||
160
src/entities/Font/api/proxy/proxyFonts.ts
Normal file
160
src/entities/Font/api/proxy/proxyFonts.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -3,8 +3,15 @@ import { appliedFontsManager } from '$entities/Font';
|
|||||||
import { displayedFontsStore } from '$features/DisplayFont';
|
import { displayedFontsStore } from '$features/DisplayFont';
|
||||||
import FontDisplay from '$features/DisplayFont/ui/FontDisplay/FontDisplay.svelte';
|
import FontDisplay from '$features/DisplayFont/ui/FontDisplay/FontDisplay.svelte';
|
||||||
import { controlManager } from '$features/SetupFont';
|
import { controlManager } from '$features/SetupFont';
|
||||||
|
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||||
import ComparisonSlider from '$widgets/ComparisonSlider/ui/ComparisonSlider/ComparisonSlider.svelte';
|
import ComparisonSlider from '$widgets/ComparisonSlider/ui/ComparisonSlider/ComparisonSlider.svelte';
|
||||||
import { FontSearch } from '$widgets/FontSearch';
|
import { FontSearch } from '$widgets/FontSearch';
|
||||||
|
import { cubicOut } from 'svelte/easing';
|
||||||
|
import { Spring } from 'svelte/motion';
|
||||||
|
import type {
|
||||||
|
SlideParams,
|
||||||
|
TransitionConfig,
|
||||||
|
} from 'svelte/transition';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Page Component
|
* Page Component
|
||||||
@@ -13,6 +20,9 @@ import { FontSearch } from '$widgets/FontSearch';
|
|||||||
let searchContainer: HTMLElement;
|
let searchContainer: HTMLElement;
|
||||||
|
|
||||||
let isExpanded = $state(false);
|
let isExpanded = $state(false);
|
||||||
|
let isOpen = $state(false);
|
||||||
|
|
||||||
|
let isEmptyScreen = $derived(!displayedFontsStore.hasAnyFonts && !isExpanded && !isOpen);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
appliedFontsManager.touch(
|
appliedFontsManager.touch(
|
||||||
@@ -22,19 +32,63 @@ $effect(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Font List -->
|
<!-- Font List -->
|
||||||
<div class="p-2 will-change-[height]">
|
<div class="p-2 h-full flex flex-col gap-3 overflow-hidden">
|
||||||
<div bind:this={searchContainer}>
|
{#key isEmptyScreen}
|
||||||
<FontSearch bind:showFilters={isExpanded} />
|
<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>
|
</div>
|
||||||
|
|
||||||
<ComparisonSlider />
|
<div class="will-change-tranform transition-transform content my-2">
|
||||||
|
|
||||||
<div class="will-change-tranform transition-transform content">
|
|
||||||
<FontDisplay />
|
<FontDisplay />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<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 {
|
.content {
|
||||||
/* Tells the browser to skip rendering off-screen content */
|
/* Tells the browser to skip rendering off-screen content */
|
||||||
content-visibility: auto;
|
content-visibility: auto;
|
||||||
@@ -44,4 +98,10 @@ $effect(() => {
|
|||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.will-change-[height] {
|
||||||
|
will-change: flex-grow, padding;
|
||||||
|
/* Forces GPU acceleration for the layout shift */
|
||||||
|
transform: translateZ(0);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -17,30 +17,46 @@ import { useId } from 'bits-ui';
|
|||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** Unique identifier for the input element */
|
/**
|
||||||
|
* Unique identifier for the input element
|
||||||
|
*/
|
||||||
id?: string;
|
id?: string;
|
||||||
/** Current search value (bindable) */
|
/**
|
||||||
|
* Current search value (bindable)
|
||||||
|
*/
|
||||||
value: string;
|
value: string;
|
||||||
/** Additional CSS classes for the container */
|
/**
|
||||||
|
* Whether popover is open (bindable)
|
||||||
|
*/
|
||||||
|
isOpen?: boolean;
|
||||||
|
/**
|
||||||
|
* Additional CSS classes for the container
|
||||||
|
*/
|
||||||
class?: string;
|
class?: string;
|
||||||
/** Placeholder text for the input */
|
/**
|
||||||
|
* Placeholder text for the input
|
||||||
|
*/
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
/** Optional label displayed above the input */
|
/**
|
||||||
|
* Optional label displayed above the input
|
||||||
|
*/
|
||||||
label?: string;
|
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;
|
children: Snippet<[{ id: string }]> | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
id = 'search-bar',
|
id = 'search-bar',
|
||||||
value = $bindable(),
|
value = $bindable(''),
|
||||||
|
isOpen = $bindable(false),
|
||||||
class: className,
|
class: className,
|
||||||
placeholder,
|
placeholder,
|
||||||
label,
|
label,
|
||||||
children,
|
children,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let open = $state(false);
|
|
||||||
let triggerRef = $state<HTMLInputElement>(null!);
|
let triggerRef = $state<HTMLInputElement>(null!);
|
||||||
// svelte-ignore state_referenced_locally
|
// svelte-ignore state_referenced_locally
|
||||||
const contentId = useId(id);
|
const contentId = useId(id);
|
||||||
@@ -52,11 +68,11 @@ function handleKeyDown(event: KeyboardEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleInputClick() {
|
function handleInputClick() {
|
||||||
open = true;
|
isOpen = true;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<PopoverRoot bind:open>
|
<PopoverRoot bind:open={isOpen}>
|
||||||
<PopoverTrigger bind:ref={triggerRef}>
|
<PopoverTrigger bind:ref={triggerRef}>
|
||||||
{#snippet child({ props })}
|
{#snippet child({ props })}
|
||||||
{@const { onclick, ...rest } = props}
|
{@const { onclick, ...rest } = props}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -27,9 +27,10 @@ import { type SlideParams } from 'svelte/transition';
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
showFilters?: boolean;
|
showFilters?: boolean;
|
||||||
|
isOpen?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { showFilters = $bindable(false) }: Props = $props();
|
let { showFilters = $bindable(false), isOpen = $bindable(false) }: Props = $props();
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
/**
|
/**
|
||||||
@@ -68,6 +69,7 @@ function toggleFilters() {
|
|||||||
class="w-full"
|
class="w-full"
|
||||||
placeholder="Search fonts by name..."
|
placeholder="Search fonts by name..."
|
||||||
bind:value={filterManager.queryValue}
|
bind:value={filterManager.queryValue}
|
||||||
|
bind:isOpen
|
||||||
>
|
>
|
||||||
<SuggestedFonts />
|
<SuggestedFonts />
|
||||||
</SearchBar>
|
</SearchBar>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const [send, receive] = crossfade({
|
|||||||
|
|
||||||
{#if displayedFontsStore.hasAnyFonts}
|
{#if displayedFontsStore.hasAnyFonts}
|
||||||
<div
|
<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' }}
|
in:receive={{ key: 'panel' }}
|
||||||
out:send={{ key: 'panel' }}
|
out:send={{ key: 'panel' }}
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user