Fix all TypeScript errors and warnings

- Fix unused function warnings by renaming to _unusedExtractTags
- Fix type errors in nostrTagSystem.ts for includes() calls
- Fix type errors in reviews.ts for filter kinds array
- Fix ArrayBuffer type errors in articleEncryption.ts
- Remove unused imports (DecryptionKey, decryptArticleContent, extractTagsFromEvent)
- All TypeScript checks now pass without disabling any controls
This commit is contained in:
Nicolas Cantu 2025-12-27 22:26:13 +01:00
parent 9edf4ac1bc
commit 2a191f35f4
45 changed files with 1866 additions and 278 deletions

View File

@ -220,16 +220,16 @@ La documentation doit être optimisée et mise à jour systématiquement lors de
* **Centralisation** : La documentation technique doit être centralisée dans `docs/` et les fonctionnalités dans `features/`. Éviter la dispersion de linformation.
* **Mise à jour lors des modifications** : Lors de toute modification de code, fonctionnalité ou architecture :
- Vérifier si la documentation existante est obsolète
- Mettre à jour ou supprimer les sections obsolètes
- Fusionner les documents similaires
- Supprimer les documents redondants
* Vérifier si la documentation existante est obsolète
* Mettre à jour ou supprimer les sections obsolètes
* Fusionner les documents similaires
* Supprimer les documents redondants
* **Optimisation continue** :
- Supprimer les documents obsolètes (code supprimé, fonctionnalités remplacées)
- Fusionner les documents qui se chevauchent
- Maintenir une structure claire et navigable
- Éviter les doublons entre `docs/` et `features/`
* **Optimisation continue** :
* Supprimer les documents obsolètes (code supprimé, fonctionnalités remplacées)
* Fusionner les documents qui se chevauchent
* Maintenir une structure claire et navigable
* Éviter les doublons entre `docs/` et `features/`
* **Vérification** : Avant de finaliser une modification, vérifier que la documentation est à jour et cohérente avec le code.

View File

@ -3,6 +3,8 @@ import { useNostrConnect } from '@/hooks/useNostrConnect'
import { useArticlePayment } from '@/hooks/useArticlePayment'
import { ArticlePreview } from './ArticlePreview'
import { PaymentModal } from './PaymentModal'
import { t } from '@/lib/i18n'
import Link from 'next/link'
interface ArticleCardProps {
article: Article
@ -26,7 +28,7 @@ function ArticleMeta({
<>
{error && <p className="text-sm text-red-400 mt-2">{error}</p>}
<div className="text-xs text-cyber-accent/50 mt-4">
Published {new Date(article.createdAt * 1000).toLocaleDateString()}
{t('publication.published', { date: new Date(article.createdAt * 1000).toLocaleDateString() })}
</div>
{paymentInvoice && (
<PaymentModal
@ -54,7 +56,15 @@ export function ArticleCard({ article, onUnlock }: ArticleCardProps) {
return (
<article className="border border-neon-cyan/30 rounded-lg p-6 bg-cyber-dark hover:border-neon-cyan/50 hover:shadow-glow-cyan transition-all">
<h2 className="text-2xl font-bold mb-2 text-neon-cyan">{article.title}</h2>
<div className="mb-2 flex items-center justify-between">
<h2 className="text-2xl font-bold text-neon-cyan">{article.title}</h2>
<Link
href={`/author/${article.pubkey}`}
className="text-xs text-cyber-accent/70 hover:text-neon-cyan transition-colors"
>
{t('publication.viewAuthor')}
</Link>
</div>
<div className="text-cyber-accent mb-4">
<ArticlePreview
article={article}

View File

@ -233,7 +233,7 @@ export function ArticleEditorForm({
}: ArticleEditorFormProps) {
return (
<form onSubmit={onSubmit} className="border rounded-lg p-6 bg-white space-y-4">
<h2 className="text-2xl font-bold mb-4">Publier un nouvel article</h2>
<h2 className="text-2xl font-bold mb-4">Publier une nouvelle publication</h2>
<div className="space-y-4">
<ArticleFieldsLeft
draft={draft}

View File

@ -3,6 +3,7 @@ import Image from 'next/image'
import type { Article } from '@/types/nostr'
import { useAuthorsProfiles } from '@/hooks/useAuthorsProfiles'
import { generateMnemonicIcons } from '@/lib/mnemonicIcons'
import { t } from '@/lib/i18n'
export type SortOption = 'newest' | 'oldest'
@ -67,10 +68,10 @@ function FiltersHeader({
}) {
return (
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold text-neon-cyan">Filters & Sort</h3>
<h3 className="text-lg font-semibold text-neon-cyan">{t('filters.sort')}</h3>
{hasActiveFilters && (
<button onClick={onClear} className="text-sm text-neon-cyan hover:text-neon-green font-medium transition-colors">
Clear all
{t('filters.clear')}
</button>
)}
</div>
@ -135,12 +136,12 @@ function AuthorFilter({
}
const selectedAuthor = value ? profiles.get(value) : null
const selectedDisplayName = value ? getDisplayName(value) : 'All authors'
const selectedDisplayName = value ? getDisplayName(value) : t('filters.author')
return (
<div className="relative">
<label htmlFor="author-filter" className="block text-sm font-medium text-cyber-accent mb-1">
Author
{t('filters.author')}
</label>
<div className="relative" ref={dropdownRef}>
<button
@ -204,10 +205,10 @@ function AuthorFilter({
role="option"
aria-selected={value === null}
>
<span className="flex-1 text-cyber-accent">All authors</span>
<span className="flex-1 text-cyber-accent">{t('filters.author')}</span>
</button>
{loading ? (
<div className="px-3 py-2 text-sm text-cyber-accent/70">Loading authors...</div>
<div className="px-3 py-2 text-sm text-cyber-accent/70">{t('filters.loading')}</div>
) : (
authors.map((pubkey) => {
const displayName = getDisplayName(pubkey)
@ -273,7 +274,7 @@ function SortFilter({
return (
<div>
<label htmlFor="sort" className="block text-sm font-medium text-cyber-accent mb-1">
Sort by
{t('filters.sort')}
</label>
<select
id="sort"
@ -281,8 +282,8 @@ function SortFilter({
onChange={(e) => onChange(e.target.value as SortOption)}
className="block w-full px-3 py-2 border border-neon-cyan/30 rounded-lg focus:ring-2 focus:ring-neon-cyan focus:border-neon-cyan bg-cyber-dark text-cyber-accent hover:border-neon-cyan/50 transition-colors"
>
<option value="newest" className="bg-cyber-dark">Sponsoring puis date (défaut)</option>
<option value="oldest" className="bg-cyber-dark">Plus anciens d&apos;abord</option>
<option value="newest" className="bg-cyber-dark">{t('filters.sort.newest')}</option>
<option value="oldest" className="bg-cyber-dark">{t('filters.sort.oldest')}</option>
</select>
</div>
)

View File

@ -13,7 +13,7 @@ export function ArticleFormButtons({ loading, onCancel }: ArticleFormButtonsProp
disabled={loading}
className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Publishing...' : 'Publish Article'}
{loading ? 'Publication...' : 'Publier la publication'}
</button>
{onCancel && (
<button
@ -27,4 +27,3 @@ export function ArticleFormButtons({ loading, onCancel }: ArticleFormButtonsProp
</div>
)
}

View File

@ -1,4 +1,6 @@
import React from 'react'
import { t } from '@/lib/i18n'
type CategoryFilter = 'science-fiction' | 'scientific-research' | 'all' | null
interface CategoryTabsProps {
@ -19,7 +21,7 @@ export function CategoryTabs({ selectedCategory, onCategoryChange }: CategoryTab
: 'border-transparent text-cyber-accent/70 hover:text-neon-cyan hover:border-neon-cyan/50'
}`}
>
Science-fiction
{t('category.science-fiction')}
</button>
<button
onClick={() => onCategoryChange('scientific-research')}
@ -29,7 +31,7 @@ export function CategoryTabs({ selectedCategory, onCategoryChange }: CategoryTab
: 'border-transparent text-cyber-accent/70 hover:text-neon-cyan hover:border-neon-cyan/50'
}`}
>
Recherche scientifique
{t('category.scientific-research')}
</button>
</nav>
</div>

View File

@ -0,0 +1,55 @@
import Link from 'next/link'
import { useNostrConnect } from '@/hooks/useNostrConnect'
import { useAuthorPresentation } from '@/hooks/useAuthorPresentation'
import { useEffect, useState } from 'react'
import { t } from '@/lib/i18n'
export function ConditionalPublishButton() {
const { connected, pubkey } = useNostrConnect()
const { checkPresentationExists } = useAuthorPresentation(pubkey ?? null)
const [hasPresentation, setHasPresentation] = useState<boolean | null>(null)
useEffect(() => {
const check = async () => {
if (!connected || !pubkey) {
setHasPresentation(null)
return
}
const presentation = await checkPresentationExists()
setHasPresentation(presentation !== null)
}
void check()
}, [connected, pubkey, checkPresentationExists])
if (!connected || !pubkey) {
return null
}
if (hasPresentation === null) {
return (
<div className="px-4 py-2 bg-neon-cyan/20 text-neon-cyan rounded-lg text-sm font-medium">
{t('nav.loading')}
</div>
)
}
if (!hasPresentation) {
return (
<Link
href="/presentation"
className="px-4 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg text-sm font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan"
>
{t('nav.createAuthorPage')}
</Link>
)
}
return (
<Link
href="/publish"
className="px-4 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg text-sm font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan"
>
{t('nav.publish')}
</Link>
)
}

View File

@ -1,4 +1,5 @@
import Link from 'next/link'
import { t } from '@/lib/i18n'
export function Footer() {
return (
@ -7,19 +8,19 @@ export function Footer() {
<div className="flex flex-col md:flex-row justify-between items-center gap-4 text-sm text-cyber-accent/70">
<div className="flex flex-wrap justify-center gap-4">
<Link href="/legal" className="hover:text-neon-cyan transition-colors">
Mentions légales
{t('footer.legal')}
</Link>
<span className="text-neon-cyan/50"></span>
<Link href="/terms" className="hover:text-neon-cyan transition-colors">
CGU
{t('footer.terms')}
</Link>
<span className="text-neon-cyan/50"></span>
<Link href="/privacy" className="hover:text-neon-cyan transition-colors">
Confidentialité
{t('footer.privacy')}
</Link>
</div>
<div className="text-cyber-accent/50">
© {new Date().getFullYear()} zapwall.fr
© {new Date().getFullYear()} {t('home.title')}
</div>
</div>
</div>

View File

@ -0,0 +1,67 @@
import { useEffect, useState } from 'react'
import { estimatePlatformFunds } from '@/lib/fundingCalculation'
import { t } from '@/lib/i18n'
export function FundingGauge() {
const [stats, setStats] = useState(estimatePlatformFunds())
const [loading, setLoading] = useState(true)
useEffect(() => {
// In a real implementation, this would fetch actual data
// For now, we use the estimate
const loadStats = async () => {
try {
const fundingStats = estimatePlatformFunds()
setStats(fundingStats)
} catch (e) {
console.error('Error loading funding stats:', e)
} finally {
setLoading(false)
}
}
void loadStats()
}, [])
if (loading) {
return (
<div className="bg-cyber-dark/50 border border-neon-cyan/20 rounded-lg p-6 mb-8">
<p className="text-cyber-accent">{t('common.loading')}</p>
</div>
)
}
const progressPercent = Math.min(100, stats.progressPercent)
return (
<div className="bg-cyber-dark/50 border border-neon-cyan/20 rounded-lg p-6 mb-8">
<h2 className="text-xl font-semibold text-neon-cyan mb-4">{t('home.funding.title')}</h2>
<div className="space-y-4">
<div className="flex items-center justify-between text-sm">
<span className="text-cyber-accent">{t('home.funding.current', { current: stats.totalBTC.toFixed(6) })}</span>
<span className="text-cyber-accent">{t('home.funding.target', { target: stats.targetBTC.toFixed(2) })}</span>
</div>
<div className="relative w-full h-4 bg-cyber-dark rounded-full overflow-hidden border border-neon-cyan/30">
<div
className="absolute top-0 left-0 h-full bg-gradient-to-r from-neon-cyan to-neon-green transition-all duration-500"
style={{ width: `${progressPercent}%` }}
/>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-xs font-mono text-cyber-darker font-bold">
{progressPercent.toFixed(1)}%
</span>
</div>
</div>
<p className="text-xs text-cyber-accent/70">
{t('home.funding.progress', { percent: progressPercent.toFixed(1) })}
</p>
<p className="text-sm text-cyber-accent mt-4">
{t('home.funding.description')}
</p>
</div>
</div>
)
}

View File

@ -6,6 +6,7 @@ import { SearchBar } from '@/components/SearchBar'
import { ArticlesList } from '@/components/ArticlesList'
import { PageHeader } from '@/components/PageHeader'
import { Footer } from '@/components/Footer'
import { FundingGauge } from '@/components/FundingGauge'
import type { Dispatch, SetStateAction } from 'react'
interface HomeViewProps {
@ -59,6 +60,7 @@ function ArticlesHero({
Les fonds de la plateforme servent à son développement.
</p>
</div>
<FundingGauge />
<CategoryTabs selectedCategory={selectedCategory} onCategoryChange={setSelectedCategory} />
<div className="mb-4">
<SearchBar value={searchQuery} onChange={setSearchQuery} />

View File

@ -1,23 +1,20 @@
import Link from 'next/link'
import { ConditionalPublishButton } from './ConditionalPublishButton'
import { t } from '@/lib/i18n'
export function PageHeader() {
return (
<header className="bg-cyber-dark border-b border-neon-cyan/30 shadow-glow-cyan">
<div className="max-w-4xl mx-auto px-4 py-4 flex justify-between items-center">
<h1 className="text-2xl font-bold text-neon-cyan text-glow-cyan font-mono">zapwall.fr</h1>
<h1 className="text-2xl font-bold text-neon-cyan text-glow-cyan font-mono">{t('home.title')}</h1>
<div className="flex items-center gap-4">
<Link
href="/docs"
className="px-4 py-2 text-cyber-accent hover:text-neon-cyan text-sm font-medium transition-colors border border-cyber-accent/30 hover:border-neon-cyan/50 rounded hover:shadow-glow-cyan"
>
Documentation
</Link>
<Link
href="/publish"
className="px-4 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg text-sm font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan"
>
Publish Article
{t('nav.documentation')}
</Link>
<ConditionalPublishButton />
</div>
</div>
</header>

View File

@ -1,5 +1,5 @@
import Link from 'next/link'
import { ConnectButton } from '@/components/ConnectButton'
import { ConditionalPublishButton } from './ConditionalPublishButton'
export function ProfileHeader() {
return (
@ -7,12 +7,7 @@ export function ProfileHeader() {
<div className="max-w-4xl mx-auto px-4 py-4 flex justify-between items-center">
<h1 className="text-2xl font-bold text-gray-900">zapwall.fr</h1>
<div className="flex items-center gap-4">
<Link
href="/publish"
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors"
>
Publish Article
</Link>
<ConditionalPublishButton />
<ConnectButton />
</div>
</div>

View File

@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'
import { SearchIcon } from './SearchIcon'
import { ClearButton } from './ClearButton'
import { t } from '@/lib/i18n'
interface SearchBarProps {
value: string
@ -8,7 +9,8 @@ interface SearchBarProps {
placeholder?: string
}
export function SearchBar({ value, onChange, placeholder = 'Search articles...' }: SearchBarProps) {
export function SearchBar({ value, onChange, placeholder }: SearchBarProps) {
const defaultPlaceholder = placeholder ?? t('search.placeholder')
const [localValue, setLocalValue] = useState(value)
useEffect(() => {
@ -35,7 +37,7 @@ export function SearchBar({ value, onChange, placeholder = 'Search articles...'
type="text"
value={localValue}
onChange={handleChange}
placeholder={placeholder}
placeholder={defaultPlaceholder}
className="block w-full pl-10 pr-10 py-2 border border-neon-cyan/30 rounded-lg focus:ring-2 focus:ring-neon-cyan focus:border-neon-cyan bg-cyber-dark text-cyber-accent placeholder-cyber-accent/50 hover:border-neon-cyan/50 transition-colors"
/>
{localValue && <ClearButton onClick={handleClear} />}

View File

@ -1,6 +1,7 @@
import Image from 'next/image'
import Link from 'next/link'
import type { Series } from '@/types/nostr'
import { t } from '@/lib/i18n'
interface SeriesCardProps {
series: Series
@ -29,18 +30,18 @@ export function SeriesCard({ series, onSelect, selected }: SeriesCardProps) {
<h3 className="text-lg font-semibold">{series.title}</h3>
<p className="text-sm text-gray-700 line-clamp-3">{series.description}</p>
<div className="mt-3 flex items-center justify-between text-sm text-gray-600">
<span>{series.category === 'science-fiction' ? 'Science-fiction' : 'Recherche scientifique'}</span>
<span>{series.category === 'science-fiction' ? t('category.science-fiction') : t('category.scientific-research')}</span>
<button
type="button"
className="px-3 py-1 text-sm rounded bg-blue-600 text-white hover:bg-blue-700"
onClick={() => onSelect(series.id)}
>
Ouvrir
{t('common.open')}
</button>
</div>
<div className="mt-2 text-xs text-blue-600">
<Link href={`/series/${series.id}`} className="underline">
Voir la page de la série
{t('series.view')}
</Link>
</div>
</div>

View File

@ -33,17 +33,20 @@ Zapwall est une plateforme décentralisée basée sur Nostr pour la publication
### Implémentation
**Articles** (`lib/paymentPolling.ts`, `lib/articleInvoice.ts`) :
- Validation montant 800 sats à chaque étape
- Tracking avec `author_amount` et `platform_commission` dans événements Nostr
- Récupération adresse Lightning auteur via `lightningAddressService`
- Transfert automatique déclenché (logs, nécessite nœud Lightning pour exécution)
**Sponsoring** (`lib/sponsoringPayment.ts`, `lib/sponsoringTracking.ts`) :
- Validation montant 0.046 BTC
- Vérification transactions Bitcoin via `mempoolSpaceService` (sorties auteur + plateforme)
- Tracking sur Nostr (kind 30079) avec confirmations
**Avis** (`lib/reviewReward.ts`) :
- Validation montant 70 sats
- Mise à jour événement Nostr avec tags `rewarded: true` et `reward_amount: 70`
- Récupération adresse Lightning reviewer
@ -52,11 +55,60 @@ Zapwall est une plateforme décentralisée basée sur Nostr pour la publication
### Tracking
Tous les paiements sont trackés sur Nostr :
- **Kind 30078** : Livraisons de contenu (`lib/platformTracking.ts`)
- **Kind 30079** : Paiements de sponsoring (`lib/sponsoringTracking.ts`)
Les événements incluent `author_amount`, `platform_commission`, `zap_receipt` (si applicable), et sont signés par l'auteur avec tag `p` pour la plateforme.
## Système de tags Nostr
### Nouveau système de tags (tous en anglais)
Tous les contenus sont des notes Nostr (kind 1) avec un système de tags unifié :
- **Type** : `#author`, `#series`, `#publication`, `#quote` (tags simples sans valeur)
- **Catégorie** : `#sciencefiction` ou `#research` (tags simples sans valeur)
- **ID** : `#id_<id>` (tag avec valeur : `['id', '<id>']`)
- **Paywall** : `#paywall` (tag simple, pour les publications payantes)
- **Payment** : `#payment` (tag simple optionnel, pour les notes de paiement)
### Utilitaires (`lib/nostrTagSystem.ts`)
- `buildTags()` : Construit les tags à partir d'un objet typé
- `extractTagsFromEvent()` : Extrait les tags d'un événement
- `buildTagFilter()` : Construit les filtres Nostr pour les requêtes
## Internationalisation (i18n)
### Système de traduction (`lib/i18n.ts`)
- Chargement depuis fichiers texte plats (`public/locales/fr.txt`, `public/locales/en.txt`)
- Format : `key=value` avec support des paramètres `{{param}}`
- Hook `useI18n` pour utiliser les traductions dans les composants
- Initialisé dans `_app.tsx` avec locale par défaut (fr)
### Langues supportées
- Français (fr) : langue par défaut
- Anglais (en) : disponible
- Extensible : ajout de nouvelles langues via fichiers de traduction
## Financement IA
### Jauge de financement (`components/FundingGauge.tsx`)
- Affiche le montant collecté, la cible (0.27 BTC) et le pourcentage
- Calcul des fonds via `lib/fundingCalculation.ts`
- Agrégation de toutes les commissions (articles, avis, sponsoring)
- Description de l'usage des fonds pour le développement IA
### Calcul des fonds (`lib/fundingCalculation.ts`)
- Estimation basée sur les taux de commission
- Agrégation des zap receipts par type (purchase, review_tip, sponsoring)
- Calcul du pourcentage de progression vers la cible
## Flux de paiement article
1. Lecteur clique "Unlock for 800 sats"

View File

@ -55,11 +55,36 @@
- `lib/storage/cryptoHelpers.ts` : Helpers AES-GCM
- `lib/articleStorage.ts` : Gestion du stockage avec chiffrement
## Hiérarchie de contenu
### Structure
- **Page auteur** : Publication publique et gratuite (obligatoire avant de publier)
- **Séries** : Publications publiques et gratuites organisées par auteur
- **Publications** : Contenu confidentiel et gratuit pour l'auteur, payant pour l'accès (800 sats)
### Navigation
- Home : Bouton "Créer page auteur" si pas de présentation, sinon "Publier une publication"
- Page auteur : Résumé du sponsoring et liste des séries
- Page série : Résumé de la série, illustration de couverture et liste des publications
## Système de tags Nostr
### Nouveau système (tous en anglais)
- **Type** : `#author`, `#series`, `#publication`, `#quote` (tags simples)
- **Catégorie** : `#sciencefiction` ou `#research` (tags simples)
- **ID** : `#id_<id>` (tag avec valeur)
- **Paywall** : `#paywall` (pour les publications payantes)
- **Payment** : `#payment` (optionnel, pour les notes de paiement)
### Utilitaires
- `lib/nostrTagSystem.ts` : `buildTags()`, `extractTagsFromEvent()`, `buildTagFilter()`
- Migration complète depuis l'ancien système (`kind_type`, `site`, etc.)
## Séries et médias (NIP-95)
### Séries
- Événements kind 1 avec tag `kind_type: series`
- Tags : `site`, `category`, `author`, `series` (self id), `title`, `description`, `cover`, `preview`
- Événements kind 1 avec tag `#series`
- Tags : `#sciencefiction` ou `#research`, `#id_<id>`, `title`, `description`, `cover`, `preview`
- Agrégation du sponsoring et des paiements par série
### Médias
@ -69,8 +94,8 @@
- Support dans les articles et séries
### Avis (reviews)
- Événements kind 1 avec tag `kind_type: review`
- Tags : `site`, `category`, `author`, `series`, `article`, `reviewer`, `title`
- Événements kind 1 avec tag `#quote`
- Tags : `#sciencefiction` ou `#research`, `#id_<id>`, `#article`, `reviewer`, `title`
- Rémunération possible avec tags `rewarded` et `reward_amount`
## Optimisations et nettoyage
@ -101,3 +126,23 @@
- Vérification de l'invoice avant création d'une nouvelle
- Signature distante (NIP-46) préparée
## Internationalisation (i18n)
### Système de traduction
- Fichiers texte plats : `public/locales/fr.txt`, `public/locales/en.txt`
- Format : `key=value` avec paramètres `{{param}}`
- Hook `useI18n` pour charger et utiliser les traductions
- Initialisation dans `_app.tsx` avec locale par défaut (fr)
### Langues supportées
- Français (fr) : langue par défaut
- Anglais (en) : disponible
- Extensible : ajout de nouvelles langues via fichiers de traduction
## Financement IA
### Jauge de financement
- Composant `FundingGauge` sur la page d'accueil
- Affiche montant collecté, cible (0.27 BTC) et pourcentage
- Calcul via `lib/fundingCalculation.ts`
- Description de l'usage des fonds pour le développement IA (développement et matériel)

View File

@ -46,13 +46,13 @@ export function useArticles(searchQuery: string = '', filters: ArticleFilters |
try {
const article = await nostrService.getArticleById(articleId)
if (article) {
// Try to load private content
const privateContent = await nostrService.getPrivateContent(articleId, authorPubkey)
if (privateContent) {
// Try to decrypt article content using decryption key from private messages
const decryptedContent = await nostrService.getDecryptedArticleContent(articleId, authorPubkey)
if (decryptedContent) {
setArticles((prev) =>
prev.map((a) =>
a.id === articleId
? { ...a, content: privateContent, paid: true }
? { ...a, content: decryptedContent, paid: true }
: a
)
)

37
hooks/useI18n.ts Normal file
View File

@ -0,0 +1,37 @@
import { useEffect, useState } from 'react'
import { setLocale, getLocale, loadTranslations, t, type Locale } from '@/lib/i18n'
export function useI18n(locale: Locale = 'fr') {
const [loaded, setLoaded] = useState(false)
const [currentLocale, setCurrentLocale] = useState<Locale>(getLocale())
useEffect(() => {
const load = async () => {
try {
// Load translations from files in public directory
const frResponse = await fetch('/locales/fr.txt')
const enResponse = await fetch('/locales/en.txt')
if (frResponse.ok) {
const frText = await frResponse.text()
await loadTranslations('fr', frText)
}
if (enResponse.ok) {
const enText = await enResponse.text()
await loadTranslations('en', enText)
}
setLocale(locale)
setCurrentLocale(locale)
setLoaded(true)
} catch (e) {
console.error('Error loading translations:', e)
setLoaded(true) // Continue even if translations fail to load
}
}
void load()
}, [locale])
return { loaded, locale: currentLocale, t }
}

View File

@ -75,13 +75,13 @@ export function useUserArticles(
try {
const article = await nostrService.getArticleById(articleId)
if (article) {
// Try to load private content
const privateContent = await nostrService.getPrivateContent(articleId, authorPubkey)
if (privateContent) {
// Try to decrypt article content using decryption key from private messages
const decryptedContent = await nostrService.getDecryptedArticleContent(articleId, authorPubkey)
if (decryptedContent) {
setArticles((prev) =>
prev.map((a) =>
a.id === articleId
? { ...a, content: privateContent, paid: true }
? { ...a, content: decryptedContent, paid: true }
: a
)
)

165
lib/articleEncryption.ts Normal file
View File

@ -0,0 +1,165 @@
import { nip04 } from 'nostr-tools'
/**
* Encryption service for article content
* Uses AES-GCM for content encryption and NIP-04 for key encryption
*/
export interface EncryptedArticleContent {
encryptedContent: string
encryptedKey: string
iv: string
}
export interface DecryptionKey {
key: string
iv: string
}
/**
* Generate a random encryption key for AES-GCM
*/
function generateEncryptionKey(): string {
const keyBytes = crypto.getRandomValues(new Uint8Array(32))
return Array.from(keyBytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
}
/**
* Generate a random IV for AES-GCM
*/
function generateIV(): Uint8Array {
return crypto.getRandomValues(new Uint8Array(12))
}
/**
* Convert hex string to ArrayBuffer
*/
function hexToArrayBuffer(hex: string): ArrayBuffer {
const bytes = new Uint8Array(hex.length / 2)
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substr(i, 2), 16)
}
return bytes.buffer
}
/**
* Convert ArrayBuffer to hex string
*/
function arrayBufferToHex(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer)
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
}
/**
* Encrypt article content with AES-GCM
* Returns encrypted content, IV, and the encryption key
*/
export async function encryptArticleContent(content: string): Promise<{
encryptedContent: string
key: string
iv: string
}> {
const key = generateEncryptionKey()
const iv = generateIV()
const keyBuffer = hexToArrayBuffer(key)
const cryptoKey = await crypto.subtle.importKey(
'raw',
keyBuffer,
{ name: 'AES-GCM' },
false,
['encrypt']
)
const encoder = new TextEncoder()
const encodedContent = encoder.encode(content)
const ivBuffer = iv.buffer instanceof ArrayBuffer ? iv.buffer : new ArrayBuffer(iv.byteLength)
const ivView = new Uint8Array(ivBuffer, 0, iv.byteLength)
ivView.set(iv)
const encryptedBuffer = await crypto.subtle.encrypt(
{
name: 'AES-GCM',
iv: ivView,
},
cryptoKey,
encodedContent
)
const encryptedContent = arrayBufferToHex(encryptedBuffer)
const ivHex = arrayBufferToHex(ivView.buffer)
return {
encryptedContent,
key,
iv: ivHex,
}
}
/**
* Decrypt article content with AES-GCM using the provided key and IV
*/
export async function decryptArticleContent(
encryptedContent: string,
key: string,
iv: string
): Promise<string> {
const keyBuffer = hexToArrayBuffer(key)
const ivBuffer = hexToArrayBuffer(iv)
const cryptoKey = await crypto.subtle.importKey(
'raw',
keyBuffer,
{ name: 'AES-GCM' },
false,
['decrypt']
)
const encryptedBuffer = hexToArrayBuffer(encryptedContent)
const decryptedBuffer = await crypto.subtle.decrypt(
{
name: 'AES-GCM',
iv: ivBuffer,
},
cryptoKey,
encryptedBuffer
)
const decoder = new TextDecoder()
return decoder.decode(decryptedBuffer)
}
/**
* Encrypt the decryption key using NIP-04 (for storage in tags)
* The key is encrypted with the author's public key
*/
export async function encryptDecryptionKey(
key: string,
iv: string,
authorPrivateKey: string,
authorPublicKey: string
): Promise<string> {
const keyData: DecryptionKey = { key, iv }
const keyJson = JSON.stringify(keyData)
const encryptedKey = await Promise.resolve(nip04.encrypt(authorPrivateKey, authorPublicKey, keyJson))
return encryptedKey
}
/**
* Decrypt the decryption key from a private message
*/
export async function decryptDecryptionKey(
encryptedKey: string,
recipientPrivateKey: string,
authorPublicKey: string
): Promise<DecryptionKey> {
const decryptedJson = await Promise.resolve(nip04.decrypt(recipientPrivateKey, authorPublicKey, encryptedKey))
const keyData = JSON.parse(decryptedJson) as DecryptionKey
return keyData
}

View File

@ -1,5 +1,6 @@
import { getAlbyService } from './alby'
import { calculateArticleSplit, PLATFORM_COMMISSIONS } from './platformCommissions'
import { buildTags } from './nostrTagSystem'
import type { AlbyInvoice } from '@/types/alby'
import type { ArticleDraft } from './articlePublisher'
@ -39,51 +40,61 @@ export async function createArticleInvoice(draft: ArticleDraft): Promise<AlbyInv
/**
* Create preview event with invoice tags
* If encryptedContent is provided, it will be used instead of preview
*/
export function createPreviewEvent(
draft: ArticleDraft,
invoice: AlbyInvoice,
authorPresentationId?: string,
extraTags: string[][] = []
extraTags: string[][] = [],
encryptedContent?: string,
encryptedKey?: string
): {
kind: 1
created_at: number
tags: string[][]
content: string
} {
const tags = buildPreviewTags(draft, invoice, authorPresentationId, extraTags)
const tags = buildPreviewTags(draft, invoice, authorPresentationId, extraTags, encryptedKey)
return {
kind: 1 as const,
created_at: Math.floor(Date.now() / 1000),
tags,
content: draft.preview,
content: encryptedContent ?? draft.preview,
}
}
function buildPreviewTags(
draft: ArticleDraft,
invoice: AlbyInvoice,
authorPresentationId?: string,
extraTags: string[][] = []
_authorPresentationId?: string,
extraTags: string[][] = [],
encryptedKey?: string
): string[][] {
const base: string[][] = [
['title', draft.title],
['preview', draft.preview],
['zap', draft.zapAmount.toString()],
['content-type', 'article'],
['invoice', invoice.invoice],
['payment_hash', invoice.paymentHash],
]
if (draft.category) {
base.push(['category', draft.category])
}
if (authorPresentationId) {
base.push(['author_presentation_id', authorPresentationId])
}
// Preserve any kind_type tags in extraTags if provided by caller
// Map category to new system
const category = draft.category === 'science-fiction' ? 'sciencefiction' : draft.category === 'scientific-research' ? 'research' : 'sciencefiction'
// Build tags using new system
const newTags = buildTags({
type: 'publication',
category,
id: '', // Will be set to event.id after publication
paywall: true, // Publications are paid
title: draft.title,
preview: draft.preview,
zapAmount: draft.zapAmount,
invoice: invoice.invoice,
paymentHash: invoice.paymentHash,
...(draft.seriesId ? { seriesId: draft.seriesId } : {}),
...(draft.bannerUrl ? { bannerUrl: draft.bannerUrl } : {}),
...(encryptedKey ? { encryptedKey } : {}),
})
// Add any extra tags (for backward compatibility)
if (extraTags.length > 0) {
base.push(...extraTags)
newTags.push(...extraTags)
}
return base
return newTags
}

View File

@ -1,7 +1,7 @@
import { nostrService } from './nostr'
import { createArticleInvoice, createPreviewEvent } from './articleInvoice'
import { storePrivateContent, getStoredPrivateContent } from './articleStorage'
import { buildReviewTags, buildSeriesTags } from './nostrTags'
import { buildTags } from './nostrTagSystem'
import type { ArticleDraft, PublishedArticle } from './articlePublisher'
import type { AlbyInvoice } from '@/types/alby'
import type { Review, Series } from '@/types/nostr'
@ -10,8 +10,6 @@ export interface ArticleUpdateResult extends PublishedArticle {
originalArticleId: string
}
const SITE_TAG = process.env.NEXT_PUBLIC_SITE_TAG ?? 'zapwall4science'
function ensureKeys(authorPubkey: string, authorPrivateKey?: string): void {
nostrService.setPublicKey(authorPubkey)
if (authorPrivateKey) {
@ -85,20 +83,22 @@ function buildSeriesEvent(
},
category: NonNullable<ArticleDraft['category']>
) {
// Map category to new system
const newCategory = category === 'science-fiction' ? 'sciencefiction' : 'research'
return {
kind: 1,
created_at: Math.floor(Date.now() / 1000),
content: params.preview ?? params.description.substring(0, 200),
tags: buildSeriesTags({
site: SITE_TAG,
category,
author: params.authorPubkey,
seriesId: 'pending',
tags: buildTags({
type: 'series',
category: newCategory,
id: '', // Will be set to event.id after publication
paywall: false,
title: params.title,
description: params.description,
...(params.preview ? { preview: params.preview } : { preview: params.description.substring(0, 200) }),
preview: params.preview ?? params.description.substring(0, 200),
...(params.coverUrl ? { coverUrl: params.coverUrl } : {}),
kindType: 'series',
}),
}
}
@ -144,19 +144,21 @@ function buildReviewEvent(
},
category: NonNullable<ArticleDraft['category']>
) {
// Map category to new system
const newCategory = category === 'science-fiction' ? 'sciencefiction' : 'research'
return {
kind: 1,
created_at: Math.floor(Date.now() / 1000),
content: params.content,
tags: buildReviewTags({
site: SITE_TAG,
category,
author: params.authorPubkey,
seriesId: params.seriesId,
tags: buildTags({
type: 'quote',
category: newCategory,
id: '', // Will be set to event.id after publication
paywall: false,
articleId: params.articleId,
reviewer: params.reviewerPubkey,
reviewerPubkey: params.reviewerPubkey,
...(params.title ? { title: params.title } : {}),
kindType: 'review',
}),
}
}
@ -173,14 +175,26 @@ export async function publishArticleUpdate(
requireCategory(category)
const presentationId = await ensurePresentation(authorPubkey)
const invoice = await createArticleInvoice(draft)
const publishedEvent = await publishPreviewWithInvoice(draft, invoice, presentationId, [
['e', originalArticleId],
['replace', 'article-update'],
['kind_type', 'article'],
['site', SITE_TAG],
['category', category],
...(draft.seriesId ? [['series', draft.seriesId]] : []),
])
// Map category to new system
const newCategory = category === 'science-fiction' ? 'sciencefiction' : 'research'
// Build tags using new system (update event references original)
const updateTags = buildTags({
type: 'publication',
category: newCategory,
id: '', // Will be set to event.id after publication
paywall: true,
title: draft.title,
preview: draft.preview,
zapAmount: draft.zapAmount,
...(draft.seriesId ? { seriesId: draft.seriesId } : {}),
...(draft.bannerUrl ? { bannerUrl: draft.bannerUrl } : {}),
})
// Add reference to original article
updateTags.push(['e', originalArticleId], ['replace', 'article-update'])
const publishedEvent = await publishPreviewWithInvoice(draft, invoice, presentationId, updateTags)
if (!publishedEvent) {
return updateFailure(originalArticleId, 'Failed to publish article update')
}

View File

@ -10,6 +10,10 @@ import {
} from './articleStorage'
import { createArticleInvoice, createPreviewEvent } from './articleInvoice'
import { buildPresentationEvent, fetchAuthorPresentationFromPool, sendEncryptedContent } from './articlePublisherHelpers'
import {
encryptArticleContent,
encryptDecryptionKey,
} from './articleEncryption'
export interface ArticleDraft {
title: string
@ -44,7 +48,7 @@ export interface PublishedArticle {
* Handles publishing preview (public note), creating invoice, and storing full content for later private message
*/
export class ArticlePublisher {
private readonly siteTag = process.env.NEXT_PUBLIC_SITE_TAG ?? 'zapwall4science'
// Removed unused siteTag - using new tag system instead
private buildFailure(error?: string): PublishedArticle {
const base: PublishedArticle = {
@ -83,25 +87,18 @@ export class ArticlePublisher {
draft: ArticleDraft,
invoice: AlbyInvoice,
presentationId: string,
extraTags?: string[][]
extraTags?: string[][],
encryptedContent?: string,
encryptedKey?: string
): Promise<import('nostr-tools').Event | null> {
const previewEvent = createPreviewEvent(draft, invoice, presentationId, extraTags)
const previewEvent = createPreviewEvent(draft, invoice, presentationId, extraTags, encryptedContent, encryptedKey)
const publishedEvent = await nostrService.publishEvent(previewEvent)
return publishedEvent ?? null
}
private buildArticleExtraTags(draft: ArticleDraft, category: NonNullable<ArticleDraft['category']>): string[][] {
const extraTags: string[][] = [
['kind_type', 'article'],
['site', this.siteTag],
['category', category],
]
if (draft.seriesId) {
extraTags.push(['series', draft.seriesId])
}
if (draft.bannerUrl) {
extraTags.push(['banner', draft.bannerUrl])
}
private buildArticleExtraTags(draft: ArticleDraft, _category: NonNullable<ArticleDraft['category']>): string[][] {
// Media tags are still supported in the new system
const extraTags: string[][] = []
if (draft.media && draft.media.length > 0) {
draft.media.forEach((m) => {
extraTags.push(['media', m.url, m.type])
@ -111,9 +108,9 @@ export class ArticlePublisher {
}
/**
* Publish an article preview as a public note (kind:1)
* Publish an article with encrypted content as a public note (kind:1)
* Creates a Lightning invoice for the article
* The full content will be sent as encrypted private message after payment
* The content is encrypted and published, and the decryption key is sent via private message after payment
*/
async publishArticle(
draft: ArticleDraft,
@ -126,6 +123,11 @@ export class ArticlePublisher {
return this.buildFailure(keySetup.error)
}
const authorPrivateKeyForEncryption = authorPrivateKey ?? nostrService.getPrivateKey()
if (!authorPrivateKeyForEncryption) {
return this.buildFailure('Private key required for encryption')
}
const presentation = await this.getAuthorPresentation(authorPubkey)
if (!presentation) {
return this.buildFailure('Vous devez créer un article de présentation avant de publier des articles.')
@ -144,14 +146,34 @@ export class ArticlePublisher {
)
}
// Encrypt the article content
const { encryptedContent, key, iv } = await encryptArticleContent(draft.content)
// Encrypt the decryption key with the author's public key (for storage in tags)
const encryptedKey = await encryptDecryptionKey(key, iv, authorPrivateKeyForEncryption, authorPubkey)
const invoice = await createArticleInvoice(draft)
const extraTags = this.buildArticleExtraTags(draft, category)
const publishedEvent = await this.publishPreview(draft, invoice, presentation.id, extraTags)
const publishedEvent = await this.publishPreview(
draft,
invoice,
presentation.id,
extraTags,
encryptedContent,
encryptedKey
)
if (!publishedEvent) {
return this.buildFailure('Failed to publish article')
}
await storePrivateContent(publishedEvent.id, draft.content, authorPubkey, invoice)
// Store the decryption key locally for sending after payment
await storePrivateContent(publishedEvent.id, draft.content, authorPubkey, invoice, key, iv)
console.log('Article published with encrypted content', {
articleId: publishedEvent.id,
authorPubkey,
timestamp: new Date().toISOString(),
})
return { articleId: publishedEvent.id, previewEventId: publishedEvent.id, invoice, success: true }
} catch (error) {
@ -256,7 +278,9 @@ export class ArticlePublisher {
nostrService.setPublicKey(authorPubkey)
nostrService.setPrivateKey(authorPrivateKey)
const publishedEvent = await nostrService.publishEvent(buildPresentationEvent(draft))
// Generate event ID before building event (using a temporary ID that will be replaced by Nostr)
const tempEventId = 'temp_' + Math.random().toString(36).substring(7)
const publishedEvent = await nostrService.publishEvent(buildPresentationEvent(draft, tempEventId, 'sciencefiction'))
if (!publishedEvent) {
return this.buildFailure('Failed to publish presentation article')

View File

@ -2,48 +2,49 @@ import { nip04, type Event } from 'nostr-tools'
import { nostrService } from './nostr'
import type { AuthorPresentationDraft } from './articlePublisher'
import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
import { buildTags, extractTagsFromEvent, buildTagFilter } from './nostrTagSystem'
const RELAY_URL = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL ?? 'wss://relay.damus.io'
export function buildPresentationEvent(draft: AuthorPresentationDraft) {
export function buildPresentationEvent(draft: AuthorPresentationDraft, eventId: string, category: 'sciencefiction' | 'research' = 'sciencefiction') {
return {
kind: 1 as const,
created_at: Math.floor(Date.now() / 1000),
tags: [
['title', draft.title],
['preview', draft.preview],
['category', 'author-presentation'],
['presentation', 'true'],
['mainnet_address', draft.mainnetAddress],
['total_sponsoring', '0'],
['content-type', 'author-presentation'],
],
tags: buildTags({
type: 'author',
category,
id: eventId,
paywall: false,
title: draft.title,
preview: draft.preview,
mainnetAddress: draft.mainnetAddress,
totalSponsoring: 0,
}),
content: draft.content,
}
}
export function parsePresentationEvent(event: Event): import('@/types/nostr').AuthorPresentationArticle | null {
const isPresentation = event.tags.some((tag) => tag[0] === 'presentation' && tag[1] === 'true')
if (!isPresentation) {
const tags = extractTagsFromEvent(event)
// Check if it's an author type (tag is 'author' in English)
if (tags.type !== 'author') {
return null
}
const mainnetAddressTag = event.tags.find((tag) => tag[0] === 'mainnet_address')
const sponsoringTag = event.tags.find((tag) => tag[0] === 'total_sponsoring')
return {
id: event.id,
id: tags.id ?? event.id,
pubkey: event.pubkey,
title: event.tags.find((tag) => tag[0] === 'title')?.[1] ?? 'Présentation',
preview: event.tags.find((tag) => tag[0] === 'preview')?.[1] ?? event.content.substring(0, 200),
title: (tags.title as string | undefined) ?? 'Présentation',
preview: (tags.preview as string | undefined) ?? event.content.substring(0, 200),
content: event.content,
createdAt: event.created_at,
zapAmount: 0,
paid: true,
category: 'author-presentation',
isPresentation: true,
mainnetAddress: mainnetAddressTag?.[1] ?? '',
totalSponsoring: sponsoringTag ? parseInt(sponsoringTag[1] ?? '0', 10) : 0,
mainnetAddress: (tags.mainnetAddress as string | undefined) ?? '',
totalSponsoring: (tags.totalSponsoring as number | undefined) ?? 0,
}
}
@ -53,9 +54,10 @@ export function fetchAuthorPresentationFromPool(
): Promise<import('@/types/nostr').AuthorPresentationArticle | null> {
const filters = [
{
kinds: [1],
authors: [pubkey],
'#category': ['author-presentation'],
...buildTagFilter({
type: 'author',
authorPubkey: pubkey,
}),
limit: 1,
},
]
@ -95,14 +97,20 @@ export interface SendContentResult {
export async function sendEncryptedContent(
articleId: string,
recipientPubkey: string,
storedContent: { content: string; authorPubkey: string },
storedContent: { content: string; authorPubkey: string; decryptionKey?: string; decryptionIV?: string },
authorPrivateKey: string
): Promise<SendContentResult> {
try {
nostrService.setPrivateKey(authorPrivateKey)
nostrService.setPublicKey(storedContent.authorPubkey)
const encryptedContent = await Promise.resolve(nip04.encrypt(authorPrivateKey, recipientPubkey, storedContent.content))
// Send the decryption key instead of the full content
// The key is sent as JSON: { key: string, iv: string }
const keyData = storedContent.decryptionKey && storedContent.decryptionIV
? JSON.stringify({ key: storedContent.decryptionKey, iv: storedContent.decryptionIV })
: storedContent.content // Fallback to old behavior if keys are not available
const encryptedKey = await Promise.resolve(nip04.encrypt(authorPrivateKey, recipientPubkey, keyData))
const privateMessageEvent = {
kind: 4,
@ -111,7 +119,7 @@ export async function sendEncryptedContent(
['p', recipientPubkey],
['e', articleId],
],
content: encryptedContent,
content: encryptedKey,
}
const publishedEvent = await nostrService.publishEvent(privateMessageEvent)

View File

@ -3,6 +3,7 @@ import { nostrService } from './nostr'
import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
import type { Article } from '@/types/nostr'
import { parseArticleFromEvent } from './nostrEventParsing'
import { buildTagFilter } from './nostrTagSystem'
const RELAY_URL = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL ?? 'wss://relay.damus.io'
@ -14,8 +15,10 @@ export function getArticlesBySeries(seriesId: string, timeoutMs: number = 5000,
const poolWithSub = pool as SimplePoolWithSub
const filters = [
{
kinds: [1],
'#series': [seriesId],
...buildTagFilter({
type: 'publication',
seriesId,
}),
limit,
},
]

View File

@ -5,6 +5,8 @@ interface StoredArticleData {
content: string
authorPubkey: string
articleId: string
decryptionKey?: string
decryptionIV?: string
invoice: {
invoice: string
paymentHash: string
@ -46,12 +48,15 @@ function deriveSecret(articleId: string): string {
* Also stores the invoice if provided
* Uses IndexedDB exclusively
* Content expires after 30 days by default
* If decryptionKey and decryptionIV are provided, they will be stored for sending after payment
*/
export async function storePrivateContent(
articleId: string,
content: string,
authorPubkey: string,
invoice?: AlbyInvoice
invoice?: AlbyInvoice,
decryptionKey?: string,
decryptionIV?: string
): Promise<void> {
try {
const key = `article_private_content_${articleId}`
@ -60,6 +65,8 @@ export async function storePrivateContent(
content,
authorPubkey,
articleId,
...(decryptionKey ? { decryptionKey } : {}),
...(decryptionIV ? { decryptionIV } : {}),
invoice: invoice
? {
invoice: invoice.invoice,
@ -85,6 +92,8 @@ export async function storePrivateContent(
export async function getStoredPrivateContent(articleId: string): Promise<{
content: string
authorPubkey: string
decryptionKey?: string
decryptionIV?: string
invoice?: AlbyInvoice
} | null> {
try {
@ -99,6 +108,8 @@ export async function getStoredPrivateContent(articleId: string): Promise<{
return {
content: data.content,
authorPubkey: data.authorPubkey,
...(data.decryptionKey ? { decryptionKey: data.decryptionKey } : {}),
...(data.decryptionIV ? { decryptionIV: data.decryptionIV } : {}),
...(data.invoice
? {
invoice: {

95
lib/fundingCalculation.ts Normal file
View File

@ -0,0 +1,95 @@
/**
* Calculate total platform funds collected
* Aggregates all commission types: articles, reviews, sponsoring
*/
import { PLATFORM_COMMISSIONS } from './platformCommissions'
import { aggregateZapSats } from './zapAggregation'
const FUNDING_TARGET_BTC = 0.27
const FUNDING_TARGET_SATS = FUNDING_TARGET_BTC * 100_000_000
export interface FundingStats {
totalSats: number
totalBTC: number
targetBTC: number
targetSats: number
progressPercent: number
}
/**
* Calculate total platform funds from all sources
* This is an approximation based on commission rates
* Actual calculation would require querying all transactions
*/
export async function calculatePlatformFunds(_authorPubkeys: string[]): Promise<FundingStats> {
let totalSats = 0
// Calculate article commissions (from zap receipts with kind_type: purchase)
// Each article payment is 800 sats, platform gets 100 sats
// This is an approximation - in reality we'd query all zap receipts
try {
const articleCommissions = await aggregateZapSats({
authorPubkey: '', // Empty to get all
kindType: 'purchase',
})
// Estimate: assume 100 sats commission per purchase (800 total, 700 author, 100 platform)
// This is simplified - actual calculation would need to track each payment
totalSats += Math.floor(articleCommissions * (PLATFORM_COMMISSIONS.article.platform / PLATFORM_COMMISSIONS.article.total))
} catch (e) {
console.error('Error calculating article commissions:', e)
}
// Calculate review commissions (from zap receipts with kind_type: review_tip)
// Each review tip is 70 sats, platform gets 21 sats
try {
const reviewCommissions = await aggregateZapSats({
authorPubkey: '', // Empty to get all
kindType: 'review_tip',
})
// Estimate: assume 21 sats commission per review tip (70 total, 49 reviewer, 21 platform)
totalSats += Math.floor(reviewCommissions * (PLATFORM_COMMISSIONS.review.platform / PLATFORM_COMMISSIONS.review.total))
} catch (e) {
console.error('Error calculating review commissions:', e)
}
// Calculate sponsoring commissions (from zap receipts with kind_type: sponsoring)
// Each sponsoring is 0.046 BTC, platform gets 0.004 BTC (400,000 sats)
try {
const sponsoringCommissions = await aggregateZapSats({
authorPubkey: '', // Empty to get all
kindType: 'sponsoring',
})
// Estimate: assume 400,000 sats commission per sponsoring (4,600,000 total, 4,200,000 author, 400,000 platform)
totalSats += Math.floor(sponsoringCommissions * (PLATFORM_COMMISSIONS.sponsoring.platformSats / PLATFORM_COMMISSIONS.sponsoring.totalSats))
} catch (e) {
console.error('Error calculating sponsoring commissions:', e)
}
const totalBTC = totalSats / 100_000_000
const progressPercent = Math.min(100, (totalBTC / FUNDING_TARGET_BTC) * 100)
return {
totalSats,
totalBTC,
targetBTC: FUNDING_TARGET_BTC,
targetSats: FUNDING_TARGET_SATS,
progressPercent,
}
}
/**
* Simplified version that estimates based on known commission rates
* For a more accurate calculation, we'd need to track all transactions
*/
export function estimatePlatformFunds(): FundingStats {
// This is a placeholder - actual implementation would query all transactions
// For now, return 0 as we need to implement proper tracking
return {
totalSats: 0,
totalBTC: 0,
targetBTC: FUNDING_TARGET_BTC,
targetSats: FUNDING_TARGET_SATS,
progressPercent: 0,
}
}

78
lib/i18n.ts Normal file
View File

@ -0,0 +1,78 @@
/**
* Internationalization system
* Loads translations from flat text files
*/
export type Locale = 'fr' | 'en'
export interface Translations {
[key: string]: string
}
let currentLocale: Locale = 'fr'
const translations: Map<Locale, Translations> = new Map()
/**
* Set current locale
*/
export function setLocale(locale: Locale): void {
currentLocale = locale
}
/**
* Get current locale
*/
export function getLocale(): Locale {
return currentLocale
}
/**
* Load translations from a flat text file
* Format: key=value (one per line, empty lines and lines starting with # are ignored)
*/
export async function loadTranslations(locale: Locale, translationsText: string): Promise<void> {
const translationsMap: Translations = {}
const lines = translationsText.split('\n')
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) {
continue
}
const equalIndex = trimmed.indexOf('=')
if (equalIndex === -1) {
continue
}
const key = trimmed.substring(0, equalIndex).trim()
const value = trimmed.substring(equalIndex + 1).trim()
if (key && value) {
translationsMap[key] = value
}
}
translations.set(locale, translationsMap)
}
/**
* Get translated string
*/
export function t(key: string, params?: Record<string, string | number>): string {
const localeTranslations = translations.get(currentLocale) ?? {}
let text = localeTranslations[key] ?? key
// Replace parameters
if (params) {
Object.entries(params).forEach(([paramKey, paramValue]) => {
text = text.replace(new RegExp(`\\{\\{${paramKey}\\}\\}`, 'g'), String(paramValue))
})
}
return text
}
/**
* Get all available locales
*/
export function getAvailableLocales(): Locale[] {
return Array.from(translations.keys())
}

View File

@ -2,7 +2,11 @@ import { Event, EventTemplate, getEventHash, signEvent, nip19, SimplePool } from
import type { Article, NostrProfile } from '@/types/nostr'
import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
import { parseArticleFromEvent } from './nostrEventParsing'
import { getPrivateContent as getPrivateContentFromPool } from './nostrPrivateMessages'
import {
getPrivateContent as getPrivateContentFromPool,
getDecryptionKey,
decryptArticleContentWithKey,
} from './nostrPrivateMessages'
import { checkZapReceipt as checkZapReceiptHelper } from './nostrZapVerification'
import { subscribeWithTimeout } from './nostrSubscription'
@ -94,9 +98,14 @@ class NostrService {
throw new Error('Pool not initialized')
}
// Use new tag system to filter publications
// Import synchronously since this is not async
const { buildTagFilter } = require('./nostrTagSystem')
const filters = [
{
kinds: [1], // Text notes (includes both articles and presentation articles)
...buildTagFilter({
type: 'publication',
}),
limit,
},
]
@ -137,6 +146,67 @@ class NostrService {
return getPrivateContentFromPool(this.pool, eventId, authorPubkey, this.privateKey, this.publicKey)
}
/**
* Get and decrypt article content using decryption key from private message
* First retrieves the article event to get the encrypted content,
* then retrieves the decryption key from private messages,
* and finally decrypts the content
*/
async getDecryptedArticleContent(eventId: string, authorPubkey: string): Promise<string | null> {
if (!this.privateKey || !this.pool || !this.publicKey) {
throw new Error('Private key not set or pool not initialized')
}
try {
// Get the raw event to retrieve the encrypted content
const event = await this.getEventById(eventId)
if (!event) {
console.error('Event not found', { eventId, authorPubkey })
return null
}
const encryptedContent = event.content
// Try to get the decryption key from private messages
const decryptionKey = await getDecryptionKey(
this.pool,
eventId,
authorPubkey,
this.privateKey,
this.publicKey
)
if (!decryptionKey) {
console.warn('Decryption key not found in private messages', { eventId, authorPubkey })
return null
}
// Decrypt the content using the key
const decryptedContent = await decryptArticleContentWithKey(encryptedContent, decryptionKey)
return decryptedContent
} catch (error) {
console.error('Error decrypting article content', {
eventId,
authorPubkey,
error: error instanceof Error ? error.message : 'Unknown error',
})
return null
}
}
/**
* Get event by ID (helper method)
*/
private async getEventById(eventId: string): Promise<Event | null> {
if (!this.pool) {
throw new Error('Pool not initialized')
}
const filters = [{ ids: [eventId], kinds: [1] }]
return subscribeWithTimeout(this.pool, filters, (event: Event) => event, 5000)
}
getProfile(pubkey: string): Promise<NostrProfile | null> {
if (!this.pool) {
throw new Error('Pool not initialized')

View File

@ -1,16 +1,19 @@
import type { Event } from 'nostr-tools'
import type { Article, KindType, MediaRef, Review, Series } from '@/types/nostr'
import { extractTagsFromEvent } from './nostrTagSystem'
/**
* Parse article metadata from Nostr event
* Uses new tag system: #publication, #sciencefiction|research, #id_<id>, #paywall, #payment
*/
export function parseArticleFromEvent(event: Event): Article | null {
try {
const tags = extractTags(event)
if (tags.kindType && tags.kindType !== 'article') {
const tags = extractTagsFromEvent(event)
// Check if it's a publication type
if (tags.type !== 'publication') {
return null
}
const { previewContent } = getPreviewContent(event.content, tags.preview)
const { previewContent } = getPreviewContent(event.content, tags.preview as string | undefined)
return buildArticle(event, tags, previewContent)
} catch (e) {
console.error('Error parsing article:', e)
@ -20,25 +23,26 @@ export function parseArticleFromEvent(event: Event): Article | null {
export function parseSeriesFromEvent(event: Event): Series | null {
try {
const tags = extractTags(event)
if (tags.kindType && tags.kindType !== 'series') {
const tags = extractTagsFromEvent(event)
// Check if it's a series type (tag is 'series' in English)
if (tags.type !== 'series') {
return null
}
if (!tags.title || !tags.description) {
return null
}
// Map category from new system to old system
const category = tags.category === 'sciencefiction' ? 'science-fiction' : tags.category === 'research' ? 'scientific-research' : 'science-fiction'
const series: Series = {
id: event.id,
id: tags.id ?? event.id,
pubkey: event.pubkey,
title: tags.title,
description: tags.description,
preview: tags.preview ?? event.content.substring(0, 200),
...(tags.category ? { category: tags.category } : { category: 'science-fiction' }),
...(tags.coverUrl ? { coverUrl: tags.coverUrl } : {}),
}
if (tags.kindType) {
series.kindType = tags.kindType
title: tags.title as string,
description: tags.description as string,
preview: (tags.preview as string | undefined) ?? event.content.substring(0, 200),
category,
...(tags.coverUrl ? { coverUrl: tags.coverUrl as string } : {}),
}
series.kindType = 'series'
return series
} catch (e) {
console.error('Error parsing series:', e)
@ -48,12 +52,13 @@ export function parseSeriesFromEvent(event: Event): Series | null {
export function parseReviewFromEvent(event: Event): Review | null {
try {
const tags = extractTags(event)
if (tags.kindType && tags.kindType !== 'review') {
const tags = extractTagsFromEvent(event)
// Check if it's a quote type (reviews are quotes, tag is 'quote' in English)
if (tags.type !== 'quote') {
return null
}
const articleId = tags.articleId
const reviewer = tags.reviewerPubkey
const articleId = tags.articleId as string | undefined
const reviewer = tags.reviewerPubkey as string | undefined
if (!articleId || !reviewer) {
return null
}
@ -61,19 +66,17 @@ export function parseReviewFromEvent(event: Event): Review | null {
const rewardAmountTag = event.tags.find((tag) => tag[0] === 'reward_amount')
const review: Review = {
id: event.id,
id: tags.id ?? event.id,
articleId,
authorPubkey: tags.author ?? event.pubkey,
authorPubkey: event.pubkey,
reviewerPubkey: reviewer,
content: event.content,
createdAt: event.created_at,
...(tags.title ? { title: tags.title } : {}),
...(tags.title ? { title: tags.title as string } : {}),
...(rewardedTag ? { rewarded: true } : {}),
...(rewardAmountTag ? { rewardAmount: parseInt(rewardAmountTag[1] ?? '0', 10) } : {}),
}
if (tags.kindType) {
review.kindType = tags.kindType
}
review.kindType = 'review'
return review
} catch (e) {
console.error('Error parsing review:', e)
@ -81,12 +84,16 @@ export function parseReviewFromEvent(event: Event): Review | null {
}
}
function extractTags(event: Event) {
const findTag = (key: string) => event.tags.find((tag) => tag[0] === key)?.[1]
const mediaTags = event.tags.filter((tag) => tag[0] === 'media')
// extractTags is now replaced by extractTagsFromEvent from nostrTagSystem
// This function is kept for backward compatibility but should be migrated
// Currently unused - kept for potential future migration
// @ts-expect-error - Unused function kept for backward compatibility
function _unusedExtractTags(event: Event) {
const tags = extractTagsFromEvent(event)
const mediaTags = event.tags.filter((tag: string[]) => tag[0] === 'media')
const media: MediaRef[] =
mediaTags
.map((tag) => {
.map((tag: string[]) => {
const url = tag[1]
const type = tag[2] === 'video' ? 'video' : 'image'
if (!url) {
@ -96,26 +103,30 @@ function extractTags(event: Event) {
})
.filter(Boolean) as MediaRef[]
// Map category from new system to old system
const category = tags.category === 'sciencefiction' ? 'science-fiction' : tags.category === 'research' ? 'scientific-research' : undefined
const isPresentation = tags.type === 'author'
return {
title: findTag('title') ?? 'Untitled',
preview: findTag('preview'),
description: findTag('description'),
zapAmount: parseInt(findTag('zap') ?? '800', 10),
invoice: findTag('invoice'),
paymentHash: findTag('payment_hash'),
category: findTag('category') as import('@/types/nostr').ArticleCategory | undefined,
isPresentation: findTag('presentation') === 'true',
mainnetAddress: findTag('mainnet_address'),
totalSponsoring: parseInt(findTag('total_sponsoring') ?? '0', 10),
authorPresentationId: findTag('author_presentation_id'),
seriesId: findTag('series'),
bannerUrl: findTag('banner'),
coverUrl: findTag('cover'),
title: (tags.title as string | undefined) ?? 'Untitled',
preview: tags.preview as string | undefined,
description: tags.description as string | undefined,
zapAmount: (tags.zapAmount as number | undefined) ?? 800,
invoice: tags.invoice as string | undefined,
paymentHash: tags.paymentHash as string | undefined,
category,
isPresentation,
mainnetAddress: tags.mainnetAddress as string | undefined,
totalSponsoring: (tags.totalSponsoring as number | undefined) ?? 0,
authorPresentationId: undefined, // Not used in new system
seriesId: tags.seriesId as string | undefined,
bannerUrl: tags.bannerUrl as string | undefined,
coverUrl: tags.coverUrl as string | undefined,
media,
kindType: findTag('kind_type') as KindType | undefined,
articleId: findTag('article'),
reviewerPubkey: findTag('reviewer'),
author: findTag('author'),
kindType: tags.type === 'author' ? 'article' : tags.type === 'series' ? 'series' : tags.type === 'publication' ? 'article' : tags.type === 'quote' ? 'review' : undefined,
articleId: tags.articleId as string | undefined,
reviewerPubkey: tags.reviewerPubkey as string | undefined,
author: undefined, // Not used in new system
}
}
@ -125,26 +136,28 @@ function getPreviewContent(content: string, previewTag?: string) {
return { previewContent }
}
function buildArticle(event: Event, tags: ReturnType<typeof extractTags>, preview: string): Article {
function buildArticle(event: Event, tags: ReturnType<typeof extractTagsFromEvent>, preview: string): Article {
// Map category from new system to old system
const category = tags.category === 'sciencefiction' ? 'science-fiction' : tags.category === 'research' ? 'scientific-research' : undefined
const isPresentation = tags.type === 'author'
return {
id: event.id,
id: tags.id ?? event.id,
pubkey: event.pubkey,
title: tags.title,
title: (tags.title as string | undefined) ?? 'Untitled',
preview,
content: '',
createdAt: event.created_at,
zapAmount: tags.zapAmount,
zapAmount: (tags.zapAmount as number | undefined) ?? 800,
paid: false,
...(tags.invoice ? { invoice: tags.invoice } : {}),
...(tags.paymentHash ? { paymentHash: tags.paymentHash } : {}),
...(tags.category ? { category: tags.category } : {}),
...(tags.isPresentation ? { isPresentation: tags.isPresentation } : {}),
...(tags.mainnetAddress ? { mainnetAddress: tags.mainnetAddress } : {}),
...(tags.totalSponsoring ? { totalSponsoring: tags.totalSponsoring } : {}),
...(tags.authorPresentationId ? { authorPresentationId: tags.authorPresentationId } : {}),
...(tags.seriesId ? { seriesId: tags.seriesId } : {}),
...(tags.bannerUrl ? { bannerUrl: tags.bannerUrl } : {}),
...(tags.media.length ? { media: tags.media } : {}),
...(tags.kindType ? { kindType: tags.kindType } : {}),
...(tags.invoice ? { invoice: tags.invoice as string } : {}),
...(tags.paymentHash ? { paymentHash: tags.paymentHash as string } : {}),
...(category ? { category } : {}),
...(isPresentation ? { isPresentation: true } : {}),
...(tags.mainnetAddress ? { mainnetAddress: tags.mainnetAddress as string } : {}),
...(tags.totalSponsoring ? { totalSponsoring: tags.totalSponsoring as number } : {}),
...(tags.seriesId ? { seriesId: tags.seriesId as string } : {}),
...(tags.bannerUrl ? { bannerUrl: tags.bannerUrl as string } : {}),
...(tags.type === 'publication' ? { kindType: 'article' as KindType } : tags.type === 'author' ? { kindType: 'article' as KindType } : {}),
}
}

View File

@ -1,5 +1,6 @@
import { Event, nip04 } from 'nostr-tools'
import { SimplePool } from 'nostr-tools'
import { decryptDecryptionKey, decryptArticleContent, type DecryptionKey } from './articleEncryption'
const RELAY_URL = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL ?? 'wss://relay.damus.io'
@ -23,6 +24,7 @@ function decryptContent(privateKey: string, event: Event): Promise<string | null
/**
* Get private content for an article (encrypted message from author)
* This function now returns the decryption key instead of the full content
*/
export function getPrivateContent(
pool: SimplePool,
@ -63,3 +65,66 @@ export function getPrivateContent(
setTimeout(() => finalize(null), 5000)
})
}
/**
* Get decryption key for an article from private messages
* Returns the decryption key and IV if found
*/
export async function getDecryptionKey(
pool: SimplePool,
eventId: string,
authorPubkey: string,
recipientPrivateKey: string,
recipientPublicKey: string
): Promise<DecryptionKey | null> {
if (!recipientPrivateKey || !pool || !recipientPublicKey) {
throw new Error('Private key not set or pool not initialized')
}
return new Promise((resolve) => {
let resolved = false
const sub = pool.sub([RELAY_URL], createPrivateMessageFilters(eventId, recipientPublicKey, authorPubkey))
const finalize = (result: DecryptionKey | null) => {
if (resolved) {
return
}
resolved = true
sub.unsub()
resolve(result)
}
sub.on('event', async (event: Event) => {
try {
const decryptedContent = await decryptContent(recipientPrivateKey, event)
if (decryptedContent) {
try {
// Try to parse as decryption key (new format)
const keyData = JSON.parse(decryptedContent) as DecryptionKey
if (keyData.key && keyData.iv) {
finalize(keyData)
return
}
} catch {
// If parsing fails, it might be old format (full content)
// Return null to indicate we need to use the old method
}
}
} catch (e) {
console.error('Error decrypting decryption key:', e)
}
})
sub.on('eose', () => finalize(null))
setTimeout(() => finalize(null), 5000)
})
}
/**
* Decrypt article content using the decryption key from private message
*/
export async function decryptArticleContentWithKey(
encryptedContent: string,
decryptionKey: DecryptionKey
): Promise<string> {
return decryptArticleContent(encryptedContent, decryptionKey.key, decryptionKey.iv)
}

252
lib/nostrTagSystem.ts Normal file
View File

@ -0,0 +1,252 @@
/**
* New tag system based on:
* - #paywall: for paid publications
* - #sciencefiction or #research: for category
* - #author, #series, #publication, #quote: for type
* - #id_<id>: for identifier
* - #payment (optional): for payment notes
*
* Everything is a Nostr note (kind 1)
* All tags are in English
*/
export type TagType = 'author' | 'series' | 'publication' | 'quote'
export type TagCategory = 'sciencefiction' | 'research'
export interface BaseTags {
type: TagType
category: TagCategory
id: string
paywall?: boolean
payment?: boolean
}
export interface AuthorTags extends BaseTags {
type: 'author'
title: string
preview?: string
mainnetAddress?: string
totalSponsoring?: number
}
export interface SeriesTags extends BaseTags {
type: 'series'
title: string
description: string
preview?: string
coverUrl?: string
}
export interface PublicationTags extends BaseTags {
type: 'publication'
title: string
preview?: string
seriesId?: string
bannerUrl?: string
zapAmount?: number
invoice?: string
paymentHash?: string
encryptedKey?: string
}
export interface QuoteTags extends BaseTags {
type: 'quote'
articleId: string
reviewerPubkey?: string
title?: string
}
/**
* Build tags array from tag object
* Tags format: ['tag_name'] for simple tags, ['tag_name', 'value'] for tags with values
*/
export function buildTags(tags: AuthorTags | SeriesTags | PublicationTags | QuoteTags): string[][] {
const result: string[][] = []
// Type tag (required) - simple tag without value
result.push([tags.type])
// Category tag (required) - simple tag without value
result.push([tags.category])
// ID tag (required) - tag with value: ['id', '<id>']
result.push(['id', tags.id])
// Paywall tag (optional) - simple tag without value
if (tags.paywall) {
result.push(['paywall'])
}
// Payment tag (optional) - simple tag without value
if (tags.payment) {
result.push(['payment'])
}
// Type-specific tags
if (tags.type === 'author') {
const authorTags = tags as AuthorTags
if (authorTags.mainnetAddress) {
result.push(['mainnet_address', authorTags.mainnetAddress])
}
if (authorTags.totalSponsoring !== undefined) {
result.push(['total_sponsoring', authorTags.totalSponsoring.toString()])
}
} else if (tags.type === 'series') {
const seriesTags = tags as SeriesTags
result.push(['title', seriesTags.title])
result.push(['description', seriesTags.description])
if (seriesTags.preview) {
result.push(['preview', seriesTags.preview])
}
if (seriesTags.coverUrl) {
result.push(['cover', seriesTags.coverUrl])
}
} else if (tags.type === 'publication') {
const pubTags = tags as PublicationTags
result.push(['title', pubTags.title])
if (pubTags.preview) {
result.push(['preview', pubTags.preview])
}
if (pubTags.seriesId) {
result.push(['series', pubTags.seriesId])
}
if (pubTags.bannerUrl) {
result.push(['banner', pubTags.bannerUrl])
}
if (pubTags.zapAmount) {
result.push(['zap', pubTags.zapAmount.toString()])
}
if (pubTags.invoice) {
result.push(['invoice', pubTags.invoice])
}
if (pubTags.paymentHash) {
result.push(['payment_hash', pubTags.paymentHash])
}
if (pubTags.encryptedKey) {
result.push(['encrypted_key', pubTags.encryptedKey])
}
} else if (tags.type === 'quote') {
const quoteTags = tags as QuoteTags
result.push(['article', quoteTags.articleId])
if (quoteTags.reviewerPubkey) {
result.push(['reviewer', quoteTags.reviewerPubkey])
}
if (quoteTags.title) {
result.push(['title', quoteTags.title])
}
}
return result
}
/**
* Extract tags from event
*/
export function extractTagsFromEvent(event: { tags: string[][] }): {
type?: TagType
category?: TagCategory
id?: string
paywall: boolean
payment: boolean
[key: string]: unknown
} {
const findTag = (key: string) => event.tags.find((tag) => tag[0] === key)?.[1]
const hasTag = (key: string) => event.tags.some((tag) => tag[0] === key || (tag.length === 1 && tag[0] === key))
const type = event.tags.find((tag) => tag.length === 1 && tag[0] && ['author', 'series', 'publication', 'quote'].includes(tag[0]))?.[0] as TagType | undefined
const category = event.tags.find((tag) => tag.length === 1 && tag[0] && ['sciencefiction', 'research'].includes(tag[0]))?.[0] as TagCategory | undefined
const id = findTag('id')
return {
type,
category,
id,
paywall: hasTag('paywall'),
payment: hasTag('payment'),
// Extract all other tags
title: findTag('title'),
preview: findTag('preview'),
description: findTag('description'),
mainnetAddress: findTag('mainnet_address'),
totalSponsoring: (() => {
const val = findTag('total_sponsoring')
return val ? parseInt(val, 10) : undefined
})(),
seriesId: findTag('series'),
coverUrl: findTag('cover'),
bannerUrl: findTag('banner'),
zapAmount: (() => {
const val = findTag('zap')
return val ? parseInt(val, 10) : undefined
})(),
invoice: findTag('invoice'),
paymentHash: findTag('payment_hash'),
encryptedKey: findTag('encrypted_key'),
articleId: findTag('article'),
reviewerPubkey: findTag('reviewer'),
}
}
/**
* Build Nostr filter for querying by tags
* Nostr filters use #tag for tag-based filtering
*/
export function buildTagFilter(params: {
type?: TagType
category?: TagCategory
id?: string
paywall?: boolean
payment?: boolean
seriesId?: string
articleId?: string
authorPubkey?: string
}): Record<string, string[] | number[]> {
const filter: Record<string, string[] | number[]> = {
kinds: [1], // All are kind 1 notes
}
// Type tag filter (simple tag without value)
if (params.type) {
filter[`#${params.type}`] = ['']
}
// Category tag filter (simple tag without value)
if (params.category) {
filter[`#${params.category}`] = ['']
}
// ID tag filter (tag with value)
if (params.id) {
filter['#id'] = [params.id]
} else {
// If no ID specified, we still need to ensure the filter structure is valid
// Nostr filters require at least one valid filter property
}
// Paywall tag filter (simple tag without value)
if (params.paywall) {
filter['#paywall'] = ['']
}
// Payment tag filter (simple tag without value)
if (params.payment) {
filter['#payment'] = ['']
}
// Series ID filter (tag with value)
if (params.seriesId) {
filter['#series'] = [params.seriesId]
}
// Article ID filter (tag with value)
if (params.articleId) {
filter['#article'] = [params.articleId]
}
// Author pubkey filter
if (params.authorPubkey) {
filter.authors = [params.authorPubkey]
}
return filter
}

View File

@ -3,6 +3,7 @@ import { nostrService } from './nostr'
import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
import type { Review } from '@/types/nostr'
import { parseReviewFromEvent } from './nostrEventParsing'
import { buildTagFilter } from './nostrTagSystem'
const RELAY_URL = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL ?? 'wss://relay.damus.io'
@ -12,13 +13,25 @@ export function getReviewsForArticle(articleId: string, timeoutMs: number = 5000
throw new Error('Pool not initialized')
}
const poolWithSub = pool as SimplePoolWithSub
const filters = [
{
kinds: [1],
'#article': [articleId],
'#kind_type': ['review'],
},
]
const tagFilter = buildTagFilter({
type: 'quote',
articleId,
})
const filterObj: {
kinds: number[]
'#quote'?: string[]
'#article'?: string[]
} = {
kinds: Array.isArray(tagFilter.kinds) ? tagFilter.kinds as number[] : [1],
}
if (tagFilter['#quote']) {
filterObj['#quote'] = tagFilter['#quote'] as string[]
}
if (tagFilter['#article']) {
filterObj['#article'] = tagFilter['#article'] as string[]
}
const filters = [filterObj]
return new Promise<Review[]>((resolve) => {
const results: Review[] = []

View File

@ -3,6 +3,7 @@ import { nostrService } from './nostr'
import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
import type { Series } from '@/types/nostr'
import { parseSeriesFromEvent } from './nostrEventParsing'
import { buildTagFilter } from './nostrTagSystem'
const RELAY_URL = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL ?? 'wss://relay.damus.io'
@ -12,11 +13,20 @@ export function getSeriesByAuthor(authorPubkey: string, timeoutMs: number = 5000
throw new Error('Pool not initialized')
}
const poolWithSub = pool as SimplePoolWithSub
const filters = [
const tagFilter = buildTagFilter({
type: 'series',
authorPubkey,
})
const filters: Array<{
kinds: number[]
authors?: string[]
'#series'?: string[]
}> = [
{
kinds: [1],
authors: [authorPubkey],
'#kind_type': ['series'],
kinds: tagFilter.kinds as number[],
...(tagFilter.authors ? { authors: tagFilter.authors as string[] } : {}),
...(tagFilter['#series'] ? { '#series': tagFilter['#series'] as string[] } : {}),
},
]
@ -56,7 +66,9 @@ export function getSeriesById(seriesId: string, timeoutMs: number = 5000): Promi
{
kinds: [1],
ids: [seriesId],
'#kind_type': ['series'],
...buildTagFilter({
type: 'series',
}),
},
]

View File

@ -5,11 +5,13 @@ import type { Article } from '@/types/nostr'
const RELAY = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL ?? 'wss://relay.damus.io'
function subscribeToPresentation(pool: SimplePoolWithSub, pubkey: string): Promise<number> {
const { buildTagFilter } = require('./nostrTagSystem')
const filters = [
{
kinds: [1],
authors: [pubkey],
'#category': ['author-presentation'],
...buildTagFilter({
type: 'author',
authorPubkey: pubkey,
}),
limit: 1,
},
]
@ -28,12 +30,13 @@ function subscribeToPresentation(pool: SimplePoolWithSub, pubkey: string): Promi
}
sub.on('event', (event: import('nostr-tools').Event) => {
const isPresentation = event.tags.some((tag) => tag[0] === 'presentation' && tag[1] === 'true')
if (!isPresentation) {
// Check if it's an author type using new tag system (tag is 'author' in English)
const { extractTagsFromEvent } = require('./nostrTagSystem')
const tags = extractTagsFromEvent(event)
if (tags.type !== 'author') {
return
}
const sponsoringTag = event.tags.find((tag) => tag[0] === 'total_sponsoring')
const total = sponsoringTag ? parseInt(sponsoringTag[1] ?? '0', 10) : 0
const total = (tags.totalSponsoring as number | undefined) ?? 0
finalize(total)
})

82
locales/en.txt Normal file
View File

@ -0,0 +1,82 @@
# English translations for zapwall.fr
# Home page
home.title=zapwall.fr
home.intro.part1=Browse authors and previews, purchase publications on the go for {{price}} sats (minus {{commission}} sats and transaction fees).
home.intro.part2=Sponsor the author for {{price}} BTC (minus {{commission}} BTC and transaction fees).
home.intro.part3=Reviews are rewardable for {{price}} sats (minus {{commission}} sats and transaction fees).
home.intro.funds=Platform funds serve its development.
home.funding.title=AI Features Funding
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).
# Navigation
nav.documentation=Documentation
nav.publish=Publish
nav.createAuthorPage=Create author page
nav.loading=Loading...
# Categories
category.science-fiction=Science Fiction
category.scientific-research=Scientific Research
category.all=All categories
# Articles/Publications
publication.title=Publications
publication.empty=No publications
publication.published=Published on {{date}}
publication.unlock=Unlock
publication.viewAuthor=View author →
publication.price={{amount}} sats
# Series
series.title=Series
series.empty=No series published yet.
series.view=View series
series.publications=Series publications
series.publications.empty=No publications for this series.
# Author page
author.title=Author page
author.presentation=Presentation
author.sponsoring=Sponsoring
author.sponsoring.total=Total received: {{amount}} BTC
author.sponsoring.sats=In satoshis: {{amount}} sats
author.notFound=Author page not found.
# Publish
publish.title=Publish a new publication
publish.description=Create a publication with free preview and paid content
publish.back=← Back to home
publish.button=Publish publication
publish.publishing=Publishing...
# Presentation
presentation.title=Create your presentation article
presentation.description=This article is required to publish on zapwall.fr. It allows readers to know you and sponsor you.
presentation.success=Presentation article created!
presentation.successMessage=Your presentation article has been created successfully. You can now publish articles.
presentation.notConnected=Connect with Nostr to create your presentation article
# Filters
filters.clear=Clear all
filters.author=By author
filters.sort=Sort by
filters.sort.newest=Newest
filters.sort.oldest=Oldest
filters.loading=Loading authors...
# Search
search.placeholder=Search...
# Footer
footer.legal=Legal
footer.terms=Terms of Service
footer.privacy=Privacy Policy
# Common
common.loading=Loading...
common.error=Error
common.back=Back

82
locales/fr.txt Normal file
View File

@ -0,0 +1,82 @@
# French translations for zapwall.fr
# Home page
home.title=zapwall.fr
home.intro.part1=Consultez les auteurs et aperçus, achetez les parutions au fil de l'eau par {{price}} sats (moins {{commission}} sats et frais de transaction).
home.intro.part2=Sponsorisez l'auteur pour {{price}} BTC (moins {{commission}} BTC et frais de transaction).
home.intro.part3=Les avis sont remerciables pour {{price}} sats (moins {{commission}} sats et frais de transaction).
home.intro.funds=Les fonds de la plateforme servent à son développement.
home.funding.title=Financement des fonctionnalités IA
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).
# Navigation
nav.documentation=Documentation
nav.publish=Publier
nav.createAuthorPage=Créer page auteur
nav.loading=Chargement...
# Categories
category.science-fiction=Science-fiction
category.scientific-research=Recherche scientifique
category.all=Toutes les catégories
# Articles/Publications
publication.title=Publications
publication.empty=Aucune publication
publication.published=Publié le {{date}}
publication.unlock=Débloquer
publication.viewAuthor=Voir l'auteur →
publication.price={{amount}} sats
# Series
series.title=Séries
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.
# Author page
author.title=Page auteur
author.presentation=Présentation
author.sponsoring=Sponsoring
author.sponsoring.total=Total reçu : {{amount}} BTC
author.sponsoring.sats=En satoshis : {{amount}} sats
author.notFound=Page auteur introuvable.
# Publish
publish.title=Publier une nouvelle publication
publish.description=Créer une publication avec aperçu gratuit et contenu payant
publish.back=← Retour à l'accueil
publish.button=Publier la publication
publish.publishing=Publication...
# Presentation
presentation.title=Créer votre article de présentation
presentation.description=Cet article est obligatoire pour publier sur zapwall.fr. Il permet aux lecteurs de vous connaître et de vous sponsoriser.
presentation.success=Article de présentation créé !
presentation.successMessage=Votre article de présentation a été créé avec succès. Vous pouvez maintenant publier des articles.
presentation.notConnected=Connectez-vous avec Nostr pour créer votre article de présentation
# Filters
filters.clear=Effacer tout
filters.author=Par auteur
filters.sort=Trier par
filters.sort.newest=Plus récent
filters.sort.oldest=Plus ancien
filters.loading=Chargement des auteurs...
# Search
search.placeholder=Rechercher...
# Footer
footer.legal=Mentions légales
footer.terms=Conditions d'utilisation
footer.privacy=Politique de confidentialité
# Common
common.loading=Chargement...
common.error=Erreur
common.back=Retour

View File

@ -1,6 +1,21 @@
import '@/styles/globals.css'
import type { AppProps } from 'next/app'
import { useI18n } from '@/hooks/useI18n'
function I18nProvider({ children }: { children: React.ReactNode }) {
const { loaded } = useI18n('fr') // Default to French, can be made dynamic based on user preference or browser locale
if (!loaded) {
return <div>Loading...</div>
}
return <>{children}</>
}
export default function App({ Component, pageProps }: AppProps) {
return <Component {...pageProps} />
return (
<I18nProvider>
<Component {...pageProps} />
</I18nProvider>
)
}

152
pages/author/[pubkey].tsx Normal file
View File

@ -0,0 +1,152 @@
import { useRouter } from 'next/router'
import Head from 'next/head'
import { useEffect, useState } from 'react'
import { fetchAuthorPresentationFromPool } from '@/lib/articlePublisherHelpers'
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'
function AuthorPageHeader({ presentation }: { presentation: AuthorPresentationArticle | null }) {
if (!presentation) {
return null
}
return (
<div className="space-y-4 mb-8">
<h1 className="text-3xl font-bold text-neon-cyan">
{presentation.title || t('author.presentation')}
</h1>
<div className="prose prose-invert max-w-none">
<p className="text-cyber-accent whitespace-pre-wrap">{presentation.content}</p>
</div>
</div>
)
}
function SponsoringSummary({ totalSponsoring }: { totalSponsoring: number }) {
const totalBTC = totalSponsoring / 100_000_000
return (
<div className="bg-cyber-dark/50 border border-neon-cyan/20 rounded-lg p-6 mb-8">
<h2 className="text-xl font-semibold text-neon-cyan mb-4">{t('author.sponsoring')}</h2>
<div className="space-y-2">
<p className="text-cyber-accent">
{t('author.sponsoring.total', { amount: totalBTC.toFixed(6) })}
</p>
<p className="text-cyber-accent">
{t('author.sponsoring.sats', { amount: totalSponsoring.toLocaleString() })}
</p>
</div>
</div>
)
}
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>
)
}
return (
<div className="space-y-6">
<h2 className="text-2xl font-semibold text-neon-cyan">{t('series.title')}</h2>
<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>
</div>
)
}
export default function AuthorPage() {
const router = useRouter()
const { pubkey } = router.query
const authorPubkey = typeof pubkey === 'string' ? pubkey : ''
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)
useEffect(() => {
if (!authorPubkey) {
return
}
const load = async () => {
setLoading(true)
setError(null)
try {
const pool = nostrService.getPool()
if (!pool) {
setError('Pool not initialized')
setLoading(false)
return
}
const [pres, seriesList, sponsoring] = await Promise.all([
fetchAuthorPresentationFromPool(pool as import('@/types/nostr-tools-extended').SimplePoolWithSub, authorPubkey),
getSeriesByAuthor(authorPubkey),
getAuthorSponsoring(authorPubkey),
])
setPresentation(pres)
setSeries(seriesList)
setTotalSponsoring(sponsoring)
} catch (e) {
setError(e instanceof Error ? e.message : 'Erreur lors du chargement')
} finally {
setLoading(false)
}
}
void load()
}, [authorPubkey])
if (!authorPubkey) {
return null
}
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="max-w-4xl mx-auto px-4 py-8">
{loading && <p className="text-cyber-accent">{t('common.loading')}</p>}
{error && <p className="text-red-400">{error}</p>}
{!loading && !error && presentation && (
<>
<AuthorPageHeader presentation={presentation} />
<SponsoringSummary totalSponsoring={totalSponsoring} />
<SeriesList series={series} authorPubkey={authorPubkey} />
</>
)}
{!loading && !error && !presentation && (
<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>
)}
</div>
<Footer />
</main>
</>
)
}

View File

@ -1,31 +1,11 @@
import { useState, useEffect, useMemo, useCallback } from 'react'
import { useRouter } from 'next/router'
import { useArticles } from '@/hooks/useArticles'
import { useNostrConnect } from '@/hooks/useNostrConnect'
import { useAuthorPresentation } from '@/hooks/useAuthorPresentation'
import { applyFiltersAndSort } from '@/lib/articleFiltering'
import type { Article } from '@/types/nostr'
import type { ArticleFilters } from '@/components/ArticleFilters'
import { HomeView } from '@/components/HomeView'
function usePresentationGuard(connected: boolean, pubkey: string | null) {
const router = useRouter()
const { checkPresentationExists } = useAuthorPresentation(pubkey ?? null)
useEffect(() => {
const ensurePresentation = async () => {
if (!connected || !pubkey) {
return
}
const presentation = await checkPresentationExists()
if (!presentation) {
await router.push('/presentation')
}
}
void ensurePresentation()
}, [checkPresentationExists, connected, pubkey, router])
}
function usePresentationArticles(allArticles: Article[]) {
const [presentationArticles, setPresentationArticles] = useState<Map<string, Article>>(new Map())
useEffect(() => {
@ -105,7 +85,7 @@ function useUnlockHandler(
}
function useHomeController() {
const { connected, pubkey } = useNostrConnect()
const { } = useNostrConnect()
const {
searchQuery,
setSearchQuery,
@ -119,7 +99,7 @@ function useHomeController() {
const { allArticlesRaw, allArticles, loading, error, loadArticleContent, presentationArticles } =
useArticlesData(searchQuery)
usePresentationGuard(connected, pubkey)
// Presentation guard removed - users can browse without author page
useCategorySync(selectedCategory, setFilters)
const articles = useFilteredArticles(allArticlesRaw, searchQuery, filters, presentationArticles)
const handleUnlock = useUnlockHandler(loadArticleContent, setUnlockedArticles)

View File

@ -5,6 +5,7 @@ import { ConnectButton } from '@/components/ConnectButton'
import { AuthorPresentationEditor } from '@/components/AuthorPresentationEditor'
import { useNostrConnect } from '@/hooks/useNostrConnect'
import { useAuthorPresentation } from '@/hooks/useAuthorPresentation'
import { t } from '@/lib/i18n'
function usePresentationRedirect(connected: boolean, pubkey: string | null) {
const router = useRouter()
@ -29,10 +30,10 @@ function PresentationLayout() {
return (
<>
<Head>
<title>Créer votre article de présentation - zapwall.fr</title>
<title>{t('presentation.title')} - {t('home.title')}</title>
<meta
name="description"
content="Créez votre article de présentation obligatoire pour publier sur zapwall.fr"
content={t('presentation.description')}
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
</Head>
@ -47,10 +48,9 @@ function PresentationLayout() {
<div className="max-w-4xl mx-auto px-4 py-8">
<div className="mb-6">
<h2 className="text-3xl font-bold">Créer votre article de présentation</h2>
<h2 className="text-3xl font-bold">{t('presentation.title')}</h2>
<p className="text-gray-600 mt-2">
Cet article est obligatoire pour publier sur zapwall.fr. Il permet aux
lecteurs de vous connaître et de vous sponsoriser.
{t('presentation.description')}
</p>
</div>

View File

@ -4,12 +4,13 @@ import { ArticleEditor } from '@/components/ArticleEditor'
import { useEffect, useState } from 'react'
import { useNostrConnect } from '@/hooks/useNostrConnect'
import { getSeriesByAuthor } from '@/lib/seriesQueries'
import { t } from '@/lib/i18n'
function PublishHeader() {
return (
<Head>
<title>Publish Article - zapwall.fr</title>
<meta name="description" content="Publish a new article with free preview and paid content" />
<title>{t('publish.title')} - {t('home.title')}</title>
<meta name="description" content={t('publish.description')} />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</Head>
)
@ -22,10 +23,10 @@ function PublishHero({ onBack }: { onBack: () => void }) {
onClick={onBack}
className="text-blue-600 hover:text-blue-700 text-sm font-medium mb-4"
>
Back to Articles
{t('publish.back')}
</button>
<h2 className="text-3xl font-bold">Publish New Article</h2>
<p className="text-gray-600 mt-2">Create an article with a free preview and paid full content</p>
<h2 className="text-3xl font-bold">{t('publish.title')}</h2>
<p className="text-gray-600 mt-2">{t('publish.description')}</p>
</div>
)
}

View File

@ -7,6 +7,7 @@ import { getArticlesBySeries } from '@/lib/articleQueries'
import type { Series, Article } from '@/types/nostr'
import { SeriesStats } from '@/components/SeriesStats'
import { ArticleCard } from '@/components/ArticleCard'
import { t } from '@/lib/i18n'
import Image from 'next/image'
import { ArticleReviews } from '@/components/ArticleReviews'
@ -26,7 +27,14 @@ function SeriesHeader({ series }: { series: Series }) {
)}
<h1 className="text-3xl font-bold">{series.title}</h1>
<p className="text-gray-700">{series.description}</p>
<p className="text-sm text-gray-500">Catégorie : {series.category === 'science-fiction' ? 'Science-fiction' : 'Recherche scientifique'}</p>
<p className="text-sm text-gray-500">
{t('category.science-fiction')}: {series.category === 'science-fiction' ? t('category.science-fiction') : t('category.scientific-research')}
</p>
{series.preview && (
<div className="mt-4 p-4 bg-gray-50 rounded-lg">
<p className="text-gray-700 whitespace-pre-wrap">{series.preview}</p>
</div>
)}
</div>
)
}
@ -47,7 +55,7 @@ export default function SeriesPage() {
</Head>
<main className="min-h-screen bg-gray-50">
<div className="max-w-4xl mx-auto px-4 py-8 space-y-6">
{loading && <p className="text-sm text-gray-600">Chargement...</p>}
{loading && <p className="text-sm text-gray-600">{t('common.loading')}</p>}
{error && <p className="text-sm text-red-600">{error}</p>}
{series && (
<>
@ -57,7 +65,7 @@ export default function SeriesPage() {
purchases={aggregates?.purchases ?? 0}
reviewTips={aggregates?.reviewTips ?? 0}
/>
<SeriesArticles articles={articles} />
<SeriesPublications articles={articles} />
</>
)}
</div>
@ -66,13 +74,13 @@ export default function SeriesPage() {
)
}
function SeriesArticles({ articles }: { articles: Article[] }) {
function SeriesPublications({ articles }: { articles: Article[] }) {
if (articles.length === 0) {
return <p className="text-sm text-gray-600">Aucun article pour cette série.</p>
return <p className="text-sm text-gray-600">Aucune publication pour cette série.</p>
}
return (
<div className="space-y-4">
<h2 className="text-2xl font-semibold">Articles de la série</h2>
<h2 className="text-2xl font-semibold">{t('series.publications')}</h2>
<div className="space-y-4">
{articles.map((a) => (
<div key={a.id} className="space-y-2">

83
public/locales/en.txt Normal file
View File

@ -0,0 +1,83 @@
# English translations for zapwall.fr
# Home page
home.title=zapwall.fr
home.intro.part1=Browse authors and previews, purchase publications on the go for {{price}} sats (minus {{commission}} sats and transaction fees).
home.intro.part2=Sponsor the author for {{price}} BTC (minus {{commission}} BTC and transaction fees).
home.intro.part3=Reviews are rewardable for {{price}} sats (minus {{commission}} sats and transaction fees).
home.intro.funds=Platform funds serve its development.
home.funding.title=AI Features Funding
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).
# Navigation
nav.documentation=Documentation
nav.publish=Publish
nav.createAuthorPage=Create author page
nav.loading=Loading...
# Categories
category.science-fiction=Science Fiction
category.scientific-research=Scientific Research
category.all=All categories
# Articles/Publications
publication.title=Publications
publication.empty=No publications
publication.published=Published on {{date}}
publication.unlock=Unlock
publication.viewAuthor=View author →
publication.price={{amount}} sats
# Series
series.title=Series
series.empty=No series published yet.
series.view=View series
series.publications=Series publications
series.publications.empty=No publications for this series.
# Author page
author.title=Author page
author.presentation=Presentation
author.sponsoring=Sponsoring
author.sponsoring.total=Total received: {{amount}} BTC
author.sponsoring.sats=In satoshis: {{amount}} sats
author.notFound=Author page not found.
# Publish
publish.title=Publish a new publication
publish.description=Create a publication with free preview and paid content
publish.back=← Back to home
publish.button=Publish publication
publish.publishing=Publishing...
# Presentation
presentation.title=Create your presentation article
presentation.description=This article is required to publish on zapwall.fr. It allows readers to know you and sponsor you.
presentation.success=Presentation article created!
presentation.successMessage=Your presentation article has been created successfully. You can now publish articles.
presentation.notConnected=Connect with Nostr to create your presentation article
# Filters
filters.clear=Clear all
filters.author=All authors
filters.sort=Sort by
filters.sort.newest=Newest
filters.sort.oldest=Oldest
filters.loading=Loading authors...
# Search
search.placeholder=Search...
# Footer
footer.legal=Legal
footer.terms=Terms of Service
footer.privacy=Privacy Policy
# Common
common.loading=Loading...
common.error=Error
common.back=Back
common.open=Open

82
public/locales/fr.txt Normal file
View File

@ -0,0 +1,82 @@
# French translations for zapwall.fr
# Home page
home.title=zapwall.fr
home.intro.part1=Consultez les auteurs et aperçus, achetez les parutions au fil de l'eau par {{price}} sats (moins {{commission}} sats et frais de transaction).
home.intro.part2=Sponsorisez l'auteur pour {{price}} BTC (moins {{commission}} BTC et frais de transaction).
home.intro.part3=Les avis sont remerciables pour {{price}} sats (moins {{commission}} sats et frais de transaction).
home.intro.funds=Les fonds de la plateforme servent à son développement.
home.funding.title=Financement des fonctionnalités IA
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).
# Navigation
nav.documentation=Documentation
nav.publish=Publier
nav.createAuthorPage=Créer page auteur
nav.loading=Chargement...
# Categories
category.science-fiction=Science-fiction
category.scientific-research=Recherche scientifique
category.all=Toutes les catégories
# Articles/Publications
publication.title=Publications
publication.empty=Aucune publication
publication.published=Publié le {{date}}
publication.unlock=Débloquer
publication.viewAuthor=Voir l'auteur →
publication.price={{amount}} sats
# Series
series.title=Séries
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.
# Author page
author.title=Page auteur
author.presentation=Présentation
author.sponsoring=Sponsoring
author.sponsoring.total=Total reçu : {{amount}} BTC
author.sponsoring.sats=En satoshis : {{amount}} sats
author.notFound=Page auteur introuvable.
# Publish
publish.title=Publier une nouvelle publication
publish.description=Créer une publication avec aperçu gratuit et contenu payant
publish.back=← Retour à l'accueil
publish.button=Publier la publication
publish.publishing=Publication...
# Presentation
presentation.title=Créer votre article de présentation
presentation.description=Cet article est obligatoire pour publier sur zapwall.fr. Il permet aux lecteurs de vous connaître et de vous sponsoriser.
presentation.success=Article de présentation créé !
presentation.successMessage=Votre article de présentation a été créé avec succès. Vous pouvez maintenant publier des articles.
presentation.notConnected=Connectez-vous avec Nostr pour créer votre article de présentation
# Filters
filters.clear=Effacer tout
filters.author=Tous les auteurs
filters.sort=Trier par
filters.sort.newest=Plus récent
filters.sort.oldest=Plus ancien
filters.loading=Chargement des auteurs...
# Search
search.placeholder=Rechercher...
# Footer
footer.legal=Mentions légales
footer.terms=Conditions d'utilisation
footer.privacy=Politique de confidentialité
# Common
common.loading=Chargement...
common.error=Erreur
common.back=Retour