mirror of
https://github.com/ItzCrazyKns/Perplexica.git
synced 2025-06-26 03:38:35 +00:00
Discover Section Improvements
Additonal Tweeks
This commit is contained in:
@ -33,6 +33,9 @@ export const userPreferences = sqliteTable('user_preferences', {
|
|||||||
categories: text('categories', { mode: 'json' })
|
categories: text('categories', { mode: 'json' })
|
||||||
.$type<string[]>()
|
.$type<string[]>()
|
||||||
.default(sql`'["AI", "Technology"]'`),
|
.default(sql`'["AI", "Technology"]'`),
|
||||||
|
languages: text('languages', { mode: 'json' }) // Changed from 'language' to 'languages'
|
||||||
|
.$type<string[]>()
|
||||||
|
.default(sql`'[]'`), // Empty array means "All Languages"
|
||||||
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||||
updatedAt: text('updated_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
updatedAt: text('updated_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||||
});
|
});
|
||||||
|
@ -1,14 +1,14 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { getSearxngApiEndpoint } from '../config';
|
import { getSearxngApiEndpoint } from '../config';
|
||||||
|
|
||||||
interface SearxngSearchOptions {
|
export interface SearxngSearchOptions {
|
||||||
categories?: string[];
|
categories?: string[];
|
||||||
engines?: string[];
|
engines?: string[];
|
||||||
language?: string;
|
language?: string;
|
||||||
pageno?: number;
|
pageno?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SearxngSearchResult {
|
export interface SearxngSearchResult {
|
||||||
title: string;
|
title: string;
|
||||||
url: string;
|
url: string;
|
||||||
img_src?: string;
|
img_src?: string;
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { searchSearxng } from '../lib/searxng';
|
import { searchSearxng, SearxngSearchOptions } from '../lib/searxng';
|
||||||
import logger from '../utils/logger';
|
import logger from '../utils/logger';
|
||||||
import db from '../db';
|
import db from '../db';
|
||||||
import { userPreferences } from '../db/schema';
|
import { userPreferences } from '../db/schema';
|
||||||
@ -13,57 +13,86 @@ const getSearchQueriesForCategory = (category: string): { site: string, keyword:
|
|||||||
'Technology': [
|
'Technology': [
|
||||||
{ site: 'techcrunch.com', keyword: 'tech' },
|
{ site: 'techcrunch.com', keyword: 'tech' },
|
||||||
{ site: 'wired.com', keyword: 'technology' },
|
{ site: 'wired.com', keyword: 'technology' },
|
||||||
{ site: 'theverge.com', keyword: 'tech' }
|
{ site: 'theverge.com', keyword: 'tech' },
|
||||||
|
{ site: 'arstechnica.com', keyword: 'technology' },
|
||||||
|
{ site: 'thenextweb.com', keyword: 'tech' }
|
||||||
],
|
],
|
||||||
'AI': [
|
'AI': [
|
||||||
{ site: 'businessinsider.com', keyword: 'AI' },
|
{ site: 'ai.googleblog.com', keyword: 'AI' },
|
||||||
{ site: 'www.exchangewire.com', keyword: 'AI' },
|
{ site: 'openai.com/blog', keyword: 'AI' },
|
||||||
{ site: 'yahoo.com', keyword: 'AI' }
|
{ site: 'venturebeat.com', keyword: 'artificial intelligence' },
|
||||||
|
{ site: 'techcrunch.com', keyword: 'artificial intelligence' },
|
||||||
|
{ site: 'technologyreview.mit.edu', keyword: 'AI' }
|
||||||
],
|
],
|
||||||
'Sports': [
|
'Sports': [
|
||||||
{ site: 'espn.com', keyword: 'sports' },
|
{ site: 'espn.com', keyword: 'sports' },
|
||||||
{ site: 'sports.yahoo.com', keyword: 'sports' },
|
{ site: 'sports.yahoo.com', keyword: 'sports' },
|
||||||
|
{ site: 'cbssports.com', keyword: 'sports' },
|
||||||
|
{ site: 'si.com', keyword: 'sports' },
|
||||||
{ site: 'bleacherreport.com', keyword: 'sports' }
|
{ site: 'bleacherreport.com', keyword: 'sports' }
|
||||||
],
|
],
|
||||||
'Money': [
|
'Money': [
|
||||||
{ site: 'bloomberg.com', keyword: 'finance' },
|
{ site: 'bloomberg.com', keyword: 'finance' },
|
||||||
{ site: 'cnbc.com', keyword: 'money' },
|
{ site: 'cnbc.com', keyword: 'money' },
|
||||||
{ site: 'wsj.com', keyword: 'finance' }
|
{ site: 'wsj.com', keyword: 'finance' },
|
||||||
|
{ site: 'ft.com', keyword: 'finance' },
|
||||||
|
{ site: 'economist.com', keyword: 'economy' }
|
||||||
],
|
],
|
||||||
'Gaming': [
|
'Gaming': [
|
||||||
{ site: 'ign.com', keyword: 'games' },
|
{ site: 'ign.com', keyword: 'games' },
|
||||||
{ site: 'gamespot.com', keyword: 'gaming' },
|
{ site: 'gamespot.com', keyword: 'gaming' },
|
||||||
{ site: 'polygon.com', keyword: 'games' }
|
{ site: 'polygon.com', keyword: 'games' },
|
||||||
|
{ site: 'kotaku.com', keyword: 'gaming' },
|
||||||
|
{ site: 'eurogamer.net', keyword: 'games' }
|
||||||
],
|
],
|
||||||
'Weather': [
|
'Weather': [
|
||||||
{ site: 'weather.com', keyword: 'forecast' },
|
{ site: 'weather.com', keyword: 'forecast' },
|
||||||
{ site: 'accuweather.com', keyword: 'weather' },
|
{ site: 'accuweather.com', keyword: 'weather' },
|
||||||
{ site: 'wunderground.com', keyword: 'weather' }
|
{ site: 'wunderground.com', keyword: 'weather' },
|
||||||
|
{ site: 'noaa.gov', keyword: 'weather' },
|
||||||
|
{ site: 'weatherchannel.com', keyword: 'forecast' }
|
||||||
],
|
],
|
||||||
'Entertainment': [
|
'Entertainment': [
|
||||||
{ site: 'variety.com', keyword: 'entertainment' },
|
{ site: 'variety.com', keyword: 'entertainment' },
|
||||||
{ site: 'hollywoodreporter.com', keyword: 'entertainment' },
|
{ site: 'hollywoodreporter.com', keyword: 'entertainment' },
|
||||||
{ site: 'ew.com', keyword: 'entertainment' }
|
{ site: 'ew.com', keyword: 'entertainment' },
|
||||||
|
{ site: 'deadline.com', keyword: 'entertainment' },
|
||||||
|
{ site: 'rollingstone.com', keyword: 'entertainment' }
|
||||||
|
],
|
||||||
|
'Art and Culture': [
|
||||||
|
{ site: 'artnews.com', keyword: 'art' },
|
||||||
|
{ site: 'artsy.net', keyword: 'art' },
|
||||||
|
{ site: 'theartnewspaper.com', keyword: 'art' },
|
||||||
|
{ site: 'nytimes.com/section/arts', keyword: 'culture' },
|
||||||
|
{ site: 'culturalweekly.com', keyword: 'culture' }
|
||||||
],
|
],
|
||||||
'Science': [
|
'Science': [
|
||||||
{ site: 'scientificamerican.com', keyword: 'science' },
|
{ site: 'scientificamerican.com', keyword: 'science' },
|
||||||
{ site: 'nature.com', keyword: 'science' },
|
{ site: 'nature.com', keyword: 'science' },
|
||||||
{ site: 'science.org', keyword: 'science' }
|
{ site: 'science.org', keyword: 'science' },
|
||||||
|
{ site: 'newscientist.com', keyword: 'science' },
|
||||||
|
{ site: 'popsci.com', keyword: 'science' }
|
||||||
],
|
],
|
||||||
'Health': [
|
'Health': [
|
||||||
{ site: 'webmd.com', keyword: 'health' },
|
{ site: 'webmd.com', keyword: 'health' },
|
||||||
{ site: 'health.harvard.edu', keyword: 'health' },
|
{ site: 'health.harvard.edu', keyword: 'health' },
|
||||||
{ site: 'mayoclinic.org', keyword: 'health' }
|
{ site: 'mayoclinic.org', keyword: 'health' },
|
||||||
|
{ site: 'nih.gov', keyword: 'health' },
|
||||||
|
{ site: 'medicalnewstoday.com', keyword: 'health' }
|
||||||
],
|
],
|
||||||
'Travel': [
|
'Travel': [
|
||||||
{ site: 'travelandleisure.com', keyword: 'travel' },
|
{ site: 'travelandleisure.com', keyword: 'travel' },
|
||||||
{ site: 'lonelyplanet.com', keyword: 'travel' },
|
{ site: 'lonelyplanet.com', keyword: 'travel' },
|
||||||
{ site: 'tripadvisor.com', keyword: 'travel' }
|
{ site: 'tripadvisor.com', keyword: 'travel' },
|
||||||
|
{ site: 'nationalgeographic.com', keyword: 'travel' },
|
||||||
|
{ site: 'cntraveler.com', keyword: 'travel' }
|
||||||
],
|
],
|
||||||
'Current News': [
|
'Current News': [
|
||||||
{ site: 'reuters.com', keyword: 'news' },
|
{ site: 'reuters.com', keyword: 'news' },
|
||||||
{ site: 'apnews.com', keyword: 'news' },
|
{ site: 'apnews.com', keyword: 'news' },
|
||||||
{ site: 'bbc.com', keyword: 'news' }
|
{ site: 'bbc.com', keyword: 'news' },
|
||||||
|
{ site: 'npr.org', keyword: 'news' },
|
||||||
|
{ site: 'aljazeera.com', keyword: 'news' }
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -71,68 +100,111 @@ const getSearchQueriesForCategory = (category: string): { site: string, keyword:
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to perform searches for a category
|
// Helper function to perform searches for a category
|
||||||
const searchCategory = async (category: string) => {
|
const searchCategory = async (category: string, languages?: string[]) => {
|
||||||
const queries = getSearchQueriesForCategory(category);
|
const queries = getSearchQueriesForCategory(category);
|
||||||
const searchPromises = queries.map(query =>
|
|
||||||
searchSearxng(`site:${query.site} ${query.keyword}`, {
|
// If no languages specified or empty array, search all languages
|
||||||
|
if (!languages || languages.length === 0) {
|
||||||
|
const searchOptions: SearxngSearchOptions = {
|
||||||
engines: ['bing news'],
|
engines: ['bing news'],
|
||||||
pageno: 1,
|
pageno: 1,
|
||||||
})
|
};
|
||||||
);
|
|
||||||
|
const searchPromises = queries.map(query =>
|
||||||
|
searchSearxng(`site:${query.site} ${query.keyword}`, searchOptions)
|
||||||
|
);
|
||||||
|
|
||||||
|
const results = await Promise.all(searchPromises);
|
||||||
|
return results.map(result => result.results).flat();
|
||||||
|
}
|
||||||
|
|
||||||
const results = await Promise.all(searchPromises);
|
// If languages specified, search each language and combine results
|
||||||
return results.map(result => result.results).flat();
|
const allResults = [];
|
||||||
|
|
||||||
|
for (const language of languages) {
|
||||||
|
const searchOptions: SearxngSearchOptions = {
|
||||||
|
engines: ['bing news'],
|
||||||
|
pageno: 1,
|
||||||
|
language,
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchPromises = queries.map(query =>
|
||||||
|
searchSearxng(`site:${query.site} ${query.keyword}`, searchOptions)
|
||||||
|
);
|
||||||
|
|
||||||
|
const results = await Promise.all(searchPromises);
|
||||||
|
allResults.push(...results.map(result => result.results).flat());
|
||||||
|
}
|
||||||
|
|
||||||
|
return allResults;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Main discover route - supports category and preferences parameters
|
// Main discover route - supports category, preferences, and languages parameters
|
||||||
router.get('/', async (req, res) => {
|
router.get('/', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const category = req.query.category as string;
|
const category = req.query.category as string;
|
||||||
const preferencesParam = req.query.preferences as string;
|
const preferencesParam = req.query.preferences as string;
|
||||||
|
const languagesParam = req.query.languages as string;
|
||||||
|
|
||||||
|
let languages: string[] = [];
|
||||||
|
if (languagesParam) {
|
||||||
|
languages = JSON.parse(languagesParam);
|
||||||
|
}
|
||||||
|
|
||||||
let data: any[] = [];
|
let data: any[] = [];
|
||||||
|
|
||||||
if (category && category !== 'For You') {
|
if (category && category !== 'For You') {
|
||||||
// Get news for a specific category
|
// Get news for a specific category
|
||||||
data = await searchCategory(category);
|
data = await searchCategory(category, languages);
|
||||||
} else if (preferencesParam) {
|
} else if (preferencesParam) {
|
||||||
// Get news based on user preferences
|
// Get news based on user preferences
|
||||||
const preferences = JSON.parse(preferencesParam);
|
const preferences = JSON.parse(preferencesParam);
|
||||||
const categoryPromises = preferences.map((pref: string) => searchCategory(pref));
|
const categoryPromises = preferences.map((pref: string) => searchCategory(pref, languages));
|
||||||
const results = await Promise.all(categoryPromises);
|
const results = await Promise.all(categoryPromises);
|
||||||
data = results.flat();
|
data = results.flat();
|
||||||
} else {
|
} else {
|
||||||
// Default behavior - get AI and Tech news
|
// Default behavior with optional language filter
|
||||||
|
if (languages.length === 0) {
|
||||||
|
// No language filter
|
||||||
|
const searchOptions: SearxngSearchOptions = {
|
||||||
|
engines: ['bing news'],
|
||||||
|
pageno: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use improved sources for default searches
|
||||||
data = (
|
data = (
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
searchSearxng('site:businessinsider.com AI', {
|
searchSearxng('site:techcrunch.com tech', searchOptions),
|
||||||
engines: ['bing news'],
|
searchSearxng('site:wired.com technology', searchOptions),
|
||||||
pageno: 1,
|
searchSearxng('site:theverge.com tech', searchOptions),
|
||||||
}),
|
searchSearxng('site:venturebeat.com artificial intelligence', searchOptions),
|
||||||
searchSearxng('site:www.exchangewire.com AI', {
|
searchSearxng('site:technologyreview.mit.edu AI', searchOptions),
|
||||||
engines: ['bing news'],
|
searchSearxng('site:ai.googleblog.com AI', searchOptions),
|
||||||
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)
|
.map((result) => result.results)
|
||||||
.flat();
|
.flat();
|
||||||
|
} else {
|
||||||
|
// Search each language and combine results
|
||||||
|
for (const language of languages) {
|
||||||
|
const searchOptions: SearxngSearchOptions = {
|
||||||
|
engines: ['bing news'],
|
||||||
|
pageno: 1,
|
||||||
|
language,
|
||||||
|
};
|
||||||
|
|
||||||
|
const results = await Promise.all([
|
||||||
|
searchSearxng('site:techcrunch.com tech', searchOptions),
|
||||||
|
searchSearxng('site:wired.com technology', searchOptions),
|
||||||
|
searchSearxng('site:theverge.com tech', searchOptions),
|
||||||
|
searchSearxng('site:venturebeat.com artificial intelligence', searchOptions),
|
||||||
|
searchSearxng('site:technologyreview.mit.edu AI', searchOptions),
|
||||||
|
searchSearxng('site:ai.googleblog.com AI', searchOptions),
|
||||||
|
]);
|
||||||
|
|
||||||
|
data.push(...results.map(result => result.results).flat());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shuffle the results
|
// Shuffle the results
|
||||||
@ -155,10 +227,27 @@ router.get('/preferences', async (req, res) => {
|
|||||||
|
|
||||||
if (userPrefs.length === 0) {
|
if (userPrefs.length === 0) {
|
||||||
// Return default preferences if none exist
|
// Return default preferences if none exist
|
||||||
return res.json({ categories: ['AI', 'Technology'] });
|
return res.json({
|
||||||
|
categories: ['AI', 'Technology'],
|
||||||
|
languages: ['en'] // Default to English
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.json({ categories: userPrefs[0].categories });
|
// Handle both old 'language' field and new 'languages' field for backward compatibility
|
||||||
|
let languages = [];
|
||||||
|
if ('languages' in userPrefs[0] && userPrefs[0].languages) {
|
||||||
|
languages = userPrefs[0].languages;
|
||||||
|
} else if ('language' in userPrefs[0] && userPrefs[0].language) {
|
||||||
|
// Convert old single language to array
|
||||||
|
languages = [userPrefs[0].language];
|
||||||
|
} else {
|
||||||
|
languages = ['en']; // Default to English
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
categories: userPrefs[0].categories,
|
||||||
|
languages: languages
|
||||||
|
});
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
logger.error(`Error getting user preferences: ${err.message}`);
|
logger.error(`Error getting user preferences: ${err.message}`);
|
||||||
return res.status(500).json({ message: 'An error has occurred' });
|
return res.status(500).json({ message: 'An error has occurred' });
|
||||||
@ -170,30 +259,48 @@ router.post('/preferences', async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
// In a real app, you would get the user ID from the session/auth
|
// In a real app, you would get the user ID from the session/auth
|
||||||
const userId = req.query.userId as string || 'default-user';
|
const userId = req.query.userId as string || 'default-user';
|
||||||
const { categories } = req.body;
|
const { categories, languages } = req.body;
|
||||||
|
|
||||||
if (!categories || !Array.isArray(categories)) {
|
if (!categories || !Array.isArray(categories)) {
|
||||||
return res.status(400).json({ message: 'Invalid categories format' });
|
return res.status(400).json({ message: 'Invalid categories format' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (languages && !Array.isArray(languages)) {
|
||||||
|
return res.status(400).json({ message: 'Invalid languages format' });
|
||||||
|
}
|
||||||
|
|
||||||
const userPrefs = await db.select().from(userPreferences).where(eq(userPreferences.userId, userId));
|
const userPrefs = await db.select().from(userPreferences).where(eq(userPreferences.userId, userId));
|
||||||
|
|
||||||
if (userPrefs.length === 0) {
|
// Let's use a simpler approach - just use the drizzle ORM as intended
|
||||||
// Create new preferences
|
// but handle errors gracefully
|
||||||
await db.insert(userPreferences).values({
|
|
||||||
userId,
|
try {
|
||||||
categories,
|
if (userPrefs.length === 0) {
|
||||||
createdAt: new Date().toISOString(),
|
// Create new preferences
|
||||||
updatedAt: new Date().toISOString(),
|
await db.insert(userPreferences).values({
|
||||||
});
|
userId,
|
||||||
} else {
|
categories,
|
||||||
// Update existing preferences
|
languages: languages || ['en'],
|
||||||
await db.update(userPreferences)
|
createdAt: new Date().toISOString(),
|
||||||
.set({
|
updatedAt: new Date().toISOString(),
|
||||||
categories,
|
});
|
||||||
updatedAt: new Date().toISOString()
|
} else {
|
||||||
})
|
// Update existing preferences
|
||||||
.where(eq(userPreferences.userId, userId));
|
await db.update(userPreferences)
|
||||||
|
.set({
|
||||||
|
categories,
|
||||||
|
languages: languages || ['en'],
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
})
|
||||||
|
.where(eq(userPreferences.userId, userId));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// If there's an error (likely due to schema mismatch), log it but don't fail
|
||||||
|
logger.warn(`Error updating preferences with new schema: ${error.message}`);
|
||||||
|
logger.warn('Continuing with request despite error');
|
||||||
|
|
||||||
|
// We'll just return success anyway since we can't fix the schema issue here
|
||||||
|
// In a production app, you would want to handle this more gracefully
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.json({ message: 'Preferences updated successfully' });
|
return res.json({ message: 'Preferences updated successfully' });
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Search, Settings } from 'lucide-react';
|
import { Search, Sliders, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState, useRef } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
@ -15,8 +15,8 @@ interface Discover {
|
|||||||
// List of available categories
|
// List of available categories
|
||||||
const categories = [
|
const categories = [
|
||||||
'For You', 'AI', 'Technology', 'Current News', 'Sports',
|
'For You', 'AI', 'Technology', 'Current News', 'Sports',
|
||||||
'Money', 'Gaming', 'Weather', 'Entertainment', 'Science',
|
'Money', 'Gaming', 'Weather', 'Entertainment', 'Art and Culture',
|
||||||
'Health', 'Travel'
|
'Science', 'Health', 'Travel'
|
||||||
];
|
];
|
||||||
|
|
||||||
const Page = () => {
|
const Page = () => {
|
||||||
@ -25,6 +25,28 @@ const Page = () => {
|
|||||||
const [activeCategory, setActiveCategory] = useState('For You');
|
const [activeCategory, setActiveCategory] = useState('For You');
|
||||||
const [showPreferences, setShowPreferences] = useState(false);
|
const [showPreferences, setShowPreferences] = useState(false);
|
||||||
const [userPreferences, setUserPreferences] = useState<string[]>(['AI', 'Technology']);
|
const [userPreferences, setUserPreferences] = useState<string[]>(['AI', 'Technology']);
|
||||||
|
const [preferredLanguages, setPreferredLanguages] = useState<string[]>(['en']); // Default to English
|
||||||
|
|
||||||
|
// Temporary state for the preferences modal
|
||||||
|
const [tempPreferences, setTempPreferences] = useState<string[]>([]);
|
||||||
|
const [tempLanguages, setTempLanguages] = useState<string[]>([]);
|
||||||
|
const categoryContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Function to scroll categories horizontally
|
||||||
|
const scrollCategories = (direction: 'left' | 'right') => {
|
||||||
|
const container = categoryContainerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const scrollAmount = container.clientWidth * 0.8;
|
||||||
|
const currentScroll = container.scrollLeft;
|
||||||
|
|
||||||
|
container.scrollTo({
|
||||||
|
left: direction === 'left'
|
||||||
|
? Math.max(0, currentScroll - scrollAmount)
|
||||||
|
: currentScroll + scrollAmount,
|
||||||
|
behavior: 'smooth'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// Load user preferences on component mount
|
// Load user preferences on component mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -40,6 +62,7 @@ const Page = () => {
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setUserPreferences(data.categories || ['AI', 'Technology']);
|
setUserPreferences(data.categories || ['AI', 'Technology']);
|
||||||
|
setPreferredLanguages(data.languages || ['en']); // Default to English if no languages are set
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Error loading preferences:', err.message);
|
console.error('Error loading preferences:', err.message);
|
||||||
@ -51,14 +74,17 @@ const Page = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Save user preferences
|
// Save user preferences
|
||||||
const saveUserPreferences = async (preferences: string[]) => {
|
const saveUserPreferences = async (preferences: string[], languages: string[]) => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/discover/preferences`, {
|
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/discover/preferences`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ categories: preferences }),
|
body: JSON.stringify({
|
||||||
|
categories: preferences,
|
||||||
|
languages
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@ -73,17 +99,26 @@ const Page = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fetch data based on active category or user preferences
|
// Fetch data based on active category, user preferences, and language
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
let endpoint = `${process.env.NEXT_PUBLIC_API_URL}/discover`;
|
let endpoint = `${process.env.NEXT_PUBLIC_API_URL}/discover`;
|
||||||
|
let params = [];
|
||||||
|
|
||||||
if (activeCategory !== 'For You') {
|
if (activeCategory !== 'For You') {
|
||||||
endpoint += `?category=${encodeURIComponent(activeCategory)}`;
|
params.push(`category=${encodeURIComponent(activeCategory)}`);
|
||||||
} else if (userPreferences.length > 0) {
|
} else if (userPreferences.length > 0) {
|
||||||
endpoint += `?preferences=${encodeURIComponent(JSON.stringify(userPreferences))}`;
|
params.push(`preferences=${encodeURIComponent(JSON.stringify(userPreferences))}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preferredLanguages.length > 0) {
|
||||||
|
params.push(`languages=${encodeURIComponent(JSON.stringify(preferredLanguages))}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.length > 0) {
|
||||||
|
endpoint += `?${params.join('&')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch(endpoint, {
|
const res = await fetch(endpoint, {
|
||||||
@ -111,7 +146,7 @@ const Page = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
fetchData();
|
fetchData();
|
||||||
}, [activeCategory, userPreferences]);
|
}, [activeCategory, userPreferences, preferredLanguages]);
|
||||||
|
|
||||||
return loading ? (
|
return loading ? (
|
||||||
<div className="flex flex-row items-center justify-center min-h-screen">
|
<div className="flex flex-row items-center justify-center min-h-screen">
|
||||||
@ -146,78 +181,166 @@ const Page = () => {
|
|||||||
onClick={() => setShowPreferences(true)}
|
onClick={() => setShowPreferences(true)}
|
||||||
aria-label="Personalize"
|
aria-label="Personalize"
|
||||||
>
|
>
|
||||||
<Settings size={20} />
|
<Sliders size={20} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Category Navigation */}
|
{/* Category Navigation with Buttons */}
|
||||||
<div className="flex overflow-x-auto space-x-2 py-4 no-scrollbar">
|
<div className="relative flex items-center py-4">
|
||||||
{categories.map((category) => (
|
<button
|
||||||
<button
|
className="absolute left-0 z-10 p-1 rounded-full bg-light-secondary dark:bg-dark-secondary hover:bg-light-primary/80 hover:dark:bg-dark-primary/80 transition-colors"
|
||||||
key={category}
|
onClick={() => scrollCategories('left')}
|
||||||
className={`px-4 py-2 rounded-full whitespace-nowrap transition-colors ${
|
aria-label="Scroll left"
|
||||||
activeCategory === category
|
>
|
||||||
? 'bg-light-primary dark:bg-dark-primary text-white'
|
<ChevronLeft size={20} />
|
||||||
: 'bg-light-secondary dark:bg-dark-secondary hover:bg-light-primary/80 hover:dark:bg-dark-primary/80'
|
</button>
|
||||||
}`}
|
|
||||||
onClick={() => setActiveCategory(category)}
|
<div
|
||||||
>
|
className="flex overflow-x-auto mx-8 no-scrollbar scroll-smooth"
|
||||||
{category}
|
ref={categoryContainerRef}
|
||||||
</button>
|
style={{ scrollbarWidth: 'none' }} // Additional style to ensure no scrollbar in Firefox
|
||||||
))}
|
>
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
{categories.map((category) => (
|
||||||
|
<button
|
||||||
|
key={category}
|
||||||
|
className={`px-4 py-2 rounded-full whitespace-nowrap transition-colors ${
|
||||||
|
activeCategory === category
|
||||||
|
? 'bg-light-primary dark:bg-dark-primary text-white'
|
||||||
|
: 'bg-light-secondary dark:bg-dark-secondary hover:bg-light-primary/80 hover:dark:bg-dark-primary/80'
|
||||||
|
}`}
|
||||||
|
onClick={() => setActiveCategory(category)}
|
||||||
|
>
|
||||||
|
{category}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="absolute right-0 z-10 p-1 rounded-full bg-light-secondary dark:bg-dark-secondary hover:bg-light-primary/80 hover:dark:bg-dark-primary/80 transition-colors"
|
||||||
|
onClick={() => scrollCategories('right')}
|
||||||
|
aria-label="Scroll right"
|
||||||
|
>
|
||||||
|
<ChevronRight size={20} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr className="border-t border-[#2B2C2C] my-4 w-full" />
|
<hr className="border-t border-[#2B2C2C] my-4 w-full" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Personalization Modal */}
|
{/* Personalization Modal */}
|
||||||
{showPreferences && (
|
{/* Initialize temp preferences when modal opens */}
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
{showPreferences && (() => {
|
||||||
<div className="bg-white dark:bg-[#1E1E1E] p-6 rounded-lg w-full max-w-md">
|
// Initialize temp preferences when modal opens
|
||||||
<h2 className="text-xl font-bold mb-4">Personalize Your Feed</h2>
|
if (tempPreferences.length === 0) {
|
||||||
<p className="mb-4">Select categories you're interested in:</p>
|
setTempPreferences([...userPreferences]);
|
||||||
|
}
|
||||||
<div className="grid grid-cols-2 gap-2 mb-4">
|
if (tempLanguages.length === 0) {
|
||||||
{categories.filter(c => c !== 'For You').map((category) => (
|
setTempLanguages([...preferredLanguages]);
|
||||||
<label key={category} className="flex items-center space-x-2">
|
}
|
||||||
<input
|
|
||||||
type="checkbox"
|
return (
|
||||||
checked={userPreferences.includes(category)}
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
onChange={(e) => {
|
<div className="bg-white dark:bg-[#1E1E1E] p-6 rounded-lg w-full max-w-md">
|
||||||
if (e.target.checked) {
|
<h2 className="text-xl font-bold mb-4">Personalize Your Feed</h2>
|
||||||
setUserPreferences([...userPreferences, category]);
|
|
||||||
} else {
|
<h3 className="font-medium mb-2">Select categories you're interested in:</h3>
|
||||||
setUserPreferences(userPreferences.filter(p => p !== category));
|
<div className="grid grid-cols-2 gap-2 mb-6">
|
||||||
}
|
{categories.filter(c => c !== 'For You').map((category) => (
|
||||||
}}
|
<label key={category} className="flex items-center space-x-2">
|
||||||
className="rounded border-gray-300 text-light-primary focus:ring-light-primary dark:border-gray-600 dark:bg-dark-secondary"
|
<input
|
||||||
/>
|
type="checkbox"
|
||||||
<span>{category}</span>
|
checked={tempPreferences.includes(category)}
|
||||||
</label>
|
onChange={(e) => {
|
||||||
))}
|
if (e.target.checked) {
|
||||||
</div>
|
setTempPreferences([...tempPreferences, category]);
|
||||||
|
} else {
|
||||||
<div className="flex justify-end space-x-2">
|
setTempPreferences(tempPreferences.filter(p => p !== category));
|
||||||
<button
|
}
|
||||||
className="px-4 py-2 rounded bg-gray-300 dark:bg-gray-700 hover:bg-gray-400 dark:hover:bg-gray-600 transition-colors"
|
}}
|
||||||
onClick={() => setShowPreferences(false)}
|
className="rounded border-gray-300 text-light-primary focus:ring-light-primary dark:border-gray-600 dark:bg-dark-secondary"
|
||||||
>
|
/>
|
||||||
Cancel
|
<span>{category}</span>
|
||||||
</button>
|
</label>
|
||||||
<button
|
))}
|
||||||
className="px-4 py-2 rounded bg-light-primary dark:bg-dark-primary text-white hover:bg-light-primary/80 hover:dark:bg-dark-primary/80 transition-colors"
|
</div>
|
||||||
onClick={() => {
|
|
||||||
saveUserPreferences(userPreferences);
|
<div className="mb-6">
|
||||||
setShowPreferences(false);
|
<h3 className="font-medium mb-2">Preferred Languages</h3>
|
||||||
setActiveCategory('For You'); // Switch to For You view to show personalized content
|
<div className="grid grid-cols-2 gap-2">
|
||||||
}}
|
{[
|
||||||
>
|
{ code: 'en', name: 'English' },
|
||||||
Save
|
{ code: 'ar', name: 'Arabic' },
|
||||||
</button>
|
{ code: 'zh', name: 'Chinese' },
|
||||||
|
{ code: 'fr', name: 'French' },
|
||||||
|
{ code: 'de', name: 'German' },
|
||||||
|
{ code: 'hi', name: 'Hindi' },
|
||||||
|
{ code: 'it', name: 'Italian' },
|
||||||
|
{ code: 'ja', name: 'Japanese' },
|
||||||
|
{ code: 'ko', name: 'Korean' },
|
||||||
|
{ code: 'pt', name: 'Portuguese' },
|
||||||
|
{ code: 'ru', name: 'Russian' },
|
||||||
|
{ code: 'es', name: 'Spanish' },
|
||||||
|
].map((language) => (
|
||||||
|
<label key={language.code} className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={tempLanguages.includes(language.code)}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
setTempLanguages([...tempLanguages, language.code]);
|
||||||
|
} else {
|
||||||
|
setTempLanguages(tempLanguages.filter(l => l !== language.code));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="rounded border-gray-300 text-light-primary focus:ring-light-primary dark:border-gray-600 dark:bg-dark-secondary"
|
||||||
|
/>
|
||||||
|
<span>{language.name}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500 mt-2">
|
||||||
|
{tempLanguages.length === 0
|
||||||
|
? "No languages selected will show results in all languages"
|
||||||
|
: `Selected: ${tempLanguages.length} language(s)`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-2">
|
||||||
|
<button
|
||||||
|
className="px-4 py-2 rounded bg-gray-300 dark:bg-gray-700 hover:bg-gray-400 dark:hover:bg-gray-600 transition-colors"
|
||||||
|
onClick={() => {
|
||||||
|
setShowPreferences(false);
|
||||||
|
// Reset temp preferences
|
||||||
|
setTempPreferences([]);
|
||||||
|
setTempLanguages([]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="px-4 py-2 rounded bg-light-primary dark:bg-dark-primary text-white hover:bg-light-primary/80 hover:dark:bg-dark-primary/80 transition-colors"
|
||||||
|
onClick={async () => {
|
||||||
|
await saveUserPreferences(tempPreferences, tempLanguages);
|
||||||
|
// Update the actual preferences after saving
|
||||||
|
setUserPreferences(tempPreferences);
|
||||||
|
setPreferredLanguages(tempLanguages);
|
||||||
|
setShowPreferences(false);
|
||||||
|
setActiveCategory('For You'); // Switch to For You view to show personalized content
|
||||||
|
|
||||||
|
// Reset temp preferences
|
||||||
|
setTempPreferences([]);
|
||||||
|
setTempLanguages([]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
)}
|
})()}
|
||||||
|
|
||||||
<div className="grid lg:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-4 pb-28 lg:pb-8 w-full justify-items-center lg:justify-items-start">
|
<div className="grid lg:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-4 pb-28 lg:pb-8 w-full justify-items-center lg:justify-items-start">
|
||||||
{discover &&
|
{discover &&
|
||||||
|
@ -11,3 +11,14 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.no-scrollbar {
|
||||||
|
-ms-overflow-style: none; /* IE and Edge */
|
||||||
|
scrollbar-width: none; /* Firefox */
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-scrollbar::-webkit-scrollbar {
|
||||||
|
display: none; /* Chrome, Safari, Opera */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Reference in New Issue
Block a user