Load the full free Google Fonts gallery in Zona de marca typography.

Browse ~1950 OFL fonts with search, category filters and live preview; one click adds a font to the brand kit.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-10-10 02:00:09 +02:00
parent 1715b0ac67
commit 3cb3296518
4 changed files with 433 additions and 3 deletions

View File

@@ -0,0 +1,44 @@
/**
* GET ?q=&category=&limit=&offset= — full free Google Fonts catalog.
*/
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { listGoogleFonts } from '@/lib/google-fonts-catalog';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const sp = req.nextUrl.searchParams;
try {
const result = await listGoogleFonts({
q: sp.get('q') || undefined,
category: sp.get('category') || undefined,
limit: Number(sp.get('limit') || 80) || 80,
offset: Number(sp.get('offset') || 0) || 0,
});
return NextResponse.json({
ok: true,
source: 'google-fonts',
license: 'All Google Fonts are free and open source (OFL / Apache / Ubuntu…)',
...result,
});
} catch (e) {
return NextResponse.json(
{
error:
e instanceof Error
? e.message
: 'No se pudo cargar el catálogo de Google Fonts',
},
{ status: 502 }
);
}
}

View File

@@ -30,6 +30,7 @@ import {
type ColorToken, type ColorToken,
type FontToken, type FontToken,
} from '@/lib/brandbook'; } from '@/lib/brandbook';
import GoogleFontsGallery from '@/components/GoogleFontsGallery';
function Field({ function Field({
label, label,
@@ -806,18 +807,47 @@ export default function BrandZone({ tenantLabel }: { tenantLabel: string }) {
</div> </div>
</div> </div>
<div> <div className="space-y-3">
<GoogleFontsGallery
selectedFamilies={book.fonts_kit.map((f) => f.family)}
onPick={(font, pickRole) => {
const exists = book.fonts_kit.some(
(f) =>
f.family.toLowerCase() === font.family.toLowerCase()
);
if (exists) {
setOkMsg(`${font.family} ya está en el kit`);
return;
}
const weights = font.variants
.filter((v) => /^\d+$/.test(v))
.slice(0, 6)
.join(',');
const token: FontToken = {
id: newId('font'),
family: font.family,
role: pickRole,
weights: weights || '400,700',
source: 'google',
url_or_notes: `https://fonts.google.com/specimen/${encodeURIComponent(
font.family.replace(/ /g, '+')
)}`,
};
patch({ fonts_kit: [...book.fonts_kit, token] });
setOkMsg(`${font.family} añadida al kit (${pickRole})`);
}}
/>
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<p className="text-sm font-semibold text-gray-900 inline-flex items-center gap-1.5"> <p className="text-sm font-semibold text-gray-900 inline-flex items-center gap-1.5">
<Type className="w-4 h-4 text-rose-500" /> <Type className="w-4 h-4 text-rose-500" />
Tipografías Tipografías del kit
</p> </p>
<button <button
type="button" type="button"
onClick={addFont} onClick={addFont}
className="text-xs font-semibold text-brand-700 inline-flex items-center gap-1" className="text-xs font-semibold text-brand-700 inline-flex items-center gap-1"
> >
<Plus className="w-3.5 h-3.5" /> Añadir fuente <Plus className="w-3.5 h-3.5" /> Manual
</button> </button>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">

View File

@@ -0,0 +1,244 @@
'use client';
import React, { useEffect, useMemo, useState } from 'react';
import { Check, Loader2, Search, Type } from 'lucide-react';
export type GoogleFontPick = {
family: string;
category: string;
variants: string[];
};
type ApiFont = {
family: string;
category: string;
variants: string[];
subsets: string[];
popularity: number;
};
function loadPreviewCss(families: string[]) {
if (typeof document === 'undefined' || !families.length) return;
const id = 'gf-brand-preview';
let link = document.getElementById(id) as HTMLLinkElement | null;
// CSS2 API: multiple families, limited weights for preview
const parts = families.slice(0, 40).map((f) => {
const fam = encodeURIComponent(f).replace(/%20/g, '+');
return `family=${fam}:wght@400;700`;
});
const href = `https://fonts.googleapis.com/css2?${parts.join('&')}&display=swap`;
if (!link) {
link = document.createElement('link');
link.id = id;
link.rel = 'stylesheet';
document.head.appendChild(link);
}
if (link.href !== href) link.href = href;
}
export default function GoogleFontsGallery({
selectedFamilies,
onPick,
}: {
selectedFamilies: string[];
onPick: (font: GoogleFontPick, role: 'display' | 'headline' | 'body' | 'ui') => void;
}) {
const [q, setQ] = useState('');
const [category, setCategory] = useState('all');
const [categories, setCategories] = useState<string[]>([]);
const [fonts, setFonts] = useState<ApiFont[]>([]);
const [total, setTotal] = useState(0);
const [offset, setOffset] = useState(0);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(null);
const [role, setRole] = useState<'display' | 'headline' | 'body' | 'ui'>(
'body'
);
const limit = 60;
const selectedSet = useMemo(
() => new Set(selectedFamilies.map((f) => f.toLowerCase())),
[selectedFamilies]
);
useEffect(() => {
let cancelled = false;
const t = setTimeout(async () => {
setLoading(true);
setErr(null);
try {
const params = new URLSearchParams({
q,
category,
limit: String(limit),
offset: String(offset),
});
const r = await fetch(`/api/brandbook/google-fonts?${params}`);
const j = await r.json();
if (!r.ok) throw new Error(j.error || 'Error catálogo');
if (cancelled) return;
setFonts(j.fonts || []);
setTotal(j.total || 0);
setCategories(j.categories || []);
} catch (e) {
if (!cancelled) {
setErr(e instanceof Error ? e.message : 'Error');
setFonts([]);
}
} finally {
if (!cancelled) setLoading(false);
}
}, q ? 250 : 0);
return () => {
cancelled = true;
clearTimeout(t);
};
}, [q, category, offset]);
useEffect(() => {
loadPreviewCss(fonts.map((f) => f.family));
}, [fonts]);
return (
<div className="rounded-xl border border-gray-200 bg-gray-50/80 p-3 space-y-3">
<div className="flex flex-col sm:flex-row sm:items-center gap-2 justify-between">
<p className="text-sm font-semibold text-gray-900 inline-flex items-center gap-1.5">
<Type className="w-4 h-4 text-rose-500" />
Galería Google Fonts (gratis)
</p>
<p className="text-[11px] text-gray-500">
{total.toLocaleString('es-ES')} tipografías libres · OFL / Apache
</p>
</div>
<div className="flex flex-col md:flex-row gap-2">
<div className="relative flex-1">
<Search className="w-3.5 h-3.5 absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400" />
<input
value={q}
onChange={(e) => {
setOffset(0);
setQ(e.target.value);
}}
placeholder="Buscar fuente (Inter, Playfair, Roboto…)"
className="w-full rounded-lg border border-gray-200 bg-white pl-8 pr-3 py-2 text-sm"
/>
</div>
<select
value={category}
onChange={(e) => {
setOffset(0);
setCategory(e.target.value);
}}
className="rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm md:w-44"
>
<option value="all">Todas las categorías</option>
{categories.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
<select
value={role}
onChange={(e) =>
setRole(e.target.value as 'display' | 'headline' | 'body' | 'ui')
}
className="rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm md:w-36"
title="Rol al añadir"
>
<option value="display">Rol: display</option>
<option value="headline">Rol: headline</option>
<option value="body">Rol: body</option>
<option value="ui">Rol: ui</option>
</select>
</div>
{err ? (
<p className="text-xs text-red-600">{err}</p>
) : null}
{loading ? (
<div className="flex items-center gap-2 text-xs text-gray-500 py-8 justify-center">
<Loader2 className="w-4 h-4 animate-spin" />
Cargando catálogo de Google Fonts…
</div>
) : (
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3 max-h-[420px] overflow-y-auto pr-1">
{fonts.map((f) => {
const on = selectedSet.has(f.family.toLowerCase());
return (
<button
key={f.family}
type="button"
onClick={() =>
onPick(
{
family: f.family,
category: f.category,
variants: f.variants,
},
role
)
}
className={`text-left rounded-lg border px-3 py-2.5 transition-colors ${
on
? 'border-brand-300 bg-brand-50'
: 'border-gray-200 bg-white hover:border-gray-300'
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p
className="text-base text-gray-900 truncate leading-tight"
style={{ fontFamily: `"${f.family}", sans-serif` }}
>
{f.family}
</p>
<p
className="text-sm text-gray-600 mt-1 truncate"
style={{ fontFamily: `"${f.family}", sans-serif` }}
>
Aa Bb Cc 123 — la marca habla
</p>
<p className="text-[10px] text-gray-400 mt-1">
{f.category}
{on ? ' · en el kit' : ' · clic para añadir'}
</p>
</div>
{on ? (
<Check className="w-4 h-4 text-brand-600 shrink-0" />
) : null}
</div>
</button>
);
})}
</div>
)}
<div className="flex items-center justify-between gap-2 pt-1">
<button
type="button"
disabled={offset <= 0 || loading}
onClick={() => setOffset(Math.max(0, offset - limit))}
className="text-xs font-semibold text-gray-700 disabled:opacity-40"
>
← Anterior
</button>
<p className="text-[11px] text-gray-500">
{total
? `${offset + 1}–${Math.min(offset + limit, total)} de ${total}`
: '—'}
</p>
<button
type="button"
disabled={offset + limit >= total || loading}
onClick={() => setOffset(offset + limit)}
className="text-xs font-semibold text-gray-700 disabled:opacity-40"
>
Siguiente →
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,112 @@
/**
* Full free Google Fonts catalog (entire Google Fonts library is free/OSS).
* Fetched server-side from fonts.google.com metadata; cached in memory.
*/
export type GoogleFontEntry = {
family: string;
category: string;
variants: string[];
subsets: string[];
popularity: number;
};
type Cache = {
at: number;
fonts: GoogleFontEntry[];
};
const TTL_MS = 24 * 60 * 60 * 1000;
let cache: Cache | null = null;
function specimenUrl(family: string): string {
return `https://fonts.google.com/specimen/${encodeURIComponent(
family.replace(/ /g, '+')
)}`;
}
export function googleFontSpecimenUrl(family: string): string {
return specimenUrl(family);
}
export function googleFontCssUrl(
family: string,
weights = '400;500;600;700'
): string {
const fam = family.replace(/ /g, '+');
return `https://fonts.googleapis.com/css2?family=${fam}:wght@${weights}&display=swap`;
}
async function fetchCatalog(): Promise<GoogleFontEntry[]> {
if (cache && Date.now() - cache.at < TTL_MS) return cache.fonts;
// Official metadata (all free fonts on Google Fonts)
const r = await fetch('https://fonts.google.com/metadata/fonts', {
headers: {
Accept: 'application/json',
'User-Agent': '4Ventures-CentroDeMando/1.0',
},
next: { revalidate: 86400 },
});
if (!r.ok) {
throw new Error(`Google Fonts metadata ${r.status}`);
}
let text = await r.text();
// Response is prefixed with )]}'
if (text.startsWith(")]}'")) {
text = text.replace(/^\)\]\}'\n?/, '');
}
const json = JSON.parse(text) as {
familyMetadataList?: Array<{
family?: string;
category?: string;
fonts?: Record<string, unknown>;
subsets?: string[];
popularity?: number;
}>;
};
const list = json.familyMetadataList || [];
const fonts: GoogleFontEntry[] = list
.filter((f) => f.family)
.map((f, i) => ({
family: String(f.family),
category: String(f.category || 'sans-serif'),
variants: f.fonts ? Object.keys(f.fonts) : ['400', '700'],
subsets: Array.isArray(f.subsets) ? f.subsets.map(String) : ['latin'],
popularity: typeof f.popularity === 'number' ? f.popularity : i,
}))
.sort((a, b) => a.popularity - b.popularity || a.family.localeCompare(b.family));
cache = { at: Date.now(), fonts };
return fonts;
}
export async function listGoogleFonts(opts?: {
q?: string;
category?: string;
limit?: number;
offset?: number;
}): Promise<{
total: number;
fonts: GoogleFontEntry[];
categories: string[];
}> {
const all = await fetchCatalog();
const categories = [...new Set(all.map((f) => f.category))].sort();
const q = (opts?.q || '').trim().toLowerCase();
const cat = (opts?.category || '').trim().toLowerCase();
let filtered = all;
if (cat && cat !== 'all') {
filtered = filtered.filter((f) => f.category.toLowerCase() === cat);
}
if (q) {
filtered = filtered.filter((f) => f.family.toLowerCase().includes(q));
}
const offset = Math.max(0, opts?.offset || 0);
const limit = Math.min(200, Math.max(1, opts?.limit || 60));
return {
total: filtered.length,
fonts: filtered.slice(offset, offset + limit),
categories,
};
}