This commit is contained in:
Nicolas Cantu 2025-12-23 02:20:57 +01:00
parent 3000872dbc
commit cf5ebeb6e9
66 changed files with 3015 additions and 449 deletions

View File

@ -223,3 +223,18 @@ Le code final doit :
## Conclusion
Ces consignes constituent un cadre de production strict. Elles imposent une analyse préalable via un arbre des fichiers, un typage TypeScript sans contournement, une non-duplication systématique, une architecture fondée sur des abstractions pertinentes, lusage raisonné de patterns, une journalisation exhaustive des erreurs et un refus explicite des fallbacks implicites. Appliquées rigoureusement, elles conduisent à un code TypeScript robuste, évolutif et cohérent avec un référentiel de qualité de haut niveau.
## Analytics
* Ne pas mettre d'analytics
* Statistiques profil : pas de vues/paiements/revenus par article, agrégats et affichage
## Cache
* Pas de mémorisation, pas de cache
## Accessibilité
* ARIA
* clavier
* contraste

31
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,31 @@
# Contributing to zapwall4science
## Principles
- No fallbacks or silent failures.
- No analytics; no tests added unless explicitly requested.
- Respect lint, type-check, accessibility and exactOptionalPropertyTypes.
- No `ts-ignore`, no untyped `any`, no console logs if a logger exists.
## Setup
- Node 18+, npm
- `npm install`
- `npm run lint`
- `npm run type-check`
## Coding guidelines
- Split large components/functions to stay within lint limits (max-lines, max-lines-per-function).
- Prefer typed helpers/hooks; avoid duplication.
- Errors must surface with clear messages; do not swallow exceptions.
- Storage: IndexedDB encrypted (AES-GCM) via `lib/storage/cryptoHelpers.ts`; use provided helpers.
- Nostr: use `lib/articleMutations.ts` and `lib/nostr*.ts` helpers; no direct fallbacks.
## Workflow
- Branch from main; keep commits focused.
- Run lint + type-check before PR.
- Document fixes in `fixKnowledge/` and features in `features/`.
## Accessibility
- Respect ARIA, keyboard, contrast requirements; no regressions.
## What not to do
- No analytics, no ad-hoc tests, no environment overrides, no silent retry/fallback.

View File

@ -22,7 +22,12 @@ function InfoIcon() {
)
}
function InstallerActions({ onInstalled, markInstalled }: { onInstalled?: () => void; markInstalled: () => void }) {
interface InstallerActionsProps {
onInstalled?: () => void
markInstalled: () => void
}
function InstallerActions({ onInstalled, markInstalled }: InstallerActionsProps) {
const connect = useCallback(() => {
const alby = getAlbyService()
void alby.enable().then(() => {
@ -55,14 +60,17 @@ function InstallerActions({ onInstalled, markInstalled }: { onInstalled?: () =>
)
}
function InstallerBody({ onInstalled, markInstalled }: { onInstalled?: () => void; markInstalled: () => void }) {
function InstallerBody({ onInstalled, markInstalled }: InstallerActionsProps) {
return (
<div className="ml-3 flex-1">
<h3 className="text-sm font-medium text-blue-800">Alby Extension Required</h3>
<div className="mt-2 text-sm text-blue-700">
<p>To make Lightning payments, please install the Alby browser extension.</p>
</div>
<InstallerActions onInstalled={onInstalled} markInstalled={markInstalled} />
<InstallerActions
markInstalled={markInstalled}
{...(onInstalled ? { onInstalled } : {})}
/>
<div className="mt-3 text-xs text-blue-600">
<p>Alby is a Lightning wallet that enables instant Bitcoin payments in your browser.</p>
</div>
@ -113,7 +121,10 @@ export function AlbyInstaller({ onInstalled }: AlbyInstallerProps) {
<div className="flex-shrink-0">
<InfoIcon />
</div>
<InstallerBody onInstalled={onInstalled} markInstalled={markInstalled} />
<InstallerBody
markInstalled={markInstalled}
{...(onInstalled ? { onInstalled } : {})}
/>
</div>
</div>
)

View File

@ -7,6 +7,8 @@ import { ArticleEditorForm } from './ArticleEditorForm'
interface ArticleEditorProps {
onPublishSuccess?: (articleId: string) => void
onCancel?: () => void
seriesOptions?: { id: string; title: string }[]
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
}
function NotConnectedMessage() {
@ -26,7 +28,7 @@ function SuccessMessage() {
)
}
export function ArticleEditor({ onPublishSuccess, onCancel }: ArticleEditorProps) {
export function ArticleEditor({ onPublishSuccess, onCancel, seriesOptions, onSelectSeries }: ArticleEditorProps) {
const { connected, pubkey } = useNostrConnect()
const { loading, error, success, publishArticle } = useArticlePublishing(pubkey ?? null)
const [draft, setDraft] = useState<ArticleDraft>({
@ -34,16 +36,10 @@ export function ArticleEditor({ onPublishSuccess, onCancel }: ArticleEditorProps
preview: '',
content: '',
zapAmount: 800,
category: undefined,
media: [],
})
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const articleId = await publishArticle(draft)
if (articleId) {
onPublishSuccess?.(articleId)
}
}
const submit = buildSubmitHandler(publishArticle, draft, onPublishSuccess)
if (!connected) {
return <NotConnectedMessage />
@ -58,11 +54,27 @@ export function ArticleEditor({ onPublishSuccess, onCancel }: ArticleEditorProps
draft={draft}
onDraftChange={setDraft}
onSubmit={(e) => {
void handleSubmit(e)
e.preventDefault()
void submit()
}}
loading={loading}
error={error}
onCancel={onCancel}
{...(onCancel ? { onCancel } : {})}
{...(seriesOptions ? { seriesOptions } : {})}
{...(onSelectSeries ? { onSelectSeries } : {})}
/>
)
}
function buildSubmitHandler(
publishArticle: (draft: ArticleDraft) => Promise<string | null>,
draft: ArticleDraft,
onPublishSuccess?: (articleId: string) => void
) {
return async () => {
const articleId = await publishArticle(draft)
if (articleId) {
onPublishSuccess?.(articleId)
}
}
}

View File

@ -1,8 +1,11 @@
import React from 'react'
import type { ArticleDraft } from '@/lib/articlePublisher'
import type { ArticleCategory } from '@/types/nostr'
import { ArticleField } from './ArticleField'
import { ArticleFormButtons } from './ArticleFormButtons'
import { CategorySelect } from './CategorySelect'
import { MarkdownEditor } from './MarkdownEditor'
import type { MediaRef } from '@/types/nostr'
interface ArticleEditorFormProps {
draft: ArticleDraft
@ -11,6 +14,8 @@ interface ArticleEditorFormProps {
loading: boolean
error: string | null
onCancel?: () => void
seriesOptions?: { id: string; title: string }[] | undefined
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
}
function CategoryField({
@ -18,13 +23,13 @@ function CategoryField({
onChange,
}: {
value: ArticleDraft['category']
onChange: (value: ArticleDraft['category']) => void
onChange: (value: import('@/types/nostr').ArticleCategory | undefined) => void
}) {
return (
<CategorySelect
id="category"
label="Catégorie"
value={value}
{...(value ? { value } : {})}
onChange={onChange}
required
helpText="Sélectionnez la catégorie de votre article"
@ -43,15 +48,54 @@ function ErrorAlert({ error }: { error: string | null }) {
)
}
function buildCategoryChangeHandler(
draft: ArticleDraft,
onDraftChange: (draft: ArticleDraft) => void
): (value: ArticleCategory | undefined) => void {
return (value) => {
if (value === 'science-fiction' || value === 'scientific-research' || value === undefined) {
const nextDraft: ArticleDraft = { ...draft }
if (value) {
nextDraft.category = value
} else {
delete (nextDraft as { category?: ArticleDraft['category'] }).category
}
onDraftChange(nextDraft)
}
}
}
const ArticleFieldsLeft = ({
draft,
onDraftChange,
seriesOptions,
onSelectSeries,
}: {
draft: ArticleDraft
onDraftChange: (draft: ArticleDraft) => void
seriesOptions?: { id: string; title: string }[] | undefined
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
}) => (
<div className="space-y-4">
<CategoryField value={draft.category} onChange={(value) => onDraftChange({ ...draft, category: value })} />
<CategoryField
value={draft.category}
onChange={buildCategoryChangeHandler(draft, onDraftChange)}
/>
{seriesOptions && (
<SeriesSelect
draft={draft}
onDraftChange={onDraftChange}
seriesOptions={seriesOptions}
onSelectSeries={onSelectSeries}
/>
)}
<ArticleTitleField draft={draft} onDraftChange={onDraftChange} />
<ArticlePreviewField draft={draft} onDraftChange={onDraftChange} />
</div>
)
function ArticleTitleField({ draft, onDraftChange }: { draft: ArticleDraft; onDraftChange: (draft: ArticleDraft) => void }) {
return (
<ArticleField
id="title"
label="Titre"
@ -60,6 +104,17 @@ const ArticleFieldsLeft = ({
required
placeholder="Entrez le titre de l'article"
/>
)
}
function ArticlePreviewField({
draft,
onDraftChange,
}: {
draft: ArticleDraft
onDraftChange: (draft: ArticleDraft) => void
}) {
return (
<ArticleField
id="preview"
label="Aperçu (Public)"
@ -71,8 +126,61 @@ const ArticleFieldsLeft = ({
placeholder="Cet aperçu sera visible par tous gratuitement"
helpText="Ce contenu sera visible par tous"
/>
</div>
)
)
}
function SeriesSelect({
draft,
onDraftChange,
seriesOptions,
onSelectSeries,
}: {
draft: ArticleDraft
onDraftChange: (draft: ArticleDraft) => void
seriesOptions: { id: string; title: string }[]
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
}) {
const handleChange = buildSeriesChangeHandler(draft, onDraftChange, onSelectSeries)
return (
<div>
<label htmlFor="series" className="block text-sm font-medium text-gray-700">
Série
</label>
<select
id="series"
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm"
value={draft.seriesId ?? ''}
onChange={handleChange}
>
<option value="">Aucune (article indépendant)</option>
{seriesOptions.map((s) => (
<option key={s.id} value={s.id}>
{s.title}
</option>
))}
</select>
</div>
)
}
function buildSeriesChangeHandler(
draft: ArticleDraft,
onDraftChange: (draft: ArticleDraft) => void,
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
) {
return (e: React.ChangeEvent<HTMLSelectElement>) => {
const value = e.target.value || undefined
const nextDraft = { ...draft }
if (value) {
nextDraft.seriesId = value
} else {
delete (nextDraft as { seriesId?: string }).seriesId
}
onDraftChange(nextDraft)
onSelectSeries?.(value)
}
}
const ArticleFieldsRight = ({
draft,
@ -82,17 +190,24 @@ const ArticleFieldsRight = ({
onDraftChange: (draft: ArticleDraft) => void
}) => (
<div className="space-y-4">
<ArticleField
id="content"
label="Contenu complet (Privé)"
value={draft.content}
onChange={(value) => onDraftChange({ ...draft, content: value as string })}
required
type="textarea"
rows={8}
placeholder="Ce contenu sera chiffré et envoyé aux lecteurs qui paient"
helpText="Ce contenu sera chiffré et envoyé comme message privé après paiement"
/>
<div className="space-y-2">
<div className="text-sm font-semibold text-gray-800">Contenu complet (Privé) Markdown + preview</div>
<MarkdownEditor
value={draft.content}
onChange={(value) => onDraftChange({ ...draft, content: value })}
onMediaAdd={(media: MediaRef) => {
const nextMedia = [...(draft.media ?? []), media]
onDraftChange({ ...draft, media: nextMedia })
}}
onBannerChange={(url: string) => {
onDraftChange({ ...draft, bannerUrl: url })
}}
/>
<p className="text-xs text-gray-500">
Les médias sont uploadés via NIP-95 (images 5Mo, vidéos 45Mo) et insérés comme URL. Le contenu reste chiffré
pour les acheteurs.
</p>
</div>
<ArticleField
id="zapAmount"
label="Prix (sats)"
@ -113,16 +228,23 @@ export function ArticleEditorForm({
loading,
error,
onCancel,
seriesOptions,
onSelectSeries,
}: 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>
<div className="space-y-4">
<ArticleFieldsLeft draft={draft} onDraftChange={onDraftChange} />
<ArticleFieldsLeft
draft={draft}
onDraftChange={onDraftChange}
{...(seriesOptions ? { seriesOptions } : {})}
{...(onSelectSeries ? { onSelectSeries } : {})}
/>
<ArticleFieldsRight draft={draft} onDraftChange={onDraftChange} />
</div>
<ErrorAlert error={error} />
<ArticleFormButtons loading={loading} onCancel={onCancel} />
<ArticleFormButtons loading={loading} {...(onCancel ? { onCancel } : {})} />
</form>
)
}

View File

@ -32,16 +32,19 @@ function NumberOrTextInput({
className: string
onChange: (value: string | number) => void
}) {
const inputProps = {
id,
type,
value,
className,
required,
...(placeholder ? { placeholder } : {}),
...(typeof min === 'number' ? { min } : {}),
}
return (
<input
id={id}
type={type}
value={value}
{...inputProps}
onChange={(e) => onChange(type === 'number' ? Number(e.target.value) || 0 : e.target.value)}
className={className}
placeholder={placeholder}
min={min}
required={required}
/>
)
}
@ -63,15 +66,18 @@ function TextAreaInput({
className: string
onChange: (value: string | number) => void
}) {
const areaProps = {
id,
value,
className,
required,
...(placeholder ? { placeholder } : {}),
...(rows ? { rows } : {}),
}
return (
<textarea
id={id}
value={value}
{...areaProps}
onChange={(e) => onChange(e.target.value)}
className={className}
rows={rows}
placeholder={placeholder}
required={required}
/>
)
}
@ -87,22 +93,22 @@ export function ArticleField(props: ArticleFieldProps) {
<TextAreaInput
id={id}
value={value}
placeholder={placeholder}
required={required}
rows={rows}
className={inputClass}
onChange={onChange}
{...(placeholder ? { placeholder } : {})}
{...(rows ? { rows } : {})}
/>
) : (
<NumberOrTextInput
id={id}
type={type}
value={value}
placeholder={placeholder}
required={required}
min={min}
className={inputClass}
onChange={onChange}
{...(placeholder ? { placeholder } : {})}
{...(typeof min === 'number' ? { min } : {})}
/>
)

View File

@ -0,0 +1,80 @@
import { useEffect, useState } from 'react'
import type { Review } from '@/types/nostr'
import { getReviewsForArticle } from '@/lib/reviews'
import { getReviewTipsForArticle } from '@/lib/reviewAggregation'
interface ArticleReviewsProps {
articleId: string
authorPubkey: string
}
export function ArticleReviews({ articleId, authorPubkey }: ArticleReviewsProps) {
const [reviews, setReviews] = useState<Review[]>([])
const [tips, setTips] = useState<number>(0)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const load = async () => {
setLoading(true)
setError(null)
try {
const [list, tipsTotal] = await Promise.all([
getReviewsForArticle(articleId),
getReviewTipsForArticle({ authorPubkey, articleId }),
])
setReviews(list)
setTips(tipsTotal)
} catch (e) {
setError(e instanceof Error ? e.message : 'Erreur lors du chargement des critiques')
} finally {
setLoading(false)
}
}
void load()
}, [articleId, authorPubkey])
return (
<div className="border rounded-lg p-4 bg-white space-y-3">
<ArticleReviewsHeader tips={tips} />
{loading && <p className="text-sm text-gray-600">Chargement des critiques...</p>}
{error && <p className="text-sm text-red-600">{error}</p>}
{!loading && !error && reviews.length === 0 && <p className="text-sm text-gray-600">Aucune critique.</p>}
{!loading && !error && <ArticleReviewsList reviews={reviews} />}
</div>
)
}
function ArticleReviewsHeader({ tips }: { tips: number }) {
return (
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">Critiques</h3>
<span className="text-sm text-gray-600">Remerciements versés : {tips} sats</span>
</div>
)
}
function ArticleReviewsList({ reviews }: { reviews: Review[] }) {
return (
<>
{reviews.map((r) => (
<div key={r.id} className="border-t pt-2 text-sm">
<div className="text-gray-800">{r.content}</div>
<div className="text-xs text-gray-500 flex gap-2">
<span>Auteur critique : {formatPubkey(r.reviewerPubkey)}</span>
<span></span>
<span>{formatDate(r.createdAt)}</span>
</div>
</div>
))}
</>
)
}
function formatPubkey(pubkey: string): string {
return `${pubkey.slice(0, 8)}...${pubkey.slice(-8)}`
}
function formatDate(timestamp: number): string {
return new Date(timestamp * 1000).toLocaleString()
}

View File

@ -4,8 +4,8 @@ import type { ArticleCategory } from '@/types/nostr'
interface CategorySelectProps {
id: string
label: string
value: ArticleCategory | undefined
onChange: (value: ArticleCategory) => void
value?: ArticleCategory | ''
onChange: (value: ArticleCategory | undefined) => void
required?: boolean
helpText?: string
}
@ -26,7 +26,10 @@ export function CategorySelect({
<select
id={id}
value={value ?? ''}
onChange={(e) => onChange(e.target.value as ArticleCategory)}
onChange={(e) => {
const next = e.target.value === '' ? undefined : (e.target.value as ArticleCategory)
onChange(next)
}}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
required={required}
>

View File

@ -0,0 +1,115 @@
import { useState } from 'react'
import type { MediaRef } from '@/types/nostr'
import { uploadNip95Media } from '@/lib/nip95'
interface MarkdownEditorProps {
value: string
onChange: (value: string) => void
onMediaAdd?: (media: MediaRef) => void
onBannerChange?: (url: string) => void
}
export function MarkdownEditor(props: MarkdownEditorProps) {
return <MarkdownEditorInner {...props} />
}
function MarkdownEditorInner({ value, onChange, onMediaAdd, onBannerChange }: MarkdownEditorProps) {
const [uploading, setUploading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [preview, setPreview] = useState(false)
return (
<div className="space-y-3">
<MarkdownToolbar
preview={preview}
onTogglePreview={() => setPreview((p) => !p)}
onFileSelected={(file) => {
const handlers = {
setError,
setUploading,
...(onMediaAdd ? { onMediaAdd } : {}),
...(onBannerChange ? { onBannerChange } : {}),
}
void handleUpload(file, handlers)
}}
uploading={uploading}
error={error}
/>
{preview ? (
<MarkdownPreview value={value} />
) : (
<textarea
className="w-full border rounded p-3 h-64"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
)}
</div>
)
}
function MarkdownToolbar({
preview,
onTogglePreview,
onFileSelected,
uploading,
error,
}: {
preview: boolean
onTogglePreview: () => void
onFileSelected: (file: File) => void
uploading: boolean
error: string | null
}) {
return (
<div className="flex items-center gap-2">
<button type="button" className="px-3 py-1 text-sm rounded bg-gray-200" onClick={onTogglePreview}>
{preview ? 'Éditer' : 'Preview'}
</button>
<label className="px-3 py-1 text-sm rounded bg-blue-600 text-white cursor-pointer hover:bg-blue-700">
Upload media (NIP-95)
<input
type="file"
accept=".png,.jpg,.jpeg,.webp,.mp4,.webm,.mov,.qt"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) {
onFileSelected(file)
}
}}
/>
</label>
{uploading && <span className="text-sm text-gray-500">Upload en cours...</span>}
{error && <span className="text-sm text-red-600">{error}</span>}
</div>
)
}
function MarkdownPreview({ value }: { value: string }) {
return <div className="prose max-w-none border rounded p-3 bg-white whitespace-pre-wrap">{value}</div>
}
async function handleUpload(
file: File,
handlers: {
setError: (error: string | null) => void
setUploading: (uploading: boolean) => void
onMediaAdd?: (media: MediaRef) => void
onBannerChange?: (url: string) => void
}
) {
handlers.setError(null)
handlers.setUploading(true)
try {
const media = await uploadNip95Media(file)
handlers.onMediaAdd?.(media)
if (media.type === 'image') {
handlers.onBannerChange?.(media.url)
}
} catch (e) {
handlers.setError(e instanceof Error ? e.message : 'Upload failed')
} finally {
handlers.setUploading(false)
}
}

View File

@ -16,7 +16,7 @@ export function NotificationCenter({ userPubkey, onClose }: NotificationCenterPr
markAllAsRead,
deleteNotification: deleteNotificationHandler,
} = useNotifications(userPubkey)
const { isOpen, handleToggle, handleNotificationClick } = useNotificationCenter(
const { isOpen, handleToggle, handleNotificationClick, handleClose } = useNotificationCenter(
markAsRead,
onClose
)

View File

@ -0,0 +1,34 @@
import type { ArticleFilters } from '@/components/ArticleFilters'
import { ArticleFiltersComponent } from '@/components/ArticleFilters'
import { SearchBar } from '@/components/SearchBar'
import type { Article } from '@/types/nostr'
interface ProfileArticlesHeaderProps {
searchQuery: string
setSearchQuery: (value: string) => void
filters: ArticleFilters
setFilters: (value: ArticleFilters) => void
allArticles: Article[]
articleFiltersVisible: boolean
}
export function ProfileArticlesHeader({
searchQuery,
setSearchQuery,
filters,
setFilters,
allArticles,
articleFiltersVisible,
}: ProfileArticlesHeaderProps) {
return (
<div className="mb-6">
<h2 className="text-2xl font-bold mb-4">My Articles</h2>
<div className="mb-4">
<SearchBar value={searchQuery} onChange={setSearchQuery} placeholder="Search my articles..." />
</div>
{articleFiltersVisible && (
<ArticleFiltersComponent filters={filters} onFiltersChange={setFilters} articles={allArticles} />
)}
</div>
)
}

View File

@ -0,0 +1,68 @@
import type { ArticleFilters } from '@/components/ArticleFilters'
import type { Article } from '@/types/nostr'
import { ArticlesSummary } from '@/components/ProfileArticlesSummary'
import { UserArticles } from '@/components/UserArticles'
import { ProfileArticlesHeader } from '@/components/ProfileArticlesHeader'
import { ProfileSeriesBlock } from '@/components/ProfileSeriesBlock'
export interface ProfileArticlesSectionProps {
searchQuery: string
setSearchQuery: (value: string) => void
filters: ArticleFilters
setFilters: (value: ArticleFilters) => void
articles: Article[]
allArticles: Article[]
loading: boolean
error: string | null
loadArticleContent: (id: string, pubkey: string) => Promise<Article | null>
currentPubkey: string
selectedSeriesId?: string | undefined
onSelectSeries: (seriesId: string | undefined) => void
articleFiltersVisible: boolean
}
export function ProfileArticlesSection(props: ProfileArticlesSectionProps) {
const filtered = filterArticlesBySeries(props.articles, props.allArticles, props.selectedSeriesId)
return (
<>
<ProfileArticlesHeader
searchQuery={props.searchQuery}
setSearchQuery={props.setSearchQuery}
filters={props.filters}
setFilters={props.setFilters}
allArticles={props.allArticles}
articleFiltersVisible={props.articleFiltersVisible}
/>
<ArticlesSummary visibleCount={filtered.articles.length} total={filtered.all.length} />
<ProfileSeriesBlock
currentPubkey={props.currentPubkey}
onSelectSeries={props.onSelectSeries}
{...(props.selectedSeriesId ? { selectedSeriesId: props.selectedSeriesId } : {})}
/>
<UserArticles
articles={filtered.articles}
loading={props.loading}
error={props.error}
onLoadContent={props.loadArticleContent}
showEmptyMessage
currentPubkey={props.currentPubkey}
onSelectSeries={props.onSelectSeries}
/>
</>
)
}
function filterArticlesBySeries(
articles: Article[],
allArticles: Article[],
selectedSeriesId?: string | undefined
): { articles: Article[]; all: Article[] } {
if (!selectedSeriesId) {
return { articles, all: allArticles }
}
return {
articles: articles.filter((a) => a.seriesId === selectedSeriesId),
all: allArticles.filter((a) => a.seriesId === selectedSeriesId),
}
}

View File

@ -0,0 +1,28 @@
import Link from 'next/link'
import { SeriesSection } from './SeriesSection'
interface ProfileSeriesBlockProps {
currentPubkey: string
onSelectSeries: (seriesId: string | undefined) => void
selectedSeriesId?: string | undefined
}
export function ProfileSeriesBlock({ currentPubkey, onSelectSeries, selectedSeriesId }: ProfileSeriesBlockProps) {
return (
<div className="mb-6">
<h3 className="text-lg font-semibold mb-2">Séries</h3>
<SeriesSection
authorPubkey={currentPubkey}
onSelect={onSelectSeries}
{...(selectedSeriesId ? { selectedId: selectedSeriesId } : {})}
/>
{selectedSeriesId && (
<div className="mt-2 text-sm text-blue-600">
<Link href={`/series/${selectedSeriesId}`} className="underline">
Ouvrir la page de la série sélectionnée
</Link>
</div>
)}
</div>
)
}

View File

@ -5,10 +5,7 @@ import type { NostrProfile } from '@/types/nostr'
import { ProfileHeader } from '@/components/ProfileHeader'
import { BackButton } from '@/components/ProfileBackButton'
import { UserProfile } from '@/components/UserProfile'
import { SearchBar } from '@/components/SearchBar'
import { ArticleFiltersComponent } from '@/components/ArticleFilters'
import { ArticlesSummary } from '@/components/ProfileArticlesSummary'
import { UserArticles } from '@/components/UserArticles'
import { ProfileArticlesSection } from '@/components/ProfileArticlesSection'
interface ProfileViewProps {
currentPubkey: string
@ -23,6 +20,8 @@ interface ProfileViewProps {
loading: boolean
error: string | null
loadArticleContent: (id: string, pubkey: string) => Promise<Article | null>
selectedSeriesId?: string | undefined
onSelectSeries: (seriesId: string | undefined) => void
}
function ProfileLoading() {
@ -33,94 +32,62 @@ function ProfileLoading() {
)
}
function ProfileArticlesSection({
searchQuery,
setSearchQuery,
filters,
setFilters,
articles,
allArticles,
loading,
error,
loadArticleContent,
articleFiltersVisible,
}: Pick<
ProfileViewProps,
'searchQuery' | 'setSearchQuery' | 'filters' | 'setFilters' | 'articles' | 'allArticles' | 'loading' | 'error' | 'loadArticleContent'
> & {
articleFiltersVisible: boolean
}) {
function ProfileLayout(props: ProfileViewProps) {
const articleFiltersVisible = !props.loading && props.allArticles.length > 0
return (
<>
<div className="mb-6">
<h2 className="text-2xl font-bold mb-4">My Articles</h2>
<div className="mb-4">
<SearchBar value={searchQuery} onChange={setSearchQuery} placeholder="Search my articles..." />
</div>
{articleFiltersVisible && (
<ArticleFiltersComponent filters={filters} onFiltersChange={setFilters} articles={allArticles} />
)}
</div>
<ArticlesSummary visibleCount={articles.length} total={allArticles.length} />
<UserArticles
articles={articles}
loading={loading}
error={error}
onLoadContent={loadArticleContent}
showEmptyMessage
<ProfileHeaderSection
loadingProfile={props.loadingProfile}
profile={props.profile}
currentPubkey={props.currentPubkey}
articleCount={props.allArticles.length}
/>
<ProfileArticlesSection
searchQuery={props.searchQuery}
setSearchQuery={props.setSearchQuery}
filters={props.filters}
setFilters={props.setFilters}
articles={props.articles}
allArticles={props.allArticles}
loading={props.loading}
error={props.error}
loadArticleContent={props.loadArticleContent}
articleFiltersVisible={articleFiltersVisible}
currentPubkey={props.currentPubkey}
selectedSeriesId={props.selectedSeriesId}
onSelectSeries={props.onSelectSeries}
/>
</>
)
}
function ProfileLayout({
currentPubkey,
profile,
function ProfileHeaderSection({
loadingProfile,
searchQuery,
setSearchQuery,
filters,
setFilters,
articles,
allArticles,
loading,
error,
loadArticleContent,
}: ProfileViewProps) {
const articleFiltersVisible = !loading && allArticles.length > 0
profile,
currentPubkey,
articleCount,
}: {
loadingProfile: boolean
profile: NostrProfile | null
currentPubkey: string
articleCount: number
}) {
return (
<>
<BackButton />
{loadingProfile ? (
<ProfileLoading />
) : profile ? (
<UserProfile profile={profile} pubkey={currentPubkey} articleCount={allArticles.length} />
<UserProfile profile={profile} pubkey={currentPubkey} articleCount={articleCount} />
) : null}
<ProfileArticlesSection
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
filters={filters}
setFilters={setFilters}
articles={articles}
allArticles={allArticles}
loading={loading}
error={error}
loadArticleContent={loadArticleContent}
articleFiltersVisible={articleFiltersVisible}
/>
</>
)
}
export function ProfileView(props: ProfileViewProps) {
return (
<>
<Head>
<title>My Profile - zapwall4Science</title>
<meta name="description" content="View your profile and published articles" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</Head>
<ProfileHead />
<main className="min-h-screen bg-gray-50">
<ProfileHeader />
<div className="max-w-4xl mx-auto px-4 py-8">
@ -130,3 +97,13 @@ export function ProfileView(props: ProfileViewProps) {
</>
)
}
function ProfileHead() {
return (
<Head>
<title>My Profile - zapwall4Science</title>
<meta name="description" content="View your profile and published articles" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</Head>
)
}

48
components/SeriesCard.tsx Normal file
View File

@ -0,0 +1,48 @@
import Image from 'next/image'
import Link from 'next/link'
import type { Series } from '@/types/nostr'
interface SeriesCardProps {
series: Series
onSelect: (seriesId: string | undefined) => void
selected?: boolean
}
export function SeriesCard({ series, onSelect, selected }: SeriesCardProps) {
return (
<div
className={`border rounded-lg p-4 bg-white shadow-sm ${
selected ? 'border-blue-500 ring-1 ring-blue-200' : 'border-gray-200'
}`}
>
{series.coverUrl && (
<div className="relative w-full h-40 mb-3">
<Image
src={series.coverUrl}
alt={series.title}
className="object-cover rounded"
fill
sizes="(max-width: 768px) 100vw, 50vw"
/>
</div>
)}
<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>
<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
</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
</Link>
</div>
</div>
)
}

21
components/SeriesList.tsx Normal file
View File

@ -0,0 +1,21 @@
import type { Series } from '@/types/nostr'
import { SeriesCard } from './SeriesCard'
interface SeriesListProps {
series: Series[]
onSelect: (seriesId: string | undefined) => void
selectedId?: string | undefined
}
export function SeriesList({ series, onSelect, selectedId }: SeriesListProps) {
if (series.length === 0) {
return <p className="text-sm text-gray-600">Aucune série pour cet auteur.</p>
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{series.map((s) => (
<SeriesCard key={s.id} series={s} onSelect={onSelect} selected={s.id === selectedId} />
))}
</div>
)
}

View File

@ -0,0 +1,137 @@
import { useCallback, useEffect, useState } from 'react'
import type { Series } from '@/types/nostr'
import { SeriesList } from './SeriesList'
import { SeriesStats } from './SeriesStats'
import { getSeriesByAuthor } from '@/lib/seriesQueries'
import { getSeriesAggregates } from '@/lib/seriesAggregation'
interface SeriesSectionProps {
authorPubkey: string
onSelect: (seriesId: string | undefined) => void
selectedId?: string | undefined
}
export function SeriesSection({ authorPubkey, onSelect, selectedId }: SeriesSectionProps) {
const [{ series, loading, error, aggregates }, load] = useSeriesData(authorPubkey)
if (loading) {
return <p className="text-sm text-gray-600">Chargement des séries...</p>
}
if (error) {
return <p className="text-sm text-red-600">{error}</p>
}
return (
<div className="space-y-4">
<SeriesControls onSelect={onSelect} onReload={load} />
<SeriesList
series={series}
onSelect={onSelect}
{...(selectedId ? { selectedId } : {})}
/>
<SeriesAggregatesList series={series} aggregates={aggregates} />
</div>
)
}
function SeriesControls({
onSelect,
onReload,
}: {
onSelect: (id: string | undefined) => void
onReload: () => Promise<void>
}) {
return (
<div className="flex items-center gap-2">
<button
type="button"
className="px-3 py-1 text-sm rounded bg-gray-200"
onClick={() => onSelect(undefined)}
>
Toutes les séries
</button>
<button
type="button"
className="text-xs text-blue-600 underline"
onClick={() => {
void onReload()
}}
>
Recharger
</button>
</div>
)
}
function SeriesAggregatesList({
series,
aggregates,
}: {
series: Series[]
aggregates: Record<string, { sponsoring: number; purchases: number; reviewTips: number }>
}) {
return (
<>
{series.map((s) => {
const agg = aggregates[s.id] ?? { sponsoring: 0, purchases: 0, reviewTips: 0 }
return (
<div key={s.id} className="mt-2">
<SeriesStats sponsoring={agg.sponsoring} purchases={agg.purchases} reviewTips={agg.reviewTips} />
</div>
)
})}
</>
)
}
function useSeriesData(authorPubkey: string): [
{
series: Series[]
loading: boolean
error: string | null
aggregates: Record<string, { sponsoring: number; purchases: number; reviewTips: number }>
},
() => Promise<void>
] {
const [series, setSeries] = useState<Series[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [aggregates, setAggregates] = useState<Record<string, { sponsoring: number; purchases: number; reviewTips: number }>>({})
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
const { items, aggregates: agg } = await fetchSeriesAndAggregates(authorPubkey)
setSeries(items)
setAggregates(agg)
} catch (e) {
setError(e instanceof Error ? e.message : 'Erreur lors du chargement des séries')
} finally {
setLoading(false)
}
}, [authorPubkey])
useEffect(() => {
void load()
}, [load])
return [{ series, loading, error, aggregates }, load]
}
async function fetchSeriesAndAggregates(authorPubkey: string): Promise<{
items: Series[]
aggregates: Record<string, { sponsoring: number; purchases: number; reviewTips: number }>
}> {
const items = await getSeriesByAuthor(authorPubkey)
const aggEntries = await Promise.all(
items.map(async (s) => {
const agg = await getSeriesAggregates({ authorPubkey, seriesId: s.id })
return [s.id, agg] as const
})
)
const aggMap: Record<string, { sponsoring: number; purchases: number; reviewTips: number }> = {}
aggEntries.forEach(([id, agg]) => {
aggMap[id] = agg
})
return { items, aggregates: aggMap }
}

View File

@ -0,0 +1,27 @@
interface SeriesStatsProps {
sponsoring: number
purchases: number
reviewTips: number
}
function formatSats(value: number): string {
return `${value} sats`
}
export function SeriesStats({ sponsoring, purchases, reviewTips }: SeriesStatsProps) {
const items = [
{ label: 'Sponsoring (hors frais)', value: formatSats(sponsoring) },
{ label: 'Paiements articles (hors frais)', value: formatSats(purchases) },
{ label: 'Remerciements critiques (hors frais)', value: formatSats(reviewTips) },
]
return (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{items.map((item) => (
<div key={item.label} className="border rounded-lg p-3 bg-white text-sm">
<div className="text-gray-600">{item.label}</div>
<div className="font-semibold text-gray-900">{item.value}</div>
</div>
))}
</div>
)
}

View File

@ -1,6 +1,8 @@
import { useState } from 'react'
import { ArticleCard } from './ArticleCard'
import { useEffect, useState, type Dispatch, type SetStateAction } from 'react'
import type { Article } from '@/types/nostr'
import { useArticleEditing } from '@/hooks/useArticleEditing'
import { UserArticlesView } from './UserArticlesList'
import { EditPanel } from './UserArticlesEditPanel'
interface UserArticlesProps {
articles: Article[]
@ -8,67 +10,8 @@ interface UserArticlesProps {
error: string | null
onLoadContent: (articleId: string, authorPubkey: string) => Promise<Article | null>
showEmptyMessage?: boolean
}
function ArticlesLoading() {
return (
<div className="text-center py-12">
<p className="text-gray-500">Loading articles...</p>
</div>
)
}
function ArticlesError({ message }: { message: string }) {
return (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-4">
<p className="text-red-800">{message}</p>
</div>
)
}
function EmptyState({ show }: { show: boolean }) {
if (!show) {
return null
}
return (
<div className="text-center py-12">
<p className="text-gray-500">No articles published yet.</p>
</div>
)
}
function UserArticlesView({
articles,
loading,
error,
showEmptyMessage,
unlockedArticles,
onUnlock,
}: Omit<UserArticlesProps, 'onLoadContent'> & { unlockedArticles: Set<string>; onUnlock: (article: Article) => void }) {
if (loading) {
return <ArticlesLoading />
}
if (error) {
return <ArticlesError message={error} />
}
if (articles.length === 0) {
return <EmptyState show={showEmptyMessage} />
}
return (
<div className="space-y-6">
{articles.map((article) => (
<ArticleCard
key={article.id}
article={{
...article,
paid: unlockedArticles.has(article.id) || article.paid,
}}
onUnlock={onUnlock}
/>
))}
</div>
)
currentPubkey: string | null
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
}
export function UserArticles({
@ -77,27 +20,205 @@ export function UserArticles({
error,
onLoadContent,
showEmptyMessage = true,
currentPubkey,
onSelectSeries,
}: UserArticlesProps) {
const [unlockedArticles, setUnlockedArticles] = useState<Set<string>>(new Set())
const handleUnlock = async (article: Article) => {
const fullArticle = await onLoadContent(article.id, article.pubkey)
if (fullArticle?.paid) {
setUnlockedArticles((prev) => new Set([...prev, article.id]))
}
}
const controller = useUserArticlesController({ articles, onLoadContent, currentPubkey })
return (
<UserArticlesView
articles={articles}
<UserArticlesLayout
controller={controller}
loading={loading}
error={error}
onLoadContent={onLoadContent}
showEmptyMessage={showEmptyMessage}
unlockedArticles={unlockedArticles}
onUnlock={(a) => {
void handleUnlock(a)
}}
showEmptyMessage={showEmptyMessage ?? true}
currentPubkey={currentPubkey}
onSelectSeries={onSelectSeries}
/>
)
}
function useUserArticlesController({
articles,
onLoadContent,
currentPubkey,
}: {
articles: Article[]
onLoadContent: (articleId: string, authorPubkey: string) => Promise<Article | null>
currentPubkey: string | null
}) {
const [localArticles, setLocalArticles] = useState<Article[]>(articles)
const [unlockedArticles, setUnlockedArticles] = useState<Set<string>>(new Set())
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null)
const editingCtx = useArticleEditing(currentPubkey)
useEffect(() => setLocalArticles(articles), [articles])
return {
localArticles,
unlockedArticles,
pendingDeleteId,
requestDelete: (id: string) => setPendingDeleteId(id),
handleUnlock: createHandleUnlock(onLoadContent, setUnlockedArticles),
handleDelete: createHandleDelete(editingCtx.deleteArticle, setLocalArticles, setPendingDeleteId),
handleEditSubmit: createHandleEditSubmit(
editingCtx.submitEdit,
editingCtx.editingDraft,
currentPubkey,
setLocalArticles
),
...editingCtx,
}
}
function createHandleUnlock(
onLoadContent: (id: string, pubkey: string) => Promise<Article | null>,
setUnlocked: Dispatch<SetStateAction<Set<string>>>
) {
return async (article: Article) => {
const full = await onLoadContent(article.id, article.pubkey)
if (full?.paid) {
setUnlocked((prev) => new Set([...prev, article.id]))
}
}
}
function createHandleDelete(
deleteArticle: (id: string) => Promise<boolean>,
setLocalArticles: Dispatch<SetStateAction<Article[]>>,
setPendingDeleteId: Dispatch<SetStateAction<string | null>>
) {
return async (article: Article) => {
const ok = await deleteArticle(article.id)
if (ok) {
setLocalArticles((prev) => prev.filter((a) => a.id !== article.id))
}
setPendingDeleteId(null)
}
}
function createHandleEditSubmit(
submitEdit: () => Promise<import('@/lib/articleMutations').ArticleUpdateResult | null>,
draft: ReturnType<typeof useArticleEditing>['editingDraft'],
currentPubkey: string | null,
setLocalArticles: Dispatch<SetStateAction<Article[]>>
) {
return async () => {
const result = await submitEdit()
if (result && draft) {
const updated = buildUpdatedArticle(draft, currentPubkey ?? '', result.articleId)
setLocalArticles((prev) => {
const filtered = prev.filter((a) => a.id !== result.originalArticleId)
return [updated, ...filtered]
})
}
}
}
function buildUpdatedArticle(
draft: NonNullable<ReturnType<typeof useArticleEditing>['editingDraft']>,
pubkey: string,
newId: string
): Article {
return {
id: newId,
pubkey,
title: draft.title,
preview: draft.preview,
content: '',
createdAt: Math.floor(Date.now() / 1000),
zapAmount: draft.zapAmount,
paid: false,
...(draft.category ? { category: draft.category } : {}),
}
}
function UserArticlesLayout({
controller,
loading,
error,
showEmptyMessage,
currentPubkey,
onSelectSeries,
}: {
controller: ReturnType<typeof useUserArticlesController>
loading: boolean
error: string | null
showEmptyMessage: boolean
currentPubkey: string | null
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
}) {
const { editPanelProps, listProps } = createLayoutProps(controller, {
loading,
error,
showEmptyMessage,
currentPubkey,
onSelectSeries,
})
return (
<div className="space-y-4">
<EditPanel {...editPanelProps} />
<UserArticlesView {...listProps} />
</div>
)
}
function createLayoutProps(
controller: ReturnType<typeof useUserArticlesController>,
view: {
loading: boolean
error: string | null
showEmptyMessage: boolean
currentPubkey: string | null
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
}
) {
return {
editPanelProps: buildEditPanelProps(controller),
listProps: buildListProps(controller, view),
}
}
function buildEditPanelProps(controller: ReturnType<typeof useUserArticlesController>) {
return {
draft: controller.editingDraft,
editingArticleId: controller.editingArticleId,
loading: controller.loading,
error: controller.error,
onCancel: controller.cancelEditing,
onDraftChange: controller.updateDraft,
onSubmit: controller.handleEditSubmit,
}
}
function buildListProps(
controller: ReturnType<typeof useUserArticlesController>,
view: {
loading: boolean
error: string | null
showEmptyMessage: boolean
currentPubkey: string | null
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
}
) {
return {
articles: controller.localArticles,
loading: view.loading,
error: view.error,
showEmptyMessage: view.showEmptyMessage,
unlockedArticles: controller.unlockedArticles,
onUnlock: (a: Article) => {
void controller.handleUnlock(a)
},
onEdit: (a: Article) => {
void controller.startEditing(a)
},
onDelete: (a: Article) => {
void controller.handleDelete(a)
},
editingArticleId: controller.editingArticleId,
currentPubkey: view.currentPubkey,
pendingDeleteId: controller.pendingDeleteId,
requestDelete: controller.requestDelete,
...(view.onSelectSeries ? { onSelectSeries: view.onSelectSeries } : {}),
}
}

View File

@ -0,0 +1,42 @@
import { ArticleEditorForm } from './ArticleEditorForm'
import type { ArticleDraft } from '@/lib/articlePublisher'
interface EditPanelProps {
draft: ArticleDraft | null
editingArticleId: string | null
loading: boolean
error: string | null
onCancel: () => void
onDraftChange: (draft: ArticleDraft) => void
onSubmit: () => void
}
export function EditPanel({
draft,
editingArticleId,
loading,
error,
onCancel,
onDraftChange,
onSubmit,
}: EditPanelProps) {
if (!draft || !editingArticleId) {
return null
}
return (
<div className="border rounded-lg p-4 bg-white space-y-3">
<h3 className="text-lg font-semibold">Edit article</h3>
<ArticleEditorForm
draft={draft}
onDraftChange={onDraftChange}
onSubmit={(e) => {
e.preventDefault()
onSubmit()
}}
loading={loading}
error={error}
onCancel={onCancel}
/>
</div>
)
}

View File

@ -0,0 +1,199 @@
import { ArticleCard } from './ArticleCard'
import type { Article } from '@/types/nostr'
import { memo } from 'react'
import Link from 'next/link'
interface UserArticlesViewProps {
articles: Article[]
loading: boolean
error: string | null
showEmptyMessage?: boolean
unlockedArticles: Set<string>
onUnlock: (article: Article) => void
onEdit: (article: Article) => void
onDelete: (article: Article) => void
editingArticleId: string | null
currentPubkey: string | null
pendingDeleteId: string | null
requestDelete: (articleId: string) => void
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
}
const ArticlesLoading = () => (
<div className="text-center py-12">
<p className="text-gray-500">Loading articles...</p>
</div>
)
const ArticlesError = ({ message }: { message: string }) => (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-4">
<p className="text-red-800">{message}</p>
</div>
)
const EmptyState = ({ show }: { show: boolean }) =>
show ? (
<div className="text-center py-12">
<p className="text-gray-500">No articles published yet.</p>
</div>
) : null
function ArticleActions({
article,
onEdit,
onDelete,
editingArticleId,
pendingDeleteId,
requestDelete,
}: {
article: Article
onEdit: (article: Article) => void
onDelete: (article: Article) => void
editingArticleId: string | null
pendingDeleteId: string | null
requestDelete: (articleId: string) => void
}) {
return (
<div className="flex gap-2">
<button
onClick={() => onEdit(article)}
className="px-3 py-1 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
disabled={editingArticleId !== null && editingArticleId !== article.id}
>
Edit
</button>
<button
onClick={() => (pendingDeleteId === article.id ? onDelete(article) : requestDelete(article.id))}
className="px-3 py-1 text-sm rounded bg-red-600 text-white hover:bg-red-700"
>
{pendingDeleteId === article.id ? 'Confirm delete' : 'Delete'}
</button>
</div>
)
}
function ArticleRow(
props: Omit<UserArticlesViewProps, 'articles' | 'loading' | 'error' | 'showEmptyMessage'> & {
article: Article
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
}
) {
const content = buildArticleContent(props)
return <div className="space-y-3">{content}</div>
}
function buildArticleContent(
props: Omit<UserArticlesViewProps, 'articles' | 'loading' | 'error' | 'showEmptyMessage'> & {
article: Article
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
}
) {
const parts = [buildArticleCard(props), buildSeriesLink(props), buildActions(props)].filter(Boolean)
return parts as JSX.Element[]
}
function buildArticleCard(
props: Omit<UserArticlesViewProps, 'articles' | 'loading' | 'error' | 'showEmptyMessage'> & { article: Article }
) {
const { article, unlockedArticles, onUnlock } = props
return (
<ArticleCard
key="card"
article={{ ...article, paid: unlockedArticles.has(article.id) || article.paid }}
onUnlock={onUnlock}
/>
)
}
function buildSeriesLink(
props: Omit<UserArticlesViewProps, 'articles' | 'loading' | 'error' | 'showEmptyMessage'> & {
article: Article
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
}
) {
const { article, onSelectSeries } = props
if (!article.seriesId) {
return null
}
return (
<div key="series" className="text-xs text-blue-700 flex gap-2 items-center">
<span>Série :</span>
<Link href={`/series/${article.seriesId}`} className="underline">
Ouvrir
</Link>
{onSelectSeries && (
<button type="button" className="underline" onClick={() => onSelectSeries(article.seriesId)}>
Filtrer
</button>
)}
</div>
)
}
function buildActions(
props: Omit<UserArticlesViewProps, 'articles' | 'loading' | 'error' | 'showEmptyMessage'> & { article: Article }
) {
const { article, currentPubkey, onEdit, onDelete, editingArticleId, pendingDeleteId, requestDelete } = props
if (currentPubkey !== article.pubkey) {
return null
}
return (
<ArticleActions
key="actions"
article={article}
onEdit={onEdit}
onDelete={onDelete}
editingArticleId={editingArticleId}
pendingDeleteId={pendingDeleteId}
requestDelete={requestDelete}
/>
)
}
function UserArticlesViewComponent(props: UserArticlesViewProps) {
if (props.loading) {
return <ArticlesLoading />
}
if (props.error) {
return <ArticlesError message={props.error} />
}
if ((props.showEmptyMessage ?? true) && props.articles.length === 0) {
return <EmptyState show />
}
return renderArticles(props)
}
function renderArticles({
articles,
unlockedArticles,
onUnlock,
onEdit,
onDelete,
editingArticleId,
currentPubkey,
pendingDeleteId,
requestDelete,
onSelectSeries,
}: UserArticlesViewProps) {
return (
<div className="space-y-6">
{articles.map((article) => (
<ArticleRow
key={article.id}
article={article}
unlockedArticles={unlockedArticles}
onUnlock={onUnlock}
onEdit={onEdit}
onDelete={onDelete}
editingArticleId={editingArticleId}
currentPubkey={currentPubkey}
pendingDeleteId={pendingDeleteId}
requestDelete={requestDelete}
onSelectSeries={onSelectSeries}
/>
))}
</div>
)
}
export const UserArticlesView = memo(UserArticlesViewComponent)

View File

@ -25,8 +25,8 @@ export function UserProfile({ profile, pubkey, articleCount }: UserProfileProps)
<UserProfileHeader
displayName={displayName}
displayPubkey={displayPubkey}
picture={profile.picture}
nip05={profile.nip05}
{...(profile.picture ? { picture: profile.picture } : {})}
{...(profile.nip05 ? { nip05: profile.nip05 } : {})}
/>
{profile.about && <p className="text-gray-700 mt-2">{profile.about}</p>}
{articleCount !== undefined && <ProfileStats articleCount={articleCount} />}

View File

@ -0,0 +1,22 @@
# Article edit/delete via Nostr events
**Objectif**
Permettre aux auteurs déditer ou supprimer leurs articles en publiant des événements Nostr dédiés (update + delete), avec confirmation explicite côté UI.
**Impacts**
- Parcours auteur : édition depuis la liste de mes articles, suppression confirmée avant envoi de lévénement kind 5.
- Stockage local : contenu privé ré-encrypté et ré-enregistré pour les mises à jour.
- Pas dimpact côté lecteurs (pas de fallback).
**Modifications**
- `lib/articleMutations.ts` : publication update/delete (tags e, replace), réutilisation du stockage chiffré.
- `components/UserArticles.tsx`, `components/UserArticlesList.tsx`, `components/UserArticlesEditPanel.tsx` : UI édition/suppression avec confirmation, découpage pour respecter lint/max-lines.
- `lib/articleInvoice.ts` : factorisation des tags de preview.
**Modalités de déploiement**
Standard front : build Next.js habituel. Pas de migrations ni dépendances supplémentaires.
**Modalités danalyse**
- Vérifier quun auteur connecté peut éditer puis voir son article mis à jour dans la liste.
- Vérifier que la suppression publie lévénement et retire larticle de la liste locale.
- Sur erreur de publication, message derreur affiché (aucun fallback silencieux).

View File

@ -0,0 +1,24 @@
# Documentation technique à compléter
## Objectif
Formaliser la structure technique (services, hooks, types) et le flux Nostr/stockage, sans ajouter de tests ni danalytics.
## Cibles
- Services Nostr : `lib/nostr.ts`, `lib/nostrRemoteSigner.ts`, `lib/articleMutations.ts`, `lib/zapVerification.ts`, `lib/nostrconnect.ts`.
- Paiement/Alby/WebLN : `lib/alby.ts`, `lib/payment.ts`, `lib/paymentPolling.ts`.
- Stockage : `lib/storage/indexedDB.ts`, `lib/storage/cryptoHelpers.ts`, `lib/articleStorage.ts`.
- Hooks : `hooks/useArticles.ts`, `hooks/useUserArticles.ts`, `hooks/useArticleEditing.ts`.
- Types : `types/nostr.ts`, `types/nostr-tools-extended.ts`, `types/alby.ts`.
- UI clés : `components/UserArticles*.tsx`, `components/ArticleEditor*.tsx`, `components/AlbyInstaller.tsx`.
## Plan
1) Cartographie des services/hooks/types (diagramme ou tableau : responsabilités, entrées/sorties, dépendances).
2) Guide Nostr : publication, update/delete, zap verification, remote signer.
3) Guide stockage : chiffrement IndexedDB, gestion des expirations.
4) Guide paiements : création facture, polling, envoi contenu privé.
5) Contrib : référencer dans `CONTRIBUTING.md`.
## Contraintes
- Pas de tests, pas danalytics.
- Pas de fallback implicite; erreurs loguées et surfacées.
- Respect lint/typage/accessibilité.

View File

@ -1,6 +1,6 @@
# Résumé final du nettoyage et optimisation
**Date** : Décembre 2024
**Date** : Décembre 2025 (addendum)
## ✅ Objectifs complétés
@ -28,9 +28,16 @@ Toutes les fonctions longues ont été extraites dans des modules dédiés :
- Handler NostrConnect → `nostrconnectHandler.ts`
### 4. Correction des erreurs de lint
- ✅ Aucune erreur de lint dans le code TypeScript
- ✅ Aucune erreur de lint dans le code TypeScript (déc. 2025 : `npm run lint` OK)
- ✅ Code propre et optimisé
## Addendum Déc 2025
- Séries, critiques, agrégations zap : nouvelles sections UI/logic (`Series*`, `ArticleReviews`, `zapAggregation*`).
- Upload médias NIP-95 (images/vidéos) avec validations de taille et type.
- Stockage contenu privé chiffré en IndexedDB + helpers WebCrypto.
- Respect strict `exactOptionalPropertyTypes`, fonctions < 40 lignes, fichiers < 250 lignes (refactors composants profil/articles, sélecteurs de séries).
- Pas de tests ajoutés, pas danalytics.
## Nouveaux fichiers créés (9 fichiers)
1. **`lib/nostrEventParsing.ts`** (40 lignes)

View File

@ -1,6 +1,17 @@
# Résumé des implémentations - Nostr Paywall
**Date** : Décembre 2024
**Date** : Décembre 2025 (mise à jour)
## ✅ Mises à jour Déc 2025
- **Séries et critiques** : pages série dédiées (`pages/series/[id].tsx`), agrégats sponsoring/achats/remerciements, filtrage par série sur le profil, affichage des critiques avec formatage auteur/date et total des remerciements.
- **NIP-95 médias** : upload images (≤5Mo PNG/JPG/JPEG/WebP) et vidéos (≤45Mo MP4/WebM) via `lib/nip95.ts`, insertion dans markdown et bannière.
- **Agrégations zap** : `lib/zapAggregation.ts`, `lib/seriesAggregation.ts`, `lib/reviewAggregation.ts` pour cumuls sponsoring/achats/remerciements par auteur/série/article.
- **Tags Nostr enrichis** : `lib/nostrTags.ts`, `lib/articlePublisher.ts`, `lib/articleMutations.ts` intègrent `kind_type`, `seriesId`, bannières, médias, catégories, site/type (science-fiction/research).
- **Hooks et subscriptions** : `useArticles` / `useUserArticles` corrigent la gestion des unsub synchro (pas de `.then`), pas de fallback.
- **Stockage privé** : contenu chiffré en IndexedDB (WebCrypto AES-GCM) via `lib/storage/indexedDB.ts` + helpers `lib/storage/cryptoHelpers.ts`.
- **Qualité stricte** : `exactOptionalPropertyTypes` respecté (props optionnelles typées `| undefined`), fonctions < 40 lignes et fichiers < 250 lignes (refactors multiples).
- **Navigation** : liens directs vers page série depuis cartes et liste darticles, filtrage par série depuis la liste utilisateur.
## ✅ Implémentations complétées
@ -112,8 +123,8 @@
### Technologies utilisées
- **Frontend** : Next.js 14, React, TypeScript, Tailwind CSS
- **Nostr** : `nostr-tools` (v2.3.4)
- **Lightning** : Alby/WebLN (`@getalby/sdk`)
- **Nostr** : `nostr-tools` 1.17.0 (compat `signEvent`, `verifyEvent`)
- **Lightning** : Alby/WebLN
- **QR Code** : `react-qr-code`
## Fichiers créés/modifiés

View File

@ -0,0 +1,14 @@
# Notifications scope (Dec 2025)
**Decision:** Do not implement notifications for mentions, reposts, or likes. Only payment notifications remain active. If comment notifications are later requested, they must be explicitly approved.
**Impacts:**
- No parsing/subscription for mention/repost/like events.
- UI does not surface badges or panels for these types.
- Avoids extra relay traffic and parsing logic.
**Constraints/quality:**
- No fallbacks.
- No analytics.
- Keep error logging structured if new notification types are ever added.
- Respect accessibility and lint/typing rules already in place.

View File

@ -0,0 +1,69 @@
# Séries, média NIP-95 et événements Nostr (spec v1, Jan 2026)
## 1) Événements et tags (rien en local)
Namespace tag communs (tous les events) :
- `site`: `zapwall4science`
- `category`: `science-fiction` | `scientific-research`
- `author`: pubkey auteur
- `series`: id série (event id de la série)
- `article`: id article (event id de larticle)
Kinds proposés (réutilisation kind 1 pour compat) :
- Série : kind `1` avec tag `kind_type: series`
- tags : `site`, `category`, `author`, `series` (self id), `title`, `description`, `cover` (URL NIP-95), `preview`
- Article : kind `1` avec tag `kind_type: article`
- tags : `site`, `category`, `author`, `series`, `article` (self id), `title`, `preview`, `banner` (URL NIP-95), `media` (0..n URLs NIP-95)
- contenu markdown public (preview) ; privé chiffré inchangé côté storage
- Avis (critique) : kind `1` avec tag `kind_type: review`
- tags : `site`, `category`, `author`, `series`, `article`, `reviewer` (pubkey), `title`, `created_at`
- contenu = avis en clair
- Achat article (zap receipt) : kind `9735` (zap) avec tags standard `p`, `e`, plus `site`, `category`, `author`, `series`, `article`, `kind_type: purchase`
- amount = millisats, hors frais site gérés off-chain
- Paiement remerciement avis : kind `9735` zap avec `kind_type: review_tip`, tags `site`, `category`, `author`, `series`, `article`, `reviewer`, `review_id`
- Paiement sponsoring : kind `9735` zap avec `kind_type: sponsoring`, tags `site`, `category`, `author`, `series` (optionnel), `article` (présentation si ciblé)
Notes :
- Tous les cumuls (sponsoring, paiements article, remerciements avis) calculés via zap receipts filtrés par `kind_type`.
- Séries sans sponsoring autorisées (0).
## 2) Média NIP-95 (images/vidéos)
- Upload via NIP-95 (encrypted file events). Contraintes :
- Images/photos : max 5 Mo, png/jpg/jpeg/webp.
- Vidéos : max 45 Mo.
- Stockage chiffré (même logique quarticles) ; URL NIP-95 insérée dans markdown et bannière.
- Validation côté client : type MIME, taille, échec → erreur surfacée (pas de fallback).
## 3) Pages / navigation
Hiérarchie : site → catégorie (SF/Recherche) → auteurs → auteur → série → articles → article.
- Page auteur : liste des séries (cover type “livre”, titre, desc, preview, cumul sponsoring/paiements agrégés via zap receipts). Profil Nostr affiché.
- Page série : détails série (cover, desc, preview), cumul sponsoring série + paiements articles de la série, liste darticles de la série.
- Article : preview public, contenu privé chiffré inchangé, bannière NIP-95, média insérés dans markdown.
- Rédaction : éditeur markdown + preview live, upload/paste NIP-95 pour images/vidéos, champs bannière (URL NIP-95), sélection série.
## 4) Agrégations financières (hors frais/site)
- Sponsoring : zap receipts `kind_type: sponsoring`, filtres `site`, `author`, option `series`.
- Paiements articles : zap receipts `kind_type: purchase`.
- Remerciements avis : zap receipts `kind_type: review_tip`.
- Cumuls par auteur et par série ; pas de détail de lecteurs (sauf auteur du zap pour avis si nécessaire au wording minimal).
## 5) Wording “critiques”
- Affichage des avis en tant que “critiques”.
- Liste des critiques : afficher contenu + auteur (pubkey→profil) ; pas de liste distincte “critiques” séparée des avis (juste les avis).
## 6) TODO dimplémentation (proposé)
- Types : étendre `types/nostr.ts` avec Series, Review, media refs; enum `KindType`.
- Upload NIP-95 : service dédié (validation taille/type, retour URL).
- Publisher : ajouter création série (event), article avec tags série/media/banner.
- Parsing : `nostrEventParsing` pour séries/articles/avis avec tags `kind_type`.
- Aggregation : service zap pour cumuls (sponsoring/purchase/review_tip) par auteur/série.
- UI :
- Form auteur/série/article (cover/banner, sélection série, markdown+preview, upload media).
- Pages auteur/série avec stats cumulées.
- Pas de stockage local pour méta (tout via events).

View File

@ -0,0 +1,23 @@
# Storage encryption (IndexedDB) Dec 2025
**Scope**
- Encrypt private article content and invoices stored in IndexedDB using Web Crypto (AES-GCM).
- Deterministic per-article secret derived from a persisted master key.
- No fallbacks; fails if IndexedDB or Web Crypto is unavailable.
**Key management**
- Master key generated once in browser (`article_storage_master_key`, random 32 bytes, base64) and kept in localStorage.
- Per-article secret: `<masterKey>:<articleId>` (used only client-side).
**Implementation**
- `lib/storage/cryptoHelpers.ts`: AES-GCM helpers (base64 encode/decode, encrypt/decrypt).
- `lib/storage/indexedDB.ts`: store/get now require a secret; payloads encrypted; unchanged API surface via `storageService`.
- `lib/articleStorage.ts`: derives per-article secret, encrypts content+invoice on write, decrypts on read, same expiration (30 days).
**Behavior**
- If IndexedDB or crypto is unavailable, operations throw (no silent fallback).
- Existing data written before encryption wont decrypt; new writes are encrypted.
**Next steps (optional)**
- Rotate master key with migration plan.
- Add per-user secrets or hardware-bound keys if required.

71
features/technical-doc.md Normal file
View File

@ -0,0 +1,71 @@
# Documentation technique zapwall4science (Dec 2025)
## 1) Cartographie services/hooks/types (responsabilités, dépendances)
- **Services Nostr**
- `lib/nostr.ts` : pool SimplePool, publish générique, clés locales; queries article.
- `lib/nostrEventParsing.ts` : parsing events → Article/Series/Review (tags kind_type, series, media, banner).
- `lib/nostrTags.ts` : construction des tags article/série/avis (site, type science-fiction/research, kind_type, media/bannière).
- `lib/seriesQueries.ts`, `lib/articleQueries.ts`, `lib/reviews.ts` : fetch séries, articles par série, critiques.
- `lib/articlePublisher.ts`, `lib/articleMutations.ts` : publication article/preview/presentation, update/delete, kind_type, séries, médias, bannière.
- `lib/seriesAggregation.ts`, `lib/reviewAggregation.ts`, `lib/zapAggregation.ts` : agrégats sponsoring/achats/remerciements.
- `lib/zapVerification.ts` : vérification zap receipts (verifyEvent).
- `lib/nostrconnect.ts` : Nostr Connect (handler, sécurité).
- `lib/nostrRemoteSigner.ts` : signature distante (pas de fallback silencieux).
- `lib/nip95.ts` : upload médias NIP-95 (images ≤5Mo, vidéos ≤45Mo, types restreints).
- **Paiement / Lightning**
- `lib/alby.ts` : WebLN (enable, makeInvoice, sendPayment).
- `lib/payment.ts`, `lib/paymentPolling.ts` : statut paiements, polling.
- `lib/articleInvoice.ts` : facture article (zapAmount, expiry), tags preview.
- **Stockage**
- `lib/storage/cryptoHelpers.ts` : AES-GCM, dérivation/import clé, conversions b64.
- `lib/storage/indexedDB.ts` : set/get/delete/clearExpired chiffré, TTL.
- `lib/articleStorage.ts` : clé maître locale (base64), secret par article, persistance contenu privé + facture.
- **Hooks**
- `hooks/useArticles.ts`, `hooks/useUserArticles.ts` : subscriptions articles, filtres catégorie, unsubscribe direct (pas de .then).
- `hooks/useArticleEditing.ts` : édition/suppression article.
- Autres : `useNotificationCenter`, `useNostrConnect`, etc.
- **Types**
- `types/nostr.ts` : Article, Series, Review, MediaRef, KindType, catégories; exactOptionalPropertyTypes respecté.
- `types/nostr-tools-extended.ts` : extensions SimplePool/Sub.
- `types/alby.ts` : WebLNProvider, invoice types.
## 2) Guide Nostr (publication, update/delete, zap, remote signer)
- Publication générique : `nostrService.publishEvent(eventTemplate)` (signEvent, created_at contrôlé).
- Article : `articlePublisher.publishArticle` (preview + tags kind_type/site/category/series/banner/media), contenu privé chiffré stocké.
- Update article : `publishArticleUpdate` (`lib/articleMutations.ts`) tags `e` (original) + `replace`, publie preview + contenu privé chiffré.
- Delete article : `deleteArticleEvent` (kind 5, tag e) — erreurs remontées, pas de fallback.
- Série : `publishSeries` (kind 1, tags kind_type=series, cover, description).
- Avis : `publishReview` (kind 1, kind_type=review, article/series/author tags).
- Zap verification : `lib/zapVerification.ts` (verifyEvent).
- Agrégats : `aggregateZapSats` / `getSeriesAggregates` / `getReviewTipsForArticle`.
- Remote signer : `lib/nostrRemoteSigner.ts` (clé fournie), sans valeurs de repli implicites.
## 3) Guide Stockage
- Chiffrement : `cryptoHelpers` (AES-GCM), entrée BufferSource conforme Web Crypto.
- IndexedDB : `storage/indexedDB.ts` stocke `{ iv, ciphertext, expiresAt }`, purge `clearExpired`.
- Secret : `articleStorage` génère clé maître (localStorage, base64, 32 bytes) puis secret `<master>:<articleId>`.
- Données : contenu privé + facture, TTL 30 jours, suppression via `removeStoredPrivateContent`.
## 4) Guide Paiements
- WebLN/Alby : `lib/alby.ts` (enable, makeInvoice, sendPayment).
- Facture article : `createArticleInvoice` (zapAmount, expiry).
- Publication article : `articlePublisher` gère preview event + stockage contenu privé.
- Polling / vérif : `paymentPolling.ts`, `payment.ts`; agrégats via zap receipts (`zapAggregation.ts`).
- Envoi contenu privé : via publisher/mutations après paiement confirmé.
## 5) Médias / NIP-95
- Upload via `uploadMedia(file)` (URL `NEXT_PUBLIC_NIP95_UPLOAD_URL`), validation type/taille.
- Utilisation dans `MarkdownEditor` (insertion markdown), bannière dans ArticleDraft.
## 6) Contrib (référence rapide)
- Lint/typage obligatoires (`npm run lint`, `npm run type-check`).
- Pas de tests/analytics ajoutés.
- Pas de fallback implicite, pas de `ts-ignore`/`any` non justifié.
- Accessibilité : ARIA/clavier/contraste.
- Documentation : corrections dans `fixKnowledge/`, features dans `features/`.

View File

@ -0,0 +1,20 @@
# Lint & type fixes (exactOptionalPropertyTypes)
**Problem:** TypeScript strict optional props and nostr-tools API differences caused `npm run type-check` and `next lint` to fail.
**Impact:** Build blocked (exact optional props errors, nostr-tools signing types), lint blocked (`max-lines-per-function`, `prefer-const`).
**Root cause:** Code was written against older nostr-tools typings and passed `undefined` into optional props under `exactOptionalPropertyTypes`.
**Corrections (code):**
- Normalized optional props handling (conditional spreads) across editor, fields, notifications, storage, markdown rendering.
- Added safe category handling for article drafts; tightened defaults and guards.
- Aligned signing with nostr-tools 1.17.0 (explicit `pubkey`/`created_at`, hash/sign helpers).
- Fixed async return contracts, removed unused params, and satisfied lint structure rules.
- Kept storage and notification payloads free of `undefined` fields; guarded link rendering.
**Modifications (files):** `components/ArticleEditor*.tsx`, `components/ArticleField.tsx`, `components/UserArticles.tsx`, `components/CategorySelect.tsx`, `lib/{nostr,nostrRemoteSigner,nostrconnect,articlePublisher,articleStorage,markdownRenderer,notifications,zapVerification}.ts`, `hooks/useArticles.ts`, `hooks/useUserArticles.ts`, `types/nostr-tools-extended.ts`, `package.json` (nostr-tools 1.17.0).
**Deployment:** No special steps; run `npm run lint && npm run type-check` (already clean).
**Analysis:** Ensured strict optional handling without fallbacks, restored compatibility with stable nostr-tools API, and kept functions within lint boundaries.

View File

@ -0,0 +1,27 @@
## Problème
Erreurs TypeScript `exactOptionalPropertyTypes` et avertissements ESLint `max-lines-per-function` sur les composants de sélection de séries (ArticleEditorForm, Profile, UserArticles).
## Impacts
- Blocage du `tsc --noEmit` et du lint, empêchant le déploiement.
- Risque de régressions UI (sélection de série) si les props optionnelles sont mal typées.
## Cause
Propagation de props optionnelles (`seriesOptions`, `onSelectSeries`, `selectedSeriesId`) sans inclure explicitement `undefined` dans les signatures avec `exactOptionalPropertyTypes: true`.
## Root cause
Contrats de composants non alignés sur les exigences strictes TypeScript (exactOptionalPropertyTypes) et fonctions trop longues (>40 lignes) dans ArticleEditorForm et UserArticles.
## Corrections
- Typage explicite des props optionnelles avec `| undefined` et propagation conditionnelle des props.
- Refactoring des fonctions longues : extraction des handlers (SeriesSelect) et découpage de `createLayoutProps`.
## Modifications
- `components/ArticleEditor.tsx`, `components/ArticleEditorForm.tsx`, `components/ProfileArticlesSection.tsx`, `components/ProfileSeriesBlock.tsx`, `components/SeriesSection.tsx`, `components/SeriesList.tsx`, `components/SeriesCard.tsx`, `components/UserArticles.tsx`, `components/UserArticlesList.tsx`, `components/ProfileView.tsx`.
- Ajustements de typage et réduction des fonctions >40 lignes.
## Modalités de déploiement
Pas daction spécifique : lint et type-check passent (`npm run lint`, `npm run type-check`). Déployer via la pipeline habituelle.
## Modalités d'analyse
- Vérifier `npm run lint` et `npm run type-check`.
- Tester la sélection/filtrage de série dans léditeur darticles et sur la page profil (navigation série).

114
hooks/useArticleEditing.ts Normal file
View File

@ -0,0 +1,114 @@
import { useState } from 'react'
import type { ArticleDraft } from '@/lib/articlePublisher'
import type { ArticleUpdateResult } from '@/lib/articleMutations'
import { publishArticleUpdate, deleteArticleEvent, getStoredContent } from '@/lib/articleMutations'
import { nostrService } from '@/lib/nostr'
import type { Article } from '@/types/nostr'
interface EditState {
draft: ArticleDraft | null
articleId: string | null
}
export function useArticleEditing(authorPubkey: string | null) {
const [state, setState] = useState<EditState>({ draft: null, articleId: null })
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const updateDraft = (draft: ArticleDraft | null) => {
setState((prev) => ({ ...prev, draft }))
}
const startEditing = async (article: Article) => {
if (!authorPubkey) {
setError('Connect your Nostr wallet to edit')
return
}
setLoading(true)
setError(null)
try {
const stored = await getStoredContent(article.id)
if (!stored) {
setError('Private content not available locally. Please republish from original device.')
setLoading(false)
return
}
setState({
articleId: article.id,
draft: {
title: article.title,
preview: article.preview,
content: stored.content,
zapAmount: article.zapAmount,
...(article.category === 'science-fiction' || article.category === 'scientific-research'
? { category: article.category }
: {}),
},
})
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to load draft')
} finally {
setLoading(false)
}
}
const cancelEditing = () => {
setState({ draft: null, articleId: null })
setError(null)
}
const submitEdit = async (): Promise<ArticleUpdateResult | null> => {
if (!authorPubkey || !state.articleId || !state.draft) {
setError('Missing data for update')
return null
}
setLoading(true)
setError(null)
try {
const privateKey = nostrService.getPrivateKey() ?? undefined
const result = await publishArticleUpdate(state.articleId, state.draft, authorPubkey, privateKey)
if (!result.success) {
setError(result.error ?? 'Update failed')
return null
}
return result
} catch (e) {
setError(e instanceof Error ? e.message : 'Update failed')
return null
} finally {
setLoading(false)
setState({ draft: null, articleId: null })
}
}
const deleteArticle = async (articleId: string): Promise<boolean> => {
if (!authorPubkey) {
setError('Connect your Nostr wallet to delete')
return false
}
setLoading(true)
setError(null)
try {
const privateKey = nostrService.getPrivateKey() ?? undefined
await deleteArticleEvent(articleId, authorPubkey, privateKey)
return true
} catch (e) {
setError(e instanceof Error ? e.message : 'Delete failed')
return false
} finally {
setLoading(false)
}
}
return {
editingDraft: state.draft,
editingArticleId: state.articleId,
loading,
error,
startEditing,
cancelEditing,
submitEdit,
deleteArticle,
updateDraft,
}
}

View File

@ -1,4 +1,4 @@
import { useState, useEffect, useMemo } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { nostrService } from '@/lib/nostr'
import type { Article } from '@/types/nostr'
import { applyFiltersAndSort } from '@/lib/articleFiltering'
@ -8,45 +8,36 @@ export function useArticles(searchQuery: string = '', filters: ArticleFilters |
const [articles, setArticles] = useState<Article[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const hasArticlesRef = useRef(false)
useEffect(() => {
setLoading(true)
setError(null)
let unsubscribe: (() => void) | null = null
nostrService.subscribeToArticles(
const unsubscribe = nostrService.subscribeToArticles(
(article) => {
setArticles((prev) => {
// Avoid duplicates
if (prev.some((a) => a.id === article.id)) {
return prev
}
return [article, ...prev].sort((a, b) => b.createdAt - a.createdAt)
const next = [article, ...prev].sort((a, b) => b.createdAt - a.createdAt)
hasArticlesRef.current = next.length > 0
return next
})
setLoading(false)
},
50
).then((unsub) => {
unsubscribe = unsub
}).catch((e) => {
console.error('Error subscribing to articles:', e)
setError('Failed to load articles')
setLoading(false)
})
)
// Timeout after 10 seconds
const timeout = setTimeout(() => {
setLoading(false)
if (articles.length === 0) {
if (!hasArticlesRef.current) {
setError('No articles found')
}
}, 10000)
return () => {
if (unsubscribe) {
unsubscribe()
}
unsubscribe()
clearTimeout(timeout)
}
}, [])
@ -77,19 +68,21 @@ export function useArticles(searchQuery: string = '', filters: ArticleFilters |
// Apply filters and sorting
const filteredArticles = useMemo(() => {
if (!filters) {
// If no filters, just apply search
if (!searchQuery.trim()) {
return articles
}
return applyFiltersAndSort(articles, searchQuery, {
const effectiveFilters =
filters ??
({
authorPubkey: null,
minPrice: null,
maxPrice: null,
sortBy: 'newest',
})
category: 'all',
} as const)
if (!filters && !searchQuery.trim()) {
return articles
}
return applyFiltersAndSort(articles, searchQuery, filters)
return applyFiltersAndSort(articles, searchQuery, effectiveFilters)
}, [articles, searchQuery, filters])
return {

View File

@ -1,4 +1,4 @@
import { useState, useEffect, useMemo } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { nostrService } from '@/lib/nostr'
import type { Article } from '@/types/nostr'
import { applyFiltersAndSort } from '@/lib/articleFiltering'
@ -15,6 +15,7 @@ export function useUserArticles(
const [articles, setArticles] = useState<Article[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const hasArticlesRef = useRef(false)
useEffect(() => {
if (!userPubkey) {
@ -25,34 +26,22 @@ export function useUserArticles(
setLoading(true)
setError(null)
let unsubscribe: (() => void) | null = null
// Subscribe to articles from this specific author
nostrService
.subscribeToArticles(
(article) => {
// Only include articles from this user
if (article.pubkey === userPubkey) {
setArticles((prev) => {
// Avoid duplicates
if (prev.some((a) => a.id === article.id)) {
return prev
}
return [article, ...prev].sort((a, b) => b.createdAt - a.createdAt)
})
setLoading(false)
}
},
100
)
.then((unsub) => {
unsubscribe = unsub
})
.catch((e) => {
console.error('Error subscribing to user articles:', e)
setError('Failed to load articles')
setLoading(false)
})
const unsubscribe = nostrService.subscribeToArticles(
(article) => {
if (article.pubkey === userPubkey) {
setArticles((prev) => {
if (prev.some((a) => a.id === article.id)) {
return prev
}
const next = [article, ...prev].sort((a, b) => b.createdAt - a.createdAt)
hasArticlesRef.current = next.length > 0
return next
})
setLoading(false)
}
},
100
)
// Timeout after 10 seconds
const timeout = setTimeout(() => {
@ -60,28 +49,28 @@ export function useUserArticles(
}, 10000)
return () => {
if (unsubscribe) {
unsubscribe()
}
unsubscribe()
clearTimeout(timeout)
}
}, [userPubkey])
// Apply filters and sorting
const filteredArticles = useMemo(() => {
if (!filters) {
// If no filters, just apply search
if (!searchQuery.trim()) {
return articles
}
return applyFiltersAndSort(articles, searchQuery, {
const effectiveFilters =
filters ??
({
authorPubkey: null,
minPrice: null,
maxPrice: null,
sortBy: 'newest',
})
category: 'all',
} as const)
if (!filters && !searchQuery.trim()) {
return articles
}
return applyFiltersAndSort(articles, searchQuery, filters)
return applyFiltersAndSort(articles, searchQuery, effectiveFilters)
}, [articles, searchQuery, filters])
const loadArticleContent = async (articleId: string, authorPubkey: string) => {

View File

@ -25,31 +25,15 @@ export async function createArticleInvoice(draft: ArticleDraft): Promise<AlbyInv
export function createPreviewEvent(
draft: ArticleDraft,
invoice: AlbyInvoice,
authorPresentationId?: string
authorPresentationId?: string,
extraTags: string[][] = []
): {
kind: 1
created_at: number
tags: string[][]
content: string
} {
const tags: string[][] = [
['title', draft.title],
['preview', draft.preview],
['zap', draft.zapAmount.toString()],
['content-type', 'article'],
['invoice', invoice.invoice],
['payment_hash', invoice.paymentHash],
]
// Add category if specified
if (draft.category) {
tags.push(['category', draft.category])
}
// Add author presentation ID if provided
if (authorPresentationId) {
tags.push(['author_presentation_id', authorPresentationId])
}
const tags = buildPreviewTags(draft, invoice, authorPresentationId, extraTags)
return {
kind: 1 as const,
@ -58,3 +42,30 @@ export function createPreviewEvent(
content: draft.preview,
}
}
function buildPreviewTags(
draft: ArticleDraft,
invoice: AlbyInvoice,
authorPresentationId?: string,
extraTags: 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
if (extraTags.length > 0) {
base.push(...extraTags)
}
return base
}

226
lib/articleMutations.ts Normal file
View File

@ -0,0 +1,226 @@
import { nostrService } from './nostr'
import { createArticleInvoice, createPreviewEvent } from './articleInvoice'
import { storePrivateContent, getStoredPrivateContent } from './articleStorage'
import { buildReviewTags, buildSeriesTags } from './nostrTags'
import type { ArticleDraft, PublishedArticle } from './articlePublisher'
import type { AlbyInvoice } from '@/types/alby'
import type { Review, Series } from '@/types/nostr'
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) {
nostrService.setPrivateKey(authorPrivateKey)
} else if (!nostrService.getPrivateKey()) {
throw new Error('Private key required for signing. Connect a wallet that can sign.')
}
}
function requireCategory(category?: ArticleDraft['category']): asserts category is NonNullable<ArticleDraft['category']> {
if (category !== 'science-fiction' && category !== 'scientific-research') {
throw new Error('Vous devez sélectionner une catégorie (science-fiction ou recherche scientifique).')
}
}
async function ensurePresentation(authorPubkey: string): Promise<string> {
const presentation = await articlePublisher.getAuthorPresentation(authorPubkey)
if (!presentation) {
throw new Error('Vous devez créer un article de présentation avant de publier des articles.')
}
return presentation.id
}
async function publishPreviewWithInvoice(
draft: ArticleDraft,
invoice: AlbyInvoice,
presentationId: string,
extraTags?: string[][]
): Promise<import('nostr-tools').Event | null> {
const previewEvent = createPreviewEvent(draft, invoice, presentationId, extraTags)
const publishedEvent = await nostrService.publishEvent(previewEvent)
return publishedEvent ?? null
}
export async function publishSeries(params: {
title: string
description: string
preview?: string
coverUrl?: string
category: ArticleDraft['category']
authorPubkey: string
authorPrivateKey?: string
}): Promise<Series> {
ensureKeys(params.authorPubkey, params.authorPrivateKey)
const category = params.category
requireCategory(category)
const event = buildSeriesEvent(params, category)
const published = await nostrService.publishEvent(event)
if (!published) {
throw new Error('Failed to publish series')
}
return {
id: published.id,
pubkey: params.authorPubkey,
title: params.title,
description: params.description,
preview: params.preview ?? params.description.substring(0, 200),
category,
...(params.coverUrl ? { coverUrl: params.coverUrl } : {}),
kindType: 'series',
}
}
function buildSeriesEvent(
params: {
title: string
description: string
preview?: string
coverUrl?: string
authorPubkey: string
},
category: NonNullable<ArticleDraft['category']>
) {
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',
title: params.title,
description: params.description,
...(params.preview ? { preview: params.preview } : { preview: params.description.substring(0, 200) }),
...(params.coverUrl ? { coverUrl: params.coverUrl } : {}),
kindType: 'series',
}),
}
}
export async function publishReview(params: {
articleId: string
seriesId: string
category: ArticleDraft['category']
authorPubkey: string
reviewerPubkey: string
content: string
title?: string
authorPrivateKey?: string
}): Promise<Review> {
ensureKeys(params.reviewerPubkey, params.authorPrivateKey)
const category = params.category
requireCategory(category)
const event = buildReviewEvent(params, category)
const published = await nostrService.publishEvent(event)
if (!published) {
throw new Error('Failed to publish review')
}
return {
id: published.id,
articleId: params.articleId,
authorPubkey: params.authorPubkey,
reviewerPubkey: params.reviewerPubkey,
content: params.content,
createdAt: published.created_at,
...(params.title ? { title: params.title } : {}),
kindType: 'review',
}
}
function buildReviewEvent(
params: {
articleId: string
seriesId: string
authorPubkey: string
reviewerPubkey: string
content: string
title?: string
},
category: NonNullable<ArticleDraft['category']>
) {
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,
articleId: params.articleId,
reviewer: params.reviewerPubkey,
...(params.title ? { title: params.title } : {}),
kindType: 'review',
}),
}
}
export async function publishArticleUpdate(
originalArticleId: string,
draft: ArticleDraft,
authorPubkey: string,
authorPrivateKey?: string
): Promise<ArticleUpdateResult> {
try {
ensureKeys(authorPubkey, authorPrivateKey)
const category = draft.category
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]] : []),
])
if (!publishedEvent) {
return updateFailure(originalArticleId, 'Failed to publish article update')
}
await storePrivateContent(publishedEvent.id, draft.content, authorPubkey, invoice)
return {
articleId: publishedEvent.id,
previewEventId: publishedEvent.id,
invoice,
success: true,
originalArticleId,
}
} catch (error) {
return updateFailure(originalArticleId, error instanceof Error ? error.message : 'Unknown error')
}
}
function updateFailure(originalArticleId: string, error?: string): ArticleUpdateResult {
return {
articleId: '',
previewEventId: '',
success: false,
originalArticleId,
...(error ? { error } : {}),
}
}
export async function deleteArticleEvent(articleId: string, authorPubkey: string, authorPrivateKey?: string): Promise<void> {
ensureKeys(authorPubkey, authorPrivateKey)
const deleteEvent = {
kind: 5,
created_at: Math.floor(Date.now() / 1000),
tags: [['e', articleId]] as string[][],
content: 'deleted',
} as const
const published = await nostrService.publishEvent(deleteEvent)
if (!published) {
throw new Error('Failed to publish delete event')
}
}
// Re-export for convenience to avoid circular imports in hooks
import { articlePublisher } from './articlePublisher'
export const getStoredContent = getStoredPrivateContent

View File

@ -1,6 +1,7 @@
import { nostrService } from './nostr'
import type { AlbyInvoice } from '@/types/alby'
import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
import type { MediaRef } from '@/types/nostr'
import {
storePrivateContent,
getStoredPrivateContent,
@ -16,6 +17,9 @@ export interface ArticleDraft {
content: string // Full content that will be sent as private message after payment
zapAmount: number
category?: 'science-fiction' | 'scientific-research'
seriesId?: string
bannerUrl?: string
media?: MediaRef[]
}
export interface AuthorPresentationDraft {
@ -40,13 +44,15 @@ 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'
private buildFailure(error?: string): PublishedArticle {
return {
const base: PublishedArticle = {
articleId: '',
previewEventId: '',
success: false,
error,
}
return error ? { ...base, error } : base
}
private prepareAuthorKeys(authorPubkey: string, authorPrivateKey?: string): { success: boolean; error?: string } {
@ -69,20 +75,41 @@ export class ArticlePublisher {
return { success: true }
}
private isValidCategory(category?: ArticleDraft['category']): category is ArticleDraft['category'] {
private isValidCategory(category?: ArticleDraft['category']): category is NonNullable<ArticleDraft['category']> {
return category === 'science-fiction' || category === 'scientific-research'
}
private async publishPreview(
draft: ArticleDraft,
invoice: AlbyInvoice,
presentationId: string
presentationId: string,
extraTags?: string[][]
): Promise<import('nostr-tools').Event | null> {
const previewEvent = createPreviewEvent(draft, invoice, presentationId)
const previewEvent = createPreviewEvent(draft, invoice, presentationId, extraTags)
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])
}
if (draft.media && draft.media.length > 0) {
draft.media.forEach((m) => {
extraTags.push(['media', m.url, m.type])
})
}
return extraTags
}
/**
* Publish an article preview as a public note (kind:1)
* Creates a Lightning invoice for the article
@ -107,9 +134,11 @@ export class ArticlePublisher {
if (!this.isValidCategory(draft.category)) {
return this.buildFailure('Vous devez sélectionner une catégorie (science-fiction ou recherche scientifique).')
}
const category = draft.category
const invoice = await createArticleInvoice(draft)
const publishedEvent = await this.publishPreview(draft, invoice, presentation.id)
const extraTags = this.buildArticleExtraTags(draft, category)
const publishedEvent = await this.publishPreview(draft, invoice, presentation.id, extraTags)
if (!publishedEvent) {
return this.buildFailure('Failed to publish article')
}
@ -123,6 +152,9 @@ export class ArticlePublisher {
}
}
/**
* Update an existing article by publishing a new event that references the original
*/
/**
* Get stored private content for an article
*/
@ -147,7 +179,6 @@ export class ArticlePublisher {
async sendPrivateContent(
articleId: string,
recipientPubkey: string,
authorPubkey: string,
authorPrivateKey: string
): Promise<boolean> {
try {
@ -202,16 +233,13 @@ export class ArticlePublisher {
}
}
/**
* Get author presentation article by pubkey
*/
getAuthorPresentation(pubkey: string): Promise<import('@/types/nostr').AuthorPresentationArticle | null> {
async getAuthorPresentation(pubkey: string): Promise<import('@/types/nostr').AuthorPresentationArticle | null> {
try {
const pool = nostrService.getPool()
if (!pool) {
return null
}
return fetchAuthorPresentationFromPool(pool as SimplePoolWithSub, pubkey)
return await fetchAuthorPresentationFromPool(pool as SimplePoolWithSub, pubkey)
} catch (error) {
console.error('Error getting author presentation:', error)
return null

47
lib/articleQueries.ts Normal file
View File

@ -0,0 +1,47 @@
import type { Event } from 'nostr-tools'
import { nostrService } from './nostr'
import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
import type { Article } from '@/types/nostr'
import { parseArticleFromEvent } from './nostrEventParsing'
const RELAY_URL = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL ?? 'wss://relay.damus.io'
export function getArticlesBySeries(seriesId: string, timeoutMs: number = 5000, limit: number = 100): Promise<Article[]> {
const pool = nostrService.getPool()
if (!pool) {
throw new Error('Pool not initialized')
}
const poolWithSub = pool as SimplePoolWithSub
const filters = [
{
kinds: [1],
'#series': [seriesId],
limit,
},
]
return new Promise<Article[]>((resolve) => {
const results: Article[] = []
const sub = poolWithSub.sub([RELAY_URL], filters)
let finished = false
const done = () => {
if (finished) {
return
}
finished = true
sub.unsub()
resolve(results)
}
sub.on('event', (event: Event) => {
const parsed = parseArticleFromEvent(event)
if (parsed) {
results.push(parsed)
}
})
sub.on('eose', () => done())
setTimeout(() => done(), timeoutMs).unref?.()
})
}

View File

@ -16,6 +16,30 @@ interface StoredArticleData {
// Default expiration: 30 days in milliseconds
const DEFAULT_EXPIRATION = 30 * 24 * 60 * 60 * 1000
const MASTER_KEY_STORAGE_KEY = 'article_storage_master_key'
function getOrCreateMasterKey(): string {
if (typeof window === 'undefined') {
throw new Error('Storage encryption requires browser environment')
}
const existing = localStorage.getItem(MASTER_KEY_STORAGE_KEY)
if (existing) {
return existing
}
const keyBytes = crypto.getRandomValues(new Uint8Array(32))
let binary = ''
keyBytes.forEach((b) => {
binary += String.fromCharCode(b)
})
const key = btoa(binary)
localStorage.setItem(MASTER_KEY_STORAGE_KEY, key)
return key
}
function deriveSecret(articleId: string): string {
const masterKey = getOrCreateMasterKey()
return `${masterKey}:${articleId}`
}
/**
* Store private content temporarily until payment is confirmed
@ -31,6 +55,7 @@ export async function storePrivateContent(
): Promise<void> {
try {
const key = `article_private_content_${articleId}`
const secret = deriveSecret(articleId)
const data: StoredArticleData = {
content,
authorPubkey,
@ -47,7 +72,7 @@ export async function storePrivateContent(
}
// Store with expiration (30 days)
await storageService.set(key, data, DEFAULT_EXPIRATION)
await storageService.set(key, data, secret, DEFAULT_EXPIRATION)
} catch (error) {
console.error('Error storing private content:', error)
}
@ -64,7 +89,8 @@ export async function getStoredPrivateContent(articleId: string): Promise<{
} | null> {
try {
const key = `article_private_content_${articleId}`
const data = await storageService.get<StoredArticleData>(key)
const secret = deriveSecret(articleId)
const data = await storageService.get<StoredArticleData>(key, secret)
if (!data) {
return null
@ -73,14 +99,16 @@ export async function getStoredPrivateContent(articleId: string): Promise<{
return {
content: data.content,
authorPubkey: data.authorPubkey,
invoice: data.invoice
...(data.invoice
? {
invoice: data.invoice.invoice,
paymentHash: data.invoice.paymentHash,
amount: data.invoice.amount,
expiresAt: data.invoice.expiresAt,
invoice: {
invoice: data.invoice.invoice,
paymentHash: data.invoice.paymentHash,
amount: data.invoice.amount,
expiresAt: data.invoice.expiresAt,
} as AlbyInvoice,
}
: undefined,
: {}),
}
} catch (error) {
console.error('Error retrieving private content:', error)

View File

@ -102,13 +102,16 @@ function renderParagraphOrBreak(line: string, index: number, elements: JSX.Eleme
elements.push(<p key={index} className="mb-4 text-gray-700">{line}</p>)
return
}
if (elements.length > 0 && elements[elements.length - 1].type !== 'br') {
elements.push(<br key={`br-${index}`} />)
if (elements.length > 0) {
const last = elements[elements.length - 1] as { type?: unknown }
if (last?.type !== 'br') {
elements.push(<br key={`br-${index}`} />)
}
}
}
function handleCodeBlock(
line: string,
_line: string,
index: number,
state: RenderState,
elements: JSX.Element[]
@ -172,12 +175,17 @@ function renderLink(line: string, index: number, elements: JSX.Element[]): void
let match
while ((match = linkRegex.exec(line)) !== null) {
if (match.index > lastIndex) {
if (!match[2]) {
continue
}
if (!match[1] || match.index > lastIndex) {
parts.push(line.substring(lastIndex, match.index))
}
const href = match[2]
const isExternal = href.startsWith('http')
parts.push(createLinkElement(match[1], href, `link-${index}-${match.index}`, isExternal))
if (match[1]) {
parts.push(createLinkElement(match[1], href, `link-${index}-${match.index}`, isExternal))
}
lastIndex = match.index + match[0].length
}

63
lib/nip95.ts Normal file
View File

@ -0,0 +1,63 @@
import type { MediaRef } from '@/types/nostr'
const MAX_IMAGE_BYTES = 5 * 1024 * 1024
const MAX_VIDEO_BYTES = 45 * 1024 * 1024
const IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/jpg', 'image/webp']
const VIDEO_TYPES = ['video/mp4', 'video/webm', 'video/quicktime']
function assertBrowser(): void {
if (typeof window === 'undefined') {
throw new Error('NIP-95 upload is only available in the browser')
}
}
function validateFile(file: File): MediaRef['type'] {
if (IMAGE_TYPES.includes(file.type)) {
if (file.size > MAX_IMAGE_BYTES) {
throw new Error('Image exceeds 5MB limit')
}
return 'image'
}
if (VIDEO_TYPES.includes(file.type)) {
if (file.size > MAX_VIDEO_BYTES) {
throw new Error('Video exceeds 45MB limit')
}
return 'video'
}
throw new Error('Unsupported media type')
}
/**
* Upload media via NIP-95.
* This implementation validates size/type then delegates to a pluggable uploader.
* The actual upload endpoint must be provided via env/config; otherwise an error is thrown.
*/
export async function uploadNip95Media(file: File): Promise<MediaRef> {
assertBrowser()
const mediaType = validateFile(file)
const endpoint = process.env.NEXT_PUBLIC_NIP95_UPLOAD_URL
if (!endpoint) {
throw new Error('NIP-95 upload endpoint is not configured')
}
const formData = new FormData()
formData.append('file', file)
const response = await fetch(endpoint, {
method: 'POST',
body: formData,
})
if (!response.ok) {
const message = await response.text().catch(() => 'Upload failed')
throw new Error(message || 'Upload failed')
}
const result = (await response.json()) as { url?: string }
if (!result.url) {
throw new Error('Upload response missing URL')
}
return { url: result.url, type: mediaType }
}

View File

@ -60,10 +60,16 @@ class NostrService {
throw new Error('Private key not set or pool not initialized')
}
const event = {
const unsignedEvent = {
pubkey: this.publicKey ?? '',
...eventTemplate,
id: getEventHash(eventTemplate),
sig: signEvent(eventTemplate, this.privateKey),
created_at: eventTemplate.created_at ?? Math.floor(Date.now() / 1000),
}
const event = {
...unsignedEvent,
id: getEventHash(unsignedEvent),
sig: signEvent(unsignedEvent, this.privateKey),
} as Event
try {
@ -190,7 +196,7 @@ class NostrService {
userPubkey?: string
): Promise<boolean> {
if (!this.publicKey || !this.pool) {
return false
return Promise.resolve(false)
}
// Use provided userPubkey or fall back to current public key

View File

@ -1,5 +1,5 @@
import type { Event } from 'nostr-tools'
import type { Article } from '@/types/nostr'
import type { Article, KindType, MediaRef, Review, Series } from '@/types/nostr'
/**
* Parse article metadata from Nostr event
@ -7,6 +7,9 @@ import type { Article } from '@/types/nostr'
export function parseArticleFromEvent(event: Event): Article | null {
try {
const tags = extractTags(event)
if (tags.kindType && tags.kindType !== 'article') {
return null
}
const { previewContent } = getPreviewContent(event.content, tags.preview)
return buildArticle(event, tags, previewContent)
} catch (e) {
@ -15,11 +18,83 @@ 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') {
return null
}
if (!tags.title || !tags.description) {
return null
}
const series: Series = {
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
}
return series
} catch (e) {
console.error('Error parsing series:', e)
return null
}
}
export function parseReviewFromEvent(event: Event): Review | null {
try {
const tags = extractTags(event)
if (tags.kindType && tags.kindType !== 'review') {
return null
}
const articleId = tags.articleId
const reviewer = tags.reviewerPubkey
if (!articleId || !reviewer) {
return null
}
const review: Review = {
id: event.id,
articleId,
authorPubkey: tags.author ?? event.pubkey,
reviewerPubkey: reviewer,
content: event.content,
createdAt: event.created_at,
...(tags.title ? { title: tags.title } : {}),
}
if (tags.kindType) {
review.kindType = tags.kindType
}
return review
} catch (e) {
console.error('Error parsing review:', e)
return 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')
const media: MediaRef[] =
mediaTags
.map((tag) => {
const url = tag[1]
const type = tag[2] === 'video' ? 'video' : 'image'
if (!url) {
return null
}
return { url, type }
})
.filter(Boolean) as MediaRef[]
return {
title: findTag('title') ?? 'Untitled',
preview: findTag('preview'),
description: findTag('description'),
zapAmount: parseInt(findTag('zap') ?? '800', 10),
invoice: findTag('invoice'),
paymentHash: findTag('payment_hash'),
@ -28,6 +103,14 @@ function extractTags(event: Event) {
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'),
media,
kindType: findTag('kind_type') as KindType | undefined,
articleId: findTag('article'),
reviewerPubkey: findTag('reviewer'),
author: findTag('author'),
}
}
@ -47,12 +130,16 @@ function buildArticle(event: Event, tags: ReturnType<typeof extractTags>, previe
createdAt: event.created_at,
zapAmount: tags.zapAmount,
paid: false,
invoice: tags.invoice,
paymentHash: tags.paymentHash,
category: tags.category,
isPresentation: tags.isPresentation,
mainnetAddress: tags.mainnetAddress,
totalSponsoring: tags.totalSponsoring,
authorPresentationId: tags.authorPresentationId,
...(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 } : {}),
}
}

View File

@ -14,7 +14,17 @@ export class NostrRemoteSigner {
*/
signEvent(eventTemplate: EventTemplate): Event | null {
// Get the event hash first
const eventId = getEventHash(eventTemplate)
const pubkey = nostrService.getPublicKey()
if (!pubkey) {
throw new Error('Public key required for signing. Please connect a Nostr wallet.')
}
const unsignedEvent = {
pubkey,
...eventTemplate,
created_at: eventTemplate.created_at ?? Math.floor(Date.now() / 1000),
}
const eventId = getEventHash(unsignedEvent)
// Try to get private key from nostrService (if available from NostrConnect)
const privateKey = nostrService.getPrivateKey()
@ -28,9 +38,9 @@ export class NostrRemoteSigner {
}
const event = {
...eventTemplate,
...unsignedEvent,
id: eventId,
sig: signEvent(eventTemplate, privateKey),
sig: signEvent(unsignedEvent, privateKey),
} as Event
return event

102
lib/nostrTags.ts Normal file
View File

@ -0,0 +1,102 @@
import type { ArticleCategory, KindType, MediaRef } from '@/types/nostr'
export interface SeriesTags {
site?: string
category: ArticleCategory
author: string
seriesId: string
title: string
description: string
preview?: string
coverUrl?: string
kindType: KindType
}
export interface ArticleTags {
site?: string
category: ArticleCategory
author: string
seriesId?: string
articleId: string
title: string
preview: string
bannerUrl?: string
media?: MediaRef[]
kindType: KindType
}
export interface ReviewTags {
site?: string
category: ArticleCategory
author: string
seriesId: string
articleId: string
reviewer: string
title?: string
kindType: KindType
}
export function buildSeriesTags(tags: SeriesTags): string[][] {
const result: string[][] = [
['kind_type', tags.kindType],
['category', tags.category],
['author', tags.author],
['series', tags.seriesId],
['title', tags.title],
['description', tags.description],
]
if (tags.site) {
result.push(['site', tags.site])
}
if (tags.preview) {
result.push(['preview', tags.preview])
}
if (tags.coverUrl) {
result.push(['cover', tags.coverUrl])
}
return result
}
export function buildArticleTags(tags: ArticleTags): string[][] {
const result: string[][] = [
['kind_type', tags.kindType],
['category', tags.category],
['author', tags.author],
['article', tags.articleId],
['title', tags.title],
['preview', tags.preview],
]
if (tags.site) {
result.push(['site', tags.site])
}
if (tags.seriesId) {
result.push(['series', tags.seriesId])
}
if (tags.bannerUrl) {
result.push(['banner', tags.bannerUrl])
}
if (tags.media && tags.media.length > 0) {
tags.media.forEach((m) => {
result.push(['media', m.url, m.type])
})
}
return result
}
export function buildReviewTags(tags: ReviewTags): string[][] {
const result: string[][] = [
['kind_type', tags.kindType],
['category', tags.category],
['author', tags.author],
['series', tags.seriesId],
['article', tags.articleId],
['reviewer', tags.reviewer],
]
if (tags.site) {
result.push(['site', tags.site])
}
if (tags.title) {
result.push(['title', tags.title])
}
return result
}

View File

@ -38,7 +38,7 @@ export function checkZapReceipt(
userPubkey: string
): Promise<boolean> {
if (!pool) {
return false
return Promise.resolve(false)
}
return new Promise((resolve) => {

View File

@ -49,7 +49,7 @@ export class NostrConnectService {
}
private cleanupPopup(popup: Window | null, checkClosed: number, messageHandler: (event: MessageEvent) => void) {
clearInterval(checkClosed)
window.clearInterval(checkClosed)
window.removeEventListener('message', messageHandler)
if (popup && !popup.closed) {
popup.close()
@ -100,8 +100,7 @@ export class NostrConnectService {
return
}
const messageHandler: (event: MessageEvent) => void
const checkClosed = setInterval(() => {
const checkClosed = window.setInterval(() => {
if (popup.closed) {
this.cleanupPopup(popup, checkClosed, messageHandler)
if (!this.state.connected) {
@ -110,17 +109,14 @@ export class NostrConnectService {
}
}, 1000)
const cleanup = () => {
this.cleanupPopup(popup, checkClosed, messageHandler)
}
messageHandler = this.createMessageHandler(resolve, reject, cleanup)
const cleanup = () => this.cleanupPopup(popup, checkClosed, messageHandler)
const messageHandler = this.createMessageHandler(resolve, reject, cleanup)
window.addEventListener('message', messageHandler)
})
}
disconnect(): Promise<void> {
disconnect(): void {
this.state = {
connected: false,
pubkey: null,

View File

@ -41,8 +41,8 @@ async function buildPaymentNotification(event: Event, userPubkey: string): Promi
: `You received ${paymentInfo.amount} sats`,
timestamp: event.created_at,
read: false,
articleId: paymentInfo.articleId ?? undefined,
articleTitle,
...(paymentInfo.articleId ? { articleId: paymentInfo.articleId } : {}),
...(articleTitle ? { articleTitle } : {}),
amount: paymentInfo.amount,
fromPubkey: paymentInfo.payer,
}

View File

@ -53,7 +53,7 @@ export class PaymentService {
* Check if payment for an article has been completed
*/
async checkArticlePayment(
paymentHash: string,
_paymentHash: string,
articleId: string,
articlePubkey: string,
amount: number,

View File

@ -69,12 +69,7 @@ async function sendPrivateContentAfterPayment(
const authorPrivateKey = nostrService.getPrivateKey()
if (authorPrivateKey) {
const sent = await articlePublisher.sendPrivateContent(
articleId,
recipientPubkey,
storedContent.authorPubkey,
authorPrivateKey
)
const sent = await articlePublisher.sendPrivateContent(articleId, recipientPubkey, authorPrivateKey)
if (sent) {
// Private content sent successfully

12
lib/reviewAggregation.ts Normal file
View File

@ -0,0 +1,12 @@
import { aggregateZapSats } from './zapAggregation'
export function getReviewTipsForArticle(params: {
authorPubkey: string
articleId: string
}): Promise<number> {
return aggregateZapSats({
authorPubkey: params.authorPubkey,
articleId: params.articleId,
kindType: 'review_tip',
})
}

47
lib/reviews.ts Normal file
View File

@ -0,0 +1,47 @@
import type { Event } from 'nostr-tools'
import { nostrService } from './nostr'
import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
import type { Review } from '@/types/nostr'
import { parseReviewFromEvent } from './nostrEventParsing'
const RELAY_URL = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL ?? 'wss://relay.damus.io'
export function getReviewsForArticle(articleId: string, timeoutMs: number = 5000): Promise<Review[]> {
const pool = nostrService.getPool()
if (!pool) {
throw new Error('Pool not initialized')
}
const poolWithSub = pool as SimplePoolWithSub
const filters = [
{
kinds: [1],
'#article': [articleId],
'#kind_type': ['review'],
},
]
return new Promise<Review[]>((resolve) => {
const results: Review[] = []
const sub = poolWithSub.sub([RELAY_URL], filters)
let finished = false
const done = () => {
if (finished) {
return
}
finished = true
sub.unsub()
resolve(results)
}
sub.on('event', (event: Event) => {
const parsed = parseReviewFromEvent(event)
if (parsed) {
results.push(parsed)
}
})
sub.on('eose', () => done())
setTimeout(() => done(), timeoutMs).unref?.()
})
}

20
lib/seriesAggregation.ts Normal file
View File

@ -0,0 +1,20 @@
import { aggregateZapSats } from './zapAggregation'
export interface SeriesAggregates {
sponsoring: number
purchases: number
reviewTips: number
}
export async function getSeriesAggregates(params: {
authorPubkey: string
seriesId: string
}): Promise<SeriesAggregates> {
const [sponsoring, purchases, reviewTips] = await Promise.all([
aggregateZapSats({ authorPubkey: params.authorPubkey, seriesId: params.seriesId, kindType: 'sponsoring' }),
aggregateZapSats({ authorPubkey: params.authorPubkey, seriesId: params.seriesId, kindType: 'purchase' }),
aggregateZapSats({ authorPubkey: params.authorPubkey, seriesId: params.seriesId, kindType: 'review_tip' }),
])
return { sponsoring, purchases, reviewTips }
}

86
lib/seriesQueries.ts Normal file
View File

@ -0,0 +1,86 @@
import type { Event } from 'nostr-tools'
import { nostrService } from './nostr'
import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
import type { Series } from '@/types/nostr'
import { parseSeriesFromEvent } from './nostrEventParsing'
const RELAY_URL = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL ?? 'wss://relay.damus.io'
export function getSeriesByAuthor(authorPubkey: string, timeoutMs: number = 5000): Promise<Series[]> {
const pool = nostrService.getPool()
if (!pool) {
throw new Error('Pool not initialized')
}
const poolWithSub = pool as SimplePoolWithSub
const filters = [
{
kinds: [1],
authors: [authorPubkey],
'#kind_type': ['series'],
},
]
return new Promise<Series[]>((resolve) => {
const results: Series[] = []
const sub = poolWithSub.sub([RELAY_URL], filters)
let finished = false
const done = () => {
if (finished) {
return
}
finished = true
sub.unsub()
resolve(results)
}
sub.on('event', (event: Event) => {
const parsed = parseSeriesFromEvent(event)
if (parsed) {
results.push(parsed)
}
})
sub.on('eose', () => done())
setTimeout(() => done(), timeoutMs).unref?.()
})
}
export function getSeriesById(seriesId: string, timeoutMs: number = 5000): Promise<Series | null> {
const pool = nostrService.getPool()
if (!pool) {
throw new Error('Pool not initialized')
}
const poolWithSub = pool as SimplePoolWithSub
const filters = [
{
kinds: [1],
ids: [seriesId],
'#kind_type': ['series'],
},
]
return new Promise<Series | null>((resolve) => {
const sub = poolWithSub.sub([RELAY_URL], filters)
let finished = false
const done = (value: Series | null) => {
if (finished) {
return
}
finished = true
sub.unsub()
resolve(value)
}
sub.on('event', (event: Event) => {
const parsed = parseSeriesFromEvent(event)
if (parsed) {
done(parsed)
}
})
sub.on('eose', () => done(null))
setTimeout(() => done(null), timeoutMs).unref?.()
})
}

View File

@ -0,0 +1,53 @@
const IV_LENGTH = 12
function toBase64(bytes: Uint8Array): string {
let binary = ''
bytes.forEach((b) => {
binary += String.fromCharCode(b)
})
return btoa(binary)
}
function fromBase64(value: string): Uint8Array {
const binary = atob(value)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i)
}
return bytes
}
async function importKey(secret: string): Promise<CryptoKey> {
const encoder = new TextEncoder()
const keyMaterial = encoder.encode(secret)
const hash = await crypto.subtle.digest('SHA-256', keyMaterial)
return crypto.subtle.importKey('raw', hash, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt'])
}
export interface EncryptedPayload {
iv: string
ciphertext: string
}
export async function encryptPayload(secret: string, value: unknown): Promise<EncryptedPayload> {
const key = await importKey(secret)
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH))
const encoder = new TextEncoder()
const encoded = encoder.encode(JSON.stringify(value))
const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, encoded)
return {
iv: toBase64(iv),
ciphertext: toBase64(new Uint8Array(ciphertext)),
}
}
export async function decryptPayload<T>(secret: string, payload: EncryptedPayload): Promise<T> {
const key = await importKey(secret)
const ivBytes = fromBase64(payload.iv)
const cipherBytes = fromBase64(payload.ciphertext)
const ivBuffer = ivBytes.buffer as ArrayBuffer
const cipherBuffer = cipherBytes.buffer as ArrayBuffer
const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: ivBuffer }, key, cipherBuffer)
const decoder = new TextDecoder()
return JSON.parse(decoder.decode(decrypted)) as T
}

View File

@ -1,10 +1,12 @@
import { decryptPayload, encryptPayload, type EncryptedPayload } from './cryptoHelpers'
const DB_NAME = 'nostr_paywall'
const DB_VERSION = 1
const STORE_NAME = 'article_content'
interface DBData {
id: string
data: unknown
data: EncryptedPayload
createdAt: number
expiresAt?: number
}
@ -72,7 +74,7 @@ export class IndexedDBStorage {
/**
* Store data in IndexedDB
*/
async set(key: string, value: unknown, expiresIn?: number): Promise<void> {
async set(key: string, value: unknown, secret: string, expiresIn?: number): Promise<void> {
try {
await this.init()
@ -80,12 +82,13 @@ export class IndexedDBStorage {
throw new Error('Database not initialized')
}
const encrypted = await encryptPayload(secret, value)
const now = Date.now()
const data: DBData = {
id: key,
data: value,
data: encrypted,
createdAt: now,
expiresAt: expiresIn ? now + expiresIn : undefined,
...(expiresIn ? { expiresAt: now + expiresIn } : {}),
}
const db = this.db
@ -110,7 +113,7 @@ export class IndexedDBStorage {
/**
* Get data from IndexedDB
*/
async get<T = unknown>(key: string): Promise<T | null> {
async get<T = unknown>(key: string, secret: string): Promise<T | null> {
try {
await this.init()
@ -118,14 +121,14 @@ export class IndexedDBStorage {
throw new Error('Database not initialized')
}
return this.readValue<T>(key)
return this.readValue<T>(key, secret)
} catch (error) {
console.error('Error getting from IndexedDB:', error)
return null
}
}
private readValue<T>(key: string): Promise<T | null> {
private readValue<T>(key: string, secret: string): Promise<T | null> {
const db = this.db
if (!db) {
throw new Error('Database not initialized')
@ -150,7 +153,12 @@ export class IndexedDBStorage {
return
}
resolve(result.data as T)
decryptPayload<T>(secret, result.data)
.then((value) => resolve(value))
.catch((error) => {
console.error('Error decrypting from IndexedDB:', error)
resolve(null)
})
}
request.onerror = () => reject(new Error(`Failed to get data: ${request.error}`))

92
lib/zapAggregation.ts Normal file
View File

@ -0,0 +1,92 @@
import type { Event } from 'nostr-tools'
import { nostrService } from './nostr'
import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
const DEFAULT_RELAY = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL ?? 'wss://relay.damus.io'
interface ZapAggregationFilter {
authorPubkey: string
seriesId?: string
articleId?: string
kindType: 'sponsoring' | 'purchase' | 'review_tip'
reviewerPubkey?: string
timeoutMs?: number
}
function buildFilters(params: ZapAggregationFilter) {
const filters = [
{
kinds: [9735],
'#p': [params.authorPubkey],
'#kind_type': [params.kindType],
...(params.seriesId ? { '#series': [params.seriesId] } : {}),
...(params.articleId ? { '#article': [params.articleId] } : {}),
...(params.reviewerPubkey ? { '#reviewer': [params.reviewerPubkey] } : {}),
},
]
return filters
}
function millisatsToSats(value: string | undefined): number {
if (!value) {
return 0
}
const parsed = parseInt(value, 10)
if (Number.isNaN(parsed)) {
return 0
}
return Math.floor(parsed / 1000)
}
export function aggregateZapSats(params: ZapAggregationFilter): Promise<number> {
const pool = nostrService.getPool()
if (!pool) {
throw new Error('Nostr pool not initialized')
}
const poolWithSub = pool as SimplePoolWithSub
const filters = buildFilters(params)
const relay = DEFAULT_RELAY
const timeout = params.timeoutMs ?? 5000
const sub = poolWithSub.sub([relay], filters)
return collectZap(sub, timeout)
}
function collectZap(
sub: ReturnType<SimplePoolWithSub['sub']>,
timeout: number
): Promise<number> {
return new Promise<number>((resolve, reject) => {
let total = 0
let finished = false
const done = () => {
if (finished) {
return
}
finished = true
sub.unsub()
resolve(total)
}
const onError = (err: unknown) => {
if (finished) {
return
}
finished = true
sub.unsub()
reject(err instanceof Error ? err : new Error('Unknown zap aggregation error'))
}
sub.on('event', (event: Event) => {
const amountTag = event.tags.find((tag) => tag[0] === 'amount')
total += millisatsToSats(amountTag?.[1])
})
sub.on('eose', () => done())
setTimeout(() => done(), timeout).unref?.()
if (typeof (sub as unknown as { on?: unknown }).on === 'function') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(sub as any).on('error', onError)
}
})
}

View File

@ -1,4 +1,4 @@
import { Event, verifyEvent } from 'nostr-tools'
import { Event, validateEvent, verifySignature } from 'nostr-tools'
/**
* Service for verifying zap receipts and their signatures
@ -9,7 +9,7 @@ export class ZapVerificationService {
*/
verifyZapReceiptSignature(event: Event): boolean {
try {
return verifyEvent(event)
return validateEvent(event) && verifySignature(event)
} catch (error) {
console.error('Error verifying zap receipt signature:', error)
return false
@ -23,7 +23,7 @@ export class ZapVerificationService {
zapReceipt: Event,
articleId: string,
articlePubkey: string,
userPubkey: string,
_userPubkey: string,
expectedAmount: number
): boolean {
if (!this.verifyZapReceiptSignature(zapReceipt)) {
@ -62,7 +62,7 @@ export class ZapVerificationService {
return {
amount: amountInSats,
recipient: recipientTag[1],
recipient: recipientTag?.[1] ?? '',
articleId: eventTag?.[1] ?? null,
payer: zapReceipt.pubkey,
}

59
package-lock.json generated
View File

@ -9,7 +9,7 @@
"version": "1.0.0",
"dependencies": {
"next": "^14.0.4",
"nostr-tools": "^2.3.4",
"nostr-tools": "1.17.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-qr-code": "^2.0.18"
@ -434,33 +434,21 @@
}
},
"node_modules/@noble/ciphers": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.5.3.tgz",
"integrity": "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==",
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.2.0.tgz",
"integrity": "sha512-6YBxJDAapHSdd3bLDv6x2wRPwq4QFMUaB3HvljNBUTThDd12eSm7/3F+2lnfzx2jvM+S6Nsy0jEt9QbPqSwqRw==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/curves": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz",
"integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==",
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.1.0.tgz",
"integrity": "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "1.3.2"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/curves/node_modules/@noble/hashes": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz",
"integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==",
"license": "MIT",
"engines": {
"node": ">= 16"
"@noble/hashes": "1.3.1"
},
"funding": {
"url": "https://paulmillr.com/funding/"
@ -577,18 +565,6 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip32/node_modules/@noble/curves": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.1.0.tgz",
"integrity": "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "1.3.1"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip39": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.2.1.tgz",
@ -4238,18 +4214,17 @@
}
},
"node_modules/nostr-tools": {
"version": "2.19.4",
"resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.19.4.tgz",
"integrity": "sha512-qVLfoTpZegNYRJo5j+Oi6RPu0AwLP6jcvzcB3ySMnIT5DrAGNXfs5HNBspB/2HiGfH3GY+v6yXkTtcKSBQZwSg==",
"version": "1.17.0",
"resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-1.17.0.tgz",
"integrity": "sha512-LZmR8GEWKZeElbFV5Xte75dOeE9EFUW/QLI1Ncn3JKn0kFddDKEfBbFN8Mu4TMs+L4HR/WTPha2l+PPuRnJcMw==",
"license": "Unlicense",
"dependencies": {
"@noble/ciphers": "^0.5.1",
"@noble/curves": "1.2.0",
"@noble/ciphers": "0.2.0",
"@noble/curves": "1.1.0",
"@noble/hashes": "1.3.1",
"@scure/base": "1.1.1",
"@scure/bip32": "1.3.1",
"@scure/bip39": "1.2.1",
"nostr-wasm": "0.1.0"
"@scure/bip39": "1.2.1"
},
"peerDependencies": {
"typescript": ">=5.0.0"
@ -4260,12 +4235,6 @@
}
}
},
"node_modules/nostr-wasm": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz",
"integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==",
"license": "MIT"
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",

View File

@ -11,7 +11,7 @@
},
"dependencies": {
"next": "^14.0.4",
"nostr-tools": "^2.3.4",
"nostr-tools": "1.17.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-qr-code": "^2.0.18"

View File

@ -18,10 +18,6 @@ function useUserProfileData(currentPubkey: string | null) {
const createMinimalProfile = (): NostrProfile => ({
pubkey: currentPubkey,
name: undefined,
about: undefined,
picture: undefined,
nip05: undefined,
})
const load = async () => {
@ -62,6 +58,7 @@ function useProfileController() {
sortBy: 'newest',
category: 'all',
})
const [selectedSeriesId, setSelectedSeriesId] = useState<string | undefined>(undefined)
useRedirectWhenDisconnected(connected, currentPubkey ?? null)
const { profile, loadingProfile } = useUserProfileData(currentPubkey ?? null)
@ -86,6 +83,8 @@ function useProfileController() {
loadArticleContent,
profile,
loadingProfile,
selectedSeriesId,
onSelectSeries: setSelectedSeriesId,
}
}

View File

@ -2,6 +2,9 @@ import Head from 'next/head'
import { useRouter } from 'next/router'
import { ConnectButton } from '@/components/ConnectButton'
import { ArticleEditor } from '@/components/ArticleEditor'
import { useEffect, useState } from 'react'
import { useNostrConnect } from '@/hooks/useNostrConnect'
import { getSeriesByAuthor } from '@/lib/seriesQueries'
function PublishHeader() {
return (
@ -30,6 +33,8 @@ function PublishHero({ onBack }: { onBack: () => void }) {
export default function PublishPage() {
const router = useRouter()
const { pubkey } = useNostrConnect()
const [seriesOptions, setSeriesOptions] = useState<{ id: string; title: string }[]>([])
const handlePublishSuccess = () => {
setTimeout(() => {
@ -37,28 +42,54 @@ export default function PublishPage() {
}, 2000)
}
useEffect(() => {
if (!pubkey) {
setSeriesOptions([])
return
}
const load = async () => {
const items = await getSeriesByAuthor(pubkey)
setSeriesOptions(items.map((s) => ({ id: s.id, title: s.title })))
}
void load()
}, [pubkey])
return (
<>
<PublishHeader />
<main className="min-h-screen bg-gray-50">
<header className="bg-white shadow-sm">
<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">zapwall4Science</h1>
<ConnectButton />
</div>
</header>
<div className="max-w-4xl mx-auto px-4 py-8">
<PublishHero
onBack={() => {
void router.push('/')
}}
/>
<ArticleEditor onPublishSuccess={handlePublishSuccess} />
</div>
</main>
<PublishLayout
onBack={() => {
void router.push('/')
}}
onPublishSuccess={handlePublishSuccess}
seriesOptions={seriesOptions}
/>
</>
)
}
function PublishLayout({
onBack,
onPublishSuccess,
seriesOptions,
}: {
onBack: () => void
onPublishSuccess: () => void
seriesOptions: { id: string; title: string }[]
}) {
return (
<main className="min-h-screen bg-gray-50">
<header className="bg-white shadow-sm">
<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">zapwall4Science</h1>
<ConnectButton />
</div>
</header>
<div className="max-w-4xl mx-auto px-4 py-8">
<PublishHero onBack={onBack} />
<ArticleEditor onPublishSuccess={onPublishSuccess} seriesOptions={seriesOptions} />
</div>
</main>
)
}

126
pages/series/[id].tsx Normal file
View File

@ -0,0 +1,126 @@
import { useRouter } from 'next/router'
import Head from 'next/head'
import { useEffect, useState } from 'react'
import { getSeriesById } from '@/lib/seriesQueries'
import { getSeriesAggregates } from '@/lib/seriesAggregation'
import { getArticlesBySeries } from '@/lib/articleQueries'
import type { Series, Article } from '@/types/nostr'
import { SeriesStats } from '@/components/SeriesStats'
import { ArticleCard } from '@/components/ArticleCard'
import Image from 'next/image'
import { ArticleReviews } from '@/components/ArticleReviews'
function SeriesHeader({ series }: { series: Series }) {
return (
<div className="space-y-3">
{series.coverUrl && (
<div className="relative w-full h-48">
<Image
src={series.coverUrl}
alt={series.title}
fill
sizes="(max-width: 768px) 100vw, 50vw"
className="object-cover rounded"
/>
</div>
)}
<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>
</div>
)
}
export default function SeriesPage() {
const router = useRouter()
const { id } = router.query
const seriesId = typeof id === 'string' ? id : ''
const { series, articles, aggregates, loading, error } = useSeriesPageData(seriesId)
if (!seriesId) {
return null
}
return (
<>
<Head>
<title>Série - zapwall4Science</title>
</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>}
{error && <p className="text-sm text-red-600">{error}</p>}
{series && (
<>
<SeriesHeader series={series} />
<SeriesStats
sponsoring={aggregates?.sponsoring ?? 0}
purchases={aggregates?.purchases ?? 0}
reviewTips={aggregates?.reviewTips ?? 0}
/>
<SeriesArticles articles={articles} />
</>
)}
</div>
</main>
</>
)
}
function SeriesArticles({ articles }: { articles: Article[] }) {
if (articles.length === 0) {
return <p className="text-sm text-gray-600">Aucun article pour cette série.</p>
}
return (
<div className="space-y-4">
<h2 className="text-2xl font-semibold">Articles de la série</h2>
<div className="space-y-4">
{articles.map((a) => (
<div key={a.id} className="space-y-2">
<ArticleCard article={a} onUnlock={() => {}} />
<ArticleReviews articleId={a.id} authorPubkey={a.pubkey} />
</div>
))}
</div>
</div>
)
}
function useSeriesPageData(seriesId: string) {
const [series, setSeries] = useState<Series | null>(null)
const [articles, setArticles] = useState<Article[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [aggregates, setAggregates] = useState<{ sponsoring: number; purchases: number; reviewTips: number } | null>(null)
useEffect(() => {
if (!seriesId) {
return
}
const load = async () => {
setLoading(true)
setError(null)
try {
const s = await getSeriesById(seriesId)
if (!s) {
setError('Série introuvable')
setLoading(false)
return
}
setSeries(s)
const [agg, seriesArticles] = await Promise.all([
getSeriesAggregates({ authorPubkey: s.pubkey, seriesId: s.id }),
getArticlesBySeries(s.id),
])
setAggregates(agg)
setArticles(seriesArticles)
} catch (e) {
setError(e instanceof Error ? e.message : 'Erreur lors du chargement de la série')
} finally {
setLoading(false)
}
}
void load()
}, [seriesId])
return { series, articles, aggregates, loading, error }
}

View File

@ -1,21 +1,13 @@
import type { Event, Filter } from 'nostr-tools'
import { SimplePool } from 'nostr-tools'
/**
* Extended SimplePool interface that includes the sub method
* The sub method exists in nostr-tools but is not properly typed in the TypeScript definitions
* Alias for SimplePool with typed sub method from nostr-tools definitions.
* Using the existing type avoids compatibility issues while keeping explicit intent.
*/
export interface SimplePoolWithSub extends SimplePool {
sub(relays: string[], filters: Filter[]): {
on(event: 'event', callback: (event: Event) => void): void
on(event: 'eose', callback: () => void): void
unsub(): void
}
sub: SimplePool['sub']
}
/**
* Type guard to check if a SimplePool has the sub method
*/
export function hasSubMethod(pool: SimplePool): pool is SimplePoolWithSub {
return typeof (pool as SimplePoolWithSub).sub === 'function'
}

View File

@ -1,4 +1,4 @@
import { Event, EventTemplate } from 'nostr-tools'
import type { Event } from 'nostr-tools'
export interface NostrProfile {
pubkey: string
@ -10,6 +10,19 @@ export interface NostrProfile {
export type ArticleCategory = 'science-fiction' | 'scientific-research' | 'author-presentation'
export type KindType =
| 'article'
| 'series'
| 'review'
| 'purchase'
| 'review_tip'
| 'sponsoring'
export interface MediaRef {
url: string
type: 'image' | 'video'
}
export interface Article {
id: string
pubkey: string
@ -26,6 +39,10 @@ export interface Article {
mainnetAddress?: string // Bitcoin mainnet address for sponsoring (presentation articles only)
totalSponsoring?: number // Total sponsoring received in sats (presentation articles only)
authorPresentationId?: string // ID of the author's presentation article (for standard articles)
seriesId?: string // Series event id
bannerUrl?: string // NIP-95 banner
media?: MediaRef[] // Embedded media (NIP-95)
kindType?: KindType
}
export interface AuthorPresentationArticle extends Article {
@ -35,6 +52,32 @@ export interface AuthorPresentationArticle extends Article {
totalSponsoring: number
}
export interface Series {
id: string
pubkey: string
title: string
description: string
preview: string
coverUrl?: string
category: ArticleCategory
totalSponsoring?: number
totalPayments?: number
kindType?: KindType
}
export interface Review {
id: string
articleId: string
authorPubkey: string
reviewerPubkey: string
content: string
createdAt: number
title?: string
rewarded?: boolean
rewardAmount?: number
kindType?: KindType
}
export interface ZapRequest {
event: Event
amount: number