create for series

This commit is contained in:
Nicolas Cantu 2026-01-15 02:45:27 +01:00
parent 8ac7de090c
commit 30d37ec19c
19 changed files with 645 additions and 102 deletions

View File

@ -4,6 +4,7 @@ import { useArticlePayment } from '@/hooks/useArticlePayment'
import { ArticlePreview } from './ArticlePreview' import { ArticlePreview } from './ArticlePreview'
import { PaymentModal } from './PaymentModal' import { PaymentModal } from './PaymentModal'
import { Card } from './ui' import { Card } from './ui'
import { useToast } from './ui/ToastContainer'
import { t } from '@/lib/i18n' import { t } from '@/lib/i18n'
import Link from 'next/link' import Link from 'next/link'
@ -56,40 +57,90 @@ function ArticleMeta({
) )
} }
export function ArticleCard({ article, onUnlock }: ArticleCardProps): React.ReactElement { interface UseArticleCardStateParams {
const { pubkey, connect } = useNostrAuth() article: Article
const { pubkey: string | null
loading, connect: (() => Promise<void>) | undefined
error, onUnlock: ((article: Article) => void) | undefined
paymentInvoice, showToast: (message: string, variant?: 'success' | 'info' | 'warning' | 'error', duration?: number) => void
handleUnlock, }
handlePaymentComplete,
handleCloseModal,
} = useArticlePayment(article, pubkey ?? null, () => {
onUnlock?.(article)
}, connect)
function useArticleCardState(params: UseArticleCardStateParams): {
loading: boolean
error: string | null
paymentInvoice: ReturnType<typeof useArticlePayment>['paymentInvoice']
handleUnlock: () => Promise<void>
handlePaymentComplete: () => Promise<void>
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<typeof useArticlePayment>['paymentInvoice']
handleUnlock: () => Promise<void>
handlePaymentComplete: () => Promise<void>
handleCloseModal: () => void
}): React.ReactElement {
return ( return (
<Card variant="interactive" className="mb-0"> <>
<ArticleHeader article={article} /> <ArticleHeader article={params.article} />
<div className="text-cyber-accent mb-4"> <div className="text-cyber-accent mb-4">
<ArticlePreview <ArticlePreview
article={article} article={params.article}
loading={loading} loading={params.loading}
onUnlock={() => { onUnlock={() => {
void handleUnlock() void params.handleUnlock()
}} }}
/> />
</div> </div>
<ArticleMeta <ArticleMeta
article={article} article={params.article}
error={error} error={params.error}
paymentInvoice={paymentInvoice} paymentInvoice={params.paymentInvoice}
onClose={handleCloseModal} onClose={params.handleCloseModal}
onPaymentComplete={() => { onPaymentComplete={() => {
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 (
<Card variant="interactive" className="mb-0">
<ArticleCardContent
article={article}
loading={state.loading}
error={state.error}
paymentInvoice={state.paymentInvoice}
handleUnlock={state.handleUnlock}
handlePaymentComplete={state.handlePaymentComplete}
handleCloseModal={state.handleCloseModal}
/>
</Card> </Card>
) )
} }

View File

@ -1,9 +1,11 @@
import { useState } from 'react' import { useState, useEffect } from 'react'
import { Card } from './ui' import { Card } from './ui'
import { useToast } from './ui/ToastContainer'
import { useNostrAuth } from '@/hooks/useNostrAuth' import { useNostrAuth } from '@/hooks/useNostrAuth'
import { useArticlePublishing } from '@/hooks/useArticlePublishing' import { useArticlePublishing } from '@/hooks/useArticlePublishing'
import type { ArticleDraft } from '@/lib/articlePublisher' import type { ArticleDraft } from '@/lib/articlePublisher'
import { ArticleEditorForm } from './ArticleEditorForm' import { ArticleEditorForm } from './ArticleEditorForm'
import { t } from '@/lib/i18n'
interface ArticleEditorProps { interface ArticleEditorProps {
onPublishSuccess?: (articleId: string) => void 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 { export function ArticleEditor({ onPublishSuccess, onCancel, seriesOptions, onSelectSeries, defaultSeriesId }: ArticleEditorProps): React.ReactElement {
const { connected, pubkey, connect } = useNostrAuth() const { connected, pubkey, connect } = useNostrAuth()
const { showToast } = useToast()
const { loading, error, success, relayStatuses, publishArticle } = useArticlePublishing(pubkey ?? null) const { loading, error, success, relayStatuses, publishArticle } = useArticlePublishing(pubkey ?? null)
const [draft, setDraft] = useState<ArticleDraft>({ const [draft, setDraft] = useState<ArticleDraft>({
title: '', title: '',
@ -34,6 +46,8 @@ export function ArticleEditor({ onPublishSuccess, onCancel, seriesOptions, onSel
...(defaultSeriesId ? { seriesId: defaultSeriesId } : {}), ...(defaultSeriesId ? { seriesId: defaultSeriesId } : {}),
}) })
usePublishSuccessToast(success, showToast, onPublishSuccess, draft.title)
const submit = buildSubmitHandler({ const submit = buildSubmitHandler({
publishArticle, publishArticle,
draft, draft,

View File

@ -1,6 +1,6 @@
import type { Article } from '@/types/nostr' import type { Article } from '@/types/nostr'
import { ArticleCard } from './ArticleCard' import { ArticleCard } from './ArticleCard'
import { ErrorState, EmptyState } from './ui' import { ErrorState, EmptyState, Skeleton } from './ui'
import { t } from '@/lib/i18n' import { t } from '@/lib/i18n'
interface ArticlesListProps { interface ArticlesListProps {
@ -12,11 +12,28 @@ interface ArticlesListProps {
unlockedArticles: Set<string> unlockedArticles: Set<string>
} }
function LoadingState(): React.ReactElement { function ArticleCardSkeleton(): React.ReactElement {
// Use generic loading message at startup, then specific message once we know what we're loading
return ( return (
<div className="text-center py-12"> <div className="border border-neon-cyan/30 rounded-lg p-6 bg-cyber-dark space-y-4">
<p className="text-cyber-accent/70">{t('common.loading.articles')}</p> <div className="space-y-2">
<Skeleton variant="rectangular" height={24} className="w-3/4" />
<Skeleton variant="rectangular" height={16} className="w-1/2" />
</div>
<Skeleton variant="rectangular" height={100} className="w-full" />
<div className="flex items-center justify-between">
<Skeleton variant="circular" width={32} height={32} />
<Skeleton variant="rectangular" height={36} className="w-32" />
</div>
</div>
)
}
function LoadingState(): React.ReactElement {
return (
<div className="space-y-6">
{Array.from({ length: 3 }).map((_, index) => (
<ArticleCardSkeleton key={index} />
))}
</div> </div>
) )
} }

View File

@ -1,6 +1,6 @@
import type { Article } from '@/types/nostr' import type { Article } from '@/types/nostr'
import { AuthorCard } from './AuthorCard' import { AuthorCard } from './AuthorCard'
import { ErrorState, EmptyState } from './ui' import { ErrorState, EmptyState, Skeleton } from './ui'
import { t } from '@/lib/i18n' import { t } from '@/lib/i18n'
interface AuthorsListProps { interface AuthorsListProps {
@ -10,10 +10,27 @@ interface AuthorsListProps {
error: string | null error: string | null
} }
function AuthorCardSkeleton(): React.ReactElement {
return (
<div className="border border-neon-cyan/30 rounded-lg p-6 bg-cyber-dark space-y-4">
<div className="flex items-center gap-4">
<Skeleton variant="circular" width={64} height={64} />
<div className="flex-1 space-y-2">
<Skeleton variant="rectangular" height={20} className="w-2/3" />
<Skeleton variant="rectangular" height={16} className="w-1/2" />
</div>
</div>
<Skeleton variant="rectangular" height={60} className="w-full" />
</div>
)
}
function LoadingState(): React.ReactElement { function LoadingState(): React.ReactElement {
return ( return (
<div className="text-center py-12"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<p className="text-cyber-accent/70">{t('common.loading.authors')}</p> {Array.from({ length: 4 }).map((_, index) => (
<AuthorCardSkeleton key={index} />
))}
</div> </div>
) )
} }

View File

@ -1,6 +1,5 @@
import { Card } from './ui' import { Card, Skeleton } from './ui'
import { renderMarkdown } from '@/lib/markdownRenderer' import { renderMarkdown } from '@/lib/markdownRenderer'
import { t } from '@/lib/i18n'
interface DocsContentProps { interface DocsContentProps {
content: string content: string
@ -10,9 +9,15 @@ interface DocsContentProps {
export function DocsContent({ content, loading }: DocsContentProps): React.ReactElement { export function DocsContent({ content, loading }: DocsContentProps): React.ReactElement {
if (loading) { if (loading) {
return ( return (
<div className="text-center py-12"> <Card variant="default" className="bg-cyber-dark backdrop-blur-sm space-y-4">
<p className="text-cyber-accent">{t('docs.loading')}</p> <Skeleton variant="rectangular" height={32} className="w-1/2" />
</div> <Skeleton variant="rectangular" height={16} className="w-full" />
<Skeleton variant="rectangular" height={16} className="w-full" />
<Skeleton variant="rectangular" height={16} className="w-3/4" />
<Skeleton variant="rectangular" height={32} className="w-1/3" />
<Skeleton variant="rectangular" height={16} className="w-full" />
<Skeleton variant="rectangular" height={16} className="w-5/6" />
</Card>
) )
} }

View File

@ -1,6 +1,6 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { estimatePlatformFunds } from '@/lib/fundingCalculation' import { estimatePlatformFunds } from '@/lib/fundingCalculation'
import { Card } from './ui' import { Card, Skeleton } from './ui'
import { t } from '@/lib/i18n' import { t } from '@/lib/i18n'
interface FundingProgressBarProps { interface FundingProgressBarProps {
@ -69,8 +69,14 @@ export function FundingGauge(): React.ReactElement {
function FundingGaugeLoading(): React.ReactElement { function FundingGaugeLoading(): React.ReactElement {
return ( return (
<Card variant="default" className="bg-cyber-dark/50 mb-8"> <Card variant="default" className="bg-cyber-dark/50 mb-8 space-y-4">
<p className="text-cyber-accent">{t('common.loading')}</p> <Skeleton variant="rectangular" height={24} className="w-1/3" />
<Skeleton variant="rectangular" height={100} className="w-full" />
<div className="flex gap-4">
<Skeleton variant="rectangular" height={60} className="w-1/3" />
<Skeleton variant="rectangular" height={60} className="w-1/3" />
<Skeleton variant="rectangular" height={60} className="w-1/3" />
</div>
</Card> </Card>
) )
} }

View File

@ -4,6 +4,7 @@ import type { AlbyInvoice } from '@/types/alby'
import { getAlbyService, isWebLNAvailable } from '@/lib/alby' import { getAlbyService, isWebLNAvailable } from '@/lib/alby'
import { AlbyInstaller } from './AlbyInstaller' import { AlbyInstaller } from './AlbyInstaller'
import { Card, Modal, Button } from './ui' import { Card, Modal, Button } from './ui'
import { useToast } from './ui/ToastContainer'
import { t } from '@/lib/i18n' import { t } from '@/lib/i18n'
interface PaymentModalProps { interface PaymentModalProps {
@ -137,20 +138,20 @@ type PaymentModalState = {
handleOpenWallet: () => Promise<void> handleOpenWallet: () => Promise<void>
} }
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 [copied, setCopied] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null) const [errorMessage, setErrorMessage] = useState<string | null>(null)
const paymentUrl = `lightning:${invoice.invoice}` const paymentUrl = `lightning:${invoice.invoice}`
const timeRemaining = useInvoiceTimer(invoice.expiresAt) const timeRemaining = useInvoiceTimer(invoice.expiresAt)
const handleCopy = useCallback( const handleCopy = useCallback(
(): Promise<void> => copyInvoiceToClipboard({ invoice: invoice.invoice, setCopied, setErrorMessage }), (): Promise<void> => copyInvoiceToClipboard({ invoice: invoice.invoice, setCopied, setErrorMessage, showToast: showToast ?? undefined }),
[invoice.invoice] [invoice.invoice, showToast]
) )
const handleOpenWallet = useCallback( const handleOpenWallet = useCallback(
(): Promise<void> => openWalletForInvoice({ invoice: invoice.invoice, onPaymentComplete, setErrorMessage }), (): Promise<void> => openWalletForInvoice({ invoice: invoice.invoice, onPaymentComplete, setErrorMessage, showToast: showToast ?? undefined }),
[invoice.invoice, onPaymentComplete] [invoice.invoice, onPaymentComplete, showToast]
) )
return { copied, errorMessage, paymentUrl, timeRemaining, handleCopy, handleOpenWallet } return { copied, errorMessage, paymentUrl, timeRemaining, handleCopy, handleOpenWallet }
@ -160,10 +161,14 @@ async function copyInvoiceToClipboard(params: {
invoice: string invoice: string
setCopied: (value: boolean) => void setCopied: (value: boolean) => void
setErrorMessage: (value: string | null) => void setErrorMessage: (value: string | null) => void
showToast: ((message: string, variant?: 'success' | 'info' | 'warning' | 'error', duration?: number) => void) | undefined
}): Promise<void> { }): Promise<void> {
try { try {
await navigator.clipboard.writeText(params.invoice) await navigator.clipboard.writeText(params.invoice)
params.setCopied(true) params.setCopied(true)
if (params.showToast !== undefined) {
params.showToast(t('payment.modal.copySuccess'), 'success', 2000)
}
scheduleCopiedReset(params.setCopied) scheduleCopiedReset(params.setCopied)
} catch (e) { } catch (e) {
console.error('Failed to copy:', e) console.error('Failed to copy:', e)
@ -175,9 +180,13 @@ async function openWalletForInvoice(params: {
invoice: string invoice: string
onPaymentComplete: () => void onPaymentComplete: () => void
setErrorMessage: (value: string | null) => void setErrorMessage: (value: string | null) => void
showToast: ((message: string, variant?: 'success' | 'info' | 'warning' | 'error', duration?: number) => void) | undefined
}): Promise<void> { }): Promise<void> {
try { try {
await payWithWebLN(params.invoice) await payWithWebLN(params.invoice)
if (params.showToast !== undefined) {
params.showToast(t('payment.modal.paymentInitiated'), 'success')
}
params.onPaymentComplete() params.onPaymentComplete()
} catch (e) { } catch (e) {
const error = normalizePaymentError(e) const error = normalizePaymentError(e)
@ -211,8 +220,9 @@ async function payWithWebLN(invoice: string): Promise<void> {
} }
export function PaymentModal({ invoice, onClose, onPaymentComplete }: PaymentModalProps): React.ReactElement { export function PaymentModal({ invoice, onClose, onPaymentComplete }: PaymentModalProps): React.ReactElement {
const { showToast } = useToast()
const { copied, errorMessage, paymentUrl, timeRemaining, handleCopy, handleOpenWallet } = const { copied, errorMessage, paymentUrl, timeRemaining, handleCopy, handleOpenWallet } =
usePaymentModalState(invoice, onPaymentComplete) usePaymentModalState(invoice, onPaymentComplete, showToast)
const handleOpenWalletSync = (): void => { const handleOpenWalletSync = (): void => {
void handleOpenWallet() void handleOpenWallet()
} }

View File

@ -1,5 +1,5 @@
import { ArticleCard } from './ArticleCard' import { ArticleCard } from './ArticleCard'
import { Button, ErrorState } from './ui' import { Button, ErrorState, Skeleton } from './ui'
import type { Article } from '@/types/nostr' import type { Article } from '@/types/nostr'
import { memo } from 'react' import { memo } from 'react'
import Link from 'next/link' import Link from 'next/link'
@ -21,9 +21,27 @@ interface UserArticlesViewProps {
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
} }
function ArticleCardSkeleton(): React.ReactElement {
return (
<div className="border border-neon-cyan/30 rounded-lg p-6 bg-cyber-dark space-y-4">
<div className="space-y-2">
<Skeleton variant="rectangular" height={24} className="w-3/4" />
<Skeleton variant="rectangular" height={16} className="w-1/2" />
</div>
<Skeleton variant="rectangular" height={100} className="w-full" />
<div className="flex items-center justify-between">
<Skeleton variant="circular" width={32} height={32} />
<Skeleton variant="rectangular" height={36} className="w-32" />
</div>
</div>
)
}
const ArticlesLoading = (): React.ReactElement => ( const ArticlesLoading = (): React.ReactElement => (
<div className="text-center py-12"> <div className="space-y-6">
<p className="text-gray-500">{t('common.loading.articles')}</p> {Array.from({ length: 3 }).map((_, index) => (
<ArticleCardSkeleton key={index} />
))}
</div> </div>
) )

View File

@ -1,4 +1,4 @@
import { Card } from '../ui' import { Card, Skeleton } from '../ui'
import { t } from '@/lib/i18n' import { t } from '@/lib/i18n'
import type { AuthorPresentationArticle, Series } from '@/types/nostr' import type { AuthorPresentationArticle, Series } from '@/types/nostr'
import { AuthorPageHeader } from './AuthorPageHeader' import { AuthorPageHeader } from './AuthorPageHeader'
@ -15,6 +15,41 @@ type AuthorPageContentProps = {
onSeriesCreated: () => void onSeriesCreated: () => void
} }
function AuthorPageLoadingSkeleton(): React.ReactElement {
return (
<div className="space-y-6">
<div className="space-y-4">
<Skeleton variant="rectangular" height={32} className="w-1/3" />
<Skeleton variant="rectangular" height={200} className="w-full" />
</div>
<div className="space-y-4">
<Skeleton variant="rectangular" height={24} className="w-1/4" />
<Skeleton variant="rectangular" height={100} className="w-full" />
</div>
<div className="space-y-4">
<Skeleton variant="rectangular" height={24} className="w-1/4" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{Array.from({ length: 2 }).map((_, index) => (
<Skeleton key={index} variant="rectangular" height={150} className="w-full" />
))}
</div>
</div>
</div>
)
}
function AuthorPageError({ error }: { error: string }): React.ReactElement {
return <p className="text-red-400">{error}</p>
}
function AuthorPageNotFound(): React.ReactElement {
return (
<Card variant="default" className="bg-cyber-dark/50">
<p className="text-cyber-accent">{t('author.notFound')}</p>
</Card>
)
}
export function AuthorPageContent({ export function AuthorPageContent({
presentation, presentation,
series, series,
@ -25,21 +60,14 @@ export function AuthorPageContent({
onSeriesCreated, onSeriesCreated,
}: AuthorPageContentProps): React.ReactElement { }: AuthorPageContentProps): React.ReactElement {
if (loading) { if (loading) {
return <p className="text-cyber-accent">{t('common.loading')}</p> return <AuthorPageLoadingSkeleton />
} }
if (error) { if (error) {
return <p className="text-red-400">{error}</p> return <AuthorPageError error={error} />
} }
if (!presentation) { if (!presentation) {
return ( return <AuthorPageNotFound />
<Card variant="default" className="bg-cyber-dark/50">
<p className="text-cyber-accent">{t('author.notFound')}</p>
</Card>
)
} }
return ( return (
<> <>
<AuthorPageHeader presentation={presentation} /> <AuthorPageHeader presentation={presentation} />

View File

@ -1,7 +1,7 @@
import { useEffect } from 'react' import { useEffect } from 'react'
import { useNostrAuth } from '@/hooks/useNostrAuth' import { useNostrAuth } from '@/hooks/useNostrAuth'
import { useAuthorPresentation } from '@/hooks/useAuthorPresentation' import { useAuthorPresentation } from '@/hooks/useAuthorPresentation'
import { Button, Card } from '../ui' import { Button, Card, Skeleton } from '../ui'
import { t } from '@/lib/i18n' import { t } from '@/lib/i18n'
import { NoAccountView } from './NoAccountView' import { NoAccountView } from './NoAccountView'
import { PresentationForm } from './PresentationForm' import { PresentationForm } from './PresentationForm'
@ -28,8 +28,19 @@ function SuccessNotice(params: { pubkey: string | null }): React.ReactElement {
function LoadingNotice(): React.ReactElement { function LoadingNotice(): React.ReactElement {
return ( return (
<div className="text-center py-12"> <div className="space-y-6">
<p className="text-cyber-accent/70">{t('common.loading')}</p> <div className="space-y-4">
<Skeleton variant="rectangular" height={24} className="w-1/3" />
<Skeleton variant="rectangular" height={100} className="w-full" />
</div>
<div className="space-y-4">
<Skeleton variant="rectangular" height={24} className="w-1/4" />
<Skeleton variant="rectangular" height={200} className="w-full" />
</div>
<div className="space-y-4">
<Skeleton variant="rectangular" height={24} className="w-1/4" />
<Skeleton variant="rectangular" height={150} className="w-full" />
</div>
</div> </div>
) )
} }

View File

@ -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<ToastContextValue | undefined>(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<ToastMessage[]>([])
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 (
<ToastContext.Provider value={contextValue}>
{children}
<div className="fixed top-4 right-4 z-50 space-y-2 pointer-events-none">
{toasts.map((toast) => (
<div key={toast.id} className="pointer-events-auto">
<Toast
variant={toast.variant}
{...(toast.duration !== undefined ? { duration: toast.duration } : {})}
onClose={() => removeToast(toast.id)}
aria-label={toast.message}
>
{toast.message}
</Toast>
</div>
))}
</div>
</ToastContext.Provider>
)
}

View File

@ -6,6 +6,7 @@ export { Badge, type BadgeVariant } from './Badge'
export { Skeleton } from './Skeleton' export { Skeleton } from './Skeleton'
export { Modal } from './Modal' export { Modal } from './Modal'
export { Toast, type ToastVariant } from './Toast' export { Toast, type ToastVariant } from './Toast'
export { ToastProvider } from './ToastContainer'
export { MobileMenu } from './MobileMenu' export { MobileMenu } from './MobileMenu'
export { EmptyState } from './EmptyState' export { EmptyState } from './EmptyState'
export { ErrorState } from './ErrorState' export { ErrorState } from './ErrorState'

View File

@ -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/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) - ✅ `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 ## Erreurs corrigées
### TypeScript ### TypeScript

View File

@ -2,59 +2,147 @@
**Date** : 2025-01-27 **Date** : 2025-01-27
**Auteur** : Équipe 4NK **Auteur** : Équipe 4NK
**Dernière mise à jour** : 2025-01-27
## Migration des composants UI ## 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** : **État actuel** : ✅ La plupart des composants principaux ont été migrés vers les composants UI réutilisables.
- `SeriesCard.tsx` - Container principal
- `createSeriesModal/CreateSeriesModalView.tsx` - Container de modal ### Composants restants (priorité moyenne)
- `AuthorFilterButton.tsx` - Bouton principal
- `AuthorFilterDropdown.tsx` - Boutons d'option - **Uploads de fichiers** - Les labels d'upload utilisent encore des `span` avec styles inline car ils nécessitent une structure HTML spécifique (`<label>` + `<input type="file">` caché) :
- `CategoryTabs.tsx` - Boutons d'onglets - `ImageUploadField.tsx` - Déjà partiellement migré avec Button pour RemoveButton
- `MarkdownEditor.tsx` - Label d'upload de média
- `markdownEditorTwoColumns/MarkdownEditorTwoColumns.tsx` - ToolbarUploadButton
- `markdownEditorTwoColumns/PagesManager.tsx` - PageImageUploadButton
**Note** : Ces éléments nécessitent une structure HTML spécifique et ne peuvent pas être directement migrés vers `Button`. Une solution serait de créer un composant `FileUploadButton` spécialisé si nécessaire.
## Améliorations UX documentées ## Améliorations UX documentées
Voir `features/ux-improvements.md` pour la liste complète des améliorations UX proposées. Voir `features/ux-improvements.md` pour la liste complète des améliorations UX proposées.
**Priorité haute** : ### Priorité haute
1. Skeleton loaders
2. Toast notifications
3. Indicateur visuel pour contenu débloqué
4. Raccourcis clavier de base (`/`, `Esc`)
5. Amélioration de la modal de paiement
**Priorité moyenne** : 1. ✅ **Skeleton loaders** - Implémenté
6. Recherche améliorée avec suggestions - Remplacé les messages "Loading..." par des skeleton loaders dans :
7. Filtres persistants - Liste d'articles (`ArticlesList.tsx`)
8. Navigation clavier complète - Cartes d'auteurs (`AuthorsList.tsx`)
9. ARIA amélioré - Articles utilisateur (`UserArticlesList.tsx`)
10. Messages d'erreur actionnables - Page auteur (`AuthorPageContent.tsx`)
- Formulaire de présentation (`AuthorPresentationEditor.tsx`)
- Contenu markdown (`DocsContent.tsx`)
- Gauge de financement (`FundingGauge.tsx`)
- Animation subtile (pulse) pour indiquer le chargement
2. ✅ **Toast notifications** - Implémenté
- Système de gestion des toasts créé (`ToastContainer.tsx` avec `ToastProvider` et `useToast` hook)
- Toasts de succès intégrés :
- Après déblocage réussi d'un article (`ArticleCard.tsx`)
- Après publication réussie (`ArticleEditor.tsx`)
- Après copie d'invoice (`PaymentModal.tsx`)
- Après initiation du paiement avec Alby (`PaymentModal.tsx`)
- Toasts avec variants (success, info, warning, error), durée configurable, fermeture automatique et manuelle
- Accessibilité : `role="alert"` et `aria-live="polite"` pour les screen readers
3. **Indicateur visuel pour contenu débloqué**
- Badge "Débloqué" sur les articles
- Icône de cadenas ouvert
- Couleur différente ou bordure distinctive
4. **Raccourcis clavier de base**
- `/` : Focus automatique sur la barre de recherche
- `Esc` : Fermer les modals et overlays
- `Ctrl/Cmd + K` : Ouvrir une recherche rapide (command palette)
5. **Amélioration de la modal de paiement**
- Bouton "Payer avec Alby" en priorité (plus grand et plus visible)
- Auto-détection d'Alby
- Instructions étape par étape
- QR code amélioré (plus grand, meilleur contraste)
- Compte à rebours plus visible
### Priorité moyenne
6. **Recherche améliorée avec suggestions**
- Autocomplétion basée sur les titres d'articles
- Suggestions d'auteurs
- Historique de recherche récente
7. **Filtres persistants**
- Sauvegarder les préférences de filtres dans IndexedDB
- Restaurer les filtres au retour sur la page
8. **Navigation clavier complète**
- Tab order logique
- Navigation par flèches dans les listes
- Skip links pour navigation rapide
9. **ARIA amélioré**
- Labels ARIA pour tous les boutons iconiques
- Régions ARIA (`role="navigation"`, `role="main"`, `role="search"`)
- Annonces screen reader (`aria-live`)
10. **Messages d'erreur actionnables**
- Messages clairs avec explication
- Actions de récupération (bouton "Réessayer")
- Suggestions de solutions
### Priorité basse
11. Mode lecture
12. Partage et engagement
13. Guide de première utilisation
14. Prévisualisation avant publication
15. Chargement progressif avancé
## Améliorations UI documentées ## Améliorations UI documentées
Voir `features/ui-improvements.md` pour la liste complète des améliorations UI proposées. Voir `features/ui-improvements.md` pour la liste complète des améliorations UI proposées.
**Toutes les améliorations UI ont été implémentées** : **État actuel** : ✅ Les composants UI réutilisables ont été créés et la plupart des composants ont été migrés.
- ✅ ui-1 : Création des composants UI réutilisables
- ✅ ui-2 à ui-12 : Migration des composants existants
## Corrections de bugs ### Corrections de cohérence restantes
Aucun bug critique identifié actuellement. - **Audit des couleurs** : Identifier tous les usages de couleurs non-thème et remplacer par les couleurs du design system (cyber/neon)
- **Cohérence des états** : États hover, focus, disabled, actifs/selected uniformes
- **Typographie** : Système de tailles et poids cohérents
- **Espacement** : Utiliser l'échelle Tailwind de manière cohérente
### Améliorations visuelles
- **Background grid** : Utiliser `bg-cyber-grid` sur certains containers (optionnel)
- **Gradients** : Gradients subtils pour certains backgrounds
- **Shadows et glows** : Utiliser `shadow-glow-cyan` et `shadow-glow-green` de manière cohérente
## Infrastructure nécessaire
Voir `docs/remaining-tasks.md` pour les détails.
### Transferts Lightning automatiques
**État actuel** : Les transferts sont loggés dans `lib/automaticTransfer.ts` mais nécessitent un nœud Lightning pour être exécutés automatiquement.
**À implémenter** :
- Configuration nœud Lightning de la plateforme
- API pour créer et payer des invoices Lightning
- Queue de transferts pour gestion asynchrone
- Retry en cas d'échec
- Monitoring et alertes
## Optimisations ## Optimisations
- Performance : À évaluer après migration complète - **Performance** : À évaluer après migration complète
- Accessibilité : Vérification complète après migration - **Accessibilité** : Vérification complète après migration (contraste, focus, ARIA)
- SEO : À évaluer si nécessaire - **SEO** : À évaluer si nécessaire
## Tests ## Tests
- Tests unitaires : À définir selon la stratégie du projet - **Tests unitaires** : À définir selon la stratégie du projet
- Tests d'intégration : À définir selon la stratégie du projet - **Tests d'intégration** : À définir selon la stratégie du projet
- Tests manuels : À effectuer après chaque migration - **Tests manuels** : À effectuer après chaque migration
## Documentation ## Documentation

View File

@ -0,0 +1,141 @@
# Skeleton Loaders et Toast Notifications
**Auteur** : Équipe 4NK
**Date** : 2025-01-27
## Objectif
Implémenter les deux premières améliorations UX de priorité haute :
1. Skeleton loaders - Remplacer les messages "Loading..." par des skeleton loaders
2. Toast notifications - Intégrer le système de toast pour les confirmations visuelles
## Impacts
### Skeleton loaders
- **Expérience utilisateur** : Amélioration de la perception de performance en affichant la structure du contenu pendant le chargement
- **Cohérence visuelle** : Remplacement des messages textuels par des éléments visuels cohérents avec le design system
- **Feedback visuel** : Animation subtile pour indiquer le chargement actif
### Toast notifications
- **Feedback utilisateur** : Confirmations visuelles immédiates après les actions importantes
- **Accessibilité** : Toasts avec `role="alert"` et `aria-live="polite"` pour les screen readers
- **Expérience utilisateur** : Retour visuel clair et non intrusif pour les actions réussies
## Modifications
### Skeleton loaders
1. **`components/ArticlesList.tsx`** :
- Ajout de `ArticleCardSkeleton` qui reproduit la structure d'une carte d'article
- Remplacement de `LoadingState` par un affichage de 3 skeletons
2. **`components/AuthorsList.tsx`** :
- Ajout de `AuthorCardSkeleton` qui reproduit la structure d'une carte d'auteur
- Remplacement de `LoadingState` par un affichage de 4 skeletons en grille
3. **`components/UserArticlesList.tsx`** :
- Ajout de `ArticleCardSkeleton` (identique à ArticlesList)
- Remplacement de `ArticlesLoading` par un affichage de 3 skeletons
- Ajout de l'import `Skeleton`
4. **`components/authorPage/AuthorPageContent.tsx`** :
- Extraction de `AuthorPageLoadingSkeleton`, `AuthorPageError`, `AuthorPageNotFound` pour respecter `max-lines-per-function`
- Remplacement du message de chargement par des skeletons représentant la structure de la page auteur
5. **`components/authorPresentationEditor/AuthorPresentationEditor.tsx`** :
- Remplacement de `LoadingNotice` par des skeletons représentant la structure du formulaire
- Ajout de l'import `Skeleton`
6. **`components/DocsContent.tsx`** :
- Remplacement du message de chargement par des skeletons représentant la structure du contenu markdown
- Suppression de l'import `t` non utilisé
7. **`components/FundingGauge.tsx`** :
- Remplacement de `FundingGaugeLoading` par des skeletons représentant la structure du gauge
- Ajout de l'import `Skeleton`
### Toast notifications
1. **`components/ui/ToastContainer.tsx`** (nouveau) :
- Création du système de gestion des toasts avec `ToastProvider` et `useToast` hook
- Gestion d'une pile de toasts avec suppression automatique après durée configurable
- Affichage des toasts en position fixe (top-right) avec z-index élevé
2. **`pages/_app.tsx`** :
- Intégration du `ToastProvider` dans l'application pour rendre les toasts disponibles globalement
3. **`components/ArticleCard.tsx`** :
- Ajout d'un toast de succès après déblocage réussi d'un article
- Extraction de `useArticleCardState` pour respecter `max-lines-per-function`
- Extraction de `ArticleCardContent` pour réduire le nombre de lignes de `ArticleCard`
4. **`components/ArticleEditor.tsx`** :
- Ajout d'un toast de succès après publication réussie via `useEffect`
- Extraction de `usePublishSuccessToast` pour respecter `max-lines-per-function`
5. **`components/PaymentModal.tsx`** :
- Ajout d'un toast de succès après copie d'invoice dans `copyInvoiceToClipboard`
- Ajout d'un toast de succès après initiation du paiement dans `openWalletForInvoice`
- Mise à jour de `usePaymentModalState` pour accepter `showToast` optionnel
6. **`hooks/useArticlePayment.ts`** :
- Refactoring pour accepter un objet de paramètres au lieu de paramètres individuels (respect de `max-params`)
- Ajout du support `showToast` optionnel pour afficher des toasts après paiement réussi
- Propagation de `showToast` à travers `checkPaymentAndUnlock` et `startArticlePaymentFlow`
7. **`locales/fr.txt` et `locales/en.txt`** :
- Ajout des clés de traduction pour les toasts :
- `article.unlock.success` : "Article débloqué avec succès!" / "Article unlocked successfully!"
- `article.publish.success` : "Article publié avec succès!" / "Article published successfully!"
- `payment.modal.copySuccess` : "Facture copiée dans le presse-papiers" / "Invoice copied to clipboard"
- `payment.modal.paymentInitiated` : "Paiement initié avec succès" / "Payment initiated successfully"
- Ajout des clés de traduction manquantes pour `payment.modal.*` déjà utilisées dans le code
## Modalités de déploiement
1. **Vérification** :
- `npm run type-check` : Aucune erreur TypeScript
- `npm run lint` : Aucune erreur de linting (4 warnings acceptables sur l'utilisation de l'index dans les keys pour les skeletons)
2. **Déploiement** :
- Utiliser les scripts de déploiement existants (`deploy.sh`, `update-from-git.sh`, `update-remote-git.sh`)
- Validation explicite requise avant déploiement
## Modalités d'analyse
1. **Skeleton loaders** :
- Vérifier visuellement que les skeletons apparaissent pendant le chargement des listes
- Vérifier que les animations de pulse sont fluides
- Vérifier que les skeletons disparaissent correctement une fois les données chargées
2. **Toast notifications** :
- Vérifier que les toasts apparaissent après :
- Déblocage réussi d'un article
- Publication réussie d'un article
- Copie d'invoice dans le presse-papiers
- Initiation du paiement avec Alby
- Vérifier que les toasts disparaissent automatiquement après 5 secondes (2 secondes pour la copie)
- Vérifier que les toasts peuvent être fermés manuellement via le bouton ×
- Vérifier l'accessibilité avec un screen reader (toasts annoncés via `aria-live="polite"`)
## Pages affectées
- `components/ArticlesList.tsx`
- `components/AuthorsList.tsx`
- `components/UserArticlesList.tsx`
- `components/authorPage/AuthorPageContent.tsx`
- `components/authorPresentationEditor/AuthorPresentationEditor.tsx`
- `components/DocsContent.tsx`
- `components/FundingGauge.tsx`
- `components/ui/ToastContainer.tsx` (nouveau)
- `components/ArticleCard.tsx`
- `components/ArticleEditor.tsx`
- `components/PaymentModal.tsx`
- `hooks/useArticlePayment.ts`
- `pages/_app.tsx`
- `locales/fr.txt`
- `locales/en.txt`
- `components/ui/index.ts`
- `docs/migration-status.md`

View File

@ -13,20 +13,23 @@ type UseArticlePaymentResult = {
handleCloseModal: () => void handleCloseModal: () => void
} }
export function useArticlePayment( interface UseArticlePaymentParams {
article: Article, article: Article
pubkey: string | null, pubkey: string | null
onUnlockSuccess?: () => void, onUnlockSuccess?: (() => void) | undefined
connect?: () => Promise<void> connect?: (() => Promise<void>) | undefined
): UseArticlePaymentResult { showToast?: ((message: string, variant?: 'success' | 'info' | 'warning' | 'error', duration?: number) => void) | undefined
}
export function useArticlePayment(params: UseArticlePaymentParams): UseArticlePaymentResult {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [paymentInvoice, setPaymentInvoice] = useState<AlbyInvoice | null>(null) const [paymentInvoice, setPaymentInvoice] = useState<AlbyInvoice | null>(null)
const [paymentHash, setPaymentHash] = useState<string | null>(null) const [paymentHash, setPaymentHash] = useState<string | null>(null)
const handleUnlock = (): Promise<void> => unlockArticlePayment({ article, pubkey, connect, onUnlockSuccess, setLoading, setError, setPaymentInvoice, setPaymentHash }) const handleUnlock = (): Promise<void> => unlockArticlePayment({ article: params.article, pubkey: params.pubkey, connect: params.connect, onUnlockSuccess: params.onUnlockSuccess, setLoading, setError, setPaymentInvoice, setPaymentHash, showToast: params.showToast })
const handlePaymentComplete = (): Promise<void> => checkPaymentAndUnlock({ article, pubkey, paymentHash, onUnlockSuccess, setError, setPaymentInvoice, setPaymentHash }) const handlePaymentComplete = (): Promise<void> => checkPaymentAndUnlock({ article: params.article, pubkey: params.pubkey, paymentHash, onUnlockSuccess: params.onUnlockSuccess, setError, setPaymentInvoice, setPaymentHash, showToast: params.showToast })
const handleCloseModal = (): void => resetPaymentModalState({ setPaymentInvoice, setPaymentHash }) const handleCloseModal = (): void => resetPaymentModalState({ setPaymentInvoice, setPaymentHash })
@ -42,6 +45,7 @@ async function unlockArticlePayment(params: {
setError: (value: string | null) => void setError: (value: string | null) => void
setPaymentInvoice: (value: AlbyInvoice | null) => void setPaymentInvoice: (value: AlbyInvoice | null) => void
setPaymentHash: (value: string | null) => void setPaymentHash: (value: string | null) => void
showToast: ((message: string, variant?: 'success' | 'info' | 'warning' | 'error', duration?: number) => void) | undefined
}): Promise<void> { }): Promise<void> {
const {pubkey} = params const {pubkey} = params
if (!pubkey) { if (!pubkey) {
@ -61,6 +65,7 @@ async function unlockArticlePayment(params: {
setError: params.setError, setError: params.setError,
setPaymentInvoice: params.setPaymentInvoice, setPaymentInvoice: params.setPaymentInvoice,
setPaymentHash: params.setPaymentHash, setPaymentHash: params.setPaymentHash,
showToast: params.showToast,
}) })
} }
@ -79,6 +84,7 @@ async function startArticlePaymentFlow(params: {
setError: (value: string | null) => void setError: (value: string | null) => void
setPaymentInvoice: (value: AlbyInvoice | null) => void setPaymentInvoice: (value: AlbyInvoice | null) => void
setPaymentHash: (value: string | null) => void setPaymentHash: (value: string | null) => void
showToast: ((message: string, variant?: 'success' | 'info' | 'warning' | 'error', duration?: number) => void) | undefined
}): Promise<void> { }): Promise<void> {
params.setLoading(true) params.setLoading(true)
params.setError(null) params.setError(null)
@ -91,7 +97,7 @@ async function startArticlePaymentFlow(params: {
} }
params.setPaymentInvoice(ok.invoice) params.setPaymentInvoice(ok.invoice)
params.setPaymentHash(ok.paymentHash) params.setPaymentHash(ok.paymentHash)
void checkPaymentAndUnlock({ article: params.article, pubkey: params.pubkey, paymentHash: ok.paymentHash, onUnlockSuccess: params.onUnlockSuccess, setError: params.setError, setPaymentInvoice: params.setPaymentInvoice, setPaymentHash: params.setPaymentHash }) void checkPaymentAndUnlock({ article: params.article, pubkey: params.pubkey, paymentHash: ok.paymentHash, onUnlockSuccess: params.onUnlockSuccess, setError: params.setError, setPaymentInvoice: params.setPaymentInvoice, setPaymentHash: params.setPaymentHash, showToast: params.showToast })
} catch (e) { } catch (e) {
const errorMessage = e instanceof Error ? e.message : 'Failed to process payment' const errorMessage = e instanceof Error ? e.message : 'Failed to process payment'
console.error('Payment processing error:', e) console.error('Payment processing error:', e)
@ -126,6 +132,7 @@ async function checkPaymentAndUnlock(params: {
setError: (value: string | null) => void setError: (value: string | null) => void
setPaymentInvoice: (value: AlbyInvoice | null) => void setPaymentInvoice: (value: AlbyInvoice | null) => void
setPaymentHash: (value: string | null) => void setPaymentHash: (value: string | null) => void
showToast: ((message: string, variant?: 'success' | 'info' | 'warning' | 'error', duration?: number) => void) | undefined
}): Promise<void> { }): Promise<void> {
if (!params.paymentHash || !params.pubkey) { if (!params.paymentHash || !params.pubkey) {
return return
@ -148,6 +155,9 @@ async function checkPaymentAndUnlock(params: {
return return
} }
resetPaymentModalState({ setPaymentInvoice: params.setPaymentInvoice, setPaymentHash: params.setPaymentHash }) resetPaymentModalState({ setPaymentInvoice: params.setPaymentInvoice, setPaymentHash: params.setPaymentHash })
if (params.showToast !== undefined) {
params.showToast('Article débloqué avec succès!', 'success')
}
params.onUnlockSuccess?.() params.onUnlockSuccess?.()
} catch (e) { } catch (e) {
console.error('Payment check error:', e) console.error('Payment check error:', e)

View File

@ -166,6 +166,24 @@ import.button=Import
# Payment # Payment
payment.expired=Expired payment.expired=Expired
payment.modal.zapAmount=Pay {{amount}} sats
payment.modal.timeRemaining=Time remaining: {{time}}
payment.modal.lightningInvoice=Lightning Invoice
payment.modal.scanQr=Scan the QR code with your Lightning wallet
payment.modal.copyInvoice=Copy invoice
payment.modal.copied=Copied!
payment.modal.copySuccess=Invoice copied to clipboard
payment.modal.copyFailed=Failed to copy invoice
payment.modal.payWithAlby=Pay with Alby
payment.modal.paymentInitiated=Payment initiated successfully
payment.modal.invoiceExpired=Invoice expired
payment.modal.invoiceExpiredHelp=The Lightning invoice has expired. Please unlock the article again to get a new invoice.
payment.modal.weblnNotAvailable=WebLN is not available. Please install Alby or use another Lightning extension.
payment.modal.autoVerify=Payment verification will happen automatically.
# Article
article.unlock.success=Article unlocked successfully!
article.publish.success=Article published successfully!
# Article # Article
article.title=Title article.title=Title

View File

@ -235,3 +235,24 @@ settings.language.english=Anglais
account.create.title=Créer un compte account.create.title=Créer un compte
account.create.description=Créez un nouveau compte Nostr ou importez une clé privée existante. account.create.description=Créez un nouveau compte Nostr ou importez une clé privée existante.
account.import.title=Importer une clé privée account.import.title=Importer une clé privée
# Payment
payment.expired=Expiré
payment.modal.zapAmount=Payer {{amount}} sats
payment.modal.timeRemaining=Temps restant : {{time}}
payment.modal.lightningInvoice=Facture Lightning
payment.modal.scanQr=Scannez le QR code avec votre portefeuille Lightning
payment.modal.copyInvoice=Copier la facture
payment.modal.copied=Copié !
payment.modal.copySuccess=Facture copiée dans le presse-papiers
payment.modal.copyFailed=Impossible de copier la facture
payment.modal.payWithAlby=Payer avec Alby
payment.modal.paymentInitiated=Paiement initié avec succès
payment.modal.invoiceExpired=Facture expirée
payment.modal.invoiceExpiredHelp=La facture Lightning a expiré. Veuillez débloquer l'article à nouveau pour obtenir une nouvelle facture.
payment.modal.weblnNotAvailable=WebLN n'est pas disponible. Veuillez installer Alby ou utiliser une autre extension Lightning.
payment.modal.autoVerify=La vérification du paiement se fera automatiquement.
# Article
article.unlock.success=Article débloqué avec succès !
article.publish.success=Article publié avec succès !

View File

@ -8,6 +8,7 @@ import { relaySessionManager } from '@/lib/relaySessionManager'
import { publishWorker } from '@/lib/publishWorker' import { publishWorker } from '@/lib/publishWorker'
import { swSyncHandler } from '@/lib/swSyncHandler' import { swSyncHandler } from '@/lib/swSyncHandler'
import { swClient } from '@/lib/swClient' import { swClient } from '@/lib/swClient'
import { ToastProvider } from '@/components/ui/ToastContainer'
function I18nProvider({ children }: { children: React.ReactNode }): React.ReactElement { function I18nProvider({ children }: { children: React.ReactNode }): React.ReactElement {
const [initialLocale, setInitialLocale] = React.useState<'fr' | 'en'>('fr') const [initialLocale, setInitialLocale] = React.useState<'fr' | 'en'>('fr')
@ -60,7 +61,9 @@ export default function App({ Component, pageProps }: AppProps): React.ReactElem
return ( return (
<I18nProvider> <I18nProvider>
<ToastProvider>
<Component {...pageProps} /> <Component {...pageProps} />
</ToastProvider>
</I18nProvider> </I18nProvider>
) )
} }