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 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">
|
||||||
|
|||||||
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