mirror of
https://github.com/Dvorinka/Trackeep.git
synced 2026-06-04 12:32:58 +00:00
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:
@@ -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
Reference in New Issue
Block a user