feat: complete stage 2 core refactor

This commit is contained in:
Jason
2025-10-16 10:49:56 +08:00
parent cc0b7053aa
commit b88eb88608
17 changed files with 1521 additions and 461 deletions

View File

@@ -1,37 +0,0 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Provider } from "../types";
import { AppType } from "../lib/tauri-api";
import ProviderForm from "./ProviderForm";
interface AddProviderModalProps {
appType: AppType;
onAdd: (provider: Omit<Provider, "id">) => void;
onClose: () => void;
}
const AddProviderModal: React.FC<AddProviderModalProps> = ({
appType,
onAdd,
onClose,
}) => {
const { t } = useTranslation();
const title =
appType === "claude"
? t("provider.addClaudeProvider")
: t("provider.addCodexProvider");
return (
<ProviderForm
appType={appType}
title={title}
submitText={t("common.add")}
showPresets={true}
onSubmit={onAdd}
onClose={onClose}
/>
);
};
export default AddProviderModal;

View File

@@ -1,4 +1,4 @@
import { AppType } from "../lib/tauri-api";
import type { AppType } from "@/lib/api";
import { ClaudeIcon, CodexIcon } from "./BrandIcons";
interface AppSwitcherProps {

View File

@@ -1,73 +0,0 @@
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Provider } from "../types";
import { AppType } from "../lib/tauri-api";
import ProviderForm from "./ProviderForm";
interface EditProviderModalProps {
appType: AppType;
provider: Provider;
onSave: (provider: Provider) => void;
onClose: () => void;
}
const EditProviderModal: React.FC<EditProviderModalProps> = ({
appType,
provider,
onSave,
onClose,
}) => {
const { t } = useTranslation();
const [effectiveProvider, setEffectiveProvider] =
useState<Provider>(provider);
// 若为当前应用且正在编辑“当前供应商”,则优先读取 live 配置作为初始值Claude/Codex 均适用)
useEffect(() => {
let mounted = true;
const maybeLoadLive = async () => {
try {
const currentId = await window.api.getCurrentProvider(appType);
if (currentId && currentId === provider.id) {
const live = await window.api.getLiveProviderSettings(appType);
if (!mounted) return;
setEffectiveProvider({ ...provider, settingsConfig: live });
} else {
setEffectiveProvider(provider);
}
} catch (e) {
// 读取失败则回退到原 provider
setEffectiveProvider(provider);
}
};
maybeLoadLive();
return () => {
mounted = false;
};
}, [appType, provider]);
const handleSubmit = (data: Omit<Provider, "id">) => {
onSave({
...provider,
...data,
});
};
const title =
appType === "claude"
? t("provider.editClaudeProvider")
: t("provider.editCodexProvider");
return (
<ProviderForm
appType={appType}
title={title}
submitText={t("common.save")}
initialData={effectiveProvider}
showPresets={false}
onSubmit={handleSubmit}
onClose={onClose}
/>
);
};
export default EditProviderModal;

View File

@@ -0,0 +1,58 @@
import { Moon, Sun } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useTheme } from "@/components/theme-provider";
export function ModeToggle() {
const { theme, setTheme } = useTheme();
const { t } = useTranslation();
const handleChange = (value: string) => {
if (value === "light" || value === "dark" || value === "system") {
setTheme(value);
}
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">
{t("common.toggleTheme", { defaultValue: "切换主题" })}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>
{t("common.theme", { defaultValue: "主题" })}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
value={theme}
onValueChange={handleChange}
>
<DropdownMenuRadioItem value="light">
{t("common.lightMode", { defaultValue: "浅色" })}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="dark">
{t("common.darkMode", { defaultValue: "深色" })}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="system">
{t("common.systemMode", { defaultValue: "跟随系统" })}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,77 @@
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import type { Provider } from "@/types";
import type { AppType } from "@/lib/api";
import {
ProviderForm,
type ProviderFormValues,
} from "@/components/providers/forms/ProviderForm";
interface AddProviderDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
appType: AppType;
onSubmit: (provider: Omit<Provider, "id">) => Promise<void> | void;
}
export function AddProviderDialog({
open,
onOpenChange,
appType,
onSubmit,
}: AddProviderDialogProps) {
const { t } = useTranslation();
const handleSubmit = useCallback(
async (values: ProviderFormValues) => {
const parsedConfig = JSON.parse(values.settingsConfig) as Record<
string,
unknown
>;
const providerData: Omit<Provider, "id"> = {
name: values.name.trim(),
websiteUrl: values.websiteUrl?.trim() || undefined,
settingsConfig: parsedConfig,
meta: {},
};
await onSubmit(providerData);
onOpenChange(false);
},
[onSubmit, onOpenChange],
);
const submitLabel =
appType === "claude"
? t("provider.addClaudeProvider", { defaultValue: "添加 Claude 供应商" })
: t("provider.addCodexProvider", { defaultValue: "添加 Codex 供应商" });
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{submitLabel}</DialogTitle>
<DialogDescription>
{t("provider.addDescription", {
defaultValue: "填写信息后即可在列表中快速切换供应商。",
})}
</DialogDescription>
</DialogHeader>
<ProviderForm
submitLabel={t("common.add", { defaultValue: "添加" })}
onSubmit={handleSubmit}
onCancel={() => onOpenChange(false)}
/>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,84 @@
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import type { Provider } from "@/types";
import {
ProviderForm,
type ProviderFormValues,
} from "@/components/providers/forms/ProviderForm";
interface EditProviderDialogProps {
open: boolean;
provider: Provider | null;
onOpenChange: (open: boolean) => void;
onSubmit: (provider: Provider) => Promise<void> | void;
}
export function EditProviderDialog({
open,
provider,
onOpenChange,
onSubmit,
}: EditProviderDialogProps) {
const { t } = useTranslation();
const handleSubmit = useCallback(
async (values: ProviderFormValues) => {
if (!provider) return;
const parsedConfig = JSON.parse(values.settingsConfig) as Record<
string,
unknown
>;
const updatedProvider: Provider = {
...provider,
name: values.name.trim(),
websiteUrl: values.websiteUrl?.trim() || undefined,
settingsConfig: parsedConfig,
};
await onSubmit(updatedProvider);
onOpenChange(false);
},
[onSubmit, onOpenChange, provider],
);
if (!provider) {
return null;
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>
{t("provider.editProvider", { defaultValue: "编辑供应商" })}
</DialogTitle>
<DialogDescription>
{t("provider.editDescription", {
defaultValue: "更新配置后将立即应用到当前供应商。",
})}
</DialogDescription>
</DialogHeader>
<ProviderForm
submitLabel={t("common.save", { defaultValue: "保存" })}
onSubmit={handleSubmit}
onCancel={() => onOpenChange(false)}
initialData={{
name: provider.name,
websiteUrl: provider.websiteUrl,
settingsConfig: provider.settingsConfig,
}}
/>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,75 @@
import { BarChart3, Check, Play, Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface ProviderActionsProps {
isCurrent: boolean;
onSwitch: () => void;
onEdit: () => void;
onConfigureUsage: () => void;
onDelete: () => void;
}
export function ProviderActions({
isCurrent,
onSwitch,
onEdit,
onConfigureUsage,
onDelete,
}: ProviderActionsProps) {
const { t } = useTranslation();
return (
<div className="flex items-center gap-2">
<Button
size="sm"
variant={isCurrent ? "secondary" : "default"}
onClick={onSwitch}
disabled={isCurrent}
className={cn(
"w-[96px]",
isCurrent && "text-muted-foreground hover:text-muted-foreground",
)}
>
{isCurrent ? (
<>
<Check className="h-4 w-4" />
{t("provider.inUse", { defaultValue: "已启用" })}
</>
) : (
<>
<Play className="h-4 w-4" />
{t("provider.enable", { defaultValue: "启用" })}
</>
)}
</Button>
<Button size="sm" variant="outline" onClick={onEdit}>
{t("common.edit", { defaultValue: "编辑" })}
</Button>
<Button
size="sm"
variant="outline"
onClick={onConfigureUsage}
title={t("provider.configureUsage", { defaultValue: "配置用量查询" })}
>
<BarChart3 className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={onDelete}
disabled={isCurrent}
className={cn(
"text-destructive hover:text-destructive",
isCurrent && "text-muted-foreground hover:text-muted-foreground",
)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
);
}

View File

@@ -0,0 +1,155 @@
import { useMemo } from "react";
import { GripVertical, Link } from "lucide-react";
import { useTranslation } from "react-i18next";
import type {
DraggableAttributes,
DraggableSyntheticListeners,
} from "@dnd-kit/core";
import type { Provider } from "@/types";
import type { AppType } from "@/lib/api";
import { cn } from "@/lib/utils";
import { ProviderActions } from "@/components/providers/ProviderActions";
import UsageFooter from "@/components/UsageFooter";
interface DragHandleProps {
attributes: DraggableAttributes;
listeners: DraggableSyntheticListeners;
isDragging: boolean;
}
interface ProviderCardProps {
provider: Provider;
isCurrent: boolean;
appType: AppType;
onSwitch: (provider: Provider) => void;
onEdit: (provider: Provider) => void;
onDelete: (provider: Provider) => void;
onConfigureUsage: (provider: Provider) => void;
onOpenWebsite: (url: string) => void;
dragHandleProps?: DragHandleProps;
}
const extractApiUrl = (provider: Provider, fallbackText: string) => {
if (provider.websiteUrl) {
return provider.websiteUrl;
}
const config = provider.settingsConfig;
if (config && typeof config === "object") {
const envBase = (config as Record<string, any>)?.env?.ANTHROPIC_BASE_URL;
if (typeof envBase === "string" && envBase.trim()) {
return envBase;
}
const baseUrl = (config as Record<string, any>)?.config;
if (typeof baseUrl === "string" && baseUrl.includes("base_url")) {
const match = baseUrl.match(/base_url\s*=\s*['"]([^'"]+)['"]/);
if (match?.[1]) {
return match[1];
}
}
}
return fallbackText;
};
export function ProviderCard({
provider,
isCurrent,
appType,
onSwitch,
onEdit,
onDelete,
onConfigureUsage,
onOpenWebsite,
dragHandleProps,
}: ProviderCardProps) {
const { t } = useTranslation();
const fallbackUrlText = t("provider.notConfigured", {
defaultValue: "未配置接口地址",
});
const displayUrl = useMemo(() => {
return extractApiUrl(provider, fallbackUrlText);
}, [provider, fallbackUrlText]);
const usageEnabled = provider.meta?.usage_script?.enabled ?? false;
const handleOpenWebsite = () => {
if (!displayUrl || displayUrl === fallbackUrlText) {
return;
}
onOpenWebsite(displayUrl);
};
return (
<div
className={cn(
"rounded-lg border bg-card p-4 shadow-sm transition-[box-shadow,transform] duration-200",
isCurrent
? "border-primary/70 bg-primary/5"
: "border-border hover:border-primary/40",
dragHandleProps?.isDragging && "cursor-grabbing border-primary/60 shadow-lg",
)}
>
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex flex-1 items-start gap-3">
<button
type="button"
className={cn(
"mt-1 flex h-8 w-8 items-center justify-center rounded-md border border-transparent text-muted-foreground transition-colors hover:border-muted hover:text-foreground",
dragHandleProps?.isDragging && "border-primary text-primary",
)}
aria-label={t("provider.dragHandle", { defaultValue: "拖拽排序" })}
{...(dragHandleProps?.attributes ?? {})}
{...(dragHandleProps?.listeners ?? {})}
>
<GripVertical className="h-4 w-4" />
</button>
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-base font-semibold leading-none">
{provider.name}
</h3>
{isCurrent && (
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
{t("provider.currentlyUsing", { defaultValue: "当前使用" })}
</span>
)}
</div>
{displayUrl && (
<button
type="button"
onClick={handleOpenWebsite}
className="inline-flex items-center gap-1 text-sm text-primary transition-colors hover:underline"
title={displayUrl}
>
<Link className="h-3.5 w-3.5" />
<span className="truncate">{displayUrl}</span>
</button>
)}
</div>
</div>
<ProviderActions
isCurrent={isCurrent}
onSwitch={() => onSwitch(provider)}
onEdit={() => onEdit(provider)}
onConfigureUsage={() => onConfigureUsage(provider)}
onDelete={() => onDelete(provider)}
/>
</div>
<UsageFooter
providerId={provider.id}
appType={appType}
usageEnabled={usageEnabled}
/>
</div>
);
}

View File

@@ -0,0 +1,32 @@
import { Users } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
interface ProviderEmptyStateProps {
onCreate?: () => void;
}
export function ProviderEmptyState({ onCreate }: ProviderEmptyStateProps) {
const { t } = useTranslation();
return (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-muted-foreground/30 p-10 text-center">
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
<Users className="h-7 w-7 text-muted-foreground" />
</div>
<h3 className="text-lg font-semibold">
{t("provider.noProviders", { defaultValue: "暂无供应商" })}
</h3>
<p className="mt-2 max-w-sm text-sm text-muted-foreground">
{t("provider.noProvidersDescription", {
defaultValue: "开始添加一个供应商以快速完成切换。",
})}
</p>
{onCreate && (
<Button className="mt-6" onClick={onCreate}>
{t("provider.addProvider", { defaultValue: "添加供应商" })}
</Button>
)}
</div>
);
}

View File

@@ -0,0 +1,153 @@
import { CSS } from "@dnd-kit/utilities";
import {
DndContext,
closestCenter,
} from "@dnd-kit/core";
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import type { CSSProperties } from "react";
import type { Provider } from "@/types";
import type { AppType } from "@/lib/api";
import { useDragSort } from "@/hooks/useDragSort";
import { ProviderCard } from "@/components/providers/ProviderCard";
import { ProviderEmptyState } from "@/components/providers/ProviderEmptyState";
interface ProviderListProps {
providers: Record<string, Provider>;
currentProviderId: string;
appType: AppType;
onSwitch: (provider: Provider) => void;
onEdit: (provider: Provider) => void;
onDelete: (provider: Provider) => void;
onConfigureUsage?: (provider: Provider) => void;
onOpenWebsite: (url: string) => void;
onCreate?: () => void;
isLoading?: boolean;
}
export function ProviderList({
providers,
currentProviderId,
appType,
onSwitch,
onEdit,
onDelete,
onConfigureUsage,
onOpenWebsite,
onCreate,
isLoading = false,
}: ProviderListProps) {
const { sortedProviders, sensors, handleDragEnd } = useDragSort(
providers,
appType,
);
if (isLoading) {
return (
<div className="space-y-3">
{[0, 1, 2].map((index) => (
<div
key={index}
className="h-28 w-full rounded-lg border border-dashed border-muted-foreground/40 bg-muted/40"
/>
))}
</div>
);
}
if (sortedProviders.length === 0) {
return <ProviderEmptyState onCreate={onCreate} />;
}
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={sortedProviders.map((provider) => provider.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-3">
{sortedProviders.map((provider) => (
<SortableProviderCard
key={provider.id}
provider={provider}
isCurrent={provider.id === currentProviderId}
appType={appType}
onSwitch={onSwitch}
onEdit={onEdit}
onDelete={onDelete}
onConfigureUsage={onConfigureUsage}
onOpenWebsite={onOpenWebsite}
/>
))}
</div>
</SortableContext>
</DndContext>
);
}
interface SortableProviderCardProps {
provider: Provider;
isCurrent: boolean;
appType: AppType;
onSwitch: (provider: Provider) => void;
onEdit: (provider: Provider) => void;
onDelete: (provider: Provider) => void;
onConfigureUsage?: (provider: Provider) => void;
onOpenWebsite: (url: string) => void;
}
function SortableProviderCard({
provider,
isCurrent,
appType,
onSwitch,
onEdit,
onDelete,
onConfigureUsage,
onOpenWebsite,
}: SortableProviderCardProps) {
const {
setNodeRef,
attributes,
listeners,
transform,
transition,
isDragging,
} = useSortable({ id: provider.id });
const style: CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div ref={setNodeRef} style={style}>
<ProviderCard
provider={provider}
isCurrent={isCurrent}
appType={appType}
onSwitch={onSwitch}
onEdit={onEdit}
onDelete={onDelete}
onConfigureUsage={
onConfigureUsage
? (item) => onConfigureUsage(item)
: () => undefined
}
onOpenWebsite={onOpenWebsite}
dragHandleProps={{
attributes,
listeners,
isDragging,
}}
/>
</div>
);
}

View File

@@ -0,0 +1,166 @@
import { useEffect, useMemo } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { useTheme } from "@/components/theme-provider";
import JsonEditor from "@/components/JsonEditor";
import {
providerSchema,
type ProviderFormData,
} from "@/lib/schemas/provider";
interface ProviderFormProps {
submitLabel: string;
onSubmit: (values: ProviderFormData) => void;
onCancel: () => void;
initialData?: {
name?: string;
websiteUrl?: string;
settingsConfig?: Record<string, unknown>;
};
}
const DEFAULT_CONFIG_PLACEHOLDER = `{
"env": {},
"config": {}
}`;
export function ProviderForm({
submitLabel,
onSubmit,
onCancel,
initialData,
}: ProviderFormProps) {
const { t } = useTranslation();
const { theme } = useTheme();
const defaultValues: ProviderFormData = useMemo(
() => ({
name: initialData?.name ?? "",
websiteUrl: initialData?.websiteUrl ?? "",
settingsConfig: initialData?.settingsConfig
? JSON.stringify(initialData.settingsConfig, null, 2)
: DEFAULT_CONFIG_PLACEHOLDER,
}),
[initialData],
);
const form = useForm<ProviderFormData>({
resolver: zodResolver(providerSchema),
defaultValues,
mode: "onSubmit",
});
useEffect(() => {
form.reset(defaultValues);
}, [defaultValues, form]);
const isDarkMode = useMemo(() => {
if (theme === "dark") return true;
if (theme === "light") return false;
return typeof window !== "undefined"
? window.document.documentElement.classList.contains("dark")
: false;
}, [theme]);
const handleSubmit = (values: ProviderFormData) => {
onSubmit({
...values,
websiteUrl: values.websiteUrl?.trim() ?? "",
settingsConfig: values.settingsConfig.trim(),
});
};
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSubmit)}
className="space-y-6"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("provider.name", { defaultValue: "供应商名称" })}
</FormLabel>
<FormControl>
<Input
{...field}
placeholder={t("provider.namePlaceholder", {
defaultValue: "例如Claude 官方",
})}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="websiteUrl"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("provider.websiteUrl", { defaultValue: "官网链接" })}
</FormLabel>
<FormControl>
<Input
{...field}
placeholder="https://"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="settingsConfig"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("provider.configJson", { defaultValue: "配置 JSON" })}
</FormLabel>
<FormControl>
<div className="rounded-md border">
<JsonEditor
value={field.value}
onChange={field.onChange}
placeholder={DEFAULT_CONFIG_PLACEHOLDER}
darkMode={isDarkMode}
rows={14}
showValidation
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-2">
<Button variant="outline" type="button" onClick={onCancel}>
{t("common.cancel", { defaultValue: "取消" })}
</Button>
<Button type="submit">{submitLabel}</Button>
</div>
</form>
</Form>
);
}
export type ProviderFormValues = ProviderFormData;

View File

@@ -0,0 +1,120 @@
import React, {
createContext,
useContext,
useEffect,
useMemo,
useState,
} from "react";
type Theme = "light" | "dark" | "system";
interface ThemeProviderProps {
children: React.ReactNode;
defaultTheme?: Theme;
storageKey?: string;
}
interface ThemeContextValue {
theme: Theme;
setTheme: (theme: Theme) => void;
}
const ThemeProviderContext = createContext<ThemeContextValue | undefined>(
undefined,
);
export function ThemeProvider({
children,
defaultTheme = "system",
storageKey = "cc-switch-theme",
}: ThemeProviderProps) {
const getInitialTheme = () => {
if (typeof window === "undefined") {
return defaultTheme;
}
const stored = window.localStorage.getItem(storageKey) as Theme | null;
if (stored === "light" || stored === "dark" || stored === "system") {
return stored;
}
return defaultTheme;
};
const [theme, setThemeState] = useState<Theme>(getInitialTheme);
useEffect(() => {
if (typeof window === "undefined") {
return;
}
window.localStorage.setItem(storageKey, theme);
}, [theme, storageKey]);
useEffect(() => {
if (typeof window === "undefined") {
return;
}
const root = window.document.documentElement;
root.classList.remove("light", "dark");
if (theme === "system") {
const isDark =
window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)").matches;
root.classList.add(isDark ? "dark" : "light");
return;
}
root.classList.add(theme);
}, [theme]);
useEffect(() => {
if (typeof window === "undefined") {
return;
}
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = () => {
if (theme !== "system") {
return;
}
const root = window.document.documentElement;
root.classList.toggle("dark", mediaQuery.matches);
root.classList.toggle("light", !mediaQuery.matches);
};
if (theme === "system") {
handleChange();
}
mediaQuery.addEventListener("change", handleChange);
return () => mediaQuery.removeEventListener("change", handleChange);
}, [theme]);
const value = useMemo<ThemeContextValue>(
() => ({
theme,
setTheme: (nextTheme: Theme) => {
setThemeState(nextTheme);
},
}),
[theme],
);
return (
<ThemeProviderContext.Provider value={value}>
{children}
</ThemeProviderContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeProviderContext);
if (context === undefined) {
throw new Error("useTheme must be used within a ThemeProvider");
}
return context;
}

View File

@@ -0,0 +1,203 @@
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { cn } from "@/lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
inset && "pl-8",
className
)}
{...props}
/>
));
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
className
)}
{...props}
/>
));
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 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",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<svg
className="h-4 w-4"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M16.704 5.292a1 1 0 0 1 .083 1.32l-.083.094-8 8a1 1 0 0 1-1.32.083l-.094-.083-4-4a1 1 0 0 1 1.32-1.497l.094.083L8 12.585l7.293-7.292a1 1 0 0 1 1.32-.083l.094.083Z"
fill="currentColor"
/>
</svg>
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<div className="h-2 w-2 rounded-full bg-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName =
DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold text-muted-foreground",
inset && "pl-8",
className
)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
));
DropdownMenuSeparator.displayName =
DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => (
<span
className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
{...props}
/>
);
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
DropdownMenuRadioGroup,
};