mirror of
https://github.com/Dvorinka/Trackeep.git
synced 2026-07-29 05:53:50 +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:
+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(', ')}`
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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`, {
|
||||
@@ -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
|
||||
@@ -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