fix key import

This commit is contained in:
Nicolas Cantu 2026-01-06 08:10:43 +01:00
parent 27cb1a7b5b
commit 572ee2dde5
40 changed files with 533 additions and 138 deletions

View File

@ -1,4 +1,3 @@
import React from 'react'
interface ArticleFieldProps {
id: string

View File

@ -1,4 +1,3 @@
import React from 'react'
import { t } from '@/lib/i18n'
interface ArticleFormButtonsProps {

View File

@ -1,4 +1,3 @@
import React from 'react'
import type { Article } from '@/types/nostr'
interface ArticlePreviewProps {

View File

@ -1,4 +1,3 @@
import React from 'react'
import Image from 'next/image'
import { t } from '@/lib/i18n'

View File

@ -3,7 +3,6 @@ import { useRouter } from 'next/router'
import { useNostrAuth } from '@/hooks/useNostrAuth'
import { useAuthorPresentation } from '@/hooks/useAuthorPresentation'
import { ArticleField } from './ArticleField'
import { ArticleFormButtons } from './ArticleFormButtons'
import { CreateAccountModal } from './CreateAccountModal'
import { RecoveryStep } from './CreateAccountModalSteps'
import { UnlockAccountModal } from './UnlockAccountModal'
@ -251,7 +250,7 @@ function useAuthorPresentationState(pubkey: string | null, existingAuthorName?:
presentation,
contentDescription,
mainnetAddress: existingPresentation.mainnetAddress || '',
pictureUrl: existingPresentation.bannerUrl,
...(existingPresentation.bannerUrl ? { pictureUrl: existingPresentation.bannerUrl } : {}),
}
}
return {

View File

@ -1,4 +1,3 @@
import React from 'react'
import type { ArticleCategory } from '@/types/nostr'
interface CategorySelectProps {

View File

@ -1,4 +1,3 @@
import React from 'react'
import { t } from '@/lib/i18n'
type CategoryFilter = 'science-fiction' | 'scientific-research' | 'all' | null

View File

@ -1,4 +1,4 @@
import React from 'react'
import { t } from '@/lib/i18n'
interface ClearButtonProps {
onClick: () => void
@ -9,7 +9,7 @@ export function ClearButton({ onClick }: ClearButtonProps) {
<button
onClick={onClick}
className="absolute inset-y-0 right-0 pr-3 flex items-center text-neon-cyan/70 hover:text-neon-cyan transition-colors"
aria-label="Clear search"
aria-label={t('search.clear')}
>
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path

View File

@ -4,6 +4,7 @@ import { ConnectedUserMenu } from './ConnectedUserMenu'
import { RecoveryStep } from './CreateAccountModalSteps'
import { UnlockAccountModal } from './UnlockAccountModal'
import type { NostrProfile } from '@/types/nostr'
import { t } from '@/lib/i18n'
function ConnectForm({
onCreateAccount,
@ -23,14 +24,14 @@ function ConnectForm({
disabled={loading}
className="px-6 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan disabled:opacity-50"
>
Créer un compte
{t('connect.createAccount')}
</button>
<button
onClick={onUnlock}
disabled={loading}
className="px-6 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg font-medium transition-colors disabled:opacity-50"
>
Se connecter
{t('connect.connect')}
</button>
{error && <p className="text-sm text-red-400">{error}</p>}
</div>
@ -146,7 +147,7 @@ export function ConnectButton() {
return <ConnectedState pubkey={pubkey} profile={profile} loading={loading} disconnect={disconnect} />
}
if (accountExists === true && pubkey && !isUnlocked && !showUnlockModal && !showCreateModal) {
if (accountExists === true && pubkey && !isUnlocked && !showUnlockModal) {
return (
<UnlockState
loading={loading}

View File

@ -0,0 +1,225 @@
import { useState } from 'react'
import { ImageUploadField } from './ImageUploadField'
import { publishSeries } from '@/lib/articleMutations'
import { useNostrAuth } from '@/hooks/useNostrAuth'
import { nostrService } from '@/lib/nostr'
import { t } from '@/lib/i18n'
import type { ArticleDraft } from '@/lib/articlePublisherTypes'
interface CreateSeriesModalProps {
isOpen: boolean
onClose: () => void
onSuccess: () => void
authorPubkey: string
}
interface SeriesDraft {
title: string
description: string
preview: string
coverUrl: string
category: ArticleDraft['category']
}
export function CreateSeriesModal({ isOpen, onClose, onSuccess, authorPubkey }: CreateSeriesModalProps) {
const { pubkey, isUnlocked } = useNostrAuth()
const [draft, setDraft] = useState<SeriesDraft>({
title: '',
description: '',
preview: '',
coverUrl: '',
category: 'science-fiction',
})
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
if (!isOpen) {
return null
}
const privateKey = nostrService.getPrivateKey()
const canPublish = pubkey === authorPubkey && isUnlocked && privateKey !== null
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!canPublish) {
setError(t('series.create.error.notAuthor'))
return
}
if (!draft.title.trim() || !draft.description.trim() || !draft.preview.trim()) {
setError(t('series.create.error.missingFields'))
return
}
setLoading(true)
setError(null)
try {
if (!privateKey) {
setError(t('series.create.error.notAuthor'))
return
}
await publishSeries({
title: draft.title,
description: draft.description,
preview: draft.preview,
...(draft.coverUrl ? { coverUrl: draft.coverUrl } : {}),
category: draft.category,
authorPubkey,
authorPrivateKey: privateKey,
})
// Reset form
setDraft({
title: '',
description: '',
preview: '',
coverUrl: '',
category: 'science-fiction',
})
onSuccess()
onClose()
} catch (e) {
setError(e instanceof Error ? e.message : t('series.create.error.publishFailed'))
} finally {
setLoading(false)
}
}
const handleClose = () => {
if (!loading) {
setDraft({
title: '',
description: '',
preview: '',
coverUrl: '',
category: 'science-fiction',
})
setError(null)
onClose()
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="bg-cyber-dark border border-neon-cyan/30 rounded-lg p-6 max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-semibold text-neon-cyan">{t('series.create.title')}</h2>
<button
type="button"
onClick={handleClose}
disabled={loading}
className="text-cyber-accent hover:text-neon-cyan transition-colors disabled:opacity-50"
>
</button>
</div>
{!canPublish && (
<div className="mb-4 p-4 bg-yellow-900/30 border border-yellow-500/50 rounded text-yellow-300">
<p>{t('series.create.error.notAuthor')}</p>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="series-title" className="block text-sm font-medium text-neon-cyan mb-2">
{t('series.create.field.title')}
</label>
<input
id="series-title"
type="text"
value={draft.title}
onChange={(e) => setDraft({ ...draft, title: e.target.value })}
className="w-full px-3 py-2 bg-cyber-darker border border-cyber-accent/30 rounded text-cyber-light focus:border-neon-cyan focus:outline-none"
required
disabled={loading || !canPublish}
/>
</div>
<div>
<label htmlFor="series-description" className="block text-sm font-medium text-neon-cyan mb-2">
{t('series.create.field.description')}
</label>
<textarea
id="series-description"
value={draft.description}
onChange={(e) => setDraft({ ...draft, description: e.target.value })}
rows={4}
className="w-full px-3 py-2 bg-cyber-darker border border-cyber-accent/30 rounded text-cyber-light focus:border-neon-cyan focus:outline-none"
required
disabled={loading || !canPublish}
/>
</div>
<div>
<label htmlFor="series-preview" className="block text-sm font-medium text-neon-cyan mb-2">
{t('series.create.field.preview')}
</label>
<textarea
id="series-preview"
value={draft.preview}
onChange={(e) => setDraft({ ...draft, preview: e.target.value })}
rows={3}
className="w-full px-3 py-2 bg-cyber-darker border border-cyber-accent/30 rounded text-cyber-light focus:border-neon-cyan focus:outline-none"
required
disabled={loading || !canPublish}
/>
<p className="text-xs text-cyber-accent/70 mt-1">{t('series.create.field.preview.help')}</p>
</div>
<div>
<label htmlFor="series-category" className="block text-sm font-medium text-neon-cyan mb-2">
{t('series.create.field.category')}
</label>
<select
id="series-category"
value={draft.category}
onChange={(e) => setDraft({ ...draft, category: e.target.value as ArticleDraft['category'] })}
className="w-full px-3 py-2 bg-cyber-darker border border-cyber-accent/30 rounded text-cyber-light focus:border-neon-cyan focus:outline-none"
required
disabled={loading || !canPublish}
>
<option value="science-fiction">{t('category.science-fiction')}</option>
<option value="scientific-research">{t('category.scientific-research')}</option>
</select>
</div>
<div>
<ImageUploadField
id="series-cover"
label={t('series.create.field.cover')}
value={draft.coverUrl}
onChange={(url) => setDraft({ ...draft, coverUrl: url })}
helpText={t('series.create.field.cover.help')}
/>
</div>
{error && (
<div className="p-4 bg-red-900/30 border border-red-500/50 rounded text-red-300">
<p>{error}</p>
</div>
)}
<div className="flex items-center justify-end gap-4 pt-4">
<button
type="button"
onClick={handleClose}
disabled={loading}
className="px-4 py-2 bg-cyber-darker border border-cyber-accent/30 rounded text-cyber-light hover:border-neon-cyan transition-colors disabled:opacity-50"
>
{t('common.cancel')}
</button>
<button
type="submit"
disabled={loading || !canPublish}
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 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? t('common.loading') : t('series.create.submit')}
</button>
</div>
</form>
</div>
</div>
)
}

View File

@ -1,12 +1,5 @@
import { t } from '@/lib/i18n'
type DocSection = 'user-guide' | 'faq' | 'publishing' | 'payment'
interface DocLink {
id: DocSection
title: string
file: string
}
import type { DocLink, DocSection } from '@/hooks/useDocs'
interface DocsSidebarProps {
docs: DocLink[]

View File

@ -43,6 +43,7 @@ function FundingStats({ stats }: { stats: ReturnType<typeof estimatePlatformFund
export function FundingGauge() {
const [stats, setStats] = useState(estimatePlatformFunds())
const [certificationStats, setCertificationStats] = useState(estimatePlatformFunds())
const [loading, setLoading] = useState(true)
useEffect(() => {
@ -52,6 +53,8 @@ export function FundingGauge() {
try {
const fundingStats = estimatePlatformFunds()
setStats(fundingStats)
// Certification uses the same funding pool
setCertificationStats(fundingStats)
} catch (e) {
console.error('Error loading funding stats:', e)
} finally {
@ -70,9 +73,15 @@ export function FundingGauge() {
}
return (
<div className="bg-cyber-dark/50 border border-neon-cyan/20 rounded-lg p-6 mb-8">
<div className="space-y-6">
<div className="bg-cyber-dark/50 border border-neon-cyan/20 rounded-lg p-6">
<h2 className="text-xl font-semibold text-neon-cyan mb-4">{t('home.funding.title')}</h2>
<FundingStats stats={stats} />
</div>
<div className="bg-cyber-dark/50 border border-neon-cyan/20 rounded-lg p-6">
<h2 className="text-xl font-semibold text-neon-cyan mb-4">{t('home.funding.certification.title')}</h2>
<FundingStats stats={certificationStats} />
</div>
</div>
)
}

View File

@ -61,20 +61,9 @@ function ArticlesHero({
function HomeIntroSection() {
return (
<div className="mt-12 mb-8">
<div className="mb-6 text-cyber-accent leading-relaxed bg-cyber-dark/50 border border-neon-cyan/20 rounded-lg p-4 backdrop-blur-sm">
<p className="mb-2">
Consultez les auteurs et aperçus, achetez les parutions au fil de l&apos;eau par <strong className="text-neon-green">800 sats</strong> (moins 100 sats et frais de transaction).
<p className="mb-6 text-sm text-cyber-accent/70">
{t('home.funding.description')}
</p>
<p className="mb-2">
Sponsorisez l&apos;auteur pour <strong className="text-neon-green">0.046 BTC</strong> (moins 0.004 BTC et frais de transaction).
</p>
<p className="mb-2">
Les avis sont remerciables pour <strong className="text-neon-green">70 sats</strong> (moins 21 sats et frais de transaction).
</p>
<p className="text-sm text-cyber-accent/70 italic">
Les fonds de la plateforme servent à son développement.
</p>
</div>
<FundingGauge />
</div>
)

View File

@ -1,6 +1,7 @@
import { useState } from 'react'
import type { MediaRef } from '@/types/nostr'
import { uploadNip95Media } from '@/lib/nip95'
import { t } from '@/lib/i18n'
interface MarkdownEditorProps {
value: string
@ -108,7 +109,7 @@ async function handleUpload(
handlers.onBannerChange?.(media.url)
}
} catch (e) {
handlers.setError(e instanceof Error ? e.message : 'Upload failed')
handlers.setError(e instanceof Error ? e.message : t('upload.error.failed'))
} finally {
handlers.setUploading(false)
}

View File

@ -224,9 +224,6 @@ export function Nip95ConfigManager({ onConfigChange }: Nip95ConfigManagerProps)
{api.url}
</div>
)}
<div className="text-sm text-cyber-accent mt-1">
{t('settings.nip95.list.priorityLabel', { priority: api.priority, id: api.id })}
</div>
</div>
<div className="flex items-center gap-2">
<label className="flex items-center gap-2 cursor-pointer">
@ -249,7 +246,7 @@ export function Nip95ConfigManager({ onConfigChange }: Nip95ConfigManagerProps)
</button>
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<label className="flex items-center gap-2">
<span className="text-sm text-cyber-accent">{t('settings.nip95.list.priority')}:</span>
<input
@ -264,6 +261,7 @@ export function Nip95ConfigManager({ onConfigChange }: Nip95ConfigManagerProps)
}}
className="w-20 px-2 py-1 bg-cyber-darker border border-cyber-accent/30 rounded text-cyber-light focus:border-neon-cyan focus:outline-none"
/>
<span className="text-sm text-cyber-accent">| ID: {api.id}</span>
</label>
</div>
</div>

View File

@ -1,5 +1,5 @@
import React from 'react'
import { formatTime } from '@/lib/formatTime'
import { t } from '@/lib/i18n'
interface NotificationActionsProps {
timestamp: number
@ -16,7 +16,7 @@ export function NotificationActions({ timestamp, onDelete }: NotificationActions
onDelete()
}}
className="text-gray-400 hover:text-red-600 transition-colors"
aria-label="Delete notification"
aria-label={t('notification.delete')}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
@ -30,4 +30,3 @@ export function NotificationActions({ timestamp, onDelete }: NotificationActions
</div>
)
}

View File

@ -1,4 +1,3 @@
import React from 'react'
interface NotificationBadgeButtonProps {
unreadCount: number
@ -34,4 +33,3 @@ export function NotificationBadgeButton({ unreadCount, onClick }: NotificationBa
</button>
)
}

View File

@ -1,4 +1,3 @@
import React from 'react'
import Link from 'next/link'
import type { Notification } from '@/types/notifications'

View File

@ -1,4 +1,3 @@
import React from 'react'
import type { Notification } from '@/types/notifications'
import { NotificationContent } from './NotificationContent'
import { NotificationActions } from './NotificationActions'

View File

@ -1,4 +1,3 @@
import React from 'react'
import type { Notification } from '@/types/notifications'
import { NotificationItem } from './NotificationItem'
import { NotificationPanelHeader } from './NotificationPanelHeader'

View File

@ -1,4 +1,3 @@
import React from 'react'
interface NotificationPanelHeaderProps {
unreadCount: number
@ -41,4 +40,3 @@ export function NotificationPanelHeader({
</div>
)
}

View File

@ -1,4 +1,3 @@
import React from 'react'
export function SearchIcon() {
return (

View File

@ -2,6 +2,7 @@ import { ArticleCard } from './ArticleCard'
import type { Article } from '@/types/nostr'
import { memo } from 'react'
import Link from 'next/link'
import { t } from '@/lib/i18n'
interface UserArticlesViewProps {
articles: Article[]
@ -21,7 +22,7 @@ interface UserArticlesViewProps {
const ArticlesLoading = () => (
<div className="text-center py-12">
<p className="text-gray-500">Loading articles...</p>
<p className="text-gray-500">{t('common.loading.articles')}</p>
</div>
)
@ -34,7 +35,7 @@ const ArticlesError = ({ message }: { message: string }) => (
const EmptyState = ({ show }: { show: boolean }) =>
show ? (
<div className="text-center py-12">
<p className="text-gray-500">No articles published yet.</p>
<p className="text-gray-500">{t('common.empty.articles')}</p>
</div>
) : null
@ -60,13 +61,13 @@ function ArticleActions({
className="px-3 py-1 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
disabled={editingArticleId !== null && editingArticleId !== article.id}
>
Edit
{t('common.edit')}
</button>
<button
onClick={() => (pendingDeleteId === article.id ? onDelete(article) : requestDelete(article.id))}
className="px-3 py-1 text-sm rounded bg-red-600 text-white hover:bg-red-700"
>
{pendingDeleteId === article.id ? 'Confirm delete' : 'Delete'}
{pendingDeleteId === article.id ? t('common.confirmDelete') : t('common.delete')}
</button>
</div>
)

View File

@ -1,5 +1,4 @@
import Image from 'next/image'
import React from 'react'
interface UserProfileHeaderProps {
displayName: string

View File

@ -0,0 +1,16 @@
# Frais et contributions
## Tarification
### Achat de publications
Consultez les auteurs et aperçus, achetez les parutions au fil de l'eau par **800 sats** (moins 100 sats et frais de transaction).
### Sponsoring d'auteur
Sponsorisez l'auteur pour **0.046 BTC** (moins 0.004 BTC et frais de transaction).
### Remerciements d'avis
Les avis sont remerciables pour **70 sats** (moins 21 sats et frais de transaction).
## Utilisation des fonds
Les fonds collectés par la plateforme servent au développement de fonctions IA gratuites pour les auteurs (développement et matériel).

View File

@ -1,8 +1,8 @@
import { useState, useEffect } from 'react'
type DocSection = 'user-guide' | 'faq' | 'publishing' | 'payment'
export type DocSection = 'user-guide' | 'faq' | 'publishing' | 'payment' | 'fees-and-contributions'
interface DocLink {
export interface DocLink {
id: DocSection
title: string
file: string

View File

@ -33,7 +33,7 @@ export function canUserDelete(event: Event, userPubkey: string): boolean {
* All users can read public content (previews, metadata)
* For paid content, users must have paid (via zap receipt) to access full content
*/
export function canUserRead(event: Event, userPubkey: string | null, hasPaid: boolean = false): {
export function canUserRead(event: Event, _userPubkey: string | null, hasPaid: boolean = false): {
canReadPreview: boolean
canReadFullContent: boolean
} {
@ -101,6 +101,6 @@ export function getAccessControl(
canReadPreview,
canReadFullContent,
isPaid,
reason,
...(reason ? { reason } : {}),
}
}

View File

@ -188,7 +188,7 @@ async function buildReviewEvent(
articleId: params.articleId,
reviewerPubkey: params.reviewerPubkey,
content: params.content,
title: params.title ?? undefined,
...(params.title ? { title: params.title } : {}),
})
// Build JSON metadata

View File

@ -1,18 +1,6 @@
import type { Article } from '@/types/nostr'
import type { ArticleFilters } from '@/components/ArticleFilters'
/**
* Map category from ArticleFilters to tag category
*/
function mapCategoryToTag(category: ArticleFilters['category']): 'sciencefiction' | 'research' | null {
if (category === 'science-fiction') {
return 'sciencefiction'
}
if (category === 'scientific-research') {
return 'research'
}
return null
}
/**
* Get authors (presentation articles) filtered by category

View File

@ -177,9 +177,15 @@ export async function extractAuthorFromEvent(event: Event): Promise<ExtractedAut
return {
type: 'author',
id,
...authorData,
pubkey: authorData.pubkey,
authorName: authorData.authorName,
presentation: authorData.presentation,
contentDescription: authorData.contentDescription,
category: authorData.category,
eventId: event.id,
url: metadata.url as string | undefined,
...(authorData.mainnetAddress ? { mainnetAddress: authorData.mainnetAddress } : {}),
...(authorData.pictureUrl ? { pictureUrl: authorData.pictureUrl } : {}),
...(metadata.url ? { url: metadata.url as string } : {}),
}
}
@ -220,8 +226,13 @@ export async function extractSeriesFromEvent(event: Event): Promise<ExtractedSer
return {
type: 'series',
id,
...seriesData,
pubkey: seriesData.pubkey,
title: seriesData.title,
description: seriesData.description,
category: seriesData.category,
eventId: event.id,
...(seriesData.coverUrl ? { coverUrl: seriesData.coverUrl } : {}),
...(seriesData.preview ? { preview: seriesData.preview } : {}),
}
}
@ -241,8 +252,13 @@ export async function extractSeriesFromEvent(event: Event): Promise<ExtractedSer
return {
type: 'series',
id,
...seriesData,
pubkey: seriesData.pubkey,
title: seriesData.title,
description: seriesData.description,
category: seriesData.category,
eventId: event.id,
...(seriesData.coverUrl ? { coverUrl: seriesData.coverUrl } : {}),
...(seriesData.preview ? { preview: seriesData.preview } : {}),
}
}
@ -282,8 +298,14 @@ export async function extractPublicationFromEvent(event: Event): Promise<Extract
return {
type: 'publication',
id,
...publicationData,
pubkey: publicationData.pubkey,
title: publicationData.title,
preview: publicationData.preview,
category: publicationData.category,
zapAmount: publicationData.zapAmount,
eventId: event.id,
...(publicationData.seriesId ? { seriesId: publicationData.seriesId } : {}),
...(publicationData.bannerUrl ? { bannerUrl: publicationData.bannerUrl } : {}),
}
}
@ -304,8 +326,14 @@ export async function extractPublicationFromEvent(event: Event): Promise<Extract
return {
type: 'publication',
id,
...publicationData,
pubkey: publicationData.pubkey,
title: publicationData.title,
preview: publicationData.preview,
category: publicationData.category,
zapAmount: publicationData.zapAmount,
eventId: event.id,
...(publicationData.seriesId ? { seriesId: publicationData.seriesId } : {}),
...(publicationData.bannerUrl ? { bannerUrl: publicationData.bannerUrl } : {}),
}
}
@ -362,13 +390,23 @@ export async function extractReviewFromEvent(event: Event): Promise<ExtractedRev
title: tags.title as string | undefined,
}
const id = await generateReviewHashId(reviewData)
const id = await generateReviewHashId({
pubkey: reviewData.pubkey,
articleId: reviewData.articleId,
reviewerPubkey: reviewData.reviewerPubkey,
content: reviewData.content,
...(reviewData.title ? { title: reviewData.title } : {}),
})
return {
type: 'review',
id,
...reviewData,
pubkey: reviewData.pubkey,
articleId: reviewData.articleId,
reviewerPubkey: reviewData.reviewerPubkey,
content: reviewData.content,
eventId: event.id,
...(reviewData.title ? { title: reviewData.title } : {}),
}
}
@ -502,13 +540,25 @@ export async function extractSponsoringFromEvent(event: Event): Promise<Extracte
paymentHash,
}
const id = await generateSponsoringHashId(sponsoringData)
const id = await generateSponsoringHashId({
payerPubkey: sponsoringData.payerPubkey,
authorPubkey: sponsoringData.authorPubkey,
amount: sponsoringData.amount,
paymentHash: sponsoringData.paymentHash,
...(sponsoringData.seriesId ? { seriesId: sponsoringData.seriesId } : {}),
...(sponsoringData.articleId ? { articleId: sponsoringData.articleId } : {}),
})
return {
type: 'sponsoring',
id,
...sponsoringData,
payerPubkey: sponsoringData.payerPubkey,
authorPubkey: sponsoringData.authorPubkey,
amount: sponsoringData.amount,
paymentHash: sponsoringData.paymentHash,
eventId: event.id,
...(sponsoringData.seriesId ? { seriesId: sponsoringData.seriesId } : {}),
...(sponsoringData.articleId ? { articleId: sponsoringData.articleId } : {}),
}
}

View File

@ -9,7 +9,6 @@
*/
import type { Event } from 'nostr-tools'
import { extractTagsFromEvent } from './nostrTagSystem'
import { canModifyObject, getNextVersion } from './versionManager'
/**
@ -26,7 +25,7 @@ export function canUserModifyObject(event: Event, userPubkey: string): boolean {
*/
export async function buildUpdateEvent(
originalEvent: Event,
updatedData: Record<string, unknown>,
_updatedData: Record<string, unknown>,
userPubkey: string
): Promise<Event | null> {
// Check if user can modify
@ -34,7 +33,6 @@ export async function buildUpdateEvent(
throw new Error('Only the author can modify this object')
}
const tags = extractTagsFromEvent(originalEvent)
const nextVersion = getNextVersion([originalEvent])
// Build new event with incremented version
@ -70,7 +68,6 @@ export async function buildDeleteEvent(
throw new Error('Only the author can delete this object')
}
const tags = extractTagsFromEvent(originalEvent)
const nextVersion = getNextVersion([originalEvent])
// Build new event with hidden=true and incremented version

View File

@ -21,7 +21,7 @@ export function extractPresentationData(presentation: Article): {
const newFormatMatch = content.match(/Présentation personnelle : (.+?)(?:\nDescription de votre contenu :|$)/s)
const descriptionMatch = content.match(/Description de votre contenu : (.+?)(?:\nAdresse Bitcoin mainnet|$)/s)
if (newFormatMatch && descriptionMatch) {
if (newFormatMatch && descriptionMatch && newFormatMatch[1] && descriptionMatch[1]) {
return {
presentation: newFormatMatch[1].trim(),
contentDescription: descriptionMatch[1].trim(),

View File

@ -32,7 +32,7 @@ export function parseObjectUrl(url: string): {
version: number | null
} {
const match = url.match(/https?:\/\/zapwall\.fr\/(author|series|publication|review)\/([a-f0-9]+)_(\d+)_(\d+)/i)
if (!match) {
if (!match || !match[1] || !match[2] || !match[3] || !match[4]) {
return { objectType: null, idHash: null, index: null, version: null }
}

View File

@ -60,7 +60,10 @@ export function getLatestVersion(events: Event[]): Event | null {
// Sort by version (descending) and take the first (latest)
visible.sort((a, b) => b.version - a.version)
latestVersions.push(visible[0])
const latest = visible[0]
if (latest) {
latestVersions.push(latest)
}
}
// If we have multiple IDs, we need to return the one with the highest version
@ -71,7 +74,8 @@ export function getLatestVersion(events: Event[]): Event | null {
// Sort by version and return the latest
latestVersions.sort((a, b) => b.version - a.version)
return latestVersions[0].event
const latestVersion = latestVersions[0]
return latestVersion ? latestVersion.event : null
}
/**

View File

@ -11,6 +11,7 @@ home.funding.target=Target: {{target}} BTC
home.funding.current=Raised: {{current}} BTC
home.funding.progress={{percent}}% of funding reached
home.funding.description=Funds collected by the platform serve the development of free AI features for authors (development and hardware).
home.funding.certification.title=Certification on a Bitcoin-anchored signet of intellectual property
# Navigation
nav.documentation=Documentation
@ -18,12 +19,17 @@ nav.publish=Publish profile
nav.createAuthorPage=Create author page
nav.loading=Loading...
# Connect
connect.createAccount=Create account
connect.connect=Connect
# Documentation
docs.title=Documentation
docs.userGuide=User Guide
docs.faq=FAQ
docs.publishing=Publishing Guide
docs.payment=Payment Guide
docs.feesAndContributions=Fees and Contributions
docs.error=Error
docs.error.loadFailed=Unable to load documentation.
docs.meta.description=Complete documentation for zapwall.fr
@ -47,6 +53,19 @@ series.empty=No series published yet.
series.view=View series
series.publications=Series publications
series.publications.empty=No publications for this series.
series.create.button=Create a series
series.create.title=Create a new series
series.create.submit=Create series
series.create.field.title=Series title
series.create.field.description=Series description
series.create.field.preview=Preview of publication content
series.create.field.preview.help=This preview will be visible to everyone to give a taste of the series content
series.create.field.category=Publication type
series.create.field.cover=Cover image
series.create.field.cover.help=Cover image for the series (optional, max 5MB, formats: PNG, JPG, WebP)
series.create.error.notAuthor=You must be the author of this page and have unlocked your account to create a series
series.create.error.missingFields=Please fill in all required fields
series.create.error.publishFailed=Error publishing series
# Author page
author.title=Author page
@ -56,6 +75,7 @@ author.sponsoring.total=Total received: {{amount}} BTC
author.sponsoring.sats=In satoshis: {{amount}} sats
author.notFound=Author page not found.
author.profileNote=This profile data is specific to zapwall.fr and may differ from your Nostr profile.
author.profilePicture=Profile picture
# Publish
publish.title=Publish a new publication
@ -119,6 +139,19 @@ footer.privacy=Privacy Policy
common.loading=Loading...
common.loading.articles=Loading articles...
common.loading.authors=Loading authors...
common.edit=Edit
common.delete=Delete
common.confirmDelete=Confirm delete
common.cancel=Cancel
# Search
search.clear=Clear search
# Upload
upload.error.failed=Upload failed
# Notification
notification.delete=Delete notification
common.error=Error
common.error.noContent=No content found
common.empty.articles=No articles found. Check back later!

View File

@ -11,6 +11,7 @@ home.funding.target=Cible : {{target}} BTC
home.funding.current=Collecté : {{current}} BTC
home.funding.progress={{percent}}% du financement atteint
home.funding.description=Les fonds collectés par la plateforme servent au développement de fonctions IA gratuites pour les auteurs (développement et matériel).
home.funding.certification.title=Certification sur un signet ancré sur Bitcoin de la propriété intellectuelle
# Navigation
nav.documentation=Documentation
@ -18,12 +19,17 @@ nav.publish=Publier le profil
nav.createAuthorPage=Créer page auteur
nav.loading=Chargement...
# Connect
connect.createAccount=Créer un compte
connect.connect=Se connecter
# Documentation
docs.title=Documentation
docs.userGuide=Guide d'utilisation
docs.faq=FAQ
docs.publishing=Guide de publication
docs.payment=Guide de paiement
docs.feesAndContributions=Frais et contributions
docs.error=Erreur
docs.error.loadFailed=Impossible de charger la documentation.
docs.meta.description=Documentation complète pour zapwall.fr
@ -47,6 +53,19 @@ series.empty=Aucune série publiée pour le moment.
series.view=Voir la série
series.publications=Publications de la série
series.publications.empty=Aucune publication pour cette série.
series.create.button=Créer une série
series.create.title=Créer une nouvelle série
series.create.submit=Créer la série
series.create.field.title=Titre de la série
series.create.field.description=Description de la série
series.create.field.preview=Aperçu du contenu d'une publication
series.create.field.preview.help=Cet aperçu sera visible par tous pour donner un avant-goût du contenu de la série
series.create.field.category=Type de publication
series.create.field.cover=Image de couverture
series.create.field.cover.help=Image de couverture pour la série (optionnel, max 5Mo, formats: PNG, JPG, WebP)
series.create.error.notAuthor=Vous devez être l'auteur de cette page et avoir déverrouillé votre compte pour créer une série
series.create.error.missingFields=Veuillez remplir tous les champs obligatoires
series.create.error.publishFailed=Erreur lors de la publication de la série
# Author page
author.title=Page auteur
@ -56,6 +75,7 @@ author.sponsoring.total=Total reçu : {{amount}} BTC
author.sponsoring.sats=En satoshis : {{amount}} sats
author.notFound=Page auteur introuvable.
author.profileNote=Les données de ce profil sont spécifiques à zapwall.fr et peuvent différer de votre profil Nostr.
author.profilePicture=Photo de profil
# Publish
publish.title=Publier une nouvelle publication

View File

@ -12,6 +12,8 @@ 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'
function AuthorPageHeader({ presentation }: { presentation: AuthorPresentationArticle | null }) {
if (!presentation) {
@ -25,7 +27,7 @@ function AuthorPageHeader({ presentation }: { presentation: AuthorPresentationAr
<div className="relative w-32 h-32 rounded-lg overflow-hidden border border-neon-cyan/20 flex-shrink-0">
<Image
src={presentation.bannerUrl}
alt="Profile picture"
alt={t('author.profilePicture')}
fill
className="object-cover"
/>
@ -65,18 +67,30 @@ function SponsoringSummary({ totalSponsoring }: { totalSponsoring: number }) {
)
}
function SeriesList({ series }: { series: Series[]; authorPubkey: string }) {
if (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>
)
}
function SeriesList({ series, authorPubkey, onSeriesCreated }: { series: Series[]; authorPubkey: string; onSeriesCreated: () => void }) {
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}`}>
@ -84,6 +98,13 @@ function SeriesList({ series }: { series: Series[]; authorPubkey: string }) {
</Link>
))}
</div>
)}
<CreateSeriesModal
isOpen={showCreateModal}
onClose={() => setShowCreateModal(false)}
onSuccess={onSeriesCreated}
authorPubkey={authorPubkey}
/>
</div>
)
}
@ -110,12 +131,11 @@ function useAuthorData(authorPubkey: string) {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const reload = async () => {
if (!authorPubkey) {
return
}
const load = async () => {
setLoading(true)
setError(null)
@ -131,10 +151,11 @@ function useAuthorData(authorPubkey: string) {
}
}
void load()
useEffect(() => {
void reload()
}, [authorPubkey])
return { presentation, series, totalSponsoring, loading, error }
return { presentation, series, totalSponsoring, loading, error, reload }
}
function AuthorPageContent({
@ -144,6 +165,7 @@ function AuthorPageContent({
authorPubkey,
loading,
error,
onSeriesCreated,
}: {
presentation: AuthorPresentationArticle | null
series: Series[]
@ -151,6 +173,7 @@ function AuthorPageContent({
authorPubkey: string
loading: boolean
error: string | null
onSeriesCreated: () => void
}) {
if (loading) {
return <p className="text-cyber-accent">{t('common.loading')}</p>
@ -165,7 +188,7 @@ function AuthorPageContent({
<>
<AuthorPageHeader presentation={presentation} />
<SponsoringSummary totalSponsoring={totalSponsoring} />
<SeriesList series={series} authorPubkey={authorPubkey} />
<SeriesList series={series} authorPubkey={authorPubkey} onSeriesCreated={onSeriesCreated} />
</>
)
}
@ -181,7 +204,7 @@ export default function AuthorPage() {
const router = useRouter()
const { pubkey } = router.query
const authorPubkey = typeof pubkey === 'string' ? pubkey : ''
const { presentation, series, totalSponsoring, loading, error } = useAuthorData(authorPubkey)
const { presentation, series, totalSponsoring, loading, error, reload } = useAuthorData(authorPubkey)
if (!authorPubkey) {
return null
@ -204,6 +227,7 @@ export default function AuthorPage() {
authorPubkey={authorPubkey}
loading={loading}
error={error}
onSeriesCreated={reload}
/>
</div>
<Footer />

View File

@ -3,17 +3,9 @@ import { DocsSidebar } from '@/components/DocsSidebar'
import { DocsContent } from '@/components/DocsContent'
import { PageHeader } from '@/components/PageHeader'
import { Footer } from '@/components/Footer'
import { useDocs } from '@/hooks/useDocs'
import { useDocs, type DocLink, type DocSection } from '@/hooks/useDocs'
import { t } from '@/lib/i18n'
type DocSection = 'user-guide' | 'faq' | 'publishing' | 'payment'
interface DocLink {
id: DocSection
title: string
file: string
}
export default function DocsPage() {
const docs: DocLink[] = [
{
@ -36,6 +28,11 @@ export default function DocsPage() {
title: t('docs.payment'),
file: 'payment-guide.md',
},
{
id: 'fees-and-contributions',
title: t('docs.feesAndContributions'),
file: 'fees-and-contributions.md',
},
]
const { selectedDoc, docContent, loading, loadDoc } = useDocs(docs)
@ -55,7 +52,7 @@ export default function DocsPage() {
<DocsSidebar
docs={docs}
selectedDoc={selectedDoc}
onSelectDoc={(slug) => {
onSelectDoc={(slug: DocSection) => {
void loadDoc(slug)
}}
/>

View File

@ -11,6 +11,7 @@ home.funding.target=Target: {{target}} BTC
home.funding.current=Raised: {{current}} BTC
home.funding.progress={{percent}}% of funding reached
home.funding.description=Funds collected by the platform serve the development of free AI features for authors (development and hardware).
home.funding.certification.title=Certification on a Bitcoin-anchored signet of intellectual property
# Navigation
nav.documentation=Documentation
@ -18,12 +19,17 @@ nav.publish=Publish profile
nav.createAuthorPage=Create author page
nav.loading=Loading...
# Connect
connect.createAccount=Create account
connect.connect=Connect
# Documentation
docs.title=Documentation
docs.userGuide=User Guide
docs.faq=FAQ
docs.publishing=Publishing Guide
docs.payment=Payment Guide
docs.feesAndContributions=Fees and Contributions
docs.error=Error
docs.error.loadFailed=Unable to load documentation.
docs.meta.description=Complete documentation for zapwall.fr
@ -48,6 +54,19 @@ series.empty=No series published yet.
series.view=View series
series.publications=Series publications
series.publications.empty=No publications for this series.
series.create.button=Create a series
series.create.title=Create a new series
series.create.submit=Create series
series.create.field.title=Series title
series.create.field.description=Series description
series.create.field.preview=Preview of publication content
series.create.field.preview.help=This preview will be visible to everyone to give a taste of the series content
series.create.field.category=Publication type
series.create.field.cover=Cover image
series.create.field.cover.help=Cover image for the series (optional, max 5MB, formats: PNG, JPG, WebP)
series.create.error.notAuthor=You must be the author of this page and have unlocked your account to create a series
series.create.error.missingFields=Please fill in all required fields
series.create.error.publishFailed=Error publishing series
# Author page
author.title=Author page
@ -57,6 +76,7 @@ author.sponsoring.total=Total received: {{amount}} BTC
author.sponsoring.sats=In satoshis: {{amount}} sats
author.notFound=Author page not found.
author.profileNote=This profile data is specific to zapwall.fr and may differ from your Nostr profile.
author.profilePicture=Profile picture
# Publish
publish.title=Publish a new publication
@ -120,6 +140,19 @@ footer.privacy=Privacy Policy
common.loading=Loading...
common.loading.articles=Loading articles...
common.loading.authors=Loading authors...
common.edit=Edit
common.delete=Delete
common.confirmDelete=Confirm delete
common.cancel=Cancel
# Search
search.clear=Clear search
# Upload
upload.error.failed=Upload failed
# Notification
notification.delete=Delete notification
common.error=Error
common.error.noContent=No content found
common.empty.articles=No articles found. Check back later!

View File

@ -11,6 +11,7 @@ home.funding.target=Cible : {{target}} BTC
home.funding.current=Collecté : {{current}} BTC
home.funding.progress={{percent}}% du financement atteint
home.funding.description=Les fonds collectés par la plateforme servent au développement de fonctions IA gratuites pour les auteurs (développement et matériel).
home.funding.certification.title=Certification sur un signet ancré sur Bitcoin de la propriété intellectuelle
# Navigation
nav.documentation=Documentation
@ -18,12 +19,17 @@ nav.publish=Publier le profil
nav.createAuthorPage=Créer page auteur
nav.loading=Chargement...
# Connect
connect.createAccount=Créer un compte
connect.connect=Se connecter
# Documentation
docs.title=Documentation
docs.userGuide=Guide d'utilisation
docs.faq=FAQ
docs.publishing=Guide de publication
docs.payment=Guide de paiement
docs.feesAndContributions=Frais et contributions
docs.error=Erreur
docs.error.loadFailed=Impossible de charger la documentation.
docs.meta.description=Documentation complète pour zapwall.fr
@ -48,6 +54,19 @@ series.empty=Aucune série publiée pour le moment.
series.view=Voir la série
series.publications=Publications de la série
series.publications.empty=Aucune publication pour cette série.
series.create.button=Créer une série
series.create.title=Créer une nouvelle série
series.create.submit=Créer la série
series.create.field.title=Titre de la série
series.create.field.description=Description de la série
series.create.field.preview=Aperçu du contenu d'une publication
series.create.field.preview.help=Cet aperçu sera visible par tous pour donner un avant-goût du contenu de la série
series.create.field.category=Type de publication
series.create.field.cover=Image de couverture
series.create.field.cover.help=Image de couverture pour la série (optionnel, max 5Mo, formats: PNG, JPG, WebP)
series.create.error.notAuthor=Vous devez être l'auteur de cette page et avoir déverrouillé votre compte pour créer une série
series.create.error.missingFields=Veuillez remplir tous les champs obligatoires
series.create.error.publishFailed=Erreur lors de la publication de la série
# Author page
author.title=Page auteur
@ -57,6 +76,7 @@ author.sponsoring.total=Total reçu : {{amount}} BTC
author.sponsoring.sats=En satoshis : {{amount}} sats
author.notFound=Page auteur introuvable.
author.profileNote=Les données de ce profil sont spécifiques à zapwall.fr et peuvent différer de votre profil Nostr.
author.profilePicture=Photo de profil
# Publish
publish.title=Publier une nouvelle publication
@ -120,6 +140,19 @@ footer.privacy=Politique de confidentialité
common.loading=Chargement...
common.loading.articles=Chargement des articles...
common.loading.authors=Chargement des auteurs...
common.edit=Modifier
common.delete=Supprimer
common.confirmDelete=Confirmer la suppression
common.cancel=Annuler
# Search
search.clear=Effacer la recherche
# Upload
upload.error.failed=Échec du téléchargement
# Notification
notification.delete=Supprimer la notification
common.error=Erreur
common.error.noContent=Aucun contenu trouvé
common.empty.articles=Aucun article trouvé. Revenez plus tard !