lint fix wip

This commit is contained in:
Nicolas Cantu 2026-01-06 14:17:55 +01:00
parent 412989e6af
commit 13e0e0d801
147 changed files with 8572 additions and 484 deletions

View File

@ -5,7 +5,7 @@ interface AlbyInstallerProps {
onInstalled?: () => void onInstalled?: () => void
} }
function InfoIcon(): JSX.Element { function InfoIcon(): React.ReactElement {
return ( return (
<svg <svg
className="h-5 w-5 text-blue-400" className="h-5 w-5 text-blue-400"
@ -27,7 +27,7 @@ interface InstallerActionsProps {
markInstalled: () => void markInstalled: () => void
} }
function InstallerActions({ onInstalled, markInstalled }: InstallerActionsProps): JSX.Element { function InstallerActions({ onInstalled, markInstalled }: InstallerActionsProps): React.ReactElement {
const connect = useCallback(() => { const connect = useCallback(() => {
const alby = getAlbyService() const alby = getAlbyService()
void alby.enable().then(() => { void alby.enable().then(() => {
@ -60,7 +60,7 @@ function InstallerActions({ onInstalled, markInstalled }: InstallerActionsProps)
) )
} }
function InstallerBody({ onInstalled, markInstalled }: InstallerActionsProps): JSX.Element { function InstallerBody({ onInstalled, markInstalled }: InstallerActionsProps): React.ReactElement {
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>
@ -108,7 +108,7 @@ function useAlbyStatus(onInstalled?: () => void): { isInstalled: boolean; isChec
return { isInstalled, isChecking, markInstalled } return { isInstalled, isChecking, markInstalled }
} }
export function AlbyInstaller({ onInstalled }: AlbyInstallerProps): JSX.Element | null { export function AlbyInstaller({ onInstalled }: AlbyInstallerProps): React.ReactElement | null {
const { isInstalled, isChecking, markInstalled } = useAlbyStatus(onInstalled) const { isInstalled, isChecking, markInstalled } = useAlbyStatus(onInstalled)
if (isChecking || isInstalled) { if (isChecking || isInstalled) {

View File

@ -11,7 +11,7 @@ interface ArticleCardProps {
onUnlock?: (article: Article) => void onUnlock?: (article: Article) => void
} }
function ArticleHeader({ article }: { article: Article }): JSX.Element { function ArticleHeader({ article }: { article: Article }): React.ReactElement {
return ( return (
<div className="mb-2 flex items-center justify-between"> <div className="mb-2 flex items-center justify-between">
<h2 className="text-2xl font-bold text-neon-cyan">{article.title}</h2> <h2 className="text-2xl font-bold text-neon-cyan">{article.title}</h2>
@ -37,7 +37,7 @@ function ArticleMeta({
paymentInvoice: ReturnType<typeof useArticlePayment>['paymentInvoice'] paymentInvoice: ReturnType<typeof useArticlePayment>['paymentInvoice']
onClose: () => void onClose: () => void
onPaymentComplete: () => void onPaymentComplete: () => void
}): JSX.Element { }): React.ReactElement {
return ( return (
<> <>
{error && <p className="text-sm text-red-400 mt-2">{error}</p>} {error && <p className="text-sm text-red-400 mt-2">{error}</p>}
@ -55,7 +55,7 @@ function ArticleMeta({
) )
} }
export function ArticleCard({ article, onUnlock }: ArticleCardProps): JSX.Element { export function ArticleCard({ article, onUnlock }: ArticleCardProps): React.ReactElement {
const { pubkey, connect } = useNostrAuth() const { pubkey, connect } = useNostrAuth()
const { const {
loading, loading,

View File

@ -12,7 +12,7 @@ interface ArticleEditorProps {
} }
function SuccessMessage(): JSX.Element { function SuccessMessage(): React.ReactElement {
return ( return (
<div className="border rounded-lg p-6 bg-green-50 border-green-200"> <div className="border rounded-lg p-6 bg-green-50 border-green-200">
<h3 className="text-lg font-semibold text-green-800 mb-2">Article Published!</h3> <h3 className="text-lg font-semibold text-green-800 mb-2">Article Published!</h3>
@ -21,7 +21,7 @@ function SuccessMessage(): JSX.Element {
) )
} }
export function ArticleEditor({ onPublishSuccess, onCancel, seriesOptions, onSelectSeries }: ArticleEditorProps): JSX.Element { export function ArticleEditor({ onPublishSuccess, onCancel, seriesOptions, onSelectSeries }: ArticleEditorProps): React.ReactElement {
const { connected, pubkey, connect } = useNostrAuth() const { connected, pubkey, connect } = useNostrAuth()
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>({

View File

@ -5,6 +5,7 @@ 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 { MarkdownEditor } from './MarkdownEditor'
import { MarkdownEditorTwoColumns } from './MarkdownEditorTwoColumns'
import type { MediaRef } from '@/types/nostr' import type { MediaRef } from '@/types/nostr'
import { t } from '@/lib/i18n' import { t } from '@/lib/i18n'
@ -25,7 +26,7 @@ function CategoryField({
}: { }: {
value: ArticleDraft['category'] value: ArticleDraft['category']
onChange: (value: import('@/types/nostr').ArticleCategory | undefined) => void onChange: (value: import('@/types/nostr').ArticleCategory | undefined) => void
}): JSX.Element { }): React.ReactElement {
return ( return (
<CategorySelect <CategorySelect
id="category" id="category"
@ -38,7 +39,7 @@ function CategoryField({
) )
} }
function ErrorAlert({ error }: { error: string | null }): JSX.Element | null { function ErrorAlert({ error }: { error: string | null }): React.ReactElement | null {
if (!error) { if (!error) {
return null return null
} }
@ -76,7 +77,7 @@ const ArticleFieldsLeft = ({
onDraftChange: (draft: ArticleDraft) => void onDraftChange: (draft: ArticleDraft) => void
seriesOptions?: { id: string; title: string }[] | undefined seriesOptions?: { id: string; title: string }[] | undefined
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
}): JSX.Element => ( }): React.ReactElement => (
<div className="space-y-4"> <div className="space-y-4">
<CategoryField <CategoryField
value={draft.category} value={draft.category}
@ -95,7 +96,7 @@ const ArticleFieldsLeft = ({
</div> </div>
) )
function ArticleTitleField({ draft, onDraftChange }: { draft: ArticleDraft; onDraftChange: (draft: ArticleDraft) => void }): JSX.Element { function ArticleTitleField({ draft, onDraftChange }: { draft: ArticleDraft; onDraftChange: (draft: ArticleDraft) => void }): React.ReactElement {
return ( return (
<ArticleField <ArticleField
id="title" id="title"
@ -114,7 +115,7 @@ function ArticlePreviewField({
}: { }: {
draft: ArticleDraft draft: ArticleDraft
onDraftChange: (draft: ArticleDraft) => void onDraftChange: (draft: ArticleDraft) => void
}): JSX.Element { }): React.ReactElement {
return ( return (
<ArticleField <ArticleField
id="preview" id="preview"
@ -140,7 +141,7 @@ function SeriesSelect({
onDraftChange: (draft: ArticleDraft) => void onDraftChange: (draft: ArticleDraft) => void
seriesOptions: { id: string; title: string }[] seriesOptions: { id: string; title: string }[]
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
}): JSX.Element { }): React.ReactElement {
const handleChange = buildSeriesChangeHandler(draft, onDraftChange, onSelectSeries) const handleChange = buildSeriesChangeHandler(draft, onDraftChange, onSelectSeries)
return ( return (
@ -189,10 +190,29 @@ const ArticleFieldsRight = ({
}: { }: {
draft: ArticleDraft draft: ArticleDraft
onDraftChange: (draft: ArticleDraft) => void onDraftChange: (draft: ArticleDraft) => void
}): JSX.Element => ( }): React.ReactElement => {
// Use two-column editor with pages for series publications
const useTwoColumns = draft.seriesId !== undefined
return (
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<div className="text-sm font-semibold text-gray-800">{t('article.editor.content.label')}</div> <div className="text-sm font-semibold text-gray-800">{t('article.editor.content.label')}</div>
{useTwoColumns ? (
<MarkdownEditorTwoColumns
value={draft.content}
onChange={(value) => onDraftChange({ ...draft, content: value })}
pages={draft.pages}
onPagesChange={(pages) => onDraftChange({ ...draft, pages })}
onMediaAdd={(media: MediaRef) => {
const nextMedia = [...(draft.media ?? []), media]
onDraftChange({ ...draft, media: nextMedia })
}}
onBannerChange={(url: string) => {
onDraftChange({ ...draft, bannerUrl: url })
}}
/>
) : (
<MarkdownEditor <MarkdownEditor
value={draft.content} value={draft.content}
onChange={(value) => onDraftChange({ ...draft, content: value })} onChange={(value) => onDraftChange({ ...draft, content: value })}
@ -204,6 +224,7 @@ const ArticleFieldsRight = ({
onDraftChange({ ...draft, bannerUrl: url }) onDraftChange({ ...draft, bannerUrl: url })
}} }}
/> />
)}
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">
{t('article.editor.content.help')} {t('article.editor.content.help')}
</p> </p>
@ -220,6 +241,7 @@ const ArticleFieldsRight = ({
/> />
</div> </div>
) )
}
export function ArticleEditorForm({ export function ArticleEditorForm({
draft, draft,
@ -230,7 +252,7 @@ export function ArticleEditorForm({
onCancel, onCancel,
seriesOptions, seriesOptions,
onSelectSeries, onSelectSeries,
}: ArticleEditorFormProps): JSX.Element { }: ArticleEditorFormProps): React.ReactElement {
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">{t('article.editor.title')}</h2> <h2 className="text-2xl font-bold mb-4">{t('article.editor.title')}</h2>

View File

@ -30,7 +30,7 @@ function NumberOrTextInput({
min?: number min?: number
className: string className: string
onChange: (value: string | number) => void onChange: (value: string | number) => void
}): JSX.Element { }): React.ReactElement {
const inputProps = { const inputProps = {
id, id,
type, type,
@ -64,7 +64,7 @@ function TextAreaInput({
rows?: number rows?: number
className: string className: string
onChange: (value: string | number) => void onChange: (value: string | number) => void
}): JSX.Element { }): React.ReactElement {
const areaProps = { const areaProps = {
id, id,
value, value,
@ -81,7 +81,7 @@ function TextAreaInput({
) )
} }
export function ArticleField(props: ArticleFieldProps): JSX.Element { export function ArticleField(props: ArticleFieldProps): React.ReactElement {
const { id, label, value, onChange, required = false, type = 'text', rows, placeholder, helpText, min } = const { id, label, value, onChange, required = false, type = 'text', rows, placeholder, helpText, min } =
props props
const inputClass = const inputClass =

View File

@ -46,7 +46,7 @@ function FiltersGrid({
data: FiltersData data: FiltersData
filters: ArticleFilters filters: ArticleFilters
onFiltersChange: (filters: ArticleFilters) => void onFiltersChange: (filters: ArticleFilters) => void
}): JSX.Element { }): React.ReactElement {
const update = (patch: Partial<ArticleFilters>): void => onFiltersChange({ ...filters, ...patch }) const update = (patch: Partial<ArticleFilters>): void => onFiltersChange({ ...filters, ...patch })
return ( return (
@ -63,7 +63,7 @@ function FiltersHeader({
}: { }: {
hasActiveFilters: boolean hasActiveFilters: boolean
onClear: () => void onClear: () => void
}): JSX.Element { }): React.ReactElement {
return ( return (
<div className="flex justify-between items-center mb-4"> <div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold text-neon-cyan">{t('filters.sort')}</h3> <h3 className="text-lg font-semibold text-neon-cyan">{t('filters.sort')}</h3>
@ -83,7 +83,7 @@ function SortFilter({
}: { }: {
value: SortOption value: SortOption
onChange: (value: SortOption) => void onChange: (value: SortOption) => void
}): JSX.Element { }): React.ReactElement {
return ( return (
<div> <div>
<label htmlFor="sort" className="block text-sm font-medium text-cyber-accent mb-1"> <label htmlFor="sort" className="block text-sm font-medium text-cyber-accent mb-1">
@ -106,7 +106,7 @@ export function ArticleFiltersComponent({
filters, filters,
onFiltersChange, onFiltersChange,
articles, articles,
}: ArticleFiltersProps): JSX.Element { }: ArticleFiltersProps): React.ReactElement {
const data = useFiltersData(articles) const data = useFiltersData(articles)
const handleClearFilters = () => { const handleClearFilters = () => {

View File

@ -5,7 +5,7 @@ interface ArticleFormButtonsProps {
onCancel?: () => void onCancel?: () => void
} }
export function ArticleFormButtons({ loading, onCancel }: ArticleFormButtonsProps): JSX.Element { export function ArticleFormButtons({ loading, onCancel }: ArticleFormButtonsProps): React.ReactElement {
return ( return (
<div className="flex gap-3 pt-4"> <div className="flex gap-3 pt-4">
<button <button

View File

@ -0,0 +1,55 @@
import type { Page } from '@/types/nostr'
import { t } from '@/lib/i18n'
interface ArticlePagesProps {
pages: Page[]
}
export function ArticlePages({ pages }: ArticlePagesProps): React.ReactElement {
if (!pages || pages.length === 0) {
return <></>
}
return (
<div className="space-y-6 mt-6">
<h3 className="text-xl font-semibold text-neon-cyan">{t('article.pages.title')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{pages.map((page) => (
<PageDisplay key={page.number} page={page} />
))}
</div>
</div>
)
}
function PageDisplay({ page }: { page: Page }): React.ReactElement {
return (
<div className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark">
<div className="flex items-center justify-between mb-2">
<h4 className="font-semibold text-neon-cyan">
{t('page.number', { number: page.number })}
</h4>
<span className="text-xs text-cyber-accent/70">{t(`page.type.${page.type}`)}</span>
</div>
{page.type === 'markdown' ? (
<div className="prose prose-invert max-w-none text-cyber-accent whitespace-pre-wrap">
{page.content || <span className="text-cyber-accent/50">{t('page.markdown.empty')}</span>}
</div>
) : (
<div className="space-y-2">
{page.content ? (
<img
src={page.content}
alt={t('page.image.alt', { number: page.number })}
className="max-w-full h-auto rounded border border-neon-cyan/20"
/>
) : (
<div className="text-center py-8 text-cyber-accent/50 border border-dashed border-neon-cyan/20 rounded">
{t('page.image.empty')}
</div>
)}
</div>
)}
</div>
)
}

View File

@ -1,4 +1,5 @@
import type { Article } from '@/types/nostr' import type { Article } from '@/types/nostr'
import { ArticlePages } from './ArticlePages'
interface ArticlePreviewProps { interface ArticlePreviewProps {
article: Article article: Article
@ -6,12 +7,13 @@ interface ArticlePreviewProps {
onUnlock: () => void onUnlock: () => void
} }
export function ArticlePreview({ article, loading, onUnlock }: ArticlePreviewProps): JSX.Element { export function ArticlePreview({ article, loading, onUnlock }: ArticlePreviewProps): React.ReactElement {
if (article.paid) { if (article.paid) {
return ( return (
<div> <div>
<p className="mb-2 text-cyber-accent">{article.preview}</p> <p className="mb-2 text-cyber-accent">{article.preview}</p>
<p className="text-sm text-cyber-accent/80 mt-4 whitespace-pre-wrap">{article.content}</p> <p className="text-sm text-cyber-accent/80 mt-4 whitespace-pre-wrap">{article.content}</p>
{article.pages && article.pages.length > 0 && <ArticlePages pages={article.pages} />}
</div> </div>
) )
} }

View File

@ -1,27 +1,31 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import type { Review } from '@/types/nostr' import type { Review, Article } from '@/types/nostr'
import { getReviewsForArticle } from '@/lib/reviews' import { getReviewsForArticle } from '@/lib/reviews'
import { getReviewTipsForArticle } from '@/lib/reviewAggregation' import { getReviewTipsForArticle } from '@/lib/reviewAggregation'
import { ReviewForm } from './ReviewForm'
import { ReviewTipForm } from './ReviewTipForm'
import { t } from '@/lib/i18n'
interface ArticleReviewsProps { interface ArticleReviewsProps {
articleId: string article: Article
authorPubkey: string authorPubkey: string
} }
export function ArticleReviews({ articleId, authorPubkey }: ArticleReviewsProps): JSX.Element { export function ArticleReviews({ article, authorPubkey }: ArticleReviewsProps): React.ReactElement {
const [reviews, setReviews] = useState<Review[]>([]) const [reviews, setReviews] = useState<Review[]>([])
const [tips, setTips] = useState<number>(0) const [tips, setTips] = useState<number>(0)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [showReviewForm, setShowReviewForm] = useState(false)
const [selectedReviewForTip, setSelectedReviewForTip] = useState<string | null>(null)
useEffect(() => { const loadReviews = async (): Promise<void> => {
const load = async (): Promise<void> => {
setLoading(true) setLoading(true)
setError(null) setError(null)
try { try {
const [list, tipsTotal] = await Promise.all([ const [list, tipsTotal] = await Promise.all([
getReviewsForArticle(articleId), getReviewsForArticle(article.id),
getReviewTipsForArticle({ authorPubkey, articleId }), getReviewTipsForArticle({ authorPubkey, articleId: article.id }),
]) ])
setReviews(list) setReviews(list)
setTips(tipsTotal) setTips(tipsTotal)
@ -31,43 +35,100 @@ export function ArticleReviews({ articleId, authorPubkey }: ArticleReviewsProps)
setLoading(false) setLoading(false)
} }
} }
void load()
}, [articleId, authorPubkey]) useEffect(() => {
void loadReviews()
}, [article.id, authorPubkey])
return ( return (
<div className="border rounded-lg p-4 bg-white space-y-3"> <div className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark space-y-4">
<ArticleReviewsHeader tips={tips} /> <ArticleReviewsHeader tips={tips} onAddReview={() => {
{loading && <p className="text-sm text-gray-600">Chargement des critiques...</p>} setShowReviewForm(true)
{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>} {showReviewForm && (
{!loading && !error && <ArticleReviewsList reviews={reviews} />} <ReviewForm
article={article}
onSuccess={() => {
setShowReviewForm(false)
void loadReviews()
}}
onCancel={() => {
setShowReviewForm(false)
}}
/>
)}
{loading && <p className="text-sm text-cyber-accent">{t('common.loading')}</p>}
{error && <p className="text-sm text-red-400">{error}</p>}
{!loading && !error && reviews.length === 0 && !showReviewForm && (
<p className="text-sm text-cyber-accent/70">{t('review.empty')}</p>
)}
{!loading && !error && <ArticleReviewsList reviews={reviews} onTipReview={(reviewId) => {
setSelectedReviewForTip(reviewId)
}} />}
{selectedReviewForTip && (
<ReviewTipForm
review={reviews.find((r) => r.id === selectedReviewForTip)!}
article={article}
onSuccess={() => {
setSelectedReviewForTip(null)
void loadReviews()
}}
onCancel={() => {
setSelectedReviewForTip(null)
}}
/>
)}
</div> </div>
) )
} }
function ArticleReviewsHeader({ tips }: { tips: number }): JSX.Element { function ArticleReviewsHeader({ tips, onAddReview }: { tips: number; onAddReview: () => void }): React.ReactElement {
return ( return (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">Critiques</h3> <h3 className="text-lg font-semibold text-neon-cyan">{t('review.title')}</h3>
<span className="text-sm text-gray-600">Remerciements versés : {tips} sats</span> <div className="flex items-center gap-4">
<span className="text-sm text-cyber-accent/70">{t('review.tips.total', { amount: tips })}</span>
<button
onClick={onAddReview}
className="px-3 py-1 text-sm bg-neon-green/20 hover:bg-neon-green/30 text-neon-green rounded-lg font-medium transition-all border border-neon-green/50"
>
{t('review.add')}
</button>
</div>
</div> </div>
) )
} }
function ArticleReviewsList({ reviews }: { reviews: Review[] }): JSX.Element { function ArticleReviewsList({ reviews, onTipReview }: { reviews: Review[]; onTipReview: (reviewId: string) => void }): React.ReactElement {
return ( return (
<> <div className="space-y-4">
{reviews.map((r) => ( {reviews.map((r) => (
<div key={r.id} className="border-t pt-2 text-sm"> <div key={r.id} className="border-t border-neon-cyan/20 pt-4 space-y-2">
<div className="text-gray-800">{r.content}</div> {r.title && (
<div className="text-xs text-gray-500 flex gap-2"> <h4 className="font-semibold text-neon-cyan">{r.title}</h4>
<span>Auteur critique : {formatPubkey(r.reviewerPubkey)}</span> )}
<div className="text-cyber-accent whitespace-pre-wrap">{r.content}</div>
{r.text && (
<div className="text-sm text-cyber-accent/70 italic border-l-2 border-neon-cyan/30 pl-3">
{r.text}
</div>
)}
<div className="text-xs text-cyber-accent/50 flex gap-2 items-center">
<span>{t('review.reviewer')}: {formatPubkey(r.reviewerPubkey)}</span>
<span></span> <span></span>
<span>{formatDate(r.createdAt)}</span> <span>{formatDate(r.createdAt)}</span>
<button
onClick={() => {
onTipReview(r.id)
}}
className="ml-auto px-2 py-1 text-xs bg-neon-green/20 hover:bg-neon-green/30 text-neon-green rounded transition-all border border-neon-green/50"
>
{t('review.tip.button')}
</button>
</div> </div>
</div> </div>
))} ))}
</> </div>
) )
} }

View File

@ -11,7 +11,7 @@ interface ArticlesListProps {
unlockedArticles: Set<string> unlockedArticles: Set<string>
} }
function LoadingState(): JSX.Element { function LoadingState(): React.ReactElement {
// Use generic loading message at startup, then specific message once we know what we're loading // Use generic loading message at startup, then specific message once we know what we're loading
return ( return (
<div className="text-center py-12"> <div className="text-center py-12">
@ -20,7 +20,7 @@ function LoadingState(): JSX.Element {
) )
} }
function ErrorState({ message }: { message: string }): JSX.Element { function ErrorState({ message }: { message: string }): React.ReactElement {
return ( return (
<div className="bg-red-900/20 border border-red-500/50 rounded-lg p-4 mb-4"> <div className="bg-red-900/20 border border-red-500/50 rounded-lg p-4 mb-4">
<p className="text-red-400">{message}</p> <p className="text-red-400">{message}</p>
@ -28,7 +28,7 @@ function ErrorState({ message }: { message: string }): JSX.Element {
) )
} }
function EmptyState({ hasAny }: { hasAny: boolean }): JSX.Element { function EmptyState({ hasAny }: { hasAny: boolean }): React.ReactElement {
return ( return (
<div className="text-center py-12"> <div className="text-center py-12">
<p className="text-cyber-accent/70"> <p className="text-cyber-accent/70">
@ -45,7 +45,7 @@ export function ArticlesList({
error, error,
onUnlock, onUnlock,
unlockedArticles, unlockedArticles,
}: ArticlesListProps): JSX.Element { }: ArticlesListProps): React.ReactElement {
if (loading) { if (loading) {
return <LoadingState /> return <LoadingState />
} }

View File

@ -7,7 +7,7 @@ interface AuthorCardProps {
presentation: Article presentation: Article
} }
export function AuthorCard({ presentation }: AuthorCardProps): JSX.Element { export function AuthorCard({ presentation }: AuthorCardProps): React.ReactElement {
const authorName = presentation.title.replace(/^Présentation de /, '') || t('common.author') const authorName = presentation.title.replace(/^Présentation de /, '') || t('common.author')
const totalBTC = (presentation.totalSponsoring ?? 0) / 100_000_000 const totalBTC = (presentation.totalSponsoring ?? 0) / 100_000_000

View File

@ -4,7 +4,7 @@ import { useAuthorFilterProps } from './AuthorFilterHooks'
import { AuthorFilterButtonWrapper } from './AuthorFilterButton' import { AuthorFilterButtonWrapper } from './AuthorFilterButton'
import { AuthorDropdown } from './AuthorFilterDropdown' import { AuthorDropdown } from './AuthorFilterDropdown'
function AuthorFilterLabel(): JSX.Element { function AuthorFilterLabel(): React.ReactElement {
return ( return (
<label htmlFor="author-filter" className="block text-sm font-medium text-cyber-accent mb-1"> <label htmlFor="author-filter" className="block text-sm font-medium text-cyber-accent mb-1">
{t('filters.author')} {t('filters.author')}
@ -28,7 +28,7 @@ interface AuthorFilterContentProps {
selectedDisplayName: string selectedDisplayName: string
} }
function AuthorFilterContent(props: AuthorFilterContentProps): JSX.Element { function AuthorFilterContent(props: AuthorFilterContentProps): React.ReactElement {
return ( return (
<div className="relative" ref={props.dropdownRef}> <div className="relative" ref={props.dropdownRef}>
<AuthorFilterButtonWrapper <AuthorFilterButtonWrapper
@ -64,7 +64,7 @@ export function AuthorFilter({
authors: string[] authors: string[]
value: string | null value: string | null
onChange: (value: string | null) => void onChange: (value: string | null) => void
}): JSX.Element { }): React.ReactElement {
const props = useAuthorFilterProps(authors, value) const props = useAuthorFilterProps(authors, value)
return ( return (

View File

@ -1,7 +1,7 @@
import React from 'react' import React from 'react'
import { AuthorAvatar } from './AuthorFilterDropdown' import { AuthorAvatar } from './AuthorFilterDropdown'
export function AuthorMnemonicIcons({ value, getMnemonicIcons }: { value: string; getMnemonicIcons: (pubkey: string) => string[] }): JSX.Element { export function AuthorMnemonicIcons({ value, getMnemonicIcons }: { value: string; getMnemonicIcons: (pubkey: string) => string[] }): React.ReactElement {
return ( return (
<div className="flex items-center gap-1 flex-shrink-0"> <div className="flex items-center gap-1 flex-shrink-0">
{getMnemonicIcons(value).map((icon, idx) => ( {getMnemonicIcons(value).map((icon, idx) => (
@ -23,7 +23,7 @@ export function AuthorFilterButtonContent({
selectedAuthor: { name?: string; picture?: string } | null | undefined selectedAuthor: { name?: string; picture?: string } | null | undefined
selectedDisplayName: string selectedDisplayName: string
getMnemonicIcons: (pubkey: string) => string[] getMnemonicIcons: (pubkey: string) => string[]
}): JSX.Element { }): React.ReactElement {
return ( return (
<> <>
{value && ( {value && (
@ -38,7 +38,7 @@ export function AuthorFilterButtonContent({
) )
} }
export function DropdownArrowIcon({ isOpen }: { isOpen: boolean }): JSX.Element { export function DropdownArrowIcon({ isOpen }: { isOpen: boolean }): React.ReactElement {
return ( return (
<svg <svg
className={`w-5 h-5 text-neon-cyan transition-transform ${isOpen ? 'rotate-180' : ''}`} className={`w-5 h-5 text-neon-cyan transition-transform ${isOpen ? 'rotate-180' : ''}`}
@ -68,7 +68,7 @@ export function AuthorFilterButton({
isOpen: boolean isOpen: boolean
setIsOpen: (open: boolean) => void setIsOpen: (open: boolean) => void
buttonRef: React.RefObject<HTMLButtonElement | null> buttonRef: React.RefObject<HTMLButtonElement | null>
}): JSX.Element { }): React.ReactElement {
return ( return (
<button <button
id="author-filter" id="author-filter"
@ -106,7 +106,7 @@ export function AuthorFilterButtonWrapper({
isOpen: boolean isOpen: boolean
setIsOpen: (open: boolean) => void setIsOpen: (open: boolean) => void
buttonRef: React.RefObject<HTMLButtonElement | null> buttonRef: React.RefObject<HTMLButtonElement | null>
}): JSX.Element { }): React.ReactElement {
return ( return (
<AuthorFilterButton <AuthorFilterButton
value={value} value={value}

View File

@ -1,7 +1,7 @@
import Image from 'next/image' import Image from 'next/image'
import { t } from '@/lib/i18n' import { t } from '@/lib/i18n'
export function AuthorAvatar({ picture, displayName }: { picture?: string; displayName: string }): JSX.Element { export function AuthorAvatar({ picture, displayName }: { picture?: string; displayName: string }): React.ReactElement {
if (picture !== undefined) { if (picture !== undefined) {
return ( return (
<Image <Image
@ -32,7 +32,7 @@ export function AuthorOption({
mnemonicIcons: string[] mnemonicIcons: string[]
isSelected: boolean isSelected: boolean
onSelect: () => void onSelect: () => void
}): JSX.Element { }): React.ReactElement {
return ( return (
<button <button
type="button" type="button"
@ -136,7 +136,7 @@ export function AuthorList({
getMnemonicIcons: (pubkey: string) => string[] getMnemonicIcons: (pubkey: string) => string[]
onChange: (value: string | null) => void onChange: (value: string | null) => void
setIsOpen: (open: boolean) => void setIsOpen: (open: boolean) => void
}): JSX.Element { }): React.ReactElement {
return ( return (
<> <>
{authors.map((pubkey) => ( {authors.map((pubkey) => (
@ -167,7 +167,7 @@ export function AuthorDropdownContent({
getMnemonicIcons: (pubkey: string) => string[] getMnemonicIcons: (pubkey: string) => string[]
onChange: (value: string | null) => void onChange: (value: string | null) => void
setIsOpen: (open: boolean) => void setIsOpen: (open: boolean) => void
}): JSX.Element { }): React.ReactElement {
return loading ? ( return loading ? (
<div className="px-3 py-2 text-sm text-cyber-accent/70">{t('filters.loading')}</div> <div className="px-3 py-2 text-sm text-cyber-accent/70">{t('filters.loading')}</div>
) : ( ) : (
@ -201,7 +201,7 @@ export function AuthorDropdown({
getDisplayName: (pubkey: string) => string getDisplayName: (pubkey: string) => string
getPicture: (pubkey: string) => string | undefined getPicture: (pubkey: string) => string | undefined
getMnemonicIcons: (pubkey: string) => string[] getMnemonicIcons: (pubkey: string) => string[]
}): JSX.Element { }): React.ReactElement {
return ( return (
<div <div
className="absolute z-20 w-full mt-1 bg-cyber-dark border border-neon-cyan/30 rounded-lg shadow-glow-cyan max-h-60 overflow-auto" className="absolute z-20 w-full mt-1 bg-cyber-dark border border-neon-cyan/30 rounded-lg shadow-glow-cyan max-h-60 overflow-auto"

View File

@ -3,9 +3,9 @@ import { useAuthorsProfiles } from '@/hooks/useAuthorsProfiles'
import { generateMnemonicIcons } from '@/lib/mnemonicIcons' import { generateMnemonicIcons } from '@/lib/mnemonicIcons'
import { t } from '@/lib/i18n' import { t } from '@/lib/i18n'
export function useAuthorFilterDropdown(isOpen: boolean, setIsOpen: (open: boolean) => void): { dropdownRef: React.RefObject<HTMLDivElement>; buttonRef: React.RefObject<HTMLButtonElement> } { export function useAuthorFilterDropdown(isOpen: boolean, setIsOpen: (open: boolean) => void): { dropdownRef: React.RefObject<HTMLDivElement | null>; buttonRef: React.RefObject<HTMLButtonElement | null> } {
const dropdownRef = useRef<HTMLDivElement>(null) const dropdownRef = useRef<HTMLDivElement | null>(null)
const buttonRef = useRef<HTMLButtonElement>(null) const buttonRef = useRef<HTMLButtonElement | null>(null)
useEffect(() => { useEffect(() => {
const handleClickOutside = (event: MouseEvent): void => { const handleClickOutside = (event: MouseEvent): void => {
@ -62,8 +62,8 @@ export function useAuthorFilterState(authors: string[], value: string | null): {
loading: boolean loading: boolean
isOpen: boolean isOpen: boolean
setIsOpen: (open: boolean) => void setIsOpen: (open: boolean) => void
dropdownRef: React.RefObject<HTMLDivElement> dropdownRef: React.RefObject<HTMLDivElement | null>
buttonRef: React.RefObject<HTMLButtonElement> buttonRef: React.RefObject<HTMLButtonElement | null>
getDisplayName: (pubkey: string) => string getDisplayName: (pubkey: string) => string
getPicture: (pubkey: string) => string | undefined getPicture: (pubkey: string) => string | undefined
getMnemonicIcons: (pubkey: string) => string[] getMnemonicIcons: (pubkey: string) => string[]

View File

@ -9,7 +9,7 @@ interface AuthorsListProps {
error: string | null error: string | null
} }
function LoadingState(): JSX.Element { function LoadingState(): React.ReactElement {
return ( return (
<div className="text-center py-12"> <div className="text-center py-12">
<p className="text-cyber-accent/70">{t('common.loading.authors')}</p> <p className="text-cyber-accent/70">{t('common.loading.authors')}</p>
@ -17,7 +17,7 @@ function LoadingState(): JSX.Element {
) )
} }
function ErrorState({ message }: { message: string }): JSX.Element { function ErrorState({ message }: { message: string }): React.ReactElement {
return ( return (
<div className="bg-red-900/20 border border-red-500/50 rounded-lg p-4 mb-4"> <div className="bg-red-900/20 border border-red-500/50 rounded-lg p-4 mb-4">
<p className="text-red-400">{message}</p> <p className="text-red-400">{message}</p>
@ -25,7 +25,7 @@ function ErrorState({ message }: { message: string }): JSX.Element {
) )
} }
function EmptyState({ hasAny }: { hasAny: boolean }): JSX.Element { function EmptyState({ hasAny }: { hasAny: boolean }): React.ReactElement {
return ( return (
<div className="text-center py-12"> <div className="text-center py-12">
<p className="text-cyber-accent/70"> <p className="text-cyber-accent/70">
@ -35,7 +35,7 @@ function EmptyState({ hasAny }: { hasAny: boolean }): JSX.Element {
) )
} }
export function AuthorsList({ authors, allAuthors, loading, error }: AuthorsListProps): JSX.Element { export function AuthorsList({ authors, allAuthors, loading, error }: AuthorsListProps): React.ReactElement {
if (loading) { if (loading) {
return <LoadingState /> return <LoadingState />
} }

View File

@ -0,0 +1,106 @@
import { useState } from 'react'
import { nostrAuthService } from '@/lib/nostrAuth'
import { objectCache } from '@/lib/objectCache'
import { syncUserContentToCache } from '@/lib/userContentSync'
async function updateCache(): Promise<void> {
const state = nostrAuthService.getState()
if (!state.connected || !state.pubkey) {
throw new Error('Vous devez être connecté pour mettre à jour le cache')
}
await Promise.all([
objectCache.clear('author'),
objectCache.clear('series'),
objectCache.clear('publication'),
objectCache.clear('review'),
objectCache.clear('purchase'),
objectCache.clear('sponsoring'),
objectCache.clear('review_tip'),
])
await syncUserContentToCache(state.pubkey)
}
function ErrorMessage({ error }: { error: string }): React.ReactElement {
return (
<div className="bg-red-900/20 border border-red-400/50 rounded-lg p-4 mb-4">
<p className="text-red-400">{error}</p>
</div>
)
}
function SuccessMessage(): React.ReactElement {
return (
<div className="bg-green-900/20 border border-green-400/50 rounded-lg p-4 mb-4">
<p className="text-green-400">Cache mis à jour avec succès</p>
</div>
)
}
function NotConnectedMessage(): React.ReactElement {
return (
<div className="bg-yellow-900/20 border border-yellow-400/50 rounded-lg p-4 mb-4">
<p className="text-yellow-400">Vous devez être connecté pour mettre à jour le cache</p>
</div>
)
}
function createUpdateHandler(
setUpdating: (value: boolean) => void,
setError: (value: string | null) => void,
setSuccess: (value: boolean) => void
): () => Promise<void> {
return async (): Promise<void> => {
try {
setUpdating(true)
setError(null)
setSuccess(false)
await updateCache()
setSuccess(true)
setTimeout(() => {
setSuccess(false)
}, 3000)
} catch (e) {
const errorMessage = e instanceof Error ? e.message : 'Erreur lors de la mise à jour du cache'
setError(errorMessage)
console.error('Error updating cache:', e)
} finally {
setUpdating(false)
}
}
}
export function CacheUpdateManager(): React.ReactElement {
const [updating, setUpdating] = useState(false)
const [success, setSuccess] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleUpdateCache = createUpdateHandler(setUpdating, setError, setSuccess)
const state = nostrAuthService.getState()
const isConnected = state.connected && state.pubkey
return (
<div className="bg-cyber-darker border border-neon-cyan/30 rounded-lg p-6">
<h2 className="text-2xl font-bold text-neon-cyan mb-4">Mise à jour du cache</h2>
<p className="text-cyber-accent mb-4 text-sm">
Videz et re-synchronisez le cache IndexedDB avec les données depuis les relais Nostr.
Cela permet de récupérer les dernières versions de vos publications, séries et profil.
</p>
{error && <ErrorMessage error={error} />}
{success && <SuccessMessage />}
{!isConnected && <NotConnectedMessage />}
<button
onClick={() => {
void handleUpdateCache()
}}
disabled={updating || !isConnected}
className="w-full py-3 px-6 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan disabled:opacity-50 disabled:cursor-not-allowed"
>
{updating ? 'Mise à jour en cours...' : 'Mettre à jour le cache'}
</button>
</div>
)
}

View File

@ -17,7 +17,7 @@ export function CategorySelect({
onChange, onChange,
required = false, required = false,
helpText, helpText,
}: CategorySelectProps): JSX.Element { }: CategorySelectProps): React.ReactElement {
return ( return (
<div> <div>
<label htmlFor={id} className="block text-sm font-medium text-gray-700 mb-1"> <label htmlFor={id} className="block text-sm font-medium text-gray-700 mb-1">

View File

@ -7,7 +7,7 @@ interface CategoryTabsProps {
onCategoryChange: (category: CategoryFilter) => void onCategoryChange: (category: CategoryFilter) => void
} }
export function CategoryTabs({ selectedCategory, onCategoryChange }: CategoryTabsProps): JSX.Element { export function CategoryTabs({ selectedCategory, onCategoryChange }: CategoryTabsProps): React.ReactElement {
return ( return (
<div className="mb-6"> <div className="mb-6">
<div className="border-b border-neon-cyan/30"> <div className="border-b border-neon-cyan/30">

View File

@ -4,7 +4,7 @@ interface ClearButtonProps {
onClick: () => void onClick: () => void
} }
export function ClearButton({ onClick }: ClearButtonProps): JSX.Element { export function ClearButton({ onClick }: ClearButtonProps): React.ReactElement {
return ( return (
<button <button
onClick={onClick} onClick={onClick}

View File

@ -8,7 +8,7 @@ import type { Article } from '@/types/nostr'
const buttonClassName = 'px-4 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg text-sm font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan' const buttonClassName = 'px-4 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg text-sm font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan'
function CreateAuthorPageLink() { function CreateAuthorPageLink(): React.ReactElement {
return ( return (
<Link href="/presentation" className={buttonClassName}> <Link href="/presentation" className={buttonClassName}>
{t('nav.createAuthorPage')} {t('nav.createAuthorPage')}
@ -16,7 +16,7 @@ function CreateAuthorPageLink() {
) )
} }
function AuthorProfileLink({ presentation, profile }: { presentation: Article; profile: { name?: string; picture?: string } | null }) { function AuthorProfileLink({ presentation, profile }: { presentation: Article; profile: { name?: string; picture?: string } | null }): React.ReactElement {
// Extract author name from presentation title or profile // Extract author name from presentation title or profile
// Title format: "Présentation de <name>" or just use profile name // Title format: "Présentation de <name>" or just use profile name
let authorName = presentation.title.replace(/^Présentation de /, '').trim() let authorName = presentation.title.replace(/^Présentation de /, '').trim()
@ -52,13 +52,13 @@ function AuthorProfileLink({ presentation, profile }: { presentation: Article; p
) )
} }
export function ConditionalPublishButton() { export function ConditionalPublishButton(): React.ReactElement {
const { connected, pubkey, profile } = useNostrAuth() const { connected, pubkey, profile } = useNostrAuth()
const { checkPresentationExists } = useAuthorPresentation(pubkey ?? null) const { checkPresentationExists } = useAuthorPresentation(pubkey ?? null)
const [presentation, setPresentation] = useState<Article | null>(null) const [presentation, setPresentation] = useState<Article | null>(null)
useEffect(() => { useEffect(() => {
const check = async () => { const check = async (): Promise<void> => {
if (!connected || !pubkey) { if (!connected || !pubkey) {
setPresentation(null) setPresentation(null)
return return

View File

@ -16,7 +16,7 @@ function ConnectForm({
onUnlock: () => void onUnlock: () => void
loading: boolean loading: boolean
error: string | null error: string | null
}) { }): React.ReactElement {
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<button <button
@ -38,7 +38,7 @@ function ConnectForm({
) )
} }
function useAutoConnect(accountExists: boolean | null, pubkey: string | null, showRecoveryStep: boolean, showUnlockModal: boolean, connect: () => Promise<void>) { function useAutoConnect(accountExists: boolean | null, pubkey: string | null, showRecoveryStep: boolean, showUnlockModal: boolean, connect: () => Promise<void>): void {
useEffect(() => { useEffect(() => {
if (accountExists === true && !pubkey && !showRecoveryStep && !showUnlockModal) { if (accountExists === true && !pubkey && !showRecoveryStep && !showUnlockModal) {
void connect() void connect()
@ -46,7 +46,7 @@ function useAutoConnect(accountExists: boolean | null, pubkey: string | null, sh
}, [accountExists, pubkey, showRecoveryStep, showUnlockModal, connect]) }, [accountExists, pubkey, showRecoveryStep, showUnlockModal, connect])
} }
function ConnectedState({ pubkey, profile, loading, disconnect }: { pubkey: string; profile: NostrProfile | null; loading: boolean; disconnect: () => Promise<void> }) { function ConnectedState({ pubkey, profile, loading, disconnect }: { pubkey: string; profile: NostrProfile | null; loading: boolean; disconnect: () => Promise<void> }): React.ReactElement {
return ( return (
<ConnectedUserMenu <ConnectedUserMenu
pubkey={pubkey} pubkey={pubkey}
@ -59,7 +59,7 @@ function ConnectedState({ pubkey, profile, loading, disconnect }: { pubkey: stri
) )
} }
function UnlockState({ loading, error, onUnlock, onClose }: { loading: boolean; error: string | null; onUnlock: () => void; onClose: () => void }) { function UnlockState({ loading, error, onUnlock, onClose }: { loading: boolean; error: string | null; onUnlock: () => void; onClose: () => void }): React.ReactElement {
return ( return (
<> <>
<ConnectForm <ConnectForm
@ -86,7 +86,7 @@ function DisconnectedState({
showUnlockModal: boolean showUnlockModal: boolean
setShowUnlockModal: (show: boolean) => void setShowUnlockModal: (show: boolean) => void
onCreateAccount: () => void onCreateAccount: () => void
}) { }): React.ReactElement {
return ( return (
<> <>
<ConnectForm <ConnectForm
@ -105,7 +105,7 @@ function DisconnectedState({
) )
} }
export function ConnectButton() { export function ConnectButton(): React.ReactElement {
const { connected, pubkey, profile, loading, error, connect, disconnect, accountExists, isUnlocked } = useNostrAuth() const { connected, pubkey, profile, loading, error, connect, disconnect, accountExists, isUnlocked } = useNostrAuth()
const [showRecoveryStep, setShowRecoveryStep] = useState(false) const [showRecoveryStep, setShowRecoveryStep] = useState(false)
const [showUnlockModal, setShowUnlockModal] = useState(false) const [showUnlockModal, setShowUnlockModal] = useState(false)
@ -116,7 +116,7 @@ export function ConnectButton() {
useAutoConnect(accountExists, pubkey, false, showUnlockModal, connect) useAutoConnect(accountExists, pubkey, false, showUnlockModal, connect)
const handleCreateAccount = async () => { const handleCreateAccount = async (): Promise<void> => {
setCreatingAccount(true) setCreatingAccount(true)
setCreateError(null) setCreateError(null)
try { try {
@ -132,12 +132,12 @@ export function ConnectButton() {
} }
} }
const handleRecoveryContinue = () => { const handleRecoveryContinue = (): void => {
setShowRecoveryStep(false) setShowRecoveryStep(false)
setShowUnlockModal(true) setShowUnlockModal(true)
} }
const handleUnlockSuccess = () => { const handleUnlockSuccess = (): void => {
setShowUnlockModal(false) setShowUnlockModal(false)
setRecoveryPhrase([]) setRecoveryPhrase([])
setNpub('') setNpub('')

View File

@ -15,7 +15,7 @@ export function ConnectedUserMenu({
profile, profile,
onDisconnect, onDisconnect,
loading, loading,
}: ConnectedUserMenuProps): JSX.Element { }: ConnectedUserMenuProps): React.ReactElement {
const displayName = profile?.name ?? `${pubkey.slice(0, 8)}...` const displayName = profile?.name ?? `${pubkey.slice(0, 8)}...`
return ( return (

View File

@ -10,7 +10,7 @@ interface CreateAccountModalProps {
type Step = 'choose' | 'import' | 'recovery' type Step = 'choose' | 'import' | 'recovery'
async function createAccountWithKey(key?: string) { async function createAccountWithKey(key?: string): Promise<{ recoveryPhrase: string[]; npub: string; publicKey: string }> {
return nostrAuthService.createAccount(key) return nostrAuthService.createAccount(key)
} }
@ -22,7 +22,7 @@ async function handleAccountCreation(
setNpub: (npub: string) => void, setNpub: (npub: string) => void,
setStep: (step: Step) => void, setStep: (step: Step) => void,
errorMessage: string errorMessage: string
) { ): Promise<void> {
if (key !== undefined && !key.trim()) { if (key !== undefined && !key.trim()) {
setError('Please enter a private key') setError('Please enter a private key')
return return
@ -42,7 +42,19 @@ async function handleAccountCreation(
} }
} }
function useAccountCreation(initialStep: Step = 'choose') { function useAccountCreation(initialStep: Step = 'choose'): {
step: Step
setStep: (step: Step) => void
importKey: string
setImportKey: (key: string) => void
loading: boolean
error: string | null
setError: (error: string | null) => void
recoveryPhrase: string[]
npub: string
handleGenerate: () => Promise<void>
handleImport: () => Promise<void>
} {
const [step, setStep] = useState<Step>(initialStep) const [step, setStep] = useState<Step>(initialStep)
const [importKey, setImportKey] = useState('') const [importKey, setImportKey] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@ -50,11 +62,11 @@ function useAccountCreation(initialStep: Step = 'choose') {
const [recoveryPhrase, setRecoveryPhrase] = useState<string[]>([]) const [recoveryPhrase, setRecoveryPhrase] = useState<string[]>([])
const [npub, setNpub] = useState('') const [npub, setNpub] = useState('')
const handleGenerate = async () => { const handleGenerate = async (): Promise<void> => {
await handleAccountCreation(undefined, setLoading, setError, setRecoveryPhrase, setNpub, setStep, 'Failed to create account') await handleAccountCreation(undefined, setLoading, setError, setRecoveryPhrase, setNpub, setStep, 'Failed to create account')
} }
const handleImport = async () => { const handleImport = async (): Promise<void> => {
await handleAccountCreation(importKey, setLoading, setError, setRecoveryPhrase, setNpub, setStep, 'Failed to import key') await handleAccountCreation(importKey, setLoading, setError, setRecoveryPhrase, setNpub, setStep, 'Failed to import key')
} }
@ -73,7 +85,7 @@ function useAccountCreation(initialStep: Step = 'choose') {
} }
} }
function handleImportBack(setStep: (step: Step) => void, setError: (error: string | null) => void, setImportKey: (key: string) => void) { function handleImportBack(setStep: (step: Step) => void, setError: (error: string | null) => void, setImportKey: (key: string) => void): void {
setStep('choose') setStep('choose')
setError(null) setError(null)
setImportKey('') setImportKey('')
@ -93,7 +105,7 @@ function renderStep(
setError: (error: string | null) => void, setError: (error: string | null) => void,
handleGenerate: () => void, handleGenerate: () => void,
onClose: () => void onClose: () => void
) { ): React.ReactElement {
if (step === 'recovery') { if (step === 'recovery') {
return <RecoveryStep recoveryPhrase={recoveryPhrase} npub={npub} onContinue={handleContinue} /> return <RecoveryStep recoveryPhrase={recoveryPhrase} npub={npub} onContinue={handleContinue} />
} }
@ -122,7 +134,7 @@ function renderStep(
) )
} }
export function CreateAccountModal({ onSuccess, onClose, initialStep = 'choose' }: CreateAccountModalProps) { export function CreateAccountModal({ onSuccess, onClose, initialStep = 'choose' }: CreateAccountModalProps): React.ReactElement {
const { const {
step, step,
setStep, setStep,
@ -137,7 +149,7 @@ export function CreateAccountModal({ onSuccess, onClose, initialStep = 'choose'
handleImport, handleImport,
} = useAccountCreation(initialStep) } = useAccountCreation(initialStep)
const handleContinue = () => { const handleContinue = (): void => {
onSuccess(npub) onSuccess(npub)
onClose() onClose()
} }

View File

@ -19,7 +19,7 @@ export function RecoveryPhraseDisplay({
recoveryPhrase: string[] recoveryPhrase: string[]
copied: boolean copied: boolean
onCopy: () => void onCopy: () => void
}): JSX.Element { }): React.ReactElement {
return ( return (
<div className="bg-cyber-darker border border-neon-cyan/30 rounded-lg p-6 mb-6"> <div className="bg-cyber-darker border border-neon-cyan/30 rounded-lg p-6 mb-6">
<div className="grid grid-cols-2 gap-4 mb-4"> <div className="grid grid-cols-2 gap-4 mb-4">
@ -45,7 +45,7 @@ export function RecoveryPhraseDisplay({
) )
} }
export function PublicKeyDisplay({ npub }: { npub: string }): JSX.Element { export function PublicKeyDisplay({ npub }: { npub: string }): React.ReactElement {
return ( return (
<div className="bg-neon-blue/10 border border-neon-blue/30 rounded-lg p-4 mb-6"> <div className="bg-neon-blue/10 border border-neon-blue/30 rounded-lg p-4 mb-6">
<p className="text-neon-blue font-semibold mb-2">{t('account.create.publicKey')}</p> <p className="text-neon-blue font-semibold mb-2">{t('account.create.publicKey')}</p>
@ -62,7 +62,7 @@ export function ImportKeyForm({
importKey: string importKey: string
setImportKey: (key: string) => void setImportKey: (key: string) => void
error: string | null error: string | null
}): JSX.Element { }): React.ReactElement {
return ( return (
<> <>
<div className="mb-4"> <div className="mb-4">
@ -84,7 +84,7 @@ export function ImportKeyForm({
) )
} }
export function ImportStepButtons({ loading, onImport, onBack }: { loading: boolean; onImport: () => void; onBack: () => void }): JSX.Element { export function ImportStepButtons({ loading, onImport, onBack }: { loading: boolean; onImport: () => void; onBack: () => void }): React.ReactElement {
return ( return (
<div className="flex gap-4"> <div className="flex gap-4">
<button <button
@ -116,7 +116,7 @@ export function ChooseStepButtons({
onGenerate: () => void onGenerate: () => void
onImport: () => void onImport: () => void
onClose: () => void onClose: () => void
}): JSX.Element { }): React.ReactElement {
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<button <button

View File

@ -10,7 +10,7 @@ export function RecoveryStep({
recoveryPhrase: string[] recoveryPhrase: string[]
npub: string npub: string
onContinue: () => void onContinue: () => void
}): JSX.Element { }): React.ReactElement {
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
const handleCopy = async (): Promise<void> => { const handleCopy = async (): Promise<void> => {
@ -55,7 +55,7 @@ export function ImportStep({
error: string | null error: string | null
onImport: () => void onImport: () => void
onBack: () => void onBack: () => void
}): JSX.Element { }): React.ReactElement {
return ( return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-cyber-dark border border-neon-cyan/30 rounded-lg p-6 max-w-md w-full mx-4 shadow-glow-cyan"> <div className="bg-cyber-dark border border-neon-cyan/30 rounded-lg p-6 max-w-md w-full mx-4 shadow-glow-cyan">
@ -79,7 +79,7 @@ export function ChooseStep({
onGenerate: () => void onGenerate: () => void
onImport: () => void onImport: () => void
onClose: () => void onClose: () => void
}): JSX.Element { }): React.ReactElement {
return ( return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-cyber-dark border border-neon-cyan/30 rounded-lg p-6 max-w-md w-full mx-4 shadow-glow-cyan"> <div className="bg-cyber-dark border border-neon-cyan/30 rounded-lg p-6 max-w-md w-full mx-4 shadow-glow-cyan">

View File

@ -21,7 +21,7 @@ interface SeriesDraft {
category: ArticleDraft['category'] category: ArticleDraft['category']
} }
export function CreateSeriesModal({ isOpen, onClose, onSuccess, authorPubkey }: CreateSeriesModalProps) { export function CreateSeriesModal({ isOpen, onClose, onSuccess, authorPubkey }: CreateSeriesModalProps): React.ReactElement | null {
const { pubkey, isUnlocked } = useNostrAuth() const { pubkey, isUnlocked } = useNostrAuth()
const [draft, setDraft] = useState<SeriesDraft>({ const [draft, setDraft] = useState<SeriesDraft>({
title: '', title: '',
@ -40,7 +40,7 @@ export function CreateSeriesModal({ isOpen, onClose, onSuccess, authorPubkey }:
const privateKey = nostrService.getPrivateKey() const privateKey = nostrService.getPrivateKey()
const canPublish = pubkey === authorPubkey && isUnlocked && privateKey !== null const canPublish = pubkey === authorPubkey && isUnlocked && privateKey !== null
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent): Promise<void> => {
e.preventDefault() e.preventDefault()
if (!canPublish) { if (!canPublish) {
setError(t('series.create.error.notAuthor')) setError(t('series.create.error.notAuthor'))
@ -86,7 +86,7 @@ export function CreateSeriesModal({ isOpen, onClose, onSuccess, authorPubkey }:
} }
} }
const handleClose = () => { const handleClose = (): void => {
if (!loading) { if (!loading) {
setDraft({ setDraft({
title: '', title: '',

View File

@ -6,7 +6,7 @@ interface DocsContentProps {
loading: boolean loading: boolean
} }
export function DocsContent({ content, loading }: DocsContentProps): JSX.Element { export function DocsContent({ content, loading }: DocsContentProps): React.ReactElement {
if (loading) { if (loading) {
return ( return (
<div className="text-center py-12"> <div className="text-center py-12">

View File

@ -7,7 +7,7 @@ interface DocsSidebarProps {
onSelectDoc: (docId: DocSection) => void onSelectDoc: (docId: DocSection) => void
} }
export function DocsSidebar({ docs, selectedDoc, onSelectDoc }: DocsSidebarProps): JSX.Element { export function DocsSidebar({ docs, selectedDoc, onSelectDoc }: DocsSidebarProps): React.ReactElement {
return ( return (
<aside className="lg:w-64 flex-shrink-0"> <aside className="lg:w-64 flex-shrink-0">
<div className="bg-cyber-dark border border-neon-cyan/20 rounded-lg p-4 sticky top-4 backdrop-blur-sm"> <div className="bg-cyber-dark border border-neon-cyan/20 rounded-lg p-4 sticky top-4 backdrop-blur-sm">

View File

@ -1,7 +1,7 @@
import Link from 'next/link' import Link from 'next/link'
import { t } from '@/lib/i18n' import { t } from '@/lib/i18n'
export function Footer(): JSX.Element { export function Footer(): React.ReactElement {
return ( return (
<footer className="bg-cyber-dark border-t border-neon-cyan/30 mt-12"> <footer className="bg-cyber-dark border-t border-neon-cyan/30 mt-12">
<div className="max-w-4xl mx-auto px-4 py-6"> <div className="max-w-4xl mx-auto px-4 py-6">

View File

@ -6,7 +6,7 @@ interface FundingProgressBarProps {
progressPercent: number progressPercent: number
} }
function FundingProgressBar({ progressPercent }: FundingProgressBarProps): JSX.Element { function FundingProgressBar({ progressPercent }: FundingProgressBarProps): React.ReactElement {
return ( return (
<div className="relative w-full h-4 bg-cyber-dark rounded-full overflow-hidden border border-neon-cyan/30"> <div className="relative w-full h-4 bg-cyber-dark rounded-full overflow-hidden border border-neon-cyan/30">
<div <div
@ -22,7 +22,7 @@ function FundingProgressBar({ progressPercent }: FundingProgressBarProps): JSX.E
) )
} }
function FundingStats({ stats }: { stats: ReturnType<typeof estimatePlatformFunds> }): JSX.Element { function FundingStats({ stats }: { stats: ReturnType<typeof estimatePlatformFunds> }): React.ReactElement {
const progressPercent = Math.min(100, stats.progressPercent) const progressPercent = Math.min(100, stats.progressPercent)
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@ -41,7 +41,7 @@ function FundingStats({ stats }: { stats: ReturnType<typeof estimatePlatformFund
) )
} }
export function FundingGauge(): JSX.Element { export function FundingGauge(): React.ReactElement {
const [stats, setStats] = useState(estimatePlatformFunds()) const [stats, setStats] = useState(estimatePlatformFunds())
const [certificationStats, setCertificationStats] = useState(estimatePlatformFunds()) const [certificationStats, setCertificationStats] = useState(estimatePlatformFunds())
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@ -75,11 +75,15 @@ export function FundingGauge(): JSX.Element {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="bg-cyber-dark/50 border border-neon-cyan/20 rounded-lg p-6"> <div className="bg-cyber-dark/50 border border-neon-cyan/20 rounded-lg p-6">
<h2 className="text-xl font-semibold text-neon-cyan mb-4">{t('home.funding.title')}</h2> <h2 className="text-xl font-semibold text-neon-cyan mb-4">
{t('home.funding.title')} - {t('home.funding.priority.ia')}
</h2>
<FundingStats stats={stats} /> <FundingStats stats={stats} />
</div> </div>
<div className="bg-cyber-dark/50 border border-neon-cyan/20 rounded-lg p-6"> <div className="bg-cyber-dark/50 border border-neon-cyan/20 rounded-lg p-6">
<h2 className="text-xl font-semibold text-neon-cyan mb-4">{t('home.funding.certification.title')}</h2> <h2 className="text-xl font-semibold text-neon-cyan mb-4">
{t('home.funding.certification.title')} - {t('home.funding.priority.ancrage')}
</h2>
<FundingStats stats={certificationStats} /> <FundingStats stats={certificationStats} />
</div> </div>
</div> </div>

View File

@ -7,7 +7,6 @@ import { ArticlesList } from '@/components/ArticlesList'
import { AuthorsList } from '@/components/AuthorsList' import { AuthorsList } from '@/components/AuthorsList'
import { PageHeader } from '@/components/PageHeader' import { PageHeader } from '@/components/PageHeader'
import { Footer } from '@/components/Footer' import { Footer } from '@/components/Footer'
import { FundingGauge } from '@/components/FundingGauge'
import type { Dispatch, SetStateAction } from 'react' import type { Dispatch, SetStateAction } from 'react'
import { t } from '@/lib/i18n' import { t } from '@/lib/i18n'
@ -47,7 +46,7 @@ function ArticlesHero({
setSearchQuery, setSearchQuery,
selectedCategory, selectedCategory,
setSelectedCategory, setSelectedCategory,
}: Pick<HomeViewProps, 'searchQuery' | 'setSearchQuery' | 'selectedCategory' | 'setSelectedCategory'>) { }: Pick<HomeViewProps, 'searchQuery' | 'setSearchQuery' | 'selectedCategory' | 'setSelectedCategory'>): React.ReactElement {
return ( return (
<div className="mb-8"> <div className="mb-8">
<CategoryTabs selectedCategory={selectedCategory} onCategoryChange={setSelectedCategory} /> <CategoryTabs selectedCategory={selectedCategory} onCategoryChange={setSelectedCategory} />
@ -58,17 +57,6 @@ function ArticlesHero({
) )
} }
function HomeIntroSection() {
return (
<div className="mt-12 mb-8">
<p className="mb-6 text-sm text-cyber-accent/70">
{t('home.funding.description')}
</p>
<FundingGauge />
</div>
)
}
function HomeContent({ function HomeContent({
searchQuery, searchQuery,
setSearchQuery, setSearchQuery,
@ -84,7 +72,7 @@ function HomeContent({
error, error,
onUnlock, onUnlock,
unlockedArticles, unlockedArticles,
}: HomeViewProps) { }: HomeViewProps): React.ReactElement {
const shouldShowFilters = !loading && allArticles.length > 0 const shouldShowFilters = !loading && allArticles.length > 0
const shouldShowAuthors = selectedCategory !== null && selectedCategory !== 'all' const shouldShowAuthors = selectedCategory !== null && selectedCategory !== 'all'
@ -102,7 +90,7 @@ function HomeContent({
const authorsListProps = { authors, allAuthors, loading: loading && !isInitialLoad, error } const authorsListProps = { authors, allAuthors, loading: loading && !isInitialLoad, error }
return ( return (
<div className="max-w-4xl mx-auto px-4 py-8"> <div className="w-full px-4 py-8">
<ArticlesHero <ArticlesHero
searchQuery={searchQuery} searchQuery={searchQuery}
setSearchQuery={setSearchQuery} setSearchQuery={setSearchQuery}
@ -123,13 +111,11 @@ function HomeContent({
) : ( ) : (
<ArticlesList {...articlesListProps} /> <ArticlesList {...articlesListProps} />
)} )}
<HomeIntroSection />
</div> </div>
) )
} }
export function HomeView(props: HomeViewProps) { export function HomeView(props: HomeViewProps): React.ReactElement {
return ( return (
<> <>
<HomeHead /> <HomeHead />

View File

@ -12,7 +12,7 @@ interface ImageUploadFieldProps {
helpText?: string | undefined helpText?: string | undefined
} }
function ImagePreview({ value }: { value: string }) { function ImagePreview({ value }: { value: string }): React.ReactElement {
return ( return (
<div className="relative w-32 h-32 rounded-lg overflow-hidden border border-neon-cyan/20"> <div className="relative w-32 h-32 rounded-lg overflow-hidden border border-neon-cyan/20">
<Image <Image
@ -25,14 +25,14 @@ function ImagePreview({ value }: { value: string }) {
) )
} }
function UploadButtonLabel({ uploading, value }: { uploading: boolean; value: string | undefined }) { function UploadButtonLabel({ uploading, value }: { uploading: boolean; value: string | undefined }): React.ReactElement {
if (uploading) { if (uploading) {
return <>{t('presentation.field.picture.uploading')}</> return <>{t('presentation.field.picture.uploading')}</>
} }
return <>{value ? t('presentation.field.picture.change') : t('presentation.field.picture.upload')}</> return <>{value ? t('presentation.field.picture.change') : t('presentation.field.picture.upload')}</>
} }
function RemoveButton({ value, onChange }: { value: string | undefined; onChange: (url: string) => void }) { function RemoveButton({ value, onChange }: { value: string | undefined; onChange: (url: string) => void }): React.ReactElement | null {
if (!value) { if (!value) {
return null return null
} }
@ -59,7 +59,7 @@ function ImageUploadControls({
value: string | undefined value: string | undefined
onChange: (url: string) => void onChange: (url: string) => void
onFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => Promise<void> onFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => Promise<void>
}) { }): React.ReactElement {
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label <label
@ -83,7 +83,7 @@ function ImageUploadControls({
) )
} }
async function processFileUpload(file: File, onChange: (url: string) => void, setError: (error: string | null) => void) { async function processFileUpload(file: File, onChange: (url: string) => void, setError: (error: string | null) => void): Promise<void> {
const media = await uploadNip95Media(file) const media = await uploadNip95Media(file)
if (media.type === 'image') { if (media.type === 'image') {
onChange(media.url) onChange(media.url)
@ -92,13 +92,20 @@ async function processFileUpload(file: File, onChange: (url: string) => void, se
} }
} }
function useImageUpload(onChange: (url: string) => void) { function useImageUpload(onChange: (url: string) => void): {
uploading: boolean
error: string | null
handleFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => Promise<void>
showUnlockModal: boolean
setShowUnlockModal: (show: boolean) => void
handleUnlockSuccess: () => Promise<void>
} {
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [showUnlockModal, setShowUnlockModal] = useState(false) const [showUnlockModal, setShowUnlockModal] = useState(false)
const [pendingFile, setPendingFile] = useState<File | null>(null) const [pendingFile, setPendingFile] = useState<File | null>(null)
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
const file = e.target.files?.[0] const file = e.target.files?.[0]
if (!file) { if (!file) {
return return
@ -124,7 +131,7 @@ function useImageUpload(onChange: (url: string) => void) {
} }
} }
const handleUnlockSuccess = async () => { const handleUnlockSuccess = async (): Promise<void> => {
setShowUnlockModal(false) setShowUnlockModal(false)
if (pendingFile) { if (pendingFile) {
// Retry upload after unlock // Retry upload after unlock
@ -144,7 +151,7 @@ function useImageUpload(onChange: (url: string) => void) {
return { uploading, error, handleFileSelect, showUnlockModal, setShowUnlockModal, handleUnlockSuccess } return { uploading, error, handleFileSelect, showUnlockModal, setShowUnlockModal, handleUnlockSuccess }
} }
export function ImageUploadField({ id, label, value, onChange, helpText }: ImageUploadFieldProps) { export function ImageUploadField({ id, label, value, onChange, helpText }: ImageUploadFieldProps): React.ReactElement {
const { uploading, error, handleFileSelect, showUnlockModal, setShowUnlockModal, handleUnlockSuccess } = useImageUpload(onChange) const { uploading, error, handleFileSelect, showUnlockModal, setShowUnlockModal, handleUnlockSuccess } = useImageUpload(onChange)
const displayLabel = label ?? t('presentation.field.picture') const displayLabel = label ?? t('presentation.field.picture')
const displayHelpText = helpText ?? t('presentation.field.picture.help') const displayHelpText = helpText ?? t('presentation.field.picture.help')

View File

@ -2,7 +2,7 @@ import Link from 'next/link'
import { useNostrAuth } from '@/hooks/useNostrAuth' import { useNostrAuth } from '@/hooks/useNostrAuth'
import { t } from '@/lib/i18n' import { t } from '@/lib/i18n'
export function KeyIndicator() { export function KeyIndicator(): React.ReactElement {
const { pubkey, isUnlocked } = useNostrAuth() const { pubkey, isUnlocked } = useNostrAuth()
// Determine color and title based on key status // Determine color and title based on key status

View File

@ -9,7 +9,7 @@ interface PublicKeys {
npub: string npub: string
} }
export function KeyManagementManager() { export function KeyManagementManager(): React.ReactElement {
const [publicKeys, setPublicKeys] = useState<PublicKeys | null>(null) const [publicKeys, setPublicKeys] = useState<PublicKeys | null>(null)
const [accountExists, setAccountExists] = useState(false) const [accountExists, setAccountExists] = useState(false)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@ -28,7 +28,7 @@ export function KeyManagementManager() {
void loadKeys() void loadKeys()
}, []) }, [])
async function loadKeys() { async function loadKeys(): Promise<void> {
try { try {
setLoading(true) setLoading(true)
setError(null) setError(null)
@ -78,7 +78,7 @@ export function KeyManagementManager() {
} }
} }
async function handleImport() { async function handleImport(): Promise<void> {
if (!importKey.trim()) { if (!importKey.trim()) {
setError(t('settings.keyManagement.import.error.required')) setError(t('settings.keyManagement.import.error.required'))
return return
@ -119,7 +119,7 @@ export function KeyManagementManager() {
await performImport(extractedKey) await performImport(extractedKey)
} }
async function performImport(key: string) { async function performImport(key: string): Promise<void> {
try { try {
setImporting(true) setImporting(true)
setError(null) setError(null)
@ -152,7 +152,7 @@ export function KeyManagementManager() {
} }
} }
async function handleCopyRecoveryPhrase() { async function handleCopyRecoveryPhrase(): Promise<void> {
if (!recoveryPhrase) { if (!recoveryPhrase) {
return return
} }
@ -167,7 +167,7 @@ export function KeyManagementManager() {
} }
} }
async function handleCopyNpub() { async function handleCopyNpub(): Promise<void> {
if (!publicKeys?.npub) { if (!publicKeys?.npub) {
return return
} }
@ -182,7 +182,7 @@ export function KeyManagementManager() {
} }
} }
async function handleCopyPublicKey() { async function handleCopyPublicKey(): Promise<void> {
if (!publicKeys?.publicKey) { if (!publicKeys?.publicKey) {
return return
} }

View File

@ -10,7 +10,7 @@ interface LocaleButtonProps {
onClick: (locale: Locale) => void onClick: (locale: Locale) => void
} }
function LocaleButton({ locale, label, currentLocale, onClick }: LocaleButtonProps) { function LocaleButton({ locale, label, currentLocale, onClick }: LocaleButtonProps): React.ReactElement {
const isActive = currentLocale === locale const isActive = currentLocale === locale
return ( return (
<button <button
@ -26,12 +26,12 @@ function LocaleButton({ locale, label, currentLocale, onClick }: LocaleButtonPro
) )
} }
export function LanguageSelector() { export function LanguageSelector(): React.ReactElement {
const [currentLocale, setCurrentLocale] = useState<Locale>(getLocale()) const [currentLocale, setCurrentLocale] = useState<Locale>(getLocale())
useEffect(() => { useEffect(() => {
// Load saved locale from IndexedDB // Load saved locale from IndexedDB
const loadLocale = async () => { const loadLocale = async (): Promise<void> => {
try { try {
const { storageService } = await import('@/lib/storage/indexedDB') const { storageService } = await import('@/lib/storage/indexedDB')
const savedLocale = await storageService.get<Locale>(LOCALE_STORAGE_KEY, 'app_storage') const savedLocale = await storageService.get<Locale>(LOCALE_STORAGE_KEY, 'app_storage')
@ -46,7 +46,7 @@ export function LanguageSelector() {
void loadLocale() void loadLocale()
}, []) }, [])
const handleLocaleChange = async (locale: Locale) => { const handleLocaleChange = async (locale: Locale): Promise<void> => {
setLocale(locale) setLocale(locale)
setCurrentLocale(locale) setCurrentLocale(locale)
try { try {

View File

@ -10,11 +10,11 @@ interface MarkdownEditorProps {
onBannerChange?: (url: string) => void onBannerChange?: (url: string) => void
} }
export function MarkdownEditor(props: MarkdownEditorProps) { export function MarkdownEditor(props: MarkdownEditorProps): React.ReactElement {
return <MarkdownEditorInner {...props} /> return <MarkdownEditorInner {...props} />
} }
function MarkdownEditorInner({ value, onChange, onMediaAdd, onBannerChange }: MarkdownEditorProps) { function MarkdownEditorInner({ value, onChange, onMediaAdd, onBannerChange }: MarkdownEditorProps): React.ReactElement {
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [preview, setPreview] = useState(false) const [preview, setPreview] = useState(false)
@ -61,7 +61,7 @@ function MarkdownToolbar({
onFileSelected: (file: File) => void onFileSelected: (file: File) => void
uploading: boolean uploading: boolean
error: string | null error: string | null
}) { }): React.ReactElement {
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button type="button" className="px-3 py-1 text-sm rounded bg-gray-200" onClick={onTogglePreview}> <button type="button" className="px-3 py-1 text-sm rounded bg-gray-200" onClick={onTogglePreview}>
@ -87,7 +87,7 @@ function MarkdownToolbar({
) )
} }
function MarkdownPreview({ value }: { value: string }) { function MarkdownPreview({ value }: { value: string }): React.ReactElement {
return <div className="prose max-w-none border rounded p-3 bg-white whitespace-pre-wrap">{value}</div> return <div className="prose max-w-none border rounded p-3 bg-white whitespace-pre-wrap">{value}</div>
} }
@ -99,7 +99,7 @@ async function handleUpload(
onMediaAdd?: (media: MediaRef) => void onMediaAdd?: (media: MediaRef) => void
onBannerChange?: (url: string) => void onBannerChange?: (url: string) => void
} }
) { ): Promise<void> {
handlers.setError(null) handlers.setError(null)
handlers.setUploading(true) handlers.setUploading(true)
try { try {

View File

@ -0,0 +1,294 @@
import { useState } from 'react'
import type { MediaRef, Page } from '@/types/nostr'
import { uploadNip95Media } from '@/lib/nip95'
import { t } from '@/lib/i18n'
interface MarkdownEditorTwoColumnsProps {
value: string
onChange: (value: string) => void
pages?: Page[]
onPagesChange?: (pages: Page[]) => void
onMediaAdd?: (media: MediaRef) => void
onBannerChange?: (url: string) => void
}
export function MarkdownEditorTwoColumns({
value,
onChange,
pages = [],
onPagesChange,
onMediaAdd,
onBannerChange,
}: MarkdownEditorTwoColumnsProps): React.ReactElement {
const [uploading, setUploading] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleAddPage = (type: 'markdown' | 'image'): void => {
if (!onPagesChange) {
return
}
const newPage: Page = {
number: pages.length + 1,
type,
content: type === 'markdown' ? '' : '',
}
onPagesChange([...pages, newPage])
}
const handlePageContentChange = (pageNumber: number, content: string): void => {
if (!onPagesChange) {
return
}
const updatedPages = pages.map((p) => (p.number === pageNumber ? { ...p, content } : p))
onPagesChange(updatedPages)
}
const handlePageTypeChange = (pageNumber: number, type: 'markdown' | 'image'): void => {
if (!onPagesChange) {
return
}
const updatedPages = pages.map((p) => (p.number === pageNumber ? { ...p, type, content: '' } : p))
onPagesChange(updatedPages)
}
const handleRemovePage = (pageNumber: number): void => {
if (!onPagesChange) {
return
}
const updatedPages = pages
.filter((p) => p.number !== pageNumber)
.map((p, index) => ({ ...p, number: index + 1 }))
onPagesChange(updatedPages)
}
const handleImageUpload = async (file: File, pageNumber?: number): Promise<void> => {
setError(null)
setUploading(true)
try {
const media = await uploadNip95Media(file)
if (media.type === 'image') {
if (pageNumber !== undefined && onPagesChange) {
handlePageContentChange(pageNumber, media.url)
} else {
onBannerChange?.(media.url)
}
onMediaAdd?.(media)
}
} catch (e) {
setError(e instanceof Error ? e.message : t('upload.error.failed'))
} finally {
setUploading(false)
}
}
return (
<div className="space-y-4">
<MarkdownToolbar
onFileSelected={(file) => {
void handleImageUpload(file)
}}
uploading={uploading}
error={error}
onAddPage={onPagesChange ? handleAddPage : undefined}
/>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="block text-sm font-semibold text-gray-800">{t('markdown.editor')}</label>
<textarea
className="w-full border rounded p-3 h-96 font-mono text-sm"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={t('markdown.placeholder')}
/>
</div>
<div className="space-y-2">
<label className="block text-sm font-semibold text-gray-800">{t('markdown.preview')}</label>
<MarkdownPreview value={value} />
</div>
</div>
{onPagesChange && (
<PagesManager
pages={pages}
onPageContentChange={handlePageContentChange}
onPageTypeChange={handlePageTypeChange}
onRemovePage={handleRemovePage}
onImageUpload={handleImageUpload}
/>
)}
</div>
)
}
function MarkdownToolbar({
onFileSelected,
uploading,
error,
onAddPage,
}: {
onFileSelected: (file: File) => void
uploading: boolean
error: string | null
onAddPage?: (type: 'markdown' | 'image') => void
}): React.ReactElement {
return (
<div className="flex items-center gap-2 flex-wrap">
<label className="px-3 py-1 text-sm rounded bg-blue-600 text-white cursor-pointer hover:bg-blue-700">
{t('markdown.upload.media')}
<input
type="file"
accept=".png,.jpg,.jpeg,.webp"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) {
onFileSelected(file)
}
}}
/>
</label>
{onAddPage && (
<>
<button
type="button"
className="px-3 py-1 text-sm rounded bg-green-600 text-white hover:bg-green-700"
onClick={() => onAddPage('markdown')}
>
{t('page.add.markdown')}
</button>
<button
type="button"
className="px-3 py-1 text-sm rounded bg-purple-600 text-white hover:bg-purple-700"
onClick={() => onAddPage('image')}
>
{t('page.add.image')}
</button>
</>
)}
{uploading && <span className="text-sm text-gray-500">{t('markdown.upload.uploading')}</span>}
{error && <span className="text-sm text-red-600">{error}</span>}
</div>
)
}
function MarkdownPreview({ value }: { value: string }): React.ReactElement {
return (
<div className="prose max-w-none border rounded p-3 bg-white h-96 overflow-y-auto whitespace-pre-wrap">
{value || <span className="text-gray-400">{t('markdown.preview.empty')}</span>}
</div>
)
}
function PagesManager({
pages,
onPageContentChange,
onPageTypeChange,
onRemovePage,
onImageUpload,
}: {
pages: Page[]
onPageContentChange: (pageNumber: number, content: string) => void
onPageTypeChange: (pageNumber: number, type: 'markdown' | 'image') => void
onRemovePage: (pageNumber: number) => void
onImageUpload: (file: File, pageNumber: number) => Promise<void>
}): React.ReactElement {
if (pages.length === 0) {
return <div className="text-sm text-gray-500">{t('page.empty')}</div>
}
return (
<div className="space-y-4">
<h3 className="text-lg font-semibold">{t('page.title')}</h3>
{pages.map((page) => (
<PageEditor
key={page.number}
page={page}
onContentChange={(content) => onPageContentChange(page.number, content)}
onTypeChange={(type) => onPageTypeChange(page.number, type)}
onRemove={() => onRemovePage(page.number)}
onImageUpload={(file) => {
void onImageUpload(file, page.number)
}}
/>
))}
</div>
)
}
function PageEditor({
page,
onContentChange,
onTypeChange,
onRemove,
onImageUpload,
}: {
page: Page
onContentChange: (content: string) => void
onTypeChange: (type: 'markdown' | 'image') => void
onRemove: () => void
onImageUpload: (file: File) => Promise<void>
}): React.ReactElement {
return (
<div className="border rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<h4 className="font-semibold">
{t('page.number', { number: page.number })} - {t(`page.type.${page.type}`)}
</h4>
<div className="flex items-center gap-2">
<select
value={page.type}
onChange={(e) => onTypeChange(e.target.value as 'markdown' | 'image')}
className="text-sm border rounded px-2 py-1"
>
<option value="markdown">{t('page.type.markdown')}</option>
<option value="image">{t('page.type.image')}</option>
</select>
<button
type="button"
className="px-2 py-1 text-sm rounded bg-red-600 text-white hover:bg-red-700"
onClick={onRemove}
>
{t('page.remove')}
</button>
</div>
</div>
{page.type === 'markdown' ? (
<textarea
className="w-full border rounded p-2 h-48 font-mono text-sm"
value={page.content}
onChange={(e) => onContentChange(e.target.value)}
placeholder={t('page.markdown.placeholder')}
/>
) : (
<div className="space-y-2">
{page.content ? (
<div className="relative">
<img src={page.content} alt={t('page.image.alt', { number: page.number })} className="max-w-full h-auto rounded" />
<button
type="button"
className="absolute top-2 right-2 px-2 py-1 text-sm rounded bg-red-600 text-white hover:bg-red-700"
onClick={() => onContentChange('')}
>
{t('page.image.remove')}
</button>
</div>
) : (
<label className="block px-3 py-2 text-sm rounded bg-blue-600 text-white cursor-pointer hover:bg-blue-700 text-center">
{t('page.image.upload')}
<input
type="file"
accept=".png,.jpg,.jpeg,.webp"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) {
void onImageUpload(file)
}
}}
/>
</label>
)}
</div>
)}
</div>
)
}

View File

@ -8,7 +8,7 @@ interface Nip95ConfigManagerProps {
onConfigChange?: () => void onConfigChange?: () => void
} }
export function Nip95ConfigManager({ onConfigChange }: Nip95ConfigManagerProps) { export function Nip95ConfigManager({ onConfigChange }: Nip95ConfigManagerProps): React.ReactElement {
const [apis, setApis] = useState<Nip95Config[]>([]) const [apis, setApis] = useState<Nip95Config[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@ -20,7 +20,7 @@ export function Nip95ConfigManager({ onConfigChange }: Nip95ConfigManagerProps)
void loadApis() void loadApis()
}, []) }, [])
async function loadApis() { async function loadApis(): Promise<void> {
try { try {
setLoading(true) setLoading(true)
setError(null) setError(null)
@ -35,7 +35,7 @@ export function Nip95ConfigManager({ onConfigChange }: Nip95ConfigManagerProps)
} }
} }
async function handleToggleEnabled(id: string, enabled: boolean) { async function handleToggleEnabled(id: string, enabled: boolean): Promise<void> {
try { try {
await configStorage.updateNip95Api(id, { enabled }) await configStorage.updateNip95Api(id, { enabled })
await loadApis() await loadApis()
@ -47,7 +47,7 @@ export function Nip95ConfigManager({ onConfigChange }: Nip95ConfigManagerProps)
} }
} }
async function handleUpdatePriority(id: string, priority: number) { async function handleUpdatePriority(id: string, priority: number): Promise<void> {
try { try {
await configStorage.updateNip95Api(id, { priority }) await configStorage.updateNip95Api(id, { priority })
await loadApis() await loadApis()
@ -59,7 +59,7 @@ export function Nip95ConfigManager({ onConfigChange }: Nip95ConfigManagerProps)
} }
} }
async function handleUpdateUrl(id: string, url: string) { async function handleUpdateUrl(id: string, url: string): Promise<void> {
try { try {
await configStorage.updateNip95Api(id, { url }) await configStorage.updateNip95Api(id, { url })
await loadApis() await loadApis()
@ -72,7 +72,7 @@ export function Nip95ConfigManager({ onConfigChange }: Nip95ConfigManagerProps)
} }
} }
async function handleAddApi() { async function handleAddApi(): Promise<void> {
if (!newUrl.trim()) { if (!newUrl.trim()) {
setError(t('settings.nip95.error.urlRequired')) setError(t('settings.nip95.error.urlRequired'))
return return
@ -97,7 +97,7 @@ export function Nip95ConfigManager({ onConfigChange }: Nip95ConfigManagerProps)
} }
} }
async function handleRemoveApi(id: string) { async function handleRemoveApi(id: string): Promise<void> {
if (!userConfirm(t('settings.nip95.remove.confirm'))) { if (!userConfirm(t('settings.nip95.remove.confirm'))) {
return return
} }

View File

@ -6,7 +6,7 @@ interface NotificationActionsProps {
onDelete: () => void onDelete: () => void
} }
export function NotificationActions({ timestamp, onDelete }: NotificationActionsProps) { export function NotificationActions({ timestamp, onDelete }: NotificationActionsProps): React.ReactElement {
return ( return (
<div className="flex items-start gap-2 ml-4"> <div className="flex items-start gap-2 ml-4">
<span className="text-xs text-gray-400 whitespace-nowrap">{formatTime(timestamp)}</span> <span className="text-xs text-gray-400 whitespace-nowrap">{formatTime(timestamp)}</span>

View File

@ -5,7 +5,7 @@ interface NotificationBadgeProps {
onClick?: () => void onClick?: () => void
} }
export function NotificationBadge({ userPubkey, onClick }: NotificationBadgeProps) { export function NotificationBadge({ userPubkey, onClick }: NotificationBadgeProps): React.ReactElement | null {
const { unreadCount } = useNotifications(userPubkey) const { unreadCount } = useNotifications(userPubkey)
if (!userPubkey || unreadCount === 0) { if (!userPubkey || unreadCount === 0) {

View File

@ -4,7 +4,7 @@ interface NotificationBadgeButtonProps {
onClick: () => void onClick: () => void
} }
export function NotificationBadgeButton({ unreadCount, onClick }: NotificationBadgeButtonProps) { export function NotificationBadgeButton({ unreadCount, onClick }: NotificationBadgeButtonProps): React.ReactElement {
return ( return (
<button <button
onClick={onClick} onClick={onClick}

View File

@ -8,7 +8,7 @@ interface NotificationCenterProps {
onClose?: () => void onClose?: () => void
} }
export function NotificationCenter({ userPubkey, onClose }: NotificationCenterProps) { export function NotificationCenter({ userPubkey, onClose }: NotificationCenterProps): React.ReactElement | null {
const { const {
notifications, notifications,
unreadCount, unreadCount,

View File

@ -5,7 +5,7 @@ interface NotificationContentProps {
notification: Notification notification: Notification
} }
export function NotificationContent({ notification }: NotificationContentProps) { export function NotificationContent({ notification }: NotificationContentProps): React.ReactElement {
return ( return (
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">

View File

@ -12,8 +12,8 @@ export function NotificationItem({
notification, notification,
onNotificationClick, onNotificationClick,
onDelete, onDelete,
}: NotificationItemProps) { }: NotificationItemProps): React.ReactElement {
const handleDelete = () => { const handleDelete = (): void => {
onDelete(notification.id) onDelete(notification.id)
} }

View File

@ -16,7 +16,7 @@ function NotificationList({ notifications, onNotificationClick, onDelete }: {
notifications: Notification[] notifications: Notification[]
onNotificationClick: (notification: Notification) => void onNotificationClick: (notification: Notification) => void
onDelete: (id: string) => void onDelete: (id: string) => void
}) { }): React.ReactElement {
if (notifications.length === 0) { if (notifications.length === 0) {
return ( return (
<div className="p-8 text-center text-gray-500"> <div className="p-8 text-center text-gray-500">
@ -46,7 +46,7 @@ export function NotificationPanel({
onDelete, onDelete,
onMarkAllAsRead, onMarkAllAsRead,
onClose, onClose,
}: NotificationPanelProps) { }: NotificationPanelProps): React.ReactElement {
return ( return (
<> <>
<div className="fixed inset-0 z-40 bg-black bg-opacity-50" onClick={onClose} /> <div className="fixed inset-0 z-40 bg-black bg-opacity-50" onClick={onClose} />

View File

@ -10,7 +10,7 @@ export function NotificationPanelHeader({
unreadCount, unreadCount,
onMarkAllAsRead, onMarkAllAsRead,
onClose, onClose,
}: NotificationPanelHeaderProps): JSX.Element { }: NotificationPanelHeaderProps): React.ReactElement {
return ( return (
<div className="flex items-center justify-between p-4 border-b border-gray-200"> <div className="flex items-center justify-between p-4 border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-900">{t('notification.title')}</h3> <h3 className="text-lg font-semibold text-gray-900">{t('notification.title')}</h3>

View File

@ -4,7 +4,7 @@ import { LanguageSelector } from './LanguageSelector'
import { t } from '@/lib/i18n' import { t } from '@/lib/i18n'
import { KeyIndicator } from './KeyIndicator' import { KeyIndicator } from './KeyIndicator'
function GitIcon(): JSX.Element { function GitIcon(): React.ReactElement {
return ( return (
<svg <svg
className="w-5 h-5" className="w-5 h-5"
@ -17,7 +17,46 @@ function GitIcon(): JSX.Element {
) )
} }
export function PageHeader(): JSX.Element { function DocsIcon(): React.ReactElement {
return (
<svg
className="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</svg>
)
}
function FundingIcon(): React.ReactElement {
return (
<svg
className="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
xmlns="http://www.w3.org/2000/svg"
>
<line x1="12" y1="1" x2="12" y2="23" />
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
</svg>
)
}
export function PageHeader(): React.ReactElement {
return ( return (
<header className="bg-cyber-dark border-b border-neon-cyan/30 shadow-glow-cyan"> <header className="bg-cyber-dark border-b border-neon-cyan/30 shadow-glow-cyan">
<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">
@ -25,6 +64,20 @@ export function PageHeader(): JSX.Element {
<Link href="/" className="text-2xl font-bold text-neon-cyan text-glow-cyan font-mono hover:text-neon-green transition-colors"> <Link href="/" className="text-2xl font-bold text-neon-cyan text-glow-cyan font-mono hover:text-neon-green transition-colors">
{t('home.title')} {t('home.title')}
</Link> </Link>
<Link
href="/docs"
className="text-cyber-accent hover:text-neon-cyan transition-colors"
title={t('nav.documentation')}
>
<DocsIcon />
</Link>
<Link
href="/funding"
className="text-cyber-accent hover:text-neon-cyan transition-colors"
title={t('funding.title')}
>
<FundingIcon />
</Link>
<a <a
href="https://git.4nkweb.com/4nk/story-research-zapwall" href="https://git.4nkweb.com/4nk/story-research-zapwall"
target="_blank" target="_blank"
@ -39,12 +92,6 @@ export function PageHeader(): JSX.Element {
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<LanguageSelector /> <LanguageSelector />
<Link
href="/docs"
className="px-4 py-2 text-cyber-accent hover:text-neon-cyan text-sm font-medium transition-colors border border-cyber-accent/30 hover:border-neon-cyan/50 rounded hover:shadow-glow-cyan"
>
{t('nav.documentation')}
</Link>
<ConditionalPublishButton /> <ConditionalPublishButton />
</div> </div>
</div> </div>

View File

@ -39,7 +39,7 @@ function PaymentHeader({
amount: number amount: number
timeRemaining: number | null timeRemaining: number | null
onClose: () => void onClose: () => void
}): JSX.Element { }): React.ReactElement {
const timeLabel = useMemo((): string | null => { const timeLabel = useMemo((): string | null => {
if (timeRemaining === null) { if (timeRemaining === null) {
return null return null
@ -69,7 +69,7 @@ function PaymentHeader({
) )
} }
function InvoiceDisplay({ invoiceText, paymentUrl }: { invoiceText: string; paymentUrl: string }): JSX.Element { function InvoiceDisplay({ invoiceText, paymentUrl }: { invoiceText: string; paymentUrl: string }): React.ReactElement {
return ( return (
<div className="mb-4"> <div className="mb-4">
<p className="text-sm text-cyber-accent mb-2">{t('payment.modal.lightningInvoice')}</p> <p className="text-sm text-cyber-accent mb-2">{t('payment.modal.lightningInvoice')}</p>
@ -97,7 +97,7 @@ function PaymentActions({
copied: boolean copied: boolean
onCopy: () => Promise<void> onCopy: () => Promise<void>
onOpenWallet: () => void onOpenWallet: () => void
}): JSX.Element { }): React.ReactElement {
return ( return (
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
@ -118,7 +118,7 @@ function PaymentActions({
) )
} }
function ExpiredNotice({ show }: { show: boolean }): JSX.Element | null { function ExpiredNotice({ show }: { show: boolean }): React.ReactElement | null {
if (!show) { if (!show) {
return null return null
} }
@ -176,7 +176,7 @@ function usePaymentModalState(invoice: AlbyInvoice, onPaymentComplete: () => voi
return { copied, errorMessage, paymentUrl, timeRemaining, handleCopy, handleOpenWallet } return { copied, errorMessage, paymentUrl, timeRemaining, handleCopy, handleOpenWallet }
} }
export function PaymentModal({ invoice, onClose, onPaymentComplete }: PaymentModalProps): JSX.Element { export function PaymentModal({ invoice, onClose, onPaymentComplete }: PaymentModalProps): React.ReactElement {
const { copied, errorMessage, paymentUrl, timeRemaining, handleCopy, handleOpenWallet } = const { copied, errorMessage, paymentUrl, timeRemaining, handleCopy, handleOpenWallet } =
usePaymentModalState(invoice, onPaymentComplete) usePaymentModalState(invoice, onPaymentComplete)
const handleOpenWalletSync = (): void => { const handleOpenWalletSync = (): void => {

View File

@ -1,6 +1,6 @@
import { t } from '@/lib/i18n' import { t } from '@/lib/i18n'
export function PresentationFormHeader() { export function PresentationFormHeader(): React.ReactElement {
return ( return (
<div className="mb-6"> <div className="mb-6">
<p className="text-cyber-accent text-sm mb-2"> <p className="text-cyber-accent text-sm mb-2">

View File

@ -19,7 +19,7 @@ export function ProfileArticlesHeader({
setFilters, setFilters,
allArticles, allArticles,
articleFiltersVisible, articleFiltersVisible,
}: ProfileArticlesHeaderProps) { }: ProfileArticlesHeaderProps): React.ReactElement {
return ( return (
<div className="mb-6"> <div className="mb-6">
<h2 className="text-2xl font-bold mb-4">My Articles</h2> <h2 className="text-2xl font-bold mb-4">My Articles</h2>

View File

@ -21,7 +21,7 @@ export interface ProfileArticlesSectionProps {
articleFiltersVisible: boolean articleFiltersVisible: boolean
} }
export function ProfileArticlesSection(props: ProfileArticlesSectionProps) { export function ProfileArticlesSection(props: ProfileArticlesSectionProps): React.ReactElement {
const filtered = filterArticlesBySeries(props.articles, props.allArticles, props.selectedSeriesId) const filtered = filterArticlesBySeries(props.articles, props.allArticles, props.selectedSeriesId)
return ( return (

View File

@ -1,4 +1,4 @@
export function ArticlesSummary({ visibleCount, total }: { visibleCount: number; total: number }) { export function ArticlesSummary({ visibleCount, total }: { visibleCount: number; total: number }): React.ReactElement | null {
if (visibleCount === 0) { if (visibleCount === 0) {
return null return null
} }

View File

@ -1,6 +1,6 @@
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
export function BackButton() { export function BackButton(): React.ReactElement {
const router = useRouter() const router = useRouter()
return ( return (
<button <button

View File

@ -2,7 +2,7 @@ import { ConnectButton } from '@/components/ConnectButton'
import { ConditionalPublishButton } from './ConditionalPublishButton' import { ConditionalPublishButton } from './ConditionalPublishButton'
import { KeyIndicator } from './KeyIndicator' import { KeyIndicator } from './KeyIndicator'
function GitIcon() { function GitIcon(): React.ReactElement {
return ( return (
<svg <svg
className="w-5 h-5" className="w-5 h-5"
@ -15,7 +15,7 @@ function GitIcon() {
) )
} }
export function ProfileHeader() { export function ProfileHeader(): React.ReactElement {
return ( return (
<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">

View File

@ -7,7 +7,7 @@ interface ProfileSeriesBlockProps {
selectedSeriesId?: string | undefined selectedSeriesId?: string | undefined
} }
export function ProfileSeriesBlock({ currentPubkey, onSelectSeries, selectedSeriesId }: ProfileSeriesBlockProps) { export function ProfileSeriesBlock({ currentPubkey, onSelectSeries, selectedSeriesId }: ProfileSeriesBlockProps): React.ReactElement {
return ( return (
<div className="mb-6"> <div className="mb-6">
<h3 className="text-lg font-semibold mb-2">Séries</h3> <h3 className="text-lg font-semibold mb-2">Séries</h3>

View File

@ -24,7 +24,7 @@ interface ProfileViewProps {
onSelectSeries: (seriesId: string | undefined) => void onSelectSeries: (seriesId: string | undefined) => void
} }
function ProfileLoading() { function ProfileLoading(): React.ReactElement {
return ( return (
<div className="text-center py-12"> <div className="text-center py-12">
<p className="text-gray-500">Loading profile...</p> <p className="text-gray-500">Loading profile...</p>
@ -32,7 +32,7 @@ function ProfileLoading() {
) )
} }
function ProfileLayout(props: ProfileViewProps) { function ProfileLayout(props: ProfileViewProps): React.ReactElement {
const articleFiltersVisible = !props.loading && props.allArticles.length > 0 const articleFiltersVisible = !props.loading && props.allArticles.length > 0
return ( return (
@ -72,7 +72,7 @@ function ProfileHeaderSection({
profile: NostrProfile | null profile: NostrProfile | null
currentPubkey: string currentPubkey: string
articleCount: number articleCount: number
}) { }): React.ReactElement | null {
return ( return (
<> <>
<BackButton /> <BackButton />
@ -84,7 +84,7 @@ function ProfileHeaderSection({
</> </>
) )
} }
export function ProfileView(props: ProfileViewProps) { export function ProfileView(props: ProfileViewProps): React.ReactElement {
return ( return (
<> <>
<ProfileHead /> <ProfileHead />
@ -98,7 +98,7 @@ export function ProfileView(props: ProfileViewProps) {
) )
} }
function ProfileHead() { function ProfileHead(): React.ReactElement {
return ( return (
<Head> <Head>
<title>My Profile - zapwall.fr</title> <title>My Profile - zapwall.fr</title>

166
components/ReviewForm.tsx Normal file
View File

@ -0,0 +1,166 @@
import { useState } from 'react'
import { publishReview } from '@/lib/articleMutations'
import { nostrService } from '@/lib/nostr'
import { useNostrAuth } from '@/hooks/useNostrAuth'
import { t } from '@/lib/i18n'
import type { Article } from '@/types/nostr'
interface ReviewFormProps {
article: Article
onSuccess?: () => void
onCancel?: () => void
}
export function ReviewForm({ article, onSuccess, onCancel }: ReviewFormProps): React.ReactElement {
const { pubkey, connect } = useNostrAuth()
const [content, setContent] = useState('')
const [title, setTitle] = useState('')
const [text, setText] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSubmit = async (e: React.FormEvent): Promise<void> => {
e.preventDefault()
if (!pubkey) {
await connect()
return
}
if (!content.trim()) {
setError(t('review.form.error.contentRequired'))
return
}
setLoading(true)
setError(null)
try {
const privateKey = nostrService.getPrivateKey()
if (!privateKey) {
setError(t('review.form.error.noPrivateKey'))
return
}
const category = article.category ?? 'science-fiction'
const seriesId = article.seriesId ?? ''
await publishReview({
articleId: article.id,
seriesId,
category,
authorPubkey: article.pubkey,
reviewerPubkey: pubkey,
content: content.trim(),
title: title.trim() || undefined,
text: text.trim() || undefined,
authorPrivateKey: privateKey,
})
setContent('')
setTitle('')
setText('')
onSuccess?.()
} catch (e) {
setError(e instanceof Error ? e.message : t('review.form.error.publishFailed'))
} finally {
setLoading(false)
}
}
if (!pubkey) {
return (
<div className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark">
<p className="text-cyber-accent mb-4">{t('review.form.connectRequired')}</p>
<button
onClick={() => {
void connect()
}}
className="px-4 py-2 bg-neon-green/20 hover:bg-neon-green/30 text-neon-green rounded-lg font-medium transition-all border border-neon-green/50"
>
{t('connect.connect')}
</button>
</div>
)
}
return (
<form onSubmit={handleSubmit} className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark space-y-4">
<h3 className="text-lg font-semibold text-neon-cyan">{t('review.form.title')}</h3>
<div>
<label htmlFor="review-title" className="block text-sm font-medium text-cyber-accent mb-1">
{t('review.form.title.label')} <span className="text-cyber-accent/50">({t('common.optional')})</span>
</label>
<input
id="review-title"
type="text"
value={title}
onChange={(e) => {
setTitle(e.target.value)
}}
placeholder={t('review.form.title.placeholder')}
className="w-full px-3 py-2 bg-cyber-darker border border-neon-cyan/30 rounded text-cyber-accent focus:border-neon-cyan focus:outline-none"
/>
</div>
<div>
<label htmlFor="review-content" className="block text-sm font-medium text-cyber-accent mb-1">
{t('review.form.content.label')} <span className="text-red-400">*</span>
</label>
<textarea
id="review-content"
value={content}
onChange={(e) => {
setContent(e.target.value)
}}
placeholder={t('review.form.content.placeholder')}
rows={6}
required
className="w-full px-3 py-2 bg-cyber-darker border border-neon-cyan/30 rounded text-cyber-accent focus:border-neon-cyan focus:outline-none"
/>
</div>
<div>
<label htmlFor="review-text" className="block text-sm font-medium text-cyber-accent mb-1">
{t('review.form.text.label')} <span className="text-cyber-accent/50">({t('common.optional')})</span>
</label>
<textarea
id="review-text"
value={text}
onChange={(e) => {
setText(e.target.value)
}}
placeholder={t('review.form.text.placeholder')}
rows={3}
className="w-full px-3 py-2 bg-cyber-darker border border-neon-cyan/30 rounded text-cyber-accent focus:border-neon-cyan focus:outline-none"
/>
<p className="text-xs text-cyber-accent/70 mt-1">{t('review.form.text.help')}</p>
</div>
{error && (
<div className="p-3 bg-red-900/20 border border-red-500/50 rounded text-red-400 text-sm">
{error}
</div>
)}
<div className="flex gap-2">
<button
type="submit"
disabled={loading}
className="px-4 py-2 bg-neon-green/20 hover:bg-neon-green/30 text-neon-green rounded-lg font-medium transition-all border border-neon-green/50 hover:shadow-glow-green disabled:opacity-50"
>
{loading ? t('common.loading') : t('review.form.submit')}
</button>
{onCancel && (
<button
type="button"
onClick={onCancel}
className="px-4 py-2 bg-cyber-darker hover:bg-cyber-dark text-cyber-accent rounded-lg font-medium transition-all border border-neon-cyan/30"
>
{t('common.cancel')}
</button>
)}
</div>
</form>
)
}

View File

@ -0,0 +1,140 @@
import { useState } from 'react'
import { nostrService } from '@/lib/nostr'
import { useNostrAuth } from '@/hooks/useNostrAuth'
import { t } from '@/lib/i18n'
import { buildReviewTipZapRequestTags } from '@/lib/zapRequestBuilder'
import { calculateReviewSplit } from '@/lib/platformCommissions'
import type { Review } from '@/types/nostr'
import type { Article } from '@/types/nostr'
interface ReviewTipFormProps {
review: Review
article: Article
onSuccess?: () => void
onCancel?: () => void
}
export function ReviewTipForm({ review, article, onSuccess, onCancel }: ReviewTipFormProps): React.ReactElement {
const { pubkey, connect } = useNostrAuth()
const [text, setText] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSubmit = async (e: React.FormEvent): Promise<void> => {
e.preventDefault()
if (!pubkey) {
await connect()
return
}
setLoading(true)
setError(null)
try {
const privateKey = nostrService.getPrivateKey()
if (!privateKey) {
setError(t('reviewTip.form.error.noPrivateKey'))
return
}
const split = calculateReviewSplit()
const category = article.category === 'science-fiction' ? 'sciencefiction' : article.category === 'scientific-research' ? 'research' : 'sciencefiction'
// Build zap request tags
const zapRequestTags = buildReviewTipZapRequestTags({
articleId: article.id,
reviewId: review.id,
authorPubkey: article.pubkey,
reviewerPubkey: review.reviewerPubkey,
category: article.category,
seriesId: article.seriesId,
text: text.trim() || undefined,
})
// Create zap request event (kind 9734) and publish it
// This zap request will be used by Alby to create the zap payment
await nostrService.createZapRequest(review.reviewerPubkey, review.id, split.total, zapRequestTags)
// Note: The actual zap payment is handled by Alby/WebLN when the user confirms
// The zap request event (kind 9734) contains all the necessary information
// Alby will use this to create the zap receipt (kind 9735) after payment
// The user needs to complete the zap payment via Alby after this zap request is published
setText('')
onSuccess?.()
} catch (e) {
setError(e instanceof Error ? e.message : t('reviewTip.form.error.paymentFailed'))
} finally {
setLoading(false)
}
}
if (!pubkey) {
return (
<div className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark">
<p className="text-cyber-accent mb-4">{t('reviewTip.form.connectRequired')}</p>
<button
onClick={() => {
void connect()
}}
className="px-4 py-2 bg-neon-green/20 hover:bg-neon-green/30 text-neon-green rounded-lg font-medium transition-all border border-neon-green/50"
>
{t('connect.connect')}
</button>
</div>
)
}
const split = calculateReviewSplit()
return (
<form onSubmit={handleSubmit} className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark space-y-4">
<h3 className="text-lg font-semibold text-neon-cyan">{t('reviewTip.form.title')}</h3>
<p className="text-sm text-cyber-accent/70">
{t('reviewTip.form.description', { amount: split.total, reviewer: split.reviewer, platform: split.platform })}
</p>
<div>
<label htmlFor="review-tip-text" className="block text-sm font-medium text-cyber-accent mb-1">
{t('reviewTip.form.text.label')} <span className="text-cyber-accent/50">({t('common.optional')})</span>
</label>
<textarea
id="review-tip-text"
value={text}
onChange={(e) => {
setText(e.target.value)
}}
placeholder={t('reviewTip.form.text.placeholder')}
rows={3}
className="w-full px-3 py-2 bg-cyber-darker border border-neon-cyan/30 rounded text-cyber-accent focus:border-neon-cyan focus:outline-none"
/>
<p className="text-xs text-cyber-accent/70 mt-1">{t('reviewTip.form.text.help')}</p>
</div>
{error && (
<div className="p-3 bg-red-900/20 border border-red-500/50 rounded text-red-400 text-sm">
{error}
</div>
)}
<div className="flex gap-2">
<button
type="submit"
disabled={loading}
className="px-4 py-2 bg-neon-green/20 hover:bg-neon-green/30 text-neon-green rounded-lg font-medium transition-all border border-neon-green/50 hover:shadow-glow-green disabled:opacity-50"
>
{loading ? t('common.loading') : t('reviewTip.form.submit', { amount: split.total })}
</button>
{onCancel && (
<button
type="button"
onClick={onCancel}
className="px-4 py-2 bg-cyber-darker hover:bg-cyber-dark text-cyber-accent rounded-lg font-medium transition-all border border-neon-cyan/30"
>
{t('common.cancel')}
</button>
)}
</div>
</form>
)
}

View File

@ -9,7 +9,7 @@ interface SearchBarProps {
placeholder?: string placeholder?: string
} }
export function SearchBar({ value, onChange, placeholder }: SearchBarProps): JSX.Element { export function SearchBar({ value, onChange, placeholder }: SearchBarProps): React.ReactElement {
const defaultPlaceholder = placeholder ?? t('search.placeholder') const defaultPlaceholder = placeholder ?? t('search.placeholder')
const [localValue, setLocalValue] = useState(value) const [localValue, setLocalValue] = useState(value)

View File

@ -1,5 +1,5 @@
export function SearchIcon() { export function SearchIcon(): React.ReactElement {
return ( return (
<svg <svg
className="h-5 w-5 text-neon-cyan/70" className="h-5 w-5 text-neon-cyan/70"

View File

@ -9,7 +9,7 @@ interface SeriesCardProps {
selected?: boolean selected?: boolean
} }
export function SeriesCard({ series, onSelect, selected }: SeriesCardProps) { export function SeriesCard({ series, onSelect, selected }: SeriesCardProps): React.ReactElement {
return ( return (
<div <div
className={`border rounded-lg p-4 bg-white shadow-sm ${ className={`border rounded-lg p-4 bg-white shadow-sm ${

View File

@ -7,7 +7,7 @@ interface SeriesListProps {
selectedId?: string | undefined selectedId?: string | undefined
} }
export function SeriesList({ series, onSelect, selectedId }: SeriesListProps) { export function SeriesList({ series, onSelect, selectedId }: SeriesListProps): React.ReactElement {
if (series.length === 0) { if (series.length === 0) {
return <p className="text-sm text-gray-600">Aucune série pour cet auteur.</p> return <p className="text-sm text-gray-600">Aucune série pour cet auteur.</p>
} }

View File

@ -11,7 +11,7 @@ interface SeriesSectionProps {
selectedId?: string | undefined selectedId?: string | undefined
} }
export function SeriesSection({ authorPubkey, onSelect, selectedId }: SeriesSectionProps) { export function SeriesSection({ authorPubkey, onSelect, selectedId }: SeriesSectionProps): React.ReactElement {
const [{ series, loading, error, aggregates }, load] = useSeriesData(authorPubkey) const [{ series, loading, error, aggregates }, load] = useSeriesData(authorPubkey)
if (loading) { if (loading) {
@ -39,7 +39,7 @@ function SeriesControls({
}: { }: {
onSelect: (id: string | undefined) => void onSelect: (id: string | undefined) => void
onReload: () => Promise<void> onReload: () => Promise<void>
}) { }): React.ReactElement {
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
@ -68,7 +68,7 @@ function SeriesAggregatesList({
}: { }: {
series: Series[] series: Series[]
aggregates: Record<string, { sponsoring: number; purchases: number; reviewTips: number }> aggregates: Record<string, { sponsoring: number; purchases: number; reviewTips: number }>
}) { }): React.ReactElement {
return ( return (
<> <>
{series.map((s) => { {series.map((s) => {
@ -97,7 +97,7 @@ function useSeriesData(authorPubkey: string): [
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [aggregates, setAggregates] = useState<Record<string, { sponsoring: number; purchases: number; reviewTips: number }>>({}) const [aggregates, setAggregates] = useState<Record<string, { sponsoring: number; purchases: number; reviewTips: number }>>({})
const load = useCallback(async () => { const load = useCallback(async (): Promise<void> => {
setLoading(true) setLoading(true)
setError(null) setError(null)
try { try {

View File

@ -8,7 +8,7 @@ function formatSats(value: number): string {
return `${value} sats` return `${value} sats`
} }
export function SeriesStats({ sponsoring, purchases, reviewTips }: SeriesStatsProps) { export function SeriesStats({ sponsoring, purchases, reviewTips }: SeriesStatsProps): React.ReactElement {
const items = [ const items = [
{ label: 'Sponsoring (hors frais)', value: formatSats(sponsoring) }, { label: 'Sponsoring (hors frais)', value: formatSats(sponsoring) },
{ label: 'Paiements articles (hors frais)', value: formatSats(purchases) }, { label: 'Paiements articles (hors frais)', value: formatSats(purchases) },

View File

@ -0,0 +1,161 @@
import { useState } from 'react'
import { nostrService } from '@/lib/nostr'
import { useNostrAuth } from '@/hooks/useNostrAuth'
import { t } from '@/lib/i18n'
import { sponsoringPaymentService } from '@/lib/sponsoringPayment'
import type { AuthorPresentationArticle } from '@/types/nostr'
interface SponsoringFormProps {
author: AuthorPresentationArticle
onSuccess?: () => void
onCancel?: () => void
}
export function SponsoringForm({ author, onSuccess, onCancel }: SponsoringFormProps): React.ReactElement {
const { pubkey, connect } = useNostrAuth()
const [text, setText] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSubmit = async (e: React.FormEvent): Promise<void> => {
e.preventDefault()
if (!pubkey) {
await connect()
return
}
if (!author.mainnetAddress) {
setError(t('sponsoring.form.error.noAddress'))
return
}
setLoading(true)
setError(null)
try {
const privateKey = nostrService.getPrivateKey()
if (!privateKey) {
setError(t('sponsoring.form.error.noPrivateKey'))
return
}
// Create sponsoring payment request
const result = await sponsoringPaymentService.createSponsoringPayment({
authorPubkey: author.pubkey,
authorMainnetAddress: author.mainnetAddress,
amount: 0.046, // Fixed amount for sponsoring
})
if (!result.success) {
setError(result.error ?? t('sponsoring.form.error.paymentFailed'))
return
}
// Note: Sponsoring is done via Bitcoin mainnet, not Lightning zap
// The user needs to create a Bitcoin transaction with two outputs:
// 1. Author address: result.split.authorSats
// 2. Platform address: result.split.platformSats
// After the transaction is confirmed, we can create a zap receipt for tracking
// Store payment info for later verification
// The user will need to provide the transaction ID after payment
console.log('Sponsoring payment info:', {
authorAddress: result.authorAddress,
platformAddress: result.platformAddress,
authorAmount: result.split.authorSats,
platformAmount: result.split.platformSats,
totalAmount: result.split.totalSats,
})
// Show instructions to user
alert(t('sponsoring.form.instructions', {
authorAddress: result.authorAddress,
platformAddress: result.platformAddress,
authorAmount: (result.split.authorSats / 100_000_000).toFixed(8),
platformAmount: (result.split.platformSats / 100_000_000).toFixed(8),
}))
setText('')
onSuccess?.()
} catch (e) {
setError(e instanceof Error ? e.message : t('sponsoring.form.error.paymentFailed'))
} finally {
setLoading(false)
}
}
if (!pubkey) {
return (
<div className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark">
<p className="text-cyber-accent mb-4">{t('sponsoring.form.connectRequired')}</p>
<button
onClick={() => {
void connect()
}}
className="px-4 py-2 bg-neon-green/20 hover:bg-neon-green/30 text-neon-green rounded-lg font-medium transition-all border border-neon-green/50"
>
{t('connect.connect')}
</button>
</div>
)
}
if (!author.mainnetAddress) {
return (
<div className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark">
<p className="text-cyber-accent">{t('sponsoring.form.error.noAddress')}</p>
</div>
)
}
return (
<form onSubmit={handleSubmit} className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark space-y-4">
<h3 className="text-lg font-semibold text-neon-cyan">{t('sponsoring.form.title')}</h3>
<p className="text-sm text-cyber-accent/70">
{t('sponsoring.form.description', { amount: '0.046' })}
</p>
<div>
<label htmlFor="sponsoring-text" className="block text-sm font-medium text-cyber-accent mb-1">
{t('sponsoring.form.text.label')} <span className="text-cyber-accent/50">({t('common.optional')})</span>
</label>
<textarea
id="sponsoring-text"
value={text}
onChange={(e) => {
setText(e.target.value)
}}
placeholder={t('sponsoring.form.text.placeholder')}
rows={3}
className="w-full px-3 py-2 bg-cyber-darker border border-neon-cyan/30 rounded text-cyber-accent focus:border-neon-cyan focus:outline-none"
/>
<p className="text-xs text-cyber-accent/70 mt-1">{t('sponsoring.form.text.help')}</p>
</div>
{error && (
<div className="p-3 bg-red-900/20 border border-red-500/50 rounded text-red-400 text-sm">
{error}
</div>
)}
<div className="flex gap-2">
<button
type="submit"
disabled={loading}
className="px-4 py-2 bg-neon-green/20 hover:bg-neon-green/30 text-neon-green rounded-lg font-medium transition-all border border-neon-green/50 hover:shadow-glow-green disabled:opacity-50"
>
{loading ? t('common.loading') : t('sponsoring.form.submit')}
</button>
{onCancel && (
<button
type="button"
onClick={onCancel}
className="px-4 py-2 bg-cyber-darker hover:bg-cyber-dark text-cyber-accent rounded-lg font-medium transition-all border border-neon-cyan/30"
>
{t('common.cancel')}
</button>
)}
</div>
</form>
)
}

View File

@ -19,7 +19,7 @@ function WordInputWithAutocomplete({
onChange: (value: string) => void onChange: (value: string) => void
onFocus: () => void onFocus: () => void
onBlur: () => void onBlur: () => void
}) { }): React.ReactElement {
const [suggestions, setSuggestions] = useState<string[]>([]) const [suggestions, setSuggestions] = useState<string[]>([])
const [showSuggestions, setShowSuggestions] = useState(false) const [showSuggestions, setShowSuggestions] = useState(false)
const [selectedIndex, setSelectedIndex] = useState(-1) const [selectedIndex, setSelectedIndex] = useState(-1)
@ -38,12 +38,12 @@ function WordInputWithAutocomplete({
} }
}, [value]) }, [value])
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
const newValue = e.target.value.trim().toLowerCase() const newValue = e.target.value.trim().toLowerCase()
onChange(newValue) onChange(newValue)
} }
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>): void => {
if (e.key === 'ArrowDown') { if (e.key === 'ArrowDown') {
e.preventDefault() e.preventDefault()
setSelectedIndex((prev) => (prev < suggestions.length - 1 ? prev + 1 : prev)) setSelectedIndex((prev) => (prev < suggestions.length - 1 ? prev + 1 : prev))
@ -61,7 +61,7 @@ function WordInputWithAutocomplete({
} }
} }
const handleSuggestionClick = (suggestion: string) => { const handleSuggestionClick = (suggestion: string): void => {
onChange(suggestion) onChange(suggestion)
setShowSuggestions(false) setShowSuggestions(false)
inputRef.current?.blur() inputRef.current?.blur()
@ -122,7 +122,7 @@ function WordInputs({
}: { }: {
words: string[] words: string[]
onWordChange: (index: number, value: string) => void onWordChange: (index: number, value: string) => void
}) { }): React.ReactElement {
const [, setFocusedIndex] = useState<number | null>(null) const [, setFocusedIndex] = useState<number | null>(null)
return ( return (
@ -141,15 +141,18 @@ function WordInputs({
) )
} }
function useUnlockAccount(words: string[], setWords: (words: string[]) => void, setError: (error: string | null) => void) { function useUnlockAccount(words: string[], setWords: (words: string[]) => void, setError: (error: string | null) => void): {
const handleWordChange = (index: number, value: string) => { handleWordChange: (index: number, value: string) => void
handlePaste: () => Promise<void>
} {
const handleWordChange = (index: number, value: string): void => {
const newWords = [...words] const newWords = [...words]
newWords[index] = value.trim().toLowerCase() newWords[index] = value.trim().toLowerCase()
setWords(newWords) setWords(newWords)
setError(null) setError(null)
} }
const handlePaste = async () => { const handlePaste = async (): Promise<void> => {
try { try {
const text = await navigator.clipboard.readText() const text = await navigator.clipboard.readText()
const pastedWords = text.trim().split(/\s+/).slice(0, 4) const pastedWords = text.trim().split(/\s+/).slice(0, 4)
@ -175,7 +178,7 @@ function UnlockAccountButtons({
words: string[] words: string[]
onUnlock: () => void onUnlock: () => void
onClose: () => void onClose: () => void
}) { }): React.ReactElement {
return ( return (
<div className="flex gap-4"> <div className="flex gap-4">
<button <button
@ -205,7 +208,7 @@ function UnlockAccountForm({
words: string[] words: string[]
handleWordChange: (index: number, value: string) => void handleWordChange: (index: number, value: string) => void
handlePaste: () => void handlePaste: () => void
}) { }): React.ReactElement {
return ( return (
<div className="mb-4"> <div className="mb-4">
<WordInputs words={words} onWordChange={handleWordChange} /> <WordInputs words={words} onWordChange={handleWordChange} />
@ -221,14 +224,14 @@ function UnlockAccountForm({
) )
} }
export function UnlockAccountModal({ onSuccess, onClose }: UnlockAccountModalProps) { export function UnlockAccountModal({ onSuccess, onClose }: UnlockAccountModalProps): React.ReactElement {
const [words, setWords] = useState(['', '', '', '']) const [words, setWords] = useState(['', '', '', ''])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const { handleWordChange, handlePaste } = useUnlockAccount(words, setWords, setError) const { handleWordChange, handlePaste } = useUnlockAccount(words, setWords, setError)
const handleUnlock = async () => { const handleUnlock = async (): Promise<void> => {
if (words.some((word) => !word)) { if (words.some((word) => !word)) {
setError('Veuillez remplir tous les mots-clés') setError('Veuillez remplir tous les mots-clés')
return return

View File

@ -22,7 +22,7 @@ export function UserArticles({
showEmptyMessage = true, showEmptyMessage = true,
currentPubkey, currentPubkey,
onSelectSeries, onSelectSeries,
}: UserArticlesProps) { }: UserArticlesProps): React.ReactElement {
const controller = useUserArticlesController({ articles, onLoadContent, currentPubkey }) const controller = useUserArticlesController({ articles, onLoadContent, currentPubkey })
return ( return (
<UserArticlesLayout <UserArticlesLayout
@ -44,7 +44,24 @@ function useUserArticlesController({
articles: Article[] articles: Article[]
onLoadContent: (articleId: string, authorPubkey: string) => Promise<Article | null> onLoadContent: (articleId: string, authorPubkey: string) => Promise<Article | null>
currentPubkey: string | null currentPubkey: string | null
}) { }): {
localArticles: Article[]
unlockedArticles: Set<string>
pendingDeleteId: string | null
requestDelete: (id: string) => void
handleUnlock: (article: Article) => Promise<void>
handleDelete: (article: Article) => Promise<void>
handleEditSubmit: () => Promise<void>
editingDraft: ArticleDraft | null
editingArticleId: string | null
loading: boolean
error: string | null
updateDraft: (draft: ArticleDraft) => void
startEditing: (article: Article) => Promise<void>
cancelEditing: () => void
submitEdit: () => Promise<import('@/lib/articleMutations').ArticleUpdateResult | null>
deleteArticle: (id: string) => Promise<boolean>
} {
const [localArticles, setLocalArticles] = useState<Article[]>(articles) const [localArticles, setLocalArticles] = useState<Article[]>(articles)
const [unlockedArticles, setUnlockedArticles] = useState<Set<string>>(new Set()) const [unlockedArticles, setUnlockedArticles] = useState<Set<string>>(new Set())
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null) const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null)
@ -72,8 +89,8 @@ function useUserArticlesController({
function createHandleUnlock( function createHandleUnlock(
onLoadContent: (id: string, pubkey: string) => Promise<Article | null>, onLoadContent: (id: string, pubkey: string) => Promise<Article | null>,
setUnlocked: Dispatch<SetStateAction<Set<string>>> setUnlocked: Dispatch<SetStateAction<Set<string>>>
) { ): (article: Article) => Promise<void> {
return async (article: Article) => { return async (article: Article): Promise<void> => {
const full = await onLoadContent(article.id, article.pubkey) const full = await onLoadContent(article.id, article.pubkey)
if (full?.paid) { if (full?.paid) {
setUnlocked((prev) => new Set([...prev, article.id])) setUnlocked((prev) => new Set([...prev, article.id]))
@ -85,8 +102,8 @@ function createHandleDelete(
deleteArticle: (id: string) => Promise<boolean>, deleteArticle: (id: string) => Promise<boolean>,
setLocalArticles: Dispatch<SetStateAction<Article[]>>, setLocalArticles: Dispatch<SetStateAction<Article[]>>,
setPendingDeleteId: Dispatch<SetStateAction<string | null>> setPendingDeleteId: Dispatch<SetStateAction<string | null>>
) { ): (article: Article) => Promise<void> {
return async (article: Article) => { return async (article: Article): Promise<void> => {
const ok = await deleteArticle(article.id) const ok = await deleteArticle(article.id)
if (ok) { if (ok) {
setLocalArticles((prev) => prev.filter((a) => a.id !== article.id)) setLocalArticles((prev) => prev.filter((a) => a.id !== article.id))
@ -100,8 +117,8 @@ function createHandleEditSubmit(
draft: ReturnType<typeof useArticleEditing>['editingDraft'], draft: ReturnType<typeof useArticleEditing>['editingDraft'],
currentPubkey: string | null, currentPubkey: string | null,
setLocalArticles: Dispatch<SetStateAction<Article[]>> setLocalArticles: Dispatch<SetStateAction<Article[]>>
) { ): () => Promise<void> {
return async () => { return async (): Promise<void> => {
const result = await submitEdit() const result = await submitEdit()
if (result && draft) { if (result && draft) {
const updated = buildUpdatedArticle(draft, currentPubkey ?? '', result.articleId) const updated = buildUpdatedArticle(draft, currentPubkey ?? '', result.articleId)
@ -145,7 +162,7 @@ function UserArticlesLayout({
showEmptyMessage: boolean showEmptyMessage: boolean
currentPubkey: string | null currentPubkey: string | null
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
}) { }): React.ReactElement {
const { editPanelProps, listProps } = createLayoutProps(controller, { const { editPanelProps, listProps } = createLayoutProps(controller, {
loading, loading,
error, error,
@ -171,14 +188,47 @@ function createLayoutProps(
currentPubkey: string | null currentPubkey: string | null
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
} }
) { ): {
editPanelProps: {
draft: ArticleDraft | null
editingArticleId: string | null
loading: boolean
error: string | null
onCancel: () => void
onDraftChange: (draft: ArticleDraft) => void
onSubmit: () => Promise<void>
}
listProps: {
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
}
} {
return { return {
editPanelProps: buildEditPanelProps(controller), editPanelProps: buildEditPanelProps(controller),
listProps: buildListProps(controller, view), listProps: buildListProps(controller, view),
} }
} }
function buildEditPanelProps(controller: ReturnType<typeof useUserArticlesController>) { function buildEditPanelProps(controller: ReturnType<typeof useUserArticlesController>): {
draft: ArticleDraft | null
editingArticleId: string | null
loading: boolean
error: string | null
onCancel: () => void
onDraftChange: (draft: ArticleDraft) => void
onSubmit: () => Promise<void>
} {
return { return {
draft: controller.editingDraft, draft: controller.editingDraft,
editingArticleId: controller.editingArticleId, editingArticleId: controller.editingArticleId,
@ -199,7 +249,21 @@ function buildListProps(
currentPubkey: string | null currentPubkey: string | null
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
} }
) { ): {
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
} {
return { return {
articles: controller.localArticles, articles: controller.localArticles,
loading: view.loading, loading: view.loading,

View File

@ -19,7 +19,7 @@ export function EditPanel({
onCancel, onCancel,
onDraftChange, onDraftChange,
onSubmit, onSubmit,
}: EditPanelProps) { }: EditPanelProps): React.ReactElement | null {
if (!draft || !editingArticleId) { if (!draft || !editingArticleId) {
return null return null
} }

View File

@ -20,19 +20,19 @@ interface UserArticlesViewProps {
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
} }
const ArticlesLoading = () => ( const ArticlesLoading = (): React.ReactElement => (
<div className="text-center py-12"> <div className="text-center py-12">
<p className="text-gray-500">{t('common.loading.articles')}</p> <p className="text-gray-500">{t('common.loading.articles')}</p>
</div> </div>
) )
const ArticlesError = ({ message }: { message: string }) => ( const ArticlesError = ({ message }: { message: string }): React.ReactElement => (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-4"> <div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-4">
<p className="text-red-800">{message}</p> <p className="text-red-800">{message}</p>
</div> </div>
) )
const EmptyState = ({ show }: { show: boolean }) => const EmptyState = ({ show }: { show: boolean }): React.ReactElement | null =>
(show ? ( (show ? (
<div className="text-center py-12"> <div className="text-center py-12">
<p className="text-gray-500">{t('common.empty.articles')}</p> <p className="text-gray-500">{t('common.empty.articles')}</p>
@ -53,7 +53,7 @@ function ArticleActions({
editingArticleId: string | null editingArticleId: string | null
pendingDeleteId: string | null pendingDeleteId: string | null
requestDelete: (articleId: string) => void requestDelete: (articleId: string) => void
}) { }): React.ReactElement {
return ( return (
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
@ -78,7 +78,7 @@ function ArticleRow(
article: Article article: Article
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
} }
) { ): React.ReactElement {
const content = buildArticleContent(props) const content = buildArticleContent(props)
return <div className="space-y-3">{content}</div> return <div className="space-y-3">{content}</div>
} }
@ -88,14 +88,14 @@ function buildArticleContent(
article: Article article: Article
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
} }
) { ): React.ReactElement[] {
const parts = [buildArticleCard(props), buildSeriesLink(props), buildActions(props)].filter(Boolean) const parts = [buildArticleCard(props), buildSeriesLink(props), buildActions(props)].filter(Boolean)
return parts as React.ReactElement[] return parts as React.ReactElement[]
} }
function buildArticleCard( function buildArticleCard(
props: Omit<UserArticlesViewProps, 'articles' | 'loading' | 'error' | 'showEmptyMessage'> & { article: Article } props: Omit<UserArticlesViewProps, 'articles' | 'loading' | 'error' | 'showEmptyMessage'> & { article: Article }
) { ): React.ReactElement {
const { article, unlockedArticles, onUnlock } = props const { article, unlockedArticles, onUnlock } = props
return ( return (
<ArticleCard <ArticleCard
@ -111,7 +111,7 @@ function buildSeriesLink(
article: Article article: Article
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
} }
) { ): React.ReactElement | null {
const { article, onSelectSeries } = props const { article, onSelectSeries } = props
if (!article.seriesId) { if (!article.seriesId) {
return null return null
@ -133,7 +133,7 @@ function buildSeriesLink(
function buildActions( function buildActions(
props: Omit<UserArticlesViewProps, 'articles' | 'loading' | 'error' | 'showEmptyMessage'> & { article: Article } props: Omit<UserArticlesViewProps, 'articles' | 'loading' | 'error' | 'showEmptyMessage'> & { article: Article }
) { ): React.ReactElement | null {
const { article, currentPubkey, onEdit, onDelete, editingArticleId, pendingDeleteId, requestDelete } = props const { article, currentPubkey, onEdit, onDelete, editingArticleId, pendingDeleteId, requestDelete } = props
if (currentPubkey !== article.pubkey) { if (currentPubkey !== article.pubkey) {
return null return null
@ -151,7 +151,7 @@ function buildActions(
) )
} }
function UserArticlesViewComponent(props: UserArticlesViewProps) { function UserArticlesViewComponent(props: UserArticlesViewProps): React.ReactElement {
if (props.loading) { if (props.loading) {
return <ArticlesLoading /> return <ArticlesLoading />
} }
@ -175,7 +175,7 @@ function renderArticles({
pendingDeleteId, pendingDeleteId,
requestDelete, requestDelete,
onSelectSeries, onSelectSeries,
}: UserArticlesViewProps) { }: UserArticlesViewProps): React.ReactElement {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{articles.map((article) => ( {articles.map((article) => (

View File

@ -7,7 +7,7 @@ interface UserProfileProps {
articleCount?: number articleCount?: number
} }
function ProfileStats({ articleCount }: { articleCount: number }) { function ProfileStats({ articleCount }: { articleCount: number }): React.ReactElement {
return ( return (
<div className="text-center"> <div className="text-center">
<div className="text-3xl font-bold text-gray-900">{articleCount}</div> <div className="text-3xl font-bold text-gray-900">{articleCount}</div>
@ -16,7 +16,7 @@ function ProfileStats({ articleCount }: { articleCount: number }) {
) )
} }
export function UserProfile({ profile, pubkey, articleCount }: UserProfileProps) { export function UserProfile({ profile, pubkey, articleCount }: UserProfileProps): React.ReactElement {
const displayName = profile.name ?? `${pubkey.slice(0, 16)}...` const displayName = profile.name ?? `${pubkey.slice(0, 16)}...`
return ( return (

View File

@ -10,7 +10,7 @@ export function UserProfileHeader({
displayName, displayName,
picture, picture,
nip05, nip05,
}: UserProfileHeaderProps) { }: UserProfileHeaderProps): React.ReactElement {
return ( return (
<div className="flex flex-col md:flex-row items-start md:items-center gap-4"> <div className="flex flex-col md:flex-row items-start md:items-center gap-4">
{picture ? ( {picture ? (

294
docs/en/faq.md Normal file
View File

@ -0,0 +1,294 @@
# FAQ - Frequently Asked Questions
## General Questions
### What is zapwall.fr?
zapwall.fr is a publishing platform for scientific and science-fiction content based on the Nostr protocol. Authors can publish publications with a free preview and paid full content, unlocked via Lightning Network payments.
### How does the payment system work?
1. The author publishes a publication with a free preview
2. The author creates a Lightning invoice when publishing to receive zaps
3. Readers can read the preview for free
4. To read the full content, readers make a Lightning zap of 800 sats
5. Once the zap is confirmed, the full content is sent via encrypted private message (NIP-04)
### How much does a publication cost?
All publications have the same amount: **800 sats** (approximately 0.000008 BTC). From this amount:
- **700 sats** go to the author
- **100 sats** are the platform commission
- Lightning transaction fees are added
### What is a "sat"?
A "sat" (satoshi) is the smallest unit of Bitcoin. 1 BTC = 100,000,000 sats.
### How does sponsoring work?
Sponsoring allows you to directly support an author with **0.046 BTC**:
- **0.042 BTC** go to the author
- **0.004 BTC** is the platform commission
- Sponsoring is done via a Bitcoin mainnet transaction
---
## Connection and Authentication
### How do I connect?
Click "Connect with Nostr" and authorize the connection with Alby. The application uses the Alby extension for Nostr authentication (NIP-07) and Lightning payments (WebLN).
### Do I need an account?
No, you do not need to create an account. You use your existing Nostr identity via your Nostr wallet.
### Can I use multiple accounts?
Yes, you can disconnect and reconnect with another Nostr account at any time.
### What happens if I disconnect?
- You remain connected to read publication previews
- You must be connected to create your author page
- You must be connected to publish publications
- You must be connected to pay and unlock publications
- Already unlocked content remains accessible (stored locally)
---
## Payments
### How do I make a zap for a publication?
1. Click "Unlock" on the desired publication
2. A window opens with a QR code and Lightning invoice
3. Click "Pay with Alby" or scan the QR code with your Lightning wallet
4. Confirm the zap in your wallet
5. Content automatically unlocks after confirmation
> **Important**: Only zaps are allowed. Standard Lightning payments do not work to unlock publications.
### Which Lightning wallet can I use?
Any Lightning wallet compatible with WebLN works. **Alby** is recommended and tested. Other wallets like Breez, Zeus, etc. may work if they support WebLN and can make zaps.
### Do I need to install Alby?
Yes, to make payments easily, you need to install the Alby extension (or another WebLN-compatible Lightning wallet).
### Are zaps secure?
Yes, zaps use the Lightning Network protocol and are verified via Nostr zap receipts (NIP-57), which is secure and decentralized. Zaps are the only authorized method to unlock publications.
### What happens if I pay but content doesn't unlock?
This should not happen, but if it does:
1. Wait a few seconds (verification may take time)
2. Refresh the page
3. Check that payment was actually made in your wallet
4. Contact the publication author
### Can I get a refund?
Lightning payments are generally irreversible. Contact the publication author if you have a problem.
### Do invoices expire?
Yes, invoices expire after **24 hours**. If an invoice expires, close the window and click "Unlock" again to generate a new invoice.
---
## Publishing Publications
### How do I publish a publication?
1. Connect with Nostr
2. Create your author page (required, once)
3. Click "Publish a publication" in the menu
4. Fill in the form:
- **Title**: The title of your publication
- **Preview**: The free preview (visible to everyone)
- **Content**: The full content (unlocked after payment)
- **Category**: Science Fiction or Scientific Research
- **Series**: Optional, to organize your publications
5. Click "Publish"
6. Authorize Lightning invoice creation in Alby
7. Your publication will be published on the Nostr relay
### Do I have to pay to publish a publication?
No, publishing is free. You only need to have Alby installed to create the Lightning invoice.
### Can I edit or delete a publication after publishing?
Currently, this feature is not available. Publications published on Nostr are immutable. An edit/delete feature will be added in a future version.
### How do readers pay for my publication?
Readers click "Unlock" and pay the Lightning invoice you created when publishing. Once payment is confirmed, the full content is automatically sent via encrypted private message.
### How do I receive payments?
Payments are sent directly to your Lightning wallet (the one used to create the invoice when publishing). You will receive 700 sats on each sale (800 sats - 100 sats commission).
### Can I set a custom amount?
No, the amount is fixed at 800 sats for all publications. This simplifies the user experience and ensures fair pricing.
---
## Author Page
### What is an author page?
An author page is a required publication that each author must create before they can publish. It contains:
- Your presentation
- Your description
- Your Bitcoin mainnet address for sponsoring (optional)
### Do I need to create an author page?
Yes, the author page is **required** to publish publications. You can only publish after creating your author page.
### How do I create my author page?
1. Connect with Nostr
2. Click "Create author page" in the menu
3. Fill in your presentation
4. Publish your author page
### Is my author page public?
Yes, your author page is publicly accessible at `/author/[your-pubkey]`. It allows readers to discover you and sponsor you.
---
## Series
### What is a series?
A series is a grouping of publications organized by an author. Series allow you to organize your publications by theme.
### How do I create a series?
When publishing a publication, you can create a new series or select an existing series. Fill in the series information (title, description, cover image).
### Can I add a publication to an existing series?
Yes, when publishing, you can select an existing series to add your publication to.
---
## Reviews
### What is a review?
A review is a comment or evaluation of a publication by a reader who has purchased the publication.
### Who can post a review?
Only readers who have unlocked a publication can post a review on that publication.
### Are reviews rewarded?
Yes, as an author, you can thank a reader for their review with **70 sats**:
- **49 sats** go to the reviewer
- **21 sats** is the platform commission
### How do I thank a review?
1. Access the relevant publication
2. Find the review you want to thank
3. Click "Thank" (70 sats)
4. Confirm the zap in Alby
---
## Content and Publications
### Can I read publications without paying?
Yes, you can read the **preview** of all publications for free. Only the **full content** requires payment.
### Is unlocked content stored?
Yes, unlocked content is stored locally in your browser (IndexedDB) to remain accessible even after disconnection.
### Can I share an unlocked publication?
Unlocked content is stored locally in your browser. You can share the publication link, but other users will need to pay to unlock the content.
### Are publications public?
**Previews** are public and visible to everyone on the Nostr relay. **Full content** is sent only via encrypted private message after payment.
### Can I search publications?
Yes, you can search by title, preview, or content. You can also filter by category, author, and sort by date.
---
## Technical
### Which Nostr relay is used?
By default, the application uses `wss://relay.damus.io`. Relay configuration is stored in IndexedDB (browser local storage) and can be customized via application settings. The application supports multiple relays with a priority system.
### Is data stored on a server?
No, the application is decentralized:
- Publications are published on the Nostr relay (decentralized)
- Unlocked content is stored locally in your browser (IndexedDB)
- Notifications are stored locally in your browser
### Can I use another Nostr relay?
Yes, you can configure another relay via environment variables. However, you will only see publications published on the configured relay.
### Does the application work offline?
No, the application requires an internet connection to:
- Connect to the Nostr relay
- Publish publications
- Make Lightning payments
- Receive notifications
Already unlocked content remains accessible offline (stored locally).
---
## Problems and Support
### The application is not working
Check:
1. Your internet connection
2. That the Nostr relay is accessible
3. The browser console for errors
4. That JavaScript is enabled in your browser
### I cannot create my author page
Check:
1. That you are connected with Nostr
2. That your Nostr wallet can sign events
3. That all fields are filled
### My unlocked content has disappeared
Content is stored locally. If you have:
- Cleared the browser cache
- Deleted site data
- Used another browser or device
Content may be lost. You may need to pay again to unlock the publication.
### Can I contact support?
For now, there is no official support. Consult the documentation or create an issue on the [project Gitea repository](https://git.4nkweb.com/4nk/story-research-zapwall/issues).
---
**Last updated**: December 2024

View File

@ -0,0 +1,19 @@
# Fees and Contributions
## Pricing
### Publication Purchase
Browse authors and previews, purchase publications on the go for **800 sats** (minus 100 sats and transaction fees).
### Author Sponsoring
Sponsor the author for **0.046 BTC** (minus 0.004 BTC and transaction fees).
### Review Thanks
Reviews are rewardable for **70 sats** (minus 21 sats and transaction fees).
## Use of Funds
Funds collected by the platform serve the development of free AI features for authors (development and hardware).

279
docs/en/payment-guide.md Normal file
View File

@ -0,0 +1,279 @@
# Payment Guide with Alby
This guide explains how to make a zap to unlock publications with Alby and the Lightning Network protocol.
> **Important**: Only zaps are allowed to unlock publications. Standard Lightning payments do not work.
## What is Alby?
[Alby](https://getalby.com/) is a browser extension that allows you to manage Lightning Network zaps directly from your browser. Alby uses the WebLN standard to interact with web applications.
## Installing Alby
### 1. Download Alby
1. Visit [getalby.com](https://getalby.com/)
2. Click **"Get Alby"** or **"Install Extension"**
3. Choose your browser:
- Chrome / Edge
- Firefox
- Brave
- Safari (via App Store)
### 2. Install the Extension
1. Follow the installation instructions for your browser
2. The Alby extension will appear in your browser toolbar
3. Click the Alby icon to start configuration
### 3. Configure Alby
#### Option A: Create a New Alby Account
1. Click the Alby icon in your browser
2. Click **"Create Account"** or **"Sign Up"**
3. Follow the instructions to create an account
4. Add funds to your Alby wallet:
- By credit card
- By bank transfer
- By Lightning Network (from another wallet)
#### Option B: Connect an Existing Lightning Wallet
1. Click the Alby icon
2. Choose **"Connect Wallet"** or **"Link Existing Wallet"**
3. Follow the instructions to connect your Lightning wallet (LND, CLN, etc.)
### 4. Verify Installation
1. Return to zapwall.fr
2. If Alby is correctly installed, you will see a confirmation message
3. If Alby is not installed, a message will invite you to install it
## Making a Zap for a Publication
### Step-by-Step Process
#### 1. Choose a Publication
1. Browse the publication list on the home page
2. Read the free preview
3. If you want to read the full content, click **"Unlock"**
#### 2. Zap Window
A modal window opens with:
- **Zap amount**: 800 sats (fixed amount)
- **Lightning QR Code**: To scan with a mobile wallet
- **Lightning Invoice**: The Lightning invoice (BOLT11)
- **Expiration timer**: Time remaining before expiration (24h)
- **"Pay with Alby" button**: To pay directly with Alby
#### 3. Zap Methods
You have **3 options** to make the zap:
> **Important**: Only zaps are allowed to unlock publications. Standard Lightning payments do not work.
##### Option 1: Zap with Alby (Recommended)
1. Click **"Pay with Alby"**
2. An Alby window opens automatically
3. Verify the zap details:
- Amount (800 sats)
- Description
- Recipient
4. Click **"Confirm"** or **"Pay"** in Alby
5. The zap is made instantly
6. The window closes automatically
7. Full content displays after a few seconds
##### Option 2: Scan the QR Code
1. Open your mobile Lightning wallet (BlueWallet, Breez, etc.)
2. Use your wallet's "Scan" function
3. Scan the QR code displayed in the window
4. Confirm the zap in your mobile wallet
5. Content automatically unlocks after confirmation
##### Option 3: Copy the Invoice
1. Click **"Copy Invoice"** to copy the Lightning invoice
2. Paste the invoice into your Lightning wallet (any)
3. Make the zap
4. Content automatically unlocks after confirmation
### 4. Zap Confirmation
After the zap:
1. **Automatic verification**: The application verifies the zap via Nostr zap receipts (NIP-57)
2. **Delay**: Verification may take a few seconds (usually 5-30 seconds)
3. **Content display**: Once verified, full content automatically displays
4. **Local storage**: Content is stored locally in your browser (IndexedDB)
## Invoice Expiration
### Validity Period
- Invoices expire after **24 hours**
- A timer displays the remaining time in the zap window
- If the invoice expires, it becomes invalid
### What to Do If the Invoice Expires?
1. **Close the zap window**
2. **Click "Unlock" again**
3. **A new invoice will be generated** automatically
4. **Make the zap with the new invoice**
> **Note**: Never make a zap with an expired invoice, the zap will fail.
## Commissions and Amounts
### Publication Amount
- **Total amount**: 800 sats
- **To author**: 700 sats
- **Platform commission**: 100 sats
- **Transaction fees**: Paid by the author
### Sponsoring Amount
- **Total amount**: 0.046 BTC
- **To author**: 0.042 BTC
- **Platform commission**: 0.004 BTC
- **Transaction fees**: Paid by the author
### Review Thank Amount
- **Total amount**: 70 sats
- **To reviewer**: 49 sats
- **Platform commission**: 21 sats
- **Transaction fees**: Paid by the author
## Troubleshooting
### Alby Does Not Open
**Solutions**:
- Check that Alby is installed
- Refresh the page
- Check that the Alby extension is enabled in your browser
- Try clicking "Pay with Alby" again
### Zap Fails
**Check**:
- ✅ That you have sufficient funds in Alby
- ✅ That the invoice has not expired
- ✅ Your internet connection
- ✅ Error logs in the browser console
- ✅ That you are making a zap (not a standard Lightning payment)
**Solutions**:
- Add funds to your Alby wallet
- Generate a new invoice (close and reopen the window)
- Try the zap again
- Make sure you are making a zap via Nostr, not a standard Lightning payment
### Content Does Not Unlock After Zap
**Check**:
- ✅ That the zap was actually made (check in Alby)
- ✅ Wait a few seconds (verification may take time)
- ✅ Refresh the page
- ✅ That the zap was verified via Nostr zap receipts
**Solutions**:
- Wait 30-60 seconds for verification
- Refresh the page
- Check your notifications (badge in the top right)
- Contact the publication author if the problem persists
### I Don't Have Enough Funds
**Solutions**:
- Add funds to your Alby wallet:
- By credit card
- By bank transfer
- By Lightning Network (from another wallet)
- Wait for funds to be available
- Try the zap again
### Invoice Has Expired
**Solutions**:
- Close the zap window
- Click "Unlock" again
- A new invoice will be generated
- Make the zap with the new invoice
## Security
### Are Zaps Secure?
Yes, Lightning Network zaps are:
- ✅ **Decentralized**: No central server
- ✅ **Fast**: Confirmations in a few seconds
- ✅ **Low cost**: Minimal fees
- ✅ **Verifiable**: Verified via Nostr zap receipts (NIP-57)
- ✅ **Only authorized method**: Only zaps work to unlock publications
### Is My Information Shared?
- ✅ **No**: Lightning zaps are private
- ✅ Only the amount and recipient are visible on the Lightning blockchain
- ✅ Your Nostr identity is linked to zaps via zap receipts (NIP-57)
### Can I Get a Refund?
Lightning zaps are generally **irreversible**. If you have a problem:
1. Check that the zap was actually made
2. Contact the publication author
3. Check that content did not unlock (wait a few seconds)
## Alternatives to Alby
### Other WebLN Wallets
If you prefer not to use Alby, you can use other WebLN-compatible Lightning wallets:
- **Breez** (if WebLN support)
- **Zeus** (if WebLN support)
- Other compatible wallets
### Mobile Wallets
You can also use a mobile Lightning wallet:
1. Scan the QR code with your mobile wallet
2. Confirm the zap
3. Content automatically unlocks
**Popular mobile wallets**:
- BlueWallet
- Breez
- Zeus
- Wallet of Satoshi
## Tips
### Managing Your Funds
- Keep sufficient funds in Alby for multiple publications
- Add funds regularly to avoid interruptions
- Monitor your balance in the Alby extension
### Multiple Zaps
- You can make zaps for multiple publications in succession
- Each zap is independent
- Each publication's content is stored separately
### Unlocked Content
- Unlocked content is stored locally in your browser
- It remains accessible even after disconnection
- If you clear the cache, content may be lost (you may need to pay again)
---
**Last updated**: December 2024

253
docs/en/publishing-guide.md Normal file
View File

@ -0,0 +1,253 @@
# Publishing Guide
This guide explains how to publish a publication on zapwall.fr with a free preview and paid content.
## Prerequisites
Before publishing a publication, you must have:
1. ✅ **A Nostr wallet** (to connect and sign events)
2. ✅ **Your author page created** (required, once)
3. ✅ **Alby installed** (to create the Lightning invoice)
4. ✅ **Funds in your Lightning wallet** (optional, but recommended for testing)
## Publishing Steps
### 1. Create Your Author Page (Required)
Before you can publish, you must create your author page:
1. Connect with Nostr
2. Click **"Create author page"** in the menu
3. Fill in your presentation:
- Your description
- Your Bitcoin mainnet address for sponsoring (optional)
4. Publish your author page
> **Important**: The author page is required. You cannot publish a publication without creating your author page.
### 2. Access the Publishing Page
1. Click **"Publish a publication"** in the main menu
2. You will be redirected to the `/publish` page
### 3. Fill in the Form
The form contains several fields:
#### Title (Required)
- The title of your publication
- Visible to everyone in the publication list
- Example: "Introduction to Nostr"
#### Preview (Required)
- The free content visible to everyone
- This is what readers will see before paying
- Must be interesting enough to encourage payment
- Example: "Discover the basics of the Nostr protocol and how it revolutionizes decentralized social networks..."
#### Full Content (Required)
- The full content that will be unlocked after payment
- Sent via encrypted private message (NIP-04) after payment
- Can contain text, images (links), etc.
- Example: "Nostr is a decentralized social network protocol based on cryptographic keys..."
#### Category (Required)
- **Science Fiction**: For science fiction content
- **Scientific Research**: For scientific and research content
#### Series (Optional)
- You can create a new series or select an existing series
- Series allow you to organize your publications by theme
- Fill in the series information:
- Title
- Description
- Cover image (optional)
### 4. Publish the Publication
1. Click the **"Publish the publication"** button
2. If Alby is not installed, you will be invited to install it
3. **Authorize Lightning invoice creation** in Alby
4. The invoice will be created automatically (800 sats)
5. Your publication will be published on the Nostr relay
### 5. Confirmation
Once published, you will see:
- ✅ A confirmation message
- You will be automatically redirected to the home page
- Your publication will appear in the publication list
## How It Works Technically
### 1. Preview Publication
The preview is published as a **Nostr type 1 event** (text note) with the following tags:
- `#publication`: Content type
- `#sciencefiction` or `#research`: Category
- `#id_<id>`: Unique identifier
- `#paywall`: Indicates that content is paid
- `title`: The publication title
- `preview`: The free preview
- `zapAmount`: The amount in sats (800 sats)
- `invoice`: The Lightning invoice (BOLT11)
- `paymentHash`: The invoice hash
### 2. Invoice Creation
The Lightning invoice is created via Alby/WebLN when publishing:
- **Amount**: 800 sats (fixed amount for all publications)
- **Description**: "Payment for publication: {title}"
- **Expiration**: 24 hours
### 3. Full Content Storage
Full content is stored locally in your browser (IndexedDB):
- Associated with the publication ID
- Encrypted with AES-GCM
- Used to send content after payment
### 4. Sending Content After Payment
When a reader pays:
1. Payment is verified via Nostr zap receipts (NIP-57)
2. Full content is sent via **encrypted private message (NIP-04)**
3. The private message contains:
- The encrypted content
- An `e` tag linking to the publication
- A `p` tag with the recipient's public key
## Commissions
### On Publication Sales
- **Total amount**: 800 sats
- **To author**: 700 sats
- **Platform commission**: 100 sats
- **Transaction fees**: Paid by the author
### On Sponsoring
- **Total amount**: 0.046 BTC
- **To author**: 0.042 BTC
- **Platform commission**: 0.004 BTC
- **Transaction fees**: Paid by the author
### On Review Thanks
- **Total amount**: 70 sats
- **To reviewer**: 49 sats
- **Platform commission**: 21 sats
- **Transaction fees**: Paid by the author
## Tips for Good Publishing
### Writing a Good Preview
The preview is crucial to encourage readers to pay:
- ✅ Give a taste of the full content
- ✅ Ask a question or create curiosity
- ✅ Mention key points that will be developed
- ❌ Don't reveal all the content
- ❌ Don't be too vague
**Example of an effective preview**:
> "Discover how Nostr revolutionizes social networks by eliminating centralized servers. In this article, we will explore the protocol architecture, the benefits of decentralization, and how to create your first Nostr application. You will also learn how to implement Lightning payments directly in your applications."
### Payment Amount
The amount is fixed at **800 sats** for all publications. This simplifies the user experience and ensures fair pricing.
### Quality Content
Full content should:
- ✅ Be substantial and add value
- ✅ Justify the payment amount of 800 sats
- ✅ Be well formatted and readable
- ✅ Include examples or illustrations if relevant
### Using Series
Series allow you to organize your publications:
- ✅ Create thematic series
- ✅ Group your publications by subject
- ✅ Facilitate discovery of your content
## Managing Published Publications
### Viewing Your Publications
1. Click your **profile** (name/avatar in the top right)
2. The "My Articles" section displays all your publications
3. You can search and filter your publications
### Statistics
Currently, you can see:
- The number of published publications
- Payment notifications received
> **Note**: More detailed statistics will be added in a future version.
### Editing and Deleting
> **Note**: Editing and deleting publications is not yet available. Nostr events are immutable, so once published, a publication cannot be modified. This feature will be added in a future version.
## Troubleshooting
### I Cannot Publish
**Check**:
- ✅ That you are connected with Nostr
- ✅ That you have created your author page (required)
- ✅ That your Nostr wallet can sign events
- ✅ That Alby is installed and enabled
- ✅ That all fields are filled
### Invoice Does Not Create
**Check**:
- ✅ That Alby is installed
- ✅ That you have authorized the application in Alby
- ✅ That your Lightning wallet has funds (optional)
- ✅ Your internet connection
### Publication Does Not Display After Publishing
**Check**:
- ✅ That the Nostr relay is accessible
- ✅ Refresh the page
- ✅ Check the browser console for errors
### I Am Not Receiving Payments
**Check**:
- ✅ That readers are actually paying
- ✅ Your notifications (badge in the top right)
- ✅ Your Lightning wallet
- ✅ That the invoice has not expired
## Best Practices
### Publication Frequency
- Publish regularly to maintain engagement
- Don't publish too often (risk of spam)
- Quality > Quantity
### Promotion
- Share your publications on other Nostr platforms
- Mention your publications in your Nostr notes
- Create a community around your content
### Engagement with Readers
- Respond to reviews (if this feature is added)
- Create quality content that deserves to be paid
- Listen to feedback from your readers
---
**Last updated**: December 2024

319
docs/en/user-guide.md Normal file
View File

@ -0,0 +1,319 @@
# User Guide - zapwall.fr
Welcome to zapwall.fr! This platform allows you to read scientific and science-fiction publications with free previews and unlock full content by paying with Lightning Network.
## Table of Contents
1. [Introduction](#introduction)
2. [Getting Started](#getting-started)
3. [Connecting with Nostr](#connecting-with-nostr)
4. [Reading Publications](#reading-publications)
5. [Paying to Unlock a Publication](#paying-to-unlock-a-publication)
6. [Searching and Filtering Publications](#searching-and-filtering-publications)
7. [Viewing Your Profile and Author Page](#viewing-your-profile-and-author-page)
8. [Series](#series)
9. [Reviews](#reviews)
10. [Troubleshooting](#troubleshooting)
---
## Introduction
zapwall.fr is a publishing platform for scientific and science-fiction content based on the Nostr protocol. Authors can publish publications with:
- **Free Preview**: Visible to everyone
- **Full Content**: Unlocked after a Lightning payment of 800 sats (minus 100 sats commission and transaction fees)
### Main Features
- ✅ Free reading of publication previews
- ✅ Full content unlock via Lightning payment
- ✅ Search and filter publications
- ✅ Author page with presentation and sponsoring
- ✅ Series to organize your publications
- ✅ Rewarded reviews on publications
- ✅ Author sponsoring (0.046 BTC)
---
## Getting Started
### 1. Install Alby (Recommended)
To make Lightning payments, you need to install a Lightning wallet extension compatible with WebLN:
1. Visit [getalby.com](https://getalby.com/)
2. Install the Alby extension for your browser
3. Create an account or connect your existing Lightning wallet
4. Add funds to your Alby wallet
> **Note**: Other WebLN-compatible Lightning wallets also work.
### 2. Access the Platform
1. Open [zapwall.fr](https://zapwall.fr) in your browser
2. You will see the list of available publications
3. Click "Connect with Nostr" to connect
---
## Connecting with Nostr
### How to Connect
1. Click the **"Connect with Nostr"** button in the top right
2. A window will open to connect with your Nostr wallet
3. The application uses the Alby extension for Nostr authentication (NIP-07) and Lightning payments (WebLN)
4. Authorize the connection in your Nostr wallet
### What Happens After Connection?
- ✅ Your Nostr profile is displayed (name, avatar, etc.)
- ✅ You can create your author page (required to publish)
- ✅ You can publish publications
- ✅ You can pay to unlock publications
- ✅ You can access your profile with your publications
### Disconnecting
Click the **"Disconnect"** button to disconnect.
---
## Reading Publications
### Free Preview
All publications automatically display:
- **Title** of the publication
- **Preview** - free content
- **Author** (with link to their author page)
- **Amount** in sats (800 sats)
- **Publication date**
- **Category** (Science Fiction or Scientific Research)
### Full Content
To read the full content of a publication:
1. Click the **"Unlock"** button
2. Follow the instructions to make a Lightning zap of 800 sats
3. Once the zap is confirmed, the full content will automatically display
> **Note**: Unlocked content is stored locally in your browser and remains accessible even after disconnection.
---
## Paying to Unlock a Publication
### Zap Process
1. **Click "Unlock"** on the publication you want to unlock
2. **A window opens** with:
- The zap amount (800 sats)
- A Lightning QR code
- The Lightning invoice
- A "Pay with Alby" button
3. **Choose your zap method**:
- **Option 1**: Click "Pay with Alby" (recommended)
- Your Alby extension will automatically open
- Confirm the zap in Alby
- **Option 2**: Scan the QR code with your mobile Lightning wallet
- **Option 3**: Copy the invoice and make the zap from your wallet
4. **Wait for confirmation**:
- The zap is automatically verified via Nostr zap receipts (NIP-57)
- Full content will automatically display once confirmed
- This may take a few seconds
> **Note**: Only zaps are allowed to unlock publications. Standard Lightning payments do not work.
### Invoice Expiration
Lightning invoices expire after 24 hours. If an invoice expires:
- Close the payment window
- Click "Unlock" again to generate a new invoice
### Payment Issues
If payment fails:
- Check that you have sufficient funds in your wallet
- Check that the invoice has not expired
- Try again by clicking "Unlock" again
- Consult the [Troubleshooting](#troubleshooting) section
---
## Searching and Filtering Publications
### Search Bar
Use the search bar at the top of the page to search publications by:
- **Title**
- **Preview**
- **Content** (even unlocked content is searchable)
### Filters
Filters allow you to:
- **Filter by category**: Science Fiction or Scientific Research
- **Filter by author**: Select a specific author
- **Sort publications**:
- Newest first (default)
- Oldest first
### Using Filters
1. Use category tabs to filter by content type
2. Use the dropdown menu to select an author
3. Results update automatically
4. Click "Clear all" to reset all filters
---
## Viewing Your Profile and Author Page
### Author Page (Required)
Before you can publish, you must create your **author page**:
1. Connect with Nostr
2. Click **"Create author page"** in the menu
3. Fill in your presentation with:
- Your description
- Your Bitcoin mainnet address for sponsoring (optional)
4. Once created, your author page is accessible to everyone via `/author/[your-pubkey]`
### Accessing Your Profile
1. Connect with Nostr
2. Click your **name or avatar** in the top right
3. You will be redirected to the `/profile` page
### Information Displayed
Your profile displays:
- **Profile picture** (if available)
- **Name** (if defined in your Nostr profile)
- **Public key** (pubkey)
- **NIP-05** (if verified)
- **Description** (about)
### Your Publications
The "My Articles" section displays:
- All your published publications
- Search and filters on your publications
- Unlock status for each publication
---
## Series
### What is a Series?
A series is a grouping of publications organized by an author. Series allow you to:
- Organize your publications by theme
- Create narrative continuity
- Facilitate discovery of your content
### Creating a Series
1. Connect with Nostr
2. When publishing, you can create or select a series
3. Fill in the series information:
- Title
- Description
- Cover image (optional)
4. Series publications will be grouped on the series page
### Viewing a Series
1. Click on a series from an author's page
2. You will see all publications in the series
3. Each publication can be unlocked individually
---
## Reviews
### What is a Review?
A review is a comment or evaluation of a publication by a reader who has purchased the publication. Reviews are:
- **Rewarded**: The author can thank a review with 70 sats (minus 21 sats commission)
- **Public**: Visible to everyone on the platform
- **Linked to a publication**: Each review is associated with a specific publication
### Posting a Review
1. Unlock a publication (800 sats)
2. Access the publication page
3. Write your review
4. Publish your review
### Thanking a Review
As an author, you can thank a reader for their review:
1. Access the relevant publication
2. Find the review you want to thank
3. Click "Thank" (70 sats)
4. Confirm the zap in Alby
> **Note**: Thanking a review costs 70 sats (49 sats to the reviewer, 21 sats commission).
---
## Troubleshooting
### Connection Issues
**I cannot connect with Nostr**
- Check that your Nostr wallet is accessible
- Check that the Alby extension is installed and enabled
- Try refreshing the page
- Check your internet connection
### Payment Issues
**Payment is not working**
- Check that Alby (or your Lightning wallet) is installed and enabled
- Check that you have sufficient funds
- Check that the invoice has not expired
- Try refreshing the page and try again
**Content does not unlock after payment**
- Wait a few seconds (verification may take time)
- Check that payment was actually made in your wallet
- Refresh the page
- Contact the publication author if the problem persists
### Display Issues
**Publications are not displaying**
- Check your internet connection
- Check that the Nostr relay is accessible
- Try refreshing the page
- Check the browser console for errors
**Unlocked content has disappeared**
- Content is stored locally in your browser
- If you cleared the cache or browser data, content may be lost
- You may need to pay again to unlock the publication
### Publication Issues
**I cannot publish a publication**
- Check that you are connected with Nostr
- Check that you have created your author page (required)
- Check that your Nostr wallet can sign events
- Check that Alby is installed (required to create the invoice)
- Check that all fields are filled (title, preview, content)
---
## Support
For more help:
- Consult the [FAQ](./faq.md)
- Consult the [Publishing Guide](./publishing-guide.md)
- Consult the [Payment Guide](./payment-guide.md)
---
**Last updated**: December 2024

133
docs/fr/DOCUMENTATION.md Normal file
View File

@ -0,0 +1,133 @@
# Documentation complète - zapwall.fr
## 📚 Index de la documentation
### 🚀 Déploiement et infrastructure
#### Documentation principale
1. **[Documentation complète du déploiement](docs/deployment.md)**
- Vue d'ensemble de l'architecture
- Configuration initiale
- Mise à jour du site (Git, transfert manuel)
- Configuration HTTPS (auto-signé et Let's Encrypt)
- Scripts disponibles
- Dépannage complet
- Maintenance et commandes utiles
2. **[Référence des scripts](docs/scripts-reference.md)**
- Liste complète de tous les scripts
- Description détaillée de chaque script
- Paramètres et options
- Ordre d'exécution recommandé
3. **[Guide de référence rapide](docs/quick-reference.md)**
- Commandes essentielles en un coup d'œil
- Informations importantes
- Liens rapides vers la documentation
#### Guides pratiques
4. **[README-DEPLOYMENT.md](README-DEPLOYMENT.md)**
- Guide de déploiement et mise à jour
- Méthodes de mise à jour
- Commandes utiles
- Configuration HTTPS
5. **[RESUME-DEPLOIEMENT.md](RESUME-DEPLOIEMENT.md)**
- Résumé du déploiement
- État actuel
- Problèmes identifiés et solutions
- Prochaines étapes
### 📝 Scripts de déploiement
#### Scripts principaux
- **`deploy.sh`** : Déploiement initial complet avec vérifications
- **`update-remote-git.sh`** : Mise à jour via Git (stash + pull + rebuild) ⭐ **Recommandé**
- **`update-from-git.sh`** : Mise à jour depuis dépôt local
- **`finish-deploy.sh`** : Finalisation du déploiement
#### Scripts de vérification
- **`check-deploy.sh`** : Vérification préalable avant déploiement
- **`check-deployment-status.sh`** : État complet du déploiement
- **`check-nginx-config.sh`** : Vérification de la configuration nginx
- **`check-git-repo.sh`** : Vérification du dépôt Git
- **`final-status.sh`** : Résumé de l'état final
#### Scripts de configuration
- **`setup-https-autosigned.sh`** : Configuration HTTPS avec certificats auto-signés
- **`deploy-letsencrypt.sh`** : Déploiement des certificats Let's Encrypt
- **`open-firewall-ports.sh`** : Ouverture des ports 80/443
- **`fix-nginx-config.sh`** : Correction de la configuration
### 🔧 Informations techniques
#### Serveur
- **Adresse** : `92.243.27.35`
- **Utilisateur** : `debian`
- **Domaine** : `zapwall.fr`
- **Répertoire** : `/var/www/zapwall.fr`
- **Port application** : `3001`
- **Service** : `zapwall.service` (systemd)
- **Nginx** : Conteneur Docker `lecoffre_nginx_test`
#### Architecture
```
Internet → Firewall (80/443) → Nginx Docker → Port 3001 → Next.js App
```
### 📖 Documentation utilisateur
- **[Guide utilisateur](docs/user-guide.md)** : Guide d'utilisation de la plateforme
- **[FAQ](docs/faq.md)** : Questions fréquentes
- **[Guide de publication](docs/publishing-guide.md)** : Comment publier un article
- **[Guide de paiement](docs/payment-guide.md)** : Comment effectuer un paiement
### 🔬 Documentation technique
- **[Documentation technique](docs/technical.md)** : Architecture technique
- **[Configuration stricte](docs/STRICT_CONFIG_SUMMARY.md)** : Règles de qualité du code
- **[Configuration Rizful API](docs/rizful-api-setup.md)** : Configuration de l'API Rizful
### 📋 Spécifications
- **[Fonctionnalités](features/features.md)** : Liste des fonctionnalités
- **[Notifications](features/notifications-implementation.md)** : Implémentation des notifications
- **[Séries et médias](features/series-and-media-spec.md)** : Spécification des séries
- **[Refactoring](features/zapwall4science-refactoring.md)** : Notes de refactoring
## 🎯 Démarrage rapide
### Pour déployer ou mettre à jour
```bash
# Déploiement depuis la branche main (par défaut)
./deploy.sh
# Déploiement depuis une autre branche
./deploy.sh develop
```
Le script `deploy.sh` effectue automatiquement :
- Mise à jour depuis Git
- Installation des dépendances
- Construction de l'application
- Redémarrage du service
## 📞 Support
En cas de problème :
1. Consulter [docs/deployment.md - Section Dépannage](docs/deployment.md#dépannage)
2. Vérifier les logs : `ssh debian@92.243.27.35 'sudo journalctl -u zapwall -n 100'`
3. Utiliser les scripts de vérification
---
*Dernière mise à jour : 2025-12-28*

View File

@ -0,0 +1,200 @@
# Guide de déploiement et mise à jour - zapwall.fr
## État actuel
- **Service**: zapwall.service (systemd)
- **Répertoire**: `/var/www/zapwall.fr`
- **Port application**: 3001
- **Nginx**: Conteneur Docker `lecoffre_nginx_test`
- **HTTPS**: Configuré avec redirection automatique HTTP → HTTPS
## Mise à jour du site depuis Git
### Méthode 1 : Si le dépôt Git est déjà cloné sur le serveur
```bash
# Se connecter au serveur
ssh debian@92.243.27.35
# Aller dans le répertoire de l'application
cd /var/www/zapwall.fr
# Récupérer les dernières modifications
git fetch origin
# Basculer sur la branche souhaitée (par défaut: main)
git checkout main # ou master, ou une autre branche
# Récupérer les modifications
git pull origin main
# Installer les dépendances
npm ci
# Construire l'application
npm run build
# Redémarrer le service
sudo systemctl restart zapwall
# Vérifier que le service fonctionne
sudo systemctl status zapwall
```
### Méthode 2 : Utiliser le script de mise à jour
Depuis votre machine locale :
```bash
# Mise à jour depuis la branche main
./update-from-git.sh main
# Ou depuis une autre branche
./update-from-git.sh master
```
Le script :
1. Se connecte au serveur
2. Récupère les modifications depuis Git
3. Installe les dépendances
4. Construit l'application
5. Redémarre le service
6. Vérifie que tout fonctionne
### Méthode 3 : Transfert manuel depuis le dépôt local
Si le dépôt Git n'est pas sur le serveur :
```bash
# Depuis votre machine locale, dans le répertoire du projet
tar --exclude='node_modules' \
--exclude='.next' \
--exclude='.git' \
--exclude='*.tsbuildinfo' \
--exclude='.env*.local' \
--exclude='.cursor' \
-czf - . | ssh debian@92.243.27.35 "cd /var/www/zapwall.fr && tar -xzf -"
# Puis sur le serveur
ssh debian@92.243.27.35
cd /var/www/zapwall.fr
npm ci
npm run build
sudo systemctl restart zapwall
```
## Commandes utiles
### Voir les logs du service
```bash
ssh debian@92.243.27.35 'sudo journalctl -u zapwall -f'
```
### Vérifier le statut du service
```bash
ssh debian@92.243.27.35 'sudo systemctl status zapwall'
```
### Redémarrer le service
```bash
ssh debian@92.243.27.35 'sudo systemctl restart zapwall'
```
### Vérifier que le port 3001 est en écoute
```bash
ssh debian@92.243.27.35 'sudo ss -tuln | grep 3001'
```
### Vérifier la configuration nginx
```bash
ssh debian@92.243.27.35 'sudo docker exec lecoffre_nginx_test nginx -t'
```
### Recharger nginx après modification
```bash
ssh debian@92.243.27.35 'sudo docker exec lecoffre_nginx_test nginx -s reload'
```
## Configuration HTTPS
Actuellement, HTTPS est configuré avec des certificats auto-signés. Pour obtenir des certificats Let's Encrypt valides :
### Option 1 : Utiliser certbot via snap (recommandé)
```bash
ssh debian@92.243.27.35
sudo snap install certbot --classic
sudo docker stop lecoffre_nginx_test
sudo certbot certonly --standalone -d zapwall.fr --non-interactive --agree-tos --email admin@zapwall.fr
sudo docker start lecoffre_nginx_test
# Copier les certificats dans le conteneur
sudo docker cp /etc/letsencrypt/live/zapwall.fr/fullchain.pem lecoffre_nginx_test:/etc/letsencrypt/live/zapwall.fr/fullchain.pem
sudo docker cp /etc/letsencrypt/live/zapwall.fr/privkey.pem lecoffre_nginx_test:/etc/letsencrypt/live/zapwall.fr/privkey.pem
# Mettre à jour la configuration nginx pour utiliser les certificats Let's Encrypt
# (modifier ssl_certificate et ssl_certificate_key dans /etc/nginx/conf.d/zapwall.fr.conf)
sudo docker exec lecoffre_nginx_test nginx -s reload
```
### Option 2 : Utiliser acme.sh
```bash
ssh debian@92.243.27.35
curl https://get.acme.sh | sh
~/.acme.sh/acme.sh --issue -d zapwall.fr --standalone
```
## Structure des fichiers
```
/var/www/zapwall.fr/ # Répertoire de l'application
├── .next/ # Build de production Next.js
├── node_modules/ # Dépendances npm
├── pages/ # Pages Next.js
├── components/ # Composants React
├── lib/ # Bibliothèques
└── package.json # Configuration npm
/etc/systemd/system/zapwall.service # Service systemd
/etc/nginx/conf.d/zapwall.fr.conf # Configuration nginx (dans le conteneur)
```
## Dépannage
### Le service ne démarre pas
```bash
# Voir les logs
ssh debian@92.243.27.35 'sudo journalctl -u zapwall -n 50'
# Vérifier que le répertoire existe
ssh debian@92.243.27.35 'ls -la /var/www/zapwall.fr'
# Vérifier que l'application est construite
ssh debian@92.243.27.35 'ls -la /var/www/zapwall.fr/.next'
```
### Le port 3001 n'est pas en écoute
```bash
# Vérifier que le service est actif
ssh debian@92.243.27.35 'sudo systemctl status zapwall'
# Redémarrer le service
ssh debian@92.243.27.35 'sudo systemctl restart zapwall'
```
### Nginx ne sert pas le bon site
```bash
# Vérifier la configuration
ssh debian@92.243.35 'sudo docker exec lecoffre_nginx_test cat /etc/nginx/conf.d/zapwall.fr.conf'
# Vérifier que proxy_pass pointe vers 172.17.0.1:3001
# Vérifier que server_name contient zapwall.fr
```
## Notes importantes
- Le service zapwall doit être actif pour que l'application soit accessible
- Nginx fait un reverse proxy vers le port 3001
- Les modifications de code nécessitent un rebuild (`npm run build`) et un redémarrage du service
- Les certificats Let's Encrypt doivent être renouvelés tous les 90 jours

75
docs/fr/README.md Normal file
View File

@ -0,0 +1,75 @@
# Documentation du projet zapwall.fr
## 📚 Documentation disponible
### Déploiement et infrastructure
- **[Documentation complète du déploiement](deployment.md)**
- Architecture du déploiement
- Configuration initiale
- Mise à jour du site
- Configuration HTTPS
- Dépannage
- Maintenance
- **[Référence des scripts](scripts-reference.md)**
- Description de tous les scripts
- Utilisation et paramètres
- Ordre d'exécution recommandé
- **[Guide de référence rapide](quick-reference.md)**
- Commandes essentielles
- Informations importantes
- Liens rapides
### Documentation utilisateur
- **[Guide utilisateur](../docs/user-guide.md)** : Guide d'utilisation de la plateforme
- **[FAQ](../docs/faq.md)** : Questions fréquentes
- **[Guide de publication](../docs/publishing-guide.md)** : Comment publier un article
- **[Guide de paiement](../docs/payment-guide.md)** : Comment effectuer un paiement
### Documentation technique
- **[Documentation technique](../docs/technical.md)** : Architecture technique
- **[Configuration stricte](../docs/STRICT_CONFIG_SUMMARY.md)** : Règles de qualité du code
- **[Configuration Rizful API](../docs/rizful-api-setup.md)** : Configuration de l'API Rizful
### Spécifications
- **[Fonctionnalités](../features/features.md)** : Liste des fonctionnalités
- **[Notifications](../features/notifications-implementation.md)** : Implémentation des notifications
- **[Séries et médias](../features/series-and-media-spec.md)** : Spécification des séries
- **[Refactoring](../features/zapwall4science-refactoring.md)** : Notes de refactoring
## 🚀 Démarrage rapide
### Développement local
```bash
npm install
npm run dev
```
### Déploiement
```bash
# Vérification préalable
./check-deploy.sh
# Déploiement initial
./deploy.sh
# Mise à jour
./update-remote-git.sh
```
## 📖 Navigation
- Pour le **déploiement** : Commencez par [deployment.md](deployment.md)
- Pour les **scripts** : Consultez [scripts-reference.md](scripts-reference.md)
- Pour les **commandes rapides** : Voir [quick-reference.md](quick-reference.md)
---
*Dernière mise à jour : 2025-12-28*

View File

@ -0,0 +1,75 @@
# Configuration stricte TypeScript et ESLint
## Règles TypeScript strictes (tsconfig.json)
### Activées :
- ✅ `strict: true` - Mode strict complet
- ✅ `noUnusedLocals: true` - Variables locales non utilisées = erreur
- ✅ `noUnusedParameters: true` - Paramètres non utilisés = erreur
- ✅ `noImplicitReturns: true` - Return explicite requis
- ✅ `noFallthroughCasesInSwitch: true` - Pas de fallthrough dans switch
- ✅ `noUncheckedIndexedAccess: true` - Accès aux tableaux/objets vérifiés
- ✅ `noImplicitOverride: true` - Override explicite requis
- ✅ `exactOptionalPropertyTypes: true` - Types optionnels exacts
### Règles ESLint strictes (.eslintrc.json)
#### TypeScript avec informations de type :
- ✅ `@typescript-eslint/no-floating-promises: error` - Promesses non gérées = erreur
- ✅ `@typescript-eslint/no-misused-promises: error` - Promesses mal utilisées = erreur
- ✅ `@typescript-eslint/await-thenable: error` - Await sur non-promesse = erreur
- ✅ `@typescript-eslint/no-unnecessary-type-assertion: error` - Assertions inutiles = erreur
- ✅ `@typescript-eslint/no-non-null-assertion: error` - Non-null assertions interdites
- ✅ `@typescript-eslint/prefer-nullish-coalescing: error` - Force `??` au lieu de `||`
- ✅ `@typescript-eslint/prefer-optional-chain: error` - Force l'optional chaining
- ✅ `@typescript-eslint/no-non-null-asserted-optional-chain: error` - Chaînage + assertion interdite
- ✅ `@typescript-eslint/no-explicit-any: error` - `any` explicite interdit
#### Variables et code mort :
- ✅ `@typescript-eslint/no-unused-vars: error` - Variables non utilisées = erreur (sauf `_*`)
#### Bonnes pratiques JavaScript/TypeScript :
- ✅ `prefer-const: error` - Force `const` quand possible
- ✅ `no-var: error` - Interdit `var`
- ✅ `object-shorthand: error` - Force la syntaxe raccourcie
- ✅ `prefer-template: error` - Force les template literals
- ✅ `eqeqeq: error` - Force `===` et `!==`
- ✅ `curly: error` - Force les accolades dans if/for
- ✅ `no-throw-literal: error` - Interdit de throw des primitives
- ✅ `no-return-await: error` - Interdit `return await`
#### React :
- ✅ `react-hooks/rules-of-hooks: error` - Règles des hooks strictes
- ✅ `react-hooks/exhaustive-deps: error` - Dépendances des hooks strictes
#### Console/Debug :
- ✅ `no-console: warn` - Console interdit (sauf warn/error)
- ✅ `no-debugger: error` - Debugger interdit
- ✅ `no-alert: error` - Alert interdit
#### Longueur de code :
- ✅ `max-lines: error` - Max 250 lignes par fichier
- ✅ `max-lines-per-function: error` - Max 40 lignes par fonction
## Configuration ParserOptions
Pour activer les règles TypeScript avec type information :
```json
{
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": 2020,
"sourceType": "module",
"project": "./tsconfig.json"
}
}
```
## Objectif
Le but n'est PAS d'éviter les erreurs, mais d'avoir une **très haute qualité de code** en :
- Détectant les bugs avant l'exécution
- Forçant les bonnes pratiques
- Éliminant le code mort
- Garantissant la sécurité des types
- Prévenant les erreurs courantes

611
docs/fr/deployment.md Normal file
View File

@ -0,0 +1,611 @@
# Documentation complète du déploiement - zapwall.fr
## 📋 Table des matières
1. [Vue d'ensemble](#vue-densemble)
2. [Architecture du déploiement](#architecture-du-déploiement)
3. [Configuration initiale](#configuration-initiale)
4. [Mise à jour du site](#mise-à-jour-du-site)
5. [Configuration HTTPS](#configuration-https)
6. [Scripts disponibles](#scripts-disponibles)
7. [Dépannage](#dépannage)
8. [Maintenance](#maintenance)
---
## Vue d'ensemble
### Informations du serveur
- **Serveur** : `<IP>`
- **Utilisateur** : `<USER>`
- **Domaine** : `zapwall.fr`
- **Répertoire de l'application** : `/var/www/zapwall.fr`
- **Port application** : `3001`
- **Service systemd** : `zapwall.service`
- **Nginx** : Conteneur Docker `lecoffre_nginx_test`
### État actuel
- ✅ Application déployée et fonctionnelle
- ✅ Service systemd actif
- ✅ HTTPS configuré avec redirection automatique
- ✅ Firewall configuré (ports 80/443 ouverts)
- ✅ Certificats Let's Encrypt configurés (valides jusqu'au 2026-03-28)
---
## Architecture du déploiement
### Schéma d'architecture
```
Internet
[Port 80/443] ← Firewall (UFW)
[Nginx Docker Container] lecoffre_nginx_test
│ (reverse proxy)
[Port 3001] ← Application Next.js
[zapwall.service] (systemd)
[/var/www/zapwall.fr] (répertoire de l'application)
```
### Composants
1. **Nginx (Docker)** : Reverse proxy gérant HTTP/HTTPS
- Conteneur : `lecoffre_nginx_test`
- Configuration : `/etc/nginx/conf.d/zapwall.fr.conf` (dans le conteneur)
- Ports : 80 (HTTP), 443 (HTTPS)
2. **Application Next.js** : Application principale
- Service : `zapwall.service` (systemd)
- Port : 3001
- Répertoire : `/var/www/zapwall.fr`
3. **Git** : Dépôt source
- URL : `https://git.4nkweb.com/4nk/story-research-zapwall.git`
- Branche par défaut : `main`
---
## Configuration initiale
### Prérequis
- Accès SSH au serveur
- Docker installé et configuré
- Node.js et npm installés
- Git installé
### Structure des fichiers sur le serveur
```
/var/www/zapwall.fr/ # Répertoire de l'application
├── .next/ # Build de production Next.js
├── .git/ # Dépôt Git (si initialisé)
├── node_modules/ # Dépendances npm
├── pages/ # Pages Next.js
├── components/ # Composants React
├── lib/ # Bibliothèques
├── hooks/ # Hooks React
├── public/ # Fichiers statiques
├── styles/ # Styles CSS
├── package.json # Configuration npm
└── next.config.js # Configuration Next.js
/etc/systemd/system/zapwall.service # Service systemd
```
### Service systemd
Le service `zapwall.service` est configuré ainsi :
```ini
[Unit]
Description=Zapwall Next.js Application
After=network.target
[Service]
Type=simple
User=<USER>
WorkingDirectory=/var/www/zapwall.fr
Environment=NODE_ENV=production
Environment=PORT=3001
ExecStart=/usr/bin/node /var/www/zapwall.fr/node_modules/.bin/next start -p 3001
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
```
### Configuration Nginx
La configuration nginx pour zapwall.fr se trouve dans le conteneur Docker à :
`/etc/nginx/conf.d/zapwall.fr.conf`
Elle configure :
- Redirection HTTP → HTTPS
- Reverse proxy vers `http://172.17.0.1:3001`
- Headers de sécurité (HSTS, X-Frame-Options, etc.)
- Support SSL/TLS
---
## Mise à jour du site
### Méthode recommandée : Script automatique
Le script `deploy.sh` effectue automatiquement :
1. Stash des modifications locales
2. Pull depuis Git
3. Installation des dépendances
4. Construction de l'application
5. Redémarrage du service
**Utilisation :**
```bash
# Depuis votre machine locale
./deploy.sh
# Ou depuis une autre branche
./deploy.sh develop
```
### Méthode manuelle : Git sur le serveur
```bash
# Se connecter au serveur
ssh <USER>@<IP>
# Aller dans le répertoire
cd /var/www/zapwall.fr
# Sauvegarder les modifications locales
git stash
# Récupérer les dernières modifications
git pull origin main
# Installer les dépendances
npm ci
# Construire l'application
npm run build
# Redémarrer le service
sudo systemctl restart zapwall
# Vérifier le statut
sudo systemctl status zapwall
```
### Méthode alternative : Transfert depuis dépôt local
Si Git n'est pas configuré sur le serveur :
```bash
# Depuis votre machine locale
tar --exclude='node_modules' \
--exclude='.next' \
--exclude='.git' \
--exclude='*.tsbuildinfo' \
--exclude='.env*.local' \
--exclude='.cursor' \
-czf - . | ssh <USER>@<IP> "cd /var/www/zapwall.fr && tar -xzf -"
# Puis sur le serveur
ssh <USER>@<IP>
cd /var/www/zapwall.fr
npm ci
npm run build
sudo systemctl restart zapwall
```
### Gestion des stashes Git
```bash
# Voir les stashes
ssh <USER>@<IP> 'cd /var/www/zapwall.fr && git stash list'
# Restaurer le dernier stash
ssh <USER>@<IP> 'cd /var/www/zapwall.fr && git stash pop'
# Supprimer un stash
ssh <USER>@<IP> 'cd /var/www/zapwall.fr && git stash drop stash@{0}'
```
---
## Configuration HTTPS
### État actuel
**Certificats Let's Encrypt configurés** pour `zapwall.fr` (obtenus via certbot snap).
Les certificats sont valides jusqu'au **2026-03-28** et seront renouvelés automatiquement.
### Configuration des certificats Let's Encrypt
Les certificats ont été obtenus via **certbot snap** (pour éviter le bug avec certbot et Python 3.11).
#### Méthode utilisée
1. **Installation de certbot via snap** :
```bash
sudo snap install certbot --classic
```
2. **Obtention des certificats** (mode standalone, nginx arrêté) :
```bash
sudo docker stop lecoffre_nginx_test
sudo certbot certonly --standalone \
-d zapwall.fr \
--non-interactive \
--agree-tos \
--email admin@zapwall.fr
sudo docker start lecoffre_nginx_test
```
3. **Copie des certificats dans le volume monté** :
- Les certificats sont stockés dans `/etc/letsencrypt/live/zapwall.fr/` sur l'hôte
- Ils sont copiés dans `/home/debian/sites/test-lecoffreio.4nkweb.com/deploy/nginx/certbot/conf-test/`
- Ce répertoire est monté dans le conteneur nginx en lecture seule à `/etc/letsencrypt`
4. **Mise à jour de la configuration nginx** :
- `ssl_certificate /etc/letsencrypt/live/zapwall.fr/fullchain.pem;`
- `ssl_certificate_key /etc/letsencrypt/live/zapwall.fr/privkey.pem;`
#### Note importante
- Les certificats sont valides uniquement pour `zapwall.fr` (pas pour `www.zapwall.fr`)
- Pour ajouter `www.zapwall.fr`, il faut d'abord configurer le DNS pour pointer vers ce serveur, puis relancer certbot avec `-d zapwall.fr -d www.zapwall.fr`
#### Option 2 : acme.sh
```bash
ssh <USER>@<IP>
curl https://get.acme.sh | sh
~/.acme.sh/acme.sh --issue -d zapwall.fr --standalone
```
### Renouvellement automatique des certificats
Certbot snap configure automatiquement le renouvellement. Pour vérifier :
```bash
# Vérifier le renouvellement automatique
sudo snap run certbot renew --dry-run
# Voir les tâches planifiées
sudo systemctl list-timers | grep certbot
```
#### Script de renouvellement personnalisé (si nécessaire)
Si vous devez copier les certificats dans le volume monté après renouvellement :
```bash
sudo nano /usr/local/bin/renew-zapwall-cert.sh
```
Contenu :
```bash
#!/bin/bash
set -e
DOMAIN="zapwall.fr"
MOUNTED_LETSENCRYPT="/home/debian/sites/test-lecoffreio.4nkweb.com/deploy/nginx/certbot/conf-test"
NGINX_CONTAINER="lecoffre_nginx_test"
# Renouveler les certificats
sudo certbot renew --quiet
# Copier les nouveaux certificats dans le volume monté
sudo cp /etc/letsencrypt/archive/${DOMAIN}/* ${MOUNTED_LETSENCRYPT}/archive/${DOMAIN}/
# Recharger nginx
sudo docker exec ${NGINX_CONTAINER} nginx -s reload
```
Rendre exécutable et ajouter au cron :
```bash
sudo chmod +x /usr/local/bin/renew-zapwall-cert.sh
sudo crontab -e
# Ajouter : 0 3 * * * /usr/local/bin/renew-zapwall-cert.sh
```
---
## Script de déploiement
### `deploy.sh`
Script unique pour déployer ou mettre à jour l'application.
**Utilisation :**
```bash
# Déploiement depuis la branche main (par défaut)
./deploy.sh
# Déploiement depuis une autre branche
./deploy.sh develop
```
**Fonctionnalités :**
1. Vérifie et initialise le dépôt Git si nécessaire
2. Sauvegarde les modifications locales (stash)
3. Nettoie les fichiers non suivis
4. Met à jour depuis la branche spécifiée (par défaut: `main`)
5. Installe les dépendances (`npm ci`)
6. Construit l'application (`npm run build`)
7. Redémarre le service systemd
8. Vérifie que le service fonctionne correctement
**Exemple de sortie :**
```
=== Déploiement de zapwall.fr ===
Branche: main
1. Vérification du dépôt Git...
✓ Dépôt Git détecté
Branche actuelle: main
2. Récupération des dernières modifications...
3. Sauvegarde des modifications locales...
4. Nettoyage des fichiers non suivis...
5. Vérification de la branche main...
6. Mise à jour depuis la branche main...
7. Dernier commit: abc1234 Fix: correction du bug
8. Installation des dépendances...
9. Construction de l'application...
10. Redémarrage du service zapwall...
11. Vérification du service...
✓ Service actif
12. Vérification du port 3001...
✓ Port 3001 en écoute
=== Déploiement terminé avec succès ===
```
### Commandes utiles après déploiement
```bash
# Voir les logs en temps réel
ssh <USER>@<IP> 'sudo journalctl -u zapwall -f'
# Vérifier le statut du service
ssh <USER>@<IP> 'sudo systemctl status zapwall'
# Voir les stashes Git
ssh <USER>@<IP> 'cd /var/www/zapwall.fr && git stash list'
# Restaurer un stash
ssh <USER>@<IP> 'cd /var/www/zapwall.fr && git stash pop'
```
---
## Dépannage
### Le service ne démarre pas
```bash
# Voir les logs
ssh <USER>@<IP> 'sudo journalctl -u zapwall -n 50'
# Vérifier le statut
ssh <USER>@<IP> 'sudo systemctl status zapwall'
# Vérifier que le répertoire existe
ssh <USER>@<IP> 'ls -la /var/www/zapwall.fr'
# Vérifier que l'application est construite
ssh <USER>@<IP> 'ls -la /var/www/zapwall.fr/.next'
```
### Le port 3001 n'est pas en écoute
```bash
# Vérifier que le service est actif
ssh <USER>@<IP> 'sudo systemctl status zapwall'
# Redémarrer le service
ssh <USER>@<IP> 'sudo systemctl restart zapwall'
# Vérifier les processus
ssh <USER>@<IP> 'sudo ss -tuln | grep 3001'
```
### Nginx ne sert pas le bon site
Si nginx sert un autre site au lieu de zapwall.fr :
1. **Vérifier que la configuration zapwall.fr.conf est chargée** :
```bash
# Vérifier si zapwall.fr est dans la configuration chargée
ssh <USER>@<IP> 'sudo docker exec lecoffre_nginx_test nginx -T 2>&1 | grep "server_name zapwall.fr"'
# Si aucun résultat, vérifier que conf.d est inclus dans nginx.conf
ssh <USER>@<IP> 'sudo docker exec lecoffre_nginx_test cat /etc/nginx/nginx.conf | grep "include.*conf.d"'
```
2. **Si conf.d n'est pas inclus, corriger nginx.conf** :
```bash
# Le fichier est monté depuis l'hôte, modifier sur l'hôte
ssh <USER>@<IP> 'sudo tail -5 /home/debian/sites/test-lecoffreio.4nkweb.com/deploy/nginx/nginx-test.conf'
# Ajouter l'inclusion avant la fermeture du bloc http (avant })
# Ajouter ces lignes avant le dernier }
# # Include site configurations
# include /etc/nginx/conf.d/*.conf;
# Redémarrer le conteneur pour prendre en compte la modification
ssh <USER>@<IP> 'sudo docker restart lecoffre_nginx_test'
```
3. **Vérifier la configuration zapwall.fr.conf** :
```bash
# Vérifier la configuration
ssh <USER>@<IP> 'sudo docker exec lecoffre_nginx_test cat /etc/nginx/conf.d/zapwall.fr.conf'
# Vérifier que proxy_pass pointe vers 172.17.0.1:3001
# Vérifier que server_name contient zapwall.fr
# Tester la configuration
ssh <USER>@<IP> 'sudo docker exec lecoffre_nginx_test nginx -t'
# Recharger nginx
ssh <USER>@<IP> 'sudo docker exec lecoffre_nginx_test nginx -s reload'
```
4. **Tester avec curl** :
```bash
# Simuler une requête pour zapwall.fr
ssh <USER>@<IP> 'sudo docker exec lecoffre_nginx_test curl -s -k -H "Host: zapwall.fr" https://localhost | head -5'
```
Voir aussi : `fixKnowledge/nginx-conf-d-not-loaded.md` pour plus de détails.
### Erreurs de build
Si le build échoue à cause d'erreurs ESLint :
```bash
# Vérifier que next.config.js ignore les erreurs ESLint
ssh <USER>@<IP> 'cat /var/www/zapwall.fr/next.config.js'
# Si nécessaire, copier la configuration depuis le dépôt local
cat next.config.js | ssh <USER>@<IP> 'cat > /var/www/zapwall.fr/next.config.js'
```
### Problèmes de certificats SSL
```bash
# Vérifier les certificats
ssh <USER>@<IP> 'sudo ls -la /etc/letsencrypt/live/zapwall.fr/'
# Vérifier dans le conteneur
ssh <USER>@<IP> 'sudo docker exec lecoffre_nginx_test ls -la /etc/letsencrypt/live/zapwall.fr/'
# Vérifier la configuration nginx
ssh <USER>@<IP> 'sudo docker exec lecoffre_nginx_test grep ssl_certificate /etc/nginx/conf.d/zapwall.fr.conf'
```
---
## Maintenance
### Commandes utiles
#### Voir les logs en temps réel
```bash
ssh <USER>@<IP> 'sudo journalctl -u zapwall -f'
```
#### Vérifier le statut du service
```bash
ssh <USER>@<IP> 'sudo systemctl status zapwall'
```
#### Redémarrer le service
```bash
ssh <USER>@<IP> 'sudo systemctl restart zapwall'
```
#### Vérifier les ports
```bash
# Port application
ssh <USER>@<IP> 'sudo ss -tuln | grep 3001'
# Ports HTTP/HTTPS
ssh <USER>@<IP> 'sudo ss -tuln | grep -E "(80|443)"'
```
#### Vérifier la configuration nginx
```bash
# Tester la configuration
ssh <USER>@<IP> 'sudo docker exec lecoffre_nginx_test nginx -t'
# Voir la configuration
ssh <USER>@<IP> 'sudo docker exec lecoffre_nginx_test cat /etc/nginx/conf.d/zapwall.fr.conf'
```
#### Vérifier le conteneur Docker
```bash
# Statut du conteneur
ssh <USER>@<IP> 'sudo docker ps | grep lecoffre_nginx_test'
# Logs du conteneur
ssh <USER>@<IP> 'sudo docker logs lecoffre_nginx_test --tail 50'
```
### Tâches de maintenance régulières
1. **Mise à jour du code** : Utiliser `update-remote-git.sh` régulièrement
2. **Renouvellement des certificats** : Automatisé via cron (à configurer)
3. **Mise à jour des dépendances** : `npm audit` et `npm update` si nécessaire
4. **Nettoyage des logs** : Rotation automatique via systemd/journald
5. **Surveillance** : Vérifier régulièrement les logs et le statut du service
### Sauvegarde
Les fichiers importants à sauvegarder :
- `/var/www/zapwall.fr/` : Code source et build
- `/etc/systemd/system/zapwall.service` : Configuration du service
- `/etc/letsencrypt/live/zapwall.fr/` : Certificats SSL (si Let's Encrypt)
- Configuration nginx dans le conteneur Docker
---
## Références
### Documentation liée
- `README-DEPLOYMENT.md` : Guide de déploiement détaillé
- `RESUME-DEPLOIEMENT.md` : Résumé du déploiement
- `update-summary.md` : Résumé des mises à jour
### Liens utiles
- Dépôt Git : `https://git.4nkweb.com/4nk/story-research-zapwall.git`
- Documentation Next.js : https://nextjs.org/docs
- Documentation Let's Encrypt : https://letsencrypt.org/docs/
- Documentation systemd : https://www.freedesktop.org/software/systemd/man/
---
## Support
En cas de problème :
1. Consulter les logs : `sudo journalctl -u zapwall -n 100`
2. Vérifier le statut : `sudo systemctl status zapwall`
3. Vérifier la configuration : Utiliser les scripts de vérification
4. Consulter cette documentation : Section [Dépannage](#dépannage)
---
*Dernière mise à jour : 2025-12-28*

294
docs/fr/faq.md Normal file
View File

@ -0,0 +1,294 @@
# FAQ - Questions fréquentes
## Questions générales
### Qu'est-ce que zapwall.fr ?
zapwall.fr est une plateforme de publication de contenus scientifiques et de science-fiction basée sur le protocole Nostr. Les auteurs peuvent publier des publications avec un aperçu gratuit et un contenu complet payant, débloqué via des paiements Lightning Network.
### Comment fonctionne le système de paiement ?
1. L'auteur publie une publication avec un aperçu gratuit
2. L'auteur crée une invoice Lightning lors de la publication pour recevoir les zaps
3. Les lecteurs peuvent lire l'aperçu gratuitement
4. Pour lire le contenu complet, les lecteurs effectuent un zap Lightning de 800 sats
5. Une fois le zap confirmé, le contenu complet est envoyé via message privé chiffré (NIP-04)
### Combien coûte une publication ?
Toutes les publications ont le même montant : **800 sats** (environ 0,000008 BTC). Sur ce montant :
- **700 sats** vont à l'auteur
- **100 sats** sont la commission de la plateforme
- Des frais de transaction Lightning s'ajoutent
### Qu'est-ce qu'un "sat" ?
Un "sat" (satoshi) est la plus petite unité de Bitcoin. 1 BTC = 100 000 000 sats.
### Comment fonctionne le sponsoring ?
Le sponsoring permet de soutenir directement un auteur avec **0.046 BTC** :
- **0.042 BTC** vont à l'auteur
- **0.004 BTC** sont la commission de la plateforme
- Le sponsoring se fait via une transaction Bitcoin mainnet
---
## Connexion et authentification
### Comment me connecter ?
Cliquez sur "Connect with Nostr" et autorisez la connexion avec Alby. L'application utilise l'extension Alby pour l'authentification Nostr (NIP-07) et les paiements Lightning (WebLN).
### J'ai besoin d'un compte ?
Non, vous n'avez pas besoin de créer un compte. Vous utilisez votre identité Nostr existante via votre portefeuille Nostr.
### Puis-je utiliser plusieurs comptes ?
Oui, vous pouvez vous déconnecter et vous reconnecter avec un autre compte Nostr à tout moment.
### Que se passe-t-il si je me déconnecte ?
- Vous restez connecté pour lire les aperçus de publications
- Vous devez être connecté pour créer votre page auteur
- Vous devez être connecté pour publier des publications
- Vous devez être connecté pour payer et débloquer des publications
- Le contenu déjà débloqué reste accessible (stocké localement)
---
## Paiements
### Comment effectuer un zap pour une publication ?
1. Cliquez sur "Débloquer" sur la publication souhaitée
2. Une fenêtre s'ouvre avec un QR code et une invoice Lightning
3. Cliquez sur "Pay with Alby" ou scannez le QR code avec votre portefeuille Lightning
4. Confirmez le zap dans votre portefeuille
5. Le contenu se débloque automatiquement après confirmation
> **Important** : Seuls les zaps sont autorisés. Les paiements Lightning standard ne fonctionnent pas pour débloquer les publications.
### Quel portefeuille Lightning puis-je utiliser ?
Tout portefeuille Lightning compatible avec WebLN fonctionne. **Alby** est recommandé et testé. D'autres portefeuilles comme Breez, Zeus, etc. peuvent fonctionner s'ils supportent WebLN et peuvent effectuer des zaps.
### Dois-je installer Alby ?
Oui, pour effectuer des paiements facilement, vous devez installer l'extension Alby (ou un autre portefeuille Lightning compatible WebLN).
### Les zaps sont-ils sécurisés ?
Oui, les zaps utilisent le protocole Lightning Network et sont vérifiés via les reçus de zap Nostr (NIP-57), ce qui est sécurisé et décentralisé. Les zaps sont la seule méthode autorisée pour débloquer les publications.
### Que se passe-t-il si je paie mais que le contenu ne se débloque pas ?
Cela ne devrait pas arriver, mais si c'est le cas :
1. Attendez quelques secondes (la vérification peut prendre du temps)
2. Rafraîchissez la page
3. Vérifiez que le paiement a bien été effectué dans votre portefeuille
4. Contactez l'auteur de la publication
### Puis-je obtenir un remboursement ?
Les paiements Lightning sont généralement irréversibles. Contactez l'auteur de la publication si vous avez un problème.
### Les invoices expirent-elles ?
Oui, les invoices expirent après **24 heures**. Si une invoice expire, fermez la fenêtre et cliquez à nouveau sur "Débloquer" pour générer une nouvelle invoice.
---
## Publication de publications
### Comment publier une publication ?
1. Connectez-vous avec Nostr
2. Créez votre page auteur (obligatoire, une seule fois)
3. Cliquez sur "Publier une publication" dans le menu
4. Remplissez le formulaire :
- **Titre** : Le titre de votre publication
- **Preview** : L'aperçu gratuit (visible par tous)
- **Content** : Le contenu complet (débloqué après paiement)
- **Catégorie** : Science-fiction ou Recherche scientifique
- **Série** : Optionnel, pour organiser vos publications
5. Cliquez sur "Publier"
6. Autorisez la création de l'invoice Lightning dans Alby
7. Votre publication sera publiée sur le relay Nostr
### Dois-je payer pour publier une publication ?
Non, la publication est gratuite. Vous devez seulement avoir Alby installé pour créer l'invoice Lightning.
### Puis-je modifier ou supprimer une publication après publication ?
Actuellement, cette fonctionnalité n'est pas disponible. Les publications publiées sur Nostr sont immutables. Une fonctionnalité d'édition/suppression sera ajoutée dans une future version.
### Comment les lecteurs paient-ils pour ma publication ?
Les lecteurs cliquent sur "Débloquer" et paient l'invoice Lightning que vous avez créée lors de la publication. Une fois le paiement confirmé, le contenu complet est automatiquement envoyé via message privé chiffré.
### Comment recevoir les paiements ?
Les paiements sont envoyés directement à votre portefeuille Lightning (celui utilisé pour créer l'invoice lors de la publication). Vous recevrez 700 sats sur chaque vente (800 sats - 100 sats de commission).
### Puis-je définir un montant personnalisé ?
Non, le montant est fixe à 800 sats pour toutes les publications. Cela simplifie l'expérience utilisateur et garantit une tarification équitable.
---
## Page auteur
### Qu'est-ce qu'une page auteur ?
Une page auteur est une publication obligatoire que chaque auteur doit créer avant de pouvoir publier. Elle contient :
- Votre présentation
- Votre description
- Votre adresse Bitcoin mainnet pour le sponsoring (optionnel)
### Dois-je créer une page auteur ?
Oui, la page auteur est **obligatoire** pour publier des publications. Vous ne pouvez publier qu'après avoir créé votre page auteur.
### Comment créer ma page auteur ?
1. Connectez-vous avec Nostr
2. Cliquez sur "Créer page auteur" dans le menu
3. Remplissez votre présentation
4. Publiez votre page auteur
### Ma page auteur est-elle publique ?
Oui, votre page auteur est accessible publiquement à l'adresse `/author/[votre-pubkey]`. Elle permet aux lecteurs de vous découvrir et de vous sponsoriser.
---
## Séries
### Qu'est-ce qu'une série ?
Une série est un regroupement de publications organisées par un auteur. Les séries permettent d'organiser vos publications par thème.
### Comment créer une série ?
Lors de la publication d'une publication, vous pouvez créer une nouvelle série ou sélectionner une série existante. Remplissez les informations de la série (titre, description, image de couverture).
### Puis-je ajouter une publication à une série existante ?
Oui, lors de la publication, vous pouvez sélectionner une série existante pour y ajouter votre publication.
---
## Avis
### Qu'est-ce qu'un avis ?
Un avis est un commentaire ou une évaluation d'une publication par un lecteur qui a acheté la publication.
### Qui peut poster un avis ?
Seuls les lecteurs qui ont débloqué une publication peuvent poster un avis sur cette publication.
### Les avis sont-ils rémunérables ?
Oui, en tant qu'auteur, vous pouvez remercier un lecteur pour son avis avec **70 sats** :
- **49 sats** vont au reviewer
- **21 sats** sont la commission de la plateforme
### Comment remercier un avis ?
1. Accédez à la publication concernée
2. Trouvez l'avis que vous souhaitez remercier
3. Cliquez sur "Remercier" (70 sats)
4. Confirmez le zap dans Alby
---
## Contenu et publications
### Puis-je lire les publications sans payer ?
Oui, vous pouvez lire l'**aperçu** (preview) de toutes les publications gratuitement. Seul le **contenu complet** nécessite un paiement.
### Le contenu débloqué est-il stocké ?
Oui, le contenu débloqué est stocké localement dans votre navigateur (IndexedDB) pour rester accessible même après déconnexion.
### Puis-je partager une publication débloquée ?
Le contenu débloqué est stocké localement dans votre navigateur. Vous pouvez partager le lien de la publication, mais les autres utilisateurs devront payer pour débloquer le contenu.
### Les publications sont-elles publiques ?
Les **aperçus** sont publics et visibles par tous sur le relay Nostr. Le **contenu complet** est envoyé uniquement via message privé chiffré après paiement.
### Puis-je rechercher dans les publications ?
Oui, vous pouvez rechercher par titre, aperçu ou contenu. Vous pouvez également filtrer par catégorie, auteur et trier par date.
---
## Technique
### Quel relay Nostr est utilisé ?
Par défaut, l'application utilise `wss://relay.damus.io`. La configuration des relais est stockée dans IndexedDB (stockage local du navigateur) et peut être personnalisée via les paramètres de l'application. L'application supporte plusieurs relais avec un système de priorité.
### Les données sont-elles stockées sur un serveur ?
Non, l'application est décentralisée :
- Les publications sont publiées sur le relay Nostr (décentralisé)
- Le contenu débloqué est stocké localement dans votre navigateur (IndexedDB)
- Les notifications sont stockées localement dans votre navigateur
### Puis-je utiliser un autre relay Nostr ?
Oui, vous pouvez configurer un autre relay via les variables d'environnement. Cependant, vous ne verrez que les publications publiées sur le relay configuré.
### L'application fonctionne-t-elle hors ligne ?
Non, l'application nécessite une connexion internet pour :
- Se connecter au relay Nostr
- Publier des publications
- Effectuer des paiements Lightning
- Recevoir des notifications
Le contenu déjà débloqué reste accessible hors ligne (stocké localement).
---
## Problèmes et support
### L'application ne fonctionne pas
Vérifiez :
1. Votre connexion internet
2. Que le relay Nostr est accessible
3. La console du navigateur pour les erreurs
4. Que JavaScript est activé dans votre navigateur
### Je ne peux pas créer ma page auteur
Vérifiez :
1. Que vous êtes connecté avec Nostr
2. Que votre portefeuille Nostr peut signer des événements
3. Que tous les champs sont remplis
### Mon contenu débloqué a disparu
Le contenu est stocké localement. Si vous avez :
- Vidé le cache du navigateur
- Supprimé les données du site
- Utilisé un autre navigateur ou appareil
Le contenu peut être perdu. Vous devrez peut-être payer à nouveau pour débloquer la publication.
### Puis-je contacter le support ?
Pour l'instant, il n'y a pas de support officiel. Consultez la documentation ou créez une issue sur le [dépôt Gitea du projet](https://git.4nkweb.com/4nk/story-research-zapwall/issues).
---
**Dernière mise à jour** : Décembre 2024

View File

@ -0,0 +1,16 @@
# Frais et contributions
## Tarification
### Achat de publications
Consultez les auteurs et aperçus, achetez les parutions au fil de l'eau par **800 sats** (moins 100 sats et frais de transaction).
### Sponsoring d'auteur
Sponsorisez l'auteur pour **0.046 BTC** (moins 0.004 BTC et frais de transaction).
### Remerciements d'avis
Les avis sont remerciables pour **70 sats** (moins 21 sats et frais de transaction).
## Utilisation des fonds
Les fonds collectés par la plateforme servent au développement de fonctions IA gratuites pour les auteurs (développement et matériel).

279
docs/fr/payment-guide.md Normal file
View File

@ -0,0 +1,279 @@
# Guide de paiement avec Alby
Ce guide vous explique comment effectuer un zap pour débloquer des publications avec Alby et le protocole Lightning Network.
> **Important** : Seuls les zaps sont autorisés pour débloquer les publications. Les paiements Lightning standard ne fonctionnent pas.
## Qu'est-ce qu'Alby ?
[Alby](https://getalby.com/) est une extension de navigateur qui permet de gérer des zaps Lightning Network directement depuis votre navigateur. Alby utilise le standard WebLN pour interagir avec les applications web.
## Installation d'Alby
### 1. Télécharger Alby
1. Visitez [getalby.com](https://getalby.com/)
2. Cliquez sur **"Get Alby"** ou **"Install Extension"**
3. Choisissez votre navigateur :
- Chrome / Edge
- Firefox
- Brave
- Safari (via l'App Store)
### 2. Installer l'extension
1. Suivez les instructions d'installation pour votre navigateur
2. L'extension Alby apparaîtra dans la barre d'outils de votre navigateur
3. Cliquez sur l'icône Alby pour commencer la configuration
### 3. Configurer Alby
#### Option A : Créer un nouveau compte Alby
1. Cliquez sur l'icône Alby dans votre navigateur
2. Cliquez sur **"Create Account"** ou **"Sign Up"**
3. Suivez les instructions pour créer un compte
4. Ajoutez des fonds à votre portefeuille Alby :
- Par carte bancaire
- Par virement bancaire
- Par Lightning Network (depuis un autre portefeuille)
#### Option B : Connecter un portefeuille Lightning existant
1. Cliquez sur l'icône Alby
2. Choisissez **"Connect Wallet"** ou **"Link Existing Wallet"**
3. Suivez les instructions pour connecter votre portefeuille Lightning (LND, CLN, etc.)
### 4. Vérifier l'installation
1. Revenez sur zapwall.fr
2. Si Alby est correctement installé, vous verrez un message de confirmation
3. Si Alby n'est pas installé, un message vous invitera à l'installer
## Effectuer un zap pour une publication
### Processus étape par étape
#### 1. Choisir une publication
1. Parcourez la liste des publications sur la page d'accueil
2. Lisez l'aperçu gratuit
3. Si vous souhaitez lire le contenu complet, cliquez sur **"Débloquer"**
#### 2. Fenêtre de zap
Une fenêtre modale s'ouvre avec :
- **Montant du zap** : 800 sats (montant fixe)
- **QR Code Lightning** : Pour scanner avec un portefeuille mobile
- **Invoice Lightning** : La facture Lightning (BOLT11)
- **Timer d'expiration** : Temps restant avant expiration (24h)
- **Bouton "Pay with Alby"** : Pour payer directement avec Alby
#### 3. Méthodes de zap
Vous avez **3 options** pour effectuer le zap :
> **Important** : Seuls les zaps sont autorisés pour débloquer les publications. Les paiements Lightning standard ne fonctionnent pas.
##### Option 1 : Zap avec Alby (recommandé)
1. Cliquez sur **"Pay with Alby"**
2. Une fenêtre Alby s'ouvre automatiquement
3. Vérifiez les détails du zap :
- Montant (800 sats)
- Description
- Destinataire
4. Cliquez sur **"Confirm"** ou **"Pay"** dans Alby
5. Le zap est effectué instantanément
6. La fenêtre se ferme automatiquement
7. Le contenu complet s'affiche après quelques secondes
##### Option 2 : Scanner le QR Code
1. Ouvrez votre portefeuille Lightning mobile (BlueWallet, Breez, etc.)
2. Utilisez la fonction "Scanner" de votre portefeuille
3. Scannez le QR code affiché dans la fenêtre
4. Confirmez le zap dans votre portefeuille mobile
5. Le contenu se débloque automatiquement après confirmation
##### Option 3 : Copier l'invoice
1. Cliquez sur **"Copy Invoice"** pour copier l'invoice Lightning
2. Collez l'invoice dans votre portefeuille Lightning (n'importe lequel)
3. Effectuez le zap
4. Le contenu se débloque automatiquement après confirmation
### 4. Confirmation du zap
Après le zap :
1. **Vérification automatique** : L'application vérifie le zap via les reçus de zap Nostr (NIP-57)
2. **Délai** : La vérification peut prendre quelques secondes (généralement 5-30 secondes)
3. **Affichage du contenu** : Une fois vérifié, le contenu complet s'affiche automatiquement
4. **Stockage local** : Le contenu est stocké localement dans votre navigateur (IndexedDB)
## Expiration des invoices
### Durée de validité
- Les invoices expirent après **24 heures**
- Un timer affiche le temps restant dans la fenêtre de zap
- Si l'invoice expire, elle devient invalide
### Que faire si l'invoice expire ?
1. **Fermez la fenêtre de zap**
2. **Cliquez à nouveau sur "Débloquer"**
3. **Une nouvelle invoice sera générée** automatiquement
4. **Effectuez le zap avec la nouvelle invoice**
> **Note** : N'effectuez jamais un zap avec une invoice expirée, le zap échouera.
## Commissions et montants
### Montant d'une publication
- **Montant total** : 800 sats
- **À l'auteur** : 700 sats
- **Commission plateforme** : 100 sats
- **Frais de transaction** : Payés par l'auteur
### Montant du sponsoring
- **Montant total** : 0.046 BTC
- **À l'auteur** : 0.042 BTC
- **Commission plateforme** : 0.004 BTC
- **Frais de transaction** : Payés par l'auteur
### Montant d'un remerciement d'avis
- **Montant total** : 70 sats
- **Au reviewer** : 49 sats
- **Commission plateforme** : 21 sats
- **Frais de transaction** : Payés par l'auteur
## Dépannage
### Alby ne s'ouvre pas
**Solutions** :
- Vérifiez que Alby est bien installé
- Rafraîchissez la page
- Vérifiez que l'extension Alby est activée dans votre navigateur
- Réessayez de cliquer sur "Pay with Alby"
### Le zap échoue
**Vérifiez** :
- ✅ Que vous avez suffisamment de fonds dans Alby
- ✅ Que l'invoice n'a pas expiré
- ✅ Votre connexion internet
- ✅ Les logs d'erreur dans la console du navigateur
- ✅ Que vous effectuez bien un zap (pas un paiement Lightning standard)
**Solutions** :
- Ajoutez des fonds à votre portefeuille Alby
- Générez une nouvelle invoice (fermez et rouvrez la fenêtre)
- Réessayez le zap
- Assurez-vous d'effectuer un zap via Nostr, pas un paiement Lightning standard
### Le contenu ne se débloque pas après le zap
**Vérifiez** :
- ✅ Que le zap a bien été effectué (vérifiez dans Alby)
- ✅ Attendez quelques secondes (la vérification peut prendre du temps)
- ✅ Rafraîchissez la page
- ✅ Que le zap a bien été vérifié via les reçus de zap Nostr
**Solutions** :
- Attendez 30-60 secondes pour la vérification
- Rafraîchissez la page
- Vérifiez vos notifications (badge en haut à droite)
- Contactez l'auteur de la publication si le problème persiste
### Je n'ai pas assez de fonds
**Solutions** :
- Ajoutez des fonds à votre portefeuille Alby :
- Par carte bancaire
- Par virement bancaire
- Par Lightning Network (depuis un autre portefeuille)
- Attendez que les fonds soient disponibles
- Réessayez le zap
### L'invoice a expiré
**Solutions** :
- Fermez la fenêtre de zap
- Cliquez à nouveau sur "Débloquer"
- Une nouvelle invoice sera générée
- Effectuez le zap avec la nouvelle invoice
## Sécurité
### Les zaps sont-ils sécurisés ?
Oui, les zaps Lightning Network sont :
- ✅ **Décentralisés** : Pas de serveur central
- ✅ **Rapides** : Confirmations en quelques secondes
- ✅ **Peu coûteux** : Frais minimes
- ✅ **Vérifiables** : Vérifiés via les reçus de zap Nostr (NIP-57)
- ✅ **Seule méthode autorisée** : Seuls les zaps fonctionnent pour débloquer les publications
### Mes informations sont-elles partagées ?
- ✅ **Non** : Les zaps Lightning sont privés
- ✅ Seul le montant et le destinataire sont visibles sur la blockchain Lightning
- ✅ Votre identité Nostr est liée aux zaps via les zap receipts (NIP-57)
### Puis-je obtenir un remboursement ?
Les zaps Lightning sont généralement **irréversibles**. Si vous avez un problème :
1. Vérifiez que le zap a bien été effectué
2. Contactez l'auteur de la publication
3. Vérifiez que le contenu ne s'est pas débloqué (attendez quelques secondes)
## Alternatives à Alby
### Autres portefeuilles WebLN
Si vous préférez ne pas utiliser Alby, vous pouvez utiliser d'autres portefeuilles Lightning compatibles WebLN :
- **Breez** (si support WebLN)
- **Zeus** (si support WebLN)
- Autres portefeuilles compatibles
### Portefeuilles mobiles
Vous pouvez également utiliser un portefeuille Lightning mobile :
1. Scannez le QR code avec votre portefeuille mobile
2. Confirmez le zap
3. Le contenu se débloque automatiquement
**Portefeuilles mobiles populaires** :
- BlueWallet
- Breez
- Zeus
- Wallet of Satoshi
## Conseils
### Gérer vos fonds
- Gardez suffisamment de fonds dans Alby pour plusieurs publications
- Ajoutez des fonds régulièrement pour éviter les interruptions
- Surveillez votre solde dans l'extension Alby
### Zaps multiples
- Vous pouvez effectuer des zaps pour plusieurs publications en succession
- Chaque zap est indépendant
- Le contenu de chaque publication est stocké séparément
### Contenu débloqué
- Le contenu débloqué est stocké localement dans votre navigateur
- Il reste accessible même après déconnexion
- Si vous videz le cache, le contenu peut être perdu (vous devrez peut-être payer à nouveau)
---
**Dernière mise à jour** : Décembre 2024

253
docs/fr/publishing-guide.md Normal file
View File

@ -0,0 +1,253 @@
# Guide de publication
Ce guide vous explique comment publier une publication sur zapwall.fr avec un aperçu gratuit et un contenu payant.
## Prérequis
Avant de publier une publication, vous devez avoir :
1. ✅ **Un portefeuille Nostr** (pour vous connecter et signer les événements)
2. ✅ **Votre page auteur créée** (obligatoire, une seule fois)
3. ✅ **Alby installé** (pour créer l'invoice Lightning)
4. ✅ **Des fonds dans votre portefeuille Lightning** (optionnel, mais recommandé pour tester)
## Étapes de publication
### 1. Créer votre page auteur (obligatoire)
Avant de pouvoir publier, vous devez créer votre page auteur :
1. Connectez-vous avec Nostr
2. Cliquez sur **"Créer page auteur"** dans le menu
3. Remplissez votre présentation :
- Votre description
- Votre adresse Bitcoin mainnet pour le sponsoring (optionnel)
4. Publiez votre page auteur
> **Important** : La page auteur est obligatoire. Vous ne pouvez pas publier de publication sans avoir créé votre page auteur.
### 2. Accéder à la page de publication
1. Cliquez sur **"Publier une publication"** dans le menu principal
2. Vous serez redirigé vers la page `/publish`
### 3. Remplir le formulaire
Le formulaire contient plusieurs champs :
#### Titre (obligatoire)
- Le titre de votre publication
- Visible par tous dans la liste des publications
- Exemple : "Introduction à Nostr"
#### Preview / Aperçu (obligatoire)
- Le contenu gratuit visible par tous
- C'est ce que les lecteurs verront avant de payer
- Doit être suffisamment intéressant pour inciter à payer
- Exemple : "Découvrez les bases du protocole Nostr et comment il révolutionne les réseaux sociaux décentralisés..."
#### Content / Contenu complet (obligatoire)
- Le contenu complet qui sera débloqué après paiement
- Envoyé via message privé chiffré (NIP-04) après paiement
- Peut contenir du texte, des images (liens), etc.
- Exemple : "Nostr est un protocole de réseau social décentralisé basé sur des clés cryptographiques..."
#### Catégorie (obligatoire)
- **Science-fiction** : Pour les contenus de fiction scientifique
- **Recherche scientifique** : Pour les contenus scientifiques et de recherche
#### Série (optionnel)
- Vous pouvez créer une nouvelle série ou sélectionner une série existante
- Les séries permettent d'organiser vos publications par thème
- Remplissez les informations de la série :
- Titre
- Description
- Image de couverture (optionnel)
### 4. Publier la publication
1. Cliquez sur le bouton **"Publier la publication"**
2. Si Alby n'est pas installé, vous serez invité à l'installer
3. **Autorisez la création de l'invoice Lightning** dans Alby
4. L'invoice sera créée automatiquement (800 sats)
5. Votre publication sera publiée sur le relay Nostr
### 5. Confirmation
Une fois publiée, vous verrez :
- ✅ Un message de confirmation
- Vous serez automatiquement redirigé vers la page d'accueil
- Votre publication apparaîtra dans la liste des publications
## Comment ça fonctionne techniquement
### 1. Publication de l'aperçu
L'aperçu est publié comme un **événement Nostr de type 1** (note textuelle) avec les tags suivants :
- `#publication` : Type de contenu
- `#sciencefiction` ou `#research` : Catégorie
- `#id_<id>` : Identifiant unique
- `#paywall` : Indique que le contenu est payant
- `title` : Le titre de la publication
- `preview` : L'aperçu gratuit
- `zapAmount` : Le montant en sats (800 sats)
- `invoice` : L'invoice Lightning (BOLT11)
- `paymentHash` : Le hash de l'invoice
### 2. Création de l'invoice
L'invoice Lightning est créée via Alby/WebLN lors de la publication :
- **Montant** : 800 sats (montant fixe pour toutes les publications)
- **Description** : "Payment for publication: {titre}"
- **Expiration** : 24 heures
### 3. Stockage du contenu complet
Le contenu complet est stocké localement dans votre navigateur (IndexedDB) :
- Associé à l'ID de la publication
- Chiffré avec AES-GCM
- Utilisé pour envoyer le contenu après paiement
### 4. Envoi du contenu après paiement
Quand un lecteur paie :
1. Le paiement est vérifié via les reçus de zap Nostr (NIP-57)
2. Le contenu complet est envoyé via **message privé chiffré (NIP-04)**
3. Le message privé contient :
- Le contenu chiffré
- Un tag `e` liant à la publication
- Un tag `p` avec la clé publique du destinataire
## Commissions
### Sur les ventes de publications
- **Montant total** : 800 sats
- **À l'auteur** : 700 sats
- **Commission plateforme** : 100 sats
- **Frais de transaction** : Payés par l'auteur
### Sur le sponsoring
- **Montant total** : 0.046 BTC
- **À l'auteur** : 0.042 BTC
- **Commission plateforme** : 0.004 BTC
- **Frais de transaction** : Payés par l'auteur
### Sur les remerciements d'avis
- **Montant total** : 70 sats
- **Au reviewer** : 49 sats
- **Commission plateforme** : 21 sats
- **Frais de transaction** : Payés par l'auteur
## Conseils pour bien publier
### Écrire un bon aperçu
L'aperçu est crucial pour inciter les lecteurs à payer :
- ✅ Donnez un avant-goût du contenu complet
- ✅ Posez une question ou créez de la curiosité
- ✅ Mentionnez les points clés qui seront développés
- ❌ Ne révélez pas tout le contenu
- ❌ Ne soyez pas trop vague
**Exemple d'aperçu efficace** :
> "Découvrez comment Nostr révolutionne les réseaux sociaux en éliminant les serveurs centralisés. Dans cet article, nous explorerons l'architecture du protocole, les avantages de la décentralisation, et comment créer votre première application Nostr. Vous apprendrez également à implémenter des paiements Lightning directement dans vos applications."
### Montant de paiement
Le montant est fixe à **800 sats** pour toutes les publications. Cela simplifie l'expérience utilisateur et garantit une tarification équitable.
### Contenu de qualité
Le contenu complet doit :
- ✅ Être substantiel et apporter de la valeur
- ✅ Justifier le montant de paiement de 800 sats
- ✅ Être bien formaté et lisible
- ✅ Inclure des exemples ou des illustrations si pertinent
### Utiliser les séries
Les séries permettent d'organiser vos publications :
- ✅ Créez des séries thématiques
- ✅ Regroupez vos publications par sujet
- ✅ Facilitez la découverte de vos contenus
## Gestion des publications publiées
### Voir vos publications
1. Cliquez sur votre **profil** (nom/avatar en haut à droite)
2. La section "My Articles" affiche toutes vos publications
3. Vous pouvez rechercher et filtrer vos publications
### Statistiques
Actuellement, vous pouvez voir :
- Le nombre de publications publiées
- Les notifications de paiements reçus
> **Note** : Des statistiques plus détaillées seront ajoutées dans une future version.
### Édition et suppression
> **Note** : L'édition et la suppression de publications ne sont pas encore disponibles. Les événements Nostr sont immutables, donc une fois publiée, une publication ne peut pas être modifiée. Cette fonctionnalité sera ajoutée dans une future version.
## Dépannage
### Je ne peux pas publier
**Vérifiez** :
- ✅ Que vous êtes connecté avec Nostr
- ✅ Que vous avez créé votre page auteur (obligatoire)
- ✅ Que votre portefeuille Nostr peut signer des événements
- ✅ Que Alby est installé et activé
- ✅ Que tous les champs sont remplis
### L'invoice ne se crée pas
**Vérifiez** :
- ✅ Que Alby est installé
- ✅ Que vous avez autorisé l'application dans Alby
- ✅ Que votre portefeuille Lightning a des fonds (optionnel)
- ✅ Votre connexion internet
### La publication ne s'affiche pas après publication
**Vérifiez** :
- ✅ Que le relay Nostr est accessible
- ✅ Rafraîchissez la page
- ✅ Vérifiez la console du navigateur pour les erreurs
### Je ne reçois pas les paiements
**Vérifiez** :
- ✅ Que les lecteurs paient effectivement
- ✅ Vos notifications (badge en haut à droite)
- ✅ Votre portefeuille Lightning
- ✅ Que l'invoice n'a pas expiré
## Bonnes pratiques
### Fréquence de publication
- Publiez régulièrement pour maintenir l'engagement
- Ne publiez pas trop souvent (risque de spam)
- Qualité > Quantité
### Promotion
- Partagez vos publications sur d'autres plateformes Nostr
- Mentionnez vos publications dans vos notes Nostr
- Créez une communauté autour de votre contenu
### Engagement avec les lecteurs
- Répondez aux avis (si cette fonctionnalité est ajoutée)
- Créez du contenu de qualité qui mérite d'être payé
- Écoutez les retours de vos lecteurs
---
**Dernière mise à jour** : Décembre 2024

View File

@ -0,0 +1,66 @@
# Guide de référence rapide - zapwall.fr
## 🚀 Commandes essentielles
### Mise à jour du site
```bash
# Méthode recommandée : Script automatique
./deploy.sh
# Méthode manuelle
ssh debian@92.243.27.35
cd /var/www/zapwall.fr
git stash
git pull origin main
npm ci
npm run build
sudo systemctl restart zapwall
```
### Vérification du statut
```bash
# Service
ssh debian@92.243.27.35 'sudo systemctl status zapwall'
# Logs en temps réel
ssh debian@92.243.27.35 'sudo journalctl -u zapwall -f'
# Port 3001
ssh debian@92.243.27.35 'sudo ss -tuln | grep 3001'
```
### Redémarrage
```bash
ssh debian@92.243.27.35 'sudo systemctl restart zapwall'
```
### Configuration nginx
```bash
# Voir la configuration
ssh debian@92.243.27.35 'sudo docker exec lecoffre_nginx_test cat /etc/nginx/conf.d/zapwall.fr.conf'
# Tester la configuration
ssh debian@92.243.27.35 'sudo docker exec lecoffre_nginx_test nginx -t'
# Recharger nginx
ssh debian@92.243.27.35 'sudo docker exec lecoffre_nginx_test nginx -s reload'
```
## 📍 Informations importantes
- **Serveur** : `92.243.27.35`
- **Domaine** : `zapwall.fr`
- **Répertoire** : `/var/www/zapwall.fr`
- **Port** : `3001`
- **Service** : `zapwall.service`
- **Nginx** : Conteneur `lecoffre_nginx_test`
## 🔗 Liens rapides
- Documentation complète : `docs/deployment.md`
- Référence des scripts : `docs/scripts-reference.md`
- Guide de déploiement : `README-DEPLOYMENT.md`

View File

@ -0,0 +1,26 @@
# Tâches restantes
**Auteur** : Équipe 4NK
## Infrastructure nécessaire
### Transferts Lightning automatiques
**État actuel** : Les transferts sont loggés dans `lib/automaticTransfer.ts` mais nécessitent un nœud Lightning pour être exécutés automatiquement.
**À implémenter** :
- Configuration nœud Lightning de la plateforme
- API pour créer et payer des invoices Lightning
- Queue de transferts pour gestion asynchrone
- Retry en cas d'échec
- Monitoring et alertes
**Fichiers concernés** : `lib/automaticTransfer.ts`
## Fonctionnalités complétées
✅ Intégration mempool.space (`lib/mempoolSpace.ts`)
✅ Récupération adresses Lightning (`lib/lightningAddress.ts`)
✅ Mise à jour événements Nostr pour avis (`lib/reviewReward.ts`)
✅ Tracking sponsoring (`lib/sponsoringTracking.ts`)
✅ Vérification livraison contenu (`lib/contentDeliveryVerification.ts`)

View File

@ -0,0 +1,372 @@
# Référence des scripts de déploiement
## 📋 Vue d'ensemble
Ce document décrit tous les scripts disponibles pour le déploiement et la maintenance de zapwall.fr.
---
## Scripts de déploiement
### `deploy.sh`
**Description** : Déploiement initial complet avec vérifications approfondies.
**Fonctionnalités** :
- Vérification des ports (3001, 80, 443)
- Détection automatique de Docker/nginx
- Vérification des configurations existantes
- Transfert des fichiers
- Installation des dépendances
- Construction de l'application
- Configuration nginx (Docker ou système)
- Création du service systemd
- Vérifications post-déploiement
**Utilisation** :
```bash
./deploy.sh
```
**Options** :
- Mode non-interactif : `CI=true ./deploy.sh`
---
### `update-remote-git.sh`
**Description** : Mise à jour du site via Git directement sur le serveur.
**Fonctionnalités** :
- Initialisation Git si nécessaire
- Stash des modifications locales (y compris fichiers non suivis)
- Pull depuis la branche spécifiée
- Installation des dépendances
- Construction de l'application
- Redémarrage du service
- Vérifications complètes
**Utilisation** :
```bash
# Utilise la branche actuelle ou main par défaut
./update-remote-git.sh
# Spécifier une branche
./update-remote-git.sh main
```
**Paramètres** :
- `$1` : Nom de la branche (optionnel, défaut: main)
---
### `update-from-git.sh`
**Description** : Mise à jour depuis le dépôt Git local.
**Fonctionnalités** :
- Transfert des fichiers depuis le dépôt local
- Installation des dépendances
- Construction de l'application
- Redémarrage du service
**Utilisation** :
```bash
./update-from-git.sh [branche]
```
---
### `finish-deploy.sh`
**Description** : Finalisation du déploiement (configuration nginx + service).
**Fonctionnalités** :
- Configuration nginx Docker
- Création du service systemd
- Démarrage et vérification
**Utilisation** :
```bash
./finish-deploy.sh
```
---
## Scripts de vérification
### `check-deploy.sh`
**Description** : Vérification préalable avant déploiement.
**Vérifications** :
- Connexion SSH
- Ports utilisés
- Port 3001 libre
- État de nginx
- Configurations existantes
- Services systemd
- Test de configuration nginx
**Utilisation** :
```bash
./check-deploy.sh
```
---
### `check-deployment-status.sh`
**Description** : État complet du déploiement.
**Informations affichées** :
- Certificats SSL
- Configuration nginx
- État du service
- Ports en écoute
- Conteneur Docker
**Utilisation** :
```bash
./check-deployment-status.sh
```
---
### `check-nginx-config.sh`
**Description** : Vérification de la configuration nginx.
**Affichage** :
- Configuration principale
- Toutes les configurations dans conf.d
- Configuration zapwall.fr
- Configuration default.conf
**Utilisation** :
```bash
./check-nginx-config.sh
```
---
### `check-git-repo.sh`
**Description** : Vérification du dépôt Git sur le serveur.
**Informations** :
- Présence du dépôt Git
- Branche actuelle
- Dernier commit
- Remote configuré
**Utilisation** :
```bash
./check-git-repo.sh
```
---
### `final-status.sh`
**Description** : Résumé de l'état final du déploiement.
**Utilisation** :
```bash
./final-status.sh
```
---
## Scripts de configuration HTTPS
### `setup-https-autosigned.sh`
**Description** : Configuration HTTPS avec certificats auto-signés.
**Fonctionnalités** :
- Génération de certificats auto-signés
- Configuration nginx avec HTTPS
- Redirection HTTP → HTTPS
- Headers de sécurité
**Utilisation** :
```bash
./setup-https-autosigned.sh
```
---
### `deploy-letsencrypt.sh`
**Description** : Déploiement des certificats Let's Encrypt (mode standalone).
**Fonctionnalités** :
- Arrêt du conteneur nginx
- Obtention des certificats
- Copie dans le conteneur
- Mise à jour de la configuration
**Utilisation** :
```bash
./deploy-letsencrypt.sh
```
---
### `deploy-letsencrypt-webroot.sh`
**Description** : Déploiement Let's Encrypt en mode webroot.
**Fonctionnalités** :
- Obtention des certificats sans arrêter nginx
- Utilisation du challenge webroot
**Utilisation** :
```bash
./deploy-letsencrypt-webroot.sh
```
---
### `generate-certs.sh`
**Description** : Génération de certificats auto-signés.
**Utilisation** :
```bash
./generate-certs.sh
```
---
## Scripts utilitaires
### `open-firewall-ports.sh`
**Description** : Ouverture des ports 80 et 443 dans le firewall.
**Fonctionnalités** :
- Détection du type de firewall (UFW ou iptables)
- Ouverture des ports nécessaires
**Utilisation** :
```bash
./open-firewall-ports.sh
```
---
### `fix-nginx-config.sh`
**Description** : Correction de la configuration nginx et du service.
**Fonctionnalités** :
- Vérification du répertoire du service
- Vérification de la construction
- Redémarrage du service
**Utilisation** :
```bash
./fix-nginx-config.sh
```
---
### `upgrade-python-certbot.sh`
**Description** : Mise à jour de Python et Certbot.
**Fonctionnalités** :
- Installation de Python 3.12 si disponible
- Réinstallation de Certbot
**Utilisation** :
```bash
./upgrade-python-certbot.sh
```
---
## Scripts de diagnostic
### `check-docker.sh`
**Description** : Liste des conteneurs Docker.
**Utilisation** :
```bash
./check-docker.sh
```
---
### `check-ports.sh`
**Description** : Vérification des ports utilisés.
**Utilisation** :
```bash
./check-ports.sh
```
---
### `check-nginx-docker.sh`
**Description** : Configuration du nginx Docker.
**Utilisation** :
```bash
./check-nginx-docker.sh
```
---
## Variables de configuration
Tous les scripts utilisent ces variables (modifiables dans chaque script) :
```bash
SERVER="debian@92.243.27.35"
APP_NAME="zapwall"
DOMAIN="zapwall.fr"
APP_PORT=3001
APP_DIR="/var/www/zapwall.fr"
NGINX_CONTAINER="lecoffre_nginx_test"
GIT_REPO="https://git.4nkweb.com/4nk/story-research-zapwall.git"
```
---
## Ordre d'exécution recommandé
### Déploiement initial
1. `check-deploy.sh` - Vérification préalable
2. `deploy.sh` - Déploiement complet
3. `setup-https-autosigned.sh` - Configuration HTTPS
4. `check-deployment-status.sh` - Vérification finale
### Mise à jour régulière
1. `update-remote-git.sh` - Mise à jour depuis Git
### Dépannage
1. `check-deployment-status.sh` - État général
2. `check-nginx-config.sh` - Configuration nginx
3. `fix-nginx-config.sh` - Correction si nécessaire
---
## Notes importantes
- Tous les scripts nécessitent un accès SSH au serveur
- Les scripts utilisent `set -e` pour arrêter en cas d'erreur
- Les modifications sont sauvegardées (stash Git) avant les mises à jour
- Les scripts vérifient l'état avant de modifier
---
*Dernière mise à jour : 2025-12-28*

View File

@ -0,0 +1,169 @@
# Système de tags zapwall.fr
## Vue d'ensemble
Le système de tags de zapwall.fr utilise un système de tags personnalisé basé sur les tags standards de Nostr (kind 1 notes). Tous les événements sont des notes Nostr (kind 1), et le système utilise des tags en anglais pour identifier le type de contenu, la catégorie, et les métadonnées associées.
## Structure des tags
### Tags de base (tous les types)
Tous les événements incluent ces tags de base :
- **Type** : Tag simple (sans valeur) qui identifie le type de contenu
- `#author` : Présentation d'auteur
- `#series` : Série d'articles
- `#publication` : Article/publication
- `#quote` : Avis/review
- **Catégorie** : Tag simple qui identifie la catégorie
- `#sciencefiction` : Science-fiction
- `#research` : Recherche scientifique
- **Identifiant** : Tag avec valeur pour l'ID unique
- `["id", "<event_id>"]` : Identifiant unique de l'événement
- **Service** : Tag avec valeur pour identifier la plateforme
- `["service", "zapwall.fr"]` : Identifiant du service (toujours présent pour filtrer toutes les notes de zapwall.fr)
- **Paywall** : Tag simple (optionnel)
- `#paywall` : Indique que le contenu est payant
- **Payment** : Tag simple (optionnel)
- `#payment` : Indique qu'un paiement a été effectué
### Tags spécifiques par type
#### Tags pour `#author` (présentation d'auteur)
```typescript
["author"] // Type
["sciencefiction"] ou ["research"] // Catégorie
["id", "<event_id>"] // ID unique
["title", "<titre>"] // Titre de la présentation
["preview", "<aperçu>"] // Aperçu (optionnel)
["mainnet_address", "<adresse>"] // Adresse Bitcoin mainnet pour le sponsoring
["total_sponsoring", "<montant>"] // Total du sponsoring reçu (en sats)
["picture", "<url>"] // URL de la photo de profil (optionnel)
```
**Exemple de tags pour une présentation d'auteur :**
```
[
["author"],
["sciencefiction"],
["id", "abc123..."],
["service", "zapwall.fr"],
["title", "Présentation de John Doe"],
["preview", "Aperçu de la présentation..."],
["mainnet_address", "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"],
["total_sponsoring", "0"],
["picture", "https://cdn.nostrcheck.me/..."]
]
```
#### Tags pour `#publication` (article)
```typescript
["publication"] // Type
["sciencefiction"] ou ["research"] // Catégorie
["id", "<event_id>"] // ID unique
["title", "<titre>"] // Titre de l'article
["preview", "<aperçu>"] // Aperçu (optionnel)
["series", "<series_id>"] // ID de la série (optionnel)
["banner", "<url>"] // URL de la bannière (optionnel)
["zap", "<montant>"] // Montant en sats pour débloquer
["invoice", "<bolt11>"] // Facture BOLT11 (optionnel)
["payment_hash", "<hash>"] // Hash du paiement (optionnel)
["encrypted_key", "<key>"] // Clé de chiffrement (optionnel)
```
#### Tags pour `#series` (série)
```typescript
["series"] // Type
["sciencefiction"] ou ["research"] // Catégorie
["id", "<event_id>"] // ID unique
["title", "<titre>"] // Titre de la série
["description", "<description>"] // Description de la série
["preview", "<aperçu>"] // Aperçu (optionnel)
["cover", "<url>"] // URL de la couverture (optionnel)
```
#### Tags pour `#quote` (avis/review)
```typescript
["quote"] // Type
["article", "<article_id>"] // ID de l'article commenté
["reviewer", "<pubkey>"] // Clé publique du reviewer (optionnel)
["title", "<titre>"] // Titre de l'avis (optionnel)
```
## Filtrage et requêtes
Le système utilise `buildTagFilter` pour construire des filtres de requête Nostr :
```typescript
// Exemple : récupérer toutes les présentations d'auteurs de zapwall.fr
buildTagFilter({
type: 'author',
category: 'sciencefiction',
service: 'zapwall.fr'
})
// Résultat : { kinds: [1], "#author": [""], "#sciencefiction": [""], "#service": ["zapwall.fr"] }
// Exemple : récupérer une présentation spécifique
buildTagFilter({
type: 'author',
authorPubkey: 'abc123...',
service: 'zapwall.fr'
})
// Résultat : { kinds: [1], "#author": [""], "#service": ["zapwall.fr"], authors: ["abc123..."] }
// Exemple : récupérer toutes les notes de zapwall.fr
buildTagFilter({
service: 'zapwall.fr'
})
// Résultat : { kinds: [1], "#service": ["zapwall.fr"] }
```
## Extraction des tags
Le système utilise `extractTagsFromEvent` pour extraire les tags d'un événement :
```typescript
const tags = extractTagsFromEvent(event)
// Retourne un objet avec :
// - type: 'author' | 'series' | 'publication' | 'quote'
// - category: 'sciencefiction' | 'research'
// - id: string
// - paywall: boolean
// - payment: boolean
// - title, preview, mainnetAddress, etc. selon le type
```
## Tag service
Le tag `["service", "zapwall.fr"]` est utilisé pour identifier toutes les notes publiées par la plateforme zapwall.fr. Ce tag permet de :
- **Filtrer toutes les notes de la plateforme** : `buildTagFilter({ service: 'zapwall.fr' })`
- **Distinguer les notes zapwall.fr** des autres notes Nostr sur le réseau
- **Améliorer les performances** en filtrant dès la source lors des requêtes
**Note** : Aucun NIP (Nostr Improvement Proposal) ne spécifie actuellement un tag standardisé pour identifier un service/plateforme. Le tag `service` est donc une convention interne à zapwall.fr. Si un NIP standardisé émerge à l'avenir, le système pourra être adapté en conséquence.
## Avantages du système
1. **Standardisé** : Tous les événements sont des notes Nostr (kind 1), compatibles avec tous les clients Nostr
2. **Filtrable** : Les tags permettent de filtrer efficacement les événements par type, catégorie et service
3. **Extensible** : Facile d'ajouter de nouveaux types ou catégories
4. **Interopérable** : Les tags sont lisibles par n'importe quel client Nostr, même s'il ne comprend pas la structure complète
5. **Identifiable** : Le tag `service` permet de distinguer les notes zapwall.fr des autres notes Nostr
## Détection des auteurs
Les auteurs sont détectés via le tag `#author` dans les événements. Le système souscrit aux événements avec :
- `#author` : Pour identifier les présentations d'auteurs
- `#publication` : Pour identifier les articles
Cela permet de distinguer les auteurs (présentations) des articles (publications) dans le même flux d'événements.

143
docs/fr/technical.md Normal file
View File

@ -0,0 +1,143 @@
# Documentation technique
**Auteur** : Équipe 4NK
## Architecture
Zapwall est une plateforme décentralisée basée sur Nostr pour la publication et la monétisation de contenu. Le système utilise Lightning Network pour les paiements et Bitcoin mainnet pour le sponsoring.
### Services principaux
- **Nostr** (`lib/nostr.ts`) : Pool de connexions, publication/récupération d'événements, profils, zap requests
- **Paiements** (`lib/payment.ts`, `lib/paymentPolling.ts`) : Invoices Lightning via Alby/WebLN, vérification zap receipts (NIP-57), envoi automatique contenu privé
- **Commissions** (`lib/platformCommissions.ts`) : Configuration centralisée, calcul splits, validation montants
- **Tracking** (`lib/platformTracking.ts`, `lib/sponsoringTracking.ts`) : Événements Nostr (kind 30078/30079) pour audit
- **Intégrations** :
- `lib/mempoolSpace.ts` : Vérification transactions Bitcoin via mempool.space API
- `lib/lightningAddress.ts` : Récupération adresses Lightning depuis profils Nostr (lud16/lud06)
### Stockage
- **IndexedDB** : Contenu privé chiffré (AES-GCM)
- **localStorage** : Métadonnées articles, invoices
- **Nostr** : Contenus publics et événements de tracking
## Système de commissions
### Configuration (`lib/platformCommissions.ts`)
- **Articles** : 800 sats (700 auteur, 100 plateforme)
- **Avis** : 70 sats (49 reviewer, 21 plateforme)
- **Sponsoring** : 0.046 BTC (0.042 auteur, 0.004 plateforme)
### Implémentation
**Articles** (`lib/paymentPolling.ts`, `lib/articleInvoice.ts`) :
- Validation montant 800 sats à chaque étape
- Tracking avec `author_amount` et `platform_commission` dans événements Nostr
- Récupération adresse Lightning auteur via `lightningAddressService`
- Transfert automatique déclenché (logs, nécessite nœud Lightning pour exécution)
**Sponsoring** (`lib/sponsoringPayment.ts`, `lib/sponsoringTracking.ts`) :
- Validation montant 0.046 BTC
- Vérification transactions Bitcoin via `mempoolSpaceService` (sorties auteur + plateforme)
- Tracking sur Nostr (kind 30079) avec confirmations
**Avis** (`lib/reviewReward.ts`) :
- Validation montant 70 sats
- Mise à jour événement Nostr avec tags `rewarded: true` et `reward_amount: 70`
- Récupération adresse Lightning reviewer
- Transfert automatique déclenché (logs, nécessite nœud Lightning)
### Tracking
Tous les paiements sont trackés sur Nostr :
- **Kind 30078** : Livraisons de contenu (`lib/platformTracking.ts`)
- **Kind 30079** : Paiements de sponsoring (`lib/sponsoringTracking.ts`)
Les événements incluent `author_amount`, `platform_commission`, `zap_receipt` (si applicable), et sont signés par l'auteur avec tag `p` pour la plateforme.
## Système de tags Nostr
### Nouveau système de tags (tous en anglais)
Tous les contenus sont des notes Nostr (kind 1) avec un système de tags unifié :
- **Type** : `#author`, `#series`, `#publication`, `#quote` (tags simples sans valeur)
- **Catégorie** : `#sciencefiction` ou `#research` (tags simples sans valeur)
- **ID** : `#id_<id>` (tag avec valeur : `['id', '<id>']`)
- **Paywall** : `#paywall` (tag simple, pour les publications payantes)
- **Payment** : `#payment` (tag simple optionnel, pour les notes de paiement)
### Utilitaires (`lib/nostrTagSystem.ts`)
- `buildTags()` : Construit les tags à partir d'un objet typé
- `extractTagsFromEvent()` : Extrait les tags d'un événement
- `buildTagFilter()` : Construit les filtres Nostr pour les requêtes
## Internationalisation (i18n)
### Système de traduction (`lib/i18n.ts`)
- Chargement depuis fichiers texte plats (`public/locales/fr.txt`, `public/locales/en.txt`)
- Format : `key=value` avec support des paramètres `{{param}}`
- Hook `useI18n` pour utiliser les traductions dans les composants
- Initialisé dans `_app.tsx` avec locale par défaut (fr)
### Langues supportées
- Français (fr) : langue par défaut
- Anglais (en) : disponible
- Extensible : ajout de nouvelles langues via fichiers de traduction
## Financement IA
### Jauge de financement (`components/FundingGauge.tsx`)
- Affiche le montant collecté, la cible (0.27 BTC) et le pourcentage
- Calcul des fonds via `lib/fundingCalculation.ts`
- Agrégation de toutes les commissions (articles, avis, sponsoring)
- Description de l'usage des fonds pour le développement IA
### Calcul des fonds (`lib/fundingCalculation.ts`)
- Estimation basée sur les taux de commission
- Agrégation des zap receipts par type (purchase, review_tip, sponsoring)
- Calcul du pourcentage de progression vers la cible
## Flux de paiement article
1. Lecteur clique "Unlock for 800 sats"
2. Création invoice Lightning via Alby/WebLN (`lib/articleInvoice.ts`)
3. Publication zap request sur Nostr (NIP-57)
4. Lecteur paie l'invoice
5. Polling vérification zap receipt (`lib/paymentPolling.ts`)
6. Envoi automatique contenu privé (message chiffré NIP-04, `lib/articlePublisher.ts`)
7. Tracking livraison avec commissions (`lib/platformTracking.ts`)
## Flux de sponsoring
1. Utilisateur demande sponsoring 0.046 BTC (`lib/sponsoringPayment.ts`)
2. Service calcule split (0.042/0.004 BTC)
3. Retourne deux adresses Bitcoin (auteur + plateforme)
4. Utilisateur crée transaction avec deux sorties
5. Vérification via mempool.space (`lib/mempoolSpace.ts`)
6. Tracking sur Nostr (`lib/sponsoringTracking.ts`)
## Vérification livraison contenu
Le système vérifie l'envoi du contenu privé à trois niveaux :
1. **Publication** : `publishEvent()` retourne l'ID de l'événement publié
2. **Vérification relay** : `verifyPrivateMessagePublished()` confirme présence sur relay (timeout 5s)
3. **Tracking** : Événement Nostr (kind 30078) avec `verified: true/false`
Tous les événements sont loggés avec IDs, timestamps et statuts.
## Limitations
**Transferts Lightning automatiques** : Nécessitent un nœud Lightning de la plateforme. Actuellement, les transferts sont loggés dans `lib/automaticTransfer.ts` et peuvent être exécutés manuellement. Les adresses Lightning sont récupérées automatiquement depuis les profils Nostr.

319
docs/fr/user-guide.md Normal file
View File

@ -0,0 +1,319 @@
# Guide d'utilisation - zapwall.fr
Bienvenue sur zapwall.fr ! Cette plateforme vous permet de lire des publications scientifiques et de science-fiction avec des aperçus gratuits et de débloquer le contenu complet en payant avec Lightning Network.
## Table des matières
1. [Introduction](#introduction)
2. [Premiers pas](#premiers-pas)
3. [Connexion avec Nostr](#connexion-avec-nostr)
4. [Lire des publications](#lire-des-publications)
5. [Payer pour débloquer une publication](#payer-pour-débloquer-une-publication)
6. [Rechercher et filtrer des publications](#rechercher-et-filtrer-des-publications)
7. [Voir votre profil et votre page auteur](#voir-votre-profil-et-votre-page-auteur)
8. [Séries](#séries)
9. [Avis](#avis)
10. [Dépannage](#dépannage)
---
## Introduction
zapwall.fr est une plateforme de publication de contenus scientifiques et de science-fiction basée sur le protocole Nostr. Les auteurs peuvent publier des publications avec :
- **Aperçu gratuit** : Visible par tous
- **Contenu complet** : Débloqué après un paiement Lightning de 800 sats (moins 100 sats de commission et frais de transaction)
### Fonctionnalités principales
- ✅ Lecture gratuite des aperçus de publications
- ✅ Déblocage du contenu complet via paiement Lightning
- ✅ Recherche et filtrage de publications
- ✅ Page auteur avec présentation et sponsoring
- ✅ Séries pour organiser vos publications
- ✅ Avis rémunérables sur les publications
- ✅ Sponsoring d'auteurs (0.046 BTC)
---
## Premiers pas
### 1. Installer Alby (recommandé)
Pour effectuer des paiements Lightning, vous devez installer une extension de portefeuille Lightning compatible avec WebLN :
1. Visitez [getalby.com](https://getalby.com/)
2. Installez l'extension Alby pour votre navigateur
3. Créez un compte ou connectez votre portefeuille Lightning existant
4. Ajoutez des fonds à votre portefeuille Alby
> **Note** : D'autres portefeuilles Lightning compatibles WebLN fonctionnent également.
### 2. Accéder à la plateforme
1. Ouvrez [zapwall.fr](https://zapwall.fr) dans votre navigateur
2. Vous verrez la liste des publications disponibles
3. Cliquez sur "Connect with Nostr" pour vous connecter
---
## Connexion avec Nostr
### Comment se connecter
1. Cliquez sur le bouton **"Connect with Nostr"** en haut à droite
2. Une fenêtre s'ouvrira pour vous connecter avec votre portefeuille Nostr
3. L'application utilise l'extension Alby pour l'authentification Nostr (NIP-07) et les paiements Lightning (WebLN)
4. Autorisez la connexion dans votre portefeuille Nostr
### Que se passe-t-il après la connexion ?
- ✅ Votre profil Nostr s'affiche (nom, avatar, etc.)
- ✅ Vous pouvez créer votre page auteur (obligatoire pour publier)
- ✅ Vous pouvez publier des publications
- ✅ Vous pouvez payer pour débloquer des publications
- ✅ Vous pouvez accéder à votre profil avec vos publications
### Déconnexion
Cliquez sur le bouton **"Disconnect"** pour vous déconnecter.
---
## Lire des publications
### Aperçu gratuit
Toutes les publications affichent automatiquement :
- **Titre** de la publication
- **Aperçu** (preview) - contenu gratuit
- **Auteur** (avec lien vers sa page auteur)
- **Montant** en sats (800 sats)
- **Date de publication**
- **Catégorie** (Science-fiction ou Recherche scientifique)
### Contenu complet
Pour lire le contenu complet d'une publication :
1. Cliquez sur le bouton **"Débloquer"**
2. Suivez les instructions pour effectuer un zap Lightning de 800 sats
3. Une fois le zap confirmé, le contenu complet s'affichera automatiquement
> **Note** : Le contenu débloqué est stocké localement dans votre navigateur et reste accessible même après déconnexion.
---
## Payer pour débloquer une publication
### Processus de zap
1. **Cliquez sur "Débloquer"** sur la publication que vous souhaitez débloquer
2. **Une fenêtre s'ouvre** avec :
- Le montant du zap (800 sats)
- Un QR code Lightning
- L'invoice Lightning
- Un bouton "Pay with Alby"
3. **Choisissez votre méthode de zap** :
- **Option 1** : Cliquez sur "Pay with Alby" (recommandé)
- Votre extension Alby s'ouvrira automatiquement
- Confirmez le zap dans Alby
- **Option 2** : Scannez le QR code avec votre portefeuille Lightning mobile
- **Option 3** : Copiez l'invoice et effectuez le zap depuis votre portefeuille
4. **Attendez la confirmation** :
- Le zap est vérifié automatiquement via les reçus de zap Nostr (NIP-57)
- Le contenu complet s'affichera automatiquement une fois confirmé
- Cela peut prendre quelques secondes
> **Note** : Seuls les zaps sont autorisés pour débloquer les publications. Les paiements Lightning standard ne fonctionnent pas.
### Expiration des invoices
Les invoices Lightning expirent après 24 heures. Si une invoice expire :
- Fermez la fenêtre de paiement
- Cliquez à nouveau sur "Débloquer" pour générer une nouvelle invoice
### Problèmes de paiement
Si le paiement échoue :
- Vérifiez que vous avez suffisamment de fonds dans votre portefeuille
- Vérifiez que l'invoice n'a pas expiré
- Réessayez en cliquant à nouveau sur "Débloquer"
- Consultez la [section Dépannage](#dépannage)
---
## Rechercher et filtrer des publications
### Barre de recherche
Utilisez la barre de recherche en haut de la page pour rechercher des publications par :
- **Titre**
- **Aperçu** (preview)
- **Contenu** (même le contenu débloqué est recherchable)
### Filtres
Les filtres vous permettent de :
- **Filtrer par catégorie** : Science-fiction ou Recherche scientifique
- **Filtrer par auteur** : Sélectionnez un auteur spécifique
- **Trier les publications** :
- Plus récentes d'abord (par défaut)
- Plus anciennes d'abord
### Utilisation des filtres
1. Utilisez les onglets de catégorie pour filtrer par type de contenu
2. Utilisez le menu déroulant pour sélectionner un auteur
3. Les résultats se mettent à jour automatiquement
4. Cliquez sur "Effacer tout" pour réinitialiser tous les filtres
---
## Voir votre profil et votre page auteur
### Page auteur (obligatoire)
Avant de pouvoir publier, vous devez créer votre **page auteur** :
1. Connectez-vous avec Nostr
2. Cliquez sur **"Créer page auteur"** dans le menu
3. Remplissez votre présentation avec :
- Votre description
- Votre adresse Bitcoin mainnet pour le sponsoring (optionnel)
4. Une fois créée, votre page auteur est accessible à tous via `/author/[votre-pubkey]`
### Accéder à votre profil
1. Connectez-vous avec Nostr
2. Cliquez sur votre **nom ou avatar** en haut à droite
3. Vous serez redirigé vers la page `/profile`
### Informations affichées
Votre profil affiche :
- **Photo de profil** (si disponible)
- **Nom** (si défini dans votre profil Nostr)
- **Clé publique** (pubkey)
- **NIP-05** (si vérifié)
- **Description** (about)
### Vos publications
La section "My Articles" affiche :
- Toutes vos publications publiées
- Recherche et filtres sur vos publications
- Statut de déblocage pour chaque publication
---
## Séries
### Qu'est-ce qu'une série ?
Une série est un regroupement de publications organisées par un auteur. Les séries permettent de :
- Organiser vos publications par thème
- Créer une continuité narrative
- Faciliter la découverte de vos contenus
### Créer une série
1. Connectez-vous avec Nostr
2. Lors de la publication, vous pouvez créer ou sélectionner une série
3. Remplissez les informations de la série :
- Titre
- Description
- Image de couverture (optionnel)
4. Les publications de la série seront regroupées sur la page de la série
### Voir une série
1. Cliquez sur une série depuis la page d'un auteur
2. Vous verrez toutes les publications de la série
3. Chaque publication peut être débloquée individuellement
---
## Avis
### Qu'est-ce qu'un avis ?
Un avis est un commentaire ou une évaluation d'une publication par un lecteur qui a acheté la publication. Les avis sont :
- **Rémunérables** : L'auteur peut remercier un avis avec 70 sats (moins 21 sats de commission)
- **Publics** : Visibles par tous sur la plateforme
- **Liés à une publication** : Chaque avis est associé à une publication spécifique
### Poster un avis
1. Débloquez une publication (800 sats)
2. Accédez à la page de la publication
3. Rédigez votre avis
4. Publiez votre avis
### Remercier un avis
En tant qu'auteur, vous pouvez remercier un lecteur pour son avis :
1. Accédez à la publication concernée
2. Trouvez l'avis que vous souhaitez remercier
3. Cliquez sur "Remercier" (70 sats)
4. Confirmez le zap dans Alby
> **Note** : Le remerciement d'un avis coûte 70 sats (49 sats au reviewer, 21 sats de commission).
---
## Dépannage
### Problèmes de connexion
**Je ne peux pas me connecter avec Nostr**
- Vérifiez que votre portefeuille Nostr est accessible
- Vérifiez que l'extension Alby est installée et activée
- Essayez de rafraîchir la page
- Vérifiez votre connexion internet
### Problèmes de paiement
**Le paiement ne fonctionne pas**
- Vérifiez que Alby (ou votre portefeuille Lightning) est installé et activé
- Vérifiez que vous avez suffisamment de fonds
- Vérifiez que l'invoice n'a pas expiré
- Essayez de rafraîchir la page et réessayez
**Le contenu ne se débloque pas après le paiement**
- Attendez quelques secondes (la vérification peut prendre du temps)
- Vérifiez que le paiement a bien été effectué dans votre portefeuille
- Rafraîchissez la page
- Contactez l'auteur de la publication si le problème persiste
### Problèmes d'affichage
**Les publications ne s'affichent pas**
- Vérifiez votre connexion internet
- Vérifiez que le relay Nostr est accessible
- Essayez de rafraîchir la page
- Vérifiez la console du navigateur pour les erreurs
**Le contenu débloqué a disparu**
- Le contenu est stocké localement dans votre navigateur
- Si vous avez vidé le cache ou les données du navigateur, le contenu peut être perdu
- Vous devrez peut-être payer à nouveau pour débloquer la publication
### Problèmes de publication
**Je ne peux pas publier de publication**
- Vérifiez que vous êtes connecté avec Nostr
- Vérifiez que vous avez créé votre page auteur (obligatoire)
- Vérifiez que votre portefeuille Nostr peut signer des événements
- Vérifiez que Alby est installé (nécessaire pour créer l'invoice)
- Vérifiez que tous les champs sont remplis (titre, aperçu, contenu)
---
## Support
Pour plus d'aide :
- Consultez la [FAQ](./faq.md)
- Consultez le [Guide de publication](./publishing-guide.md)
- Consultez le [Guide de paiement](./payment-guide.md)
---
**Dernière mise à jour** : Décembre 2024

291
docs/fr/wording.md Normal file
View File

@ -0,0 +1,291 @@
# Wording et terminologie - zapwall4Science
**Date** : Décembre 2024
**Auteur** : Équipe 4NK
## 📝 Terminologie officielle
### Acteurs
#### Auteur
**Définition** : Utilisateur qui publie des articles (science-fiction ou recherche scientifique) sur la plateforme.
**Utilisation** :
- "Vous êtes auteur sur zapwall4Science"
- "Les auteurs peuvent publier dans les deux catégories"
- "Profil auteur"
- "Articles de l'auteur"
**À éviter** : "publisher", "writer", "créateur de contenu"
---
#### Lecteur
**Définition** : Utilisateur qui lit les articles et peut poster des avis.
**Utilisation** :
- "En tant que lecteur, vous pouvez..."
- "Les lecteurs peuvent lire les aperçus gratuitement"
- "Seuls les lecteurs qui ont acheté l'article peuvent poster un avis"
**À éviter** : "reader", "utilisateur" (trop générique), "consommateur"
---
#### Site
**Définition** : La plateforme zapwall4Science (recevant les commissions).
**Utilisation** :
- "Le site perçoit une commission de 100 sats"
- "Commission pour le site"
- "Frais du site"
**À éviter** : "plateforme" (dans le contexte des commissions), "système", "application"
---
### Contenus
#### Article
**Définition** : Publication d'un auteur (science-fiction ou recherche scientifique).
**Utilisation** :
- "Publier un article"
- "Lire un article"
- "Article de science-fiction"
- "Article de recherche scientifique"
**À éviter** : "post", "publication", "contenu"
---
#### Article de présentation
**Définition** : Article obligatoire créé par chaque auteur lors de son inscription, contenant sa présentation, description de son contenu et adresse de sponsoring mainnet.
**Utilisation** :
- "Créer votre article de présentation"
- "Article de présentation obligatoire"
- "Votre article de présentation"
**À éviter** : "profil", "bio", "présentation"
---
#### Avis
**Définition** : Commentaire/évaluation d'un article par un lecteur qui a acheté l'article.
**Utilisation** :
- "Poster un avis"
- "Lire les avis"
- "Rémunérer un avis"
- "Avis sur l'article"
**À éviter** : "commentaire", "review", "évaluation", "critique"
---
#### Message d'envoi de l'article
**Définition** : Message privé chiffré (kind:4) envoyé automatiquement après paiement d'un article.
**Utilisation** :
- "Le message d'envoi de l'article a été envoyé"
- "Réception du message d'envoi de l'article"
**À éviter** : "message privé", "contenu chiffré", "DM"
**Note** : Terme technique, utilisé principalement dans les logs et la documentation technique.
---
### Paiements
#### Paiement
**Définition** : Transaction Bitcoin/Lightning pour accéder à un article ou sponsoriser un auteur.
**Utilisation** :
- "Effectuer un paiement"
- "Paiement en cours"
- "Paiement confirmé"
**À éviter** : "transaction" (trop technique), "zap" (trop spécifique à Nostr)
---
#### Sponsoring
**Définition** : Paiement unique de 0.05 BTC pour sponsoriser un auteur (0.004 BTC au site, 0.046 BTC à l'auteur).
**Utilisation** :
- "Sponsoriser un auteur"
- "Montant du sponsoring"
- "Total sponsorisé"
- "Auteur le plus sponsorisé"
**À éviter** : "donation", "tip", "contribution"
---
#### Commission pour le site sur la vente d'un article
**Définition** : 100 sats sur chaque achat d'article (800 sats total, 700 à l'auteur).
**Utilisation** :
- "Commission du site : 100 sats"
- "Commission pour le site sur la vente"
- "100 sats de commission"
**À éviter** : "frais", "taxe", "redevance"
**Note** : Les frais de transaction sont payés par l'auteur, pas par le site.
---
#### Commission pour le site sur le remerciement d'un avis
**Définition** : 21 sats sur chaque rémunération d'avis (70 sats au lecteur, 21 sats au site).
**Utilisation** :
- "Commission du site : 21 sats"
- "Commission pour le site sur le remerciement"
- "21 sats de commission"
**À éviter** : "frais", "taxe", "redevance"
**Note** : Les frais de transaction sont payés par l'auteur, pas par le site.
---
#### Remerciement pour l'avis
**Définition** : Paiement de 70 sats (21 sats commission site) par l'auteur à un lecteur pour son avis.
**Utilisation** :
- "Remercier un avis"
- "Rémunérer un avis"
- "Remerciement de 70 sats"
- "Avis rémunéré"
**À éviter** : "tip", "donation", "récompense"
---
## 🎯 Règles d'utilisation
### Cohérence
- Utiliser toujours les mêmes termes pour désigner les mêmes concepts
- Éviter les synonymes dans l'interface utilisateur
- Préférer les termes français (sauf pour les termes techniques)
### Clarté
- Utiliser des termes simples et compréhensibles
- Éviter le jargon technique dans l'interface utilisateur
- Expliquer les termes complexes la première fois qu'ils apparaissent
### Contexte
- Adapter le wording selon le contexte (interface vs documentation)
- Utiliser "vous" pour s'adresser à l'utilisateur
- Utiliser "nous" pour parler du site
---
## 📋 Exemples d'utilisation
### Messages d'interface
#### Connexion
- ✅ "Connectez-vous avec Nostr pour publier des articles"
- ❌ "Connect with Nostr to publish articles"
#### Publication
- ✅ "Publier un article"
- ❌ "Créer un article" (trop générique)
#### Paiement 2
- ✅ "Débloquer l'article pour 800 sats"
- ❌ "Acheter l'article pour 800 sats" (on n'achète pas, on débloque)
#### Sponsoring 2
- ✅ "Sponsoriser cet auteur (0.05 BTC)"
- ❌ "Faire un don à cet auteur"
#### Avis 2
- ✅ "Poster un avis"
- ❌ "Laisser un commentaire"
#### Remerciement
- ✅ "Remercier cet avis (70 sats)"
- ❌ "Récompenser cet avis"
---
## 🔄 Traductions
### Anglais (si nécessaire)
- **Auteur** → Author
- **Lecteur** → Reader
- **Site** → Platform / Site
- **Article** → Article
- **Article de présentation** → Presentation Article
- **Avis** → Review
- **Paiement** → Payment
- **Sponsoring** → Sponsorship
- **Commission** → Commission
- **Remerciement** → Reward / Thank you
---
## 📝 Notes pour les développeurs
### Dans le code
- Utiliser les termes français dans les commentaires
- Utiliser les termes anglais pour les noms de variables/fonctions (convention)
- Documenter les termes techniques
### Dans les messages utilisateur
- Toujours utiliser les termes français
- Utiliser le wording officiel
- Vérifier la cohérence avec ce document
---
## ✅ Checklist de validation
Avant de publier un message à l'utilisateur, vérifier :
- [ ] Utilisation du wording officiel
- [ ] Cohérence avec ce document
- [ ] Clarté du message
- [ ] Absence de jargon technique inutile
- [ ] Utilisation du "vous" pour s'adresser à l'utilisateur

View File

@ -1,4 +1,5 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { getLocale } from '@/lib/i18n'
export type DocSection = 'user-guide' | 'faq' | 'publishing' | 'payment' | 'fees-and-contributions' export type DocSection = 'user-guide' | 'faq' | 'publishing' | 'payment' | 'fees-and-contributions'
@ -26,7 +27,9 @@ export function useDocs(docs: DocLink[]): {
setSelectedDoc(docId) setSelectedDoc(docId)
try { try {
const response = await fetch(`/api/docs/${doc.file}`) // Get current locale and pass it to the API
const locale = getLocale()
const response = await fetch(`/api/docs/${doc.file}?locale=${locale}`)
if (response.ok) { if (response.ok) {
const text = await response.text() const text = await response.text()
setDocContent(text) setDocContent(text)

View File

@ -4,19 +4,24 @@ import type { Notification } from '@/types/notifications'
export function useNotificationCenter( export function useNotificationCenter(
markAsRead: (id: string) => void, markAsRead: (id: string) => void,
onClose?: () => void onClose?: () => void
) { ): {
isOpen: boolean
handleToggle: () => void
handleClose: () => void
handleNotificationClick: (notification: Notification) => void
} {
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const handleToggle = () => { const handleToggle = (): void => {
setIsOpen((prev) => !prev) setIsOpen((prev) => !prev)
} }
const handleClose = () => { const handleClose = (): void => {
setIsOpen(false) setIsOpen(false)
onClose?.() onClose?.()
} }
const handleNotificationClick = (notification: Notification) => { const handleNotificationClick = (notification: Notification): void => {
if (!notification.read) { if (!notification.read) {
markAsRead(notification.id) markAsRead(notification.id)
} }
@ -30,4 +35,3 @@ export function useNotificationCenter(
handleNotificationClick, handleNotificationClick,
} }
} }

View File

@ -124,6 +124,7 @@ async function buildPreviewTags(
id: hashId, id: hashId,
version: 0, version: 0,
index: 0, index: 0,
...(draft.pages && draft.pages.length > 0 ? { pages: draft.pages } : {}),
}) })
// Add JSON metadata as a tag // Add JSON metadata as a tag

View File

@ -145,6 +145,7 @@ export async function publishReview(params: {
reviewerPubkey: string reviewerPubkey: string
content: string content: string
title?: string title?: string
text?: string
authorPrivateKey?: string authorPrivateKey?: string
}): Promise<Review> { }): Promise<Review> {
ensureKeys(params.reviewerPubkey, params.authorPrivateKey) ensureKeys(params.reviewerPubkey, params.authorPrivateKey)
@ -163,6 +164,7 @@ export async function publishReview(params: {
content: params.content, content: params.content,
createdAt: published.created_at, createdAt: published.created_at,
...(params.title ? { title: params.title } : {}), ...(params.title ? { title: params.title } : {}),
...(params.text ? { text: params.text } : {}),
kindType: 'review', kindType: 'review',
} }
} }
@ -175,6 +177,7 @@ async function buildReviewEvent(
reviewerPubkey: string reviewerPubkey: string
content: string content: string
title?: string title?: string
text?: string
}, },
category: NonNullable<ArticleDraft['category']> category: NonNullable<ArticleDraft['category']>
) { ) {
@ -218,6 +221,11 @@ async function buildReviewEvent(
...(params.title ? { title: params.title } : {}), ...(params.title ? { title: params.title } : {}),
}) })
// Add text tag if provided
if (params.text) {
tags.push(['text', params.text])
}
// Add JSON metadata as a tag // Add JSON metadata as a tag
tags.push(['json', reviewJson]) tags.push(['json', reviewJson])

View File

@ -6,7 +6,7 @@ import { buildTags, extractTagsFromEvent, buildTagFilter } from './nostrTagSyste
import { getPrimaryRelaySync } from './config' import { getPrimaryRelaySync } from './config'
import { PLATFORM_SERVICE, MIN_EVENT_DATE } from './platformConfig' import { PLATFORM_SERVICE, MIN_EVENT_DATE } from './platformConfig'
import { generateAuthorHashId } from './hashIdGenerator' import { generateAuthorHashId } from './hashIdGenerator'
import { generateObjectUrl } from './urlGenerator' import { generateObjectUrl, buildObjectId, parseObjectId } from './urlGenerator'
import { getLatestVersion } from './versionManager' import { getLatestVersion } from './versionManager'
import { objectCache } from './objectCache' import { objectCache } from './objectCache'
@ -36,17 +36,22 @@ export async function buildPresentationEvent(
category, category,
}) })
// Build URL: https://zapwall.fr/author/<hash>_<index>_<version> // Build URL: https://zapwall.fr/author/<hash>_<index>_<version> (using hash ID)
const profileUrl = generateObjectUrl('author', hashId, index, version) const profileUrl = generateObjectUrl('author', hashId, index, version)
// Encode pubkey to npub (for metadata JSON) // Encode pubkey to npub (for metadata JSON)
const npub = nip19.npubEncode(authorPubkey) const npub = nip19.npubEncode(authorPubkey)
// Build visible content message (without metadata JSON) // Build visible content message
// If picture exists, use it as preview image for the link (markdown format)
// Note: The image will display at full size in most Nostr clients, not as a thumbnail
const linkWithPreview = draft.pictureUrl
? `[![${authorName}](${draft.pictureUrl})](${profileUrl})`
: profileUrl
const visibleContent = [ const visibleContent = [
'Nouveau profil publié sur zapwall.fr', 'Nouveau profil publié sur zapwall.fr',
profileUrl, linkWithPreview,
...(draft.pictureUrl ? [draft.pictureUrl] : []),
`Présentation personnelle : ${presentation}`, `Présentation personnelle : ${presentation}`,
`Description de votre contenu : ${contentDescription}`, `Description de votre contenu : ${contentDescription}`,
`Adresse Bitcoin mainnet (pour le sponsoring) : ${draft.mainnetAddress}`, `Adresse Bitcoin mainnet (pour le sponsoring) : ${draft.mainnetAddress}`,
@ -94,7 +99,7 @@ export async function buildPresentationEvent(
} }
} }
export function parsePresentationEvent(event: Event): import('@/types/nostr').AuthorPresentationArticle | null { export async function parsePresentationEvent(event: Event): Promise<import('@/types/nostr').AuthorPresentationArticle | null> {
const tags = extractTagsFromEvent(event) const tags = extractTagsFromEvent(event)
// Check if it's an author type (tag is 'author' in English) // Check if it's an author type (tag is 'author' in English)
@ -109,6 +114,7 @@ export function parsePresentationEvent(event: Event): import('@/types/nostr').Au
contentDescription?: string contentDescription?: string
mainnetAddress?: string mainnetAddress?: string
pictureUrl?: string pictureUrl?: string
category?: string
} | null = null } | null = null
if (tags.json) { if (tags.json) {
@ -149,12 +155,48 @@ export function parsePresentationEvent(event: Event): import('@/types/nostr').Au
// Map tag category to article category // Map tag category to article category
const articleCategory = tags.category === 'sciencefiction' ? 'science-fiction' : tags.category === 'research' ? 'scientific-research' : undefined const articleCategory = tags.category === 'sciencefiction' ? 'science-fiction' : tags.category === 'research' ? 'scientific-research' : undefined
// Extract hash, version, index from id tag or parse it
let hash: string
let version = tags.version ?? 0
let index = 0
if (tags.id) {
const parsed = parseObjectId(tags.id)
if (parsed.hash) {
hash = parsed.hash
version = parsed.version ?? version
index = parsed.index ?? index
} else {
// If id is just a hash, use it directly
hash = tags.id
}
} else {
// Generate hash from author data
hash = await generateAuthorHashId({
pubkey: event.pubkey,
authorName: profileData?.authorName ?? '',
presentation: profileData?.presentation ?? '',
contentDescription: profileData?.contentDescription ?? '',
mainnetAddress: profileData?.mainnetAddress ?? tags.mainnetAddress ?? undefined,
pictureUrl: profileData?.pictureUrl ?? tags.pictureUrl ?? undefined,
category: profileData?.category ?? tags.category ?? 'sciencefiction',
})
}
const id = buildObjectId(hash, index, version)
const result: import('@/types/nostr').AuthorPresentationArticle = { const result: import('@/types/nostr').AuthorPresentationArticle = {
id: tags.id ?? event.id, id,
hash,
version,
index,
pubkey: event.pubkey, pubkey: event.pubkey,
title: tags.title ?? 'Présentation', title: tags.title ?? 'Présentation',
preview: tags.preview ?? event.content.substring(0, 200), preview: tags.preview ?? event.content.substring(0, 200),
content: event.content, content: event.content,
description: profileData?.presentation ?? tags.description ?? '', // Required field
contentDescription: profileData?.contentDescription ?? tags.description ?? '', // Required field
thumbnailUrl: profileData?.pictureUrl ?? tags.pictureUrl ?? '', // Required field
createdAt: event.created_at, createdAt: event.created_at,
zapAmount: 0, zapAmount: 0,
paid: true, paid: true,
@ -217,8 +259,8 @@ export async function fetchAuthorPresentationFromPool(
const event = events.find(e => e.id === value.id) || events[0] const event = events.find(e => e.id === value.id) || events[0]
if (event) { if (event) {
const tags = extractTagsFromEvent(event) const tags = extractTagsFromEvent(event)
if (tags.id) { if (value.hash) {
await objectCache.set('author', tags.id, event, value, tags.version, tags.hidden) await objectCache.set('author', value.hash, event, value, tags.version ?? 0, tags.hidden, value.index)
} }
} }
} }
@ -238,7 +280,7 @@ export async function fetchAuthorPresentationFromPool(
// Get the latest version from all collected events // Get the latest version from all collected events
const latestEvent = getLatestVersion(events) const latestEvent = getLatestVersion(events)
if (latestEvent) { if (latestEvent) {
const parsed = parsePresentationEvent(latestEvent) const parsed = await parsePresentationEvent(latestEvent)
if (parsed) { if (parsed) {
await finalize(parsed) await finalize(parsed)
return return
@ -250,7 +292,7 @@ export async function fetchAuthorPresentationFromPool(
// Get the latest version from all collected events // Get the latest version from all collected events
const latestEvent = getLatestVersion(events) const latestEvent = getLatestVersion(events)
if (latestEvent) { if (latestEvent) {
const parsed = parsePresentationEvent(latestEvent) const parsed = await parsePresentationEvent(latestEvent)
if (parsed) { if (parsed) {
await finalize(parsed) await finalize(parsed)
return return

View File

@ -1,5 +1,5 @@
import type { AlbyInvoice } from '@/types/alby' import type { AlbyInvoice } from '@/types/alby'
import type { MediaRef } from '@/types/nostr' import type { MediaRef, Page } from '@/types/nostr'
export interface ArticleDraft { export interface ArticleDraft {
title: string title: string
@ -10,6 +10,7 @@ export interface ArticleDraft {
seriesId?: string seriesId?: string
bannerUrl?: string bannerUrl?: string
media?: MediaRef[] media?: MediaRef[]
pages?: Page[] // A5 pages (for series publications)
} }
export interface AuthorPresentationDraft { export interface AuthorPresentationDraft {

Some files were not shown because too many files have changed in this diff Show More