mirror of
https://github.com/Dvorinka/Trackeep.git
synced 2026-06-04 20:42:59 +00:00
first test
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
};
|
||||
+1064
-116
File diff suppressed because it is too large
Load Diff
+477
-222
@@ -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>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
+1820
-156
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user