create for series
This commit is contained in:
parent
57acb3d9f3
commit
6fcfae4cc0
@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState, useCallback } from 'react'
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
import { getAlbyService } from '@/lib/alby'
|
import { getAlbyService } from '@/lib/alby'
|
||||||
|
import { Button } from './ui'
|
||||||
|
|
||||||
interface AlbyInstallerProps {
|
interface AlbyInstallerProps {
|
||||||
onInstalled?: () => void
|
onInstalled?: () => void
|
||||||
@ -43,18 +44,20 @@ function InstallerActions({ onInstalled, markInstalled }: InstallerActionsProps)
|
|||||||
href="https://getalby.com/"
|
href="https://getalby.com/"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="inline-flex items-center justify-center px-4 py-2 border border-neon-cyan/50 text-sm font-medium rounded-lg text-neon-cyan bg-neon-cyan/20 hover:bg-neon-cyan/30 hover:shadow-glow-cyan focus:outline-none focus:ring-2 focus:ring-neon-cyan transition-all"
|
|
||||||
>
|
>
|
||||||
Install Alby
|
<Button variant="primary" size="small">
|
||||||
|
Install Alby
|
||||||
|
</Button>
|
||||||
</a>
|
</a>
|
||||||
<button
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="small"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void connect()
|
void connect()
|
||||||
}}
|
}}
|
||||||
className="inline-flex items-center justify-center px-4 py-2 border border-neon-cyan/30 text-sm font-medium rounded-lg text-cyber-accent bg-cyber-light hover:bg-cyber-dark hover:border-neon-cyan/50 focus:outline-none focus:ring-2 focus:ring-neon-cyan transition-all"
|
|
||||||
>
|
>
|
||||||
Already installed? Connect
|
Already installed? Connect
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { useNostrAuth } from '@/hooks/useNostrAuth'
|
|||||||
import { useArticlePayment } from '@/hooks/useArticlePayment'
|
import { useArticlePayment } from '@/hooks/useArticlePayment'
|
||||||
import { ArticlePreview } from './ArticlePreview'
|
import { ArticlePreview } from './ArticlePreview'
|
||||||
import { PaymentModal } from './PaymentModal'
|
import { PaymentModal } from './PaymentModal'
|
||||||
|
import { Card } from './ui'
|
||||||
import { t } from '@/lib/i18n'
|
import { t } from '@/lib/i18n'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
|
||||||
@ -69,7 +70,7 @@ export function ArticleCard({ article, onUnlock }: ArticleCardProps): React.Reac
|
|||||||
}, connect)
|
}, connect)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="border border-neon-cyan/30 rounded-lg p-6 bg-cyber-dark hover:border-neon-cyan/50 hover:shadow-glow-cyan transition-all">
|
<Card variant="interactive" className="mb-0">
|
||||||
<ArticleHeader article={article} />
|
<ArticleHeader article={article} />
|
||||||
<div className="text-cyber-accent mb-4">
|
<div className="text-cyber-accent mb-4">
|
||||||
<ArticlePreview
|
<ArticlePreview
|
||||||
@ -89,6 +90,6 @@ export function ArticleCard({ article, onUnlock }: ArticleCardProps): React.Reac
|
|||||||
void handlePaymentComplete()
|
void handlePaymentComplete()
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</article>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import type { Article } from '@/types/nostr'
|
import type { Article } from '@/types/nostr'
|
||||||
|
import { Card } from './ui'
|
||||||
import { t } from '@/lib/i18n'
|
import { t } from '@/lib/i18n'
|
||||||
import { AuthorFilter } from './AuthorFilter'
|
import { AuthorFilter } from './AuthorFilter'
|
||||||
|
|
||||||
@ -123,9 +124,9 @@ export function ArticleFiltersComponent({
|
|||||||
filters.category !== null
|
filters.category !== null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-cyber-dark border border-neon-cyan/30 rounded-lg p-4 mb-6 shadow-glow-cyan">
|
<Card variant="default" className="mb-6 shadow-glow-cyan">
|
||||||
<FiltersHeader hasActiveFilters={hasActiveFilters} onClear={handleClearFilters} />
|
<FiltersHeader hasActiveFilters={hasActiveFilters} onClear={handleClearFilters} />
|
||||||
<FiltersGrid data={data} filters={filters} onFiltersChange={onFiltersChange} />
|
<FiltersGrid data={data} filters={filters} onFiltersChange={onFiltersChange} />
|
||||||
</div>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { Button } from './ui'
|
||||||
import { t } from '@/lib/i18n'
|
import { t } from '@/lib/i18n'
|
||||||
|
|
||||||
interface ArticleFormButtonsProps {
|
interface ArticleFormButtonsProps {
|
||||||
@ -10,21 +11,23 @@ export function ArticleFormButtons({ loading, onCancel }: ArticleFormButtonsProp
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-3 pt-4">
|
<div className="space-y-3 pt-4">
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="flex-1 px-4 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan disabled:opacity-50 disabled:cursor-not-allowed"
|
loading={loading}
|
||||||
|
className="flex-1"
|
||||||
>
|
>
|
||||||
{loading ? t('publish.publishing') : t('publish.button')}
|
{loading ? t('publish.publishing') : t('publish.button')}
|
||||||
</button>
|
</Button>
|
||||||
{onCancel && (
|
{onCancel && (
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
className="px-4 py-2 bg-cyber-dark hover:bg-cyber-dark/80 text-cyber-accent rounded-lg font-medium transition-colors border border-cyber-accent/30 hover:border-neon-cyan/50"
|
|
||||||
>
|
>
|
||||||
{t('common.back')}
|
{t('common.back')}
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import type { Article } from '@/types/nostr'
|
import type { Article } from '@/types/nostr'
|
||||||
|
import { Button } from './ui'
|
||||||
import { ArticlePages } from './ArticlePages'
|
import { ArticlePages } from './ArticlePages'
|
||||||
|
|
||||||
interface ArticlePreviewProps {
|
interface ArticlePreviewProps {
|
||||||
@ -25,13 +26,14 @@ export function ArticlePreview({ article, loading, onUnlock }: ArticlePreviewPro
|
|||||||
<p className="text-sm text-cyber-accent/70 mb-4">
|
<p className="text-sm text-cyber-accent/70 mb-4">
|
||||||
Contenu complet disponible après un zap de {article.zapAmount} sats
|
Contenu complet disponible après un zap de {article.zapAmount} sats
|
||||||
</p>
|
</p>
|
||||||
<button
|
<Button
|
||||||
|
variant="success"
|
||||||
onClick={onUnlock}
|
onClick={onUnlock}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="px-4 py-2 bg-neon-green/20 hover:bg-neon-green/30 text-neon-green rounded-lg font-medium transition-all border border-neon-green/50 hover:shadow-glow-green disabled:opacity-50"
|
loading={loading}
|
||||||
>
|
>
|
||||||
{loading ? 'Traitement...' : `Débloquer avec ${article.zapAmount} sats zap`}
|
{loading ? 'Traitement...' : `Débloquer avec ${article.zapAmount} sats zap`}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react'
|
|||||||
import type { Review, Article } from '@/types/nostr'
|
import type { Review, Article } from '@/types/nostr'
|
||||||
import { getReviewsForArticle } from '@/lib/reviews'
|
import { getReviewsForArticle } from '@/lib/reviews'
|
||||||
import { getReviewTipsForArticle } from '@/lib/reviewAggregation'
|
import { getReviewTipsForArticle } from '@/lib/reviewAggregation'
|
||||||
|
import { Card, ErrorState, Button } from './ui'
|
||||||
import { ReviewForm } from './ReviewForm'
|
import { ReviewForm } from './ReviewForm'
|
||||||
import { ReviewTipForm } from './ReviewTipForm'
|
import { ReviewTipForm } from './ReviewTipForm'
|
||||||
import { t } from '@/lib/i18n'
|
import { t } from '@/lib/i18n'
|
||||||
@ -17,7 +18,7 @@ export function ArticleReviews({ article, authorPubkey }: ArticleReviewsProps):
|
|||||||
const tipSelection = useReviewTipSelection({ article, reviews: data.reviews, reload: data.reload })
|
const tipSelection = useReviewTipSelection({ article, reviews: data.reviews, reload: data.reload })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark space-y-4">
|
<Card variant="default" className="space-y-4">
|
||||||
<ArticleReviewsHeader tips={data.tips} onAddReview={reviewForm.open} />
|
<ArticleReviewsHeader tips={data.tips} onAddReview={reviewForm.open} />
|
||||||
{reviewForm.show && (
|
{reviewForm.show && (
|
||||||
<ReviewForm
|
<ReviewForm
|
||||||
@ -27,13 +28,13 @@ export function ArticleReviews({ article, authorPubkey }: ArticleReviewsProps):
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{data.loading && <p className="text-sm text-cyber-accent">{t('common.loading')}</p>}
|
{data.loading && <p className="text-sm text-cyber-accent">{t('common.loading')}</p>}
|
||||||
{data.error && <p className="text-sm text-red-400">{data.error}</p>}
|
{data.error && <ErrorState message={data.error} />}
|
||||||
{!data.loading && !data.error && data.reviews.length === 0 && !reviewForm.show && (
|
{!data.loading && !data.error && data.reviews.length === 0 && !reviewForm.show && (
|
||||||
<p className="text-sm text-cyber-accent/70">{t('review.empty')}</p>
|
<p className="text-sm text-cyber-accent/70">{t('review.empty')}</p>
|
||||||
)}
|
)}
|
||||||
{!data.loading && !data.error && <ArticleReviewsList reviews={data.reviews} onTipReview={tipSelection.select} />}
|
{!data.loading && !data.error && <ArticleReviewsList reviews={data.reviews} onTipReview={tipSelection.select} />}
|
||||||
<SelectedReviewTipForm selection={tipSelection} />
|
<SelectedReviewTipForm selection={tipSelection} />
|
||||||
</div>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -163,12 +164,9 @@ function ArticleReviewsHeader({ tips, onAddReview }: { tips: number; onAddReview
|
|||||||
<h3 className="text-lg font-semibold text-neon-cyan">{t('review.title')}</h3>
|
<h3 className="text-lg font-semibold text-neon-cyan">{t('review.title')}</h3>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<span className="text-sm text-cyber-accent/70">{t('review.tips.total', { amount: tips })}</span>
|
<span className="text-sm text-cyber-accent/70">{t('review.tips.total', { amount: tips })}</span>
|
||||||
<button
|
<Button variant="success" size="small" onClick={onAddReview}>
|
||||||
onClick={onAddReview}
|
|
||||||
className="px-3 py-1 text-sm bg-neon-green/20 hover:bg-neon-green/30 text-neon-green rounded-lg font-medium transition-all border border-neon-green/50"
|
|
||||||
>
|
|
||||||
{t('review.add')}
|
{t('review.add')}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@ -192,14 +190,16 @@ function ArticleReviewsList({ reviews, onTipReview }: { reviews: Review[]; onTip
|
|||||||
<span>{t('review.reviewer')}: {formatPubkey(r.reviewerPubkey)}</span>
|
<span>{t('review.reviewer')}: {formatPubkey(r.reviewerPubkey)}</span>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span>{formatDate(r.createdAt)}</span>
|
<span>{formatDate(r.createdAt)}</span>
|
||||||
<button
|
<Button
|
||||||
|
variant="success"
|
||||||
|
size="small"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onTipReview(r.id)
|
onTipReview(r.id)
|
||||||
}}
|
}}
|
||||||
className="ml-auto px-2 py-1 text-xs bg-neon-green/20 hover:bg-neon-green/30 text-neon-green rounded transition-all border border-neon-green/50"
|
className="ml-auto"
|
||||||
>
|
>
|
||||||
{t('review.tip.button')}
|
{t('review.tip.button')}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import type { Article } from '@/types/nostr'
|
import type { Article } from '@/types/nostr'
|
||||||
import { ArticleCard } from './ArticleCard'
|
import { ArticleCard } from './ArticleCard'
|
||||||
|
import { ErrorState, EmptyState } from './ui'
|
||||||
import { t } from '@/lib/i18n'
|
import { t } from '@/lib/i18n'
|
||||||
|
|
||||||
interface ArticlesListProps {
|
interface ArticlesListProps {
|
||||||
@ -20,21 +21,11 @@ function LoadingState(): React.ReactElement {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ErrorState({ message }: { message: string }): React.ReactElement {
|
function ArticlesEmptyState({ hasAny }: { hasAny: boolean }): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<div className="bg-red-900/20 border border-red-500/50 rounded-lg p-4 mb-4">
|
<EmptyState
|
||||||
<p className="text-red-400">{message}</p>
|
title={hasAny ? t('common.empty.articles.filtered') : t('common.empty.articles')}
|
||||||
</div>
|
/>
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function EmptyState({ hasAny }: { hasAny: boolean }): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<p className="text-cyber-accent/70">
|
|
||||||
{hasAny ? t('common.empty.articles.filtered') : t('common.empty.articles')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -53,7 +44,7 @@ export function ArticlesList({
|
|||||||
return <ErrorState message={error} />
|
return <ErrorState message={error} />
|
||||||
}
|
}
|
||||||
if (articles.length === 0) {
|
if (articles.length === 0) {
|
||||||
return <EmptyState hasAny={allArticles.length > 0} />
|
return <ArticlesEmptyState hasAny={allArticles.length > 0} />
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import Link from 'next/link'
|
import { useRouter } from 'next/router'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import type { Article } from '@/types/nostr'
|
import type { Article } from '@/types/nostr'
|
||||||
|
import { Card } from './ui'
|
||||||
import { t } from '@/lib/i18n'
|
import { t } from '@/lib/i18n'
|
||||||
|
|
||||||
interface AuthorCardProps {
|
interface AuthorCardProps {
|
||||||
@ -8,14 +9,16 @@ interface AuthorCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AuthorCard({ presentation }: AuthorCardProps): React.ReactElement {
|
export function AuthorCard({ presentation }: AuthorCardProps): React.ReactElement {
|
||||||
|
const router = useRouter()
|
||||||
const authorName = presentation.title.replace(/^Présentation de /, '') || t('common.author')
|
const authorName = presentation.title.replace(/^Présentation de /, '') || t('common.author')
|
||||||
const totalBTC = (presentation.totalSponsoring ?? 0) / 100_000_000
|
const totalBTC = (presentation.totalSponsoring ?? 0) / 100_000_000
|
||||||
|
|
||||||
|
const handleClick = (): void => {
|
||||||
|
void router.push(`/author/${presentation.pubkey}`)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Card variant="interactive" onClick={handleClick} className="bg-cyber-dark/50">
|
||||||
href={`/author/${presentation.pubkey}`}
|
|
||||||
className="block border border-neon-cyan/20 rounded-lg p-6 bg-cyber-dark/50 hover:bg-cyber-dark hover:border-neon-cyan/40 transition-all hover:shadow-glow-cyan"
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
{presentation.bannerUrl && (
|
{presentation.bannerUrl && (
|
||||||
<div className="relative w-20 h-20 rounded-lg overflow-hidden border border-neon-cyan/20 flex-shrink-0">
|
<div className="relative w-20 h-20 rounded-lg overflow-hidden border border-neon-cyan/20 flex-shrink-0">
|
||||||
@ -37,6 +40,6 @@ export function AuthorCard({ presentation }: AuthorCardProps): React.ReactElemen
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import type { Article } from '@/types/nostr'
|
import type { Article } from '@/types/nostr'
|
||||||
import { AuthorCard } from './AuthorCard'
|
import { AuthorCard } from './AuthorCard'
|
||||||
|
import { ErrorState, EmptyState } from './ui'
|
||||||
import { t } from '@/lib/i18n'
|
import { t } from '@/lib/i18n'
|
||||||
|
|
||||||
interface AuthorsListProps {
|
interface AuthorsListProps {
|
||||||
@ -17,21 +18,11 @@ function LoadingState(): React.ReactElement {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ErrorState({ message }: { message: string }): React.ReactElement {
|
function AuthorsEmptyState({ hasAny }: { hasAny: boolean }): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<div className="bg-red-900/20 border border-red-500/50 rounded-lg p-4 mb-4">
|
<EmptyState
|
||||||
<p className="text-red-400">{message}</p>
|
title={hasAny ? t('common.empty.authors.filtered') : t('common.empty.authors')}
|
||||||
</div>
|
/>
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function EmptyState({ hasAny }: { hasAny: boolean }): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<p className="text-cyber-accent/70">
|
|
||||||
{hasAny ? t('common.empty.authors.filtered') : t('common.empty.authors')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -43,7 +34,7 @@ export function AuthorsList({ authors, allAuthors, loading, error }: AuthorsList
|
|||||||
return <ErrorState message={error} />
|
return <ErrorState message={error} />
|
||||||
}
|
}
|
||||||
if (authors.length === 0) {
|
if (authors.length === 0) {
|
||||||
return <EmptyState hasAny={allAuthors.length > 0} />
|
return <AuthorsEmptyState hasAny={allAuthors.length > 0} />
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -7,9 +7,13 @@ interface ClearButtonProps {
|
|||||||
export function ClearButton({ onClick }: ClearButtonProps): React.ReactElement {
|
export function ClearButton({ onClick }: ClearButtonProps): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={(e) => {
|
||||||
className="absolute inset-y-0 right-0 pr-3 flex items-center text-neon-cyan/70 hover:text-neon-cyan transition-colors"
|
e.preventDefault()
|
||||||
|
onClick()
|
||||||
|
}}
|
||||||
|
className="text-neon-cyan/70 hover:text-neon-cyan transition-colors focus:outline-none focus:ring-2 focus:ring-neon-cyan rounded"
|
||||||
aria-label={t('search.clear')}
|
aria-label={t('search.clear')}
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||||
<path
|
<path
|
||||||
|
|||||||
@ -3,15 +3,16 @@ import Image from 'next/image'
|
|||||||
import { useNostrAuth } from '@/hooks/useNostrAuth'
|
import { useNostrAuth } from '@/hooks/useNostrAuth'
|
||||||
import { useAuthorPresentation } from '@/hooks/useAuthorPresentation'
|
import { useAuthorPresentation } from '@/hooks/useAuthorPresentation'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Button } from './ui'
|
||||||
import { t } from '@/lib/i18n'
|
import { t } from '@/lib/i18n'
|
||||||
import type { Article } from '@/types/nostr'
|
import type { Article } from '@/types/nostr'
|
||||||
|
|
||||||
const buttonClassName = 'px-4 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg text-sm font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan'
|
|
||||||
|
|
||||||
function CreateAuthorPageLink(): React.ReactElement {
|
function CreateAuthorPageLink(): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<Link href="/presentation" className={buttonClassName}>
|
<Link href="/presentation">
|
||||||
{t('nav.createAuthorPage')}
|
<Button variant="primary" size="small">
|
||||||
|
{t('nav.createAuthorPage')}
|
||||||
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { ConnectedUserMenu } from './ConnectedUserMenu'
|
|||||||
import { RecoveryStep } from './CreateAccountModalSteps'
|
import { RecoveryStep } from './CreateAccountModalSteps'
|
||||||
import { UnlockAccountModal } from './UnlockAccountModal'
|
import { UnlockAccountModal } from './UnlockAccountModal'
|
||||||
import type { NostrProfile } from '@/types/nostr'
|
import type { NostrProfile } from '@/types/nostr'
|
||||||
|
import { Button } from './ui'
|
||||||
import { t } from '@/lib/i18n'
|
import { t } from '@/lib/i18n'
|
||||||
import { getConnectButtonMode } from './connectButton/connectButtonMode'
|
import { getConnectButtonMode } from './connectButton/connectButtonMode'
|
||||||
import { useAutoConnect, useConnectButtonUiState } from './connectButton/useConnectButtonUiState'
|
import { useAutoConnect, useConnectButtonUiState } from './connectButton/useConnectButtonUiState'
|
||||||
@ -20,20 +21,22 @@ function ConnectForm({
|
|||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<button
|
<Button
|
||||||
|
variant="primary"
|
||||||
onClick={onCreateAccount}
|
onClick={onCreateAccount}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="px-6 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan disabled:opacity-50"
|
size="large"
|
||||||
>
|
>
|
||||||
{t('connect.createAccount')}
|
{t('connect.createAccount')}
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
|
variant="secondary"
|
||||||
onClick={onUnlock}
|
onClick={onUnlock}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="px-6 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg font-medium transition-colors disabled:opacity-50"
|
size="large"
|
||||||
>
|
>
|
||||||
{t('connect.connect')}
|
{t('connect.connect')}
|
||||||
</button>
|
</Button>
|
||||||
{error && <p className="text-sm text-red-400">{error}</p>}
|
{error && <p className="text-sm text-red-400">{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import QRCode from 'react-qr-code'
|
|||||||
import type { AlbyInvoice } from '@/types/alby'
|
import type { AlbyInvoice } from '@/types/alby'
|
||||||
import { getAlbyService, isWebLNAvailable } from '@/lib/alby'
|
import { getAlbyService, isWebLNAvailable } from '@/lib/alby'
|
||||||
import { AlbyInstaller } from './AlbyInstaller'
|
import { AlbyInstaller } from './AlbyInstaller'
|
||||||
import { Modal } from './ui/Modal'
|
import { Modal, Button } from './ui'
|
||||||
import { t } from '@/lib/i18n'
|
import { t } from '@/lib/i18n'
|
||||||
|
|
||||||
interface PaymentModalProps {
|
interface PaymentModalProps {
|
||||||
@ -94,20 +94,22 @@ function PaymentActions({
|
|||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<Button
|
||||||
|
variant="secondary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void onCopy()
|
void onCopy()
|
||||||
}}
|
}}
|
||||||
className="flex-1 px-4 py-2 bg-cyber-light border border-neon-cyan/30 hover:border-neon-cyan/50 hover:bg-cyber-dark text-cyber-accent hover:text-neon-cyan rounded-lg font-medium transition-colors"
|
className="flex-1"
|
||||||
>
|
>
|
||||||
{copied ? t('payment.modal.copied') : t('payment.modal.copyInvoice')}
|
{copied ? t('payment.modal.copied') : t('payment.modal.copyInvoice')}
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
|
variant="primary"
|
||||||
onClick={onOpenWallet}
|
onClick={onOpenWallet}
|
||||||
className="flex-1 px-4 py-2 bg-neon-cyan/20 border border-neon-cyan/50 hover:bg-neon-cyan/30 hover:border-neon-cyan text-neon-cyan hover:text-neon-green rounded-lg font-medium transition-colors"
|
className="flex-1"
|
||||||
>
|
>
|
||||||
{t('payment.modal.payWithAlby')}
|
{t('payment.modal.payWithAlby')}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { SearchIcon } from './SearchIcon'
|
import { SearchIcon } from './SearchIcon'
|
||||||
import { ClearButton } from './ClearButton'
|
import { ClearButton } from './ClearButton'
|
||||||
|
import { Input } from './ui'
|
||||||
import { t } from '@/lib/i18n'
|
import { t } from '@/lib/i18n'
|
||||||
|
|
||||||
interface SearchBarProps {
|
interface SearchBarProps {
|
||||||
@ -29,18 +30,14 @@ export function SearchBar({ value, onChange, placeholder }: SearchBarProps): Rea
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<Input
|
||||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
type="text"
|
||||||
<SearchIcon />
|
value={localValue}
|
||||||
</div>
|
onChange={handleChange}
|
||||||
<input
|
placeholder={defaultPlaceholder}
|
||||||
type="text"
|
leftIcon={<SearchIcon />}
|
||||||
value={localValue}
|
rightIcon={localValue ? <ClearButton onClick={handleClear} /> : undefined}
|
||||||
onChange={handleChange}
|
className="pr-10"
|
||||||
placeholder={defaultPlaceholder}
|
/>
|
||||||
className="block w-full pl-10 pr-10 py-2 border border-neon-cyan/30 rounded-lg focus:ring-2 focus:ring-neon-cyan focus:border-neon-cyan bg-cyber-dark text-cyber-accent placeholder-cyber-accent/50 hover:border-neon-cyan/50 transition-colors"
|
|
||||||
/>
|
|
||||||
{localValue && <ClearButton onClick={handleClear} />}
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { nostrService } from '@/lib/nostr'
|
import { nostrService } from '@/lib/nostr'
|
||||||
import { useNostrAuth } from '@/hooks/useNostrAuth'
|
import { useNostrAuth } from '@/hooks/useNostrAuth'
|
||||||
|
import { Button, Card, Textarea, ErrorState } from './ui'
|
||||||
import { t } from '@/lib/i18n'
|
import { t } from '@/lib/i18n'
|
||||||
import { sponsoringPaymentService } from '@/lib/sponsoringPayment'
|
import { sponsoringPaymentService } from '@/lib/sponsoringPayment'
|
||||||
import type { AuthorPresentationArticle } from '@/types/nostr'
|
import type { AuthorPresentationArticle } from '@/types/nostr'
|
||||||
@ -129,36 +130,38 @@ async function submitSponsoring(params: {
|
|||||||
|
|
||||||
function SponsoringConnectRequired(params: { onConnect: () => void }): React.ReactElement {
|
function SponsoringConnectRequired(params: { onConnect: () => void }): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<div className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark">
|
<Card variant="default">
|
||||||
<p className="text-cyber-accent mb-4">{t('sponsoring.form.connectRequired')}</p>
|
<p className="text-cyber-accent mb-4">{t('sponsoring.form.connectRequired')}</p>
|
||||||
<button onClick={params.onConnect} className="px-4 py-2 bg-neon-green/20 hover:bg-neon-green/30 text-neon-green rounded-lg font-medium transition-all border border-neon-green/50">
|
<Button variant="success" onClick={params.onConnect}>
|
||||||
{t('connect.connect')}
|
{t('connect.connect')}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SponsoringNoAddress(): React.ReactElement {
|
function SponsoringNoAddress(): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<div className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark">
|
<Card variant="default">
|
||||||
<p className="text-cyber-accent">{t('sponsoring.form.error.noAddress')}</p>
|
<p className="text-cyber-accent">{t('sponsoring.form.error.noAddress')}</p>
|
||||||
</div>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SponsoringInstructions(params: { instructions: SponsoringInstructionsState; onClose: () => void; onCancel: () => void }): React.ReactElement {
|
function SponsoringInstructions(params: { instructions: SponsoringInstructionsState; onClose: () => void; onCancel: () => void }): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<div className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark space-y-4" role="dialog" aria-modal="true">
|
<div role="dialog" aria-modal="true">
|
||||||
<h3 className="text-lg font-semibold text-neon-cyan">{t('sponsoring.form.title')}</h3>
|
<Card variant="default" className="space-y-4">
|
||||||
<p className="text-sm text-cyber-accent/70">{t('sponsoring.form.instructions', { authorAddress: params.instructions.authorAddress, platformAddress: params.instructions.platformAddress, authorAmount: params.instructions.authorBtc, platformAmount: params.instructions.platformBtc })}</p>
|
<h3 className="text-lg font-semibold text-neon-cyan">{t('sponsoring.form.title')}</h3>
|
||||||
<div className="flex gap-3">
|
<p className="text-sm text-cyber-accent/70">{t('sponsoring.form.instructions', { authorAddress: params.instructions.authorAddress, platformAddress: params.instructions.platformAddress, authorAmount: params.instructions.authorBtc, platformAmount: params.instructions.platformBtc })}</p>
|
||||||
<button type="button" onClick={params.onClose} className="px-4 py-2 bg-neon-green/20 hover:bg-neon-green/30 text-neon-green rounded-lg font-medium transition-all border border-neon-green/50">
|
<div className="flex gap-3">
|
||||||
{t('common.close')}
|
<Button type="button" variant="success" onClick={params.onClose}>
|
||||||
</button>
|
{t('common.close')}
|
||||||
<button type="button" onClick={params.onCancel} className="px-4 py-2 bg-neon-cyan/10 hover:bg-neon-cyan/20 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/30">
|
</Button>
|
||||||
{t('common.cancel')}
|
<Button type="button" variant="ghost" onClick={params.onCancel}>
|
||||||
</button>
|
{t('common.cancel')}
|
||||||
</div>
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -175,29 +178,24 @@ function SponsoringFormView(params: {
|
|||||||
<form onSubmit={params.onSubmit} className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark space-y-4">
|
<form onSubmit={params.onSubmit} className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark space-y-4">
|
||||||
<h3 className="text-lg font-semibold text-neon-cyan">{t('sponsoring.form.title')}</h3>
|
<h3 className="text-lg font-semibold text-neon-cyan">{t('sponsoring.form.title')}</h3>
|
||||||
<p className="text-sm text-cyber-accent/70">{t('sponsoring.form.description', { amount: '0.046' })}</p>
|
<p className="text-sm text-cyber-accent/70">{t('sponsoring.form.description', { amount: '0.046' })}</p>
|
||||||
<div>
|
<Textarea
|
||||||
<label htmlFor="sponsoring-text" className="block text-sm font-medium text-cyber-accent mb-1">
|
id="sponsoring-text"
|
||||||
{t('sponsoring.form.text.label')} <span className="text-cyber-accent/50">({t('common.optional')})</span>
|
label={`${t('sponsoring.form.text.label')} (${t('common.optional')})`}
|
||||||
</label>
|
value={params.text}
|
||||||
<textarea
|
onChange={(e) => params.setText(e.target.value)}
|
||||||
id="sponsoring-text"
|
placeholder={t('sponsoring.form.text.placeholder')}
|
||||||
value={params.text}
|
rows={3}
|
||||||
onChange={(e) => params.setText(e.target.value)}
|
helperText={t('sponsoring.form.text.help')}
|
||||||
placeholder={t('sponsoring.form.text.placeholder')}
|
/>
|
||||||
rows={3}
|
{params.error && <ErrorState message={params.error} />}
|
||||||
className="w-full px-3 py-2 bg-cyber-darker border border-neon-cyan/30 rounded text-cyber-accent focus:border-neon-cyan focus:outline-none"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-cyber-accent/70 mt-1">{t('sponsoring.form.text.help')}</p>
|
|
||||||
</div>
|
|
||||||
{params.error && <div className="p-3 bg-red-900/20 border border-red-500/50 rounded text-red-400 text-sm">{params.error}</div>}
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button type="submit" disabled={params.loading} className="px-4 py-2 bg-neon-green/20 hover:bg-neon-green/30 text-neon-green rounded-lg font-medium transition-all border border-neon-green/50 hover:shadow-glow-green disabled:opacity-50">
|
<Button type="submit" variant="success" disabled={params.loading} loading={params.loading}>
|
||||||
{params.loading ? t('common.loading') : t('sponsoring.form.submit')}
|
{params.loading ? t('common.loading') : t('sponsoring.form.submit')}
|
||||||
</button>
|
</Button>
|
||||||
{params.onCancel && (
|
{params.onCancel && (
|
||||||
<button type="button" onClick={params.onCancel} className="px-4 py-2 bg-cyber-darker hover:bg-cyber-dark text-cyber-accent rounded-lg font-medium transition-all border border-neon-cyan/30">
|
<Button type="button" variant="ghost" onClick={params.onCancel}>
|
||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -1,16 +1,14 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import { Button, Card } from '@/components/ui'
|
||||||
import { t } from '@/lib/i18n'
|
import { t } from '@/lib/i18n'
|
||||||
|
|
||||||
export function ConnectRequiredCard(params: { message: string; onConnect: () => void }): React.ReactElement {
|
export function ConnectRequiredCard(params: { message: string; onConnect: () => void }): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<div className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark">
|
<Card variant="default">
|
||||||
<p className="text-cyber-accent mb-4">{params.message}</p>
|
<p className="text-cyber-accent mb-4">{params.message}</p>
|
||||||
<button
|
<Button variant="success" onClick={params.onConnect}>
|
||||||
onClick={params.onConnect}
|
|
||||||
className="px-4 py-2 bg-neon-green/20 hover:bg-neon-green/30 text-neon-green rounded-lg font-medium transition-all border border-neon-green/50"
|
|
||||||
>
|
|
||||||
{t('connect.connect')}
|
{t('connect.connect')}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import { Button, ErrorState } from '@/components/ui'
|
||||||
import { t } from '@/lib/i18n'
|
import { t } from '@/lib/i18n'
|
||||||
import type { ReviewFormController } from './useReviewFormController'
|
import type { ReviewFormController } from './useReviewFormController'
|
||||||
|
|
||||||
@ -60,32 +61,29 @@ function ReviewFormFields(params: { ctrl: ReviewFormController }): React.ReactEl
|
|||||||
function ReviewFormActions(params: { loading: boolean; onCancel?: (() => void) | undefined }): React.ReactElement {
|
function ReviewFormActions(params: { loading: boolean; onCancel?: (() => void) | undefined }): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
variant="success"
|
||||||
disabled={params.loading}
|
disabled={params.loading}
|
||||||
className="px-4 py-2 bg-neon-green/20 hover:bg-neon-green/30 text-neon-green rounded-lg font-medium transition-all border border-neon-green/50 hover:shadow-glow-green disabled:opacity-50"
|
loading={params.loading}
|
||||||
>
|
>
|
||||||
{params.loading ? t('common.loading') : t('review.form.submit')}
|
{params.loading ? t('common.loading') : t('review.form.submit')}
|
||||||
</button>
|
</Button>
|
||||||
{params.onCancel ? (
|
{params.onCancel && (
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
onClick={params.onCancel}
|
onClick={params.onCancel}
|
||||||
className="px-4 py-2 bg-cyber-darker hover:bg-cyber-dark text-cyber-accent rounded-lg font-medium transition-all border border-neon-cyan/30"
|
|
||||||
>
|
>
|
||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</button>
|
</Button>
|
||||||
) : null}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ErrorBox({ message }: { message: string }): React.ReactElement {
|
function ErrorBox({ message }: { message: string }): React.ReactElement {
|
||||||
return (
|
return <ErrorState message={message} />
|
||||||
<div className="p-3 bg-red-900/20 border border-red-500/50 rounded text-red-400 text-sm">
|
|
||||||
{message}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function TextInput(params: {
|
function TextInput(params: {
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import { Button, ErrorState } from '@/components/ui'
|
||||||
import { t } from '@/lib/i18n'
|
import { t } from '@/lib/i18n'
|
||||||
import type { ReviewTipFormController } from './useReviewTipFormController'
|
import type { ReviewTipFormController } from './useReviewTipFormController'
|
||||||
|
|
||||||
@ -57,30 +58,27 @@ function ReviewTipFormActions(params: {
|
|||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
variant="success"
|
||||||
disabled={params.loading}
|
disabled={params.loading}
|
||||||
className="px-4 py-2 bg-neon-green/20 hover:bg-neon-green/30 text-neon-green rounded-lg font-medium transition-all border border-neon-green/50 hover:shadow-glow-green disabled:opacity-50"
|
loading={params.loading}
|
||||||
>
|
>
|
||||||
{params.loading ? t('common.loading') : t('reviewTip.form.submit', { amount: params.amount })}
|
{params.loading ? t('common.loading') : t('reviewTip.form.submit', { amount: params.amount })}
|
||||||
</button>
|
</Button>
|
||||||
{params.onCancel ? (
|
{params.onCancel && (
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
onClick={params.onCancel}
|
onClick={params.onCancel}
|
||||||
className="px-4 py-2 bg-cyber-darker hover:bg-cyber-dark text-cyber-accent rounded-lg font-medium transition-all border border-neon-cyan/30"
|
|
||||||
>
|
>
|
||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</button>
|
</Button>
|
||||||
) : null}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ErrorBox({ message }: { message: string }): React.ReactElement {
|
function ErrorBox({ message }: { message: string }): React.ReactElement {
|
||||||
return (
|
return <ErrorState message={message} />
|
||||||
<div className="p-3 bg-red-900/20 border border-red-500/50 rounded text-red-400 text-sm">
|
|
||||||
{message}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -69,7 +69,6 @@ export function MobileMenu({ children, 'aria-label': ariaLabel }: MobileMenuProp
|
|||||||
/>
|
/>
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<MobileMenuDrawer
|
<MobileMenuDrawer
|
||||||
isOpen={isOpen}
|
|
||||||
onClose={() => setIsOpen(false)}
|
onClose={() => setIsOpen(false)}
|
||||||
ariaLabel={ariaLabel ?? 'Mobile menu'}
|
ariaLabel={ariaLabel ?? 'Mobile menu'}
|
||||||
>
|
>
|
||||||
@ -102,44 +101,64 @@ function MobileMenuButton({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function MobileMenuDrawer({
|
function MobileMenuOverlay({ onClose }: { onClose: () => void }): React.ReactElement {
|
||||||
isOpen,
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black bg-opacity-50 z-40 md:hidden"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobileMenuContent({
|
||||||
|
onClose,
|
||||||
|
ariaLabel,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
onClose: () => void
|
||||||
|
ariaLabel: string
|
||||||
|
children: ReactNode
|
||||||
|
}): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id="mobile-menu"
|
||||||
|
className="fixed top-0 right-0 h-full w-80 max-w-[85vw] bg-cyber-dark border-l border-neon-cyan/30 shadow-glow-cyan z-50 transform transition-transform md:hidden overflow-y-auto"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
>
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="flex justify-end mb-4">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-cyber-accent hover:text-neon-cyan text-2xl transition-colors focus:outline-none focus:ring-2 focus:ring-neon-cyan rounded"
|
||||||
|
aria-label="Close menu"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobileMenuDrawer({
|
||||||
onClose,
|
onClose,
|
||||||
ariaLabel,
|
ariaLabel,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
isOpen: boolean
|
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
ariaLabel: string
|
ariaLabel: string
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<MobileMenuOverlay onClose={onClose} />
|
||||||
className="fixed inset-0 bg-black bg-opacity-50 z-40 md:hidden"
|
<MobileMenuContent onClose={onClose} ariaLabel={ariaLabel}>
|
||||||
onClick={onClose}
|
{children}
|
||||||
aria-hidden="true"
|
</MobileMenuContent>
|
||||||
/>
|
|
||||||
<div
|
|
||||||
id="mobile-menu"
|
|
||||||
className="fixed top-0 right-0 h-full w-80 max-w-[85vw] bg-cyber-dark border-l border-neon-cyan/30 shadow-glow-cyan z-50 transform transition-transform md:hidden overflow-y-auto"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-label={ariaLabel}
|
|
||||||
>
|
|
||||||
<div className="p-4">
|
|
||||||
<div className="flex justify-end mb-4">
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="text-cyber-accent hover:text-neon-cyan text-2xl transition-colors focus:outline-none focus:ring-2 focus:ring-neon-cyan rounded"
|
|
||||||
aria-label="Close menu"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-4">{children}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -43,7 +43,7 @@ function ModalHeader({
|
|||||||
showCloseButton,
|
showCloseButton,
|
||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
title?: string
|
title?: string | undefined
|
||||||
showCloseButton: boolean
|
showCloseButton: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}): React.ReactElement | null {
|
}): React.ReactElement | null {
|
||||||
@ -58,32 +58,28 @@ function ModalHeader({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Modal({
|
function useModalFocus(modalRef: React.RefObject<HTMLDivElement | null>, isOpen: boolean): void {
|
||||||
children,
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
title,
|
|
||||||
size = 'medium',
|
|
||||||
showCloseButton = true,
|
|
||||||
'aria-label': ariaLabel,
|
|
||||||
}: ModalProps): React.ReactElement | null {
|
|
||||||
const modalRef = useRef<HTMLDivElement>(null)
|
|
||||||
const previousFocusRef = useRef<HTMLElement | null>(null)
|
const previousFocusRef = useRef<HTMLElement | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
previousFocusRef.current = document.activeElement as HTMLElement
|
const { activeElement } = document
|
||||||
|
if (activeElement instanceof HTMLElement) {
|
||||||
|
previousFocusRef.current = activeElement
|
||||||
|
}
|
||||||
const firstFocusable = modalRef.current?.querySelector(
|
const firstFocusable = modalRef.current?.querySelector(
|
||||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||||
) as HTMLElement | null
|
)
|
||||||
if (firstFocusable) {
|
if (firstFocusable instanceof HTMLElement) {
|
||||||
firstFocusable.focus()
|
firstFocusable.focus()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
previousFocusRef.current?.focus()
|
previousFocusRef.current?.focus()
|
||||||
}
|
}
|
||||||
}, [isOpen])
|
}, [isOpen, modalRef])
|
||||||
|
}
|
||||||
|
|
||||||
|
function useModalKeyboard(isOpen: boolean, onClose: () => void): void {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleEscape = (e: KeyboardEvent): void => {
|
const handleEscape = (e: KeyboardEvent): void => {
|
||||||
if (e.key === 'Escape' && isOpen) {
|
if (e.key === 'Escape' && isOpen) {
|
||||||
@ -101,13 +97,17 @@ export function Modal({
|
|||||||
document.body.style.overflow = ''
|
document.body.style.overflow = ''
|
||||||
}
|
}
|
||||||
}, [isOpen, onClose])
|
}, [isOpen, onClose])
|
||||||
|
}
|
||||||
|
|
||||||
if (!isOpen) {
|
function ModalOverlay({
|
||||||
return null
|
onClose,
|
||||||
}
|
ariaLabel,
|
||||||
|
children,
|
||||||
const sizeClasses = getSizeClasses(size)
|
}: {
|
||||||
|
onClose: () => void
|
||||||
|
ariaLabel: string | undefined
|
||||||
|
children: ReactNode
|
||||||
|
}): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
|
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
|
||||||
@ -118,16 +118,70 @@ export function Modal({
|
|||||||
}}
|
}}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label={ariaLabel ?? title}
|
aria-label={ariaLabel}
|
||||||
>
|
>
|
||||||
<div
|
{children}
|
||||||
ref={modalRef}
|
|
||||||
className={`bg-cyber-dark border border-neon-cyan/30 rounded-lg p-6 w-full mx-4 max-h-[90vh] overflow-y-auto shadow-glow-cyan ${sizeClasses}`}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<ModalHeader title={title} showCloseButton={showCloseButton} onClose={onClose} />
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ModalContent({
|
||||||
|
modalRef,
|
||||||
|
sizeClasses,
|
||||||
|
title,
|
||||||
|
showCloseButton,
|
||||||
|
onClose,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
modalRef: React.RefObject<HTMLDivElement | null>
|
||||||
|
sizeClasses: string
|
||||||
|
title?: string | undefined
|
||||||
|
showCloseButton: boolean
|
||||||
|
onClose: () => void
|
||||||
|
children: ReactNode
|
||||||
|
}): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={modalRef}
|
||||||
|
className={`bg-cyber-dark border border-neon-cyan/30 rounded-lg p-6 w-full mx-4 max-h-[90vh] overflow-y-auto shadow-glow-cyan ${sizeClasses}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<ModalHeader title={title} showCloseButton={showCloseButton} onClose={onClose} />
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Modal({
|
||||||
|
children,
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
title,
|
||||||
|
size = 'medium',
|
||||||
|
showCloseButton = true,
|
||||||
|
'aria-label': ariaLabel,
|
||||||
|
}: ModalProps): React.ReactElement | null {
|
||||||
|
const modalRef = useRef<HTMLDivElement>(null)
|
||||||
|
useModalFocus(modalRef, isOpen)
|
||||||
|
useModalKeyboard(isOpen, onClose)
|
||||||
|
|
||||||
|
if (!isOpen) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeClasses = getSizeClasses(size)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalOverlay onClose={onClose} ariaLabel={ariaLabel ?? title}>
|
||||||
|
<ModalContent
|
||||||
|
modalRef={modalRef}
|
||||||
|
sizeClasses={sizeClasses}
|
||||||
|
title={title}
|
||||||
|
showCloseButton={showCloseButton}
|
||||||
|
onClose={onClose}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ModalContent>
|
||||||
|
</ModalOverlay>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user