Discover Section Improvements

Additonal Tweeks
This commit is contained in:
haddadrm
2025-02-25 20:22:48 +04:00
parent 92f6a9f7e1
commit 649bb4ea7e
5 changed files with 384 additions and 140 deletions

View File

@ -33,6 +33,9 @@ export const userPreferences = sqliteTable('user_preferences', {
categories: text('categories', { mode: 'json' })
.$type<string[]>()
.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`),
updatedAt: text('updated_at').notNull().default(sql`CURRENT_TIMESTAMP`),
});

View File

@ -1,14 +1,14 @@
import axios from 'axios';
import { getSearxngApiEndpoint } from '../config';
interface SearxngSearchOptions {
export interface SearxngSearchOptions {
categories?: string[];
engines?: string[];
language?: string;
pageno?: number;
}
interface SearxngSearchResult {
export interface SearxngSearchResult {
title: string;
url: string;
img_src?: string;

View File

@ -1,5 +1,5 @@
import express from 'express';
import { searchSearxng } from '../lib/searxng';
import { searchSearxng, SearxngSearchOptions } from '../lib/searxng';
import logger from '../utils/logger';
import db from '../db';
import { userPreferences } from '../db/schema';
@ -13,57 +13,86 @@ const getSearchQueriesForCategory = (category: string): { site: string, keyword:
'Technology': [
{ site: 'techcrunch.com', keyword: 'tech' },
{ 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': [
{ site: 'businessinsider.com', keyword: 'AI' },
{ site: 'www.exchangewire.com', keyword: 'AI' },
{ site: 'yahoo.com', keyword: 'AI' }
{ site: 'ai.googleblog.com', keyword: 'AI' },
{ site: 'openai.com/blog', keyword: 'AI' },
{ site: 'venturebeat.com', keyword: 'artificial intelligence' },
{ site: 'techcrunch.com', keyword: 'artificial intelligence' },
{ site: 'technologyreview.mit.edu', keyword: 'AI' }
],
'Sports': [
{ site: 'espn.com', keyword: 'sports' },
{ site: 'sports.yahoo.com', keyword: 'sports' },
{ site: 'cbssports.com', keyword: 'sports' },
{ site: 'si.com', keyword: 'sports' },
{ site: 'bleacherreport.com', keyword: 'sports' }
],
'Money': [
{ site: 'bloomberg.com', keyword: 'finance' },
{ 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': [
{ site: 'ign.com', keyword: 'games' },
{ 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': [
{ site: 'weather.com', keyword: 'forecast' },
{ 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': [
{ site: 'variety.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': [
{ site: 'scientificamerican.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': [
{ site: 'webmd.com', 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': [
{ site: 'travelandleisure.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': [
{ site: 'reuters.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
const searchCategory = async (category: string) => {
const searchCategory = async (category: string, languages?: string[]) => {
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'],
pageno: 1,
})
);
};
const results = await Promise.all(searchPromises);
return results.map(result => result.results).flat();
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();
}
// If languages specified, search each language and combine results
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) => {
try {
const category = req.query.category 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[] = [];
if (category && category !== 'For You') {
// Get news for a specific category
data = await searchCategory(category);
data = await searchCategory(category, languages);
} else if (preferencesParam) {
// Get news based on user preferences
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);
data = results.flat();
} 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 = (
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,
}),
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),
])
)
.map((result) => result.results)
.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
@ -155,10 +227,27 @@ router.get('/preferences', async (req, res) => {
if (userPrefs.length === 0) {
// 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) {
logger.error(`Error getting user preferences: ${err.message}`);
return res.status(500).json({ message: 'An error has occurred' });
@ -170,30 +259,48 @@ 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;
const { categories, languages } = req.body;
if (!categories || !Array.isArray(categories)) {
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));
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({
// Let's use a simpler approach - just use the drizzle ORM as intended
// but handle errors gracefully
try {
if (userPrefs.length === 0) {
// Create new preferences
await db.insert(userPreferences).values({
userId,
categories,
updatedAt: new Date().toISOString()
})
.where(eq(userPreferences.userId, userId));
languages: languages || ['en'],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
} else {
// Update existing preferences
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' });

View File

@ -1,7 +1,7 @@
'use client';
import { Search, Settings } from 'lucide-react';
import { useEffect, useState } from 'react';
import { Search, Sliders, ChevronLeft, ChevronRight } from 'lucide-react';
import { useEffect, useState, useRef } from 'react';
import Link from 'next/link';
import { toast } from 'sonner';
@ -15,8 +15,8 @@ interface Discover {
// List of available categories
const categories = [
'For You', 'AI', 'Technology', 'Current News', 'Sports',
'Money', 'Gaming', 'Weather', 'Entertainment', 'Science',
'Health', 'Travel'
'Money', 'Gaming', 'Weather', 'Entertainment', 'Art and Culture',
'Science', 'Health', 'Travel'
];
const Page = () => {
@ -25,6 +25,28 @@ const Page = () => {
const [activeCategory, setActiveCategory] = useState('For You');
const [showPreferences, setShowPreferences] = useState(false);
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
useEffect(() => {
@ -40,6 +62,7 @@ const Page = () => {
if (res.ok) {
const data = await res.json();
setUserPreferences(data.categories || ['AI', 'Technology']);
setPreferredLanguages(data.languages || ['en']); // Default to English if no languages are set
}
} catch (err: any) {
console.error('Error loading preferences:', err.message);
@ -51,14 +74,17 @@ const Page = () => {
}, []);
// Save user preferences
const saveUserPreferences = async (preferences: string[]) => {
const saveUserPreferences = async (preferences: string[], languages: 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 }),
body: JSON.stringify({
categories: preferences,
languages
}),
});
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(() => {
const fetchData = async () => {
setLoading(true);
try {
let endpoint = `${process.env.NEXT_PUBLIC_API_URL}/discover`;
let params = [];
if (activeCategory !== 'For You') {
endpoint += `?category=${encodeURIComponent(activeCategory)}`;
params.push(`category=${encodeURIComponent(activeCategory)}`);
} 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, {
@ -111,7 +146,7 @@ const Page = () => {
};
fetchData();
}, [activeCategory, userPreferences]);
}, [activeCategory, userPreferences, preferredLanguages]);
return loading ? (
<div className="flex flex-row items-center justify-center min-h-screen">
@ -146,78 +181,166 @@ const Page = () => {
onClick={() => setShowPreferences(true)}
aria-label="Personalize"
>
<Settings size={20} />
<Sliders size={20} />
</button>
</div>
{/* Category Navigation */}
<div className="flex overflow-x-auto space-x-2 py-4 no-scrollbar">
{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>
))}
{/* Category Navigation with Buttons */}
<div className="relative flex items-center py-4">
<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"
onClick={() => scrollCategories('left')}
aria-label="Scroll left"
>
<ChevronLeft size={20} />
</button>
<div
className="flex overflow-x-auto mx-8 no-scrollbar scroll-smooth"
ref={categoryContainerRef}
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>
<hr className="border-t border-[#2B2C2C] my-4 w-full" />
</div>
{/* Personalization Modal */}
{showPreferences && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-[#1E1E1E] p-6 rounded-lg w-full max-w-md">
<h2 className="text-xl font-bold mb-4">Personalize Your Feed</h2>
<p className="mb-4">Select categories you&apos;re interested in:</p>
{/* Initialize temp preferences when modal opens */}
{showPreferences && (() => {
// Initialize temp preferences when modal opens
if (tempPreferences.length === 0) {
setTempPreferences([...userPreferences]);
}
if (tempLanguages.length === 0) {
setTempLanguages([...preferredLanguages]);
}
<div className="grid grid-cols-2 gap-2 mb-4">
{categories.filter(c => c !== 'For You').map((category) => (
<label key={category} className="flex items-center space-x-2">
<input
type="checkbox"
checked={userPreferences.includes(category)}
onChange={(e) => {
if (e.target.checked) {
setUserPreferences([...userPreferences, category]);
} else {
setUserPreferences(userPreferences.filter(p => p !== category));
}
}}
className="rounded border-gray-300 text-light-primary focus:ring-light-primary dark:border-gray-600 dark:bg-dark-secondary"
/>
<span>{category}</span>
</label>
))}
</div>
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-[#1E1E1E] p-6 rounded-lg w-full max-w-md">
<h2 className="text-xl font-bold mb-4">Personalize Your Feed</h2>
<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)}
>
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={() => {
saveUserPreferences(userPreferences);
setShowPreferences(false);
setActiveCategory('For You'); // Switch to For You view to show personalized content
}}
>
Save
</button>
<h3 className="font-medium mb-2">Select categories you&apos;re interested in:</h3>
<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">
<input
type="checkbox"
checked={tempPreferences.includes(category)}
onChange={(e) => {
if (e.target.checked) {
setTempPreferences([...tempPreferences, category]);
} else {
setTempPreferences(tempPreferences.filter(p => p !== category));
}
}}
className="rounded border-gray-300 text-light-primary focus:ring-light-primary dark:border-gray-600 dark:bg-dark-secondary"
/>
<span>{category}</span>
</label>
))}
</div>
<div className="mb-6">
<h3 className="font-medium mb-2">Preferred Languages</h3>
<div className="grid grid-cols-2 gap-2">
{[
{ code: 'en', name: 'English' },
{ code: 'ar', name: 'Arabic' },
{ 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 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 &&

View File

@ -11,3 +11,14 @@
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 */
}
}