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:
44
src/app/api/brandbook/google-fonts/route.ts
Normal file
44
src/app/api/brandbook/google-fonts/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }) {
|
||||
</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">
|
||||
<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" />
|
||||
Tipografías
|
||||
Tipografías del kit
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addFont}
|
||||
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>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
|
||||
244
src/components/GoogleFontsGallery.tsx
Normal file
244
src/components/GoogleFontsGallery.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
112
src/lib/google-fonts-catalog.ts
Normal file
112
src/lib/google-fonts-catalog.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user