mirror of
https://github.com/Dvorinka/Trackeep.git
synced 2026-07-29 14:03:51 +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 />'} />
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user