mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-06 02:31:13 +02:00
feat: Add comprehensive theme editor with live preview
Implements a powerful theme creator and editor with the following features: - **Live Preview**: Toggle real-time theme preview with Eye/EyeOff button - **Modern Color Pickers**: HSL color pickers for all theme properties - **Organized Sections**: Tabbed interface with Core Colors, Syntax, Scrollbar, and Gradient tabs - **Accordion Groups**: Core colors organized by purpose (Surfaces, Interactive, Forms, Status, Nostr-specific) - **Base Theme Selection**: Start from any built-in theme (Dark, Light, Plan 9) - **Import/Export**: JSON export/import for theme portability - **Metadata Editing**: Edit theme ID, name, description - **Save Functionality**: Save custom themes to localStorage - **Reset**: Reset to base theme with confirmation Technical implementation: - Added `react-colorful` for HSL color picking - Added `@radix-ui/react-select` for dropdown selects - Created `ThemeEditor` component with full color management - Created `select.tsx` UI component following Radix pattern - Added `theme-editor` command to man pages - Added `theme-editor` appId to WindowRenderer - All 838 tests passing, build successful Usage: Run `theme-editor` command to launch the editor
This commit is contained in:
96
package-lock.json
generated
96
package-lock.json
generated
@@ -20,6 +20,7 @@
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
@@ -59,6 +60,7 @@
|
||||
"prismjs": "^1.30.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.1",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-medium-image-zoom": "^5.4.0",
|
||||
@@ -3255,6 +3257,90 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-select": {
|
||||
"version": "2.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz",
|
||||
"integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/number": "1.1.1",
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-collection": "1.1.7",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-direction": "1.1.1",
|
||||
"@radix-ui/react-dismissable-layer": "1.1.11",
|
||||
"@radix-ui/react-focus-guards": "1.1.3",
|
||||
"@radix-ui/react-focus-scope": "1.1.7",
|
||||
"@radix-ui/react-id": "1.1.1",
|
||||
"@radix-ui/react-popper": "1.2.8",
|
||||
"@radix-ui/react-portal": "1.1.9",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-slot": "1.2.3",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1",
|
||||
"@radix-ui/react-use-previous": "1.1.1",
|
||||
"@radix-ui/react-visually-hidden": "1.2.3",
|
||||
"aria-hidden": "^1.2.4",
|
||||
"react-remove-scroll": "^2.6.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slider": {
|
||||
"version": "1.3.6",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz",
|
||||
@@ -10224,6 +10310,16 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-colorful": {
|
||||
"version": "5.6.1",
|
||||
"resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.6.1.tgz",
|
||||
"integrity": "sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dnd": {
|
||||
"version": "16.0.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-16.0.1.tgz",
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
@@ -67,6 +68,7 @@
|
||||
"prismjs": "^1.30.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.1",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-medium-image-zoom": "^5.4.0",
|
||||
|
||||
727
src/components/ThemeEditor.tsx
Normal file
727
src/components/ThemeEditor.tsx
Normal file
@@ -0,0 +1,727 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { HslColorPicker, HslColor } from "react-colorful";
|
||||
import { useTheme } from "@/lib/themes";
|
||||
import type { Theme, HSLValue, RGBValue } from "@/lib/themes/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Download, Upload, RotateCcw, Save, Eye, EyeOff } from "lucide-react";
|
||||
import { applyTheme } from "@/lib/themes/apply";
|
||||
import { builtinThemeList } from "@/lib/themes/builtin";
|
||||
|
||||
/**
|
||||
* Convert HSL string "220 70% 50%" to HslColor object
|
||||
*/
|
||||
function parseHSL(hsl: HSLValue): HslColor {
|
||||
const parts = hsl.split(" ");
|
||||
const h = parseFloat(parts[0] || "0");
|
||||
const s = parseFloat(parts[1]?.replace("%", "") || "0");
|
||||
const l = parseFloat(parts[2]?.replace("%", "") || "0");
|
||||
return { h, s, l };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert HslColor object to HSL string "220 70% 50%"
|
||||
*/
|
||||
function formatHSL(color: HslColor): HSLValue {
|
||||
return `${Math.round(color.h)} ${Math.round(color.s)}% ${Math.round(color.l)}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert RGB string "250 204 21" to object
|
||||
*/
|
||||
function parseRGB(rgb: RGBValue): { r: number; g: number; b: number } {
|
||||
const parts = rgb.split(" ");
|
||||
return {
|
||||
r: parseFloat(parts[0] || "0"),
|
||||
g: parseFloat(parts[1] || "0"),
|
||||
b: parseFloat(parts[2] || "0"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert RGB object to string "250 204 21"
|
||||
*/
|
||||
function formatRGB(color: { r: number; g: number; b: number }): RGBValue {
|
||||
return `${Math.round(color.r)} ${Math.round(color.g)} ${Math.round(color.b)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* HSL Color Picker Component
|
||||
*/
|
||||
interface ColorPickerProps {
|
||||
label: string;
|
||||
value: HSLValue;
|
||||
onChange: (value: HSLValue) => void;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
function ColorPicker({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
description,
|
||||
}: ColorPickerProps) {
|
||||
const [color, setColor] = useState<HslColor>(parseHSL(value));
|
||||
const [inputValue, setInputValue] = useState(value);
|
||||
|
||||
// Update local state when prop changes
|
||||
useEffect(() => {
|
||||
setColor(parseHSL(value));
|
||||
setInputValue(value);
|
||||
}, [value]);
|
||||
|
||||
const handleColorChange = (newColor: HslColor) => {
|
||||
setColor(newColor);
|
||||
const hslString = formatHSL(newColor);
|
||||
setInputValue(hslString);
|
||||
onChange(hslString);
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const val = e.target.value;
|
||||
setInputValue(val);
|
||||
// Try to parse and update if valid
|
||||
try {
|
||||
const parsed = parseHSL(val);
|
||||
setColor(parsed);
|
||||
onChange(val);
|
||||
} catch {
|
||||
// Invalid format, don't update
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">{label}</Label>
|
||||
{description && (
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
)}
|
||||
<div className="flex gap-3 items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<HslColorPicker color={color} onChange={handleColorChange} />
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<Input
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
className="font-mono text-sm"
|
||||
placeholder="220 70% 50%"
|
||||
/>
|
||||
<div
|
||||
className="h-12 rounded border-2 border-border"
|
||||
style={{ background: `hsl(${value})` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* RGB Color Picker Component (for gradients)
|
||||
*/
|
||||
interface RGBPickerProps {
|
||||
label: string;
|
||||
value: RGBValue;
|
||||
onChange: (value: RGBValue) => void;
|
||||
}
|
||||
|
||||
function RGBPicker({ label, value, onChange }: RGBPickerProps) {
|
||||
const rgb = parseRGB(value);
|
||||
|
||||
const handleChange = (channel: "r" | "g" | "b", val: number) => {
|
||||
const newRgb = { ...rgb, [channel]: Math.max(0, Math.min(255, val)) };
|
||||
onChange(formatRGB(newRgb));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">{label}</Label>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<Label className="text-xs text-muted-foreground">R</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="255"
|
||||
value={rgb.r}
|
||||
onChange={(e) => handleChange("r", parseInt(e.target.value))}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Label className="text-xs text-muted-foreground">G</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="255"
|
||||
value={rgb.g}
|
||||
onChange={(e) => handleChange("g", parseInt(e.target.value))}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Label className="text-xs text-muted-foreground">B</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="255"
|
||||
value={rgb.b}
|
||||
onChange={(e) => handleChange("b", parseInt(e.target.value))}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="h-12 rounded border-2 border-border"
|
||||
style={{ background: `rgb(${value})` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Main Theme Editor Component
|
||||
*/
|
||||
export function ThemeEditor() {
|
||||
const { theme: currentTheme, addCustomTheme, availableThemes } = useTheme();
|
||||
const [editingTheme, setEditingTheme] = useState<Theme>(
|
||||
JSON.parse(JSON.stringify(currentTheme)),
|
||||
);
|
||||
const [previewEnabled, setPreviewEnabled] = useState(false);
|
||||
const [baseThemeId, setBaseThemeId] = useState(currentTheme.id);
|
||||
|
||||
// Apply preview in real-time
|
||||
useEffect(() => {
|
||||
if (previewEnabled) {
|
||||
applyTheme(editingTheme);
|
||||
}
|
||||
}, [editingTheme, previewEnabled]);
|
||||
|
||||
// Cleanup: restore original theme when preview is disabled or component unmounts
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (previewEnabled) {
|
||||
applyTheme(currentTheme);
|
||||
}
|
||||
};
|
||||
}, [currentTheme, previewEnabled]);
|
||||
|
||||
const handleBaseThemeChange = (themeId: string) => {
|
||||
setBaseThemeId(themeId);
|
||||
const baseTheme = availableThemes.find((t) => t.id === themeId);
|
||||
if (baseTheme) {
|
||||
setEditingTheme(JSON.parse(JSON.stringify(baseTheme)));
|
||||
}
|
||||
};
|
||||
|
||||
const handleColorChange = (path: string, value: HSLValue | RGBValue) => {
|
||||
setEditingTheme((prev) => {
|
||||
const updated = JSON.parse(JSON.stringify(prev));
|
||||
const keys = path.split(".");
|
||||
let obj: any = updated;
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
obj = obj[keys[i]];
|
||||
}
|
||||
obj[keys[keys.length - 1]] = value;
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
const handleMetadataChange = (
|
||||
field: "id" | "name" | "description" | "author" | "version",
|
||||
value: string,
|
||||
) => {
|
||||
setEditingTheme((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
addCustomTheme(editingTheme);
|
||||
alert(`Theme "${editingTheme.name}" saved successfully!`);
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
const json = JSON.stringify(editingTheme, null, 2);
|
||||
const blob = new Blob([json], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${editingTheme.id}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleImport = () => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = ".json";
|
||||
input.onchange = (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const imported = JSON.parse(e.target?.result as string);
|
||||
setEditingTheme(imported);
|
||||
alert("Theme imported successfully!");
|
||||
} catch {
|
||||
alert("Failed to import theme. Invalid JSON.");
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (confirm("Reset to base theme? Unsaved changes will be lost.")) {
|
||||
const baseTheme = availableThemes.find((t) => t.id === baseThemeId);
|
||||
if (baseTheme) {
|
||||
setEditingTheme(JSON.parse(JSON.stringify(baseTheme)));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-background">
|
||||
{/* Header */}
|
||||
<div className="border-b border-border px-4 py-3 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Theme Editor</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant={previewEnabled ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setPreviewEnabled(!previewEnabled)}
|
||||
>
|
||||
{previewEnabled ? (
|
||||
<>
|
||||
<Eye className="w-4 h-4 mr-2" />
|
||||
Live Preview On
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<EyeOff className="w-4 h-4 mr-2" />
|
||||
Live Preview Off
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label className="text-xs">Theme ID</Label>
|
||||
<Input
|
||||
value={editingTheme.id}
|
||||
onChange={(e) => handleMetadataChange("id", e.target.value)}
|
||||
className="h-8 text-sm"
|
||||
placeholder="my-theme"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Theme Name</Label>
|
||||
<Input
|
||||
value={editingTheme.name}
|
||||
onChange={(e) => handleMetadataChange("name", e.target.value)}
|
||||
className="h-8 text-sm"
|
||||
placeholder="My Theme"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={baseThemeId} onValueChange={handleBaseThemeChange}>
|
||||
<SelectTrigger className="h-8 text-sm flex-1">
|
||||
<SelectValue placeholder="Base theme" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{builtinThemeList.map((theme) => (
|
||||
<SelectItem key={theme.id} value={theme.id}>
|
||||
{theme.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" size="sm" onClick={handleReset}>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
Reset
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleImport}>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
Import
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleExport}>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Export
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave}>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Editor Tabs */}
|
||||
<ScrollArea className="flex-1">
|
||||
<Tabs defaultValue="core" className="p-4">
|
||||
<TabsList className="grid grid-cols-4 w-full">
|
||||
<TabsTrigger value="core">Core Colors</TabsTrigger>
|
||||
<TabsTrigger value="syntax">Syntax</TabsTrigger>
|
||||
<TabsTrigger value="scrollbar">Scrollbar</TabsTrigger>
|
||||
<TabsTrigger value="gradient">Gradient</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Core Colors */}
|
||||
<TabsContent value="core" className="space-y-4 mt-4">
|
||||
<Accordion type="multiple" className="w-full">
|
||||
<AccordionItem value="surfaces">
|
||||
<AccordionTrigger>Surfaces</AccordionTrigger>
|
||||
<AccordionContent className="space-y-6 pt-4">
|
||||
<ColorPicker
|
||||
label="Background"
|
||||
value={editingTheme.colors.background}
|
||||
onChange={(v) => handleColorChange("colors.background", v)}
|
||||
description="Main app background"
|
||||
/>
|
||||
<ColorPicker
|
||||
label="Foreground"
|
||||
value={editingTheme.colors.foreground}
|
||||
onChange={(v) => handleColorChange("colors.foreground", v)}
|
||||
description="Main text color"
|
||||
/>
|
||||
<ColorPicker
|
||||
label="Card"
|
||||
value={editingTheme.colors.card}
|
||||
onChange={(v) => handleColorChange("colors.card", v)}
|
||||
description="Card/panel background"
|
||||
/>
|
||||
<ColorPicker
|
||||
label="Card Foreground"
|
||||
value={editingTheme.colors.cardForeground}
|
||||
onChange={(v) =>
|
||||
handleColorChange("colors.cardForeground", v)
|
||||
}
|
||||
description="Text on cards"
|
||||
/>
|
||||
<ColorPicker
|
||||
label="Popover"
|
||||
value={editingTheme.colors.popover}
|
||||
onChange={(v) => handleColorChange("colors.popover", v)}
|
||||
description="Popover/dropdown background"
|
||||
/>
|
||||
<ColorPicker
|
||||
label="Popover Foreground"
|
||||
value={editingTheme.colors.popoverForeground}
|
||||
onChange={(v) =>
|
||||
handleColorChange("colors.popoverForeground", v)
|
||||
}
|
||||
description="Text in popovers"
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="interactive">
|
||||
<AccordionTrigger>Interactive Elements</AccordionTrigger>
|
||||
<AccordionContent className="space-y-6 pt-4">
|
||||
<ColorPicker
|
||||
label="Primary"
|
||||
value={editingTheme.colors.primary}
|
||||
onChange={(v) => handleColorChange("colors.primary", v)}
|
||||
description="Primary buttons and interactive elements"
|
||||
/>
|
||||
<ColorPicker
|
||||
label="Primary Foreground"
|
||||
value={editingTheme.colors.primaryForeground}
|
||||
onChange={(v) =>
|
||||
handleColorChange("colors.primaryForeground", v)
|
||||
}
|
||||
description="Text on primary elements"
|
||||
/>
|
||||
<ColorPicker
|
||||
label="Secondary"
|
||||
value={editingTheme.colors.secondary}
|
||||
onChange={(v) => handleColorChange("colors.secondary", v)}
|
||||
description="Secondary buttons"
|
||||
/>
|
||||
<ColorPicker
|
||||
label="Secondary Foreground"
|
||||
value={editingTheme.colors.secondaryForeground}
|
||||
onChange={(v) =>
|
||||
handleColorChange("colors.secondaryForeground", v)
|
||||
}
|
||||
description="Text on secondary elements"
|
||||
/>
|
||||
<ColorPicker
|
||||
label="Accent"
|
||||
value={editingTheme.colors.accent}
|
||||
onChange={(v) => handleColorChange("colors.accent", v)}
|
||||
description="Accent/highlight color"
|
||||
/>
|
||||
<ColorPicker
|
||||
label="Accent Foreground"
|
||||
value={editingTheme.colors.accentForeground}
|
||||
onChange={(v) =>
|
||||
handleColorChange("colors.accentForeground", v)
|
||||
}
|
||||
description="Text on accent elements"
|
||||
/>
|
||||
<ColorPicker
|
||||
label="Muted"
|
||||
value={editingTheme.colors.muted}
|
||||
onChange={(v) => handleColorChange("colors.muted", v)}
|
||||
description="Muted/subdued elements"
|
||||
/>
|
||||
<ColorPicker
|
||||
label="Muted Foreground"
|
||||
value={editingTheme.colors.mutedForeground}
|
||||
onChange={(v) =>
|
||||
handleColorChange("colors.mutedForeground", v)
|
||||
}
|
||||
description="Text on muted elements"
|
||||
/>
|
||||
<ColorPicker
|
||||
label="Destructive"
|
||||
value={editingTheme.colors.destructive}
|
||||
onChange={(v) => handleColorChange("colors.destructive", v)}
|
||||
description="Destructive/error actions"
|
||||
/>
|
||||
<ColorPicker
|
||||
label="Destructive Foreground"
|
||||
value={editingTheme.colors.destructiveForeground}
|
||||
onChange={(v) =>
|
||||
handleColorChange("colors.destructiveForeground", v)
|
||||
}
|
||||
description="Text on destructive elements"
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="forms">
|
||||
<AccordionTrigger>Form Elements</AccordionTrigger>
|
||||
<AccordionContent className="space-y-6 pt-4">
|
||||
<ColorPicker
|
||||
label="Border"
|
||||
value={editingTheme.colors.border}
|
||||
onChange={(v) => handleColorChange("colors.border", v)}
|
||||
description="Border color"
|
||||
/>
|
||||
<ColorPicker
|
||||
label="Input"
|
||||
value={editingTheme.colors.input}
|
||||
onChange={(v) => handleColorChange("colors.input", v)}
|
||||
description="Input background"
|
||||
/>
|
||||
<ColorPicker
|
||||
label="Ring"
|
||||
value={editingTheme.colors.ring}
|
||||
onChange={(v) => handleColorChange("colors.ring", v)}
|
||||
description="Focus ring color"
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="status">
|
||||
<AccordionTrigger>Status & Feedback</AccordionTrigger>
|
||||
<AccordionContent className="space-y-6 pt-4">
|
||||
<ColorPicker
|
||||
label="Success"
|
||||
value={editingTheme.colors.success}
|
||||
onChange={(v) => handleColorChange("colors.success", v)}
|
||||
description="Success states"
|
||||
/>
|
||||
<ColorPicker
|
||||
label="Warning"
|
||||
value={editingTheme.colors.warning}
|
||||
onChange={(v) => handleColorChange("colors.warning", v)}
|
||||
description="Warning states"
|
||||
/>
|
||||
<ColorPicker
|
||||
label="Info"
|
||||
value={editingTheme.colors.info}
|
||||
onChange={(v) => handleColorChange("colors.info", v)}
|
||||
description="Info states"
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="nostr">
|
||||
<AccordionTrigger>Nostr-Specific</AccordionTrigger>
|
||||
<AccordionContent className="space-y-6 pt-4">
|
||||
<ColorPicker
|
||||
label="Zap"
|
||||
value={editingTheme.colors.zap}
|
||||
onChange={(v) => handleColorChange("colors.zap", v)}
|
||||
description="Lightning zap color"
|
||||
/>
|
||||
<ColorPicker
|
||||
label="Live"
|
||||
value={editingTheme.colors.live}
|
||||
onChange={(v) => handleColorChange("colors.live", v)}
|
||||
description="Live streaming indicator"
|
||||
/>
|
||||
<ColorPicker
|
||||
label="Highlight"
|
||||
value={editingTheme.colors.highlight}
|
||||
onChange={(v) => handleColorChange("colors.highlight", v)}
|
||||
description="User highlight color"
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</TabsContent>
|
||||
|
||||
{/* Syntax Highlighting */}
|
||||
<TabsContent value="syntax" className="space-y-6 mt-4">
|
||||
<div className="space-y-6">
|
||||
<ColorPicker
|
||||
label="Comment"
|
||||
value={editingTheme.syntax.comment}
|
||||
onChange={(v) => handleColorChange("syntax.comment", v)}
|
||||
/>
|
||||
<ColorPicker
|
||||
label="Punctuation"
|
||||
value={editingTheme.syntax.punctuation}
|
||||
onChange={(v) => handleColorChange("syntax.punctuation", v)}
|
||||
/>
|
||||
<ColorPicker
|
||||
label="Property"
|
||||
value={editingTheme.syntax.property}
|
||||
onChange={(v) => handleColorChange("syntax.property", v)}
|
||||
/>
|
||||
<ColorPicker
|
||||
label="String"
|
||||
value={editingTheme.syntax.string}
|
||||
onChange={(v) => handleColorChange("syntax.string", v)}
|
||||
/>
|
||||
<ColorPicker
|
||||
label="Keyword"
|
||||
value={editingTheme.syntax.keyword}
|
||||
onChange={(v) => handleColorChange("syntax.keyword", v)}
|
||||
/>
|
||||
<ColorPicker
|
||||
label="Function"
|
||||
value={editingTheme.syntax.function}
|
||||
onChange={(v) => handleColorChange("syntax.function", v)}
|
||||
/>
|
||||
<ColorPicker
|
||||
label="Variable"
|
||||
value={editingTheme.syntax.variable}
|
||||
onChange={(v) => handleColorChange("syntax.variable", v)}
|
||||
/>
|
||||
<ColorPicker
|
||||
label="Operator"
|
||||
value={editingTheme.syntax.operator}
|
||||
onChange={(v) => handleColorChange("syntax.operator", v)}
|
||||
/>
|
||||
<ColorPicker
|
||||
label="Diff Inserted"
|
||||
value={editingTheme.syntax.diffInserted}
|
||||
onChange={(v) => handleColorChange("syntax.diffInserted", v)}
|
||||
/>
|
||||
<ColorPicker
|
||||
label="Diff Inserted Background"
|
||||
value={editingTheme.syntax.diffInsertedBg}
|
||||
onChange={(v) => handleColorChange("syntax.diffInsertedBg", v)}
|
||||
/>
|
||||
<ColorPicker
|
||||
label="Diff Deleted"
|
||||
value={editingTheme.syntax.diffDeleted}
|
||||
onChange={(v) => handleColorChange("syntax.diffDeleted", v)}
|
||||
/>
|
||||
<ColorPicker
|
||||
label="Diff Deleted Background"
|
||||
value={editingTheme.syntax.diffDeletedBg}
|
||||
onChange={(v) => handleColorChange("syntax.diffDeletedBg", v)}
|
||||
/>
|
||||
<ColorPicker
|
||||
label="Diff Meta"
|
||||
value={editingTheme.syntax.diffMeta}
|
||||
onChange={(v) => handleColorChange("syntax.diffMeta", v)}
|
||||
/>
|
||||
<ColorPicker
|
||||
label="Diff Meta Background"
|
||||
value={editingTheme.syntax.diffMetaBg}
|
||||
onChange={(v) => handleColorChange("syntax.diffMetaBg", v)}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Scrollbar */}
|
||||
<TabsContent value="scrollbar" className="space-y-6 mt-4">
|
||||
<ColorPicker
|
||||
label="Thumb"
|
||||
value={editingTheme.scrollbar.thumb}
|
||||
onChange={(v) => handleColorChange("scrollbar.thumb", v)}
|
||||
description="Scrollbar thumb color"
|
||||
/>
|
||||
<ColorPicker
|
||||
label="Thumb Hover"
|
||||
value={editingTheme.scrollbar.thumbHover}
|
||||
onChange={(v) => handleColorChange("scrollbar.thumbHover", v)}
|
||||
description="Scrollbar thumb hover color"
|
||||
/>
|
||||
<ColorPicker
|
||||
label="Track"
|
||||
value={editingTheme.scrollbar.track}
|
||||
onChange={(v) => handleColorChange("scrollbar.track", v)}
|
||||
description="Scrollbar track color"
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* Gradient */}
|
||||
<TabsContent value="gradient" className="space-y-6 mt-4">
|
||||
<RGBPicker
|
||||
label="Color 1 (Top - Yellow)"
|
||||
value={editingTheme.gradient.color1}
|
||||
onChange={(v) => handleColorChange("gradient.color1", v)}
|
||||
/>
|
||||
<RGBPicker
|
||||
label="Color 2 (Upper-Middle - Orange)"
|
||||
value={editingTheme.gradient.color2}
|
||||
onChange={(v) => handleColorChange("gradient.color2", v)}
|
||||
/>
|
||||
<RGBPicker
|
||||
label="Color 3 (Lower-Middle - Purple)"
|
||||
value={editingTheme.gradient.color3}
|
||||
onChange={(v) => handleColorChange("gradient.color3", v)}
|
||||
/>
|
||||
<RGBPicker
|
||||
label="Color 4 (Bottom - Cyan)"
|
||||
value={editingTheme.gradient.color4}
|
||||
onChange={(v) => handleColorChange("gradient.color4", v)}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<Label>Preview</Label>
|
||||
<div
|
||||
className="h-32 rounded border-2 border-border"
|
||||
style={{
|
||||
background: `linear-gradient(to bottom, rgb(${editingTheme.gradient.color1}), rgb(${editingTheme.gradient.color2}), rgb(${editingTheme.gradient.color3}), rgb(${editingTheme.gradient.color4}))`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -42,6 +42,9 @@ const SpellbooksViewer = lazy(() =>
|
||||
const BlossomViewer = lazy(() =>
|
||||
import("./BlossomViewer").then((m) => ({ default: m.BlossomViewer })),
|
||||
);
|
||||
const ThemeEditor = lazy(() =>
|
||||
import("./ThemeEditor").then((m) => ({ default: m.ThemeEditor })),
|
||||
);
|
||||
|
||||
// Loading fallback component
|
||||
function ViewerLoading() {
|
||||
@@ -210,6 +213,9 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) {
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case "theme-editor":
|
||||
content = <ThemeEditor />;
|
||||
break;
|
||||
default:
|
||||
content = (
|
||||
<div className="p-4 text-muted-foreground">
|
||||
|
||||
158
src/components/ui/select.tsx
Normal file
158
src/components/ui/select.tsx
Normal file
@@ -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 "@/lib/utils";
|
||||
|
||||
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-10 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="h-4 w-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="h-4 w-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="h-4 w-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 h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-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,
|
||||
};
|
||||
@@ -20,7 +20,8 @@ export type AppId =
|
||||
| "spells"
|
||||
| "spellbooks"
|
||||
| "blossom"
|
||||
| "win";
|
||||
| "win"
|
||||
| "theme-editor";
|
||||
|
||||
export interface WindowInstance {
|
||||
id: string;
|
||||
|
||||
@@ -575,4 +575,16 @@ export const manPages: Record<string, ManPageEntry> = {
|
||||
},
|
||||
defaultProps: { subcommand: "servers" },
|
||||
},
|
||||
"theme-editor": {
|
||||
name: "theme-editor",
|
||||
section: "1",
|
||||
synopsis: "theme-editor",
|
||||
description:
|
||||
"Open the theme editor to create and customize color themes. Features live preview, modern color pickers for all theme colors, and the ability to save, export, and import custom themes. Start from any built-in theme as a base.",
|
||||
examples: ["theme-editor Open the theme editor with current theme"],
|
||||
seeAlso: ["help"],
|
||||
appId: "theme-editor",
|
||||
category: "System",
|
||||
defaultProps: {},
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user