This commit is contained in:
Nicolas Cantu 2026-01-15 11:31:09 +01:00
parent 30d37ec19c
commit b792e5373a
44 changed files with 2766 additions and 403 deletions

View File

@ -3,7 +3,7 @@ import { useNostrAuth } from '@/hooks/useNostrAuth'
import { useArticlePayment } from '@/hooks/useArticlePayment'
import { ArticlePreview } from './ArticlePreview'
import { PaymentModal } from './PaymentModal'
import { Card } from './ui'
import { Card, Badge } from './ui'
import { useToast } from './ui/ToastContainer'
import { t } from '@/lib/i18n'
import Link from 'next/link'
@ -11,12 +11,36 @@ import Link from 'next/link'
interface ArticleCardProps {
article: Article
onUnlock?: (article: Article) => void
allArticles?: Article[]
unlockedArticles?: Set<string>
}
function ArticleHeader({ article }: { article: Article }): React.ReactElement {
return (
<div className="mb-2 flex items-center justify-between">
<h2 className="text-2xl font-bold text-neon-cyan">{article.title}</h2>
<div className="flex items-center gap-3 flex-1">
<h2 className="text-2xl font-bold text-neon-cyan">{article.title}</h2>
{article.paid && (
<Badge variant="success" className="flex items-center gap-1" aria-label={t('article.unlocked.badge')}>
<svg
className="w-3 h-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z"
/>
</svg>
{t('article.unlocked.badge')}
</Badge>
)}
</div>
<Link
href={`/author/${article.pubkey}`}
className="text-xs text-cyber-accent/70 hover:text-neon-cyan transition-colors"
@ -93,6 +117,8 @@ function ArticleCardContent(params: {
handleUnlock: () => Promise<void>
handlePaymentComplete: () => Promise<void>
handleCloseModal: () => void
allArticles?: Article[]
unlockedArticles?: Set<string>
}): React.ReactElement {
return (
<>
@ -104,6 +130,8 @@ function ArticleCardContent(params: {
onUnlock={() => {
void params.handleUnlock()
}}
{...(params.allArticles !== undefined ? { allArticles: params.allArticles } : {})}
{...(params.unlockedArticles !== undefined ? { unlockedArticles: params.unlockedArticles } : {})}
/>
</div>
<ArticleMeta
@ -119,7 +147,7 @@ function ArticleCardContent(params: {
)
}
export function ArticleCard({ article, onUnlock }: ArticleCardProps): React.ReactElement {
export function ArticleCard({ article, onUnlock, allArticles, unlockedArticles }: ArticleCardProps): React.ReactElement {
const { pubkey, connect } = useNostrAuth()
const { showToast } = useToast()
const state = useArticleCardState({
@ -130,8 +158,12 @@ export function ArticleCard({ article, onUnlock }: ArticleCardProps): React.Reac
showToast,
})
const cardClassName = article.paid
? 'mb-0 border-2 border-neon-green/40 shadow-[0_0_5px_#00ff41,0_0_10px_#00ff41]'
: 'mb-0'
return (
<Card variant="interactive" className="mb-0">
<Card variant="interactive" className={cardClassName}>
<ArticleCardContent
article={article}
loading={state.loading}
@ -140,6 +172,8 @@ export function ArticleCard({ article, onUnlock }: ArticleCardProps): React.Reac
handleUnlock={state.handleUnlock}
handlePaymentComplete={state.handlePaymentComplete}
handleCloseModal={state.handleCloseModal}
{...(allArticles !== undefined ? { allArticles } : {})}
{...(unlockedArticles !== undefined ? { unlockedArticles } : {})}
/>
</Card>
)

View File

@ -4,6 +4,7 @@ import { t } from '@/lib/i18n'
import { useNostrAuth } from '@/hooks/useNostrAuth'
import { useEffect, useState } from 'react'
import { objectCache } from '@/lib/objectCache'
import { ReadingMode } from './ReadingMode'
interface ArticlePagesProps {
pages: Page[]
@ -39,14 +40,16 @@ function LockedPagesView({ pagesCount }: { pagesCount: number }): React.ReactEle
function PurchasedPagesView({ pages }: { pages: Page[] }): React.ReactElement {
return (
<div className="space-y-6 mt-6">
<h3 className="text-xl font-semibold text-neon-cyan">{t('article.pages.title')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{pages.map((page) => (
<PageDisplay key={page.number} page={page} />
))}
<ReadingMode>
<div className="space-y-6 mt-6">
<h3 className="text-xl font-semibold text-neon-cyan">{t('article.pages.title')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{pages.map((page) => (
<PageDisplay key={page.number} page={page} />
))}
</div>
</div>
</div>
</ReadingMode>
)
}

View File

@ -1,40 +1,87 @@
import type { Article } from '@/types/nostr'
import { Button } from './ui'
import { ArticlePages } from './ArticlePages'
import { ReadingMode } from './ReadingMode'
import { ShareButtons } from './ShareButtons'
import { ArticleSuggestions } from './ArticleSuggestions'
import { findSimilarArticles, findAuthorArticles } from '@/lib/articleSuggestions'
interface ArticlePreviewProps {
article: Article
loading: boolean
onUnlock: () => void
allArticles?: Article[]
unlockedArticles?: Set<string>
}
export function ArticlePreview({ article, loading, onUnlock }: ArticlePreviewProps): React.ReactElement {
if (article.paid) {
return (
function PaidArticleContent({
article,
onUnlock,
allArticles,
unlockedArticles,
}: {
article: Article
onUnlock: () => void
allArticles: Article[]
unlockedArticles: Set<string>
}): React.ReactElement {
const similarArticles = findSimilarArticles(article, allArticles)
const authorArticles = findAuthorArticles(article, allArticles)
return (
<ReadingMode>
<div>
<p className="mb-2 text-cyber-accent">{article.preview}</p>
<p className="text-sm text-cyber-accent/80 mt-4 whitespace-pre-wrap">{article.content}</p>
{article.pages && article.pages.length > 0 && <ArticlePages pages={article.pages} articleId={article.id} />}
<div className="mt-6 pt-4 border-t border-neon-cyan/30">
<ShareButtons articleId={article.id} articleTitle={article.title} authorPubkey={article.pubkey} />
</div>
<ArticleSuggestions
similarArticles={similarArticles}
authorArticles={authorArticles}
onUnlock={onUnlock}
unlockedArticles={unlockedArticles}
/>
</div>
</ReadingMode>
)
}
export function ArticlePreview({
article,
loading,
onUnlock,
allArticles = [],
unlockedArticles = new Set(),
}: ArticlePreviewProps): React.ReactElement {
if (article.paid) {
return (
<PaidArticleContent
article={article}
onUnlock={onUnlock}
allArticles={allArticles}
unlockedArticles={unlockedArticles}
/>
)
}
return (
<div>
<p className="mb-4 text-cyber-accent">{article.preview}</p>
<div className="border-t border-neon-cyan/30 pt-4">
<p className="text-sm text-cyber-accent/70 mb-4">
Contenu complet disponible après un zap de {article.zapAmount} sats
</p>
<Button
variant="success"
onClick={onUnlock}
disabled={loading}
loading={loading}
>
{loading ? 'Traitement...' : `Débloquer avec ${article.zapAmount} sats zap`}
</Button>
</div>
<div>
<p className="mb-4 text-cyber-accent">{article.preview}</p>
<div className="border-t border-neon-cyan/30 pt-4">
<p className="text-sm text-cyber-accent/70 mb-4">
Contenu complet disponible après un zap de {article.zapAmount} sats
</p>
<Button
variant="success"
onClick={onUnlock}
disabled={loading}
loading={loading}
>
{loading ? 'Traitement...' : `Débloquer avec ${article.zapAmount} sats zap`}
</Button>
</div>
</div>
)
}

View File

@ -0,0 +1,85 @@
import type { Article } from '@/types/nostr'
import { ArticleCard } from './ArticleCard'
import { t } from '@/lib/i18n'
interface ArticleSuggestionsProps {
similarArticles: Article[]
authorArticles: Article[]
onUnlock: (article: Article) => void
unlockedArticles: Set<string>
}
export function ArticleSuggestions({
similarArticles,
authorArticles,
onUnlock,
unlockedArticles,
}: ArticleSuggestionsProps): React.ReactElement | null {
const hasSimilar = similarArticles.length > 0
const hasAuthor = authorArticles.length > 0
if (!hasSimilar && !hasAuthor) {
return null
}
return (
<div className="mt-8 space-y-6">
{hasSimilar && (
<SimilarArticlesSection articles={similarArticles} onUnlock={onUnlock} unlockedArticles={unlockedArticles} />
)}
{hasAuthor && (
<AuthorArticlesSection articles={authorArticles} onUnlock={onUnlock} unlockedArticles={unlockedArticles} />
)}
</div>
)
}
function SimilarArticlesSection({
articles,
onUnlock,
unlockedArticles,
}: {
articles: Article[]
onUnlock: (article: Article) => void
unlockedArticles: Set<string>
}): React.ReactElement {
return (
<section aria-label={t('suggestions.similarArticles')}>
<h3 className="text-xl font-semibold text-neon-cyan mb-4">{t('suggestions.similarArticles')}</h3>
<div className="space-y-4">
{articles.slice(0, 3).map((article) => (
<ArticleCard
key={article.id}
article={{ ...article, paid: unlockedArticles.has(article.id) || article.paid }}
onUnlock={onUnlock}
/>
))}
</div>
</section>
)
}
function AuthorArticlesSection({
articles,
onUnlock,
unlockedArticles,
}: {
articles: Article[]
onUnlock: (article: Article) => void
unlockedArticles: Set<string>
}): React.ReactElement {
return (
<section aria-label={t('suggestions.authorArticles')}>
<h3 className="text-xl font-semibold text-neon-cyan mb-4">{t('suggestions.authorArticles')}</h3>
<div className="space-y-4">
{articles.slice(0, 3).map((article) => (
<ArticleCard
key={article.id}
article={{ ...article, paid: unlockedArticles.has(article.id) || article.paid }}
onUnlock={onUnlock}
/>
))}
</div>
</section>
)
}

View File

@ -1,7 +1,9 @@
import { useRef } from 'react'
import type { Article } from '@/types/nostr'
import { ArticleCard } from './ArticleCard'
import { ErrorState, EmptyState, Skeleton } from './ui'
import { t } from '@/lib/i18n'
import { useArrowNavigation } from '@/hooks/useArrowNavigation'
interface ArticlesListProps {
articles: Article[]
@ -31,8 +33,8 @@ function ArticleCardSkeleton(): React.ReactElement {
function LoadingState(): React.ReactElement {
return (
<div className="space-y-6">
{Array.from({ length: 3 }).map((_, index) => (
<ArticleCardSkeleton key={index} />
{Array.from({ length: 3 }, (_, index) => (
<ArticleCardSkeleton key={`article-skeleton-${index}`} />
))}
</div>
)
@ -46,6 +48,61 @@ function ArticlesEmptyState({ hasAny }: { hasAny: boolean }): React.ReactElement
)
}
function ArticlesErrorState({ error }: { error: string }): React.ReactElement {
const errorObj = new Error(error)
return (
<ErrorState
message={error}
error={errorObj}
onRetry={() => {
window.location.reload()
}}
onCheckConnection={() => {
if (navigator.onLine) {
window.location.reload()
} else {
// eslint-disable-next-line no-alert
alert(t('errors.network.offline'))
}
}}
/>
)
}
function ArticlesContent({
articles,
allArticles,
onUnlock,
unlockedArticles,
containerRef,
}: {
articles: Article[]
allArticles: Article[]
onUnlock: (article: Article) => void
unlockedArticles: Set<string>
containerRef: React.RefObject<HTMLDivElement | null>
}): React.ReactElement {
return (
<section id="articles-section" aria-label={t('navigation.articlesSection')} tabIndex={-1}>
<div className="mb-4 text-sm text-cyber-accent/70">
Showing {articles.length} of {allArticles.length} article{allArticles.length !== 1 ? 's' : ''}
</div>
<div ref={containerRef} className="space-y-6" role="list">
{articles.map((article) => (
<div key={article.id} role="listitem">
<ArticleCard
article={{ ...article, paid: unlockedArticles.has(article.id) || article.paid }}
onUnlock={onUnlock}
allArticles={allArticles}
unlockedArticles={unlockedArticles}
/>
</div>
))}
</div>
</section>
)
}
export function ArticlesList({
articles,
allArticles,
@ -54,30 +111,26 @@ export function ArticlesList({
onUnlock,
unlockedArticles,
}: ArticlesListProps): React.ReactElement {
const containerRef = useRef<HTMLDivElement>(null)
useArrowNavigation({ itemCount: articles.length, containerRef, enabled: !loading && articles.length > 0 })
if (loading) {
return <LoadingState />
}
if (error) {
return <ErrorState message={error} />
return <ArticlesErrorState error={error} />
}
if (articles.length === 0) {
return <ArticlesEmptyState hasAny={allArticles.length > 0} />
}
return (
<>
<div className="mb-4 text-sm text-cyber-accent/70">
Showing {articles.length} of {allArticles.length} article{allArticles.length !== 1 ? 's' : ''}
</div>
<div className="space-y-6">
{articles.map((article) => (
<ArticleCard
key={article.id}
article={{ ...article, paid: unlockedArticles.has(article.id) || article.paid }}
onUnlock={onUnlock}
/>
))}
</div>
</>
<ArticlesContent
articles={articles}
allArticles={allArticles}
onUnlock={onUnlock}
unlockedArticles={unlockedArticles}
containerRef={containerRef}
/>
)
}

View File

@ -1,6 +1,7 @@
import React from 'react'
import { Button } from './ui'
import { AuthorAvatar } from './AuthorFilterDropdown'
import { t } from '@/lib/i18n'
export function AuthorMnemonicIcons({ value, getMnemonicIcons }: { value: string; getMnemonicIcons: (pubkey: string) => string[] }): React.ReactElement {
return (
@ -70,6 +71,8 @@ export function AuthorFilterButton({
setIsOpen: (open: boolean) => void
buttonRef: React.RefObject<HTMLButtonElement | null>
}): React.ReactElement {
const ariaLabel = value ? `${t('filters.author.selected', { author: selectedDisplayName })}` : t('filters.author.select')
return (
<Button
id="author-filter"
@ -80,6 +83,7 @@ export function AuthorFilterButton({
className="w-full px-3 py-2 border-neon-cyan/30 bg-cyber-dark text-cyber-accent text-left justify-start hover:border-neon-cyan/50"
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-label={ariaLabel}
>
<AuthorFilterButtonContent
value={value}

View File

@ -1,7 +1,9 @@
import { useRef } from 'react'
import type { Article } from '@/types/nostr'
import { AuthorCard } from './AuthorCard'
import { ErrorState, EmptyState, Skeleton } from './ui'
import { t } from '@/lib/i18n'
import { useArrowNavigation } from '@/hooks/useArrowNavigation'
interface AuthorsListProps {
authors: Article[]
@ -28,8 +30,8 @@ function AuthorCardSkeleton(): React.ReactElement {
function LoadingState(): React.ReactElement {
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{Array.from({ length: 4 }).map((_, index) => (
<AuthorCardSkeleton key={index} />
{Array.from({ length: 4 }, (_, index) => (
<AuthorCardSkeleton key={`author-skeleton-${index}`} />
))}
</div>
)
@ -43,27 +45,53 @@ function AuthorsEmptyState({ hasAny }: { hasAny: boolean }): React.ReactElement
)
}
function AuthorsErrorState({ error }: { error: string }): React.ReactElement {
const errorObj = new Error(error)
return (
<ErrorState
message={error}
error={errorObj}
onRetry={() => {
window.location.reload()
}}
onCheckConnection={() => {
if (navigator.onLine) {
window.location.reload()
} else {
// eslint-disable-next-line no-alert
alert(t('errors.network.offline'))
}
}}
/>
)
}
export function AuthorsList({ authors, allAuthors, loading, error }: AuthorsListProps): React.ReactElement {
const containerRef = useRef<HTMLDivElement>(null)
useArrowNavigation({ itemCount: authors.length, containerRef, enabled: !loading && authors.length > 0 })
if (loading) {
return <LoadingState />
}
if (error) {
return <ErrorState message={error} />
return <AuthorsErrorState error={error} />
}
if (authors.length === 0) {
return <AuthorsEmptyState hasAny={allAuthors.length > 0} />
}
return (
<>
<section id="articles-section" aria-label={t('navigation.authorsSection')} tabIndex={-1}>
<div className="mb-4 text-sm text-cyber-accent/70">
Showing {authors.length} of {allAuthors.length} author{allAuthors.length !== 1 ? 's' : ''}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div ref={containerRef} className="grid grid-cols-1 md:grid-cols-2 gap-6" role="list">
{authors.map((author) => (
<AuthorCard key={author.pubkey} presentation={author} />
<div key={author.pubkey} role="listitem">
<AuthorCard presentation={author} />
</div>
))}
</div>
</>
</section>
)
}

View File

@ -12,11 +12,14 @@ export function CategoryTabs({ selectedCategory, onCategoryChange }: CategoryTab
return (
<div className="mb-6">
<div className="border-b border-neon-cyan/30">
<nav className="-mb-px flex space-x-8">
<nav role="tablist" aria-label={t('navigation.categories')} className="-mb-px flex space-x-8">
<Button
type="button"
variant="ghost"
onClick={() => onCategoryChange('science-fiction')}
role="tab"
aria-selected={selectedCategory === 'science-fiction'}
aria-controls="articles-section"
className={`py-4 px-1 border-b-2 font-medium text-sm transition-colors rounded-none ${
selectedCategory === 'science-fiction'
? 'border-neon-cyan text-neon-cyan'
@ -29,6 +32,9 @@ export function CategoryTabs({ selectedCategory, onCategoryChange }: CategoryTab
type="button"
variant="ghost"
onClick={() => onCategoryChange('scientific-research')}
role="tab"
aria-selected={selectedCategory === 'scientific-research'}
aria-controls="articles-section"
className={`py-4 px-1 border-b-2 font-medium text-sm transition-colors rounded-none ${
selectedCategory === 'scientific-research'
? 'border-neon-cyan text-neon-cyan'

View File

@ -3,7 +3,7 @@ import { t } from '@/lib/i18n'
export function Footer(): React.ReactElement {
return (
<footer className="bg-cyber-dark border-t border-neon-cyan/30 mt-12">
<footer role="contentinfo" className="bg-cyber-dark border-t border-neon-cyan/30 mt-12">
<div className="max-w-4xl mx-auto px-4 py-6">
<div className="flex flex-col md:flex-row justify-between items-center gap-4 text-sm text-cyber-accent/70">
<div className="flex flex-wrap justify-center gap-4">

View File

@ -7,6 +7,7 @@ import { ArticlesList } from '@/components/ArticlesList'
import { AuthorsList } from '@/components/AuthorsList'
import { PageHeader } from '@/components/PageHeader'
import { Footer } from '@/components/Footer'
import { SkipLinks } from '@/components/SkipLinks'
import type { Dispatch, SetStateAction } from 'react'
import { t } from '@/lib/i18n'
@ -66,7 +67,7 @@ function HomeContent(props: HomeViewProps): React.ReactElement {
const isInitialLoad = props.loading && props.allArticles.length === 0 && props.allAuthors.length === 0
return (
<div className="w-full px-4 py-8">
<div className="w-full px-4 py-8" id="main-content" tabIndex={-1}>
<ArticlesHero
searchQuery={props.searchQuery}
setSearchQuery={props.setSearchQuery}
@ -75,7 +76,9 @@ function HomeContent(props: HomeViewProps): React.ReactElement {
/>
{shouldShowFilters && !shouldShowAuthors && (
<ArticleFiltersComponent filters={props.filters} onFiltersChange={props.setFilters} articles={props.allArticles} />
<aside id="filters-section" role="complementary" aria-label={t('navigation.filtersSection')} tabIndex={-1}>
<ArticleFiltersComponent filters={props.filters} onFiltersChange={props.setFilters} articles={props.allArticles} />
</aside>
)}
<HomeMainList
@ -159,7 +162,8 @@ export function HomeView(props: HomeViewProps): React.ReactElement {
return (
<>
<HomeHead />
<main className="min-h-screen bg-cyber-darker">
<SkipLinks />
<main role="main" className="min-h-screen bg-cyber-darker">
<PageHeader />
<HomeContent {...props} />
<Footer />

View File

@ -28,6 +28,7 @@ export function KeyIndicator(): React.ReactElement {
href="/settings"
className={`ml-2 text-xl ${color} hover:opacity-80 transition-opacity cursor-pointer`}
title={title}
aria-label={title}
onClick={(e) => e.stopPropagation()}
>
🔑

View File

@ -1,4 +1,5 @@
import { Button } from './ui'
import { t } from '@/lib/i18n'
interface NotificationBadgeButtonProps {
unreadCount: number
@ -6,13 +7,17 @@ interface NotificationBadgeButtonProps {
}
export function NotificationBadgeButton({ unreadCount, onClick }: NotificationBadgeButtonProps): React.ReactElement {
const ariaLabel = unreadCount > 0
? `${unreadCount} ${unreadCount === 1 ? t('notifications.badge.unread.singular') : t('notifications.badge.unread.plural')}`
: t('notifications.badge.noUnread')
return (
<Button
type="button"
variant="ghost"
onClick={onClick}
className="relative p-2 text-gray-600 hover:text-gray-900"
aria-label={`${unreadCount} unread notification${unreadCount !== 1 ? 's' : ''}`}
aria-label={ariaLabel}
>
<svg
xmlns="http://www.w3.org/2000/svg"

View File

@ -1,3 +1,4 @@
import { useEffect } from 'react'
import { Card } from './ui'
import type { Notification } from '@/lib/notificationService'
import { NotificationItem } from './NotificationItem'
@ -48,6 +49,19 @@ export function NotificationPanel({
onMarkAllAsRead,
onClose,
}: NotificationPanelProps): React.ReactElement {
useEffect(() => {
const handleEscape = (e: KeyboardEvent): void => {
if (e.key === 'Escape') {
onClose()
}
}
document.addEventListener('keydown', handleEscape)
return () => {
document.removeEventListener('keydown', handleEscape)
}
}, [onClose])
return (
<>
<div className="fixed inset-0 z-40 bg-black bg-opacity-50" onClick={onClose} />

View File

@ -58,7 +58,7 @@ function FundingIcon(): React.ReactElement {
export function PageHeader(): React.ReactElement {
return (
<header className="bg-cyber-dark border-b border-neon-cyan/30 shadow-glow-cyan">
<header role="navigation" className="bg-cyber-dark border-b border-neon-cyan/30 shadow-glow-cyan">
<div className="max-w-4xl mx-auto px-4 py-4 flex justify-between items-center">
<HeaderLeft />
<HeaderRight />
@ -86,6 +86,7 @@ function HeaderLinks(): React.ReactElement {
href="/docs"
className="text-cyber-accent hover:text-neon-cyan transition-colors"
title={t('nav.documentation')}
aria-label={t('nav.documentation')}
>
<DocsIcon />
</Link>
@ -93,6 +94,7 @@ function HeaderLinks(): React.ReactElement {
href="/funding"
className="text-cyber-accent hover:text-neon-cyan transition-colors"
title={t('funding.title')}
aria-label={t('funding.title')}
>
<FundingIcon />
</Link>
@ -102,6 +104,7 @@ function HeaderLinks(): React.ReactElement {
rel="noopener noreferrer"
className="text-cyber-accent hover:text-neon-cyan transition-colors"
title={t('common.repositoryGit')}
aria-label={t('common.repositoryGit')}
onClick={(e) => e.stopPropagation()}
>
<GitIcon />

View File

@ -1,11 +1,19 @@
import { useEffect, useMemo, useState, useCallback } from 'react'
import QRCode from 'react-qr-code'
import { useEffect, useState, useCallback } from 'react'
import type { AlbyInvoice } from '@/types/alby'
import { getAlbyService, isWebLNAvailable } from '@/lib/alby'
import { copyInvoiceToClipboard, openWalletForInvoice } from '@/lib/paymentModalHelpers'
import { AlbyInstaller } from './AlbyInstaller'
import { Card, Modal, Button } from './ui'
import { Modal } from './ui'
import { useToast } from './ui/ToastContainer'
import { t } from '@/lib/i18n'
import {
PaymentHeader,
InvoiceDisplay,
PaymentInstructions,
PaymentActions,
ExpiredNotice,
PaymentError,
} from './paymentModal/PaymentModalComponents'
interface PaymentModalProps {
invoice: AlbyInvoice
@ -33,101 +41,6 @@ function useInvoiceTimer(expiresAt?: number): number | null {
return timeRemaining
}
function PaymentHeader({
amount,
timeRemaining,
}: {
amount: number
timeRemaining: number | null
}): React.ReactElement {
const timeLabel = useMemo((): string | null => {
if (timeRemaining === null) {
return null
}
if (timeRemaining <= 0) {
return t('payment.expired')
}
const minutes = Math.floor(timeRemaining / 60)
const secs = timeRemaining % 60
return `${minutes}:${secs.toString().padStart(2, '0')}`
}, [timeRemaining])
return (
<div className="mb-4">
<h2 className="text-xl font-bold text-neon-cyan">{t('payment.modal.zapAmount', { amount })}</h2>
{timeLabel && (
<p className={`text-sm ${timeRemaining !== null && timeRemaining <= 60 ? 'text-red-400 font-semibold' : 'text-cyber-accent/70'}`}>
{t('payment.modal.timeRemaining', { time: timeLabel })}
</p>
)}
</div>
)
}
function InvoiceDisplay({ invoiceText, paymentUrl }: { invoiceText: string; paymentUrl: string }): React.ReactElement {
return (
<div className="mb-4">
<p className="text-sm text-cyber-accent mb-2">{t('payment.modal.lightningInvoice')}</p>
<Card variant="default" className="bg-cyber-darker border-neon-cyan/20 p-3 break-all text-sm font-mono mb-4 text-neon-cyan">
{invoiceText}
</Card>
<div className="flex justify-center mb-4">
<Card variant="default" className="bg-cyber-dark p-4 border-2 border-neon-cyan/30">
<QRCode
value={paymentUrl}
size={200}
style={{ height: 'auto', maxWidth: '100%', width: '100%' }}
viewBox="0 0 256 256"
/>
</Card>
</div>
<p className="text-xs text-cyber-accent/70 text-center mb-2">{t('payment.modal.scanQr')}</p>
</div>
)
}
function PaymentActions({
copied,
onCopy,
onOpenWallet,
}: {
copied: boolean
onCopy: () => Promise<void>
onOpenWallet: () => void
}): React.ReactElement {
return (
<div className="flex gap-2">
<Button
variant="secondary"
onClick={() => {
void onCopy()
}}
className="flex-1"
>
{copied ? t('payment.modal.copied') : t('payment.modal.copyInvoice')}
</Button>
<Button
variant="primary"
onClick={onOpenWallet}
className="flex-1"
>
{t('payment.modal.payWithAlby')}
</Button>
</div>
)
}
function ExpiredNotice({ show }: { show: boolean }): React.ReactElement | null {
if (!show) {
return null
}
return (
<Card variant="default" className="mt-4 p-3 bg-red-900/20 border-red-400/50">
<p className="text-sm text-red-400 font-semibold mb-2">{t('payment.modal.invoiceExpired')}</p>
<p className="text-xs text-red-400/80">{t('payment.modal.invoiceExpiredHelp')}</p>
</Card>
)
}
type PaymentModalState = {
copied: boolean
@ -157,72 +70,70 @@ function usePaymentModalState(invoice: AlbyInvoice, onPaymentComplete: () => voi
return { copied, errorMessage, paymentUrl, timeRemaining, handleCopy, handleOpenWallet }
}
async function copyInvoiceToClipboard(params: {
invoice: string
setCopied: (value: boolean) => void
setErrorMessage: (value: string | null) => void
showToast: ((message: string, variant?: 'success' | 'info' | 'warning' | 'error', duration?: number) => void) | undefined
}): Promise<void> {
try {
await navigator.clipboard.writeText(params.invoice)
params.setCopied(true)
if (params.showToast !== undefined) {
params.showToast(t('payment.modal.copySuccess'), 'success', 2000)
function useAlbyDetection(): boolean {
const [hasAlby, setHasAlby] = useState(false)
useEffect(() => {
const checkAlby = (): void => {
const alby = getAlbyService()
setHasAlby(isWebLNAvailable() && alby.isEnabled())
}
scheduleCopiedReset(params.setCopied)
} catch (e) {
console.error('Failed to copy:', e)
params.setErrorMessage(t('payment.modal.copyFailed'))
}
checkAlby()
const interval = setInterval(checkAlby, 1000)
return () => clearInterval(interval)
}, [])
return hasAlby
}
async function openWalletForInvoice(params: {
invoice: string
onPaymentComplete: () => void
setErrorMessage: (value: string | null) => void
showToast: ((message: string, variant?: 'success' | 'info' | 'warning' | 'error', duration?: number) => void) | undefined
}): Promise<void> {
try {
await payWithWebLN(params.invoice)
if (params.showToast !== undefined) {
params.showToast(t('payment.modal.paymentInitiated'), 'success')
}
params.onPaymentComplete()
} catch (e) {
const error = normalizePaymentError(e)
if (isUserCancellationError(error)) {
return
}
console.error('Payment failed:', error)
params.setErrorMessage(error.message)
}
}
function normalizePaymentError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error))
}
function scheduleCopiedReset(setCopied: (value: boolean) => void): void {
setTimeout(() => setCopied(false), 2000)
}
function isUserCancellationError(error: Error): boolean {
return error.message.includes('user rejected') || error.message.includes('cancelled')
}
async function payWithWebLN(invoice: string): Promise<void> {
const alby = getAlbyService()
if (!isWebLNAvailable()) {
throw new Error(t('payment.modal.weblnNotAvailable'))
}
await alby.enable()
await alby.sendPayment(invoice)
function PaymentModalContent({
invoice,
copied,
errorMessage,
paymentUrl,
timeRemaining,
handleCopy,
handleOpenWallet,
hasAlby,
}: {
invoice: AlbyInvoice
copied: boolean
errorMessage: string | null
paymentUrl: string
timeRemaining: number | null
handleCopy: () => Promise<void>
handleOpenWallet: () => void
hasAlby: boolean
}): React.ReactElement {
return (
<>
{!hasAlby && <AlbyInstaller />}
<PaymentHeader amount={invoice.amount} timeRemaining={timeRemaining} />
<PaymentInstructions hasAlby={hasAlby} />
<InvoiceDisplay invoiceText={invoice.invoice} paymentUrl={paymentUrl} />
<PaymentActions
copied={copied}
onCopy={handleCopy}
onOpenWallet={handleOpenWallet}
hasAlby={hasAlby}
/>
<ExpiredNotice show={timeRemaining !== null && timeRemaining <= 0} />
<PaymentError errorMessage={errorMessage} onRetry={handleOpenWallet} />
<p className="text-xs text-cyber-accent/70 mt-4 text-center">
{t('payment.modal.autoVerify')}
</p>
</>
)
}
export function PaymentModal({ invoice, onClose, onPaymentComplete }: PaymentModalProps): React.ReactElement {
const { showToast } = useToast()
const { copied, errorMessage, paymentUrl, timeRemaining, handleCopy, handleOpenWallet } =
usePaymentModalState(invoice, onPaymentComplete, showToast)
const hasAlby = useAlbyDetection()
const handleOpenWalletSync = (): void => {
void handleOpenWallet()
}
@ -232,26 +143,19 @@ export function PaymentModal({ invoice, onClose, onPaymentComplete }: PaymentMod
isOpen
onClose={onClose}
title={t('payment.modal.zapAmount', { amount: invoice.amount })}
size="small"
size="medium"
aria-label={t('payment.modal.zapAmount', { amount: invoice.amount })}
>
<AlbyInstaller />
<PaymentHeader amount={invoice.amount} timeRemaining={timeRemaining} />
<InvoiceDisplay invoiceText={invoice.invoice} paymentUrl={paymentUrl} />
<PaymentActions
<PaymentModalContent
invoice={invoice}
copied={copied}
onCopy={handleCopy}
onOpenWallet={handleOpenWalletSync}
errorMessage={errorMessage}
paymentUrl={paymentUrl}
timeRemaining={timeRemaining}
handleCopy={handleCopy}
handleOpenWallet={handleOpenWalletSync}
hasAlby={hasAlby}
/>
<ExpiredNotice show={timeRemaining !== null && timeRemaining <= 0} />
{errorMessage && (
<p className="text-xs text-red-400 mt-3 text-center" role="alert">
{errorMessage}
</p>
)}
<p className="text-xs text-cyber-accent/70 mt-4 text-center">
{t('payment.modal.autoVerify')}
</p>
</Modal>
)
}

215
components/ReadingMode.tsx Normal file
View File

@ -0,0 +1,215 @@
import { useState, useEffect } from 'react'
import { Button } from './ui'
import { t } from '@/lib/i18n'
import { getReadingModeSettings, saveReadingModeSettings, type ReadingModeSettings } from '@/lib/readingModePreferences'
const DEFAULT_SETTINGS: ReadingModeSettings = {
maxWidth: 'medium',
fontSize: 'medium',
lineHeight: 'normal',
}
interface ReadingModeProps {
children: React.ReactNode
className?: string
}
function useReadingModeState(): {
isActive: boolean
setIsActive: (value: boolean) => void
settings: ReadingModeSettings
setSettings: (settings: ReadingModeSettings) => void
} {
const [isActive, setIsActive] = useState(false)
const [settings, setSettings] = useState<ReadingModeSettings>(DEFAULT_SETTINGS)
useEffect(() => {
const load = async (): Promise<void> => {
try {
const saved = await getReadingModeSettings()
if (saved !== null) {
setSettings(saved)
}
} catch (error) {
console.error('Error loading reading mode settings:', error)
}
}
void load()
}, [])
useEffect(() => {
if (isActive) {
const save = async (): Promise<void> => {
try {
await saveReadingModeSettings(settings)
} catch (error) {
console.error('Error saving reading mode settings:', error)
}
}
void save()
}
}, [isActive, settings])
return { isActive, setIsActive, settings, setSettings }
}
export function ReadingMode({ children, className = '' }: ReadingModeProps): React.ReactElement {
const { isActive, setIsActive, settings, setSettings } = useReadingModeState()
const readingModeClasses = isActive ? getReadingModeClasses(settings) : ''
const containerClasses = isActive ? `reading-mode ${readingModeClasses}` : ''
return (
<div className={className}>
<ReadingModeToggle isActive={isActive} onToggle={() => setIsActive(!isActive)} />
{isActive && (
<ReadingModeControls settings={settings} onSettingsChange={setSettings} />
)}
<div className={containerClasses}>
{children}
</div>
</div>
)
}
function ReadingModeToggle({ isActive, onToggle }: { isActive: boolean; onToggle: () => void }): React.ReactElement {
return (
<Button
type="button"
variant="ghost"
size="small"
onClick={onToggle}
aria-label={isActive ? t('readingMode.disable') : t('readingMode.enable')}
className="mb-4"
>
{isActive ? t('readingMode.disable') : t('readingMode.enable')}
</Button>
)
}
function ReadingModeControls({
settings,
onSettingsChange,
}: {
settings: ReadingModeSettings
onSettingsChange: (settings: ReadingModeSettings) => void
}): React.ReactElement {
return (
<div className="mb-4 p-4 bg-cyber-dark border border-neon-cyan/30 rounded-lg space-y-4">
<WidthControl settings={settings} onSettingsChange={onSettingsChange} />
<FontSizeControl settings={settings} onSettingsChange={onSettingsChange} />
<LineHeightControl settings={settings} onSettingsChange={onSettingsChange} />
</div>
)
}
function WidthControl({
settings,
onSettingsChange,
}: {
settings: ReadingModeSettings
onSettingsChange: (settings: ReadingModeSettings) => void
}): React.ReactElement {
const widths: ReadingModeSettings['maxWidth'][] = ['narrow', 'medium', 'wide', 'full']
return (
<div>
<label className="block text-sm font-medium text-neon-cyan mb-2">
{t('readingMode.maxWidth')}
</label>
<div className="flex gap-2">
{widths.map((width) => (
<Button
key={width}
type="button"
variant={settings.maxWidth === width ? 'primary' : 'secondary'}
size="small"
onClick={() => onSettingsChange({ ...settings, maxWidth: width })}
>
{t(`readingMode.maxWidth.${width}`)}
</Button>
))}
</div>
</div>
)
}
function FontSizeControl({
settings,
onSettingsChange,
}: {
settings: ReadingModeSettings
onSettingsChange: (settings: ReadingModeSettings) => void
}): React.ReactElement {
const sizes: ReadingModeSettings['fontSize'][] = ['small', 'medium', 'large', 'xlarge']
return (
<div>
<label className="block text-sm font-medium text-neon-cyan mb-2">
{t('readingMode.fontSize')}
</label>
<div className="flex gap-2">
{sizes.map((size) => (
<Button
key={size}
type="button"
variant={settings.fontSize === size ? 'primary' : 'secondary'}
size="small"
onClick={() => onSettingsChange({ ...settings, fontSize: size })}
>
{t(`readingMode.fontSize.${size}`)}
</Button>
))}
</div>
</div>
)
}
function LineHeightControl({
settings,
onSettingsChange,
}: {
settings: ReadingModeSettings
onSettingsChange: (settings: ReadingModeSettings) => void
}): React.ReactElement {
const heights: ReadingModeSettings['lineHeight'][] = ['tight', 'normal', 'relaxed']
return (
<div>
<label className="block text-sm font-medium text-neon-cyan mb-2">
{t('readingMode.lineHeight')}
</label>
<div className="flex gap-2">
{heights.map((height) => (
<Button
key={height}
type="button"
variant={settings.lineHeight === height ? 'primary' : 'secondary'}
size="small"
onClick={() => onSettingsChange({ ...settings, lineHeight: height })}
>
{t(`readingMode.lineHeight.${height}`)}
</Button>
))}
</div>
</div>
)
}
function getReadingModeClasses(settings: ReadingModeSettings): string {
const widthClasses: Record<ReadingModeSettings['maxWidth'], string> = {
narrow: 'max-w-2xl',
medium: 'max-w-3xl',
wide: 'max-w-5xl',
full: 'max-w-full',
}
const fontSizeClasses: Record<ReadingModeSettings['fontSize'], string> = {
small: 'text-sm',
medium: 'text-base',
large: 'text-lg',
xlarge: 'text-xl',
}
const lineHeightClasses: Record<ReadingModeSettings['lineHeight'], string> = {
tight: 'leading-tight',
normal: 'leading-normal',
relaxed: 'leading-relaxed',
}
return `${widthClasses[settings.maxWidth]} ${fontSizeClasses[settings.fontSize]} ${lineHeightClasses[settings.lineHeight]} mx-auto`
}

View File

@ -1,7 +1,9 @@
import { useState, useEffect } from 'react'
import React, { useState, useEffect, useRef } from 'react'
import { SearchIcon } from './SearchIcon'
import { ClearButton } from './ClearButton'
import { Input } from './ui'
import { SearchSuggestions } from './SearchSuggestions'
import { saveSearchQuery } from '@/lib/searchHistory'
import { t } from '@/lib/i18n'
interface SearchBarProps {
@ -10,34 +12,166 @@ interface SearchBarProps {
placeholder?: string
}
export function SearchBar({ value, onChange, placeholder }: SearchBarProps): React.ReactElement {
const defaultPlaceholder = placeholder ?? t('search.placeholder')
function useSearchBarLocalValue(value: string): [string, (value: string) => void] {
const [localValue, setLocalValue] = useState(value)
useEffect(() => {
setLocalValue(value)
}, [value])
return [localValue, setLocalValue]
}
function useSearchBarFocusState(): [boolean, () => void, () => void] {
const [isFocused, setIsFocused] = useState(false)
const handleFocus = (): void => {
setIsFocused(true)
}
const handleBlur = (): void => {
setIsFocused(false)
}
return [isFocused, handleFocus, handleBlur]
}
interface UseSearchBarHandlersParams {
setLocalValue: (value: string) => void
onChange: (value: string) => void
isFocused: boolean
setShowSuggestions: (show: boolean) => void
}
function useSearchBarHandlers(params: UseSearchBarHandlersParams): {
handleChange: (e: React.ChangeEvent<HTMLInputElement>) => void
handleClear: () => void
handleFocusWithSuggestions: () => void
handleSelectSuggestion: (query: string) => void
handleCloseSuggestions: () => void
} {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
const newValue = e.target.value
setLocalValue(newValue)
onChange(newValue)
params.setLocalValue(newValue)
params.onChange(newValue)
if (newValue.trim() && params.isFocused) {
params.setShowSuggestions(true)
}
}
const handleClear = (): void => {
setLocalValue('')
onChange('')
params.setLocalValue('')
params.onChange('')
params.setShowSuggestions(false)
}
return (
<Input
type="text"
value={localValue}
onChange={handleChange}
placeholder={defaultPlaceholder}
leftIcon={<SearchIcon />}
rightIcon={localValue ? <ClearButton onClick={handleClear} /> : undefined}
className="pr-10"
/>
)
const handleFocusWithSuggestions = (): void => {
params.setShowSuggestions(true)
}
const handleSelectSuggestion = (query: string): void => {
params.setLocalValue(query)
params.onChange(query)
params.setShowSuggestions(false)
void saveSearchQuery(query)
}
const handleCloseSuggestions = (): void => {
params.setShowSuggestions(false)
}
return {
handleChange,
handleClear,
handleFocusWithSuggestions,
handleSelectSuggestion,
handleCloseSuggestions,
}
}
function useSearchBarState(value: string, onChange: (value: string) => void): {
localValue: string
showSuggestions: boolean
handleChange: (e: React.ChangeEvent<HTMLInputElement>) => void
handleClear: () => void
handleFocus: () => void
handleBlur: () => void
handleSelectSuggestion: (query: string) => void
handleCloseSuggestions: () => void
setShowSuggestions: (show: boolean) => void
} {
const [localValue, setLocalValue] = useSearchBarLocalValue(value)
const [showSuggestions, setShowSuggestions] = useState(false)
const [isFocused, , handleBlur] = useSearchBarFocusState()
const handlers = useSearchBarHandlers({ setLocalValue, onChange, isFocused, setShowSuggestions })
return {
localValue,
showSuggestions,
handleChange: handlers.handleChange,
handleClear: handlers.handleClear,
handleFocus: handlers.handleFocusWithSuggestions,
handleBlur,
handleSelectSuggestion: handlers.handleSelectSuggestion,
handleCloseSuggestions: handlers.handleCloseSuggestions,
setShowSuggestions,
}
}
function useClickOutsideHandler(containerRef: React.RefObject<HTMLDivElement | null>, showSuggestions: boolean, setShowSuggestions: (show: boolean) => void): void {
useEffect(() => {
if (!showSuggestions) {
return
}
const handleClickOutside = (e: MouseEvent): void => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setShowSuggestions(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [showSuggestions, setShowSuggestions, containerRef])
}
export const SearchBar = React.forwardRef<HTMLInputElement, SearchBarProps>(
({ value, onChange, placeholder }, ref): React.ReactElement => {
const defaultPlaceholder = placeholder ?? t('search.placeholder')
const containerRef = useRef<HTMLDivElement>(null)
const {
localValue,
showSuggestions,
handleChange,
handleClear,
handleFocus,
handleBlur,
handleSelectSuggestion,
handleCloseSuggestions,
setShowSuggestions,
} = useSearchBarState(value, onChange)
useClickOutsideHandler(containerRef, showSuggestions, setShowSuggestions)
return (
<div ref={containerRef} className="relative w-full">
<Input
ref={ref}
type="text"
value={localValue}
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
placeholder={defaultPlaceholder}
leftIcon={<SearchIcon />}
rightIcon={localValue ? <ClearButton onClick={handleClear} /> : undefined}
className="pr-10"
role="search"
aria-label={t('search.placeholder')}
aria-autocomplete="list"
aria-expanded={showSuggestions}
/>
{showSuggestions && (
<SearchSuggestions query={localValue} onSelect={handleSelectSuggestion} onClose={handleCloseSuggestions} />
)}
</div>
)
}
)
SearchBar.displayName = 'SearchBar'

View File

@ -0,0 +1,211 @@
import { useEffect, useState, useRef } from 'react'
import type { Article } from '@/types/nostr'
import { objectCache } from '@/lib/objectCache'
import { getSearchHistory } from '@/lib/searchHistory'
import { Card, Button } from './ui'
import { t } from '@/lib/i18n'
interface SearchSuggestionsProps {
query: string
onSelect: (query: string) => void
onClose: () => void
}
interface SuggestionItem {
type: 'article' | 'author' | 'history'
title: string
query: string
subtitle?: string
timestamp?: number
}
const loadHistorySuggestions = async (): Promise<SuggestionItem[]> => {
try {
const history = await getSearchHistory()
return history.slice(0, 5).map((item) => ({
type: 'history' as const,
title: item.query,
query: item.query,
timestamp: item.timestamp,
}))
} catch (error) {
console.error('Error loading search history:', error)
return []
}
}
const loadSuggestions = async (searchQuery: string): Promise<SuggestionItem[]> => {
try {
const allArticles = (await objectCache.getAll('publication')) as Article[]
const allAuthors = (await objectCache.getAll('author')) as Article[]
const queryLower = searchQuery.toLowerCase()
const articleSuggestions: SuggestionItem[] = allArticles
.filter((article) => article.title.toLowerCase().includes(queryLower))
.slice(0, 5)
.map((article) => ({
type: 'article' as const,
title: article.title,
query: article.title,
subtitle: article.preview?.substring(0, 60),
}))
const authorSuggestions: SuggestionItem[] = allAuthors
.filter((author) => {
const name = extractAuthorName(author)
return name.toLowerCase().includes(queryLower)
})
.slice(0, 3)
.map((author) => ({
type: 'author' as const,
title: extractAuthorName(author),
query: extractAuthorName(author),
subtitle: author.description?.substring(0, 60),
}))
return [...articleSuggestions, ...authorSuggestions].slice(0, 8)
} catch (error) {
console.error('Error loading suggestions:', error)
return []
}
}
function useSuggestionsLoader(query: string): { suggestions: SuggestionItem[]; loading: boolean } {
const [suggestions, setSuggestions] = useState<SuggestionItem[]>([])
const [loading, setLoading] = useState(false)
useEffect(() => {
const updateSuggestions = async (): Promise<void> => {
if (!query.trim()) {
const historySuggestions = await loadHistorySuggestions()
setSuggestions(historySuggestions)
return
}
setLoading(true)
try {
const loadedSuggestions = await loadSuggestions(query)
setSuggestions(loadedSuggestions)
} finally {
setLoading(false)
}
}
void updateSuggestions()
}, [query])
return { suggestions, loading }
}
function useClickOutsideHandler(containerRef: React.RefObject<HTMLDivElement | null>, onClose: () => void): void {
useEffect(() => {
const handleClickOutside = (e: MouseEvent): void => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
onClose()
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [containerRef, onClose])
}
export function SearchSuggestions({ query, onSelect, onClose }: SearchSuggestionsProps): React.ReactElement | null {
const containerRef = useRef<HTMLDivElement>(null)
const { suggestions, loading } = useSuggestionsLoader(query)
useClickOutsideHandler(containerRef, onClose)
if (suggestions.length === 0 && !loading) {
return null
}
return (
<div ref={containerRef} className="absolute top-full left-0 right-0 mt-1 z-50">
<Card variant="default" className="bg-cyber-dark border-neon-cyan/30 max-h-96 overflow-y-auto">
{loading && (
<div className="p-4 text-center text-cyber-accent/70">
<p>{t('common.loading')}</p>
</div>
)}
{!loading && suggestions.length > 0 && (
<div className="divide-y divide-neon-cyan/20">
{suggestions.map((suggestion, index) => (
<SuggestionItemComponent
key={`${suggestion.type}-${suggestion.query}-${suggestion.timestamp ?? index}`}
suggestion={suggestion}
onSelect={onSelect}
/>
))}
</div>
)}
</Card>
</div>
)
}
function getSuggestionIcon(type: SuggestionItem['type']): React.ReactElement {
if (type === 'article') {
return (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
)
}
if (type === 'author') {
return (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
)
}
return (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)
}
function getSuggestionAriaLabel(type: SuggestionItem['type'], title: string): string {
if (type === 'article') {
return `${t('search.suggestion.article')}: ${title}`
}
if (type === 'author') {
return `${t('search.suggestion.author')}: ${title}`
}
return `${t('search.suggestion.history')}: ${title}`
}
function SuggestionItemComponent({
suggestion,
onSelect,
}: {
suggestion: SuggestionItem
onSelect: (query: string) => void
}): React.ReactElement {
return (
<Button
variant="ghost"
onClick={() => {
onSelect(suggestion.query)
}}
className="w-full justify-start text-left p-3 h-auto"
aria-label={getSuggestionAriaLabel(suggestion.type, suggestion.title)}
>
<div className="flex items-start gap-3 flex-1">
<div className="text-neon-cyan mt-0.5 flex-shrink-0">{getSuggestionIcon(suggestion.type)}</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-cyber-accent truncate">{suggestion.title}</div>
{suggestion.subtitle && (
<div className="text-xs text-cyber-accent/70 mt-1 truncate">{suggestion.subtitle}</div>
)}
</div>
</div>
</Button>
)
}
function extractAuthorName(author: Article): string {
if (author.isPresentation) {
return author.title
}
return author.title
}

133
components/ShareButtons.tsx Normal file
View File

@ -0,0 +1,133 @@
import { Button } from './ui'
import { t } from '@/lib/i18n'
import { useToast } from './ui/ToastContainer'
interface ShareButtonsProps {
articleId: string
articleTitle: string
authorPubkey?: string
}
export function ShareButtons({ articleId, articleTitle, authorPubkey }: ShareButtonsProps): React.ReactElement {
const { showToast } = useToast()
const articleUrl = typeof window !== 'undefined' ? `${window.location.origin}/article/${articleId}` : ''
return (
<div className="flex flex-wrap gap-2">
<CopyLinkButton url={articleUrl} showToast={showToast} />
{authorPubkey !== undefined && (
<ShareToNostrButton articleId={articleId} articleTitle={articleTitle} authorPubkey={authorPubkey} showToast={showToast} />
)}
</div>
)
}
function CopyLinkButton({
url,
showToast,
}: {
url: string
showToast: (message: string, variant?: 'success' | 'info' | 'warning' | 'error', duration?: number) => void
}): React.ReactElement {
const handleCopy = async (): Promise<void> => {
try {
await navigator.clipboard.writeText(url)
showToast(t('share.copySuccess'), 'success', 2000)
} catch (error) {
console.error('Failed to copy link:', error)
showToast(t('share.copyFailed'), 'error')
}
}
return (
<Button
type="button"
variant="secondary"
size="small"
onClick={() => {
void handleCopy()
}}
aria-label={t('share.copyLink')}
>
{t('share.copyLink')}
</Button>
)
}
function handleNostrShare(params: {
articleId: string
authorPubkey: string
articleTitle: string
showToast: (message: string, variant?: 'success' | 'info' | 'warning' | 'error', duration?: number) => void
}): Promise<void> {
const articleUrl = typeof window !== 'undefined' ? `${window.location.origin}/article/${params.articleId}` : ''
const nostrNote = `nostr:${params.authorPubkey}:${params.articleId}`
const shareData: ShareData = {
title: params.articleTitle,
text: params.articleTitle,
url: articleUrl,
}
if (navigator.share !== undefined) {
return handleNativeShare(shareData, params.showToast)
}
return handleClipboardShare(nostrNote, params.showToast)
}
async function handleNativeShare(
shareData: ShareData,
showToast: (message: string, variant?: 'success' | 'info' | 'warning' | 'error', duration?: number) => void
): Promise<void> {
try {
await navigator.share(shareData)
showToast(t('share.shareSuccess'), 'success', 2000)
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
return
}
throw error
}
}
async function handleClipboardShare(
nostrNote: string,
showToast: (message: string, variant?: 'success' | 'info' | 'warning' | 'error', duration?: number) => void
): Promise<void> {
await navigator.clipboard.writeText(nostrNote)
showToast(t('share.nostrLinkCopied'), 'success', 2000)
}
function ShareToNostrButton({
articleId,
articleTitle,
authorPubkey,
showToast,
}: {
articleId: string
articleTitle: string
authorPubkey: string
showToast: (message: string, variant?: 'success' | 'info' | 'warning' | 'error', duration?: number) => void
}): React.ReactElement {
const handleShare = async (): Promise<void> => {
try {
await handleNostrShare({ articleId, authorPubkey, articleTitle, showToast })
} catch (error) {
console.error('Failed to share:', error)
showToast(t('share.shareFailed'), 'error')
}
}
return (
<Button
type="button"
variant="secondary"
size="small"
onClick={() => {
void handleShare()
}}
aria-label={t('share.shareToNostr')}
>
{t('share.shareToNostr')}
</Button>
)
}

83
components/SkipLinks.tsx Normal file
View File

@ -0,0 +1,83 @@
import { useEffect, useState } from 'react'
import { t } from '@/lib/i18n'
interface SkipLink {
id: string
label: string
targetId: string
}
const SKIP_LINKS: SkipLink[] = [
{ id: 'skip-main', label: 'navigation.skipToMain', targetId: 'main-content' },
{ id: 'skip-filters', label: 'navigation.skipToFilters', targetId: 'filters-section' },
{ id: 'skip-articles', label: 'navigation.skipToArticles', targetId: 'articles-section' },
]
export function SkipLinks(): React.ReactElement | null {
const [isVisible, setIsVisible] = useState(false)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent): void => {
if (e.key === 'Tab' && !e.shiftKey) {
setIsVisible(true)
}
}
const handleKeyUp = (e: KeyboardEvent): void => {
if (e.key === 'Tab' && !e.shiftKey) {
setIsVisible(true)
}
}
const handleClick = (): void => {
setIsVisible(false)
}
document.addEventListener('keydown', handleKeyDown)
document.addEventListener('keyup', handleKeyUp)
document.addEventListener('click', handleClick, true)
document.addEventListener('mousedown', handleClick, true)
return () => {
document.removeEventListener('keydown', handleKeyDown)
document.removeEventListener('keyup', handleKeyUp)
document.removeEventListener('click', handleClick, true)
document.removeEventListener('mousedown', handleClick, true)
}
}, [])
if (!isVisible) {
return null
}
return (
<div className="sr-only focus-within:not-sr-only focus-within:absolute focus-within:z-50 focus-within:top-4 focus-within:left-4">
<nav aria-label={t('navigation.skipLinks')} className="flex flex-col gap-2">
{SKIP_LINKS.map((link) => (
<SkipLinkItem key={link.id} link={link} onFocus={() => setIsVisible(true)} />
))}
</nav>
</div>
)
}
function SkipLinkItem({ link, onFocus }: { link: SkipLink; onFocus: () => void }): React.ReactElement {
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>): void => {
e.preventDefault()
const target = document.getElementById(link.targetId)
if (target) {
target.focus()
target.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
}
return (
<a
href={`#${link.targetId}`}
onClick={handleClick}
onFocus={onFocus}
className="bg-neon-cyan text-cyber-darker px-4 py-2 rounded-lg font-semibold focus:outline-none focus:ring-2 focus:ring-neon-green focus:ring-offset-2 focus:ring-offset-cyber-darker"
aria-label={t(link.label)}
>
{t(link.label)}
</a>
)
}

View File

@ -39,8 +39,8 @@ function ArticleCardSkeleton(): React.ReactElement {
const ArticlesLoading = (): React.ReactElement => (
<div className="space-y-6">
{Array.from({ length: 3 }).map((_, index) => (
<ArticleCardSkeleton key={index} />
{Array.from({ length: 3 }, (_, index) => (
<ArticleCardSkeleton key={`user-article-skeleton-${index}`} />
))}
</div>
)

View File

@ -29,8 +29,8 @@ function AuthorPageLoadingSkeleton(): React.ReactElement {
<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" />
{Array.from({ length: 2 }, (_, index) => (
<Skeleton key={`author-page-skeleton-${index}`} variant="rectangular" height={150} className="w-full" />
))}
</div>
</div>

View File

@ -1,4 +1,4 @@
import React from 'react'
import React, { useEffect } from 'react'
import { ImageUploadField } from '../ImageUploadField'
import { Button, Card, ErrorState, Input, Textarea } from '../ui'
import { t } from '@/lib/i18n'
@ -6,13 +6,40 @@ import type { SeriesDraft } from './createSeriesModalTypes'
import type { CreateSeriesModalController } from './useCreateSeriesModalController'
export function CreateSeriesModalView({ ctrl }: { ctrl: CreateSeriesModalController }): React.ReactElement {
const handleOverlayClick = (e: React.MouseEvent<HTMLDivElement>): void => {
if (e.target === e.currentTarget && !ctrl.loading) {
ctrl.handleClose()
}
}
const handleCardClick = (e: React.MouseEvent<HTMLDivElement>): void => {
e.stopPropagation()
}
useEffect(() => {
const handleEscape = (e: KeyboardEvent): void => {
if (e.key === 'Escape' && !ctrl.loading) {
ctrl.handleClose()
}
}
document.addEventListener('keydown', handleEscape)
document.body.style.overflow = 'hidden'
return () => {
document.removeEventListener('keydown', handleEscape)
document.body.style.overflow = ''
}
}, [ctrl])
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<Card variant="default" className="bg-cyber-dark max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<CreateSeriesModalHeader loading={ctrl.loading} onClose={ctrl.handleClose} />
{!ctrl.canPublish ? <NotAuthorWarning /> : null}
<CreateSeriesForm ctrl={ctrl} />
</Card>
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm" onClick={handleOverlayClick}>
<div onClick={handleCardClick}>
<Card variant="default" className="bg-cyber-dark max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<CreateSeriesModalHeader loading={ctrl.loading} onClose={ctrl.handleClose} />
{!ctrl.canPublish ? <NotAuthorWarning /> : null}
<CreateSeriesForm ctrl={ctrl} />
</Card>
</div>
</div>
)
}

View File

@ -0,0 +1,202 @@
import { useMemo } from 'react'
import QRCode from 'react-qr-code'
import { Card, Button, Badge, ErrorState } from '../ui'
import { t } from '@/lib/i18n'
export function PaymentHeader({
amount,
timeRemaining,
}: {
amount: number
timeRemaining: number | null
}): React.ReactElement {
const timeLabel = useMemo((): string | null => {
if (timeRemaining === null) {
return null
}
if (timeRemaining <= 0) {
return t('payment.expired')
}
const minutes = Math.floor(timeRemaining / 60)
const secs = timeRemaining % 60
return `${minutes}:${secs.toString().padStart(2, '0')}`
}, [timeRemaining])
const isUrgent = timeRemaining !== null && timeRemaining <= 60
return (
<div className="mb-6">
<h2 className="text-2xl font-bold text-neon-cyan mb-2">{t('payment.modal.zapAmount', { amount })}</h2>
{timeLabel && (
<div className="flex items-center gap-2">
<Badge
variant={isUrgent ? 'error' : 'info'}
className={`text-base px-3 py-1 font-mono ${isUrgent ? 'animate-pulse' : ''}`}
aria-label={t('payment.modal.timeRemaining', { time: timeLabel })}
>
{timeLabel}
</Badge>
<span className={`text-sm ${isUrgent ? 'text-red-400 font-semibold' : 'text-cyber-accent/70'}`}>
{t('payment.modal.timeRemaining', { time: timeLabel })}
</span>
</div>
)}
</div>
)
}
export function InvoiceDisplay({ invoiceText, paymentUrl }: { invoiceText: string; paymentUrl: string }): React.ReactElement {
return (
<div className="mb-6">
<p className="text-sm text-cyber-accent mb-2">{t('payment.modal.lightningInvoice')}</p>
<Card variant="default" className="bg-cyber-darker border-neon-cyan/20 p-3 break-all text-sm font-mono mb-4 text-neon-cyan">
{invoiceText}
</Card>
<div className="flex justify-center mb-4">
<Card variant="default" className="bg-white p-6 border-4 border-neon-cyan/50 shadow-[0_0_20px_rgba(0,255,255,0.3)]">
<QRCode
value={paymentUrl}
size={300}
style={{ height: 'auto', maxWidth: '100%', width: '100%' }}
viewBox="0 0 256 256"
fgColor="#000000"
bgColor="#ffffff"
/>
</Card>
</div>
<p className="text-xs text-cyber-accent/70 text-center mb-2">{t('payment.modal.scanQr')}</p>
</div>
)
}
export function PaymentInstructions({ hasAlby }: { hasAlby: boolean }): React.ReactElement {
if (hasAlby) {
return (
<Card variant="default" className="bg-cyber-dark/50 mb-4 p-4">
<h3 className="text-sm font-semibold text-neon-cyan mb-2">{t('payment.modal.instructions.title')}</h3>
<ol className="list-decimal list-inside space-y-1 text-xs text-cyber-accent">
<li>{t('payment.modal.instructions.step1')}</li>
<li>{t('payment.modal.instructions.step2')}</li>
<li>{t('payment.modal.instructions.step3')}</li>
</ol>
</Card>
)
}
return (
<Card variant="default" className="bg-cyber-dark/50 mb-4 p-4">
<h3 className="text-sm font-semibold text-neon-cyan mb-2">{t('payment.modal.instructions.titleNoAlby')}</h3>
<ol className="list-decimal list-inside space-y-1 text-xs text-cyber-accent">
<li>{t('payment.modal.instructions.step1NoAlby')}</li>
<li>{t('payment.modal.instructions.step2NoAlby')}</li>
<li>{t('payment.modal.instructions.step3NoAlby')}</li>
</ol>
</Card>
)
}
export function PaymentActionsWithAlby({
copied,
onCopy,
onOpenWallet,
}: {
copied: boolean
onCopy: () => Promise<void>
onOpenWallet: () => void
}): React.ReactElement {
return (
<div className="flex flex-col gap-3">
<Button
variant="success"
onClick={onOpenWallet}
className="w-full text-lg py-3 font-semibold"
size="large"
>
{t('payment.modal.payWithAlby')}
</Button>
<Button
variant="secondary"
onClick={() => {
void onCopy()
}}
className="w-full"
>
{copied ? t('payment.modal.copied') : t('payment.modal.copyInvoice')}
</Button>
</div>
)
}
export function PaymentActionsWithoutAlby({
copied,
onCopy,
}: {
copied: boolean
onCopy: () => Promise<void>
}): React.ReactElement {
return (
<div className="flex gap-2">
<Button
variant="secondary"
onClick={() => {
void onCopy()
}}
className="flex-1"
>
{copied ? t('payment.modal.copied') : t('payment.modal.copyInvoice')}
</Button>
</div>
)
}
export function PaymentActions({
copied,
onCopy,
onOpenWallet,
hasAlby,
}: {
copied: boolean
onCopy: () => Promise<void>
onOpenWallet: () => void
hasAlby: boolean
}): React.ReactElement {
if (hasAlby) {
return <PaymentActionsWithAlby copied={copied} onCopy={onCopy} onOpenWallet={onOpenWallet} />
}
return <PaymentActionsWithoutAlby copied={copied} onCopy={onCopy} />
}
export function ExpiredNotice({ show }: { show: boolean }): React.ReactElement | null {
if (!show) {
return null
}
return (
<Card variant="default" className="mt-4 p-3 bg-red-900/20 border-red-400/50">
<p className="text-sm text-red-400 font-semibold mb-2">{t('payment.modal.invoiceExpired')}</p>
<p className="text-xs text-red-400/80">{t('payment.modal.invoiceExpiredHelp')}</p>
</Card>
)
}
export function PaymentError({
errorMessage,
onRetry,
}: {
errorMessage: string | null
onRetry?: () => void
}): React.ReactElement | null {
if (!errorMessage) {
return null
}
const errorObj = new Error(errorMessage)
return (
<div className="mt-3">
<ErrorState
message={errorMessage}
error={errorObj}
{...(onRetry !== undefined ? { onRetry } : {})}
showDocumentationLink
className="text-xs"
/>
</div>
)
}

View File

@ -1,10 +1,17 @@
import type { ReactNode } from 'react'
import { Card } from './Card'
import { Button } from './Button'
import { t } from '@/lib/i18n'
import { classifyError, type ErrorClassification } from '@/lib/errorClassification'
interface ErrorStateProps {
message: string
action?: ReactNode
className?: string
onRetry?: () => void
onCheckConnection?: () => void
showDocumentationLink?: boolean
error?: unknown
}
function ErrorIcon(): React.ReactElement {
@ -26,7 +33,116 @@ function ErrorIcon(): React.ReactElement {
)
}
export function ErrorState({ message, action, className = '' }: ErrorStateProps): React.ReactElement {
function getUserFriendlyMessage(classification: ErrorClassification): string {
return classification.message
}
function getErrorClassification(error: unknown | undefined): ErrorClassification | null {
if (error === undefined) {
return null
}
return classifyError(error)
}
function RetryButton({ onRetry }: { onRetry: () => void }): React.ReactElement {
return (
<Button type="button" variant="primary" size="small" onClick={onRetry}>
{t('errors.actions.retry')}
</Button>
)
}
function CheckConnectionButton({ onCheckConnection }: { onCheckConnection: () => void }): React.ReactElement {
return (
<Button type="button" variant="secondary" size="small" onClick={onCheckConnection}>
{t('errors.actions.checkConnection')}
</Button>
)
}
function DocumentationButton(): React.ReactElement {
return (
<Button
type="button"
variant="ghost"
size="small"
onClick={() => {
window.open('/docs', '_blank')
}}
>
{t('errors.actions.viewDocumentation')}
</Button>
)
}
function computeActionFlags(
classification: ErrorClassification | null,
onRetry: (() => void) | undefined,
onCheckConnection: (() => void) | undefined,
showDocumentationLink: boolean
): { showRetry: boolean; showCheck: boolean; showDoc: boolean } {
const canRetry = classification?.canRetry ?? false
const canCheckConnection = classification?.canCheckConnection ?? false
const needsDocumentation = classification?.needsDocumentation ?? false
return {
showRetry: canRetry && onRetry !== undefined,
showCheck: canCheckConnection && onCheckConnection !== undefined,
showDoc: needsDocumentation && showDocumentationLink,
}
}
function hasAnyAction(
action: ReactNode | undefined,
showRetry: boolean,
showCheck: boolean,
showDoc: boolean
): boolean {
return action !== undefined || showRetry || showCheck || showDoc
}
function ErrorActions({
action,
classification,
onRetry,
onCheckConnection,
showDocumentationLink,
}: {
action?: ReactNode
classification: ErrorClassification | null
onRetry?: () => void
onCheckConnection?: () => void
showDocumentationLink?: boolean
}): React.ReactElement | null {
const flags = computeActionFlags(classification, onRetry, onCheckConnection, showDocumentationLink ?? false)
if (!hasAnyAction(action, flags.showRetry, flags.showCheck, flags.showDoc)) {
return null
}
return (
<div className="mt-3 flex flex-wrap gap-2">
{action !== undefined && action}
{flags.showRetry && onRetry !== undefined && <RetryButton onRetry={onRetry} />}
{flags.showCheck && onCheckConnection !== undefined && <CheckConnectionButton onCheckConnection={onCheckConnection} />}
{flags.showDoc && <DocumentationButton />}
</div>
)
}
export function ErrorState({
message,
action,
className = '',
onRetry,
onCheckConnection,
showDocumentationLink = false,
error,
}: ErrorStateProps): React.ReactElement {
const classification = getErrorClassification(error)
const displayMessage = classification !== null ? getUserFriendlyMessage(classification) : message
const suggestion = classification?.suggestion !== undefined ? t(classification.suggestion) : undefined
return (
<Card variant="default" className={`bg-red-900/20 border-red-500/50 ${className}`} role="alert">
<div className="flex items-start gap-3">
@ -34,8 +150,15 @@ export function ErrorState({ message, action, className = '' }: ErrorStateProps)
<ErrorIcon />
</div>
<div className="flex-1">
<p className="text-sm text-red-400 font-medium mb-2">{message}</p>
{action && <div className="mt-3">{action}</div>}
<p className="text-sm text-red-400 font-medium mb-2">{displayMessage}</p>
{suggestion !== undefined && <p className="text-xs text-red-400/70 mb-3">{suggestion}</p>}
<ErrorActions
action={action}
classification={classification}
{...(onRetry !== undefined ? { onRetry } : {})}
{...(onCheckConnection !== undefined ? { onCheckConnection } : {})}
showDocumentationLink={showDocumentationLink}
/>
</div>
</div>
</Card>

View File

@ -1,4 +1,4 @@
import { useMemo } from 'react'
import React, { useMemo } from 'react'
import type { ReactNode } from 'react'
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
@ -90,38 +90,34 @@ function InputHelper({ inputId, helperText }: { inputId: string; helperText: str
)
}
export function Input({
label,
error,
helperText,
leftIcon,
rightIcon,
className = '',
id,
...props
}: InputProps): React.ReactElement {
const inputId = useMemo(() => generateId('input', id), [id])
const inputClasses = useMemo(
() => getInputClasses({ error, leftIcon, rightIcon, className }),
[error, leftIcon, rightIcon, className]
)
const ariaDescribedBy = useMemo(() => getAriaDescribedBy(inputId, error, helperText), [inputId, error, helperText])
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ label, error, helperText, leftIcon, rightIcon, className = '', id, ...props }, ref): React.ReactElement => {
const inputId = useMemo(() => generateId('input', id), [id])
const inputClasses = useMemo(
() => getInputClasses({ error, leftIcon, rightIcon, className }),
[error, leftIcon, rightIcon, className]
)
const ariaDescribedBy = useMemo(() => getAriaDescribedBy(inputId, error, helperText), [inputId, error, helperText])
return (
<div className="w-full">
{label && <InputLabel inputId={inputId} label={label} />}
<div className="relative">
<InputIcons leftIcon={leftIcon} rightIcon={rightIcon} />
<input
id={inputId}
className={inputClasses}
aria-invalid={error ? 'true' : 'false'}
aria-describedby={ariaDescribedBy}
{...props}
/>
return (
<div className="w-full">
{label && <InputLabel inputId={inputId} label={label} />}
<div className="relative">
<InputIcons leftIcon={leftIcon} rightIcon={rightIcon} />
<input
ref={ref}
id={inputId}
className={inputClasses}
aria-invalid={error ? 'true' : 'false'}
aria-describedby={ariaDescribedBy}
{...props}
/>
</div>
{error && <InputError inputId={inputId} error={error} />}
{helperText && !error && <InputHelper inputId={inputId} helperText={helperText} />}
</div>
{error && <InputError inputId={inputId} error={error} />}
{helperText && !error && <InputHelper inputId={inputId} helperText={helperText} />}
</div>
)
}
)
}
)
Input.displayName = 'Input'

View File

@ -1,6 +1,7 @@
import { useEffect } from 'react'
import type { ReactNode } from 'react'
import { Button } from './Button'
import { t } from '@/lib/i18n'
export type ToastVariant = 'info' | 'success' | 'warning' | 'error'
@ -61,7 +62,7 @@ export function Toast({
variant="ghost"
onClick={onClose}
className="ml-4 text-current hover:opacity-70 p-0"
aria-label="Close notification"
aria-label={t('common.close')}
>
×
</Button>

View File

@ -45,7 +45,12 @@ export function ToastProvider({ children }: { children: React.ReactNode }): Reac
return (
<ToastContext.Provider value={contextValue}>
{children}
<div className="fixed top-4 right-4 z-50 space-y-2 pointer-events-none">
<div
className="fixed top-4 right-4 z-50 space-y-2 pointer-events-none"
role="region"
aria-live="polite"
aria-label="Notifications"
>
{toasts.map((toast) => (
<div key={toast.id} className="pointer-events-auto">
<Toast

View File

@ -2,6 +2,7 @@
**Date** : 2025-01-27
**Auteur** : Équipe 4NK
**Dernière mise à jour** : 2025-01-27 (Amélioration modal paiement + Recherche avec suggestions + Filtres persistants + Navigation clavier complète + ARIA amélioré + Messages d'erreur actionnables)
## Composants UI réutilisables créés
@ -125,6 +126,80 @@ Aucun composant prioritaire restant. Tous les composants principaux ont été mi
- ✅ `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`)
### Indicateur visuel pour contenu débloqué (Priorité haute #3)
- ✅ `components/ArticleCard.tsx` - Ajout d'un badge "Débloqué" avec icône de cadenas ouvert dans le header de l'article
- ✅ `components/ArticleCard.tsx` - Ajout d'une bordure distinctive (border-neon-green/40) et d'un glow vert (shadow-glow-green) sur les cartes d'articles débloqués
- ✅ `locales/fr.txt` et `locales/en.txt` - Ajout de la clé de traduction `article.unlocked.badge`
### Raccourcis clavier de base (Priorité haute #4)
- ✅ `hooks/useKeyboardShortcuts.ts` (nouveau) - Création du hook pour gérer les raccourcis clavier globaux
- ✅ `pages/_app.tsx` - Intégration du hook `useKeyboardShortcuts` dans l'application
- ✅ `components/ui/Input.tsx` - Ajout du support `forwardRef` pour permettre le focus programmatique
- ✅ `components/SearchBar.tsx` - Ajout du support `forwardRef` et `role="search"` pour l'accessibilité
- ✅ `components/NotificationPanel.tsx` - Ajout de la gestion de `Esc` pour fermer le panel
- ✅ `components/createSeriesModal/CreateSeriesModalView.tsx` - Ajout de la gestion de `Esc` pour fermer la modal
- ✅ Raccourci `/` : Focus automatique sur la barre de recherche (détection via `role="search"`)
- ✅ Raccourci `Esc` : Déjà implémenté pour les modals via `useModalKeyboard` dans `components/ui/Modal.tsx`, pour `MobileMenu` via `useEffect`, et ajouté pour `NotificationPanel` et `CreateSeriesModalView`
### Amélioration de la modal de paiement (Priorité haute #5)
- ✅ `components/PaymentModal.tsx` - Refactorisation avec extraction de composants dans `components/paymentModal/PaymentModalComponents.tsx`
- ✅ `lib/paymentModalHelpers.ts` (nouveau) - Extraction des fonctions utilitaires de paiement
- ✅ `components/paymentModal/PaymentModalComponents.tsx` (nouveau) - Composants de présentation extraits
- ✅ Bouton "Payer avec Alby" en priorité (variant "success", taille "large", affiché en premier si Alby détecté)
- ✅ Auto-détection d'Alby avec hook `useAlbyDetection()` vérifiant périodiquement la disponibilité
- ✅ Instructions étape par étape affichées conditionnellement selon la présence d'Alby
- ✅ QR code amélioré (taille 300px, fond blanc, bordure cyan avec glow `shadow-[0_0_20px_rgba(0,255,255,0.3)]`)
- ✅ Compte à rebours plus visible (Badge avec animation pulse si < 60s, variant "error" si urgent)
- ✅ `locales/fr.txt` et `locales/en.txt` - Ajout des clés de traduction pour les instructions
### Recherche améliorée avec suggestions (Priorité moyenne #6)
- ✅ `components/SearchSuggestions.tsx` (nouveau) - Composant de suggestions avec autocomplétion
- ✅ `components/SearchBar.tsx` - Intégration du composant de suggestions avec gestion du focus
- ✅ `lib/searchHistory.ts` (nouveau) - Service de gestion de l'historique de recherche dans IndexedDB
- ✅ Autocomplétion basée sur les titres d'articles (5 suggestions max, recherche insensible à la casse)
- ✅ Suggestions d'auteurs (3 suggestions max, basées sur le nom extrait du titre)
- ✅ Historique de recherche récente (5 dernières recherches, stocké dans IndexedDB, affiché quand la recherche est vide)
- ✅ `locales/fr.txt` et `locales/en.txt` - Ajout des clés de traduction pour les types de suggestions
### Filtres persistants (Priorité moyenne #7)
- ✅ `lib/filterPreferences.ts` (nouveau) - Service de gestion de la persistance des filtres dans IndexedDB
- ✅ `pages/index.tsx` - Hook `useFilterPersistence()` pour charger et sauvegarder automatiquement les filtres
- ✅ Chargement automatique des filtres sauvegardés au démarrage de la page
- ✅ Sauvegarde automatique des filtres à chaque modification
- ✅ Utilisation de la même base de données IndexedDB (`nostr_paywall_settings`) avec un store dédié (`filter_preferences`)
### Navigation clavier complète (Priorité moyenne #8)
- ✅ `components/SkipLinks.tsx` (nouveau) - Composant de skip links pour navigation rapide
- ✅ `hooks/useArrowNavigation.ts` (nouveau) - Hook pour navigation par flèches dans les listes (ArrowUp/Down/Home/End)
- ✅ `components/ArticlesList.tsx` - Intégration de la navigation par flèches et ajout de `role="list"` et `role="listitem"`
- ✅ `components/AuthorsList.tsx` - Intégration de la navigation par flèches et ajout de `role="list"` et `role="listitem"`
- ✅ `components/HomeView.tsx` - Ajout des IDs pour les skip links (`main-content`, `filters-section`, `articles-section`)
- ✅ Tab order logique avec `tabIndex={-1}` sur les sections pour permettre le focus programmatique
- ✅ `locales/fr.txt` et `locales/en.txt` - Ajout des traductions pour les skip links et sections
### ARIA amélioré (Priorité moyenne #9)
- ✅ `components/PageHeader.tsx` - Ajout de `role="navigation"` et `aria-label` pour tous les liens iconiques (DocsIcon, FundingIcon, GitIcon)
- ✅ `components/HomeView.tsx` - Ajout de `role="main"` au contenu principal
- ✅ `components/HomeView.tsx` - Ajout de `role="complementary"` aux filtres (section changée en `<aside>`)
- ✅ `components/Footer.tsx` - Ajout de `role="contentinfo"`
- ✅ `components/CategoryTabs.tsx` - Ajout de `role="tablist"` et `aria-label`, `role="tab"` et `aria-selected` pour les onglets
- ✅ `components/ui/ToastContainer.tsx` - Ajout de `role="region"`, `aria-live="polite"` et `aria-label="Notifications"`
- ✅ `components/ui/Toast.tsx` - Amélioration avec `aria-label` traduit pour le bouton de fermeture
- ✅ `components/AuthorFilterButton.tsx` - Ajout de `aria-label` dynamique selon l'état (sélectionné ou non)
- ✅ `components/NotificationBadgeButton.tsx` - Amélioration de `aria-label` avec traductions (singulier/pluriel)
- ✅ `components/KeyIndicator.tsx` - Ajout de `aria-label` pour le lien iconique
- ✅ `components/ClearButton.tsx` - Déjà présent avec `aria-label`
- ✅ `locales/fr.txt` et `locales/en.txt` - Ajout des traductions pour les notifications, filtres, et navigation
### Messages d'erreur actionnables (Priorité moyenne #10)
- ✅ `lib/errorClassification.ts` (nouveau) - Système de classification des erreurs (réseau, paiement, validation, chargement, inconnu)
- ✅ `components/ui/ErrorState.tsx` - Amélioration avec support des actions de récupération (`onRetry`, `onCheckConnection`, `showDocumentationLink`)
- ✅ `components/ArticlesList.tsx` - Intégration des messages d'erreur actionnables avec boutons de récupération
- ✅ `components/AuthorsList.tsx` - Intégration des messages d'erreur actionnables avec boutons de récupération
- ✅ `components/paymentModal/PaymentModalComponents.tsx` - Amélioration de `PaymentError` avec actions de récupération
- ✅ `components/PaymentModal.tsx` - Passage de `onRetry` à `PaymentError`
- ✅ `locales/fr.txt` et `locales/en.txt` - Ajout des traductions pour les messages d'erreur et actions (suggestions, boutons)
## Erreurs corrigées
### TypeScript
@ -133,6 +208,17 @@ Aucun composant prioritaire restant. Tous les composants principaux ont été mi
### Linting
- ✅ Toutes les erreurs de linting ont été corrigées
- ✅ `react/no-array-index-key` warnings corrigés dans :
- `components/ArticlesList.tsx` - Clés uniques pour les skeletons (`article-skeleton-${index}`)
- `components/AuthorsList.tsx` - Clés uniques pour les skeletons (`author-skeleton-${index}`)
- `components/UserArticlesList.tsx` - Clés uniques pour les skeletons (`user-article-skeleton-${index}`)
- `components/authorPage/AuthorPageContent.tsx` - Clés uniques pour les skeletons (`author-page-skeleton-${index}`)
- `components/SearchSuggestions.tsx` - Clés uniques pour les suggestions (`${type}-${query}-${timestamp ?? index}`)
### Réduction de la complexité
- ✅ `components/PaymentModal.tsx` - Extraction de composants vers `components/paymentModal/PaymentModalComponents.tsx` et fonctions utilitaires vers `lib/paymentModalHelpers.ts` pour respecter `max-lines` (250) et `max-lines-per-function` (40)
- ✅ `components/SearchBar.tsx` - Extraction de hooks personnalisés (`useSearchBarLocalValue`, `useSearchBarFocusState`, `useSearchBarHandlers`, `useClickOutsideHandler`) pour réduire la complexité
- ✅ `components/SearchSuggestions.tsx` - Extraction de fonctions utilitaires (`loadHistorySuggestions`, `loadSuggestions`, `getSuggestionIcon`, `getSuggestionAriaLabel`) et hooks personnalisés (`useSuggestionsLoader`, `useClickOutsideHandler`) pour réduire la complexité
## État actuel

View File

@ -2,7 +2,7 @@
**Date** : 2025-01-27
**Auteur** : Équipe 4NK
**Dernière mise à jour** : 2025-01-27
**Dernière mise à jour** : 2025-01-27 (Amélioration modal paiement + Recherche avec suggestions + Filtres persistants + Navigation clavier complète + ARIA amélioré + Messages d'erreur actionnables)
## Migration des composants UI
@ -47,48 +47,53 @@ Voir `features/ux-improvements.md` pour la liste complète des améliorations UX
- 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
3. **Indicateur visuel pour contenu débloqué** - Implémenté
- Badge "Débloqué" avec icône de cadenas ouvert dans le header de l'article (`ArticleCard.tsx`)
- Bordure distinctive (border-neon-green/40) et glow vert (shadow-glow-green) sur les cartes d'articles débloqués
- Accessibilité : `aria-label` sur le badge pour les screen readers
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)
4. ✅ **Raccourcis clavier de base** - Implémenté
- `/` : Focus automatique sur la barre de recherche (détection via `role="search"`)
- `Esc` : Fermeture des modals et overlays :
- Déjà implémenté pour les modals (`Modal.tsx`) et le menu mobile (`MobileMenu.tsx`)
- Ajouté pour `NotificationPanel` et `CreateSeriesModalView`
- `Ctrl/Cmd + K` : Ouvrir une recherche rapide (command palette) - À implémenter
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
5. **Amélioration de la modal de paiement** - Implémenté
- Bouton "Payer avec Alby" en priorité (variant "success", taille "large", plus visible)
- Auto-détection d'Alby avec vérification périodique
- Instructions étape par étape affichées conditionnellement selon la présence d'Alby
- QR code amélioré (taille 300px, fond blanc, bordure cyan avec glow)
- Compte à rebours plus visible (Badge avec animation pulse si < 60s)
### 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
6. **Recherche améliorée avec suggestions** - Implémenté
- Autocomplétion basée sur les titres d'articles (5 suggestions max)
- Suggestions d'auteurs (3 suggestions max)
- Historique de recherche récente (5 dernières recherches, stocké dans IndexedDB)
7. **Filtres persistants**
- Sauvegarder les préférences de filtres dans IndexedDB
- Restaurer les filtres au retour sur la page
7. ✅ **Filtres persistants** - Implémenté
- Sauvegarder les préférences de filtres dans IndexedDB (service `lib/filterPreferences.ts`)
- Restaurer les filtres au retour sur la page (chargement automatique dans `useHomeState()`)
- Sauvegarde automatique à chaque modification des filtres
8. **Navigation clavier complète**
- Tab order logique
- Navigation par flèches dans les listes
- Skip links pour navigation rapide
8. **Navigation clavier complète** - Implémenté
- Tab order logique (IDs ajoutés pour les sections principales)
- Navigation par flèches dans les listes (hook `useArrowNavigation` avec support ArrowUp/Down/Home/End)
- Skip links pour navigation rapide (composant `SkipLinks` avec liens vers main-content, filters-section, articles-section)
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`)
9. **ARIA amélioré** - Implémenté
- Labels ARIA pour tous les boutons iconiques (liens dans PageHeader, KeyIndicator, NotificationBadgeButton, AuthorFilterButton, ClearButton, Toast close button)
- Régions ARIA (`role="navigation"` pour le header, `role="main"` pour le contenu principal, `role="complementary"` pour les filtres, `role="contentinfo"` pour le footer, `role="tablist"` pour CategoryTabs)
- Annonces screen reader (`aria-live="polite"` dans ToastContainer et Toast, `role="alert"` pour les toasts)
10. **Messages d'erreur actionnables**
- Messages clairs avec explication
- Actions de récupération (bouton "Réessayer")
- Suggestions de solutions
10. ✅ **Messages d'erreur actionnables** - Implémenté
- Messages clairs avec explication (système de classification des erreurs dans `lib/errorClassification.ts`)
- Actions de récupération (bouton "Réessayer", "Vérifier la connexion", "Voir la documentation")
- Suggestions de solutions contextuelles selon le type d'erreur (réseau, paiement, validation, chargement)
- Composant `ErrorState` amélioré avec support des actions de récupération
- Intégration dans `ArticlesList`, `AuthorsList`, et `PaymentModal`
### Priorité basse

View File

@ -1,13 +1,15 @@
# Skeleton Loaders et Toast Notifications
# Skeleton Loaders, Toast Notifications, Indicateur Visuel et Raccourcis Clavier
**Auteur** : Équipe 4NK
**Date** : 2025-01-27
## Objectif
Implémenter les deux premières améliorations UX de priorité haute :
Implémenter les quatre 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
3. Indicateur visuel pour contenu débloqué - Badge et bordure distinctive pour les articles débloqués
4. Raccourcis clavier de base - `/` pour focus sur recherche, `Esc` pour fermer modals/overlays
## Impacts
@ -23,6 +25,18 @@ Implémenter les deux premières améliorations UX de priorité haute :
- **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
### Indicateur visuel pour contenu débloqué
- **Clarté visuelle** : Identification immédiate des articles déjà débloqués
- **Feedback positif** : Confirmation visuelle que l'utilisateur a accès au contenu complet
- **Accessibilité** : Badge avec `aria-label` pour les screen readers
### Raccourcis clavier de base
- **Efficacité** : Réduction du nombre de clics pour accéder à la recherche
- **Accessibilité** : Navigation au clavier améliorée
- **Expérience utilisateur** : Raccourcis intuitifs et standards
## Modifications
### Skeleton loaders
@ -93,6 +107,52 @@ Implémenter les deux premières améliorations UX de priorité haute :
- `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
### Indicateur visuel pour contenu débloqué
1. **`components/ArticleCard.tsx`** :
- Ajout d'un badge "Débloqué" avec icône SVG de cadenas ouvert dans `ArticleHeader`
- Badge affiché uniquement si `article.paid === true`
- Badge avec variant "success" (vert) et `aria-label` pour l'accessibilité
- Ajout d'une bordure distinctive (border-2 border-neon-green/40) et d'un glow vert (shadow-[0_0_5px_#00ff41,0_0_10px_#00ff41]) sur la Card pour les articles débloqués
- Utilisation de `shadow-[...]` avec les valeurs de `tailwind.config.js` (glow-green)
2. **`locales/fr.txt` et `locales/en.txt`** :
- Ajout de la clé de traduction `article.unlocked.badge` : "Débloqué" / "Unlocked"
### Raccourcis clavier de base
1. **`hooks/useKeyboardShortcuts.ts`** (nouveau) :
- Création du hook pour gérer les raccourcis clavier globaux
- Détection de la touche `/` pour focus sur la barre de recherche
- Vérification que l'utilisateur n'est pas déjà dans un champ de saisie avant d'activer le raccourci
- Recherche de l'input avec `role="search"` et focus/select automatique
2. **`components/ui/Input.tsx`** :
- Ajout du support `forwardRef` pour permettre le focus programmatique
- Conservation de toutes les fonctionnalités existantes
3. **`components/SearchBar.tsx`** :
- Ajout du support `forwardRef` pour permettre le focus programmatique
- Ajout de `role="search"` pour l'accessibilité et la détection par le hook
- Ajout de `aria-label` pour l'accessibilité
4. **`pages/_app.tsx`** :
- Intégration du hook `useKeyboardShortcuts` dans l'application pour activer les raccourcis globaux
6. **`components/NotificationPanel.tsx`** :
- Ajout de la gestion de `Esc` pour fermer le panel de notifications
- Ajout de `useEffect` pour écouter les événements clavier
7. **`components/createSeriesModal/CreateSeriesModalView.tsx`** :
- Ajout de la gestion de `Esc` pour fermer la modal de création de série
- Ajout de la gestion du clic sur l'overlay pour fermer la modal
- Ajout de `document.body.style.overflow = 'hidden'` pour empêcher le scroll pendant l'ouverture
8. **Raccourci `Esc`** :
- Déjà implémenté pour les modals via `useModalKeyboard` dans `components/ui/Modal.tsx`
- Déjà implémenté pour le menu mobile via `useEffect` dans `components/ui/MobileMenu.tsx`
- Ajouté pour `NotificationPanel` et `CreateSeriesModalView`
## Modalités de déploiement
1. **Vérification** :
@ -120,6 +180,22 @@ Implémenter les deux premières améliorations UX de priorité haute :
- 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"`)
3. **Indicateur visuel pour contenu débloqué** :
- Vérifier que le badge "Débloqué" apparaît dans le header des articles débloqués
- Vérifier que l'icône de cadenas ouvert est visible dans le badge
- Vérifier que les cartes d'articles débloqués ont une bordure verte distinctive et un glow vert
- Vérifier que les articles non débloqués n'ont pas de badge ni de bordure distinctive
- Vérifier l'accessibilité avec un screen reader (badge annoncé via `aria-label`)
4. **Raccourcis clavier de base** :
- Vérifier que la touche `/` focus la barre de recherche et sélectionne son contenu
- Vérifier que le raccourci `/` ne s'active pas si l'utilisateur est déjà dans un champ de saisie (input, textarea, contenteditable)
- Vérifier que la touche `Esc` ferme les modals (`Modal.tsx`)
- Vérifier que la touche `Esc` ferme le menu mobile (`MobileMenu.tsx`)
- Vérifier que la touche `Esc` ferme le panel de notifications (`NotificationPanel.tsx`)
- Vérifier que la touche `Esc` ferme la modal de création de série (`CreateSeriesModalView.tsx`)
- Tester sur différentes pages (HomeView, ProfileView) pour s'assurer que le raccourci `/` fonctionne partout
## Pages affectées
- `components/ArticlesList.tsx`
@ -138,4 +214,8 @@ Implémenter les deux premières améliorations UX de priorité haute :
- `locales/fr.txt`
- `locales/en.txt`
- `components/ui/index.ts`
- `hooks/useKeyboardShortcuts.ts` (nouveau)
- `components/NotificationPanel.tsx`
- `components/createSeriesModal/CreateSeriesModalView.tsx`
- `docs/migration-status.md`
- `docs/todo-remaining.md`

View File

@ -2,6 +2,7 @@
**Auteur** : Équipe 4NK
**Date** : 2025-01-27
**Dernière mise à jour** : 2025-01-27 (Amélioration modal paiement + Recherche avec suggestions + Filtres persistants + Navigation clavier complète + ARIA amélioré + Messages d'erreur actionnables)
## Vue d'ensemble
@ -56,55 +57,56 @@ Ce document liste les améliorations UX proposées pour la plateforme zapwall.fr
## 3. Recherche et filtres
### Recherche améliorée
- **Recherche en temps réel** avec suggestions :
- Autocomplétion basée sur les titres d'articles
- Suggestions d'auteurs
- Historique de recherche récente
- **Filtres combinables visibles** :
### Recherche améliorée ✅ Implémenté
- **Recherche en temps réel** avec suggestions :
- Autocomplétion basée sur les titres d'articles (5 suggestions max)
- Suggestions d'auteurs (3 suggestions max)
- Historique de recherche récente (5 dernières recherches, affiché quand la recherche est vide)
- **Filtres combinables visibles** :
- Badges actifs montrant les filtres appliqués
- Compteur de résultats ("X articles trouvés")
- Indication claire quand aucun résultat
- **Historique de recherche** :
- Sauvegarder les recherches récentes dans IndexedDB
- Afficher les recherches populaires
- ✅ **Historique de recherche** :
- Sauvegarder les recherches récentes dans IndexedDB (service `lib/searchHistory.ts`)
- Affichage automatique de l'historique quand la recherche est vide
- ⏳ Afficher les recherches populaires (à implémenter)
### Filtres persistants
- Sauvegarder les préférences de filtres dans IndexedDB
- Restaurer les filtres au retour sur la page
- Options de filtres avancés (date, catégorie, auteur combinés)
### Filtres persistants ✅ Implémenté
- ✅ Sauvegarder les préférences de filtres dans IndexedDB (service `lib/filterPreferences.ts`)
- ✅ Restaurer les filtres au retour sur la page (chargement automatique dans `useHomeState()`)
- ✅ Sauvegarde automatique à chaque modification des filtres
- ⏳ Options de filtres avancés (date, catégorie, auteur combinés) - À implémenter
## 4. Modal de paiement
## 4. Modal de paiement ✅ Implémenté
### Simplification du flux
- **Bouton "Payer avec Alby" en priorité** :
- Plus grand et plus visible
- **Bouton "Payer avec Alby" en priorité** :
- Plus grand et plus visible (variant "success", taille "large")
- Couleur distinctive (neon-green)
- Positionné en haut de la modal
- **Auto-détection d'Alby** :
- Détecter si Alby est disponible
- Ouvrir automatiquement Alby si disponible
- Afficher un message si Alby n'est pas installé
- **Instructions étape par étape** :
- Guide visuel pour nouveaux utilisateurs
- Étapes numérotées claires
- Lien vers installation d'Alby si nécessaire
- **Option "Se souvenir de ce choix"** :
- Mémoriser la méthode de paiement préférée
- Pré-sélectionner la méthode au prochain paiement
- Positionné en premier si Alby détecté
- ✅ **Auto-détection d'Alby** :
- Détecter si Alby est disponible (hook `useAlbyDetection()` avec vérification périodique)
- Affichage conditionnel du bouton selon la détection
- Message d'installation affiché si Alby n'est pas installé
- ✅ **Instructions étape par étape** :
- Guide visuel pour nouveaux utilisateurs (affiché conditionnellement)
- Étapes numérotées claires (différentes selon présence d'Alby)
- Instructions traduites (fr/en)
- ⏳ **Option "Se souvenir de ce choix"** :
- À implémenter (mémorisation de la méthode de paiement préférée)
### Amélioration visuelle
- **QR code amélioré** :
- Plus grand (minimum 300x300px)
- Meilleur contraste
- Bordure distinctive
- **Copie d'invoice** :
- Bouton de copie plus visible
- **QR code amélioré** :
- Plus grand (300x300px)
- Meilleur contraste (fond blanc, bordure cyan avec glow)
- Bordure distinctive (`border-4 border-neon-cyan/50 shadow-[0_0_20px_rgba(0,255,255,0.3)]`)
- **Copie d'invoice** :
- Bouton de copie visible (variant "secondary")
- Confirmation visuelle immédiate (toast)
- Copie en un clic
- **Compte à rebours** :
- Plus visible (barre de progression circulaire)
- Alerte visuelle quand < 60 secondes
- **Compte à rebours** :
- Plus visible (Badge avec format MM:SS)
- Alerte visuelle quand < 60 secondes (animation pulse, variant "error")
- Message d'expiration clair
## 5. Accessibilité clavier
@ -123,36 +125,40 @@ Ce document liste les améliorations UX proposées pour la plateforme zapwall.fr
- Modal d'aide avec tous les raccourcis
- Indication des raccourcis dans les tooltips
### ARIA amélioré
- **Labels ARIA** :
- Tous les boutons iconiques ont des labels
- Images avec alt text descriptif
- Formulaires avec labels associés
- **Régions ARIA** :
### ARIA amélioré ✅ Implémenté
- **Labels ARIA** :
- Tous les boutons iconiques ont des labels (liens dans PageHeader, KeyIndicator, NotificationBadgeButton, AuthorFilterButton, ClearButton, Toast close button)
- Images avec alt text descriptif (déjà présent dans la plupart des composants)
- Formulaires avec labels associés (déjà présent)
- **Régions ARIA** :
- `role="navigation"` pour le header
- `role="main"` pour le contenu principal
- `role="search"` pour la barre de recherche
- `role="complementary"` pour les filtres
- **Annonces screen reader** :
- `aria-live` pour changements d'état (paiement réussi, erreurs)
- Messages d'erreur annoncés
- Confirmations d'actions annoncées
- `role="search"` pour la barre de recherche (déjà présent)
- `role="complementary"` pour les filtres (section changée en `<aside>`)
- `role="contentinfo"` pour le footer
- `role="tablist"` et `role="tab"` pour CategoryTabs
- ✅ **Annonces screen reader** :
- `aria-live="polite"` dans ToastContainer (région) et Toast (éléments individuels)
- `role="alert"` pour les toasts
- Messages d'erreur annoncés via les toasts
- Confirmations d'actions annoncées via les toasts
## 6. Gestion d'erreurs
### Messages d'erreur actionnables
- **Messages clairs** :
- Langage simple et compréhensible
- Explication de l'erreur
- Impact sur l'utilisateur
- **Actions de récupération** :
- Bouton "Réessayer" pour erreurs réseau
- Bouton "Vérifier la connexion" si applicable
- Lien vers documentation si erreur complexe
- **Suggestions de solutions** :
- "Vérifiez votre connexion Alby"
- "Assurez-vous d'avoir des fonds suffisants"
- "Vérifiez votre connexion Internet"
### Messages d'erreur actionnables ✅ Implémenté
- ✅ **Messages clairs** :
- Langage simple et compréhensible (système de classification dans `lib/errorClassification.ts`)
- Explication de l'erreur (message d'erreur original préservé)
- Impact sur l'utilisateur (suggestions contextuelles selon le type d'erreur)
- ✅ **Actions de récupération** :
- Bouton "Réessayer" pour erreurs réseau, paiement, chargement (affiché automatiquement selon la classification)
- Bouton "Vérifier la connexion" pour erreurs réseau et chargement (affiché automatiquement)
- Lien vers documentation pour erreurs de paiement (affiché automatiquement)
- ✅ **Suggestions de solutions** :
- "Vérifiez votre connexion Internet et réessayez" (erreurs réseau)
- "Vérifiez votre connexion Alby et assurez-vous d'avoir des fonds suffisants" (erreurs paiement)
- "Vérifiez que tous les champs requis sont remplis correctement" (erreurs validation)
- "Impossible de charger les données. Vérifiez votre connexion et réessayez" (erreurs chargement)
### Récupération gracieuse
- **Retry automatique** :
@ -274,14 +280,14 @@ Ce document liste les améliorations UX proposées pour la plateforme zapwall.fr
2. Toast notifications
3. Indicateur visuel pour contenu débloqué
4. Raccourcis clavier de base (`/`, `Esc`)
5. Amélioration de la modal de paiement
5. Amélioration de la modal de paiement - Implémenté
### Priorité moyenne
6. Recherche améliorée avec suggestions
7. Filtres persistants
8. Navigation clavier complète
9. ARIA amélioré
10. Messages d'erreur actionnables
6. Recherche améliorée avec suggestions - Implémenté
7. Filtres persistants - Implémenté
8. Navigation clavier complète - Implémenté
9. ARIA amélioré - Implémenté
10. Messages d'erreur actionnables - Implémenté
### Priorité basse
11. Mode lecture

103
hooks/useArrowNavigation.ts Normal file
View File

@ -0,0 +1,103 @@
import { useEffect, useRef, type RefObject } from 'react'
interface UseArrowNavigationParams {
itemCount: number
containerRef: RefObject<HTMLElement | null>
enabled?: boolean
}
function handleArrowDown(
e: KeyboardEvent,
focusableElements: NodeListOf<HTMLElement>,
getCurrentIndex: () => number,
setCurrentIndex: (index: number) => void
): void {
e.preventDefault()
const currentIndex = getCurrentIndex()
const nextIndex = currentIndex < focusableElements.length - 1 ? currentIndex + 1 : 0
setCurrentIndex(nextIndex)
focusableElements[nextIndex]?.focus()
}
function handleArrowUp(
e: KeyboardEvent,
focusableElements: NodeListOf<HTMLElement>,
getCurrentIndex: () => number,
setCurrentIndex: (index: number) => void
): void {
e.preventDefault()
const currentIndex = getCurrentIndex()
const prevIndex = currentIndex > 0 ? currentIndex - 1 : focusableElements.length - 1
setCurrentIndex(prevIndex)
focusableElements[prevIndex]?.focus()
}
function handleHome(e: KeyboardEvent, focusableElements: NodeListOf<HTMLElement>, setCurrentIndex: (index: number) => void): void {
e.preventDefault()
setCurrentIndex(0)
focusableElements[0]?.focus()
}
function handleEnd(e: KeyboardEvent, focusableElements: NodeListOf<HTMLElement>, setCurrentIndex: (index: number) => void): void {
e.preventDefault()
setCurrentIndex(focusableElements.length - 1)
focusableElements[focusableElements.length - 1]?.focus()
}
function createKeyDownHandler(
containerRef: RefObject<HTMLElement | null>,
getCurrentIndex: () => number,
setCurrentIndex: (index: number) => void
): (e: KeyboardEvent) => void {
return (e: KeyboardEvent): void => {
if (!containerRef.current) {
return
}
const focusableElements = containerRef.current.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])'
)
if (focusableElements.length === 0) {
return
}
if (e.key === 'ArrowDown') {
handleArrowDown(e, focusableElements, getCurrentIndex, setCurrentIndex)
} else if (e.key === 'ArrowUp') {
handleArrowUp(e, focusableElements, getCurrentIndex, setCurrentIndex)
} else if (e.key === 'Home') {
handleHome(e, focusableElements, setCurrentIndex)
} else if (e.key === 'End') {
handleEnd(e, focusableElements, setCurrentIndex)
}
}
}
export function useArrowNavigation({ itemCount, containerRef, enabled = true }: UseArrowNavigationParams): void {
const currentIndexRef = useRef<number>(-1)
useEffect(() => {
if (!enabled || itemCount === 0) {
return
}
const getCurrentIndex = (): number => currentIndexRef.current
const setCurrentIndex = (index: number): void => {
currentIndexRef.current = index
}
const handleKeyDown = createKeyDownHandler(containerRef, getCurrentIndex, setCurrentIndex)
const container = containerRef.current
if (!container) {
return
}
container.addEventListener('keydown', handleKeyDown)
return () => {
container.removeEventListener('keydown', handleKeyDown)
}
}, [itemCount, containerRef, enabled])
}

View File

@ -0,0 +1,39 @@
import { useEffect } from 'react'
export function useKeyboardShortcuts(): void {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent): void => {
if (e.key === '/' && !isInputFocused()) {
e.preventDefault()
focusSearchInput()
}
}
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('keydown', handleKeyDown)
}
}, [])
}
function isInputFocused(): boolean {
const { activeElement } = document
if (!activeElement) {
return false
}
const tagName = activeElement.tagName.toLowerCase()
return (
tagName === 'input' ||
tagName === 'textarea' ||
activeElement.getAttribute('contenteditable') === 'true' ||
activeElement.getAttribute('role') === 'textbox'
)
}
function focusSearchInput(): void {
const searchInput = document.querySelector<HTMLInputElement>('input[role="search"]')
if (searchInput) {
searchInput.focus()
searchInput.select()
}
}

83
lib/articleSuggestions.ts Normal file
View File

@ -0,0 +1,83 @@
import type { Article } from '@/types/nostr'
/**
* Find similar articles based on category and title keywords
*/
export function findSimilarArticles(currentArticle: Article, allArticles: Article[], limit: number = 3): Article[] {
if (!currentArticle.category) {
return []
}
const currentTitleWords = extractKeywords(currentArticle.title)
const similar = allArticles
.filter((article) => {
if (article.id === currentArticle.id || article.isPresentation) {
return false
}
if (article.category !== currentArticle.category) {
return false
}
const articleTitleWords = extractKeywords(article.title)
return hasCommonKeywords(currentTitleWords, articleTitleWords)
})
.sort((a, b) => {
const aWords = extractKeywords(a.title)
const bWords = extractKeywords(b.title)
const aScore = countCommonKeywords(currentTitleWords, aWords)
const bScore = countCommonKeywords(currentTitleWords, bWords)
return bScore - aScore
})
.slice(0, limit)
return similar
}
/**
* Find articles by the same author
*/
export function findAuthorArticles(currentArticle: Article, allArticles: Article[], limit: number = 3): Article[] {
return allArticles
.filter((article) => {
if (article.id === currentArticle.id || article.isPresentation) {
return false
}
return article.pubkey === currentArticle.pubkey
})
.sort((a, b) => b.createdAt - a.createdAt)
.slice(0, limit)
}
/**
* Find articles in the same category
*/
export function findCategoryArticles(currentArticle: Article, allArticles: Article[], limit: number = 3): Article[] {
if (!currentArticle.category) {
return []
}
return allArticles
.filter((article) => {
if (article.id === currentArticle.id || article.isPresentation) {
return false
}
return article.category === currentArticle.category
})
.sort((a, b) => b.createdAt - a.createdAt)
.slice(0, limit)
}
function extractKeywords(text: string): string[] {
return text
.toLowerCase()
.split(/\s+/)
.filter((word) => word.length > 3)
.filter((word, index, array) => array.indexOf(word) === index)
}
function hasCommonKeywords(words1: string[], words2: string[]): boolean {
return words1.some((word) => words2.includes(word))
}
function countCommonKeywords(words1: string[], words2: string[]): number {
return words1.filter((word) => words2.includes(word)).length
}

142
lib/errorClassification.ts Normal file
View File

@ -0,0 +1,142 @@
/**
* Error classification system for actionable error messages
*/
export type ErrorType = 'network' | 'payment' | 'validation' | 'loading' | 'unknown'
export interface ErrorClassification {
type: ErrorType
message: string
suggestion?: string
canRetry: boolean
canCheckConnection: boolean
needsDocumentation: boolean
}
function isNetworkError(lowerMessage: string): boolean {
return (
lowerMessage.includes('network') ||
lowerMessage.includes('fetch') ||
lowerMessage.includes('timeout') ||
lowerMessage.includes('timed out') ||
lowerMessage.includes('econnreset') ||
lowerMessage.includes('econnrefused') ||
lowerMessage.includes('enotfound') ||
lowerMessage.includes('eai_again') ||
lowerMessage.includes('dns') ||
lowerMessage.includes('failed to fetch')
)
}
function isPaymentError(lowerMessage: string): boolean {
return (
lowerMessage.includes('payment') ||
lowerMessage.includes('invoice') ||
lowerMessage.includes('webln') ||
lowerMessage.includes('alby') ||
lowerMessage.includes('insufficient funds') ||
lowerMessage.includes('funds')
)
}
function isValidationError(lowerMessage: string): boolean {
return (
lowerMessage.includes('invalid') ||
lowerMessage.includes('validation') ||
lowerMessage.includes('required') ||
lowerMessage.includes('missing')
)
}
function isLoadingError(lowerMessage: string): boolean {
return (
lowerMessage.includes('loading') ||
lowerMessage.includes('failed to load') ||
lowerMessage.includes('could not load')
)
}
function createNetworkClassification(errorMessage: string): ErrorClassification {
return {
type: 'network',
message: errorMessage,
suggestion: 'errors.network.suggestion',
canRetry: true,
canCheckConnection: true,
needsDocumentation: false,
}
}
function createPaymentClassification(errorMessage: string): ErrorClassification {
return {
type: 'payment',
message: errorMessage,
suggestion: 'errors.payment.suggestion',
canRetry: true,
canCheckConnection: false,
needsDocumentation: true,
}
}
function createValidationClassification(errorMessage: string): ErrorClassification {
return {
type: 'validation',
message: errorMessage,
suggestion: 'errors.validation.suggestion',
canRetry: false,
canCheckConnection: false,
needsDocumentation: false,
}
}
function createLoadingClassification(errorMessage: string): ErrorClassification {
return {
type: 'loading',
message: errorMessage,
suggestion: 'errors.loading.suggestion',
canRetry: true,
canCheckConnection: true,
needsDocumentation: false,
}
}
function createUnknownClassification(errorMessage: string): ErrorClassification {
return {
type: 'unknown',
message: errorMessage,
suggestion: 'errors.unknown.suggestion',
canRetry: true,
canCheckConnection: false,
needsDocumentation: false,
}
}
/**
* Classify an error to determine appropriate recovery actions
*/
export function classifyError(error: unknown): ErrorClassification {
const errorMessage = error instanceof Error ? error.message : String(error)
const lowerMessage = errorMessage.toLowerCase()
if (isNetworkError(lowerMessage)) {
return createNetworkClassification(errorMessage)
}
if (isPaymentError(lowerMessage)) {
return createPaymentClassification(errorMessage)
}
if (isValidationError(lowerMessage)) {
return createValidationClassification(errorMessage)
}
if (isLoadingError(lowerMessage)) {
return createLoadingClassification(errorMessage)
}
return createUnknownClassification(errorMessage)
}
/**
* Get user-friendly error message based on classification
*/
export function getUserFriendlyMessage(classification: ErrorClassification): string {
return classification.message
}

77
lib/filterPreferences.ts Normal file
View File

@ -0,0 +1,77 @@
import { createIndexedDBHelper, type IndexedDBHelper } from './helpers/indexedDBHelper'
import type { ArticleFilters } from '@/components/ArticleFilters'
const DB_NAME = 'nostr_paywall_settings'
const DB_VERSION = 3
const STORE_NAME = 'filter_preferences'
export interface FilterPreferencesItem {
key: 'filters'
value: ArticleFilters
timestamp: number
}
const FILTERS_KEY = 'filters'
class FilterPreferencesService {
private readonly dbHelper: IndexedDBHelper
constructor() {
this.dbHelper = createIndexedDBHelper({
dbName: DB_NAME,
version: DB_VERSION,
storeName: STORE_NAME,
keyPath: 'key',
indexes: [{ name: 'timestamp', keyPath: 'timestamp', unique: false }],
})
}
async saveFilters(filters: ArticleFilters): Promise<void> {
try {
const item: FilterPreferencesItem = {
key: FILTERS_KEY,
value: filters,
timestamp: Date.now(),
}
await this.dbHelper.put(item)
} catch (error) {
console.error('Error saving filter preferences:', error)
}
}
async getFilters(): Promise<ArticleFilters | null> {
try {
const item = await this.dbHelper.get<FilterPreferencesItem>(FILTERS_KEY)
if (item?.value) {
return item.value
}
return null
} catch (error) {
console.error('Error getting filter preferences:', error)
return null
}
}
async clearFilters(): Promise<void> {
try {
await this.dbHelper.delete(FILTERS_KEY)
} catch (error) {
console.error('Error clearing filter preferences:', error)
throw error
}
}
}
const filterPreferencesService = new FilterPreferencesService()
export async function saveFilterPreferences(filters: ArticleFilters): Promise<void> {
return filterPreferencesService.saveFilters(filters)
}
export async function getFilterPreferences(): Promise<ArticleFilters | null> {
return filterPreferencesService.getFilters()
}
export async function clearFilterPreferences(): Promise<void> {
return filterPreferencesService.clearFilters()
}

View File

@ -0,0 +1,64 @@
import { t } from './i18n'
import { getAlbyService, isWebLNAvailable } from './alby'
export async function copyInvoiceToClipboard(params: {
invoice: string
setCopied: (value: boolean) => void
setErrorMessage: (value: string | null) => void
showToast: ((message: string, variant?: 'success' | 'info' | 'warning' | 'error', duration?: number) => void) | undefined
}): Promise<void> {
try {
await navigator.clipboard.writeText(params.invoice)
params.setCopied(true)
if (params.showToast !== undefined) {
params.showToast(t('payment.modal.copySuccess'), 'success', 2000)
}
scheduleCopiedReset(params.setCopied)
} catch (e) {
console.error('Failed to copy:', e)
params.setErrorMessage(t('payment.modal.copyFailed'))
}
}
export async function openWalletForInvoice(params: {
invoice: string
onPaymentComplete: () => void
setErrorMessage: (value: string | null) => void
showToast: ((message: string, variant?: 'success' | 'info' | 'warning' | 'error', duration?: number) => void) | undefined
}): Promise<void> {
try {
await payWithWebLN(params.invoice)
if (params.showToast !== undefined) {
params.showToast(t('payment.modal.paymentInitiated'), 'success')
}
params.onPaymentComplete()
} catch (e) {
const error = normalizePaymentError(e)
if (isUserCancellationError(error)) {
return
}
console.error('Payment failed:', error)
params.setErrorMessage(error.message)
}
}
function normalizePaymentError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error))
}
function scheduleCopiedReset(setCopied: (value: boolean) => void): void {
setTimeout(() => setCopied(false), 2000)
}
function isUserCancellationError(error: Error): boolean {
return error.message.includes('user rejected') || error.message.includes('cancelled')
}
async function payWithWebLN(invoice: string): Promise<void> {
const alby = getAlbyService()
if (!isWebLNAvailable()) {
throw new Error(t('payment.modal.weblnNotAvailable'))
}
await alby.enable()
await alby.sendPayment(invoice)
}

View File

@ -0,0 +1,79 @@
import { createIndexedDBHelper, type IndexedDBHelper } from './helpers/indexedDBHelper'
const DB_NAME = 'nostr_paywall_settings'
const DB_VERSION = 3
const STORE_NAME = 'reading_mode_preferences'
export interface ReadingModeSettings {
maxWidth: 'narrow' | 'medium' | 'wide' | 'full'
fontSize: 'small' | 'medium' | 'large' | 'xlarge'
lineHeight: 'tight' | 'normal' | 'relaxed'
}
export interface ReadingModePreferencesItem {
key: 'settings'
value: ReadingModeSettings
timestamp: number
}
const SETTINGS_KEY = 'settings'
class ReadingModePreferencesService {
private readonly dbHelper: IndexedDBHelper
constructor() {
this.dbHelper = createIndexedDBHelper({
dbName: DB_NAME,
version: DB_VERSION,
storeName: STORE_NAME,
keyPath: 'key',
indexes: [{ name: 'timestamp', keyPath: 'timestamp', unique: false }],
})
}
async getSettings(): Promise<ReadingModeSettings | null> {
try {
const result = await this.dbHelper.get<ReadingModePreferencesItem>(SETTINGS_KEY)
return result?.value ?? null
} catch (error) {
console.error('Error loading reading mode preferences:', error)
return null
}
}
async saveSettings(settings: ReadingModeSettings): Promise<void> {
try {
await this.dbHelper.put({
key: SETTINGS_KEY,
value: settings,
timestamp: Date.now(),
})
} catch (error) {
console.error('Error saving reading mode preferences:', error)
throw error
}
}
async clearSettings(): Promise<void> {
try {
await this.dbHelper.delete(SETTINGS_KEY)
} catch (error) {
console.error('Error clearing reading mode preferences:', error)
throw error
}
}
}
const readingModePreferencesService = new ReadingModePreferencesService()
export async function getReadingModeSettings(): Promise<ReadingModeSettings | null> {
return readingModePreferencesService.getSettings()
}
export async function saveReadingModeSettings(settings: ReadingModeSettings): Promise<void> {
return readingModePreferencesService.saveSettings(settings)
}
export async function clearReadingModeSettings(): Promise<void> {
return readingModePreferencesService.clearSettings()
}

91
lib/searchHistory.ts Normal file
View File

@ -0,0 +1,91 @@
import { createIndexedDBHelper, type IndexedDBHelper } from './helpers/indexedDBHelper'
const DB_NAME = 'nostr_paywall_settings'
const DB_VERSION = 3
const STORE_NAME = 'search_history'
export interface SearchHistoryItem {
query: string
timestamp: number
}
const MAX_HISTORY_ITEMS = 10
class SearchHistoryService {
private readonly dbHelper: IndexedDBHelper
constructor() {
this.dbHelper = createIndexedDBHelper({
dbName: DB_NAME,
version: DB_VERSION,
storeName: STORE_NAME,
keyPath: 'query',
indexes: [{ name: 'timestamp', keyPath: 'timestamp', unique: false }],
})
}
async saveSearchQuery(query: string): Promise<void> {
if (!query.trim()) {
return
}
try {
const trimmedQuery = query.trim()
const existing = await this.dbHelper.get<SearchHistoryItem>(trimmedQuery)
if (existing) {
await this.dbHelper.delete(trimmedQuery)
}
await this.dbHelper.put({ query: trimmedQuery, timestamp: Date.now() })
await this.limitHistorySize()
} catch (error) {
console.error('Error saving search history:', error)
}
}
async getSearchHistory(): Promise<SearchHistoryItem[]> {
try {
const items = await this.dbHelper.getAll<SearchHistoryItem>()
const sorted = items.sort((a, b) => b.timestamp - a.timestamp)
return sorted.slice(0, MAX_HISTORY_ITEMS)
} catch (error) {
console.error('Error getting search history:', error)
return []
}
}
async clearSearchHistory(): Promise<void> {
try {
await this.dbHelper.clear()
} catch (error) {
console.error('Error clearing search history:', error)
throw error
}
}
private async limitHistorySize(): Promise<void> {
try {
const history = await this.getSearchHistory()
if (history.length > MAX_HISTORY_ITEMS) {
const toDelete = history.slice(MAX_HISTORY_ITEMS)
for (const item of toDelete) {
await this.dbHelper.delete(item.query)
}
}
} catch (error) {
console.error('Error limiting history size:', error)
}
}
}
const searchHistoryService = new SearchHistoryService()
export async function saveSearchQuery(query: string): Promise<void> {
return searchHistoryService.saveSearchQuery(query)
}
export async function getSearchHistory(): Promise<SearchHistoryItem[]> {
return searchHistoryService.getSearchHistory()
}
export async function clearSearchHistory(): Promise<void> {
return searchHistoryService.clearSearchHistory()
}

View File

@ -23,6 +23,14 @@ nav.documentation=Documentation
nav.publish=Publish profile
nav.createAuthorPage=Create author page
nav.loading=Loading...
navigation.skipLinks=Skip navigation links
navigation.skipToMain=Skip to main content
navigation.skipToFilters=Skip to filters
navigation.skipToArticles=Skip to articles
navigation.articlesSection=Articles list
navigation.authorsSection=Authors list
navigation.filtersSection=Filters
navigation.categories=Categories
# Connect
connect.createAccount=Create account
@ -127,6 +135,8 @@ presentation.delete.error=Error deleting author page
# Filters
filters.clear=Clear all
filters.author=All authors
filters.author.select=Select an author
filters.author.selected=Selected author: {{author}}
filters.sort=Sort by
filters.sort.newest=Newest
filters.sort.oldest=Oldest
@ -134,6 +144,9 @@ filters.loading=Loading authors...
# Search
search.placeholder=Search...
search.suggestion.article=Article
search.suggestion.author=Author
search.suggestion.history=History
# Footer
footer.legal=Legal
@ -180,10 +193,67 @@ 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.
payment.modal.instructions.title=Payment Instructions
payment.modal.instructions.step1=Click the "Pay with Alby" button below
payment.modal.instructions.step2=Confirm the payment in the Alby window that opens
payment.modal.instructions.step3=Content will be unlocked automatically after confirmation
payment.modal.instructions.titleNoAlby=Payment Instructions
payment.modal.instructions.step1NoAlby=Install the Alby extension from getalby.com
payment.modal.instructions.step2NoAlby=Reload this page after installation
payment.modal.instructions.step3NoAlby=Or scan the QR code with your mobile Lightning wallet
# Article
article.unlock.success=Article unlocked successfully!
article.publish.success=Article published successfully!
article.unlocked.badge=Unlocked
# Notifications
notifications.badge.unread.singular=unread notification
notifications.badge.unread.plural=unread notifications
notifications.badge.noUnread=No unread notifications
# Reading Mode
readingMode.enable=Enable reading mode
readingMode.disable=Disable reading mode
readingMode.maxWidth=Max width
readingMode.maxWidth.narrow=Narrow
readingMode.maxWidth.medium=Medium
readingMode.maxWidth.wide=Wide
readingMode.maxWidth.full=Full width
readingMode.fontSize=Font size
readingMode.fontSize.small=Small
readingMode.fontSize.medium=Medium
readingMode.fontSize.large=Large
readingMode.fontSize.xlarge=Extra large
readingMode.lineHeight=Line height
readingMode.lineHeight.tight=Tight
readingMode.lineHeight.normal=Normal
readingMode.lineHeight.relaxed=Relaxed
# Share
share.copyLink=Copy link
share.copySuccess=Link copied successfully
share.copyFailed=Failed to copy link
share.shareToNostr=Share on Nostr
share.shareSuccess=Share successful
share.shareFailed=Share failed
share.nostrLinkCopied=Nostr link copied
# Suggestions
suggestions.similarArticles=Similar articles
suggestions.authorArticles=Articles by the same author
suggestions.categoryArticles=Articles in the same category
# Errors
errors.network.suggestion=Check your Internet connection and try again.
errors.network.offline=You appear to be offline. Check your Internet connection.
errors.payment.suggestion=Check your Alby connection and ensure you have sufficient funds.
errors.validation.suggestion=Verify that all required fields are filled correctly.
errors.loading.suggestion=Unable to load data. Check your connection and try again.
errors.unknown.suggestion=An unexpected error occurred. Try again or contact support.
errors.actions.retry=Retry
errors.actions.checkConnection=Check connection
errors.actions.viewDocumentation=View documentation
# Article
article.title=Title
@ -208,6 +278,7 @@ common.empty.authors=No authors found. Check back later!
common.empty.authors.filtered=No authors match your search or filters.
common.back=Back
common.open=Open
common.close=Close
# Settings
settings.title=Settings

View File

@ -23,6 +23,14 @@ nav.documentation=Documentation
nav.publish=Publier le profil
nav.createAuthorPage=Créer page auteur
nav.loading=Chargement...
navigation.skipLinks=Liens de navigation rapide
navigation.skipToMain=Aller au contenu principal
navigation.skipToFilters=Aller aux filtres
navigation.skipToArticles=Aller aux articles
navigation.articlesSection=Liste des articles
navigation.authorsSection=Liste des auteurs
navigation.filtersSection=Filtres
navigation.categories=Catégories
# Connect
connect.createAccount=Créer un compte
@ -44,6 +52,9 @@ category.science-fiction=Science-fiction
category.scientific-research=Recherche scientifique
category.all=Toutes les catégories
# Common
common.close=Fermer
# Articles/Publications
publication.title=Publications
publication.empty=Aucune publication
@ -127,6 +138,8 @@ presentation.delete.error=Erreur lors de la suppression de la page auteur
# Filters
filters.clear=Effacer tout
filters.author=Tous les auteurs
filters.author.select=Sélectionner un auteur
filters.author.selected=Auteur sélectionné : {{author}}
filters.sort=Trier par
filters.sort.newest=Plus récent
filters.sort.oldest=Plus ancien
@ -134,6 +147,9 @@ filters.loading=Chargement des auteurs...
# Search
search.placeholder=Rechercher...
search.suggestion.article=Article
search.suggestion.author=Auteur
search.suggestion.history=Historique
# Footer
footer.legal=Mentions légales
@ -152,6 +168,7 @@ common.empty.authors=Aucun auteur trouvé. Revenez plus tard !
common.empty.authors.filtered=Aucun auteur ne correspond à votre recherche ou à vos filtres.
common.back=Retour
common.open=Ouvrir
common.close=Fermer
# Settings
settings.title=Paramètres
@ -252,7 +269,64 @@ 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.
payment.modal.instructions.title=Instructions de paiement
payment.modal.instructions.step1=Cliquez sur le bouton "Payer avec Alby" ci-dessous
payment.modal.instructions.step2=Confirmez le paiement dans la fenêtre Alby qui s'ouvre
payment.modal.instructions.step3=Le contenu sera débloqué automatiquement après confirmation
payment.modal.instructions.titleNoAlby=Instructions de paiement
payment.modal.instructions.step1NoAlby=Installez l'extension Alby depuis getalby.com
payment.modal.instructions.step2NoAlby=Rechargez cette page après l'installation
payment.modal.instructions.step3NoAlby=Ou scannez le QR code avec votre portefeuille Lightning mobile
# Article
article.unlock.success=Article débloqué avec succès !
article.publish.success=Article publié avec succès !
article.unlocked.badge=Débloqué
# Notifications
notifications.badge.unread.singular=notification non lue
notifications.badge.unread.plural=notifications non lues
notifications.badge.noUnread=Aucune notification non lue
# Reading Mode
readingMode.enable=Activer le mode lecture
readingMode.disable=Désactiver le mode lecture
readingMode.maxWidth=Largeur maximale
readingMode.maxWidth.narrow=Étroite
readingMode.maxWidth.medium=Moyenne
readingMode.maxWidth.wide=Large
readingMode.maxWidth.full=Pleine largeur
readingMode.fontSize=Taille de police
readingMode.fontSize.small=Petite
readingMode.fontSize.medium=Moyenne
readingMode.fontSize.large=Grande
readingMode.fontSize.xlarge=Très grande
readingMode.lineHeight=Interligne
readingMode.lineHeight.tight=Serré
readingMode.lineHeight.normal=Normal
readingMode.lineHeight.relaxed=Relâché
# Share
share.copyLink=Copier le lien
share.copySuccess=Lien copié avec succès
share.copyFailed=Échec de la copie du lien
share.shareToNostr=Partager sur Nostr
share.shareSuccess=Partage réussi
share.shareFailed=Échec du partage
share.nostrLinkCopied=Lien Nostr copié
# Suggestions
suggestions.similarArticles=Articles similaires
suggestions.authorArticles=Articles du même auteur
suggestions.categoryArticles=Articles de la même catégorie
# Errors
errors.network.suggestion=Vérifiez votre connexion Internet et réessayez.
errors.network.offline=Vous semblez être hors ligne. Vérifiez votre connexion Internet.
errors.payment.suggestion=Vérifiez votre connexion Alby et assurez-vous d'avoir des fonds suffisants.
errors.validation.suggestion=Vérifiez que tous les champs requis sont remplis correctement.
errors.loading.suggestion=Impossible de charger les données. Vérifiez votre connexion et réessayez.
errors.unknown.suggestion=Une erreur inattendue s'est produite. Réessayez ou contactez le support.
errors.actions.retry=Réessayer
errors.actions.checkConnection=Vérifier la connexion
errors.actions.viewDocumentation=Voir la documentation

View File

@ -2,6 +2,7 @@ import '@/styles/globals.css'
import type { AppProps } from 'next/app'
import Router from 'next/router'
import { useI18n } from '@/hooks/useI18n'
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts'
import React from 'react'
import { nostrAuthService } from '@/lib/nostrAuth'
import { relaySessionManager } from '@/lib/relaySessionManager'
@ -58,6 +59,7 @@ export default function App({ Component, pageProps }: AppProps): React.ReactElem
usePublishWorkerLifecycle()
usePlatformSyncOnNavigation()
useUserSyncOnNavigationAndAuth()
useKeyboardShortcuts()
return (
<I18nProvider>

View File

@ -6,6 +6,7 @@ import { getAuthorsByCategory, sortAuthors } from '@/lib/authorFiltering'
import type { Article } from '@/types/nostr'
import type { ArticleFilters } from '@/components/ArticleFilters'
import { HomeView } from '@/components/HomeView'
import { getFilterPreferences, saveFilterPreferences } from '@/lib/filterPreferences'
function usePresentationArticles(allArticles: Article[]): Map<string, Article> {
return useMemo(() => {
@ -19,6 +20,43 @@ function usePresentationArticles(allArticles: Article[]): Map<string, Article> {
}, [allArticles])
}
function useFilterPersistence(
filters: ArticleFilters,
setFilters: React.Dispatch<React.SetStateAction<ArticleFilters>>
): void {
const [filtersLoaded, setFiltersLoaded] = useState(false)
useEffect(() => {
const loadFilters = async (): Promise<void> => {
try {
const savedFilters = await getFilterPreferences()
if (savedFilters !== null) {
setFilters(savedFilters)
}
} catch (error) {
console.error('Error loading filter preferences:', error)
} finally {
setFiltersLoaded(true)
}
}
void loadFilters()
}, [setFilters])
useEffect(() => {
if (!filtersLoaded) {
return
}
const saveFilters = async (): Promise<void> => {
try {
await saveFilterPreferences(filters)
} catch (error) {
console.error('Error saving filter preferences:', error)
}
}
void saveFilters()
}, [filters, filtersLoaded])
}
function useHomeState(): {
searchQuery: string
setSearchQuery: React.Dispatch<React.SetStateAction<string>>
@ -38,6 +76,8 @@ function useHomeState(): {
})
const [unlockedArticles, setUnlockedArticles] = useState<Set<string>>(new Set())
useFilterPersistence(filters, setFilters)
return {
searchQuery,
setSearchQuery,