import { createSignal, onMount, Show } from 'solid-js'; import { Card } from '@/components/ui/Card'; import { Button } from '@/components/ui/Button'; import { BookmarkModal } from '@/components/ui/BookmarkModal'; import { EditBookmarkModal } from '@/components/ui/EditBookmarkModal'; import { VideoUploadModal } from '@/components/ui/VideoUploadModal'; import { DropdownMenu, DropdownMenuItem } from '@/components/ui/DropdownMenu'; import { SearchTagFilterBar } from '@/components/ui/SearchTagFilterBar'; import { IconDotsVertical, IconStar, IconEdit, IconTrash, IconExternalLink, IconVideo, IconBookmark } from '@tabler/icons-solidjs'; import { getApiV1BaseUrl } from '@/lib/api-url'; import { useHaptics } from '@/lib/haptics'; const API_BASE_URL = getApiV1BaseUrl(); interface BookmarkTag { id: number; name: string; color?: string; } interface Bookmark { id: number; title: string; url: string; description?: string; // Normalized tags: always string[] for easier filtering/rendering tags: string[]; created_at?: string; isImportant?: boolean; favicon?: string; screenshot?: string; screenshot_thumbnail?: string; screenshot_medium?: string; screenshot_large?: string; screenshot_original?: string; } interface VideoBookmarkTag { name: string; } interface VideoBookmark { id: number; video_id: string; title: string; description: string; channel: string; thumbnail: string; url: string; duration: string; publishedAt: string; tags: VideoBookmarkTag[]; } export const Bookmarks = () => { const getBookmarkInitial = (title?: string) => { const safeTitle = typeof title === 'string' ? title.trim() : ''; return (safeTitle.charAt(0) || '?').toUpperCase(); }; const adaptBookmarkFromApi = (raw: any): Bookmark => { const rawTags: BookmarkTag[] | string[] | undefined = raw.tags; let tags: string[] = []; if (Array.isArray(rawTags)) { if (rawTags.length > 0 && typeof rawTags[0] === 'string') { tags = rawTags as string[]; } else { tags = (rawTags as BookmarkTag[]).map((t) => t.name).filter(Boolean); } } return { id: raw.id, title: raw.title || raw.url, url: raw.url, description: raw.description, tags, created_at: raw.created_at, isImportant: raw.is_favorite ?? raw.isImportant ?? false, favicon: raw.favicon, screenshot: raw.screenshot, screenshot_thumbnail: raw.screenshot_thumbnail, screenshot_medium: raw.screenshot_medium, screenshot_large: raw.screenshot_large, screenshot_original: raw.screenshot_original, }; }; const getFaviconUrl = (bookmark: Bookmark) => { try { const url = new URL(bookmark.url); return `https://www.google.com/s2/favicons?domain=${url.hostname}&sz=64`; } catch { return ''; } }; const getScreenshotUrl = (bookmark: Bookmark) => { return ( bookmark.screenshot_medium || bookmark.screenshot || bookmark.screenshot_large || bookmark.screenshot_thumbnail || bookmark.screenshot_original || '' ); }; const [bookmarks, setBookmarks] = createSignal([]); const [videoBookmarks, setVideoBookmarks] = createSignal([]); const [isLoading, setIsLoading] = createSignal(true); const [isLoadingVideos, setIsLoadingVideos] = createSignal(true); const [searchTerm, setSearchTerm] = createSignal(''); const [selectedTag, setSelectedTag] = createSignal(''); const [videoSearchTerm, setVideoSearchTerm] = createSignal(''); const [videoSelectedTag, setVideoSelectedTag] = createSignal(''); const [showAddModal, setShowAddModal] = createSignal(false); const [showEditModal, setShowEditModal] = createSignal(false); const [showVideoModal, setShowVideoModal] = createSignal(false); const [editingBookmark, setEditingBookmark] = createSignal(null); const [activeTab, setActiveTab] = createSignal<'bookmarks' | 'videos'>('bookmarks'); const haptics = useHaptics(); // We no longer show inline HTML content previews, only the bookmark cards themselves onMount(async () => { try { // Load regular bookmarks const bookmarksResponse = await fetch(`${API_BASE_URL}/bookmarks`, { headers: { 'Authorization': localStorage.getItem('trackeep_token') ? `Bearer ${localStorage.getItem('trackeep_token')}` : '', }, }); if (!bookmarksResponse.ok) { throw new Error('Failed to load bookmarks'); } const bookmarksData = await bookmarksResponse.json(); // Normalize API response: // - Ensure we always work with an array // - Map Tag objects to simple string[] const normalized: Bookmark[] = (Array.isArray(bookmarksData) ? bookmarksData : []).map(adaptBookmarkFromApi); setBookmarks(normalized); // Load video bookmarks try { const videosResponse = await fetch(`${API_BASE_URL}/video-bookmarks`, { headers: { 'Authorization': localStorage.getItem('trackeep_token') ? `Bearer ${localStorage.getItem('trackeep_token')}` : '', }, }); if (videosResponse.ok) { const videosData = await videosResponse.json(); const items = Array.isArray(videosData?.bookmarks) ? videosData.bookmarks : []; setVideoBookmarks(items.map(adaptVideoBookmarkFromApi)); } else { setVideoBookmarks([]); } } catch (videoError) { console.warn('Failed to load video bookmarks:', videoError); setVideoBookmarks([]); } setIsLoadingVideos(false); } catch (error) { console.error('Failed to load bookmarks:', error); setBookmarks([]); setVideoBookmarks([]); setIsLoadingVideos(false); } finally { setIsLoading(false); } }); // Get all unique tags from bookmarks const getAllTags = () => { const tags = new Set(); bookmarks().forEach((bookmark) => { (bookmark.tags || []).forEach((tag) => tags.add(tag)); }); return Array.from(tags).sort(); }; // Get all unique tags from video bookmarks const getAllVideoTags = () => { const tags = new Set(); videoBookmarks().forEach((video) => { (video.tags || []).forEach((tag: any) => tags.add(tag.name)); }); return Array.from(tags).sort(); }; const filteredBookmarks = () => { const term = searchTerm().toLowerCase(); const tag = selectedTag(); return bookmarks().filter(bookmark => { const matchesSearch = !term || bookmark.title.toLowerCase().includes(term) || bookmark.url.toLowerCase().includes(term) || bookmark.description?.toLowerCase().includes(term) || (bookmark.tags || []).some((t) => t.toLowerCase().includes(term)); const matchesTag = !tag || (bookmark.tags || []).includes(tag); return matchesSearch && matchesTag; }); }; const filteredVideoBookmarks = () => { const term = videoSearchTerm().toLowerCase(); const tag = videoSelectedTag(); return videoBookmarks().filter(video => { const matchesSearch = !term || video.title.toLowerCase().includes(term) || video.description.toLowerCase().includes(term) || video.channel.toLowerCase().includes(term) || (video.tags || []).some((t: any) => t.name.toLowerCase().includes(term)); const matchesTag = !tag || (video.tags || []).some((t: any) => t.name === tag); return matchesSearch && matchesTag; }); }; // We no longer fetch or display full page metadata/content previews here. const handleAddBookmark = async (bookmarkData: any) => { try { const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1'; const response = await fetch(`${API_BASE_URL}/bookmarks`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': localStorage.getItem('trackeep_token') ? `Bearer ${localStorage.getItem('trackeep_token')}` : '', }, body: JSON.stringify(bookmarkData), }); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.error || 'Failed to create bookmark'); } const raw = await response.json(); const newBookmark = adaptBookmarkFromApi(raw); setBookmarks(prev => [newBookmark, ...prev]); setShowAddModal(false); haptics.success(); // Success feedback for adding bookmark } catch (error) { haptics.error(); // Error feedback alert(error instanceof Error ? error.message : 'Failed to add bookmark'); } }; const toggleImportant = (bookmarkId: number) => { setBookmarks((prev) => prev.map((bookmark) => bookmark.id === bookmarkId ? { ...bookmark, isImportant: !bookmark.isImportant } : bookmark ) ); haptics.selection(); // Selection feedback for starring }; const deleteBookmark = async (bookmarkId: number) => { if (confirm('Are you sure you want to delete this bookmark?')) { try { const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1'; const response = await fetch(`${API_BASE_URL}/bookmarks/${bookmarkId}`, { method: 'DELETE', headers: { 'Authorization': localStorage.getItem('trackeep_token') ? `Bearer ${localStorage.getItem('trackeep_token')}` : '', }, }); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.error || 'Failed to delete bookmark'); } setBookmarks(prev => prev.filter(bookmark => bookmark.id !== bookmarkId)); haptics.delete(); // Delete feedback } catch (error) { haptics.error(); // Error feedback alert(error instanceof Error ? error.message : 'Failed to delete bookmark'); } } }; const editBookmark = (bookmark: Bookmark) => { setEditingBookmark(bookmark); setShowEditModal(true); }; const handleTagClick = (tag: string) => { setSelectedTag((current) => (current === tag ? '' : tag)); setSearchTerm(''); // Clear search when filtering by tag }; const handleVideoTagClick = (tag: string) => { setVideoSelectedTag((current) => (current === tag ? '' : tag)); setVideoSearchTerm(''); // Clear search when filtering by tag }; const resetFilters = () => { setSearchTerm(''); setSelectedTag(''); }; const resetVideoFilters = () => { setVideoSearchTerm(''); setVideoSelectedTag(''); }; const handleEditBookmark = async (bookmarkData: Partial) => { if (!editingBookmark()) return; try { const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1'; const response = await fetch(`${API_BASE_URL}/bookmarks/${editingBookmark()!.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Authorization': localStorage.getItem('trackeep_token') ? `Bearer ${localStorage.getItem('trackeep_token')}` : '', }, body: JSON.stringify(bookmarkData), }); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.error || 'Failed to update bookmark'); } const raw = await response.json(); const updatedBookmark = adaptBookmarkFromApi(raw); setBookmarks(prev => prev.map(bookmark => bookmark.id === updatedBookmark.id ? updatedBookmark : bookmark ) ); setShowEditModal(false); setEditingBookmark(null); } catch (error) { alert(error instanceof Error ? error.message : 'Failed to update bookmark'); } }; const handleVideoSubmit = async (video: any) => { try { const response = await fetch(`${API_BASE_URL}/video-bookmarks`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${localStorage.getItem('trackeep_token') || localStorage.getItem('token') || ''}` }, body: JSON.stringify({ url: video.url, description: video.description, tags: Array.isArray(video.tags) ? video.tags.join(',') : '', is_favorite: false, }) }); if (response.ok) { const data = await response.json(); const created = data?.bookmark ? adaptVideoBookmarkFromApi(data.bookmark) : null; if (created) { setVideoBookmarks(prev => [created, ...prev]); } } else { const error = await response.json().catch(() => ({})); throw new Error(error.error || 'Failed to save video bookmark'); } setShowVideoModal(false); } catch (error) { console.error('Failed to add video:', error); alert(error instanceof Error ? error.message : 'Failed to add video bookmark'); setShowVideoModal(false); } }; return (

Bookmarks

{/* Tabs */}
{/* Content based on active tab */} setSearchTerm(value)} tagOptions={getAllTags()} selectedTag={selectedTag()} onTagChange={(value) => setSelectedTag(value)} onReset={resetFilters} /> setShowAddModal(false)} onSubmit={handleAddBookmark} availableTags={getAllTags()} /> { setShowEditModal(false); setEditingBookmark(null); }} onSubmit={handleEditBookmark} bookmark={editingBookmark()} availableTags={getAllTags()} /> {isLoading() ? (
{[...Array(3)].map(() => (
))}
) : (
{filteredBookmarks().map((bookmark) => { const faviconUrl = getFaviconUrl(bookmark); const screenshotUrl = getScreenshotUrl(bookmark); return (
{/* Left side: preview image + favicon + title + URL + tags */}
{screenshotUrl && (
Website preview { e.currentTarget.style.display = 'none'; }} />
)}
{faviconUrl ? ( { const img = e.currentTarget; img.style.display = 'none'; const span = document.createElement('span'); span.className = 'text-xs text-muted-foreground font-medium'; span.textContent = getBookmarkInitial(bookmark.title); img.parentElement!.appendChild(span); }} /> ) : ( {getBookmarkInitial(bookmark.title)} )}

{bookmark.title}

{bookmark.url}

{bookmark.description && (

{bookmark.description}

)}
{(bookmark.tags || []).map((tag) => ( ))}
{/* Right side: optional date above important star + menu */}
{bookmark.created_at && !isNaN(new Date(bookmark.created_at).getTime()) && (
{new Date(bookmark.created_at).toLocaleDateString()}
)}
} > editBookmark(bookmark)} icon={IconEdit}> Edit toggleImportant(bookmark.id)} icon={IconStar} > {bookmark.isImportant ? 'Remove from favorites' : 'Mark as favorite'} deleteBookmark(bookmark.id)} icon={IconTrash} variant="destructive" > Delete
); })} {filteredBookmarks().length === 0 && (

{searchTerm() ? 'No bookmarks found matching your search.' : 'No bookmarks yet. Add your first bookmark!'}

)}
)}
setVideoSearchTerm(value)} tagOptions={getAllVideoTags()} selectedTag={videoSelectedTag()} onTagChange={(value) => setVideoSelectedTag(value)} onReset={resetVideoFilters} /> {isLoadingVideos() ? (
{[...Array(3)].map(() => (
))}
) : (
{filteredVideoBookmarks().map((video) => (
{video.title}

{video.title}

{video.description}

{video.channel} {video.duration} {video.publishedAt}
{video.tags.map((tag: any) => ( ))}
} > window.open(video.url, '_blank')} icon={IconExternalLink} > Open in New Tab navigator.clipboard.writeText(video.url)} icon={IconEdit} > Copy Link { if (confirm('Are you sure you want to delete this video bookmark?')) { fetch(`${API_BASE_URL}/video-bookmarks/${video.id}`, { method: 'DELETE', headers: { 'Authorization': `Bearer ${localStorage.getItem('trackeep_token') || localStorage.getItem('token') || ''}` } }).then((response) => { if (!response.ok) { throw new Error('Failed to delete video bookmark'); } setVideoBookmarks(prev => prev.filter(v => v.id !== video.id)); }).catch((error) => { console.error('Failed to delete video bookmark:', error); alert(error instanceof Error ? error.message : 'Failed to delete video bookmark'); }); } }} icon={IconTrash} variant="destructive" > Delete
))} {filteredVideoBookmarks().length === 0 && (

{videoSearchTerm() || videoSelectedTag() ? 'No video bookmarks found matching your search.' : 'No video bookmarks yet. Save your first YouTube video!'}

)}
)}
{/* Video Upload Modal */} setShowVideoModal(false)} onSubmit={handleVideoSubmit} />
); }; const adaptVideoBookmarkFromApi = (raw: any): VideoBookmark => { const rawTags = typeof raw?.tags === 'string' ? raw.tags.split(',').map((tag: string) => tag.trim()).filter(Boolean) : Array.isArray(raw?.tags) ? raw.tags.map((tag: any) => typeof tag === 'string' ? tag : tag?.name).filter(Boolean) : []; return { id: raw.id, video_id: raw.video_id, title: raw.title || raw.url || 'Untitled video', description: raw.description || '', channel: raw.channel || 'Unknown channel', thumbnail: raw.thumbnail || '', url: raw.url, duration: raw.duration || 'Unknown', publishedAt: raw.published_at || raw.created_at || 'Unknown', tags: rawTags.map((name: string) => ({ name })), }; };