diff --git a/components/ArticleCard.tsx b/components/ArticleCard.tsx index 1e46112..e9da8e6 100644 --- a/components/ArticleCard.tsx +++ b/components/ArticleCard.tsx @@ -4,6 +4,7 @@ import { useArticlePayment } from '@/hooks/useArticlePayment' import { ArticlePreview } from './ArticlePreview' import { PaymentModal } from './PaymentModal' import { Card } from './ui' +import { useToast } from './ui/ToastContainer' import { t } from '@/lib/i18n' import Link from 'next/link' @@ -56,40 +57,90 @@ function ArticleMeta({ ) } -export function ArticleCard({ article, onUnlock }: ArticleCardProps): React.ReactElement { - const { pubkey, connect } = useNostrAuth() - const { - loading, - error, - paymentInvoice, - handleUnlock, - handlePaymentComplete, - handleCloseModal, - } = useArticlePayment(article, pubkey ?? null, () => { - onUnlock?.(article) - }, connect) +interface UseArticleCardStateParams { + article: Article + pubkey: string | null + connect: (() => Promise) | undefined + onUnlock: ((article: Article) => void) | undefined + showToast: (message: string, variant?: 'success' | 'info' | 'warning' | 'error', duration?: number) => void +} +function useArticleCardState(params: UseArticleCardStateParams): { + loading: boolean + error: string | null + paymentInvoice: ReturnType['paymentInvoice'] + handleUnlock: () => Promise + handlePaymentComplete: () => Promise + handleCloseModal: () => void +} { + return useArticlePayment({ + article: params.article, + pubkey: params.pubkey, + onUnlockSuccess: () => { + params.showToast(t('article.unlock.success'), 'success') + params.onUnlock?.(params.article) + }, + connect: params.connect, + showToast: params.showToast, + }) +} + +function ArticleCardContent(params: { + article: Article + loading: boolean + error: string | null + paymentInvoice: ReturnType['paymentInvoice'] + handleUnlock: () => Promise + handlePaymentComplete: () => Promise + handleCloseModal: () => void +}): React.ReactElement { return ( - - + <> +
{ - void handleUnlock() + void params.handleUnlock() }} />
{ - void handlePaymentComplete() + void params.handlePaymentComplete() }} /> + + ) +} + +export function ArticleCard({ article, onUnlock }: ArticleCardProps): React.ReactElement { + const { pubkey, connect } = useNostrAuth() + const { showToast } = useToast() + const state = useArticleCardState({ + article, + pubkey: pubkey ?? null, + connect, + onUnlock, + showToast, + }) + + return ( + + ) } diff --git a/components/ArticleEditor.tsx b/components/ArticleEditor.tsx index ce7b276..9abd143 100644 --- a/components/ArticleEditor.tsx +++ b/components/ArticleEditor.tsx @@ -1,9 +1,11 @@ -import { useState } from 'react' +import { useState, useEffect } from 'react' import { Card } from './ui' +import { useToast } from './ui/ToastContainer' import { useNostrAuth } from '@/hooks/useNostrAuth' import { useArticlePublishing } from '@/hooks/useArticlePublishing' import type { ArticleDraft } from '@/lib/articlePublisher' import { ArticleEditorForm } from './ArticleEditorForm' +import { t } from '@/lib/i18n' interface ArticleEditorProps { onPublishSuccess?: (articleId: string) => void @@ -22,8 +24,18 @@ function SuccessMessage(): React.ReactElement { ) } +function usePublishSuccessToast(success: boolean, showToast: (message: string, variant?: 'success' | 'info' | 'warning' | 'error', duration?: number) => void, onPublishSuccess: ((articleId: string) => void) | undefined, draftTitle: string): void { + useEffect(() => { + if (success) { + showToast(t('article.publish.success'), 'success') + onPublishSuccess?.(draftTitle) + } + }, [success, showToast, onPublishSuccess, draftTitle]) +} + export function ArticleEditor({ onPublishSuccess, onCancel, seriesOptions, onSelectSeries, defaultSeriesId }: ArticleEditorProps): React.ReactElement { const { connected, pubkey, connect } = useNostrAuth() + const { showToast } = useToast() const { loading, error, success, relayStatuses, publishArticle } = useArticlePublishing(pubkey ?? null) const [draft, setDraft] = useState({ title: '', @@ -34,6 +46,8 @@ export function ArticleEditor({ onPublishSuccess, onCancel, seriesOptions, onSel ...(defaultSeriesId ? { seriesId: defaultSeriesId } : {}), }) + usePublishSuccessToast(success, showToast, onPublishSuccess, draft.title) + const submit = buildSubmitHandler({ publishArticle, draft, diff --git a/components/ArticlesList.tsx b/components/ArticlesList.tsx index 4d19dcc..8443527 100644 --- a/components/ArticlesList.tsx +++ b/components/ArticlesList.tsx @@ -1,6 +1,6 @@ import type { Article } from '@/types/nostr' import { ArticleCard } from './ArticleCard' -import { ErrorState, EmptyState } from './ui' +import { ErrorState, EmptyState, Skeleton } from './ui' import { t } from '@/lib/i18n' interface ArticlesListProps { @@ -12,11 +12,28 @@ interface ArticlesListProps { unlockedArticles: Set } -function LoadingState(): React.ReactElement { - // Use generic loading message at startup, then specific message once we know what we're loading +function ArticleCardSkeleton(): React.ReactElement { return ( -
-

{t('common.loading.articles')}

+
+
+ + +
+ +
+ + +
+
+ ) +} + +function LoadingState(): React.ReactElement { + return ( +
+ {Array.from({ length: 3 }).map((_, index) => ( + + ))}
) } diff --git a/components/AuthorsList.tsx b/components/AuthorsList.tsx index b99e781..e59a199 100644 --- a/components/AuthorsList.tsx +++ b/components/AuthorsList.tsx @@ -1,6 +1,6 @@ import type { Article } from '@/types/nostr' import { AuthorCard } from './AuthorCard' -import { ErrorState, EmptyState } from './ui' +import { ErrorState, EmptyState, Skeleton } from './ui' import { t } from '@/lib/i18n' interface AuthorsListProps { @@ -10,10 +10,27 @@ interface AuthorsListProps { error: string | null } +function AuthorCardSkeleton(): React.ReactElement { + return ( +
+
+ +
+ + +
+
+ +
+ ) +} + function LoadingState(): React.ReactElement { return ( -
-

{t('common.loading.authors')}

+
+ {Array.from({ length: 4 }).map((_, index) => ( + + ))}
) } diff --git a/components/DocsContent.tsx b/components/DocsContent.tsx index 57d8148..3a58060 100644 --- a/components/DocsContent.tsx +++ b/components/DocsContent.tsx @@ -1,6 +1,5 @@ -import { Card } from './ui' +import { Card, Skeleton } from './ui' import { renderMarkdown } from '@/lib/markdownRenderer' -import { t } from '@/lib/i18n' interface DocsContentProps { content: string @@ -10,9 +9,15 @@ interface DocsContentProps { export function DocsContent({ content, loading }: DocsContentProps): React.ReactElement { if (loading) { return ( -
-

{t('docs.loading')}

-
+ + + + + + + + + ) } diff --git a/components/FundingGauge.tsx b/components/FundingGauge.tsx index 36e9ef8..6a1ffa7 100644 --- a/components/FundingGauge.tsx +++ b/components/FundingGauge.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' import { estimatePlatformFunds } from '@/lib/fundingCalculation' -import { Card } from './ui' +import { Card, Skeleton } from './ui' import { t } from '@/lib/i18n' interface FundingProgressBarProps { @@ -69,8 +69,14 @@ export function FundingGauge(): React.ReactElement { function FundingGaugeLoading(): React.ReactElement { return ( - -

{t('common.loading')}

+ + + +
+ + + +
) } diff --git a/components/PaymentModal.tsx b/components/PaymentModal.tsx index 135f23b..3266329 100644 --- a/components/PaymentModal.tsx +++ b/components/PaymentModal.tsx @@ -4,6 +4,7 @@ import type { AlbyInvoice } from '@/types/alby' import { getAlbyService, isWebLNAvailable } from '@/lib/alby' import { AlbyInstaller } from './AlbyInstaller' import { Card, Modal, Button } from './ui' +import { useToast } from './ui/ToastContainer' import { t } from '@/lib/i18n' interface PaymentModalProps { @@ -137,20 +138,20 @@ type PaymentModalState = { handleOpenWallet: () => Promise } -function usePaymentModalState(invoice: AlbyInvoice, onPaymentComplete: () => void): PaymentModalState { +function usePaymentModalState(invoice: AlbyInvoice, onPaymentComplete: () => void, showToast: ((message: string, variant?: 'success' | 'info' | 'warning' | 'error', duration?: number) => void) | undefined): PaymentModalState { const [copied, setCopied] = useState(false) const [errorMessage, setErrorMessage] = useState(null) const paymentUrl = `lightning:${invoice.invoice}` const timeRemaining = useInvoiceTimer(invoice.expiresAt) const handleCopy = useCallback( - (): Promise => copyInvoiceToClipboard({ invoice: invoice.invoice, setCopied, setErrorMessage }), - [invoice.invoice] + (): Promise => copyInvoiceToClipboard({ invoice: invoice.invoice, setCopied, setErrorMessage, showToast: showToast ?? undefined }), + [invoice.invoice, showToast] ) const handleOpenWallet = useCallback( - (): Promise => openWalletForInvoice({ invoice: invoice.invoice, onPaymentComplete, setErrorMessage }), - [invoice.invoice, onPaymentComplete] + (): Promise => openWalletForInvoice({ invoice: invoice.invoice, onPaymentComplete, setErrorMessage, showToast: showToast ?? undefined }), + [invoice.invoice, onPaymentComplete, showToast] ) return { copied, errorMessage, paymentUrl, timeRemaining, handleCopy, handleOpenWallet } @@ -160,10 +161,14 @@ async function copyInvoiceToClipboard(params: { invoice: string setCopied: (value: boolean) => void setErrorMessage: (value: string | null) => void + showToast: ((message: string, variant?: 'success' | 'info' | 'warning' | 'error', duration?: number) => void) | undefined }): Promise { try { await navigator.clipboard.writeText(params.invoice) params.setCopied(true) + if (params.showToast !== undefined) { + params.showToast(t('payment.modal.copySuccess'), 'success', 2000) + } scheduleCopiedReset(params.setCopied) } catch (e) { console.error('Failed to copy:', e) @@ -175,9 +180,13 @@ async function openWalletForInvoice(params: { invoice: string onPaymentComplete: () => void setErrorMessage: (value: string | null) => void + showToast: ((message: string, variant?: 'success' | 'info' | 'warning' | 'error', duration?: number) => void) | undefined }): Promise { try { await payWithWebLN(params.invoice) + if (params.showToast !== undefined) { + params.showToast(t('payment.modal.paymentInitiated'), 'success') + } params.onPaymentComplete() } catch (e) { const error = normalizePaymentError(e) @@ -211,8 +220,9 @@ async function payWithWebLN(invoice: string): Promise { } export function PaymentModal({ invoice, onClose, onPaymentComplete }: PaymentModalProps): React.ReactElement { + const { showToast } = useToast() const { copied, errorMessage, paymentUrl, timeRemaining, handleCopy, handleOpenWallet } = - usePaymentModalState(invoice, onPaymentComplete) + usePaymentModalState(invoice, onPaymentComplete, showToast) const handleOpenWalletSync = (): void => { void handleOpenWallet() } diff --git a/components/UserArticlesList.tsx b/components/UserArticlesList.tsx index 9a08c1b..9540835 100644 --- a/components/UserArticlesList.tsx +++ b/components/UserArticlesList.tsx @@ -1,5 +1,5 @@ import { ArticleCard } from './ArticleCard' -import { Button, ErrorState } from './ui' +import { Button, ErrorState, Skeleton } from './ui' import type { Article } from '@/types/nostr' import { memo } from 'react' import Link from 'next/link' @@ -21,9 +21,27 @@ interface UserArticlesViewProps { onSelectSeries?: ((seriesId: string | undefined) => void) | undefined } +function ArticleCardSkeleton(): React.ReactElement { + return ( +
+
+ + +
+ +
+ + +
+
+ ) +} + const ArticlesLoading = (): React.ReactElement => ( -
-

{t('common.loading.articles')}

+
+ {Array.from({ length: 3 }).map((_, index) => ( + + ))}
) diff --git a/components/authorPage/AuthorPageContent.tsx b/components/authorPage/AuthorPageContent.tsx index 77766e1..8be2e18 100644 --- a/components/authorPage/AuthorPageContent.tsx +++ b/components/authorPage/AuthorPageContent.tsx @@ -1,4 +1,4 @@ -import { Card } from '../ui' +import { Card, Skeleton } from '../ui' import { t } from '@/lib/i18n' import type { AuthorPresentationArticle, Series } from '@/types/nostr' import { AuthorPageHeader } from './AuthorPageHeader' @@ -15,6 +15,41 @@ type AuthorPageContentProps = { onSeriesCreated: () => void } +function AuthorPageLoadingSkeleton(): React.ReactElement { + return ( +
+
+ + +
+
+ + +
+
+ +
+ {Array.from({ length: 2 }).map((_, index) => ( + + ))} +
+
+
+ ) +} + +function AuthorPageError({ error }: { error: string }): React.ReactElement { + return

{error}

+} + +function AuthorPageNotFound(): React.ReactElement { + return ( + +

{t('author.notFound')}

+
+ ) +} + export function AuthorPageContent({ presentation, series, @@ -25,21 +60,14 @@ export function AuthorPageContent({ onSeriesCreated, }: AuthorPageContentProps): React.ReactElement { if (loading) { - return

{t('common.loading')}

+ return } - if (error) { - return

{error}

+ return } - if (!presentation) { - return ( - -

{t('author.notFound')}

-
- ) + return } - return ( <> diff --git a/components/authorPresentationEditor/AuthorPresentationEditor.tsx b/components/authorPresentationEditor/AuthorPresentationEditor.tsx index 2bc07db..ff63fb6 100644 --- a/components/authorPresentationEditor/AuthorPresentationEditor.tsx +++ b/components/authorPresentationEditor/AuthorPresentationEditor.tsx @@ -1,7 +1,7 @@ import { useEffect } from 'react' import { useNostrAuth } from '@/hooks/useNostrAuth' import { useAuthorPresentation } from '@/hooks/useAuthorPresentation' -import { Button, Card } from '../ui' +import { Button, Card, Skeleton } from '../ui' import { t } from '@/lib/i18n' import { NoAccountView } from './NoAccountView' import { PresentationForm } from './PresentationForm' @@ -28,8 +28,19 @@ function SuccessNotice(params: { pubkey: string | null }): React.ReactElement { function LoadingNotice(): React.ReactElement { return ( -
-

{t('common.loading')}

+
+
+ + +
+
+ + +
+
+ + +
) } diff --git a/components/ui/ToastContainer.tsx b/components/ui/ToastContainer.tsx new file mode 100644 index 0000000..4411c7e --- /dev/null +++ b/components/ui/ToastContainer.tsx @@ -0,0 +1,64 @@ +import React, { useState, useCallback } from 'react' +import { Toast, type ToastVariant } from './Toast' + +interface ToastMessage { + id: string + message: string + variant: ToastVariant + duration?: number +} + +interface ToastContextValue { + showToast: (message: string, variant?: ToastVariant, duration?: number) => void +} + +const ToastContext = React.createContext(undefined) + +function generateToastId(): string { + return `toast-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` +} + +export function useToast(): ToastContextValue { + const context = React.useContext(ToastContext) + if (!context) { + throw new Error('useToast must be used within ToastProvider') + } + return context +} + +export function ToastProvider({ children }: { children: React.ReactNode }): React.ReactElement { + const [toasts, setToasts] = useState([]) + + const showToast = useCallback((message: string, variant: ToastVariant = 'info', duration = 5000): void => { + const id = generateToastId() + setToasts((prev) => [...prev, { id, message, variant, duration }]) + }, []) + + const removeToast = useCallback((id: string): void => { + setToasts((prev) => prev.filter((toast) => toast.id !== id)) + }, []) + + const contextValue: ToastContextValue = { + showToast, + } + + return ( + + {children} +
+ {toasts.map((toast) => ( +
+ removeToast(toast.id)} + aria-label={toast.message} + > + {toast.message} + +
+ ))} +
+
+ ) +} diff --git a/components/ui/index.ts b/components/ui/index.ts index 3b7f6e0..453072c 100644 --- a/components/ui/index.ts +++ b/components/ui/index.ts @@ -6,6 +6,7 @@ export { Badge, type BadgeVariant } from './Badge' export { Skeleton } from './Skeleton' export { Modal } from './Modal' export { Toast, type ToastVariant } from './Toast' +export { ToastProvider } from './ToastContainer' export { MobileMenu } from './MobileMenu' export { EmptyState } from './EmptyState' export { ErrorState } from './ErrorState' diff --git a/docs/migration-status.md b/docs/migration-status.md index 1d0841f..531d35e 100644 --- a/docs/migration-status.md +++ b/docs/migration-status.md @@ -105,6 +105,26 @@ Aucun composant prioritaire restant. Tous les composants principaux ont été mi - ✅ `components/markdownEditorTwoColumns/MarkdownEditorTwoColumns.tsx` - Migration de MarkdownPreview vers Card et textarea vers Textarea - ✅ `components/markdownEditorTwoColumns/PagesManager.tsx` - Nettoyage des classes CSS redondantes dans Textarea (w-full, border, rounded, p-* gérés par le composant) +## Améliorations UX implémentées + +### Skeleton loaders (Priorité haute #1) +- ✅ `components/ArticlesList.tsx` - Remplacement du message "Loading..." par des skeleton loaders pour les cartes d'articles +- ✅ `components/AuthorsList.tsx` - Remplacement du message "Loading..." par des skeleton loaders pour les cartes d'auteurs +- ✅ `components/UserArticlesList.tsx` - Remplacement du message "Loading..." par des skeleton loaders pour les articles utilisateur +- ✅ `components/authorPage/AuthorPageContent.tsx` - Remplacement du message "Loading..." par des skeleton loaders pour la page auteur +- ✅ `components/authorPresentationEditor/AuthorPresentationEditor.tsx` - Remplacement du message "Loading..." par des skeleton loaders pour le formulaire de présentation +- ✅ `components/DocsContent.tsx` - Remplacement du message "Loading..." par des skeleton loaders pour le contenu markdown +- ✅ `components/FundingGauge.tsx` - Remplacement du message "Loading..." par des skeleton loaders pour le gauge de financement + +### Toast notifications (Priorité haute #2) +- ✅ `components/ui/ToastContainer.tsx` - Création du système de gestion des toasts avec ToastProvider et useToast hook +- ✅ `pages/_app.tsx` - Intégration du ToastProvider dans l'application +- ✅ `components/ArticleCard.tsx` - Ajout d'un toast de succès après déblocage réussi d'un article +- ✅ `components/ArticleEditor.tsx` - Ajout d'un toast de succès après publication réussie +- ✅ `components/PaymentModal.tsx` - Ajout d'un toast de succès après copie d'invoice et après initiation du paiement +- ✅ `hooks/useArticlePayment.ts` - Ajout du support showToast pour afficher les toasts après paiement +- ✅ `locales/fr.txt` et `locales/en.txt` - Ajout des clés de traduction pour les toasts (`article.unlock.success`, `article.publish.success`, `payment.modal.copySuccess`, `payment.modal.paymentInitiated`) + ## Erreurs corrigées ### TypeScript diff --git a/docs/todo-remaining.md b/docs/todo-remaining.md index 087a127..c4dbdd6 100644 --- a/docs/todo-remaining.md +++ b/docs/todo-remaining.md @@ -2,59 +2,147 @@ **Date** : 2025-01-27 **Auteur** : Équipe 4NK +**Dernière mise à jour** : 2025-01-27 ## Migration des composants UI -Voir `docs/migration-status.md` pour la liste complète des composants restants à migrer vers les composants UI réutilisables. +Voir `docs/migration-status.md` pour la liste complète des composants migrés et restants. -**Priorité haute** : -- `SeriesCard.tsx` - Container principal -- `createSeriesModal/CreateSeriesModalView.tsx` - Container de modal -- `AuthorFilterButton.tsx` - Bouton principal -- `AuthorFilterDropdown.tsx` - Boutons d'option -- `CategoryTabs.tsx` - Boutons d'onglets +**État actuel** : ✅ La plupart des composants principaux ont été migrés vers les composants UI réutilisables. + +### Composants restants (priorité moyenne) + +- **Uploads de fichiers** - Les labels d'upload utilisent encore des `span` avec styles inline car ils nécessitent une structure HTML spécifique (`