lint fix wip
This commit is contained in:
parent
5694dbdb8a
commit
20a46ce2bc
@ -12,7 +12,6 @@ interface ArticleEditorProps {
|
||||
defaultSeriesId?: string
|
||||
}
|
||||
|
||||
|
||||
function SuccessMessage(): React.ReactElement {
|
||||
return (
|
||||
<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 } : {}),
|
||||
})
|
||||
|
||||
const submit = buildSubmitHandler(publishArticle, draft, onPublishSuccess, connect, connected)
|
||||
const submit = buildSubmitHandler({
|
||||
publishArticle,
|
||||
draft,
|
||||
onPublishSuccess,
|
||||
connect,
|
||||
connected,
|
||||
})
|
||||
|
||||
if (success) {
|
||||
return <SuccessMessage />
|
||||
@ -58,21 +63,25 @@ export function ArticleEditor({ onPublishSuccess, onCancel, seriesOptions, onSel
|
||||
)
|
||||
}
|
||||
|
||||
function buildSubmitHandler(
|
||||
publishArticle: (draft: ArticleDraft) => Promise<string | null>,
|
||||
draft: ArticleDraft,
|
||||
onPublishSuccess?: (articleId: string) => void,
|
||||
connect?: () => Promise<void>,
|
||||
interface SubmitHandlerParams {
|
||||
publishArticle: (draft: ArticleDraft) => Promise<string | null>
|
||||
draft: ArticleDraft
|
||||
onPublishSuccess?: (articleId: string) => void
|
||||
connect?: () => Promise<void>
|
||||
connected?: boolean
|
||||
}
|
||||
|
||||
function buildSubmitHandler(
|
||||
params: SubmitHandlerParams
|
||||
): () => Promise<void> {
|
||||
return async (): Promise<void> => {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
<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 {
|
||||
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 => (
|
||||
<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({
|
||||
draft,
|
||||
onDraftChange,
|
||||
|
||||
180
components/ArticleEditorFormFieldsLeft.tsx
Normal file
180
components/ArticleEditorFormFieldsLeft.tsx
Normal 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)
|
||||
}
|
||||
}
|
||||
118
components/ArticleEditorFormFieldsRight.tsx
Normal file
118
components/ArticleEditorFormFieldsRight.tsx
Normal 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')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -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<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])
|
||||
const hasPurchased = useHasPurchasedArticle({ pubkey, articleId })
|
||||
|
||||
if (!pages || pages.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
// If user hasn't purchased, show locked message
|
||||
if (!hasPurchased) {
|
||||
if (!pubkey || !hasPurchased) {
|
||||
return <LockedPagesView pagesCount={pages.length} />
|
||||
}
|
||||
|
||||
return <PurchasedPagesView pages={pages} />
|
||||
}
|
||||
|
||||
function LockedPagesView({ pagesCount }: { pagesCount: number }): React.ReactElement {
|
||||
return (
|
||||
<div className="space-y-6 mt-6">
|
||||
<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">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// User has purchased, show all pages
|
||||
function PurchasedPagesView({ pages }: { pages: Page[] }): React.ReactElement {
|
||||
return (
|
||||
<div className="space-y-6 mt-6">
|
||||
<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 {
|
||||
return (
|
||||
<div className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark">
|
||||
|
||||
@ -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 (
|
||||
<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 [tips, setTips] = useState<number>(0)
|
||||
const [loading, setLoading] = useState(false)
|
||||
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)
|
||||
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,58 +80,81 @@ export function ArticleReviews({ article, authorPubkey }: ArticleReviewsProps):
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [article.id, authorPubkey])
|
||||
}, [articleId, authorPubkey])
|
||||
|
||||
useEffect(() => {
|
||||
void loadReviews()
|
||||
}, [loadReviews])
|
||||
void reload()
|
||||
}, [reload])
|
||||
|
||||
return (
|
||||
<div className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark space-y-4">
|
||||
<ArticleReviewsHeader tips={tips} onAddReview={() => {
|
||||
setShowReviewForm(true)
|
||||
}} />
|
||||
{showReviewForm && (
|
||||
<ReviewForm
|
||||
article={article}
|
||||
onSuccess={() => {
|
||||
setShowReviewForm(false)
|
||||
void loadReviews()
|
||||
}}
|
||||
onCancel={() => {
|
||||
setShowReviewForm(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{loading && <p className="text-sm text-cyber-accent">{t('common.loading')}</p>}
|
||||
{error && <p className="text-sm text-red-400">{error}</p>}
|
||||
{!loading && !error && reviews.length === 0 && !showReviewForm && (
|
||||
<p className="text-sm text-cyber-accent/70">{t('review.empty')}</p>
|
||||
)}
|
||||
{!loading && !error && <ArticleReviewsList reviews={reviews} onTipReview={(reviewId) => {
|
||||
setSelectedReviewForTip(reviewId)
|
||||
}} />}
|
||||
{selectedReviewForTip && (() => {
|
||||
const review = reviews.find((r) => r.id === selectedReviewForTip)
|
||||
if (!review) {
|
||||
return {
|
||||
reviews,
|
||||
tips,
|
||||
loading,
|
||||
error,
|
||||
reload,
|
||||
}
|
||||
}
|
||||
|
||||
function useReviewFormState({ reload }: { reload: () => Promise<void> }): {
|
||||
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<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 (
|
||||
<ReviewTipForm
|
||||
review={review}
|
||||
article={article}
|
||||
onSuccess={() => {
|
||||
setSelectedReviewForTip(null)
|
||||
void loadReviews()
|
||||
}}
|
||||
onCancel={() => {
|
||||
setSelectedReviewForTip(null)
|
||||
}}
|
||||
review={selection.selectedReviewForTip}
|
||||
article={selection.article}
|
||||
onSuccess={selection.onTipSuccess}
|
||||
onCancel={selection.clear}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ArticleReviewsHeader({ tips, onAddReview }: { tips: number; onAddReview: () => void }): React.ReactElement {
|
||||
|
||||
@ -5,7 +5,7 @@ export function AuthorMnemonicIcons({ value, getMnemonicIcons }: { value: string
|
||||
return (
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{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}
|
||||
</span>
|
||||
))}
|
||||
|
||||
@ -47,7 +47,7 @@ export function AuthorOption({
|
||||
<span className="flex-1 truncate text-cyber-accent">{displayName}</span>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{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}
|
||||
</span>
|
||||
))}
|
||||
@ -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) => (
|
||||
<AuthorOption
|
||||
key={pubkey}
|
||||
{...createAuthorOptionProps(pubkey, value, getDisplayName, getPicture, getMnemonicIcons, onChange, setIsOpen)}
|
||||
{...createAuthorOptionProps({
|
||||
pubkey,
|
||||
value,
|
||||
getDisplayName,
|
||||
getPicture,
|
||||
getMnemonicIcons,
|
||||
onChange,
|
||||
setIsOpen,
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
|
||||
@ -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>): 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 {
|
||||
return (
|
||||
<ConnectedUserMenu
|
||||
@ -63,7 +56,7 @@ function UnlockState({ loading, error, onUnlock, onClose }: { loading: boolean;
|
||||
return (
|
||||
<>
|
||||
<ConnectForm
|
||||
onCreateAccount={() => {}}
|
||||
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<string[]>([])
|
||||
const [npub, setNpub] = useState('')
|
||||
const [creatingAccount, setCreatingAccount] = useState(false)
|
||||
const [createError, setCreateError] = useState<string | null>(null)
|
||||
const ui = useConnectButtonUiState()
|
||||
|
||||
useAutoConnect(accountExists, pubkey, false, showUnlockModal, connect)
|
||||
useAutoConnect({
|
||||
accountExists,
|
||||
pubkey,
|
||||
showRecoveryStep: ui.showRecoveryStep,
|
||||
showUnlockModal: ui.showUnlockModal,
|
||||
connect,
|
||||
})
|
||||
|
||||
const handleCreateAccount = async (): Promise<void> => {
|
||||
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 <ConnectedState pubkey={pubkey} profile={profile} loading={loading} disconnect={disconnect} />
|
||||
}
|
||||
|
||||
if (accountExists === true && pubkey && !isUnlocked && !showUnlockModal) {
|
||||
return (
|
||||
<UnlockState
|
||||
loading={loading}
|
||||
error={error}
|
||||
onUnlock={() => setShowUnlockModal(true)}
|
||||
onClose={() => setShowUnlockModal(false)}
|
||||
/>
|
||||
)
|
||||
if (mode === 'unlock_required') {
|
||||
return <UnlockState loading={loading} error={error} onUnlock={ui.openUnlockModal} onClose={ui.closeUnlockModal} />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DisconnectedState
|
||||
loading={loading ?? creatingAccount}
|
||||
error={error ?? createError}
|
||||
showUnlockModal={showUnlockModal}
|
||||
setShowUnlockModal={setShowUnlockModal}
|
||||
onCreateAccount={() => { void handleCreateAccount() }}
|
||||
/>
|
||||
{showRecoveryStep && (
|
||||
<RecoveryStep
|
||||
recoveryPhrase={recoveryPhrase}
|
||||
npub={npub}
|
||||
onContinue={handleRecoveryContinue}
|
||||
/>
|
||||
)}
|
||||
{showUnlockModal && (
|
||||
<UnlockAccountModal
|
||||
onSuccess={handleUnlockSuccess}
|
||||
onClose={() => setShowUnlockModal(false)}
|
||||
loading={loading ?? ui.creatingAccount}
|
||||
error={error ?? ui.createError}
|
||||
showUnlockModal={ui.showUnlockModal}
|
||||
setShowUnlockModal={ui.setShowUnlockModal}
|
||||
onCreateAccount={ui.onCreateAccount}
|
||||
/>
|
||||
{ui.showRecoveryStep && (
|
||||
<RecoveryStep recoveryPhrase={ui.recoveryPhrase} npub={ui.npub} onContinue={ui.onRecoveryContinue} />
|
||||
)}
|
||||
{ui.showUnlockModal && <UnlockAccountModal onSuccess={ui.onUnlockSuccess} onClose={ui.closeUnlockModal} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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<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
|
||||
}
|
||||
|
||||
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<void>
|
||||
handleImport: () => Promise<void>
|
||||
} {
|
||||
}
|
||||
|
||||
function useAccountCreation(initialStep: Step = 'choose'): AccountCreationState {
|
||||
const [step, setStep] = useState<Step>(initialStep)
|
||||
const [importKey, setImportKey] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
@ -62,27 +66,29 @@ function useAccountCreation(initialStep: Step = 'choose'): {
|
||||
const [recoveryPhrase, setRecoveryPhrase] = useState<string[]>([])
|
||||
const [npub, setNpub] = useState('')
|
||||
|
||||
const handleGenerate = async (): Promise<void> => {
|
||||
await handleAccountCreation(undefined, setLoading, setError, setRecoveryPhrase, setNpub, setStep, 'Failed to create account')
|
||||
}
|
||||
|
||||
const handleImport = async (): Promise<void> => {
|
||||
await handleAccountCreation(importKey, setLoading, setError, setRecoveryPhrase, setNpub, setStep, 'Failed to import key')
|
||||
}
|
||||
|
||||
return {
|
||||
step,
|
||||
setStep,
|
||||
importKey,
|
||||
setImportKey,
|
||||
loading,
|
||||
error,
|
||||
const handleGenerate = (): Promise<void> =>
|
||||
handleAccountCreation({
|
||||
key: undefined,
|
||||
setLoading,
|
||||
setError,
|
||||
recoveryPhrase,
|
||||
npub,
|
||||
handleGenerate,
|
||||
handleImport,
|
||||
}
|
||||
setRecoveryPhrase,
|
||||
setNpub,
|
||||
setStep,
|
||||
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 {
|
||||
@ -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 <RecoveryStep recoveryPhrase={recoveryPhrase} npub={npub} onContinue={handleContinue} />
|
||||
}
|
||||
|
||||
function renderStep(params: RenderStepParams): React.ReactElement {
|
||||
if (params.step === 'recovery') {
|
||||
return <RecoveryStep recoveryPhrase={params.recoveryPhrase} npub={params.npub} onContinue={params.handleContinue} />
|
||||
}
|
||||
|
||||
if (step === 'import') {
|
||||
if (params.step === 'import') {
|
||||
return (
|
||||
<ImportStep
|
||||
importKey={importKey}
|
||||
setImportKey={setImportKey}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onImport={handleImport}
|
||||
onBack={() => 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 (
|
||||
<ChooseStep
|
||||
loading={loading}
|
||||
error={error}
|
||||
onGenerate={handleGenerate}
|
||||
onImport={() => 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,
|
||||
})
|
||||
}
|
||||
|
||||
@ -20,16 +20,17 @@ export function RecoveryPhraseDisplay({
|
||||
copied: boolean
|
||||
onCopy: () => void
|
||||
}): React.ReactElement {
|
||||
const recoveryItems = buildRecoveryPhraseItems(recoveryPhrase)
|
||||
return (
|
||||
<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">
|
||||
{recoveryPhrase.map((word, index) => (
|
||||
{recoveryItems.map((item, index) => (
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
@ -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 {
|
||||
return (
|
||||
<div className="bg-neon-blue/10 border border-neon-blue/30 rounded-lg p-4 mb-6">
|
||||
|
||||
@ -42,18 +42,52 @@ function FundingStats({ stats }: { stats: ReturnType<typeof estimatePlatformFund
|
||||
}
|
||||
|
||||
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 [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<void> => {
|
||||
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 (
|
||||
<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>
|
||||
)
|
||||
return { stats, certificationStats, loading }
|
||||
}
|
||||
|
||||
@ -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<SyncProgress | null>(null)
|
||||
const [progress, setProgress] = useState<SyncProgress | null>(() => syncProgressManager.getProgress())
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = syncProgressManager.subscribe((newProgress) => {
|
||||
setProgress(newProgress)
|
||||
})
|
||||
|
||||
// Check current progress immediately
|
||||
const currentProgress = syncProgressManager.getProgress()
|
||||
if (currentProgress) {
|
||||
setProgress(currentProgress)
|
||||
}
|
||||
|
||||
return () => {
|
||||
unsubscribe()
|
||||
}
|
||||
|
||||
19
components/connectButton/connectButtonMode.ts
Normal file
19
components/connectButton/connectButtonMode.ts
Normal 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'
|
||||
}
|
||||
137
components/connectButton/useConnectButtonUiState.ts
Normal file
137
components/connectButton/useConnectButtonUiState.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -21,7 +21,9 @@ export function useDocs(docs: DocLink[]): {
|
||||
|
||||
const loadDoc = useCallback(async (docId: DocSection): Promise<void> => {
|
||||
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)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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<CryptoKey> {
|
||||
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<ArrayBuffer> },
|
||||
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,
|
||||
|
||||
@ -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<import('@/types/nostr').AuthorPresentationArticle | null>((resolve) => {
|
||||
let resolved = false
|
||||
const relayUrl = getPrimaryRelaySync()
|
||||
const { createSubscription } = require('@/types/nostr-tools-extended')
|
||||
const sub = createSubscription(pool, [relayUrl], filters)
|
||||
|
||||
const events: Event[] = []
|
||||
|
||||
@ -28,12 +28,12 @@ async function getOrCreateMasterKey(): Promise<string> {
|
||||
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
|
||||
}
|
||||
|
||||
@ -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}`))
|
||||
|
||||
@ -38,7 +38,7 @@ export async function generateHashId(obj: Record<string, unknown>): Promise<stri
|
||||
const canonical = canonicalizeObject(obj)
|
||||
const encoder = new TextEncoder()
|
||||
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))
|
||||
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
@ -27,8 +27,8 @@ export function useSyncProgress(options: UseSyncProgressOptions = {}): UseSyncPr
|
||||
|
||||
const [syncProgress, setSyncProgress] = useState<SyncProgress | null>(null)
|
||||
const [isSyncing, setIsSyncing] = useState(false)
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const onCompleteRef = useRef(onComplete)
|
||||
const isMonitoringRef = useRef(false)
|
||||
|
||||
@ -105,4 +105,3 @@ export function useSyncProgress(options: UseSyncProgressOptions = {}): UseSyncPr
|
||||
stopMonitoring,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -19,7 +19,7 @@ const PBKDF2_HASH = 'SHA-256'
|
||||
* Generate a random KEK (Key Encryption Key)
|
||||
*/
|
||||
async function generateKEK(): Promise<CryptoKey> {
|
||||
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<CryptoKey> {
|
||||
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<CryptoKey> {
|
||||
)
|
||||
|
||||
// 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<CryptoKey> {
|
||||
* Export KEK to raw bytes (for storage)
|
||||
*/
|
||||
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)
|
||||
}
|
||||
|
||||
@ -81,7 +81,7 @@ async function importKEK(keyBytes: Uint8Array): Promise<CryptoKey> {
|
||||
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<Enc
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
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 },
|
||||
phraseKey,
|
||||
data
|
||||
@ -114,7 +114,7 @@ async function encryptKEK(kek: CryptoKey, recoveryPhrase: string[]): Promise<Enc
|
||||
bytes.forEach((b) => {
|
||||
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<EncryptedPayload> {
|
||||
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<string> {
|
||||
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
|
||||
|
||||
@ -4,7 +4,7 @@ const MEMPOOL_API_BASE = 'https://mempool.space/api'
|
||||
|
||||
export async function getTransaction(txid: string): Promise<MempoolTransaction | null> {
|
||||
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<MempoolTransaction |
|
||||
|
||||
export async function getConfirmations(blockHeight: number): Promise<number> {
|
||||
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
|
||||
}
|
||||
|
||||
@ -77,7 +77,7 @@ function parseUploadResponse(result: unknown, endpoint: string): string {
|
||||
async function tryUploadEndpoint(endpoint: string, formData: FormData, useProxy: boolean = false): Promise<string> {
|
||||
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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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<string | null>((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<DecryptionKey | null>((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 => {
|
||||
|
||||
@ -178,7 +178,8 @@ 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 import('./notificationDetector')
|
||||
.then(({ notificationDetector }) => {
|
||||
void notificationDetector.checkObjectChange({
|
||||
objectType,
|
||||
objectId: id,
|
||||
@ -186,6 +187,10 @@ class ObjectCacheService {
|
||||
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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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[] = []
|
||||
|
||||
@ -22,7 +22,7 @@ interface UnpublishedObject {
|
||||
|
||||
class PublishWorkerService {
|
||||
private isRunning = false
|
||||
private intervalId: NodeJS.Timeout | null = null
|
||||
private intervalId: ReturnType<typeof setInterval> | null = null
|
||||
private unpublishedObjects: Map<string, UnpublishedObject> = new Map()
|
||||
private processing = false
|
||||
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<number>
|
||||
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)
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}, [])
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
92
scripts/lintReportSummary.py
Normal file
92
scripts/lintReportSummary.py
Normal 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))
|
||||
Loading…
x
Reference in New Issue
Block a user