lint fix wip

This commit is contained in:
Nicolas Cantu 2026-01-08 23:04:56 +01:00
parent 5694dbdb8a
commit 20a46ce2bc
40 changed files with 1037 additions and 584 deletions

View File

@ -12,7 +12,6 @@ interface ArticleEditorProps {
defaultSeriesId?: string defaultSeriesId?: string
} }
function SuccessMessage(): React.ReactElement { 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">
@ -34,7 +33,13 @@ export function ArticleEditor({ onPublishSuccess, onCancel, seriesOptions, onSel
...(defaultSeriesId ? { seriesId: defaultSeriesId } : {}), ...(defaultSeriesId ? { seriesId: defaultSeriesId } : {}),
}) })
const submit = buildSubmitHandler(publishArticle, draft, onPublishSuccess, connect, connected) const submit = buildSubmitHandler({
publishArticle,
draft,
onPublishSuccess,
connect,
connected,
})
if (success) { if (success) {
return <SuccessMessage /> return <SuccessMessage />
@ -58,21 +63,25 @@ export function ArticleEditor({ onPublishSuccess, onCancel, seriesOptions, onSel
) )
} }
function buildSubmitHandler( interface SubmitHandlerParams {
publishArticle: (draft: ArticleDraft) => Promise<string | null>, publishArticle: (draft: ArticleDraft) => Promise<string | null>
draft: ArticleDraft, draft: ArticleDraft
onPublishSuccess?: (articleId: string) => void, onPublishSuccess?: (articleId: string) => void
connect?: () => Promise<void>, connect?: () => Promise<void>
connected?: boolean connected?: boolean
}
function buildSubmitHandler(
params: SubmitHandlerParams
): () => Promise<void> { ): () => Promise<void> {
return async (): Promise<void> => { return async (): Promise<void> => {
if (!connected && connect) { if (!params.connected && params.connect) {
await connect() await params.connect()
return return
} }
const articleId = await publishArticle(draft) const articleId = await params.publishArticle(params.draft)
if (articleId) { if (articleId) {
onPublishSuccess?.(articleId) params.onPublishSuccess?.(articleId)
} }
} }
} }

View File

@ -1,15 +1,10 @@
import React from 'react' import React from 'react'
import type { ArticleDraft } from '@/lib/articlePublisher' import type { ArticleDraft } from '@/lib/articlePublisher'
import type { ArticleCategory } from '@/types/nostr'
import { ArticleField } from './ArticleField'
import { ArticleFormButtons } from './ArticleFormButtons' import { ArticleFormButtons } from './ArticleFormButtons'
import { CategorySelect } from './CategorySelect'
import { MarkdownEditor } from './MarkdownEditor'
import { MarkdownEditorTwoColumns } from './MarkdownEditorTwoColumns'
import type { MediaRef } from '@/types/nostr'
import { t } from '@/lib/i18n'
import type { RelayPublishStatus } from '@/lib/publishResult' import type { RelayPublishStatus } from '@/lib/publishResult'
import { t } from '@/lib/i18n'
import { ArticleFieldsLeft } from './ArticleEditorFormFieldsLeft'
import { ArticleFieldsRight } from './ArticleEditorFormFieldsRight'
interface ArticleEditorFormProps { interface ArticleEditorFormProps {
draft: ArticleDraft draft: ArticleDraft
@ -23,25 +18,6 @@ interface ArticleEditorFormProps {
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
} }
function CategoryField({
value,
onChange,
}: {
value: ArticleDraft['category']
onChange: (value: import('@/types/nostr').ArticleCategory | undefined) => void
}): React.ReactElement {
return (
<CategorySelect
id="category"
label={t('article.editor.category')}
{...(value ? { value } : {})}
onChange={onChange}
required
helpText={t('article.editor.category.help')}
/>
)
}
function ErrorAlert({ error }: { error: string | null }): React.ReactElement | null { function ErrorAlert({ error }: { error: string | null }): React.ReactElement | null {
if (!error) { if (!error) {
return null return null
@ -53,199 +29,6 @@ function ErrorAlert({ error }: { error: string | null }): React.ReactElement | n
) )
} }
function buildCategoryChangeHandler(
draft: ArticleDraft,
onDraftChange: (draft: ArticleDraft) => void
): (value: ArticleCategory | undefined) => void {
return (value) => {
if (value === 'science-fiction' || value === 'scientific-research' || value === undefined) {
const nextDraft: ArticleDraft = { ...draft }
if (value) {
nextDraft.category = value
} else {
delete (nextDraft as { category?: ArticleDraft['category'] }).category
}
onDraftChange(nextDraft)
}
}
}
const ArticleFieldsLeft = ({
draft,
onDraftChange,
seriesOptions,
onSelectSeries,
}: {
draft: ArticleDraft
onDraftChange: (draft: ArticleDraft) => void
seriesOptions?: { id: string; title: string }[] | undefined
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
}): React.ReactElement => (
<div className="space-y-4">
<CategoryField
value={draft.category}
onChange={buildCategoryChangeHandler(draft, onDraftChange)}
/>
{seriesOptions && (
<SeriesSelect
draft={draft}
onDraftChange={onDraftChange}
seriesOptions={seriesOptions}
onSelectSeries={onSelectSeries}
/>
)}
<ArticleTitleField draft={draft} onDraftChange={onDraftChange} />
<ArticlePreviewField draft={draft} onDraftChange={onDraftChange} />
</div>
)
function ArticleTitleField({ draft, onDraftChange }: { draft: ArticleDraft; onDraftChange: (draft: ArticleDraft) => void }): React.ReactElement {
return (
<ArticleField
id="title"
label={t('article.title')}
value={draft.title}
onChange={(value) => onDraftChange({ ...draft, title: value as string })}
required
placeholder={t('article.editor.title.placeholder')}
/>
)
}
function ArticlePreviewField({
draft,
onDraftChange,
}: {
draft: ArticleDraft
onDraftChange: (draft: ArticleDraft) => void
}): React.ReactElement {
return (
<ArticleField
id="preview"
label={t('article.editor.preview.label')}
value={draft.preview}
onChange={(value) => onDraftChange({ ...draft, preview: value as string })}
required
type="textarea"
rows={4}
placeholder={t('article.editor.preview.placeholder')}
helpText={t('article.editor.preview.help')}
/>
)
}
function SeriesSelect({
draft,
onDraftChange,
seriesOptions,
onSelectSeries,
}: {
draft: ArticleDraft
onDraftChange: (draft: ArticleDraft) => void
seriesOptions: { id: string; title: string }[]
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
}): React.ReactElement {
const handleChange = buildSeriesChangeHandler(draft, onDraftChange, onSelectSeries)
return (
<div>
<label htmlFor="series" className="block text-sm font-medium text-gray-700">
{t('article.editor.series.label')}
</label>
<select
id="series"
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm"
value={draft.seriesId ?? ''}
onChange={handleChange}
>
<option value="">{t('article.editor.series.none')}</option>
{seriesOptions.map((s) => (
<option key={s.id} value={s.id}>
{s.title}
</option>
))}
</select>
</div>
)
}
function buildSeriesChangeHandler(
draft: ArticleDraft,
onDraftChange: (draft: ArticleDraft) => void,
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
): (e: React.ChangeEvent<HTMLSelectElement>) => void {
return (e: React.ChangeEvent<HTMLSelectElement>): void => {
const value = e.target.value || undefined
const nextDraft = { ...draft }
if (value) {
nextDraft.seriesId = value
} else {
delete (nextDraft as { seriesId?: string }).seriesId
}
onDraftChange(nextDraft)
onSelectSeries?.(value)
}
}
const ArticleFieldsRight = ({
draft,
onDraftChange,
}: {
draft: ArticleDraft
onDraftChange: (draft: ArticleDraft) => void
}): 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-2">
<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 })}
{...(draft.pages ? { 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
value={draft.content}
onChange={(value) => onDraftChange({ ...draft, content: value })}
onMediaAdd={(media: MediaRef) => {
const nextMedia = [...(draft.media ?? []), media]
onDraftChange({ ...draft, media: nextMedia })
}}
onBannerChange={(url: string) => {
onDraftChange({ ...draft, bannerUrl: url })
}}
/>
)}
<p className="text-xs text-gray-500">
{t('article.editor.content.help')}
</p>
</div>
<ArticleField
id="zapAmount"
label={t('article.editor.sponsoring.label')}
value={draft.zapAmount}
onChange={(value) => onDraftChange({ ...draft, zapAmount: value as number })}
required
type="number"
min={1}
helpText={t('article.editor.sponsoring.help')}
/>
</div>
)
}
export function ArticleEditorForm({ export function ArticleEditorForm({
draft, draft,
onDraftChange, onDraftChange,

View File

@ -0,0 +1,180 @@
import React from 'react'
import type { ArticleDraft } from '@/lib/articlePublisher'
import type { ArticleCategory } from '@/types/nostr'
import { ArticleField } from './ArticleField'
import { CategorySelect } from './CategorySelect'
import { t } from '@/lib/i18n'
interface ArticleFieldsLeftProps {
draft: ArticleDraft
onDraftChange: (draft: ArticleDraft) => void
seriesOptions?: { id: string; title: string }[] | undefined
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
}
export function ArticleFieldsLeft({
draft,
onDraftChange,
seriesOptions,
onSelectSeries,
}: ArticleFieldsLeftProps): React.ReactElement {
return (
<div className="space-y-4">
<CategoryField
value={draft.category}
onChange={buildCategoryChangeHandler({ draft, onDraftChange })}
/>
{seriesOptions && (
<SeriesSelect
draft={draft}
onDraftChange={onDraftChange}
seriesOptions={seriesOptions}
onSelectSeries={onSelectSeries}
/>
)}
<ArticleTitleField draft={draft} onDraftChange={onDraftChange} />
<ArticlePreviewField draft={draft} onDraftChange={onDraftChange} />
</div>
)
}
interface CategoryFieldProps {
value: ArticleDraft['category']
onChange: (value: ArticleCategory | undefined) => void
}
function CategoryField({ value, onChange }: CategoryFieldProps): React.ReactElement {
return (
<CategorySelect
id="category"
label={t('article.editor.category')}
{...(value ? { value } : {})}
onChange={onChange}
required
helpText={t('article.editor.category.help')}
/>
)
}
interface BuildCategoryChangeHandlerParams {
draft: ArticleDraft
onDraftChange: (draft: ArticleDraft) => void
}
function buildCategoryChangeHandler(
params: BuildCategoryChangeHandlerParams
): (value: ArticleCategory | undefined) => void {
return (value): void => {
if (value !== 'science-fiction' && value !== 'scientific-research' && value !== undefined) {
return
}
if (value) {
params.onDraftChange({ ...params.draft, category: value })
return
}
const { category: _category, ...rest } = params.draft
params.onDraftChange(rest)
}
}
function ArticleTitleField({
draft,
onDraftChange,
}: {
draft: ArticleDraft
onDraftChange: (draft: ArticleDraft) => void
}): React.ReactElement {
return (
<ArticleField
id="title"
label={t('article.title')}
value={draft.title}
onChange={(value) => onDraftChange({ ...draft, title: value as string })}
required
placeholder={t('article.editor.title.placeholder')}
/>
)
}
function ArticlePreviewField({
draft,
onDraftChange,
}: {
draft: ArticleDraft
onDraftChange: (draft: ArticleDraft) => void
}): React.ReactElement {
return (
<ArticleField
id="preview"
label={t('article.editor.preview.label')}
value={draft.preview}
onChange={(value) => onDraftChange({ ...draft, preview: value as string })}
required
type="textarea"
rows={4}
placeholder={t('article.editor.preview.placeholder')}
helpText={t('article.editor.preview.help')}
/>
)
}
function SeriesSelect({
draft,
onDraftChange,
seriesOptions,
onSelectSeries,
}: {
draft: ArticleDraft
onDraftChange: (draft: ArticleDraft) => void
seriesOptions: { id: string; title: string }[]
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
}): React.ReactElement {
const handleChange = buildSeriesChangeHandler({ draft, onDraftChange, onSelectSeries })
return (
<div>
<label htmlFor="series" className="block text-sm font-medium text-gray-700">
{t('article.editor.series.label')}
</label>
<select
id="series"
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm"
value={draft.seriesId ?? ''}
onChange={handleChange}
>
<option value="">{t('article.editor.series.none')}</option>
{seriesOptions.map((s) => (
<option key={s.id} value={s.id}>
{s.title}
</option>
))}
</select>
</div>
)
}
interface BuildSeriesChangeHandlerParams {
draft: ArticleDraft
onDraftChange: (draft: ArticleDraft) => void
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
}
function buildSeriesChangeHandler(
params: BuildSeriesChangeHandlerParams
): (e: React.ChangeEvent<HTMLSelectElement>) => void {
return (e): void => {
const value = e.target.value === '' ? undefined : e.target.value
if (value) {
params.onDraftChange({ ...params.draft, seriesId: value })
params.onSelectSeries?.(value)
return
}
const { seriesId: _seriesId, ...rest } = params.draft
params.onDraftChange(rest)
params.onSelectSeries?.(undefined)
}
}

View File

@ -0,0 +1,118 @@
import React from 'react'
import type { ArticleDraft } from '@/lib/articlePublisher'
import type { MediaRef } from '@/types/nostr'
import { ArticleField } from './ArticleField'
import { MarkdownEditor } from './MarkdownEditor'
import { MarkdownEditorTwoColumns } from './MarkdownEditorTwoColumns'
import { t } from '@/lib/i18n'
interface ArticleFieldsRightProps {
draft: ArticleDraft
onDraftChange: (draft: ArticleDraft) => void
}
export function ArticleFieldsRight({ draft, onDraftChange }: ArticleFieldsRightProps): React.ReactElement {
return (
<div className="space-y-4">
<ContentEditorSection draft={draft} onDraftChange={onDraftChange} />
<ZapAmountField draft={draft} onDraftChange={onDraftChange} />
</div>
)
}
function ContentEditorSection({
draft,
onDraftChange,
}: {
draft: ArticleDraft
onDraftChange: (draft: ArticleDraft) => void
}): React.ReactElement {
const useTwoColumns = draft.seriesId !== undefined
return (
<div className="space-y-2">
<div className="text-sm font-semibold text-gray-800">{t('article.editor.content.label')}</div>
<ContentEditor draft={draft} onDraftChange={onDraftChange} useTwoColumns={useTwoColumns} />
<p className="text-xs text-gray-500">{t('article.editor.content.help')}</p>
</div>
)
}
function ContentEditor({
draft,
onDraftChange,
useTwoColumns,
}: {
draft: ArticleDraft
onDraftChange: (draft: ArticleDraft) => void
useTwoColumns: boolean
}): React.ReactElement {
const onMediaAdd = buildMediaAddHandler({ draft, onDraftChange })
const onBannerChange = buildBannerChangeHandler({ draft, onDraftChange })
const onContentChange = (value: string): void => onDraftChange({ ...draft, content: value })
if (useTwoColumns) {
return (
<MarkdownEditorTwoColumns
value={draft.content}
onChange={onContentChange}
{...(draft.pages ? { pages: draft.pages } : {})}
onPagesChange={(pages) => onDraftChange({ ...draft, pages })}
onMediaAdd={onMediaAdd}
onBannerChange={onBannerChange}
/>
)
}
return (
<MarkdownEditor
value={draft.content}
onChange={onContentChange}
onMediaAdd={onMediaAdd}
onBannerChange={onBannerChange}
/>
)
}
interface BuildMediaAddHandlerParams {
draft: ArticleDraft
onDraftChange: (draft: ArticleDraft) => void
}
function buildMediaAddHandler(params: BuildMediaAddHandlerParams): (media: MediaRef) => void {
return (media): void => {
const nextMedia = [...(params.draft.media ?? []), media]
params.onDraftChange({ ...params.draft, media: nextMedia })
}
}
interface BuildBannerChangeHandlerParams {
draft: ArticleDraft
onDraftChange: (draft: ArticleDraft) => void
}
function buildBannerChangeHandler(params: BuildBannerChangeHandlerParams): (url: string) => void {
return (url): void => {
params.onDraftChange({ ...params.draft, bannerUrl: url })
}
}
function ZapAmountField({
draft,
onDraftChange,
}: {
draft: ArticleDraft
onDraftChange: (draft: ArticleDraft) => void
}): React.ReactElement {
return (
<ArticleField
id="zapAmount"
label={t('article.editor.sponsoring.label')}
value={draft.zapAmount}
onChange={(value) => onDraftChange({ ...draft, zapAmount: value as number })}
required
type="number"
min={1}
helpText={t('article.editor.sponsoring.help')}
/>
)
}

View File

@ -11,54 +11,32 @@ interface ArticlePagesProps {
export function ArticlePages({ pages, articleId }: ArticlePagesProps): React.ReactElement | null { export function ArticlePages({ pages, articleId }: ArticlePagesProps): React.ReactElement | null {
const { pubkey } = useNostrAuth() const { pubkey } = useNostrAuth()
const [hasPurchased, setHasPurchased] = useState(false) const hasPurchased = useHasPurchasedArticle({ pubkey, articleId })
useEffect(() => {
const checkPurchase = async (): Promise<void> => {
if (!pubkey || !articleId) {
setHasPurchased(false)
return
}
try {
// Check if user has purchased this article from cache
const purchases = await objectCache.getAll('purchase')
const userPurchases = purchases.filter((p) => {
if (typeof p !== 'object' || p === null) {
return false
}
const purchase = p as { payerPubkey?: string; articleId?: string }
return purchase.payerPubkey === pubkey && purchase.articleId === articleId
})
setHasPurchased(userPurchases.length > 0)
} catch (error) {
console.error('Error checking purchase status:', error)
setHasPurchased(false)
}
}
void checkPurchase()
}, [pubkey, articleId])
if (!pages || pages.length === 0) { if (!pages || pages.length === 0) {
return null return null
} }
// If user hasn't purchased, show locked message if (!pubkey || !hasPurchased) {
if (!hasPurchased) { return <LockedPagesView pagesCount={pages.length} />
}
return <PurchasedPagesView pages={pages} />
}
function LockedPagesView({ pagesCount }: { pagesCount: number }): React.ReactElement {
return ( return (
<div className="space-y-6 mt-6"> <div className="space-y-6 mt-6">
<h3 className="text-xl font-semibold text-neon-cyan">{t('article.pages.title')}</h3> <h3 className="text-xl font-semibold text-neon-cyan">{t('article.pages.title')}</h3>
<div className="border border-neon-cyan/30 rounded-lg p-6 bg-cyber-dark text-center"> <div className="border border-neon-cyan/30 rounded-lg p-6 bg-cyber-dark text-center">
<p className="text-cyber-accent mb-2">{t('article.pages.locked.title')}</p> <p className="text-cyber-accent mb-2">{t('article.pages.locked.title')}</p>
<p className="text-sm text-cyber-accent/70">{t('article.pages.locked.message', { count: pages.length })}</p> <p className="text-sm text-cyber-accent/70">{t('article.pages.locked.message', { count: pagesCount })}</p>
</div> </div>
</div> </div>
) )
} }
// User has purchased, show all pages function PurchasedPagesView({ pages }: { pages: Page[] }): React.ReactElement {
return ( return (
<div className="space-y-6 mt-6"> <div className="space-y-6 mt-6">
<h3 className="text-xl font-semibold text-neon-cyan">{t('article.pages.title')}</h3> <h3 className="text-xl font-semibold text-neon-cyan">{t('article.pages.title')}</h3>
@ -71,6 +49,53 @@ export function ArticlePages({ pages, articleId }: ArticlePagesProps): React.Rea
) )
} }
function useHasPurchasedArticle({
pubkey,
articleId,
}: {
pubkey: string | null
articleId: string
}): boolean {
const [hasPurchased, setHasPurchased] = useState(false)
useEffect(() => {
if (!pubkey) {
return
}
const checkPurchase = async (): Promise<void> => {
try {
const purchases = await objectCache.getAll('purchase')
const userPurchases = purchases.filter((p) => isUserPurchase({ p, pubkey, articleId }))
setHasPurchased(userPurchases.length > 0)
} catch (error) {
console.error('Error checking purchase status:', error)
setHasPurchased(false)
}
}
void checkPurchase()
}, [pubkey, articleId])
return hasPurchased
}
function isUserPurchase({
p,
pubkey,
articleId,
}: {
p: unknown
pubkey: string
articleId: string
}): boolean {
if (typeof p !== 'object' || p === null) {
return false
}
const purchase = p as { payerPubkey?: string; articleId?: string }
return purchase.payerPubkey === pubkey && purchase.articleId === articleId
}
function PageDisplay({ page }: { page: Page }): React.ReactElement { function PageDisplay({ page }: { page: Page }): React.ReactElement {
return ( return (
<div className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark"> <div className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark">

View File

@ -12,20 +12,66 @@ interface ArticleReviewsProps {
} }
export function ArticleReviews({ article, authorPubkey }: ArticleReviewsProps): React.ReactElement { export function ArticleReviews({ article, authorPubkey }: ArticleReviewsProps): React.ReactElement {
const data = useArticleReviewsData({ articleId: article.id, authorPubkey })
const reviewForm = useReviewFormState({ reload: data.reload })
const tipSelection = useReviewTipSelection({ article, reviews: data.reviews, reload: data.reload })
return (
<div className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark space-y-4">
<ArticleReviewsHeader tips={data.tips} onAddReview={reviewForm.open} />
{reviewForm.show && (
<ReviewForm
article={article}
onSuccess={reviewForm.onSuccess}
onCancel={reviewForm.close}
/>
)}
{data.loading && <p className="text-sm text-cyber-accent">{t('common.loading')}</p>}
{data.error && <p className="text-sm text-red-400">{data.error}</p>}
{!data.loading && !data.error && data.reviews.length === 0 && !reviewForm.show && (
<p className="text-sm text-cyber-accent/70">{t('review.empty')}</p>
)}
{!data.loading && !data.error && <ArticleReviewsList reviews={data.reviews} onTipReview={tipSelection.select} />}
<SelectedReviewTipForm selection={tipSelection} />
</div>
)
}
interface ReviewTipSelectionController {
article: Article
selectedReviewForTip: Review | null
onTipSuccess: () => void
clear: () => void
select: (reviewId: string) => void
}
interface ArticleReviewsData {
reviews: Review[]
tips: number
loading: boolean
error: string | null
reload: () => Promise<void>
}
function useArticleReviewsData({
articleId,
authorPubkey,
}: {
articleId: string
authorPubkey: string
}): ArticleReviewsData {
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)
const loadReviews = useCallback(async (): Promise<void> => { const reload = useCallback(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(article.id), getReviewsForArticle(articleId),
getReviewTipsForArticle({ authorPubkey, articleId: article.id }), getReviewTipsForArticle({ authorPubkey, articleId }),
]) ])
setReviews(list) setReviews(list)
setTips(tipsTotal) setTips(tipsTotal)
@ -34,58 +80,81 @@ export function ArticleReviews({ article, authorPubkey }: ArticleReviewsProps):
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, [article.id, authorPubkey]) }, [articleId, authorPubkey])
useEffect(() => { useEffect(() => {
void loadReviews() void reload()
}, [loadReviews]) }, [reload])
return ( return {
<div className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark space-y-4"> reviews,
<ArticleReviewsHeader tips={tips} onAddReview={() => { tips,
setShowReviewForm(true) loading,
}} /> error,
{showReviewForm && ( reload,
<ReviewForm }
article={article} }
onSuccess={() => {
setShowReviewForm(false) function useReviewFormState({ reload }: { reload: () => Promise<void> }): {
void loadReviews() show: boolean
}} open: () => void
onCancel={() => { close: () => void
setShowReviewForm(false) onSuccess: () => void
}} } {
/> const [show, setShow] = useState(false)
)} const open = useCallback((): void => setShow(true), [])
{loading && <p className="text-sm text-cyber-accent">{t('common.loading')}</p>} const close = useCallback((): void => setShow(false), [])
{error && <p className="text-sm text-red-400">{error}</p>}
{!loading && !error && reviews.length === 0 && !showReviewForm && ( const onSuccess = useCallback((): void => {
<p className="text-sm text-cyber-accent/70">{t('review.empty')}</p> close()
)} void reload()
{!loading && !error && <ArticleReviewsList reviews={reviews} onTipReview={(reviewId) => { }, [close, reload])
setSelectedReviewForTip(reviewId)
}} />} return { show, open, close, onSuccess }
{selectedReviewForTip && (() => { }
const review = reviews.find((r) => r.id === selectedReviewForTip)
if (!review) { function useReviewTipSelection({
article,
reviews,
reload,
}: {
article: Article
reviews: Review[]
reload: () => Promise<void>
}): ReviewTipSelectionController {
const [selectedReviewId, setSelectedReviewId] = useState<string | null>(null)
const select = useCallback((reviewId: string): void => setSelectedReviewId(reviewId), [])
const clear = useCallback((): void => setSelectedReviewId(null), [])
const onTipSuccess = useCallback((): void => {
clear()
void reload()
}, [clear, reload])
const selectedReviewForTip = selectedReviewId ? findReviewById(reviews, selectedReviewId) : null
return { article, selectedReviewForTip, onTipSuccess, clear, select }
}
function findReviewById(reviews: Review[], reviewId: string): Review | null {
const review = reviews.find((r) => r.id === reviewId)
return review ?? null
}
function SelectedReviewTipForm({ selection }: { selection: ReviewTipSelectionController }): React.ReactElement | null {
if (!selection.selectedReviewForTip) {
return null return null
} }
return ( return (
<ReviewTipForm <ReviewTipForm
review={review} review={selection.selectedReviewForTip}
article={article} article={selection.article}
onSuccess={() => { onSuccess={selection.onTipSuccess}
setSelectedReviewForTip(null) onCancel={selection.clear}
void loadReviews()
}}
onCancel={() => {
setSelectedReviewForTip(null)
}}
/> />
) )
})()}
</div>
)
} }
function ArticleReviewsHeader({ tips, onAddReview }: { tips: number; onAddReview: () => void }): React.ReactElement { function ArticleReviewsHeader({ tips, onAddReview }: { tips: number; onAddReview: () => void }): React.ReactElement {

View File

@ -5,7 +5,7 @@ export function AuthorMnemonicIcons({ value, getMnemonicIcons }: { value: string
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) => (
<span key={`mnemonic-icon-${idx}-${icon}`} className="text-sm" title={`Mnemonic icon ${idx + 1}`}> <span key={`${value}-${icon}`} className="text-sm" title={`Mnemonic icon ${idx + 1}`}>
{icon} {icon}
</span> </span>
))} ))}

View File

@ -47,7 +47,7 @@ export function AuthorOption({
<span className="flex-1 truncate text-cyber-accent">{displayName}</span> <span className="flex-1 truncate text-cyber-accent">{displayName}</span>
<div className="flex items-center gap-1 flex-shrink-0"> <div className="flex items-center gap-1 flex-shrink-0">
{mnemonicIcons.map((icon, idx) => ( {mnemonicIcons.map((icon, idx) => (
<span key={`mnemonic-icon-${idx}-${icon}`} className="text-sm" title={`Mnemonic icon ${idx + 1}`}> <span key={`${displayName}-${icon}`} className="text-sm" title={`Mnemonic icon ${idx + 1}`}>
{icon} {icon}
</span> </span>
))} ))}
@ -83,22 +83,24 @@ export function AllAuthorsOption({
) )
} }
export function createAuthorOptionProps( interface CreateAuthorOptionPropsParams {
pubkey: string, pubkey: string
value: string | null, value: string | 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[]
onChange: (value: string | null) => void, onChange: (value: string | null) => void
setIsOpen: (open: boolean) => void setIsOpen: (open: boolean) => void
): { }
export function createAuthorOptionProps(params: CreateAuthorOptionPropsParams): {
displayName: string displayName: string
mnemonicIcons: string[] mnemonicIcons: string[]
isSelected: boolean isSelected: boolean
onSelect: () => void onSelect: () => void
picture?: string picture?: string
} { } {
const pictureValue = getPicture(pubkey) const pictureValue = params.getPicture(params.pubkey)
const optionProps: { const optionProps: {
displayName: string displayName: string
mnemonicIcons: string[] mnemonicIcons: string[]
@ -106,12 +108,12 @@ export function createAuthorOptionProps(
onSelect: () => void onSelect: () => void
picture?: string picture?: string
} = { } = {
displayName: getDisplayName(pubkey), displayName: params.getDisplayName(params.pubkey),
mnemonicIcons: getMnemonicIcons(pubkey), mnemonicIcons: params.getMnemonicIcons(params.pubkey),
isSelected: value === pubkey, isSelected: params.value === params.pubkey,
onSelect: () => { onSelect: () => {
onChange(pubkey) params.onChange(params.pubkey)
setIsOpen(false) params.setIsOpen(false)
}, },
} }
if (pictureValue !== undefined) { if (pictureValue !== undefined) {
@ -142,7 +144,15 @@ export function AuthorList({
{authors.map((pubkey) => ( {authors.map((pubkey) => (
<AuthorOption <AuthorOption
key={pubkey} key={pubkey}
{...createAuthorOptionProps(pubkey, value, getDisplayName, getPicture, getMnemonicIcons, onChange, setIsOpen)} {...createAuthorOptionProps({
pubkey,
value,
getDisplayName,
getPicture,
getMnemonicIcons,
onChange,
setIsOpen,
})}
/> />
))} ))}
</> </>

View File

@ -1,10 +1,11 @@
import { useState, useEffect } from 'react'
import { useNostrAuth } from '@/hooks/useNostrAuth' import { useNostrAuth } from '@/hooks/useNostrAuth'
import { ConnectedUserMenu } from './ConnectedUserMenu' import { ConnectedUserMenu } from './ConnectedUserMenu'
import { RecoveryStep } from './CreateAccountModalSteps' import { RecoveryStep } from './CreateAccountModalSteps'
import { UnlockAccountModal } from './UnlockAccountModal' import { UnlockAccountModal } from './UnlockAccountModal'
import type { NostrProfile } from '@/types/nostr' import type { NostrProfile } from '@/types/nostr'
import { t } from '@/lib/i18n' import { t } from '@/lib/i18n'
import { getConnectButtonMode } from './connectButton/connectButtonMode'
import { useAutoConnect, useConnectButtonUiState } from './connectButton/useConnectButtonUiState'
function ConnectForm({ function ConnectForm({
onCreateAccount, onCreateAccount,
@ -38,14 +39,6 @@ function ConnectForm({
) )
} }
function useAutoConnect(accountExists: boolean | null, pubkey: string | null, showRecoveryStep: boolean, showUnlockModal: boolean, connect: () => Promise<void>): void {
useEffect(() => {
if (accountExists === true && !pubkey && !showRecoveryStep && !showUnlockModal) {
void connect()
}
}, [accountExists, pubkey, showRecoveryStep, showUnlockModal, connect])
}
function ConnectedState({ pubkey, profile, loading, disconnect }: { pubkey: string; profile: NostrProfile | null; loading: boolean; disconnect: () => Promise<void> }): React.ReactElement { function ConnectedState({ pubkey, profile, loading, disconnect }: { pubkey: string; profile: NostrProfile | null; loading: boolean; disconnect: () => Promise<void> }): React.ReactElement {
return ( return (
<ConnectedUserMenu <ConnectedUserMenu
@ -63,7 +56,7 @@ function UnlockState({ loading, error, onUnlock, onClose }: { loading: boolean;
return ( return (
<> <>
<ConnectForm <ConnectForm
onCreateAccount={() => {}} onCreateAccount={noop}
onUnlock={onUnlock} onUnlock={onUnlock}
loading={loading} loading={loading}
error={error} error={error}
@ -73,6 +66,10 @@ function UnlockState({ loading, error, onUnlock, onClose }: { loading: boolean;
) )
} }
function noop(): void {
// Intentionally empty: UnlockState must render ConnectForm but "create account" is not available in this mode.
}
function DisconnectedState({ function DisconnectedState({
loading, loading,
@ -107,79 +104,45 @@ function DisconnectedState({
export function ConnectButton(): React.ReactElement { 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 ui = useConnectButtonUiState()
const [showUnlockModal, setShowUnlockModal] = useState(false)
const [recoveryPhrase, setRecoveryPhrase] = useState<string[]>([])
const [npub, setNpub] = useState('')
const [creatingAccount, setCreatingAccount] = useState(false)
const [createError, setCreateError] = useState<string | null>(null)
useAutoConnect(accountExists, pubkey, false, showUnlockModal, connect) useAutoConnect({
accountExists,
pubkey,
showRecoveryStep: ui.showRecoveryStep,
showUnlockModal: ui.showUnlockModal,
connect,
})
const handleCreateAccount = async (): Promise<void> => { const mode = getConnectButtonMode({
setCreatingAccount(true) connected,
setCreateError(null) pubkey,
try { isUnlocked,
const { nostrAuthService } = await import('@/lib/nostrAuth') accountExists,
const result = await nostrAuthService.createAccount() showUnlockModal: ui.showUnlockModal,
setRecoveryPhrase(result.recoveryPhrase) })
setNpub(result.npub)
setShowRecoveryStep(true)
} catch (e) {
setCreateError(e instanceof Error ? e.message : 'Failed to create account')
} finally {
setCreatingAccount(false)
}
}
const handleRecoveryContinue = (): void => { if (mode === 'connected') {
setShowRecoveryStep(false)
setShowUnlockModal(true)
}
const handleUnlockSuccess = (): void => {
setShowUnlockModal(false)
setRecoveryPhrase([])
setNpub('')
}
if (connected && pubkey && isUnlocked) {
return <ConnectedState pubkey={pubkey} profile={profile} loading={loading} disconnect={disconnect} /> return <ConnectedState pubkey={pubkey} profile={profile} loading={loading} disconnect={disconnect} />
} }
if (accountExists === true && pubkey && !isUnlocked && !showUnlockModal) { if (mode === 'unlock_required') {
return ( return <UnlockState loading={loading} error={error} onUnlock={ui.openUnlockModal} onClose={ui.closeUnlockModal} />
<UnlockState
loading={loading}
error={error}
onUnlock={() => setShowUnlockModal(true)}
onClose={() => setShowUnlockModal(false)}
/>
)
} }
return ( return (
<> <>
<DisconnectedState <DisconnectedState
loading={loading ?? creatingAccount} loading={loading ?? ui.creatingAccount}
error={error ?? createError} error={error ?? ui.createError}
showUnlockModal={showUnlockModal} showUnlockModal={ui.showUnlockModal}
setShowUnlockModal={setShowUnlockModal} setShowUnlockModal={ui.setShowUnlockModal}
onCreateAccount={() => { void handleCreateAccount() }} onCreateAccount={ui.onCreateAccount}
/>
{showRecoveryStep && (
<RecoveryStep
recoveryPhrase={recoveryPhrase}
npub={npub}
onContinue={handleRecoveryContinue}
/>
)}
{showUnlockModal && (
<UnlockAccountModal
onSuccess={handleUnlockSuccess}
onClose={() => setShowUnlockModal(false)}
/> />
{ui.showRecoveryStep && (
<RecoveryStep recoveryPhrase={ui.recoveryPhrase} npub={ui.npub} onContinue={ui.onRecoveryContinue} />
)} )}
{ui.showUnlockModal && <UnlockAccountModal onSuccess={ui.onUnlockSuccess} onClose={ui.closeUnlockModal} />}
</> </>
) )
} }

View File

@ -14,35 +14,37 @@ async function createAccountWithKey(key?: string): Promise<{ recoveryPhrase: str
return nostrAuthService.createAccount(key) return nostrAuthService.createAccount(key)
} }
async function handleAccountCreation( interface HandleAccountCreationParams {
key: string | undefined, key: string | undefined
setLoading: (loading: boolean) => void, setLoading: (loading: boolean) => void
setError: (error: string | null) => void, setError: (error: string | null) => void
setRecoveryPhrase: (phrase: string[]) => void, setRecoveryPhrase: (phrase: string[]) => void
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()) {
setError('Please enter a private key') async function handleAccountCreation(params: HandleAccountCreationParams): Promise<void> {
if (params.key !== undefined && !params.key.trim()) {
params.setError('Please enter a private key')
return return
} }
setLoading(true) params.setLoading(true)
setError(null) params.setError(null)
try { try {
const result = await createAccountWithKey(key?.trim()) const result = await createAccountWithKey(params.key?.trim())
setRecoveryPhrase(result.recoveryPhrase) params.setRecoveryPhrase(result.recoveryPhrase)
setNpub(result.npub) params.setNpub(result.npub)
setStep('recovery') params.setStep('recovery')
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : errorMessage) params.setError(e instanceof Error ? e.message : params.errorMessage)
} finally { } finally {
setLoading(false) params.setLoading(false)
} }
} }
function useAccountCreation(initialStep: Step = 'choose'): { interface AccountCreationState {
step: Step step: Step
setStep: (step: Step) => void setStep: (step: Step) => void
importKey: string importKey: string
@ -54,7 +56,9 @@ function useAccountCreation(initialStep: Step = 'choose'): {
npub: string npub: string
handleGenerate: () => Promise<void> handleGenerate: () => Promise<void>
handleImport: () => Promise<void> handleImport: () => Promise<void>
} { }
function useAccountCreation(initialStep: Step = 'choose'): AccountCreationState {
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)
@ -62,27 +66,29 @@ 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 (): Promise<void> => { const handleGenerate = (): Promise<void> =>
await handleAccountCreation(undefined, setLoading, setError, setRecoveryPhrase, setNpub, setStep, 'Failed to create account') handleAccountCreation({
} key: undefined,
setLoading,
const handleImport = async (): Promise<void> => {
await handleAccountCreation(importKey, setLoading, setError, setRecoveryPhrase, setNpub, setStep, 'Failed to import key')
}
return {
step,
setStep,
importKey,
setImportKey,
loading,
error,
setError, setError,
recoveryPhrase, setRecoveryPhrase,
npub, setNpub,
handleGenerate, setStep,
handleImport, errorMessage: 'Failed to create account',
} })
const handleImport = (): Promise<void> =>
handleAccountCreation({
key: importKey,
setLoading,
setError,
setRecoveryPhrase,
setNpub,
setStep,
errorMessage: 'Failed to import key',
})
return { step, setStep, importKey, setImportKey, loading, error, setError, recoveryPhrase, npub, handleGenerate, handleImport }
} }
function handleImportBack(setStep: (step: Step) => void, setError: (error: string | null) => void, setImportKey: (key: string) => void): void { function handleImportBack(setStep: (step: Step) => void, setError: (error: string | null) => void, setImportKey: (key: string) => void): void {
@ -91,45 +97,47 @@ function handleImportBack(setStep: (step: Step) => void, setError: (error: strin
setImportKey('') setImportKey('')
} }
function renderStep( interface RenderStepParams {
step: Step, step: Step
recoveryPhrase: string[], recoveryPhrase: string[]
npub: string, npub: string
importKey: string, importKey: string
setImportKey: (key: string) => void, setImportKey: (key: string) => void
loading: boolean, loading: boolean
error: string | null, error: string | null
handleContinue: () => void, handleContinue: () => void
handleImport: () => void, handleImport: () => void
setStep: (step: Step) => void, setStep: (step: Step) => void
setError: (error: string | null) => void, setError: (error: string | null) => void
handleGenerate: () => void, handleGenerate: () => void
onClose: () => void onClose: () => void
): React.ReactElement {
if (step === 'recovery') {
return <RecoveryStep recoveryPhrase={recoveryPhrase} npub={npub} onContinue={handleContinue} />
} }
if (step === 'import') { function renderStep(params: RenderStepParams): React.ReactElement {
if (params.step === 'recovery') {
return <RecoveryStep recoveryPhrase={params.recoveryPhrase} npub={params.npub} onContinue={params.handleContinue} />
}
if (params.step === 'import') {
return ( return (
<ImportStep <ImportStep
importKey={importKey} importKey={params.importKey}
setImportKey={setImportKey} setImportKey={params.setImportKey}
loading={loading} loading={params.loading}
error={error} error={params.error}
onImport={handleImport} onImport={params.handleImport}
onBack={() => handleImportBack(setStep, setError, setImportKey)} onBack={() => handleImportBack(params.setStep, params.setError, params.setImportKey)}
/> />
) )
} }
return ( return (
<ChooseStep <ChooseStep
loading={loading} loading={params.loading}
error={error} error={params.error}
onGenerate={handleGenerate} onGenerate={params.handleGenerate}
onImport={() => setStep('import')} onImport={() => params.setStep('import')}
onClose={onClose} onClose={params.onClose}
/> />
) )
} }
@ -154,7 +162,7 @@ export function CreateAccountModal({ onSuccess, onClose, initialStep = 'choose'
onClose() onClose()
} }
return renderStep( return renderStep({
step, step,
recoveryPhrase, recoveryPhrase,
npub, npub,
@ -163,10 +171,14 @@ export function CreateAccountModal({ onSuccess, onClose, initialStep = 'choose'
loading, loading,
error, error,
handleContinue, handleContinue,
() => { void handleImport() }, handleImport: () => {
void handleImport()
},
setStep, setStep,
setError, setError,
() => { void handleGenerate() }, handleGenerate: () => {
onClose void handleGenerate()
) },
onClose,
})
} }

View File

@ -20,16 +20,17 @@ export function RecoveryPhraseDisplay({
copied: boolean copied: boolean
onCopy: () => void onCopy: () => void
}): React.ReactElement { }): React.ReactElement {
const recoveryItems = buildRecoveryPhraseItems(recoveryPhrase)
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">
{recoveryPhrase.map((word, index) => ( {recoveryItems.map((item, index) => (
<div <div
key={`recovery-word-${index}-${word}`} key={item.key}
className="bg-cyber-dark border border-neon-cyan/30 rounded-lg p-3 text-center font-mono text-lg" className="bg-cyber-dark border border-neon-cyan/30 rounded-lg p-3 text-center font-mono text-lg"
> >
<span className="text-cyber-accent/70 text-sm mr-2">{index + 1}.</span> <span className="text-cyber-accent/70 text-sm mr-2">{index + 1}.</span>
<span className="font-semibold text-neon-cyan">{word}</span> <span className="font-semibold text-neon-cyan">{item.word}</span>
</div> </div>
))} ))}
</div> </div>
@ -45,6 +46,15 @@ export function RecoveryPhraseDisplay({
) )
} }
function buildRecoveryPhraseItems(recoveryPhrase: string[]): { key: string; word: string }[] {
const counts = new Map<string, number>()
return recoveryPhrase.map((word) => {
const nextCount = (counts.get(word) ?? 0) + 1
counts.set(word, nextCount)
return { key: `${word}-${nextCount}`, word }
})
}
export function PublicKeyDisplay({ npub }: { npub: string }): React.ReactElement { 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">

View File

@ -42,18 +42,52 @@ function FundingStats({ stats }: { stats: ReturnType<typeof estimatePlatformFund
} }
export function FundingGauge(): React.ReactElement { export function FundingGauge(): React.ReactElement {
const state = useFundingGaugeState()
if (state.loading) {
return <FundingGaugeLoading />
}
return (
<div className="space-y-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')} - {t('home.funding.priority.ia')}
</h2>
<FundingStats stats={state.stats} />
</div>
<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')} - {t('home.funding.priority.ancrage')}
</h2>
<FundingStats stats={state.certificationStats} />
</div>
</div>
)
}
function FundingGaugeLoading(): React.ReactElement {
return (
<div className="bg-cyber-dark/50 border border-neon-cyan/20 rounded-lg p-6 mb-8">
<p className="text-cyber-accent">{t('common.loading')}</p>
</div>
)
}
function useFundingGaugeState(): {
stats: ReturnType<typeof estimatePlatformFunds>
certificationStats: ReturnType<typeof estimatePlatformFunds>
loading: boolean
} {
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)
useEffect(() => { useEffect(() => {
// In a real implementation, this would fetch actual data
// For now, we use the estimate
const loadStats = async (): Promise<void> => { const loadStats = async (): Promise<void> => {
try { try {
const fundingStats = estimatePlatformFunds() const fundingStats = estimatePlatformFunds()
setStats(fundingStats) setStats(fundingStats)
// Certification uses the same funding pool
setCertificationStats(fundingStats) setCertificationStats(fundingStats)
} catch (e) { } catch (e) {
console.error('Error loading funding stats:', e) console.error('Error loading funding stats:', e)
@ -64,28 +98,5 @@ export function FundingGauge(): React.ReactElement {
void loadStats() void loadStats()
}, []) }, [])
if (loading) { return { stats, certificationStats, loading }
return (
<div className="bg-cyber-dark/50 border border-neon-cyan/20 rounded-lg p-6 mb-8">
<p className="text-cyber-accent">{t('common.loading')}</p>
</div>
)
}
return (
<div className="space-y-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')} - {t('home.funding.priority.ia')}
</h2>
<FundingStats stats={stats} />
</div>
<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')} - {t('home.funding.priority.ancrage')}
</h2>
<FundingStats stats={certificationStats} />
</div>
</div>
)
} }

View File

@ -46,19 +46,13 @@ function SyncIcon(): React.ReactElement {
* Shows sync icon + relay name when syncing * Shows sync icon + relay name when syncing
*/ */
export function SyncStatus(): React.ReactElement | null { export function SyncStatus(): React.ReactElement | null {
const [progress, setProgress] = useState<SyncProgress | null>(null) const [progress, setProgress] = useState<SyncProgress | null>(() => syncProgressManager.getProgress())
useEffect(() => { useEffect(() => {
const unsubscribe = syncProgressManager.subscribe((newProgress) => { const unsubscribe = syncProgressManager.subscribe((newProgress) => {
setProgress(newProgress) setProgress(newProgress)
}) })
// Check current progress immediately
const currentProgress = syncProgressManager.getProgress()
if (currentProgress) {
setProgress(currentProgress)
}
return () => { return () => {
unsubscribe() unsubscribe()
} }

View File

@ -0,0 +1,19 @@
export type ConnectButtonMode = 'connected' | 'unlock_required' | 'default'
export function getConnectButtonMode(params: {
connected: boolean
pubkey: string | null
isUnlocked: boolean
accountExists: boolean | null
showUnlockModal: boolean
}): ConnectButtonMode {
if (params.connected && params.pubkey && params.isUnlocked) {
return 'connected'
}
if (params.accountExists === true && params.pubkey && !params.isUnlocked && !params.showUnlockModal) {
return 'unlock_required'
}
return 'default'
}

View File

@ -0,0 +1,137 @@
import { useEffect, useState } from 'react'
export function useAutoConnect(params: {
accountExists: boolean | null
pubkey: string | null
showRecoveryStep: boolean
showUnlockModal: boolean
connect: () => Promise<void>
}): void {
const { accountExists, pubkey, showRecoveryStep, showUnlockModal, connect } = params
useEffect(() => {
if (accountExists === true && !pubkey && !showRecoveryStep && !showUnlockModal) {
void connect()
}
}, [accountExists, pubkey, showRecoveryStep, showUnlockModal, connect])
}
export function useConnectButtonUiState(): {
showRecoveryStep: boolean
showUnlockModal: boolean
setShowUnlockModal: (show: boolean) => void
recoveryPhrase: string[]
npub: string
creatingAccount: boolean
createError: string | null
onCreateAccount: () => void
onRecoveryContinue: () => void
onUnlockSuccess: () => void
openUnlockModal: () => void
closeUnlockModal: () => void
} {
const unlockModal = useUnlockModalVisibility()
const recovery = useRecoveryStepState()
const [creatingAccount, setCreatingAccount] = useState(false)
const [createError, setCreateError] = useState<string | null>(null)
const onCreateAccount = (): void => {
void handleCreateAccount({
setCreatingAccount,
setCreateError,
setRecoveryPhrase: recovery.setRecoveryPhrase,
setNpub: recovery.setNpub,
setShowRecoveryStep: recovery.setShowRecoveryStep,
})
}
const onRecoveryContinue = (): void => {
recovery.hideRecoveryStep()
unlockModal.openUnlockModal()
}
const onUnlockSuccess = (): void => {
unlockModal.closeUnlockModal()
recovery.resetRecoveryData()
}
return {
showRecoveryStep: recovery.showRecoveryStep,
showUnlockModal: unlockModal.showUnlockModal,
setShowUnlockModal: unlockModal.setShowUnlockModal,
recoveryPhrase: recovery.recoveryPhrase,
npub: recovery.npub,
creatingAccount,
createError,
onCreateAccount,
onRecoveryContinue,
onUnlockSuccess,
openUnlockModal: unlockModal.openUnlockModal,
closeUnlockModal: unlockModal.closeUnlockModal,
}
}
function useUnlockModalVisibility(): {
showUnlockModal: boolean
setShowUnlockModal: (show: boolean) => void
openUnlockModal: () => void
closeUnlockModal: () => void
} {
const [showUnlockModal, setShowUnlockModal] = useState(false)
const openUnlockModal = (): void => setShowUnlockModal(true)
const closeUnlockModal = (): void => setShowUnlockModal(false)
return { showUnlockModal, setShowUnlockModal, openUnlockModal, closeUnlockModal }
}
function useRecoveryStepState(): {
showRecoveryStep: boolean
recoveryPhrase: string[]
npub: string
setShowRecoveryStep: (show: boolean) => void
setRecoveryPhrase: (words: string[]) => void
setNpub: (npub: string) => void
hideRecoveryStep: () => void
resetRecoveryData: () => void
} {
const [showRecoveryStep, setShowRecoveryStep] = useState(false)
const [recoveryPhrase, setRecoveryPhrase] = useState<string[]>([])
const [npub, setNpub] = useState('')
const hideRecoveryStep = (): void => setShowRecoveryStep(false)
const resetRecoveryData = (): void => {
setRecoveryPhrase([])
setNpub('')
}
return {
showRecoveryStep,
recoveryPhrase,
npub,
setShowRecoveryStep,
setRecoveryPhrase,
setNpub,
hideRecoveryStep,
resetRecoveryData,
}
}
async function handleCreateAccount(params: {
setCreatingAccount: (creating: boolean) => void
setCreateError: (error: string | null) => void
setRecoveryPhrase: (words: string[]) => void
setNpub: (npub: string) => void
setShowRecoveryStep: (show: boolean) => void
}): Promise<void> {
params.setCreatingAccount(true)
params.setCreateError(null)
try {
const { nostrAuthService } = await import('@/lib/nostrAuth')
const result = await nostrAuthService.createAccount()
params.setRecoveryPhrase(result.recoveryPhrase)
params.setNpub(result.npub)
params.setShowRecoveryStep(true)
} catch (e) {
params.setCreateError(e instanceof Error ? e.message : 'Failed to create account')
} finally {
params.setCreatingAccount(false)
}
}

View File

@ -21,7 +21,9 @@ export function useDocs(docs: DocLink[]): {
const loadDoc = useCallback(async (docId: DocSection): Promise<void> => { const loadDoc = useCallback(async (docId: DocSection): Promise<void> => {
const doc = docs.find((d) => d.id === docId) const doc = docs.find((d) => d.id === docId)
if (!doc) {return} if (!doc) {
return
}
setLoading(true) setLoading(true)
setSelectedDoc(docId) setSelectedDoc(docId)
@ -29,7 +31,7 @@ export function useDocs(docs: DocLink[]): {
try { try {
// Get current locale and pass it to the API // Get current locale and pass it to the API
const locale = getLocale() const locale = getLocale()
const response = await fetch(`/api/docs/${doc.file}?locale=${locale}`) const response = await globalThis.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

@ -26,8 +26,8 @@ export function useI18n(locale: Locale = 'fr'): {
const initialLocale = savedLocale && (savedLocale === 'fr' || savedLocale === 'en') ? savedLocale : locale const initialLocale = savedLocale && (savedLocale === 'fr' || savedLocale === 'en') ? savedLocale : locale
// Load translations from files in public directory // Load translations from files in public directory
const frResponse = await fetch('/locales/fr.txt') const frResponse = await globalThis.fetch('/locales/fr.txt')
const enResponse = await fetch('/locales/en.txt') const enResponse = await globalThis.fetch('/locales/en.txt')
if (frResponse.ok) { if (frResponse.ok) {
const frText = await frResponse.text() const frText = await frResponse.text()

View File

@ -20,7 +20,7 @@ export interface DecryptionKey {
* Generate a random encryption key for AES-GCM * Generate a random encryption key for AES-GCM
*/ */
function generateEncryptionKey(): string { function generateEncryptionKey(): string {
const keyBytes = crypto.getRandomValues(new Uint8Array(32)) const keyBytes = globalThis.crypto.getRandomValues(new Uint8Array(32))
return Array.from(keyBytes) return Array.from(keyBytes)
.map((b) => b.toString(16).padStart(2, '0')) .map((b) => b.toString(16).padStart(2, '0'))
.join('') .join('')
@ -30,7 +30,7 @@ function generateEncryptionKey(): string {
* Generate a random IV for AES-GCM * Generate a random IV for AES-GCM
*/ */
function generateIV(): Uint8Array { function generateIV(): Uint8Array {
return crypto.getRandomValues(new Uint8Array(12)) return globalThis.crypto.getRandomValues(new Uint8Array(12))
} }
/** /**
@ -60,7 +60,7 @@ function arrayBufferToHex(buffer: ArrayBuffer): string {
*/ */
async function prepareEncryptionKey(key: string): Promise<CryptoKey> { async function prepareEncryptionKey(key: string): Promise<CryptoKey> {
const keyBuffer = hexToArrayBuffer(key) const keyBuffer = hexToArrayBuffer(key)
return crypto.subtle.importKey('raw', keyBuffer, { name: 'AES-GCM' }, false, ['encrypt']) return globalThis.crypto.subtle.importKey('raw', keyBuffer, { name: 'AES-GCM' }, false, ['encrypt'])
} }
function prepareIV(iv: Uint8Array): { view: Uint8Array; buffer: ArrayBuffer } { function prepareIV(iv: Uint8Array): { view: Uint8Array; buffer: ArrayBuffer } {
@ -85,7 +85,7 @@ export async function encryptArticleContent(content: string): Promise<{
const encodedContent = encoder.encode(content) const encodedContent = encoder.encode(content)
const { view: ivView, buffer: ivBuffer } = prepareIV(iv) const { view: ivView, buffer: ivBuffer } = prepareIV(iv)
const encryptedBuffer = await crypto.subtle.encrypt( const encryptedBuffer = await globalThis.crypto.subtle.encrypt(
{ name: 'AES-GCM', iv: ivView as Uint8Array<ArrayBuffer> }, { name: 'AES-GCM', iv: ivView as Uint8Array<ArrayBuffer> },
cryptoKey, cryptoKey,
encodedContent encodedContent
@ -109,7 +109,7 @@ export async function decryptArticleContent(
const keyBuffer = hexToArrayBuffer(key) const keyBuffer = hexToArrayBuffer(key)
const ivBuffer = hexToArrayBuffer(iv) const ivBuffer = hexToArrayBuffer(iv)
const cryptoKey = await crypto.subtle.importKey( const cryptoKey = await globalThis.crypto.subtle.importKey(
'raw', 'raw',
keyBuffer, keyBuffer,
{ name: 'AES-GCM' }, { name: 'AES-GCM' },
@ -119,7 +119,7 @@ export async function decryptArticleContent(
const encryptedBuffer = hexToArrayBuffer(encryptedContent) const encryptedBuffer = hexToArrayBuffer(encryptedContent)
const decryptedBuffer = await crypto.subtle.decrypt( const decryptedBuffer = await globalThis.crypto.subtle.decrypt(
{ {
name: 'AES-GCM', name: 'AES-GCM',
iv: ivBuffer, iv: ivBuffer,

View File

@ -2,6 +2,7 @@ import { type Event } from 'nostr-tools'
import { nip19 } from 'nostr-tools' import { nip19 } from 'nostr-tools'
import type { AuthorPresentationDraft } from './articlePublisher' import type { AuthorPresentationDraft } from './articlePublisher'
import type { SimplePoolWithSub } from '@/types/nostr-tools-extended' import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
import { createSubscription } from '@/types/nostr-tools-extended'
import { buildTags, extractTagsFromEvent, buildTagFilter } from './nostrTagSystem' import { buildTags, extractTagsFromEvent, buildTagFilter } from './nostrTagSystem'
import { getPrimaryRelaySync } from './config' import { getPrimaryRelaySync } from './config'
import { PLATFORM_SERVICE, MIN_EVENT_DATE } from './platformConfig' import { PLATFORM_SERVICE, MIN_EVENT_DATE } from './platformConfig'
@ -297,7 +298,6 @@ export async function fetchAuthorPresentationFromPool(
return new Promise<import('@/types/nostr').AuthorPresentationArticle | null>((resolve) => { return new Promise<import('@/types/nostr').AuthorPresentationArticle | null>((resolve) => {
let resolved = false let resolved = false
const relayUrl = getPrimaryRelaySync() const relayUrl = getPrimaryRelaySync()
const { createSubscription } = require('@/types/nostr-tools-extended')
const sub = createSubscription(pool, [relayUrl], filters) const sub = createSubscription(pool, [relayUrl], filters)
const events: Event[] = [] const events: Event[] = []

View File

@ -28,12 +28,12 @@ async function getOrCreateMasterKey(): Promise<string> {
if (existing) { if (existing) {
return existing return existing
} }
const keyBytes = crypto.getRandomValues(new Uint8Array(32)) const keyBytes = globalThis.crypto.getRandomValues(new Uint8Array(32))
let binary = '' let binary = ''
keyBytes.forEach((b) => { keyBytes.forEach((b) => {
binary += String.fromCharCode(b) binary += String.fromCharCode(b)
}) })
const key = btoa(binary) const key = globalThis.btoa(binary)
await storageService.set(MASTER_KEY_STORAGE_KEY, key, 'article_storage') await storageService.set(MASTER_KEY_STORAGE_KEY, key, 'article_storage')
return key return key
} }

View File

@ -60,7 +60,7 @@ export class ConfigStorage {
return return
} }
const request = indexedDB.open(DB_NAME, DB_VERSION) const request = globalThis.indexedDB.open(DB_NAME, DB_VERSION)
request.onerror = () => { request.onerror = () => {
reject(new Error(`Failed to open IndexedDB: ${request.error}`)) reject(new Error(`Failed to open IndexedDB: ${request.error}`))

View File

@ -38,7 +38,7 @@ export async function generateHashId(obj: Record<string, unknown>): Promise<stri
const canonical = canonicalizeObject(obj) const canonical = canonicalizeObject(obj)
const encoder = new TextEncoder() const encoder = new TextEncoder()
const data = encoder.encode(canonical) const data = encoder.encode(canonical)
const hashBuffer = await crypto.subtle.digest('SHA-256', data) const hashBuffer = await globalThis.crypto.subtle.digest('SHA-256', data)
const hashArray = Array.from(new Uint8Array(hashBuffer)) const hashArray = Array.from(new Uint8Array(hashBuffer))
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('') return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
} }

View File

@ -27,8 +27,8 @@ export function useSyncProgress(options: UseSyncProgressOptions = {}): UseSyncPr
const [syncProgress, setSyncProgress] = useState<SyncProgress | null>(null) const [syncProgress, setSyncProgress] = useState<SyncProgress | null>(null)
const [isSyncing, setIsSyncing] = useState(false) const [isSyncing, setIsSyncing] = useState(false)
const intervalRef = useRef<NodeJS.Timeout | null>(null) const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const timeoutRef = useRef<NodeJS.Timeout | null>(null) const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const onCompleteRef = useRef(onComplete) const onCompleteRef = useRef(onComplete)
const isMonitoringRef = useRef(false) const isMonitoringRef = useRef(false)
@ -105,4 +105,3 @@ export function useSyncProgress(options: UseSyncProgressOptions = {}): UseSyncPr
stopMonitoring, stopMonitoring,
} }
} }

View File

@ -217,7 +217,7 @@ export const BIP39_WORDLIST = [
*/ */
export function generateRecoveryPhrase(): string[] { export function generateRecoveryPhrase(): string[] {
const words: string[] = [] const words: string[] = []
const random = crypto.getRandomValues(new Uint32Array(4)) const random = globalThis.crypto.getRandomValues(new Uint32Array(4))
for (let i = 0; i < 4; i += 1) { for (let i = 0; i < 4; i += 1) {
const randomValue = random[i] const randomValue = random[i]

View File

@ -19,7 +19,7 @@ const PBKDF2_HASH = 'SHA-256'
* Generate a random KEK (Key Encryption Key) * Generate a random KEK (Key Encryption Key)
*/ */
async function generateKEK(): Promise<CryptoKey> { async function generateKEK(): Promise<CryptoKey> {
return crypto.subtle.generateKey( return globalThis.crypto.subtle.generateKey(
{ name: 'AES-GCM', length: 256 }, { name: 'AES-GCM', length: 256 },
true, // extractable true, // extractable
['encrypt', 'decrypt'] ['encrypt', 'decrypt']
@ -35,12 +35,12 @@ async function deriveKeyFromPhrase(phrase: string[]): Promise<CryptoKey> {
const password = encoder.encode(phraseString) const password = encoder.encode(phraseString)
// Generate deterministic salt from phrase // Generate deterministic salt from phrase
const saltBuffer = await crypto.subtle.digest('SHA-256', password) const saltBuffer = await globalThis.crypto.subtle.digest('SHA-256', password)
const saltArray = new Uint8Array(saltBuffer) const saltArray = new Uint8Array(saltBuffer)
const salt = saltArray.slice(0, 32) const salt = saltArray.slice(0, 32)
// Import password as key material // Import password as key material
const keyMaterial = await crypto.subtle.importKey( const keyMaterial = await globalThis.crypto.subtle.importKey(
'raw', 'raw',
password, password,
'PBKDF2', 'PBKDF2',
@ -49,7 +49,7 @@ async function deriveKeyFromPhrase(phrase: string[]): Promise<CryptoKey> {
) )
// Derive key using PBKDF2 // Derive key using PBKDF2
const derivedKey = await crypto.subtle.deriveKey( const derivedKey = await globalThis.crypto.subtle.deriveKey(
{ {
name: 'PBKDF2', name: 'PBKDF2',
salt, salt,
@ -69,7 +69,7 @@ async function deriveKeyFromPhrase(phrase: string[]): Promise<CryptoKey> {
* Export KEK to raw bytes (for storage) * Export KEK to raw bytes (for storage)
*/ */
async function exportKEK(kek: CryptoKey): Promise<Uint8Array> { async function exportKEK(kek: CryptoKey): Promise<Uint8Array> {
const exported = await crypto.subtle.exportKey('raw', kek) const exported = await globalThis.crypto.subtle.exportKey('raw', kek)
return new Uint8Array(exported) return new Uint8Array(exported)
} }
@ -81,7 +81,7 @@ async function importKEK(keyBytes: Uint8Array): Promise<CryptoKey> {
const buffer = new ArrayBuffer(keyBytes.length) const buffer = new ArrayBuffer(keyBytes.length)
const view = new Uint8Array(buffer) const view = new Uint8Array(buffer)
view.set(keyBytes) view.set(keyBytes)
return crypto.subtle.importKey( return globalThis.crypto.subtle.importKey(
'raw', 'raw',
buffer, buffer,
{ name: 'AES-GCM' }, { name: 'AES-GCM' },
@ -99,9 +99,9 @@ async function encryptKEK(kek: CryptoKey, recoveryPhrase: string[]): Promise<Enc
const encoder = new TextEncoder() const encoder = new TextEncoder()
const data = encoder.encode(Array.from(kekBytes).map(b => b.toString(16).padStart(2, '0')).join('')) const data = encoder.encode(Array.from(kekBytes).map(b => b.toString(16).padStart(2, '0')).join(''))
const iv = crypto.getRandomValues(new Uint8Array(12)) const iv = globalThis.crypto.getRandomValues(new Uint8Array(12))
const encrypted = await crypto.subtle.encrypt( const encrypted = await globalThis.crypto.subtle.encrypt(
{ name: 'AES-GCM', iv }, { name: 'AES-GCM', iv },
phraseKey, phraseKey,
data data
@ -114,7 +114,7 @@ async function encryptKEK(kek: CryptoKey, recoveryPhrase: string[]): Promise<Enc
bytes.forEach((b) => { bytes.forEach((b) => {
binary += String.fromCharCode(b) binary += String.fromCharCode(b)
}) })
return btoa(binary) return globalThis.btoa(binary)
} }
return { return {
@ -130,7 +130,7 @@ async function decryptKEK(encryptedKEK: EncryptedPayload, recoveryPhrase: string
const phraseKey = await deriveKeyFromPhrase(recoveryPhrase) const phraseKey = await deriveKeyFromPhrase(recoveryPhrase)
function fromBase64(value: string): Uint8Array { function fromBase64(value: string): Uint8Array {
const binary = atob(value) const binary = globalThis.atob(value)
const bytes = new Uint8Array(binary.length) const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i += 1) { for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i) bytes[i] = binary.charCodeAt(i)
@ -150,7 +150,7 @@ async function decryptKEK(encryptedKEK: EncryptedPayload, recoveryPhrase: string
const cipherView = new Uint8Array(cipherBuffer) const cipherView = new Uint8Array(cipherBuffer)
cipherView.set(ciphertext) cipherView.set(ciphertext)
const decrypted = await crypto.subtle.decrypt( const decrypted = await globalThis.crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: ivView }, { name: 'AES-GCM', iv: ivView },
phraseKey, phraseKey,
cipherBuffer cipherBuffer
@ -171,9 +171,9 @@ async function decryptKEK(encryptedKEK: EncryptedPayload, recoveryPhrase: string
async function encryptPrivateKeyWithKEK(privateKey: string, kek: CryptoKey): Promise<EncryptedPayload> { async function encryptPrivateKeyWithKEK(privateKey: string, kek: CryptoKey): Promise<EncryptedPayload> {
const encoder = new TextEncoder() const encoder = new TextEncoder()
const data = encoder.encode(privateKey) const data = encoder.encode(privateKey)
const iv = crypto.getRandomValues(new Uint8Array(12)) const iv = globalThis.crypto.getRandomValues(new Uint8Array(12))
const encrypted = await crypto.subtle.encrypt( const encrypted = await globalThis.crypto.subtle.encrypt(
{ name: 'AES-GCM', iv }, { name: 'AES-GCM', iv },
kek, kek,
data data
@ -186,7 +186,7 @@ async function encryptPrivateKeyWithKEK(privateKey: string, kek: CryptoKey): Pro
bytes.forEach((b) => { bytes.forEach((b) => {
binary += String.fromCharCode(b) binary += String.fromCharCode(b)
}) })
return btoa(binary) return globalThis.btoa(binary)
} }
return { return {
@ -200,7 +200,7 @@ async function encryptPrivateKeyWithKEK(privateKey: string, kek: CryptoKey): Pro
*/ */
async function decryptPrivateKeyWithKEK(encryptedPrivateKey: EncryptedPayload, kek: CryptoKey): Promise<string> { async function decryptPrivateKeyWithKEK(encryptedPrivateKey: EncryptedPayload, kek: CryptoKey): Promise<string> {
function fromBase64(value: string): Uint8Array { function fromBase64(value: string): Uint8Array {
const binary = atob(value) const binary = globalThis.atob(value)
const bytes = new Uint8Array(binary.length) const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i += 1) { for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i) bytes[i] = binary.charCodeAt(i)
@ -220,7 +220,7 @@ async function decryptPrivateKeyWithKEK(encryptedPrivateKey: EncryptedPayload, k
const cipherView = new Uint8Array(cipherBuffer) const cipherView = new Uint8Array(cipherBuffer)
cipherView.set(ciphertext) cipherView.set(ciphertext)
const decrypted = await crypto.subtle.decrypt( const decrypted = await globalThis.crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: ivView }, { name: 'AES-GCM', iv: ivView },
kek, kek,
cipherBuffer cipherBuffer

View File

@ -4,7 +4,7 @@ const MEMPOOL_API_BASE = 'https://mempool.space/api'
export async function getTransaction(txid: string): Promise<MempoolTransaction | null> { export async function getTransaction(txid: string): Promise<MempoolTransaction | null> {
try { try {
const response = await fetch(`${MEMPOOL_API_BASE}/tx/${txid}`) const response = await globalThis.fetch(`${MEMPOOL_API_BASE}/tx/${txid}`)
if (!response.ok) { if (!response.ok) {
if (response.status === 404) { if (response.status === 404) {
@ -28,7 +28,7 @@ export async function getTransaction(txid: string): Promise<MempoolTransaction |
export async function getConfirmations(blockHeight: number): Promise<number> { export async function getConfirmations(blockHeight: number): Promise<number> {
try { try {
const response = await fetch(`${MEMPOOL_API_BASE}/blocks/tip/height`) const response = await globalThis.fetch(`${MEMPOOL_API_BASE}/blocks/tip/height`)
if (!response.ok) { if (!response.ok) {
return 0 return 0
} }

View File

@ -77,7 +77,7 @@ function parseUploadResponse(result: unknown, endpoint: string): string {
async function tryUploadEndpoint(endpoint: string, formData: FormData, useProxy: boolean = false): Promise<string> { async function tryUploadEndpoint(endpoint: string, formData: FormData, useProxy: boolean = false): Promise<string> {
const targetUrl = useProxy ? endpoint : endpoint const targetUrl = useProxy ? endpoint : endpoint
const response = await fetch(targetUrl, { const response = await globalThis.fetch(targetUrl, {
method: 'POST', method: 'POST',
body: formData, body: formData,
// Don't set Content-Type manually - browser will set it with boundary automatically // Don't set Content-Type manually - browser will set it with boundary automatically

View File

@ -63,7 +63,7 @@ export async function generateNip98Token(method: string, url: string, payloadHas
// Encode event as base64 JSON // Encode event as base64 JSON
const eventJson = JSON.stringify(signedEvent) const eventJson = JSON.stringify(signedEvent)
const eventBytes = new TextEncoder().encode(eventJson) const eventBytes = new TextEncoder().encode(eventJson)
const base64Token = btoa(String.fromCharCode(...eventBytes)) const base64Token = globalThis.btoa(String.fromCharCode(...eventBytes))
return base64Token return base64Token
} }

View File

@ -2,6 +2,7 @@ import { Event, nip04 } from 'nostr-tools'
import { SimplePool } from 'nostr-tools' import { SimplePool } from 'nostr-tools'
import { decryptArticleContent, type DecryptionKey } from './articleEncryption' import { decryptArticleContent, type DecryptionKey } from './articleEncryption'
import { getPrimaryRelaySync } from './config' import { getPrimaryRelaySync } from './config'
import { createSubscription } from '@/types/nostr-tools-extended'
function createPrivateMessageFilters(eventId: string, publicKey: string, authorPubkey: string): Array<{ function createPrivateMessageFilters(eventId: string, publicKey: string, authorPubkey: string): Array<{
kinds: number[] kinds: number[]
@ -45,7 +46,6 @@ export function getPrivateContent(
return new Promise<string | null>((resolve) => { return new Promise<string | null>((resolve) => {
let resolved = false let resolved = false
const relayUrl = getPrimaryRelaySync() const relayUrl = getPrimaryRelaySync()
const { createSubscription } = require('@/types/nostr-tools-extended')
const sub = createSubscription(pool, [relayUrl], createPrivateMessageFilters(eventId, publicKey, authorPubkey)) const sub = createSubscription(pool, [relayUrl], createPrivateMessageFilters(eventId, publicKey, authorPubkey))
const finalize = (result: string | null): void => { const finalize = (result: string | null): void => {
@ -122,7 +122,6 @@ export async function getDecryptionKey(
return new Promise<DecryptionKey | null>((resolve) => { return new Promise<DecryptionKey | null>((resolve) => {
let resolved = false let resolved = false
const relayUrl = getPrimaryRelaySync() const relayUrl = getPrimaryRelaySync()
const { createSubscription } = require('@/types/nostr-tools-extended')
const sub = createSubscription(pool, [relayUrl], createPrivateMessageFilters(eventId, recipientPublicKey, authorPubkey)) const sub = createSubscription(pool, [relayUrl], createPrivateMessageFilters(eventId, recipientPublicKey, authorPubkey))
const finalize = (result: DecryptionKey | null): void => { const finalize = (result: DecryptionKey | null): void => {

View File

@ -178,7 +178,8 @@ class ObjectCacheService {
// Notify about published status change (false -> array of relays) // Notify about published status change (false -> array of relays)
if (oldPublished === false && Array.isArray(published) && published.length > 0) { if (oldPublished === false && Array.isArray(published) && published.length > 0) {
const eventId = id.split(':')[1] ?? id const eventId = id.split(':')[1] ?? id
const { notificationDetector } = require('./notificationDetector') void import('./notificationDetector')
.then(({ notificationDetector }) => {
void notificationDetector.checkObjectChange({ void notificationDetector.checkObjectChange({
objectType, objectType,
objectId: id, objectId: id,
@ -186,6 +187,10 @@ class ObjectCacheService {
oldPublished, oldPublished,
newPublished: published, newPublished: published,
}) })
})
.catch((error) => {
console.error('Failed to notify published status change:', error)
})
} }
} catch (updateError) { } catch (updateError) {
console.error(`Error updating published status for ${objectType} object:`, updateError) console.error(`Error updating published status for ${objectType} object:`, updateError)

View File

@ -1,6 +1,7 @@
import { nostrService } from './nostr' import { nostrService } from './nostr'
import { getPrimaryRelaySync } from './config' import { getPrimaryRelaySync } from './config'
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
import { createSubscription } from '@/types/nostr-tools-extended'
export function parseZapAmount(event: import('nostr-tools').Event): number { export function parseZapAmount(event: import('nostr-tools').Event): number {
const amountTag = event.tags.find((tag) => tag[0] === 'amount')?.[1] const amountTag = event.tags.find((tag) => tag[0] === 'amount')?.[1]
@ -17,7 +18,6 @@ export function createZapReceiptSubscription(pool: import('nostr-tools').SimpleP
}, },
] ]
const relayUrl = getPrimaryRelaySync() const relayUrl = getPrimaryRelaySync()
const { createSubscription } = require('@/types/nostr-tools-extended')
return createSubscription(pool, [relayUrl], filters) return createSubscription(pool, [relayUrl], filters)
} }

View File

@ -6,6 +6,7 @@
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
import type { SimplePoolWithSub } from '@/types/nostr-tools-extended' import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
import { createSubscription } from '@/types/nostr-tools-extended'
import { nostrService } from './nostr' import { nostrService } from './nostr'
import { PLATFORM_SERVICE, MIN_EVENT_DATE } from './platformConfig' import { PLATFORM_SERVICE, MIN_EVENT_DATE } from './platformConfig'
import { extractTagsFromEvent } from './nostrTagSystem' import { extractTagsFromEvent } from './nostrTagSystem'
@ -109,7 +110,6 @@ class PlatformSyncService {
try { try {
console.warn(`[PlatformSync] Synchronizing from relay ${i + 1}/${activeRelays.length}: ${relayUrl}`) console.warn(`[PlatformSync] Synchronizing from relay ${i + 1}/${activeRelays.length}: ${relayUrl}`)
const { createSubscription } = require('@/types/nostr-tools-extended')
const sub = createSubscription(pool, [relayUrl], filters) const sub = createSubscription(pool, [relayUrl], filters)
const relayEvents: Event[] = [] const relayEvents: Event[] = []

View File

@ -22,7 +22,7 @@ interface UnpublishedObject {
class PublishWorkerService { class PublishWorkerService {
private isRunning = false private isRunning = false
private intervalId: NodeJS.Timeout | null = null private intervalId: ReturnType<typeof setInterval> | null = null
private unpublishedObjects: Map<string, UnpublishedObject> = new Map() private unpublishedObjects: Map<string, UnpublishedObject> = new Map()
private processing = false private processing = false

View File

@ -5,7 +5,7 @@
import { websocketService } from './websocketService' import { websocketService } from './websocketService'
import { writeService } from './writeService' import { writeService } from './writeService'
import type { Event, EventTemplate } from 'nostr-tools' import type { EventTemplate } from 'nostr-tools'
import { finalizeEvent } from 'nostr-tools' import { finalizeEvent } from 'nostr-tools'
import { hexToBytes } from 'nostr-tools/utils' import { hexToBytes } from 'nostr-tools/utils'
import type { ObjectType } from './objectCache' import type { ObjectType } from './objectCache'
@ -94,7 +94,7 @@ class WriteOrchestrator {
version: number, version: number,
hidden: boolean, hidden: boolean,
index?: number index?: number
): Promise<{ success: boolean; event: Event; published: false | string[] }> { ): Promise<{ success: boolean; event: NostrEvent; published: false | string[] }> {
if (!this.privateKey) { if (!this.privateKey) {
throw new Error('Private key not set') throw new Error('Private key not set')
} }
@ -124,7 +124,7 @@ class WriteOrchestrator {
return { return {
success: result.success, success: result.success,
event, event: finalizedEvent,
published: result.published, published: result.published,
} }
} }

View File

@ -1,6 +1,7 @@
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
import { nostrService } from './nostr' import { nostrService } from './nostr'
import type { Subscription } from '@/types/nostr-tools-extended' import type { Subscription } from '@/types/nostr-tools-extended'
import { createSubscription } from '@/types/nostr-tools-extended'
import { getPrimaryRelaySync } from './config' import { getPrimaryRelaySync } from './config'
interface ZapAggregationFilter { interface ZapAggregationFilter {
@ -50,7 +51,6 @@ export function aggregateZapSats(params: ZapAggregationFilter): Promise<number>
const filters = buildFilters(params) const filters = buildFilters(params)
const relay = getPrimaryRelaySync() const relay = getPrimaryRelaySync()
const timeout = params.timeoutMs ?? 5000 const timeout = params.timeoutMs ?? 5000
const { createSubscription } = require('@/types/nostr-tools-extended')
const sub = createSubscription(pool, [relay], filters) const sub = createSubscription(pool, [relay], filters)
return collectZap(sub, timeout) return collectZap(sub, timeout)

View File

@ -1,5 +1,6 @@
import '@/styles/globals.css' import '@/styles/globals.css'
import type { AppProps } from 'next/app' import type { AppProps } from 'next/app'
import Router from 'next/router'
import { useI18n } from '@/hooks/useI18n' import { useI18n } from '@/hooks/useI18n'
import React from 'react' import React from 'react'
import { nostrAuthService } from '@/lib/nostrAuth' import { nostrAuthService } from '@/lib/nostrAuth'
@ -92,11 +93,10 @@ export default function App({ Component, pageProps }: AppProps): React.ReactElem
} }
// Listen to route changes // Listen to route changes
const router = require('next/router').default Router.events?.on('routeChangeComplete', handleRouteChange)
router.events?.on('routeChangeComplete', handleRouteChange)
return () => { return () => {
router.events?.off('routeChangeComplete', handleRouteChange) Router.events?.off('routeChangeComplete', handleRouteChange)
void swClient.stopPlatformSync() void swClient.stopPlatformSync()
} }
}, []) }, [])
@ -123,12 +123,11 @@ export default function App({ Component, pageProps }: AppProps): React.ReactElem
void startUserSync() void startUserSync()
// Also listen to connection changes and route changes to sync when user connects or navigates // Also listen to connection changes and route changes to sync when user connects or navigates
const router = require('next/router').default
const handleRouteChange = (): void => { const handleRouteChange = (): void => {
void startUserSync() void startUserSync()
} }
router.events?.on('routeChangeComplete', handleRouteChange) Router.events?.on('routeChangeComplete', handleRouteChange)
const unsubscribe = nostrAuthService.subscribe((state) => { const unsubscribe = nostrAuthService.subscribe((state) => {
if (state.connected && state.pubkey) { if (state.connected && state.pubkey) {
@ -137,7 +136,7 @@ export default function App({ Component, pageProps }: AppProps): React.ReactElem
}) })
return () => { return () => {
router.events?.off('routeChangeComplete', handleRouteChange) Router.events?.off('routeChangeComplete', handleRouteChange)
unsubscribe() unsubscribe()
} }
}, []) }, [])

View File

@ -193,7 +193,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
proxyRequest.on('error', (error) => { proxyRequest.on('error', (error) => {
// Check for DNS errors specifically // Check for DNS errors specifically
const errorCode = (error as NodeJS.ErrnoException).code const errorCode = getErrnoCode(error)
if (errorCode === 'ENOTFOUND' || errorCode === 'EAI_AGAIN') { if (errorCode === 'ENOTFOUND' || errorCode === 'EAI_AGAIN') {
console.error('NIP-95 proxy DNS error:', { console.error('NIP-95 proxy DNS error:', {
targetEndpoint, targetEndpoint,
@ -373,3 +373,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}) })
} }
} }
function getErrnoCode(error: unknown): string | undefined {
if (typeof error !== 'object' || error === null) {
return undefined
}
const maybe = error as { code?: unknown }
return typeof maybe.code === 'string' ? maybe.code : undefined
}

View File

@ -7,12 +7,17 @@ const CACHE_NAME = 'zapwall-sync-v1'
const SYNC_INTERVAL_MS = 60000 // 1 minute const SYNC_INTERVAL_MS = 60000 // 1 minute
const REPUBLISH_INTERVAL_MS = 30000 // 30 seconds const REPUBLISH_INTERVAL_MS = 30000 // 30 seconds
const self = globalThis
const caches = globalThis.caches
const setInterval = globalThis.setInterval
const clearInterval = globalThis.clearInterval
let syncInProgress = false let syncInProgress = false
let publishWorkerInterval = null let publishWorkerInterval = null
let notificationDetectorInterval = null let notificationDetectorInterval = null
// Install event - cache resources // Install event - cache resources
self.addEventListener('install', (event) => { self.addEventListener('install', () => {
console.log('[SW] Service Worker installing') console.log('[SW] Service Worker installing')
self.skipWaiting() // Activate immediately self.skipWaiting() // Activate immediately
}) })

View File

@ -18,6 +18,10 @@ const DB_VERSIONS = {
payment_note: 3, payment_note: 3,
} }
const self = globalThis
const indexedDB = globalThis.indexedDB
const IDBKeyRange = globalThis.IDBKeyRange
// Pile d'écritures pour éviter les conflits // Pile d'écritures pour éviter les conflits
const writeQueue = [] const writeQueue = []
let processingQueue = false let processingQueue = false

View File

@ -0,0 +1,92 @@
from __future__ import annotations
from collections import Counter
from dataclasses import dataclass
from pathlib import Path
import re
import sys
@dataclass(frozen=True)
class RuleCounts:
errors: Counter[str]
warnings: Counter[str]
def _extract_rule_from_line(line: str) -> str | None:
# ESLint flat output lines look like:
# "<file>\n 12:34 error Message... rule-name"
# Only parse "location lines" to avoid counting explanatory multiline blocks.
# Require at least two spaces before the rule token; otherwise we might capture the last
# word of the message (e.g. "renders") for multiline explanatory errors that omit rule ids.
m = re.match(r"^\s*\d+:\d+\s+(error|warning)\s+.+\s{2,}([@\w\-/]+)\s*$", line)
if not m:
return None
return m.group(2)
def parse_eslint_output(path: Path) -> RuleCounts:
errors: Counter[str] = Counter()
warnings: Counter[str] = Counter()
with path.open("r", encoding="utf-8", errors="replace") as f:
for line in f:
rule = _extract_rule_from_line(line)
if not rule:
continue
if " error " in line:
errors[rule] += 1
continue
if " warning " in line:
warnings[rule] += 1
return RuleCounts(errors=errors, warnings=warnings)
def _order_bucket(rule: str) -> int:
# User requested ordering:
# - file size last: max-lines
# - function size before last: max-lines-per-function
if rule == "max-lines":
return 2
if rule == "max-lines-per-function":
return 1
return 0
def print_summary(counts: RuleCounts) -> None:
print("ERRORS by rule:")
for rule, cnt in sorted(
counts.errors.items(),
key=lambda kv: (_order_bucket(kv[0]), -kv[1], kv[0]),
):
print(f"{cnt:4d} {rule}")
print("\nWARNINGS by rule:")
for rule, cnt in sorted(counts.warnings.items(), key=lambda kv: (-kv[1], kv[0])):
print(f"{cnt:4d} {rule}")
total_errors = sum(counts.errors.values())
total_warnings = sum(counts.warnings.values())
print(f"\nTotal errors: {total_errors} | Total warnings: {total_warnings}")
def main(argv: list[str]) -> int:
if len(argv) != 2:
print("Usage: python scripts/lintReportSummary.py <path-to-eslint-output.txt>", file=sys.stderr)
return 2
path = Path(argv[1])
if not path.exists():
print(f"File not found: {path}", file=sys.stderr)
return 2
counts = parse_eslint_output(path)
print_summary(counts)
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))