LinkLoom_1 / App.tsx
tahiryamin's picture
feat: Implement dark mode and due date functionality
b2c7feb
import React, { useState, useMemo, useEffect } from 'react';
import { Link, LinkStatus, SortConfig } from './types';
import { useLocalStorage } from './hooks/useLocalStorage';
import { useDarkMode } from './hooks/useDarkMode';
import Header from './components/Header';
import AddLinkForm from './components/AddLinkForm';
import LinkList from './components/LinkList';
import FilterControls from './components/FilterControls';
import { fetchLinkMetadata, fetchDeepAnalysis } from './services/geminiService';
import Spinner from './components/Spinner';
import CategoryManager from './components/CategoryManager';
import Dashboard from './components/Dashboard';
import BulkActionsToolbar from './components/BulkActionsToolbar';
const DEFAULT_CATEGORIES = ['Technology', 'News', 'Productivity', 'Lifestyle', 'Programming', 'Uncategorized'];
const App: React.FC = () => {
const [links, setLinks] = useLocalStorage<Link[]>('saved-links', []);
const [filter, setFilter] = useState<LinkStatus | 'ALL'>('ALL');
const [categoryFilter, setCategoryFilter] = useState<string>('ALL');
const [categories, setCategories] = useLocalStorage<string[]>('link-categories', DEFAULT_CATEGORIES);
const [sortConfig, setSortConfig] = useLocalStorage<SortConfig>('link-sort-config', { key: 'createdAt', direction: 'DESC' });
const [isProcessingSharedLink, setIsProcessingSharedLink] = useState(false);
const [isCategoryManagerOpen, setIsCategoryManagerOpen] = useState(false);
const [analyzingLinkId, setAnalyzingLinkId] = useState<string | null>(null);
const [selectedLinkIds, setSelectedLinkIds] = useState(new Set<string>());
const [theme, setTheme] = useDarkMode();
// Handle incoming shares from PWA share target
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const sharedUrl = urlParams.get('url');
const sharedText = urlParams.get('text');
const urlToProcess = sharedUrl || sharedText; // Sometimes URL is in text field
if (urlToProcess) {
// Use regex to find a URL in the text, as some platforms send it in the text field
const urlRegex = /(https?:\/\/[^\s]+)/;
const foundUrl = urlToProcess.match(urlRegex);
if (foundUrl && foundUrl[0]) {
// Clean the URL from the address bar to prevent re-processing on refresh
window.history.replaceState({}, document.title, window.location.pathname);
handleSharedLink(foundUrl[0]);
}
}
}, []); // Empty dependency array ensures this runs only once on mount
// Handle Keyboard Shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Add Link Shortcut (Cmd/Ctrl + K)
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
document.getElementById('add-link-input')?.focus();
}
// Open Category Manager Shortcut (Cmd/Ctrl + Shift + C)
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === 'c') {
e.preventDefault();
setIsCategoryManagerOpen(true);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, []);
// Update meta theme-color on theme change for PWA
useEffect(() => {
const metaThemeColor = document.querySelector('meta[name="theme-color"]');
if (metaThemeColor) {
metaThemeColor.setAttribute('content', theme === 'dark' ? '#0f172a' : '#6366f1');
}
}, [theme]);
const handleAddLink = (newLink: Omit<Link, 'id' | 'status' | 'createdAt'>) => {
const linkToAdd: Link = {
...newLink,
id: crypto.randomUUID(),
status: LinkStatus.PENDING,
createdAt: Date.now(),
};
// Prevent adding duplicates, especially from sharing
if (!links.some(link => link.url === linkToAdd.url)) {
setLinks(prevLinks => [linkToAdd, ...prevLinks]);
// Add new category if it doesn't exist
handleAddCategory(linkToAdd.category);
}
};
const handleSharedLink = async (url: string) => {
if (links.some(link => link.url === url)) {
console.log("Shared link already exists.");
return;
}
setIsProcessingSharedLink(true);
try {
const metadata = await fetchLinkMetadata(url.trim());
handleAddLink({ url: url.trim(), ...metadata });
} catch (err) {
console.error("Failed to process shared link:", err);
// Add a fallback link with an error message
handleAddLink({
url: url.trim(),
title: "Unable to Access Document Content",
summary: `Could not analyze ${url.trim()}. You can manage it manually.`,
category: 'Error',
sources: [],
});
} finally {
setIsProcessingSharedLink(false);
}
};
const handleToggleStatus = (id: string) => {
setLinks(prevLinks =>
prevLinks.map(link =>
link.id === id
? { ...link, status: link.status === LinkStatus.DONE ? LinkStatus.PENDING : LinkStatus.DONE }
: link
)
);
};
const handleDeleteLink = (id: string) => {
setLinks(prevLinks => prevLinks.filter(link => link.id !== id));
setSelectedLinkIds(prev => {
const newSet = new Set(prev);
newSet.delete(id);
return newSet;
});
};
const handleUpdateLink = (id: string, updates: Partial<Omit<Link, 'id' | 'url' | 'createdAt'>>) => {
setLinks(prevLinks =>
prevLinks.map(link =>
link.id === id ? { ...link, ...updates } : link
)
);
if(updates.category){
handleAddCategory(updates.category);
}
};
const handleDeepAnalysis = async (linkId: string) => {
const linkToAnalyze = links.find(link => link.id === linkId);
if (!linkToAnalyze) return;
setAnalyzingLinkId(linkId);
try {
const deepSummary = await fetchDeepAnalysis(linkToAnalyze.url);
handleUpdateLink(linkId, { summary: deepSummary });
} catch (error) {
console.error("Deep analysis failed:", error);
handleUpdateLink(linkId, { summary: "Deep analysis failed. Please try again."});
} finally {
setAnalyzingLinkId(null);
}
};
const handleClearCompleted = () => {
setLinks(prevLinks => prevLinks.filter(link => link.status !== LinkStatus.DONE));
};
// Category Management Handlers
const handleAddCategory = (name: string) => {
const newCategory = name.trim();
if (newCategory && !categories.find(c => c.toLowerCase() === newCategory.toLowerCase())) {
setCategories(prev => [...prev, newCategory].sort());
}
};
const handleUpdateCategory = (oldName: string, newName: string) => {
const trimmedNewName = newName.trim();
if (!trimmedNewName || oldName.toLowerCase() === trimmedNewName.toLowerCase()) return;
if (categories.find(c => c.toLowerCase() === trimmedNewName.toLowerCase())) {
alert(`Category "${trimmedNewName}" already exists.`);
return;
}
// Update links
setLinks(prevLinks => prevLinks.map(link =>
link.category.toLowerCase() === oldName.toLowerCase() ? { ...link, category: trimmedNewName } : link
));
// Update category list
setCategories(prev => prev.map(c => c.toLowerCase() === oldName.toLowerCase() ? trimmedNewName : c).sort());
};
const handleDeleteCategory = (name: string) => {
if (name.toLowerCase() === 'uncategorized') return;
// Re-assign links to 'Uncategorized'
setLinks(prevLinks => prevLinks.map(link =>
link.category.toLowerCase() === name.toLowerCase() ? { ...link, category: 'Uncategorized' } : link
));
// Remove from category list
setCategories(prev => prev.filter(c => c.toLowerCase() !== name.toLowerCase()));
};
// Selection Handlers
const handleToggleSelection = (id: string) => {
setSelectedLinkIds(prev => {
const newSet = new Set(prev);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
return newSet;
});
};
const handleClearSelection = () => setSelectedLinkIds(new Set());
const handleToggleSelectAll = (visibleLinkIds: string[]) => {
const allVisibleSelected = visibleLinkIds.every(id => selectedLinkIds.has(id));
if (allVisibleSelected) {
// Deselect all visible
setSelectedLinkIds(prev => {
const newSet = new Set(prev);
visibleLinkIds.forEach(id => newSet.delete(id));
return newSet;
});
} else {
// Select all visible
setSelectedLinkIds(prev => new Set([...prev, ...visibleLinkIds]));
}
};
const handleBulkAction = (action: 'delete' | 'setStatus' | 'setCategory', payload?: any) => {
if (action === 'delete') {
if (window.confirm(`Are you sure you want to delete ${selectedLinkIds.size} selected links?`)) {
setLinks(prev => prev.filter(link => !selectedLinkIds.has(link.id)));
handleClearSelection();
}
} else {
setLinks(prev => prev.map(link => {
if (selectedLinkIds.has(link.id)) {
if (action === 'setStatus') return { ...link, status: payload };
if (action === 'setCategory') return { ...link, category: payload };
}
return link;
}));
handleClearSelection();
}
};
const sortedAndFilteredLinks = useMemo(() => {
const statusFiltered = filter === 'ALL'
? links
: links.filter(link => link.status === filter);
const categoryFiltered = categoryFilter === 'ALL'
? statusFiltered
: statusFiltered.filter(link => link.category === categoryFilter);
return [...categoryFiltered].sort((a, b) => {
const { key, direction } = sortConfig;
const order = direction === 'ASC' ? 1 : -1;
switch (key) {
case 'createdAt':
return (a.createdAt - b.createdAt) * order;
case 'title':
return a.title.localeCompare(b.title) * order;
case 'status':
// PENDING is "less than" DONE
if (a.status === b.status) return 0;
return (a.status === LinkStatus.PENDING ? -1 : 1) * order;
case 'dueDate':
if (!a.dueDate && !b.dueDate) return 0;
if (a.dueDate && !b.dueDate) return -1 * order; // items with due dates come first
if (!a.dueDate && b.dueDate) return 1 * order;
if (a.dueDate && b.dueDate) {
return (a.dueDate - b.dueDate) * order;
}
return 0;
default:
return 0;
}
});
}, [links, filter, categoryFilter, sortConfig]);
const completedCount = useMemo(() => links.filter(link => link.status === LinkStatus.DONE).length, [links]);
// Clear selection when filters change to avoid confusion
useEffect(() => {
handleClearSelection();
}, [filter, categoryFilter, sortConfig]);
return (
<>
<div className="min-h-screen font-sans text-slate-800 dark:text-slate-200">
<div className="container mx-auto max-w-3xl p-4 md:p-8 pb-24">
<Header
onManageCategories={() => setIsCategoryManagerOpen(true)}
theme={theme}
setTheme={setTheme}
/>
<main>
{isProcessingSharedLink && (
<div className="fixed top-0 left-0 right-0 z-50 flex justify-center p-4 bg-indigo-600 text-white shadow-lg animate-pulse">
<div className="flex items-center gap-3">
<Spinner />
<span>Processing shared link...</span>
</div>
</div>
)}
<AddLinkForm onAddLink={handleAddLink} />
<div className="mt-8 bg-white dark:bg-slate-800 rounded-lg shadow-lg">
<Dashboard links={links} />
<FilterControls
currentFilter={filter}
onFilterChange={setFilter}
categoryFilter={categoryFilter}
onCategoryFilterChange={setCategoryFilter}
categories={['ALL', ...categories]}
sortConfig={sortConfig}
onSortChange={setSortConfig}
onClearCompleted={handleClearCompleted}
hasCompletedItems={completedCount > 0}
/>
<LinkList
links={sortedAndFilteredLinks}
categories={categories}
selectedLinkIds={selectedLinkIds}
onToggleSelection={handleToggleSelection}
onToggleSelectAll={handleToggleSelectAll}
onToggleStatus={handleToggleStatus}
onDeleteLink={handleDeleteLink}
onUpdateLink={handleUpdateLink}
onAddCategory={handleAddCategory}
analyzingLinkId={analyzingLinkId}
onDeepAnalysis={handleDeepAnalysis}
/>
</div>
</main>
</div>
</div>
<BulkActionsToolbar
selectedCount={selectedLinkIds.size}
categories={categories.filter(c => c.toLowerCase() !== 'uncategorized')}
onBulkAction={handleBulkAction}
onClearSelection={handleClearSelection}
/>
<CategoryManager
isOpen={isCategoryManagerOpen}
onClose={() => setIsCategoryManagerOpen(false)}
categories={categories}
onAddCategory={handleAddCategory}
onUpdateCategory={handleUpdateCategory}
onDeleteCategory={handleDeleteCategory}
/>
</>
);
};
export default App;