diff options
| author | qixing-jk <street-anime-olive@duck.com> | 2025-09-07 22:06:28 +0800 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-09-07 15:06:28 +0100 |
| commit | 44bc838f6aeb4ac5b1f7f67e47edb4fd10286733 (patch) | |
| tree | 26412791ff96dbf8de56defb0daf7004ac08e3bc | |
| parent | 4362663dcf26a23cb222b501e0e3056906651245 (diff) | |
| download | karakeep-44bc838f6aeb4ac5b1f7f67e47edb4fd10286733.tar.zst | |
feat(extension): Add theme and dynamic icon support (#1894)
* feat: add theme selection support to browser extension
- integrate theme settings with plugin settings storage
- add theme selector dropdown to options page
- implement custom ThemeProvider using plugin settings
- include new Select UI component for theme selection
* feat(extension): add dynamic icon theme switching (#1100)
Add updateIcon() function to dynamically change extension icon based on
selected theme (light/dark/system). Update icon on initial load and when
settings change to reflect current theme preference.
Closes #1100
* fix(extension): switch dark mode strategy from media to selector
This allows manual control over dark mode via class toggling rather
than relying on the OS/browser preference.
* fix(extension): move icon update logic to content script
The `window` object is inaccessible in the background script, causing
icon updates to fail. This change relocates the icon update logic to the
content script where `window.matchMedia` is available.
- Remove `updateIcon` function from background script
- Add icon update logic to `ThemeProvider` component
- Consolidate theme and icon updates in single effect
* feat(settings): make theme field required in settings schema
Remove optional flag from theme field to enforce presence in
settings validation schema.
* deps: Upgrade the extension deps
* minor fixes
---------
Co-authored-by: MohamedBassem <me@mbassem.com>
| -rw-r--r-- | apps/browser-extension/src/OptionsPage.tsx | 22 | ||||
| -rw-r--r-- | apps/browser-extension/src/components/ui/select.tsx | 158 | ||||
| -rw-r--r-- | apps/browser-extension/src/utils/ThemeProvider.tsx | 64 | ||||
| -rw-r--r-- | apps/browser-extension/src/utils/providers.tsx | 4 | ||||
| -rw-r--r-- | apps/browser-extension/src/utils/settings.ts | 2 | ||||
| -rw-r--r-- | apps/browser-extension/tailwind.config.js | 2 |
6 files changed, 220 insertions, 32 deletions
diff --git a/apps/browser-extension/src/OptionsPage.tsx b/apps/browser-extension/src/OptionsPage.tsx index 41b72178..ef51bc02 100644 --- a/apps/browser-extension/src/OptionsPage.tsx +++ b/apps/browser-extension/src/OptionsPage.tsx @@ -2,14 +2,23 @@ import React, { useEffect } from "react"; import { useNavigate } from "react-router-dom"; import { Button } from "./components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "./components/ui/select"; import Logo from "./Logo"; import Spinner from "./Spinner"; import usePluginSettings from "./utils/settings"; +import { useTheme } from "./utils/ThemeProvider"; import { api } from "./utils/trpc"; export default function OptionsPage() { const navigate = useNavigate(); const { settings, setSettings } = usePluginSettings(); + const { setTheme, theme } = useTheme(); const { data: whoami, error: whoAmIError } = api.users.whoami.useQuery( undefined, @@ -63,6 +72,19 @@ export default function OptionsPage() { <span className="my-auto">Logged in as:</span> {loggedInMessage} </div> + <div className="flex gap-2"> + <span className="my-auto">Theme:</span> + <Select value={theme} onValueChange={setTheme}> + <SelectTrigger className="w-24"> + <SelectValue placeholder="Theme" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="light">Light</SelectItem> + <SelectItem value="dark">Dark</SelectItem> + <SelectItem value="system">System</SelectItem> + </SelectContent> + </Select> + </div> <Button onClick={onLogout}>Logout</Button> </div> ); diff --git a/apps/browser-extension/src/components/ui/select.tsx b/apps/browser-extension/src/components/ui/select.tsx new file mode 100644 index 00000000..796eb204 --- /dev/null +++ b/apps/browser-extension/src/components/ui/select.tsx @@ -0,0 +1,158 @@ +import * as React from "react"; +import * as SelectPrimitive from "@radix-ui/react-select"; +import { Check, ChevronDown, ChevronUp } from "lucide-react"; + +import { cn } from "../../utils/css"; + +const Select = SelectPrimitive.Root; + +const SelectGroup = SelectPrimitive.Group; + +const SelectValue = SelectPrimitive.Value; + +const SelectTrigger = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.Trigger>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> +>(({ className, children, ...props }, ref) => ( + <SelectPrimitive.Trigger + ref={ref} + className={cn( + "flex h-8 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", + className, + )} + {...props} + > + {children} + <SelectPrimitive.Icon asChild> + <ChevronDown className="size-4 opacity-50" /> + </SelectPrimitive.Icon> + </SelectPrimitive.Trigger> +)); +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.ScrollUpButton>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton> +>(({ className, ...props }, ref) => ( + <SelectPrimitive.ScrollUpButton + ref={ref} + className={cn( + "flex cursor-default items-center justify-center py-1", + className, + )} + {...props} + > + <ChevronUp className="size-4" /> + </SelectPrimitive.ScrollUpButton> +)); +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.ScrollDownButton>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton> +>(({ className, ...props }, ref) => ( + <SelectPrimitive.ScrollDownButton + ref={ref} + className={cn( + "flex cursor-default items-center justify-center py-1", + className, + )} + {...props} + > + <ChevronDown className="size-4" /> + </SelectPrimitive.ScrollDownButton> +)); +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName; + +const SelectContent = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content> +>(({ className, children, position = "popper", ...props }, ref) => ( + <SelectPrimitive.Portal> + <SelectPrimitive.Content + ref={ref} + className={cn( + "relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-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", + position === "popper" && + "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", + className, + )} + position={position} + {...props} + > + <SelectScrollUpButton /> + <SelectPrimitive.Viewport + className={cn( + "p-1", + position === "popper" && + "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]", + )} + > + {children} + </SelectPrimitive.Viewport> + <SelectScrollDownButton /> + </SelectPrimitive.Content> + </SelectPrimitive.Portal> +)); +SelectContent.displayName = SelectPrimitive.Content.displayName; + +const SelectLabel = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.Label>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label> +>(({ className, ...props }, ref) => ( + <SelectPrimitive.Label + ref={ref} + className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)} + {...props} + /> +)); +SelectLabel.displayName = SelectPrimitive.Label.displayName; + +const SelectItem = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.Item>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> +>(({ className, children, ...props }, ref) => ( + <SelectPrimitive.Item + ref={ref} + className={cn( + "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", + className, + )} + {...props} + > + <span className="absolute left-2 flex size-3.5 items-center justify-center"> + <SelectPrimitive.ItemIndicator> + <Check className="size-4" /> + </SelectPrimitive.ItemIndicator> + </span> + + <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> + </SelectPrimitive.Item> +)); +SelectItem.displayName = SelectPrimitive.Item.displayName; + +const SelectSeparator = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.Separator>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator> +>(({ className, ...props }, ref) => ( + <SelectPrimitive.Separator + ref={ref} + className={cn("-mx-1 my-1 h-px bg-muted", className)} + {...props} + /> +)); +SelectSeparator.displayName = SelectPrimitive.Separator.displayName; + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +}; diff --git a/apps/browser-extension/src/utils/ThemeProvider.tsx b/apps/browser-extension/src/utils/ThemeProvider.tsx index 79a0b32f..20a928e1 100644 --- a/apps/browser-extension/src/utils/ThemeProvider.tsx +++ b/apps/browser-extension/src/utils/ThemeProvider.tsx @@ -1,11 +1,11 @@ -import { createContext, useContext, useEffect, useState } from "react"; +import { createContext, useContext, useEffect } from "react"; + +import usePluginSettings from "./settings"; type Theme = "dark" | "light" | "system"; interface ThemeProviderProps { children: React.ReactNode; - defaultTheme?: Theme; - storageKey?: string; } interface ThemeProviderState { @@ -20,39 +20,47 @@ const initialState: ThemeProviderState = { 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, - ); +export function ThemeProvider({ children, ...props }: ThemeProviderProps) { + const { settings, setSettings } = usePluginSettings(); + const theme = settings.theme; 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); + const updateIcon = (useDarkModeIcons: boolean) => { + const iconSuffix = useDarkModeIcons ? "-darkmode.png" : ".png"; + + const iconPaths = { + "16": `logo-16${iconSuffix}`, + "48": `logo-48${iconSuffix}`, + "128": `logo-128${iconSuffix}`, + }; + chrome.action.setIcon({ path: iconPaths }); + }; + + const applyThemeAndIcon = () => { + root.classList.remove("light", "dark"); + + let currentTheme: "light" | "dark"; + if (theme === "system") { + currentTheme = window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; + } else { + currentTheme = theme; + } + + root.classList.add(currentTheme); + updateIcon(currentTheme === "dark"); + }; + + applyThemeAndIcon(); }, [theme]); const value = { theme, - setTheme: (theme: Theme) => { - localStorage.setItem(storageKey, theme); - setTheme(theme); + setTheme: (newTheme: Theme) => { + setSettings((s) => ({ ...s, theme: newTheme })); }, }; diff --git a/apps/browser-extension/src/utils/providers.tsx b/apps/browser-extension/src/utils/providers.tsx index 827cc84e..86489d6d 100644 --- a/apps/browser-extension/src/utils/providers.tsx +++ b/apps/browser-extension/src/utils/providers.tsx @@ -8,9 +8,7 @@ export function Providers({ children }: { children: React.ReactNode }) { return ( <TRPCProvider settings={settings}> - <ThemeProvider defaultTheme="system" storageKey="vite-ui-theme"> - {children} - </ThemeProvider> + <ThemeProvider>{children}</ThemeProvider> </TRPCProvider> ); } diff --git a/apps/browser-extension/src/utils/settings.ts b/apps/browser-extension/src/utils/settings.ts index c273acfa..523699b4 100644 --- a/apps/browser-extension/src/utils/settings.ts +++ b/apps/browser-extension/src/utils/settings.ts @@ -5,11 +5,13 @@ const zSettingsSchema = z.object({ apiKey: z.string(), apiKeyId: z.string().optional(), address: z.string(), + theme: z.enum(["light", "dark", "system"]).optional().default("system"), }); const DEFAULT_SETTINGS: Settings = { apiKey: "", address: "", + theme: "system", }; export type Settings = z.infer<typeof zSettingsSchema>; diff --git a/apps/browser-extension/tailwind.config.js b/apps/browser-extension/tailwind.config.js index d378ba15..d140560b 100644 --- a/apps/browser-extension/tailwind.config.js +++ b/apps/browser-extension/tailwind.config.js @@ -1,7 +1,7 @@ import web from "@karakeep/tailwind-config/web"; const config = { - darkMode: "media", + darkMode: "selector", content: web.content, presets: [web], }; |
