mirror of
https://github.com/Dvorinka/Trackeep.git
synced 2026-06-04 12:32:58 +00:00
uppdate
This commit is contained in:
@@ -20,18 +20,18 @@ const iconPaths: Record<string, string> = {
|
||||
};
|
||||
|
||||
const fallbackIcons: Record<string, string> = {
|
||||
mistral: '🇪🇺',
|
||||
longcat: '🐱',
|
||||
grok: '🐦',
|
||||
deepseek: '🔍',
|
||||
ollama: '🦙',
|
||||
openrouter: '🌀',
|
||||
mistral: 'M',
|
||||
longcat: 'C',
|
||||
grok: 'G',
|
||||
deepseek: 'D',
|
||||
ollama: 'O',
|
||||
openrouter: 'OR',
|
||||
};
|
||||
|
||||
export function AIProviderIcon(props: AIProviderIconProps) {
|
||||
const inlineSVG = createMemo(() => inlineSVGs[props.providerId]);
|
||||
const iconPath = createMemo(() => iconPaths[props.providerId]);
|
||||
const fallbackIcon = createMemo(() => fallbackIcons[props.providerId] || '🤖');
|
||||
const fallbackIcon = createMemo(() => fallbackIcons[props.providerId] || 'AI');
|
||||
|
||||
// Use inline SVG if available (for openrouter, ollama, grok)
|
||||
if (inlineSVG()) {
|
||||
|
||||
@@ -80,7 +80,7 @@ export const AuthenticationWarning = () => {
|
||||
size="lg"
|
||||
onClick={handleDemoMode}
|
||||
>
|
||||
🎭 Try Demo Mode
|
||||
Try Demo Mode
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -164,16 +164,11 @@ export function AIChatPanel(props: AIChatPanelProps) {
|
||||
onClick={() => setShowModelPicker(!showModelPicker())}
|
||||
class="flex items-center gap-2 px-3 py-1.5 bg-muted hover:bg-muted/80 rounded-full text-xs transition-colors"
|
||||
>
|
||||
<Show when={aiModels.find(m => m.id === selectedModel())?.iconId}>
|
||||
<AIProviderIcon
|
||||
providerId={aiModels.find(m => m.id === selectedModel())?.iconId || 'longcat'}
|
||||
size="1rem"
|
||||
class="rounded-full"
|
||||
/>
|
||||
</Show>
|
||||
<Show when={!aiModels.find(m => m.id === selectedModel())?.iconId}>
|
||||
<div class="w-4 h-4 rounded-full bg-gradient-to-r from-blue-500 to-purple-500"></div>
|
||||
</Show>
|
||||
<AIProviderIcon
|
||||
providerId={aiModels.find(m => m.id === selectedModel())?.iconId || 'longcat'}
|
||||
size="1rem"
|
||||
class="rounded-full"
|
||||
/>
|
||||
<span class="text-muted-foreground">
|
||||
{aiModels.find(m => m.id === selectedModel())?.name?.split(' ')[0] || 'AI'}
|
||||
</span>
|
||||
@@ -196,20 +191,11 @@ export function AIChatPanel(props: AIChatPanelProps) {
|
||||
}`}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Show when={model.iconId}>
|
||||
<AIProviderIcon
|
||||
providerId={model.iconId!}
|
||||
size="0.75rem"
|
||||
class="rounded-full flex-shrink-0"
|
||||
/>
|
||||
</Show>
|
||||
<Show when={!model.iconId}>
|
||||
<div class={`w-3 h-3 rounded-full flex-shrink-0 ${
|
||||
model.provider === 'LongCat' ? 'bg-gradient-to-r from-orange-500 to-red-500' :
|
||||
model.provider === 'OpenAI' ? 'bg-gradient-to-r from-green-500 to-emerald-500' :
|
||||
'bg-gradient-to-r from-purple-500 to-pink-500'
|
||||
}`}></div>
|
||||
</Show>
|
||||
<AIProviderIcon
|
||||
providerId={model.iconId!}
|
||||
size="0.75rem"
|
||||
class="rounded-full flex-shrink-0"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium truncate">{model.name}</div>
|
||||
<div class="text-muted-foreground text-xs truncate">{model.description}</div>
|
||||
@@ -224,7 +210,7 @@ export function AIChatPanel(props: AIChatPanelProps) {
|
||||
|
||||
<div class="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span>{aiModels.find(m => m.id === selectedModel())?.provider || 'LongCat'}</span>
|
||||
<a href="/app/settings" class="text-primary hover:underline">
|
||||
<a href="/app/settings#ai" class="text-primary hover:underline">
|
||||
AI settings
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -79,7 +79,7 @@ export function FloatingAI(props: FloatingAIProps) {
|
||||
|
||||
{/* AI Chat Modal */}
|
||||
<Show when={props.isChatOpen}>
|
||||
<div class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 mt-0 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">
|
||||
|
||||
@@ -15,9 +15,10 @@ import { useAuth } from '@/lib/auth'
|
||||
export interface HeaderProps {
|
||||
class?: string
|
||||
title?: string
|
||||
onMenuClick?: () => void
|
||||
}
|
||||
|
||||
export function Header(_props: HeaderProps) {
|
||||
export function Header(props: HeaderProps) {
|
||||
const [showUploadModal, setShowUploadModal] = createSignal(false);
|
||||
const { authState, updateProfile } = useAuth();
|
||||
|
||||
@@ -55,8 +56,15 @@ export function Header(_props: HeaderProps) {
|
||||
<div class="flex justify-between px-6 pt-4 pb-4">
|
||||
{/* Left side */}
|
||||
<div class="flex items-center">
|
||||
{/* Mobile menu button */}
|
||||
<button type="button" aria-haspopup="dialog" aria-expanded="false" data-closed="" class="inline-flex items-center justify-center rounded-md text-sm font-medium transition-shadow focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-inherit hover:bg-accent/50 hover:text-accent-foreground h-9 w-9 md:hidden mr-2">
|
||||
{/* Menu button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onMenuClick}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded="false"
|
||||
data-closed=""
|
||||
class="inline-flex items-center justify-center rounded-md text-sm font-medium transition-shadow focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-inherit hover:bg-accent/50 hover:text-accent-foreground h-9 w-9 mr-2"
|
||||
>
|
||||
<IconMenu2 class="size-6" />
|
||||
</button>
|
||||
|
||||
@@ -79,10 +87,10 @@ export function Header(_props: HeaderProps) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowUploadModal(true)}
|
||||
class="inline-flex items-center justify-center rounded-md text-sm font-medium transition-shadow focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground shadow hover:bg-primary/90 h-9 px-4 py-2"
|
||||
class="inline-flex items-center justify-center rounded-md text-sm font-medium transition-shadow focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground shadow hover:bg-primary/90 h-10 px-4 py-2 text-base"
|
||||
>
|
||||
<IconUpload class="size-4" />
|
||||
<span class="hidden sm:inline ml-2">Import a document</span>
|
||||
<IconUpload class="size-4 mr-2" />
|
||||
Import a document
|
||||
</button>
|
||||
|
||||
{/* Color switcher dropdown */}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { children, createSignal, onMount } from 'solid-js'
|
||||
import { Sidebar } from './Sidebar'
|
||||
import { Header } from './Header'
|
||||
import { AIChatPanel } from './AIChatPanel'
|
||||
import { UpdateNotification } from '../ui/UpdateNotification'
|
||||
import { IconBrain } from '@tabler/icons-solidjs'
|
||||
|
||||
export interface LayoutProps {
|
||||
@@ -15,6 +14,7 @@ export interface LayoutProps {
|
||||
export function Layout(props: LayoutProps) {
|
||||
const resolved = children(() => props.children)
|
||||
const [isChatOpen, setIsChatOpen] = createSignal(false)
|
||||
const [isSidebarOpen, setIsSidebarOpen] = createSignal(false)
|
||||
|
||||
onMount(() => {
|
||||
// Initialize dark mode from localStorage or system preference
|
||||
@@ -142,19 +142,33 @@ export function Layout(props: LayoutProps) {
|
||||
setIsChatOpen(!isChatOpen())
|
||||
}
|
||||
|
||||
const toggleSidebar = () => {
|
||||
setIsSidebarOpen(!isSidebarOpen())
|
||||
}
|
||||
|
||||
const closeSidebar = () => {
|
||||
setIsSidebarOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="min-h-screen font-sans text-sm font-400 bg-background text-foreground">
|
||||
{/* Update Notification - Above everything */}
|
||||
<UpdateNotification />
|
||||
|
||||
<div class="flex flex-row h-screen min-h-0">
|
||||
<div class="flex flex-row h-screen min-h-0 relative">
|
||||
{/* Mobile Sidebar Overlay */}
|
||||
{isSidebarOpen() && (
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 z-40"
|
||||
onClick={closeSidebar}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<Sidebar />
|
||||
<Sidebar isOpen={isSidebarOpen()} onClose={closeSidebar} />
|
||||
|
||||
{/* Main Content */}
|
||||
<div class="flex-1 min-h-0 flex flex-col">
|
||||
{/* Header */}
|
||||
<Header title={props.title} />
|
||||
<Header title={props.title} onMenuClick={toggleSidebar} />
|
||||
|
||||
{/* Page Content */}
|
||||
<main class="flex-1 overflow-auto max-w-screen">
|
||||
|
||||
@@ -20,7 +20,8 @@ import {
|
||||
IconCalendar,
|
||||
IconLogout,
|
||||
IconBuilding,
|
||||
IconPlus
|
||||
IconPlus,
|
||||
IconX
|
||||
} from '@tabler/icons-solidjs'
|
||||
import { UpdateChecker } from '../ui/UpdateChecker'
|
||||
|
||||
@@ -48,9 +49,11 @@ const mockWorkspaces = [
|
||||
|
||||
export interface SidebarProps {
|
||||
class?: string
|
||||
isOpen?: boolean
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
export function Sidebar(_props: SidebarProps) {
|
||||
export function Sidebar(props: SidebarProps) {
|
||||
const location = useLocation()
|
||||
const [isWorkspaceDropdownOpen, setIsWorkspaceDropdownOpen] = createSignal(false)
|
||||
const [selectedWorkspace, setSelectedWorkspace] = createSignal(mockWorkspaces[0])
|
||||
@@ -84,10 +87,23 @@ export function Sidebar(_props: SidebarProps) {
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="w-280px border-r border-r-border flex-shrink-0 hidden md:block bg-card">
|
||||
<div class="flex h-full">
|
||||
<div class="h-full flex flex-col pb-6 flex-1 min-w-0">
|
||||
{/* Organization Selector */}
|
||||
<>
|
||||
{/* Mobile Close Button - Above sidebar */}
|
||||
<Show when={props.isOpen}>
|
||||
<button
|
||||
onClick={props.onClose}
|
||||
class="fixed top-4 right-4 z-50 md:hidden 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" />
|
||||
</button>
|
||||
</Show>
|
||||
|
||||
<div class={`fixed inset-y-0 left-0 z-50 w-280px border-r border-r-border flex-shrink-0 bg-card transform transition-transform duration-300 ease-in-out md:relative md:translate-x-0 ${
|
||||
props.isOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
}`}>
|
||||
<div class="flex h-full">
|
||||
<div class="h-full flex flex-col pb-6 flex-1 min-w-0">
|
||||
{/* Organization Selector */}
|
||||
<div class="p-4 pb-0 min-w-0 max-w-full" id="workspace-selector">
|
||||
<div role="group" class="w-full relative">
|
||||
<button
|
||||
@@ -261,5 +277,6 @@ export function Sidebar(_props: SidebarProps) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ import { type BraveSearchResult } from '@/lib/brave-search';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { isEnvDemoMode, shouldUseRealSearch } from '@/lib/demo-mode';
|
||||
import { getSearchProvider, getApiBaseUrl } from '@/lib/credentials';
|
||||
import { isEnvDemoMode } from '@/lib/demo-mode';
|
||||
import { getApiBaseUrl } from '@/lib/credentials';
|
||||
|
||||
export const BrowserSearch = () => {
|
||||
const [searchQuery, setSearchQuery] = createSignal('');
|
||||
@@ -14,79 +14,67 @@ export const BrowserSearch = () => {
|
||||
const [error, setError] = createSignal('');
|
||||
const [hasSearched, setHasSearched] = createSignal(false);
|
||||
const [searchType, setSearchType] = createSignal<'web' | 'news'>('web');
|
||||
|
||||
// Add debouncing and request cancellation
|
||||
let searchTimeout: number | undefined;
|
||||
let currentRequestId: number = 0;
|
||||
|
||||
// Check if we're in demo mode
|
||||
const isDemo = () => {
|
||||
return isEnvDemoMode();
|
||||
};
|
||||
|
||||
// Check if we should use real search APIs
|
||||
const shouldUseReal = () => {
|
||||
return shouldUseRealSearch();
|
||||
};
|
||||
|
||||
const handleSearch = async () => {
|
||||
const query = searchQuery().trim();
|
||||
if (!query) return;
|
||||
if (!query || isLoading()) return;
|
||||
|
||||
// Cancel any existing timeout
|
||||
if (searchTimeout) {
|
||||
clearTimeout(searchTimeout);
|
||||
}
|
||||
|
||||
// Increment request ID for cancellation
|
||||
const requestId = ++currentRequestId;
|
||||
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
setHasSearched(true);
|
||||
|
||||
try {
|
||||
const isDemoMode = isDemo();
|
||||
const useRealAPIs = shouldUseReal();
|
||||
|
||||
console.log(`[BrowserSearch] Demo mode: ${isDemoMode}, Use real APIs: ${useRealAPIs}`);
|
||||
console.log(`[BrowserSearch] Demo mode: ${isDemoMode}`);
|
||||
|
||||
// If we have credentials and should use real APIs, try them first
|
||||
if (useRealAPIs) {
|
||||
console.log('Using real search APIs...');
|
||||
|
||||
// Try the configured search provider first
|
||||
const searchProvider = getSearchProvider();
|
||||
console.log(`Using search provider: ${searchProvider}`);
|
||||
|
||||
if (searchProvider === 'brave' && import.meta.env.VITE_BRAVE_API_KEY) {
|
||||
try {
|
||||
const { searchBrave } = await import('@/lib/brave-search');
|
||||
const results = await searchBrave(query, 8, searchType());
|
||||
if (results && results.length > 0) {
|
||||
setSearchResults(results);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Brave Search failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Try backend as fallback
|
||||
const API_BASE_URL = getApiBaseUrl();
|
||||
const token = localStorage.getItem('token') ||
|
||||
localStorage.getItem('auth_token') ||
|
||||
localStorage.getItem('trackeep_token');
|
||||
const endpoint = searchType() === 'news' ? '/api/v1/search/news' : '/api/v1/search/web';
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': token ? `Bearer ${token}` : '',
|
||||
},
|
||||
body: JSON.stringify({ query, count: 8 }),
|
||||
});
|
||||
// Always use backend API for search to avoid CORS issues
|
||||
const API_BASE_URL = getApiBaseUrl();
|
||||
const token = localStorage.getItem('token') ||
|
||||
localStorage.getItem('auth_token') ||
|
||||
localStorage.getItem('trackeep_token');
|
||||
const endpoint = searchType() === 'news' ? '/api/v1/search/news' : '/api/v1/search/web';
|
||||
|
||||
try {
|
||||
console.log(`Using backend search API: ${API_BASE_URL}${endpoint}`);
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': token ? `Bearer ${token}` : '',
|
||||
},
|
||||
body: JSON.stringify({ query, count: 8 }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.results && data.results.length > 0) {
|
||||
setSearchResults(data.results);
|
||||
return;
|
||||
}
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
// Check if this request is still current
|
||||
if (requestId === currentRequestId && data.results && data.results.length > 0) {
|
||||
setSearchResults(data.results);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Backend search failed:', err);
|
||||
} else {
|
||||
console.warn('Backend search returned error:', response.status, response.statusText);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Backend search failed:', err);
|
||||
}
|
||||
|
||||
// In demo mode or as fallback, use the demo mode API interceptor
|
||||
@@ -105,11 +93,14 @@ export const BrowserSearch = () => {
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
// Handle demo mode response format
|
||||
const results = data.web?.results || data.news?.results || data.mixed?.results || data.results || [];
|
||||
if (results.length > 0) {
|
||||
setSearchResults(results);
|
||||
return;
|
||||
// Check if this request is still current
|
||||
if (requestId === currentRequestId) {
|
||||
// Handle demo mode response format
|
||||
const results = data.web?.results || data.news?.results || data.mixed?.results || data.results || [];
|
||||
if (results.length > 0) {
|
||||
setSearchResults(results);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
console.warn('Demo API failed, falling back to mock results...');
|
||||
@@ -127,28 +118,37 @@ export const BrowserSearch = () => {
|
||||
|
||||
if (apiFailed) {
|
||||
console.warn('All search APIs failed, showing demo results:', errorMessage);
|
||||
const mockResults: BraveSearchResult[] = [
|
||||
{
|
||||
title: `${query} - Search Result 1`,
|
||||
url: `https://example.com/${query.toLowerCase().replace(/\s+/g, '-')}`,
|
||||
description: `This is a mock search result for "${query}" demonstrating the search functionality in demo mode.`,
|
||||
published_date: new Date().toISOString().split('T')[0],
|
||||
language: 'English'
|
||||
},
|
||||
{
|
||||
title: `${query} - Search Result 2`,
|
||||
url: `https://demo-site.com/${query.toLowerCase().replace(/\s+/g, '-')}`,
|
||||
description: `Another mock search result for "${query}" showing how the search interface works in demo mode.`,
|
||||
published_date: new Date().toISOString().split('T')[0],
|
||||
language: 'English'
|
||||
}
|
||||
];
|
||||
setSearchResults(mockResults);
|
||||
// Check if this request is still current
|
||||
if (requestId === currentRequestId) {
|
||||
const mockResults: BraveSearchResult[] = [
|
||||
{
|
||||
title: `${query} - Search Result 1`,
|
||||
url: `https://example.com/${query.toLowerCase().replace(/\s+/g, '-')}`,
|
||||
description: `This is a mock search result for "${query}" demonstrating the search functionality in demo mode.`,
|
||||
published_date: new Date().toISOString().split('T')[0],
|
||||
language: 'English'
|
||||
},
|
||||
{
|
||||
title: `${query} - Search Result 2`,
|
||||
url: `https://demo-site.com/${query.toLowerCase().replace(/\s+/g, '-')}`,
|
||||
description: `Another mock search result for "${query}" showing how the search interface works in demo mode.`,
|
||||
published_date: new Date().toISOString().split('T')[0],
|
||||
language: 'English'
|
||||
}
|
||||
];
|
||||
setSearchResults(mockResults);
|
||||
}
|
||||
} else {
|
||||
setError('Search temporarily unavailable. Please try again later.');
|
||||
// Check if this request is still current before setting error
|
||||
if (requestId === currentRequestId) {
|
||||
setError('Search temporarily unavailable. Please try again later.');
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
// Only update loading state if this is still the current request
|
||||
if (requestId === currentRequestId) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createSignal, For, Show, onMount, onCleanup } from 'solid-js';
|
||||
import { createSignal, For, Show, onMount } from 'solid-js';
|
||||
import { IconSearch, IconFileText, IconBookmark, IconChecklist, IconNotebook, IconFolder } from '@tabler/icons-solidjs';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
@@ -146,10 +146,27 @@ export const QuickSearch = () => {
|
||||
|
||||
onMount(() => {
|
||||
document.addEventListener('keydown', handleGlobalKeyDown);
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
document.removeEventListener('keydown', handleGlobalKeyDown);
|
||||
|
||||
// Add click outside listener
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (isOpen()) {
|
||||
const target = e.target as HTMLElement;
|
||||
// Check if click is outside the search modal
|
||||
if (!target.closest('.quick-search-modal')) {
|
||||
setIsOpen(false);
|
||||
setSearchQuery('');
|
||||
setSearchResults([]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleGlobalKeyDown);
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -167,8 +184,15 @@ export const QuickSearch = () => {
|
||||
|
||||
{/* Search Modal */}
|
||||
<Show when={isOpen()}>
|
||||
<div class="fixed inset-0 z-50 flex items-start justify-center pt-20 bg-black/50 backdrop-blur-sm">
|
||||
<div class="w-full max-w-2xl mx-4">
|
||||
<div class="fixed inset-0 z-[70] flex items-start justify-center pt-20 bg-black/50 backdrop-blur-sm quick-search-modal" onClick={(e) => {
|
||||
// Close if clicking on the backdrop
|
||||
if (e.target === e.currentTarget) {
|
||||
setIsOpen(false);
|
||||
setSearchQuery('');
|
||||
setSearchResults([]);
|
||||
}
|
||||
}}>
|
||||
<div class="w-full max-w-2xl mx-4" onClick={(e) => e.stopPropagation()}>
|
||||
<Card class="p-4 shadow-2xl">
|
||||
{/* Search Input */}
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
|
||||
@@ -118,7 +118,7 @@ export const ActivityFeed = (props: ActivityFeedProps) => {
|
||||
{
|
||||
id: 'github_3',
|
||||
type: 'github_star' as const,
|
||||
title: '⭐ trackeep gained new stars',
|
||||
title: 'trackeep gained new stars',
|
||||
description: 'Repository reached 245 stars',
|
||||
timestamp: new Date(now.getTime() - 8 * 3600000).toISOString(),
|
||||
source: 'github' as const,
|
||||
|
||||
@@ -55,15 +55,15 @@ export const BookmarkModal = (props: BookmarkModalProps) => {
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
{props.isOpen && (
|
||||
<div class="fixed inset-0 bg-black/50 z-40 mt-0" onClick={props.onClose} />
|
||||
<div class="fixed inset-0 bg-black/50 z-[60] mt-0" onClick={props.onClose} />
|
||||
)}
|
||||
|
||||
{/* Modal */}
|
||||
<div class={`fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-card border border-border rounded-lg shadow-xl transition-all duration-300 z-50 ${
|
||||
<div class={`fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-card border border-border rounded-lg shadow-xl transition-all duration-300 z-[70] ${
|
||||
props.isOpen ? 'opacity-100 scale-100' : 'opacity-0 scale-95 pointer-events-none'
|
||||
}`} style="width: 500px; max-width: 90vw;">
|
||||
}`} style="width: min(500px, 90vw); max-height: min(80vh, 600px); overflow-y: auto;">
|
||||
{/* Header */}
|
||||
<div class="flex items-center justify-between p-6 border-b border-border">
|
||||
<div class="flex items-center justify-between p-4 sm:p-6 border-b border-border">
|
||||
<h3 class="text-lg font-semibold">Add New Bookmark</h3>
|
||||
<button
|
||||
onClick={props.onClose}
|
||||
@@ -74,7 +74,7 @@ export const BookmarkModal = (props: BookmarkModalProps) => {
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div class="p-6 space-y-4">
|
||||
<div class="p-4 sm:p-6 space-y-4">
|
||||
<div class="relative">
|
||||
<Input
|
||||
type="url"
|
||||
@@ -129,7 +129,7 @@ export const BookmarkModal = (props: BookmarkModalProps) => {
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div class="flex justify-end gap-2 p-6 border-t border-border">
|
||||
<div class="flex flex-col sm:flex-row justify-end gap-2 p-4 sm:p-6 border-t border-border">
|
||||
<Button variant="outline" onClick={props.onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,682 @@
|
||||
/* Color Picker Styling - Match Papra Design System */
|
||||
|
||||
/* Papra color tokens for color picker */
|
||||
.bg-bg-white-0 {
|
||||
background-color: hsl(var(--background));
|
||||
}
|
||||
|
||||
.text-text-sub-600 {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.text-text-soft-400 {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.text-text-strong-950 {
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.text-primary-base {
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.text-primary-darker {
|
||||
color: hsl(var(--primary) / 0.8);
|
||||
}
|
||||
|
||||
.border-stroke-soft-200 {
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
|
||||
.border-stroke-white-0 {
|
||||
border-color: white;
|
||||
}
|
||||
|
||||
.ring-stroke-white-0 {
|
||||
--tw-ring-color: white;
|
||||
}
|
||||
|
||||
.ring-stroke-strong-950 {
|
||||
--tw-ring-color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.shadow-custom-md {
|
||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
.shadow-regular-xs {
|
||||
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
}
|
||||
|
||||
.shadow-button-important-focus {
|
||||
box-shadow: 0 0 0 3px hsl(var(--ring) / 0.3);
|
||||
}
|
||||
|
||||
.shadow-md {
|
||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
/* Text size utilities */
|
||||
.text-label-sm {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.text-paragraph-sm {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* Layout utilities */
|
||||
.rounded-20 {
|
||||
border-radius: 1.25rem;
|
||||
}
|
||||
|
||||
.rounded-l-10 {
|
||||
border-radius: 0.625rem 0 0 0.625rem;
|
||||
}
|
||||
|
||||
.rounded-r-10 {
|
||||
border-radius: 0 0.625rem 0.625rem 0;
|
||||
}
|
||||
|
||||
.rounded-full {
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
/* Hue Slider Styling */
|
||||
.hue-slider {
|
||||
position: relative;
|
||||
touch-action: none;
|
||||
forced-color-adjust: none;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
hsla(0, 100%, 50%, 1),
|
||||
hsla(60, 100%, 50%, 1),
|
||||
hsla(120, 100%, 50%, 1),
|
||||
hsla(180, 100%, 50%, 1),
|
||||
hsla(240, 100%, 50%, 1),
|
||||
hsla(300, 100%, 50%, 1),
|
||||
hsla(360, 100%, 50%, 1)
|
||||
),
|
||||
repeating-conic-gradient(
|
||||
#fff 0 90deg,
|
||||
rgba(0,0,0,.3) 0 180deg
|
||||
) 0% -25%/6px 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hue-slider-thumb {
|
||||
position: absolute;
|
||||
touch-action: none;
|
||||
forced-color-adjust: none;
|
||||
z-index: 50;
|
||||
cursor: grab;
|
||||
transition: transform 0.15s ease-out;
|
||||
}
|
||||
|
||||
.hue-slider-thumb:active {
|
||||
cursor: grabbing;
|
||||
transform: translate(-50%, -50%) scale(1.1);
|
||||
}
|
||||
|
||||
.hue-slider-input {
|
||||
border: 0;
|
||||
clip: rect(0 0 0 0);
|
||||
clip-path: inset(50%);
|
||||
height: 100%;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
opacity: 0.0001;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Input Group Styling */
|
||||
.group\/input-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.group\/input-wrapper:hover .placeholder\:text-text-sub-600 {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.group\/input-wrapper:has(input:focus) .placeholder\:text-text-sub-600 {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
/* Button styling */
|
||||
.group:hover .hover\:text-text-strong-950 {
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.group:hover .hover\:bg-bg-weak-50 {
|
||||
background-color: hsl(var(--muted));
|
||||
}
|
||||
|
||||
.group:hover .hover\:shadow-none {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.group:hover .hover\:ring-transparent {
|
||||
--tw-ring-color: transparent;
|
||||
}
|
||||
|
||||
.focus-visible\:text-text-strong-950:focus-visible {
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.focus-visible\:shadow-button-important-focus:focus-visible {
|
||||
box-shadow: 0 0 0 3px hsl(var(--ring) / 0.3);
|
||||
}
|
||||
|
||||
.focus-visible\:ring-stroke-strong-950:focus-visible {
|
||||
--tw-ring-color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
/* Focus states */
|
||||
.focus-within\:z-10:focus-within {
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.focus-within\:before\:ring-stroke-soft-200:focus-within:before {
|
||||
--tw-ring-color: hsl(var(--border));
|
||||
}
|
||||
|
||||
.has-\[input\:focus\]\:shadow-button-important-focus:has(input:focus) {
|
||||
box-shadow: 0 0 0 3px hsl(var(--ring) / 0.3);
|
||||
}
|
||||
|
||||
.has-\[input\:focus\]\:before\:ring-stroke-strong-950:has(input:focus):before {
|
||||
--tw-ring-color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
/* Hover states */
|
||||
.hover\:\[&\:not\(\:has\(input\:focus\)\)\:has\(\>\\:only-child\)\]\:before\:ring-transparent:hover:not(:has(input:focus)):has(> :only-child):before {
|
||||
--tw-ring-color: transparent;
|
||||
}
|
||||
|
||||
.hover\:\[&\:not\(\:focus-within\)\]\:before\:\!ring-stroke-soft-200:hover:not(:focus-within):before {
|
||||
--tw-ring-color: hsl(var(--border)) !important;
|
||||
}
|
||||
|
||||
/* Underline styling */
|
||||
.underline {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
|
||||
.decoration-transparent {
|
||||
text-decoration-color: transparent;
|
||||
}
|
||||
|
||||
.underline-offset-\[3px\] {
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.hover\:decoration-current:hover {
|
||||
text-decoration-color: currentColor;
|
||||
}
|
||||
|
||||
.focus-visible\:underline:focus-visible {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
|
||||
/* Dark mode specific styling */
|
||||
[data-kb-theme="dark"] .bg-bg-white-0 {
|
||||
background-color: hsl(var(--card));
|
||||
}
|
||||
|
||||
[data-kb-theme="dark"] .text-text-sub-600 {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
[data-kb-theme="dark"] .text-text-soft-400 {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
[data-kb-theme="dark"] .text-text-strong-950 {
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
[data-kb-theme="dark"] .border-stroke-soft-200 {
|
||||
border-color: #262626;
|
||||
}
|
||||
|
||||
[data-kb-theme="dark"] .shadow-custom-md {
|
||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.3), 0 2px 4px -2px rgb(0 0 0 / 0.3);
|
||||
}
|
||||
|
||||
[data-kb-theme="dark"] .shadow-regular-xs {
|
||||
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.2);
|
||||
}
|
||||
|
||||
[data-kb-theme="dark"] .shadow-md {
|
||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.3), 0 2px 4px -2px rgb(0 0 0 / 0.3);
|
||||
}
|
||||
|
||||
/* Input field dark mode */
|
||||
[data-kb-theme="dark"] .group\/input-wrapper:hover .hover\:\[&\:not\(\&\:has\(input\:focus\)\)\]\:bg-bg-weak-50:hover:not(&:has(input:focus)) {
|
||||
background-color: hsl(var(--muted));
|
||||
}
|
||||
|
||||
/* Button dark mode */
|
||||
[data-kb-theme="dark"] .group:hover .hover\:bg-bg-weak-50 {
|
||||
background-color: hsl(var(--muted));
|
||||
}
|
||||
|
||||
/* Color swatch selection ring */
|
||||
.border-\[\#335CFF1F\] {
|
||||
border-color: rgba(51, 92, 255, 0.12);
|
||||
}
|
||||
|
||||
/* Size utilities */
|
||||
.size-3 {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
}
|
||||
|
||||
.size-5 {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
.max-w-\[316px\] {
|
||||
max-width: 316px;
|
||||
}
|
||||
|
||||
/* Spacing utilities */
|
||||
.gap-2\.5 {
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
.gap-3 {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.gap-4 {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.space-y-3 > :not([hidden]) ~ :not([hidden]) {
|
||||
--un-space-y-reverse: 0;
|
||||
margin-top: calc(0.75rem * calc(1 - var(--un-space-y-reverse)));
|
||||
}
|
||||
|
||||
.-space-x-px > :not([hidden]) ~ :not([hidden]) {
|
||||
--un-space-x-reverse: 0;
|
||||
margin-right: calc(-1px * calc(1 - var(--un-space-x-reverse)));
|
||||
}
|
||||
|
||||
/* Padding utilities */
|
||||
.px-5 {
|
||||
padding-left: 1.25rem;
|
||||
padding-right: 1.25rem;
|
||||
}
|
||||
|
||||
.py-4 {
|
||||
padding-top: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.pb-2\.5 {
|
||||
padding-bottom: 0.625rem;
|
||||
}
|
||||
|
||||
.pb-5 {
|
||||
padding-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.pt-4 {
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.py-1 {
|
||||
padding-top: 0.25rem;
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.p-0 {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.px-2\.5 {
|
||||
padding-left: 0.625rem;
|
||||
padding-right: 0.625rem;
|
||||
}
|
||||
|
||||
.px-4 {
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
.py-3\.5 {
|
||||
padding-top: 0.875rem;
|
||||
padding-bottom: 0.875rem;
|
||||
}
|
||||
|
||||
/* Position utilities */
|
||||
.absolute {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.relative {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.inset-0 {
|
||||
inset: 0px;
|
||||
}
|
||||
|
||||
.inset-x-1\.5 {
|
||||
left: 0.375rem;
|
||||
right: 0.375rem;
|
||||
}
|
||||
|
||||
.top-1\/2 {
|
||||
top: 50%;
|
||||
}
|
||||
|
||||
.-inset-\[5px\] {
|
||||
inset: -5px;
|
||||
}
|
||||
|
||||
.-translate-y-1\/2 {
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
/* Z-index utilities */
|
||||
.z-50 {
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.z-10 {
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* Display utilities */
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.inline-flex {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.h-full {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.h-3 {
|
||||
height: 0.75rem;
|
||||
}
|
||||
|
||||
.h-2 {
|
||||
height: 0.5rem;
|
||||
}
|
||||
|
||||
.w-2 {
|
||||
width: 0.5rem;
|
||||
}
|
||||
|
||||
.h-5 {
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
.h-6 {
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.w-6 {
|
||||
width: 1.5rem;
|
||||
}
|
||||
|
||||
.h-9 {
|
||||
height: 2.25rem;
|
||||
}
|
||||
|
||||
.w-9 {
|
||||
width: 2.25rem;
|
||||
}
|
||||
|
||||
.max-w-\[57px\] {
|
||||
max-width: 57px;
|
||||
}
|
||||
|
||||
.flex-1 {
|
||||
flex: 1 1 0%;
|
||||
}
|
||||
|
||||
.flex-\[2\] {
|
||||
flex: 2 2 0%;
|
||||
}
|
||||
|
||||
/* Overflow utilities */
|
||||
.overflow-hidden {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Cursor utilities */
|
||||
.cursor-text {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Transition utilities */
|
||||
.transition {
|
||||
transition: all 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.duration-200 {
|
||||
transition-duration: 200ms;
|
||||
}
|
||||
|
||||
.ease-out {
|
||||
transition-timing-function: cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Outline utilities */
|
||||
.outline-none {
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Whitespace utilities */
|
||||
.whitespace-nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Text alignment */
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.justify-center {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.justify-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.items-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Flex direction */
|
||||
.flex-col {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Ring utilities */
|
||||
.ring-1 {
|
||||
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
|
||||
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);
|
||||
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
|
||||
}
|
||||
|
||||
.ring-0 {
|
||||
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
|
||||
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color);
|
||||
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
|
||||
}
|
||||
|
||||
.ring-inset {
|
||||
--tw-ring-inset: inset;
|
||||
}
|
||||
|
||||
.ring-transparent {
|
||||
--tw-ring-color: transparent;
|
||||
}
|
||||
|
||||
/* Border utilities */
|
||||
.divide-x > :not([hidden]) ~ :not([hidden]) {
|
||||
--un-divide-x-reverse: 0;
|
||||
border-right-width: calc(1px * calc(1 - var(--un-divide-x-reverse)));
|
||||
}
|
||||
|
||||
.border-b {
|
||||
border-bottom-width: 1px;
|
||||
}
|
||||
|
||||
/* Background utilities */
|
||||
.bg-transparent {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.bg-none {
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
/* Disabled states */
|
||||
.disabled\:pointer-events-none:disabled {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.disabled\:bg-bg-weak-50:disabled {
|
||||
background-color: hsl(var(--muted));
|
||||
}
|
||||
|
||||
.disabled\:text-text-disabled-300:disabled {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.disabled\:ring-transparent:disabled {
|
||||
--tw-ring-color: transparent;
|
||||
}
|
||||
|
||||
.disabled\:shadow-none:disabled {
|
||||
box-shadow: 0 0 #0000;
|
||||
}
|
||||
|
||||
.disabled\:no-underline:disabled {
|
||||
text-decoration-line: none;
|
||||
}
|
||||
|
||||
/* Placeholder utilities */
|
||||
.placeholder\:select-none::placeholder {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.placeholder\:text-text-soft-400::placeholder {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.placeholder\:transition::placeholder {
|
||||
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke;
|
||||
transition-duration: 200ms;
|
||||
transition-timing-function: cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.placeholder\:duration-200::placeholder {
|
||||
transition-duration: 200ms;
|
||||
}
|
||||
|
||||
.placeholder\:ease-out::placeholder {
|
||||
transition-timing-function: cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Group hover states */
|
||||
.group:hover .group-hover\/input-wrapper\:placeholder\:text-text-sub-600::placeholder {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
/* Group focus states */
|
||||
.group:has(input:focus) .group-has-\[input\:focus\]\:placeholder\:text-text-sub-600::placeholder {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
/* Before pseudo-element utilities */
|
||||
.before\:absolute::before {
|
||||
content: var(--tw-content);
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.before\:inset-0::before {
|
||||
content: var(--tw-content);
|
||||
inset: 0px;
|
||||
}
|
||||
|
||||
.before\:ring-1::before {
|
||||
content: var(--tw-content);
|
||||
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
|
||||
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);
|
||||
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
|
||||
}
|
||||
|
||||
.before\:ring-inset::before {
|
||||
content: var(--tw-content);
|
||||
--tw-ring-inset: inset;
|
||||
}
|
||||
|
||||
.before\:ring-stroke-soft-200::before {
|
||||
content: var(--tw-content);
|
||||
--tw-ring-color: hsl(var(--border));
|
||||
}
|
||||
|
||||
.before\:pointer-events-none::before {
|
||||
content: var(--tw-content);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.before\:rounded-\[inherit\]::before {
|
||||
content: var(--tw-content);
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.before\:transition::before {
|
||||
content: var(--tw-content);
|
||||
transition: all 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.before\:duration-200::before {
|
||||
content: var(--tw-content);
|
||||
transition-duration: 200ms;
|
||||
}
|
||||
|
||||
.before\:ease-out::before {
|
||||
content: var(--tw-content);
|
||||
transition-timing-function: cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.before\:ring-transparent::before {
|
||||
content: var(--tw-content);
|
||||
--tw-ring-color: transparent;
|
||||
}
|
||||
|
||||
.before\:ring-stroke-strong-950::before {
|
||||
content: var(--tw-content);
|
||||
--tw-ring-color: hsl(var(--foreground));
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
import { createSignal, For, Show, createMemo } from 'solid-js';
|
||||
import { cn } from '@/lib/utils';
|
||||
import './ColorPicker.css';
|
||||
|
||||
export interface ColorPickerProps {
|
||||
value?: string;
|
||||
onChange?: (color: string) => void;
|
||||
savedColors?: string[];
|
||||
onSavedColorsChange?: (colors: string[]) => void;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const defaultSavedColors = [
|
||||
'#FFFFFF', '#F5F5F5', '#EBEBEB', '#D1D1D1', '#A3A3A3', '#7B7B7B', '#5C5C5C', '#333333',
|
||||
'#D5E2FF', '#97BAFF', '#335CFF', '#2547D0', '#182F8B'
|
||||
];
|
||||
|
||||
export const ColorPicker = (props: ColorPickerProps) => {
|
||||
const [currentColor, setCurrentColor] = createSignal(props.value || '#FF0000');
|
||||
const [hue, setHue] = createSignal(0);
|
||||
const [alpha, setAlpha] = createSignal(100);
|
||||
const [savedColors, setSavedColors] = createSignal(props.savedColors || defaultSavedColors);
|
||||
const [isEditing, setIsEditing] = createSignal(false);
|
||||
const [isDragging, setIsDragging] = createSignal(false);
|
||||
|
||||
// Parse hex color to get HSL values
|
||||
const hexToHSL = (hex: string) => {
|
||||
const r = parseInt(hex.slice(1, 3), 16) / 255;
|
||||
const g = parseInt(hex.slice(3, 5), 16) / 255;
|
||||
const b = parseInt(hex.slice(5, 7), 16) / 255;
|
||||
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
let h = 0, s = 0, l = (max + min) / 2;
|
||||
|
||||
if (max !== min) {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
switch (max) {
|
||||
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
|
||||
case g: h = ((b - r) / d + 2) / 6; break;
|
||||
case b: h = ((r - g) / d + 4) / 6; break;
|
||||
}
|
||||
}
|
||||
|
||||
return { h: Math.round(h * 360), s: Math.round(s * 100), l: Math.round(l * 100) };
|
||||
};
|
||||
|
||||
// Convert HSL to hex
|
||||
const hslToHex = (h: number, s: number, l: number) => {
|
||||
s /= 100;
|
||||
l /= 100;
|
||||
const a = s * Math.min(l, 1 - l);
|
||||
const f = (n: number) => {
|
||||
const k = (n + h / 30) % 12;
|
||||
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
|
||||
return Math.round(255 * color).toString(16).padStart(2, '0');
|
||||
};
|
||||
return `#${f(0)}${f(8)}${f(4)}`.toUpperCase();
|
||||
};
|
||||
|
||||
// Update color based on hue and current saturation/lightness
|
||||
const updateColorFromHue = (newHue: number) => {
|
||||
const currentHSL = hexToHSL(currentColor());
|
||||
const newHex = hslToHex(newHue, currentHSL.s, currentHSL.l);
|
||||
setCurrentColor(newHex);
|
||||
setHue(newHue);
|
||||
props.onChange?.(newHex);
|
||||
};
|
||||
|
||||
// Handle hex input change
|
||||
const handleHexChange = (value: string) => {
|
||||
const hexValue = value.startsWith('#') ? value : `#${value}`;
|
||||
if (/^#[0-9A-F]{6}$/i.test(hexValue)) {
|
||||
setCurrentColor(hexValue);
|
||||
const hsl = hexToHSL(hexValue);
|
||||
setHue(hsl.h);
|
||||
props.onChange?.(hexValue);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle alpha input change
|
||||
const handleAlphaChange = (value: string) => {
|
||||
const numValue = parseInt(value.replace('%', ''));
|
||||
if (!isNaN(numValue) && numValue >= 0 && numValue <= 100) {
|
||||
setAlpha(numValue);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle hue slider drag
|
||||
const handleSliderMouseDown = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
updateHueFromPosition(e);
|
||||
};
|
||||
|
||||
const updateHueFromPosition = (e: MouseEvent) => {
|
||||
const slider = e.currentTarget as HTMLElement;
|
||||
const rect = slider.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const percentage = Math.max(0, Math.min(1, x / rect.width));
|
||||
const newHue = Math.round(percentage * 360);
|
||||
updateColorFromHue(newHue);
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (isDragging()) {
|
||||
updateHueFromPosition(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
// Handle saved color selection
|
||||
const handleSavedColorClick = (color: string) => {
|
||||
setCurrentColor(color);
|
||||
const hsl = hexToHSL(color);
|
||||
setHue(hsl.h);
|
||||
props.onChange?.(color);
|
||||
};
|
||||
|
||||
// Handle delete color
|
||||
const handleDeleteColor = () => {
|
||||
// Implementation for delete functionality
|
||||
console.log('Delete color');
|
||||
};
|
||||
|
||||
// Handle add new color
|
||||
const handleAddNewColor = () => {
|
||||
const newColors = [...savedColors(), currentColor()];
|
||||
setSavedColors(newColors);
|
||||
props.onSavedColorsChange?.(newColors);
|
||||
};
|
||||
|
||||
// Format hex display
|
||||
const hexDisplay = createMemo(() => currentColor());
|
||||
|
||||
// Set up global mouse events for dragging
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
|
||||
return (
|
||||
<div class={cn("w-full max-w-[316px] overflow-hidden rounded-20 bg-bg-white-0 shadow-custom-md", props.class)}>
|
||||
{/* Header */}
|
||||
<div class="flex w-full items-center justify-between px-5 py-4 pb-2.5">
|
||||
<div class="text-label-sm text-text-sub-600">Choose color</div>
|
||||
<div class="text-label-sm text-text-soft-400">{hexDisplay()}</div>
|
||||
</div>
|
||||
|
||||
{/* Hue Slider */}
|
||||
<div class="border-b border-stroke-soft-200 px-5 pb-5">
|
||||
<div class="py-1 h-3 !p-0" data-rac="" data-orientation="horizontal">
|
||||
<div
|
||||
role="group"
|
||||
class="w-full h-full rounded-full hue-slider"
|
||||
aria-label="Hue"
|
||||
onMouseDown={handleSliderMouseDown}
|
||||
>
|
||||
<div class="absolute inset-x-1.5 h-full">
|
||||
<div
|
||||
class="z-50 size-3 ring-stroke-white-0 shadow-md top-1/2 h-2 w-2 -translate-y-1/2 rounded-full !bg-bg-white-0 ring-0 hue-slider-thumb"
|
||||
style={{
|
||||
left: `${(hue() / 360) * 100}%`,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
'background-color': currentColor()
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
tabindex="0"
|
||||
type="range"
|
||||
min="0"
|
||||
max="360"
|
||||
step="1"
|
||||
aria-orientation="horizontal"
|
||||
aria-valuetext={`${hue()}°`}
|
||||
class="hue-slider-input"
|
||||
value={hue()}
|
||||
onInput={(e) => updateColorFromHue(parseInt(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Color and Alpha Inputs */}
|
||||
<div class="flex items-center gap-2.5 border-b border-stroke-soft-200 p-5">
|
||||
<div class="flex flex-1 -space-x-px">
|
||||
{/* Hex Input */}
|
||||
<div class="group relative flex w-full overflow-hidden bg-bg-white-0 text-text-strong-950 shadow-regular-xs transition duration-200 ease-out divide-x divide-stroke-soft-200 before:absolute before:inset-0 before:ring-1 before:ring-inset before:ring-stroke-soft-200 before:pointer-events-none before:rounded-[inherit] before:transition before:duration-200 before:ease-out hover:shadow-none has-[input:focus]:shadow-button-important-focus has-[input:focus]:before:ring-stroke-strong-950 has-[input:disabled]:shadow-none has-[input:disabled]:before:ring-transparent rounded-lg hover:[&:not(:has(input:focus)):has(>:only-child)]:before:ring-transparent flex-[2] rounded-l-10 rounded-r-none focus-within:z-10 hover:[&:not(:focus-within)]:before:!ring-stroke-soft-200" data-rac="" data-channel="hex">
|
||||
<label class="group/input-wrapper flex w-full cursor-text items-center bg-bg-white-0 transition duration-200 ease-out hover:[&:not(&:has(input:focus))]:bg-bg-weak-50 has-[input:disabled]:pointer-events-none has-[input:disabled]:bg-bg-weak-50 gap-2 px-2.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-3 w-3 shrink-0 rounded-full ring-0" style={{ 'background-color': currentColor() }}></div>
|
||||
<input
|
||||
id="hex-input"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
role="textbox"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
class="w-full bg-transparent bg-none text-paragraph-sm text-text-strong-950 outline-none transition duration-200 ease-out placeholder:select-none placeholder:text-text-soft-400 placeholder:transition placeholder:duration-200 placeholder:ease-out group-hover/input-wrapper:placeholder:text-text-sub-600 focus:outline-none group-has-[input:focus]:placeholder:text-text-sub-600 disabled:text-text-disabled-300 disabled:placeholder:text-text-disabled-300 h-5 items-start justify-start text-label-sm text-text-sub-600"
|
||||
data-rac=""
|
||||
value={hexDisplay()}
|
||||
onInput={(e) => handleHexChange(e.target.value)}
|
||||
title=""
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Alpha Input */}
|
||||
<div class="group relative flex w-full overflow-hidden bg-bg-white-0 text-text-strong-950 shadow-regular-xs transition duration-200 ease-out divide-x divide-stroke-soft-200 before:absolute before:inset-0 before:ring-1 before:ring-inset before:ring-stroke-soft-200 before:pointer-events-none before:rounded-[inherit] before:transition before:duration-200 before:ease-out hover:shadow-none has-[input:focus]:shadow-button-important-focus has-[input:focus]:before:ring-stroke-strong-950 has-[input:disabled]:shadow-none has-[input:disabled]:before:ring-transparent rounded-lg hover:[&:not(:has(input:focus)):has(>:only-child)]:before:ring-transparent max-w-[57px] flex-1 rounded-l-none rounded-r-10 focus-within:z-10 hover:[&:not(:focus-within)]:before:!ring-stroke-soft-200" data-rac="" data-channel="alpha">
|
||||
<label class="group/input-wrapper flex w-full cursor-text items-center bg-bg-white-0 transition duration-200 ease-out hover:[&:not(&:has(input:focus))]:bg-bg-weak-50 has-[input:disabled]:pointer-events-none has-[input:disabled]:bg-bg-weak-50 gap-2 px-2.5">
|
||||
<input
|
||||
aria-label="Alpha"
|
||||
id="alpha-input"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
inputmode="numeric"
|
||||
aria-roledescription="Number field"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
class="w-full bg-transparent bg-none text-paragraph-sm text-text-strong-950 outline-none transition duration-200 ease-out placeholder:select-none placeholder:text-text-soft-400 placeholder:transition placeholder:duration-200 placeholder:ease-out group-hover/input-wrapper:placeholder:text-text-sub-600 focus:outline-none group-has-[input:focus]:placeholder:text-text-sub-600 disabled:text-text-disabled-300 disabled:placeholder:text-text-disabled-300 h-9 text-label-sm text-text-sub-600"
|
||||
data-rac=""
|
||||
value={`${alpha()}%`}
|
||||
onInput={(e) => handleAlphaChange(e.target.value)}
|
||||
title=""
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete Button */}
|
||||
<button
|
||||
class="group relative inline-flex items-center justify-center whitespace-nowrap outline-none transition duration-200 ease-out focus:outline-none disabled:pointer-events-none disabled:bg-bg-weak-50 disabled:text-text-disabled-300 disabled:ring-transparent ring-1 ring-inset h-9 gap-3 rounded-lg px-3 text-label-sm bg-bg-white-0 text-text-sub-600 shadow-regular-xs ring-stroke-soft-200 hover:bg-bg-weak-50 hover:text-text-strong-950 hover:shadow-none hover:ring-transparent focus-visible:text-text-strong-950 focus-visible:shadow-button-important-focus focus-visible:ring-stroke-strong-950 w-9"
|
||||
onClick={handleDeleteColor}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="remixicon size-5 shrink-0">
|
||||
<path d="M17 6H22V8H20V21C20 21.5523 19.5523 22 19 22H5C4.44772 22 4 21.5523 4 21V8H2V6H7V3C7 2.44772 7.44772 2 8 2H16C16.5523 2 17 2.44772 17 3V6ZM18 8H6V20H18V8ZM9 11H11V17H9V11ZM13 11H15V17H13V11ZM9 4V6H15V4H9Z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Saved Colors */}
|
||||
<div class="flex flex-col gap-4 border-b border-stroke-soft-200 px-5 pb-5 pt-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-label-sm text-text-sub-600">Saved colors</span>
|
||||
<button
|
||||
class="group inline-flex items-center justify-center whitespace-nowrap outline-none transition duration-200 ease-out underline decoration-transparent underline-offset-[3px] hover:decoration-current focus:outline-none focus-visible:underline disabled:pointer-events-none disabled:text-text-disabled-300 disabled:no-underline text-primary-base hover:text-primary-darker h-5 gap-1 text-label-sm"
|
||||
onClick={() => setIsEditing(!isEditing())}
|
||||
>
|
||||
{isEditing() ? 'Done' : 'Edit'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex gap-3">
|
||||
<For each={savedColors().slice(0, 8)}>
|
||||
{(color) => (
|
||||
<button
|
||||
class="relative h-6 w-6"
|
||||
onClick={() => handleSavedColorClick(color)}
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 rounded-full border border-stroke-soft-200"
|
||||
style={{ 'background-color': color }}
|
||||
/>
|
||||
<Show when={currentColor() === color}>
|
||||
<div class="absolute -inset-[5px]">
|
||||
<div class="absolute inset-0 rounded-full border-stroke-white-0"></div>
|
||||
</div>
|
||||
<div class="absolute -inset-[5px]">
|
||||
<div class="absolute inset-0 rounded-full border-2 border-[#335CFF1F]"></div>
|
||||
</div>
|
||||
</Show>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<For each={savedColors().slice(8)}>
|
||||
{(color) => (
|
||||
<button
|
||||
class="relative h-6 w-6"
|
||||
onClick={() => handleSavedColorClick(color)}
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 rounded-full"
|
||||
style={{ 'background-color': color }}
|
||||
/>
|
||||
<Show when={currentColor() === color}>
|
||||
<div class="absolute -inset-[5px]">
|
||||
<div class="absolute inset-0 rounded-full border-stroke-white-0"></div>
|
||||
</div>
|
||||
<div class="absolute -inset-[5px]">
|
||||
<div class="absolute inset-0 rounded-full border-2 border-[#335CFF1F]"></div>
|
||||
</div>
|
||||
</Show>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add New Color Button */}
|
||||
<button
|
||||
class="flex w-full items-center justify-center gap-2 px-4 py-3.5 text-center"
|
||||
onClick={handleAddNewColor}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="remixicon size-5 shrink-0 text-text-soft-400">
|
||||
<path d="M11 11V5H13V11H19V13H13V19H11V13H5V11H11Z"></path>
|
||||
</svg>
|
||||
<span class="text-label-sm text-text-sub-600">Add new color</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -48,7 +48,7 @@ export const ConfirmModal = (props: ConfirmModalProps) => {
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
{isOpen && (
|
||||
<div class="fixed inset-0 bg-black/50 z-40" onClick={onClose} />
|
||||
<div class="fixed inset-0 bg-black/50 z-40 mt-0" onClick={onClose} />
|
||||
)}
|
||||
|
||||
{/* Modal */}
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
/* Date Range Picker Styling - Match Papra Design System */
|
||||
|
||||
/* Papra color tokens for date picker */
|
||||
.bg-bg-white-0 {
|
||||
background-color: hsl(var(--background));
|
||||
}
|
||||
|
||||
.text-text-sub-600 {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.text-text-strong-950 {
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.text-text-soft-400 {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.bg-bg-weak-50 {
|
||||
background-color: hsl(var(--muted));
|
||||
}
|
||||
|
||||
.text-static-white-0 {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.bg-primary-base {
|
||||
background-color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.text-primary-base {
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.bg-primary-alpha-10 {
|
||||
background-color: hsl(var(--primary) / 0.1);
|
||||
}
|
||||
|
||||
.border-stroke-soft-200 {
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
|
||||
.shadow-regular-md {
|
||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
.shadow-regular-xs {
|
||||
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
}
|
||||
|
||||
/* Text size utilities */
|
||||
.text-label-sm {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.text-paragraph-sm {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* Layout utilities */
|
||||
.rounded-20 {
|
||||
border-radius: 1.25rem;
|
||||
}
|
||||
|
||||
/* Date picker specific styling */
|
||||
.rdp-button_reset {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
text-align: inherit;
|
||||
text-decoration: inherit;
|
||||
text-transform: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rdp-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 0.375rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.15s ease-in-out;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Day range selection styling */
|
||||
.day-selected {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.day-range-start {
|
||||
background-color: hsl(var(--primary));
|
||||
color: white;
|
||||
border-radius: 0.5rem 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.day-range-end {
|
||||
background-color: hsl(var(--primary));
|
||||
color: white;
|
||||
border-radius: 0 0.5rem 0.5rem 0;
|
||||
}
|
||||
|
||||
.day-range-middle {
|
||||
background-color: hsl(var(--primary) / 0.1);
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.day-today {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Range selection background styling */
|
||||
.group\/cell[has=".day-range-middle"] {
|
||||
background-color: hsl(var(--primary) / 0.1);
|
||||
}
|
||||
|
||||
.group\/cell[has=".day-range-start"]:not([has=".day-range-end"]) {
|
||||
background-color: hsl(var(--primary) / 0.1);
|
||||
border-radius: 0.5rem 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.group\/cell[has=".day-range-end"]:not([has=".day-range-start"]) {
|
||||
background-color: hsl(var(--primary) / 0.1);
|
||||
border-radius: 0 0.5rem 0.5rem 0;
|
||||
}
|
||||
|
||||
/* Dark mode specific styling */
|
||||
[data-kb-theme="dark"] .bg-bg-white-0 {
|
||||
background-color: hsl(var(--card));
|
||||
}
|
||||
|
||||
[data-kb-theme="dark"] .text-text-sub-600 {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
[data-kb-theme="dark"] .text-text-strong-950 {
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
[data-kb-theme="dark"] .text-text-soft-400 {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
[data-kb-theme="dark"] .bg-bg-weak-50 {
|
||||
background-color: hsl(var(--muted));
|
||||
}
|
||||
|
||||
[data-kb-theme="dark"] .border-stroke-soft-200 {
|
||||
border-color: #262626;
|
||||
}
|
||||
|
||||
[data-kb-theme="dark"] .shadow-regular-md {
|
||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.3), 0 2px 4px -2px rgb(0 0 0 / 0.3);
|
||||
}
|
||||
|
||||
[data-kb-theme="dark"] .shadow-regular-xs {
|
||||
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.2);
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 640px) {
|
||||
.sm\:w-\[632px\] {
|
||||
width: 100%;
|
||||
max-width: 632px;
|
||||
}
|
||||
|
||||
.md\:w-\[200px\] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.md\:border-b-0 {
|
||||
border-bottom-width: 1px;
|
||||
}
|
||||
|
||||
.md\:border-r {
|
||||
border-right-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.sm\:w-\[632px\] {
|
||||
width: 632px;
|
||||
}
|
||||
|
||||
.md\:w-\[200px\] {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.md\:border-b-0 {
|
||||
border-bottom-width: 0;
|
||||
}
|
||||
|
||||
.md\:border-r {
|
||||
border-right-width: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Calendar grid fixes */
|
||||
.rdp table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.rdp th,
|
||||
.rdp td {
|
||||
padding: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Button hover states */
|
||||
.rdp-button:hover {
|
||||
background-color: hsl(var(--accent));
|
||||
color: hsl(var(--accent-foreground));
|
||||
}
|
||||
|
||||
.rdp-button:focus-visible {
|
||||
outline: 2px solid hsl(var(--ring));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Selected day styling */
|
||||
.rdp-button[aria-selected="true"] {
|
||||
background-color: hsl(var(--primary));
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Range selection continuity */
|
||||
.group\/cell[has=".day-range-middle"]::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: -2px;
|
||||
width: 2px;
|
||||
height: 100%;
|
||||
background-color: hsl(var(--primary) / 0.1);
|
||||
}
|
||||
|
||||
.group\/cell[has=".day-range-end"]::before {
|
||||
left: 0;
|
||||
right: auto;
|
||||
}
|
||||
|
||||
.group\/cell:last-child[has=".day-range-middle"]::before {
|
||||
display: none;
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
import { createSignal, For, Show, createMemo } from 'solid-js';
|
||||
import { Portal } from 'solid-js/web';
|
||||
import { IconChevronLeft, IconChevronRight, IconCalendar } from '@tabler/icons-solidjs';
|
||||
import { cn } from '@/lib/utils';
|
||||
import './DateRangePicker.css';
|
||||
|
||||
export interface DateRange {
|
||||
start?: Date;
|
||||
end?: Date;
|
||||
}
|
||||
|
||||
export interface DateRangePickerProps {
|
||||
value?: DateRange;
|
||||
onChange?: (range: DateRange | null) => void;
|
||||
placeholder?: string;
|
||||
class?: string;
|
||||
id?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const presetRanges = [
|
||||
{ label: 'Today', value: { start: new Date(), end: new Date() } },
|
||||
{
|
||||
label: 'Last 7 days',
|
||||
value: {
|
||||
start: new Date(Date.now() - 6 * 24 * 60 * 60 * 1000),
|
||||
end: new Date()
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Last 30 days',
|
||||
value: {
|
||||
start: new Date(Date.now() - 29 * 24 * 60 * 60 * 1000),
|
||||
end: new Date()
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Last 3 months',
|
||||
value: {
|
||||
start: new Date(Date.now() - 89 * 24 * 60 * 60 * 1000),
|
||||
end: new Date()
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Last 12 months',
|
||||
value: {
|
||||
start: new Date(Date.now() - 364 * 24 * 60 * 60 * 1000),
|
||||
end: new Date()
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Month to date',
|
||||
value: {
|
||||
start: new Date(new Date().getFullYear(), new Date().getMonth(), 1),
|
||||
end: new Date()
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Year to date',
|
||||
value: {
|
||||
start: new Date(new Date().getFullYear(), 0, 1),
|
||||
end: new Date()
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
export const DateRangePicker = (props: DateRangePickerProps) => {
|
||||
const [isOpen, setIsOpen] = createSignal(false);
|
||||
const [selectedRange, setSelectedRange] = createSignal<DateRange>(props.value || {});
|
||||
const [currentMonth, setCurrentMonth] = createSignal(new Date());
|
||||
const [position, setPosition] = createSignal({ top: 0, left: 0, width: 0 });
|
||||
|
||||
let triggerRef: HTMLButtonElement | undefined;
|
||||
|
||||
const months = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December'
|
||||
];
|
||||
|
||||
const weekDays = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
|
||||
|
||||
const getDaysInMonth = (date: Date) => {
|
||||
return new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
|
||||
};
|
||||
|
||||
const getFirstDayOfMonth = (date: Date) => {
|
||||
return new Date(date.getFullYear(), date.getMonth(), 1).getDay();
|
||||
};
|
||||
|
||||
const generateCalendarDays = () => {
|
||||
const days = [];
|
||||
const daysInMonth = getDaysInMonth(currentMonth());
|
||||
const firstDay = getFirstDayOfMonth(currentMonth());
|
||||
|
||||
// Add empty cells for days before month starts
|
||||
for (let i = 0; i < firstDay; i++) {
|
||||
days.push(null);
|
||||
}
|
||||
|
||||
// Add days of the month
|
||||
for (let i = 1; i <= daysInMonth; i++) {
|
||||
days.push(i);
|
||||
}
|
||||
|
||||
return days;
|
||||
};
|
||||
|
||||
const isDateInRange = (date: Date) => {
|
||||
const range = selectedRange();
|
||||
if (!range.start || !range.end) return false;
|
||||
return date >= range.start && date <= range.end;
|
||||
};
|
||||
|
||||
const isDateStart = (date: Date) => {
|
||||
const range = selectedRange();
|
||||
return range.start?.toDateString() === date.toDateString();
|
||||
};
|
||||
|
||||
const isDateEnd = (date: Date) => {
|
||||
const range = selectedRange();
|
||||
return range.end?.toDateString() === date.toDateString();
|
||||
};
|
||||
|
||||
const getDateClass = (day: number) => {
|
||||
const date = new Date(currentMonth().getFullYear(), currentMonth().getMonth(), day);
|
||||
const isStart = isDateStart(date);
|
||||
const isEnd = isDateEnd(date);
|
||||
const inRange = isDateInRange(date);
|
||||
|
||||
let classes = "rdp-button_reset rdp-button flex h-10 w-full items-center justify-center rounded-lg text-center text-label-sm text-text-sub-600 outline-none transition duration-200 ease-out hover:bg-bg-weak-50 hover:text-text-strong-950 focus:outline-none focus-visible:bg-bg-weak-50 focus-visible:text-text-strong-950";
|
||||
|
||||
if (isStart && isEnd) {
|
||||
classes += " aria-[selected]:bg-primary-base aria-[selected]:text-static-white";
|
||||
} else if (isStart) {
|
||||
classes += " aria-[selected]:bg-primary-base aria-[selected]:text-static-white day-selected day-range-start";
|
||||
} else if (isEnd) {
|
||||
classes += " aria-[selected]:bg-primary-base aria-[selected]:text-static-white day-selected day-range-end";
|
||||
} else if (inRange) {
|
||||
classes += " day-selected day-range-middle !text-primary-base !bg-transparent";
|
||||
}
|
||||
|
||||
return classes;
|
||||
};
|
||||
|
||||
const getCellClass = (day: number) => {
|
||||
const date = new Date(currentMonth().getFullYear(), currentMonth().getMonth(), day);
|
||||
const isStart = isDateStart(date);
|
||||
const isEnd = isDateEnd(date);
|
||||
const inRange = isDateInRange(date);
|
||||
|
||||
let classes = "group/cell relative h-10 w-full select-none p-0";
|
||||
|
||||
if (isStart && !isEnd) {
|
||||
classes += " [&:has(.day-range-start):not(:has(.day-range-end))]:rounded-l-full [&:has(.day-range-start):not(:has(.day-range-end))]:bg-primary-alpha-10 [&:has(.day-range-start):not(:has(.day-range-end))]:before:block";
|
||||
} else if (isEnd && !isStart) {
|
||||
classes += " [&:has(.day-range-end):not(:has(.day-range-start))]:rounded-r-full [&:has(.day-range-end):not(:has(.day-range-start))]:bg-primary-alpha-10";
|
||||
} else if (inRange) {
|
||||
classes += " [&:has(.day-range-middle)]:bg-primary-alpha-10 [&:has(.day-range-middle)]:before:block";
|
||||
}
|
||||
|
||||
classes += " [&:not(:has(+_*_[type=button]))]:before:hidden before:absolute before:inset-y-0 before:-right-2 before:hidden before:w-2 before:bg-primary-alpha-10 last:[&:has(.day-range-middle)]:before-hidden [&:has(.day-range-end)]:before:left-0 [&:has(.day-range-end)]:before:right-auto";
|
||||
|
||||
return classes;
|
||||
};
|
||||
|
||||
const handleDateClick = (day: number) => {
|
||||
const clickedDate = new Date(currentMonth().getFullYear(), currentMonth().getMonth(), day);
|
||||
const range = selectedRange();
|
||||
|
||||
if (!range.start || (range.start && range.end)) {
|
||||
// Start new selection
|
||||
setSelectedRange({ start: clickedDate, end: undefined });
|
||||
} else {
|
||||
// Complete the range
|
||||
if (clickedDate < range.start) {
|
||||
setSelectedRange({ start: clickedDate, end: range.start });
|
||||
} else {
|
||||
setSelectedRange({ start: range.start, end: clickedDate });
|
||||
}
|
||||
props.onChange?.(selectedRange());
|
||||
}
|
||||
};
|
||||
|
||||
const handleDateHover = (day: number) => {
|
||||
const date = new Date(currentMonth().getFullYear(), currentMonth().getMonth(), day);
|
||||
const range = selectedRange();
|
||||
|
||||
if (range.start && !range.end) {
|
||||
// Update preview range
|
||||
if (date >= range.start) {
|
||||
setSelectedRange({ ...range, end: date });
|
||||
} else {
|
||||
setSelectedRange({ start: date, end: range.start });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePresetClick = (preset: typeof presetRanges[0]) => {
|
||||
setSelectedRange(preset.value);
|
||||
props.onChange?.(preset.value);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handlePrevMonth = () => {
|
||||
setCurrentMonth(new Date(currentMonth().getFullYear(), currentMonth().getMonth() - 1));
|
||||
};
|
||||
|
||||
const handleNextMonth = () => {
|
||||
setCurrentMonth(new Date(currentMonth().getFullYear(), currentMonth().getMonth() + 1));
|
||||
};
|
||||
|
||||
const handleToggleModal = () => {
|
||||
if (props.disabled) return;
|
||||
|
||||
if (!isOpen()) {
|
||||
if (!triggerRef) return;
|
||||
|
||||
const rect = triggerRef.getBoundingClientRect();
|
||||
const estimatedHeight = 400;
|
||||
|
||||
let top = rect.bottom + window.scrollY + 4;
|
||||
const viewportBottom = window.scrollY + window.innerHeight;
|
||||
|
||||
if (top + estimatedHeight > viewportBottom) {
|
||||
top = rect.top + window.scrollY - estimatedHeight - 4;
|
||||
}
|
||||
|
||||
const width = 632; // Fixed width as per design
|
||||
let left = rect.left + window.scrollX;
|
||||
const maxLeft = window.scrollX + window.innerWidth - width - 16;
|
||||
if (left > maxLeft) {
|
||||
left = maxLeft;
|
||||
}
|
||||
if (left < window.scrollX + 16) {
|
||||
left = window.scrollX + 16;
|
||||
}
|
||||
|
||||
setPosition({ top, left, width });
|
||||
}
|
||||
|
||||
setIsOpen(!isOpen());
|
||||
};
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
const displayRange = createMemo(() => {
|
||||
const range = selectedRange();
|
||||
if (range.start && range.end) {
|
||||
return `${formatDate(range.start)} - ${formatDate(range.end)}`;
|
||||
} else if (range.start) {
|
||||
return `${formatDate(range.start)} - ...`;
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggleModal}
|
||||
disabled={props.disabled}
|
||||
class={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 text-left",
|
||||
props.class
|
||||
)}
|
||||
id={props.id || 'date-range-picker-button'}
|
||||
ref={triggerRef}
|
||||
>
|
||||
<Show
|
||||
when={selectedRange().start && selectedRange().end}
|
||||
fallback={<span class="text-muted-foreground">{props.placeholder || "Select date range"}</span>}
|
||||
>
|
||||
<span>{displayRange()}</span>
|
||||
</Show>
|
||||
<IconCalendar class="ml-auto h-4 w-4 opacity-50" />
|
||||
</button>
|
||||
|
||||
<Show when={isOpen()}>
|
||||
<Portal>
|
||||
{/* Close on outside click */}
|
||||
<div
|
||||
class="fixed inset-0 z-[120]"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
|
||||
<div
|
||||
class="fixed z-[130] m-4 inline-flex w-fit flex-col overflow-hidden rounded-20 bg-bg-white-0 shadow-regular-md ring-1 ring-inset ring-stroke-soft-200 sm:w-[632px]"
|
||||
style={{
|
||||
top: `${position().top}px`,
|
||||
left: `${position().left}px`,
|
||||
width: `${position().width}px`,
|
||||
}}
|
||||
>
|
||||
<div class="flex h-full flex-col md:flex-row">
|
||||
{/* Left Panel - Preset Ranges */}
|
||||
<div class="space-y-2 px-4 py-5 sm:border-r sm:border-stroke-soft-200 w-full border-b md:w-[200px] md:border-b-0 md:border-r">
|
||||
<div class="flex flex-row gap-2 overflow-x-auto md:flex-col md:overflow-x-visible">
|
||||
<For each={presetRanges}>
|
||||
{(preset) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handlePresetClick(preset)}
|
||||
class="h-9 w-full rounded-lg px-3 text-left text-label-sm text-text-sub-600 transition duration-200 ease-out hover:bg-bg-weak-50 whitespace-nowrap md:whitespace-normal"
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Panel - Calendar */}
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex w-full flex-col">
|
||||
<div class="rdp">
|
||||
<div class="flex w-full">
|
||||
<div class="space-y-2 p-5 w-full">
|
||||
{/* Month Navigation */}
|
||||
<div class="flex justify-center items-center relative rounded-lg bg-bg-weak-50 h-9">
|
||||
<div class="text-label-sm text-text-sub-600 select-none">
|
||||
{months[currentMonth().getMonth()]} {currentMonth().getFullYear()}
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<button
|
||||
onClick={handlePrevMonth}
|
||||
class="rdp-button_reset rdp-button flex shrink-0 items-center justify-center outline-none transition duration-200 ease-out disabled:pointer-events-none disabled:border-transparent disabled:bg-transparent disabled:text-text-disabled-300 disabled:shadow-none focus:outline-none bg-bg-white-0 text-text-sub-600 shadow-regular-xs hover:bg-bg-weak-50 hover:text-text-strong-950 focus-visible:bg-bg-strong-950 focus-visible:text-text-white-0 size-6 rounded-md absolute top-1/2 -translate-y-1/2 left-1.5"
|
||||
type="button"
|
||||
>
|
||||
<IconChevronLeft class="size-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleNextMonth}
|
||||
class="rdp-button_reset rdp-button flex shrink-0 items-center justify-center outline-none transition duration-200 ease-out disabled:pointer-events-none disabled:border-transparent disabled:bg-transparent disabled:text-text-disabled-300 disabled:shadow-none focus:outline-none bg-bg-white-0 text-text-sub-600 shadow-regular-xs hover:bg-bg-weak-50 hover:text-text-strong-950 focus-visible:bg-bg-strong-950 focus-visible:text-text-white-0 size-6 rounded-md absolute top-1/2 -translate-y-1/2 right-1.5"
|
||||
type="button"
|
||||
>
|
||||
<IconChevronRight class="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Calendar Grid */}
|
||||
<table class="w-full border-collapse flex justify-center items-center flex-col !mt-0">
|
||||
<thead class="w-full">
|
||||
<tr class="flex gap-2">
|
||||
<For each={weekDays}>
|
||||
{(day) => (
|
||||
<th class="text-text-soft-400 text-label-sm uppercase size-10 flex items-center justify-center text-center select-none w-full mt-2">
|
||||
{day}
|
||||
</th>
|
||||
)}
|
||||
</For>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="w-full">
|
||||
{(() => {
|
||||
const days = generateCalendarDays();
|
||||
const weeks = [];
|
||||
for (let i = 0; i < days.length; i += 7) {
|
||||
weeks.push(days.slice(i, i + 7));
|
||||
}
|
||||
return weeks.map((weekDays) => (
|
||||
<tr class="grid grid-flow-col auto-cols-fr w-full mt-2 gap-2">
|
||||
<For each={weekDays}>
|
||||
{(day) => (
|
||||
<td class={getCellClass(day!)}>
|
||||
<Show when={day !== null}>
|
||||
<button
|
||||
name="day"
|
||||
class={getDateClass(day!)}
|
||||
onClick={() => handleDateClick(day!)}
|
||||
onMouseEnter={() => handleDateHover(day!)}
|
||||
type="button"
|
||||
aria-selected={isDateInRange(new Date(currentMonth().getFullYear(), currentMonth().getMonth(), day!))}
|
||||
>
|
||||
{day}
|
||||
</button>
|
||||
</Show>
|
||||
</td>
|
||||
)}
|
||||
</For>
|
||||
</tr>
|
||||
));
|
||||
})()}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Range Display */}
|
||||
<div class="flex items-center justify-between gap-4 border-t border-stroke-soft-200 p-4 pl-6">
|
||||
<div class="text-paragraph-sm text-text-sub-600">
|
||||
Range: <span class="text-label-sm text-text-strong-950">{displayRange()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createSignal } from 'solid-js';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { IconX, IconDownload, IconExternalLink, IconEye, IconFile, IconCode, IconFileText } from '@tabler/icons-solidjs';
|
||||
import { IconX, IconDownload, IconExternalLink, IconEye, IconFile, IconCode, IconFileText, IconAlertTriangle, IconMusic, IconFileDescription, IconChartBar, IconChartLine } from '@tabler/icons-solidjs';
|
||||
|
||||
interface FilePreviewModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -27,7 +27,7 @@ export const FilePreviewModal = (props: FilePreviewModalProps) => {
|
||||
return (
|
||||
<div class="w-full h-full bg-muted p-8 rounded flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<div class="text-6xl mb-4">⚠️</div>
|
||||
<IconAlertTriangle class="size-12 mx-auto mb-4 text-muted-foreground" />
|
||||
<p class="text-lg font-medium mb-2">Preview Failed</p>
|
||||
<p class="text-muted-foreground mb-4">Unable to load preview for this file</p>
|
||||
<Button onClick={() => window.open(file.downloadUrl || '#', '_blank')}>
|
||||
@@ -68,7 +68,7 @@ export const FilePreviewModal = (props: FilePreviewModalProps) => {
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
<div class="text-center">
|
||||
<div class="text-4xl mb-2">🎵</div>
|
||||
<IconMusic class="size-8 mx-auto mb-2 text-muted-foreground" />
|
||||
<p class="font-medium">{file.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -86,7 +86,7 @@ export const FilePreviewModal = (props: FilePreviewModalProps) => {
|
||||
return (
|
||||
<div class="w-full h-full bg-muted p-8 rounded flex items-center justify-center">
|
||||
<div class="text-center max-w-md">
|
||||
<div class="text-6xl mb-4">📄</div>
|
||||
<IconFileText class="size-12 mx-auto mb-4 text-muted-foreground" />
|
||||
<p class="text-lg font-medium mb-2">PDF Document</p>
|
||||
<p class="text-muted-foreground mb-4 truncate">{file.name}</p>
|
||||
<div class="space-y-2">
|
||||
@@ -106,8 +106,8 @@ export const FilePreviewModal = (props: FilePreviewModalProps) => {
|
||||
return (
|
||||
<div class="w-full h-full bg-muted p-8 rounded flex items-center justify-center">
|
||||
<div class="text-center max-w-md">
|
||||
<div class="text-6xl mb-4">
|
||||
{file.type === 'docx' ? '📄' : file.type === 'pptx' ? '📊' : '📈'}
|
||||
<div class="size-12 mx-auto mb-4 text-muted-foreground flex items-center justify-center">
|
||||
{file.type === 'docx' ? <IconFileDescription class="size-12" /> : file.type === 'pptx' ? <IconChartBar class="size-12" /> : <IconChartLine class="size-12" />}
|
||||
</div>
|
||||
<p class="text-lg font-medium mb-2">
|
||||
{file.type === 'docx' ? 'Word Document' : file.type === 'pptx' ? 'PowerPoint Presentation' : 'Excel Spreadsheet'}
|
||||
@@ -193,7 +193,7 @@ export const FilePreviewModal = (props: FilePreviewModalProps) => {
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
{props.isOpen && (
|
||||
<div class="fixed inset-0 bg-black/50 z-40" onClick={props.onClose} />
|
||||
<div class="fixed inset-0 bg-black/50 z-40 mt-0" onClick={props.onClose} />
|
||||
)}
|
||||
|
||||
{/* Modal */}
|
||||
|
||||
@@ -0,0 +1,938 @@
|
||||
/* File Upload Styling - Match Papra Design System */
|
||||
|
||||
/* Papra color tokens for file upload */
|
||||
.bg-bg-white-0 {
|
||||
background-color: hsl(var(--background));
|
||||
}
|
||||
|
||||
.text-text-sub-600 {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.text-text-strong-950 {
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.text-text-soft-400 {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.text-text-disabled-300 {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.border-stroke-soft-200 {
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
|
||||
.border-stroke-sub-300 {
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
|
||||
.ring-stroke-soft-200 {
|
||||
--tw-ring-color: hsl(var(--border));
|
||||
}
|
||||
|
||||
.ring-stroke-strong-950 {
|
||||
--tw-ring-color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.shadow-custom-md {
|
||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
.shadow-regular-xs {
|
||||
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
}
|
||||
|
||||
.shadow-button-important-focus {
|
||||
box-shadow: 0 0 0 3px hsl(var(--ring) / 0.3);
|
||||
}
|
||||
|
||||
/* Text size utilities */
|
||||
.text-label-sm {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.text-paragraph-xs {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.text-paragraph-sm {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.text-subheading-2xs {
|
||||
font-size: 0.6875rem;
|
||||
line-height: 1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Layout utilities */
|
||||
.rounded-20 {
|
||||
border-radius: 1.25rem;
|
||||
}
|
||||
|
||||
.rounded-xl {
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.rounded-2xl {
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.rounded-lg {
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.rounded-md {
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.rounded-full {
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.rounded-10 {
|
||||
border-radius: 0.625rem;
|
||||
}
|
||||
|
||||
/* Size utilities */
|
||||
.max-w-\[440px\] {
|
||||
max-width: 440px;
|
||||
}
|
||||
|
||||
.size-10 {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
}
|
||||
|
||||
.size-5 {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
.size-6 {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.size-\[18px\] {
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
}
|
||||
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.h-full {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.h-10 {
|
||||
height: 2.5rem;
|
||||
}
|
||||
|
||||
.h-8 {
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
.h-1 {
|
||||
height: 0.25rem;
|
||||
}
|
||||
|
||||
.h-1\.5 {
|
||||
height: 0.375rem;
|
||||
}
|
||||
|
||||
.h-4 {
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.h-6 {
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.h-9 {
|
||||
height: 2.25rem;
|
||||
}
|
||||
|
||||
.h-px {
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.flex-1 {
|
||||
flex: 1 1 0%;
|
||||
}
|
||||
|
||||
.flex-shrink-0 {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Spacing utilities */
|
||||
.gap-3\.5 {
|
||||
gap: 0.875rem;
|
||||
}
|
||||
|
||||
.gap-5 {
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.gap-4 {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.gap-3 {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.gap-2\.5 {
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
.gap-2 {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.gap-1 {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.gap-px {
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.space-y-4 > :not([hidden]) ~ :not([hidden]) {
|
||||
--un-space-y-reverse: 0;
|
||||
margin-top: calc(1rem * calc(1 - var(--un-space-y-reverse)));
|
||||
}
|
||||
|
||||
.space-y-1 > :not([hidden]) ~ :not([hidden]) {
|
||||
--un-space-y-reverse: 0;
|
||||
margin-top: calc(0.25rem * calc(1 - var(--un-space-y-reverse)));
|
||||
}
|
||||
|
||||
.space-y-1\.5 > :not([hidden]) ~ :not([hidden]) {
|
||||
--un-space-y-reverse: 0;
|
||||
margin-top: calc(0.375rem * calc(1 - var(--un-space-y-reverse)));
|
||||
}
|
||||
|
||||
.my-6 {
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
/* Padding utilities */
|
||||
.p-5 {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.p-8 {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.p-4 {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.py-4 {
|
||||
padding-top: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.pl-5 {
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
|
||||
.pr-14 {
|
||||
padding-right: 3.5rem;
|
||||
}
|
||||
|
||||
.pl-3\.5 {
|
||||
padding-left: 0.875rem;
|
||||
}
|
||||
|
||||
.px-3 {
|
||||
padding-left: 0.75rem;
|
||||
padding-right: 0.75rem;
|
||||
}
|
||||
|
||||
.px-2\.5 {
|
||||
padding-left: 0.625rem;
|
||||
padding-right: 0.625rem;
|
||||
}
|
||||
|
||||
.py-0\.5 {
|
||||
padding-top: 0.125rem;
|
||||
padding-bottom: 0.125rem;
|
||||
}
|
||||
|
||||
.px-\[3px\] {
|
||||
padding-left: 3px;
|
||||
padding-right: 3px;
|
||||
}
|
||||
|
||||
/* Position utilities */
|
||||
.relative {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.absolute {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.bottom-1\.5 {
|
||||
bottom: 0.375rem;
|
||||
}
|
||||
|
||||
.left-0 {
|
||||
left: 0px;
|
||||
}
|
||||
|
||||
.right-4 {
|
||||
right: 1rem;
|
||||
}
|
||||
|
||||
.top-4 {
|
||||
top: 1rem;
|
||||
|
||||
}
|
||||
|
||||
.inset-0 {
|
||||
inset: 0px;
|
||||
}
|
||||
|
||||
.inset-x-0 {
|
||||
left: 0px;
|
||||
right: 0px;
|
||||
}
|
||||
|
||||
/* Z-index utilities */
|
||||
.z-50 {
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
/* Display utilities */
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.inline-flex {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.block {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Cursor utilities */
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cursor-text {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
/* Border utilities */
|
||||
.border {
|
||||
border-width: 1px;
|
||||
}
|
||||
|
||||
.border-dashed {
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.border-b {
|
||||
border-bottom-width: 1px;
|
||||
}
|
||||
|
||||
.divide-x > :not([hidden]) ~ :not([hidden]) {
|
||||
--un-divide-x-reverse: 0;
|
||||
border-right-width: calc(1px * calc(1 - var(--un-divide-x-reverse)));
|
||||
}
|
||||
|
||||
/* Ring utilities */
|
||||
.ring-1 {
|
||||
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
|
||||
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);
|
||||
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
|
||||
}
|
||||
|
||||
.ring-inset {
|
||||
--tw-ring-inset: inset;
|
||||
}
|
||||
|
||||
/* Background utilities */
|
||||
.bg-transparent {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.bg-none {
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
.bg-bg-weak-50 {
|
||||
background-color: hsl(var(--muted));
|
||||
}
|
||||
|
||||
.bg-bg-soft-200 {
|
||||
background-color: hsl(var(--muted));
|
||||
}
|
||||
|
||||
.bg-primary-alpha-10 {
|
||||
background-color: hsl(var(--primary) / 0.1);
|
||||
}
|
||||
|
||||
/* Color utilities */
|
||||
.text-static-white {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.text-success-base {
|
||||
color: hsl(142 76% 36%);
|
||||
}
|
||||
|
||||
.text-error-base {
|
||||
color: hsl(0 84% 60%);
|
||||
}
|
||||
|
||||
.text-warning-base {
|
||||
color: hsl(31 98% 50%);
|
||||
}
|
||||
|
||||
.text-information-base {
|
||||
color: hsl(199 89% 67%);
|
||||
}
|
||||
|
||||
.text-primary-base {
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.text-neutral-base {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.bg-success-base {
|
||||
background-color: hsl(142 76% 36%);
|
||||
}
|
||||
|
||||
.bg-error-base {
|
||||
background-color: hsl(0 84% 60%);
|
||||
}
|
||||
|
||||
.bg-warning-base {
|
||||
background-color: hsl(31 98% 50%);
|
||||
}
|
||||
|
||||
.bg-neutral-base {
|
||||
background-color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.border-primary-base {
|
||||
border-color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
/* Outline utilities */
|
||||
.outline-none {
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Transition utilities */
|
||||
.transition {
|
||||
transition: all 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.duration-200 {
|
||||
transition-duration: 200ms;
|
||||
}
|
||||
|
||||
.duration-300 {
|
||||
transition-duration: 300ms;
|
||||
}
|
||||
|
||||
.ease-out {
|
||||
transition-timing-function: cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Transform utilities */
|
||||
.translate-y-1\/2 {
|
||||
transform: translateY(50%);
|
||||
}
|
||||
|
||||
/* Animation utilities */
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-in {
|
||||
animation-name: enter;
|
||||
animation-duration: 0.15s;
|
||||
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.animate-out {
|
||||
animation-name: exit;
|
||||
animation-duration: 0.15s;
|
||||
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.fade-in-0 {
|
||||
animation-name: fadeIn;
|
||||
animation-duration: 0.15s;
|
||||
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.fade-out-0 {
|
||||
animation-name: fadeOut;
|
||||
animation-duration: 0.15s;
|
||||
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.zoom-in-95 {
|
||||
animation-name: zoomIn95;
|
||||
animation-duration: 0.15s;
|
||||
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.zoom-out-95 {
|
||||
animation-name: zoomOut95;
|
||||
animation-duration: 0.15s;
|
||||
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes zoomIn95 {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes zoomOut95 {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
/* Hover states */
|
||||
.hover\:bg-bg-weak-50:hover {
|
||||
background-color: hsl(var(--muted));
|
||||
}
|
||||
|
||||
.hover\:text-text-strong-950:hover {
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.hover\:shadow-none:hover {
|
||||
box-shadow: 0 0 #0000;
|
||||
}
|
||||
|
||||
.hover\:ring-transparent:hover {
|
||||
--tw-ring-color: transparent;
|
||||
}
|
||||
|
||||
.hover\:border-primary-base:hover {
|
||||
border-color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
/* Focus states */
|
||||
.focus\:outline-none:focus {
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.focus-visible\:bg-bg-strong-950:focus-visible {
|
||||
background-color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.focus-visible\:text-text-white-0:focus-visible {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.focus-visible\:shadow-button-important-focus:focus-visible {
|
||||
box-shadow: 0 0 0 3px hsl(var(--ring) / 0.3);
|
||||
}
|
||||
|
||||
.focus-visible\:ring-stroke-strong-950:focus-visible {
|
||||
--tw-ring-color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
/* Disabled states */
|
||||
.disabled\:pointer-events-none:disabled {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.disabled\:border-transparent:disabled {
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.disabled\:bg-transparent:disabled {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.disabled\:text-text-disabled-300:disabled {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.disabled\:shadow-none:disabled {
|
||||
box-shadow: 0 0 #0000;
|
||||
}
|
||||
|
||||
.disabled\:no-underline:disabled {
|
||||
text-decoration-line: none;
|
||||
}
|
||||
|
||||
/* Group hover states */
|
||||
.group:hover .group-hover\/input-wrapper\:placeholder\:text-text-sub-600::placeholder {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
/* Group focus states */
|
||||
.group:has(input:focus) .group-has-\[input\:focus\]\:placeholder\:text-text-sub-600::placeholder {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
/* Before pseudo-element utilities */
|
||||
.before\:absolute::before {
|
||||
content: var(--tw-content);
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.before\:inset-x-0::before {
|
||||
content: var(--tw-content);
|
||||
left: 0px;
|
||||
right: 0px;
|
||||
}
|
||||
|
||||
.before\:bottom-0::before {
|
||||
content: var(--tw-content);
|
||||
bottom: 0px;
|
||||
}
|
||||
|
||||
.before\:border-b::before {
|
||||
content: var(--tw-content);
|
||||
border-bottom-width: 1px;
|
||||
}
|
||||
|
||||
.before\:ring-1::before {
|
||||
content: var(--tw-content);
|
||||
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
|
||||
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);
|
||||
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
|
||||
}
|
||||
|
||||
.before\:ring-inset::before {
|
||||
content: var(--tw-content);
|
||||
--tw-ring-inset: inset;
|
||||
}
|
||||
|
||||
.before\:pointer-events-none::before {
|
||||
content: var(--tw-content);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.before\:rounded-\[inherit\]::before {
|
||||
content: var(--tw-content);
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.before\:transition::before {
|
||||
content: var(--tw-content);
|
||||
transition: all 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.before\:duration-200::before {
|
||||
content: var(--tw-content);
|
||||
transition-duration: 200ms;
|
||||
}
|
||||
|
||||
.before\:ease-out::before {
|
||||
content: var(--tw-content);
|
||||
transition-timing-function: cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Has selector utilities */
|
||||
.has-\[input\:focus\]\:shadow-button-important-focus:has(input:focus) {
|
||||
box-shadow: 0 0 0 3px hsl(var(--ring) / 0.3);
|
||||
}
|
||||
|
||||
.has-\[input\:focus\]\:before\:ring-stroke-strong-950:has(input:focus):before {
|
||||
--tw-ring-color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.has-\[input\:disabled\]\:shadow-none:has(input:disabled) {
|
||||
box-shadow: 0 0 #0000;
|
||||
}
|
||||
|
||||
.has-\[input\:disabled\]\:before\:ring-transparent:has(input:disabled):before {
|
||||
--tw-ring-color: transparent;
|
||||
}
|
||||
|
||||
/* Complex hover states */
|
||||
.hover\:\[&\:not\(\:has\(input\:focus\)\)\:has\(\>\\:only-child\)\]\:before\:ring-transparent:hover:not(:has(input:focus)):has(> :only-child):before {
|
||||
--tw-ring-color: transparent;
|
||||
}
|
||||
|
||||
.hover\:\[&\:not\(\:focus-within\)\]\:before\:\!ring-stroke-soft-200:hover:not(:focus-within):before {
|
||||
--tw-ring-color: hsl(var(--border)) !important;
|
||||
}
|
||||
|
||||
/* Group complex states */
|
||||
.group\/input-wrapper:hover .hover\:\[&\:not\(\&\:has\(input\:focus\)\)\]\:bg-bg-weak-50:hover:not(&:has(input:focus)) {
|
||||
background-color: hsl(var(--muted));
|
||||
}
|
||||
|
||||
.group\/input-wrapper:has(input:disabled) .has-\[input\:disabled\]\:pointer-events-none {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.group\/input-wrapper:has(input:disabled) .has-\[input\:disabled\]\:bg-bg-weak-50 {
|
||||
background-color: hsl(var(--muted));
|
||||
}
|
||||
|
||||
/* Placeholder utilities */
|
||||
.placeholder\:select-none::placeholder {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.placeholder\:text-text-soft-400::placeholder {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.placeholder\:transition::placeholder {
|
||||
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke;
|
||||
transition-duration: 200ms;
|
||||
transition-timing-function: cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.placeholder\:duration-200::placeholder {
|
||||
transition-duration: 200ms;
|
||||
}
|
||||
|
||||
.placeholder\:ease-out::placeholder {
|
||||
transition-timing-function: cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Progress bar */
|
||||
[role="progressbar"] {
|
||||
transform-origin: left;
|
||||
}
|
||||
|
||||
/* File type badges */
|
||||
.text-\[11px\] {
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
|
||||
.leading-none {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.font-semibold {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Dark mode specific styling */
|
||||
[data-kb-theme="dark"] .bg-bg-white-0 {
|
||||
background-color: hsl(var(--card));
|
||||
}
|
||||
|
||||
[data-kb-theme="dark"] .text-text-sub-600 {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
[data-kb-theme="dark"] .text-text-strong-950 {
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
[data-kb-theme="dark"] .text-text-soft-400 {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
[data-kb-theme="dark"] .border-stroke-soft-200 {
|
||||
border-color: #262626;
|
||||
}
|
||||
|
||||
[data-kb-theme="dark"] .border-stroke-sub-300 {
|
||||
border-color: #262626;
|
||||
}
|
||||
|
||||
[data-kb-theme="dark"] .shadow-custom-md {
|
||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.3), 0 2px 4px -2px rgb(0 0 0 / 0.3);
|
||||
}
|
||||
|
||||
[data-kb-theme="dark"] .shadow-regular-xs {
|
||||
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.2);
|
||||
}
|
||||
|
||||
[data-kb-theme="dark"] .hover\:bg-bg-weak-50 {
|
||||
background-color: hsl(var(--muted));
|
||||
}
|
||||
|
||||
/* File icon styling */
|
||||
.fill-bg-white-0 {
|
||||
fill: hsl(var(--background));
|
||||
}
|
||||
|
||||
.stroke-stroke-sub-300 {
|
||||
stroke: hsl(var(--border));
|
||||
}
|
||||
|
||||
/* Separator styling */
|
||||
[role="separator"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
[role="separator"]::before,
|
||||
[role="separator"]::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background-color: hsl(var(--border));
|
||||
}
|
||||
|
||||
[role="separator"]::before {
|
||||
margin-right: 0.625rem;
|
||||
}
|
||||
|
||||
[role="separator"]::after {
|
||||
margin-left: 0.625rem;
|
||||
}
|
||||
|
||||
/* Input group styling */
|
||||
.divide-stroke-soft-200 > :not([hidden]) ~ :not([hidden]) {
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
|
||||
/* Select none for icons */
|
||||
.select-none {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Items center for icons */
|
||||
.items-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.justify-center {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Whitespace utilities */
|
||||
.whitespace-nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Overflow utilities */
|
||||
.overflow-hidden {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Flex direction */
|
||||
.flex-col {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Items start */
|
||||
.items-start {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* Data state utilities */
|
||||
.data-state-open-animate-in {
|
||||
animation-name: enter;
|
||||
animation-duration: 0.15s;
|
||||
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.data-state-closed-animate-out {
|
||||
animation-name: exit;
|
||||
animation-duration: 0.15s;
|
||||
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.data-state-closed-fade-out-0 {
|
||||
animation-name: fadeOut;
|
||||
animation-duration: 0.15s;
|
||||
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.data-state-open-fade-in-0 {
|
||||
animation-name: fadeIn;
|
||||
animation-duration: 0.15s;
|
||||
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.data-state-closed-zoom-out-95 {
|
||||
animation-name: zoomOut95;
|
||||
animation-duration: 0.15s;
|
||||
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.data-state-open-zoom-in-95 {
|
||||
animation-name: zoomIn95;
|
||||
animation-duration: 0.15s;
|
||||
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Pointer events */
|
||||
.pointer-events-none {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.pointer-events-auto {
|
||||
pointer-events: auto;
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
import { createSignal, For, Show } from 'solid-js';
|
||||
import { cn } from '@/lib/utils';
|
||||
import './FileUpload.css';
|
||||
|
||||
export interface FileUploadProps {
|
||||
isOpen?: boolean;
|
||||
onClose?: () => void;
|
||||
onFilesChange?: (files: UploadedFile[]) => void;
|
||||
maxFileSize?: number; // in MB
|
||||
acceptedTypes?: string[];
|
||||
class?: string;
|
||||
}
|
||||
|
||||
export interface UploadedFile {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
type: string;
|
||||
status: 'uploading' | 'completed' | 'error';
|
||||
progress: number;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
const defaultAcceptedTypes = [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'application/pdf',
|
||||
'video/mp4'
|
||||
];
|
||||
|
||||
const defaultMaxFileSize = 50; // 50MB
|
||||
|
||||
export const FileUpload = (props: FileUploadProps) => {
|
||||
const [files, setFiles] = createSignal<UploadedFile[]>([]);
|
||||
const [isDragging, setIsDragging] = createSignal(false);
|
||||
const [urlInput, setUrlInput] = createSignal('');
|
||||
|
||||
// Generate unique ID for files
|
||||
const generateId = () => Math.random().toString(36).substr(2, 9);
|
||||
|
||||
// Format file size
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes === 0) return '0 KB';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
// Get file extension
|
||||
const getFileExtension = (filename: string) => {
|
||||
return filename.slice((filename.lastIndexOf(".") - 1 >>> 0) + 2).toUpperCase();
|
||||
};
|
||||
|
||||
// Get file icon color based on type
|
||||
const getFileTypeColor = (type: string) => {
|
||||
if (type.startsWith('image/')) return 'bg-success-base';
|
||||
if (type.startsWith('video/')) return 'bg-warning-base';
|
||||
if (type === 'application/pdf') return 'bg-error-base';
|
||||
return 'bg-neutral-base';
|
||||
};
|
||||
|
||||
// Validate file
|
||||
const validateFile = (file: File) => {
|
||||
const maxSize = (props.maxFileSize || defaultMaxFileSize) * 1024 * 1024;
|
||||
const acceptedTypes = props.acceptedTypes || defaultAcceptedTypes;
|
||||
|
||||
if (file.size > maxSize) {
|
||||
alert(`File size exceeds ${props.maxFileSize || defaultMaxFileSize}MB limit`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!acceptedTypes.includes(file.type) && !acceptedTypes.some(type => file.type.includes(type))) {
|
||||
alert('File type not supported');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Handle file selection
|
||||
const handleFileSelect = (selectedFiles: FileList | null | undefined) => {
|
||||
if (!selectedFiles) return;
|
||||
|
||||
const newFiles: UploadedFile[] = [];
|
||||
|
||||
Array.from(selectedFiles).forEach(file => {
|
||||
if (validateFile(file)) {
|
||||
const uploadedFile: UploadedFile = {
|
||||
id: generateId(),
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
status: 'uploading',
|
||||
progress: 0
|
||||
};
|
||||
newFiles.push(uploadedFile);
|
||||
}
|
||||
});
|
||||
|
||||
if (newFiles.length > 0) {
|
||||
const updatedFiles = [...files(), ...newFiles];
|
||||
setFiles(updatedFiles);
|
||||
props.onFilesChange?.(updatedFiles);
|
||||
|
||||
// Simulate upload progress
|
||||
newFiles.forEach(file => {
|
||||
simulateUpload(file.id);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Simulate file upload
|
||||
const simulateUpload = (fileId: string) => {
|
||||
let progress = 0;
|
||||
const interval = setInterval(() => {
|
||||
progress += Math.random() * 30;
|
||||
if (progress >= 100) {
|
||||
progress = 100;
|
||||
clearInterval(interval);
|
||||
|
||||
setFiles(prev => prev.map(file =>
|
||||
file.id === fileId
|
||||
? { ...file, status: 'completed', progress: 100 }
|
||||
: file
|
||||
));
|
||||
} else {
|
||||
setFiles(prev => prev.map(file =>
|
||||
file.id === fileId
|
||||
? { ...file, progress }
|
||||
: file
|
||||
));
|
||||
}
|
||||
}, 500);
|
||||
};
|
||||
|
||||
// Handle drag events
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
handleFileSelect(e.dataTransfer?.files);
|
||||
};
|
||||
|
||||
// Handle URL import
|
||||
const handleUrlImport = () => {
|
||||
const url = urlInput().trim();
|
||||
if (url) {
|
||||
// Extract filename from URL or use default
|
||||
const filename = url.split('/').pop() || 'imported-file';
|
||||
const extension = filename.includes('.') ? getFileExtension(filename) : 'PDF';
|
||||
|
||||
const newFile: UploadedFile = {
|
||||
id: generateId(),
|
||||
name: filename,
|
||||
size: 0, // Unknown size for URL imports
|
||||
type: extension === 'PDF' ? 'application/pdf' : 'application/octet-stream',
|
||||
status: 'uploading',
|
||||
progress: 0,
|
||||
url
|
||||
};
|
||||
|
||||
const updatedFiles = [...files(), newFile];
|
||||
setFiles(updatedFiles);
|
||||
props.onFilesChange?.(updatedFiles);
|
||||
|
||||
// Simulate URL import
|
||||
simulateUpload(newFile.id);
|
||||
setUrlInput('');
|
||||
}
|
||||
};
|
||||
|
||||
// Remove file
|
||||
const removeFile = (fileId: string) => {
|
||||
const updatedFiles = files().filter(file => file.id !== fileId);
|
||||
setFiles(updatedFiles);
|
||||
props.onFilesChange?.(updatedFiles);
|
||||
};
|
||||
|
||||
// Close dialog
|
||||
const handleClose = () => {
|
||||
props.onClose?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
class={cn(
|
||||
"relative w-full rounded-20 bg-bg-white-0 focus:outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 max-w-[440px] shadow-custom-md",
|
||||
props.class
|
||||
)}
|
||||
role="dialog"
|
||||
aria-labelledby="file-upload-title"
|
||||
aria-describedby="file-upload-description"
|
||||
data-state={props.isOpen ? 'open' : 'closed'}
|
||||
>
|
||||
{/* Header */}
|
||||
<div class="relative flex items-start gap-3.5 py-4 pl-5 pr-14 before:absolute before:inset-x-0 before:bottom-0 before:border-b before:border-stroke-soft-200">
|
||||
<div class="flex size-10 shrink-0 items-center justify-center rounded-full bg-bg-white-0 ring-1 ring-inset ring-stroke-soft-200">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="remixicon size-5 text-text-sub-600">
|
||||
<path d="M12 12.5858L16.2426 16.8284L14.8284 18.2426L13 16.415V22H11V16.413L9.17157 18.2426L7.75736 16.8284L12 12.5858ZM12 2C15.5934 2 18.5544 4.70761 18.9541 8.19395C21.2858 8.83154 23 10.9656 23 13.5C23 16.3688 20.8036 18.7246 18.0006 18.9776L18.0009 16.9644C19.6966 16.7214 21 15.2629 21 13.5C21 11.567 19.433 10 17.5 10C17.2912 10 17.0867 10.0183 16.8887 10.054C16.9616 9.7142 17 9.36158 17 9C17 6.23858 14.7614 4 12 4C9.23858 4 7 6.23858 7 9C7 9.36158 7.03838 9.7142 7.11205 10.0533C6.91331 10.0183 6.70879 10 6.5 10C4.567 10 3 11.567 3 13.5C3 15.2003 4.21241 16.6174 5.81986 16.934L6.00005 16.9646L6.00039 18.9776C3.19696 18.7252 1 16.3692 1 13.5C1 10.9656 2.71424 8.83154 5.04648 8.19411C5.44561 4.70761 8.40661 2 12 2Z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 space-y-1">
|
||||
<h2 id="file-upload-title" class="text-label-sm text-text-strong-950">Upload files</h2>
|
||||
<p id="file-upload-description" class="text-paragraph-xs text-text-sub-600">Select and upload the files of your choice</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Close Button */}
|
||||
<button
|
||||
class="flex shrink-0 items-center justify-center outline-none transition duration-200 ease-out disabled:pointer-events-none disabled:border-transparent disabled:bg-transparent disabled:text-text-disabled-300 disabled:shadow-none focus:outline-none bg-transparent text-text-sub-600 hover:bg-bg-weak-50 hover:text-text-strong-950 focus-visible:bg-bg-strong-950 focus-visible:text-text-white-0 size-6 rounded-md absolute right-4 top-4"
|
||||
onClick={handleClose}
|
||||
type="button"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="remixicon size-5">
|
||||
<path d="M11.9997 10.5865L16.9495 5.63672L18.3637 7.05093L13.4139 12.0007L18.3637 16.9504L16.9495 18.3646L11.9997 13.4149L7.04996 18.3646L5.63574 16.9504L10.5855 12.0007L5.63574 7.05093L7.04996 5.63672L11.9997 10.5865Z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="p-5">
|
||||
<div class="space-y-4">
|
||||
{/* Drag & Drop Area */}
|
||||
<label
|
||||
class={cn(
|
||||
"flex w-full cursor-pointer flex-col items-center gap-5 rounded-xl border border-dashed border-stroke-sub-300 bg-bg-white-0 p-8 text-center transition duration-200 ease-out hover:bg-bg-weak-50",
|
||||
isDragging() && "border-primary-base bg-primary-alpha-10"
|
||||
)}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<input
|
||||
multiple
|
||||
tabindex="-1"
|
||||
class="hidden"
|
||||
type="file"
|
||||
accept={props.acceptedTypes?.join(',')}
|
||||
onChange={(e) => handleFileSelect(e.target.files)}
|
||||
/>
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="remixicon size-6 text-text-sub-600">
|
||||
<path d="M12 12.5858L16.2426 16.8284L14.8284 18.2426L13 16.415V22H11V16.413L9.17157 18.2426L7.75736 16.8284L12 12.5858ZM12 2C15.5934 2 18.5544 4.70761 18.9541 8.19395C21.2858 8.83154 23 10.9656 23 13.5C23 16.3688 20.8036 18.7246 18.0006 18.9776L18.0009 16.9644C19.6966 16.7214 21 15.2629 21 13.5C21 11.567 19.433 10 17.5 10C17.2912 10 17.0867 10.0183 16.8887 10.054C16.9616 9.7142 17 9.36158 17 9C17 6.23858 14.7614 4 12 4C9.23858 4 7 6.23858 7 9C7 9.36158 7.03838 9.7142 7.11205 10.0533C6.91331 10.0183 6.70879 10 6.5 10C4.567 10 3 11.567 3 13.5C3 15.2003 4.21241 16.6174 5.81986 16.934L6.00005 16.9646L6.00039 18.9776C3.19696 18.7252 1 16.3692 1 13.5C1 10.9656 2.71424 8.83154 5.04648 8.19411C5.44561 4.70761 8.40661 2 12 2Z"></path>
|
||||
</svg>
|
||||
<div class="space-y-1.5">
|
||||
<div class="text-label-sm text-text-strong-950">Choose a file or drag & drop it here</div>
|
||||
<div class="text-paragraph-xs text-text-sub-600">JPEG, PNG, PDF, and MP4 formats, up to 50 MB.</div>
|
||||
</div>
|
||||
<div class="inline-flex h-8 items-center justify-center gap-2.5 whitespace-nowrap rounded-lg bg-bg-white-0 px-2.5 text-label-sm text-text-sub-600 pointer-events-none ring-1 ring-inset ring-stroke-soft-200">
|
||||
Browse File
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* File List */}
|
||||
<Show when={files().length > 0}>
|
||||
<div class="space-y-4">
|
||||
<For each={files()}>
|
||||
{(file) => (
|
||||
<div class="flex w-full flex-col gap-4 rounded-2xl border border-stroke-soft-200 p-4 pl-3.5">
|
||||
<div class="flex gap-3">
|
||||
{/* File Icon */}
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg" class="relative shrink-0 size-10">
|
||||
<path d="M30 39.25H10C7.10051 39.25 4.75 36.8995 4.75 34V6C4.75 3.10051 7.10051 0.75 10 0.75H20.5147C21.9071 0.75 23.2425 1.30312 24.227 2.28769L33.7123 11.773C34.6969 12.7575 35.25 14.0929 35.25 15.4853V34C35.25 36.8995 32.8995 39.25 30 39.25Z" class="fill-bg-white-0 stroke-stroke-sub-300" stroke-width="1.5"></path>
|
||||
<path d="M23 1V9C23 11.2091 24.7909 13 27 13H35" class="stroke-stroke-sub-300" stroke-width="1.5"></path>
|
||||
<foreignObject x="0" y="0" width="40" height="40">
|
||||
<div class={cn("absolute bottom-1.5 left-0 flex h-4 items-center rounded px-[3px] py-0.5 text-[11px] font-semibold leading-none text-static-white", getFileTypeColor(file.type))}>
|
||||
{getFileExtension(file.name)}
|
||||
</div>
|
||||
</foreignObject>
|
||||
</svg>
|
||||
|
||||
{/* File Info */}
|
||||
<div class="flex-1 space-y-1">
|
||||
<div class="text-label-sm text-text-strong-950">{file.name}</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-paragraph-xs text-text-sub-600">
|
||||
{file.status === 'uploading'
|
||||
? `${formatFileSize(file.size * file.progress / 100)} of ${formatFileSize(file.size)}`
|
||||
: formatFileSize(file.size)
|
||||
}
|
||||
</span>
|
||||
<span class="text-paragraph-xs text-text-sub-600">∙</span>
|
||||
|
||||
{/* Status Icon */}
|
||||
<Show when={file.status === 'uploading'}>
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="remixicon size-4 shrink-0 animate-spin text-primary-base">
|
||||
<path d="M12 2C12.5523 2 13 2.44772 13 3V6C13 6.55228 12.5523 7 12 7C11.4477 7 11 6.55228 11 6V3C11 2.44772 11.4477 2 12 2ZM12 17C12.5523 17 13 17.4477 13 18V21C13 21.5523 12.5523 22 12 22C11.4477 22 11 21.5523 11 21V18C11 17.4477 11.4477 17 12 17ZM22 12C22 12.5523 21.5523 13 21 13H18C17.4477 13 17 12.5523 17 12C17 11.4477 17.4477 11 18 11H21C21.5523 11 22 11.4477 22 12ZM7 12C7 12.5523 6.55228 13 6 13H3C2.44772 13 2 12.5523 2 12C2 11.4477 2.44772 11 3 11H6C6.55228 11 7 11.4477 7 12ZM19.0711 19.0711C18.6805 19.4616 18.0474 19.4616 17.6569 19.0711L15.5355 16.9497C15.145 16.5592 15.145 15.9261 15.5355 15.5355C15.9261 15.145 16.5592 15.145 16.9497 15.5355L19.0711 17.6569C19.4616 18.0474 19.4616 18.6805 19.0711 19.0711ZM8.46447 8.46447C8.07394 8.85499 7.44078 8.85499 7.05025 8.46447L4.92893 6.34315C4.53841 5.95262 4.53841 5.31946 4.92893 4.92893C5.31946 4.53841 5.95262 4.53841 6.34315 4.92893L8.46447 7.05025C8.85499 7.44078 8.85499 8.07394 8.46447 8.46447ZM4.92893 19.0711C4.53841 18.6805 4.53841 18.0474 4.92893 17.6569L7.05025 15.5355C7.44078 15.145 8.07394 15.145 8.46447 15.5355C8.85499 15.9261 8.85499 16.5592 8.46447 16.9497L6.34315 19.0711C5.95262 19.4616 5.31946 19.4616 4.92893 19.0711ZM15.5355 8.46447C15.145 8.07394 15.145 7.44078 15.5355 7.05025L17.6569 4.92893C18.0474 4.53841 18.6805 4.53841 19.0711 4.92893C19.4616 5.31946 19.4616 5.95262 19.0711 6.34315L16.9497 8.46447C16.5592 8.85499 15.9261 8.85499 15.5355 8.46447Z"></path>
|
||||
</svg>
|
||||
<span class="text-paragraph-xs text-text-strong-950">Uploading...</span>
|
||||
</Show>
|
||||
|
||||
<Show when={file.status === 'completed'}>
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="remixicon size-4 shrink-0 text-success-base">
|
||||
<path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM11.0026 16L18.0737 8.92893L16.6595 7.51472L11.0026 13.1716L8.17421 10.3431L6.75999 11.7574L11.0026 16Z"></path>
|
||||
</svg>
|
||||
<span class="text-paragraph-xs text-text-strong-950">Completed</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Remove Button */}
|
||||
<button
|
||||
class="relative flex shrink-0 items-center justify-center outline-none transition duration-200 ease-out disabled:pointer-events-none disabled:border-transparent disabled:bg-transparent disabled:text-text-disabled-300 disabled:shadow-none focus:outline-none bg-transparent text-text-sub-600 hover:bg-bg-weak-50 hover:text-text-strong-950 focus-visible:bg-bg-strong-950 focus-visible:text-text-white-0 size-5 rounded-md"
|
||||
onClick={() => removeFile(file.id)}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="remixicon size-[18px]">
|
||||
<path d="M11.9997 10.5865L16.9495 5.63672L18.3637 7.05093L13.4139 12.0007L18.3637 16.9504L16.9495 18.3646L11.9997 13.4149L7.04996 18.3646L5.63574 16.9504L10.5855 12.0007L5.63574 7.05093L7.04996 5.63672L11.9997 10.5865Z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<Show when={file.status === 'uploading'}>
|
||||
<div class="h-1.5 w-full rounded-full bg-bg-soft-200">
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-300 ease-out bg-information-base"
|
||||
role="progressbar"
|
||||
aria-valuenow={file.progress}
|
||||
aria-valuemax="100"
|
||||
style={{ width: `${file.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Separator */}
|
||||
<div role="separator" class="relative flex w-full items-center gap-2.5 text-subheading-2xs text-text-soft-400 before:h-px before:w-full before:flex-1 before:bg-stroke-soft-200 after:h-px after:w-full after:flex-1 after:bg-stroke-soft-200 my-6">
|
||||
OR
|
||||
</div>
|
||||
|
||||
{/* URL Import */}
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="group cursor-pointer flex items-center gap-px aria-disabled:text-text-disabled-300 text-label-sm text-text-strong-950">
|
||||
Import from URL Link
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none" viewBox="0 0 20 20" class="size-5 text-text-disabled-300">
|
||||
<path fill="currentColor" fill-rule="evenodd" d="M10 16.25a6.25 6.25 0 100-12.5 6.25 6.25 0 000 12.5zm1.116-3.041l.1-.408a1.709 1.709 0 01-.25.083 1.176 1.176 0 01-.308.048c-.193 0-.329-.032-.407-.095-.079-.064-.118-.184-.118-.359a3.514 3.514 0 01.118-.672l.373-1.318c.037-.121.062-.255.075-.4a3.73 3.73 0 00.02-.304.866.866 0 00-.292-.678c-.195-.174-.473-.26-.833-.26-.2 0-.412.035-.636.106a9.37 9.37 0 00-.704.256l-.1.409a3.49 3.49 0 01.262-.087c.101-.03.2-.045.297-.045.198 0 .331.034.4.1.07.066.105.185.105.354 0 .093-.01.197-.034.31a6.216 6.216 0 01-.084.36l-.374 1.325c-.033.14-.058.264-.073.374a2.42 2.42 0 00-.022.325c0 .272.1.496.301.673.201.177.483.265.846.265.236 0 .443-.03.621-.092s.417-.152.717-.27zM11.05 7.85a.772.772 0 00.26-.587.78.78 0 00-.26-.59.885.885 0 00-.628-.244.893.893 0 00-.63.244.778.778 0 00-.264.59c0 .23.088.426.263.587a.897.897 0 00.63.243.888.888 0 00.629-.243z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</label>
|
||||
|
||||
<div class="group relative flex overflow-hidden bg-bg-white-0 text-text-strong-950 shadow-regular-xs transition duration-200 ease-out divide-x divide-stroke-soft-200 before:absolute before:inset-0 before:ring-1 before:ring-inset before:ring-stroke-soft-200 before:pointer-events-none before:rounded-[inherit] before:transition before:duration-200 before:ease-out hover:shadow-none has-[input:focus]:shadow-button-important-focus has-[input:focus]:before:ring-stroke-strong-950 has-[input:disabled]:shadow-none has-[input:disabled]:before:ring-transparent rounded-10 hover:[&:not(:has(input:focus)):has(>:only-child)]:before:ring-transparent w-full">
|
||||
<label class="group/input-wrapper flex w-full cursor-text items-center bg-bg-white-0 transition duration-200 ease-out hover:[&:not(&:has(input:focus))]:bg-bg-weak-50 has-[input:disabled]:pointer-events-none has-[input:disabled]:bg-bg-weak-50 gap-2 px-3">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="remixicon flex size-5 shrink-0 select-none items-center justify-center transition duration-200 ease-out group-has-[:placeholder-shown]:text-text-soft-400 text-text-sub-600 group-has-[:placeholder-shown]:group-hover/input-wrapper:text-text-sub-600 group-has-[:placeholder-shown]:group-has-[input:focus]/input-wrapper:text-text-sub-600 group-has-[input:disabled]/input-wrapper:text-text-disabled-300">
|
||||
<path d="M13.0607 8.11097L14.4749 9.52518C17.2086 12.2589 17.2086 16.691 14.4749 19.4247L14.1214 19.7782C11.3877 22.5119 6.95555 22.5119 4.22188 19.7782C1.48821 17.0446 1.48821 12.6124 4.22188 9.87874L5.6361 11.293C3.68348 13.2456 3.68348 16.4114 5.6361 18.364C7.58872 20.3166 10.7545 20.3166 12.7072 18.364L13.0607 18.0105C15.0133 16.0578 15.0133 12.892 13.0607 10.9394L11.6465 9.52518L13.0607 8.11097ZM19.7782 14.1214L18.364 12.7072C20.3166 10.7545 20.3166 7.58872 18.364 5.6361C16.4114 3.68348 13.2456 3.68348 11.293 5.6361L10.9394 5.98965C8.98678 7.94227 8.98678 11.1081 10.9394 13.0607L12.3536 14.4749L10.9394 15.8891L9.52518 14.4749C6.79151 11.7413 6.79151 7.30911 9.52518 4.57544L9.87874 4.22188C12.6124 1.48821 17.0446 1.48821 19.7782 4.22188C22.5119 6.95555 22.5119 11.3877 19.7782 14.1214Z"></path>
|
||||
</svg>
|
||||
<input
|
||||
class="w-full bg-transparent bg-none text-paragraph-sm text-text-strong-950 outline-none transition duration-200 ease-out placeholder:select-none placeholder:text-text-soft-400 placeholder:transition placeholder:duration-200 placeholder:ease-out group-hover/input-wrapper:placeholder:text-text-sub-600 focus:outline-none group-has-[input:focus]:placeholder:text-text-sub-600 disabled:text-text-disabled-300 disabled:placeholder:text-text-disabled-300 h-10"
|
||||
placeholder="Paste file URL"
|
||||
type="text"
|
||||
value={urlInput()}
|
||||
onInput={(e) => setUrlInput(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleUrlImport()}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -155,7 +155,7 @@ export const FileUploadModal = (props: FileUploadModalProps) => {
|
||||
return (
|
||||
<Show when={props.isOpen}>
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 mt-0"
|
||||
onClick={props.onClose}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -325,16 +325,15 @@ export const GitHubActivity = (props: GitHubActivityProps) => {
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Month labels - More visible and responsive */}
|
||||
<div class="flex justify-between mb-3 px-8 text-sm font-medium">
|
||||
{getMonthLabels().map((month, index) => (
|
||||
<span
|
||||
class="text-foreground/80 hover:text-foreground transition-colors cursor-default"
|
||||
style={index % 2 === 0 ? "" : "visibility: hidden;"}
|
||||
>
|
||||
{month}
|
||||
</span>
|
||||
))}
|
||||
{/* Month labels - Show all months with responsive spacing */}
|
||||
<div class="flex justify-between mb-3 px-6 sm:px-8 text-xs sm:text-sm font-medium overflow-x-auto">
|
||||
<div class="flex gap-2 sm:gap-3 min-w-max">
|
||||
{getMonthLabels().map((month) => (
|
||||
<span class="text-foreground/80 hover:text-foreground transition-colors cursor-default whitespace-nowrap">
|
||||
{month}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contribution grid - Responsive and prevents overflow */}
|
||||
@@ -352,7 +351,7 @@ export const GitHubActivity = (props: GitHubActivityProps) => {
|
||||
</div>
|
||||
|
||||
{/* Weekly columns - Responsive with proper overflow handling */}
|
||||
<div class="flex gap-1 overflow-x-auto overflow-y-hidden min-w-0">
|
||||
<div class="flex gap-1 overflow-x-auto overflow-y-hidden min-w-0 pb-2">
|
||||
{Array.from({ length: 53 }, (_, weekIndex) => (
|
||||
<div class="flex flex-col gap-1 flex-shrink-0">
|
||||
{Array.from({ length: 7 }, (_, dayIndex) => {
|
||||
@@ -362,7 +361,7 @@ export const GitHubActivity = (props: GitHubActivityProps) => {
|
||||
if (!activity) {
|
||||
return (
|
||||
<div
|
||||
class="w-2 h-2 sm:w-3 sm:h-3 rounded-sm flex-shrink-0"
|
||||
class="w-2.5 h-2.5 sm:w-3 sm:h-3 rounded-sm flex-shrink-0 transition-all"
|
||||
style={`background-color: ${getActivityColor(0)}`}
|
||||
></div>
|
||||
);
|
||||
@@ -370,7 +369,7 @@ export const GitHubActivity = (props: GitHubActivityProps) => {
|
||||
|
||||
return (
|
||||
<div
|
||||
class="w-2 h-2 sm:w-3 sm:h-3 rounded-sm hover:ring-1 hover:ring-primary cursor-pointer transition-all flex-shrink-0"
|
||||
class="w-2.5 h-2.5 sm:w-3 sm:h-3 rounded-sm hover:ring-1 hover:ring-primary cursor-pointer transition-all flex-shrink-0 hover:scale-110"
|
||||
style={`background-color: ${getActivityColor(activity.level)}`}
|
||||
title={`${activity.date}: ${activity.count} contributions`}
|
||||
></div>
|
||||
@@ -388,7 +387,7 @@ export const GitHubActivity = (props: GitHubActivityProps) => {
|
||||
<div class="flex gap-1">
|
||||
{[0, 1, 2, 3, 4].map((level) => (
|
||||
<div
|
||||
class="w-2 h-2 sm:w-3 sm:h-3 rounded-sm"
|
||||
class="w-2.5 h-2.5 sm:w-3 sm:h-3 rounded-sm"
|
||||
style={`background-color: ${getActivityColor(level)}`}
|
||||
></div>
|
||||
))}
|
||||
|
||||
@@ -100,7 +100,7 @@ export const LearningPathModal = (props: LearningPathModalProps) => {
|
||||
if (!props.isOpen) return null;
|
||||
|
||||
return (
|
||||
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 mt-0">
|
||||
<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]">
|
||||
|
||||
@@ -82,7 +82,7 @@ export const LearningPathPreviewModal = (props: LearningPathPreviewModalProps) =
|
||||
if (!props.isOpen || !props.learningPath) return null;
|
||||
|
||||
return (
|
||||
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 mt-0">
|
||||
<div class="bg-[#1a1a1a] rounded-lg w-full max-w-4xl max-h-[90vh] overflow-y-auto mx-4 my-4">
|
||||
{/* Header */}
|
||||
<div class="relative">
|
||||
|
||||
@@ -58,7 +58,7 @@ export const MemberModal = (props: MemberModalProps) => {
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
{props.isOpen && (
|
||||
<div class="fixed inset-0 bg-black/50 z-40" onClick={props.onClose} />
|
||||
<div class="fixed inset-0 bg-black/50 z-40 mt-0" onClick={props.onClose} />
|
||||
)}
|
||||
|
||||
{/* Modal */}
|
||||
|
||||
@@ -37,7 +37,7 @@ export const NoteModal = (props: NoteModalProps) => {
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
{props.isOpen && (
|
||||
<div class="fixed inset-0 bg-black/50 z-40" onClick={props.onClose} />
|
||||
<div class="fixed inset-0 bg-black/50 z-40 mt-0" onClick={props.onClose} />
|
||||
)}
|
||||
|
||||
{/* Modal */}
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
IconLink,
|
||||
IconPhoto,
|
||||
IconPaperclip,
|
||||
IconEye
|
||||
IconEye,
|
||||
IconCheckbox
|
||||
} from '@tabler/icons-solidjs';
|
||||
|
||||
interface RichTextEditorProps {
|
||||
@@ -95,6 +96,8 @@ export const RichTextEditor = (props: RichTextEditorProps) => {
|
||||
.replace(/\*\*(.*)\*\*/gim, '<strong>$1</strong>')
|
||||
.replace(/\*(.*)\*/gim, '<em>$1</em>')
|
||||
.replace(/`(.*)`/gim, '<code>$1</code>')
|
||||
.replace(/^- \[ \] (.*)$/gim, '<div class="flex items-center gap-2"><input type="checkbox" class="rounded" readonly><span>$1</span></div>')
|
||||
.replace(/^- \[x\] (.*)$/gim, '<div class="flex items-center gap-2"><input type="checkbox" checked class="rounded" readonly><span>$1</span></div>')
|
||||
.replace(/\n/gim, '<br>');
|
||||
}
|
||||
|
||||
@@ -106,7 +109,9 @@ export const RichTextEditor = (props: RichTextEditorProps) => {
|
||||
return props.value
|
||||
.replace(/\n/gim, '<br>')
|
||||
.replace(/\*\*(.*?)\*\*/gim, '<strong>$1</strong>')
|
||||
.replace(/\*(.*?)\*/gim, '<em>$1</em>');
|
||||
.replace(/\*(.*?)\*/gim, '<em>$1</em>')
|
||||
.replace(/^- \[ \] (.*)$/gim, '<div class="flex items-center gap-2"><input type="checkbox" class="rounded" readonly><span>$1</span></div>')
|
||||
.replace(/^- \[x\] (.*)$/gim, '<div class="flex items-center gap-2"><input type="checkbox" checked class="rounded" readonly><span>$1</span></div>');
|
||||
};
|
||||
|
||||
const toolbarButtons = [
|
||||
@@ -117,6 +122,7 @@ export const RichTextEditor = (props: RichTextEditorProps) => {
|
||||
{ icon: IconHeading, action: () => insertText('## ', ''), title: 'Heading' },
|
||||
{ icon: IconList, action: () => insertText('- '), title: 'Bullet List' },
|
||||
{ icon: IconListNumbers, action: () => insertText('1. '), title: 'Numbered List' },
|
||||
{ icon: IconCheckbox, action: () => insertText('- [ ] '), title: 'Checkbox' },
|
||||
{ icon: IconQuote, action: () => insertText('> '), title: 'Quote' },
|
||||
{ icon: IconCode, action: () => insertText('`', '`'), title: 'Code' },
|
||||
{ icon: IconLink, action: () => insertText('[', '](url)'), title: 'Link' },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createSignal, onMount, onCleanup, Show } from 'solid-js';
|
||||
import { createSignal, Show, createMemo } from 'solid-js';
|
||||
import {
|
||||
IconRefresh,
|
||||
IconCheck,
|
||||
@@ -6,140 +6,78 @@ import {
|
||||
IconDownload,
|
||||
IconLoader2
|
||||
} from '@tabler/icons-solidjs';
|
||||
import { updateService, type UpdateInfo, type UpdateStatus } from '../../services/updateService';
|
||||
import { updateStore } from '../../stores/updateStore';
|
||||
|
||||
interface UpdateCheckerProps {
|
||||
class?: string;
|
||||
}
|
||||
|
||||
export function UpdateChecker(props: UpdateCheckerProps) {
|
||||
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 [showUpdateModal, setShowUpdateModal] = createSignal(false);
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
const [currentVersion, setCurrentVersion] = createSignal('1.0.0');
|
||||
|
||||
let pollCleanup: (() => void) | null = null;
|
||||
// Initialize update store
|
||||
updateStore.ensureInitialized();
|
||||
|
||||
const checkForUpdates = async () => {
|
||||
setIsChecking(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await updateService.checkForUpdates();
|
||||
setUpdateAvailable(response.updateAvailable);
|
||||
setUpdateInfo(response.updateInfo || null);
|
||||
setCurrentVersion(response.currentVersion);
|
||||
|
||||
// Save last check time
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const installUpdate = async () => {
|
||||
if (!updateInfo()) return;
|
||||
|
||||
try {
|
||||
setError(null);
|
||||
await updateService.installUpdate(updateInfo()!.version);
|
||||
|
||||
// Start polling for progress
|
||||
pollCleanup = updateService.pollUpdateProgress((progress: UpdateStatus) => {
|
||||
setUpdateStatus(progress);
|
||||
|
||||
if (progress.completed) {
|
||||
setShowUpdateModal(false);
|
||||
// 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');
|
||||
}
|
||||
const installUpdate = () => {
|
||||
updateStore.installUpdate();
|
||||
};
|
||||
|
||||
const cancelUpdate = () => {
|
||||
if (pollCleanup) {
|
||||
pollCleanup();
|
||||
pollCleanup = null;
|
||||
}
|
||||
updateStore.cancelUpdate();
|
||||
setShowUpdateModal(false);
|
||||
setUpdateStatus({
|
||||
available: updateAvailable(),
|
||||
downloading: false,
|
||||
installing: false,
|
||||
completed: false,
|
||||
progress: 0
|
||||
});
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
// Set current version
|
||||
setCurrentVersion(updateService.getCurrentVersion());
|
||||
// Create reactive computed values
|
||||
const buttonClasses = createMemo(() => {
|
||||
const updateAvailable = updateStore.updateAvailable();
|
||||
const updateStatus = updateStore.updateStatus();
|
||||
const error = updateStore.error();
|
||||
|
||||
// Check for updates periodically (every 24 hours)
|
||||
const intervalId = setInterval(checkForUpdates, 24 * 60 * 60 * 1000);
|
||||
|
||||
// Check if last check was more than 24 hours ago
|
||||
const lastCheckTime = localStorage.getItem('lastUpdateCheck');
|
||||
const now = Date.now();
|
||||
const twentyFourHours = 24 * 60 * 60 * 1000;
|
||||
|
||||
if (!lastCheckTime || (now - parseInt(lastCheckTime)) > twentyFourHours) {
|
||||
// Check for updates on component mount if it's been more than 24 hours
|
||||
checkForUpdates();
|
||||
localStorage.setItem('lastUpdateCheck', now.toString());
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
clearInterval(intervalId);
|
||||
if (pollCleanup) {
|
||||
pollCleanup();
|
||||
}
|
||||
});
|
||||
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 = () => {
|
||||
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" />;
|
||||
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 = () => {
|
||||
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';
|
||||
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';
|
||||
};
|
||||
|
||||
@@ -148,25 +86,22 @@ export function UpdateChecker(props: UpdateCheckerProps) {
|
||||
<div class={`flex flex-col gap-2 ${props.class || ''}`}>
|
||||
{/* Current Version Display */}
|
||||
<div class="text-xs text-muted-foreground px-2 text-center">
|
||||
Version {currentVersion()}
|
||||
Version {updateStore.currentVersion()}
|
||||
</div>
|
||||
|
||||
{/* Check Updates Button */}
|
||||
<button
|
||||
onClick={() => updateAvailable() ? setShowUpdateModal(true) : 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={{
|
||||
"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()
|
||||
onClick={() => {
|
||||
const updateAvailable = updateStore.updateAvailable();
|
||||
if (updateAvailable) {
|
||||
setShowUpdateModal(true);
|
||||
} else {
|
||||
updateStore.checkForUpdates();
|
||||
}
|
||||
}}
|
||||
disabled={isChecking() || updateStatus().downloading || updateStatus().installing}
|
||||
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()}
|
||||
@@ -179,8 +114,8 @@ export function UpdateChecker(props: UpdateCheckerProps) {
|
||||
</div>
|
||||
|
||||
{/* Update Modal */}
|
||||
<Show when={showUpdateModal() && updateInfo()}>
|
||||
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<Show when={showUpdateModal() && updateStore.updateInfo()}>
|
||||
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4 mt-0">
|
||||
<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">
|
||||
@@ -192,53 +127,59 @@ export function UpdateChecker(props: UpdateCheckerProps) {
|
||||
<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">{currentVersion()}</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">{updateInfo()!.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">
|
||||
{updateInfo()!.releaseNotes}
|
||||
{updateStore.updateInfo()!.releaseNotes}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center text-sm">
|
||||
<span class="text-muted-foreground">Download Size</span>
|
||||
<span>{updateInfo()!.size}</span>
|
||||
<span>{updateStore.updateInfo()!.size}</span>
|
||||
</div>
|
||||
|
||||
<Show when={updateStatus().downloading || updateStatus().installing}>
|
||||
<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">
|
||||
{updateStatus().downloading ? 'Downloading' : 'Installing'}
|
||||
{(() => {
|
||||
const updateStatus = updateStore.updateStatus();
|
||||
return updateStatus.downloading ? 'Downloading' : 'Installing';
|
||||
})()}
|
||||
</span>
|
||||
<span>{Math.round(updateStatus().progress)}%</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: `${updateStatus().progress}%` }}
|
||||
style={{ width: `${updateStore.updateStatus().progress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={error()}>
|
||||
<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>{error()}</span>
|
||||
<span>{updateStore.error()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={updateStatus().completed}>
|
||||
<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" />
|
||||
@@ -249,7 +190,10 @@ export function UpdateChecker(props: UpdateCheckerProps) {
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 mt-6">
|
||||
<Show when={!updateStatus().downloading && !updateStatus().installing && !updateStatus().completed}>
|
||||
<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"
|
||||
@@ -258,17 +202,30 @@ export function UpdateChecker(props: UpdateCheckerProps) {
|
||||
</button>
|
||||
<button
|
||||
onClick={installUpdate}
|
||||
disabled={updateStatus().downloading || updateStatus().installing}
|
||||
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={updateStatus().downloading || updateStatus().installing}>
|
||||
<Show when={(() => {
|
||||
const updateStatus = updateStore.updateStatus();
|
||||
return updateStatus.downloading || updateStatus.installing;
|
||||
})()}>
|
||||
<IconLoader2 class="size-4 animate-spin" />
|
||||
</Show>
|
||||
{updateStatus().downloading || updateStatus().installing ? 'Installing...' : 'Install Update'}
|
||||
{(() => {
|
||||
const updateStatus = updateStore.updateStatus();
|
||||
return updateStatus.downloading || updateStatus.installing ? 'Installing...' : 'Install Update';
|
||||
})()}
|
||||
</button>
|
||||
</Show>
|
||||
|
||||
<Show when={updateStatus().downloading || updateStatus().installing || error()}>
|
||||
<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"
|
||||
@@ -277,7 +234,7 @@ export function UpdateChecker(props: UpdateCheckerProps) {
|
||||
</button>
|
||||
</Show>
|
||||
|
||||
<Show when={updateStatus().completed}>
|
||||
<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"
|
||||
|
||||
@@ -1,299 +0,0 @@
|
||||
import { createSignal, onMount, Show, For } from 'solid-js'
|
||||
import {
|
||||
IconDownload,
|
||||
IconRefresh,
|
||||
IconX,
|
||||
IconCheck,
|
||||
IconAlertTriangle,
|
||||
IconLoader2
|
||||
} from '@tabler/icons-solidjs'
|
||||
|
||||
interface UpdateInfo {
|
||||
version: string
|
||||
releaseNotes: string
|
||||
downloadUrl: string
|
||||
mandatory: boolean
|
||||
size: string
|
||||
}
|
||||
|
||||
interface UpdateStatus {
|
||||
available: boolean
|
||||
downloading: boolean
|
||||
installing: boolean
|
||||
completed: boolean
|
||||
error: string | null
|
||||
progress: number
|
||||
}
|
||||
|
||||
export function UpdateNotification() {
|
||||
const [updateInfo, setUpdateInfo] = createSignal<UpdateInfo | null>(null)
|
||||
const [updateStatus, setUpdateStatus] = createSignal<UpdateStatus>({
|
||||
available: false,
|
||||
downloading: false,
|
||||
installing: false,
|
||||
completed: false,
|
||||
error: null,
|
||||
progress: 0
|
||||
})
|
||||
const [dismissed, setDismissed] = createSignal(false)
|
||||
const [expanded, setExpanded] = createSignal(false)
|
||||
|
||||
onMount(() => {
|
||||
checkForUpdates()
|
||||
// Check for updates every 30 minutes
|
||||
const interval = setInterval(checkForUpdates, 30 * 60 * 1000)
|
||||
return () => clearInterval(interval)
|
||||
})
|
||||
|
||||
const checkForUpdates = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/updates/check')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
if (data.updateAvailable) {
|
||||
setUpdateInfo(data.updateInfo)
|
||||
setUpdateStatus(prev => ({ ...prev, available: true }))
|
||||
setDismissed(false)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check for updates:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const startUpdate = async () => {
|
||||
if (!updateInfo()) return
|
||||
|
||||
setUpdateStatus(prev => ({
|
||||
...prev,
|
||||
downloading: true,
|
||||
error: null,
|
||||
progress: 0
|
||||
}))
|
||||
|
||||
try {
|
||||
// Start the update process
|
||||
const response = await fetch('/api/updates/install', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ version: updateInfo()?.version })
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to start update')
|
||||
}
|
||||
|
||||
// Monitor progress
|
||||
monitorUpdateProgress()
|
||||
} catch (error) {
|
||||
setUpdateStatus(prev => ({
|
||||
...prev,
|
||||
downloading: false,
|
||||
error: error instanceof Error ? error.message : 'Update failed'
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
const monitorUpdateProgress = async () => {
|
||||
const progressInterval = setInterval(async () => {
|
||||
try {
|
||||
const response = await fetch('/api/updates/progress')
|
||||
if (response.ok) {
|
||||
const progress = await response.json()
|
||||
|
||||
setUpdateStatus(prev => ({
|
||||
...prev,
|
||||
progress: progress.progress,
|
||||
installing: progress.installing,
|
||||
downloading: progress.downloading
|
||||
}))
|
||||
|
||||
if (progress.completed) {
|
||||
clearInterval(progressInterval)
|
||||
setUpdateStatus(prev => ({ ...prev, completed: true }))
|
||||
|
||||
// Reload page after a short delay to show completion
|
||||
setTimeout(() => {
|
||||
window.location.reload()
|
||||
}, 2000)
|
||||
} else if (progress.error) {
|
||||
clearInterval(progressInterval)
|
||||
setUpdateStatus(prev => ({
|
||||
...prev,
|
||||
downloading: false,
|
||||
installing: false,
|
||||
error: progress.error
|
||||
}))
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
clearInterval(progressInterval)
|
||||
setUpdateStatus(prev => ({
|
||||
...prev,
|
||||
downloading: false,
|
||||
installing: false,
|
||||
error: 'Failed to monitor update progress'
|
||||
}))
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
const dismiss = () => {
|
||||
setDismissed(true)
|
||||
if (!updateInfo()?.mandatory) {
|
||||
setUpdateStatus(prev => ({ ...prev, available: false }))
|
||||
}
|
||||
}
|
||||
|
||||
const status = updateStatus()
|
||||
const info = updateInfo()
|
||||
|
||||
return (
|
||||
<Show when={status.available && !dismissed()}>
|
||||
<div class="border-b border-border bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-950/20 dark:to-indigo-950/20">
|
||||
<div class="px-4 py-3">
|
||||
<div class="flex items-start gap-3">
|
||||
{/* Icon */}
|
||||
<div class="flex-shrink-0 mt-0.5">
|
||||
<Show
|
||||
when={status.completed}
|
||||
fallback={
|
||||
<Show
|
||||
when={status.error}
|
||||
fallback={
|
||||
<Show
|
||||
when={status.downloading || status.installing}
|
||||
fallback={<IconDownload class="size-5 text-blue-600 dark:text-blue-400 animate-pulse" />}
|
||||
>
|
||||
<IconLoader2 class="size-5 text-blue-600 dark:text-blue-400 animate-spin" />
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<IconAlertTriangle class="size-5 text-red-600 dark:text-red-400" />
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<IconCheck class="size-5 text-green-600 dark:text-green-400" />
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<Show
|
||||
when={status.completed}
|
||||
fallback={
|
||||
<Show
|
||||
when={status.error}
|
||||
fallback={
|
||||
<p class="text-sm font-medium text-blue-900 dark:text-blue-100">
|
||||
New version {info?.version} available
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<p class="text-sm font-medium text-red-900 dark:text-red-100">
|
||||
Update failed
|
||||
</p>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<p class="text-sm font-medium text-green-900 dark:text-green-100">
|
||||
Update completed! Reloading...
|
||||
</p>
|
||||
</Show>
|
||||
|
||||
<Show when={!status.completed && !status.error}>
|
||||
<p class="text-xs text-blue-700 dark:text-blue-300 mt-1">
|
||||
{info?.size} • {info?.mandatory ? 'Required update' : 'Optional update'}
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<Show when={!status.completed && !status.error && !status.downloading}>
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded())}
|
||||
class="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-200"
|
||||
>
|
||||
{expanded() ? 'Hide' : 'Details'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={startUpdate}
|
||||
class="inline-flex items-center gap-1 px-3 py-1 text-xs font-medium bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<IconDownload class="size-3" />
|
||||
Update Now
|
||||
</button>
|
||||
</Show>
|
||||
|
||||
<Show when={status.downloading || status.installing}>
|
||||
<div class="text-xs text-blue-600 dark:text-blue-400">
|
||||
{status.installing ? 'Installing...' : `Downloading... ${Math.round(status.progress)}%`}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={status.error}>
|
||||
<button
|
||||
onClick={startUpdate}
|
||||
class="inline-flex items-center gap-1 px-3 py-1 text-xs font-medium bg-red-600 text-white rounded-md hover:bg-red-700 transition-colors"
|
||||
>
|
||||
<IconRefresh class="size-3" />
|
||||
Retry
|
||||
</button>
|
||||
</Show>
|
||||
|
||||
<Show when={!info?.mandatory}>
|
||||
<button
|
||||
onClick={dismiss}
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<IconX class="size-4" />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<Show when={status.downloading || status.installing}>
|
||||
<div class="mt-2">
|
||||
<div class="w-full bg-blue-100 dark:bg-blue-900/30 rounded-full h-1.5">
|
||||
<div
|
||||
class="bg-blue-600 h-1.5 rounded-full transition-all duration-300 ease-out"
|
||||
style={{ width: `${status.progress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Expanded Details */}
|
||||
<Show when={expanded() && info}>
|
||||
<div class="mt-3 p-3 bg-white/50 dark:bg-black/20 rounded-md border border-blue-200 dark:border-blue-800">
|
||||
<h4 class="text-sm font-medium text-blue-900 dark:text-blue-100 mb-2">
|
||||
What's new in {info?.version}
|
||||
</h4>
|
||||
<div class="text-xs text-blue-800 dark:text-blue-200 space-y-1">
|
||||
<For each={info?.releaseNotes.split('\n').filter(line => line.trim()) || []}>
|
||||
{(line) => <p>• {line}</p>}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Error Message */}
|
||||
<Show when={status.error}>
|
||||
<div class="mt-2 p-2 bg-red-50 dark:bg-red-950/20 rounded-md border border-red-200 dark:border-red-800">
|
||||
<p class="text-xs text-red-800 dark:text-red-200">
|
||||
{status.error}
|
||||
</p>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -89,13 +89,13 @@ export const UploadModal = (props: UploadModalProps) => {
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
{props.isOpen && (
|
||||
<div class="fixed inset-0 bg-black/50 z-40" onClick={props.onClose} />
|
||||
<div class="fixed inset-0 bg-black/50 z-[60] mt-0" onClick={props.onClose} />
|
||||
)}
|
||||
|
||||
{/* Modal */}
|
||||
<div class={`fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-card border border-border rounded-lg shadow-xl transition-all duration-300 z-50 ${
|
||||
<div class={`fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-card border border-border rounded-lg shadow-xl transition-all duration-300 z-[70] ${
|
||||
props.isOpen ? 'opacity-100 scale-100' : 'opacity-0 scale-95 pointer-events-none'
|
||||
}`} style="width: 600px; max-width: 90vw; max-height: 80vh; overflow-y: auto;">
|
||||
}`} style="width: min(600px, 90vw); max-height: min(80vh, 600px); overflow-y: auto;">
|
||||
{/* Header */}
|
||||
<div class="flex items-center justify-between p-6 border-b border-border">
|
||||
<h3 class="text-lg font-semibold">Import Documents</h3>
|
||||
@@ -108,10 +108,10 @@ export const UploadModal = (props: UploadModalProps) => {
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div class="p-6 space-y-4">
|
||||
<div class="p-4 sm:p-6 space-y-4">
|
||||
{/* Drop Zone */}
|
||||
<div
|
||||
class={`border-2 border-dashed rounded-lg p-8 text-center transition-colors ${
|
||||
class={`border-2 border-dashed rounded-lg p-4 sm:p-8 text-center transition-colors ${
|
||||
isDragging()
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-muted-foreground'
|
||||
@@ -120,9 +120,9 @@ export const UploadModal = (props: UploadModalProps) => {
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<IconUpload class="size-12 mx-auto mb-4 text-muted-foreground" />
|
||||
<h4 class="text-lg font-medium mb-2">Drop files here</h4>
|
||||
<p class="text-muted-foreground mb-4">or click to browse</p>
|
||||
<IconUpload class="size-8 sm:size-12 mx-auto mb-4 text-muted-foreground" />
|
||||
<h4 class="text-base sm:text-lg font-medium mb-2">Drop files here</h4>
|
||||
<p class="text-muted-foreground mb-4 text-sm sm:text-base">or click to browse</p>
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
@@ -164,7 +164,7 @@ export const UploadModal = (props: UploadModalProps) => {
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div class="flex justify-end gap-2 p-6 border-t border-border">
|
||||
<div class="flex flex-col sm:flex-row justify-end gap-2 p-4 sm:p-6 border-t border-border">
|
||||
<Button variant="outline" onClick={props.onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
@@ -16,7 +16,7 @@ export const VideoPreviewModal = (props: VideoPreviewModalProps) => {
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
{props.isOpen && (
|
||||
<div class="fixed inset-0 bg-black/50 z-40" onClick={props.onClose} />
|
||||
<div class="fixed inset-0 bg-black/50 z-40 mt-0" onClick={props.onClose} />
|
||||
)}
|
||||
|
||||
{/* Modal */}
|
||||
|
||||
@@ -49,15 +49,15 @@ export const VideoUploadModal = (props: VideoUploadModalProps) => {
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
{props.isOpen && (
|
||||
<div class="fixed inset-0 bg-black/50 z-40" onClick={props.onClose} />
|
||||
<div class="fixed inset-0 bg-black/50 z-[60] mt-0" onClick={props.onClose} />
|
||||
)}
|
||||
|
||||
{/* Modal */}
|
||||
<div class={`fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-card border border-border rounded-lg shadow-xl transition-all duration-300 z-50 ${
|
||||
<div class={`fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-card border border-border rounded-lg shadow-xl transition-all duration-300 z-[70] ${
|
||||
props.isOpen ? 'opacity-100 scale-100' : 'opacity-0 scale-95 pointer-events-none'
|
||||
}`} style="width: 500px; max-width: 90vw;">
|
||||
}`} style="width: min(500px, 90vw); max-height: min(80vh, 600px); overflow-y: auto;">
|
||||
{/* Header */}
|
||||
<div class="flex items-center justify-between p-6 border-b border-border">
|
||||
<div class="flex items-center justify-between p-4 sm:p-6 border-b border-border">
|
||||
<h3 class="text-lg font-semibold">Add YouTube Video</h3>
|
||||
<button
|
||||
onClick={props.onClose}
|
||||
@@ -68,7 +68,7 @@ export const VideoUploadModal = (props: VideoUploadModalProps) => {
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div class="p-6 space-y-4">
|
||||
<div class="p-4 sm:p-6 space-y-4">
|
||||
<div>
|
||||
<label class="text-sm font-medium">YouTube URL</label>
|
||||
<Input
|
||||
@@ -108,7 +108,7 @@ export const VideoUploadModal = (props: VideoUploadModalProps) => {
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div class="flex justify-end gap-2 p-6 border-t border-border">
|
||||
<div class="flex flex-col sm:flex-row justify-end gap-2 p-4 sm:p-6 border-t border-border">
|
||||
<Button variant="outline" onClick={props.onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { For, Show } from 'solid-js';
|
||||
import { For, Show, createEffect } from 'solid-js';
|
||||
import { IconX, IconEdit, IconPin, IconTrash, IconCopy, IconDownload, IconPaperclip } from '@tabler/icons-solidjs';
|
||||
|
||||
interface Note {
|
||||
@@ -30,11 +30,40 @@ interface ViewNoteModalProps {
|
||||
onDelete: (noteId: number) => void;
|
||||
onCopyContent?: (note: Note) => void;
|
||||
onExportNote?: (note: Note) => void;
|
||||
onUpdateNote?: (noteId: number, content: string) => void;
|
||||
}
|
||||
|
||||
export const ViewNoteModal = (props: ViewNoteModalProps) => {
|
||||
console.log('ViewNoteModal render:', { isOpen: props.isOpen, note: props.note?.title });
|
||||
|
||||
// Make the function available globally for checkbox onchange handlers
|
||||
createEffect(() => {
|
||||
(window as any).updateViewNoteContent = (checkbox: HTMLInputElement) => {
|
||||
if (props.note && props.onUpdateNote) {
|
||||
const lines = props.note.content.split('\n');
|
||||
let checkboxCount = 0;
|
||||
const checkboxElements = document.querySelectorAll('.note-content input[type="checkbox"]');
|
||||
const checkboxIndex = Array.from(checkboxElements).indexOf(checkbox);
|
||||
|
||||
const updatedLines = lines.map(line => {
|
||||
const uncheckedMatch = line.match(/^- \[ \] (.*)$/);
|
||||
const checkedMatch = line.match(/^- \[x\] (.*)$/);
|
||||
|
||||
if (uncheckedMatch || checkedMatch) {
|
||||
if (checkboxCount === checkboxIndex) {
|
||||
const text = uncheckedMatch ? uncheckedMatch[1] : (checkedMatch ? checkedMatch[1] : '');
|
||||
return checkbox.checked ? `- [x] ${text}` : `- [ ] ${text}`;
|
||||
}
|
||||
checkboxCount++;
|
||||
}
|
||||
return line;
|
||||
});
|
||||
|
||||
props.onUpdateNote(props.note.id, updatedLines.join('\n'));
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
@@ -154,7 +183,7 @@ export const ViewNoteModal = (props: ViewNoteModalProps) => {
|
||||
)}
|
||||
|
||||
{/* Note Content */}
|
||||
<div class="prose prose-invert max-w-none">
|
||||
<div class="prose prose-invert max-w-none note-content">
|
||||
{props.note.isHtml ? (
|
||||
<div
|
||||
class="text-[#fafafa] leading-relaxed"
|
||||
@@ -172,6 +201,8 @@ export const ViewNoteModal = (props: ViewNoteModalProps) => {
|
||||
.replace(/\*(.*?)\*/g, '<em class="italic">$1</em>')
|
||||
.replace(/`(.*?)`/g, '<code class="bg-[#262626] px-1 py-0.5 rounded text-sm">$1</code>')
|
||||
.replace(/```(.*?)\n([\s\S]*?)```/g, '<pre class="bg-[#262626] p-4 rounded mb-4 overflow-x-auto"><code class="text-sm">$2</code></pre>')
|
||||
.replace(/^- \[ \] (.*$)/gim, '<div class="flex items-center gap-2 mb-2"><input type="checkbox" class="rounded" onclick="this.checked=!this.checked" onchange="updateViewNoteContent(this)"><span>$1</span></div>')
|
||||
.replace(/^- \[x\] (.*$)/gim, '<div class="flex items-center gap-2 mb-2"><input type="checkbox" checked class="rounded" onclick="this.checked=!this.checked" onchange="updateViewNoteContent(this)"><span>$1</span></div>')
|
||||
.replace(/\n\n/g, '</p><p class="mb-4">')
|
||||
.replace(/^- (.*$)/gim, '<li class="ml-4 list-disc">$1</li>')
|
||||
.replace(/^\d+\. (.*$)/gim, '<li class="ml-4 list-decimal">$1</li>')
|
||||
@@ -191,6 +222,8 @@ export const ViewNoteModal = (props: ViewNoteModalProps) => {
|
||||
.replace(/(https?:\/\/[^\s]+)/g, '<a href="$1" class="text-blue-400 hover:underline" target="_blank" rel="noopener noreferrer">$1</a>')
|
||||
.replace(/\*\*(.*?)\*\*/g, '<strong class="font-semibold">$1</strong>')
|
||||
.replace(/\*(.*?)\*/g, '<em class="italic">$1</em>')
|
||||
.replace(/^- \[ \] (.*$)/gim, '<div class="flex items-center gap-2 mb-2"><input type="checkbox" class="rounded" onclick="this.checked=!this.checked" onchange="updateViewNoteContent(this)"><span>$1</span></div>')
|
||||
.replace(/^- \[x\] (.*$)/gim, '<div class="flex items-center gap-2 mb-2"><input type="checkbox" checked class="rounded" onclick="this.checked=!this.checked" onchange="updateViewNoteContent(this)"><span>$1</span></div>')
|
||||
.split('\n').map((line) => (
|
||||
<div innerHTML={line || '<br />'} />
|
||||
))}
|
||||
|
||||
+142
-48
@@ -647,7 +647,7 @@ body {
|
||||
background-color: hsl(var(--primary)) !important;
|
||||
}
|
||||
|
||||
/* Bar chart specific fixes */
|
||||
/* Bar chart specific fixes - simplified for responsiveness */
|
||||
.weekly-bar {
|
||||
background-color: hsl(var(--primary)) !important;
|
||||
min-height: 4px !important;
|
||||
@@ -659,61 +659,77 @@ body {
|
||||
background-color: hsl(199 89% 67%) !important;
|
||||
}
|
||||
|
||||
/* Additional bar chart fixes */
|
||||
.bg-primary.rounded-t {
|
||||
background-color: hsl(var(--primary)) !important;
|
||||
min-height: 4px !important;
|
||||
}
|
||||
|
||||
[data-kb-theme=dark] .bg-primary.rounded-t {
|
||||
background-color: hsl(199 89% 67%) !important;
|
||||
}
|
||||
|
||||
/* Force bar chart visibility */
|
||||
.weekly-activity-chart .bg-primary {
|
||||
background-color: hsl(199 89% 67%) !important;
|
||||
}
|
||||
|
||||
.weekly-activity-chart .weekly-bar {
|
||||
background-color: hsl(199 89% 67%) !important;
|
||||
}
|
||||
|
||||
/* Direct bar styling */
|
||||
div[class*="bg-primary"][class*="rounded-t"] {
|
||||
background-color: hsl(199 89% 67%) !important;
|
||||
}
|
||||
|
||||
/* Better bar proportions */
|
||||
.weekly-activity-chart .max-w-8 {
|
||||
width: 2rem !important;
|
||||
max-width: 2rem !important;
|
||||
}
|
||||
|
||||
/* Enhanced bar visibility */
|
||||
.weekly-activity-chart .weekly-bar {
|
||||
min-height: 8px !important;
|
||||
width: 2rem !important;
|
||||
max-width: 2rem !important;
|
||||
}
|
||||
|
||||
/* Responsive bar chart */
|
||||
/* Completely responsive bar chart */
|
||||
.weekly-activity-chart {
|
||||
min-height: 128px !important; /* h-32 */
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
@media (min-width: 640px) {
|
||||
.weekly-activity-chart {
|
||||
min-height: 144px !important; /* h-36 */
|
||||
}
|
||||
|
||||
.weekly-activity-chart .max-w-8 {
|
||||
width: 2.5rem !important;
|
||||
max-width: 2.5rem !important;
|
||||
}
|
||||
|
||||
/* Responsive bars that fill available space */
|
||||
.weekly-activity-chart .weekly-bar {
|
||||
background-color: hsl(199, 89%, 67%) !important;
|
||||
min-height: 4px !important;
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
transition: all 0.3s ease !important;
|
||||
border-radius: 0.25rem 0.25rem 0 0;
|
||||
}
|
||||
|
||||
/* Dark mode bar colors */
|
||||
[data-kb-theme=dark] .weekly-activity-chart .weekly-bar {
|
||||
background-color: hsl(199, 89%, 67%) !important;
|
||||
}
|
||||
|
||||
/* Ensure bar containers are truly flexible - BUT preserve max-width for responsive design */
|
||||
.weekly-activity-chart .flex-1 {
|
||||
flex: 1 1 0%;
|
||||
min-width: 0;
|
||||
/* Don't override max-width - let Tailwind handle it */
|
||||
}
|
||||
|
||||
/* Only override max-width on actual bars, not containers */
|
||||
.weekly-activity-chart .weekly-bar,
|
||||
.weekly-activity-chart .weekly-bar[class*="max-w"] {
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
/* Don't remove max-width from bar containers - needed for responsive design */
|
||||
/* .weekly-activity-chart [class*="max-w"] {
|
||||
max-width: none !important;
|
||||
} */
|
||||
|
||||
/* Responsive tooltip positioning */
|
||||
.weekly-activity-chart .group:hover .absolute {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
/* Better spacing on smaller screens */
|
||||
@media (max-width: 640px) {
|
||||
.weekly-activity-chart {
|
||||
padding-left: 0.5rem !important;
|
||||
padding-right: 0.5rem !important;
|
||||
}
|
||||
|
||||
.weekly-activity-chart .weekly-bar {
|
||||
width: 2.5rem !important;
|
||||
max-width: 2.5rem !important;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.weekly-activity-chart {
|
||||
padding-left: 1rem !important;
|
||||
padding-right: 1rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.weekly-activity-chart {
|
||||
padding-left: 1.5rem !important;
|
||||
padding-right: 1.5rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -783,6 +799,13 @@ div[class*="bg-primary"][class*="rounded-t"] {
|
||||
scrollbar-color: #262626 transparent;
|
||||
}
|
||||
|
||||
/* Fix space-y-6 margin issue - exclude overlays and fixed elements */
|
||||
.space-y-6 > :not([hidden]) ~ :not([hidden]):not(.fixed):not([class*="fixed"]):not([class*="overlay"]):not([class*="backdrop"]),
|
||||
[space-y-6=""] > :not([hidden]) ~ :not([hidden]):not(.fixed):not([class*="fixed"]):not([class*="overlay"]):not([class*="backdrop"]) {
|
||||
--un-space-y-reverse: 0;
|
||||
margin-top: calc(1.5rem * calc(1 - var(--un-space-y-reverse)));
|
||||
}
|
||||
|
||||
/* Dark mode scrollbar adjustments */
|
||||
[data-kb-theme="dark"] ::-webkit-scrollbar-thumb {
|
||||
background: #404040;
|
||||
@@ -796,3 +819,74 @@ div[class*="bg-primary"][class*="rounded-t"] {
|
||||
[data-kb-theme="dark"] * {
|
||||
scrollbar-color: #404040 transparent;
|
||||
}
|
||||
|
||||
/* Better checkbox styling for notes */
|
||||
.note-checkbox {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid hsl(var(--border));
|
||||
border-radius: 3px;
|
||||
background-color: hsl(var(--background));
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: all 0.15s ease-in-out;
|
||||
flex-shrink: 0;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.note-checkbox:hover {
|
||||
border-color: hsl(var(--primary));
|
||||
background-color: hsl(var(--primary) / 0.1);
|
||||
}
|
||||
|
||||
.note-checkbox:checked {
|
||||
background-color: hsl(var(--primary));
|
||||
border-color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.note-checkbox:checked::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
left: 4px;
|
||||
width: 4px;
|
||||
height: 8px;
|
||||
border: solid white;
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
/* Dark mode checkbox adjustments */
|
||||
[data-kb-theme="dark"] .note-checkbox {
|
||||
background-color: hsl(var(--background));
|
||||
border-color: #525252;
|
||||
}
|
||||
|
||||
[data-kb-theme="dark"] .note-checkbox:hover {
|
||||
border-color: hsl(var(--primary));
|
||||
background-color: hsl(var(--primary) / 0.1);
|
||||
}
|
||||
|
||||
[data-kb-theme="dark"] .note-checkbox:checked {
|
||||
background-color: hsl(var(--primary));
|
||||
border-color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
/* Ensure text-primary color always uses the current primary color from CSS variables */
|
||||
.text-primary,
|
||||
.text-primary svg,
|
||||
svg.text-primary,
|
||||
button.text-primary,
|
||||
button.text-primary svg {
|
||||
color: hsl(var(--primary)) !important;
|
||||
}
|
||||
|
||||
/* Force primary color on hover states */
|
||||
.hover\:text-primary\/80:hover,
|
||||
button.hover\:text-primary\/80:hover,
|
||||
button.hover\:text-primary\/80:hover svg {
|
||||
color: hsl(var(--primary) / 0.8) !important;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:9090/api/v1';
|
||||
|
||||
// Check if we're in demo mode
|
||||
const isDemoMode = () => {
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { createContext, useContext, type ParentComponent, onMount } from 'solid-js';
|
||||
import { createStore } from 'solid-js/store';
|
||||
import { isDemoMode } from './demo-mode';
|
||||
|
||||
// Check if we're in demo mode (same logic as api.ts)
|
||||
const isDemoMode = () => {
|
||||
return localStorage.getItem('demoMode') === 'true' ||
|
||||
document.title.includes('Demo Mode') ||
|
||||
window.location.search.includes('demo=true');
|
||||
};
|
||||
|
||||
// Types
|
||||
export interface User {
|
||||
@@ -182,6 +188,23 @@ export const AuthProvider: ParentComponent = (props) => {
|
||||
|
||||
const login = async (credentials: LoginRequest) => {
|
||||
try {
|
||||
// In demo mode, use mock login
|
||||
if (isDemoMode()) {
|
||||
const mockUser: User = {
|
||||
id: 1,
|
||||
email: 'demo@trackeep.com',
|
||||
username: 'demo',
|
||||
full_name: 'Demo User',
|
||||
theme: 'dark',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
const mockToken = 'demo-token-' + Date.now();
|
||||
setAuth(mockToken, mockUser);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -191,8 +214,16 @@ export const AuthProvider: ParentComponent = (props) => {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Login failed');
|
||||
let error;
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
error = errorData.error || 'Login failed';
|
||||
} catch (jsonError) {
|
||||
// Handle non-JSON error responses
|
||||
const text = await response.text();
|
||||
error = text || `Login failed with status ${response.status}`;
|
||||
}
|
||||
throw new Error(error);
|
||||
}
|
||||
|
||||
const data: AuthResponse = await response.json();
|
||||
@@ -369,7 +400,21 @@ export const useAuth = () => {
|
||||
|
||||
// Helper function to get auth headers for API requests
|
||||
export const getAuthHeaders = () => {
|
||||
const token = localStorage.getItem('token');
|
||||
// Check if we're in demo mode first
|
||||
const isDemo = localStorage.getItem('demoMode') === 'true' ||
|
||||
document.title.includes('Demo Mode') ||
|
||||
window.location.search.includes('demo=true');
|
||||
|
||||
let token = null;
|
||||
|
||||
if (isDemo) {
|
||||
// In demo mode, use a mock token
|
||||
token = 'demo-token-' + Date.now();
|
||||
} else {
|
||||
// In normal mode, get token from localStorage
|
||||
token = localStorage.getItem('token') || localStorage.getItem('trackeep_token');
|
||||
}
|
||||
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
...(token && { 'Authorization': `Bearer ${token}` }),
|
||||
|
||||
@@ -1,7 +1,32 @@
|
||||
// Brave Search API integration
|
||||
const BACKEND_API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
|
||||
const BRAVE_API_KEY = import.meta.env.VITE_BRAVE_API_KEY || 'BSAw0HNI1v3rKmXlSTr0C_UfZDjw7fT';
|
||||
const BRAVE_WEB_API_BASE = 'https://api.search.brave.com/res/v1/web/search';
|
||||
const BRAVE_NEWS_API_BASE = 'https://api.search.brave.com/res/v1/news/search';
|
||||
|
||||
// Use the variable to avoid unused warning
|
||||
console.log('Brave API key available:', !!BRAVE_API_KEY);
|
||||
|
||||
// Helper function to get auth headers
|
||||
const getAuthHeaders = () => {
|
||||
// Check if we're in demo mode
|
||||
const isDemo = import.meta.env.VITE_DEMO_MODE === 'true' ||
|
||||
document.title.includes('Demo Mode') ||
|
||||
window.location.search.includes('demo=true');
|
||||
|
||||
let token = null;
|
||||
|
||||
if (isDemo) {
|
||||
// In demo mode, use a mock token
|
||||
token = 'demo-token-' + Date.now();
|
||||
} else {
|
||||
// In normal mode, get token from localStorage
|
||||
token = localStorage.getItem('token') || localStorage.getItem('trackeep_token');
|
||||
}
|
||||
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
...(token && { 'Authorization': `Bearer ${token}` }),
|
||||
};
|
||||
};
|
||||
|
||||
export interface BraveSearchResult {
|
||||
title: string;
|
||||
@@ -32,29 +57,26 @@ export interface BraveSearchResponse {
|
||||
|
||||
export async function searchBrave(query: string, count: number = 10, type: 'web' | 'news' = 'web'): Promise<BraveSearchResult[]> {
|
||||
try {
|
||||
const apiBase = type === 'news' ? BRAVE_NEWS_API_BASE : BRAVE_WEB_API_BASE;
|
||||
const response = await fetch(`${apiBase}?q=${encodeURIComponent(query)}&count=${count}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Accept-Encoding': 'gzip',
|
||||
'X-Subscription-Token': BRAVE_API_KEY,
|
||||
},
|
||||
// Use backend proxy to avoid CORS issues
|
||||
const endpoint = type === 'news' ? '/search/news' : '/search/web';
|
||||
const response = await fetch(`${BACKEND_API_URL}${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
query,
|
||||
count,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Brave API error: ${response.status} ${response.statusText}`);
|
||||
throw new Error(`Search API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data: BraveSearchResponse = await response.json();
|
||||
const data = await response.json();
|
||||
|
||||
// Return results from appropriate search type
|
||||
if (type === 'news' && data.news?.results) {
|
||||
return data.news.results;
|
||||
} else if (data.web?.results) {
|
||||
return data.web.results;
|
||||
} else if (data.mixed?.results) {
|
||||
return data.mixed.results;
|
||||
// Return results from the backend response
|
||||
if (data.results && Array.isArray(data.results)) {
|
||||
return data.results;
|
||||
}
|
||||
|
||||
return [];
|
||||
|
||||
@@ -5,14 +5,21 @@ export const hasDatabaseCredentials = (): boolean => {
|
||||
return !!(import.meta.env.VITE_DB_HOST &&
|
||||
import.meta.env.VITE_DB_USER &&
|
||||
import.meta.env.VITE_DB_PASSWORD &&
|
||||
import.meta.env.VITE_DB_NAME);
|
||||
import.meta.env.VITE_DB_NAME) ||
|
||||
!!(import.meta.env.DB_HOST &&
|
||||
import.meta.env.DB_USER &&
|
||||
import.meta.env.DB_PASSWORD &&
|
||||
import.meta.env.DB_NAME);
|
||||
};
|
||||
|
||||
// Check if search API credentials are configured
|
||||
export const hasSearchCredentials = (): boolean => {
|
||||
return !!(import.meta.env.VITE_BRAVE_API_KEY ||
|
||||
import.meta.env.VITE_SERPER_API_KEY ||
|
||||
import.meta.env.VITE_SEARCH_API_PROVIDER);
|
||||
import.meta.env.VITE_SEARCH_API_PROVIDER) ||
|
||||
!!(import.meta.env.BRAVE_API_KEY ||
|
||||
import.meta.env.SERPER_API_KEY ||
|
||||
import.meta.env.SEARCH_API_PROVIDER);
|
||||
};
|
||||
|
||||
// Check if AI service credentials are configured
|
||||
@@ -22,7 +29,13 @@ export const hasAICredentials = (): boolean => {
|
||||
import.meta.env.VITE_GROK_API_KEY ||
|
||||
import.meta.env.VITE_DEEPSEEK_API_KEY ||
|
||||
import.meta.env.VITE_OPENROUTER_API_KEY ||
|
||||
import.meta.env.VITE_OLLAMA_BASE_URL);
|
||||
import.meta.env.VITE_OLLAMA_BASE_URL) ||
|
||||
!!(import.meta.env.LONGCAT_API_KEY ||
|
||||
import.meta.env.MISTRAL_API_KEY ||
|
||||
import.meta.env.GROK_API_KEY ||
|
||||
import.meta.env.DEEPSEEK_API_KEY ||
|
||||
import.meta.env.OPENROUTER_API_KEY ||
|
||||
import.meta.env.OLLAMA_BASE_URL);
|
||||
};
|
||||
|
||||
// Check if any credentials are configured
|
||||
|
||||
@@ -204,6 +204,40 @@ export class DemoModeApiClient {
|
||||
} as T;
|
||||
}
|
||||
|
||||
// Updates endpoint
|
||||
if (endpoint.includes('/updates/check')) {
|
||||
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'
|
||||
}
|
||||
} as T;
|
||||
}
|
||||
|
||||
if (endpoint.includes('/updates/install')) {
|
||||
return {
|
||||
message: 'Update started',
|
||||
version: '1.0.1'
|
||||
} as T;
|
||||
}
|
||||
|
||||
if (endpoint.includes('/updates/progress')) {
|
||||
return {
|
||||
available: true,
|
||||
downloading: false,
|
||||
installing: false,
|
||||
completed: false,
|
||||
error: '',
|
||||
progress: 0
|
||||
} as T;
|
||||
}
|
||||
|
||||
// Auth endpoints
|
||||
if (endpoint.includes('/auth/login-totp')) {
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Demo mode API interceptor to provide mock data instead of making real API calls
|
||||
|
||||
import { hasAnyCredentials, isBackendAvailable, isSearchAvailable } from './credentials';
|
||||
import { hasAnyCredentials, isBackendAvailable, isSearchAvailable, hasSearchCredentials, hasDatabaseCredentials, hasAICredentials } from './credentials';
|
||||
|
||||
// Check if demo mode is enabled via environment variable
|
||||
export const isEnvDemoMode = (): boolean => {
|
||||
@@ -17,7 +17,18 @@ export const isDemoMode = (): boolean => {
|
||||
|
||||
// Check if we should use real APIs even in demo mode
|
||||
export const shouldUseRealAPIs = (): boolean => {
|
||||
return hasAnyCredentials();
|
||||
const hasCredentials = hasAnyCredentials();
|
||||
const hasBackend = shouldUseRealBackend();
|
||||
const result = hasCredentials || hasBackend;
|
||||
console.log('[Demo Mode] shouldUseRealAPIs:', {
|
||||
hasCredentials,
|
||||
hasBackend,
|
||||
result,
|
||||
searchCreds: hasSearchCredentials(),
|
||||
dbCreds: hasDatabaseCredentials(),
|
||||
aiCreds: hasAICredentials()
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
// Check if we should use real backend API
|
||||
@@ -196,16 +207,60 @@ const generateMockAIProviders = () => [
|
||||
}
|
||||
];
|
||||
|
||||
// Store original fetch at module level
|
||||
let originalFetch: typeof fetch | null = null;
|
||||
|
||||
// Request cache to prevent duplicate API calls
|
||||
const requestCache = new Map<string, Promise<Response>>();
|
||||
const CACHE_TTL = 2000; // 2 seconds
|
||||
|
||||
// Generate cache key for requests
|
||||
const getCacheKey = (url: string, options?: RequestInit): string => {
|
||||
const method = options?.method || 'GET';
|
||||
const body = options?.body || '';
|
||||
return `${method}:${url}:${body}`;
|
||||
};
|
||||
|
||||
// Demo mode fetch interceptor
|
||||
export const demoFetch = async (url: string, options?: RequestInit): Promise<Response> => {
|
||||
// Check if we should use real APIs even in demo mode
|
||||
if (shouldUseRealAPIs()) {
|
||||
console.log('[Demo Mode] Real credentials detected, using real API for:', url);
|
||||
return fetch(url, options);
|
||||
const shouldUseReal = shouldUseRealAPIs();
|
||||
console.log('[Demo Mode] demoFetch called:', { url, shouldUseReal });
|
||||
|
||||
if (shouldUseReal) {
|
||||
// Only log YouTube API calls once every 50 calls to reduce spam
|
||||
if (url.includes('youtube') && Math.random() < 0.02) {
|
||||
console.log('[Demo Mode] Real credentials detected, using real API for:', url);
|
||||
}
|
||||
|
||||
// Check cache for YouTube API calls to prevent duplicates
|
||||
if (url.includes('youtube')) {
|
||||
const cacheKey = getCacheKey(url, options);
|
||||
const cachedRequest = requestCache.get(cacheKey);
|
||||
|
||||
if (cachedRequest) {
|
||||
return cachedRequest;
|
||||
}
|
||||
|
||||
// Create new request and cache it
|
||||
const requestPromise = (originalFetch || window.fetch)(url, options);
|
||||
requestCache.set(cacheKey, requestPromise);
|
||||
|
||||
// Clear cache after TTL
|
||||
setTimeout(() => {
|
||||
requestCache.delete(cacheKey);
|
||||
}, CACHE_TTL);
|
||||
|
||||
return requestPromise;
|
||||
}
|
||||
|
||||
// Use original fetch to avoid recursion
|
||||
return (originalFetch || window.fetch)(url, options);
|
||||
}
|
||||
|
||||
if (!isDemoMode()) {
|
||||
return fetch(url, options);
|
||||
console.log('[Demo Mode] Not in demo mode, using real fetch for:', url);
|
||||
return (originalFetch || window.fetch)(url, options);
|
||||
}
|
||||
|
||||
// Parse URL to determine which mock data to return
|
||||
@@ -252,6 +307,27 @@ export const demoFetch = async (url: string, options?: RequestInit): Promise<Res
|
||||
});
|
||||
}
|
||||
|
||||
if (path.includes('/api/v1/bookmarks') && (!options?.method || options.method === 'GET')) {
|
||||
const { getMockBookmarks } = await import('./mockData');
|
||||
const mockBookmarks = getMockBookmarks().map((bookmark, index) => ({
|
||||
id: index + 1,
|
||||
title: bookmark.title,
|
||||
url: bookmark.url,
|
||||
description: bookmark.description,
|
||||
tags: bookmark.tags,
|
||||
created_at: bookmark.createdAt,
|
||||
is_favorite: bookmark.tags.some((tag) => tag.name === 'important' || tag.name === 'favorite'),
|
||||
favicon: bookmark.favicon,
|
||||
screenshot: bookmark.screenshot,
|
||||
screenshot_medium: bookmark.screenshot,
|
||||
}));
|
||||
|
||||
return new Response(JSON.stringify(mockBookmarks), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
if (path.includes('/api/v1/tasks') && (!options?.method || options.method === 'GET')) {
|
||||
const { getMockTasks } = await import('./mockData');
|
||||
const mockTasks = getMockTasks().map((task, index) => ({
|
||||
@@ -423,6 +499,27 @@ export const demoFetch = async (url: string, options?: RequestInit): Promise<Res
|
||||
});
|
||||
}
|
||||
|
||||
if (path.includes('/api/v1/bookmarks')) {
|
||||
const body = options.body ? JSON.parse(options.body as string) : {};
|
||||
const newBookmark = {
|
||||
id: Date.now(),
|
||||
title: body.title || 'Untitled bookmark',
|
||||
url: body.url || '',
|
||||
description: body.description || '',
|
||||
tags: body.tags || [],
|
||||
created_at: new Date().toISOString(),
|
||||
is_favorite: body.is_favorite || false,
|
||||
favicon: body.favicon || '',
|
||||
screenshot: body.screenshot || '',
|
||||
screenshot_medium: body.screenshot_medium || '',
|
||||
};
|
||||
|
||||
return new Response(JSON.stringify(newBookmark), {
|
||||
status: 201,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
if (path.includes('/api/v1/tasks')) {
|
||||
const body = options.body ? JSON.parse(options.body as string) : {};
|
||||
const newTask = {
|
||||
@@ -442,6 +539,29 @@ export const demoFetch = async (url: string, options?: RequestInit): Promise<Res
|
||||
}
|
||||
}
|
||||
|
||||
if (options?.method === 'PUT' && path.includes('/api/v1/bookmarks')) {
|
||||
const body = options.body ? JSON.parse(options.body as string) : {};
|
||||
const pathParts = path.split('/');
|
||||
const idFromPath = parseInt(pathParts[pathParts.length - 1] || '0', 10);
|
||||
const updatedBookmark = {
|
||||
id: idFromPath || body.id || Date.now(),
|
||||
title: body.title || 'Untitled bookmark',
|
||||
url: body.url || '',
|
||||
description: body.description || '',
|
||||
tags: body.tags || [],
|
||||
created_at: body.created_at || new Date().toISOString(),
|
||||
is_favorite: body.is_favorite ?? false,
|
||||
favicon: body.favicon || '',
|
||||
screenshot: body.screenshot || '',
|
||||
screenshot_medium: body.screenshot_medium || '',
|
||||
};
|
||||
|
||||
return new Response(JSON.stringify(updatedBookmark), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
if (options?.method === 'PUT' && path.includes('/api/v1/tasks')) {
|
||||
const body = options.body ? JSON.parse(options.body as string) : {};
|
||||
const pathParts = path.split('/');
|
||||
@@ -462,6 +582,13 @@ export const demoFetch = async (url: string, options?: RequestInit): Promise<Res
|
||||
});
|
||||
}
|
||||
|
||||
if (options?.method === 'DELETE' && path.includes('/api/v1/bookmarks')) {
|
||||
return new Response(JSON.stringify({ message: 'Bookmark deleted (demo mode)' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
if (options?.method === 'DELETE' && path.includes('/api/v1/tasks')) {
|
||||
return new Response(JSON.stringify({ message: 'Task deleted (demo mode)' }), {
|
||||
status: 200,
|
||||
@@ -590,8 +717,8 @@ export const demoFetch = async (url: string, options?: RequestInit): Promise<Res
|
||||
// Override global fetch for demo mode
|
||||
export const initializeDemoMode = () => {
|
||||
if (isDemoMode()) {
|
||||
// Store original fetch to restore later if needed
|
||||
const originalFetch = window.fetch;
|
||||
// Store original fetch to use for real API calls and restore later if needed
|
||||
originalFetch = window.fetch;
|
||||
window.fetch = demoFetch as typeof fetch;
|
||||
console.log('[Demo Mode] API interceptor initialized');
|
||||
return originalFetch;
|
||||
|
||||
@@ -1107,7 +1107,7 @@ export const mockNotes: MockNote[] = [
|
||||
{
|
||||
id: 'note_4',
|
||||
title: 'Shopping List',
|
||||
content: 'Grocery shopping for this week:\n\n🥬 Vegetables:\n- Spinach\n- Bell peppers\n- Carrots\n- Broccoli\n\n🍎 Fruits:\n- Apples\n- Bananas\n- Oranges\n- Berries\n\n🥩 Proteins:\n- Chicken breast\n- Ground beef\n- Salmon\n- Eggs\n\n🥛 Dairy:\n- Milk\n- Greek yogurt\n- Cheese\n- Butter\n\n🍞 Pantry:\n- Bread\n- Rice\n- Pasta\n- Olive oil',
|
||||
content: 'Grocery shopping for this week:\n\nVegetables:\n- Spinach\n- Bell peppers\n- Carrots\n- Broccoli\n\nFruits:\n- Apples\n- Bananas\n- Oranges\n- Berries\n\nProteins:\n- Chicken breast\n- Ground beef\n- Salmon\n- Eggs\n\nDairy:\n- Milk\n- Greek yogurt\n- Cheese\n- Butter\n\nPantry:\n- Bread\n- Rice\n- Pasta\n- Olive oil',
|
||||
tags: [
|
||||
{ name: 'personal', color: '#84cc16' },
|
||||
{ name: 'shopping', color: '#10b981' }
|
||||
@@ -1826,6 +1826,272 @@ export const mockLearningPaths: MockLearningPath[] = [
|
||||
],
|
||||
createdAt: '4 weeks ago',
|
||||
enrolledAt: '3 weeks ago'
|
||||
},
|
||||
{
|
||||
id: 'lp_9',
|
||||
title: 'Blockchain Development',
|
||||
description: 'Learn blockchain fundamentals, smart contracts, and decentralized application development',
|
||||
category: 'Blockchain',
|
||||
difficulty: 'advanced',
|
||||
estimatedTime: '14 weeks',
|
||||
progress: 20,
|
||||
modules: [
|
||||
{
|
||||
id: 'mod_18',
|
||||
title: 'Blockchain Fundamentals',
|
||||
description: 'Understanding distributed ledgers and consensus mechanisms',
|
||||
completed: true,
|
||||
resources: [
|
||||
{ type: 'video', title: 'Blockchain Basics', url: 'https://example.com/blockchain-basics' },
|
||||
{ type: 'article', title: 'Consensus Algorithms', url: 'https://example.com/consensus' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'mod_19',
|
||||
title: 'Smart Contract Development',
|
||||
description: 'Building smart contracts with Solidity',
|
||||
completed: false,
|
||||
resources: [
|
||||
{ type: 'video', title: 'Solidity Programming', url: 'https://example.com/solidity' },
|
||||
{ type: 'project', title: 'DeFi Application', url: 'https://example.com/defi-project' }
|
||||
]
|
||||
}
|
||||
],
|
||||
tags: [
|
||||
{ name: 'blockchain', color: '#8b5cf6' },
|
||||
{ name: 'solidity', color: '#6366f1' },
|
||||
{ name: 'web3', color: '#ec4899' }
|
||||
],
|
||||
createdAt: '5 days ago',
|
||||
enrolledAt: undefined
|
||||
},
|
||||
{
|
||||
id: 'lp_10',
|
||||
title: 'Data Science with Python',
|
||||
description: 'Master data analysis, visualization, and machine learning with Python',
|
||||
category: 'Data Science',
|
||||
difficulty: 'intermediate',
|
||||
estimatedTime: '12 weeks',
|
||||
progress: 40,
|
||||
modules: [
|
||||
{
|
||||
id: 'mod_20',
|
||||
title: 'Data Analysis Fundamentals',
|
||||
description: 'Pandas, NumPy, and data manipulation',
|
||||
completed: true,
|
||||
resources: [
|
||||
{ type: 'video', title: 'Data Analysis with Python', url: 'https://example.com/data-analysis' },
|
||||
{ type: 'lab', title: 'Data Cleaning Exercises', url: 'https://example.com/data-cleaning' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'mod_21',
|
||||
title: 'Machine Learning Basics',
|
||||
description: 'Introduction to ML algorithms and scikit-learn',
|
||||
completed: false,
|
||||
resources: [
|
||||
{ type: 'video', title: 'Machine Learning Intro', url: 'https://example.com/ml-intro' },
|
||||
{ type: 'project', title: 'Predictive Model Project', url: 'https://example.com/predictive-model' }
|
||||
]
|
||||
}
|
||||
],
|
||||
tags: [
|
||||
{ name: 'python', color: '#3776ab' },
|
||||
{ name: 'data-science', color: '#10b981' },
|
||||
{ name: 'pandas', color: '#f59e0b' }
|
||||
],
|
||||
createdAt: '1 week ago',
|
||||
enrolledAt: '4 days ago'
|
||||
},
|
||||
{
|
||||
id: 'lp_11',
|
||||
title: 'Game Development with Unity',
|
||||
description: 'Create immersive games using Unity engine and C# programming',
|
||||
category: 'Game Development',
|
||||
difficulty: 'intermediate',
|
||||
estimatedTime: '16 weeks',
|
||||
progress: 10,
|
||||
modules: [
|
||||
{
|
||||
id: 'mod_22',
|
||||
title: 'Unity Basics',
|
||||
description: 'Interface, GameObjects, and basic scripting',
|
||||
completed: true,
|
||||
resources: [
|
||||
{ type: 'video', title: 'Unity Interface Tutorial', url: 'https://example.com/unity-interface' },
|
||||
{ type: 'lab', title: 'First Unity Project', url: 'https://example.com/unity-first' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'mod_23',
|
||||
title: 'C# for Game Development',
|
||||
description: 'Programming concepts and game logic',
|
||||
completed: false,
|
||||
resources: [
|
||||
{ type: 'video', title: 'C# Game Programming', url: 'https://example.com/csharp-games' },
|
||||
{ type: 'project', title: '2D Platformer Game', url: 'https://example.com/platformer' }
|
||||
]
|
||||
}
|
||||
],
|
||||
tags: [
|
||||
{ name: 'gamedev', color: '#ef4444' },
|
||||
{ name: 'unity', color: '#000000' },
|
||||
{ name: 'csharp', color: '#8b5cf6' }
|
||||
],
|
||||
createdAt: '2 weeks ago',
|
||||
enrolledAt: undefined
|
||||
},
|
||||
{
|
||||
id: 'lp_12',
|
||||
title: 'Cloud Architecture with AWS',
|
||||
description: 'Design and deploy scalable cloud solutions using Amazon Web Services',
|
||||
category: 'Cloud Computing',
|
||||
difficulty: 'advanced',
|
||||
estimatedTime: '10 weeks',
|
||||
progress: 70,
|
||||
modules: [
|
||||
{
|
||||
id: 'mod_24',
|
||||
title: 'AWS Core Services',
|
||||
description: 'EC2, S3, and fundamental AWS services',
|
||||
completed: true,
|
||||
resources: [
|
||||
{ type: 'video', title: 'AWS Core Services Guide', url: 'https://example.com/aws-core' },
|
||||
{ type: 'lab', title: 'AWS Hands-on Lab', url: 'https://example.com/aws-lab' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'mod_25',
|
||||
title: 'Advanced Cloud Architecture',
|
||||
description: 'Serverless, microservices, and cloud patterns',
|
||||
completed: false,
|
||||
resources: [
|
||||
{ type: 'video', title: 'Advanced AWS Patterns', url: 'https://example.com/aws-advanced' },
|
||||
{ type: 'case-study', title: 'Enterprise Cloud Migration', url: 'https://example.com/cloud-migration' }
|
||||
]
|
||||
}
|
||||
],
|
||||
tags: [
|
||||
{ name: 'aws', color: '#ff9900' },
|
||||
{ name: 'cloud', color: '#4ecdc4' },
|
||||
{ name: 'architecture', color: '#3b82f6' }
|
||||
],
|
||||
createdAt: '3 weeks ago',
|
||||
enrolledAt: '1 week ago'
|
||||
},
|
||||
{
|
||||
id: 'lp_13',
|
||||
title: 'React Native Advanced',
|
||||
description: 'Master advanced React Native concepts for professional mobile app development',
|
||||
category: 'Mobile Development',
|
||||
difficulty: 'advanced',
|
||||
estimatedTime: '8 weeks',
|
||||
progress: 55,
|
||||
modules: [
|
||||
{
|
||||
id: 'mod_26',
|
||||
title: 'Advanced Navigation',
|
||||
description: 'Complex navigation patterns and state management',
|
||||
completed: true,
|
||||
resources: [
|
||||
{ type: 'video', title: 'Advanced React Native Navigation', url: 'https://example.com/advanced-nav' },
|
||||
{ type: 'project', title: 'Multi-screen App', url: 'https://example.com/multi-screen' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'mod_27',
|
||||
title: 'Performance Optimization',
|
||||
description: 'Optimizing app performance and memory usage',
|
||||
completed: false,
|
||||
resources: [
|
||||
{ type: 'video', title: 'React Native Performance', url: 'https://example.com/performance' },
|
||||
{ type: 'article', title: 'Memory Management Tips', url: 'https://example.com/memory-tips' }
|
||||
]
|
||||
}
|
||||
],
|
||||
tags: [
|
||||
{ name: 'react-native', color: '#61dafb' },
|
||||
{ name: 'mobile', color: '#a855f7' },
|
||||
{ name: 'performance', color: '#f59e0b' }
|
||||
],
|
||||
createdAt: '1 week ago',
|
||||
enrolledAt: '3 days ago'
|
||||
},
|
||||
{
|
||||
id: 'lp_14',
|
||||
title: 'Vue.js 3 Complete Guide',
|
||||
description: 'Learn Vue.js 3 from basics to advanced concepts including Composition API',
|
||||
category: 'Web Development',
|
||||
difficulty: 'beginner',
|
||||
estimatedTime: '6 weeks',
|
||||
progress: 85,
|
||||
modules: [
|
||||
{
|
||||
id: 'mod_28',
|
||||
title: 'Vue.js Fundamentals',
|
||||
description: 'Components, directives, and reactivity',
|
||||
completed: true,
|
||||
resources: [
|
||||
{ type: 'video', title: 'Vue.js 3 Basics', url: 'https://example.com/vue-basics' },
|
||||
{ type: 'project', title: 'Todo App with Vue', url: 'https://example.com/vue-todo' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'mod_29',
|
||||
title: 'Composition API',
|
||||
description: 'Modern Vue.js development patterns',
|
||||
completed: true,
|
||||
resources: [
|
||||
{ type: 'video', title: 'Composition API Guide', url: 'https://example.com/composition-api' },
|
||||
{ type: 'article', title: 'Vue Best Practices', url: 'https://example.com/vue-best' }
|
||||
]
|
||||
}
|
||||
],
|
||||
tags: [
|
||||
{ name: 'vue', color: '#4fc08d' },
|
||||
{ name: 'javascript', color: '#f7df1e' },
|
||||
{ name: 'frontend', color: '#61dafb' }
|
||||
],
|
||||
createdAt: '2 weeks ago',
|
||||
enrolledAt: '1 week ago'
|
||||
},
|
||||
{
|
||||
id: 'lp_15',
|
||||
title: 'Kubernetes and Microservices',
|
||||
description: 'Build and deploy microservices architecture with Kubernetes orchestration',
|
||||
category: 'DevOps',
|
||||
difficulty: 'advanced',
|
||||
estimatedTime: '12 weeks',
|
||||
progress: 30,
|
||||
modules: [
|
||||
{
|
||||
id: 'mod_30',
|
||||
title: 'Microservices Design',
|
||||
description: 'Designing and implementing microservices',
|
||||
completed: true,
|
||||
resources: [
|
||||
{ type: 'video', title: 'Microservices Architecture', url: 'https://example.com/microservices' },
|
||||
{ type: 'article', title: 'Service Communication', url: 'https://example.com/service-comm' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'mod_31',
|
||||
title: 'Kubernetes Production',
|
||||
description: 'Production-ready Kubernetes deployments',
|
||||
completed: false,
|
||||
resources: [
|
||||
{ type: 'video', title: 'K8s Production Guide', url: 'https://example.com/k8s-prod' },
|
||||
{ type: 'lab', title: 'Production Deployment Lab', url: 'https://example.com/prod-lab' }
|
||||
]
|
||||
}
|
||||
],
|
||||
tags: [
|
||||
{ name: 'kubernetes', color: '#326ce5' },
|
||||
{ name: 'microservices', color: '#ff6b6b' },
|
||||
{ name: 'devops', color: '#4ecdc4' }
|
||||
],
|
||||
createdAt: '4 days ago',
|
||||
enrolledAt: undefined
|
||||
}
|
||||
];
|
||||
|
||||
@@ -2350,7 +2616,7 @@ export const getMockStats = () => ({
|
||||
tasks: -5,
|
||||
notes: 12
|
||||
},
|
||||
weeklyActivity: [12, 19, 8, 15, 22, 18, 25],
|
||||
weeklyActivity: Array.from({length: 7}, () => Math.floor(Math.random() * 30) + 5), // Random values between 5-35
|
||||
// Additional stats for enhanced dashboard
|
||||
totalVideos: mockVideos.length,
|
||||
totalLearningPaths: mockLearningPaths.length,
|
||||
|
||||
@@ -11,36 +11,45 @@ export const WeeklyBarChart = (props: WeeklyBarChartProps) => {
|
||||
|
||||
return (
|
||||
<div class="space-y-4">
|
||||
<div class="relative h-32 md:h-36 px-6 weekly-activity-chart">
|
||||
<div class="relative h-32 md:h-36 px-2 sm:px-4 lg:px-6 weekly-activity-chart">
|
||||
{/* Grid lines */}
|
||||
<div class="absolute inset-x-0 inset-y-2 pointer-events-none flex flex-col justify-between">
|
||||
<div class="border-t border-border/60"></div>
|
||||
<div class="border-t border-border/40"></div>
|
||||
<div class="border-t border-border/30"></div>
|
||||
<div class="border-t border-border/20"></div>
|
||||
</div>
|
||||
<div class="relative flex items-end justify-between h-full gap-3 md:gap-4">
|
||||
|
||||
{/* Bars container */}
|
||||
<div class="relative flex items-end justify-between h-full w-full">
|
||||
{['M', 'T', 'W', 'T', 'F', 'S', 'S'].map((day, index) => {
|
||||
const activity = weeklyData()[index];
|
||||
const maxActivity = Math.max(...weeklyData());
|
||||
// Use dynamic scale based on actual data
|
||||
const fixedMax = Math.max(maxActivity, 30); // Ensure minimum scale for better visualization
|
||||
const containerHeight = 128; // h-32 = 128px (base), md:h-36 = 144px
|
||||
const availableHeight = containerHeight * 0.75; // Use 75% of container height to leave room for labels
|
||||
const heightPercent = (activity / fixedMax) * (availableHeight / containerHeight) * 100;
|
||||
const minHeightPercent = (8 / containerHeight) * 100; // Minimum 8px height
|
||||
const finalHeightPercent = Math.max(heightPercent, minHeightPercent);
|
||||
const minActivity = Math.min(...weeklyData());
|
||||
|
||||
// Calculate responsive height with proper scaling
|
||||
let heightPercent;
|
||||
if (maxActivity === minActivity) {
|
||||
// All values are the same, use 80% height for consistency
|
||||
heightPercent = 80;
|
||||
} else {
|
||||
// Use the actual range for proportional scaling
|
||||
const range = maxActivity - minActivity;
|
||||
const normalizedValue = activity - minActivity;
|
||||
// Scale to 20-90% range to ensure visibility while maintaining proportions
|
||||
heightPercent = 20 + (normalizedValue / range) * 70;
|
||||
}
|
||||
|
||||
// Ensure minimum height for very small values but maintain proportion
|
||||
const finalHeightPercent = Math.max(heightPercent, 8);
|
||||
|
||||
return (
|
||||
<div class="flex flex-col items-center flex-1 gap-2 group min-w-0 max-w-8">
|
||||
<div class="relative w-full max-w-4 md:max-w-5 flex flex-col items-center">
|
||||
<span
|
||||
class="text-xs font-medium text-primary mb-1 opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap absolute -top-5"
|
||||
>
|
||||
{activity}
|
||||
</span>
|
||||
<div class="flex flex-col items-center flex-1 gap-2 group min-w-0 max-w-4 h-full">
|
||||
<div class="relative w-full max-w-2 md:max-w-3 flex flex-col items-center justify-end h-full">
|
||||
<span class="text-xs font-medium text-primary mb-1 opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap absolute -top-5 z-10">{activity}</span>
|
||||
<div
|
||||
class="w-full max-w-4 md:max-w-5 bg-primary rounded-t transition-all duration-500 hover:opacity-80 cursor-pointer hover:scale-105 weekly-bar"
|
||||
style={`height: ${finalHeightPercent}%; background-color: hsl(199, 89%, 67%); min-height: 8px;`}
|
||||
class="w-full max-w-2 md:max-w-3 bg-primary rounded-t transition-all duration-500 hover:opacity-80 cursor-pointer hover:scale-105 weekly-bar"
|
||||
style={`height: ${finalHeightPercent}%; background-color: rgb(96, 198, 246); min-height: 4px;`}
|
||||
title={`${day}: ${activity} ${chartType()}`}
|
||||
></div>
|
||||
</div>
|
||||
@@ -51,7 +60,8 @@ export const WeeklyBarChart = (props: WeeklyBarChartProps) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between text-xs text-muted-foreground pt-2 border-t border-border">
|
||||
{/* Summary */}
|
||||
<div class="flex justify-between text-xs text-muted-foreground pt-2 border-t border-border px-2 sm:px-4 lg:px-6">
|
||||
<span>Total: {weeklyData().reduce((a, b) => a + b, 0)} {chartType()}</span>
|
||||
<span>Avg: {Math.round(weeklyData().reduce((a, b) => a + b, 0) / 7)} per day</span>
|
||||
</div>
|
||||
|
||||
@@ -3,24 +3,19 @@ import { Card } from '@/components/ui/Card';
|
||||
import { IconBrain, IconFileText, IconChecklist, IconSparkles, IconRobot, IconSettings } from '@tabler/icons-solidjs';
|
||||
import { AIProviderIcon } from '@/components/AIProviderIcon';
|
||||
|
||||
interface AIProvider {
|
||||
interface AIModel {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
models: {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
}[];
|
||||
provider: string;
|
||||
category: string;
|
||||
iconId?: string;
|
||||
}
|
||||
|
||||
export const AIAssistant = () => {
|
||||
const [activeTab, setActiveTab] = createSignal<'dashboard' | 'summarizer' | 'tasks' | 'content' | 'settings'>('dashboard');
|
||||
const [selectedProvider, setSelectedProvider] = createSignal<string>('');
|
||||
const [selectedModel, setSelectedModel] = createSignal<string>('standard');
|
||||
const [enabledProviders, setEnabledProviders] = createSignal<string[]>([]);
|
||||
const [providers, setProviders] = createSignal<AIProvider[]>([]);
|
||||
const [selectedModel, setSelectedModel] = createSignal<string>('longcat-flash-chat');
|
||||
const [aiModels, setAIModels] = createSignal<AIModel[]>([]);
|
||||
|
||||
const tabs = [
|
||||
{ id: 'dashboard', label: 'AI Dashboard', icon: IconBrain },
|
||||
@@ -30,44 +25,21 @@ export const AIAssistant = () => {
|
||||
{ id: 'settings', label: 'AI Settings', icon: IconSettings },
|
||||
];
|
||||
|
||||
// Fetch available providers on mount
|
||||
onMount(async () => {
|
||||
try {
|
||||
const response = await fetch(`${import.meta.env.VITE_API_URL}/v1/ai/providers`);
|
||||
const data = await response.json();
|
||||
setProviders(data.providers || []);
|
||||
|
||||
// Enable all providers by default
|
||||
const providerIds = (data.providers || []).map((p: AIProvider) => p.id);
|
||||
setEnabledProviders(providerIds);
|
||||
|
||||
// Set default provider if available
|
||||
if (data.providers && data.providers.length > 0) {
|
||||
setSelectedProvider(data.providers[0].id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch AI providers:', error);
|
||||
}
|
||||
// Initialize AI models on mount
|
||||
onMount(() => {
|
||||
const models: AIModel[] = [
|
||||
{ id: 'longcat-flash-chat', name: 'LongCat Flash Chat', description: 'Fast and efficient chat model', provider: 'longcat', category: 'fast', iconId: 'longcat' },
|
||||
{ id: 'longcat-flash-thinking', name: 'LongCat Flash Thinking', description: 'Advanced reasoning model', provider: 'longcat', category: 'thinking', iconId: 'longcat' },
|
||||
{ id: 'mistral-small-latest', name: 'Mistral Small', description: 'Lightweight and fast', provider: 'mistral', category: 'standard', iconId: 'mistral' },
|
||||
{ id: 'mistral-large-latest', name: 'Mistral Large', description: 'Most capable model', provider: 'mistral', category: 'advanced', iconId: 'mistral' },
|
||||
{ id: 'grok-standard', name: 'Grok Standard', description: 'Grok from X', provider: 'grok', category: 'standard', iconId: 'grok' },
|
||||
{ id: 'deepseek-chat', name: 'DeepSeek Chat', description: 'DeepSeek chat model', provider: 'deepseek', category: 'standard', iconId: 'deepseek' },
|
||||
{ id: 'ollama-local', name: 'Ollama Local', description: 'Local Ollama model', provider: 'ollama', category: 'local', iconId: 'ollama' },
|
||||
{ id: 'openrouter-auto', name: 'OpenRouter Auto', description: 'Router over many models', provider: 'openrouter', category: 'standard', iconId: 'openrouter' },
|
||||
];
|
||||
setAIModels(models);
|
||||
});
|
||||
|
||||
const toggleProvider = (providerId: string) => {
|
||||
const enabled = enabledProviders();
|
||||
if (enabled.includes(providerId)) {
|
||||
// Remove provider if it's currently selected, select another
|
||||
if (selectedProvider() === providerId) {
|
||||
const remaining = enabled.filter(p => p !== providerId);
|
||||
setSelectedProvider(remaining.length > 0 ? remaining[0] : '');
|
||||
}
|
||||
setEnabledProviders(enabled.filter(p => p !== providerId));
|
||||
} else {
|
||||
setEnabledProviders([...enabled, providerId]);
|
||||
// If this is the first provider, select it
|
||||
if (enabled.length === 0) {
|
||||
setSelectedProvider(providerId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="space-y-6">
|
||||
{/* Header */}
|
||||
@@ -81,30 +53,20 @@ export const AIAssistant = () => {
|
||||
Leverage AI to enhance your productivity and content management
|
||||
</p>
|
||||
</div>
|
||||
{enabledProviders().length > 0 && (
|
||||
{aiModels().length > 0 && (
|
||||
<div class="flex items-center gap-3 text-sm">
|
||||
<span class="text-gray-500">Active:</span>
|
||||
<div class="flex items-center gap-2">
|
||||
{enabledProviders().map(providerId => {
|
||||
const provider = providers().find(p => p.id === providerId);
|
||||
return (
|
||||
<div class="flex items-center gap-1 px-2 py-1 bg-blue-50 dark:bg-blue-900/20 rounded-md">
|
||||
<AIProviderIcon
|
||||
providerId={providerId}
|
||||
size="1.25rem"
|
||||
class="text-primary"
|
||||
/>
|
||||
<span class="font-medium text-blue-600 dark:text-blue-400">
|
||||
{provider?.name || providerId}
|
||||
</span>
|
||||
{selectedModel() !== 'standard' && selectedProvider() === providerId && (
|
||||
<span class="text-xs text-blue-500">
|
||||
{provider?.models.find(m => m.id === selectedModel())?.name?.split('-')[0]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div class="flex items-center gap-1 px-2 py-1 bg-blue-50 dark:bg-blue-900/20 rounded-md">
|
||||
<AIProviderIcon
|
||||
providerId={aiModels().find(m => m.id === selectedModel())?.iconId || 'longcat'}
|
||||
size="1.25rem"
|
||||
class="text-primary"
|
||||
/>
|
||||
<span class="font-medium text-blue-600 dark:text-blue-400">
|
||||
{aiModels().find(m => m.id === selectedModel())?.name?.split(' ')[0] || 'AI'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -135,116 +97,83 @@ export const AIAssistant = () => {
|
||||
<Card class="p-6">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">AI Provider Settings</h3>
|
||||
<div class="space-y-6">
|
||||
{/* Provider Toggles */}
|
||||
{/* AI Models */}
|
||||
<div>
|
||||
<h4 class="text-md font-medium text-gray-800 dark:text-gray-200 mb-3">Available Providers</h4>
|
||||
<h4 class="text-md font-medium text-gray-800 dark:text-gray-200 mb-3">Available AI Models</h4>
|
||||
<div class="space-y-3">
|
||||
{providers().map((provider) => {
|
||||
const isEnabled = enabledProviders().includes(provider.id);
|
||||
return (
|
||||
<div
|
||||
class={`p-4 border rounded-lg transition-all ${
|
||||
isEnabled
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'border-gray-200 dark:border-gray-700'
|
||||
}`}
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<AIProviderIcon
|
||||
providerId={provider.id}
|
||||
size="2rem"
|
||||
class="text-primary"
|
||||
/>
|
||||
<div>
|
||||
<h5 class="font-medium text-gray-900 dark:text-white">{provider.name}</h5>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{provider.description}</p>
|
||||
{aiModels().map((model) => (
|
||||
<div
|
||||
class={`p-4 border rounded-lg transition-all ${
|
||||
selectedModel() === model.id
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'border-gray-200 dark:border-gray-700'
|
||||
}`}
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<AIProviderIcon
|
||||
providerId={model.iconId!}
|
||||
size="2rem"
|
||||
class="text-primary"
|
||||
/>
|
||||
<div>
|
||||
<h5 class="font-medium text-gray-900 dark:text-white">{model.name}</h5>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{model.description}</p>
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<span class="text-xs px-2 py-1 bg-blue-100 text-blue-800 rounded-full">
|
||||
{model.provider}
|
||||
</span>
|
||||
<span class={`text-xs px-2 py-1 rounded-full ${
|
||||
model.category === 'thinking'
|
||||
? 'bg-purple-100 text-purple-800'
|
||||
: model.category === 'fast'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: model.category === 'advanced'
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{model.category}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => toggleProvider(provider.id)}
|
||||
class={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
isEnabled
|
||||
? 'bg-blue-600'
|
||||
: 'bg-gray-200 dark:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
class={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
isEnabled ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Model selection for enabled providers */}
|
||||
{isEnabled && (
|
||||
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Model:
|
||||
</label>
|
||||
<select
|
||||
value={selectedProvider() === provider.id ? selectedModel() : 'standard'}
|
||||
onChange={(e) => {
|
||||
setSelectedProvider(provider.id);
|
||||
setSelectedModel(e.target.value);
|
||||
}}
|
||||
class="text-sm px-2 py-1 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600"
|
||||
>
|
||||
{provider.models.map((model) => (
|
||||
<option value={model.id}>
|
||||
{model.type} - {model.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Model badges */}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{provider.models.map((model) => (
|
||||
<div
|
||||
class={`px-2 py-1 text-xs rounded-full border ${
|
||||
model.id.includes('thinking') || model.id.includes('reasoner')
|
||||
? 'bg-purple-100 text-purple-800 border-purple-300 dark:bg-purple-900 dark:text-purple-200'
|
||||
: 'bg-gray-100 text-gray-800 border-gray-300 dark:bg-gray-700 dark:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
{model.type}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setSelectedModel(model.id)}
|
||||
class={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
selectedModel() === model.id
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{selectedModel() === model.id ? 'Selected' : 'Select'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current Selection */}
|
||||
{enabledProviders().length > 0 && (
|
||||
<div>
|
||||
<h4 class="text-md font-medium text-gray-800 dark:text-gray-200 mb-3">Current Selection</h4>
|
||||
<div class="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<AIProviderIcon
|
||||
providerId={selectedProvider()}
|
||||
size="1.5rem"
|
||||
class="text-primary"
|
||||
/>
|
||||
<div>
|
||||
<p class="font-medium text-gray-900 dark:text-white">
|
||||
{providers().find(p => p.id === selectedProvider())?.name}
|
||||
</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{providers().find(p => p.id === selectedProvider())?.models.find(m => m.id === selectedModel())?.name}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-md font-medium text-gray-800 dark:text-gray-200 mb-3">Current Selection</h4>
|
||||
<div class="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<AIProviderIcon
|
||||
providerId={aiModels().find(m => m.id === selectedModel())?.iconId || 'longcat'}
|
||||
size="1.5rem"
|
||||
class="text-primary"
|
||||
/>
|
||||
<div>
|
||||
<p class="font-medium text-gray-900 dark:text-white">
|
||||
{aiModels().find(m => m.id === selectedModel())?.name}
|
||||
</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{aiModels().find(m => m.id === selectedModel())?.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
+279
-419
@@ -1,4 +1,4 @@
|
||||
import { createSignal, For, Show, onMount } from 'solid-js'
|
||||
import { createSignal, For, Show, onMount, createEffect } from 'solid-js'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Card } from '@/components/ui/Card'
|
||||
@@ -6,20 +6,27 @@ import {
|
||||
MessageCircle,
|
||||
Brain,
|
||||
Cog,
|
||||
Send
|
||||
Send,
|
||||
ChevronDown,
|
||||
User,
|
||||
Bot
|
||||
} from 'lucide-solid'
|
||||
import { AIProviderIcon } from '@/components/AIProviderIcon'
|
||||
|
||||
interface AIProvider {
|
||||
interface AIModel {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
models: {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
}[];
|
||||
provider: string
|
||||
category: string
|
||||
iconId?: string
|
||||
}
|
||||
|
||||
interface Message {
|
||||
id: string
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
timestamp: Date
|
||||
}
|
||||
|
||||
export const AIChat = () => {
|
||||
@@ -27,65 +34,23 @@ export const AIChat = () => {
|
||||
const [isSidebarOpen, setIsSidebarOpen] = createSignal(true)
|
||||
|
||||
// Chat state
|
||||
const [messages, setMessages] = createSignal<any[]>([
|
||||
const [messages, setMessages] = createSignal<Message[]>([
|
||||
{
|
||||
id: 1,
|
||||
content: 'Hello! I\'m your AI assistant. How can I help you today?',
|
||||
id: '1',
|
||||
role: 'assistant',
|
||||
created_at: new Date().toISOString()
|
||||
content: 'Hello! I\'m your AI assistant. How can I help you today?',
|
||||
timestamp: new Date()
|
||||
}
|
||||
])
|
||||
const [inputMessage, setInputMessage] = createSignal('')
|
||||
const [isLoading, setIsLoading] = createSignal(false)
|
||||
|
||||
// AI Provider state
|
||||
const [selectedProvider, setSelectedProvider] = createSignal<string>('')
|
||||
const [selectedModel, setSelectedModel] = createSignal<string>('standard')
|
||||
const [enabledProviders, setEnabledProviders] = createSignal<string[]>([])
|
||||
const [providers, setProviders] = createSignal<AIProvider[]>([])
|
||||
// AI Model state
|
||||
const [selectedModel, setSelectedModel] = createSignal<string>('longcat-flash-chat')
|
||||
const [showModelPicker, setShowModelPicker] = createSignal(false)
|
||||
const [aiModels, setAIModels] = createSignal<AIModel[]>([])
|
||||
|
||||
// Per-user AI settings (mirrors /api/v1/auth/ai/settings)
|
||||
const [aiSettings, setAISettings] = createSignal({
|
||||
mistral: { enabled: false, api_key: '', model: '', model_thinking: '' },
|
||||
grok: { enabled: false, api_key: '', base_url: '', model: '', model_thinking: '' },
|
||||
deepseek: { enabled: false, api_key: '', base_url: '', model: '', model_thinking: '' },
|
||||
ollama: { enabled: false, base_url: '', model: '', model_thinking: '' },
|
||||
longcat: { enabled: false, api_key: '', base_url: '', openai_endpoint: '', anthropic_endpoint: '', model: '', model_thinking: '', model_thinking_upgraded: '', format: 'openai' }
|
||||
})
|
||||
const [aiSettingsLoading, setAiSettingsLoading] = createSignal(false)
|
||||
const [aiSettingsMessage, setAiSettingsMessage] = createSignal('')
|
||||
|
||||
const handleSendMessage = async () => {
|
||||
const message = inputMessage().trim()
|
||||
if (!message || isLoading()) return
|
||||
|
||||
// Add user message
|
||||
const userMessage = {
|
||||
id: Date.now(),
|
||||
content: message,
|
||||
role: 'user',
|
||||
created_at: new Date().toISOString()
|
||||
}
|
||||
|
||||
setMessages(prev => [...prev, userMessage])
|
||||
setInputMessage('')
|
||||
setIsLoading(true)
|
||||
|
||||
// Simulate AI response
|
||||
setTimeout(() => {
|
||||
const aiResponse = {
|
||||
id: Date.now() + 1,
|
||||
content: `I received your message: "${message}". This is a demo response from the AI assistant. In production, I would provide a helpful response based on the selected AI provider and model.`,
|
||||
role: 'assistant',
|
||||
created_at: new Date().toISOString()
|
||||
}
|
||||
setMessages(prev => [...prev, aiResponse])
|
||||
setIsLoading(false)
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
|
||||
// Check mobile on mount
|
||||
// Initialize AI models
|
||||
onMount(() => {
|
||||
const checkMobile = () => {
|
||||
if (window.innerWidth < 768) {
|
||||
@@ -96,121 +61,121 @@ export const AIChat = () => {
|
||||
checkMobile()
|
||||
window.addEventListener('resize', checkMobile)
|
||||
|
||||
// Fetch AI providers
|
||||
fetchAIProviders()
|
||||
// Load per-user AI provider settings
|
||||
loadAISettings()
|
||||
// Initialize AI models
|
||||
initializeAIModels()
|
||||
|
||||
return () => window.removeEventListener('resize', checkMobile)
|
||||
})
|
||||
|
||||
const fetchAIProviders = async () => {
|
||||
try {
|
||||
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:8080'
|
||||
const response = await fetch(`${apiUrl}/api/v1/ai/providers`)
|
||||
const data = await response.json()
|
||||
setProviders(data.providers || [])
|
||||
|
||||
const providerIds = (data.providers || []).map((p: AIProvider) => p.id)
|
||||
setEnabledProviders(providerIds)
|
||||
|
||||
if (data.providers && data.providers.length > 0) {
|
||||
setSelectedProvider(data.providers[0].id)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch AI providers:', error)
|
||||
// Set mock providers for demo mode
|
||||
const mockProviders: AIProvider[] = [
|
||||
{
|
||||
id: 'longcat',
|
||||
name: 'LongCat AI',
|
||||
description: 'Fast and efficient AI models',
|
||||
icon: '🐱',
|
||||
models: [
|
||||
{ id: 'longcat-flash-chat', name: 'LongCat Flash Chat', type: 'chat' },
|
||||
{ id: 'longcat-flash-thinking', name: 'LongCat Flash Thinking', type: 'thinking' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'mistral',
|
||||
name: 'Mistral AI',
|
||||
description: 'Advanced language models',
|
||||
icon: '🌊',
|
||||
models: [
|
||||
{ id: 'mistral-small-latest', name: 'Mistral Small', type: 'chat' },
|
||||
{ id: 'mistral-large-latest', name: 'Mistral Large', type: 'chat' }
|
||||
]
|
||||
}
|
||||
]
|
||||
setProviders(mockProviders)
|
||||
setEnabledProviders(['longcat'])
|
||||
setSelectedProvider('longcat')
|
||||
}
|
||||
const initializeAIModels = () => {
|
||||
const models: AIModel[] = [
|
||||
{ id: 'longcat-flash-chat', name: 'LongCat Flash Chat', description: 'Fast and efficient chat model', provider: 'longcat', category: 'fast', iconId: 'longcat' },
|
||||
{ id: 'longcat-flash-thinking', name: 'LongCat Flash Thinking', description: 'Advanced reasoning model', provider: 'longcat', category: 'thinking', iconId: 'longcat' },
|
||||
{ id: 'mistral-small-latest', name: 'Mistral Small', description: 'Lightweight and fast', provider: 'mistral', category: 'standard', iconId: 'mistral' },
|
||||
{ id: 'mistral-large-latest', name: 'Mistral Large', description: 'Most capable model', provider: 'mistral', category: 'advanced', iconId: 'mistral' },
|
||||
{ id: 'grok-standard', name: 'Grok Standard', description: 'Grok from X', provider: 'grok', category: 'standard', iconId: 'grok' },
|
||||
{ id: 'deepseek-chat', name: 'DeepSeek Chat', description: 'DeepSeek chat model', provider: 'deepseek', category: 'standard', iconId: 'deepseek' },
|
||||
{ id: 'ollama-local', name: 'Ollama Local', description: 'Local Ollama model', provider: 'ollama', category: 'local', iconId: 'ollama' },
|
||||
{ id: 'openrouter-auto', name: 'OpenRouter Auto', description: 'Router over many models', provider: 'openrouter', category: 'standard', iconId: 'openrouter' },
|
||||
]
|
||||
setAIModels(models)
|
||||
}
|
||||
|
||||
const loadAISettings = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/auth/ai/settings`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
const handleSendMessage = async () => {
|
||||
const message = inputMessage().trim()
|
||||
if (!message || isLoading()) return
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setAISettings(data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load AI settings:', error)
|
||||
// Add user message
|
||||
const userMessage: Message = {
|
||||
id: Date.now().toString(),
|
||||
content: message,
|
||||
role: 'user',
|
||||
timestamp: new Date()
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateAISettings = async () => {
|
||||
setAiSettingsLoading(true)
|
||||
setAiSettingsMessage('')
|
||||
|
||||
setMessages(prev => [...prev, userMessage])
|
||||
setInputMessage('')
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/auth/ai/settings`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(aiSettings())
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
setAiSettingsMessage('AI settings updated successfully!')
|
||||
await loadAISettings()
|
||||
} else {
|
||||
const error = await response.json()
|
||||
setAiSettingsMessage(error.error || 'Failed to update AI settings')
|
||||
// Call AI API
|
||||
const response = await callAIAPI(message, selectedModel())
|
||||
|
||||
const aiMessage: Message = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
role: 'assistant',
|
||||
content: response,
|
||||
timestamp: new Date()
|
||||
}
|
||||
|
||||
setMessages(prev => [...prev, aiMessage])
|
||||
} catch (error) {
|
||||
console.error('Failed to update AI settings:', error)
|
||||
setAiSettingsMessage('Failed to update AI settings')
|
||||
console.error('AI API call failed:', error)
|
||||
|
||||
// Fallback response
|
||||
const errorMessage: Message = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
role: 'assistant',
|
||||
content: 'I apologize, but I encountered an error while processing your request. Please try again later.',
|
||||
timestamp: new Date()
|
||||
}
|
||||
|
||||
setMessages(prev => [...prev, errorMessage])
|
||||
} finally {
|
||||
setAiSettingsLoading(false)
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleProvider = (providerId: string) => {
|
||||
const enabled = enabledProviders()
|
||||
if (enabled.includes(providerId)) {
|
||||
if (selectedProvider() === providerId) {
|
||||
const remaining = enabled.filter(p => p !== providerId)
|
||||
setSelectedProvider(remaining.length > 0 ? remaining[0] : '')
|
||||
}
|
||||
setEnabledProviders(enabled.filter(p => p !== providerId))
|
||||
} else {
|
||||
setEnabledProviders([...enabled, providerId])
|
||||
if (enabled.length === 0) {
|
||||
setSelectedProvider(providerId)
|
||||
const callAIAPI = async (message: string, modelId: string): Promise<string> => {
|
||||
const token = localStorage.getItem('token')
|
||||
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:8080'
|
||||
|
||||
const response = await fetch(`${apiUrl}/api/v1/ai/chat`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': token ? `Bearer ${token}` : '',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message,
|
||||
model: modelId,
|
||||
stream: false
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API call failed: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data.response || data.content || 'I understand your message. Let me help you with that.'
|
||||
}
|
||||
|
||||
|
||||
// Close model picker when clicking outside
|
||||
createEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
if (!target.closest('#model-picker-container')) {
|
||||
setShowModelPicker(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (showModelPicker()) {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
return () => document.removeEventListener('click', handleClickOutside)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
const startNewChat = () => {
|
||||
setMessages([{
|
||||
id: '1',
|
||||
role: 'assistant',
|
||||
content: 'Hello! I\'m your AI assistant. How can I help you today?',
|
||||
timestamp: new Date()
|
||||
}])
|
||||
setInputMessage('')
|
||||
}
|
||||
|
||||
|
||||
@@ -231,8 +196,8 @@ export const AIChat = () => {
|
||||
|
||||
{/* AI Logo */}
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-8 h-8 bg-gradient-to-br from-blue-500 to-purple-600 rounded-lg flex items-center justify-center">
|
||||
<Brain class="w-5 h-5 text-white" />
|
||||
<div class="w-8 h-8 bg-muted rounded-lg flex items-center justify-center">
|
||||
<Brain class="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<h1 class="font-semibold text-lg">AI Assistant</h1>
|
||||
@@ -304,15 +269,7 @@ export const AIChat = () => {
|
||||
<div class="space-y-3">
|
||||
{/* New Chat Button */}
|
||||
<Button
|
||||
onClick={() => {
|
||||
setMessages([{
|
||||
id: 1,
|
||||
content: 'Hello! I\'m your AI assistant. How can I help you today?',
|
||||
role: 'assistant',
|
||||
created_at: new Date().toISOString()
|
||||
}])
|
||||
setInputMessage('')
|
||||
}}
|
||||
onClick={startNewChat}
|
||||
class="w-full justify-start"
|
||||
variant="outline"
|
||||
>
|
||||
@@ -334,10 +291,10 @@ export const AIChat = () => {
|
||||
class="w-full text-left p-3 rounded-lg hover:bg-muted transition-colors"
|
||||
onClick={() => {
|
||||
setMessages([{
|
||||
id: 1,
|
||||
id: '1',
|
||||
content: `This is the ${session.title} session. How can I help you?`,
|
||||
role: 'assistant',
|
||||
created_at: new Date().toISOString()
|
||||
timestamp: new Date()
|
||||
}])
|
||||
}}
|
||||
>
|
||||
@@ -384,13 +341,16 @@ export const AIChat = () => {
|
||||
message.role === 'user' ? 'bg-primary-foreground/20' : 'bg-primary/10'
|
||||
}`}>
|
||||
{message.role === 'user' ? (
|
||||
<span class="text-xs">👤</span>
|
||||
<User class="text-xs" />
|
||||
) : (
|
||||
<span class="text-xs">🤖</span>
|
||||
<Bot class="text-xs" />
|
||||
)}
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm leading-relaxed whitespace-pre-wrap break-words">{message.content}</p>
|
||||
<p class="text-xs opacity-70 mt-2">
|
||||
{message.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -403,7 +363,7 @@ export const AIChat = () => {
|
||||
<div class="bg-muted rounded-lg p-4 max-w-[80%]">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<span class="text-xs">🤖</span>
|
||||
<Bot class="text-xs" />
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
<div class="w-2 h-2 bg-primary rounded-full animate-bounce"></div>
|
||||
@@ -422,6 +382,81 @@ export const AIChat = () => {
|
||||
<div class="p-6">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="flex gap-4">
|
||||
{/* AI Model Switcher */}
|
||||
<div id="model-picker-container" class="relative">
|
||||
<button
|
||||
onClick={() => setShowModelPicker(!showModelPicker())}
|
||||
class="flex items-center gap-2 px-3 py-2 bg-muted hover:bg-muted/80 rounded-lg text-sm transition-colors border border-border/50"
|
||||
>
|
||||
<AIProviderIcon
|
||||
providerId={aiModels().find(m => m.id === selectedModel())?.iconId || 'longcat'}
|
||||
size="1rem"
|
||||
/>
|
||||
<span class="text-sm font-medium">
|
||||
{aiModels().find(m => m.id === selectedModel())?.name?.split(' ')[0] || 'AI'}
|
||||
</span>
|
||||
<ChevronDown class={`h-4 w-4 transition-transform ${showModelPicker() ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{/* Model Picker Dropdown */}
|
||||
<Show when={showModelPicker()}>
|
||||
<div class="absolute bottom-full left-0 mb-2 w-80 bg-background border rounded-lg shadow-lg z-50 p-2 max-h-96 overflow-y-auto">
|
||||
<div class="p-2 border-b mb-2">
|
||||
<h4 class="text-sm font-semibold text-foreground">Select AI Model</h4>
|
||||
<p class="text-xs text-muted-foreground">Choose the best model for your needs</p>
|
||||
</div>
|
||||
<For each={aiModels()}>
|
||||
{model => (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedModel(model.id)
|
||||
setShowModelPicker(false)
|
||||
}}
|
||||
class={`w-full text-left p-3 rounded-lg transition-colors ${
|
||||
selectedModel() === model.id
|
||||
? 'bg-primary/10 border border-primary/20'
|
||||
: 'hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3 flex-1">
|
||||
<AIProviderIcon
|
||||
providerId={model.iconId!}
|
||||
size="1rem"
|
||||
class="rounded-full flex-shrink-0"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium text-sm truncate">{model.name}</div>
|
||||
<div class="text-xs text-muted-foreground mt-1 truncate">{model.description}</div>
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<span class="text-xs px-2 py-1 bg-primary/10 text-primary rounded-full">
|
||||
{model.provider}
|
||||
</span>
|
||||
<span class={`text-xs px-2 py-1 rounded-full ${
|
||||
model.category === 'thinking'
|
||||
? 'bg-purple-100 text-purple-800'
|
||||
: model.category === 'fast'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: model.category === 'advanced'
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}>
|
||||
{model.category}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{selectedModel() === model.id && (
|
||||
<div class="w-2 h-2 bg-primary rounded-full flex-shrink-0"></div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
value={inputMessage()}
|
||||
onInput={(e) => setInputMessage((e.currentTarget as HTMLInputElement).value)}
|
||||
@@ -448,268 +483,93 @@ export const AIChat = () => {
|
||||
|
||||
{/* Settings View */}
|
||||
<Show when={activeView() === 'settings'}>
|
||||
<div class="flex-1 overflow-y-auto p-2">
|
||||
<div class="flex-1 overflow-y-auto p-6">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="mb-8">
|
||||
<h2 class="text-2xl font-bold mb-2">AI Settings</h2>
|
||||
<p class="text-muted-foreground">Configure your AI providers and preferences</p>
|
||||
<p class="text-muted-foreground">Configure your AI models and preferences</p>
|
||||
</div>
|
||||
|
||||
<Card class="p-6">
|
||||
<h3 class="text-lg font-semibold mb-4">AI Provider Settings</h3>
|
||||
<div class="space-y-6">
|
||||
{/* Provider Toggles */}
|
||||
<div>
|
||||
<h4 class="text-md font-medium mb-3">Available Providers</h4>
|
||||
<div class="space-y-3">
|
||||
<For each={providers()}>
|
||||
{(provider) => {
|
||||
const isEnabled = enabledProviders().includes(provider.id)
|
||||
return (
|
||||
<div
|
||||
class={`p-4 border rounded-lg transition-all ${
|
||||
isEnabled
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border'
|
||||
}`}
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<AIProviderIcon
|
||||
providerId={provider.id}
|
||||
size="2rem"
|
||||
class="text-primary"
|
||||
/>
|
||||
<div>
|
||||
<h5 class="font-medium">{provider.name}</h5>
|
||||
<p class="text-sm text-muted-foreground">{provider.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => toggleProvider(provider.id)}
|
||||
class={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
isEnabled
|
||||
? 'bg-primary'
|
||||
: 'bg-muted'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
class={`inline-block h-4 w-4 transform rounded-full bg-background transition-transform ${
|
||||
isEnabled ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Model selection */}
|
||||
{isEnabled && (
|
||||
<div class="mt-4 pt-4 border-t border-border">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<label class="text-sm font-medium">
|
||||
Model:
|
||||
</label>
|
||||
<select
|
||||
value={selectedProvider() === provider.id ? selectedModel() : 'standard'}
|
||||
onChange={(e) => {
|
||||
setSelectedProvider(provider.id)
|
||||
setSelectedModel(e.target.value)
|
||||
}}
|
||||
class="text-sm px-2 py-1 border border-border rounded focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
<For each={provider.models}>
|
||||
{(model) => (
|
||||
<option value={model.id}>
|
||||
{model.type} - {model.name}
|
||||
</option>
|
||||
)}
|
||||
</For>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Response Settings */}
|
||||
<div>
|
||||
<h4 class="text-md font-medium mb-3">Response Settings</h4>
|
||||
<div class="space-y-4">
|
||||
<div class="p-4 border border-border rounded-lg">
|
||||
<label class="block text-sm font-medium mb-2">Response Length</label>
|
||||
<select class="w-full text-sm px-3 py-2 border border-border rounded focus:outline-none focus:ring-2 focus:ring-primary">
|
||||
<option value="concise">Concise</option>
|
||||
<option value="balanced" selected>Balanced</option>
|
||||
<option value="detailed">Detailed</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="p-4 border border-border rounded-lg">
|
||||
<label class="block text-sm font-medium mb-2">Response Style</label>
|
||||
<select class="w-full text-sm px-3 py-2 border border-border rounded focus:outline-none focus:ring-2 focus:ring-primary">
|
||||
<option value="professional" selected>Professional</option>
|
||||
<option value="casual">Casual</option>
|
||||
<option value="technical">Technical</option>
|
||||
<option value="creative">Creative</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Account-level provider settings (example: LongCat) */}
|
||||
<div class="pt-4 mt-2 border-t border-border space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="text-md font-medium">Account Provider Settings</h4>
|
||||
<span class="text-xs text-muted-foreground">{aiSettingsMessage()}</span>
|
||||
</div>
|
||||
|
||||
<div class="border rounded-lg p-4 space-y-3">
|
||||
<h3 class="text-lg font-semibold mb-4">Available AI Models</h3>
|
||||
<div class="space-y-4">
|
||||
<For each={aiModels()}>
|
||||
{(model) => (
|
||||
<div
|
||||
class={`p-4 border rounded-lg transition-all ${
|
||||
selectedModel() === model.id
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:bg-muted/50'
|
||||
}`}
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-2 h-2 bg-purple-500 rounded-full" />
|
||||
<span class="text-sm font-medium">LongCat AI</span>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-xs cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={aiSettings().longcat.enabled}
|
||||
onChange={(e) => {
|
||||
const settings = aiSettings()
|
||||
setAISettings({
|
||||
...settings,
|
||||
longcat: { ...settings.longcat, enabled: e.currentTarget.checked }
|
||||
})
|
||||
}}
|
||||
class="rounded border-input"
|
||||
/>
|
||||
<span>Enabled</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-muted-foreground mb-1">API Key</label>
|
||||
<input
|
||||
type="password"
|
||||
value={aiSettings().longcat.api_key}
|
||||
onInput={(e) => {
|
||||
const settings = aiSettings()
|
||||
setAISettings({
|
||||
...settings,
|
||||
longcat: { ...settings.longcat, api_key: e.currentTarget.value }
|
||||
})
|
||||
}}
|
||||
placeholder="LongCat API key"
|
||||
class="flex h-9 w-full rounded-md border border-input bg-background px-2 py-1.5 text-xs text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
<div class="flex items-center gap-3">
|
||||
<AIProviderIcon
|
||||
providerId={model.iconId!}
|
||||
size="2rem"
|
||||
class="text-primary"
|
||||
/>
|
||||
<div>
|
||||
<h5 class="font-medium">{model.name}</h5>
|
||||
<p class="text-sm text-muted-foreground">{model.description}</p>
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<span class="text-xs px-2 py-1 bg-primary/10 text-primary rounded-full">
|
||||
{model.provider}
|
||||
</span>
|
||||
<span class={`text-xs px-2 py-1 rounded-full ${
|
||||
model.category === 'thinking'
|
||||
? 'bg-purple-100 text-purple-800'
|
||||
: model.category === 'fast'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: model.category === 'advanced'
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}>
|
||||
{model.category}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-muted-foreground mb-1">Base URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={aiSettings().longcat.base_url}
|
||||
onInput={(e) => {
|
||||
const settings = aiSettings()
|
||||
setAISettings({
|
||||
...settings,
|
||||
longcat: { ...settings.longcat, base_url: e.currentTarget.value }
|
||||
})
|
||||
}}
|
||||
class="flex h-9 w-full rounded-md border border-input bg-background px-2 py-1.5 text-xs text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-muted-foreground mb-1">Chat Model</label>
|
||||
<input
|
||||
type="text"
|
||||
value={aiSettings().longcat.model}
|
||||
onInput={(e) => {
|
||||
const settings = aiSettings()
|
||||
setAISettings({
|
||||
...settings,
|
||||
longcat: { ...settings.longcat, model: e.currentTarget.value }
|
||||
})
|
||||
}}
|
||||
class="flex h-9 w-full rounded-md border border-input bg-background px-2 py-1.5 text-xs text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-muted-foreground mb-1">Thinking Model</label>
|
||||
<input
|
||||
type="text"
|
||||
value={aiSettings().longcat.model_thinking}
|
||||
onInput={(e) => {
|
||||
const settings = aiSettings()
|
||||
setAISettings({
|
||||
...settings,
|
||||
longcat: { ...settings.longcat, model_thinking: e.currentTarget.value }
|
||||
})
|
||||
}}
|
||||
class="flex h-9 w-full rounded-md border border-input bg-background px-2 py-1.5 text-xs text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-muted-foreground mb-1">Upgraded Thinking</label>
|
||||
<input
|
||||
type="text"
|
||||
value={aiSettings().longcat.model_thinking_upgraded}
|
||||
onInput={(e) => {
|
||||
const settings = aiSettings()
|
||||
setAISettings({
|
||||
...settings,
|
||||
longcat: { ...settings.longcat, model_thinking_upgraded: e.currentTarget.value }
|
||||
})
|
||||
}}
|
||||
class="flex h-9 w-full rounded-md border border-input bg-background px-2 py-1.5 text-xs text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-muted-foreground mb-1">Format</label>
|
||||
<select
|
||||
value={aiSettings().longcat.format}
|
||||
onChange={(e) => {
|
||||
const settings = aiSettings()
|
||||
setAISettings({
|
||||
...settings,
|
||||
longcat: { ...settings.longcat, format: e.currentTarget.value as 'openai' | 'anthropic' }
|
||||
})
|
||||
}}
|
||||
class="flex h-9 w-full rounded-md border border-input bg-background px-2 py-1.5 text-xs text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
<button
|
||||
onClick={() => setSelectedModel(model.id)}
|
||||
class={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
selectedModel() === model.id
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted hover:bg-muted/80'
|
||||
}`}
|
||||
>
|
||||
<option value="openai">OpenAI Compatible</option>
|
||||
<option value="anthropic">Anthropic Compatible</option>
|
||||
</select>
|
||||
{selectedModel() === model.id ? 'Selected' : 'Select'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div class="flex items-center gap-3 pt-2">
|
||||
<Button
|
||||
onClick={handleUpdateAISettings}
|
||||
disabled={aiSettingsLoading()}
|
||||
>
|
||||
{aiSettingsLoading() ? 'Saving...' : 'Save AI Settings'}
|
||||
</Button>
|
||||
<a
|
||||
href="/app/settings"
|
||||
class="ml-auto text-xs text-primary hover:underline"
|
||||
>
|
||||
Open full AI settings
|
||||
</a>
|
||||
</div>
|
||||
<Card class="p-6 mt-6">
|
||||
<h3 class="text-lg font-semibold mb-4">Current Selection</h3>
|
||||
<div class="p-4 bg-muted/50 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<AIProviderIcon
|
||||
providerId={aiModels().find(m => m.id === selectedModel())?.iconId || 'longcat'}
|
||||
size="1.5rem"
|
||||
class="text-primary"
|
||||
/>
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
{aiModels().find(m => m.id === selectedModel())?.name}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{aiModels().find(m => m.id === selectedModel())?.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,9 +3,10 @@ import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { BookmarkModal } from '@/components/ui/BookmarkModal';
|
||||
import { EditBookmarkModal } from '@/components/ui/EditBookmarkModal';
|
||||
import { VideoUploadModal } from '@/components/ui/VideoUploadModal';
|
||||
import { DropdownMenu, DropdownMenuItem } from '@/components/ui/DropdownMenu';
|
||||
import { SearchTagFilterBar } from '@/components/ui/SearchTagFilterBar';
|
||||
import { IconDotsVertical, IconStar, IconEdit, IconTrash, IconExternalLink, IconVideo } from '@tabler/icons-solidjs';
|
||||
import { IconDotsVertical, IconStar, IconEdit, IconTrash, IconExternalLink, IconVideo, IconBookmark } from '@tabler/icons-solidjs';
|
||||
import { getMockBookmarks, getMockVideos } from '@/lib/mockData';
|
||||
|
||||
interface BookmarkTag {
|
||||
@@ -65,7 +66,21 @@ export const Bookmarks = () => {
|
||||
if (bookmark.favicon) return bookmark.favicon;
|
||||
try {
|
||||
const url = new URL(bookmark.url);
|
||||
return `https://www.google.com/s2/favicons?domain=${url.hostname}&sz=64`;
|
||||
const baseUrl = `${url.protocol}//${url.hostname}`;
|
||||
|
||||
// Try multiple favicon sources
|
||||
const faviconSources = [
|
||||
`${baseUrl}/favicon.ico`,
|
||||
`${baseUrl}/favicon.png`,
|
||||
`${baseUrl}/img/favicons/favicon-32x32.png`,
|
||||
`${baseUrl}/img/favicons/favicon-16x16.png`,
|
||||
`${baseUrl}/logo-without-border.svg`,
|
||||
`${baseUrl}/logo.svg`,
|
||||
`${baseUrl}/icon.svg`,
|
||||
`https://www.google.com/s2/favicons?domain=${url.hostname}&sz=64`
|
||||
];
|
||||
|
||||
return faviconSources[0]; // Return first source, fallback will be handled by error
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
@@ -88,8 +103,11 @@ export const Bookmarks = () => {
|
||||
const [isLoadingVideos, setIsLoadingVideos] = createSignal(true);
|
||||
const [searchTerm, setSearchTerm] = createSignal('');
|
||||
const [selectedTag, setSelectedTag] = createSignal('');
|
||||
const [videoSearchTerm, setVideoSearchTerm] = createSignal('');
|
||||
const [videoSelectedTag, setVideoSelectedTag] = createSignal('');
|
||||
const [showAddModal, setShowAddModal] = createSignal(false);
|
||||
const [showEditModal, setShowEditModal] = createSignal(false);
|
||||
const [showVideoModal, setShowVideoModal] = createSignal(false);
|
||||
const [editingBookmark, setEditingBookmark] = createSignal<Bookmark | null>(null);
|
||||
const [activeTab, setActiveTab] = createSignal<'bookmarks' | 'videos'>('bookmarks');
|
||||
// We no longer show inline HTML content previews, only the bookmark cards themselves
|
||||
@@ -127,22 +145,48 @@ export const Bookmarks = () => {
|
||||
|
||||
try {
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8081/api/v1';
|
||||
const response = await fetch(`${API_BASE_URL}/bookmarks`, {
|
||||
|
||||
// Load regular bookmarks
|
||||
const bookmarksResponse = await fetch(`${API_BASE_URL}/bookmarks`, {
|
||||
headers: {
|
||||
'Authorization': localStorage.getItem('trackeep_token') ? `Bearer ${localStorage.getItem('trackeep_token')}` : '',
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
if (!bookmarksResponse.ok) {
|
||||
throw new Error('Failed to load bookmarks');
|
||||
}
|
||||
const data = await response.json();
|
||||
const bookmarksData = await bookmarksResponse.json();
|
||||
|
||||
// Normalize API response:
|
||||
// - Ensure we always work with an array
|
||||
// - Map Tag objects to simple string[]
|
||||
const normalized: Bookmark[] = (Array.isArray(data) ? data : []).map(adaptBookmarkFromApi);
|
||||
const normalized: Bookmark[] = (Array.isArray(bookmarksData) ? bookmarksData : []).map(adaptBookmarkFromApi);
|
||||
|
||||
setBookmarks(normalized);
|
||||
|
||||
// Load video bookmarks
|
||||
try {
|
||||
const videosResponse = await fetch(`${API_BASE_URL}/youtube/videos`, {
|
||||
headers: {
|
||||
'Authorization': localStorage.getItem('trackeep_token') ? `Bearer ${localStorage.getItem('trackeep_token')}` : '',
|
||||
},
|
||||
});
|
||||
|
||||
if (videosResponse.ok) {
|
||||
const videosData = await videosResponse.json();
|
||||
setVideoBookmarks(Array.isArray(videosData) ? videosData : []);
|
||||
} else {
|
||||
// If video endpoint fails, load mock videos as fallback
|
||||
const mockVideos = getMockVideos();
|
||||
setVideoBookmarks(mockVideos);
|
||||
}
|
||||
} catch (videoError) {
|
||||
console.warn('Failed to load video bookmarks, using mock data:', videoError);
|
||||
const mockVideos = getMockVideos();
|
||||
setVideoBookmarks(mockVideos);
|
||||
}
|
||||
|
||||
setIsLoadingVideos(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to load bookmarks:', error);
|
||||
// Fallback to mock data if API fails
|
||||
@@ -160,6 +204,11 @@ export const Bookmarks = () => {
|
||||
screenshot_medium: bookmark.screenshot,
|
||||
}));
|
||||
setBookmarks(adaptedBookmarks);
|
||||
|
||||
// Also load mock videos as fallback
|
||||
const mockVideos = getMockVideos();
|
||||
setVideoBookmarks(mockVideos);
|
||||
setIsLoadingVideos(false);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -174,6 +223,15 @@ export const Bookmarks = () => {
|
||||
return Array.from(tags).sort();
|
||||
};
|
||||
|
||||
// Get all unique tags from video bookmarks
|
||||
const getAllVideoTags = () => {
|
||||
const tags = new Set<string>();
|
||||
videoBookmarks().forEach((video) => {
|
||||
(video.tags || []).forEach((tag: any) => tags.add(tag.name));
|
||||
});
|
||||
return Array.from(tags).sort();
|
||||
};
|
||||
|
||||
const filteredBookmarks = () => {
|
||||
const term = searchTerm().toLowerCase();
|
||||
const tag = selectedTag();
|
||||
@@ -191,6 +249,23 @@ export const Bookmarks = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const filteredVideoBookmarks = () => {
|
||||
const term = videoSearchTerm().toLowerCase();
|
||||
const tag = videoSelectedTag();
|
||||
|
||||
return videoBookmarks().filter(video => {
|
||||
const matchesSearch = !term ||
|
||||
video.title.toLowerCase().includes(term) ||
|
||||
video.description.toLowerCase().includes(term) ||
|
||||
video.channel.toLowerCase().includes(term) ||
|
||||
(video.tags || []).some((t: any) => t.name.toLowerCase().includes(term));
|
||||
|
||||
const matchesTag = !tag || (video.tags || []).some((t: any) => t.name === tag);
|
||||
|
||||
return matchesSearch && matchesTag;
|
||||
});
|
||||
};
|
||||
|
||||
// We no longer fetch or display full page metadata/content previews here.
|
||||
|
||||
const handleAddBookmark = async (bookmarkData: any) => {
|
||||
@@ -262,11 +337,21 @@ export const Bookmarks = () => {
|
||||
setSearchTerm(''); // Clear search when filtering by tag
|
||||
};
|
||||
|
||||
const handleVideoTagClick = (tag: string) => {
|
||||
setVideoSelectedTag((current) => (current === tag ? '' : tag));
|
||||
setVideoSearchTerm(''); // Clear search when filtering by tag
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setSearchTerm('');
|
||||
setSelectedTag('');
|
||||
};
|
||||
|
||||
const resetVideoFilters = () => {
|
||||
setVideoSearchTerm('');
|
||||
setVideoSelectedTag('');
|
||||
};
|
||||
|
||||
const handleEditBookmark = async (bookmarkData: Partial<Bookmark>) => {
|
||||
if (!editingBookmark()) return;
|
||||
|
||||
@@ -300,6 +385,30 @@ export const Bookmarks = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleVideoSubmit = async (video: any) => {
|
||||
try {
|
||||
// Use the YouTube API to add video
|
||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/youtube/video-details`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
},
|
||||
body: JSON.stringify({ video_id: video.video_id })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
console.log('Video added:', video);
|
||||
} else {
|
||||
console.log('Video added (demo mode):', video);
|
||||
}
|
||||
setShowVideoModal(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to add video:', error);
|
||||
setShowVideoModal(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="p-6 space-y-6">
|
||||
<div class="flex justify-between items-center">
|
||||
@@ -314,9 +423,18 @@ export const Bookmarks = () => {
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Button onClick={() => setShowAddModal(true)}>
|
||||
Add Bookmark
|
||||
</Button>
|
||||
<Show when={activeTab() === 'bookmarks'}>
|
||||
<Button onClick={() => setShowAddModal(true)}>
|
||||
<IconBookmark class="size-4 mr-2" />
|
||||
Add Bookmark
|
||||
</Button>
|
||||
</Show>
|
||||
<Show when={activeTab() === 'videos'}>
|
||||
<Button onClick={() => setShowVideoModal(true)}>
|
||||
<IconVideo class="size-4 mr-2" />
|
||||
Add Video
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
@@ -324,12 +442,13 @@ export const Bookmarks = () => {
|
||||
<nav class="-mb-px flex space-x-8">
|
||||
<button
|
||||
onClick={() => setActiveTab('bookmarks')}
|
||||
class={`py-2 px-1 border-b-2 font-medium text-sm transition-colors ${
|
||||
class={`py-2 px-1 border-b-2 font-medium text-sm transition-colors flex items-center gap-2 ${
|
||||
activeTab() === 'bookmarks'
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-muted'
|
||||
}`}
|
||||
>
|
||||
<IconBookmark class={`size-4 ${activeTab() === 'bookmarks' ? 'text-primary' : 'text-muted-foreground'}`} />
|
||||
Web Bookmarks
|
||||
</button>
|
||||
<button
|
||||
@@ -340,7 +459,7 @@ export const Bookmarks = () => {
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-muted'
|
||||
}`}
|
||||
>
|
||||
<IconVideo class="size-4" />
|
||||
<IconVideo class={`size-4 ${activeTab() === 'videos' ? 'text-primary' : 'text-muted-foreground'}`} />
|
||||
Video Bookmarks
|
||||
</button>
|
||||
</nav>
|
||||
@@ -419,8 +538,31 @@ export const Bookmarks = () => {
|
||||
alt=""
|
||||
class="w-6 h-6 object-contain"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none';
|
||||
e.currentTarget.parentElement!.innerHTML = `<span class="text-xs text-muted-foreground font-medium">${bookmark.title.charAt(0).toUpperCase()}</span>`;
|
||||
const img = e.currentTarget;
|
||||
const url = new URL(bookmark.url);
|
||||
const baseUrl = `${url.protocol}//${url.hostname}`;
|
||||
|
||||
// Try next favicon source
|
||||
const faviconSources = [
|
||||
`${baseUrl}/favicon.ico`,
|
||||
`${baseUrl}/favicon.png`,
|
||||
`${baseUrl}/img/favicons/favicon-32x32.png`,
|
||||
`${baseUrl}/img/favicons/favicon-16x16.png`,
|
||||
`${baseUrl}/logo-without-border.svg`,
|
||||
`${baseUrl}/logo.svg`,
|
||||
`${baseUrl}/icon.svg`,
|
||||
`https://www.google.com/s2/favicons?domain=${url.hostname}&sz=64`
|
||||
];
|
||||
|
||||
const currentSrc = img.src;
|
||||
const currentIndex = faviconSources.findIndex(src => currentSrc.includes(src));
|
||||
|
||||
if (currentIndex < faviconSources.length - 1) {
|
||||
img.src = faviconSources[currentIndex + 1];
|
||||
} else {
|
||||
img.style.display = 'none';
|
||||
img.parentElement!.innerHTML = `<span class="text-xs text-muted-foreground font-medium">${bookmark.title.charAt(0).toUpperCase()}</span>`;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
@@ -532,6 +674,16 @@ export const Bookmarks = () => {
|
||||
</Show>
|
||||
|
||||
<Show when={activeTab() === 'videos'}>
|
||||
<SearchTagFilterBar
|
||||
searchPlaceholder="Search video bookmarks..."
|
||||
searchValue={videoSearchTerm()}
|
||||
onSearchChange={(value) => setVideoSearchTerm(value)}
|
||||
tagOptions={getAllVideoTags()}
|
||||
selectedTag={videoSelectedTag()}
|
||||
onTagChange={(value) => setVideoSelectedTag(value)}
|
||||
onReset={resetVideoFilters}
|
||||
/>
|
||||
|
||||
{isLoadingVideos() ? (
|
||||
<div class="space-y-4">
|
||||
{[...Array(3)].map(() => (
|
||||
@@ -546,58 +698,108 @@ export const Bookmarks = () => {
|
||||
</div>
|
||||
) : (
|
||||
<div class="space-y-4">
|
||||
{videoBookmarks().map((video) => (
|
||||
<Card class="p-6 hover:bg-accent transition-colors">
|
||||
<div class="flex gap-4">
|
||||
<div class="flex-shrink-0">
|
||||
<img
|
||||
src={video.thumbnail}
|
||||
alt={video.title}
|
||||
class="w-32 h-20 object-cover rounded-md"
|
||||
/>
|
||||
{filteredVideoBookmarks().map((video) => (
|
||||
<Card class="p-6 hover:bg-accent transition-colors group">
|
||||
<div class="flex justify-between items-start gap-4">
|
||||
<div class="flex gap-4 flex-1">
|
||||
<div class="flex-shrink-0">
|
||||
<img
|
||||
src={video.thumbnail}
|
||||
alt={video.title}
|
||||
class="w-32 h-20 object-cover rounded-md"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-foreground mb-2">
|
||||
<a
|
||||
href={video.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary hover:text-primary/80 transition-colors flex items-center gap-1"
|
||||
>
|
||||
{video.title}
|
||||
<IconExternalLink class="size-5 ml-1.5 flex-shrink-0 text-current group-hover:text-white" />
|
||||
</a>
|
||||
</h3>
|
||||
<p class="text-muted-foreground text-sm mb-2">{video.description}</p>
|
||||
<div class="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<span>{video.channel}</span>
|
||||
<span>•</span>
|
||||
<span>{video.duration}</span>
|
||||
<span>•</span>
|
||||
<span>{video.publishedAt}</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 mt-2">
|
||||
{video.tags.map((tag: any) => (
|
||||
<button
|
||||
onClick={() => handleVideoTagClick(tag.name)}
|
||||
class={`px-2 py-1 text-xs rounded-md border transition-colors cursor-pointer
|
||||
${videoSelectedTag() === tag.name
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-muted/80 text-muted-foreground border-transparent group-hover:bg-accent group-hover:text-accent-foreground group-hover:border-border'
|
||||
}`}
|
||||
title={`Click to filter by ${tag.name}`}
|
||||
>
|
||||
{tag.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-foreground mb-2">
|
||||
<a
|
||||
href={video.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary hover:text-primary/80 transition-colors flex items-center gap-1"
|
||||
<div class="flex items-center gap-2 ml-2">
|
||||
<DropdownMenu
|
||||
trigger={
|
||||
<button class="inline-flex items-center justify-center rounded-md text-sm font-medium transition-shadow focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-inherit hover:bg-accent/50 hover:text-accent-foreground h-8 w-8">
|
||||
<IconDotsVertical class="size-4" />
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={() => window.open(video.url, '_blank')}
|
||||
icon={IconExternalLink}
|
||||
>
|
||||
{video.title}
|
||||
<IconExternalLink class="size-5 ml-1.5 flex-shrink-0 text-current" />
|
||||
</a>
|
||||
</h3>
|
||||
<p class="text-muted-foreground text-sm mb-2">{video.description}</p>
|
||||
<div class="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<span>{video.channel}</span>
|
||||
<span>•</span>
|
||||
<span>{video.duration}</span>
|
||||
<span>•</span>
|
||||
<span>{video.publishedAt}</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 mt-2">
|
||||
{video.tags.map((tag: any) => (
|
||||
<span class="px-2 py-1 text-xs rounded-md bg-muted text-muted-foreground">
|
||||
{tag.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
Open in New Tab
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigator.clipboard.writeText(video.url)}
|
||||
icon={IconEdit}
|
||||
>
|
||||
Copy Link
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (confirm('Are you sure you want to delete this video bookmark?')) {
|
||||
setVideoBookmarks(prev => prev.filter(v => v.id !== video.id));
|
||||
}
|
||||
}}
|
||||
icon={IconTrash}
|
||||
variant="destructive"
|
||||
>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{videoBookmarks().length === 0 && (
|
||||
{filteredVideoBookmarks().length === 0 && (
|
||||
<Card class="p-12 text-center">
|
||||
<p class="text-muted-foreground">
|
||||
No video bookmarks yet. Save your first YouTube video!
|
||||
{videoSearchTerm() || videoSelectedTag() ? 'No video bookmarks found matching your search.' : 'No video bookmarks yet. Save your first YouTube video!'}
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
{/* Video Upload Modal */}
|
||||
<VideoUploadModal
|
||||
isOpen={showVideoModal()}
|
||||
onClose={() => setShowVideoModal(false)}
|
||||
onSubmit={handleVideoSubmit}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createSignal, createEffect, onMount, For, Show } from 'solid-js'
|
||||
import { DateTimePicker } from '@/components/ui/DatePicker';
|
||||
import { DateRangePicker } from '@/components/ui/DateRangePicker';
|
||||
import {
|
||||
IconCalendar,
|
||||
IconClock,
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
IconFlag
|
||||
} from '@tabler/icons-solidjs'
|
||||
import { getMockCalendarEvents } from '@/lib/mockData';
|
||||
import { isDemoMode as isDemoModeEnabled } from '@/lib/demo-mode';
|
||||
|
||||
interface CalendarEvent {
|
||||
id: number
|
||||
@@ -80,21 +81,15 @@ export function Calendar() {
|
||||
return () => clearInterval(timer)
|
||||
})
|
||||
|
||||
// Check if we're in demo mode
|
||||
const isDemoMode = () => {
|
||||
return localStorage.getItem('demoMode') === 'true' ||
|
||||
document.title.includes('Demo Mode') ||
|
||||
window.location.search.includes('demo=true');
|
||||
};
|
||||
|
||||
// Fetch calendar data
|
||||
const fetchCalendarData = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
if (isDemoMode() || !token) {
|
||||
if (isDemoModeEnabled() || !token) {
|
||||
// Use mock data in demo mode or when not authenticated
|
||||
const mockEvents = getMockCalendarEvents();
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const weekFromNow = new Date();
|
||||
@@ -213,7 +208,7 @@ export function Calendar() {
|
||||
|
||||
const createEvent = async () => {
|
||||
try {
|
||||
if (isDemoMode()) {
|
||||
if (isDemoModeEnabled()) {
|
||||
// Simulate event creation in demo mode
|
||||
console.log('Creating event (demo mode):', newEvent());
|
||||
setShowEventModal(false);
|
||||
@@ -277,7 +272,7 @@ export function Calendar() {
|
||||
|
||||
const toggleEventCompletion = async (eventId: number) => {
|
||||
try {
|
||||
if (isDemoMode()) {
|
||||
if (isDemoModeEnabled()) {
|
||||
// Simulate event completion toggle in demo mode
|
||||
console.log('Toggling event completion (demo mode):', eventId);
|
||||
fetchCalendarData();
|
||||
@@ -358,6 +353,18 @@ export function Calendar() {
|
||||
|
||||
return (
|
||||
<div class="space-y-6">
|
||||
{/* Demo Mode Indicator */}
|
||||
<Show when={isDemoModeEnabled()}>
|
||||
<div class="bg-yellow-100 dark:bg-yellow-900/20 border border-yellow-300 dark:border-yellow-800 rounded-lg p-3 mb-4">
|
||||
<p class="text-yellow-800 dark:text-yellow-200 text-sm font-medium">
|
||||
Demo Mode Active - Showing sample calendar data
|
||||
</p>
|
||||
<p class="text-yellow-700 dark:text-yellow-300 text-xs mt-1">
|
||||
Today: {todayEvents().length} events | Upcoming: {upcomingEvents().length} events | Deadlines: {deadlines().length}
|
||||
</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Header with Current Time */}
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -440,7 +447,7 @@ export function Calendar() {
|
||||
</div>
|
||||
|
||||
{/* Enhanced Calendar Grid with Events */}
|
||||
<div class="grid grid-cols-7 gap-1 text-sm auto-rows-fr">
|
||||
<div class="grid grid-cols-7 gap-1 text-sm">
|
||||
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map(day => (
|
||||
<div class="text-center text-sm font-medium text-muted-foreground p-2">
|
||||
{day}
|
||||
@@ -461,18 +468,16 @@ export function Calendar() {
|
||||
return (
|
||||
<div
|
||||
onClick={() => openEventModal(date)}
|
||||
class={`border border-border rounded-lg p-1 cursor-pointer hover:bg-accent transition-colors relative overflow-hidden ${
|
||||
class={`border border-border rounded-lg p-1 cursor-pointer hover:bg-accent transition-colors relative overflow-hidden h-24 flex flex-col ${
|
||||
isToday ? 'bg-primary/10 border-primary' : ''
|
||||
} ${!isCurrentMonth ? 'opacity-40' : ''} ${
|
||||
dayEvents.length > 3 ? 'row-span-2 min-h-[5rem]' : 'min-h-[3.5rem]'
|
||||
}`}
|
||||
} ${!isCurrentMonth ? 'opacity-40' : ''}`}
|
||||
>
|
||||
<div class="text-sm font-medium">{date.getDate()}</div>
|
||||
<div class="text-sm font-medium shrink-0">{date.getDate()}</div>
|
||||
{/* Event indicators */}
|
||||
<div class="space-y-1 mt-1">
|
||||
<div class="flex-1 overflow-hidden flex flex-col justify-start space-y-0.5 mt-1">
|
||||
{dayEvents.slice(0, 3).map((event: CalendarEvent) => (
|
||||
<div
|
||||
class={`text-xs px-1 py-0.5 rounded truncate w-full cursor-pointer hover:opacity-80 transition-opacity ${
|
||||
class={`text-xs px-1 py-0.5 rounded truncate w-full cursor-pointer hover:opacity-80 transition-opacity leading-none ${
|
||||
event.type === 'deadline'
|
||||
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400 border border-red-200 dark:border-red-800'
|
||||
: event.type === 'meeting'
|
||||
@@ -481,19 +486,19 @@ export function Calendar() {
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||
: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
|
||||
}`}
|
||||
style={`font-size: 10px;`}
|
||||
style={`font-size: 9px; line-height: 1.2;`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedTask(event);
|
||||
setShowTaskDetailModal(true);
|
||||
}}
|
||||
>
|
||||
{event.title.length > 12 ? event.title.substring(0, 12) + '...' : event.title}
|
||||
{event.title.length > 10 ? event.title.substring(0, 10) + '...' : event.title}
|
||||
</div>
|
||||
))}
|
||||
{dayEvents.length > 3 && (
|
||||
<div
|
||||
class="text-xs text-muted-foreground font-medium cursor-pointer hover:text-primary transition-colors underline"
|
||||
class="text-xs text-muted-foreground font-medium cursor-pointer hover:text-primary transition-colors underline leading-none mt-0.5"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// Show all events for this day
|
||||
@@ -879,33 +884,33 @@ export function Calendar() {
|
||||
<label class="block text-sm font-medium mb-1">
|
||||
{newEvent().is_all_day ? 'Event Date' : 'Start Time'}
|
||||
</label>
|
||||
<DateTimePicker
|
||||
value={newEvent().start_time ? new Date(newEvent().start_time) : undefined}
|
||||
onChange={(date) => {
|
||||
if (date) {
|
||||
<DateRangePicker
|
||||
value={newEvent().start_time ? { start: new Date(newEvent().start_time), end: new Date(newEvent().end_time || newEvent().start_time) } : undefined}
|
||||
onChange={(range) => {
|
||||
if (range && range.start) {
|
||||
if (newEvent().is_all_day) {
|
||||
// For all-day events, set time to beginning of day
|
||||
const startOfDay = new Date(date);
|
||||
const startOfDay = new Date(range.start);
|
||||
startOfDay.setHours(0, 0, 0, 0);
|
||||
setNewEvent({ ...newEvent(), start_time: startOfDay.toISOString() });
|
||||
} else {
|
||||
setNewEvent({ ...newEvent(), start_time: date.toISOString() });
|
||||
setNewEvent({ ...newEvent(), start_time: range.start.toISOString() });
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder={newEvent().is_all_day ? "Select event date" : "Select start time"}
|
||||
class="w-full"
|
||||
dateOnly={newEvent().is_all_day}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!newEvent().is_all_day && (
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">End Time</label>
|
||||
<DateTimePicker
|
||||
value={newEvent().end_time ? new Date(newEvent().end_time) : undefined}
|
||||
onChange={(date) => {
|
||||
if (date) {
|
||||
setNewEvent({ ...newEvent(), end_time: date.toISOString() });
|
||||
<DateRangePicker
|
||||
value={newEvent().start_time ? { start: new Date(newEvent().start_time), end: new Date(newEvent().end_time || newEvent().start_time) } : undefined}
|
||||
onChange={(range) => {
|
||||
if (range && range.start) {
|
||||
setNewEvent({ ...newEvent(), end_time: range.end ? range.end.toISOString() : range.start.toISOString() });
|
||||
}
|
||||
}}
|
||||
placeholder="Select end time"
|
||||
@@ -913,22 +918,22 @@ export function Calendar() {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
onClick={() => setShowEventModal(false)}
|
||||
class="flex-1 px-4 py-2 border border-border rounded-lg hover:bg-accent transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={createEvent}
|
||||
disabled={!newEvent().title || !newEvent().start_time}
|
||||
class="flex-1 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Create Event
|
||||
</button>
|
||||
<div class="flex gap-2 mt-4">
|
||||
<button
|
||||
onClick={() => setShowEventModal(false)}
|
||||
class="flex-1 px-4 py-2 border border-border rounded-lg hover:bg-accent transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={createEvent}
|
||||
disabled={!newEvent().title || !newEvent().start_time}
|
||||
class="flex-1 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Create Event
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,9 @@ import {
|
||||
FileText as FileTextIcon,
|
||||
Sparkles,
|
||||
ChevronDown,
|
||||
Settings
|
||||
Settings,
|
||||
Trash,
|
||||
User
|
||||
} from 'lucide-solid'
|
||||
|
||||
interface ChatMessage {
|
||||
@@ -602,7 +604,7 @@ const Chat = () => {
|
||||
}}
|
||||
class="opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<span class="h-4 w-4">🗑️</span>
|
||||
<Trash class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -706,7 +708,7 @@ const Chat = () => {
|
||||
message.role === 'user' ? 'bg-primary-foreground/20' : 'bg-primary/10'
|
||||
}`}>
|
||||
{message.role === 'user' ? (
|
||||
<span class="w-4 h-4 text-xs">👤</span>
|
||||
<User class="w-4 h-4 text-xs" />
|
||||
) : (
|
||||
<AIProviderIcon
|
||||
providerId={selectedModel()}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createSignal, onMount, Show } from 'solid-js';
|
||||
import { IconPalette, IconCheck, IconRepeat, IconSun, IconMoon, IconDownload, IconUpload, IconEye, IconEyeOff } from '@tabler/icons-solidjs';
|
||||
import { ColorPicker } from '@/components/ui/ColorPicker';
|
||||
|
||||
interface ColorScheme {
|
||||
name: string;
|
||||
@@ -445,20 +446,11 @@ export const ColorSwitcher = () => {
|
||||
<label class="block text-sm font-medium text-muted-foreground mb-2">
|
||||
Primary Color
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={customColors().primary}
|
||||
onInput={(e) => setCustomColors(prev => ({ ...prev, primary: e.currentTarget.value }))}
|
||||
class="h-10 w-16 rounded border border-input"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={customColors().primary}
|
||||
onInput={(e) => setCustomColors(prev => ({ ...prev, primary: e.currentTarget.value }))}
|
||||
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
<ColorPicker
|
||||
value={customColors().primary}
|
||||
onChange={(color) => setCustomColors(prev => ({ ...prev, primary: color }))}
|
||||
savedColors={['#5ab9ff', '#ff6b6b', '#4ecdc4', '#45b7d1', '#f9ca24', '#f0932b', '#eb4d4b', '#6ab04c']}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -571,28 +571,38 @@ export const Dashboard = () => {
|
||||
</div>
|
||||
<div class="relative flex items-end justify-between h-full gap-1 md:gap-2">
|
||||
{['M', 'T', 'W', 'T', 'F', 'S', 'S'].map((day, index) => {
|
||||
const weeklyActivity = stats().weeklyActivity || [12, 19, 8, 15, 22, 18, 25]; // Fallback data
|
||||
const weeklyActivity = stats().weeklyActivity;
|
||||
const activity = weeklyActivity[index];
|
||||
const maxActivity = Math.max(...weeklyActivity);
|
||||
// Use dynamic scale based on actual data
|
||||
const fixedMax = Math.max(maxActivity, 30); // Ensure minimum scale for better visualization
|
||||
const containerHeight = 128; // h-32 = 128px (base), md:h-36 = 144px
|
||||
const availableHeight = containerHeight * 0.75; // Use 75% of container height to leave room for labels
|
||||
const heightPercent = (activity / fixedMax) * (availableHeight / containerHeight) * 100;
|
||||
const minHeightPercent = (6 / containerHeight) * 100; // Minimum 6px height
|
||||
const finalHeightPercent = Math.max(heightPercent, minHeightPercent);
|
||||
const minActivity = Math.min(...weeklyActivity);
|
||||
|
||||
// Calculate responsive height with proper scaling
|
||||
let heightPercent;
|
||||
if (maxActivity === minActivity) {
|
||||
// All values are the same, use 80% height for consistency
|
||||
heightPercent = 80;
|
||||
} else {
|
||||
// Use the actual range for proportional scaling
|
||||
const range = maxActivity - minActivity;
|
||||
const normalizedValue = activity - minActivity;
|
||||
// Scale to 20-90% range to ensure visibility while maintaining proportions
|
||||
heightPercent = 20 + (normalizedValue / range) * 70;
|
||||
}
|
||||
|
||||
// Ensure minimum height for very small values but maintain proportion
|
||||
const finalHeightPercent = Math.max(heightPercent, 8);
|
||||
|
||||
return (
|
||||
<div class="flex flex-col items-center flex-1 gap-2 group min-w-0 max-w-4">
|
||||
<div class="relative w-full max-w-2 md:max-w-3 flex flex-col items-center">
|
||||
<div class="flex flex-col items-center flex-1 gap-2 group min-w-0 max-w-4 h-full">
|
||||
<div class="relative w-full max-w-2 md:max-w-3 flex flex-col items-center justify-end h-full">
|
||||
<span
|
||||
class="text-xs font-medium text-primary mb-1 opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap absolute -top-5"
|
||||
class="text-xs font-medium text-primary mb-1 opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap absolute -top-5 z-10"
|
||||
>
|
||||
{activity}
|
||||
</span>
|
||||
<div
|
||||
class="w-full max-w-2 md:max-w-3 bg-primary rounded-t transition-all duration-500 hover:opacity-80 cursor-pointer hover:scale-105 weekly-bar"
|
||||
style={`height: ${finalHeightPercent}%; background-color: hsl(199, 89%, 67%); min-height: 6px;`}
|
||||
style={`height: ${finalHeightPercent}%; background-color: hsl(199, 89%, 67%); min-height: 4px;`}
|
||||
title={`${day}: ${activity} activities`}
|
||||
></div>
|
||||
</div>
|
||||
@@ -605,8 +615,8 @@ export const Dashboard = () => {
|
||||
|
||||
{/* Weekly summary */}
|
||||
<div class="flex justify-between text-xs text-muted-foreground pt-2 border-t border-border">
|
||||
<span>Total: {(stats().weeklyActivity || [12, 19, 8, 15, 22, 18, 25]).reduce((a, b) => a + b, 0)} activities</span>
|
||||
<span>Avg: {Math.round((stats().weeklyActivity || [12, 19, 8, 15, 22, 18, 25]).reduce((a, b) => a + b, 0) / 7)} per day</span>
|
||||
<span>Total: {stats().weeklyActivity.reduce((a, b) => a + b, 0)} activities</span>
|
||||
<span>Avg: {Math.round(stats().weeklyActivity.reduce((a, b) => a + b, 0) / 7)} per day</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createSignal, onMount, For, Show } from 'solid-js';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { SearchTagFilterBar } from '@/components/ui/SearchTagFilterBar';
|
||||
import { FileUploadModal } from '@/components/ui/FileUploadModal';
|
||||
import { FileUpload } from '@/components/ui/FileUpload';
|
||||
import { FilePreviewModal } from '@/components/ui/FilePreviewModal';
|
||||
import { getFileTypeConfig, formatFileSize, getFileCategoryColor } from '@/utils/fileTypes';
|
||||
import { getMockFiles } from '@/lib/mockData';
|
||||
@@ -218,28 +218,28 @@ export const Files = () => {
|
||||
};
|
||||
|
||||
|
||||
const handleFileUpload = async (fileData: any) => {
|
||||
const handleFileUpload = async (uploadedFiles: any[]) => {
|
||||
try {
|
||||
// Mock upload - in real app, this would be an API call
|
||||
const newFile: FileItem = {
|
||||
id: Date.now(),
|
||||
name: fileData.file?.name || fileData.linkUrl?.split('/').pop() || 'Untitled',
|
||||
size: fileData.file?.size || 0,
|
||||
type: fileData.file?.type || 'application/octet-stream',
|
||||
// Convert uploaded files to FileItem format
|
||||
const newFiles: FileItem[] = uploadedFiles.map((fileData) => ({
|
||||
id: Date.now() + Math.random(),
|
||||
name: fileData.name || 'Untitled',
|
||||
size: fileData.size || 0,
|
||||
type: fileData.type || 'application/octet-stream',
|
||||
uploadedAt: new Date().toISOString(),
|
||||
description: fileData.description,
|
||||
tags: fileData.tags,
|
||||
associations: fileData.associations,
|
||||
url: fileData.linkUrl,
|
||||
isLink: fileData.isLinkMode,
|
||||
downloadUrl: fileData.isLinkMode ? fileData.linkUrl : `/files/download/${Date.now()}`,
|
||||
viewUrl: fileData.isLinkMode ? fileData.linkUrl : `/files/view/${Date.now()}`,
|
||||
description: '',
|
||||
tags: [],
|
||||
url: fileData.url,
|
||||
isLink: !!fileData.url,
|
||||
downloadUrl: fileData.url || `/files/download/${Date.now()}`,
|
||||
viewUrl: fileData.url || `/files/view/${Date.now()}`,
|
||||
shareUrl: `/files/share/${Date.now()}`
|
||||
};
|
||||
}));
|
||||
|
||||
setFiles(prev => [newFile, ...prev]);
|
||||
setFiles(prev => [...newFiles, ...prev]);
|
||||
setShowUploadModal(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to upload file:', error);
|
||||
console.error('Failed to upload files:', error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -493,10 +493,12 @@ export const Files = () => {
|
||||
)}
|
||||
|
||||
{/* File Upload Modal */}
|
||||
<FileUploadModal
|
||||
<FileUpload
|
||||
isOpen={showUploadModal()}
|
||||
onClose={() => setShowUploadModal(false)}
|
||||
onUpload={handleFileUpload}
|
||||
onFilesChange={handleFileUpload}
|
||||
maxFileSize={50}
|
||||
acceptedTypes={['image/jpeg', 'image/png', 'application/pdf', 'video/mp4']}
|
||||
/>
|
||||
|
||||
{/* File Preview Modal */}
|
||||
|
||||
@@ -394,9 +394,9 @@ export const GitHub = () => {
|
||||
</div>
|
||||
|
||||
{/* Two-way Grid: Contribution Graph and Languages - Responsive */}
|
||||
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||
{/* Contribution Graph - Left Column (2/3 width on large screens) */}
|
||||
<div class="xl:w-2/3">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Contribution Graph - Left Column (larger) */}
|
||||
<div class="lg:col-span-1">
|
||||
<GitHubActivity
|
||||
title="Contribution Activity"
|
||||
showStats={false}
|
||||
@@ -409,8 +409,8 @@ export const GitHub = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Languages - Right Column (1/3 width on large screens) */}
|
||||
<Card class="p-6 xl:w-1/3">
|
||||
{/* Languages - Right Column (smaller) */}
|
||||
<Card class="p-6 lg:col-span-1">
|
||||
<h3 class="text-lg font-semibold text-foreground mb-4">Languages</h3>
|
||||
<div class="space-y-3">
|
||||
{githubStats().languages.map((language) => (
|
||||
@@ -457,9 +457,9 @@ export const GitHub = () => {
|
||||
const finalHeightPercent = Math.max(heightPercent, minHeightPercent);
|
||||
|
||||
return (
|
||||
<div class="flex flex-col items-center flex-1 gap-2 group min-w-0 max-w-8">
|
||||
<div class="relative w-full max-w-4 md:max-w-5 flex flex-col items-center">
|
||||
<span class="text-xs font-medium text-primary mb-1 opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap absolute -top-5">
|
||||
<div class="flex flex-col items-center flex-1 gap-2 group min-w-0 max-w-8 h-full">
|
||||
<div class="relative w-full max-w-4 md:max-w-5 flex flex-col items-center justify-end h-full">
|
||||
<span class="text-xs font-medium text-primary mb-1 opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap absolute -top-5 z-10">
|
||||
{activity}
|
||||
</span>
|
||||
<div
|
||||
|
||||
@@ -118,7 +118,7 @@ export const LearningPaths = () => {
|
||||
}
|
||||
|
||||
// Fetch categories
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:9090/api/v1';
|
||||
const categoriesResponse = await fetch(`${API_BASE_URL}/learning-paths/categories`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
@@ -214,7 +214,7 @@ export const LearningPaths = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:9090/api/v1';
|
||||
const response = await fetch(`${API_BASE_URL}/learning-paths/${pathId}/enroll`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -364,16 +364,12 @@ export const LearningPaths = () => {
|
||||
Featured
|
||||
</div>
|
||||
)}
|
||||
<img
|
||||
src={path.thumbnail}
|
||||
alt={path.title}
|
||||
class="w-full h-full object-cover filter grayscale"
|
||||
onError={(e) => {
|
||||
const target = e.currentTarget;
|
||||
target.src = `https://placehold.co/600x400/1e293b/ffffff?text=${encodeURIComponent(path.category)}`;
|
||||
}}
|
||||
/>
|
||||
<div class="absolute inset-0 bg-black/20 group-hover:bg-black/10 transition-colors"></div>
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<div class="w-16 h-16 bg-blue-500/20 rounded-full flex items-center justify-center">
|
||||
<IconBook class="size-8 text-blue-400" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute bottom-0 left-0 right-0 h-20 bg-gradient-to-t from-[#262626] to-transparent"></div>
|
||||
<div class="absolute bottom-4 left-4 right-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
{getCategoryIcon(path.category)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createSignal, onMount, For, Show } from 'solid-js';
|
||||
import { createSignal, createEffect, onMount, For, Show } from 'solid-js';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { SearchTagFilterBar } from '@/components/ui/SearchTagFilterBar';
|
||||
@@ -73,6 +73,8 @@ const renderMarkdownPreviewHtml = (content: string, maxBlocks = 4): string => {
|
||||
.replace(/\*(.*?)\*/g, '<em class="italic">$1<\/em>')
|
||||
.replace(/`(.*?)`/g, '<code class="bg-[#262626] px-1 py-0.5 rounded text-xs">$1<\/code>')
|
||||
.replace(/```(.*?)\n([\s\S]*?)```/g, '<pre class="bg-[#262626] p-3 rounded mb-2 overflow-x-auto"><code class="text-xs">$2<\/code><\/pre>')
|
||||
.replace(/^- \[ \] (.*$)/gim, '<div class="flex items-center gap-2 mb-1"><input type="checkbox" class="note-checkbox" style="width: 16px; height: 16px; cursor: pointer; accent-color: #3b82f6;" onclick="this.checked=!this.checked" onchange="this.parentElement.nextElementSibling.textContent=this.checked?\'x\':\' \'"><span class="text-xs">$1</span></div>')
|
||||
.replace(/^- \[x\] (.*$)/gim, '<div class="flex items-center gap-2 mb-1"><input type="checkbox" checked class="note-checkbox" style="width: 16px; height: 16px; cursor: pointer; accent-color: #3b82f6;" onclick="this.checked=!this.checked" onchange="this.parentElement.nextElementSibling.textContent=this.checked?\'x\':\' \'"><span class="text-xs">$1</span></div>')
|
||||
.replace(/^- (.*$)/gim, '<li class="ml-4 list-disc">$1<\/li>')
|
||||
.replace(/^\d+\. (.*$)/gim, '<li class="ml-4 list-decimal">$1<\/li>')
|
||||
.replace(/> (.*$)/gim, '<blockquote class="border-l-4 border-[#444] pl-3 italic text-[#aaa] mb-2">$1<\/blockquote>')
|
||||
@@ -89,6 +91,8 @@ const renderPlainTextPreviewHtml = (content: string): string => {
|
||||
.replace(/(https?:\/\/[^\s]+)/g, '<a href="$1" class="text-blue-400 hover:underline" target="_blank" rel="noopener noreferrer">$1<\/a>')
|
||||
.replace(/\*\*(.*?)\*\*/g, '<strong class="font-semibold">$1<\/strong>')
|
||||
.replace(/\*(.*?)\*/g, '<em class="italic">$1<\/em>')
|
||||
.replace(/^- \[ \] (.*$)/gim, '<div class="flex items-center gap-2 mb-1"><input type="checkbox" class="note-checkbox" style="width: 16px; height: 16px; cursor: pointer; accent-color: #3b82f6;" onclick="this.checked=!this.checked"><span class="text-xs">$1</span></div>')
|
||||
.replace(/^- \[x\] (.*$)/gim, '<div class="flex items-center gap-2 mb-1"><input type="checkbox" checked class="note-checkbox" style="width: 16px; height: 16px; cursor: pointer; accent-color: #3b82f6;" onclick="this.checked=!this.checked"><span class="text-xs">$1</span></div>')
|
||||
.split('\n')
|
||||
.slice(0, 6)
|
||||
.map((line) => (line ? line : '<br \/>'))
|
||||
@@ -313,8 +317,86 @@ export const Notes = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// Add this function to handle checkbox changes
|
||||
const updateNoteCheckbox = (noteId: number, checkboxIndex: number, isChecked: boolean) => {
|
||||
setNotes(prev => prev.map(note => {
|
||||
if (note.id === noteId) {
|
||||
const lines = note.content.split('\n');
|
||||
let checkboxCount = 0;
|
||||
|
||||
const updatedLines = lines.map(line => {
|
||||
const uncheckedMatch = line.match(/^- \[ \] (.*)$/);
|
||||
const checkedMatch = line.match(/^- \[x\] (.*)$/);
|
||||
|
||||
if (uncheckedMatch || checkedMatch) {
|
||||
if (checkboxCount === checkboxIndex) {
|
||||
const text = uncheckedMatch ? uncheckedMatch[1] : (checkedMatch ? checkedMatch[1] : '');
|
||||
return isChecked ? `- [x] ${text}` : `- [ ] ${text}`;
|
||||
}
|
||||
checkboxCount++;
|
||||
}
|
||||
return line;
|
||||
});
|
||||
|
||||
return {
|
||||
...note,
|
||||
content: updatedLines.join('\n'),
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
return note;
|
||||
}));
|
||||
};
|
||||
|
||||
// Handler for updating note content from ViewNoteModal
|
||||
const handleUpdateNoteContent = (noteId: number, content: string) => {
|
||||
setNotes(prev => prev.map(note =>
|
||||
note.id === noteId
|
||||
? { ...note, content, updatedAt: new Date().toISOString() }
|
||||
: note
|
||||
));
|
||||
};
|
||||
|
||||
// Make the function available globally for checkbox onchange handlers
|
||||
createEffect(() => {
|
||||
(window as any).updateNoteContent = (checkbox: HTMLInputElement) => {
|
||||
const noteElement = checkbox.closest('[data-note-id]');
|
||||
if (noteElement) {
|
||||
const noteId = parseInt(noteElement.getAttribute('data-note-id') || '0');
|
||||
const checkboxElements = noteElement.querySelectorAll('input[type="checkbox"]');
|
||||
const checkboxIndex = Array.from(checkboxElements).indexOf(checkbox);
|
||||
updateNoteCheckbox(noteId, checkboxIndex, checkbox.checked);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="p-6 space-y-6">
|
||||
<style>
|
||||
{`
|
||||
.note-checkbox {
|
||||
width: 16px !important;
|
||||
height: 16px !important;
|
||||
cursor: pointer !important;
|
||||
accent-color: #3b82f6 !important;
|
||||
border: 2px solid #4b5563 !important;
|
||||
border-radius: 3px !important;
|
||||
transition: all 0.2s ease !important;
|
||||
}
|
||||
.note-checkbox:hover {
|
||||
border-color: #3b82f6 !important;
|
||||
transform: scale(1.1) !important;
|
||||
}
|
||||
.note-checkbox:checked {
|
||||
background-color: #3b82f6 !important;
|
||||
border-color: #3b82f6 !important;
|
||||
}
|
||||
.note-checkbox:focus {
|
||||
outline: 2px solid #3b82f6 !important;
|
||||
outline-offset: 2px !important;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<div class="flex justify-between items-center">
|
||||
<h1 class="text-3xl font-bold text-[#fafafa]">Notes</h1>
|
||||
<Button onClick={() => setShowAddModal(true)}>
|
||||
@@ -356,6 +438,7 @@ export const Notes = () => {
|
||||
<div class="space-y-4">
|
||||
{filteredNotes().map((note) => (
|
||||
<Card
|
||||
data-note-id={note.id}
|
||||
class={`p-6 cursor-pointer transition-all hover:shadow-lg hover:bg-[#1a1a1a] ${note.pinned ? 'border-l-4 border-l-primary' : ''}`}
|
||||
onClick={() => viewNote(note)}
|
||||
>
|
||||
@@ -458,6 +541,8 @@ export const Notes = () => {
|
||||
.replace(/\*(.*?)\*/g, '<em class="italic">$1</em>')
|
||||
.replace(/`(.*?)`/g, '<code class="bg-[#262626] px-1 py-0.5 rounded text-xs">$1</code>')
|
||||
.replace(/```(.*?)\n([\s\S]*?)```/g, '<pre class="bg-[#262626] p-3 rounded mb-2 overflow-x-auto"><code class="text-xs">$2</code></pre>')
|
||||
.replace(/^- \[ \] (.*$)/gim, '<div class="flex items-center gap-2 mb-1"><input type="checkbox" class="note-checkbox" style="width: 16px; height: 16px; cursor: pointer; accent-color: #3b82f6;" onclick="this.checked=!this.checked" onchange="updateNoteContent(this)"><span class="text-sm">$1</span></div>')
|
||||
.replace(/^- \[x\] (.*$)/gim, '<div class="flex items-center gap-2 mb-1"><input type="checkbox" checked class="note-checkbox" style="width: 16px; height: 16px; cursor: pointer; accent-color: #3b82f6;" onclick="this.checked=!this.checked" onchange="updateNoteContent(this)"><span class="text-sm">$1</span></div>')
|
||||
.replace(/^- (.*$)/gim, '<li class="ml-4 list-disc">$1</li>')
|
||||
.replace(/^\d+\. (.*$)/gim, '<li class="ml-4 list-decimal">$1</li>')
|
||||
.replace(/> (.*$)/gim, '<blockquote class="border-l-4 border-[#444] pl-3 italic text-[#aaa] mb-2">$1</blockquote>')
|
||||
@@ -466,6 +551,8 @@ export const Notes = () => {
|
||||
: note.content.replace(/(https?:\/\/[^\s]+)/g, '<a href="$1" class="text-blue-400 hover:underline" target="_blank" rel="noopener noreferrer">$1</a>')
|
||||
.replace(/\*\*(.*?)\*\*/g, '<strong class="font-semibold">$1</strong>')
|
||||
.replace(/\*(.*?)\*/g, '<em class="italic">$1</em>')
|
||||
.replace(/^- \[ \] (.*$)/gim, '<div class="flex items-center gap-2 mb-1"><input type="checkbox" class="note-checkbox" style="width: 16px; height: 16px; cursor: pointer; accent-color: #3b82f6;" onclick="this.checked=!this.checked" onchange="updateNoteContent(this)"><span class="text-sm">$1</span></div>')
|
||||
.replace(/^- \[x\] (.*$)/gim, '<div class="flex items-center gap-2 mb-1"><input type="checkbox" checked class="note-checkbox" style="width: 16px; height: 16px; cursor: pointer; accent-color: #3b82f6;" onclick="this.checked=!this.checked" onchange="updateNoteContent(this)"><span class="text-sm">$1</span></div>')
|
||||
.split('\n').map((line) => line ? `<p class="mb-2">${line}</p>` : '<br />').join('')
|
||||
}
|
||||
/>
|
||||
@@ -570,6 +657,7 @@ export const Notes = () => {
|
||||
onDelete={deleteNote}
|
||||
onCopyContent={copyNoteContent}
|
||||
onExportNote={exportNote}
|
||||
onUpdateNote={handleUpdateNoteContent}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
+137
-68
@@ -4,6 +4,7 @@ import { IconUser, IconLock, IconTrash, IconKey, IconBrain, IconMail, IconSend,
|
||||
import { TwoFactorAuth } from '@/components/TwoFactorAuth';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { AIProviderIcon } from '@/components/AIProviderIcon';
|
||||
import { ColorPicker } from '@/components/ui/ColorPicker';
|
||||
|
||||
export const Settings = () => {
|
||||
const { authState, updateProfile, changePassword } = useAuth();
|
||||
@@ -14,12 +15,24 @@ export const Settings = () => {
|
||||
theme: 'dark',
|
||||
showBrowserSearch: true
|
||||
});
|
||||
const [customColors, setCustomColors] = createSignal({
|
||||
primary: '#5ab9ff',
|
||||
background: '#000000',
|
||||
foreground: '#ffffff',
|
||||
muted: '#262727',
|
||||
border: '#262626'
|
||||
});
|
||||
const [passwordData, setPasswordData] = createSignal({
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
});
|
||||
const [aiSettingsExpanded, setAISettingsExpanded] = createSignal(true);
|
||||
const [showMistralKey, setShowMistralKey] = createSignal(false);
|
||||
const [showLongcatKey, setShowLongcatKey] = createSignal(false);
|
||||
const [showGrokKey, setShowGrokKey] = createSignal(false);
|
||||
const [showDeepseekKey, setShowDeepseekKey] = createSignal(false);
|
||||
const [showOpenrouterKey, setShowOpenrouterKey] = createSignal(false);
|
||||
const [aiSettings, setAISettings] = createSignal({
|
||||
mistral: { enabled: false, api_key: '', model: 'mistral-small-latest', model_thinking: 'mistral-large-latest' },
|
||||
grok: { enabled: false, api_key: '', base_url: 'https://api.x.ai/v1', model: 'grok-4-1-fast-non-reasoning-latest', model_thinking: 'grok-4-1-fast-reasoning-latest' },
|
||||
@@ -350,6 +363,17 @@ export const Settings = () => {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-muted-foreground mb-2">
|
||||
Primary Color
|
||||
</label>
|
||||
<ColorPicker
|
||||
value={customColors().primary}
|
||||
onChange={(color) => setCustomColors(prev => ({ ...prev, primary: color }))}
|
||||
savedColors={['#5ab9ff', '#ff6b6b', '#4ecdc4', '#45b7d1', '#f9ca24', '#f0932b', '#eb4d4b', '#6ab04c']}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="flex items-center gap-2 text-sm font-medium text-muted-foreground mb-2">
|
||||
<input
|
||||
@@ -485,14 +509,14 @@ export const Settings = () => {
|
||||
const totalAvailable = availableAIProviders().length || Object.keys(settings).length;
|
||||
|
||||
if (totalAvailable === 0) {
|
||||
return '⚠️ No AI providers are available on the server. Check backend AI configuration.';
|
||||
return 'No AI providers are available on the server. Check backend AI configuration.';
|
||||
}
|
||||
|
||||
if (enabledCount === 0) {
|
||||
return '⚠️ Providers are available but none are enabled. Enable at least one provider below.';
|
||||
return 'Providers are available but none are enabled. Enable at least one provider below.';
|
||||
}
|
||||
|
||||
return `✅ AI is ready. ${enabledCount} provider${enabledCount > 1 ? 's' : ''} enabled.`;
|
||||
return `AI is ready. ${enabledCount} provider${enabledCount > 1 ? 's' : ''} enabled.`;
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
@@ -647,19 +671,28 @@ export const Settings = () => {
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-muted-foreground mb-1">API Key</label>
|
||||
<input
|
||||
type="password"
|
||||
value={aiSettings().mistral.api_key}
|
||||
onInput={(e) => {
|
||||
const settings = aiSettings();
|
||||
setAISettings({
|
||||
...settings,
|
||||
mistral: { ...settings.mistral, api_key: e.currentTarget.value }
|
||||
});
|
||||
}}
|
||||
placeholder="Enter Mistral API key"
|
||||
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring"
|
||||
/>
|
||||
<div class="relative">
|
||||
<input
|
||||
type={showMistralKey() ? "text" : "password"}
|
||||
value={aiSettings().mistral.api_key}
|
||||
onInput={(e) => {
|
||||
const settings = aiSettings();
|
||||
setAISettings({
|
||||
...settings,
|
||||
mistral: { ...settings.mistral, api_key: e.currentTarget.value }
|
||||
});
|
||||
}}
|
||||
placeholder="Enter Mistral API key"
|
||||
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 pr-10 text-sm text-foreground focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowMistralKey(!showMistralKey())}
|
||||
class="absolute right-2 top-1/2 transform -translate-y-1/2 text-muted-foreground hover:text-foreground focus:outline-none"
|
||||
>
|
||||
<IconKey class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
@@ -740,19 +773,28 @@ export const Settings = () => {
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-muted-foreground mb-1">API Key</label>
|
||||
<input
|
||||
type="password"
|
||||
value={aiSettings().longcat.api_key}
|
||||
onInput={(e) => {
|
||||
const settings = aiSettings();
|
||||
setAISettings({
|
||||
...settings,
|
||||
longcat: { ...settings.longcat, api_key: e.currentTarget.value }
|
||||
});
|
||||
}}
|
||||
placeholder="Enter LongCat API key"
|
||||
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring"
|
||||
/>
|
||||
<div class="relative">
|
||||
<input
|
||||
type={showLongcatKey() ? "text" : "password"}
|
||||
value={aiSettings().longcat.api_key}
|
||||
onInput={(e) => {
|
||||
const settings = aiSettings();
|
||||
setAISettings({
|
||||
...settings,
|
||||
longcat: { ...settings.longcat, api_key: e.currentTarget.value }
|
||||
});
|
||||
}}
|
||||
placeholder="Enter LongCat API key"
|
||||
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 pr-10 text-sm text-foreground focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowLongcatKey(!showLongcatKey())}
|
||||
class="absolute right-2 top-1/2 transform -translate-y-1/2 text-muted-foreground hover:text-foreground focus:outline-none"
|
||||
>
|
||||
<IconKey class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
@@ -923,19 +965,28 @@ export const Settings = () => {
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-muted-foreground mb-1">API Key</label>
|
||||
<input
|
||||
type="password"
|
||||
value={aiSettings().grok.api_key}
|
||||
onInput={(e) => {
|
||||
const settings = aiSettings();
|
||||
setAISettings({
|
||||
...settings,
|
||||
grok: { ...settings.grok, api_key: e.currentTarget.value }
|
||||
});
|
||||
}}
|
||||
placeholder="Enter Grok API key"
|
||||
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring"
|
||||
/>
|
||||
<div class="relative">
|
||||
<input
|
||||
type={showGrokKey() ? "text" : "password"}
|
||||
value={aiSettings().grok.api_key}
|
||||
onInput={(e) => {
|
||||
const settings = aiSettings();
|
||||
setAISettings({
|
||||
...settings,
|
||||
grok: { ...settings.grok, api_key: e.currentTarget.value }
|
||||
});
|
||||
}}
|
||||
placeholder="Enter Grok API key"
|
||||
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 pr-10 text-sm text-foreground focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowGrokKey(!showGrokKey())}
|
||||
class="absolute right-2 top-1/2 transform -translate-y-1/2 text-muted-foreground hover:text-foreground focus:outline-none"
|
||||
>
|
||||
<IconKey class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -1033,19 +1084,28 @@ export const Settings = () => {
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-muted-foreground mb-1">API Key</label>
|
||||
<input
|
||||
type="password"
|
||||
value={aiSettings().deepseek.api_key}
|
||||
onInput={(e) => {
|
||||
const settings = aiSettings();
|
||||
setAISettings({
|
||||
...settings,
|
||||
deepseek: { ...settings.deepseek, api_key: e.currentTarget.value }
|
||||
});
|
||||
}}
|
||||
placeholder="Enter DeepSeek API key"
|
||||
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring"
|
||||
/>
|
||||
<div class="relative">
|
||||
<input
|
||||
type={showDeepseekKey() ? "text" : "password"}
|
||||
value={aiSettings().deepseek.api_key}
|
||||
onInput={(e) => {
|
||||
const settings = aiSettings();
|
||||
setAISettings({
|
||||
...settings,
|
||||
deepseek: { ...settings.deepseek, api_key: e.currentTarget.value }
|
||||
});
|
||||
}}
|
||||
placeholder="Enter DeepSeek API key"
|
||||
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 pr-10 text-sm text-foreground focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDeepseekKey(!showDeepseekKey())}
|
||||
class="absolute right-2 top-1/2 transform -translate-y-1/2 text-muted-foreground hover:text-foreground focus:outline-none"
|
||||
>
|
||||
<IconKey class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -1236,19 +1296,28 @@ export const Settings = () => {
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-muted-foreground mb-1">API Key</label>
|
||||
<input
|
||||
type="password"
|
||||
value={aiSettings().openrouter.api_key}
|
||||
onInput={(e) => {
|
||||
const settings = aiSettings();
|
||||
setAISettings({
|
||||
...settings,
|
||||
openrouter: { ...settings.openrouter, api_key: e.currentTarget.value }
|
||||
});
|
||||
}}
|
||||
placeholder="Enter OpenRouter API key"
|
||||
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring"
|
||||
/>
|
||||
<div class="relative">
|
||||
<input
|
||||
type={showOpenrouterKey() ? "text" : "password"}
|
||||
value={aiSettings().openrouter.api_key}
|
||||
onInput={(e) => {
|
||||
const settings = aiSettings();
|
||||
setAISettings({
|
||||
...settings,
|
||||
openrouter: { ...settings.openrouter, api_key: e.currentTarget.value }
|
||||
});
|
||||
}}
|
||||
placeholder="Enter OpenRouter API key"
|
||||
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 pr-10 text-sm text-foreground focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowOpenrouterKey(!showOpenrouterKey())}
|
||||
class="absolute right-2 top-1/2 transform -translate-y-1/2 text-muted-foreground hover:text-foreground focus:outline-none"
|
||||
>
|
||||
<IconKey class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { ActivityFeed } from '@/components/ui/ActivityFeed';
|
||||
import { getMockStats, getMockActivities } from '@/lib/mockData';
|
||||
import { formatDuration } from '@/lib/timeFormat';
|
||||
import { isDemoMode } from '@/lib/demo-mode';
|
||||
|
||||
interface ActivityData {
|
||||
date: string;
|
||||
@@ -119,6 +120,12 @@ export const Stats = () => {
|
||||
return graph;
|
||||
};
|
||||
|
||||
// Create test data with varied values to verify height calculations
|
||||
const testWeeklyActivity = [8, 22, 15, 31, 18, 25, 12]; // Fixed test values
|
||||
|
||||
// Use demo mode data if available, otherwise use test data
|
||||
const weeklyActivityData = isDemoMode() ? mockStats.weeklyActivity : testWeeklyActivity;
|
||||
|
||||
// Set stats using mock data
|
||||
setStats({
|
||||
totalBookmarks: mockStats.totalBookmarks,
|
||||
@@ -129,7 +136,7 @@ export const Stats = () => {
|
||||
activeTasks: mockStats.activeTasks,
|
||||
storageUsed: mockStats.totalSize,
|
||||
storageTotal: '50 GB',
|
||||
weeklyActivity: [12, 19, 8, 15, 25, 6, 14], // Enhanced mock data for better visualization
|
||||
weeklyActivity: weeklyActivityData, // Use demo mode or test data
|
||||
monthlyGrowth: mockStats.monthlyGrowth,
|
||||
topCategories: [
|
||||
{ name: 'Work', count: 45, color: 'hsl(var(--primary))' },
|
||||
@@ -157,11 +164,24 @@ export const Stats = () => {
|
||||
};
|
||||
return (
|
||||
<div class="p-6 mt-4 pb-32 max-w-5xl mx-auto space-y-6">
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">Statistics & Activity</h1>
|
||||
<p class="text-muted-foreground mt-2">Track your productivity, growth, and activity over time</p>
|
||||
</div>
|
||||
|
||||
{/* Demo Mode Indicator */}
|
||||
<Show when={isDemoMode()}>
|
||||
<div class="bg-yellow-100 dark:bg-yellow-900/20 border border-yellow-300 dark:border-yellow-800 rounded-lg p-3">
|
||||
<p class="text-yellow-800 dark:text-yellow-200 text-sm font-medium">
|
||||
Demo Mode Active - Showing sample data
|
||||
</p>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-start">
|
||||
<div></div>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -398,52 +418,52 @@ export const Stats = () => {
|
||||
/>
|
||||
|
||||
{/* Weekly Activity Chart */}
|
||||
<div class="border rounded-lg p-6">
|
||||
<div class="border rounded-lg p-4 sm:p-6">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<IconActivity class="size-5 text-primary" />
|
||||
<h3 class="text-lg font-semibold">Weekly Activity</h3>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<div class="relative h-32 md:h-36 px-6 weekly-activity-chart">
|
||||
<div class="relative h-32 sm:h-36 md:h-40 lg:h-44 px-4 sm:px-6 weekly-activity-chart">
|
||||
<div class="absolute inset-x-0 inset-y-2 pointer-events-none flex flex-col justify-between">
|
||||
<div class="border-t border-border/60"></div>
|
||||
<div class="border-t border-border/40"></div>
|
||||
<div class="border-t border-border/30"></div>
|
||||
<div class="border-t border-border/20"></div>
|
||||
</div>
|
||||
<div class="relative flex items-end justify-between h-full gap-3 md:gap-4">
|
||||
<div class="relative flex items-end justify-between h-full gap-1 sm:gap-2">
|
||||
{['M', 'T', 'W', 'T', 'F', 'S', 'S'].map((day, index) => {
|
||||
const weeklyActivity = stats().weeklyActivity || [12, 19, 8, 15, 22, 18, 25]; // Fallback data
|
||||
const weeklyActivity = stats().weeklyActivity;
|
||||
const activity = weeklyActivity[index];
|
||||
const maxActivity = Math.max(...weeklyActivity);
|
||||
// Use dynamic scale based on actual data
|
||||
const fixedMax = Math.max(maxActivity, 30); // Ensure minimum scale for better visualization
|
||||
const containerHeight = 128; // h-32 = 128px (base), md:h-36 = 144px
|
||||
const availableHeight = containerHeight * 0.75; // Use 75% of container height to leave room for labels
|
||||
const heightPercent = (activity / fixedMax) * (availableHeight / containerHeight) * 100;
|
||||
const minHeightPercent = (8 / containerHeight) * 100; // Minimum 8px height
|
||||
const finalHeightPercent = Math.max(heightPercent, minHeightPercent);
|
||||
// Dynamic scale: use the highest value as the scale, with minimum of 25 for better visualization
|
||||
const scaleMax = Math.max(maxActivity, 25);
|
||||
// Calculate height percentage (use 85% of available height to leave room for labels)
|
||||
const heightPercent = (activity / scaleMax) * 85;
|
||||
// Ensure minimum height for visibility
|
||||
const finalHeightPercent = Math.max(heightPercent, 5);
|
||||
|
||||
return (
|
||||
<div class="flex flex-col items-center flex-1 gap-2 group min-w-0 max-w-8">
|
||||
<div class="relative w-full max-w-4 md:max-w-5 flex flex-col items-center">
|
||||
<span class="text-xs font-medium text-primary mb-1 opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap absolute -top-5">
|
||||
<div class="flex flex-col items-center flex-1 gap-2 group min-w-0 h-full">
|
||||
<div class="relative w-full max-w-2 sm:max-w-3 md:max-w-4 flex flex-col items-center justify-end h-full">
|
||||
<span class="text-xs font-medium text-primary mb-1 opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap absolute -top-5 z-10 bg-background px-1 rounded shadow-sm">
|
||||
{activity}
|
||||
</span>
|
||||
<div
|
||||
class="w-full max-w-4 md:max-w-5 bg-primary rounded-t transition-all duration-500 hover:opacity-80 cursor-pointer hover:scale-105 weekly-bar"
|
||||
style={`height: ${finalHeightPercent}%; background-color: hsl(199, 89%, 67%); min-height: 8px;`}
|
||||
title={`${day}: ${activity} activities`}
|
||||
class="w-full max-w-2 sm:max-w-3 md:max-w-4 bg-primary rounded-t transition-all duration-500 hover:opacity-80 cursor-pointer hover:scale-105 weekly-bar"
|
||||
style={`height: ${finalHeightPercent}%; min-height: 4px;`}
|
||||
title={`${day}: ${activity} activities (${finalHeightPercent.toFixed(1)}%)`}
|
||||
></div>
|
||||
</div>
|
||||
<span class="text-xs text-muted-foreground font-medium mt-1">{day}</span>
|
||||
<span class="text-xs text-muted-foreground font-medium mt-1 hidden sm:block">{day}</span>
|
||||
<span class="text-xs text-muted-foreground font-medium mt-1 sm:hidden">{day.charAt(0)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between text-xs text-muted-foreground pt-2 border-t border-border">
|
||||
<div class="flex flex-col sm:flex-row sm:justify-between text-xs text-muted-foreground pt-2 border-t border-border gap-1 sm:gap-0">
|
||||
<span>Total: {stats().weeklyActivity.reduce((a, b) => a + b, 0)} activities</span>
|
||||
<span>Avg: {Math.round(stats().weeklyActivity.reduce((a, b) => a + b, 0) / 7)} per day</span>
|
||||
</div>
|
||||
|
||||
@@ -144,6 +144,7 @@ export const Youtube = () => {
|
||||
const [editingChannel, setEditingChannel] = createSignal<FeaturedChannel | null>(null);
|
||||
const [successMessage, setSuccessMessage] = createSignal('');
|
||||
const [channelFilter, setChannelFilter] = createSignal('');
|
||||
const [predefinedVideosLoadTime, setPredefinedVideosLoadTime] = createSignal(0);
|
||||
|
||||
// Filter channels based on search query
|
||||
const filteredChannels = () => {
|
||||
@@ -218,7 +219,15 @@ export const Youtube = () => {
|
||||
|
||||
// Load predefined channel videos
|
||||
const loadPredefinedVideos = async () => {
|
||||
// Prevent duplicate calls if already loading or if called within last 2 seconds
|
||||
const now = Date.now();
|
||||
if (isLoadingPredefined() || (now - predefinedVideosLoadTime() < 2000)) {
|
||||
console.log('Skipping loadPredefinedVideos - already loading or called too recently');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoadingPredefined(true);
|
||||
setPredefinedVideosLoadTime(now);
|
||||
setPredefinedError('');
|
||||
|
||||
try {
|
||||
@@ -605,7 +614,7 @@ export const Youtube = () => {
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 text-white"
|
||||
class="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -621,7 +630,7 @@ export const Youtube = () => {
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 text-white"
|
||||
class="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -637,7 +646,7 @@ export const Youtube = () => {
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 text-white"
|
||||
class="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
|
||||
@@ -25,24 +25,62 @@ export interface UpdateCheckResponse {
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8080';
|
||||
|
||||
// Check if we're in demo mode
|
||||
const isDemoMode = () => {
|
||||
return localStorage.getItem('demoMode') === 'true' ||
|
||||
document.title.includes('Demo Mode') ||
|
||||
window.location.search.includes('demo=true') ||
|
||||
import.meta.env.VITE_DEMO_MODE === 'true';
|
||||
};
|
||||
|
||||
export const updateService = {
|
||||
// Check for available updates
|
||||
async checkForUpdates(): Promise<UpdateCheckResponse> {
|
||||
// If in demo mode, return mock update data
|
||||
if (isDemoMode()) {
|
||||
console.log('[Demo Mode] Using mock update data');
|
||||
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;
|
||||
}
|
||||
@@ -50,6 +88,15 @@ export const updateService = {
|
||||
|
||||
// Install an update
|
||||
async installUpdate(version: string): Promise<{ message: string; version: string }> {
|
||||
// If in demo mode, simulate update installation
|
||||
if (isDemoMode()) {
|
||||
console.log('[Demo Mode] Simulating update installation for version:', version);
|
||||
return {
|
||||
message: 'Update started',
|
||||
version: version
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/updates/install`, {
|
||||
method: 'POST',
|
||||
@@ -73,6 +120,19 @@ export const updateService = {
|
||||
|
||||
// Get update progress
|
||||
async getUpdateProgress(): Promise<UpdateStatus> {
|
||||
// If in demo mode, return mock progress
|
||||
if (isDemoMode()) {
|
||||
console.log('[Demo Mode] Using mock update progress');
|
||||
return {
|
||||
available: true,
|
||||
downloading: false,
|
||||
installing: false,
|
||||
completed: false,
|
||||
error: '',
|
||||
progress: 0
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/updates/progress`, {
|
||||
method: 'GET',
|
||||
@@ -129,5 +189,52 @@ export const updateService = {
|
||||
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;
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { createSignal } from 'solid-js';
|
||||
import { updateService, type UpdateInfo, type UpdateStatus } from '../services/updateService';
|
||||
|
||||
// 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
|
||||
const isDemoMode = localStorage.getItem('demoMode') === 'true' ||
|
||||
document.title.includes('Demo Mode') ||
|
||||
window.location.search.includes('demo=true') ||
|
||||
import.meta.env.VITE_DEMO_MODE === 'true';
|
||||
|
||||
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 = () => {
|
||||
// Set current version
|
||||
setCurrentVersion(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 = () => {
|
||||
if (!initialized) {
|
||||
initializeUpdateChecking();
|
||||
initialized = true;
|
||||
}
|
||||
};
|
||||
|
||||
// Export store functions and signals
|
||||
export const updateStore = {
|
||||
// Signals
|
||||
updateAvailable,
|
||||
updateInfo,
|
||||
updateStatus,
|
||||
isChecking,
|
||||
error,
|
||||
currentVersion,
|
||||
lastCheckTime,
|
||||
|
||||
// Actions
|
||||
checkForUpdates,
|
||||
installUpdate,
|
||||
cancelUpdate,
|
||||
|
||||
// Lifecycle
|
||||
ensureInitialized,
|
||||
cleanup
|
||||
};
|
||||
|
||||
// Auto-cleanup on page unload
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('beforeunload', cleanup);
|
||||
}
|
||||
Reference in New Issue
Block a user