// FontVault — typography discovery engine
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { FontCard, FontPreview, SearchBar } from '@/components/ui';
import type { Font, FontCategory, SortOrder } from '@/lib/types';
// ── Type definitions ──────────────────────────────────────
interface FontVaultConfig {
apiEndpoint: string;
previewText: string;
maxResults: number;
enablePairing: boolean;
theme: 'light' | 'dark';
}
// ── Font weight utilities ──────────────────────────────────
const getWeightLabel = (weight: number): string => {
const labels: Record<number, string> = {
100: 'Thin', 200: 'ExtraLight',
300: 'Light', 400: 'Regular',
500: 'Medium', 600: 'SemiBold',
700: 'Bold', 800: 'ExtraBold',
900: 'Black',
};
return labels[weight] ?? `w${weight}`;
};
// ── Levenshtein distance for fuzzy search ─────────────────
function levenshtein(a: string, b: string): number {
const dp = Array.from({ length: a.length + 1 },
(_, i) => Array.from({ length: b.length + 1 },
(_, j) => i === 0 ? j : j === 0 ? i : 0));
for (let i = 1; i <= a.length; i++)
for (let j = 1; j <= b.length; j++)
dp[i][j] = a[i-1] === b[j-1]
? dp[i-1][j-1]
: 1 + Math.min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]);
return dp[a.length][b.length];
}
/* ── CSS: Font card component ─────────────────────────── */
.font-card {
display: grid;
grid-template-rows: auto 1fr auto;
border: 1px solid var(--border-color);
border-radius: 12px;
background: var(--card-bg);
transition: transform 0.22s, box-shadow 0.22s;
overflow: hidden;
}
.font-card:hover {
transform: translateY(-3px);
box-shadow: 0 12px 40px rgba(0,0,0,0.12);
}
// ── FontCard React component ────────────────────────────
export function FontCard({
font, previewText, onSelect, isFavorited,
}: {
font: Font;
previewText: string;
onSelect: (id: string) => void;
isFavorited: boolean;
}) {
const ref = useRef<HTMLDivElement>(null);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const obs = new IntersectionObserver(([e]) => {
if (e.isIntersecting) { setLoaded(true); obs.disconnect(); }
}, { threshold: 0.1 });
obs.observe(el);
return () => obs.disconnect();
}, []);
return (
<div
ref={ref}
className="font-card"
role="button"
tabIndex={0}
onClick={() => onSelect(font.id)}
>
<div className="font-card__preview">
{loaded &&
<span style={{ fontFamily: font.cssFamily }}>
{previewText}
</span>
}
</div>
</div>
);
}
// ── useFontSearch hook ────────────────────────────────────
export function useFontSearch(fonts: Font[]) {
const [query, setQuery] = useState('');
const [category, setCategory] = useState<FontCategory>('all');
const [sort, setSort] = useState<SortOrder>('popular');
const results = useMemo(() => {
let out = [...fonts];
if (category !== 'all')
out = out.filter(f => f.category === category);
if (query.trim())
out = out.filter(f =>
f.name.toLowerCase().includes(query.toLowerCase())
);
return out.sort((a, b) =>
sort === 'alpha'
? a.name.localeCompare(b.name)
: b.popularity - a.popularity
);
}, [fonts, query, category, sort]);
return { query, setQuery, category, setCategory, sort, setSort, results };
}
/* ── Keyframes: card entrance ─────────────────────────── */
@keyframes cardEntrance {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
// ── Font detail page: specimen renderer ───────────────────
async function renderSpecimen(
fontId: string,
canvas: HTMLCanvasElement,
opts: { size: number; text: string }
): Promise<void> {
const ctx = canvas.getContext('2d');
if (!ctx) return;
await loadFont(fontId);
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.font = `${opts.size}px '${fontId}'`;
ctx.fillStyle = 'currentColor';
ctx.fillText(opts.text, 24, opts.size + 24);
}
// ── Pairing engine: cosine similarity on embeddings ───────
function cosineSimilarity(a: number[], b: number[]): number {
const dot = a.reduce((s, v, i) => s + v * b[i], 0);
const mag = (v: number[]) =>
Math.sqrt(v.reduce((s, x) => s + x * x, 0));
return dot / (mag(a) * mag(b));
}
/* ── API route: /api/fonts/search ─────────────────────── */
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const q = searchParams.get('q') ?? '';
const cat = searchParams.get('cat');
const page = Number(searchParams.get('page') ?? 1);
const fonts = await searchFonts({ q, cat, page });
return NextResponse.json({ fonts, total: fonts.length });
}
// FontVault — typography discovery engine
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { FontCard, FontPreview, SearchBar } from '@/components/ui';
import type { Font, FontCategory, SortOrder } from '@/lib/types';
// ── Type definitions ──────────────────────────────────────
interface FontVaultConfig {
apiEndpoint: string;
previewText: string;
maxResults: number;
enablePairing: boolean;
theme: 'light' | 'dark';
}
// ── Font weight utilities ──────────────────────────────────
const getWeightLabel = (weight: number): string => {
const labels: Record<number, string> = {
100: 'Thin', 200: 'ExtraLight',
300: 'Light', 400: 'Regular',
500: 'Medium', 600: 'SemiBold',
700: 'Bold', 800: 'ExtraBold',
900: 'Black',
};
return labels[weight] ?? `w${weight}`;
};
// ── Levenshtein distance for fuzzy search ─────────────────
function levenshtein(a: string, b: string): number {
const dp = Array.from({ length: a.length + 1 },
(_, i) => Array.from({ length: b.length + 1 },
(_, j) => i === 0 ? j : j === 0 ? i : 0));
for (let i = 1; i <= a.length; i++)
for (let j = 1; j <= b.length; j++)
dp[i][j] = a[i-1] === b[j-1]
? dp[i-1][j-1]
: 1 + Math.min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]);
return dp[a.length][b.length];
}
/* ── CSS: Font card component ─────────────────────────── */
.font-card {
display: grid;
grid-template-rows: auto 1fr auto;
border: 1px solid var(--border-color);
border-radius: 12px;
background: var(--card-bg);
transition: transform 0.22s, box-shadow 0.22s;
overflow: hidden;
}
.font-card:hover {
transform: translateY(-3px);
box-shadow: 0 12px 40px rgba(0,0,0,0.12);
}
// ── FontCard React component ────────────────────────────
export function FontCard({
font, previewText, onSelect, isFavorited,
}: {
font: Font;
previewText: string;
onSelect: (id: string) => void;
isFavorited: boolean;
}) {
const ref = useRef<HTMLDivElement>(null);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const obs = new IntersectionObserver(([e]) => {
if (e.isIntersecting) { setLoaded(true); obs.disconnect(); }
}, { threshold: 0.1 });
obs.observe(el);
return () => obs.disconnect();
}, []);
return (
<div
ref={ref}
className="font-card"
role="button"
tabIndex={0}
onClick={() => onSelect(font.id)}
>
<div className="font-card__preview">
{loaded &&
<span style={{ fontFamily: font.cssFamily }}>
{previewText}
</span>
}
</div>
</div>
);
}
// ── useFontSearch hook ────────────────────────────────────
export function useFontSearch(fonts: Font[]) {
const [query, setQuery] = useState('');
const [category, setCategory] = useState<FontCategory>('all');
const [sort, setSort] = useState<SortOrder>('popular');
const results = useMemo(() => {
let out = [...fonts];
if (category !== 'all')
out = out.filter(f => f.category === category);
if (query.trim())
out = out.filter(f =>
f.name.toLowerCase().includes(query.toLowerCase())
);
return out.sort((a, b) =>
sort === 'alpha'
? a.name.localeCompare(b.name)
: b.popularity - a.popularity
);
}, [fonts, query, category, sort]);
return { query, setQuery, category, setCategory, sort, setSort, results };
}
/* ── Keyframes: card entrance ─────────────────────────── */
@keyframes cardEntrance {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
// ── Font detail page: specimen renderer ───────────────────
async function renderSpecimen(
fontId: string,
canvas: HTMLCanvasElement,
opts: { size: number; text: string }
): Promise<void> {
const ctx = canvas.getContext('2d');
if (!ctx) return;
await loadFont(fontId);
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.font = `${opts.size}px '${fontId}'`;
ctx.fillStyle = 'currentColor';
ctx.fillText(opts.text, 24, opts.size + 24);
}
// ── Pairing engine: cosine similarity on embeddings ───────
function cosineSimilarity(a: number[], b: number[]): number {
const dot = a.reduce((s, v, i) => s + v * b[i], 0);
const mag = (v: number[]) =>
Math.sqrt(v.reduce((s, x) => s + x * x, 0));
return dot / (mag(a) * mag(b));
}
/* ── API route: /api/fonts/search ─────────────────────── */
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const q = searchParams.get('q') ?? '';
const cat = searchParams.get('cat');
const page = Number(searchParams.get('page') ?? 1);
const fonts = await searchFonts({ q, cat, page });
return NextResponse.json({ fonts, total: fonts.length });
}
Free & Open-Source Typefaces
Find The Font That Feels Right.
Explore a curated world of typefaces made to bring your ideas to life.