Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions app/accent-provider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"use client";

import { createContext, useCallback, useContext, useEffect, useState } from "react";

import { apiJson } from "@/lib/api";
import { isValidAccentColor, type AccentColorId } from "@/lib/accent-colors";

export const ACCENT_STORAGE_KEY = "modelhub-accent";

type AccentContextValue = {
accent: AccentColorId | null;
setAccent: (accent: AccentColorId | null) => void;
};

const AccentContext = createContext<AccentContextValue | undefined>(undefined);

function applyAccentToDocument(accent: AccentColorId | null) {
const root = document.documentElement;
if (accent && accent !== "default") {
root.setAttribute("data-accent", accent);
} else {
root.removeAttribute("data-accent");
}
}

/**
* Aplica a preferência de accent do usuário (UserSettings.accentColor) em
* runtime. A pintura inicial acontece via script inline no layout (anti-flash,
* lendo o localStorage); este provider sincroniza com o valor do servidor.
*/
export function AccentProvider({ children }: { children: React.ReactNode }) {
const [accent, setAccentState] = useState<AccentColorId | null>(null);

useEffect(() => {
let cancelled = false;

async function loadAccent() {
try {
const data = await apiJson<{ settings?: { accentColor?: string | null } }>(
"/user/settings",
);
if (cancelled) return;
const stored = data.settings?.accentColor;
if (isValidAccentColor(stored)) {
setAccentState(stored);
applyAccentToDocument(stored);
}
} catch {
// Sem sessão ou offline: mantém o que o script inline aplicou.
}
}

void loadAccent();
return () => {
cancelled = true;
};
}, []);

const setAccent = useCallback((next: AccentColorId | null) => {
setAccentState(next);
applyAccentToDocument(next);
try {
if (next && next !== "default") {
window.localStorage.setItem(ACCENT_STORAGE_KEY, next);
} else {
window.localStorage.removeItem(ACCENT_STORAGE_KEY);
}
} catch {
// localStorage bloqueado: apenas não persiste localmente.
}
}, []);

return <AccentContext.Provider value={{ accent, setAccent }}>{children}</AccentContext.Provider>;
}

export function useAccent(): AccentContextValue {
const ctx = useContext(AccentContext);
if (!ctx) throw new Error("useAccent must be used within AccentProvider");
return ctx;
}
127 changes: 127 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,133 @@
}
}

/*
* Accent color presets (issue #177).
* Selecionados via atributo data-accent no <html>. "default" (sem atributo)
* mantém o tema two-tone atual: navy no claro, âmbar no escuro.
* Cada preset sobrescreve --primary/--ring/--accent para dar destaque
* consistente em ambos os modos; o restante das vars segue o tema base.
*/
[data-accent="blue"] {
--primary: oklch(0.48 0.19 262.5);
--primary-foreground: oklch(0.985 0 0);
--ring: oklch(0.62 0.19 262.5);
--accent: oklch(0.93 0.03 255);
--accent-foreground: oklch(0.38 0.13 265);
--sidebar-primary: oklch(0.62 0.19 262.5);
--sidebar-ring: oklch(0.62 0.19 262.5);
}

.dark[data-accent="blue"] {
--primary: oklch(0.68 0.16 262.5);
--primary-foreground: oklch(0.16 0.03 262);
--ring: oklch(0.68 0.16 262.5);
--accent: oklch(0.33 0.06 262);
--accent-foreground: oklch(0.78 0.12 262.5);
--sidebar-primary: oklch(0.68 0.16 262.5);
--sidebar-ring: oklch(0.68 0.16 262.5);
}

[data-accent="violet"] {
--primary: oklch(0.51 0.25 293);
--primary-foreground: oklch(0.985 0 0);
--ring: oklch(0.62 0.23 293);
--accent: oklch(0.94 0.03 300);
--accent-foreground: oklch(0.4 0.16 293);
--sidebar-primary: oklch(0.62 0.23 293);
--sidebar-ring: oklch(0.62 0.23 293);
}

.dark[data-accent="violet"] {
--primary: oklch(0.7 0.19 293);
--primary-foreground: oklch(0.17 0.05 293);
--ring: oklch(0.7 0.19 293);
--accent: oklch(0.34 0.07 293);
--accent-foreground: oklch(0.8 0.12 293);
--sidebar-primary: oklch(0.7 0.19 293);
--sidebar-ring: oklch(0.7 0.19 293);
}

[data-accent="emerald"] {
--primary: oklch(0.5 0.14 163);
--primary-foreground: oklch(0.985 0 0);
--ring: oklch(0.62 0.14 163);
--accent: oklch(0.94 0.03 163);
--accent-foreground: oklch(0.35 0.09 163);
--sidebar-primary: oklch(0.62 0.14 163);
--sidebar-ring: oklch(0.62 0.14 163);
}

.dark[data-accent="emerald"] {
--primary: oklch(0.71 0.15 163);
--primary-foreground: oklch(0.17 0.04 163);
--ring: oklch(0.71 0.15 163);
--accent: oklch(0.33 0.05 163);
--accent-foreground: oklch(0.8 0.12 163);
--sidebar-primary: oklch(0.71 0.15 163);
--sidebar-ring: oklch(0.71 0.15 163);
}

[data-accent="orange"] {
--primary: oklch(0.6 0.2 41);
--primary-foreground: oklch(0.985 0 0);
--ring: oklch(0.68 0.18 41);
--accent: oklch(0.95 0.03 55);
--accent-foreground: oklch(0.42 0.14 41);
--sidebar-primary: oklch(0.68 0.18 41);
--sidebar-ring: oklch(0.68 0.18 41);
}

.dark[data-accent="orange"] {
--primary: oklch(0.74 0.16 48);
--primary-foreground: oklch(0.19 0.05 48);
--ring: oklch(0.74 0.16 48);
--accent: oklch(0.35 0.06 48);
--accent-foreground: oklch(0.82 0.12 48);
--sidebar-primary: oklch(0.74 0.16 48);
--sidebar-ring: oklch(0.74 0.16 48);
}

[data-accent="rose"] {
--primary: oklch(0.53 0.21 17);
--primary-foreground: oklch(0.985 0 0);
--ring: oklch(0.64 0.19 17);
--accent: oklch(0.94 0.03 17);
--accent-foreground: oklch(0.42 0.15 17);
--sidebar-primary: oklch(0.64 0.19 17);
--sidebar-ring: oklch(0.64 0.19 17);
}

.dark[data-accent="rose"] {
--primary: oklch(0.71 0.17 17);
--primary-foreground: oklch(0.18 0.04 17);
--ring: oklch(0.71 0.17 17);
--accent: oklch(0.34 0.06 17);
--accent-foreground: oklch(0.8 0.11 17);
--sidebar-primary: oklch(0.71 0.17 17);
--sidebar-ring: oklch(0.71 0.17 17);
}

[data-accent="teal"] {
--primary: oklch(0.5 0.1 185);
--primary-foreground: oklch(0.985 0 0);
--ring: oklch(0.62 0.1 185);
--accent: oklch(0.94 0.02 185);
--accent-foreground: oklch(0.36 0.07 185);
--sidebar-primary: oklch(0.62 0.1 185);
--sidebar-ring: oklch(0.62 0.1 185);
}

.dark[data-accent="teal"] {
--primary: oklch(0.72 0.11 185);
--primary-foreground: oklch(0.17 0.03 185);
--ring: oklch(0.72 0.11 185);
--accent: oklch(0.33 0.04 185);
--accent-foreground: oklch(0.8 0.09 185);
--sidebar-primary: oklch(0.72 0.11 185);
--sidebar-ring: oklch(0.72 0.11 185);
}

@layer components {
.markdown-renderer {
@apply text-sm leading-7 text-foreground;
Expand Down
8 changes: 8 additions & 0 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,14 @@ export default function RootLayout({
suppressHydrationWarning
className={`${fontSans.variable} ${fontSerif.variable} ${fontMono.variable} antialiased`}
>
<head>
{/* Aplica o accent salvo antes da hidratação para evitar flash (issue #177). */}
<script
dangerouslySetInnerHTML={{
__html: `(function(){try{var a=localStorage.getItem("modelhub-accent");var v=["blue","violet","emerald","orange","rose","teal"];if(a&&v.indexOf(a)!==-1){document.documentElement.setAttribute("data-accent",a)}}catch(e){}})();`,
}}
/>
</head>
<body className="min-h-svh bg-background text-foreground">
<Providers>{children}</Providers>
<Analytics />
Expand Down
15 changes: 9 additions & 6 deletions app/providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,21 @@ import { ThemeProvider } from "next-themes";
import { authClient } from "@/lib/auth/client";
import { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import { AccentProvider } from "./accent-provider";

const uiAuthClient = authClient as unknown as ComponentProps<typeof NeonAuthUIProvider>["authClient"];

export function Providers({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
<NeonAuthUIProvider authClient={uiAuthClient} redirectTo="/chat">
<TooltipProvider delayDuration={100}>
{children}
<Toaster richColors />
</TooltipProvider>
</NeonAuthUIProvider>
<AccentProvider>
<NeonAuthUIProvider authClient={uiAuthClient} redirectTo="/chat">
<TooltipProvider delayDuration={100}>
{children}
<Toaster richColors />
</TooltipProvider>
</NeonAuthUIProvider>
</AccentProvider>
</ThemeProvider>
);
}
69 changes: 67 additions & 2 deletions components/chat/settings-dialog.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
"use client";

import { useCallback, useEffect, useState } from "react";
import { BrainIcon, Loader2Icon, SaveIcon, Trash2Icon, UserIcon } from "lucide-react";
import { BrainIcon, CheckIcon, Loader2Icon, PaletteIcon, SaveIcon, Trash2Icon, UserIcon } from "lucide-react";
import { toast } from "sonner";

import { useAccent } from "@/app/accent-provider";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Expand All @@ -16,10 +17,13 @@ import {
} from "@/components/ui/sheet";

import { apiJson, apiJsonRequest } from "@/lib/api";
import { ACCENT_COLOR_OPTIONS, ACCENT_SWATCH, type AccentColorId } from "@/lib/accent-colors";
import { cn } from "@/lib/utils";

type UserSettings = {
customInstructionsAbout: string | null;
customInstructionsStyle: string | null;
accentColor: string | null;
};

type UserMemory = {
Expand All @@ -34,14 +38,17 @@ type Props = {
};

export function SettingsDialog({ open, onOpenChange }: Props) {
const [tab, setTab] = useState<"instructions" | "memory">("instructions");
const [tab, setTab] = useState<"instructions" | "appearance" | "memory">("instructions");
const [loading, setLoading] = useState(false);

// Custom instructions state
const [about, setAbout] = useState("");
const [style, setStyle] = useState("");
const [savingInstructions, setSavingInstructions] = useState(false);

// Appearance state
const { accent: currentAccent, setAccent } = useAccent();

// Memory state
const [memories, setMemories] = useState<UserMemory[]>([]);
const [newMemory, setNewMemory] = useState("");
Expand Down Expand Up @@ -94,6 +101,15 @@ export function SettingsDialog({ open, onOpenChange }: Props) {
}
}

async function handleAccentChange(next: AccentColorId) {
setAccent(next);
try {
await apiJsonRequest("/user/settings", "PATCH", { accentColor: next });
} catch {
toast.error("Falha ao salvar a cor de destaque.");
}
}

async function handleAddMemory() {
if (!newMemory.trim()) return;
try {
Expand Down Expand Up @@ -139,6 +155,16 @@ export function SettingsDialog({ open, onOpenChange }: Props) {
<UserIcon className="mr-1 inline-block size-3" />
Instruções
</button>
<button
type="button"
onClick={() => setTab("appearance")}
className={`flex-1 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
tab === "appearance" ? "bg-background shadow-sm" : "text-muted-foreground hover:text-foreground"
}`}
>
<PaletteIcon className="mr-1 inline-block size-3" />
Aparência
</button>
<button
type="button"
onClick={() => setTab("memory")}
Expand Down Expand Up @@ -189,6 +215,45 @@ export function SettingsDialog({ open, onOpenChange }: Props) {
</div>
</>
)
) : tab === "appearance" ? (
<div className="flex flex-col gap-4">
<div>
<p className="text-xs font-medium">Cor de destaque</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Personaliza botões, links e foco. Aplicado na hora e salvo na sua conta.
</p>
</div>
<div className="grid grid-cols-4 gap-3 sm:grid-cols-7">
{ACCENT_COLOR_OPTIONS.map((option) => {
const active = (currentAccent ?? "default") === option.id;
return (
<button
key={option.id}
type="button"
onClick={() => void handleAccentChange(option.id)}
aria-pressed={active}
aria-label={`Cor de destaque: ${option.label}`}
className="group flex flex-col items-center gap-1.5 rounded-lg p-1.5 transition-colors hover:bg-muted"
>
<span
className={cn(
"relative flex size-8 items-center justify-center rounded-full ring-offset-2 ring-offset-background transition-all",
active
? "ring-2 ring-ring"
: "group-hover:ring-2 group-hover:ring-ring/60",
)}
style={{ backgroundColor: ACCENT_SWATCH[option.id] }}
>
{active ? <CheckIcon className="size-4 text-white" strokeWidth={3} /> : null}
</span>
<span className="text-[10px] font-medium text-muted-foreground group-hover:text-foreground">
{option.label}
</span>
</button>
);
})}
</div>
</div>
) : (
<div className="flex flex-col gap-3">
<p className="text-xs text-muted-foreground">
Expand Down
Loading
Loading