feat:migliorato pulsanti voto per poter votare piu volte
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import MagicCard from './MagicCard'
|
||||
import { fetchPersonalizedFeed } from '../services/feedService'
|
||||
import { sendFeedback } from '../services/feedbackService'
|
||||
import type { Article } from '../types/article'
|
||||
|
||||
type FeedContentProps = {
|
||||
@@ -11,6 +12,46 @@ type FeedContentProps = {
|
||||
function FeedContent({ sentimentFilter = null, topicsFilter = null }: FeedContentProps) {
|
||||
const [articles, setArticles] = useState<Article[]>([])
|
||||
const [status, setStatus] = useState<'loading' | 'ok' | 'error'>('loading')
|
||||
const [voteByArticle, setVoteByArticle] = useState<Record<string, 1 | -1 | null>>({})
|
||||
const [pendingByArticle, setPendingByArticle] = useState<Record<string, boolean>>({})
|
||||
const [voteError, setVoteError] = useState<string | null>(null)
|
||||
|
||||
const sendVoteDelta = async (articleId: string, previousVote: 1 | -1 | null, nextVote: 1 | -1 | null) => {
|
||||
// Without server idempotency, simulate undo by applying compensating vote.
|
||||
if (previousVote === nextVote) return
|
||||
|
||||
if (previousVote === null && nextVote !== null) {
|
||||
await sendFeedback(articleId, nextVote)
|
||||
return
|
||||
}
|
||||
|
||||
if (previousVote !== null && nextVote === null) {
|
||||
await sendFeedback(articleId, previousVote === 1 ? -1 : 1)
|
||||
return
|
||||
}
|
||||
|
||||
if (previousVote !== null && nextVote !== null) {
|
||||
await sendFeedback(articleId, nextVote)
|
||||
}
|
||||
}
|
||||
|
||||
const handleVoteChange = async (articleId: string, nextVote: 1 | -1 | null) => {
|
||||
const previousVote = voteByArticle[articleId] ?? null
|
||||
if (previousVote === nextVote || pendingByArticle[articleId]) return
|
||||
|
||||
setVoteError(null)
|
||||
setVoteByArticle((prev) => ({ ...prev, [articleId]: nextVote }))
|
||||
setPendingByArticle((prev) => ({ ...prev, [articleId]: true }))
|
||||
|
||||
try {
|
||||
await sendVoteDelta(articleId, previousVote, nextVote)
|
||||
} catch {
|
||||
setVoteByArticle((prev) => ({ ...prev, [articleId]: previousVote }))
|
||||
setVoteError('Impossibile aggiornare il voto. Riprova.')
|
||||
} finally {
|
||||
setPendingByArticle((prev) => ({ ...prev, [articleId]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
// Prende il fetch del feed personalizzato e popola la variabile di stato con i dati
|
||||
useEffect(() => {fetchPersonalizedFeed().then((data) => {
|
||||
@@ -30,6 +71,7 @@ function FeedContent({ sentimentFilter = null, topicsFilter = null }: FeedConten
|
||||
{/* Gestione degli stati*/}
|
||||
{status === 'loading' && <p>Caricamento feed...</p>}
|
||||
{status === 'error' && <p>Errore nel caricamento del feed.</p>}
|
||||
{voteError && <p className="feed-no-results">{voteError}</p>}
|
||||
{/*Render della lista di articoli se il caricamento è andato a buon fine*/}
|
||||
{status === 'ok' && (
|
||||
<>
|
||||
@@ -78,6 +120,9 @@ function FeedContent({ sentimentFilter = null, topicsFilter = null }: FeedConten
|
||||
summary={a.summary}
|
||||
tags={a.macroTopics?.length ? a.macroTopics : (a.trendingTopics || [a.category])}
|
||||
entities={a.entities || []}
|
||||
voteState={voteByArticle[a.uniqueKey] ?? null}
|
||||
votePending={pendingByArticle[a.uniqueKey] ?? false}
|
||||
onVoteChange={handleVoteChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { sendFeedback } from '../services/feedbackService'
|
||||
|
||||
type MagicCardProps = {
|
||||
articleId?: string
|
||||
@@ -12,16 +10,18 @@ type MagicCardProps = {
|
||||
summary: string
|
||||
tags: string[]
|
||||
entities: string[]
|
||||
voteState: 1 | -1 | null
|
||||
votePending?: boolean
|
||||
onVoteChange: (articleId: string, nextVote: 1 | -1 | null) => void
|
||||
}
|
||||
|
||||
// Card notizia: mostra fonte, sentiment, testo, tag, entità e azioni rapide.
|
||||
function MagicCard({ articleId, articleUrl, source, timeAgo, sentiment, title, summary, tags, entities }: MagicCardProps) {
|
||||
const [voted, setVoted] = useState<1 | -1 | null>(null)
|
||||
function MagicCard({ articleId, articleUrl, source, timeAgo, sentiment, title, summary, tags, entities, voteState, votePending = false, onVoteChange }: MagicCardProps) {
|
||||
const handleVote = (clickedVote: 1 | -1) => {
|
||||
if (!articleId || votePending) return
|
||||
|
||||
const handleVote = (vote: 1 | -1) => {
|
||||
if (voted !== null) return
|
||||
setVoted(vote)
|
||||
if (articleId) sendFeedback(articleId, vote)
|
||||
const nextVote = voteState === clickedVote ? null : clickedVote
|
||||
onVoteChange(articleId, nextVote)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -79,8 +79,8 @@ function MagicCard({ articleId, articleUrl, source, timeAgo, sentiment, title, s
|
||||
label="Mi piace"
|
||||
tone="like"
|
||||
onClick={() => handleVote(1)}
|
||||
disabled={voted !== null}
|
||||
style={{ opacity: voted === -1 ? 0.4 : 1 }}
|
||||
disabled={votePending}
|
||||
style={{ opacity: voteState === -1 ? 0.4 : 1 }}
|
||||
>
|
||||
<ThumbUpIcon />
|
||||
</ActionButton>
|
||||
@@ -88,8 +88,8 @@ function MagicCard({ articleId, articleUrl, source, timeAgo, sentiment, title, s
|
||||
label="Non mi piace"
|
||||
tone="dislike"
|
||||
onClick={() => handleVote(-1)}
|
||||
disabled={voted !== null}
|
||||
style={{ opacity: voted === 1 ? 0.4 : 1 }}
|
||||
disabled={votePending}
|
||||
style={{ opacity: voteState === 1 ? 0.4 : 1 }}
|
||||
>
|
||||
<ThumbDownIcon />
|
||||
</ActionButton>
|
||||
|
||||
@@ -1,21 +1,48 @@
|
||||
const N8N = import.meta.env.VITE_N8N_URL
|
||||
const FEEDBACK_TIMEOUT_MS = 5000
|
||||
|
||||
export const sendFeedback = async (
|
||||
articleId: string,
|
||||
vote: 1 | -1
|
||||
): Promise<void> => {
|
||||
const token = localStorage.getItem('briefai_token')
|
||||
if (!token) return
|
||||
if (!token) throw new Error('Token mancante')
|
||||
|
||||
const payload = JSON.parse(atob(token.split('.')[1]))
|
||||
if (!N8N) throw new Error('VITE_N8N_URL non configurata')
|
||||
|
||||
await fetch(`${N8N}/briefai/feedback`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
userId: payload.userId,
|
||||
articleId,
|
||||
vote,
|
||||
}),
|
||||
})
|
||||
let payload: { userId?: string } | null = null
|
||||
try {
|
||||
payload = JSON.parse(atob(token.split('.')[1]))
|
||||
} catch {
|
||||
throw new Error('Token non valido')
|
||||
}
|
||||
|
||||
if (!payload?.userId) throw new Error('userId non presente nel token')
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), FEEDBACK_TIMEOUT_MS)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${N8N}/briefai/feedback`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
userId: payload.userId,
|
||||
articleId,
|
||||
vote,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Feedback HTTP ${response.status}`)
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw new Error('Timeout feedback')
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user