aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorqixing-jk <street-anime-olive@duck.com>2025-09-07 22:06:28 +0800
committerGitHub <noreply@github.com>2025-09-07 15:06:28 +0100
commit44bc838f6aeb4ac5b1f7f67e47edb4fd10286733 (patch)
tree26412791ff96dbf8de56defb0daf7004ac08e3bc
parent4362663dcf26a23cb222b501e0e3056906651245 (diff)
downloadkarakeep-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.tsx22
-rw-r--r--apps/browser-extension/src/components/ui/select.tsx158
-rw-r--r--apps/browser-extension/src/utils/ThemeProvider.tsx64
-rw-r--r--apps/browser-extension/src/utils/providers.tsx4
-rw-r--r--apps/browser-extension/src/utils/settings.ts2
-rw-r--r--apps/browser-extension/tailwind.config.js2
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],
};