diff --git a/src/components/layout/user-menu.tsx b/src/components/layout/user-menu.tsx index aa4eb0a..dca693c 100644 --- a/src/components/layout/user-menu.tsx +++ b/src/components/layout/user-menu.tsx @@ -1,6 +1,5 @@ import { Link, useNavigate } from "@tanstack/react-router"; import { ChevronDown, LogOut, Settings } from "lucide-react"; -import { ThemeToggle } from "@/components/theme-toggle"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { @@ -76,9 +75,6 @@ export function UserMenu({ showIdentity = false, menuDirection = "down" }: UserM Settings - - - void signOut({ diff --git a/src/components/task-page-code-view.tsx b/src/components/task-page-code-view.tsx index 7846473..7090194 100644 --- a/src/components/task-page-code-view.tsx +++ b/src/components/task-page-code-view.tsx @@ -25,7 +25,7 @@ export function TaskPageCodeView({ isRunnerBackedTask, preparingWorkspace, }: TaskPageCodeViewProps) { - const { theme } = useTheme(); + const { mode } = useTheme(); if (preparingWorkspace) { return ( @@ -96,7 +96,7 @@ export function TaskPageCodeView({ diffStyle: "split", lineDiffType: "word", overflow: "scroll", - themeType: theme, + themeType: mode, }} className="runner-diff-view" /> diff --git a/src/components/theme-provider.tsx b/src/components/theme-provider.tsx index f9e64b6..ba07cfe 100644 --- a/src/components/theme-provider.tsx +++ b/src/components/theme-provider.tsx @@ -1,17 +1,24 @@ import { createContext, useContext, useEffect, useState, type ReactNode } from "react"; import { localStorageKeys } from "@/lib/session-state"; -import { applyTheme, defaultTheme, getStoredTheme, type Theme } from "@/lib/theme"; +import { + applyTheme, + type ClankerId, + defaultTheme, + getStoredTheme, + getThemeMode, + type ThemeMode, +} from "@/lib/theme"; type ThemeContextValue = { - theme: Theme; - setTheme: (theme: Theme) => void; - toggleTheme: () => void; + theme: ClankerId; + mode: ThemeMode; + setTheme: (theme: ClankerId) => void; }; const ThemeContext = createContext(null); export function ThemeProvider({ children }: { children: ReactNode }) { - const [theme, setThemeState] = useState(defaultTheme); + const [theme, setThemeState] = useState(defaultTheme); useEffect(() => { setThemeState(getStoredTheme()); @@ -21,7 +28,9 @@ export function ThemeProvider({ children }: { children: ReactNode }) { applyTheme(theme); }, [theme]); - const setTheme = (nextTheme: Theme) => { + const mode = getThemeMode(theme); + + const setTheme = (nextTheme: ClankerId) => { setThemeState(nextTheme); if (typeof window === "undefined") { @@ -35,14 +44,8 @@ export function ThemeProvider({ children }: { children: ReactNode }) { } }; - const toggleTheme = () => { - setTheme(theme === "dark" ? "light" : "dark"); - }; - return ( - - {children} - + {children} ); } diff --git a/src/components/theme-toggle.tsx b/src/components/theme-toggle.tsx deleted file mode 100644 index 6d1d3f4..0000000 --- a/src/components/theme-toggle.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { Moon, Sun } from "lucide-react"; -import { useTheme } from "@/components/theme-provider"; -import { Button } from "@/components/ui/button"; -import { DropdownMenuItem } from "@/components/ui/dropdown-menu"; -import { cn } from "@/lib/utils"; - -type ThemeToggleProps = { - variant?: "button" | "menu-item"; - className?: string; -}; - -export function ThemeToggle({ variant = "button", className }: ThemeToggleProps) { - const { theme, toggleTheme } = useTheme(); - const isDark = theme === "dark"; - const Icon = isDark ? Sun : Moon; - const label = isDark ? "Light mode" : "Dark mode"; - - if (variant === "menu-item") { - return ( - - - {label} - - ); - } - - return ( - - ); -} diff --git a/src/index.css b/src/index.css index 7d65418..5406568 100644 --- a/src/index.css +++ b/src/index.css @@ -31,7 +31,8 @@ --color-ring: var(--ring); } -:root { +:root, +:root[data-theme="r2d2"] { --radius: 0.4rem; --background: #eaf1f8; --foreground: #0f2238; @@ -57,29 +58,129 @@ --body-grid: rgb(17 35 58 / 5%); } -.dark { - --background: #101924; - --foreground: #edf4ff; - --card: #172332; - --card-foreground: #edf4ff; - --popover: #172332; - --popover-foreground: #edf4ff; - --primary: #7db7ff; - --primary-foreground: #0c1621; - --secondary: #2a3f56; - --secondary-foreground: #edf4ff; - --muted: #203043; - --muted-foreground: #a0b4ca; - --accent: #314d6b; - --accent-foreground: #edf4ff; +:root[data-theme="bb8"] { + --background: #f5e6d4; + --foreground: #2c180d; + --card: #fffaf2; + --card-foreground: #2c180d; + --popover: #fffaf2; + --popover-foreground: #2c180d; + --primary: #e8791d; + --primary-foreground: #fff8ef; + --secondary: #e3c4a5; + --secondary-foreground: #3e2414; + --muted: #ead7c1; + --muted-foreground: #6a4326; + --accent: #f0bb84; + --accent-foreground: #2c180d; + --destructive: #c94a35; + --border: #744523; + --input: #fffdfa; + --ring: #e8791d; + --body-glow-primary: rgb(232 121 29 / 18%); + --body-glow-secondary: rgb(116 69 35 / 10%); + --body-glow-tertiary: rgb(255 250 242 / 44%); + --body-grid: rgb(44 24 13 / 5%); +} + +:root[data-theme="bd1"] { + --background: #ecf6f4; + --foreground: #16343d; + --card: #f8fbfb; + --card-foreground: #16343d; + --popover: #f8fbfb; + --popover-foreground: #16343d; + --primary: #e3554f; + --primary-foreground: #fff7f6; + --secondary: #cfe4e2; + --secondary-foreground: #1e4953; + --muted: #dceceb; + --muted-foreground: #56727b; + --accent: #93d1cc; + --accent-foreground: #16343d; + --destructive: #c7443f; + --border: #2f8f92; + --input: #ffffff; + --ring: #2f8f92; + --body-glow-primary: rgb(47 143 146 / 16%); + --body-glow-secondary: rgb(227 85 79 / 10%); + --body-glow-tertiary: rgb(248 251 251 / 45%); + --body-grid: rgb(22 52 61 / 5%); +} + +:root[data-theme="k2so"] { + --background: #111317; + --foreground: #eef2f5; + --card: #191d22; + --card-foreground: #eef2f5; + --popover: #191d22; + --popover-foreground: #eef2f5; + --primary: #f05a5a; + --primary-foreground: #170d0d; + --secondary: #2d333b; + --secondary-foreground: #eef2f5; + --muted: #20252c; + --muted-foreground: #a4acb7; + --accent: #464d57; + --accent-foreground: #eef2f5; --destructive: #ff7b7b; - --border: #5f7f9e; - --input: #122030; - --ring: #7db7ff; - --body-glow-primary: rgb(86 153 255 / 20%); - --body-glow-secondary: rgb(79 210 194 / 14%); - --body-glow-tertiary: rgb(9 13 20 / 38%); - --body-grid: rgb(237 244 255 / 4%); + --border: #8d96a3; + --input: #171b20; + --ring: #f05a5a; + --body-glow-primary: rgb(240 90 90 / 19%); + --body-glow-secondary: rgb(141 150 163 / 10%); + --body-glow-tertiary: rgb(7 8 10 / 40%); + --body-grid: rgb(238 242 245 / 4%); +} + +:root[data-theme="ig11"] { + --background: #151816; + --foreground: #e3e7df; + --card: #202521; + --card-foreground: #e3e7df; + --popover: #202521; + --popover-foreground: #e3e7df; + --primary: #95a878; + --primary-foreground: #141713; + --secondary: #2e362f; + --secondary-foreground: #e3e7df; + --muted: #252c27; + --muted-foreground: #a6b0a0; + --accent: #485249; + --accent-foreground: #edf3e9; + --destructive: #d96f67; + --border: #7e8a78; + --input: #1b201c; + --ring: #95a878; + --body-glow-primary: rgb(149 168 120 / 17%); + --body-glow-secondary: rgb(93 103 91 / 13%); + --body-glow-tertiary: rgb(9 11 10 / 34%); + --body-grid: rgb(227 231 223 / 4%); +} + +:root[data-theme="ig88"] { + --background: #131415; + --foreground: #e7eaee; + --card: #1c1f22; + --card-foreground: #e7eaee; + --popover: #1c1f22; + --popover-foreground: #e7eaee; + --primary: #c98f43; + --primary-foreground: #18110a; + --secondary: #2a2e33; + --secondary-foreground: #e7eaee; + --muted: #22262a; + --muted-foreground: #9fa6af; + --accent: #3d434a; + --accent-foreground: #edf1f4; + --destructive: #db6a62; + --border: #8d949c; + --input: #171a1c; + --ring: #c98f43; + --body-glow-primary: rgb(201 143 67 / 16%); + --body-glow-secondary: rgb(141 148 156 / 10%); + --body-glow-tertiary: rgb(6 7 8 / 40%); + --body-grid: rgb(231 234 238 / 4%); } html, diff --git a/src/lib/session-state.ts b/src/lib/session-state.ts index d4c5687..041e543 100644 --- a/src/lib/session-state.ts +++ b/src/lib/session-state.ts @@ -146,8 +146,8 @@ export const localStorageKeys = { "last-used-task-model", ), theme: () => - createStorageStateKey<"light" | "dark">("local", "theme", { - parse: (value) => (value === "dark" ? "dark" : "light"), + createStorageStateKey("local", "theme", { + parse: (value) => value, serialize: (value) => value, }), }; diff --git a/src/lib/theme.ts b/src/lib/theme.ts index 75e8d74..0a7bf22 100644 --- a/src/lib/theme.ts +++ b/src/lib/theme.ts @@ -1,37 +1,109 @@ import { localStorageKeys } from "@/lib/session-state"; -export type Theme = "light" | "dark"; +export type ThemeMode = "light" | "dark"; -export const defaultTheme: Theme = "light"; +export type ClankerId = "r2d2" | "k2so" | "bb8" | "ig11" | "bd1" | "ig88"; -const themeColors: Record = { - light: "#eaf1f8", - dark: "#101924", +type ClankerThemeOption = { + id: ClankerId; + label: string; + description: string; + mode: ThemeMode; + themeColor: string; + previewColors: string[]; }; +type ClankerThemeOptions = Record; + +export const themeOptions = { + r2d2: { + id: "r2d2", + label: "R2-D2", + description: "Clean rebel blue with bright white panels.", + mode: "light", + themeColor: "#eaf1f8", + previewColors: ["#1f6fdb", "#f8fbff", "#2b4b69"], + }, + bb8: { + id: "bb8", + label: "BB-8", + description: "Warm sand tones with an orange spark.", + mode: "light", + themeColor: "#f5e6d4", + previewColors: ["#e8791d", "#fffaf2", "#744523"], + }, + bd1: { + id: "bd1", + label: "BD-1", + description: "Bright explorer white with red and teal details.", + mode: "light", + themeColor: "#ecf6f4", + previewColors: ["#e3554f", "#f8fbfb", "#2f8f92"], + }, + k2so: { + id: "k2so", + label: "K-2SO", + description: "Stealth black with imperial red highlights.", + mode: "dark", + themeColor: "#111317", + previewColors: ["#f05a5a", "#191d22", "#8d96a3"], + }, + ig88: { + id: "ig88", + label: "IG-88", + description: "Hunter-killer black steel with cold amber targeting.", + mode: "dark", + themeColor: "#131415", + previewColors: ["#c98f43", "#1c1f22", "#8d949c"], + }, + + ig11: { + id: "ig11", + label: "IG-11", + description: "Gunmetal steel with muted olive instrumentation.", + mode: "dark", + themeColor: "#151816", + previewColors: ["#95a878", "#242b26", "#c8d0c7"], + }, +} as const satisfies ClankerThemeOptions; + +export const defaultTheme: ClankerId = "r2d2"; + const themeStorageKey = localStorageKeys.theme().storageKey; -function resolveTheme(value: unknown): Theme { - return value === "dark" ? "dark" : defaultTheme; +function resolveTheme(value: unknown) { + return Object.keys(themeOptions).includes(value as ClankerId) + ? (value as ClankerId) + : defaultTheme; +} + +export function getThemeMode(theme: ClankerId) { + return themeOptions[theme].mode; +} + +function getThemeColor(theme: ClankerId) { + return themeOptions[theme].themeColor; } -export function applyTheme(theme: Theme) { +export function applyTheme(theme: ClankerId) { if (typeof document === "undefined") { return; } const root = document.documentElement; - root.classList.toggle("dark", theme === "dark"); - root.style.colorScheme = theme; + const mode = getThemeMode(theme); + root.dataset.theme = theme; + root.classList.toggle("dark", mode === "dark"); + root.style.colorScheme = mode ?? "light"; const themeColorMeta = document.querySelector('meta[name="theme-color"]'); if (themeColorMeta instanceof HTMLMetaElement) { - themeColorMeta.content = themeColors[theme]; + themeColorMeta.content = getThemeColor(theme) ?? themeOptions[defaultTheme].themeColor; } } -export function getStoredTheme(): Theme { +export function getStoredTheme(): ClankerId { if (typeof window === "undefined") { return defaultTheme; } @@ -43,8 +115,12 @@ export function getStoredTheme(): Theme { } } -export const themeInitializationScript = `(function(){try{var theme=localStorage.getItem(${JSON.stringify( +export const themeInitializationScript = `(function(){var defaults={theme:${JSON.stringify( + defaultTheme, +)},mode:${JSON.stringify(themeOptions[defaultTheme].mode)},themeColor:${JSON.stringify( + themeOptions[defaultTheme].themeColor, +)}};try{var themeModes=${JSON.stringify(themeOptions)};var themeColors=${JSON.stringify( + themeOptions, +)};var themeIds=${JSON.stringify(Object.keys(themeOptions).join(","))};var storedTheme=localStorage.getItem(${JSON.stringify( themeStorageKey, -)});var resolved=theme==="dark"?"dark":"light";var root=document.documentElement;root.classList.toggle("dark",resolved==="dark");root.style.colorScheme=resolved;var meta=document.querySelector('meta[name="theme-color"]');if(meta){meta.setAttribute("content",resolved==="dark"?${JSON.stringify( - themeColors.dark, -)}:${JSON.stringify(themeColors.light)})}}catch(_error){document.documentElement.style.colorScheme="light";}})();`; +)});var resolved=themeIds.indexOf(storedTheme)>-1?storedTheme:defaults.theme;var root=document.documentElement;root.dataset.theme=resolved;root.classList.toggle("dark",themeModes[resolved]==="dark");root.style.colorScheme=themeModes[resolved]||defaults.mode;var meta=document.querySelector('meta[name="theme-color"]');if(meta){meta.setAttribute("content",themeColors[resolved]||defaults.themeColor)}}catch(_error){var root=document.documentElement;root.dataset.theme=defaults.theme;root.classList.toggle("dark",defaults.mode==="dark");root.style.colorScheme=defaults.mode;}})();`; diff --git a/src/pages/settings-page.tsx b/src/pages/settings-page.tsx index 539b73b..c112a81 100644 --- a/src/pages/settings-page.tsx +++ b/src/pages/settings-page.tsx @@ -1,11 +1,13 @@ import { useState } from "react"; import { useNavigate, useSearch } from "@tanstack/react-router"; import { useLiveQuery } from "@tanstack/react-db"; -import { BookMarked, Loader2, Plus } from "lucide-react"; -import { ThemeToggle } from "@/components/theme-toggle"; +import { BookMarked, Check, Loader2, Plus } from "lucide-react"; +import { useTheme } from "@/components/theme-provider"; import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Card, CardContent } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; +import { themeOptions } from "@/lib/theme"; +import { cn } from "@/lib/utils"; import { AddProjectDialog } from "../components/add-project-dialog"; import { useOrganization } from "../components/layout/use-organization"; import { projectsCollection } from "../lib/collections"; @@ -18,6 +20,7 @@ function formatMsTimestamp(msTimestamp: bigint): string { export function SettingsPage() { const navigate = useNavigate(); const { addProject } = useSearch({ from: "/_layout/settings" }); + const { theme, setTheme } = useTheme(); const { data: projects, isLoading } = useLiveQuery((q) => q.from({ p: projectsCollection }).orderBy(({ p }) => p.created_at, "asc"), ); @@ -134,22 +137,75 @@ export function SettingsPage() {
- - -
- Appearance - - Switch the interface between the default light theme and a new dark mode. - -
- -
-
+
+

+ Appearance +

+

+ Pick the droid colourway you want Clanki to use on this device. +

+
+ +
+ {Object.entries(themeOptions).map(([_, option]) => { + const isSelected = option.id === theme; + const modeLabel = themeOptions[option.id].mode === "dark" ? "Night Ops" : "Day Shift"; + + return ( + + ); + })} +
-

+

Projects

) : projects.length === 0 ? ( -
+

No projects yet. Add a repository to get started.