mirror of
https://github.com/Dvorinka/Trackeep.git
synced 2026-10-28 16:01:15 +00:00
refactor: unify docker deployment and restructure frontend architecture
This commit implements a unified Docker deployment strategy, moving from separate frontend and backend images to a single, multi-stage build image containing both services. It also introduces a major reorganization of the frontend directory structure and simplifies the environment configuration. Key changes: - **Deployment**: Added a multi-stage `Dockerfile` and `docker-entrypoint.sh` to package the Go backend and Nginx-served frontend into a single container. - **CI/CD**: Updated GitHub Actions workflows (`ci-cd.yml`, `release.yml`) to build and push the new unified image instead of separate ones. - **Frontend Refactor**: Reorganized `frontend/src/pages` into a domain-driven directory structure (e.g., `auth/`, `admin/`, `content/`, `communication/`, `productivity/`, `settings/`, `misc/`). - **Configuration**: Simplified `.env.example` and updated `docker-compose.yml` to reflect the unified service model and single host port. - **Cleanup**: Removed deprecated `docker-compose.demo.yml`, `docker-compose.prod.yml`, and various unused frontend components and services. - **Backend**: Refactored configuration loading to use exported `GetDurationEnv` for better consistency.
This commit is contained in:
1 parent
c6a99c7e21
commit
6c448b336a
71 files changed
+135367
-4481
No files matched your search
+4
-4
@@ -55,9 +55,9 @@ http {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# API proxy to backend with retry logic
|
||||
# API proxy to backend (internal localhost)
|
||||
location /api/ {
|
||||
proxy_pass http://trackeep-backend:8080/;
|
||||
proxy_pass http://localhost:8080/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
@@ -66,7 +66,7 @@ http {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
|
||||
|
||||
# Retry configuration
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_send_timeout 10s;
|
||||
@@ -74,7 +74,7 @@ http {
|
||||
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503 http_504;
|
||||
proxy_next_upstream_tries 3;
|
||||
proxy_next_upstream_timeout 30s;
|
||||
|
||||
|
||||
# Buffer settings
|
||||
proxy_buffering on;
|
||||
proxy_buffer_size 4k;
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
+26
-26
@@ -3,33 +3,33 @@ import { QueryClient, QueryClientProvider } from '@tanstack/solid-query'
|
||||
import { Layout } from '@/components/layout/Layout'
|
||||
import { ProtectedRoute } from '@/components/ProtectedRoute'
|
||||
import { ToastContainer } from '@/components/ui/Toast'
|
||||
import { Dashboard } from '@/pages/Dashboard'
|
||||
import { Bookmarks } from '@/pages/Bookmarks'
|
||||
import { Tasks } from '@/pages/Tasks'
|
||||
import { Files } from '@/pages/Files'
|
||||
import { Notes } from '@/pages/Notes'
|
||||
import Chat from '@/pages/Chat'
|
||||
import { Settings } from '@/pages/Settings'
|
||||
import { Login } from '@/pages/Login'
|
||||
import { Youtube } from '@/pages/Youtube'
|
||||
import { Members } from '@/pages/Members'
|
||||
import { RemovedStuff } from '@/pages/RemovedStuff'
|
||||
import { AdminSettings } from '@/pages/AdminSettings'
|
||||
import { ColorSwitcher } from '@/pages/ColorSwitcher'
|
||||
import { AdminDashboard } from '@/pages/AdminDashboard'
|
||||
import { Stats } from '@/pages/Stats'
|
||||
import { Profile } from '@/pages/Profile'
|
||||
import { LearningPaths } from '@/pages/LearningPaths'
|
||||
import { GitHub } from '@/pages/GitHub'
|
||||
import { TimeTracking } from '@/pages/TimeTracking'
|
||||
import { Calendar } from '@/pages/Calendar'
|
||||
import { AuthCallback } from '@/pages/AuthCallback'
|
||||
import { Dashboard } from '@/pages/misc/Dashboard'
|
||||
import { Bookmarks } from '@/pages/content/Bookmarks'
|
||||
import { Tasks } from '@/pages/productivity/Tasks'
|
||||
import { Files } from '@/pages/content/Files'
|
||||
import { Notes } from '@/pages/content/Notes'
|
||||
import Chat from '@/pages/communication/Chat'
|
||||
import { Settings } from '@/pages/settings/Settings'
|
||||
import { Login } from '@/pages/auth/Login'
|
||||
import { Youtube } from '@/pages/content/Youtube'
|
||||
import { Members } from '@/pages/admin/Members'
|
||||
import { RemovedStuff } from '@/pages/misc/RemovedStuff'
|
||||
import { AdminSettings } from '@/pages/admin/AdminSettings'
|
||||
import { ColorSwitcher } from '@/pages/settings/ColorSwitcher'
|
||||
import { AdminDashboard } from '@/pages/admin/AdminDashboard'
|
||||
import { Stats } from '@/pages/productivity/Stats'
|
||||
import { Profile } from '@/pages/auth/Profile'
|
||||
import { LearningPaths } from '@/pages/content/LearningPaths'
|
||||
import { GitHub } from '@/pages/content/GitHub'
|
||||
import { TimeTracking } from '@/pages/productivity/TimeTracking'
|
||||
import { Calendar } from '@/pages/productivity/Calendar'
|
||||
import { AuthCallback } from '@/pages/auth/AuthCallback'
|
||||
import { AuthProvider, useAuth } from '@/lib/auth'
|
||||
import { Search } from '@/pages/Search'
|
||||
import { Analytics } from '@/pages/Analytics'
|
||||
import { Messages } from '@/pages/Messages'
|
||||
import { ShareTarget } from '@/pages/ShareTarget'
|
||||
import BrowserExtensionSettings from '@/pages/BrowserExtensionSettings'
|
||||
import { Search } from '@/pages/content/Search'
|
||||
import { Analytics } from '@/pages/admin/Analytics'
|
||||
import { Messages } from '@/pages/communication/Messages'
|
||||
import { ShareTarget } from '@/pages/misc/ShareTarget'
|
||||
import BrowserExtensionSettings from '@/pages/settings/BrowserExtensionSettings'
|
||||
import { initializeDemoMode, clearDemoMode, isEnvDemoMode } from '@/lib/demo-mode'
|
||||
import { onMount, createEffect } from 'solid-js'
|
||||
import { useNavigate } from '@solidjs/router'
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
import { createSignal, Show } from 'solid-js'
|
||||
import { IconX, IconSend, IconUser, IconChevronDown } from '@tabler/icons-solidjs'
|
||||
import longcatIcon from '@/assets/longcat-color.svg'
|
||||
import { ModalPortal } from '@/components/ui/ModalPortal'
|
||||
|
||||
interface FloatingAIProps {
|
||||
onToggleChat: () => void
|
||||
isChatOpen: boolean
|
||||
}
|
||||
|
||||
interface Message {
|
||||
id: string
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
timestamp: Date
|
||||
}
|
||||
|
||||
export function FloatingAI(props: FloatingAIProps) {
|
||||
const [messages, setMessages] = createSignal<Message[]>([
|
||||
{
|
||||
id: '1',
|
||||
role: 'assistant',
|
||||
content: 'Hello! I\'m your AI assistant. How can I help you today?',
|
||||
timestamp: new Date()
|
||||
}
|
||||
])
|
||||
const [inputValue, setInputValue] = createSignal('')
|
||||
const [selectedModel, setSelectedModel] = createSignal('longcat-flash-chat')
|
||||
const [showModelSelector, setShowModelSelector] = createSignal(false)
|
||||
|
||||
const aiModels = [
|
||||
{ id: 'longcat-flash-chat', name: 'LongCat Flash', description: 'Fast and efficient' },
|
||||
{ id: 'gpt-4', name: 'GPT-4', description: 'Most capable' },
|
||||
{ id: 'claude-3', name: 'Claude 3', description: 'Balanced performance' }
|
||||
]
|
||||
|
||||
const handleSendMessage = () => {
|
||||
const value = inputValue().trim()
|
||||
if (!value) return
|
||||
|
||||
const userMessage: Message = {
|
||||
id: Date.now().toString(),
|
||||
role: 'user',
|
||||
content: value,
|
||||
timestamp: new Date()
|
||||
}
|
||||
|
||||
setMessages(prev => [...prev, userMessage])
|
||||
setInputValue('')
|
||||
|
||||
// Simulate AI response
|
||||
setTimeout(() => {
|
||||
const aiMessage: Message = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
role: 'assistant',
|
||||
content: 'I understand your question. Let me help you with that...',
|
||||
timestamp: new Date()
|
||||
}
|
||||
setMessages(prev => [...prev, aiMessage])
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
const handleKeyPress = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSendMessage()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Floating AI Button */}
|
||||
<button
|
||||
onClick={props.onToggleChat}
|
||||
class="fixed bottom-6 right-8 z-40 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg hover:bg-primary/90 transition-all duration-200 hover:scale-110 w-14 h-14"
|
||||
title="AI Assistant"
|
||||
>
|
||||
<img src={longcatIcon} alt="AI Assistant" class="size-6" />
|
||||
</button>
|
||||
|
||||
{/* AI Chat Modal */}
|
||||
<Show when={props.isChatOpen}>
|
||||
<ModalPortal>
|
||||
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div class="bg-card border border-border rounded-lg shadow-xl max-w-md w-full max-h-[600px] flex flex-col" style="width: 420px;">
|
||||
{/* Header */}
|
||||
<div class="flex items-center justify-between p-4 border-b border-border bg-gradient-to-r from-primary/10 to-primary/5">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center p-3 rounded-lg bg-primary/20">
|
||||
<img src={longcatIcon} alt="AI Assistant" class="size-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-foreground">AI Assistant</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-xs text-muted-foreground">Always here to help</p>
|
||||
<div class="relative">
|
||||
<button
|
||||
onClick={() => setShowModelSelector(!showModelSelector())}
|
||||
class="flex items-center gap-1 text-xs text-primary hover:text-primary/80 transition-colors"
|
||||
>
|
||||
{aiModels.find(m => m.id === selectedModel())?.name || 'LongCat Flash'}
|
||||
<IconChevronDown class="size-3" />
|
||||
</button>
|
||||
|
||||
{/* Model Selector Dropdown */}
|
||||
<Show when={showModelSelector()}>
|
||||
<div class="absolute bottom-full left-0 mb-2 w-48 bg-popover border border-border rounded-md shadow-lg z-10">
|
||||
{aiModels.map((model) => (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedModel(model.id)
|
||||
setShowModelSelector(false)
|
||||
}}
|
||||
class="w-full text-left px-3 py-2 text-sm hover:bg-accent transition-colors first:rounded-t-md last:rounded-b-md"
|
||||
>
|
||||
<div class="font-medium">{model.name}</div>
|
||||
<div class="text-xs text-muted-foreground">{model.description}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={props.onToggleChat}
|
||||
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"
|
||||
>
|
||||
<IconX class="size-4 text-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div class="flex-1 overflow-y-auto p-4 space-y-4 bg-gradient-to-b from-background to-muted/20" style="max-height: 400px;">
|
||||
{messages().map((message) => (
|
||||
<div class={`flex gap-3 ${message.role === 'user' ? 'justify-end' : 'justify-start'} animate-in slide-in-from-bottom-2 duration-200`}>
|
||||
{message.role === 'assistant' && (
|
||||
<div class="flex items-center justify-center p-2 rounded-lg bg-primary/10 flex-shrink-0">
|
||||
<img src={longcatIcon} alt="AI Assistant" class="size-4" />
|
||||
</div>
|
||||
)}
|
||||
<div class={`max-w-[300px] rounded-lg p-3 shadow-sm ${
|
||||
message.role === 'user'
|
||||
? 'bg-primary text-primary-foreground ml-auto'
|
||||
: 'bg-muted border border-border'
|
||||
}`}>
|
||||
<p class="text-sm leading-relaxed">{message.content}</p>
|
||||
<p class="text-xs opacity-70 mt-2">
|
||||
{message.timestamp.toLocaleTimeString()}
|
||||
</p>
|
||||
</div>
|
||||
{message.role === 'user' && (
|
||||
<div class="flex items-center justify-center p-2 rounded-lg bg-primary flex-shrink-0">
|
||||
<IconUser class="size-4 text-primary-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div class="p-4 border-t border-border bg-muted/30">
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={inputValue()}
|
||||
onInput={(e) => setInputValue(e.currentTarget.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="Type your message..."
|
||||
class="flex-1 h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSendMessage}
|
||||
disabled={!inputValue().trim()}
|
||||
class="inline-flex items-center justify-center rounded-md text-sm font-medium transition-all duration-200 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 hover:bg-primary/90 hover:shadow-md h-10 px-4"
|
||||
>
|
||||
<IconSend class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalPortal>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -43,8 +43,6 @@ export const BrowserSearch = () => {
|
||||
try {
|
||||
const isDemoMode = isDemo();
|
||||
|
||||
console.log(`[BrowserSearch] Demo mode: ${isDemoMode}`);
|
||||
|
||||
// Always use backend API for search to avoid CORS issues
|
||||
const API_BASE_URL = getApiBaseUrl();
|
||||
const token = localStorage.getItem('token') ||
|
||||
|
||||
@@ -1,248 +0,0 @@
|
||||
import { createSignal, Show } from 'solid-js'
|
||||
import { Button } from './Button'
|
||||
import { IconDownload, IconUpload, IconFileText, IconAlertTriangle, IconCheck } from '@tabler/icons-solidjs'
|
||||
import { exportData as exportDataUtil, importData as importDataUtil, validateImportData, getImportSummary, type ExportData } from '@/lib/export-import'
|
||||
|
||||
export interface ExportImportProps {
|
||||
data?: {
|
||||
bookmarks?: any[]
|
||||
tasks?: any[]
|
||||
notes?: any[]
|
||||
files?: any[]
|
||||
}
|
||||
onImport?: (data: ExportData) => Promise<void>
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export const ExportImport = (props: ExportImportProps) => {
|
||||
const [isImporting, setIsImporting] = createSignal(false)
|
||||
const [importStatus, setImportStatus] = createSignal<'idle' | 'validating' | 'success' | 'error'>('idle')
|
||||
const [importMessage, setImportMessage] = createSignal('')
|
||||
const [importData, setImportData] = createSignal<ExportData | null>(null)
|
||||
|
||||
const handleExport = async (type?: 'bookmarks' | 'tasks' | 'notes' | 'files' | 'all') => {
|
||||
try {
|
||||
let exportDataPayload = {}
|
||||
let filename = ''
|
||||
|
||||
if (type === 'all' || !type) {
|
||||
exportDataPayload = props.data || {}
|
||||
filename = `trackeep-full-export-${new Date().toISOString().split('T')[0]}.json`
|
||||
} else {
|
||||
exportDataPayload = { [type]: props.data?.[type] || [] }
|
||||
filename = `trackeep-${type}-export-${new Date().toISOString().split('T')[0]}.json`
|
||||
}
|
||||
|
||||
await exportDataUtil(exportDataPayload, filename)
|
||||
} catch (error) {
|
||||
console.error('Export failed:', error)
|
||||
alert('Export failed. Please try again.')
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileSelect = async (event: Event) => {
|
||||
const file = (event.target as HTMLInputElement).files?.[0]
|
||||
if (!file) return
|
||||
|
||||
setIsImporting(true)
|
||||
setImportStatus('validating')
|
||||
setImportMessage('Reading and validating file...')
|
||||
|
||||
try {
|
||||
const data = await importDataUtil(file)
|
||||
const validation = validateImportData(data)
|
||||
|
||||
if (!validation.isValid) {
|
||||
setImportStatus('error')
|
||||
setImportMessage(`Validation failed: ${validation.errors.join(', ')}`)
|
||||
return
|
||||
}
|
||||
|
||||
setImportData(data)
|
||||
setImportStatus('success')
|
||||
setImportMessage(getImportSummary(data))
|
||||
} catch (error) {
|
||||
setImportStatus('error')
|
||||
setImportMessage((error as Error).message)
|
||||
} finally {
|
||||
setIsImporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleImport = async () => {
|
||||
const data = importData()
|
||||
if (!data || !props.onImport) return
|
||||
|
||||
try {
|
||||
await props.onImport(data)
|
||||
setImportStatus('idle')
|
||||
setImportMessage('Import completed successfully!')
|
||||
setImportData(null)
|
||||
|
||||
// Reset file input
|
||||
const fileInput = document.getElementById('import-file-input') as HTMLInputElement
|
||||
if (fileInput) fileInput.value = ''
|
||||
} catch (error) {
|
||||
setImportStatus('error')
|
||||
setImportMessage(`Import failed: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const resetImport = () => {
|
||||
setImportStatus('idle')
|
||||
setImportMessage('')
|
||||
setImportData(null)
|
||||
|
||||
// Reset file input
|
||||
const fileInput = document.getElementById('import-file-input') as HTMLInputElement
|
||||
if (fileInput) fileInput.value = ''
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="space-y-6">
|
||||
{/* Export Section */}
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-white mb-4 flex items-center">
|
||||
<IconDownload class="mr-2 h-5 w-5" />
|
||||
Export Data
|
||||
</h3>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleExport('all')}
|
||||
disabled={props.disabled}
|
||||
class="text-gray-300 border-gray-600 hover:text-white hover:border-gray-500"
|
||||
>
|
||||
<IconFileText class="mr-2 h-4 w-4" />
|
||||
Export All
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleExport('bookmarks')}
|
||||
disabled={props.disabled}
|
||||
class="text-gray-300 border-gray-600 hover:text-white hover:border-gray-500"
|
||||
>
|
||||
Bookmarks
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleExport('tasks')}
|
||||
disabled={props.disabled}
|
||||
class="text-gray-300 border-gray-600 hover:text-white hover:border-gray-500"
|
||||
>
|
||||
Tasks
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleExport('notes')}
|
||||
disabled={props.disabled}
|
||||
class="text-gray-300 border-gray-600 hover:text-white hover:border-gray-500"
|
||||
>
|
||||
Notes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Import Section */}
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-white mb-4 flex items-center">
|
||||
<IconUpload class="mr-2 h-5 w-5" />
|
||||
Import Data
|
||||
</h3>
|
||||
|
||||
{/* File Input */}
|
||||
<div class="mb-4">
|
||||
<input
|
||||
id="import-file-input"
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={handleFileSelect}
|
||||
disabled={props.disabled || isImporting()}
|
||||
class="block w-full text-sm text-gray-300 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-600 file:text-white hover:file:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Import Status */}
|
||||
<Show when={importStatus() !== 'idle'}>
|
||||
<div class={`p-4 rounded-lg mb-4 ${
|
||||
importStatus() === 'success'
|
||||
? 'bg-green-900/20 border border-green-700/50 text-green-300'
|
||||
: importStatus() === 'error'
|
||||
? 'bg-red-900/20 border border-red-700/50 text-red-300'
|
||||
: 'bg-blue-900/20 border border-blue-700/50 text-blue-300'
|
||||
}`}>
|
||||
<div class="flex items-start">
|
||||
<Show
|
||||
when={importStatus() === 'success'}
|
||||
fallback={<IconAlertTriangle class="mr-2 h-5 w-5 flex-shrink-0 mt-0.5" />}
|
||||
>
|
||||
<IconCheck class="mr-2 h-5 w-5 flex-shrink-0 mt-0.5" />
|
||||
</Show>
|
||||
<div class="flex-1">
|
||||
<p class="font-medium">
|
||||
{importStatus() === 'validating' ? 'Validating...' :
|
||||
importStatus() === 'success' ? 'File Valid' :
|
||||
'Import Error'}
|
||||
</p>
|
||||
<p class="text-sm mt-1">{importMessage()}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Import Actions */}
|
||||
<Show when={importStatus() === 'success' && props.onImport}>
|
||||
<div class="flex space-x-3">
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
disabled={isImporting()}
|
||||
class="bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
{isImporting() ? 'Importing...' : 'Import Data'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={resetImport}
|
||||
disabled={isImporting()}
|
||||
class="text-gray-300 border-gray-600 hover:text-white hover:border-gray-500"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Import Preview */}
|
||||
<Show when={importData() && importStatus() === 'success'}>
|
||||
<div class="mt-4 p-4 bg-gray-800 border border-gray-700 rounded-lg">
|
||||
<h4 class="text-sm font-medium text-white mb-2">Import Preview</h4>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<span class="text-gray-400">Bookmarks:</span>
|
||||
<span class="ml-2 text-white">{importData()!.bookmarks.length}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-400">Tasks:</span>
|
||||
<span class="ml-2 text-white">{importData()!.tasks.length}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-400">Notes:</span>
|
||||
<span class="ml-2 text-white">{importData()!.notes.length}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-400">Files:</span>
|
||||
<span class="ml-2 text-white">{importData()!.files.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 text-xs text-gray-400">
|
||||
Export date: {new Date(importData()!.exportDate).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,392 +0,0 @@
|
||||
import { createSignal, For, Show, onMount, onCleanup } from 'solid-js';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { ModalPortal } from '@/components/ui/ModalPortal';
|
||||
import {
|
||||
IconX,
|
||||
IconUpload,
|
||||
IconLink,
|
||||
IconTag,
|
||||
IconFileText,
|
||||
IconPhoto,
|
||||
IconVideo,
|
||||
IconMusic,
|
||||
IconFolder
|
||||
} from '@tabler/icons-solidjs';
|
||||
|
||||
interface FileUploadModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onUpload: (fileData: any) => void;
|
||||
}
|
||||
|
||||
interface Association {
|
||||
id: string;
|
||||
type: 'task' | 'bookmark' | 'note' | 'project';
|
||||
title: string;
|
||||
}
|
||||
|
||||
export const FileUploadModal = (props: FileUploadModalProps) => {
|
||||
const [selectedFile, setSelectedFile] = createSignal<File | null>(null);
|
||||
const [description, setDescription] = createSignal('');
|
||||
const [tags, setTags] = createSignal<string[]>([]);
|
||||
const [tagInput, setTagInput] = createSignal('');
|
||||
const [associations, setAssociations] = createSignal<Association[]>([]);
|
||||
const [linkUrl, setLinkUrl] = createSignal('');
|
||||
const [isLinkMode, setIsLinkMode] = createSignal(false);
|
||||
const [dragActive, setDragActive] = createSignal(false);
|
||||
|
||||
onMount(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && props.isOpen) {
|
||||
props.onClose();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
onCleanup(() => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
});
|
||||
});
|
||||
|
||||
const handleFileSelect = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
if (target.files && target.files.length > 0) {
|
||||
setSelectedFile(target.files[0]);
|
||||
setIsLinkMode(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrag = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.type === "dragenter" || e.type === "dragover") {
|
||||
setDragActive(true);
|
||||
} else if (e.type === "dragleave") {
|
||||
setDragActive(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragActive(false);
|
||||
|
||||
if (e.dataTransfer?.files && e.dataTransfer.files[0]) {
|
||||
setSelectedFile(e.dataTransfer.files[0]);
|
||||
setIsLinkMode(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addTag = () => {
|
||||
const tag = tagInput().trim();
|
||||
if (tag && !tags().includes(tag)) {
|
||||
setTags([...tags(), tag]);
|
||||
setTagInput('');
|
||||
}
|
||||
};
|
||||
|
||||
const removeTag = (tagToRemove: string) => {
|
||||
setTags(tags().filter(tag => tag !== tagToRemove));
|
||||
};
|
||||
|
||||
const addAssociation = (type: Association['type']) => {
|
||||
// Mock association - in real app, this would open a picker
|
||||
const mockAssociation: Association = {
|
||||
id: Date.now().toString(),
|
||||
type,
|
||||
title: `Sample ${type} ${Date.now()}`
|
||||
};
|
||||
setAssociations([...associations(), mockAssociation]);
|
||||
};
|
||||
|
||||
const removeAssociation = (id: string) => {
|
||||
setAssociations(associations().filter(assoc => assoc.id !== id));
|
||||
};
|
||||
|
||||
const handleUpload = () => {
|
||||
const fileData = {
|
||||
file: selectedFile(),
|
||||
linkUrl: linkUrl(),
|
||||
description: description(),
|
||||
tags: tags(),
|
||||
associations: associations(),
|
||||
isLinkMode: isLinkMode()
|
||||
};
|
||||
|
||||
props.onUpload(fileData);
|
||||
props.onClose();
|
||||
|
||||
// Reset form
|
||||
setSelectedFile(null);
|
||||
setDescription('');
|
||||
setTags([]);
|
||||
setTagInput('');
|
||||
setAssociations([]);
|
||||
setLinkUrl('');
|
||||
setIsLinkMode(false);
|
||||
};
|
||||
|
||||
const getFileIcon = (file?: File) => {
|
||||
if (!file) return IconFolder;
|
||||
|
||||
if (file.type.startsWith('image/')) return IconPhoto;
|
||||
if (file.type.startsWith('video/')) return IconVideo;
|
||||
if (file.type.startsWith('audio/')) return IconMusic;
|
||||
return IconFileText;
|
||||
};
|
||||
|
||||
const isValidUrl = (url: string) => {
|
||||
try {
|
||||
new URL(url);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const canUpload = () => {
|
||||
if (isLinkMode()) {
|
||||
return linkUrl() && isValidUrl(linkUrl());
|
||||
}
|
||||
return selectedFile() !== null;
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalPortal>
|
||||
<Show when={props.isOpen}>
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||
onClick={props.onClose}
|
||||
>
|
||||
<div
|
||||
class="bg-card rounded-lg border border-border p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto mx-4 my-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-semibold">Upload File</h2>
|
||||
<Button variant="ghost" onClick={props.onClose}>
|
||||
<IconX class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Upload Mode Toggle */}
|
||||
<div class="flex gap-2 mb-6">
|
||||
<Button
|
||||
variant={!isLinkMode() ? "default" : "outline"}
|
||||
onClick={() => setIsLinkMode(false)}
|
||||
class="flex-1"
|
||||
>
|
||||
<IconUpload class="size-4 mr-2" />
|
||||
Upload File
|
||||
</Button>
|
||||
<Button
|
||||
variant={isLinkMode() ? "default" : "outline"}
|
||||
onClick={() => setIsLinkMode(true)}
|
||||
class="flex-1"
|
||||
>
|
||||
<IconLink class="size-4 mr-2" />
|
||||
Add Link
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* File Upload Area */}
|
||||
<Show when={!isLinkMode()}>
|
||||
<Card class="p-8 mb-6">
|
||||
<div
|
||||
class={`border-2 border-dashed rounded-lg p-8 text-center transition-colors ${
|
||||
dragActive()
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'border-border hover:border-primary/50'
|
||||
}`}
|
||||
onDragEnter={handleDrag}
|
||||
onDragLeave={handleDrag}
|
||||
onDragOver={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
onChange={handleFileSelect}
|
||||
class="hidden"
|
||||
id="file-input"
|
||||
/>
|
||||
|
||||
<Show
|
||||
when={selectedFile()}
|
||||
fallback={
|
||||
<div>
|
||||
<IconUpload class="size-12 mx-auto mb-4 text-muted-foreground" />
|
||||
<p class="text-lg font-medium mb-2">Drop file here or click to browse</p>
|
||||
<p class="text-sm text-muted-foreground mb-4">
|
||||
Supports all file types
|
||||
</p>
|
||||
<Button onClick={() => document.getElementById('file-input')?.click()}>
|
||||
Choose File
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="flex items-center gap-4 justify-center">
|
||||
<div class="text-4xl text-primary">
|
||||
{(() => {
|
||||
const IconComponent = getFileIcon(selectedFile()!);
|
||||
return <IconComponent size={48} />;
|
||||
})()}
|
||||
</div>
|
||||
<div class="text-left">
|
||||
<p class="font-medium">{selectedFile()!.name}</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{(selectedFile()!.size / 1024 / 1024).toFixed(2)} MB
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => document.getElementById('file-input')?.click()}
|
||||
class="mt-2"
|
||||
>
|
||||
Change File
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Card>
|
||||
</Show>
|
||||
|
||||
{/* Link Input */}
|
||||
<Show when={isLinkMode()}>
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium mb-2">File URL</label>
|
||||
<Input
|
||||
type="url"
|
||||
placeholder="https://example.com/file.pdf"
|
||||
value={linkUrl()}
|
||||
onInput={(e: any) => setLinkUrl(e.currentTarget.value)}
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Description */}
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium mb-2">Description</label>
|
||||
<textarea
|
||||
class="w-full px-3 py-2 border border-border rounded-lg bg-background resize-none"
|
||||
rows={3}
|
||||
placeholder="Optional description..."
|
||||
value={description()}
|
||||
onInput={(e: any) => setDescription(e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium mb-2">Tags</label>
|
||||
<div class="flex gap-2 mb-3">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Add tag..."
|
||||
value={tagInput()}
|
||||
onInput={(e: any) => setTagInput(e.currentTarget.value)}
|
||||
onKeyDown={(e: any) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addTag();
|
||||
}
|
||||
}}
|
||||
class="flex-1"
|
||||
/>
|
||||
<Button onClick={addTag} disabled={!tagInput().trim()}>
|
||||
<IconTag class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<For each={tags()}>
|
||||
{(tag) => (
|
||||
<span class="inline-flex items-center gap-1 px-2 py-1 bg-primary/10 text-primary rounded-md text-sm">
|
||||
{tag}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeTag(tag)}
|
||||
class="h-4 w-4 p-0 hover:bg-primary/20"
|
||||
>
|
||||
<IconX class="size-3" />
|
||||
</Button>
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Associations */}
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium mb-2">Link to</label>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-2 mb-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => addAssociation('task')}
|
||||
>
|
||||
Task
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => addAssociation('bookmark')}
|
||||
>
|
||||
Bookmark
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => addAssociation('note')}
|
||||
>
|
||||
Note
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => addAssociation('project')}
|
||||
>
|
||||
Project
|
||||
</Button>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<For each={associations()}>
|
||||
{(assoc) => (
|
||||
<div class="flex items-center justify-between p-2 bg-muted rounded-md">
|
||||
<span class="text-sm">
|
||||
<span class="font-medium capitalize">{assoc.type}:</span> {assoc.title}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeAssociation(assoc.id)}
|
||||
class="h-6 w-6 p-0"
|
||||
>
|
||||
<IconX class="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div class="flex gap-3 pt-4 border-t border-border">
|
||||
<Button variant="outline" onClick={props.onClose} class="flex-1">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleUpload} disabled={!canUpload()} class="flex-1">
|
||||
Upload
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</ModalPortal>
|
||||
);
|
||||
};
|
||||
@@ -1,273 +0,0 @@
|
||||
import { createSignal } from 'solid-js';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { ModalPortal } from '@/components/ui/ModalPortal';
|
||||
import { IconX } from '@tabler/icons-solidjs';
|
||||
|
||||
interface LearningPathFormData {
|
||||
title: string;
|
||||
description: string;
|
||||
category: string;
|
||||
difficulty: 'beginner' | 'intermediate' | 'advanced';
|
||||
duration: string;
|
||||
thumbnail?: string;
|
||||
is_featured?: boolean;
|
||||
}
|
||||
|
||||
interface LearningPathModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (learningPath: LearningPathFormData) => Promise<void>;
|
||||
learningPath?: LearningPathFormData | null;
|
||||
isEdit?: boolean;
|
||||
}
|
||||
|
||||
export const LearningPathModal = (props: LearningPathModalProps) => {
|
||||
const [learningPathData, setLearningPathData] = createSignal<LearningPathFormData>({
|
||||
title: '',
|
||||
description: '',
|
||||
category: '',
|
||||
difficulty: 'beginner',
|
||||
duration: '',
|
||||
thumbnail: '',
|
||||
is_featured: false
|
||||
});
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = createSignal(false);
|
||||
|
||||
// Reset form when modal opens/closes or learningPath changes
|
||||
const resetForm = () => {
|
||||
if (props.learningPath && props.isEdit) {
|
||||
setLearningPathData({
|
||||
title: props.learningPath.title,
|
||||
description: props.learningPath.description,
|
||||
category: props.learningPath.category,
|
||||
difficulty: props.learningPath.difficulty,
|
||||
duration: props.learningPath.duration,
|
||||
thumbnail: props.learningPath.thumbnail || '',
|
||||
is_featured: props.learningPath.is_featured || false
|
||||
});
|
||||
} else {
|
||||
setLearningPathData({
|
||||
title: '',
|
||||
description: '',
|
||||
category: '',
|
||||
difficulty: 'beginner',
|
||||
duration: '',
|
||||
thumbnail: '',
|
||||
is_featured: false
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Reset form when modal opens/closes
|
||||
if (props.isOpen) {
|
||||
resetForm();
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!learningPathData().title.trim() || !learningPathData().description.trim()) {
|
||||
// Display inline error instead of alert
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await props.onSubmit(learningPathData());
|
||||
props.onClose();
|
||||
resetForm();
|
||||
} catch (error) {
|
||||
console.error('Failed to save learning path:', error);
|
||||
// Let the parent handle the error display
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (field: keyof LearningPathFormData) => {
|
||||
return (e: Event) => {
|
||||
const target = e.currentTarget as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement;
|
||||
if (target) {
|
||||
setLearningPathData(prev => ({
|
||||
...prev,
|
||||
[field]: target.type === 'checkbox' ? (target as HTMLInputElement).checked : target.value
|
||||
}));
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
if (!props.isOpen) return null;
|
||||
|
||||
return (
|
||||
<ModalPortal>
|
||||
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div class="bg-[#1a1a1a] rounded-lg w-full max-w-2xl max-h-[90vh] overflow-y-auto mx-4 my-4">
|
||||
{/* Header */}
|
||||
<div class="flex items-center justify-between p-6 border-b border-[#404040]">
|
||||
<h2 class="text-xl font-semibold text-[#fafafa]">
|
||||
{props.isEdit ? 'Edit Learning Path' : 'Create New Learning Path'}
|
||||
</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={props.onClose}
|
||||
class="text-[#a3a3a3] hover:text-[#fafafa]"
|
||||
>
|
||||
<IconX class="size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} class="p-6 space-y-6">
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[#fafafa] mb-2">
|
||||
Title *
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={learningPathData().title}
|
||||
onInput={handleInputChange('title')}
|
||||
placeholder="Enter learning path title"
|
||||
required
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[#fafafa] mb-2">
|
||||
Description *
|
||||
</label>
|
||||
<textarea
|
||||
value={learningPathData().description}
|
||||
onInput={handleInputChange('description')}
|
||||
placeholder="Describe what students will learn in this path"
|
||||
rows={4}
|
||||
required
|
||||
class="w-full px-3 py-2 bg-[#262626] text-[#fafafa] border border-[#404040] rounded-lg focus:outline-none focus:border-blue-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category and Difficulty */}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[#fafafa] mb-2">
|
||||
Category *
|
||||
</label>
|
||||
<select
|
||||
value={learningPathData().category}
|
||||
onChange={handleInputChange('category')}
|
||||
required
|
||||
class="w-full px-3 py-2 bg-[#262626] text-[#fafafa] border border-[#404040] rounded-lg focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
<option value="">Select a category</option>
|
||||
<option value="programming">Programming</option>
|
||||
<option value="web-development">Web Development</option>
|
||||
<option value="mobile-development">Mobile Development</option>
|
||||
<option value="data-science">Data Science</option>
|
||||
<option value="machine-learning">Machine Learning</option>
|
||||
<option value="cybersecurity">Cybersecurity</option>
|
||||
<option value="design">Design</option>
|
||||
<option value="business">Business</option>
|
||||
<option value="marketing">Marketing</option>
|
||||
<option value="photography">Photography</option>
|
||||
<option value="music">Music</option>
|
||||
<option value="writing">Writing</option>
|
||||
<option value="languages">Languages</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[#fafafa] mb-2">
|
||||
Difficulty *
|
||||
</label>
|
||||
<select
|
||||
value={learningPathData().difficulty}
|
||||
onChange={handleInputChange('difficulty')}
|
||||
required
|
||||
class="w-full px-3 py-2 bg-[#262626] text-[#fafafa] border border-[#404040] rounded-lg focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
<option value="beginner">Beginner</option>
|
||||
<option value="intermediate">Intermediate</option>
|
||||
<option value="advanced">Advanced</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Duration */}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[#fafafa] mb-2">
|
||||
Duration *
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={learningPathData().duration}
|
||||
onInput={handleInputChange('duration')}
|
||||
placeholder="e.g., 8 weeks, 3 months"
|
||||
required
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Thumbnail */}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[#fafafa] mb-2">
|
||||
Thumbnail URL (optional)
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={learningPathData().thumbnail}
|
||||
onInput={handleInputChange('thumbnail')}
|
||||
placeholder="https://example.com/image.jpg"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Featured */}
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="featured"
|
||||
checked={learningPathData().is_featured}
|
||||
onChange={handleInputChange('is_featured')}
|
||||
class="w-4 h-4 text-blue-600 bg-[#262626] border-[#404040] rounded focus:ring-blue-500"
|
||||
/>
|
||||
<label for="featured" class="text-sm font-medium text-[#fafafa]">
|
||||
Featured Learning Path
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div class="flex justify-end gap-3 pt-4 border-t border-[#404040]">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={props.onClose}
|
||||
disabled={isSubmitting()}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={(e) => handleSubmit(e)}
|
||||
disabled={isSubmitting()}
|
||||
class="min-w-[100px]"
|
||||
>
|
||||
{isSubmitting() ? (
|
||||
<span class="flex items-center gap-2">
|
||||
<span class="w-4 h-4 border-2 border-primary-foreground/30 border-t-primary-foreground rounded-full animate-spin"></span>
|
||||
{props.isEdit ? 'Updating...' : 'Creating...'}
|
||||
</span>
|
||||
) : (
|
||||
props.isEdit ? 'Update Learning Path' : 'Create Learning Path'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</ModalPortal>
|
||||
);
|
||||
};
|
||||
@@ -1,222 +0,0 @@
|
||||
import { createSignal, For, Show } from 'solid-js'
|
||||
import { Button } from './Button'
|
||||
import { Input } from './Input'
|
||||
import { IconSearch, IconFilter, IconX, IconCalendar, IconTag, IconFlag } from '@tabler/icons-solidjs'
|
||||
|
||||
export interface SearchFiltersProps {
|
||||
onSearchChange: (query: string) => void
|
||||
onFiltersChange: (filters: Record<string, any>) => void
|
||||
placeholder?: string
|
||||
showFilters?: boolean
|
||||
filterOptions?: {
|
||||
tags?: string[]
|
||||
statuses?: string[]
|
||||
priorities?: string[]
|
||||
dateRanges?: string[]
|
||||
}
|
||||
}
|
||||
|
||||
export const SearchFilters = (props: SearchFiltersProps) => {
|
||||
const [searchQuery, setSearchQuery] = createSignal('')
|
||||
const [showAdvancedFilters, setShowAdvancedFilters] = createSignal(props.showFilters || false)
|
||||
const [activeFilters, setActiveFilters] = createSignal<Record<string, any>>({})
|
||||
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearchQuery(value)
|
||||
props.onSearchChange(value)
|
||||
}
|
||||
|
||||
const handleFilterChange = (filterKey: string, value: any) => {
|
||||
const newFilters = { ...activeFilters(), [filterKey]: value }
|
||||
if (!value || (Array.isArray(value) && value.length === 0)) {
|
||||
delete newFilters[filterKey]
|
||||
}
|
||||
setActiveFilters(newFilters)
|
||||
props.onFiltersChange(newFilters)
|
||||
}
|
||||
|
||||
const clearAllFilters = () => {
|
||||
setActiveFilters({})
|
||||
setSearchQuery('')
|
||||
props.onSearchChange('')
|
||||
props.onFiltersChange({})
|
||||
}
|
||||
|
||||
const activeFilterCount = () => {
|
||||
const filters = activeFilters()
|
||||
return Object.keys(filters).length + (searchQuery() ? 1 : 0)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="space-y-4">
|
||||
{/* Search Bar */}
|
||||
<div class="relative">
|
||||
<IconSearch class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={props.placeholder || "Search..."}
|
||||
value={searchQuery()}
|
||||
onInput={(e) => e.target && handleSearchChange((e.target as HTMLInputElement).value)}
|
||||
class="pl-10 bg-gray-800 border-gray-700 text-white placeholder-gray-400"
|
||||
/>
|
||||
|
||||
{/* Filter Toggle */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowAdvancedFilters(!showAdvancedFilters())}
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-white"
|
||||
>
|
||||
<IconFilter class="h-4 w-4" />
|
||||
<Show when={activeFilterCount() > 0}>
|
||||
<span class="ml-1 text-xs bg-blue-600 text-white rounded-full px-2 py-0.5">
|
||||
{activeFilterCount()}
|
||||
</span>
|
||||
</Show>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Advanced Filters */}
|
||||
<Show when={showAdvancedFilters()}>
|
||||
<div class="bg-gray-800 border border-gray-700 rounded-lg p-4 space-y-4">
|
||||
{/* Filter Header */}
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-medium text-white">Advanced Filters</h3>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearAllFilters}
|
||||
class="text-gray-400 hover:text-white"
|
||||
>
|
||||
<IconX class="mr-1 h-3 w-3" />
|
||||
Clear All
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* Tags Filter */}
|
||||
<Show when={props.filterOptions?.tags && props.filterOptions.tags.length > 0}>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">
|
||||
<IconTag class="inline h-4 w-4 mr-1" />
|
||||
Tags
|
||||
</label>
|
||||
<select
|
||||
class="w-full bg-gray-700 border border-gray-600 text-white rounded-md px-3 py-2 text-sm"
|
||||
onChange={(e) => handleFilterChange('tag', (e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
<option value="">All Tags</option>
|
||||
<For each={props.filterOptions!.tags}>
|
||||
{(tag) => (
|
||||
<option value={tag} selected={activeFilters().tag === tag}>
|
||||
{tag}
|
||||
</option>
|
||||
)}
|
||||
</For>
|
||||
</select>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Status Filter */}
|
||||
<Show when={props.filterOptions?.statuses && props.filterOptions.statuses.length > 0}>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Status</label>
|
||||
<select
|
||||
class="w-full bg-gray-700 border border-gray-600 text-white rounded-md px-3 py-2 text-sm"
|
||||
onChange={(e) => handleFilterChange('status', (e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<For each={props.filterOptions!.statuses}>
|
||||
{(status) => (
|
||||
<option value={status} selected={activeFilters().status === status}>
|
||||
{status.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase())}
|
||||
</option>
|
||||
)}
|
||||
</For>
|
||||
</select>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Priority Filter */}
|
||||
<Show when={props.filterOptions?.priorities && props.filterOptions.priorities.length > 0}>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">
|
||||
<IconFlag class="inline h-4 w-4 mr-1" />
|
||||
Priority
|
||||
</label>
|
||||
<select
|
||||
class="w-full bg-gray-700 border border-gray-600 text-white rounded-md px-3 py-2 text-sm"
|
||||
onChange={(e) => handleFilterChange('priority', (e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
<option value="">All Priorities</option>
|
||||
<For each={props.filterOptions!.priorities}>
|
||||
{(priority) => (
|
||||
<option value={priority} selected={activeFilters().priority === priority}>
|
||||
{priority.charAt(0).toUpperCase() + priority.slice(1)}
|
||||
</option>
|
||||
)}
|
||||
</For>
|
||||
</select>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Date Range Filter */}
|
||||
<Show when={props.filterOptions?.dateRanges && props.filterOptions.dateRanges.length > 0}>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">
|
||||
<IconCalendar class="inline h-4 w-4 mr-1" />
|
||||
Date Range
|
||||
</label>
|
||||
<select
|
||||
class="w-full bg-gray-700 border border-gray-600 text-white rounded-md px-3 py-2 text-sm"
|
||||
onChange={(e) => handleFilterChange('dateRange', (e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
<option value="">Any Time</option>
|
||||
<For each={props.filterOptions!.dateRanges}>
|
||||
{(range) => (
|
||||
<option value={range} selected={activeFilters().dateRange === range}>
|
||||
{range}
|
||||
</option>
|
||||
)}
|
||||
</For>
|
||||
</select>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Active Filters Display */}
|
||||
<Show when={activeFilterCount() > 0}>
|
||||
<div class="flex flex-wrap gap-2 pt-2 border-t border-gray-700">
|
||||
<For each={Object.entries(activeFilters())}>
|
||||
{([key, value]) => (
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs bg-blue-600 text-white">
|
||||
{key}: {value}
|
||||
<button
|
||||
onClick={() => handleFilterChange(key, null)}
|
||||
class="ml-1 hover:text-blue-200"
|
||||
>
|
||||
<IconX class="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
<Show when={searchQuery()}>
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs bg-blue-600 text-white">
|
||||
Search: {searchQuery()}
|
||||
<button
|
||||
onClick={() => handleSearchChange('')}
|
||||
class="ml-1 hover:text-blue-200"
|
||||
>
|
||||
<IconX class="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
import { createSignal, Show, createMemo } from 'solid-js';
|
||||
import {
|
||||
IconRefresh,
|
||||
IconCheck,
|
||||
IconAlertTriangle,
|
||||
IconDownload,
|
||||
IconLoader2
|
||||
} from '@tabler/icons-solidjs';
|
||||
import { updateStore } from '../../stores/updateStore';
|
||||
import { ModalPortal } from './ModalPortal';
|
||||
|
||||
interface UpdateCheckerProps {
|
||||
class?: string;
|
||||
}
|
||||
|
||||
export function UpdateChecker(props: UpdateCheckerProps) {
|
||||
const [showUpdateModal, setShowUpdateModal] = createSignal(false);
|
||||
|
||||
// Initialize update store
|
||||
updateStore.ensureInitialized().catch(console.error);
|
||||
|
||||
const installUpdate = () => {
|
||||
updateStore.installUpdate();
|
||||
};
|
||||
|
||||
const cancelUpdate = () => {
|
||||
updateStore.cancelUpdate();
|
||||
setShowUpdateModal(false);
|
||||
};
|
||||
|
||||
// Create reactive computed values
|
||||
const buttonClasses = createMemo(() => {
|
||||
const updateAvailable = updateStore.updateAvailable();
|
||||
const updateStatus = updateStore.updateStatus();
|
||||
const error = updateStore.error();
|
||||
|
||||
return {
|
||||
"bg-blue-500/20 text-blue-400": updateAvailable && !updateStatus.downloading && !updateStatus.installing,
|
||||
"hover:bg-blue-500/30": updateAvailable && !updateStatus.downloading && !updateStatus.installing,
|
||||
"bg-orange-500/20 text-orange-400": updateStatus.downloading || updateStatus.installing,
|
||||
"hover:bg-orange-500/30": updateStatus.downloading || updateStatus.installing,
|
||||
"bg-green-500/20 text-green-400": updateStatus.completed,
|
||||
"hover:bg-green-500/30": updateStatus.completed,
|
||||
"bg-red-500/20 text-red-400": !!error,
|
||||
"hover:bg-red-500/30": !!error,
|
||||
"hover:bg-[#262626] hover:text-white text-[#a3a3a3]": !updateAvailable && !updateStatus.downloading && !updateStatus.installing && !updateStatus.completed && !error
|
||||
};
|
||||
});
|
||||
|
||||
const isDisabled = createMemo(() => {
|
||||
const isChecking = updateStore.isChecking();
|
||||
const updateStatus = updateStore.updateStatus();
|
||||
return isChecking || updateStatus.downloading || updateStatus.installing;
|
||||
});
|
||||
|
||||
const getStatusIcon = () => {
|
||||
const isChecking = updateStore.isChecking();
|
||||
const updateStatus = updateStore.updateStatus();
|
||||
const updateAvailable = updateStore.updateAvailable();
|
||||
const error = updateStore.error();
|
||||
|
||||
if (isChecking) return <IconLoader2 class="size-4 animate-spin" />;
|
||||
if (updateStatus.downloading || updateStatus.installing) return <IconLoader2 class="size-4 animate-spin" />;
|
||||
if (updateStatus.completed) return <IconCheck class="size-4 text-green-500" />;
|
||||
if (updateAvailable) return <IconDownload class="size-4 text-blue-500" />;
|
||||
if (error) return <IconAlertTriangle class="size-4 text-red-500" />;
|
||||
return <IconRefresh class="size-4" />;
|
||||
};
|
||||
|
||||
const getStatusText = () => {
|
||||
const isChecking = updateStore.isChecking();
|
||||
const updateStatus = updateStore.updateStatus();
|
||||
const updateAvailable = updateStore.updateAvailable();
|
||||
const error = updateStore.error();
|
||||
|
||||
if (isChecking) return 'Checking...';
|
||||
if (updateStatus.downloading) return `Downloading... ${Math.round(updateStatus.progress)}%`;
|
||||
if (updateStatus.installing) return `Installing... ${Math.round(updateStatus.progress)}%`;
|
||||
if (updateStatus.completed) return 'Update Complete';
|
||||
if (updateAvailable) return 'Update Available';
|
||||
if (error) return 'Update Failed';
|
||||
return 'Check Updates';
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class={`flex flex-col gap-2 ${props.class || ''}`}>
|
||||
{/* Current Version Display */}
|
||||
<div class="text-xs text-muted-foreground px-2 text-center">
|
||||
Version {updateStore.currentVersion()}
|
||||
</div>
|
||||
|
||||
{/* Check Updates Button */}
|
||||
<button
|
||||
onClick={() => {
|
||||
const updateAvailable = updateStore.updateAvailable();
|
||||
if (updateAvailable) {
|
||||
setShowUpdateModal(true);
|
||||
} else {
|
||||
updateStore.checkForUpdates();
|
||||
}
|
||||
}}
|
||||
class="group inline-flex rounded-md text-sm font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 h-9 px-4 py-2 justify-start items-center gap-2 truncate relative overflow-hidden w-full"
|
||||
classList={buttonClasses()}
|
||||
disabled={isDisabled()}
|
||||
>
|
||||
<div class="relative z-10 flex items-center gap-2">
|
||||
{getStatusIcon()}
|
||||
<div class="transition-colors truncate">
|
||||
{getStatusText()}
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-200"></div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Update Modal */}
|
||||
<Show when={showUpdateModal() && updateStore.updateInfo()}>
|
||||
<ModalPortal>
|
||||
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div class="bg-card border border-border rounded-lg shadow-lg max-w-md w-full max-h-[80vh] overflow-auto">
|
||||
<div class="p-6">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<IconDownload class="size-6 text-blue-500" />
|
||||
<h2 class="text-lg font-semibold">Update Available</h2>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<span class="text-sm text-muted-foreground">Current Version</span>
|
||||
<span class="text-sm font-medium">{updateStore.currentVersion()}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm text-muted-foreground">Latest Version</span>
|
||||
<span class="text-sm font-medium text-blue-500">{updateStore.updateInfo()!.version}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-sm font-medium mb-2">Release Notes</h3>
|
||||
<div class="text-sm text-muted-foreground whitespace-pre-line bg-muted/30 rounded p-3">
|
||||
{updateStore.updateInfo()!.releaseNotes}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center text-sm">
|
||||
<span class="text-muted-foreground">Download Size</span>
|
||||
<span>{updateStore.updateInfo()!.size}</span>
|
||||
</div>
|
||||
|
||||
<Show when={(() => {
|
||||
const updateStatus = updateStore.updateStatus();
|
||||
return updateStatus.downloading || updateStatus.installing;
|
||||
})()}>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">
|
||||
{(() => {
|
||||
const updateStatus = updateStore.updateStatus();
|
||||
return updateStatus.downloading ? 'Downloading' : 'Installing';
|
||||
})()}
|
||||
</span>
|
||||
<span>{(() => Math.round(updateStore.updateStatus().progress))()}%</span>
|
||||
</div>
|
||||
<div class="w-full bg-muted rounded-full h-2">
|
||||
<div
|
||||
class="bg-blue-500 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${updateStore.updateStatus().progress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={updateStore.error()}>
|
||||
<div class="bg-red-500/10 border border-red-500/20 rounded p-3">
|
||||
<div class="flex items-center gap-2 text-red-500 text-sm">
|
||||
<IconAlertTriangle class="size-4" />
|
||||
<span>{updateStore.error()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={(() => updateStore.updateStatus().completed)}>
|
||||
<div class="bg-green-500/10 border border-green-500/20 rounded p-3">
|
||||
<div class="flex items-center gap-2 text-green-500 text-sm">
|
||||
<IconCheck class="size-4" />
|
||||
<span>Update completed successfully! Restarting...</span>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 mt-6">
|
||||
<Show when={(() => {
|
||||
const updateStatus = updateStore.updateStatus();
|
||||
return !updateStatus.downloading && !updateStatus.installing && !updateStatus.completed;
|
||||
})()}>
|
||||
<button
|
||||
onClick={() => setShowUpdateModal(false)}
|
||||
class="flex-1 px-4 py-2 text-sm border border-border rounded-md hover:bg-muted transition-colors"
|
||||
>
|
||||
Later
|
||||
</button>
|
||||
<button
|
||||
onClick={installUpdate}
|
||||
disabled={(() => {
|
||||
const updateStatus = updateStore.updateStatus();
|
||||
return updateStatus.downloading || updateStatus.installing;
|
||||
})()}
|
||||
class="flex-1 px-4 py-2 text-sm bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
<Show when={(() => {
|
||||
const updateStatus = updateStore.updateStatus();
|
||||
return updateStatus.downloading || updateStatus.installing;
|
||||
})()}>
|
||||
<IconLoader2 class="size-4 animate-spin" />
|
||||
</Show>
|
||||
{(() => {
|
||||
const updateStatus = updateStore.updateStatus();
|
||||
return updateStatus.downloading || updateStatus.installing ? 'Installing...' : 'Install Update';
|
||||
})()}
|
||||
</button>
|
||||
</Show>
|
||||
|
||||
<Show when={(() => {
|
||||
const updateStatus = updateStore.updateStatus();
|
||||
const error = updateStore.error();
|
||||
return updateStatus.downloading || updateStatus.installing || error;
|
||||
})()}>
|
||||
<button
|
||||
onClick={cancelUpdate}
|
||||
class="px-4 py-2 text-sm border border-border rounded-md hover:bg-muted transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</Show>
|
||||
|
||||
<Show when={(() => updateStore.updateStatus().completed)}>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
class="w-full px-4 py-2 text-sm bg-green-500 text-white rounded-md hover:bg-green-600 transition-colors"
|
||||
>
|
||||
Reload Application
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalPortal>
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
import { createSignal, For, Show } from 'solid-js'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface VirtualListProps<T> {
|
||||
items: T[]
|
||||
itemHeight: number
|
||||
containerHeight: number
|
||||
renderItem: (item: T, index: number) => any
|
||||
overscan?: number
|
||||
class?: string
|
||||
}
|
||||
|
||||
export function VirtualList<T>(props: VirtualListProps<T>) {
|
||||
const [scrollTop, setScrollTop] = createSignal(0)
|
||||
|
||||
const overscan = props.overscan || 5
|
||||
const itemHeight = props.itemHeight
|
||||
|
||||
const visibleRange = () => {
|
||||
const start = Math.floor(scrollTop() / itemHeight)
|
||||
const visibleCount = Math.ceil(props.containerHeight / itemHeight)
|
||||
const end = start + visibleCount
|
||||
|
||||
return {
|
||||
start: Math.max(0, start - overscan),
|
||||
end: Math.min(props.items.length, end + overscan)
|
||||
}
|
||||
}
|
||||
|
||||
const totalHeight = () => props.items.length * itemHeight
|
||||
|
||||
const offsetY = () => visibleRange().start * itemHeight
|
||||
|
||||
const visibleItems = () => {
|
||||
const { start, end } = visibleRange()
|
||||
return props.items.slice(start, end).map((item, index) => ({
|
||||
item,
|
||||
index: start + index
|
||||
}))
|
||||
}
|
||||
|
||||
const handleScroll = (e: Event) => {
|
||||
const target = e.target as HTMLElement
|
||||
setScrollTop(target.scrollTop)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
class={cn('overflow-auto', props.class)}
|
||||
style={{ height: `${props.containerHeight}px` }}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<div style={{ height: `${totalHeight()}px`, position: 'relative' }}>
|
||||
<div style={{ transform: `translateY(${offsetY()}px)` }}>
|
||||
<For each={visibleItems()}>
|
||||
{({ item, index }) => (
|
||||
<div style={{ height: `${itemHeight}px` }}>
|
||||
{props.renderItem(item, index)}
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface InfiniteScrollProps<T> {
|
||||
items: T[]
|
||||
loading: boolean
|
||||
hasMore: boolean
|
||||
onLoadMore: () => void
|
||||
renderItem: (item: T, index: number) => any
|
||||
loader?: any
|
||||
class?: string
|
||||
}
|
||||
|
||||
export function InfiniteScroll<T>(props: InfiniteScrollProps<T>) {
|
||||
const handleScroll = (e: Event) => {
|
||||
const target = e.target as HTMLElement
|
||||
const { scrollTop, scrollHeight, clientHeight } = target
|
||||
|
||||
// Load more when user is near the bottom
|
||||
if (
|
||||
!props.loading &&
|
||||
props.hasMore &&
|
||||
scrollHeight - scrollTop - clientHeight < 200
|
||||
) {
|
||||
props.onLoadMore()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
class={cn('overflow-auto', props.class)}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<For each={props.items}>
|
||||
{(item, index) => props.renderItem(item, index())}
|
||||
</For>
|
||||
|
||||
<Show when={props.loading}>
|
||||
{props.loader || (
|
||||
<div class="p-4 text-center">
|
||||
<div class="inline-block animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<Show when={!props.hasMore && props.items.length > 0}>
|
||||
<div class="p-4 text-center text-muted-foreground text-sm">
|
||||
No more items to load
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,373 +0,0 @@
|
||||
import { createQuery, useQueryClient, createMutation } from '@tanstack/solid-query';
|
||||
import { getAuthHeaders } from './auth';
|
||||
|
||||
// API base URL
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
|
||||
|
||||
// Retry configuration
|
||||
const DEFAULT_RETRY_CONFIG = {
|
||||
retry: 3,
|
||||
retryDelay: (attemptIndex: number) => Math.min(1000 * 2 ** attemptIndex, 30000),
|
||||
networkMode: 'online' as const,
|
||||
};
|
||||
|
||||
// Generic API client with retry logic
|
||||
const apiClient = {
|
||||
async get<T>(endpoint: string): Promise<T> {
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || `API Error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
async post<T>(endpoint: string, data?: any): Promise<T> {
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...getAuthHeaders(),
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || `API Error: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
async put<T>(endpoint: string, data?: any): Promise<T> {
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
...getAuthHeaders(),
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || `API Error: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
async delete<T>(endpoint: string): Promise<T> {
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || `API Error: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
};
|
||||
|
||||
// Types
|
||||
export interface Bookmark {
|
||||
id: number;
|
||||
user_id: number;
|
||||
title: string;
|
||||
url: string;
|
||||
description?: string;
|
||||
is_read: boolean;
|
||||
is_favorite: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export interface Task {
|
||||
id: number;
|
||||
user_id: number;
|
||||
title: string;
|
||||
description?: string;
|
||||
status: 'pending' | 'in_progress' | 'completed';
|
||||
priority: 'low' | 'medium' | 'high';
|
||||
progress: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export interface Note {
|
||||
id: number;
|
||||
user_id: number;
|
||||
title: string;
|
||||
content: string;
|
||||
content_type: string;
|
||||
is_pinned: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export interface FileItem {
|
||||
id: number;
|
||||
user_id: number;
|
||||
filename: string;
|
||||
original_name: string;
|
||||
file_size: number;
|
||||
mime_type: string;
|
||||
file_path: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// Bookmarks API
|
||||
export const bookmarksApi = {
|
||||
useGetAll: () => createQuery(() => ({
|
||||
queryKey: ['bookmarks'],
|
||||
queryFn: () => apiClient.get<Bookmark[]>('/bookmarks'),
|
||||
...DEFAULT_RETRY_CONFIG,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
})),
|
||||
|
||||
useGetById: (id: number) => createQuery(() => ({
|
||||
queryKey: ['bookmarks', id],
|
||||
queryFn: () => apiClient.get<Bookmark>(`/bookmarks/${id}`),
|
||||
...DEFAULT_RETRY_CONFIG,
|
||||
staleTime: 10 * 60 * 1000, // 10 minutes
|
||||
})),
|
||||
|
||||
useCreate: () => {
|
||||
const queryClient = useQueryClient();
|
||||
return createMutation(() => ({
|
||||
mutationFn: (data: Omit<Bookmark, 'id' | 'user_id' | 'created_at' | 'updated_at'>) =>
|
||||
apiClient.post<Bookmark>('/bookmarks', data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['bookmarks'] });
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to create bookmark:', error);
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
useUpdate: () => {
|
||||
const queryClient = useQueryClient();
|
||||
return createMutation(() => ({
|
||||
mutationFn: ({ id, data }: { id: number; data: Partial<Bookmark> }) =>
|
||||
apiClient.put<Bookmark>(`/bookmarks/${id}`, data),
|
||||
onSuccess: (_, { id }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['bookmarks'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['bookmarks', id] });
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to update bookmark:', error);
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
useDelete: () => {
|
||||
const queryClient = useQueryClient();
|
||||
return createMutation(() => ({
|
||||
mutationFn: (id: number) => apiClient.delete(`/bookmarks/${id}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['bookmarks'] });
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to delete bookmark:', error);
|
||||
},
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
// Tasks API
|
||||
export const tasksApi = {
|
||||
useGetAll: () => createQuery(() => ({
|
||||
queryKey: ['tasks'],
|
||||
queryFn: () => apiClient.get<Task[]>('/tasks'),
|
||||
...DEFAULT_RETRY_CONFIG,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
})),
|
||||
|
||||
useGetById: (id: number) => createQuery(() => ({
|
||||
queryKey: ['tasks', id],
|
||||
queryFn: () => apiClient.get<Task>(`/tasks/${id}`),
|
||||
...DEFAULT_RETRY_CONFIG,
|
||||
staleTime: 10 * 60 * 1000, // 10 minutes
|
||||
})),
|
||||
|
||||
useCreate: () => {
|
||||
const queryClient = useQueryClient();
|
||||
return createMutation(() => ({
|
||||
mutationFn: (data: Omit<Task, 'id' | 'user_id' | 'created_at' | 'updated_at'>) =>
|
||||
apiClient.post<Task>('/tasks', data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to create task:', error);
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
useUpdate: () => {
|
||||
const queryClient = useQueryClient();
|
||||
return createMutation(() => ({
|
||||
mutationFn: ({ id, data }: { id: number; data: Partial<Task> }) =>
|
||||
apiClient.put<Task>(`/tasks/${id}`, data),
|
||||
onSuccess: (_, { id }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks', id] });
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to update task:', error);
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
useDelete: () => {
|
||||
const queryClient = useQueryClient();
|
||||
return createMutation(() => ({
|
||||
mutationFn: (id: number) => apiClient.delete(`/tasks/${id}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to delete task:', error);
|
||||
},
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
// Notes API
|
||||
export const notesApi = {
|
||||
useGetAll: (search?: string, tag?: string) => createQuery(() => ({
|
||||
queryKey: ['notes', search, tag],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams();
|
||||
if (search) params.append('search', search);
|
||||
if (tag) params.append('tag', tag);
|
||||
const queryString = params.toString();
|
||||
return apiClient.get<Note[]>(`/notes${queryString ? `?${queryString}` : ''}`);
|
||||
},
|
||||
...DEFAULT_RETRY_CONFIG,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
})),
|
||||
|
||||
useGetById: (id: number) => createQuery(() => ({
|
||||
queryKey: ['notes', id],
|
||||
queryFn: () => apiClient.get<Note>(`/notes/${id}`),
|
||||
...DEFAULT_RETRY_CONFIG,
|
||||
staleTime: 10 * 60 * 1000, // 10 minutes
|
||||
})),
|
||||
|
||||
useCreate: () => {
|
||||
const queryClient = useQueryClient();
|
||||
return createMutation(() => ({
|
||||
mutationFn: (data: Omit<Note, 'id' | 'user_id' | 'created_at' | 'updated_at'>) =>
|
||||
apiClient.post<Note>('/notes', data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['notes'] });
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to create note:', error);
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
useUpdate: () => {
|
||||
const queryClient = useQueryClient();
|
||||
return createMutation(() => ({
|
||||
mutationFn: ({ id, data }: { id: number; data: Partial<Note> }) =>
|
||||
apiClient.put<Note>(`/notes/${id}`, data),
|
||||
onSuccess: (_, { id }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['notes'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['notes', id] });
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to update note:', error);
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
useDelete: () => {
|
||||
const queryClient = useQueryClient();
|
||||
return createMutation(() => ({
|
||||
mutationFn: (id: number) => apiClient.delete(`/notes/${id}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['notes'] });
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to delete note:', error);
|
||||
},
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
// Files API
|
||||
export const filesApi = {
|
||||
useGetAll: () => createQuery(() => ({
|
||||
queryKey: ['files'],
|
||||
queryFn: () => apiClient.get<FileItem[]>('/files'),
|
||||
...DEFAULT_RETRY_CONFIG,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
})),
|
||||
|
||||
useGetById: (id: number) => createQuery(() => ({
|
||||
queryKey: ['files', id],
|
||||
queryFn: () => apiClient.get<FileItem>(`/files/${id}`),
|
||||
...DEFAULT_RETRY_CONFIG,
|
||||
staleTime: 10 * 60 * 1000, // 10 minutes
|
||||
})),
|
||||
|
||||
useUpload: () => {
|
||||
const queryClient = useQueryClient();
|
||||
return createMutation(() => ({
|
||||
mutationFn: async (file: globalThis.File) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/files/upload`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': getAuthHeaders().Authorization || '',
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || 'Upload failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['files'] });
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to upload file:', error);
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
useDelete: () => {
|
||||
const queryClient = useQueryClient();
|
||||
return createMutation(() => ({
|
||||
mutationFn: (id: number) => apiClient.delete(`/files/${id}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['files'] });
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to delete file:', error);
|
||||
},
|
||||
}));
|
||||
},
|
||||
};
|
||||
@@ -1,128 +0,0 @@
|
||||
import type { Bookmark, Task, Note, FileItem } from './api-client'
|
||||
|
||||
export interface ExportData {
|
||||
version: string
|
||||
exportDate: string
|
||||
bookmarks: Bookmark[]
|
||||
tasks: Task[]
|
||||
notes: Note[]
|
||||
files: FileItem[]
|
||||
}
|
||||
|
||||
export const exportData = async (data: {
|
||||
bookmarks?: Bookmark[]
|
||||
tasks?: Task[]
|
||||
notes?: Note[]
|
||||
files?: FileItem[]
|
||||
}, filename?: string) => {
|
||||
const exportData: ExportData = {
|
||||
version: '1.0.0',
|
||||
exportDate: new Date().toISOString(),
|
||||
bookmarks: data.bookmarks || [],
|
||||
tasks: data.tasks || [],
|
||||
notes: data.notes || [],
|
||||
files: data.files || []
|
||||
}
|
||||
|
||||
const jsonString = JSON.stringify(exportData, null, 2)
|
||||
const blob = new Blob([jsonString], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename || `trackeep-export-${new Date().toISOString().split('T')[0]}.json`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
export const importData = async (file: File): Promise<ExportData> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const content = e.target?.result as string
|
||||
const data = JSON.parse(content) as ExportData
|
||||
|
||||
// Validate the structure
|
||||
if (!data.version || !data.exportDate) {
|
||||
throw new Error('Invalid export file format')
|
||||
}
|
||||
|
||||
resolve(data)
|
||||
} catch (error) {
|
||||
reject(new Error('Failed to parse export file: ' + (error as Error).message))
|
||||
}
|
||||
}
|
||||
|
||||
reader.onerror = () => {
|
||||
reject(new Error('Failed to read file'))
|
||||
}
|
||||
|
||||
reader.readAsText(file)
|
||||
})
|
||||
}
|
||||
|
||||
export const validateImportData = (data: ExportData): { isValid: boolean; errors: string[] } => {
|
||||
const errors: string[] = []
|
||||
|
||||
// Check version compatibility
|
||||
if (!data.version) {
|
||||
errors.push('Missing version information')
|
||||
}
|
||||
|
||||
// Check required fields
|
||||
if (!data.exportDate) {
|
||||
errors.push('Missing export date')
|
||||
}
|
||||
|
||||
// Validate data types
|
||||
if (data.bookmarks && !Array.isArray(data.bookmarks)) {
|
||||
errors.push('Bookmarks data is not an array')
|
||||
}
|
||||
|
||||
if (data.tasks && !Array.isArray(data.tasks)) {
|
||||
errors.push('Tasks data is not an array')
|
||||
}
|
||||
|
||||
if (data.notes && !Array.isArray(data.notes)) {
|
||||
errors.push('Notes data is not an array')
|
||||
}
|
||||
|
||||
if (data.files && !Array.isArray(data.files)) {
|
||||
errors.push('Files data is not an array')
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors
|
||||
}
|
||||
}
|
||||
|
||||
export const getImportSummary = (data: ExportData): string => {
|
||||
const summary = []
|
||||
|
||||
if (data.bookmarks.length > 0) {
|
||||
summary.push(`${data.bookmarks.length} bookmarks`)
|
||||
}
|
||||
|
||||
if (data.tasks.length > 0) {
|
||||
summary.push(`${data.tasks.length} tasks`)
|
||||
}
|
||||
|
||||
if (data.notes.length > 0) {
|
||||
summary.push(`${data.notes.length} notes`)
|
||||
}
|
||||
|
||||
if (data.files.length > 0) {
|
||||
summary.push(`${data.files.length} files`)
|
||||
}
|
||||
|
||||
if (summary.length === 0) {
|
||||
return 'No data to import'
|
||||
}
|
||||
|
||||
return `Import contains: ${summary.join(', ')}`
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -514,7 +514,10 @@ export const Bookmarks = () => {
|
||||
onError={(e) => {
|
||||
const img = e.currentTarget;
|
||||
img.style.display = 'none';
|
||||
img.parentElement!.innerHTML = `<span class="text-xs text-muted-foreground font-medium">${getBookmarkInitial(bookmark.title)}</span>`;
|
||||
const span = document.createElement('span');
|
||||
span.className = 'text-xs text-muted-foreground font-medium';
|
||||
span.textContent = getBookmarkInitial(bookmark.title);
|
||||
img.parentElement!.appendChild(span);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
@@ -335,7 +335,12 @@ export const Files = () => {
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
window.location.href = data.install_url as string;
|
||||
const installUrl = data.install_url as string;
|
||||
if (installUrl && (installUrl.startsWith('https://github.com/') || installUrl.startsWith('https://api.github.com/'))) {
|
||||
window.location.href = installUrl;
|
||||
} else {
|
||||
throw new Error('Invalid install URL received');
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to start GitHub App installation';
|
||||
setGitHubError(message);
|
||||
@@ -464,7 +464,12 @@ export const GitHub = () => {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
window.location.href = data.install_url as string;
|
||||
const installUrl = data.install_url as string;
|
||||
if (installUrl && (installUrl.startsWith('https://github.com/') || installUrl.startsWith('https://api.github.com/'))) {
|
||||
window.location.href = installUrl;
|
||||
} else {
|
||||
throw new Error('Invalid install URL received');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to start GitHub App installation:', error);
|
||||
setBackupError(error instanceof Error ? error.message : 'Failed to start GitHub App installation');
|
||||
File renamed without changes.
@@ -112,12 +112,10 @@ export const Notes = () => {
|
||||
|
||||
// Check if we should use demo mode or real API
|
||||
if (isDemoMode() && !shouldUseRealBackend()) {
|
||||
console.log('[Notes] Loading demo notes data');
|
||||
// Load mock notes data for demo mode
|
||||
const mockNotesData = getMockNotes();
|
||||
notesData = mockNotesData;
|
||||
} else {
|
||||
console.log('[Notes] Loading notes from real API');
|
||||
// Load from real API
|
||||
const token = localStorage.getItem('trackeep_token') || localStorage.getItem('token');
|
||||
const response = await fetch(`${API_BASE_URL}/notes`, {
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -41,7 +41,11 @@ export const RemovedStuff = () => {
|
||||
// Load auto-remove settings from localStorage
|
||||
const savedSettings = localStorage.getItem('autoRemoveSettings');
|
||||
if (savedSettings) {
|
||||
setAutoRemoveSettings(JSON.parse(savedSettings));
|
||||
try {
|
||||
setAutoRemoveSettings(JSON.parse(savedSettings));
|
||||
} catch {
|
||||
console.warn('Failed to parse autoRemoveSettings');
|
||||
}
|
||||
}
|
||||
|
||||
// Try to load from API first, then fallback to localStorage
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1,245 +0,0 @@
|
||||
// Update service for handling application updates
|
||||
import { isDemoMode } from '@/lib/demo-mode';
|
||||
|
||||
export interface UpdateInfo {
|
||||
version: string;
|
||||
releaseNotes: string;
|
||||
downloadUrl: string;
|
||||
mandatory: boolean;
|
||||
size: string;
|
||||
}
|
||||
|
||||
export interface UpdateStatus {
|
||||
available: boolean;
|
||||
downloading: boolean;
|
||||
installing: boolean;
|
||||
completed: boolean;
|
||||
error?: string;
|
||||
progress: number;
|
||||
}
|
||||
|
||||
export interface UpdateCheckResponse {
|
||||
updateAvailable: boolean;
|
||||
currentVersion: string;
|
||||
latestVersion: string;
|
||||
updateInfo?: UpdateInfo;
|
||||
}
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8080';
|
||||
|
||||
export const updateService = {
|
||||
// Check for available updates
|
||||
async checkForUpdates(): Promise<UpdateCheckResponse> {
|
||||
// If in demo mode, return mock update data
|
||||
if (isDemoMode()) {
|
||||
return {
|
||||
updateAvailable: true,
|
||||
currentVersion: '1.0.0',
|
||||
latestVersion: '1.0.1',
|
||||
updateInfo: {
|
||||
version: '1.0.1',
|
||||
releaseNotes: '• New AI features added\n• Performance improvements\n• Bug fixes and security patches\n• Enhanced user interface',
|
||||
downloadUrl: 'https://github.com/trackeep/trackeep/releases/latest',
|
||||
mandatory: false,
|
||||
size: '~25MB'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout
|
||||
|
||||
try {
|
||||
console.log('[Real Mode] Checking for updates at:', `${API_BASE}/api/updates/check`);
|
||||
const response = await fetch(`${API_BASE}/api/updates/check`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`,
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw new Error('Update check timed out');
|
||||
}
|
||||
|
||||
console.error('Failed to check for updates:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Install an update
|
||||
async installUpdate(version: string): Promise<{ message: string; version: string }> {
|
||||
// If in demo mode, simulate update installation
|
||||
if (isDemoMode()) {
|
||||
return {
|
||||
message: 'Update started',
|
||||
version: version
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/updates/install`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`,
|
||||
},
|
||||
body: JSON.stringify({ version }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Failed to install update:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Get update progress
|
||||
async getUpdateProgress(): Promise<UpdateStatus> {
|
||||
// If in demo mode, return mock progress
|
||||
if (isDemoMode()) {
|
||||
return {
|
||||
available: true,
|
||||
downloading: false,
|
||||
installing: false,
|
||||
completed: false,
|
||||
error: '',
|
||||
progress: 0
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/updates/progress`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Failed to get update progress:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Get current app version from package.json
|
||||
async getCurrentVersion(): Promise<string> {
|
||||
// Try to get version from package.json first, then fallback
|
||||
try {
|
||||
const response = await fetch('/package.json');
|
||||
if (response.ok) {
|
||||
const packageJson = await response.json();
|
||||
if (packageJson.version) {
|
||||
console.log('Version from package.json:', packageJson.version);
|
||||
return packageJson.version;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Could not read package.json:', error);
|
||||
}
|
||||
|
||||
// Fallback to environment variable or default
|
||||
return import.meta.env.VITE_APP_VERSION || '1.2.5';
|
||||
},
|
||||
|
||||
// Poll for update progress during installation
|
||||
pollUpdateProgress(callback: (progress: UpdateStatus) => void, interval: number = 2000): () => void {
|
||||
let isActive = true;
|
||||
|
||||
const poll = async () => {
|
||||
if (!isActive) return;
|
||||
|
||||
try {
|
||||
const progress = await this.getUpdateProgress();
|
||||
callback(progress);
|
||||
|
||||
// Stop polling if update is completed or failed
|
||||
if (progress.completed || progress.error) {
|
||||
isActive = false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error polling update progress:', error);
|
||||
isActive = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Start polling
|
||||
poll();
|
||||
const intervalId = setInterval(poll, interval);
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
isActive = false;
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
},
|
||||
|
||||
// Simulate update progress for demo mode
|
||||
simulateUpdateProgress(callback: (progress: UpdateStatus) => void): () => void {
|
||||
let isActive = true;
|
||||
let progress = 0;
|
||||
let phase = 'downloading'; // 'downloading' -> 'installing' -> 'completed'
|
||||
|
||||
const simulate = () => {
|
||||
if (!isActive) return;
|
||||
|
||||
progress += Math.random() * 15 + 5; // Random progress increment
|
||||
|
||||
if (progress >= 100) {
|
||||
progress = 100;
|
||||
if (phase === 'downloading') {
|
||||
phase = 'installing';
|
||||
progress = 0;
|
||||
} else if (phase === 'installing') {
|
||||
phase = 'completed';
|
||||
isActive = false;
|
||||
}
|
||||
}
|
||||
|
||||
const updateStatus: UpdateStatus = {
|
||||
available: true,
|
||||
downloading: phase === 'downloading',
|
||||
installing: phase === 'installing',
|
||||
completed: phase === 'completed',
|
||||
error: '',
|
||||
progress: progress
|
||||
};
|
||||
|
||||
callback(updateStatus);
|
||||
|
||||
if (isActive) {
|
||||
const delay = phase === 'downloading' ? 500 : 1000; // Faster download, slower install
|
||||
setTimeout(simulate, delay);
|
||||
}
|
||||
};
|
||||
|
||||
// Start simulation
|
||||
simulate();
|
||||
|
||||
return () => {
|
||||
isActive = false;
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -1,184 +0,0 @@
|
||||
import { createSignal } from 'solid-js';
|
||||
import { updateService, type UpdateInfo, type UpdateStatus } from '../services/updateService';
|
||||
import { isDemoMode } from '@/lib/demo-mode';
|
||||
|
||||
// Global update state store
|
||||
const [updateAvailable, setUpdateAvailable] = createSignal(false);
|
||||
const [updateInfo, setUpdateInfo] = createSignal<UpdateInfo | null>(null);
|
||||
const [updateStatus, setUpdateStatus] = createSignal<UpdateStatus>({
|
||||
available: false,
|
||||
downloading: false,
|
||||
installing: false,
|
||||
completed: false,
|
||||
progress: 0
|
||||
});
|
||||
const [isChecking, setIsChecking] = createSignal(false);
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
const [currentVersion, setCurrentVersion] = createSignal('1.0.0');
|
||||
const [lastCheckTime, setLastCheckTime] = createSignal<number>(0);
|
||||
|
||||
let pollCleanup: (() => void) | null = null;
|
||||
let checkInterval: number | null = null;
|
||||
|
||||
let checkInProgress = false;
|
||||
|
||||
// Check for updates
|
||||
const checkForUpdates = async () => {
|
||||
// Prevent multiple simultaneous checks with both signal and flag
|
||||
if (isChecking() || checkInProgress) return;
|
||||
|
||||
checkInProgress = true;
|
||||
setIsChecking(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await updateService.checkForUpdates();
|
||||
setUpdateAvailable(response.updateAvailable);
|
||||
setUpdateInfo(response.updateInfo || null);
|
||||
setCurrentVersion(response.currentVersion);
|
||||
setLastCheckTime(Date.now());
|
||||
|
||||
// Save last check time to localStorage
|
||||
localStorage.setItem('lastUpdateCheck', Date.now().toString());
|
||||
|
||||
if (response.updateAvailable && response.updateInfo) {
|
||||
setUpdateStatus(prev => ({ ...prev, available: true }));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to check for updates:', err);
|
||||
setError('Failed to check for updates');
|
||||
} finally {
|
||||
setIsChecking(false);
|
||||
checkInProgress = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Install update
|
||||
const installUpdate = async () => {
|
||||
if (!updateInfo()) return;
|
||||
|
||||
try {
|
||||
setError(null);
|
||||
await updateService.installUpdate(updateInfo()!.version);
|
||||
|
||||
// Start polling for progress or simulation in demo mode
|
||||
if (isDemoMode()) {
|
||||
pollCleanup = updateService.simulateUpdateProgress((progress: UpdateStatus) => {
|
||||
setUpdateStatus(progress);
|
||||
|
||||
if (progress.completed) {
|
||||
// Show success notification or trigger reload
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
if (progress.error) {
|
||||
setError(progress.error);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
pollCleanup = updateService.pollUpdateProgress((progress: UpdateStatus) => {
|
||||
setUpdateStatus(progress);
|
||||
|
||||
if (progress.completed) {
|
||||
// Show success notification or trigger reload
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
if (progress.error) {
|
||||
setError(progress.error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('Failed to install update:', err);
|
||||
setError('Failed to install update');
|
||||
}
|
||||
};
|
||||
|
||||
// Cancel update
|
||||
const cancelUpdate = () => {
|
||||
if (pollCleanup) {
|
||||
pollCleanup();
|
||||
pollCleanup = null;
|
||||
}
|
||||
setUpdateStatus({
|
||||
available: updateAvailable(),
|
||||
downloading: false,
|
||||
installing: false,
|
||||
completed: false,
|
||||
progress: 0
|
||||
});
|
||||
};
|
||||
|
||||
// Initialize update checking
|
||||
const initializeUpdateChecking = async () => {
|
||||
// Set current version
|
||||
setCurrentVersion(await updateService.getCurrentVersion());
|
||||
|
||||
// Check if last check was more than 24 hours ago
|
||||
const lastCheckTimeStr = localStorage.getItem('lastUpdateCheck');
|
||||
const now = Date.now();
|
||||
const twentyFourHours = 24 * 60 * 60 * 1000;
|
||||
|
||||
if (!lastCheckTimeStr || (now - parseInt(lastCheckTimeStr)) > twentyFourHours) {
|
||||
// Check for updates on initialization if it's been more than 24 hours
|
||||
checkForUpdates();
|
||||
} else {
|
||||
setLastCheckTime(parseInt(lastCheckTimeStr));
|
||||
}
|
||||
|
||||
// Set up periodic checking (every 24 hours)
|
||||
checkInterval = setInterval(checkForUpdates, twentyFourHours);
|
||||
};
|
||||
|
||||
// Cleanup
|
||||
const cleanup = () => {
|
||||
if (checkInterval) {
|
||||
clearInterval(checkInterval);
|
||||
checkInterval = null;
|
||||
}
|
||||
if (pollCleanup) {
|
||||
pollCleanup();
|
||||
pollCleanup = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-initialize when store is imported
|
||||
let initialized = false;
|
||||
const ensureInitialized = async () => {
|
||||
if (!initialized) {
|
||||
await initializeUpdateChecking();
|
||||
initialized = true;
|
||||
}
|
||||
};
|
||||
|
||||
// Export store functions and signals
|
||||
export const updateStore = {
|
||||
// Signals
|
||||
updateAvailable,
|
||||
updateInfo,
|
||||
updateStatus,
|
||||
isChecking,
|
||||
error,
|
||||
currentVersion,
|
||||
lastCheckTime,
|
||||
|
||||
// Actions
|
||||
checkForUpdates,
|
||||
installUpdate,
|
||||
cancelUpdate,
|
||||
|
||||
// Lifecycle
|
||||
ensureInitialized,
|
||||
cleanup
|
||||
};
|
||||
|
||||
// Auto-cleanup on page unload
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('beforeunload', cleanup);
|
||||
}
|
||||
Reference in new issue
Block a user