Prima versione della web app BriefAI
@@ -0,0 +1,7 @@
|
||||
la data del piano pro è statica e non penso che sia registrato nel database se il piano è pro o free
|
||||
|
||||
onCancelSubscription e onUpgrade sono void (AccountSettings.tsx)
|
||||
|
||||
in feedservices.ts non capisco se il token è quello che ho impostato io o è random
|
||||
|
||||
email non va bene perchè viene accettata a prescindere anche se non ha senso
|
||||
@@ -0,0 +1,36 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# environment
|
||||
.env
|
||||
|
||||
# python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
venv/
|
||||
.venv/
|
||||
|
||||
# logs and outputs
|
||||
*.log
|
||||
output/
|
||||
@@ -0,0 +1,67 @@
|
||||
# 📰 Digital Twin News
|
||||
|
||||
Sistema di News Intelligence basato su LLM che raccoglie notizie tramite RSS,
|
||||
le analizza con AI e fornisce una dashboard personalizzata per aziende.
|
||||
|
||||
## Team
|
||||
| Membro | Ruolo |
|
||||
|--------|-------|
|
||||
| Cipolla Diego | Data Pipeline Engineer |
|
||||
| Bocca Lorenzo | Frontend & Auth Engineer |
|
||||
| Galluzzo Matteo | AI, Modelli & Logica |
|
||||
| Salvafiorita Nicolò | Integrazione AI/UI |
|
||||
|
||||
## Architettura
|
||||
```
|
||||
[RSS Feed] → [Data Pipeline] → [MongoDB + Redis]
|
||||
↓
|
||||
[AI Analysis (Claude API)]
|
||||
↓
|
||||
[Backend API (Express)]
|
||||
↓
|
||||
[Frontend Dashboard (React)]
|
||||
```
|
||||
|
||||
## Stack tecnologico
|
||||
| Layer | Tecnologie |
|
||||
|-------|-----------|
|
||||
| Data Pipeline | Node.js, RSS Parser, Redis, node-cron |
|
||||
| Storage | MongoDB, Redis |
|
||||
| AI & Analytics | Python, Claude API, NLTK |
|
||||
| Backend | Node.js, Express, JWT |
|
||||
| Frontend | React, Material UI, Chart.js, Recharts |
|
||||
| Infrastruttura | Docker Compose |
|
||||
|
||||
## Avvio rapido
|
||||
```bash
|
||||
# 1. Avvia i database
|
||||
docker-compose up -d mongo redis
|
||||
|
||||
# 2. AI Analysis
|
||||
cd ai-analysis
|
||||
source venv/bin/activate
|
||||
python src/scheduler.py
|
||||
|
||||
# 3. Backend API
|
||||
cd backend
|
||||
npm run dev
|
||||
|
||||
# 4. Data Pipeline
|
||||
cd data-pipeline
|
||||
npm start
|
||||
|
||||
# 5. Frontend
|
||||
cd frontend
|
||||
npm start
|
||||
```
|
||||
|
||||
## KPI del sistema
|
||||
- Frequenza aggiornamento: ogni 15 minuti
|
||||
- Batch AI: 10 articoli per ciclo
|
||||
- Autenticazione: JWT 24h
|
||||
- Fallback locale: attivo se API LLM non disponibile
|
||||
|
||||
## Limitazioni documentate
|
||||
- Sistema batch-based (non real-time puro)
|
||||
- Free tier API LLM gestito con fallback locale
|
||||
- Testing automatizzato fuori scope MVP
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "src/server.js",
|
||||
"scripts": {
|
||||
"start": "node src/server.js",
|
||||
"dev": "nodemon src/server.js"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "commonjs",
|
||||
"dependencies": {
|
||||
"bcryptjs": "^3.0.3",
|
||||
"cors": "^2.8.6",
|
||||
"dotenv": "^17.4.1",
|
||||
"express": "^5.2.1",
|
||||
"helmet": "^8.1.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"mongoose": "^9.4.1",
|
||||
"morgan": "^1.10.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.14"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
require('dotenv').config();
|
||||
|
||||
module.exports = function auth(req, res, next) {
|
||||
const token = req.header('Authorization')?.replace('Bearer ', '');
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: 'Accesso negato. Token mancante.',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
req.user = decoded;
|
||||
return next();
|
||||
} catch (err) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: 'Token non valido o scaduto.',
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const articleSchema = new mongoose.Schema(
|
||||
{
|
||||
uniqueKey: { type: String, unique: true, required: true },
|
||||
title: { type: String, required: true },
|
||||
url: String,
|
||||
pubDate: String,
|
||||
source: String,
|
||||
category: String,
|
||||
content: String,
|
||||
|
||||
summary: String,
|
||||
sentiment: {
|
||||
type: String,
|
||||
enum: ['Positive', 'Neutral', 'Negative', null],
|
||||
default: null,
|
||||
},
|
||||
entities: [String],
|
||||
trendingTopics: [String],
|
||||
macroTopics: [String],
|
||||
|
||||
status: { type: String, enum: ['raw', 'processed'], default: 'raw' },
|
||||
aiProcessed: { type: Boolean, default: false },
|
||||
aiError: String,
|
||||
processedAt: Date,
|
||||
createdAt: { type: Date, default: Date.now },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
module.exports = mongoose.model('Article', articleSchema);
|
||||
@@ -0,0 +1,77 @@
|
||||
const mongoose = require('mongoose');
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
// Register model early so post-save hook can resolve it safely.
|
||||
require('./UserProfile');
|
||||
|
||||
const userSchema = new mongoose.Schema(
|
||||
{
|
||||
userId: { type: String, unique: true, required: true },
|
||||
email: { type: String, unique: true, required: true },
|
||||
password: { type: String, required: true },
|
||||
username: { type: String, unique: true, required: true },
|
||||
role: { type: String, enum: ['admin', 'user'], default: 'user' },
|
||||
|
||||
macroTopics: [String],
|
||||
keywords: [String],
|
||||
weights: {
|
||||
type: Map,
|
||||
of: Number,
|
||||
default: () =>
|
||||
new Map([
|
||||
['tech', 1.0],
|
||||
['news', 1.0],
|
||||
['social', 1.0],
|
||||
['news-it', 1.0],
|
||||
['general', 1.0],
|
||||
]),
|
||||
},
|
||||
|
||||
|
||||
preferredSources: [String],
|
||||
|
||||
lastFeedGeneratedAt: Date,
|
||||
createdAt: { type: Date, default: Date.now },
|
||||
updatedAt: { type: Date, default: Date.now },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
userSchema.pre('save', async function preSave() {
|
||||
if (!this.isModified('password')) return;
|
||||
this.password = await bcrypt.hash(this.password, 10);
|
||||
});
|
||||
|
||||
userSchema.methods.comparePassword = async function comparePassword(candidatePassword) {
|
||||
return bcrypt.compare(candidatePassword, this.password);
|
||||
};
|
||||
|
||||
userSchema.post('save', async function syncUserProfile(doc) {
|
||||
const UserProfile = mongoose.model('UserProfile');
|
||||
|
||||
const weightsObject = {};
|
||||
if (doc.weights instanceof Map) {
|
||||
doc.weights.forEach((value, key) => {
|
||||
weightsObject[key] = value;
|
||||
});
|
||||
}
|
||||
|
||||
await UserProfile.updateOne(
|
||||
{ userId: doc.userId },
|
||||
{
|
||||
$set: {
|
||||
userId: doc.userId,
|
||||
macroTopics: doc.macroTopics || ['Scienza & Ricerca'],
|
||||
keywords: doc.keywords || [],
|
||||
weights: weightsObject,
|
||||
// sentimentPreference removed: we no longer persist a global sentiment preference
|
||||
preferredSources: doc.preferredSources || [],
|
||||
lastFeedGeneratedAt: doc.lastFeedGeneratedAt || null,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
},
|
||||
{ upsert: true }
|
||||
);
|
||||
});
|
||||
|
||||
module.exports = mongoose.model('User', userSchema);
|
||||
@@ -0,0 +1,25 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const userProfileSchema = new mongoose.Schema(
|
||||
{
|
||||
userId: { type: String, unique: true, required: true },
|
||||
macroTopics: { type: [String], default: ['Scienza & Ricerca'] },
|
||||
keywords: { type: [String], default: [] },
|
||||
weights: {
|
||||
type: Object,
|
||||
default: {
|
||||
tech: 1.0,
|
||||
news: 1.0,
|
||||
social: 1.0,
|
||||
'news-it': 1.0,
|
||||
general: 1.0,
|
||||
},
|
||||
},
|
||||
preferredSources: { type: [String], default: [] },
|
||||
lastFeedGeneratedAt: Date,
|
||||
updatedAt: { type: Date, default: Date.now },
|
||||
},
|
||||
{ collection: 'user_profiles', timestamps: false }
|
||||
);
|
||||
|
||||
module.exports = mongoose.model('UserProfile', userProfileSchema);
|
||||
@@ -0,0 +1,82 @@
|
||||
const express = require('express');
|
||||
const Article = require('../models/Article');
|
||||
const auth = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/', auth, async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
category,
|
||||
sentiment,
|
||||
source,
|
||||
status = 'processed',
|
||||
limit = 50,
|
||||
page = 1,
|
||||
search,
|
||||
} = req.query;
|
||||
|
||||
const filter = { status };
|
||||
|
||||
if (category) filter.category = category;
|
||||
if (sentiment) filter.sentiment = sentiment;
|
||||
if (source) filter.source = source;
|
||||
if (search) {
|
||||
filter.$or = [
|
||||
{ title: { $regex: search, $options: 'i' } },
|
||||
{ summary: { $regex: search, $options: 'i' } },
|
||||
];
|
||||
}
|
||||
|
||||
const parsedLimit = Number.parseInt(limit, 10) || 50;
|
||||
const parsedPage = Number.parseInt(page, 10) || 1;
|
||||
|
||||
const articles = await Article.find(filter)
|
||||
.sort({ pubDate: -1 })
|
||||
.limit(parsedLimit)
|
||||
.skip((parsedPage - 1) * parsedLimit)
|
||||
.select('-__v');
|
||||
|
||||
const total = await Article.countDocuments(filter);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
articles,
|
||||
total,
|
||||
page: parsedPage,
|
||||
limit: parsedLimit,
|
||||
totalPages: Math.ceil(total / parsedLimit),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Articles List Error]', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:uniqueKey', auth, async (req, res) => {
|
||||
try {
|
||||
const article = await Article.findOne({ uniqueKey: req.params.uniqueKey });
|
||||
|
||||
if (!article) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Articolo non trovato.',
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
article,
|
||||
});
|
||||
} catch (err) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,161 @@
|
||||
const express = require('express');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const User = require('../models/User');
|
||||
const auth = require('../middleware/auth');
|
||||
|
||||
require('dotenv').config();
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.post('/register', async (req, res) => {
|
||||
try {
|
||||
const { email, password, username } = req.body;
|
||||
|
||||
// Support payloads that send preferences nested under `preferences` (frontend)
|
||||
// or as top-level fields for backwards compatibility.
|
||||
const pref = req.body && req.body.preferences ? req.body.preferences : {}
|
||||
const rawMacroTopics = Array.isArray(pref.macroTopics) ? pref.macroTopics : req.body.macroTopics
|
||||
const rawKeywords = Array.isArray(pref.keywords) ? pref.keywords : req.body.keywords
|
||||
|
||||
const macroTopics = Array.isArray(rawMacroTopics) && rawMacroTopics.length
|
||||
? rawMacroTopics
|
||||
: ['Scienza & Ricerca']
|
||||
|
||||
const keywords = Array.isArray(rawKeywords) ? rawKeywords : []
|
||||
|
||||
if (!email || !password || !username) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Email, password e username sono obbligatori.',
|
||||
});
|
||||
}
|
||||
|
||||
const existingUser = await User.findOne({
|
||||
$or: [{ email }, { username }],
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Email o username gia registrati.',
|
||||
});
|
||||
}
|
||||
|
||||
const userId = `user_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
|
||||
|
||||
const user = new User({
|
||||
userId,
|
||||
email,
|
||||
password,
|
||||
username,
|
||||
macroTopics,
|
||||
keywords,
|
||||
});
|
||||
|
||||
await user.save();
|
||||
|
||||
// Genera token JWT al momento della registrazione per evitare login separato
|
||||
const token = jwt.sign(
|
||||
{
|
||||
userId: user.userId,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
},
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '24h' }
|
||||
);
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
message: 'Utente registrato con successo.',
|
||||
token,
|
||||
userId: user.userId,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Auth Register Error]', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/login', async (req, res) => {
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
|
||||
if (!email || !password) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Email e password sono obbligatori.',
|
||||
});
|
||||
}
|
||||
|
||||
const user = await User.findOne({ email });
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Utente non trovato.',
|
||||
});
|
||||
}
|
||||
|
||||
const valid = await user.comparePassword(password);
|
||||
if (!valid) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: 'Password errata.',
|
||||
});
|
||||
}
|
||||
|
||||
const token = jwt.sign(
|
||||
{
|
||||
userId: user.userId,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
},
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '24h' }
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
token,
|
||||
userId: user.userId,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Auth Login Error]', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/me', auth, async (req, res) => {
|
||||
try {
|
||||
const user = await User.findOne({ userId: req.user.userId }).select('-password');
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Utente non trovato.',
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
user,
|
||||
});
|
||||
} catch (err) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,110 @@
|
||||
const express = require('express');
|
||||
const User = require('../models/User');
|
||||
const UserProfile = require('../models/UserProfile');
|
||||
const auth = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/', auth, async (req, res) => {
|
||||
try {
|
||||
const user = await User.findOne({ userId: req.user.userId }).select('-password');
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Profilo non trovato.',
|
||||
});
|
||||
}
|
||||
|
||||
// Read associated UserProfile (may be created/updated by n8n)
|
||||
const userProfile = await UserProfile.findOne({ userId: user.userId }).lean();
|
||||
|
||||
// Convert weights map (from User model) to plain object
|
||||
const userWeights = user.weights instanceof Map ? Object.fromEntries(user.weights) : user.weights || {};
|
||||
|
||||
// Build merged profile: prefer values from userProfile when available
|
||||
const profile = {
|
||||
userId: user.userId,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
macroTopics: user.macroTopics || [],
|
||||
keywords: user.keywords || [],
|
||||
weights: userWeights,
|
||||
preferredSources: user.preferredSources || [],
|
||||
lastFeedGeneratedAt: user.lastFeedGeneratedAt || null,
|
||||
};
|
||||
|
||||
if (userProfile) {
|
||||
profile.macroTopics = userProfile.macroTopics || profile.macroTopics;
|
||||
profile.keywords = userProfile.keywords || profile.keywords;
|
||||
profile.weights = userProfile.weights || profile.weights;
|
||||
profile.preferredSources = userProfile.preferredSources || profile.preferredSources;
|
||||
profile.lastFeedGeneratedAt = userProfile.lastFeedGeneratedAt || profile.lastFeedGeneratedAt;
|
||||
}
|
||||
|
||||
return res.json({ success: true, profile });
|
||||
} catch (err) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/', auth, async (req, res) => {
|
||||
try {
|
||||
const { macroTopics, keywords, preferredSources } = req.body;
|
||||
|
||||
const user = await User.findOne({ userId: req.user.userId });
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Utente non trovato.',
|
||||
});
|
||||
}
|
||||
|
||||
if (macroTopics) user.macroTopics = macroTopics;
|
||||
if (keywords) user.keywords = keywords;
|
||||
if (preferredSources) user.preferredSources = preferredSources;
|
||||
|
||||
user.updatedAt = new Date();
|
||||
await user.save();
|
||||
|
||||
// Sync su user_profiles in modo che n8n e backend siano allineati
|
||||
try {
|
||||
await UserProfile.findOneAndUpdate(
|
||||
{ userId: user.userId },
|
||||
{
|
||||
macroTopics: user.macroTopics,
|
||||
keywords: user.keywords,
|
||||
preferredSources: user.preferredSources,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{ upsert: true }
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('[Profile Sync] Impossibile aggiornare user_profiles:', e.message || e);
|
||||
}
|
||||
|
||||
const weights = user.weights instanceof Map ? Object.fromEntries(user.weights) : user.weights || {};
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: 'Profilo aggiornato con successo.',
|
||||
profile: {
|
||||
userId: user.userId,
|
||||
macroTopics: user.macroTopics,
|
||||
keywords: user.keywords,
|
||||
weights,
|
||||
preferredSources: user.preferredSources,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,88 @@
|
||||
const express = require('express');
|
||||
const Article = require('../models/Article');
|
||||
const auth = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/sentiment', auth, async (req, res) => {
|
||||
try {
|
||||
const result = await Article.aggregate([
|
||||
{ $match: { status: 'processed' } },
|
||||
{ $group: { _id: '$sentiment', count: { $sum: 1 } } },
|
||||
]);
|
||||
|
||||
return res.json({ success: true, data: result });
|
||||
} catch (err) {
|
||||
return res.status(500).json({ success: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/categories', auth, async (req, res) => {
|
||||
try {
|
||||
const result = await Article.aggregate([
|
||||
{ $match: { status: 'processed' } },
|
||||
{ $group: { _id: '$category', count: { $sum: 1 } } },
|
||||
{ $sort: { count: -1 } },
|
||||
]);
|
||||
|
||||
return res.json({ success: true, data: result });
|
||||
} catch (err) {
|
||||
return res.status(500).json({ success: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/trending', auth, async (req, res) => {
|
||||
try {
|
||||
const result = await Article.aggregate([
|
||||
{ $match: { status: 'processed' } },
|
||||
{ $unwind: '$trendingTopics' },
|
||||
{ $group: { _id: '$trendingTopics', count: { $sum: 1 } } },
|
||||
{ $sort: { count: -1 } },
|
||||
{ $limit: 10 },
|
||||
]);
|
||||
|
||||
return res.json({ success: true, data: result });
|
||||
} catch (err) {
|
||||
return res.status(500).json({ success: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/sources', auth, async (req, res) => {
|
||||
try {
|
||||
const result = await Article.aggregate([
|
||||
{ $match: { status: 'processed' } },
|
||||
{ $group: { _id: '$source', count: { $sum: 1 } } },
|
||||
{ $sort: { count: -1 } },
|
||||
]);
|
||||
|
||||
return res.json({ success: true, data: result });
|
||||
} catch (err) {
|
||||
return res.status(500).json({ success: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/overview', auth, async (req, res) => {
|
||||
try {
|
||||
const total = await Article.countDocuments({});
|
||||
const processed = await Article.countDocuments({ status: 'processed' });
|
||||
const raw = await Article.countDocuments({ status: 'raw' });
|
||||
|
||||
const last24h = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
const recentArticles = await Article.countDocuments({ createdAt: { $gte: last24h } });
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
total,
|
||||
processed,
|
||||
raw,
|
||||
recentArticles,
|
||||
processingRate: total > 0 ? ((processed / total) * 100).toFixed(1) : 0,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
return res.status(500).json({ success: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,96 @@
|
||||
const express = require('express');
|
||||
const mongoose = require('mongoose');
|
||||
const cors = require('cors');
|
||||
const helmet = require('helmet');
|
||||
const morgan = require('morgan');
|
||||
|
||||
require('dotenv').config();
|
||||
|
||||
const authRoutes = require('./routes/auth');
|
||||
const articleRoutes = require('./routes/articles');
|
||||
const profileRoutes = require('./routes/profile');
|
||||
const statsRoutes = require('./routes/stats');
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use(helmet());
|
||||
const allowedOrigins = process.env.CORS_ORIGIN
|
||||
? process.env.CORS_ORIGIN.split(',')
|
||||
: process.env.NODE_ENV === 'production'
|
||||
? [process.env.FRONTEND_URL || 'https://your-production-domain.com']
|
||||
: ['http://localhost:5173'];
|
||||
|
||||
app.use(
|
||||
cors({
|
||||
origin: (origin, callback) => {
|
||||
if (!origin || allowedOrigins.includes(origin)) return callback(null, true);
|
||||
return callback(new Error('Not allowed by CORS'));
|
||||
},
|
||||
credentials: true,
|
||||
})
|
||||
);
|
||||
app.use(morgan('dev'));
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/articles', articleRoutes);
|
||||
app.use('/api/profile', profileRoutes);
|
||||
app.use('/api/stats', statsRoutes);
|
||||
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
service: 'BriefAI Backend',
|
||||
version: '1.0.0',
|
||||
});
|
||||
});
|
||||
|
||||
app.use((req, res) => {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: 'Endpoint non trovato.',
|
||||
});
|
||||
});
|
||||
|
||||
app.use((err, req, res, next) => {
|
||||
console.error('[Server Error]', err.stack);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: process.env.NODE_ENV === 'production' ? 'Errore interno del server.' : err.message,
|
||||
});
|
||||
if (typeof next === 'function') {
|
||||
return next(err);
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const port = Number.parseInt(process.env.PORT, 10) || 5000;
|
||||
|
||||
mongoose
|
||||
.connect(process.env.MONGO_URI)
|
||||
.then(() => {
|
||||
console.log('Connesso a MongoDB Atlas');
|
||||
app.listen(port, () => {
|
||||
console.log(`Backend BriefAI attivo su http://localhost:${port}`);
|
||||
console.log('Endpoints disponibili:');
|
||||
console.log('POST /api/auth/register');
|
||||
console.log('POST /api/auth/login');
|
||||
console.log('GET /api/auth/me');
|
||||
console.log('GET /api/articles');
|
||||
console.log('GET /api/articles/:uniqueKey');
|
||||
console.log('GET /api/profile');
|
||||
console.log('PUT /api/profile');
|
||||
console.log('GET /api/stats/sentiment');
|
||||
console.log('GET /api/stats/categories');
|
||||
console.log('GET /api/stats/trending');
|
||||
console.log('GET /api/stats/sources');
|
||||
console.log('GET /api/stats/overview');
|
||||
console.log('GET /health');
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Errore connessione MongoDB:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
# version: '3.8'
|
||||
|
||||
# services:
|
||||
# n8n:
|
||||
# image: n8nio/n8n:latest
|
||||
# ports:
|
||||
# - "5678:5678"
|
||||
# environment:
|
||||
# - N8N_BASIC_AUTH_ACTIVE=true
|
||||
# - N8N_BASIC_AUTH_USER=admin
|
||||
# - N8N_BASIC_AUTH_PASSWORD=root
|
||||
# - JWT_SECRET=changeme_replace_with_secure_value
|
||||
# volumes:
|
||||
# - n8n_data:/home/node/.n8n
|
||||
|
||||
# volumes:
|
||||
# n8n_data:
|
||||
@@ -0,0 +1,40 @@
|
||||
// Snippet: JWT and password helpers for n8n Code nodes
|
||||
// Usage: paste the needed functions into a Code node at the start of your webhook workflows.
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
function base64url(bufOrStr){
|
||||
const b = Buffer.isBuffer(bufOrStr) ? bufOrStr : Buffer.from(String(bufOrStr));
|
||||
return b.toString('base64').replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,'');
|
||||
}
|
||||
|
||||
function signJWT(payload, secret, expiresInSec=86400){
|
||||
const header = base64url(JSON.stringify({ alg:'HS256', typ:'JWT' }));
|
||||
const exp = Math.floor(Date.now()/1000) + expiresInSec;
|
||||
const body = base64url(JSON.stringify({ ...payload, exp }));
|
||||
const sig = base64url(crypto.createHmac('sha256', secret).update(`${header}.${body}`).digest());
|
||||
return `${header}.${body}.${sig}`;
|
||||
}
|
||||
|
||||
function verifyJWT(token, secret){
|
||||
try {
|
||||
const [h,b,s] = token.split('.');
|
||||
const expected = base64url(crypto.createHmac('sha256', secret).update(`${h}.${b}`).digest());
|
||||
if (expected !== s) return null;
|
||||
const payload = JSON.parse(Buffer.from(b.replace(/-/g,'+').replace(/_/g,'/'),'base64').toString());
|
||||
if (payload.exp && Math.floor(Date.now()/1000) > payload.exp) return null;
|
||||
return payload;
|
||||
} catch(e){ return null; }
|
||||
}
|
||||
|
||||
function hashPassword(password){
|
||||
const salt = crypto.randomBytes(16).toString('hex');
|
||||
const derived = crypto.scryptSync(password, salt, 64).toString('hex');
|
||||
return { salt, hash: derived };
|
||||
}
|
||||
|
||||
function verifyPassword(password, salt, hash){
|
||||
return crypto.scryptSync(password, salt, 64).toString('hex') === hash;
|
||||
}
|
||||
|
||||
module.exports = { signJWT, verifyJWT, hashPassword, verifyPassword };
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@mui/icons-material": "^7.3.9",
|
||||
"@mui/material": "^7.3.9",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"axios": "^1.13.6",
|
||||
"chart.js": "^4.5.1",
|
||||
"react": "^19.2.4",
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-router-dom": "^7.13.1",
|
||||
"react-scripts": "^5.0.1",
|
||||
"recharts": "^3.8.0",
|
||||
"web-vitals": "^2.1.4"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,43 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Web site created using create-react-app"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
Only files inside the `public` folder can be referenced from the HTML.
|
||||
|
||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>React App</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
You can add webfonts, meta tags, or analytics to this file.
|
||||
The build step will place the bundled scripts into the <body> tag.
|
||||
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
</body>
|
||||
</html>
|
||||
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"short_name": "React App",
|
||||
"name": "Create React App Sample",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
@@ -0,0 +1,19 @@
|
||||
@echo off
|
||||
cd /d "%~dp0"
|
||||
|
||||
echo Starting n8n with Docker...
|
||||
docker-compose up -d n8n
|
||||
|
||||
echo Starting Data Pipeline...
|
||||
start "data-pipeline" cmd /k "cd briefai\data-pipeline && npm start"
|
||||
|
||||
echo Starting AI Analysis...
|
||||
start "ai-analysis" cmd /k "cd ai-analysis && call venv\Scripts\activate.bat && python src\scheduler.py"
|
||||
|
||||
echo Starting Backend...
|
||||
start "backend" cmd /k "cd backend && npm run dev"
|
||||
|
||||
echo Starting Frontend...
|
||||
start "frontend" cmd /k "cd frontend && npm start"
|
||||
|
||||
exit /b 0
|
||||
@@ -0,0 +1,2 @@
|
||||
VITE_API_URL=http://localhost:5000
|
||||
VITE_N8N_URL=https://n8n-cipolla.ampere.lucasacchi.net/webhook
|
||||
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,75 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is enabled on this template. See [this documentation](https://react.dev/learn/react-compiler) for more information.
|
||||
|
||||
Note: This will impact Vite dev & build performances.
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
@@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend-briefai</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "frontend-briefai",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-router-dom": "^7.14.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.29.0",
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@rolldown/plugin-babel": "^0.2.3",
|
||||
"@types/babel__core": "^7.20.5",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.5.0",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.58.2",
|
||||
"vite": "^8.0.9"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,14 @@
|
||||
.app-shell {
|
||||
width: 100%;
|
||||
height: 100dvh;
|
||||
display: flex;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.app-shell > * {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useState } from 'react'
|
||||
import type { ReactElement } from 'react'
|
||||
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||
import './App.css'
|
||||
import FeedPage from './pages/FeedPage'
|
||||
import HomePage from './pages/HomePage'
|
||||
import LoginPage from './pages/LoginPage'
|
||||
import OnboardingPage from './pages/OnboardingPage'
|
||||
import RegisterPage from './pages/RegisterPage'
|
||||
import SettingsPage from './pages/SettingsPage'
|
||||
import TrendsPage from './pages/TrendsPage'
|
||||
|
||||
function App() {
|
||||
// Stato minimo di autenticazione usato per abilitare/bloccare le route private.
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
||||
|
||||
return (
|
||||
<main className="app-shell">
|
||||
<Routes>
|
||||
{/* Home pubblica: prima pagina visibile a utenti non autenticati. */}
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route
|
||||
path="/login"
|
||||
element={<LoginPage onLoginSuccess={() => setIsAuthenticated(true)} />}
|
||||
/>
|
||||
{/* Route pubblica: consente la registrazione di un nuovo utente. */}
|
||||
<Route
|
||||
path="/register"
|
||||
element={<RegisterPage onRegisterSuccess={() => setIsAuthenticated(true)} />}
|
||||
/>
|
||||
<Route path="/onboarding" element={<OnboardingPage />} />
|
||||
{/* Compatibilita: vecchio path /home reindirizzato al nuovo onboarding. */}
|
||||
<Route path="/home" element={<Navigate to="/onboarding" replace />} />
|
||||
<Route
|
||||
path="/feed"
|
||||
element={
|
||||
<ProtectedRoute isAuthenticated={isAuthenticated}>
|
||||
<FeedPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/tendenze"
|
||||
element={
|
||||
<ProtectedRoute isAuthenticated={isAuthenticated}>
|
||||
<TrendsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/impostazioni"
|
||||
element={
|
||||
<ProtectedRoute isAuthenticated={isAuthenticated}>
|
||||
<SettingsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
type ProtectedRouteProps = {
|
||||
isAuthenticated: boolean
|
||||
children: ReactElement
|
||||
}
|
||||
|
||||
function ProtectedRoute({ isAuthenticated, children }: ProtectedRouteProps) {
|
||||
// Guard tecnica della route: se l'utente non e autenticato, verifica anche
|
||||
// la presenza di un token nel localStorage come fallback per evitare
|
||||
// condizioni di gara (es. subito dopo la registrazione).
|
||||
const token = localStorage.getItem('briefai_token')
|
||||
if (!isAuthenticated && !token) {
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
||||
|
||||
export default App
|
||||
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,75 @@
|
||||
type SubscriptionState = 'free' | 'pro'
|
||||
|
||||
type AccountSettingsProps = {
|
||||
username: string
|
||||
email: string
|
||||
subscriptionState: SubscriptionState
|
||||
onUpgrade: () => void
|
||||
onCancelSubscription: () => void
|
||||
}
|
||||
|
||||
// Sezione account: mostra profilo e gestisce in modo condizionale il piano attivo.
|
||||
function AccountSettings({username, email, subscriptionState, onUpgrade, onCancelSubscription}: AccountSettingsProps) {
|
||||
const isProPlan = subscriptionState === 'pro'
|
||||
|
||||
return (
|
||||
<section className="settings-stack" aria-label="Profilo">
|
||||
<section className="settings-card" aria-label="Informazioni profilo">
|
||||
<header className="settings-section-header">
|
||||
<div>
|
||||
<h2>Informazioni profilo</h2>
|
||||
<p>Controlla i dati di base associati al tuo account.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="profile-info-grid">
|
||||
<div>
|
||||
<span className="profile-info-label">Email</span>
|
||||
<p className="profile-info-value">{email}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="profile-info-label">Nome utente</span>
|
||||
<p className="profile-info-value">{username}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="settings-card" aria-label="Gestione abbonamento">
|
||||
<header className="settings-section-header">
|
||||
<div>
|
||||
<h2>Gestione abbonamento</h2>
|
||||
<p>Gestisci il piano attualmente associato al tuo profilo.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="subscription-box">
|
||||
<div className="subscription-copy">
|
||||
<span className={`plan-badge ${isProPlan ? 'pro' : 'free'}`}>
|
||||
{isProPlan ? 'Piano Pro' : 'Piano Gratuito'}
|
||||
</span>
|
||||
{isProPlan ? (
|
||||
<p>Scade il 12/12/2026</p>
|
||||
) : (
|
||||
<p>Passa al Pro per sbloccare monitoraggio e analisi avanzate.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="subscription-actions">
|
||||
{isProPlan ? (
|
||||
<button type="button" className="subscription-link-button" onClick={onCancelSubscription}>
|
||||
Annulla abbonamento
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className="subscription-upgrade-button" onClick={onUpgrade}>
|
||||
Passa a Pro
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export type { SubscriptionState }
|
||||
export default AccountSettings
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import MagicCard from './MagicCard'
|
||||
import { fetchPersonalizedFeed } from '../services/feedService'
|
||||
import type { Article } from '../types/article'
|
||||
|
||||
type FeedContentProps = {
|
||||
sentimentFilter?: string | null
|
||||
topicsFilter?: string | null
|
||||
}
|
||||
|
||||
function FeedContent({ sentimentFilter = null, topicsFilter = null }: FeedContentProps) {
|
||||
const [articles, setArticles] = useState<Article[]>([])
|
||||
const [status, setStatus] = useState<'loading' | 'ok' | 'error'>('loading')
|
||||
|
||||
// Prende il fetch del feed personalizzato e popola la variabile di stato con i dati
|
||||
useEffect(() => {fetchPersonalizedFeed().then((data) => {
|
||||
setArticles(data)
|
||||
setStatus('ok')
|
||||
})
|
||||
.catch(() => setStatus('error'))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<section className="feed-content" aria-label="Contenuto feed">
|
||||
<header className="feed-header">
|
||||
<p className="feed-kicker">BriefAI Notizie</p>
|
||||
<h1>Il tuo flusso di intelligenza</h1>
|
||||
<p>Notizie personalizzate con approfondimenti AI</p>
|
||||
</header>
|
||||
{/* Gestione degli stati*/}
|
||||
{status === 'loading' && <p>Caricamento feed...</p>}
|
||||
{status === 'error' && <p>Errore nel caricamento del feed.</p>}
|
||||
{/*Render della lista di articoli se il caricamento è andato a buon fine*/}
|
||||
{status === 'ok' && (
|
||||
<>
|
||||
{/* Client-side filtering */}
|
||||
{(() => {
|
||||
const matchesTopic = (a: Article) => {
|
||||
if (!topicsFilter || topicsFilter === 'All Topics') return true
|
||||
const topic = topicsFilter.toLowerCase()
|
||||
const catMatch = (a.category || '').toLowerCase() === topic
|
||||
const trending = (a.trendingTopics || []).some((t) =>
|
||||
String(t || '').toLowerCase() === topic
|
||||
)
|
||||
const macroTopicMatch = (a.macroTopics || []).some((t) =>
|
||||
String(t || '').toLowerCase() === topic
|
||||
)
|
||||
return catMatch || trending || macroTopicMatch
|
||||
}
|
||||
|
||||
const matchesSentiment = (a: Article) => {
|
||||
if (!sentimentFilter || sentimentFilter === 'All Sentiment') return true
|
||||
return a.sentiment === sentimentFilter
|
||||
}
|
||||
|
||||
const filtered = articles.filter((a) => matchesSentiment(a) && matchesTopic(a))
|
||||
|
||||
return (
|
||||
<div>
|
||||
{filtered.length > 0 ? (
|
||||
<>
|
||||
<div className="feed-list">
|
||||
{filtered.map((a) => (
|
||||
<MagicCard
|
||||
key={a.uniqueKey}
|
||||
articleId={a.uniqueKey}
|
||||
source={a.source}
|
||||
timeAgo={new Date(a.pubDate).toLocaleString()}
|
||||
sentiment={
|
||||
a.sentiment === 'Positive'
|
||||
? 'Positivo'
|
||||
: a.sentiment === 'Negative'
|
||||
? 'Negativo'
|
||||
: 'Neutrale'
|
||||
}
|
||||
title={a.title}
|
||||
summary={a.summary}
|
||||
tags={a.macroTopics?.length ? a.macroTopics : (a.trendingTopics || [a.category])}
|
||||
entities={a.entities || []}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="feed-filter-info">
|
||||
Mostrando {filtered.length} di {articles.length} articoli
|
||||
{(sentimentFilter && sentimentFilter !== 'All Sentiment') ||
|
||||
(topicsFilter && topicsFilter !== 'All Topics')
|
||||
? ` con${sentimentFilter && sentimentFilter !== 'All Sentiment' ? ` sentiment ${sentimentFilter}` : ''}${
|
||||
sentimentFilter && topicsFilter ? ' e' : ''
|
||||
}${topicsFilter && topicsFilter !== 'All Topics' ? ` topic ${topicsFilter}` : ''}`
|
||||
: ''}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="feed-no-results">
|
||||
Nessun articolo trovato con i filtri selezionati
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="feed-footer">
|
||||
<button type="button" className="load-more-button">
|
||||
Carica altre notizie
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default FeedContent
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
type FeedSidebarProps = {
|
||||
activeItem?: 'feed' | 'tendenze' | 'impostazioni'
|
||||
}
|
||||
|
||||
// Sidebar principale del feed: gestisce brand, navigazione e blocco profilo.
|
||||
const navigationItems = [
|
||||
{ id: 'feed', label: 'Notizie', icon: LayoutDashboardIcon },
|
||||
{ id: 'tendenze', label: 'Tendenze', icon: TrendingUpIcon },
|
||||
{ id: 'impostazioni', label: 'Impostazioni', icon: SettingsIcon },
|
||||
] as const
|
||||
|
||||
function FeedSidebar({ activeItem = 'feed' }: FeedSidebarProps) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<aside className="feed-sidebar" aria-label="Navigazione principale">
|
||||
{/* Logo BreafAI. */}
|
||||
<div className="sidebar-brand">
|
||||
<div className="brand-mark" aria-hidden="true">
|
||||
<SparklesIcon />
|
||||
</div>
|
||||
<div className="brand-text">BriefAI</div>
|
||||
</div>
|
||||
|
||||
{/* Menu di navigazione laterale. */}
|
||||
<nav className="sidebar-nav" aria-label="Menu notizie">
|
||||
{navigationItems.map((item) => {
|
||||
const isActive = item.id === activeItem
|
||||
const Icon = item.icon
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={`sidebar-nav-item ${isActive ? 'active' : ''}`}
|
||||
onClick={() => navigate(item.id === 'feed' ? '/feed' : `/${item.id}`)}
|
||||
>
|
||||
<Icon />
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Box profilo: resta agganciato in basso. */}
|
||||
<div className="sidebar-profile">
|
||||
<div className="profile-avatar" aria-hidden="true">
|
||||
JD
|
||||
</div>
|
||||
<div className="profile-meta">
|
||||
<strong>John Doe</strong>
|
||||
<span>john@example.com</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
function SparklesIcon() {
|
||||
return <span aria-hidden="true">✦</span>
|
||||
}
|
||||
|
||||
function LayoutDashboardIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<rect x="3" y="3" width="8" height="8" rx="2" />
|
||||
<rect x="13" y="3" width="8" height="5" rx="2" />
|
||||
<rect x="13" y="10" width="8" height="11" rx="2" />
|
||||
<rect x="3" y="13" width="8" height="8" rx="2" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function TrendingUpIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M3 17l6-6 4 4 8-8" />
|
||||
<path d="M14 7h7v7" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingsIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 8.5a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7Z" />
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.05.05a2 2 0 0 1-1.42 3.41 2 2 0 0 1-1.42-.59l-.06-.05a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.08a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.05a2 2 0 0 1-2.84-2.83l.05-.05A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.05-.05A2 2 0 0 1 5.64 3.72a2 2 0 0 1 1.42.59l.06.05a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.08a1.65 1.65 0 0 0 1 1.51h.06a1.65 1.65 0 0 0 1.82-.33l.06-.05a2 2 0 0 1 2.83 2.83l-.05.05A1.65 1.65 0 0 0 20.4 9H21a2 2 0 0 1 0 4h-.08a1.65 1.65 0 0 0-1.51 1Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default FeedSidebar
|
||||
@@ -0,0 +1,65 @@
|
||||
type FeedTopbarProps = {
|
||||
showFeedFilters?: boolean
|
||||
sentimentFilter?: string | null
|
||||
onSentimentChange?: (sentiment: string) => void
|
||||
topicsFilter?: string | null
|
||||
onTopicChange?: (topic: string) => void
|
||||
}
|
||||
|
||||
function FeedTopbar({
|
||||
showFeedFilters = false,
|
||||
sentimentFilter = null,
|
||||
onSentimentChange,
|
||||
topicsFilter = null,
|
||||
onTopicChange,
|
||||
}: FeedTopbarProps) {
|
||||
return (
|
||||
<header
|
||||
className={`feed-topbar ${showFeedFilters ? 'with-filters' : ''}`}
|
||||
aria-label="Intestazione feed"
|
||||
>
|
||||
{showFeedFilters ? (
|
||||
<nav className="feed-filter-nav" aria-label="Filtri feed">
|
||||
<label className="feed-filter-control" htmlFor="sentiment-filter">
|
||||
<span className="feed-filter-label">Sentiment</span>
|
||||
<select
|
||||
id="sentiment-filter"
|
||||
value={sentimentFilter || 'All Sentiment'}
|
||||
onChange={(e) => onSentimentChange?.(e.target.value)}
|
||||
>
|
||||
<option>All Sentiment</option>
|
||||
<option>Positive</option>
|
||||
<option>Negative</option>
|
||||
<option>Neutral</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="feed-filter-control" htmlFor="topic-filter">
|
||||
<span className="feed-filter-label">Topics</span>
|
||||
<select
|
||||
id="topic-filter"
|
||||
value={topicsFilter || 'All Topics'}
|
||||
onChange={(e) => onTopicChange?.(e.target.value)}
|
||||
>
|
||||
<option>All Topics</option>
|
||||
<option>Intelligenza Artificiale</option>
|
||||
<option>Cybersecurity</option>
|
||||
<option>Business & Finanza</option>
|
||||
<option>Politica & Geopolitica</option>
|
||||
<option>Startup & Innovazione</option>
|
||||
<option>Software & Sviluppo</option>
|
||||
<option>Scienza & Ricerca</option>
|
||||
<option>Energia & Ambiente</option>
|
||||
<option>Economia & Mercati</option>
|
||||
<option>Social Media & Cultura</option>
|
||||
<option>Salute & Medicina</option>
|
||||
<option>Trasporti & Mobilità</option>
|
||||
</select>
|
||||
</label>
|
||||
</nav>
|
||||
) : null}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
export default FeedTopbar
|
||||
@@ -0,0 +1,78 @@
|
||||
type InterestPreferencesProps = {
|
||||
selectedMacroTopics: string[]
|
||||
onToggleMacroTopic: (topic: string) => void
|
||||
onSaveMacroTopics: () => void
|
||||
}
|
||||
|
||||
type CategoryOption = {
|
||||
label: string
|
||||
emoji: string
|
||||
}
|
||||
|
||||
const macroTopicOptions: CategoryOption[] = [
|
||||
{ label: 'Intelligenza Artificiale', emoji: '🤖' },
|
||||
{ label: 'Cybersecurity', emoji: '🔒' },
|
||||
{ label: 'Business & Finanza', emoji: '💼' },
|
||||
{ label: 'Politica & Geopolitica', emoji: '⚖️' },
|
||||
{ label: 'Startup & Innovazione', emoji: '🚀' },
|
||||
{ label: 'Software & Sviluppo', emoji: '💻' },
|
||||
{ label: 'Scienza & Ricerca', emoji: '🔬' },
|
||||
{ label: 'Energia & Ambiente', emoji: '🌱' },
|
||||
{ label: 'Economia & Mercati', emoji: '📈' },
|
||||
{ label: 'Social Media & Cultura', emoji: '📱' },
|
||||
{ label: 'Salute & Medicina', emoji: '🏥' },
|
||||
{ label: 'Trasporti & Mobilità', emoji: '🚗' },
|
||||
]
|
||||
|
||||
// Sezione interessi: gestisce la selezione rapida delle categorie del feed.
|
||||
function InterestPreferences({ selectedMacroTopics, onToggleMacroTopic, onSaveMacroTopics }: InterestPreferencesProps) {
|
||||
return (
|
||||
<section className="settings-card" aria-label="Le tue categorie">
|
||||
<header className="settings-section-header">
|
||||
<div>
|
||||
<h2>I tuoi Macro-Topics</h2>
|
||||
<p>Scegli i macro-temi che devono modellare il tuo feed.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="category-grid">
|
||||
{macroTopicOptions.map((topic) => {
|
||||
const isSelected = selectedMacroTopics.includes(topic.label)
|
||||
|
||||
return (
|
||||
<button
|
||||
key={topic.label}
|
||||
type="button"
|
||||
className={`category-tile ${isSelected ? 'selected' : ''}`}
|
||||
onClick={() => onToggleMacroTopic(topic.label)}
|
||||
>
|
||||
<span className="category-emoji" aria-hidden="true">
|
||||
{topic.emoji}
|
||||
</span>
|
||||
<span className="category-label">{topic.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="settings-action-row">
|
||||
<button type="button" className="settings-save-button" onClick={onSaveMacroTopics}>
|
||||
<SaveIcon />
|
||||
Salva
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function SaveIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M17 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V7l-4-4Z" />
|
||||
<path d="M7 3v6h8V3" />
|
||||
<path d="M7 21v-6h10v6" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default InterestPreferences
|
||||
@@ -0,0 +1,141 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { sendFeedback } from '../services/feedbackService'
|
||||
|
||||
type MagicCardProps = {
|
||||
articleId?: string
|
||||
source: string
|
||||
timeAgo: string
|
||||
sentiment: 'Positivo' | 'Negativo' | 'Neutrale'
|
||||
title: string
|
||||
summary: string
|
||||
tags: string[]
|
||||
entities: string[]
|
||||
}
|
||||
|
||||
// Card notizia: mostra fonte, sentiment, testo, tag, entità e azioni rapide.
|
||||
function MagicCard({ articleId, source, timeAgo, sentiment, title, summary, tags, entities }: MagicCardProps) {
|
||||
const [voted, setVoted] = useState<1 | -1 | null>(null)
|
||||
|
||||
const handleVote = (vote: 1 | -1) => {
|
||||
if (voted !== null) return
|
||||
setVoted(vote)
|
||||
if (articleId) sendFeedback(articleId, vote)
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="magic-card">
|
||||
{/* Testata card: fonte e badge del sentiment allineati ai lati opposti. */}
|
||||
<header className="magic-card-header">
|
||||
<div>
|
||||
<p className="magic-card-source">
|
||||
{source} • {timeAgo}
|
||||
</p>
|
||||
</div>
|
||||
<span className={`sentiment-badge ${sentiment.toLowerCase()}`}>{sentiment}</span>
|
||||
</header>
|
||||
|
||||
<h3 className="magic-card-title">{title}</h3>
|
||||
<p className="magic-card-summary">{summary}</p>
|
||||
|
||||
{/* Tag tematici: servono a classificare velocemente la notizia. */}
|
||||
<div className="magic-card-tags">
|
||||
{tags.map((tag) => (
|
||||
<span key={tag} className="tag-chip">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Entità rilevate: evidenziano i nomi chiave citati nella notizia. */}
|
||||
<div className="magic-card-entities">
|
||||
<span className="entities-label">Entità:</span>
|
||||
<div className="chip-wrap">
|
||||
{entities.map((entity) => (
|
||||
<span key={entity} className="entity-chip">
|
||||
{entity}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Azioni rapide: pulsanti iconici per interagire con la card. */}
|
||||
<footer className="magic-card-actions" aria-label="Azioni notizia">
|
||||
<ActionButton
|
||||
label="Mi piace"
|
||||
tone="like"
|
||||
onClick={() => handleVote(1)}
|
||||
disabled={voted !== null}
|
||||
style={{ opacity: voted === -1 ? 0.4 : 1 }}
|
||||
>
|
||||
<ThumbUpIcon />
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
label="Non mi piace"
|
||||
tone="dislike"
|
||||
onClick={() => handleVote(-1)}
|
||||
disabled={voted !== null}
|
||||
style={{ opacity: voted === 1 ? 0.4 : 1 }}
|
||||
>
|
||||
<ThumbDownIcon />
|
||||
</ActionButton>
|
||||
<ActionButton label="Salva" tone="save">
|
||||
<BookmarkIcon />
|
||||
</ActionButton>
|
||||
<ActionButton label="Condividi" tone="share">
|
||||
<ShareIcon />
|
||||
</ActionButton>
|
||||
</footer>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
// Piccolo bottone riutilizzabile per mantenere coerente il footer azioni.
|
||||
function ActionButton({ label, tone, children, onClick, disabled, style }: { label: string; tone: string; children: ReactNode; onClick?: () => void; disabled?: boolean; style?: React.CSSProperties }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`action-button ${tone}`}
|
||||
aria-label={label}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function ThumbUpIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M7 11v10H4V11h3Zm3 10h7.5a2.5 2.5 0 0 0 2.45-2.02l1.03-5.48A2.5 2.5 0 0 0 18.53 10H13V5.5a2.5 2.5 0 0 0-5 0V11L10 21Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function ThumbDownIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M7 13V3H4v10h3Zm3-10h7.5a2.5 2.5 0 0 1 2.45 2.02l1.03 5.48A2.5 2.5 0 0 1 18.53 14H13v4.5a2.5 2.5 0 0 1-5 0V13L10 3Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function BookmarkIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M6 3a2 2 0 0 0-2 2v16l8-4 8 4V5a2 2 0 0 0-2-2H6Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function ShareIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M18 8a3 3 0 1 0-2.82-4h-.01A3 3 0 0 0 18 8ZM6 15a3 3 0 1 0 0 6 3 3 0 0 0 0-6Zm12-4a3 3 0 0 0-2.83 2H15L9 10V8.8A3 3 0 1 0 7.4 10l5.2 2.6v.4a3 3 0 1 0 1.4 2.4V14l5.16-2.58A3 3 0 0 0 18 11Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default MagicCard
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Navigate, Outlet } from 'react-router-dom';
|
||||
import { getMe } from '../services/authService';
|
||||
|
||||
export default function ProtectedRoute() {
|
||||
const [status, setStatus] = useState<'loading'|'ok'|'unauth'>('loading');
|
||||
|
||||
useEffect(() => {
|
||||
getMe().then(user =>
|
||||
setStatus(user ? 'ok' : 'unauth')
|
||||
).catch(() => setStatus('unauth'));
|
||||
}, []);
|
||||
|
||||
if (status === 'loading') return <div>Caricamento...</div>;
|
||||
if (status === 'unauth') return <Navigate to='/login' replace />;
|
||||
return <Outlet />;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
type SettingsTab = 'interests' | 'account'
|
||||
|
||||
type SettingsTabsProps = {
|
||||
activeTab: SettingsTab
|
||||
onTabChange: (tab: SettingsTab) => void
|
||||
}
|
||||
|
||||
const tabItems: Array<{ id: SettingsTab; label: string }> = [
|
||||
{ id: 'interests', label: 'Interessi' },
|
||||
{ id: 'account', label: 'Profilo' },
|
||||
]
|
||||
|
||||
// Tabs di Settings: servono per separare preferenze feed e dati account.
|
||||
function SettingsTabs({ activeTab, onTabChange }: SettingsTabsProps) {
|
||||
return (
|
||||
<div className="settings-tabs" role="tablist" aria-label="Schede impostazioni">
|
||||
{tabItems.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === tab.id}
|
||||
className={`settings-tab ${activeTab === tab.id ? 'active' : ''}`}
|
||||
onClick={() => onTabChange(tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type { SettingsTab }
|
||||
export default SettingsTabs
|
||||
@@ -0,0 +1,110 @@
|
||||
type TrackedKeywordsProps = {
|
||||
keywords: string[]
|
||||
keywordInput: string
|
||||
onKeywordInputChange: (value: string) => void
|
||||
onAddKeyword: (keyword: string) => void
|
||||
onRemoveKeyword: (keyword: string) => void
|
||||
onSaveKeywords: () => void
|
||||
}
|
||||
|
||||
const keywordSuggestions = ['OpenAI', 'Google AI', 'Anthropic', 'Tesla', 'SpaceX']
|
||||
|
||||
// Sezione keyword: permette di aggiungere, rimuovere e salvare le entità monitorate.
|
||||
function TrackedKeywords({
|
||||
keywords,
|
||||
keywordInput,
|
||||
onKeywordInputChange,
|
||||
onAddKeyword,
|
||||
onRemoveKeyword,
|
||||
onSaveKeywords,
|
||||
}: TrackedKeywordsProps) {
|
||||
const handleSubmit = () => {
|
||||
onAddKeyword(keywordInput)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="settings-card" aria-label="Parole chiave monitorate">
|
||||
<header className="settings-section-header">
|
||||
<div>
|
||||
<h2>Parole chiave monitorate</h2>
|
||||
<p>Monitora aziende, persone e temi con un tracciamento rapido.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="keyword-toolbar">
|
||||
<input
|
||||
type="text"
|
||||
className="settings-keyword-input"
|
||||
placeholder="Aggiungi parola chiave..."
|
||||
value={keywordInput}
|
||||
onChange={(event) => onKeywordInputChange(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button type="button" className="keyword-add-button" onClick={handleSubmit}>
|
||||
<PlusIcon />
|
||||
Aggiungi
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="suggestion-row" aria-label="Suggerimenti parole chiave">
|
||||
{keywordSuggestions.map((suggestion) => (
|
||||
<button
|
||||
key={suggestion}
|
||||
type="button"
|
||||
className="suggestion-chip"
|
||||
onClick={() => onAddKeyword(suggestion)}
|
||||
>
|
||||
<span aria-hidden="true">+</span>
|
||||
{suggestion}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{keywords.length > 0 && (
|
||||
<div className="tracked-chip-wrap">
|
||||
{keywords.map((keyword) => (
|
||||
<span key={keyword} className="tracked-chip">
|
||||
{keyword}
|
||||
<button
|
||||
type="button"
|
||||
className="tracked-chip-remove"
|
||||
aria-label={`Rimuovi ${keyword}`}
|
||||
onClick={() => onRemoveKeyword(keyword)}
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="settings-action-row">
|
||||
<button type="button" className="settings-save-button" onClick={onSaveKeywords}>
|
||||
<SaveIcon />
|
||||
Salva
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function PlusIcon() {
|
||||
return <span aria-hidden="true">+</span>
|
||||
}
|
||||
|
||||
function SaveIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M17 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V7l-4-4Z" />
|
||||
<path d="M7 3v6h8V3" />
|
||||
<path d="M7 21v-6h10v6" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default TrackedKeywords
|
||||
@@ -0,0 +1,42 @@
|
||||
:root {
|
||||
--text: #13212b;
|
||||
--muted: #4e636f;
|
||||
--bg-top: #f7fbff;
|
||||
--bg-bottom: #e8f2ff;
|
||||
--panel-bg: rgba(255, 255, 255, 0.82);
|
||||
--panel-border: #c7d7e3;
|
||||
--panel-shadow: 0 22px 55px rgba(17, 44, 70, 0.18);
|
||||
--input-bg: #ffffff;
|
||||
--focus: #1f7ab8;
|
||||
--accent-a: #0e7490;
|
||||
--accent-b: #155e75;
|
||||
--danger: #c23616;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
color: var(--text);
|
||||
font-family: 'Space Grotesk', 'Segoe UI', sans-serif;
|
||||
line-height: 1.45;
|
||||
overflow-x: hidden;
|
||||
background:
|
||||
radial-gradient(circle at 15% 15%, rgba(31, 122, 184, 0.2), transparent 34%),
|
||||
radial-gradient(circle at 85% 85%, rgba(14, 116, 144, 0.22), transparent 28%),
|
||||
linear-gradient(180deg, var(--bg-top), var(--bg-bottom));
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,441 @@
|
||||
.feed-layout {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 256px minmax(0, 1fr);
|
||||
background: #f8fafc;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.feed-sidebar {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
padding: 20px 16px;
|
||||
background: #fff;
|
||||
border-right: 1px solid #e5e7eb;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 14px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #2563eb, #7c3aed);
|
||||
}
|
||||
|
||||
.brand-mark span {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, #2563eb, #7c3aed);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.sidebar-nav-item {
|
||||
width: 100%;
|
||||
margin-top: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
color: #64748b;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sidebar-nav-item:hover {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.sidebar-nav-item.active {
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #2563eb, #7c3aed);
|
||||
box-shadow: 0 10px 24px rgba(37, 99, 235, 0.18);
|
||||
}
|
||||
|
||||
.sidebar-nav-item svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.sidebar-profile {
|
||||
margin-top: auto;
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding-top: 18px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 999px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, #2563eb, #7c3aed);
|
||||
}
|
||||
|
||||
.profile-meta {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.profile-meta strong {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.profile-meta span {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.feed-main {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.feed-topbar {
|
||||
height: 64px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.feed-topbar.with-filters {
|
||||
height: auto;
|
||||
min-height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 24px;
|
||||
}
|
||||
|
||||
.feed-filter-nav {
|
||||
width: 100%;
|
||||
max-width: 896px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.feed-filter-control {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.feed-filter-label {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.feed-filter-control select {
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 10px;
|
||||
padding: 8px 10px;
|
||||
font: inherit;
|
||||
color: #0f172a;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.feed-filter-control select:focus-visible {
|
||||
outline: 2px solid #1f7ab8;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.feed-content {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
max-width: 896px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 24px 48px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.feed-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.feed-kicker {
|
||||
margin-bottom: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.feed-header h1 {
|
||||
margin: 0 0 8px;
|
||||
font-size: clamp(34px, 4vw, 48px);
|
||||
}
|
||||
|
||||
.feed-header p {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.feed-list {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.magic-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 20px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
|
||||
transition: transform 180ms ease, box-shadow 180ms ease, border-color 180ms ease;
|
||||
}
|
||||
|
||||
.magic-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: #dbeafe;
|
||||
box-shadow: 0 16px 34px rgba(37, 99, 235, 0.08);
|
||||
}
|
||||
|
||||
.magic-card-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.magic-card-source {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.sentiment-badge {
|
||||
border-radius: 999px;
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sentiment-badge.positivo {
|
||||
color: #166534;
|
||||
background: #dcfce7;
|
||||
}
|
||||
|
||||
.sentiment-badge.negativo {
|
||||
color: #991b1b;
|
||||
background: #fee2e2;
|
||||
}
|
||||
|
||||
.sentiment-badge.neutrale {
|
||||
color: #334155;
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
.magic-card-title {
|
||||
margin: 0 0 10px;
|
||||
font-size: 22px;
|
||||
line-height: 1.2;
|
||||
color: #111827;
|
||||
transition: color 180ms ease;
|
||||
}
|
||||
|
||||
.magic-card:hover .magic-card-title {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.magic-card-summary {
|
||||
line-height: 1.7;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.magic-card-tags,
|
||||
.magic-card-entities {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.tag-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 7px 12px;
|
||||
border-radius: 999px;
|
||||
color: #1d4ed8;
|
||||
background: #dbeafe;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.entities-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.entity-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 7px 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #c4b5fd;
|
||||
color: #6d28d9;
|
||||
background: #f5f3ff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.magic-card-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
margin-top: 0;
|
||||
min-height: 44px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 14px;
|
||||
color: #64748b;
|
||||
background: #fff;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
transition: color 180ms ease, border-color 180ms ease, background 180ms ease, transform 180ms ease;
|
||||
}
|
||||
|
||||
.action-button:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.action-button.like:hover {
|
||||
color: #16a34a;
|
||||
border-color: #86efac;
|
||||
background: #f0fdf4;
|
||||
}
|
||||
|
||||
.action-button.dislike:hover {
|
||||
color: #dc2626;
|
||||
border-color: #fca5a5;
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.action-button.save:hover {
|
||||
color: #2563eb;
|
||||
border-color: #93c5fd;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.action-button.share:hover {
|
||||
color: #7c3aed;
|
||||
border-color: #c4b5fd;
|
||||
background: #f5f3ff;
|
||||
}
|
||||
|
||||
.action-button svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.feed-footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 32px 0 8px;
|
||||
}
|
||||
|
||||
.load-more-button {
|
||||
color: #2563eb;
|
||||
background: #fff;
|
||||
border: 1px solid #93c5fd;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.load-more-button:hover {
|
||||
background: #dbeafe;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.feed-layout {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.feed-sidebar {
|
||||
min-height: auto;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.sidebar-profile {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.feed-topbar.with-filters {
|
||||
padding: 10px 16px;
|
||||
}
|
||||
|
||||
.feed-filter-nav {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.feed-filter-control {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.feed-content {
|
||||
padding: 24px 16px 36px;
|
||||
}
|
||||
|
||||
.magic-card-actions {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useState } from 'react'
|
||||
import FeedContent from '../components/FeedContent'
|
||||
import FeedSidebar from '../components/FeedSidebar'
|
||||
import FeedTopbar from '../components/FeedTopbar'
|
||||
import './FeedPage.css'
|
||||
|
||||
function FeedPage() {
|
||||
const [sentimentFilter, setSentimentFilter] = useState<string | null>(null)
|
||||
const [topicsFilter, setTopicsFilter] = useState<string | null>(null)
|
||||
|
||||
const handleSentimentChange = (sentiment: string) => {
|
||||
setSentimentFilter(sentiment === 'All Sentiment' ? null : sentiment)
|
||||
}
|
||||
|
||||
const handleTopicChange = (topic: string) => {
|
||||
setTopicsFilter(topic === 'All Topics' ? null : topic)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="feed-layout" aria-label="Feed BriefAI">
|
||||
<FeedSidebar activeItem="feed" />
|
||||
|
||||
<section className="feed-main">
|
||||
<FeedTopbar
|
||||
showFeedFilters
|
||||
sentimentFilter={sentimentFilter}
|
||||
onSentimentChange={handleSentimentChange}
|
||||
topicsFilter={topicsFilter}
|
||||
onTopicChange={handleTopicChange}
|
||||
/>
|
||||
<FeedContent sentimentFilter={sentimentFilter} topicsFilter={topicsFilter} />
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FeedPage
|
||||
@@ -0,0 +1,373 @@
|
||||
.home-page {
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
padding: 56px 24px 48px;
|
||||
background: linear-gradient(180deg, #f8fafc 0%, #ffffff 100%);
|
||||
}
|
||||
|
||||
.home-container {
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.hero-section {
|
||||
width: 100%;
|
||||
max-width: 1024px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hero-badge {
|
||||
margin: 0 auto 20px;
|
||||
width: fit-content;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 18px;
|
||||
border-radius: 999px;
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
background: linear-gradient(135deg, #2563eb, #7c3aed);
|
||||
box-shadow: 0 14px 28px rgba(37, 99, 235, 0.28);
|
||||
}
|
||||
|
||||
.hero-badge svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.hero-section h1 {
|
||||
margin: 0 0 24px;
|
||||
font-size: clamp(2.5rem, 6vw, 3.75rem);
|
||||
line-height: 1.05;
|
||||
font-weight: 800;
|
||||
background: linear-gradient(135deg, #2563eb, #7c3aed);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
margin: 0 auto;
|
||||
max-width: 960px;
|
||||
font-size: clamp(1.06rem, 2.4vw, 1.25rem);
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.plan-grid {
|
||||
margin-top: 36px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.plan-card {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: linear-gradient(160deg, #ffffff 0%, #f8fafc 100%);
|
||||
padding: 24px;
|
||||
box-shadow: 0 20px 34px rgba(15, 23, 42, 0.09);
|
||||
transition: transform 0.25s ease, box-shadow 0.25s ease, border-color 0.25s ease;
|
||||
}
|
||||
|
||||
.plan-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -1px;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(135deg, rgba(37, 99, 235, 0.2), rgba(124, 58, 237, 0.2));
|
||||
opacity: 0;
|
||||
z-index: -2;
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.plan-card::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: -42px;
|
||||
top: -42px;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 999px;
|
||||
background: radial-gradient(circle, rgba(59, 130, 246, 0.2), transparent 70%);
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.plan-card:hover {
|
||||
transform: translateY(-6px);
|
||||
border-color: #bfdbfe;
|
||||
box-shadow: 0 28px 42px rgba(15, 23, 42, 0.15);
|
||||
}
|
||||
|
||||
.plan-card:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.free-plan {
|
||||
border-color: #dbeafe;
|
||||
}
|
||||
|
||||
.premium-plan {
|
||||
border-color: #ddd6fe;
|
||||
background: linear-gradient(160deg, #ffffff 0%, #f5f3ff 100%);
|
||||
box-shadow: 0 22px 38px rgba(91, 33, 182, 0.16);
|
||||
}
|
||||
|
||||
.plan-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.74rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
font-weight: 700;
|
||||
color: #1e3a8a;
|
||||
background: #dbeafe;
|
||||
}
|
||||
|
||||
.plan-chip.premium {
|
||||
color: #5b21b6;
|
||||
background: #ede9fe;
|
||||
}
|
||||
|
||||
.plan-card h2 {
|
||||
margin: 14px 0 10px;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.plan-card p {
|
||||
margin: 0;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.plan-actions {
|
||||
margin-top: 18px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.plan-actions a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.plan-login-button,
|
||||
.plan-register-button {
|
||||
min-width: 116px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 12px;
|
||||
font-weight: 600;
|
||||
padding: 11px 16px;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.plan-login-button {
|
||||
color: #ffffff;
|
||||
background: linear-gradient(135deg, #2563eb, #7c3aed);
|
||||
box-shadow: 0 10px 20px rgba(37, 99, 235, 0.28);
|
||||
}
|
||||
|
||||
.plan-login-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 20px 36px rgba(37, 99, 235, 0.35);
|
||||
}
|
||||
|
||||
.plan-register-button {
|
||||
color: #334155;
|
||||
border: 2px solid #e2e8f0;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
.plan-register-button:hover {
|
||||
border-color: #3b82f6;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.benefits-section {
|
||||
width: 100%;
|
||||
max-width: 1280px;
|
||||
margin-top: 96px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.benefit-card {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 16px;
|
||||
padding: 32px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 24px 36px rgba(15, 23, 42, 0.1);
|
||||
}
|
||||
|
||||
.benefit-icon-wrap {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 14px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.benefit-icon-wrap svg {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.benefit-icon-wrap.blue {
|
||||
background: #dbeafe;
|
||||
color: #1e3a8a;
|
||||
}
|
||||
|
||||
.benefit-icon-wrap.violet {
|
||||
background: #ede9fe;
|
||||
color: #5b21b6;
|
||||
}
|
||||
|
||||
.benefit-icon-wrap.green {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.benefit-card h3 {
|
||||
margin: 18px 0 12px;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.benefit-card p {
|
||||
margin: 0;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.preview-section {
|
||||
width: 100%;
|
||||
max-width: 1024px;
|
||||
margin-top: 96px;
|
||||
}
|
||||
|
||||
.preview-section h2 {
|
||||
margin: 0 0 32px;
|
||||
text-align: center;
|
||||
font-size: clamp(1.85rem, 4vw, 2rem);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.preview-shell {
|
||||
border-radius: 24px;
|
||||
padding: 48px;
|
||||
background: linear-gradient(135deg, #f1f5f9, #e2e8f0);
|
||||
}
|
||||
|
||||
.preview-card {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 20px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 20px 36px rgba(15, 23, 42, 0.11);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.preview-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
font-size: 0.9rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.sentiment-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
padding: 6px 10px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: #166534;
|
||||
background: #dcfce7;
|
||||
}
|
||||
|
||||
.preview-card h3 {
|
||||
margin: 16px 0 12px;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.preview-card p {
|
||||
margin: 0 0 16px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.preview-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tag-chip {
|
||||
border-radius: 999px;
|
||||
padding: 6px 12px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tag-chip.ai {
|
||||
color: #1d4ed8;
|
||||
background: #dbeafe;
|
||||
}
|
||||
|
||||
.tag-chip.openai {
|
||||
color: #6d28d9;
|
||||
background: #ede9fe;
|
||||
}
|
||||
|
||||
.tag-chip.safety {
|
||||
color: #475569;
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
.preview-actions {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
color: #64748b;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.home-footer {
|
||||
margin-top: 96px;
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.benefits-section {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 899px) {
|
||||
.plan-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.preview-shell {
|
||||
padding: 24px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import './HomePage.css'
|
||||
|
||||
function HomePage() {
|
||||
return (
|
||||
<section className="home-page" aria-label="Home pubblica BriefAI">
|
||||
<div className="home-container">
|
||||
{/* Hero principale: comunica il valore del prodotto in pochi secondi. */}
|
||||
<header className="hero-section">
|
||||
<p className="hero-badge">
|
||||
<SparklesIcon />
|
||||
<span>La tua intelligence sulle notizie potenziata dall'IA</span>
|
||||
</p>
|
||||
|
||||
<h1>Ottieni notizie pronte all'azione in 30 secondi</h1>
|
||||
|
||||
<p className="hero-subtitle">
|
||||
BriefAI trasforma il sovraccarico informativo in intelligence operativa. Riassunti
|
||||
generati dall'IA, analisi del sentiment e feed personalizzati per founder, analisti e
|
||||
appassionati di tecnologia.
|
||||
</p>
|
||||
|
||||
<div className="plan-grid" aria-label="Piani disponibili">
|
||||
<article className="plan-card free-plan" aria-label="Piano gratuito">
|
||||
<span className="plan-chip">Free</span>
|
||||
<h2>Piano Gratuito</h2>
|
||||
<p>
|
||||
Inizia con il feed intelligente di base, riepiloghi rapidi e monitoraggio leggero
|
||||
dei trend principali.
|
||||
</p>
|
||||
<div className="plan-actions">
|
||||
<Link to="/login" className="plan-login-button">
|
||||
Login
|
||||
</Link>
|
||||
<Link to="/onboarding" className="plan-register-button">
|
||||
Registrati
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="plan-card premium-plan" aria-label="Piano premium">
|
||||
<span className="plan-chip premium">Premium</span>
|
||||
<h2>Piano a Pagamento</h2>
|
||||
<p>
|
||||
Sblocca analisi avanzate, alert strategici, maggiore profondita di sentiment e
|
||||
tracciamento esteso di keyword e competitor.
|
||||
</p>
|
||||
<div className="plan-actions">
|
||||
<Link to="/login" className="plan-login-button">
|
||||
Login
|
||||
</Link>
|
||||
<Link to="/onboarding" className="plan-register-button">
|
||||
Registrati
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Sezione benefici: tre card per descrivere le funzionalita principali. */}
|
||||
<section className="benefits-section" aria-label="Benefici principali">
|
||||
<article className="benefit-card">
|
||||
<div className="benefit-icon-wrap blue">
|
||||
<SparklesIcon />
|
||||
</div>
|
||||
<h3>Feed Personalizzato</h3>
|
||||
<p>
|
||||
L'IA impara i tuoi interessi e ti mostra solo cio che conta davvero. Monitora
|
||||
competitor, brand e temi emergenti in un unico flusso.
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<article className="benefit-card">
|
||||
<div className="benefit-icon-wrap violet">
|
||||
<ZapIcon />
|
||||
</div>
|
||||
<h3>Riassunti IA</h3>
|
||||
<p>
|
||||
Ogni articolo compresso in massimo 50 parole. Hai l'essenza senza rumore e recuperi
|
||||
ore preziose ogni giorno.
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<article className="benefit-card">
|
||||
<div className="benefit-icon-wrap green">
|
||||
<TrendingUpIcon />
|
||||
</div>
|
||||
<h3>Insight di Sentiment</h3>
|
||||
<p>
|
||||
Analisi istantanea del sentiment su ogni notizia. Comprendi rapidamente percezione di
|
||||
mercato e reputazione del brand.
|
||||
</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
{/* Anteprima prodotto: simula una card reale del feed per mostrare l'output. */}
|
||||
<section className="preview-section" aria-label="Anteprima applicazione">
|
||||
<h2>Guarda BriefAI in azione</h2>
|
||||
|
||||
<div className="preview-shell">
|
||||
<article className="preview-card" aria-label="Demo articolo IA">
|
||||
<header className="preview-card-header">
|
||||
<span>TechCrunch • 2 ore fa</span>
|
||||
<span className="sentiment-badge">Positivo</span>
|
||||
</header>
|
||||
|
||||
<h3>OpenAI annuncia un importante passo avanti nella sicurezza dell'IA</h3>
|
||||
|
||||
<p>
|
||||
OpenAI introduce un nuovo framework di sicurezza che riduce del 60% gli output
|
||||
dannosi. I leader del settore elogiano l'approccio. Potrebbe diventare uno standard
|
||||
per i laboratori di IA.
|
||||
</p>
|
||||
|
||||
<div className="preview-tags" aria-label="Tag articolo">
|
||||
<span className="tag-chip ai">IA</span>
|
||||
<span className="tag-chip openai">OpenAI</span>
|
||||
<span className="tag-chip safety">Sicurezza</span>
|
||||
</div>
|
||||
|
||||
<footer className="preview-actions" aria-label="Azioni articolo">
|
||||
<span>👍 Mi piace</span>
|
||||
<span>💾 Salva</span>
|
||||
<span>🔗 Condividi</span>
|
||||
</footer>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer className="home-footer">BriefAI © 2026 - Strumento di Decision Intelligence</footer>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function SparklesIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 3l1.6 4.4L18 9l-4.4 1.6L12 15l-1.6-4.4L6 9l4.4-1.6L12 3Z" />
|
||||
<path d="M19 14l.8 2.2L22 17l-2.2.8L19 20l-.8-2.2L16 17l2.2-.8L19 14Z" />
|
||||
<path d="M5 14l.8 2.2L8 17l-2.2.8L5 20l-.8-2.2L2 17l2.2-.8L5 14Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function ZapIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M13 2 4 14h6l-1 8 9-12h-6l1-8Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function TrendingUpIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M3 17l6-6 4 4 8-8" />
|
||||
<path d="M14 7h7v7" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default HomePage
|
||||
@@ -0,0 +1,112 @@
|
||||
.app-shell > .auth-panel {
|
||||
flex: 0 1 460px;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.auth-panel,
|
||||
.home-panel {
|
||||
width: min(100%, 460px);
|
||||
background: var(--panel-bg);
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 18px;
|
||||
padding: 28px;
|
||||
box-shadow: var(--panel-shadow);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.auth-panel .eyebrow {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.auth-panel h1 {
|
||||
margin: 8px 0 8px;
|
||||
font-size: clamp(36px, 4vw, 44px);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.auth-panel .subtitle {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.top-link-row {
|
||||
margin: -8px 0 16px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.login-form label {
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.login-form input,
|
||||
.login-form button {
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--panel-border);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.login-form input {
|
||||
background: var(--input-bg);
|
||||
color: var(--text);
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.login-form input:focus-visible,
|
||||
.login-form button:focus-visible {
|
||||
outline: 2px solid var(--focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.login-form button {
|
||||
margin-top: 8px;
|
||||
cursor: pointer;
|
||||
padding: 12px 14px;
|
||||
color: #fff;
|
||||
border-color: transparent;
|
||||
background: linear-gradient(120deg, var(--accent-a), var(--accent-b));
|
||||
}
|
||||
|
||||
.checkbox-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
margin-top: 6px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.checkbox-row input {
|
||||
margin-top: 3px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.checkbox-row span {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 8px 0 0;
|
||||
color: var(--danger);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.auth-footer {
|
||||
margin-top: 16px;
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.text-link {
|
||||
color: var(--accent-b);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { FormEvent } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { login } from '../services/authService'
|
||||
import './LoginPage.css'
|
||||
|
||||
type LoginPageProps = {
|
||||
onLoginSuccess?: () => void
|
||||
}
|
||||
|
||||
function LoginPage({ onLoginSuccess }: LoginPageProps) {
|
||||
const navigate = useNavigate()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
try {
|
||||
await login(email, password)
|
||||
setError('')
|
||||
if (typeof onLoginSuccess === 'function') onLoginSuccess()
|
||||
navigate('/feed')
|
||||
} catch (err: any) {
|
||||
setError('Credenziali non valide')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="auth-panel" aria-label="Accesso BriefAI">
|
||||
<p className="eyebrow">BriefAI</p>
|
||||
<h1>Accedi</h1>
|
||||
<p className="subtitle">Inserisci le tue credenziali per iniziare la configurazione.</p>
|
||||
|
||||
<form className="login-form" onSubmit={handleSubmit}>
|
||||
<label htmlFor="email">Email</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
placeholder="you@example.com"
|
||||
autoComplete="username"
|
||||
required
|
||||
/>
|
||||
|
||||
<label htmlFor="password">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder="Inserisci la password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
|
||||
<p className="top-link-row">
|
||||
<a className="text-link" href="#" aria-label="Recupera password">
|
||||
Ti sei dimenticato la password?
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<button type="submit">Entra</button>
|
||||
|
||||
{error && (
|
||||
<p className="error" role="alert" aria-live="polite">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<p className="auth-footer">
|
||||
Non hai un account? <Link className="text-link" to="/onboarding">Registrati</Link>
|
||||
</p>
|
||||
|
||||
<p className="auth-footer">
|
||||
Vuoi tornare alla pagina iniziale? <Link className="text-link" to="/">Home</Link>
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default LoginPage
|
||||
@@ -0,0 +1,210 @@
|
||||
.app-shell > .onboarding-shell {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.onboarding-shell {
|
||||
width: min(100%, 920px);
|
||||
height: 100%;
|
||||
padding: 82px 16px 24px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.progress-wrapper {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
background: rgba(247, 251, 255, 0.95);
|
||||
backdrop-filter: blur(6px);
|
||||
padding: 0 16px 8px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.progress-track {
|
||||
height: 2px;
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #2563eb, #7c3aed);
|
||||
transition: width 280ms ease;
|
||||
}
|
||||
|
||||
.progress-meta {
|
||||
margin: 8px auto 0;
|
||||
width: min(100%, 920px);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.onboarding-card {
|
||||
max-width: 780px;
|
||||
margin: 0 auto;
|
||||
background: #fff;
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 18px;
|
||||
padding: 28px;
|
||||
box-shadow: var(--panel-shadow);
|
||||
}
|
||||
|
||||
.onboarding-card h1 {
|
||||
margin: 8px 0 8px;
|
||||
font-size: clamp(36px, 4vw, 44px);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.onboarding-card .subtitle {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.topics-grid {
|
||||
margin-top: 16px;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.topic-card {
|
||||
position: relative;
|
||||
border: 1px solid #dce7f0;
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
padding: 18px 12px;
|
||||
color: var(--text);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
transition: transform 180ms ease, border-color 180ms ease, background 180ms ease;
|
||||
}
|
||||
|
||||
.topic-card:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.topic-card.selected {
|
||||
border-color: #2563eb;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.topic-emoji {
|
||||
font-size: 2.25rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.topic-check {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 8px;
|
||||
color: #2563eb;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.onboarding-action {
|
||||
margin-top: 18px;
|
||||
width: 100%;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
color: #fff;
|
||||
background: linear-gradient(90deg, #2563eb, #7c3aed);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.onboarding-action:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.keyword-input {
|
||||
width: 100%;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--panel-border);
|
||||
padding: 12px 14px;
|
||||
background: #fff;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.keyword-input:focus-visible {
|
||||
outline: none;
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.15);
|
||||
}
|
||||
|
||||
.chip-section {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.chip-section h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.chip-wrap {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.suggestion-chip {
|
||||
margin-top: 0;
|
||||
border: 1px solid #d5dbe4;
|
||||
background: #e5e7eb;
|
||||
color: #1f2937;
|
||||
border-radius: 999px;
|
||||
padding: 8px 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.suggestion-chip:hover {
|
||||
background: #dbeafe;
|
||||
border-color: #93c5fd;
|
||||
}
|
||||
|
||||
.tracking-chip {
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
border-radius: 999px;
|
||||
padding: 8px 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.remove-chip {
|
||||
margin-top: 0;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
color: #2563eb;
|
||||
background: #fff;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.step-actions {
|
||||
margin-top: 20px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.secondary-action {
|
||||
margin-top: 18px;
|
||||
border: 1px solid #d1d5db;
|
||||
background: #e5e7eb;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.topics-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import './OnboardingPage.css'
|
||||
|
||||
const TOPIC_OPTIONS = [
|
||||
{ label: 'Intelligenza Artificiale', emoji: '🤖' },
|
||||
{ label: 'Cybersecurity', emoji: '🔒' },
|
||||
{ label: 'Business & Finanza', emoji: '💼' },
|
||||
{ label: 'Politica & Geopolitica', emoji: '⚖️' },
|
||||
{ label: 'Startup & Innovazione', emoji: '🚀' },
|
||||
{ label: 'Software & Sviluppo', emoji: '💻' },
|
||||
{ label: 'Scienza & Ricerca', emoji: '🔬' },
|
||||
{ label: 'Energia & Ambiente', emoji: '🌱' },
|
||||
{ label: 'Economia & Mercati', emoji: '📈' },
|
||||
{ label: 'Social Media & Cultura', emoji: '📱' },
|
||||
{ label: 'Salute & Medicina', emoji: '🏥' },
|
||||
{ label: 'Trasporti & Mobilità', emoji: '🚗' },
|
||||
] as const
|
||||
|
||||
const SUGGESTIONS = ['OpenAI', 'Google AI', 'Anthropic', 'Tesla', 'SpaceX'] as const
|
||||
|
||||
function OnboardingPage() {
|
||||
const navigate = useNavigate()
|
||||
// Step corrente del wizard: 1 per categorie, 2 per tracking keyword.
|
||||
const [step, setStep] = useState<1 | 2>(1)
|
||||
// Stato accumulato dello step 1 (interessi selezionati).
|
||||
const [selectedTopics, setSelectedTopics] = useState<string[]>([])
|
||||
// Stato accumulato dello step 2 (keyword da monitorare).
|
||||
const [keywords, setKeywords] = useState<string[]>([])
|
||||
const [keywordInput, setKeywordInput] = useState('')
|
||||
|
||||
const progressPercent = step === 1 ? 50 : 100
|
||||
|
||||
// Evita di mostrare nei suggerimenti keyword gia presenti nella lista personale.
|
||||
const suggestionPool = useMemo(
|
||||
() => SUGGESTIONS.filter((item) => !keywords.includes(item)),
|
||||
[keywords],
|
||||
)
|
||||
|
||||
// Toggle atomico della card: se gia selezionata la rimuove, altrimenti la aggiunge.
|
||||
const toggleTopic = (topic: string) => {
|
||||
setSelectedTopics((prevTopics) =>
|
||||
prevTopics.includes(topic)
|
||||
? prevTopics.filter((savedTopic) => savedTopic !== topic)
|
||||
: [...prevTopics, topic],
|
||||
)
|
||||
}
|
||||
|
||||
const addKeyword = (rawValue: string) => {
|
||||
const cleanedKeyword = rawValue.trim()
|
||||
|
||||
// Normalizzazione semplice: ignora vuoti e duplicati per mantenere la lista consistente.
|
||||
if (!cleanedKeyword || keywords.includes(cleanedKeyword)) {
|
||||
return
|
||||
}
|
||||
|
||||
setKeywords((prevKeywords) => [...prevKeywords, cleanedKeyword])
|
||||
setKeywordInput('')
|
||||
}
|
||||
|
||||
const removeKeyword = (keywordToRemove: string) => {
|
||||
setKeywords((prevKeywords) =>
|
||||
prevKeywords.filter((savedKeyword) => savedKeyword !== keywordToRemove),
|
||||
)
|
||||
}
|
||||
|
||||
const goToNextStep = () => {
|
||||
// Primo click su Continua: passa allo step 2 senza fare submit globale.
|
||||
if (step === 1) {
|
||||
setStep(2)
|
||||
return
|
||||
}
|
||||
|
||||
// Salvataggio demo in localStorage: simula la persistenza prima del redirect
|
||||
// verso il form di registrazione. Nessuna chiamata al backend qui.
|
||||
localStorage.setItem(
|
||||
'briefai-onboarding',
|
||||
JSON.stringify({ selectedTopics, keywords }),
|
||||
)
|
||||
// Dopo il wizard, mostra il form di registrazione (pre-auth flow).
|
||||
navigate('/register')
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="onboarding-shell" aria-label="Configurazione iniziale BriefAI">
|
||||
<header className="progress-wrapper" aria-label="Progresso onboarding">
|
||||
<div className="progress-track">
|
||||
<div className="progress-fill" style={{ width: `${progressPercent}%` }}></div>
|
||||
</div>
|
||||
<div className="progress-meta">
|
||||
<span>{`Passo ${step} di 2`}</span>
|
||||
<span>{`${progressPercent}%`}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{step === 1 ? (
|
||||
<article className="onboarding-card">
|
||||
<h1>A cosa sei interessato?</h1>
|
||||
<p className="subtitle">Seleziona tutte le categorie che ti interessano</p>
|
||||
|
||||
<div className="topics-grid">
|
||||
{TOPIC_OPTIONS.map((topic) => {
|
||||
const isSelected = selectedTopics.includes(topic.label)
|
||||
|
||||
return (
|
||||
<button
|
||||
key={topic.label}
|
||||
type="button"
|
||||
className={`topic-card ${isSelected ? 'selected' : ''}`}
|
||||
onClick={() => toggleTopic(topic.label)}
|
||||
>
|
||||
<span className="topic-emoji" aria-hidden="true">
|
||||
{topic.emoji}
|
||||
</span>
|
||||
<span>{topic.label}</span>
|
||||
{isSelected && (
|
||||
<span className="topic-check" aria-hidden="true">
|
||||
✓
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="onboarding-action"
|
||||
onClick={goToNextStep}
|
||||
disabled={selectedTopics.length === 0}
|
||||
>
|
||||
Continua
|
||||
</button>
|
||||
</article>
|
||||
) : (
|
||||
<article className="onboarding-card">
|
||||
<h1>Tieni traccia di argomenti specifici</h1>
|
||||
<p className="subtitle">Aggiungi concorrenti, marchi o parole chiave da monitorare.</p>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
className="keyword-input"
|
||||
placeholder="Inserisci concorrenti, marchi, argomenti..."
|
||||
value={keywordInput}
|
||||
onChange={(event) => setKeywordInput(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
addKeyword(keywordInput)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<section className="chip-section" aria-label="Suggerimenti">
|
||||
<h2>Suggerimenti</h2>
|
||||
<div className="chip-wrap">
|
||||
{suggestionPool.map((item) => (
|
||||
<button
|
||||
key={item}
|
||||
type="button"
|
||||
className="suggestion-chip"
|
||||
onClick={() => addKeyword(item)}
|
||||
>
|
||||
<span aria-hidden="true">+</span>
|
||||
{item}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{keywords.length > 0 && (
|
||||
<section className="chip-section" aria-label="La tua lista di monitoraggio">
|
||||
<h2>La tua lista di monitoraggio</h2>
|
||||
<div className="chip-wrap">
|
||||
{keywords.map((keyword) => (
|
||||
<span key={keyword} className="tracking-chip">
|
||||
{keyword}
|
||||
<button
|
||||
type="button"
|
||||
className="remove-chip"
|
||||
onClick={() => removeKeyword(keyword)}
|
||||
aria-label={`Rimuovi ${keyword}`}
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="step-actions">
|
||||
<button type="button" className="secondary-action" onClick={() => setStep(1)}>
|
||||
Indietro
|
||||
</button>
|
||||
<button type="button" className="onboarding-action" onClick={goToNextStep}>
|
||||
Continua
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default OnboardingPage
|
||||
@@ -0,0 +1 @@
|
||||
@import './LoginPage.css';
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { FormEvent } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { register } from '../services/authService'
|
||||
import './RegisterPage.css'
|
||||
|
||||
type RegisterPageProps = {
|
||||
onRegisterSuccess?: () => void
|
||||
}
|
||||
|
||||
function RegisterPage({ onRegisterSuccess }: RegisterPageProps) {
|
||||
const navigate = useNavigate()
|
||||
const [username, setUsername] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [acceptedTerms, setAcceptedTerms] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
|
||||
// Validazione esplicita: se i termini non sono accettati, blocca l'invio.
|
||||
if (!acceptedTerms) {
|
||||
setError("Devi accettare i Termini di servizio e l'Informativa sulla privacy.")
|
||||
return
|
||||
}
|
||||
|
||||
// Recupera preferenze accumulate durante l'onboarding (se presenti)
|
||||
let macroTopics: string[] | undefined = undefined
|
||||
let keywords: string[] | undefined = undefined
|
||||
try {
|
||||
const raw = localStorage.getItem('briefai-onboarding')
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw)
|
||||
macroTopics = parsed.selectedTopics || undefined
|
||||
keywords = parsed.keywords || undefined
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore parsing errors
|
||||
}
|
||||
|
||||
try {
|
||||
await register({
|
||||
email,
|
||||
password: newPassword,
|
||||
username,
|
||||
preferences: {
|
||||
macroTopics,
|
||||
keywords,
|
||||
},
|
||||
})
|
||||
setError('')
|
||||
// Pulizia locale
|
||||
setUsername('')
|
||||
setEmail('')
|
||||
setNewPassword('')
|
||||
setAcceptedTerms(false)
|
||||
if (typeof onRegisterSuccess === 'function') onRegisterSuccess()
|
||||
// Dopo la registrazione il token è salvato dal servizio; redirige al feed
|
||||
navigate('/feed')
|
||||
} catch (err: any) {
|
||||
// Estrae il messaggio d'errore lanciato dal backend (`throw await res.json()`),
|
||||
// oppure mostra un fallback se l'API non risponde correttamente.
|
||||
const errorMessage = err?.error || err?.message || 'Errore nella registrazione'
|
||||
setError(errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="auth-panel" aria-label="Registrazione BriefAI">
|
||||
<p className="eyebrow">BriefAI</p>
|
||||
<h1>Registrati</h1>
|
||||
<p className="subtitle">Crea il tuo account per iniziare.</p>
|
||||
|
||||
<form className="login-form" onSubmit={handleSubmit}>
|
||||
<label htmlFor="register-username">Utente</label>
|
||||
<input
|
||||
id="register-username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
placeholder="Il tuo username"
|
||||
autoComplete="username"
|
||||
required
|
||||
/>
|
||||
|
||||
<label htmlFor="register-email">Email</label>
|
||||
<input
|
||||
id="register-email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
placeholder="nome@email.com"
|
||||
autoComplete="email"
|
||||
required
|
||||
/>
|
||||
|
||||
<label htmlFor="register-password">Nuova password</label>
|
||||
<input
|
||||
id="register-password"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(event) => setNewPassword(event.target.value)}
|
||||
placeholder="Nuova password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
/>
|
||||
|
||||
<label className="checkbox-row" htmlFor="register-terms">
|
||||
<input
|
||||
id="register-terms"
|
||||
type="checkbox"
|
||||
checked={acceptedTerms}
|
||||
onChange={(event) => setAcceptedTerms(event.target.checked)}
|
||||
/>
|
||||
<span>Accetto i Termini di servizio e l'Informativa sulla privacy</span>
|
||||
</label>
|
||||
|
||||
<button type="submit">Crea account</button>
|
||||
|
||||
{error && (
|
||||
<p className="error" role="alert" aria-live="polite">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<p className="auth-footer">
|
||||
Se hai gia un account fai <Link className="text-link" to="/login">accesso</Link>
|
||||
</p>
|
||||
|
||||
<p className="auth-footer">
|
||||
Torna alla <Link className="text-link" to="/">home</Link>
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default RegisterPage
|
||||
@@ -0,0 +1,309 @@
|
||||
.settings-layout {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.settings-main {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.settings-header h1 {
|
||||
margin: 0 0 6px;
|
||||
font-size: clamp(34px, 4vw, 48px);
|
||||
}
|
||||
|
||||
.settings-header p {
|
||||
margin: 0;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.settings-tabs {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
margin: 24px 0 20px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.settings-tab {
|
||||
margin-top: 0;
|
||||
padding: 12px 0;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: #64748b;
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
transition: color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.settings-tab:hover {
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.settings-tab.active {
|
||||
color: #2563eb;
|
||||
border-bottom-color: #2563eb;
|
||||
}
|
||||
|
||||
.settings-stack {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 20px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.settings-section-header h2 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.settings-section-header p {
|
||||
margin: 0;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.category-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.category-tile {
|
||||
position: relative;
|
||||
min-height: 108px;
|
||||
margin-top: 0;
|
||||
padding: 16px 12px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 8px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
color: #111827;
|
||||
transition: transform 0.2s ease, border-color 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.category-tile:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.category-tile.selected {
|
||||
border-color: #2563eb;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.category-emoji {
|
||||
font-size: 1.875rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.category-label {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.settings-action-row {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.settings-save-button {
|
||||
margin-top: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: linear-gradient(90deg, #2563eb, #1d4ed8);
|
||||
transition: transform 0.2s ease, opacity 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.settings-save-button:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.settings-save-button svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.keyword-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.settings-keyword-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.settings-keyword-input:focus-visible {
|
||||
outline: none;
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.15);
|
||||
}
|
||||
|
||||
.keyword-add-button {
|
||||
margin-top: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: #2563eb;
|
||||
transition: transform 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.keyword-add-button:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.suggestion-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.tracked-chip-wrap {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.tracked-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
background: #dbeafe;
|
||||
color: #1d4ed8;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tracked-chip-remove {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-top: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
color: #1d4ed8;
|
||||
background: #fff;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.profile-info-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.profile-info-label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.profile-info-value {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.subscription-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-top: 20px;
|
||||
padding: 20px;
|
||||
border-radius: 18px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.subscription-copy {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.plan-badge {
|
||||
width: fit-content;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.plan-badge.free {
|
||||
color: #334155;
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
.plan-badge.pro {
|
||||
color: #fff;
|
||||
background: linear-gradient(90deg, #2563eb, #7c3aed);
|
||||
}
|
||||
|
||||
.subscription-copy p {
|
||||
margin: 0;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.subscription-upgrade-button {
|
||||
margin-top: 0;
|
||||
background: linear-gradient(90deg, #2563eb, #7c3aed);
|
||||
}
|
||||
|
||||
.subscription-link-button {
|
||||
margin-top: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
color: #2563eb;
|
||||
background: transparent;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.category-grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.settings-content {
|
||||
padding: 24px 16px 36px;
|
||||
}
|
||||
|
||||
.settings-tabs {
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.keyword-toolbar,
|
||||
.subscription-box {
|
||||
grid-template-columns: 1fr;
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.settings-action-row {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.settings-save-button,
|
||||
.keyword-add-button {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import FeedSidebar from '../components/FeedSidebar'
|
||||
import FeedTopbar from '../components/FeedTopbar'
|
||||
import AccountSettings, { type SubscriptionState } from '../components/AccountSettings'
|
||||
import InterestPreferences from '../components/InterestPreferences'
|
||||
import SettingsTabs, { type SettingsTab } from '../components/SettingsTabs'
|
||||
import TrackedKeywords from '../components/TrackedKeywords'
|
||||
import './FeedPage.css'
|
||||
import './SettingsPage.css'
|
||||
import { fetchProfile, updateProfile } from '../services/apiService'
|
||||
|
||||
type SettingsSnapshot = {
|
||||
selectedMacroTopics: string[]
|
||||
keywords: string[]
|
||||
subscriptionState: SubscriptionState
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'briefai-settings'
|
||||
const ONBOARDING_KEY = 'briefai-onboarding'
|
||||
const DEFAULT_MACRO_TOPICS = ['Scienza & Ricerca']
|
||||
|
||||
function readInitialSettings(): SettingsSnapshot {
|
||||
if (typeof window === 'undefined') {
|
||||
return {
|
||||
selectedMacroTopics: DEFAULT_MACRO_TOPICS,
|
||||
keywords: [],
|
||||
subscriptionState: 'free',
|
||||
}
|
||||
}
|
||||
|
||||
const onboardingSnapshot = readJSON<{ selectedTopics?: string[]; keywords?: string[] }>(
|
||||
ONBOARDING_KEY,
|
||||
)
|
||||
const storedSnapshot = readJSON<Partial<SettingsSnapshot>>(STORAGE_KEY)
|
||||
|
||||
return {
|
||||
selectedMacroTopics:
|
||||
storedSnapshot?.selectedMacroTopics ?? onboardingSnapshot?.selectedTopics ?? DEFAULT_MACRO_TOPICS,
|
||||
keywords: storedSnapshot?.keywords ?? onboardingSnapshot?.keywords ?? [],
|
||||
subscriptionState: storedSnapshot?.subscriptionState ?? 'free',
|
||||
}
|
||||
}
|
||||
|
||||
function readJSON<T>(storageKey: string): T | undefined {
|
||||
try {
|
||||
const rawValue = window.localStorage.getItem(storageKey)
|
||||
|
||||
if (!rawValue) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return JSON.parse(rawValue) as T
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function persistSettings(snapshot: SettingsSnapshot) {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot))
|
||||
}
|
||||
|
||||
// Pagina Impostazioni: gestisce preferenze feed e account con una struttura a tab.
|
||||
function SettingsPage() {
|
||||
const [activeTab, setActiveTab] = useState<SettingsTab>('interests')
|
||||
const [selectedMacroTopics, setSelectedMacroTopics] = useState<string[]>(() =>
|
||||
readInitialSettings().selectedMacroTopics,
|
||||
)
|
||||
const [keywords, setKeywords] = useState<string[]>(() => readInitialSettings().keywords)
|
||||
const [keywordInput, setKeywordInput] = useState('')
|
||||
const [subscriptionState, setSubscriptionState] = useState<SubscriptionState>(
|
||||
() => readInitialSettings().subscriptionState,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
fetchProfile()
|
||||
.then((res) => {
|
||||
if (res && res.profile) {
|
||||
setSelectedMacroTopics(res.profile.macroTopics || [])
|
||||
setKeywords(res.profile.keywords || [])
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
const handleToggleMacroTopic = (topic: string) => {
|
||||
// Toggle semplice: se la categoria esiste la rimuove, altrimenti la aggiunge.
|
||||
setSelectedMacroTopics((currentTopics) =>
|
||||
currentTopics.includes(topic)
|
||||
? currentTopics.filter((savedTopic) => savedTopic !== topic)
|
||||
: [...currentTopics, topic],
|
||||
)
|
||||
}
|
||||
|
||||
const handleSaveMacroTopics = () => {
|
||||
updateProfile({ macroTopics: selectedMacroTopics })
|
||||
.then((res) => {
|
||||
if (res && res.profile) {
|
||||
setSelectedMacroTopics(res.profile.macroTopics || selectedMacroTopics)
|
||||
}
|
||||
persistSettings({ selectedMacroTopics, keywords, subscriptionState })
|
||||
})
|
||||
.catch(() => {
|
||||
// fallback local persist
|
||||
persistSettings({ selectedMacroTopics, keywords, subscriptionState })
|
||||
})
|
||||
}
|
||||
|
||||
const handleAddKeyword = (rawKeyword: string) => {
|
||||
const cleanedKeyword = rawKeyword.trim()
|
||||
|
||||
// Evita duplicati e valori vuoti per mantenere la lista leggibile.
|
||||
if (!cleanedKeyword || keywords.includes(cleanedKeyword)) {
|
||||
return
|
||||
}
|
||||
|
||||
setKeywords((currentKeywords) => [...currentKeywords, cleanedKeyword])
|
||||
setKeywordInput('')
|
||||
}
|
||||
|
||||
const handleRemoveKeyword = (keywordToRemove: string) => {
|
||||
setKeywords((currentKeywords) =>
|
||||
currentKeywords.filter((savedKeyword) => savedKeyword !== keywordToRemove),
|
||||
)
|
||||
}
|
||||
|
||||
const handleSaveKeywords = () => {
|
||||
updateProfile({ keywords })
|
||||
.then((res) => {
|
||||
if (res && res.profile) {
|
||||
setKeywords(res.profile.keywords || keywords)
|
||||
}
|
||||
persistSettings({ selectedMacroTopics, keywords, subscriptionState })
|
||||
})
|
||||
.catch(() => {
|
||||
persistSettings({ selectedMacroTopics, keywords, subscriptionState })
|
||||
})
|
||||
}
|
||||
|
||||
const handleUpgrade = () => {
|
||||
const nextSubscriptionState: SubscriptionState = 'pro'
|
||||
setSubscriptionState(nextSubscriptionState)
|
||||
persistSettings({ selectedMacroTopics, keywords, subscriptionState: nextSubscriptionState })
|
||||
}
|
||||
|
||||
const handleCancelSubscription = () => {
|
||||
const nextSubscriptionState: SubscriptionState = 'free'
|
||||
setSubscriptionState(nextSubscriptionState)
|
||||
persistSettings({ selectedMacroTopics, keywords, subscriptionState: nextSubscriptionState })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="feed-layout settings-layout" aria-label="Impostazioni BriefAI">
|
||||
<FeedSidebar activeItem="impostazioni" />
|
||||
|
||||
<section className="feed-main settings-main">
|
||||
<FeedTopbar />
|
||||
|
||||
<div className="feed-content settings-content">
|
||||
<header className="settings-header">
|
||||
<h1>Impostazioni</h1>
|
||||
<p>Gestisci preferenze e profilo</p>
|
||||
</header>
|
||||
|
||||
<SettingsTabs activeTab={activeTab} onTabChange={setActiveTab} />
|
||||
|
||||
{activeTab === 'interests' ? (
|
||||
<div className="settings-stack">
|
||||
<InterestPreferences
|
||||
selectedMacroTopics={selectedMacroTopics}
|
||||
onToggleMacroTopic={handleToggleMacroTopic}
|
||||
onSaveMacroTopics={handleSaveMacroTopics}
|
||||
/>
|
||||
|
||||
<TrackedKeywords
|
||||
keywords={keywords}
|
||||
keywordInput={keywordInput}
|
||||
onKeywordInputChange={setKeywordInput}
|
||||
onAddKeyword={handleAddKeyword}
|
||||
onRemoveKeyword={handleRemoveKeyword}
|
||||
onSaveKeywords={handleSaveKeywords}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<AccountSettings
|
||||
username="John Doe"
|
||||
email="john@example.com"
|
||||
subscriptionState={subscriptionState}
|
||||
onUpgrade={handleUpgrade}
|
||||
onCancelSubscription={handleCancelSubscription}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SettingsPage
|
||||
@@ -0,0 +1,289 @@
|
||||
.trends-content {
|
||||
max-width: 1280px;
|
||||
}
|
||||
|
||||
.trends-header {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.trends-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.trends-flame {
|
||||
font-size: 32px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.trends-header h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(34px, 4vw, 48px);
|
||||
font-weight: 800;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.trends-header p {
|
||||
margin: 10px 0 0;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.trends-stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 14px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.trend-stat-card {
|
||||
border: 1px solid;
|
||||
border-radius: 18px;
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.trend-stat-card.hottest {
|
||||
border-color: #fb923c;
|
||||
background: linear-gradient(135deg, #fff7ed, #fee2e2);
|
||||
color: #9a3412;
|
||||
}
|
||||
|
||||
.trend-stat-card.emerging {
|
||||
border-color: #60a5fa;
|
||||
background: linear-gradient(135deg, #eff6ff, #f5f3ff);
|
||||
color: #1e3a8a;
|
||||
}
|
||||
|
||||
.trend-stat-card.growth {
|
||||
border-color: #4ade80;
|
||||
background: linear-gradient(135deg, #ecfdf5, #d1fae5);
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.trend-stat-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.trend-stat-icon svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.trend-stat-label {
|
||||
margin: 8px 0 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.trend-stat-value {
|
||||
margin: 0;
|
||||
font-size: clamp(26px, 3vw, 32px);
|
||||
font-weight: 800;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.trend-stat-meta {
|
||||
margin: 6px 0 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
.trends-list {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.topic-card-row {
|
||||
display: grid;
|
||||
grid-template-columns: 48px minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
padding: 14px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 18px;
|
||||
background: #fff;
|
||||
box-shadow: 0 10px 26px rgba(15, 23, 42, 0.04);
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.topic-card-row:hover {
|
||||
border-color: #60a5fa;
|
||||
box-shadow: 0 16px 36px rgba(37, 99, 235, 0.12);
|
||||
}
|
||||
|
||||
.topic-rank {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-weight: 800;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.topic-rank.rank-1 {
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #f97316, #ef4444);
|
||||
}
|
||||
|
||||
.topic-rank.rank-2 {
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
|
||||
}
|
||||
|
||||
.topic-rank.rank-3 {
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #22c55e, #10b981);
|
||||
}
|
||||
|
||||
.topic-rank.rank-default {
|
||||
color: #4b5563;
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.topic-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.topic-top-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.topic-top-row h2 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
line-height: 1.2;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.topic-sentiment {
|
||||
border-radius: 999px;
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.topic-sentiment.positive {
|
||||
color: #166534;
|
||||
background: #dcfce7;
|
||||
}
|
||||
|
||||
.topic-sentiment.negative {
|
||||
color: #991b1b;
|
||||
background: #fee2e2;
|
||||
}
|
||||
|
||||
.topic-sentiment.neutral {
|
||||
color: #475569;
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
.topic-description {
|
||||
margin: 8px 0 0;
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.topic-metrics-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.topic-growth {
|
||||
margin: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #16a34a;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.topic-growth svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.topic-volume,
|
||||
.topic-articles {
|
||||
margin: 0;
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.topic-volume strong,
|
||||
.topic-articles strong {
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.topic-progress-track {
|
||||
margin-top: 12px;
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: #e5e7eb;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.topic-progress-fill {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.topic-progress-fill.top {
|
||||
background: linear-gradient(90deg, #f97316, #ef4444);
|
||||
}
|
||||
|
||||
.topic-progress-fill.standard {
|
||||
background: linear-gradient(90deg, #3b82f6, #8b5cf6);
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.trends-stats-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.trends-stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.topic-card-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.topic-rank {
|
||||
width: 48px;
|
||||
}
|
||||
|
||||
.topic-top-row {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.topic-top-row h2 {
|
||||
font-size: 21px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import FeedSidebar from '../components/FeedSidebar'
|
||||
import FeedTopbar from '../components/FeedTopbar'
|
||||
import './FeedPage.css'
|
||||
import './TrendsPage.css'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { fetchTrendingTopics, fetchSentimentStats } from '../services/apiService'
|
||||
|
||||
type Sentiment = 'Positive' | 'Negative' | 'Neutral'
|
||||
|
||||
type TrendingTopic = {
|
||||
rank: number
|
||||
name: string
|
||||
description: string
|
||||
volume: number
|
||||
growth: number
|
||||
articles: number
|
||||
sentiment: Sentiment
|
||||
}
|
||||
|
||||
const defaultTrendingTopics: TrendingTopic[] = [
|
||||
{
|
||||
rank: 1,
|
||||
name: 'AI Safety',
|
||||
description: 'Major breakthroughs in AI safety frameworks and alignment research',
|
||||
volume: 1420,
|
||||
growth: 45,
|
||||
articles: 87,
|
||||
sentiment: 'Positive',
|
||||
},
|
||||
{
|
||||
rank: 2,
|
||||
name: 'Quantum Computing',
|
||||
description: 'New quantum processors and cryptography implications',
|
||||
volume: 980,
|
||||
growth: 32,
|
||||
articles: 54,
|
||||
sentiment: 'Neutral',
|
||||
},
|
||||
{
|
||||
rank: 3,
|
||||
name: 'Fintech Investment',
|
||||
description: 'Record funding rounds in AI-powered financial solutions',
|
||||
volume: 870,
|
||||
growth: 28,
|
||||
articles: 62,
|
||||
sentiment: 'Positive',
|
||||
},
|
||||
{
|
||||
rank: 4,
|
||||
name: 'Crypto Regulation',
|
||||
description: 'Increased regulatory scrutiny and compliance requirements',
|
||||
volume: 760,
|
||||
growth: 18,
|
||||
articles: 48,
|
||||
sentiment: 'Negative',
|
||||
},
|
||||
{
|
||||
rank: 5,
|
||||
name: 'Clean Energy',
|
||||
description: 'Battery technology breakthroughs and renewable energy adoption',
|
||||
volume: 640,
|
||||
growth: 15,
|
||||
articles: 41,
|
||||
sentiment: 'Positive',
|
||||
},
|
||||
{
|
||||
rank: 6,
|
||||
name: 'Remote Work Tech',
|
||||
description: 'New collaboration tools and hybrid workplace solutions',
|
||||
volume: 520,
|
||||
growth: 12,
|
||||
articles: 35,
|
||||
sentiment: 'Neutral',
|
||||
},
|
||||
{
|
||||
rank: 7,
|
||||
name: 'Cybersecurity',
|
||||
description: 'Rising threats and zero-trust architecture adoption',
|
||||
volume: 480,
|
||||
growth: 10,
|
||||
articles: 32,
|
||||
sentiment: 'Negative',
|
||||
},
|
||||
{
|
||||
rank: 8,
|
||||
name: 'Space Tech',
|
||||
description: 'Commercial space launches and satellite communications',
|
||||
volume: 420,
|
||||
growth: 8,
|
||||
articles: 28,
|
||||
sentiment: 'Positive',
|
||||
},
|
||||
]
|
||||
|
||||
function formatMentions(value: number) {
|
||||
return value.toLocaleString('en-US')
|
||||
}
|
||||
|
||||
function getSentimentClass(sentiment: Sentiment) {
|
||||
if (sentiment === 'Positive') {
|
||||
return 'topic-sentiment positive'
|
||||
}
|
||||
|
||||
if (sentiment === 'Negative') {
|
||||
return 'topic-sentiment negative'
|
||||
}
|
||||
|
||||
return 'topic-sentiment neutral'
|
||||
}
|
||||
|
||||
function getRankClass(rank: number) {
|
||||
if (rank === 1) return 'topic-rank rank-1'
|
||||
if (rank === 2) return 'topic-rank rank-2'
|
||||
if (rank === 3) return 'topic-rank rank-3'
|
||||
return 'topic-rank rank-default'
|
||||
}
|
||||
|
||||
function getProgressClass(rank: number) {
|
||||
return rank === 1 ? 'topic-progress-fill top' : 'topic-progress-fill standard'
|
||||
}
|
||||
|
||||
function TrendsPage() {
|
||||
const [topics, setTopics] = useState<TrendingTopic[]>(defaultTrendingTopics)
|
||||
const [sentimentOverview, setSentimentOverview] = useState<any>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetchTrendingTopics()
|
||||
.then((data) => {
|
||||
if (Array.isArray(data) && data.length) setTopics(data)
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
fetchSentimentStats()
|
||||
.then((data) => setSentimentOverview(data))
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="feed-layout" aria-label="Trending Topics BriefAI">
|
||||
<FeedSidebar activeItem="tendenze" />
|
||||
|
||||
<section className="feed-main">
|
||||
<FeedTopbar />
|
||||
|
||||
<section className="feed-content trends-content" aria-label="Contenuto trending topics">
|
||||
<header className="trends-header">
|
||||
<div className="trends-title-row">
|
||||
<span className="trends-flame" aria-hidden="true">
|
||||
🔥
|
||||
</span>
|
||||
<h1>Trending Topics</h1>
|
||||
</div>
|
||||
<p>Hot topics ranked by growth and volume</p>
|
||||
</header>
|
||||
|
||||
<section className="trends-stats-grid" aria-label="Statistiche trend principali">
|
||||
<article className="trend-stat-card hottest">
|
||||
<span className="trend-stat-icon" aria-hidden="true">
|
||||
🔥
|
||||
</span>
|
||||
<p className="trend-stat-label">Hottest Topic</p>
|
||||
<p className="trend-stat-value">AI Safety</p>
|
||||
<p className="trend-stat-meta">+45% growth this week</p>
|
||||
</article>
|
||||
|
||||
<article className="trend-stat-card emerging">
|
||||
<span className="trend-stat-icon" aria-hidden="true">
|
||||
<TrendingUpMiniIcon />
|
||||
</span>
|
||||
<p className="trend-stat-label">Emerging Topics</p>
|
||||
<p className="trend-stat-value">12</p>
|
||||
<p className="trend-stat-meta">New this week</p>
|
||||
</article>
|
||||
|
||||
<article className="trend-stat-card growth">
|
||||
<span className="trend-stat-icon" aria-hidden="true">
|
||||
<ArrowUpMiniIcon />
|
||||
</span>
|
||||
<p className="trend-stat-label">Avg. Growth</p>
|
||||
<p className="trend-stat-value">+21%</p>
|
||||
<p className="trend-stat-meta">Across all topics</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section className="trends-list" aria-label="Lista trending topics">
|
||||
{topics.map((topic) => (
|
||||
<article key={topic.rank} className="topic-card-row">
|
||||
<div className={getRankClass(topic.rank)} aria-label={`Rank ${topic.rank}`}>
|
||||
#{topic.rank}
|
||||
</div>
|
||||
|
||||
<div className="topic-main">
|
||||
<div className="topic-top-row">
|
||||
<h2>{topic.name}</h2>
|
||||
<span className={getSentimentClass(topic.sentiment)}>{topic.sentiment}</span>
|
||||
</div>
|
||||
|
||||
<p className="topic-description">{topic.description}</p>
|
||||
|
||||
<div className="topic-metrics-row" aria-label={`Metriche ${topic.name}`}>
|
||||
<p className="topic-growth">
|
||||
<TrendingUpMiniIcon />
|
||||
<span>{`+${topic.growth}%`}</span>
|
||||
</p>
|
||||
<p className="topic-volume">
|
||||
<strong>{formatMentions(topic.volume)}</strong> mentions
|
||||
</p>
|
||||
<p className="topic-articles">
|
||||
<strong>{topic.articles}</strong> articles
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="topic-progress-track" aria-hidden="true">
|
||||
<div
|
||||
className={getProgressClass(topic.rank)}
|
||||
style={{ width: `${Math.min((topic.growth / 50) * 100, 100)}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TrendingUpMiniIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M3 16l6-6 4 4 8-8" />
|
||||
<path d="M14 6h7v7" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function ArrowUpMiniIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M12 19V5" />
|
||||
<path d="M6 11l6-6 6 6" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default TrendsPage
|
||||
@@ -0,0 +1,66 @@
|
||||
const BASE = import.meta.env.VITE_API_URL as string
|
||||
import { getAuthHeader } from './authService'
|
||||
|
||||
const authFetch = (path: string, options: RequestInit = {}) =>
|
||||
fetch(`${BASE}${path}`, {
|
||||
...options,
|
||||
headers: { ...getAuthHeader(), ...options.headers },
|
||||
})
|
||||
|
||||
// GET /api/stats/sentiment
|
||||
export const fetchSentimentStats = () =>
|
||||
authFetch('/api/stats/sentiment').then((r) => r.json())
|
||||
|
||||
// GET /api/stats/trending
|
||||
export const fetchTrendingTopics = () =>
|
||||
authFetch('/api/stats/trending').then((r) => r.json())
|
||||
|
||||
// GET /api/stats/categories
|
||||
export const fetchCategoryStats = () =>
|
||||
authFetch('/api/stats/categories').then((r) => r.json())
|
||||
|
||||
// GET /api/stats/sources
|
||||
export const fetchSourceStats = () =>
|
||||
authFetch('/api/stats/sources').then((r) => r.json())
|
||||
|
||||
// GET /api/stats/overview
|
||||
export const fetchOverview = () =>
|
||||
authFetch('/api/stats/overview').then((r) => r.json())
|
||||
|
||||
// GET /api/profile
|
||||
export const fetchProfile = () =>
|
||||
authFetch('/api/profile').then((r) => r.json())
|
||||
|
||||
// PUT /api/profile
|
||||
export const updateProfile = async (data: {
|
||||
userId?: string
|
||||
macroTopics?: string[]
|
||||
keywords?: string[]
|
||||
}) => {
|
||||
// Sync to backend
|
||||
const backendRes = await authFetch('/api/profile', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
}).then((r) => r.json());
|
||||
|
||||
// Opt-in sync to n8n if url is present and userId is known
|
||||
const n8nUrl = import.meta.env.VITE_N8N_URL;
|
||||
if (n8nUrl && data.userId && data.macroTopics) {
|
||||
try {
|
||||
await fetch(`${n8nUrl}/briefai/profile/update`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
userId: data.userId,
|
||||
macroTopics: data.macroTopics,
|
||||
keywords: data.keywords,
|
||||
})
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn("Failed to sync profile to n8n webhook", e);
|
||||
}
|
||||
}
|
||||
|
||||
return backendRes;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
const BASE = import.meta.env.VITE_API_URL as string;
|
||||
|
||||
// Salva il token dopo login o registrazione
|
||||
const saveToken = (token: string) =>
|
||||
localStorage.setItem('briefai_token', token);
|
||||
|
||||
// Restituisce l'header Authorization da aggiungere a ogni richiesta protetta
|
||||
export const getAuthHeader = (): Record<string, string> => {
|
||||
const token = localStorage.getItem('briefai_token');
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
};
|
||||
|
||||
// Decodifica il payload JWT senza librerie esterne
|
||||
export const decodeToken = (token: string) => {
|
||||
try {
|
||||
return JSON.parse(atob(token.split('.')[1]));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// POST /api/auth/login
|
||||
export const login = async (email: string, password: string) => {
|
||||
const res = await fetch(`${BASE}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
if (!res.ok) throw await res.json();
|
||||
const data = await res.json();
|
||||
saveToken(data.token);
|
||||
return data;
|
||||
};
|
||||
|
||||
// POST /api/auth/register
|
||||
export const register = async (payload: {
|
||||
email: string;
|
||||
password: string;
|
||||
username: string;
|
||||
preferences?: {
|
||||
macroTopics?: string[] | undefined;
|
||||
keywords?: string[] | undefined;
|
||||
} | undefined;
|
||||
}) => {
|
||||
const res = await fetch(`${BASE}/api/auth/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) throw await res.json();
|
||||
const data = await res.json();
|
||||
saveToken(data.token);
|
||||
return data;
|
||||
};
|
||||
|
||||
// GET /api/auth/me — verifica se il token è ancora valido
|
||||
export const getMe = async () => {
|
||||
const res = await fetch(`${BASE}/api/auth/me`, {
|
||||
headers: getAuthHeader(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
localStorage.removeItem('briefai_token');
|
||||
return null;
|
||||
}
|
||||
return res.json();
|
||||
};
|
||||
|
||||
// Logout locale
|
||||
export const logout = () =>
|
||||
localStorage.removeItem('briefai_token');
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Article } from '../types/article'
|
||||
import { getAuthHeader, decodeToken } from './authService'
|
||||
|
||||
const N8N = import.meta.env.VITE_N8N_URL
|
||||
|
||||
export const fetchPersonalizedFeed = async (): Promise<Article[]> => {
|
||||
console.log("🚀 N8N URL:", N8N);
|
||||
const token = localStorage.getItem('briefai_token')
|
||||
if (!token) throw new Error('Non autenticato')
|
||||
|
||||
const payload = decodeToken(token)
|
||||
const userId: string = payload?.userId
|
||||
if (!userId) throw new Error('Token non valido')
|
||||
|
||||
const res = await fetch(`${N8N}/briefai/feed`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...getAuthHeader() },
|
||||
body: JSON.stringify({ userId, limit: 20 }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const status = res.status
|
||||
const statusText = res.statusText
|
||||
const errorText = await res.text().catch(() => 'non leggibile')
|
||||
console.error('[FeedService] HTTP Error:', { status, statusText, errorText })
|
||||
throw new Error(`Errore nel recupero del feed: ${status} ${statusText}`)
|
||||
}
|
||||
const data = await res.json()
|
||||
console.log('[FeedService] Response:', data)
|
||||
const articles = (data.articles ?? []).map((a: any) => {
|
||||
const idVal = a.uniqueKey ?? a.id ?? a._id ?? ''
|
||||
const uniqueKey = typeof idVal === 'object' ? String(idVal) : idVal
|
||||
return { ...a, uniqueKey }
|
||||
})
|
||||
return articles
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
const N8N = import.meta.env.VITE_N8N_URL
|
||||
|
||||
export const sendFeedback = async (
|
||||
articleId: string,
|
||||
vote: 1 | -1
|
||||
): Promise<void> => {
|
||||
const token = localStorage.getItem('briefai_token')
|
||||
if (!token) return
|
||||
|
||||
const payload = JSON.parse(atob(token.split('.')[1]))
|
||||
|
||||
await fetch(`${N8N}/briefai/feedback`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
userId: payload.userId,
|
||||
articleId,
|
||||
vote,
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export interface Article {
|
||||
uniqueKey: string;
|
||||
title: string;
|
||||
url: string;
|
||||
pubDate: string;
|
||||
source: string;
|
||||
category: string;
|
||||
summary: string;
|
||||
sentiment: 'Positive' | 'Negative' | 'Neutral';
|
||||
entities: string[];
|
||||
trendingTopics: string[];
|
||||
macroTopics?: string[];
|
||||
score?: number;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "esnext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react, { reactCompilerPreset } from '@vitejs/plugin-react'
|
||||
import babel from '@rolldown/plugin-babel'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
babel({ presets: [reactCompilerPreset()] })
|
||||
],
|
||||
})
|
||||