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:
Felipe Coutinho
2026-02-15 21:35:00 +00:00
parent 4b442a907a
commit 2362a70b9d
25 changed files with 2779 additions and 70 deletions

View File

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

View File

@@ -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(), ),
}); )
} catch (authError: any) { .limit(1);
// Se der erro é porque a senha está incorreta
console.error("Erro ao validar senha:", authError); 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,
});
if (!isValid) {
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,
}); });
} }

View File

@@ -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))

View File

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

View File

@@ -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,35 +43,43 @@ 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 (
<PrivacyProvider> <FontProvider
<SidebarProvider> systemFont={fontPrefs.systemFont}
<AppSidebar moneyFont={fontPrefs.moneyFont}
user={{ ...session.user, image: session.user.image ?? null }} >
pagadorAvatarUrl={adminPagador?.avatarUrl ?? null} <PrivacyProvider>
pagadores={pagadoresList.map((item) => ({ <SidebarProvider>
id: item.id, <AppSidebar
name: item.name, user={{ ...session.user, image: session.user.image ?? null }}
avatarUrl: item.avatarUrl, pagadorAvatarUrl={adminPagador?.avatarUrl ?? null}
canEdit: item.canEdit, pagadores={pagadoresList.map((item) => ({
}))} id: item.id,
preLancamentosCount={preLancamentosCount} name: item.name,
variant="sidebar" avatarUrl: item.avatarUrl,
/> canEdit: item.canEdit,
<SidebarInset> }))}
<SiteHeader notificationsSnapshot={notificationsSnapshot} /> preLancamentosCount={preLancamentosCount}
<div className="flex flex-1 flex-col"> variant="sidebar"
<div className="@container/main flex flex-1 flex-col gap-2"> />
<div className="flex flex-col gap-4 py-4 md:gap-6"> <SidebarInset>
{children} <SiteHeader notificationsSnapshot={notificationsSnapshot} />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<div className="flex flex-col gap-4 py-4 md:gap-6">
{children}
</div>
</div> </div>
</div> </div>
</div> </SidebarInset>
</SidebarInset> </SidebarProvider>
</SidebarProvider> </PrivacyProvider>
</PrivacyProvider> </FontProvider>
); );
} }

View File

@@ -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);

View File

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

View File

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

View 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;
}

View File

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

View File

@@ -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:{" "}

View File

@@ -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[];

View 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;

File diff suppressed because it is too large Load Diff

View File

@@ -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
View 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,
};
},
);

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -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)";
}