diff --git a/backend/app.js b/backend/app.js index 3d99f71..ed21b02 100644 --- a/backend/app.js +++ b/backend/app.js @@ -65,6 +65,8 @@ app.use('/api/profile', mutationLimiter, require('./routes/profile')); app.use('/api/notifications', require('./routes/notifications')); app.use('/api/discussion', mutationLimiter, require('./routes/discussion')); app.use('/api/dashboard', require('./routes/dashboard')); +app.use('/api/videos', mutationLimiter, require('./routes/videos')); +app.use('/api/feeds', require('./routes/feeds')); app.get('/health', (req, res) => { const { clientCount } = require('./lib/sseEmitter'); diff --git a/backend/config/constants.js b/backend/config/constants.js new file mode 100644 index 0000000..eeee3ec --- /dev/null +++ b/backend/config/constants.js @@ -0,0 +1,34 @@ +// config/constants.js + +// separation of citation CATEGORY (what the claim is) vs TOPIC (what the claim is about) + +const CATEGORIES = [ + 'Statistics & Data', + 'Quote / Misattribution', + 'Historical Claim', + 'Scientific Claim', + 'Context / Methodology', + 'Other', +]; + +const DEFAULT_CATEGORY = 'Uncategorized'; +const ALL_CATEGORIES = [...CATEGORIES, DEFAULT_CATEGORY]; + + +const TOPICS = [ + 'Science & Technology', + 'History', + 'Politics & News', + 'Education', + 'Health & Fitness', + 'Economics', + 'Philosophy', + 'Entertainment' +]; + +module.exports = { + CATEGORIES, + DEFAULT_CATEGORY, + ALL_CATEGORIES, + TOPICS +}; \ No newline at end of file diff --git a/backend/config/experts.js b/backend/config/experts.js index 3c20d80..94c8141 100644 --- a/backend/config/experts.js +++ b/backend/config/experts.js @@ -1,16 +1,38 @@ -// Hardcoded expert allowlist. Add usernames via the EXPERT_USERNAMES env var -// (comma-separated) for local/deployment configuration without code changes. -const DEFAULT_EXPERTS = []; - -const EXPERT_USERNAMES = new Set([ - ...DEFAULT_EXPERTS, - ...(process.env.EXPERT_USERNAMES - ? process.env.EXPERT_USERNAMES.split(',').map(s => s.trim()).filter(Boolean) - : []), -]); - -function isExpert(username) { - return typeof username === 'string' && EXPERT_USERNAMES.has(username); +// config/experts.js + +// Hardcoded expert domain mapping. +// Instead of a simple array, we map usernames to their specialized Topics. +const DEFAULT_EXPERTS = { +}; + + +const expertRegistry = { ...DEFAULT_EXPERTS }; + +if (process.env.EXPERT_CONFIG) { + const entries = process.env.EXPERT_CONFIG.split('|'); + entries.forEach(entry => { + const [username, topicsStr] = entry.split(':'); + if (username && topicsStr) { + expertRegistry[username.trim()] = topicsStr.split(',').map(t => t.trim()); + } + }); +} + + +function isExpert(username, topic = null) { + if (typeof username !== 'string' || !expertRegistry[username]) { + return false; + } + + if (topic) { + return expertRegistry[username].includes(topic); + } + + return true; +} + +function getExpertTopics(username) { + return expertRegistry[username] || []; } -module.exports = { EXPERT_USERNAMES, isExpert }; +module.exports = { isExpert, getExpertTopics, expertRegistry }; \ No newline at end of file diff --git a/backend/lib/notifyExperts.js b/backend/lib/notifyExperts.js index 7783aa0..89ab2a9 100644 --- a/backend/lib/notifyExperts.js +++ b/backend/lib/notifyExperts.js @@ -2,9 +2,16 @@ const Expert = require('../models/Expert'); const UserProfile = require('../models/UserProfile'); const Notification = require('../models/Notification'); +// NOTE: Expert.topics is a subject-matter taxonomy (e.g. "History"), while `category` +// here is a claim-type taxonomy (e.g. "Quote / Misattribution") — they're different +// dimensions (see backend/config/constants.js). Citations/requests aren't currently +// run through the topic-classification pipeline (only backend/routes/videos.js is), +// so this match against Expert.topics will find no experts until that's wired up. +// Kept as `category` (not silently dropped) so this starts working the moment +// citation/request topic classification lands, without another schema change here. async function notifyExpertsForCategory(category, { videoId, itemId, itemType, title, excludeUsername }) { try { - const experts = await Expert.find({ categories: category }).lean(); + const experts = await Expert.find({ topics: category }).lean(); const notifications = []; for (const expert of experts) { @@ -12,7 +19,7 @@ async function notifyExpertsForCategory(category, { videoId, itemId, itemType, t // Check if expert has muted this category const profile = await UserProfile.findOne({ username: expert.username }).lean(); - const muted = profile?.mutedCategories || []; + const muted = profile?.mutedTopics || []; if (muted.includes(category)) continue; notifications.push({ diff --git a/backend/models/Citation.js b/backend/models/Citation.js index fcc4817..d03a9ac 100644 --- a/backend/models/Citation.js +++ b/backend/models/Citation.js @@ -15,6 +15,7 @@ const citationSchema = new mongoose.Schema({ parentCitationId: { type: String, default: null }, category: { type: String, enum: ALL_CATEGORIES, default: DEFAULT_CATEGORY }, categoryVerified: { type: Boolean, default: false }, + topics: { type: [String], default: [] }, verifiedBy: { type: String, default: null }, verifiedAt: { type: Date, default: null }, }); @@ -37,6 +38,9 @@ citationSchema.index({ videoId: 1, username: 1 }); // Category filter/aggregation index for the panel filter and dashboard. citationSchema.index({ videoId: 1, category: 1 }); +// Filter within given topic +citationSchema.index({ topics: 1, verifiedBy: 1 }); + // Thread index: speeds up nested reply queries. citationSchema.index({ parentCitationId: 1, dateAdded: 1 }); diff --git a/backend/models/Expert.js b/backend/models/Expert.js index 8c0bfc6..8afb86f 100644 --- a/backend/models/Expert.js +++ b/backend/models/Expert.js @@ -1,14 +1,14 @@ const mongoose = require('mongoose'); -const { ALL_CATEGORIES } = require('../config/categories'); +const { TOPICS } = require('../config/constants'); const expertSchema = new mongoose.Schema({ username: { type: String, required: true, unique: true }, - categories: [{ type: String, enum: ALL_CATEGORIES }], + topics: [{ type: String, enum: TOPICS }], // Uses TOPICS from constants grantedAt: { type: Date, default: Date.now }, grantedBy: { type: String, default: null }, }); // username's index is already created by `unique: true` above. -expertSchema.index({ categories: 1 }); +expertSchema.index({ topics: 1 }); module.exports = mongoose.model('Expert', expertSchema); diff --git a/backend/models/ExpertApplication.js b/backend/models/ExpertApplication.js index 0d9a2bb..3339862 100644 --- a/backend/models/ExpertApplication.js +++ b/backend/models/ExpertApplication.js @@ -1,9 +1,9 @@ const mongoose = require('mongoose'); -const { ALL_CATEGORIES } = require('../config/categories'); +const { TOPICS } = require('../config/constants'); const expertApplicationSchema = new mongoose.Schema({ username: { type: String, required: true }, - category: { type: String, required: true, enum: ALL_CATEGORIES }, + topics: [{ type: String, enum: TOPICS, required: true }], credentials: { type: String, required: true }, status: { type: String, enum: ['pending', 'approved', 'rejected'], default: 'pending' }, reason: { type: String, default: null }, diff --git a/backend/models/Notification.js b/backend/models/Notification.js index 0f100db..c6a32ff 100644 --- a/backend/models/Notification.js +++ b/backend/models/Notification.js @@ -4,6 +4,7 @@ const notificationSchema = new mongoose.Schema({ username: { type: String, required: true }, type: { type: String, enum: ['new_citation', 'new_request', 'application_approved', 'application_rejected'], required: true }, category: { type: String, default: null }, + topic: { type: String, default: null }, videoId: { type: String, default: null }, itemId: { type: String, default: null }, itemType: { type: String, enum: ['citation', 'request', null], default: null }, diff --git a/backend/models/Request.js b/backend/models/Request.js index 0a375a2..c32e86e 100644 --- a/backend/models/Request.js +++ b/backend/models/Request.js @@ -10,12 +10,15 @@ const requestSchema = new mongoose.Schema({ username: { type: String, required: true }, dateAdded: { type: Date, default: Date.now }, voteScore: { type: Number, default: 0 }, + category: { type: String, enum: ALL_CATEGORIES, default: DEFAULT_CATEGORY }, categoryVerified: { type: Boolean, default: false }, + + topics: { type: [String], default: [] }, + verifiedBy: { type: String, default: null }, verifiedAt: { type: Date, default: null }, }); - // ── Indexes ─────────────────────────────────── // Primary sort index: list queries filter by videoId then sort by date. @@ -30,4 +33,7 @@ requestSchema.index({ videoId: 1, username: 1 }); // Category filter/aggregation index for the panel filter and dashboard. requestSchema.index({ videoId: 1, category: 1 }); +// Filter within given topic +requestSchema.index({ topics: 1, verifiedBy: 1 }); + module.exports = mongoose.model('Request', requestSchema); diff --git a/backend/models/UserProfile.js b/backend/models/UserProfile.js index 7337db0..4bd2157 100644 --- a/backend/models/UserProfile.js +++ b/backend/models/UserProfile.js @@ -4,8 +4,8 @@ const userProfileSchema = new mongoose.Schema({ username: { type: String, required: true, unique: true }, displayName: { type: String, default: '' }, bio: { type: String, default: '', maxlength: 500 }, - followedCategories: [{ type: String }], - mutedCategories: [{ type: String }], + followedTopics: [{ type: String }], + mutedTopics: [{ type: String }], createdAt: { type: Date, default: Date.now }, }); diff --git a/backend/models/Video.js b/backend/models/Video.js new file mode 100644 index 0000000..c7d87e3 --- /dev/null +++ b/backend/models/Video.js @@ -0,0 +1,32 @@ +const mongoose = require('mongoose'); + +const videoSchema = new mongoose.Schema({ + videoId: { + type: String, + required: true, + unique: true + }, + title: { + type: String, + required: true + }, + thumbnailUrl: { + type: String, + default: '' + }, + channelName: { + type: String, + default: '' + }, + youtubeTopics: [{ + type: String + }], + lastUpdated: { + type: Date, + default: Date.now + } +}); + +// videoId's index is already created by `unique: true` above. + +module.exports = mongoose.model('Video', videoSchema); \ No newline at end of file diff --git a/backend/routes/citations.js b/backend/routes/citations.js index 54dc94f..6b2d4d4 100644 --- a/backend/routes/citations.js +++ b/backend/routes/citations.js @@ -1,7 +1,7 @@ const router = require('express').Router(); const Citation = require('../models/Citation'); const sseEmitter = require('../lib/sseEmitter'); -const { CATEGORIES, ALL_CATEGORIES, DEFAULT_CATEGORY } = require('../config/categories'); +const { ALL_CATEGORIES, DEFAULT_CATEGORY, TOPICS } = require('../config/constants'); const { isExpert } = require('../config/experts'); const { notifyExpertsForCategory } = require('../lib/notifyExperts'); const { applyVote } = require('../lib/voting'); @@ -231,7 +231,7 @@ router.patch('/:videoId/:id/category', async (req, res) => { if (!category || !username) { return res.status(400).json({ success: false, error: 'category and username are required' }); } - if (!CATEGORIES.includes(category)) { + if (!ALL_CATEGORIES.includes(category)) { return res.status(400).json({ success: false, error: 'Invalid category' }); } diff --git a/backend/routes/experts.js b/backend/routes/experts.js index 052999b..89716ef 100644 --- a/backend/routes/experts.js +++ b/backend/routes/experts.js @@ -3,20 +3,20 @@ const Expert = require('../models/Expert'); const ExpertApplication = require('../models/ExpertApplication'); const { isExpert: isHardcodedExpert } = require('../config/experts'); const { isAdmin } = require('../config/admins'); -const { ALL_CATEGORIES } = require('../config/categories'); +const { TOPICS } = require('../config/constants'); // GET /api/experts/:username — check if user is an expert router.get('/:username', async (req, res) => { try { if (isHardcodedExpert(req.params.username)) { - return res.json({ success: true, isExpert: true, categories: ALL_CATEGORIES }); + return res.json({ success: true, isExpert: true, topics: TOPICS }); } const expert = await Expert.findOne({ username: req.params.username }).lean(); res.set('Cache-Control', 'no-store'); res.json({ success: true, isExpert: !!expert, - categories: expert ? expert.categories : [], + topics: expert ? expert.topics : [], }); } catch (err) { res.status(500).json({ success: false, error: err.message }); @@ -26,22 +26,22 @@ router.get('/:username', async (req, res) => { // POST /api/experts/apply — submit expert application router.post('/apply', async (req, res) => { try { - const { username, category, credentials } = req.body; - if (!username || !category || !credentials) { - return res.status(400).json({ success: false, error: 'username, category, and credentials are required' }); + const { username, topics, credentials } = req.body; + if (!username || !topics || !Array.isArray(topics) || topics.length === 0 || !credentials) { + return res.status(400).json({ success: false, error: 'username, topics (non-empty array), and credentials are required' }); } - if (!ALL_CATEGORIES.includes(category)) { - return res.status(400).json({ success: false, error: 'Invalid category' }); + if (!topics.every(t => TOPICS.includes(t))) { + return res.status(400).json({ success: false, error: 'Invalid topic' }); } const existing = await ExpertApplication.findOne({ - username, category, status: 'pending', + username, status: 'pending', }); if (existing) { - return res.status(409).json({ success: false, error: 'You already have a pending application for this category' }); + return res.status(409).json({ success: false, error: 'You already have a pending application' }); } - const app = await ExpertApplication.create({ username, category, credentials }); + const app = await ExpertApplication.create({ username, topics, credentials }); res.status(201).json({ success: true, id: app._id }); } catch (err) { res.status(500).json({ success: false, error: err.message }); @@ -103,7 +103,7 @@ router.patch('/applications/:id/review', async (req, res) => { if (status === 'approved') { await Expert.findOneAndUpdate( { username: app.username }, - { $addToSet: { categories: app.category }, $setOnInsert: { grantedAt: new Date(), grantedBy: reviewedBy } }, + { $addToSet: { topics: { $each: app.topics } }, $setOnInsert: { grantedAt: new Date(), grantedBy: reviewedBy } }, { upsert: true } ); } diff --git a/backend/routes/feeds.js b/backend/routes/feeds.js new file mode 100644 index 0000000..9546379 --- /dev/null +++ b/backend/routes/feeds.js @@ -0,0 +1,92 @@ +const router = require('express').Router(); +const Request = require('../models/Request'); +const Expert = require('../models/Expert'); + +// ── GET /api/feeds/general ─────────────────── +router.get('/general', async (req, res) => { + try { + const { topic, page = 1, limit = 20 } = req.query; + const skip = (parseInt(page) - 1) * parseInt(limit); + + const pipeline = [ + { + $lookup: { + from: 'videos', + localField: 'videoId', + foreignField: 'videoId', + as: 'videoDoc' + } + }, + { $unwind: { path: '$videoDoc', preserveNullAndEmptyArrays: true } }, + + { + $addFields: { + video: { + title: '$videoDoc.title', + thumbnailUrl: '$videoDoc.thumbnailUrl', + videoId: '$videoId' + }, + topics: { $ifNull: ['$videoDoc.youtubeTopics', []] } + } + } + ]; + + if (topic && topic !== 'All') { + pipeline.push({ $match: { topics: topic } }); + } + + pipeline.push({ $sort: { voteScore: -1, dateAdded: -1 } }); + pipeline.push({ $skip: skip }, { $limit: parseInt(limit) }); + + const feed = await Request.aggregate(pipeline); + + res.json({ success: true, data: feed }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +// ── GET /api/feeds/expert ──────────────────── +router.get('/expert', async (req, res) => { + try { + const { username } = req.query; + if (!username) return res.status(400).json({ success: false, error: 'Username required' }); + + const expert = await Expert.findOne({ username }); + if (!expert || !expert.topics || expert.topics.length === 0) { + return res.json({ success: true, data: [] }); + } + + const pipeline = [ + { + $lookup: { + from: 'videos', + localField: 'videoId', + foreignField: 'videoId', + as: 'videoDoc' + } + }, + { $unwind: { path: '$videoDoc', preserveNullAndEmptyArrays: true } }, + { + $addFields: { + video: { + title: '$videoDoc.title', + thumbnailUrl: '$videoDoc.thumbnailUrl', + videoId: '$videoId' + }, + topics: { $ifNull: ['$videoDoc.youtubeTopics', []] } + } + }, + { $match: { topics: { $in: expert.topics } } }, + { $sort: { voteScore: -1, dateAdded: -1 } }, + { $limit: 50 } + ]; + + const feed = await Request.aggregate(pipeline); + res.json({ success: true, data: feed }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/backend/routes/profile.js b/backend/routes/profile.js index d3ae90e..4ab39bc 100644 --- a/backend/routes/profile.js +++ b/backend/routes/profile.js @@ -3,10 +3,10 @@ const UserProfile = require('../models/UserProfile'); const Citation = require('../models/Citation'); const Request = require('../models/Request'); const Expert = require('../models/Expert'); -const { ALL_CATEGORIES } = require('../config/categories'); +const { TOPICS } = require('../config/constants'); const MAX_DISPLAY_NAME_LEN = 100; -const MAX_FOLLOWED_CATEGORIES = ALL_CATEGORIES.length; +const MAX_FOLLOWED_TOPICS = TOPICS.length; // Case-insensitive, '@'-prefix-tolerant username match — same convention used // for ownership checks elsewhere in the backend. @@ -36,9 +36,9 @@ router.get('/:username', async (req, res) => { res.set('Cache-Control', 'no-store'); res.json({ success: true, - profile: profile || { username, displayName: '', bio: '', followedCategories: [] }, + profile: profile || { username, displayName: '', bio: '', followedTopics: [] }, stats: { citations: citationCount, requests: requestCount, upvotes }, - expert: expert ? { categories: expert.categories } : null, + expert: expert ? { topics: expert.topics } : null, }); } catch (err) { res.status(500).json({ success: false, error: err.message }); @@ -52,21 +52,21 @@ router.put('/:username', async (req, res) => { return res.status(403).json({ success: false, error: 'You can only edit your own profile' }); } - const { displayName, bio, followedCategories } = req.body; + const { displayName, bio, followedTopics } = req.body; if (displayName !== undefined && (typeof displayName !== 'string' || displayName.length > MAX_DISPLAY_NAME_LEN)) { return res.status(400).json({ success: false, error: `displayName must be at most ${MAX_DISPLAY_NAME_LEN} characters` }); } - if (followedCategories !== undefined) { - if (!Array.isArray(followedCategories) || followedCategories.length > MAX_FOLLOWED_CATEGORIES - || !followedCategories.every(c => ALL_CATEGORIES.includes(c))) { - return res.status(400).json({ success: false, error: 'followedCategories must be an array of valid category names' }); + if (followedTopics !== undefined) { + if (!Array.isArray(followedTopics) || followedTopics.length > MAX_FOLLOWED_TOPICS + || !followedTopics.every(t => TOPICS.includes(t))) { + return res.status(400).json({ success: false, error: 'followedTopics must be an array of valid topic names' }); } } const profile = await UserProfile.findOneAndUpdate( { username: req.params.username }, - { displayName, bio, followedCategories }, + { displayName, bio, followedTopics }, { upsert: true, new: true } ); diff --git a/backend/routes/requests.js b/backend/routes/requests.js index c552cae..0ef0931 100644 --- a/backend/routes/requests.js +++ b/backend/routes/requests.js @@ -1,7 +1,7 @@ const router = require('express').Router(); const Request = require('../models/Request'); const sseEmitter = require('../lib/sseEmitter'); -const { CATEGORIES, ALL_CATEGORIES, DEFAULT_CATEGORY } = require('../config/categories'); +const { ALL_CATEGORIES, DEFAULT_CATEGORY, TOPICS } = require('../config/constants'); const { isExpert } = require('../config/experts'); const { notifyExpertsForCategory } = require('../lib/notifyExperts'); const { applyVote } = require('../lib/voting'); @@ -251,7 +251,7 @@ router.patch('/:videoId/:id/category', async (req, res) => { if (!category || !username) { return res.status(400).json({ success: false, error: 'category and username are required' }); } - if (!CATEGORIES.includes(category)) { + if (!ALL_CATEGORIES.includes(category)) { return res.status(400).json({ success: false, error: 'Invalid category' }); } diff --git a/backend/routes/videos.js b/backend/routes/videos.js new file mode 100644 index 0000000..7684c53 --- /dev/null +++ b/backend/routes/videos.js @@ -0,0 +1,39 @@ +const express = require('express'); +const router = express.Router(); +const Video = require('../models/Video'); +const { mapRawTagsToTopics } = require('../utils/topicMapper'); + +// ── POST /api/videos/upsert ───────────────────────────── +router.post('/upsert', async (req, res) => { + try { + const { videoId, title, channelName, thumbnailUrl, rawTags } = req.body; + + if (!videoId || !title) { + return res.status(400).json({ success: false, error: 'videoId and title are required' }); + } + + const cleanTopics = mapRawTagsToTopics(rawTags); + + const video = await Video.findOneAndUpdate( + { videoId }, + { + $set: { + title, + channelName: channelName || '', + thumbnailUrl: thumbnailUrl || `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`, + youtubeTopics: cleanTopics, + lastUpdated: Date.now() + } + }, + { new: true, upsert: true } + ); + + res.json({ success: true, data: video }); + + } catch (error) { + console.error('Video upsert error:', error); + res.status(500).json({ success: false, error: 'Server error during video upsert' }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/backend/tests/experts.test.js b/backend/tests/experts.test.js index a9f9bea..951c72f 100644 --- a/backend/tests/experts.test.js +++ b/backend/tests/experts.test.js @@ -30,7 +30,7 @@ describe('GET /api/experts/applications/pending', () => { it('allows requests from an allowlisted admin', async () => { await request(app).post('/api/experts/apply').send({ - username: 'alice', category: 'Historical Claim', credentials: 'I have a degree', + username: 'alice', topics: ['History'], credentials: 'I have a degree', }); const res = await request(app).get('/api/experts/applications/pending?adminUsername=admin_carol'); @@ -43,7 +43,7 @@ describe('GET /api/experts/applications/pending', () => { describe('PATCH /api/experts/applications/:id/review', () => { async function submitApplication(overrides = {}) { const res = await request(app).post('/api/experts/apply').send({ - username: 'alice', category: 'Historical Claim', credentials: 'I have a degree', + username: 'alice', topics: ['History'], credentials: 'I have a degree', ...overrides, }); return res.body.id; @@ -78,6 +78,6 @@ describe('PATCH /api/experts/applications/:id/review', () => { const check = await request(app).get('/api/experts/alice'); expect(check.body.isExpert).toBe(true); - expect(check.body.categories).toContain('Historical Claim'); + expect(check.body.topics).toContain('History'); }); }); diff --git a/backend/tests/profile.test.js b/backend/tests/profile.test.js index 8f5ab0c..29793be 100644 --- a/backend/tests/profile.test.js +++ b/backend/tests/profile.test.js @@ -39,21 +39,21 @@ describe('PUT /api/profile/:username', () => { expect(profile.displayName).toBe('Alice A.'); }); - it('rejects an invalid category in followedCategories', async () => { + it('rejects an invalid topic in followedTopics', async () => { const res = await request(app) .put('/api/profile/alice') - .send({ requesterUsername: 'alice', followedCategories: ['Not A Real Category'] }); + .send({ requesterUsername: 'alice', followedTopics: ['Not A Real Topic'] }); expect(res.status).toBe(400); }); - it('accepts valid categories in followedCategories', async () => { + it('accepts valid topics in followedTopics', async () => { const res = await request(app) .put('/api/profile/alice') - .send({ requesterUsername: 'alice', followedCategories: ['Historical Claim'] }); + .send({ requesterUsername: 'alice', followedTopics: ['History'] }); expect(res.status).toBe(200); - expect(res.body.profile.followedCategories).toEqual(['Historical Claim']); + expect(res.body.profile.followedTopics).toEqual(['History']); }); }); diff --git a/backend/tests/setup.js b/backend/tests/setup.js index c074c63..8c51f49 100644 --- a/backend/tests/setup.js +++ b/backend/tests/setup.js @@ -1,7 +1,10 @@ const mongoose = require('mongoose'); // Fixture expert account used by category-verification tests. -process.env.EXPERT_USERNAMES = 'expert_bob'; +// config/experts.js reads EXPERT_CONFIG ("username:topic1,topic2|..."), not +// the old EXPERT_USERNAMES — isExpert(username) with no topic arg just needs +// the registry entry to exist, so any topic value here is sufficient. +process.env.EXPERT_CONFIG = 'expert_bob:History'; // Fixture admin account used by expert-application review tests. process.env.ADMIN_USERNAMES = 'admin_carol'; diff --git a/backend/utils/topicMapper.js b/backend/utils/topicMapper.js new file mode 100644 index 0000000..9fa636b --- /dev/null +++ b/backend/utils/topicMapper.js @@ -0,0 +1,45 @@ +const { TOPICS } = require('../config/constants'); + +// mapping raw YouTube keywords/genres to our official TOPICS +const KEYWORD_MAP = { + 'science': 'Science & Technology', + 'physics': 'Science & Technology', + 'technology': 'Science & Technology', + 'programming': 'Science & Technology', + 'history': 'History', + 'politics': 'Politics & News', + 'news': 'Politics & News', + 'education': 'Education', + 'fitness': 'Health & Fitness', + 'health': 'Health & Fitness', + 'workout': 'Health & Fitness', + 'economics': 'Economics', + 'finance': 'Economics', + 'philosophy': 'Philosophy', + 'entertainment': 'Entertainment', + 'gaming': 'Entertainment', + 'video games': 'Entertainment' +}; + +function mapRawTagsToTopics(rawTags = []) { + const matchedTopics = new Set(); + + rawTags.forEach(tag => { + const normalizedTag = tag.toLowerCase().trim(); + + if (KEYWORD_MAP[normalizedTag]) { + matchedTopics.add(KEYWORD_MAP[normalizedTag]); + return; + } + + for (const [key, topic] of Object.entries(KEYWORD_MAP)) { + if (normalizedTag.includes(key)) { + matchedTopics.add(topic); + } + } + }); + + return Array.from(matchedTopics); +} + +module.exports = { mapRawTagsToTopics }; \ No newline at end of file diff --git a/background/background.js b/background/background.js index bfbf743..09255c5 100644 --- a/background/background.js +++ b/background/background.js @@ -185,7 +185,7 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { return true; } if (request.type === 'applyExpert') { - handleApplyExpert(request.username, request.category, request.credentials).then(sendResponse); + handleApplyExpert(request.username, request.topics, request.credentials).then(sendResponse); return true; } if (request.type === 'getMyApplications') { @@ -193,7 +193,7 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { return true; } if (request.type === 'getPendingApplications') { - handleGetPendingApplications().then(sendResponse); + handleGetPendingApplications(request.adminUsername).then(sendResponse); return true; } if (request.type === 'reviewApplication') { @@ -240,6 +240,18 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { handleGetProfileHistory(request.username, request.page).then(sendResponse); return true; } + if (request.type === 'upsertVideo') { + handleUpsertVideo(request.data).then(sendResponse); + return true; + } + if (request.type === 'getExpertFeed') { + handleGetExpertFeed(request.username).then(sendResponse); + return true; + } + if (request.type === 'getGeneralFeed') { + handleGetGeneralFeed(request.topic, request.page, request.limit).then(sendResponse); + return true; + } }); async function handleGetCitations(videoId, page = 1, limit = 20) { @@ -431,7 +443,11 @@ async function handleUpdateCategory(itemType, videoId, itemId, category, usernam async function handleCheckExpert(username) { try { const result = await apiRequest(`/experts/${encodeURIComponent(username)}`); - return { success: true, isExpert: result.isExpert }; + return { + success: true, + isExpert: result.isExpert, + topics: result.topics || [], + }; } catch (error) { return { success: false, error: error.message }; } @@ -468,9 +484,9 @@ async function handleReportItem(data) { } } -async function handleApplyExpert(username, category, credentials) { +async function handleApplyExpert(username, topics, credentials) { try { - const result = await apiRequest('/experts/apply', 'POST', { username, category, credentials }); + const result = await apiRequest('/experts/apply', 'POST', { username, topics, credentials }); return { success: true, id: result.id }; } catch (error) { return { success: false, error: error.message }; @@ -486,9 +502,9 @@ async function handleGetMyApplications(username) { } } -async function handleGetPendingApplications() { +async function handleGetPendingApplications(adminUsername) { try { - const data = await apiRequest('/experts/applications/pending'); + const data = await apiRequest(`/experts/applications/pending?adminUsername=${encodeURIComponent(adminUsername || '')}`); return { success: true, applications: data.applications || [] }; } catch (error) { return { success: false, error: error.message }; @@ -597,4 +613,34 @@ async function handleAddQuickReply({ parentCitationId, description, videoId, use } catch (error) { return { success: false, error: error.message }; } -} \ No newline at end of file +} + +// ── Feeds & Metadata Handlers ────────────────── + +async function handleUpsertVideo(data) { + try { + const result = await apiRequest('/videos/upsert', 'POST', data); + return { success: true, data: result.data }; + } catch (error) { + return { success: false, error: error.message }; + } +} + +async function handleGetExpertFeed(username) { + try { + const result = await apiRequest(`/feeds/expert?username=${encodeURIComponent(username)}`); + return { success: true, data: result.data }; + } catch (error) { + return { success: false, error: error.message }; + } +} + +async function handleGetGeneralFeed(topic, page = 1, limit = 20) { + try { + const query = new URLSearchParams({ topic: topic || 'All', page, limit }).toString(); + const result = await apiRequest(`/feeds/general?${query}`); + return { success: true, data: result.data, pagination: result.pagination }; + } catch (error) { + return { success: false, error: error.message }; + } +} diff --git a/config/config.js b/config/config.js index 76f07c4..87c80a0 100644 --- a/config/config.js +++ b/config/config.js @@ -1,6 +1,8 @@ +// config.js + var API_BASE_URL = "http://localhost:3000/api"; -// Shared category taxonomy. Keep in sync with `backend/config/categories.js`. + var CATEGORIES = [ 'Statistics & Data', 'Quote / Misattribution', @@ -11,3 +13,16 @@ var CATEGORIES = [ ]; var DEFAULT_CATEGORY = 'Uncategorized'; +var ALL_CATEGORIES = [...CATEGORIES, DEFAULT_CATEGORY]; + + +var TOPICS = [ + 'Science & Technology', + 'History', + 'Politics & News', + 'Education', + 'Health & Fitness', + 'Economics', + 'Philosophy', + 'Entertainment' +]; \ No newline at end of file diff --git a/content/api.js b/content/api.js index c7a61b6..3da43d5 100644 --- a/content/api.js +++ b/content/api.js @@ -170,11 +170,14 @@ async function apiUpdateCategory(itemId, itemType, videoId, category, username) async function apiCheckExpert(username) { const res = await _send({ type: 'checkExpert', username }); - return !!res.isExpert; + return { + isExpert: !!res.isExpert, + topics: res.topics || [], + }; } -async function apiApplyExpert(username, category, credentials) { - return _send({ type: 'applyExpert', username, category, credentials }); +async function apiApplyExpert(username, topics, credentials) { + return _send({ type: 'applyExpert', username, topics, credentials }); } async function apiGetMyApplications(username) { @@ -182,8 +185,8 @@ async function apiGetMyApplications(username) { return res.applications || []; } -async function apiGetPendingApplications() { - const res = await _send({ type: 'getPendingApplications' }); +async function apiGetPendingApplications(adminUsername) { + const res = await _send({ type: 'getPendingApplications', adminUsername }); return res.applications || []; } @@ -228,4 +231,38 @@ async function apiGetDashboardStats(videoId) { citationsByCategory: res.citationsByCategory || [], verificationStats: res.verificationStats || { citations: [], requests: [] }, }; -} \ No newline at end of file +} + +// ── Feeds & Video Metadata ─────────────────── + +/** + * Sends scraped YouTube metadata to the backend for caching/upserting. + * @param {Object} metadata - { videoId, title, channelName, rawTags } + */ +async function apiUpsertVideo(metadata) { + return _send({ type: 'upsertVideo', data: metadata }); +} + +/** + * Fetches the personalized feed for an expert based on their assigned Topics. + * @param {string} username + */ +async function apiGetExpertFeed(username) { + const res = await _send({ type: 'getExpertFeed', username }); + return res.data || []; +} + +/** + * Fetches the general browsable feed, optionally filtered by a video Topic + * (backend/routes/feeds.js matches against the linked video's youtubeTopics). + * @param {string} topic + * @param {number} page + * @param {number} limit + */ +async function apiGetGeneralFeed(topic = 'All', page = 1, limit = 20) { + const res = await _send({ type: 'getGeneralFeed', topic, page, limit }); + return { + feed: res.data || [], + pagination: res.pagination || null, + }; +} diff --git a/content/player.js b/content/player.js index 0f863c2..d8d24fa 100644 --- a/content/player.js +++ b/content/player.js @@ -59,6 +59,8 @@ function setupTimeTracking() { function setupVideoChangeTracking() { _currentVideoId = getCurrentVideoId(); + + syncVideoMetadata(); window.addEventListener('yt-navigate-start', () => { console.log('[player] YouTube navigation started'); @@ -72,6 +74,8 @@ function setupVideoChangeTracking() { _currentVideoId = newVideoId; currentTime = 0; + syncVideoMetadata(); + setTimeout(() => { const newVideo = document.querySelector('video'); if (newVideo) { @@ -94,3 +98,53 @@ function seekToTime(seconds) { const video = document.querySelector('video'); if (video) video.currentTime = seconds; } + +// ── Metadata & Topic Extraction ─────────────── + +async function syncVideoMetadata() { + const videoId = getCurrentVideoId(); + if (!videoId) return; + + try { + + const titleEl = document.querySelector('meta[name="title"]'); + const title = titleEl ? titleEl.content : document.title.replace(' - YouTube', ''); + + const channelEl = document.querySelector('link[itemprop="name"]'); + const channelName = channelEl ? channelEl.getAttribute('content') : ''; + + let rawTags = []; + + const keywordMeta = document.querySelector('meta[name="keywords"]'); + if (keywordMeta && keywordMeta.content) { + rawTags = keywordMeta.content.split(',').map(s => s.trim()); + } + + const schemaScript = document.querySelector('script[type="application/ld+json"]'); + if (schemaScript) { + try { + const data = JSON.parse(schemaScript.textContent); + if (data.genre) { + if (Array.isArray(data.genre)) { + rawTags.push(...data.genre); + } else { + rawTags.push(data.genre); + } + } + } catch (e) { + console.warn('[metadata] Could not parse JSON-LD'); + } + } + + await apiUpsertVideo({ + videoId, + title, + channelName, + rawTags + }); + + console.log(`[metadata] Synced metadata for video: ${videoId}`); + } catch (err) { + console.error('[metadata] Failed to sync video metadata:', err); + } +} \ No newline at end of file diff --git a/content/username.js b/content/username.js index 78c10c8..3be75da 100644 --- a/content/username.js +++ b/content/username.js @@ -34,6 +34,7 @@ async function getYouTubeUsername() { } // 4. Passive wait — observe the DOM until YouTube hydrates the handle. + // No clicks, no side effects. Gives up after 10s. const observed = await _waitForHandleInDOM(10000); if (observed) { _cacheUsername(observed); diff --git a/content/youtubeScraper.js b/content/youtubeScraper.js new file mode 100644 index 0000000..5c07553 --- /dev/null +++ b/content/youtubeScraper.js @@ -0,0 +1,65 @@ +// scrapes the current YouTube DOM for video metadata and tags +function extractVideoMetadata() { + const urlParams = new URLSearchParams(window.location.search); + const videoId = urlParams.get('v'); + + if (!videoId) return null; + + const titleEl = document.querySelector('meta[name="title"]'); + const title = titleEl ? titleEl.content : document.title.replace(' - YouTube', ''); + + const channelEl = document.querySelector('link[itemprop="name"]'); + const channelName = channelEl ? channelEl.getAttribute('content') : ''; + + let rawTags = []; + + // strategy A: standard meta keywords + const keywordMeta = document.querySelector('meta[name="keywords"]'); + if (keywordMeta && keywordMeta.content) { + rawTags = keywordMeta.content.split(',').map(s => s.trim()); + } + + // strategy B: fallback to JSON-LD schema + const schemaScript = document.querySelector('script[type="application/ld+json"]'); + if (schemaScript) { + try { + const data = JSON.parse(schemaScript.textContent); + if (data.genre) { + // Genre can be a string or array + if (Array.isArray(data.genre)) { + rawTags.push(...data.genre); + } else { + rawTags.push(data.genre); + } + } + } catch (e) { + console.warn('[Citepoint] Failed to parse YouTube JSON-LD'); + } + } + + return { + videoId, + title, + channelName, + rawTags + }; +} + +// fire extracted data to backend +async function syncVideoWithBackend() { + const metadata = extractVideoMetadata(); + if (!metadata) return; + + try { + const response = await fetch('http://localhost:3000/api/videos/upsert', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(metadata) + }); + + const result = await response.json(); + console.log('[Citepoint] Video synced:', result); + } catch (err) { + console.error('[Citepoint] Failed to sync video data:', err); + } +} \ No newline at end of file diff --git a/dashboard/dashboard.css b/dashboard/dashboard.css index 028a8fb..2231a43 100644 --- a/dashboard/dashboard.css +++ b/dashboard/dashboard.css @@ -554,3 +554,916 @@ body { flex-direction: column; } } + +/* ── Notifications Section ────────────────────── */ + +.notif-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.notif-header h2 { + margin: 0; + display: flex; + align-items: center; + gap: 8px; +} + +.notif-badge { + background: #e74c3c; + color: white; + font-size: 11px; + font-weight: 700; + padding: 2px 7px; + border-radius: 10px; +} + +.mark-all-btn { + background: none; + border: 1px solid #065fd4; + color: #065fd4; + border-radius: 6px; + padding: 5px 12px; + font-size: 12px; + cursor: pointer; +} + +.mark-all-btn:hover { background: rgba(6, 95, 212, 0.08); } + +.notif-item { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 10px 12px; + border-radius: 8px; + margin-bottom: 4px; + cursor: pointer; + transition: background 0.1s; +} + +.notif-item:hover { background: rgba(0, 0, 0, 0.04); } + +.notif-item.unread { + background: rgba(6, 95, 212, 0.06); + border-left: 3px solid #065fd4; +} + +.notif-icon { font-size: 18px; flex-shrink: 0; } + +.notif-body { + display: flex; + flex-direction: column; + gap: 2px; + flex: 1; +} + +.notif-title { font-size: 13px; font-weight: 500; } + +.notif-category { + font-size: 11px; + color: #606060; +} + +.notif-time { font-size: 11px; color: #999; } + +/* ── Profile Section ──────────────────────────── */ + +.profile-stats-row { + display: flex; + gap: 12px; + margin-bottom: 16px; + flex-wrap: wrap; +} + +.stat-card { + background: white; + border: 1px solid rgba(0, 0, 0, 0.1); + border-radius: 10px; + padding: 14px 20px; + text-align: center; + flex: 1; + min-width: 100px; +} + +.stat-number { + display: block; + font-size: 24px; + font-weight: 700; + color: #065fd4; +} + +.stat-expert .stat-number { + color: #27ae60; +} + +.stat-label { + display: block; + font-size: 12px; + color: #606060; + margin-top: 4px; +} + +.history-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 0; + border-bottom: 1px solid rgba(0, 0, 0, 0.06); + font-size: 13px; +} + +.history-type { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + padding: 2px 6px; + border-radius: 6px; + flex-shrink: 0; +} + +.type-citation { background: rgba(6, 95, 212, 0.1); color: #065fd4; } +.type-request { background: rgba(0, 0, 0, 0.07); color: #606060; } + +.history-title { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.history-score { + color: #27ae60; + font-weight: 500; + flex-shrink: 0; +} + +.history-date { + color: #999; + font-size: 12px; + flex-shrink: 0; +} + +/* ── Expert Section ───────────────────────────── */ + +.expert-badge-banner { + background: rgba(46, 204, 113, 0.1); + border: 1px solid rgba(46, 204, 113, 0.3); + border-radius: 8px; + padding: 12px 16px; + font-weight: 500; + color: #27ae60; + margin-bottom: 16px; +} + +.expert-check { + font-weight: 700; + margin-right: 4px; +} + +.success-message { + color: #27ae60; + font-weight: 500; +} + +.application-card { + background: white; + border: 1px solid rgba(0, 0, 0, 0.1); + border-radius: 8px; + padding: 14px; + margin-bottom: 10px; +} + +.app-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} + +.app-category { + font-weight: 500; + font-size: 14px; +} + +.app-username { + font-weight: 600; + color: #065fd4; +} + +/* ── Empty & Error Messages ─────────────────────────── */ + +.empty-message { + color: var(--cp-text-secondary); + font-size: var(--cp-text-md); + text-align: center; + padding: var(--cp-space-20) 0; +} + +.error-message { + color: var(--cp-error); + font-size: var(--cp-text-md); +} + +.success-message { + color: var(--cp-success); + font-weight: var(--cp-weight-medium); +} + +/* ── Notifications ──────────────────────────────────── */ + +.notif-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--cp-space-12); +} + +.notif-header h2 { + margin: 0; + display: flex; + align-items: center; + gap: var(--cp-space-8); +} + +.notif-badge { + background: var(--cp-error); + color: white; + font-size: var(--cp-text-xs); + font-weight: var(--cp-weight-bold); + padding: 2px 7px; + border-radius: 10px; +} + +.mark-all-btn { + background: none; + border: 1px solid var(--cp-accent); + color: var(--cp-accent); + border-radius: var(--cp-radius-md); + padding: 5px 12px; + font-size: var(--cp-text-sm); + font-weight: var(--cp-weight-medium); + cursor: pointer; + transition: background var(--cp-transition); +} + +.mark-all-btn:hover { + background: var(--cp-accent-bg, rgba(6, 95, 212, 0.08)); +} + +.notif-item { + display: flex; + align-items: flex-start; + gap: var(--cp-space-10); + padding: var(--cp-space-10) var(--cp-space-12); + border-radius: var(--cp-radius-md); + margin-bottom: var(--cp-space-4); + cursor: pointer; + transition: background var(--cp-transition); +} + +.notif-item:hover { + background: var(--cp-bg-hover); +} + +.notif-item.unread { + background: rgba(6, 95, 212, 0.06); + border-left: 3px solid var(--cp-accent); +} + +.notif-icon { + font-size: 18px; + flex-shrink: 0; +} + +.notif-body { + display: flex; + flex-direction: column; + gap: var(--cp-space-2); + flex: 1; +} + +.notif-title { + font-size: var(--cp-text-base); + font-weight: var(--cp-weight-medium); +} + +.notif-category { + font-size: var(--cp-text-xs); + color: var(--cp-text-secondary); +} + +.notif-time { + font-size: var(--cp-text-xs); + color: var(--cp-text-tertiary); +} + +/* ── Profile ────────────────────────────────────────── */ + +.profile-stats-row { + display: flex; + gap: var(--cp-space-12); + margin-bottom: var(--cp-space-16); + flex-wrap: wrap; +} + +.stat-card { + background: var(--cp-bg-card); + border: 1px solid var(--cp-border); + border-radius: var(--cp-radius-lg); + padding: 14px 20px; + text-align: center; + flex: 1; + min-width: 100px; +} + +.stat-number { + display: block; + font-size: var(--cp-text-3xl); + font-weight: var(--cp-weight-bold); + color: var(--cp-accent); +} + +.stat-expert .stat-number { + color: var(--cp-success); +} + +.stat-label { + display: block; + font-size: var(--cp-text-sm); + color: var(--cp-text-secondary); + margin-top: var(--cp-space-4); +} + +/* ── History ────────────────────────────────────────── */ + +.history-item { + display: flex; + align-items: center; + gap: var(--cp-space-10); + padding: var(--cp-space-8) 0; + border-bottom: 1px solid var(--cp-divider); + font-size: var(--cp-text-base); +} + +.history-type { + font-size: var(--cp-text-xs); + font-weight: var(--cp-weight-semi); + text-transform: uppercase; + padding: 2px 6px; + border-radius: var(--cp-radius-md); + flex-shrink: 0; +} + +.type-citation { + background: rgba(6, 95, 212, 0.1); + color: var(--cp-accent); +} + +.type-request { + background: rgba(0, 0, 0, 0.07); + color: var(--cp-text-secondary); +} + +.history-title { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.history-score { + color: var(--cp-success); + font-weight: var(--cp-weight-medium); + flex-shrink: 0; +} + +.history-date { + color: var(--cp-text-tertiary); + font-size: var(--cp-text-sm); + flex-shrink: 0; +} + +/* ── Expert Section ─────────────────────────────────── */ + +.expert-badge-banner { + background: var(--cp-success-bg); + border: 1px solid var(--cp-success-border); + border-radius: var(--cp-radius-md); + padding: var(--cp-space-12) var(--cp-space-16); + font-weight: var(--cp-weight-medium); + color: var(--cp-success); + margin-bottom: var(--cp-space-16); +} + +.expert-check { + font-weight: var(--cp-weight-bold); + margin-right: var(--cp-space-4); +} + +.application-card { + background: var(--cp-bg-card); + border: 1px solid var(--cp-border); + border-radius: var(--cp-radius-lg); + padding: 14px; + margin-bottom: var(--cp-space-10); +} + +.app-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--cp-space-8); +} + +.app-category { + font-weight: var(--cp-weight-medium); + font-size: var(--cp-text-md); +} + +.app-username { + font-weight: var(--cp-weight-semi); + color: var(--cp-accent); +} + +.app-status { + font-size: var(--cp-text-sm); + font-weight: var(--cp-weight-semi); + text-transform: uppercase; + padding: 2px 8px; + border-radius: 10px; +} + +.status-pending { background: var(--cp-warning-bg); color: var(--cp-warning); } +.status-approved { background: var(--cp-success-bg); color: var(--cp-success); } +.status-rejected { background: var(--cp-error-bg); color: var(--cp-error); } + +.app-credentials { + font-size: var(--cp-text-base); + color: var(--cp-text-secondary); + margin: 6px 0; +} + +.app-reason { + font-size: var(--cp-text-sm); + color: var(--cp-text-tertiary); + font-style: italic; +} + +.app-date { + font-size: var(--cp-text-sm); + color: var(--cp-text-tertiary); +} + +.admin-actions { + display: flex; + gap: var(--cp-space-8); + margin-top: var(--cp-space-10); +} + +.approve-btn, +.reject-btn { + border: none; + border-radius: var(--cp-radius-md); + padding: 6px 14px; + font-size: var(--cp-text-base); + font-weight: var(--cp-weight-medium); + cursor: pointer; + transition: background var(--cp-transition); +} + +.approve-btn { + background: var(--cp-success); + color: white; +} + +.approve-btn:hover { + background: var(--cp-success-hover); +} + +.reject-btn { + background: var(--cp-error); + color: white; +} + +.reject-btn:hover { + background: var(--cp-error-hover); +} + +/* ── Expert Application Form ────────────────────────── */ + +#expert-apply-form .form-group { + margin-bottom: 14px; +} + +#expert-apply-form label { + display: block; + font-size: var(--cp-text-base); + font-weight: var(--cp-weight-medium); + margin-bottom: 6px; +} + +#expert-apply-form select, +#expert-apply-form textarea { + width: 100%; + padding: 8px 12px; + border: 1px solid var(--cp-border-input); + border-radius: var(--cp-radius-md); + font-size: var(--cp-text-md); + font-family: var(--cp-font); + background-color: var(--cp-bg-card); + transition: border-color var(--cp-transition); +} + +#expert-apply-form select:focus, +#expert-apply-form textarea:focus { + outline: none; + border-color: var(--cp-accent); + box-shadow: 0 0 0 2px rgba(6, 95, 212, 0.1); +} + +/* ── Shared Submit Button (dashboard + profile) ────── */ + +.submit-btn, +#expert-apply-form .submit-btn { + background: var(--cp-accent); + color: white; + border: none; + border-radius: var(--cp-radius-pill); + padding: 10px 20px; + font-size: var(--cp-text-md); + font-weight: var(--cp-weight-medium); + font-family: var(--cp-font); + cursor: pointer; + transition: background var(--cp-transition); +} + +.submit-btn:hover, +#expert-apply-form .submit-btn:hover { + background: var(--cp-accent-hover); +} + +/* ── Profile Form ───────────────────────────────────── */ + +#profile-form .form-group { + margin-bottom: 14px; +} + +#profile-form label { + display: block; + font-size: var(--cp-text-base); + font-weight: var(--cp-weight-medium); + margin-bottom: 6px; +} + +#profile-form input[type="text"], +#profile-form textarea { + width: 100%; + padding: 8px 12px; + border: 1px solid var(--cp-border-input); + border-radius: var(--cp-radius-md); + font-size: var(--cp-text-md); + font-family: var(--cp-font); + background-color: var(--cp-bg-card); + transition: border-color var(--cp-transition); +} + +#profile-form input[type="text"]:focus, +#profile-form textarea:focus { + outline: none; + border-color: var(--cp-accent); + box-shadow: 0 0 0 2px rgba(6, 95, 212, 0.1); +} + +/* ── Responsive ─────────────────────────────────────── */ + +@media (max-width: 600px) { + .dashboard-container { + padding: var(--cp-space-16) var(--cp-space-12) var(--cp-space-32); + } + + .bar-row { + grid-template-columns: 100px 1fr 40px; + gap: var(--cp-space-8); + } + + .profile-stats-row { + flex-direction: column; + } + + .stat-card { + min-width: auto; + } + + .admin-actions { + flex-direction: column; + } +} + +.app-status { + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + padding: 2px 8px; + border-radius: 10px; +} + +.status-pending { background: rgba(255, 152, 0, 0.15); color: #e65100; } +.status-approved { background: rgba(46, 204, 113, 0.15); color: #27ae60; } +.status-rejected { background: rgba(231, 76, 60, 0.15); color: #c0392b; } + +.app-credentials { + font-size: 13px; + color: #444; + margin: 6px 0; +} + +.app-reason { + font-size: 12px; + color: #888; + font-style: italic; +} + +.app-date { + font-size: 12px; + color: #999; +} + +.admin-actions { + display: flex; + gap: 8px; + margin-top: 10px; +} + +.approve-btn, .reject-btn { + border: none; + border-radius: 6px; + padding: 6px 14px; + font-size: 13px; + font-weight: 500; + cursor: pointer; +} + +.approve-btn { + background: #27ae60; + color: white; +} + +.approve-btn:hover { background: #219a52; } + +.reject-btn { + background: #e74c3c; + color: white; +} + +.reject-btn:hover { background: #c0392b; } + +#expert-apply-form .form-group { + margin-bottom: 14px; +} + +#expert-apply-form label { + display: block; + font-size: 13px; + font-weight: 500; + margin-bottom: 6px; +} + +#expert-apply-form select, +#expert-apply-form textarea { + width: 100%; + padding: 8px 10px; + border: 1px solid #ddd; + border-radius: 6px; + font-size: 14px; + font-family: "Roboto", "Arial", sans-serif; +} + +#expert-apply-form .submit-btn { + background: #065fd4; + color: white; + border: none; + border-radius: 8px; + padding: 10px 20px; + font-size: 14px; + font-weight: 500; + cursor: pointer; +} + +#expert-apply-form .submit-btn:hover { background: #0356c6; } + +/* ── Navigation Bar ──────────────────────────── */ +.top-nav { + display: flex; + gap: 8px; + margin-bottom: 24px; + border-bottom: 1px solid #dadce0; + padding-bottom: 16px; + overflow-x: auto; +} + +.nav-btn { + background: transparent; + border: 1px solid transparent; + padding: 8px 16px; + font-size: 14px; + font-weight: 500; + color: #606060; + cursor: pointer; + border-radius: 20px; + transition: all 0.2s ease; + white-space: nowrap; +} + +.nav-btn:hover { + background-color: rgba(0, 0, 0, 0.04); + color: #0f0f0f; +} + +.nav-btn.active { + background-color: #065fd4; + color: white; + border-color: #065fd4; +} + +/* ── Tab Panes ───────────────────────────────── */ +.tab-pane { + display: none; +} + +.tab-pane.active { + display: block; + animation: fadeIn 0.25s ease-in-out; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: translateY(0); } +} + +/* ── Feeds Section ───────────────────────────── */ + +.feed-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--cp-space-16); +} + +.feed-filter-select { + padding: 6px 12px; + border: 1px solid var(--cp-border-input); + border-radius: var(--cp-radius-md); + background-color: var(--cp-bg-card); + font-family: inherit; + font-size: var(--cp-text-sm); + cursor: pointer; +} + +.feed-list { + display: flex; + flex-direction: column; + gap: var(--cp-space-16); +} + +.feed-card { + display: flex; + background: var(--cp-bg-card); + border: 1px solid var(--cp-border); + border-radius: var(--cp-radius-lg); + overflow: hidden; + transition: box-shadow var(--cp-transition); +} + +.feed-card:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05); +} + +.feed-thumb-container { + width: 160px; + flex-shrink: 0; + background-color: #000; +} + +.feed-thumbnail { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.feed-card-content { + padding: var(--cp-space-12) var(--cp-space-16); + flex: 1; + display: flex; + flex-direction: column; +} + +.feed-item-title { + margin: 0 0 var(--cp-space-4); + font-size: var(--cp-text-md); + font-weight: var(--cp-weight-medium); +} + +.feed-video-title { + font-size: var(--cp-text-sm); + color: var(--cp-text-secondary); + margin-bottom: var(--cp-space-8); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.feed-meta-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--cp-space-8); + margin-bottom: auto; +} + +.feed-category-badge { + font-size: 11px; + font-weight: var(--cp-weight-semi); + padding: 2px 8px; + border-radius: var(--cp-radius-pill); +} + +.feed-topic-tag { + font-size: 11px; + color: var(--cp-text-secondary); + background: rgba(0,0,0,0.05); + padding: 2px 8px; + border-radius: 4px; +} + +.feed-footer { + display: flex; + justify-content: space-between; + margin-top: var(--cp-space-12); + font-size: var(--cp-text-sm); +} + +.feed-score { + color: var(--cp-success); + font-weight: var(--cp-weight-bold); +} + +.feed-date { + color: var(--cp-text-tertiary); +} + +.feed-card-actions { + display: flex; + align-items: center; + padding: var(--cp-space-16); + border-left: 1px solid var(--cp-border); +} + +.feed-go-btn { + text-decoration: none; + font-size: var(--cp-text-sm); + padding: 8px 16px; +} + +/* ── Checkbox Grid (Expert Topics) ───────────── */ + +.checkbox-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 8px; + margin-top: 8px; + padding: 12px; + background: rgba(0,0,0,0.02); + border: 1px solid var(--cp-border-input); + border-radius: var(--cp-radius-md); +} + +.topic-checkbox-label { + display: flex; + align-items: center; + gap: 8px; + font-size: var(--cp-text-sm) !important; + font-weight: 400 !important; + cursor: pointer; +} + +@media (max-width: 600px) { + .feed-card { + flex-direction: column; + } + .feed-thumb-container { + width: 100%; + height: 140px; + } + .feed-card-actions { + border-left: none; + border-top: 1px solid var(--cp-border); + justify-content: center; + } + .feed-go-btn { + width: 100%; + text-align: center; + } +} \ No newline at end of file diff --git a/dashboard/dashboard.html b/dashboard/dashboard.html index 121ef9d..7ddcc94 100644 --- a/dashboard/dashboard.html +++ b/dashboard/dashboard.html @@ -5,108 +5,14 @@
Trending categories and expert verification activity
-