178 lines
5.7 KiB
TypeScript
178 lines
5.7 KiB
TypeScript
"use client";
|
|
|
|
import type { LucideIcon } from "lucide-react";
|
|
import {
|
|
Monitor,
|
|
Check,
|
|
Sun,
|
|
Moon,
|
|
Sunset,
|
|
Waves,
|
|
Leaf,
|
|
Hexagon,
|
|
Rose,
|
|
Flower2,
|
|
Mountain,
|
|
TreePine,
|
|
Grape,
|
|
Star,
|
|
Flame,
|
|
Wine,
|
|
DraftingCompass,
|
|
CircuitBoard,
|
|
Disc3,
|
|
Orbit,
|
|
NotebookText,
|
|
} from "lucide-react";
|
|
|
|
import { useTheme } from "@/components/theme/theme-provider";
|
|
import type { ThemeName } from "@/components/theme/theme-provider";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuLabel,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
|
|
export type ThemeOption = {
|
|
value: ThemeName;
|
|
label: string;
|
|
icon: LucideIcon;
|
|
swatch: string;
|
|
};
|
|
|
|
// The looks, grouped by surface. Each value is one next-themes value (see
|
|
// app/layout.tsx for the class mapping); the little dot is the theme's own
|
|
// accent so the picker previews the vibe. Exported for reuse by other
|
|
// theme pickers (e.g. components/projects/project-theme-picker.tsx).
|
|
export const LIGHT_OPTIONS: ThemeOption[] = [
|
|
{ value: "default", label: "Default", icon: Sun, swatch: "#4f56e0" },
|
|
{ value: "sunset", label: "Sunset", icon: Sunset, swatch: "#c2410c" },
|
|
{ value: "meadow", label: "Meadow", icon: Leaf, swatch: "#059669" },
|
|
{ value: "honey", label: "Honey", icon: Hexagon, swatch: "#d97706" },
|
|
{ value: "rose", label: "Rosé", icon: Rose, swatch: "#e11d48" },
|
|
{ value: "lavender", label: "Lavender", icon: Flower2, swatch: "#7c3aed" },
|
|
{ value: "slate", label: "Slate", icon: Mountain, swatch: "#4a6d9c" },
|
|
{ value: "blueprint", label: "Blueprint", icon: DraftingCompass, swatch: "#2b4bc4" },
|
|
{ value: "vaporwave", label: "Vaporwave", icon: Disc3, swatch: "#e879f9" },
|
|
{ value: "notebook", label: "Notebook", icon: NotebookText, swatch: "#3b5fc4" },
|
|
];
|
|
export const DARK_OPTIONS: ThemeOption[] = [
|
|
{ value: "dark", label: "Dark", icon: Moon, swatch: "#7c83f2" },
|
|
{ value: "ocean", label: "Ocean", icon: Waves, swatch: "#5eead4" },
|
|
{ value: "pine", label: "Pine", icon: TreePine, swatch: "#34d399" },
|
|
{ value: "plum", label: "Plum", icon: Grape, swatch: "#c084fc" },
|
|
{ value: "midnight", label: "Midnight", icon: Star, swatch: "#93c5fd" },
|
|
{ value: "ember", label: "Ember", icon: Flame, swatch: "#fbbf24" },
|
|
{ value: "rosewood", label: "Rosewood", icon: Wine, swatch: "#fb7185" },
|
|
{ value: "cyberpunk", label: "Cyberpunk", icon: CircuitBoard, swatch: "#ff2bd6" },
|
|
{ value: "starfield", label: "Starfield", icon: Orbit, swatch: "#38bdf8" },
|
|
];
|
|
export const ALL_OPTIONS = [...LIGHT_OPTIONS, ...DARK_OPTIONS];
|
|
|
|
export function OptionRow({
|
|
option,
|
|
active,
|
|
onSelect,
|
|
}: {
|
|
option: ThemeOption;
|
|
active: boolean;
|
|
onSelect: () => void;
|
|
}) {
|
|
const Icon = option.icon;
|
|
return (
|
|
<DropdownMenuItem
|
|
onClick={onSelect}
|
|
className="gap-2.5 px-2 py-1.5 text-sm cursor-pointer"
|
|
>
|
|
<Icon className="size-4 text-muted-foreground" />
|
|
<span>{option.label}</span>
|
|
<span
|
|
className="ml-auto size-3 rounded-full ring-1 ring-foreground/15"
|
|
style={{ backgroundColor: option.swatch }}
|
|
aria-hidden
|
|
/>
|
|
{active && <Check className="size-4 shrink-0" />}
|
|
</DropdownMenuItem>
|
|
);
|
|
}
|
|
|
|
export function ThemeToggle({
|
|
collapsed,
|
|
menuSide = "right",
|
|
menuAlign = "start",
|
|
}: {
|
|
collapsed?: boolean;
|
|
// Which side of the trigger the picker menu opens on: "right" for the
|
|
// sidebar, "bottom" when the trigger sits in the mobile top bar (where a
|
|
// right-opening menu would run off the screen).
|
|
menuSide?: "right" | "bottom";
|
|
menuAlign?: "start" | "end";
|
|
}) {
|
|
const { theme, setTheme } = useTheme();
|
|
// The picker shows all the real themes in Light/Dark sections; "system" is
|
|
// handled by a dedicated row since it resolves to one of them per device
|
|
// preference.
|
|
const known = ALL_OPTIONS.find((o) => o.value === theme);
|
|
const isSystem = theme === "system" || theme === undefined;
|
|
const Icon = (isSystem ? Monitor : known?.icon) ?? Monitor;
|
|
const label = isSystem ? "System" : known?.label ?? "Default";
|
|
|
|
return (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
render={
|
|
<Button
|
|
variant="ghost"
|
|
size={collapsed ? "icon" : "default"}
|
|
className={collapsed ? undefined : "w-full justify-start gap-2"}
|
|
>
|
|
<Icon className="size-4" />
|
|
{!collapsed && <span>{label}</span>}
|
|
</Button>
|
|
}
|
|
/>
|
|
<DropdownMenuContent align={menuAlign} side={menuSide} className="w-52">
|
|
<DropdownMenuLabel>Light</DropdownMenuLabel>
|
|
{LIGHT_OPTIONS.map((option) => (
|
|
<OptionRow
|
|
key={option.value}
|
|
option={option}
|
|
active={theme === option.value}
|
|
onSelect={() => setTheme(option.value)}
|
|
/>
|
|
))}
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuLabel>Dark</DropdownMenuLabel>
|
|
{DARK_OPTIONS.map((option) => (
|
|
<OptionRow
|
|
key={option.value}
|
|
option={option}
|
|
active={theme === option.value}
|
|
onSelect={() => setTheme(option.value)}
|
|
/>
|
|
))}
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
onClick={() => setTheme("system")}
|
|
className="gap-2.5 px-2 py-1.5 text-sm cursor-pointer"
|
|
>
|
|
<Monitor className="size-4 text-muted-foreground" />
|
|
<span>Match system</span>
|
|
<span
|
|
className="ml-auto size-3 rounded-full ring-1 ring-foreground/15"
|
|
style={{
|
|
background: "linear-gradient(90deg, #4f56e0 50%, #7c83f2 50%)",
|
|
}}
|
|
aria-hidden
|
|
/>
|
|
{isSystem && <Check className="size-4 shrink-0" />}
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
);
|
|
}
|