mirror of
https://github.com/Dvorinka/Trackeep.git
synced 2026-06-05 04:52:58 +00:00
uppdate
This commit is contained in:
@@ -3,24 +3,19 @@ import { Card } from '@/components/ui/Card';
|
||||
import { IconBrain, IconFileText, IconChecklist, IconSparkles, IconRobot, IconSettings } from '@tabler/icons-solidjs';
|
||||
import { AIProviderIcon } from '@/components/AIProviderIcon';
|
||||
|
||||
interface AIProvider {
|
||||
interface AIModel {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
models: {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
}[];
|
||||
provider: string;
|
||||
category: string;
|
||||
iconId?: string;
|
||||
}
|
||||
|
||||
export const AIAssistant = () => {
|
||||
const [activeTab, setActiveTab] = createSignal<'dashboard' | 'summarizer' | 'tasks' | 'content' | 'settings'>('dashboard');
|
||||
const [selectedProvider, setSelectedProvider] = createSignal<string>('');
|
||||
const [selectedModel, setSelectedModel] = createSignal<string>('standard');
|
||||
const [enabledProviders, setEnabledProviders] = createSignal<string[]>([]);
|
||||
const [providers, setProviders] = createSignal<AIProvider[]>([]);
|
||||
const [selectedModel, setSelectedModel] = createSignal<string>('longcat-flash-chat');
|
||||
const [aiModels, setAIModels] = createSignal<AIModel[]>([]);
|
||||
|
||||
const tabs = [
|
||||
{ id: 'dashboard', label: 'AI Dashboard', icon: IconBrain },
|
||||
@@ -30,44 +25,21 @@ export const AIAssistant = () => {
|
||||
{ id: 'settings', label: 'AI Settings', icon: IconSettings },
|
||||
];
|
||||
|
||||
// Fetch available providers on mount
|
||||
onMount(async () => {
|
||||
try {
|
||||
const response = await fetch(`${import.meta.env.VITE_API_URL}/v1/ai/providers`);
|
||||
const data = await response.json();
|
||||
setProviders(data.providers || []);
|
||||
|
||||
// Enable all providers by default
|
||||
const providerIds = (data.providers || []).map((p: AIProvider) => p.id);
|
||||
setEnabledProviders(providerIds);
|
||||
|
||||
// Set default provider if available
|
||||
if (data.providers && data.providers.length > 0) {
|
||||
setSelectedProvider(data.providers[0].id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch AI providers:', error);
|
||||
}
|
||||
// Initialize AI models on mount
|
||||
onMount(() => {
|
||||
const models: AIModel[] = [
|
||||
{ id: 'longcat-flash-chat', name: 'LongCat Flash Chat', description: 'Fast and efficient chat model', provider: 'longcat', category: 'fast', iconId: 'longcat' },
|
||||
{ id: 'longcat-flash-thinking', name: 'LongCat Flash Thinking', description: 'Advanced reasoning model', provider: 'longcat', category: 'thinking', iconId: 'longcat' },
|
||||
{ id: 'mistral-small-latest', name: 'Mistral Small', description: 'Lightweight and fast', provider: 'mistral', category: 'standard', iconId: 'mistral' },
|
||||
{ id: 'mistral-large-latest', name: 'Mistral Large', description: 'Most capable model', provider: 'mistral', category: 'advanced', iconId: 'mistral' },
|
||||
{ id: 'grok-standard', name: 'Grok Standard', description: 'Grok from X', provider: 'grok', category: 'standard', iconId: 'grok' },
|
||||
{ id: 'deepseek-chat', name: 'DeepSeek Chat', description: 'DeepSeek chat model', provider: 'deepseek', category: 'standard', iconId: 'deepseek' },
|
||||
{ id: 'ollama-local', name: 'Ollama Local', description: 'Local Ollama model', provider: 'ollama', category: 'local', iconId: 'ollama' },
|
||||
{ id: 'openrouter-auto', name: 'OpenRouter Auto', description: 'Router over many models', provider: 'openrouter', category: 'standard', iconId: 'openrouter' },
|
||||
];
|
||||
setAIModels(models);
|
||||
});
|
||||
|
||||
const toggleProvider = (providerId: string) => {
|
||||
const enabled = enabledProviders();
|
||||
if (enabled.includes(providerId)) {
|
||||
// Remove provider if it's currently selected, select another
|
||||
if (selectedProvider() === providerId) {
|
||||
const remaining = enabled.filter(p => p !== providerId);
|
||||
setSelectedProvider(remaining.length > 0 ? remaining[0] : '');
|
||||
}
|
||||
setEnabledProviders(enabled.filter(p => p !== providerId));
|
||||
} else {
|
||||
setEnabledProviders([...enabled, providerId]);
|
||||
// If this is the first provider, select it
|
||||
if (enabled.length === 0) {
|
||||
setSelectedProvider(providerId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="space-y-6">
|
||||
{/* Header */}
|
||||
@@ -81,30 +53,20 @@ export const AIAssistant = () => {
|
||||
Leverage AI to enhance your productivity and content management
|
||||
</p>
|
||||
</div>
|
||||
{enabledProviders().length > 0 && (
|
||||
{aiModels().length > 0 && (
|
||||
<div class="flex items-center gap-3 text-sm">
|
||||
<span class="text-gray-500">Active:</span>
|
||||
<div class="flex items-center gap-2">
|
||||
{enabledProviders().map(providerId => {
|
||||
const provider = providers().find(p => p.id === providerId);
|
||||
return (
|
||||
<div class="flex items-center gap-1 px-2 py-1 bg-blue-50 dark:bg-blue-900/20 rounded-md">
|
||||
<AIProviderIcon
|
||||
providerId={providerId}
|
||||
size="1.25rem"
|
||||
class="text-primary"
|
||||
/>
|
||||
<span class="font-medium text-blue-600 dark:text-blue-400">
|
||||
{provider?.name || providerId}
|
||||
</span>
|
||||
{selectedModel() !== 'standard' && selectedProvider() === providerId && (
|
||||
<span class="text-xs text-blue-500">
|
||||
{provider?.models.find(m => m.id === selectedModel())?.name?.split('-')[0]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div class="flex items-center gap-1 px-2 py-1 bg-blue-50 dark:bg-blue-900/20 rounded-md">
|
||||
<AIProviderIcon
|
||||
providerId={aiModels().find(m => m.id === selectedModel())?.iconId || 'longcat'}
|
||||
size="1.25rem"
|
||||
class="text-primary"
|
||||
/>
|
||||
<span class="font-medium text-blue-600 dark:text-blue-400">
|
||||
{aiModels().find(m => m.id === selectedModel())?.name?.split(' ')[0] || 'AI'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -135,116 +97,83 @@ export const AIAssistant = () => {
|
||||
<Card class="p-6">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">AI Provider Settings</h3>
|
||||
<div class="space-y-6">
|
||||
{/* Provider Toggles */}
|
||||
{/* AI Models */}
|
||||
<div>
|
||||
<h4 class="text-md font-medium text-gray-800 dark:text-gray-200 mb-3">Available Providers</h4>
|
||||
<h4 class="text-md font-medium text-gray-800 dark:text-gray-200 mb-3">Available AI Models</h4>
|
||||
<div class="space-y-3">
|
||||
{providers().map((provider) => {
|
||||
const isEnabled = enabledProviders().includes(provider.id);
|
||||
return (
|
||||
<div
|
||||
class={`p-4 border rounded-lg transition-all ${
|
||||
isEnabled
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'border-gray-200 dark:border-gray-700'
|
||||
}`}
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<AIProviderIcon
|
||||
providerId={provider.id}
|
||||
size="2rem"
|
||||
class="text-primary"
|
||||
/>
|
||||
<div>
|
||||
<h5 class="font-medium text-gray-900 dark:text-white">{provider.name}</h5>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{provider.description}</p>
|
||||
{aiModels().map((model) => (
|
||||
<div
|
||||
class={`p-4 border rounded-lg transition-all ${
|
||||
selectedModel() === model.id
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'border-gray-200 dark:border-gray-700'
|
||||
}`}
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<AIProviderIcon
|
||||
providerId={model.iconId!}
|
||||
size="2rem"
|
||||
class="text-primary"
|
||||
/>
|
||||
<div>
|
||||
<h5 class="font-medium text-gray-900 dark:text-white">{model.name}</h5>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{model.description}</p>
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<span class="text-xs px-2 py-1 bg-blue-100 text-blue-800 rounded-full">
|
||||
{model.provider}
|
||||
</span>
|
||||
<span class={`text-xs px-2 py-1 rounded-full ${
|
||||
model.category === 'thinking'
|
||||
? 'bg-purple-100 text-purple-800'
|
||||
: model.category === 'fast'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: model.category === 'advanced'
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{model.category}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => toggleProvider(provider.id)}
|
||||
class={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
isEnabled
|
||||
? 'bg-blue-600'
|
||||
: 'bg-gray-200 dark:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
class={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
isEnabled ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Model selection for enabled providers */}
|
||||
{isEnabled && (
|
||||
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Model:
|
||||
</label>
|
||||
<select
|
||||
value={selectedProvider() === provider.id ? selectedModel() : 'standard'}
|
||||
onChange={(e) => {
|
||||
setSelectedProvider(provider.id);
|
||||
setSelectedModel(e.target.value);
|
||||
}}
|
||||
class="text-sm px-2 py-1 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600"
|
||||
>
|
||||
{provider.models.map((model) => (
|
||||
<option value={model.id}>
|
||||
{model.type} - {model.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Model badges */}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{provider.models.map((model) => (
|
||||
<div
|
||||
class={`px-2 py-1 text-xs rounded-full border ${
|
||||
model.id.includes('thinking') || model.id.includes('reasoner')
|
||||
? 'bg-purple-100 text-purple-800 border-purple-300 dark:bg-purple-900 dark:text-purple-200'
|
||||
: 'bg-gray-100 text-gray-800 border-gray-300 dark:bg-gray-700 dark:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
{model.type}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setSelectedModel(model.id)}
|
||||
class={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
selectedModel() === model.id
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{selectedModel() === model.id ? 'Selected' : 'Select'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current Selection */}
|
||||
{enabledProviders().length > 0 && (
|
||||
<div>
|
||||
<h4 class="text-md font-medium text-gray-800 dark:text-gray-200 mb-3">Current Selection</h4>
|
||||
<div class="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<AIProviderIcon
|
||||
providerId={selectedProvider()}
|
||||
size="1.5rem"
|
||||
class="text-primary"
|
||||
/>
|
||||
<div>
|
||||
<p class="font-medium text-gray-900 dark:text-white">
|
||||
{providers().find(p => p.id === selectedProvider())?.name}
|
||||
</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{providers().find(p => p.id === selectedProvider())?.models.find(m => m.id === selectedModel())?.name}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-md font-medium text-gray-800 dark:text-gray-200 mb-3">Current Selection</h4>
|
||||
<div class="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<AIProviderIcon
|
||||
providerId={aiModels().find(m => m.id === selectedModel())?.iconId || 'longcat'}
|
||||
size="1.5rem"
|
||||
class="text-primary"
|
||||
/>
|
||||
<div>
|
||||
<p class="font-medium text-gray-900 dark:text-white">
|
||||
{aiModels().find(m => m.id === selectedModel())?.name}
|
||||
</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{aiModels().find(m => m.id === selectedModel())?.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
+279
-419
@@ -1,4 +1,4 @@
|
||||
import { createSignal, For, Show, onMount } from 'solid-js'
|
||||
import { createSignal, For, Show, onMount, createEffect } from 'solid-js'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Card } from '@/components/ui/Card'
|
||||
@@ -6,20 +6,27 @@ import {
|
||||
MessageCircle,
|
||||
Brain,
|
||||
Cog,
|
||||
Send
|
||||
Send,
|
||||
ChevronDown,
|
||||
User,
|
||||
Bot
|
||||
} from 'lucide-solid'
|
||||
import { AIProviderIcon } from '@/components/AIProviderIcon'
|
||||
|
||||
interface AIProvider {
|
||||
interface AIModel {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
models: {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
}[];
|
||||
provider: string
|
||||
category: string
|
||||
iconId?: string
|
||||
}
|
||||
|
||||
interface Message {
|
||||
id: string
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
timestamp: Date
|
||||
}
|
||||
|
||||
export const AIChat = () => {
|
||||
@@ -27,65 +34,23 @@ export const AIChat = () => {
|
||||
const [isSidebarOpen, setIsSidebarOpen] = createSignal(true)
|
||||
|
||||
// Chat state
|
||||
const [messages, setMessages] = createSignal<any[]>([
|
||||
const [messages, setMessages] = createSignal<Message[]>([
|
||||
{
|
||||
id: 1,
|
||||
content: 'Hello! I\'m your AI assistant. How can I help you today?',
|
||||
id: '1',
|
||||
role: 'assistant',
|
||||
created_at: new Date().toISOString()
|
||||
content: 'Hello! I\'m your AI assistant. How can I help you today?',
|
||||
timestamp: new Date()
|
||||
}
|
||||
])
|
||||
const [inputMessage, setInputMessage] = createSignal('')
|
||||
const [isLoading, setIsLoading] = createSignal(false)
|
||||
|
||||
// AI Provider state
|
||||
const [selectedProvider, setSelectedProvider] = createSignal<string>('')
|
||||
const [selectedModel, setSelectedModel] = createSignal<string>('standard')
|
||||
const [enabledProviders, setEnabledProviders] = createSignal<string[]>([])
|
||||
const [providers, setProviders] = createSignal<AIProvider[]>([])
|
||||
// AI Model state
|
||||
const [selectedModel, setSelectedModel] = createSignal<string>('longcat-flash-chat')
|
||||
const [showModelPicker, setShowModelPicker] = createSignal(false)
|
||||
const [aiModels, setAIModels] = createSignal<AIModel[]>([])
|
||||
|
||||
// Per-user AI settings (mirrors /api/v1/auth/ai/settings)
|
||||
const [aiSettings, setAISettings] = createSignal({
|
||||
mistral: { enabled: false, api_key: '', model: '', model_thinking: '' },
|
||||
grok: { enabled: false, api_key: '', base_url: '', model: '', model_thinking: '' },
|
||||
deepseek: { enabled: false, api_key: '', base_url: '', model: '', model_thinking: '' },
|
||||
ollama: { enabled: false, base_url: '', model: '', model_thinking: '' },
|
||||
longcat: { enabled: false, api_key: '', base_url: '', openai_endpoint: '', anthropic_endpoint: '', model: '', model_thinking: '', model_thinking_upgraded: '', format: 'openai' }
|
||||
})
|
||||
const [aiSettingsLoading, setAiSettingsLoading] = createSignal(false)
|
||||
const [aiSettingsMessage, setAiSettingsMessage] = createSignal('')
|
||||
|
||||
const handleSendMessage = async () => {
|
||||
const message = inputMessage().trim()
|
||||
if (!message || isLoading()) return
|
||||
|
||||
// Add user message
|
||||
const userMessage = {
|
||||
id: Date.now(),
|
||||
content: message,
|
||||
role: 'user',
|
||||
created_at: new Date().toISOString()
|
||||
}
|
||||
|
||||
setMessages(prev => [...prev, userMessage])
|
||||
setInputMessage('')
|
||||
setIsLoading(true)
|
||||
|
||||
// Simulate AI response
|
||||
setTimeout(() => {
|
||||
const aiResponse = {
|
||||
id: Date.now() + 1,
|
||||
content: `I received your message: "${message}". This is a demo response from the AI assistant. In production, I would provide a helpful response based on the selected AI provider and model.`,
|
||||
role: 'assistant',
|
||||
created_at: new Date().toISOString()
|
||||
}
|
||||
setMessages(prev => [...prev, aiResponse])
|
||||
setIsLoading(false)
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
|
||||
// Check mobile on mount
|
||||
// Initialize AI models
|
||||
onMount(() => {
|
||||
const checkMobile = () => {
|
||||
if (window.innerWidth < 768) {
|
||||
@@ -96,121 +61,121 @@ export const AIChat = () => {
|
||||
checkMobile()
|
||||
window.addEventListener('resize', checkMobile)
|
||||
|
||||
// Fetch AI providers
|
||||
fetchAIProviders()
|
||||
// Load per-user AI provider settings
|
||||
loadAISettings()
|
||||
// Initialize AI models
|
||||
initializeAIModels()
|
||||
|
||||
return () => window.removeEventListener('resize', checkMobile)
|
||||
})
|
||||
|
||||
const fetchAIProviders = async () => {
|
||||
try {
|
||||
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:8080'
|
||||
const response = await fetch(`${apiUrl}/api/v1/ai/providers`)
|
||||
const data = await response.json()
|
||||
setProviders(data.providers || [])
|
||||
|
||||
const providerIds = (data.providers || []).map((p: AIProvider) => p.id)
|
||||
setEnabledProviders(providerIds)
|
||||
|
||||
if (data.providers && data.providers.length > 0) {
|
||||
setSelectedProvider(data.providers[0].id)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch AI providers:', error)
|
||||
// Set mock providers for demo mode
|
||||
const mockProviders: AIProvider[] = [
|
||||
{
|
||||
id: 'longcat',
|
||||
name: 'LongCat AI',
|
||||
description: 'Fast and efficient AI models',
|
||||
icon: '🐱',
|
||||
models: [
|
||||
{ id: 'longcat-flash-chat', name: 'LongCat Flash Chat', type: 'chat' },
|
||||
{ id: 'longcat-flash-thinking', name: 'LongCat Flash Thinking', type: 'thinking' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'mistral',
|
||||
name: 'Mistral AI',
|
||||
description: 'Advanced language models',
|
||||
icon: '🌊',
|
||||
models: [
|
||||
{ id: 'mistral-small-latest', name: 'Mistral Small', type: 'chat' },
|
||||
{ id: 'mistral-large-latest', name: 'Mistral Large', type: 'chat' }
|
||||
]
|
||||
}
|
||||
]
|
||||
setProviders(mockProviders)
|
||||
setEnabledProviders(['longcat'])
|
||||
setSelectedProvider('longcat')
|
||||
}
|
||||
const initializeAIModels = () => {
|
||||
const models: AIModel[] = [
|
||||
{ id: 'longcat-flash-chat', name: 'LongCat Flash Chat', description: 'Fast and efficient chat model', provider: 'longcat', category: 'fast', iconId: 'longcat' },
|
||||
{ id: 'longcat-flash-thinking', name: 'LongCat Flash Thinking', description: 'Advanced reasoning model', provider: 'longcat', category: 'thinking', iconId: 'longcat' },
|
||||
{ id: 'mistral-small-latest', name: 'Mistral Small', description: 'Lightweight and fast', provider: 'mistral', category: 'standard', iconId: 'mistral' },
|
||||
{ id: 'mistral-large-latest', name: 'Mistral Large', description: 'Most capable model', provider: 'mistral', category: 'advanced', iconId: 'mistral' },
|
||||
{ id: 'grok-standard', name: 'Grok Standard', description: 'Grok from X', provider: 'grok', category: 'standard', iconId: 'grok' },
|
||||
{ id: 'deepseek-chat', name: 'DeepSeek Chat', description: 'DeepSeek chat model', provider: 'deepseek', category: 'standard', iconId: 'deepseek' },
|
||||
{ id: 'ollama-local', name: 'Ollama Local', description: 'Local Ollama model', provider: 'ollama', category: 'local', iconId: 'ollama' },
|
||||
{ id: 'openrouter-auto', name: 'OpenRouter Auto', description: 'Router over many models', provider: 'openrouter', category: 'standard', iconId: 'openrouter' },
|
||||
]
|
||||
setAIModels(models)
|
||||
}
|
||||
|
||||
const loadAISettings = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/auth/ai/settings`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
const handleSendMessage = async () => {
|
||||
const message = inputMessage().trim()
|
||||
if (!message || isLoading()) return
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setAISettings(data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load AI settings:', error)
|
||||
// Add user message
|
||||
const userMessage: Message = {
|
||||
id: Date.now().toString(),
|
||||
content: message,
|
||||
role: 'user',
|
||||
timestamp: new Date()
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateAISettings = async () => {
|
||||
setAiSettingsLoading(true)
|
||||
setAiSettingsMessage('')
|
||||
|
||||
setMessages(prev => [...prev, userMessage])
|
||||
setInputMessage('')
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/auth/ai/settings`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(aiSettings())
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
setAiSettingsMessage('AI settings updated successfully!')
|
||||
await loadAISettings()
|
||||
} else {
|
||||
const error = await response.json()
|
||||
setAiSettingsMessage(error.error || 'Failed to update AI settings')
|
||||
// Call AI API
|
||||
const response = await callAIAPI(message, selectedModel())
|
||||
|
||||
const aiMessage: Message = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
role: 'assistant',
|
||||
content: response,
|
||||
timestamp: new Date()
|
||||
}
|
||||
|
||||
setMessages(prev => [...prev, aiMessage])
|
||||
} catch (error) {
|
||||
console.error('Failed to update AI settings:', error)
|
||||
setAiSettingsMessage('Failed to update AI settings')
|
||||
console.error('AI API call failed:', error)
|
||||
|
||||
// Fallback response
|
||||
const errorMessage: Message = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
role: 'assistant',
|
||||
content: 'I apologize, but I encountered an error while processing your request. Please try again later.',
|
||||
timestamp: new Date()
|
||||
}
|
||||
|
||||
setMessages(prev => [...prev, errorMessage])
|
||||
} finally {
|
||||
setAiSettingsLoading(false)
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleProvider = (providerId: string) => {
|
||||
const enabled = enabledProviders()
|
||||
if (enabled.includes(providerId)) {
|
||||
if (selectedProvider() === providerId) {
|
||||
const remaining = enabled.filter(p => p !== providerId)
|
||||
setSelectedProvider(remaining.length > 0 ? remaining[0] : '')
|
||||
}
|
||||
setEnabledProviders(enabled.filter(p => p !== providerId))
|
||||
} else {
|
||||
setEnabledProviders([...enabled, providerId])
|
||||
if (enabled.length === 0) {
|
||||
setSelectedProvider(providerId)
|
||||
const callAIAPI = async (message: string, modelId: string): Promise<string> => {
|
||||
const token = localStorage.getItem('token')
|
||||
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:8080'
|
||||
|
||||
const response = await fetch(`${apiUrl}/api/v1/ai/chat`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': token ? `Bearer ${token}` : '',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message,
|
||||
model: modelId,
|
||||
stream: false
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API call failed: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data.response || data.content || 'I understand your message. Let me help you with that.'
|
||||
}
|
||||
|
||||
|
||||
// Close model picker when clicking outside
|
||||
createEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
if (!target.closest('#model-picker-container')) {
|
||||
setShowModelPicker(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (showModelPicker()) {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
return () => document.removeEventListener('click', handleClickOutside)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
const startNewChat = () => {
|
||||
setMessages([{
|
||||
id: '1',
|
||||
role: 'assistant',
|
||||
content: 'Hello! I\'m your AI assistant. How can I help you today?',
|
||||
timestamp: new Date()
|
||||
}])
|
||||
setInputMessage('')
|
||||
}
|
||||
|
||||
|
||||
@@ -231,8 +196,8 @@ export const AIChat = () => {
|
||||
|
||||
{/* AI Logo */}
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-8 h-8 bg-gradient-to-br from-blue-500 to-purple-600 rounded-lg flex items-center justify-center">
|
||||
<Brain class="w-5 h-5 text-white" />
|
||||
<div class="w-8 h-8 bg-muted rounded-lg flex items-center justify-center">
|
||||
<Brain class="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<h1 class="font-semibold text-lg">AI Assistant</h1>
|
||||
@@ -304,15 +269,7 @@ export const AIChat = () => {
|
||||
<div class="space-y-3">
|
||||
{/* New Chat Button */}
|
||||
<Button
|
||||
onClick={() => {
|
||||
setMessages([{
|
||||
id: 1,
|
||||
content: 'Hello! I\'m your AI assistant. How can I help you today?',
|
||||
role: 'assistant',
|
||||
created_at: new Date().toISOString()
|
||||
}])
|
||||
setInputMessage('')
|
||||
}}
|
||||
onClick={startNewChat}
|
||||
class="w-full justify-start"
|
||||
variant="outline"
|
||||
>
|
||||
@@ -334,10 +291,10 @@ export const AIChat = () => {
|
||||
class="w-full text-left p-3 rounded-lg hover:bg-muted transition-colors"
|
||||
onClick={() => {
|
||||
setMessages([{
|
||||
id: 1,
|
||||
id: '1',
|
||||
content: `This is the ${session.title} session. How can I help you?`,
|
||||
role: 'assistant',
|
||||
created_at: new Date().toISOString()
|
||||
timestamp: new Date()
|
||||
}])
|
||||
}}
|
||||
>
|
||||
@@ -384,13 +341,16 @@ export const AIChat = () => {
|
||||
message.role === 'user' ? 'bg-primary-foreground/20' : 'bg-primary/10'
|
||||
}`}>
|
||||
{message.role === 'user' ? (
|
||||
<span class="text-xs">👤</span>
|
||||
<User class="text-xs" />
|
||||
) : (
|
||||
<span class="text-xs">🤖</span>
|
||||
<Bot class="text-xs" />
|
||||
)}
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm leading-relaxed whitespace-pre-wrap break-words">{message.content}</p>
|
||||
<p class="text-xs opacity-70 mt-2">
|
||||
{message.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -403,7 +363,7 @@ export const AIChat = () => {
|
||||
<div class="bg-muted rounded-lg p-4 max-w-[80%]">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<span class="text-xs">🤖</span>
|
||||
<Bot class="text-xs" />
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
<div class="w-2 h-2 bg-primary rounded-full animate-bounce"></div>
|
||||
@@ -422,6 +382,81 @@ export const AIChat = () => {
|
||||
<div class="p-6">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="flex gap-4">
|
||||
{/* AI Model Switcher */}
|
||||
<div id="model-picker-container" class="relative">
|
||||
<button
|
||||
onClick={() => setShowModelPicker(!showModelPicker())}
|
||||
class="flex items-center gap-2 px-3 py-2 bg-muted hover:bg-muted/80 rounded-lg text-sm transition-colors border border-border/50"
|
||||
>
|
||||
<AIProviderIcon
|
||||
providerId={aiModels().find(m => m.id === selectedModel())?.iconId || 'longcat'}
|
||||
size="1rem"
|
||||
/>
|
||||
<span class="text-sm font-medium">
|
||||
{aiModels().find(m => m.id === selectedModel())?.name?.split(' ')[0] || 'AI'}
|
||||
</span>
|
||||
<ChevronDown class={`h-4 w-4 transition-transform ${showModelPicker() ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{/* Model Picker Dropdown */}
|
||||
<Show when={showModelPicker()}>
|
||||
<div class="absolute bottom-full left-0 mb-2 w-80 bg-background border rounded-lg shadow-lg z-50 p-2 max-h-96 overflow-y-auto">
|
||||
<div class="p-2 border-b mb-2">
|
||||
<h4 class="text-sm font-semibold text-foreground">Select AI Model</h4>
|
||||
<p class="text-xs text-muted-foreground">Choose the best model for your needs</p>
|
||||
</div>
|
||||
<For each={aiModels()}>
|
||||
{model => (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedModel(model.id)
|
||||
setShowModelPicker(false)
|
||||
}}
|
||||
class={`w-full text-left p-3 rounded-lg transition-colors ${
|
||||
selectedModel() === model.id
|
||||
? 'bg-primary/10 border border-primary/20'
|
||||
: 'hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3 flex-1">
|
||||
<AIProviderIcon
|
||||
providerId={model.iconId!}
|
||||
size="1rem"
|
||||
class="rounded-full flex-shrink-0"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium text-sm truncate">{model.name}</div>
|
||||
<div class="text-xs text-muted-foreground mt-1 truncate">{model.description}</div>
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<span class="text-xs px-2 py-1 bg-primary/10 text-primary rounded-full">
|
||||
{model.provider}
|
||||
</span>
|
||||
<span class={`text-xs px-2 py-1 rounded-full ${
|
||||
model.category === 'thinking'
|
||||
? 'bg-purple-100 text-purple-800'
|
||||
: model.category === 'fast'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: model.category === 'advanced'
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}>
|
||||
{model.category}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{selectedModel() === model.id && (
|
||||
<div class="w-2 h-2 bg-primary rounded-full flex-shrink-0"></div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
value={inputMessage()}
|
||||
onInput={(e) => setInputMessage((e.currentTarget as HTMLInputElement).value)}
|
||||
@@ -448,268 +483,93 @@ export const AIChat = () => {
|
||||
|
||||
{/* Settings View */}
|
||||
<Show when={activeView() === 'settings'}>
|
||||
<div class="flex-1 overflow-y-auto p-2">
|
||||
<div class="flex-1 overflow-y-auto p-6">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="mb-8">
|
||||
<h2 class="text-2xl font-bold mb-2">AI Settings</h2>
|
||||
<p class="text-muted-foreground">Configure your AI providers and preferences</p>
|
||||
<p class="text-muted-foreground">Configure your AI models and preferences</p>
|
||||
</div>
|
||||
|
||||
<Card class="p-6">
|
||||
<h3 class="text-lg font-semibold mb-4">AI Provider Settings</h3>
|
||||
<div class="space-y-6">
|
||||
{/* Provider Toggles */}
|
||||
<div>
|
||||
<h4 class="text-md font-medium mb-3">Available Providers</h4>
|
||||
<div class="space-y-3">
|
||||
<For each={providers()}>
|
||||
{(provider) => {
|
||||
const isEnabled = enabledProviders().includes(provider.id)
|
||||
return (
|
||||
<div
|
||||
class={`p-4 border rounded-lg transition-all ${
|
||||
isEnabled
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border'
|
||||
}`}
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<AIProviderIcon
|
||||
providerId={provider.id}
|
||||
size="2rem"
|
||||
class="text-primary"
|
||||
/>
|
||||
<div>
|
||||
<h5 class="font-medium">{provider.name}</h5>
|
||||
<p class="text-sm text-muted-foreground">{provider.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => toggleProvider(provider.id)}
|
||||
class={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
isEnabled
|
||||
? 'bg-primary'
|
||||
: 'bg-muted'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
class={`inline-block h-4 w-4 transform rounded-full bg-background transition-transform ${
|
||||
isEnabled ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Model selection */}
|
||||
{isEnabled && (
|
||||
<div class="mt-4 pt-4 border-t border-border">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<label class="text-sm font-medium">
|
||||
Model:
|
||||
</label>
|
||||
<select
|
||||
value={selectedProvider() === provider.id ? selectedModel() : 'standard'}
|
||||
onChange={(e) => {
|
||||
setSelectedProvider(provider.id)
|
||||
setSelectedModel(e.target.value)
|
||||
}}
|
||||
class="text-sm px-2 py-1 border border-border rounded focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
<For each={provider.models}>
|
||||
{(model) => (
|
||||
<option value={model.id}>
|
||||
{model.type} - {model.name}
|
||||
</option>
|
||||
)}
|
||||
</For>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Response Settings */}
|
||||
<div>
|
||||
<h4 class="text-md font-medium mb-3">Response Settings</h4>
|
||||
<div class="space-y-4">
|
||||
<div class="p-4 border border-border rounded-lg">
|
||||
<label class="block text-sm font-medium mb-2">Response Length</label>
|
||||
<select class="w-full text-sm px-3 py-2 border border-border rounded focus:outline-none focus:ring-2 focus:ring-primary">
|
||||
<option value="concise">Concise</option>
|
||||
<option value="balanced" selected>Balanced</option>
|
||||
<option value="detailed">Detailed</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="p-4 border border-border rounded-lg">
|
||||
<label class="block text-sm font-medium mb-2">Response Style</label>
|
||||
<select class="w-full text-sm px-3 py-2 border border-border rounded focus:outline-none focus:ring-2 focus:ring-primary">
|
||||
<option value="professional" selected>Professional</option>
|
||||
<option value="casual">Casual</option>
|
||||
<option value="technical">Technical</option>
|
||||
<option value="creative">Creative</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Account-level provider settings (example: LongCat) */}
|
||||
<div class="pt-4 mt-2 border-t border-border space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="text-md font-medium">Account Provider Settings</h4>
|
||||
<span class="text-xs text-muted-foreground">{aiSettingsMessage()}</span>
|
||||
</div>
|
||||
|
||||
<div class="border rounded-lg p-4 space-y-3">
|
||||
<h3 class="text-lg font-semibold mb-4">Available AI Models</h3>
|
||||
<div class="space-y-4">
|
||||
<For each={aiModels()}>
|
||||
{(model) => (
|
||||
<div
|
||||
class={`p-4 border rounded-lg transition-all ${
|
||||
selectedModel() === model.id
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:bg-muted/50'
|
||||
}`}
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-2 h-2 bg-purple-500 rounded-full" />
|
||||
<span class="text-sm font-medium">LongCat AI</span>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-xs cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={aiSettings().longcat.enabled}
|
||||
onChange={(e) => {
|
||||
const settings = aiSettings()
|
||||
setAISettings({
|
||||
...settings,
|
||||
longcat: { ...settings.longcat, enabled: e.currentTarget.checked }
|
||||
})
|
||||
}}
|
||||
class="rounded border-input"
|
||||
/>
|
||||
<span>Enabled</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-muted-foreground mb-1">API Key</label>
|
||||
<input
|
||||
type="password"
|
||||
value={aiSettings().longcat.api_key}
|
||||
onInput={(e) => {
|
||||
const settings = aiSettings()
|
||||
setAISettings({
|
||||
...settings,
|
||||
longcat: { ...settings.longcat, api_key: e.currentTarget.value }
|
||||
})
|
||||
}}
|
||||
placeholder="LongCat API key"
|
||||
class="flex h-9 w-full rounded-md border border-input bg-background px-2 py-1.5 text-xs text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
<div class="flex items-center gap-3">
|
||||
<AIProviderIcon
|
||||
providerId={model.iconId!}
|
||||
size="2rem"
|
||||
class="text-primary"
|
||||
/>
|
||||
<div>
|
||||
<h5 class="font-medium">{model.name}</h5>
|
||||
<p class="text-sm text-muted-foreground">{model.description}</p>
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<span class="text-xs px-2 py-1 bg-primary/10 text-primary rounded-full">
|
||||
{model.provider}
|
||||
</span>
|
||||
<span class={`text-xs px-2 py-1 rounded-full ${
|
||||
model.category === 'thinking'
|
||||
? 'bg-purple-100 text-purple-800'
|
||||
: model.category === 'fast'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: model.category === 'advanced'
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}>
|
||||
{model.category}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-muted-foreground mb-1">Base URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={aiSettings().longcat.base_url}
|
||||
onInput={(e) => {
|
||||
const settings = aiSettings()
|
||||
setAISettings({
|
||||
...settings,
|
||||
longcat: { ...settings.longcat, base_url: e.currentTarget.value }
|
||||
})
|
||||
}}
|
||||
class="flex h-9 w-full rounded-md border border-input bg-background px-2 py-1.5 text-xs text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-muted-foreground mb-1">Chat Model</label>
|
||||
<input
|
||||
type="text"
|
||||
value={aiSettings().longcat.model}
|
||||
onInput={(e) => {
|
||||
const settings = aiSettings()
|
||||
setAISettings({
|
||||
...settings,
|
||||
longcat: { ...settings.longcat, model: e.currentTarget.value }
|
||||
})
|
||||
}}
|
||||
class="flex h-9 w-full rounded-md border border-input bg-background px-2 py-1.5 text-xs text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-muted-foreground mb-1">Thinking Model</label>
|
||||
<input
|
||||
type="text"
|
||||
value={aiSettings().longcat.model_thinking}
|
||||
onInput={(e) => {
|
||||
const settings = aiSettings()
|
||||
setAISettings({
|
||||
...settings,
|
||||
longcat: { ...settings.longcat, model_thinking: e.currentTarget.value }
|
||||
})
|
||||
}}
|
||||
class="flex h-9 w-full rounded-md border border-input bg-background px-2 py-1.5 text-xs text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-muted-foreground mb-1">Upgraded Thinking</label>
|
||||
<input
|
||||
type="text"
|
||||
value={aiSettings().longcat.model_thinking_upgraded}
|
||||
onInput={(e) => {
|
||||
const settings = aiSettings()
|
||||
setAISettings({
|
||||
...settings,
|
||||
longcat: { ...settings.longcat, model_thinking_upgraded: e.currentTarget.value }
|
||||
})
|
||||
}}
|
||||
class="flex h-9 w-full rounded-md border border-input bg-background px-2 py-1.5 text-xs text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-muted-foreground mb-1">Format</label>
|
||||
<select
|
||||
value={aiSettings().longcat.format}
|
||||
onChange={(e) => {
|
||||
const settings = aiSettings()
|
||||
setAISettings({
|
||||
...settings,
|
||||
longcat: { ...settings.longcat, format: e.currentTarget.value as 'openai' | 'anthropic' }
|
||||
})
|
||||
}}
|
||||
class="flex h-9 w-full rounded-md border border-input bg-background px-2 py-1.5 text-xs text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
<button
|
||||
onClick={() => setSelectedModel(model.id)}
|
||||
class={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
selectedModel() === model.id
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted hover:bg-muted/80'
|
||||
}`}
|
||||
>
|
||||
<option value="openai">OpenAI Compatible</option>
|
||||
<option value="anthropic">Anthropic Compatible</option>
|
||||
</select>
|
||||
{selectedModel() === model.id ? 'Selected' : 'Select'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div class="flex items-center gap-3 pt-2">
|
||||
<Button
|
||||
onClick={handleUpdateAISettings}
|
||||
disabled={aiSettingsLoading()}
|
||||
>
|
||||
{aiSettingsLoading() ? 'Saving...' : 'Save AI Settings'}
|
||||
</Button>
|
||||
<a
|
||||
href="/app/settings"
|
||||
class="ml-auto text-xs text-primary hover:underline"
|
||||
>
|
||||
Open full AI settings
|
||||
</a>
|
||||
</div>
|
||||
<Card class="p-6 mt-6">
|
||||
<h3 class="text-lg font-semibold mb-4">Current Selection</h3>
|
||||
<div class="p-4 bg-muted/50 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<AIProviderIcon
|
||||
providerId={aiModels().find(m => m.id === selectedModel())?.iconId || 'longcat'}
|
||||
size="1.5rem"
|
||||
class="text-primary"
|
||||
/>
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
{aiModels().find(m => m.id === selectedModel())?.name}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{aiModels().find(m => m.id === selectedModel())?.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,9 +3,10 @@ import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { BookmarkModal } from '@/components/ui/BookmarkModal';
|
||||
import { EditBookmarkModal } from '@/components/ui/EditBookmarkModal';
|
||||
import { VideoUploadModal } from '@/components/ui/VideoUploadModal';
|
||||
import { DropdownMenu, DropdownMenuItem } from '@/components/ui/DropdownMenu';
|
||||
import { SearchTagFilterBar } from '@/components/ui/SearchTagFilterBar';
|
||||
import { IconDotsVertical, IconStar, IconEdit, IconTrash, IconExternalLink, IconVideo } from '@tabler/icons-solidjs';
|
||||
import { IconDotsVertical, IconStar, IconEdit, IconTrash, IconExternalLink, IconVideo, IconBookmark } from '@tabler/icons-solidjs';
|
||||
import { getMockBookmarks, getMockVideos } from '@/lib/mockData';
|
||||
|
||||
interface BookmarkTag {
|
||||
@@ -65,7 +66,21 @@ export const Bookmarks = () => {
|
||||
if (bookmark.favicon) return bookmark.favicon;
|
||||
try {
|
||||
const url = new URL(bookmark.url);
|
||||
return `https://www.google.com/s2/favicons?domain=${url.hostname}&sz=64`;
|
||||
const baseUrl = `${url.protocol}//${url.hostname}`;
|
||||
|
||||
// Try multiple favicon sources
|
||||
const faviconSources = [
|
||||
`${baseUrl}/favicon.ico`,
|
||||
`${baseUrl}/favicon.png`,
|
||||
`${baseUrl}/img/favicons/favicon-32x32.png`,
|
||||
`${baseUrl}/img/favicons/favicon-16x16.png`,
|
||||
`${baseUrl}/logo-without-border.svg`,
|
||||
`${baseUrl}/logo.svg`,
|
||||
`${baseUrl}/icon.svg`,
|
||||
`https://www.google.com/s2/favicons?domain=${url.hostname}&sz=64`
|
||||
];
|
||||
|
||||
return faviconSources[0]; // Return first source, fallback will be handled by error
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
@@ -88,8 +103,11 @@ export const Bookmarks = () => {
|
||||
const [isLoadingVideos, setIsLoadingVideos] = createSignal(true);
|
||||
const [searchTerm, setSearchTerm] = createSignal('');
|
||||
const [selectedTag, setSelectedTag] = createSignal('');
|
||||
const [videoSearchTerm, setVideoSearchTerm] = createSignal('');
|
||||
const [videoSelectedTag, setVideoSelectedTag] = createSignal('');
|
||||
const [showAddModal, setShowAddModal] = createSignal(false);
|
||||
const [showEditModal, setShowEditModal] = createSignal(false);
|
||||
const [showVideoModal, setShowVideoModal] = createSignal(false);
|
||||
const [editingBookmark, setEditingBookmark] = createSignal<Bookmark | null>(null);
|
||||
const [activeTab, setActiveTab] = createSignal<'bookmarks' | 'videos'>('bookmarks');
|
||||
// We no longer show inline HTML content previews, only the bookmark cards themselves
|
||||
@@ -127,22 +145,48 @@ export const Bookmarks = () => {
|
||||
|
||||
try {
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8081/api/v1';
|
||||
const response = await fetch(`${API_BASE_URL}/bookmarks`, {
|
||||
|
||||
// Load regular bookmarks
|
||||
const bookmarksResponse = await fetch(`${API_BASE_URL}/bookmarks`, {
|
||||
headers: {
|
||||
'Authorization': localStorage.getItem('trackeep_token') ? `Bearer ${localStorage.getItem('trackeep_token')}` : '',
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
if (!bookmarksResponse.ok) {
|
||||
throw new Error('Failed to load bookmarks');
|
||||
}
|
||||
const data = await response.json();
|
||||
const bookmarksData = await bookmarksResponse.json();
|
||||
|
||||
// Normalize API response:
|
||||
// - Ensure we always work with an array
|
||||
// - Map Tag objects to simple string[]
|
||||
const normalized: Bookmark[] = (Array.isArray(data) ? data : []).map(adaptBookmarkFromApi);
|
||||
const normalized: Bookmark[] = (Array.isArray(bookmarksData) ? bookmarksData : []).map(adaptBookmarkFromApi);
|
||||
|
||||
setBookmarks(normalized);
|
||||
|
||||
// Load video bookmarks
|
||||
try {
|
||||
const videosResponse = await fetch(`${API_BASE_URL}/youtube/videos`, {
|
||||
headers: {
|
||||
'Authorization': localStorage.getItem('trackeep_token') ? `Bearer ${localStorage.getItem('trackeep_token')}` : '',
|
||||
},
|
||||
});
|
||||
|
||||
if (videosResponse.ok) {
|
||||
const videosData = await videosResponse.json();
|
||||
setVideoBookmarks(Array.isArray(videosData) ? videosData : []);
|
||||
} else {
|
||||
// If video endpoint fails, load mock videos as fallback
|
||||
const mockVideos = getMockVideos();
|
||||
setVideoBookmarks(mockVideos);
|
||||
}
|
||||
} catch (videoError) {
|
||||
console.warn('Failed to load video bookmarks, using mock data:', videoError);
|
||||
const mockVideos = getMockVideos();
|
||||
setVideoBookmarks(mockVideos);
|
||||
}
|
||||
|
||||
setIsLoadingVideos(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to load bookmarks:', error);
|
||||
// Fallback to mock data if API fails
|
||||
@@ -160,6 +204,11 @@ export const Bookmarks = () => {
|
||||
screenshot_medium: bookmark.screenshot,
|
||||
}));
|
||||
setBookmarks(adaptedBookmarks);
|
||||
|
||||
// Also load mock videos as fallback
|
||||
const mockVideos = getMockVideos();
|
||||
setVideoBookmarks(mockVideos);
|
||||
setIsLoadingVideos(false);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -174,6 +223,15 @@ export const Bookmarks = () => {
|
||||
return Array.from(tags).sort();
|
||||
};
|
||||
|
||||
// Get all unique tags from video bookmarks
|
||||
const getAllVideoTags = () => {
|
||||
const tags = new Set<string>();
|
||||
videoBookmarks().forEach((video) => {
|
||||
(video.tags || []).forEach((tag: any) => tags.add(tag.name));
|
||||
});
|
||||
return Array.from(tags).sort();
|
||||
};
|
||||
|
||||
const filteredBookmarks = () => {
|
||||
const term = searchTerm().toLowerCase();
|
||||
const tag = selectedTag();
|
||||
@@ -191,6 +249,23 @@ export const Bookmarks = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const filteredVideoBookmarks = () => {
|
||||
const term = videoSearchTerm().toLowerCase();
|
||||
const tag = videoSelectedTag();
|
||||
|
||||
return videoBookmarks().filter(video => {
|
||||
const matchesSearch = !term ||
|
||||
video.title.toLowerCase().includes(term) ||
|
||||
video.description.toLowerCase().includes(term) ||
|
||||
video.channel.toLowerCase().includes(term) ||
|
||||
(video.tags || []).some((t: any) => t.name.toLowerCase().includes(term));
|
||||
|
||||
const matchesTag = !tag || (video.tags || []).some((t: any) => t.name === tag);
|
||||
|
||||
return matchesSearch && matchesTag;
|
||||
});
|
||||
};
|
||||
|
||||
// We no longer fetch or display full page metadata/content previews here.
|
||||
|
||||
const handleAddBookmark = async (bookmarkData: any) => {
|
||||
@@ -262,11 +337,21 @@ export const Bookmarks = () => {
|
||||
setSearchTerm(''); // Clear search when filtering by tag
|
||||
};
|
||||
|
||||
const handleVideoTagClick = (tag: string) => {
|
||||
setVideoSelectedTag((current) => (current === tag ? '' : tag));
|
||||
setVideoSearchTerm(''); // Clear search when filtering by tag
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setSearchTerm('');
|
||||
setSelectedTag('');
|
||||
};
|
||||
|
||||
const resetVideoFilters = () => {
|
||||
setVideoSearchTerm('');
|
||||
setVideoSelectedTag('');
|
||||
};
|
||||
|
||||
const handleEditBookmark = async (bookmarkData: Partial<Bookmark>) => {
|
||||
if (!editingBookmark()) return;
|
||||
|
||||
@@ -300,6 +385,30 @@ export const Bookmarks = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleVideoSubmit = async (video: any) => {
|
||||
try {
|
||||
// Use the YouTube API to add video
|
||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/youtube/video-details`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
},
|
||||
body: JSON.stringify({ video_id: video.video_id })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
console.log('Video added:', video);
|
||||
} else {
|
||||
console.log('Video added (demo mode):', video);
|
||||
}
|
||||
setShowVideoModal(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to add video:', error);
|
||||
setShowVideoModal(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="p-6 space-y-6">
|
||||
<div class="flex justify-between items-center">
|
||||
@@ -314,9 +423,18 @@ export const Bookmarks = () => {
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Button onClick={() => setShowAddModal(true)}>
|
||||
Add Bookmark
|
||||
</Button>
|
||||
<Show when={activeTab() === 'bookmarks'}>
|
||||
<Button onClick={() => setShowAddModal(true)}>
|
||||
<IconBookmark class="size-4 mr-2" />
|
||||
Add Bookmark
|
||||
</Button>
|
||||
</Show>
|
||||
<Show when={activeTab() === 'videos'}>
|
||||
<Button onClick={() => setShowVideoModal(true)}>
|
||||
<IconVideo class="size-4 mr-2" />
|
||||
Add Video
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
@@ -324,12 +442,13 @@ export const Bookmarks = () => {
|
||||
<nav class="-mb-px flex space-x-8">
|
||||
<button
|
||||
onClick={() => setActiveTab('bookmarks')}
|
||||
class={`py-2 px-1 border-b-2 font-medium text-sm transition-colors ${
|
||||
class={`py-2 px-1 border-b-2 font-medium text-sm transition-colors flex items-center gap-2 ${
|
||||
activeTab() === 'bookmarks'
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-muted'
|
||||
}`}
|
||||
>
|
||||
<IconBookmark class={`size-4 ${activeTab() === 'bookmarks' ? 'text-primary' : 'text-muted-foreground'}`} />
|
||||
Web Bookmarks
|
||||
</button>
|
||||
<button
|
||||
@@ -340,7 +459,7 @@ export const Bookmarks = () => {
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-muted'
|
||||
}`}
|
||||
>
|
||||
<IconVideo class="size-4" />
|
||||
<IconVideo class={`size-4 ${activeTab() === 'videos' ? 'text-primary' : 'text-muted-foreground'}`} />
|
||||
Video Bookmarks
|
||||
</button>
|
||||
</nav>
|
||||
@@ -419,8 +538,31 @@ export const Bookmarks = () => {
|
||||
alt=""
|
||||
class="w-6 h-6 object-contain"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none';
|
||||
e.currentTarget.parentElement!.innerHTML = `<span class="text-xs text-muted-foreground font-medium">${bookmark.title.charAt(0).toUpperCase()}</span>`;
|
||||
const img = e.currentTarget;
|
||||
const url = new URL(bookmark.url);
|
||||
const baseUrl = `${url.protocol}//${url.hostname}`;
|
||||
|
||||
// Try next favicon source
|
||||
const faviconSources = [
|
||||
`${baseUrl}/favicon.ico`,
|
||||
`${baseUrl}/favicon.png`,
|
||||
`${baseUrl}/img/favicons/favicon-32x32.png`,
|
||||
`${baseUrl}/img/favicons/favicon-16x16.png`,
|
||||
`${baseUrl}/logo-without-border.svg`,
|
||||
`${baseUrl}/logo.svg`,
|
||||
`${baseUrl}/icon.svg`,
|
||||
`https://www.google.com/s2/favicons?domain=${url.hostname}&sz=64`
|
||||
];
|
||||
|
||||
const currentSrc = img.src;
|
||||
const currentIndex = faviconSources.findIndex(src => currentSrc.includes(src));
|
||||
|
||||
if (currentIndex < faviconSources.length - 1) {
|
||||
img.src = faviconSources[currentIndex + 1];
|
||||
} else {
|
||||
img.style.display = 'none';
|
||||
img.parentElement!.innerHTML = `<span class="text-xs text-muted-foreground font-medium">${bookmark.title.charAt(0).toUpperCase()}</span>`;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
@@ -532,6 +674,16 @@ export const Bookmarks = () => {
|
||||
</Show>
|
||||
|
||||
<Show when={activeTab() === 'videos'}>
|
||||
<SearchTagFilterBar
|
||||
searchPlaceholder="Search video bookmarks..."
|
||||
searchValue={videoSearchTerm()}
|
||||
onSearchChange={(value) => setVideoSearchTerm(value)}
|
||||
tagOptions={getAllVideoTags()}
|
||||
selectedTag={videoSelectedTag()}
|
||||
onTagChange={(value) => setVideoSelectedTag(value)}
|
||||
onReset={resetVideoFilters}
|
||||
/>
|
||||
|
||||
{isLoadingVideos() ? (
|
||||
<div class="space-y-4">
|
||||
{[...Array(3)].map(() => (
|
||||
@@ -546,58 +698,108 @@ export const Bookmarks = () => {
|
||||
</div>
|
||||
) : (
|
||||
<div class="space-y-4">
|
||||
{videoBookmarks().map((video) => (
|
||||
<Card class="p-6 hover:bg-accent transition-colors">
|
||||
<div class="flex gap-4">
|
||||
<div class="flex-shrink-0">
|
||||
<img
|
||||
src={video.thumbnail}
|
||||
alt={video.title}
|
||||
class="w-32 h-20 object-cover rounded-md"
|
||||
/>
|
||||
{filteredVideoBookmarks().map((video) => (
|
||||
<Card class="p-6 hover:bg-accent transition-colors group">
|
||||
<div class="flex justify-between items-start gap-4">
|
||||
<div class="flex gap-4 flex-1">
|
||||
<div class="flex-shrink-0">
|
||||
<img
|
||||
src={video.thumbnail}
|
||||
alt={video.title}
|
||||
class="w-32 h-20 object-cover rounded-md"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-foreground mb-2">
|
||||
<a
|
||||
href={video.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary hover:text-primary/80 transition-colors flex items-center gap-1"
|
||||
>
|
||||
{video.title}
|
||||
<IconExternalLink class="size-5 ml-1.5 flex-shrink-0 text-current group-hover:text-white" />
|
||||
</a>
|
||||
</h3>
|
||||
<p class="text-muted-foreground text-sm mb-2">{video.description}</p>
|
||||
<div class="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<span>{video.channel}</span>
|
||||
<span>•</span>
|
||||
<span>{video.duration}</span>
|
||||
<span>•</span>
|
||||
<span>{video.publishedAt}</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 mt-2">
|
||||
{video.tags.map((tag: any) => (
|
||||
<button
|
||||
onClick={() => handleVideoTagClick(tag.name)}
|
||||
class={`px-2 py-1 text-xs rounded-md border transition-colors cursor-pointer
|
||||
${videoSelectedTag() === tag.name
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-muted/80 text-muted-foreground border-transparent group-hover:bg-accent group-hover:text-accent-foreground group-hover:border-border'
|
||||
}`}
|
||||
title={`Click to filter by ${tag.name}`}
|
||||
>
|
||||
{tag.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-foreground mb-2">
|
||||
<a
|
||||
href={video.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary hover:text-primary/80 transition-colors flex items-center gap-1"
|
||||
<div class="flex items-center gap-2 ml-2">
|
||||
<DropdownMenu
|
||||
trigger={
|
||||
<button 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-inherit hover:bg-accent/50 hover:text-accent-foreground h-8 w-8">
|
||||
<IconDotsVertical class="size-4" />
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={() => window.open(video.url, '_blank')}
|
||||
icon={IconExternalLink}
|
||||
>
|
||||
{video.title}
|
||||
<IconExternalLink class="size-5 ml-1.5 flex-shrink-0 text-current" />
|
||||
</a>
|
||||
</h3>
|
||||
<p class="text-muted-foreground text-sm mb-2">{video.description}</p>
|
||||
<div class="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<span>{video.channel}</span>
|
||||
<span>•</span>
|
||||
<span>{video.duration}</span>
|
||||
<span>•</span>
|
||||
<span>{video.publishedAt}</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 mt-2">
|
||||
{video.tags.map((tag: any) => (
|
||||
<span class="px-2 py-1 text-xs rounded-md bg-muted text-muted-foreground">
|
||||
{tag.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
Open in New Tab
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigator.clipboard.writeText(video.url)}
|
||||
icon={IconEdit}
|
||||
>
|
||||
Copy Link
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (confirm('Are you sure you want to delete this video bookmark?')) {
|
||||
setVideoBookmarks(prev => prev.filter(v => v.id !== video.id));
|
||||
}
|
||||
}}
|
||||
icon={IconTrash}
|
||||
variant="destructive"
|
||||
>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{videoBookmarks().length === 0 && (
|
||||
{filteredVideoBookmarks().length === 0 && (
|
||||
<Card class="p-12 text-center">
|
||||
<p class="text-muted-foreground">
|
||||
No video bookmarks yet. Save your first YouTube video!
|
||||
{videoSearchTerm() || videoSelectedTag() ? 'No video bookmarks found matching your search.' : 'No video bookmarks yet. Save your first YouTube video!'}
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
{/* Video Upload Modal */}
|
||||
<VideoUploadModal
|
||||
isOpen={showVideoModal()}
|
||||
onClose={() => setShowVideoModal(false)}
|
||||
onSubmit={handleVideoSubmit}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createSignal, createEffect, onMount, For, Show } from 'solid-js'
|
||||
import { DateTimePicker } from '@/components/ui/DatePicker';
|
||||
import { DateRangePicker } from '@/components/ui/DateRangePicker';
|
||||
import {
|
||||
IconCalendar,
|
||||
IconClock,
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
IconFlag
|
||||
} from '@tabler/icons-solidjs'
|
||||
import { getMockCalendarEvents } from '@/lib/mockData';
|
||||
import { isDemoMode as isDemoModeEnabled } from '@/lib/demo-mode';
|
||||
|
||||
interface CalendarEvent {
|
||||
id: number
|
||||
@@ -80,21 +81,15 @@ export function Calendar() {
|
||||
return () => clearInterval(timer)
|
||||
})
|
||||
|
||||
// Check if we're in demo mode
|
||||
const isDemoMode = () => {
|
||||
return localStorage.getItem('demoMode') === 'true' ||
|
||||
document.title.includes('Demo Mode') ||
|
||||
window.location.search.includes('demo=true');
|
||||
};
|
||||
|
||||
// Fetch calendar data
|
||||
const fetchCalendarData = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
if (isDemoMode() || !token) {
|
||||
if (isDemoModeEnabled() || !token) {
|
||||
// Use mock data in demo mode or when not authenticated
|
||||
const mockEvents = getMockCalendarEvents();
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const weekFromNow = new Date();
|
||||
@@ -213,7 +208,7 @@ export function Calendar() {
|
||||
|
||||
const createEvent = async () => {
|
||||
try {
|
||||
if (isDemoMode()) {
|
||||
if (isDemoModeEnabled()) {
|
||||
// Simulate event creation in demo mode
|
||||
console.log('Creating event (demo mode):', newEvent());
|
||||
setShowEventModal(false);
|
||||
@@ -277,7 +272,7 @@ export function Calendar() {
|
||||
|
||||
const toggleEventCompletion = async (eventId: number) => {
|
||||
try {
|
||||
if (isDemoMode()) {
|
||||
if (isDemoModeEnabled()) {
|
||||
// Simulate event completion toggle in demo mode
|
||||
console.log('Toggling event completion (demo mode):', eventId);
|
||||
fetchCalendarData();
|
||||
@@ -358,6 +353,18 @@ export function Calendar() {
|
||||
|
||||
return (
|
||||
<div class="space-y-6">
|
||||
{/* Demo Mode Indicator */}
|
||||
<Show when={isDemoModeEnabled()}>
|
||||
<div class="bg-yellow-100 dark:bg-yellow-900/20 border border-yellow-300 dark:border-yellow-800 rounded-lg p-3 mb-4">
|
||||
<p class="text-yellow-800 dark:text-yellow-200 text-sm font-medium">
|
||||
Demo Mode Active - Showing sample calendar data
|
||||
</p>
|
||||
<p class="text-yellow-700 dark:text-yellow-300 text-xs mt-1">
|
||||
Today: {todayEvents().length} events | Upcoming: {upcomingEvents().length} events | Deadlines: {deadlines().length}
|
||||
</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Header with Current Time */}
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -440,7 +447,7 @@ export function Calendar() {
|
||||
</div>
|
||||
|
||||
{/* Enhanced Calendar Grid with Events */}
|
||||
<div class="grid grid-cols-7 gap-1 text-sm auto-rows-fr">
|
||||
<div class="grid grid-cols-7 gap-1 text-sm">
|
||||
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map(day => (
|
||||
<div class="text-center text-sm font-medium text-muted-foreground p-2">
|
||||
{day}
|
||||
@@ -461,18 +468,16 @@ export function Calendar() {
|
||||
return (
|
||||
<div
|
||||
onClick={() => openEventModal(date)}
|
||||
class={`border border-border rounded-lg p-1 cursor-pointer hover:bg-accent transition-colors relative overflow-hidden ${
|
||||
class={`border border-border rounded-lg p-1 cursor-pointer hover:bg-accent transition-colors relative overflow-hidden h-24 flex flex-col ${
|
||||
isToday ? 'bg-primary/10 border-primary' : ''
|
||||
} ${!isCurrentMonth ? 'opacity-40' : ''} ${
|
||||
dayEvents.length > 3 ? 'row-span-2 min-h-[5rem]' : 'min-h-[3.5rem]'
|
||||
}`}
|
||||
} ${!isCurrentMonth ? 'opacity-40' : ''}`}
|
||||
>
|
||||
<div class="text-sm font-medium">{date.getDate()}</div>
|
||||
<div class="text-sm font-medium shrink-0">{date.getDate()}</div>
|
||||
{/* Event indicators */}
|
||||
<div class="space-y-1 mt-1">
|
||||
<div class="flex-1 overflow-hidden flex flex-col justify-start space-y-0.5 mt-1">
|
||||
{dayEvents.slice(0, 3).map((event: CalendarEvent) => (
|
||||
<div
|
||||
class={`text-xs px-1 py-0.5 rounded truncate w-full cursor-pointer hover:opacity-80 transition-opacity ${
|
||||
class={`text-xs px-1 py-0.5 rounded truncate w-full cursor-pointer hover:opacity-80 transition-opacity leading-none ${
|
||||
event.type === 'deadline'
|
||||
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400 border border-red-200 dark:border-red-800'
|
||||
: event.type === 'meeting'
|
||||
@@ -481,19 +486,19 @@ export function Calendar() {
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||
: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
|
||||
}`}
|
||||
style={`font-size: 10px;`}
|
||||
style={`font-size: 9px; line-height: 1.2;`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedTask(event);
|
||||
setShowTaskDetailModal(true);
|
||||
}}
|
||||
>
|
||||
{event.title.length > 12 ? event.title.substring(0, 12) + '...' : event.title}
|
||||
{event.title.length > 10 ? event.title.substring(0, 10) + '...' : event.title}
|
||||
</div>
|
||||
))}
|
||||
{dayEvents.length > 3 && (
|
||||
<div
|
||||
class="text-xs text-muted-foreground font-medium cursor-pointer hover:text-primary transition-colors underline"
|
||||
class="text-xs text-muted-foreground font-medium cursor-pointer hover:text-primary transition-colors underline leading-none mt-0.5"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// Show all events for this day
|
||||
@@ -879,33 +884,33 @@ export function Calendar() {
|
||||
<label class="block text-sm font-medium mb-1">
|
||||
{newEvent().is_all_day ? 'Event Date' : 'Start Time'}
|
||||
</label>
|
||||
<DateTimePicker
|
||||
value={newEvent().start_time ? new Date(newEvent().start_time) : undefined}
|
||||
onChange={(date) => {
|
||||
if (date) {
|
||||
<DateRangePicker
|
||||
value={newEvent().start_time ? { start: new Date(newEvent().start_time), end: new Date(newEvent().end_time || newEvent().start_time) } : undefined}
|
||||
onChange={(range) => {
|
||||
if (range && range.start) {
|
||||
if (newEvent().is_all_day) {
|
||||
// For all-day events, set time to beginning of day
|
||||
const startOfDay = new Date(date);
|
||||
const startOfDay = new Date(range.start);
|
||||
startOfDay.setHours(0, 0, 0, 0);
|
||||
setNewEvent({ ...newEvent(), start_time: startOfDay.toISOString() });
|
||||
} else {
|
||||
setNewEvent({ ...newEvent(), start_time: date.toISOString() });
|
||||
setNewEvent({ ...newEvent(), start_time: range.start.toISOString() });
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder={newEvent().is_all_day ? "Select event date" : "Select start time"}
|
||||
class="w-full"
|
||||
dateOnly={newEvent().is_all_day}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!newEvent().is_all_day && (
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">End Time</label>
|
||||
<DateTimePicker
|
||||
value={newEvent().end_time ? new Date(newEvent().end_time) : undefined}
|
||||
onChange={(date) => {
|
||||
if (date) {
|
||||
setNewEvent({ ...newEvent(), end_time: date.toISOString() });
|
||||
<DateRangePicker
|
||||
value={newEvent().start_time ? { start: new Date(newEvent().start_time), end: new Date(newEvent().end_time || newEvent().start_time) } : undefined}
|
||||
onChange={(range) => {
|
||||
if (range && range.start) {
|
||||
setNewEvent({ ...newEvent(), end_time: range.end ? range.end.toISOString() : range.start.toISOString() });
|
||||
}
|
||||
}}
|
||||
placeholder="Select end time"
|
||||
@@ -913,22 +918,22 @@ export function Calendar() {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
onClick={() => setShowEventModal(false)}
|
||||
class="flex-1 px-4 py-2 border border-border rounded-lg hover:bg-accent transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={createEvent}
|
||||
disabled={!newEvent().title || !newEvent().start_time}
|
||||
class="flex-1 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Create Event
|
||||
</button>
|
||||
<div class="flex gap-2 mt-4">
|
||||
<button
|
||||
onClick={() => setShowEventModal(false)}
|
||||
class="flex-1 px-4 py-2 border border-border rounded-lg hover:bg-accent transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={createEvent}
|
||||
disabled={!newEvent().title || !newEvent().start_time}
|
||||
class="flex-1 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Create Event
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,9 @@ import {
|
||||
FileText as FileTextIcon,
|
||||
Sparkles,
|
||||
ChevronDown,
|
||||
Settings
|
||||
Settings,
|
||||
Trash,
|
||||
User
|
||||
} from 'lucide-solid'
|
||||
|
||||
interface ChatMessage {
|
||||
@@ -602,7 +604,7 @@ const Chat = () => {
|
||||
}}
|
||||
class="opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<span class="h-4 w-4">🗑️</span>
|
||||
<Trash class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -706,7 +708,7 @@ const Chat = () => {
|
||||
message.role === 'user' ? 'bg-primary-foreground/20' : 'bg-primary/10'
|
||||
}`}>
|
||||
{message.role === 'user' ? (
|
||||
<span class="w-4 h-4 text-xs">👤</span>
|
||||
<User class="w-4 h-4 text-xs" />
|
||||
) : (
|
||||
<AIProviderIcon
|
||||
providerId={selectedModel()}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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;
|
||||
@@ -445,20 +446,11 @@ export const ColorSwitcher = () => {
|
||||
<label class="block text-sm font-medium text-muted-foreground mb-2">
|
||||
Primary Color
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={customColors().primary}
|
||||
onInput={(e) => setCustomColors(prev => ({ ...prev, primary: e.currentTarget.value }))}
|
||||
class="h-10 w-16 rounded border border-input"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={customColors().primary}
|
||||
onInput={(e) => setCustomColors(prev => ({ ...prev, primary: 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>
|
||||
<ColorPicker
|
||||
value={customColors().primary}
|
||||
onChange={(color) => setCustomColors(prev => ({ ...prev, primary: color }))}
|
||||
savedColors={['#5ab9ff', '#ff6b6b', '#4ecdc4', '#45b7d1', '#f9ca24', '#f0932b', '#eb4d4b', '#6ab04c']}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -571,28 +571,38 @@ export const Dashboard = () => {
|
||||
</div>
|
||||
<div class="relative flex items-end justify-between h-full gap-1 md:gap-2">
|
||||
{['M', 'T', 'W', 'T', 'F', 'S', 'S'].map((day, index) => {
|
||||
const weeklyActivity = stats().weeklyActivity || [12, 19, 8, 15, 22, 18, 25]; // Fallback data
|
||||
const weeklyActivity = stats().weeklyActivity;
|
||||
const activity = weeklyActivity[index];
|
||||
const maxActivity = Math.max(...weeklyActivity);
|
||||
// Use dynamic scale based on actual data
|
||||
const fixedMax = Math.max(maxActivity, 30); // Ensure minimum scale for better visualization
|
||||
const containerHeight = 128; // h-32 = 128px (base), md:h-36 = 144px
|
||||
const availableHeight = containerHeight * 0.75; // Use 75% of container height to leave room for labels
|
||||
const heightPercent = (activity / fixedMax) * (availableHeight / containerHeight) * 100;
|
||||
const minHeightPercent = (6 / containerHeight) * 100; // Minimum 6px height
|
||||
const finalHeightPercent = Math.max(heightPercent, minHeightPercent);
|
||||
const minActivity = Math.min(...weeklyActivity);
|
||||
|
||||
// Calculate responsive height with proper scaling
|
||||
let heightPercent;
|
||||
if (maxActivity === minActivity) {
|
||||
// All values are the same, use 80% height for consistency
|
||||
heightPercent = 80;
|
||||
} else {
|
||||
// Use the actual range for proportional scaling
|
||||
const range = maxActivity - minActivity;
|
||||
const normalizedValue = activity - minActivity;
|
||||
// Scale to 20-90% range to ensure visibility while maintaining proportions
|
||||
heightPercent = 20 + (normalizedValue / range) * 70;
|
||||
}
|
||||
|
||||
// Ensure minimum height for very small values but maintain proportion
|
||||
const finalHeightPercent = Math.max(heightPercent, 8);
|
||||
|
||||
return (
|
||||
<div class="flex flex-col items-center flex-1 gap-2 group min-w-0 max-w-4">
|
||||
<div class="relative w-full max-w-2 md:max-w-3 flex flex-col items-center">
|
||||
<div class="flex flex-col items-center flex-1 gap-2 group min-w-0 max-w-4 h-full">
|
||||
<div class="relative w-full max-w-2 md:max-w-3 flex flex-col items-center justify-end h-full">
|
||||
<span
|
||||
class="text-xs font-medium text-primary mb-1 opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap absolute -top-5"
|
||||
class="text-xs font-medium text-primary mb-1 opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap absolute -top-5 z-10"
|
||||
>
|
||||
{activity}
|
||||
</span>
|
||||
<div
|
||||
class="w-full max-w-2 md:max-w-3 bg-primary rounded-t transition-all duration-500 hover:opacity-80 cursor-pointer hover:scale-105 weekly-bar"
|
||||
style={`height: ${finalHeightPercent}%; background-color: hsl(199, 89%, 67%); min-height: 6px;`}
|
||||
style={`height: ${finalHeightPercent}%; background-color: hsl(199, 89%, 67%); min-height: 4px;`}
|
||||
title={`${day}: ${activity} activities`}
|
||||
></div>
|
||||
</div>
|
||||
@@ -605,8 +615,8 @@ export const Dashboard = () => {
|
||||
|
||||
{/* Weekly summary */}
|
||||
<div class="flex justify-between text-xs text-muted-foreground pt-2 border-t border-border">
|
||||
<span>Total: {(stats().weeklyActivity || [12, 19, 8, 15, 22, 18, 25]).reduce((a, b) => a + b, 0)} activities</span>
|
||||
<span>Avg: {Math.round((stats().weeklyActivity || [12, 19, 8, 15, 22, 18, 25]).reduce((a, b) => a + b, 0) / 7)} per day</span>
|
||||
<span>Total: {stats().weeklyActivity.reduce((a, b) => a + b, 0)} activities</span>
|
||||
<span>Avg: {Math.round(stats().weeklyActivity.reduce((a, b) => a + b, 0) / 7)} per day</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createSignal, onMount, For, Show } from 'solid-js';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { SearchTagFilterBar } from '@/components/ui/SearchTagFilterBar';
|
||||
import { FileUploadModal } from '@/components/ui/FileUploadModal';
|
||||
import { FileUpload } from '@/components/ui/FileUpload';
|
||||
import { FilePreviewModal } from '@/components/ui/FilePreviewModal';
|
||||
import { getFileTypeConfig, formatFileSize, getFileCategoryColor } from '@/utils/fileTypes';
|
||||
import { getMockFiles } from '@/lib/mockData';
|
||||
@@ -218,28 +218,28 @@ export const Files = () => {
|
||||
};
|
||||
|
||||
|
||||
const handleFileUpload = async (fileData: any) => {
|
||||
const handleFileUpload = async (uploadedFiles: any[]) => {
|
||||
try {
|
||||
// Mock upload - in real app, this would be an API call
|
||||
const newFile: FileItem = {
|
||||
id: Date.now(),
|
||||
name: fileData.file?.name || fileData.linkUrl?.split('/').pop() || 'Untitled',
|
||||
size: fileData.file?.size || 0,
|
||||
type: fileData.file?.type || 'application/octet-stream',
|
||||
// Convert uploaded files to FileItem format
|
||||
const newFiles: FileItem[] = uploadedFiles.map((fileData) => ({
|
||||
id: Date.now() + Math.random(),
|
||||
name: fileData.name || 'Untitled',
|
||||
size: fileData.size || 0,
|
||||
type: fileData.type || 'application/octet-stream',
|
||||
uploadedAt: new Date().toISOString(),
|
||||
description: fileData.description,
|
||||
tags: fileData.tags,
|
||||
associations: fileData.associations,
|
||||
url: fileData.linkUrl,
|
||||
isLink: fileData.isLinkMode,
|
||||
downloadUrl: fileData.isLinkMode ? fileData.linkUrl : `/files/download/${Date.now()}`,
|
||||
viewUrl: fileData.isLinkMode ? fileData.linkUrl : `/files/view/${Date.now()}`,
|
||||
description: '',
|
||||
tags: [],
|
||||
url: fileData.url,
|
||||
isLink: !!fileData.url,
|
||||
downloadUrl: fileData.url || `/files/download/${Date.now()}`,
|
||||
viewUrl: fileData.url || `/files/view/${Date.now()}`,
|
||||
shareUrl: `/files/share/${Date.now()}`
|
||||
};
|
||||
}));
|
||||
|
||||
setFiles(prev => [newFile, ...prev]);
|
||||
setFiles(prev => [...newFiles, ...prev]);
|
||||
setShowUploadModal(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to upload file:', error);
|
||||
console.error('Failed to upload files:', error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -493,10 +493,12 @@ export const Files = () => {
|
||||
)}
|
||||
|
||||
{/* File Upload Modal */}
|
||||
<FileUploadModal
|
||||
<FileUpload
|
||||
isOpen={showUploadModal()}
|
||||
onClose={() => setShowUploadModal(false)}
|
||||
onUpload={handleFileUpload}
|
||||
onFilesChange={handleFileUpload}
|
||||
maxFileSize={50}
|
||||
acceptedTypes={['image/jpeg', 'image/png', 'application/pdf', 'video/mp4']}
|
||||
/>
|
||||
|
||||
{/* File Preview Modal */}
|
||||
|
||||
@@ -394,9 +394,9 @@ export const GitHub = () => {
|
||||
</div>
|
||||
|
||||
{/* Two-way Grid: Contribution Graph and Languages - Responsive */}
|
||||
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||
{/* Contribution Graph - Left Column (2/3 width on large screens) */}
|
||||
<div class="xl:w-2/3">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Contribution Graph - Left Column (larger) */}
|
||||
<div class="lg:col-span-1">
|
||||
<GitHubActivity
|
||||
title="Contribution Activity"
|
||||
showStats={false}
|
||||
@@ -409,8 +409,8 @@ export const GitHub = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Languages - Right Column (1/3 width on large screens) */}
|
||||
<Card class="p-6 xl:w-1/3">
|
||||
{/* Languages - Right Column (smaller) */}
|
||||
<Card class="p-6 lg:col-span-1">
|
||||
<h3 class="text-lg font-semibold text-foreground mb-4">Languages</h3>
|
||||
<div class="space-y-3">
|
||||
{githubStats().languages.map((language) => (
|
||||
@@ -457,9 +457,9 @@ export const GitHub = () => {
|
||||
const finalHeightPercent = Math.max(heightPercent, minHeightPercent);
|
||||
|
||||
return (
|
||||
<div class="flex flex-col items-center flex-1 gap-2 group min-w-0 max-w-8">
|
||||
<div class="relative w-full max-w-4 md:max-w-5 flex flex-col items-center">
|
||||
<span class="text-xs font-medium text-primary mb-1 opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap absolute -top-5">
|
||||
<div class="flex flex-col items-center flex-1 gap-2 group min-w-0 max-w-8 h-full">
|
||||
<div class="relative w-full max-w-4 md:max-w-5 flex flex-col items-center justify-end h-full">
|
||||
<span class="text-xs font-medium text-primary mb-1 opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap absolute -top-5 z-10">
|
||||
{activity}
|
||||
</span>
|
||||
<div
|
||||
|
||||
@@ -118,7 +118,7 @@ export const LearningPaths = () => {
|
||||
}
|
||||
|
||||
// Fetch categories
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:9090/api/v1';
|
||||
const categoriesResponse = await fetch(`${API_BASE_URL}/learning-paths/categories`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
@@ -214,7 +214,7 @@ export const LearningPaths = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:9090/api/v1';
|
||||
const response = await fetch(`${API_BASE_URL}/learning-paths/${pathId}/enroll`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -364,16 +364,12 @@ export const LearningPaths = () => {
|
||||
Featured
|
||||
</div>
|
||||
)}
|
||||
<img
|
||||
src={path.thumbnail}
|
||||
alt={path.title}
|
||||
class="w-full h-full object-cover filter grayscale"
|
||||
onError={(e) => {
|
||||
const target = e.currentTarget;
|
||||
target.src = `https://placehold.co/600x400/1e293b/ffffff?text=${encodeURIComponent(path.category)}`;
|
||||
}}
|
||||
/>
|
||||
<div class="absolute inset-0 bg-black/20 group-hover:bg-black/10 transition-colors"></div>
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<div class="w-16 h-16 bg-blue-500/20 rounded-full flex items-center justify-center">
|
||||
<IconBook class="size-8 text-blue-400" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute bottom-0 left-0 right-0 h-20 bg-gradient-to-t from-[#262626] to-transparent"></div>
|
||||
<div class="absolute bottom-4 left-4 right-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
{getCategoryIcon(path.category)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createSignal, onMount, For, Show } from 'solid-js';
|
||||
import { createSignal, createEffect, onMount, For, Show } from 'solid-js';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { SearchTagFilterBar } from '@/components/ui/SearchTagFilterBar';
|
||||
@@ -73,6 +73,8 @@ const renderMarkdownPreviewHtml = (content: string, maxBlocks = 4): string => {
|
||||
.replace(/\*(.*?)\*/g, '<em class="italic">$1<\/em>')
|
||||
.replace(/`(.*?)`/g, '<code class="bg-[#262626] px-1 py-0.5 rounded text-xs">$1<\/code>')
|
||||
.replace(/```(.*?)\n([\s\S]*?)```/g, '<pre class="bg-[#262626] p-3 rounded mb-2 overflow-x-auto"><code class="text-xs">$2<\/code><\/pre>')
|
||||
.replace(/^- \[ \] (.*$)/gim, '<div class="flex items-center gap-2 mb-1"><input type="checkbox" class="note-checkbox" style="width: 16px; height: 16px; cursor: pointer; accent-color: #3b82f6;" onclick="this.checked=!this.checked" onchange="this.parentElement.nextElementSibling.textContent=this.checked?\'x\':\' \'"><span class="text-xs">$1</span></div>')
|
||||
.replace(/^- \[x\] (.*$)/gim, '<div class="flex items-center gap-2 mb-1"><input type="checkbox" checked class="note-checkbox" style="width: 16px; height: 16px; cursor: pointer; accent-color: #3b82f6;" onclick="this.checked=!this.checked" onchange="this.parentElement.nextElementSibling.textContent=this.checked?\'x\':\' \'"><span class="text-xs">$1</span></div>')
|
||||
.replace(/^- (.*$)/gim, '<li class="ml-4 list-disc">$1<\/li>')
|
||||
.replace(/^\d+\. (.*$)/gim, '<li class="ml-4 list-decimal">$1<\/li>')
|
||||
.replace(/> (.*$)/gim, '<blockquote class="border-l-4 border-[#444] pl-3 italic text-[#aaa] mb-2">$1<\/blockquote>')
|
||||
@@ -89,6 +91,8 @@ const renderPlainTextPreviewHtml = (content: string): string => {
|
||||
.replace(/(https?:\/\/[^\s]+)/g, '<a href="$1" class="text-blue-400 hover:underline" target="_blank" rel="noopener noreferrer">$1<\/a>')
|
||||
.replace(/\*\*(.*?)\*\*/g, '<strong class="font-semibold">$1<\/strong>')
|
||||
.replace(/\*(.*?)\*/g, '<em class="italic">$1<\/em>')
|
||||
.replace(/^- \[ \] (.*$)/gim, '<div class="flex items-center gap-2 mb-1"><input type="checkbox" class="note-checkbox" style="width: 16px; height: 16px; cursor: pointer; accent-color: #3b82f6;" onclick="this.checked=!this.checked"><span class="text-xs">$1</span></div>')
|
||||
.replace(/^- \[x\] (.*$)/gim, '<div class="flex items-center gap-2 mb-1"><input type="checkbox" checked class="note-checkbox" style="width: 16px; height: 16px; cursor: pointer; accent-color: #3b82f6;" onclick="this.checked=!this.checked"><span class="text-xs">$1</span></div>')
|
||||
.split('\n')
|
||||
.slice(0, 6)
|
||||
.map((line) => (line ? line : '<br \/>'))
|
||||
@@ -313,8 +317,86 @@ export const Notes = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// Add this function to handle checkbox changes
|
||||
const updateNoteCheckbox = (noteId: number, checkboxIndex: number, isChecked: boolean) => {
|
||||
setNotes(prev => prev.map(note => {
|
||||
if (note.id === noteId) {
|
||||
const lines = note.content.split('\n');
|
||||
let checkboxCount = 0;
|
||||
|
||||
const updatedLines = lines.map(line => {
|
||||
const uncheckedMatch = line.match(/^- \[ \] (.*)$/);
|
||||
const checkedMatch = line.match(/^- \[x\] (.*)$/);
|
||||
|
||||
if (uncheckedMatch || checkedMatch) {
|
||||
if (checkboxCount === checkboxIndex) {
|
||||
const text = uncheckedMatch ? uncheckedMatch[1] : (checkedMatch ? checkedMatch[1] : '');
|
||||
return isChecked ? `- [x] ${text}` : `- [ ] ${text}`;
|
||||
}
|
||||
checkboxCount++;
|
||||
}
|
||||
return line;
|
||||
});
|
||||
|
||||
return {
|
||||
...note,
|
||||
content: updatedLines.join('\n'),
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
return note;
|
||||
}));
|
||||
};
|
||||
|
||||
// Handler for updating note content from ViewNoteModal
|
||||
const handleUpdateNoteContent = (noteId: number, content: string) => {
|
||||
setNotes(prev => prev.map(note =>
|
||||
note.id === noteId
|
||||
? { ...note, content, updatedAt: new Date().toISOString() }
|
||||
: note
|
||||
));
|
||||
};
|
||||
|
||||
// Make the function available globally for checkbox onchange handlers
|
||||
createEffect(() => {
|
||||
(window as any).updateNoteContent = (checkbox: HTMLInputElement) => {
|
||||
const noteElement = checkbox.closest('[data-note-id]');
|
||||
if (noteElement) {
|
||||
const noteId = parseInt(noteElement.getAttribute('data-note-id') || '0');
|
||||
const checkboxElements = noteElement.querySelectorAll('input[type="checkbox"]');
|
||||
const checkboxIndex = Array.from(checkboxElements).indexOf(checkbox);
|
||||
updateNoteCheckbox(noteId, checkboxIndex, checkbox.checked);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="p-6 space-y-6">
|
||||
<style>
|
||||
{`
|
||||
.note-checkbox {
|
||||
width: 16px !important;
|
||||
height: 16px !important;
|
||||
cursor: pointer !important;
|
||||
accent-color: #3b82f6 !important;
|
||||
border: 2px solid #4b5563 !important;
|
||||
border-radius: 3px !important;
|
||||
transition: all 0.2s ease !important;
|
||||
}
|
||||
.note-checkbox:hover {
|
||||
border-color: #3b82f6 !important;
|
||||
transform: scale(1.1) !important;
|
||||
}
|
||||
.note-checkbox:checked {
|
||||
background-color: #3b82f6 !important;
|
||||
border-color: #3b82f6 !important;
|
||||
}
|
||||
.note-checkbox:focus {
|
||||
outline: 2px solid #3b82f6 !important;
|
||||
outline-offset: 2px !important;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<div class="flex justify-between items-center">
|
||||
<h1 class="text-3xl font-bold text-[#fafafa]">Notes</h1>
|
||||
<Button onClick={() => setShowAddModal(true)}>
|
||||
@@ -356,6 +438,7 @@ export const Notes = () => {
|
||||
<div class="space-y-4">
|
||||
{filteredNotes().map((note) => (
|
||||
<Card
|
||||
data-note-id={note.id}
|
||||
class={`p-6 cursor-pointer transition-all hover:shadow-lg hover:bg-[#1a1a1a] ${note.pinned ? 'border-l-4 border-l-primary' : ''}`}
|
||||
onClick={() => viewNote(note)}
|
||||
>
|
||||
@@ -458,6 +541,8 @@ export const Notes = () => {
|
||||
.replace(/\*(.*?)\*/g, '<em class="italic">$1</em>')
|
||||
.replace(/`(.*?)`/g, '<code class="bg-[#262626] px-1 py-0.5 rounded text-xs">$1</code>')
|
||||
.replace(/```(.*?)\n([\s\S]*?)```/g, '<pre class="bg-[#262626] p-3 rounded mb-2 overflow-x-auto"><code class="text-xs">$2</code></pre>')
|
||||
.replace(/^- \[ \] (.*$)/gim, '<div class="flex items-center gap-2 mb-1"><input type="checkbox" class="note-checkbox" style="width: 16px; height: 16px; cursor: pointer; accent-color: #3b82f6;" onclick="this.checked=!this.checked" onchange="updateNoteContent(this)"><span class="text-sm">$1</span></div>')
|
||||
.replace(/^- \[x\] (.*$)/gim, '<div class="flex items-center gap-2 mb-1"><input type="checkbox" checked class="note-checkbox" style="width: 16px; height: 16px; cursor: pointer; accent-color: #3b82f6;" onclick="this.checked=!this.checked" onchange="updateNoteContent(this)"><span class="text-sm">$1</span></div>')
|
||||
.replace(/^- (.*$)/gim, '<li class="ml-4 list-disc">$1</li>')
|
||||
.replace(/^\d+\. (.*$)/gim, '<li class="ml-4 list-decimal">$1</li>')
|
||||
.replace(/> (.*$)/gim, '<blockquote class="border-l-4 border-[#444] pl-3 italic text-[#aaa] mb-2">$1</blockquote>')
|
||||
@@ -466,6 +551,8 @@ export const Notes = () => {
|
||||
: note.content.replace(/(https?:\/\/[^\s]+)/g, '<a href="$1" class="text-blue-400 hover:underline" target="_blank" rel="noopener noreferrer">$1</a>')
|
||||
.replace(/\*\*(.*?)\*\*/g, '<strong class="font-semibold">$1</strong>')
|
||||
.replace(/\*(.*?)\*/g, '<em class="italic">$1</em>')
|
||||
.replace(/^- \[ \] (.*$)/gim, '<div class="flex items-center gap-2 mb-1"><input type="checkbox" class="note-checkbox" style="width: 16px; height: 16px; cursor: pointer; accent-color: #3b82f6;" onclick="this.checked=!this.checked" onchange="updateNoteContent(this)"><span class="text-sm">$1</span></div>')
|
||||
.replace(/^- \[x\] (.*$)/gim, '<div class="flex items-center gap-2 mb-1"><input type="checkbox" checked class="note-checkbox" style="width: 16px; height: 16px; cursor: pointer; accent-color: #3b82f6;" onclick="this.checked=!this.checked" onchange="updateNoteContent(this)"><span class="text-sm">$1</span></div>')
|
||||
.split('\n').map((line) => line ? `<p class="mb-2">${line}</p>` : '<br />').join('')
|
||||
}
|
||||
/>
|
||||
@@ -570,6 +657,7 @@ export const Notes = () => {
|
||||
onDelete={deleteNote}
|
||||
onCopyContent={copyNoteContent}
|
||||
onExportNote={exportNote}
|
||||
onUpdateNote={handleUpdateNoteContent}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
+137
-68
@@ -4,6 +4,7 @@ import { IconUser, IconLock, IconTrash, IconKey, IconBrain, IconMail, IconSend,
|
||||
import { TwoFactorAuth } from '@/components/TwoFactorAuth';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { AIProviderIcon } from '@/components/AIProviderIcon';
|
||||
import { ColorPicker } from '@/components/ui/ColorPicker';
|
||||
|
||||
export const Settings = () => {
|
||||
const { authState, updateProfile, changePassword } = useAuth();
|
||||
@@ -14,12 +15,24 @@ export const Settings = () => {
|
||||
theme: 'dark',
|
||||
showBrowserSearch: true
|
||||
});
|
||||
const [customColors, setCustomColors] = createSignal({
|
||||
primary: '#5ab9ff',
|
||||
background: '#000000',
|
||||
foreground: '#ffffff',
|
||||
muted: '#262727',
|
||||
border: '#262626'
|
||||
});
|
||||
const [passwordData, setPasswordData] = createSignal({
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
});
|
||||
const [aiSettingsExpanded, setAISettingsExpanded] = createSignal(true);
|
||||
const [showMistralKey, setShowMistralKey] = createSignal(false);
|
||||
const [showLongcatKey, setShowLongcatKey] = createSignal(false);
|
||||
const [showGrokKey, setShowGrokKey] = createSignal(false);
|
||||
const [showDeepseekKey, setShowDeepseekKey] = createSignal(false);
|
||||
const [showOpenrouterKey, setShowOpenrouterKey] = createSignal(false);
|
||||
const [aiSettings, setAISettings] = createSignal({
|
||||
mistral: { enabled: false, api_key: '', model: 'mistral-small-latest', model_thinking: 'mistral-large-latest' },
|
||||
grok: { enabled: false, api_key: '', base_url: 'https://api.x.ai/v1', model: 'grok-4-1-fast-non-reasoning-latest', model_thinking: 'grok-4-1-fast-reasoning-latest' },
|
||||
@@ -350,6 +363,17 @@ export const Settings = () => {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<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="flex items-center gap-2 text-sm font-medium text-muted-foreground mb-2">
|
||||
<input
|
||||
@@ -485,14 +509,14 @@ export const Settings = () => {
|
||||
const totalAvailable = availableAIProviders().length || Object.keys(settings).length;
|
||||
|
||||
if (totalAvailable === 0) {
|
||||
return '⚠️ No AI providers are available on the server. Check backend AI configuration.';
|
||||
return 'No AI providers are available on the server. Check backend AI configuration.';
|
||||
}
|
||||
|
||||
if (enabledCount === 0) {
|
||||
return '⚠️ Providers are available but none are enabled. Enable at least one provider below.';
|
||||
return 'Providers are available but none are enabled. Enable at least one provider below.';
|
||||
}
|
||||
|
||||
return `✅ AI is ready. ${enabledCount} provider${enabledCount > 1 ? 's' : ''} enabled.`;
|
||||
return `AI is ready. ${enabledCount} provider${enabledCount > 1 ? 's' : ''} enabled.`;
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
@@ -647,19 +671,28 @@ export const Settings = () => {
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-muted-foreground mb-1">API Key</label>
|
||||
<input
|
||||
type="password"
|
||||
value={aiSettings().mistral.api_key}
|
||||
onInput={(e) => {
|
||||
const settings = aiSettings();
|
||||
setAISettings({
|
||||
...settings,
|
||||
mistral: { ...settings.mistral, api_key: e.currentTarget.value }
|
||||
});
|
||||
}}
|
||||
placeholder="Enter Mistral API key"
|
||||
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 class="relative">
|
||||
<input
|
||||
type={showMistralKey() ? "text" : "password"}
|
||||
value={aiSettings().mistral.api_key}
|
||||
onInput={(e) => {
|
||||
const settings = aiSettings();
|
||||
setAISettings({
|
||||
...settings,
|
||||
mistral: { ...settings.mistral, api_key: e.currentTarget.value }
|
||||
});
|
||||
}}
|
||||
placeholder="Enter Mistral API key"
|
||||
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 pr-10 text-sm text-foreground focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowMistralKey(!showMistralKey())}
|
||||
class="absolute right-2 top-1/2 transform -translate-y-1/2 text-muted-foreground hover:text-foreground focus:outline-none"
|
||||
>
|
||||
<IconKey class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
@@ -740,19 +773,28 @@ export const Settings = () => {
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-muted-foreground mb-1">API Key</label>
|
||||
<input
|
||||
type="password"
|
||||
value={aiSettings().longcat.api_key}
|
||||
onInput={(e) => {
|
||||
const settings = aiSettings();
|
||||
setAISettings({
|
||||
...settings,
|
||||
longcat: { ...settings.longcat, api_key: e.currentTarget.value }
|
||||
});
|
||||
}}
|
||||
placeholder="Enter LongCat API key"
|
||||
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 class="relative">
|
||||
<input
|
||||
type={showLongcatKey() ? "text" : "password"}
|
||||
value={aiSettings().longcat.api_key}
|
||||
onInput={(e) => {
|
||||
const settings = aiSettings();
|
||||
setAISettings({
|
||||
...settings,
|
||||
longcat: { ...settings.longcat, api_key: e.currentTarget.value }
|
||||
});
|
||||
}}
|
||||
placeholder="Enter LongCat API key"
|
||||
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 pr-10 text-sm text-foreground focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowLongcatKey(!showLongcatKey())}
|
||||
class="absolute right-2 top-1/2 transform -translate-y-1/2 text-muted-foreground hover:text-foreground focus:outline-none"
|
||||
>
|
||||
<IconKey class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
@@ -923,19 +965,28 @@ export const Settings = () => {
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-muted-foreground mb-1">API Key</label>
|
||||
<input
|
||||
type="password"
|
||||
value={aiSettings().grok.api_key}
|
||||
onInput={(e) => {
|
||||
const settings = aiSettings();
|
||||
setAISettings({
|
||||
...settings,
|
||||
grok: { ...settings.grok, api_key: e.currentTarget.value }
|
||||
});
|
||||
}}
|
||||
placeholder="Enter Grok API key"
|
||||
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 class="relative">
|
||||
<input
|
||||
type={showGrokKey() ? "text" : "password"}
|
||||
value={aiSettings().grok.api_key}
|
||||
onInput={(e) => {
|
||||
const settings = aiSettings();
|
||||
setAISettings({
|
||||
...settings,
|
||||
grok: { ...settings.grok, api_key: e.currentTarget.value }
|
||||
});
|
||||
}}
|
||||
placeholder="Enter Grok API key"
|
||||
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 pr-10 text-sm text-foreground focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowGrokKey(!showGrokKey())}
|
||||
class="absolute right-2 top-1/2 transform -translate-y-1/2 text-muted-foreground hover:text-foreground focus:outline-none"
|
||||
>
|
||||
<IconKey class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -1033,19 +1084,28 @@ export const Settings = () => {
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-muted-foreground mb-1">API Key</label>
|
||||
<input
|
||||
type="password"
|
||||
value={aiSettings().deepseek.api_key}
|
||||
onInput={(e) => {
|
||||
const settings = aiSettings();
|
||||
setAISettings({
|
||||
...settings,
|
||||
deepseek: { ...settings.deepseek, api_key: e.currentTarget.value }
|
||||
});
|
||||
}}
|
||||
placeholder="Enter DeepSeek API key"
|
||||
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 class="relative">
|
||||
<input
|
||||
type={showDeepseekKey() ? "text" : "password"}
|
||||
value={aiSettings().deepseek.api_key}
|
||||
onInput={(e) => {
|
||||
const settings = aiSettings();
|
||||
setAISettings({
|
||||
...settings,
|
||||
deepseek: { ...settings.deepseek, api_key: e.currentTarget.value }
|
||||
});
|
||||
}}
|
||||
placeholder="Enter DeepSeek API key"
|
||||
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 pr-10 text-sm text-foreground focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDeepseekKey(!showDeepseekKey())}
|
||||
class="absolute right-2 top-1/2 transform -translate-y-1/2 text-muted-foreground hover:text-foreground focus:outline-none"
|
||||
>
|
||||
<IconKey class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -1236,19 +1296,28 @@ export const Settings = () => {
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-muted-foreground mb-1">API Key</label>
|
||||
<input
|
||||
type="password"
|
||||
value={aiSettings().openrouter.api_key}
|
||||
onInput={(e) => {
|
||||
const settings = aiSettings();
|
||||
setAISettings({
|
||||
...settings,
|
||||
openrouter: { ...settings.openrouter, api_key: e.currentTarget.value }
|
||||
});
|
||||
}}
|
||||
placeholder="Enter OpenRouter API key"
|
||||
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 class="relative">
|
||||
<input
|
||||
type={showOpenrouterKey() ? "text" : "password"}
|
||||
value={aiSettings().openrouter.api_key}
|
||||
onInput={(e) => {
|
||||
const settings = aiSettings();
|
||||
setAISettings({
|
||||
...settings,
|
||||
openrouter: { ...settings.openrouter, api_key: e.currentTarget.value }
|
||||
});
|
||||
}}
|
||||
placeholder="Enter OpenRouter API key"
|
||||
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 pr-10 text-sm text-foreground focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowOpenrouterKey(!showOpenrouterKey())}
|
||||
class="absolute right-2 top-1/2 transform -translate-y-1/2 text-muted-foreground hover:text-foreground focus:outline-none"
|
||||
>
|
||||
<IconKey class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { ActivityFeed } from '@/components/ui/ActivityFeed';
|
||||
import { getMockStats, getMockActivities } from '@/lib/mockData';
|
||||
import { formatDuration } from '@/lib/timeFormat';
|
||||
import { isDemoMode } from '@/lib/demo-mode';
|
||||
|
||||
interface ActivityData {
|
||||
date: string;
|
||||
@@ -119,6 +120,12 @@ export const Stats = () => {
|
||||
return graph;
|
||||
};
|
||||
|
||||
// Create test data with varied values to verify height calculations
|
||||
const testWeeklyActivity = [8, 22, 15, 31, 18, 25, 12]; // Fixed test values
|
||||
|
||||
// Use demo mode data if available, otherwise use test data
|
||||
const weeklyActivityData = isDemoMode() ? mockStats.weeklyActivity : testWeeklyActivity;
|
||||
|
||||
// Set stats using mock data
|
||||
setStats({
|
||||
totalBookmarks: mockStats.totalBookmarks,
|
||||
@@ -129,7 +136,7 @@ export const Stats = () => {
|
||||
activeTasks: mockStats.activeTasks,
|
||||
storageUsed: mockStats.totalSize,
|
||||
storageTotal: '50 GB',
|
||||
weeklyActivity: [12, 19, 8, 15, 25, 6, 14], // Enhanced mock data for better visualization
|
||||
weeklyActivity: weeklyActivityData, // Use demo mode or test data
|
||||
monthlyGrowth: mockStats.monthlyGrowth,
|
||||
topCategories: [
|
||||
{ name: 'Work', count: 45, color: 'hsl(var(--primary))' },
|
||||
@@ -157,11 +164,24 @@ export const Stats = () => {
|
||||
};
|
||||
return (
|
||||
<div class="p-6 mt-4 pb-32 max-w-5xl mx-auto space-y-6">
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">Statistics & Activity</h1>
|
||||
<p class="text-muted-foreground mt-2">Track your productivity, growth, and activity over time</p>
|
||||
</div>
|
||||
|
||||
{/* Demo Mode Indicator */}
|
||||
<Show when={isDemoMode()}>
|
||||
<div class="bg-yellow-100 dark:bg-yellow-900/20 border border-yellow-300 dark:border-yellow-800 rounded-lg p-3">
|
||||
<p class="text-yellow-800 dark:text-yellow-200 text-sm font-medium">
|
||||
Demo Mode Active - Showing sample data
|
||||
</p>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-start">
|
||||
<div></div>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -398,52 +418,52 @@ export const Stats = () => {
|
||||
/>
|
||||
|
||||
{/* Weekly Activity Chart */}
|
||||
<div class="border rounded-lg p-6">
|
||||
<div class="border rounded-lg p-4 sm:p-6">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<IconActivity class="size-5 text-primary" />
|
||||
<h3 class="text-lg font-semibold">Weekly Activity</h3>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<div class="relative h-32 md:h-36 px-6 weekly-activity-chart">
|
||||
<div class="relative h-32 sm:h-36 md:h-40 lg:h-44 px-4 sm:px-6 weekly-activity-chart">
|
||||
<div class="absolute inset-x-0 inset-y-2 pointer-events-none flex flex-col justify-between">
|
||||
<div class="border-t border-border/60"></div>
|
||||
<div class="border-t border-border/40"></div>
|
||||
<div class="border-t border-border/30"></div>
|
||||
<div class="border-t border-border/20"></div>
|
||||
</div>
|
||||
<div class="relative flex items-end justify-between h-full gap-3 md:gap-4">
|
||||
<div class="relative flex items-end justify-between h-full gap-1 sm:gap-2">
|
||||
{['M', 'T', 'W', 'T', 'F', 'S', 'S'].map((day, index) => {
|
||||
const weeklyActivity = stats().weeklyActivity || [12, 19, 8, 15, 22, 18, 25]; // Fallback data
|
||||
const weeklyActivity = stats().weeklyActivity;
|
||||
const activity = weeklyActivity[index];
|
||||
const maxActivity = Math.max(...weeklyActivity);
|
||||
// Use dynamic scale based on actual data
|
||||
const fixedMax = Math.max(maxActivity, 30); // Ensure minimum scale for better visualization
|
||||
const containerHeight = 128; // h-32 = 128px (base), md:h-36 = 144px
|
||||
const availableHeight = containerHeight * 0.75; // Use 75% of container height to leave room for labels
|
||||
const heightPercent = (activity / fixedMax) * (availableHeight / containerHeight) * 100;
|
||||
const minHeightPercent = (8 / containerHeight) * 100; // Minimum 8px height
|
||||
const finalHeightPercent = Math.max(heightPercent, minHeightPercent);
|
||||
// Dynamic scale: use the highest value as the scale, with minimum of 25 for better visualization
|
||||
const scaleMax = Math.max(maxActivity, 25);
|
||||
// Calculate height percentage (use 85% of available height to leave room for labels)
|
||||
const heightPercent = (activity / scaleMax) * 85;
|
||||
// Ensure minimum height for visibility
|
||||
const finalHeightPercent = Math.max(heightPercent, 5);
|
||||
|
||||
return (
|
||||
<div class="flex flex-col items-center flex-1 gap-2 group min-w-0 max-w-8">
|
||||
<div class="relative w-full max-w-4 md:max-w-5 flex flex-col items-center">
|
||||
<span class="text-xs font-medium text-primary mb-1 opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap absolute -top-5">
|
||||
<div class="flex flex-col items-center flex-1 gap-2 group min-w-0 h-full">
|
||||
<div class="relative w-full max-w-2 sm:max-w-3 md:max-w-4 flex flex-col items-center justify-end h-full">
|
||||
<span class="text-xs font-medium text-primary mb-1 opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap absolute -top-5 z-10 bg-background px-1 rounded shadow-sm">
|
||||
{activity}
|
||||
</span>
|
||||
<div
|
||||
class="w-full max-w-4 md:max-w-5 bg-primary rounded-t transition-all duration-500 hover:opacity-80 cursor-pointer hover:scale-105 weekly-bar"
|
||||
style={`height: ${finalHeightPercent}%; background-color: hsl(199, 89%, 67%); min-height: 8px;`}
|
||||
title={`${day}: ${activity} activities`}
|
||||
class="w-full max-w-2 sm:max-w-3 md:max-w-4 bg-primary rounded-t transition-all duration-500 hover:opacity-80 cursor-pointer hover:scale-105 weekly-bar"
|
||||
style={`height: ${finalHeightPercent}%; min-height: 4px;`}
|
||||
title={`${day}: ${activity} activities (${finalHeightPercent.toFixed(1)}%)`}
|
||||
></div>
|
||||
</div>
|
||||
<span class="text-xs text-muted-foreground font-medium mt-1">{day}</span>
|
||||
<span class="text-xs text-muted-foreground font-medium mt-1 hidden sm:block">{day}</span>
|
||||
<span class="text-xs text-muted-foreground font-medium mt-1 sm:hidden">{day.charAt(0)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between text-xs text-muted-foreground pt-2 border-t border-border">
|
||||
<div class="flex flex-col sm:flex-row sm:justify-between text-xs text-muted-foreground pt-2 border-t border-border gap-1 sm:gap-0">
|
||||
<span>Total: {stats().weeklyActivity.reduce((a, b) => a + b, 0)} activities</span>
|
||||
<span>Avg: {Math.round(stats().weeklyActivity.reduce((a, b) => a + b, 0) / 7)} per day</span>
|
||||
</div>
|
||||
|
||||
@@ -144,6 +144,7 @@ export const Youtube = () => {
|
||||
const [editingChannel, setEditingChannel] = createSignal<FeaturedChannel | null>(null);
|
||||
const [successMessage, setSuccessMessage] = createSignal('');
|
||||
const [channelFilter, setChannelFilter] = createSignal('');
|
||||
const [predefinedVideosLoadTime, setPredefinedVideosLoadTime] = createSignal(0);
|
||||
|
||||
// Filter channels based on search query
|
||||
const filteredChannels = () => {
|
||||
@@ -218,7 +219,15 @@ export const Youtube = () => {
|
||||
|
||||
// Load predefined channel videos
|
||||
const loadPredefinedVideos = async () => {
|
||||
// Prevent duplicate calls if already loading or if called within last 2 seconds
|
||||
const now = Date.now();
|
||||
if (isLoadingPredefined() || (now - predefinedVideosLoadTime() < 2000)) {
|
||||
console.log('Skipping loadPredefinedVideos - already loading or called too recently');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoadingPredefined(true);
|
||||
setPredefinedVideosLoadTime(now);
|
||||
setPredefinedError('');
|
||||
|
||||
try {
|
||||
@@ -605,7 +614,7 @@ export const Youtube = () => {
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 text-white"
|
||||
class="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -621,7 +630,7 @@ export const Youtube = () => {
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 text-white"
|
||||
class="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -637,7 +646,7 @@ export const Youtube = () => {
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 text-white"
|
||||
class="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
|
||||
Reference in New Issue
Block a user