diff --git a/.cursor/rules/quality.mdc b/.cursor/rules/quality.mdc index 557ac57..1965a57 100644 --- a/.cursor/rules/quality.mdc +++ b/.cursor/rules/quality.mdc @@ -223,3 +223,18 @@ Le code final doit : ## Conclusion Ces consignes constituent un cadre de production strict. Elles imposent une analyse préalable via un arbre des fichiers, un typage TypeScript sans contournement, une non-duplication systématique, une architecture fondée sur des abstractions pertinentes, l’usage raisonné de patterns, une journalisation exhaustive des erreurs et un refus explicite des fallbacks implicites. Appliquées rigoureusement, elles conduisent à un code TypeScript robuste, évolutif et cohérent avec un référentiel de qualité de haut niveau. + +## Analytics + +* Ne pas mettre d'analytics +* Statistiques profil : pas de vues/paiements/revenus par article, agrégats et affichage + +## Cache + +* Pas de mémorisation, pas de cache + +## Accessibilité + +* ARIA +* clavier +* contraste diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..53c2420 --- /dev/null +++ b/CONTRIBUTING.md @@ -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. diff --git a/components/AlbyInstaller.tsx b/components/AlbyInstaller.tsx index 96f624d..ec79d2b 100644 --- a/components/AlbyInstaller.tsx +++ b/components/AlbyInstaller.tsx @@ -22,7 +22,12 @@ function InfoIcon() { ) } -function InstallerActions({ onInstalled, markInstalled }: { onInstalled?: () => void; markInstalled: () => void }) { +interface InstallerActionsProps { + onInstalled?: () => void + markInstalled: () => void +} + +function InstallerActions({ onInstalled, markInstalled }: InstallerActionsProps) { const connect = useCallback(() => { const alby = getAlbyService() void alby.enable().then(() => { @@ -55,14 +60,17 @@ function InstallerActions({ onInstalled, markInstalled }: { onInstalled?: () => ) } -function InstallerBody({ onInstalled, markInstalled }: { onInstalled?: () => void; markInstalled: () => void }) { +function InstallerBody({ onInstalled, markInstalled }: InstallerActionsProps) { return (

Alby Extension Required

To make Lightning payments, please install the Alby browser extension.

- +

Alby is a Lightning wallet that enables instant Bitcoin payments in your browser.

@@ -113,7 +121,10 @@ export function AlbyInstaller({ onInstalled }: AlbyInstallerProps) {
- +
) diff --git a/components/ArticleEditor.tsx b/components/ArticleEditor.tsx index f383f5a..657d0d6 100644 --- a/components/ArticleEditor.tsx +++ b/components/ArticleEditor.tsx @@ -7,6 +7,8 @@ import { ArticleEditorForm } from './ArticleEditorForm' interface ArticleEditorProps { onPublishSuccess?: (articleId: string) => void onCancel?: () => void + seriesOptions?: { id: string; title: string }[] + onSelectSeries?: ((seriesId: string | undefined) => void) | undefined } function NotConnectedMessage() { @@ -26,7 +28,7 @@ function SuccessMessage() { ) } -export function ArticleEditor({ onPublishSuccess, onCancel }: ArticleEditorProps) { +export function ArticleEditor({ onPublishSuccess, onCancel, seriesOptions, onSelectSeries }: ArticleEditorProps) { const { connected, pubkey } = useNostrConnect() const { loading, error, success, publishArticle } = useArticlePublishing(pubkey ?? null) const [draft, setDraft] = useState({ @@ -34,16 +36,10 @@ export function ArticleEditor({ onPublishSuccess, onCancel }: ArticleEditorProps preview: '', content: '', zapAmount: 800, - category: undefined, + media: [], }) - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - const articleId = await publishArticle(draft) - if (articleId) { - onPublishSuccess?.(articleId) - } - } + const submit = buildSubmitHandler(publishArticle, draft, onPublishSuccess) if (!connected) { return @@ -58,11 +54,27 @@ export function ArticleEditor({ onPublishSuccess, onCancel }: ArticleEditorProps draft={draft} onDraftChange={setDraft} onSubmit={(e) => { - void handleSubmit(e) + e.preventDefault() + void submit() }} loading={loading} error={error} - onCancel={onCancel} + {...(onCancel ? { onCancel } : {})} + {...(seriesOptions ? { seriesOptions } : {})} + {...(onSelectSeries ? { onSelectSeries } : {})} /> ) } + +function buildSubmitHandler( + publishArticle: (draft: ArticleDraft) => Promise, + draft: ArticleDraft, + onPublishSuccess?: (articleId: string) => void +) { + return async () => { + const articleId = await publishArticle(draft) + if (articleId) { + onPublishSuccess?.(articleId) + } + } +} diff --git a/components/ArticleEditorForm.tsx b/components/ArticleEditorForm.tsx index 22204e6..afab2cc 100644 --- a/components/ArticleEditorForm.tsx +++ b/components/ArticleEditorForm.tsx @@ -1,8 +1,11 @@ import React from 'react' import type { ArticleDraft } from '@/lib/articlePublisher' +import type { ArticleCategory } from '@/types/nostr' import { ArticleField } from './ArticleField' import { ArticleFormButtons } from './ArticleFormButtons' import { CategorySelect } from './CategorySelect' +import { MarkdownEditor } from './MarkdownEditor' +import type { MediaRef } from '@/types/nostr' interface ArticleEditorFormProps { draft: ArticleDraft @@ -11,6 +14,8 @@ interface ArticleEditorFormProps { loading: boolean error: string | null onCancel?: () => void + seriesOptions?: { id: string; title: string }[] | undefined + onSelectSeries?: ((seriesId: string | undefined) => void) | undefined } function CategoryField({ @@ -18,13 +23,13 @@ function CategoryField({ onChange, }: { value: ArticleDraft['category'] - onChange: (value: ArticleDraft['category']) => void + onChange: (value: import('@/types/nostr').ArticleCategory | undefined) => void }) { return ( void +): (value: ArticleCategory | undefined) => void { + return (value) => { + if (value === 'science-fiction' || value === 'scientific-research' || value === undefined) { + const nextDraft: ArticleDraft = { ...draft } + if (value) { + nextDraft.category = value + } else { + delete (nextDraft as { category?: ArticleDraft['category'] }).category + } + onDraftChange(nextDraft) + } + } +} + const ArticleFieldsLeft = ({ draft, onDraftChange, + seriesOptions, + onSelectSeries, }: { draft: ArticleDraft onDraftChange: (draft: ArticleDraft) => void + seriesOptions?: { id: string; title: string }[] | undefined + onSelectSeries?: ((seriesId: string | undefined) => void) | undefined }) => (
- onDraftChange({ ...draft, category: value })} /> + + {seriesOptions && ( + + )} + + +
+) + +function ArticleTitleField({ draft, onDraftChange }: { draft: ArticleDraft; onDraftChange: (draft: ArticleDraft) => void }) { + return ( + ) +} + +function ArticlePreviewField({ + draft, + onDraftChange, +}: { + draft: ArticleDraft + onDraftChange: (draft: ArticleDraft) => void +}) { + return ( - -) + ) +} + +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 ( +
+ + +
+ ) +} + +function buildSeriesChangeHandler( + draft: ArticleDraft, + onDraftChange: (draft: ArticleDraft) => void, + onSelectSeries?: ((seriesId: string | undefined) => void) | undefined +) { + return (e: React.ChangeEvent) => { + const value = e.target.value || undefined + const nextDraft = { ...draft } + if (value) { + nextDraft.seriesId = value + } else { + delete (nextDraft as { seriesId?: string }).seriesId + } + onDraftChange(nextDraft) + onSelectSeries?.(value) + } +} const ArticleFieldsRight = ({ draft, @@ -82,17 +190,24 @@ const ArticleFieldsRight = ({ onDraftChange: (draft: ArticleDraft) => void }) => (
- onDraftChange({ ...draft, content: value as string })} - required - type="textarea" - rows={8} - placeholder="Ce contenu sera chiffré et envoyé aux lecteurs qui paient" - helpText="Ce contenu sera chiffré et envoyé comme message privé après paiement" - /> +
+
Contenu complet (Privé) — Markdown + preview
+ onDraftChange({ ...draft, content: value })} + onMediaAdd={(media: MediaRef) => { + const nextMedia = [...(draft.media ?? []), media] + onDraftChange({ ...draft, media: nextMedia }) + }} + onBannerChange={(url: string) => { + onDraftChange({ ...draft, bannerUrl: url }) + }} + /> +

+ 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. +

+

Publier un nouvel article

- +
- + ) } diff --git a/components/ArticleField.tsx b/components/ArticleField.tsx index fb18082..b28bafb 100644 --- a/components/ArticleField.tsx +++ b/components/ArticleField.tsx @@ -32,16 +32,19 @@ function NumberOrTextInput({ className: string onChange: (value: string | number) => void }) { + const inputProps = { + id, + type, + value, + className, + required, + ...(placeholder ? { placeholder } : {}), + ...(typeof min === 'number' ? { min } : {}), + } return ( onChange(type === 'number' ? Number(e.target.value) || 0 : e.target.value)} - className={className} - placeholder={placeholder} - min={min} - required={required} /> ) } @@ -63,15 +66,18 @@ function TextAreaInput({ className: string onChange: (value: string | number) => void }) { + const areaProps = { + id, + value, + className, + required, + ...(placeholder ? { placeholder } : {}), + ...(rows ? { rows } : {}), + } return (