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 { useArticlePayment } from '@/hooks/useArticlePayment'
import { ArticlePreview } from './ArticlePreview' import { ArticlePreview } from './ArticlePreview'
import { PaymentModal } from './PaymentModal' import { PaymentModal } from './PaymentModal'
import { Card } from './ui' import { Card, Badge } from './ui'
import { useToast } from './ui/ToastContainer' import { useToast } from './ui/ToastContainer'
import { t } from '@/lib/i18n' import { t } from '@/lib/i18n'
import Link from 'next/link' import Link from 'next/link'
@ -11,12 +11,36 @@ import Link from 'next/link'
interface ArticleCardProps { interface ArticleCardProps {
article: Article article: Article
onUnlock?: (article: Article) => void onUnlock?: (article: Article) => void
allArticles?: Article[]
unlockedArticles?: Set<string>
} }
function ArticleHeader({ article }: { article: Article }): React.ReactElement { function ArticleHeader({ article }: { article: Article }): React.ReactElement {
return ( return (
<div className="mb-2 flex items-center justify-between"> <div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-3 flex-1">
<h2 className="text-2xl font-bold text-neon-cyan">{article.title}</h2> <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 <Link
href={`/author/${article.pubkey}`} href={`/author/${article.pubkey}`}
className="text-xs text-cyber-accent/70 hover:text-neon-cyan transition-colors" className="text-xs text-cyber-accent/70 hover:text-neon-cyan transition-colors"
@ -93,6 +117,8 @@ function ArticleCardContent(params: {
handleUnlock: () => Promise<void> handleUnlock: () => Promise<void>
handlePaymentComplete: () => Promise<void> handlePaymentComplete: () => Promise<void>
handleCloseModal: () => void handleCloseModal: () => void
allArticles?: Article[]
unlockedArticles?: Set<string>
}): React.ReactElement { }): React.ReactElement {
return ( return (
<> <>
@ -104,6 +130,8 @@ function ArticleCardContent(params: {
onUnlock={() => { onUnlock={() => {
void params.handleUnlock() void params.handleUnlock()
}} }}
{...(params.allArticles !== undefined ? { allArticles: params.allArticles } : {})}
{...(params.unlockedArticles !== undefined ? { unlockedArticles: params.unlockedArticles } : {})}
/> />
</div> </div>
<ArticleMeta <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 { pubkey, connect } = useNostrAuth()
const { showToast } = useToast() const { showToast } = useToast()
const state = useArticleCardState({ const state = useArticleCardState({
@ -130,8 +158,12 @@ export function ArticleCard({ article, onUnlock }: ArticleCardProps): React.Reac
showToast, 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 ( return (
<Card variant="interactive" className="mb-0"> <Card variant="interactive" className={cardClassName}>
<ArticleCardContent <ArticleCardContent
article={article} article={article}
loading={state.loading} loading={state.loading}
@ -140,6 +172,8 @@ export function ArticleCard({ article, onUnlock }: ArticleCardProps): React.Reac
handleUnlock={state.handleUnlock} handleUnlock={state.handleUnlock}
handlePaymentComplete={state.handlePaymentComplete} handlePaymentComplete={state.handlePaymentComplete}
handleCloseModal={state.handleCloseModal} handleCloseModal={state.handleCloseModal}
{...(allArticles !== undefined ? { allArticles } : {})}
{...(unlockedArticles !== undefined ? { unlockedArticles } : {})}
/> />
</Card> </Card>
) )

View File

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

View File

@ -1,21 +1,68 @@
import type { Article } from '@/types/nostr' import type { Article } from '@/types/nostr'
import { Button } from './ui' import { Button } from './ui'
import { ArticlePages } from './ArticlePages' import { ArticlePages } from './ArticlePages'
import { ReadingMode } from './ReadingMode'
import { ShareButtons } from './ShareButtons'
import { ArticleSuggestions } from './ArticleSuggestions'
import { findSimilarArticles, findAuthorArticles } from '@/lib/articleSuggestions'
interface ArticlePreviewProps { interface ArticlePreviewProps {
article: Article article: Article
loading: boolean loading: boolean
onUnlock: () => void onUnlock: () => void
allArticles?: Article[]
unlockedArticles?: Set<string>
} }
export function ArticlePreview({ article, loading, onUnlock }: ArticlePreviewProps): React.ReactElement { function PaidArticleContent({
if (article.paid) { 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 ( return (
<ReadingMode>
<div> <div>
<p className="mb-2 text-cyber-accent">{article.preview}</p> <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> <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} />} {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> </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}
/>
) )
} }

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 type { Article } from '@/types/nostr'
import { ArticleCard } from './ArticleCard' import { ArticleCard } from './ArticleCard'
import { ErrorState, EmptyState, Skeleton } from './ui' import { ErrorState, EmptyState, Skeleton } from './ui'
import { t } from '@/lib/i18n' import { t } from '@/lib/i18n'
import { useArrowNavigation } from '@/hooks/useArrowNavigation'
interface ArticlesListProps { interface ArticlesListProps {
articles: Article[] articles: Article[]
@ -31,8 +33,8 @@ function ArticleCardSkeleton(): React.ReactElement {
function LoadingState(): React.ReactElement { function LoadingState(): React.ReactElement {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{Array.from({ length: 3 }).map((_, index) => ( {Array.from({ length: 3 }, (_, index) => (
<ArticleCardSkeleton key={index} /> <ArticleCardSkeleton key={`article-skeleton-${index}`} />
))} ))}
</div> </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({ export function ArticlesList({
articles, articles,
allArticles, allArticles,
@ -54,30 +111,26 @@ export function ArticlesList({
onUnlock, onUnlock,
unlockedArticles, unlockedArticles,
}: ArticlesListProps): React.ReactElement { }: ArticlesListProps): React.ReactElement {
const containerRef = useRef<HTMLDivElement>(null)
useArrowNavigation({ itemCount: articles.length, containerRef, enabled: !loading && articles.length > 0 })
if (loading) { if (loading) {
return <LoadingState /> return <LoadingState />
} }
if (error) { if (error) {
return <ErrorState message={error} /> return <ArticlesErrorState error={error} />
} }
if (articles.length === 0) { if (articles.length === 0) {
return <ArticlesEmptyState hasAny={allArticles.length > 0} /> return <ArticlesEmptyState hasAny={allArticles.length > 0} />
} }
return ( return (
<> <ArticlesContent
<div className="mb-4 text-sm text-cyber-accent/70"> articles={articles}
Showing {articles.length} of {allArticles.length} article{allArticles.length !== 1 ? 's' : ''} allArticles={allArticles}
</div>
<div className="space-y-6">
{articles.map((article) => (
<ArticleCard
key={article.id}
article={{ ...article, paid: unlockedArticles.has(article.id) || article.paid }}
onUnlock={onUnlock} onUnlock={onUnlock}
unlockedArticles={unlockedArticles}
containerRef={containerRef}
/> />
))}
</div>
</>
) )
} }

View File

@ -1,6 +1,7 @@
import React from 'react' import React from 'react'
import { Button } from './ui' import { Button } from './ui'
import { AuthorAvatar } from './AuthorFilterDropdown' import { AuthorAvatar } from './AuthorFilterDropdown'
import { t } from '@/lib/i18n'
export function AuthorMnemonicIcons({ value, getMnemonicIcons }: { value: string; getMnemonicIcons: (pubkey: string) => string[] }): React.ReactElement { export function AuthorMnemonicIcons({ value, getMnemonicIcons }: { value: string; getMnemonicIcons: (pubkey: string) => string[] }): React.ReactElement {
return ( return (
@ -70,6 +71,8 @@ export function AuthorFilterButton({
setIsOpen: (open: boolean) => void setIsOpen: (open: boolean) => void
buttonRef: React.RefObject<HTMLButtonElement | null> buttonRef: React.RefObject<HTMLButtonElement | null>
}): React.ReactElement { }): React.ReactElement {
const ariaLabel = value ? `${t('filters.author.selected', { author: selectedDisplayName })}` : t('filters.author.select')
return ( return (
<Button <Button
id="author-filter" 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" 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-expanded={isOpen}
aria-haspopup="listbox" aria-haspopup="listbox"
aria-label={ariaLabel}
> >
<AuthorFilterButtonContent <AuthorFilterButtonContent
value={value} value={value}

View File

@ -1,7 +1,9 @@
import { useRef } from 'react'
import type { Article } from '@/types/nostr' import type { Article } from '@/types/nostr'
import { AuthorCard } from './AuthorCard' import { AuthorCard } from './AuthorCard'
import { ErrorState, EmptyState, Skeleton } from './ui' import { ErrorState, EmptyState, Skeleton } from './ui'
import { t } from '@/lib/i18n' import { t } from '@/lib/i18n'
import { useArrowNavigation } from '@/hooks/useArrowNavigation'
interface AuthorsListProps { interface AuthorsListProps {
authors: Article[] authors: Article[]
@ -28,8 +30,8 @@ function AuthorCardSkeleton(): React.ReactElement {
function LoadingState(): React.ReactElement { function LoadingState(): React.ReactElement {
return ( return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{Array.from({ length: 4 }).map((_, index) => ( {Array.from({ length: 4 }, (_, index) => (
<AuthorCardSkeleton key={index} /> <AuthorCardSkeleton key={`author-skeleton-${index}`} />
))} ))}
</div> </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 { 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) { if (loading) {
return <LoadingState /> return <LoadingState />
} }
if (error) { if (error) {
return <ErrorState message={error} /> return <AuthorsErrorState error={error} />
} }
if (authors.length === 0) { if (authors.length === 0) {
return <AuthorsEmptyState hasAny={allAuthors.length > 0} /> return <AuthorsEmptyState hasAny={allAuthors.length > 0} />
} }
return ( return (
<> <section id="articles-section" aria-label={t('navigation.authorsSection')} tabIndex={-1}>
<div className="mb-4 text-sm text-cyber-accent/70"> <div className="mb-4 text-sm text-cyber-accent/70">
Showing {authors.length} of {allAuthors.length} author{allAuthors.length !== 1 ? 's' : ''} Showing {authors.length} of {allAuthors.length} author{allAuthors.length !== 1 ? 's' : ''}
</div> </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) => ( {authors.map((author) => (
<AuthorCard key={author.pubkey} presentation={author} /> <div key={author.pubkey} role="listitem">
<AuthorCard presentation={author} />
</div>
))} ))}
</div> </div>
</> </section>
) )
} }

View File

@ -12,11 +12,14 @@ export function CategoryTabs({ selectedCategory, onCategoryChange }: CategoryTab
return ( return (
<div className="mb-6"> <div className="mb-6">
<div className="border-b border-neon-cyan/30"> <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 <Button
type="button" type="button"
variant="ghost" variant="ghost"
onClick={() => onCategoryChange('science-fiction')} 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 ${ className={`py-4 px-1 border-b-2 font-medium text-sm transition-colors rounded-none ${
selectedCategory === 'science-fiction' selectedCategory === 'science-fiction'
? 'border-neon-cyan text-neon-cyan' ? 'border-neon-cyan text-neon-cyan'
@ -29,6 +32,9 @@ export function CategoryTabs({ selectedCategory, onCategoryChange }: CategoryTab
type="button" type="button"
variant="ghost" variant="ghost"
onClick={() => onCategoryChange('scientific-research')} 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 ${ className={`py-4 px-1 border-b-2 font-medium text-sm transition-colors rounded-none ${
selectedCategory === 'scientific-research' selectedCategory === 'scientific-research'
? 'border-neon-cyan text-neon-cyan' ? 'border-neon-cyan text-neon-cyan'

View File

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

View File

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

View File

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

View File

@ -1,3 +1,4 @@
import { useEffect } from 'react'
import { Card } from './ui' import { Card } from './ui'
import type { Notification } from '@/lib/notificationService' import type { Notification } from '@/lib/notificationService'
import { NotificationItem } from './NotificationItem' import { NotificationItem } from './NotificationItem'
@ -48,6 +49,19 @@ export function NotificationPanel({
onMarkAllAsRead, onMarkAllAsRead,
onClose, onClose,
}: NotificationPanelProps): React.ReactElement { }: 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 ( return (
<> <>
<div className="fixed inset-0 z-40 bg-black bg-opacity-50" onClick={onClose} /> <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 { export function PageHeader(): React.ReactElement {
return ( 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"> <div className="max-w-4xl mx-auto px-4 py-4 flex justify-between items-center">
<HeaderLeft /> <HeaderLeft />
<HeaderRight /> <HeaderRight />
@ -86,6 +86,7 @@ function HeaderLinks(): React.ReactElement {
href="/docs" href="/docs"
className="text-cyber-accent hover:text-neon-cyan transition-colors" className="text-cyber-accent hover:text-neon-cyan transition-colors"
title={t('nav.documentation')} title={t('nav.documentation')}
aria-label={t('nav.documentation')}
> >
<DocsIcon /> <DocsIcon />
</Link> </Link>
@ -93,6 +94,7 @@ function HeaderLinks(): React.ReactElement {
href="/funding" href="/funding"
className="text-cyber-accent hover:text-neon-cyan transition-colors" className="text-cyber-accent hover:text-neon-cyan transition-colors"
title={t('funding.title')} title={t('funding.title')}
aria-label={t('funding.title')}
> >
<FundingIcon /> <FundingIcon />
</Link> </Link>
@ -102,6 +104,7 @@ function HeaderLinks(): React.ReactElement {
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-cyber-accent hover:text-neon-cyan transition-colors" className="text-cyber-accent hover:text-neon-cyan transition-colors"
title={t('common.repositoryGit')} title={t('common.repositoryGit')}
aria-label={t('common.repositoryGit')}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<GitIcon /> <GitIcon />

View File

@ -1,11 +1,19 @@
import { useEffect, useMemo, useState, useCallback } from 'react' import { useEffect, useState, useCallback } from 'react'
import QRCode from 'react-qr-code'
import type { AlbyInvoice } from '@/types/alby' import type { AlbyInvoice } from '@/types/alby'
import { getAlbyService, isWebLNAvailable } from '@/lib/alby' import { getAlbyService, isWebLNAvailable } from '@/lib/alby'
import { copyInvoiceToClipboard, openWalletForInvoice } from '@/lib/paymentModalHelpers'
import { AlbyInstaller } from './AlbyInstaller' import { AlbyInstaller } from './AlbyInstaller'
import { Card, Modal, Button } from './ui' import { Modal } from './ui'
import { useToast } from './ui/ToastContainer' import { useToast } from './ui/ToastContainer'
import { t } from '@/lib/i18n' import { t } from '@/lib/i18n'
import {
PaymentHeader,
InvoiceDisplay,
PaymentInstructions,
PaymentActions,
ExpiredNotice,
PaymentError,
} from './paymentModal/PaymentModalComponents'
interface PaymentModalProps { interface PaymentModalProps {
invoice: AlbyInvoice invoice: AlbyInvoice
@ -33,101 +41,6 @@ function useInvoiceTimer(expiresAt?: number): number | null {
return timeRemaining 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 = { type PaymentModalState = {
copied: boolean copied: boolean
@ -157,72 +70,70 @@ function usePaymentModalState(invoice: AlbyInvoice, onPaymentComplete: () => voi
return { copied, errorMessage, paymentUrl, timeRemaining, handleCopy, handleOpenWallet } 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)
}
scheduleCopiedReset(params.setCopied)
} catch (e) {
console.error('Failed to copy:', e)
params.setErrorMessage(t('payment.modal.copyFailed'))
}
}
async function openWalletForInvoice(params: { function useAlbyDetection(): boolean {
invoice: string const [hasAlby, setHasAlby] = useState(false)
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 { useEffect(() => {
return error instanceof Error ? error : new Error(String(error)) const checkAlby = (): void => {
}
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() const alby = getAlbyService()
if (!isWebLNAvailable()) { setHasAlby(isWebLNAvailable() && alby.isEnabled())
throw new Error(t('payment.modal.weblnNotAvailable'))
} }
await alby.enable() checkAlby()
await alby.sendPayment(invoice) const interval = setInterval(checkAlby, 1000)
return () => clearInterval(interval)
}, [])
return hasAlby
}
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 { export function PaymentModal({ invoice, onClose, onPaymentComplete }: PaymentModalProps): React.ReactElement {
const { showToast } = useToast() const { showToast } = useToast()
const { copied, errorMessage, paymentUrl, timeRemaining, handleCopy, handleOpenWallet } = const { copied, errorMessage, paymentUrl, timeRemaining, handleCopy, handleOpenWallet } =
usePaymentModalState(invoice, onPaymentComplete, showToast) usePaymentModalState(invoice, onPaymentComplete, showToast)
const hasAlby = useAlbyDetection()
const handleOpenWalletSync = (): void => { const handleOpenWalletSync = (): void => {
void handleOpenWallet() void handleOpenWallet()
} }
@ -232,26 +143,19 @@ export function PaymentModal({ invoice, onClose, onPaymentComplete }: PaymentMod
isOpen isOpen
onClose={onClose} onClose={onClose}
title={t('payment.modal.zapAmount', { amount: invoice.amount })} title={t('payment.modal.zapAmount', { amount: invoice.amount })}
size="small" size="medium"
aria-label={t('payment.modal.zapAmount', { amount: invoice.amount })} aria-label={t('payment.modal.zapAmount', { amount: invoice.amount })}
> >
<AlbyInstaller /> <PaymentModalContent
<PaymentHeader amount={invoice.amount} timeRemaining={timeRemaining} /> invoice={invoice}
<InvoiceDisplay invoiceText={invoice.invoice} paymentUrl={paymentUrl} />
<PaymentActions
copied={copied} copied={copied}
onCopy={handleCopy} errorMessage={errorMessage}
onOpenWallet={handleOpenWalletSync} 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> </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 { SearchIcon } from './SearchIcon'
import { ClearButton } from './ClearButton' import { ClearButton } from './ClearButton'
import { Input } from './ui' import { Input } from './ui'
import { SearchSuggestions } from './SearchSuggestions'
import { saveSearchQuery } from '@/lib/searchHistory'
import { t } from '@/lib/i18n' import { t } from '@/lib/i18n'
interface SearchBarProps { interface SearchBarProps {
@ -10,34 +12,166 @@ interface SearchBarProps {
placeholder?: string placeholder?: string
} }
export function SearchBar({ value, onChange, placeholder }: SearchBarProps): React.ReactElement { function useSearchBarLocalValue(value: string): [string, (value: string) => void] {
const defaultPlaceholder = placeholder ?? t('search.placeholder')
const [localValue, setLocalValue] = useState(value) const [localValue, setLocalValue] = useState(value)
useEffect(() => { useEffect(() => {
setLocalValue(value) setLocalValue(value)
}, [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 handleChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
const newValue = e.target.value const newValue = e.target.value
setLocalValue(newValue) params.setLocalValue(newValue)
onChange(newValue) params.onChange(newValue)
if (newValue.trim() && params.isFocused) {
params.setShowSuggestions(true)
}
} }
const handleClear = (): void => { const handleClear = (): void => {
setLocalValue('') params.setLocalValue('')
onChange('') params.onChange('')
params.setShowSuggestions(false)
} }
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 ( return (
<div ref={containerRef} className="relative w-full">
<Input <Input
ref={ref}
type="text" type="text"
value={localValue} value={localValue}
onChange={handleChange} onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
placeholder={defaultPlaceholder} placeholder={defaultPlaceholder}
leftIcon={<SearchIcon />} leftIcon={<SearchIcon />}
rightIcon={localValue ? <ClearButton onClick={handleClear} /> : undefined} rightIcon={localValue ? <ClearButton onClick={handleClear} /> : undefined}
className="pr-10" 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 => ( const ArticlesLoading = (): React.ReactElement => (
<div className="space-y-6"> <div className="space-y-6">
{Array.from({ length: 3 }).map((_, index) => ( {Array.from({ length: 3 }, (_, index) => (
<ArticleCardSkeleton key={index} /> <ArticleCardSkeleton key={`user-article-skeleton-${index}`} />
))} ))}
</div> </div>
) )

View File

@ -29,8 +29,8 @@ function AuthorPageLoadingSkeleton(): React.ReactElement {
<div className="space-y-4"> <div className="space-y-4">
<Skeleton variant="rectangular" height={24} className="w-1/4" /> <Skeleton variant="rectangular" height={24} className="w-1/4" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{Array.from({ length: 2 }).map((_, index) => ( {Array.from({ length: 2 }, (_, index) => (
<Skeleton key={index} variant="rectangular" height={150} className="w-full" /> <Skeleton key={`author-page-skeleton-${index}`} variant="rectangular" height={150} className="w-full" />
))} ))}
</div> </div>
</div> </div>

View File

@ -1,4 +1,4 @@
import React from 'react' import React, { useEffect } from 'react'
import { ImageUploadField } from '../ImageUploadField' import { ImageUploadField } from '../ImageUploadField'
import { Button, Card, ErrorState, Input, Textarea } from '../ui' import { Button, Card, ErrorState, Input, Textarea } from '../ui'
import { t } from '@/lib/i18n' import { t } from '@/lib/i18n'
@ -6,14 +6,41 @@ import type { SeriesDraft } from './createSeriesModalTypes'
import type { CreateSeriesModalController } from './useCreateSeriesModalController' import type { CreateSeriesModalController } from './useCreateSeriesModalController'
export function CreateSeriesModalView({ ctrl }: { ctrl: CreateSeriesModalController }): React.ReactElement { 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 ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"> <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"> <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} /> <CreateSeriesModalHeader loading={ctrl.loading} onClose={ctrl.handleClose} />
{!ctrl.canPublish ? <NotAuthorWarning /> : null} {!ctrl.canPublish ? <NotAuthorWarning /> : null}
<CreateSeriesForm ctrl={ctrl} /> <CreateSeriesForm ctrl={ctrl} />
</Card> </Card>
</div> </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 type { ReactNode } from 'react'
import { Card } from './Card' import { Card } from './Card'
import { Button } from './Button'
import { t } from '@/lib/i18n'
import { classifyError, type ErrorClassification } from '@/lib/errorClassification'
interface ErrorStateProps { interface ErrorStateProps {
message: string message: string
action?: ReactNode action?: ReactNode
className?: string className?: string
onRetry?: () => void
onCheckConnection?: () => void
showDocumentationLink?: boolean
error?: unknown
} }
function ErrorIcon(): React.ReactElement { 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 ( return (
<Card variant="default" className={`bg-red-900/20 border-red-500/50 ${className}`} role="alert"> <Card variant="default" className={`bg-red-900/20 border-red-500/50 ${className}`} role="alert">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
@ -34,8 +150,15 @@ export function ErrorState({ message, action, className = '' }: ErrorStateProps)
<ErrorIcon /> <ErrorIcon />
</div> </div>
<div className="flex-1"> <div className="flex-1">
<p className="text-sm text-red-400 font-medium mb-2">{message}</p> <p className="text-sm text-red-400 font-medium mb-2">{displayMessage}</p>
{action && <div className="mt-3">{action}</div>} {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>
</div> </div>
</Card> </Card>

View File

@ -1,4 +1,4 @@
import { useMemo } from 'react' import React, { useMemo } from 'react'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> { interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
@ -90,16 +90,8 @@ function InputHelper({ inputId, helperText }: { inputId: string; helperText: str
) )
} }
export function Input({ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
label, ({ label, error, helperText, leftIcon, rightIcon, className = '', id, ...props }, ref): React.ReactElement => {
error,
helperText,
leftIcon,
rightIcon,
className = '',
id,
...props
}: InputProps): React.ReactElement {
const inputId = useMemo(() => generateId('input', id), [id]) const inputId = useMemo(() => generateId('input', id), [id])
const inputClasses = useMemo( const inputClasses = useMemo(
() => getInputClasses({ error, leftIcon, rightIcon, className }), () => getInputClasses({ error, leftIcon, rightIcon, className }),
@ -113,6 +105,7 @@ export function Input({
<div className="relative"> <div className="relative">
<InputIcons leftIcon={leftIcon} rightIcon={rightIcon} /> <InputIcons leftIcon={leftIcon} rightIcon={rightIcon} />
<input <input
ref={ref}
id={inputId} id={inputId}
className={inputClasses} className={inputClasses}
aria-invalid={error ? 'true' : 'false'} aria-invalid={error ? 'true' : 'false'}
@ -125,3 +118,6 @@ export function Input({
</div> </div>
) )
} }
)
Input.displayName = 'Input'

View File

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

View File

@ -45,7 +45,12 @@ export function ToastProvider({ children }: { children: React.ReactNode }): Reac
return ( return (
<ToastContext.Provider value={contextValue}> <ToastContext.Provider value={contextValue}>
{children} {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) => ( {toasts.map((toast) => (
<div key={toast.id} className="pointer-events-auto"> <div key={toast.id} className="pointer-events-auto">
<Toast <Toast

View File

@ -2,6 +2,7 @@
**Date** : 2025-01-27 **Date** : 2025-01-27
**Auteur** : Équipe 4NK **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 ## 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 - ✅ `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`) - ✅ `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 ## Erreurs corrigées
### TypeScript ### TypeScript
@ -133,6 +208,17 @@ Aucun composant prioritaire restant. Tous les composants principaux ont été mi
### Linting ### Linting
- ✅ Toutes les erreurs de linting ont été corrigées - ✅ 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 ## État actuel

View File

@ -2,7 +2,7 @@
**Date** : 2025-01-27 **Date** : 2025-01-27
**Auteur** : Équipe 4NK **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 ## 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 - 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 - Accessibilité : `role="alert"` et `aria-live="polite"` pour les screen readers
3. **Indicateur visuel pour contenu débloqué** 3. **Indicateur visuel pour contenu débloqué** - Implémenté
- Badge "Débloqué" sur les articles - Badge "Débloqué" avec icône de cadenas ouvert dans le header de l'article (`ArticleCard.tsx`)
- Icône de cadenas ouvert - Bordure distinctive (border-neon-green/40) et glow vert (shadow-glow-green) sur les cartes d'articles débloqués
- Couleur différente ou bordure distinctive - Accessibilité : `aria-label` sur le badge pour les screen readers
4. **Raccourcis clavier de base** 4. ✅ **Raccourcis clavier de base** - Implémenté
- `/` : Focus automatique sur la barre de recherche - `/` : Focus automatique sur la barre de recherche (détection via `role="search"`)
- `Esc` : Fermer les modals et overlays - `Esc` : Fermeture des modals et overlays :
- `Ctrl/Cmd + K` : Ouvrir une recherche rapide (command palette) - 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** 5. **Amélioration de la modal de paiement** - Implémenté
- Bouton "Payer avec Alby" en priorité (plus grand et plus visible) - Bouton "Payer avec Alby" en priorité (variant "success", taille "large", plus visible)
- Auto-détection d'Alby - Auto-détection d'Alby avec vérification périodique
- Instructions étape par étape - Instructions étape par étape affichées conditionnellement selon la présence d'Alby
- QR code amélioré (plus grand, meilleur contraste) - QR code amélioré (taille 300px, fond blanc, bordure cyan avec glow)
- Compte à rebours plus visible - Compte à rebours plus visible (Badge avec animation pulse si < 60s)
### Priorité moyenne ### Priorité moyenne
6. **Recherche améliorée avec suggestions** 6. **Recherche améliorée avec suggestions** - Implémenté
- Autocomplétion basée sur les titres d'articles - Autocomplétion basée sur les titres d'articles (5 suggestions max)
- Suggestions d'auteurs - Suggestions d'auteurs (3 suggestions max)
- Historique de recherche récente - Historique de recherche récente (5 dernières recherches, stocké dans IndexedDB)
7. **Filtres persistants** 7. ✅ **Filtres persistants** - Implémenté
- Sauvegarder les préférences de filtres dans IndexedDB - Sauvegarder les préférences de filtres dans IndexedDB (service `lib/filterPreferences.ts`)
- Restaurer les filtres au retour sur la page - Restaurer les filtres au retour sur la page (chargement automatique dans `useHomeState()`)
- Sauvegarde automatique à chaque modification des filtres
8. **Navigation clavier complète** 8. **Navigation clavier complète** - Implémenté
- Tab order logique - Tab order logique (IDs ajoutés pour les sections principales)
- Navigation par flèches dans les listes - Navigation par flèches dans les listes (hook `useArrowNavigation` avec support ArrowUp/Down/Home/End)
- Skip links pour navigation rapide - Skip links pour navigation rapide (composant `SkipLinks` avec liens vers main-content, filters-section, articles-section)
9. **ARIA amélioré** 9. **ARIA amélioré** - Implémenté
- Labels ARIA pour tous les boutons iconiques - Labels ARIA pour tous les boutons iconiques (liens dans PageHeader, KeyIndicator, NotificationBadgeButton, AuthorFilterButton, ClearButton, Toast close button)
- Régions ARIA (`role="navigation"`, `role="main"`, `role="search"`) - 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`) - Annonces screen reader (`aria-live="polite"` dans ToastContainer et Toast, `role="alert"` pour les toasts)
10. **Messages d'erreur actionnables** 10. ✅ **Messages d'erreur actionnables** - Implémenté
- Messages clairs avec explication - Messages clairs avec explication (système de classification des erreurs dans `lib/errorClassification.ts`)
- Actions de récupération (bouton "Réessayer") - Actions de récupération (bouton "Réessayer", "Vérifier la connexion", "Voir la documentation")
- Suggestions de solutions - 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 ### 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 **Auteur** : Équipe 4NK
**Date** : 2025-01-27 **Date** : 2025-01-27
## Objectif ## 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 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 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 ## 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 - **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 - **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 ## Modifications
### Skeleton loaders ### 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" - `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 - 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 ## Modalités de déploiement
1. **Vérification** : 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 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"`) - 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 ## Pages affectées
- `components/ArticlesList.tsx` - `components/ArticlesList.tsx`
@ -138,4 +214,8 @@ Implémenter les deux premières améliorations UX de priorité haute :
- `locales/fr.txt` - `locales/fr.txt`
- `locales/en.txt` - `locales/en.txt`
- `components/ui/index.ts` - `components/ui/index.ts`
- `hooks/useKeyboardShortcuts.ts` (nouveau)
- `components/NotificationPanel.tsx`
- `components/createSeriesModal/CreateSeriesModalView.tsx`
- `docs/migration-status.md` - `docs/migration-status.md`
- `docs/todo-remaining.md`

View File

@ -2,6 +2,7 @@
**Auteur** : Équipe 4NK **Auteur** : Équipe 4NK
**Date** : 2025-01-27 **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 ## 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 ## 3. Recherche et filtres
### Recherche améliorée ### Recherche améliorée ✅ Implémenté
- **Recherche en temps réel** avec suggestions : - **Recherche en temps réel** avec suggestions :
- Autocomplétion basée sur les titres d'articles - Autocomplétion basée sur les titres d'articles (5 suggestions max)
- Suggestions d'auteurs - Suggestions d'auteurs (3 suggestions max)
- Historique de recherche récente - Historique de recherche récente (5 dernières recherches, affiché quand la recherche est vide)
- **Filtres combinables visibles** : - **Filtres combinables visibles** :
- Badges actifs montrant les filtres appliqués - Badges actifs montrant les filtres appliqués
- Compteur de résultats ("X articles trouvés") - Compteur de résultats ("X articles trouvés")
- Indication claire quand aucun résultat - Indication claire quand aucun résultat
- **Historique de recherche** : - ✅ **Historique de recherche** :
- Sauvegarder les recherches récentes dans IndexedDB - Sauvegarder les recherches récentes dans IndexedDB (service `lib/searchHistory.ts`)
- Afficher les recherches populaires - Affichage automatique de l'historique quand la recherche est vide
- ⏳ Afficher les recherches populaires (à implémenter)
### Filtres persistants ### Filtres persistants ✅ Implémenté
- Sauvegarder les préférences de filtres dans IndexedDB - ✅ Sauvegarder les préférences de filtres dans IndexedDB (service `lib/filterPreferences.ts`)
- Restaurer les filtres au retour sur la page - ✅ Restaurer les filtres au retour sur la page (chargement automatique dans `useHomeState()`)
- Options de filtres avancés (date, catégorie, auteur combinés) - ✅ 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 ### Simplification du flux
- **Bouton "Payer avec Alby" en priorité** : - **Bouton "Payer avec Alby" en priorité** :
- Plus grand et plus visible - Plus grand et plus visible (variant "success", taille "large")
- Couleur distinctive (neon-green) - Couleur distinctive (neon-green)
- Positionné en haut de la modal - Positionné en premier si Alby détecté
- **Auto-détection d'Alby** : - ✅ **Auto-détection d'Alby** :
- Détecter si Alby est disponible - Détecter si Alby est disponible (hook `useAlbyDetection()` avec vérification périodique)
- Ouvrir automatiquement Alby si disponible - Affichage conditionnel du bouton selon la détection
- Afficher un message si Alby n'est pas installé - Message d'installation affiché si Alby n'est pas installé
- **Instructions étape par étape** : - ✅ **Instructions étape par étape** :
- Guide visuel pour nouveaux utilisateurs - Guide visuel pour nouveaux utilisateurs (affiché conditionnellement)
- Étapes numérotées claires - Étapes numérotées claires (différentes selon présence d'Alby)
- Lien vers installation d'Alby si nécessaire - Instructions traduites (fr/en)
- **Option "Se souvenir de ce choix"** : - ⏳ **Option "Se souvenir de ce choix"** :
- Mémoriser la méthode de paiement préférée - À implémenter (mémorisation de la méthode de paiement préférée)
- Pré-sélectionner la méthode au prochain paiement
### Amélioration visuelle ### Amélioration visuelle
- **QR code amélioré** : - **QR code amélioré** :
- Plus grand (minimum 300x300px) - Plus grand (300x300px)
- Meilleur contraste - Meilleur contraste (fond blanc, bordure cyan avec glow)
- Bordure distinctive - Bordure distinctive (`border-4 border-neon-cyan/50 shadow-[0_0_20px_rgba(0,255,255,0.3)]`)
- **Copie d'invoice** : - **Copie d'invoice** :
- Bouton de copie plus visible - Bouton de copie visible (variant "secondary")
- Confirmation visuelle immédiate (toast) - Confirmation visuelle immédiate (toast)
- Copie en un clic - Copie en un clic
- **Compte à rebours** : - **Compte à rebours** :
- Plus visible (barre de progression circulaire) - Plus visible (Badge avec format MM:SS)
- Alerte visuelle quand < 60 secondes - Alerte visuelle quand < 60 secondes (animation pulse, variant "error")
- Message d'expiration clair - Message d'expiration clair
## 5. Accessibilité clavier ## 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 - Modal d'aide avec tous les raccourcis
- Indication des raccourcis dans les tooltips - Indication des raccourcis dans les tooltips
### ARIA amélioré ### ARIA amélioré ✅ Implémenté
- **Labels ARIA** : - **Labels ARIA** :
- Tous les boutons iconiques ont des labels - Tous les boutons iconiques ont des labels (liens dans PageHeader, KeyIndicator, NotificationBadgeButton, AuthorFilterButton, ClearButton, Toast close button)
- Images avec alt text descriptif - Images avec alt text descriptif (déjà présent dans la plupart des composants)
- Formulaires avec labels associés - Formulaires avec labels associés (déjà présent)
- **Régions ARIA** : - **Régions ARIA** :
- `role="navigation"` pour le header - `role="navigation"` pour le header
- `role="main"` pour le contenu principal - `role="main"` pour le contenu principal
- `role="search"` pour la barre de recherche - `role="search"` pour la barre de recherche (déjà présent)
- `role="complementary"` pour les filtres - `role="complementary"` pour les filtres (section changée en `<aside>`)
- **Annonces screen reader** : - `role="contentinfo"` pour le footer
- `aria-live` pour changements d'état (paiement réussi, erreurs) - `role="tablist"` et `role="tab"` pour CategoryTabs
- Messages d'erreur annoncés - ✅ **Annonces screen reader** :
- Confirmations d'actions annoncées - `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 ## 6. Gestion d'erreurs
### Messages d'erreur actionnables ### Messages d'erreur actionnables ✅ Implémenté
- **Messages clairs** : - ✅ **Messages clairs** :
- Langage simple et compréhensible - Langage simple et compréhensible (système de classification dans `lib/errorClassification.ts`)
- Explication de l'erreur - Explication de l'erreur (message d'erreur original préservé)
- Impact sur l'utilisateur - Impact sur l'utilisateur (suggestions contextuelles selon le type d'erreur)
- **Actions de récupération** : - ✅ **Actions de récupération** :
- Bouton "Réessayer" pour erreurs réseau - Bouton "Réessayer" pour erreurs réseau, paiement, chargement (affiché automatiquement selon la classification)
- Bouton "Vérifier la connexion" si applicable - Bouton "Vérifier la connexion" pour erreurs réseau et chargement (affiché automatiquement)
- Lien vers documentation si erreur complexe - Lien vers documentation pour erreurs de paiement (affiché automatiquement)
- **Suggestions de solutions** : - ✅ **Suggestions de solutions** :
- "Vérifiez votre connexion Alby" - "Vérifiez votre connexion Internet et réessayez" (erreurs réseau)
- "Assurez-vous d'avoir des fonds suffisants" - "Vérifiez votre connexion Alby et assurez-vous d'avoir des fonds suffisants" (erreurs paiement)
- "Vérifiez votre connexion Internet" - "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 ### Récupération gracieuse
- **Retry automatique** : - **Retry automatique** :
@ -274,14 +280,14 @@ Ce document liste les améliorations UX proposées pour la plateforme zapwall.fr
2. Toast notifications 2. Toast notifications
3. Indicateur visuel pour contenu débloqué 3. Indicateur visuel pour contenu débloqué
4. Raccourcis clavier de base (`/`, `Esc`) 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 ### Priorité moyenne
6. Recherche améliorée avec suggestions 6. Recherche améliorée avec suggestions - Implémenté
7. Filtres persistants 7. Filtres persistants - Implémenté
8. Navigation clavier complète 8. Navigation clavier complète - Implémenté
9. ARIA amélioré 9. ARIA amélioré - Implémenté
10. Messages d'erreur actionnables 10. Messages d'erreur actionnables - Implémenté
### Priorité basse ### Priorité basse
11. Mode lecture 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.publish=Publish profile
nav.createAuthorPage=Create author page nav.createAuthorPage=Create author page
nav.loading=Loading... 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
connect.createAccount=Create account connect.createAccount=Create account
@ -127,6 +135,8 @@ presentation.delete.error=Error deleting author page
# Filters # Filters
filters.clear=Clear all filters.clear=Clear all
filters.author=All authors filters.author=All authors
filters.author.select=Select an author
filters.author.selected=Selected author: {{author}}
filters.sort=Sort by filters.sort=Sort by
filters.sort.newest=Newest filters.sort.newest=Newest
filters.sort.oldest=Oldest filters.sort.oldest=Oldest
@ -134,6 +144,9 @@ filters.loading=Loading authors...
# Search # Search
search.placeholder=Search... search.placeholder=Search...
search.suggestion.article=Article
search.suggestion.author=Author
search.suggestion.history=History
# Footer # Footer
footer.legal=Legal 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.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.weblnNotAvailable=WebLN is not available. Please install Alby or use another Lightning extension.
payment.modal.autoVerify=Payment verification will happen automatically. 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
article.unlock.success=Article unlocked successfully! article.unlock.success=Article unlocked successfully!
article.publish.success=Article published 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
article.title=Title 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.empty.authors.filtered=No authors match your search or filters.
common.back=Back common.back=Back
common.open=Open common.open=Open
common.close=Close
# Settings # Settings
settings.title=Settings settings.title=Settings

View File

@ -23,6 +23,14 @@ nav.documentation=Documentation
nav.publish=Publier le profil nav.publish=Publier le profil
nav.createAuthorPage=Créer page auteur nav.createAuthorPage=Créer page auteur
nav.loading=Chargement... 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
connect.createAccount=Créer un compte connect.createAccount=Créer un compte
@ -44,6 +52,9 @@ category.science-fiction=Science-fiction
category.scientific-research=Recherche scientifique category.scientific-research=Recherche scientifique
category.all=Toutes les catégories category.all=Toutes les catégories
# Common
common.close=Fermer
# Articles/Publications # Articles/Publications
publication.title=Publications publication.title=Publications
publication.empty=Aucune publication publication.empty=Aucune publication
@ -127,6 +138,8 @@ presentation.delete.error=Erreur lors de la suppression de la page auteur
# Filters # Filters
filters.clear=Effacer tout filters.clear=Effacer tout
filters.author=Tous les auteurs 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=Trier par
filters.sort.newest=Plus récent filters.sort.newest=Plus récent
filters.sort.oldest=Plus ancien filters.sort.oldest=Plus ancien
@ -134,6 +147,9 @@ filters.loading=Chargement des auteurs...
# Search # Search
search.placeholder=Rechercher... search.placeholder=Rechercher...
search.suggestion.article=Article
search.suggestion.author=Auteur
search.suggestion.history=Historique
# Footer # Footer
footer.legal=Mentions légales 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.empty.authors.filtered=Aucun auteur ne correspond à votre recherche ou à vos filtres.
common.back=Retour common.back=Retour
common.open=Ouvrir common.open=Ouvrir
common.close=Fermer
# Settings # Settings
settings.title=Paramètres 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.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.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.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
article.unlock.success=Article débloqué avec succès ! article.unlock.success=Article débloqué avec succès !
article.publish.success=Article publié 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 type { AppProps } from 'next/app'
import Router from 'next/router' import Router from 'next/router'
import { useI18n } from '@/hooks/useI18n' import { useI18n } from '@/hooks/useI18n'
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts'
import React from 'react' import React from 'react'
import { nostrAuthService } from '@/lib/nostrAuth' import { nostrAuthService } from '@/lib/nostrAuth'
import { relaySessionManager } from '@/lib/relaySessionManager' import { relaySessionManager } from '@/lib/relaySessionManager'
@ -58,6 +59,7 @@ export default function App({ Component, pageProps }: AppProps): React.ReactElem
usePublishWorkerLifecycle() usePublishWorkerLifecycle()
usePlatformSyncOnNavigation() usePlatformSyncOnNavigation()
useUserSyncOnNavigationAndAuth() useUserSyncOnNavigationAndAuth()
useKeyboardShortcuts()
return ( return (
<I18nProvider> <I18nProvider>

View File

@ -6,6 +6,7 @@ import { getAuthorsByCategory, sortAuthors } from '@/lib/authorFiltering'
import type { Article } from '@/types/nostr' import type { Article } from '@/types/nostr'
import type { ArticleFilters } from '@/components/ArticleFilters' import type { ArticleFilters } from '@/components/ArticleFilters'
import { HomeView } from '@/components/HomeView' import { HomeView } from '@/components/HomeView'
import { getFilterPreferences, saveFilterPreferences } from '@/lib/filterPreferences'
function usePresentationArticles(allArticles: Article[]): Map<string, Article> { function usePresentationArticles(allArticles: Article[]): Map<string, Article> {
return useMemo(() => { return useMemo(() => {
@ -19,6 +20,43 @@ function usePresentationArticles(allArticles: Article[]): Map<string, Article> {
}, [allArticles]) }, [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(): { function useHomeState(): {
searchQuery: string searchQuery: string
setSearchQuery: React.Dispatch<React.SetStateAction<string>> setSearchQuery: React.Dispatch<React.SetStateAction<string>>
@ -38,6 +76,8 @@ function useHomeState(): {
}) })
const [unlockedArticles, setUnlockedArticles] = useState<Set<string>>(new Set()) const [unlockedArticles, setUnlockedArticles] = useState<Set<string>>(new Set())
useFilterPersistence(filters, setFilters)
return { return {
searchQuery, searchQuery,
setSearchQuery, setSearchQuery,