mirror of
https://github.com/Dvorinka/Trackeep.git
synced 2026-06-04 04:22:57 +00:00
083373a24f
- Add Redis architecture implementation - Update browser extension functionality - Clean up deprecated files and documentation - Enhance backend handlers for auth, messages, search - Add new configuration options and settings - Update Docker and deployment configurations
784 lines
28 KiB
TypeScript
784 lines
28 KiB
TypeScript
import { createSignal, For, Show, onMount } from 'solid-js';
|
|
import { useSearchParams } from '@solidjs/router';
|
|
import {
|
|
IconSearch,
|
|
IconFilter,
|
|
IconBookmark,
|
|
IconChecklist,
|
|
IconNotebook,
|
|
IconFolder,
|
|
IconX,
|
|
IconStar,
|
|
IconEye,
|
|
IconEyeOff,
|
|
IconFileText
|
|
} from '@tabler/icons-solidjs';
|
|
import { Input } from '@/components/ui/Input';
|
|
import { Card } from '@/components/ui/Card';
|
|
import { Button } from '@/components/ui/Button';
|
|
import { SavedSearches } from './SavedSearches';
|
|
|
|
interface SearchFilters {
|
|
query: string;
|
|
content_type: 'all' | 'bookmarks' | 'tasks' | 'notes' | 'files';
|
|
tags: string[];
|
|
date_range: {
|
|
start: string;
|
|
end: string;
|
|
};
|
|
author: string;
|
|
language: string;
|
|
file_types: string[];
|
|
is_favorite?: boolean;
|
|
is_read?: boolean;
|
|
is_public?: boolean;
|
|
limit: number;
|
|
offset: number;
|
|
search_mode: 'fulltext' | 'semantic' | 'hybrid'; // New field
|
|
threshold: number; // Similarity threshold for semantic search
|
|
}
|
|
|
|
interface SearchResult {
|
|
id: number;
|
|
type: string;
|
|
title: string;
|
|
description: string;
|
|
content: string;
|
|
tags: Array<{ id: number; name: string; color: string }>;
|
|
created_at: string;
|
|
updated_at: string;
|
|
url?: string;
|
|
status?: string;
|
|
priority?: string;
|
|
due_date?: string;
|
|
is_favorite?: boolean;
|
|
is_read?: boolean;
|
|
is_public?: boolean;
|
|
author?: string;
|
|
file_size?: number;
|
|
mime_type?: string;
|
|
file_type?: string;
|
|
progress?: number;
|
|
highlights?: Record<string, string[]>;
|
|
score: number;
|
|
similarity?: number; // Semantic similarity score
|
|
}
|
|
|
|
interface SearchResponse {
|
|
results: SearchResult[];
|
|
total: number;
|
|
query: string;
|
|
filters: SearchFilters;
|
|
took: number;
|
|
suggestions: string[];
|
|
aggregations: Record<string, number>;
|
|
}
|
|
|
|
export const EnhancedSearch = () => {
|
|
const [activeTab, setActiveTab] = createSignal<'search' | 'saved'>('search');
|
|
const [searchQuery, setSearchQuery] = createSignal('');
|
|
const [filters, setFilters] = createSignal<SearchFilters>({
|
|
query: '',
|
|
content_type: 'all',
|
|
tags: [],
|
|
date_range: { start: '', end: '' },
|
|
author: '',
|
|
language: '',
|
|
file_types: [],
|
|
limit: 20,
|
|
offset: 0,
|
|
search_mode: 'fulltext',
|
|
threshold: 0.7
|
|
});
|
|
const [searchResults, setSearchResults] = createSignal<SearchResult[]>([]);
|
|
const [total, setTotal] = createSignal(0);
|
|
const [loading, setLoading] = createSignal(false);
|
|
const [showFilters, setShowFilters] = createSignal(false);
|
|
const [aggregations, setAggregations] = createSignal<Record<string, number>>({});
|
|
const [took, setTook] = createSignal(0);
|
|
const [searchParams] = useSearchParams();
|
|
|
|
// API call to search
|
|
const performSearch = async (resetOffset = true) => {
|
|
setLoading(true);
|
|
|
|
const currentFilters = { ...filters() };
|
|
currentFilters.query = searchQuery();
|
|
|
|
if (resetOffset) {
|
|
currentFilters.offset = 0;
|
|
}
|
|
|
|
try {
|
|
// Try multiple token sources for better compatibility
|
|
const token = localStorage.getItem('token') ||
|
|
localStorage.getItem('auth_token') ||
|
|
localStorage.getItem('trackeep_token');
|
|
let response;
|
|
|
|
if (currentFilters.search_mode === 'semantic') {
|
|
// Use semantic search API
|
|
response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/search/semantic`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': token ? `Bearer ${token}` : ''
|
|
},
|
|
body: JSON.stringify({
|
|
query: currentFilters.query,
|
|
content_type: currentFilters.content_type,
|
|
limit: currentFilters.limit,
|
|
threshold: currentFilters.threshold
|
|
})
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
const results = Array.isArray(data?.results) ? data.results : [];
|
|
if (resetOffset) {
|
|
setSearchResults(results);
|
|
} else {
|
|
setSearchResults(prev => [...prev, ...results]);
|
|
}
|
|
setTotal(results.length); // Semantic search doesn't return total count
|
|
setTook(Number(data?.took) || 0);
|
|
}
|
|
} else {
|
|
// Use enhanced full-text search API
|
|
response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/search/enhanced`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': token ? `Bearer ${token}` : ''
|
|
},
|
|
body: JSON.stringify(currentFilters)
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data: SearchResponse = await response.json();
|
|
const results = Array.isArray((data as any)?.results) ? (data as any).results : [];
|
|
if (resetOffset) {
|
|
setSearchResults(results);
|
|
} else {
|
|
setSearchResults(prev => [...prev, ...results]);
|
|
}
|
|
setTotal(Number((data as any)?.total) || results.length);
|
|
setAggregations((data as any)?.aggregations && typeof (data as any).aggregations === 'object' ? (data as any).aggregations : {});
|
|
setTook(Number((data as any)?.took) || 0);
|
|
}
|
|
}
|
|
|
|
if (!response.ok) {
|
|
// If unauthorized, fallback to mock data
|
|
if (response.status === 401) {
|
|
console.warn('Search authorization failed, using mock data');
|
|
const mockResults: SearchResult[] = [
|
|
{
|
|
id: 1,
|
|
type: 'bookmark',
|
|
title: `Mock result for "${currentFilters.query}"`,
|
|
description: 'This is a mock search result due to authorization issues',
|
|
content: 'Mock content for demonstration purposes',
|
|
tags: [{ id: 1, name: 'demo', color: '#6b7280' }],
|
|
created_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString(),
|
|
url: 'https://example.com',
|
|
score: 0.9
|
|
},
|
|
{
|
|
id: 2,
|
|
type: 'note',
|
|
title: `Another mock result for "${currentFilters.query}"`,
|
|
description: 'Another mock search result in demo mode',
|
|
content: 'Additional mock content for search demonstration',
|
|
tags: [{ id: 2, name: 'mock', color: '#3b82f6' }],
|
|
created_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString(),
|
|
score: 0.8
|
|
}
|
|
];
|
|
|
|
if (resetOffset) {
|
|
setSearchResults(mockResults);
|
|
} else {
|
|
setSearchResults(prev => [...prev, ...mockResults]);
|
|
}
|
|
setTotal(mockResults.length);
|
|
setTook(50);
|
|
return;
|
|
}
|
|
throw new Error('Search failed');
|
|
}
|
|
} catch (error) {
|
|
console.error('Search failed:', error);
|
|
// Fallback to mock data on any error
|
|
const mockResults: SearchResult[] = [
|
|
{
|
|
id: 1,
|
|
type: 'bookmark',
|
|
title: `Fallback result for "${currentFilters.query}"`,
|
|
description: 'This is a fallback search result due to API errors',
|
|
content: 'Fallback content for demonstration purposes',
|
|
tags: [{ id: 1, name: 'fallback', color: '#ef4444' }],
|
|
created_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString(),
|
|
url: 'https://example.com',
|
|
score: 0.7
|
|
}
|
|
];
|
|
|
|
if (resetOffset) {
|
|
setSearchResults(mockResults);
|
|
} else {
|
|
setSearchResults(prev => [...prev, ...mockResults]);
|
|
}
|
|
setTotal(mockResults.length);
|
|
setTook(100);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
// Debounced search
|
|
let searchTimeout: number;
|
|
const debouncedSearch = () => {
|
|
clearTimeout(searchTimeout);
|
|
searchTimeout = setTimeout(() => performSearch(), 300);
|
|
};
|
|
|
|
// Handle search input
|
|
const handleSearchInput = (value: string) => {
|
|
setSearchQuery(value);
|
|
debouncedSearch();
|
|
};
|
|
|
|
// Handle filter changes
|
|
const updateFilter = (key: keyof SearchFilters, value: any) => {
|
|
setFilters(prev => ({ ...prev, [key]: value }));
|
|
setTimeout(() => performSearch(), 100);
|
|
};
|
|
|
|
// Add/remove tag filter
|
|
const toggleTag = (tag: string) => {
|
|
const currentTags = filters().tags;
|
|
const newTags = currentTags.includes(tag)
|
|
? currentTags.filter(t => t !== tag)
|
|
: [...currentTags, tag];
|
|
updateFilter('tags', newTags);
|
|
};
|
|
|
|
// Clear all filters
|
|
const clearFilters = () => {
|
|
setFilters({
|
|
query: searchQuery(),
|
|
content_type: 'all',
|
|
tags: [],
|
|
date_range: { start: '', end: '' },
|
|
author: '',
|
|
language: '',
|
|
file_types: [],
|
|
limit: 20,
|
|
offset: 0,
|
|
search_mode: 'fulltext',
|
|
threshold: 0.7
|
|
});
|
|
setTimeout(() => performSearch(), 100);
|
|
};
|
|
|
|
// Load more results
|
|
const loadMore = () => {
|
|
setFilters(prev => ({ ...prev, offset: prev.offset + prev.limit }));
|
|
setTimeout(() => performSearch(false), 100);
|
|
};
|
|
|
|
// Get icon for content type
|
|
const getIcon = (type: string) => {
|
|
switch (type) {
|
|
case 'bookmark':
|
|
return IconBookmark;
|
|
case 'task':
|
|
return IconChecklist;
|
|
case 'note':
|
|
return IconNotebook;
|
|
case 'file':
|
|
return IconFolder;
|
|
default:
|
|
return IconFileText;
|
|
}
|
|
};
|
|
|
|
// Get color for content type
|
|
const getTypeColor = (type: string) => {
|
|
switch (type) {
|
|
case 'bookmark':
|
|
return 'text-green-400';
|
|
case 'task':
|
|
return 'text-yellow-400';
|
|
case 'note':
|
|
return 'text-purple-400';
|
|
case 'file':
|
|
return 'text-orange-400';
|
|
default:
|
|
return 'text-gray-400';
|
|
}
|
|
};
|
|
|
|
// Format file size
|
|
const formatFileSize = (bytes?: number) => {
|
|
if (!bytes) return '';
|
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
|
|
};
|
|
|
|
// Format date
|
|
const formatDate = (dateString: string) => {
|
|
return new Date(dateString).toLocaleDateString();
|
|
};
|
|
|
|
// Get priority color
|
|
const getPriorityColor = (priority?: string) => {
|
|
switch (priority) {
|
|
case 'urgent':
|
|
return 'bg-red-500';
|
|
case 'high':
|
|
return 'bg-orange-500';
|
|
case 'medium':
|
|
return 'bg-yellow-500';
|
|
case 'low':
|
|
return 'bg-green-500';
|
|
default:
|
|
return 'bg-gray-500';
|
|
}
|
|
};
|
|
|
|
// Get status color
|
|
const getStatusColor = (status?: string) => {
|
|
switch (status) {
|
|
case 'completed':
|
|
return 'bg-green-500';
|
|
case 'in_progress':
|
|
return 'bg-blue-500';
|
|
case 'pending':
|
|
return 'bg-yellow-500';
|
|
case 'cancelled':
|
|
return 'bg-red-500';
|
|
default:
|
|
return 'bg-gray-500';
|
|
}
|
|
};
|
|
|
|
// Initial search on mount (respect URL query/tag params)
|
|
onMount(() => {
|
|
const urlQuery = (searchParams as any).query || '';
|
|
const urlTag = (searchParams as any).tag || '';
|
|
|
|
const initialQuery = urlQuery || urlTag || '';
|
|
|
|
if (initialQuery) {
|
|
setSearchQuery(initialQuery);
|
|
setFilters(prev => ({
|
|
...prev,
|
|
query: initialQuery,
|
|
tags: urlTag ? [urlTag] : prev.tags,
|
|
}));
|
|
}
|
|
|
|
performSearch();
|
|
});
|
|
|
|
return (
|
|
<div class="space-y-6">
|
|
{/* Header with Tabs */}
|
|
<div class="space-y-4">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<h1 class="text-3xl font-bold">Enhanced Search</h1>
|
|
<p class="text-muted-foreground mt-2">
|
|
Search across all your content with powerful filters and AI-powered discovery
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div class="border-b">
|
|
<nav class="flex space-x-8">
|
|
<button
|
|
class={`py-2 px-1 border-b-2 font-medium text-sm transition-colors ${
|
|
activeTab() === 'search'
|
|
? 'border-primary text-primary'
|
|
: 'border-transparent text-muted-foreground hover:text-foreground'
|
|
}`}
|
|
onClick={() => setActiveTab('search')}
|
|
>
|
|
<div class="flex items-center gap-2">
|
|
<IconSearch class="size-4" />
|
|
Search
|
|
</div>
|
|
</button>
|
|
<button
|
|
class={`py-2 px-1 border-b-2 font-medium text-sm transition-colors ${
|
|
activeTab() === 'saved'
|
|
? 'border-primary text-primary'
|
|
: 'border-transparent text-muted-foreground hover:text-foreground'
|
|
}`}
|
|
onClick={() => setActiveTab('saved')}
|
|
>
|
|
<div class="flex items-center gap-2">
|
|
<IconBookmark class="size-4" />
|
|
Saved Searches
|
|
</div>
|
|
</button>
|
|
</nav>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tab Content */}
|
|
<Show when={activeTab() === 'search'}>
|
|
<div class="space-y-6">
|
|
{/* Search Input */}
|
|
<div class="space-y-4">
|
|
<div class="relative">
|
|
<IconSearch class="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground size-5" />
|
|
<Input
|
|
type="text"
|
|
placeholder="Search across all your content..."
|
|
value={searchQuery()}
|
|
onInput={(e: any) => handleSearchInput(e.target?.value || '')}
|
|
class="pl-10 pr-12 h-12 text-lg"
|
|
/>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setShowFilters(!showFilters())}
|
|
class="absolute right-2 top-1/2 transform -translate-y-1/2"
|
|
>
|
|
<IconFilter class="size-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Search Stats */}
|
|
<Show when={total() > 0}>
|
|
<div class="flex items-center justify-between text-sm text-muted-foreground">
|
|
<span>Found {total()} results in {took()}ms</span>
|
|
<div class="flex items-center gap-4">
|
|
<For each={Object.entries(aggregations())}>
|
|
{([type, count]) => (
|
|
<span>{type}: {count}</span>
|
|
)}
|
|
</For>
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
|
|
{/* Filters Panel */}
|
|
<Show when={showFilters()}>
|
|
<Card class="p-6 space-y-4">
|
|
<div class="flex items-center justify-between">
|
|
<h3 class="text-lg font-semibold">Filters</h3>
|
|
<div class="flex gap-2">
|
|
<Button variant="outline" size="sm" onClick={clearFilters}>
|
|
Clear All
|
|
</Button>
|
|
<Button variant="ghost" size="sm" onClick={() => setShowFilters(false)}>
|
|
<IconX class="size-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{/* Search Mode */}
|
|
<div class="space-y-2">
|
|
<label class="text-sm font-medium">Search Mode</label>
|
|
<select
|
|
value={filters().search_mode}
|
|
onChange={(e: any) => updateFilter('search_mode', e.target.value)}
|
|
class="w-full p-2 border rounded-md bg-background"
|
|
>
|
|
<option value="fulltext">Full-Text Search</option>
|
|
<option value="semantic">Semantic Search</option>
|
|
<option value="hybrid">Hybrid (Coming Soon)</option>
|
|
</select>
|
|
</div>
|
|
|
|
{/* Similarity Threshold (for semantic search) */}
|
|
<Show when={filters().search_mode === 'semantic'}>
|
|
<div class="space-y-2">
|
|
<label class="text-sm font-medium">Similarity Threshold: {filters().threshold.toFixed(2)}</label>
|
|
<input
|
|
type="range"
|
|
min="0.1"
|
|
max="1.0"
|
|
step="0.1"
|
|
value={filters().threshold}
|
|
onChange={(e: any) => updateFilter('threshold', parseFloat(e.target.value))}
|
|
class="w-full"
|
|
/>
|
|
<div class="flex justify-between text-xs text-muted-foreground">
|
|
<span>More results</span>
|
|
<span>More precise</span>
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
|
|
{/* Content Type Filter */}
|
|
<div class="space-y-2">
|
|
<label class="text-sm font-medium">Content Type</label>
|
|
<select
|
|
value={filters().content_type}
|
|
onChange={(e: any) => updateFilter('content_type', e.target.value)}
|
|
class="w-full p-2 border rounded-md bg-background"
|
|
>
|
|
<option value="all">All Types</option>
|
|
<option value="bookmarks">Bookmarks</option>
|
|
<option value="tasks">Tasks</option>
|
|
<option value="notes">Notes</option>
|
|
<option value="files">Files</option>
|
|
</select>
|
|
</div>
|
|
|
|
{/* Date Range */}
|
|
<div class="space-y-2">
|
|
<label class="text-sm font-medium">Date Range</label>
|
|
<div class="flex gap-2">
|
|
<Input
|
|
type="date"
|
|
value={filters().date_range.start}
|
|
onChange={(e: any) => updateFilter('date_range', {
|
|
...filters().date_range,
|
|
start: e.target?.value || ''
|
|
})}
|
|
placeholder="Start date"
|
|
/>
|
|
<Input
|
|
type="date"
|
|
value={filters().date_range.end}
|
|
onChange={(e: any) => updateFilter('date_range', {
|
|
...filters().date_range,
|
|
end: e.target?.value || ''
|
|
})}
|
|
placeholder="End date"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Author Filter */}
|
|
<div class="space-y-2">
|
|
<label class="text-sm font-medium">Author</label>
|
|
<Input
|
|
type="text"
|
|
value={filters().author}
|
|
onChange={(e: any) => updateFilter('author', e.target?.value || '')}
|
|
placeholder="Filter by author"
|
|
/>
|
|
</div>
|
|
|
|
{/* Boolean Filters */}
|
|
<div class="space-y-2">
|
|
<label class="text-sm font-medium">Quick Filters</label>
|
|
<div class="flex flex-wrap gap-2">
|
|
<Button
|
|
variant={filters().is_favorite ? "default" : "outline"}
|
|
size="sm"
|
|
onClick={() => updateFilter('is_favorite', !filters().is_favorite)}
|
|
>
|
|
<IconStar class="size-3 mr-1" />
|
|
Favorites
|
|
</Button>
|
|
<Button
|
|
variant={filters().is_read ? "default" : "outline"}
|
|
size="sm"
|
|
onClick={() => updateFilter('is_read', !filters().is_read)}
|
|
>
|
|
<IconEye class="size-3 mr-1" />
|
|
Read
|
|
</Button>
|
|
<Button
|
|
variant={filters().is_public ? "default" : "outline"}
|
|
size="sm"
|
|
onClick={() => updateFilter('is_public', !filters().is_public)}
|
|
>
|
|
<IconEyeOff class="size-3 mr-1" />
|
|
Public
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Active Tags */}
|
|
<Show when={filters().tags.length > 0}>
|
|
<div class="space-y-2">
|
|
<label class="text-sm font-medium">Active Tags</label>
|
|
<div class="flex flex-wrap gap-2">
|
|
<For each={filters().tags}>
|
|
{(tag) => (
|
|
<span class="inline-block bg-secondary text-secondary-foreground text-xs px-2 py-1 rounded cursor-pointer hover:bg-secondary/80" onClick={() => toggleTag(tag)}>
|
|
{tag}
|
|
<IconX class="inline size-3 ml-1" />
|
|
</span>
|
|
)}
|
|
</For>
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
</Card>
|
|
</Show>
|
|
|
|
{/* Search Results */}
|
|
<div class="space-y-4">
|
|
<Show when={loading()}>
|
|
<div class="text-center py-8">
|
|
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
|
|
<p class="mt-2 text-muted-foreground">Searching...</p>
|
|
</div>
|
|
</Show>
|
|
|
|
<Show when={!loading() && searchResults().length === 0 && searchQuery()}>
|
|
<div class="text-center py-8 text-muted-foreground">
|
|
<IconSearch class="size-12 mx-auto mb-4 opacity-50" />
|
|
<h3 class="text-lg font-medium mb-2">No results found</h3>
|
|
<p>Try adjusting your search terms or filters</p>
|
|
</div>
|
|
</Show>
|
|
|
|
<Show when={!loading() && searchResults().length > 0}>
|
|
<div class="space-y-4">
|
|
<For each={searchResults()}>
|
|
{(result) => {
|
|
const Icon = getIcon(result.type);
|
|
return (
|
|
<Card class="p-6 hover:shadow-md transition-shadow">
|
|
<div class="flex items-start gap-4">
|
|
{/* Type Icon */}
|
|
<div class={`p-2 rounded-lg bg-muted ${getTypeColor(result.type)}`}>
|
|
<Icon class="size-5" />
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div class="flex-1 min-w-0">
|
|
<div class="flex items-start justify-between gap-4">
|
|
<div class="flex-1">
|
|
<h3 class="text-lg font-semibold mb-1">{result.title}</h3>
|
|
<p class="text-muted-foreground mb-2">{result.description}</p>
|
|
|
|
{/* Content preview */}
|
|
<Show when={result.content}>
|
|
<p class="text-sm text-muted-foreground line-clamp-2 mb-3">
|
|
{result.content.substring(0, 200)}...
|
|
</p>
|
|
</Show>
|
|
|
|
{/* Tags */}
|
|
<Show when={result.tags.length > 0}>
|
|
<div class="flex flex-wrap gap-1 mb-3">
|
|
<For each={result.tags}>
|
|
{(tag) => (
|
|
<span
|
|
class="inline-block bg-secondary text-secondary-foreground text-xs px-2 py-1 rounded cursor-pointer hover:bg-secondary/80"
|
|
onClick={() => toggleTag(tag.name)}
|
|
>
|
|
{tag.name}
|
|
</span>
|
|
)}
|
|
</For>
|
|
</div>
|
|
</Show>
|
|
|
|
{/* Metadata */}
|
|
<div class="flex items-center gap-4 text-xs text-muted-foreground">
|
|
<span class="capitalize">{result.type}</span>
|
|
<span>Created {formatDate(result.created_at)}</span>
|
|
<Show when={result.updated_at !== result.created_at}>
|
|
<span>Updated {formatDate(result.updated_at)}</span>
|
|
</Show>
|
|
<Show when={result.author}>
|
|
<span>By {result.author}</span>
|
|
</Show>
|
|
<Show when={result.file_size}>
|
|
<span>{formatFileSize(result.file_size)}</span>
|
|
</Show>
|
|
<Show when={result.score}>
|
|
<span>Score: {result.score.toFixed(1)}</span>
|
|
</Show>
|
|
<Show when={result.similarity !== undefined}>
|
|
<span>Similarity: {(result.similarity! * 100).toFixed(1)}%</span>
|
|
</Show>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Status/Priority Indicators */}
|
|
<div class="flex flex-col items-end gap-2">
|
|
<Show when={result.status}>
|
|
<span class={`inline-block ${getStatusColor(result.status)} text-white text-xs px-2 py-1 rounded`}>
|
|
{result.status}
|
|
</span>
|
|
</Show>
|
|
<Show when={result.priority}>
|
|
<span class={`inline-block ${getPriorityColor(result.priority)} text-white text-xs px-2 py-1 rounded`}>
|
|
{result.priority}
|
|
</span>
|
|
</Show>
|
|
<Show when={result.is_favorite}>
|
|
<IconStar class="size-4 text-yellow-500 fill-current" />
|
|
</Show>
|
|
<Show when={result.is_read !== undefined}>
|
|
{result.is_read ? (
|
|
<IconEye class="size-4 text-green-500" />
|
|
) : (
|
|
<IconEyeOff class="size-4 text-gray-400" />
|
|
)}
|
|
</Show>
|
|
<Show when={result.progress !== undefined}>
|
|
<div class="text-xs text-muted-foreground">
|
|
{result.progress}%
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
</div>
|
|
|
|
{/* URL for bookmarks */}
|
|
<Show when={result.url}>
|
|
<div class="mt-3">
|
|
<a
|
|
href={result.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
class="text-sm text-blue-500 hover:underline"
|
|
>
|
|
{result.url}
|
|
</a>
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
);
|
|
}}
|
|
</For>
|
|
|
|
{/* Load More */}
|
|
<Show when={searchResults().length < total()}>
|
|
<div class="text-center">
|
|
<Button
|
|
variant="outline"
|
|
onClick={loadMore}
|
|
disabled={loading()}
|
|
>
|
|
Load More Results
|
|
</Button>
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
|
|
<Show when={activeTab() === 'saved'}>
|
|
<SavedSearches />
|
|
</Show>
|
|
</div>
|
|
);
|
|
};
|