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 (
|
||||
<div className="text-xs text-cyber-accent/70 mt-1">
|
||||
{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 }> {
|
||||
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) }
|
||||
}
|
||||
|
||||
|
||||
@ -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}`
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
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'
|
||||
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'
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user