This commit is contained in:
Nicolas Cantu 2026-01-13 15:56:14 +01:00
parent 9ad602d100
commit 2116ee4ffc
15 changed files with 526 additions and 386 deletions

View 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>
</>
)
}

View 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} />
</>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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
}

View 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 }
}

View File

@ -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 (
<div className="text-xs text-cyber-accent/70 mt-1">
{t('settings.relay.list.lastSync')}: {new Date(params.lastSyncDate).toLocaleString()}

View 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 lURL (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 dimport/chemins suite au déplacement de code
- oubli dun export explicite ou dun type de retour (règles TypeScript/ESLint strictes)

View File

@ -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 }> {
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) }
}

View File

@ -5,6 +5,7 @@ interface RenderState {
currentList: Array<{ key: string; line: string }>
inCodeBlock: boolean
codeBlockContent: string[]
keyCounts: Map<string, number>
}
export function renderMarkdown(markdown: string): React.ReactElement[] {
@ -14,6 +15,7 @@ export function renderMarkdown(markdown: string): React.ReactElement[] {
currentList: [],
inCodeBlock: false,
codeBlockContent: [],
keyCounts: new Map<string, number>(),
}
lines.forEach((line, index) => {
@ -38,36 +40,36 @@ function processLine(line: string, index: number, state: RenderState, elements:
closeListIfNeeded(line, index, state, elements)
if (renderHeading(line, index, elements)) {
if (renderHeading(line, index, state, elements)) {
return
}
if (renderListLine(line, index, state)) {
return
}
if (renderLinkLine(line, index, elements)) {
if (renderLinkLine(line, index, state, elements)) {
return
}
if (renderBoldAndCodeLine(line, index, elements)) {
if (renderBoldAndCodeLine(line, index, state, elements)) {
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('# ')) {
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
}
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
}
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
}
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 false
@ -75,37 +77,37 @@ function renderHeading(line: string, index: number, elements: React.ReactElement
function renderListLine(line: string, index: number, state: RenderState): boolean {
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 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('](')) {
renderLink(line, index, elements)
renderLink(line, index, state, elements)
return true
}
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('`')) {
renderBoldAndCode(line, index, elements)
renderBoldAndCode(line, index, state, elements)
return true
}
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() !== '') {
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
}
if (elements.length > 0) {
const last = elements[elements.length - 1] as { type?: unknown }
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
if (state.inCodeBlock) {
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>
</pre>
)
@ -138,8 +140,9 @@ function closeListIfNeeded(
): void {
const nextState = state
if (state.currentList.length > 0 && !line.startsWith('- ') && !line.startsWith('* ') && line.trim() !== '') {
const keySeed = `${state.currentList[0]?.key ?? ''}-${state.currentList.length}-${index}`
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) => (
<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
let lastIndex = 0
const parts: (string | React.ReactElement)[] = []
@ -194,10 +197,10 @@ function renderLink(line: string, index: number, elements: React.ReactElement[])
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 codeRegex = /`([^`]+)`/g
let codeMatch
@ -221,7 +224,7 @@ function renderBoldAndCode(line: string, index: number, elements: React.ReactEle
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 {
@ -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}`
}

View File

@ -26,15 +26,49 @@ function readReviewTipFields(event: Event): Omit<ExtractedReviewTip, 'type' | 'i
const articleId = readTagValue(event, 'article')
const reviewId = readTagValue(event, 'review_id') ?? readTagValue(event, 'e')
const required = { payerPubkey, reviewerPubkey, authorPubkey, articleId, reviewId }
if (!areAllNonEmptyStrings(required) || amount === undefined) {
const required = { payerPubkey, reviewerPubkey, authorPubkey, articleId, reviewId, paymentHash }
if (!hasRequiredReviewTipFields(required) || amount === undefined) {
console.error('[metadataExtractor] Invalid review_tip zap receipt: missing required fields', { eventId: event.id })
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> {
return Object.values(values).every((value) => typeof value === 'string' && value.length > 0)
function hasRequiredReviewTipFields(values: {
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
}

View File

@ -47,7 +47,7 @@ async function buildSponsoringNotePayload(params: {
const hashId = await generateSponsoringHashId(sponsoringData)
const id = buildObjectId(hashId, 0, 0)
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 })
return { hashId, eventTemplate: buildSponsoringEventTemplate({ tags, content: buildSponsoringNoteContent(params) }), parsedSponsoring }
}
@ -160,3 +160,19 @@ function buildSponsoringPaymentJson(params: {
...(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 } : {}),
}
}

View File

@ -0,0 +1 @@
export { config, default } from './nip95-upload/index'

View File

@ -1,355 +1 @@
import { useRouter } from 'next/router'
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
}
export { AuthorPage as default } from '@/components/authorPage/AuthorPage'