lint fix wip
This commit is contained in:
parent
20a46ce2bc
commit
899c20631a
@ -1,227 +1,11 @@
|
|||||||
import { useState } from 'react'
|
import type { CreateSeriesModalProps } from './createSeriesModal/createSeriesModalTypes'
|
||||||
import { ImageUploadField } from './ImageUploadField'
|
import { CreateSeriesModalView } from './createSeriesModal/CreateSeriesModalView'
|
||||||
import { publishSeries } from '@/lib/articleMutations'
|
import { useCreateSeriesModalController } from './createSeriesModal/useCreateSeriesModalController'
|
||||||
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']
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CreateSeriesModal({ isOpen, onClose, onSuccess, authorPubkey }: CreateSeriesModalProps): React.ReactElement | null {
|
export function CreateSeriesModal({ isOpen, onClose, onSuccess, authorPubkey }: CreateSeriesModalProps): React.ReactElement | null {
|
||||||
const { pubkey, isUnlocked } = useNostrAuth()
|
const ctrl = useCreateSeriesModalController({ authorPubkey, onClose, onSuccess, isOpen })
|
||||||
const [draft, setDraft] = useState<SeriesDraft>({
|
|
||||||
title: '',
|
|
||||||
description: '',
|
|
||||||
preview: '',
|
|
||||||
coverUrl: '',
|
|
||||||
category: 'science-fiction',
|
|
||||||
})
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
return <CreateSeriesModalView ctrl={ctrl} />
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,173 +1,24 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import { publishReview } from '@/lib/articleMutations'
|
|
||||||
import { nostrService } from '@/lib/nostr'
|
|
||||||
import { useNostrAuth } from '@/hooks/useNostrAuth'
|
import { useNostrAuth } from '@/hooks/useNostrAuth'
|
||||||
import { t } from '@/lib/i18n'
|
import { t } from '@/lib/i18n'
|
||||||
import type { Article } from '@/types/nostr'
|
import { ConnectRequiredCard } from './reviewForms/ConnectRequiredCard'
|
||||||
|
import { ReviewFormView } from './reviewForms/ReviewFormView'
|
||||||
interface ReviewFormProps {
|
import { useReviewFormController } from './reviewForms/useReviewFormController'
|
||||||
article: Article
|
import type { ReviewFormProps } from './reviewForms/reviewFormTypes'
|
||||||
onSuccess?: () => void
|
|
||||||
onCancel?: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ReviewForm({ article, onSuccess, onCancel }: ReviewFormProps): React.ReactElement {
|
export function ReviewForm({ article, onSuccess, onCancel }: ReviewFormProps): React.ReactElement {
|
||||||
const { pubkey, connect } = useNostrAuth()
|
const { pubkey, connect } = useNostrAuth()
|
||||||
const [content, setContent] = useState('')
|
const ctrl = useReviewFormController({ article, pubkey, onSuccess })
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!pubkey) {
|
if (!pubkey) {
|
||||||
return (
|
return (
|
||||||
<div className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark">
|
<ConnectRequiredCard
|
||||||
<p className="text-cyber-accent mb-4">{t('review.form.connectRequired')}</p>
|
message={t('review.form.connectRequired')}
|
||||||
<button
|
onConnect={() => {
|
||||||
onClick={() => {
|
void connect()
|
||||||
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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return <ReviewFormView ctrl={ctrl} onCancel={onCancel} />
|
||||||
<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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,149 +1,23 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import { nostrService } from '@/lib/nostr'
|
|
||||||
import { useNostrAuth } from '@/hooks/useNostrAuth'
|
import { useNostrAuth } from '@/hooks/useNostrAuth'
|
||||||
import { t } from '@/lib/i18n'
|
import { t } from '@/lib/i18n'
|
||||||
import { buildReviewTipZapRequestTags } from '@/lib/zapRequestBuilder'
|
import { ConnectRequiredCard } from './reviewForms/ConnectRequiredCard'
|
||||||
import { calculateReviewSplit } from '@/lib/platformCommissions'
|
import { ReviewTipFormView } from './reviewForms/ReviewTipFormView'
|
||||||
import type { Review } from '@/types/nostr'
|
import { useReviewTipFormController } from './reviewForms/useReviewTipFormController'
|
||||||
import type { Article } from '@/types/nostr'
|
import type { ReviewTipFormProps } from './reviewForms/reviewFormTypes'
|
||||||
|
|
||||||
interface ReviewTipFormProps {
|
|
||||||
review: Review
|
|
||||||
article: Article
|
|
||||||
onSuccess?: () => void
|
|
||||||
onCancel?: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ReviewTipForm({ review, article, onSuccess, onCancel }: ReviewTipFormProps): React.ReactElement {
|
export function ReviewTipForm({ review, article, onSuccess, onCancel }: ReviewTipFormProps): React.ReactElement {
|
||||||
const { pubkey, connect } = useNostrAuth()
|
const { pubkey, connect } = useNostrAuth()
|
||||||
const [text, setText] = useState('')
|
const ctrl = useReviewTipFormController({ review, article, pubkey, onSuccess })
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!pubkey) {
|
if (!pubkey) {
|
||||||
return (
|
return (
|
||||||
<div className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark">
|
<ConnectRequiredCard
|
||||||
<p className="text-cyber-accent mb-4">{t('reviewTip.form.connectRequired')}</p>
|
message={t('reviewTip.form.connectRequired')}
|
||||||
<button
|
onConnect={() => {
|
||||||
onClick={() => {
|
void connect()
|
||||||
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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
return <ReviewTipFormView ctrl={ctrl} onCancel={onCancel} />
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
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 { ArticleDraft } from '@/lib/articlePublisher'
|
||||||
import type { RelayPublishStatus } from '@/lib/publishResult'
|
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): {
|
export function useArticlePublishing(pubkey: string | null): {
|
||||||
loading: boolean
|
loading: boolean
|
||||||
error: string | null
|
error: string | null
|
||||||
@ -16,46 +27,73 @@ export function useArticlePublishing(pubkey: string | null): {
|
|||||||
const [success, setSuccess] = useState(false)
|
const [success, setSuccess] = useState(false)
|
||||||
const [relayStatuses, setRelayStatuses] = useState<RelayPublishStatus[]>([])
|
const [relayStatuses, setRelayStatuses] = useState<RelayPublishStatus[]>([])
|
||||||
|
|
||||||
const publishArticle = async (draft: ArticleDraft): Promise<string | null> => {
|
const publishArticle = buildPublishArticleHandler({
|
||||||
if (!pubkey) {
|
pubkey,
|
||||||
setError('Please connect with Nostr first')
|
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
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!draft.title.trim() || !draft.preview.trim() || !draft.content.trim()) {
|
const validationError = validateDraft(draft)
|
||||||
setError('Please fill in all fields')
|
if (validationError) {
|
||||||
|
params.setError(validationError)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true)
|
params.setLoading(true)
|
||||||
setError(null)
|
params.setError(null)
|
||||||
setRelayStatuses([])
|
params.setRelayStatuses([])
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const privateKey = nostrService.getPrivateKey()
|
const privateKey = nostrService.getPrivateKey()
|
||||||
const result = await articlePublisher.publishArticle(draft, pubkey, privateKey ?? undefined)
|
const result = await articlePublisher.publishArticle(draft, params.pubkey, privateKey ?? undefined)
|
||||||
|
return handlePublishResult({ result, setSuccess: params.setSuccess, setRelayStatuses: params.setRelayStatuses, setError: params.setError })
|
||||||
if (result.success) {
|
|
||||||
setSuccess(true)
|
|
||||||
setRelayStatuses(result.relayStatuses ?? [])
|
|
||||||
return result.articleId
|
|
||||||
}
|
|
||||||
|
|
||||||
setError(result.error ?? 'Failed to publish article')
|
|
||||||
return null
|
|
||||||
} catch (e) {
|
} 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
|
return null
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
params.setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return {
|
|
||||||
loading,
|
function handlePublishResult(params: {
|
||||||
error,
|
result: { success: boolean; relayStatuses?: RelayPublishStatus[]; articleId: string | null; error?: string | undefined }
|
||||||
success,
|
setSuccess: (success: boolean) => void
|
||||||
relayStatuses,
|
setRelayStatuses: (statuses: RelayPublishStatus[]) => void
|
||||||
publishArticle,
|
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 { canReadPreview, canReadFullContent } = canUserRead(event, userPubkey, hasPaid)
|
||||||
const isPaid = isPaidContent(event)
|
const isPaid = isPaidContent(event)
|
||||||
|
|
||||||
let reason: string | undefined
|
const reason = getAccessControlReason({ isPaid, canReadFullContent, userPubkey, canModify, canDelete })
|
||||||
if (isPaid && !canReadFullContent) {
|
|
||||||
reason = t('access.paymentRequired')
|
|
||||||
} else if (!canModify && userPubkey) {
|
|
||||||
reason = t('access.onlyAuthorModify')
|
|
||||||
} else if (!canDelete && userPubkey) {
|
|
||||||
reason = t('access.onlyAuthorDelete')
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
canModify,
|
canModify,
|
||||||
@ -105,3 +98,29 @@ export function getAccessControl(
|
|||||||
...(reason ? { reason } : {}),
|
...(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>,
|
operation: (relayUrl: string, pool: SimplePoolWithSub) => Promise<T>,
|
||||||
timeout: number = 10000
|
timeout: number = 10000
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
// Get active relays (enabled and not marked inactive for this session)
|
const initialActiveRelays = await relaySessionManager.getActiveRelays()
|
||||||
const activeRelays = await relaySessionManager.getActiveRelays()
|
if (initialActiveRelays.length === 0) {
|
||||||
|
|
||||||
if (activeRelays.length === 0) {
|
|
||||||
throw new Error('No active relays available')
|
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 lastError: Error | null = null
|
||||||
let attempts = 0
|
|
||||||
const maxAttempts = activeRelays.length * 2 // Try all active relays twice (loop once)
|
|
||||||
|
|
||||||
while (attempts < maxAttempts) {
|
for (let attempts = 0; attempts < maxAttempts; attempts += 1) {
|
||||||
// Get current active relays (may have changed if some were marked inactive)
|
const { relayUrl, relayIndex, totalRelays } = await pickRelayForAttempt(attempts)
|
||||||
const currentActiveRelays = await relaySessionManager.getActiveRelays()
|
const attempt = await tryOperationOnRelay({ relayUrl, relayIndex, totalRelays, poolWithSub, operation, timeout })
|
||||||
|
if (attempt.ok) {
|
||||||
if (currentActiveRelays.length === 0) {
|
return attempt.value
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
lastError = attempt.error
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we get here, all relays failed
|
|
||||||
throw lastError ?? new Error('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
|
* Create a subscription with relay rotation
|
||||||
* Tries each relay until one succeeds
|
* Tries each relay until one succeeds
|
||||||
|
|||||||
@ -24,56 +24,62 @@ export function getLatestVersion(events: Event[]): Event | null {
|
|||||||
return 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[]>()
|
const byId = new Map<string, VersionedObject[]>()
|
||||||
|
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
const tags = extractTagsFromEvent(event)
|
const tags = extractTagsFromEvent(event)
|
||||||
if (tags.id) {
|
if (tags.id) {
|
||||||
if (!byId.has(tags.id)) {
|
const list = byId.get(tags.id) ?? []
|
||||||
byId.set(tags.id, [])
|
list.push({
|
||||||
}
|
event,
|
||||||
|
version: tags.version,
|
||||||
const idArray = byId.get(tags.id)
|
hidden: tags.hidden,
|
||||||
if (idArray) {
|
pubkey: event.pubkey,
|
||||||
idArray.push({
|
id: tags.id,
|
||||||
event,
|
})
|
||||||
version: tags.version,
|
byId.set(tags.id, list)
|
||||||
hidden: tags.hidden,
|
|
||||||
pubkey: event.pubkey,
|
|
||||||
id: tags.id,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// For each ID, find the latest non-hidden version
|
return byId
|
||||||
const latestVersions: VersionedObject[] = []
|
}
|
||||||
|
|
||||||
|
function getLatestVisibleById(byId: Map<string, VersionedObject[]>): VersionedObject[] {
|
||||||
|
const latest: VersionedObject[] = []
|
||||||
|
|
||||||
for (const objects of byId.values()) {
|
for (const objects of byId.values()) {
|
||||||
// Filter out hidden objects
|
const candidate = pickLatestVisible(objects)
|
||||||
const visible = objects.filter((obj) => !obj.hidden)
|
if (candidate) {
|
||||||
|
latest.push(candidate)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we have multiple IDs, we need to return the one with the highest version
|
return latest
|
||||||
// But typically we expect one ID per query, so return the first
|
}
|
||||||
if (latestVersions.length === 0) {
|
|
||||||
|
function pickLatestVisible(objects: VersionedObject[]): VersionedObject | null {
|
||||||
|
const visible = objects.filter((obj) => !obj.hidden)
|
||||||
|
if (visible.length === 0) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by version and return the latest
|
visible.sort((a, b) => b.version - a.version)
|
||||||
latestVersions.sort((a, b) => b.version - a.version)
|
return visible[0] ?? null
|
||||||
const latestVersion = latestVersions[0]
|
}
|
||||||
return latestVersion ? latestVersion.event : 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 {
|
export default function SeriesPage(): React.ReactElement | null {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { id } = router.query
|
const { id } = router.query
|
||||||
const seriesId = typeof id === 'string' ? id : ''
|
const seriesId = getSeriesIdFromQuery(id)
|
||||||
const { series, articles, aggregates, loading, error } = useSeriesPageData(seriesId)
|
const data = useSeriesPageData(seriesId ?? '')
|
||||||
|
|
||||||
if (!seriesId) {
|
if (!seriesId) {
|
||||||
return null
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
@ -57,26 +77,47 @@ export default function SeriesPage(): React.ReactElement | null {
|
|||||||
</Head>
|
</Head>
|
||||||
<main className="min-h-screen bg-gray-50">
|
<main className="min-h-screen bg-gray-50">
|
||||||
<div className="w-full px-4 py-8 space-y-6">
|
<div className="w-full px-4 py-8 space-y-6">
|
||||||
{loading && <p className="text-sm text-gray-600">{t('common.loading')}</p>}
|
<SeriesPageStatus loading={loading} error={error} />
|
||||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
{series ? <SeriesPageContent series={series} articles={articles} aggregates={aggregates} /> : null}
|
||||||
{series && (
|
|
||||||
<>
|
|
||||||
<SeriesHeader series={series} />
|
|
||||||
<SeriesActions series={series} />
|
|
||||||
<SeriesStats
|
|
||||||
sponsoring={aggregates?.sponsoring ?? 0}
|
|
||||||
purchases={aggregates?.purchases ?? 0}
|
|
||||||
reviewTips={aggregates?.reviewTips ?? 0}
|
|
||||||
/>
|
|
||||||
<SeriesPublications articles={articles} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</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 {
|
function SeriesActions({ series }: { series: Series }): React.ReactElement | null {
|
||||||
const { pubkey } = useNostrAuth()
|
const { pubkey } = useNostrAuth()
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user