refactor: unify docker deployment and restructure frontend architecture

This commit implements a unified Docker deployment strategy, moving from separate frontend and backend images to a single, multi-stage build image containing both services. It also introduces a major reorganization of the frontend directory structure and simplifies the environment configuration.

Key changes:
- **Deployment**: Added a multi-stage `Dockerfile` and `docker-entrypoint.sh` to package the Go backend and Nginx-served frontend into a single container.
- **CI/CD**: Updated GitHub Actions workflows (`ci-cd.yml`, `release.yml`) to build and push the new unified image instead of separate ones.
- **Frontend Refactor**: Reorganized `frontend/src/pages` into a domain-driven directory structure (e.g., `auth/`, `admin/`, `content/`, `communication/`, `productivity/`, `settings/`, `misc/`).
- **Configuration**: Simplified `.env.example` and updated `docker-compose.yml` to reflect the unified service model and single host port.
- **Cleanup**: Removed deprecated `docker-compose.demo.yml`, `docker-compose.prod.yml`, and various unused frontend components and services.
- **Backend**: Refactored configuration loading to use exported `GetDurationEnv` for better consistency.
This commit is contained in:
Tomas Dvorak
2026-05-10 10:48:41 +02:00
parent c6a99c7e21
commit 6c448b336a
71 changed files with 135367 additions and 4481 deletions
@@ -0,0 +1,669 @@
import { createSignal, createEffect, Show, For } from 'solid-js';
import { Card } from '../components/ui/Card';
import { Button } from '../components/ui/Button';
import { Input } from '../components/ui/Input';
import { toast } from '../components/ui/Toast';
import { CheckCircle, AlertCircle, Shield, Key, Globe, Clock, Users, Settings } from 'lucide-solid';
import { getApiV1BaseUrl } from '@/lib/api-url';
interface APIKey {
id: number;
name: string;
permissions: string[];
is_active: boolean;
last_used?: string;
expires_at?: string;
created_at: string;
updated_at: string;
}
interface BrowserExtension {
id: number;
extension_id: string;
name: string;
is_active: boolean;
last_seen?: string;
created_at: string;
updated_at: string;
}
interface QuickStartGuide {
title: string;
description: string;
icon: any;
steps: string[];
}
interface Example {
title: string;
description: string;
code: string;
language: string;
}
const BrowserExtensionSettings = () => {
const apiBaseUrl = getApiV1BaseUrl();
const [apiKeys, setApiKeys] = createSignal<APIKey[]>([]);
const [extensions, setExtensions] = createSignal<BrowserExtension[]>([]);
const [loading, setLoading] = createSignal(false);
const [showCreateKey, setShowCreateKey] = createSignal(false);
const [newKeyName, setNewKeyName] = createSignal('');
const [newKeyPermissions, setNewKeyPermissions] = createSignal<string[]>([
'bookmarks:read',
'bookmarks:write',
'files:read',
'files:write'
]);
const [activeTab, setActiveTab] = createSignal<'overview' | 'api-keys' | 'extensions' | 'examples'>('api-keys');
const quickStartGuides: QuickStartGuide[] = [
{
title: 'Generate API Key',
description: 'Create a secure API key for your browser extension',
icon: <Key class="w-5 h-5 text-blue-600" />,
steps: [
'Go to Settings → Browser Extension',
'Click "Generate New Key"',
'Choose permissions and name',
'Copy the generated key'
]
},
{
title: 'Configure Extension',
description: 'Set up your browser extension with the API key',
icon: <Settings class="w-5 h-5 text-green-600" />,
steps: [
'Install the Trackeep browser extension',
'Open extension options',
'Paste your API key',
'Test connection',
'Start saving bookmarks!'
]
},
{
title: 'Security Best Practices',
description: 'Keep your API keys secure and monitor usage',
icon: <Shield class="w-5 h-5 text-purple-600" />,
steps: [
'Use unique names for each key',
'Set expiration dates for temporary access',
'Revoke unused keys immediately',
'Monitor key usage regularly',
'Never share keys publicly'
]
}
];
const codeExamples: Example[] = [
{
title: 'JavaScript Extension',
description: 'Basic API key validation and bookmark creation',
code: `// Validate API key
const response = await fetch('/api/v1/browser-extension/validate', {
headers: {
'Authorization': 'Bearer tk_your_api_key_here'
}
});
// Create bookmark
const bookmarkData = {
title: 'My Awesome Bookmark',
url: 'https://example.com',
description: 'A useful website',
tags: ['development', 'tools']
};
const createResponse = await fetch('/api/v1/bookmarks', {
method: 'POST',
headers: {
'Authorization': 'Bearer tk_your_api_key_here',
'Content-Type': 'application/json'
},
body: JSON.stringify(bookmarkData)
});`,
language: 'javascript'
},
{
title: 'Python Integration',
description: 'Server-side API integration example',
code: `import requests
# Validate API key
response = requests.get(
'https://your-trackeep.com/api/v1/browser-extension/validate',
headers={'Authorization': 'Bearer tk_your_api_key_here'}
)
# Create bookmark
bookmark_data = {
'title': 'My Awesome Bookmark',
'url': 'https://example.com',
'description': 'A useful website',
'tags': ['development', 'tools']
}
response = requests.post(
'https://your-trackeep.com/api/v1/bookmarks',
json=bookmark_data,
headers={
'Authorization': 'Bearer tk_your_api_key_here',
'Content-Type': 'application/json'
}
)`,
language: 'python'
},
{
title: 'cURL Command',
description: 'Command line testing for API endpoints',
code: `# Validate API key
curl -X GET \\\n -H "Authorization: Bearer tk_your_api_key_here" \\\n https://your-trackeep.com/api/v1/browser-extension/validate
# Create bookmark
curl -X POST \\\n -H "Authorization: Bearer tk_your_api_key_here" \\\n -H "Content-Type: application/json" \\\n -d '{"title":"My Bookmark","url":"https://example.com"}' \\\n https://your-trackeep.com/api/v1/bookmarks`,
language: 'bash'
}
];
const availablePermissions = [
{ id: 'bookmarks:read', label: 'Read Bookmarks', description: 'View and read your bookmarks' },
{ id: 'bookmarks:write', label: 'Write Bookmarks', description: 'Create, edit, and delete bookmarks' },
{ id: 'files:read', label: 'Read Files', description: 'View and download your files' },
{ id: 'files:write', label: 'Write Files', description: 'Upload, edit, and delete files' },
{ id: 'notes:read', label: 'Read Notes', description: 'View your notes' },
{ id: 'notes:write', label: 'Write Notes', description: 'Create, edit, and delete notes' },
{ id: 'tasks:read', label: 'Read Tasks', description: 'View your tasks' },
{ id: 'tasks:write', label: 'Write Tasks', description: 'Create, edit, and delete tasks' }
];
const loadApiKeys = async () => {
try {
const response = await fetch(`${apiBaseUrl}/browser-extension/api-keys`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
if (response.ok) {
const data = await response.json();
setApiKeys(data);
} else {
toast.error('Failed to load API keys');
}
} catch (error) {
toast.error('Error loading API keys');
}
};
const loadExtensions = async () => {
try {
const response = await fetch(`${apiBaseUrl}/browser-extension/extensions`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
if (response.ok) {
const data = await response.json();
setExtensions(data);
} else {
toast.error('Failed to load extensions');
}
} catch (error) {
toast.error('Error loading extensions');
}
};
const createAPIKey = async () => {
if (!newKeyName().trim()) {
toast.error('Please enter a name for the API key');
return;
}
if (newKeyPermissions().length === 0) {
toast.error('Please select at least one permission');
return;
}
setLoading(true);
try {
const response = await fetch(`${apiBaseUrl}/browser-extension/api-keys/generate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({
name: newKeyName(),
permissions: newKeyPermissions(),
expires_in: 365 // 1 year
})
});
if (response.ok) {
const data = await response.json();
toast.success(`API key "${data.name}" created successfully!`);
setNewKeyName('');
setNewKeyPermissions(['bookmarks:read', 'bookmarks:write', 'files:read', 'files:write']);
setShowCreateKey(false);
await loadApiKeys();
} else {
const error = await response.json();
toast.error(error.error || 'Failed to create API key');
}
} catch (error) {
toast.error('Error creating API key');
} finally {
setLoading(false);
}
};
const revokeAPIKey = async (keyId: number) => {
if (!confirm('Are you sure you want to revoke this API key? This action cannot be undone.')) {
return;
}
try {
const response = await fetch(`${apiBaseUrl}/browser-extension/api-keys/${keyId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
if (response.ok) {
toast.success('API key revoked successfully');
await loadApiKeys();
} else {
const error = await response.json();
toast.error(error.error || 'Failed to revoke API key');
}
} catch (error) {
toast.error('Error revoking API key');
}
};
const revokeExtension = async (extensionId: string) => {
if (!confirm('Are you sure you want to revoke this extension? This action cannot be undone.')) {
return;
}
try {
const response = await fetch(`${apiBaseUrl}/browser-extension/extensions/${extensionId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
if (response.ok) {
toast.success('Extension revoked successfully');
await loadExtensions();
} else {
const error = await response.json();
toast.error(error.error || 'Failed to revoke extension');
}
} catch (error) {
toast.error('Error revoking extension');
}
};
const togglePermission = (permissionId: string) => {
const current = newKeyPermissions();
if (current.includes(permissionId)) {
setNewKeyPermissions(current.filter(p => p !== permissionId));
} else {
setNewKeyPermissions([...current, permissionId]);
}
};
createEffect(() => {
loadApiKeys();
loadExtensions();
}, []);
return (
<div class="p-6">
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900 mb-2">Browser Extension Settings</h1>
<p class="text-gray-600">Manage API keys and browser extensions for secure access to your Trackeep account.</p>
</div>
{/* Tab Navigation */}
<div class="border-b border-gray-200 mb-6">
<nav class="flex space-x-8">
<button
class={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab() === 'overview'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
onClick={() => setActiveTab('overview')}
>
<Globe class="w-4 h-4 mr-2" />
Overview
</button>
<button
class={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab() === 'api-keys'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
onClick={() => setActiveTab('api-keys')}
>
<Key class="w-4 h-4 mr-2" />
API Keys
</button>
<button
class={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab() === 'extensions'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
onClick={() => setActiveTab('extensions')}
>
<Users class="w-4 h-4 mr-2" />
Extensions
</button>
<button
class={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab() === 'examples'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
onClick={() => setActiveTab('examples')}
>
<Shield class="w-4 h-4 mr-2" />
Examples
</button>
</nav>
</div>
{/* Overview Tab */}
<Show when={activeTab() === 'overview'}>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<For each={quickStartGuides}>
{guide => (
<Card class="p-6">
<div class="text-center mb-4">
<div class="inline-flex items-center justify-center w-12 h-12 bg-blue-100 rounded-full mb-3">
{guide.icon}
</div>
<h3 class="text-lg font-semibold text-gray-900">{guide.title}</h3>
<p class="text-gray-600 text-sm mb-4">{guide.description}</p>
</div>
<div class="space-y-2">
<For each={guide.steps}>
{step => (
<div class="flex items-center space-x-2">
<CheckCircle class="w-4 h-4 text-green-500 flex-shrink-0" />
<span class="text-sm text-gray-700">{step}</span>
</div>
)}
</For>
</div>
</Card>
)}
</For>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card class="p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4 flex items-center">
<AlertCircle class="w-5 h-5 text-orange-500 mr-2" />
Security Status
</h3>
<div class="space-y-3">
<div class="flex items-center space-x-2">
<div class="w-3 h-3 bg-green-500 rounded-full"></div>
<div>
<div class="font-medium text-green-700">API Keys Secure</div>
<div class="text-sm text-gray-600">All keys using secure API key authentication</div>
</div>
</div>
<div class="flex items-center space-x-2">
<div class="w-3 h-3 bg-green-500 rounded-full"></div>
<div>
<div class="font-medium text-green-700">Extensions Registered</div>
<div class="text-sm text-gray-600">{extensions().length} active extensions</div>
</div>
</div>
<div class="flex items-center space-x-2">
<div class="w-3 h-3 bg-yellow-500 rounded-full"></div>
<div>
<div class="font-medium text-yellow-700">Quick Setup Available</div>
<div class="text-sm text-gray-600">Get started in under 5 minutes</div>
</div>
</div>
</div>
</Card>
<Card class="p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4 flex items-center">
<Clock class="w-5 h-5 text-blue-500 mr-2" />
Recent Activity
</h3>
<div class="space-y-2">
<div class="text-sm text-gray-600">
<div class="font-medium text-gray-900">Last API key created:</div>
<div>{apiKeys().length > 0 ? new Date(apiKeys()[0].created_at).toLocaleDateString() : 'No keys created yet'}</div>
</div>
<div class="text-sm text-gray-600">
<div class="font-medium text-gray-900">Total API keys:</div>
<div>{apiKeys().length} active, {apiKeys().filter(k => !k.is_active).length} revoked</div>
</div>
<div class="text-sm text-gray-600">
<div class="font-medium text-gray-900">Extensions active:</div>
<div>{extensions().length} connected</div>
</div>
</div>
</Card>
</div>
</Show>
{/* API Keys Section */}
<Show when={activeTab() === 'api-keys'}>
<Card class="mb-6">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-semibold">API Keys</h2>
<Button onClick={() => setShowCreateKey(true)} class="btn-primary">
Generate New Key
</Button>
</div>
<Show when={showCreateKey()}>
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4">
<h3 class="text-lg font-medium mb-4">Create New API Key</h3>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">Key Name</label>
<Input
value={newKeyName()}
onInput={(e) => setNewKeyName((e.target as HTMLInputElement).value)}
placeholder="e.g., Chrome Extension, Laptop Backup"
class="w-full"
/>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">Permissions</label>
<div class="space-y-2">
<For each={availablePermissions}>
{permission => (
<label class="flex items-center space-x-2 cursor-pointer">
<input
type="checkbox"
checked={newKeyPermissions().includes(permission.id)}
onChange={() => togglePermission(permission.id)}
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<div>
<span class="font-medium">{permission.label}</span>
<span class="text-sm text-gray-500">{permission.description}</span>
</div>
</label>
)}
</For>
</div>
</div>
<div class="flex space-x-2">
<Button
onClick={() => setShowCreateKey(false)}
class="btn-secondary"
>
Cancel
</Button>
<Button
onClick={createAPIKey}
disabled={loading()}
class="btn-primary"
>
{loading() ? 'Creating...' : 'Create Key'}
</Button>
</div>
</div>
</Show>
<div class="space-y-3">
<For each={apiKeys()}>
{key => (
<div class="bg-white border border-gray-200 rounded-lg p-4">
<div class="flex justify-between items-start">
<div class="flex-1">
<h3 class="text-lg font-medium">{key.name}</h3>
<div class="text-sm text-gray-500 mb-2">
Created: {new Date(key.created_at).toLocaleDateString()}
</div>
<div class="flex flex-wrap gap-1">
<For each={key.permissions}>
{permission => (
<span class="inline-block bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded">
{permission}
</span>
)}
</For>
</div>
{key.expires_at && (
<div class="text-sm text-orange-600">
Expires: {new Date(key.expires_at).toLocaleDateString()}
</div>
)}
</div>
<div class="flex space-x-2">
<span class={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
key.is_active ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}>
{key.is_active ? 'Active' : 'Revoked'}
</span>
<Button
onClick={() => revokeAPIKey(key.id)}
class="btn-secondary btn-sm"
disabled={!key.is_active}
>
Revoke
</Button>
</div>
</div>
</div>
)}
</For>
</div>
</Card>
</Show>
{/* Browser Extensions Section */}
<Card>
<div class="mb-4">
<h2 class="text-xl font-semibold">Registered Extensions</h2>
<p class="text-sm text-gray-600">Manage browser extensions that have access to your account.</p>
</div>
<div class="space-y-3">
<For each={extensions()}>
{extension => (
<div class="bg-white border border-gray-200 rounded-lg p-4">
<div class="flex justify-between items-start">
<div class="flex-1">
<h3 class="text-lg font-medium">{extension.name}</h3>
<div class="text-sm text-gray-500 mb-2">
Extension ID: {extension.extension_id}
</div>
<div class="text-sm text-gray-500 mb-2">
Registered: {new Date(extension.created_at).toLocaleDateString()}
</div>
{extension.last_seen && (
<div class="text-sm text-gray-500">
Last seen: {new Date(extension.last_seen).toLocaleDateString()}
</div>
)}
</div>
<div class="flex space-x-2">
<span class={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
extension.is_active ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}>
{extension.is_active ? 'Active' : 'Revoked'}
</span>
<Button
onClick={() => revokeExtension(extension.extension_id)}
class="btn-secondary btn-sm"
disabled={!extension.is_active}
>
Revoke
</Button>
</div>
</div>
</div>
)}
</For>
</div>
</Card>
{/* Examples Tab */}
<Show when={activeTab() === 'examples'}>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<For each={codeExamples}>
{example => (
<Card class="p-6">
<div class="mb-4">
<h3 class="text-lg font-semibold text-gray-900 mb-2 flex items-center">
<div class="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center mr-3">
<span class="text-blue-600 font-mono text-xs font-bold">{example.language.toUpperCase()}</span>
</div>
{example.title}
</h3>
<p class="text-gray-600 text-sm mb-4">{example.description}</p>
</div>
<div class="bg-gray-900 rounded-lg p-4 overflow-x-auto">
<pre class="text-green-400 text-sm">
<code>{example.code}</code>
</pre>
</div>
<div class="flex justify-between items-center mt-4">
<Button
onClick={() => {
navigator.clipboard.writeText(example.code);
toast.success('Code copied to clipboard!');
}}
class="btn-secondary"
>
Copy Code
</Button>
<Button
onClick={() => window.open(`https://your-trackeep.com/api/v1/browser-extension/validate`, '_blank')}
class="btn-primary"
>
Test API
</Button>
</div>
</Card>
)}
</For>
</div>
</Show>
</div>
);
};
export default BrowserExtensionSettings;
@@ -0,0 +1,660 @@
import { createSignal, onMount, Show } from 'solid-js';
import { IconPalette, IconCheck, IconRepeat, IconSun, IconMoon, IconDownload, IconUpload, IconEye, IconEyeOff } from '@tabler/icons-solidjs';
import { ColorPicker } from '@/components/ui/ColorPicker';
interface ColorScheme {
name: string;
primary: string;
background: string;
foreground: string;
muted: string;
border: string;
}
export const ColorSwitcher = () => {
const [schemes, setSchemes] = createSignal<ColorScheme[]>([]);
const [currentScheme, setCurrentScheme] = createSignal('default');
const [isDarkMode, setIsDarkMode] = createSignal(false);
// Initialize custom colors with proper defaults
const getInitialCustomColors = () => {
const currentTheme = document.documentElement.getAttribute('data-kb-theme');
const darkMode = currentTheme === 'dark';
return {
primary: '#5ab9ff',
background: darkMode ? '#1a1a1a' : '#ffffff',
foreground: darkMode ? '#ffffff' : '#000000',
muted: darkMode ? '#262727' : '#f5f5f5',
border: '#262626'
};
};
const [customColors, setCustomColors] = createSignal(getInitialCustomColors());
const [showAdvanced, setShowAdvanced] = createSignal(false);
const [savedSchemes, setSavedSchemes] = createSignal<ColorScheme[]>([]);
const [showPreview, setShowPreview] = createSignal(true);
onMount(() => {
// Check current theme
const currentTheme = document.documentElement.getAttribute('data-kb-theme');
setIsDarkMode(currentTheme === 'dark');
// Load saved color scheme from localStorage
const savedScheme = localStorage.getItem('colorScheme');
const savedColors = localStorage.getItem('customColors');
if (savedColors && savedScheme === 'custom') {
try {
const colors = JSON.parse(savedColors);
setCustomColors(colors);
applyCustomColors();
} catch (e) {
console.error('Failed to load custom colors:', e);
}
} else if (savedScheme) {
setCurrentScheme(savedScheme);
}
// Predefined color schemes with more options
setSchemes([
{
name: 'default',
primary: '#5ab9ff',
background: isDarkMode() ? '#1a1a1a' : '#ffffff',
foreground: isDarkMode() ? '#ffffff' : '#000000',
muted: isDarkMode() ? '#262727' : '#f5f5f5',
border: '#262626'
},
{
name: 'ocean',
primary: '#0077be',
background: isDarkMode() ? '#001f3f' : '#e6f3ff',
foreground: isDarkMode() ? '#ffffff' : '#000000',
muted: isDarkMode() ? '#003366' : '#cce7ff',
border: '#004080'
},
{
name: 'forest',
primary: '#228b22',
background: isDarkMode() ? '#0d2818' : '#f0f8f0',
foreground: isDarkMode() ? '#ffffff' : '#000000',
muted: isDarkMode() ? '#1a431a' : '#d4edd4',
border: '#2d5a2d'
},
{
name: 'sunset',
primary: '#ff6b35',
background: isDarkMode() ? '#2c1810' : '#fff5f0',
foreground: isDarkMode() ? '#ffffff' : '#000000',
muted: isDarkMode() ? '#5c2e00' : '#ffe4d6',
border: '#8b4513'
},
{
name: 'purple',
primary: '#8b5cf6',
background: isDarkMode() ? '#1a0033' : '#f8f5ff',
foreground: isDarkMode() ? '#ffffff' : '#000000',
muted: isDarkMode() ? '#330066' : '#ede9fe',
border: '#4d0099'
},
{
name: 'rose',
primary: '#f43f5e',
background: isDarkMode() ? '#2d1111' : '#fff1f2',
foreground: isDarkMode() ? '#ffffff' : '#000000',
muted: isDarkMode() ? '#5a1a1a' : '#ffe4e6',
border: '#881337'
},
{
name: 'amber',
primary: '#f59e0b',
background: isDarkMode() ? '#2d1a00' : '#fffbeb',
foreground: isDarkMode() ? '#ffffff' : '#000000',
muted: isDarkMode() ? '#5c4a00' : '#fef3c7',
border: '#78350f'
},
{
name: 'emerald',
primary: '#10b981',
background: isDarkMode() ? '#022c22' : '#ecfdf5',
foreground: isDarkMode() ? '#ffffff' : '#000000',
muted: isDarkMode() ? '#064e3b' : '#d1fae5',
border: '#047857'
},
{
name: 'cyan',
primary: '#06b6d4',
background: isDarkMode() ? '#022c3a' : '#ecfeff',
foreground: isDarkMode() ? '#ffffff' : '#000000',
muted: isDarkMode() ? '#164e63' : '#cffafe',
border: '#0891b2'
},
{
name: 'indigo',
primary: '#6366f1',
background: isDarkMode() ? '#1e1b4b' : '#eef2ff',
foreground: isDarkMode() ? '#ffffff' : '#000000',
muted: isDarkMode() ? '#312e81' : '#e0e7ff',
border: '#4338ca'
}
]);
});
const toggleDarkMode = () => {
const newDarkMode = !isDarkMode();
setIsDarkMode(newDarkMode);
if (newDarkMode) {
document.documentElement.setAttribute('data-kb-theme', 'dark');
localStorage.setItem('theme', 'dark');
} else {
document.documentElement.removeAttribute('data-kb-theme');
localStorage.setItem('theme', 'light');
}
// Update custom colors for new theme
setCustomColors(prev => ({
...prev,
background: newDarkMode ? '#1a1a1a' : '#ffffff',
foreground: newDarkMode ? '#ffffff' : '#000000',
muted: newDarkMode ? '#262727' : '#f5f5f5'
}));
// Update schemes with new theme
updateSchemesForTheme(newDarkMode);
};
const updateSchemesForTheme = (dark: boolean) => {
setSchemes([
{
name: 'default',
primary: '#5ab9ff',
background: dark ? '#1a1a1a' : '#ffffff',
foreground: dark ? '#ffffff' : '#000000',
muted: dark ? '#262727' : '#f5f5f5',
border: '#262626'
},
{
name: 'ocean',
primary: '#0077be',
background: dark ? '#001f3f' : '#e6f3ff',
foreground: dark ? '#ffffff' : '#000000',
muted: dark ? '#003366' : '#cce7ff',
border: '#004080'
},
{
name: 'forest',
primary: '#228b22',
background: dark ? '#0d2818' : '#f0f8f0',
foreground: dark ? '#ffffff' : '#000000',
muted: dark ? '#1a431a' : '#d4edd4',
border: '#2d5a2d'
},
{
name: 'sunset',
primary: '#ff6b35',
background: dark ? '#2c1810' : '#fff5f0',
foreground: dark ? '#ffffff' : '#000000',
muted: dark ? '#5c2e00' : '#ffe4d6',
border: '#8b4513'
},
{
name: 'purple',
primary: '#8b5cf6',
background: dark ? '#1a0033' : '#f8f5ff',
foreground: dark ? '#ffffff' : '#000000',
muted: dark ? '#330066' : '#ede9fe',
border: '#4d0099'
}
]);
};
const applyScheme = (scheme: ColorScheme) => {
setCurrentScheme(scheme.name);
setCustomColors(scheme);
// Save to localStorage for persistence
localStorage.setItem('colorScheme', scheme.name);
localStorage.removeItem('customColors'); // Clear custom colors when applying preset
// Apply colors to CSS variables with proper HSL conversion
const root = document.documentElement;
// Convert hex to HSL for CSS variables
const hexToHsl = (hex: string) => {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
if (!result) return '0 0% 100%';
let r = parseInt(result[1], 16) / 255;
let g = parseInt(result[2], 16) / 255;
let b = parseInt(result[3], 16) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
let h = 0, s = 0, l = (max + min) / 2;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
case g: h = ((b - r) / d + 2) / 6; break;
case b: h = ((r - g) / d + 4) / 6; break;
}
}
return `${Math.round(h * 360)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`;
};
// Apply the colors
root.style.setProperty('--primary', hexToHsl(scheme.primary));
root.style.setProperty('--background', hexToHsl(scheme.background));
root.style.setProperty('--foreground', hexToHsl(scheme.foreground));
root.style.setProperty('--muted', hexToHsl(scheme.muted));
root.style.setProperty('--border', scheme.border);
// Also set as CSS custom properties for direct use
root.style.setProperty('--colors-primary', hexToHsl(scheme.primary));
root.style.setProperty('--colors-background', hexToHsl(scheme.background));
root.style.setProperty('--colors-foreground', hexToHsl(scheme.foreground));
root.style.setProperty('--colors-muted', hexToHsl(scheme.muted));
root.style.setProperty('--colors-border', scheme.border);
};
const applyCustomColors = () => {
const root = document.documentElement;
const colors = customColors();
// Save custom colors to localStorage
localStorage.setItem('colorScheme', 'custom');
localStorage.setItem('customColors', JSON.stringify(colors));
// Convert hex to HSL for CSS variables
const hexToHsl = (hex: string) => {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
if (!result) return '0 0% 100%';
let r = parseInt(result[1], 16) / 255;
let g = parseInt(result[2], 16) / 255;
let b = parseInt(result[3], 16) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
let h = 0, s = 0, l = (max + min) / 2;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
case g: h = ((b - r) / d + 2) / 6; break;
case b: h = ((r - g) / d + 4) / 6; break;
}
}
return `${Math.round(h * 360)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`;
};
// Apply the colors
root.style.setProperty('--primary', hexToHsl(colors.primary));
root.style.setProperty('--background', hexToHsl(colors.background));
root.style.setProperty('--foreground', hexToHsl(colors.foreground));
root.style.setProperty('--muted', hexToHsl(colors.muted));
root.style.setProperty('--border', colors.border);
// Also set as CSS custom properties for direct use
root.style.setProperty('--colors-primary', hexToHsl(colors.primary));
root.style.setProperty('--colors-background', hexToHsl(colors.background));
root.style.setProperty('--colors-foreground', hexToHsl(colors.foreground));
root.style.setProperty('--colors-muted', hexToHsl(colors.muted));
root.style.setProperty('--colors-border', colors.border);
setCurrentScheme('custom');
};
const resetColors = () => {
const defaultScheme = schemes().find(s => s.name === 'default');
if (defaultScheme) {
applyScheme(defaultScheme);
}
};
// Advanced functions
const exportColorScheme = () => {
const scheme = currentScheme() === 'custom' ? { ...customColors(), name: 'custom' } : schemes().find(s => s.name === currentScheme());
if (scheme) {
const data = JSON.stringify(scheme, null, 2);
const blob = new Blob([data], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${scheme.name}-color-scheme.json`;
a.click();
URL.revokeObjectURL(url);
}
};
const importColorScheme = (event: Event) => {
const file = (event.target as HTMLInputElement).files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
try {
const scheme = JSON.parse(e.target?.result as string) as ColorScheme;
if (scheme.name && scheme.primary && scheme.background && scheme.foreground && scheme.muted && scheme.border) {
setCustomColors(scheme);
applyCustomColors();
} else {
alert('Invalid color scheme format');
}
} catch (error) {
alert('Failed to import color scheme');
}
};
reader.readAsText(file);
}
};
const saveCustomScheme = () => {
const schemeName = prompt('Enter a name for your custom scheme:');
if (schemeName && customColors()) {
const newScheme: ColorScheme = {
name: schemeName,
...customColors()
};
const updatedSchemes = [...savedSchemes(), newScheme];
setSavedSchemes(updatedSchemes);
localStorage.setItem('savedSchemes', JSON.stringify(updatedSchemes));
}
};
return (
<div class="p-6 mt-4 pb-32 max-w-5xl mx-auto">
<h1 class="text-3xl font-bold text-foreground mb-6 flex items-center gap-2">
<IconPalette class="size-8" />
Color Switcher
</h1>
<div class="space-y-6">
{/* Dark Mode Toggle */}
<div class="border rounded-lg p-6">
<h2 class="text-xl font-semibold text-foreground mb-4">Theme Mode</h2>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
{isDarkMode() ? (
<IconMoon class="size-6 text-primary" />
) : (
<IconSun class="size-6 text-primary" />
)}
<div>
<h3 class="font-medium text-foreground">
{isDarkMode() ? 'Dark Mode' : 'Light Mode'}
</h3>
<p class="text-sm text-muted-foreground">
Toggle between dark and light theme
</p>
</div>
</div>
<button
onClick={toggleDarkMode}
class="inline-flex items-center justify-center rounded-md text-sm font-medium transition-shadow focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground shadow hover:bg-primary/90 h-auto items-center gap-2 py-2 px-4"
>
{isDarkMode() ? <IconSun class="size-4 text-primary-foreground" /> : <IconMoon class="size-4 text-primary-foreground" />}
Switch to {isDarkMode() ? 'Light' : 'Dark'}
</button>
</div>
</div>
{/* Predefined Schemes */}
<div class="border rounded-lg p-6">
<h2 class="text-xl font-semibold text-foreground mb-4">Color Schemes</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{schemes().map((scheme) => (
<div
class={`border rounded-lg p-4 cursor-pointer transition-all hover:shadow-md ${
currentScheme() === scheme.name ? 'ring-2 ring-primary' : ''
}`}
onClick={() => applyScheme(scheme)}
>
<div class="flex items-center justify-between mb-3">
<h3 class="font-medium text-foreground capitalize">{scheme.name}</h3>
{currentScheme() === scheme.name && (
<IconCheck class="size-5 text-primary" />
)}
</div>
<div class="flex gap-1 mb-3">
<div
class="w-8 h-8 rounded border"
style={`background-color: ${scheme.primary}`}
title="Primary"
/>
<div
class="w-8 h-8 rounded border"
style={`background-color: ${scheme.background}`}
title="Background"
/>
<div
class="w-8 h-8 rounded border"
style={`background-color: ${scheme.muted}`}
title="Muted"
/>
<div
class="w-8 h-8 rounded border"
style={`background-color: ${scheme.border}`}
title="Border"
/>
</div>
<div class="text-xs text-muted-foreground">
Click to apply this scheme
</div>
</div>
))}
</div>
</div>
{/* Custom Colors */}
<div class="border rounded-lg p-6">
<h2 class="text-xl font-semibold text-foreground mb-4">Custom Colors</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-muted-foreground mb-2">
Primary Color
</label>
<ColorPicker
value={customColors().primary}
onChange={(color) => setCustomColors(prev => ({ ...prev, primary: color }))}
savedColors={['#5ab9ff', '#ff6b6b', '#4ecdc4', '#45b7d1', '#f9ca24', '#f0932b', '#eb4d4b', '#6ab04c']}
/>
</div>
<div>
<label class="block text-sm font-medium text-muted-foreground mb-2">
Background Color
</label>
<div class="flex gap-2">
<input
type="color"
value={customColors().background}
onInput={(e) => setCustomColors(prev => ({ ...prev, background: e.currentTarget.value }))}
class="h-10 w-16 rounded border border-input"
/>
<input
type="text"
value={customColors().background}
onInput={(e) => setCustomColors(prev => ({ ...prev, background: e.currentTarget.value }))}
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring"
/>
</div>
</div>
<div>
<label class="block text-sm font-medium text-muted-foreground mb-2">
Foreground Color
</label>
<div class="flex gap-2">
<input
type="color"
value={customColors().foreground}
onInput={(e) => setCustomColors(prev => ({ ...prev, foreground: e.currentTarget.value }))}
class="h-10 w-16 rounded border border-input"
/>
<input
type="text"
value={customColors().foreground}
onInput={(e) => setCustomColors(prev => ({ ...prev, foreground: e.currentTarget.value }))}
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring"
/>
</div>
</div>
<div>
<label class="block text-sm font-medium text-muted-foreground mb-2">
Muted Color
</label>
<div class="flex gap-2">
<input
type="color"
value={customColors().muted}
onInput={(e) => setCustomColors(prev => ({ ...prev, muted: e.currentTarget.value }))}
class="h-10 w-16 rounded border border-input"
/>
<input
type="text"
value={customColors().muted}
onInput={(e) => setCustomColors(prev => ({ ...prev, muted: e.currentTarget.value }))}
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring"
/>
</div>
</div>
<div>
<label class="block text-sm font-medium text-muted-foreground mb-2">
Border Color
</label>
<div class="flex gap-2">
<input
type="color"
value={customColors().border}
onInput={(e) => setCustomColors(prev => ({ ...prev, border: e.currentTarget.value }))}
class="h-10 w-16 rounded border border-input"
/>
<input
type="text"
value={customColors().border}
onInput={(e) => setCustomColors(prev => ({ ...prev, border: e.currentTarget.value }))}
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring"
/>
</div>
</div>
</div>
<div class="flex gap-4 mt-6">
<button
type="button"
onClick={applyCustomColors}
class="inline-flex justify-center rounded-md text-sm font-medium transition-shadow focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground shadow hover:bg-primary/90 h-auto items-center gap-2 py-2 px-4"
>
<IconPalette class="size-4 text-primary-foreground" />
Apply Custom Colors
</button>
<button
type="button"
onClick={resetColors}
class="inline-flex justify-center rounded-md text-sm font-medium transition-shadow focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-inherit hover:bg-accent/50 hover:text-accent-foreground h-auto items-center gap-2 py-2 px-4 border"
>
<IconRepeat class="size-4 text-foreground" />
Reset to Default
</button>
</div>
</div>
{/* Advanced Options */}
<div class="border rounded-lg p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold text-foreground">Advanced Options</h2>
<button
type="button"
onClick={() => setShowAdvanced(!showAdvanced())}
class="inline-flex justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-inherit hover:bg-accent/50 hover:text-accent-foreground h-auto items-center gap-2 py-1 px-3"
>
{showAdvanced() ? 'Hide Advanced' : 'Show Advanced'}
</button>
</div>
<Show when={showAdvanced()}>
<div class="space-y-4">
{/* Export/Import */}
<div class="flex gap-4">
<button
type="button"
onClick={exportColorScheme}
class="inline-flex justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground shadow hover:bg-primary/90 h-auto items-center gap-2 py-2 px-4"
>
<IconDownload class="size-4 text-primary-foreground" />
Export Scheme
</button>
<label class="inline-flex justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-inherit hover:bg-accent/50 hover:text-accent-foreground h-auto items-center gap-2 py-2 px-4 border cursor-pointer">
<IconUpload class="size-4 text-foreground" />
Import Scheme
<input
type="file"
accept=".json"
onChange={importColorScheme}
class="hidden"
/>
</label>
<button
type="button"
onClick={saveCustomScheme}
class="inline-flex justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-inherit hover:bg-accent/50 hover:text-accent-foreground h-auto items-center gap-2 py-2 px-4 border"
>
Save Custom Scheme
</button>
</div>
{/* Preview Toggle */}
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-foreground">Show Preview Panel</span>
<button
type="button"
onClick={() => setShowPreview(!showPreview())}
class="inline-flex justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-inherit hover:bg-accent/50 hover:text-accent-foreground h-auto items-center gap-2 py-1 px-3"
>
{showPreview() ? <IconEye class="size-4 text-foreground" /> : <IconEyeOff class="size-4 text-foreground" />}
{showPreview() ? 'Hide' : 'Show'}
</button>
</div>
</div>
</Show>
</div>
{/* Preview */}
<Show when={showPreview()}>
<div class="border rounded-lg p-6">
<h2 class="text-xl font-semibold text-foreground mb-4">Preview</h2>
<div class="space-y-4">
<div class="p-4 bg-muted rounded-lg">
<h3 class="font-medium text-foreground mb-2">Sample Content</h3>
<p class="text-muted-foreground mb-3">
This is how your content will look with the selected colors.
</p>
<button class="inline-flex justify-center rounded-md text-sm font-medium transition-shadow focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground shadow hover:bg-primary/90 h-auto items-center gap-2 py-2 px-4">
Sample Button
</button>
</div>
<div class="border rounded-lg p-4">
<h3 class="font-medium text-foreground mb-2">Border Example</h3>
<p class="text-muted-foreground">
This shows how borders will appear with your color scheme.
</p>
</div>
</div>
</div>
</Show>
</div>
</div>
);
};
File diff suppressed because it is too large Load Diff