\
This commit is contained in:
parent
3000872dbc
commit
cf5ebeb6e9
@ -223,3 +223,18 @@ Le code final doit :
|
||||
## Conclusion
|
||||
|
||||
Ces consignes constituent un cadre de production strict. Elles imposent une analyse préalable via un arbre des fichiers, un typage TypeScript sans contournement, une non-duplication systématique, une architecture fondée sur des abstractions pertinentes, l’usage 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
31
CONTRIBUTING.md
Normal 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.
|
||||
@ -22,7 +22,12 @@ function InfoIcon() {
|
||||
)
|
||||
}
|
||||
|
||||
function InstallerActions({ onInstalled, markInstalled }: { onInstalled?: () => void; markInstalled: () => void }) {
|
||||
interface InstallerActionsProps {
|
||||
onInstalled?: () => void
|
||||
markInstalled: () => void
|
||||
}
|
||||
|
||||
function InstallerActions({ onInstalled, markInstalled }: InstallerActionsProps) {
|
||||
const connect = useCallback(() => {
|
||||
const alby = getAlbyService()
|
||||
void alby.enable().then(() => {
|
||||
@ -55,14 +60,17 @@ function InstallerActions({ onInstalled, markInstalled }: { onInstalled?: () =>
|
||||
)
|
||||
}
|
||||
|
||||
function InstallerBody({ onInstalled, markInstalled }: { onInstalled?: () => void; markInstalled: () => void }) {
|
||||
function InstallerBody({ onInstalled, markInstalled }: InstallerActionsProps) {
|
||||
return (
|
||||
<div className="ml-3 flex-1">
|
||||
<h3 className="text-sm font-medium text-blue-800">Alby Extension Required</h3>
|
||||
<div className="mt-2 text-sm text-blue-700">
|
||||
<p>To make Lightning payments, please install the Alby browser extension.</p>
|
||||
</div>
|
||||
<InstallerActions onInstalled={onInstalled} markInstalled={markInstalled} />
|
||||
<InstallerActions
|
||||
markInstalled={markInstalled}
|
||||
{...(onInstalled ? { onInstalled } : {})}
|
||||
/>
|
||||
<div className="mt-3 text-xs text-blue-600">
|
||||
<p>Alby is a Lightning wallet that enables instant Bitcoin payments in your browser.</p>
|
||||
</div>
|
||||
@ -113,7 +121,10 @@ export function AlbyInstaller({ onInstalled }: AlbyInstallerProps) {
|
||||
<div className="flex-shrink-0">
|
||||
<InfoIcon />
|
||||
</div>
|
||||
<InstallerBody onInstalled={onInstalled} markInstalled={markInstalled} />
|
||||
<InstallerBody
|
||||
markInstalled={markInstalled}
|
||||
{...(onInstalled ? { onInstalled } : {})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -7,6 +7,8 @@ import { ArticleEditorForm } from './ArticleEditorForm'
|
||||
interface ArticleEditorProps {
|
||||
onPublishSuccess?: (articleId: string) => void
|
||||
onCancel?: () => void
|
||||
seriesOptions?: { id: string; title: string }[]
|
||||
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
|
||||
}
|
||||
|
||||
function NotConnectedMessage() {
|
||||
@ -26,7 +28,7 @@ function SuccessMessage() {
|
||||
)
|
||||
}
|
||||
|
||||
export function ArticleEditor({ onPublishSuccess, onCancel }: ArticleEditorProps) {
|
||||
export function ArticleEditor({ onPublishSuccess, onCancel, seriesOptions, onSelectSeries }: ArticleEditorProps) {
|
||||
const { connected, pubkey } = useNostrConnect()
|
||||
const { loading, error, success, publishArticle } = useArticlePublishing(pubkey ?? null)
|
||||
const [draft, setDraft] = useState<ArticleDraft>({
|
||||
@ -34,16 +36,10 @@ export function ArticleEditor({ onPublishSuccess, onCancel }: ArticleEditorProps
|
||||
preview: '',
|
||||
content: '',
|
||||
zapAmount: 800,
|
||||
category: undefined,
|
||||
media: [],
|
||||
})
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const articleId = await publishArticle(draft)
|
||||
if (articleId) {
|
||||
onPublishSuccess?.(articleId)
|
||||
}
|
||||
}
|
||||
const submit = buildSubmitHandler(publishArticle, draft, onPublishSuccess)
|
||||
|
||||
if (!connected) {
|
||||
return <NotConnectedMessage />
|
||||
@ -58,11 +54,27 @@ export function ArticleEditor({ onPublishSuccess, onCancel }: ArticleEditorProps
|
||||
draft={draft}
|
||||
onDraftChange={setDraft}
|
||||
onSubmit={(e) => {
|
||||
void handleSubmit(e)
|
||||
e.preventDefault()
|
||||
void submit()
|
||||
}}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onCancel={onCancel}
|
||||
{...(onCancel ? { onCancel } : {})}
|
||||
{...(seriesOptions ? { seriesOptions } : {})}
|
||||
{...(onSelectSeries ? { onSelectSeries } : {})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function buildSubmitHandler(
|
||||
publishArticle: (draft: ArticleDraft) => Promise<string | null>,
|
||||
draft: ArticleDraft,
|
||||
onPublishSuccess?: (articleId: string) => void
|
||||
) {
|
||||
return async () => {
|
||||
const articleId = await publishArticle(draft)
|
||||
if (articleId) {
|
||||
onPublishSuccess?.(articleId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
import React from 'react'
|
||||
import type { ArticleDraft } from '@/lib/articlePublisher'
|
||||
import type { ArticleCategory } from '@/types/nostr'
|
||||
import { ArticleField } from './ArticleField'
|
||||
import { ArticleFormButtons } from './ArticleFormButtons'
|
||||
import { CategorySelect } from './CategorySelect'
|
||||
import { MarkdownEditor } from './MarkdownEditor'
|
||||
import type { MediaRef } from '@/types/nostr'
|
||||
|
||||
interface ArticleEditorFormProps {
|
||||
draft: ArticleDraft
|
||||
@ -11,6 +14,8 @@ interface ArticleEditorFormProps {
|
||||
loading: boolean
|
||||
error: string | null
|
||||
onCancel?: () => void
|
||||
seriesOptions?: { id: string; title: string }[] | undefined
|
||||
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
|
||||
}
|
||||
|
||||
function CategoryField({
|
||||
@ -18,13 +23,13 @@ function CategoryField({
|
||||
onChange,
|
||||
}: {
|
||||
value: ArticleDraft['category']
|
||||
onChange: (value: ArticleDraft['category']) => void
|
||||
onChange: (value: import('@/types/nostr').ArticleCategory | undefined) => void
|
||||
}) {
|
||||
return (
|
||||
<CategorySelect
|
||||
id="category"
|
||||
label="Catégorie"
|
||||
value={value}
|
||||
{...(value ? { value } : {})}
|
||||
onChange={onChange}
|
||||
required
|
||||
helpText="Sélectionnez la catégorie de votre article"
|
||||
@ -43,15 +48,54 @@ function ErrorAlert({ error }: { error: string | null }) {
|
||||
)
|
||||
}
|
||||
|
||||
function buildCategoryChangeHandler(
|
||||
draft: ArticleDraft,
|
||||
onDraftChange: (draft: ArticleDraft) => void
|
||||
): (value: ArticleCategory | undefined) => void {
|
||||
return (value) => {
|
||||
if (value === 'science-fiction' || value === 'scientific-research' || value === undefined) {
|
||||
const nextDraft: ArticleDraft = { ...draft }
|
||||
if (value) {
|
||||
nextDraft.category = value
|
||||
} else {
|
||||
delete (nextDraft as { category?: ArticleDraft['category'] }).category
|
||||
}
|
||||
onDraftChange(nextDraft)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ArticleFieldsLeft = ({
|
||||
draft,
|
||||
onDraftChange,
|
||||
seriesOptions,
|
||||
onSelectSeries,
|
||||
}: {
|
||||
draft: ArticleDraft
|
||||
onDraftChange: (draft: ArticleDraft) => void
|
||||
seriesOptions?: { id: string; title: string }[] | undefined
|
||||
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
|
||||
}) => (
|
||||
<div className="space-y-4">
|
||||
<CategoryField value={draft.category} onChange={(value) => onDraftChange({ ...draft, category: value })} />
|
||||
<CategoryField
|
||||
value={draft.category}
|
||||
onChange={buildCategoryChangeHandler(draft, onDraftChange)}
|
||||
/>
|
||||
{seriesOptions && (
|
||||
<SeriesSelect
|
||||
draft={draft}
|
||||
onDraftChange={onDraftChange}
|
||||
seriesOptions={seriesOptions}
|
||||
onSelectSeries={onSelectSeries}
|
||||
/>
|
||||
)}
|
||||
<ArticleTitleField draft={draft} onDraftChange={onDraftChange} />
|
||||
<ArticlePreviewField draft={draft} onDraftChange={onDraftChange} />
|
||||
</div>
|
||||
)
|
||||
|
||||
function ArticleTitleField({ draft, onDraftChange }: { draft: ArticleDraft; onDraftChange: (draft: ArticleDraft) => void }) {
|
||||
return (
|
||||
<ArticleField
|
||||
id="title"
|
||||
label="Titre"
|
||||
@ -60,6 +104,17 @@ const ArticleFieldsLeft = ({
|
||||
required
|
||||
placeholder="Entrez le titre de l'article"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ArticlePreviewField({
|
||||
draft,
|
||||
onDraftChange,
|
||||
}: {
|
||||
draft: ArticleDraft
|
||||
onDraftChange: (draft: ArticleDraft) => void
|
||||
}) {
|
||||
return (
|
||||
<ArticleField
|
||||
id="preview"
|
||||
label="Aperçu (Public)"
|
||||
@ -71,8 +126,61 @@ const ArticleFieldsLeft = ({
|
||||
placeholder="Cet aperçu sera visible par tous gratuitement"
|
||||
helpText="Ce contenu sera visible par tous"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function SeriesSelect({
|
||||
draft,
|
||||
onDraftChange,
|
||||
seriesOptions,
|
||||
onSelectSeries,
|
||||
}: {
|
||||
draft: ArticleDraft
|
||||
onDraftChange: (draft: ArticleDraft) => void
|
||||
seriesOptions: { id: string; title: string }[]
|
||||
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
|
||||
}) {
|
||||
const handleChange = buildSeriesChangeHandler(draft, onDraftChange, onSelectSeries)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor="series" className="block text-sm font-medium text-gray-700">
|
||||
Série
|
||||
</label>
|
||||
<select
|
||||
id="series"
|
||||
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm"
|
||||
value={draft.seriesId ?? ''}
|
||||
onChange={handleChange}
|
||||
>
|
||||
<option value="">Aucune (article indépendant)</option>
|
||||
{seriesOptions.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function buildSeriesChangeHandler(
|
||||
draft: ArticleDraft,
|
||||
onDraftChange: (draft: ArticleDraft) => void,
|
||||
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
|
||||
) {
|
||||
return (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const value = e.target.value || undefined
|
||||
const nextDraft = { ...draft }
|
||||
if (value) {
|
||||
nextDraft.seriesId = value
|
||||
} else {
|
||||
delete (nextDraft as { seriesId?: string }).seriesId
|
||||
}
|
||||
onDraftChange(nextDraft)
|
||||
onSelectSeries?.(value)
|
||||
}
|
||||
}
|
||||
|
||||
const ArticleFieldsRight = ({
|
||||
draft,
|
||||
@ -82,17 +190,24 @@ const ArticleFieldsRight = ({
|
||||
onDraftChange: (draft: ArticleDraft) => void
|
||||
}) => (
|
||||
<div className="space-y-4">
|
||||
<ArticleField
|
||||
id="content"
|
||||
label="Contenu complet (Privé)"
|
||||
value={draft.content}
|
||||
onChange={(value) => onDraftChange({ ...draft, content: value as string })}
|
||||
required
|
||||
type="textarea"
|
||||
rows={8}
|
||||
placeholder="Ce contenu sera chiffré et envoyé aux lecteurs qui paient"
|
||||
helpText="Ce contenu sera chiffré et envoyé comme message privé après paiement"
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-semibold text-gray-800">Contenu complet (Privé) — Markdown + preview</div>
|
||||
<MarkdownEditor
|
||||
value={draft.content}
|
||||
onChange={(value) => onDraftChange({ ...draft, content: value })}
|
||||
onMediaAdd={(media: MediaRef) => {
|
||||
const nextMedia = [...(draft.media ?? []), media]
|
||||
onDraftChange({ ...draft, media: nextMedia })
|
||||
}}
|
||||
onBannerChange={(url: string) => {
|
||||
onDraftChange({ ...draft, bannerUrl: url })
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-gray-500">
|
||||
Les médias sont uploadés via NIP-95 (images ≤5Mo, vidéos ≤45Mo) et insérés comme URL. Le contenu reste chiffré
|
||||
pour les acheteurs.
|
||||
</p>
|
||||
</div>
|
||||
<ArticleField
|
||||
id="zapAmount"
|
||||
label="Prix (sats)"
|
||||
@ -113,16 +228,23 @@ export function ArticleEditorForm({
|
||||
loading,
|
||||
error,
|
||||
onCancel,
|
||||
seriesOptions,
|
||||
onSelectSeries,
|
||||
}: ArticleEditorFormProps) {
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="border rounded-lg p-6 bg-white space-y-4">
|
||||
<h2 className="text-2xl font-bold mb-4">Publier un nouvel article</h2>
|
||||
<div className="space-y-4">
|
||||
<ArticleFieldsLeft draft={draft} onDraftChange={onDraftChange} />
|
||||
<ArticleFieldsLeft
|
||||
draft={draft}
|
||||
onDraftChange={onDraftChange}
|
||||
{...(seriesOptions ? { seriesOptions } : {})}
|
||||
{...(onSelectSeries ? { onSelectSeries } : {})}
|
||||
/>
|
||||
<ArticleFieldsRight draft={draft} onDraftChange={onDraftChange} />
|
||||
</div>
|
||||
<ErrorAlert error={error} />
|
||||
<ArticleFormButtons loading={loading} onCancel={onCancel} />
|
||||
<ArticleFormButtons loading={loading} {...(onCancel ? { onCancel } : {})} />
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
@ -32,16 +32,19 @@ function NumberOrTextInput({
|
||||
className: string
|
||||
onChange: (value: string | number) => void
|
||||
}) {
|
||||
const inputProps = {
|
||||
id,
|
||||
type,
|
||||
value,
|
||||
className,
|
||||
required,
|
||||
...(placeholder ? { placeholder } : {}),
|
||||
...(typeof min === 'number' ? { min } : {}),
|
||||
}
|
||||
return (
|
||||
<input
|
||||
id={id}
|
||||
type={type}
|
||||
value={value}
|
||||
{...inputProps}
|
||||
onChange={(e) => onChange(type === 'number' ? Number(e.target.value) || 0 : e.target.value)}
|
||||
className={className}
|
||||
placeholder={placeholder}
|
||||
min={min}
|
||||
required={required}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -63,15 +66,18 @@ function TextAreaInput({
|
||||
className: string
|
||||
onChange: (value: string | number) => void
|
||||
}) {
|
||||
const areaProps = {
|
||||
id,
|
||||
value,
|
||||
className,
|
||||
required,
|
||||
...(placeholder ? { placeholder } : {}),
|
||||
...(rows ? { rows } : {}),
|
||||
}
|
||||
return (
|
||||
<textarea
|
||||
id={id}
|
||||
value={value}
|
||||
{...areaProps}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className={className}
|
||||
rows={rows}
|
||||
placeholder={placeholder}
|
||||
required={required}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -87,22 +93,22 @@ export function ArticleField(props: ArticleFieldProps) {
|
||||
<TextAreaInput
|
||||
id={id}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
required={required}
|
||||
rows={rows}
|
||||
className={inputClass}
|
||||
onChange={onChange}
|
||||
{...(placeholder ? { placeholder } : {})}
|
||||
{...(rows ? { rows } : {})}
|
||||
/>
|
||||
) : (
|
||||
<NumberOrTextInput
|
||||
id={id}
|
||||
type={type}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
required={required}
|
||||
min={min}
|
||||
className={inputClass}
|
||||
onChange={onChange}
|
||||
{...(placeholder ? { placeholder } : {})}
|
||||
{...(typeof min === 'number' ? { min } : {})}
|
||||
/>
|
||||
)
|
||||
|
||||
|
||||
80
components/ArticleReviews.tsx
Normal file
80
components/ArticleReviews.tsx
Normal 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()
|
||||
}
|
||||
@ -4,8 +4,8 @@ import type { ArticleCategory } from '@/types/nostr'
|
||||
interface CategorySelectProps {
|
||||
id: string
|
||||
label: string
|
||||
value: ArticleCategory | undefined
|
||||
onChange: (value: ArticleCategory) => void
|
||||
value?: ArticleCategory | ''
|
||||
onChange: (value: ArticleCategory | undefined) => void
|
||||
required?: boolean
|
||||
helpText?: string
|
||||
}
|
||||
@ -26,7 +26,10 @@ export function CategorySelect({
|
||||
<select
|
||||
id={id}
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value as ArticleCategory)}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value === '' ? undefined : (e.target.value as ArticleCategory)
|
||||
onChange(next)
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
required={required}
|
||||
>
|
||||
|
||||
115
components/MarkdownEditor.tsx
Normal file
115
components/MarkdownEditor.tsx
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -16,7 +16,7 @@ export function NotificationCenter({ userPubkey, onClose }: NotificationCenterPr
|
||||
markAllAsRead,
|
||||
deleteNotification: deleteNotificationHandler,
|
||||
} = useNotifications(userPubkey)
|
||||
const { isOpen, handleToggle, handleNotificationClick } = useNotificationCenter(
|
||||
const { isOpen, handleToggle, handleNotificationClick, handleClose } = useNotificationCenter(
|
||||
markAsRead,
|
||||
onClose
|
||||
)
|
||||
|
||||
34
components/ProfileArticlesHeader.tsx
Normal file
34
components/ProfileArticlesHeader.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
68
components/ProfileArticlesSection.tsx
Normal file
68
components/ProfileArticlesSection.tsx
Normal 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),
|
||||
}
|
||||
}
|
||||
28
components/ProfileSeriesBlock.tsx
Normal file
28
components/ProfileSeriesBlock.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -5,10 +5,7 @@ import type { NostrProfile } from '@/types/nostr'
|
||||
import { ProfileHeader } from '@/components/ProfileHeader'
|
||||
import { BackButton } from '@/components/ProfileBackButton'
|
||||
import { UserProfile } from '@/components/UserProfile'
|
||||
import { SearchBar } from '@/components/SearchBar'
|
||||
import { ArticleFiltersComponent } from '@/components/ArticleFilters'
|
||||
import { ArticlesSummary } from '@/components/ProfileArticlesSummary'
|
||||
import { UserArticles } from '@/components/UserArticles'
|
||||
import { ProfileArticlesSection } from '@/components/ProfileArticlesSection'
|
||||
|
||||
interface ProfileViewProps {
|
||||
currentPubkey: string
|
||||
@ -23,6 +20,8 @@ interface ProfileViewProps {
|
||||
loading: boolean
|
||||
error: string | null
|
||||
loadArticleContent: (id: string, pubkey: string) => Promise<Article | null>
|
||||
selectedSeriesId?: string | undefined
|
||||
onSelectSeries: (seriesId: string | undefined) => void
|
||||
}
|
||||
|
||||
function ProfileLoading() {
|
||||
@ -33,94 +32,62 @@ function ProfileLoading() {
|
||||
)
|
||||
}
|
||||
|
||||
function ProfileArticlesSection({
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
filters,
|
||||
setFilters,
|
||||
articles,
|
||||
allArticles,
|
||||
loading,
|
||||
error,
|
||||
loadArticleContent,
|
||||
articleFiltersVisible,
|
||||
}: Pick<
|
||||
ProfileViewProps,
|
||||
'searchQuery' | 'setSearchQuery' | 'filters' | 'setFilters' | 'articles' | 'allArticles' | 'loading' | 'error' | 'loadArticleContent'
|
||||
> & {
|
||||
articleFiltersVisible: boolean
|
||||
}) {
|
||||
function ProfileLayout(props: ProfileViewProps) {
|
||||
const articleFiltersVisible = !props.loading && props.allArticles.length > 0
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<h2 className="text-2xl font-bold mb-4">My Articles</h2>
|
||||
<div className="mb-4">
|
||||
<SearchBar value={searchQuery} onChange={setSearchQuery} placeholder="Search my articles..." />
|
||||
</div>
|
||||
{articleFiltersVisible && (
|
||||
<ArticleFiltersComponent filters={filters} onFiltersChange={setFilters} articles={allArticles} />
|
||||
)}
|
||||
</div>
|
||||
<ArticlesSummary visibleCount={articles.length} total={allArticles.length} />
|
||||
<UserArticles
|
||||
articles={articles}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onLoadContent={loadArticleContent}
|
||||
showEmptyMessage
|
||||
<ProfileHeaderSection
|
||||
loadingProfile={props.loadingProfile}
|
||||
profile={props.profile}
|
||||
currentPubkey={props.currentPubkey}
|
||||
articleCount={props.allArticles.length}
|
||||
/>
|
||||
<ProfileArticlesSection
|
||||
searchQuery={props.searchQuery}
|
||||
setSearchQuery={props.setSearchQuery}
|
||||
filters={props.filters}
|
||||
setFilters={props.setFilters}
|
||||
articles={props.articles}
|
||||
allArticles={props.allArticles}
|
||||
loading={props.loading}
|
||||
error={props.error}
|
||||
loadArticleContent={props.loadArticleContent}
|
||||
articleFiltersVisible={articleFiltersVisible}
|
||||
currentPubkey={props.currentPubkey}
|
||||
selectedSeriesId={props.selectedSeriesId}
|
||||
onSelectSeries={props.onSelectSeries}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ProfileLayout({
|
||||
currentPubkey,
|
||||
profile,
|
||||
function ProfileHeaderSection({
|
||||
loadingProfile,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
filters,
|
||||
setFilters,
|
||||
articles,
|
||||
allArticles,
|
||||
loading,
|
||||
error,
|
||||
loadArticleContent,
|
||||
}: ProfileViewProps) {
|
||||
const articleFiltersVisible = !loading && allArticles.length > 0
|
||||
|
||||
profile,
|
||||
currentPubkey,
|
||||
articleCount,
|
||||
}: {
|
||||
loadingProfile: boolean
|
||||
profile: NostrProfile | null
|
||||
currentPubkey: string
|
||||
articleCount: number
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<BackButton />
|
||||
{loadingProfile ? (
|
||||
<ProfileLoading />
|
||||
) : profile ? (
|
||||
<UserProfile profile={profile} pubkey={currentPubkey} articleCount={allArticles.length} />
|
||||
<UserProfile profile={profile} pubkey={currentPubkey} articleCount={articleCount} />
|
||||
) : null}
|
||||
<ProfileArticlesSection
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
filters={filters}
|
||||
setFilters={setFilters}
|
||||
articles={articles}
|
||||
allArticles={allArticles}
|
||||
loading={loading}
|
||||
error={error}
|
||||
loadArticleContent={loadArticleContent}
|
||||
articleFiltersVisible={articleFiltersVisible}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProfileView(props: ProfileViewProps) {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>My Profile - zapwall4Science</title>
|
||||
<meta name="description" content="View your profile and published articles" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
</Head>
|
||||
<ProfileHead />
|
||||
<main className="min-h-screen bg-gray-50">
|
||||
<ProfileHeader />
|
||||
<div className="max-w-4xl mx-auto px-4 py-8">
|
||||
@ -130,3 +97,13 @@ export function ProfileView(props: ProfileViewProps) {
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ProfileHead() {
|
||||
return (
|
||||
<Head>
|
||||
<title>My Profile - zapwall4Science</title>
|
||||
<meta name="description" content="View your profile and published articles" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
</Head>
|
||||
)
|
||||
}
|
||||
|
||||
48
components/SeriesCard.tsx
Normal file
48
components/SeriesCard.tsx
Normal 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
21
components/SeriesList.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
137
components/SeriesSection.tsx
Normal file
137
components/SeriesSection.tsx
Normal 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 }
|
||||
}
|
||||
27
components/SeriesStats.tsx
Normal file
27
components/SeriesStats.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -1,6 +1,8 @@
|
||||
import { useState } from 'react'
|
||||
import { ArticleCard } from './ArticleCard'
|
||||
import { useEffect, useState, type Dispatch, type SetStateAction } from 'react'
|
||||
import type { Article } from '@/types/nostr'
|
||||
import { useArticleEditing } from '@/hooks/useArticleEditing'
|
||||
import { UserArticlesView } from './UserArticlesList'
|
||||
import { EditPanel } from './UserArticlesEditPanel'
|
||||
|
||||
interface UserArticlesProps {
|
||||
articles: Article[]
|
||||
@ -8,67 +10,8 @@ interface UserArticlesProps {
|
||||
error: string | null
|
||||
onLoadContent: (articleId: string, authorPubkey: string) => Promise<Article | null>
|
||||
showEmptyMessage?: boolean
|
||||
}
|
||||
|
||||
function ArticlesLoading() {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500">Loading articles...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ArticlesError({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-4">
|
||||
<p className="text-red-800">{message}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyState({ show }: { show: boolean }) {
|
||||
if (!show) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500">No articles published yet.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function UserArticlesView({
|
||||
articles,
|
||||
loading,
|
||||
error,
|
||||
showEmptyMessage,
|
||||
unlockedArticles,
|
||||
onUnlock,
|
||||
}: Omit<UserArticlesProps, 'onLoadContent'> & { unlockedArticles: Set<string>; onUnlock: (article: Article) => void }) {
|
||||
if (loading) {
|
||||
return <ArticlesLoading />
|
||||
}
|
||||
if (error) {
|
||||
return <ArticlesError message={error} />
|
||||
}
|
||||
if (articles.length === 0) {
|
||||
return <EmptyState show={showEmptyMessage} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{articles.map((article) => (
|
||||
<ArticleCard
|
||||
key={article.id}
|
||||
article={{
|
||||
...article,
|
||||
paid: unlockedArticles.has(article.id) || article.paid,
|
||||
}}
|
||||
onUnlock={onUnlock}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
currentPubkey: string | null
|
||||
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
|
||||
}
|
||||
|
||||
export function UserArticles({
|
||||
@ -77,27 +20,205 @@ export function UserArticles({
|
||||
error,
|
||||
onLoadContent,
|
||||
showEmptyMessage = true,
|
||||
currentPubkey,
|
||||
onSelectSeries,
|
||||
}: UserArticlesProps) {
|
||||
const [unlockedArticles, setUnlockedArticles] = useState<Set<string>>(new Set())
|
||||
|
||||
const handleUnlock = async (article: Article) => {
|
||||
const fullArticle = await onLoadContent(article.id, article.pubkey)
|
||||
if (fullArticle?.paid) {
|
||||
setUnlockedArticles((prev) => new Set([...prev, article.id]))
|
||||
}
|
||||
}
|
||||
|
||||
const controller = useUserArticlesController({ articles, onLoadContent, currentPubkey })
|
||||
return (
|
||||
<UserArticlesView
|
||||
articles={articles}
|
||||
<UserArticlesLayout
|
||||
controller={controller}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onLoadContent={onLoadContent}
|
||||
showEmptyMessage={showEmptyMessage}
|
||||
unlockedArticles={unlockedArticles}
|
||||
onUnlock={(a) => {
|
||||
void handleUnlock(a)
|
||||
}}
|
||||
showEmptyMessage={showEmptyMessage ?? true}
|
||||
currentPubkey={currentPubkey}
|
||||
onSelectSeries={onSelectSeries}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function useUserArticlesController({
|
||||
articles,
|
||||
onLoadContent,
|
||||
currentPubkey,
|
||||
}: {
|
||||
articles: Article[]
|
||||
onLoadContent: (articleId: string, authorPubkey: string) => Promise<Article | null>
|
||||
currentPubkey: string | null
|
||||
}) {
|
||||
const [localArticles, setLocalArticles] = useState<Article[]>(articles)
|
||||
const [unlockedArticles, setUnlockedArticles] = useState<Set<string>>(new Set())
|
||||
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null)
|
||||
const editingCtx = useArticleEditing(currentPubkey)
|
||||
|
||||
useEffect(() => setLocalArticles(articles), [articles])
|
||||
|
||||
return {
|
||||
localArticles,
|
||||
unlockedArticles,
|
||||
pendingDeleteId,
|
||||
requestDelete: (id: string) => setPendingDeleteId(id),
|
||||
handleUnlock: createHandleUnlock(onLoadContent, setUnlockedArticles),
|
||||
handleDelete: createHandleDelete(editingCtx.deleteArticle, setLocalArticles, setPendingDeleteId),
|
||||
handleEditSubmit: createHandleEditSubmit(
|
||||
editingCtx.submitEdit,
|
||||
editingCtx.editingDraft,
|
||||
currentPubkey,
|
||||
setLocalArticles
|
||||
),
|
||||
...editingCtx,
|
||||
}
|
||||
}
|
||||
|
||||
function createHandleUnlock(
|
||||
onLoadContent: (id: string, pubkey: string) => Promise<Article | null>,
|
||||
setUnlocked: Dispatch<SetStateAction<Set<string>>>
|
||||
) {
|
||||
return async (article: Article) => {
|
||||
const full = await onLoadContent(article.id, article.pubkey)
|
||||
if (full?.paid) {
|
||||
setUnlocked((prev) => new Set([...prev, article.id]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createHandleDelete(
|
||||
deleteArticle: (id: string) => Promise<boolean>,
|
||||
setLocalArticles: Dispatch<SetStateAction<Article[]>>,
|
||||
setPendingDeleteId: Dispatch<SetStateAction<string | null>>
|
||||
) {
|
||||
return async (article: Article) => {
|
||||
const ok = await deleteArticle(article.id)
|
||||
if (ok) {
|
||||
setLocalArticles((prev) => prev.filter((a) => a.id !== article.id))
|
||||
}
|
||||
setPendingDeleteId(null)
|
||||
}
|
||||
}
|
||||
|
||||
function createHandleEditSubmit(
|
||||
submitEdit: () => Promise<import('@/lib/articleMutations').ArticleUpdateResult | null>,
|
||||
draft: ReturnType<typeof useArticleEditing>['editingDraft'],
|
||||
currentPubkey: string | null,
|
||||
setLocalArticles: Dispatch<SetStateAction<Article[]>>
|
||||
) {
|
||||
return async () => {
|
||||
const result = await submitEdit()
|
||||
if (result && draft) {
|
||||
const updated = buildUpdatedArticle(draft, currentPubkey ?? '', result.articleId)
|
||||
setLocalArticles((prev) => {
|
||||
const filtered = prev.filter((a) => a.id !== result.originalArticleId)
|
||||
return [updated, ...filtered]
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildUpdatedArticle(
|
||||
draft: NonNullable<ReturnType<typeof useArticleEditing>['editingDraft']>,
|
||||
pubkey: string,
|
||||
newId: string
|
||||
): Article {
|
||||
return {
|
||||
id: newId,
|
||||
pubkey,
|
||||
title: draft.title,
|
||||
preview: draft.preview,
|
||||
content: '',
|
||||
createdAt: Math.floor(Date.now() / 1000),
|
||||
zapAmount: draft.zapAmount,
|
||||
paid: false,
|
||||
...(draft.category ? { category: draft.category } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function UserArticlesLayout({
|
||||
controller,
|
||||
loading,
|
||||
error,
|
||||
showEmptyMessage,
|
||||
currentPubkey,
|
||||
onSelectSeries,
|
||||
}: {
|
||||
controller: ReturnType<typeof useUserArticlesController>
|
||||
loading: boolean
|
||||
error: string | null
|
||||
showEmptyMessage: boolean
|
||||
currentPubkey: string | null
|
||||
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
|
||||
}) {
|
||||
const { editPanelProps, listProps } = createLayoutProps(controller, {
|
||||
loading,
|
||||
error,
|
||||
showEmptyMessage,
|
||||
currentPubkey,
|
||||
onSelectSeries,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<EditPanel {...editPanelProps} />
|
||||
<UserArticlesView {...listProps} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function createLayoutProps(
|
||||
controller: ReturnType<typeof useUserArticlesController>,
|
||||
view: {
|
||||
loading: boolean
|
||||
error: string | null
|
||||
showEmptyMessage: boolean
|
||||
currentPubkey: string | null
|
||||
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
|
||||
}
|
||||
) {
|
||||
return {
|
||||
editPanelProps: buildEditPanelProps(controller),
|
||||
listProps: buildListProps(controller, view),
|
||||
}
|
||||
}
|
||||
|
||||
function buildEditPanelProps(controller: ReturnType<typeof useUserArticlesController>) {
|
||||
return {
|
||||
draft: controller.editingDraft,
|
||||
editingArticleId: controller.editingArticleId,
|
||||
loading: controller.loading,
|
||||
error: controller.error,
|
||||
onCancel: controller.cancelEditing,
|
||||
onDraftChange: controller.updateDraft,
|
||||
onSubmit: controller.handleEditSubmit,
|
||||
}
|
||||
}
|
||||
|
||||
function buildListProps(
|
||||
controller: ReturnType<typeof useUserArticlesController>,
|
||||
view: {
|
||||
loading: boolean
|
||||
error: string | null
|
||||
showEmptyMessage: boolean
|
||||
currentPubkey: string | null
|
||||
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
|
||||
}
|
||||
) {
|
||||
return {
|
||||
articles: controller.localArticles,
|
||||
loading: view.loading,
|
||||
error: view.error,
|
||||
showEmptyMessage: view.showEmptyMessage,
|
||||
unlockedArticles: controller.unlockedArticles,
|
||||
onUnlock: (a: Article) => {
|
||||
void controller.handleUnlock(a)
|
||||
},
|
||||
onEdit: (a: Article) => {
|
||||
void controller.startEditing(a)
|
||||
},
|
||||
onDelete: (a: Article) => {
|
||||
void controller.handleDelete(a)
|
||||
},
|
||||
editingArticleId: controller.editingArticleId,
|
||||
currentPubkey: view.currentPubkey,
|
||||
pendingDeleteId: controller.pendingDeleteId,
|
||||
requestDelete: controller.requestDelete,
|
||||
...(view.onSelectSeries ? { onSelectSeries: view.onSelectSeries } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
42
components/UserArticlesEditPanel.tsx
Normal file
42
components/UserArticlesEditPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
199
components/UserArticlesList.tsx
Normal file
199
components/UserArticlesList.tsx
Normal 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)
|
||||
@ -25,8 +25,8 @@ export function UserProfile({ profile, pubkey, articleCount }: UserProfileProps)
|
||||
<UserProfileHeader
|
||||
displayName={displayName}
|
||||
displayPubkey={displayPubkey}
|
||||
picture={profile.picture}
|
||||
nip05={profile.nip05}
|
||||
{...(profile.picture ? { picture: profile.picture } : {})}
|
||||
{...(profile.nip05 ? { nip05: profile.nip05 } : {})}
|
||||
/>
|
||||
{profile.about && <p className="text-gray-700 mt-2">{profile.about}</p>}
|
||||
{articleCount !== undefined && <ProfileStats articleCount={articleCount} />}
|
||||
|
||||
22
features/article-edit-delete.md
Normal file
22
features/article-edit-delete.md
Normal 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 d’impact 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 d’analyse**
|
||||
- Vérifier qu’un auteur connecté peut éditer puis voir son article mis à jour dans la liste.
|
||||
- Vérifier que la suppression publie l’événement et retire l’article de la liste locale.
|
||||
- Sur erreur de publication, message d’erreur affiché (aucun fallback silencieux).
|
||||
24
features/documentation-plan.md
Normal file
24
features/documentation-plan.md
Normal 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 d’analytics.
|
||||
|
||||
## 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 d’analytics.
|
||||
- Pas de fallback implicite; erreurs loguées et surfacées.
|
||||
- Respect lint/typage/accessibilité.
|
||||
@ -1,6 +1,6 @@
|
||||
# Résumé final du nettoyage et optimisation
|
||||
|
||||
**Date** : Décembre 2024
|
||||
**Date** : Décembre 2025 (addendum)
|
||||
|
||||
## ✅ Objectifs complétés
|
||||
|
||||
@ -28,9 +28,16 @@ Toutes les fonctions longues ont été extraites dans des modules dédiés :
|
||||
- Handler NostrConnect → `nostrconnectHandler.ts`
|
||||
|
||||
### 4. Correction des erreurs de lint
|
||||
- ✅ Aucune erreur de lint dans le code TypeScript
|
||||
- ✅ Aucune erreur de lint dans le code TypeScript (déc. 2025 : `npm run lint` OK)
|
||||
- ✅ Code propre et optimisé
|
||||
|
||||
## Addendum Déc 2025
|
||||
- Séries, critiques, agrégations zap : nouvelles sections UI/logic (`Series*`, `ArticleReviews`, `zapAggregation*`).
|
||||
- Upload médias NIP-95 (images/vidéos) avec validations de taille et type.
|
||||
- Stockage contenu privé chiffré en IndexedDB + helpers WebCrypto.
|
||||
- Respect strict `exactOptionalPropertyTypes`, fonctions < 40 lignes, fichiers < 250 lignes (refactors composants profil/articles, sélecteurs de séries).
|
||||
- Pas de tests ajoutés, pas d’analytics.
|
||||
|
||||
## Nouveaux fichiers créés (9 fichiers)
|
||||
|
||||
1. **`lib/nostrEventParsing.ts`** (40 lignes)
|
||||
|
||||
@ -1,6 +1,17 @@
|
||||
# Résumé des implémentations - Nostr Paywall
|
||||
|
||||
**Date** : Décembre 2024
|
||||
**Date** : Décembre 2025 (mise à jour)
|
||||
|
||||
## ✅ Mises à jour Déc 2025
|
||||
|
||||
- **Séries et critiques** : pages série dédiées (`pages/series/[id].tsx`), agrégats sponsoring/achats/remerciements, filtrage par série sur le profil, affichage des critiques avec formatage auteur/date et total des remerciements.
|
||||
- **NIP-95 médias** : upload images (≤5Mo PNG/JPG/JPEG/WebP) et vidéos (≤45Mo MP4/WebM) via `lib/nip95.ts`, insertion dans markdown et bannière.
|
||||
- **Agrégations zap** : `lib/zapAggregation.ts`, `lib/seriesAggregation.ts`, `lib/reviewAggregation.ts` pour cumuls sponsoring/achats/remerciements par auteur/série/article.
|
||||
- **Tags Nostr enrichis** : `lib/nostrTags.ts`, `lib/articlePublisher.ts`, `lib/articleMutations.ts` intègrent `kind_type`, `seriesId`, bannières, médias, catégories, site/type (science-fiction/research).
|
||||
- **Hooks et subscriptions** : `useArticles` / `useUserArticles` corrigent la gestion des unsub synchro (pas de `.then`), pas de fallback.
|
||||
- **Stockage privé** : contenu chiffré en IndexedDB (WebCrypto AES-GCM) via `lib/storage/indexedDB.ts` + helpers `lib/storage/cryptoHelpers.ts`.
|
||||
- **Qualité stricte** : `exactOptionalPropertyTypes` respecté (props optionnelles typées `| undefined`), fonctions < 40 lignes et fichiers < 250 lignes (refactors multiples).
|
||||
- **Navigation** : liens directs vers page série depuis cartes et liste d’articles, filtrage par série depuis la liste utilisateur.
|
||||
|
||||
## ✅ Implémentations complétées
|
||||
|
||||
@ -112,8 +123,8 @@
|
||||
### Technologies utilisées
|
||||
|
||||
- **Frontend** : Next.js 14, React, TypeScript, Tailwind CSS
|
||||
- **Nostr** : `nostr-tools` (v2.3.4)
|
||||
- **Lightning** : Alby/WebLN (`@getalby/sdk`)
|
||||
- **Nostr** : `nostr-tools` 1.17.0 (compat `signEvent`, `verifyEvent`)
|
||||
- **Lightning** : Alby/WebLN
|
||||
- **QR Code** : `react-qr-code`
|
||||
|
||||
## Fichiers créés/modifiés
|
||||
|
||||
14
features/notifications-scope.md
Normal file
14
features/notifications-scope.md
Normal 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.
|
||||
69
features/series-and-media-spec.md
Normal file
69
features/series-and-media-spec.md
Normal 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 l’article)
|
||||
|
||||
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 qu’articles) ; 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 d’articles 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 d’implé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).
|
||||
23
features/storage-encryption.md
Normal file
23
features/storage-encryption.md
Normal 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 won’t 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
71
features/technical-doc.md
Normal 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/`.
|
||||
20
fixKnowledge/2025-12-22-lint-type-fixes.md
Normal file
20
fixKnowledge/2025-12-22-lint-type-fixes.md
Normal 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.
|
||||
27
fixKnowledge/2025-12-23-optional-props-lint-types.md
Normal file
27
fixKnowledge/2025-12-23-optional-props-lint-types.md
Normal 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 d’action 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 d’articles et sur la page profil (navigation série).
|
||||
114
hooks/useArticleEditing.ts
Normal file
114
hooks/useArticleEditing.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { nostrService } from '@/lib/nostr'
|
||||
import type { Article } from '@/types/nostr'
|
||||
import { applyFiltersAndSort } from '@/lib/articleFiltering'
|
||||
@ -8,45 +8,36 @@ export function useArticles(searchQuery: string = '', filters: ArticleFilters |
|
||||
const [articles, setArticles] = useState<Article[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const hasArticlesRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
let unsubscribe: (() => void) | null = null
|
||||
|
||||
nostrService.subscribeToArticles(
|
||||
const unsubscribe = nostrService.subscribeToArticles(
|
||||
(article) => {
|
||||
setArticles((prev) => {
|
||||
// Avoid duplicates
|
||||
if (prev.some((a) => a.id === article.id)) {
|
||||
return prev
|
||||
}
|
||||
return [article, ...prev].sort((a, b) => b.createdAt - a.createdAt)
|
||||
const next = [article, ...prev].sort((a, b) => b.createdAt - a.createdAt)
|
||||
hasArticlesRef.current = next.length > 0
|
||||
return next
|
||||
})
|
||||
setLoading(false)
|
||||
},
|
||||
50
|
||||
).then((unsub) => {
|
||||
unsubscribe = unsub
|
||||
}).catch((e) => {
|
||||
console.error('Error subscribing to articles:', e)
|
||||
setError('Failed to load articles')
|
||||
setLoading(false)
|
||||
})
|
||||
)
|
||||
|
||||
// Timeout after 10 seconds
|
||||
const timeout = setTimeout(() => {
|
||||
setLoading(false)
|
||||
if (articles.length === 0) {
|
||||
if (!hasArticlesRef.current) {
|
||||
setError('No articles found')
|
||||
}
|
||||
}, 10000)
|
||||
|
||||
return () => {
|
||||
if (unsubscribe) {
|
||||
unsubscribe()
|
||||
}
|
||||
unsubscribe()
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}, [])
|
||||
@ -77,19 +68,21 @@ export function useArticles(searchQuery: string = '', filters: ArticleFilters |
|
||||
|
||||
// Apply filters and sorting
|
||||
const filteredArticles = useMemo(() => {
|
||||
if (!filters) {
|
||||
// If no filters, just apply search
|
||||
if (!searchQuery.trim()) {
|
||||
return articles
|
||||
}
|
||||
return applyFiltersAndSort(articles, searchQuery, {
|
||||
const effectiveFilters =
|
||||
filters ??
|
||||
({
|
||||
authorPubkey: null,
|
||||
minPrice: null,
|
||||
maxPrice: null,
|
||||
sortBy: 'newest',
|
||||
})
|
||||
category: 'all',
|
||||
} as const)
|
||||
|
||||
if (!filters && !searchQuery.trim()) {
|
||||
return articles
|
||||
}
|
||||
return applyFiltersAndSort(articles, searchQuery, filters)
|
||||
|
||||
return applyFiltersAndSort(articles, searchQuery, effectiveFilters)
|
||||
}, [articles, searchQuery, filters])
|
||||
|
||||
return {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { nostrService } from '@/lib/nostr'
|
||||
import type { Article } from '@/types/nostr'
|
||||
import { applyFiltersAndSort } from '@/lib/articleFiltering'
|
||||
@ -15,6 +15,7 @@ export function useUserArticles(
|
||||
const [articles, setArticles] = useState<Article[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const hasArticlesRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!userPubkey) {
|
||||
@ -25,34 +26,22 @@ export function useUserArticles(
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
let unsubscribe: (() => void) | null = null
|
||||
|
||||
// Subscribe to articles from this specific author
|
||||
nostrService
|
||||
.subscribeToArticles(
|
||||
(article) => {
|
||||
// Only include articles from this user
|
||||
if (article.pubkey === userPubkey) {
|
||||
setArticles((prev) => {
|
||||
// Avoid duplicates
|
||||
if (prev.some((a) => a.id === article.id)) {
|
||||
return prev
|
||||
}
|
||||
return [article, ...prev].sort((a, b) => b.createdAt - a.createdAt)
|
||||
})
|
||||
setLoading(false)
|
||||
}
|
||||
},
|
||||
100
|
||||
)
|
||||
.then((unsub) => {
|
||||
unsubscribe = unsub
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error('Error subscribing to user articles:', e)
|
||||
setError('Failed to load articles')
|
||||
setLoading(false)
|
||||
})
|
||||
const unsubscribe = nostrService.subscribeToArticles(
|
||||
(article) => {
|
||||
if (article.pubkey === userPubkey) {
|
||||
setArticles((prev) => {
|
||||
if (prev.some((a) => a.id === article.id)) {
|
||||
return prev
|
||||
}
|
||||
const next = [article, ...prev].sort((a, b) => b.createdAt - a.createdAt)
|
||||
hasArticlesRef.current = next.length > 0
|
||||
return next
|
||||
})
|
||||
setLoading(false)
|
||||
}
|
||||
},
|
||||
100
|
||||
)
|
||||
|
||||
// Timeout after 10 seconds
|
||||
const timeout = setTimeout(() => {
|
||||
@ -60,28 +49,28 @@ export function useUserArticles(
|
||||
}, 10000)
|
||||
|
||||
return () => {
|
||||
if (unsubscribe) {
|
||||
unsubscribe()
|
||||
}
|
||||
unsubscribe()
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}, [userPubkey])
|
||||
|
||||
// Apply filters and sorting
|
||||
const filteredArticles = useMemo(() => {
|
||||
if (!filters) {
|
||||
// If no filters, just apply search
|
||||
if (!searchQuery.trim()) {
|
||||
return articles
|
||||
}
|
||||
return applyFiltersAndSort(articles, searchQuery, {
|
||||
const effectiveFilters =
|
||||
filters ??
|
||||
({
|
||||
authorPubkey: null,
|
||||
minPrice: null,
|
||||
maxPrice: null,
|
||||
sortBy: 'newest',
|
||||
})
|
||||
category: 'all',
|
||||
} as const)
|
||||
|
||||
if (!filters && !searchQuery.trim()) {
|
||||
return articles
|
||||
}
|
||||
return applyFiltersAndSort(articles, searchQuery, filters)
|
||||
|
||||
return applyFiltersAndSort(articles, searchQuery, effectiveFilters)
|
||||
}, [articles, searchQuery, filters])
|
||||
|
||||
const loadArticleContent = async (articleId: string, authorPubkey: string) => {
|
||||
|
||||
@ -25,31 +25,15 @@ export async function createArticleInvoice(draft: ArticleDraft): Promise<AlbyInv
|
||||
export function createPreviewEvent(
|
||||
draft: ArticleDraft,
|
||||
invoice: AlbyInvoice,
|
||||
authorPresentationId?: string
|
||||
authorPresentationId?: string,
|
||||
extraTags: string[][] = []
|
||||
): {
|
||||
kind: 1
|
||||
created_at: number
|
||||
tags: string[][]
|
||||
content: string
|
||||
} {
|
||||
const tags: string[][] = [
|
||||
['title', draft.title],
|
||||
['preview', draft.preview],
|
||||
['zap', draft.zapAmount.toString()],
|
||||
['content-type', 'article'],
|
||||
['invoice', invoice.invoice],
|
||||
['payment_hash', invoice.paymentHash],
|
||||
]
|
||||
|
||||
// Add category if specified
|
||||
if (draft.category) {
|
||||
tags.push(['category', draft.category])
|
||||
}
|
||||
|
||||
// Add author presentation ID if provided
|
||||
if (authorPresentationId) {
|
||||
tags.push(['author_presentation_id', authorPresentationId])
|
||||
}
|
||||
const tags = buildPreviewTags(draft, invoice, authorPresentationId, extraTags)
|
||||
|
||||
return {
|
||||
kind: 1 as const,
|
||||
@ -58,3 +42,30 @@ export function createPreviewEvent(
|
||||
content: draft.preview,
|
||||
}
|
||||
}
|
||||
|
||||
function buildPreviewTags(
|
||||
draft: ArticleDraft,
|
||||
invoice: AlbyInvoice,
|
||||
authorPresentationId?: string,
|
||||
extraTags: string[][] = []
|
||||
): string[][] {
|
||||
const base: string[][] = [
|
||||
['title', draft.title],
|
||||
['preview', draft.preview],
|
||||
['zap', draft.zapAmount.toString()],
|
||||
['content-type', 'article'],
|
||||
['invoice', invoice.invoice],
|
||||
['payment_hash', invoice.paymentHash],
|
||||
]
|
||||
if (draft.category) {
|
||||
base.push(['category', draft.category])
|
||||
}
|
||||
if (authorPresentationId) {
|
||||
base.push(['author_presentation_id', authorPresentationId])
|
||||
}
|
||||
// Preserve any kind_type tags in extraTags if provided by caller
|
||||
if (extraTags.length > 0) {
|
||||
base.push(...extraTags)
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
226
lib/articleMutations.ts
Normal file
226
lib/articleMutations.ts
Normal 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
|
||||
@ -1,6 +1,7 @@
|
||||
import { nostrService } from './nostr'
|
||||
import type { AlbyInvoice } from '@/types/alby'
|
||||
import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
|
||||
import type { MediaRef } from '@/types/nostr'
|
||||
import {
|
||||
storePrivateContent,
|
||||
getStoredPrivateContent,
|
||||
@ -16,6 +17,9 @@ export interface ArticleDraft {
|
||||
content: string // Full content that will be sent as private message after payment
|
||||
zapAmount: number
|
||||
category?: 'science-fiction' | 'scientific-research'
|
||||
seriesId?: string
|
||||
bannerUrl?: string
|
||||
media?: MediaRef[]
|
||||
}
|
||||
|
||||
export interface AuthorPresentationDraft {
|
||||
@ -40,13 +44,15 @@ export interface PublishedArticle {
|
||||
* Handles publishing preview (public note), creating invoice, and storing full content for later private message
|
||||
*/
|
||||
export class ArticlePublisher {
|
||||
private readonly siteTag = process.env.NEXT_PUBLIC_SITE_TAG ?? 'zapwall4science'
|
||||
|
||||
private buildFailure(error?: string): PublishedArticle {
|
||||
return {
|
||||
const base: PublishedArticle = {
|
||||
articleId: '',
|
||||
previewEventId: '',
|
||||
success: false,
|
||||
error,
|
||||
}
|
||||
return error ? { ...base, error } : base
|
||||
}
|
||||
|
||||
private prepareAuthorKeys(authorPubkey: string, authorPrivateKey?: string): { success: boolean; error?: string } {
|
||||
@ -69,20 +75,41 @@ export class ArticlePublisher {
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
private isValidCategory(category?: ArticleDraft['category']): category is ArticleDraft['category'] {
|
||||
private isValidCategory(category?: ArticleDraft['category']): category is NonNullable<ArticleDraft['category']> {
|
||||
return category === 'science-fiction' || category === 'scientific-research'
|
||||
}
|
||||
|
||||
private async publishPreview(
|
||||
draft: ArticleDraft,
|
||||
invoice: AlbyInvoice,
|
||||
presentationId: string
|
||||
presentationId: string,
|
||||
extraTags?: string[][]
|
||||
): Promise<import('nostr-tools').Event | null> {
|
||||
const previewEvent = createPreviewEvent(draft, invoice, presentationId)
|
||||
const previewEvent = createPreviewEvent(draft, invoice, presentationId, extraTags)
|
||||
const publishedEvent = await nostrService.publishEvent(previewEvent)
|
||||
return publishedEvent ?? null
|
||||
}
|
||||
|
||||
private buildArticleExtraTags(draft: ArticleDraft, category: NonNullable<ArticleDraft['category']>): string[][] {
|
||||
const extraTags: string[][] = [
|
||||
['kind_type', 'article'],
|
||||
['site', this.siteTag],
|
||||
['category', category],
|
||||
]
|
||||
if (draft.seriesId) {
|
||||
extraTags.push(['series', draft.seriesId])
|
||||
}
|
||||
if (draft.bannerUrl) {
|
||||
extraTags.push(['banner', draft.bannerUrl])
|
||||
}
|
||||
if (draft.media && draft.media.length > 0) {
|
||||
draft.media.forEach((m) => {
|
||||
extraTags.push(['media', m.url, m.type])
|
||||
})
|
||||
}
|
||||
return extraTags
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish an article preview as a public note (kind:1)
|
||||
* Creates a Lightning invoice for the article
|
||||
@ -107,9 +134,11 @@ export class ArticlePublisher {
|
||||
if (!this.isValidCategory(draft.category)) {
|
||||
return this.buildFailure('Vous devez sélectionner une catégorie (science-fiction ou recherche scientifique).')
|
||||
}
|
||||
const category = draft.category
|
||||
|
||||
const invoice = await createArticleInvoice(draft)
|
||||
const publishedEvent = await this.publishPreview(draft, invoice, presentation.id)
|
||||
const extraTags = this.buildArticleExtraTags(draft, category)
|
||||
const publishedEvent = await this.publishPreview(draft, invoice, presentation.id, extraTags)
|
||||
if (!publishedEvent) {
|
||||
return this.buildFailure('Failed to publish article')
|
||||
}
|
||||
@ -123,6 +152,9 @@ export class ArticlePublisher {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing article by publishing a new event that references the original
|
||||
*/
|
||||
/**
|
||||
* Get stored private content for an article
|
||||
*/
|
||||
@ -147,7 +179,6 @@ export class ArticlePublisher {
|
||||
async sendPrivateContent(
|
||||
articleId: string,
|
||||
recipientPubkey: string,
|
||||
authorPubkey: string,
|
||||
authorPrivateKey: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
@ -202,16 +233,13 @@ export class ArticlePublisher {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get author presentation article by pubkey
|
||||
*/
|
||||
getAuthorPresentation(pubkey: string): Promise<import('@/types/nostr').AuthorPresentationArticle | null> {
|
||||
async getAuthorPresentation(pubkey: string): Promise<import('@/types/nostr').AuthorPresentationArticle | null> {
|
||||
try {
|
||||
const pool = nostrService.getPool()
|
||||
if (!pool) {
|
||||
return null
|
||||
}
|
||||
return fetchAuthorPresentationFromPool(pool as SimplePoolWithSub, pubkey)
|
||||
return await fetchAuthorPresentationFromPool(pool as SimplePoolWithSub, pubkey)
|
||||
} catch (error) {
|
||||
console.error('Error getting author presentation:', error)
|
||||
return null
|
||||
|
||||
47
lib/articleQueries.ts
Normal file
47
lib/articleQueries.ts
Normal 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?.()
|
||||
})
|
||||
}
|
||||
@ -16,6 +16,30 @@ interface StoredArticleData {
|
||||
|
||||
// Default expiration: 30 days in milliseconds
|
||||
const DEFAULT_EXPIRATION = 30 * 24 * 60 * 60 * 1000
|
||||
const MASTER_KEY_STORAGE_KEY = 'article_storage_master_key'
|
||||
|
||||
function getOrCreateMasterKey(): string {
|
||||
if (typeof window === 'undefined') {
|
||||
throw new Error('Storage encryption requires browser environment')
|
||||
}
|
||||
const existing = localStorage.getItem(MASTER_KEY_STORAGE_KEY)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
const keyBytes = crypto.getRandomValues(new Uint8Array(32))
|
||||
let binary = ''
|
||||
keyBytes.forEach((b) => {
|
||||
binary += String.fromCharCode(b)
|
||||
})
|
||||
const key = btoa(binary)
|
||||
localStorage.setItem(MASTER_KEY_STORAGE_KEY, key)
|
||||
return key
|
||||
}
|
||||
|
||||
function deriveSecret(articleId: string): string {
|
||||
const masterKey = getOrCreateMasterKey()
|
||||
return `${masterKey}:${articleId}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Store private content temporarily until payment is confirmed
|
||||
@ -31,6 +55,7 @@ export async function storePrivateContent(
|
||||
): Promise<void> {
|
||||
try {
|
||||
const key = `article_private_content_${articleId}`
|
||||
const secret = deriveSecret(articleId)
|
||||
const data: StoredArticleData = {
|
||||
content,
|
||||
authorPubkey,
|
||||
@ -47,7 +72,7 @@ export async function storePrivateContent(
|
||||
}
|
||||
|
||||
// Store with expiration (30 days)
|
||||
await storageService.set(key, data, DEFAULT_EXPIRATION)
|
||||
await storageService.set(key, data, secret, DEFAULT_EXPIRATION)
|
||||
} catch (error) {
|
||||
console.error('Error storing private content:', error)
|
||||
}
|
||||
@ -64,7 +89,8 @@ export async function getStoredPrivateContent(articleId: string): Promise<{
|
||||
} | null> {
|
||||
try {
|
||||
const key = `article_private_content_${articleId}`
|
||||
const data = await storageService.get<StoredArticleData>(key)
|
||||
const secret = deriveSecret(articleId)
|
||||
const data = await storageService.get<StoredArticleData>(key, secret)
|
||||
|
||||
if (!data) {
|
||||
return null
|
||||
@ -73,14 +99,16 @@ export async function getStoredPrivateContent(articleId: string): Promise<{
|
||||
return {
|
||||
content: data.content,
|
||||
authorPubkey: data.authorPubkey,
|
||||
invoice: data.invoice
|
||||
...(data.invoice
|
||||
? {
|
||||
invoice: data.invoice.invoice,
|
||||
paymentHash: data.invoice.paymentHash,
|
||||
amount: data.invoice.amount,
|
||||
expiresAt: data.invoice.expiresAt,
|
||||
invoice: {
|
||||
invoice: data.invoice.invoice,
|
||||
paymentHash: data.invoice.paymentHash,
|
||||
amount: data.invoice.amount,
|
||||
expiresAt: data.invoice.expiresAt,
|
||||
} as AlbyInvoice,
|
||||
}
|
||||
: undefined,
|
||||
: {}),
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error retrieving private content:', error)
|
||||
|
||||
@ -102,13 +102,16 @@ function renderParagraphOrBreak(line: string, index: number, elements: JSX.Eleme
|
||||
elements.push(<p key={index} className="mb-4 text-gray-700">{line}</p>)
|
||||
return
|
||||
}
|
||||
if (elements.length > 0 && elements[elements.length - 1].type !== 'br') {
|
||||
elements.push(<br key={`br-${index}`} />)
|
||||
if (elements.length > 0) {
|
||||
const last = elements[elements.length - 1] as { type?: unknown }
|
||||
if (last?.type !== 'br') {
|
||||
elements.push(<br key={`br-${index}`} />)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleCodeBlock(
|
||||
line: string,
|
||||
_line: string,
|
||||
index: number,
|
||||
state: RenderState,
|
||||
elements: JSX.Element[]
|
||||
@ -172,12 +175,17 @@ function renderLink(line: string, index: number, elements: JSX.Element[]): void
|
||||
let match
|
||||
|
||||
while ((match = linkRegex.exec(line)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
if (!match[2]) {
|
||||
continue
|
||||
}
|
||||
if (!match[1] || match.index > lastIndex) {
|
||||
parts.push(line.substring(lastIndex, match.index))
|
||||
}
|
||||
const href = match[2]
|
||||
const isExternal = href.startsWith('http')
|
||||
parts.push(createLinkElement(match[1], href, `link-${index}-${match.index}`, isExternal))
|
||||
if (match[1]) {
|
||||
parts.push(createLinkElement(match[1], href, `link-${index}-${match.index}`, isExternal))
|
||||
}
|
||||
lastIndex = match.index + match[0].length
|
||||
}
|
||||
|
||||
|
||||
63
lib/nip95.ts
Normal file
63
lib/nip95.ts
Normal 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 }
|
||||
}
|
||||
14
lib/nostr.ts
14
lib/nostr.ts
@ -60,10 +60,16 @@ class NostrService {
|
||||
throw new Error('Private key not set or pool not initialized')
|
||||
}
|
||||
|
||||
const event = {
|
||||
const unsignedEvent = {
|
||||
pubkey: this.publicKey ?? '',
|
||||
...eventTemplate,
|
||||
id: getEventHash(eventTemplate),
|
||||
sig: signEvent(eventTemplate, this.privateKey),
|
||||
created_at: eventTemplate.created_at ?? Math.floor(Date.now() / 1000),
|
||||
}
|
||||
|
||||
const event = {
|
||||
...unsignedEvent,
|
||||
id: getEventHash(unsignedEvent),
|
||||
sig: signEvent(unsignedEvent, this.privateKey),
|
||||
} as Event
|
||||
|
||||
try {
|
||||
@ -190,7 +196,7 @@ class NostrService {
|
||||
userPubkey?: string
|
||||
): Promise<boolean> {
|
||||
if (!this.publicKey || !this.pool) {
|
||||
return false
|
||||
return Promise.resolve(false)
|
||||
}
|
||||
|
||||
// Use provided userPubkey or fall back to current public key
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { Event } from 'nostr-tools'
|
||||
import type { Article } from '@/types/nostr'
|
||||
import type { Article, KindType, MediaRef, Review, Series } from '@/types/nostr'
|
||||
|
||||
/**
|
||||
* Parse article metadata from Nostr event
|
||||
@ -7,6 +7,9 @@ import type { Article } from '@/types/nostr'
|
||||
export function parseArticleFromEvent(event: Event): Article | null {
|
||||
try {
|
||||
const tags = extractTags(event)
|
||||
if (tags.kindType && tags.kindType !== 'article') {
|
||||
return null
|
||||
}
|
||||
const { previewContent } = getPreviewContent(event.content, tags.preview)
|
||||
return buildArticle(event, tags, previewContent)
|
||||
} catch (e) {
|
||||
@ -15,11 +18,83 @@ export function parseArticleFromEvent(event: Event): Article | null {
|
||||
}
|
||||
}
|
||||
|
||||
export function parseSeriesFromEvent(event: Event): Series | null {
|
||||
try {
|
||||
const tags = extractTags(event)
|
||||
if (tags.kindType && tags.kindType !== 'series') {
|
||||
return null
|
||||
}
|
||||
if (!tags.title || !tags.description) {
|
||||
return null
|
||||
}
|
||||
const series: Series = {
|
||||
id: event.id,
|
||||
pubkey: event.pubkey,
|
||||
title: tags.title,
|
||||
description: tags.description,
|
||||
preview: tags.preview ?? event.content.substring(0, 200),
|
||||
...(tags.category ? { category: tags.category } : { category: 'science-fiction' }),
|
||||
...(tags.coverUrl ? { coverUrl: tags.coverUrl } : {}),
|
||||
}
|
||||
if (tags.kindType) {
|
||||
series.kindType = tags.kindType
|
||||
}
|
||||
return series
|
||||
} catch (e) {
|
||||
console.error('Error parsing series:', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function parseReviewFromEvent(event: Event): Review | null {
|
||||
try {
|
||||
const tags = extractTags(event)
|
||||
if (tags.kindType && tags.kindType !== 'review') {
|
||||
return null
|
||||
}
|
||||
const articleId = tags.articleId
|
||||
const reviewer = tags.reviewerPubkey
|
||||
if (!articleId || !reviewer) {
|
||||
return null
|
||||
}
|
||||
const review: Review = {
|
||||
id: event.id,
|
||||
articleId,
|
||||
authorPubkey: tags.author ?? event.pubkey,
|
||||
reviewerPubkey: reviewer,
|
||||
content: event.content,
|
||||
createdAt: event.created_at,
|
||||
...(tags.title ? { title: tags.title } : {}),
|
||||
}
|
||||
if (tags.kindType) {
|
||||
review.kindType = tags.kindType
|
||||
}
|
||||
return review
|
||||
} catch (e) {
|
||||
console.error('Error parsing review:', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function extractTags(event: Event) {
|
||||
const findTag = (key: string) => event.tags.find((tag) => tag[0] === key)?.[1]
|
||||
const mediaTags = event.tags.filter((tag) => tag[0] === 'media')
|
||||
const media: MediaRef[] =
|
||||
mediaTags
|
||||
.map((tag) => {
|
||||
const url = tag[1]
|
||||
const type = tag[2] === 'video' ? 'video' : 'image'
|
||||
if (!url) {
|
||||
return null
|
||||
}
|
||||
return { url, type }
|
||||
})
|
||||
.filter(Boolean) as MediaRef[]
|
||||
|
||||
return {
|
||||
title: findTag('title') ?? 'Untitled',
|
||||
preview: findTag('preview'),
|
||||
description: findTag('description'),
|
||||
zapAmount: parseInt(findTag('zap') ?? '800', 10),
|
||||
invoice: findTag('invoice'),
|
||||
paymentHash: findTag('payment_hash'),
|
||||
@ -28,6 +103,14 @@ function extractTags(event: Event) {
|
||||
mainnetAddress: findTag('mainnet_address'),
|
||||
totalSponsoring: parseInt(findTag('total_sponsoring') ?? '0', 10),
|
||||
authorPresentationId: findTag('author_presentation_id'),
|
||||
seriesId: findTag('series'),
|
||||
bannerUrl: findTag('banner'),
|
||||
coverUrl: findTag('cover'),
|
||||
media,
|
||||
kindType: findTag('kind_type') as KindType | undefined,
|
||||
articleId: findTag('article'),
|
||||
reviewerPubkey: findTag('reviewer'),
|
||||
author: findTag('author'),
|
||||
}
|
||||
}
|
||||
|
||||
@ -47,12 +130,16 @@ function buildArticle(event: Event, tags: ReturnType<typeof extractTags>, previe
|
||||
createdAt: event.created_at,
|
||||
zapAmount: tags.zapAmount,
|
||||
paid: false,
|
||||
invoice: tags.invoice,
|
||||
paymentHash: tags.paymentHash,
|
||||
category: tags.category,
|
||||
isPresentation: tags.isPresentation,
|
||||
mainnetAddress: tags.mainnetAddress,
|
||||
totalSponsoring: tags.totalSponsoring,
|
||||
authorPresentationId: tags.authorPresentationId,
|
||||
...(tags.invoice ? { invoice: tags.invoice } : {}),
|
||||
...(tags.paymentHash ? { paymentHash: tags.paymentHash } : {}),
|
||||
...(tags.category ? { category: tags.category } : {}),
|
||||
...(tags.isPresentation ? { isPresentation: tags.isPresentation } : {}),
|
||||
...(tags.mainnetAddress ? { mainnetAddress: tags.mainnetAddress } : {}),
|
||||
...(tags.totalSponsoring ? { totalSponsoring: tags.totalSponsoring } : {}),
|
||||
...(tags.authorPresentationId ? { authorPresentationId: tags.authorPresentationId } : {}),
|
||||
...(tags.seriesId ? { seriesId: tags.seriesId } : {}),
|
||||
...(tags.bannerUrl ? { bannerUrl: tags.bannerUrl } : {}),
|
||||
...(tags.media.length ? { media: tags.media } : {}),
|
||||
...(tags.kindType ? { kindType: tags.kindType } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,7 +14,17 @@ export class NostrRemoteSigner {
|
||||
*/
|
||||
signEvent(eventTemplate: EventTemplate): Event | null {
|
||||
// Get the event hash first
|
||||
const eventId = getEventHash(eventTemplate)
|
||||
const pubkey = nostrService.getPublicKey()
|
||||
if (!pubkey) {
|
||||
throw new Error('Public key required for signing. Please connect a Nostr wallet.')
|
||||
}
|
||||
|
||||
const unsignedEvent = {
|
||||
pubkey,
|
||||
...eventTemplate,
|
||||
created_at: eventTemplate.created_at ?? Math.floor(Date.now() / 1000),
|
||||
}
|
||||
const eventId = getEventHash(unsignedEvent)
|
||||
|
||||
// Try to get private key from nostrService (if available from NostrConnect)
|
||||
const privateKey = nostrService.getPrivateKey()
|
||||
@ -28,9 +38,9 @@ export class NostrRemoteSigner {
|
||||
}
|
||||
|
||||
const event = {
|
||||
...eventTemplate,
|
||||
...unsignedEvent,
|
||||
id: eventId,
|
||||
sig: signEvent(eventTemplate, privateKey),
|
||||
sig: signEvent(unsignedEvent, privateKey),
|
||||
} as Event
|
||||
|
||||
return event
|
||||
|
||||
102
lib/nostrTags.ts
Normal file
102
lib/nostrTags.ts
Normal 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
|
||||
}
|
||||
@ -38,7 +38,7 @@ export function checkZapReceipt(
|
||||
userPubkey: string
|
||||
): Promise<boolean> {
|
||||
if (!pool) {
|
||||
return false
|
||||
return Promise.resolve(false)
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
|
||||
@ -49,7 +49,7 @@ export class NostrConnectService {
|
||||
}
|
||||
|
||||
private cleanupPopup(popup: Window | null, checkClosed: number, messageHandler: (event: MessageEvent) => void) {
|
||||
clearInterval(checkClosed)
|
||||
window.clearInterval(checkClosed)
|
||||
window.removeEventListener('message', messageHandler)
|
||||
if (popup && !popup.closed) {
|
||||
popup.close()
|
||||
@ -100,8 +100,7 @@ export class NostrConnectService {
|
||||
return
|
||||
}
|
||||
|
||||
const messageHandler: (event: MessageEvent) => void
|
||||
const checkClosed = setInterval(() => {
|
||||
const checkClosed = window.setInterval(() => {
|
||||
if (popup.closed) {
|
||||
this.cleanupPopup(popup, checkClosed, messageHandler)
|
||||
if (!this.state.connected) {
|
||||
@ -110,17 +109,14 @@ export class NostrConnectService {
|
||||
}
|
||||
}, 1000)
|
||||
|
||||
const cleanup = () => {
|
||||
this.cleanupPopup(popup, checkClosed, messageHandler)
|
||||
}
|
||||
|
||||
messageHandler = this.createMessageHandler(resolve, reject, cleanup)
|
||||
const cleanup = () => this.cleanupPopup(popup, checkClosed, messageHandler)
|
||||
const messageHandler = this.createMessageHandler(resolve, reject, cleanup)
|
||||
|
||||
window.addEventListener('message', messageHandler)
|
||||
})
|
||||
}
|
||||
|
||||
disconnect(): Promise<void> {
|
||||
disconnect(): void {
|
||||
this.state = {
|
||||
connected: false,
|
||||
pubkey: null,
|
||||
|
||||
@ -41,8 +41,8 @@ async function buildPaymentNotification(event: Event, userPubkey: string): Promi
|
||||
: `You received ${paymentInfo.amount} sats`,
|
||||
timestamp: event.created_at,
|
||||
read: false,
|
||||
articleId: paymentInfo.articleId ?? undefined,
|
||||
articleTitle,
|
||||
...(paymentInfo.articleId ? { articleId: paymentInfo.articleId } : {}),
|
||||
...(articleTitle ? { articleTitle } : {}),
|
||||
amount: paymentInfo.amount,
|
||||
fromPubkey: paymentInfo.payer,
|
||||
}
|
||||
|
||||
@ -53,7 +53,7 @@ export class PaymentService {
|
||||
* Check if payment for an article has been completed
|
||||
*/
|
||||
async checkArticlePayment(
|
||||
paymentHash: string,
|
||||
_paymentHash: string,
|
||||
articleId: string,
|
||||
articlePubkey: string,
|
||||
amount: number,
|
||||
|
||||
@ -69,12 +69,7 @@ async function sendPrivateContentAfterPayment(
|
||||
const authorPrivateKey = nostrService.getPrivateKey()
|
||||
|
||||
if (authorPrivateKey) {
|
||||
const sent = await articlePublisher.sendPrivateContent(
|
||||
articleId,
|
||||
recipientPubkey,
|
||||
storedContent.authorPubkey,
|
||||
authorPrivateKey
|
||||
)
|
||||
const sent = await articlePublisher.sendPrivateContent(articleId, recipientPubkey, authorPrivateKey)
|
||||
|
||||
if (sent) {
|
||||
// Private content sent successfully
|
||||
|
||||
12
lib/reviewAggregation.ts
Normal file
12
lib/reviewAggregation.ts
Normal 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
47
lib/reviews.ts
Normal 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
20
lib/seriesAggregation.ts
Normal 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
86
lib/seriesQueries.ts
Normal 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?.()
|
||||
})
|
||||
}
|
||||
53
lib/storage/cryptoHelpers.ts
Normal file
53
lib/storage/cryptoHelpers.ts
Normal 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
|
||||
}
|
||||
@ -1,10 +1,12 @@
|
||||
import { decryptPayload, encryptPayload, type EncryptedPayload } from './cryptoHelpers'
|
||||
|
||||
const DB_NAME = 'nostr_paywall'
|
||||
const DB_VERSION = 1
|
||||
const STORE_NAME = 'article_content'
|
||||
|
||||
interface DBData {
|
||||
id: string
|
||||
data: unknown
|
||||
data: EncryptedPayload
|
||||
createdAt: number
|
||||
expiresAt?: number
|
||||
}
|
||||
@ -72,7 +74,7 @@ export class IndexedDBStorage {
|
||||
/**
|
||||
* Store data in IndexedDB
|
||||
*/
|
||||
async set(key: string, value: unknown, expiresIn?: number): Promise<void> {
|
||||
async set(key: string, value: unknown, secret: string, expiresIn?: number): Promise<void> {
|
||||
try {
|
||||
await this.init()
|
||||
|
||||
@ -80,12 +82,13 @@ export class IndexedDBStorage {
|
||||
throw new Error('Database not initialized')
|
||||
}
|
||||
|
||||
const encrypted = await encryptPayload(secret, value)
|
||||
const now = Date.now()
|
||||
const data: DBData = {
|
||||
id: key,
|
||||
data: value,
|
||||
data: encrypted,
|
||||
createdAt: now,
|
||||
expiresAt: expiresIn ? now + expiresIn : undefined,
|
||||
...(expiresIn ? { expiresAt: now + expiresIn } : {}),
|
||||
}
|
||||
|
||||
const db = this.db
|
||||
@ -110,7 +113,7 @@ export class IndexedDBStorage {
|
||||
/**
|
||||
* Get data from IndexedDB
|
||||
*/
|
||||
async get<T = unknown>(key: string): Promise<T | null> {
|
||||
async get<T = unknown>(key: string, secret: string): Promise<T | null> {
|
||||
try {
|
||||
await this.init()
|
||||
|
||||
@ -118,14 +121,14 @@ export class IndexedDBStorage {
|
||||
throw new Error('Database not initialized')
|
||||
}
|
||||
|
||||
return this.readValue<T>(key)
|
||||
return this.readValue<T>(key, secret)
|
||||
} catch (error) {
|
||||
console.error('Error getting from IndexedDB:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private readValue<T>(key: string): Promise<T | null> {
|
||||
private readValue<T>(key: string, secret: string): Promise<T | null> {
|
||||
const db = this.db
|
||||
if (!db) {
|
||||
throw new Error('Database not initialized')
|
||||
@ -150,7 +153,12 @@ export class IndexedDBStorage {
|
||||
return
|
||||
}
|
||||
|
||||
resolve(result.data as T)
|
||||
decryptPayload<T>(secret, result.data)
|
||||
.then((value) => resolve(value))
|
||||
.catch((error) => {
|
||||
console.error('Error decrypting from IndexedDB:', error)
|
||||
resolve(null)
|
||||
})
|
||||
}
|
||||
|
||||
request.onerror = () => reject(new Error(`Failed to get data: ${request.error}`))
|
||||
|
||||
92
lib/zapAggregation.ts
Normal file
92
lib/zapAggregation.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
import { Event, verifyEvent } from 'nostr-tools'
|
||||
import { Event, validateEvent, verifySignature } from 'nostr-tools'
|
||||
|
||||
/**
|
||||
* Service for verifying zap receipts and their signatures
|
||||
@ -9,7 +9,7 @@ export class ZapVerificationService {
|
||||
*/
|
||||
verifyZapReceiptSignature(event: Event): boolean {
|
||||
try {
|
||||
return verifyEvent(event)
|
||||
return validateEvent(event) && verifySignature(event)
|
||||
} catch (error) {
|
||||
console.error('Error verifying zap receipt signature:', error)
|
||||
return false
|
||||
@ -23,7 +23,7 @@ export class ZapVerificationService {
|
||||
zapReceipt: Event,
|
||||
articleId: string,
|
||||
articlePubkey: string,
|
||||
userPubkey: string,
|
||||
_userPubkey: string,
|
||||
expectedAmount: number
|
||||
): boolean {
|
||||
if (!this.verifyZapReceiptSignature(zapReceipt)) {
|
||||
@ -62,7 +62,7 @@ export class ZapVerificationService {
|
||||
|
||||
return {
|
||||
amount: amountInSats,
|
||||
recipient: recipientTag[1],
|
||||
recipient: recipientTag?.[1] ?? '',
|
||||
articleId: eventTag?.[1] ?? null,
|
||||
payer: zapReceipt.pubkey,
|
||||
}
|
||||
|
||||
59
package-lock.json
generated
59
package-lock.json
generated
@ -9,7 +9,7 @@
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"next": "^14.0.4",
|
||||
"nostr-tools": "^2.3.4",
|
||||
"nostr-tools": "1.17.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-qr-code": "^2.0.18"
|
||||
@ -434,33 +434,21 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/ciphers": {
|
||||
"version": "0.5.3",
|
||||
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.5.3.tgz",
|
||||
"integrity": "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==",
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.2.0.tgz",
|
||||
"integrity": "sha512-6YBxJDAapHSdd3bLDv6x2wRPwq4QFMUaB3HvljNBUTThDd12eSm7/3F+2lnfzx2jvM+S6Nsy0jEt9QbPqSwqRw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/curves": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz",
|
||||
"integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==",
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.1.0.tgz",
|
||||
"integrity": "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "1.3.2"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/curves/node_modules/@noble/hashes": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz",
|
||||
"integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
"@noble/hashes": "1.3.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
@ -577,18 +565,6 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/bip32/node_modules/@noble/curves": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.1.0.tgz",
|
||||
"integrity": "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "1.3.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/bip39": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.2.1.tgz",
|
||||
@ -4238,18 +4214,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nostr-tools": {
|
||||
"version": "2.19.4",
|
||||
"resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.19.4.tgz",
|
||||
"integrity": "sha512-qVLfoTpZegNYRJo5j+Oi6RPu0AwLP6jcvzcB3ySMnIT5DrAGNXfs5HNBspB/2HiGfH3GY+v6yXkTtcKSBQZwSg==",
|
||||
"version": "1.17.0",
|
||||
"resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-1.17.0.tgz",
|
||||
"integrity": "sha512-LZmR8GEWKZeElbFV5Xte75dOeE9EFUW/QLI1Ncn3JKn0kFddDKEfBbFN8Mu4TMs+L4HR/WTPha2l+PPuRnJcMw==",
|
||||
"license": "Unlicense",
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "^0.5.1",
|
||||
"@noble/curves": "1.2.0",
|
||||
"@noble/ciphers": "0.2.0",
|
||||
"@noble/curves": "1.1.0",
|
||||
"@noble/hashes": "1.3.1",
|
||||
"@scure/base": "1.1.1",
|
||||
"@scure/bip32": "1.3.1",
|
||||
"@scure/bip39": "1.2.1",
|
||||
"nostr-wasm": "0.1.0"
|
||||
"@scure/bip39": "1.2.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=5.0.0"
|
||||
@ -4260,12 +4235,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/nostr-wasm": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz",
|
||||
"integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "^14.0.4",
|
||||
"nostr-tools": "^2.3.4",
|
||||
"nostr-tools": "1.17.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-qr-code": "^2.0.18"
|
||||
|
||||
@ -18,10 +18,6 @@ function useUserProfileData(currentPubkey: string | null) {
|
||||
|
||||
const createMinimalProfile = (): NostrProfile => ({
|
||||
pubkey: currentPubkey,
|
||||
name: undefined,
|
||||
about: undefined,
|
||||
picture: undefined,
|
||||
nip05: undefined,
|
||||
})
|
||||
|
||||
const load = async () => {
|
||||
@ -62,6 +58,7 @@ function useProfileController() {
|
||||
sortBy: 'newest',
|
||||
category: 'all',
|
||||
})
|
||||
const [selectedSeriesId, setSelectedSeriesId] = useState<string | undefined>(undefined)
|
||||
|
||||
useRedirectWhenDisconnected(connected, currentPubkey ?? null)
|
||||
const { profile, loadingProfile } = useUserProfileData(currentPubkey ?? null)
|
||||
@ -86,6 +83,8 @@ function useProfileController() {
|
||||
loadArticleContent,
|
||||
profile,
|
||||
loadingProfile,
|
||||
selectedSeriesId,
|
||||
onSelectSeries: setSelectedSeriesId,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -2,6 +2,9 @@ import Head from 'next/head'
|
||||
import { useRouter } from 'next/router'
|
||||
import { ConnectButton } from '@/components/ConnectButton'
|
||||
import { ArticleEditor } from '@/components/ArticleEditor'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNostrConnect } from '@/hooks/useNostrConnect'
|
||||
import { getSeriesByAuthor } from '@/lib/seriesQueries'
|
||||
|
||||
function PublishHeader() {
|
||||
return (
|
||||
@ -30,6 +33,8 @@ function PublishHero({ onBack }: { onBack: () => void }) {
|
||||
|
||||
export default function PublishPage() {
|
||||
const router = useRouter()
|
||||
const { pubkey } = useNostrConnect()
|
||||
const [seriesOptions, setSeriesOptions] = useState<{ id: string; title: string }[]>([])
|
||||
|
||||
const handlePublishSuccess = () => {
|
||||
setTimeout(() => {
|
||||
@ -37,28 +42,54 @@ export default function PublishPage() {
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!pubkey) {
|
||||
setSeriesOptions([])
|
||||
return
|
||||
}
|
||||
const load = async () => {
|
||||
const items = await getSeriesByAuthor(pubkey)
|
||||
setSeriesOptions(items.map((s) => ({ id: s.id, title: s.title })))
|
||||
}
|
||||
void load()
|
||||
}, [pubkey])
|
||||
|
||||
return (
|
||||
<>
|
||||
<PublishHeader />
|
||||
|
||||
<main className="min-h-screen bg-gray-50">
|
||||
<header className="bg-white shadow-sm">
|
||||
<div className="max-w-4xl mx-auto px-4 py-4 flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold text-gray-900">zapwall4Science</h1>
|
||||
<ConnectButton />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="max-w-4xl mx-auto px-4 py-8">
|
||||
<PublishHero
|
||||
onBack={() => {
|
||||
void router.push('/')
|
||||
}}
|
||||
/>
|
||||
|
||||
<ArticleEditor onPublishSuccess={handlePublishSuccess} />
|
||||
</div>
|
||||
</main>
|
||||
<PublishLayout
|
||||
onBack={() => {
|
||||
void router.push('/')
|
||||
}}
|
||||
onPublishSuccess={handlePublishSuccess}
|
||||
seriesOptions={seriesOptions}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function PublishLayout({
|
||||
onBack,
|
||||
onPublishSuccess,
|
||||
seriesOptions,
|
||||
}: {
|
||||
onBack: () => void
|
||||
onPublishSuccess: () => void
|
||||
seriesOptions: { id: string; title: string }[]
|
||||
}) {
|
||||
return (
|
||||
<main className="min-h-screen bg-gray-50">
|
||||
<header className="bg-white shadow-sm">
|
||||
<div className="max-w-4xl mx-auto px-4 py-4 flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold text-gray-900">zapwall4Science</h1>
|
||||
<ConnectButton />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="max-w-4xl mx-auto px-4 py-8">
|
||||
<PublishHero onBack={onBack} />
|
||||
<ArticleEditor onPublishSuccess={onPublishSuccess} seriesOptions={seriesOptions} />
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
126
pages/series/[id].tsx
Normal file
126
pages/series/[id].tsx
Normal 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 }
|
||||
}
|
||||
@ -1,21 +1,13 @@
|
||||
import type { Event, Filter } from 'nostr-tools'
|
||||
import { SimplePool } from 'nostr-tools'
|
||||
|
||||
/**
|
||||
* Extended SimplePool interface that includes the sub method
|
||||
* The sub method exists in nostr-tools but is not properly typed in the TypeScript definitions
|
||||
* Alias for SimplePool with typed sub method from nostr-tools definitions.
|
||||
* Using the existing type avoids compatibility issues while keeping explicit intent.
|
||||
*/
|
||||
export interface SimplePoolWithSub extends SimplePool {
|
||||
sub(relays: string[], filters: Filter[]): {
|
||||
on(event: 'event', callback: (event: Event) => void): void
|
||||
on(event: 'eose', callback: () => void): void
|
||||
unsub(): void
|
||||
}
|
||||
sub: SimplePool['sub']
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if a SimplePool has the sub method
|
||||
*/
|
||||
export function hasSubMethod(pool: SimplePool): pool is SimplePoolWithSub {
|
||||
return typeof (pool as SimplePoolWithSub).sub === 'function'
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Event, EventTemplate } from 'nostr-tools'
|
||||
import type { Event } from 'nostr-tools'
|
||||
|
||||
export interface NostrProfile {
|
||||
pubkey: string
|
||||
@ -10,6 +10,19 @@ export interface NostrProfile {
|
||||
|
||||
export type ArticleCategory = 'science-fiction' | 'scientific-research' | 'author-presentation'
|
||||
|
||||
export type KindType =
|
||||
| 'article'
|
||||
| 'series'
|
||||
| 'review'
|
||||
| 'purchase'
|
||||
| 'review_tip'
|
||||
| 'sponsoring'
|
||||
|
||||
export interface MediaRef {
|
||||
url: string
|
||||
type: 'image' | 'video'
|
||||
}
|
||||
|
||||
export interface Article {
|
||||
id: string
|
||||
pubkey: string
|
||||
@ -26,6 +39,10 @@ export interface Article {
|
||||
mainnetAddress?: string // Bitcoin mainnet address for sponsoring (presentation articles only)
|
||||
totalSponsoring?: number // Total sponsoring received in sats (presentation articles only)
|
||||
authorPresentationId?: string // ID of the author's presentation article (for standard articles)
|
||||
seriesId?: string // Series event id
|
||||
bannerUrl?: string // NIP-95 banner
|
||||
media?: MediaRef[] // Embedded media (NIP-95)
|
||||
kindType?: KindType
|
||||
}
|
||||
|
||||
export interface AuthorPresentationArticle extends Article {
|
||||
@ -35,6 +52,32 @@ export interface AuthorPresentationArticle extends Article {
|
||||
totalSponsoring: number
|
||||
}
|
||||
|
||||
export interface Series {
|
||||
id: string
|
||||
pubkey: string
|
||||
title: string
|
||||
description: string
|
||||
preview: string
|
||||
coverUrl?: string
|
||||
category: ArticleCategory
|
||||
totalSponsoring?: number
|
||||
totalPayments?: number
|
||||
kindType?: KindType
|
||||
}
|
||||
|
||||
export interface Review {
|
||||
id: string
|
||||
articleId: string
|
||||
authorPubkey: string
|
||||
reviewerPubkey: string
|
||||
content: string
|
||||
createdAt: number
|
||||
title?: string
|
||||
rewarded?: boolean
|
||||
rewardAmount?: number
|
||||
kindType?: KindType
|
||||
}
|
||||
|
||||
export interface ZapRequest {
|
||||
event: Event
|
||||
amount: number
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user