diff --git a/frontend/bun.lockb b/frontend/bun.lockb new file mode 100644 index 0000000..08a563b Binary files /dev/null and b/frontend/bun.lockb differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 65c03e7..e8b9c86 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,46 +1,135 @@ import { useEffect, useRef, useCallback, useState } from 'react' import { WikiCard } from './components/WikiCard' import { useWikiArticles } from './hooks/useWikiArticles' -import { Loader2 } from 'lucide-react' +import { Loader2, MoreHorizontal, Info, Globe2 } from 'lucide-react' import { Analytics } from "@vercel/analytics/react" -import { LanguageSelector } from './components/LanguageSelector' +import { useLocalization } from './hooks/useLocalization' +import { LANGUAGES } from './languages' + +type DialogType = 'none' | 'about' | 'language' | 'topics'; function App() { - const [showAbout, setShowAbout] = useState(false) - const { articles, loading, fetchArticles } = useWikiArticles() - const observerTarget = useRef(null) + const isMobile = window.innerWidth <= 768; // Adjust if needed + + // Built-in topics + const initialTopics = [ + { label: 'Random', value: '' }, + { label: 'Cats', value: 'Category:Cats' }, + { label: 'Music', value: 'Category:Music' }, + { label: 'History', value: 'Category:History' }, + ] + + const [activeDialog, setActiveDialog] = useState('none') + const [topics, setTopics] = useState(initialTopics) + const [selectedTopic, setSelectedTopic] = useState('') + const [newCategory, setNewCategory] = useState('') + const [currentIndex, setCurrentIndex] = useState(0) + + const { + articles, + loading, + getMoreArticles, + fetchArticles, + resetArticles + } = useWikiArticles(); + + const observerTarget = useRef(null) + + // For language selection + const { setLanguage } = useLocalization() + + // Close any open dialog on ESC + useEffect(() => { + const handleEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setActiveDialog('none') + } + } + document.addEventListener('keydown', handleEscape) + return () => document.removeEventListener('keydown', handleEscape) + }, []) + + /** + * Whenever `selectedTopic` changes, reset articles + * and do an initial fetch for the new topic. + */ + useEffect(() => { + resetArticles(); + setCurrentIndex(0); + + // Fetch the first chunk of articles + fetchArticles(selectedTopic) + .then(() => { + // Optionally fetch next chunk into buffer + return fetchArticles(selectedTopic, true); + }) + .catch(console.error); + // We intentionally omit fetchArticles from the dependencies + // to avoid re-creating the function and causing infinite loops + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedTopic]) + + /** + * Infinite scroll intersection observer + */ const handleObserver = useCallback( - (entries: IntersectionObserverEntry[]) => { + async (entries: IntersectionObserverEntry[]) => { const [target] = entries + // If a dialog is open or already loading, skip + if (activeDialog !== 'none') return; if (target.isIntersecting && !loading) { - fetchArticles() + // get more articles from the buffer or from the server + await getMoreArticles(selectedTopic); } }, - [loading, fetchArticles] + [activeDialog, loading, selectedTopic, getMoreArticles] ) + // Attach observer useEffect(() => { const observer = new IntersectionObserver(handleObserver, { threshold: 0.1, rootMargin: '100px', }) - if (observerTarget.current) { observer.observe(observerTarget.current) } - return () => observer.disconnect() }, [handleObserver]) - useEffect(() => { - fetchArticles() - }, []) + // Handler for selecting a topic from the menu + const handleTopicSelect = (topicValue: string) => { + setSelectedTopic(topicValue) + setActiveDialog('none') + } + + // Handler for adding a custom category + const addNewCategory = () => { + if (!newCategory.trim()) return + const catValue = newCategory.startsWith('Category:') + ? newCategory.trim() + : `Category:${newCategory.trim()}` + + // Avoid duplicates + if (topics.find((t) => t.value === catValue)) { + alert('Topic already exists!') + return + } + + setTopics((prev) => [ + ...prev, + { label: newCategory.trim(), value: catValue }, + ]) + setNewCategory('') + } return (
+ {/* App Title */}
-
+ {/* Top-right buttons */} +
+ {/* About */} + + + {/* Language */} + + + {/* Topics */} -
- {showAbout && ( -
-
- + {/* LANGUAGE DIALOG */} + {activeDialog === 'language' && ( +
setActiveDialog('none')} + className={`${ + isMobile + ? "fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4" + : "absolute top-12 right-4 backdrop-blur-md bg-white/10 rounded-md shadow-lg p-4 w-48 border border-white/20 z-50" + }`} + style={{ zIndex: 9999 }} + > +
e.stopPropagation()} + className={`${ + isMobile + ? "backdrop-blur-md bg-white/10 p-6 rounded-lg w-full max-w-md relative border border-white/20" + : "" + }`} + > + {LANGUAGES.map((language) => ( + + ))} +
+
+ )} + + {/* TOPICS DIALOG */} + {activeDialog === 'topics' && ( +
setActiveDialog('none')} + > +
e.stopPropagation()} + > +
+ setNewCategory(e.target.value)} + placeholder="New Category" + className="w-full mb-2 px-2 py-1 text-sm + bg-white/20 text-white placeholder-white/50 + rounded border border-white/20 + focus:outline-none focus:border-white/40" + /> + +
+ +
+ +
+ {topics.map((topic) => ( + + ))} +
+
+
+ )} + + {/* ABOUT DIALOG */} + {activeDialog === 'about' && ( +
setActiveDialog('none')} + className={` + ${ + isMobile + ? 'fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4' + : 'absolute top-12 right-4 backdrop-blur-md bg-white/10 rounded-md shadow-lg p-4 w-80 border border-white/20 z-50' + } + `} + > +
e.stopPropagation()} + className={` + ${ + isMobile + ? 'backdrop-blur-md bg-white/10 p-6 rounded-lg max-w-md relative border border-white/20' + : '' + } + `} + >

About WikiTok

-

+

A TikTok-style interface for exploring random Wikipedia articles.

@@ -81,6 +314,15 @@ function App() { > @Aizkmusic + , modified by{' '} + + Jacob +

Check out the code on{' '} @@ -97,19 +339,29 @@ function App() {

)} - {articles.map((article) => ( - + {/* Render all articles */} + {articles.map((article, idx) => ( + setCurrentIndex(idx)} + /> ))} + + {/* Intersection Observer "sentinel" */}
+ {loading && (
Loading...
)} +
- ) + ); } -export default App +export default App; diff --git a/frontend/src/components/LanguageSelector.tsx b/frontend/src/components/LanguageSelector.tsx deleted file mode 100644 index 03c34db..0000000 --- a/frontend/src/components/LanguageSelector.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { useState, useEffect, useRef } from "react"; -import { LANGUAGES } from "../languages"; -import { useLocalization } from "../hooks/useLocalization"; - -export function LanguageSelector() { - const [showDropdown, setShowDropdown] = useState(false); - const { setLanguage } = useLocalization(); - const dropdownRef = useRef(null); - - const handleClickOutside = (event: MouseEvent) => { - if ( - dropdownRef.current && - !dropdownRef.current.contains(event.target as Node) - ) { - setShowDropdown(false); - } - }; - - useEffect(() => { - document.addEventListener("mousedown", handleClickOutside); - return () => { - document.removeEventListener("mousedown", handleClickOutside); - }; - }, []); - - return ( -
setShowDropdown(!showDropdown)} - ref={dropdownRef} - > - - - {showDropdown && ( -
- {LANGUAGES.map((language) => ( - - ))} -
- )} -
- ); -} diff --git a/frontend/src/hooks/useWikiArticles.ts b/frontend/src/hooks/useWikiArticles.ts index 5618795..01cab63 100644 --- a/frontend/src/hooks/useWikiArticles.ts +++ b/frontend/src/hooks/useWikiArticles.ts @@ -12,8 +12,12 @@ interface WikiArticle { }; } -const preloadImage = (src: string): Promise => { +/** + * Helper to preload an image so it doesn't appear blank on first render. + */ +const preloadImage = (src?: string): Promise => { return new Promise((resolve, reject) => { + if (!src) return resolve(); const img = new Image(); img.src = src; img.onload = () => resolve(); @@ -23,70 +27,150 @@ const preloadImage = (src: string): Promise => { export function useWikiArticles() { const [articles, setArticles] = useState([]); - const [loading, setLoading] = useState(false); const [buffer, setBuffer] = useState([]); - const {currentLanguage} = useLocalization() - - const fetchArticles = async (forBuffer = false) => { - if (loading) return; - setLoading(true); - try { - const response = await fetch( - currentLanguage.api + - new URLSearchParams({ - action: "query", - format: "json", - generator: "random", - grnnamespace: "0", - prop: "extracts|pageimages", - grnlimit: "20", - exintro: "1", - exchars: "1000", - exlimit: "max", - explaintext: "1", - piprop: "thumbnail", - pithumbsize: "400", - origin: "*", - }) - ); - - const data = await response.json(); - const newArticles = Object.values(data.query.pages) - .map((page: any) => ({ + const [loading, setLoading] = useState(false); + + // We'll store the entire `continue` object here. + // For categorymembers, it's `gcmcontinue`. + // For random, it's `grncontinue`. + // e.g. if data.continue = { gcmcontinue: 'page|12345', continue: '-||' } + const [wikiContinueParams, setWikiContinueParams] = useState | null>(null); + + const { currentLanguage } = useLocalization(); + + /** + * Performs the actual fetch from the Wikipedia API. + * + * @param topic - e.g. `""` for random, or `"Category:Music"` + * @param forBuffer - if true, new articles go into the buffer + */ + const fetchArticles = useCallback( + async (topic: string, forBuffer = false) => { + if (loading) return; // skip if already loading + setLoading(true); + + try { + // Base query parameters + const params: Record = { + action: "query", + format: "json", + prop: "extracts|pageimages", + exintro: "1", + exchars: "1000", + explaintext: "1", + piprop: "thumbnail", + pithumbsize: "400", + origin: "*", + }; + + // If no topic => "Random" mode + if (!topic) { + params.generator = "random"; + params.grnnamespace = "0"; + params.grnlimit = "20"; + } else { + // If a topic => Use categorymembers generator + params.generator = "categorymembers"; + params.gcmnamespace = "0"; + params.gcmlimit = "20"; + params.gcmtitle = topic; // e.g. Category:Music + } + + // If we have a continue object from a previous fetch, include it + // For categorymembers, that might be .gcmcontinue + // For random, that might be .grncontinue + if (wikiContinueParams) { + Object.entries(wikiContinueParams).forEach(([key, val]) => { + params[key] = val; + }); + } + + // Convert to query string + const queryString = new URLSearchParams(params).toString(); + const response = await fetch(currentLanguage.api + queryString); + const data = await response.json(); + + // If no pages found, stop + if (!data.query?.pages) { + console.warn("No articles returned for this topic/category"); + setLoading(false); + return; + } + + // Next "continue" object, e.g. {gcmcontinue: "...", continue: "..."} + // If Wikipedia has more pages in that category, it returns one of these + if (data.continue) { + setWikiContinueParams(data.continue); + } else { + // No more pages to fetch + setWikiContinueParams(null); + } + + // Turn the pages object into an array + const fetchedPages = Object.values(data.query.pages) as any[]; + const newArticles = fetchedPages.map((page: any) => ({ title: page.title, extract: page.extract, pageid: page.pageid, thumbnail: page.thumbnail, - })) - .filter((article) => article.thumbnail); + })); + + // Preload images so they don't flash in + await Promise.allSettled(newArticles.map(a => preloadImage(a.thumbnail?.source))); - await Promise.allSettled( - newArticles - .filter((article) => article.thumbnail) - .map((article) => preloadImage(article.thumbnail!.source)) - ); + // If we want them in the buffer, do so, else put them directly in `articles` + if (forBuffer) { + setBuffer(newArticles); + } else { + setArticles((prev) => [...prev, ...newArticles]); + } + } catch (error) { + console.error("Error fetching articles:", error); + } finally { + setLoading(false); + } + }, + [loading, currentLanguage.api, wikiContinueParams] + ); - if (forBuffer) { - setBuffer(newArticles); + /** + * Public function to load more articles (used by the infinite scroll). + * + * 1) If there's something in buffer, move it to the main list. + * 2) Then prefill the buffer again (if there's still more). + */ + const getMoreArticles = useCallback( + async (topic: string) => { + if (buffer.length > 0) { + // Dump buffer into main list + setArticles((prev) => [...prev, ...buffer]); + setBuffer([]); + // Pre-fetch the next chunk + await fetchArticles(topic, true); } else { - setArticles((prev) => [...prev, ...newArticles]); - fetchArticles(true); + // If buffer empty, fetch right away + await fetchArticles(topic, false); } - } catch (error) { - console.error("Error fetching articles:", error); - } - setLoading(false); - }; + }, + [buffer, fetchArticles] + ); - const getMoreArticles = useCallback(() => { - if (buffer.length > 0) { - setArticles((prev) => [...prev, ...buffer]); - setBuffer([]); - fetchArticles(true); - } else { - fetchArticles(false); - } - }, [buffer]); - - return { articles, loading, fetchArticles: getMoreArticles }; + /** + * Reset function: Call this whenever the topic changes, + * so we start from scratch with no old articles or `continue`. + */ + const resetArticles = useCallback(() => { + setArticles([]); + setBuffer([]); + setWikiContinueParams(null); + }, []); + + return { + articles, + loading, + getMoreArticles, + fetchArticles, + resetArticles, + setArticles, + }; }