first test

This commit is contained in:
Tomas Dvorak
2026-02-08 14:14:55 +01:00
parent 18aa702174
commit d27cf14110
372 changed files with 98089 additions and 2585 deletions
+305
View File
@@ -0,0 +1,305 @@
import { createSignal, onMount } from 'solid-js';
import { Card } from '@/components/ui/Card';
import { IconBrain, IconFileText, IconChecklist, IconSparkles, IconRobot, IconSettings } from '@tabler/icons-solidjs';
import { AIProviderIcon } from '@/components/AIProviderIcon';
interface AIProvider {
id: string;
name: string;
description: string;
icon: string;
models: {
id: string;
name: string;
type: 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 tabs = [
{ id: 'dashboard', label: 'AI Dashboard', icon: IconBrain },
{ id: 'summarizer', label: 'Content Summarizer', icon: IconFileText },
{ id: 'tasks', label: 'Task Suggestions', icon: IconChecklist },
{ id: 'content', label: 'Content Generation', icon: IconSparkles },
{ 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);
}
});
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 */}
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
<IconRobot class="size-8 text-primary" />
AI Assistant
</h1>
<p class="text-gray-600 dark:text-gray-400 mt-2">
Leverage AI to enhance your productivity and content management
</p>
</div>
{enabledProviders().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>
</div>
)}
</div>
{/* Tabs */}
<div class="border-b border-gray-200 dark:border-gray-700">
<nav class="-mb-px flex space-x-8">
{tabs.map((tab) => (
<button
onClick={() => setActiveTab(tab.id as any)}
class={`flex items-center gap-2 py-2 px-1 border-b-2 font-medium text-sm ${
activeTab() === tab.id
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
<tab.icon class="size-5" />
{tab.label}
</button>
))}
</nav>
</div>
{/* Content */}
<div class="space-y-6">
{activeTab() === 'settings' && (
<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 */}
<div>
<h4 class="text-md font-medium text-gray-800 dark:text-gray-200 mb-3">Available Providers</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>
</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>
)}
</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>
</div>
</div>
)}
</div>
</Card>
)}
{activeTab() === 'dashboard' && (
<Card class="p-6 text-center">
<IconBrain class="size-12 text-primary mx-auto" />
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2 mt-4">
AI Dashboard
</h3>
<p class="text-gray-600 dark:text-gray-400">
AI Dashboard component temporarily disabled.
</p>
</Card>
)}
{activeTab() === 'summarizer' && (
<Card class="p-6 text-center">
<IconFileText class="size-12 text-primary mx-auto" />
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2 mt-4">
Content Summarizer
</h3>
<p class="text-gray-600 dark:text-gray-400">
Content Summarizer component temporarily disabled.
</p>
</Card>
)}
{activeTab() === 'tasks' && (
<Card class="p-6 text-center">
<IconChecklist class="size-12 text-primary mx-auto" />
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2 mt-4">
Task Suggestions
</h3>
<p class="text-gray-600 dark:text-gray-400">
AI-powered task suggestions based on your calendar, deadlines, and habits.
</p>
<p class="text-sm text-gray-500 mt-2">
View and manage suggestions from the AI Dashboard.
</p>
</Card>
)}
{activeTab() === 'content' && (
<Card class="p-6 text-center">
<IconSparkles class="size-12 text-primary mx-auto" />
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2 mt-4">
Content Generation
</h3>
<p class="text-gray-600 dark:text-gray-400">
Generate blog posts, code, emails, and more with AI assistance.
</p>
<p class="text-sm text-gray-500 mt-2">
Coming soon - Advanced AI content generation tools.
</p>
</Card>
)}
</div>
</div>
);
};
+719
View File
@@ -0,0 +1,719 @@
import { createSignal, For, Show, onMount } from 'solid-js'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Card } from '@/components/ui/Card'
import {
MessageCircle,
Brain,
Cog,
Send
} from 'lucide-solid'
import { AIProviderIcon } from '@/components/AIProviderIcon'
interface AIProvider {
id: string
name: string
description: string
icon: string
models: {
id: string
name: string
type: string
}[];
}
export const AIChat = () => {
const [activeView, setActiveView] = createSignal<'chat' | 'settings'>('chat')
const [isSidebarOpen, setIsSidebarOpen] = createSignal(true)
// Chat state
const [messages, setMessages] = createSignal<any[]>([
{
id: 1,
content: 'Hello! I\'m your AI assistant. How can I help you today?',
role: 'assistant',
created_at: new Date().toISOString()
}
])
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[]>([])
// 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
onMount(() => {
const checkMobile = () => {
if (window.innerWidth < 768) {
setIsSidebarOpen(false)
}
}
checkMobile()
window.addEventListener('resize', checkMobile)
// Fetch AI providers
fetchAIProviders()
// Load per-user AI provider settings
loadAISettings()
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 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'
}
})
if (response.ok) {
const data = await response.json()
setAISettings(data)
}
} catch (error) {
console.error('Failed to load AI settings:', error)
}
}
const handleUpdateAISettings = async () => {
setAiSettingsLoading(true)
setAiSettingsMessage('')
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')
}
} catch (error) {
console.error('Failed to update AI settings:', error)
setAiSettingsMessage('Failed to update AI settings')
} finally {
setAiSettingsLoading(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)
}
}
}
return (
<div class="h-full w-full flex flex-col bg-background">
{/* Header */}
<header class="border-b bg-card/95 backdrop-blur-sm z-10">
<div class="flex items-center justify-between px-4 py-3">
<div class="flex items-center gap-3">
<Button
variant="ghost"
size="sm"
onClick={() => setIsSidebarOpen(!isSidebarOpen())}
class="md:hidden"
>
<MessageCircle class="h-4 w-4" />
</Button>
{/* 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>
<div class="flex flex-col">
<h1 class="font-semibold text-lg">AI Assistant</h1>
<p class="text-sm text-muted-foreground">Your intelligent workspace companion</p>
</div>
</div>
</div>
{/* Model Switcher */}
<div class="flex items-center gap-3">
<select
value={selectedModel()}
onChange={(e) => setSelectedModel(e.target.value)}
class="px-3 py-2 text-sm border border-border rounded-md bg-background focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="standard">Standard Model</option>
<option value="advanced">Advanced Model</option>
<option value="fast">Fast Model</option>
<option value="creative">Creative Model</option>
</select>
{/* View Switcher */}
<div class="flex items-center gap-1 p-1 bg-muted rounded-lg">
<button
onClick={() => setActiveView('chat')}
class={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
activeView() === 'chat'
? 'bg-background shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
Chat
</button>
<button
onClick={() => setActiveView('settings')}
class={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
activeView() === 'settings'
? 'bg-background shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
Settings
</button>
</div>
</div>
</div>
</header>
<div class="flex flex-1 overflow-hidden">
{/* Sidebar */}
<Show when={isSidebarOpen()}>
<aside class="w-80 border-r bg-card flex flex-col hidden md:flex">
{/* Sidebar Header */}
<div class="p-4 border-b">
<div class="flex items-center justify-between">
<h2 class="font-semibold">Chat Sessions</h2>
<Button
variant="ghost"
size="sm"
onClick={() => setActiveView('settings')}
>
<Cog class="h-4 w-4" />
</Button>
</div>
</div>
{/* Sessions List */}
<div class="flex-1 overflow-y-auto p-4">
<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('')
}}
class="w-full justify-start"
variant="outline"
>
<MessageCircle class="h-4 w-4 mr-2" />
New Chat
</Button>
{/* Chat Sessions */}
<div class="space-y-2">
<div class="text-sm text-muted-foreground font-medium px-3 py-2">
Recent Chats
</div>
{[
{ id: '1', title: 'Getting Started', message_count: 2, last_message: '2 hours ago' },
{ id: '2', title: 'Project Planning', message_count: 5, last_message: '1 day ago' },
{ id: '3', title: 'Technical Discussion', message_count: 3, last_message: '2 days ago' }
].map(session => (
<button
class="w-full text-left p-3 rounded-lg hover:bg-muted transition-colors"
onClick={() => {
setMessages([{
id: 1,
content: `This is the ${session.title} session. How can I help you?`,
role: 'assistant',
created_at: new Date().toISOString()
}])
}}
>
<div class="flex items-center justify-between">
<div class="flex-1 min-w-0">
<h4 class="font-medium truncate">{session.title}</h4>
<p class="text-sm text-muted-foreground">
{session.message_count} messages {session.last_message}
</p>
</div>
</div>
</button>
))}
</div>
</div>
</div>
</aside>
</Show>
{/* Main Content */}
<main class="flex-1 flex flex-col overflow-hidden">
{/* Chat View */}
<Show when={activeView() === 'chat'}>
<div class="flex-1 flex flex-col">
{/* Messages Area */}
<div class="flex-1 overflow-y-auto p-6">
<div class="max-w-4xl mx-auto space-y-6">
<For each={messages()}>
{message => (
<div
class={`flex gap-4 ${
message.role === 'user' ? 'justify-end' : 'justify-start'
}`}
>
<div
class={`max-w-[80%] rounded-lg p-4 ${
message.role === 'user'
? 'bg-primary text-primary-foreground'
: 'bg-muted'
}`}
>
<div class="flex items-start gap-3">
<div class={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center ${
message.role === 'user' ? 'bg-primary-foreground/20' : 'bg-primary/10'
}`}>
{message.role === 'user' ? (
<span class="text-xs">👤</span>
) : (
<span class="text-xs">🤖</span>
)}
</div>
<div class="flex-1">
<p class="text-sm leading-relaxed whitespace-pre-wrap break-words">{message.content}</p>
</div>
</div>
</div>
</div>
)}
</For>
{isLoading() && (
<div class="flex justify-start">
<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>
</div>
<div class="flex gap-1">
<div class="w-2 h-2 bg-primary rounded-full animate-bounce"></div>
<div class="w-2 h-2 bg-primary rounded-full animate-bounce" style="animation-delay: 0.1s"></div>
<div class="w-2 h-2 bg-primary rounded-full animate-bounce" style="animation-delay: 0.2s"></div>
</div>
</div>
</div>
</div>
)}
</div>
</div>
{/* Input Area */}
<div class="border-t bg-card/95 backdrop-blur-sm">
<div class="p-6">
<div class="max-w-4xl mx-auto">
<div class="flex gap-4">
<Input
value={inputMessage()}
onInput={(e) => setInputMessage((e.currentTarget as HTMLInputElement).value)}
placeholder="Type your message..."
class="flex-1"
onKeyDown={(e: KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey && inputMessage().trim()) {
handleSendMessage()
}
}}
/>
<Button
disabled={isLoading() || !inputMessage().trim()}
onClick={handleSendMessage}
>
<Send class="h-4 w-4" />
</Button>
</div>
</div>
</div>
</div>
</div>
</Show>
{/* Settings View */}
<Show when={activeView() === 'settings'}>
<div class="flex-1 overflow-y-auto p-2">
<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>
</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">
<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>
<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"
>
<option value="openai">OpenAI Compatible</option>
<option value="anthropic">Anthropic Compatible</option>
</select>
</div>
</div>
<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>
</div>
</div>
</Card>
</div>
</div>
</Show>
</main>
</div>
</div>
)
}
export default AIChat
+202
View File
@@ -0,0 +1,202 @@
import { createSignal } from 'solid-js';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { ActivityFeed } from '@/components/ui/ActivityFeed';
import {
IconTrendingUp,
IconClock,
IconFilter,
IconRefresh,
IconDownload,
IconSettings
} from '@tabler/icons-solidjs';
export const Activity = () => {
const [refreshKey, setRefreshKey] = createSignal(0);
const [showFilters, setShowFilters] = createSignal(false);
const handleRefresh = () => {
setRefreshKey(prev => prev + 1);
};
return (
<div class="p-6 space-y-6">
{/* Header */}
<div class="flex justify-between items-start">
<div>
<h1 class="text-3xl font-bold text-[#fafafa]">Activity Dashboard</h1>
<p class="text-[#a3a3a3] mt-2">
All your Trackeep activity enriched with GitHub data, unified in one place
</p>
</div>
<div class="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setShowFilters(!showFilters())}
>
<IconFilter class="size-4 mr-2" />
Filters
</Button>
<Button
variant="outline"
size="sm"
onClick={handleRefresh}
>
<IconRefresh class="size-4 mr-2" />
Refresh
</Button>
<Button variant="outline" size="sm">
<IconDownload class="size-4 mr-2" />
Export
</Button>
</div>
</div>
{/* Stats Overview */}
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card class="p-6 border-l-4 border-l-primary">
<div class="flex items-center gap-3">
<div class="bg-primary/10 p-3 rounded-lg">
<IconTrendingUp class="size-6 text-primary" />
</div>
<div>
<p class="text-2xl font-bold text-[#fafafa]">247</p>
<p class="text-sm text-[#a3a3a3]">Total Activities</p>
</div>
</div>
</Card>
<Card class="p-6 border-l-4 border-l-primary">
<div class="flex items-center gap-3">
<div class="bg-primary/10 p-3 rounded-lg">
<IconTrendingUp class="size-6 text-primary" />
</div>
<div>
<p class="text-2xl font-bold text-[#fafafa]">89</p>
<p class="text-sm text-[#a3a3a3]">Trackeep Items</p>
</div>
</div>
</Card>
<Card class="p-6 border-l-4 border-l-primary">
<div class="flex items-center gap-3">
<div class="bg-primary/10 p-3 rounded-lg">
<IconTrendingUp class="size-6 text-primary" />
</div>
<div>
<p class="text-2xl font-bold text-[#fafafa]">158</p>
<p class="text-sm text-[#a3a3a3]">GitHub Events</p>
</div>
</div>
</Card>
<Card class="p-6 border-l-4 border-l-primary">
<div class="flex items-center gap-3">
<div class="bg-primary/10 p-3 rounded-lg">
<IconClock class="size-6 text-primary" />
</div>
<div>
<p class="text-2xl font-bold text-[#fafafa]">2h</p>
<p class="text-sm text-[#a3a3a3]">Last Activity</p>
</div>
</div>
</Card>
</div>
{/* Main Activity Feed */}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main Feed */}
<div class="lg:col-span-2">
<Card class="p-6">
<ActivityFeed
refreshKey={refreshKey()}
limit={20}
showFilter={showFilters()}
/>
</Card>
</div>
{/* Sidebar */}
<div class="space-y-6">
{/* Quick Stats */}
<Card class="p-6">
<h3 class="text-lg font-semibold text-[#fafafa] mb-4">Activity Breakdown</h3>
<div class="space-y-3">
<div class="flex justify-between items-center">
<span class="text-sm text-[#a3a3a3]">Bookmarks</span>
<span class="text-sm font-medium text-primary">23</span>
</div>
<div class="flex justify-between items-center">
<span class="text-sm text-[#a3a3a3]">Tasks</span>
<span class="text-sm font-medium text-primary">31</span>
</div>
<div class="flex justify-between items-center">
<span class="text-sm text-[#a3a3a3]">Notes</span>
<span class="text-sm font-medium text-primary">18</span>
</div>
<div class="flex justify-between items-center">
<span class="text-sm text-[#a3a3a3]">Files</span>
<span class="text-sm font-medium text-primary">17</span>
</div>
<div class="border-t border-[#262626] pt-3 mt-3">
<div class="flex justify-between items-center">
<span class="text-sm text-[#a3a3a3]">Commits</span>
<span class="text-sm font-medium text-primary">89</span>
</div>
<div class="flex justify-between items-center">
<span class="text-sm text-[#a3a3a3]">Pull Requests</span>
<span class="text-sm font-medium text-primary">12</span>
</div>
<div class="flex justify-between items-center">
<span class="text-sm text-[#a3a3a3]">Stars</span>
<span class="text-sm font-medium text-primary">45</span>
</div>
<div class="flex justify-between items-center">
<span class="text-sm text-[#a3a3a3]">Forks</span>
<span class="text-sm font-medium text-primary">12</span>
</div>
</div>
</div>
</Card>
{/* Recent Repos */}
<Card class="p-6">
<h3 class="text-lg font-semibold text-[#fafafa] mb-4">Active Repositories</h3>
<div class="space-y-3">
{[
{ name: 'trackeep', language: 'TypeScript', activity: '2h ago' },
{ name: 'solid-components', language: 'TypeScript', activity: '5h ago' },
{ name: 'go-api', language: 'Go', activity: '1d ago' },
{ name: 'ml-models', language: 'Python', activity: '2d ago' }
].map((repo) => (
<div class="flex items-center justify-between p-3 bg-[#262626] rounded-lg">
<div>
<p class="text-sm font-medium text-[#fafafa]">{repo.name}</p>
<p class="text-xs text-[#a3a3a3]">{repo.language}</p>
</div>
<span class="text-xs text-[#a3a3a3]">{repo.activity}</span>
</div>
))}
</div>
</Card>
{/* Settings */}
<Card class="p-6">
<h3 class="text-lg font-semibold text-[#fafafa] mb-4">Activity Settings</h3>
<div class="space-y-3">
<Button variant="outline" size="sm" class="w-full justify-start">
<IconSettings class="size-4 mr-2" />
Configure Filters
</Button>
<Button variant="outline" size="sm" class="w-full justify-start">
<IconDownload class="size-4 mr-2" />
Export Activity Data
</Button>
</div>
</Card>
</div>
</div>
</div>
);
};
+404
View File
@@ -0,0 +1,404 @@
import { createSignal, onMount } from 'solid-js';
import {
IconUsers,
IconFileText,
IconBookmark,
IconFolder,
IconTrendingUp,
IconActivity,
IconDatabase,
IconPalette,
IconSettings,
IconUpload,
IconEdit,
IconGitBranch,
IconClock,
IconChecklist
} from '@tabler/icons-solidjs';
import { ColorSwitcher } from './ColorSwitcher';
interface ProjectStats {
totalUsers: number;
totalDocuments: number;
totalBookmarks: number;
totalTasks: number;
totalNotes: number;
totalStorage: string;
activeUsers: number;
systemUptime: string;
apiCallsToday: number;
databaseSize: string;
serverLoad: number;
lastBackup: string;
}
interface SystemActivity {
id: string;
type: 'user_login' | 'file_upload' | 'bookmark_created' | 'task_completed' | 'system_backup';
description: string;
timestamp: string;
user?: string;
}
interface GitHubActivity {
id: string;
repo: string;
commit: string;
author: string;
message: string;
timestamp: string;
type: 'commit' | 'pull_request' | 'merge';
}
export const AdminDashboard = () => {
const [stats, setStats] = createSignal<ProjectStats>({
totalUsers: 0,
totalDocuments: 0,
totalBookmarks: 0,
totalTasks: 0,
totalNotes: 0,
totalStorage: '0 MB',
activeUsers: 0,
systemUptime: '0 days',
apiCallsToday: 0,
databaseSize: '0 MB',
serverLoad: 0,
lastBackup: 'Never'
});
const [systemActivities, setSystemActivities] = createSignal<SystemActivity[]>([]);
const [githubActivities, setGithubActivities] = createSignal<GitHubActivity[]>([]);
const [, setIsLoading] = createSignal(true);
onMount(() => {
// Mock admin stats data
setStats({
totalUsers: 156,
totalDocuments: 1247,
totalBookmarks: 892,
totalTasks: 456,
totalNotes: 623,
totalStorage: '2.4 GB',
activeUsers: 23,
systemUptime: '45 days',
apiCallsToday: 12456,
databaseSize: '847 MB',
serverLoad: 35,
lastBackup: '2024-01-15 02:30:00'
});
// Mock system activities
setSystemActivities([
{
id: '1',
type: 'user_login',
description: 'Admin user logged in',
timestamp: '2 minutes ago',
user: 'admin@trackeep.com'
},
{
id: '2',
type: 'file_upload',
description: 'User uploaded 3 documents',
timestamp: '15 minutes ago',
user: 'john.doe@example.com'
},
{
id: '3',
type: 'bookmark_created',
description: 'New bookmark added to collection',
timestamp: '1 hour ago',
user: 'jane.smith@example.com'
},
{
id: '4',
type: 'system_backup',
description: 'Automated backup completed successfully',
timestamp: '2 hours ago'
},
{
id: '5',
type: 'task_completed',
description: 'Project milestone completed',
timestamp: '3 hours ago',
user: 'mike.wilson@example.com'
}
]);
// Mock GitHub activities
setGithubActivities([
{
id: '1',
repo: 'trackeep/frontend',
commit: 'a1b2c3d',
author: 'John Doe',
message: 'Add pagination functionality to dashboard',
timestamp: '30 minutes ago',
type: 'commit'
},
{
id: '2',
repo: 'trackeep/backend',
commit: 'e4f5g6h',
author: 'Jane Smith',
message: 'Fix authentication middleware bug',
timestamp: '2 hours ago',
type: 'commit'
},
{
id: '3',
repo: 'trackeep/docs',
commit: 'i7j8k9l',
author: 'Mike Wilson',
message: 'Update API documentation',
timestamp: '4 hours ago',
type: 'merge'
}
]);
setIsLoading(false);
});
const handleBackupDatabase = async () => {
try {
alert('Database backup initiated successfully!');
// In real app, this would call the backup API
} catch (error) {
alert('Failed to backup database');
}
};
const handleManageUsers = () => {
window.open('/app/members', '_blank');
};
const handleSystemSettings = () => {
window.open('/app/admin-settings', '_blank');
};
const getActivityIcon = (type: string) => {
switch (type) {
case 'user_login': return IconUsers;
case 'file_upload': return IconUpload;
case 'bookmark_created': return IconBookmark;
case 'task_completed': return IconChecklist;
case 'system_backup': return IconDatabase;
default: return IconActivity;
}
};
const getGitHubIcon = (type: string) => {
switch (type) {
case 'commit': return IconGitBranch;
case 'pull_request': return IconEdit;
case 'merge': return IconGitBranch;
default: return IconGitBranch;
}
};
return (
<div class="p-6 mt-4 pb-32 max-w-6xl mx-auto">
<div class="flex justify-between items-center mb-8">
<div>
<h1 class="text-3xl font-bold text-foreground">Admin Dashboard</h1>
<p class="text-muted-foreground mt-2">System overview and management</p>
</div>
<div class="flex items-center gap-2">
<IconSettings class="size-5 text-muted-foreground" />
<span class="text-sm text-muted-foreground">Administrator Access</span>
</div>
</div>
{/* Main Stats Grid */}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<div class="border rounded-lg p-4">
<div class="flex items-center gap-3">
<div class="bg-muted flex items-center justify-center p-2 rounded-lg">
<IconUsers class="size-5 text-primary" />
</div>
<div>
<p class="text-2xl font-light">{stats().totalUsers}</p>
<p class="text-sm text-muted-foreground">Total Users</p>
</div>
</div>
</div>
<div class="border rounded-lg p-4">
<div class="flex items-center gap-3">
<div class="bg-muted flex items-center justify-center p-2 rounded-lg">
<IconFileText class="size-5 text-primary" />
</div>
<div>
<p class="text-2xl font-light">{stats().totalDocuments}</p>
<p class="text-sm text-muted-foreground">Documents</p>
</div>
</div>
</div>
<div class="border rounded-lg p-4">
<div class="flex items-center gap-3">
<div class="bg-muted flex items-center justify-center p-2 rounded-lg">
<IconBookmark class="size-5 text-primary" />
</div>
<div>
<p class="text-2xl font-light">{stats().totalBookmarks}</p>
<p class="text-sm text-muted-foreground">Bookmarks</p>
</div>
</div>
</div>
<div class="border rounded-lg p-4">
<div class="flex items-center gap-3">
<div class="bg-muted flex items-center justify-center p-2 rounded-lg">
<IconFolder class="size-5 text-primary" />
</div>
<div>
<p class="text-2xl font-light">{stats().totalStorage}</p>
<p class="text-sm text-muted-foreground">Storage Used</p>
</div>
</div>
</div>
</div>
{/* Secondary Stats and Activity */}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
{/* System Activity */}
<div class="lg:col-span-2 border rounded-lg p-6">
<div class="flex items-center gap-2 mb-4">
<IconActivity class="size-5 text-primary" />
<h3 class="text-lg font-semibold">System Activity</h3>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-3">
<div class="flex justify-between items-center">
<span class="text-sm text-muted-foreground">Active Users</span>
<span class="font-medium">{stats().activeUsers}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-sm text-muted-foreground">Tasks Completed</span>
<span class="font-medium">{stats().totalTasks}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-sm text-muted-foreground">Notes Created</span>
<span class="font-medium">{stats().totalNotes}</span>
</div>
</div>
<div class="space-y-3">
<div class="flex justify-between items-center">
<span class="text-sm text-muted-foreground">System Uptime</span>
<span class="font-medium">{stats().systemUptime}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-sm text-muted-foreground">Database Size</span>
<span class="font-medium">847 MB</span>
</div>
<div class="flex justify-between items-center">
<span class="text-sm text-muted-foreground">API Calls Today</span>
<span class="font-medium">12,456</span>
</div>
</div>
</div>
</div>
{/* Quick Actions */}
<div class="border rounded-lg p-6">
<div class="flex items-center gap-2 mb-4">
<IconTrendingUp class="size-5 text-primary" />
<h3 class="text-lg font-semibold">Quick Actions</h3>
</div>
<div class="space-y-2">
<button class="w-full text-left 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-9 px-3" onClick={handleBackupDatabase}>
<IconDatabase class="size-4 mr-2" />
Backup Database
</button>
<button class="w-full text-left 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-9 px-3" onClick={handleManageUsers}>
<IconUsers class="size-4 mr-2" />
Manage Users
</button>
<button class="w-full text-left 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-9 px-3" onClick={handleSystemSettings}>
<IconSettings class="size-4 mr-2" />
System Settings
</button>
</div>
</div>
</div>
{/* Timeline and GitHub Activity */}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
{/* System Activity Timeline */}
<div class="border rounded-lg p-6">
<div class="flex items-center gap-2 mb-4">
<IconClock class="size-5 text-primary" />
<h3 class="text-lg font-semibold">System Activity Timeline</h3>
</div>
<div class="space-y-4">
{systemActivities().map((activity, index) => {
const ActivityIcon = getActivityIcon(activity.type);
return (
<div class="flex items-start gap-3">
<div class="flex flex-col items-center">
<div class="bg-muted flex items-center justify-center p-2 rounded-full">
<ActivityIcon class="size-4 text-primary" />
</div>
{index < systemActivities().length - 1 && (
<div class="w-0.5 h-8 bg-muted mt-2"></div>
)}
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium">{activity.description}</p>
<p class="text-xs text-muted-foreground">{activity.timestamp}</p>
{activity.user && (
<p class="text-xs text-muted-foreground">User: {activity.user}</p>
)}
</div>
</div>
);
})}
</div>
</div>
{/* GitHub Activity */}
<div class="border rounded-lg p-6">
<div class="flex items-center gap-2 mb-4">
<IconGitBranch class="size-5 text-primary" />
<h3 class="text-lg font-semibold">GitHub Activity</h3>
</div>
<div class="space-y-4">
{githubActivities().map((activity) => {
const GitHubIcon = getGitHubIcon(activity.type);
return (
<div class="border rounded-lg p-3 hover:bg-muted/50 transition-colors">
<div class="flex items-start gap-3">
<div class="bg-muted flex items-center justify-center p-2 rounded-lg">
<GitHubIcon class="size-4 text-primary" />
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span class="text-sm font-medium">{activity.repo}</span>
<span class="text-xs text-muted-foreground"></span>
<span class="text-xs text-muted-foreground">{activity.timestamp}</span>
</div>
<p class="text-sm text-muted-foreground mb-1">{activity.message}</p>
<div class="flex items-center gap-2">
<span class="text-xs font-mono bg-muted px-2 py-1 rounded">{activity.commit}</span>
<span class="text-xs text-muted-foreground">by {activity.author}</span>
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
{/* Color Switcher Section */}
<div class="border rounded-lg p-6">
<div class="flex items-center gap-2 mb-4">
<IconPalette class="size-5 text-primary" />
<h3 class="text-lg font-semibold">Theme Customization</h3>
</div>
<ColorSwitcher />
</div>
</div>
);
};
+276
View File
@@ -0,0 +1,276 @@
import { createSignal, onMount, For, Show } from 'solid-js';
import { IconSettings, IconUsers, IconDatabase, IconShield, IconCheck } from '@tabler/icons-solidjs';
interface AdminSetting {
key: string;
label: string;
value: any;
type: 'string' | 'number' | 'boolean';
description: string;
category: 'user' | 'system' | 'security';
icon: string;
}
export const AdminSettings = () => {
const [settings, setSettings] = createSignal<AdminSetting[]>([]);
const [isLoading, setIsLoading] = createSignal(false);
const [message, setMessage] = createSignal('');
onMount(() => {
setSettings([
{
key: 'max_users',
label: 'Maximum Users',
value: '100',
type: 'number',
description: 'Maximum number of users allowed in the workspace',
category: 'user',
icon: 'IconUsers'
},
{
key: 'allow_registration',
label: 'Allow Registration',
value: true,
type: 'boolean',
description: 'Allow new users to register',
category: 'user',
icon: 'IconUsers'
},
{
key: 'maintenance_mode',
label: 'Maintenance Mode',
value: false,
type: 'boolean',
description: 'Put the application in maintenance mode',
category: 'system',
icon: 'IconDatabase'
},
{
key: 'enable_2fa',
label: 'Enable 2FA',
value: false,
type: 'boolean',
description: 'Require two-factor authentication for all users',
category: 'security',
icon: 'IconShield'
},
{
key: 'session_timeout',
label: 'Session Timeout (hours)',
value: '24',
type: 'number',
description: 'Hours before user sessions expire',
category: 'security',
icon: 'IconShield'
}
]);
});
const updateSetting = (key: string, value: any) => {
setSettings(prev =>
prev.map(setting =>
setting.key === key ? { ...setting, value } : setting
)
);
};
const saveSettings = async () => {
setIsLoading(true);
try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000));
setMessage('Settings saved successfully!');
setTimeout(() => setMessage(''), 3000);
} catch (error) {
setMessage('Failed to save settings');
setTimeout(() => setMessage(''), 3000);
} finally {
setIsLoading(false);
}
};
return (
<div class="p-6 mt-4 pb-32 max-w-6xl mx-auto">
<div class="mb-8">
<h1 class="text-3xl font-bold text-foreground mb-2 flex items-center gap-3">
<IconSettings class="size-8 text-primary" />
Admin Settings
</h1>
<p class="text-muted-foreground">
Manage system-wide settings and configurations
</p>
</div>
<Show when={message()}>
<div class="p-4 rounded-lg text-sm mb-6 bg-primary/15 text-primary border border-primary/20">
{message()}
</div>
</Show>
<div class="space-y-8">
{/* User Settings */}
<div class="border rounded-lg p-6 bg-card">
<div class="flex items-center gap-3 mb-6">
<div class="flex items-center justify-center p-2 rounded-lg bg-primary/10">
<IconUsers class="size-5 text-primary" />
</div>
<div>
<h2 class="text-xl font-semibold text-foreground">User Settings</h2>
<p class="text-sm text-muted-foreground">Manage user-related configurations</p>
</div>
</div>
<div class="space-y-4">
<For each={settings().filter(s => s.category === 'user')}>
{(setting) => (
<div class="flex items-center justify-between p-4 bg-muted/30 rounded-lg">
<div class="flex-1">
<label class="text-sm font-medium text-foreground">{setting.label}</label>
<p class="text-xs text-muted-foreground mt-1">{setting.description}</p>
</div>
{setting.type === 'boolean' ? (
<button
type="button"
onClick={() => updateSetting(setting.key, !setting.value)}
class={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 ${
setting.value ? 'bg-primary' : 'bg-muted'
}`}
>
<span
class={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
setting.value ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
) : (
<input
type={setting.type}
value={String(setting.value)}
onInput={(e) => updateSetting(setting.key, e.currentTarget.value)}
class="flex h-10 w-32 rounded-md border border-input bg-background px-3 py-2 text-sm"
/>
)}
</div>
)}
</For>
</div>
</div>
{/* System Settings */}
<div class="border rounded-lg p-6 bg-card">
<div class="flex items-center gap-3 mb-6">
<div class="flex items-center justify-center p-2 rounded-lg bg-primary/10">
<IconDatabase class="size-5 text-primary" />
</div>
<div>
<h2 class="text-xl font-semibold text-foreground">System Settings</h2>
<p class="text-sm text-muted-foreground">Manage system configurations</p>
</div>
</div>
<div class="space-y-4">
<For each={settings().filter(s => s.category === 'system')}>
{(setting) => (
<div class="flex items-center justify-between p-4 bg-muted/30 rounded-lg">
<div class="flex-1">
<label class="text-sm font-medium text-foreground">{setting.label}</label>
<p class="text-xs text-muted-foreground mt-1">{setting.description}</p>
</div>
{setting.type === 'boolean' ? (
<button
type="button"
onClick={() => updateSetting(setting.key, !setting.value)}
class={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 ${
setting.value ? 'bg-primary' : 'bg-muted'
}`}
>
<span
class={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
setting.value ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
) : (
<input
type={setting.type}
value={String(setting.value)}
onInput={(e) => updateSetting(setting.key, e.currentTarget.value)}
class="flex h-10 w-32 rounded-md border border-input bg-background px-3 py-2 text-sm"
/>
)}
</div>
)}
</For>
</div>
</div>
{/* Security Settings */}
<div class="border rounded-lg p-6 bg-card">
<div class="flex items-center gap-3 mb-6">
<div class="flex items-center justify-center p-2 rounded-lg bg-primary/10">
<IconShield class="size-5 text-primary" />
</div>
<div>
<h2 class="text-xl font-semibold text-foreground">Security Settings</h2>
<p class="text-sm text-muted-foreground">Manage security configurations</p>
</div>
</div>
<div class="space-y-4">
<For each={settings().filter(s => s.category === 'security')}>
{(setting) => (
<div class="flex items-center justify-between p-4 bg-muted/30 rounded-lg">
<div class="flex-1">
<label class="text-sm font-medium text-foreground">{setting.label}</label>
<p class="text-xs text-muted-foreground mt-1">{setting.description}</p>
</div>
{setting.type === 'boolean' ? (
<button
type="button"
onClick={() => updateSetting(setting.key, !setting.value)}
class={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 ${
setting.value ? 'bg-primary' : 'bg-muted'
}`}
>
<span
class={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
setting.value ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
) : (
<input
type={setting.type}
value={String(setting.value)}
onInput={(e) => updateSetting(setting.key, e.currentTarget.value)}
class="flex h-10 w-32 rounded-md border border-input bg-background px-3 py-2 text-sm"
/>
)}
</div>
)}
</For>
</div>
</div>
</div>
{/* Save Button */}
<div class="flex justify-end mt-8">
<button
type="button"
onClick={saveSettings}
disabled={isLoading()}
class="inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground shadow-sm hover:bg-primary/90 h-11 px-6 gap-2"
>
{isLoading() ? (
<>
<div class="w-4 h-4 border-2 border-primary-foreground/30 border-t-transparent rounded-full animate-spin"></div>
Saving...
</>
) : (
<>
<IconCheck class="size-4" />
Save Settings
</>
)}
</button>
</div>
</div>
);
};
+516
View File
@@ -0,0 +1,516 @@
import { createSignal, onMount, For, Show } from 'solid-js';
import {
IconChartLine,
IconBookmarks,
IconChecklist,
IconClock,
IconTarget,
IconBrain,
IconGitBranch,
IconBulb,
IconAward
} from '@tabler/icons-solidjs';
import { Card, CardHeader, CardTitle, CardContent, CardDescription } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
interface AnalyticsData {
period: {
start_date: string;
end_date: string;
days: number;
};
summary: {
hours_tracked: number;
tasks_completed: number;
bookmarks_added: number;
notes_created: number;
courses_completed: number;
github_commits: number;
};
analytics: Array<{
date: string;
hours_tracked: number;
tasks_completed: number;
bookmarks_added: number;
notes_created: number;
courses_completed: number;
github_commits: number;
study_streak: number;
productivity_score: number;
}>;
productivity_metrics: Array<{
period: string;
start_date: string;
end_date: string;
total_hours: number;
billable_hours: number;
non_billable_hours: number;
tasks_completed: number;
average_task_time: number;
peak_productivity_hour: number;
focus_score: number;
efficiency_score: number;
}>;
learning_analytics: Array<{
id: number;
course: {
title: string;
description: string;
};
start_date: string;
last_accessed: string;
time_spent: number;
progress: number;
modules_completed: number;
total_modules: number;
average_score: number;
streak_days: number;
skills_acquired: string[];
}>;
github_analytics: Array<{
date: string;
commits: number;
pull_requests: number;
issues_opened: number;
issues_closed: number;
reviews: number;
contributions: number;
languages: Record<string, number>;
repositories: string[];
}>;
goals: Array<{
id: number;
title: string;
description: string;
category: string;
target_value: number;
current_value: number;
unit: string;
deadline: string;
status: string;
priority: string;
progress: number;
is_completed: boolean;
milestones: Array<{
id: number;
title: string;
target_value: number;
current_value: number;
deadline: string;
status: string;
is_completed: boolean;
}>;
}>;
habit_analytics: Array<{
habit_name: string;
start_date: string;
last_completed: string;
streak: number;
best_streak: number;
total_days: number;
completion_rate: number;
frequency: string;
category: string;
goal_target: number;
goal_achieved: boolean;
}>;
}
export const Analytics = () => {
const [analytics, setAnalytics] = createSignal<AnalyticsData | null>(null);
const [loading, setLoading] = createSignal(true);
const [error, setError] = createSignal<string | null>(null);
const [selectedPeriod, setSelectedPeriod] = createSignal('30');
const fetchAnalytics = async () => {
try {
setLoading(true);
const token = localStorage.getItem('token');
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/analytics/dashboard?days=${selectedPeriod()}`, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error('Failed to fetch analytics');
}
const data = await response.json();
setAnalytics(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setLoading(false);
}
};
onMount(() => {
fetchAnalytics();
});
const formatHours = (hours: number) => {
const h = Math.floor(hours);
const m = Math.round((hours - h) * 60);
return `${h}h ${m}m`;
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString();
};
const getPriorityColor = (priority: string) => {
switch (priority) {
case 'urgent': return 'text-destructive';
case 'high': return 'text-orange-500';
case 'medium': return 'text-yellow-500';
case 'low': return 'text-muted-foreground';
default: return 'text-gray-500';
}
};
// Component render
return (
<div class="p-6 space-y-6">
<div class="flex justify-between items-center">
<div>
<h1 class="text-3xl font-bold">Analytics & Insights</h1>
<p class="text-muted-foreground">Track your productivity and progress</p>
</div>
<div class="flex gap-2">
<select
value={selectedPeriod()}
onChange={(e) => setSelectedPeriod(e.target.value)}
class="px-3 py-2 border rounded-md bg-background"
>
<option value="7">Last 7 days</option>
<option value="30">Last 30 days</option>
<option value="90">Last 90 days</option>
</select>
<Button onClick={fetchAnalytics}>Refresh</Button>
</div>
</div>
<Show when={error()}>
<div class="bg-destructive/15 border border-destructive/20 rounded-md p-4">
<p class="text-destructive">{error()}</p>
</div>
</Show>
<Show when={loading()}>
<div class="text-center py-8">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
<p class="mt-2 text-muted-foreground">Loading analytics...</p>
</div>
</Show>
<Show when={analytics()}>
<div class="space-y-6">
{/* Summary Cards */}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<Card>
<CardContent class="p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-muted-foreground">Hours Tracked</p>
<p class="text-2xl font-bold">{formatHours(analytics()!.summary.hours_tracked)}</p>
</div>
<IconClock class="h-8 w-8 text-blue-500" />
</div>
</CardContent>
</Card>
<Card>
<CardContent class="p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-muted-foreground">Tasks Completed</p>
<p class="text-2xl font-bold">{analytics()!.summary.tasks_completed}</p>
</div>
<IconChecklist class="h-8 w-8 text-primary" />
</div>
</CardContent>
</Card>
<Card>
<CardContent class="p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-muted-foreground">Bookmarks Added</p>
<p class="text-2xl font-bold">{analytics()!.summary.bookmarks_added}</p>
</div>
<IconBookmarks class="h-8 w-8 text-purple-500" />
</div>
</CardContent>
</Card>
<Card>
<CardContent class="p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-muted-foreground">GitHub Commits</p>
<p class="text-2xl font-bold">{analytics()!.summary.github_commits}</p>
</div>
<IconGitBranch class="h-8 w-8 text-orange-500" />
</div>
</CardContent>
</Card>
</div>
{/* Goals Progress */}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<IconTarget class="h-5 w-5" />
Active Goals
</CardTitle>
<CardDescription>Track your goal progress</CardDescription>
</CardHeader>
<CardContent>
<div class="space-y-4">
<For each={analytics()!.goals.filter(g => g.status === 'active').slice(0, 5)}>
{(goal) => (
<div class="space-y-2">
<div class="flex justify-between items-center">
<div class="flex-1">
<h4 class="font-medium">{goal.title}</h4>
<p class="text-sm text-muted-foreground">
{goal.current_value} / {goal.target_value} {goal.unit}
</p>
</div>
<div class="flex items-center gap-2">
<span class={`text-sm font-medium ${getPriorityColor(goal.priority)}`}>
{goal.priority}
</span>
<span class="text-sm font-medium">{Math.round(goal.progress)}%</span>
</div>
</div>
<div class="w-full bg-gray-200 rounded-full h-2">
<div
class="bg-blue-500 h-2 rounded-full transition-all duration-300"
style={`width: ${goal.progress}%`}
></div>
</div>
<p class="text-xs text-muted-foreground">
Deadline: {formatDate(goal.deadline)}
</p>
</div>
)}
</For>
<Show when={analytics()!.goals.filter(g => g.status === 'active').length === 0}>
<p class="text-muted-foreground text-center py-4">No active goals</p>
</Show>
</div>
</CardContent>
</Card>
{/* Habit Tracking */}
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<IconAward class="h-5 w-5" />
Habit Tracking
</CardTitle>
<CardDescription>Your daily habits and streaks</CardDescription>
</CardHeader>
<CardContent>
<div class="space-y-4">
<For each={analytics()!.habit_analytics.slice(0, 5)}>
{(habit) => (
<div class="flex justify-between items-center p-3 border rounded-lg">
<div>
<h4 class="font-medium">{habit.habit_name}</h4>
<p class="text-sm text-muted-foreground">
{habit.frequency} {Math.round(habit.completion_rate)}% completion
</p>
</div>
<div class="text-right">
<div class="flex items-center gap-1">
<IconBulb class="h-4 w-4 text-orange-500" />
<span class="font-medium">{habit.streak} day streak</span>
</div>
<p class="text-xs text-muted-foreground">
Best: {habit.best_streak} days
</p>
</div>
</div>
)}
</For>
<Show when={analytics()!.habit_analytics.length === 0}>
<p class="text-muted-foreground text-center py-4">No habits tracked</p>
</Show>
</div>
</CardContent>
</Card>
</div>
{/* Learning Progress */}
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<IconBrain class="h-5 w-5" />
Learning Progress
</CardTitle>
<CardDescription>Your course progress and achievements</CardDescription>
</CardHeader>
<CardContent>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<For each={analytics()!.learning_analytics.slice(0, 6)}>
{(course) => (
<div class="border rounded-lg p-4">
<h4 class="font-medium truncate">{course.course.title}</h4>
<p class="text-sm text-muted-foreground mb-2">
{course.modules_completed}/{course.total_modules} modules
</p>
<div class="w-full bg-gray-200 rounded-full h-2 mb-2">
<div
class="bg-primary h-2 rounded-full transition-all duration-300"
style={`width: ${course.progress}%`}
></div>
</div>
<div class="flex justify-between text-xs text-muted-foreground">
<span>{Math.round(course.progress)}% complete</span>
<span>{course.streak_days} day streak</span>
</div>
</div>
)}
</For>
<Show when={analytics()!.learning_analytics.length === 0}>
<div class="col-span-full text-center py-8">
<p class="text-muted-foreground">No courses in progress</p>
</div>
</Show>
</div>
</CardContent>
</Card>
{/* GitHub Activity */}
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<IconGitBranch class="h-5 w-5" />
GitHub Activity
</CardTitle>
<CardDescription>Your contribution summary</CardDescription>
</CardHeader>
<CardContent>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div class="text-center">
<p class="text-2xl font-bold">{analytics()!.summary.github_commits}</p>
<p class="text-sm text-muted-foreground">Commits</p>
</div>
<div class="text-center">
<p class="text-2xl font-bold">
{analytics()!.github_analytics.reduce((sum, day) => sum + day.pull_requests, 0)}
</p>
<p class="text-sm text-muted-foreground">Pull Requests</p>
</div>
<div class="text-center">
<p class="text-2xl font-bold">
{analytics()!.github_analytics.reduce((sum, day) => sum + day.issues_opened, 0)}
</p>
<p class="text-sm text-muted-foreground">Issues Opened</p>
</div>
<div class="text-center">
<p class="text-2xl font-bold">
{analytics()!.github_analytics.reduce((sum, day) => sum + day.reviews, 0)}
</p>
<p class="text-sm text-muted-foreground">Reviews</p>
</div>
</div>
<div class="space-y-2">
<For each={analytics()!.github_analytics.slice(0, 7)}>
{(day) => (
<div class="flex justify-between items-center p-2 border rounded">
<span class="text-sm">{formatDate(day.date)}</span>
<div class="flex gap-4 text-sm">
<span>{day.commits} commits</span>
<span>{day.pull_requests} PRs</span>
<span>{day.issues_opened} issues</span>
</div>
</div>
)}
</For>
</div>
</CardContent>
</Card>
{/* Productivity Insights */}
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<IconChartLine class="h-5 w-5" />
Productivity Insights
</CardTitle>
<CardDescription>Key insights and patterns</CardDescription>
</CardHeader>
<CardContent>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h4 class="font-medium mb-3">Daily Activity</h4>
<div class="space-y-2">
<For each={analytics()!.analytics.slice(0, 7)}>
{(day) => (
<div class="flex justify-between items-center">
<span class="text-sm">{formatDate(day.date)}</span>
<div class="flex items-center gap-2">
<span class="text-sm">{formatHours(day.hours_tracked)}</span>
<span class="text-sm text-muted-foreground">
{day.tasks_completed} tasks
</span>
<Show when={day.productivity_score > 0}>
<span class="text-xs px-2 py-1 bg-primary/10 text-primary rounded">
{Math.round(day.productivity_score)}%
</span>
</Show>
</div>
</div>
)}
</For>
</div>
</div>
<div>
<h4 class="font-medium mb-3">Key Metrics</h4>
<div class="space-y-2">
<div class="flex justify-between">
<span class="text-sm text-muted-foreground">Average Daily Hours</span>
<span class="text-sm font-medium">
{formatHours(analytics()!.summary.hours_tracked / analytics()!.period.days)}
</span>
</div>
<div class="flex justify-between">
<span class="text-sm text-muted-foreground">Tasks per Day</span>
<span class="text-sm font-medium">
{(analytics()!.summary.tasks_completed / analytics()!.period.days).toFixed(1)}
</span>
</div>
<div class="flex justify-between">
<span class="text-sm text-muted-foreground">Study Streak</span>
<span class="text-sm font-medium">
{Math.max(...analytics()!.analytics.map(a => a.study_streak))} days
</span>
</div>
<div class="flex justify-between">
<span class="text-sm text-muted-foreground">Average Productivity</span>
<span class="text-sm font-medium">
{Math.round(
analytics()!.analytics.reduce((sum, a) => sum + a.productivity_score, 0) /
analytics()!.analytics.length
)}%
</span>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</Show>
</div>
);
};
+68
View File
@@ -0,0 +1,68 @@
import { createSignal, onMount } from 'solid-js';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
export const AuthCallback = () => {
const [status, setStatus] = createSignal<'loading' | 'success' | 'error'>('loading');
const [message, setMessage] = createSignal('Processing authentication...');
onMount(() => {
const urlParams = new URLSearchParams(window.location.search);
const token = urlParams.get('token');
if (token) {
// Store the token from Trackeep backend
localStorage.setItem('token', token);
setStatus('success');
setMessage('Authentication successful! Redirecting...');
// Redirect to dashboard after a short delay
setTimeout(() => {
window.location.href = '/app';
}, 2000);
} else {
setStatus('error');
setMessage('Authentication failed. Please try again.');
}
});
return (
<div class="min-h-screen flex items-center justify-center bg-background">
<Card class="p-8 max-w-md w-full">
<div class="text-center">
{status() === 'loading' && (
<div class="flex flex-col items-center gap-4">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
<p class="text-lg text-foreground">{message()}</p>
</div>
)}
{status() === 'success' && (
<div class="flex flex-col items-center gap-4">
<div class="w-12 h-12 bg-primary rounded-full flex items-center justify-center">
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
</div>
<p class="text-lg text-primary font-medium">{message()}</p>
</div>
)}
{status() === 'error' && (
<div class="flex flex-col items-center gap-4">
<div class="w-12 h-12 bg-destructive rounded-full flex items-center justify-center">
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</div>
<p class="text-lg text-destructive font-medium">{message()}</p>
<Button onClick={() => window.location.href = '/login'}>
Back to Login
</Button>
</div>
)}
</div>
</Card>
</div>
);
};
-220
View File
@@ -1,220 +0,0 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import {
IconBookmark,
IconSearch,
IconPlus,
IconExternalLink,
IconTag,
IconClock,
IconLoader2
} from '@tabler/icons-solidjs'
import { createSignal, onMount, For } from 'solid-js'
import { bookmarksApi, type Bookmark } from '@/lib/api'
export function Bookmarks() {
const [bookmarks, setBookmarks] = createSignal<Bookmark[]>([])
const [loading, setLoading] = createSignal(true)
const [searchQuery, setSearchQuery] = createSignal('')
const [error, setError] = createSignal<string | null>(null)
const loadBookmarks = async () => {
try {
setLoading(true)
setError(null)
const data = await bookmarksApi.getAll()
setBookmarks(data)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load bookmarks')
console.error('Error loading bookmarks:', err)
} finally {
setLoading(false)
}
}
const filteredBookmarks = () => {
const query = searchQuery().toLowerCase()
if (!query) return bookmarks()
return bookmarks().filter(bookmark =>
bookmark.title.toLowerCase().includes(query) ||
bookmark.description?.toLowerCase().includes(query) ||
bookmark.url.toLowerCase().includes(query) ||
bookmark.tags.some(tag => tag.toLowerCase().includes(query))
)
}
const handleDeleteBookmark = async (id: number) => {
if (!confirm('Are you sure you want to delete this bookmark?')) return
try {
await bookmarksApi.delete(id)
setBookmarks(prev => prev.filter(b => b.id !== id))
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete bookmark')
console.error('Error deleting bookmark:', err)
}
}
onMount(() => {
loadBookmarks()
})
return (
<div class="space-y-6">
{/* Page Header */}
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-white">Bookmarks</h1>
<p class="text-gray-400 mt-2">Manage and organize your saved links</p>
</div>
<Button>
<IconPlus class="mr-2 h-4 w-4" />
Add Bookmark
</Button>
</div>
{/* Error Message */}
{error() && (
<div class="bg-red-900/20 border border-red-700 text-red-400 px-4 py-3 rounded-lg">
{error()}
<Button
variant="ghost"
size="sm"
class="ml-2 text-red-400 hover:text-red-300"
onClick={() => setError(null)}
>
Dismiss
</Button>
</div>
)}
{/* Search and Filters */}
<div class="flex flex-col sm:flex-row gap-4">
<div class="relative flex-1">
<IconSearch class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Input
type="search"
placeholder="Search bookmarks..."
class="pl-10 bg-gray-800 border-gray-700 text-white placeholder-gray-400"
value={searchQuery()}
onInput={(e) => {
const target = e.currentTarget as HTMLInputElement
if (target) setSearchQuery(target.value)
}}
/>
</div>
<div class="flex gap-2">
<Button variant="outline" size="sm">
<IconTag class="mr-2 h-4 w-4" />
All Tags
</Button>
<Button variant="outline" size="sm">
<IconClock class="mr-2 h-4 w-4" />
Recent
</Button>
</div>
</div>
{/* Loading State */}
{loading() && (
<div class="flex items-center justify-center py-12">
<IconLoader2 class="h-8 w-8 animate-spin text-primary-500" />
<span class="ml-2 text-gray-400">Loading bookmarks...</span>
</div>
)}
{/* Empty State */}
{!loading() && filteredBookmarks().length === 0 && (
<div class="text-center py-12">
<IconBookmark class="h-12 w-12 text-gray-600 mx-auto mb-4" />
<h3 class="text-lg font-medium text-gray-300 mb-2">
{searchQuery() ? 'No bookmarks found' : 'No bookmarks yet'}
</h3>
<p class="text-gray-500">
{searchQuery()
? 'Try adjusting your search terms'
: 'Start by adding your first bookmark'
}
</p>
</div>
)}
{/* Bookmarks Grid */}
{!loading() && (
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<For each={filteredBookmarks()}>
{(bookmark) => (
<Card class="hover:shadow-lg transition-shadow">
<CardHeader class="pb-3">
<div class="flex items-start justify-between">
<div class="flex items-center space-x-3">
<span class="text-2xl">🔖</span>
<div class="min-w-0 flex-1">
<CardTitle class="text-lg text-white truncate">
{bookmark.title}
</CardTitle>
<CardDescription class="text-xs text-primary-400 truncate">
{bookmark.url}
</CardDescription>
</div>
</div>
<Button
variant="ghost"
size="icon"
class="text-gray-400 hover:text-white"
onClick={() => window.open(bookmark.url, '_blank')}
>
<IconExternalLink class="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent class="space-y-3">
{bookmark.description && (
<p class="text-sm text-gray-300 line-clamp-2">
{bookmark.description}
</p>
)}
{/* Tags */}
<div class="flex flex-wrap gap-1">
<For each={bookmark.tags}>
{(tag) => (
<span
class="inline-flex items-center px-2 py-1 rounded-full text-xs bg-gray-700 text-gray-300"
>
{tag}
</span>
)}
</For>
</div>
{/* Actions */}
<div class="flex items-center justify-between pt-2 border-t border-gray-700">
<span class="text-xs text-gray-400">
{new Date(bookmark.created_at).toLocaleDateString()}
</span>
<div class="flex space-x-1">
<Button variant="ghost" size="sm" class="text-gray-400 hover:text-white">
Edit
</Button>
<Button
variant="ghost"
size="sm"
class="text-gray-400 hover:text-red-400"
onClick={() => handleDeleteBookmark(bookmark.id)}
>
Delete
</Button>
</div>
</div>
</CardContent>
</Card>
)}
</For>
</div>
)}
</div>
)
}
+577 -219
View File
@@ -1,245 +1,603 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { ErrorBoundary } from '@/components/ui/ErrorBoundary'
import { SkeletonGrid } from '@/components/ui/LoadingState'
import {
IconBookmark,
IconSearch,
IconPlus,
IconExternalLink,
IconTag,
IconClock,
IconStar,
IconStarOff,
IconRefresh,
IconAlertTriangle
} from '@tabler/icons-solidjs'
import { createSignal, For, Show } from 'solid-js'
import { bookmarksApi, type Bookmark } from '@/lib/api-client'
import { createSignal, onMount, Show } from 'solid-js';
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 { DropdownMenu, DropdownMenuItem } from '@/components/ui/DropdownMenu';
import { SearchTagFilterBar } from '@/components/ui/SearchTagFilterBar';
import { IconDotsVertical, IconStar, IconEdit, IconTrash, IconExternalLink, IconVideo } from '@tabler/icons-solidjs';
import { getMockBookmarks, getMockVideos } from '@/lib/mockData';
export function Bookmarks() {
const [searchQuery, setSearchQuery] = createSignal('')
const bookmarksQuery = bookmarksApi.useGetAll()
const deleteBookmarkMutation = bookmarksApi.useDelete()
const updateBookmarkMutation = bookmarksApi.useUpdate()
interface BookmarkTag {
id: number;
name: string;
color?: string;
}
interface Bookmark {
id: number;
title: string;
url: string;
description?: string;
// Normalized tags: always string[] for easier filtering/rendering
tags: string[];
created_at?: string;
isImportant?: boolean;
favicon?: string;
screenshot?: string;
screenshot_thumbnail?: string;
screenshot_medium?: string;
screenshot_large?: string;
screenshot_original?: string;
}
export const Bookmarks = () => {
const adaptBookmarkFromApi = (raw: any): Bookmark => {
const rawTags: BookmarkTag[] | string[] | undefined = raw.tags;
let tags: string[] = [];
if (Array.isArray(rawTags)) {
if (rawTags.length > 0 && typeof rawTags[0] === 'string') {
tags = rawTags as string[];
} else {
tags = (rawTags as BookmarkTag[]).map((t) => t.name).filter(Boolean);
}
}
return {
id: raw.id,
title: raw.title || raw.url,
url: raw.url,
description: raw.description,
tags,
created_at: raw.created_at,
isImportant: raw.is_favorite ?? raw.isImportant ?? false,
favicon: raw.favicon,
screenshot: raw.screenshot,
screenshot_thumbnail: raw.screenshot_thumbnail,
screenshot_medium: raw.screenshot_medium,
screenshot_large: raw.screenshot_large,
screenshot_original: raw.screenshot_original,
};
};
const getFaviconUrl = (bookmark: Bookmark) => {
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`;
} catch {
return '';
}
};
const getScreenshotUrl = (bookmark: Bookmark) => {
return (
bookmark.screenshot_medium ||
bookmark.screenshot ||
bookmark.screenshot_large ||
bookmark.screenshot_thumbnail ||
bookmark.screenshot_original ||
''
);
};
const [bookmarks, setBookmarks] = createSignal<Bookmark[]>([]);
const [videoBookmarks, setVideoBookmarks] = createSignal<any[]>([]);
const [isLoading, setIsLoading] = createSignal(true);
const [isLoadingVideos, setIsLoadingVideos] = createSignal(true);
const [searchTerm, setSearchTerm] = createSignal('');
const [selectedTag, setSelectedTag] = createSignal('');
const [showAddModal, setShowAddModal] = createSignal(false);
const [showEditModal, setShowEditModal] = 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
onMount(async () => {
// Check if we're in demo mode and load mock data directly
const isDemoMode = localStorage.getItem('demoMode') === 'true' ||
document.title.includes('Demo Mode') ||
window.location.search.includes('demo=true');
if (isDemoMode) {
console.log('Demo mode detected, loading mock bookmarks');
const mockBookmarks = getMockBookmarks();
const adaptedBookmarks: Bookmark[] = mockBookmarks.map((bookmark, index) => ({
id: index + 1,
title: bookmark.title,
url: bookmark.url,
description: bookmark.description,
tags: bookmark.tags.map((tag) => tag.name),
created_at: bookmark.createdAt,
isImportant: bookmark.tags.some((tag) => tag.name === 'important' || tag.name === 'favorite'),
favicon: bookmark.favicon,
screenshot: bookmark.screenshot,
screenshot_medium: bookmark.screenshot,
}));
setBookmarks(adaptedBookmarks);
setIsLoading(false);
// Load mock video bookmarks
const mockVideos = getMockVideos();
setVideoBookmarks(mockVideos);
setIsLoadingVideos(false);
return;
}
try {
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8081/api/v1';
const response = await fetch(`${API_BASE_URL}/bookmarks`, {
headers: {
'Authorization': localStorage.getItem('trackeep_token') ? `Bearer ${localStorage.getItem('trackeep_token')}` : '',
},
});
if (!response.ok) {
throw new Error('Failed to load bookmarks');
}
const data = await response.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);
setBookmarks(normalized);
} catch (error) {
console.error('Failed to load bookmarks:', error);
// Fallback to mock data if API fails
const mockBookmarks = getMockBookmarks();
const adaptedBookmarks: Bookmark[] = mockBookmarks.map((bookmark, index) => ({
id: index + 1,
title: bookmark.title,
url: bookmark.url,
description: bookmark.description,
tags: bookmark.tags.map((tag) => tag.name),
created_at: bookmark.createdAt,
isImportant: bookmark.tags.some((tag) => tag.name === 'important' || tag.name === 'favorite'),
favicon: bookmark.favicon,
screenshot: bookmark.screenshot,
screenshot_medium: bookmark.screenshot,
}));
setBookmarks(adaptedBookmarks);
} finally {
setIsLoading(false);
}
});
// Get all unique tags from bookmarks
const getAllTags = () => {
const tags = new Set<string>();
bookmarks().forEach((bookmark) => {
(bookmark.tags || []).forEach((tag) => tags.add(tag));
});
return Array.from(tags).sort();
};
const filteredBookmarks = () => {
const query = searchQuery().toLowerCase()
if (!query) return bookmarksQuery.data || []
const term = searchTerm().toLowerCase();
const tag = selectedTag();
return (bookmarksQuery.data || []).filter(bookmark =>
bookmark.title.toLowerCase().includes(query) ||
bookmark.description?.toLowerCase().includes(query) ||
bookmark.url.toLowerCase().includes(query) ||
bookmark.tags.some(tag => tag.toLowerCase().includes(query))
)
}
return bookmarks().filter(bookmark => {
const matchesSearch = !term ||
bookmark.title.toLowerCase().includes(term) ||
bookmark.url.toLowerCase().includes(term) ||
bookmark.description?.toLowerCase().includes(term) ||
(bookmark.tags || []).some((t) => t.toLowerCase().includes(term));
const matchesTag = !tag || (bookmark.tags || []).includes(tag);
return matchesSearch && matchesTag;
});
};
const handleDeleteBookmark = async (id: number) => {
if (!confirm('Are you sure you want to delete this bookmark?')) return
// We no longer fetch or display full page metadata/content previews here.
const handleAddBookmark = async (bookmarkData: any) => {
try {
await deleteBookmarkMutation.mutateAsync(id)
} catch (error) {
console.error('Error deleting bookmark:', error)
// Error is already handled by the mutation's onError callback
}
}
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
const response = await fetch(`${API_BASE_URL}/bookmarks`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': localStorage.getItem('trackeep_token') ? `Bearer ${localStorage.getItem('trackeep_token')}` : '',
},
body: JSON.stringify(bookmarkData),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to create bookmark');
}
const raw = await response.json();
const newBookmark = adaptBookmarkFromApi(raw);
setBookmarks(prev => [newBookmark, ...prev]);
setShowAddModal(false);
} catch (error) {
alert(error instanceof Error ? error.message : 'Failed to add bookmark');
}
};
const toggleImportant = (bookmarkId: number) => {
setBookmarks((prev) =>
prev.map((bookmark) =>
bookmark.id === bookmarkId
? { ...bookmark, isImportant: !bookmark.isImportant }
: bookmark
)
);
};
const deleteBookmark = async (bookmarkId: number) => {
if (confirm('Are you sure you want to delete this bookmark?')) {
try {
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
const response = await fetch(`${API_BASE_URL}/bookmarks/${bookmarkId}`, {
method: 'DELETE',
headers: {
'Authorization': localStorage.getItem('trackeep_token') ? `Bearer ${localStorage.getItem('trackeep_token')}` : '',
},
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to delete bookmark');
}
setBookmarks(prev => prev.filter(bookmark => bookmark.id !== bookmarkId));
} catch (error) {
alert(error instanceof Error ? error.message : 'Failed to delete bookmark');
}
}
};
const editBookmark = (bookmark: Bookmark) => {
setEditingBookmark(bookmark);
setShowEditModal(true);
};
const handleTagClick = (tag: string) => {
setSelectedTag((current) => (current === tag ? '' : tag));
setSearchTerm(''); // Clear search when filtering by tag
};
const resetFilters = () => {
setSearchTerm('');
setSelectedTag('');
};
const handleEditBookmark = async (bookmarkData: Partial<Bookmark>) => {
if (!editingBookmark()) return;
const handleToggleFavorite = async (bookmark: Bookmark) => {
try {
await updateBookmarkMutation.mutateAsync({
id: bookmark.id,
data: { is_favorite: !bookmark.is_favorite }
})
} catch (error) {
console.error('Error updating bookmark:', error)
// Error is already handled by the mutation's onError callback
}
}
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
const response = await fetch(`${API_BASE_URL}/bookmarks/${editingBookmark()!.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': localStorage.getItem('trackeep_token') ? `Bearer ${localStorage.getItem('trackeep_token')}` : '',
},
body: JSON.stringify(bookmarkData),
});
const handleToggleRead = async (bookmark: Bookmark) => {
try {
await updateBookmarkMutation.mutateAsync({
id: bookmark.id,
data: { is_read: !bookmark.is_read }
})
} catch (error) {
console.error('Error updating bookmark:', error)
// Error is already handled by the mutation's onError callback
}
}
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to update bookmark');
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString()
}
const raw = await response.json();
const updatedBookmark = adaptBookmarkFromApi(raw);
setBookmarks(prev =>
prev.map(bookmark =>
bookmark.id === updatedBookmark.id ? updatedBookmark : bookmark
)
);
setShowEditModal(false);
setEditingBookmark(null);
} catch (error) {
alert(error instanceof Error ? error.message : 'Failed to update bookmark');
}
};
return (
<ErrorBoundary>
<div class="space-y-6">
{/* Header */}
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-[#fafafa]">Bookmarks</h1>
<p class="text-[#a3a3a3]">Save and organize your favorite links</p>
</div>
<Button class="bg-[#39b9ff] hover:bg-[#2a8fdb]">
<IconPlus class="mr-2 h-4 w-4" />
Add Bookmark
</Button>
</div>
{/* Search */}
<div class="relative">
<IconSearch class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#a3a3a3]" />
<Input
type="search"
placeholder="Search bookmarks..."
value={searchQuery()}
onInput={(e) => e.target && setSearchQuery((e.target as HTMLInputElement).value)}
class="pl-10 bg-[#141415] border-[#262626] text-[#fafafa] placeholder-[#a3a3a3]"
/>
</div>
{/* Loading State */}
<Show when={bookmarksQuery.isLoading}>
<SkeletonGrid count={6} />
</Show>
{/* Error State */}
<Show when={bookmarksQuery.isError}>
<div class="bg-red-500/10 border border-red-500/50 text-red-400 px-4 py-3 rounded-lg flex items-center justify-between">
<div class="flex items-center">
<IconAlertTriangle class="mr-2 h-5 w-5" />
<span>Failed to load bookmarks: {bookmarksQuery.error?.message}</span>
<div class="p-6 space-y-6">
<div class="flex justify-between items-center">
<div>
<h1 class="text-3xl font-bold text-foreground">Bookmarks</h1>
<Show when={localStorage.getItem('demoMode') === 'true' || window.location.search.includes('demo=true')}>
<div class="flex items-center gap-2 mt-2">
<span class="px-2 py-1 bg-yellow-100 text-yellow-800 text-xs font-medium rounded-full">
Demo Mode
</span>
<span class="text-sm text-muted-foreground">Showing sample bookmarks</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => bookmarksQuery.refetch()}
class="text-red-400 hover:text-red-300"
>
<IconRefresh class="mr-2 h-4 w-4" />
Retry
</Button>
</Show>
</div>
<Button onClick={() => setShowAddModal(true)}>
Add Bookmark
</Button>
</div>
{/* Tabs */}
<div class="border-b border-border">
<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 ${
activeTab() === 'bookmarks'
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-muted'
}`}
>
Web Bookmarks
</button>
<button
onClick={() => setActiveTab('videos')}
class={`py-2 px-1 border-b-2 font-medium text-sm transition-colors flex items-center gap-2 ${
activeTab() === 'videos'
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-muted'
}`}
>
<IconVideo class="size-4" />
Video Bookmarks
</button>
</nav>
</div>
{/* Content based on active tab */}
<Show when={activeTab() === 'bookmarks'}>
<SearchTagFilterBar
searchPlaceholder="Search bookmarks..."
searchValue={searchTerm()}
onSearchChange={(value) => setSearchTerm(value)}
tagOptions={getAllTags()}
selectedTag={selectedTag()}
onTagChange={(value) => setSelectedTag(value)}
onReset={resetFilters}
/>
<BookmarkModal
isOpen={showAddModal()}
onClose={() => setShowAddModal(false)}
onSubmit={handleAddBookmark}
availableTags={getAllTags()}
/>
<EditBookmarkModal
isOpen={showEditModal()}
onClose={() => {
setShowEditModal(false);
setEditingBookmark(null);
}}
onSubmit={handleEditBookmark}
bookmark={editingBookmark()}
availableTags={getAllTags()}
/>
{isLoading() ? (
<div class="space-y-4">
{[...Array(3)].map(() => (
<Card class="p-6">
<div class="animate-pulse">
<div class="h-6 bg-muted rounded mb-2"></div>
<div class="h-4 bg-muted rounded mb-2 w-3/4"></div>
<div class="h-4 bg-muted rounded w-1/2"></div>
</div>
</Card>
))}
</div>
</Show>
{/* Bookmarks Grid */}
<Show when={!bookmarksQuery.isLoading && !bookmarksQuery.isError}>
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<For each={filteredBookmarks()}>
{(bookmark) => (
<Card class="bg-[#141415] border-[#262626] hover:border-[#39b9ff] transition-colors">
<CardHeader class="pb-3">
<div class="flex items-start justify-between">
) : (
<div class="space-y-4">
{filteredBookmarks().map((bookmark) => {
const faviconUrl = getFaviconUrl(bookmark);
const screenshotUrl = getScreenshotUrl(bookmark);
return (
<Card class="p-6 hover:bg-accent transition-colors">
<div class="flex justify-between items-start gap-4">
{/* Left side: preview image + favicon + title + URL + tags */}
<div class="flex-1 min-w-0">
<CardTitle class="text-[#fafafa] truncate">
<a
href={bookmark.url}
target="_blank"
rel="noopener noreferrer"
class="hover:text-[#39b9ff] transition-colors"
{screenshotUrl && (
<div class="mb-3 rounded-md overflow-hidden border border-border bg-muted/40">
<img
src={screenshotUrl}
alt="Website preview"
class="w-full h-32 sm:h-40 object-cover"
loading="lazy"
onError={(e) => {
e.currentTarget.style.display = 'none';
}}
/>
</div>
)}
<div class="flex items-center gap-3 mb-2">
<div class="flex-shrink-0 w-8 h-8 bg-muted rounded-md flex items-center justify-center overflow-hidden">
{faviconUrl ? (
<img
src={faviconUrl}
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>`;
}}
/>
) : (
<span class="text-xs text-muted-foreground font-medium">
{bookmark.title.charAt(0).toUpperCase()}
</span>
)}
</div>
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-foreground truncate">
<a
href={bookmark.url}
target="_blank"
rel="noopener noreferrer"
class="text-primary hover:text-primary/80 transition-colors flex items-center gap-1"
>
{bookmark.title}
<IconExternalLink class="size-5 ml-1.5 flex-shrink-0 text-current" />
</a>
</h3>
<p class="text-muted-foreground text-sm truncate">{bookmark.url}</p>
</div>
</div>
{bookmark.description && (
<p class="text-foreground text-sm mb-3 line-clamp-2">{bookmark.description}</p>
)}
<div class="flex flex-wrap gap-2 mt-1">
{(bookmark.tags || []).map((tag) => (
<button
onClick={() => handleTagClick(tag)}
class={`px-2 py-1 text-xs rounded-md border transition-colors cursor-pointer
${selectedTag() === tag
? 'bg-primary text-primary-foreground border-primary'
: 'bg-muted text-muted-foreground border-transparent hover:bg-primary hover:text-primary-foreground hover:border-primary'
}`}
title={`Click to filter by ${tag}`}
>
{bookmark.title}
</a>
</CardTitle>
<CardDescription class="text-[#a3a3a3] text-xs mt-1">
{new URL(bookmark.url).hostname}
</CardDescription>
</div>
<div class="flex items-center space-x-1 ml-2">
<Button
variant="ghost"
size="icon"
class="h-8 w-8 text-[#a3a3a3] hover:text-[#fafafa]"
onClick={() => handleToggleFavorite(bookmark)}
>
<Show when={bookmark.is_favorite} fallback={<IconStarOff class="h-4 w-4" />}>
<IconStar class="h-4 w-4 text-yellow-500" />
</Show>
</Button>
{tag}
</button>
))}
</div>
</div>
</CardHeader>
<CardContent class="space-y-3">
<Show when={bookmark.description}>
<p class="text-sm text-[#a3a3a3] line-clamp-2">
{bookmark.description}
</p>
</Show>
{/* Tags */}
<Show when={bookmark.tags.length > 0}>
<div class="flex flex-wrap gap-1">
<For each={bookmark.tags}>
{(tag) => (
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs bg-[#262626] text-[#a3a3a3]">
<IconTag class="mr-1 h-3 w-3" />
{tag}
</span>
)}
</For>
</div>
</Show>
{/* Actions */}
<div class="flex items-center justify-between pt-2 border-t border-[#262626]">
<div class="flex items-center space-x-2">
<Button
variant="ghost"
size="sm"
class={`text-xs ${bookmark.is_read ? 'text-[#a3a3a3]' : 'text-[#39b9ff]'}`}
onClick={() => handleToggleRead(bookmark)}
{/* Right side: optional date above important star + menu */}
<div class="flex flex-col items-end gap-2 ml-2">
{bookmark.created_at && !isNaN(new Date(bookmark.created_at).getTime()) && (
<div class="text-muted-foreground text-xs">
{new Date(bookmark.created_at).toLocaleDateString()}
</div>
)}
<div class="flex items-center gap-2">
<button
onClick={() => toggleImportant(bookmark.id)}
class={`flex-shrink-0 p-1 rounded hover:bg-accent/50 transition-colors ${
bookmark.isImportant ? 'order-first' : ''
}`}
title={bookmark.isImportant ? 'Remove from favorites' : 'Mark as favorite'}
>
{bookmark.is_read ? 'Read' : 'Unread'}
</Button>
<span class="text-xs text-[#a3a3a3] flex items-center">
<IconClock class="mr-1 h-3 w-3" />
{formatDate(bookmark.created_at)}
</span>
</div>
<div class="flex items-center space-x-1">
<Button
variant="ghost"
size="icon"
class="h-8 w-8 text-[#a3a3a3] hover:text-[#fafafa]"
onClick={() => window.open(bookmark.url, '_blank')}
<IconStar
class={`size-4 ${
bookmark.isImportant
? 'text-primary fill-primary'
: 'text-muted-foreground hover:text-foreground'
}`}
/>
</button>
<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>
}
>
<IconExternalLink class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 text-[#a3a3a3] hover:text-red-400"
onClick={() => handleDeleteBookmark(bookmark.id)}
>
×
</Button>
<DropdownMenuItem onClick={() => editBookmark(bookmark)} icon={IconEdit}>
Edit
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => toggleImportant(bookmark.id)}
icon={IconStar}
>
{bookmark.isImportant ? 'Remove from favorites' : 'Mark as favorite'}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => deleteBookmark(bookmark.id)}
icon={IconTrash}
variant="destructive"
>
Delete
</DropdownMenuItem>
</DropdownMenu>
</div>
</div>
</CardContent>
</div>
</Card>
);
})}
{filteredBookmarks().length === 0 && (
<Card class="p-12 text-center">
<p class="text-muted-foreground">
{searchTerm() ? 'No bookmarks found matching your search.' : 'No bookmarks yet. Add your first bookmark!'}
</p>
</Card>
)}
</For>
</div>
{/* Empty State */}
<Show when={filteredBookmarks().length === 0}>
<div class="text-center py-12">
<IconBookmark class="mx-auto h-12 w-12 text-[#a3a3a3]" />
<h3 class="mt-2 text-sm font-medium text-[#fafafa]">No bookmarks found</h3>
<p class="mt-1 text-sm text-[#a3a3a3]">
{searchQuery() ? 'Try adjusting your search terms' : 'Get started by adding your first bookmark'}
</p>
</div>
</Show>
</Show>
</div>
</ErrorBoundary>
)
}
)}
</Show>
<Show when={activeTab() === 'videos'}>
{isLoadingVideos() ? (
<div class="space-y-4">
{[...Array(3)].map(() => (
<Card class="p-6">
<div class="animate-pulse">
<div class="h-6 bg-muted rounded mb-2"></div>
<div class="h-4 bg-muted rounded mb-2 w-3/4"></div>
<div class="h-4 bg-muted rounded w-1/2"></div>
</div>
</Card>
))}
</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"
/>
</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" />
</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>
</div>
</div>
</Card>
))}
{videoBookmarks().length === 0 && (
<Card class="p-12 text-center">
<p class="text-muted-foreground">
No video bookmarks yet. Save your first YouTube video!
</p>
</Card>
)}
</div>
)}
</Show>
</div>
);
};
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+652
View File
@@ -0,0 +1,652 @@
import { createSignal, onMount, For, Show } from 'solid-js';
import { IconPalette, IconCheck, IconRepeat, IconSun, IconMoon, IconDownload, IconUpload, IconEye, IconEyeOff } from '@tabler/icons-solidjs';
interface ColorScheme {
name: string;
primary: string;
background: string;
foreground: string;
muted: string;
border: string;
}
export const ColorSwitcher = () => {
const [schemes, setSchemes] = createSignal<ColorScheme[]>([]);
const [currentScheme, setCurrentScheme] = createSignal('default');
const [isDarkMode, setIsDarkMode] = createSignal(false);
const [customColors, setCustomColors] = createSignal({
primary: '#5ab9ff',
background: '#000000',
foreground: '#ffffff',
muted: '#262727',
border: '#262626'
});
const [showAdvanced, setShowAdvanced] = createSignal(false);
const [savedSchemes, setSavedSchemes] = createSignal<ColorScheme[]>([]);
const [showPreview, setShowPreview] = createSignal(true);
onMount(() => {
// Check current theme
const currentTheme = document.documentElement.getAttribute('data-kb-theme');
setIsDarkMode(currentTheme === 'dark');
// Load saved color scheme from localStorage
const savedScheme = localStorage.getItem('colorScheme');
const savedColors = localStorage.getItem('customColors');
if (savedColors && savedScheme === 'custom') {
try {
const colors = JSON.parse(savedColors);
setCustomColors(colors);
applyCustomColors();
} catch (e) {
console.error('Failed to load custom colors:', e);
}
} else if (savedScheme) {
setCurrentScheme(savedScheme);
}
// Predefined color schemes with more options
setSchemes([
{
name: 'default',
primary: '#5ab9ff',
background: isDarkMode() ? '#1a1a1a' : '#ffffff',
foreground: isDarkMode() ? '#ffffff' : '#000000',
muted: isDarkMode() ? '#262727' : '#f5f5f5',
border: '#262626'
},
{
name: 'ocean',
primary: '#0077be',
background: isDarkMode() ? '#001f3f' : '#e6f3ff',
foreground: isDarkMode() ? '#ffffff' : '#000000',
muted: isDarkMode() ? '#003366' : '#cce7ff',
border: '#004080'
},
{
name: 'forest',
primary: '#228b22',
background: isDarkMode() ? '#0d2818' : '#f0f8f0',
foreground: isDarkMode() ? '#ffffff' : '#000000',
muted: isDarkMode() ? '#1a431a' : '#d4edd4',
border: '#2d5a2d'
},
{
name: 'sunset',
primary: '#ff6b35',
background: isDarkMode() ? '#2c1810' : '#fff5f0',
foreground: isDarkMode() ? '#ffffff' : '#000000',
muted: isDarkMode() ? '#5c2e00' : '#ffe4d6',
border: '#8b4513'
},
{
name: 'purple',
primary: '#8b5cf6',
background: isDarkMode() ? '#1a0033' : '#f8f5ff',
foreground: isDarkMode() ? '#ffffff' : '#000000',
muted: isDarkMode() ? '#330066' : '#ede9fe',
border: '#4d0099'
},
{
name: 'rose',
primary: '#f43f5e',
background: isDarkMode() ? '#2d1111' : '#fff1f2',
foreground: isDarkMode() ? '#ffffff' : '#000000',
muted: isDarkMode() ? '#5a1a1a' : '#ffe4e6',
border: '#881337'
},
{
name: 'amber',
primary: '#f59e0b',
background: isDarkMode() ? '#2d1a00' : '#fffbeb',
foreground: isDarkMode() ? '#ffffff' : '#000000',
muted: isDarkMode() ? '#5c4a00' : '#fef3c7',
border: '#78350f'
},
{
name: 'emerald',
primary: '#10b981',
background: isDarkMode() ? '#022c22' : '#ecfdf5',
foreground: isDarkMode() ? '#ffffff' : '#000000',
muted: isDarkMode() ? '#064e3b' : '#d1fae5',
border: '#047857'
},
{
name: 'cyan',
primary: '#06b6d4',
background: isDarkMode() ? '#022c3a' : '#ecfeff',
foreground: isDarkMode() ? '#ffffff' : '#000000',
muted: isDarkMode() ? '#164e63' : '#cffafe',
border: '#0891b2'
},
{
name: 'indigo',
primary: '#6366f1',
background: isDarkMode() ? '#1e1b4b' : '#eef2ff',
foreground: isDarkMode() ? '#ffffff' : '#000000',
muted: isDarkMode() ? '#312e81' : '#e0e7ff',
border: '#4338ca'
}
]);
});
const toggleDarkMode = () => {
const newDarkMode = !isDarkMode();
setIsDarkMode(newDarkMode);
if (newDarkMode) {
document.documentElement.setAttribute('data-kb-theme', 'dark');
localStorage.setItem('theme', 'dark');
} else {
document.documentElement.removeAttribute('data-kb-theme');
localStorage.setItem('theme', 'light');
}
// Update schemes with new theme
updateSchemesForTheme(newDarkMode);
};
const updateSchemesForTheme = (dark: boolean) => {
setSchemes([
{
name: 'default',
primary: '#5ab9ff',
background: dark ? '#1a1a1a' : '#ffffff',
foreground: dark ? '#ffffff' : '#000000',
muted: dark ? '#262727' : '#f5f5f5',
border: '#262626'
},
{
name: 'ocean',
primary: '#0077be',
background: dark ? '#001f3f' : '#e6f3ff',
foreground: dark ? '#ffffff' : '#000000',
muted: dark ? '#003366' : '#cce7ff',
border: '#004080'
},
{
name: 'forest',
primary: '#228b22',
background: dark ? '#0d2818' : '#f0f8f0',
foreground: dark ? '#ffffff' : '#000000',
muted: dark ? '#1a431a' : '#d4edd4',
border: '#2d5a2d'
},
{
name: 'sunset',
primary: '#ff6b35',
background: dark ? '#2c1810' : '#fff5f0',
foreground: dark ? '#ffffff' : '#000000',
muted: dark ? '#5c2e00' : '#ffe4d6',
border: '#8b4513'
},
{
name: 'purple',
primary: '#8b5cf6',
background: dark ? '#1a0033' : '#f8f5ff',
foreground: dark ? '#ffffff' : '#000000',
muted: dark ? '#330066' : '#ede9fe',
border: '#4d0099'
}
]);
};
const applyScheme = (scheme: ColorScheme) => {
setCurrentScheme(scheme.name);
setCustomColors(scheme);
// Save to localStorage for persistence
localStorage.setItem('colorScheme', scheme.name);
localStorage.removeItem('customColors'); // Clear custom colors when applying preset
// Apply colors to CSS variables with proper HSL conversion
const root = document.documentElement;
// Convert hex to HSL for CSS variables
const hexToHsl = (hex: string) => {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
if (!result) return '0 0% 100%';
let r = parseInt(result[1], 16) / 255;
let g = parseInt(result[2], 16) / 255;
let b = parseInt(result[3], 16) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
let h = 0, s = 0, l = (max + min) / 2;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
case g: h = ((b - r) / d + 2) / 6; break;
case b: h = ((r - g) / d + 4) / 6; break;
}
}
return `${Math.round(h * 360)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`;
};
// Apply the colors
root.style.setProperty('--primary', hexToHsl(scheme.primary));
root.style.setProperty('--background', hexToHsl(scheme.background));
root.style.setProperty('--foreground', hexToHsl(scheme.foreground));
root.style.setProperty('--muted', hexToHsl(scheme.muted));
root.style.setProperty('--border', scheme.border);
// Also set as CSS custom properties for direct use
root.style.setProperty('--colors-primary', hexToHsl(scheme.primary));
root.style.setProperty('--colors-background', hexToHsl(scheme.background));
root.style.setProperty('--colors-foreground', hexToHsl(scheme.foreground));
root.style.setProperty('--colors-muted', hexToHsl(scheme.muted));
root.style.setProperty('--colors-border', scheme.border);
};
const applyCustomColors = () => {
const root = document.documentElement;
const colors = customColors();
// Save custom colors to localStorage
localStorage.setItem('colorScheme', 'custom');
localStorage.setItem('customColors', JSON.stringify(colors));
// Convert hex to HSL for CSS variables
const hexToHsl = (hex: string) => {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
if (!result) return '0 0% 100%';
let r = parseInt(result[1], 16) / 255;
let g = parseInt(result[2], 16) / 255;
let b = parseInt(result[3], 16) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
let h = 0, s = 0, l = (max + min) / 2;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
case g: h = ((b - r) / d + 2) / 6; break;
case b: h = ((r - g) / d + 4) / 6; break;
}
}
return `${Math.round(h * 360)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`;
};
// Apply the colors
root.style.setProperty('--primary', hexToHsl(colors.primary));
root.style.setProperty('--background', hexToHsl(colors.background));
root.style.setProperty('--foreground', hexToHsl(colors.foreground));
root.style.setProperty('--muted', hexToHsl(colors.muted));
root.style.setProperty('--border', colors.border);
// Also set as CSS custom properties for direct use
root.style.setProperty('--colors-primary', hexToHsl(colors.primary));
root.style.setProperty('--colors-background', hexToHsl(colors.background));
root.style.setProperty('--colors-foreground', hexToHsl(colors.foreground));
root.style.setProperty('--colors-muted', hexToHsl(colors.muted));
root.style.setProperty('--colors-border', colors.border);
setCurrentScheme('custom');
};
const resetColors = () => {
const defaultScheme = schemes().find(s => s.name === 'default');
if (defaultScheme) {
applyScheme(defaultScheme);
}
};
// Advanced functions
const exportColorScheme = () => {
const scheme = currentScheme() === 'custom' ? { ...customColors(), name: 'custom' } : schemes().find(s => s.name === currentScheme());
if (scheme) {
const data = JSON.stringify(scheme, null, 2);
const blob = new Blob([data], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${scheme.name}-color-scheme.json`;
a.click();
URL.revokeObjectURL(url);
}
};
const importColorScheme = (event: Event) => {
const file = (event.target as HTMLInputElement).files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
try {
const scheme = JSON.parse(e.target?.result as string) as ColorScheme;
if (scheme.name && scheme.primary && scheme.background && scheme.foreground && scheme.muted && scheme.border) {
setCustomColors(scheme);
applyCustomColors();
} else {
alert('Invalid color scheme format');
}
} catch (error) {
alert('Failed to import color scheme');
}
};
reader.readAsText(file);
}
};
const saveCustomScheme = () => {
const schemeName = prompt('Enter a name for your custom scheme:');
if (schemeName && customColors()) {
const newScheme: ColorScheme = {
name: schemeName,
...customColors()
};
const updatedSchemes = [...savedSchemes(), newScheme];
setSavedSchemes(updatedSchemes);
localStorage.setItem('savedSchemes', JSON.stringify(updatedSchemes));
}
};
return (
<div class="p-6 mt-4 pb-32 max-w-5xl mx-auto">
<h1 class="text-3xl font-bold text-foreground mb-6 flex items-center gap-2">
<IconPalette class="size-8" />
Color Switcher
</h1>
<div class="space-y-6">
{/* Dark Mode Toggle */}
<div class="border rounded-lg p-6">
<h2 class="text-xl font-semibold text-foreground mb-4">Theme Mode</h2>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
{isDarkMode() ? (
<IconMoon class="size-6 text-primary" />
) : (
<IconSun class="size-6 text-primary" />
)}
<div>
<h3 class="font-medium text-foreground">
{isDarkMode() ? 'Dark Mode' : 'Light Mode'}
</h3>
<p class="text-sm text-muted-foreground">
Toggle between dark and light theme
</p>
</div>
</div>
<button
onClick={toggleDarkMode}
class="inline-flex items-center justify-center rounded-md text-sm font-medium transition-shadow focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground shadow hover:bg-primary/90 h-auto items-center gap-2 py-2 px-4"
>
{isDarkMode() ? <IconSun class="size-4 text-primary-foreground" /> : <IconMoon class="size-4 text-primary-foreground" />}
Switch to {isDarkMode() ? 'Light' : 'Dark'}
</button>
</div>
</div>
{/* Predefined Schemes */}
<div class="border rounded-lg p-6">
<h2 class="text-xl font-semibold text-foreground mb-4">Color Schemes</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{schemes().map((scheme) => (
<div
class={`border rounded-lg p-4 cursor-pointer transition-all hover:shadow-md ${
currentScheme() === scheme.name ? 'ring-2 ring-primary' : ''
}`}
onClick={() => applyScheme(scheme)}
>
<div class="flex items-center justify-between mb-3">
<h3 class="font-medium text-foreground capitalize">{scheme.name}</h3>
{currentScheme() === scheme.name && (
<IconCheck class="size-5 text-primary" />
)}
</div>
<div class="flex gap-1 mb-3">
<div
class="w-8 h-8 rounded border"
style={`background-color: ${scheme.primary}`}
title="Primary"
/>
<div
class="w-8 h-8 rounded border"
style={`background-color: ${scheme.background}`}
title="Background"
/>
<div
class="w-8 h-8 rounded border"
style={`background-color: ${scheme.muted}`}
title="Muted"
/>
<div
class="w-8 h-8 rounded border"
style={`background-color: ${scheme.border}`}
title="Border"
/>
</div>
<div class="text-xs text-muted-foreground">
Click to apply this scheme
</div>
</div>
))}
</div>
</div>
{/* Custom Colors */}
<div class="border rounded-lg p-6">
<h2 class="text-xl font-semibold text-foreground mb-4">Custom Colors</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-muted-foreground mb-2">
Primary Color
</label>
<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>
</div>
<div>
<label class="block text-sm font-medium text-muted-foreground mb-2">
Background Color
</label>
<div class="flex gap-2">
<input
type="color"
value={customColors().background}
onInput={(e) => setCustomColors(prev => ({ ...prev, background: e.currentTarget.value }))}
class="h-10 w-16 rounded border border-input"
/>
<input
type="text"
value={customColors().background}
onInput={(e) => setCustomColors(prev => ({ ...prev, background: e.currentTarget.value }))}
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring"
/>
</div>
</div>
<div>
<label class="block text-sm font-medium text-muted-foreground mb-2">
Foreground Color
</label>
<div class="flex gap-2">
<input
type="color"
value={customColors().foreground}
onInput={(e) => setCustomColors(prev => ({ ...prev, foreground: e.currentTarget.value }))}
class="h-10 w-16 rounded border border-input"
/>
<input
type="text"
value={customColors().foreground}
onInput={(e) => setCustomColors(prev => ({ ...prev, foreground: e.currentTarget.value }))}
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring"
/>
</div>
</div>
<div>
<label class="block text-sm font-medium text-muted-foreground mb-2">
Muted Color
</label>
<div class="flex gap-2">
<input
type="color"
value={customColors().muted}
onInput={(e) => setCustomColors(prev => ({ ...prev, muted: e.currentTarget.value }))}
class="h-10 w-16 rounded border border-input"
/>
<input
type="text"
value={customColors().muted}
onInput={(e) => setCustomColors(prev => ({ ...prev, muted: e.currentTarget.value }))}
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring"
/>
</div>
</div>
<div>
<label class="block text-sm font-medium text-muted-foreground mb-2">
Border Color
</label>
<div class="flex gap-2">
<input
type="color"
value={customColors().border}
onInput={(e) => setCustomColors(prev => ({ ...prev, border: e.currentTarget.value }))}
class="h-10 w-16 rounded border border-input"
/>
<input
type="text"
value={customColors().border}
onInput={(e) => setCustomColors(prev => ({ ...prev, border: e.currentTarget.value }))}
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring"
/>
</div>
</div>
</div>
<div class="flex gap-4 mt-6">
<button
type="button"
onClick={applyCustomColors}
class="inline-flex justify-center rounded-md text-sm font-medium transition-shadow focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground shadow hover:bg-primary/90 h-auto items-center gap-2 py-2 px-4"
>
<IconPalette class="size-4 text-primary-foreground" />
Apply Custom Colors
</button>
<button
type="button"
onClick={resetColors}
class="inline-flex justify-center rounded-md text-sm font-medium transition-shadow focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-inherit hover:bg-accent/50 hover:text-accent-foreground h-auto items-center gap-2 py-2 px-4 border"
>
<IconRepeat class="size-4 text-foreground" />
Reset to Default
</button>
</div>
</div>
{/* Advanced Options */}
<div class="border rounded-lg p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold text-foreground">Advanced Options</h2>
<button
type="button"
onClick={() => setShowAdvanced(!showAdvanced())}
class="inline-flex justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-inherit hover:bg-accent/50 hover:text-accent-foreground h-auto items-center gap-2 py-1 px-3"
>
{showAdvanced() ? 'Hide Advanced' : 'Show Advanced'}
</button>
</div>
<Show when={showAdvanced()}>
<div class="space-y-4">
{/* Export/Import */}
<div class="flex gap-4">
<button
type="button"
onClick={exportColorScheme}
class="inline-flex justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground shadow hover:bg-primary/90 h-auto items-center gap-2 py-2 px-4"
>
<IconDownload class="size-4 text-primary-foreground" />
Export Scheme
</button>
<label class="inline-flex justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-inherit hover:bg-accent/50 hover:text-accent-foreground h-auto items-center gap-2 py-2 px-4 border cursor-pointer">
<IconUpload class="size-4 text-foreground" />
Import Scheme
<input
type="file"
accept=".json"
onChange={importColorScheme}
class="hidden"
/>
</label>
<button
type="button"
onClick={saveCustomScheme}
class="inline-flex justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-inherit hover:bg-accent/50 hover:text-accent-foreground h-auto items-center gap-2 py-2 px-4 border"
>
Save Custom Scheme
</button>
</div>
{/* Preview Toggle */}
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-foreground">Show Preview Panel</span>
<button
type="button"
onClick={() => setShowPreview(!showPreview())}
class="inline-flex justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-inherit hover:bg-accent/50 hover:text-accent-foreground h-auto items-center gap-2 py-1 px-3"
>
{showPreview() ? <IconEye class="size-4 text-foreground" /> : <IconEyeOff class="size-4 text-foreground" />}
{showPreview() ? 'Hide' : 'Show'}
</button>
</div>
</div>
</Show>
</div>
{/* Preview */}
<Show when={showPreview()}>
<div class="border rounded-lg p-6">
<h2 class="text-xl font-semibold text-foreground mb-4">Preview</h2>
<div class="space-y-4">
<div class="p-4 bg-muted rounded-lg">
<h3 class="font-medium text-foreground mb-2">Sample Content</h3>
<p class="text-muted-foreground mb-3">
This is how your content will look with the selected colors.
</p>
<button class="inline-flex justify-center rounded-md text-sm font-medium transition-shadow focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground shadow hover:bg-primary/90 h-auto items-center gap-2 py-2 px-4">
Sample Button
</button>
</div>
<div class="border rounded-lg p-4">
<h3 class="font-medium text-foreground mb-2">Border Example</h3>
<p class="text-muted-foreground">
This shows how borders will appear with your color scheme.
</p>
</div>
</div>
</div>
</Show>
</div>
</div>
);
};
File diff suppressed because it is too large Load Diff
+477 -222
View File
@@ -1,255 +1,510 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
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 { FilePreviewModal } from '@/components/ui/FilePreviewModal';
import { getFileTypeConfig, formatFileSize, getFileCategoryColor } from '@/utils/fileTypes';
import { getMockFiles } from '@/lib/mockData';
import {
IconSearch,
IconDownload,
IconUpload,
IconEye,
IconTrash,
IconCalendar,
IconLoader2,
IconUpload
} from '@tabler/icons-solidjs'
import { createSignal, For, Show } from 'solid-js'
import { filesApi, type FileItem } from '@/lib/api-client'
IconDownload,
IconCopy,
IconShare
} from '@tabler/icons-solidjs';
const fileIcons = {
'document': '📄',
'image': '🖼️',
'video': '🎥',
'audio': '🎵',
'archive': '📦',
'other': '📁'
interface FileItem {
id: number;
name: string;
size: number;
type: string;
uploadedAt: string;
description?: string;
tags: string[];
associations?: Association[];
url?: string;
isLink?: boolean;
preview?: string;
downloadUrl?: string;
viewUrl?: string;
shareUrl?: string;
}
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
interface Association {
id: string;
type: 'task' | 'bookmark' | 'note' | 'project';
title: string;
}
export function Files() {
const [searchQuery, setSearchQuery] = createSignal('')
const filesQuery = filesApi.useGetAll()
const deleteFileMutation = filesApi.useDelete()
const uploadFileMutation = filesApi.useUpload()
export const Files = () => {
const [files, setFiles] = createSignal<FileItem[]>([]);
const [isLoading, setIsLoading] = createSignal(true);
const [searchTerm, setSearchTerm] = createSignal('');
const [selectedTags, setSelectedTags] = createSignal<string[]>([]);
const [showUploadModal, setShowUploadModal] = createSignal(false);
const [showPreviewModal, setShowPreviewModal] = createSignal(false);
const [selectedFile, setSelectedFile] = createSignal<FileItem | null>(null);
const [copiedLink, setCopiedLink] = createSignal(false);
// 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');
};
onMount(async () => {
try {
if (isDemoMode()) {
// Use mock data in demo mode
const mockFiles = getMockFiles();
const mappedFiles: FileItem[] = mockFiles.map(file => ({
id: parseInt(file.id),
name: file.name,
size: file.size,
type: file.type,
uploadedAt: file.uploadedAt,
description: file.description,
tags: file.tags.map(tag => tag.name),
associations: file.associations?.map(assoc => ({
id: assoc.id,
type: assoc.type as 'task' | 'bookmark' | 'note' | 'project',
title: assoc.title
})),
url: file.url,
isLink: file.isLink,
preview: file.preview,
downloadUrl: file.downloadUrl,
viewUrl: file.viewUrl,
shareUrl: file.shareUrl
}));
setFiles(mappedFiles);
setIsLoading(false);
return;
}
// TODO: Replace with actual API call
// const response = await fetch('/api/v1/files');
// const data = await response.json();
// Mock data for now
setFiles([
{
id: 1,
name: 'project-plan.pdf',
size: 2048576,
type: 'application/pdf',
uploadedAt: '2024-01-15T10:30:00Z',
description: 'Q1 2024 project roadmap and milestones',
tags: ['planning', 'q1-2024'],
downloadUrl: '/files/download/1',
viewUrl: '/files/view/1',
shareUrl: '/files/share/1'
},
{
id: 2,
name: 'meeting-notes.docx',
size: 524288,
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
uploadedAt: '2024-01-14T15:45:00Z',
description: 'Team sync meeting notes',
tags: ['meetings', 'team'],
downloadUrl: '/files/download/2',
viewUrl: '/files/view/2',
shareUrl: '/files/share/2'
},
{
id: 3,
name: 'screenshot.png',
size: 1024000,
type: 'image/png',
uploadedAt: '2024-01-13T09:20:00Z',
description: 'UI design mockup',
tags: ['design', 'ui'],
preview: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
associations: [
{ id: '1', type: 'project', title: 'Website Redesign' },
{ id: '2', type: 'task', title: 'Create mockups' }
],
downloadUrl: '/files/download/3',
viewUrl: '/files/view/3',
shareUrl: '/files/share/3'
},
{
id: 4,
name: 'app.js',
size: 256000,
type: 'text/javascript',
uploadedAt: '2024-01-12T14:15:00Z',
description: 'Main application logic',
tags: ['javascript', 'frontend'],
preview: 'console.log("Hello World");\n\nfunction main() {\n // Main application logic\n return true;\n}',
associations: [
{ id: '3', type: 'project', title: 'Frontend App' }
],
downloadUrl: '/files/download/4',
viewUrl: '/files/view/4',
shareUrl: '/files/share/4'
},
{
id: 5,
name: 'database.sql',
size: 512000,
type: 'application/sql',
uploadedAt: '2024-01-11T11:30:00Z',
description: 'Database schema',
tags: ['database', 'sql'],
preview: 'CREATE TABLE users (\n id INT PRIMARY KEY,\n name VARCHAR(255) NOT NULL\n);',
associations: [
{ id: '4', type: 'project', title: 'Backend API' }
],
downloadUrl: '/files/download/5',
viewUrl: '/files/view/5',
shareUrl: '/files/share/5'
},
{
id: 6,
name: 'presentation.pptx',
size: 3072000,
type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
uploadedAt: '2024-01-10T16:45:00Z',
description: 'Q4 review presentation',
tags: ['presentation', 'q4'],
downloadUrl: '/files/download/6',
viewUrl: '/files/view/6',
shareUrl: '/files/share/6'
}
]);
} catch (error) {
console.error('Failed to load files:', error);
} finally {
setIsLoading(false);
}
});
const filteredFiles = () => {
const query = searchQuery().toLowerCase()
if (!query) return filesQuery.data || []
const term = searchTerm().toLowerCase();
const tags = selectedTags();
return (filesQuery.data || []).filter(file =>
file.original_name.toLowerCase().includes(query) ||
file.mime_type.toLowerCase().includes(query)
)
}
return files().filter(file => {
const matchesSearch = file.name.toLowerCase().includes(term) ||
file.description?.toLowerCase().includes(term) ||
file.tags.some(tag => tag.toLowerCase().includes(term));
const matchesTags = tags.length === 0 ||
tags.every(tag => file.tags.includes(tag));
return matchesSearch && matchesTags;
});
};
const getFileType = (mimeType: string): string => {
if (mimeType.startsWith('image/')) return 'image'
if (mimeType.startsWith('video/')) return 'video'
if (mimeType.startsWith('audio/')) return 'audio'
if (mimeType.includes('document') || mimeType.includes('pdf') || mimeType.includes('text')) return 'document'
if (mimeType.includes('zip') || mimeType.includes('archive')) return 'archive'
return 'other'
}
const allTags = () => {
const tagSet = new Set<string>();
files().forEach(file => {
file.tags.forEach(tag => tagSet.add(tag));
});
return Array.from(tagSet).sort();
};
const handleFileUpload = async (event: Event) => {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
if (!file) return
try {
await uploadFileMutation.mutateAsync(file)
target.value = '' // Reset input
} catch (error) {
console.error('Error uploading file:', error)
alert('Failed to upload file')
const toggleTag = (tag: string) => {
const currentTags = selectedTags();
if (currentTags.includes(tag)) {
setSelectedTags([]);
} else {
setSelectedTags([tag]);
}
}
};
const handleDeleteFile = async (fileId: number) => {
if (!confirm('Are you sure you want to delete this file?')) return
const handleFileUpload = async (fileData: any) => {
try {
await deleteFileMutation.mutateAsync(fileId)
// 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',
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()}`,
shareUrl: `/files/share/${Date.now()}`
};
setFiles(prev => [newFile, ...prev]);
} catch (error) {
console.error('Error deleting file:', error)
alert('Failed to delete file')
console.error('Failed to upload file:', error);
}
}
};
const handlePreviewFile = (file: FileItem) => {
setSelectedFile(file);
setShowPreviewModal(true);
};
const handleCopyLink = async (file: FileItem) => {
try {
const link = file.isLink ? file.url : file.shareUrl || '#';
if (link) {
await navigator.clipboard.writeText(link);
setCopiedLink(true);
setTimeout(() => setCopiedLink(false), 2000);
}
} catch (error) {
console.error('Failed to copy link:', error);
}
};
const handleShareFile = (file: FileItem) => {
// In a real app, this would open a share dialog or generate a shareable link
const shareUrl = file.shareUrl || '#';
if (navigator.share) {
navigator.share({
title: file.name,
text: file.description,
url: shareUrl
});
} else {
window.open(shareUrl, '_blank');
}
};
const handleDownloadFile = (file: FileItem) => {
const link = document.createElement('a')
link.href = `http://localhost:8080/api/v1/files/${file.id}/download`
link.download = file.original_name
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
if (file.isLink && file.url) {
window.open(file.url, '_blank');
} else if (file.downloadUrl) {
// In a real app, this would trigger an actual download
const link = document.createElement('a');
link.href = file.downloadUrl;
link.download = file.name;
link.click();
}
};
const deleteFile = async (fileId: number) => {
try {
// TODO: Replace with actual API call
// await fetch(`/api/v1/files/${fileId}`, { method: 'DELETE' });
setFiles(prev => prev.filter(file => file.id !== fileId));
} catch (error) {
console.error('Failed to delete file:', error);
}
};
return (
<div class="space-y-6">
{/* Page Header */}
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-white">Files</h1>
<p class="text-gray-400 mt-2">Store and manage your documents and media</p>
</div>
<div class="relative">
<input
type="file"
id="file-upload"
class="hidden"
onChange={handleFileUpload}
disabled={uploadFileMutation.isPending}
/>
<label for="file-upload">
<Button
disabled={uploadFileMutation.isPending}
class="cursor-pointer"
onClick={() => document.getElementById('file-upload')?.click()}
>
{uploadFileMutation.isPending ? (
<>
<IconLoader2 class="mr-2 h-4 w-4 animate-spin" />
Uploading...
</>
) : (
<>
<IconUpload class="mr-2 h-4 w-4" />
Upload File
</>
)}
</Button>
</label>
</div>
<div class="p-6 space-y-6">
<div class="flex justify-between items-center">
<h1 class="text-3xl font-bold text-foreground">Files</h1>
<Button onClick={() => setShowUploadModal(true)}>
<IconUpload class="size-4 mr-2" />
Upload File
</Button>
</div>
{/* Error Display */}
<Show when={filesQuery.error}>
<div class="bg-red-900 border border-red-700 text-red-200 px-4 py-3 rounded">
Failed to load files: {filesQuery.error?.message}
<SearchTagFilterBar
searchPlaceholder="Search files..."
searchValue={searchTerm()}
onSearchChange={(value) => setSearchTerm(value)}
tagOptions={allTags()}
selectedTag={selectedTags()[0] || ''}
onTagChange={(value) => setSelectedTags(value ? [value] : [])}
onReset={() => {
setSearchTerm('');
setSelectedTags([]);
}}
/>
<Show when={copiedLink()}>
<div class="bg-primary/15 text-primary px-3 py-1 rounded-md text-sm">
Link copied!
</div>
</Show>
{/* Search and Filters */}
<div class="flex flex-col sm:flex-row gap-4">
<div class="relative flex-1">
<IconSearch class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Input
type="search"
placeholder="Search files..."
value={searchQuery()}
onInput={(e) => setSearchQuery((e.target as HTMLInputElement).value)}
class="pl-10 bg-gray-800 border-gray-700 text-white placeholder-gray-400"
/>
</div>
<div class="flex gap-2">
<Button variant="outline" size="sm">
All Types
</Button>
<Button variant="outline" size="sm">
All Tags
</Button>
<Button variant="outline" size="sm">
<IconCalendar class="mr-2 h-4 w-4" />
Recent
</Button>
</div>
</div>
{/* Loading State */}
<Show when={filesQuery.isLoading}>
<div class="flex items-center justify-center py-12">
<IconLoader2 class="h-8 w-8 animate-spin text-blue-400" />
<span class="ml-2 text-gray-400">Loading files...</span>
</div>
</Show>
{/* Files Grid */}
<Show when={!filesQuery.isLoading && !filesQuery.error}>
{isLoading() ? (
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<For each={filteredFiles()}>
{(file) => (
<Card class="hover:shadow-lg transition-shadow">
<CardHeader class="pb-3">
<div class="flex items-start justify-between">
<div class="flex items-center space-x-3">
<span class="text-2xl">
{fileIcons[getFileType(file.mime_type) as keyof typeof fileIcons] || fileIcons.other}
</span>
<div class="min-w-0 flex-1">
<CardTitle class="text-lg text-white truncate">
{file.original_name}
</CardTitle>
<CardDescription class="text-xs text-gray-400">
{formatFileSize(file.file_size)} {getFileType(file.mime_type).toUpperCase()}
</CardDescription>
{[...Array(6)].map(() => (
<Card class="p-6">
<div class="animate-pulse">
<div class="h-12 bg-[#262626] rounded mb-4"></div>
<div class="h-4 bg-[#262626] rounded mb-2"></div>
<div class="h-4 bg-[#262626] rounded w-3/4"></div>
</div>
</Card>
))}
</div>
) : (
<>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<For each={filteredFiles()}>
{(file) => {
const fileTypeConfig = getFileTypeConfig(file.type, file.name);
const IconComponent = fileTypeConfig.icon;
return (
<Card
class="p-6 hover:bg-accent/50 transition-colors cursor-pointer"
onClick={() => handlePreviewFile(file)}
>
<div class="flex items-start justify-between mb-4">
<div class={`text-3xl ${fileTypeConfig.color}`}>
<IconComponent size={32} />
</div>
<div class="flex gap-1">
<Button
variant="ghost"
onClick={(e) => {
e.stopPropagation();
handlePreviewFile(file);
}}
class="text-foreground hover:text-foreground/80 p-1"
>
<IconEye size={16} />
</Button>
<Button
variant="ghost"
onClick={(e) => {
e.stopPropagation();
handleCopyLink(file);
}}
class="text-foreground hover:text-foreground/80 p-1"
>
<IconCopy size={16} />
</Button>
<Button
variant="ghost"
onClick={(e) => {
e.stopPropagation();
handleShareFile(file);
}}
class="text-foreground hover:text-foreground/80 p-1"
>
<IconShare size={16} />
</Button>
<Button
variant="ghost"
onClick={(e) => {
e.stopPropagation();
deleteFile(file.id);
}}
class="text-destructive hover:text-destructive/80 p-1"
>
<IconTrash size={16} />
</Button>
</div>
</div>
</div>
</CardHeader>
<CardContent class="space-y-3">
{file.mime_type && (
<p class="text-sm text-gray-300 mb-3">
{file.mime_type}
</p>
)}
{/* Actions */}
<div class="flex items-center justify-between pt-2 border-t border-gray-700">
<span class="text-xs text-gray-400">
{new Date(file.created_at).toLocaleDateString()}
</span>
<div class="flex space-x-1">
<Button
variant="ghost"
size="sm"
class="text-gray-400 hover:text-white"
onClick={() => handleDownloadFile(file)}
>
<IconDownload class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
class="text-gray-400 hover:text-red-400"
onClick={() => handleDeleteFile(file.id)}
>
<IconTrash class="h-4 w-4" />
</Button>
<div class="mb-2">
<span class={`inline-block px-2 py-1 text-xs rounded-full ${getFileCategoryColor(fileTypeConfig.category)}`}>
{fileTypeConfig.displayName}
</span>
{file.isLink && (
<span class="ml-2 inline-block px-2 py-1 text-xs rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
Link
</span>
)}
</div>
</div>
</CardContent>
</Card>
)}
</For>
</div>
{/* Empty State */}
<Show when={filteredFiles().length === 0}>
<div class="text-center py-12">
<div class="mx-auto h-12 w-12 text-gray-400 mb-4 flex items-center justify-center text-2xl">📁</div>
<h3 class="text-lg font-medium text-white mb-2">No files found</h3>
<p class="text-gray-400 mb-4">
{searchQuery() ? 'Try adjusting your search terms' : 'Upload your first file to get started'}
</p>
<label for="file-upload">
<Button
disabled={uploadFileMutation.isPending}
class="cursor-pointer"
onClick={() => document.getElementById('file-upload')?.click()}
>
<IconUpload class="mr-2 h-4 w-4" />
Upload File
</Button>
</label>
<h3 class="text-lg font-semibold text-foreground mb-1 truncate">
{file.name}
</h3>
<p class="text-muted-foreground text-sm mb-2">
{formatFileSize(file.size)}
</p>
{file.description && (
<p class="text-foreground text-sm mb-3 line-clamp-2">
{file.description}
</p>
)}
{/* Tags */}
<div class="flex flex-wrap gap-1 mb-3">
<For each={file.tags}>
{(tag) => (
<button
onClick={(e) => {
e.stopPropagation();
toggleTag(tag);
}}
class="px-2 py-1 bg-muted hover:bg-muted/80 text-muted-foreground hover:text-foreground text-xs rounded-md transition-colors cursor-pointer"
>
{tag}
</button>
)}
</For>
</div>
{/* Associations */}
<Show when={file.associations && file.associations.length > 0}>
<div class="mb-3">
<p class="text-xs text-muted-foreground mb-1">Linked to:</p>
<div class="flex flex-wrap gap-1">
<For each={file.associations}>
{(assoc) => (
<span class="px-2 py-1 bg-primary/10 text-primary text-xs rounded-md">
{assoc.type}: {assoc.title}
</span>
)}
</For>
</div>
</div>
</Show>
<div class="flex justify-between items-center text-xs text-muted-foreground">
<span>{new Date(file.uploadedAt).toLocaleDateString()}</span>
<div class="flex gap-1">
<Button
variant="ghost"
class="text-foreground hover:text-foreground/80 p-1"
onClick={(e) => {
e.stopPropagation();
handleDownloadFile(file);
}}
>
<IconDownload size={14} />
</Button>
</div>
</div>
</Card>
);
}}
</For>
</div>
</Show>
</Show>
{filteredFiles().length === 0 && (
<Card class="p-12 text-center">
<p class="text-muted-foreground">
{searchTerm() || selectedTags().length > 0
? 'No files found matching your search or filters.'
: 'No files uploaded yet. Upload your first file!'}
</p>
</Card>
)}
</>
)}
{/* File Upload Modal */}
<FileUploadModal
isOpen={showUploadModal()}
onClose={() => setShowUploadModal(false)}
onUpload={handleFileUpload}
/>
{/* File Preview Modal */}
<FilePreviewModal
isOpen={showPreviewModal()}
onClose={() => setShowPreviewModal(false)}
file={selectedFile()}
/>
</div>
)
}
);
};
+553
View File
@@ -0,0 +1,553 @@
import { createSignal, onMount } from 'solid-js';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { GitHubActivity } from '@/components/ui/GitHubActivity';
import {
IconBrandGithub,
IconTrendingUp,
IconFolder,
IconStar,
IconGitFork,
IconEye,
IconExternalLink,
IconRefresh,
IconActivity
} from '@tabler/icons-solidjs';
interface GitHubRepo {
id: number;
name: string;
full_name: string;
description: string;
html_url: string;
stargazers_count: number;
forks_count: number;
watchers_count: number;
language: string;
updated_at: string;
created_at: string;
size: number;
open_issues_count: number;
default_branch: string;
}
interface GitHubStats {
totalRepos: number;
totalStars: number;
totalForks: number;
totalWatchers: number;
languages: Array<{
name: string;
count: number;
color: string;
}>;
recentActivity: Array<{
type: string;
repo: string;
date: string;
message: string;
}>;
repos: GitHubRepo[];
}
export const GitHub = () => {
const [githubStats, setGithubStats] = createSignal<GitHubStats>({
totalRepos: 0,
totalStars: 0,
totalForks: 0,
totalWatchers: 0,
languages: [],
recentActivity: [],
repos: []
});
const [weeklyActivity, setWeeklyActivity] = createSignal([0, 0, 0, 0, 0, 0, 0]);
const [username, setUsername] = createSignal('');
const [isConnected, setIsConnected] = createSignal(false);
onMount(() => {
// Check if user is authenticated and has GitHub connected
checkGitHubConnection();
});
const checkGitHubConnection = async () => {
try {
const token = localStorage.getItem('token');
if (!token) {
loadMockData();
return;
}
const response = await fetch(`${import.meta.env.VITE_API_URL}/auth/me`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
const userData = await response.json();
if (userData.user.github_id) {
setIsConnected(true);
setUsername(userData.user.username);
await fetchGitHubStats();
} else {
loadMockData();
}
} else {
loadMockData();
}
} catch (error) {
console.error('Failed to check GitHub connection:', error);
loadMockData();
}
};
const fetchGitHubStats = async () => {
try {
const token = localStorage.getItem('token');
if (!token) {
throw new Error('No authentication token');
}
const response = await fetch(`${import.meta.env.VITE_API_URL}/github/repos`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
throw new Error('Failed to fetch GitHub stats');
}
const data = await response.json();
const repos = data.repos || [];
// Process real GitHub data
const languages = processLanguages(repos);
const recentActivity = generateRecentActivity(repos);
const totalStars = repos.reduce((sum: number, repo: GitHubRepo) => sum + repo.stargazers_count, 0);
const totalForks = repos.reduce((sum: number, repo: GitHubRepo) => sum + repo.forks_count, 0);
const totalWatchers = repos.reduce((sum: number, repo: GitHubRepo) => sum + repo.watchers_count, 0);
setGithubStats({
totalRepos: repos.length,
totalStars,
totalForks,
totalWatchers,
languages,
recentActivity,
repos
});
} catch (error) {
console.error('Failed to fetch GitHub stats:', error);
// Fallback to mock data
loadMockData();
}
};
const processLanguages = (repos: GitHubRepo[]) => {
const languageMap = new Map<string, number>();
repos.forEach(repo => {
if (repo.language) {
languageMap.set(repo.language, (languageMap.get(repo.language) || 0) + 1);
}
});
return Array.from(languageMap.entries()).map(([name, count]) => ({
name,
count,
color: getLanguageColor()
}));
};
const generateRecentActivity = (repos: GitHubRepo[]) => {
// Sort repos by updated_at and take recent ones
const sortedRepos = repos
.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime())
.slice(0, 5);
return sortedRepos.map(repo => ({
type: 'push',
repo: repo.name,
date: formatDate(repo.updated_at),
message: `Updated ${repo.name}`
}));
};
const loadMockData = () => {
// Load mock data for demonstration
const mockRepos: GitHubRepo[] = [
{
id: 1,
name: 'trackeep',
full_name: 'demo/trackeep',
description: 'A comprehensive productivity and bookmark management system',
html_url: 'https://github.com/demo/trackeep',
stargazers_count: 156,
forks_count: 42,
watchers_count: 28,
language: 'TypeScript',
updated_at: '2024-01-28T10:30:00Z',
created_at: '2023-06-15T14:20:00Z',
size: 2456,
open_issues_count: 3,
default_branch: 'main'
},
{
id: 2,
name: 'solid-components',
full_name: 'demo/solid-components',
description: 'Reusable SolidJS components for modern web applications',
html_url: 'https://github.com/demo/solid-components',
stargazers_count: 89,
forks_count: 23,
watchers_count: 15,
language: 'TypeScript',
updated_at: '2024-01-27T16:45:00Z',
created_at: '2023-08-22T09:15:00Z',
size: 1234,
open_issues_count: 1,
default_branch: 'main'
}
];
const languages = [
{ name: 'TypeScript', count: 2, color: '#3178c6' },
{ name: 'Go', count: 1, color: '#00ADD8' }
];
const recentActivity = [
{
type: 'push',
repo: 'trackeep',
date: '2024-01-28',
message: 'feat: add GitHub integration'
}
];
// Generate mock weekly activity data
const mockWeeklyActivity = [
Math.floor(Math.random() * 20) + 5, // Monday
Math.floor(Math.random() * 25) + 8, // Tuesday
Math.floor(Math.random() * 22) + 6, // Wednesday
Math.floor(Math.random() * 18) + 4, // Thursday
Math.floor(Math.random() * 15) + 3, // Friday
Math.floor(Math.random() * 12) + 2, // Saturday
Math.floor(Math.random() * 10) + 1 // Sunday
];
setWeeklyActivity(mockWeeklyActivity);
setGithubStats({
totalRepos: mockRepos.length,
totalStars: mockRepos.reduce((sum, repo) => sum + repo.stargazers_count, 0),
totalForks: mockRepos.reduce((sum, repo) => sum + repo.forks_count, 0),
totalWatchers: mockRepos.reduce((sum, repo) => sum + repo.watchers_count, 0),
languages,
recentActivity,
repos: mockRepos
});
};
const connectGitHub = () => {
// Redirect to centralized OAuth service
window.location.href = 'https://oauth.tdvorak.dev/auth/github?redirect_uri=' + encodeURIComponent(window.location.origin + '/api/v1/auth/oauth/callback');
};
const disconnectGitHub = async () => {
try {
// In a real implementation, you might want to disconnect the GitHub account
// For now, we'll just clear the local state
setIsConnected(false);
setUsername('');
loadMockData();
} catch (error) {
console.error('Failed to disconnect GitHub:', error);
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString();
};
const getLanguageColor = () => {
// Use primary color for all languages instead of language-specific colors
return 'hsl(var(--primary))';
};
return (
<div class="p-6 space-y-6 overflow-x-hidden max-w-full">
{/* Header */}
<div class="flex justify-between items-center">
<div>
<h1 class="text-3xl font-bold text-foreground">GitHub Integration</h1>
<p class="text-muted-foreground mt-2">Track your GitHub repositories and activity</p>
</div>
<div class="flex gap-2 flex-shrink-0">
{isConnected() ? (
<>
<Button variant="outline" size="sm" onClick={() => fetchGitHubStats()}>
<IconRefresh class="size-4 mr-2" />
Refresh
</Button>
<Button variant="outline" size="sm" onClick={disconnectGitHub}>
Disconnect
</Button>
</>
) : (
<Button onClick={connectGitHub}>
<IconBrandGithub class="size-4 mr-2" />
Connect GitHub
</Button>
)}
</div>
</div>
{/* Connection Status */}
{isConnected() && (
<Card class="p-4">
<div class="flex items-center gap-3">
<div class="bg-primary/10 flex items-center justify-center p-2 rounded-lg">
<IconBrandGithub class="size-5 text-primary" />
</div>
<div>
<p class="text-sm font-medium text-foreground">Connected as @{username()}</p>
<p class="text-xs text-muted-foreground">Syncing data from GitHub API</p>
</div>
</div>
</Card>
)}
{/* Stats Overview - 2-column layout with larger left column */}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Left Column - Main Stats */}
<div class="space-y-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Card class="p-6">
<div class="flex items-center gap-3">
<div class="bg-primary/10 flex items-center justify-center p-3 rounded-lg">
<IconFolder class="size-6 text-primary" />
</div>
<div>
<p class="text-2xl font-bold text-foreground">{githubStats().totalRepos}</p>
<p class="text-sm text-muted-foreground">Repositories</p>
</div>
</div>
</Card>
<Card class="p-6">
<div class="flex items-center gap-3">
<div class="bg-primary/10 flex items-center justify-center p-3 rounded-lg">
<IconStar class="size-6 text-primary" />
</div>
<div>
<p class="text-2xl font-bold text-foreground">{githubStats().totalStars}</p>
<p class="text-sm text-muted-foreground">Total Stars</p>
</div>
</div>
</Card>
<Card class="p-6">
<div class="flex items-center gap-3">
<div class="bg-primary/10 flex items-center justify-center p-3 rounded-lg">
<IconGitFork class="size-6 text-primary" />
</div>
<div>
<p class="text-2xl font-bold text-foreground">{githubStats().totalForks}</p>
<p class="text-sm text-muted-foreground">Total Forks</p>
</div>
</div>
</Card>
<Card class="p-6">
<div class="flex items-center gap-3">
<div class="bg-primary/10 flex items-center justify-center p-3 rounded-lg">
<IconEye class="size-6 text-primary" />
</div>
<div>
<p class="text-2xl font-bold text-foreground">{githubStats().totalWatchers}</p>
<p class="text-sm text-muted-foreground">Watchers</p>
</div>
</div>
</Card>
</div>
</div>
{/* Right Column - Additional Stats */}
<div class="space-y-4">
{/* Additional GitHub stats can go here */}
<Card class="p-6">
<div class="flex items-center gap-3">
<div class="bg-primary/10 flex items-center justify-center p-3 rounded-lg">
<IconActivity class="size-6 text-primary" />
</div>
<div>
<p class="text-2xl font-bold text-foreground">{weeklyActivity().reduce((a, b) => a + b, 0)}</p>
<p class="text-sm text-muted-foreground">Weekly Activity</p>
</div>
</div>
</Card>
</div>
</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">
<GitHubActivity
title="Contribution Activity"
showStats={false}
showContributionGraph={true}
showRecentActivity={false}
compact={true}
period="year"
fullWidth={true}
hideHeader={false}
/>
</div>
{/* Languages - Right Column (1/3 width on large screens) */}
<Card class="p-6 xl:w-1/3">
<h3 class="text-lg font-semibold text-foreground mb-4">Languages</h3>
<div class="space-y-3">
{githubStats().languages.map((language) => (
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div
class="w-3 h-3 rounded-full flex-shrink-0"
style={`background-color: ${language.color}`}
></div>
<span class="text-sm text-foreground truncate">{language.name}</span>
</div>
<span class="text-sm text-muted-foreground flex-shrink-0">{language.count} repos</span>
</div>
))}
</div>
</Card>
</div>
{/* Weekly Activity Chart */}
<Card class="p-6">
<div class="flex items-center gap-2 mb-4">
<IconActivity class="size-5 text-primary" />
<h3 class="text-lg font-semibold text-foreground">Weekly Activity</h3>
</div>
<div class="space-y-4">
<div class="relative h-32 md:h-36 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">
{['M', 'T', 'W', 'T', 'F', 'S', 'S'].map((day, index) => {
const weeklyActivityData = weeklyActivity() || [12, 19, 8, 15, 22, 18, 25]; // Fallback data
const activity = weeklyActivityData[index];
const maxActivity = Math.max(...weeklyActivityData);
// 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);
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">
{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} contributions`}
></div>
</div>
<span class="text-xs text-muted-foreground font-medium mt-1">{day}</span>
</div>
);
})}
</div>
</div>
<div class="flex justify-between text-xs text-muted-foreground pt-2 border-t border-border">
<span>Total: {weeklyActivity().reduce((a, b) => a + b, 0)} contributions</span>
<span>Avg: {Math.round(weeklyActivity().reduce((a, b) => a + b, 0) / 7)} per day</span>
</div>
</div>
</Card>
{/* Recent Activity */}
<Card class="p-6">
<h3 class="text-lg font-semibold text-foreground mb-4">Recent Activity</h3>
<div class="space-y-3">
{githubStats().recentActivity.map((activity) => (
<div class="flex items-center justify-between p-3 bg-muted rounded-lg">
<div class="flex items-center gap-3">
<div class="bg-primary/10 p-2 rounded-lg">
<IconTrendingUp class="size-4 text-primary" />
</div>
<div>
<p class="text-sm text-foreground">{activity.message}</p>
<p class="text-xs text-muted-foreground">{activity.repo} {activity.date}</p>
</div>
</div>
<span class="text-xs text-muted-foreground capitalize">{activity.type.replace('_', ' ')}</span>
</div>
))}
</div>
</Card>
{/* Repositories */}
<Card class="p-6">
<h3 class="text-lg font-semibold text-foreground mb-4">Repositories</h3>
<div class="space-y-4">
{githubStats().repos.map((repo) => (
<div class="border border-border rounded-lg p-4">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-2 mb-2">
<h4 class="text-lg font-medium text-foreground">{repo.name}</h4>
{repo.language && (
<span
class="text-xs px-2 py-1 rounded-full"
style={`background-color: ${getLanguageColor()}20; color: ${getLanguageColor()}`}
>
{repo.language}
</span>
)}
</div>
<p class="text-sm text-muted-foreground mb-3">{repo.description}</p>
<div class="flex items-center gap-4 text-xs text-muted-foreground">
<div class="flex items-center gap-1">
<IconStar class="size-3" />
<span>{repo.stargazers_count}</span>
</div>
<div class="flex items-center gap-1">
<IconGitFork class="size-3" />
<span>{repo.forks_count}</span>
</div>
<div class="flex items-center gap-1">
<IconEye class="size-3" />
<span>{repo.watchers_count}</span>
</div>
<span>Updated {formatDate(repo.updated_at)}</span>
</div>
</div>
<Button variant="ghost" size="sm">
<IconExternalLink class="size-4" />
</Button>
</div>
</div>
))}
</div>
</Card>
</div>
);
};
+496
View File
@@ -0,0 +1,496 @@
import { createSignal, onMount, Show } from 'solid-js';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { LearningPathPreviewModal } from '@/components/ui/LearningPathPreviewModal';
import { getMockLearningPaths } from '@/lib/mockData';
import {
IconClock,
IconUsers,
IconStar,
IconFilter,
IconSearch,
IconAlertCircle,
IconCode,
IconCloud,
IconPalette,
IconBriefcase,
IconCamera,
IconMusic,
IconWriting,
IconLanguage,
IconDeviceLaptop,
IconShield,
IconBrain,
IconBook
} from '@tabler/icons-solidjs';
interface LearningPath {
id: number;
title: string;
description: string;
category: string;
difficulty: string;
duration: string;
thumbnail: string;
is_featured: boolean;
enrollment_count: number;
rating: number;
review_count: number;
creator: {
username: string;
full_name: string;
};
tags: Array<{
name: string;
color: string;
}>;
modules?: Array<{
id: string;
title: string;
description: string;
completed: boolean;
resources: Array<{
type: string;
title: string;
url: string;
}>;
}>;
createdAt?: string;
enrolledAt?: string;
}
export const LearningPaths = () => {
const [learningPaths, setLearningPaths] = createSignal<LearningPath[]>([]);
const [categories, setCategories] = createSignal<string[]>([]);
const [isLoading, setIsLoading] = createSignal(true);
const [searchTerm, setSearchTerm] = createSignal('');
const [selectedCategory, setSelectedCategory] = createSignal('');
const [selectedDifficulty, setSelectedDifficulty] = createSignal('');
const [successMessage, setSuccessMessage] = createSignal('');
const [errorMessage, setErrorMessage] = createSignal('');
const [enrolledPaths, setEnrolledPaths] = createSignal<Set<number>>(new Set());
const [isPreviewOpen, setIsPreviewOpen] = createSignal(false);
const [selectedPath, setSelectedPath] = createSignal<LearningPath | null>(null);
// 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');
};
const fetchData = async () => {
try {
if (isDemoMode()) {
// Use mock data in demo mode
const mockLearningPaths = getMockLearningPaths();
const mappedPaths: LearningPath[] = mockLearningPaths.map((path, index) => ({
id: index + 1,
title: path.title,
description: path.description,
category: path.category,
difficulty: path.difficulty,
duration: path.estimatedTime,
thumbnail: `https://picsum.photos/seed/${path.category.replace(/\s+/g, '-').toLowerCase()}-${index}/400/200.jpg`,
is_featured: index < 2, // Make first 2 paths featured
enrollment_count: Math.floor(Math.random() * 2000) + 200,
rating: 4.0 + Math.random() * 1.0,
review_count: Math.floor(Math.random() * 200) + 20,
creator: {
username: 'instructor',
full_name: 'Expert Instructor'
},
tags: path.tags,
modules: path.modules,
createdAt: path.createdAt,
enrolledAt: path.enrolledAt
}));
setLearningPaths(mappedPaths);
// Extract unique categories from mock data
const uniqueCategories = [...new Set(mockLearningPaths.map(path => path.category))];
setCategories(uniqueCategories);
setIsLoading(false);
return;
}
// Fetch categories
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
const categoriesResponse = await fetch(`${API_BASE_URL}/learning-paths/categories`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
if (categoriesResponse.ok) {
const categoriesData = await categoriesResponse.json();
setCategories(categoriesData.categories || []);
}
// Fetch learning paths
const params = new URLSearchParams();
if (searchTerm()) params.append('search', searchTerm());
if (selectedCategory()) params.append('category', selectedCategory());
if (selectedDifficulty()) params.append('difficulty', selectedDifficulty());
const response = await fetch(`${API_BASE_URL}/learning-paths?${params}`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
if (response.ok) {
const data = await response.json();
setLearningPaths(data);
}
} catch (error) {
console.error('Failed to load learning paths:', error);
setErrorMessage('Failed to load learning paths. Please try again.');
setTimeout(() => setErrorMessage(''), 3000);
} finally {
setIsLoading(false);
}
};
onMount(fetchData);
const handleSearch = () => {
// Refetch with search parameters
fetchData();
};
const getDifficultyColor = (_difficulty: string) => {
// Use single main project color (blue) for all difficulties
return 'bg-blue-500/20 text-blue-400 border border-blue-500/30';
};
const getCategoryIcon = (category: string) => {
switch (category.toLowerCase()) {
case 'programming':
case 'web development':
return <IconCode class="size-4" />;
case 'mobile development':
return <IconDeviceLaptop class="size-4" />;
case 'data science':
case 'machine learning':
return <IconBrain class="size-4" />;
case 'cybersecurity':
return <IconShield class="size-4" />;
case 'devops':
return <IconCloud class="size-4" />;
case 'design':
return <IconPalette class="size-4" />;
case 'business':
return <IconBriefcase class="size-4" />;
case 'marketing':
return <IconBriefcase class="size-4" />;
case 'photography':
return <IconCamera class="size-4" />;
case 'music':
return <IconMusic class="size-4" />;
case 'writing':
return <IconWriting class="size-4" />;
case 'languages':
return <IconLanguage class="size-4" />;
default:
return <IconBook class="size-4" />;
}
};
const getCategoryColor = (_category: string) => {
// Use single main project color (blue) for all categories
return 'bg-blue-500/20 text-blue-400 border-blue-500/30';
};
const handleEnroll = async (pathId: number) => {
try {
if (isDemoMode()) {
// Simulate enrollment in demo mode
setEnrolledPaths(prev => new Set(prev).add(pathId));
setSuccessMessage('Successfully enrolled in learning path!');
setTimeout(() => setSuccessMessage(''), 3000);
return;
}
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
const response = await fetch(`${API_BASE_URL}/learning-paths/${pathId}/enroll`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
if (response.ok) {
setEnrolledPaths(prev => new Set(prev).add(pathId));
setSuccessMessage('Successfully enrolled in learning path!');
setTimeout(() => setSuccessMessage(''), 3000);
} else {
throw new Error('Failed to enroll');
}
} catch (error) {
console.error('Error enrolling in learning path:', error);
setErrorMessage('Failed to enroll. Please try again.');
setTimeout(() => setErrorMessage(''), 3000);
}
};
const openPreview = (path: LearningPath) => {
setSelectedPath(path);
setIsPreviewOpen(true);
};
const renderStars = (rating: number) => {
const stars = [];
const fullStars = Math.floor(rating);
const hasHalfStar = rating % 1 !== 0;
for (let i = 0; i < fullStars; i++) {
stars.push(<IconStar class="size-4 fill-yellow-400 text-yellow-400" />);
}
if (hasHalfStar) {
stars.push(<IconStar class="size-4 fill-yellow-400/50 text-yellow-400" />);
}
const emptyStars = 5 - Math.ceil(rating);
for (let i = 0; i < emptyStars; i++) {
stars.push(<IconStar class="size-4 text-gray-400" />);
}
return stars;
};
return (
<div class="p-6 space-y-6">
<div class="flex justify-between items-center">
<h1 class="text-3xl font-bold text-[#fafafa]">Learning Paths</h1>
</div>
{/* Success and Error Messages */}
<Show when={successMessage()}>
<Card class="p-4 border-primary/20 bg-primary/5">
<div class="flex items-center gap-2">
<IconAlertCircle class="size-4 text-primary" />
<p class="text-primary text-sm">{successMessage()}</p>
</div>
</Card>
</Show>
<Show when={errorMessage()}>
<Card class="p-4 border-destructive/20 bg-destructive/5">
<div class="flex items-center gap-2">
<IconAlertCircle class="size-4 text-destructive" />
<p class="text-destructive text-sm">{errorMessage()}</p>
</div>
</Card>
</Show>
{/* Search and Filters */}
<div class="bg-[#1a1a1a] rounded-lg p-6 space-y-4">
<div class="flex flex-col lg:flex-row gap-4">
<div class="flex-1 relative">
<IconSearch class="absolute left-3 top-1/2 transform -translate-y-1/2 text-[#a3a3a3] size-4" />
<Input
type="text"
placeholder="Search learning paths..."
value={searchTerm()}
onInput={(e) => {
const target = e.currentTarget as HTMLInputElement;
if (target) setSearchTerm(target.value);
}}
class="pl-10"
/>
</div>
<select
value={selectedCategory()}
onChange={(e) => {
const target = e.currentTarget as HTMLSelectElement;
if (target) setSelectedCategory(target.value);
}}
class="px-4 py-2 bg-[#262626] text-[#fafafa] border border-[#404040] rounded-lg focus:outline-none focus:border-primary"
>
<option value="">All Categories</option>
{categories().map(category => (
<option value={category}>{category}</option>
))}
</select>
<select
value={selectedDifficulty()}
onChange={(e) => {
const target = e.currentTarget as HTMLSelectElement;
if (target) setSelectedDifficulty(target.value);
}}
class="px-4 py-2 bg-[#262626] text-[#fafafa] border border-[#404040] rounded-lg focus:outline-none focus:border-primary"
>
<option value="">All Levels</option>
<option value="beginner">Beginner</option>
<option value="intermediate">Intermediate</option>
<option value="advanced">Advanced</option>
</select>
<Button onClick={handleSearch} class="whitespace-nowrap">
<IconFilter class="size-4 mr-2" />
Apply Filters
</Button>
</div>
</div>
{/* Learning Paths Grid */}
{isLoading() ? (
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[...Array(6)].map(() => (
<Card class="animate-pulse">
<div class="h-48 bg-[#262626] rounded-t-lg"></div>
<div class="p-6 space-y-3">
<div class="h-6 bg-[#262626] rounded"></div>
<div class="h-4 bg-[#262626] rounded w-3/4"></div>
<div class="h-4 bg-[#262626] rounded w-1/2"></div>
</div>
</Card>
))}
</div>
) : (
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{learningPaths().map((path) => (
<Card class="overflow-hidden hover:shadow-xl transition-all duration-300 group cursor-pointer bg-[#1a1a1a] border-[#404040]">
{/* Thumbnail */}
<div class="h-48 bg-[#262626] relative overflow-hidden">
{path.is_featured && (
<div class="absolute top-4 left-4 bg-blue-500 text-white px-3 py-1 rounded-full text-xs font-semibold z-10">
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 bottom-4 left-4 right-4">
<div class="flex items-center gap-2 mb-2">
{getCategoryIcon(path.category)}
<span class={`px-2 py-1 rounded-full text-xs font-medium border ${getCategoryColor(path.category)}`}>
{path.category}
</span>
</div>
<h3 class="text-xl font-bold text-white mb-2 line-clamp-2">{path.title}</h3>
<div class="flex items-center gap-2">
<span class={`px-2 py-1 rounded-full text-xs font-medium border ${getDifficultyColor(path.difficulty)}`}>
{path.difficulty}
</span>
</div>
</div>
</div>
{/* Content */}
<div class="p-6 space-y-4">
<p class="text-[#a3a3a3] text-sm line-clamp-3">{path.description}</p>
{/* Stats */}
<div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-4">
<div class="flex items-center gap-1">
<IconUsers class="size-4 text-[#a3a3a3]" />
<span class="text-[#a3a3a3]">{path.enrollment_count}</span>
</div>
<div class="flex items-center gap-1">
<IconClock class="size-4 text-[#a3a3a3]" />
<span class="text-[#a3a3a3]">{path.duration}</span>
</div>
</div>
{path.rating > 0 && (
<div class="flex items-center gap-1">
{renderStars(path.rating)}
<span class="text-[#a3a3a3] text-xs">({path.review_count})</span>
</div>
)}
</div>
{/* Tags */}
{path.tags && path.tags.length > 0 && (
<div class="flex flex-wrap gap-2">
{path.tags.slice(0, 3).map((tag) => (
<span
class="px-2 py-1 rounded-full text-xs font-medium"
style={`background-color: ${tag.color}20; color: ${tag.color}`}
>
{tag.name}
</span>
))}
{path.tags.length > 3 && (
<span class="px-2 py-1 rounded-full text-xs bg-[#262626] text-[#a3a3a3]">
+{path.tags.length - 3}
</span>
)}
</div>
)}
{/* Action Buttons */}
<div class="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
openPreview(path);
}}
>
Preview
</Button>
<Button
size="sm"
onClick={(e) => {
e.stopPropagation();
handleEnroll(path.id);
}}
disabled={enrolledPaths().has(path.id)}
class="flex-1"
>
{enrolledPaths().has(path.id) ? 'Enrolled' : 'Enroll Now'}
</Button>
</div>
</div>
</Card>
))}
</div>
)}
{/* Empty State */}
{!isLoading() && learningPaths().length === 0 && (
<div class="text-center py-12">
<div class="text-[#a3a3a3] text-lg mb-4">
No learning paths found matching your criteria.
</div>
<Button variant="outline" onClick={() => {
setSearchTerm('');
setSelectedCategory('');
setSelectedDifficulty('');
fetchData();
}}>
Clear Filters
</Button>
</div>
)}
<LearningPathPreviewModal
isOpen={isPreviewOpen()}
onClose={() => {
setIsPreviewOpen(false);
setSelectedPath(null);
}}
learningPath={selectedPath()}
onEnroll={handleEnroll}
/>
</div>
);
};
+196 -98
View File
@@ -1,8 +1,11 @@
import { createSignal } from 'solid-js';
import { createSignal, onMount } from 'solid-js';
import { useAuth, type LoginRequest, type RegisterRequest } from '@/lib/auth';
import { isEnvDemoMode } from '@/lib/demo-mode';
import { useNavigate } from '@solidjs/router';
export const Login = () => {
const { login, register } = useAuth();
const navigate = useNavigate();
const [isLogin, setIsLogin] = createSignal(true);
const [formData, setFormData] = createSignal<LoginRequest | RegisterRequest>({
email: '',
@@ -10,8 +13,51 @@ export const Login = () => {
...(isLogin() ? {} : { username: '', fullName: '' }),
});
const [error, setError] = createSignal('');
const [noAccountsExist, setNoAccountsExist] = createSignal(false);
const [registrationDisabled, setRegistrationDisabled] = createSignal(false);
const [loading, setLoading] = createSignal(false);
// Check if users exist and set appropriate mode
onMount(async () => {
// Auto-fill demo credentials if in demo mode
if (isEnvDemoMode()) {
setFormData({
email: 'demo@trackeep.com',
password: 'demo123',
...(isLogin() ? {} : { username: 'demo', fullName: 'Demo User' }),
});
return;
}
try {
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1'}/auth/check-users`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (response.ok) {
const data = await response.json();
if (data.hasUsers) {
// Users exist - disable registration
setRegistrationDisabled(true);
setNoAccountsExist(false);
// Force to login mode
setIsLogin(true);
} else {
// No users exist - allow registration for first user (admin)
setRegistrationDisabled(false);
setNoAccountsExist(true);
// Force to registration mode
setIsLogin(false);
}
}
} catch (err) {
console.warn('Failed to check if users exist:', err);
}
});
const handleSubmit = async (e: Event) => {
e.preventDefault();
setError('');
@@ -23,7 +69,8 @@ export const Login = () => {
} else {
await register(formData() as RegisterRequest);
}
// Navigation will be handled by the auth state change
// Navigate to app after successful login/registration
navigate('/app');
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
@@ -36,6 +83,12 @@ export const Login = () => {
};
const toggleMode = () => {
// Prevent toggling if registration is disabled (users exist)
if (registrationDisabled()) {
setError('Registration is disabled. Please contact your administrator to create an account.');
return;
}
setIsLogin(!isLogin());
setError('');
setFormData({
@@ -51,111 +104,156 @@ export const Login = () => {
<div class="text-center mb-8">
<h1 class="text-3xl font-bold text-[#fafafa] mb-2">Trackeep</h1>
<p class="text-[#a3a3a3]">
{isLogin() ? 'Welcome back' : 'Create your account'}
{isEnvDemoMode() ? 'Demo Mode' : (isLogin() ? 'Welcome back' : 'Create your account')}
</p>
</div>
<form onSubmit={handleSubmit} class="space-y-6">
{error() && (
<div class="bg-red-500/10 border border-red-500/50 text-red-400 px-4 py-3 rounded">
{error()}
{/* Demo Mode - Show only demo button */}
{isEnvDemoMode() ? (
<div class="space-y-6">
<div class="text-center">
<div class="mb-6 bg-green-500/10 border border-green-500/50 text-green-400 px-4 py-3 rounded">
<div class="flex items-center gap-2 mb-1">
<span class="w-2 h-2 bg-green-500 rounded-full"></span>
<span class="font-medium">Demo Mode Active</span>
</div>
<p class="text-xs">Experience Trackeep with mock data - no login required</p>
</div>
</div>
)}
<div>
<label for="email" class="block text-sm font-medium text-[#fafafa] mb-2">
Email
</label>
<input
id="email"
type="email"
required
value={formData().email}
onInput={(e) => handleInputChange('email', e.currentTarget.value)}
class="w-full px-3 py-2 bg-[#18181b] border border-[#262626] rounded-md text-[#fafafa] placeholder-[#a3a3a3] focus:outline-none focus:ring-2 focus:ring-[#39b9ff] focus:border-transparent"
placeholder="your@email.com"
/>
</div>
{!isLogin() && (
<>
<div>
<label for="username" class="block text-sm font-medium text-[#fafafa] mb-2">
Username
</label>
<input
id="username"
type="text"
required
value={(formData() as RegisterRequest).username}
onInput={(e) => handleInputChange('username', e.currentTarget.value)}
class="w-full px-3 py-2 bg-[#18181b] border border-[#262626] rounded-md text-[#fafafa] placeholder-[#a3a3a3] focus:outline-none focus:ring-2 focus:ring-[#39b9ff] focus:border-transparent"
placeholder="username"
/>
</div>
<div>
<label for="fullName" class="block text-sm font-medium text-[#fafafa] mb-2">
Full Name
</label>
<input
id="fullName"
type="text"
required
value={(formData() as RegisterRequest).fullName}
onInput={(e) => handleInputChange('fullName', e.currentTarget.value)}
class="w-full px-3 py-2 bg-[#18181b] border border-[#262626] rounded-md text-[#fafafa] placeholder-[#a3a3a3] focus:outline-none focus:ring-2 focus:ring-[#39b9ff] focus:border-transparent"
placeholder="Your Name"
/>
</div>
</>
)}
<div>
<label for="password" class="block text-sm font-medium text-[#fafafa] mb-2">
Password
</label>
<input
id="password"
type="password"
required
minLength={6}
value={formData().password}
onInput={(e) => handleInputChange('password', e.currentTarget.value)}
class="w-full px-3 py-2 bg-[#18181b] border border-[#262626] rounded-md text-[#fafafa] placeholder-[#a3a3a3] focus:outline-none focus:ring-2 focus:ring-[#39b9ff] focus:border-transparent"
placeholder="••••••••"
/>
</div>
<button
type="submit"
disabled={loading()}
class="w-full bg-[#39b9ff] text-white py-2 px-4 rounded-md hover:bg-[#2a8fdb] focus:outline-none focus:ring-2 focus:ring-[#39b9ff] focus:ring-offset-2 focus:ring-offset-[#141415] disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{loading() ? 'Please wait...' : isLogin() ? 'Sign In' : 'Sign Up'}
</button>
</form>
<div class="mt-6 text-center">
<p class="text-[#a3a3a3]">
{isLogin() ? "Don't have an account?" : 'Already have an account?'}
<button
type="button"
onClick={toggleMode}
class="ml-1 text-[#39b9ff] hover:text-[#2a8fdb] focus:outline-none focus:underline"
onClick={() => {
// Auto-submit with demo credentials
handleSubmit(new Event('submit') as any);
}}
disabled={loading()}
class="w-full bg-green-600 text-white py-3 px-4 rounded-md hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-600 focus:ring-offset-2 focus:ring-offset-[#141415] disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-lg font-medium"
>
{isLogin() ? 'Sign up' : 'Sign in'}
{loading() ? 'Entering Demo...' : 'Enter Demo Mode'}
</button>
</p>
</div>
<div class="mt-8 pt-6 border-t border-[#262626]">
<div class="text-center text-sm text-[#a3a3a3]">
<p>Demo Account:</p>
<p>Email: demo@trackeep.com</p>
<p>Password: demo123</p>
</div>
</div>
) : (
<>
{/* Registration disabled message */}
{registrationDisabled() && (
<div class="mb-6 bg-blue-500/10 border border-blue-500/50 text-blue-400 px-4 py-3 rounded">
<div class="flex items-center gap-2 mb-1">
<span class="w-2 h-2 bg-blue-500 rounded-full"></span>
<span class="font-medium">Registration Disabled</span>
</div>
<p class="text-xs">Accounts can only be created by the administrator. Please contact your admin to get an account.</p>
</div>
)}
{/* No accounts exist message */}
{noAccountsExist() && !isLogin() && (
<div class="mb-6 bg-yellow-500/10 border border-yellow-500/50 text-yellow-400 px-4 py-3 rounded">
<div class="flex items-center gap-2 mb-1">
<span class="w-2 h-2 bg-yellow-500 rounded-full"></span>
<span class="font-medium">Create Admin Account</span>
</div>
<p class="text-xs">No accounts exist yet. Create the first administrator account to get started.</p>
</div>
)}
<form onSubmit={handleSubmit} class="space-y-6">
{error() && (
<div class="bg-red-500/10 border border-red-500/50 text-red-400 px-4 py-3 rounded">
{error()}
</div>
)}
<div>
<label for="email" class="block text-sm font-medium text-[#fafafa] mb-2">
Email
</label>
<input
id="email"
type="email"
required
value={formData().email}
onInput={(e) => handleInputChange('email', e.currentTarget.value)}
class="w-full px-3 py-2 bg-[#18181b] border border-[#262626] rounded-md text-[#fafafa] placeholder-[#a3a3a3] focus:outline-none focus:ring-2 focus:ring-[#39b9ff] focus:border-transparent"
placeholder="your@email.com"
/>
</div>
{!isLogin() && (
<>
<div>
<label for="username" class="block text-sm font-medium text-[#fafafa] mb-2">
Username
</label>
<input
id="username"
type="text"
required
value={(formData() as RegisterRequest).username}
onInput={(e) => handleInputChange('username', e.currentTarget.value)}
class="w-full px-3 py-2 bg-[#18181b] border border-[#262626] rounded-md text-[#fafafa] placeholder-[#a3a3a3] focus:outline-none focus:ring-2 focus:ring-[#39b9ff] focus:border-transparent"
placeholder="username"
/>
</div>
<div>
<label for="fullName" class="block text-sm font-medium text-[#fafafa] mb-2">
Full Name
</label>
<input
id="fullName"
type="text"
required
value={(formData() as RegisterRequest).fullName}
onInput={(e) => handleInputChange('fullName', e.currentTarget.value)}
class="w-full px-3 py-2 bg-[#18181b] border border-[#262626] rounded-md text-[#fafafa] placeholder-[#a3a3a3] focus:outline-none focus:ring-2 focus:ring-[#39b9ff] focus:border-transparent"
placeholder="Your Name"
/>
</div>
</>
)}
<div>
<label for="password" class="block text-sm font-medium text-[#fafafa] mb-2">
Password
</label>
<input
id="password"
type="password"
required
minLength={6}
value={formData().password}
onInput={(e) => handleInputChange('password', e.currentTarget.value)}
class="w-full px-3 py-2 bg-[#18181b] border border-[#262626] rounded-md text-[#fafafa] placeholder-[#a3a3a3] focus:outline-none focus:ring-2 focus:ring-[#39b9ff] focus:border-transparent"
placeholder="••••••••"
/>
</div>
<button
type="submit"
disabled={loading()}
class="w-full bg-[#39b9ff] text-white py-2 px-4 rounded-md hover:bg-[#2a8fdb] focus:outline-none focus:ring-2 focus:ring-[#39b9ff] focus:ring-offset-2 focus:ring-offset-[#141415] disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{loading() ? 'Please wait...' : isLogin() ? 'Sign In' : 'Sign Up'}
</button>
</form>
<div class="mt-6 text-center">
{!registrationDisabled() && (
<p class="text-[#a3a3a3]">
{isLogin() ? "Don't have an account?" : 'Already have an account?'}
<button
type="button"
onClick={toggleMode}
class="ml-1 text-[#39b9ff] hover:text-[#2a8fdb] focus:outline-none focus:underline"
>
{isLogin() ? 'Sign up' : 'Sign in'}
</button>
</p>
)}
</div>
</>
)}
</div>
</div>
);
+211
View File
@@ -0,0 +1,211 @@
import { createSignal, onMount } from 'solid-js';
import { IconPlus, IconDotsVertical, IconEdit, IconTrash, IconShield, IconShieldCheck } from '@tabler/icons-solidjs';
import { DropdownMenu, DropdownMenuItem } from '@/components/ui/DropdownMenu';
import { MemberModal } from '@/components/ui/MemberModal';
import { ConfirmModal } from '@/components/ui/ConfirmModal';
interface Member {
id: string;
name: string;
email: string;
role: 'Admin' | 'Member';
avatar: string;
joinedAt: string;
}
export const Members = () => {
const [members, setMembers] = createSignal<Member[]>([]);
const [showAddModal, setShowAddModal] = createSignal(false);
const [showEditModal, setShowEditModal] = createSignal(false);
const [showDeleteModal, setShowDeleteModal] = createSignal(false);
const [editingMember, setEditingMember] = createSignal<Member | null>(null);
const [deletingMember, setDeletingMember] = createSignal<Member | null>(null);
const handleAddMember = (memberData: Omit<Member, 'id' | 'avatar' | 'joinedAt'>) => {
const newMember: Member = {
...memberData,
id: Date.now().toString(),
avatar: memberData.name.split(' ').map(n => n[0]).join('').toUpperCase(),
joinedAt: 'Just now'
};
setMembers(prev => [...prev, newMember]);
setShowAddModal(false);
};
const handleEditMember = (memberData: Omit<Member, 'id' | 'avatar' | 'joinedAt'>) => {
if (!editingMember()) return;
setMembers(prev =>
prev.map(m =>
m.id === editingMember()!.id
? {
...m,
...memberData,
avatar: memberData.name.split(' ').map(n => n[0]).join('').toUpperCase()
}
: m
)
);
setShowEditModal(false);
setEditingMember(null);
};
const openEditModal = (member: Member) => {
setEditingMember(member);
setShowEditModal(true);
};
const openDeleteModal = (member: Member) => {
setDeletingMember(member);
setShowDeleteModal(true);
};
const handleDeleteMember = () => {
if (!deletingMember()) return;
setMembers(prev => prev.filter(m => m.id !== deletingMember()!.id));
setShowDeleteModal(false);
setDeletingMember(null);
};
const handleToggleRole = (member: Member) => {
const newRole = member.role === 'Admin' ? 'Member' : 'Admin';
setMembers(prev =>
prev.map(m =>
m.id === member.id ? { ...m, role: newRole } : m
)
);
};
onMount(() => {
// Mock data
setMembers([
{
id: '1',
name: 'John Doe',
email: 'john@example.com',
role: 'Admin',
avatar: 'JD',
joinedAt: '2 weeks ago'
},
{
id: '2',
name: 'Jane Smith',
email: 'jane@example.com',
role: 'Member',
avatar: 'JS',
joinedAt: '1 month ago'
},
{
id: '3',
name: 'Bob Johnson',
email: 'bob@example.com',
role: 'Member',
avatar: 'BJ',
joinedAt: '3 months ago'
}
]);
});
return (
<div class="p-6 mt-4 pb-32 max-w-5xl mx-auto">
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold text-foreground">Members</h1>
<button type="button" class="inline-flex justify-center rounded-md text-sm font-medium transition-shadow focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground shadow hover:bg-primary/90 h-auto items-center gap-2 py-2 px-4" onClick={() => setShowAddModal(true)}>
<IconPlus class="size-4" />
Add Member
</button>
</div>
<div class="w-full overflow-auto">
<table class="w-full caption-bottom text-sm">
<thead class="[&_tr]:border-b">
<tr class="border-b transition-colors data-[state=selected]:bg-muted">
<th class="h-10 px-2 text-left align-middle font-medium text-muted-foreground">Member</th>
<th class="h-10 px-2 text-left align-middle font-medium text-muted-foreground">Role</th>
<th class="h-10 px-2 text-left align-middle font-medium text-muted-foreground">Joined</th>
<th class="h-10 px-2 text-left align-middle font-medium text-muted-foreground text-right">Actions</th>
</tr>
</thead>
<tbody class="[&_tr:last-child]:border-0">
{members().map((member) => (
<tr class="border-b transition-colors data-[state=selected]:bg-muted">
<td class="p-2 align-middle">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-sm font-medium">
{member.avatar}
</div>
<div>
<div class="font-medium">{member.name}</div>
<div class="text-sm text-muted-foreground">{member.email}</div>
</div>
</div>
</td>
<td class="p-2 align-middle">
<span class="inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2">
{member.role}
</span>
</td>
<td class="p-2 align-middle text-muted-foreground">
{member.joinedAt}
</td>
<td class="p-2 align-middle">
<div class="flex items-center justify-end">
<DropdownMenu
trigger={
<button type="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-9 w-9">
<IconDotsVertical class="size-4" />
</button>
}
>
<DropdownMenuItem onClick={() => openEditModal(member)} icon={IconEdit}>
Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleToggleRole(member)} icon={member.role === 'Admin' ? IconShieldCheck : IconShield}>
{member.role === 'Admin' ? 'Make Member' : 'Make Admin'}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => openDeleteModal(member)} icon={IconTrash} variant="destructive">
Remove
</DropdownMenuItem>
</DropdownMenu>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Modals */}
<MemberModal
isOpen={showAddModal()}
onClose={() => setShowAddModal(false)}
onSubmit={handleAddMember}
/>
<MemberModal
isOpen={showEditModal()}
onClose={() => {
setShowEditModal(false);
setEditingMember(null);
}}
onSubmit={handleEditMember}
member={editingMember()}
isEdit={true}
/>
<ConfirmModal
isOpen={showDeleteModal()}
onClose={() => {
setShowDeleteModal(false);
setDeletingMember(null);
}}
onConfirm={handleDeleteMember}
title="Remove Member"
message={`Are you sure you want to remove ${deletingMember()?.name} from the team?`}
confirmText="Remove"
type="danger"
/>
</div>
);
};
+556 -166
View File
@@ -1,186 +1,576 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import {
IconNotebook,
IconSearch,
IconPlus,
IconEdit,
IconTrash,
IconCalendar,
IconTag,
IconLoader2
} from '@tabler/icons-solidjs'
import { createSignal, For, Show } from 'solid-js'
import { notesApi, type Note } from '@/lib/api-client'
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 { NoteModal } from '@/components/ui/NoteModal';
import { ViewNoteModal } from '@/components/ui/ViewNoteModal';
import { IconPin, IconTrash, IconEdit, IconCopy, IconDownload, IconPaperclip } from '@tabler/icons-solidjs';
import { getMockNotes } from '@/lib/mockData';
export function Notes() {
const [searchQuery, setSearchQuery] = createSignal('')
const notesQuery = notesApi.useGetAll()
const deleteNoteMutation = notesApi.useDelete()
interface Note {
id: number;
title: string;
content: string;
createdAt: string;
updatedAt: string;
tags: string[];
pinned: boolean;
attachments?: Array<{
id: string;
name: string;
type: string;
size: string;
url?: string;
}>;
isMarkdown?: boolean;
isHtml?: boolean;
}
const normalizeMockDate = (dateStr: string): string => {
const directDate = new Date(dateStr);
if (!isNaN(directDate.getTime())) {
return directDate.toISOString();
}
const match = dateStr.match(/(\d+)\s+(day|days|week|weeks|month|months|year|years)\s+ago/i);
if (!match) {
return new Date().toISOString();
}
const value = parseInt(match[1], 10);
const unit = match[2].toLowerCase();
const date = new Date();
switch (unit) {
case 'day':
case 'days':
date.setDate(date.getDate() - value);
break;
case 'week':
case 'weeks':
date.setDate(date.getDate() - value * 7);
break;
case 'month':
case 'months':
date.setMonth(date.getMonth() - value);
break;
case 'year':
case 'years':
date.setFullYear(date.getFullYear() - value);
break;
}
return date.toISOString();
};
const renderMarkdownPreviewHtml = (content: string, maxBlocks = 4): string => {
const html = content
.replace(/^# (.*$)/gim, '<h1 class="text-base font-semibold mb-1">$1<\/h1>')
.replace(/^## (.*$)/gim, '<h2 class="text-sm font-semibold mb-1">$1<\/h2>')
.replace(/^### (.*$)/gim, '<h3 class="text-sm font-semibold mb-1">$1<\/h3>')
.replace(/^#### (.*$)/gim, '<h4 class="text-xs font-semibold mb-1">$1<\/h4>')
.replace(/\*\*(.*?)\*\*/g, '<strong class="font-semibold">$1<\/strong>')
.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, '<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>')
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" class="text-blue-400 hover:underline" target="_blank" rel="noopener noreferrer">$1<\/a>')
.replace(/\n\n+/g, '<\/p><p class="mb-2">');
const parts = html.split('<\/p><p class="mb-2">');
const limited = parts.slice(0, maxBlocks).join('<\/p><p class="mb-2">');
return limited;
};
const renderPlainTextPreviewHtml = (content: string): string => {
return 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>')
.split('\n')
.slice(0, 6)
.map((line) => (line ? line : '<br \/>'))
.join('\n');
};
export const Notes = () => {
const [notes, setNotes] = createSignal<Note[]>([]);
const [isLoading, setIsLoading] = createSignal(true);
const [searchTerm, setSearchTerm] = createSignal('');
const [selectedTags, setSelectedTags] = createSignal<string[]>([]);
const [showAddModal, setShowAddModal] = createSignal(false);
const [showEditModal, setShowEditModal] = createSignal(false);
const [showViewModal, setShowViewModal] = createSignal(false);
const [editingNote, setEditingNote] = createSignal<Note | null>(null);
const [viewingNote, setViewingNote] = createSignal<Note | null>(null);
const [copiedContent, setCopiedContent] = createSignal(false);
const [expandedNotes, setExpandedNotes] = createSignal<Set<number>>(new Set());
// 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');
};
onMount(async () => {
try {
if (isDemoMode()) {
// Use mock data in demo mode
const mockNotes = getMockNotes();
const adaptedNotes = mockNotes.map((note, index) => ({
id: index + 1,
title: note.title,
content: note.content,
createdAt: normalizeMockDate(note.createdAt),
updatedAt: normalizeMockDate(note.updatedAt),
tags: note.tags.map(tag => tag.name),
pinned: note.tags.some(tag => tag.name === 'important' || tag.name === 'pinned'),
attachments: note.attachments?.map((att, index) => ({
id: `att_${index}`,
name: att.name,
type: att.type,
size: att.size,
url: `/attachments/${att.name}`
})) || [],
isMarkdown: note.content.includes('#') || note.content.includes('*'),
isHtml: note.content.includes('<') && note.content.includes('>')
}));
setNotes(adaptedNotes);
setIsLoading(false);
return;
}
// Load mock notes data
const mockNotes = getMockNotes();
const adaptedNotes = mockNotes.map((note, index) => ({
id: index + 1,
title: note.title,
content: note.content,
createdAt: normalizeMockDate(note.createdAt),
updatedAt: normalizeMockDate(note.updatedAt),
tags: note.tags.map(tag => tag.name),
pinned: note.tags.some(tag => tag.name === 'important' || tag.name === 'pinned'),
attachments: note.attachments?.map((att, index) => ({
id: `att_${index}`,
name: att.name,
type: att.type,
size: att.size,
url: `/attachments/${att.name}`
})) || [],
isMarkdown: note.content.includes('#') || note.content.includes('*'),
isHtml: note.content.includes('<') && note.content.includes('>')
}));
setNotes(adaptedNotes);
} catch (error) {
console.error('Failed to load notes:', error);
} finally {
setIsLoading(false);
}
});
const filteredNotes = () => {
const query = searchQuery().toLowerCase()
if (!query) return notesQuery.data || []
const term = searchTerm().toLowerCase();
const tags = selectedTags();
return (notesQuery.data || []).filter(note =>
note.title.toLowerCase().includes(query) ||
note.content.toLowerCase().includes(query) ||
note.tags.some(tag => tag.toLowerCase().includes(query))
)
}
return notes().filter(note => {
const matchesSearch = note.title.toLowerCase().includes(term) ||
note.content.toLowerCase().includes(term) ||
note.tags.some(tag => tag.toLowerCase().includes(term));
const matchesTags = tags.length === 0 ||
tags.every(tag => note.tags.includes(tag));
return matchesSearch && matchesTags;
}).sort((a, b) => {
if (a.pinned && !b.pinned) return -1;
if (!a.pinned && b.pinned) return 1;
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
});
};
const handleDeleteNote = async (noteId: number) => {
if (!confirm('Are you sure you want to delete this note?')) return
try {
await deleteNoteMutation.mutateAsync(noteId)
} catch (error) {
console.error('Error deleting note:', error)
alert('Failed to delete note')
const allTags = () => {
const tagSet = new Set<string>();
notes().forEach(note => {
note.tags.forEach(tag => tagSet.add(tag));
});
return Array.from(tagSet).sort();
};
const toggleTag = (tag: string) => {
const currentTags = selectedTags();
if (currentTags.includes(tag)) {
setSelectedTags([]);
} else {
setSelectedTags([tag]);
}
}
};
const handleAddNote = async (noteData: any) => {
try {
// TODO: Replace with actual API call
const note: Note = {
id: Date.now(),
title: noteData.title,
content: noteData.content,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
tags: noteData.tags,
pinned: false
};
setNotes(prev => [note, ...prev]);
setShowAddModal(false);
} catch (error) {
console.error('Failed to add note:', error);
}
};
const handleEditNote = async (noteData: any) => {
try {
// TODO: Replace with actual API call
setNotes(prev => prev.map(note =>
note.id === noteData.id
? {
...note,
title: noteData.title,
content: noteData.content,
tags: noteData.tags,
updatedAt: new Date().toISOString()
}
: note
));
setShowEditModal(false);
setEditingNote(null);
} catch (error) {
console.error('Failed to update note:', error);
}
};
const togglePin = async (noteId: number) => {
try {
// TODO: Replace with actual API call
setNotes(prev => prev.map(note =>
note.id === noteId ? { ...note, pinned: !note.pinned } : note
));
} catch (error) {
console.error('Failed to toggle pin:', error);
}
};
const deleteNote = async (noteId: number) => {
try {
// TODO: Replace with actual API call
setNotes(prev => prev.filter(note => note.id !== noteId));
} catch (error) {
console.error('Failed to delete note:', error);
}
};
const startEditNote = (note: Note) => {
setEditingNote(note);
setShowEditModal(true);
};
const viewNote = (note: Note) => {
console.log('Viewing note:', note.title);
setViewingNote(note);
setShowViewModal(true);
};
const copyNoteContent = async (note: Note) => {
try {
await navigator.clipboard.writeText(note.content);
setCopiedContent(true);
setTimeout(() => setCopiedContent(false), 2000);
} catch (error) {
console.error('Failed to copy content:', error);
}
};
const toggleNoteExpansion = (noteId: number) => {
setExpandedNotes(prev => {
const newSet = new Set(prev);
if (newSet.has(noteId)) {
newSet.delete(noteId);
} else {
newSet.add(noteId);
}
return newSet;
});
};
const exportNote = (note: Note) => {
const content = note.isMarkdown ? `# ${note.title}\n\n${note.content}` : note.content;
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${note.title.replace(/\s+/g, '_')}.md`;
a.click();
URL.revokeObjectURL(url);
};
return (
<div class="space-y-6">
{/* Page Header */}
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-white">Notes</h1>
<p class="text-gray-400 mt-2">Capture and organize your thoughts and ideas</p>
</div>
<Button>
<IconPlus class="mr-2 h-4 w-4" />
New Note
<div class="p-6 space-y-6">
<div class="flex justify-between items-center">
<h1 class="text-3xl font-bold text-[#fafafa]">Notes</h1>
<Button onClick={() => setShowAddModal(true)}>
Add Note
</Button>
</div>
{/* Error Display */}
<Show when={notesQuery.error}>
<div class="bg-red-900 border border-red-700 text-red-200 px-4 py-3 rounded">
Failed to load notes: {notesQuery.error?.message}
<SearchTagFilterBar
searchPlaceholder="Search notes..."
searchValue={searchTerm()}
onSearchChange={(value) => setSearchTerm(value)}
tagOptions={allTags()}
selectedTag={selectedTags()[0] || ''}
onTagChange={(value) => setSelectedTags(value ? [value] : [])}
onReset={() => {
setSearchTerm('');
setSelectedTags([]);
}}
/>
<Show when={copiedContent()}>
<div class="bg-primary/15 text-primary px-3 py-1 rounded-md text-sm">
Content copied!
</div>
</Show>
{/* Search and Filters */}
<div class="flex flex-col sm:flex-row gap-4">
<div class="relative flex-1">
<IconSearch class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Input
type="search"
placeholder="Search notes..."
value={searchQuery()}
onInput={(e) => setSearchQuery((e.target as HTMLInputElement).value)}
class="pl-10 bg-gray-800 border-gray-700 text-white placeholder-gray-400"
/>
{isLoading() ? (
<div class="space-y-4">
{[...Array(3)].map(() => (
<Card class="p-6">
<div class="animate-pulse">
<div class="h-6 bg-[#262626] rounded mb-2"></div>
<div class="h-4 bg-[#262626] rounded w-3/4"></div>
</div>
</Card>
))}
</div>
<div class="flex gap-2">
<Button variant="outline" size="sm">
<IconTag class="mr-2 h-4 w-4" />
All Tags
</Button>
<Button variant="outline" size="sm">
<IconCalendar class="mr-2 h-4 w-4" />
Recent
</Button>
</div>
</div>
{/* Loading State */}
<Show when={notesQuery.isLoading}>
<div class="flex items-center justify-center py-12">
<IconLoader2 class="h-8 w-8 animate-spin text-blue-400" />
<span class="ml-2 text-gray-400">Loading notes...</span>
</div>
</Show>
{/* Notes Grid */}
<Show when={!notesQuery.isLoading && !notesQuery.error}>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<For each={filteredNotes()}>
{(note) => (
<Card class="hover:shadow-lg transition-shadow">
<CardHeader class="pb-3">
<div class="flex items-start justify-between">
<div class="flex items-center space-x-3">
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-primary-600">
<IconNotebook class="h-4 w-4 text-white" />
</div>
<div class="min-w-0 flex-1">
<CardTitle class="text-lg text-white truncate">
{note.title}
</CardTitle>
<CardDescription class="text-xs text-gray-400">
{new Date(note.updated_at).toLocaleDateString()}
</CardDescription>
</div>
</div>
) : (
<div class="space-y-4">
{filteredNotes().map((note) => (
<Card
class={`p-6 cursor-pointer transition-all hover:shadow-lg hover:bg-[#1a1a1a] ${note.pinned ? 'border-l-4 border-l-primary' : ''}`}
onClick={() => viewNote(note)}
>
<div class="flex justify-between items-start mb-3">
<div class="flex items-center gap-2">
<h3 class="text-lg font-semibold text-[#fafafa]">{note.title}</h3>
{note.pinned && <IconPin class="size-4 text-primary" />}
{note.isMarkdown && <span class="text-xs px-2 py-1 bg-primary/10 text-primary rounded">MD</span>}
{note.isHtml && <span class="text-xs px-2 py-1 bg-primary/10 text-primary rounded">HTML</span>}
</div>
<div class="flex gap-1">
<Button
variant="ghost"
onClick={(e) => {
e.stopPropagation();
copyNoteContent(note);
}}
class="text-white hover:text-white/80 p-1"
>
<IconCopy size={16} />
</Button>
<Button
variant="ghost"
onClick={(e) => {
e.stopPropagation();
exportNote(note);
}}
class="text-white hover:text-white/80 p-1"
>
<IconDownload size={16} />
</Button>
<Button
variant="ghost"
onClick={(e) => {
e.stopPropagation();
startEditNote(note);
}}
class="text-white hover:text-white/80 p-1"
>
<IconEdit size={16} />
</Button>
<Button
variant="ghost"
onClick={(e) => {
e.stopPropagation();
togglePin(note.id);
}}
class="text-primary hover:text-primary/80 p-1"
{...{title: note.pinned ? "Unpin note" : "Pin note"}}
>
<IconPin size={16} />
</Button>
<Button
variant="ghost"
onClick={(e) => {
e.stopPropagation();
deleteNote(note.id);
}}
class="text-destructive hover:text-destructive/80 p-1"
>
<IconTrash size={16} />
</Button>
</div>
</div>
<div class="text-[#a3a3a3] text-sm mb-3">
<div class="prose prose-invert max-w-none">
<Show
when={expandedNotes().has(note.id)}
fallback={
<div
class="overflow-hidden"
style={{
display: '-webkit-box',
'-webkit-line-clamp': '3',
'-webkit-box-orient': 'vertical',
'max-height': '4.5em',
'line-height': '1.5em'
}}
innerHTML={
note.isHtml
? note.content
: note.isMarkdown
? renderMarkdownPreviewHtml(note.content)
: renderPlainTextPreviewHtml(note.content)
}
/>
}
>
<div
innerHTML={
note.isHtml
? note.content
: note.isMarkdown
? note.content.replace(/^# (.*$)/gim, '<h1 class="text-base font-semibold mb-2">$1</h1>')
.replace(/^## (.*$)/gim, '<h2 class="text-sm font-semibold mb-1">$1</h2>')
.replace(/^### (.*$)/gim, '<h3 class="text-sm font-semibold mb-1">$1</h3>')
.replace(/^#### (.*$)/gim, '<h4 class="text-xs font-semibold mb-1">$1</h4>')
.replace(/\*\*(.*?)\*\*/g, '<strong class="font-semibold">$1</strong>')
.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, '<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>')
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" class="text-blue-400 hover:underline" target="_blank" rel="noopener noreferrer">$1</a>')
.replace(/\n\n+/g, '</p><p class="mb-2">')
: 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>')
.split('\n').map((line) => line ? `<p class="mb-2">${line}</p>` : '<br />').join('')
}
/>
</Show>
</div>
<button
onClick={(e) => {
e.stopPropagation();
console.log('Show more clicked for note:', note.title);
toggleNoteExpansion(note.id);
}}
class="mt-2 text-xs text-primary hover:text-primary/80 font-medium cursor-pointer transition-colors"
>
{expandedNotes().has(note.id) ? 'Show less ←' : 'Show more →'}
</button>
</div>
{/* Attachments */}
<Show when={note.attachments && note.attachments.length > 0}>
<div class="mb-3">
<div class="flex items-center gap-2 mb-2">
<IconPaperclip class="size-4 text-[#a3a3a3]" />
<span class="text-xs text-[#a3a3a3]">Attachments ({note.attachments?.length || 0})</span>
</div>
</CardHeader>
<CardContent class="space-y-3">
{note.content && (
<p class="text-sm text-gray-300 line-clamp-3">
{note.content}
</p>
)}
{/* Tags */}
{note.tags && note.tags.length > 0 && (
<div class="flex flex-wrap gap-1">
<For each={note.tags}>
{(tag) => (
<span
class="inline-flex items-center px-2 py-1 rounded-full text-xs bg-gray-700 text-gray-300"
>
{tag}
</span>
)}
</For>
</div>
)}
{/* Actions */}
<div class="flex items-center justify-between pt-2 border-t border-gray-700">
<span class="text-xs text-gray-400">
Created {new Date(note.created_at).toLocaleDateString()}
</span>
<div class="flex space-x-1">
<Button variant="ghost" size="sm" class="text-gray-400 hover:text-white">
<IconEdit class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
class="text-gray-400 hover:text-red-400"
onClick={() => handleDeleteNote(note.id)}
>
<IconTrash class="h-4 w-4" />
</Button>
</div>
<div class="flex flex-wrap gap-2">
<For each={note.attachments || []}>
{(attachment) => (
<div class="flex items-center gap-2 px-2 py-1 bg-[#262626] rounded-md text-xs">
<span class="text-[#a3a3a3]">{attachment.name}</span>
<span class="text-[#666]">({attachment.size})</span>
</div>
)}
</For>
</div>
</CardContent>
</Card>
)}
</For>
</div>
</Show>
<div class="flex flex-wrap gap-2 mb-3">
<For each={note.tags}>
{(tag) => (
<button
onClick={(e) => {
e.stopPropagation();
toggleTag(tag);
}}
class="px-2 py-1 bg-muted hover:bg-muted/80 text-muted-foreground hover:text-foreground text-xs rounded-md transition-colors cursor-pointer"
>
{tag}
</button>
)}
</For>
</div>
<p class="text-[#a3a3a3] text-xs">
Updated: {note.updatedAt && !isNaN(new Date(note.updatedAt).getTime()) ? new Date(note.updatedAt).toLocaleDateString() : 'Invalid Date'}
</p>
</Card>
))}
{filteredNotes().length === 0 && (
<Card class="p-12 text-center">
<p class="text-muted-foreground">
{searchTerm() || selectedTags().length > 0
? 'No notes found matching your search or filters.'
: 'No notes yet. Add your first note!'}
</p>
</Card>
)}
</div>
{/* Empty State */}
<Show when={filteredNotes().length === 0}>
<div class="text-center py-12">
<IconNotebook class="mx-auto h-12 w-12 text-gray-400 mb-4" />
<h3 class="text-lg font-medium text-white mb-2">No notes found</h3>
<p class="text-gray-400 mb-4">
{searchQuery() ? 'Try adjusting your search terms' : 'Create your first note to get started'}
</p>
<Button>
<IconPlus class="mr-2 h-4 w-4" />
New Note
</Button>
</div>
</Show>
</Show>
)}
{/* Add Note Modal */}
<NoteModal
isOpen={showAddModal()}
onClose={() => setShowAddModal(false)}
onSubmit={handleAddNote}
availableTags={allTags()}
/>
{/* Edit Note Modal */}
<NoteModal
isOpen={showEditModal()}
onClose={() => {
setShowEditModal(false);
setEditingNote(null);
}}
onSubmit={handleEditNote}
note={editingNote()}
availableTags={allTags()}
/>
{/* View Note Modal */}
<ViewNoteModal
isOpen={showViewModal()}
onClose={() => {
setShowViewModal(false);
setViewingNote(null);
}}
note={viewingNote()}
onEdit={startEditNote}
onTogglePin={togglePin}
onDelete={deleteNote}
onCopyContent={copyNoteContent}
onExportNote={exportNote}
/>
</div>
)
}
);
};
+98
View File
@@ -0,0 +1,98 @@
import { Button } from '@/components/ui/Button';
import { GitHubActivity } from '@/components/ui/GitHubActivity';
import { IconSettings } from '@tabler/icons-solidjs';
export const Profile = () => {
// Custom events for Profile page
const profileEvents = [
{
type: 'commit' as const,
title: 'feat: Add advanced color scheme management',
date: '2024-01-28',
link: '/app/activity',
repo: 'trackeep',
action: 'pushed'
},
{
type: 'pull_request' as const,
title: 'Enhance admin settings with toggle buttons',
date: '2024-01-27',
link: '/app/admin',
repo: 'trackeep',
action: 'opened'
},
{
type: 'merge' as const,
title: 'Merge branch: feature/ai-chat-enhancements',
date: '2024-01-26',
link: '/app/chat',
repo: 'trackeep',
action: 'merged'
},
{
type: 'bookmark' as const,
title: 'Added bookmark: Advanced React Patterns',
date: '2024-01-25',
link: '/app/bookmarks'
},
{
type: 'project' as const,
title: 'Updated project: Trackeep Dashboard',
date: '2024-01-24',
link: '/app/projects'
},
{
type: 'learning' as const,
title: 'Completed lesson: SolidJS Fundamentals',
date: '2024-01-23',
link: '/app/learning'
},
{
type: 'note' as const,
title: 'Created note: API Architecture Ideas',
date: '2024-01-22',
link: '/app/notes'
},
{
type: 'push' as const,
title: 'Fix navigation icon colors and responsiveness',
date: '2024-01-21',
link: '/app/activity',
repo: 'trackeep',
action: 'pushed'
}
];
return (
<div class="p-6 space-y-6 h-full overflow-hidden">
{/* Header */}
<div class="flex justify-between items-center flex-shrink-0">
<div>
<h1 class="text-3xl font-bold text-foreground">Profile Activity</h1>
<p class="text-muted-foreground mt-2">Track your contributions and activity over time</p>
</div>
<div class="flex gap-2">
<Button variant="outline" size="sm">
<IconSettings class="size-4 mr-2" />
Settings
</Button>
</div>
</div>
{/* GitHub Activity Component */}
<div class="flex-1 overflow-hidden">
<GitHubActivity
title="Profile Activity"
customEvents={profileEvents}
showStats={true}
showContributionGraph={true}
showRecentActivity={true}
compact={false}
period="year"
hideHeader={true}
fullWidth={true}
/>
</div>
</div>
);
};
+266
View File
@@ -0,0 +1,266 @@
import { createSignal, onMount } from 'solid-js';
import { IconUpload, IconFileText, IconFolder, IconVideo, IconBookmark, IconChecklist, IconNotebook, IconPlus, IconSearch } from '@tabler/icons-solidjs';
interface QuickItem {
id: string;
name: string;
type: 'file' | 'bookmark' | 'task' | 'note' | 'video';
description: string;
icon: any;
action: string;
}
export const QuickSelection = () => {
const [quickItems, setQuickItems] = createSignal<QuickItem[]>([]);
const [searchTerm, setSearchTerm] = createSignal('');
const [selectedCategory, setSelectedCategory] = createSignal('all');
onMount(() => {
setQuickItems([
{
id: '1',
name: 'Upload Document',
type: 'file',
description: 'Upload a new document to your workspace',
icon: IconUpload,
action: 'upload'
},
{
id: '2',
name: 'Create Bookmark',
type: 'bookmark',
description: 'Save a new bookmark',
icon: IconBookmark,
action: 'create'
},
{
id: '3',
name: 'Add Task',
type: 'task',
description: 'Create a new task',
icon: IconChecklist,
action: 'create'
},
{
id: '4',
name: 'Write Note',
type: 'note',
description: 'Create a new note',
icon: IconNotebook,
action: 'create'
},
{
id: '5',
name: 'Import YouTube',
type: 'video',
description: 'Import a YouTube video',
icon: IconVideo,
action: 'import'
},
{
id: '6',
name: 'Browse Files',
type: 'file',
description: 'Browse existing files',
icon: IconFolder,
action: 'browse'
},
{
id: '7',
name: 'Quick Upload',
type: 'file',
description: 'Quick upload with drag & drop',
icon: IconUpload,
action: 'quick-upload'
},
{
id: '8',
name: 'Recent Files',
type: 'file',
description: 'View recently uploaded files',
icon: IconFileText,
action: 'recent'
}
]);
});
const filteredItems = () => {
return quickItems().filter(item => {
const matchesSearch = item.name.toLowerCase().includes(searchTerm().toLowerCase()) ||
item.description.toLowerCase().includes(searchTerm().toLowerCase());
const matchesCategory = selectedCategory() === 'all' || item.type === selectedCategory();
return matchesSearch && matchesCategory;
});
};
const handleAction = (action: string) => {
console.log(`Action: ${action}`);
// Handle different actions based on the type
switch (action) {
case 'upload':
// Trigger file upload
break;
case 'create':
// Create new item
break;
case 'import':
// Import from external source
break;
case 'browse':
// Navigate to files
break;
case 'quick-upload':
// Quick upload modal
break;
case 'recent':
// Show recent files
break;
default:
break;
}
};
const categories = [
{ value: 'all', label: 'All Items' },
{ value: 'file', label: 'Files' },
{ value: 'bookmark', label: 'Bookmarks' },
{ value: 'task', label: 'Tasks' },
{ value: 'note', label: 'Notes' },
{ value: 'video', label: 'Videos' }
];
return (
<div class="p-6 mt-4 pb-32 max-w-5xl mx-auto">
<h1 class="text-3xl font-bold text-foreground mb-6 flex items-center gap-2">
<IconUpload class="size-8" />
Quick Selection
</h1>
{/* Search and Filter */}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<div class="relative">
<IconSearch class="absolute left-3 top-1/2 transform -translate-y-1/2 size-4 text-muted-foreground" />
<input
type="text"
placeholder="Search actions..."
value={searchTerm()}
onInput={(e) => setSearchTerm(e.currentTarget.value)}
class="flex h-10 w-full rounded-md border border-input bg-background pl-10 pr-3 py-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring"
/>
</div>
<select
value={selectedCategory()}
onChange={(e) => setSelectedCategory(e.target.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"
>
{categories.map(cat => (
<option value={cat.value}>{cat.label}</option>
))}
</select>
</div>
{/* Quick Actions Grid */}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-8">
{filteredItems().map((item) => {
const Icon = item.icon;
return (
<div
class="border rounded-lg p-6 cursor-pointer transition-all hover:shadow-md hover:border-primary/50"
onClick={() => handleAction(item.action)}
>
<div class="flex items-center gap-3 mb-3">
<div class="p-2 rounded-lg bg-muted">
<Icon class="size-6 text-primary" />
</div>
<div>
<h3 class="font-medium text-foreground">{item.name}</h3>
<span class="text-xs text-muted-foreground capitalize">{item.type}</span>
</div>
</div>
<p class="text-sm text-muted-foreground mb-4">{item.description}</p>
<button
type="button"
class="inline-flex justify-center rounded-md text-sm font-medium transition-shadow focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground shadow hover:bg-primary/90 h-auto items-center gap-2 py-1.5 px-3"
>
<IconPlus class="size-4" />
{item.action === 'upload' ? 'Upload' :
item.action === 'create' ? 'Create' :
item.action === 'import' ? 'Import' :
item.action === 'browse' ? 'Browse' :
item.action === 'quick-upload' ? 'Quick Upload' :
item.action === 'recent' ? 'View Recent' : 'Action'}
</button>
</div>
);
})}
</div>
{/* Custom Upload Section */}
<div class="border rounded-lg p-6">
<h2 class="text-xl font-semibold text-foreground mb-4">Custom Upload</h2>
<div class="border-2 border-dashed border-muted-foreground/25 rounded-lg p-8 text-center">
<IconUpload class="size-12 text-muted-foreground mx-auto mb-4" />
<h3 class="text-lg font-medium text-foreground mb-2">Drag & Drop Files</h3>
<p class="text-muted-foreground mb-4">
Or click to select files from your computer
</p>
<button
type="button"
class="inline-flex justify-center rounded-md text-sm font-medium transition-shadow focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground shadow hover:bg-primary/90 h-auto items-center gap-2 py-2 px-4"
>
<IconUpload class="size-4" />
Select Files
</button>
</div>
<div class="mt-4">
<h4 class="font-medium text-foreground mb-2">Supported Formats:</h4>
<div class="flex flex-wrap gap-2">
{['PDF', 'DOC', 'DOCX', 'PPT', 'PPTX', 'TXT', 'MD', 'JPG', 'PNG', 'GIF'].map(format => (
<span class="inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold">
{format}
</span>
))}
</div>
</div>
</div>
{/* Recent Activity */}
<div class="border rounded-lg p-6 mt-6">
<h2 class="text-xl font-semibold text-foreground mb-4">Recent Quick Actions</h2>
<div class="space-y-3">
<div class="flex items-center justify-between p-3 bg-muted/50 rounded-lg">
<div class="flex items-center gap-3">
<IconFileText class="size-5 text-primary" />
<div>
<p class="font-medium text-foreground">Document uploaded</p>
<p class="text-sm text-muted-foreground">presentation.pptx</p>
</div>
</div>
<span class="text-sm text-muted-foreground">2 minutes ago</span>
</div>
<div class="flex items-center justify-between p-3 bg-muted/50 rounded-lg">
<div class="flex items-center gap-3">
<IconBookmark class="size-5 text-primary" />
<div>
<p class="font-medium text-foreground">Bookmark created</p>
<p class="text-sm text-muted-foreground">SolidJS Documentation</p>
</div>
</div>
<span class="text-sm text-muted-foreground">15 minutes ago</span>
</div>
<div class="flex items-center justify-between p-3 bg-muted/50 rounded-lg">
<div class="flex items-center gap-3">
<IconNotebook class="size-5 text-primary" />
<div>
<p class="font-medium text-foreground">Note created</p>
<p class="text-sm text-muted-foreground">Project Notes</p>
</div>
</div>
<span class="text-sm text-muted-foreground">1 hour ago</span>
</div>
</div>
</div>
</div>
);
};
+416
View File
@@ -0,0 +1,416 @@
import { createSignal, onMount, Show } from 'solid-js';
import { IconTrash, IconRestore, IconFileText, IconFileTypePpt, IconFileTypeDocx, IconClock, IconSettings, IconAlertTriangle } from '@tabler/icons-solidjs';
interface RemovedItem {
id: string;
name: string;
type: string;
removedAt: string;
removedBy: string;
size?: string;
path?: string;
daysInTrash?: number;
}
interface AutoRemoveSettings {
enabled: boolean;
afterDays: number;
autoEmpty: boolean;
}
export const RemovedStuff = () => {
const [removedItems, setRemovedItems] = createSignal<RemovedItem[]>([]);
const [autoRemoveSettings, setAutoRemoveSettings] = createSignal<AutoRemoveSettings>({
enabled: false,
afterDays: 30,
autoEmpty: false
});
const [showSettings, setShowSettings] = createSignal(false);
const [selectedItems, setSelectedItems] = createSignal<string[]>([]);
onMount(() => {
// Load auto-remove settings from localStorage
const savedSettings = localStorage.getItem('autoRemoveSettings');
if (savedSettings) {
setAutoRemoveSettings(JSON.parse(savedSettings));
}
// Enhanced mock data with more realistic items
const mockItems: RemovedItem[] = [
{
id: '1',
name: 'Old Document',
type: 'docx',
removedAt: '2 days ago',
removedBy: 'John Doe',
size: '2.5 MB',
path: '/documents/old-document.docx',
daysInTrash: 2
},
{
id: '2',
name: 'Deleted Presentation',
type: 'pptx',
removedAt: '1 week ago',
removedBy: 'Jane Smith',
size: '15.3 MB',
path: '/presentations/deleted-presentation.pptx',
daysInTrash: 7
},
{
id: '3',
name: 'Removed Note',
type: 'note',
removedAt: '2 weeks ago',
removedBy: 'Admin',
size: '156 KB',
path: '/notes/removed-note.md',
daysInTrash: 14
},
{
id: '4',
name: 'Old Backup File',
type: 'zip',
removedAt: '3 weeks ago',
removedBy: 'System',
size: '125.7 MB',
path: '/backups/old-backup.zip',
daysInTrash: 21
},
{
id: '5',
name: 'Temporary Files',
type: 'folder',
removedAt: '1 month ago',
removedBy: 'John Doe',
size: '8.2 MB',
path: '/temp/temporary-files',
daysInTrash: 30
}
];
setRemovedItems(mockItems);
// Check for auto-remove on mount
checkAutoRemove();
});
const checkAutoRemove = () => {
const settings = autoRemoveSettings();
if (!settings.enabled) return;
const itemsToRemove = removedItems().filter(item =>
(item.daysInTrash || 0) >= settings.afterDays
);
if (itemsToRemove.length > 0) {
if (settings.autoEmpty) {
// Auto-empty trash
setRemovedItems([]);
console.log(`Auto-removed ${itemsToRemove.length} items from trash`);
} else {
// Show notification for manual review
console.log(`${itemsToRemove.length} items are ready for auto-remove`);
}
}
};
const saveAutoRemoveSettings = (settings: AutoRemoveSettings) => {
setAutoRemoveSettings(settings);
localStorage.setItem('autoRemoveSettings', JSON.stringify(settings));
checkAutoRemove();
};
const getFileIcon = (type: string) => {
switch (type) {
case 'docx':
return IconFileTypeDocx;
case 'pptx':
return IconFileTypePpt;
case 'zip':
return IconFileText;
case 'folder':
return IconFileText;
default:
return IconFileText;
}
};
const handleEmptyTrash = () => {
if (confirm('Are you sure you want to permanently delete all items in the trash? This action cannot be undone.')) {
setRemovedItems([]);
alert('Trash emptied successfully!');
}
};
const handleRestoreItem = (id: string) => {
const item = removedItems().find(item => item.id === id);
if (item) {
setRemovedItems(prev => prev.filter(item => item.id !== id));
alert(`"${item.name}" has been restored successfully!`);
}
};
const handlePermanentlyDelete = (id: string) => {
const item = removedItems().find(item => item.id === id);
if (item && confirm(`Are you sure you want to permanently delete "${item.name}"? This action cannot be undone.`)) {
setRemovedItems(prev => prev.filter(item => item.id !== id));
alert(`"${item.name}" has been permanently deleted!`);
}
};
const handleBulkRestore = () => {
if (selectedItems().length === 0) return;
if (confirm(`Are you sure you want to restore ${selectedItems().length} items?`)) {
const itemsToRestore = removedItems().filter(item => selectedItems().includes(item.id));
setRemovedItems(prev => prev.filter(item => !selectedItems().includes(item.id)));
setSelectedItems([]);
alert(`${itemsToRestore.length} items have been restored successfully!`);
}
};
const handleBulkDelete = () => {
if (selectedItems().length === 0) return;
if (confirm(`Are you sure you want to permanently delete ${selectedItems().length} items? This action cannot be undone.`)) {
const itemsToDelete = removedItems().filter(item => selectedItems().includes(item.id));
setRemovedItems(prev => prev.filter(item => !selectedItems().includes(item.id)));
setSelectedItems([]);
alert(`${itemsToDelete.length} items have been permanently deleted!`);
}
};
const getItemsReadyForAutoRemove = () => {
const settings = autoRemoveSettings();
if (!settings.enabled) return [];
return removedItems().filter(item =>
(item.daysInTrash || 0) >= settings.afterDays
);
};
return (
<div class="p-6 mt-4 pb-32 max-w-5xl mx-auto">
<div class="flex justify-between items-center mb-6">
<div>
<h1 class="text-3xl font-bold text-foreground">Removed Stuff</h1>
<p class="text-muted-foreground mt-1">
{removedItems().length} items in trash
{autoRemoveSettings().enabled && ` • Auto-remove enabled (${autoRemoveSettings().afterDays} days)`}
</p>
</div>
<div class="flex gap-2">
<button
type="button"
onClick={() => setShowSettings(!showSettings())}
class="inline-flex justify-center rounded-md text-sm font-medium transition-shadow focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-secondary text-secondary-foreground shadow hover:bg-secondary/90 h-auto items-center gap-2 py-2 px-4"
>
<IconSettings class="size-4" />
Auto-Remove
</button>
<button
type="button"
onClick={handleEmptyTrash}
class="inline-flex justify-center rounded-md text-sm font-medium transition-shadow focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-destructive text-destructive-foreground shadow hover:bg-destructive/90 h-auto items-center gap-2 py-2 px-4"
>
<IconTrash class="size-4" />
Empty Trash
</button>
</div>
</div>
{/* Auto-Remove Settings */}
<Show when={showSettings()}>
<div class="border rounded-lg p-4 mb-6 bg-muted/30">
<h3 class="text-lg font-medium text-foreground mb-4 flex items-center gap-2">
<IconClock class="size-5" />
Auto-Remove Settings
</h3>
<div class="space-y-4">
<div class="flex items-center gap-2">
<input
type="checkbox"
id="auto-remove-enabled"
checked={autoRemoveSettings().enabled}
onChange={(e) => saveAutoRemoveSettings({
...autoRemoveSettings(),
enabled: e.currentTarget.checked
})}
class="rounded border-input"
/>
<label for="auto-remove-enabled" class="text-sm font-medium text-foreground">
Enable automatic removal
</label>
</div>
<Show when={autoRemoveSettings().enabled}>
<div class="space-y-3">
<div>
<label class="block text-sm font-medium text-muted-foreground mb-1">
Remove items after (days):
</label>
<input
type="number"
min="1"
max="365"
value={autoRemoveSettings().afterDays}
onChange={(e) => saveAutoRemoveSettings({
...autoRemoveSettings(),
afterDays: parseInt(e.currentTarget.value) || 30
})}
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring"
/>
</div>
<div class="flex items-center gap-2">
<input
type="checkbox"
id="auto-empty"
checked={autoRemoveSettings().autoEmpty}
onChange={(e) => saveAutoRemoveSettings({
...autoRemoveSettings(),
autoEmpty: e.currentTarget.checked
})}
class="rounded border-input"
/>
<label for="auto-empty" class="text-sm font-medium text-foreground">
Auto-empty trash when items expire
</label>
</div>
</div>
</Show>
</div>
{/* Items ready for auto-remove */}
<Show when={autoRemoveSettings().enabled && getItemsReadyForAutoRemove().length > 0}>
<div class="mt-4 p-3 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
<div class="flex items-center gap-2 text-yellow-800 dark:text-yellow-200">
<IconAlertTriangle class="size-4" />
<span class="text-sm font-medium">
{getItemsReadyForAutoRemove().length} items are ready for automatic removal
</span>
</div>
</div>
</Show>
</div>
</Show>
{/* Bulk Actions */}
<Show when={selectedItems().length > 0}>
<div class="border rounded-lg p-3 mb-4 bg-primary/5">
<div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">
{selectedItems().length} items selected
</span>
<div class="flex gap-2">
<button
onClick={handleBulkRestore}
class="inline-flex justify-center rounded-md text-sm font-medium transition-shadow focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground shadow hover:bg-primary/90 h-auto items-center gap-2 py-1.5 px-3"
>
<IconRestore class="size-4" />
Restore Selected
</button>
<button
onClick={handleBulkDelete}
class="inline-flex justify-center rounded-md text-sm font-medium transition-shadow focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-destructive text-destructive-foreground shadow hover:bg-destructive/90 h-auto items-center gap-2 py-1.5 px-3"
>
<IconTrash class="size-4" />
Delete Selected
</button>
</div>
</div>
</div>
</Show>
{/* Enhanced Table */}
<div class="w-full overflow-auto">
<table class="w-full caption-bottom text-sm">
<thead class="[&_tr]:border-b">
<tr class="border-b transition-colors data-[state=selected]:bg-muted">
<th class="h-10 px-2 text-left align-middle font-medium text-muted-foreground">
<input
type="checkbox"
checked={selectedItems().length === removedItems().length && removedItems().length > 0}
onChange={() => {
if (selectedItems().length === removedItems().length) {
setSelectedItems([]);
} else {
setSelectedItems(removedItems().map(item => item.id));
}
}}
class="rounded border-input"
/>
</th>
<th class="h-10 px-2 text-left align-middle font-medium text-muted-foreground">Item</th>
<th class="h-10 px-2 text-left align-middle font-medium text-muted-foreground">Type</th>
<th class="h-10 px-2 text-left align-middle font-medium text-muted-foreground">Size</th>
<th class="h-10 px-2 text-left align-middle font-medium text-muted-foreground">Removed By</th>
<th class="h-10 px-2 text-left align-middle font-medium text-muted-foreground">Removed At</th>
<th class="h-10 px-2 text-left align-middle font-medium text-muted-foreground text-right">Actions</th>
</tr>
</thead>
<tbody class="[&_tr:last-child]:border-0">
{removedItems().map((item) => {
const FileIcon = getFileIcon(item.type);
return (
<tr class="border-b transition-colors data-[state=selected]:bg-muted">
<td class="p-2 align-middle">
<input
type="checkbox"
checked={selectedItems().includes(item.id)}
onChange={() => {
if (selectedItems().includes(item.id)) {
setSelectedItems(prev => prev.filter(id => id !== item.id));
} else {
setSelectedItems(prev => [...prev, item.id]);
}
}}
class="rounded border-input"
/>
</td>
<td class="p-2 align-middle">
<div class="flex items-center gap-3">
<div class="bg-muted flex items-center justify-center p-2 rounded-lg">
<FileIcon class="size-6 text-destructive" />
</div>
<div>
<div class="font-medium">{item.name}</div>
<div class="text-xs text-muted-foreground">{item.path}</div>
</div>
</div>
</td>
<td class="p-2 align-middle">
<span class="inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2">
{item.type}
</span>
</td>
<td class="p-2 align-middle text-muted-foreground">
{item.size}
</td>
<td class="p-2 align-middle text-muted-foreground">
{item.removedBy}
</td>
<td class="p-2 align-middle text-muted-foreground">
{item.removedAt}
</td>
<td class="p-2 align-middle">
<div class="flex items-center justify-end gap-2">
<button type="button" onClick={() => handleRestoreItem(item.id)} class="inline-flex items-center justify-center rounded-md text-sm font-medium transition-shadow focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground shadow hover:bg-primary/90 h-auto items-center gap-2 py-1.5 px-3">
<IconRestore class="size-4" />
Restore
</button>
<button type="button" onClick={() => handlePermanentlyDelete(item.id)} 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-destructive text-destructive-foreground shadow hover:bg-destructive/90 h-auto items-center gap-2 py-1.5 px-3">
<IconTrash class="size-4" />
Delete
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
};
+9
View File
@@ -0,0 +1,9 @@
import { EnhancedSearch } from '@/components/search/EnhancedSearch';
export const Search = () => {
return (
<div class="container mx-auto px-4 py-8">
<EnhancedSearch />
</div>
);
};
File diff suppressed because it is too large Load Diff
+615
View File
@@ -0,0 +1,615 @@
import { createSignal, onMount, Show } from 'solid-js';
import { Button } from '@/components/ui/Button';
import { GitHubActivity } from '@/components/ui/GitHubActivity';
import {
IconActivity,
IconUsers,
IconBookmarks,
IconFileText,
IconChecklist,
IconNotebook,
IconSettings,
IconCalendar,
IconDownload,
IconFilter,
IconRefresh,
IconChartLine,
IconFolder,
IconClock
} from '@tabler/icons-solidjs';
import { ActivityFeed } from '@/components/ui/ActivityFeed';
import { getMockStats, getMockActivities } from '@/lib/mockData';
import { formatDuration } from '@/lib/timeFormat';
interface ActivityData {
date: string;
count: number;
level: number; // 0-5 intensity level
}
interface StatsData {
totalBookmarks: number;
totalDocuments: number;
totalTasks: number;
totalNotes: number;
completedTasks: number;
activeTasks: number;
storageUsed: string;
storageTotal: string;
weeklyActivity: number[];
monthlyGrowth: {
bookmarks: number;
documents: number;
tasks: number;
notes: number;
};
topCategories: Array<{
name: string;
count: number;
color: string;
}>;
recentActivity: Array<{
type: string;
count: number;
change: number;
}>;
contributionGraph: ActivityData[];
totalTimeTracked?: number;
averageProductivity?: number;
recentProjects?: Array<{
name: string;
progress: number;
status: string;
}>;
}
export const Stats = () => {
const [stats, setStats] = createSignal<StatsData>({
totalBookmarks: 0,
totalDocuments: 0,
totalTasks: 0,
totalNotes: 0,
completedTasks: 0,
activeTasks: 0,
storageUsed: '0 MB',
storageTotal: '50 GB',
weeklyActivity: [0, 0, 0, 0, 0, 0, 0],
monthlyGrowth: {
bookmarks: 0,
documents: 0,
tasks: 0,
notes: 0
},
topCategories: [],
recentActivity: [],
contributionGraph: []
});
const [timeRange, setTimeRange] = createSignal<'week' | 'month' | 'year'>('week');
const [refreshKey, setRefreshKey] = createSignal(0);
const [showFilters, setShowFilters] = createSignal(false);
const handleRefresh = () => {
setRefreshKey(prev => prev + 1);
};
onMount(() => {
// Use mock data from our mockData file
const mockStats = getMockStats();
const mockActivities = getMockActivities();
// Generate mock contribution graph data
const generateContributionGraph = () => {
const graph: ActivityData[] = [];
const today = new Date();
const oneYearAgo = new Date(today);
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
for (let d = new Date(oneYearAgo); d <= today; d.setDate(d.getDate() + 1)) {
const count = Math.floor(Math.random() * 10);
const level = count === 0 ? 0 : Math.ceil(count / 2);
graph.push({
date: new Date(d).toISOString().split('T')[0],
count,
level
});
}
return graph;
};
// Set stats using mock data
setStats({
totalBookmarks: mockStats.totalBookmarks,
totalDocuments: mockStats.totalDocuments,
totalTasks: mockStats.totalTasks,
totalNotes: mockStats.totalNotes,
completedTasks: mockStats.completedTasks,
activeTasks: mockStats.activeTasks,
storageUsed: mockStats.totalSize,
storageTotal: '50 GB',
weeklyActivity: [12, 19, 8, 15, 25, 6, 14], // Enhanced mock data for better visualization
monthlyGrowth: mockStats.monthlyGrowth,
topCategories: [
{ name: 'Work', count: 45, color: 'hsl(var(--primary))' },
{ name: 'Personal', count: 32, color: 'hsl(var(--primary))' },
{ name: 'Learning', count: 28, color: 'hsl(var(--primary))' }
],
recentActivity: [
{ type: 'Bookmarks', count: mockActivities.filter(a => a.type === 'bookmark').length, change: 8 },
{ type: 'Documents', count: mockActivities.filter(a => a.type === 'document').length, change: -2 },
{ type: 'Tasks', count: mockActivities.filter(a => a.type === 'task').length, change: 3 },
{ type: 'Notes', count: mockActivities.filter(a => a.type === 'note').length, change: 12 }
],
contributionGraph: generateContributionGraph()
});
});
const storagePercentage = () => {
const used = parseFloat(stats().storageUsed);
const total = parseFloat(stats().storageTotal);
return Math.round((used / total) * 100);
};
const taskCompletionRate = () => {
return Math.round((stats().completedTasks / stats().totalTasks) * 100);
};
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>
<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>
<div class="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setShowFilters(!showFilters())}
>
<IconFilter class="size-4 mr-2" />
Filters
</Button>
<Button
variant="outline"
size="sm"
onClick={handleRefresh}
>
<IconRefresh class="size-4 mr-2" />
Refresh
</Button>
<Button variant="outline" size="sm">
<IconDownload class="size-4 mr-2" />
Export
</Button>
{(['week', 'month', 'year'] as const).map((range) => (
<Button
variant={timeRange() === range ? 'default' : 'outline'}
onClick={() => setTimeRange(range)}
size="sm"
>
{range.charAt(0).toUpperCase() + range.slice(1)}
</Button>
))}
</div>
</div>
{/* Main Stats Grid - 2-column layout with larger left column */}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Left Column - Main Stats */}
<div class="space-y-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="border rounded-lg p-4 bg-card hover:shadow-sm transition-shadow">
<div class="flex items-center gap-3">
<div class="bg-muted flex items-center justify-center p-2 rounded-lg">
<IconBookmarks class="size-5 text-primary" />
</div>
<div>
<p class="text-2xl font-light text-foreground">{stats().totalBookmarks}</p>
<p class="text-sm text-muted-foreground">Bookmarks</p>
</div>
</div>
</div>
<div class="border rounded-lg p-4 bg-card hover:shadow-sm transition-shadow">
<div class="flex items-center gap-3">
<div class="bg-muted flex items-center justify-center p-2 rounded-lg">
<IconFileText class="size-5 text-primary" />
</div>
<div>
<p class="text-2xl font-light text-foreground">{stats().totalDocuments}</p>
<p class="text-sm text-muted-foreground">Documents</p>
</div>
</div>
</div>
<div class="border rounded-lg p-4 bg-card hover:shadow-sm transition-shadow">
<div class="flex items-center gap-3">
<div class="bg-muted flex items-center justify-center p-2 rounded-lg">
<IconChecklist class="size-5 text-primary" />
</div>
<div>
<p class="text-2xl font-light text-foreground">{stats().totalTasks}</p>
<p class="text-sm text-muted-foreground">Tasks</p>
</div>
</div>
</div>
<div class="border rounded-lg p-4 bg-card hover:shadow-sm transition-shadow">
<div class="flex items-center gap-3">
<div class="bg-muted flex items-center justify-center p-2 rounded-lg">
<IconNotebook class="size-5 text-primary" />
</div>
<div>
<p class="text-2xl font-light text-foreground">{stats().totalNotes}</p>
<p class="text-sm text-muted-foreground">Notes</p>
</div>
</div>
</div>
</div>
</div>
{/* Right Column - Enhanced Stats */}
<div class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div class="border rounded-lg p-4 bg-card hover:shadow-sm transition-shadow">
<div class="flex flex-col items-center text-center gap-2">
<div class="bg-muted flex items-center justify-center p-2 rounded-lg">
<IconClock class="size-5 text-primary" />
</div>
<div>
<p class="text-xl font-bold text-foreground">{formatDuration(stats().totalTimeTracked || 0)}</p>
<p class="text-xs text-muted-foreground font-medium">Total Time</p>
</div>
</div>
</div>
<div class="border rounded-lg p-4 bg-card hover:shadow-sm transition-shadow">
<div class="flex items-center gap-2">
<IconUsers class="size-4 text-primary" />
<div>
<p class="text-lg font-semibold text-foreground">12</p>
<p class="text-xs text-muted-foreground">Collaborators</p>
</div>
</div>
</div>
<div class="border rounded-lg p-4 bg-card hover:shadow-sm transition-shadow">
<div class="flex items-center gap-2">
<IconChartLine class="size-4 text-primary" />
<div>
<p class="text-lg font-semibold text-foreground">{stats().averageProductivity || 78}%</p>
<p class="text-xs text-muted-foreground">Productivity</p>
</div>
</div>
</div>
<div class="border rounded-lg p-4 bg-card hover:shadow-sm transition-shadow">
<div class="flex items-center gap-2">
<IconCalendar class="size-4 text-primary" />
<div>
<p class="text-lg font-semibold text-foreground">156</p>
<p class="text-xs text-muted-foreground">Days Active</p>
</div>
</div>
</div>
<div class="border rounded-lg p-4 bg-card hover:shadow-sm transition-shadow">
<div class="flex items-center gap-2">
<IconSettings class="size-4 text-primary" />
<div>
<p class="text-lg font-semibold text-foreground">{stats().recentProjects?.length || 4}</p>
<p class="text-xs text-muted-foreground">Projects</p>
</div>
</div>
</div>
<div class="border rounded-lg p-4 bg-card hover:shadow-sm transition-shadow">
<div class="flex items-center gap-2">
<IconFolder class="size-4 text-primary" />
<div>
<p class="text-lg font-semibold text-foreground">{stats().storageUsed || 12.94} GB</p>
<p class="text-xs text-muted-foreground">Storage Used</p>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Progress and Storage */}
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="border rounded-lg p-6">
<div class="flex items-center gap-2 mb-4">
<IconChartLine class="size-5 text-primary" />
<h3 class="text-lg font-semibold">Task Completion</h3>
</div>
<div class="space-y-4">
<div>
<div class="flex justify-between text-sm mb-2">
<span class="text-muted-foreground">Completed</span>
<span>{stats().completedTasks}/{stats().totalTasks}</span>
</div>
<div class="w-full bg-muted rounded-full h-3">
<div
class="bg-primary h-3 rounded-full transition-all duration-500"
style={`width: ${taskCompletionRate()}%`}
></div>
</div>
<p class="text-xs text-muted-foreground mt-1">{taskCompletionRate()}% completion rate</p>
</div>
<div class="grid grid-cols-2 gap-4 pt-2">
<div class="text-center">
<p class="text-xl font-semibold">{stats().completedTasks}</p>
<p class="text-xs text-muted-foreground">Completed</p>
</div>
<div class="text-center">
<p class="text-xl font-semibold">{stats().activeTasks}</p>
<p class="text-xs text-muted-foreground">Active</p>
</div>
</div>
</div>
</div>
<div class="border rounded-lg p-6">
<div class="flex items-center gap-2 mb-4">
<IconFolder class="size-5" />
<h3 class="text-lg font-semibold">Storage Usage</h3>
</div>
<div class="space-y-4">
<div>
<div class="flex justify-between text-sm mb-2">
<span class="text-muted-foreground">Used Space</span>
<span>{stats().storageUsed} / {stats().storageTotal}</span>
</div>
<div class="w-full bg-muted rounded-full h-3">
<div
class="bg-primary h-3 rounded-full transition-all duration-500"
style={`width: ${storagePercentage()}%`}
></div>
</div>
<p class="text-xs text-muted-foreground mt-1">{storagePercentage()}% of storage used</p>
</div>
<div class="grid grid-cols-2 gap-4 pt-2">
<div class="text-center">
<p class="text-xl font-semibold">{stats().totalDocuments}</p>
<p class="text-xs text-muted-foreground">Files</p>
</div>
<div class="text-center">
<p class="text-xl font-semibold">{stats().storageUsed}</p>
<p class="text-xs text-muted-foreground">Used</p>
</div>
</div>
</div>
</div>
</div>
{/* GitHub-like Contribution Graph */}
<GitHubActivity
title="Activity Overview"
showStats={false}
showContributionGraph={true}
showRecentActivity={false}
compact={true}
period="year"
/>
{/* Weekly Activity Chart */}
<div class="border rounded-lg 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="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">
{['M', 'T', 'W', 'T', 'F', 'S', 'S'].map((day, index) => {
const weeklyActivity = stats().weeklyActivity || [12, 19, 8, 15, 22, 18, 25]; // Fallback data
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);
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">
{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`}
></div>
</div>
<span class="text-xs text-muted-foreground font-medium mt-1">{day}</span>
</div>
);
})}
</div>
</div>
<div class="flex justify-between text-xs text-muted-foreground pt-2 border-t border-border">
<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>
{/* Top Categories */}
<div class="border rounded-lg p-6">
<div class="flex items-center gap-2 mb-4">
<IconUsers class="size-5 text-primary" />
<h3 class="text-lg font-semibold">Top Categories</h3>
</div>
<div class="space-y-3">
{stats().topCategories.map((category) => (
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div
class="w-3 h-3 rounded-full"
style={`background-color: ${category.color}`}
></div>
<span class="text-sm">{category.name}</span>
</div>
<div class="flex items-center gap-2">
<div class="w-24 bg-muted rounded-full h-2">
<div
class="bg-primary h-2 rounded-full transition-all duration-500"
style={`width: ${(category.count / Math.max(...stats().topCategories.map(c => c.count))) * 100}%`}
></div>
</div>
<span class="text-sm text-muted-foreground w-8 text-right">{category.count}</span>
</div>
</div>
))}
</div>
</div>
{/* Activity Section - Responsive Layout */}
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6">
{/* Main Activity Feed */}
<div>
<div class="border rounded-lg p-6">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
<IconActivity class="size-5 text-primary" />
<h3 class="text-lg font-semibold">Recent Activity</h3>
</div>
<Button
variant="outline"
size="sm"
onClick={handleRefresh}
>
<IconRefresh class="size-4 mr-2" />
Refresh
</Button>
</div>
<div class="space-y-3">
<ActivityFeed
refreshKey={refreshKey()}
limit={8}
showFilter={showFilters()}
/>
</div>
</div>
</div>
{/* Activity Sidebar - Responsive */}
<div class="space-y-6">
{/* Activity Breakdown */}
<div class="border rounded-lg p-4 sm:p-6">
<h3 class="text-lg font-semibold mb-4">Activity Breakdown</h3>
<div class="space-y-3">
{stats().recentActivity.map((activity) => (
<div class="flex justify-between items-center">
<span class="text-sm text-muted-foreground">{activity.type}</span>
<div class="flex items-center gap-2">
<span class="text-sm font-medium">{activity.count}</span>
<Show when={activity.change !== 0}>
<span class={`text-xs text-muted-foreground`}>
{activity.change > 0 ? '+' : ''}{activity.change}
</span>
</Show>
</div>
</div>
))}
<div class="border-t pt-3 mt-3">
<div class="flex justify-between items-center">
<span class="text-sm text-muted-foreground">Commits</span>
<span class="text-sm font-medium">89</span>
</div>
<div class="flex justify-between items-center">
<span class="text-sm text-muted-foreground">Pull Requests</span>
<span class="text-sm font-medium">12</span>
</div>
<div class="flex justify-between items-center">
<span class="text-sm text-muted-foreground">Stars</span>
<span class="text-sm font-medium">45</span>
</div>
<div class="flex justify-between items-center">
<span class="text-sm text-muted-foreground">Forks</span>
<span class="text-sm font-medium">12</span>
</div>
</div>
</div>
</div>
{/* Active Repositories */}
<div class="border rounded-lg p-4 sm:p-6">
<h3 class="text-lg font-semibold mb-4">Active Repositories</h3>
<div class="space-y-3">
{[
{ name: 'trackeep', language: 'TypeScript', activity: '2h ago' },
{ name: 'solid-components', language: 'TypeScript', activity: '5h ago' },
{ name: 'go-api', language: 'Go', activity: '1d ago' },
{ name: 'ml-models', language: 'Python', activity: '2d ago' }
].map((repo) => (
<div class="flex items-center justify-between p-3 bg-muted rounded-lg">
<div>
<p class="text-sm font-medium">{repo.name}</p>
<p class="text-xs text-muted-foreground">{repo.language}</p>
</div>
<span class="text-xs text-muted-foreground">{repo.activity}</span>
</div>
))}
</div>
</div>
{/* Activity Settings */}
<div class="border rounded-lg p-4 sm:p-6">
<h3 class="text-lg font-semibold mb-4">Activity Settings</h3>
<div class="space-y-3">
<Button
variant="outline"
size="sm"
class="w-full justify-start"
onClick={() => setShowFilters(!showFilters())}
>
<IconSettings class="size-4 mr-2" />
Configure Filters
</Button>
<Button
variant="outline"
size="sm"
class="w-full justify-start"
onClick={() => {
// Export functionality
const data = {
stats: stats(),
exportDate: new Date().toISOString()
};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `trackeep-activity-${new Date().toISOString().split('T')[0]}.json`;
a.click();
URL.revokeObjectURL(url);
}}
>
<IconDownload class="size-4 mr-2" />
Export Activity Data
</Button>
</div>
</div>
</div>
</div>
</div>
);
};
+326 -243
View File
@@ -1,267 +1,350 @@
import { Card, CardContent } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import { ErrorBoundary } from '@/components/ui/ErrorBoundary'
import { SkeletonList } from '@/components/ui/LoadingState'
import { SearchFilters } from '@/components/ui/SearchFilters'
import {
IconPlus,
IconCheck,
IconX,
IconFlag,
IconRefresh,
IconAlertTriangle
} from '@tabler/icons-solidjs'
import { createSignal, For, Show, createMemo } from 'solid-js'
import { tasksApi, type Task } from '@/lib/api-client'
import { createSignal, onMount } from 'solid-js';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { TaskModal } from '@/components/ui/TaskModal';
import { IconEdit, IconTrash } from '@tabler/icons-solidjs';
import { getMockTasks } from '@/lib/mockData';
const statusColors = {
'pending': 'bg-yellow-600',
'in_progress': 'bg-blue-600',
'completed': 'bg-green-600'
interface Task {
id: number;
title: string;
description?: string;
completed: boolean;
priority: 'low' | 'medium' | 'high';
createdAt: string;
dueDate?: string;
}
const priorityColors = {
'low': 'text-gray-400',
'medium': 'text-yellow-400',
'high': 'text-red-400'
}
export const Tasks = () => {
const [tasks, setTasks] = createSignal<Task[]>([]);
const [isLoading, setIsLoading] = createSignal(true);
const [showAddModal, setShowAddModal] = createSignal(false);
const [showEditModal, setShowEditModal] = createSignal(false);
const [editingTask, setEditingTask] = createSignal<Task | null>(null);
const [filter, setFilter] = createSignal<'all' | 'active' | 'completed'>('all');
const [searchTerm, setSearchTerm] = createSignal('');
export function Tasks() {
const [searchQuery, setSearchQuery] = createSignal('')
const [filters, setFilters] = createSignal<Record<string, any>>({})
const tasksQuery = tasksApi.useGetAll()
const deleteTaskMutation = tasksApi.useDelete()
const updateTaskMutation = tasksApi.useUpdate()
// Get unique values for filter options
const filterOptions = createMemo(() => {
const tasks = tasksQuery.data || []
return {
statuses: ['pending', 'in_progress', 'completed'],
priorities: ['low', 'medium', 'high'],
dateRanges: ['Today', 'This Week', 'This Month', 'This Year'],
tags: Array.from(new Set(tasks.flatMap(task => task.tags)))
}
})
// Filter tasks based on search and filters
const filteredTasks = createMemo(() => {
const tasks = tasksQuery.data || []
const query = searchQuery().toLowerCase()
const currentFilters = filters()
return tasks.filter(task => {
// Search filter
if (query && !(
task.title.toLowerCase().includes(query) ||
task.description?.toLowerCase().includes(query) ||
task.tags.some(tag => tag.toLowerCase().includes(query))
)) {
return false
}
// Status filter
if (currentFilters.status && task.status !== currentFilters.status) {
return false
}
// Priority filter
if (currentFilters.priority && task.priority !== currentFilters.priority) {
return false
}
// Tag filter
if (currentFilters.tag && !task.tags.includes(currentFilters.tag)) {
return false
}
// Date range filter
if (currentFilters.dateRange) {
const taskDate = new Date(task.created_at)
const now = new Date()
switch (currentFilters.dateRange) {
case 'Today':
if (taskDate.toDateString() !== now.toDateString()) return false
break
case 'This Week':
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
if (taskDate < weekAgo) return false
break
case 'This Month':
if (taskDate.getMonth() !== now.getMonth() || taskDate.getFullYear() !== now.getFullYear()) return false
break
case 'This Year':
if (taskDate.getFullYear() !== now.getFullYear()) return false
break
}
}
return true
})
})
const handleStatusToggle = async (taskId: number, currentStatus: string) => {
const newStatus = currentStatus === 'completed' ? 'pending' : 'completed'
onMount(async () => {
try {
await updateTaskMutation.mutateAsync({
id: taskId,
data: { status: newStatus as Task['status'] }
})
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
const response = await fetch(`${API_BASE_URL}/tasks`, {
headers: {
'Authorization': localStorage.getItem('trackeep_token') ? `Bearer ${localStorage.getItem('trackeep_token')}` : '',
},
});
if (!response.ok) {
throw new Error('Failed to load tasks');
}
const data = await response.json();
setTasks(data);
} catch (error) {
console.error('Error updating task:', error)
console.error('Failed to load tasks:', error);
// Fallback to mock data if API fails
const mockTasks = getMockTasks();
const adaptedTasks = mockTasks.map((task, index) => ({
id: index + 1,
title: task.title,
description: task.description,
completed: task.status === 'completed',
priority: task.priority,
createdAt: task.createdAt,
dueDate: task.dueDate
}));
setTasks(adaptedTasks);
} finally {
setIsLoading(false);
}
}
});
const handleDeleteTask = async (taskId: number) => {
if (!confirm('Are you sure you want to delete this task?')) return
const filteredTasks = () => {
const term = searchTerm().toLowerCase();
const filtered = tasks().filter(task => {
const matchesSearch = !term ||
task.title.toLowerCase().includes(term) ||
(task.description && task.description.toLowerCase().includes(term));
const matchesFilter =
(filter() === 'active' && !task.completed) ||
(filter() === 'completed' && task.completed) ||
filter() === 'all';
return matchesSearch && matchesFilter;
});
return filtered.sort((a, b) => {
const priorityOrder = { high: 0, medium: 1, low: 2 };
if (a.completed !== b.completed) return a.completed ? 1 : -1;
return priorityOrder[a.priority] - priorityOrder[b.priority];
});
};
const handleAddTask = async (task: Omit<Task, 'id'>) => {
try {
await deleteTaskMutation.mutateAsync(taskId)
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
const response = await fetch(`${API_BASE_URL}/tasks`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': localStorage.getItem('trackeep_token') ? `Bearer ${localStorage.getItem('trackeep_token')}` : '',
},
body: JSON.stringify(task),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to create task');
}
const newTask = await response.json();
setTasks(prev => [newTask, ...prev]);
setShowAddModal(false);
} catch (error) {
console.error('Error deleting task:', error)
alert(error instanceof Error ? error.message : 'Failed to add task');
}
}
};
const handleEditTask = async (task: Omit<Task, 'id'>) => {
if (!editingTask()) return;
try {
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
const response = await fetch(`${API_BASE_URL}/tasks/${editingTask()!.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': localStorage.getItem('trackeep_token') ? `Bearer ${localStorage.getItem('trackeep_token')}` : '',
},
body: JSON.stringify(task),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to update task');
}
const updatedTask = await response.json();
setTasks(prev =>
prev.map(task =>
task.id === updatedTask.id ? updatedTask : task
)
);
setShowEditModal(false);
setEditingTask(null);
} catch (error) {
alert(error instanceof Error ? error.message : 'Failed to update task');
}
};
const toggleTaskComplete = async (taskId: number) => {
try {
// TODO: Replace with actual API call
setTasks(prev => prev.map(task =>
task.id === taskId ? { ...task, completed: !task.completed } : task
));
} catch (error) {
console.error('Failed to update task:', error);
}
};
const deleteTask = async (taskId: number) => {
if (confirm('Are you sure you want to delete this task?')) {
try {
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
const response = await fetch(`${API_BASE_URL}/tasks/${taskId}`, {
method: 'DELETE',
headers: {
'Authorization': localStorage.getItem('trackeep_token') ? `Bearer ${localStorage.getItem('trackeep_token')}` : '',
},
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to delete task');
}
setTasks(prev => prev.filter(task => task.id !== taskId));
} catch (error) {
alert(error instanceof Error ? error.message : 'Failed to delete task');
}
}
};
const editTask = (task: Task) => {
setEditingTask(task);
setShowEditModal(true);
};
const getPriorityColor = (priority: string) => {
switch (priority) {
case 'high': return 'text-destructive bg-destructive/10';
case 'medium': return 'text-yellow-400 bg-yellow-400/10';
case 'low': return 'text-muted-foreground bg-muted/10';
default: return 'text-gray-400 bg-gray-400/10';
}
};
const taskStats = () => {
const total = tasks().length;
const completed = tasks().filter(t => t.completed).length;
const active = total - completed;
return { total, completed, active };
};
return (
<ErrorBoundary>
<div class="space-y-6">
{/* Page Header */}
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-white">Tasks</h1>
<p class="text-gray-400 mt-2">Manage your to-do lists and track progress</p>
</div>
<Button>
<IconPlus class="mr-2 h-4 w-4" />
Add Task
</Button>
<div class="p-6 space-y-6">
<div class="flex justify-between items-center">
<h1 class="text-3xl font-bold text-[#fafafa]">Tasks</h1>
<Button onClick={() => setShowAddModal(true)}>
Add Task
</Button>
</div>
<TaskModal
isOpen={showAddModal()}
onClose={() => setShowAddModal(false)}
onSubmit={handleAddTask}
/>
<TaskModal
isOpen={showEditModal()}
onClose={() => {
setShowEditModal(false);
setEditingTask(null);
}}
onSubmit={handleEditTask}
task={editingTask()}
isEdit={true}
/>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<Card class="p-4 text-center">
<p class="text-2xl font-bold text-[#fafafa]">{taskStats().total}</p>
<p class="text-[#a3a3a3] text-sm">Total Tasks</p>
</Card>
<Card class="p-4 text-center">
<p class="text-2xl font-bold text-[#fafafa]">{taskStats().active}</p>
<p class="text-[#a3a3a3] text-sm">Active</p>
</Card>
<Card class="p-4 text-center">
<p class="text-2xl font-bold text-blue-400">{taskStats().completed}</p>
<p class="text-[#a3a3a3] text-sm">Completed</p>
</Card>
</div>
<div class="flex flex-col sm:flex-row gap-4 mb-6">
<div class="flex-1">
<Input
type="text"
placeholder="Search tasks..."
value={searchTerm()}
onInput={(e) => {
const target = e.currentTarget as HTMLInputElement;
if (target) setSearchTerm(target.value);
}}
class="w-full"
/>
</div>
{/* Search and Filters */}
<SearchFilters
onSearchChange={setSearchQuery}
onFiltersChange={setFilters}
placeholder="Search tasks..."
filterOptions={filterOptions()}
/>
{/* Error Display */}
<Show when={tasksQuery.error}>
<div class="bg-red-900 border border-red-700 text-red-200 px-4 py-3 rounded-lg flex items-center justify-between">
<div class="flex items-center">
<IconAlertTriangle class="mr-2 h-5 w-5" />
<span>Failed to load tasks: {tasksQuery.error?.message}</span>
</div>
<div class="flex space-x-2">
{(['all', 'active', 'completed'] as const).map((filterOption) => (
<Button
variant="ghost"
size="sm"
onClick={() => tasksQuery.refetch()}
class="text-red-400 hover:text-red-300"
variant={filter() === filterOption ? 'default' : 'outline'}
onClick={() => setFilter(filterOption)}
class="capitalize"
>
<IconRefresh class="mr-2 h-4 w-4" />
Retry
{filterOption}
</Button>
</div>
</Show>
))}
</div>
</div>
{/* Loading State */}
<Show when={tasksQuery.isLoading}>
<SkeletonList count={5} />
</Show>
{/* Tasks List */}
<Show when={!tasksQuery.isLoading && !tasksQuery.error}>
<div class="space-y-4">
<For each={filteredTasks()}>
{(task) => (
<Card class="hover:shadow-lg transition-shadow">
<CardContent class="p-6">
<div class="flex items-start justify-between">
<div class="flex items-start space-x-4 flex-1">
{/* Status Checkbox */}
<div class="flex items-center justify-center mt-1">
<button
onClick={() => handleStatusToggle(task.id, task.status)}
class={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
task.status === 'completed'
? 'bg-green-600 border-green-600'
: 'border-gray-600'
}`}
>
{task.status === 'completed' && (
<IconCheck class="h-3 w-3 text-white" />
)}
</button>
</div>
{/* Task Content */}
<div class="flex-1 min-w-0">
<div class="flex items-center space-x-3 mb-2">
<h3 class={`text-lg font-semibold ${
task.status === 'completed' ? 'text-gray-400 line-through' : 'text-white'
}`}>
{task.title}
</h3>
<span class={`inline-flex items-center px-2 py-1 rounded-full text-xs ${statusColors[task.status]} text-white`}>
{task.status.replace('_', ' ')}
</span>
<IconFlag class={`h-4 w-4 ${priorityColors[task.priority]}`} />
</div>
{task.description && (
<p class="text-gray-300 mb-3">
{task.description}
</p>
)}
<div class="flex items-center space-x-4 text-sm text-gray-400">
<span>Created {new Date(task.created_at).toLocaleDateString()}</span>
</div>
</div>
</div>
{/* Actions */}
<div class="flex space-x-2 ml-4">
<Button variant="ghost" size="sm" class="text-gray-400 hover:text-white">
Edit
</Button>
<Button
variant="ghost"
size="sm"
class="text-gray-400 hover:text-red-400"
onClick={() => handleDeleteTask(task.id)}
{isLoading() ? (
<div class="space-y-4">
{[...Array(3)].map(() => (
<Card class="p-6">
<div class="animate-pulse">
<div class="h-6 bg-[#262626] rounded mb-2"></div>
<div class="h-4 bg-[#262626] rounded w-3/4"></div>
</div>
</Card>
))}
</div>
) : (
<div class="space-y-4">
{filteredTasks().map((task) => (
<div
class={`cursor-pointer transition-all ${task.completed ? 'opacity-60' : ''}`}
onClick={() => toggleTaskComplete(task.id)}
>
<Card class={`p-6 hover:bg-[#141415]`}>
<div class="flex items-start space-x-3">
<input
type="checkbox"
checked={task.completed}
onChange={(e) => {
e.stopPropagation();
toggleTaskComplete(task.id);
}}
class="mt-1 w-4 h-4 text-[#39b9ff] bg-[#141415] border-[#262626] rounded focus:ring-[#39b9ff]"
/>
<div class="flex-1">
<div class="flex items-center justify-between">
<h3 class={`text-lg font-semibold text-[#fafafa] ${task.completed ? 'line-through' : ''}`}>
{task.title}
</h3>
<div class="flex items-center space-x-2">
<span class={`px-2 py-1 text-xs rounded-md ${getPriorityColor(task.priority)}`}>
{task.priority}
</span>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
editTask(task);
}}
class="text-blue-400 hover:text-blue-300"
>
<IconX class="h-4 w-4" />
<IconEdit class="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
deleteTask(task.id);
}}
class="text-red-400 hover:text-red-300"
>
<IconTrash class="w-4 h-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
)}
</For>
</div>
{/* Empty State */}
<Show when={filteredTasks().length === 0}>
<div class="text-center py-12">
<IconFlag class="mx-auto h-12 w-12 text-gray-400 mb-4" />
<h3 class="text-lg font-medium text-white mb-2">No tasks found</h3>
<p class="text-gray-400 mb-4">
{searchQuery() || Object.keys(filters()).length > 0
? 'Try adjusting your search and filters'
: 'Create your first task to get started'
}
</p>
<Button>
<IconPlus class="mr-2 h-4 w-4" />
Add Task
</Button>
{task.description && (
<p class="text-[#a3a3a3] text-sm mt-1">{task.description}</p>
)}
{task.dueDate && (
<p class="text-[#a3a3a3] text-xs mt-2">
Due: {new Date(task.dueDate).toLocaleDateString()}
</p>
)}
</div>
</div>
</Card>
</div>
</Show>
</Show>
</div>
</ErrorBoundary>
)
}
))}
{filteredTasks().length === 0 && (
<Card class="p-12 text-center">
<p class="text-[#a3a3a3]">
{filter() === 'completed' ? 'No completed tasks yet.' :
filter() === 'active' ? 'No active tasks. Great job!' :
'No tasks yet. Add your first task!'}
</p>
</Card>
)}
</div>
)}
</div>
);
};
+194
View File
@@ -0,0 +1,194 @@
import { createSignal, createEffect, onMount } from 'solid-js';
import { Timer } from '@/components/Timer';
import { TimeEntriesList } from '@/components/TimeEntriesList';
import { type TimeEntry, timeEntriesApi, demoTimeEntriesApi } from '@/lib/api';
import { IconClock, IconActivity, IconCurrencyDollar } from '@tabler/icons-solidjs';
import { isDemoMode } from '@/lib/demo-mode';
export const TimeTracking = () => {
const [refreshTrigger, setRefreshTrigger] = createSignal(0);
const [timeEntries, setTimeEntries] = createSignal<TimeEntry[]>([]);
const [loading, setLoading] = createSignal(true);
const [currentRunningEntry, setCurrentRunningEntry] = createSignal<TimeEntry | null>(null);
const [currentElapsedSeconds, setCurrentElapsedSeconds] = createSignal(0);
// Use appropriate API based on demo mode
const getApi = () => isDemoMode() ? demoTimeEntriesApi : timeEntriesApi;
const loadTimeEntries = async () => {
try {
setLoading(true);
const response = await getApi().getAll();
// Handle different response formats
let entries: TimeEntry[] = [];
if (response && response.time_entries) {
entries = response.time_entries;
} else if (response && Array.isArray(response)) {
entries = response;
} else {
console.warn('Unexpected response format:', response);
entries = [];
}
setTimeEntries(entries);
} catch (err) {
console.error('Failed to load time entries:', err);
setTimeEntries([]); // Ensure empty array on error
} finally {
setLoading(false);
}
};
// Calculate today's statistics including real-time running timer
const getTodayStats = () => {
const entries = timeEntries() || [];
const today = new Date().toDateString();
const todayEntries = entries.filter(entry =>
new Date(entry.start_time).toDateString() === today
);
// Start with completed entries
let totalSeconds = todayEntries.reduce((sum, entry) =>
sum + (entry.duration || 0), 0
);
let billableSeconds = todayEntries.reduce((sum, entry) =>
sum + (entry.duration || 0), 0
);
let totalBillableAmount = todayEntries.reduce((sum, entry) => {
if (entry.duration && entry.hourly_rate && entry.billable) {
return sum + (entry.duration / 3600 * entry.hourly_rate);
}
return sum;
}, 0);
// Add real-time data from currently running timer
const runningEntry = currentRunningEntry();
if (runningEntry && new Date(runningEntry.start_time).toDateString() === today) {
const elapsed = currentElapsedSeconds();
totalSeconds += elapsed;
if (runningEntry.billable) {
billableSeconds += elapsed;
if (runningEntry.hourly_rate) {
totalBillableAmount += (elapsed / 3600 * runningEntry.hourly_rate);
}
}
}
const runningCount = todayEntries.filter(entry => entry.is_running).length +
(runningEntry ? 1 : 0);
return {
totalSeconds,
totalEntries: todayEntries.length + (runningEntry ? 1 : 0),
billableSeconds,
totalBillableAmount,
runningCount
};
};
const formatTime = (seconds: number): string => {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return `${hours}h ${minutes}m`;
};
const formatAmount = (amount: number): string => {
return `$${amount.toFixed(2)}`;
};
const handleTimeEntryCreated = (_timeEntry: TimeEntry) => {
// Trigger refresh of the time entries list
setRefreshTrigger(prev => prev + 1);
};
// Handle real-time timer updates
const handleTimerUpdate = (entry: TimeEntry | null, elapsedSeconds: number) => {
setCurrentRunningEntry(entry);
setCurrentElapsedSeconds(elapsedSeconds);
};
// Load time entries on mount and when refresh trigger changes
onMount(() => {
loadTimeEntries();
});
createEffect(() => {
if (refreshTrigger() > 0) {
loadTimeEntries();
}
});
return (
<div class="p-6 mt-4 pb-32 max-w-5xl mx-auto space-y-6">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Timer Component */}
<div>
<Timer
onTimeEntryCreated={handleTimeEntryCreated}
onTimerUpdate={handleTimerUpdate}
/>
</div>
{/* Time Stats - Standardized Design */}
<div class="border rounded-lg p-4">
<h2 class="text-lg font-semibold mb-4">Today's Overview</h2>
<div class="grid grid-cols-2 gap-4">
<div class="flex items-center gap-3">
<div class="bg-muted flex items-center justify-center p-2 rounded-lg">
<IconClock class="size-5 text-primary" />
</div>
<div>
<p class="text-2xl font-light">{formatTime(getTodayStats().totalSeconds)}</p>
<p class="text-sm text-muted-foreground">Total Time Today</p>
</div>
</div>
<div class="flex items-center gap-3">
<div class="bg-muted flex items-center justify-center p-2 rounded-lg">
<IconActivity class="size-5 text-primary" />
</div>
<div>
<p class="text-2xl font-light">{getTodayStats().totalEntries}</p>
<p class="text-sm text-muted-foreground">Entries Today</p>
</div>
</div>
<div class="flex items-center gap-3">
<div class="bg-muted flex items-center justify-center p-2 rounded-lg">
<IconCurrencyDollar class="size-5 text-primary" />
</div>
<div>
<p class="text-2xl font-light">{formatAmount(getTodayStats().totalBillableAmount)}</p>
<p class="text-sm text-muted-foreground">
Billable Today
{currentRunningEntry() && currentRunningEntry()?.billable && (
<span class="ml-1 text-green-600 dark:text-green-400">
Live
</span>
)}
</p>
</div>
</div>
<div class="flex items-center gap-3">
<div class="bg-muted flex items-center justify-center p-2 rounded-lg">
<IconActivity class="size-5 text-primary" />
</div>
<div>
<p class="text-2xl font-light">{getTodayStats().runningCount}</p>
<p class="text-sm text-muted-foreground">Running Timers</p>
</div>
</div>
</div>
</div>
</div>
{/* Time Entries List */}
<div>
<TimeEntriesList refreshTrigger={refreshTrigger()} />
</div>
</div>
);
};
File diff suppressed because it is too large Load Diff