diff --git a/messages/de.json b/messages/de.json index 55ad22e..3d6eb74 100644 --- a/messages/de.json +++ b/messages/de.json @@ -124,6 +124,9 @@ "en": "English", "de": "Deutsch" }, + "Theme": { + "toggleLabel": "Dark Mode" + }, "PostCard": { "justNow": "gerade eben", "minutesAgo": "vor {count} Min.", diff --git a/messages/en.json b/messages/en.json index 027cd94..9a842dd 100644 --- a/messages/en.json +++ b/messages/en.json @@ -124,6 +124,9 @@ "en": "English", "de": "Deutsch" }, + "Theme": { + "toggleLabel": "Dark Mode" + }, "PostCard": { "justNow": "just now", "minutesAgo": "{count}m ago", diff --git a/package-lock.json b/package-lock.json index a2adeb8..977beba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 9959ae2..6c788cd 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ed52b43..015a420 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 { diff --git a/src/__tests__/owner.test.ts b/src/__tests__/owner.test.ts new file mode 100644 index 0000000..0350b21 --- /dev/null +++ b/src/__tests__/owner.test.ts @@ -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; +}) { + 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(); + }); +}); diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index 7316bfe..66cd116 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -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 (
+ {/* Desktop sidebar — hidden on mobile */} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 56f3efc..0003b8e 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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 ( - - - - - - - {children} - - - - - - - + + + + + + + {children} + + + + + + + + ); } diff --git a/src/components/layout/MobileNav.tsx b/src/components/layout/MobileNav.tsx index f200426..c53f2e5 100644 --- a/src/components/layout/MobileNav.tsx +++ b/src/components/layout/MobileNav.tsx @@ -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) { + {t("addPet")} + setDark(!isDark)} + className="flex items-center gap-2 cursor-pointer" + > + {isDark ? ( +
diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index d34a9ea..906b1d5 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -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 */} -
+ {/* Language switcher + Dark Mode toggle */} +
+
{/* Legal mini-footer */} diff --git a/src/components/layout/ThemeInitializer.tsx b/src/components/layout/ThemeInitializer.tsx new file mode 100644 index 0000000..3b3beae --- /dev/null +++ b/src/components/layout/ThemeInitializer.tsx @@ -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; +} diff --git a/src/components/layout/ThemeToggle.tsx b/src/components/layout/ThemeToggle.tsx new file mode 100644 index 0000000..1445b4d --- /dev/null +++ b/src/components/layout/ThemeToggle.tsx @@ -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 ( +
+ {isDark ? ( +
+ ); +} diff --git a/src/components/providers/ClerkThemeProvider.tsx b/src/components/providers/ClerkThemeProvider.tsx new file mode 100644 index 0000000..0061700 --- /dev/null +++ b/src/components/providers/ClerkThemeProvider.tsx @@ -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 ( + + {children} + + ); +} diff --git a/src/hooks/useThemeToggle.ts b/src/hooks/useThemeToggle.ts new file mode 100644 index 0000000..addd9e3 --- /dev/null +++ b/src/hooks/useThemeToggle.ts @@ -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 }; +} diff --git a/src/trpc/routers/_app.ts b/src/trpc/routers/_app.ts index 2949aa1..01487b1 100644 --- a/src/trpc/routers/_app.ts +++ b/src/trpc/routers/_app.ts @@ -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 diff --git a/src/trpc/routers/owner.ts b/src/trpc/routers/owner.ts new file mode 100644 index 0000000..aa60697 --- /dev/null +++ b/src/trpc/routers/owner.ts @@ -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 }; + }), +});