feat(v1.5.0): customização de fontes e correção de cores em tendências
Adiciona sistema de customização de fontes por usuário via CSS custom properties, com preview ao vivo e persistência no banco. Corrige lógica de cores invertida na tabela de receitas em tendências. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
19
CHANGELOG.md
19
CHANGELOG.md
@@ -5,6 +5,25 @@ Todas as mudanças notáveis deste projeto serão documentadas neste arquivo.
|
|||||||
O formato é baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/),
|
O formato é baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/),
|
||||||
e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR/).
|
e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR/).
|
||||||
|
|
||||||
|
## [1.5.0] - 2026-02-15
|
||||||
|
|
||||||
|
### Adicionado
|
||||||
|
|
||||||
|
- Customização de fontes nas preferências — fonte da interface e fonte de valores monetários configuráveis por usuário
|
||||||
|
- 13 fontes disponíveis: AI Sans, Anthropic Sans, SF Pro Display, SF Pro Rounded, Inter, Geist Sans, Roboto, Reddit Sans, Fira Sans, Ubuntu, JetBrains Mono, Fira Code, IBM Plex Mono
|
||||||
|
- FontProvider com preview ao vivo — troca de fonte aplica instantaneamente via CSS variables, sem necessidade de reload
|
||||||
|
- Fontes Apple SF Pro (Display e Rounded) carregadas localmente com 4 pesos (Regular, Medium, Semibold, Bold)
|
||||||
|
- Colunas `system_font` e `money_font` na tabela `preferencias_usuario`
|
||||||
|
|
||||||
|
### Corrigido
|
||||||
|
|
||||||
|
- Cores de variação invertidas na tabela de receitas em `/relatorios/tendencias` — aumento agora é verde (bom) e diminuição é vermelho (ruim), consistente com a semântica de receita
|
||||||
|
|
||||||
|
### Alterado
|
||||||
|
|
||||||
|
- Sistema de fontes migrado de className direto para CSS custom properties (`--font-app`, `--font-money`) via `@theme inline`
|
||||||
|
- MoneyValues usa `var(--font-money)` em vez de classe fixa, permitindo customização
|
||||||
|
|
||||||
## [1.4.1] - 2026-02-15
|
## [1.4.1] - 2026-02-15
|
||||||
|
|
||||||
### Adicionado
|
### Adicionado
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { createHash, randomBytes } from "node:crypto";
|
import { createHash, randomBytes } from "node:crypto";
|
||||||
|
import { verifyPassword } from "better-auth/crypto";
|
||||||
import { and, eq, isNull, ne } from "drizzle-orm";
|
import { and, eq, isNull, ne } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { pagadores, tokensApi } from "@/db/schema";
|
import { account, pagadores, tokensApi } from "@/db/schema";
|
||||||
import { auth } from "@/lib/auth/config";
|
import { auth } from "@/lib/auth/config";
|
||||||
import { db, schema } from "@/lib/db";
|
import { db, schema } from "@/lib/db";
|
||||||
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
||||||
@@ -51,8 +52,26 @@ const deleteAccountSchema = z.object({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const VALID_FONTS = [
|
||||||
|
"ai-sans",
|
||||||
|
"anthropic-sans",
|
||||||
|
"fira-code",
|
||||||
|
"fira-sans",
|
||||||
|
"geist",
|
||||||
|
"ibm-plex-mono",
|
||||||
|
"inter",
|
||||||
|
"jetbrains-mono",
|
||||||
|
"reddit-sans",
|
||||||
|
"roboto",
|
||||||
|
"sf-pro-display",
|
||||||
|
"sf-pro-rounded",
|
||||||
|
"ubuntu",
|
||||||
|
] as const;
|
||||||
|
|
||||||
const updatePreferencesSchema = z.object({
|
const updatePreferencesSchema = z.object({
|
||||||
disableMagnetlines: z.boolean(),
|
disableMagnetlines: z.boolean(),
|
||||||
|
systemFont: z.enum(VALID_FONTS).default("ai-sans"),
|
||||||
|
moneyFont: z.enum(VALID_FONTS).default("ai-sans"),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
@@ -235,19 +254,32 @@ export async function updateEmailAction(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validar senha tentando fazer changePassword para a mesma senha
|
// Buscar hash da senha no registro de credencial
|
||||||
// Se falhar, a senha atual está incorreta
|
const credentialAccount = await db
|
||||||
try {
|
.select({ password: account.password })
|
||||||
await auth.api.changePassword({
|
.from(account)
|
||||||
body: {
|
.where(
|
||||||
newPassword: validated.password,
|
and(
|
||||||
currentPassword: validated.password,
|
eq(account.userId, session.user.id),
|
||||||
},
|
eq(account.providerId, "credential"),
|
||||||
headers: await headers(),
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const storedHash = credentialAccount[0]?.password;
|
||||||
|
if (!storedHash) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Conta de credencial não encontrada.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = await verifyPassword({
|
||||||
|
password: validated.password,
|
||||||
|
hash: storedHash,
|
||||||
});
|
});
|
||||||
} catch (authError: any) {
|
|
||||||
// Se der erro é porque a senha está incorreta
|
if (!isValid) {
|
||||||
console.error("Erro ao validar senha:", authError);
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: "Senha incorreta",
|
error: "Senha incorreta",
|
||||||
@@ -385,6 +417,8 @@ export async function updatePreferencesAction(
|
|||||||
.update(schema.preferenciasUsuario)
|
.update(schema.preferenciasUsuario)
|
||||||
.set({
|
.set({
|
||||||
disableMagnetlines: validated.disableMagnetlines,
|
disableMagnetlines: validated.disableMagnetlines,
|
||||||
|
systemFont: validated.systemFont,
|
||||||
|
moneyFont: validated.moneyFont,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(schema.preferenciasUsuario.userId, session.user.id));
|
.where(eq(schema.preferenciasUsuario.userId, session.user.id));
|
||||||
@@ -393,6 +427,8 @@ export async function updatePreferencesAction(
|
|||||||
await db.insert(schema.preferenciasUsuario).values({
|
await db.insert(schema.preferenciasUsuario).values({
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
disableMagnetlines: validated.disableMagnetlines,
|
disableMagnetlines: validated.disableMagnetlines,
|
||||||
|
systemFont: validated.systemFont,
|
||||||
|
moneyFont: validated.moneyFont,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { db, schema } from "@/lib/db";
|
|||||||
|
|
||||||
export interface UserPreferences {
|
export interface UserPreferences {
|
||||||
disableMagnetlines: boolean;
|
disableMagnetlines: boolean;
|
||||||
|
systemFont: string;
|
||||||
|
moneyFont: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiToken {
|
export interface ApiToken {
|
||||||
@@ -30,6 +32,8 @@ export async function fetchUserPreferences(
|
|||||||
const result = await db
|
const result = await db
|
||||||
.select({
|
.select({
|
||||||
disableMagnetlines: schema.preferenciasUsuario.disableMagnetlines,
|
disableMagnetlines: schema.preferenciasUsuario.disableMagnetlines,
|
||||||
|
systemFont: schema.preferenciasUsuario.systemFont,
|
||||||
|
moneyFont: schema.preferenciasUsuario.moneyFont,
|
||||||
})
|
})
|
||||||
.from(schema.preferenciasUsuario)
|
.from(schema.preferenciasUsuario)
|
||||||
.where(eq(schema.preferenciasUsuario.userId, userId))
|
.where(eq(schema.preferenciasUsuario.userId, userId))
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ export default async function Page() {
|
|||||||
disableMagnetlines={
|
disableMagnetlines={
|
||||||
userPreferences?.disableMagnetlines ?? false
|
userPreferences?.disableMagnetlines ?? false
|
||||||
}
|
}
|
||||||
|
systemFont={userPreferences?.systemFont ?? "ai-sans"}
|
||||||
|
moneyFont={userPreferences?.moneyFont ?? "ai-sans"}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { FontProvider } from "@/components/font-provider";
|
||||||
import { SiteHeader } from "@/components/header-dashboard";
|
import { SiteHeader } from "@/components/header-dashboard";
|
||||||
import { PrivacyProvider } from "@/components/privacy-provider";
|
import { PrivacyProvider } from "@/components/privacy-provider";
|
||||||
import { AppSidebar } from "@/components/sidebar/app-sidebar";
|
import { AppSidebar } from "@/components/sidebar/app-sidebar";
|
||||||
@@ -6,6 +7,7 @@ import { getUserSession } from "@/lib/auth/server";
|
|||||||
import { fetchDashboardNotifications } from "@/lib/dashboard/notifications";
|
import { fetchDashboardNotifications } from "@/lib/dashboard/notifications";
|
||||||
import { fetchPagadoresWithAccess } from "@/lib/pagadores/access";
|
import { fetchPagadoresWithAccess } from "@/lib/pagadores/access";
|
||||||
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
||||||
|
import { fetchUserFontPreferences } from "@/lib/preferences/fonts";
|
||||||
import { parsePeriodParam } from "@/lib/utils/period";
|
import { parsePeriodParam } from "@/lib/utils/period";
|
||||||
import { fetchPendingInboxCount } from "./pre-lancamentos/data";
|
import { fetchPendingInboxCount } from "./pre-lancamentos/data";
|
||||||
|
|
||||||
@@ -41,10 +43,17 @@ export default async function DashboardLayout({
|
|||||||
currentPeriod,
|
currentPeriod,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Buscar contagem de pré-lançamentos pendentes
|
// Buscar contagem de pré-lançamentos pendentes e preferências de fonte
|
||||||
const preLancamentosCount = await fetchPendingInboxCount(session.user.id);
|
const [preLancamentosCount, fontPrefs] = await Promise.all([
|
||||||
|
fetchPendingInboxCount(session.user.id),
|
||||||
|
fetchUserFontPreferences(session.user.id),
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<FontProvider
|
||||||
|
systemFont={fontPrefs.systemFont}
|
||||||
|
moneyFont={fontPrefs.moneyFont}
|
||||||
|
>
|
||||||
<PrivacyProvider>
|
<PrivacyProvider>
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<AppSidebar
|
<AppSidebar
|
||||||
@@ -71,5 +80,6 @@ export default async function DashboardLayout({
|
|||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
</PrivacyProvider>
|
</PrivacyProvider>
|
||||||
|
</FontProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
/* Font customization */
|
||||||
|
--font-app: var(--font-ai-sans);
|
||||||
|
--font-money: var(--font-ai-sans);
|
||||||
|
|
||||||
/* Base surfaces - warm cream with subtle orange undertone */
|
/* Base surfaces - warm cream with subtle orange undertone */
|
||||||
--background: oklch(96.563% 0.00504 67.275);
|
--background: oklch(96.563% 0.00504 67.275);
|
||||||
--foreground: oklch(18% 0.02 45);
|
--foreground: oklch(18% 0.02 45);
|
||||||
@@ -71,7 +75,7 @@
|
|||||||
--sidebar-ring: oklch(69.18% 0.18855 38.353);
|
--sidebar-ring: oklch(69.18% 0.18855 38.353);
|
||||||
|
|
||||||
/* Layout */
|
/* Layout */
|
||||||
--radius: 0.8rem;
|
--radius: 1rem;
|
||||||
|
|
||||||
/* Shadows - warm tinted for cohesion */
|
/* Shadows - warm tinted for cohesion */
|
||||||
--shadow-2xs: 0 1px 2px 0px oklch(35% 0.02 45 / 0.04);
|
--shadow-2xs: 0 1px 2px 0px oklch(35% 0.02 45 / 0.04);
|
||||||
@@ -160,7 +164,7 @@
|
|||||||
--sidebar-ring: oklch(69.18% 0.18855 38.353);
|
--sidebar-ring: oklch(69.18% 0.18855 38.353);
|
||||||
|
|
||||||
/* Layout */
|
/* Layout */
|
||||||
--radius: 0.8rem;
|
--radius: 1rem;
|
||||||
|
|
||||||
/* Shadows - deeper for dark mode */
|
/* Shadows - deeper for dark mode */
|
||||||
--shadow-2xs: 0 1px 2px 0px oklch(0% 0 0 / 0.3);
|
--shadow-2xs: 0 1px 2px 0px oklch(0% 0 0 / 0.3);
|
||||||
@@ -186,6 +190,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
|
--default-font-family: var(--font-app);
|
||||||
|
--default-mono-font-family: var(--font-money);
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--color-card: var(--card);
|
--color-card: var(--card);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { SpeedInsights } from "@vercel/speed-insights/next";
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { ThemeProvider } from "@/components/theme-provider";
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import { main_font } from "@/public/fonts/font_index";
|
import { allFontVariables } from "@/public/fonts/font_index";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -17,14 +17,11 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" className={allFontVariables} suppressHydrationWarning>
|
||||||
<head>
|
<head>
|
||||||
<meta name="apple-mobile-web-app-title" content="Opensheets" />
|
<meta name="apple-mobile-web-app-title" content="Opensheets" />
|
||||||
</head>
|
</head>
|
||||||
<body
|
<body className="antialiased" suppressHydrationWarning>
|
||||||
className={`${main_font.className} antialiased `}
|
|
||||||
suppressHydrationWarning
|
|
||||||
>
|
|
||||||
<ThemeProvider attribute="class" defaultTheme="light">
|
<ThemeProvider attribute="class" defaultTheme="light">
|
||||||
{children}
|
{children}
|
||||||
<Toaster position="top-right" />
|
<Toaster position="top-right" />
|
||||||
|
|||||||
@@ -1,22 +1,51 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useState, useTransition } from "react";
|
import { useEffect, useState, useTransition } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { updatePreferencesAction } from "@/app/(dashboard)/ajustes/actions";
|
import { updatePreferencesAction } from "@/app/(dashboard)/ajustes/actions";
|
||||||
|
import { useFont } from "@/components/font-provider";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { FONT_OPTIONS, getFontVariable } from "@/public/fonts/font_index";
|
||||||
|
|
||||||
interface PreferencesFormProps {
|
interface PreferencesFormProps {
|
||||||
disableMagnetlines: boolean;
|
disableMagnetlines: boolean;
|
||||||
|
systemFont: string;
|
||||||
|
moneyFont: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PreferencesForm({ disableMagnetlines }: PreferencesFormProps) {
|
export function PreferencesForm({
|
||||||
|
disableMagnetlines,
|
||||||
|
systemFont: initialSystemFont,
|
||||||
|
moneyFont: initialMoneyFont,
|
||||||
|
}: PreferencesFormProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const [magnetlinesDisabled, setMagnetlinesDisabled] =
|
const [magnetlinesDisabled, setMagnetlinesDisabled] =
|
||||||
useState(disableMagnetlines);
|
useState(disableMagnetlines);
|
||||||
|
const [selectedSystemFont, setSelectedSystemFont] =
|
||||||
|
useState(initialSystemFont);
|
||||||
|
const [selectedMoneyFont, setSelectedMoneyFont] = useState(initialMoneyFont);
|
||||||
|
|
||||||
|
const fontCtx = useFont();
|
||||||
|
|
||||||
|
// Live preview: update CSS vars when font selection changes
|
||||||
|
useEffect(() => {
|
||||||
|
fontCtx.setSystemFont(selectedSystemFont);
|
||||||
|
}, [selectedSystemFont, fontCtx.setSystemFont]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fontCtx.setMoneyFont(selectedMoneyFont);
|
||||||
|
}, [selectedMoneyFont, fontCtx.setMoneyFont]);
|
||||||
|
|
||||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -24,16 +53,13 @@ export function PreferencesForm({ disableMagnetlines }: PreferencesFormProps) {
|
|||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const result = await updatePreferencesAction({
|
const result = await updatePreferencesAction({
|
||||||
disableMagnetlines: magnetlinesDisabled,
|
disableMagnetlines: magnetlinesDisabled,
|
||||||
|
systemFont: selectedSystemFont,
|
||||||
|
moneyFont: selectedMoneyFont,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success(result.message);
|
toast.success(result.message);
|
||||||
// Recarregar a página para aplicar as mudanças nos componentes
|
|
||||||
router.refresh();
|
router.refresh();
|
||||||
// Forçar reload completo para garantir que os hooks re-executem
|
|
||||||
setTimeout(() => {
|
|
||||||
window.location.reload();
|
|
||||||
}, 500);
|
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.error);
|
toast.error(result.error);
|
||||||
}
|
}
|
||||||
@@ -41,16 +67,103 @@ export function PreferencesForm({ disableMagnetlines }: PreferencesFormProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col space-y-6">
|
<form onSubmit={handleSubmit} className="flex flex-col gap-8">
|
||||||
<div className="space-y-4 max-w-md">
|
{/* Seção 1: Tipografia */}
|
||||||
<div className="flex items-center justify-between rounded-lg border border-dashed p-4">
|
<section className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-semibold">Tipografia</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Personalize as fontes usadas na interface e nos valores monetários.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fonte do sistema */}
|
||||||
|
<div className="space-y-2 max-w-md">
|
||||||
|
<Label htmlFor="system-font">Fonte do sistema</Label>
|
||||||
|
<Select
|
||||||
|
value={selectedSystemFont}
|
||||||
|
onValueChange={setSelectedSystemFont}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="system-font">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{FONT_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt.key} value={opt.key}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: opt.variable,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p
|
||||||
|
className="text-sm text-muted-foreground pt-1"
|
||||||
|
style={{
|
||||||
|
fontFamily: getFontVariable(selectedSystemFont),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Suas finanças em um só lugar
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fonte de valores */}
|
||||||
|
<div className="space-y-2 max-w-md">
|
||||||
|
<Label htmlFor="money-font">Fonte de valores</Label>
|
||||||
|
<Select
|
||||||
|
value={selectedMoneyFont}
|
||||||
|
onValueChange={setSelectedMoneyFont}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="money-font">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{FONT_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt.key} value={opt.key}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: opt.variable,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p
|
||||||
|
className="text-sm text-muted-foreground pt-1 tabular-nums"
|
||||||
|
style={{
|
||||||
|
fontFamily: getFontVariable(selectedMoneyFont),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
R$ 1.234,56
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="border-b" />
|
||||||
|
|
||||||
|
{/* Seção 3: Dashboard */}
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-semibold">Dashboard</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Opções que afetam a experiência no painel principal.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between rounded-lg border p-4 max-w-md">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<Label htmlFor="magnetlines" className="text-base">
|
<Label htmlFor="magnetlines" className="text-base">
|
||||||
Desabilitar Magnetlines
|
Desabilitar Magnetlines
|
||||||
</Label>
|
</Label>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Remove o recurso de linhas magnéticas do sistema. Essa mudança
|
Remove o recurso de linhas magnéticas do sistema.
|
||||||
afeta a interface e interações visuais.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
@@ -60,7 +173,7 @@ export function PreferencesForm({ disableMagnetlines }: PreferencesFormProps) {
|
|||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button type="submit" disabled={isPending} className="w-fit">
|
<Button type="submit" disabled={isPending} className="w-fit">
|
||||||
|
|||||||
80
components/font-provider.tsx
Normal file
80
components/font-provider.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { getFontVariable } from "@/public/fonts/font_index";
|
||||||
|
|
||||||
|
type FontContextValue = {
|
||||||
|
systemFont: string;
|
||||||
|
moneyFont: string;
|
||||||
|
setSystemFont: (key: string) => void;
|
||||||
|
setMoneyFont: (key: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FontContext = createContext<FontContextValue | null>(null);
|
||||||
|
|
||||||
|
export function FontProvider({
|
||||||
|
systemFont: initialSystemFont,
|
||||||
|
moneyFont: initialMoneyFont,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
systemFont: string;
|
||||||
|
moneyFont: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const [systemFont, setSystemFontState] = useState(initialSystemFont);
|
||||||
|
const [moneyFont, setMoneyFontState] = useState(initialMoneyFont);
|
||||||
|
|
||||||
|
const applyFontVars = useCallback((sys: string, money: string) => {
|
||||||
|
document.documentElement.style.setProperty(
|
||||||
|
"--font-app",
|
||||||
|
getFontVariable(sys),
|
||||||
|
);
|
||||||
|
document.documentElement.style.setProperty(
|
||||||
|
"--font-money",
|
||||||
|
getFontVariable(money),
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
applyFontVars(systemFont, moneyFont);
|
||||||
|
}, [systemFont, moneyFont, applyFontVars]);
|
||||||
|
|
||||||
|
const setSystemFont = useCallback((key: string) => {
|
||||||
|
setSystemFontState(key);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setMoneyFont = useCallback((key: string) => {
|
||||||
|
setMoneyFontState(key);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({ systemFont, moneyFont, setSystemFont, setMoneyFont }),
|
||||||
|
[systemFont, moneyFont, setSystemFont, setMoneyFont],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<style
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `:root { --font-app: ${getFontVariable(initialSystemFont)}; --font-money: ${getFontVariable(initialMoneyFont)}; }`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<FontContext value={value}>{children}</FontContext>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFont() {
|
||||||
|
const ctx = useContext(FontContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("useFont must be used within FontProvider");
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils/ui";
|
import { cn } from "@/lib/utils/ui";
|
||||||
import { money_font } from "@/public/fonts/font_index";
|
|
||||||
import { usePrivacyMode } from "./privacy-provider";
|
import { usePrivacyMode } from "./privacy-provider";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -24,8 +23,8 @@ function MoneyValues({ amount, className, showPositiveSign = false }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
|
style={{ fontFamily: "var(--font-money)" }}
|
||||||
className={cn(
|
className={cn(
|
||||||
money_font.className,
|
|
||||||
"inline-flex items-baseline transition-all duration-200 tracking-tighter",
|
"inline-flex items-baseline transition-all duration-200 tracking-tighter",
|
||||||
privacyMode &&
|
privacyMode &&
|
||||||
"blur-[6px] select-none hover:blur-none focus-within:blur-none",
|
"blur-[6px] select-none hover:blur-none focus-within:blur-none",
|
||||||
|
|||||||
@@ -32,6 +32,13 @@ export function CategoryCell({
|
|||||||
const isIncrease = percentageChange !== null && percentageChange > 0;
|
const isIncrease = percentageChange !== null && percentageChange > 0;
|
||||||
const isDecrease = percentageChange !== null && percentageChange < 0;
|
const isDecrease = percentageChange !== null && percentageChange < 0;
|
||||||
|
|
||||||
|
// Despesa: aumento é ruim (vermelho), diminuição é bom (verde)
|
||||||
|
// Receita: aumento é bom (verde), diminuição é ruim (vermelho)
|
||||||
|
const isPositive =
|
||||||
|
categoryType === "receita" ? isIncrease : isDecrease;
|
||||||
|
const isNegative =
|
||||||
|
categoryType === "receita" ? isDecrease : isIncrease;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
@@ -41,8 +48,8 @@ export function CategoryCell({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-0.5 text-xs",
|
"flex items-center gap-0.5 text-xs",
|
||||||
isIncrease && "text-destructive",
|
isNegative && "text-destructive",
|
||||||
isDecrease && "text-success",
|
isPositive && "text-success",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isIncrease && <RiArrowUpSFill className="h-3 w-3" />}
|
{isIncrease && <RiArrowUpSFill className="h-3 w-3" />}
|
||||||
@@ -63,8 +70,8 @@ export function CategoryCell({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"font-medium",
|
"font-medium",
|
||||||
isIncrease && "text-destructive",
|
isNegative && "text-destructive",
|
||||||
isDecrease && "text-success",
|
isPositive && "text-success",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
Diferença:{" "}
|
Diferença:{" "}
|
||||||
|
|||||||
@@ -107,6 +107,8 @@ export const preferenciasUsuario = pgTable("preferencias_usuario", {
|
|||||||
.unique()
|
.unique()
|
||||||
.references(() => user.id, { onDelete: "cascade" }),
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
disableMagnetlines: boolean("disable_magnetlines").notNull().default(false),
|
disableMagnetlines: boolean("disable_magnetlines").notNull().default(false),
|
||||||
|
systemFont: text("system_font").notNull().default("ai-sans"),
|
||||||
|
moneyFont: text("money_font").notNull().default("ai-sans"),
|
||||||
dashboardWidgets: jsonb("dashboard_widgets").$type<{
|
dashboardWidgets: jsonb("dashboard_widgets").$type<{
|
||||||
order: string[];
|
order: string[];
|
||||||
hidden: string[];
|
hidden: string[];
|
||||||
|
|||||||
2
drizzle/0016_complete_randall.sql
Normal file
2
drizzle/0016_complete_randall.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "preferencias_usuario" ADD COLUMN "system_font" text DEFAULT 'ai-sans' NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "preferencias_usuario" ADD COLUMN "money_font" text DEFAULT 'ai-sans' NOT NULL;
|
||||||
2191
drizzle/meta/0016_snapshot.json
Normal file
2191
drizzle/meta/0016_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -113,6 +113,13 @@
|
|||||||
"when": 1770332054481,
|
"when": 1770332054481,
|
||||||
"tag": "0015_concerned_kat_farrell",
|
"tag": "0015_concerned_kat_farrell",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 16,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1771166328908,
|
||||||
|
"tag": "0016_complete_randall",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
33
lib/preferences/fonts.ts
Normal file
33
lib/preferences/fonts.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { cache } from "react";
|
||||||
|
import { db, schema } from "@/lib/db";
|
||||||
|
|
||||||
|
export type FontPreferences = {
|
||||||
|
systemFont: string;
|
||||||
|
moneyFont: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_FONT_PREFS: FontPreferences = {
|
||||||
|
systemFont: "ai-sans",
|
||||||
|
moneyFont: "ai-sans",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchUserFontPreferences = cache(
|
||||||
|
async (userId: string): Promise<FontPreferences> => {
|
||||||
|
const result = await db
|
||||||
|
.select({
|
||||||
|
systemFont: schema.preferenciasUsuario.systemFont,
|
||||||
|
moneyFont: schema.preferenciasUsuario.moneyFont,
|
||||||
|
})
|
||||||
|
.from(schema.preferenciasUsuario)
|
||||||
|
.where(eq(schema.preferenciasUsuario.userId, userId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!result[0]) return DEFAULT_FONT_PREFS;
|
||||||
|
|
||||||
|
return {
|
||||||
|
systemFont: result[0].systemFont,
|
||||||
|
moneyFont: result[0].moneyFont,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
BIN
public/fonts/SF-Pro-Display-Bold.otf
Normal file
BIN
public/fonts/SF-Pro-Display-Bold.otf
Normal file
Binary file not shown.
BIN
public/fonts/SF-Pro-Display-Medium.otf
Normal file
BIN
public/fonts/SF-Pro-Display-Medium.otf
Normal file
Binary file not shown.
BIN
public/fonts/SF-Pro-Display-Regular.otf
Normal file
BIN
public/fonts/SF-Pro-Display-Regular.otf
Normal file
Binary file not shown.
BIN
public/fonts/SF-Pro-Display-Semibold.otf
Normal file
BIN
public/fonts/SF-Pro-Display-Semibold.otf
Normal file
Binary file not shown.
BIN
public/fonts/SF-Pro-Rounded-Bold.otf
Normal file
BIN
public/fonts/SF-Pro-Rounded-Bold.otf
Normal file
Binary file not shown.
BIN
public/fonts/SF-Pro-Rounded-Medium.otf
Normal file
BIN
public/fonts/SF-Pro-Rounded-Medium.otf
Normal file
Binary file not shown.
BIN
public/fonts/SF-Pro-Rounded-Regular.otf
Normal file
BIN
public/fonts/SF-Pro-Rounded-Regular.otf
Normal file
Binary file not shown.
BIN
public/fonts/SF-Pro-Rounded-Semibold.otf
Normal file
BIN
public/fonts/SF-Pro-Rounded-Semibold.otf
Normal file
Binary file not shown.
@@ -1,3 +1,14 @@
|
|||||||
|
import {
|
||||||
|
Fira_Code,
|
||||||
|
Fira_Sans,
|
||||||
|
Geist,
|
||||||
|
IBM_Plex_Mono,
|
||||||
|
Inter,
|
||||||
|
JetBrains_Mono,
|
||||||
|
Reddit_Sans,
|
||||||
|
Roboto,
|
||||||
|
Ubuntu,
|
||||||
|
} from "next/font/google";
|
||||||
import localFont from "next/font/local";
|
import localFont from "next/font/local";
|
||||||
|
|
||||||
const ai_sans = localFont({
|
const ai_sans = localFont({
|
||||||
@@ -14,14 +25,204 @@ const ai_sans = localFont({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
display: "swap",
|
display: "swap",
|
||||||
|
variable: "--font-ai-sans",
|
||||||
});
|
});
|
||||||
|
|
||||||
const anthropic_sans = localFont({
|
const anthropic_sans = localFont({
|
||||||
src: "./anthropicSans.woff2",
|
src: "./anthropicSans.woff2",
|
||||||
display: "swap",
|
display: "swap",
|
||||||
|
variable: "--font-anthropic-sans",
|
||||||
});
|
});
|
||||||
|
|
||||||
const main_font = ai_sans;
|
const sf_pro_display = localFont({
|
||||||
const money_font = ai_sans;
|
src: [
|
||||||
|
{
|
||||||
|
path: "./SF-Pro-Display-Regular.otf",
|
||||||
|
weight: "400",
|
||||||
|
style: "normal",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "./SF-Pro-Display-Medium.otf",
|
||||||
|
weight: "500",
|
||||||
|
style: "normal",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "./SF-Pro-Display-Semibold.otf",
|
||||||
|
weight: "600",
|
||||||
|
style: "normal",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "./SF-Pro-Display-Bold.otf",
|
||||||
|
weight: "700",
|
||||||
|
style: "normal",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
display: "swap",
|
||||||
|
variable: "--font-sf-pro-display",
|
||||||
|
});
|
||||||
|
|
||||||
export { main_font, money_font };
|
const sf_pro_rounded = localFont({
|
||||||
|
src: [
|
||||||
|
{
|
||||||
|
path: "./SF-Pro-Rounded-Regular.otf",
|
||||||
|
weight: "400",
|
||||||
|
style: "normal",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "./SF-Pro-Rounded-Medium.otf",
|
||||||
|
weight: "500",
|
||||||
|
style: "normal",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "./SF-Pro-Rounded-Semibold.otf",
|
||||||
|
weight: "600",
|
||||||
|
style: "normal",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "./SF-Pro-Rounded-Bold.otf",
|
||||||
|
weight: "700",
|
||||||
|
style: "normal",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
display: "swap",
|
||||||
|
variable: "--font-sf-pro-rounded",
|
||||||
|
});
|
||||||
|
|
||||||
|
const inter = Inter({
|
||||||
|
subsets: ["latin"],
|
||||||
|
display: "swap",
|
||||||
|
variable: "--font-inter",
|
||||||
|
});
|
||||||
|
|
||||||
|
const geist_sans = Geist({
|
||||||
|
subsets: ["latin"],
|
||||||
|
display: "swap",
|
||||||
|
variable: "--font-geist",
|
||||||
|
});
|
||||||
|
|
||||||
|
const roboto = Roboto({
|
||||||
|
subsets: ["latin"],
|
||||||
|
display: "swap",
|
||||||
|
variable: "--font-roboto",
|
||||||
|
});
|
||||||
|
|
||||||
|
const reddit_sans = Reddit_Sans({
|
||||||
|
subsets: ["latin"],
|
||||||
|
display: "swap",
|
||||||
|
variable: "--font-reddit-sans",
|
||||||
|
});
|
||||||
|
|
||||||
|
const fira_sans = Fira_Sans({
|
||||||
|
weight: ["400", "500", "600", "700"],
|
||||||
|
subsets: ["latin"],
|
||||||
|
display: "swap",
|
||||||
|
variable: "--font-fira-sans",
|
||||||
|
});
|
||||||
|
|
||||||
|
const ubuntu = Ubuntu({
|
||||||
|
weight: ["400"],
|
||||||
|
subsets: ["latin"],
|
||||||
|
display: "swap",
|
||||||
|
variable: "--font-ubuntu",
|
||||||
|
});
|
||||||
|
|
||||||
|
const jetbrains_mono = JetBrains_Mono({
|
||||||
|
subsets: ["latin"],
|
||||||
|
display: "swap",
|
||||||
|
variable: "--font-jetbrains-mono",
|
||||||
|
});
|
||||||
|
|
||||||
|
const fira_code = Fira_Code({
|
||||||
|
subsets: ["latin"],
|
||||||
|
display: "swap",
|
||||||
|
variable: "--font-fira-code",
|
||||||
|
});
|
||||||
|
|
||||||
|
const ibm_plex_mono = IBM_Plex_Mono({
|
||||||
|
weight: ["400", "500", "600"],
|
||||||
|
subsets: ["latin"],
|
||||||
|
display: "swap",
|
||||||
|
variable: "--font-ibm-plex-mono",
|
||||||
|
});
|
||||||
|
|
||||||
|
export type FontOption = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
variable: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FONT_OPTIONS: FontOption[] = [
|
||||||
|
{ key: "ai-sans", label: "AI Sans", variable: "var(--font-ai-sans)" },
|
||||||
|
{
|
||||||
|
key: "anthropic-sans",
|
||||||
|
label: "Anthropic Sans",
|
||||||
|
variable: "var(--font-anthropic-sans)",
|
||||||
|
},
|
||||||
|
{ key: "fira-code", label: "Fira Code", variable: "var(--font-fira-code)" },
|
||||||
|
{
|
||||||
|
key: "fira-sans",
|
||||||
|
label: "Fira Sans",
|
||||||
|
variable: "var(--font-fira-sans)",
|
||||||
|
},
|
||||||
|
{ key: "geist", label: "Geist Sans", variable: "var(--font-geist)" },
|
||||||
|
{
|
||||||
|
key: "ibm-plex-mono",
|
||||||
|
label: "IBM Plex Mono",
|
||||||
|
variable: "var(--font-ibm-plex-mono)",
|
||||||
|
},
|
||||||
|
{ key: "inter", label: "Inter", variable: "var(--font-inter)" },
|
||||||
|
{
|
||||||
|
key: "jetbrains-mono",
|
||||||
|
label: "JetBrains Mono",
|
||||||
|
variable: "var(--font-jetbrains-mono)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "reddit-sans",
|
||||||
|
label: "Reddit Sans",
|
||||||
|
variable: "var(--font-reddit-sans)",
|
||||||
|
},
|
||||||
|
{ key: "roboto", label: "Roboto", variable: "var(--font-roboto)" },
|
||||||
|
{
|
||||||
|
key: "sf-pro-display",
|
||||||
|
label: "SF Pro Display",
|
||||||
|
variable: "var(--font-sf-pro-display)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "sf-pro-rounded",
|
||||||
|
label: "SF Pro Rounded",
|
||||||
|
variable: "var(--font-sf-pro-rounded)",
|
||||||
|
},
|
||||||
|
{ key: "ubuntu", label: "Ubuntu", variable: "var(--font-ubuntu)" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** @deprecated Use FONT_OPTIONS */
|
||||||
|
export const SYSTEM_FONT_OPTIONS = FONT_OPTIONS;
|
||||||
|
/** @deprecated Use FONT_OPTIONS */
|
||||||
|
export const MONEY_FONT_OPTIONS = FONT_OPTIONS;
|
||||||
|
|
||||||
|
const allFonts = [
|
||||||
|
ai_sans,
|
||||||
|
anthropic_sans,
|
||||||
|
sf_pro_display,
|
||||||
|
sf_pro_rounded,
|
||||||
|
inter,
|
||||||
|
geist_sans,
|
||||||
|
roboto,
|
||||||
|
reddit_sans,
|
||||||
|
fira_sans,
|
||||||
|
ubuntu,
|
||||||
|
jetbrains_mono,
|
||||||
|
fira_code,
|
||||||
|
ibm_plex_mono,
|
||||||
|
];
|
||||||
|
|
||||||
|
export const allFontVariables = allFonts.map((f) => f.variable).join(" ");
|
||||||
|
|
||||||
|
// Backward compatibility
|
||||||
|
export const main_font = ai_sans;
|
||||||
|
export const money_font = ai_sans;
|
||||||
|
|
||||||
|
export function getFontVariable(key: string): string {
|
||||||
|
const option = FONT_OPTIONS.find((o) => o.key === key);
|
||||||
|
return option?.variable ?? "var(--font-ai-sans)";
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user