From 44bc838f6aeb4ac5b1f7f67e47edb4fd10286733 Mon Sep 17 00:00:00 2001 From: qixing-jk Date: Sun, 7 Sep 2025 22:06:28 +0800 Subject: 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 --- apps/browser-extension/src/OptionsPage.tsx | 22 +++ .../browser-extension/src/components/ui/select.tsx | 158 +++++++++++++++++++++ apps/browser-extension/src/utils/ThemeProvider.tsx | 64 +++++---- apps/browser-extension/src/utils/providers.tsx | 4 +- apps/browser-extension/src/utils/settings.ts | 2 + apps/browser-extension/tailwind.config.js | 2 +- 6 files changed, 220 insertions(+), 32 deletions(-) create mode 100644 apps/browser-extension/src/components/ui/select.tsx (limited to 'apps/browser-extension') 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() { Logged in as: {loggedInMessage} +
+ Theme: + +
); 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, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:line-clamp-1", + className, + )} + {...props} + > + {children} + + + + +)); +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName; + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = "popper", ...props }, ref) => ( + + + + + {children} + + + + +)); +SelectContent.displayName = SelectPrimitive.Content.displayName; + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectLabel.displayName = SelectPrimitive.Label.displayName; + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + + {children} + +)); +SelectItem.displayName = SelectPrimitive.Item.displayName; + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +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(initialState); -export function ThemeProvider({ - children, - defaultTheme = "system", - storageKey = "vite-ui-theme", - ...props -}: ThemeProviderProps) { - const [theme, setTheme] = useState( - () => (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 ( - - {children} - + {children} ); } 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; 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], }; -- cgit v1.2.3-70-g09d2