From 3cb32965184759c5662ec1d7eb51719df4445a96 Mon Sep 17 00:00:00 2001 From: 4Ventures <4ventures@MacBook-Air-de-4Ventures.local> Date: Sat, 10 Oct 2026 02:00:09 +0200 Subject: [PATCH] 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 --- src/app/api/brandbook/google-fonts/route.ts | 44 ++++ src/components/BrandZone.tsx | 36 ++- src/components/GoogleFontsGallery.tsx | 244 ++++++++++++++++++++ src/lib/google-fonts-catalog.ts | 112 +++++++++ 4 files changed, 433 insertions(+), 3 deletions(-) create mode 100644 src/app/api/brandbook/google-fonts/route.ts create mode 100644 src/components/GoogleFontsGallery.tsx create mode 100644 src/lib/google-fonts-catalog.ts diff --git a/src/app/api/brandbook/google-fonts/route.ts b/src/app/api/brandbook/google-fonts/route.ts new file mode 100644 index 0000000..0416b2d --- /dev/null +++ b/src/app/api/brandbook/google-fonts/route.ts @@ -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 } + ); + } +} diff --git a/src/components/BrandZone.tsx b/src/components/BrandZone.tsx index fec8bb3..be3f828 100644 --- a/src/components/BrandZone.tsx +++ b/src/components/BrandZone.tsx @@ -30,6 +30,7 @@ import { type ColorToken, type FontToken, } from '@/lib/brandbook'; +import GoogleFontsGallery from '@/components/GoogleFontsGallery'; function Field({ label, @@ -806,18 +807,47 @@ export default function BrandZone({ tenantLabel }: { tenantLabel: string }) { -
+
+ 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})`); + }} + />

- Tipografías + Tipografías del kit

diff --git a/src/components/GoogleFontsGallery.tsx b/src/components/GoogleFontsGallery.tsx new file mode 100644 index 0000000..3a94699 --- /dev/null +++ b/src/components/GoogleFontsGallery.tsx @@ -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([]); + const [fonts, setFonts] = useState([]); + const [total, setTotal] = useState(0); + const [offset, setOffset] = useState(0); + const [loading, setLoading] = useState(true); + const [err, setErr] = useState(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 ( +
+
+

+ + Galería Google Fonts (gratis) +

+

+ {total.toLocaleString('es-ES')} tipografías libres · OFL / Apache +

+
+ +
+
+ + { + 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" + /> +
+ + +
+ + {err ? ( +

{err}

+ ) : null} + + {loading ? ( +
+ + Cargando catálogo de Google Fonts… +
+ ) : ( +
+ {fonts.map((f) => { + const on = selectedSet.has(f.family.toLowerCase()); + return ( + + ); + })} +
+ )} + +
+ +

+ {total + ? `${offset + 1}–${Math.min(offset + limit, total)} de ${total}` + : '—'} +

+ +
+
+ ); +} diff --git a/src/lib/google-fonts-catalog.ts b/src/lib/google-fonts-catalog.ts new file mode 100644 index 0000000..4393249 --- /dev/null +++ b/src/lib/google-fonts-catalog.ts @@ -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 { + 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; + 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, + }; +}