lint wip
This commit is contained in:
parent
9ad602d100
commit
2116ee4ffc
49
components/authorPage/AuthorPage.tsx
Normal file
49
components/authorPage/AuthorPage.tsx
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import Head from 'next/head'
|
||||||
|
import { useRouter } from 'next/router'
|
||||||
|
import { Footer } from '@/components/Footer'
|
||||||
|
import { PageHeader } from '@/components/PageHeader'
|
||||||
|
import { t } from '@/lib/i18n'
|
||||||
|
import { AuthorPageContent } from './AuthorPageContent'
|
||||||
|
import { resolveAuthorHashIdOrPubkey } from './resolveAuthorHashIdOrPubkey'
|
||||||
|
import { useAuthorData } from './useAuthorData'
|
||||||
|
|
||||||
|
export function AuthorPage(): React.ReactElement {
|
||||||
|
const router = useRouter()
|
||||||
|
const { pubkey } = router.query
|
||||||
|
const hashIdOrPubkey = resolveAuthorHashIdOrPubkey(pubkey)
|
||||||
|
|
||||||
|
const { presentation, series, totalSponsoring, loading, error, reload } = useAuthorData(hashIdOrPubkey ?? '')
|
||||||
|
const onSeriesCreated = (): void => {
|
||||||
|
void reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hashIdOrPubkey) {
|
||||||
|
return <div />
|
||||||
|
}
|
||||||
|
|
||||||
|
const actualAuthorPubkey = presentation?.pubkey ?? ''
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>{t('author.title')} - {t('home.title')}</title>
|
||||||
|
<meta name="description" content={t('author.presentation')} />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
</Head>
|
||||||
|
<main className="min-h-screen bg-cyber-darker">
|
||||||
|
<PageHeader />
|
||||||
|
<div className="w-full px-4 py-8">
|
||||||
|
<AuthorPageContent
|
||||||
|
presentation={presentation}
|
||||||
|
series={series}
|
||||||
|
totalSponsoring={totalSponsoring}
|
||||||
|
authorPubkey={actualAuthorPubkey}
|
||||||
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
onSeriesCreated={onSeriesCreated}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Footer />
|
||||||
|
</main>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
49
components/authorPage/AuthorPageContent.tsx
Normal file
49
components/authorPage/AuthorPageContent.tsx
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import { t } from '@/lib/i18n'
|
||||||
|
import type { AuthorPresentationArticle, Series } from '@/types/nostr'
|
||||||
|
import { AuthorPageHeader } from './AuthorPageHeader'
|
||||||
|
import { SponsoringSummary } from './SponsoringSummary'
|
||||||
|
import { SeriesList } from './SeriesList'
|
||||||
|
|
||||||
|
type AuthorPageContentProps = {
|
||||||
|
presentation: AuthorPresentationArticle | null
|
||||||
|
series: Series[]
|
||||||
|
totalSponsoring: number
|
||||||
|
authorPubkey: string
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
onSeriesCreated: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthorPageContent({
|
||||||
|
presentation,
|
||||||
|
series,
|
||||||
|
totalSponsoring,
|
||||||
|
authorPubkey,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
onSeriesCreated,
|
||||||
|
}: AuthorPageContentProps): React.ReactElement {
|
||||||
|
if (loading) {
|
||||||
|
return <p className="text-cyber-accent">{t('common.loading')}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <p className="text-red-400">{error}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!presentation) {
|
||||||
|
return (
|
||||||
|
<div className="bg-cyber-dark/50 border border-neon-cyan/20 rounded-lg p-6">
|
||||||
|
<p className="text-cyber-accent">{t('author.notFound')}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AuthorPageHeader presentation={presentation} />
|
||||||
|
<SponsoringSummary totalSponsoring={totalSponsoring} author={presentation} onSponsor={onSeriesCreated} />
|
||||||
|
<SeriesList series={series} authorPubkey={authorPubkey} onSeriesCreated={onSeriesCreated} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
62
components/authorPage/AuthorPageHeader.tsx
Normal file
62
components/authorPage/AuthorPageHeader.tsx
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import Image from 'next/image'
|
||||||
|
import { t } from '@/lib/i18n'
|
||||||
|
import type { AuthorPresentationArticle } from '@/types/nostr'
|
||||||
|
|
||||||
|
export function AuthorPageHeader(params: { presentation: AuthorPresentationArticle | null }): React.ReactElement | null {
|
||||||
|
if (!params.presentation) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const authorName = getAuthorNameFromPresentationTitle(params.presentation.title)
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 mb-8">
|
||||||
|
<div className="flex items-start gap-6">
|
||||||
|
<AuthorProfileImage bannerUrl={params.presentation.bannerUrl} />
|
||||||
|
<div className="flex-1 space-y-4">
|
||||||
|
<AuthorHeaderTitle authorName={authorName} />
|
||||||
|
<AuthorPresentationSection title={t('presentation.field.presentation')} text={params.presentation.description} />
|
||||||
|
<AuthorPresentationSection title={t('presentation.field.contentDescription')} text={params.presentation.contentDescription} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAuthorNameFromPresentationTitle(title: string): string {
|
||||||
|
const trimmed = title.replace(/^Présentation de /, '').trim()
|
||||||
|
return trimmed.length > 0 ? trimmed : title
|
||||||
|
}
|
||||||
|
|
||||||
|
function AuthorProfileImage(params: { bannerUrl: string | undefined }): React.ReactElement | null {
|
||||||
|
if (!params.bannerUrl) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="relative w-32 h-32 rounded-lg overflow-hidden border border-neon-cyan/20 flex-shrink-0">
|
||||||
|
<Image src={params.bannerUrl} alt={t('author.profilePicture')} fill className="object-cover" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AuthorHeaderTitle(params: { authorName: string }): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-neon-cyan mb-2">{params.authorName}</h1>
|
||||||
|
<p className="text-xs text-cyber-accent/60 italic mb-4">{t('author.profileNote')}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AuthorPresentationSection(params: { title: string; text: string | undefined }): React.ReactElement | null {
|
||||||
|
if (!params.text) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h2 className="text-lg font-semibold text-neon-cyan">{params.title}</h2>
|
||||||
|
<div className="prose prose-invert max-w-none">
|
||||||
|
<p className="text-cyber-accent whitespace-pre-wrap">{params.text}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
63
components/authorPage/SeriesList.tsx
Normal file
63
components/authorPage/SeriesList.tsx
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import Link from 'next/link'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { CreateSeriesModal } from '@/components/CreateSeriesModal'
|
||||||
|
import { SeriesCard } from '@/components/SeriesCard'
|
||||||
|
import { useNostrAuth } from '@/hooks/useNostrAuth'
|
||||||
|
import { t } from '@/lib/i18n'
|
||||||
|
import type { Series } from '@/types/nostr'
|
||||||
|
|
||||||
|
export function SeriesList(params: { series: Series[]; authorPubkey: string; onSeriesCreated: () => void }): React.ReactElement {
|
||||||
|
const { pubkey, isUnlocked } = useNostrAuth()
|
||||||
|
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||||
|
const isAuthor = pubkey === params.authorPubkey && isUnlocked
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<SeriesListHeader isAuthor={isAuthor} onCreate={() => setShowCreateModal(true)} />
|
||||||
|
<SeriesGrid series={params.series} />
|
||||||
|
<CreateSeriesModal
|
||||||
|
isOpen={showCreateModal}
|
||||||
|
onClose={() => setShowCreateModal(false)}
|
||||||
|
onSuccess={params.onSeriesCreated}
|
||||||
|
authorPubkey={params.authorPubkey}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SeriesListHeader(params: { isAuthor: boolean; onCreate: () => void }): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-2xl font-semibold text-neon-cyan">{t('series.title')}</h2>
|
||||||
|
{params.isAuthor && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={params.onCreate}
|
||||||
|
className="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"
|
||||||
|
>
|
||||||
|
{t('series.create.button')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SeriesGrid(params: { series: Series[] }): React.ReactElement {
|
||||||
|
if (params.series.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bg-cyber-dark/50 border border-neon-cyan/20 rounded-lg p-6">
|
||||||
|
<p className="text-cyber-accent">{t('series.empty')}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{params.series.map((s) => (
|
||||||
|
<Link key={s.id} href={`/series/${s.id}`}>
|
||||||
|
<SeriesCard series={s} onSelect={() => {}} />
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
71
components/authorPage/SponsoringSummary.tsx
Normal file
71
components/authorPage/SponsoringSummary.tsx
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { SponsoringForm } from '@/components/SponsoringForm'
|
||||||
|
import { t } from '@/lib/i18n'
|
||||||
|
import type { AuthorPresentationArticle } from '@/types/nostr'
|
||||||
|
|
||||||
|
type SponsoringSummaryProps = {
|
||||||
|
totalSponsoring: number
|
||||||
|
author: AuthorPresentationArticle | null
|
||||||
|
onSponsor: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SponsoringSummary({ totalSponsoring, author, onSponsor }: SponsoringSummaryProps): React.ReactElement {
|
||||||
|
const totalBTC = totalSponsoring / 100_000_000
|
||||||
|
const [showForm, setShowForm] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-cyber-dark/50 border border-neon-cyan/20 rounded-lg p-6 mb-8">
|
||||||
|
<SponsoringSummaryHeader showSponsorButton={author !== null} onSponsorClick={() => setShowForm(true)} />
|
||||||
|
<SponsoringTotals totalBTC={totalBTC} totalSats={totalSponsoring} />
|
||||||
|
<SponsoringFormPanel show={showForm} author={author} onClose={() => setShowForm(false)} onSponsor={onSponsor} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SponsoringSummaryHeader(params: { showSponsorButton: boolean; onSponsorClick: () => void }): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-xl font-semibold text-neon-cyan">{t('author.sponsoring')}</h2>
|
||||||
|
{params.showSponsorButton && (
|
||||||
|
<button
|
||||||
|
onClick={params.onSponsorClick}
|
||||||
|
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('sponsoring.form.submit')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SponsoringTotals(params: { totalBTC: number; totalSats: number }): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-cyber-accent">{t('author.sponsoring.total', { amount: params.totalBTC.toFixed(6) })}</p>
|
||||||
|
<p className="text-cyber-accent">{t('author.sponsoring.sats', { amount: params.totalSats.toLocaleString() })}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SponsoringFormPanel(params: {
|
||||||
|
show: boolean
|
||||||
|
author: AuthorPresentationArticle | null
|
||||||
|
onClose: () => void
|
||||||
|
onSponsor: () => void
|
||||||
|
}): React.ReactElement | null {
|
||||||
|
if (!params.show || !params.author) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="mt-4">
|
||||||
|
<SponsoringForm
|
||||||
|
author={params.author}
|
||||||
|
onSuccess={() => {
|
||||||
|
params.onClose()
|
||||||
|
params.onSponsor()
|
||||||
|
}}
|
||||||
|
onCancel={params.onClose}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
19
components/authorPage/resolveAuthorHashIdOrPubkey.ts
Normal file
19
components/authorPage/resolveAuthorHashIdOrPubkey.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { parseObjectUrl } from '@/lib/urlGenerator'
|
||||||
|
|
||||||
|
export function resolveAuthorHashIdOrPubkey(pubkeyParam: string | string[] | undefined): string | null {
|
||||||
|
if (typeof pubkeyParam !== 'string') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const urlMatch = pubkeyParam.match(/^([a-f0-9]+)_(\d+)_(\d+)$/i)
|
||||||
|
if (urlMatch?.[1]) {
|
||||||
|
return urlMatch[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedUrl = parseObjectUrl(`https://zapwall.fr/author/${pubkeyParam}`)
|
||||||
|
if (parsedUrl.objectType === 'author' && parsedUrl.idHash) {
|
||||||
|
return parsedUrl.idHash
|
||||||
|
}
|
||||||
|
|
||||||
|
return pubkeyParam
|
||||||
|
}
|
||||||
70
components/authorPage/useAuthorData.ts
Normal file
70
components/authorPage/useAuthorData.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { fetchAuthorByHashId } from '@/lib/authorQueries'
|
||||||
|
import { getSeriesByAuthor } from '@/lib/seriesQueries'
|
||||||
|
import { getAuthorSponsoring } from '@/lib/sponsoring'
|
||||||
|
import { nostrService } from '@/lib/nostr'
|
||||||
|
import type { AuthorPresentationArticle, Series } from '@/types/nostr'
|
||||||
|
|
||||||
|
async function loadAuthorData(hashId: string): Promise<{
|
||||||
|
pres: AuthorPresentationArticle | null
|
||||||
|
seriesList: Series[]
|
||||||
|
sponsoring: number
|
||||||
|
}> {
|
||||||
|
const pool = nostrService.getPool()
|
||||||
|
if (!pool) {
|
||||||
|
throw new Error('Pool not initialized')
|
||||||
|
}
|
||||||
|
|
||||||
|
const pres = await fetchAuthorByHashId(pool, hashId)
|
||||||
|
if (!pres) {
|
||||||
|
return { pres: null, seriesList: [], sponsoring: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const [seriesList, sponsoring] = await Promise.all([
|
||||||
|
getSeriesByAuthor(pres.pubkey),
|
||||||
|
getAuthorSponsoring(pres.pubkey),
|
||||||
|
])
|
||||||
|
|
||||||
|
return { pres, seriesList, sponsoring }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuthorData(hashIdOrPubkey: string): {
|
||||||
|
presentation: AuthorPresentationArticle | null
|
||||||
|
series: Series[]
|
||||||
|
totalSponsoring: number
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
reload: () => Promise<void>
|
||||||
|
} {
|
||||||
|
const [presentation, setPresentation] = useState<AuthorPresentationArticle | null>(null)
|
||||||
|
const [series, setSeries] = useState<Series[]>([])
|
||||||
|
const [totalSponsoring, setTotalSponsoring] = useState<number>(0)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const reload = useCallback(async (): Promise<void> => {
|
||||||
|
if (!hashIdOrPubkey) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { pres, seriesList, sponsoring } = await loadAuthorData(hashIdOrPubkey)
|
||||||
|
setPresentation(pres)
|
||||||
|
setSeries(seriesList)
|
||||||
|
setTotalSponsoring(sponsoring)
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Erreur lors du chargement')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [hashIdOrPubkey])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void reload()
|
||||||
|
}, [hashIdOrPubkey, reload])
|
||||||
|
|
||||||
|
return { presentation, series, totalSponsoring, loading, error, reload }
|
||||||
|
}
|
||||||
@ -99,7 +99,7 @@ function UrlEditor(params: { relay: RelayConfig; onStop: () => void; onUpdate: (
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function LastSync(params: { lastSyncDate: string }): React.ReactElement {
|
function LastSync(params: { lastSyncDate: number }): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<div className="text-xs text-cyber-accent/70 mt-1">
|
<div className="text-xs text-cyber-accent/70 mt-1">
|
||||||
{t('settings.relay.list.lastSync')}: {new Date(params.lastSyncDate).toLocaleString()}
|
{t('settings.relay.list.lastSync')}: {new Date(params.lastSyncDate).toLocaleString()}
|
||||||
|
|||||||
44
fixKnowledge/2026-01-13-lint-max-lines-author-page.md
Normal file
44
fixKnowledge/2026-01-13-lint-max-lines-author-page.md
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
## Contexte
|
||||||
|
|
||||||
|
ESLint signalait un `max-lines` sur `pages/author/[pubkey].tsx` (fichier trop long). Le reste du projet passait.
|
||||||
|
|
||||||
|
## Root cause
|
||||||
|
|
||||||
|
La page regroupait dans un seul fichier :
|
||||||
|
- logique de chargement (hook + requêtes)
|
||||||
|
- composants de présentation (header, sponsoring, liste de séries)
|
||||||
|
- parsing de l’URL (compatibilité ancien format vs hash)
|
||||||
|
|
||||||
|
Ce regroupement dépassait la limite `max-lines` (250).
|
||||||
|
|
||||||
|
## Correctif
|
||||||
|
|
||||||
|
Refactor purement structurel (sans changement fonctionnel) :
|
||||||
|
- extraction de la logique et des sous-composants dans `components/authorPage/*`
|
||||||
|
- remplacement de `pages/author/[pubkey].tsx` par un simple re-export du composant page
|
||||||
|
|
||||||
|
## Pages / fichiers affectés
|
||||||
|
|
||||||
|
- `pages/author/[pubkey].tsx`
|
||||||
|
- `components/authorPage/AuthorPage.tsx`
|
||||||
|
- `components/authorPage/AuthorPageContent.tsx`
|
||||||
|
- `components/authorPage/AuthorPageHeader.tsx`
|
||||||
|
- `components/authorPage/SponsoringSummary.tsx`
|
||||||
|
- `components/authorPage/SeriesList.tsx`
|
||||||
|
- `components/authorPage/useAuthorData.ts`
|
||||||
|
- `components/authorPage/resolveAuthorHashIdOrPubkey.ts`
|
||||||
|
- `pages/api/nip95-upload.ts` (shim de compatibilité pour la validation Next/TypeScript)
|
||||||
|
- `lib/metadataExtractor/reviewTip.ts` (validation stricte des champs requis)
|
||||||
|
- `lib/paymentNotes/sponsoring.ts` (exactOptionalPropertyTypes)
|
||||||
|
- `components/relayManager/RelayCard.tsx` (typage lastSyncDate)
|
||||||
|
- `lib/keyManagementTwoLevel/crypto.ts` (BufferSource)
|
||||||
|
|
||||||
|
## Vérification
|
||||||
|
|
||||||
|
- `npm run lint` (doit sortir en succès, sans erreur `max-lines` sur `pages/author/[pubkey].tsx`)
|
||||||
|
- `npm run type-check` (doit sortir en succès)
|
||||||
|
|
||||||
|
## Risques / régressions possibles
|
||||||
|
|
||||||
|
- erreurs d’import/chemins suite au déplacement de code
|
||||||
|
- oubli d’un export explicite ou d’un type de retour (règles TypeScript/ESLint strictes)
|
||||||
@ -36,7 +36,8 @@ export async function importKEK(keyBytes: Uint8Array): Promise<CryptoKey> {
|
|||||||
|
|
||||||
export async function encryptWithAesGcm(params: { key: CryptoKey; plaintext: Uint8Array }): Promise<{ iv: Uint8Array; ciphertext: Uint8Array }> {
|
export async function encryptWithAesGcm(params: { key: CryptoKey; plaintext: Uint8Array }): Promise<{ iv: Uint8Array; ciphertext: Uint8Array }> {
|
||||||
const iv = globalThis.crypto.getRandomValues(new Uint8Array(12))
|
const iv = globalThis.crypto.getRandomValues(new Uint8Array(12))
|
||||||
const encrypted = await globalThis.crypto.subtle.encrypt({ name: 'AES-GCM', iv }, params.key, params.plaintext)
|
const plaintext = new Uint8Array(params.plaintext)
|
||||||
|
const encrypted = await globalThis.crypto.subtle.encrypt({ name: 'AES-GCM', iv }, params.key, plaintext)
|
||||||
return { iv, ciphertext: new Uint8Array(encrypted) }
|
return { iv, ciphertext: new Uint8Array(encrypted) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -5,6 +5,7 @@ interface RenderState {
|
|||||||
currentList: Array<{ key: string; line: string }>
|
currentList: Array<{ key: string; line: string }>
|
||||||
inCodeBlock: boolean
|
inCodeBlock: boolean
|
||||||
codeBlockContent: string[]
|
codeBlockContent: string[]
|
||||||
|
keyCounts: Map<string, number>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderMarkdown(markdown: string): React.ReactElement[] {
|
export function renderMarkdown(markdown: string): React.ReactElement[] {
|
||||||
@ -14,6 +15,7 @@ export function renderMarkdown(markdown: string): React.ReactElement[] {
|
|||||||
currentList: [],
|
currentList: [],
|
||||||
inCodeBlock: false,
|
inCodeBlock: false,
|
||||||
codeBlockContent: [],
|
codeBlockContent: [],
|
||||||
|
keyCounts: new Map<string, number>(),
|
||||||
}
|
}
|
||||||
|
|
||||||
lines.forEach((line, index) => {
|
lines.forEach((line, index) => {
|
||||||
@ -38,36 +40,36 @@ function processLine(line: string, index: number, state: RenderState, elements:
|
|||||||
|
|
||||||
closeListIfNeeded(line, index, state, elements)
|
closeListIfNeeded(line, index, state, elements)
|
||||||
|
|
||||||
if (renderHeading(line, index, elements)) {
|
if (renderHeading(line, index, state, elements)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (renderListLine(line, index, state)) {
|
if (renderListLine(line, index, state)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (renderLinkLine(line, index, elements)) {
|
if (renderLinkLine(line, index, state, elements)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (renderBoldAndCodeLine(line, index, elements)) {
|
if (renderBoldAndCodeLine(line, index, state, elements)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
renderParagraphOrBreak(line, index, elements)
|
renderParagraphOrBreak(line, index, state, elements)
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderHeading(line: string, index: number, elements: React.ReactElement[]): boolean {
|
function renderHeading(line: string, index: number, state: RenderState, elements: React.ReactElement[]): boolean {
|
||||||
if (line.startsWith('# ')) {
|
if (line.startsWith('# ')) {
|
||||||
elements.push(<h1 key={index} className="text-3xl font-bold mt-8 mb-4 text-neon-cyan font-mono">{line.substring(2)}</h1>)
|
elements.push(<h1 key={nextElementKey(state, 'h1', line)} className="text-3xl font-bold mt-8 mb-4 text-neon-cyan font-mono">{line.substring(2)}</h1>)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if (line.startsWith('## ')) {
|
if (line.startsWith('## ')) {
|
||||||
elements.push(<h2 key={index} className="text-2xl font-bold mt-6 mb-3 text-neon-cyan font-mono">{line.substring(3)}</h2>)
|
elements.push(<h2 key={nextElementKey(state, 'h2', line)} className="text-2xl font-bold mt-6 mb-3 text-neon-cyan font-mono">{line.substring(3)}</h2>)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if (line.startsWith('### ')) {
|
if (line.startsWith('### ')) {
|
||||||
elements.push(<h3 key={index} className="text-xl font-semibold mt-4 mb-2 text-neon-cyan font-mono">{line.substring(4)}</h3>)
|
elements.push(<h3 key={nextElementKey(state, 'h3', line)} className="text-xl font-semibold mt-4 mb-2 text-neon-cyan font-mono">{line.substring(4)}</h3>)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if (line.startsWith('#### ')) {
|
if (line.startsWith('#### ')) {
|
||||||
elements.push(<h4 key={index} className="text-lg font-semibold mt-3 mb-2 text-neon-cyan font-mono">{line.substring(5)}</h4>)
|
elements.push(<h4 key={nextElementKey(state, 'h4', line)} className="text-lg font-semibold mt-3 mb-2 text-neon-cyan font-mono">{line.substring(5)}</h4>)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
@ -75,37 +77,37 @@ function renderHeading(line: string, index: number, elements: React.ReactElement
|
|||||||
|
|
||||||
function renderListLine(line: string, index: number, state: RenderState): boolean {
|
function renderListLine(line: string, index: number, state: RenderState): boolean {
|
||||||
if (line.startsWith('- ') || line.startsWith('* ')) {
|
if (line.startsWith('- ') || line.startsWith('* ')) {
|
||||||
state.currentList.push({ key: `li-${index}-${line}`, line })
|
state.currentList.push({ key: nextElementKey(state, 'li', line), line })
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderLinkLine(line: string, index: number, elements: React.ReactElement[]): boolean {
|
function renderLinkLine(line: string, index: number, state: RenderState, elements: React.ReactElement[]): boolean {
|
||||||
if (line.includes('[') && line.includes('](')) {
|
if (line.includes('[') && line.includes('](')) {
|
||||||
renderLink(line, index, elements)
|
renderLink(line, index, state, elements)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderBoldAndCodeLine(line: string, index: number, elements: React.ReactElement[]): boolean {
|
function renderBoldAndCodeLine(line: string, index: number, state: RenderState, elements: React.ReactElement[]): boolean {
|
||||||
if (line.includes('**') || line.includes('`')) {
|
if (line.includes('**') || line.includes('`')) {
|
||||||
renderBoldAndCode(line, index, elements)
|
renderBoldAndCode(line, index, state, elements)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderParagraphOrBreak(line: string, index: number, elements: React.ReactElement[]): void {
|
function renderParagraphOrBreak(line: string, index: number, state: RenderState, elements: React.ReactElement[]): void {
|
||||||
if (line.trim() !== '') {
|
if (line.trim() !== '') {
|
||||||
elements.push(<p key={index} className="mb-4 text-cyber-accent">{line}</p>)
|
elements.push(<p key={nextElementKey(state, 'p', line)} className="mb-4 text-cyber-accent">{line}</p>)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (elements.length > 0) {
|
if (elements.length > 0) {
|
||||||
const last = elements[elements.length - 1] as { type?: unknown }
|
const last = elements[elements.length - 1] as { type?: unknown }
|
||||||
if (last?.type !== 'br') {
|
if (last?.type !== 'br') {
|
||||||
elements.push(<br key={`br-${index}`} />)
|
elements.push(<br key={nextElementKey(state, 'br', 'br')} />)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -119,7 +121,7 @@ function handleCodeBlock(
|
|||||||
const nextState = state
|
const nextState = state
|
||||||
if (state.inCodeBlock) {
|
if (state.inCodeBlock) {
|
||||||
elements.push(
|
elements.push(
|
||||||
<pre key={`code-${index}`} className="bg-cyber-darker border border-neon-cyan/20 p-4 rounded-lg overflow-x-auto my-4 text-neon-cyan font-mono text-sm">
|
<pre key={nextElementKey(state, 'code', state.codeBlockContent.join('\n'))} className="bg-cyber-darker border border-neon-cyan/20 p-4 rounded-lg overflow-x-auto my-4 text-neon-cyan font-mono text-sm">
|
||||||
<code>{state.codeBlockContent.join('\n')}</code>
|
<code>{state.codeBlockContent.join('\n')}</code>
|
||||||
</pre>
|
</pre>
|
||||||
)
|
)
|
||||||
@ -138,8 +140,9 @@ function closeListIfNeeded(
|
|||||||
): void {
|
): void {
|
||||||
const nextState = state
|
const nextState = state
|
||||||
if (state.currentList.length > 0 && !line.startsWith('- ') && !line.startsWith('* ') && line.trim() !== '') {
|
if (state.currentList.length > 0 && !line.startsWith('- ') && !line.startsWith('* ') && line.trim() !== '') {
|
||||||
|
const keySeed = `${state.currentList[0]?.key ?? ''}-${state.currentList.length}-${index}`
|
||||||
elements.push(
|
elements.push(
|
||||||
<ul key={`list-${index}`} className="list-disc list-inside mb-4 space-y-1 text-cyber-accent marker:text-neon-cyan">
|
<ul key={nextElementKey(state, 'list', keySeed)} className="list-disc list-inside mb-4 space-y-1 text-cyber-accent marker:text-neon-cyan">
|
||||||
{state.currentList.map((item) => (
|
{state.currentList.map((item) => (
|
||||||
<li key={item.key} className="ml-4">{item.line.substring(2).trim()}</li>
|
<li key={item.key} className="ml-4">{item.line.substring(2).trim()}</li>
|
||||||
))}
|
))}
|
||||||
@ -170,7 +173,7 @@ function createLinkElement(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderLink(line: string, index: number, elements: React.ReactElement[]): void {
|
function renderLink(line: string, index: number, state: RenderState, elements: React.ReactElement[]): void {
|
||||||
const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g
|
const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g
|
||||||
let lastIndex = 0
|
let lastIndex = 0
|
||||||
const parts: (string | React.ReactElement)[] = []
|
const parts: (string | React.ReactElement)[] = []
|
||||||
@ -194,10 +197,10 @@ function renderLink(line: string, index: number, elements: React.ReactElement[])
|
|||||||
parts.push(line.substring(lastIndex))
|
parts.push(line.substring(lastIndex))
|
||||||
}
|
}
|
||||||
|
|
||||||
elements.push(<p key={index} className="mb-4">{parts}</p>)
|
elements.push(<p key={nextElementKey(state, 'p', line)} className="mb-4">{parts}</p>)
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderBoldAndCode(line: string, index: number, elements: React.ReactElement[]): void {
|
function renderBoldAndCode(line: string, index: number, state: RenderState, elements: React.ReactElement[]): void {
|
||||||
const parts: (string | React.ReactElement)[] = []
|
const parts: (string | React.ReactElement)[] = []
|
||||||
const codeRegex = /`([^`]+)`/g
|
const codeRegex = /`([^`]+)`/g
|
||||||
let codeMatch
|
let codeMatch
|
||||||
@ -221,7 +224,7 @@ function renderBoldAndCode(line: string, index: number, elements: React.ReactEle
|
|||||||
processBold(remaining, parts)
|
processBold(remaining, parts)
|
||||||
}
|
}
|
||||||
|
|
||||||
elements.push(<p key={index} className="mb-4">{parts.length > 0 ? parts : line}</p>)
|
elements.push(<p key={nextElementKey(state, 'p', line)} className="mb-4">{parts.length > 0 ? parts : line}</p>)
|
||||||
}
|
}
|
||||||
|
|
||||||
function processBold(text: string, parts: (string | React.ReactElement)[]): void {
|
function processBold(text: string, parts: (string | React.ReactElement)[]): void {
|
||||||
@ -237,3 +240,15 @@ function processBold(text: string, parts: (string | React.ReactElement)[]): void
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function nextElementKey(state: RenderState, prefix: string, raw: string): string {
|
||||||
|
const base = buildKeyBase(prefix, raw)
|
||||||
|
const nextCount = (state.keyCounts.get(base) ?? 0) + 1
|
||||||
|
state.keyCounts.set(base, nextCount)
|
||||||
|
return `${base}-${nextCount}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildKeyBase(prefix: string, raw: string): string {
|
||||||
|
const normalized = raw.trim().replace(/\s+/g, ' ').slice(0, 80)
|
||||||
|
return `${prefix}-${normalized}`
|
||||||
|
}
|
||||||
|
|||||||
@ -26,15 +26,49 @@ function readReviewTipFields(event: Event): Omit<ExtractedReviewTip, 'type' | 'i
|
|||||||
const articleId = readTagValue(event, 'article')
|
const articleId = readTagValue(event, 'article')
|
||||||
const reviewId = readTagValue(event, 'review_id') ?? readTagValue(event, 'e')
|
const reviewId = readTagValue(event, 'review_id') ?? readTagValue(event, 'e')
|
||||||
|
|
||||||
const required = { payerPubkey, reviewerPubkey, authorPubkey, articleId, reviewId }
|
const required = { payerPubkey, reviewerPubkey, authorPubkey, articleId, reviewId, paymentHash }
|
||||||
if (!areAllNonEmptyStrings(required) || amount === undefined) {
|
if (!hasRequiredReviewTipFields(required) || amount === undefined) {
|
||||||
console.error('[metadataExtractor] Invalid review_tip zap receipt: missing required fields', { eventId: event.id })
|
console.error('[metadataExtractor] Invalid review_tip zap receipt: missing required fields', { eventId: event.id })
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ...required, amount, paymentHash }
|
return {
|
||||||
|
payerPubkey: required.payerPubkey,
|
||||||
|
reviewerPubkey: required.reviewerPubkey,
|
||||||
|
authorPubkey: required.authorPubkey,
|
||||||
|
articleId: required.articleId,
|
||||||
|
reviewId: required.reviewId,
|
||||||
|
amount,
|
||||||
|
paymentHash: required.paymentHash,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function areAllNonEmptyStrings(values: Record<string, string | undefined>): values is Record<string, string> {
|
function hasRequiredReviewTipFields(values: {
|
||||||
return Object.values(values).every((value) => typeof value === 'string' && value.length > 0)
|
payerPubkey: string | undefined
|
||||||
|
reviewerPubkey: string | undefined
|
||||||
|
authorPubkey: string | undefined
|
||||||
|
articleId: string | undefined
|
||||||
|
reviewId: string | undefined
|
||||||
|
paymentHash: string | undefined
|
||||||
|
}): values is {
|
||||||
|
payerPubkey: string
|
||||||
|
reviewerPubkey: string
|
||||||
|
authorPubkey: string
|
||||||
|
articleId: string
|
||||||
|
reviewId: string
|
||||||
|
paymentHash: string
|
||||||
|
} {
|
||||||
|
const requiredKeys: ReadonlyArray<keyof typeof values> = [
|
||||||
|
'payerPubkey',
|
||||||
|
'reviewerPubkey',
|
||||||
|
'authorPubkey',
|
||||||
|
'articleId',
|
||||||
|
'reviewId',
|
||||||
|
'paymentHash',
|
||||||
|
]
|
||||||
|
return requiredKeys.every((key) => isNonEmptyString(values[key]))
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNonEmptyString(value: unknown): value is string {
|
||||||
|
return typeof value === 'string' && value.length > 0
|
||||||
}
|
}
|
||||||
|
|||||||
@ -47,7 +47,7 @@ async function buildSponsoringNotePayload(params: {
|
|||||||
const hashId = await generateSponsoringHashId(sponsoringData)
|
const hashId = await generateSponsoringHashId(sponsoringData)
|
||||||
const id = buildObjectId(hashId, 0, 0)
|
const id = buildObjectId(hashId, 0, 0)
|
||||||
const tags = buildSponsoringNoteTags({ ...params, hashId })
|
const tags = buildSponsoringNoteTags({ ...params, hashId })
|
||||||
tags.push(['json', buildSponsoringPaymentJson({ id, hashId, sponsoringData, text: params.text, transactionId: params.transactionId })])
|
tags.push(['json', buildSponsoringPaymentJson(buildSponsoringPaymentJsonInput({ id, hashId, sponsoringData, text: params.text, transactionId: params.transactionId }))])
|
||||||
const parsedSponsoring = buildParsedSponsoring({ ...params, id, hashId })
|
const parsedSponsoring = buildParsedSponsoring({ ...params, id, hashId })
|
||||||
return { hashId, eventTemplate: buildSponsoringEventTemplate({ tags, content: buildSponsoringNoteContent(params) }), parsedSponsoring }
|
return { hashId, eventTemplate: buildSponsoringEventTemplate({ tags, content: buildSponsoringNoteContent(params) }), parsedSponsoring }
|
||||||
}
|
}
|
||||||
@ -160,3 +160,19 @@ function buildSponsoringPaymentJson(params: {
|
|||||||
...(params.transactionId ? { transactionId: params.transactionId } : {}),
|
...(params.transactionId ? { transactionId: params.transactionId } : {}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildSponsoringPaymentJsonInput(params: {
|
||||||
|
id: string
|
||||||
|
hashId: string
|
||||||
|
sponsoringData: Record<string, unknown>
|
||||||
|
text: string | undefined
|
||||||
|
transactionId: string | undefined
|
||||||
|
}): { id: string; hashId: string; sponsoringData: Record<string, unknown>; text?: string; transactionId?: string } {
|
||||||
|
return {
|
||||||
|
id: params.id,
|
||||||
|
hashId: params.hashId,
|
||||||
|
sponsoringData: params.sponsoringData,
|
||||||
|
...(params.text ? { text: params.text } : {}),
|
||||||
|
...(params.transactionId ? { transactionId: params.transactionId } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
1
pages/api/nip95-upload.ts
Normal file
1
pages/api/nip95-upload.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { config, default } from './nip95-upload/index'
|
||||||
@ -1,355 +1 @@
|
|||||||
import { useRouter } from 'next/router'
|
export { AuthorPage as default } from '@/components/authorPage/AuthorPage'
|
||||||
import Head from 'next/head'
|
|
||||||
import { useEffect, useState, useCallback } from 'react'
|
|
||||||
import { fetchAuthorByHashId } from '@/lib/authorQueries'
|
|
||||||
import { getSeriesByAuthor } from '@/lib/seriesQueries'
|
|
||||||
import { getAuthorSponsoring } from '@/lib/sponsoring'
|
|
||||||
import { nostrService } from '@/lib/nostr'
|
|
||||||
import type { AuthorPresentationArticle, Series } from '@/types/nostr'
|
|
||||||
import { PageHeader } from '@/components/PageHeader'
|
|
||||||
import { Footer } from '@/components/Footer'
|
|
||||||
import { t } from '@/lib/i18n'
|
|
||||||
import Link from 'next/link'
|
|
||||||
import { SeriesCard } from '@/components/SeriesCard'
|
|
||||||
import Image from 'next/image'
|
|
||||||
import { CreateSeriesModal } from '@/components/CreateSeriesModal'
|
|
||||||
import { useNostrAuth } from '@/hooks/useNostrAuth'
|
|
||||||
import { parseObjectUrl } from '@/lib/urlGenerator'
|
|
||||||
import { SponsoringForm } from '@/components/SponsoringForm'
|
|
||||||
|
|
||||||
function AuthorPageHeader({ presentation }: { presentation: AuthorPresentationArticle | null }): React.ReactElement | null {
|
|
||||||
if (!presentation) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
const authorName = getAuthorNameFromPresentationTitle(presentation.title)
|
|
||||||
return (
|
|
||||||
<div className="space-y-4 mb-8">
|
|
||||||
<div className="flex items-start gap-6">
|
|
||||||
<AuthorProfileImage {...(presentation.bannerUrl ? { bannerUrl: presentation.bannerUrl } : {})} />
|
|
||||||
<div className="flex-1 space-y-4">
|
|
||||||
<AuthorHeaderTitle authorName={authorName} />
|
|
||||||
<AuthorPresentationSection title={t('presentation.field.presentation')} text={presentation.description} />
|
|
||||||
<AuthorPresentationSection title={t('presentation.field.contentDescription')} text={presentation.contentDescription} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAuthorNameFromPresentationTitle(title: string): string {
|
|
||||||
const trimmed = title.replace(/^Présentation de /, '').trim()
|
|
||||||
return trimmed.length > 0 ? trimmed : title
|
|
||||||
}
|
|
||||||
|
|
||||||
function AuthorProfileImage(params: { bannerUrl?: string }): React.ReactElement | null {
|
|
||||||
if (!params.bannerUrl) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="relative w-32 h-32 rounded-lg overflow-hidden border border-neon-cyan/20 flex-shrink-0">
|
|
||||||
<Image src={params.bannerUrl} alt={t('author.profilePicture')} fill className="object-cover" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AuthorHeaderTitle(params: { authorName: string }): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold text-neon-cyan mb-2">{params.authorName}</h1>
|
|
||||||
<p className="text-xs text-cyber-accent/60 italic mb-4">{t('author.profileNote')}</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AuthorPresentationSection(params: { title: string; text: string | undefined }): React.ReactElement | null {
|
|
||||||
if (!params.text) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h2 className="text-lg font-semibold text-neon-cyan">{params.title}</h2>
|
|
||||||
<div className="prose prose-invert max-w-none">
|
|
||||||
<p className="text-cyber-accent whitespace-pre-wrap">{params.text}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
type SponsoringSummaryProps = {
|
|
||||||
totalSponsoring: number
|
|
||||||
author: AuthorPresentationArticle | null
|
|
||||||
onSponsor: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
function SponsoringSummary({ totalSponsoring, author, onSponsor }: SponsoringSummaryProps): React.ReactElement {
|
|
||||||
const totalBTC = totalSponsoring / 100_000_000
|
|
||||||
const [showForm, setShowForm] = useState(false)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-cyber-dark/50 border border-neon-cyan/20 rounded-lg p-6 mb-8">
|
|
||||||
<SponsoringSummaryHeader showSponsorButton={author !== null} onSponsorClick={() => setShowForm(true)} />
|
|
||||||
<SponsoringTotals totalBTC={totalBTC} totalSats={totalSponsoring} />
|
|
||||||
<SponsoringFormPanel show={showForm} author={author} onClose={() => setShowForm(false)} onSponsor={onSponsor} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SponsoringSummaryHeader(params: { showSponsorButton: boolean; onSponsorClick: () => void }): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h2 className="text-xl font-semibold text-neon-cyan">{t('author.sponsoring')}</h2>
|
|
||||||
{params.showSponsorButton && (
|
|
||||||
<button
|
|
||||||
onClick={params.onSponsorClick}
|
|
||||||
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('sponsoring.form.submit')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SponsoringTotals(params: { totalBTC: number; totalSats: number }): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-cyber-accent">{t('author.sponsoring.total', { amount: params.totalBTC.toFixed(6) })}</p>
|
|
||||||
<p className="text-cyber-accent">{t('author.sponsoring.sats', { amount: params.totalSats.toLocaleString() })}</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SponsoringFormPanel(params: {
|
|
||||||
show: boolean
|
|
||||||
author: AuthorPresentationArticle | null
|
|
||||||
onClose: () => void
|
|
||||||
onSponsor: () => void
|
|
||||||
}): React.ReactElement | null {
|
|
||||||
if (!params.show || !params.author) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="mt-4">
|
|
||||||
<SponsoringForm
|
|
||||||
author={params.author}
|
|
||||||
onSuccess={() => {
|
|
||||||
params.onClose()
|
|
||||||
params.onSponsor()
|
|
||||||
}}
|
|
||||||
onCancel={params.onClose}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SeriesList({ series, authorPubkey, onSeriesCreated }: { series: Series[]; authorPubkey: string; onSeriesCreated: () => void }): React.ReactElement {
|
|
||||||
const { pubkey, isUnlocked } = useNostrAuth()
|
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
|
||||||
const isAuthor = pubkey === authorPubkey && isUnlocked
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h2 className="text-2xl font-semibold text-neon-cyan">{t('series.title')}</h2>
|
|
||||||
{isAuthor && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowCreateModal(true)}
|
|
||||||
className="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"
|
|
||||||
>
|
|
||||||
{t('series.create.button')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{series.length === 0 ? (
|
|
||||||
<div className="bg-cyber-dark/50 border border-neon-cyan/20 rounded-lg p-6">
|
|
||||||
<p className="text-cyber-accent">{t('series.empty')}</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
{series.map((s) => (
|
|
||||||
<Link key={s.id} href={`/series/${s.id}`}>
|
|
||||||
<SeriesCard series={s} onSelect={() => {}} />
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<CreateSeriesModal
|
|
||||||
isOpen={showCreateModal}
|
|
||||||
onClose={() => setShowCreateModal(false)}
|
|
||||||
onSuccess={onSeriesCreated}
|
|
||||||
authorPubkey={authorPubkey}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadAuthorData(hashId: string): Promise<{ pres: AuthorPresentationArticle | null; seriesList: Series[]; sponsoring: number }> {
|
|
||||||
const pool = nostrService.getPool()
|
|
||||||
if (!pool) {
|
|
||||||
throw new Error('Pool not initialized')
|
|
||||||
}
|
|
||||||
|
|
||||||
const pres = await fetchAuthorByHashId(pool, hashId)
|
|
||||||
if (!pres) {
|
|
||||||
return { pres: null, seriesList: [], sponsoring: 0 }
|
|
||||||
}
|
|
||||||
|
|
||||||
const [seriesList, sponsoring] = await Promise.all([
|
|
||||||
getSeriesByAuthor(pres.pubkey),
|
|
||||||
getAuthorSponsoring(pres.pubkey),
|
|
||||||
])
|
|
||||||
|
|
||||||
return { pres, seriesList, sponsoring }
|
|
||||||
}
|
|
||||||
|
|
||||||
function useAuthorData(hashIdOrPubkey: string): {
|
|
||||||
presentation: AuthorPresentationArticle | null
|
|
||||||
series: Series[]
|
|
||||||
totalSponsoring: number
|
|
||||||
loading: boolean
|
|
||||||
error: string | null
|
|
||||||
reload: () => Promise<void>
|
|
||||||
} {
|
|
||||||
const [presentation, setPresentation] = useState<AuthorPresentationArticle | null>(null)
|
|
||||||
const [series, setSeries] = useState<Series[]>([])
|
|
||||||
const [totalSponsoring, setTotalSponsoring] = useState<number>(0)
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const reload = useCallback(async (): Promise<void> => {
|
|
||||||
if (!hashIdOrPubkey) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { pres, seriesList, sponsoring } = await loadAuthorData(hashIdOrPubkey)
|
|
||||||
setPresentation(pres)
|
|
||||||
setSeries(seriesList)
|
|
||||||
setTotalSponsoring(sponsoring)
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : 'Erreur lors du chargement')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}, [hashIdOrPubkey])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void reload()
|
|
||||||
}, [hashIdOrPubkey, reload])
|
|
||||||
|
|
||||||
return { presentation, series, totalSponsoring, loading, error, reload }
|
|
||||||
}
|
|
||||||
|
|
||||||
type AuthorPageContentProps = {
|
|
||||||
presentation: AuthorPresentationArticle | null
|
|
||||||
series: Series[]
|
|
||||||
totalSponsoring: number
|
|
||||||
authorPubkey: string
|
|
||||||
loading: boolean
|
|
||||||
error: string | null
|
|
||||||
onSeriesCreated: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
function AuthorPageContent({
|
|
||||||
presentation,
|
|
||||||
series,
|
|
||||||
totalSponsoring,
|
|
||||||
authorPubkey,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
onSeriesCreated,
|
|
||||||
}: AuthorPageContentProps): React.ReactElement {
|
|
||||||
if (loading) {
|
|
||||||
return <p className="text-cyber-accent">{t('common.loading')}</p>
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return <p className="text-red-400">{error}</p>
|
|
||||||
}
|
|
||||||
|
|
||||||
if (presentation) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<AuthorPageHeader presentation={presentation} />
|
|
||||||
<SponsoringSummary
|
|
||||||
totalSponsoring={totalSponsoring}
|
|
||||||
author={presentation}
|
|
||||||
onSponsor={onSeriesCreated}
|
|
||||||
/>
|
|
||||||
<SeriesList series={series} authorPubkey={authorPubkey} onSeriesCreated={onSeriesCreated} />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-cyber-dark/50 border border-neon-cyan/20 rounded-lg p-6">
|
|
||||||
<p className="text-cyber-accent">{t('author.notFound')}</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AuthorPage(): React.ReactElement {
|
|
||||||
const router = useRouter()
|
|
||||||
const { pubkey } = router.query
|
|
||||||
|
|
||||||
// Parse the URL parameter - it can be either:
|
|
||||||
// 1. Old format: /author/<pubkey> (for backward compatibility)
|
|
||||||
// 2. New format: /author/<hash>_<index>_<version> (standard format)
|
|
||||||
const hashIdOrPubkey = resolveAuthorHashIdOrPubkey(pubkey)
|
|
||||||
|
|
||||||
const { presentation, series, totalSponsoring, loading, error, reload } = useAuthorData(hashIdOrPubkey ?? '')
|
|
||||||
const onSeriesCreated = (): void => {
|
|
||||||
void reload()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hashIdOrPubkey) {
|
|
||||||
return <div />
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the actual pubkey from presentation
|
|
||||||
const actualAuthorPubkey = presentation?.pubkey ?? ''
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Head>
|
|
||||||
<title>{t('author.title')} - {t('home.title')}</title>
|
|
||||||
<meta name="description" content={t('author.presentation')} />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
</Head>
|
|
||||||
<main className="min-h-screen bg-cyber-darker">
|
|
||||||
<PageHeader />
|
|
||||||
<div className="w-full px-4 py-8">
|
|
||||||
<AuthorPageContent
|
|
||||||
presentation={presentation}
|
|
||||||
series={series}
|
|
||||||
totalSponsoring={totalSponsoring}
|
|
||||||
authorPubkey={actualAuthorPubkey}
|
|
||||||
loading={loading}
|
|
||||||
error={error}
|
|
||||||
onSeriesCreated={onSeriesCreated}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Footer />
|
|
||||||
</main>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveAuthorHashIdOrPubkey(pubkeyParam: string | string[] | undefined): string | null {
|
|
||||||
if (typeof pubkeyParam !== 'string') {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
const urlMatch = pubkeyParam.match(/^([a-f0-9]+)_(\d+)_(\d+)$/i)
|
|
||||||
if (urlMatch?.[1]) {
|
|
||||||
return urlMatch[1]
|
|
||||||
}
|
|
||||||
const parsedUrl = parseObjectUrl(`https://zapwall.fr/author/${pubkeyParam}`)
|
|
||||||
if (parsedUrl.objectType === 'author' && parsedUrl.idHash) {
|
|
||||||
return parsedUrl.idHash
|
|
||||||
}
|
|
||||||
return pubkeyParam
|
|
||||||
}
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user