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

View File

@ -7,6 +7,8 @@ import { ArticleEditorForm } from './ArticleEditorForm'
interface ArticleEditorProps { interface ArticleEditorProps {
onPublishSuccess?: (articleId: string) => void onPublishSuccess?: (articleId: string) => void
onCancel?: () => void onCancel?: () => void
seriesOptions?: { id: string; title: string }[]
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
} }
function NotConnectedMessage() { 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 { connected, pubkey } = useNostrConnect()
const { loading, error, success, publishArticle } = useArticlePublishing(pubkey ?? null) const { loading, error, success, publishArticle } = useArticlePublishing(pubkey ?? null)
const [draft, setDraft] = useState<ArticleDraft>({ const [draft, setDraft] = useState<ArticleDraft>({
@ -34,16 +36,10 @@ export function ArticleEditor({ onPublishSuccess, onCancel }: ArticleEditorProps
preview: '', preview: '',
content: '', content: '',
zapAmount: 800, zapAmount: 800,
category: undefined, media: [],
}) })
const handleSubmit = async (e: React.FormEvent) => { const submit = buildSubmitHandler(publishArticle, draft, onPublishSuccess)
e.preventDefault()
const articleId = await publishArticle(draft)
if (articleId) {
onPublishSuccess?.(articleId)
}
}
if (!connected) { if (!connected) {
return <NotConnectedMessage /> return <NotConnectedMessage />
@ -58,11 +54,27 @@ export function ArticleEditor({ onPublishSuccess, onCancel }: ArticleEditorProps
draft={draft} draft={draft}
onDraftChange={setDraft} onDraftChange={setDraft}
onSubmit={(e) => { onSubmit={(e) => {
void handleSubmit(e) e.preventDefault()
void submit()
}} }}
loading={loading} loading={loading}
error={error} 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 React from 'react'
import type { ArticleDraft } from '@/lib/articlePublisher' import type { ArticleDraft } from '@/lib/articlePublisher'
import type { ArticleCategory } from '@/types/nostr'
import { ArticleField } from './ArticleField' import { ArticleField } from './ArticleField'
import { ArticleFormButtons } from './ArticleFormButtons' import { ArticleFormButtons } from './ArticleFormButtons'
import { CategorySelect } from './CategorySelect' import { CategorySelect } from './CategorySelect'
import { MarkdownEditor } from './MarkdownEditor'
import type { MediaRef } from '@/types/nostr'
interface ArticleEditorFormProps { interface ArticleEditorFormProps {
draft: ArticleDraft draft: ArticleDraft
@ -11,6 +14,8 @@ interface ArticleEditorFormProps {
loading: boolean loading: boolean
error: string | null error: string | null
onCancel?: () => void onCancel?: () => void
seriesOptions?: { id: string; title: string }[] | undefined
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
} }
function CategoryField({ function CategoryField({
@ -18,13 +23,13 @@ function CategoryField({
onChange, onChange,
}: { }: {
value: ArticleDraft['category'] value: ArticleDraft['category']
onChange: (value: ArticleDraft['category']) => void onChange: (value: import('@/types/nostr').ArticleCategory | undefined) => void
}) { }) {
return ( return (
<CategorySelect <CategorySelect
id="category" id="category"
label="Catégorie" label="Catégorie"
value={value} {...(value ? { value } : {})}
onChange={onChange} onChange={onChange}
required required
helpText="Sélectionnez la catégorie de votre article" 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 = ({ const ArticleFieldsLeft = ({
draft, draft,
onDraftChange, onDraftChange,
seriesOptions,
onSelectSeries,
}: { }: {
draft: ArticleDraft draft: ArticleDraft
onDraftChange: (draft: ArticleDraft) => void onDraftChange: (draft: ArticleDraft) => void
seriesOptions?: { id: string; title: string }[] | undefined
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
}) => ( }) => (
<div className="space-y-4"> <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 <ArticleField
id="title" id="title"
label="Titre" label="Titre"
@ -60,6 +104,17 @@ const ArticleFieldsLeft = ({
required required
placeholder="Entrez le titre de l'article" placeholder="Entrez le titre de l'article"
/> />
)
}
function ArticlePreviewField({
draft,
onDraftChange,
}: {
draft: ArticleDraft
onDraftChange: (draft: ArticleDraft) => void
}) {
return (
<ArticleField <ArticleField
id="preview" id="preview"
label="Aperçu (Public)" label="Aperçu (Public)"
@ -71,8 +126,61 @@ const ArticleFieldsLeft = ({
placeholder="Cet aperçu sera visible par tous gratuitement" placeholder="Cet aperçu sera visible par tous gratuitement"
helpText="Ce contenu sera visible par tous" 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 = ({ const ArticleFieldsRight = ({
draft, draft,
@ -82,17 +190,24 @@ const ArticleFieldsRight = ({
onDraftChange: (draft: ArticleDraft) => void onDraftChange: (draft: ArticleDraft) => void
}) => ( }) => (
<div className="space-y-4"> <div className="space-y-4">
<ArticleField <div className="space-y-2">
id="content" <div className="text-sm font-semibold text-gray-800">Contenu complet (Privé) Markdown + preview</div>
label="Contenu complet (Privé)" <MarkdownEditor
value={draft.content} value={draft.content}
onChange={(value) => onDraftChange({ ...draft, content: value as string })} onChange={(value) => onDraftChange({ ...draft, content: value })}
required onMediaAdd={(media: MediaRef) => {
type="textarea" const nextMedia = [...(draft.media ?? []), media]
rows={8} onDraftChange({ ...draft, media: nextMedia })
placeholder="Ce contenu sera chiffré et envoyé aux lecteurs qui paient" }}
helpText="Ce contenu sera chiffré et envoyé comme message privé après paiement" 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 <ArticleField
id="zapAmount" id="zapAmount"
label="Prix (sats)" label="Prix (sats)"
@ -113,16 +228,23 @@ export function ArticleEditorForm({
loading, loading,
error, error,
onCancel, onCancel,
seriesOptions,
onSelectSeries,
}: ArticleEditorFormProps) { }: ArticleEditorFormProps) {
return ( return (
<form onSubmit={onSubmit} className="border rounded-lg p-6 bg-white space-y-4"> <form onSubmit={onSubmit} className="border rounded-lg p-6 bg-white space-y-4">
<h2 className="text-2xl font-bold mb-4">Publier un nouvel article</h2> <h2 className="text-2xl font-bold mb-4">Publier un nouvel article</h2>
<div className="space-y-4"> <div className="space-y-4">
<ArticleFieldsLeft draft={draft} onDraftChange={onDraftChange} /> <ArticleFieldsLeft
draft={draft}
onDraftChange={onDraftChange}
{...(seriesOptions ? { seriesOptions } : {})}
{...(onSelectSeries ? { onSelectSeries } : {})}
/>
<ArticleFieldsRight draft={draft} onDraftChange={onDraftChange} /> <ArticleFieldsRight draft={draft} onDraftChange={onDraftChange} />
</div> </div>
<ErrorAlert error={error} /> <ErrorAlert error={error} />
<ArticleFormButtons loading={loading} onCancel={onCancel} /> <ArticleFormButtons loading={loading} {...(onCancel ? { onCancel } : {})} />
</form> </form>
) )
} }

View File

@ -32,16 +32,19 @@ function NumberOrTextInput({
className: string className: string
onChange: (value: string | number) => void onChange: (value: string | number) => void
}) { }) {
const inputProps = {
id,
type,
value,
className,
required,
...(placeholder ? { placeholder } : {}),
...(typeof min === 'number' ? { min } : {}),
}
return ( return (
<input <input
id={id} {...inputProps}
type={type}
value={value}
onChange={(e) => onChange(type === 'number' ? Number(e.target.value) || 0 : e.target.value)} 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 className: string
onChange: (value: string | number) => void onChange: (value: string | number) => void
}) { }) {
const areaProps = {
id,
value,
className,
required,
...(placeholder ? { placeholder } : {}),
...(rows ? { rows } : {}),
}
return ( return (
<textarea <textarea
id={id} {...areaProps}
value={value}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
className={className}
rows={rows}
placeholder={placeholder}
required={required}
/> />
) )
} }
@ -87,22 +93,22 @@ export function ArticleField(props: ArticleFieldProps) {
<TextAreaInput <TextAreaInput
id={id} id={id}
value={value} value={value}
placeholder={placeholder}
required={required} required={required}
rows={rows}
className={inputClass} className={inputClass}
onChange={onChange} onChange={onChange}
{...(placeholder ? { placeholder } : {})}
{...(rows ? { rows } : {})}
/> />
) : ( ) : (
<NumberOrTextInput <NumberOrTextInput
id={id} id={id}
type={type} type={type}
value={value} value={value}
placeholder={placeholder}
required={required} required={required}
min={min}
className={inputClass} className={inputClass}
onChange={onChange} 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 { interface CategorySelectProps {
id: string id: string
label: string label: string
value: ArticleCategory | undefined value?: ArticleCategory | ''
onChange: (value: ArticleCategory) => void onChange: (value: ArticleCategory | undefined) => void
required?: boolean required?: boolean
helpText?: string helpText?: string
} }
@ -26,7 +26,10 @@ export function CategorySelect({
<select <select
id={id} id={id}
value={value ?? ''} 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" 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} 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, markAllAsRead,
deleteNotification: deleteNotificationHandler, deleteNotification: deleteNotificationHandler,
} = useNotifications(userPubkey) } = useNotifications(userPubkey)
const { isOpen, handleToggle, handleNotificationClick } = useNotificationCenter( const { isOpen, handleToggle, handleNotificationClick, handleClose } = useNotificationCenter(
markAsRead, markAsRead,
onClose 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 { ProfileHeader } from '@/components/ProfileHeader'
import { BackButton } from '@/components/ProfileBackButton' import { BackButton } from '@/components/ProfileBackButton'
import { UserProfile } from '@/components/UserProfile' import { UserProfile } from '@/components/UserProfile'
import { SearchBar } from '@/components/SearchBar' import { ProfileArticlesSection } from '@/components/ProfileArticlesSection'
import { ArticleFiltersComponent } from '@/components/ArticleFilters'
import { ArticlesSummary } from '@/components/ProfileArticlesSummary'
import { UserArticles } from '@/components/UserArticles'
interface ProfileViewProps { interface ProfileViewProps {
currentPubkey: string currentPubkey: string
@ -23,6 +20,8 @@ interface ProfileViewProps {
loading: boolean loading: boolean
error: string | null error: string | null
loadArticleContent: (id: string, pubkey: string) => Promise<Article | null> loadArticleContent: (id: string, pubkey: string) => Promise<Article | null>
selectedSeriesId?: string | undefined
onSelectSeries: (seriesId: string | undefined) => void
} }
function ProfileLoading() { function ProfileLoading() {
@ -33,94 +32,62 @@ function ProfileLoading() {
) )
} }
function ProfileArticlesSection({ function ProfileLayout(props: ProfileViewProps) {
searchQuery, const articleFiltersVisible = !props.loading && props.allArticles.length > 0
setSearchQuery,
filters,
setFilters,
articles,
allArticles,
loading,
error,
loadArticleContent,
articleFiltersVisible,
}: Pick<
ProfileViewProps,
'searchQuery' | 'setSearchQuery' | 'filters' | 'setFilters' | 'articles' | 'allArticles' | 'loading' | 'error' | 'loadArticleContent'
> & {
articleFiltersVisible: boolean
}) {
return ( return (
<> <>
<div className="mb-6"> <ProfileHeaderSection
<h2 className="text-2xl font-bold mb-4">My Articles</h2> loadingProfile={props.loadingProfile}
<div className="mb-4"> profile={props.profile}
<SearchBar value={searchQuery} onChange={setSearchQuery} placeholder="Search my articles..." /> currentPubkey={props.currentPubkey}
</div> articleCount={props.allArticles.length}
{articleFiltersVisible && ( />
<ArticleFiltersComponent filters={filters} onFiltersChange={setFilters} articles={allArticles} /> <ProfileArticlesSection
)} searchQuery={props.searchQuery}
</div> setSearchQuery={props.setSearchQuery}
<ArticlesSummary visibleCount={articles.length} total={allArticles.length} /> filters={props.filters}
<UserArticles setFilters={props.setFilters}
articles={articles} articles={props.articles}
loading={loading} allArticles={props.allArticles}
error={error} loading={props.loading}
onLoadContent={loadArticleContent} error={props.error}
showEmptyMessage loadArticleContent={props.loadArticleContent}
articleFiltersVisible={articleFiltersVisible}
currentPubkey={props.currentPubkey}
selectedSeriesId={props.selectedSeriesId}
onSelectSeries={props.onSelectSeries}
/> />
</> </>
) )
} }
function ProfileLayout({ function ProfileHeaderSection({
currentPubkey,
profile,
loadingProfile, loadingProfile,
searchQuery, profile,
setSearchQuery, currentPubkey,
filters, articleCount,
setFilters, }: {
articles, loadingProfile: boolean
allArticles, profile: NostrProfile | null
loading, currentPubkey: string
error, articleCount: number
loadArticleContent, }) {
}: ProfileViewProps) {
const articleFiltersVisible = !loading && allArticles.length > 0
return ( return (
<> <>
<BackButton /> <BackButton />
{loadingProfile ? ( {loadingProfile ? (
<ProfileLoading /> <ProfileLoading />
) : profile ? ( ) : profile ? (
<UserProfile profile={profile} pubkey={currentPubkey} articleCount={allArticles.length} /> <UserProfile profile={profile} pubkey={currentPubkey} articleCount={articleCount} />
) : null} ) : 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) { export function ProfileView(props: ProfileViewProps) {
return ( return (
<> <>
<Head> <ProfileHead />
<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>
<main className="min-h-screen bg-gray-50"> <main className="min-h-screen bg-gray-50">
<ProfileHeader /> <ProfileHeader />
<div className="max-w-4xl mx-auto px-4 py-8"> <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 { useEffect, useState, type Dispatch, type SetStateAction } from 'react'
import { ArticleCard } from './ArticleCard'
import type { Article } from '@/types/nostr' import type { Article } from '@/types/nostr'
import { useArticleEditing } from '@/hooks/useArticleEditing'
import { UserArticlesView } from './UserArticlesList'
import { EditPanel } from './UserArticlesEditPanel'
interface UserArticlesProps { interface UserArticlesProps {
articles: Article[] articles: Article[]
@ -8,67 +10,8 @@ interface UserArticlesProps {
error: string | null error: string | null
onLoadContent: (articleId: string, authorPubkey: string) => Promise<Article | null> onLoadContent: (articleId: string, authorPubkey: string) => Promise<Article | null>
showEmptyMessage?: boolean showEmptyMessage?: boolean
} currentPubkey: string | null
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
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>
)
} }
export function UserArticles({ export function UserArticles({
@ -77,27 +20,205 @@ export function UserArticles({
error, error,
onLoadContent, onLoadContent,
showEmptyMessage = true, showEmptyMessage = true,
currentPubkey,
onSelectSeries,
}: UserArticlesProps) { }: UserArticlesProps) {
const [unlockedArticles, setUnlockedArticles] = useState<Set<string>>(new Set()) const controller = useUserArticlesController({ articles, onLoadContent, currentPubkey })
const handleUnlock = async (article: Article) => {
const fullArticle = await onLoadContent(article.id, article.pubkey)
if (fullArticle?.paid) {
setUnlockedArticles((prev) => new Set([...prev, article.id]))
}
}
return ( return (
<UserArticlesView <UserArticlesLayout
articles={articles} controller={controller}
loading={loading} loading={loading}
error={error} error={error}
onLoadContent={onLoadContent} showEmptyMessage={showEmptyMessage ?? true}
showEmptyMessage={showEmptyMessage} currentPubkey={currentPubkey}
unlockedArticles={unlockedArticles} onSelectSeries={onSelectSeries}
onUnlock={(a) => {
void handleUnlock(a)
}}
/> />
) )
} }
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 <UserProfileHeader
displayName={displayName} displayName={displayName}
displayPubkey={displayPubkey} displayPubkey={displayPubkey}
picture={profile.picture} {...(profile.picture ? { picture: profile.picture } : {})}
nip05={profile.nip05} {...(profile.nip05 ? { nip05: profile.nip05 } : {})}
/> />
{profile.about && <p className="text-gray-700 mt-2">{profile.about}</p>} {profile.about && <p className="text-gray-700 mt-2">{profile.about}</p>}
{articleCount !== undefined && <ProfileStats articleCount={articleCount} />} {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 # Résumé final du nettoyage et optimisation
**Date** : Décembre 2024 **Date** : Décembre 2025 (addendum)
## ✅ Objectifs complétés ## ✅ 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` - Handler NostrConnect → `nostrconnectHandler.ts`
### 4. Correction des erreurs de lint ### 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é - ✅ 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) ## Nouveaux fichiers créés (9 fichiers)
1. **`lib/nostrEventParsing.ts`** (40 lignes) 1. **`lib/nostrEventParsing.ts`** (40 lignes)

View File

@ -1,6 +1,17 @@
# Résumé des implémentations - Nostr Paywall # 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 ## ✅ Implémentations complétées
@ -112,8 +123,8 @@
### Technologies utilisées ### Technologies utilisées
- **Frontend** : Next.js 14, React, TypeScript, Tailwind CSS - **Frontend** : Next.js 14, React, TypeScript, Tailwind CSS
- **Nostr** : `nostr-tools` (v2.3.4) - **Nostr** : `nostr-tools` 1.17.0 (compat `signEvent`, `verifyEvent`)
- **Lightning** : Alby/WebLN (`@getalby/sdk`) - **Lightning** : Alby/WebLN
- **QR Code** : `react-qr-code` - **QR Code** : `react-qr-code`
## Fichiers créés/modifiés ## 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 { nostrService } from '@/lib/nostr'
import type { Article } from '@/types/nostr' import type { Article } from '@/types/nostr'
import { applyFiltersAndSort } from '@/lib/articleFiltering' import { applyFiltersAndSort } from '@/lib/articleFiltering'
@ -8,45 +8,36 @@ export function useArticles(searchQuery: string = '', filters: ArticleFilters |
const [articles, setArticles] = useState<Article[]>([]) const [articles, setArticles] = useState<Article[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const hasArticlesRef = useRef(false)
useEffect(() => { useEffect(() => {
setLoading(true) setLoading(true)
setError(null) setError(null)
let unsubscribe: (() => void) | null = null const unsubscribe = nostrService.subscribeToArticles(
nostrService.subscribeToArticles(
(article) => { (article) => {
setArticles((prev) => { setArticles((prev) => {
// Avoid duplicates
if (prev.some((a) => a.id === article.id)) { if (prev.some((a) => a.id === article.id)) {
return prev 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) setLoading(false)
}, },
50 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(() => { const timeout = setTimeout(() => {
setLoading(false) setLoading(false)
if (articles.length === 0) { if (!hasArticlesRef.current) {
setError('No articles found') setError('No articles found')
} }
}, 10000) }, 10000)
return () => { return () => {
if (unsubscribe) { unsubscribe()
unsubscribe()
}
clearTimeout(timeout) clearTimeout(timeout)
} }
}, []) }, [])
@ -77,19 +68,21 @@ export function useArticles(searchQuery: string = '', filters: ArticleFilters |
// Apply filters and sorting // Apply filters and sorting
const filteredArticles = useMemo(() => { const filteredArticles = useMemo(() => {
if (!filters) { const effectiveFilters =
// If no filters, just apply search filters ??
if (!searchQuery.trim()) { ({
return articles
}
return applyFiltersAndSort(articles, searchQuery, {
authorPubkey: null, authorPubkey: null,
minPrice: null, minPrice: null,
maxPrice: null, maxPrice: null,
sortBy: 'newest', 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]) }, [articles, searchQuery, filters])
return { 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 { nostrService } from '@/lib/nostr'
import type { Article } from '@/types/nostr' import type { Article } from '@/types/nostr'
import { applyFiltersAndSort } from '@/lib/articleFiltering' import { applyFiltersAndSort } from '@/lib/articleFiltering'
@ -15,6 +15,7 @@ export function useUserArticles(
const [articles, setArticles] = useState<Article[]>([]) const [articles, setArticles] = useState<Article[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const hasArticlesRef = useRef(false)
useEffect(() => { useEffect(() => {
if (!userPubkey) { if (!userPubkey) {
@ -25,34 +26,22 @@ export function useUserArticles(
setLoading(true) setLoading(true)
setError(null) setError(null)
let unsubscribe: (() => void) | null = null const unsubscribe = nostrService.subscribeToArticles(
(article) => {
// Subscribe to articles from this specific author if (article.pubkey === userPubkey) {
nostrService setArticles((prev) => {
.subscribeToArticles( if (prev.some((a) => a.id === article.id)) {
(article) => { return prev
// Only include articles from this user }
if (article.pubkey === userPubkey) { const next = [article, ...prev].sort((a, b) => b.createdAt - a.createdAt)
setArticles((prev) => { hasArticlesRef.current = next.length > 0
// Avoid duplicates return next
if (prev.some((a) => a.id === article.id)) { })
return prev setLoading(false)
} }
return [article, ...prev].sort((a, b) => b.createdAt - a.createdAt) },
}) 100
setLoading(false) )
}
},
100
)
.then((unsub) => {
unsubscribe = unsub
})
.catch((e) => {
console.error('Error subscribing to user articles:', e)
setError('Failed to load articles')
setLoading(false)
})
// Timeout after 10 seconds // Timeout after 10 seconds
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
@ -60,28 +49,28 @@ export function useUserArticles(
}, 10000) }, 10000)
return () => { return () => {
if (unsubscribe) { unsubscribe()
unsubscribe()
}
clearTimeout(timeout) clearTimeout(timeout)
} }
}, [userPubkey]) }, [userPubkey])
// Apply filters and sorting // Apply filters and sorting
const filteredArticles = useMemo(() => { const filteredArticles = useMemo(() => {
if (!filters) { const effectiveFilters =
// If no filters, just apply search filters ??
if (!searchQuery.trim()) { ({
return articles
}
return applyFiltersAndSort(articles, searchQuery, {
authorPubkey: null, authorPubkey: null,
minPrice: null, minPrice: null,
maxPrice: null, maxPrice: null,
sortBy: 'newest', 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]) }, [articles, searchQuery, filters])
const loadArticleContent = async (articleId: string, authorPubkey: string) => { const loadArticleContent = async (articleId: string, authorPubkey: string) => {

View File

@ -25,31 +25,15 @@ export async function createArticleInvoice(draft: ArticleDraft): Promise<AlbyInv
export function createPreviewEvent( export function createPreviewEvent(
draft: ArticleDraft, draft: ArticleDraft,
invoice: AlbyInvoice, invoice: AlbyInvoice,
authorPresentationId?: string authorPresentationId?: string,
extraTags: string[][] = []
): { ): {
kind: 1 kind: 1
created_at: number created_at: number
tags: string[][] tags: string[][]
content: string content: string
} { } {
const tags: string[][] = [ const tags = buildPreviewTags(draft, invoice, authorPresentationId, extraTags)
['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])
}
return { return {
kind: 1 as const, kind: 1 as const,
@ -58,3 +42,30 @@ export function createPreviewEvent(
content: draft.preview, 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 { nostrService } from './nostr'
import type { AlbyInvoice } from '@/types/alby' import type { AlbyInvoice } from '@/types/alby'
import type { SimplePoolWithSub } from '@/types/nostr-tools-extended' import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
import type { MediaRef } from '@/types/nostr'
import { import {
storePrivateContent, storePrivateContent,
getStoredPrivateContent, getStoredPrivateContent,
@ -16,6 +17,9 @@ export interface ArticleDraft {
content: string // Full content that will be sent as private message after payment content: string // Full content that will be sent as private message after payment
zapAmount: number zapAmount: number
category?: 'science-fiction' | 'scientific-research' category?: 'science-fiction' | 'scientific-research'
seriesId?: string
bannerUrl?: string
media?: MediaRef[]
} }
export interface AuthorPresentationDraft { 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 * Handles publishing preview (public note), creating invoice, and storing full content for later private message
*/ */
export class ArticlePublisher { export class ArticlePublisher {
private readonly siteTag = process.env.NEXT_PUBLIC_SITE_TAG ?? 'zapwall4science'
private buildFailure(error?: string): PublishedArticle { private buildFailure(error?: string): PublishedArticle {
return { const base: PublishedArticle = {
articleId: '', articleId: '',
previewEventId: '', previewEventId: '',
success: false, success: false,
error,
} }
return error ? { ...base, error } : base
} }
private prepareAuthorKeys(authorPubkey: string, authorPrivateKey?: string): { success: boolean; error?: string } { private prepareAuthorKeys(authorPubkey: string, authorPrivateKey?: string): { success: boolean; error?: string } {
@ -69,20 +75,41 @@ export class ArticlePublisher {
return { success: true } 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' return category === 'science-fiction' || category === 'scientific-research'
} }
private async publishPreview( private async publishPreview(
draft: ArticleDraft, draft: ArticleDraft,
invoice: AlbyInvoice, invoice: AlbyInvoice,
presentationId: string presentationId: string,
extraTags?: string[][]
): Promise<import('nostr-tools').Event | null> { ): 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) const publishedEvent = await nostrService.publishEvent(previewEvent)
return publishedEvent ?? null 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) * Publish an article preview as a public note (kind:1)
* Creates a Lightning invoice for the article * Creates a Lightning invoice for the article
@ -107,9 +134,11 @@ export class ArticlePublisher {
if (!this.isValidCategory(draft.category)) { if (!this.isValidCategory(draft.category)) {
return this.buildFailure('Vous devez sélectionner une catégorie (science-fiction ou recherche scientifique).') 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 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) { if (!publishedEvent) {
return this.buildFailure('Failed to publish article') 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 * Get stored private content for an article
*/ */
@ -147,7 +179,6 @@ export class ArticlePublisher {
async sendPrivateContent( async sendPrivateContent(
articleId: string, articleId: string,
recipientPubkey: string, recipientPubkey: string,
authorPubkey: string,
authorPrivateKey: string authorPrivateKey: string
): Promise<boolean> { ): Promise<boolean> {
try { try {
@ -202,16 +233,13 @@ export class ArticlePublisher {
} }
} }
/** async getAuthorPresentation(pubkey: string): Promise<import('@/types/nostr').AuthorPresentationArticle | null> {
* Get author presentation article by pubkey
*/
getAuthorPresentation(pubkey: string): Promise<import('@/types/nostr').AuthorPresentationArticle | null> {
try { try {
const pool = nostrService.getPool() const pool = nostrService.getPool()
if (!pool) { if (!pool) {
return null return null
} }
return fetchAuthorPresentationFromPool(pool as SimplePoolWithSub, pubkey) return await fetchAuthorPresentationFromPool(pool as SimplePoolWithSub, pubkey)
} catch (error) { } catch (error) {
console.error('Error getting author presentation:', error) console.error('Error getting author presentation:', error)
return null 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 // Default expiration: 30 days in milliseconds
const DEFAULT_EXPIRATION = 30 * 24 * 60 * 60 * 1000 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 * Store private content temporarily until payment is confirmed
@ -31,6 +55,7 @@ export async function storePrivateContent(
): Promise<void> { ): Promise<void> {
try { try {
const key = `article_private_content_${articleId}` const key = `article_private_content_${articleId}`
const secret = deriveSecret(articleId)
const data: StoredArticleData = { const data: StoredArticleData = {
content, content,
authorPubkey, authorPubkey,
@ -47,7 +72,7 @@ export async function storePrivateContent(
} }
// Store with expiration (30 days) // Store with expiration (30 days)
await storageService.set(key, data, DEFAULT_EXPIRATION) await storageService.set(key, data, secret, DEFAULT_EXPIRATION)
} catch (error) { } catch (error) {
console.error('Error storing private content:', error) console.error('Error storing private content:', error)
} }
@ -64,7 +89,8 @@ export async function getStoredPrivateContent(articleId: string): Promise<{
} | null> { } | null> {
try { try {
const key = `article_private_content_${articleId}` 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) { if (!data) {
return null return null
@ -73,14 +99,16 @@ export async function getStoredPrivateContent(articleId: string): Promise<{
return { return {
content: data.content, content: data.content,
authorPubkey: data.authorPubkey, authorPubkey: data.authorPubkey,
invoice: data.invoice ...(data.invoice
? { ? {
invoice: data.invoice.invoice, invoice: {
paymentHash: data.invoice.paymentHash, invoice: data.invoice.invoice,
amount: data.invoice.amount, paymentHash: data.invoice.paymentHash,
expiresAt: data.invoice.expiresAt, amount: data.invoice.amount,
expiresAt: data.invoice.expiresAt,
} as AlbyInvoice,
} }
: undefined, : {}),
} }
} catch (error) { } catch (error) {
console.error('Error retrieving private content:', 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>) elements.push(<p key={index} className="mb-4 text-gray-700">{line}</p>)
return return
} }
if (elements.length > 0 && elements[elements.length - 1].type !== 'br') { if (elements.length > 0) {
elements.push(<br key={`br-${index}`} />) const last = elements[elements.length - 1] as { type?: unknown }
if (last?.type !== 'br') {
elements.push(<br key={`br-${index}`} />)
}
} }
} }
function handleCodeBlock( function handleCodeBlock(
line: string, _line: string,
index: number, index: number,
state: RenderState, state: RenderState,
elements: JSX.Element[] elements: JSX.Element[]
@ -172,12 +175,17 @@ function renderLink(line: string, index: number, elements: JSX.Element[]): void
let match let match
while ((match = linkRegex.exec(line)) !== null) { 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)) parts.push(line.substring(lastIndex, match.index))
} }
const href = match[2] const href = match[2]
const isExternal = href.startsWith('http') 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 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') throw new Error('Private key not set or pool not initialized')
} }
const event = { const unsignedEvent = {
pubkey: this.publicKey ?? '',
...eventTemplate, ...eventTemplate,
id: getEventHash(eventTemplate), created_at: eventTemplate.created_at ?? Math.floor(Date.now() / 1000),
sig: signEvent(eventTemplate, this.privateKey), }
const event = {
...unsignedEvent,
id: getEventHash(unsignedEvent),
sig: signEvent(unsignedEvent, this.privateKey),
} as Event } as Event
try { try {
@ -190,7 +196,7 @@ class NostrService {
userPubkey?: string userPubkey?: string
): Promise<boolean> { ): Promise<boolean> {
if (!this.publicKey || !this.pool) { if (!this.publicKey || !this.pool) {
return false return Promise.resolve(false)
} }
// Use provided userPubkey or fall back to current public key // Use provided userPubkey or fall back to current public key

View File

@ -1,5 +1,5 @@
import type { Event } from 'nostr-tools' 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 * Parse article metadata from Nostr event
@ -7,6 +7,9 @@ import type { Article } from '@/types/nostr'
export function parseArticleFromEvent(event: Event): Article | null { export function parseArticleFromEvent(event: Event): Article | null {
try { try {
const tags = extractTags(event) const tags = extractTags(event)
if (tags.kindType && tags.kindType !== 'article') {
return null
}
const { previewContent } = getPreviewContent(event.content, tags.preview) const { previewContent } = getPreviewContent(event.content, tags.preview)
return buildArticle(event, tags, previewContent) return buildArticle(event, tags, previewContent)
} catch (e) { } 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) { function extractTags(event: Event) {
const findTag = (key: string) => event.tags.find((tag) => tag[0] === key)?.[1] 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 { return {
title: findTag('title') ?? 'Untitled', title: findTag('title') ?? 'Untitled',
preview: findTag('preview'), preview: findTag('preview'),
description: findTag('description'),
zapAmount: parseInt(findTag('zap') ?? '800', 10), zapAmount: parseInt(findTag('zap') ?? '800', 10),
invoice: findTag('invoice'), invoice: findTag('invoice'),
paymentHash: findTag('payment_hash'), paymentHash: findTag('payment_hash'),
@ -28,6 +103,14 @@ function extractTags(event: Event) {
mainnetAddress: findTag('mainnet_address'), mainnetAddress: findTag('mainnet_address'),
totalSponsoring: parseInt(findTag('total_sponsoring') ?? '0', 10), totalSponsoring: parseInt(findTag('total_sponsoring') ?? '0', 10),
authorPresentationId: findTag('author_presentation_id'), 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, createdAt: event.created_at,
zapAmount: tags.zapAmount, zapAmount: tags.zapAmount,
paid: false, paid: false,
invoice: tags.invoice, ...(tags.invoice ? { invoice: tags.invoice } : {}),
paymentHash: tags.paymentHash, ...(tags.paymentHash ? { paymentHash: tags.paymentHash } : {}),
category: tags.category, ...(tags.category ? { category: tags.category } : {}),
isPresentation: tags.isPresentation, ...(tags.isPresentation ? { isPresentation: tags.isPresentation } : {}),
mainnetAddress: tags.mainnetAddress, ...(tags.mainnetAddress ? { mainnetAddress: tags.mainnetAddress } : {}),
totalSponsoring: tags.totalSponsoring, ...(tags.totalSponsoring ? { totalSponsoring: tags.totalSponsoring } : {}),
authorPresentationId: tags.authorPresentationId, ...(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 { signEvent(eventTemplate: EventTemplate): Event | null {
// Get the event hash first // 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) // Try to get private key from nostrService (if available from NostrConnect)
const privateKey = nostrService.getPrivateKey() const privateKey = nostrService.getPrivateKey()
@ -28,9 +38,9 @@ export class NostrRemoteSigner {
} }
const event = { const event = {
...eventTemplate, ...unsignedEvent,
id: eventId, id: eventId,
sig: signEvent(eventTemplate, privateKey), sig: signEvent(unsignedEvent, privateKey),
} as Event } as Event
return 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 userPubkey: string
): Promise<boolean> { ): Promise<boolean> {
if (!pool) { if (!pool) {
return false return Promise.resolve(false)
} }
return new Promise((resolve) => { return new Promise((resolve) => {

View File

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

View File

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

View File

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

View File

@ -69,12 +69,7 @@ async function sendPrivateContentAfterPayment(
const authorPrivateKey = nostrService.getPrivateKey() const authorPrivateKey = nostrService.getPrivateKey()
if (authorPrivateKey) { if (authorPrivateKey) {
const sent = await articlePublisher.sendPrivateContent( const sent = await articlePublisher.sendPrivateContent(articleId, recipientPubkey, authorPrivateKey)
articleId,
recipientPubkey,
storedContent.authorPubkey,
authorPrivateKey
)
if (sent) { if (sent) {
// Private content sent successfully // 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_NAME = 'nostr_paywall'
const DB_VERSION = 1 const DB_VERSION = 1
const STORE_NAME = 'article_content' const STORE_NAME = 'article_content'
interface DBData { interface DBData {
id: string id: string
data: unknown data: EncryptedPayload
createdAt: number createdAt: number
expiresAt?: number expiresAt?: number
} }
@ -72,7 +74,7 @@ export class IndexedDBStorage {
/** /**
* Store data in IndexedDB * 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 { try {
await this.init() await this.init()
@ -80,12 +82,13 @@ export class IndexedDBStorage {
throw new Error('Database not initialized') throw new Error('Database not initialized')
} }
const encrypted = await encryptPayload(secret, value)
const now = Date.now() const now = Date.now()
const data: DBData = { const data: DBData = {
id: key, id: key,
data: value, data: encrypted,
createdAt: now, createdAt: now,
expiresAt: expiresIn ? now + expiresIn : undefined, ...(expiresIn ? { expiresAt: now + expiresIn } : {}),
} }
const db = this.db const db = this.db
@ -110,7 +113,7 @@ export class IndexedDBStorage {
/** /**
* Get data from IndexedDB * 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 { try {
await this.init() await this.init()
@ -118,14 +121,14 @@ export class IndexedDBStorage {
throw new Error('Database not initialized') throw new Error('Database not initialized')
} }
return this.readValue<T>(key) return this.readValue<T>(key, secret)
} catch (error) { } catch (error) {
console.error('Error getting from IndexedDB:', error) console.error('Error getting from IndexedDB:', error)
return null return null
} }
} }
private readValue<T>(key: string): Promise<T | null> { private readValue<T>(key: string, secret: string): Promise<T | null> {
const db = this.db const db = this.db
if (!db) { if (!db) {
throw new Error('Database not initialized') throw new Error('Database not initialized')
@ -150,7 +153,12 @@ export class IndexedDBStorage {
return 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}`)) 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 * Service for verifying zap receipts and their signatures
@ -9,7 +9,7 @@ export class ZapVerificationService {
*/ */
verifyZapReceiptSignature(event: Event): boolean { verifyZapReceiptSignature(event: Event): boolean {
try { try {
return verifyEvent(event) return validateEvent(event) && verifySignature(event)
} catch (error) { } catch (error) {
console.error('Error verifying zap receipt signature:', error) console.error('Error verifying zap receipt signature:', error)
return false return false
@ -23,7 +23,7 @@ export class ZapVerificationService {
zapReceipt: Event, zapReceipt: Event,
articleId: string, articleId: string,
articlePubkey: string, articlePubkey: string,
userPubkey: string, _userPubkey: string,
expectedAmount: number expectedAmount: number
): boolean { ): boolean {
if (!this.verifyZapReceiptSignature(zapReceipt)) { if (!this.verifyZapReceiptSignature(zapReceipt)) {
@ -62,7 +62,7 @@ export class ZapVerificationService {
return { return {
amount: amountInSats, amount: amountInSats,
recipient: recipientTag[1], recipient: recipientTag?.[1] ?? '',
articleId: eventTag?.[1] ?? null, articleId: eventTag?.[1] ?? null,
payer: zapReceipt.pubkey, payer: zapReceipt.pubkey,
} }

59
package-lock.json generated
View File

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

View File

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

View File

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

View File

@ -2,6 +2,9 @@ import Head from 'next/head'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { ConnectButton } from '@/components/ConnectButton' import { ConnectButton } from '@/components/ConnectButton'
import { ArticleEditor } from '@/components/ArticleEditor' import { ArticleEditor } from '@/components/ArticleEditor'
import { useEffect, useState } from 'react'
import { useNostrConnect } from '@/hooks/useNostrConnect'
import { getSeriesByAuthor } from '@/lib/seriesQueries'
function PublishHeader() { function PublishHeader() {
return ( return (
@ -30,6 +33,8 @@ function PublishHero({ onBack }: { onBack: () => void }) {
export default function PublishPage() { export default function PublishPage() {
const router = useRouter() const router = useRouter()
const { pubkey } = useNostrConnect()
const [seriesOptions, setSeriesOptions] = useState<{ id: string; title: string }[]>([])
const handlePublishSuccess = () => { const handlePublishSuccess = () => {
setTimeout(() => { setTimeout(() => {
@ -37,28 +42,54 @@ export default function PublishPage() {
}, 2000) }, 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 ( return (
<> <>
<PublishHeader /> <PublishHeader />
<PublishLayout
<main className="min-h-screen bg-gray-50"> onBack={() => {
<header className="bg-white shadow-sm"> void router.push('/')
<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> onPublishSuccess={handlePublishSuccess}
<ConnectButton /> seriesOptions={seriesOptions}
</div> />
</header>
<div className="max-w-4xl mx-auto px-4 py-8">
<PublishHero
onBack={() => {
void router.push('/')
}}
/>
<ArticleEditor onPublishSuccess={handlePublishSuccess} />
</div>
</main>
</> </>
) )
} }
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' import { SimplePool } from 'nostr-tools'
/** /**
* Extended SimplePool interface that includes the sub method * Alias for SimplePool with typed sub method from nostr-tools definitions.
* The sub method exists in nostr-tools but is not properly typed in the TypeScript definitions * Using the existing type avoids compatibility issues while keeping explicit intent.
*/ */
export interface SimplePoolWithSub extends SimplePool { export interface SimplePoolWithSub extends SimplePool {
sub(relays: string[], filters: Filter[]): { sub: SimplePool['sub']
on(event: 'event', callback: (event: Event) => void): void
on(event: 'eose', callback: () => void): void
unsub(): void
}
} }
/**
* Type guard to check if a SimplePool has the sub method
*/
export function hasSubMethod(pool: SimplePool): pool is SimplePoolWithSub { export function hasSubMethod(pool: SimplePool): pool is SimplePoolWithSub {
return typeof (pool as SimplePoolWithSub).sub === 'function' 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 { export interface NostrProfile {
pubkey: string pubkey: string
@ -10,6 +10,19 @@ export interface NostrProfile {
export type ArticleCategory = 'science-fiction' | 'scientific-research' | 'author-presentation' 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 { export interface Article {
id: string id: string
pubkey: string pubkey: string
@ -26,6 +39,10 @@ export interface Article {
mainnetAddress?: string // Bitcoin mainnet address for sponsoring (presentation articles only) mainnetAddress?: string // Bitcoin mainnet address for sponsoring (presentation articles only)
totalSponsoring?: number // Total sponsoring received in sats (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) 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 { export interface AuthorPresentationArticle extends Article {
@ -35,6 +52,32 @@ export interface AuthorPresentationArticle extends Article {
totalSponsoring: number 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 { export interface ZapRequest {
event: Event event: Event
amount: number amount: number