added dark mode

This commit is contained in:
Arjun 2026-02-04 22:36:27 +05:30
parent 92d324a84e
commit 53bbd3b76d
4 changed files with 251 additions and 49 deletions

View file

@ -123,6 +123,9 @@
--sidebar-accent-foreground: var(--text-color, oklch(0.205 0 0));
--sidebar-border: var(--sub-alt-color, oklch(0.922 0 0));
--sidebar-ring: var(--main-color, oklch(0.708 0 0));
--scrollbar-track: oklch(0.95 0 0);
--scrollbar-thumb: oklch(0.75 0 0);
--scrollbar-thumb-hover: oklch(0.65 0 0);
}
.dark {
@ -157,15 +160,39 @@
--sidebar-accent-foreground: var(--text-color, oklch(0.985 0 0));
--sidebar-border: var(--sub-alt-color, oklch(1 0 0 / 10%));
--sidebar-ring: var(--main-color, oklch(0.556 0 0));
--scrollbar-track: oklch(0.2 0 0);
--scrollbar-thumb: oklch(0.4 0 0);
--scrollbar-thumb-hover: oklch(0.5 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
scrollbar-width: thin;
}
body {
@apply bg-background text-foreground;
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--scrollbar-track);
}
::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-thumb-hover);
}
}
/* Markdown content base styles for Streamdown/MessageResponse */

View file

@ -1,8 +1,8 @@
"use client"
import * as React from "react"
import { useState, useEffect } from "react"
import { Server, Key, Shield } from "lucide-react"
import { useState, useEffect, useCallback } from "react"
import { Server, Key, Shield, Palette, Monitor, Sun, Moon } from "lucide-react"
import {
Dialog,
@ -11,14 +11,15 @@ import {
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import { useTheme, type Theme } from "@/contexts/theme-context"
type ConfigTab = "models" | "mcp" | "security"
type ConfigTab = "models" | "mcp" | "security" | "appearance"
interface TabConfig {
id: ConfigTab
label: string
icon: React.ElementType
path: string
path?: string
description: string
}
@ -44,12 +45,82 @@ const tabs: TabConfig[] = [
path: "config/security.json",
description: "Configure allowed shell commands",
},
{
id: "appearance",
label: "Appearance",
icon: Palette,
description: "Customize the look and feel",
},
]
interface SettingsDialogProps {
children: React.ReactNode
}
function ThemeOption({
label,
icon: Icon,
isSelected,
onClick,
}: {
label: string
icon: React.ElementType
isSelected: boolean
onClick: () => void
}) {
return (
<button
onClick={onClick}
className={cn(
"flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-all",
isSelected
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50 hover:bg-muted/50"
)}
>
<Icon className={cn("size-6", isSelected ? "text-primary" : "text-muted-foreground")} />
<span className={cn("text-sm font-medium", isSelected ? "text-primary" : "text-foreground")}>
{label}
</span>
</button>
)
}
function AppearanceSettings() {
const { theme, setTheme } = useTheme()
return (
<div className="space-y-6">
<div>
<h4 className="text-sm font-medium mb-3">Theme</h4>
<p className="text-xs text-muted-foreground mb-4">
Select your preferred color scheme
</p>
<div className="grid grid-cols-3 gap-3">
<ThemeOption
label="Light"
icon={Sun}
isSelected={theme === "light"}
onClick={() => setTheme("light")}
/>
<ThemeOption
label="Dark"
icon={Moon}
isSelected={theme === "dark"}
onClick={() => setTheme("dark")}
/>
<ThemeOption
label="System"
icon={Monitor}
isSelected={theme === "system"}
onClick={() => setTheme("system")}
/>
</div>
</div>
</div>
)
}
export function SettingsDialog({ children }: SettingsDialogProps) {
const [open, setOpen] = useState(false)
const [activeTab, setActiveTab] = useState<ConfigTab>("models")
@ -60,9 +131,20 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
const [error, setError] = useState<string | null>(null)
const activeTabConfig = tabs.find((t) => t.id === activeTab)!
const isConfigTab = activeTab !== "appearance"
const loadConfig = async (tab: ConfigTab) => {
const formatJson = (jsonString: string): string => {
try {
return JSON.stringify(JSON.parse(jsonString), null, 2)
} catch {
return jsonString
}
}
const loadConfig = useCallback(async (tab: ConfigTab) => {
if (tab === "appearance") return
const tabConfig = tabs.find((t) => t.id === tab)!
if (!tabConfig.path) return
setLoading(true)
setError(null)
try {
@ -72,16 +154,17 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
const formattedContent = formatJson(result.data)
setContent(formattedContent)
setOriginalContent(formattedContent)
} catch (err) {
} catch {
setError(`Failed to load ${tabConfig.label} config`)
setContent("")
setOriginalContent("")
} finally {
setLoading(false)
}
}
}, [])
const saveConfig = async () => {
if (!isConfigTab || !activeTabConfig.path) return
setSaving(true)
setError(null)
try {
@ -103,14 +186,6 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
}
}
const formatJson = (jsonString: string): string => {
try {
return JSON.stringify(JSON.parse(jsonString), null, 2)
} catch {
return jsonString
}
}
const handleFormat = () => {
setContent(formatJson(content))
}
@ -118,10 +193,10 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
const hasChanges = content !== originalContent
useEffect(() => {
if (open) {
if (open && activeTab !== "appearance") {
loadConfig(activeTab)
}
}, [open, activeTab])
}, [open, activeTab, loadConfig])
const handleTabChange = (tab: ConfigTab) => {
if (hasChanges) {
@ -173,9 +248,11 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
</p>
</div>
{/* Editor */}
{/* Content */}
<div className="flex-1 p-4 overflow-hidden">
{loading ? (
{activeTab === "appearance" ? (
<AppearanceSettings />
) : loading ? (
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
Loading...
</div>
@ -190,36 +267,38 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
)}
</div>
{/* Footer */}
<div className="px-4 py-3 border-t flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
{error && (
<span className="text-xs text-destructive">{error}</span>
)}
{hasChanges && !error && (
<span className="text-xs text-muted-foreground">
Unsaved changes
</span>
)}
{/* Footer - only show for config tabs */}
{isConfigTab && (
<div className="px-4 py-3 border-t flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
{error && (
<span className="text-xs text-destructive">{error}</span>
)}
{hasChanges && !error && (
<span className="text-xs text-muted-foreground">
Unsaved changes
</span>
)}
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handleFormat}
disabled={loading || saving}
>
Format
</Button>
<Button
size="sm"
onClick={saveConfig}
disabled={loading || saving || !hasChanges}
>
{saving ? "Saving..." : "Save"}
</Button>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handleFormat}
disabled={loading || saving}
>
Format
</Button>
<Button
size="sm"
onClick={saveConfig}
disabled={loading || saving || !hasChanges}
>
{saving ? "Saving..." : "Save"}
</Button>
</div>
</div>
)}
</div>
</div>
</DialogContent>

View file

@ -0,0 +1,93 @@
"use client"
import * as React from "react"
export type Theme = "light" | "dark" | "system"
type ThemeContextProps = {
theme: Theme
resolvedTheme: "light" | "dark"
setTheme: (theme: Theme) => void
}
const ThemeContext = React.createContext<ThemeContextProps | null>(null)
const STORAGE_KEY = "rowboat-theme"
function getSystemTheme(): "light" | "dark" {
if (typeof window === "undefined") return "light"
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
}
export function useTheme() {
const context = React.useContext(ThemeContext)
if (!context) {
throw new Error("useTheme must be used within a ThemeProvider.")
}
return context
}
export function ThemeProvider({
defaultTheme = "system",
children,
}: {
defaultTheme?: Theme
children: React.ReactNode
}) {
const [theme, setThemeState] = React.useState<Theme>(() => {
if (typeof window === "undefined") return defaultTheme
const stored = localStorage.getItem(STORAGE_KEY) as Theme | null
return stored || defaultTheme
})
const [resolvedTheme, setResolvedTheme] = React.useState<"light" | "dark">(() => {
if (theme === "system") return getSystemTheme()
return theme
})
// Apply theme to document
React.useEffect(() => {
const root = document.documentElement
const resolved = theme === "system" ? getSystemTheme() : theme
root.classList.remove("light", "dark")
root.classList.add(resolved)
setResolvedTheme(resolved)
}, [theme])
// Listen for system theme changes
React.useEffect(() => {
if (theme !== "system") return
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
const handleChange = () => {
const resolved = getSystemTheme()
document.documentElement.classList.remove("light", "dark")
document.documentElement.classList.add(resolved)
setResolvedTheme(resolved)
}
mediaQuery.addEventListener("change", handleChange)
return () => mediaQuery.removeEventListener("change", handleChange)
}, [theme])
const setTheme = React.useCallback((newTheme: Theme) => {
localStorage.setItem(STORAGE_KEY, newTheme)
setThemeState(newTheme)
}, [])
const contextValue = React.useMemo<ThemeContextProps>(
() => ({
theme,
resolvedTheme,
setTheme,
}),
[theme, resolvedTheme, setTheme]
)
return (
<ThemeContext.Provider value={contextValue}>
{children}
</ThemeContext.Provider>
)
}

View file

@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import { PostHogProvider } from 'posthog-js/react'
import { ThemeProvider } from '@/contexts/theme-context'
const options = {
api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST,
@ -12,7 +13,9 @@ const options = {
createRoot(document.getElementById('root')!).render(
<StrictMode>
<PostHogProvider apiKey={import.meta.env.VITE_PUBLIC_POSTHOG_KEY} options={options}>
<App />
<ThemeProvider defaultTheme="system">
<App />
</ThemeProvider>
</PostHogProvider>
</StrictMode>,
)