This commit is contained in:
Tomas Dvorak
2026-02-24 10:33:08 +01:00
parent b083dac3f0
commit 55d0284b2a
90 changed files with 27855 additions and 1940 deletions
+94 -165
View File
@@ -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
View File
@@ -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>
+252 -50
View File
@@ -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>
);
};
+54 -49
View File
@@ -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>
+5 -3
View File
@@ -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()}
+6 -14
View File
@@ -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>
+24 -14
View File
@@ -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>
+22 -20
View File
@@ -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 */}
+8 -8
View File
@@ -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
+8 -12
View File
@@ -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)}
+89 -1
View File
@@ -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
View File
@@ -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>
+41 -21
View File
@@ -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>
+12 -3
View File
@@ -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"