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