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:
parent
9edf4ac1bc
commit
2a191f35f4
@ -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 l’information.
|
||||
|
||||
* **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.
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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'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>
|
||||
)
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
55
components/ConditionalPublishButton.tsx
Normal file
55
components/ConditionalPublishButton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
|
||||
67
components/FundingGauge.tsx
Normal file
67
components/FundingGauge.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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} />
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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} />}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
37
hooks/useI18n.ts
Normal 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 }
|
||||
}
|
||||
@ -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
165
lib/articleEncryption.ts
Normal 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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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')
|
||||
}
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
},
|
||||
]
|
||||
|
||||
@ -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
95
lib/fundingCalculation.ts
Normal 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
78
lib/i18n.ts
Normal 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())
|
||||
}
|
||||
74
lib/nostr.ts
74
lib/nostr.ts
@ -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')
|
||||
|
||||
@ -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 } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
252
lib/nostrTagSystem.ts
Normal 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
|
||||
}
|
||||
@ -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[] = []
|
||||
|
||||
@ -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',
|
||||
}),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@ -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
82
locales/en.txt
Normal 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
82
locales/fr.txt
Normal 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
|
||||
@ -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
152
pages/author/[pubkey].tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
83
public/locales/en.txt
Normal 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
82
public/locales/fr.txt
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user