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:
Tomas Dvorak committed 2026-05-10 10:48:41 +02:00
1 parent c6a99c7e21
commit 6c448b336a
71 files changed
+135367 -4481

No files matched your search

+4 -4
View File
@@ -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
View File
@@ -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') ||
-248
View File
@@ -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>
</>
);
}
-117
View File
@@ -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>
)
}
-373
View File
@@ -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);
},
}));
},
};
-128
View File
@@ -1,128 +0,0 @@
import type { Bookmark, Task, Note, FileItem } from './api-client'
export interface ExportData {
version: string
exportDate: string
bookmarks: Bookmark[]
tasks: Task[]
notes: Note[]
files: FileItem[]
}
export const exportData = async (data: {
bookmarks?: Bookmark[]
tasks?: Task[]
notes?: Note[]
files?: FileItem[]
}, filename?: string) => {
const exportData: ExportData = {
version: '1.0.0',
exportDate: new Date().toISOString(),
bookmarks: data.bookmarks || [],
tasks: data.tasks || [],
notes: data.notes || [],
files: data.files || []
}
const jsonString = JSON.stringify(exportData, null, 2)
const blob = new Blob([jsonString], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename || `trackeep-export-${new Date().toISOString().split('T')[0]}.json`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
export const importData = async (file: File): Promise<ExportData> => {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = (e) => {
try {
const content = e.target?.result as string
const data = JSON.parse(content) as ExportData
// Validate the structure
if (!data.version || !data.exportDate) {
throw new Error('Invalid export file format')
}
resolve(data)
} catch (error) {
reject(new Error('Failed to parse export file: ' + (error as Error).message))
}
}
reader.onerror = () => {
reject(new Error('Failed to read file'))
}
reader.readAsText(file)
})
}
export const validateImportData = (data: ExportData): { isValid: boolean; errors: string[] } => {
const errors: string[] = []
// Check version compatibility
if (!data.version) {
errors.push('Missing version information')
}
// Check required fields
if (!data.exportDate) {
errors.push('Missing export date')
}
// Validate data types
if (data.bookmarks && !Array.isArray(data.bookmarks)) {
errors.push('Bookmarks data is not an array')
}
if (data.tasks && !Array.isArray(data.tasks)) {
errors.push('Tasks data is not an array')
}
if (data.notes && !Array.isArray(data.notes)) {
errors.push('Notes data is not an array')
}
if (data.files && !Array.isArray(data.files)) {
errors.push('Files data is not an array')
}
return {
isValid: errors.length === 0,
errors
}
}
export const getImportSummary = (data: ExportData): string => {
const summary = []
if (data.bookmarks.length > 0) {
summary.push(`${data.bookmarks.length} bookmarks`)
}
if (data.tasks.length > 0) {
summary.push(`${data.tasks.length} tasks`)
}
if (data.notes.length > 0) {
summary.push(`${data.notes.length} notes`)
}
if (data.files.length > 0) {
summary.push(`${data.files.length} files`)
}
if (summary.length === 0) {
return 'No data to import'
}
return `Import contains: ${summary.join(', ')}`
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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`, {
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -41,7 +41,11 @@ export const RemovedStuff = () => {
// Load auto-remove settings from localStorage
const savedSettings = localStorage.getItem('autoRemoveSettings');
if (savedSettings) {
setAutoRemoveSettings(JSON.parse(savedSettings));
try {
setAutoRemoveSettings(JSON.parse(savedSettings));
} catch {
console.warn('Failed to parse autoRemoveSettings');
}
}
// Try to load from API first, then fallback to localStorage
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
-245
View File
@@ -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;
};
}
};
-184
View File
@@ -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);
}