diff --git a/src/db/schema.ts b/src/db/schema.ts index cee9660..7b8169f 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -26,3 +26,13 @@ export const chats = sqliteTable('chats', { .$type() .default(sql`'[]'`), }); + +export const userPreferences = sqliteTable('user_preferences', { + id: integer('id').primaryKey(), + userId: text('user_id').notNull(), + categories: text('categories', { mode: 'json' }) + .$type() + .default(sql`'["AI", "Technology"]'`), + createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`), + updatedAt: text('updated_at').notNull().default(sql`CURRENT_TIMESTAMP`), +}); diff --git a/src/routes/discover.ts b/src/routes/discover.ts index b6f8ff9..51052c9 100644 --- a/src/routes/discover.ts +++ b/src/routes/discover.ts @@ -1,42 +1,142 @@ import express from 'express'; import { searchSearxng } from '../lib/searxng'; import logger from '../utils/logger'; +import db from '../db'; +import { userPreferences } from '../db/schema'; +import { eq } from 'drizzle-orm'; const router = express.Router(); +// Helper function to get search queries for a category +const getSearchQueriesForCategory = (category: string): { site: string, keyword: string }[] => { + const categories: Record = { + 'Technology': [ + { site: 'techcrunch.com', keyword: 'tech' }, + { site: 'wired.com', keyword: 'technology' }, + { site: 'theverge.com', keyword: 'tech' } + ], + 'AI': [ + { site: 'businessinsider.com', keyword: 'AI' }, + { site: 'www.exchangewire.com', keyword: 'AI' }, + { site: 'yahoo.com', keyword: 'AI' } + ], + 'Sports': [ + { site: 'espn.com', keyword: 'sports' }, + { site: 'sports.yahoo.com', keyword: 'sports' }, + { site: 'bleacherreport.com', keyword: 'sports' } + ], + 'Money': [ + { site: 'bloomberg.com', keyword: 'finance' }, + { site: 'cnbc.com', keyword: 'money' }, + { site: 'wsj.com', keyword: 'finance' } + ], + 'Gaming': [ + { site: 'ign.com', keyword: 'games' }, + { site: 'gamespot.com', keyword: 'gaming' }, + { site: 'polygon.com', keyword: 'games' } + ], + 'Weather': [ + { site: 'weather.com', keyword: 'forecast' }, + { site: 'accuweather.com', keyword: 'weather' }, + { site: 'wunderground.com', keyword: 'weather' } + ], + 'Entertainment': [ + { site: 'variety.com', keyword: 'entertainment' }, + { site: 'hollywoodreporter.com', keyword: 'entertainment' }, + { site: 'ew.com', keyword: 'entertainment' } + ], + 'Science': [ + { site: 'scientificamerican.com', keyword: 'science' }, + { site: 'nature.com', keyword: 'science' }, + { site: 'science.org', keyword: 'science' } + ], + 'Health': [ + { site: 'webmd.com', keyword: 'health' }, + { site: 'health.harvard.edu', keyword: 'health' }, + { site: 'mayoclinic.org', keyword: 'health' } + ], + 'Travel': [ + { site: 'travelandleisure.com', keyword: 'travel' }, + { site: 'lonelyplanet.com', keyword: 'travel' }, + { site: 'tripadvisor.com', keyword: 'travel' } + ], + 'Current News': [ + { site: 'reuters.com', keyword: 'news' }, + { site: 'apnews.com', keyword: 'news' }, + { site: 'bbc.com', keyword: 'news' } + ] + }; + + return categories[category] || categories['Technology']; +}; + +// Helper function to perform searches for a category +const searchCategory = async (category: string) => { + const queries = getSearchQueriesForCategory(category); + const searchPromises = queries.map(query => + searchSearxng(`site:${query.site} ${query.keyword}`, { + engines: ['bing news'], + pageno: 1, + }) + ); + + const results = await Promise.all(searchPromises); + return results.map(result => result.results).flat(); +}; + +// Main discover route - supports category and preferences parameters router.get('/', async (req, res) => { try { - const data = ( - await Promise.all([ - searchSearxng('site:businessinsider.com AI', { - engines: ['bing news'], - pageno: 1, - }), - searchSearxng('site:www.exchangewire.com AI', { - engines: ['bing news'], - pageno: 1, - }), - searchSearxng('site:yahoo.com AI', { - engines: ['bing news'], - pageno: 1, - }), - searchSearxng('site:businessinsider.com tech', { - engines: ['bing news'], - pageno: 1, - }), - searchSearxng('site:www.exchangewire.com tech', { - engines: ['bing news'], - pageno: 1, - }), - searchSearxng('site:yahoo.com tech', { - engines: ['bing news'], - pageno: 1, - }), - ]) - ) - .map((result) => result.results) - .flat() - .sort(() => Math.random() - 0.5); + const category = req.query.category as string; + const preferencesParam = req.query.preferences as string; + + let data: any[] = []; + + if (category && category !== 'For You') { + // Get news for a specific category + data = await searchCategory(category); + } else if (preferencesParam) { + // Get news based on user preferences + const preferences = JSON.parse(preferencesParam); + const categoryPromises = preferences.map((pref: string) => searchCategory(pref)); + const results = await Promise.all(categoryPromises); + data = results.flat(); + } else { + // Default behavior - get AI and Tech news + data = ( + await Promise.all([ + searchSearxng('site:businessinsider.com AI', { + engines: ['bing news'], + pageno: 1, + }), + searchSearxng('site:www.exchangewire.com AI', { + engines: ['bing news'], + pageno: 1, + }), + searchSearxng('site:yahoo.com AI', { + engines: ['bing news'], + pageno: 1, + }), + searchSearxng('site:businessinsider.com tech', { + engines: ['bing news'], + pageno: 1, + }), + searchSearxng('site:www.exchangewire.com tech', { + engines: ['bing news'], + pageno: 1, + }), + searchSearxng('site:yahoo.com tech', { + engines: ['bing news'], + pageno: 1, + }), + ]) + ) + .map((result) => result.results) + .flat(); + } + + // Shuffle the results + data = data.sort(() => Math.random() - 0.5); return res.json({ blogs: data }); } catch (err: any) { @@ -45,4 +145,62 @@ router.get('/', async (req, res) => { } }); +// Get user preferences +router.get('/preferences', async (req, res) => { + try { + // In a real app, you would get the user ID from the session/auth + const userId = req.query.userId as string || 'default-user'; + + const userPrefs = await db.select().from(userPreferences).where(eq(userPreferences.userId, userId)); + + if (userPrefs.length === 0) { + // Return default preferences if none exist + return res.json({ categories: ['AI', 'Technology'] }); + } + + return res.json({ categories: userPrefs[0].categories }); + } catch (err: any) { + logger.error(`Error getting user preferences: ${err.message}`); + return res.status(500).json({ message: 'An error has occurred' }); + } +}); + +// Update user preferences +router.post('/preferences', async (req, res) => { + try { + // In a real app, you would get the user ID from the session/auth + const userId = req.query.userId as string || 'default-user'; + const { categories } = req.body; + + if (!categories || !Array.isArray(categories)) { + return res.status(400).json({ message: 'Invalid categories format' }); + } + + const userPrefs = await db.select().from(userPreferences).where(eq(userPreferences.userId, userId)); + + if (userPrefs.length === 0) { + // Create new preferences + await db.insert(userPreferences).values({ + userId, + categories, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + } else { + // Update existing preferences + await db.update(userPreferences) + .set({ + categories, + updatedAt: new Date().toISOString() + }) + .where(eq(userPreferences.userId, userId)); + } + + return res.json({ message: 'Preferences updated successfully' }); + } catch (err: any) { + logger.error(`Error updating user preferences: ${err.message}`); + return res.status(500).json({ message: 'An error has occurred' }); + } +}); + export default router; diff --git a/ui/app/discover/page.tsx b/ui/app/discover/page.tsx index eb94040..a725672 100644 --- a/ui/app/discover/page.tsx +++ b/ui/app/discover/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Search } from 'lucide-react'; +import { Search, Settings } from 'lucide-react'; import { useEffect, useState } from 'react'; import Link from 'next/link'; import { toast } from 'sonner'; @@ -12,14 +12,81 @@ interface Discover { thumbnail: string; } +// List of available categories +const categories = [ + 'For You', 'AI', 'Technology', 'Current News', 'Sports', + 'Money', 'Gaming', 'Weather', 'Entertainment', 'Science', + 'Health', 'Travel' +]; + const Page = () => { const [discover, setDiscover] = useState(null); const [loading, setLoading] = useState(true); + const [activeCategory, setActiveCategory] = useState('For You'); + const [showPreferences, setShowPreferences] = useState(false); + const [userPreferences, setUserPreferences] = useState(['AI', 'Technology']); + // Load user preferences on component mount + useEffect(() => { + const loadUserPreferences = async () => { + try { + const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/discover/preferences`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (res.ok) { + const data = await res.json(); + setUserPreferences(data.categories || ['AI', 'Technology']); + } + } catch (err: any) { + console.error('Error loading preferences:', err.message); + // Use default preferences if loading fails + } + }; + + loadUserPreferences(); + }, []); + + // Save user preferences + const saveUserPreferences = async (preferences: string[]) => { + try { + const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/discover/preferences`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ categories: preferences }), + }); + + if (res.ok) { + toast.success('Preferences saved successfully'); + } else { + const data = await res.json(); + throw new Error(data.message); + } + } catch (err: any) { + console.error('Error saving preferences:', err.message); + toast.error('Error saving preferences'); + } + }; + + // Fetch data based on active category or user preferences useEffect(() => { const fetchData = async () => { + setLoading(true); try { - const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/discover`, { + let endpoint = `${process.env.NEXT_PUBLIC_API_URL}/discover`; + + if (activeCategory !== 'For You') { + endpoint += `?category=${encodeURIComponent(activeCategory)}`; + } else if (userPreferences.length > 0) { + endpoint += `?preferences=${encodeURIComponent(JSON.stringify(userPreferences))}`; + } + + const res = await fetch(endpoint, { method: 'GET', headers: { 'Content-Type': 'application/json', @@ -44,7 +111,7 @@ const Page = () => { }; fetchData(); - }, []); + }, [activeCategory, userPreferences]); return loading ? (
@@ -69,13 +136,89 @@ const Page = () => { <>
-
- -

Discover

+
+
+ +

Discover

+
+
+ + {/* Category Navigation */} +
+ {categories.map((category) => ( + + ))} +
+
+ {/* Personalization Modal */} + {showPreferences && ( +
+
+

Personalize Your Feed

+

Select categories you're interested in:

+ +
+ {categories.filter(c => c !== 'For You').map((category) => ( + + ))} +
+ +
+ + +
+
+
+ )} +
{discover && discover?.map((item, i) => ( @@ -85,6 +228,7 @@ const Page = () => { className="max-w-sm rounded-lg overflow-hidden bg-light-secondary dark:bg-dark-secondary hover:-translate-y-[1px] transition duration-200" target="_blank" > + {/* Using img tag instead of Next.js Image for external URLs */}