ux wip
This commit is contained in:
parent
30d37ec19c
commit
b792e5373a
@ -3,7 +3,7 @@ import { useNostrAuth } from '@/hooks/useNostrAuth'
|
||||
import { useArticlePayment } from '@/hooks/useArticlePayment'
|
||||
import { ArticlePreview } from './ArticlePreview'
|
||||
import { PaymentModal } from './PaymentModal'
|
||||
import { Card } from './ui'
|
||||
import { Card, Badge } from './ui'
|
||||
import { useToast } from './ui/ToastContainer'
|
||||
import { t } from '@/lib/i18n'
|
||||
import Link from 'next/link'
|
||||
@ -11,12 +11,36 @@ import Link from 'next/link'
|
||||
interface ArticleCardProps {
|
||||
article: Article
|
||||
onUnlock?: (article: Article) => void
|
||||
allArticles?: Article[]
|
||||
unlockedArticles?: Set<string>
|
||||
}
|
||||
|
||||
function ArticleHeader({ article }: { article: Article }): React.ReactElement {
|
||||
return (
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold text-neon-cyan">{article.title}</h2>
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<h2 className="text-2xl font-bold text-neon-cyan">{article.title}</h2>
|
||||
{article.paid && (
|
||||
<Badge variant="success" className="flex items-center gap-1" aria-label={t('article.unlocked.badge')}>
|
||||
<svg
|
||||
className="w-3 h-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
{t('article.unlocked.badge')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
href={`/author/${article.pubkey}`}
|
||||
className="text-xs text-cyber-accent/70 hover:text-neon-cyan transition-colors"
|
||||
@ -93,6 +117,8 @@ function ArticleCardContent(params: {
|
||||
handleUnlock: () => Promise<void>
|
||||
handlePaymentComplete: () => Promise<void>
|
||||
handleCloseModal: () => void
|
||||
allArticles?: Article[]
|
||||
unlockedArticles?: Set<string>
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<>
|
||||
@ -104,6 +130,8 @@ function ArticleCardContent(params: {
|
||||
onUnlock={() => {
|
||||
void params.handleUnlock()
|
||||
}}
|
||||
{...(params.allArticles !== undefined ? { allArticles: params.allArticles } : {})}
|
||||
{...(params.unlockedArticles !== undefined ? { unlockedArticles: params.unlockedArticles } : {})}
|
||||
/>
|
||||
</div>
|
||||
<ArticleMeta
|
||||
@ -119,7 +147,7 @@ function ArticleCardContent(params: {
|
||||
)
|
||||
}
|
||||
|
||||
export function ArticleCard({ article, onUnlock }: ArticleCardProps): React.ReactElement {
|
||||
export function ArticleCard({ article, onUnlock, allArticles, unlockedArticles }: ArticleCardProps): React.ReactElement {
|
||||
const { pubkey, connect } = useNostrAuth()
|
||||
const { showToast } = useToast()
|
||||
const state = useArticleCardState({
|
||||
@ -130,8 +158,12 @@ export function ArticleCard({ article, onUnlock }: ArticleCardProps): React.Reac
|
||||
showToast,
|
||||
})
|
||||
|
||||
const cardClassName = article.paid
|
||||
? 'mb-0 border-2 border-neon-green/40 shadow-[0_0_5px_#00ff41,0_0_10px_#00ff41]'
|
||||
: 'mb-0'
|
||||
|
||||
return (
|
||||
<Card variant="interactive" className="mb-0">
|
||||
<Card variant="interactive" className={cardClassName}>
|
||||
<ArticleCardContent
|
||||
article={article}
|
||||
loading={state.loading}
|
||||
@ -140,6 +172,8 @@ export function ArticleCard({ article, onUnlock }: ArticleCardProps): React.Reac
|
||||
handleUnlock={state.handleUnlock}
|
||||
handlePaymentComplete={state.handlePaymentComplete}
|
||||
handleCloseModal={state.handleCloseModal}
|
||||
{...(allArticles !== undefined ? { allArticles } : {})}
|
||||
{...(unlockedArticles !== undefined ? { unlockedArticles } : {})}
|
||||
/>
|
||||
</Card>
|
||||
)
|
||||
|
||||
@ -4,6 +4,7 @@ import { t } from '@/lib/i18n'
|
||||
import { useNostrAuth } from '@/hooks/useNostrAuth'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { objectCache } from '@/lib/objectCache'
|
||||
import { ReadingMode } from './ReadingMode'
|
||||
|
||||
interface ArticlePagesProps {
|
||||
pages: Page[]
|
||||
@ -39,14 +40,16 @@ function LockedPagesView({ pagesCount }: { pagesCount: number }): React.ReactEle
|
||||
|
||||
function PurchasedPagesView({ pages }: { pages: Page[] }): React.ReactElement {
|
||||
return (
|
||||
<div className="space-y-6 mt-6">
|
||||
<h3 className="text-xl font-semibold text-neon-cyan">{t('article.pages.title')}</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{pages.map((page) => (
|
||||
<PageDisplay key={page.number} page={page} />
|
||||
))}
|
||||
<ReadingMode>
|
||||
<div className="space-y-6 mt-6">
|
||||
<h3 className="text-xl font-semibold text-neon-cyan">{t('article.pages.title')}</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{pages.map((page) => (
|
||||
<PageDisplay key={page.number} page={page} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ReadingMode>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,40 +1,87 @@
|
||||
import type { Article } from '@/types/nostr'
|
||||
import { Button } from './ui'
|
||||
import { ArticlePages } from './ArticlePages'
|
||||
import { ReadingMode } from './ReadingMode'
|
||||
import { ShareButtons } from './ShareButtons'
|
||||
import { ArticleSuggestions } from './ArticleSuggestions'
|
||||
import { findSimilarArticles, findAuthorArticles } from '@/lib/articleSuggestions'
|
||||
|
||||
interface ArticlePreviewProps {
|
||||
article: Article
|
||||
loading: boolean
|
||||
onUnlock: () => void
|
||||
allArticles?: Article[]
|
||||
unlockedArticles?: Set<string>
|
||||
}
|
||||
|
||||
export function ArticlePreview({ article, loading, onUnlock }: ArticlePreviewProps): React.ReactElement {
|
||||
if (article.paid) {
|
||||
return (
|
||||
function PaidArticleContent({
|
||||
article,
|
||||
onUnlock,
|
||||
allArticles,
|
||||
unlockedArticles,
|
||||
}: {
|
||||
article: Article
|
||||
onUnlock: () => void
|
||||
allArticles: Article[]
|
||||
unlockedArticles: Set<string>
|
||||
}): React.ReactElement {
|
||||
const similarArticles = findSimilarArticles(article, allArticles)
|
||||
const authorArticles = findAuthorArticles(article, allArticles)
|
||||
|
||||
return (
|
||||
<ReadingMode>
|
||||
<div>
|
||||
<p className="mb-2 text-cyber-accent">{article.preview}</p>
|
||||
<p className="text-sm text-cyber-accent/80 mt-4 whitespace-pre-wrap">{article.content}</p>
|
||||
{article.pages && article.pages.length > 0 && <ArticlePages pages={article.pages} articleId={article.id} />}
|
||||
<div className="mt-6 pt-4 border-t border-neon-cyan/30">
|
||||
<ShareButtons articleId={article.id} articleTitle={article.title} authorPubkey={article.pubkey} />
|
||||
</div>
|
||||
<ArticleSuggestions
|
||||
similarArticles={similarArticles}
|
||||
authorArticles={authorArticles}
|
||||
onUnlock={onUnlock}
|
||||
unlockedArticles={unlockedArticles}
|
||||
/>
|
||||
</div>
|
||||
</ReadingMode>
|
||||
)
|
||||
}
|
||||
|
||||
export function ArticlePreview({
|
||||
article,
|
||||
loading,
|
||||
onUnlock,
|
||||
allArticles = [],
|
||||
unlockedArticles = new Set(),
|
||||
}: ArticlePreviewProps): React.ReactElement {
|
||||
if (article.paid) {
|
||||
return (
|
||||
<PaidArticleContent
|
||||
article={article}
|
||||
onUnlock={onUnlock}
|
||||
allArticles={allArticles}
|
||||
unlockedArticles={unlockedArticles}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="mb-4 text-cyber-accent">{article.preview}</p>
|
||||
<div className="border-t border-neon-cyan/30 pt-4">
|
||||
<p className="text-sm text-cyber-accent/70 mb-4">
|
||||
Contenu complet disponible après un zap de {article.zapAmount} sats
|
||||
</p>
|
||||
<Button
|
||||
variant="success"
|
||||
onClick={onUnlock}
|
||||
disabled={loading}
|
||||
loading={loading}
|
||||
>
|
||||
{loading ? 'Traitement...' : `Débloquer avec ${article.zapAmount} sats zap`}
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-4 text-cyber-accent">{article.preview}</p>
|
||||
<div className="border-t border-neon-cyan/30 pt-4">
|
||||
<p className="text-sm text-cyber-accent/70 mb-4">
|
||||
Contenu complet disponible après un zap de {article.zapAmount} sats
|
||||
</p>
|
||||
<Button
|
||||
variant="success"
|
||||
onClick={onUnlock}
|
||||
disabled={loading}
|
||||
loading={loading}
|
||||
>
|
||||
{loading ? 'Traitement...' : `Débloquer avec ${article.zapAmount} sats zap`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
85
components/ArticleSuggestions.tsx
Normal file
85
components/ArticleSuggestions.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -1,7 +1,9 @@
|
||||
import { useRef } from 'react'
|
||||
import type { Article } from '@/types/nostr'
|
||||
import { ArticleCard } from './ArticleCard'
|
||||
import { ErrorState, EmptyState, Skeleton } from './ui'
|
||||
import { t } from '@/lib/i18n'
|
||||
import { useArrowNavigation } from '@/hooks/useArrowNavigation'
|
||||
|
||||
interface ArticlesListProps {
|
||||
articles: Article[]
|
||||
@ -31,8 +33,8 @@ function ArticleCardSkeleton(): React.ReactElement {
|
||||
function LoadingState(): React.ReactElement {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<ArticleCardSkeleton key={index} />
|
||||
{Array.from({ length: 3 }, (_, index) => (
|
||||
<ArticleCardSkeleton key={`article-skeleton-${index}`} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
@ -46,6 +48,61 @@ function ArticlesEmptyState({ hasAny }: { hasAny: boolean }): React.ReactElement
|
||||
)
|
||||
}
|
||||
|
||||
function ArticlesErrorState({ error }: { error: string }): React.ReactElement {
|
||||
const errorObj = new Error(error)
|
||||
return (
|
||||
<ErrorState
|
||||
message={error}
|
||||
error={errorObj}
|
||||
onRetry={() => {
|
||||
window.location.reload()
|
||||
}}
|
||||
onCheckConnection={() => {
|
||||
if (navigator.onLine) {
|
||||
window.location.reload()
|
||||
} else {
|
||||
// eslint-disable-next-line no-alert
|
||||
alert(t('errors.network.offline'))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ArticlesContent({
|
||||
articles,
|
||||
allArticles,
|
||||
onUnlock,
|
||||
unlockedArticles,
|
||||
containerRef,
|
||||
}: {
|
||||
articles: Article[]
|
||||
allArticles: Article[]
|
||||
onUnlock: (article: Article) => void
|
||||
unlockedArticles: Set<string>
|
||||
containerRef: React.RefObject<HTMLDivElement | null>
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<section id="articles-section" aria-label={t('navigation.articlesSection')} tabIndex={-1}>
|
||||
<div className="mb-4 text-sm text-cyber-accent/70">
|
||||
Showing {articles.length} of {allArticles.length} article{allArticles.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
<div ref={containerRef} className="space-y-6" role="list">
|
||||
{articles.map((article) => (
|
||||
<div key={article.id} role="listitem">
|
||||
<ArticleCard
|
||||
article={{ ...article, paid: unlockedArticles.has(article.id) || article.paid }}
|
||||
onUnlock={onUnlock}
|
||||
allArticles={allArticles}
|
||||
unlockedArticles={unlockedArticles}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function ArticlesList({
|
||||
articles,
|
||||
allArticles,
|
||||
@ -54,30 +111,26 @@ export function ArticlesList({
|
||||
onUnlock,
|
||||
unlockedArticles,
|
||||
}: ArticlesListProps): React.ReactElement {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
useArrowNavigation({ itemCount: articles.length, containerRef, enabled: !loading && articles.length > 0 })
|
||||
|
||||
if (loading) {
|
||||
return <LoadingState />
|
||||
}
|
||||
if (error) {
|
||||
return <ErrorState message={error} />
|
||||
return <ArticlesErrorState error={error} />
|
||||
}
|
||||
if (articles.length === 0) {
|
||||
return <ArticlesEmptyState hasAny={allArticles.length > 0} />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4 text-sm text-cyber-accent/70">
|
||||
Showing {articles.length} of {allArticles.length} article{allArticles.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
{articles.map((article) => (
|
||||
<ArticleCard
|
||||
key={article.id}
|
||||
article={{ ...article, paid: unlockedArticles.has(article.id) || article.paid }}
|
||||
onUnlock={onUnlock}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
<ArticlesContent
|
||||
articles={articles}
|
||||
allArticles={allArticles}
|
||||
onUnlock={onUnlock}
|
||||
unlockedArticles={unlockedArticles}
|
||||
containerRef={containerRef}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import React from 'react'
|
||||
import { Button } from './ui'
|
||||
import { AuthorAvatar } from './AuthorFilterDropdown'
|
||||
import { t } from '@/lib/i18n'
|
||||
|
||||
export function AuthorMnemonicIcons({ value, getMnemonicIcons }: { value: string; getMnemonicIcons: (pubkey: string) => string[] }): React.ReactElement {
|
||||
return (
|
||||
@ -70,6 +71,8 @@ export function AuthorFilterButton({
|
||||
setIsOpen: (open: boolean) => void
|
||||
buttonRef: React.RefObject<HTMLButtonElement | null>
|
||||
}): React.ReactElement {
|
||||
const ariaLabel = value ? `${t('filters.author.selected', { author: selectedDisplayName })}` : t('filters.author.select')
|
||||
|
||||
return (
|
||||
<Button
|
||||
id="author-filter"
|
||||
@ -80,6 +83,7 @@ export function AuthorFilterButton({
|
||||
className="w-full px-3 py-2 border-neon-cyan/30 bg-cyber-dark text-cyber-accent text-left justify-start hover:border-neon-cyan/50"
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="listbox"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<AuthorFilterButtonContent
|
||||
value={value}
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import { useRef } from 'react'
|
||||
import type { Article } from '@/types/nostr'
|
||||
import { AuthorCard } from './AuthorCard'
|
||||
import { ErrorState, EmptyState, Skeleton } from './ui'
|
||||
import { t } from '@/lib/i18n'
|
||||
import { useArrowNavigation } from '@/hooks/useArrowNavigation'
|
||||
|
||||
interface AuthorsListProps {
|
||||
authors: Article[]
|
||||
@ -28,8 +30,8 @@ function AuthorCardSkeleton(): React.ReactElement {
|
||||
function LoadingState(): React.ReactElement {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<AuthorCardSkeleton key={index} />
|
||||
{Array.from({ length: 4 }, (_, index) => (
|
||||
<AuthorCardSkeleton key={`author-skeleton-${index}`} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
@ -43,27 +45,53 @@ function AuthorsEmptyState({ hasAny }: { hasAny: boolean }): React.ReactElement
|
||||
)
|
||||
}
|
||||
|
||||
function AuthorsErrorState({ error }: { error: string }): React.ReactElement {
|
||||
const errorObj = new Error(error)
|
||||
return (
|
||||
<ErrorState
|
||||
message={error}
|
||||
error={errorObj}
|
||||
onRetry={() => {
|
||||
window.location.reload()
|
||||
}}
|
||||
onCheckConnection={() => {
|
||||
if (navigator.onLine) {
|
||||
window.location.reload()
|
||||
} else {
|
||||
// eslint-disable-next-line no-alert
|
||||
alert(t('errors.network.offline'))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function AuthorsList({ authors, allAuthors, loading, error }: AuthorsListProps): React.ReactElement {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
useArrowNavigation({ itemCount: authors.length, containerRef, enabled: !loading && authors.length > 0 })
|
||||
|
||||
if (loading) {
|
||||
return <LoadingState />
|
||||
}
|
||||
if (error) {
|
||||
return <ErrorState message={error} />
|
||||
return <AuthorsErrorState error={error} />
|
||||
}
|
||||
if (authors.length === 0) {
|
||||
return <AuthorsEmptyState hasAny={allAuthors.length > 0} />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<section id="articles-section" aria-label={t('navigation.authorsSection')} tabIndex={-1}>
|
||||
<div className="mb-4 text-sm text-cyber-accent/70">
|
||||
Showing {authors.length} of {allAuthors.length} author{allAuthors.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div ref={containerRef} className="grid grid-cols-1 md:grid-cols-2 gap-6" role="list">
|
||||
{authors.map((author) => (
|
||||
<AuthorCard key={author.pubkey} presentation={author} />
|
||||
<div key={author.pubkey} role="listitem">
|
||||
<AuthorCard presentation={author} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@ -12,11 +12,14 @@ export function CategoryTabs({ selectedCategory, onCategoryChange }: CategoryTab
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<div className="border-b border-neon-cyan/30">
|
||||
<nav className="-mb-px flex space-x-8">
|
||||
<nav role="tablist" aria-label={t('navigation.categories')} className="-mb-px flex space-x-8">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => onCategoryChange('science-fiction')}
|
||||
role="tab"
|
||||
aria-selected={selectedCategory === 'science-fiction'}
|
||||
aria-controls="articles-section"
|
||||
className={`py-4 px-1 border-b-2 font-medium text-sm transition-colors rounded-none ${
|
||||
selectedCategory === 'science-fiction'
|
||||
? 'border-neon-cyan text-neon-cyan'
|
||||
@ -29,6 +32,9 @@ export function CategoryTabs({ selectedCategory, onCategoryChange }: CategoryTab
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => onCategoryChange('scientific-research')}
|
||||
role="tab"
|
||||
aria-selected={selectedCategory === 'scientific-research'}
|
||||
aria-controls="articles-section"
|
||||
className={`py-4 px-1 border-b-2 font-medium text-sm transition-colors rounded-none ${
|
||||
selectedCategory === 'scientific-research'
|
||||
? 'border-neon-cyan text-neon-cyan'
|
||||
|
||||
@ -3,7 +3,7 @@ import { t } from '@/lib/i18n'
|
||||
|
||||
export function Footer(): React.ReactElement {
|
||||
return (
|
||||
<footer className="bg-cyber-dark border-t border-neon-cyan/30 mt-12">
|
||||
<footer role="contentinfo" className="bg-cyber-dark border-t border-neon-cyan/30 mt-12">
|
||||
<div className="max-w-4xl mx-auto px-4 py-6">
|
||||
<div className="flex flex-col md:flex-row justify-between items-center gap-4 text-sm text-cyber-accent/70">
|
||||
<div className="flex flex-wrap justify-center gap-4">
|
||||
|
||||
@ -7,6 +7,7 @@ import { ArticlesList } from '@/components/ArticlesList'
|
||||
import { AuthorsList } from '@/components/AuthorsList'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
import { Footer } from '@/components/Footer'
|
||||
import { SkipLinks } from '@/components/SkipLinks'
|
||||
import type { Dispatch, SetStateAction } from 'react'
|
||||
import { t } from '@/lib/i18n'
|
||||
|
||||
@ -66,7 +67,7 @@ function HomeContent(props: HomeViewProps): React.ReactElement {
|
||||
const isInitialLoad = props.loading && props.allArticles.length === 0 && props.allAuthors.length === 0
|
||||
|
||||
return (
|
||||
<div className="w-full px-4 py-8">
|
||||
<div className="w-full px-4 py-8" id="main-content" tabIndex={-1}>
|
||||
<ArticlesHero
|
||||
searchQuery={props.searchQuery}
|
||||
setSearchQuery={props.setSearchQuery}
|
||||
@ -75,7 +76,9 @@ function HomeContent(props: HomeViewProps): React.ReactElement {
|
||||
/>
|
||||
|
||||
{shouldShowFilters && !shouldShowAuthors && (
|
||||
<ArticleFiltersComponent filters={props.filters} onFiltersChange={props.setFilters} articles={props.allArticles} />
|
||||
<aside id="filters-section" role="complementary" aria-label={t('navigation.filtersSection')} tabIndex={-1}>
|
||||
<ArticleFiltersComponent filters={props.filters} onFiltersChange={props.setFilters} articles={props.allArticles} />
|
||||
</aside>
|
||||
)}
|
||||
|
||||
<HomeMainList
|
||||
@ -159,7 +162,8 @@ export function HomeView(props: HomeViewProps): React.ReactElement {
|
||||
return (
|
||||
<>
|
||||
<HomeHead />
|
||||
<main className="min-h-screen bg-cyber-darker">
|
||||
<SkipLinks />
|
||||
<main role="main" className="min-h-screen bg-cyber-darker">
|
||||
<PageHeader />
|
||||
<HomeContent {...props} />
|
||||
<Footer />
|
||||
|
||||
@ -28,6 +28,7 @@ export function KeyIndicator(): React.ReactElement {
|
||||
href="/settings"
|
||||
className={`ml-2 text-xl ${color} hover:opacity-80 transition-opacity cursor-pointer`}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
🔑
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { Button } from './ui'
|
||||
import { t } from '@/lib/i18n'
|
||||
|
||||
interface NotificationBadgeButtonProps {
|
||||
unreadCount: number
|
||||
@ -6,13 +7,17 @@ interface NotificationBadgeButtonProps {
|
||||
}
|
||||
|
||||
export function NotificationBadgeButton({ unreadCount, onClick }: NotificationBadgeButtonProps): React.ReactElement {
|
||||
const ariaLabel = unreadCount > 0
|
||||
? `${unreadCount} ${unreadCount === 1 ? t('notifications.badge.unread.singular') : t('notifications.badge.unread.plural')}`
|
||||
: t('notifications.badge.noUnread')
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onClick}
|
||||
className="relative p-2 text-gray-600 hover:text-gray-900"
|
||||
aria-label={`${unreadCount} unread notification${unreadCount !== 1 ? 's' : ''}`}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { useEffect } from 'react'
|
||||
import { Card } from './ui'
|
||||
import type { Notification } from '@/lib/notificationService'
|
||||
import { NotificationItem } from './NotificationItem'
|
||||
@ -48,6 +49,19 @@ export function NotificationPanel({
|
||||
onMarkAllAsRead,
|
||||
onClose,
|
||||
}: NotificationPanelProps): React.ReactElement {
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscape)
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40 bg-black bg-opacity-50" onClick={onClose} />
|
||||
|
||||
@ -58,7 +58,7 @@ function FundingIcon(): React.ReactElement {
|
||||
|
||||
export function PageHeader(): React.ReactElement {
|
||||
return (
|
||||
<header className="bg-cyber-dark border-b border-neon-cyan/30 shadow-glow-cyan">
|
||||
<header role="navigation" className="bg-cyber-dark border-b border-neon-cyan/30 shadow-glow-cyan">
|
||||
<div className="max-w-4xl mx-auto px-4 py-4 flex justify-between items-center">
|
||||
<HeaderLeft />
|
||||
<HeaderRight />
|
||||
@ -86,6 +86,7 @@ function HeaderLinks(): React.ReactElement {
|
||||
href="/docs"
|
||||
className="text-cyber-accent hover:text-neon-cyan transition-colors"
|
||||
title={t('nav.documentation')}
|
||||
aria-label={t('nav.documentation')}
|
||||
>
|
||||
<DocsIcon />
|
||||
</Link>
|
||||
@ -93,6 +94,7 @@ function HeaderLinks(): React.ReactElement {
|
||||
href="/funding"
|
||||
className="text-cyber-accent hover:text-neon-cyan transition-colors"
|
||||
title={t('funding.title')}
|
||||
aria-label={t('funding.title')}
|
||||
>
|
||||
<FundingIcon />
|
||||
</Link>
|
||||
@ -102,6 +104,7 @@ function HeaderLinks(): React.ReactElement {
|
||||
rel="noopener noreferrer"
|
||||
className="text-cyber-accent hover:text-neon-cyan transition-colors"
|
||||
title={t('common.repositoryGit')}
|
||||
aria-label={t('common.repositoryGit')}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<GitIcon />
|
||||
|
||||
@ -1,11 +1,19 @@
|
||||
import { useEffect, useMemo, useState, useCallback } from 'react'
|
||||
import QRCode from 'react-qr-code'
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import type { AlbyInvoice } from '@/types/alby'
|
||||
import { getAlbyService, isWebLNAvailable } from '@/lib/alby'
|
||||
import { copyInvoiceToClipboard, openWalletForInvoice } from '@/lib/paymentModalHelpers'
|
||||
import { AlbyInstaller } from './AlbyInstaller'
|
||||
import { Card, Modal, Button } from './ui'
|
||||
import { Modal } from './ui'
|
||||
import { useToast } from './ui/ToastContainer'
|
||||
import { t } from '@/lib/i18n'
|
||||
import {
|
||||
PaymentHeader,
|
||||
InvoiceDisplay,
|
||||
PaymentInstructions,
|
||||
PaymentActions,
|
||||
ExpiredNotice,
|
||||
PaymentError,
|
||||
} from './paymentModal/PaymentModalComponents'
|
||||
|
||||
interface PaymentModalProps {
|
||||
invoice: AlbyInvoice
|
||||
@ -33,101 +41,6 @@ function useInvoiceTimer(expiresAt?: number): number | null {
|
||||
return timeRemaining
|
||||
}
|
||||
|
||||
function PaymentHeader({
|
||||
amount,
|
||||
timeRemaining,
|
||||
}: {
|
||||
amount: number
|
||||
timeRemaining: number | null
|
||||
}): React.ReactElement {
|
||||
const timeLabel = useMemo((): string | null => {
|
||||
if (timeRemaining === null) {
|
||||
return null
|
||||
}
|
||||
if (timeRemaining <= 0) {
|
||||
return t('payment.expired')
|
||||
}
|
||||
const minutes = Math.floor(timeRemaining / 60)
|
||||
const secs = timeRemaining % 60
|
||||
return `${minutes}:${secs.toString().padStart(2, '0')}`
|
||||
}, [timeRemaining])
|
||||
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<h2 className="text-xl font-bold text-neon-cyan">{t('payment.modal.zapAmount', { amount })}</h2>
|
||||
{timeLabel && (
|
||||
<p className={`text-sm ${timeRemaining !== null && timeRemaining <= 60 ? 'text-red-400 font-semibold' : 'text-cyber-accent/70'}`}>
|
||||
{t('payment.modal.timeRemaining', { time: timeLabel })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InvoiceDisplay({ invoiceText, paymentUrl }: { invoiceText: string; paymentUrl: string }): React.ReactElement {
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<p className="text-sm text-cyber-accent mb-2">{t('payment.modal.lightningInvoice')}</p>
|
||||
<Card variant="default" className="bg-cyber-darker border-neon-cyan/20 p-3 break-all text-sm font-mono mb-4 text-neon-cyan">
|
||||
{invoiceText}
|
||||
</Card>
|
||||
<div className="flex justify-center mb-4">
|
||||
<Card variant="default" className="bg-cyber-dark p-4 border-2 border-neon-cyan/30">
|
||||
<QRCode
|
||||
value={paymentUrl}
|
||||
size={200}
|
||||
style={{ height: 'auto', maxWidth: '100%', width: '100%' }}
|
||||
viewBox="0 0 256 256"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
<p className="text-xs text-cyber-accent/70 text-center mb-2">{t('payment.modal.scanQr')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PaymentActions({
|
||||
copied,
|
||||
onCopy,
|
||||
onOpenWallet,
|
||||
}: {
|
||||
copied: boolean
|
||||
onCopy: () => Promise<void>
|
||||
onOpenWallet: () => void
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
void onCopy()
|
||||
}}
|
||||
className="flex-1"
|
||||
>
|
||||
{copied ? t('payment.modal.copied') : t('payment.modal.copyInvoice')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onOpenWallet}
|
||||
className="flex-1"
|
||||
>
|
||||
{t('payment.modal.payWithAlby')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ExpiredNotice({ show }: { show: boolean }): React.ReactElement | null {
|
||||
if (!show) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<Card variant="default" className="mt-4 p-3 bg-red-900/20 border-red-400/50">
|
||||
<p className="text-sm text-red-400 font-semibold mb-2">{t('payment.modal.invoiceExpired')}</p>
|
||||
<p className="text-xs text-red-400/80">{t('payment.modal.invoiceExpiredHelp')}</p>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
type PaymentModalState = {
|
||||
copied: boolean
|
||||
@ -157,72 +70,70 @@ function usePaymentModalState(invoice: AlbyInvoice, onPaymentComplete: () => voi
|
||||
return { copied, errorMessage, paymentUrl, timeRemaining, handleCopy, handleOpenWallet }
|
||||
}
|
||||
|
||||
async function copyInvoiceToClipboard(params: {
|
||||
invoice: string
|
||||
setCopied: (value: boolean) => void
|
||||
setErrorMessage: (value: string | null) => void
|
||||
showToast: ((message: string, variant?: 'success' | 'info' | 'warning' | 'error', duration?: number) => void) | undefined
|
||||
}): Promise<void> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(params.invoice)
|
||||
params.setCopied(true)
|
||||
if (params.showToast !== undefined) {
|
||||
params.showToast(t('payment.modal.copySuccess'), 'success', 2000)
|
||||
|
||||
function useAlbyDetection(): boolean {
|
||||
const [hasAlby, setHasAlby] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const checkAlby = (): void => {
|
||||
const alby = getAlbyService()
|
||||
setHasAlby(isWebLNAvailable() && alby.isEnabled())
|
||||
}
|
||||
scheduleCopiedReset(params.setCopied)
|
||||
} catch (e) {
|
||||
console.error('Failed to copy:', e)
|
||||
params.setErrorMessage(t('payment.modal.copyFailed'))
|
||||
}
|
||||
checkAlby()
|
||||
const interval = setInterval(checkAlby, 1000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
return hasAlby
|
||||
}
|
||||
|
||||
async function openWalletForInvoice(params: {
|
||||
invoice: string
|
||||
onPaymentComplete: () => void
|
||||
setErrorMessage: (value: string | null) => void
|
||||
showToast: ((message: string, variant?: 'success' | 'info' | 'warning' | 'error', duration?: number) => void) | undefined
|
||||
}): Promise<void> {
|
||||
try {
|
||||
await payWithWebLN(params.invoice)
|
||||
if (params.showToast !== undefined) {
|
||||
params.showToast(t('payment.modal.paymentInitiated'), 'success')
|
||||
}
|
||||
params.onPaymentComplete()
|
||||
} catch (e) {
|
||||
const error = normalizePaymentError(e)
|
||||
if (isUserCancellationError(error)) {
|
||||
return
|
||||
}
|
||||
console.error('Payment failed:', error)
|
||||
params.setErrorMessage(error.message)
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePaymentError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error(String(error))
|
||||
}
|
||||
|
||||
function scheduleCopiedReset(setCopied: (value: boolean) => void): void {
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
function isUserCancellationError(error: Error): boolean {
|
||||
return error.message.includes('user rejected') || error.message.includes('cancelled')
|
||||
}
|
||||
|
||||
async function payWithWebLN(invoice: string): Promise<void> {
|
||||
const alby = getAlbyService()
|
||||
if (!isWebLNAvailable()) {
|
||||
throw new Error(t('payment.modal.weblnNotAvailable'))
|
||||
}
|
||||
await alby.enable()
|
||||
await alby.sendPayment(invoice)
|
||||
function PaymentModalContent({
|
||||
invoice,
|
||||
copied,
|
||||
errorMessage,
|
||||
paymentUrl,
|
||||
timeRemaining,
|
||||
handleCopy,
|
||||
handleOpenWallet,
|
||||
hasAlby,
|
||||
}: {
|
||||
invoice: AlbyInvoice
|
||||
copied: boolean
|
||||
errorMessage: string | null
|
||||
paymentUrl: string
|
||||
timeRemaining: number | null
|
||||
handleCopy: () => Promise<void>
|
||||
handleOpenWallet: () => void
|
||||
hasAlby: boolean
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<>
|
||||
{!hasAlby && <AlbyInstaller />}
|
||||
<PaymentHeader amount={invoice.amount} timeRemaining={timeRemaining} />
|
||||
<PaymentInstructions hasAlby={hasAlby} />
|
||||
<InvoiceDisplay invoiceText={invoice.invoice} paymentUrl={paymentUrl} />
|
||||
<PaymentActions
|
||||
copied={copied}
|
||||
onCopy={handleCopy}
|
||||
onOpenWallet={handleOpenWallet}
|
||||
hasAlby={hasAlby}
|
||||
/>
|
||||
<ExpiredNotice show={timeRemaining !== null && timeRemaining <= 0} />
|
||||
<PaymentError errorMessage={errorMessage} onRetry={handleOpenWallet} />
|
||||
<p className="text-xs text-cyber-accent/70 mt-4 text-center">
|
||||
{t('payment.modal.autoVerify')}
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function PaymentModal({ invoice, onClose, onPaymentComplete }: PaymentModalProps): React.ReactElement {
|
||||
const { showToast } = useToast()
|
||||
const { copied, errorMessage, paymentUrl, timeRemaining, handleCopy, handleOpenWallet } =
|
||||
usePaymentModalState(invoice, onPaymentComplete, showToast)
|
||||
const hasAlby = useAlbyDetection()
|
||||
|
||||
const handleOpenWalletSync = (): void => {
|
||||
void handleOpenWallet()
|
||||
}
|
||||
@ -232,26 +143,19 @@ export function PaymentModal({ invoice, onClose, onPaymentComplete }: PaymentMod
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
title={t('payment.modal.zapAmount', { amount: invoice.amount })}
|
||||
size="small"
|
||||
size="medium"
|
||||
aria-label={t('payment.modal.zapAmount', { amount: invoice.amount })}
|
||||
>
|
||||
<AlbyInstaller />
|
||||
<PaymentHeader amount={invoice.amount} timeRemaining={timeRemaining} />
|
||||
<InvoiceDisplay invoiceText={invoice.invoice} paymentUrl={paymentUrl} />
|
||||
<PaymentActions
|
||||
<PaymentModalContent
|
||||
invoice={invoice}
|
||||
copied={copied}
|
||||
onCopy={handleCopy}
|
||||
onOpenWallet={handleOpenWalletSync}
|
||||
errorMessage={errorMessage}
|
||||
paymentUrl={paymentUrl}
|
||||
timeRemaining={timeRemaining}
|
||||
handleCopy={handleCopy}
|
||||
handleOpenWallet={handleOpenWalletSync}
|
||||
hasAlby={hasAlby}
|
||||
/>
|
||||
<ExpiredNotice show={timeRemaining !== null && timeRemaining <= 0} />
|
||||
{errorMessage && (
|
||||
<p className="text-xs text-red-400 mt-3 text-center" role="alert">
|
||||
{errorMessage}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-cyber-accent/70 mt-4 text-center">
|
||||
{t('payment.modal.autoVerify')}
|
||||
</p>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
215
components/ReadingMode.tsx
Normal file
215
components/ReadingMode.tsx
Normal 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`
|
||||
}
|
||||
@ -1,7 +1,9 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { SearchIcon } from './SearchIcon'
|
||||
import { ClearButton } from './ClearButton'
|
||||
import { Input } from './ui'
|
||||
import { SearchSuggestions } from './SearchSuggestions'
|
||||
import { saveSearchQuery } from '@/lib/searchHistory'
|
||||
import { t } from '@/lib/i18n'
|
||||
|
||||
interface SearchBarProps {
|
||||
@ -10,34 +12,166 @@ interface SearchBarProps {
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
export function SearchBar({ value, onChange, placeholder }: SearchBarProps): React.ReactElement {
|
||||
const defaultPlaceholder = placeholder ?? t('search.placeholder')
|
||||
function useSearchBarLocalValue(value: string): [string, (value: string) => void] {
|
||||
const [localValue, setLocalValue] = useState(value)
|
||||
|
||||
useEffect(() => {
|
||||
setLocalValue(value)
|
||||
}, [value])
|
||||
return [localValue, setLocalValue]
|
||||
}
|
||||
|
||||
function useSearchBarFocusState(): [boolean, () => void, () => void] {
|
||||
const [isFocused, setIsFocused] = useState(false)
|
||||
const handleFocus = (): void => {
|
||||
setIsFocused(true)
|
||||
}
|
||||
const handleBlur = (): void => {
|
||||
setIsFocused(false)
|
||||
}
|
||||
return [isFocused, handleFocus, handleBlur]
|
||||
}
|
||||
|
||||
interface UseSearchBarHandlersParams {
|
||||
setLocalValue: (value: string) => void
|
||||
onChange: (value: string) => void
|
||||
isFocused: boolean
|
||||
setShowSuggestions: (show: boolean) => void
|
||||
}
|
||||
|
||||
function useSearchBarHandlers(params: UseSearchBarHandlersParams): {
|
||||
handleChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
handleClear: () => void
|
||||
handleFocusWithSuggestions: () => void
|
||||
handleSelectSuggestion: (query: string) => void
|
||||
handleCloseSuggestions: () => void
|
||||
} {
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
const newValue = e.target.value
|
||||
setLocalValue(newValue)
|
||||
onChange(newValue)
|
||||
params.setLocalValue(newValue)
|
||||
params.onChange(newValue)
|
||||
if (newValue.trim() && params.isFocused) {
|
||||
params.setShowSuggestions(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClear = (): void => {
|
||||
setLocalValue('')
|
||||
onChange('')
|
||||
params.setLocalValue('')
|
||||
params.onChange('')
|
||||
params.setShowSuggestions(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
type="text"
|
||||
value={localValue}
|
||||
onChange={handleChange}
|
||||
placeholder={defaultPlaceholder}
|
||||
leftIcon={<SearchIcon />}
|
||||
rightIcon={localValue ? <ClearButton onClick={handleClear} /> : undefined}
|
||||
className="pr-10"
|
||||
/>
|
||||
)
|
||||
const handleFocusWithSuggestions = (): void => {
|
||||
params.setShowSuggestions(true)
|
||||
}
|
||||
|
||||
const handleSelectSuggestion = (query: string): void => {
|
||||
params.setLocalValue(query)
|
||||
params.onChange(query)
|
||||
params.setShowSuggestions(false)
|
||||
void saveSearchQuery(query)
|
||||
}
|
||||
|
||||
const handleCloseSuggestions = (): void => {
|
||||
params.setShowSuggestions(false)
|
||||
}
|
||||
|
||||
return {
|
||||
handleChange,
|
||||
handleClear,
|
||||
handleFocusWithSuggestions,
|
||||
handleSelectSuggestion,
|
||||
handleCloseSuggestions,
|
||||
}
|
||||
}
|
||||
|
||||
function useSearchBarState(value: string, onChange: (value: string) => void): {
|
||||
localValue: string
|
||||
showSuggestions: boolean
|
||||
handleChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
handleClear: () => void
|
||||
handleFocus: () => void
|
||||
handleBlur: () => void
|
||||
handleSelectSuggestion: (query: string) => void
|
||||
handleCloseSuggestions: () => void
|
||||
setShowSuggestions: (show: boolean) => void
|
||||
} {
|
||||
const [localValue, setLocalValue] = useSearchBarLocalValue(value)
|
||||
const [showSuggestions, setShowSuggestions] = useState(false)
|
||||
const [isFocused, , handleBlur] = useSearchBarFocusState()
|
||||
const handlers = useSearchBarHandlers({ setLocalValue, onChange, isFocused, setShowSuggestions })
|
||||
|
||||
return {
|
||||
localValue,
|
||||
showSuggestions,
|
||||
handleChange: handlers.handleChange,
|
||||
handleClear: handlers.handleClear,
|
||||
handleFocus: handlers.handleFocusWithSuggestions,
|
||||
handleBlur,
|
||||
handleSelectSuggestion: handlers.handleSelectSuggestion,
|
||||
handleCloseSuggestions: handlers.handleCloseSuggestions,
|
||||
setShowSuggestions,
|
||||
}
|
||||
}
|
||||
|
||||
function useClickOutsideHandler(containerRef: React.RefObject<HTMLDivElement | null>, showSuggestions: boolean, setShowSuggestions: (show: boolean) => void): void {
|
||||
useEffect(() => {
|
||||
if (!showSuggestions) {
|
||||
return
|
||||
}
|
||||
const handleClickOutside = (e: MouseEvent): void => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setShowSuggestions(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}, [showSuggestions, setShowSuggestions, containerRef])
|
||||
}
|
||||
|
||||
export const SearchBar = React.forwardRef<HTMLInputElement, SearchBarProps>(
|
||||
({ value, onChange, placeholder }, ref): React.ReactElement => {
|
||||
const defaultPlaceholder = placeholder ?? t('search.placeholder')
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const {
|
||||
localValue,
|
||||
showSuggestions,
|
||||
handleChange,
|
||||
handleClear,
|
||||
handleFocus,
|
||||
handleBlur,
|
||||
handleSelectSuggestion,
|
||||
handleCloseSuggestions,
|
||||
setShowSuggestions,
|
||||
} = useSearchBarState(value, onChange)
|
||||
|
||||
useClickOutsideHandler(containerRef, showSuggestions, setShowSuggestions)
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative w-full">
|
||||
<Input
|
||||
ref={ref}
|
||||
type="text"
|
||||
value={localValue}
|
||||
onChange={handleChange}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
placeholder={defaultPlaceholder}
|
||||
leftIcon={<SearchIcon />}
|
||||
rightIcon={localValue ? <ClearButton onClick={handleClear} /> : undefined}
|
||||
className="pr-10"
|
||||
role="search"
|
||||
aria-label={t('search.placeholder')}
|
||||
aria-autocomplete="list"
|
||||
aria-expanded={showSuggestions}
|
||||
/>
|
||||
{showSuggestions && (
|
||||
<SearchSuggestions query={localValue} onSelect={handleSelectSuggestion} onClose={handleCloseSuggestions} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
SearchBar.displayName = 'SearchBar'
|
||||
|
||||
211
components/SearchSuggestions.tsx
Normal file
211
components/SearchSuggestions.tsx
Normal 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
133
components/ShareButtons.tsx
Normal 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
83
components/SkipLinks.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -39,8 +39,8 @@ function ArticleCardSkeleton(): React.ReactElement {
|
||||
|
||||
const ArticlesLoading = (): React.ReactElement => (
|
||||
<div className="space-y-6">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<ArticleCardSkeleton key={index} />
|
||||
{Array.from({ length: 3 }, (_, index) => (
|
||||
<ArticleCardSkeleton key={`user-article-skeleton-${index}`} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -29,8 +29,8 @@ function AuthorPageLoadingSkeleton(): React.ReactElement {
|
||||
<div className="space-y-4">
|
||||
<Skeleton variant="rectangular" height={24} className="w-1/4" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{Array.from({ length: 2 }).map((_, index) => (
|
||||
<Skeleton key={index} variant="rectangular" height={150} className="w-full" />
|
||||
{Array.from({ length: 2 }, (_, index) => (
|
||||
<Skeleton key={`author-page-skeleton-${index}`} variant="rectangular" height={150} className="w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React from 'react'
|
||||
import React, { useEffect } from 'react'
|
||||
import { ImageUploadField } from '../ImageUploadField'
|
||||
import { Button, Card, ErrorState, Input, Textarea } from '../ui'
|
||||
import { t } from '@/lib/i18n'
|
||||
@ -6,13 +6,40 @@ import type { SeriesDraft } from './createSeriesModalTypes'
|
||||
import type { CreateSeriesModalController } from './useCreateSeriesModalController'
|
||||
|
||||
export function CreateSeriesModalView({ ctrl }: { ctrl: CreateSeriesModalController }): React.ReactElement {
|
||||
const handleOverlayClick = (e: React.MouseEvent<HTMLDivElement>): void => {
|
||||
if (e.target === e.currentTarget && !ctrl.loading) {
|
||||
ctrl.handleClose()
|
||||
}
|
||||
}
|
||||
|
||||
const handleCardClick = (e: React.MouseEvent<HTMLDivElement>): void => {
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Escape' && !ctrl.loading) {
|
||||
ctrl.handleClose()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
document.body.style.overflow = 'hidden'
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscape)
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
}, [ctrl])
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<Card variant="default" className="bg-cyber-dark max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<CreateSeriesModalHeader loading={ctrl.loading} onClose={ctrl.handleClose} />
|
||||
{!ctrl.canPublish ? <NotAuthorWarning /> : null}
|
||||
<CreateSeriesForm ctrl={ctrl} />
|
||||
</Card>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm" onClick={handleOverlayClick}>
|
||||
<div onClick={handleCardClick}>
|
||||
<Card variant="default" className="bg-cyber-dark max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<CreateSeriesModalHeader loading={ctrl.loading} onClose={ctrl.handleClose} />
|
||||
{!ctrl.canPublish ? <NotAuthorWarning /> : null}
|
||||
<CreateSeriesForm ctrl={ctrl} />
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
202
components/paymentModal/PaymentModalComponents.tsx
Normal file
202
components/paymentModal/PaymentModalComponents.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -1,10 +1,17 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Card } from './Card'
|
||||
import { Button } from './Button'
|
||||
import { t } from '@/lib/i18n'
|
||||
import { classifyError, type ErrorClassification } from '@/lib/errorClassification'
|
||||
|
||||
interface ErrorStateProps {
|
||||
message: string
|
||||
action?: ReactNode
|
||||
className?: string
|
||||
onRetry?: () => void
|
||||
onCheckConnection?: () => void
|
||||
showDocumentationLink?: boolean
|
||||
error?: unknown
|
||||
}
|
||||
|
||||
function ErrorIcon(): React.ReactElement {
|
||||
@ -26,7 +33,116 @@ function ErrorIcon(): React.ReactElement {
|
||||
)
|
||||
}
|
||||
|
||||
export function ErrorState({ message, action, className = '' }: ErrorStateProps): React.ReactElement {
|
||||
function getUserFriendlyMessage(classification: ErrorClassification): string {
|
||||
return classification.message
|
||||
}
|
||||
|
||||
function getErrorClassification(error: unknown | undefined): ErrorClassification | null {
|
||||
if (error === undefined) {
|
||||
return null
|
||||
}
|
||||
return classifyError(error)
|
||||
}
|
||||
|
||||
function RetryButton({ onRetry }: { onRetry: () => void }): React.ReactElement {
|
||||
return (
|
||||
<Button type="button" variant="primary" size="small" onClick={onRetry}>
|
||||
{t('errors.actions.retry')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function CheckConnectionButton({ onCheckConnection }: { onCheckConnection: () => void }): React.ReactElement {
|
||||
return (
|
||||
<Button type="button" variant="secondary" size="small" onClick={onCheckConnection}>
|
||||
{t('errors.actions.checkConnection')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function DocumentationButton(): React.ReactElement {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
window.open('/docs', '_blank')
|
||||
}}
|
||||
>
|
||||
{t('errors.actions.viewDocumentation')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function computeActionFlags(
|
||||
classification: ErrorClassification | null,
|
||||
onRetry: (() => void) | undefined,
|
||||
onCheckConnection: (() => void) | undefined,
|
||||
showDocumentationLink: boolean
|
||||
): { showRetry: boolean; showCheck: boolean; showDoc: boolean } {
|
||||
const canRetry = classification?.canRetry ?? false
|
||||
const canCheckConnection = classification?.canCheckConnection ?? false
|
||||
const needsDocumentation = classification?.needsDocumentation ?? false
|
||||
|
||||
return {
|
||||
showRetry: canRetry && onRetry !== undefined,
|
||||
showCheck: canCheckConnection && onCheckConnection !== undefined,
|
||||
showDoc: needsDocumentation && showDocumentationLink,
|
||||
}
|
||||
}
|
||||
|
||||
function hasAnyAction(
|
||||
action: ReactNode | undefined,
|
||||
showRetry: boolean,
|
||||
showCheck: boolean,
|
||||
showDoc: boolean
|
||||
): boolean {
|
||||
return action !== undefined || showRetry || showCheck || showDoc
|
||||
}
|
||||
|
||||
function ErrorActions({
|
||||
action,
|
||||
classification,
|
||||
onRetry,
|
||||
onCheckConnection,
|
||||
showDocumentationLink,
|
||||
}: {
|
||||
action?: ReactNode
|
||||
classification: ErrorClassification | null
|
||||
onRetry?: () => void
|
||||
onCheckConnection?: () => void
|
||||
showDocumentationLink?: boolean
|
||||
}): React.ReactElement | null {
|
||||
const flags = computeActionFlags(classification, onRetry, onCheckConnection, showDocumentationLink ?? false)
|
||||
|
||||
if (!hasAnyAction(action, flags.showRetry, flags.showCheck, flags.showDoc)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{action !== undefined && action}
|
||||
{flags.showRetry && onRetry !== undefined && <RetryButton onRetry={onRetry} />}
|
||||
{flags.showCheck && onCheckConnection !== undefined && <CheckConnectionButton onCheckConnection={onCheckConnection} />}
|
||||
{flags.showDoc && <DocumentationButton />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ErrorState({
|
||||
message,
|
||||
action,
|
||||
className = '',
|
||||
onRetry,
|
||||
onCheckConnection,
|
||||
showDocumentationLink = false,
|
||||
error,
|
||||
}: ErrorStateProps): React.ReactElement {
|
||||
const classification = getErrorClassification(error)
|
||||
const displayMessage = classification !== null ? getUserFriendlyMessage(classification) : message
|
||||
const suggestion = classification?.suggestion !== undefined ? t(classification.suggestion) : undefined
|
||||
|
||||
return (
|
||||
<Card variant="default" className={`bg-red-900/20 border-red-500/50 ${className}`} role="alert">
|
||||
<div className="flex items-start gap-3">
|
||||
@ -34,8 +150,15 @@ export function ErrorState({ message, action, className = '' }: ErrorStateProps)
|
||||
<ErrorIcon />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-red-400 font-medium mb-2">{message}</p>
|
||||
{action && <div className="mt-3">{action}</div>}
|
||||
<p className="text-sm text-red-400 font-medium mb-2">{displayMessage}</p>
|
||||
{suggestion !== undefined && <p className="text-xs text-red-400/70 mb-3">{suggestion}</p>}
|
||||
<ErrorActions
|
||||
action={action}
|
||||
classification={classification}
|
||||
{...(onRetry !== undefined ? { onRetry } : {})}
|
||||
{...(onCheckConnection !== undefined ? { onCheckConnection } : {})}
|
||||
showDocumentationLink={showDocumentationLink}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useMemo } from 'react'
|
||||
import React, { useMemo } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
@ -90,38 +90,34 @@ function InputHelper({ inputId, helperText }: { inputId: string; helperText: str
|
||||
)
|
||||
}
|
||||
|
||||
export function Input({
|
||||
label,
|
||||
error,
|
||||
helperText,
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
className = '',
|
||||
id,
|
||||
...props
|
||||
}: InputProps): React.ReactElement {
|
||||
const inputId = useMemo(() => generateId('input', id), [id])
|
||||
const inputClasses = useMemo(
|
||||
() => getInputClasses({ error, leftIcon, rightIcon, className }),
|
||||
[error, leftIcon, rightIcon, className]
|
||||
)
|
||||
const ariaDescribedBy = useMemo(() => getAriaDescribedBy(inputId, error, helperText), [inputId, error, helperText])
|
||||
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ label, error, helperText, leftIcon, rightIcon, className = '', id, ...props }, ref): React.ReactElement => {
|
||||
const inputId = useMemo(() => generateId('input', id), [id])
|
||||
const inputClasses = useMemo(
|
||||
() => getInputClasses({ error, leftIcon, rightIcon, className }),
|
||||
[error, leftIcon, rightIcon, className]
|
||||
)
|
||||
const ariaDescribedBy = useMemo(() => getAriaDescribedBy(inputId, error, helperText), [inputId, error, helperText])
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && <InputLabel inputId={inputId} label={label} />}
|
||||
<div className="relative">
|
||||
<InputIcons leftIcon={leftIcon} rightIcon={rightIcon} />
|
||||
<input
|
||||
id={inputId}
|
||||
className={inputClasses}
|
||||
aria-invalid={error ? 'true' : 'false'}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
{...props}
|
||||
/>
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && <InputLabel inputId={inputId} label={label} />}
|
||||
<div className="relative">
|
||||
<InputIcons leftIcon={leftIcon} rightIcon={rightIcon} />
|
||||
<input
|
||||
ref={ref}
|
||||
id={inputId}
|
||||
className={inputClasses}
|
||||
aria-invalid={error ? 'true' : 'false'}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
{error && <InputError inputId={inputId} error={error} />}
|
||||
{helperText && !error && <InputHelper inputId={inputId} helperText={helperText} />}
|
||||
</div>
|
||||
{error && <InputError inputId={inputId} error={error} />}
|
||||
{helperText && !error && <InputHelper inputId={inputId} helperText={helperText} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Input.displayName = 'Input'
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useEffect } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { Button } from './Button'
|
||||
import { t } from '@/lib/i18n'
|
||||
|
||||
export type ToastVariant = 'info' | 'success' | 'warning' | 'error'
|
||||
|
||||
@ -61,7 +62,7 @@ export function Toast({
|
||||
variant="ghost"
|
||||
onClick={onClose}
|
||||
className="ml-4 text-current hover:opacity-70 p-0"
|
||||
aria-label="Close notification"
|
||||
aria-label={t('common.close')}
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
|
||||
@ -45,7 +45,12 @@ export function ToastProvider({ children }: { children: React.ReactNode }): Reac
|
||||
return (
|
||||
<ToastContext.Provider value={contextValue}>
|
||||
{children}
|
||||
<div className="fixed top-4 right-4 z-50 space-y-2 pointer-events-none">
|
||||
<div
|
||||
className="fixed top-4 right-4 z-50 space-y-2 pointer-events-none"
|
||||
role="region"
|
||||
aria-live="polite"
|
||||
aria-label="Notifications"
|
||||
>
|
||||
{toasts.map((toast) => (
|
||||
<div key={toast.id} className="pointer-events-auto">
|
||||
<Toast
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
**Date** : 2025-01-27
|
||||
**Auteur** : Équipe 4NK
|
||||
**Dernière mise à jour** : 2025-01-27 (Amélioration modal paiement + Recherche avec suggestions + Filtres persistants + Navigation clavier complète + ARIA amélioré + Messages d'erreur actionnables)
|
||||
|
||||
## Composants UI réutilisables créés
|
||||
|
||||
@ -125,6 +126,80 @@ Aucun composant prioritaire restant. Tous les composants principaux ont été mi
|
||||
- ✅ `hooks/useArticlePayment.ts` - Ajout du support showToast pour afficher les toasts après paiement
|
||||
- ✅ `locales/fr.txt` et `locales/en.txt` - Ajout des clés de traduction pour les toasts (`article.unlock.success`, `article.publish.success`, `payment.modal.copySuccess`, `payment.modal.paymentInitiated`)
|
||||
|
||||
### Indicateur visuel pour contenu débloqué (Priorité haute #3)
|
||||
- ✅ `components/ArticleCard.tsx` - Ajout d'un badge "Débloqué" avec icône de cadenas ouvert dans le header de l'article
|
||||
- ✅ `components/ArticleCard.tsx` - Ajout d'une bordure distinctive (border-neon-green/40) et d'un glow vert (shadow-glow-green) sur les cartes d'articles débloqués
|
||||
- ✅ `locales/fr.txt` et `locales/en.txt` - Ajout de la clé de traduction `article.unlocked.badge`
|
||||
|
||||
### Raccourcis clavier de base (Priorité haute #4)
|
||||
- ✅ `hooks/useKeyboardShortcuts.ts` (nouveau) - Création du hook pour gérer les raccourcis clavier globaux
|
||||
- ✅ `pages/_app.tsx` - Intégration du hook `useKeyboardShortcuts` dans l'application
|
||||
- ✅ `components/ui/Input.tsx` - Ajout du support `forwardRef` pour permettre le focus programmatique
|
||||
- ✅ `components/SearchBar.tsx` - Ajout du support `forwardRef` et `role="search"` pour l'accessibilité
|
||||
- ✅ `components/NotificationPanel.tsx` - Ajout de la gestion de `Esc` pour fermer le panel
|
||||
- ✅ `components/createSeriesModal/CreateSeriesModalView.tsx` - Ajout de la gestion de `Esc` pour fermer la modal
|
||||
- ✅ Raccourci `/` : Focus automatique sur la barre de recherche (détection via `role="search"`)
|
||||
- ✅ Raccourci `Esc` : Déjà implémenté pour les modals via `useModalKeyboard` dans `components/ui/Modal.tsx`, pour `MobileMenu` via `useEffect`, et ajouté pour `NotificationPanel` et `CreateSeriesModalView`
|
||||
|
||||
### Amélioration de la modal de paiement (Priorité haute #5)
|
||||
- ✅ `components/PaymentModal.tsx` - Refactorisation avec extraction de composants dans `components/paymentModal/PaymentModalComponents.tsx`
|
||||
- ✅ `lib/paymentModalHelpers.ts` (nouveau) - Extraction des fonctions utilitaires de paiement
|
||||
- ✅ `components/paymentModal/PaymentModalComponents.tsx` (nouveau) - Composants de présentation extraits
|
||||
- ✅ Bouton "Payer avec Alby" en priorité (variant "success", taille "large", affiché en premier si Alby détecté)
|
||||
- ✅ Auto-détection d'Alby avec hook `useAlbyDetection()` vérifiant périodiquement la disponibilité
|
||||
- ✅ Instructions étape par étape affichées conditionnellement selon la présence d'Alby
|
||||
- ✅ QR code amélioré (taille 300px, fond blanc, bordure cyan avec glow `shadow-[0_0_20px_rgba(0,255,255,0.3)]`)
|
||||
- ✅ Compte à rebours plus visible (Badge avec animation pulse si < 60s, variant "error" si urgent)
|
||||
- ✅ `locales/fr.txt` et `locales/en.txt` - Ajout des clés de traduction pour les instructions
|
||||
|
||||
### Recherche améliorée avec suggestions (Priorité moyenne #6)
|
||||
- ✅ `components/SearchSuggestions.tsx` (nouveau) - Composant de suggestions avec autocomplétion
|
||||
- ✅ `components/SearchBar.tsx` - Intégration du composant de suggestions avec gestion du focus
|
||||
- ✅ `lib/searchHistory.ts` (nouveau) - Service de gestion de l'historique de recherche dans IndexedDB
|
||||
- ✅ Autocomplétion basée sur les titres d'articles (5 suggestions max, recherche insensible à la casse)
|
||||
- ✅ Suggestions d'auteurs (3 suggestions max, basées sur le nom extrait du titre)
|
||||
- ✅ Historique de recherche récente (5 dernières recherches, stocké dans IndexedDB, affiché quand la recherche est vide)
|
||||
- ✅ `locales/fr.txt` et `locales/en.txt` - Ajout des clés de traduction pour les types de suggestions
|
||||
|
||||
### Filtres persistants (Priorité moyenne #7)
|
||||
- ✅ `lib/filterPreferences.ts` (nouveau) - Service de gestion de la persistance des filtres dans IndexedDB
|
||||
- ✅ `pages/index.tsx` - Hook `useFilterPersistence()` pour charger et sauvegarder automatiquement les filtres
|
||||
- ✅ Chargement automatique des filtres sauvegardés au démarrage de la page
|
||||
- ✅ Sauvegarde automatique des filtres à chaque modification
|
||||
- ✅ Utilisation de la même base de données IndexedDB (`nostr_paywall_settings`) avec un store dédié (`filter_preferences`)
|
||||
|
||||
### Navigation clavier complète (Priorité moyenne #8)
|
||||
- ✅ `components/SkipLinks.tsx` (nouveau) - Composant de skip links pour navigation rapide
|
||||
- ✅ `hooks/useArrowNavigation.ts` (nouveau) - Hook pour navigation par flèches dans les listes (ArrowUp/Down/Home/End)
|
||||
- ✅ `components/ArticlesList.tsx` - Intégration de la navigation par flèches et ajout de `role="list"` et `role="listitem"`
|
||||
- ✅ `components/AuthorsList.tsx` - Intégration de la navigation par flèches et ajout de `role="list"` et `role="listitem"`
|
||||
- ✅ `components/HomeView.tsx` - Ajout des IDs pour les skip links (`main-content`, `filters-section`, `articles-section`)
|
||||
- ✅ Tab order logique avec `tabIndex={-1}` sur les sections pour permettre le focus programmatique
|
||||
- ✅ `locales/fr.txt` et `locales/en.txt` - Ajout des traductions pour les skip links et sections
|
||||
|
||||
### ARIA amélioré (Priorité moyenne #9)
|
||||
- ✅ `components/PageHeader.tsx` - Ajout de `role="navigation"` et `aria-label` pour tous les liens iconiques (DocsIcon, FundingIcon, GitIcon)
|
||||
- ✅ `components/HomeView.tsx` - Ajout de `role="main"` au contenu principal
|
||||
- ✅ `components/HomeView.tsx` - Ajout de `role="complementary"` aux filtres (section changée en `<aside>`)
|
||||
- ✅ `components/Footer.tsx` - Ajout de `role="contentinfo"`
|
||||
- ✅ `components/CategoryTabs.tsx` - Ajout de `role="tablist"` et `aria-label`, `role="tab"` et `aria-selected` pour les onglets
|
||||
- ✅ `components/ui/ToastContainer.tsx` - Ajout de `role="region"`, `aria-live="polite"` et `aria-label="Notifications"`
|
||||
- ✅ `components/ui/Toast.tsx` - Amélioration avec `aria-label` traduit pour le bouton de fermeture
|
||||
- ✅ `components/AuthorFilterButton.tsx` - Ajout de `aria-label` dynamique selon l'état (sélectionné ou non)
|
||||
- ✅ `components/NotificationBadgeButton.tsx` - Amélioration de `aria-label` avec traductions (singulier/pluriel)
|
||||
- ✅ `components/KeyIndicator.tsx` - Ajout de `aria-label` pour le lien iconique
|
||||
- ✅ `components/ClearButton.tsx` - Déjà présent avec `aria-label`
|
||||
- ✅ `locales/fr.txt` et `locales/en.txt` - Ajout des traductions pour les notifications, filtres, et navigation
|
||||
|
||||
### Messages d'erreur actionnables (Priorité moyenne #10)
|
||||
- ✅ `lib/errorClassification.ts` (nouveau) - Système de classification des erreurs (réseau, paiement, validation, chargement, inconnu)
|
||||
- ✅ `components/ui/ErrorState.tsx` - Amélioration avec support des actions de récupération (`onRetry`, `onCheckConnection`, `showDocumentationLink`)
|
||||
- ✅ `components/ArticlesList.tsx` - Intégration des messages d'erreur actionnables avec boutons de récupération
|
||||
- ✅ `components/AuthorsList.tsx` - Intégration des messages d'erreur actionnables avec boutons de récupération
|
||||
- ✅ `components/paymentModal/PaymentModalComponents.tsx` - Amélioration de `PaymentError` avec actions de récupération
|
||||
- ✅ `components/PaymentModal.tsx` - Passage de `onRetry` à `PaymentError`
|
||||
- ✅ `locales/fr.txt` et `locales/en.txt` - Ajout des traductions pour les messages d'erreur et actions (suggestions, boutons)
|
||||
|
||||
## Erreurs corrigées
|
||||
|
||||
### TypeScript
|
||||
@ -133,6 +208,17 @@ Aucun composant prioritaire restant. Tous les composants principaux ont été mi
|
||||
|
||||
### Linting
|
||||
- ✅ Toutes les erreurs de linting ont été corrigées
|
||||
- ✅ `react/no-array-index-key` warnings corrigés dans :
|
||||
- `components/ArticlesList.tsx` - Clés uniques pour les skeletons (`article-skeleton-${index}`)
|
||||
- `components/AuthorsList.tsx` - Clés uniques pour les skeletons (`author-skeleton-${index}`)
|
||||
- `components/UserArticlesList.tsx` - Clés uniques pour les skeletons (`user-article-skeleton-${index}`)
|
||||
- `components/authorPage/AuthorPageContent.tsx` - Clés uniques pour les skeletons (`author-page-skeleton-${index}`)
|
||||
- `components/SearchSuggestions.tsx` - Clés uniques pour les suggestions (`${type}-${query}-${timestamp ?? index}`)
|
||||
|
||||
### Réduction de la complexité
|
||||
- ✅ `components/PaymentModal.tsx` - Extraction de composants vers `components/paymentModal/PaymentModalComponents.tsx` et fonctions utilitaires vers `lib/paymentModalHelpers.ts` pour respecter `max-lines` (250) et `max-lines-per-function` (40)
|
||||
- ✅ `components/SearchBar.tsx` - Extraction de hooks personnalisés (`useSearchBarLocalValue`, `useSearchBarFocusState`, `useSearchBarHandlers`, `useClickOutsideHandler`) pour réduire la complexité
|
||||
- ✅ `components/SearchSuggestions.tsx` - Extraction de fonctions utilitaires (`loadHistorySuggestions`, `loadSuggestions`, `getSuggestionIcon`, `getSuggestionAriaLabel`) et hooks personnalisés (`useSuggestionsLoader`, `useClickOutsideHandler`) pour réduire la complexité
|
||||
|
||||
## État actuel
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
**Date** : 2025-01-27
|
||||
**Auteur** : Équipe 4NK
|
||||
**Dernière mise à jour** : 2025-01-27
|
||||
**Dernière mise à jour** : 2025-01-27 (Amélioration modal paiement + Recherche avec suggestions + Filtres persistants + Navigation clavier complète + ARIA amélioré + Messages d'erreur actionnables)
|
||||
|
||||
## Migration des composants UI
|
||||
|
||||
@ -47,48 +47,53 @@ Voir `features/ux-improvements.md` pour la liste complète des améliorations UX
|
||||
- Toasts avec variants (success, info, warning, error), durée configurable, fermeture automatique et manuelle
|
||||
- Accessibilité : `role="alert"` et `aria-live="polite"` pour les screen readers
|
||||
|
||||
3. **Indicateur visuel pour contenu débloqué**
|
||||
- Badge "Débloqué" sur les articles
|
||||
- Icône de cadenas ouvert
|
||||
- Couleur différente ou bordure distinctive
|
||||
3. ✅ **Indicateur visuel pour contenu débloqué** - Implémenté
|
||||
- Badge "Débloqué" avec icône de cadenas ouvert dans le header de l'article (`ArticleCard.tsx`)
|
||||
- Bordure distinctive (border-neon-green/40) et glow vert (shadow-glow-green) sur les cartes d'articles débloqués
|
||||
- Accessibilité : `aria-label` sur le badge pour les screen readers
|
||||
|
||||
4. **Raccourcis clavier de base**
|
||||
- `/` : Focus automatique sur la barre de recherche
|
||||
- `Esc` : Fermer les modals et overlays
|
||||
- `Ctrl/Cmd + K` : Ouvrir une recherche rapide (command palette)
|
||||
4. ✅ **Raccourcis clavier de base** - Implémenté
|
||||
- `/` : Focus automatique sur la barre de recherche (détection via `role="search"`)
|
||||
- `Esc` : Fermeture des modals et overlays :
|
||||
- Déjà implémenté pour les modals (`Modal.tsx`) et le menu mobile (`MobileMenu.tsx`)
|
||||
- Ajouté pour `NotificationPanel` et `CreateSeriesModalView`
|
||||
- `Ctrl/Cmd + K` : Ouvrir une recherche rapide (command palette) - À implémenter
|
||||
|
||||
5. **Amélioration de la modal de paiement**
|
||||
- Bouton "Payer avec Alby" en priorité (plus grand et plus visible)
|
||||
- Auto-détection d'Alby
|
||||
- Instructions étape par étape
|
||||
- QR code amélioré (plus grand, meilleur contraste)
|
||||
- Compte à rebours plus visible
|
||||
5. ✅ **Amélioration de la modal de paiement** - Implémenté
|
||||
- Bouton "Payer avec Alby" en priorité (variant "success", taille "large", plus visible)
|
||||
- Auto-détection d'Alby avec vérification périodique
|
||||
- Instructions étape par étape affichées conditionnellement selon la présence d'Alby
|
||||
- QR code amélioré (taille 300px, fond blanc, bordure cyan avec glow)
|
||||
- Compte à rebours plus visible (Badge avec animation pulse si < 60s)
|
||||
|
||||
### Priorité moyenne
|
||||
|
||||
6. **Recherche améliorée avec suggestions**
|
||||
- Autocomplétion basée sur les titres d'articles
|
||||
- Suggestions d'auteurs
|
||||
- Historique de recherche récente
|
||||
6. ✅ **Recherche améliorée avec suggestions** - Implémenté
|
||||
- Autocomplétion basée sur les titres d'articles (5 suggestions max)
|
||||
- Suggestions d'auteurs (3 suggestions max)
|
||||
- Historique de recherche récente (5 dernières recherches, stocké dans IndexedDB)
|
||||
|
||||
7. **Filtres persistants**
|
||||
- Sauvegarder les préférences de filtres dans IndexedDB
|
||||
- Restaurer les filtres au retour sur la page
|
||||
7. ✅ **Filtres persistants** - Implémenté
|
||||
- Sauvegarder les préférences de filtres dans IndexedDB (service `lib/filterPreferences.ts`)
|
||||
- Restaurer les filtres au retour sur la page (chargement automatique dans `useHomeState()`)
|
||||
- Sauvegarde automatique à chaque modification des filtres
|
||||
|
||||
8. **Navigation clavier complète**
|
||||
- Tab order logique
|
||||
- Navigation par flèches dans les listes
|
||||
- Skip links pour navigation rapide
|
||||
8. ✅ **Navigation clavier complète** - Implémenté
|
||||
- Tab order logique (IDs ajoutés pour les sections principales)
|
||||
- Navigation par flèches dans les listes (hook `useArrowNavigation` avec support ArrowUp/Down/Home/End)
|
||||
- Skip links pour navigation rapide (composant `SkipLinks` avec liens vers main-content, filters-section, articles-section)
|
||||
|
||||
9. **ARIA amélioré**
|
||||
- Labels ARIA pour tous les boutons iconiques
|
||||
- Régions ARIA (`role="navigation"`, `role="main"`, `role="search"`)
|
||||
- Annonces screen reader (`aria-live`)
|
||||
9. ✅ **ARIA amélioré** - Implémenté
|
||||
- Labels ARIA pour tous les boutons iconiques (liens dans PageHeader, KeyIndicator, NotificationBadgeButton, AuthorFilterButton, ClearButton, Toast close button)
|
||||
- Régions ARIA (`role="navigation"` pour le header, `role="main"` pour le contenu principal, `role="complementary"` pour les filtres, `role="contentinfo"` pour le footer, `role="tablist"` pour CategoryTabs)
|
||||
- Annonces screen reader (`aria-live="polite"` dans ToastContainer et Toast, `role="alert"` pour les toasts)
|
||||
|
||||
10. **Messages d'erreur actionnables**
|
||||
- Messages clairs avec explication
|
||||
- Actions de récupération (bouton "Réessayer")
|
||||
- Suggestions de solutions
|
||||
10. ✅ **Messages d'erreur actionnables** - Implémenté
|
||||
- Messages clairs avec explication (système de classification des erreurs dans `lib/errorClassification.ts`)
|
||||
- Actions de récupération (bouton "Réessayer", "Vérifier la connexion", "Voir la documentation")
|
||||
- Suggestions de solutions contextuelles selon le type d'erreur (réseau, paiement, validation, chargement)
|
||||
- Composant `ErrorState` amélioré avec support des actions de récupération
|
||||
- Intégration dans `ArticlesList`, `AuthorsList`, et `PaymentModal`
|
||||
|
||||
### Priorité basse
|
||||
|
||||
|
||||
@ -1,13 +1,15 @@
|
||||
# Skeleton Loaders et Toast Notifications
|
||||
# Skeleton Loaders, Toast Notifications, Indicateur Visuel et Raccourcis Clavier
|
||||
|
||||
**Auteur** : Équipe 4NK
|
||||
**Date** : 2025-01-27
|
||||
|
||||
## Objectif
|
||||
|
||||
Implémenter les deux premières améliorations UX de priorité haute :
|
||||
Implémenter les quatre premières améliorations UX de priorité haute :
|
||||
1. Skeleton loaders - Remplacer les messages "Loading..." par des skeleton loaders
|
||||
2. Toast notifications - Intégrer le système de toast pour les confirmations visuelles
|
||||
3. Indicateur visuel pour contenu débloqué - Badge et bordure distinctive pour les articles débloqués
|
||||
4. Raccourcis clavier de base - `/` pour focus sur recherche, `Esc` pour fermer modals/overlays
|
||||
|
||||
## Impacts
|
||||
|
||||
@ -23,6 +25,18 @@ Implémenter les deux premières améliorations UX de priorité haute :
|
||||
- **Accessibilité** : Toasts avec `role="alert"` et `aria-live="polite"` pour les screen readers
|
||||
- **Expérience utilisateur** : Retour visuel clair et non intrusif pour les actions réussies
|
||||
|
||||
### Indicateur visuel pour contenu débloqué
|
||||
|
||||
- **Clarté visuelle** : Identification immédiate des articles déjà débloqués
|
||||
- **Feedback positif** : Confirmation visuelle que l'utilisateur a accès au contenu complet
|
||||
- **Accessibilité** : Badge avec `aria-label` pour les screen readers
|
||||
|
||||
### Raccourcis clavier de base
|
||||
|
||||
- **Efficacité** : Réduction du nombre de clics pour accéder à la recherche
|
||||
- **Accessibilité** : Navigation au clavier améliorée
|
||||
- **Expérience utilisateur** : Raccourcis intuitifs et standards
|
||||
|
||||
## Modifications
|
||||
|
||||
### Skeleton loaders
|
||||
@ -93,6 +107,52 @@ Implémenter les deux premières améliorations UX de priorité haute :
|
||||
- `payment.modal.paymentInitiated` : "Paiement initié avec succès" / "Payment initiated successfully"
|
||||
- Ajout des clés de traduction manquantes pour `payment.modal.*` déjà utilisées dans le code
|
||||
|
||||
### Indicateur visuel pour contenu débloqué
|
||||
|
||||
1. **`components/ArticleCard.tsx`** :
|
||||
- Ajout d'un badge "Débloqué" avec icône SVG de cadenas ouvert dans `ArticleHeader`
|
||||
- Badge affiché uniquement si `article.paid === true`
|
||||
- Badge avec variant "success" (vert) et `aria-label` pour l'accessibilité
|
||||
- Ajout d'une bordure distinctive (border-2 border-neon-green/40) et d'un glow vert (shadow-[0_0_5px_#00ff41,0_0_10px_#00ff41]) sur la Card pour les articles débloqués
|
||||
- Utilisation de `shadow-[...]` avec les valeurs de `tailwind.config.js` (glow-green)
|
||||
|
||||
2. **`locales/fr.txt` et `locales/en.txt`** :
|
||||
- Ajout de la clé de traduction `article.unlocked.badge` : "Débloqué" / "Unlocked"
|
||||
|
||||
### Raccourcis clavier de base
|
||||
|
||||
1. **`hooks/useKeyboardShortcuts.ts`** (nouveau) :
|
||||
- Création du hook pour gérer les raccourcis clavier globaux
|
||||
- Détection de la touche `/` pour focus sur la barre de recherche
|
||||
- Vérification que l'utilisateur n'est pas déjà dans un champ de saisie avant d'activer le raccourci
|
||||
- Recherche de l'input avec `role="search"` et focus/select automatique
|
||||
|
||||
2. **`components/ui/Input.tsx`** :
|
||||
- Ajout du support `forwardRef` pour permettre le focus programmatique
|
||||
- Conservation de toutes les fonctionnalités existantes
|
||||
|
||||
3. **`components/SearchBar.tsx`** :
|
||||
- Ajout du support `forwardRef` pour permettre le focus programmatique
|
||||
- Ajout de `role="search"` pour l'accessibilité et la détection par le hook
|
||||
- Ajout de `aria-label` pour l'accessibilité
|
||||
|
||||
4. **`pages/_app.tsx`** :
|
||||
- Intégration du hook `useKeyboardShortcuts` dans l'application pour activer les raccourcis globaux
|
||||
|
||||
6. **`components/NotificationPanel.tsx`** :
|
||||
- Ajout de la gestion de `Esc` pour fermer le panel de notifications
|
||||
- Ajout de `useEffect` pour écouter les événements clavier
|
||||
|
||||
7. **`components/createSeriesModal/CreateSeriesModalView.tsx`** :
|
||||
- Ajout de la gestion de `Esc` pour fermer la modal de création de série
|
||||
- Ajout de la gestion du clic sur l'overlay pour fermer la modal
|
||||
- Ajout de `document.body.style.overflow = 'hidden'` pour empêcher le scroll pendant l'ouverture
|
||||
|
||||
8. **Raccourci `Esc`** :
|
||||
- Déjà implémenté pour les modals via `useModalKeyboard` dans `components/ui/Modal.tsx`
|
||||
- Déjà implémenté pour le menu mobile via `useEffect` dans `components/ui/MobileMenu.tsx`
|
||||
- Ajouté pour `NotificationPanel` et `CreateSeriesModalView`
|
||||
|
||||
## Modalités de déploiement
|
||||
|
||||
1. **Vérification** :
|
||||
@ -120,6 +180,22 @@ Implémenter les deux premières améliorations UX de priorité haute :
|
||||
- Vérifier que les toasts peuvent être fermés manuellement via le bouton ×
|
||||
- Vérifier l'accessibilité avec un screen reader (toasts annoncés via `aria-live="polite"`)
|
||||
|
||||
3. **Indicateur visuel pour contenu débloqué** :
|
||||
- Vérifier que le badge "Débloqué" apparaît dans le header des articles débloqués
|
||||
- Vérifier que l'icône de cadenas ouvert est visible dans le badge
|
||||
- Vérifier que les cartes d'articles débloqués ont une bordure verte distinctive et un glow vert
|
||||
- Vérifier que les articles non débloqués n'ont pas de badge ni de bordure distinctive
|
||||
- Vérifier l'accessibilité avec un screen reader (badge annoncé via `aria-label`)
|
||||
|
||||
4. **Raccourcis clavier de base** :
|
||||
- Vérifier que la touche `/` focus la barre de recherche et sélectionne son contenu
|
||||
- Vérifier que le raccourci `/` ne s'active pas si l'utilisateur est déjà dans un champ de saisie (input, textarea, contenteditable)
|
||||
- Vérifier que la touche `Esc` ferme les modals (`Modal.tsx`)
|
||||
- Vérifier que la touche `Esc` ferme le menu mobile (`MobileMenu.tsx`)
|
||||
- Vérifier que la touche `Esc` ferme le panel de notifications (`NotificationPanel.tsx`)
|
||||
- Vérifier que la touche `Esc` ferme la modal de création de série (`CreateSeriesModalView.tsx`)
|
||||
- Tester sur différentes pages (HomeView, ProfileView) pour s'assurer que le raccourci `/` fonctionne partout
|
||||
|
||||
## Pages affectées
|
||||
|
||||
- `components/ArticlesList.tsx`
|
||||
@ -138,4 +214,8 @@ Implémenter les deux premières améliorations UX de priorité haute :
|
||||
- `locales/fr.txt`
|
||||
- `locales/en.txt`
|
||||
- `components/ui/index.ts`
|
||||
- `hooks/useKeyboardShortcuts.ts` (nouveau)
|
||||
- `components/NotificationPanel.tsx`
|
||||
- `components/createSeriesModal/CreateSeriesModalView.tsx`
|
||||
- `docs/migration-status.md`
|
||||
- `docs/todo-remaining.md`
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
**Auteur** : Équipe 4NK
|
||||
**Date** : 2025-01-27
|
||||
**Dernière mise à jour** : 2025-01-27 (Amélioration modal paiement + Recherche avec suggestions + Filtres persistants + Navigation clavier complète + ARIA amélioré + Messages d'erreur actionnables)
|
||||
|
||||
## Vue d'ensemble
|
||||
|
||||
@ -56,55 +57,56 @@ Ce document liste les améliorations UX proposées pour la plateforme zapwall.fr
|
||||
|
||||
## 3. Recherche et filtres
|
||||
|
||||
### Recherche améliorée
|
||||
- **Recherche en temps réel** avec suggestions :
|
||||
- Autocomplétion basée sur les titres d'articles
|
||||
- Suggestions d'auteurs
|
||||
- Historique de recherche récente
|
||||
- **Filtres combinables visibles** :
|
||||
### Recherche améliorée ✅ Implémenté
|
||||
- ✅ **Recherche en temps réel** avec suggestions :
|
||||
- Autocomplétion basée sur les titres d'articles (5 suggestions max)
|
||||
- Suggestions d'auteurs (3 suggestions max)
|
||||
- Historique de recherche récente (5 dernières recherches, affiché quand la recherche est vide)
|
||||
- ⏳ **Filtres combinables visibles** :
|
||||
- Badges actifs montrant les filtres appliqués
|
||||
- Compteur de résultats ("X articles trouvés")
|
||||
- Indication claire quand aucun résultat
|
||||
- **Historique de recherche** :
|
||||
- Sauvegarder les recherches récentes dans IndexedDB
|
||||
- Afficher les recherches populaires
|
||||
- ✅ **Historique de recherche** :
|
||||
- Sauvegarder les recherches récentes dans IndexedDB (service `lib/searchHistory.ts`)
|
||||
- Affichage automatique de l'historique quand la recherche est vide
|
||||
- ⏳ Afficher les recherches populaires (à implémenter)
|
||||
|
||||
### Filtres persistants
|
||||
- Sauvegarder les préférences de filtres dans IndexedDB
|
||||
- Restaurer les filtres au retour sur la page
|
||||
- Options de filtres avancés (date, catégorie, auteur combinés)
|
||||
### Filtres persistants ✅ Implémenté
|
||||
- ✅ Sauvegarder les préférences de filtres dans IndexedDB (service `lib/filterPreferences.ts`)
|
||||
- ✅ Restaurer les filtres au retour sur la page (chargement automatique dans `useHomeState()`)
|
||||
- ✅ Sauvegarde automatique à chaque modification des filtres
|
||||
- ⏳ Options de filtres avancés (date, catégorie, auteur combinés) - À implémenter
|
||||
|
||||
## 4. Modal de paiement
|
||||
## 4. Modal de paiement ✅ Implémenté
|
||||
|
||||
### Simplification du flux
|
||||
- **Bouton "Payer avec Alby" en priorité** :
|
||||
- Plus grand et plus visible
|
||||
- ✅ **Bouton "Payer avec Alby" en priorité** :
|
||||
- Plus grand et plus visible (variant "success", taille "large")
|
||||
- Couleur distinctive (neon-green)
|
||||
- Positionné en haut de la modal
|
||||
- **Auto-détection d'Alby** :
|
||||
- Détecter si Alby est disponible
|
||||
- Ouvrir automatiquement Alby si disponible
|
||||
- Afficher un message si Alby n'est pas installé
|
||||
- **Instructions étape par étape** :
|
||||
- Guide visuel pour nouveaux utilisateurs
|
||||
- Étapes numérotées claires
|
||||
- Lien vers installation d'Alby si nécessaire
|
||||
- **Option "Se souvenir de ce choix"** :
|
||||
- Mémoriser la méthode de paiement préférée
|
||||
- Pré-sélectionner la méthode au prochain paiement
|
||||
- Positionné en premier si Alby détecté
|
||||
- ✅ **Auto-détection d'Alby** :
|
||||
- Détecter si Alby est disponible (hook `useAlbyDetection()` avec vérification périodique)
|
||||
- Affichage conditionnel du bouton selon la détection
|
||||
- Message d'installation affiché si Alby n'est pas installé
|
||||
- ✅ **Instructions étape par étape** :
|
||||
- Guide visuel pour nouveaux utilisateurs (affiché conditionnellement)
|
||||
- Étapes numérotées claires (différentes selon présence d'Alby)
|
||||
- Instructions traduites (fr/en)
|
||||
- ⏳ **Option "Se souvenir de ce choix"** :
|
||||
- À implémenter (mémorisation de la méthode de paiement préférée)
|
||||
|
||||
### Amélioration visuelle
|
||||
- **QR code amélioré** :
|
||||
- Plus grand (minimum 300x300px)
|
||||
- Meilleur contraste
|
||||
- Bordure distinctive
|
||||
- **Copie d'invoice** :
|
||||
- Bouton de copie plus visible
|
||||
- ✅ **QR code amélioré** :
|
||||
- Plus grand (300x300px)
|
||||
- Meilleur contraste (fond blanc, bordure cyan avec glow)
|
||||
- Bordure distinctive (`border-4 border-neon-cyan/50 shadow-[0_0_20px_rgba(0,255,255,0.3)]`)
|
||||
- ✅ **Copie d'invoice** :
|
||||
- Bouton de copie visible (variant "secondary")
|
||||
- Confirmation visuelle immédiate (toast)
|
||||
- Copie en un clic
|
||||
- **Compte à rebours** :
|
||||
- Plus visible (barre de progression circulaire)
|
||||
- Alerte visuelle quand < 60 secondes
|
||||
- ✅ **Compte à rebours** :
|
||||
- Plus visible (Badge avec format MM:SS)
|
||||
- Alerte visuelle quand < 60 secondes (animation pulse, variant "error")
|
||||
- Message d'expiration clair
|
||||
|
||||
## 5. Accessibilité clavier
|
||||
@ -123,36 +125,40 @@ Ce document liste les améliorations UX proposées pour la plateforme zapwall.fr
|
||||
- Modal d'aide avec tous les raccourcis
|
||||
- Indication des raccourcis dans les tooltips
|
||||
|
||||
### ARIA amélioré
|
||||
- **Labels ARIA** :
|
||||
- Tous les boutons iconiques ont des labels
|
||||
- Images avec alt text descriptif
|
||||
- Formulaires avec labels associés
|
||||
- **Régions ARIA** :
|
||||
### ARIA amélioré ✅ Implémenté
|
||||
- ✅ **Labels ARIA** :
|
||||
- Tous les boutons iconiques ont des labels (liens dans PageHeader, KeyIndicator, NotificationBadgeButton, AuthorFilterButton, ClearButton, Toast close button)
|
||||
- Images avec alt text descriptif (déjà présent dans la plupart des composants)
|
||||
- Formulaires avec labels associés (déjà présent)
|
||||
- ✅ **Régions ARIA** :
|
||||
- `role="navigation"` pour le header
|
||||
- `role="main"` pour le contenu principal
|
||||
- `role="search"` pour la barre de recherche
|
||||
- `role="complementary"` pour les filtres
|
||||
- **Annonces screen reader** :
|
||||
- `aria-live` pour changements d'état (paiement réussi, erreurs)
|
||||
- Messages d'erreur annoncés
|
||||
- Confirmations d'actions annoncées
|
||||
- `role="search"` pour la barre de recherche (déjà présent)
|
||||
- `role="complementary"` pour les filtres (section changée en `<aside>`)
|
||||
- `role="contentinfo"` pour le footer
|
||||
- `role="tablist"` et `role="tab"` pour CategoryTabs
|
||||
- ✅ **Annonces screen reader** :
|
||||
- `aria-live="polite"` dans ToastContainer (région) et Toast (éléments individuels)
|
||||
- `role="alert"` pour les toasts
|
||||
- Messages d'erreur annoncés via les toasts
|
||||
- Confirmations d'actions annoncées via les toasts
|
||||
|
||||
## 6. Gestion d'erreurs
|
||||
|
||||
### Messages d'erreur actionnables
|
||||
- **Messages clairs** :
|
||||
- Langage simple et compréhensible
|
||||
- Explication de l'erreur
|
||||
- Impact sur l'utilisateur
|
||||
- **Actions de récupération** :
|
||||
- Bouton "Réessayer" pour erreurs réseau
|
||||
- Bouton "Vérifier la connexion" si applicable
|
||||
- Lien vers documentation si erreur complexe
|
||||
- **Suggestions de solutions** :
|
||||
- "Vérifiez votre connexion Alby"
|
||||
- "Assurez-vous d'avoir des fonds suffisants"
|
||||
- "Vérifiez votre connexion Internet"
|
||||
### Messages d'erreur actionnables ✅ Implémenté
|
||||
- ✅ **Messages clairs** :
|
||||
- Langage simple et compréhensible (système de classification dans `lib/errorClassification.ts`)
|
||||
- Explication de l'erreur (message d'erreur original préservé)
|
||||
- Impact sur l'utilisateur (suggestions contextuelles selon le type d'erreur)
|
||||
- ✅ **Actions de récupération** :
|
||||
- Bouton "Réessayer" pour erreurs réseau, paiement, chargement (affiché automatiquement selon la classification)
|
||||
- Bouton "Vérifier la connexion" pour erreurs réseau et chargement (affiché automatiquement)
|
||||
- Lien vers documentation pour erreurs de paiement (affiché automatiquement)
|
||||
- ✅ **Suggestions de solutions** :
|
||||
- "Vérifiez votre connexion Internet et réessayez" (erreurs réseau)
|
||||
- "Vérifiez votre connexion Alby et assurez-vous d'avoir des fonds suffisants" (erreurs paiement)
|
||||
- "Vérifiez que tous les champs requis sont remplis correctement" (erreurs validation)
|
||||
- "Impossible de charger les données. Vérifiez votre connexion et réessayez" (erreurs chargement)
|
||||
|
||||
### Récupération gracieuse
|
||||
- **Retry automatique** :
|
||||
@ -274,14 +280,14 @@ Ce document liste les améliorations UX proposées pour la plateforme zapwall.fr
|
||||
2. Toast notifications
|
||||
3. Indicateur visuel pour contenu débloqué
|
||||
4. Raccourcis clavier de base (`/`, `Esc`)
|
||||
5. Amélioration de la modal de paiement
|
||||
5. ✅ Amélioration de la modal de paiement - Implémenté
|
||||
|
||||
### Priorité moyenne
|
||||
6. Recherche améliorée avec suggestions
|
||||
7. Filtres persistants
|
||||
8. Navigation clavier complète
|
||||
9. ARIA amélioré
|
||||
10. Messages d'erreur actionnables
|
||||
6. ✅ Recherche améliorée avec suggestions - Implémenté
|
||||
7. ✅ Filtres persistants - Implémenté
|
||||
8. ✅ Navigation clavier complète - Implémenté
|
||||
9. ✅ ARIA amélioré - Implémenté
|
||||
10. ✅ Messages d'erreur actionnables - Implémenté
|
||||
|
||||
### Priorité basse
|
||||
11. Mode lecture
|
||||
|
||||
103
hooks/useArrowNavigation.ts
Normal file
103
hooks/useArrowNavigation.ts
Normal 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])
|
||||
}
|
||||
39
hooks/useKeyboardShortcuts.ts
Normal file
39
hooks/useKeyboardShortcuts.ts
Normal 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
83
lib/articleSuggestions.ts
Normal 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
142
lib/errorClassification.ts
Normal 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
77
lib/filterPreferences.ts
Normal 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()
|
||||
}
|
||||
64
lib/paymentModalHelpers.ts
Normal file
64
lib/paymentModalHelpers.ts
Normal 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)
|
||||
}
|
||||
79
lib/readingModePreferences.ts
Normal file
79
lib/readingModePreferences.ts
Normal 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
91
lib/searchHistory.ts
Normal 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()
|
||||
}
|
||||
@ -23,6 +23,14 @@ nav.documentation=Documentation
|
||||
nav.publish=Publish profile
|
||||
nav.createAuthorPage=Create author page
|
||||
nav.loading=Loading...
|
||||
navigation.skipLinks=Skip navigation links
|
||||
navigation.skipToMain=Skip to main content
|
||||
navigation.skipToFilters=Skip to filters
|
||||
navigation.skipToArticles=Skip to articles
|
||||
navigation.articlesSection=Articles list
|
||||
navigation.authorsSection=Authors list
|
||||
navigation.filtersSection=Filters
|
||||
navigation.categories=Categories
|
||||
|
||||
# Connect
|
||||
connect.createAccount=Create account
|
||||
@ -127,6 +135,8 @@ presentation.delete.error=Error deleting author page
|
||||
# Filters
|
||||
filters.clear=Clear all
|
||||
filters.author=All authors
|
||||
filters.author.select=Select an author
|
||||
filters.author.selected=Selected author: {{author}}
|
||||
filters.sort=Sort by
|
||||
filters.sort.newest=Newest
|
||||
filters.sort.oldest=Oldest
|
||||
@ -134,6 +144,9 @@ filters.loading=Loading authors...
|
||||
|
||||
# Search
|
||||
search.placeholder=Search...
|
||||
search.suggestion.article=Article
|
||||
search.suggestion.author=Author
|
||||
search.suggestion.history=History
|
||||
|
||||
# Footer
|
||||
footer.legal=Legal
|
||||
@ -180,10 +193,67 @@ payment.modal.invoiceExpired=Invoice expired
|
||||
payment.modal.invoiceExpiredHelp=The Lightning invoice has expired. Please unlock the article again to get a new invoice.
|
||||
payment.modal.weblnNotAvailable=WebLN is not available. Please install Alby or use another Lightning extension.
|
||||
payment.modal.autoVerify=Payment verification will happen automatically.
|
||||
payment.modal.instructions.title=Payment Instructions
|
||||
payment.modal.instructions.step1=Click the "Pay with Alby" button below
|
||||
payment.modal.instructions.step2=Confirm the payment in the Alby window that opens
|
||||
payment.modal.instructions.step3=Content will be unlocked automatically after confirmation
|
||||
payment.modal.instructions.titleNoAlby=Payment Instructions
|
||||
payment.modal.instructions.step1NoAlby=Install the Alby extension from getalby.com
|
||||
payment.modal.instructions.step2NoAlby=Reload this page after installation
|
||||
payment.modal.instructions.step3NoAlby=Or scan the QR code with your mobile Lightning wallet
|
||||
|
||||
# Article
|
||||
article.unlock.success=Article unlocked successfully!
|
||||
article.publish.success=Article published successfully!
|
||||
article.unlocked.badge=Unlocked
|
||||
|
||||
# Notifications
|
||||
notifications.badge.unread.singular=unread notification
|
||||
notifications.badge.unread.plural=unread notifications
|
||||
notifications.badge.noUnread=No unread notifications
|
||||
|
||||
# Reading Mode
|
||||
readingMode.enable=Enable reading mode
|
||||
readingMode.disable=Disable reading mode
|
||||
readingMode.maxWidth=Max width
|
||||
readingMode.maxWidth.narrow=Narrow
|
||||
readingMode.maxWidth.medium=Medium
|
||||
readingMode.maxWidth.wide=Wide
|
||||
readingMode.maxWidth.full=Full width
|
||||
readingMode.fontSize=Font size
|
||||
readingMode.fontSize.small=Small
|
||||
readingMode.fontSize.medium=Medium
|
||||
readingMode.fontSize.large=Large
|
||||
readingMode.fontSize.xlarge=Extra large
|
||||
readingMode.lineHeight=Line height
|
||||
readingMode.lineHeight.tight=Tight
|
||||
readingMode.lineHeight.normal=Normal
|
||||
readingMode.lineHeight.relaxed=Relaxed
|
||||
|
||||
# Share
|
||||
share.copyLink=Copy link
|
||||
share.copySuccess=Link copied successfully
|
||||
share.copyFailed=Failed to copy link
|
||||
share.shareToNostr=Share on Nostr
|
||||
share.shareSuccess=Share successful
|
||||
share.shareFailed=Share failed
|
||||
share.nostrLinkCopied=Nostr link copied
|
||||
|
||||
# Suggestions
|
||||
suggestions.similarArticles=Similar articles
|
||||
suggestions.authorArticles=Articles by the same author
|
||||
suggestions.categoryArticles=Articles in the same category
|
||||
|
||||
# Errors
|
||||
errors.network.suggestion=Check your Internet connection and try again.
|
||||
errors.network.offline=You appear to be offline. Check your Internet connection.
|
||||
errors.payment.suggestion=Check your Alby connection and ensure you have sufficient funds.
|
||||
errors.validation.suggestion=Verify that all required fields are filled correctly.
|
||||
errors.loading.suggestion=Unable to load data. Check your connection and try again.
|
||||
errors.unknown.suggestion=An unexpected error occurred. Try again or contact support.
|
||||
errors.actions.retry=Retry
|
||||
errors.actions.checkConnection=Check connection
|
||||
errors.actions.viewDocumentation=View documentation
|
||||
|
||||
# Article
|
||||
article.title=Title
|
||||
@ -208,6 +278,7 @@ common.empty.authors=No authors found. Check back later!
|
||||
common.empty.authors.filtered=No authors match your search or filters.
|
||||
common.back=Back
|
||||
common.open=Open
|
||||
common.close=Close
|
||||
|
||||
# Settings
|
||||
settings.title=Settings
|
||||
|
||||
@ -23,6 +23,14 @@ nav.documentation=Documentation
|
||||
nav.publish=Publier le profil
|
||||
nav.createAuthorPage=Créer page auteur
|
||||
nav.loading=Chargement...
|
||||
navigation.skipLinks=Liens de navigation rapide
|
||||
navigation.skipToMain=Aller au contenu principal
|
||||
navigation.skipToFilters=Aller aux filtres
|
||||
navigation.skipToArticles=Aller aux articles
|
||||
navigation.articlesSection=Liste des articles
|
||||
navigation.authorsSection=Liste des auteurs
|
||||
navigation.filtersSection=Filtres
|
||||
navigation.categories=Catégories
|
||||
|
||||
# Connect
|
||||
connect.createAccount=Créer un compte
|
||||
@ -44,6 +52,9 @@ category.science-fiction=Science-fiction
|
||||
category.scientific-research=Recherche scientifique
|
||||
category.all=Toutes les catégories
|
||||
|
||||
# Common
|
||||
common.close=Fermer
|
||||
|
||||
# Articles/Publications
|
||||
publication.title=Publications
|
||||
publication.empty=Aucune publication
|
||||
@ -127,6 +138,8 @@ presentation.delete.error=Erreur lors de la suppression de la page auteur
|
||||
# Filters
|
||||
filters.clear=Effacer tout
|
||||
filters.author=Tous les auteurs
|
||||
filters.author.select=Sélectionner un auteur
|
||||
filters.author.selected=Auteur sélectionné : {{author}}
|
||||
filters.sort=Trier par
|
||||
filters.sort.newest=Plus récent
|
||||
filters.sort.oldest=Plus ancien
|
||||
@ -134,6 +147,9 @@ filters.loading=Chargement des auteurs...
|
||||
|
||||
# Search
|
||||
search.placeholder=Rechercher...
|
||||
search.suggestion.article=Article
|
||||
search.suggestion.author=Auteur
|
||||
search.suggestion.history=Historique
|
||||
|
||||
# Footer
|
||||
footer.legal=Mentions légales
|
||||
@ -152,6 +168,7 @@ common.empty.authors=Aucun auteur trouvé. Revenez plus tard !
|
||||
common.empty.authors.filtered=Aucun auteur ne correspond à votre recherche ou à vos filtres.
|
||||
common.back=Retour
|
||||
common.open=Ouvrir
|
||||
common.close=Fermer
|
||||
|
||||
# Settings
|
||||
settings.title=Paramètres
|
||||
@ -252,7 +269,64 @@ payment.modal.invoiceExpired=Facture expirée
|
||||
payment.modal.invoiceExpiredHelp=La facture Lightning a expiré. Veuillez débloquer l'article à nouveau pour obtenir une nouvelle facture.
|
||||
payment.modal.weblnNotAvailable=WebLN n'est pas disponible. Veuillez installer Alby ou utiliser une autre extension Lightning.
|
||||
payment.modal.autoVerify=La vérification du paiement se fera automatiquement.
|
||||
payment.modal.instructions.title=Instructions de paiement
|
||||
payment.modal.instructions.step1=Cliquez sur le bouton "Payer avec Alby" ci-dessous
|
||||
payment.modal.instructions.step2=Confirmez le paiement dans la fenêtre Alby qui s'ouvre
|
||||
payment.modal.instructions.step3=Le contenu sera débloqué automatiquement après confirmation
|
||||
payment.modal.instructions.titleNoAlby=Instructions de paiement
|
||||
payment.modal.instructions.step1NoAlby=Installez l'extension Alby depuis getalby.com
|
||||
payment.modal.instructions.step2NoAlby=Rechargez cette page après l'installation
|
||||
payment.modal.instructions.step3NoAlby=Ou scannez le QR code avec votre portefeuille Lightning mobile
|
||||
|
||||
# Article
|
||||
article.unlock.success=Article débloqué avec succès !
|
||||
article.publish.success=Article publié avec succès !
|
||||
article.unlocked.badge=Débloqué
|
||||
|
||||
# Notifications
|
||||
notifications.badge.unread.singular=notification non lue
|
||||
notifications.badge.unread.plural=notifications non lues
|
||||
notifications.badge.noUnread=Aucune notification non lue
|
||||
|
||||
# Reading Mode
|
||||
readingMode.enable=Activer le mode lecture
|
||||
readingMode.disable=Désactiver le mode lecture
|
||||
readingMode.maxWidth=Largeur maximale
|
||||
readingMode.maxWidth.narrow=Étroite
|
||||
readingMode.maxWidth.medium=Moyenne
|
||||
readingMode.maxWidth.wide=Large
|
||||
readingMode.maxWidth.full=Pleine largeur
|
||||
readingMode.fontSize=Taille de police
|
||||
readingMode.fontSize.small=Petite
|
||||
readingMode.fontSize.medium=Moyenne
|
||||
readingMode.fontSize.large=Grande
|
||||
readingMode.fontSize.xlarge=Très grande
|
||||
readingMode.lineHeight=Interligne
|
||||
readingMode.lineHeight.tight=Serré
|
||||
readingMode.lineHeight.normal=Normal
|
||||
readingMode.lineHeight.relaxed=Relâché
|
||||
|
||||
# Share
|
||||
share.copyLink=Copier le lien
|
||||
share.copySuccess=Lien copié avec succès
|
||||
share.copyFailed=Échec de la copie du lien
|
||||
share.shareToNostr=Partager sur Nostr
|
||||
share.shareSuccess=Partage réussi
|
||||
share.shareFailed=Échec du partage
|
||||
share.nostrLinkCopied=Lien Nostr copié
|
||||
|
||||
# Suggestions
|
||||
suggestions.similarArticles=Articles similaires
|
||||
suggestions.authorArticles=Articles du même auteur
|
||||
suggestions.categoryArticles=Articles de la même catégorie
|
||||
|
||||
# Errors
|
||||
errors.network.suggestion=Vérifiez votre connexion Internet et réessayez.
|
||||
errors.network.offline=Vous semblez être hors ligne. Vérifiez votre connexion Internet.
|
||||
errors.payment.suggestion=Vérifiez votre connexion Alby et assurez-vous d'avoir des fonds suffisants.
|
||||
errors.validation.suggestion=Vérifiez que tous les champs requis sont remplis correctement.
|
||||
errors.loading.suggestion=Impossible de charger les données. Vérifiez votre connexion et réessayez.
|
||||
errors.unknown.suggestion=Une erreur inattendue s'est produite. Réessayez ou contactez le support.
|
||||
errors.actions.retry=Réessayer
|
||||
errors.actions.checkConnection=Vérifier la connexion
|
||||
errors.actions.viewDocumentation=Voir la documentation
|
||||
|
||||
@ -2,6 +2,7 @@ import '@/styles/globals.css'
|
||||
import type { AppProps } from 'next/app'
|
||||
import Router from 'next/router'
|
||||
import { useI18n } from '@/hooks/useI18n'
|
||||
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts'
|
||||
import React from 'react'
|
||||
import { nostrAuthService } from '@/lib/nostrAuth'
|
||||
import { relaySessionManager } from '@/lib/relaySessionManager'
|
||||
@ -58,6 +59,7 @@ export default function App({ Component, pageProps }: AppProps): React.ReactElem
|
||||
usePublishWorkerLifecycle()
|
||||
usePlatformSyncOnNavigation()
|
||||
useUserSyncOnNavigationAndAuth()
|
||||
useKeyboardShortcuts()
|
||||
|
||||
return (
|
||||
<I18nProvider>
|
||||
|
||||
@ -6,6 +6,7 @@ import { getAuthorsByCategory, sortAuthors } from '@/lib/authorFiltering'
|
||||
import type { Article } from '@/types/nostr'
|
||||
import type { ArticleFilters } from '@/components/ArticleFilters'
|
||||
import { HomeView } from '@/components/HomeView'
|
||||
import { getFilterPreferences, saveFilterPreferences } from '@/lib/filterPreferences'
|
||||
|
||||
function usePresentationArticles(allArticles: Article[]): Map<string, Article> {
|
||||
return useMemo(() => {
|
||||
@ -19,6 +20,43 @@ function usePresentationArticles(allArticles: Article[]): Map<string, Article> {
|
||||
}, [allArticles])
|
||||
}
|
||||
|
||||
function useFilterPersistence(
|
||||
filters: ArticleFilters,
|
||||
setFilters: React.Dispatch<React.SetStateAction<ArticleFilters>>
|
||||
): void {
|
||||
const [filtersLoaded, setFiltersLoaded] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const loadFilters = async (): Promise<void> => {
|
||||
try {
|
||||
const savedFilters = await getFilterPreferences()
|
||||
if (savedFilters !== null) {
|
||||
setFilters(savedFilters)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading filter preferences:', error)
|
||||
} finally {
|
||||
setFiltersLoaded(true)
|
||||
}
|
||||
}
|
||||
void loadFilters()
|
||||
}, [setFilters])
|
||||
|
||||
useEffect(() => {
|
||||
if (!filtersLoaded) {
|
||||
return
|
||||
}
|
||||
const saveFilters = async (): Promise<void> => {
|
||||
try {
|
||||
await saveFilterPreferences(filters)
|
||||
} catch (error) {
|
||||
console.error('Error saving filter preferences:', error)
|
||||
}
|
||||
}
|
||||
void saveFilters()
|
||||
}, [filters, filtersLoaded])
|
||||
}
|
||||
|
||||
function useHomeState(): {
|
||||
searchQuery: string
|
||||
setSearchQuery: React.Dispatch<React.SetStateAction<string>>
|
||||
@ -38,6 +76,8 @@ function useHomeState(): {
|
||||
})
|
||||
const [unlockedArticles, setUnlockedArticles] = useState<Set<string>>(new Set())
|
||||
|
||||
useFilterPersistence(filters, setFilters)
|
||||
|
||||
return {
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user