Add theming infrastructure (task 08)
CSS-variable multi-theme system (default + warm) × {light, dark, system}; per-user theme/theme_mode columns; no-flash pre-paint script; useTheme hook + ThemePicker component on /settings.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
6710c8b231
commit
8cc2ef0732
@@ -14,9 +14,11 @@ Living progress tracker. Update at the end of each task. The canonical brief is
|
|||||||
|
|
||||||
- **07 — Household seed**. `pnpm db:seed` inserts household "Home" idempotently (powered by `tsx`). `signIn` callback assigns `owner` to the first member of the household and `member` to all subsequent users. `getCurrentSession()` in `src/lib/session.ts` returns `{ user, household, role }` and throws if unauthenticated or unmembered. `/settings/household` renders member list for all roles and a rename form for owner only. `tsc --noEmit` and `pnpm lint` both clean.
|
- **07 — Household seed**. `pnpm db:seed` inserts household "Home" idempotently (powered by `tsx`). `signIn` callback assigns `owner` to the first member of the household and `member` to all subsequent users. `getCurrentSession()` in `src/lib/session.ts` returns `{ user, household, role }` and throws if unauthenticated or unmembered. `/settings/household` renders member list for all roles and a rename form for owner only. `tsc --noEmit` and `pnpm lint` both clean.
|
||||||
|
|
||||||
|
- **08 — Theming infrastructure**. CSS-variable multi-theme system (`default` + `warm`) × `{light, dark, system}`. `users.theme` + `users.themeMode` columns in migration `0002_naive_starbolt.sql`. Theme registry in `src/modules/_core/themes.ts` (THEMES/THEME_MODES arrays — adding a third theme is one CSS block + one registry entry). Root layout reads session and sets `data-theme`/`dark` on `<html>` server-side; inline pre-paint `<script>` covers system mode and signed-out pages (no flash). `useTheme()` hook optimistically flips attributes, writes `localStorage`, and calls `setUserTheme` server action. `<ThemePicker />` component mounts on `/settings` (select for theme, segmented buttons for mode). `tsc --noEmit`, `pnpm lint`, `pnpm build` all clean.
|
||||||
|
|
||||||
## Next up
|
## Next up
|
||||||
|
|
||||||
- **08 — Theming infrastructure** ([brief](docs/tasks/08-theming.md)).
|
- **09 — Calendar module** (or next task in `docs/tasks/`).
|
||||||
|
|
||||||
## Phase 1 remaining
|
## Phase 1 remaining
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "users" ADD COLUMN "theme" text DEFAULT 'default' NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "users" ADD COLUMN "theme_mode" text DEFAULT 'system' NOT NULL;
|
||||||
@@ -116,6 +116,77 @@
|
|||||||
--sidebar-ring: oklch(0.556 0 0);
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Warm theme — light */
|
||||||
|
[data-theme="warm"] {
|
||||||
|
--background: oklch(0.98 0.012 60);
|
||||||
|
--foreground: oklch(0.18 0.03 50);
|
||||||
|
--card: oklch(0.97 0.014 58);
|
||||||
|
--card-foreground: oklch(0.18 0.03 50);
|
||||||
|
--popover: oklch(0.97 0.014 58);
|
||||||
|
--popover-foreground: oklch(0.18 0.03 50);
|
||||||
|
--primary: oklch(0.52 0.18 40);
|
||||||
|
--primary-foreground: oklch(0.98 0.01 60);
|
||||||
|
--secondary: oklch(0.92 0.02 60);
|
||||||
|
--secondary-foreground: oklch(0.25 0.04 45);
|
||||||
|
--muted: oklch(0.93 0.018 58);
|
||||||
|
--muted-foreground: oklch(0.52 0.04 50);
|
||||||
|
--accent: oklch(0.90 0.03 55);
|
||||||
|
--accent-foreground: oklch(0.22 0.04 45);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.88 0.025 60);
|
||||||
|
--input: oklch(0.88 0.025 60);
|
||||||
|
--ring: oklch(0.65 0.12 45);
|
||||||
|
--chart-1: oklch(0.72 0.16 40);
|
||||||
|
--chart-2: oklch(0.62 0.14 55);
|
||||||
|
--chart-3: oklch(0.52 0.12 45);
|
||||||
|
--chart-4: oklch(0.45 0.10 40);
|
||||||
|
--chart-5: oklch(0.35 0.08 35);
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--sidebar: oklch(0.94 0.018 58);
|
||||||
|
--sidebar-foreground: oklch(0.18 0.03 50);
|
||||||
|
--sidebar-primary: oklch(0.52 0.18 40);
|
||||||
|
--sidebar-primary-foreground: oklch(0.98 0.01 60);
|
||||||
|
--sidebar-accent: oklch(0.90 0.03 55);
|
||||||
|
--sidebar-accent-foreground: oklch(0.22 0.04 45);
|
||||||
|
--sidebar-border: oklch(0.88 0.025 60);
|
||||||
|
--sidebar-ring: oklch(0.65 0.12 45);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Warm theme — dark */
|
||||||
|
[data-theme="warm"].dark {
|
||||||
|
--background: oklch(0.16 0.025 45);
|
||||||
|
--foreground: oklch(0.95 0.015 60);
|
||||||
|
--card: oklch(0.22 0.03 48);
|
||||||
|
--card-foreground: oklch(0.95 0.015 60);
|
||||||
|
--popover: oklch(0.22 0.03 48);
|
||||||
|
--popover-foreground: oklch(0.95 0.015 60);
|
||||||
|
--primary: oklch(0.78 0.14 50);
|
||||||
|
--primary-foreground: oklch(0.16 0.025 45);
|
||||||
|
--secondary: oklch(0.28 0.04 50);
|
||||||
|
--secondary-foreground: oklch(0.95 0.015 60);
|
||||||
|
--muted: oklch(0.28 0.035 48);
|
||||||
|
--muted-foreground: oklch(0.68 0.04 55);
|
||||||
|
--accent: oklch(0.30 0.045 50);
|
||||||
|
--accent-foreground: oklch(0.95 0.015 60);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.55 0.08 50);
|
||||||
|
--chart-1: oklch(0.78 0.14 50);
|
||||||
|
--chart-2: oklch(0.65 0.12 55);
|
||||||
|
--chart-3: oklch(0.52 0.10 48);
|
||||||
|
--chart-4: oklch(0.42 0.08 42);
|
||||||
|
--chart-5: oklch(0.32 0.06 38);
|
||||||
|
--sidebar: oklch(0.20 0.03 47);
|
||||||
|
--sidebar-foreground: oklch(0.95 0.015 60);
|
||||||
|
--sidebar-primary: oklch(0.78 0.14 50);
|
||||||
|
--sidebar-primary-foreground: oklch(0.16 0.025 45);
|
||||||
|
--sidebar-accent: oklch(0.30 0.045 50);
|
||||||
|
--sidebar-accent-foreground: oklch(0.95 0.015 60);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.55 0.08 50);
|
||||||
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
|
|||||||
+46
-2
@@ -4,6 +4,10 @@ import { Geist } from "next/font/google";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { AppNav } from "@/components/app-nav";
|
import { AppNav } from "@/components/app-nav";
|
||||||
import "@/modules"; // registers all module manifests
|
import "@/modules"; // registers all module manifests
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { users } from "@/modules/_core/schema";
|
||||||
|
|
||||||
const geist = Geist({ subsets: ["latin"], variable: "--font-sans" });
|
const geist = Geist({ subsets: ["latin"], variable: "--font-sans" });
|
||||||
|
|
||||||
@@ -12,13 +16,53 @@ export const metadata: Metadata = {
|
|||||||
description: "Family coordination app",
|
description: "Family coordination app",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
// Runs before paint — reads localStorage / prefers-color-scheme and applies
|
||||||
|
// data-theme + dark class to <html> so signed-out pages also get the right theme.
|
||||||
|
const prePaintScript = `(function(){
|
||||||
|
try {
|
||||||
|
var t = localStorage.getItem('theme') || 'default';
|
||||||
|
var m = localStorage.getItem('themeMode') || 'system';
|
||||||
|
var dark = m === 'dark' || (m === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||||
|
document.documentElement.setAttribute('data-theme', t);
|
||||||
|
if (dark) document.documentElement.classList.add('dark');
|
||||||
|
else document.documentElement.classList.remove('dark');
|
||||||
|
} catch(e) {}
|
||||||
|
})();`;
|
||||||
|
|
||||||
|
export default async function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
|
let theme = "default";
|
||||||
|
let themeMode = "system";
|
||||||
|
|
||||||
|
const session = await auth();
|
||||||
|
if (session?.user?.id) {
|
||||||
|
const [row] = await db
|
||||||
|
.select({ theme: users.theme, themeMode: users.themeMode })
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, session.user.id))
|
||||||
|
.limit(1);
|
||||||
|
if (row) {
|
||||||
|
theme = row.theme;
|
||||||
|
themeMode = row.themeMode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For system mode we can't know the preference on the server — the inline
|
||||||
|
// script will correct it before paint. We optimistically render light here.
|
||||||
|
const isDark = themeMode === "dark";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="en" className={cn("font-sans", geist.variable)}>
|
<html
|
||||||
|
lang="en"
|
||||||
|
data-theme={theme}
|
||||||
|
className={cn("font-sans", geist.variable, isDark ? "dark" : "")}
|
||||||
|
>
|
||||||
|
<head>
|
||||||
|
<script dangerouslySetInnerHTML={{ __html: prePaintScript }} />
|
||||||
|
</head>
|
||||||
<body className="min-h-screen">
|
<body className="min-h-screen">
|
||||||
<AppNav />
|
<AppNav />
|
||||||
<main>{children}</main>
|
<main>{children}</main>
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { users } from "@/modules/_core/schema";
|
||||||
|
import { VALID_THEME_IDS, VALID_THEME_MODES } from "@/modules/_core/themes";
|
||||||
|
import type { ThemeId, ThemeMode } from "@/modules/_core/themes";
|
||||||
|
import { getCurrentSession } from "@/lib/session";
|
||||||
|
|
||||||
|
export async function setUserTheme({
|
||||||
|
theme,
|
||||||
|
mode,
|
||||||
|
}: {
|
||||||
|
theme: ThemeId;
|
||||||
|
mode: ThemeMode;
|
||||||
|
}) {
|
||||||
|
if (!VALID_THEME_IDS.has(theme)) throw new Error("Invalid theme");
|
||||||
|
if (!VALID_THEME_MODES.has(mode)) throw new Error("Invalid theme mode");
|
||||||
|
|
||||||
|
const { user } = await getCurrentSession();
|
||||||
|
await db
|
||||||
|
.update(users)
|
||||||
|
.set({ theme, themeMode: mode })
|
||||||
|
.where(eq(users.id, user.id));
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { getCurrentSession } from "@/lib/session";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { ThemePicker } from "@/components/theme-picker";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default async function SettingsPage() {
|
||||||
|
const { user } = await getCurrentSession();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container max-w-2xl py-8 space-y-6">
|
||||||
|
<h1 className="text-2xl font-semibold">Settings</h1>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Appearance</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ThemePicker
|
||||||
|
initialTheme={user.theme as "default" | "warm"}
|
||||||
|
initialMode={user.themeMode as "light" | "dark" | "system"}
|
||||||
|
signedIn
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/settings/household"
|
||||||
|
className="inline-flex items-center justify-center rounded-md border border-input bg-background px-4 py-2 text-sm font-medium shadow-sm hover:bg-accent hover:text-accent-foreground"
|
||||||
|
>
|
||||||
|
Household Settings
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { THEMES, THEME_MODES } from "@/modules/_core/themes";
|
||||||
|
import type { ThemeId, ThemeMode } from "@/modules/_core/themes";
|
||||||
|
import { useTheme } from "@/hooks/use-theme";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
initialTheme?: ThemeId;
|
||||||
|
initialMode?: ThemeMode;
|
||||||
|
signedIn?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemePicker({
|
||||||
|
initialTheme = "default",
|
||||||
|
initialMode = "system",
|
||||||
|
signedIn = false,
|
||||||
|
}: Props) {
|
||||||
|
const { theme, mode, setTheme, setMode } = useTheme(
|
||||||
|
initialTheme,
|
||||||
|
initialMode,
|
||||||
|
signedIn,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Theme</Label>
|
||||||
|
<Select value={theme} onValueChange={(v) => setTheme(v as ThemeId)}>
|
||||||
|
<SelectTrigger className="w-48">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{THEMES.map((t) => (
|
||||||
|
<SelectItem key={t.id} value={t.id}>
|
||||||
|
{t.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Mode</Label>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{THEME_MODES.map((m) => (
|
||||||
|
<Button
|
||||||
|
key={m.id}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className={cn(mode === m.id && "bg-primary text-primary-foreground")}
|
||||||
|
onClick={() => setMode(m.id)}
|
||||||
|
>
|
||||||
|
{m.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Label({ className, ...props }: React.ComponentProps<"label">) {
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
data-slot="label"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Label }
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Select as SelectPrimitive } from "@base-ui/react/select"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
||||||
|
|
||||||
|
const Select = SelectPrimitive.Root
|
||||||
|
|
||||||
|
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Group
|
||||||
|
data-slot="select-group"
|
||||||
|
className={cn("scroll-my-1 p-1", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Value
|
||||||
|
data-slot="select-value"
|
||||||
|
className={cn("flex flex-1 text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectTrigger({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.Trigger.Props & {
|
||||||
|
size?: "sm" | "default"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
data-slot="select-trigger"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon
|
||||||
|
render={
|
||||||
|
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
side = "bottom",
|
||||||
|
sideOffset = 4,
|
||||||
|
align = "center",
|
||||||
|
alignOffset = 0,
|
||||||
|
alignItemWithTrigger = true,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.Popup.Props &
|
||||||
|
Pick<
|
||||||
|
SelectPrimitive.Positioner.Props,
|
||||||
|
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
|
||||||
|
>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Positioner
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
alignItemWithTrigger={alignItemWithTrigger}
|
||||||
|
className="isolate z-50"
|
||||||
|
>
|
||||||
|
<SelectPrimitive.Popup
|
||||||
|
data-slot="select-content"
|
||||||
|
data-align-trigger={alignItemWithTrigger}
|
||||||
|
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.List>{children}</SelectPrimitive.List>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Popup>
|
||||||
|
</SelectPrimitive.Positioner>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.GroupLabel.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.GroupLabel
|
||||||
|
data-slot="select-label"
|
||||||
|
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.Item.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
data-slot="select-item"
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.ItemText>
|
||||||
|
<SelectPrimitive.ItemIndicator
|
||||||
|
render={
|
||||||
|
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CheckIcon className="pointer-events-none" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.Separator.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
data-slot="select-separator"
|
||||||
|
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollUpButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollUpArrow
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
className={cn(
|
||||||
|
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUpIcon
|
||||||
|
/>
|
||||||
|
</SelectPrimitive.ScrollUpArrow>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollDownButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollDownArrow
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
className={cn(
|
||||||
|
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon
|
||||||
|
/>
|
||||||
|
</SelectPrimitive.ScrollDownArrow>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import type { ThemeId, ThemeMode } from "@/modules/_core/themes";
|
||||||
|
import { setUserTheme } from "@/app/settings/actions";
|
||||||
|
|
||||||
|
function applyTheme(theme: ThemeId, mode: ThemeMode) {
|
||||||
|
const html = document.documentElement;
|
||||||
|
html.setAttribute("data-theme", theme);
|
||||||
|
const dark =
|
||||||
|
mode === "dark" ||
|
||||||
|
(mode === "system" &&
|
||||||
|
window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||||
|
html.classList.toggle("dark", dark);
|
||||||
|
try {
|
||||||
|
localStorage.setItem("theme", theme);
|
||||||
|
localStorage.setItem("themeMode", mode);
|
||||||
|
} catch {
|
||||||
|
// storage blocked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme(
|
||||||
|
initialTheme: ThemeId = "default",
|
||||||
|
initialMode: ThemeMode = "system",
|
||||||
|
signedIn = false,
|
||||||
|
) {
|
||||||
|
const [theme, setThemeState] = useState<ThemeId>(initialTheme);
|
||||||
|
const [mode, setModeState] = useState<ThemeMode>(initialMode);
|
||||||
|
|
||||||
|
const setTheme = useCallback(
|
||||||
|
(next: ThemeId) => {
|
||||||
|
setThemeState(next);
|
||||||
|
applyTheme(next, mode);
|
||||||
|
if (signedIn) void setUserTheme({ theme: next, mode });
|
||||||
|
},
|
||||||
|
[mode, signedIn],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setMode = useCallback(
|
||||||
|
(next: ThemeMode) => {
|
||||||
|
setModeState(next);
|
||||||
|
applyTheme(theme, next);
|
||||||
|
if (signedIn) void setUserTheme({ theme, mode: next });
|
||||||
|
},
|
||||||
|
[theme, signedIn],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { theme, mode, setTheme, setMode };
|
||||||
|
}
|
||||||
@@ -19,6 +19,8 @@ export const users = pgTable("users", {
|
|||||||
email: varchar("email", { length: 255 }).notNull().unique(),
|
email: varchar("email", { length: 255 }).notNull().unique(),
|
||||||
emailVerified: timestamp("email_verified", { withTimezone: true }),
|
emailVerified: timestamp("email_verified", { withTimezone: true }),
|
||||||
image: text("image"),
|
image: text("image"),
|
||||||
|
theme: text("theme").notNull().default("default"),
|
||||||
|
themeMode: text("theme_mode").notNull().default("system"),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
export type ThemeMode = "light" | "dark" | "system";
|
||||||
|
export type ThemeId = "default" | "warm" | (string & {});
|
||||||
|
|
||||||
|
export const THEMES: ReadonlyArray<{ id: ThemeId; label: string }> = [
|
||||||
|
{ id: "default", label: "Default" },
|
||||||
|
{ id: "warm", label: "Warm" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const THEME_MODES: ReadonlyArray<{ id: ThemeMode; label: string }> = [
|
||||||
|
{ id: "light", label: "Light" },
|
||||||
|
{ id: "dark", label: "Dark" },
|
||||||
|
{ id: "system", label: "System" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const VALID_THEME_IDS = new Set(THEMES.map((t) => t.id));
|
||||||
|
export const VALID_THEME_MODES = new Set<string>(["light", "dark", "system"]);
|
||||||
Reference in New Issue
Block a user