"use client"; import { useState, useEffect, useCallback, useRef } from "react"; import { Article, Folder } from "@/lib/types"; import { useReaderSettings, useTTSSettings } from "@/hooks/useSettings"; import { useTTS } from "@/hooks/useTTS"; import { ArticleList } from "@/components/ArticleList"; import { Reader } from "@/components/Reader"; import { SettingsPanel } from "@/components/SettingsPanel"; import { TTSControls } from "@/components/TTSControls"; import { AddArticle } from "@/components/AddArticle"; import { BookOpen, Star, Archive, Menu, Search, FolderIcon, Settings, X, CheckSquare, ArchiveRestore, ArrowUpDown, } from "lucide-react"; import Link from "next/link"; type FilterType = "all" | "favorites" | "archived" | "folder" | "search"; type SortType = "newest" | "oldest" | "title-asc" | "title-desc"; // Simple client-side cache for instant section switching const articleCache = new Map(); const CACHE_TTL = 30000; // 30 seconds export default function Home() { const [articles, setArticles] = useState([]); const [folders, setFolders] = useState([]); const [selectedArticle, setSelectedArticle] = useState
(null); const [filter, setFilter] = useState("all"); const [selectedFolderId, setSelectedFolderId] = useState(null); const [searchQuery, setSearchQuery] = useState(""); const [isSearching, setIsSearching] = useState(false); const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [isSidebarOpen, setIsSidebarOpen] = useState(true); const [isLoading, setIsLoading] = useState(true); const [stats, setStats] = useState<{ streak: number; todayCount: number } | null>(null); const [isSelectMode, setIsSelectMode] = useState(false); const [selectedIds, setSelectedIds] = useState>(new Set()); const [isBulkUpdating, setIsBulkUpdating] = useState(false); const [sortBy, setSortBy] = useState("newest"); const [showSortMenu, setShowSortMenu] = useState(false); const [readerSettings, setReaderSettings] = useReaderSettings(); const [ttsSettings, setTTSSettings] = useTTSSettings(); const tts = useTTS({ settings: ttsSettings, text: selectedArticle?.textContent || "", }); // Apply theme (with auto-scheduling) useEffect(() => { if (readerSettings.autoTheme) { const hour = new Date().getHours(); const isDaytime = hour >= 6 && hour < 18; const theme = isDaytime ? readerSettings.dayTheme : readerSettings.nightTheme; document.documentElement.setAttribute("data-theme", theme); } else { document.documentElement.setAttribute("data-theme", readerSettings.theme); } }, [readerSettings]); // Sort articles client-side const sortArticles = useCallback((articles: Article[], sort: SortType): Article[] => { const sorted = [...articles]; switch (sort) { case "newest": return sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); case "oldest": return sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); case "title-asc": return sorted.sort((a, b) => a.title.localeCompare(b.title)); case "title-desc": return sorted.sort((a, b) => b.title.localeCompare(a.title)); default: return sorted; } }, []); // Fetch articles with caching const fetchArticles = useCallback(async (skipCache = false) => { const cacheKey = filter === "folder" ? `folder-${selectedFolderId}` : filter; // Check cache first for instant display if (!skipCache) { const cached = articleCache.get(cacheKey); if (cached && Date.now() - cached.timestamp < CACHE_TTL) { setArticles(sortArticles(cached.data, sortBy)); setIsLoading(false); return; } } try { let url = `/api/articles?filter=${filter}`; if (filter === "folder" && selectedFolderId) { url = `/api/articles?folderId=${selectedFolderId}`; } const response = await fetch(url); if (response.ok) { const data = await response.json(); // Cache the raw data articleCache.set(cacheKey, { data, timestamp: Date.now() }); setArticles(sortArticles(data, sortBy)); } } catch (error) { console.error("Failed to fetch articles:", error); } finally { setIsLoading(false); } }, [filter, selectedFolderId, sortBy, sortArticles]); // Fetch folders const fetchFolders = useCallback(async () => { try { const response = await fetch("/api/folders"); if (response.ok) { setFolders(await response.json()); } } catch (error) { console.error("Failed to fetch folders:", error); } }, []); // Fetch stats const fetchStats = useCallback(async () => { try { const response = await fetch("/api/stats?days=7"); if (response.ok) { const data = await response.json(); const today = new Date().toISOString().split("T")[0]; const todayStats = data.daily.find((d: { date: string }) => d.date === today); setStats({ streak: data.streak, todayCount: todayStats?.articlesRead || 0, }); } } catch (error) { console.error("Failed to fetch stats:", error); } }, []); useEffect(() => { fetchArticles(); fetchFolders(); fetchStats(); }, [fetchArticles, fetchFolders, fetchStats]); // Re-sort when sort changes (use cached data for instant response) useEffect(() => { const cacheKey = filter === "folder" ? `folder-${selectedFolderId}` : filter; const cached = articleCache.get(cacheKey); if (cached) { setArticles(sortArticles(cached.data, sortBy)); } }, [sortBy, filter, selectedFolderId, sortArticles]); // Search const handleSearch = async () => { if (!searchQuery.trim()) { setFilter("all"); return; } setIsSearching(true); try { const response = await fetch(`/api/search?q=${encodeURIComponent(searchQuery)}`); if (response.ok) { const data = await response.json(); setArticles(data); setFilter("search"); } } catch (error) { console.error("Search failed:", error); } finally { setIsSearching(false); } }; // Add article const handleAddArticle = async (url: string) => { const response = await fetch("/api/articles", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url }), }); if (!response.ok) { const data = await response.json(); throw new Error(data.error || "Failed to add article"); } articleCache.clear(); await fetchArticles(true); fetchStats(); }; // Toggle favorite const handleToggleFavorite = async (id: string, isFavorite: boolean) => { await fetch(`/api/articles/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ isFavorite }), }); setArticles((prev) => prev.map((a) => (a.id === id ? { ...a, isFavorite } : a)) ); if (selectedArticle?.id === id) { setSelectedArticle((prev) => (prev ? { ...prev, isFavorite } : null)); } }; // Toggle archive const handleToggleArchive = async (id: string, isArchived: boolean) => { await fetch(`/api/articles/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ isArchived }), }); articleCache.clear(); await fetchArticles(true); fetchStats(); if (selectedArticle?.id === id) { setSelectedArticle(null); } }; // Delete article const handleDelete = async (id: string) => { await fetch(`/api/articles/${id}`, { method: "DELETE" }); articleCache.clear(); await fetchArticles(true); if (selectedArticle?.id === id) { setSelectedArticle(null); } }; // Fetch full article (with content) when opening for reading const handleSelectArticle = async (article: Article) => { // If article already has content, use it directly if (article.content && article.textContent) { setSelectedArticle(article); return; } // Otherwise fetch full article try { const response = await fetch(`/api/articles/${article.id}`); if (response.ok) { const fullArticle = await response.json(); setSelectedArticle(fullArticle); } } catch (error) { console.error("Failed to fetch article:", error); } }; // Toggle article selection const handleToggleSelect = (id: string) => { setSelectedIds((prev) => { const next = new Set(prev); if (next.has(id)) { next.delete(id); } else { next.add(id); } return next; }); }; // Select all visible articles const handleSelectAll = () => { if (selectedIds.size === articles.length) { setSelectedIds(new Set()); } else { setSelectedIds(new Set(articles.map((a) => a.id))); } }; // Bulk archive/unarchive const handleBulkArchive = async (archive: boolean) => { if (selectedIds.size === 0) return; setIsBulkUpdating(true); try { await fetch("/api/articles", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ids: Array.from(selectedIds), updates: { isArchived: archive }, }), }); // Clear cache to force refresh articleCache.clear(); await fetchArticles(true); setSelectedIds(new Set()); setIsSelectMode(false); fetchStats(); } catch (error) { console.error("Bulk update failed:", error); } finally { setIsBulkUpdating(false); } }; // Exit select mode const exitSelectMode = () => { setIsSelectMode(false); setSelectedIds(new Set()); }; // Reading view if (selectedArticle) { return ( <> { tts.stop(); setSelectedArticle(null); }} onToggleFavorite={() => handleToggleFavorite(selectedArticle.id, !selectedArticle.isFavorite) } onToggleArchive={() => { handleToggleArchive(selectedArticle.id, !selectedArticle.isArchived); }} onDelete={() => { handleDelete(selectedArticle.id); }} onOpenSettings={() => setIsSettingsOpen(true)} > setIsSettingsOpen(false)} readerSettings={readerSettings} onReaderSettingsChange={setReaderSettings} ttsSettings={ttsSettings} onTTSSettingsChange={setTTSSettings} availableVoices={tts.voices} /> ); } // List view return (
{/* Sidebar */} {/* Main content */}
{/* Select mode bar */} {isSelectMode ? (
{selectedIds.size} selected
{filter === "archived" ? ( ) : ( )}
) : (
{/* Search */}
setSearchQuery(e.target.value)} onKeyDown={(e) => e.key === "Enter" && handleSearch()} placeholder="Search articles..." className="w-full pl-10 pr-4 py-2 rounded-lg border border-[var(--border)] bg-[var(--background)] text-sm" /> {searchQuery && ( )}
{articles.length} articles {/* Sort dropdown */}
{showSortMenu && ( <>
setShowSortMenu(false)} />
{[ { value: "newest", label: "Newest first" }, { value: "oldest", label: "Oldest first" }, { value: "title-asc", label: "Title A-Z" }, { value: "title-desc", label: "Title Z-A" }, ].map((option) => ( ))}
)}
{articles.length > 0 && ( )}
)}
{isLoading || isSearching ? (
{isSearching ? "Searching..." : "Loading..."}
) : ( )}
setIsSettingsOpen(false)} readerSettings={readerSettings} onReaderSettingsChange={setReaderSettings} ttsSettings={ttsSettings} onTTSSettingsChange={setTTSSettings} availableVoices={tts.voices} />
); }