Prima versione della web app BriefAI

This commit is contained in:
Diego-C-05
2026-05-05 10:24:46 +02:00
commit 2955588c13
73 changed files with 28223 additions and 0 deletions
+7
View File
@@ -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
File diff suppressed because it is too large Load Diff
@@ -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 };
File diff suppressed because it is too large Load Diff
@@ -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"
]
}
}
Binary file not shown.

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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

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
+2
View File
@@ -0,0 +1,2 @@
VITE_API_URL=http://localhost:5000
VITE_N8N_URL=https://n8n-cipolla.ampere.lucasacchi.net/webhook
+24
View File
@@ -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?
+75
View File
@@ -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...
},
},
])
```
+23
View File
@@ -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,
},
},
])
+13
View File
@@ -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>
+3131
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -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"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -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

+14
View File
@@ -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;
}
+81
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+1
View File
@@ -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

File diff suppressed because one or more lines are too long

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
+42
View File
@@ -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;
}
+13
View File
@@ -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>,
)
+441
View File
@@ -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));
}
}
+37
View File
@@ -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
+373
View File
@@ -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;
}
}
+163
View File
@@ -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
+112
View File
@@ -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;
}
+85
View File
@@ -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';
+139
View File
@@ -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
+309
View File
@@ -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;
}
}
+198
View File
@@ -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
+289
View File
@@ -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;
}
}
+247
View File
@@ -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,
}),
})
}
+14
View File
@@ -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;
}
+25
View File
@@ -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"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+24
View File
@@ -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"]
}
+11
View File
@@ -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()] })
],
})