diff --git a/components/ArticleEditor.tsx b/components/ArticleEditor.tsx
index 49ad3a5..3ed486f 100644
--- a/components/ArticleEditor.tsx
+++ b/components/ArticleEditor.tsx
@@ -12,7 +12,6 @@ interface ArticleEditorProps {
defaultSeriesId?: string
}
-
function SuccessMessage(): React.ReactElement {
return (
@@ -34,7 +33,13 @@ export function ArticleEditor({ onPublishSuccess, onCancel, seriesOptions, onSel
...(defaultSeriesId ? { seriesId: defaultSeriesId } : {}),
})
- const submit = buildSubmitHandler(publishArticle, draft, onPublishSuccess, connect, connected)
+ const submit = buildSubmitHandler({
+ publishArticle,
+ draft,
+ onPublishSuccess,
+ connect,
+ connected,
+ })
if (success) {
return
@@ -58,21 +63,25 @@ export function ArticleEditor({ onPublishSuccess, onCancel, seriesOptions, onSel
)
}
-function buildSubmitHandler(
- publishArticle: (draft: ArticleDraft) => Promise
,
- draft: ArticleDraft,
- onPublishSuccess?: (articleId: string) => void,
- connect?: () => Promise,
+interface SubmitHandlerParams {
+ publishArticle: (draft: ArticleDraft) => Promise
+ draft: ArticleDraft
+ onPublishSuccess?: (articleId: string) => void
+ connect?: () => Promise
connected?: boolean
+}
+
+function buildSubmitHandler(
+ params: SubmitHandlerParams
): () => Promise {
return async (): Promise => {
- if (!connected && connect) {
- await connect()
+ if (!params.connected && params.connect) {
+ await params.connect()
return
}
- const articleId = await publishArticle(draft)
+ const articleId = await params.publishArticle(params.draft)
if (articleId) {
- onPublishSuccess?.(articleId)
+ params.onPublishSuccess?.(articleId)
}
}
}
diff --git a/components/ArticleEditorForm.tsx b/components/ArticleEditorForm.tsx
index ed0d943..c29a6c3 100644
--- a/components/ArticleEditorForm.tsx
+++ b/components/ArticleEditorForm.tsx
@@ -1,15 +1,10 @@
import React from 'react'
import type { ArticleDraft } from '@/lib/articlePublisher'
-import type { ArticleCategory } from '@/types/nostr'
-import { ArticleField } from './ArticleField'
import { ArticleFormButtons } from './ArticleFormButtons'
-import { CategorySelect } from './CategorySelect'
-import { MarkdownEditor } from './MarkdownEditor'
-import { MarkdownEditorTwoColumns } from './MarkdownEditorTwoColumns'
-import type { MediaRef } from '@/types/nostr'
-import { t } from '@/lib/i18n'
-
import type { RelayPublishStatus } from '@/lib/publishResult'
+import { t } from '@/lib/i18n'
+import { ArticleFieldsLeft } from './ArticleEditorFormFieldsLeft'
+import { ArticleFieldsRight } from './ArticleEditorFormFieldsRight'
interface ArticleEditorFormProps {
draft: ArticleDraft
@@ -23,25 +18,6 @@ interface ArticleEditorFormProps {
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
}
-function CategoryField({
- value,
- onChange,
-}: {
- value: ArticleDraft['category']
- onChange: (value: import('@/types/nostr').ArticleCategory | undefined) => void
-}): React.ReactElement {
- return (
-
- )
-}
-
function ErrorAlert({ error }: { error: string | null }): React.ReactElement | null {
if (!error) {
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 => (
-
-
- {seriesOptions && (
-
- )}
-
-
-
-)
-
-function ArticleTitleField({ draft, onDraftChange }: { draft: ArticleDraft; onDraftChange: (draft: ArticleDraft) => void }): React.ReactElement {
- return (
- 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 (
- 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 (
-
-
-
-
- )
-}
-
-function buildSeriesChangeHandler(
- draft: ArticleDraft,
- onDraftChange: (draft: ArticleDraft) => void,
- onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
-): (e: React.ChangeEvent) => void {
- return (e: React.ChangeEvent): 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 (
-
-
-
{t('article.editor.content.label')}
- {useTwoColumns ? (
-
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 })
- }}
- />
- ) : (
- onDraftChange({ ...draft, content: value })}
- onMediaAdd={(media: MediaRef) => {
- const nextMedia = [...(draft.media ?? []), media]
- onDraftChange({ ...draft, media: nextMedia })
- }}
- onBannerChange={(url: string) => {
- onDraftChange({ ...draft, bannerUrl: url })
- }}
- />
- )}
-
- {t('article.editor.content.help')}
-
-
-
onDraftChange({ ...draft, zapAmount: value as number })}
- required
- type="number"
- min={1}
- helpText={t('article.editor.sponsoring.help')}
- />
-
- )
-}
-
export function ArticleEditorForm({
draft,
onDraftChange,
diff --git a/components/ArticleEditorFormFieldsLeft.tsx b/components/ArticleEditorFormFieldsLeft.tsx
new file mode 100644
index 0000000..313f2c7
--- /dev/null
+++ b/components/ArticleEditorFormFieldsLeft.tsx
@@ -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 (
+
+
+ {seriesOptions && (
+
+ )}
+
+
+
+ )
+}
+
+interface CategoryFieldProps {
+ value: ArticleDraft['category']
+ onChange: (value: ArticleCategory | undefined) => void
+}
+
+function CategoryField({ value, onChange }: CategoryFieldProps): React.ReactElement {
+ return (
+
+ )
+}
+
+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 (
+ 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 (
+ 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 (
+
+
+
+
+ )
+}
+
+interface BuildSeriesChangeHandlerParams {
+ draft: ArticleDraft
+ onDraftChange: (draft: ArticleDraft) => void
+ onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
+}
+
+function buildSeriesChangeHandler(
+ params: BuildSeriesChangeHandlerParams
+): (e: React.ChangeEvent) => 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)
+ }
+}
diff --git a/components/ArticleEditorFormFieldsRight.tsx b/components/ArticleEditorFormFieldsRight.tsx
new file mode 100644
index 0000000..be6579d
--- /dev/null
+++ b/components/ArticleEditorFormFieldsRight.tsx
@@ -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 (
+
+
+
+
+ )
+}
+
+function ContentEditorSection({
+ draft,
+ onDraftChange,
+}: {
+ draft: ArticleDraft
+ onDraftChange: (draft: ArticleDraft) => void
+}): React.ReactElement {
+ const useTwoColumns = draft.seriesId !== undefined
+ return (
+
+
{t('article.editor.content.label')}
+
+
{t('article.editor.content.help')}
+
+ )
+}
+
+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 (
+ onDraftChange({ ...draft, pages })}
+ onMediaAdd={onMediaAdd}
+ onBannerChange={onBannerChange}
+ />
+ )
+ }
+
+ return (
+
+ )
+}
+
+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 (
+ onDraftChange({ ...draft, zapAmount: value as number })}
+ required
+ type="number"
+ min={1}
+ helpText={t('article.editor.sponsoring.help')}
+ />
+ )
+}
diff --git a/components/ArticlePages.tsx b/components/ArticlePages.tsx
index 8456c74..26e1f6a 100644
--- a/components/ArticlePages.tsx
+++ b/components/ArticlePages.tsx
@@ -11,54 +11,32 @@ interface ArticlePagesProps {
export function ArticlePages({ pages, articleId }: ArticlePagesProps): React.ReactElement | null {
const { pubkey } = useNostrAuth()
- const [hasPurchased, setHasPurchased] = useState(false)
-
- useEffect(() => {
- const checkPurchase = async (): Promise => {
- 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])
+ const hasPurchased = useHasPurchasedArticle({ pubkey, articleId })
if (!pages || pages.length === 0) {
return null
}
- // If user hasn't purchased, show locked message
- if (!hasPurchased) {
- return (
-
-
{t('article.pages.title')}
-
-
{t('article.pages.locked.title')}
-
{t('article.pages.locked.message', { count: pages.length })}
-
-
- )
+ if (!pubkey || !hasPurchased) {
+ return
}
- // User has purchased, show all pages
+ return
+}
+
+function LockedPagesView({ pagesCount }: { pagesCount: number }): React.ReactElement {
+ return (
+
+
{t('article.pages.title')}
+
+
{t('article.pages.locked.title')}
+
{t('article.pages.locked.message', { count: pagesCount })}
+
+
+ )
+}
+
+function PurchasedPagesView({ pages }: { pages: Page[] }): React.ReactElement {
return (
{t('article.pages.title')}
@@ -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
=> {
+ 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 {
return (
diff --git a/components/ArticleReviews.tsx b/components/ArticleReviews.tsx
index b1fd525..f90a300 100644
--- a/components/ArticleReviews.tsx
+++ b/components/ArticleReviews.tsx
@@ -12,20 +12,66 @@ interface ArticleReviewsProps {
}
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 (
+
+
+ {reviewForm.show && (
+
+ )}
+ {data.loading &&
{t('common.loading')}
}
+ {data.error &&
{data.error}
}
+ {!data.loading && !data.error && data.reviews.length === 0 && !reviewForm.show && (
+
{t('review.empty')}
+ )}
+ {!data.loading && !data.error &&
}
+
+
+ )
+}
+
+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
+}
+
+function useArticleReviewsData({
+ articleId,
+ authorPubkey,
+}: {
+ articleId: string
+ authorPubkey: string
+}): ArticleReviewsData {
const [reviews, setReviews] = useState([])
const [tips, setTips] = useState(0)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
- const [showReviewForm, setShowReviewForm] = useState(false)
- const [selectedReviewForTip, setSelectedReviewForTip] = useState(null)
- const loadReviews = useCallback(async (): Promise => {
+ const reload = useCallback(async (): Promise => {
setLoading(true)
setError(null)
try {
const [list, tipsTotal] = await Promise.all([
- getReviewsForArticle(article.id),
- getReviewTipsForArticle({ authorPubkey, articleId: article.id }),
+ getReviewsForArticle(articleId),
+ getReviewTipsForArticle({ authorPubkey, articleId }),
])
setReviews(list)
setTips(tipsTotal)
@@ -34,57 +80,80 @@ export function ArticleReviews({ article, authorPubkey }: ArticleReviewsProps):
} finally {
setLoading(false)
}
- }, [article.id, authorPubkey])
+ }, [articleId, authorPubkey])
useEffect(() => {
- void loadReviews()
- }, [loadReviews])
+ void reload()
+ }, [reload])
+
+ return {
+ reviews,
+ tips,
+ loading,
+ error,
+ reload,
+ }
+}
+
+function useReviewFormState({ reload }: { reload: () => Promise }): {
+ show: boolean
+ open: () => void
+ close: () => void
+ onSuccess: () => void
+} {
+ const [show, setShow] = useState(false)
+ const open = useCallback((): void => setShow(true), [])
+ const close = useCallback((): void => setShow(false), [])
+
+ const onSuccess = useCallback((): void => {
+ close()
+ void reload()
+ }, [close, reload])
+
+ return { show, open, close, onSuccess }
+}
+
+function useReviewTipSelection({
+ article,
+ reviews,
+ reload,
+}: {
+ article: Article
+ reviews: Review[]
+ reload: () => Promise
+}): ReviewTipSelectionController {
+ const [selectedReviewId, setSelectedReviewId] = useState(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 (
-
-
{
- setShowReviewForm(true)
- }} />
- {showReviewForm && (
- {
- setShowReviewForm(false)
- void loadReviews()
- }}
- onCancel={() => {
- setShowReviewForm(false)
- }}
- />
- )}
- {loading && {t('common.loading')}
}
- {error && {error}
}
- {!loading && !error && reviews.length === 0 && !showReviewForm && (
- {t('review.empty')}
- )}
- {!loading && !error && {
- setSelectedReviewForTip(reviewId)
- }} />}
- {selectedReviewForTip && (() => {
- const review = reviews.find((r) => r.id === selectedReviewForTip)
- if (!review) {
- return null
- }
- return (
- {
- setSelectedReviewForTip(null)
- void loadReviews()
- }}
- onCancel={() => {
- setSelectedReviewForTip(null)
- }}
- />
- )
- })()}
-
+
)
}
diff --git a/components/AuthorFilterButton.tsx b/components/AuthorFilterButton.tsx
index 8526300..e63c8c1 100644
--- a/components/AuthorFilterButton.tsx
+++ b/components/AuthorFilterButton.tsx
@@ -5,7 +5,7 @@ export function AuthorMnemonicIcons({ value, getMnemonicIcons }: { value: string
return (
{getMnemonicIcons(value).map((icon, idx) => (
-
+
{icon}
))}
diff --git a/components/AuthorFilterDropdown.tsx b/components/AuthorFilterDropdown.tsx
index 6943249..1479f82 100644
--- a/components/AuthorFilterDropdown.tsx
+++ b/components/AuthorFilterDropdown.tsx
@@ -47,7 +47,7 @@ export function AuthorOption({
{displayName}
{mnemonicIcons.map((icon, idx) => (
-
+
{icon}
))}
@@ -83,22 +83,24 @@ export function AllAuthorsOption({
)
}
-export function createAuthorOptionProps(
- pubkey: string,
- value: string | null,
- getDisplayName: (pubkey: string) => string,
- getPicture: (pubkey: string) => string | undefined,
- getMnemonicIcons: (pubkey: string) => string[],
- onChange: (value: string | null) => void,
+interface CreateAuthorOptionPropsParams {
+ pubkey: string
+ value: string | null
+ getDisplayName: (pubkey: string) => string
+ getPicture: (pubkey: string) => string | undefined
+ getMnemonicIcons: (pubkey: string) => string[]
+ onChange: (value: string | null) => void
setIsOpen: (open: boolean) => void
-): {
+}
+
+export function createAuthorOptionProps(params: CreateAuthorOptionPropsParams): {
displayName: string
mnemonicIcons: string[]
isSelected: boolean
onSelect: () => void
picture?: string
} {
- const pictureValue = getPicture(pubkey)
+ const pictureValue = params.getPicture(params.pubkey)
const optionProps: {
displayName: string
mnemonicIcons: string[]
@@ -106,12 +108,12 @@ export function createAuthorOptionProps(
onSelect: () => void
picture?: string
} = {
- displayName: getDisplayName(pubkey),
- mnemonicIcons: getMnemonicIcons(pubkey),
- isSelected: value === pubkey,
+ displayName: params.getDisplayName(params.pubkey),
+ mnemonicIcons: params.getMnemonicIcons(params.pubkey),
+ isSelected: params.value === params.pubkey,
onSelect: () => {
- onChange(pubkey)
- setIsOpen(false)
+ params.onChange(params.pubkey)
+ params.setIsOpen(false)
},
}
if (pictureValue !== undefined) {
@@ -142,7 +144,15 @@ export function AuthorList({
{authors.map((pubkey) => (
))}
>
diff --git a/components/ConnectButton.tsx b/components/ConnectButton.tsx
index 659a201..27e36d6 100644
--- a/components/ConnectButton.tsx
+++ b/components/ConnectButton.tsx
@@ -1,10 +1,11 @@
-import { useState, useEffect } from 'react'
import { useNostrAuth } from '@/hooks/useNostrAuth'
import { ConnectedUserMenu } from './ConnectedUserMenu'
import { RecoveryStep } from './CreateAccountModalSteps'
import { UnlockAccountModal } from './UnlockAccountModal'
import type { NostrProfile } from '@/types/nostr'
import { t } from '@/lib/i18n'
+import { getConnectButtonMode } from './connectButton/connectButtonMode'
+import { useAutoConnect, useConnectButtonUiState } from './connectButton/useConnectButtonUiState'
function ConnectForm({
onCreateAccount,
@@ -38,14 +39,6 @@ function ConnectForm({
)
}
-function useAutoConnect(accountExists: boolean | null, pubkey: string | null, showRecoveryStep: boolean, showUnlockModal: boolean, connect: () => Promise): 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 }): React.ReactElement {
return (
{}}
+ onCreateAccount={noop}
onUnlock={onUnlock}
loading={loading}
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({
loading,
@@ -107,79 +104,45 @@ function DisconnectedState({
export function ConnectButton(): React.ReactElement {
const { connected, pubkey, profile, loading, error, connect, disconnect, accountExists, isUnlocked } = useNostrAuth()
- const [showRecoveryStep, setShowRecoveryStep] = useState(false)
- const [showUnlockModal, setShowUnlockModal] = useState(false)
- const [recoveryPhrase, setRecoveryPhrase] = useState([])
- const [npub, setNpub] = useState('')
- const [creatingAccount, setCreatingAccount] = useState(false)
- const [createError, setCreateError] = useState(null)
+ const ui = useConnectButtonUiState()
- useAutoConnect(accountExists, pubkey, false, showUnlockModal, connect)
+ useAutoConnect({
+ accountExists,
+ pubkey,
+ showRecoveryStep: ui.showRecoveryStep,
+ showUnlockModal: ui.showUnlockModal,
+ connect,
+ })
- const handleCreateAccount = async (): Promise => {
- setCreatingAccount(true)
- setCreateError(null)
- try {
- const { nostrAuthService } = await import('@/lib/nostrAuth')
- const result = await nostrAuthService.createAccount()
- setRecoveryPhrase(result.recoveryPhrase)
- setNpub(result.npub)
- setShowRecoveryStep(true)
- } catch (e) {
- setCreateError(e instanceof Error ? e.message : 'Failed to create account')
- } finally {
- setCreatingAccount(false)
- }
- }
+ const mode = getConnectButtonMode({
+ connected,
+ pubkey,
+ isUnlocked,
+ accountExists,
+ showUnlockModal: ui.showUnlockModal,
+ })
- const handleRecoveryContinue = (): void => {
- setShowRecoveryStep(false)
- setShowUnlockModal(true)
- }
-
- const handleUnlockSuccess = (): void => {
- setShowUnlockModal(false)
- setRecoveryPhrase([])
- setNpub('')
- }
-
- if (connected && pubkey && isUnlocked) {
+ if (mode === 'connected') {
return
}
- if (accountExists === true && pubkey && !isUnlocked && !showUnlockModal) {
- return (
- setShowUnlockModal(true)}
- onClose={() => setShowUnlockModal(false)}
- />
- )
+ if (mode === 'unlock_required') {
+ return
}
return (
<>
{ void handleCreateAccount() }}
+ loading={loading ?? ui.creatingAccount}
+ error={error ?? ui.createError}
+ showUnlockModal={ui.showUnlockModal}
+ setShowUnlockModal={ui.setShowUnlockModal}
+ onCreateAccount={ui.onCreateAccount}
/>
- {showRecoveryStep && (
-
- )}
- {showUnlockModal && (
- setShowUnlockModal(false)}
- />
+ {ui.showRecoveryStep && (
+
)}
+ {ui.showUnlockModal && }
>
)
}
diff --git a/components/CreateAccountModal.tsx b/components/CreateAccountModal.tsx
index 087707f..33c7fc9 100644
--- a/components/CreateAccountModal.tsx
+++ b/components/CreateAccountModal.tsx
@@ -14,35 +14,37 @@ async function createAccountWithKey(key?: string): Promise<{ recoveryPhrase: str
return nostrAuthService.createAccount(key)
}
-async function handleAccountCreation(
- key: string | undefined,
- setLoading: (loading: boolean) => void,
- setError: (error: string | null) => void,
- setRecoveryPhrase: (phrase: string[]) => void,
- setNpub: (npub: string) => void,
- setStep: (step: Step) => void,
+interface HandleAccountCreationParams {
+ key: string | undefined
+ setLoading: (loading: boolean) => void
+ setError: (error: string | null) => void
+ setRecoveryPhrase: (phrase: string[]) => void
+ setNpub: (npub: string) => void
+ setStep: (step: Step) => void
errorMessage: string
-): Promise {
- if (key !== undefined && !key.trim()) {
- setError('Please enter a private key')
+}
+
+async function handleAccountCreation(params: HandleAccountCreationParams): Promise {
+ if (params.key !== undefined && !params.key.trim()) {
+ params.setError('Please enter a private key')
return
}
- setLoading(true)
- setError(null)
+ params.setLoading(true)
+ params.setError(null)
try {
- const result = await createAccountWithKey(key?.trim())
- setRecoveryPhrase(result.recoveryPhrase)
- setNpub(result.npub)
- setStep('recovery')
+ const result = await createAccountWithKey(params.key?.trim())
+ params.setRecoveryPhrase(result.recoveryPhrase)
+ params.setNpub(result.npub)
+ params.setStep('recovery')
} catch (e) {
- setError(e instanceof Error ? e.message : errorMessage)
+ params.setError(e instanceof Error ? e.message : params.errorMessage)
} finally {
- setLoading(false)
+ params.setLoading(false)
}
}
-function useAccountCreation(initialStep: Step = 'choose'): {
+interface AccountCreationState {
step: Step
setStep: (step: Step) => void
importKey: string
@@ -54,7 +56,9 @@ function useAccountCreation(initialStep: Step = 'choose'): {
npub: string
handleGenerate: () => Promise
handleImport: () => Promise
-} {
+}
+
+function useAccountCreation(initialStep: Step = 'choose'): AccountCreationState {
const [step, setStep] = useState(initialStep)
const [importKey, setImportKey] = useState('')
const [loading, setLoading] = useState(false)
@@ -62,27 +66,29 @@ function useAccountCreation(initialStep: Step = 'choose'): {
const [recoveryPhrase, setRecoveryPhrase] = useState([])
const [npub, setNpub] = useState('')
- const handleGenerate = async (): Promise => {
- await handleAccountCreation(undefined, setLoading, setError, setRecoveryPhrase, setNpub, setStep, 'Failed to create account')
- }
+ const handleGenerate = (): Promise =>
+ handleAccountCreation({
+ key: undefined,
+ setLoading,
+ setError,
+ setRecoveryPhrase,
+ setNpub,
+ setStep,
+ errorMessage: 'Failed to create account',
+ })
- const handleImport = async (): Promise => {
- await handleAccountCreation(importKey, setLoading, setError, setRecoveryPhrase, setNpub, setStep, 'Failed to import key')
- }
+ const handleImport = (): Promise =>
+ 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,
- }
+ 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 {
@@ -91,45 +97,47 @@ function handleImportBack(setStep: (step: Step) => void, setError: (error: strin
setImportKey('')
}
-function renderStep(
- step: Step,
- recoveryPhrase: string[],
- npub: string,
- importKey: string,
- setImportKey: (key: string) => void,
- loading: boolean,
- error: string | null,
- handleContinue: () => void,
- handleImport: () => void,
- setStep: (step: Step) => void,
- setError: (error: string | null) => void,
- handleGenerate: () => void,
+interface RenderStepParams {
+ step: Step
+ recoveryPhrase: string[]
+ npub: string
+ importKey: string
+ setImportKey: (key: string) => void
+ loading: boolean
+ error: string | null
+ handleContinue: () => void
+ handleImport: () => void
+ setStep: (step: Step) => void
+ setError: (error: string | null) => void
+ handleGenerate: () => void
onClose: () => void
-): React.ReactElement {
- if (step === 'recovery') {
- return
+}
+
+function renderStep(params: RenderStepParams): React.ReactElement {
+ if (params.step === 'recovery') {
+ return
}
- if (step === 'import') {
+ if (params.step === 'import') {
return (
handleImportBack(setStep, setError, setImportKey)}
+ importKey={params.importKey}
+ setImportKey={params.setImportKey}
+ loading={params.loading}
+ error={params.error}
+ onImport={params.handleImport}
+ onBack={() => handleImportBack(params.setStep, params.setError, params.setImportKey)}
/>
)
}
return (
setStep('import')}
- onClose={onClose}
+ loading={params.loading}
+ error={params.error}
+ onGenerate={params.handleGenerate}
+ onImport={() => params.setStep('import')}
+ onClose={params.onClose}
/>
)
}
@@ -154,7 +162,7 @@ export function CreateAccountModal({ onSuccess, onClose, initialStep = 'choose'
onClose()
}
- return renderStep(
+ return renderStep({
step,
recoveryPhrase,
npub,
@@ -163,10 +171,14 @@ export function CreateAccountModal({ onSuccess, onClose, initialStep = 'choose'
loading,
error,
handleContinue,
- () => { void handleImport() },
+ handleImport: () => {
+ void handleImport()
+ },
setStep,
setError,
- () => { void handleGenerate() },
- onClose
- )
+ handleGenerate: () => {
+ void handleGenerate()
+ },
+ onClose,
+ })
}
diff --git a/components/CreateAccountModalComponents.tsx b/components/CreateAccountModalComponents.tsx
index 978c18b..2798387 100644
--- a/components/CreateAccountModalComponents.tsx
+++ b/components/CreateAccountModalComponents.tsx
@@ -20,16 +20,17 @@ export function RecoveryPhraseDisplay({
copied: boolean
onCopy: () => void
}): React.ReactElement {
+ const recoveryItems = buildRecoveryPhraseItems(recoveryPhrase)
return (
- {recoveryPhrase.map((word, index) => (
+ {recoveryItems.map((item, index) => (
{index + 1}.
- {word}
+ {item.word}
))}
@@ -45,6 +46,15 @@ export function RecoveryPhraseDisplay({
)
}
+function buildRecoveryPhraseItems(recoveryPhrase: string[]): { key: string; word: string }[] {
+ const counts = new Map
()
+ 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 {
return (
diff --git a/components/FundingGauge.tsx b/components/FundingGauge.tsx
index 007915c..5649589 100644
--- a/components/FundingGauge.tsx
+++ b/components/FundingGauge.tsx
@@ -42,18 +42,52 @@ function FundingStats({ stats }: { stats: ReturnType
+ }
+
+ return (
+
+
+
+ {t('home.funding.title')} - {t('home.funding.priority.ia')}
+
+
+
+
+
+ {t('home.funding.certification.title')} - {t('home.funding.priority.ancrage')}
+
+
+
+
+ )
+}
+
+function FundingGaugeLoading(): React.ReactElement {
+ return (
+
+
{t('common.loading')}
+
+ )
+}
+
+function useFundingGaugeState(): {
+ stats: ReturnType
+ certificationStats: ReturnType
+ loading: boolean
+} {
const [stats, setStats] = useState(estimatePlatformFunds())
const [certificationStats, setCertificationStats] = useState(estimatePlatformFunds())
const [loading, setLoading] = useState(true)
useEffect(() => {
- // In a real implementation, this would fetch actual data
- // For now, we use the estimate
const loadStats = async (): Promise => {
try {
const fundingStats = estimatePlatformFunds()
setStats(fundingStats)
- // Certification uses the same funding pool
setCertificationStats(fundingStats)
} catch (e) {
console.error('Error loading funding stats:', e)
@@ -64,28 +98,5 @@ export function FundingGauge(): React.ReactElement {
void loadStats()
}, [])
- if (loading) {
- return (
-
-
{t('common.loading')}
-
- )
- }
-
- return (
-
-
-
- {t('home.funding.title')} - {t('home.funding.priority.ia')}
-
-
-
-
-
- {t('home.funding.certification.title')} - {t('home.funding.priority.ancrage')}
-
-
-
-
- )
+ return { stats, certificationStats, loading }
}
diff --git a/components/GlobalSyncProgressBar.tsx b/components/GlobalSyncProgressBar.tsx
index 7b22480..e322a6c 100644
--- a/components/GlobalSyncProgressBar.tsx
+++ b/components/GlobalSyncProgressBar.tsx
@@ -46,19 +46,13 @@ function SyncIcon(): React.ReactElement {
* Shows sync icon + relay name when syncing
*/
export function SyncStatus(): React.ReactElement | null {
- const [progress, setProgress] = useState(null)
+ const [progress, setProgress] = useState(() => syncProgressManager.getProgress())
useEffect(() => {
const unsubscribe = syncProgressManager.subscribe((newProgress) => {
setProgress(newProgress)
})
- // Check current progress immediately
- const currentProgress = syncProgressManager.getProgress()
- if (currentProgress) {
- setProgress(currentProgress)
- }
-
return () => {
unsubscribe()
}
diff --git a/components/connectButton/connectButtonMode.ts b/components/connectButton/connectButtonMode.ts
new file mode 100644
index 0000000..e185205
--- /dev/null
+++ b/components/connectButton/connectButtonMode.ts
@@ -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'
+}
diff --git a/components/connectButton/useConnectButtonUiState.ts b/components/connectButton/useConnectButtonUiState.ts
new file mode 100644
index 0000000..801348e
--- /dev/null
+++ b/components/connectButton/useConnectButtonUiState.ts
@@ -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 {
+ 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(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([])
+ 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 {
+ 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)
+ }
+}
diff --git a/hooks/useDocs.ts b/hooks/useDocs.ts
index e8c7cde..3f1ed3b 100644
--- a/hooks/useDocs.ts
+++ b/hooks/useDocs.ts
@@ -21,7 +21,9 @@ export function useDocs(docs: DocLink[]): {
const loadDoc = useCallback(async (docId: DocSection): Promise => {
const doc = docs.find((d) => d.id === docId)
- if (!doc) {return}
+ if (!doc) {
+ return
+ }
setLoading(true)
setSelectedDoc(docId)
@@ -29,7 +31,7 @@ export function useDocs(docs: DocLink[]): {
try {
// Get current locale and pass it to the API
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) {
const text = await response.text()
setDocContent(text)
diff --git a/hooks/useI18n.ts b/hooks/useI18n.ts
index 28c9383..35d6a38 100644
--- a/hooks/useI18n.ts
+++ b/hooks/useI18n.ts
@@ -26,8 +26,8 @@ export function useI18n(locale: Locale = 'fr'): {
const initialLocale = savedLocale && (savedLocale === 'fr' || savedLocale === 'en') ? savedLocale : locale
// Load translations from files in public directory
- const frResponse = await fetch('/locales/fr.txt')
- const enResponse = await fetch('/locales/en.txt')
+ const frResponse = await globalThis.fetch('/locales/fr.txt')
+ const enResponse = await globalThis.fetch('/locales/en.txt')
if (frResponse.ok) {
const frText = await frResponse.text()
diff --git a/lib/articleEncryption.ts b/lib/articleEncryption.ts
index 9484b2b..1983bbc 100644
--- a/lib/articleEncryption.ts
+++ b/lib/articleEncryption.ts
@@ -20,7 +20,7 @@ export interface DecryptionKey {
* Generate a random encryption key for AES-GCM
*/
function generateEncryptionKey(): string {
- const keyBytes = crypto.getRandomValues(new Uint8Array(32))
+ const keyBytes = globalThis.crypto.getRandomValues(new Uint8Array(32))
return Array.from(keyBytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
@@ -30,7 +30,7 @@ function generateEncryptionKey(): string {
* Generate a random IV for AES-GCM
*/
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 {
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 } {
@@ -85,7 +85,7 @@ export async function encryptArticleContent(content: string): Promise<{
const encodedContent = encoder.encode(content)
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 },
cryptoKey,
encodedContent
@@ -109,7 +109,7 @@ export async function decryptArticleContent(
const keyBuffer = hexToArrayBuffer(key)
const ivBuffer = hexToArrayBuffer(iv)
- const cryptoKey = await crypto.subtle.importKey(
+ const cryptoKey = await globalThis.crypto.subtle.importKey(
'raw',
keyBuffer,
{ name: 'AES-GCM' },
@@ -119,7 +119,7 @@ export async function decryptArticleContent(
const encryptedBuffer = hexToArrayBuffer(encryptedContent)
- const decryptedBuffer = await crypto.subtle.decrypt(
+ const decryptedBuffer = await globalThis.crypto.subtle.decrypt(
{
name: 'AES-GCM',
iv: ivBuffer,
diff --git a/lib/articlePublisherHelpersPresentation.ts b/lib/articlePublisherHelpersPresentation.ts
index 5f7507e..a741249 100644
--- a/lib/articlePublisherHelpersPresentation.ts
+++ b/lib/articlePublisherHelpersPresentation.ts
@@ -2,6 +2,7 @@ import { type Event } from 'nostr-tools'
import { nip19 } from 'nostr-tools'
import type { AuthorPresentationDraft } from './articlePublisher'
import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
+import { createSubscription } from '@/types/nostr-tools-extended'
import { buildTags, extractTagsFromEvent, buildTagFilter } from './nostrTagSystem'
import { getPrimaryRelaySync } from './config'
import { PLATFORM_SERVICE, MIN_EVENT_DATE } from './platformConfig'
@@ -297,7 +298,6 @@ export async function fetchAuthorPresentationFromPool(
return new Promise((resolve) => {
let resolved = false
const relayUrl = getPrimaryRelaySync()
- const { createSubscription } = require('@/types/nostr-tools-extended')
const sub = createSubscription(pool, [relayUrl], filters)
const events: Event[] = []
diff --git a/lib/articleStorage.ts b/lib/articleStorage.ts
index d5de93f..3c522b9 100644
--- a/lib/articleStorage.ts
+++ b/lib/articleStorage.ts
@@ -28,12 +28,12 @@ async function getOrCreateMasterKey(): Promise {
if (existing) {
return existing
}
- const keyBytes = crypto.getRandomValues(new Uint8Array(32))
+ const keyBytes = globalThis.crypto.getRandomValues(new Uint8Array(32))
let binary = ''
keyBytes.forEach((b) => {
binary += String.fromCharCode(b)
})
- const key = btoa(binary)
+ const key = globalThis.btoa(binary)
await storageService.set(MASTER_KEY_STORAGE_KEY, key, 'article_storage')
return key
}
diff --git a/lib/configStorage.ts b/lib/configStorage.ts
index 2d2a085..3c83746 100644
--- a/lib/configStorage.ts
+++ b/lib/configStorage.ts
@@ -60,7 +60,7 @@ export class ConfigStorage {
return
}
- const request = indexedDB.open(DB_NAME, DB_VERSION)
+ const request = globalThis.indexedDB.open(DB_NAME, DB_VERSION)
request.onerror = () => {
reject(new Error(`Failed to open IndexedDB: ${request.error}`))
diff --git a/lib/hashIdGenerator.ts b/lib/hashIdGenerator.ts
index a13cf41..4ce8828 100644
--- a/lib/hashIdGenerator.ts
+++ b/lib/hashIdGenerator.ts
@@ -38,7 +38,7 @@ export async function generateHashId(obj: Record): Promise b.toString(16).padStart(2, '0')).join('')
}
diff --git a/lib/hooks/useSyncProgress.ts b/lib/hooks/useSyncProgress.ts
index 850b2e1..ce407d0 100644
--- a/lib/hooks/useSyncProgress.ts
+++ b/lib/hooks/useSyncProgress.ts
@@ -27,8 +27,8 @@ export function useSyncProgress(options: UseSyncProgressOptions = {}): UseSyncPr
const [syncProgress, setSyncProgress] = useState(null)
const [isSyncing, setIsSyncing] = useState(false)
- const intervalRef = useRef(null)
- const timeoutRef = useRef(null)
+ const intervalRef = useRef | null>(null)
+ const timeoutRef = useRef | null>(null)
const onCompleteRef = useRef(onComplete)
const isMonitoringRef = useRef(false)
@@ -105,4 +105,3 @@ export function useSyncProgress(options: UseSyncProgressOptions = {}): UseSyncPr
stopMonitoring,
}
}
-
diff --git a/lib/keyManagementBIP39.ts b/lib/keyManagementBIP39.ts
index 418ca62..6247ce8 100644
--- a/lib/keyManagementBIP39.ts
+++ b/lib/keyManagementBIP39.ts
@@ -217,7 +217,7 @@ export const BIP39_WORDLIST = [
*/
export function generateRecoveryPhrase(): 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) {
const randomValue = random[i]
diff --git a/lib/keyManagementTwoLevel.ts b/lib/keyManagementTwoLevel.ts
index 5b43c24..65fdad1 100644
--- a/lib/keyManagementTwoLevel.ts
+++ b/lib/keyManagementTwoLevel.ts
@@ -19,7 +19,7 @@ const PBKDF2_HASH = 'SHA-256'
* Generate a random KEK (Key Encryption Key)
*/
async function generateKEK(): Promise {
- return crypto.subtle.generateKey(
+ return globalThis.crypto.subtle.generateKey(
{ name: 'AES-GCM', length: 256 },
true, // extractable
['encrypt', 'decrypt']
@@ -35,12 +35,12 @@ async function deriveKeyFromPhrase(phrase: string[]): Promise {
const password = encoder.encode(phraseString)
// 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 salt = saltArray.slice(0, 32)
// Import password as key material
- const keyMaterial = await crypto.subtle.importKey(
+ const keyMaterial = await globalThis.crypto.subtle.importKey(
'raw',
password,
'PBKDF2',
@@ -49,7 +49,7 @@ async function deriveKeyFromPhrase(phrase: string[]): Promise {
)
// Derive key using PBKDF2
- const derivedKey = await crypto.subtle.deriveKey(
+ const derivedKey = await globalThis.crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt,
@@ -69,7 +69,7 @@ async function deriveKeyFromPhrase(phrase: string[]): Promise {
* Export KEK to raw bytes (for storage)
*/
async function exportKEK(kek: CryptoKey): Promise {
- const exported = await crypto.subtle.exportKey('raw', kek)
+ const exported = await globalThis.crypto.subtle.exportKey('raw', kek)
return new Uint8Array(exported)
}
@@ -81,7 +81,7 @@ async function importKEK(keyBytes: Uint8Array): Promise {
const buffer = new ArrayBuffer(keyBytes.length)
const view = new Uint8Array(buffer)
view.set(keyBytes)
- return crypto.subtle.importKey(
+ return globalThis.crypto.subtle.importKey(
'raw',
buffer,
{ name: 'AES-GCM' },
@@ -99,9 +99,9 @@ async function encryptKEK(kek: CryptoKey, recoveryPhrase: string[]): Promise 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 },
phraseKey,
data
@@ -114,7 +114,7 @@ async function encryptKEK(kek: CryptoKey, recoveryPhrase: string[]): Promise {
binary += String.fromCharCode(b)
})
- return btoa(binary)
+ return globalThis.btoa(binary)
}
return {
@@ -130,7 +130,7 @@ async function decryptKEK(encryptedKEK: EncryptedPayload, recoveryPhrase: string
const phraseKey = await deriveKeyFromPhrase(recoveryPhrase)
function fromBase64(value: string): Uint8Array {
- const binary = atob(value)
+ const binary = globalThis.atob(value)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i)
@@ -150,7 +150,7 @@ async function decryptKEK(encryptedKEK: EncryptedPayload, recoveryPhrase: string
const cipherView = new Uint8Array(cipherBuffer)
cipherView.set(ciphertext)
- const decrypted = await crypto.subtle.decrypt(
+ const decrypted = await globalThis.crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: ivView },
phraseKey,
cipherBuffer
@@ -171,9 +171,9 @@ async function decryptKEK(encryptedKEK: EncryptedPayload, recoveryPhrase: string
async function encryptPrivateKeyWithKEK(privateKey: string, kek: CryptoKey): Promise {
const encoder = new TextEncoder()
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 },
kek,
data
@@ -186,7 +186,7 @@ async function encryptPrivateKeyWithKEK(privateKey: string, kek: CryptoKey): Pro
bytes.forEach((b) => {
binary += String.fromCharCode(b)
})
- return btoa(binary)
+ return globalThis.btoa(binary)
}
return {
@@ -200,7 +200,7 @@ async function encryptPrivateKeyWithKEK(privateKey: string, kek: CryptoKey): Pro
*/
async function decryptPrivateKeyWithKEK(encryptedPrivateKey: EncryptedPayload, kek: CryptoKey): Promise {
function fromBase64(value: string): Uint8Array {
- const binary = atob(value)
+ const binary = globalThis.atob(value)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i)
@@ -220,7 +220,7 @@ async function decryptPrivateKeyWithKEK(encryptedPrivateKey: EncryptedPayload, k
const cipherView = new Uint8Array(cipherBuffer)
cipherView.set(ciphertext)
- const decrypted = await crypto.subtle.decrypt(
+ const decrypted = await globalThis.crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: ivView },
kek,
cipherBuffer
diff --git a/lib/mempoolSpaceApi.ts b/lib/mempoolSpaceApi.ts
index e17e8c4..b12ab05 100644
--- a/lib/mempoolSpaceApi.ts
+++ b/lib/mempoolSpaceApi.ts
@@ -4,7 +4,7 @@ const MEMPOOL_API_BASE = 'https://mempool.space/api'
export async function getTransaction(txid: string): Promise {
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.status === 404) {
@@ -28,7 +28,7 @@ export async function getTransaction(txid: string): Promise {
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) {
return 0
}
diff --git a/lib/nip95.ts b/lib/nip95.ts
index dc7eff3..34627a8 100644
--- a/lib/nip95.ts
+++ b/lib/nip95.ts
@@ -77,7 +77,7 @@ function parseUploadResponse(result: unknown, endpoint: string): string {
async function tryUploadEndpoint(endpoint: string, formData: FormData, useProxy: boolean = false): Promise {
const targetUrl = useProxy ? endpoint : endpoint
- const response = await fetch(targetUrl, {
+ const response = await globalThis.fetch(targetUrl, {
method: 'POST',
body: formData,
// Don't set Content-Type manually - browser will set it with boundary automatically
diff --git a/lib/nip98.ts b/lib/nip98.ts
index d859e66..5ae2e00 100644
--- a/lib/nip98.ts
+++ b/lib/nip98.ts
@@ -63,7 +63,7 @@ export async function generateNip98Token(method: string, url: string, payloadHas
// Encode event as base64 JSON
const eventJson = JSON.stringify(signedEvent)
const eventBytes = new TextEncoder().encode(eventJson)
- const base64Token = btoa(String.fromCharCode(...eventBytes))
+ const base64Token = globalThis.btoa(String.fromCharCode(...eventBytes))
return base64Token
}
diff --git a/lib/nostrPrivateMessages.ts b/lib/nostrPrivateMessages.ts
index 519f0bb..9a3e8c4 100644
--- a/lib/nostrPrivateMessages.ts
+++ b/lib/nostrPrivateMessages.ts
@@ -2,6 +2,7 @@ import { Event, nip04 } from 'nostr-tools'
import { SimplePool } from 'nostr-tools'
import { decryptArticleContent, type DecryptionKey } from './articleEncryption'
import { getPrimaryRelaySync } from './config'
+import { createSubscription } from '@/types/nostr-tools-extended'
function createPrivateMessageFilters(eventId: string, publicKey: string, authorPubkey: string): Array<{
kinds: number[]
@@ -45,7 +46,6 @@ export function getPrivateContent(
return new Promise((resolve) => {
let resolved = false
const relayUrl = getPrimaryRelaySync()
- const { createSubscription } = require('@/types/nostr-tools-extended')
const sub = createSubscription(pool, [relayUrl], createPrivateMessageFilters(eventId, publicKey, authorPubkey))
const finalize = (result: string | null): void => {
@@ -122,7 +122,6 @@ export async function getDecryptionKey(
return new Promise((resolve) => {
let resolved = false
const relayUrl = getPrimaryRelaySync()
- const { createSubscription } = require('@/types/nostr-tools-extended')
const sub = createSubscription(pool, [relayUrl], createPrivateMessageFilters(eventId, recipientPublicKey, authorPubkey))
const finalize = (result: DecryptionKey | null): void => {
diff --git a/lib/objectCache.ts b/lib/objectCache.ts
index d01eed4..7bee63e 100644
--- a/lib/objectCache.ts
+++ b/lib/objectCache.ts
@@ -178,14 +178,19 @@ class ObjectCacheService {
// Notify about published status change (false -> array of relays)
if (oldPublished === false && Array.isArray(published) && published.length > 0) {
const eventId = id.split(':')[1] ?? id
- const { notificationDetector } = require('./notificationDetector')
- void notificationDetector.checkObjectChange({
- objectType,
- objectId: id,
- eventId,
- oldPublished,
- newPublished: published,
- })
+ void import('./notificationDetector')
+ .then(({ notificationDetector }) => {
+ void notificationDetector.checkObjectChange({
+ objectType,
+ objectId: id,
+ eventId,
+ oldPublished,
+ newPublished: published,
+ })
+ })
+ .catch((error) => {
+ console.error('Failed to notify published status change:', error)
+ })
}
} catch (updateError) {
console.error(`Error updating published status for ${objectType} object:`, updateError)
diff --git a/lib/paymentPollingZapReceipt.ts b/lib/paymentPollingZapReceipt.ts
index 3bb92f3..6f51df8 100644
--- a/lib/paymentPollingZapReceipt.ts
+++ b/lib/paymentPollingZapReceipt.ts
@@ -1,6 +1,7 @@
import { nostrService } from './nostr'
import { getPrimaryRelaySync } from './config'
import type { Event } from 'nostr-tools'
+import { createSubscription } from '@/types/nostr-tools-extended'
export function parseZapAmount(event: import('nostr-tools').Event): number {
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 { createSubscription } = require('@/types/nostr-tools-extended')
return createSubscription(pool, [relayUrl], filters)
}
diff --git a/lib/platformSync.ts b/lib/platformSync.ts
index 4db8834..811e2d6 100644
--- a/lib/platformSync.ts
+++ b/lib/platformSync.ts
@@ -6,6 +6,7 @@
import type { Event } from 'nostr-tools'
import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
+import { createSubscription } from '@/types/nostr-tools-extended'
import { nostrService } from './nostr'
import { PLATFORM_SERVICE, MIN_EVENT_DATE } from './platformConfig'
import { extractTagsFromEvent } from './nostrTagSystem'
@@ -109,7 +110,6 @@ class PlatformSyncService {
try {
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 relayEvents: Event[] = []
diff --git a/lib/publishWorker.ts b/lib/publishWorker.ts
index 57fe2d1..f300a48 100644
--- a/lib/publishWorker.ts
+++ b/lib/publishWorker.ts
@@ -22,7 +22,7 @@ interface UnpublishedObject {
class PublishWorkerService {
private isRunning = false
- private intervalId: NodeJS.Timeout | null = null
+ private intervalId: ReturnType | null = null
private unpublishedObjects: Map = new Map()
private processing = false
diff --git a/lib/writeOrchestrator.ts b/lib/writeOrchestrator.ts
index 43365f8..d21d16d 100644
--- a/lib/writeOrchestrator.ts
+++ b/lib/writeOrchestrator.ts
@@ -5,7 +5,7 @@
import { websocketService } from './websocketService'
import { writeService } from './writeService'
-import type { Event, EventTemplate } from 'nostr-tools'
+import type { EventTemplate } from 'nostr-tools'
import { finalizeEvent } from 'nostr-tools'
import { hexToBytes } from 'nostr-tools/utils'
import type { ObjectType } from './objectCache'
@@ -94,7 +94,7 @@ class WriteOrchestrator {
version: number,
hidden: boolean,
index?: number
- ): Promise<{ success: boolean; event: Event; published: false | string[] }> {
+ ): Promise<{ success: boolean; event: NostrEvent; published: false | string[] }> {
if (!this.privateKey) {
throw new Error('Private key not set')
}
@@ -124,7 +124,7 @@ class WriteOrchestrator {
return {
success: result.success,
- event,
+ event: finalizedEvent,
published: result.published,
}
}
diff --git a/lib/zapAggregation.ts b/lib/zapAggregation.ts
index 450abd0..6933835 100644
--- a/lib/zapAggregation.ts
+++ b/lib/zapAggregation.ts
@@ -1,6 +1,7 @@
import type { Event } from 'nostr-tools'
import { nostrService } from './nostr'
import type { Subscription } from '@/types/nostr-tools-extended'
+import { createSubscription } from '@/types/nostr-tools-extended'
import { getPrimaryRelaySync } from './config'
interface ZapAggregationFilter {
@@ -50,7 +51,6 @@ export function aggregateZapSats(params: ZapAggregationFilter): Promise
const filters = buildFilters(params)
const relay = getPrimaryRelaySync()
const timeout = params.timeoutMs ?? 5000
- const { createSubscription } = require('@/types/nostr-tools-extended')
const sub = createSubscription(pool, [relay], filters)
return collectZap(sub, timeout)
diff --git a/pages/_app.tsx b/pages/_app.tsx
index 24d3e94..6632e42 100644
--- a/pages/_app.tsx
+++ b/pages/_app.tsx
@@ -1,5 +1,6 @@
import '@/styles/globals.css'
import type { AppProps } from 'next/app'
+import Router from 'next/router'
import { useI18n } from '@/hooks/useI18n'
import React from 'react'
import { nostrAuthService } from '@/lib/nostrAuth'
@@ -92,11 +93,10 @@ export default function App({ Component, pageProps }: AppProps): React.ReactElem
}
// Listen to route changes
- const router = require('next/router').default
- router.events?.on('routeChangeComplete', handleRouteChange)
+ Router.events?.on('routeChangeComplete', handleRouteChange)
return () => {
- router.events?.off('routeChangeComplete', handleRouteChange)
+ Router.events?.off('routeChangeComplete', handleRouteChange)
void swClient.stopPlatformSync()
}
}, [])
@@ -123,12 +123,11 @@ export default function App({ Component, pageProps }: AppProps): React.ReactElem
void startUserSync()
// Also listen to connection changes and route changes to sync when user connects or navigates
- const router = require('next/router').default
const handleRouteChange = (): void => {
void startUserSync()
}
- router.events?.on('routeChangeComplete', handleRouteChange)
+ Router.events?.on('routeChangeComplete', handleRouteChange)
const unsubscribe = nostrAuthService.subscribe((state) => {
if (state.connected && state.pubkey) {
@@ -137,7 +136,7 @@ export default function App({ Component, pageProps }: AppProps): React.ReactElem
})
return () => {
- router.events?.off('routeChangeComplete', handleRouteChange)
+ Router.events?.off('routeChangeComplete', handleRouteChange)
unsubscribe()
}
}, [])
diff --git a/pages/api/nip95-upload.ts b/pages/api/nip95-upload.ts
index b10e708..a81c349 100644
--- a/pages/api/nip95-upload.ts
+++ b/pages/api/nip95-upload.ts
@@ -193,7 +193,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
proxyRequest.on('error', (error) => {
// Check for DNS errors specifically
- const errorCode = (error as NodeJS.ErrnoException).code
+ const errorCode = getErrnoCode(error)
if (errorCode === 'ENOTFOUND' || errorCode === 'EAI_AGAIN') {
console.error('NIP-95 proxy DNS error:', {
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
+}
diff --git a/public/sw.js b/public/sw.js
index b502631..6b84a85 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -7,12 +7,17 @@ const CACHE_NAME = 'zapwall-sync-v1'
const SYNC_INTERVAL_MS = 60000 // 1 minute
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 publishWorkerInterval = null
let notificationDetectorInterval = null
// Install event - cache resources
-self.addEventListener('install', (event) => {
+self.addEventListener('install', () => {
console.log('[SW] Service Worker installing')
self.skipWaiting() // Activate immediately
})
diff --git a/public/writeWorker.js b/public/writeWorker.js
index cfae019..057f7a9 100644
--- a/public/writeWorker.js
+++ b/public/writeWorker.js
@@ -18,6 +18,10 @@ const DB_VERSIONS = {
payment_note: 3,
}
+const self = globalThis
+const indexedDB = globalThis.indexedDB
+const IDBKeyRange = globalThis.IDBKeyRange
+
// Pile d'écritures pour éviter les conflits
const writeQueue = []
let processingQueue = false
diff --git a/scripts/lintReportSummary.py b/scripts/lintReportSummary.py
new file mode 100644
index 0000000..78c99a2
--- /dev/null
+++ b/scripts/lintReportSummary.py
@@ -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:
+ # "\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 ", 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))