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:
1 parent
2085336748
commit
d670cf9173
16 files changed
+351
-24
No files matched your search
@@ -124,6 +124,9 @@
|
||||
"en": "English",
|
||||
"de": "Deutsch"
|
||||
},
|
||||
"Theme": {
|
||||
"toggleLabel": "Dark Mode"
|
||||
},
|
||||
"PostCard": {
|
||||
"justNow": "gerade eben",
|
||||
"minutesAgo": "vor {count} Min.",
|
||||
|
||||
@@ -124,6 +124,9 @@
|
||||
"en": "English",
|
||||
"de": "Deutsch"
|
||||
},
|
||||
"Theme": {
|
||||
"toggleLabel": "Dark Mode"
|
||||
},
|
||||
"PostCard": {
|
||||
"justNow": "just now",
|
||||
"minutesAgo": "{count}m ago",
|
||||
|
||||
Generated
+63
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}),
|
||||
});
|
||||
Reference in new issue
Block a user