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
2026-05-10 10:48:41 +02:00
parent c6a99c7e21
commit 6c448b336a
71 changed files with 135367 additions and 4481 deletions
+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(', ')}`
}
@@ -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
-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);
}