feat(theme): account-bound Dark Mode toggle (Gitea #8)

Wires up the already-present next-themes dependency and globals.css
:root/.dark token infrastructure. Persists the choice on Owner.themePreference
so it survives relogin on a new device, not just localStorage. Toggle lives
in the Sidebar (desktop) and the pet-switcher dropdown (mobile); Clerk's own
UI now follows the same theme via a small ClerkThemeProvider wrapper.
This commit is contained in:
admin committed 2026-08-13 10:54:23 +02:00
1 parent 2085336748
commit d670cf9173
16 files changed
+351 -24

No files matched your search

+3
View File
@@ -124,6 +124,9 @@
"en": "English",
"de": "Deutsch"
},
"Theme": {
"toggleLabel": "Dark Mode"
},
"PostCard": {
"justNow": "gerade eben",
"minutesAgo": "vor {count} Min.",
+3
View File
@@ -124,6 +124,9 @@
"en": "English",
"de": "Deutsch"
},
"Theme": {
"toggleLabel": "Dark Mode"
},
"PostCard": {
"justNow": "just now",
"minutesAgo": "{count}m ago",
+63
View File
@@ -10,6 +10,7 @@
"dependencies": {
"@base-ui/react": "^1.5.0",
"@clerk/nextjs": "^7.4.3",
"@clerk/themes": "^2.4.57",
"@hookform/resolvers": "^5.4.0",
"@mux/mux-node": "^14.1.1",
"@mux/mux-player-react": "^3.13.0",
@@ -772,6 +773,55 @@
}
}
},
"node_modules/@clerk/themes": {
"version": "2.4.57",
"resolved": "https://registry.npmjs.org/@clerk/themes/-/themes-2.4.57.tgz",
"integrity": "sha512-Nb3bO79rMTU/MPVTC/dde6LG27/IgOMKIYi5KSvAmO4ZUHlj0OWufu6CMvz5OYVZ0YdyMnTBU2aPGRUiRzO+2w==",
"license": "MIT",
"dependencies": {
"@clerk/shared": "^3.47.2",
"tslib": "2.8.1"
},
"engines": {
"node": ">=18.17.0"
}
},
"node_modules/@clerk/themes/node_modules/@clerk/shared": {
"version": "3.47.8",
"resolved": "https://registry.npmjs.org/@clerk/shared/-/shared-3.47.8.tgz",
"integrity": "sha512-cPURU1it/Eal4uXO9SOcHzw9sfE6Opi21W8EJUg+Ri80Q6JKtK8qBmyOpIyqgf09EanexhTzS4xrmYlvn2p7Iw==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"csstype": "3.1.3",
"dequal": "2.0.3",
"glob-to-regexp": "0.4.1",
"js-cookie": "3.0.7",
"std-env": "^3.9.0",
"swr": "2.3.4"
},
"engines": {
"node": ">=18.17.0"
},
"peerDependencies": {
"react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0",
"react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/@clerk/themes/node_modules/csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT"
},
"node_modules/@csstools/color-helpers": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
@@ -15554,6 +15604,19 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/swr": {
"version": "2.3.4",
"resolved": "https://registry.npmjs.org/swr/-/swr-2.3.4.tgz",
"integrity": "sha512-bYd2lrhc+VarcpkgWclcUi92wYCpOgMws9Sd1hG1ntAu0NEy+14CbotuFjshBU2kt9rYj9TSmDcybpxpeTU1fg==",
"license": "MIT",
"dependencies": {
"dequal": "^2.0.3",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
+1
View File
@@ -14,6 +14,7 @@
"dependencies": {
"@base-ui/react": "^1.5.0",
"@clerk/nextjs": "^7.4.3",
"@clerk/themes": "^2.4.57",
"@hookform/resolvers": "^5.4.0",
"@mux/mux-node": "^14.1.1",
"@mux/mux-player-react": "^3.13.0",
+6
View File
@@ -20,10 +20,16 @@ model Owner {
dataProcessingConsentedAt DateTime? // first-pet onboarding: Willenserklärung consenting to data transfer to Clerk/Cloudflare/Mux/Supabase
dataExportTokens DataExportToken[]
warnings Warning[]
themePreference ThemePreference? // null = follows system preference, never explicitly toggled
@@index([createdAt(sort: Desc)])
}
enum ThemePreference {
LIGHT
DARK
}
// Append-only moderation history — no delete/edit endpoint by design, mirrors
// the Gitea #10 permission-matrix spec's "Verwarnungssystem ... mit Historie".
model Warning {
+91
View File
@@ -0,0 +1,91 @@
/**
* Owner Router Unit Tests
*
* Covers the account-bound Dark Mode preference (Gitea #8): the persisted
* value is the source of truth for cross-device sync, while next-themes'
* own localStorage handles same-browser flash-free reloads.
*/
import { describe, it, expect, vi } from "vitest";
vi.mock("server-only", () => ({}));
vi.mock("@clerk/nextjs/server", () => ({
auth: vi.fn().mockResolvedValue({ userId: "owner-1" }),
}));
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
import { createMockPrisma } from "@/__tests__/helpers/prisma-mock";
async function createOwnerCaller(overrides?: {
userId?: string | null;
prisma?: ReturnType<typeof createMockPrisma>;
}) {
const { ownerRouter } = await import("@/trpc/routers/owner");
const { initTRPC } = await import("@trpc/server");
const mockPrisma = overrides?.prisma ?? createMockPrisma();
const userId = overrides?.userId !== undefined ? overrides.userId : "owner-1";
const t = initTRPC
.context<{ userId: string | null; prisma: typeof mockPrisma }>()
.create();
const testRouter = t.router({ owner: ownerRouter });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const caller = testRouter.createCaller({ userId, prisma: mockPrisma as any });
return { caller, mockPrisma };
}
describe("owner.getThemePreference", () => {
it("returns null when the Owner row does not exist yet", async () => {
const { caller, mockPrisma } = await createOwnerCaller();
mockPrisma.owner.findUnique.mockResolvedValue(null);
const result = await caller.owner.getThemePreference();
expect(result).toEqual({ themePreference: null });
});
it("returns null when the Owner exists but never set a preference", async () => {
const { caller, mockPrisma } = await createOwnerCaller();
mockPrisma.owner.findUnique.mockResolvedValue({ themePreference: null });
const result = await caller.owner.getThemePreference();
expect(result).toEqual({ themePreference: null });
});
it("returns the stored preference when set", async () => {
const { caller, mockPrisma } = await createOwnerCaller();
mockPrisma.owner.findUnique.mockResolvedValue({ themePreference: "DARK" });
const result = await caller.owner.getThemePreference();
expect(result).toEqual({ themePreference: "DARK" });
});
});
describe("owner.setThemePreference", () => {
it("upserts the preference on the calling owner (works even before the Owner row exists)", async () => {
const { caller, mockPrisma } = await createOwnerCaller();
mockPrisma.owner.upsert.mockResolvedValue({ id: "owner-1" });
const result = await caller.owner.setThemePreference({ themePreference: "DARK" });
expect(mockPrisma.owner.upsert).toHaveBeenCalledWith({
where: { id: "owner-1" },
create: { id: "owner-1", themePreference: "DARK" },
update: { themePreference: "DARK" },
});
expect(result).toEqual({ ok: true });
});
it("rejects an invalid preference value", async () => {
const { caller } = await createOwnerCaller();
await expect(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
caller.owner.setThemePreference({ themePreference: "BLUE" as any })
).rejects.toThrow();
});
});
+10 -1
View File
@@ -5,6 +5,7 @@ import { createTRPCCaller } from "@/trpc/server";
import { Sidebar } from "@/components/layout/Sidebar";
import { MobileNav } from "@/components/layout/MobileNav";
import { ActivePetInitializer } from "@/components/layout/ActivePetInitializer";
import { ThemeInitializer } from "@/components/layout/ThemeInitializer";
import { LegalUpdateBanner } from "@/components/legal/LegalUpdateBanner";
import { recordVisit } from "@/lib/visit-tracking";
@@ -40,10 +41,17 @@ export default async function AppLayout({
// Fetch the owner's pets via tRPC server caller (scoped by userId)
const caller = await createTRPCCaller();
const [pets, ban] = await Promise.all([
const [pets, ban, themePref] = await Promise.all([
caller.pets.list(),
caller.admin.getActiveBan({ ownerId: userId }),
caller.owner.getThemePreference(),
]);
const dbTheme =
themePref.themePreference === "DARK"
? "dark"
: themePref.themePreference === "LIGHT"
? "light"
: null;
// Banned users see an error page, not the app
if (ban) {
@@ -62,6 +70,7 @@ export default async function AppLayout({
return (
<div className="flex min-h-screen">
<ActivePetInitializer firstPetId={pets[0].id} />
<ThemeInitializer dbTheme={dbTheme} />
<LegalUpdateBanner />
{/* Desktop sidebar — hidden on mobile */}
<Sidebar pets={pets} />
+21 -21
View File
@@ -1,10 +1,11 @@
import type { Metadata, Viewport } from "next";
import { Inter } from "next/font/google";
import { ClerkProvider } from "@clerk/nextjs";
import { ThemeProvider } from "next-themes";
import { NextIntlClientProvider } from "next-intl";
import { getLocale, getMessages } from "next-intl/server";
import { TRPCReactProvider } from "@/trpc/client";
import { ActivePetProvider } from "@/context/ActivePetContext";
import { ClerkThemeProvider } from "@/components/providers/ClerkThemeProvider";
import { Toaster } from "@/components/ui/sonner";
import "./globals.css";
@@ -46,26 +47,25 @@ export default async function RootLayout({
const messages = await getMessages();
return (
<ClerkProvider
appearance={{
variables: {
colorPrimary: "#F97316",
colorBackground: "#FAFAFA",
},
}}
<html
lang={locale}
suppressHydrationWarning
className={`${inter.variable} h-full antialiased font-sans`}
>
<html lang={locale} className={`${inter.variable} h-full antialiased font-sans`}>
<body className="min-h-full flex flex-col">
<NextIntlClientProvider locale={locale} messages={messages}>
<TRPCReactProvider>
<ActivePetProvider>
{children}
<Toaster />
</ActivePetProvider>
</TRPCReactProvider>
</NextIntlClientProvider>
</body>
</html>
</ClerkProvider>
<body className="min-h-full flex flex-col">
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
<ClerkThemeProvider>
<NextIntlClientProvider locale={locale} messages={messages}>
<TRPCReactProvider>
<ActivePetProvider>
{children}
<Toaster />
</ActivePetProvider>
</TRPCReactProvider>
</NextIntlClientProvider>
</ClerkThemeProvider>
</ThemeProvider>
</body>
</html>
);
}
+16
View File
@@ -11,6 +11,8 @@ import {
ChevronDown,
Mail,
BadgeCheck,
Moon,
Sun,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { NotificationBell } from "@/components/notifications/NotificationBell";
@@ -25,6 +27,7 @@ import {
import { PetAvatar } from "@/components/ui/pet-avatar";
import { Badge } from "@/components/ui/badge";
import { useActivePet } from "@/context/ActivePetContext";
import { useThemeToggle } from "@/hooks/useThemeToggle";
type Pet = {
id: string;
@@ -59,6 +62,8 @@ export function MobileNav({ pets }: MobileNavProps) {
const { activePetId, setActivePet } = useActivePet();
const t = useTranslations("Nav");
const tTheme = useTranslations("Theme");
const { isDark, setDark } = useThemeToggle();
const activePet = pets.find((p) => p.id === activePetId) ?? pets[0];
const [postTypeSheetOpen, setPostTypeSheetOpen] = useState(false);
@@ -143,6 +148,17 @@ export function MobileNav({ pets }: MobileNavProps) {
<span className="text-orange-500">+</span>
<span>{t("addPet")}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setDark(!isDark)}
className="flex items-center gap-2 cursor-pointer"
>
{isDark ? (
<Moon className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
) : (
<Sun className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
)}
<span>{tTheme("toggleLabel")}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
+4 -2
View File
@@ -22,6 +22,7 @@ import { useTranslations } from "next-intl";
import { PostTypeSheet } from "@/components/post-creation/PostTypeSheet";
import { NotificationBell } from "@/components/notifications/NotificationBell";
import { LanguageSwitcher } from "@/components/i18n/LanguageSwitcher";
import { ThemeToggle } from "@/components/layout/ThemeToggle";
import { UserButton } from "@clerk/nextjs";
import {
DropdownMenu,
@@ -275,9 +276,10 @@ export function Sidebar({ pets }: SidebarProps) {
{!collapsed && (
<>
{/* Language switcher */}
<div className="px-1 pb-1">
{/* Language switcher + Dark Mode toggle */}
<div className="px-1 pb-1 space-y-0.5">
<LanguageSwitcher />
<ThemeToggle />
</div>
{/* Legal mini-footer */}
@@ -0,0 +1,23 @@
"use client";
import { useEffect } from "react";
import { useTheme } from "next-themes";
/**
* Mounts invisibly in the app shell and applies the account-bound theme
* preference on brand-new devices/browsers (no "theme" key in localStorage
* yet). Never overwrites an existing local choice — a newer on-device toggle
* always wins over a possibly-stale DB value, same trade-off as
* ActivePetInitializer's localStorage/SSR mismatch.
*/
export function ThemeInitializer({ dbTheme }: { dbTheme: "light" | "dark" | null }) {
const { setTheme } = useTheme();
useEffect(() => {
if (dbTheme && !localStorage.getItem("theme")) {
setTheme(dbTheme);
}
}, [dbTheme, setTheme]);
return null;
}
+28
View File
@@ -0,0 +1,28 @@
"use client";
import { Moon, Sun } from "lucide-react";
import { useTranslations } from "next-intl";
import { Switch } from "@/components/ui/switch";
import { useThemeToggle } from "@/hooks/useThemeToggle";
export function ThemeToggle() {
const t = useTranslations("Theme");
const { isDark, setDark } = useThemeToggle();
return (
<div className="flex items-center gap-1.5 px-2 py-1.5 rounded-md text-muted-foreground">
{isDark ? (
<Moon className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
) : (
<Sun className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
)}
<span className="text-xs flex-1">{t("toggleLabel")}</span>
<Switch
size="sm"
checked={isDark}
onCheckedChange={setDark}
aria-label={t("toggleLabel")}
/>
</div>
);
}
@@ -0,0 +1,28 @@
"use client";
import { ClerkProvider } from "@clerk/nextjs";
import { dark } from "@clerk/themes";
import { useTheme } from "next-themes";
// Bridges next-themes -> Clerk's own appearance system. Kept deliberately
// minimal (baseTheme + the two variables layout.tsx already set) — the goal
// is "doesn't look broken in Dark Mode", not pixel-parity with the rest of
// the app's custom OKLCH tokens.
export function ClerkThemeProvider({ children }: { children: React.ReactNode }) {
const { resolvedTheme } = useTheme();
const isDark = resolvedTheme === "dark";
return (
<ClerkProvider
appearance={{
baseTheme: isDark ? dark : undefined,
variables: {
colorPrimary: "#F97316",
colorBackground: isDark ? "#0a0a0a" : "#FAFAFA",
},
}}
>
{children}
</ClerkProvider>
);
}
+26
View File
@@ -0,0 +1,26 @@
"use client";
import { useTheme } from "next-themes";
import { useMutation } from "@tanstack/react-query";
import { useTRPC } from "@/trpc/client";
/**
* Shared Dark Mode toggle logic for the Sidebar switch and the MobileNav
* dropdown item. Flips next-themes immediately (instant UI feedback) and
* persists the choice to the Owner row via a fire-and-forget mutation — no
* loading state, same pattern as other preference writes in this app.
*/
export function useThemeToggle() {
const { resolvedTheme, setTheme } = useTheme();
const trpc = useTRPC();
const mutation = useMutation(trpc.owner.setThemePreference.mutationOptions());
const isDark = resolvedTheme === "dark";
const setDark = (dark: boolean) => {
setTheme(dark ? "dark" : "light");
mutation.mutate({ themePreference: dark ? "DARK" : "LIGHT" });
};
return { isDark, setDark };
}
+2
View File
@@ -21,6 +21,7 @@ import { adminRouter } from "@/trpc/routers/admin";
import { invitesRouter } from "@/trpc/routers/invites";
import { adsRouter } from "@/trpc/routers/ads";
import { legalRouter } from "@/trpc/routers/legal";
import { ownerRouter } from "@/trpc/routers/owner";
export const appRouter = router({
pets: petsRouter,
@@ -46,6 +47,7 @@ export const appRouter = router({
invites: invitesRouter,
ads: adsRouter,
legal: legalRouter,
owner: ownerRouter,
});
// Export the type for use in the client
+26
View File
@@ -0,0 +1,26 @@
import "server-only";
import { z } from "zod";
import { router, protectedProcedure } from "@/trpc/init";
export const ownerRouter = router({
getThemePreference: protectedProcedure.query(async ({ ctx }) => {
const owner = await ctx.prisma.owner.findUnique({
where: { id: ctx.userId },
select: { themePreference: true },
});
return { themePreference: owner?.themePreference ?? null };
}),
// Upsert, not update: mirrors legal.acknowledge — the Owner row may not
// exist yet if this is called before the first pet-creation step.
setThemePreference: protectedProcedure
.input(z.object({ themePreference: z.enum(["LIGHT", "DARK"]) }))
.mutation(async ({ ctx, input }) => {
await ctx.prisma.owner.upsert({
where: { id: ctx.userId },
create: { id: ctx.userId, themePreference: input.themePreference },
update: { themePreference: input.themePreference },
});
return { ok: true };
}),
});