create for series
This commit is contained in:
parent
8ac7de090c
commit
30d37ec19c
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -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} />
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
64
components/ui/ToastContainer.tsx
Normal file
64
components/ui/ToastContainer.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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'
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
141
features/skeleton-loaders-toast-notifications.md
Normal file
141
features/skeleton-loaders-toast-notifications.md
Normal 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`
|
||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 !
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user