feat: add dark theme and toggle

This commit is contained in:
Thomas Bishop 2025-07-28 18:05:37 +01:00
parent 22a6bc4b82
commit 654f6f77ea
17 changed files with 575 additions and 281 deletions

115
package-lock.json generated
View file

@ -11,8 +11,10 @@
"@radix-ui/react-collapsible": "^1.1.7", "@radix-ui/react-collapsible": "^1.1.7",
"@radix-ui/react-dialog": "^1.1.10", "@radix-ui/react-dialog": "^1.1.10",
"@radix-ui/react-hover-card": "^1.1.14", "@radix-ui/react-hover-card": "^1.1.14",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-separator": "^1.1.4", "@radix-ui/react-separator": "^1.1.4",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tooltip": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.3",
"@tailwindcss/vite": "^4.1.4", "@tailwindcss/vite": "^4.1.4",
"@tanstack/react-query": "^5.83.0", "@tanstack/react-query": "^5.83.0",
@ -1426,6 +1428,52 @@
} }
} }
}, },
"node_modules/@radix-ui/react-label": {
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz",
"integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popper": { "node_modules/@radix-ui/react-popper": {
"version": "1.2.4", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.4.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.4.tgz",
@ -1584,6 +1632,58 @@
} }
} }
}, },
"node_modules/@radix-ui/react-switch": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.5.tgz",
"integrity": "sha512-5ijLkak6ZMylXsaImpZ8u4Rlf5grRmoc0p0QeX9VJtlrM4f5m3nCTX8tWga/zOA8PZYIR/t0p2Mnvd7InrJ6yQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.2",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-use-size": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip": { "node_modules/@radix-ui/react-tooltip": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.3.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.3.tgz",
@ -1715,6 +1815,21 @@
} }
} }
}, },
"node_modules/@radix-ui/react-use-previous": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz",
"integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-rect": { "node_modules/@radix-ui/react-use-rect": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",

View file

@ -13,8 +13,10 @@
"@radix-ui/react-collapsible": "^1.1.7", "@radix-ui/react-collapsible": "^1.1.7",
"@radix-ui/react-dialog": "^1.1.10", "@radix-ui/react-dialog": "^1.1.10",
"@radix-ui/react-hover-card": "^1.1.14", "@radix-ui/react-hover-card": "^1.1.14",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-separator": "^1.1.4", "@radix-ui/react-separator": "^1.1.4",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tooltip": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.3",
"@tailwindcss/vite": "^4.1.4", "@tailwindcss/vite": "^4.1.4",
"@tanstack/react-query": "^5.83.0", "@tanstack/react-query": "^5.83.0",

View file

@ -62,7 +62,7 @@ export default function EntriesListSidebar() {
{entries?.data.map((item, i) => ( {entries?.data.map((item, i) => (
<SidebarMenuItem key={i}> <SidebarMenuItem key={i}>
<Link to={`/entries/${item.title}`}> <Link to={`/entries/${item.title}`}>
<span className="text-xs text-black hover:text-gray-600 active:text-gray-700 focus:text-gray-900"> <span className="text-xs text-foreground dark:hover:text-green-300 dark:active:text-green-400 hover:text-gray-600 active:text-gray-700 focus:text-gray-900 dark:focus:text-green-900">
{item.title.replace(/_/g, " ")} {item.title.replace(/_/g, " ")}
</span> </span>
</Link> </Link>

View file

@ -62,7 +62,7 @@ export default function TagListSidebar() {
{tags?.data.map((item, i) => ( {tags?.data.map((item, i) => (
<SidebarMenuItem key={i}> <SidebarMenuItem key={i}>
<Link to={`/tags/${item}`}> <Link to={`/tags/${item}`}>
<span className="text-xs text-black hover:text-gray-600 active:text-gray-700 focus:text-gray-900"> <span className="text-xs text-foreground dark:hover:text-green-300 dark:active:text-green-400 hover:text-gray-600 active:text-gray-700 focus:text-gray-900 dark:focus:text-green-900">
{item} {item}
</span> </span>
</Link> </Link>

View file

@ -0,0 +1,28 @@
import { useState } from "react"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import { useTheme } from "@/context/ThemeProvider"
export default function ThemeToggle() {
const { theme, setTheme } = useTheme()
const [darkMode, setDarkMode] = useState(false)
const handleToggle = (checked) => {
setTheme(theme === "dark" ? "light" : "dark")
}
console.log(darkMode)
return (
<div>
<h3 className="font-semibold mb-4">Theme</h3>
<p className="text-sm">
The theme is set automatically to your system default. You can change this below.
</p>
<div className="flex items-start space-x-2 mt-5">
<Switch id="dark-theme" checked={theme === "dark"} onCheckedChange={handleToggle} />
<Label htmlFor="dark-theme">Dark theme</Label>
</div>
</div>
)
}

View file

@ -0,0 +1,22 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
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 }

View file

@ -0,0 +1,29 @@
import * as React from "react"
import * as SwitchPrimitive from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }

View file

@ -24,7 +24,7 @@ const footerMenu = [
{ {
title: "Settings", title: "Settings",
url: "#", url: "/settings",
icon: Settings, icon: Settings,
}, },
] ]
@ -75,10 +75,18 @@ export function AppSidebar() {
{footerMenu.map((item) => ( {footerMenu.map((item) => (
<SidebarMenuItem key={item.title}> <SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild className="rounded-none"> <SidebarMenuButton asChild className="rounded-none">
<a href={item.url}> <Link to={item.url}>
<item.icon />
<span>{item.title}</span>
</Link>
{/*
<a href={item.url}>
<item.icon /> <item.icon />
<span>{item.title}</span> <span>{item.title}</span>
</a> </a>
*/}
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
))} ))}

View file

@ -10,7 +10,7 @@ const columns = [
cell: ({ cell, row }) => { cell: ({ cell, row }) => {
return ( return (
<Link to={`entries/${row.original.title}`}> <Link to={`entries/${row.original.title}`}>
<span className="text-black underline-offset-3 underline hover:text-gray-700"> <span className="text-foreground underline-offset-3 underline hover:text-gray-700 dark:hover:text-green-300">
{row.original.title} {row.original.title}
</span> </span>
</Link> </Link>

View file

@ -0,0 +1,8 @@
import ThemeToggle from "@/components/ThemeToggle"
export default function Settings() {
return (
<div>
<ThemeToggle />
</div>
)
}

View file

@ -0,0 +1,72 @@
import * as React from "react"
import { createContext, useContext, useEffect, useState } from "react"
type Theme = "dark" | "light" | "system"
type ThemeProviderProps = {
children: React.ReactNode
defaultTheme?: Theme
storageKey?: string
}
type ThemeProviderState = {
theme: Theme
setTheme: (theme: Theme) => void
}
const initialState: ThemeProviderState = {
theme: "system",
setTheme: () => null,
}
const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
export function ThemeProvider({
children,
defaultTheme = "system",
storageKey = "vite-ui-theme",
...props
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme,
)
useEffect(() => {
const root = window.document.documentElement
root.classList.remove("light", "dark")
if (theme === "system") {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
root.classList.add(systemTheme)
return
}
root.classList.add(theme)
}, [theme])
const value = {
theme,
setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme)
setTheme(theme)
},
}
return (
<ThemeProviderContext.Provider {...props} value={value}>
{children}
</ThemeProviderContext.Provider>
)
}
export const useTheme = () => {
const context = useContext(ThemeProviderContext)
if (context === undefined) throw new Error("useTheme must be used within a ThemeProvider")
return context
}

View file

@ -74,6 +74,7 @@
--sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0); --sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0); --sidebar-ring: oklch(0.708 0 0);
color-scheme: light;
} }
.dark { .dark {
@ -108,15 +109,18 @@
--sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%); --sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0); --sidebar-ring: oklch(0.556 0 0);
color-scheme: dark;
} }
@layer base { @layer base {
* { * {
@apply border-border outline-ring/50; @apply border-border outline-ring/50;
} }
html { html {
font-family: "Inter", sans-serif; font-family: "Inter", sans-serif;
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }

6
src/pages/Settings.tsx Normal file
View file

@ -0,0 +1,6 @@
import Page from "@/templates/Page"
import { default as SettingsContainer } from "@/containers/Settings"
export default function Settings() {
return <Page pageTitle="Settings" pageBody={<SettingsContainer />} />
}

View file

@ -40,7 +40,7 @@ export default function Home() {
</HoverCard> </HoverCard>
is{" "} is{" "}
<a <a
className="text-black underline-offset-3 font-medium underline hover:text-gray-700" className="text-foreground underline-offset-3 font-medium underline hover:text-gray-700 dark:hover:text-green-300"
href="#" href="#"
> >
my my

View file

@ -1,4 +1,3 @@
import Main from "@/templates/Main"
import { useParams } from "react-router" import { useParams } from "react-router"
import Page from "./Page" import Page from "./Page"
export default function Entry() { export default function Entry() {

View file

@ -1,10 +1,11 @@
import AppHeader from "@/components/AppHeader" import AppHeader from "@/components/AppHeader"
import { AppSidebar } from "@/containers/AppSidebar" import { AppSidebar } from "@/containers/AppSidebar"
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar" import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
import { ThemeProvider } from "@/context/ThemeProvider"
export default function Main({ children, pageTitle }) { export default function Main({ children, pageTitle }) {
return ( return (
<> <ThemeProvider storageKey="app-theme">
<SidebarProvider variant="inset"> <SidebarProvider variant="inset">
<AppSidebar /> <AppSidebar />
<SidebarInset> <SidebarInset>
@ -12,6 +13,6 @@ export default function Main({ children, pageTitle }) {
<main>{children}</main> <main>{children}</main>
</SidebarInset> </SidebarInset>
</SidebarProvider> </SidebarProvider>
</> </ThemeProvider>
) )
} }

View file

@ -6,7 +6,7 @@ export default function Page({ pageTitle = null, pageBody = null, titleComponent
<div className="flex-1 flex flex-col overflow-auto"> <div className="flex-1 flex flex-col overflow-auto">
<div className="@container/main flex flex-col"> <div className="@container/main flex flex-col">
<div className="flex flex-1"> <div className="flex flex-1">
<div className="border-l-none w-full"> <div className="border-none w-full">
<div className="border-b py-2 px-4 lg:px-6 bg-sidebar"> <div className="border-b py-2 px-4 lg:px-6 bg-sidebar">
<h2 className="scroll-m-20 font-semibold"> <h2 className="scroll-m-20 font-semibold">
{titleComponent ? titleComponent : <span>{pageTitle}</span>} {titleComponent ? titleComponent : <span>{pageTitle}</span>}