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 @@ Citepoint Dashboard + -
-
-

Citepoint Dashboard

-

Trending categories and expert verification activity

-
- - -
-
- -
-
-

Trending Requested Categories

-
-
- -
-

Citations by Category

-
-
- -
-

Expert Verification Activity

-
-

Citations

-
-
-
-

Citation Requests

-
-
-
-
-
-

Notifications

- -
-
-
- -
-

My Profile

-
-
- -
-
-
- -
-

Expert Verification

-
- -
-
- - -
-
+
- + diff --git a/dashboard/dashboard.js b/dashboard/dashboard.js index fee399c..72de37a 100644 --- a/dashboard/dashboard.js +++ b/dashboard/dashboard.js @@ -1,7 +1,7 @@ // ───────────────────────────────────────────── // dashboard.js -// Standalone dashboard page — fetches trending -// category stats and renders simple CSS bar charts. +// Standalone dashboard page — rendering charts, +// community feeds, and expert verification. // Depends on: api.js, config/config.js // ───────────────────────────────────────────── @@ -25,10 +25,60 @@ function _escapeHtml(str) { return div.innerHTML; } -/** - * Try to find the videoId of the YouTube video the user was watching. - * Returns null if no YouTube watch tab is found (falls back to global scope). - */ +function _renderError(container, message) { + container.innerHTML = ''; + const p = document.createElement('p'); + p.className = 'error-message'; + p.textContent = message; + container.appendChild(p); +} + +function _initTabs() { + const navBtns = document.querySelectorAll('.nav-btn'); + const tabPanes = document.querySelectorAll('.tab-pane'); + + navBtns.forEach(btn => { + btn.addEventListener('click', () => { + navBtns.forEach(b => b.classList.remove('active')); + tabPanes.forEach(p => p.classList.remove('active')); + + btn.classList.add('active'); + const targetId = btn.getAttribute('data-target'); + document.getElementById(targetId).classList.add('active'); + + // Lazy load feeds when their tab is clicked + if (targetId === 'general-feed-section') _loadGeneralFeed(); + if (targetId === 'expert-feed-section') _loadExpertFeed(); + }); + }); +} + +function _populateTaxonomyUI() { + // Populate General Feed Category Filter + const filterSelect = document.getElementById('general-feed-filter'); + // Filter values must match what backend/routes/feeds.js actually filters + // /general on: video topics, not citation categories (different taxonomy). + if (typeof TOPICS !== 'undefined') { + TOPICS.forEach(topic => { + const opt = document.createElement('option'); + opt.value = topic; + opt.textContent = topic; + filterSelect.appendChild(opt); + }); + } + + // Populate Expert Application Topic Checkboxes + const topicContainer = document.getElementById('expert-topics-container'); + if (typeof TOPICS !== 'undefined') { + topicContainer.innerHTML = TOPICS.map(topic => ` + + `).join(''); + } +} + async function _getActiveVideoId() { return new Promise(resolve => { try { @@ -47,16 +97,15 @@ async function _getActiveVideoId() { }); } +// ── Chart Rendering ────────────────────────── + function _renderBarChart(container, data) { container.innerHTML = ''; - if (!data || data.length === 0) { container.innerHTML = '

No data available.

'; return; } - const max = Math.max(...data.map(d => d.count), 1); - data.forEach(({ category, count }) => { const colors = _categoryColor(category); const row = document.createElement('div'); @@ -74,14 +123,11 @@ function _renderBarChart(container, data) { function _renderStackedChart(container, data) { container.innerHTML = ''; - if (!data || data.length === 0) { container.innerHTML = '

No data available.

'; return; } - const max = Math.max(...data.map(d => d.verified + d.unverified), 1); - data.forEach(({ category, verified, unverified }) => { const total = verified + unverified; const row = document.createElement('div'); @@ -104,23 +150,125 @@ async function _loadStats() { try { const stats = await apiGetDashboardStats(videoId); - _renderBarChart(document.getElementById('requests-chart'), stats.requestsByCategory); _renderBarChart(document.getElementById('citations-chart'), stats.citationsByCategory); _renderStackedChart(document.getElementById('citations-verification-chart'), stats.verificationStats.citations); _renderStackedChart(document.getElementById('requests-verification-chart'), stats.verificationStats.requests); } catch (err) { - console.error('[dashboard] Error loading stats:', err); - const content = document.getElementById('dashboard-content'); - content.innerHTML = ''; - const p = document.createElement('p'); - p.className = 'error-message'; - p.textContent = `Error loading dashboard stats: ${err.message}`; - content.appendChild(p); + _renderError(document.getElementById('dashboard-content'), `Error loading dashboard stats: ${err.message}`); + } +} + +// ── Feeds Rendering ────────────────────────── + +function _parseTimeString(timeStr) { + if (!timeStr) return 0; + const parts = timeStr.split(':').reverse(); + let seconds = 0; + for (let i = 0; i < parts.length; i++) { + seconds += parseInt(parts[i], 10) * Math.pow(60, i); + } + return seconds; +} + +function _safeThumbUrl(url) { + try { + const parsed = new URL(url, location.href); + return (parsed.protocol === 'http:' || parsed.protocol === 'https:') ? parsed.href : ''; + } catch (_) { + return ''; + } +} + +function _renderFeedCards(container, items) { + container.innerHTML = ''; + if (!items || items.length === 0) { + container.innerHTML = '

No requests found.

'; + return; + } + + items.forEach(item => { + const catColor = _categoryColor(item.category); + const videoTitle = item.video?.title || 'Unknown Video'; + const thumbUrl = _safeThumbUrl(item.video?.thumbnailUrl) || 'https://via.placeholder.com/160x90?text=No+Video'; + const videoId = encodeURIComponent(item.video?.videoId || ''); + const topicsHtml = (item.topics || []).map(t => `${_escapeHtml(t)}`).join(''); + const startSecs = _parseTimeString(item.timestampStart); + + const card = document.createElement('div'); + card.className = 'feed-card'; + card.innerHTML = ` +
+ + Video Thumbnail + +
+
+

${_escapeHtml(item.title)}

+
${_escapeHtml(videoTitle)}
+
+ ${_escapeHtml(item.category)} + ${topicsHtml} +
+ +
+
+ View Video +
+ `; + container.appendChild(card); + }); +} + +async function _loadGeneralFeed() { + const listEl = document.getElementById('general-feed-list'); + const topic = document.getElementById('general-feed-filter').value; + listEl.innerHTML = '

Loading...

'; + + try { + const res = await apiGetGeneralFeed(topic, 1, 20); + _renderFeedCards(listEl, res.feed); + } catch (err) { + _renderError(listEl, `Could not load feed: ${err.message}`); } } -// ── Notifications Section ──────────────────── +async function _loadExpertFeed() { + const username = await _getUsername(); + const listEl = document.getElementById('expert-feed-list'); + if (!username) { + listEl.innerHTML = '

Log in to view your expert feed.

'; + return; + } + listEl.innerHTML = '

Loading...

'; + + try { + const feed = await apiGetExpertFeed(username); + _renderFeedCards(listEl, feed); + } catch (err) { + _renderError(listEl, `Could not load expert feed: ${err.message}`); + } +} + +document.getElementById('general-feed-filter').addEventListener('change', _loadGeneralFeed); + + +// ── Notifications, Profile & Expert ────────── + +async function _getUsername() { + return new Promise(resolve => { + try { + chrome.storage.local.get(['youtubeUsername'], result => { + resolve(result.youtubeUsername || null); + }); + } catch (_) { + resolve(null); + } + }); +} async function _loadNotifications() { const username = await _getUsername(); @@ -177,13 +325,10 @@ async function _loadNotifications() { listEl.appendChild(div); }); } catch (err) { - console.error('[dashboard] Notifications error:', err); listEl.innerHTML = '

Could not load notifications.

'; } } -// ── Profile Section ────────────────────────── - async function _loadProfileSection() { const username = await _getUsername(); const statsEl = document.getElementById('profile-stats'); @@ -199,11 +344,13 @@ async function _loadProfileSection() { const res = await apiGetProfile(username); const { profile, stats, expert } = res; + const expertString = expert ? (expert.topics || []).join(', ') : ''; + statsEl.innerHTML = `
${stats.citations}Citations
${stats.requests}Requests
${stats.upvotes}Upvotes
- ${expert ? `
Expert: ${_escapeHtml(expert.categories.join(', '))}
` : ''} + ${expert ? `
Expert: ${_escapeHtml(expertString)}
` : ''} `; formEl.style.display = 'block'; @@ -246,25 +393,10 @@ async function _loadProfileSection() { }); } } catch (err) { - console.error('[dashboard] Profile error:', err); statsEl.innerHTML = '

Could not load profile.

'; } } -// ── Expert Application Section ─────────────── - -async function _getUsername() { - return new Promise(resolve => { - try { - chrome.storage.local.get(['youtubeUsername'], result => { - resolve(result.youtubeUsername || null); - }); - } catch (_) { - resolve(null); - } - }); -} - async function _loadExpertSection() { const username = await _getUsername(); const statusEl = document.getElementById('expert-status'); @@ -277,12 +409,16 @@ async function _loadExpertSection() { } try { - const res = await _send({ type: 'checkExpert', username }); + const res = await apiCheckExpert(username); if (res.isExpert) { - const cats = res.categories && res.categories.length > 0 - ? res.categories.join(', ') - : 'All categories'; - statusEl.innerHTML = `
Verified Expert — ${cats}
`; + const topicsStr = res.topics && res.topics.length > 0 + ? res.topics.join(', ') + : 'No topics assigned'; + statusEl.innerHTML = `
Verified Expert — ${_escapeHtml(topicsStr)}
`; + + // Show the Expert Feed nav button + document.getElementById('nav-expert-feed').style.display = 'block'; + _loadAdminSection(username); } else { statusEl.innerHTML = '

You are not yet a verified expert.

'; formEl.style.display = 'block'; @@ -293,11 +429,13 @@ async function _loadExpertSection() { listEl.innerHTML = '

Your Applications

'; apps.forEach(app => { const statusClass = app.status === 'approved' ? 'status-approved' : app.status === 'rejected' ? 'status-rejected' : 'status-pending'; + const domain = (app.topics || []).join(', '); + const div = document.createElement('div'); div.className = 'application-card'; div.innerHTML = `
- ${_escapeHtml(app.category)} + ${_escapeHtml(domain)} ${_escapeHtml(app.status)}

${_escapeHtml(app.credentials)}

@@ -308,16 +446,22 @@ async function _loadExpertSection() { }); } - // Wire application form const form = document.getElementById('expert-application-form'); form?.addEventListener('submit', async e => { e.preventDefault(); - const category = document.getElementById('expert-category').value; + + // Gather all checked topics + const checkboxes = document.querySelectorAll('input[name="expert-topics"]:checked'); + const selectedTopics = Array.from(checkboxes).map(cb => cb.value); const credentials = document.getElementById('expert-credentials').value.trim(); - if (!category || !credentials) return; + + if (selectedTopics.length === 0) { + alert('Please select at least one topic.'); + return; + } try { - await apiApplyExpert(username, category, credentials); + await apiApplyExpert(username, selectedTopics, credentials); form.reset(); formEl.innerHTML = '

Application submitted! You will be notified when reviewed.

'; _loadExpertSection(); @@ -326,12 +470,7 @@ async function _loadExpertSection() { } }); - // Admin section — only show for existing experts - if (res.isExpert) { - _loadAdminSection(username); - } } catch (err) { - console.error('[dashboard] Expert section error:', err); statusEl.innerHTML = '

Could not load expert status.

'; } } @@ -339,9 +478,10 @@ async function _loadExpertSection() { async function _loadAdminSection(adminUsername) { const section = document.getElementById('admin-section'); const listEl = document.getElementById('admin-pending-list'); + document.getElementById('nav-admin').style.display = 'block'; try { - const apps = await apiGetPendingApplications(); + const apps = await apiGetPendingApplications(adminUsername); if (apps.length === 0) { section.style.display = 'block'; listEl.innerHTML = '

No pending applications.

'; @@ -354,10 +494,12 @@ async function _loadAdminSection(adminUsername) { apps.forEach(app => { const div = document.createElement('div'); div.className = 'application-card admin-card'; + const domain = (app.topics || []).join(', '); + div.innerHTML = `
${_escapeHtml(app.username)} - ${_escapeHtml(app.category)} + ${_escapeHtml(domain)}

${_escapeHtml(app.credentials)}

Submitted ${new Date(app.submittedAt).toLocaleDateString()} @@ -391,6 +533,8 @@ async function _loadAdminSection(adminUsername) { } document.addEventListener('DOMContentLoaded', () => { + _initTabs(); + _populateTaxonomyUI(); _loadStats(); _loadNotifications(); _loadProfileSection(); diff --git a/discussion/discussion.css b/discussion/discussion.css index 8d36b07..f7c8a86 100644 --- a/discussion/discussion.css +++ b/discussion/discussion.css @@ -164,6 +164,8 @@ body { border-color: var(--cp-accent); } +<<<<<<< HEAD +======= /* ── Thread Nodes (nesting) ─────────────────────────── */ .thread-node { @@ -188,12 +190,17 @@ body { display: none; } +>>>>>>> origin/dev /* ── Thread Items ───────────────────────────────────── */ .thread-item { background: var(--cp-bg-card); border: 1px solid var(--cp-divider); border-radius: var(--cp-radius-lg); +<<<<<<< HEAD + padding: var(--cp-space-16); + margin-bottom: var(--cp-space-10); +======= padding: var(--cp-space-12) var(--cp-space-16); margin-bottom: var(--cp-space-8); } @@ -204,13 +211,19 @@ body { border-radius: 0; padding: var(--cp-space-8) 0; margin-bottom: 0; +>>>>>>> origin/dev } .thread-item-header { display: flex; align-items: center; +<<<<<<< HEAD + gap: var(--cp-space-8); + margin-bottom: var(--cp-space-8); +======= gap: var(--cp-space-6); margin-bottom: var(--cp-space-4); +>>>>>>> origin/dev } .thread-author { @@ -238,7 +251,11 @@ body { .thread-description { font-size: var(--cp-text-md); line-height: var(--cp-leading-normal); +<<<<<<< HEAD + margin: 0 0 var(--cp-space-8); +======= margin: 0 0 var(--cp-space-6); +>>>>>>> origin/dev } .thread-category { @@ -252,6 +269,15 @@ body { margin-right: var(--cp-space-8); } +<<<<<<< HEAD +.thread-score { + font-size: var(--cp-text-base); + font-weight: var(--cp-weight-medium); + color: var(--cp-success); + margin-top: var(--cp-space-6); +} + +======= /* ── Vote Controls ──────────────────────────────────── */ .vote-controls { @@ -455,6 +481,7 @@ body { text-decoration: underline; } +>>>>>>> origin/dev /* ── Empty State ────────────────────────────────────── */ .empty-message { @@ -487,6 +514,8 @@ body { margin-left: 0; width: 100%; } +<<<<<<< HEAD +======= .thread-node--nested { margin-left: 12px; @@ -496,4 +525,5 @@ body { .thread-continue-link { margin-left: 22px; } +>>>>>>> origin/dev } diff --git a/e2e/browserChannel.js b/e2e/browserChannel.js new file mode 100644 index 0000000..5d5ba01 --- /dev/null +++ b/e2e/browserChannel.js @@ -0,0 +1,14 @@ +// Optional Chromium channel override for chromium.launchPersistentContext(). +// +// Playwright's bundled Chromium can fail to launch on some Windows machines with +// "Error: browserType.launchPersistentContext: spawn UNKNOWN" / a Windows Event Log +// entry reading "Activation context generation failed ... Dependent Assembly ... +// could not be found" — a corrupted or AV-stripped manifest resource in chrome.exe, +// not a bug in this project. chrome-headless-shell (a different, minimal Chromium +// build) is unaffected, confirming it's specific to the full GUI Chromium binary. +// +// Workaround: set PW_BROWSER_CHANNEL=msedge to run against the system-installed +// Microsoft Edge (Chromium-based, supports --load-extension) instead of downloading +// a fresh copy of Chromium. Leave unset in CI (Ubuntu has no system Edge, and the +// bundled Chromium launches fine there — this issue is Windows-specific). +module.exports = process.env.PW_BROWSER_CHANNEL || undefined; diff --git a/e2e/citepoint.delete.spec.js b/e2e/citepoint.delete.spec.js index 4b3e720..0e89221 100644 --- a/e2e/citepoint.delete.spec.js +++ b/e2e/citepoint.delete.spec.js @@ -1,5 +1,6 @@ const { test, expect, chromium } = require('@playwright/test'); const path = require('path'); +const channel = require('./browserChannel'); const EXTENSION_PATH = path.resolve(__dirname, '..'); const TEST_VIDEO = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'; @@ -33,6 +34,7 @@ test.beforeAll(async () => { context = await chromium.launchPersistentContext('', { headless: false, + channel, args: [ `--load-extension=${EXTENSION_PATH}`, `--disable-extensions-except=${EXTENSION_PATH}`, @@ -64,11 +66,19 @@ test.beforeEach(async () => { await page.waitForSelector('ytd-watch-metadata', { timeout: 30000 }); await _waitForAdToFinish(); await page.waitForSelector('#citation-controls', { timeout: 30000 }); + await _expandPanel(); await mockLogin('@testuser'); }); // ── Helpers ─────────────────────────────────────────────────────────────── +// Panel loads minimized by default (tab buttons are disabled until expanded) — click +// the toggle to open it before interacting with tabs/content. +async function _expandPanel() { + await page.locator('#toggle-extension').click(); + await page.waitForSelector('#extension-content:not([style*="display: none"])', { timeout: 5000 }).catch(() => {}); +} + async function mockLogin(handle = '@testuser') { const sw = context.serviceWorkers().find(w => w.url().includes(EXTENSION_ID)); if (sw) { @@ -167,6 +177,7 @@ async function seedCitation(title = 'Test Citation') { if (!page || page.isClosed()) return; await page.waitForSelector('#citation-controls', { timeout: 30000 }); + await _expandPanel(); await mockLogin('@testuser'); await openCitationsTab(); @@ -330,6 +341,7 @@ test('DEL-007: delete button is not visible on citations by other users', async await page.reload({ waitUntil: 'domcontentloaded' }).catch(() => {}); await _waitForAdToFinish(); await page.waitForSelector('#citation-controls', { timeout: 30000 }); + await _expandPanel(); await mockLogin('@testuser'); await openCitationsTab(); @@ -369,6 +381,7 @@ test('DEL-009: delete own citation request removes it from the list', async () = await page.reload({ waitUntil: 'domcontentloaded' }).catch(() => {}); await _waitForAdToFinish(); await page.waitForSelector('#citation-controls', { timeout: 30000 }); + await _expandPanel(); await mockLogin('@testuser'); // Switch citations then requests to force fresh fetch diff --git a/e2e/citepoint.new.spec.js b/e2e/citepoint.new.spec.js index 9159059..8d39cba 100644 --- a/e2e/citepoint.new.spec.js +++ b/e2e/citepoint.new.spec.js @@ -1,5 +1,6 @@ const { test, expect, chromium } = require('@playwright/test'); const path = require('path'); +const channel = require('./browserChannel'); const EXTENSION_PATH = path.resolve(__dirname, '..'); const TEST_VIDEO = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'; @@ -13,6 +14,7 @@ let EXTENSION_ID = ''; test.beforeAll(async () => { context = await chromium.launchPersistentContext('', { headless: false, + channel, args: [ `--load-extension=${EXTENSION_PATH}`, `--disable-extensions-except=${EXTENSION_PATH}`, @@ -40,10 +42,18 @@ test.beforeEach(async () => { await page.waitForSelector('ytd-watch-metadata', { timeout: 30000 }); await _waitForAdToFinish(); await page.waitForSelector('#citation-controls', { timeout: 30000 }); + await _expandPanel(); await mockLogin('@testuser'); await page.locator('#citations-btn').click(); }); +// Panel loads minimized by default (tab buttons are disabled until expanded) — click +// the toggle to open it before interacting with tabs/content. +async function _expandPanel() { + await page.locator('#toggle-extension').click(); + await page.waitForSelector('#extension-content:not([style*="display: none"])', { timeout: 5000 }).catch(() => {}); +} + // ── Helpers ─────────────────────────────────────────────────────────────── async function mockLogin(handle = '@testuser') { @@ -158,6 +168,7 @@ test('ADD-004: submitting a valid citation shows success toast and refreshes lis await page.reload({ waitUntil: 'domcontentloaded' }); await _waitForAdToFinish(); await page.waitForSelector('#citation-controls', { timeout: 30000 }); + await _expandPanel(); await mockLogin('@testuser'); await page.locator('#citations-btn').click(); await expect(page.locator('#citations-container')).not.toBeEmpty({ timeout: 30000 }); diff --git a/e2e/citepoint.recording.spec.js b/e2e/citepoint.recording.spec.js index 196e629..0a6ea98 100644 --- a/e2e/citepoint.recording.spec.js +++ b/e2e/citepoint.recording.spec.js @@ -1,5 +1,6 @@ const { test, expect, chromium } = require('@playwright/test'); const path = require('path'); +const channel = require('./browserChannel'); const EXTENSION_PATH = path.resolve(__dirname, '..'); const TEST_VIDEO = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'; @@ -13,6 +14,7 @@ let EXTENSION_ID = ''; test.beforeAll(async () => { context = await chromium.launchPersistentContext('', { headless: false, + channel, args: [ `--load-extension=${EXTENSION_PATH}`, `--disable-extensions-except=${EXTENSION_PATH}`, diff --git a/e2e/citepoint.requests.spec.js b/e2e/citepoint.requests.spec.js index bee6812..992af5a 100644 --- a/e2e/citepoint.requests.spec.js +++ b/e2e/citepoint.requests.spec.js @@ -1,5 +1,6 @@ const { test, expect, chromium } = require('@playwright/test'); const path = require('path'); +const channel = require('./browserChannel'); const EXTENSION_PATH = path.resolve(__dirname, '..'); const TEST_VIDEO = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'; @@ -14,6 +15,7 @@ let createdRequestIds = []; test.beforeAll(async () => { context = await chromium.launchPersistentContext('', { headless: false, + channel, args: [ `--load-extension=${EXTENSION_PATH}`, `--disable-extensions-except=${EXTENSION_PATH}`, @@ -60,11 +62,19 @@ test.beforeEach(async () => { await page.waitForSelector('ytd-watch-metadata', { timeout: 30000 }); await _waitForAdToFinish(); await page.waitForSelector('#citation-controls', { timeout: 30000 }); + await _expandPanel(); await mockLogin('@testuser'); }); // ── Helpers ─────────────────────────────────────────────────────────────── +// Panel loads minimized by default (tab buttons are disabled until expanded) — click +// the toggle to open it before interacting with tabs/content. +async function _expandPanel() { + await page.locator('#toggle-extension').click(); + await page.waitForSelector('#extension-content:not([style*="display: none"])', { timeout: 5000 }).catch(() => {}); +} + async function mockLogin(handle = '@testuser') { const sw = context.serviceWorkers().find(w => w.url().includes(EXTENSION_ID)); if (sw) { @@ -212,6 +222,7 @@ async function seedRequest({ await page.reload({ waitUntil: 'domcontentloaded' }); await _waitForAdToFinish(); await page.waitForSelector('#citation-controls', { timeout: 30000 }); + await _expandPanel(); await mockLogin('@testuser'); await openRequestsTab(); await page.waitForTimeout(3000); @@ -251,6 +262,7 @@ test('REQ-003: empty state message shown when video has no citation requests', a await page.waitForSelector('ytd-watch-metadata', { timeout: 30000 }); await _waitForAdToFinish(); await page.waitForSelector('#citation-controls', { timeout: 30000 }); + await _expandPanel(); await mockLogin('@testuser'); await openRequestsTab(); await page.waitForTimeout(3000); @@ -282,6 +294,7 @@ test('REQ-005: submitting a valid request shows success toast and form closes', await page.reload({ waitUntil: 'domcontentloaded' }); await _waitForAdToFinish(); await page.waitForSelector('#citation-controls', { timeout: 30000 }); + await _expandPanel(); await mockLogin('@testuser'); await openRequestsTab(); await page.waitForTimeout(3000); diff --git a/e2e/citepoint.voting.spec.js b/e2e/citepoint.voting.spec.js index ee2799c..fea7f65 100644 --- a/e2e/citepoint.voting.spec.js +++ b/e2e/citepoint.voting.spec.js @@ -1,5 +1,6 @@ const { test, expect, chromium } = require('@playwright/test'); const path = require('path'); +const channel = require('./browserChannel'); const EXTENSION_PATH = path.resolve(__dirname, '..'); const TEST_VIDEO = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'; @@ -14,6 +15,7 @@ let EXTENSION_ID = ''; test.beforeAll(async () => { context = await chromium.launchPersistentContext('', { headless: false, + channel, args: [ `--load-extension=${EXTENSION_PATH}`, `--disable-extensions-except=${EXTENSION_PATH}`, @@ -43,6 +45,7 @@ test.beforeEach(async () => { await page.waitForSelector('ytd-watch-metadata', { timeout: 30000 }); await _waitForAdToFinish(); await page.waitForSelector('#citation-controls', { timeout: 30000 }); + await _expandPanel(); await mockLogin('@testuser'); await page.locator('#citations-btn').click(); await page.waitForSelector('#citations-container', { timeout: 10000 }); @@ -50,6 +53,13 @@ test.beforeEach(async () => { // ── Helpers ─────────────────────────────────────────────────────────────── +// Panel loads minimized by default (tab buttons are disabled until expanded) — click +// the toggle to open it before interacting with tabs/content. +async function _expandPanel() { + await page.locator('#toggle-extension').click(); + await page.waitForSelector('#extension-content:not([style*="display: none"])', { timeout: 5000 }).catch(() => {}); +} + async function mockLogin(handle = '@testuser') { const sw = context.serviceWorkers().find(w => w.url().includes(EXTENSION_ID)); if (sw) { @@ -120,6 +130,7 @@ async function createCitation(title = 'VOT Test Citation') { await page.reload({ waitUntil: 'domcontentloaded' }); await _waitForAdToFinish(); await page.waitForSelector('#citation-controls', { timeout: 30000 }); + await _expandPanel(); await mockLogin('@testuser'); await page.locator('#citations-btn').click(); await page.waitForSelector('#citations-container', { timeout: 10000 }); @@ -322,6 +333,7 @@ test('VOT-013: upvote state persists after page reload', async () => { await page.reload({ waitUntil: 'domcontentloaded' }); await _waitForAdToFinish(); await page.waitForSelector('#citation-controls', { timeout: 30000 }); + await _expandPanel(); await mockLogin('@testuser'); await page.locator('#citations-btn').click(); await page.waitForSelector('#citations-container', { timeout: 10000 }); @@ -352,6 +364,7 @@ test('VOT-015: upvote state persists after navigating away and back via SPA', as await page.waitForSelector('ytd-watch-metadata', { timeout: 30000 }); await _waitForAdToFinish(); await page.waitForSelector('#citation-controls', { timeout: 30000 }); + await _expandPanel(); await mockLogin('@testuser'); await page.locator('#citations-btn').click(); await page.waitForSelector('#citations-container', { timeout: 10000 }); @@ -402,6 +415,7 @@ test('VOT-022: after clearing extension storage, user can vote again on same cit await page.reload({ waitUntil: 'domcontentloaded' }); await _waitForAdToFinish(); await page.waitForSelector('#citation-controls', { timeout: 30000 }); + await _expandPanel(); await mockLogin('@testuser'); await page.locator('#citations-btn').click(); await page.waitForSelector('#citations-container', { timeout: 10000 }); diff --git a/e2e/extension.spec.js b/e2e/extension.spec.js index 7d6c2a9..4550d7b 100644 --- a/e2e/extension.spec.js +++ b/e2e/extension.spec.js @@ -1,5 +1,6 @@ const { test, expect, chromium } = require('@playwright/test'); const path = require('path'); +const channel = require('./browserChannel'); const EXTENSION_PATH = path.resolve(__dirname, '..'); const TEST_VIDEO = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'; @@ -12,6 +13,7 @@ let page; test.beforeAll(async () => { context = await chromium.launchPersistentContext('', { headless: false, + channel, args: [ `--load-extension=${EXTENSION_PATH}`, `--disable-extensions-except=${EXTENSION_PATH}`, @@ -30,6 +32,13 @@ test.beforeEach(async () => { await page.goto(TEST_VIDEO, { waitUntil: 'domcontentloaded' }); await page.waitForSelector('ytd-watch-metadata', { timeout: 30000 }); await page.waitForSelector('#citation-controls', { timeout: 30000 }); + + // Panel loads minimized by default (tab buttons are disabled until expanded). + const content = page.locator('#extension-content'); + if (!(await content.isVisible())) { + await page.locator('#toggle-extension').click(); + await expect(content).toBeVisible(); + } }); // ───────────────────────────────────────────── @@ -76,14 +85,16 @@ test('toggle button collapses and expands the panel', async () => { test('clicking Citations tab shows citations section', async () => { await page.locator('#citations-btn').click(); - const title = page.locator('#citation-title'); - await expect(title).toContainText('Citations'); + await expect(page.locator('#citations-container')).toBeVisible(); + await expect(page.locator('#citation-requests-container')).toBeHidden(); + await expect(page.locator('#citations-btn')).toHaveClass(/active/); }); test('clicking Citation Requests tab switches the view', async () => { await page.locator('#citation-requests-btn').click(); - const title = page.locator('#citation-title'); - await expect(title).toContainText('Requests'); + await expect(page.locator('#citation-requests-container')).toBeVisible(); + await expect(page.locator('#citations-container')).toBeHidden(); + await expect(page.locator('#citation-requests-btn')).toHaveClass(/active/); }); // ───────────────────────────────────────────── diff --git a/e2e/helpers.js b/e2e/helpers.js index 7f4e33d..0999d3e 100644 --- a/e2e/helpers.js +++ b/e2e/helpers.js @@ -1,5 +1,6 @@ const { chromium } = require('@playwright/test'); const path = require('path'); +const channel = require('./browserChannel'); const EXTENSION_PATH = path.resolve(__dirname, '..'); @@ -7,6 +8,7 @@ const EXTENSION_PATH = path.resolve(__dirname, '..'); async function launchWithExtension() { const context = await chromium.launchPersistentContext('', { headless: false, + channel, args: [ `--load-extension=${EXTENSION_PATH}`, `--disable-extensions-except=${EXTENSION_PATH}`, @@ -29,6 +31,13 @@ async function goToVideo(page, videoId = 'dQw4w9WgXcQ') { // Wait for the extension panel to inject await page.waitForSelector('#citation-controls', { timeout: 30000 }); + + // Panel loads minimized by default (tab buttons are disabled until expanded). + const content = page.locator('#extension-content'); + if (!(await content.isVisible())) { + await page.locator('#toggle-extension').click(); + await content.waitFor({ state: 'visible', timeout: 5000 }).catch(() => {}); + } } module.exports = { launchWithExtension, goToVideo }; diff --git a/manifest.json b/manifest.json index 5acd4f1..b94416d 100644 --- a/manifest.json +++ b/manifest.json @@ -51,7 +51,8 @@ } ], "background": { - "service_worker": "background/background.js" + "service_worker": "background/background.js", + "scripts": ["background/background.js"] }, "web_accessible_resources": [{ "resources": [ @@ -65,6 +66,8 @@ "dashboard/dashboard.html", "dashboard/dashboard.js", "dashboard/dashboard.css", + "dashboard/dist/dashboard.bundle.js", + "dashboard/dist/dashboard.bundle.css", "discussion/discussion.html", "discussion/discussion.js", "discussion/discussion.css", diff --git a/package-lock.json b/package-lock.json index d343dc5..e225e03 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,12 +8,16 @@ "name": "youtube-citation-extension", "version": "1.0.0", "dependencies": { - "mongoose": "^9.6.3" + "mongoose": "^9.6.3", + "react": "^19.2.7", + "react-dom": "^19.2.7" }, "devDependencies": { "@playwright/test": "^1.50.0", + "@vitejs/plugin-react": "^6.0.3", "archiver": "^8.0.0", "selenium-webdriver": "^4.44.0", + "vite": "^8.1.1", "web-ext": "^8.0.0" } }, @@ -119,6 +123,40 @@ } } }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -338,6 +376,25 @@ "sparse-bitfield": "^3.0.3" } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -376,6 +433,16 @@ "node": ">= 8" } }, + "node_modules/@oxc-project/types": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz", + "integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, "node_modules/@playwright/test": { "version": "1.60.0", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", @@ -437,6 +504,299 @@ "node": ">=12" } }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz", + "integrity": "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.3.tgz", + "integrity": "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.3.tgz", + "integrity": "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.3.tgz", + "integrity": "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.3.tgz", + "integrity": "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.3.tgz", + "integrity": "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.3.tgz", + "integrity": "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.3.tgz", + "integrity": "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.3.tgz", + "integrity": "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.3.tgz", + "integrity": "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.3.tgz", + "integrity": "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.3.tgz", + "integrity": "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.3.tgz", + "integrity": "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz", + "integrity": "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.3.tgz", + "integrity": "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/minimatch": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz", @@ -486,6 +846,32 @@ "dev": true, "license": "ISC" }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", + "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -1848,6 +2234,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -2420,6 +2816,24 @@ "pend": "~1.2.0" } }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -3292,6 +3706,279 @@ "marky": "^1.2.2" } }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/lines-and-columns": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-2.0.4.tgz", @@ -3491,6 +4178,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -3823,6 +4529,19 @@ "dev": true, "license": "ISC" }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/pino": { "version": "9.9.5", "resolved": "https://registry.npmjs.org/pino/-/pino-9.9.5.tgz", @@ -3895,6 +4614,35 @@ "node": ">=18" } }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -4045,6 +4793,27 @@ "node": ">=0.10.0" } }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, "node_modules/readable-stream": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", @@ -4213,6 +4982,40 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/rolldown": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz", + "integrity": "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.137.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.3", + "@rolldown/binding-darwin-arm64": "1.1.3", + "@rolldown/binding-darwin-x64": "1.1.3", + "@rolldown/binding-freebsd-x64": "1.1.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.3", + "@rolldown/binding-linux-arm64-gnu": "1.1.3", + "@rolldown/binding-linux-arm64-musl": "1.1.3", + "@rolldown/binding-linux-ppc64-gnu": "1.1.3", + "@rolldown/binding-linux-s390x-gnu": "1.1.3", + "@rolldown/binding-linux-x64-gnu": "1.1.3", + "@rolldown/binding-linux-x64-musl": "1.1.3", + "@rolldown/binding-openharmony-arm64": "1.1.3", + "@rolldown/binding-wasm32-wasi": "1.1.3", + "@rolldown/binding-win32-arm64-msvc": "1.1.3", + "@rolldown/binding-win32-x64-msvc": "1.1.3" + } + }, "node_modules/run-applescript": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", @@ -4277,6 +5080,12 @@ "node": ">=11.0.0" } }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, "node_modules/selenium-webdriver": { "version": "4.44.0", "resolved": "https://registry.npmjs.org/selenium-webdriver/-/selenium-webdriver-4.44.0.tgz", @@ -4386,6 +5195,16 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-support": { "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", @@ -4637,6 +5456,23 @@ "dev": true, "license": "MIT" }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, "node_modules/tmp": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", @@ -4659,6 +5495,14 @@ "node": ">=18" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -4773,6 +5617,99 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/vite": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.1.tgz", + "integrity": "sha512-X/05/cT+VITy2AeDc1der6smvGWWREtL4hPbPTaVbjSBuuWkmNOjR6HP3NzqcQA2nF6VHGUPaFRJyft/2AE9Kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.16", + "rolldown": "~1.1.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/watchpack": { "version": "2.4.4", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz", diff --git a/package.json b/package.json index cf60fac..14d2f51 100644 --- a/package.json +++ b/package.json @@ -5,15 +5,21 @@ "scripts": { "test:e2e": "playwright test --project=chrome-extension", "test:e2e:firefox": "node selenium/firefox.test.js", - "test:e2e:all": "playwright test --project=chrome-extension && node selenium/firefox.test.js" + "test:e2e:all": "playwright test --project=chrome-extension && node selenium/firefox.test.js", + "watch": "vite build --watch", + "build": "vite build" }, "devDependencies": { "@playwright/test": "^1.50.0", + "@vitejs/plugin-react": "^6.0.3", "archiver": "^8.0.0", "selenium-webdriver": "^4.44.0", + "vite": "^8.1.1", "web-ext": "^8.0.0" }, "dependencies": { - "mongoose": "^9.6.3" + "mongoose": "^9.6.3", + "react": "^19.2.7", + "react-dom": "^19.2.7" } } diff --git a/selenium/firefox.test.js b/selenium/firefox.test.js index 6c2072c..335a1bc 100644 --- a/selenium/firefox.test.js +++ b/selenium/firefox.test.js @@ -146,6 +146,16 @@ async function goToVideo() { console.log(' [goToVideo] panel present:', hasPanel); await driver.wait(until.elementLocated(By.id('citation-controls')), TIMEOUT); + + // Panel loads minimized by default (tab buttons are disabled until expanded) — + // click the toggle to open it before any test tries to interact with tabs/content. + const contentVisible = await driver.executeScript( + 'const el = document.getElementById("extension-content"); return !!el && el.style.display !== "none";' + ); + if (!contentVisible) { + await driver.findElement(By.id('toggle-extension')).click(); + await driver.sleep(300); + } } async function isVisible(selector) { @@ -201,8 +211,8 @@ async function test_citationsTab() { // Re-find elements fresh after navigation to avoid stale element errors await driver.findElement(By.id('citations-btn')).click(); await driver.sleep(500); - const title = await driver.findElement(By.id('citation-title')).getText(); - assert.ok(title.includes('Citation'), `Expected "Citation" in title, got "${title}"`); + assert.ok(await isVisible('#citations-container'), 'Citations container should be visible'); + assert.ok(!(await isVisible('#citation-requests-container')), 'Requests container should be hidden'); console.log(' ✓ Citations tab works'); } @@ -210,8 +220,9 @@ async function test_requestsTab() { console.log(' running: Citation Requests tab works'); await goToVideo(); await driver.findElement(By.id('citation-requests-btn')).click(); - const title = await driver.findElement(By.id('citation-title')).getText(); - assert.ok(title.includes('Request'), `Expected "Request" in title, got "${title}"`); + await driver.sleep(500); + assert.ok(await isVisible('#citation-requests-container'), 'Requests container should be visible'); + assert.ok(!(await isVisible('#citations-container')), 'Citations container should be hidden'); console.log(' ✓ Citation Requests tab works'); } diff --git a/src/dashboard/App.jsx b/src/dashboard/App.jsx new file mode 100644 index 0000000..f1210b4 --- /dev/null +++ b/src/dashboard/App.jsx @@ -0,0 +1,14 @@ +import React, { useState } from 'react'; +import { UserProvider } from './context/UserContext'; +import DashboardLayout from './layout/DashboardLayout'; +import './styles/layout.css'; + +export default function App() { + const [activeView, setActiveView] = useState('general'); + + return ( + + + + ); +} \ No newline at end of file diff --git a/src/dashboard/components/Analytics.jsx b/src/dashboard/components/Analytics.jsx new file mode 100644 index 0000000..4238827 --- /dev/null +++ b/src/dashboard/components/Analytics.jsx @@ -0,0 +1,141 @@ +import React, { useState, useEffect, useCallback } from 'react'; + +const CATEGORY_COLORS = { + 'Statistics & Data': { bg: 'rgba(101, 31, 255, 0.12)', color: '#651fff' }, + 'Quote / Misattribution': { bg: 'rgba(230, 81, 0, 0.12)', color: '#e65100' }, + 'Historical Claim': { bg: 'rgba(0, 137, 123, 0.12)', color: '#00897b' }, + 'Scientific Claim': { bg: 'rgba(6, 95, 212, 0.12)', color: '#065fd4' }, + 'Context / Methodology': { bg: 'rgba(194, 24, 91, 0.12)', color: '#c2185b' }, + 'Other': { bg: 'rgba(0, 0, 0, 0.07)', color: '#606060' }, + 'Uncategorized': { bg: 'rgba(0, 0, 0, 0.05)', color: '#9e9e9e' }, +}; + +function categoryColor(category) { + return CATEGORY_COLORS[category] || CATEGORY_COLORS['Uncategorized']; +} + +function getActiveVideoId() { + return new Promise(resolve => { + try { + chrome.tabs.query({ url: ['*://www.youtube.com/watch*', '*://m.youtube.com/watch*'] }, tabs => { + if (chrome.runtime.lastError || !tabs || tabs.length === 0) return resolve(null); + try { + const url = new URL(tabs[0].url); + resolve(url.searchParams.get('v')); + } catch (_) { + resolve(null); + } + }); + } catch (_) { + resolve(null); + } + }); +} + +function BarChart({ data }) { + if (!data || data.length === 0) { + return

No data available.

; + } + const max = Math.max(...data.map(d => d.count), 1); + return ( + <> + {data.map(({ category, count }) => { + const colors = categoryColor(category); + return ( +
+ {category} +
+
+
+ {count} +
+ ); + })} + + ); +} + +function StackedChart({ data }) { + if (!data || data.length === 0) { + return

No data available.

; + } + const max = Math.max(...data.map(d => d.verified + d.unverified), 1); + return ( + <> + {data.map(({ category, verified, unverified }) => { + const total = verified + unverified; + return ( +
+ {category} +
+
+
+
+ {verified} / {total} +
+ ); + })} + + ); +} + +export default function Analytics() { + const [scope, setScope] = useState('video'); + const [stats, setStats] = useState(null); + const [error, setError] = useState(null); + + const loadStats = useCallback(async () => { + setError(null); + try { + const videoId = scope === 'video' ? await getActiveVideoId() : null; + const result = await window.apiGetDashboardStats(videoId); + setStats(result); + } catch (err) { + setError(err.message); + } + }, [scope]); + + useEffect(() => { + loadStats(); + }, [loadStats]); + + return ( +
+
+ + +
+ + {error &&

Error loading dashboard stats: {error}

} + + {stats && ( + <> +
+

Trending Requested Categories

+
+
+ +
+

Citations by Category

+
+
+ +
+

Expert Verification Activity

+
+

Citations

+
+
+
+

Citation Requests

+
+
+
+ + )} +
+ ); +} diff --git a/src/dashboard/components/ExpertFeed.jsx b/src/dashboard/components/ExpertFeed.jsx new file mode 100644 index 0000000..ec7a07a --- /dev/null +++ b/src/dashboard/components/ExpertFeed.jsx @@ -0,0 +1,52 @@ +import React, { useState, useEffect, useContext } from 'react'; +import FeedCard from './FeedCard'; +import { UserContext } from '../context/UserContext'; + +export default function ExpertFeed() { + const { user } = useContext(UserContext); + const [feed, setFeed] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!user.username) { + setLoading(false); + return; + } + let isMounted = true; + setLoading(true); + setError(null); + + window.apiGetExpertFeed(user.username) + .then(data => { if (isMounted) { setFeed(data || []); setLoading(false); } }) + .catch(err => { if (isMounted) { setError(err.message); setLoading(false); } }); + + return () => { isMounted = false; }; + }, [user.username]); + + if (!user.username) { + return

Log in to YouTube to view your expert feed.

; + } + + return ( +
+

Expert Feed

+

Requests matching the topics you're verified in.

+ + {loading &&

Loading feed...

} + {error &&

Error: {error}

} + + {!loading && !error && feed.length === 0 && ( +

No requests found for your topics.

+ )} + + {!loading && !error && feed.length > 0 && ( +
+ {feed.map(item => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/dashboard/components/FeedCard.jsx b/src/dashboard/components/FeedCard.jsx new file mode 100644 index 0000000..366c22c --- /dev/null +++ b/src/dashboard/components/FeedCard.jsx @@ -0,0 +1,80 @@ +import React from 'react'; + +const TOPIC_COLORS = { + 'Science & Technology': { bg: 'rgba(6, 95, 212, 0.12)', color: '#065fd4' }, // Blue + 'History': { bg: 'rgba(230, 81, 0, 0.12)', color: '#e65100' }, // Orange + 'Politics & News': { bg: 'rgba(194, 24, 91, 0.12)', color: '#c2185b' }, // Pink/Red + 'Education': { bg: 'rgba(0, 137, 123, 0.12)', color: '#00897b' }, // Teal + 'Health & Fitness': { bg: 'rgba(46, 204, 113, 0.12)', color: '#27ae60' }, // Green + 'Economics': { bg: 'rgba(101, 31, 255, 0.12)', color: '#651fff' }, // Purple + 'Philosophy': { bg: 'rgba(142, 68, 173, 0.12)', color: '#8e44ad' }, // Deep Purple + 'Entertainment': { bg: 'rgba(241, 196, 15, 0.12)', color: '#f39c12' }, // Yellow/Gold + 'Other': { bg: 'rgba(0, 0, 0, 0.05)', color: '#606060' } +}; + +function parseTimeString(timeStr) { + if (!timeStr) return 0; + const parts = timeStr.split(':').reverse(); + let seconds = 0; + for (let i = 0; i < parts.length; i++) { + seconds += parseInt(parts[i], 10) * Math.pow(60, i); + } + return seconds; +} + +export default function FeedCard({ item }) { + const videoTitle = item.video?.title || 'Unknown Video'; + const thumbUrl = item.video?.thumbnailUrl || 'https://via.placeholder.com/160x90?text=No+Video'; + const videoId = item.video?.videoId || ''; + const startSecs = parseTimeString(item.timestampStart); + const videoUrl = `https://youtube.com/watch?v=${videoId}&t=${startSecs}s`; + + const topics = item.topics && item.topics.length > 0 ? item.topics : ['Other']; + + return ( +
+
+ + Video Thumbnail + +
+ +
+

{item.title}

+
{videoTitle}
+ +
+ {/* primary: Topic */} + {topics.map((topic, idx) => { + const colors = TOPIC_COLORS[topic] || TOPIC_COLORS['Other']; + return ( + + {topic} + + ); + })} + + {/* secondary: grey Category tag */} + + {item.category} + +
+ +
+ ▲ {item.voteScore || 0} + {new Date(item.dateAdded).toLocaleDateString()} +
+
+ +
+ + View Video + +
+
+ ); +} \ No newline at end of file diff --git a/src/dashboard/components/GeneralFeed.jsx b/src/dashboard/components/GeneralFeed.jsx new file mode 100644 index 0000000..74d4d96 --- /dev/null +++ b/src/dashboard/components/GeneralFeed.jsx @@ -0,0 +1,69 @@ +import React, { useState, useEffect } from 'react'; +import FeedCard from './FeedCard'; + +export default function GeneralFeed() { + const [feed, setFeed] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [selectedTopic, setSelectedTopic] = useState('All'); + + const topicsList = window.TOPICS || []; + + useEffect(() => { + let isMounted = true; + setLoading(true); + setError(null); + + const fetchFeed = async () => { + try { + const res = await window.apiGetGeneralFeed(selectedTopic, 1, 20); + if (isMounted) { + setFeed(res.feed || []); + setLoading(false); + } + } catch (err) { + if (isMounted) { + setError(err.message); + setLoading(false); + } + } + }; + + fetchFeed(); + + return () => { isMounted = false; }; + }, [selectedTopic]); + + return ( +
+
+

General Citation Requests

+ +
+ + {loading &&

Loading feed...

} + {error &&

Error: {error}

} + + {!loading && !error && feed.length === 0 && ( +

No requests found for this topic.

+ )} + + {!loading && !error && feed.length > 0 && ( +
+ {feed.map(item => ( + + ))} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/dashboard/components/Notifications.jsx b/src/dashboard/components/Notifications.jsx new file mode 100644 index 0000000..1d46d07 --- /dev/null +++ b/src/dashboard/components/Notifications.jsx @@ -0,0 +1,96 @@ +import React, { useState, useEffect, useContext, useCallback } from 'react'; +import { UserContext } from '../context/UserContext'; + +const ICONS = { + new_citation: '📄', + new_request: '❓', + application_approved: '✅', + application_rejected: '❌', +}; + +export default function Notifications() { + const { user } = useContext(UserContext); + const [notifications, setNotifications] = useState([]); + const [unreadCount, setUnreadCount] = useState(0); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + if (!user.username) { setLoading(false); return; } + setLoading(true); + setError(null); + try { + const res = await window.apiGetNotifications(user.username, 1); + setNotifications(res.notifications || []); + setUnreadCount(res.unreadCount || 0); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }, [user.username]); + + useEffect(() => { + load(); + }, [load]); + + async function markRead(id) { + try { + await window.apiMarkNotificationRead(id, user.username); + setNotifications(prev => prev.map(n => n._id === id ? { ...n, read: true } : n)); + setUnreadCount(prev => Math.max(0, prev - 1)); + } catch (_) {} + } + + async function markAllRead() { + try { + await window.apiMarkAllNotificationsRead(user.username); + setNotifications(prev => prev.map(n => ({ ...n, read: true }))); + setUnreadCount(0); + } catch (_) {} + } + + if (!user.username) { + return

Log in to YouTube to see notifications.

; + } + if (loading) { + return

Loading...

; + } + if (error) { + return

Could not load notifications.

; + } + + return ( +
+
+

+ Notifications {unreadCount > 0 && {unreadCount}} +

+ {unreadCount > 0 && ( + + )} +
+ + {notifications.length === 0 ? ( +

No notifications yet.

+ ) : ( +
+ {notifications.map(n => ( +
!n.read && markRead(n._id)} + > + {ICONS[n.type] || '❌'} +
+ {n.title} + {n.category && {n.category}} + {new Date(n.createdAt).toLocaleDateString()} +
+
+ ))} +
+ )} +
+ ); +} diff --git a/src/dashboard/components/Profile.jsx b/src/dashboard/components/Profile.jsx new file mode 100644 index 0000000..b4f5d08 --- /dev/null +++ b/src/dashboard/components/Profile.jsx @@ -0,0 +1,303 @@ +import React, { useState, useEffect, useContext, useCallback } from 'react'; +import { UserContext } from '../context/UserContext'; + +function ProfileStats({ username }) { + const { user } = useContext(UserContext); + const [profile, setProfile] = useState(null); + const [stats, setStats] = useState(null); + const [expert, setExpert] = useState(null); + const [error, setError] = useState(null); + const [displayName, setDisplayName] = useState(''); + const [bio, setBio] = useState(''); + const [saveLabel, setSaveLabel] = useState('Save Profile'); + + const load = useCallback(async () => { + try { + const res = await window.apiGetProfile(username); + setProfile(res.profile); + setStats(res.stats); + setExpert(res.expert); + setDisplayName(res.profile.displayName || ''); + setBio(res.profile.bio || ''); + } catch (err) { + setError(err.message); + } + }, [username]); + + useEffect(() => { load(); }, [load]); + + async function handleSave(e) { + e.preventDefault(); + try { + await window.apiUpdateProfile(username, { + displayName: displayName.trim(), + bio: bio.trim(), + }); + setSaveLabel('Saved!'); + setTimeout(() => setSaveLabel('Save Profile'), 2000); + } catch (err) { + alert('Error saving profile: ' + err.message); + } + } + + if (error) return

Could not load profile.

; + if (!stats) return

Loading...

; + + return ( + <> +
+
{stats.citations}Citations
+
{stats.requests}Requests
+
{stats.upvotes}Upvotes
+ {expert && ( +
+ + Expert: {(expert.topics || []).join(', ')} +
+ )} +
+ +
+
+ + setDisplayName(e.target.value)} + /> +
+
+ +