lint fix wip

This commit is contained in:
Nicolas Cantu 2026-01-08 23:53:05 +01:00
parent 20a46ce2bc
commit 899c20631a
17 changed files with 1159 additions and 667 deletions

View File

@ -1,227 +1,11 @@
import { useState } from 'react'
import { ImageUploadField } from './ImageUploadField'
import { publishSeries } from '@/lib/articleMutations'
import { useNostrAuth } from '@/hooks/useNostrAuth'
import { nostrService } from '@/lib/nostr'
import { t } from '@/lib/i18n'
import type { ArticleDraft } from '@/lib/articlePublisherTypes'
interface CreateSeriesModalProps {
isOpen: boolean
onClose: () => void
onSuccess: () => void
authorPubkey: string
}
interface SeriesDraft {
title: string
description: string
preview: string
coverUrl: string
category: ArticleDraft['category']
}
import type { CreateSeriesModalProps } from './createSeriesModal/createSeriesModalTypes'
import { CreateSeriesModalView } from './createSeriesModal/CreateSeriesModalView'
import { useCreateSeriesModalController } from './createSeriesModal/useCreateSeriesModalController'
export function CreateSeriesModal({ isOpen, onClose, onSuccess, authorPubkey }: CreateSeriesModalProps): React.ReactElement | null {
const { pubkey, isUnlocked } = useNostrAuth()
const [draft, setDraft] = useState<SeriesDraft>({
title: '',
description: '',
preview: '',
coverUrl: '',
category: 'science-fiction',
})
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const ctrl = useCreateSeriesModalController({ authorPubkey, onClose, onSuccess, isOpen })
if (!isOpen) {
return null
}
const privateKey = nostrService.getPrivateKey()
const canPublish = pubkey === authorPubkey && isUnlocked && privateKey !== null
const handleSubmit = async (e: React.FormEvent): Promise<void> => {
e.preventDefault()
if (!canPublish) {
setError(t('series.create.error.notAuthor'))
return
}
if (!draft.title.trim() || !draft.description.trim() || !draft.preview.trim()) {
setError(t('series.create.error.missingFields'))
return
}
setLoading(true)
setError(null)
try {
if (!privateKey) {
setError(t('series.create.error.notAuthor'))
return
}
await publishSeries({
title: draft.title,
description: draft.description,
preview: draft.preview,
...(draft.coverUrl ? { coverUrl: draft.coverUrl } : {}),
category: draft.category,
authorPubkey,
authorPrivateKey: privateKey,
})
// Reset form
setDraft({
title: '',
description: '',
preview: '',
coverUrl: '',
category: 'science-fiction',
})
onSuccess()
onClose()
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : t('series.create.error.publishFailed'))
} finally {
setLoading(false)
}
}
const handleClose = (): void => {
if (!loading) {
setDraft({
title: '',
description: '',
preview: '',
coverUrl: '',
category: 'science-fiction',
})
setError(null)
onClose()
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="bg-cyber-dark border border-neon-cyan/30 rounded-lg p-6 max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-semibold text-neon-cyan">{t('series.create.title')}</h2>
<button
type="button"
onClick={handleClose}
disabled={loading}
className="text-cyber-accent hover:text-neon-cyan transition-colors disabled:opacity-50"
>
</button>
</div>
{!canPublish && (
<div className="mb-4 p-4 bg-yellow-900/30 border border-yellow-500/50 rounded text-yellow-300">
<p>{t('series.create.error.notAuthor')}</p>
</div>
)}
<form onSubmit={(e) => {
void handleSubmit(e)
}} className="space-y-4">
<div>
<label htmlFor="series-title" className="block text-sm font-medium text-neon-cyan mb-2">
{t('series.create.field.title')}
</label>
<input
id="series-title"
type="text"
value={draft.title}
onChange={(e) => setDraft({ ...draft, title: e.target.value })}
className="w-full px-3 py-2 bg-cyber-darker border border-cyber-accent/30 rounded text-cyber-light focus:border-neon-cyan focus:outline-none"
required
disabled={loading || !canPublish}
/>
</div>
<div>
<label htmlFor="series-description" className="block text-sm font-medium text-neon-cyan mb-2">
{t('series.create.field.description')}
</label>
<textarea
id="series-description"
value={draft.description}
onChange={(e) => setDraft({ ...draft, description: e.target.value })}
rows={4}
className="w-full px-3 py-2 bg-cyber-darker border border-cyber-accent/30 rounded text-cyber-light focus:border-neon-cyan focus:outline-none"
required
disabled={loading || !canPublish}
/>
</div>
<div>
<label htmlFor="series-preview" className="block text-sm font-medium text-neon-cyan mb-2">
{t('series.create.field.preview')}
</label>
<textarea
id="series-preview"
value={draft.preview}
onChange={(e) => setDraft({ ...draft, preview: e.target.value })}
rows={3}
className="w-full px-3 py-2 bg-cyber-darker border border-cyber-accent/30 rounded text-cyber-light focus:border-neon-cyan focus:outline-none"
required
disabled={loading || !canPublish}
/>
<p className="text-xs text-cyber-accent/70 mt-1">{t('series.create.field.preview.help')}</p>
</div>
<div>
<label htmlFor="series-category" className="block text-sm font-medium text-neon-cyan mb-2">
{t('series.create.field.category')}
</label>
<select
id="series-category"
value={draft.category}
onChange={(e) => setDraft({ ...draft, category: e.target.value as ArticleDraft['category'] })}
className="w-full px-3 py-2 bg-cyber-darker border border-cyber-accent/30 rounded text-cyber-light focus:border-neon-cyan focus:outline-none"
required
disabled={loading || !canPublish}
>
<option value="science-fiction">{t('category.science-fiction')}</option>
<option value="scientific-research">{t('category.scientific-research')}</option>
</select>
</div>
<div>
<ImageUploadField
id="series-cover"
label={t('series.create.field.cover')}
value={draft.coverUrl}
onChange={(url) => setDraft({ ...draft, coverUrl: url })}
helpText={t('series.create.field.cover.help')}
/>
</div>
{error && (
<div className="p-4 bg-red-900/30 border border-red-500/50 rounded text-red-300">
<p>{error}</p>
</div>
)}
<div className="flex items-center justify-end gap-4 pt-4">
<button
type="button"
onClick={handleClose}
disabled={loading}
className="px-4 py-2 bg-cyber-darker border border-cyber-accent/30 rounded text-cyber-light hover:border-neon-cyan transition-colors disabled:opacity-50"
>
{t('common.cancel')}
</button>
<button
type="submit"
disabled={loading || !canPublish}
className="px-4 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? t('common.loading') : t('series.create.submit')}
</button>
</div>
</form>
</div>
</div>
)
return <CreateSeriesModalView ctrl={ctrl} />
}

View File

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

View File

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

View File

@ -0,0 +1,220 @@
import React from 'react'
import { ImageUploadField } from '../ImageUploadField'
import { t } from '@/lib/i18n'
import type { SeriesDraft } from './createSeriesModalTypes'
import type { CreateSeriesModalController } from './useCreateSeriesModalController'
export function CreateSeriesModalView({ ctrl }: { ctrl: CreateSeriesModalController }): React.ReactElement {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="bg-cyber-dark border border-neon-cyan/30 rounded-lg p-6 max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<CreateSeriesModalHeader loading={ctrl.loading} onClose={ctrl.handleClose} />
{!ctrl.canPublish ? <NotAuthorWarning /> : null}
<CreateSeriesForm ctrl={ctrl} />
</div>
</div>
)
}
function CreateSeriesModalHeader({ loading, onClose }: { loading: boolean; onClose: () => void }): React.ReactElement {
return (
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-semibold text-neon-cyan">{t('series.create.title')}</h2>
<button
type="button"
onClick={onClose}
disabled={loading}
className="text-cyber-accent hover:text-neon-cyan transition-colors disabled:opacity-50"
>
</button>
</div>
)
}
function NotAuthorWarning(): React.ReactElement {
return (
<div className="mb-4 p-4 bg-yellow-900/30 border border-yellow-500/50 rounded text-yellow-300">
<p>{t('series.create.error.notAuthor')}</p>
</div>
)
}
function CreateSeriesForm({ ctrl }: { ctrl: CreateSeriesModalController }): React.ReactElement {
return (
<form onSubmit={(e) => void ctrl.handleSubmit(e)} className="space-y-4">
<SeriesTextFields draft={ctrl.draft} setDraft={ctrl.setDraft} loading={ctrl.loading} canPublish={ctrl.canPublish} />
<SeriesCategoryField draft={ctrl.draft} setDraft={ctrl.setDraft} loading={ctrl.loading} canPublish={ctrl.canPublish} />
<SeriesCoverField draft={ctrl.draft} setDraft={ctrl.setDraft} />
<SeriesError error={ctrl.error} />
<SeriesActions loading={ctrl.loading} canPublish={ctrl.canPublish} onClose={ctrl.handleClose} />
</form>
)
}
function SeriesTextFields(params: {
draft: SeriesDraft
setDraft: (draft: SeriesDraft) => void
loading: boolean
canPublish: boolean
}): React.ReactElement {
const disabled = params.loading || !params.canPublish
return (
<>
<TextField
id="series-title"
label={t('series.create.field.title')}
value={params.draft.title}
disabled={disabled}
required
onChange={(value) => params.setDraft({ ...params.draft, title: value })}
/>
<TextAreaField
id="series-description"
label={t('series.create.field.description')}
value={params.draft.description}
disabled={disabled}
required
rows={4}
onChange={(value) => params.setDraft({ ...params.draft, description: value })}
/>
<TextAreaField
id="series-preview"
label={t('series.create.field.preview')}
value={params.draft.preview}
disabled={disabled}
required
rows={3}
helpText={t('series.create.field.preview.help')}
onChange={(value) => params.setDraft({ ...params.draft, preview: value })}
/>
</>
)
}
function SeriesCategoryField(params: {
draft: SeriesDraft
setDraft: (draft: SeriesDraft) => void
loading: boolean
canPublish: boolean
}): React.ReactElement {
const disabled = params.loading || !params.canPublish
return (
<div>
<label htmlFor="series-category" className="block text-sm font-medium text-neon-cyan mb-2">
{t('series.create.field.category')}
</label>
<select
id="series-category"
value={params.draft.category}
onChange={(e) => params.setDraft({ ...params.draft, category: e.target.value as SeriesDraft['category'] })}
className="w-full px-3 py-2 bg-cyber-darker border border-cyber-accent/30 rounded text-cyber-light focus:border-neon-cyan focus:outline-none"
required
disabled={disabled}
>
<option value="science-fiction">{t('category.science-fiction')}</option>
<option value="scientific-research">{t('category.scientific-research')}</option>
</select>
</div>
)
}
function SeriesCoverField({ draft, setDraft }: { draft: SeriesDraft; setDraft: (draft: SeriesDraft) => void }): React.ReactElement {
return (
<ImageUploadField
id="series-cover"
label={t('series.create.field.cover')}
value={draft.coverUrl}
onChange={(url) => setDraft({ ...draft, coverUrl: url })}
helpText={t('series.create.field.cover.help')}
/>
)
}
function SeriesError({ error }: { error: string | null }): React.ReactElement | null {
if (!error) {
return null
}
return (
<div className="p-4 bg-red-900/30 border border-red-500/50 rounded text-red-300">
<p>{error}</p>
</div>
)
}
function SeriesActions(params: { loading: boolean; canPublish: boolean; onClose: () => void }): React.ReactElement {
return (
<div className="flex items-center justify-end gap-4 pt-4">
<button
type="button"
onClick={params.onClose}
disabled={params.loading}
className="px-4 py-2 bg-cyber-darker border border-cyber-accent/30 rounded text-cyber-light hover:border-neon-cyan transition-colors disabled:opacity-50"
>
{t('common.cancel')}
</button>
<button
type="submit"
disabled={params.loading || !params.canPublish}
className="px-4 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan disabled:opacity-50 disabled:cursor-not-allowed"
>
{params.loading ? t('common.loading') : t('series.create.submit')}
</button>
</div>
)
}
function TextField(params: {
id: string
label: string
value: string
disabled: boolean
required: boolean
onChange: (value: string) => void
}): React.ReactElement {
return (
<div>
<label htmlFor={params.id} className="block text-sm font-medium text-neon-cyan mb-2">
{params.label}
</label>
<input
id={params.id}
type="text"
value={params.value}
onChange={(e) => params.onChange(e.target.value)}
className="w-full px-3 py-2 bg-cyber-darker border border-cyber-accent/30 rounded text-cyber-light focus:border-neon-cyan focus:outline-none"
required={params.required}
disabled={params.disabled}
/>
</div>
)
}
function TextAreaField(params: {
id: string
label: string
value: string
disabled: boolean
required: boolean
rows: number
helpText?: string
onChange: (value: string) => void
}): React.ReactElement {
return (
<div>
<label htmlFor={params.id} className="block text-sm font-medium text-neon-cyan mb-2">
{params.label}
</label>
<textarea
id={params.id}
value={params.value}
onChange={(e) => params.onChange(e.target.value)}
rows={params.rows}
className="w-full px-3 py-2 bg-cyber-darker border border-cyber-accent/30 rounded text-cyber-light focus:border-neon-cyan focus:outline-none"
required={params.required}
disabled={params.disabled}
/>
{params.helpText ? <p className="text-xs text-cyber-accent/70 mt-1">{params.helpText}</p> : null}
</div>
)
}

View File

@ -0,0 +1,21 @@
import type { ArticleDraft } from '@/lib/articlePublisherTypes'
export interface CreateSeriesModalProps {
isOpen: boolean
onClose: () => void
onSuccess: () => void
authorPubkey: string
}
export interface SeriesDraft {
title: string
description: string
preview: string
coverUrl: string
category: ArticleDraft['category']
}
export interface SeriesAggregates {
canPublish: boolean
privateKey: string | null
}

View File

@ -0,0 +1,154 @@
import { useState } from 'react'
import { publishSeries } from '@/lib/articleMutations'
import { useNostrAuth } from '@/hooks/useNostrAuth'
import { nostrService } from '@/lib/nostr'
import { t } from '@/lib/i18n'
import type { SeriesDraft, SeriesAggregates } from './createSeriesModalTypes'
export interface CreateSeriesModalController {
draft: SeriesDraft
setDraft: (draft: SeriesDraft) => void
loading: boolean
error: string | null
canPublish: boolean
handleClose: () => void
handleSubmit: (e: React.FormEvent) => Promise<void>
}
export function useCreateSeriesModalController(params: {
authorPubkey: string
onClose: () => void
onSuccess: () => void
isOpen: boolean
}): CreateSeriesModalController {
const { pubkey, isUnlocked } = useNostrAuth()
const [draft, setDraft] = useState<SeriesDraft>(createInitialDraft())
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const aggregates = computeAggregates({ pubkey, isUnlocked, authorPubkey: params.authorPubkey })
const handleClose = createHandleClose({ loading, setDraft, setError, onClose: params.onClose })
const handleSubmit = createHandleSubmit({
authorPubkey: params.authorPubkey,
onClose: params.onClose,
onSuccess: params.onSuccess,
draft,
aggregates,
setDraft,
setLoading,
setError,
})
return { draft, setDraft, loading, error, canPublish: aggregates.canPublish, handleClose, handleSubmit }
}
function createInitialDraft(): SeriesDraft {
return { title: '', description: '', preview: '', coverUrl: '', category: 'science-fiction' }
}
function resetForm(params: { setDraft: (draft: SeriesDraft) => void; setError: (error: string | null) => void }): void {
params.setDraft(createInitialDraft())
params.setError(null)
}
function computeAggregates(params: { pubkey: string | null; isUnlocked: boolean; authorPubkey: string }): SeriesAggregates {
const privateKey = nostrService.getPrivateKey()
const canPublish = params.pubkey === params.authorPubkey && params.isUnlocked && privateKey !== null
return { canPublish, privateKey }
}
function getCanSubmitError(params: { canPublish: boolean; draft: SeriesDraft; privateKey: string | null }): string | null {
if (!params.canPublish || !params.privateKey) {
return t('series.create.error.notAuthor')
}
if (!params.draft.title.trim() || !params.draft.description.trim() || !params.draft.preview.trim()) {
return t('series.create.error.missingFields')
}
return null
}
function createHandleClose(params: {
loading: boolean
setDraft: (draft: SeriesDraft) => void
setError: (error: string | null) => void
onClose: () => void
}): () => void {
return (): void => {
if (params.loading) {
return
}
resetForm({ setDraft: params.setDraft, setError: params.setError })
params.onClose()
}
}
function createHandleSubmit(params: {
authorPubkey: string
onClose: () => void
onSuccess: () => void
draft: SeriesDraft
aggregates: SeriesAggregates
setDraft: (draft: SeriesDraft) => void
setLoading: (loading: boolean) => void
setError: (error: string | null) => void
}): (e: React.FormEvent) => Promise<void> {
return async (e: React.FormEvent): Promise<void> => submitSeriesForm(params, e)
}
async function submitSeriesForm(
params: {
authorPubkey: string
onClose: () => void
onSuccess: () => void
draft: SeriesDraft
aggregates: SeriesAggregates
setDraft: (draft: SeriesDraft) => void
setLoading: (loading: boolean) => void
setError: (error: string | null) => void
},
e: React.FormEvent
): Promise<void> {
e.preventDefault()
const canSubmitError = getCanSubmitError({ canPublish: params.aggregates.canPublish, draft: params.draft, privateKey: params.aggregates.privateKey })
if (canSubmitError) {
params.setError(canSubmitError)
return
}
params.setLoading(true)
params.setError(null)
try {
await publishSeries(buildPublishSeriesParams(params))
resetForm({ setDraft: params.setDraft, setError: params.setError })
params.onSuccess()
params.onClose()
} catch (submitError) {
params.setError(submitError instanceof Error ? submitError.message : t('series.create.error.publishFailed'))
} finally {
params.setLoading(false)
}
}
function buildPublishSeriesParams(params: {
authorPubkey: string
draft: SeriesDraft
aggregates: SeriesAggregates
}): {
title: string
description: string
preview: string
coverUrl?: string
category: SeriesDraft['category']
authorPubkey: string
authorPrivateKey: string
} {
return {
title: params.draft.title,
description: params.draft.description,
preview: params.draft.preview,
...(params.draft.coverUrl ? { coverUrl: params.draft.coverUrl } : {}),
category: params.draft.category,
authorPubkey: params.authorPubkey,
authorPrivateKey: params.aggregates.privateKey ?? '',
}
}

View File

@ -0,0 +1,16 @@
import React from 'react'
import { t } from '@/lib/i18n'
export function ConnectRequiredCard(params: { message: string; onConnect: () => void }): React.ReactElement {
return (
<div className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark">
<p className="text-cyber-accent mb-4">{params.message}</p>
<button
onClick={params.onConnect}
className="px-4 py-2 bg-neon-green/20 hover:bg-neon-green/30 text-neon-green rounded-lg font-medium transition-all border border-neon-green/50"
>
{t('connect.connect')}
</button>
</div>
)
}

View File

@ -0,0 +1,132 @@
import React from 'react'
import { t } from '@/lib/i18n'
import type { ReviewFormController } from './useReviewFormController'
export function ReviewFormView(params: { ctrl: ReviewFormController; onCancel?: () => void }): React.ReactElement {
return (
<form
onSubmit={(e) => void params.ctrl.handleSubmit(e)}
className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark space-y-4"
>
<h3 className="text-lg font-semibold text-neon-cyan">{t('review.form.title')}</h3>
<TextInput
id="review-title"
label={t('review.form.title.label')}
value={params.ctrl.title}
onChange={params.ctrl.setTitle}
placeholder={t('review.form.title.placeholder')}
optionalLabel={`(${t('common.optional')})`}
/>
<TextAreaInput
id="review-content"
label={t('review.form.content.label')}
value={params.ctrl.content}
onChange={params.ctrl.setContent}
placeholder={t('review.form.content.placeholder')}
rows={6}
required
requiredMark
/>
<TextAreaInput
id="review-text"
label={t('review.form.text.label')}
value={params.ctrl.text}
onChange={params.ctrl.setText}
placeholder={t('review.form.text.placeholder')}
rows={3}
helpText={t('review.form.text.help')}
optionalLabel={`(${t('common.optional')})`}
/>
{params.ctrl.error ? <ErrorBox message={params.ctrl.error} /> : null}
<div className="flex gap-2">
<button
type="submit"
disabled={params.ctrl.loading}
className="px-4 py-2 bg-neon-green/20 hover:bg-neon-green/30 text-neon-green rounded-lg font-medium transition-all border border-neon-green/50 hover:shadow-glow-green disabled:opacity-50"
>
{params.ctrl.loading ? t('common.loading') : t('review.form.submit')}
</button>
{params.onCancel ? (
<button
type="button"
onClick={params.onCancel}
className="px-4 py-2 bg-cyber-darker hover:bg-cyber-dark text-cyber-accent rounded-lg font-medium transition-all border border-neon-cyan/30"
>
{t('common.cancel')}
</button>
) : null}
</div>
</form>
)
}
function ErrorBox({ message }: { message: string }): React.ReactElement {
return (
<div className="p-3 bg-red-900/20 border border-red-500/50 rounded text-red-400 text-sm">
{message}
</div>
)
}
function TextInput(params: {
id: string
label: string
value: string
onChange: (value: string) => void
placeholder: string
optionalLabel?: string
}): React.ReactElement {
return (
<div>
<label htmlFor={params.id} className="block text-sm font-medium text-cyber-accent mb-1">
{params.label} {params.optionalLabel ? <span className="text-cyber-accent/50">{params.optionalLabel}</span> : null}
</label>
<input
id={params.id}
type="text"
value={params.value}
onChange={(e) => params.onChange(e.target.value)}
placeholder={params.placeholder}
className="w-full px-3 py-2 bg-cyber-darker border border-neon-cyan/30 rounded text-cyber-accent focus:border-neon-cyan focus:outline-none"
/>
</div>
)
}
function TextAreaInput(params: {
id: string
label: string
value: string
onChange: (value: string) => void
placeholder: string
rows: number
required?: boolean
requiredMark?: boolean
optionalLabel?: string
helpText?: string
}): React.ReactElement {
return (
<div>
<label htmlFor={params.id} className="block text-sm font-medium text-cyber-accent mb-1">
{params.label}{' '}
{params.requiredMark ? <span className="text-red-400">*</span> : null}{' '}
{params.optionalLabel ? <span className="text-cyber-accent/50">{params.optionalLabel}</span> : null}
</label>
<textarea
id={params.id}
value={params.value}
onChange={(e) => params.onChange(e.target.value)}
placeholder={params.placeholder}
rows={params.rows}
required={params.required}
className="w-full px-3 py-2 bg-cyber-darker border border-neon-cyan/30 rounded text-cyber-accent focus:border-neon-cyan focus:outline-none"
/>
{params.helpText ? <p className="text-xs text-cyber-accent/70 mt-1">{params.helpText}</p> : null}
</div>
)
}

View File

@ -0,0 +1,65 @@
import React from 'react'
import { t } from '@/lib/i18n'
import type { ReviewTipFormController } from './useReviewTipFormController'
export function ReviewTipFormView(params: { ctrl: ReviewTipFormController; onCancel?: () => void }): React.ReactElement {
return (
<form
onSubmit={(e) => void params.ctrl.handleSubmit(e)}
className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark space-y-4"
>
<h3 className="text-lg font-semibold text-neon-cyan">{t('reviewTip.form.title')}</h3>
<p className="text-sm text-cyber-accent/70">
{t('reviewTip.form.description', {
amount: params.ctrl.split.total,
reviewer: params.ctrl.split.reviewer,
platform: params.ctrl.split.platform,
})}
</p>
<div>
<label htmlFor="review-tip-text" className="block text-sm font-medium text-cyber-accent mb-1">
{t('reviewTip.form.text.label')} <span className="text-cyber-accent/50">({t('common.optional')})</span>
</label>
<textarea
id="review-tip-text"
value={params.ctrl.text}
onChange={(e) => params.ctrl.setText(e.target.value)}
placeholder={t('reviewTip.form.text.placeholder')}
rows={3}
className="w-full px-3 py-2 bg-cyber-darker border border-neon-cyan/30 rounded text-cyber-accent focus:border-neon-cyan focus:outline-none"
/>
<p className="text-xs text-cyber-accent/70 mt-1">{t('reviewTip.form.text.help')}</p>
</div>
{params.ctrl.error ? <ErrorBox message={params.ctrl.error} /> : null}
<div className="flex gap-2">
<button
type="submit"
disabled={params.ctrl.loading}
className="px-4 py-2 bg-neon-green/20 hover:bg-neon-green/30 text-neon-green rounded-lg font-medium transition-all border border-neon-green/50 hover:shadow-glow-green disabled:opacity-50"
>
{params.ctrl.loading ? t('common.loading') : t('reviewTip.form.submit', { amount: params.ctrl.split.total })}
</button>
{params.onCancel ? (
<button
type="button"
onClick={params.onCancel}
className="px-4 py-2 bg-cyber-darker hover:bg-cyber-dark text-cyber-accent rounded-lg font-medium transition-all border border-neon-cyan/30"
>
{t('common.cancel')}
</button>
) : null}
</div>
</form>
)
}
function ErrorBox({ message }: { message: string }): React.ReactElement {
return (
<div className="p-3 bg-red-900/20 border border-red-500/50 rounded text-red-400 text-sm">
{message}
</div>
)
}

View File

@ -0,0 +1,14 @@
import type { Article, Review } from '@/types/nostr'
export interface ReviewFormProps {
article: Article
onSuccess?: () => void
onCancel?: () => void
}
export interface ReviewTipFormProps {
review: Review
article: Article
onSuccess?: () => void
onCancel?: () => void
}

View File

@ -0,0 +1,166 @@
import { useState } from 'react'
import { publishReview } from '@/lib/articleMutations'
import { nostrService } from '@/lib/nostr'
import { t } from '@/lib/i18n'
import type { ReviewFormProps } from './reviewFormTypes'
import type { Article } from '@/types/nostr'
export interface ReviewFormController {
pubkey: string | null
content: string
title: string
text: string
loading: boolean
error: string | null
setContent: (value: string) => void
setTitle: (value: string) => void
setText: (value: string) => void
handleSubmit: (e: React.FormEvent) => Promise<void>
}
export function useReviewFormController(params: {
article: ReviewFormProps['article']
pubkey: string | null
onSuccess?: () => void
}): ReviewFormController {
const [content, setContent] = useState('')
const [title, setTitle] = useState('')
const [text, setText] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSubmit = buildReviewSubmitHandler({
article: params.article,
pubkey: params.pubkey,
content,
title,
text,
setLoading,
setError,
reset: () => resetFields({ setContent, setTitle, setText }),
onSuccess: params.onSuccess,
})
return {
pubkey: params.pubkey,
content,
title,
text,
loading,
error,
setContent,
setTitle,
setText,
handleSubmit,
}
}
function buildReviewSubmitHandler(params: {
article: Article
pubkey: string | null
content: string
title: string
text: string
setLoading: (loading: boolean) => void
setError: (error: string | null) => void
reset: () => void
onSuccess?: () => void
}): (e: React.FormEvent) => Promise<void> {
return async (e: React.FormEvent): Promise<void> => {
e.preventDefault()
if (!params.pubkey) {
return
}
const contentError = validateRequiredContent(params.content)
if (contentError) {
params.setError(contentError)
return
}
await submitReview(params)
}
}
async function submitReview(params: {
article: Article
pubkey: string
content: string
title: string
text: string
setLoading: (loading: boolean) => void
setError: (error: string | null) => void
reset: () => void
onSuccess?: () => void
}): Promise<void> {
params.setLoading(true)
params.setError(null)
try {
await publishReview(buildPublishReviewParams({ article: params.article, reviewerPubkey: params.pubkey, content: params.content, title: params.title, text: params.text }))
params.reset()
params.onSuccess?.()
} catch (submitError) {
params.setError(submitError instanceof Error ? submitError.message : t('review.form.error.publishFailed'))
} finally {
params.setLoading(false)
}
}
function validateRequiredContent(content: string): string | null {
if (!content.trim()) {
return t('review.form.error.contentRequired')
}
return null
}
function buildPublishReviewParams(params: {
article: Article
reviewerPubkey: string
content: string
title: string
text: string
}): {
articleId: string
seriesId: string
category: 'science-fiction' | 'scientific-research'
authorPubkey: string
reviewerPubkey: string
content: string
title?: string
text?: string
authorPrivateKey: string
} {
const privateKey = nostrService.getPrivateKey()
if (!privateKey) {
throw new Error(t('review.form.error.noPrivateKey'))
}
const category = normalizeArticleCategory(params.article)
const seriesId = params.article.seriesId ?? ''
return {
articleId: params.article.id,
seriesId,
category,
authorPubkey: params.article.pubkey,
reviewerPubkey: params.reviewerPubkey,
content: params.content.trim(),
...(params.title.trim() ? { title: params.title.trim() } : {}),
...(params.text.trim() ? { text: params.text.trim() } : {}),
authorPrivateKey: privateKey,
}
}
function normalizeArticleCategory(article: Article): 'science-fiction' | 'scientific-research' {
if (article.category === 'author-presentation') {
return 'science-fiction'
}
return article.category === 'scientific-research' ? 'scientific-research' : 'science-fiction'
}
function resetFields(params: {
setContent: (value: string) => void
setTitle: (value: string) => void
setText: (value: string) => void
}): void {
params.setContent('')
params.setTitle('')
params.setText('')
}

View File

@ -0,0 +1,83 @@
import { useState } from 'react'
import { nostrService } from '@/lib/nostr'
import { t } from '@/lib/i18n'
import { buildReviewTipZapRequestTags } from '@/lib/zapRequestBuilder'
import { calculateReviewSplit } from '@/lib/platformCommissions'
import type { Article, Review } from '@/types/nostr'
export interface ReviewTipFormController {
pubkey: string | null
text: string
loading: boolean
error: string | null
split: ReturnType<typeof calculateReviewSplit>
setText: (value: string) => void
handleSubmit: (e: React.FormEvent) => Promise<void>
}
export function useReviewTipFormController(params: {
review: Review
article: Article
pubkey: string | null
onSuccess?: () => void
}): ReviewTipFormController {
const [text, setText] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const split = calculateReviewSplit()
const handleSubmit = async (e: React.FormEvent): Promise<void> => {
e.preventDefault()
if (!params.pubkey) {
return
}
setLoading(true)
setError(null)
try {
await createAndPublishZapRequest({ review: params.review, article: params.article, text, split })
setText('')
params.onSuccess?.()
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : t('reviewTip.form.error.paymentFailed'))
} finally {
setLoading(false)
}
}
return { pubkey: params.pubkey, text, loading, error, split, setText, handleSubmit }
}
async function createAndPublishZapRequest(params: {
review: Review
article: Article
text: string
split: ReturnType<typeof calculateReviewSplit>
}): Promise<void> {
if (!nostrService.getPrivateKey()) {
throw new Error(t('reviewTip.form.error.noPrivateKey'))
}
const category = getArticleCategoryForTip(params.article)
const zapRequestTags = buildReviewTipZapRequestTags({
articleId: params.article.id,
reviewId: params.review.id,
authorPubkey: params.article.pubkey,
reviewerPubkey: params.review.reviewerPubkey,
...(category ? { category } : {}),
...(params.article.seriesId ? { seriesId: params.article.seriesId } : {}),
...(params.text.trim() ? { text: params.text.trim() } : {}),
})
await nostrService.createZapRequest(params.review.reviewerPubkey, params.review.id, params.split.total, zapRequestTags)
}
function getArticleCategoryForTip(article: Article): 'science-fiction' | 'scientific-research' | undefined {
if (article.category === 'author-presentation') {
return undefined
}
if (article.category === 'science-fiction' || article.category === 'scientific-research') {
return article.category
}
return undefined
}

View File

@ -4,6 +4,17 @@ import { nostrService } from '@/lib/nostr'
import type { ArticleDraft } from '@/lib/articlePublisher'
import type { RelayPublishStatus } from '@/lib/publishResult'
interface UseArticlePublishingState {
loading: boolean
error: string | null
success: boolean
relayStatuses: RelayPublishStatus[]
}
interface ArticlePublishingApi extends UseArticlePublishingState {
publishArticle: (draft: ArticleDraft) => Promise<string | null>
}
export function useArticlePublishing(pubkey: string | null): {
loading: boolean
error: string | null
@ -16,46 +27,73 @@ export function useArticlePublishing(pubkey: string | null): {
const [success, setSuccess] = useState(false)
const [relayStatuses, setRelayStatuses] = useState<RelayPublishStatus[]>([])
const publishArticle = async (draft: ArticleDraft): Promise<string | null> => {
if (!pubkey) {
setError('Please connect with Nostr first')
const publishArticle = buildPublishArticleHandler({
pubkey,
setLoading,
setError,
setSuccess,
setRelayStatuses,
})
const api: ArticlePublishingApi = { loading, error, success, relayStatuses, publishArticle }
return api
}
function validateDraft(draft: ArticleDraft): string | null {
if (!draft.title.trim() || !draft.preview.trim() || !draft.content.trim()) {
return 'Please fill in all fields'
}
return null
}
function buildPublishArticleHandler(params: {
pubkey: string | null
setLoading: (loading: boolean) => void
setError: (error: string | null) => void
setSuccess: (success: boolean) => void
setRelayStatuses: (statuses: RelayPublishStatus[]) => void
}): (draft: ArticleDraft) => Promise<string | null> {
return async (draft: ArticleDraft): Promise<string | null> => {
if (!params.pubkey) {
params.setError('Please connect with Nostr first')
return null
}
if (!draft.title.trim() || !draft.preview.trim() || !draft.content.trim()) {
setError('Please fill in all fields')
const validationError = validateDraft(draft)
if (validationError) {
params.setError(validationError)
return null
}
setLoading(true)
setError(null)
setRelayStatuses([])
params.setLoading(true)
params.setError(null)
params.setRelayStatuses([])
try {
const privateKey = nostrService.getPrivateKey()
const result = await articlePublisher.publishArticle(draft, pubkey, privateKey ?? undefined)
if (result.success) {
setSuccess(true)
setRelayStatuses(result.relayStatuses ?? [])
return result.articleId
}
setError(result.error ?? 'Failed to publish article')
return null
const result = await articlePublisher.publishArticle(draft, params.pubkey, privateKey ?? undefined)
return handlePublishResult({ result, setSuccess: params.setSuccess, setRelayStatuses: params.setRelayStatuses, setError: params.setError })
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to publish article')
params.setError(e instanceof Error ? e.message : 'Failed to publish article')
return null
} finally {
setLoading(false)
params.setLoading(false)
}
}
return {
loading,
error,
success,
relayStatuses,
publishArticle,
}
}
function handlePublishResult(params: {
result: { success: boolean; relayStatuses?: RelayPublishStatus[]; articleId: string | null; error?: string | undefined }
setSuccess: (success: boolean) => void
setRelayStatuses: (statuses: RelayPublishStatus[]) => void
setError: (error: string | null) => void
}): string | null {
if (params.result.success) {
params.setSuccess(true)
params.setRelayStatuses(params.result.relayStatuses ?? [])
return params.result.articleId
}
params.setError(params.result.error ?? 'Failed to publish article')
return null
}

View File

@ -87,14 +87,7 @@ export function getAccessControl(
const { canReadPreview, canReadFullContent } = canUserRead(event, userPubkey, hasPaid)
const isPaid = isPaidContent(event)
let reason: string | undefined
if (isPaid && !canReadFullContent) {
reason = t('access.paymentRequired')
} else if (!canModify && userPubkey) {
reason = t('access.onlyAuthorModify')
} else if (!canDelete && userPubkey) {
reason = t('access.onlyAuthorDelete')
}
const reason = getAccessControlReason({ isPaid, canReadFullContent, userPubkey, canModify, canDelete })
return {
canModify,
@ -105,3 +98,29 @@ export function getAccessControl(
...(reason ? { reason } : {}),
}
}
function getAccessControlReason(params: {
isPaid: boolean
canReadFullContent: boolean
userPubkey: string | null
canModify: boolean
canDelete: boolean
}): string | undefined {
if (params.isPaid && !params.canReadFullContent) {
return t('access.paymentRequired')
}
if (!params.userPubkey) {
return undefined
}
if (!params.canModify) {
return t('access.onlyAuthorModify')
}
if (!params.canDelete) {
return t('access.onlyAuthorDelete')
}
return undefined
}

View File

@ -21,78 +21,86 @@ export async function tryWithRelayRotation<T>(
operation: (relayUrl: string, pool: SimplePoolWithSub) => Promise<T>,
timeout: number = 10000
): Promise<T> {
// Get active relays (enabled and not marked inactive for this session)
const activeRelays = await relaySessionManager.getActiveRelays()
if (activeRelays.length === 0) {
const initialActiveRelays = await relaySessionManager.getActiveRelays()
if (initialActiveRelays.length === 0) {
throw new Error('No active relays available')
}
const maxAttempts = initialActiveRelays.length * 2 // Try all active relays twice (loop once)
const poolWithSub = pool as unknown as SimplePoolWithSub
let lastError: Error | null = null
let attempts = 0
const maxAttempts = activeRelays.length * 2 // Try all active relays twice (loop once)
while (attempts < maxAttempts) {
// Get current active relays (may have changed if some were marked inactive)
const currentActiveRelays = await relaySessionManager.getActiveRelays()
if (currentActiveRelays.length === 0) {
throw new Error('No active relays available')
}
const relayIndex = attempts % currentActiveRelays.length
const relayUrl = currentActiveRelays[relayIndex]
if (!relayUrl) {
throw new Error('Invalid relay configuration')
}
// Skip if relay was marked failed during the loop (it will be at the bottom now)
// We continue to use it but it's lower priority
try {
console.warn(`[RelayRotation] Trying relay ${relayIndex + 1}/${currentActiveRelays.length}: ${relayUrl}`)
// Notify progress manager that we're switching to a new relay (reset to 0 for this relay)
const { syncProgressManager } = await import('./syncProgressManager')
const currentProgress = syncProgressManager.getProgress()
if (currentProgress) {
syncProgressManager.setProgress({
...currentProgress,
currentStep: 0, // Reset to 0 when changing relay
currentRelay: relayUrl,
})
}
const poolWithSub = pool as unknown as SimplePoolWithSub
const result = await Promise.race([
operation(relayUrl, poolWithSub),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error(`Timeout after ${timeout}ms`)), timeout)
),
])
console.warn(`[RelayRotation] Success with relay: ${relayUrl}`)
return result
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
console.warn(`[RelayRotation] Relay ${relayUrl} failed: ${errorMessage}`)
// Mark relay as failed (move to bottom of priority list)
relaySessionManager.markRelayFailed(relayUrl)
lastError = error instanceof Error ? error : new Error(String(error))
attempts++
// If we've tried all relays once, loop back
if (attempts >= maxAttempts) {
break
}
for (let attempts = 0; attempts < maxAttempts; attempts += 1) {
const { relayUrl, relayIndex, totalRelays } = await pickRelayForAttempt(attempts)
const attempt = await tryOperationOnRelay({ relayUrl, relayIndex, totalRelays, poolWithSub, operation, timeout })
if (attempt.ok) {
return attempt.value
}
lastError = attempt.error
}
// If we get here, all relays failed
throw lastError ?? new Error('All relays failed')
}
async function pickRelayForAttempt(attempts: number): Promise<{ relayUrl: string; relayIndex: number; totalRelays: number }> {
const currentActiveRelays = await relaySessionManager.getActiveRelays()
if (currentActiveRelays.length === 0) {
throw new Error('No active relays available')
}
const relayIndex = attempts % currentActiveRelays.length
const relayUrl = currentActiveRelays[relayIndex]
if (!relayUrl) {
throw new Error('Invalid relay configuration')
}
return { relayUrl, relayIndex, totalRelays: currentActiveRelays.length }
}
type RelayAttemptResult<T> = { ok: true; value: T } | { ok: false; error: Error }
async function tryOperationOnRelay<T>(params: {
relayUrl: string
relayIndex: number
totalRelays: number
poolWithSub: SimplePoolWithSub
operation: (relayUrl: string, pool: SimplePoolWithSub) => Promise<T>
timeout: number
}): Promise<RelayAttemptResult<T>> {
console.warn(`[RelayRotation] Trying relay ${params.relayIndex + 1}/${params.totalRelays}: ${params.relayUrl}`)
await updateSyncProgressForRelay(params.relayUrl)
try {
const value = await withTimeout(params.operation(params.relayUrl, params.poolWithSub), params.timeout)
console.warn(`[RelayRotation] Success with relay: ${params.relayUrl}`)
return { ok: true, value }
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error))
console.warn(`[RelayRotation] Relay ${params.relayUrl} failed: ${err.message}`)
relaySessionManager.markRelayFailed(params.relayUrl)
return { ok: false, error: err }
}
}
async function updateSyncProgressForRelay(relayUrl: string): Promise<void> {
const { syncProgressManager } = await import('./syncProgressManager')
const currentProgress = syncProgressManager.getProgress()
if (!currentProgress) {
return
}
syncProgressManager.setProgress({ ...currentProgress, currentStep: 0, currentRelay: relayUrl })
}
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
return Promise.race([
promise,
new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error(`Timeout after ${timeoutMs}ms`)), timeoutMs)
}),
])
}
/**
* Create a subscription with relay rotation
* Tries each relay until one succeeds

View File

@ -24,56 +24,62 @@ export function getLatestVersion(events: Event[]): Event | null {
return null
}
// Group by ID and find the latest non-hidden version
const byId = groupVersionedObjectsById(events)
const latestById = getLatestVisibleById(byId)
const winner = pickHighestVersion(latestById)
return winner?.event ?? null
}
function groupVersionedObjectsById(events: Event[]): Map<string, VersionedObject[]> {
const byId = new Map<string, VersionedObject[]>()
for (const event of events) {
const tags = extractTagsFromEvent(event)
if (tags.id) {
if (!byId.has(tags.id)) {
byId.set(tags.id, [])
}
const idArray = byId.get(tags.id)
if (idArray) {
idArray.push({
event,
version: tags.version,
hidden: tags.hidden,
pubkey: event.pubkey,
id: tags.id,
})
}
const list = byId.get(tags.id) ?? []
list.push({
event,
version: tags.version,
hidden: tags.hidden,
pubkey: event.pubkey,
id: tags.id,
})
byId.set(tags.id, list)
}
}
// For each ID, find the latest non-hidden version
const latestVersions: VersionedObject[] = []
return byId
}
function getLatestVisibleById(byId: Map<string, VersionedObject[]>): VersionedObject[] {
const latest: VersionedObject[] = []
for (const objects of byId.values()) {
// Filter out hidden objects
const visible = objects.filter((obj) => !obj.hidden)
if (visible.length > 0) {
// Sort by version (descending) and take the first (latest)
visible.sort((a, b) => b.version - a.version)
const latest = visible[0]
if (latest) {
latestVersions.push(latest)
}
const candidate = pickLatestVisible(objects)
if (candidate) {
latest.push(candidate)
}
}
// If we have multiple IDs, we need to return the one with the highest version
// But typically we expect one ID per query, so return the first
if (latestVersions.length === 0) {
return latest
}
function pickLatestVisible(objects: VersionedObject[]): VersionedObject | null {
const visible = objects.filter((obj) => !obj.hidden)
if (visible.length === 0) {
return null
}
// Sort by version and return the latest
latestVersions.sort((a, b) => b.version - a.version)
const latestVersion = latestVersions[0]
return latestVersion ? latestVersion.event : null
visible.sort((a, b) => b.version - a.version)
return visible[0] ?? null
}
function pickHighestVersion(objects: VersionedObject[]): VersionedObject | null {
if (objects.length === 0) {
return null
}
objects.sort((a, b) => b.version - a.version)
return objects[0] ?? null
}
/**

View File

@ -44,12 +44,32 @@ function SeriesHeader({ series }: { series: Series }): React.ReactElement {
export default function SeriesPage(): React.ReactElement | null {
const router = useRouter()
const { id } = router.query
const seriesId = typeof id === 'string' ? id : ''
const { series, articles, aggregates, loading, error } = useSeriesPageData(seriesId)
const seriesId = getSeriesIdFromQuery(id)
const data = useSeriesPageData(seriesId ?? '')
if (!seriesId) {
return null
}
return <SeriesPageView series={data.series} articles={data.articles} aggregates={data.aggregates} loading={data.loading} error={data.error} />
}
function getSeriesIdFromQuery(id: unknown): string | null {
return typeof id === 'string' && id.trim() ? id : null
}
function SeriesPageView({
series,
articles,
aggregates,
loading,
error,
}: {
series: Series | null
articles: Article[]
aggregates: { sponsoring: number; purchases: number; reviewTips: number } | null
loading: boolean
error: string | null
}): React.ReactElement {
return (
<>
<Head>
@ -57,26 +77,47 @@ export default function SeriesPage(): React.ReactElement | null {
</Head>
<main className="min-h-screen bg-gray-50">
<div className="w-full px-4 py-8 space-y-6">
{loading && <p className="text-sm text-gray-600">{t('common.loading')}</p>}
{error && <p className="text-sm text-red-600">{error}</p>}
{series && (
<>
<SeriesHeader series={series} />
<SeriesActions series={series} />
<SeriesStats
sponsoring={aggregates?.sponsoring ?? 0}
purchases={aggregates?.purchases ?? 0}
reviewTips={aggregates?.reviewTips ?? 0}
/>
<SeriesPublications articles={articles} />
</>
)}
<SeriesPageStatus loading={loading} error={error} />
{series ? <SeriesPageContent series={series} articles={articles} aggregates={aggregates} /> : null}
</div>
</main>
</>
)
}
function SeriesPageStatus({ loading, error }: { loading: boolean; error: string | null }): React.ReactElement | null {
if (loading) {
return <p className="text-sm text-gray-600">{t('common.loading')}</p>
}
if (error) {
return <p className="text-sm text-red-600">{error}</p>
}
return null
}
function SeriesPageContent({
series,
articles,
aggregates,
}: {
series: Series
articles: Article[]
aggregates: { sponsoring: number; purchases: number; reviewTips: number } | null
}): React.ReactElement {
return (
<>
<SeriesHeader series={series} />
<SeriesActions series={series} />
<SeriesStats
sponsoring={aggregates?.sponsoring ?? 0}
purchases={aggregates?.purchases ?? 0}
reviewTips={aggregates?.reviewTips ?? 0}
/>
<SeriesPublications articles={articles} />
</>
)
}
function SeriesActions({ series }: { series: Series }): React.ReactElement | null {
const { pubkey } = useNostrAuth()