lint fix wip
This commit is contained in:
parent
5694dbdb8a
commit
20a46ce2bc
@ -12,7 +12,6 @@ interface ArticleEditorProps {
|
|||||||
defaultSeriesId?: string
|
defaultSeriesId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function SuccessMessage(): React.ReactElement {
|
function SuccessMessage(): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<div className="border rounded-lg p-6 bg-green-50 border-green-200">
|
<div className="border rounded-lg p-6 bg-green-50 border-green-200">
|
||||||
@ -34,7 +33,13 @@ export function ArticleEditor({ onPublishSuccess, onCancel, seriesOptions, onSel
|
|||||||
...(defaultSeriesId ? { seriesId: defaultSeriesId } : {}),
|
...(defaultSeriesId ? { seriesId: defaultSeriesId } : {}),
|
||||||
})
|
})
|
||||||
|
|
||||||
const submit = buildSubmitHandler(publishArticle, draft, onPublishSuccess, connect, connected)
|
const submit = buildSubmitHandler({
|
||||||
|
publishArticle,
|
||||||
|
draft,
|
||||||
|
onPublishSuccess,
|
||||||
|
connect,
|
||||||
|
connected,
|
||||||
|
})
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
return <SuccessMessage />
|
return <SuccessMessage />
|
||||||
@ -58,21 +63,25 @@ export function ArticleEditor({ onPublishSuccess, onCancel, seriesOptions, onSel
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSubmitHandler(
|
interface SubmitHandlerParams {
|
||||||
publishArticle: (draft: ArticleDraft) => Promise<string | null>,
|
publishArticle: (draft: ArticleDraft) => Promise<string | null>
|
||||||
draft: ArticleDraft,
|
draft: ArticleDraft
|
||||||
onPublishSuccess?: (articleId: string) => void,
|
onPublishSuccess?: (articleId: string) => void
|
||||||
connect?: () => Promise<void>,
|
connect?: () => Promise<void>
|
||||||
connected?: boolean
|
connected?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSubmitHandler(
|
||||||
|
params: SubmitHandlerParams
|
||||||
): () => Promise<void> {
|
): () => Promise<void> {
|
||||||
return async (): Promise<void> => {
|
return async (): Promise<void> => {
|
||||||
if (!connected && connect) {
|
if (!params.connected && params.connect) {
|
||||||
await connect()
|
await params.connect()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const articleId = await publishArticle(draft)
|
const articleId = await params.publishArticle(params.draft)
|
||||||
if (articleId) {
|
if (articleId) {
|
||||||
onPublishSuccess?.(articleId)
|
params.onPublishSuccess?.(articleId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,15 +1,10 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import type { ArticleDraft } from '@/lib/articlePublisher'
|
import type { ArticleDraft } from '@/lib/articlePublisher'
|
||||||
import type { ArticleCategory } from '@/types/nostr'
|
|
||||||
import { ArticleField } from './ArticleField'
|
|
||||||
import { ArticleFormButtons } from './ArticleFormButtons'
|
import { ArticleFormButtons } from './ArticleFormButtons'
|
||||||
import { CategorySelect } from './CategorySelect'
|
|
||||||
import { MarkdownEditor } from './MarkdownEditor'
|
|
||||||
import { MarkdownEditorTwoColumns } from './MarkdownEditorTwoColumns'
|
|
||||||
import type { MediaRef } from '@/types/nostr'
|
|
||||||
import { t } from '@/lib/i18n'
|
|
||||||
|
|
||||||
import type { RelayPublishStatus } from '@/lib/publishResult'
|
import type { RelayPublishStatus } from '@/lib/publishResult'
|
||||||
|
import { t } from '@/lib/i18n'
|
||||||
|
import { ArticleFieldsLeft } from './ArticleEditorFormFieldsLeft'
|
||||||
|
import { ArticleFieldsRight } from './ArticleEditorFormFieldsRight'
|
||||||
|
|
||||||
interface ArticleEditorFormProps {
|
interface ArticleEditorFormProps {
|
||||||
draft: ArticleDraft
|
draft: ArticleDraft
|
||||||
@ -23,25 +18,6 @@ interface ArticleEditorFormProps {
|
|||||||
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
|
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
function CategoryField({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
value: ArticleDraft['category']
|
|
||||||
onChange: (value: import('@/types/nostr').ArticleCategory | undefined) => void
|
|
||||||
}): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<CategorySelect
|
|
||||||
id="category"
|
|
||||||
label={t('article.editor.category')}
|
|
||||||
{...(value ? { value } : {})}
|
|
||||||
onChange={onChange}
|
|
||||||
required
|
|
||||||
helpText={t('article.editor.category.help')}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ErrorAlert({ error }: { error: string | null }): React.ReactElement | null {
|
function ErrorAlert({ error }: { error: string | null }): React.ReactElement | null {
|
||||||
if (!error) {
|
if (!error) {
|
||||||
return null
|
return null
|
||||||
@ -53,199 +29,6 @@ function ErrorAlert({ error }: { error: string | null }): React.ReactElement | n
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildCategoryChangeHandler(
|
|
||||||
draft: ArticleDraft,
|
|
||||||
onDraftChange: (draft: ArticleDraft) => void
|
|
||||||
): (value: ArticleCategory | undefined) => void {
|
|
||||||
return (value) => {
|
|
||||||
if (value === 'science-fiction' || value === 'scientific-research' || value === undefined) {
|
|
||||||
const nextDraft: ArticleDraft = { ...draft }
|
|
||||||
if (value) {
|
|
||||||
nextDraft.category = value
|
|
||||||
} else {
|
|
||||||
delete (nextDraft as { category?: ArticleDraft['category'] }).category
|
|
||||||
}
|
|
||||||
onDraftChange(nextDraft)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const ArticleFieldsLeft = ({
|
|
||||||
draft,
|
|
||||||
onDraftChange,
|
|
||||||
seriesOptions,
|
|
||||||
onSelectSeries,
|
|
||||||
}: {
|
|
||||||
draft: ArticleDraft
|
|
||||||
onDraftChange: (draft: ArticleDraft) => void
|
|
||||||
seriesOptions?: { id: string; title: string }[] | undefined
|
|
||||||
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
|
|
||||||
}): React.ReactElement => (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<CategoryField
|
|
||||||
value={draft.category}
|
|
||||||
onChange={buildCategoryChangeHandler(draft, onDraftChange)}
|
|
||||||
/>
|
|
||||||
{seriesOptions && (
|
|
||||||
<SeriesSelect
|
|
||||||
draft={draft}
|
|
||||||
onDraftChange={onDraftChange}
|
|
||||||
seriesOptions={seriesOptions}
|
|
||||||
onSelectSeries={onSelectSeries}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<ArticleTitleField draft={draft} onDraftChange={onDraftChange} />
|
|
||||||
<ArticlePreviewField draft={draft} onDraftChange={onDraftChange} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
function ArticleTitleField({ draft, onDraftChange }: { draft: ArticleDraft; onDraftChange: (draft: ArticleDraft) => void }): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<ArticleField
|
|
||||||
id="title"
|
|
||||||
label={t('article.title')}
|
|
||||||
value={draft.title}
|
|
||||||
onChange={(value) => onDraftChange({ ...draft, title: value as string })}
|
|
||||||
required
|
|
||||||
placeholder={t('article.editor.title.placeholder')}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ArticlePreviewField({
|
|
||||||
draft,
|
|
||||||
onDraftChange,
|
|
||||||
}: {
|
|
||||||
draft: ArticleDraft
|
|
||||||
onDraftChange: (draft: ArticleDraft) => void
|
|
||||||
}): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<ArticleField
|
|
||||||
id="preview"
|
|
||||||
label={t('article.editor.preview.label')}
|
|
||||||
value={draft.preview}
|
|
||||||
onChange={(value) => onDraftChange({ ...draft, preview: value as string })}
|
|
||||||
required
|
|
||||||
type="textarea"
|
|
||||||
rows={4}
|
|
||||||
placeholder={t('article.editor.preview.placeholder')}
|
|
||||||
helpText={t('article.editor.preview.help')}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SeriesSelect({
|
|
||||||
draft,
|
|
||||||
onDraftChange,
|
|
||||||
seriesOptions,
|
|
||||||
onSelectSeries,
|
|
||||||
}: {
|
|
||||||
draft: ArticleDraft
|
|
||||||
onDraftChange: (draft: ArticleDraft) => void
|
|
||||||
seriesOptions: { id: string; title: string }[]
|
|
||||||
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
|
|
||||||
}): React.ReactElement {
|
|
||||||
const handleChange = buildSeriesChangeHandler(draft, onDraftChange, onSelectSeries)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<label htmlFor="series" className="block text-sm font-medium text-gray-700">
|
|
||||||
{t('article.editor.series.label')}
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="series"
|
|
||||||
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm"
|
|
||||||
value={draft.seriesId ?? ''}
|
|
||||||
onChange={handleChange}
|
|
||||||
>
|
|
||||||
<option value="">{t('article.editor.series.none')}</option>
|
|
||||||
{seriesOptions.map((s) => (
|
|
||||||
<option key={s.id} value={s.id}>
|
|
||||||
{s.title}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSeriesChangeHandler(
|
|
||||||
draft: ArticleDraft,
|
|
||||||
onDraftChange: (draft: ArticleDraft) => void,
|
|
||||||
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
|
|
||||||
): (e: React.ChangeEvent<HTMLSelectElement>) => void {
|
|
||||||
return (e: React.ChangeEvent<HTMLSelectElement>): void => {
|
|
||||||
const value = e.target.value || undefined
|
|
||||||
const nextDraft = { ...draft }
|
|
||||||
if (value) {
|
|
||||||
nextDraft.seriesId = value
|
|
||||||
} else {
|
|
||||||
delete (nextDraft as { seriesId?: string }).seriesId
|
|
||||||
}
|
|
||||||
onDraftChange(nextDraft)
|
|
||||||
onSelectSeries?.(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const ArticleFieldsRight = ({
|
|
||||||
draft,
|
|
||||||
onDraftChange,
|
|
||||||
}: {
|
|
||||||
draft: ArticleDraft
|
|
||||||
onDraftChange: (draft: ArticleDraft) => void
|
|
||||||
}): React.ReactElement => {
|
|
||||||
// Use two-column editor with pages for series publications
|
|
||||||
const useTwoColumns = draft.seriesId !== undefined
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="text-sm font-semibold text-gray-800">{t('article.editor.content.label')}</div>
|
|
||||||
{useTwoColumns ? (
|
|
||||||
<MarkdownEditorTwoColumns
|
|
||||||
value={draft.content}
|
|
||||||
onChange={(value) => onDraftChange({ ...draft, content: value })}
|
|
||||||
{...(draft.pages ? { pages: draft.pages } : {})}
|
|
||||||
onPagesChange={(pages) => onDraftChange({ ...draft, pages })}
|
|
||||||
onMediaAdd={(media: MediaRef) => {
|
|
||||||
const nextMedia = [...(draft.media ?? []), media]
|
|
||||||
onDraftChange({ ...draft, media: nextMedia })
|
|
||||||
}}
|
|
||||||
onBannerChange={(url: string) => {
|
|
||||||
onDraftChange({ ...draft, bannerUrl: url })
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<MarkdownEditor
|
|
||||||
value={draft.content}
|
|
||||||
onChange={(value) => onDraftChange({ ...draft, content: value })}
|
|
||||||
onMediaAdd={(media: MediaRef) => {
|
|
||||||
const nextMedia = [...(draft.media ?? []), media]
|
|
||||||
onDraftChange({ ...draft, media: nextMedia })
|
|
||||||
}}
|
|
||||||
onBannerChange={(url: string) => {
|
|
||||||
onDraftChange({ ...draft, bannerUrl: url })
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<p className="text-xs text-gray-500">
|
|
||||||
{t('article.editor.content.help')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<ArticleField
|
|
||||||
id="zapAmount"
|
|
||||||
label={t('article.editor.sponsoring.label')}
|
|
||||||
value={draft.zapAmount}
|
|
||||||
onChange={(value) => onDraftChange({ ...draft, zapAmount: value as number })}
|
|
||||||
required
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
helpText={t('article.editor.sponsoring.help')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ArticleEditorForm({
|
export function ArticleEditorForm({
|
||||||
draft,
|
draft,
|
||||||
onDraftChange,
|
onDraftChange,
|
||||||
|
|||||||
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 {
|
export function ArticlePages({ pages, articleId }: ArticlePagesProps): React.ReactElement | null {
|
||||||
const { pubkey } = useNostrAuth()
|
const { pubkey } = useNostrAuth()
|
||||||
const [hasPurchased, setHasPurchased] = useState(false)
|
const hasPurchased = useHasPurchasedArticle({ pubkey, articleId })
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const checkPurchase = async (): Promise<void> => {
|
|
||||||
if (!pubkey || !articleId) {
|
|
||||||
setHasPurchased(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Check if user has purchased this article from cache
|
|
||||||
const purchases = await objectCache.getAll('purchase')
|
|
||||||
const userPurchases = purchases.filter((p) => {
|
|
||||||
if (typeof p !== 'object' || p === null) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
const purchase = p as { payerPubkey?: string; articleId?: string }
|
|
||||||
return purchase.payerPubkey === pubkey && purchase.articleId === articleId
|
|
||||||
})
|
|
||||||
|
|
||||||
setHasPurchased(userPurchases.length > 0)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error checking purchase status:', error)
|
|
||||||
setHasPurchased(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void checkPurchase()
|
|
||||||
}, [pubkey, articleId])
|
|
||||||
|
|
||||||
if (!pages || pages.length === 0) {
|
if (!pages || pages.length === 0) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// If user hasn't purchased, show locked message
|
if (!pubkey || !hasPurchased) {
|
||||||
if (!hasPurchased) {
|
return <LockedPagesView pagesCount={pages.length} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return <PurchasedPagesView pages={pages} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function LockedPagesView({ pagesCount }: { pagesCount: number }): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 mt-6">
|
<div className="space-y-6 mt-6">
|
||||||
<h3 className="text-xl font-semibold text-neon-cyan">{t('article.pages.title')}</h3>
|
<h3 className="text-xl font-semibold text-neon-cyan">{t('article.pages.title')}</h3>
|
||||||
<div className="border border-neon-cyan/30 rounded-lg p-6 bg-cyber-dark text-center">
|
<div className="border border-neon-cyan/30 rounded-lg p-6 bg-cyber-dark text-center">
|
||||||
<p className="text-cyber-accent mb-2">{t('article.pages.locked.title')}</p>
|
<p className="text-cyber-accent mb-2">{t('article.pages.locked.title')}</p>
|
||||||
<p className="text-sm text-cyber-accent/70">{t('article.pages.locked.message', { count: pages.length })}</p>
|
<p className="text-sm text-cyber-accent/70">{t('article.pages.locked.message', { count: pagesCount })}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// User has purchased, show all pages
|
function PurchasedPagesView({ pages }: { pages: Page[] }): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 mt-6">
|
<div className="space-y-6 mt-6">
|
||||||
<h3 className="text-xl font-semibold text-neon-cyan">{t('article.pages.title')}</h3>
|
<h3 className="text-xl font-semibold text-neon-cyan">{t('article.pages.title')}</h3>
|
||||||
@ -71,6 +49,53 @@ export function ArticlePages({ pages, articleId }: ArticlePagesProps): React.Rea
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function useHasPurchasedArticle({
|
||||||
|
pubkey,
|
||||||
|
articleId,
|
||||||
|
}: {
|
||||||
|
pubkey: string | null
|
||||||
|
articleId: string
|
||||||
|
}): boolean {
|
||||||
|
const [hasPurchased, setHasPurchased] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pubkey) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkPurchase = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const purchases = await objectCache.getAll('purchase')
|
||||||
|
const userPurchases = purchases.filter((p) => isUserPurchase({ p, pubkey, articleId }))
|
||||||
|
setHasPurchased(userPurchases.length > 0)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking purchase status:', error)
|
||||||
|
setHasPurchased(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void checkPurchase()
|
||||||
|
}, [pubkey, articleId])
|
||||||
|
|
||||||
|
return hasPurchased
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUserPurchase({
|
||||||
|
p,
|
||||||
|
pubkey,
|
||||||
|
articleId,
|
||||||
|
}: {
|
||||||
|
p: unknown
|
||||||
|
pubkey: string
|
||||||
|
articleId: string
|
||||||
|
}): boolean {
|
||||||
|
if (typeof p !== 'object' || p === null) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const purchase = p as { payerPubkey?: string; articleId?: string }
|
||||||
|
return purchase.payerPubkey === pubkey && purchase.articleId === articleId
|
||||||
|
}
|
||||||
|
|
||||||
function PageDisplay({ page }: { page: Page }): React.ReactElement {
|
function PageDisplay({ page }: { page: Page }): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<div className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark">
|
<div className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark">
|
||||||
|
|||||||
@ -12,20 +12,66 @@ interface ArticleReviewsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ArticleReviews({ article, authorPubkey }: ArticleReviewsProps): React.ReactElement {
|
export function ArticleReviews({ article, authorPubkey }: ArticleReviewsProps): React.ReactElement {
|
||||||
|
const data = useArticleReviewsData({ articleId: article.id, authorPubkey })
|
||||||
|
const reviewForm = useReviewFormState({ reload: data.reload })
|
||||||
|
const tipSelection = useReviewTipSelection({ article, reviews: data.reviews, reload: data.reload })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark space-y-4">
|
||||||
|
<ArticleReviewsHeader tips={data.tips} onAddReview={reviewForm.open} />
|
||||||
|
{reviewForm.show && (
|
||||||
|
<ReviewForm
|
||||||
|
article={article}
|
||||||
|
onSuccess={reviewForm.onSuccess}
|
||||||
|
onCancel={reviewForm.close}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{data.loading && <p className="text-sm text-cyber-accent">{t('common.loading')}</p>}
|
||||||
|
{data.error && <p className="text-sm text-red-400">{data.error}</p>}
|
||||||
|
{!data.loading && !data.error && data.reviews.length === 0 && !reviewForm.show && (
|
||||||
|
<p className="text-sm text-cyber-accent/70">{t('review.empty')}</p>
|
||||||
|
)}
|
||||||
|
{!data.loading && !data.error && <ArticleReviewsList reviews={data.reviews} onTipReview={tipSelection.select} />}
|
||||||
|
<SelectedReviewTipForm selection={tipSelection} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReviewTipSelectionController {
|
||||||
|
article: Article
|
||||||
|
selectedReviewForTip: Review | null
|
||||||
|
onTipSuccess: () => void
|
||||||
|
clear: () => void
|
||||||
|
select: (reviewId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ArticleReviewsData {
|
||||||
|
reviews: Review[]
|
||||||
|
tips: number
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
reload: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
function useArticleReviewsData({
|
||||||
|
articleId,
|
||||||
|
authorPubkey,
|
||||||
|
}: {
|
||||||
|
articleId: string
|
||||||
|
authorPubkey: string
|
||||||
|
}): ArticleReviewsData {
|
||||||
const [reviews, setReviews] = useState<Review[]>([])
|
const [reviews, setReviews] = useState<Review[]>([])
|
||||||
const [tips, setTips] = useState<number>(0)
|
const [tips, setTips] = useState<number>(0)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [showReviewForm, setShowReviewForm] = useState(false)
|
|
||||||
const [selectedReviewForTip, setSelectedReviewForTip] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const loadReviews = useCallback(async (): Promise<void> => {
|
const reload = useCallback(async (): Promise<void> => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const [list, tipsTotal] = await Promise.all([
|
const [list, tipsTotal] = await Promise.all([
|
||||||
getReviewsForArticle(article.id),
|
getReviewsForArticle(articleId),
|
||||||
getReviewTipsForArticle({ authorPubkey, articleId: article.id }),
|
getReviewTipsForArticle({ authorPubkey, articleId }),
|
||||||
])
|
])
|
||||||
setReviews(list)
|
setReviews(list)
|
||||||
setTips(tipsTotal)
|
setTips(tipsTotal)
|
||||||
@ -34,58 +80,81 @@ export function ArticleReviews({ article, authorPubkey }: ArticleReviewsProps):
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [article.id, authorPubkey])
|
}, [articleId, authorPubkey])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadReviews()
|
void reload()
|
||||||
}, [loadReviews])
|
}, [reload])
|
||||||
|
|
||||||
return (
|
return {
|
||||||
<div className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark space-y-4">
|
reviews,
|
||||||
<ArticleReviewsHeader tips={tips} onAddReview={() => {
|
tips,
|
||||||
setShowReviewForm(true)
|
loading,
|
||||||
}} />
|
error,
|
||||||
{showReviewForm && (
|
reload,
|
||||||
<ReviewForm
|
}
|
||||||
article={article}
|
}
|
||||||
onSuccess={() => {
|
|
||||||
setShowReviewForm(false)
|
function useReviewFormState({ reload }: { reload: () => Promise<void> }): {
|
||||||
void loadReviews()
|
show: boolean
|
||||||
}}
|
open: () => void
|
||||||
onCancel={() => {
|
close: () => void
|
||||||
setShowReviewForm(false)
|
onSuccess: () => void
|
||||||
}}
|
} {
|
||||||
/>
|
const [show, setShow] = useState(false)
|
||||||
)}
|
const open = useCallback((): void => setShow(true), [])
|
||||||
{loading && <p className="text-sm text-cyber-accent">{t('common.loading')}</p>}
|
const close = useCallback((): void => setShow(false), [])
|
||||||
{error && <p className="text-sm text-red-400">{error}</p>}
|
|
||||||
{!loading && !error && reviews.length === 0 && !showReviewForm && (
|
const onSuccess = useCallback((): void => {
|
||||||
<p className="text-sm text-cyber-accent/70">{t('review.empty')}</p>
|
close()
|
||||||
)}
|
void reload()
|
||||||
{!loading && !error && <ArticleReviewsList reviews={reviews} onTipReview={(reviewId) => {
|
}, [close, reload])
|
||||||
setSelectedReviewForTip(reviewId)
|
|
||||||
}} />}
|
return { show, open, close, onSuccess }
|
||||||
{selectedReviewForTip && (() => {
|
}
|
||||||
const review = reviews.find((r) => r.id === selectedReviewForTip)
|
|
||||||
if (!review) {
|
function useReviewTipSelection({
|
||||||
|
article,
|
||||||
|
reviews,
|
||||||
|
reload,
|
||||||
|
}: {
|
||||||
|
article: Article
|
||||||
|
reviews: Review[]
|
||||||
|
reload: () => Promise<void>
|
||||||
|
}): ReviewTipSelectionController {
|
||||||
|
const [selectedReviewId, setSelectedReviewId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const select = useCallback((reviewId: string): void => setSelectedReviewId(reviewId), [])
|
||||||
|
const clear = useCallback((): void => setSelectedReviewId(null), [])
|
||||||
|
|
||||||
|
const onTipSuccess = useCallback((): void => {
|
||||||
|
clear()
|
||||||
|
void reload()
|
||||||
|
}, [clear, reload])
|
||||||
|
|
||||||
|
const selectedReviewForTip = selectedReviewId ? findReviewById(reviews, selectedReviewId) : null
|
||||||
|
|
||||||
|
return { article, selectedReviewForTip, onTipSuccess, clear, select }
|
||||||
|
}
|
||||||
|
|
||||||
|
function findReviewById(reviews: Review[], reviewId: string): Review | null {
|
||||||
|
const review = reviews.find((r) => r.id === reviewId)
|
||||||
|
return review ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectedReviewTipForm({ selection }: { selection: ReviewTipSelectionController }): React.ReactElement | null {
|
||||||
|
if (!selection.selectedReviewForTip) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ReviewTipForm
|
<ReviewTipForm
|
||||||
review={review}
|
review={selection.selectedReviewForTip}
|
||||||
article={article}
|
article={selection.article}
|
||||||
onSuccess={() => {
|
onSuccess={selection.onTipSuccess}
|
||||||
setSelectedReviewForTip(null)
|
onCancel={selection.clear}
|
||||||
void loadReviews()
|
|
||||||
}}
|
|
||||||
onCancel={() => {
|
|
||||||
setSelectedReviewForTip(null)
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ArticleReviewsHeader({ tips, onAddReview }: { tips: number; onAddReview: () => void }): React.ReactElement {
|
function ArticleReviewsHeader({ tips, onAddReview }: { tips: number; onAddReview: () => void }): React.ReactElement {
|
||||||
|
|||||||
@ -5,7 +5,7 @@ export function AuthorMnemonicIcons({ value, getMnemonicIcons }: { value: string
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1 flex-shrink-0">
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
{getMnemonicIcons(value).map((icon, idx) => (
|
{getMnemonicIcons(value).map((icon, idx) => (
|
||||||
<span key={`mnemonic-icon-${idx}-${icon}`} className="text-sm" title={`Mnemonic icon ${idx + 1}`}>
|
<span key={`${value}-${icon}`} className="text-sm" title={`Mnemonic icon ${idx + 1}`}>
|
||||||
{icon}
|
{icon}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -47,7 +47,7 @@ export function AuthorOption({
|
|||||||
<span className="flex-1 truncate text-cyber-accent">{displayName}</span>
|
<span className="flex-1 truncate text-cyber-accent">{displayName}</span>
|
||||||
<div className="flex items-center gap-1 flex-shrink-0">
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
{mnemonicIcons.map((icon, idx) => (
|
{mnemonicIcons.map((icon, idx) => (
|
||||||
<span key={`mnemonic-icon-${idx}-${icon}`} className="text-sm" title={`Mnemonic icon ${idx + 1}`}>
|
<span key={`${displayName}-${icon}`} className="text-sm" title={`Mnemonic icon ${idx + 1}`}>
|
||||||
{icon}
|
{icon}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
@ -83,22 +83,24 @@ export function AllAuthorsOption({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createAuthorOptionProps(
|
interface CreateAuthorOptionPropsParams {
|
||||||
pubkey: string,
|
pubkey: string
|
||||||
value: string | null,
|
value: string | null
|
||||||
getDisplayName: (pubkey: string) => string,
|
getDisplayName: (pubkey: string) => string
|
||||||
getPicture: (pubkey: string) => string | undefined,
|
getPicture: (pubkey: string) => string | undefined
|
||||||
getMnemonicIcons: (pubkey: string) => string[],
|
getMnemonicIcons: (pubkey: string) => string[]
|
||||||
onChange: (value: string | null) => void,
|
onChange: (value: string | null) => void
|
||||||
setIsOpen: (open: boolean) => void
|
setIsOpen: (open: boolean) => void
|
||||||
): {
|
}
|
||||||
|
|
||||||
|
export function createAuthorOptionProps(params: CreateAuthorOptionPropsParams): {
|
||||||
displayName: string
|
displayName: string
|
||||||
mnemonicIcons: string[]
|
mnemonicIcons: string[]
|
||||||
isSelected: boolean
|
isSelected: boolean
|
||||||
onSelect: () => void
|
onSelect: () => void
|
||||||
picture?: string
|
picture?: string
|
||||||
} {
|
} {
|
||||||
const pictureValue = getPicture(pubkey)
|
const pictureValue = params.getPicture(params.pubkey)
|
||||||
const optionProps: {
|
const optionProps: {
|
||||||
displayName: string
|
displayName: string
|
||||||
mnemonicIcons: string[]
|
mnemonicIcons: string[]
|
||||||
@ -106,12 +108,12 @@ export function createAuthorOptionProps(
|
|||||||
onSelect: () => void
|
onSelect: () => void
|
||||||
picture?: string
|
picture?: string
|
||||||
} = {
|
} = {
|
||||||
displayName: getDisplayName(pubkey),
|
displayName: params.getDisplayName(params.pubkey),
|
||||||
mnemonicIcons: getMnemonicIcons(pubkey),
|
mnemonicIcons: params.getMnemonicIcons(params.pubkey),
|
||||||
isSelected: value === pubkey,
|
isSelected: params.value === params.pubkey,
|
||||||
onSelect: () => {
|
onSelect: () => {
|
||||||
onChange(pubkey)
|
params.onChange(params.pubkey)
|
||||||
setIsOpen(false)
|
params.setIsOpen(false)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if (pictureValue !== undefined) {
|
if (pictureValue !== undefined) {
|
||||||
@ -142,7 +144,15 @@ export function AuthorList({
|
|||||||
{authors.map((pubkey) => (
|
{authors.map((pubkey) => (
|
||||||
<AuthorOption
|
<AuthorOption
|
||||||
key={pubkey}
|
key={pubkey}
|
||||||
{...createAuthorOptionProps(pubkey, value, getDisplayName, getPicture, getMnemonicIcons, onChange, setIsOpen)}
|
{...createAuthorOptionProps({
|
||||||
|
pubkey,
|
||||||
|
value,
|
||||||
|
getDisplayName,
|
||||||
|
getPicture,
|
||||||
|
getMnemonicIcons,
|
||||||
|
onChange,
|
||||||
|
setIsOpen,
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
import { useState, useEffect } from 'react'
|
|
||||||
import { useNostrAuth } from '@/hooks/useNostrAuth'
|
import { useNostrAuth } from '@/hooks/useNostrAuth'
|
||||||
import { ConnectedUserMenu } from './ConnectedUserMenu'
|
import { ConnectedUserMenu } from './ConnectedUserMenu'
|
||||||
import { RecoveryStep } from './CreateAccountModalSteps'
|
import { RecoveryStep } from './CreateAccountModalSteps'
|
||||||
import { UnlockAccountModal } from './UnlockAccountModal'
|
import { UnlockAccountModal } from './UnlockAccountModal'
|
||||||
import type { NostrProfile } from '@/types/nostr'
|
import type { NostrProfile } from '@/types/nostr'
|
||||||
import { t } from '@/lib/i18n'
|
import { t } from '@/lib/i18n'
|
||||||
|
import { getConnectButtonMode } from './connectButton/connectButtonMode'
|
||||||
|
import { useAutoConnect, useConnectButtonUiState } from './connectButton/useConnectButtonUiState'
|
||||||
|
|
||||||
function ConnectForm({
|
function ConnectForm({
|
||||||
onCreateAccount,
|
onCreateAccount,
|
||||||
@ -38,14 +39,6 @@ function ConnectForm({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function useAutoConnect(accountExists: boolean | null, pubkey: string | null, showRecoveryStep: boolean, showUnlockModal: boolean, connect: () => Promise<void>): void {
|
|
||||||
useEffect(() => {
|
|
||||||
if (accountExists === true && !pubkey && !showRecoveryStep && !showUnlockModal) {
|
|
||||||
void connect()
|
|
||||||
}
|
|
||||||
}, [accountExists, pubkey, showRecoveryStep, showUnlockModal, connect])
|
|
||||||
}
|
|
||||||
|
|
||||||
function ConnectedState({ pubkey, profile, loading, disconnect }: { pubkey: string; profile: NostrProfile | null; loading: boolean; disconnect: () => Promise<void> }): React.ReactElement {
|
function ConnectedState({ pubkey, profile, loading, disconnect }: { pubkey: string; profile: NostrProfile | null; loading: boolean; disconnect: () => Promise<void> }): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<ConnectedUserMenu
|
<ConnectedUserMenu
|
||||||
@ -63,7 +56,7 @@ function UnlockState({ loading, error, onUnlock, onClose }: { loading: boolean;
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ConnectForm
|
<ConnectForm
|
||||||
onCreateAccount={() => {}}
|
onCreateAccount={noop}
|
||||||
onUnlock={onUnlock}
|
onUnlock={onUnlock}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
error={error}
|
error={error}
|
||||||
@ -73,6 +66,10 @@ function UnlockState({ loading, error, onUnlock, onClose }: { loading: boolean;
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function noop(): void {
|
||||||
|
// Intentionally empty: UnlockState must render ConnectForm but "create account" is not available in this mode.
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function DisconnectedState({
|
function DisconnectedState({
|
||||||
loading,
|
loading,
|
||||||
@ -107,79 +104,45 @@ function DisconnectedState({
|
|||||||
|
|
||||||
export function ConnectButton(): React.ReactElement {
|
export function ConnectButton(): React.ReactElement {
|
||||||
const { connected, pubkey, profile, loading, error, connect, disconnect, accountExists, isUnlocked } = useNostrAuth()
|
const { connected, pubkey, profile, loading, error, connect, disconnect, accountExists, isUnlocked } = useNostrAuth()
|
||||||
const [showRecoveryStep, setShowRecoveryStep] = useState(false)
|
const ui = useConnectButtonUiState()
|
||||||
const [showUnlockModal, setShowUnlockModal] = useState(false)
|
|
||||||
const [recoveryPhrase, setRecoveryPhrase] = useState<string[]>([])
|
|
||||||
const [npub, setNpub] = useState('')
|
|
||||||
const [creatingAccount, setCreatingAccount] = useState(false)
|
|
||||||
const [createError, setCreateError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
useAutoConnect(accountExists, pubkey, false, showUnlockModal, connect)
|
useAutoConnect({
|
||||||
|
accountExists,
|
||||||
|
pubkey,
|
||||||
|
showRecoveryStep: ui.showRecoveryStep,
|
||||||
|
showUnlockModal: ui.showUnlockModal,
|
||||||
|
connect,
|
||||||
|
})
|
||||||
|
|
||||||
const handleCreateAccount = async (): Promise<void> => {
|
const mode = getConnectButtonMode({
|
||||||
setCreatingAccount(true)
|
connected,
|
||||||
setCreateError(null)
|
pubkey,
|
||||||
try {
|
isUnlocked,
|
||||||
const { nostrAuthService } = await import('@/lib/nostrAuth')
|
accountExists,
|
||||||
const result = await nostrAuthService.createAccount()
|
showUnlockModal: ui.showUnlockModal,
|
||||||
setRecoveryPhrase(result.recoveryPhrase)
|
})
|
||||||
setNpub(result.npub)
|
|
||||||
setShowRecoveryStep(true)
|
|
||||||
} catch (e) {
|
|
||||||
setCreateError(e instanceof Error ? e.message : 'Failed to create account')
|
|
||||||
} finally {
|
|
||||||
setCreatingAccount(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleRecoveryContinue = (): void => {
|
if (mode === 'connected') {
|
||||||
setShowRecoveryStep(false)
|
|
||||||
setShowUnlockModal(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleUnlockSuccess = (): void => {
|
|
||||||
setShowUnlockModal(false)
|
|
||||||
setRecoveryPhrase([])
|
|
||||||
setNpub('')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (connected && pubkey && isUnlocked) {
|
|
||||||
return <ConnectedState pubkey={pubkey} profile={profile} loading={loading} disconnect={disconnect} />
|
return <ConnectedState pubkey={pubkey} profile={profile} loading={loading} disconnect={disconnect} />
|
||||||
}
|
}
|
||||||
|
|
||||||
if (accountExists === true && pubkey && !isUnlocked && !showUnlockModal) {
|
if (mode === 'unlock_required') {
|
||||||
return (
|
return <UnlockState loading={loading} error={error} onUnlock={ui.openUnlockModal} onClose={ui.closeUnlockModal} />
|
||||||
<UnlockState
|
|
||||||
loading={loading}
|
|
||||||
error={error}
|
|
||||||
onUnlock={() => setShowUnlockModal(true)}
|
|
||||||
onClose={() => setShowUnlockModal(false)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DisconnectedState
|
<DisconnectedState
|
||||||
loading={loading ?? creatingAccount}
|
loading={loading ?? ui.creatingAccount}
|
||||||
error={error ?? createError}
|
error={error ?? ui.createError}
|
||||||
showUnlockModal={showUnlockModal}
|
showUnlockModal={ui.showUnlockModal}
|
||||||
setShowUnlockModal={setShowUnlockModal}
|
setShowUnlockModal={ui.setShowUnlockModal}
|
||||||
onCreateAccount={() => { void handleCreateAccount() }}
|
onCreateAccount={ui.onCreateAccount}
|
||||||
/>
|
|
||||||
{showRecoveryStep && (
|
|
||||||
<RecoveryStep
|
|
||||||
recoveryPhrase={recoveryPhrase}
|
|
||||||
npub={npub}
|
|
||||||
onContinue={handleRecoveryContinue}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{showUnlockModal && (
|
|
||||||
<UnlockAccountModal
|
|
||||||
onSuccess={handleUnlockSuccess}
|
|
||||||
onClose={() => setShowUnlockModal(false)}
|
|
||||||
/>
|
/>
|
||||||
|
{ui.showRecoveryStep && (
|
||||||
|
<RecoveryStep recoveryPhrase={ui.recoveryPhrase} npub={ui.npub} onContinue={ui.onRecoveryContinue} />
|
||||||
)}
|
)}
|
||||||
|
{ui.showUnlockModal && <UnlockAccountModal onSuccess={ui.onUnlockSuccess} onClose={ui.closeUnlockModal} />}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,35 +14,37 @@ async function createAccountWithKey(key?: string): Promise<{ recoveryPhrase: str
|
|||||||
return nostrAuthService.createAccount(key)
|
return nostrAuthService.createAccount(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleAccountCreation(
|
interface HandleAccountCreationParams {
|
||||||
key: string | undefined,
|
key: string | undefined
|
||||||
setLoading: (loading: boolean) => void,
|
setLoading: (loading: boolean) => void
|
||||||
setError: (error: string | null) => void,
|
setError: (error: string | null) => void
|
||||||
setRecoveryPhrase: (phrase: string[]) => void,
|
setRecoveryPhrase: (phrase: string[]) => void
|
||||||
setNpub: (npub: string) => void,
|
setNpub: (npub: string) => void
|
||||||
setStep: (step: Step) => void,
|
setStep: (step: Step) => void
|
||||||
errorMessage: string
|
errorMessage: string
|
||||||
): Promise<void> {
|
}
|
||||||
if (key !== undefined && !key.trim()) {
|
|
||||||
setError('Please enter a private key')
|
async function handleAccountCreation(params: HandleAccountCreationParams): Promise<void> {
|
||||||
|
if (params.key !== undefined && !params.key.trim()) {
|
||||||
|
params.setError('Please enter a private key')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true)
|
params.setLoading(true)
|
||||||
setError(null)
|
params.setError(null)
|
||||||
try {
|
try {
|
||||||
const result = await createAccountWithKey(key?.trim())
|
const result = await createAccountWithKey(params.key?.trim())
|
||||||
setRecoveryPhrase(result.recoveryPhrase)
|
params.setRecoveryPhrase(result.recoveryPhrase)
|
||||||
setNpub(result.npub)
|
params.setNpub(result.npub)
|
||||||
setStep('recovery')
|
params.setStep('recovery')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : errorMessage)
|
params.setError(e instanceof Error ? e.message : params.errorMessage)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
params.setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function useAccountCreation(initialStep: Step = 'choose'): {
|
interface AccountCreationState {
|
||||||
step: Step
|
step: Step
|
||||||
setStep: (step: Step) => void
|
setStep: (step: Step) => void
|
||||||
importKey: string
|
importKey: string
|
||||||
@ -54,7 +56,9 @@ function useAccountCreation(initialStep: Step = 'choose'): {
|
|||||||
npub: string
|
npub: string
|
||||||
handleGenerate: () => Promise<void>
|
handleGenerate: () => Promise<void>
|
||||||
handleImport: () => Promise<void>
|
handleImport: () => Promise<void>
|
||||||
} {
|
}
|
||||||
|
|
||||||
|
function useAccountCreation(initialStep: Step = 'choose'): AccountCreationState {
|
||||||
const [step, setStep] = useState<Step>(initialStep)
|
const [step, setStep] = useState<Step>(initialStep)
|
||||||
const [importKey, setImportKey] = useState('')
|
const [importKey, setImportKey] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
@ -62,27 +66,29 @@ function useAccountCreation(initialStep: Step = 'choose'): {
|
|||||||
const [recoveryPhrase, setRecoveryPhrase] = useState<string[]>([])
|
const [recoveryPhrase, setRecoveryPhrase] = useState<string[]>([])
|
||||||
const [npub, setNpub] = useState('')
|
const [npub, setNpub] = useState('')
|
||||||
|
|
||||||
const handleGenerate = async (): Promise<void> => {
|
const handleGenerate = (): Promise<void> =>
|
||||||
await handleAccountCreation(undefined, setLoading, setError, setRecoveryPhrase, setNpub, setStep, 'Failed to create account')
|
handleAccountCreation({
|
||||||
}
|
key: undefined,
|
||||||
|
setLoading,
|
||||||
const handleImport = async (): Promise<void> => {
|
|
||||||
await handleAccountCreation(importKey, setLoading, setError, setRecoveryPhrase, setNpub, setStep, 'Failed to import key')
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
step,
|
|
||||||
setStep,
|
|
||||||
importKey,
|
|
||||||
setImportKey,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
setError,
|
setError,
|
||||||
recoveryPhrase,
|
setRecoveryPhrase,
|
||||||
npub,
|
setNpub,
|
||||||
handleGenerate,
|
setStep,
|
||||||
handleImport,
|
errorMessage: 'Failed to create account',
|
||||||
}
|
})
|
||||||
|
|
||||||
|
const handleImport = (): Promise<void> =>
|
||||||
|
handleAccountCreation({
|
||||||
|
key: importKey,
|
||||||
|
setLoading,
|
||||||
|
setError,
|
||||||
|
setRecoveryPhrase,
|
||||||
|
setNpub,
|
||||||
|
setStep,
|
||||||
|
errorMessage: 'Failed to import key',
|
||||||
|
})
|
||||||
|
|
||||||
|
return { step, setStep, importKey, setImportKey, loading, error, setError, recoveryPhrase, npub, handleGenerate, handleImport }
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleImportBack(setStep: (step: Step) => void, setError: (error: string | null) => void, setImportKey: (key: string) => void): void {
|
function handleImportBack(setStep: (step: Step) => void, setError: (error: string | null) => void, setImportKey: (key: string) => void): void {
|
||||||
@ -91,45 +97,47 @@ function handleImportBack(setStep: (step: Step) => void, setError: (error: strin
|
|||||||
setImportKey('')
|
setImportKey('')
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderStep(
|
interface RenderStepParams {
|
||||||
step: Step,
|
step: Step
|
||||||
recoveryPhrase: string[],
|
recoveryPhrase: string[]
|
||||||
npub: string,
|
npub: string
|
||||||
importKey: string,
|
importKey: string
|
||||||
setImportKey: (key: string) => void,
|
setImportKey: (key: string) => void
|
||||||
loading: boolean,
|
loading: boolean
|
||||||
error: string | null,
|
error: string | null
|
||||||
handleContinue: () => void,
|
handleContinue: () => void
|
||||||
handleImport: () => void,
|
handleImport: () => void
|
||||||
setStep: (step: Step) => void,
|
setStep: (step: Step) => void
|
||||||
setError: (error: string | null) => void,
|
setError: (error: string | null) => void
|
||||||
handleGenerate: () => void,
|
handleGenerate: () => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
): React.ReactElement {
|
|
||||||
if (step === 'recovery') {
|
|
||||||
return <RecoveryStep recoveryPhrase={recoveryPhrase} npub={npub} onContinue={handleContinue} />
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (step === 'import') {
|
function renderStep(params: RenderStepParams): React.ReactElement {
|
||||||
|
if (params.step === 'recovery') {
|
||||||
|
return <RecoveryStep recoveryPhrase={params.recoveryPhrase} npub={params.npub} onContinue={params.handleContinue} />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.step === 'import') {
|
||||||
return (
|
return (
|
||||||
<ImportStep
|
<ImportStep
|
||||||
importKey={importKey}
|
importKey={params.importKey}
|
||||||
setImportKey={setImportKey}
|
setImportKey={params.setImportKey}
|
||||||
loading={loading}
|
loading={params.loading}
|
||||||
error={error}
|
error={params.error}
|
||||||
onImport={handleImport}
|
onImport={params.handleImport}
|
||||||
onBack={() => handleImportBack(setStep, setError, setImportKey)}
|
onBack={() => handleImportBack(params.setStep, params.setError, params.setImportKey)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChooseStep
|
<ChooseStep
|
||||||
loading={loading}
|
loading={params.loading}
|
||||||
error={error}
|
error={params.error}
|
||||||
onGenerate={handleGenerate}
|
onGenerate={params.handleGenerate}
|
||||||
onImport={() => setStep('import')}
|
onImport={() => params.setStep('import')}
|
||||||
onClose={onClose}
|
onClose={params.onClose}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -154,7 +162,7 @@ export function CreateAccountModal({ onSuccess, onClose, initialStep = 'choose'
|
|||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
return renderStep(
|
return renderStep({
|
||||||
step,
|
step,
|
||||||
recoveryPhrase,
|
recoveryPhrase,
|
||||||
npub,
|
npub,
|
||||||
@ -163,10 +171,14 @@ export function CreateAccountModal({ onSuccess, onClose, initialStep = 'choose'
|
|||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
handleContinue,
|
handleContinue,
|
||||||
() => { void handleImport() },
|
handleImport: () => {
|
||||||
|
void handleImport()
|
||||||
|
},
|
||||||
setStep,
|
setStep,
|
||||||
setError,
|
setError,
|
||||||
() => { void handleGenerate() },
|
handleGenerate: () => {
|
||||||
onClose
|
void handleGenerate()
|
||||||
)
|
},
|
||||||
|
onClose,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,16 +20,17 @@ export function RecoveryPhraseDisplay({
|
|||||||
copied: boolean
|
copied: boolean
|
||||||
onCopy: () => void
|
onCopy: () => void
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
|
const recoveryItems = buildRecoveryPhraseItems(recoveryPhrase)
|
||||||
return (
|
return (
|
||||||
<div className="bg-cyber-darker border border-neon-cyan/30 rounded-lg p-6 mb-6">
|
<div className="bg-cyber-darker border border-neon-cyan/30 rounded-lg p-6 mb-6">
|
||||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||||
{recoveryPhrase.map((word, index) => (
|
{recoveryItems.map((item, index) => (
|
||||||
<div
|
<div
|
||||||
key={`recovery-word-${index}-${word}`}
|
key={item.key}
|
||||||
className="bg-cyber-dark border border-neon-cyan/30 rounded-lg p-3 text-center font-mono text-lg"
|
className="bg-cyber-dark border border-neon-cyan/30 rounded-lg p-3 text-center font-mono text-lg"
|
||||||
>
|
>
|
||||||
<span className="text-cyber-accent/70 text-sm mr-2">{index + 1}.</span>
|
<span className="text-cyber-accent/70 text-sm mr-2">{index + 1}.</span>
|
||||||
<span className="font-semibold text-neon-cyan">{word}</span>
|
<span className="font-semibold text-neon-cyan">{item.word}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -45,6 +46,15 @@ export function RecoveryPhraseDisplay({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildRecoveryPhraseItems(recoveryPhrase: string[]): { key: string; word: string }[] {
|
||||||
|
const counts = new Map<string, number>()
|
||||||
|
return recoveryPhrase.map((word) => {
|
||||||
|
const nextCount = (counts.get(word) ?? 0) + 1
|
||||||
|
counts.set(word, nextCount)
|
||||||
|
return { key: `${word}-${nextCount}`, word }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function PublicKeyDisplay({ npub }: { npub: string }): React.ReactElement {
|
export function PublicKeyDisplay({ npub }: { npub: string }): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<div className="bg-neon-blue/10 border border-neon-blue/30 rounded-lg p-4 mb-6">
|
<div className="bg-neon-blue/10 border border-neon-blue/30 rounded-lg p-4 mb-6">
|
||||||
|
|||||||
@ -42,18 +42,52 @@ function FundingStats({ stats }: { stats: ReturnType<typeof estimatePlatformFund
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function FundingGauge(): React.ReactElement {
|
export function FundingGauge(): React.ReactElement {
|
||||||
|
const state = useFundingGaugeState()
|
||||||
|
|
||||||
|
if (state.loading) {
|
||||||
|
return <FundingGaugeLoading />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="bg-cyber-dark/50 border border-neon-cyan/20 rounded-lg p-6">
|
||||||
|
<h2 className="text-xl font-semibold text-neon-cyan mb-4">
|
||||||
|
{t('home.funding.title')} - {t('home.funding.priority.ia')}
|
||||||
|
</h2>
|
||||||
|
<FundingStats stats={state.stats} />
|
||||||
|
</div>
|
||||||
|
<div className="bg-cyber-dark/50 border border-neon-cyan/20 rounded-lg p-6">
|
||||||
|
<h2 className="text-xl font-semibold text-neon-cyan mb-4">
|
||||||
|
{t('home.funding.certification.title')} - {t('home.funding.priority.ancrage')}
|
||||||
|
</h2>
|
||||||
|
<FundingStats stats={state.certificationStats} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FundingGaugeLoading(): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="bg-cyber-dark/50 border border-neon-cyan/20 rounded-lg p-6 mb-8">
|
||||||
|
<p className="text-cyber-accent">{t('common.loading')}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function useFundingGaugeState(): {
|
||||||
|
stats: ReturnType<typeof estimatePlatformFunds>
|
||||||
|
certificationStats: ReturnType<typeof estimatePlatformFunds>
|
||||||
|
loading: boolean
|
||||||
|
} {
|
||||||
const [stats, setStats] = useState(estimatePlatformFunds())
|
const [stats, setStats] = useState(estimatePlatformFunds())
|
||||||
const [certificationStats, setCertificationStats] = useState(estimatePlatformFunds())
|
const [certificationStats, setCertificationStats] = useState(estimatePlatformFunds())
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// In a real implementation, this would fetch actual data
|
|
||||||
// For now, we use the estimate
|
|
||||||
const loadStats = async (): Promise<void> => {
|
const loadStats = async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const fundingStats = estimatePlatformFunds()
|
const fundingStats = estimatePlatformFunds()
|
||||||
setStats(fundingStats)
|
setStats(fundingStats)
|
||||||
// Certification uses the same funding pool
|
|
||||||
setCertificationStats(fundingStats)
|
setCertificationStats(fundingStats)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error loading funding stats:', e)
|
console.error('Error loading funding stats:', e)
|
||||||
@ -64,28 +98,5 @@ export function FundingGauge(): React.ReactElement {
|
|||||||
void loadStats()
|
void loadStats()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
if (loading) {
|
return { stats, certificationStats, loading }
|
||||||
return (
|
|
||||||
<div className="bg-cyber-dark/50 border border-neon-cyan/20 rounded-lg p-6 mb-8">
|
|
||||||
<p className="text-cyber-accent">{t('common.loading')}</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="bg-cyber-dark/50 border border-neon-cyan/20 rounded-lg p-6">
|
|
||||||
<h2 className="text-xl font-semibold text-neon-cyan mb-4">
|
|
||||||
{t('home.funding.title')} - {t('home.funding.priority.ia')}
|
|
||||||
</h2>
|
|
||||||
<FundingStats stats={stats} />
|
|
||||||
</div>
|
|
||||||
<div className="bg-cyber-dark/50 border border-neon-cyan/20 rounded-lg p-6">
|
|
||||||
<h2 className="text-xl font-semibold text-neon-cyan mb-4">
|
|
||||||
{t('home.funding.certification.title')} - {t('home.funding.priority.ancrage')}
|
|
||||||
</h2>
|
|
||||||
<FundingStats stats={certificationStats} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -46,19 +46,13 @@ function SyncIcon(): React.ReactElement {
|
|||||||
* Shows sync icon + relay name when syncing
|
* Shows sync icon + relay name when syncing
|
||||||
*/
|
*/
|
||||||
export function SyncStatus(): React.ReactElement | null {
|
export function SyncStatus(): React.ReactElement | null {
|
||||||
const [progress, setProgress] = useState<SyncProgress | null>(null)
|
const [progress, setProgress] = useState<SyncProgress | null>(() => syncProgressManager.getProgress())
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubscribe = syncProgressManager.subscribe((newProgress) => {
|
const unsubscribe = syncProgressManager.subscribe((newProgress) => {
|
||||||
setProgress(newProgress)
|
setProgress(newProgress)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Check current progress immediately
|
|
||||||
const currentProgress = syncProgressManager.getProgress()
|
|
||||||
if (currentProgress) {
|
|
||||||
setProgress(currentProgress)
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsubscribe()
|
unsubscribe()
|
||||||
}
|
}
|
||||||
|
|||||||
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 loadDoc = useCallback(async (docId: DocSection): Promise<void> => {
|
||||||
const doc = docs.find((d) => d.id === docId)
|
const doc = docs.find((d) => d.id === docId)
|
||||||
if (!doc) {return}
|
if (!doc) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setSelectedDoc(docId)
|
setSelectedDoc(docId)
|
||||||
@ -29,7 +31,7 @@ export function useDocs(docs: DocLink[]): {
|
|||||||
try {
|
try {
|
||||||
// Get current locale and pass it to the API
|
// Get current locale and pass it to the API
|
||||||
const locale = getLocale()
|
const locale = getLocale()
|
||||||
const response = await fetch(`/api/docs/${doc.file}?locale=${locale}`)
|
const response = await globalThis.fetch(`/api/docs/${doc.file}?locale=${locale}`)
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const text = await response.text()
|
const text = await response.text()
|
||||||
setDocContent(text)
|
setDocContent(text)
|
||||||
|
|||||||
@ -26,8 +26,8 @@ export function useI18n(locale: Locale = 'fr'): {
|
|||||||
const initialLocale = savedLocale && (savedLocale === 'fr' || savedLocale === 'en') ? savedLocale : locale
|
const initialLocale = savedLocale && (savedLocale === 'fr' || savedLocale === 'en') ? savedLocale : locale
|
||||||
|
|
||||||
// Load translations from files in public directory
|
// Load translations from files in public directory
|
||||||
const frResponse = await fetch('/locales/fr.txt')
|
const frResponse = await globalThis.fetch('/locales/fr.txt')
|
||||||
const enResponse = await fetch('/locales/en.txt')
|
const enResponse = await globalThis.fetch('/locales/en.txt')
|
||||||
|
|
||||||
if (frResponse.ok) {
|
if (frResponse.ok) {
|
||||||
const frText = await frResponse.text()
|
const frText = await frResponse.text()
|
||||||
|
|||||||
@ -20,7 +20,7 @@ export interface DecryptionKey {
|
|||||||
* Generate a random encryption key for AES-GCM
|
* Generate a random encryption key for AES-GCM
|
||||||
*/
|
*/
|
||||||
function generateEncryptionKey(): string {
|
function generateEncryptionKey(): string {
|
||||||
const keyBytes = crypto.getRandomValues(new Uint8Array(32))
|
const keyBytes = globalThis.crypto.getRandomValues(new Uint8Array(32))
|
||||||
return Array.from(keyBytes)
|
return Array.from(keyBytes)
|
||||||
.map((b) => b.toString(16).padStart(2, '0'))
|
.map((b) => b.toString(16).padStart(2, '0'))
|
||||||
.join('')
|
.join('')
|
||||||
@ -30,7 +30,7 @@ function generateEncryptionKey(): string {
|
|||||||
* Generate a random IV for AES-GCM
|
* Generate a random IV for AES-GCM
|
||||||
*/
|
*/
|
||||||
function generateIV(): Uint8Array {
|
function generateIV(): Uint8Array {
|
||||||
return crypto.getRandomValues(new Uint8Array(12))
|
return globalThis.crypto.getRandomValues(new Uint8Array(12))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -60,7 +60,7 @@ function arrayBufferToHex(buffer: ArrayBuffer): string {
|
|||||||
*/
|
*/
|
||||||
async function prepareEncryptionKey(key: string): Promise<CryptoKey> {
|
async function prepareEncryptionKey(key: string): Promise<CryptoKey> {
|
||||||
const keyBuffer = hexToArrayBuffer(key)
|
const keyBuffer = hexToArrayBuffer(key)
|
||||||
return crypto.subtle.importKey('raw', keyBuffer, { name: 'AES-GCM' }, false, ['encrypt'])
|
return globalThis.crypto.subtle.importKey('raw', keyBuffer, { name: 'AES-GCM' }, false, ['encrypt'])
|
||||||
}
|
}
|
||||||
|
|
||||||
function prepareIV(iv: Uint8Array): { view: Uint8Array; buffer: ArrayBuffer } {
|
function prepareIV(iv: Uint8Array): { view: Uint8Array; buffer: ArrayBuffer } {
|
||||||
@ -85,7 +85,7 @@ export async function encryptArticleContent(content: string): Promise<{
|
|||||||
const encodedContent = encoder.encode(content)
|
const encodedContent = encoder.encode(content)
|
||||||
const { view: ivView, buffer: ivBuffer } = prepareIV(iv)
|
const { view: ivView, buffer: ivBuffer } = prepareIV(iv)
|
||||||
|
|
||||||
const encryptedBuffer = await crypto.subtle.encrypt(
|
const encryptedBuffer = await globalThis.crypto.subtle.encrypt(
|
||||||
{ name: 'AES-GCM', iv: ivView as Uint8Array<ArrayBuffer> },
|
{ name: 'AES-GCM', iv: ivView as Uint8Array<ArrayBuffer> },
|
||||||
cryptoKey,
|
cryptoKey,
|
||||||
encodedContent
|
encodedContent
|
||||||
@ -109,7 +109,7 @@ export async function decryptArticleContent(
|
|||||||
const keyBuffer = hexToArrayBuffer(key)
|
const keyBuffer = hexToArrayBuffer(key)
|
||||||
const ivBuffer = hexToArrayBuffer(iv)
|
const ivBuffer = hexToArrayBuffer(iv)
|
||||||
|
|
||||||
const cryptoKey = await crypto.subtle.importKey(
|
const cryptoKey = await globalThis.crypto.subtle.importKey(
|
||||||
'raw',
|
'raw',
|
||||||
keyBuffer,
|
keyBuffer,
|
||||||
{ name: 'AES-GCM' },
|
{ name: 'AES-GCM' },
|
||||||
@ -119,7 +119,7 @@ export async function decryptArticleContent(
|
|||||||
|
|
||||||
const encryptedBuffer = hexToArrayBuffer(encryptedContent)
|
const encryptedBuffer = hexToArrayBuffer(encryptedContent)
|
||||||
|
|
||||||
const decryptedBuffer = await crypto.subtle.decrypt(
|
const decryptedBuffer = await globalThis.crypto.subtle.decrypt(
|
||||||
{
|
{
|
||||||
name: 'AES-GCM',
|
name: 'AES-GCM',
|
||||||
iv: ivBuffer,
|
iv: ivBuffer,
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { type Event } from 'nostr-tools'
|
|||||||
import { nip19 } from 'nostr-tools'
|
import { nip19 } from 'nostr-tools'
|
||||||
import type { AuthorPresentationDraft } from './articlePublisher'
|
import type { AuthorPresentationDraft } from './articlePublisher'
|
||||||
import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
|
import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
|
||||||
|
import { createSubscription } from '@/types/nostr-tools-extended'
|
||||||
import { buildTags, extractTagsFromEvent, buildTagFilter } from './nostrTagSystem'
|
import { buildTags, extractTagsFromEvent, buildTagFilter } from './nostrTagSystem'
|
||||||
import { getPrimaryRelaySync } from './config'
|
import { getPrimaryRelaySync } from './config'
|
||||||
import { PLATFORM_SERVICE, MIN_EVENT_DATE } from './platformConfig'
|
import { PLATFORM_SERVICE, MIN_EVENT_DATE } from './platformConfig'
|
||||||
@ -297,7 +298,6 @@ export async function fetchAuthorPresentationFromPool(
|
|||||||
return new Promise<import('@/types/nostr').AuthorPresentationArticle | null>((resolve) => {
|
return new Promise<import('@/types/nostr').AuthorPresentationArticle | null>((resolve) => {
|
||||||
let resolved = false
|
let resolved = false
|
||||||
const relayUrl = getPrimaryRelaySync()
|
const relayUrl = getPrimaryRelaySync()
|
||||||
const { createSubscription } = require('@/types/nostr-tools-extended')
|
|
||||||
const sub = createSubscription(pool, [relayUrl], filters)
|
const sub = createSubscription(pool, [relayUrl], filters)
|
||||||
|
|
||||||
const events: Event[] = []
|
const events: Event[] = []
|
||||||
|
|||||||
@ -28,12 +28,12 @@ async function getOrCreateMasterKey(): Promise<string> {
|
|||||||
if (existing) {
|
if (existing) {
|
||||||
return existing
|
return existing
|
||||||
}
|
}
|
||||||
const keyBytes = crypto.getRandomValues(new Uint8Array(32))
|
const keyBytes = globalThis.crypto.getRandomValues(new Uint8Array(32))
|
||||||
let binary = ''
|
let binary = ''
|
||||||
keyBytes.forEach((b) => {
|
keyBytes.forEach((b) => {
|
||||||
binary += String.fromCharCode(b)
|
binary += String.fromCharCode(b)
|
||||||
})
|
})
|
||||||
const key = btoa(binary)
|
const key = globalThis.btoa(binary)
|
||||||
await storageService.set(MASTER_KEY_STORAGE_KEY, key, 'article_storage')
|
await storageService.set(MASTER_KEY_STORAGE_KEY, key, 'article_storage')
|
||||||
return key
|
return key
|
||||||
}
|
}
|
||||||
|
|||||||
@ -60,7 +60,7 @@ export class ConfigStorage {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const request = indexedDB.open(DB_NAME, DB_VERSION)
|
const request = globalThis.indexedDB.open(DB_NAME, DB_VERSION)
|
||||||
|
|
||||||
request.onerror = () => {
|
request.onerror = () => {
|
||||||
reject(new Error(`Failed to open IndexedDB: ${request.error}`))
|
reject(new Error(`Failed to open IndexedDB: ${request.error}`))
|
||||||
|
|||||||
@ -38,7 +38,7 @@ export async function generateHashId(obj: Record<string, unknown>): Promise<stri
|
|||||||
const canonical = canonicalizeObject(obj)
|
const canonical = canonicalizeObject(obj)
|
||||||
const encoder = new TextEncoder()
|
const encoder = new TextEncoder()
|
||||||
const data = encoder.encode(canonical)
|
const data = encoder.encode(canonical)
|
||||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
|
const hashBuffer = await globalThis.crypto.subtle.digest('SHA-256', data)
|
||||||
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
||||||
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
|
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,8 +27,8 @@ export function useSyncProgress(options: UseSyncProgressOptions = {}): UseSyncPr
|
|||||||
|
|
||||||
const [syncProgress, setSyncProgress] = useState<SyncProgress | null>(null)
|
const [syncProgress, setSyncProgress] = useState<SyncProgress | null>(null)
|
||||||
const [isSyncing, setIsSyncing] = useState(false)
|
const [isSyncing, setIsSyncing] = useState(false)
|
||||||
const intervalRef = useRef<NodeJS.Timeout | null>(null)
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
|
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const onCompleteRef = useRef(onComplete)
|
const onCompleteRef = useRef(onComplete)
|
||||||
const isMonitoringRef = useRef(false)
|
const isMonitoringRef = useRef(false)
|
||||||
|
|
||||||
@ -105,4 +105,3 @@ export function useSyncProgress(options: UseSyncProgressOptions = {}): UseSyncPr
|
|||||||
stopMonitoring,
|
stopMonitoring,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -217,7 +217,7 @@ export const BIP39_WORDLIST = [
|
|||||||
*/
|
*/
|
||||||
export function generateRecoveryPhrase(): string[] {
|
export function generateRecoveryPhrase(): string[] {
|
||||||
const words: string[] = []
|
const words: string[] = []
|
||||||
const random = crypto.getRandomValues(new Uint32Array(4))
|
const random = globalThis.crypto.getRandomValues(new Uint32Array(4))
|
||||||
|
|
||||||
for (let i = 0; i < 4; i += 1) {
|
for (let i = 0; i < 4; i += 1) {
|
||||||
const randomValue = random[i]
|
const randomValue = random[i]
|
||||||
|
|||||||
@ -19,7 +19,7 @@ const PBKDF2_HASH = 'SHA-256'
|
|||||||
* Generate a random KEK (Key Encryption Key)
|
* Generate a random KEK (Key Encryption Key)
|
||||||
*/
|
*/
|
||||||
async function generateKEK(): Promise<CryptoKey> {
|
async function generateKEK(): Promise<CryptoKey> {
|
||||||
return crypto.subtle.generateKey(
|
return globalThis.crypto.subtle.generateKey(
|
||||||
{ name: 'AES-GCM', length: 256 },
|
{ name: 'AES-GCM', length: 256 },
|
||||||
true, // extractable
|
true, // extractable
|
||||||
['encrypt', 'decrypt']
|
['encrypt', 'decrypt']
|
||||||
@ -35,12 +35,12 @@ async function deriveKeyFromPhrase(phrase: string[]): Promise<CryptoKey> {
|
|||||||
const password = encoder.encode(phraseString)
|
const password = encoder.encode(phraseString)
|
||||||
|
|
||||||
// Generate deterministic salt from phrase
|
// Generate deterministic salt from phrase
|
||||||
const saltBuffer = await crypto.subtle.digest('SHA-256', password)
|
const saltBuffer = await globalThis.crypto.subtle.digest('SHA-256', password)
|
||||||
const saltArray = new Uint8Array(saltBuffer)
|
const saltArray = new Uint8Array(saltBuffer)
|
||||||
const salt = saltArray.slice(0, 32)
|
const salt = saltArray.slice(0, 32)
|
||||||
|
|
||||||
// Import password as key material
|
// Import password as key material
|
||||||
const keyMaterial = await crypto.subtle.importKey(
|
const keyMaterial = await globalThis.crypto.subtle.importKey(
|
||||||
'raw',
|
'raw',
|
||||||
password,
|
password,
|
||||||
'PBKDF2',
|
'PBKDF2',
|
||||||
@ -49,7 +49,7 @@ async function deriveKeyFromPhrase(phrase: string[]): Promise<CryptoKey> {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Derive key using PBKDF2
|
// Derive key using PBKDF2
|
||||||
const derivedKey = await crypto.subtle.deriveKey(
|
const derivedKey = await globalThis.crypto.subtle.deriveKey(
|
||||||
{
|
{
|
||||||
name: 'PBKDF2',
|
name: 'PBKDF2',
|
||||||
salt,
|
salt,
|
||||||
@ -69,7 +69,7 @@ async function deriveKeyFromPhrase(phrase: string[]): Promise<CryptoKey> {
|
|||||||
* Export KEK to raw bytes (for storage)
|
* Export KEK to raw bytes (for storage)
|
||||||
*/
|
*/
|
||||||
async function exportKEK(kek: CryptoKey): Promise<Uint8Array> {
|
async function exportKEK(kek: CryptoKey): Promise<Uint8Array> {
|
||||||
const exported = await crypto.subtle.exportKey('raw', kek)
|
const exported = await globalThis.crypto.subtle.exportKey('raw', kek)
|
||||||
return new Uint8Array(exported)
|
return new Uint8Array(exported)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -81,7 +81,7 @@ async function importKEK(keyBytes: Uint8Array): Promise<CryptoKey> {
|
|||||||
const buffer = new ArrayBuffer(keyBytes.length)
|
const buffer = new ArrayBuffer(keyBytes.length)
|
||||||
const view = new Uint8Array(buffer)
|
const view = new Uint8Array(buffer)
|
||||||
view.set(keyBytes)
|
view.set(keyBytes)
|
||||||
return crypto.subtle.importKey(
|
return globalThis.crypto.subtle.importKey(
|
||||||
'raw',
|
'raw',
|
||||||
buffer,
|
buffer,
|
||||||
{ name: 'AES-GCM' },
|
{ name: 'AES-GCM' },
|
||||||
@ -99,9 +99,9 @@ async function encryptKEK(kek: CryptoKey, recoveryPhrase: string[]): Promise<Enc
|
|||||||
|
|
||||||
const encoder = new TextEncoder()
|
const encoder = new TextEncoder()
|
||||||
const data = encoder.encode(Array.from(kekBytes).map(b => b.toString(16).padStart(2, '0')).join(''))
|
const data = encoder.encode(Array.from(kekBytes).map(b => b.toString(16).padStart(2, '0')).join(''))
|
||||||
const iv = crypto.getRandomValues(new Uint8Array(12))
|
const iv = globalThis.crypto.getRandomValues(new Uint8Array(12))
|
||||||
|
|
||||||
const encrypted = await crypto.subtle.encrypt(
|
const encrypted = await globalThis.crypto.subtle.encrypt(
|
||||||
{ name: 'AES-GCM', iv },
|
{ name: 'AES-GCM', iv },
|
||||||
phraseKey,
|
phraseKey,
|
||||||
data
|
data
|
||||||
@ -114,7 +114,7 @@ async function encryptKEK(kek: CryptoKey, recoveryPhrase: string[]): Promise<Enc
|
|||||||
bytes.forEach((b) => {
|
bytes.forEach((b) => {
|
||||||
binary += String.fromCharCode(b)
|
binary += String.fromCharCode(b)
|
||||||
})
|
})
|
||||||
return btoa(binary)
|
return globalThis.btoa(binary)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -130,7 +130,7 @@ async function decryptKEK(encryptedKEK: EncryptedPayload, recoveryPhrase: string
|
|||||||
const phraseKey = await deriveKeyFromPhrase(recoveryPhrase)
|
const phraseKey = await deriveKeyFromPhrase(recoveryPhrase)
|
||||||
|
|
||||||
function fromBase64(value: string): Uint8Array {
|
function fromBase64(value: string): Uint8Array {
|
||||||
const binary = atob(value)
|
const binary = globalThis.atob(value)
|
||||||
const bytes = new Uint8Array(binary.length)
|
const bytes = new Uint8Array(binary.length)
|
||||||
for (let i = 0; i < binary.length; i += 1) {
|
for (let i = 0; i < binary.length; i += 1) {
|
||||||
bytes[i] = binary.charCodeAt(i)
|
bytes[i] = binary.charCodeAt(i)
|
||||||
@ -150,7 +150,7 @@ async function decryptKEK(encryptedKEK: EncryptedPayload, recoveryPhrase: string
|
|||||||
const cipherView = new Uint8Array(cipherBuffer)
|
const cipherView = new Uint8Array(cipherBuffer)
|
||||||
cipherView.set(ciphertext)
|
cipherView.set(ciphertext)
|
||||||
|
|
||||||
const decrypted = await crypto.subtle.decrypt(
|
const decrypted = await globalThis.crypto.subtle.decrypt(
|
||||||
{ name: 'AES-GCM', iv: ivView },
|
{ name: 'AES-GCM', iv: ivView },
|
||||||
phraseKey,
|
phraseKey,
|
||||||
cipherBuffer
|
cipherBuffer
|
||||||
@ -171,9 +171,9 @@ async function decryptKEK(encryptedKEK: EncryptedPayload, recoveryPhrase: string
|
|||||||
async function encryptPrivateKeyWithKEK(privateKey: string, kek: CryptoKey): Promise<EncryptedPayload> {
|
async function encryptPrivateKeyWithKEK(privateKey: string, kek: CryptoKey): Promise<EncryptedPayload> {
|
||||||
const encoder = new TextEncoder()
|
const encoder = new TextEncoder()
|
||||||
const data = encoder.encode(privateKey)
|
const data = encoder.encode(privateKey)
|
||||||
const iv = crypto.getRandomValues(new Uint8Array(12))
|
const iv = globalThis.crypto.getRandomValues(new Uint8Array(12))
|
||||||
|
|
||||||
const encrypted = await crypto.subtle.encrypt(
|
const encrypted = await globalThis.crypto.subtle.encrypt(
|
||||||
{ name: 'AES-GCM', iv },
|
{ name: 'AES-GCM', iv },
|
||||||
kek,
|
kek,
|
||||||
data
|
data
|
||||||
@ -186,7 +186,7 @@ async function encryptPrivateKeyWithKEK(privateKey: string, kek: CryptoKey): Pro
|
|||||||
bytes.forEach((b) => {
|
bytes.forEach((b) => {
|
||||||
binary += String.fromCharCode(b)
|
binary += String.fromCharCode(b)
|
||||||
})
|
})
|
||||||
return btoa(binary)
|
return globalThis.btoa(binary)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -200,7 +200,7 @@ async function encryptPrivateKeyWithKEK(privateKey: string, kek: CryptoKey): Pro
|
|||||||
*/
|
*/
|
||||||
async function decryptPrivateKeyWithKEK(encryptedPrivateKey: EncryptedPayload, kek: CryptoKey): Promise<string> {
|
async function decryptPrivateKeyWithKEK(encryptedPrivateKey: EncryptedPayload, kek: CryptoKey): Promise<string> {
|
||||||
function fromBase64(value: string): Uint8Array {
|
function fromBase64(value: string): Uint8Array {
|
||||||
const binary = atob(value)
|
const binary = globalThis.atob(value)
|
||||||
const bytes = new Uint8Array(binary.length)
|
const bytes = new Uint8Array(binary.length)
|
||||||
for (let i = 0; i < binary.length; i += 1) {
|
for (let i = 0; i < binary.length; i += 1) {
|
||||||
bytes[i] = binary.charCodeAt(i)
|
bytes[i] = binary.charCodeAt(i)
|
||||||
@ -220,7 +220,7 @@ async function decryptPrivateKeyWithKEK(encryptedPrivateKey: EncryptedPayload, k
|
|||||||
const cipherView = new Uint8Array(cipherBuffer)
|
const cipherView = new Uint8Array(cipherBuffer)
|
||||||
cipherView.set(ciphertext)
|
cipherView.set(ciphertext)
|
||||||
|
|
||||||
const decrypted = await crypto.subtle.decrypt(
|
const decrypted = await globalThis.crypto.subtle.decrypt(
|
||||||
{ name: 'AES-GCM', iv: ivView },
|
{ name: 'AES-GCM', iv: ivView },
|
||||||
kek,
|
kek,
|
||||||
cipherBuffer
|
cipherBuffer
|
||||||
|
|||||||
@ -4,7 +4,7 @@ const MEMPOOL_API_BASE = 'https://mempool.space/api'
|
|||||||
|
|
||||||
export async function getTransaction(txid: string): Promise<MempoolTransaction | null> {
|
export async function getTransaction(txid: string): Promise<MempoolTransaction | null> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${MEMPOOL_API_BASE}/tx/${txid}`)
|
const response = await globalThis.fetch(`${MEMPOOL_API_BASE}/tx/${txid}`)
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
if (response.status === 404) {
|
if (response.status === 404) {
|
||||||
@ -28,7 +28,7 @@ export async function getTransaction(txid: string): Promise<MempoolTransaction |
|
|||||||
|
|
||||||
export async function getConfirmations(blockHeight: number): Promise<number> {
|
export async function getConfirmations(blockHeight: number): Promise<number> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${MEMPOOL_API_BASE}/blocks/tip/height`)
|
const response = await globalThis.fetch(`${MEMPOOL_API_BASE}/blocks/tip/height`)
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|||||||
@ -77,7 +77,7 @@ function parseUploadResponse(result: unknown, endpoint: string): string {
|
|||||||
async function tryUploadEndpoint(endpoint: string, formData: FormData, useProxy: boolean = false): Promise<string> {
|
async function tryUploadEndpoint(endpoint: string, formData: FormData, useProxy: boolean = false): Promise<string> {
|
||||||
const targetUrl = useProxy ? endpoint : endpoint
|
const targetUrl = useProxy ? endpoint : endpoint
|
||||||
|
|
||||||
const response = await fetch(targetUrl, {
|
const response = await globalThis.fetch(targetUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
body: formData,
|
||||||
// Don't set Content-Type manually - browser will set it with boundary automatically
|
// Don't set Content-Type manually - browser will set it with boundary automatically
|
||||||
|
|||||||
@ -63,7 +63,7 @@ export async function generateNip98Token(method: string, url: string, payloadHas
|
|||||||
// Encode event as base64 JSON
|
// Encode event as base64 JSON
|
||||||
const eventJson = JSON.stringify(signedEvent)
|
const eventJson = JSON.stringify(signedEvent)
|
||||||
const eventBytes = new TextEncoder().encode(eventJson)
|
const eventBytes = new TextEncoder().encode(eventJson)
|
||||||
const base64Token = btoa(String.fromCharCode(...eventBytes))
|
const base64Token = globalThis.btoa(String.fromCharCode(...eventBytes))
|
||||||
|
|
||||||
return base64Token
|
return base64Token
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { Event, nip04 } from 'nostr-tools'
|
|||||||
import { SimplePool } from 'nostr-tools'
|
import { SimplePool } from 'nostr-tools'
|
||||||
import { decryptArticleContent, type DecryptionKey } from './articleEncryption'
|
import { decryptArticleContent, type DecryptionKey } from './articleEncryption'
|
||||||
import { getPrimaryRelaySync } from './config'
|
import { getPrimaryRelaySync } from './config'
|
||||||
|
import { createSubscription } from '@/types/nostr-tools-extended'
|
||||||
|
|
||||||
function createPrivateMessageFilters(eventId: string, publicKey: string, authorPubkey: string): Array<{
|
function createPrivateMessageFilters(eventId: string, publicKey: string, authorPubkey: string): Array<{
|
||||||
kinds: number[]
|
kinds: number[]
|
||||||
@ -45,7 +46,6 @@ export function getPrivateContent(
|
|||||||
return new Promise<string | null>((resolve) => {
|
return new Promise<string | null>((resolve) => {
|
||||||
let resolved = false
|
let resolved = false
|
||||||
const relayUrl = getPrimaryRelaySync()
|
const relayUrl = getPrimaryRelaySync()
|
||||||
const { createSubscription } = require('@/types/nostr-tools-extended')
|
|
||||||
const sub = createSubscription(pool, [relayUrl], createPrivateMessageFilters(eventId, publicKey, authorPubkey))
|
const sub = createSubscription(pool, [relayUrl], createPrivateMessageFilters(eventId, publicKey, authorPubkey))
|
||||||
|
|
||||||
const finalize = (result: string | null): void => {
|
const finalize = (result: string | null): void => {
|
||||||
@ -122,7 +122,6 @@ export async function getDecryptionKey(
|
|||||||
return new Promise<DecryptionKey | null>((resolve) => {
|
return new Promise<DecryptionKey | null>((resolve) => {
|
||||||
let resolved = false
|
let resolved = false
|
||||||
const relayUrl = getPrimaryRelaySync()
|
const relayUrl = getPrimaryRelaySync()
|
||||||
const { createSubscription } = require('@/types/nostr-tools-extended')
|
|
||||||
const sub = createSubscription(pool, [relayUrl], createPrivateMessageFilters(eventId, recipientPublicKey, authorPubkey))
|
const sub = createSubscription(pool, [relayUrl], createPrivateMessageFilters(eventId, recipientPublicKey, authorPubkey))
|
||||||
|
|
||||||
const finalize = (result: DecryptionKey | null): void => {
|
const finalize = (result: DecryptionKey | null): void => {
|
||||||
|
|||||||
@ -178,7 +178,8 @@ class ObjectCacheService {
|
|||||||
// Notify about published status change (false -> array of relays)
|
// Notify about published status change (false -> array of relays)
|
||||||
if (oldPublished === false && Array.isArray(published) && published.length > 0) {
|
if (oldPublished === false && Array.isArray(published) && published.length > 0) {
|
||||||
const eventId = id.split(':')[1] ?? id
|
const eventId = id.split(':')[1] ?? id
|
||||||
const { notificationDetector } = require('./notificationDetector')
|
void import('./notificationDetector')
|
||||||
|
.then(({ notificationDetector }) => {
|
||||||
void notificationDetector.checkObjectChange({
|
void notificationDetector.checkObjectChange({
|
||||||
objectType,
|
objectType,
|
||||||
objectId: id,
|
objectId: id,
|
||||||
@ -186,6 +187,10 @@ class ObjectCacheService {
|
|||||||
oldPublished,
|
oldPublished,
|
||||||
newPublished: published,
|
newPublished: published,
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to notify published status change:', error)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
} catch (updateError) {
|
} catch (updateError) {
|
||||||
console.error(`Error updating published status for ${objectType} object:`, updateError)
|
console.error(`Error updating published status for ${objectType} object:`, updateError)
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { nostrService } from './nostr'
|
import { nostrService } from './nostr'
|
||||||
import { getPrimaryRelaySync } from './config'
|
import { getPrimaryRelaySync } from './config'
|
||||||
import type { Event } from 'nostr-tools'
|
import type { Event } from 'nostr-tools'
|
||||||
|
import { createSubscription } from '@/types/nostr-tools-extended'
|
||||||
|
|
||||||
export function parseZapAmount(event: import('nostr-tools').Event): number {
|
export function parseZapAmount(event: import('nostr-tools').Event): number {
|
||||||
const amountTag = event.tags.find((tag) => tag[0] === 'amount')?.[1]
|
const amountTag = event.tags.find((tag) => tag[0] === 'amount')?.[1]
|
||||||
@ -17,7 +18,6 @@ export function createZapReceiptSubscription(pool: import('nostr-tools').SimpleP
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
const relayUrl = getPrimaryRelaySync()
|
const relayUrl = getPrimaryRelaySync()
|
||||||
const { createSubscription } = require('@/types/nostr-tools-extended')
|
|
||||||
return createSubscription(pool, [relayUrl], filters)
|
return createSubscription(pool, [relayUrl], filters)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
import type { Event } from 'nostr-tools'
|
import type { Event } from 'nostr-tools'
|
||||||
import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
|
import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
|
||||||
|
import { createSubscription } from '@/types/nostr-tools-extended'
|
||||||
import { nostrService } from './nostr'
|
import { nostrService } from './nostr'
|
||||||
import { PLATFORM_SERVICE, MIN_EVENT_DATE } from './platformConfig'
|
import { PLATFORM_SERVICE, MIN_EVENT_DATE } from './platformConfig'
|
||||||
import { extractTagsFromEvent } from './nostrTagSystem'
|
import { extractTagsFromEvent } from './nostrTagSystem'
|
||||||
@ -109,7 +110,6 @@ class PlatformSyncService {
|
|||||||
try {
|
try {
|
||||||
console.warn(`[PlatformSync] Synchronizing from relay ${i + 1}/${activeRelays.length}: ${relayUrl}`)
|
console.warn(`[PlatformSync] Synchronizing from relay ${i + 1}/${activeRelays.length}: ${relayUrl}`)
|
||||||
|
|
||||||
const { createSubscription } = require('@/types/nostr-tools-extended')
|
|
||||||
const sub = createSubscription(pool, [relayUrl], filters)
|
const sub = createSubscription(pool, [relayUrl], filters)
|
||||||
|
|
||||||
const relayEvents: Event[] = []
|
const relayEvents: Event[] = []
|
||||||
|
|||||||
@ -22,7 +22,7 @@ interface UnpublishedObject {
|
|||||||
|
|
||||||
class PublishWorkerService {
|
class PublishWorkerService {
|
||||||
private isRunning = false
|
private isRunning = false
|
||||||
private intervalId: NodeJS.Timeout | null = null
|
private intervalId: ReturnType<typeof setInterval> | null = null
|
||||||
private unpublishedObjects: Map<string, UnpublishedObject> = new Map()
|
private unpublishedObjects: Map<string, UnpublishedObject> = new Map()
|
||||||
private processing = false
|
private processing = false
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
import { websocketService } from './websocketService'
|
import { websocketService } from './websocketService'
|
||||||
import { writeService } from './writeService'
|
import { writeService } from './writeService'
|
||||||
import type { Event, EventTemplate } from 'nostr-tools'
|
import type { EventTemplate } from 'nostr-tools'
|
||||||
import { finalizeEvent } from 'nostr-tools'
|
import { finalizeEvent } from 'nostr-tools'
|
||||||
import { hexToBytes } from 'nostr-tools/utils'
|
import { hexToBytes } from 'nostr-tools/utils'
|
||||||
import type { ObjectType } from './objectCache'
|
import type { ObjectType } from './objectCache'
|
||||||
@ -94,7 +94,7 @@ class WriteOrchestrator {
|
|||||||
version: number,
|
version: number,
|
||||||
hidden: boolean,
|
hidden: boolean,
|
||||||
index?: number
|
index?: number
|
||||||
): Promise<{ success: boolean; event: Event; published: false | string[] }> {
|
): Promise<{ success: boolean; event: NostrEvent; published: false | string[] }> {
|
||||||
if (!this.privateKey) {
|
if (!this.privateKey) {
|
||||||
throw new Error('Private key not set')
|
throw new Error('Private key not set')
|
||||||
}
|
}
|
||||||
@ -124,7 +124,7 @@ class WriteOrchestrator {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
success: result.success,
|
success: result.success,
|
||||||
event,
|
event: finalizedEvent,
|
||||||
published: result.published,
|
published: result.published,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import type { Event } from 'nostr-tools'
|
import type { Event } from 'nostr-tools'
|
||||||
import { nostrService } from './nostr'
|
import { nostrService } from './nostr'
|
||||||
import type { Subscription } from '@/types/nostr-tools-extended'
|
import type { Subscription } from '@/types/nostr-tools-extended'
|
||||||
|
import { createSubscription } from '@/types/nostr-tools-extended'
|
||||||
import { getPrimaryRelaySync } from './config'
|
import { getPrimaryRelaySync } from './config'
|
||||||
|
|
||||||
interface ZapAggregationFilter {
|
interface ZapAggregationFilter {
|
||||||
@ -50,7 +51,6 @@ export function aggregateZapSats(params: ZapAggregationFilter): Promise<number>
|
|||||||
const filters = buildFilters(params)
|
const filters = buildFilters(params)
|
||||||
const relay = getPrimaryRelaySync()
|
const relay = getPrimaryRelaySync()
|
||||||
const timeout = params.timeoutMs ?? 5000
|
const timeout = params.timeoutMs ?? 5000
|
||||||
const { createSubscription } = require('@/types/nostr-tools-extended')
|
|
||||||
|
|
||||||
const sub = createSubscription(pool, [relay], filters)
|
const sub = createSubscription(pool, [relay], filters)
|
||||||
return collectZap(sub, timeout)
|
return collectZap(sub, timeout)
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import '@/styles/globals.css'
|
import '@/styles/globals.css'
|
||||||
import type { AppProps } from 'next/app'
|
import type { AppProps } from 'next/app'
|
||||||
|
import Router from 'next/router'
|
||||||
import { useI18n } from '@/hooks/useI18n'
|
import { useI18n } from '@/hooks/useI18n'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { nostrAuthService } from '@/lib/nostrAuth'
|
import { nostrAuthService } from '@/lib/nostrAuth'
|
||||||
@ -92,11 +93,10 @@ export default function App({ Component, pageProps }: AppProps): React.ReactElem
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Listen to route changes
|
// Listen to route changes
|
||||||
const router = require('next/router').default
|
Router.events?.on('routeChangeComplete', handleRouteChange)
|
||||||
router.events?.on('routeChangeComplete', handleRouteChange)
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
router.events?.off('routeChangeComplete', handleRouteChange)
|
Router.events?.off('routeChangeComplete', handleRouteChange)
|
||||||
void swClient.stopPlatformSync()
|
void swClient.stopPlatformSync()
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
@ -123,12 +123,11 @@ export default function App({ Component, pageProps }: AppProps): React.ReactElem
|
|||||||
void startUserSync()
|
void startUserSync()
|
||||||
|
|
||||||
// Also listen to connection changes and route changes to sync when user connects or navigates
|
// Also listen to connection changes and route changes to sync when user connects or navigates
|
||||||
const router = require('next/router').default
|
|
||||||
const handleRouteChange = (): void => {
|
const handleRouteChange = (): void => {
|
||||||
void startUserSync()
|
void startUserSync()
|
||||||
}
|
}
|
||||||
|
|
||||||
router.events?.on('routeChangeComplete', handleRouteChange)
|
Router.events?.on('routeChangeComplete', handleRouteChange)
|
||||||
|
|
||||||
const unsubscribe = nostrAuthService.subscribe((state) => {
|
const unsubscribe = nostrAuthService.subscribe((state) => {
|
||||||
if (state.connected && state.pubkey) {
|
if (state.connected && state.pubkey) {
|
||||||
@ -137,7 +136,7 @@ export default function App({ Component, pageProps }: AppProps): React.ReactElem
|
|||||||
})
|
})
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
router.events?.off('routeChangeComplete', handleRouteChange)
|
Router.events?.off('routeChangeComplete', handleRouteChange)
|
||||||
unsubscribe()
|
unsubscribe()
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|||||||
@ -193,7 +193,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||||||
|
|
||||||
proxyRequest.on('error', (error) => {
|
proxyRequest.on('error', (error) => {
|
||||||
// Check for DNS errors specifically
|
// Check for DNS errors specifically
|
||||||
const errorCode = (error as NodeJS.ErrnoException).code
|
const errorCode = getErrnoCode(error)
|
||||||
if (errorCode === 'ENOTFOUND' || errorCode === 'EAI_AGAIN') {
|
if (errorCode === 'ENOTFOUND' || errorCode === 'EAI_AGAIN') {
|
||||||
console.error('NIP-95 proxy DNS error:', {
|
console.error('NIP-95 proxy DNS error:', {
|
||||||
targetEndpoint,
|
targetEndpoint,
|
||||||
@ -373,3 +373,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getErrnoCode(error: unknown): string | undefined {
|
||||||
|
if (typeof error !== 'object' || error === null) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
const maybe = error as { code?: unknown }
|
||||||
|
return typeof maybe.code === 'string' ? maybe.code : undefined
|
||||||
|
}
|
||||||
|
|||||||
@ -7,12 +7,17 @@ const CACHE_NAME = 'zapwall-sync-v1'
|
|||||||
const SYNC_INTERVAL_MS = 60000 // 1 minute
|
const SYNC_INTERVAL_MS = 60000 // 1 minute
|
||||||
const REPUBLISH_INTERVAL_MS = 30000 // 30 seconds
|
const REPUBLISH_INTERVAL_MS = 30000 // 30 seconds
|
||||||
|
|
||||||
|
const self = globalThis
|
||||||
|
const caches = globalThis.caches
|
||||||
|
const setInterval = globalThis.setInterval
|
||||||
|
const clearInterval = globalThis.clearInterval
|
||||||
|
|
||||||
let syncInProgress = false
|
let syncInProgress = false
|
||||||
let publishWorkerInterval = null
|
let publishWorkerInterval = null
|
||||||
let notificationDetectorInterval = null
|
let notificationDetectorInterval = null
|
||||||
|
|
||||||
// Install event - cache resources
|
// Install event - cache resources
|
||||||
self.addEventListener('install', (event) => {
|
self.addEventListener('install', () => {
|
||||||
console.log('[SW] Service Worker installing')
|
console.log('[SW] Service Worker installing')
|
||||||
self.skipWaiting() // Activate immediately
|
self.skipWaiting() // Activate immediately
|
||||||
})
|
})
|
||||||
|
|||||||
@ -18,6 +18,10 @@ const DB_VERSIONS = {
|
|||||||
payment_note: 3,
|
payment_note: 3,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const self = globalThis
|
||||||
|
const indexedDB = globalThis.indexedDB
|
||||||
|
const IDBKeyRange = globalThis.IDBKeyRange
|
||||||
|
|
||||||
// Pile d'écritures pour éviter les conflits
|
// Pile d'écritures pour éviter les conflits
|
||||||
const writeQueue = []
|
const writeQueue = []
|
||||||
let processingQueue = false
|
let processingQueue = false
|
||||||
|
|||||||
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