lint fix wip
This commit is contained in:
parent
20a46ce2bc
commit
899c20631a
@ -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} />
|
||||
}
|
||||
|
||||
@ -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} />
|
||||
}
|
||||
|
||||
@ -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} />
|
||||
}
|
||||
|
||||
220
components/createSeriesModal/CreateSeriesModalView.tsx
Normal file
220
components/createSeriesModal/CreateSeriesModalView.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
21
components/createSeriesModal/createSeriesModalTypes.ts
Normal file
21
components/createSeriesModal/createSeriesModalTypes.ts
Normal 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
|
||||
}
|
||||
154
components/createSeriesModal/useCreateSeriesModalController.ts
Normal file
154
components/createSeriesModal/useCreateSeriesModalController.ts
Normal 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 ?? '',
|
||||
}
|
||||
}
|
||||
16
components/reviewForms/ConnectRequiredCard.tsx
Normal file
16
components/reviewForms/ConnectRequiredCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
132
components/reviewForms/ReviewFormView.tsx
Normal file
132
components/reviewForms/ReviewFormView.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
65
components/reviewForms/ReviewTipFormView.tsx
Normal file
65
components/reviewForms/ReviewTipFormView.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
14
components/reviewForms/reviewFormTypes.ts
Normal file
14
components/reviewForms/reviewFormTypes.ts
Normal 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
|
||||
}
|
||||
166
components/reviewForms/useReviewFormController.ts
Normal file
166
components/reviewForms/useReviewFormController.ts
Normal 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('')
|
||||
}
|
||||
83
components/reviewForms/useReviewTipFormController.ts
Normal file
83
components/reviewForms/useReviewTipFormController.ts
Normal 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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user