217 lines
6.8 KiB
TypeScript
217 lines
6.8 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react'
|
|
import type { Review, Article } from '@/types/nostr'
|
|
import { getReviewsForArticle } from '@/lib/reviews'
|
|
import { getReviewTipsForArticle } from '@/lib/reviewAggregation'
|
|
import { ReviewForm } from './ReviewForm'
|
|
import { ReviewTipForm } from './ReviewTipForm'
|
|
import { t } from '@/lib/i18n'
|
|
|
|
interface ArticleReviewsProps {
|
|
article: Article
|
|
authorPubkey: string
|
|
}
|
|
|
|
export function ArticleReviews({ article, authorPubkey }: ArticleReviewsProps): React.ReactElement {
|
|
const data = useArticleReviewsData({ articleId: article.id, authorPubkey })
|
|
const reviewForm = useReviewFormState({ reload: data.reload })
|
|
const tipSelection = useReviewTipSelection({ article, reviews: data.reviews, reload: data.reload })
|
|
|
|
return (
|
|
<div className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark space-y-4">
|
|
<ArticleReviewsHeader tips={data.tips} onAddReview={reviewForm.open} />
|
|
{reviewForm.show && (
|
|
<ReviewForm
|
|
article={article}
|
|
onSuccess={reviewForm.onSuccess}
|
|
onCancel={reviewForm.close}
|
|
/>
|
|
)}
|
|
{data.loading && <p className="text-sm text-cyber-accent">{t('common.loading')}</p>}
|
|
{data.error && <p className="text-sm text-red-400">{data.error}</p>}
|
|
{!data.loading && !data.error && data.reviews.length === 0 && !reviewForm.show && (
|
|
<p className="text-sm text-cyber-accent/70">{t('review.empty')}</p>
|
|
)}
|
|
{!data.loading && !data.error && <ArticleReviewsList reviews={data.reviews} onTipReview={tipSelection.select} />}
|
|
<SelectedReviewTipForm selection={tipSelection} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
interface ReviewTipSelectionController {
|
|
article: Article
|
|
selectedReviewForTip: Review | null
|
|
onTipSuccess: () => void
|
|
clear: () => void
|
|
select: (reviewId: string) => void
|
|
}
|
|
|
|
interface ArticleReviewsData {
|
|
reviews: Review[]
|
|
tips: number
|
|
loading: boolean
|
|
error: string | null
|
|
reload: () => Promise<void>
|
|
}
|
|
|
|
function useArticleReviewsData({
|
|
articleId,
|
|
authorPubkey,
|
|
}: {
|
|
articleId: string
|
|
authorPubkey: string
|
|
}): ArticleReviewsData {
|
|
const [reviews, setReviews] = useState<Review[]>([])
|
|
const [tips, setTips] = useState<number>(0)
|
|
const [loading, setLoading] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
const reload = useCallback(async (): Promise<void> => {
|
|
setLoading(true)
|
|
setError(null)
|
|
try {
|
|
const [list, tipsTotal] = await Promise.all([
|
|
getReviewsForArticle(articleId),
|
|
getReviewTipsForArticle({ authorPubkey, articleId }),
|
|
])
|
|
setReviews(list)
|
|
setTips(tipsTotal)
|
|
} catch (loadError) {
|
|
setError(loadError instanceof Error ? loadError.message : 'Erreur lors du chargement des critiques')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [articleId, authorPubkey])
|
|
|
|
useEffect(() => {
|
|
void reload()
|
|
}, [reload])
|
|
|
|
return {
|
|
reviews,
|
|
tips,
|
|
loading,
|
|
error,
|
|
reload,
|
|
}
|
|
}
|
|
|
|
function useReviewFormState({ reload }: { reload: () => Promise<void> }): {
|
|
show: boolean
|
|
open: () => void
|
|
close: () => void
|
|
onSuccess: () => void
|
|
} {
|
|
const [show, setShow] = useState(false)
|
|
const open = useCallback((): void => setShow(true), [])
|
|
const close = useCallback((): void => setShow(false), [])
|
|
|
|
const onSuccess = useCallback((): void => {
|
|
close()
|
|
void reload()
|
|
}, [close, reload])
|
|
|
|
return { show, open, close, onSuccess }
|
|
}
|
|
|
|
function useReviewTipSelection({
|
|
article,
|
|
reviews,
|
|
reload,
|
|
}: {
|
|
article: Article
|
|
reviews: Review[]
|
|
reload: () => Promise<void>
|
|
}): ReviewTipSelectionController {
|
|
const [selectedReviewId, setSelectedReviewId] = useState<string | null>(null)
|
|
|
|
const select = useCallback((reviewId: string): void => setSelectedReviewId(reviewId), [])
|
|
const clear = useCallback((): void => setSelectedReviewId(null), [])
|
|
|
|
const onTipSuccess = useCallback((): void => {
|
|
clear()
|
|
void reload()
|
|
}, [clear, reload])
|
|
|
|
const selectedReviewForTip = selectedReviewId ? findReviewById(reviews, selectedReviewId) : null
|
|
|
|
return { article, selectedReviewForTip, onTipSuccess, clear, select }
|
|
}
|
|
|
|
function findReviewById(reviews: Review[], reviewId: string): Review | null {
|
|
const review = reviews.find((r) => r.id === reviewId)
|
|
return review ?? null
|
|
}
|
|
|
|
function SelectedReviewTipForm({ selection }: { selection: ReviewTipSelectionController }): React.ReactElement | null {
|
|
if (!selection.selectedReviewForTip) {
|
|
return null
|
|
}
|
|
|
|
return (
|
|
<ReviewTipForm
|
|
review={selection.selectedReviewForTip}
|
|
article={selection.article}
|
|
onSuccess={selection.onTipSuccess}
|
|
onCancel={selection.clear}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function ArticleReviewsHeader({ tips, onAddReview }: { tips: number; onAddReview: () => void }): React.ReactElement {
|
|
return (
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="text-lg font-semibold text-neon-cyan">{t('review.title')}</h3>
|
|
<div className="flex items-center gap-4">
|
|
<span className="text-sm text-cyber-accent/70">{t('review.tips.total', { amount: tips })}</span>
|
|
<button
|
|
onClick={onAddReview}
|
|
className="px-3 py-1 text-sm bg-neon-green/20 hover:bg-neon-green/30 text-neon-green rounded-lg font-medium transition-all border border-neon-green/50"
|
|
>
|
|
{t('review.add')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ArticleReviewsList({ reviews, onTipReview }: { reviews: Review[]; onTipReview: (reviewId: string) => void }): React.ReactElement {
|
|
return (
|
|
<div className="space-y-4">
|
|
{reviews.map((r) => (
|
|
<div key={r.id} className="border-t border-neon-cyan/20 pt-4 space-y-2">
|
|
{r.title && (
|
|
<h4 className="font-semibold text-neon-cyan">{r.title}</h4>
|
|
)}
|
|
<div className="text-cyber-accent whitespace-pre-wrap">{r.content}</div>
|
|
{r.text && (
|
|
<div className="text-sm text-cyber-accent/70 italic border-l-2 border-neon-cyan/30 pl-3">
|
|
{r.text}
|
|
</div>
|
|
)}
|
|
<div className="text-xs text-cyber-accent/50 flex gap-2 items-center">
|
|
<span>{t('review.reviewer')}: {formatPubkey(r.reviewerPubkey)}</span>
|
|
<span>•</span>
|
|
<span>{formatDate(r.createdAt)}</span>
|
|
<button
|
|
onClick={() => {
|
|
onTipReview(r.id)
|
|
}}
|
|
className="ml-auto px-2 py-1 text-xs bg-neon-green/20 hover:bg-neon-green/30 text-neon-green rounded transition-all border border-neon-green/50"
|
|
>
|
|
{t('review.tip.button')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function formatPubkey(pubkey: string): string {
|
|
return `${pubkey.slice(0, 8)}...${pubkey.slice(-8)}`
|
|
}
|
|
|
|
function formatDate(timestamp: number): string {
|
|
return new Date(timestamp * 1000).toLocaleString()
|
|
}
|