lint wip
This commit is contained in:
parent
f454553a66
commit
9ad602d100
@ -1,593 +1 @@
|
|||||||
import { useState, useCallback, useEffect, type FormEvent } from 'react'
|
export { AuthorPresentationEditor } from './authorPresentationEditor/AuthorPresentationEditor'
|
||||||
import { useRouter } from 'next/router'
|
|
||||||
import { useNostrAuth } from '@/hooks/useNostrAuth'
|
|
||||||
import { useAuthorPresentation } from '@/hooks/useAuthorPresentation'
|
|
||||||
import { ArticleField } from './ArticleField'
|
|
||||||
import { CreateAccountModal } from './CreateAccountModal'
|
|
||||||
import { RecoveryStep } from './CreateAccountModalSteps'
|
|
||||||
import { UnlockAccountModal } from './UnlockAccountModal'
|
|
||||||
import { ImageUploadField } from './ImageUploadField'
|
|
||||||
import { PresentationFormHeader } from './PresentationFormHeader'
|
|
||||||
import { extractPresentationData } from '@/lib/presentationParsing'
|
|
||||||
import type { Article } from '@/types/nostr'
|
|
||||||
import { t } from '@/lib/i18n'
|
|
||||||
import { userConfirm } from '@/lib/userConfirm'
|
|
||||||
|
|
||||||
interface AuthorPresentationDraft {
|
|
||||||
authorName: string
|
|
||||||
presentation: string
|
|
||||||
contentDescription: string
|
|
||||||
mainnetAddress: string
|
|
||||||
pictureUrl?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const ADDRESS_PATTERN = /^(1|3|bc1)[a-zA-Z0-9]{25,62}$/
|
|
||||||
|
|
||||||
function SuccessNotice({ pubkey }: { pubkey: string | null }): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<div className="border border-neon-green/50 rounded-lg p-6 bg-neon-green/10">
|
|
||||||
<h3 className="text-lg font-semibold text-neon-green mb-2">{t('presentation.success')}</h3>
|
|
||||||
<p className="text-cyber-accent mb-4">
|
|
||||||
{t('presentation.successMessage')}
|
|
||||||
</p>
|
|
||||||
{pubkey && (
|
|
||||||
<div className="mt-4">
|
|
||||||
<a
|
|
||||||
href={`/author/${pubkey}`}
|
|
||||||
className="inline-block 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"
|
|
||||||
>
|
|
||||||
{t('presentation.manageSeries')}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ValidationError({ message }: { message: string | null }): React.ReactElement | null {
|
|
||||||
if (!message) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="bg-red-500/10 border border-red-500/50 rounded-lg p-3">
|
|
||||||
<p className="text-sm text-red-400">{message}</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function PresentationField({
|
|
||||||
draft,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
draft: AuthorPresentationDraft
|
|
||||||
onChange: (next: AuthorPresentationDraft) => void
|
|
||||||
}): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<ArticleField
|
|
||||||
id="presentation"
|
|
||||||
label={t('presentation.field.presentation')}
|
|
||||||
value={draft.presentation}
|
|
||||||
onChange={(value) => onChange({ ...draft, presentation: value as string })}
|
|
||||||
required
|
|
||||||
type="textarea"
|
|
||||||
rows={6}
|
|
||||||
placeholder={t('presentation.field.presentation.placeholder')}
|
|
||||||
helpText={t('presentation.field.presentation.help')}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ContentDescriptionField({
|
|
||||||
draft,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
draft: AuthorPresentationDraft
|
|
||||||
onChange: (next: AuthorPresentationDraft) => void
|
|
||||||
}): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<ArticleField
|
|
||||||
id="contentDescription"
|
|
||||||
label={t('presentation.field.contentDescription')}
|
|
||||||
value={draft.contentDescription}
|
|
||||||
onChange={(value) => onChange({ ...draft, contentDescription: value as string })}
|
|
||||||
required
|
|
||||||
type="textarea"
|
|
||||||
rows={6}
|
|
||||||
placeholder={t('presentation.field.contentDescription.placeholder')}
|
|
||||||
helpText={t('presentation.field.contentDescription.help')}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function MainnetAddressField({
|
|
||||||
draft,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
draft: AuthorPresentationDraft
|
|
||||||
onChange: (next: AuthorPresentationDraft) => void
|
|
||||||
}): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<ArticleField
|
|
||||||
id="mainnetAddress"
|
|
||||||
label={t('presentation.field.mainnetAddress')}
|
|
||||||
value={draft.mainnetAddress}
|
|
||||||
onChange={(value) => onChange({ ...draft, mainnetAddress: value as string })}
|
|
||||||
required
|
|
||||||
type="text"
|
|
||||||
placeholder={t('presentation.field.mainnetAddress.placeholder')}
|
|
||||||
helpText={t('presentation.field.mainnetAddress.help')}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AuthorNameField({
|
|
||||||
draft,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
draft: AuthorPresentationDraft
|
|
||||||
onChange: (next: AuthorPresentationDraft) => void
|
|
||||||
}): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<ArticleField
|
|
||||||
id="authorName"
|
|
||||||
label={t('presentation.field.authorName')}
|
|
||||||
value={draft.authorName}
|
|
||||||
onChange={(value) => onChange({ ...draft, authorName: value as string })}
|
|
||||||
required
|
|
||||||
type="text"
|
|
||||||
placeholder={t('presentation.field.authorName.placeholder')}
|
|
||||||
helpText={t('presentation.field.authorName.help')}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function PictureField({
|
|
||||||
draft,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
draft: AuthorPresentationDraft
|
|
||||||
onChange: (next: AuthorPresentationDraft) => void
|
|
||||||
}): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<ImageUploadField
|
|
||||||
id="picture"
|
|
||||||
value={draft.pictureUrl}
|
|
||||||
onChange={(url) => onChange({ ...draft, pictureUrl: url })}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const PresentationFields = ({
|
|
||||||
draft,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
draft: AuthorPresentationDraft
|
|
||||||
onChange: (next: AuthorPresentationDraft) => void
|
|
||||||
}): React.ReactElement => (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<AuthorNameField draft={draft} onChange={onChange} />
|
|
||||||
<PictureField draft={draft} onChange={onChange} />
|
|
||||||
<PresentationField draft={draft} onChange={onChange} />
|
|
||||||
<ContentDescriptionField draft={draft} onChange={onChange} />
|
|
||||||
<MainnetAddressField draft={draft} onChange={onChange} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
function DeleteButton({ onDelete, deleting }: { onDelete: () => void; deleting: boolean }): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onDelete}
|
|
||||||
disabled={deleting}
|
|
||||||
className="px-4 py-2 bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded-lg text-sm font-medium transition-all border border-red-500/50 hover:shadow-glow-red disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{deleting ? t('presentation.delete.deleting') : t('presentation.delete.button')}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
type PresentationFormProps = {
|
|
||||||
draft: AuthorPresentationDraft
|
|
||||||
setDraft: (next: AuthorPresentationDraft) => void
|
|
||||||
validationError: string | null
|
|
||||||
error: string | null
|
|
||||||
loading: boolean
|
|
||||||
handleSubmit: (e: FormEvent<HTMLFormElement>) => Promise<void>
|
|
||||||
deleting: boolean
|
|
||||||
handleDelete: () => void
|
|
||||||
hasExistingPresentation: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
function PresentationForm(props: PresentationFormProps): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<form
|
|
||||||
onSubmit={(e: FormEvent<HTMLFormElement>) => {
|
|
||||||
void props.handleSubmit(e)
|
|
||||||
}}
|
|
||||||
className="border border-neon-cyan/20 rounded-lg p-6 bg-cyber-dark space-y-4"
|
|
||||||
>
|
|
||||||
<PresentationFormHeader />
|
|
||||||
<PresentationFields draft={props.draft} onChange={props.setDraft} />
|
|
||||||
<ValidationError message={props.validationError ?? props.error} />
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="flex-1">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={props.loading ?? props.deleting}
|
|
||||||
className="w-full 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"
|
|
||||||
>
|
|
||||||
{getSubmitLabel(props)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{props.hasExistingPresentation && (
|
|
||||||
<DeleteButton onDelete={() => { void props.handleDelete() }} deleting={props.deleting} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSubmitLabel(params: { loading: boolean; deleting: boolean; hasExistingPresentation: boolean }): string {
|
|
||||||
if (params.loading || params.deleting) {
|
|
||||||
return t('publish.publishing')
|
|
||||||
}
|
|
||||||
return params.hasExistingPresentation ? t('presentation.update.button') : t('publish.button')
|
|
||||||
}
|
|
||||||
|
|
||||||
function useAuthorPresentationState(pubkey: string | null, existingAuthorName?: string, existingPresentation?: Article | null): {
|
|
||||||
draft: AuthorPresentationDraft
|
|
||||||
setDraft: (next: AuthorPresentationDraft) => void
|
|
||||||
validationError: string | null
|
|
||||||
error: string | null
|
|
||||||
loading: boolean
|
|
||||||
handleSubmit: (e: FormEvent<HTMLFormElement>) => Promise<void>
|
|
||||||
deleting: boolean
|
|
||||||
handleDelete: () => Promise<void>
|
|
||||||
success: boolean
|
|
||||||
} {
|
|
||||||
const { loading, error, success, publishPresentation, deletePresentation } = useAuthorPresentation(pubkey)
|
|
||||||
const router = useRouter()
|
|
||||||
const [draft, setDraft] = useState<AuthorPresentationDraft>(() => buildInitialDraft(existingPresentation, existingAuthorName))
|
|
||||||
const [validationError, setValidationError] = useState<string | null>(null)
|
|
||||||
const [deleting, setDeleting] = useState(false)
|
|
||||||
|
|
||||||
// Update authorName when profile changes
|
|
||||||
useEffect(() => {
|
|
||||||
syncAuthorNameIntoDraft({ existingAuthorName, draftAuthorName: draft.authorName, hasExistingPresentation: Boolean(existingPresentation), setDraft })
|
|
||||||
}, [existingAuthorName, existingPresentation, draft.authorName])
|
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
|
||||||
async (e: FormEvent<HTMLFormElement>) => {
|
|
||||||
e.preventDefault()
|
|
||||||
await submitPresentationDraft({ draft, setValidationError, publishPresentation })
|
|
||||||
},
|
|
||||||
[draft, publishPresentation]
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleDelete = useCallback(async () => {
|
|
||||||
await deletePresentationFlow({
|
|
||||||
existingPresentationId: existingPresentation?.id,
|
|
||||||
deletePresentation,
|
|
||||||
router,
|
|
||||||
setDeleting,
|
|
||||||
setValidationError,
|
|
||||||
})
|
|
||||||
}, [existingPresentation, deletePresentation, router])
|
|
||||||
|
|
||||||
return { loading, error, success, draft, setDraft, validationError, handleSubmit, deleting, handleDelete }
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildInitialDraft(existingPresentation: Article | null | undefined, existingAuthorName: string | undefined): AuthorPresentationDraft {
|
|
||||||
if (existingPresentation) {
|
|
||||||
const { presentation, contentDescription } = extractPresentationData(existingPresentation)
|
|
||||||
const authorName = existingPresentation.title.replace(/^Présentation de /, '') ?? existingAuthorName ?? ''
|
|
||||||
return {
|
|
||||||
authorName,
|
|
||||||
presentation,
|
|
||||||
contentDescription,
|
|
||||||
mainnetAddress: existingPresentation.mainnetAddress ?? '',
|
|
||||||
...(existingPresentation.bannerUrl ? { pictureUrl: existingPresentation.bannerUrl } : {}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
authorName: existingAuthorName ?? '',
|
|
||||||
presentation: '',
|
|
||||||
contentDescription: '',
|
|
||||||
mainnetAddress: '',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncAuthorNameIntoDraft(params: {
|
|
||||||
existingAuthorName: string | undefined
|
|
||||||
draftAuthorName: string
|
|
||||||
hasExistingPresentation: boolean
|
|
||||||
setDraft: (updater: (prev: AuthorPresentationDraft) => AuthorPresentationDraft) => void
|
|
||||||
}): void {
|
|
||||||
if (!params.existingAuthorName || params.hasExistingPresentation || params.existingAuthorName === params.draftAuthorName) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
params.setDraft((prev) => ({ ...prev, authorName: params.existingAuthorName as string }))
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitPresentationDraft(params: {
|
|
||||||
draft: AuthorPresentationDraft
|
|
||||||
setValidationError: (value: string | null) => void
|
|
||||||
publishPresentation: (draft: AuthorPresentationDraft) => Promise<void>
|
|
||||||
}): Promise<void> {
|
|
||||||
const error = validatePresentationDraft(params.draft)
|
|
||||||
if (error) {
|
|
||||||
params.setValidationError(error)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
params.setValidationError(null)
|
|
||||||
await params.publishPresentation(params.draft)
|
|
||||||
}
|
|
||||||
|
|
||||||
function validatePresentationDraft(draft: AuthorPresentationDraft): string | null {
|
|
||||||
const address = draft.mainnetAddress.trim()
|
|
||||||
if (!ADDRESS_PATTERN.test(address)) {
|
|
||||||
return t('presentation.validation.invalidAddress')
|
|
||||||
}
|
|
||||||
if (!draft.authorName.trim()) {
|
|
||||||
return t('presentation.validation.authorNameRequired')
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deletePresentationFlow(params: {
|
|
||||||
existingPresentationId: string | undefined
|
|
||||||
deletePresentation: (articleId: string) => Promise<void>
|
|
||||||
router: ReturnType<typeof useRouter>
|
|
||||||
setDeleting: (value: boolean) => void
|
|
||||||
setValidationError: (value: string | null) => void
|
|
||||||
}): Promise<void> {
|
|
||||||
if (!params.existingPresentationId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const confirmed = await userConfirm(t('presentation.delete.confirm'))
|
|
||||||
if (!confirmed) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
params.setDeleting(true)
|
|
||||||
params.setValidationError(null)
|
|
||||||
try {
|
|
||||||
await params.deletePresentation(params.existingPresentationId)
|
|
||||||
await params.router.push('/')
|
|
||||||
} catch (e) {
|
|
||||||
params.setValidationError(e instanceof Error ? e.message : t('presentation.delete.error'))
|
|
||||||
} finally {
|
|
||||||
params.setDeleting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function NoAccountActionButtons({
|
|
||||||
onGenerate,
|
|
||||||
onImport,
|
|
||||||
}: {
|
|
||||||
onGenerate: () => void
|
|
||||||
onImport: () => void
|
|
||||||
}): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-3 w-full max-w-xs">
|
|
||||||
<button
|
|
||||||
onClick={onGenerate}
|
|
||||||
className="px-6 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"
|
|
||||||
>
|
|
||||||
{t('account.create.generateButton')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={onImport}
|
|
||||||
className="px-6 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg font-medium transition-colors"
|
|
||||||
>
|
|
||||||
{t('account.create.importButton')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function NoAccountView(): React.ReactElement {
|
|
||||||
const [showImportModal, setShowImportModal] = useState(false)
|
|
||||||
const [showRecoveryStep, setShowRecoveryStep] = useState(false)
|
|
||||||
const [showUnlockModal, setShowUnlockModal] = useState(false)
|
|
||||||
const [recoveryPhrase, setRecoveryPhrase] = useState<string[]>([])
|
|
||||||
const [npub, setNpub] = useState('')
|
|
||||||
const [generating, setGenerating] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const handleGenerate = (): Promise<void> => generateNoAccount({ setGenerating, setError, setRecoveryPhrase, setNpub, setShowRecoveryStep })
|
|
||||||
const handleRecoveryContinue = (): void => transitionToUnlock({ setShowRecoveryStep, setShowUnlockModal })
|
|
||||||
const handleUnlockSuccess = (): void => resetNoAccountAfterUnlock({ setShowUnlockModal, setRecoveryPhrase, setNpub })
|
|
||||||
const handleImportSuccess = (): void => {
|
|
||||||
setShowImportModal(false)
|
|
||||||
setShowUnlockModal(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<NoAccountCard
|
|
||||||
error={error}
|
|
||||||
generating={generating}
|
|
||||||
onGenerate={() => { void handleGenerate() }}
|
|
||||||
onImport={() => setShowImportModal(true)}
|
|
||||||
modals={
|
|
||||||
<NoAccountModals
|
|
||||||
showImportModal={showImportModal}
|
|
||||||
onCloseImport={() => setShowImportModal(false)}
|
|
||||||
onImportSuccess={handleImportSuccess}
|
|
||||||
showRecoveryStep={showRecoveryStep}
|
|
||||||
recoveryPhrase={recoveryPhrase}
|
|
||||||
npub={npub}
|
|
||||||
onRecoveryContinue={handleRecoveryContinue}
|
|
||||||
showUnlockModal={showUnlockModal}
|
|
||||||
onUnlockSuccess={handleUnlockSuccess}
|
|
||||||
onCloseUnlock={() => setShowUnlockModal(false)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function generateNoAccount(params: {
|
|
||||||
setGenerating: (value: boolean) => void
|
|
||||||
setError: (value: string | null) => void
|
|
||||||
setRecoveryPhrase: (value: string[]) => void
|
|
||||||
setNpub: (value: string) => void
|
|
||||||
setShowRecoveryStep: (value: boolean) => void
|
|
||||||
}): Promise<void> {
|
|
||||||
params.setGenerating(true)
|
|
||||||
params.setError(null)
|
|
||||||
try {
|
|
||||||
const { nostrAuthService } = await import('@/lib/nostrAuth')
|
|
||||||
const result = await nostrAuthService.createAccount()
|
|
||||||
params.setRecoveryPhrase(result.recoveryPhrase)
|
|
||||||
params.setNpub(result.npub)
|
|
||||||
params.setShowRecoveryStep(true)
|
|
||||||
} catch (e) {
|
|
||||||
params.setError(e instanceof Error ? e.message : t('account.create.error.failed'))
|
|
||||||
} finally {
|
|
||||||
params.setGenerating(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function transitionToUnlock(params: { setShowRecoveryStep: (value: boolean) => void; setShowUnlockModal: (value: boolean) => void }): void {
|
|
||||||
params.setShowRecoveryStep(false)
|
|
||||||
params.setShowUnlockModal(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetNoAccountAfterUnlock(params: {
|
|
||||||
setShowUnlockModal: (value: boolean) => void
|
|
||||||
setRecoveryPhrase: (value: string[]) => void
|
|
||||||
setNpub: (value: string) => void
|
|
||||||
}): void {
|
|
||||||
params.setShowUnlockModal(false)
|
|
||||||
params.setRecoveryPhrase([])
|
|
||||||
params.setNpub('')
|
|
||||||
}
|
|
||||||
|
|
||||||
function NoAccountCard(params: {
|
|
||||||
error: string | null
|
|
||||||
generating: boolean
|
|
||||||
onGenerate: () => void
|
|
||||||
onImport: () => void
|
|
||||||
modals: React.ReactElement
|
|
||||||
}): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<div className="border border-neon-cyan/20 rounded-lg p-6 bg-cyber-dark/50">
|
|
||||||
<div className="flex flex-col items-center gap-4">
|
|
||||||
<p className="text-center text-cyber-accent mb-2">Créez un compte ou importez votre clé secrète pour commencer</p>
|
|
||||||
{params.error && <p className="text-sm text-red-400">{params.error}</p>}
|
|
||||||
<NoAccountActionButtons onGenerate={params.onGenerate} onImport={params.onImport} />
|
|
||||||
{params.generating && <p className="text-cyber-accent text-sm">Génération du compte...</p>}
|
|
||||||
{params.modals}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function NoAccountModals(params: {
|
|
||||||
showImportModal: boolean
|
|
||||||
onImportSuccess: () => void
|
|
||||||
onCloseImport: () => void
|
|
||||||
showRecoveryStep: boolean
|
|
||||||
recoveryPhrase: string[]
|
|
||||||
npub: string
|
|
||||||
onRecoveryContinue: () => void
|
|
||||||
showUnlockModal: boolean
|
|
||||||
onUnlockSuccess: () => void
|
|
||||||
onCloseUnlock: () => void
|
|
||||||
}): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{params.showImportModal && <CreateAccountModal onSuccess={params.onImportSuccess} onClose={params.onCloseImport} initialStep="import" />}
|
|
||||||
{params.showRecoveryStep && <RecoveryStep recoveryPhrase={params.recoveryPhrase} npub={params.npub} onContinue={params.onRecoveryContinue} />}
|
|
||||||
{params.showUnlockModal && <UnlockAccountModal onSuccess={params.onUnlockSuccess} onClose={params.onCloseUnlock} />}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AuthorPresentationFormView(props: {
|
|
||||||
pubkey: string | null
|
|
||||||
profile: { name?: string; pubkey: string } | null
|
|
||||||
}): React.ReactElement {
|
|
||||||
const { checkPresentationExists } = useAuthorPresentation(props.pubkey)
|
|
||||||
const presentation = useExistingPresentation({ pubkey: props.pubkey, checkPresentationExists })
|
|
||||||
const state = useAuthorPresentationState(props.pubkey, props.profile?.name, presentation.existingPresentation)
|
|
||||||
|
|
||||||
if (!props.pubkey) {
|
|
||||||
return <NoAccountView />
|
|
||||||
}
|
|
||||||
if (presentation.loadingPresentation) {
|
|
||||||
return <LoadingNotice />
|
|
||||||
}
|
|
||||||
if (state.success) {
|
|
||||||
return <SuccessNotice pubkey={props.pubkey} />
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<PresentationForm
|
|
||||||
draft={state.draft}
|
|
||||||
setDraft={state.setDraft}
|
|
||||||
validationError={state.validationError}
|
|
||||||
error={state.error}
|
|
||||||
loading={state.loading}
|
|
||||||
handleSubmit={state.handleSubmit}
|
|
||||||
deleting={state.deleting}
|
|
||||||
handleDelete={() => { void state.handleDelete() }}
|
|
||||||
hasExistingPresentation={presentation.existingPresentation !== null}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function LoadingNotice(): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<p className="text-cyber-accent/70">{t('common.loading')}</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function useExistingPresentation(params: {
|
|
||||||
pubkey: string | null
|
|
||||||
checkPresentationExists: () => Promise<Article | null>
|
|
||||||
}): { existingPresentation: Article | null; loadingPresentation: boolean } {
|
|
||||||
const [existingPresentation, setExistingPresentation] = useState<Article | null>(null)
|
|
||||||
const [loadingPresentation, setLoadingPresentation] = useState(true)
|
|
||||||
const { pubkey, checkPresentationExists } = params
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void loadExistingPresentation({ pubkey, checkPresentationExists, setExistingPresentation, setLoadingPresentation })
|
|
||||||
}, [pubkey, checkPresentationExists])
|
|
||||||
|
|
||||||
return { existingPresentation, loadingPresentation }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadExistingPresentation(params: {
|
|
||||||
pubkey: string | null
|
|
||||||
checkPresentationExists: () => Promise<Article | null>
|
|
||||||
setExistingPresentation: (value: Article | null) => void
|
|
||||||
setLoadingPresentation: (value: boolean) => void
|
|
||||||
}): Promise<void> {
|
|
||||||
if (!params.pubkey) {
|
|
||||||
params.setLoadingPresentation(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
params.setExistingPresentation(await params.checkPresentationExists())
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error loading presentation:', e)
|
|
||||||
} finally {
|
|
||||||
params.setLoadingPresentation(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function useAutoLoadPubkey(accountExists: boolean | null, pubkey: string | null, connect: () => Promise<void>): void {
|
|
||||||
useEffect(() => {
|
|
||||||
if (accountExists === true && !pubkey) {
|
|
||||||
void connect()
|
|
||||||
}
|
|
||||||
}, [accountExists, pubkey, connect])
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AuthorPresentationEditor(): React.ReactElement {
|
|
||||||
const { pubkey, profile, accountExists, connect } = useNostrAuth()
|
|
||||||
useAutoLoadPubkey(accountExists, pubkey ?? null, connect)
|
|
||||||
return <AuthorPresentationFormView pubkey={pubkey ?? null} profile={profile} />
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,559 +1 @@
|
|||||||
import { useState, useEffect } from 'react'
|
export { KeyManagementManager } from './keyManagement/KeyManagementManager'
|
||||||
import { nostrAuthService } from '@/lib/nostrAuth'
|
|
||||||
import { keyManagementService } from '@/lib/keyManagement'
|
|
||||||
import { nip19 } from 'nostr-tools'
|
|
||||||
import { t } from '@/lib/i18n'
|
|
||||||
import { SyncProgressBar } from './SyncProgressBar'
|
|
||||||
|
|
||||||
interface PublicKeys {
|
|
||||||
publicKey: string
|
|
||||||
npub: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function KeyManagementManager(): React.ReactElement {
|
|
||||||
const [publicKeys, setPublicKeys] = useState<PublicKeys | null>(null)
|
|
||||||
const [accountExists, setAccountExists] = useState(false)
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const [importKey, setImportKey] = useState('')
|
|
||||||
const [importing, setImporting] = useState(false)
|
|
||||||
const [showImportForm, setShowImportForm] = useState(false)
|
|
||||||
const [showReplaceWarning, setShowReplaceWarning] = useState(false)
|
|
||||||
const [recoveryPhrase, setRecoveryPhrase] = useState<string[] | null>(null)
|
|
||||||
const [newNpub, setNewNpub] = useState<string | null>(null)
|
|
||||||
const [copiedNpub, setCopiedNpub] = useState(false)
|
|
||||||
const [copiedPublicKey, setCopiedPublicKey] = useState(false)
|
|
||||||
const [copiedRecoveryPhrase, setCopiedRecoveryPhrase] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void loadKeys()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
async function loadKeys(): Promise<void> {
|
|
||||||
try {
|
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
const exists = await nostrAuthService.accountExists()
|
|
||||||
setAccountExists(exists)
|
|
||||||
|
|
||||||
if (exists) {
|
|
||||||
const keys = await keyManagementService.getPublicKeys()
|
|
||||||
if (keys) {
|
|
||||||
setPublicKeys(keys)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
const errorMessage = e instanceof Error ? e.message : t('settings.keyManagement.loading')
|
|
||||||
setError(errorMessage)
|
|
||||||
console.error('Error loading keys:', e)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractKeyFromUrl(url: string): string | null {
|
|
||||||
try {
|
|
||||||
// Try to parse as URL
|
|
||||||
const urlObj = new URL(url)
|
|
||||||
// Check if it's a nostr:// URL with nsec
|
|
||||||
if (urlObj.protocol === 'nostr:' || urlObj.protocol === 'nostr://') {
|
|
||||||
const path = urlObj.pathname ?? urlObj.href.replace(/^nostr:?\/\//, '')
|
|
||||||
if (path.startsWith('nsec')) {
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Check if URL contains nsec
|
|
||||||
const nsecMatch = url.match(/nsec1[a-z0-9]+/i)
|
|
||||||
if (nsecMatch) {
|
|
||||||
return nsecMatch[0]
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
} catch {
|
|
||||||
return extractKeyFromText(url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractKeyFromText(text: string): string {
|
|
||||||
const nsec = extractNsec(text)
|
|
||||||
if (nsec) {
|
|
||||||
return nsec
|
|
||||||
}
|
|
||||||
return text.trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractNsec(text: string): string | null {
|
|
||||||
const nsecMatch = text.match(/nsec1[a-z0-9]+/i)
|
|
||||||
return nsecMatch?.[0] ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
function isValidPrivateKeyFormat(key: string): boolean {
|
|
||||||
try {
|
|
||||||
const decoded = nip19.decode(key)
|
|
||||||
if (decoded.type !== 'nsec') {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return typeof decoded.data === 'string' || decoded.data instanceof Uint8Array
|
|
||||||
} catch {
|
|
||||||
return /^[0-9a-f]{64}$/i.test(key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleImport(): Promise<void> {
|
|
||||||
if (!importKey.trim()) {
|
|
||||||
setError(t('settings.keyManagement.import.error.required'))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract key from URL or text
|
|
||||||
const extractedKey = extractKeyFromUrl(importKey.trim())
|
|
||||||
if (!extractedKey) {
|
|
||||||
setError(t('settings.keyManagement.import.error.invalid'))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate key format
|
|
||||||
if (!isValidPrivateKeyFormat(extractedKey)) {
|
|
||||||
setError(t('settings.keyManagement.import.error.invalid'))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// If account exists, show warning
|
|
||||||
if (accountExists) {
|
|
||||||
setShowReplaceWarning(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await performImport(extractedKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function performImport(key: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
setImporting(true)
|
|
||||||
setError(null)
|
|
||||||
setShowReplaceWarning(false)
|
|
||||||
|
|
||||||
// If account exists, delete it first
|
|
||||||
if (accountExists) {
|
|
||||||
await nostrAuthService.deleteAccount()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new account with imported key
|
|
||||||
const result = await nostrAuthService.createAccount(key)
|
|
||||||
setRecoveryPhrase(result.recoveryPhrase)
|
|
||||||
setNewNpub(result.npub)
|
|
||||||
setImportKey('')
|
|
||||||
setShowImportForm(false)
|
|
||||||
await loadKeys()
|
|
||||||
|
|
||||||
// Sync user content via Service Worker
|
|
||||||
if (result.publicKey) {
|
|
||||||
const { swClient } = await import('@/lib/swClient')
|
|
||||||
const isReady = await swClient.isReady()
|
|
||||||
if (isReady) {
|
|
||||||
void swClient.startUserSync(result.publicKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
const errorMessage = e instanceof Error ? e.message : t('settings.keyManagement.import.error.failed')
|
|
||||||
setError(errorMessage)
|
|
||||||
console.error('Error importing key:', e)
|
|
||||||
} finally {
|
|
||||||
setImporting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleCopyRecoveryPhrase(): Promise<void> {
|
|
||||||
if (!recoveryPhrase) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(recoveryPhrase.join(' '))
|
|
||||||
setCopiedRecoveryPhrase(true)
|
|
||||||
setTimeout(() => {
|
|
||||||
setCopiedRecoveryPhrase(false)
|
|
||||||
}, 2000)
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error copying recovery phrase:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleCopyNpub(): Promise<void> {
|
|
||||||
if (!publicKeys?.npub) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(publicKeys.npub)
|
|
||||||
setCopiedNpub(true)
|
|
||||||
setTimeout(() => {
|
|
||||||
setCopiedNpub(false)
|
|
||||||
}, 2000)
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error copying npub:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleCopyPublicKey(): Promise<void> {
|
|
||||||
if (!publicKeys?.publicKey) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(publicKeys.publicKey)
|
|
||||||
setCopiedPublicKey(true)
|
|
||||||
setTimeout(() => {
|
|
||||||
setCopiedPublicKey(false)
|
|
||||||
}, 2000)
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error copying public key:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="bg-cyber-darker border border-neon-cyan/30 rounded-lg p-6">
|
|
||||||
<p className="text-cyber-accent">{t('settings.keyManagement.loading')}</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="bg-cyber-darker border border-neon-cyan/30 rounded-lg p-6">
|
|
||||||
<h2 className="text-2xl font-bold text-neon-cyan mb-4">{t('settings.keyManagement.title')}</h2>
|
|
||||||
|
|
||||||
<KeyManagementErrorBanner error={error} />
|
|
||||||
|
|
||||||
<KeyManagementPublicKeysPanel
|
|
||||||
publicKeys={publicKeys}
|
|
||||||
copiedNpub={copiedNpub}
|
|
||||||
copiedPublicKey={copiedPublicKey}
|
|
||||||
onCopyNpub={handleCopyNpub}
|
|
||||||
onCopyPublicKey={handleCopyPublicKey}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Sync Progress Bar - Always show if connected, even if publicKeys not loaded yet */}
|
|
||||||
<SyncProgressBar />
|
|
||||||
|
|
||||||
<KeyManagementNoAccountBanner publicKeys={publicKeys} accountExists={accountExists} />
|
|
||||||
|
|
||||||
<KeyManagementImportButton
|
|
||||||
accountExists={accountExists}
|
|
||||||
showImportForm={showImportForm}
|
|
||||||
onClick={() => {
|
|
||||||
setShowImportForm(true)
|
|
||||||
setError(null)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<KeyManagementImportForm
|
|
||||||
accountExists={accountExists}
|
|
||||||
showImportForm={showImportForm}
|
|
||||||
showReplaceWarning={showReplaceWarning}
|
|
||||||
importing={importing}
|
|
||||||
importKey={importKey}
|
|
||||||
onChangeImportKey={(value) => {
|
|
||||||
setImportKey(value)
|
|
||||||
setError(null)
|
|
||||||
}}
|
|
||||||
onCancel={() => {
|
|
||||||
setShowImportForm(false)
|
|
||||||
setImportKey('')
|
|
||||||
setError(null)
|
|
||||||
}}
|
|
||||||
onImport={() => {
|
|
||||||
void handleImport()
|
|
||||||
}}
|
|
||||||
onDismissReplaceWarning={() => {
|
|
||||||
setShowReplaceWarning(false)
|
|
||||||
}}
|
|
||||||
onConfirmReplace={() => {
|
|
||||||
void performImport(extractKeyFromUrl(importKey.trim()) ?? importKey.trim())
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Recovery Phrase Display (after import) */}
|
|
||||||
<KeyManagementRecoveryPanel
|
|
||||||
recoveryPhrase={recoveryPhrase}
|
|
||||||
newNpub={newNpub}
|
|
||||||
copiedRecoveryPhrase={copiedRecoveryPhrase}
|
|
||||||
onCopyRecoveryPhrase={handleCopyRecoveryPhrase}
|
|
||||||
onDone={() => {
|
|
||||||
setRecoveryPhrase(null)
|
|
||||||
setNewNpub(null)
|
|
||||||
void loadKeys()
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function KeyManagementErrorBanner(params: { error: string | null }): React.ReactElement | null {
|
|
||||||
if (!params.error) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="bg-red-900/20 border border-red-400/50 rounded-lg p-4 mb-4">
|
|
||||||
<p className="text-red-400">{params.error}</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function KeyManagementPublicKeysPanel(params: {
|
|
||||||
publicKeys: PublicKeys | null
|
|
||||||
copiedNpub: boolean
|
|
||||||
copiedPublicKey: boolean
|
|
||||||
onCopyNpub: () => Promise<void>
|
|
||||||
onCopyPublicKey: () => Promise<void>
|
|
||||||
}): React.ReactElement | null {
|
|
||||||
if (!params.publicKeys) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="space-y-4 mb-6">
|
|
||||||
<KeyManagementKeyCard
|
|
||||||
label={t('settings.keyManagement.publicKey.npub')}
|
|
||||||
value={params.publicKeys.npub}
|
|
||||||
copied={params.copiedNpub}
|
|
||||||
onCopy={params.onCopyNpub}
|
|
||||||
/>
|
|
||||||
<KeyManagementKeyCard
|
|
||||||
label={t('settings.keyManagement.publicKey.hex')}
|
|
||||||
value={params.publicKeys.publicKey}
|
|
||||||
copied={params.copiedPublicKey}
|
|
||||||
onCopy={params.onCopyPublicKey}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function KeyManagementKeyCard(params: {
|
|
||||||
label: string
|
|
||||||
value: string
|
|
||||||
copied: boolean
|
|
||||||
onCopy: () => Promise<void>
|
|
||||||
}): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<div className="bg-neon-blue/10 border border-neon-blue/30 rounded-lg p-4">
|
|
||||||
<div className="flex justify-between items-start mb-2">
|
|
||||||
<p className="text-neon-blue font-semibold">{params.label}</p>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
void params.onCopy()
|
|
||||||
}}
|
|
||||||
className="px-3 py-1 text-xs bg-cyber-light border border-neon-cyan/30 hover:border-neon-cyan/50 hover:bg-cyber-dark text-cyber-accent hover:text-neon-cyan rounded transition-colors"
|
|
||||||
>
|
|
||||||
{params.copied ? t('settings.keyManagement.copied') : t('settings.keyManagement.copy')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p className="text-neon-cyan text-sm font-mono break-all">{params.value}</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function KeyManagementNoAccountBanner(params: {
|
|
||||||
publicKeys: PublicKeys | null
|
|
||||||
accountExists: boolean
|
|
||||||
}): React.ReactElement | null {
|
|
||||||
if (params.publicKeys || params.accountExists) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="bg-yellow-900/20 border border-yellow-400/50 rounded-lg p-4 mb-6">
|
|
||||||
<p className="text-yellow-400 font-semibold mb-2">{t('settings.keyManagement.noAccount.title')}</p>
|
|
||||||
<p className="text-yellow-300/90 text-sm">{t('settings.keyManagement.noAccount.description')}</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function KeyManagementImportButton(params: {
|
|
||||||
accountExists: boolean
|
|
||||||
showImportForm: boolean
|
|
||||||
onClick: () => void
|
|
||||||
}): React.ReactElement | null {
|
|
||||||
if (params.showImportForm) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={params.onClick}
|
|
||||||
className="w-full py-3 px-6 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"
|
|
||||||
>
|
|
||||||
{params.accountExists ? t('settings.keyManagement.import.button.replace') : t('settings.keyManagement.import.button.new')}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function KeyManagementImportForm(params: {
|
|
||||||
accountExists: boolean
|
|
||||||
showImportForm: boolean
|
|
||||||
showReplaceWarning: boolean
|
|
||||||
importing: boolean
|
|
||||||
importKey: string
|
|
||||||
onChangeImportKey: (value: string) => void
|
|
||||||
onCancel: () => void
|
|
||||||
onImport: () => void
|
|
||||||
onDismissReplaceWarning: () => void
|
|
||||||
onConfirmReplace: () => void
|
|
||||||
}): React.ReactElement | null {
|
|
||||||
if (!params.showImportForm) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="bg-yellow-900/20 border border-yellow-400/50 rounded-lg p-4">
|
|
||||||
<p className="text-yellow-400 font-semibold mb-2">{t('settings.keyManagement.import.warning.title')}</p>
|
|
||||||
<p className="text-yellow-300/90 text-sm" dangerouslySetInnerHTML={{ __html: t('settings.keyManagement.import.warning.description') }} />
|
|
||||||
{params.accountExists && (
|
|
||||||
<p className="text-yellow-300/90 text-sm mt-2" dangerouslySetInnerHTML={{ __html: t('settings.keyManagement.import.warning.replace') }} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="importKey" className="block text-sm font-medium text-cyber-accent mb-2">
|
|
||||||
{t('settings.keyManagement.import.label')}
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
id="importKey"
|
|
||||||
value={params.importKey}
|
|
||||||
onChange={(e) => {
|
|
||||||
params.onChangeImportKey(e.target.value)
|
|
||||||
}}
|
|
||||||
placeholder={t('settings.keyManagement.import.placeholder')}
|
|
||||||
className="w-full px-3 py-2 bg-cyber-dark border border-neon-cyan/30 rounded-lg font-mono text-sm text-neon-cyan focus:border-neon-cyan focus:outline-none"
|
|
||||||
rows={4}
|
|
||||||
/>
|
|
||||||
<p className="text-sm text-cyber-accent/70 mt-2">{t('settings.keyManagement.import.help')}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<KeyManagementReplaceWarning
|
|
||||||
show={params.showReplaceWarning}
|
|
||||||
importing={params.importing}
|
|
||||||
onCancel={params.onDismissReplaceWarning}
|
|
||||||
onConfirm={params.onConfirmReplace}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<KeyManagementImportFormActions
|
|
||||||
show={!params.showReplaceWarning}
|
|
||||||
importing={params.importing}
|
|
||||||
onCancel={params.onCancel}
|
|
||||||
onImport={params.onImport}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function KeyManagementReplaceWarning(params: {
|
|
||||||
show: boolean
|
|
||||||
importing: boolean
|
|
||||||
onCancel: () => void
|
|
||||||
onConfirm: () => void
|
|
||||||
}): React.ReactElement | null {
|
|
||||||
if (!params.show) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="bg-red-900/20 border border-red-400/50 rounded-lg p-4">
|
|
||||||
<p className="text-red-400 font-semibold mb-2">{t('settings.keyManagement.replace.warning.title')}</p>
|
|
||||||
<p className="text-red-300/90 text-sm mb-4">{t('settings.keyManagement.replace.warning.description')}</p>
|
|
||||||
<div className="flex gap-4">
|
|
||||||
<button
|
|
||||||
onClick={params.onCancel}
|
|
||||||
className="flex-1 py-2 px-4 bg-cyber-light border border-neon-cyan/30 hover:border-neon-cyan/50 hover:bg-cyber-dark text-cyber-accent hover:text-neon-cyan rounded-lg font-medium transition-colors"
|
|
||||||
>
|
|
||||||
{t('settings.keyManagement.replace.cancel')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={params.onConfirm}
|
|
||||||
disabled={params.importing}
|
|
||||||
className="flex-1 py-2 px-4 bg-red-600/20 hover:bg-red-600/30 text-red-400 rounded-lg font-medium transition-all border border-red-400/50 hover:shadow-glow-red disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{params.importing ? t('settings.keyManagement.replace.replacing') : t('settings.keyManagement.replace.confirm')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function KeyManagementImportFormActions(params: {
|
|
||||||
show: boolean
|
|
||||||
importing: boolean
|
|
||||||
onCancel: () => void
|
|
||||||
onImport: () => void
|
|
||||||
}): React.ReactElement | null {
|
|
||||||
if (!params.show) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="flex gap-4">
|
|
||||||
<button
|
|
||||||
onClick={params.onCancel}
|
|
||||||
className="flex-1 py-2 px-4 bg-cyber-light border border-neon-cyan/30 hover:border-neon-cyan/50 hover:bg-cyber-dark text-cyber-accent hover:text-neon-cyan rounded-lg font-medium transition-colors"
|
|
||||||
>
|
|
||||||
{t('settings.keyManagement.import.cancel')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={params.onImport}
|
|
||||||
disabled={params.importing}
|
|
||||||
className="flex-1 py-2 px-4 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"
|
|
||||||
>
|
|
||||||
{params.importing ? t('settings.keyManagement.import.importing') : t('settings.keyManagement.import.import')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function KeyManagementRecoveryPanel(params: {
|
|
||||||
recoveryPhrase: string[] | null
|
|
||||||
newNpub: string | null
|
|
||||||
copiedRecoveryPhrase: boolean
|
|
||||||
onCopyRecoveryPhrase: () => Promise<void>
|
|
||||||
onDone: () => void
|
|
||||||
}): React.ReactElement | null {
|
|
||||||
if (!params.recoveryPhrase || !params.newNpub) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="mt-6 space-y-4">
|
|
||||||
<div className="bg-yellow-900/20 border border-yellow-400/50 rounded-lg p-4">
|
|
||||||
<p className="text-yellow-400 font-semibold mb-2">{t('settings.keyManagement.recovery.warning.title')}</p>
|
|
||||||
<p className="text-yellow-300/90 text-sm" dangerouslySetInnerHTML={{ __html: t('settings.keyManagement.recovery.warning.part1') }} />
|
|
||||||
<p className="text-yellow-300/90 text-sm mt-2" dangerouslySetInnerHTML={{ __html: t('settings.keyManagement.recovery.warning.part2') }} />
|
|
||||||
<p className="text-yellow-300/90 text-sm mt-2">{t('settings.keyManagement.recovery.warning.part3')}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-cyber-darker border border-neon-cyan/30 rounded-lg p-6">
|
|
||||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
|
||||||
{params.recoveryPhrase.map((word, index) => (
|
|
||||||
<div
|
|
||||||
key={`recovery-word-${index}-${word}`}
|
|
||||||
className="bg-cyber-dark border border-neon-cyan/30 rounded-lg p-3 text-center font-mono text-lg"
|
|
||||||
>
|
|
||||||
<span className="text-cyber-accent/70 text-sm mr-2">{index + 1}.</span>
|
|
||||||
<span className="font-semibold text-neon-cyan">{word}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
void params.onCopyRecoveryPhrase()
|
|
||||||
}}
|
|
||||||
className="w-full py-2 px-4 bg-cyber-light border border-neon-cyan/30 hover:border-neon-cyan/50 hover:bg-cyber-dark text-cyber-accent hover:text-neon-cyan rounded-lg text-sm font-medium transition-colors"
|
|
||||||
>
|
|
||||||
{params.copiedRecoveryPhrase ? t('settings.keyManagement.recovery.copied') : t('settings.keyManagement.recovery.copy')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-neon-blue/10 border border-neon-blue/30 rounded-lg p-4">
|
|
||||||
<p className="text-neon-blue font-semibold mb-2">{t('settings.keyManagement.recovery.newNpub')}</p>
|
|
||||||
<p className="text-neon-cyan text-sm font-mono break-all">{params.newNpub}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={params.onDone}
|
|
||||||
className="w-full py-2 px-4 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"
|
|
||||||
>
|
|
||||||
{t('settings.keyManagement.recovery.done')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,379 +1 @@
|
|||||||
import { useState } from 'react'
|
export { MarkdownEditorTwoColumns } from './markdownEditorTwoColumns/MarkdownEditorTwoColumns'
|
||||||
import type { MediaRef, Page } from '@/types/nostr'
|
|
||||||
import { uploadNip95Media } from '@/lib/nip95'
|
|
||||||
import { t } from '@/lib/i18n'
|
|
||||||
|
|
||||||
interface MarkdownEditorTwoColumnsProps {
|
|
||||||
value: string
|
|
||||||
onChange: (value: string) => void
|
|
||||||
pages?: Page[]
|
|
||||||
onPagesChange?: (pages: Page[]) => void
|
|
||||||
onMediaAdd?: (media: MediaRef) => void
|
|
||||||
onBannerChange?: (url: string) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MarkdownEditorTwoColumns(props: MarkdownEditorTwoColumnsProps): React.ReactElement {
|
|
||||||
const [uploading, setUploading] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const pages = props.pages ?? []
|
|
||||||
const pagesHandlers = createPagesHandlers({ pages, onPagesChange: props.onPagesChange })
|
|
||||||
const handleImageUpload = createImageUploadHandler({
|
|
||||||
setError,
|
|
||||||
setUploading,
|
|
||||||
onMediaAdd: props.onMediaAdd,
|
|
||||||
onBannerChange: props.onBannerChange,
|
|
||||||
onSetPageImageUrl: pagesHandlers.setPageContent,
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<MarkdownToolbar
|
|
||||||
onFileSelected={(file) => {
|
|
||||||
void handleImageUpload({ file })
|
|
||||||
}}
|
|
||||||
uploading={uploading}
|
|
||||||
error={error}
|
|
||||||
{...(props.onPagesChange ? { onAddPage: pagesHandlers.addPage } : {})}
|
|
||||||
/>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<EditorColumn value={props.value} onChange={props.onChange} />
|
|
||||||
<PreviewColumn value={props.value} />
|
|
||||||
</div>
|
|
||||||
{props.onPagesChange && (
|
|
||||||
<PagesManager
|
|
||||||
pages={pages}
|
|
||||||
onPageContentChange={pagesHandlers.setPageContent}
|
|
||||||
onPageTypeChange={pagesHandlers.setPageType}
|
|
||||||
onRemovePage={pagesHandlers.removePage}
|
|
||||||
onImageUpload={async (file, pageNumber) => {
|
|
||||||
await handleImageUpload({ file, pageNumber })
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function MarkdownToolbar({
|
|
||||||
onFileSelected,
|
|
||||||
uploading,
|
|
||||||
error,
|
|
||||||
onAddPage,
|
|
||||||
}: {
|
|
||||||
onFileSelected: (file: File) => void
|
|
||||||
uploading: boolean
|
|
||||||
error: string | null
|
|
||||||
onAddPage?: (type: 'markdown' | 'image') => void
|
|
||||||
}): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
<ToolbarUploadButton onFileSelected={onFileSelected} />
|
|
||||||
<ToolbarAddPageButtons onAddPage={onAddPage} />
|
|
||||||
<ToolbarStatus uploading={uploading} error={error} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function MarkdownPreview({ value }: { value: string }): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<div className="prose max-w-none border rounded p-3 bg-white h-96 overflow-y-auto whitespace-pre-wrap">
|
|
||||||
{value || <span className="text-gray-400">{t('markdown.preview.empty')}</span>}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function PagesManager({
|
|
||||||
pages,
|
|
||||||
onPageContentChange,
|
|
||||||
onPageTypeChange,
|
|
||||||
onRemovePage,
|
|
||||||
onImageUpload,
|
|
||||||
}: {
|
|
||||||
pages: Page[]
|
|
||||||
onPageContentChange: (pageNumber: number, content: string) => void
|
|
||||||
onPageTypeChange: (pageNumber: number, type: 'markdown' | 'image') => void
|
|
||||||
onRemovePage: (pageNumber: number) => void
|
|
||||||
onImageUpload: (file: File, pageNumber: number) => Promise<void>
|
|
||||||
}): React.ReactElement {
|
|
||||||
if (pages.length === 0) {
|
|
||||||
return <div className="text-sm text-gray-500">{t('page.empty')}</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h3 className="text-lg font-semibold">{t('page.title')}</h3>
|
|
||||||
{pages.map((page) => (
|
|
||||||
<PageEditor
|
|
||||||
key={page.number}
|
|
||||||
page={page}
|
|
||||||
onContentChange={(content) => onPageContentChange(page.number, content)}
|
|
||||||
onTypeChange={(type) => onPageTypeChange(page.number, type)}
|
|
||||||
onRemove={() => onRemovePage(page.number)}
|
|
||||||
onImageUpload={async (file) => {
|
|
||||||
await onImageUpload(file, page.number)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function PageEditor({
|
|
||||||
page,
|
|
||||||
onContentChange,
|
|
||||||
onTypeChange,
|
|
||||||
onRemove,
|
|
||||||
onImageUpload,
|
|
||||||
}: {
|
|
||||||
page: Page
|
|
||||||
onContentChange: (content: string) => void
|
|
||||||
onTypeChange: (type: 'markdown' | 'image') => void
|
|
||||||
onRemove: () => void
|
|
||||||
onImageUpload: (file: File) => Promise<void>
|
|
||||||
}): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<div className="border rounded-lg p-4 space-y-3">
|
|
||||||
<PageEditorHeader page={page} onTypeChange={onTypeChange} onRemove={onRemove} />
|
|
||||||
<PageEditorBody page={page} onContentChange={onContentChange} onImageUpload={onImageUpload} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function EditorColumn(params: { value: string; onChange: (value: string) => void }): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="block text-sm font-semibold text-gray-800">{t('markdown.editor')}</label>
|
|
||||||
<textarea
|
|
||||||
className="w-full border rounded p-3 h-96 font-mono text-sm"
|
|
||||||
value={params.value}
|
|
||||||
onChange={(e) => params.onChange(e.target.value)}
|
|
||||||
placeholder={t('markdown.placeholder')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function PreviewColumn(params: { value: string }): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="block text-sm font-semibold text-gray-800">{t('markdown.preview')}</label>
|
|
||||||
<MarkdownPreview value={params.value} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ToolbarUploadButton(params: { onFileSelected: (file: File) => void }): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<label className="px-3 py-1 text-sm rounded bg-blue-600 text-white cursor-pointer hover:bg-blue-700">
|
|
||||||
{t('markdown.upload.media')}
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept=".png,.jpg,.jpeg,.webp"
|
|
||||||
className="hidden"
|
|
||||||
onChange={(e) => {
|
|
||||||
const file = e.target.files?.[0]
|
|
||||||
if (file) {
|
|
||||||
params.onFileSelected(file)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ToolbarAddPageButtons(params: { onAddPage: ((type: 'markdown' | 'image') => void) | undefined }): React.ReactElement | null {
|
|
||||||
if (!params.onAddPage) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="px-3 py-1 text-sm rounded bg-green-600 text-white hover:bg-green-700"
|
|
||||||
onClick={() => params.onAddPage?.('markdown')}
|
|
||||||
>
|
|
||||||
{t('page.add.markdown')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="px-3 py-1 text-sm rounded bg-purple-600 text-white hover:bg-purple-700"
|
|
||||||
onClick={() => params.onAddPage?.('image')}
|
|
||||||
>
|
|
||||||
{t('page.add.image')}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ToolbarStatus(params: { uploading: boolean; error: string | null }): React.ReactElement | null {
|
|
||||||
if (!params.uploading && !params.error) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{params.uploading ? <span className="text-sm text-gray-500">{t('markdown.upload.uploading')}</span> : null}
|
|
||||||
{params.error ? <span className="text-sm text-red-600">{params.error}</span> : null}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function PageEditorHeader(params: {
|
|
||||||
page: Page
|
|
||||||
onTypeChange: (type: 'markdown' | 'image') => void
|
|
||||||
onRemove: () => void
|
|
||||||
}): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h4 className="font-semibold">
|
|
||||||
{t('page.number', { number: params.page.number })} - {t(`page.type.${params.page.type}`)}
|
|
||||||
</h4>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<select
|
|
||||||
value={params.page.type}
|
|
||||||
onChange={(e) => params.onTypeChange(e.target.value as 'markdown' | 'image')}
|
|
||||||
className="text-sm border rounded px-2 py-1"
|
|
||||||
>
|
|
||||||
<option value="markdown">{t('page.type.markdown')}</option>
|
|
||||||
<option value="image">{t('page.type.image')}</option>
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="px-2 py-1 text-sm rounded bg-red-600 text-white hover:bg-red-700"
|
|
||||||
onClick={params.onRemove}
|
|
||||||
>
|
|
||||||
{t('page.remove')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function PageEditorBody(params: {
|
|
||||||
page: Page
|
|
||||||
onContentChange: (content: string) => void
|
|
||||||
onImageUpload: (file: File) => Promise<void>
|
|
||||||
}): React.ReactElement {
|
|
||||||
if (params.page.type === 'markdown') {
|
|
||||||
return (
|
|
||||||
<textarea
|
|
||||||
className="w-full border rounded p-2 h-48 font-mono text-sm"
|
|
||||||
value={params.page.content}
|
|
||||||
onChange={(e) => params.onContentChange(e.target.value)}
|
|
||||||
placeholder={t('page.markdown.placeholder')}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return <PageEditorImageBody page={params.page} onContentChange={params.onContentChange} onImageUpload={params.onImageUpload} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function PageEditorImageBody(params: {
|
|
||||||
page: Page
|
|
||||||
onContentChange: (content: string) => void
|
|
||||||
onImageUpload: (file: File) => Promise<void>
|
|
||||||
}): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{params.page.content ? (
|
|
||||||
<div className="relative">
|
|
||||||
<img src={params.page.content} alt={t('page.image.alt', { number: params.page.number })} className="max-w-full h-auto rounded" />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="absolute top-2 right-2 px-2 py-1 text-sm rounded bg-red-600 text-white hover:bg-red-700"
|
|
||||||
onClick={() => params.onContentChange('')}
|
|
||||||
>
|
|
||||||
{t('page.image.remove')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<PageImageUploadButton onFileSelected={params.onImageUpload} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function PageImageUploadButton(params: { onFileSelected: (file: File) => Promise<void> }): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<label className="block px-3 py-2 text-sm rounded bg-blue-600 text-white cursor-pointer hover:bg-blue-700 text-center">
|
|
||||||
{t('page.image.upload')}
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept=".png,.jpg,.jpeg,.webp"
|
|
||||||
className="hidden"
|
|
||||||
onChange={(e) => {
|
|
||||||
const file = e.target.files?.[0]
|
|
||||||
if (file) {
|
|
||||||
void params.onFileSelected(file)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function createPagesHandlers(params: {
|
|
||||||
pages: Page[]
|
|
||||||
onPagesChange: ((pages: Page[]) => void) | undefined
|
|
||||||
}): { addPage: (type: 'markdown' | 'image') => void; setPageContent: (pageNumber: number, content: string) => void; setPageType: (pageNumber: number, type: 'markdown' | 'image') => void; removePage: (pageNumber: number) => void } {
|
|
||||||
const update = (next: Page[]): void => {
|
|
||||||
params.onPagesChange?.(next)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
addPage: (type) => {
|
|
||||||
if (!params.onPagesChange) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const newPage: Page = { number: params.pages.length + 1, type, content: '' }
|
|
||||||
update([...params.pages, newPage])
|
|
||||||
},
|
|
||||||
setPageContent: (pageNumber, content) => {
|
|
||||||
if (!params.onPagesChange) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
update(params.pages.map((p) => (p.number === pageNumber ? { ...p, content } : p)))
|
|
||||||
},
|
|
||||||
setPageType: (pageNumber, type) => {
|
|
||||||
if (!params.onPagesChange) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
update(params.pages.map((p) => (p.number === pageNumber ? { ...p, type, content: '' } : p)))
|
|
||||||
},
|
|
||||||
removePage: (pageNumber) => {
|
|
||||||
if (!params.onPagesChange) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
update(params.pages.filter((p) => p.number !== pageNumber).map((p, idx) => ({ ...p, number: idx + 1 })))
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createImageUploadHandler(params: {
|
|
||||||
setError: (value: string | null) => void
|
|
||||||
setUploading: (value: boolean) => void
|
|
||||||
onMediaAdd: ((media: MediaRef) => void) | undefined
|
|
||||||
onBannerChange: ((url: string) => void) | undefined
|
|
||||||
onSetPageImageUrl: (pageNumber: number, url: string) => void
|
|
||||||
}): (args: { file: File; pageNumber?: number }) => Promise<void> {
|
|
||||||
return async (args): Promise<void> => {
|
|
||||||
params.setError(null)
|
|
||||||
params.setUploading(true)
|
|
||||||
try {
|
|
||||||
const media = await uploadNip95Media(args.file)
|
|
||||||
if (media.type !== 'image') {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (args.pageNumber !== undefined) {
|
|
||||||
params.onSetPageImageUrl(args.pageNumber, media.url)
|
|
||||||
} else {
|
|
||||||
params.onBannerChange?.(media.url)
|
|
||||||
}
|
|
||||||
params.onMediaAdd?.(media)
|
|
||||||
} catch (e) {
|
|
||||||
params.setError(e instanceof Error ? e.message : t('upload.error.failed'))
|
|
||||||
} finally {
|
|
||||||
params.setUploading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,367 +1 @@
|
|||||||
import { useState, useEffect } from 'react'
|
export { Nip95ConfigManager } from './nip95Config/Nip95ConfigManager'
|
||||||
import { configStorage } from '@/lib/configStorage'
|
|
||||||
import type { Nip95Config } from '@/lib/configStorageTypes'
|
|
||||||
import { t } from '@/lib/i18n'
|
|
||||||
import { userConfirm } from '@/lib/userConfirm'
|
|
||||||
import { DragHandle } from './DragHandle'
|
|
||||||
|
|
||||||
interface Nip95ConfigManagerProps {
|
|
||||||
onConfigChange?: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Nip95ConfigManager({ onConfigChange }: Nip95ConfigManagerProps): React.ReactElement {
|
|
||||||
const [apis, setApis] = useState<Nip95Config[]>([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const [editingId, setEditingId] = useState<string | null>(null)
|
|
||||||
const [newUrl, setNewUrl] = useState('')
|
|
||||||
const [showAddForm, setShowAddForm] = useState(false)
|
|
||||||
const [draggedId, setDraggedId] = useState<string | null>(null)
|
|
||||||
const [dragOverId, setDragOverId] = useState<string | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void loadApis()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
async function loadApis(): Promise<void> {
|
|
||||||
try {
|
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
const config = await configStorage.getConfig()
|
|
||||||
setApis(config.nip95Apis.sort((a, b) => a.priority - b.priority))
|
|
||||||
} catch (e) {
|
|
||||||
const errorMessage = e instanceof Error ? e.message : t('settings.nip95.error.loadFailed')
|
|
||||||
setError(errorMessage)
|
|
||||||
console.error('Error loading NIP-95 APIs:', e)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleToggleEnabled(id: string, enabled: boolean): Promise<void> {
|
|
||||||
try {
|
|
||||||
await configStorage.updateNip95Api(id, { enabled })
|
|
||||||
await loadApis()
|
|
||||||
onConfigChange?.()
|
|
||||||
} catch (e) {
|
|
||||||
const errorMessage = e instanceof Error ? e.message : t('settings.nip95.error.updateFailed')
|
|
||||||
setError(errorMessage)
|
|
||||||
console.error('Error updating NIP-95 API:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleUpdatePriorities(newOrder: Nip95Config[]): Promise<void> {
|
|
||||||
try {
|
|
||||||
// Update priorities based on new order (priority = index + 1, lower number = higher priority)
|
|
||||||
const updatePromises = newOrder.map((api, index) => {
|
|
||||||
const newPriority = index + 1
|
|
||||||
if (api.priority !== newPriority) {
|
|
||||||
return configStorage.updateNip95Api(api.id, { priority: newPriority })
|
|
||||||
}
|
|
||||||
return Promise.resolve()
|
|
||||||
})
|
|
||||||
|
|
||||||
await Promise.all(updatePromises)
|
|
||||||
await loadApis()
|
|
||||||
onConfigChange?.()
|
|
||||||
} catch (e) {
|
|
||||||
const errorMessage = e instanceof Error ? e.message : t('settings.nip95.error.priorityFailed')
|
|
||||||
setError(errorMessage)
|
|
||||||
console.error('Error updating priorities:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDragStart(e: React.DragEvent<HTMLDivElement>, id: string): void {
|
|
||||||
setDraggedId(id)
|
|
||||||
const { dataTransfer } = e
|
|
||||||
dataTransfer.effectAllowed = 'move'
|
|
||||||
dataTransfer.setData('text/plain', id)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDragOver(e: React.DragEvent<HTMLDivElement>, id: string): void {
|
|
||||||
e.preventDefault()
|
|
||||||
const { dataTransfer } = e
|
|
||||||
dataTransfer.dropEffect = 'move'
|
|
||||||
setDragOverId(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDragLeave(): void {
|
|
||||||
setDragOverId(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDrop(e: React.DragEvent<HTMLDivElement>, targetId: string): void {
|
|
||||||
e.preventDefault()
|
|
||||||
setDragOverId(null)
|
|
||||||
|
|
||||||
if (!draggedId || draggedId === targetId) {
|
|
||||||
setDraggedId(null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const draggedIndex = apis.findIndex((api) => api.id === draggedId)
|
|
||||||
const targetIndex = apis.findIndex((api) => api.id === targetId)
|
|
||||||
|
|
||||||
if (draggedIndex === -1 || targetIndex === -1) {
|
|
||||||
setDraggedId(null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reorder the array
|
|
||||||
const newApis = [...apis]
|
|
||||||
const removed = newApis[draggedIndex]
|
|
||||||
if (!removed) {
|
|
||||||
setDraggedId(null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
newApis.splice(draggedIndex, 1)
|
|
||||||
newApis.splice(targetIndex, 0, removed)
|
|
||||||
|
|
||||||
setApis(newApis)
|
|
||||||
setDraggedId(null)
|
|
||||||
|
|
||||||
// Update priorities based on new order
|
|
||||||
void handleUpdatePriorities(newApis)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleUpdateUrl(id: string, url: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
await configStorage.updateNip95Api(id, { url })
|
|
||||||
await loadApis()
|
|
||||||
setEditingId(null)
|
|
||||||
onConfigChange?.()
|
|
||||||
} catch (e) {
|
|
||||||
const errorMessage = e instanceof Error ? e.message : t('settings.nip95.error.urlFailed')
|
|
||||||
setError(errorMessage)
|
|
||||||
console.error('Error updating URL:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleAddApi(): Promise<void> {
|
|
||||||
if (!newUrl.trim()) {
|
|
||||||
setError(t('settings.nip95.error.urlRequired'))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Validate URL format - throws if invalid
|
|
||||||
void new URL(newUrl)
|
|
||||||
await configStorage.addNip95Api(newUrl.trim(), false)
|
|
||||||
setNewUrl('')
|
|
||||||
setShowAddForm(false)
|
|
||||||
await loadApis()
|
|
||||||
onConfigChange?.()
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof TypeError && e.message.includes('Invalid URL')) {
|
|
||||||
setError(t('settings.nip95.error.invalidUrl'))
|
|
||||||
} else {
|
|
||||||
const errorMessage = e instanceof Error ? e.message : t('settings.nip95.error.addFailed')
|
|
||||||
setError(errorMessage)
|
|
||||||
}
|
|
||||||
console.error('Error adding NIP-95 API:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleRemoveApi(id: string): Promise<void> {
|
|
||||||
const confirmed = await userConfirm(t('settings.nip95.remove.confirm'))
|
|
||||||
if (!confirmed) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await configStorage.removeNip95Api(id)
|
|
||||||
await loadApis()
|
|
||||||
onConfigChange?.()
|
|
||||||
} catch (e) {
|
|
||||||
const errorMessage = e instanceof Error ? e.message : t('settings.nip95.error.removeFailed')
|
|
||||||
setError(errorMessage)
|
|
||||||
console.error('Error removing NIP-95 API:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="text-center py-8 text-neon-cyan">
|
|
||||||
<div>{t('settings.nip95.loading')}</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{error && (
|
|
||||||
<div className="bg-red-900/30 border border-red-500/50 rounded p-4 text-red-300">
|
|
||||||
{error}
|
|
||||||
<button
|
|
||||||
onClick={() => setError(null)}
|
|
||||||
className="ml-4 text-red-400 hover:text-red-200"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<h2 className="text-2xl font-bold text-neon-cyan">{t('settings.nip95.title')}</h2>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowAddForm(!showAddForm)}
|
|
||||||
className="px-4 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan border border-neon-cyan/50 rounded transition-colors"
|
|
||||||
>
|
|
||||||
{showAddForm ? t('settings.nip95.add.cancel') : t('settings.nip95.addButton')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showAddForm && (
|
|
||||||
<div className="bg-cyber-dark border border-neon-cyan/30 rounded p-4 space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-cyber-accent mb-2">
|
|
||||||
{t('settings.nip95.add.url')}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
value={newUrl}
|
|
||||||
onChange={(e) => setNewUrl(e.target.value)}
|
|
||||||
placeholder={t('settings.nip95.add.placeholder')}
|
|
||||||
className="w-full px-4 py-2 bg-cyber-darker border border-cyber-accent/30 rounded text-cyber-light focus:border-neon-cyan focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => void handleAddApi()}
|
|
||||||
className="px-4 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan border border-neon-cyan/50 rounded transition-colors"
|
|
||||||
>
|
|
||||||
{t('settings.nip95.add.add')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setShowAddForm(false)
|
|
||||||
setNewUrl('')
|
|
||||||
setError(null)
|
|
||||||
}}
|
|
||||||
className="px-4 py-2 bg-cyber-accent/20 hover:bg-cyber-accent/30 text-cyber-accent border border-cyber-accent/50 rounded transition-colors"
|
|
||||||
>
|
|
||||||
{t('settings.nip95.add.cancel')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
{apis.length === 0 ? (
|
|
||||||
<div className="text-center py-8 text-cyber-accent">
|
|
||||||
{t('settings.nip95.empty')}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
apis.map((api, index) => (
|
|
||||||
<div
|
|
||||||
key={api.id}
|
|
||||||
onDragOver={(e) => {
|
|
||||||
handleDragOver(e, api.id)
|
|
||||||
}}
|
|
||||||
onDragLeave={handleDragLeave}
|
|
||||||
onDrop={(e) => {
|
|
||||||
handleDrop(e, api.id)
|
|
||||||
}}
|
|
||||||
className={`bg-cyber-dark border rounded p-4 space-y-3 transition-all ${getApiCardClassName(api.id, draggedId, dragOverId)}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between gap-4">
|
|
||||||
<div className="flex items-center gap-3 flex-1">
|
|
||||||
<div
|
|
||||||
className="drag-handle cursor-grab active:cursor-grabbing"
|
|
||||||
draggable
|
|
||||||
onDragStart={(e) => {
|
|
||||||
handleDragStart(e, api.id)
|
|
||||||
e.stopPropagation()
|
|
||||||
}}
|
|
||||||
onMouseDown={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DragHandle />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
{editingId === api.id ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
defaultValue={api.url}
|
|
||||||
onBlur={(e) => {
|
|
||||||
if (e.target.value !== api.url) {
|
|
||||||
void handleUpdateUrl(api.id, e.target.value)
|
|
||||||
} else {
|
|
||||||
setEditingId(null)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.currentTarget.blur()
|
|
||||||
} else if (e.key === 'Escape') {
|
|
||||||
setEditingId(null)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="w-full px-3 py-2 bg-cyber-darker border border-neon-cyan/50 rounded text-cyber-light focus:border-neon-cyan focus:outline-none"
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className="text-neon-cyan cursor-pointer hover:text-neon-green transition-colors"
|
|
||||||
onClick={() => setEditingId(api.id)}
|
|
||||||
title={t('settings.nip95.list.editUrl')}
|
|
||||||
>
|
|
||||||
{api.url}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={api.enabled}
|
|
||||||
onChange={(e) => void handleToggleEnabled(api.id, e.target.checked)}
|
|
||||||
className="w-4 h-4 text-neon-cyan bg-cyber-darker border-cyber-accent rounded focus:ring-neon-cyan"
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-cyber-accent">
|
|
||||||
{api.enabled ? t('settings.nip95.list.enabled') : t('settings.nip95.list.disabled')}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<button
|
|
||||||
onClick={() => void handleRemoveApi(api.id)}
|
|
||||||
className="px-3 py-1 text-sm bg-red-900/30 hover:bg-red-900/50 text-red-300 border border-red-500/50 rounded transition-colors"
|
|
||||||
title={t('settings.nip95.list.remove')}
|
|
||||||
>
|
|
||||||
{t('settings.nip95.list.remove')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-xs text-cyber-accent/70">
|
|
||||||
<span>
|
|
||||||
{t('settings.nip95.list.priorityLabel', { priority: index + 1, id: api.id })}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-sm text-cyber-accent space-y-2">
|
|
||||||
<p>
|
|
||||||
<strong>{t('settings.nip95.note.title')}</strong> {t('settings.nip95.note.priority')}
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
{t('settings.nip95.note.fallback')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function getApiCardClassName(apiId: string, draggedId: string | null, dragOverId: string | null): string {
|
|
||||||
if (draggedId === apiId) {
|
|
||||||
return 'opacity-50 border-neon-cyan'
|
|
||||||
}
|
|
||||||
if (dragOverId === apiId) {
|
|
||||||
return 'border-neon-green shadow-lg'
|
|
||||||
}
|
|
||||||
return 'border-neon-cyan/30'
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,412 +1 @@
|
|||||||
import { useState, useEffect } from 'react'
|
export { RelayManager } from './relayManager/RelayManager'
|
||||||
import { configStorage } from '@/lib/configStorage'
|
|
||||||
import type { RelayConfig } from '@/lib/configStorageTypes'
|
|
||||||
import { t } from '@/lib/i18n'
|
|
||||||
import { userConfirm } from '@/lib/userConfirm'
|
|
||||||
import { relaySessionManager } from '@/lib/relaySessionManager'
|
|
||||||
import { DragHandle } from './DragHandle'
|
|
||||||
|
|
||||||
interface RelayManagerProps {
|
|
||||||
onConfigChange?: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RelayManager({ onConfigChange }: RelayManagerProps): React.ReactElement {
|
|
||||||
const [relays, setRelays] = useState<RelayConfig[]>([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const [editingId, setEditingId] = useState<string | null>(null)
|
|
||||||
const [newUrl, setNewUrl] = useState('')
|
|
||||||
const [showAddForm, setShowAddForm] = useState(false)
|
|
||||||
const [draggedId, setDraggedId] = useState<string | null>(null)
|
|
||||||
const [dragOverId, setDragOverId] = useState<string | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void loadRelays()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
async function loadRelays(): Promise<void> {
|
|
||||||
try {
|
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
const config = await configStorage.getConfig()
|
|
||||||
|
|
||||||
// Get failed relays from session manager and disable them in config
|
|
||||||
const failedRelays = relaySessionManager.getFailedRelays()
|
|
||||||
if (failedRelays.length > 0) {
|
|
||||||
let hasChanges = false
|
|
||||||
for (const relayUrl of failedRelays) {
|
|
||||||
// Find the relay config by URL
|
|
||||||
const relayConfig = config.relays.find((r) => r.url === relayUrl)
|
|
||||||
if (relayConfig?.enabled) {
|
|
||||||
// Disable the failed relay
|
|
||||||
await configStorage.updateRelay(relayConfig.id, { enabled: false })
|
|
||||||
hasChanges = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Reload config if we made changes
|
|
||||||
if (hasChanges) {
|
|
||||||
const updatedConfig = await configStorage.getConfig()
|
|
||||||
setRelays(updatedConfig.relays.sort((a, b) => a.priority - b.priority))
|
|
||||||
} else {
|
|
||||||
setRelays(config.relays.sort((a, b) => a.priority - b.priority))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setRelays(config.relays.sort((a, b) => a.priority - b.priority))
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
const errorMessage = e instanceof Error ? e.message : t('settings.relay.error.loadFailed')
|
|
||||||
setError(errorMessage)
|
|
||||||
console.error('Error loading relays:', e)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleToggleEnabled(id: string, enabled: boolean): Promise<void> {
|
|
||||||
try {
|
|
||||||
await configStorage.updateRelay(id, { enabled })
|
|
||||||
await loadRelays()
|
|
||||||
onConfigChange?.()
|
|
||||||
} catch (e) {
|
|
||||||
const errorMessage = e instanceof Error ? e.message : t('settings.relay.error.updateFailed')
|
|
||||||
setError(errorMessage)
|
|
||||||
console.error('Error updating relay:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleUpdatePriorities(newOrder: RelayConfig[]): Promise<void> {
|
|
||||||
try {
|
|
||||||
const updatePromises = newOrder.map((relay, index) => {
|
|
||||||
const newPriority = index + 1
|
|
||||||
if (relay.priority !== newPriority) {
|
|
||||||
return configStorage.updateRelay(relay.id, { priority: newPriority })
|
|
||||||
}
|
|
||||||
return Promise.resolve()
|
|
||||||
})
|
|
||||||
|
|
||||||
await Promise.all(updatePromises)
|
|
||||||
await loadRelays()
|
|
||||||
onConfigChange?.()
|
|
||||||
} catch (e) {
|
|
||||||
const errorMessage = e instanceof Error ? e.message : t('settings.relay.error.priorityFailed')
|
|
||||||
setError(errorMessage)
|
|
||||||
console.error('Error updating priorities:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDragStart(e: React.DragEvent<HTMLDivElement>, id: string): void {
|
|
||||||
setDraggedId(id)
|
|
||||||
const { dataTransfer } = e
|
|
||||||
dataTransfer.effectAllowed = 'move'
|
|
||||||
dataTransfer.setData('text/plain', id)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDragOver(e: React.DragEvent<HTMLDivElement>, id: string): void {
|
|
||||||
e.preventDefault()
|
|
||||||
const { dataTransfer } = e
|
|
||||||
dataTransfer.dropEffect = 'move'
|
|
||||||
setDragOverId(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDragLeave(): void {
|
|
||||||
setDragOverId(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDrop(e: React.DragEvent<HTMLDivElement>, targetId: string): void {
|
|
||||||
e.preventDefault()
|
|
||||||
setDragOverId(null)
|
|
||||||
|
|
||||||
if (!draggedId || draggedId === targetId) {
|
|
||||||
setDraggedId(null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const draggedIndex = relays.findIndex((relay) => relay.id === draggedId)
|
|
||||||
const targetIndex = relays.findIndex((relay) => relay.id === targetId)
|
|
||||||
|
|
||||||
if (draggedIndex === -1 || targetIndex === -1) {
|
|
||||||
setDraggedId(null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const newRelays = [...relays]
|
|
||||||
const removed = newRelays[draggedIndex]
|
|
||||||
if (!removed) {
|
|
||||||
setDraggedId(null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
newRelays.splice(draggedIndex, 1)
|
|
||||||
newRelays.splice(targetIndex, 0, removed)
|
|
||||||
|
|
||||||
setRelays(newRelays)
|
|
||||||
setDraggedId(null)
|
|
||||||
|
|
||||||
void handleUpdatePriorities(newRelays)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDragEnd(): void {
|
|
||||||
setDraggedId(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleUpdateUrl(id: string, url: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
await configStorage.updateRelay(id, { url })
|
|
||||||
await loadRelays()
|
|
||||||
setEditingId(null)
|
|
||||||
onConfigChange?.()
|
|
||||||
} catch (e) {
|
|
||||||
const errorMessage = e instanceof Error ? e.message : t('settings.relay.error.urlFailed')
|
|
||||||
setError(errorMessage)
|
|
||||||
console.error('Error updating URL:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleAddRelay(): Promise<void> {
|
|
||||||
if (!newUrl.trim()) {
|
|
||||||
setError(t('settings.relay.error.urlRequired'))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Normalize URL (add wss:// if missing)
|
|
||||||
let normalizedUrl = newUrl.trim()
|
|
||||||
if (!normalizedUrl.startsWith('ws://') && !normalizedUrl.startsWith('wss://')) {
|
|
||||||
normalizedUrl = `wss://${normalizedUrl}`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate URL format - throws if invalid
|
|
||||||
void new URL(normalizedUrl)
|
|
||||||
await configStorage.addRelay(normalizedUrl, true)
|
|
||||||
setNewUrl('')
|
|
||||||
setShowAddForm(false)
|
|
||||||
await loadRelays()
|
|
||||||
onConfigChange?.()
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof TypeError && e.message.includes('Invalid URL')) {
|
|
||||||
setError(t('settings.relay.error.invalidUrl'))
|
|
||||||
} else {
|
|
||||||
const errorMessage = e instanceof Error ? e.message : t('settings.relay.error.addFailed')
|
|
||||||
setError(errorMessage)
|
|
||||||
}
|
|
||||||
console.error('Error adding relay:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleRemoveRelay(id: string): Promise<void> {
|
|
||||||
const confirmed = await userConfirm(t('settings.relay.remove.confirm'))
|
|
||||||
if (!confirmed) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await configStorage.removeRelay(id)
|
|
||||||
await loadRelays()
|
|
||||||
onConfigChange?.()
|
|
||||||
} catch (e) {
|
|
||||||
const errorMessage = e instanceof Error ? e.message : t('settings.relay.error.removeFailed')
|
|
||||||
setError(errorMessage)
|
|
||||||
console.error('Error removing relay:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="text-center py-8 text-neon-cyan">
|
|
||||||
<div>{t('settings.relay.loading')}</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{error && (
|
|
||||||
<div className="bg-red-900/30 border border-red-500/50 rounded p-4 text-red-300">
|
|
||||||
{error}
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setError(null)
|
|
||||||
}}
|
|
||||||
className="ml-4 text-red-400 hover:text-red-200"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<h2 className="text-2xl font-bold text-neon-cyan">{t('settings.relay.title')}</h2>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setShowAddForm(!showAddForm)
|
|
||||||
}}
|
|
||||||
className="px-4 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan border border-neon-cyan/50 rounded transition-colors"
|
|
||||||
>
|
|
||||||
{showAddForm ? t('settings.relay.add.cancel') : t('settings.relay.addButton')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showAddForm && (
|
|
||||||
<div className="bg-cyber-dark border border-neon-cyan/30 rounded p-4 space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-cyber-accent mb-2">
|
|
||||||
{t('settings.relay.add.url')}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newUrl}
|
|
||||||
onChange={(e) => {
|
|
||||||
setNewUrl(e.target.value)
|
|
||||||
}}
|
|
||||||
placeholder={t('settings.relay.add.placeholder')}
|
|
||||||
className="w-full px-4 py-2 bg-cyber-darker border border-cyber-accent/30 rounded text-cyber-light focus:border-neon-cyan focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
void handleAddRelay()
|
|
||||||
}}
|
|
||||||
className="px-4 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan border border-neon-cyan/50 rounded transition-colors"
|
|
||||||
>
|
|
||||||
{t('settings.relay.add.add')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setShowAddForm(false)
|
|
||||||
setNewUrl('')
|
|
||||||
setError(null)
|
|
||||||
}}
|
|
||||||
className="px-4 py-2 bg-cyber-accent/20 hover:bg-cyber-accent/30 text-cyber-accent border border-cyber-accent/50 rounded transition-colors"
|
|
||||||
>
|
|
||||||
{t('settings.relay.add.cancel')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
{relays.length === 0 ? (
|
|
||||||
<div className="text-center py-8 text-cyber-accent">
|
|
||||||
{t('settings.relay.empty')}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
relays.map((relay) => (
|
|
||||||
<div
|
|
||||||
key={relay.id}
|
|
||||||
onDragOver={(e) => {
|
|
||||||
handleDragOver(e, relay.id)
|
|
||||||
}}
|
|
||||||
onDragLeave={handleDragLeave}
|
|
||||||
onDrop={(e) => {
|
|
||||||
handleDrop(e, relay.id)
|
|
||||||
}}
|
|
||||||
className={`bg-cyber-dark border rounded p-4 space-y-3 transition-all ${
|
|
||||||
(() => {
|
|
||||||
if (draggedId === relay.id) {
|
|
||||||
return 'opacity-50 border-neon-cyan'
|
|
||||||
}
|
|
||||||
if (dragOverId === relay.id) {
|
|
||||||
return 'border-neon-green shadow-lg'
|
|
||||||
}
|
|
||||||
return 'border-neon-cyan/30'
|
|
||||||
})()
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between gap-4">
|
|
||||||
<div className="flex items-center gap-3 flex-1">
|
|
||||||
<div
|
|
||||||
className="drag-handle cursor-grab active:cursor-grabbing"
|
|
||||||
draggable
|
|
||||||
onDragStart={(e) => {
|
|
||||||
handleDragStart(e, relay.id)
|
|
||||||
}}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
onMouseDown={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DragHandle />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
{editingId === relay.id ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
defaultValue={relay.url}
|
|
||||||
onBlur={(e) => {
|
|
||||||
if (e.target.value !== relay.url) {
|
|
||||||
void handleUpdateUrl(relay.id, e.target.value)
|
|
||||||
} else {
|
|
||||||
setEditingId(null)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.currentTarget.blur()
|
|
||||||
} else if (e.key === 'Escape') {
|
|
||||||
setEditingId(null)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="w-full px-3 py-2 bg-cyber-darker border border-neon-cyan/50 rounded text-cyber-light focus:border-neon-cyan focus:outline-none"
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className="text-neon-cyan cursor-pointer hover:text-neon-green transition-colors"
|
|
||||||
onClick={() => {
|
|
||||||
setEditingId(relay.id)
|
|
||||||
}}
|
|
||||||
title={t('settings.relay.list.editUrl')}
|
|
||||||
>
|
|
||||||
{relay.url}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{relay.lastSyncDate && (
|
|
||||||
<div className="text-xs text-cyber-accent/70 mt-1">
|
|
||||||
{t('settings.relay.list.lastSync')}: {new Date(relay.lastSyncDate).toLocaleString()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={relay.enabled}
|
|
||||||
onChange={(e) => {
|
|
||||||
void handleToggleEnabled(relay.id, e.target.checked)
|
|
||||||
}}
|
|
||||||
className="w-4 h-4 text-neon-cyan bg-cyber-darker border-cyber-accent rounded focus:ring-neon-cyan"
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-cyber-accent">
|
|
||||||
{relay.enabled ? t('settings.relay.list.enabled') : t('settings.relay.list.disabled')}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
void handleRemoveRelay(relay.id)
|
|
||||||
}}
|
|
||||||
className="px-3 py-1 text-sm bg-red-900/30 hover:bg-red-900/50 text-red-300 border border-red-500/50 rounded transition-colors"
|
|
||||||
title={t('settings.relay.list.remove')}
|
|
||||||
>
|
|
||||||
{t('settings.relay.list.remove')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-sm text-cyber-accent space-y-2">
|
|
||||||
<p>
|
|
||||||
<strong>{t('settings.relay.note.title')}</strong> {t('settings.relay.note.priority')}
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
{t('settings.relay.note.rotation')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@ -13,99 +13,132 @@ interface SponsoringFormProps {
|
|||||||
|
|
||||||
export function SponsoringForm({ author, onSuccess, onCancel }: SponsoringFormProps): React.ReactElement {
|
export function SponsoringForm({ author, onSuccess, onCancel }: SponsoringFormProps): React.ReactElement {
|
||||||
const { pubkey, connect } = useNostrAuth()
|
const { pubkey, connect } = useNostrAuth()
|
||||||
const [text, setText] = useState('')
|
const state = useSponsoringFormState()
|
||||||
const [loading, setLoading] = useState(false)
|
const onSubmit = (e: React.FormEvent): void => {
|
||||||
const [error, setError] = useState<string | null>(null)
|
e.preventDefault()
|
||||||
const [instructions, setInstructions] = useState<{
|
void handleSubmit({ pubkey, author, setInstructions: state.setInstructions, setError: state.setError, setLoading: state.setLoading })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pubkey) {
|
||||||
|
return <SponsoringConnectRequired onConnect={() => { void connect() }} />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!author.mainnetAddress) {
|
||||||
|
return <SponsoringNoAddress />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.instructions) {
|
||||||
|
return (
|
||||||
|
<SponsoringInstructions
|
||||||
|
instructions={state.instructions}
|
||||||
|
onClose={() => {
|
||||||
|
state.setInstructions(null)
|
||||||
|
onSuccess?.()
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
state.setInstructions(null)
|
||||||
|
onCancel?.()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SponsoringFormView
|
||||||
|
text={state.text}
|
||||||
|
setText={state.setText}
|
||||||
|
loading={state.loading}
|
||||||
|
error={state.error}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
{...(onCancel ? { onCancel } : {})}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(params: {
|
||||||
|
pubkey: string | null
|
||||||
|
author: AuthorPresentationArticle
|
||||||
|
setInstructions: (value: SponsoringInstructionsState) => void
|
||||||
|
setError: (value: string | null) => void
|
||||||
|
setLoading: (value: boolean) => void
|
||||||
|
}): Promise<void> {
|
||||||
|
if (!params.pubkey || !params.author.mainnetAddress) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await submitSponsoring({
|
||||||
|
pubkey: params.pubkey,
|
||||||
|
author: params.author,
|
||||||
|
setInstructions: params.setInstructions,
|
||||||
|
setError: params.setError,
|
||||||
|
setLoading: params.setLoading,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type SponsoringInstructionsState = {
|
||||||
authorAddress: string
|
authorAddress: string
|
||||||
platformAddress: string
|
platformAddress: string
|
||||||
authorBtc: string
|
authorBtc: string
|
||||||
platformBtc: string
|
platformBtc: string
|
||||||
} | null>(null)
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent): Promise<void> => {
|
|
||||||
e.preventDefault()
|
|
||||||
if (!pubkey) {
|
|
||||||
await connect()
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!author.mainnetAddress) {
|
function useSponsoringFormState(): {
|
||||||
setError(t('sponsoring.form.error.noAddress'))
|
text: string
|
||||||
return
|
setText: (value: string) => void
|
||||||
|
loading: boolean
|
||||||
|
setLoading: (value: boolean) => void
|
||||||
|
error: string | null
|
||||||
|
setError: (value: string | null) => void
|
||||||
|
instructions: SponsoringInstructionsState | null
|
||||||
|
setInstructions: (value: SponsoringInstructionsState | null) => void
|
||||||
|
} {
|
||||||
|
const [text, setText] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [instructions, setInstructions] = useState<SponsoringInstructionsState | null>(null)
|
||||||
|
return { text, setText, loading, setLoading, error, setError, instructions, setInstructions }
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true)
|
async function submitSponsoring(params: {
|
||||||
setError(null)
|
pubkey: string
|
||||||
|
author: AuthorPresentationArticle
|
||||||
|
setInstructions: (value: SponsoringInstructionsState) => void
|
||||||
|
setError: (value: string | null) => void
|
||||||
|
setLoading: (value: boolean) => void
|
||||||
|
}): Promise<void> {
|
||||||
|
params.setLoading(true)
|
||||||
|
params.setError(null)
|
||||||
try {
|
try {
|
||||||
const privateKey = nostrService.getPrivateKey()
|
const privateKey = nostrService.getPrivateKey()
|
||||||
if (!privateKey) {
|
if (!privateKey) {
|
||||||
setError(t('sponsoring.form.error.noPrivateKey'))
|
params.setError(t('sponsoring.form.error.noPrivateKey'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const result = await sponsoringPaymentService.createSponsoringPayment({ authorPubkey: params.author.pubkey, authorMainnetAddress: params.author.mainnetAddress ?? '', amount: 0.046 })
|
||||||
// Create sponsoring payment request
|
|
||||||
const result = await sponsoringPaymentService.createSponsoringPayment({
|
|
||||||
authorPubkey: author.pubkey,
|
|
||||||
authorMainnetAddress: author.mainnetAddress,
|
|
||||||
amount: 0.046, // Fixed amount for sponsoring
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
setError(result.error ?? t('sponsoring.form.error.paymentFailed'))
|
params.setError(result.error ?? t('sponsoring.form.error.paymentFailed'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
console.warn('Sponsoring payment info:', { authorAddress: result.authorAddress, platformAddress: result.platformAddress, authorAmount: result.split.authorSats, platformAmount: result.split.platformSats, totalAmount: result.split.totalSats })
|
||||||
// Note: Sponsoring is done via Bitcoin mainnet, not Lightning zap
|
params.setInstructions({ authorAddress: result.authorAddress, platformAddress: result.platformAddress, authorBtc: (result.split.authorSats / 100_000_000).toFixed(8), platformBtc: (result.split.platformSats / 100_000_000).toFixed(8) })
|
||||||
// The user needs to create a Bitcoin transaction with two outputs:
|
|
||||||
// 1. Author address: result.split.authorSats
|
|
||||||
// 2. Platform address: result.split.platformSats
|
|
||||||
// After the transaction is confirmed, we can create a zap receipt for tracking
|
|
||||||
|
|
||||||
// Store payment info for later verification
|
|
||||||
// The user will need to provide the transaction ID after payment
|
|
||||||
console.warn('Sponsoring payment info:', {
|
|
||||||
authorAddress: result.authorAddress,
|
|
||||||
platformAddress: result.platformAddress,
|
|
||||||
authorAmount: result.split.authorSats,
|
|
||||||
platformAmount: result.split.platformSats,
|
|
||||||
totalAmount: result.split.totalSats,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Show instructions inline (no-alert)
|
|
||||||
setInstructions({
|
|
||||||
authorAddress: result.authorAddress,
|
|
||||||
platformAddress: result.platformAddress,
|
|
||||||
authorBtc: (result.split.authorSats / 100_000_000).toFixed(8),
|
|
||||||
platformBtc: (result.split.platformSats / 100_000_000).toFixed(8),
|
|
||||||
})
|
|
||||||
|
|
||||||
setText('')
|
|
||||||
} catch (submitError) {
|
} catch (submitError) {
|
||||||
setError(submitError instanceof Error ? submitError.message : t('sponsoring.form.error.paymentFailed'))
|
params.setError(submitError instanceof Error ? submitError.message : t('sponsoring.form.error.paymentFailed'))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
params.setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!pubkey) {
|
function SponsoringConnectRequired(params: { onConnect: () => void }): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<div className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark">
|
<div className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark">
|
||||||
<p className="text-cyber-accent mb-4">{t('sponsoring.form.connectRequired')}</p>
|
<p className="text-cyber-accent mb-4">{t('sponsoring.form.connectRequired')}</p>
|
||||||
<button
|
<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">
|
||||||
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')}
|
{t('connect.connect')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!author.mainnetAddress) {
|
function SponsoringNoAddress(): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<div className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark">
|
<div className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark">
|
||||||
<p className="text-cyber-accent">{t('sponsoring.form.error.noAddress')}</p>
|
<p className="text-cyber-accent">{t('sponsoring.form.error.noAddress')}</p>
|
||||||
@ -113,37 +146,16 @@ export function SponsoringForm({ author, onSuccess, onCancel }: SponsoringFormPr
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (instructions) {
|
function SponsoringInstructions(params: { instructions: SponsoringInstructionsState; onClose: () => void; onCancel: () => void }): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<div className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark space-y-4" role="dialog" aria-modal="true">
|
<div className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark space-y-4" role="dialog" aria-modal="true">
|
||||||
<h3 className="text-lg font-semibold text-neon-cyan">{t('sponsoring.form.title')}</h3>
|
<h3 className="text-lg font-semibold text-neon-cyan">{t('sponsoring.form.title')}</h3>
|
||||||
<p className="text-sm text-cyber-accent/70">
|
<p className="text-sm text-cyber-accent/70">{t('sponsoring.form.instructions', { authorAddress: params.instructions.authorAddress, platformAddress: params.instructions.platformAddress, authorAmount: params.instructions.authorBtc, platformAmount: params.instructions.platformBtc })}</p>
|
||||||
{t('sponsoring.form.instructions', {
|
|
||||||
authorAddress: instructions.authorAddress,
|
|
||||||
platformAddress: instructions.platformAddress,
|
|
||||||
authorAmount: instructions.authorBtc,
|
|
||||||
platformAmount: instructions.platformBtc,
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<button
|
<button type="button" onClick={params.onClose} 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">
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setInstructions(null)
|
|
||||||
onSuccess?.()
|
|
||||||
}}
|
|
||||||
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('common.close')}
|
{t('common.close')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button type="button" onClick={params.onCancel} className="px-4 py-2 bg-neon-cyan/10 hover:bg-neon-cyan/20 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/30">
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setInstructions(null)
|
|
||||||
onCancel?.()
|
|
||||||
}}
|
|
||||||
className="px-4 py-2 bg-neon-cyan/10 hover:bg-neon-cyan/20 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/30"
|
|
||||||
>
|
|
||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -151,52 +163,39 @@ export function SponsoringForm({ author, onSuccess, onCancel }: SponsoringFormPr
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SponsoringFormView(params: {
|
||||||
|
text: string
|
||||||
|
setText: (value: string) => void
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
onSubmit: (e: React.FormEvent) => void
|
||||||
|
onCancel?: () => void
|
||||||
|
}): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<form onSubmit={(e) => {
|
<form onSubmit={params.onSubmit} className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark space-y-4">
|
||||||
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('sponsoring.form.title')}</h3>
|
<h3 className="text-lg font-semibold text-neon-cyan">{t('sponsoring.form.title')}</h3>
|
||||||
<p className="text-sm text-cyber-accent/70">
|
<p className="text-sm text-cyber-accent/70">{t('sponsoring.form.description', { amount: '0.046' })}</p>
|
||||||
{t('sponsoring.form.description', { amount: '0.046' })}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="sponsoring-text" className="block text-sm font-medium text-cyber-accent mb-1">
|
<label htmlFor="sponsoring-text" className="block text-sm font-medium text-cyber-accent mb-1">
|
||||||
{t('sponsoring.form.text.label')} <span className="text-cyber-accent/50">({t('common.optional')})</span>
|
{t('sponsoring.form.text.label')} <span className="text-cyber-accent/50">({t('common.optional')})</span>
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="sponsoring-text"
|
id="sponsoring-text"
|
||||||
value={text}
|
value={params.text}
|
||||||
onChange={(e) => {
|
onChange={(e) => params.setText(e.target.value)}
|
||||||
setText(e.target.value)
|
|
||||||
}}
|
|
||||||
placeholder={t('sponsoring.form.text.placeholder')}
|
placeholder={t('sponsoring.form.text.placeholder')}
|
||||||
rows={3}
|
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"
|
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('sponsoring.form.text.help')}</p>
|
<p className="text-xs text-cyber-accent/70 mt-1">{t('sponsoring.form.text.help')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
{params.error && <div className="p-3 bg-red-900/20 border border-red-500/50 rounded text-red-400 text-sm">{params.error}</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">
|
<div className="flex gap-2">
|
||||||
<button
|
<button type="submit" disabled={params.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">
|
||||||
type="submit"
|
{params.loading ? t('common.loading') : t('sponsoring.form.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('sponsoring.form.submit')}
|
|
||||||
</button>
|
</button>
|
||||||
{onCancel && (
|
{params.onCancel && (
|
||||||
<button
|
<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">
|
||||||
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')}
|
{t('common.cancel')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,346 +1 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
export { SyncProgressBar } from './syncProgressBar/SyncProgressBar'
|
||||||
import { nostrAuthService } from '@/lib/nostrAuth'
|
|
||||||
import { getLastSyncDate, setLastSyncDate as setLastSyncDateStorage, getCurrentTimestamp, calculateDaysBetween } from '@/lib/syncStorage'
|
|
||||||
import { MIN_EVENT_DATE } from '@/lib/platformConfig'
|
|
||||||
import { objectCache } from '@/lib/objectCache'
|
|
||||||
import { t } from '@/lib/i18n'
|
|
||||||
import { useSyncProgress } from '@/lib/hooks/useSyncProgress'
|
|
||||||
|
|
||||||
export function SyncProgressBar(): React.ReactElement | null {
|
|
||||||
const [lastSyncDate, setLastSyncDate] = useState<number | null>(null)
|
|
||||||
const [totalDays, setTotalDays] = useState<number>(0)
|
|
||||||
const [isInitialized, setIsInitialized] = useState(false)
|
|
||||||
const [connectionState, setConnectionState] = useState<{ connected: boolean; pubkey: string | null }>({ connected: false, pubkey: null })
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const loadSyncStatus = useCallback(async (): Promise<void> => {
|
|
||||||
try {
|
|
||||||
const state = nostrAuthService.getState()
|
|
||||||
if (!state.connected || !state.pubkey) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const storedLastSyncDate = await getLastSyncDate()
|
|
||||||
const currentTimestamp = getCurrentTimestamp()
|
|
||||||
const days = calculateDaysBetween(storedLastSyncDate, currentTimestamp)
|
|
||||||
|
|
||||||
setLastSyncDate(storedLastSyncDate)
|
|
||||||
setTotalDays(days)
|
|
||||||
} catch (loadError) {
|
|
||||||
console.error('Error loading sync status:', loadError)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const { syncProgress, isSyncing, startMonitoring, stopMonitoring } = useSyncProgress({
|
|
||||||
onComplete: loadSyncStatus,
|
|
||||||
})
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Check connection state
|
|
||||||
const checkConnection = (): void => {
|
|
||||||
const state = nostrAuthService.getState()
|
|
||||||
console.warn('[SyncProgressBar] Initial connection check:', { connected: state.connected, pubkey: state.pubkey })
|
|
||||||
setConnectionState({ connected: state.connected ?? false, pubkey: state.pubkey ?? null })
|
|
||||||
setIsInitialized(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initial check
|
|
||||||
checkConnection()
|
|
||||||
|
|
||||||
// Listen to connection changes
|
|
||||||
const unsubscribe = nostrAuthService.subscribe((state) => {
|
|
||||||
console.warn('[SyncProgressBar] Connection state changed:', { connected: state.connected, pubkey: state.pubkey })
|
|
||||||
setConnectionState({ connected: state.connected ?? false, pubkey: state.pubkey ?? null })
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
unsubscribe()
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
console.warn('[SyncProgressBar] Effect triggered:', { isInitialized, connected: connectionState.connected, pubkey: connectionState.pubkey, isSyncing })
|
|
||||||
|
|
||||||
if (!isInitialized) {
|
|
||||||
console.warn('[SyncProgressBar] Not initialized yet')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!connectionState.connected) {
|
|
||||||
console.warn('[SyncProgressBar] Not connected')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!connectionState.pubkey) {
|
|
||||||
console.warn('[SyncProgressBar] No pubkey')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
void runAutoSyncCheck({
|
|
||||||
connection: { connected: connectionState.connected, pubkey: connectionState.pubkey },
|
|
||||||
isSyncing,
|
|
||||||
loadSyncStatus,
|
|
||||||
startMonitoring,
|
|
||||||
stopMonitoring,
|
|
||||||
setError,
|
|
||||||
})
|
|
||||||
}, [isInitialized, connectionState.connected, connectionState.pubkey, isSyncing, loadSyncStatus, startMonitoring, stopMonitoring])
|
|
||||||
|
|
||||||
async function resynchronize(): Promise<void> {
|
|
||||||
try {
|
|
||||||
const state = nostrAuthService.getState()
|
|
||||||
if (!state.connected || !state.pubkey) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear cache for user content (but keep other data)
|
|
||||||
await Promise.all([
|
|
||||||
objectCache.clear('author'),
|
|
||||||
objectCache.clear('series'),
|
|
||||||
objectCache.clear('publication'),
|
|
||||||
objectCache.clear('review'),
|
|
||||||
objectCache.clear('purchase'),
|
|
||||||
objectCache.clear('sponsoring'),
|
|
||||||
objectCache.clear('review_tip'),
|
|
||||||
])
|
|
||||||
|
|
||||||
// Reset last sync date to force full resync
|
|
||||||
await setLastSyncDateStorage(MIN_EVENT_DATE)
|
|
||||||
|
|
||||||
// Reload sync status
|
|
||||||
await loadSyncStatus()
|
|
||||||
|
|
||||||
// Start full resynchronization via Service Worker
|
|
||||||
if (state.pubkey !== null) {
|
|
||||||
const { swClient } = await import('@/lib/swClient')
|
|
||||||
const isReady = await swClient.isReady()
|
|
||||||
if (isReady) {
|
|
||||||
await swClient.startUserSync(state.pubkey)
|
|
||||||
startMonitoring()
|
|
||||||
} else {
|
|
||||||
stopMonitoring()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (resyncError) {
|
|
||||||
console.error('Error resynchronizing:', resyncError)
|
|
||||||
stopMonitoring()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't show if not initialized or not connected
|
|
||||||
if (!isInitialized || !connectionState.connected || !connectionState.pubkey) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if sync is recently completed (within last hour)
|
|
||||||
const isRecentlySynced = lastSyncDate !== null && lastSyncDate >= getCurrentTimestamp() - 3600
|
|
||||||
|
|
||||||
const progressPercentage = computeProgressPercentage(syncProgress)
|
|
||||||
|
|
||||||
const formatDate = (timestamp: number): string => {
|
|
||||||
const date = new Date(timestamp * 1000)
|
|
||||||
const locale = typeof window !== 'undefined' ? navigator.language : 'fr-FR'
|
|
||||||
return date.toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const getStartDate = (): number => {
|
|
||||||
if (lastSyncDate !== null) {
|
|
||||||
return lastSyncDate
|
|
||||||
}
|
|
||||||
return MIN_EVENT_DATE
|
|
||||||
}
|
|
||||||
|
|
||||||
const startDate = getStartDate()
|
|
||||||
const endDate = getCurrentTimestamp()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-cyber-darker border border-neon-cyan/30 rounded-lg p-4 mt-6">
|
|
||||||
<SyncErrorBanner
|
|
||||||
error={error}
|
|
||||||
onDismiss={() => {
|
|
||||||
setError(null)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<h3 className="text-lg font-semibold text-neon-cyan">
|
|
||||||
{t('settings.sync.title')}
|
|
||||||
</h3>
|
|
||||||
<SyncResyncButton
|
|
||||||
isSyncing={isSyncing}
|
|
||||||
onClick={() => {
|
|
||||||
void resynchronize()
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SyncDateRange
|
|
||||||
totalDays={totalDays}
|
|
||||||
startDate={formatDate(startDate)}
|
|
||||||
endDate={formatDate(endDate)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SyncProgressSection
|
|
||||||
isSyncing={isSyncing}
|
|
||||||
syncProgress={syncProgress}
|
|
||||||
progressPercentage={progressPercentage}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SyncStatusMessage
|
|
||||||
isSyncing={isSyncing}
|
|
||||||
totalDays={totalDays}
|
|
||||||
isRecentlySynced={isRecentlySynced}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function computeProgressPercentage(syncProgress: ReturnType<typeof useSyncProgress>['syncProgress']): number {
|
|
||||||
if (!syncProgress || syncProgress.totalSteps <= 0) {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return Math.min(100, (syncProgress.currentStep / syncProgress.totalSteps) * 100)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SyncErrorBanner(params: { error: string | null; onDismiss: () => void }): React.ReactElement | null {
|
|
||||||
if (!params.error) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="mb-4 bg-red-900/30 border border-red-500/50 rounded p-3 text-red-300 text-sm">
|
|
||||||
{params.error}
|
|
||||||
<button onClick={params.onDismiss} className="ml-2 text-red-400 hover:text-red-200">
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SyncResyncButton(params: { isSyncing: boolean; onClick: () => void }): React.ReactElement | null {
|
|
||||||
if (params.isSyncing) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={params.onClick}
|
|
||||||
className="px-3 py-1 text-xs bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded border border-neon-cyan/50 hover:border-neon-cyan transition-colors"
|
|
||||||
>
|
|
||||||
{t('settings.sync.resync')}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SyncDateRange(params: { totalDays: number; startDate: string; endDate: string }): React.ReactElement | null {
|
|
||||||
if (params.totalDays <= 0) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="mb-2">
|
|
||||||
<p className="text-sm text-cyber-accent">
|
|
||||||
{t('settings.sync.daysRange', {
|
|
||||||
startDate: params.startDate,
|
|
||||||
endDate: params.endDate,
|
|
||||||
days: params.totalDays,
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SyncProgressSection(params: {
|
|
||||||
isSyncing: boolean
|
|
||||||
syncProgress: ReturnType<typeof useSyncProgress>['syncProgress']
|
|
||||||
progressPercentage: number
|
|
||||||
}): React.ReactElement | null {
|
|
||||||
if (!params.isSyncing || !params.syncProgress) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span className="text-cyber-accent">
|
|
||||||
{t('settings.sync.progress', {
|
|
||||||
current: params.syncProgress.currentStep,
|
|
||||||
total: params.syncProgress.totalSteps,
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
<span className="text-neon-cyan font-semibold">{Math.round(params.progressPercentage)}%</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full bg-cyber-dark rounded-full h-2 overflow-hidden">
|
|
||||||
<div className="bg-neon-cyan h-full transition-all duration-300" style={{ width: `${params.progressPercentage}%` }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SyncStatusMessage(params: { isSyncing: boolean; totalDays: number; isRecentlySynced: boolean }): React.ReactElement | null {
|
|
||||||
if (params.isSyncing || params.totalDays !== 0) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
if (params.isRecentlySynced) {
|
|
||||||
return <p className="text-sm text-green-400">{t('settings.sync.completed')}</p>
|
|
||||||
}
|
|
||||||
return <p className="text-sm text-cyber-accent">{t('settings.sync.ready')}</p>
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runAutoSyncCheck(params: {
|
|
||||||
connection: { connected: boolean; pubkey: string | null }
|
|
||||||
isSyncing: boolean
|
|
||||||
loadSyncStatus: () => Promise<void>
|
|
||||||
startMonitoring: () => void
|
|
||||||
stopMonitoring: () => void
|
|
||||||
setError: (value: string | null) => void
|
|
||||||
}): Promise<void> {
|
|
||||||
console.warn('[SyncProgressBar] Starting sync check...')
|
|
||||||
await params.loadSyncStatus()
|
|
||||||
|
|
||||||
const shouldStart = await shouldAutoStartSync({
|
|
||||||
isSyncing: params.isSyncing,
|
|
||||||
pubkey: params.connection.pubkey,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!shouldStart || !params.connection.pubkey) {
|
|
||||||
console.warn('[SyncProgressBar] Skipping auto-sync:', { shouldStart, isSyncing: params.isSyncing, hasPubkey: Boolean(params.connection.pubkey) })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
console.warn('[SyncProgressBar] Starting auto-sync...')
|
|
||||||
await startAutoSync({
|
|
||||||
pubkey: params.connection.pubkey,
|
|
||||||
startMonitoring: params.startMonitoring,
|
|
||||||
stopMonitoring: params.stopMonitoring,
|
|
||||||
setError: params.setError,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function shouldAutoStartSync(params: { isSyncing: boolean; pubkey: string | null }): Promise<boolean> {
|
|
||||||
if (params.isSyncing || !params.pubkey) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
const storedLastSyncDate = await getLastSyncDate()
|
|
||||||
const currentTimestamp = getCurrentTimestamp()
|
|
||||||
const isRecentlySynced = storedLastSyncDate >= currentTimestamp - 3600
|
|
||||||
console.warn('[SyncProgressBar] Sync status:', { storedLastSyncDate, currentTimestamp, isRecentlySynced })
|
|
||||||
return !isRecentlySynced
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startAutoSync(params: {
|
|
||||||
pubkey: string
|
|
||||||
startMonitoring: () => void
|
|
||||||
stopMonitoring: () => void
|
|
||||||
setError: (value: string | null) => void
|
|
||||||
}): Promise<void> {
|
|
||||||
try {
|
|
||||||
const { swClient } = await import('@/lib/swClient')
|
|
||||||
const isReady = await swClient.isReady()
|
|
||||||
if (!isReady) {
|
|
||||||
params.stopMonitoring()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await swClient.startUserSync(params.pubkey)
|
|
||||||
params.startMonitoring()
|
|
||||||
} catch (autoSyncError) {
|
|
||||||
console.error('[SyncProgressBar] Error during auto-sync:', autoSyncError)
|
|
||||||
params.stopMonitoring()
|
|
||||||
params.setError(autoSyncError instanceof Error ? autoSyncError.message : 'Erreur de synchronisation')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,274 +1 @@
|
|||||||
import { useState, useRef, useMemo } from 'react'
|
export { UnlockAccountModal } from './unlockAccount/UnlockAccountModal'
|
||||||
import { nostrAuthService } from '@/lib/nostrAuth'
|
|
||||||
import { getWordSuggestions } from '@/lib/keyManagementBIP39'
|
|
||||||
|
|
||||||
interface UnlockAccountModalProps {
|
|
||||||
onSuccess: () => void
|
|
||||||
onClose: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
function WordInputWithAutocomplete({
|
|
||||||
index,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
onFocus,
|
|
||||||
onBlur,
|
|
||||||
}: {
|
|
||||||
index: number
|
|
||||||
value: string
|
|
||||||
onChange: (value: string) => void
|
|
||||||
onFocus: () => void
|
|
||||||
onBlur: () => void
|
|
||||||
}): React.ReactElement {
|
|
||||||
const [showSuggestions, setShowSuggestions] = useState(false)
|
|
||||||
const [selectedIndex, setSelectedIndex] = useState(-1)
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
|
||||||
const suggestionsRef = useRef<HTMLDivElement>(null)
|
|
||||||
|
|
||||||
const suggestions = useMemo((): string[] => {
|
|
||||||
if (value.length === 0) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
return getWordSuggestions(value, 5)
|
|
||||||
}, [value])
|
|
||||||
|
|
||||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>): void => {
|
|
||||||
const newValue = event.target.value.trim().toLowerCase()
|
|
||||||
setSelectedIndex(-1)
|
|
||||||
if (newValue.length === 0) {
|
|
||||||
setShowSuggestions(false)
|
|
||||||
}
|
|
||||||
onChange(newValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>): void => {
|
|
||||||
if (event.key === 'ArrowDown') {
|
|
||||||
event.preventDefault()
|
|
||||||
setSelectedIndex((prev) => (prev < suggestions.length - 1 ? prev + 1 : prev))
|
|
||||||
} else if (event.key === 'ArrowUp') {
|
|
||||||
event.preventDefault()
|
|
||||||
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : -1))
|
|
||||||
} else if (event.key === 'Enter' && selectedIndex >= 0 && suggestions[selectedIndex]) {
|
|
||||||
event.preventDefault()
|
|
||||||
onChange(suggestions[selectedIndex] ?? '')
|
|
||||||
setShowSuggestions(false)
|
|
||||||
inputRef.current?.blur()
|
|
||||||
} else if (event.key === 'Escape') {
|
|
||||||
setShowSuggestions(false)
|
|
||||||
inputRef.current?.blur()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSuggestionClick = (suggestion: string): void => {
|
|
||||||
onChange(suggestion)
|
|
||||||
setShowSuggestions(false)
|
|
||||||
inputRef.current?.blur()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative">
|
|
||||||
<label htmlFor={`word-${index}`} className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Mot {index + 1}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
id={`word-${index}`}
|
|
||||||
type="text"
|
|
||||||
value={value}
|
|
||||||
onChange={handleChange}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
onFocus={onFocus}
|
|
||||||
onBlur={() => {
|
|
||||||
// Delay to allow click on suggestion
|
|
||||||
setTimeout(() => {
|
|
||||||
setShowSuggestions(false)
|
|
||||||
onBlur()
|
|
||||||
}, 200)
|
|
||||||
}}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg font-mono text-lg text-center"
|
|
||||||
autoComplete="off"
|
|
||||||
autoCapitalize="off"
|
|
||||||
autoCorrect="off"
|
|
||||||
spellCheck="false"
|
|
||||||
/>
|
|
||||||
{showSuggestions && suggestions.length > 0 && (
|
|
||||||
<div
|
|
||||||
ref={suggestionsRef}
|
|
||||||
className="absolute z-10 w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg max-h-40 overflow-y-auto"
|
|
||||||
>
|
|
||||||
{suggestions.map((suggestion, idx) => (
|
|
||||||
<button
|
|
||||||
key={suggestion}
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleSuggestionClick(suggestion)}
|
|
||||||
className={`w-full text-left px-3 py-2 hover:bg-gray-100 ${
|
|
||||||
idx === selectedIndex ? 'bg-gray-100' : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{suggestion}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function WordInputs({
|
|
||||||
words,
|
|
||||||
onWordChange,
|
|
||||||
}: {
|
|
||||||
words: string[]
|
|
||||||
onWordChange: (index: number, value: string) => void
|
|
||||||
}): React.ReactElement {
|
|
||||||
const [, setFocusedIndex] = useState<number | null>(null)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
{words.map((word, index) => (
|
|
||||||
<WordInputWithAutocomplete
|
|
||||||
key={`word-input-${index}-${word}`}
|
|
||||||
index={index}
|
|
||||||
value={word}
|
|
||||||
onChange={(value) => onWordChange(index, value)}
|
|
||||||
onFocus={() => setFocusedIndex(index)}
|
|
||||||
onBlur={() => setFocusedIndex(null)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function useUnlockAccount(words: string[], setWords: (words: string[]) => void, setError: (error: string | null) => void): {
|
|
||||||
handleWordChange: (index: number, value: string) => void
|
|
||||||
handlePaste: () => void
|
|
||||||
} {
|
|
||||||
const handleWordChange = (index: number, value: string): void => {
|
|
||||||
const newWords = [...words]
|
|
||||||
newWords[index] = value.trim().toLowerCase()
|
|
||||||
setWords(newWords)
|
|
||||||
setError(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlePasteAsync = async (): Promise<void> => {
|
|
||||||
try {
|
|
||||||
const text = await navigator.clipboard.readText()
|
|
||||||
const pastedWords = text.trim().split(/\s+/).slice(0, 4)
|
|
||||||
if (pastedWords.length === 4) {
|
|
||||||
setWords(pastedWords.map((w) => w.toLowerCase()))
|
|
||||||
setError(null)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore clipboard errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlePaste = (): void => {
|
|
||||||
void handlePasteAsync()
|
|
||||||
}
|
|
||||||
|
|
||||||
return { handleWordChange, handlePaste }
|
|
||||||
}
|
|
||||||
|
|
||||||
function UnlockAccountButtons({
|
|
||||||
loading,
|
|
||||||
words,
|
|
||||||
onUnlock,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
loading: boolean
|
|
||||||
words: string[]
|
|
||||||
onUnlock: () => void
|
|
||||||
onClose: () => void
|
|
||||||
}): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<div className="flex gap-4">
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="flex-1 py-2 px-4 bg-gray-200 hover:bg-gray-300 rounded-lg font-medium transition-colors"
|
|
||||||
>
|
|
||||||
Annuler
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
void onUnlock()
|
|
||||||
}}
|
|
||||||
disabled={loading || words.some((word) => !word)}
|
|
||||||
className="flex-1 py-2 px-4 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"
|
|
||||||
>
|
|
||||||
{loading ? 'Déverrouillage...' : 'Déverrouiller'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function UnlockAccountForm({
|
|
||||||
words,
|
|
||||||
handleWordChange,
|
|
||||||
handlePaste,
|
|
||||||
}: {
|
|
||||||
words: string[]
|
|
||||||
handleWordChange: (index: number, value: string) => void
|
|
||||||
handlePaste: () => void
|
|
||||||
}): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<div className="mb-4">
|
|
||||||
<WordInputs words={words} onWordChange={handleWordChange} />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
void handlePaste()
|
|
||||||
}}
|
|
||||||
className="mt-2 text-sm text-gray-600 hover:text-gray-800 underline"
|
|
||||||
>
|
|
||||||
Coller depuis le presse-papiers
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function UnlockAccountModal({ onSuccess, onClose }: UnlockAccountModalProps): React.ReactElement {
|
|
||||||
const [words, setWords] = useState(['', '', '', ''])
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const { handleWordChange, handlePaste } = useUnlockAccount(words, setWords, setError)
|
|
||||||
|
|
||||||
const handleUnlock = async (): Promise<void> => {
|
|
||||||
if (words.some((word) => !word)) {
|
|
||||||
setError('Veuillez remplir tous les mots-clés')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
try {
|
|
||||||
await nostrAuthService.unlockAccount(words)
|
|
||||||
onSuccess()
|
|
||||||
onClose()
|
|
||||||
} catch (unlockError) {
|
|
||||||
setError(unlockError instanceof Error ? unlockError.message : 'Échec du déverrouillage. Vérifiez vos mots-clés.')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const onUnlock = (): void => {
|
|
||||||
void handleUnlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|
||||||
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
|
|
||||||
<h2 className="text-2xl font-bold mb-4">Déverrouiller votre compte</h2>
|
|
||||||
<p className="text-gray-600 mb-6">
|
|
||||||
Entrez vos 4 mots-clés de récupération (dictionnaire BIP39) pour déverrouiller votre compte.
|
|
||||||
Ces mots déchiffrent la clé de chiffrement (KEK) stockée dans l'API Credentials, qui déchiffre ensuite votre clé privée.
|
|
||||||
</p>
|
|
||||||
<UnlockAccountForm words={words} handleWordChange={handleWordChange} handlePaste={handlePaste} />
|
|
||||||
{error && <p className="text-sm text-red-600 mb-4">{error}</p>}
|
|
||||||
<UnlockAccountButtons loading={loading} words={words} onUnlock={onUnlock} onClose={onClose} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,325 +1 @@
|
|||||||
import { useMemo, useState, type Dispatch, type SetStateAction } from 'react'
|
export { UserArticles } from './userArticles/UserArticles'
|
||||||
import type { Article } from '@/types/nostr'
|
|
||||||
import type { ArticleDraft } from '@/lib/articlePublisherTypes'
|
|
||||||
import { useArticleEditing } from '@/hooks/useArticleEditing'
|
|
||||||
import { UserArticlesView } from './UserArticlesList'
|
|
||||||
import { EditPanel } from './UserArticlesEditPanel'
|
|
||||||
|
|
||||||
interface UserArticlesProps {
|
|
||||||
articles: Article[]
|
|
||||||
loading: boolean
|
|
||||||
error: string | null
|
|
||||||
onLoadContent: (articleId: string, authorPubkey: string) => Promise<Article | null>
|
|
||||||
showEmptyMessage?: boolean
|
|
||||||
currentPubkey: string | null
|
|
||||||
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
type UserArticlesController = {
|
|
||||||
localArticles: Article[]
|
|
||||||
unlockedArticles: Set<string>
|
|
||||||
pendingDeleteId: string | null
|
|
||||||
requestDelete: (id: string) => void
|
|
||||||
handleUnlock: (article: Article) => Promise<void>
|
|
||||||
handleDelete: (article: Article) => Promise<void>
|
|
||||||
handleEditSubmit: () => Promise<void>
|
|
||||||
editingDraft: ArticleDraft | null
|
|
||||||
editingArticleId: string | null
|
|
||||||
loading: boolean
|
|
||||||
error: string | null
|
|
||||||
updateDraft: (draft: ArticleDraft) => void
|
|
||||||
startEditing: (article: Article) => Promise<void>
|
|
||||||
cancelEditing: () => void
|
|
||||||
submitEdit: () => Promise<import('@/lib/articleMutations').ArticleUpdateResult | null>
|
|
||||||
deleteArticle: (id: string) => Promise<boolean>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function UserArticles({
|
|
||||||
articles,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
onLoadContent,
|
|
||||||
showEmptyMessage = true,
|
|
||||||
currentPubkey,
|
|
||||||
onSelectSeries,
|
|
||||||
}: UserArticlesProps): React.ReactElement {
|
|
||||||
const controller = useUserArticlesController({ articles, onLoadContent, currentPubkey })
|
|
||||||
return (
|
|
||||||
<UserArticlesLayout
|
|
||||||
controller={controller}
|
|
||||||
loading={loading}
|
|
||||||
error={error}
|
|
||||||
showEmptyMessage={showEmptyMessage ?? true}
|
|
||||||
currentPubkey={currentPubkey}
|
|
||||||
onSelectSeries={onSelectSeries}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function useUserArticlesController({
|
|
||||||
articles,
|
|
||||||
onLoadContent,
|
|
||||||
currentPubkey,
|
|
||||||
}: {
|
|
||||||
articles: Article[]
|
|
||||||
onLoadContent: (articleId: string, authorPubkey: string) => Promise<Article | null>
|
|
||||||
currentPubkey: string | null
|
|
||||||
}): UserArticlesController {
|
|
||||||
const [deletedArticleIds, setDeletedArticleIds] = useState<Set<string>>(new Set())
|
|
||||||
const [articleOverridesById, setArticleOverridesById] = useState<Map<string, Article>>(new Map())
|
|
||||||
const [unlockedArticles, setUnlockedArticles] = useState<Set<string>>(new Set())
|
|
||||||
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null)
|
|
||||||
const editingCtx = useArticleEditing(currentPubkey)
|
|
||||||
|
|
||||||
const localArticles = useMemo((): Article[] => {
|
|
||||||
return articles
|
|
||||||
.filter((a) => !deletedArticleIds.has(a.id))
|
|
||||||
.map((a) => articleOverridesById.get(a.id) ?? a)
|
|
||||||
}, [articles, articleOverridesById, deletedArticleIds])
|
|
||||||
|
|
||||||
return {
|
|
||||||
localArticles,
|
|
||||||
unlockedArticles,
|
|
||||||
pendingDeleteId,
|
|
||||||
requestDelete: (id: string) => setPendingDeleteId(id),
|
|
||||||
handleUnlock: createHandleUnlock(onLoadContent, setUnlockedArticles),
|
|
||||||
handleDelete: createHandleDelete(editingCtx.deleteArticle, setDeletedArticleIds, setPendingDeleteId),
|
|
||||||
handleEditSubmit: createHandleEditSubmit(
|
|
||||||
editingCtx.submitEdit,
|
|
||||||
editingCtx.editingDraft,
|
|
||||||
currentPubkey,
|
|
||||||
setArticleOverridesById
|
|
||||||
),
|
|
||||||
...editingCtx,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createHandleUnlock(
|
|
||||||
onLoadContent: (id: string, pubkey: string) => Promise<Article | null>,
|
|
||||||
setUnlocked: Dispatch<SetStateAction<Set<string>>>
|
|
||||||
): (article: Article) => Promise<void> {
|
|
||||||
return async (article: Article): Promise<void> => {
|
|
||||||
const full = await onLoadContent(article.id, article.pubkey)
|
|
||||||
if (full?.paid) {
|
|
||||||
setUnlocked((prev) => new Set([...prev, article.id]))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createHandleDelete(
|
|
||||||
deleteArticle: (id: string) => Promise<boolean>,
|
|
||||||
setDeletedArticleIds: Dispatch<SetStateAction<Set<string>>>,
|
|
||||||
setPendingDeleteId: Dispatch<SetStateAction<string | null>>
|
|
||||||
): (article: Article) => Promise<void> {
|
|
||||||
return async (article: Article): Promise<void> => {
|
|
||||||
const ok = await deleteArticle(article.id)
|
|
||||||
if (ok) {
|
|
||||||
setDeletedArticleIds((prev) => new Set([...prev, article.id]))
|
|
||||||
}
|
|
||||||
setPendingDeleteId(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createHandleEditSubmit(
|
|
||||||
submitEdit: () => Promise<import('@/lib/articleMutations').ArticleUpdateResult | null>,
|
|
||||||
draft: ReturnType<typeof useArticleEditing>['editingDraft'],
|
|
||||||
currentPubkey: string | null,
|
|
||||||
setArticleOverridesById: Dispatch<SetStateAction<Map<string, Article>>>
|
|
||||||
): () => Promise<void> {
|
|
||||||
return async (): Promise<void> => {
|
|
||||||
const result = await submitEdit()
|
|
||||||
if (result && draft) {
|
|
||||||
const updated = buildUpdatedArticle(draft, currentPubkey ?? '', result.articleId)
|
|
||||||
setArticleOverridesById((prev) => {
|
|
||||||
const next = new Map(prev)
|
|
||||||
next.set(result.originalArticleId, { ...updated, id: result.originalArticleId })
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildUpdatedArticle(
|
|
||||||
draft: NonNullable<ReturnType<typeof useArticleEditing>['editingDraft']>,
|
|
||||||
pubkey: string,
|
|
||||||
newId: string
|
|
||||||
): Article {
|
|
||||||
const hash = newId.split('_')[0] ?? ''
|
|
||||||
const index = Number.parseInt(newId.split('_')[1] ?? '0', 10)
|
|
||||||
const version = Number.parseInt(newId.split('_')[2] ?? '0', 10)
|
|
||||||
return {
|
|
||||||
id: newId,
|
|
||||||
hash,
|
|
||||||
version,
|
|
||||||
index,
|
|
||||||
pubkey,
|
|
||||||
title: draft.title,
|
|
||||||
preview: draft.preview,
|
|
||||||
content: '',
|
|
||||||
description: draft.preview,
|
|
||||||
contentDescription: draft.preview,
|
|
||||||
createdAt: Math.floor(Date.now() / 1000),
|
|
||||||
zapAmount: draft.zapAmount,
|
|
||||||
paid: false,
|
|
||||||
thumbnailUrl: draft.bannerUrl ?? '',
|
|
||||||
...(draft.category ? { category: draft.category } : {}),
|
|
||||||
...(draft.seriesId ? { seriesId: draft.seriesId } : {}),
|
|
||||||
...(draft.bannerUrl ? { bannerUrl: draft.bannerUrl } : {}),
|
|
||||||
...(draft.media ? { media: draft.media } : {}),
|
|
||||||
...(draft.pages ? { pages: draft.pages } : {}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function UserArticlesLayout({
|
|
||||||
controller,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
showEmptyMessage,
|
|
||||||
currentPubkey,
|
|
||||||
onSelectSeries,
|
|
||||||
}: {
|
|
||||||
controller: ReturnType<typeof useUserArticlesController>
|
|
||||||
loading: boolean
|
|
||||||
error: string | null
|
|
||||||
showEmptyMessage: boolean
|
|
||||||
currentPubkey: string | null
|
|
||||||
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
|
|
||||||
}): React.ReactElement {
|
|
||||||
const { editPanelProps, listProps } = createLayoutProps(controller, {
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
showEmptyMessage,
|
|
||||||
currentPubkey,
|
|
||||||
onSelectSeries,
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<EditPanel {...editPanelProps} />
|
|
||||||
<UserArticlesView {...listProps} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function createLayoutProps(
|
|
||||||
controller: UserArticlesController,
|
|
||||||
view: {
|
|
||||||
loading: boolean
|
|
||||||
error: string | null
|
|
||||||
showEmptyMessage: boolean
|
|
||||||
currentPubkey: string | null
|
|
||||||
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
|
|
||||||
}
|
|
||||||
): {
|
|
||||||
editPanelProps: {
|
|
||||||
draft: ArticleDraft | null
|
|
||||||
editingArticleId: string | null
|
|
||||||
loading: boolean
|
|
||||||
error: string | null
|
|
||||||
onCancel: () => void
|
|
||||||
onDraftChange: (draft: ArticleDraft) => void
|
|
||||||
onSubmit: () => Promise<void>
|
|
||||||
}
|
|
||||||
listProps: {
|
|
||||||
articles: Article[]
|
|
||||||
loading: boolean
|
|
||||||
error: string | null
|
|
||||||
showEmptyMessage: boolean
|
|
||||||
unlockedArticles: Set<string>
|
|
||||||
onUnlock: (article: Article) => void
|
|
||||||
onEdit: (article: Article) => void
|
|
||||||
onDelete: (article: Article) => void
|
|
||||||
editingArticleId: string | null
|
|
||||||
currentPubkey: string | null
|
|
||||||
pendingDeleteId: string | null
|
|
||||||
requestDelete: (articleId: string) => void
|
|
||||||
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
|
|
||||||
}
|
|
||||||
} {
|
|
||||||
return {
|
|
||||||
editPanelProps: buildEditPanelProps(controller),
|
|
||||||
listProps: buildListProps(controller, view),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildEditPanelProps(controller: UserArticlesController): {
|
|
||||||
draft: ArticleDraft | null
|
|
||||||
editingArticleId: string | null
|
|
||||||
loading: boolean
|
|
||||||
error: string | null
|
|
||||||
onCancel: () => void
|
|
||||||
onDraftChange: (draft: ArticleDraft) => void
|
|
||||||
onSubmit: () => Promise<void>
|
|
||||||
} {
|
|
||||||
return {
|
|
||||||
draft: controller.editingDraft,
|
|
||||||
editingArticleId: controller.editingArticleId,
|
|
||||||
loading: controller.loading,
|
|
||||||
error: controller.error,
|
|
||||||
onCancel: controller.cancelEditing,
|
|
||||||
onDraftChange: controller.updateDraft,
|
|
||||||
onSubmit: controller.handleEditSubmit,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type UserArticlesListProps = {
|
|
||||||
articles: Article[]
|
|
||||||
loading: boolean
|
|
||||||
error: string | null
|
|
||||||
showEmptyMessage: boolean
|
|
||||||
unlockedArticles: Set<string>
|
|
||||||
onUnlock: (article: Article) => void
|
|
||||||
onEdit: (article: Article) => void
|
|
||||||
onDelete: (article: Article) => void
|
|
||||||
editingArticleId: string | null
|
|
||||||
currentPubkey: string | null
|
|
||||||
pendingDeleteId: string | null
|
|
||||||
requestDelete: (articleId: string) => void
|
|
||||||
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildListProps(
|
|
||||||
controller: UserArticlesController,
|
|
||||||
view: {
|
|
||||||
loading: boolean
|
|
||||||
error: string | null
|
|
||||||
showEmptyMessage: boolean
|
|
||||||
currentPubkey: string | null
|
|
||||||
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
|
|
||||||
}
|
|
||||||
): UserArticlesListProps {
|
|
||||||
const handlers = buildUserArticlesHandlers(controller)
|
|
||||||
return {
|
|
||||||
articles: controller.localArticles,
|
|
||||||
loading: view.loading,
|
|
||||||
error: view.error,
|
|
||||||
showEmptyMessage: view.showEmptyMessage,
|
|
||||||
unlockedArticles: controller.unlockedArticles,
|
|
||||||
onUnlock: handlers.onUnlock,
|
|
||||||
onEdit: handlers.onEdit,
|
|
||||||
onDelete: handlers.onDelete,
|
|
||||||
editingArticleId: controller.editingArticleId,
|
|
||||||
currentPubkey: view.currentPubkey,
|
|
||||||
pendingDeleteId: controller.pendingDeleteId,
|
|
||||||
requestDelete: controller.requestDelete,
|
|
||||||
...(view.onSelectSeries ? { onSelectSeries: view.onSelectSeries } : {}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildUserArticlesHandlers(controller: UserArticlesController): {
|
|
||||||
onUnlock: (article: Article) => void
|
|
||||||
onEdit: (article: Article) => void
|
|
||||||
onDelete: (article: Article) => void
|
|
||||||
} {
|
|
||||||
return {
|
|
||||||
onUnlock: (a: Article): void => {
|
|
||||||
void controller.handleUnlock(a)
|
|
||||||
},
|
|
||||||
onEdit: (a: Article): void => {
|
|
||||||
void controller.startEditing(a)
|
|
||||||
},
|
|
||||||
onDelete: (a: Article): void => {
|
|
||||||
void controller.handleDelete(a)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -0,0 +1,80 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useNostrAuth } from '@/hooks/useNostrAuth'
|
||||||
|
import { useAuthorPresentation } from '@/hooks/useAuthorPresentation'
|
||||||
|
import { t } from '@/lib/i18n'
|
||||||
|
import { NoAccountView } from './NoAccountView'
|
||||||
|
import { PresentationForm } from './PresentationForm'
|
||||||
|
import { useExistingPresentation } from './useExistingPresentation'
|
||||||
|
import { useAuthorPresentationState } from './useAuthorPresentationState'
|
||||||
|
|
||||||
|
function SuccessNotice(params: { pubkey: string | null }): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="border border-neon-green/50 rounded-lg p-6 bg-neon-green/10">
|
||||||
|
<h3 className="text-lg font-semibold text-neon-green mb-2">{t('presentation.success')}</h3>
|
||||||
|
<p className="text-cyber-accent mb-4">{t('presentation.successMessage')}</p>
|
||||||
|
{params.pubkey ? (
|
||||||
|
<div className="mt-4">
|
||||||
|
<a
|
||||||
|
href={`/author/${params.pubkey}`}
|
||||||
|
className="inline-block 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"
|
||||||
|
>
|
||||||
|
{t('presentation.manageSeries')}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoadingNotice(): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-cyber-accent/70">{t('common.loading')}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AuthorPresentationFormView(props: { pubkey: string | null; profile: { name?: string; pubkey: string } | null }): React.ReactElement {
|
||||||
|
const { checkPresentationExists } = useAuthorPresentation(props.pubkey)
|
||||||
|
const presentation = useExistingPresentation({ pubkey: props.pubkey, checkPresentationExists })
|
||||||
|
const state = useAuthorPresentationState(props.pubkey, props.profile?.name, presentation.existingPresentation)
|
||||||
|
|
||||||
|
if (!props.pubkey) {
|
||||||
|
return <NoAccountView />
|
||||||
|
}
|
||||||
|
if (presentation.loadingPresentation) {
|
||||||
|
return <LoadingNotice />
|
||||||
|
}
|
||||||
|
if (state.success) {
|
||||||
|
return <SuccessNotice pubkey={props.pubkey} />
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<PresentationForm
|
||||||
|
draft={state.draft}
|
||||||
|
setDraft={state.setDraft}
|
||||||
|
validationError={state.validationError}
|
||||||
|
error={state.error}
|
||||||
|
loading={state.loading}
|
||||||
|
handleSubmit={state.handleSubmit}
|
||||||
|
deleting={state.deleting}
|
||||||
|
handleDelete={() => {
|
||||||
|
void state.handleDelete()
|
||||||
|
}}
|
||||||
|
hasExistingPresentation={presentation.existingPresentation !== null}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function useAutoLoadPubkey(accountExists: boolean | null, pubkey: string | null, connect: () => Promise<void>): void {
|
||||||
|
useEffect(() => {
|
||||||
|
if (accountExists === true && !pubkey) {
|
||||||
|
void connect()
|
||||||
|
}
|
||||||
|
}, [accountExists, pubkey, connect])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthorPresentationEditor(): React.ReactElement {
|
||||||
|
const { pubkey, profile, accountExists, connect } = useNostrAuth()
|
||||||
|
useAutoLoadPubkey(accountExists, pubkey ?? null, connect)
|
||||||
|
return <AuthorPresentationFormView pubkey={pubkey ?? null} profile={profile} />
|
||||||
|
}
|
||||||
147
components/authorPresentationEditor/NoAccountView.tsx
Normal file
147
components/authorPresentationEditor/NoAccountView.tsx
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { CreateAccountModal } from '../CreateAccountModal'
|
||||||
|
import { RecoveryStep } from '../CreateAccountModalSteps'
|
||||||
|
import { UnlockAccountModal } from '../UnlockAccountModal'
|
||||||
|
import { t } from '@/lib/i18n'
|
||||||
|
|
||||||
|
export function NoAccountView(): React.ReactElement {
|
||||||
|
const [showImportModal, setShowImportModal] = useState(false)
|
||||||
|
const [showRecoveryStep, setShowRecoveryStep] = useState(false)
|
||||||
|
const [showUnlockModal, setShowUnlockModal] = useState(false)
|
||||||
|
const [recoveryPhrase, setRecoveryPhrase] = useState<string[]>([])
|
||||||
|
const [npub, setNpub] = useState('')
|
||||||
|
const [generating, setGenerating] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const handleGenerate = (): void => {
|
||||||
|
void generateNoAccount({ setGenerating, setError, setRecoveryPhrase, setNpub, setShowRecoveryStep })
|
||||||
|
}
|
||||||
|
const handleRecoveryContinue = (): void => transitionToUnlock({ setShowRecoveryStep, setShowUnlockModal })
|
||||||
|
const handleUnlockSuccess = (): void => resetNoAccountAfterUnlock({ setShowUnlockModal, setRecoveryPhrase, setNpub })
|
||||||
|
const handleImportSuccess = (): void => {
|
||||||
|
setShowImportModal(false)
|
||||||
|
setShowUnlockModal(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NoAccountCard
|
||||||
|
error={error}
|
||||||
|
generating={generating}
|
||||||
|
onGenerate={handleGenerate}
|
||||||
|
onImport={() => setShowImportModal(true)}
|
||||||
|
modals={
|
||||||
|
<NoAccountModals
|
||||||
|
showImportModal={showImportModal}
|
||||||
|
onCloseImport={() => setShowImportModal(false)}
|
||||||
|
onImportSuccess={handleImportSuccess}
|
||||||
|
showRecoveryStep={showRecoveryStep}
|
||||||
|
recoveryPhrase={recoveryPhrase}
|
||||||
|
npub={npub}
|
||||||
|
onRecoveryContinue={handleRecoveryContinue}
|
||||||
|
showUnlockModal={showUnlockModal}
|
||||||
|
onUnlockSuccess={handleUnlockSuccess}
|
||||||
|
onCloseUnlock={() => setShowUnlockModal(false)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NoAccountActionButtons(params: { onGenerate: () => void; onImport: () => void }): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 w-full max-w-xs">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={params.onGenerate}
|
||||||
|
className="px-6 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"
|
||||||
|
>
|
||||||
|
{t('account.create.generateButton')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={params.onImport}
|
||||||
|
className="px-6 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{t('account.create.importButton')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NoAccountCard(params: {
|
||||||
|
error: string | null
|
||||||
|
generating: boolean
|
||||||
|
onGenerate: () => void
|
||||||
|
onImport: () => void
|
||||||
|
modals: React.ReactElement
|
||||||
|
}): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="border border-neon-cyan/20 rounded-lg p-6 bg-cyber-dark/50">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<p className="text-center text-cyber-accent mb-2">Créez un compte ou importez votre clé secrète pour commencer</p>
|
||||||
|
{params.error ? <p className="text-sm text-red-400">{params.error}</p> : null}
|
||||||
|
<NoAccountActionButtons onGenerate={params.onGenerate} onImport={params.onImport} />
|
||||||
|
{params.generating ? <p className="text-cyber-accent text-sm">Génération du compte...</p> : null}
|
||||||
|
{params.modals}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NoAccountModals(params: {
|
||||||
|
showImportModal: boolean
|
||||||
|
onImportSuccess: () => void
|
||||||
|
onCloseImport: () => void
|
||||||
|
showRecoveryStep: boolean
|
||||||
|
recoveryPhrase: string[]
|
||||||
|
npub: string
|
||||||
|
onRecoveryContinue: () => void
|
||||||
|
showUnlockModal: boolean
|
||||||
|
onUnlockSuccess: () => void
|
||||||
|
onCloseUnlock: () => void
|
||||||
|
}): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{params.showImportModal ? <CreateAccountModal onSuccess={params.onImportSuccess} onClose={params.onCloseImport} initialStep="import" /> : null}
|
||||||
|
{params.showRecoveryStep ? <RecoveryStep recoveryPhrase={params.recoveryPhrase} npub={params.npub} onContinue={params.onRecoveryContinue} /> : null}
|
||||||
|
{params.showUnlockModal ? <UnlockAccountModal onSuccess={params.onUnlockSuccess} onClose={params.onCloseUnlock} /> : null}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateNoAccount(params: {
|
||||||
|
setGenerating: (value: boolean) => void
|
||||||
|
setError: (value: string | null) => void
|
||||||
|
setRecoveryPhrase: (value: string[]) => void
|
||||||
|
setNpub: (value: string) => void
|
||||||
|
setShowRecoveryStep: (value: boolean) => void
|
||||||
|
}): Promise<void> {
|
||||||
|
params.setGenerating(true)
|
||||||
|
params.setError(null)
|
||||||
|
try {
|
||||||
|
const { nostrAuthService } = await import('@/lib/nostrAuth')
|
||||||
|
const result = await nostrAuthService.createAccount()
|
||||||
|
params.setRecoveryPhrase(result.recoveryPhrase)
|
||||||
|
params.setNpub(result.npub)
|
||||||
|
params.setShowRecoveryStep(true)
|
||||||
|
} catch (e) {
|
||||||
|
params.setError(e instanceof Error ? e.message : t('account.create.error.failed'))
|
||||||
|
} finally {
|
||||||
|
params.setGenerating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function transitionToUnlock(params: { setShowRecoveryStep: (value: boolean) => void; setShowUnlockModal: (value: boolean) => void }): void {
|
||||||
|
params.setShowRecoveryStep(false)
|
||||||
|
params.setShowUnlockModal(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetNoAccountAfterUnlock(params: {
|
||||||
|
setShowUnlockModal: (value: boolean) => void
|
||||||
|
setRecoveryPhrase: (value: string[]) => void
|
||||||
|
setNpub: (value: string) => void
|
||||||
|
}): void {
|
||||||
|
params.setShowUnlockModal(false)
|
||||||
|
params.setRecoveryPhrase([])
|
||||||
|
params.setNpub('')
|
||||||
|
}
|
||||||
75
components/authorPresentationEditor/PresentationForm.tsx
Normal file
75
components/authorPresentationEditor/PresentationForm.tsx
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import type { FormEvent } from 'react'
|
||||||
|
import { PresentationFormHeader } from '../PresentationFormHeader'
|
||||||
|
import { t } from '@/lib/i18n'
|
||||||
|
import { PresentationFields } from './fields'
|
||||||
|
import type { AuthorPresentationDraft } from './types'
|
||||||
|
|
||||||
|
export interface PresentationFormProps {
|
||||||
|
draft: AuthorPresentationDraft
|
||||||
|
setDraft: (next: AuthorPresentationDraft) => void
|
||||||
|
validationError: string | null
|
||||||
|
error: string | null
|
||||||
|
loading: boolean
|
||||||
|
handleSubmit: (e: FormEvent<HTMLFormElement>) => Promise<void>
|
||||||
|
deleting: boolean
|
||||||
|
handleDelete: () => void
|
||||||
|
hasExistingPresentation: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PresentationForm(props: PresentationFormProps): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={(e: FormEvent<HTMLFormElement>) => {
|
||||||
|
void props.handleSubmit(e)
|
||||||
|
}}
|
||||||
|
className="border border-neon-cyan/20 rounded-lg p-6 bg-cyber-dark space-y-4"
|
||||||
|
>
|
||||||
|
<PresentationFormHeader />
|
||||||
|
<PresentationFields draft={props.draft} onChange={props.setDraft} />
|
||||||
|
<ValidationError message={props.validationError ?? props.error} />
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={props.loading || props.deleting}
|
||||||
|
className="w-full 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"
|
||||||
|
>
|
||||||
|
{getSubmitLabel({ loading: props.loading, deleting: props.deleting, hasExistingPresentation: props.hasExistingPresentation })}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{props.hasExistingPresentation ? <DeleteButton onDelete={props.handleDelete} deleting={props.deleting} /> : null}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ValidationError(params: { message: string | null }): React.ReactElement | null {
|
||||||
|
if (!params.message) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="bg-red-500/10 border border-red-500/50 rounded-lg p-3">
|
||||||
|
<p className="text-sm text-red-400">{params.message}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeleteButton(params: { onDelete: () => void; deleting: boolean }): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={params.onDelete}
|
||||||
|
disabled={params.deleting}
|
||||||
|
className="px-4 py-2 bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded-lg text-sm font-medium transition-all border border-red-500/50 hover:shadow-glow-red disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{params.deleting ? t('presentation.delete.deleting') : t('presentation.delete.button')}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSubmitLabel(params: { loading: boolean; deleting: boolean; hasExistingPresentation: boolean }): string {
|
||||||
|
if (params.loading || params.deleting) {
|
||||||
|
return t('publish.publishing')
|
||||||
|
}
|
||||||
|
return params.hasExistingPresentation ? t('presentation.update.button') : t('publish.button')
|
||||||
|
}
|
||||||
88
components/authorPresentationEditor/fields.tsx
Normal file
88
components/authorPresentationEditor/fields.tsx
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import { ArticleField } from '../ArticleField'
|
||||||
|
import { ImageUploadField } from '../ImageUploadField'
|
||||||
|
import { t } from '@/lib/i18n'
|
||||||
|
import type { AuthorPresentationDraft } from './types'
|
||||||
|
|
||||||
|
export function PresentationFields(params: {
|
||||||
|
draft: AuthorPresentationDraft
|
||||||
|
onChange: (next: AuthorPresentationDraft) => void
|
||||||
|
}): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<AuthorNameField draft={params.draft} onChange={params.onChange} />
|
||||||
|
<PictureField draft={params.draft} onChange={params.onChange} />
|
||||||
|
<PresentationField draft={params.draft} onChange={params.onChange} />
|
||||||
|
<ContentDescriptionField draft={params.draft} onChange={params.onChange} />
|
||||||
|
<MainnetAddressField draft={params.draft} onChange={params.onChange} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PresentationField(params: { draft: AuthorPresentationDraft; onChange: (next: AuthorPresentationDraft) => void }): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<ArticleField
|
||||||
|
id="presentation"
|
||||||
|
label={t('presentation.field.presentation')}
|
||||||
|
value={params.draft.presentation}
|
||||||
|
onChange={(value) => params.onChange({ ...params.draft, presentation: value as string })}
|
||||||
|
required
|
||||||
|
type="textarea"
|
||||||
|
rows={6}
|
||||||
|
placeholder={t('presentation.field.presentation.placeholder')}
|
||||||
|
helpText={t('presentation.field.presentation.help')}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContentDescriptionField(params: {
|
||||||
|
draft: AuthorPresentationDraft
|
||||||
|
onChange: (next: AuthorPresentationDraft) => void
|
||||||
|
}): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<ArticleField
|
||||||
|
id="contentDescription"
|
||||||
|
label={t('presentation.field.contentDescription')}
|
||||||
|
value={params.draft.contentDescription}
|
||||||
|
onChange={(value) => params.onChange({ ...params.draft, contentDescription: value as string })}
|
||||||
|
required
|
||||||
|
type="textarea"
|
||||||
|
rows={6}
|
||||||
|
placeholder={t('presentation.field.contentDescription.placeholder')}
|
||||||
|
helpText={t('presentation.field.contentDescription.help')}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MainnetAddressField(params: { draft: AuthorPresentationDraft; onChange: (next: AuthorPresentationDraft) => void }): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<ArticleField
|
||||||
|
id="mainnetAddress"
|
||||||
|
label={t('presentation.field.mainnetAddress')}
|
||||||
|
value={params.draft.mainnetAddress}
|
||||||
|
onChange={(value) => params.onChange({ ...params.draft, mainnetAddress: value as string })}
|
||||||
|
required
|
||||||
|
type="text"
|
||||||
|
placeholder={t('presentation.field.mainnetAddress.placeholder')}
|
||||||
|
helpText={t('presentation.field.mainnetAddress.help')}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AuthorNameField(params: { draft: AuthorPresentationDraft; onChange: (next: AuthorPresentationDraft) => void }): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<ArticleField
|
||||||
|
id="authorName"
|
||||||
|
label={t('presentation.field.authorName')}
|
||||||
|
value={params.draft.authorName}
|
||||||
|
onChange={(value) => params.onChange({ ...params.draft, authorName: value as string })}
|
||||||
|
required
|
||||||
|
type="text"
|
||||||
|
placeholder={t('presentation.field.authorName.placeholder')}
|
||||||
|
helpText={t('presentation.field.authorName.help')}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PictureField(params: { draft: AuthorPresentationDraft; onChange: (next: AuthorPresentationDraft) => void }): React.ReactElement {
|
||||||
|
return <ImageUploadField id="picture" value={params.draft.pictureUrl} onChange={(url) => params.onChange({ ...params.draft, pictureUrl: url })} />
|
||||||
|
}
|
||||||
7
components/authorPresentationEditor/types.ts
Normal file
7
components/authorPresentationEditor/types.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export interface AuthorPresentationDraft {
|
||||||
|
authorName: string
|
||||||
|
presentation: string
|
||||||
|
contentDescription: string
|
||||||
|
mainnetAddress: string
|
||||||
|
pictureUrl?: string
|
||||||
|
}
|
||||||
@ -0,0 +1,124 @@
|
|||||||
|
import { useCallback, useEffect, useState, type Dispatch, type FormEvent, type SetStateAction } from 'react'
|
||||||
|
import { useRouter } from 'next/router'
|
||||||
|
import { useAuthorPresentation } from '@/hooks/useAuthorPresentation'
|
||||||
|
import { extractPresentationData } from '@/lib/presentationParsing'
|
||||||
|
import { userConfirm } from '@/lib/userConfirm'
|
||||||
|
import { t } from '@/lib/i18n'
|
||||||
|
import type { Article } from '@/types/nostr'
|
||||||
|
import type { AuthorPresentationDraft } from './types'
|
||||||
|
import { validatePresentationDraft } from './validation'
|
||||||
|
|
||||||
|
export interface AuthorPresentationState {
|
||||||
|
draft: AuthorPresentationDraft
|
||||||
|
setDraft: (next: AuthorPresentationDraft) => void
|
||||||
|
validationError: string | null
|
||||||
|
error: string | null
|
||||||
|
loading: boolean
|
||||||
|
handleSubmit: (e: FormEvent<HTMLFormElement>) => Promise<void>
|
||||||
|
deleting: boolean
|
||||||
|
handleDelete: () => Promise<void>
|
||||||
|
success: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuthorPresentationState(
|
||||||
|
pubkey: string | null,
|
||||||
|
existingAuthorName: string | undefined,
|
||||||
|
existingPresentation: Article | null | undefined,
|
||||||
|
): AuthorPresentationState {
|
||||||
|
const { loading, error, success, publishPresentation, deletePresentation } = useAuthorPresentation(pubkey)
|
||||||
|
const router = useRouter()
|
||||||
|
const [draft, setDraft] = useState<AuthorPresentationDraft>(() => buildInitialDraft(existingPresentation, existingAuthorName))
|
||||||
|
const [validationError, setValidationError] = useState<string | null>(null)
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
syncAuthorNameIntoDraft({ existingAuthorName, draftAuthorName: draft.authorName, hasExistingPresentation: Boolean(existingPresentation), setDraft })
|
||||||
|
}, [existingAuthorName, existingPresentation, draft.authorName])
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
async (e: FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault()
|
||||||
|
await submitPresentationDraft({ draft, setValidationError, publishPresentation })
|
||||||
|
},
|
||||||
|
[draft, publishPresentation],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleDelete = useCallback(async () => {
|
||||||
|
await deletePresentationFlow({
|
||||||
|
existingPresentationId: existingPresentation?.id,
|
||||||
|
deletePresentation,
|
||||||
|
router,
|
||||||
|
setDeleting,
|
||||||
|
setValidationError,
|
||||||
|
})
|
||||||
|
}, [existingPresentation, deletePresentation, router])
|
||||||
|
|
||||||
|
return { loading, error, success, draft, setDraft, validationError, handleSubmit, deleting, handleDelete }
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildInitialDraft(existingPresentation: Article | null | undefined, existingAuthorName: string | undefined): AuthorPresentationDraft {
|
||||||
|
if (existingPresentation) {
|
||||||
|
const { presentation, contentDescription } = extractPresentationData(existingPresentation)
|
||||||
|
const authorName = existingPresentation.title.replace(/^Présentation de /, '') ?? existingAuthorName ?? ''
|
||||||
|
return {
|
||||||
|
authorName,
|
||||||
|
presentation,
|
||||||
|
contentDescription,
|
||||||
|
mainnetAddress: existingPresentation.mainnetAddress ?? '',
|
||||||
|
...(existingPresentation.bannerUrl ? { pictureUrl: existingPresentation.bannerUrl } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { authorName: existingAuthorName ?? '', presentation: '', contentDescription: '', mainnetAddress: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncAuthorNameIntoDraft(params: {
|
||||||
|
existingAuthorName: string | undefined
|
||||||
|
draftAuthorName: string
|
||||||
|
hasExistingPresentation: boolean
|
||||||
|
setDraft: Dispatch<SetStateAction<AuthorPresentationDraft>>
|
||||||
|
}): void {
|
||||||
|
if (!params.existingAuthorName || params.hasExistingPresentation || params.existingAuthorName === params.draftAuthorName) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
params.setDraft((prev) => ({ ...prev, authorName: params.existingAuthorName as string }))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitPresentationDraft(params: {
|
||||||
|
draft: AuthorPresentationDraft
|
||||||
|
setValidationError: (value: string | null) => void
|
||||||
|
publishPresentation: (draft: AuthorPresentationDraft) => Promise<void>
|
||||||
|
}): Promise<void> {
|
||||||
|
const validationError = validatePresentationDraft(params.draft)
|
||||||
|
if (validationError) {
|
||||||
|
params.setValidationError(validationError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
params.setValidationError(null)
|
||||||
|
await params.publishPresentation(params.draft)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deletePresentationFlow(params: {
|
||||||
|
existingPresentationId: string | undefined
|
||||||
|
deletePresentation: (articleId: string) => Promise<void>
|
||||||
|
router: ReturnType<typeof useRouter>
|
||||||
|
setDeleting: (value: boolean) => void
|
||||||
|
setValidationError: (value: string | null) => void
|
||||||
|
}): Promise<void> {
|
||||||
|
if (!params.existingPresentationId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const confirmed = await userConfirm(t('presentation.delete.confirm'))
|
||||||
|
if (!confirmed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
params.setDeleting(true)
|
||||||
|
params.setValidationError(null)
|
||||||
|
try {
|
||||||
|
await params.deletePresentation(params.existingPresentationId)
|
||||||
|
await params.router.push('/')
|
||||||
|
} catch (e) {
|
||||||
|
params.setValidationError(e instanceof Error ? e.message : t('presentation.delete.error'))
|
||||||
|
} finally {
|
||||||
|
params.setDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import type { Article } from '@/types/nostr'
|
||||||
|
|
||||||
|
export function useExistingPresentation(params: {
|
||||||
|
pubkey: string | null
|
||||||
|
checkPresentationExists: () => Promise<Article | null>
|
||||||
|
}): { existingPresentation: Article | null; loadingPresentation: boolean } {
|
||||||
|
const [existingPresentation, setExistingPresentation] = useState<Article | null>(null)
|
||||||
|
const [loadingPresentation, setLoadingPresentation] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadExistingPresentation({
|
||||||
|
pubkey: params.pubkey,
|
||||||
|
checkPresentationExists: params.checkPresentationExists,
|
||||||
|
setExistingPresentation,
|
||||||
|
setLoadingPresentation,
|
||||||
|
})
|
||||||
|
}, [params.pubkey, params.checkPresentationExists])
|
||||||
|
|
||||||
|
return { existingPresentation, loadingPresentation }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadExistingPresentation(params: {
|
||||||
|
pubkey: string | null
|
||||||
|
checkPresentationExists: () => Promise<Article | null>
|
||||||
|
setExistingPresentation: (value: Article | null) => void
|
||||||
|
setLoadingPresentation: (value: boolean) => void
|
||||||
|
}): Promise<void> {
|
||||||
|
if (!params.pubkey) {
|
||||||
|
params.setLoadingPresentation(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
params.setExistingPresentation(await params.checkPresentationExists())
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[AuthorPresentationEditor] Error loading presentation:', e)
|
||||||
|
} finally {
|
||||||
|
params.setLoadingPresentation(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
15
components/authorPresentationEditor/validation.ts
Normal file
15
components/authorPresentationEditor/validation.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { t } from '@/lib/i18n'
|
||||||
|
import type { AuthorPresentationDraft } from './types'
|
||||||
|
|
||||||
|
const ADDRESS_PATTERN = /^(1|3|bc1)[a-zA-Z0-9]{25,62}$/
|
||||||
|
|
||||||
|
export function validatePresentationDraft(draft: AuthorPresentationDraft): string | null {
|
||||||
|
const address = draft.mainnetAddress.trim()
|
||||||
|
if (!ADDRESS_PATTERN.test(address)) {
|
||||||
|
return t('presentation.validation.invalidAddress')
|
||||||
|
}
|
||||||
|
if (!draft.authorName.trim()) {
|
||||||
|
return t('presentation.validation.authorNameRequired')
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
130
components/keyManagement/KeyManagementImportForm.tsx
Normal file
130
components/keyManagement/KeyManagementImportForm.tsx
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
import { t } from '@/lib/i18n'
|
||||||
|
import type { KeyManagementManagerActions } from './useKeyManagementManager'
|
||||||
|
import type { KeyManagementManagerState } from './keyManagementController'
|
||||||
|
|
||||||
|
export function KeyManagementImportForm(params: {
|
||||||
|
state: KeyManagementManagerState
|
||||||
|
actions: KeyManagementManagerActions
|
||||||
|
}): React.ReactElement | null {
|
||||||
|
if (!params.state.showImportForm) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<KeyManagementImportWarning accountExists={params.state.accountExists} />
|
||||||
|
<KeyManagementImportTextarea importKey={params.state.importKey} onChangeImportKey={params.actions.onChangeImportKey} />
|
||||||
|
<KeyManagementReplaceWarning
|
||||||
|
show={params.state.showReplaceWarning}
|
||||||
|
importing={params.state.importing}
|
||||||
|
onCancel={params.actions.onDismissReplaceWarning}
|
||||||
|
onConfirm={params.actions.onConfirmReplace}
|
||||||
|
/>
|
||||||
|
<KeyManagementImportFormActions
|
||||||
|
show={!params.state.showReplaceWarning}
|
||||||
|
importing={params.state.importing}
|
||||||
|
onCancel={params.actions.onCancelImport}
|
||||||
|
onImport={params.actions.onImport}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function KeyManagementImportWarning(params: { accountExists: boolean }): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="bg-yellow-900/20 border border-yellow-400/50 rounded-lg p-4">
|
||||||
|
<p className="text-yellow-400 font-semibold mb-2">{t('settings.keyManagement.import.warning.title')}</p>
|
||||||
|
<p className="text-yellow-300/90 text-sm" dangerouslySetInnerHTML={{ __html: t('settings.keyManagement.import.warning.description') }} />
|
||||||
|
{params.accountExists ? (
|
||||||
|
<p className="text-yellow-300/90 text-sm mt-2" dangerouslySetInnerHTML={{ __html: t('settings.keyManagement.import.warning.replace') }} />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function KeyManagementImportTextarea(params: {
|
||||||
|
importKey: string
|
||||||
|
onChangeImportKey: (value: string) => void
|
||||||
|
}): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label htmlFor="importKey" className="block text-sm font-medium text-cyber-accent mb-2">
|
||||||
|
{t('settings.keyManagement.import.label')}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="importKey"
|
||||||
|
value={params.importKey}
|
||||||
|
onChange={(e) => {
|
||||||
|
params.onChangeImportKey(e.target.value)
|
||||||
|
}}
|
||||||
|
placeholder={t('settings.keyManagement.import.placeholder')}
|
||||||
|
className="w-full px-3 py-2 bg-cyber-dark border border-neon-cyan/30 rounded-lg font-mono text-sm text-neon-cyan focus:border-neon-cyan focus:outline-none"
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-cyber-accent/70 mt-2">{t('settings.keyManagement.import.help')}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function KeyManagementReplaceWarning(params: {
|
||||||
|
show: boolean
|
||||||
|
importing: boolean
|
||||||
|
onCancel: () => void
|
||||||
|
onConfirm: () => void
|
||||||
|
}): React.ReactElement | null {
|
||||||
|
if (!params.show) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="bg-red-900/20 border border-red-400/50 rounded-lg p-4">
|
||||||
|
<p className="text-red-400 font-semibold mb-2">{t('settings.keyManagement.replace.warning.title')}</p>
|
||||||
|
<p className="text-red-300/90 text-sm mb-4">{t('settings.keyManagement.replace.warning.description')}</p>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={params.onCancel}
|
||||||
|
className="flex-1 py-2 px-4 bg-cyber-light border border-neon-cyan/30 hover:border-neon-cyan/50 hover:bg-cyber-dark text-cyber-accent hover:text-neon-cyan rounded-lg font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{t('settings.keyManagement.replace.cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={params.onConfirm}
|
||||||
|
disabled={params.importing}
|
||||||
|
className="flex-1 py-2 px-4 bg-red-600/20 hover:bg-red-600/30 text-red-400 rounded-lg font-medium transition-all border border-red-400/50 hover:shadow-glow-red disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{params.importing ? t('settings.keyManagement.replace.replacing') : t('settings.keyManagement.replace.confirm')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function KeyManagementImportFormActions(params: {
|
||||||
|
show: boolean
|
||||||
|
importing: boolean
|
||||||
|
onCancel: () => void
|
||||||
|
onImport: () => void
|
||||||
|
}): React.ReactElement | null {
|
||||||
|
if (!params.show) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={params.onCancel}
|
||||||
|
className="flex-1 py-2 px-4 bg-cyber-light border border-neon-cyan/30 hover:border-neon-cyan/50 hover:bg-cyber-dark text-cyber-accent hover:text-neon-cyan rounded-lg font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{t('settings.keyManagement.import.cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={params.onImport}
|
||||||
|
disabled={params.importing}
|
||||||
|
className="flex-1 py-2 px-4 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"
|
||||||
|
>
|
||||||
|
{params.importing ? t('settings.keyManagement.import.importing') : t('settings.keyManagement.import.import')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
125
components/keyManagement/KeyManagementImportSection.tsx
Normal file
125
components/keyManagement/KeyManagementImportSection.tsx
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
import { t } from '@/lib/i18n'
|
||||||
|
import type { KeyManagementManagerActions } from './useKeyManagementManager'
|
||||||
|
import type { KeyManagementManagerState } from './keyManagementController'
|
||||||
|
import { KeyManagementImportForm } from './KeyManagementImportForm'
|
||||||
|
|
||||||
|
export function KeyManagementImportSection(params: {
|
||||||
|
state: KeyManagementManagerState
|
||||||
|
actions: KeyManagementManagerActions
|
||||||
|
}): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<KeyManagementErrorBanner error={params.state.error} />
|
||||||
|
<KeyManagementPublicKeysPanel
|
||||||
|
publicKeys={params.state.publicKeys}
|
||||||
|
copiedNpub={params.state.copiedNpub}
|
||||||
|
copiedPublicKey={params.state.copiedPublicKey}
|
||||||
|
onCopyNpub={params.actions.onCopyNpub}
|
||||||
|
onCopyPublicKey={params.actions.onCopyPublicKey}
|
||||||
|
/>
|
||||||
|
<KeyManagementNoAccountBanner publicKeys={params.state.publicKeys} accountExists={params.state.accountExists} />
|
||||||
|
<KeyManagementImportButton
|
||||||
|
accountExists={params.state.accountExists}
|
||||||
|
showImportForm={params.state.showImportForm}
|
||||||
|
onClick={params.actions.onShowImportForm}
|
||||||
|
/>
|
||||||
|
<KeyManagementImportForm state={params.state} actions={params.actions} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function KeyManagementErrorBanner(params: { error: string | null }): React.ReactElement | null {
|
||||||
|
if (!params.error) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="bg-red-900/20 border border-red-400/50 rounded-lg p-4 mb-4">
|
||||||
|
<p className="text-red-400">{params.error}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function KeyManagementPublicKeysPanel(params: {
|
||||||
|
publicKeys: KeyManagementManagerState['publicKeys']
|
||||||
|
copiedNpub: boolean
|
||||||
|
copiedPublicKey: boolean
|
||||||
|
onCopyNpub: () => void
|
||||||
|
onCopyPublicKey: () => void
|
||||||
|
}): React.ReactElement | null {
|
||||||
|
if (!params.publicKeys) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 mb-6">
|
||||||
|
<KeyManagementKeyCard
|
||||||
|
label={t('settings.keyManagement.publicKey.npub')}
|
||||||
|
value={params.publicKeys.npub}
|
||||||
|
copied={params.copiedNpub}
|
||||||
|
onCopy={params.onCopyNpub}
|
||||||
|
/>
|
||||||
|
<KeyManagementKeyCard
|
||||||
|
label={t('settings.keyManagement.publicKey.hex')}
|
||||||
|
value={params.publicKeys.publicKey}
|
||||||
|
copied={params.copiedPublicKey}
|
||||||
|
onCopy={params.onCopyPublicKey}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function KeyManagementKeyCard(params: {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
copied: boolean
|
||||||
|
onCopy: () => void
|
||||||
|
}): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="bg-neon-blue/10 border border-neon-blue/30 rounded-lg p-4">
|
||||||
|
<div className="flex justify-between items-start mb-2">
|
||||||
|
<p className="text-neon-blue font-semibold">{params.label}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={params.onCopy}
|
||||||
|
className="px-3 py-1 text-xs bg-cyber-light border border-neon-cyan/30 hover:border-neon-cyan/50 hover:bg-cyber-dark text-cyber-accent hover:text-neon-cyan rounded transition-colors"
|
||||||
|
>
|
||||||
|
{params.copied ? t('settings.keyManagement.copied') : t('settings.keyManagement.copy')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-neon-cyan text-sm font-mono break-all">{params.value}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function KeyManagementNoAccountBanner(params: {
|
||||||
|
publicKeys: KeyManagementManagerState['publicKeys']
|
||||||
|
accountExists: boolean
|
||||||
|
}): React.ReactElement | null {
|
||||||
|
if (params.publicKeys || params.accountExists) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="bg-yellow-900/20 border border-yellow-400/50 rounded-lg p-4 mb-6">
|
||||||
|
<p className="text-yellow-400 font-semibold mb-2">{t('settings.keyManagement.noAccount.title')}</p>
|
||||||
|
<p className="text-yellow-300/90 text-sm">{t('settings.keyManagement.noAccount.description')}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function KeyManagementImportButton(params: {
|
||||||
|
accountExists: boolean
|
||||||
|
showImportForm: boolean
|
||||||
|
onClick: () => void
|
||||||
|
}): React.ReactElement | null {
|
||||||
|
if (params.showImportForm) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={params.onClick}
|
||||||
|
className="w-full py-3 px-6 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"
|
||||||
|
>
|
||||||
|
{params.accountExists ? t('settings.keyManagement.import.button.replace') : t('settings.keyManagement.import.button.new')}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
28
components/keyManagement/KeyManagementManager.tsx
Normal file
28
components/keyManagement/KeyManagementManager.tsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { t } from '@/lib/i18n'
|
||||||
|
import { SyncProgressBar } from '../SyncProgressBar'
|
||||||
|
import { KeyManagementImportSection } from './KeyManagementImportSection'
|
||||||
|
import { KeyManagementRecoverySection } from './KeyManagementRecoverySection'
|
||||||
|
import { useKeyManagementManager } from './useKeyManagementManager'
|
||||||
|
|
||||||
|
export function KeyManagementManager(): React.ReactElement {
|
||||||
|
const { state, actions } = useKeyManagementManager()
|
||||||
|
|
||||||
|
if (state.loading) {
|
||||||
|
return (
|
||||||
|
<div className="bg-cyber-darker border border-neon-cyan/30 rounded-lg p-6">
|
||||||
|
<p className="text-cyber-accent">{t('settings.keyManagement.loading')}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="bg-cyber-darker border border-neon-cyan/30 rounded-lg p-6">
|
||||||
|
<h2 className="text-2xl font-bold text-neon-cyan mb-4">{t('settings.keyManagement.title')}</h2>
|
||||||
|
<KeyManagementImportSection state={state} actions={actions} />
|
||||||
|
<SyncProgressBar />
|
||||||
|
<KeyManagementRecoverySection state={state} actions={actions} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
98
components/keyManagement/KeyManagementRecoverySection.tsx
Normal file
98
components/keyManagement/KeyManagementRecoverySection.tsx
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import { t } from '@/lib/i18n'
|
||||||
|
import type { KeyManagementManagerActions } from './useKeyManagementManager'
|
||||||
|
import type { KeyManagementManagerState } from './keyManagementController'
|
||||||
|
|
||||||
|
export function KeyManagementRecoverySection(params: {
|
||||||
|
state: KeyManagementManagerState
|
||||||
|
actions: KeyManagementManagerActions
|
||||||
|
}): React.ReactElement | null {
|
||||||
|
if (!params.state.recoveryPhrase || !params.state.newNpub) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="mt-6 space-y-4">
|
||||||
|
<KeyManagementRecoveryWarning />
|
||||||
|
<div className="bg-cyber-darker border border-neon-cyan/30 rounded-lg p-6">
|
||||||
|
<RecoveryWordsGrid recoveryPhrase={params.state.recoveryPhrase} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={params.actions.onCopyRecoveryPhrase}
|
||||||
|
className="w-full py-2 px-4 bg-cyber-light border border-neon-cyan/30 hover:border-neon-cyan/50 hover:bg-cyber-dark text-cyber-accent hover:text-neon-cyan rounded-lg text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{params.state.copiedRecoveryPhrase ? t('settings.keyManagement.recovery.copied') : t('settings.keyManagement.recovery.copy')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<KeyManagementNewNpubCard newNpub={params.state.newNpub} />
|
||||||
|
<KeyManagementDoneButton onDone={params.actions.onDoneRecovery} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function KeyManagementRecoveryWarning(): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="bg-yellow-900/20 border border-yellow-400/50 rounded-lg p-4">
|
||||||
|
<p className="text-yellow-400 font-semibold mb-2">{t('settings.keyManagement.recovery.warning.title')}</p>
|
||||||
|
<p className="text-yellow-300/90 text-sm" dangerouslySetInnerHTML={{ __html: t('settings.keyManagement.recovery.warning.part1') }} />
|
||||||
|
<p className="text-yellow-300/90 text-sm mt-2" dangerouslySetInnerHTML={{ __html: t('settings.keyManagement.recovery.warning.part2') }} />
|
||||||
|
<p className="text-yellow-300/90 text-sm mt-2">{t('settings.keyManagement.recovery.warning.part3')}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function KeyManagementNewNpubCard(params: { newNpub: string }): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="bg-neon-blue/10 border border-neon-blue/30 rounded-lg p-4">
|
||||||
|
<p className="text-neon-blue font-semibold mb-2">{t('settings.keyManagement.recovery.newNpub')}</p>
|
||||||
|
<p className="text-neon-cyan text-sm font-mono break-all">{params.newNpub}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function KeyManagementDoneButton(params: { onDone: () => void }): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={params.onDone}
|
||||||
|
className="w-full py-2 px-4 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"
|
||||||
|
>
|
||||||
|
{t('settings.keyManagement.recovery.done')}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecoveryWordsGrid(params: { recoveryPhrase: string[] }): React.ReactElement {
|
||||||
|
const items = buildRecoveryWordItems(params.recoveryPhrase)
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="bg-cyber-dark border border-neon-cyan/30 rounded-lg p-3 text-center font-mono text-lg"
|
||||||
|
>
|
||||||
|
<span className="text-cyber-accent/70 text-sm mr-2">{item.position}.</span>
|
||||||
|
<span className="font-semibold text-neon-cyan">{item.word}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RecoveryWordItem {
|
||||||
|
id: string
|
||||||
|
position: number
|
||||||
|
word: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRecoveryWordItems(recoveryPhrase: readonly string[]): RecoveryWordItem[] {
|
||||||
|
const occurrences = new Map<string, number>()
|
||||||
|
const items: RecoveryWordItem[] = []
|
||||||
|
let position = 1
|
||||||
|
for (const word of recoveryPhrase) {
|
||||||
|
const current = occurrences.get(word) ?? 0
|
||||||
|
const next = current + 1
|
||||||
|
occurrences.set(word, next)
|
||||||
|
items.push({ id: `${word}-${next}`, position, word })
|
||||||
|
position += 1
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
53
components/keyManagement/keyImportParsing.ts
Normal file
53
components/keyManagement/keyImportParsing.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { nip19 } from 'nostr-tools'
|
||||||
|
|
||||||
|
export function extractKeyFromInput(rawInput: string): string | null {
|
||||||
|
const trimmed = rawInput.trim()
|
||||||
|
if (trimmed.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const fromUrl = extractKeyFromUrl(trimmed)
|
||||||
|
return fromUrl ?? extractKeyFromText(trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractKeyFromUrl(url: string): string | null {
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(url)
|
||||||
|
const {protocol} = urlObj
|
||||||
|
if (protocol === 'nostr:' || protocol === 'nostr://') {
|
||||||
|
const path = urlObj.pathname ?? urlObj.href.replace(/^nostr:?\/\//, '')
|
||||||
|
if (path.startsWith('nsec')) {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const nsecMatch = url.match(/nsec1[a-z0-9]+/i)
|
||||||
|
return nsecMatch?.[0] ?? null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractKeyFromText(text: string): string | null {
|
||||||
|
const nsec = extractNsec(text)
|
||||||
|
if (nsec) {
|
||||||
|
return nsec
|
||||||
|
}
|
||||||
|
const trimmed = text.trim()
|
||||||
|
return trimmed.length > 0 ? trimmed : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractNsec(text: string): string | null {
|
||||||
|
const nsecMatch = text.match(/nsec1[a-z0-9]+/i)
|
||||||
|
return nsecMatch?.[0] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidPrivateKeyFormat(key: string): boolean {
|
||||||
|
try {
|
||||||
|
const decoded = nip19.decode(key)
|
||||||
|
if (decoded.type !== 'nsec') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return typeof decoded.data === 'string' || decoded.data instanceof Uint8Array
|
||||||
|
} catch {
|
||||||
|
return /^[0-9a-f]{64}$/i.test(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
154
components/keyManagement/keyManagementController.ts
Normal file
154
components/keyManagement/keyManagementController.ts
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
import { nostrAuthService } from '@/lib/nostrAuth'
|
||||||
|
import { keyManagementService } from '@/lib/keyManagement'
|
||||||
|
import { t } from '@/lib/i18n'
|
||||||
|
import type { PublicKeys } from './types'
|
||||||
|
import { extractKeyFromInput, isValidPrivateKeyFormat } from './keyImportParsing'
|
||||||
|
|
||||||
|
export interface KeyManagementManagerState {
|
||||||
|
publicKeys: PublicKeys | null
|
||||||
|
accountExists: boolean
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
importKey: string
|
||||||
|
importing: boolean
|
||||||
|
showImportForm: boolean
|
||||||
|
showReplaceWarning: boolean
|
||||||
|
recoveryPhrase: string[] | null
|
||||||
|
newNpub: string | null
|
||||||
|
copiedNpub: boolean
|
||||||
|
copiedPublicKey: boolean
|
||||||
|
copiedRecoveryPhrase: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PatchState = (patch: Partial<KeyManagementManagerState>) => void
|
||||||
|
|
||||||
|
export const INITIAL_KEY_MANAGEMENT_STATE: KeyManagementManagerState = {
|
||||||
|
publicKeys: null,
|
||||||
|
accountExists: false,
|
||||||
|
loading: true,
|
||||||
|
error: null,
|
||||||
|
importKey: '',
|
||||||
|
importing: false,
|
||||||
|
showImportForm: false,
|
||||||
|
showReplaceWarning: false,
|
||||||
|
recoveryPhrase: null,
|
||||||
|
newNpub: null,
|
||||||
|
copiedNpub: false,
|
||||||
|
copiedPublicKey: false,
|
||||||
|
copiedRecoveryPhrase: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadKeys(params: { patchState: PatchState }): Promise<void> {
|
||||||
|
try {
|
||||||
|
params.patchState({ loading: true, error: null })
|
||||||
|
const exists = await nostrAuthService.accountExists()
|
||||||
|
params.patchState({ accountExists: exists })
|
||||||
|
if (!exists) {
|
||||||
|
params.patchState({ publicKeys: null })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const keys = await keyManagementService.getPublicKeys()
|
||||||
|
params.patchState({ publicKeys: keys ?? null })
|
||||||
|
} catch (e) {
|
||||||
|
const errorMessage = e instanceof Error ? e.message : t('settings.keyManagement.loading')
|
||||||
|
params.patchState({ error: errorMessage })
|
||||||
|
console.error('[KeyManagement] Error loading keys:', e)
|
||||||
|
} finally {
|
||||||
|
params.patchState({ loading: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleImport(params: {
|
||||||
|
state: KeyManagementManagerState
|
||||||
|
patchState: PatchState
|
||||||
|
}): Promise<void> {
|
||||||
|
const extractedKey = validateAndExtractImportKey({ importKey: params.state.importKey, patchState: params.patchState })
|
||||||
|
if (!extractedKey) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (params.state.accountExists) {
|
||||||
|
params.patchState({ showReplaceWarning: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await performImport({ key: extractedKey, accountExists: false, patchState: params.patchState })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function confirmReplace(params: {
|
||||||
|
state: KeyManagementManagerState
|
||||||
|
patchState: PatchState
|
||||||
|
}): Promise<void> {
|
||||||
|
const extractedKey = validateAndExtractImportKey({ importKey: params.state.importKey, patchState: params.patchState })
|
||||||
|
if (!extractedKey) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await performImport({ key: extractedKey, accountExists: true, patchState: params.patchState })
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateAndExtractImportKey(params: { importKey: string; patchState: PatchState }): string | null {
|
||||||
|
if (!params.importKey.trim()) {
|
||||||
|
params.patchState({ error: t('settings.keyManagement.import.error.required') })
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const extractedKey = extractKeyFromInput(params.importKey)
|
||||||
|
if (!extractedKey) {
|
||||||
|
params.patchState({ error: t('settings.keyManagement.import.error.invalid') })
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (!isValidPrivateKeyFormat(extractedKey)) {
|
||||||
|
params.patchState({ error: t('settings.keyManagement.import.error.invalid') })
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return extractedKey
|
||||||
|
}
|
||||||
|
|
||||||
|
async function performImport(params: {
|
||||||
|
key: string
|
||||||
|
accountExists: boolean
|
||||||
|
patchState: PatchState
|
||||||
|
}): Promise<void> {
|
||||||
|
try {
|
||||||
|
params.patchState({ importing: true, error: null, showReplaceWarning: false })
|
||||||
|
if (params.accountExists) {
|
||||||
|
await nostrAuthService.deleteAccount()
|
||||||
|
}
|
||||||
|
const result = await nostrAuthService.createAccount(params.key)
|
||||||
|
params.patchState({
|
||||||
|
recoveryPhrase: result.recoveryPhrase,
|
||||||
|
newNpub: result.npub,
|
||||||
|
importKey: '',
|
||||||
|
showImportForm: false,
|
||||||
|
})
|
||||||
|
await loadKeys({ patchState: params.patchState })
|
||||||
|
await maybeStartUserSync({ publicKey: result.publicKey })
|
||||||
|
} catch (e) {
|
||||||
|
const errorMessage = e instanceof Error ? e.message : t('settings.keyManagement.import.error.failed')
|
||||||
|
params.patchState({ error: errorMessage })
|
||||||
|
console.error('[KeyManagement] Error importing key:', e)
|
||||||
|
} finally {
|
||||||
|
params.patchState({ importing: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function maybeStartUserSync(params: { publicKey: string | undefined }): Promise<void> {
|
||||||
|
if (!params.publicKey) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const { swClient } = await import('@/lib/swClient')
|
||||||
|
const isReady = await swClient.isReady()
|
||||||
|
if (isReady) {
|
||||||
|
void swClient.startUserSync(params.publicKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function copyToClipboard(params: {
|
||||||
|
text: string
|
||||||
|
onCopied: () => void
|
||||||
|
onCopyFailed: (error: unknown) => void
|
||||||
|
}): Promise<void> {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(params.text)
|
||||||
|
params.onCopied()
|
||||||
|
} catch (e) {
|
||||||
|
params.onCopyFailed(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
4
components/keyManagement/types.ts
Normal file
4
components/keyManagement/types.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export interface PublicKeys {
|
||||||
|
publicKey: string
|
||||||
|
npub: string
|
||||||
|
}
|
||||||
140
components/keyManagement/useKeyManagementManager.ts
Normal file
140
components/keyManagement/useKeyManagementManager.ts
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import {
|
||||||
|
INITIAL_KEY_MANAGEMENT_STATE,
|
||||||
|
confirmReplace,
|
||||||
|
copyToClipboard,
|
||||||
|
handleImport,
|
||||||
|
loadKeys,
|
||||||
|
type KeyManagementManagerState,
|
||||||
|
type PatchState,
|
||||||
|
} from './keyManagementController'
|
||||||
|
|
||||||
|
export interface KeyManagementManagerActions {
|
||||||
|
onChangeImportKey: (value: string) => void
|
||||||
|
onShowImportForm: () => void
|
||||||
|
onCancelImport: () => void
|
||||||
|
onImport: () => void
|
||||||
|
onDismissReplaceWarning: () => void
|
||||||
|
onConfirmReplace: () => void
|
||||||
|
onCopyNpub: () => void
|
||||||
|
onCopyPublicKey: () => void
|
||||||
|
onCopyRecoveryPhrase: () => void
|
||||||
|
onDoneRecovery: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseKeyManagementManagerResult {
|
||||||
|
state: KeyManagementManagerState
|
||||||
|
actions: KeyManagementManagerActions
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useKeyManagementManager(): UseKeyManagementManagerResult {
|
||||||
|
const [state, setState] = useState<KeyManagementManagerState>(INITIAL_KEY_MANAGEMENT_STATE)
|
||||||
|
const patchState = useMemo((): PatchState => createPatchState(setState), [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadKeys({ patchState })
|
||||||
|
}, [patchState])
|
||||||
|
|
||||||
|
const actions = useMemo((): KeyManagementManagerActions => createActions({ state, patchState }), [state, patchState])
|
||||||
|
|
||||||
|
return { state, actions }
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPatchState(setState: React.Dispatch<React.SetStateAction<KeyManagementManagerState>>): PatchState {
|
||||||
|
return (patch: Partial<KeyManagementManagerState>): void => {
|
||||||
|
setState((previous) => ({ ...previous, ...patch }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createActions(params: { state: KeyManagementManagerState; patchState: PatchState }): KeyManagementManagerActions {
|
||||||
|
return {
|
||||||
|
onChangeImportKey: (value: string): void => {
|
||||||
|
params.patchState({ importKey: value, error: null })
|
||||||
|
},
|
||||||
|
onShowImportForm: (): void => {
|
||||||
|
params.patchState({ showImportForm: true, error: null })
|
||||||
|
},
|
||||||
|
onCancelImport: (): void => {
|
||||||
|
params.patchState({ showImportForm: false, importKey: '', error: null, showReplaceWarning: false })
|
||||||
|
},
|
||||||
|
onImport: (): void => {
|
||||||
|
void handleImport({ state: params.state, patchState: params.patchState })
|
||||||
|
},
|
||||||
|
onDismissReplaceWarning: (): void => {
|
||||||
|
params.patchState({ showReplaceWarning: false })
|
||||||
|
},
|
||||||
|
onConfirmReplace: (): void => {
|
||||||
|
void confirmReplace({ state: params.state, patchState: params.patchState })
|
||||||
|
},
|
||||||
|
onCopyNpub: (): void => {
|
||||||
|
void copyNpub({ state: params.state, patchState: params.patchState })
|
||||||
|
},
|
||||||
|
onCopyPublicKey: (): void => {
|
||||||
|
void copyPublicKey({ state: params.state, patchState: params.patchState })
|
||||||
|
},
|
||||||
|
onCopyRecoveryPhrase: (): void => {
|
||||||
|
void copyRecoveryPhrase({ state: params.state, patchState: params.patchState })
|
||||||
|
},
|
||||||
|
onDoneRecovery: (): void => {
|
||||||
|
params.patchState({ recoveryPhrase: null, newNpub: null })
|
||||||
|
void loadKeys({ patchState: params.patchState })
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyNpub(params: { state: KeyManagementManagerState; patchState: PatchState }): Promise<void> {
|
||||||
|
const npub = params.state.publicKeys?.npub
|
||||||
|
if (!npub) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await copyToClipboard({
|
||||||
|
text: npub,
|
||||||
|
onCopied: () => {
|
||||||
|
params.patchState({ copiedNpub: true })
|
||||||
|
scheduleResetCopiedFlag(() => params.patchState({ copiedNpub: false }))
|
||||||
|
},
|
||||||
|
onCopyFailed: (e) => {
|
||||||
|
console.error('[KeyManagement] Error copying npub:', e)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyPublicKey(params: { state: KeyManagementManagerState; patchState: PatchState }): Promise<void> {
|
||||||
|
const publicKey = params.state.publicKeys?.publicKey
|
||||||
|
if (!publicKey) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await copyToClipboard({
|
||||||
|
text: publicKey,
|
||||||
|
onCopied: () => {
|
||||||
|
params.patchState({ copiedPublicKey: true })
|
||||||
|
scheduleResetCopiedFlag(() => params.patchState({ copiedPublicKey: false }))
|
||||||
|
},
|
||||||
|
onCopyFailed: (e) => {
|
||||||
|
console.error('[KeyManagement] Error copying public key:', e)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyRecoveryPhrase(params: { state: KeyManagementManagerState; patchState: PatchState }): Promise<void> {
|
||||||
|
const {recoveryPhrase} = params.state
|
||||||
|
if (!recoveryPhrase) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await copyToClipboard({
|
||||||
|
text: recoveryPhrase.join(' '),
|
||||||
|
onCopied: () => {
|
||||||
|
params.patchState({ copiedRecoveryPhrase: true })
|
||||||
|
scheduleResetCopiedFlag(() => params.patchState({ copiedRecoveryPhrase: false }))
|
||||||
|
},
|
||||||
|
onCopyFailed: (e) => {
|
||||||
|
console.error('[KeyManagement] Error copying recovery phrase:', e)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleResetCopiedFlag(reset: () => void): void {
|
||||||
|
setTimeout(() => {
|
||||||
|
reset()
|
||||||
|
}, 2000)
|
||||||
|
}
|
||||||
158
components/markdownEditorTwoColumns/MarkdownEditorTwoColumns.tsx
Normal file
158
components/markdownEditorTwoColumns/MarkdownEditorTwoColumns.tsx
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import type { MediaRef, Page } from '@/types/nostr'
|
||||||
|
import { t } from '@/lib/i18n'
|
||||||
|
import { createPagesHandlers, PagesManager } from './PagesManager'
|
||||||
|
import { createImageUploadHandler } from './imageUpload'
|
||||||
|
|
||||||
|
export interface MarkdownEditorTwoColumnsProps {
|
||||||
|
value: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
pages?: Page[]
|
||||||
|
onPagesChange?: (pages: Page[]) => void
|
||||||
|
onMediaAdd?: (media: MediaRef) => void
|
||||||
|
onBannerChange?: (url: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MarkdownEditorTwoColumns(props: MarkdownEditorTwoColumnsProps): React.ReactElement {
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const pages = props.pages ?? []
|
||||||
|
const pagesHandlers = createPagesHandlers({ pages, onPagesChange: props.onPagesChange })
|
||||||
|
const handleImageUpload = createImageUploadHandler({
|
||||||
|
setError,
|
||||||
|
setUploading,
|
||||||
|
onMediaAdd: props.onMediaAdd,
|
||||||
|
onBannerChange: props.onBannerChange,
|
||||||
|
onSetPageImageUrl: pagesHandlers.setPageContent,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<MarkdownToolbar
|
||||||
|
onFileSelected={(file) => {
|
||||||
|
void handleImageUpload({ file })
|
||||||
|
}}
|
||||||
|
uploading={uploading}
|
||||||
|
error={error}
|
||||||
|
{...(props.onPagesChange ? { onAddPage: pagesHandlers.addPage } : {})}
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<EditorColumn value={props.value} onChange={props.onChange} />
|
||||||
|
<PreviewColumn value={props.value} />
|
||||||
|
</div>
|
||||||
|
{props.onPagesChange ? (
|
||||||
|
<PagesManager
|
||||||
|
pages={pages}
|
||||||
|
onPageContentChange={pagesHandlers.setPageContent}
|
||||||
|
onPageTypeChange={pagesHandlers.setPageType}
|
||||||
|
onRemovePage={pagesHandlers.removePage}
|
||||||
|
onImageUpload={async (file, pageNumber) => {
|
||||||
|
await handleImageUpload({ file, pageNumber })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MarkdownToolbar(params: {
|
||||||
|
onFileSelected: (file: File) => void
|
||||||
|
uploading: boolean
|
||||||
|
error: string | null
|
||||||
|
onAddPage?: (type: 'markdown' | 'image') => void
|
||||||
|
}): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<ToolbarUploadButton onFileSelected={params.onFileSelected} />
|
||||||
|
<ToolbarAddPageButtons onAddPage={params.onAddPage} />
|
||||||
|
<ToolbarStatus uploading={params.uploading} error={params.error} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditorColumn(params: { value: string; onChange: (value: string) => void }): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-semibold text-gray-800">{t('markdown.editor')}</label>
|
||||||
|
<textarea
|
||||||
|
className="w-full border rounded p-3 h-96 font-mono text-sm"
|
||||||
|
value={params.value}
|
||||||
|
onChange={(e) => params.onChange(e.target.value)}
|
||||||
|
placeholder={t('markdown.placeholder')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PreviewColumn(params: { value: string }): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-semibold text-gray-800">{t('markdown.preview')}</label>
|
||||||
|
<MarkdownPreview value={params.value} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MarkdownPreview(params: { value: string }): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="prose max-w-none border rounded p-3 bg-white h-96 overflow-y-auto whitespace-pre-wrap">
|
||||||
|
{params.value || <span className="text-gray-400">{t('markdown.preview.empty')}</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolbarUploadButton(params: { onFileSelected: (file: File) => void }): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<label className="px-3 py-1 text-sm rounded bg-blue-600 text-white cursor-pointer hover:bg-blue-700">
|
||||||
|
{t('markdown.upload.media')}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".png,.jpg,.jpeg,.webp"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (file) {
|
||||||
|
params.onFileSelected(file)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolbarAddPageButtons(params: { onAddPage: ((type: 'markdown' | 'image') => void) | undefined }): React.ReactElement | null {
|
||||||
|
if (!params.onAddPage) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="px-3 py-1 text-sm rounded bg-green-600 text-white hover:bg-green-700"
|
||||||
|
onClick={() => params.onAddPage?.('markdown')}
|
||||||
|
>
|
||||||
|
{t('page.add.markdown')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="px-3 py-1 text-sm rounded bg-purple-600 text-white hover:bg-purple-700"
|
||||||
|
onClick={() => params.onAddPage?.('image')}
|
||||||
|
>
|
||||||
|
{t('page.add.image')}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolbarStatus(params: { uploading: boolean; error: string | null }): React.ReactElement | null {
|
||||||
|
if (!params.uploading && !params.error) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{params.uploading ? <span className="text-sm text-gray-500">{t('markdown.upload.uploading')}</span> : null}
|
||||||
|
{params.error ? <span className="text-sm text-red-600">{params.error}</span> : null}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
165
components/markdownEditorTwoColumns/PagesManager.tsx
Normal file
165
components/markdownEditorTwoColumns/PagesManager.tsx
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
import { t } from '@/lib/i18n'
|
||||||
|
import type { Page } from '@/types/nostr'
|
||||||
|
|
||||||
|
export function PagesManager(params: {
|
||||||
|
pages: Page[]
|
||||||
|
onPageContentChange: (pageNumber: number, content: string) => void
|
||||||
|
onPageTypeChange: (pageNumber: number, type: 'markdown' | 'image') => void
|
||||||
|
onRemovePage: (pageNumber: number) => void
|
||||||
|
onImageUpload: (file: File, pageNumber: number) => Promise<void>
|
||||||
|
}): React.ReactElement {
|
||||||
|
if (params.pages.length === 0) {
|
||||||
|
return <div className="text-sm text-gray-500">{t('page.empty')}</div>
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold">{t('page.title')}</h3>
|
||||||
|
{params.pages.map((page) => (
|
||||||
|
<PageEditor
|
||||||
|
key={page.number}
|
||||||
|
page={page}
|
||||||
|
onContentChange={(content) => params.onPageContentChange(page.number, content)}
|
||||||
|
onTypeChange={(type) => params.onPageTypeChange(page.number, type)}
|
||||||
|
onRemove={() => params.onRemovePage(page.number)}
|
||||||
|
onImageUpload={async (file) => {
|
||||||
|
await params.onImageUpload(file, page.number)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PageEditor(params: {
|
||||||
|
page: Page
|
||||||
|
onContentChange: (content: string) => void
|
||||||
|
onTypeChange: (type: 'markdown' | 'image') => void
|
||||||
|
onRemove: () => void
|
||||||
|
onImageUpload: (file: File) => Promise<void>
|
||||||
|
}): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="border rounded-lg p-4 space-y-3">
|
||||||
|
<PageEditorHeader page={params.page} onTypeChange={params.onTypeChange} onRemove={params.onRemove} />
|
||||||
|
<PageEditorBody page={params.page} onContentChange={params.onContentChange} onImageUpload={params.onImageUpload} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PageEditorHeader(params: { page: Page; onTypeChange: (type: 'markdown' | 'image') => void; onRemove: () => void }): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h4 className="font-semibold">
|
||||||
|
{t('page.number', { number: params.page.number })} - {t(`page.type.${params.page.type}`)}
|
||||||
|
</h4>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={params.page.type}
|
||||||
|
onChange={(e) => params.onTypeChange(e.target.value as 'markdown' | 'image')}
|
||||||
|
className="text-sm border rounded px-2 py-1"
|
||||||
|
>
|
||||||
|
<option value="markdown">{t('page.type.markdown')}</option>
|
||||||
|
<option value="image">{t('page.type.image')}</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="px-2 py-1 text-sm rounded bg-red-600 text-white hover:bg-red-700"
|
||||||
|
onClick={params.onRemove}
|
||||||
|
>
|
||||||
|
{t('page.remove')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PageEditorBody(params: { page: Page; onContentChange: (content: string) => void; onImageUpload: (file: File) => Promise<void> }): React.ReactElement {
|
||||||
|
if (params.page.type === 'markdown') {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
className="w-full border rounded p-2 h-48 font-mono text-sm"
|
||||||
|
value={params.page.content}
|
||||||
|
onChange={(e) => params.onContentChange(e.target.value)}
|
||||||
|
placeholder={t('page.markdown.placeholder')}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return <PageEditorImageBody page={params.page} onContentChange={params.onContentChange} onImageUpload={params.onImageUpload} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PageEditorImageBody(params: { page: Page; onContentChange: (content: string) => void; onImageUpload: (file: File) => Promise<void> }): React.ReactElement {
|
||||||
|
if (params.page.content) {
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<img src={params.page.content} alt={t('page.image.alt', { number: params.page.number })} className="max-w-full h-auto rounded" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute top-2 right-2 px-2 py-1 text-sm rounded bg-red-600 text-white hover:bg-red-700"
|
||||||
|
onClick={() => params.onContentChange('')}
|
||||||
|
>
|
||||||
|
{t('page.image.remove')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return <PageImageUploadButton onFileSelected={params.onImageUpload} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PageImageUploadButton(params: { onFileSelected: (file: File) => Promise<void> }): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<label className="block px-3 py-2 text-sm rounded bg-blue-600 text-white cursor-pointer hover:bg-blue-700 text-center">
|
||||||
|
{t('page.image.upload')}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".png,.jpg,.jpeg,.webp"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (file) {
|
||||||
|
void params.onFileSelected(file)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPagesHandlers(params: {
|
||||||
|
pages: Page[]
|
||||||
|
onPagesChange: ((pages: Page[]) => void) | undefined
|
||||||
|
}): {
|
||||||
|
addPage: (type: 'markdown' | 'image') => void
|
||||||
|
setPageContent: (pageNumber: number, content: string) => void
|
||||||
|
setPageType: (pageNumber: number, type: 'markdown' | 'image') => void
|
||||||
|
removePage: (pageNumber: number) => void
|
||||||
|
} {
|
||||||
|
const update = (next: Page[]): void => {
|
||||||
|
params.onPagesChange?.(next)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
addPage: (type: 'markdown' | 'image'): void => {
|
||||||
|
if (!params.onPagesChange) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const newPage: Page = { number: params.pages.length + 1, type, content: '' }
|
||||||
|
update([...params.pages, newPage])
|
||||||
|
},
|
||||||
|
setPageContent: (pageNumber: number, content: string): void => {
|
||||||
|
if (!params.onPagesChange) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
update(params.pages.map((p) => (p.number === pageNumber ? { ...p, content } : p)))
|
||||||
|
},
|
||||||
|
setPageType: (pageNumber: number, type: 'markdown' | 'image'): void => {
|
||||||
|
if (!params.onPagesChange) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
update(params.pages.map((p) => (p.number === pageNumber ? { ...p, type, content: '' } : p)))
|
||||||
|
},
|
||||||
|
removePage: (pageNumber: number): void => {
|
||||||
|
if (!params.onPagesChange) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
update(params.pages.filter((p) => p.number !== pageNumber).map((p, idx) => ({ ...p, number: idx + 1 })))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
32
components/markdownEditorTwoColumns/imageUpload.ts
Normal file
32
components/markdownEditorTwoColumns/imageUpload.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import type { MediaRef } from '@/types/nostr'
|
||||||
|
import { uploadNip95Media } from '@/lib/nip95'
|
||||||
|
import { t } from '@/lib/i18n'
|
||||||
|
|
||||||
|
export function createImageUploadHandler(params: {
|
||||||
|
setError: (value: string | null) => void
|
||||||
|
setUploading: (value: boolean) => void
|
||||||
|
onMediaAdd: ((media: MediaRef) => void) | undefined
|
||||||
|
onBannerChange: ((url: string) => void) | undefined
|
||||||
|
onSetPageImageUrl: (pageNumber: number, url: string) => void
|
||||||
|
}): (args: { file: File; pageNumber?: number }) => Promise<void> {
|
||||||
|
return async (args): Promise<void> => {
|
||||||
|
params.setError(null)
|
||||||
|
params.setUploading(true)
|
||||||
|
try {
|
||||||
|
const media = await uploadNip95Media(args.file)
|
||||||
|
if (media.type !== 'image') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (args.pageNumber !== undefined) {
|
||||||
|
params.onSetPageImageUrl(args.pageNumber, media.url)
|
||||||
|
} else {
|
||||||
|
params.onBannerChange?.(media.url)
|
||||||
|
}
|
||||||
|
params.onMediaAdd?.(media)
|
||||||
|
} catch (e) {
|
||||||
|
params.setError(e instanceof Error ? e.message : t('upload.error.failed'))
|
||||||
|
} finally {
|
||||||
|
params.setUploading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
155
components/nip95Config/Nip95ApiCard.tsx
Normal file
155
components/nip95Config/Nip95ApiCard.tsx
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
import type { Nip95Config } from '@/lib/configStorageTypes'
|
||||||
|
import { t } from '@/lib/i18n'
|
||||||
|
import { DragHandle } from '../DragHandle'
|
||||||
|
import { getApiCardClassName } from './getApiCardClassName'
|
||||||
|
|
||||||
|
export function Nip95ApiCard(params: {
|
||||||
|
api: Nip95Config
|
||||||
|
priority: number
|
||||||
|
isEditing: boolean
|
||||||
|
draggedId: string | null
|
||||||
|
dragOverId: string | null
|
||||||
|
onStartEditing: (id: string) => void
|
||||||
|
onStopEditing: () => void
|
||||||
|
onUpdateUrl: (id: string, url: string) => void
|
||||||
|
onToggleEnabled: (id: string, enabled: boolean) => void
|
||||||
|
onRemoveApi: (id: string) => void
|
||||||
|
onDragStart: (e: React.DragEvent<HTMLDivElement>, id: string) => void
|
||||||
|
onDragOver: (e: React.DragEvent<HTMLDivElement>, id: string) => void
|
||||||
|
onDragLeave: () => void
|
||||||
|
onDrop: (e: React.DragEvent<HTMLDivElement>, targetId: string) => void
|
||||||
|
}): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onDragOver={(e) => params.onDragOver(e, params.api.id)}
|
||||||
|
onDragLeave={params.onDragLeave}
|
||||||
|
onDrop={(e) => params.onDrop(e, params.api.id)}
|
||||||
|
className={`bg-cyber-dark border rounded p-4 space-y-3 transition-all ${getApiCardClassName(params.api.id, params.draggedId, params.dragOverId)}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3 flex-1">
|
||||||
|
<DragGrip apiId={params.api.id} onDragStart={params.onDragStart} />
|
||||||
|
<UrlCell
|
||||||
|
api={params.api}
|
||||||
|
isEditing={params.isEditing}
|
||||||
|
onStartEditing={params.onStartEditing}
|
||||||
|
onStopEditing={params.onStopEditing}
|
||||||
|
onUpdateUrl={params.onUpdateUrl}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ActionsCell api={params.api} onToggleEnabled={params.onToggleEnabled} onRemoveApi={params.onRemoveApi} />
|
||||||
|
</div>
|
||||||
|
<PriorityRow priority={params.priority} apiId={params.api.id} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DragGrip(params: { apiId: string; onDragStart: (e: React.DragEvent<HTMLDivElement>, id: string) => void }): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="drag-handle cursor-grab active:cursor-grabbing"
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => {
|
||||||
|
params.onDragStart(e, params.apiId)
|
||||||
|
e.stopPropagation()
|
||||||
|
}}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<DragHandle />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function UrlCell(params: {
|
||||||
|
api: Nip95Config
|
||||||
|
isEditing: boolean
|
||||||
|
onStartEditing: (id: string) => void
|
||||||
|
onStopEditing: () => void
|
||||||
|
onUpdateUrl: (id: string, url: string) => void
|
||||||
|
}): React.ReactElement {
|
||||||
|
return <div className="flex-1">{params.isEditing ? <UrlEditor api={params.api} onStop={params.onStopEditing} onUpdate={params.onUpdateUrl} /> : <UrlText api={params.api} onStartEditing={params.onStartEditing} />}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
function UrlText(params: { api: Nip95Config; onStartEditing: (id: string) => void }): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="text-neon-cyan cursor-pointer hover:text-neon-green transition-colors"
|
||||||
|
onClick={() => params.onStartEditing(params.api.id)}
|
||||||
|
title={t('settings.nip95.list.editUrl')}
|
||||||
|
>
|
||||||
|
{params.api.url}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function UrlEditor(params: { api: Nip95Config; onStop: () => void; onUpdate: (id: string, url: string) => void }): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
defaultValue={params.api.url}
|
||||||
|
onBlur={(e) => {
|
||||||
|
const next = e.target.value
|
||||||
|
if (next !== params.api.url) {
|
||||||
|
params.onUpdate(params.api.id, next)
|
||||||
|
} else {
|
||||||
|
params.onStop()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.currentTarget.blur()
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
params.onStop()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full px-3 py-2 bg-cyber-darker border border-neon-cyan/50 rounded text-cyber-light focus:border-neon-cyan focus:outline-none"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActionsCell(params: { api: Nip95Config; onToggleEnabled: (id: string, enabled: boolean) => void; onRemoveApi: (id: string) => void }): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<EnabledToggle api={params.api} onToggleEnabled={params.onToggleEnabled} />
|
||||||
|
<RemoveButton apiId={params.api.id} onRemove={params.onRemoveApi} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EnabledToggle(params: { api: Nip95Config; onToggleEnabled: (id: string, enabled: boolean) => void }): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={params.api.enabled}
|
||||||
|
onChange={(e) => params.onToggleEnabled(params.api.id, e.target.checked)}
|
||||||
|
className="w-4 h-4 text-neon-cyan bg-cyber-darker border-cyber-accent rounded focus:ring-neon-cyan"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-cyber-accent">
|
||||||
|
{params.api.enabled ? t('settings.nip95.list.enabled') : t('settings.nip95.list.disabled')}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RemoveButton(params: { apiId: string; onRemove: (id: string) => void }): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => params.onRemove(params.apiId)}
|
||||||
|
className="px-3 py-1 text-sm bg-red-900/30 hover:bg-red-900/50 text-red-300 border border-red-500/50 rounded transition-colors"
|
||||||
|
title={t('settings.nip95.list.remove')}
|
||||||
|
>
|
||||||
|
{t('settings.nip95.list.remove')}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PriorityRow(params: { priority: number; apiId: string }): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-cyber-accent/70">
|
||||||
|
<span>{t('settings.nip95.list.priorityLabel', { priority: params.priority, id: params.apiId })}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
30
components/nip95Config/Nip95ApiList.tsx
Normal file
30
components/nip95Config/Nip95ApiList.tsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import type { Nip95Config } from '@/lib/configStorageTypes'
|
||||||
|
import { t } from '@/lib/i18n'
|
||||||
|
import { Nip95ApiCard } from './Nip95ApiCard'
|
||||||
|
|
||||||
|
export function Nip95ApiList(params: {
|
||||||
|
apis: Nip95Config[]
|
||||||
|
editingId: string | null
|
||||||
|
draggedId: string | null
|
||||||
|
dragOverId: string | null
|
||||||
|
onStartEditing: (id: string) => void
|
||||||
|
onStopEditing: () => void
|
||||||
|
onUpdateUrl: (id: string, url: string) => void
|
||||||
|
onToggleEnabled: (id: string, enabled: boolean) => void
|
||||||
|
onRemoveApi: (id: string) => void
|
||||||
|
onDragStart: (e: React.DragEvent<HTMLDivElement>, id: string) => void
|
||||||
|
onDragOver: (e: React.DragEvent<HTMLDivElement>, id: string) => void
|
||||||
|
onDragLeave: () => void
|
||||||
|
onDrop: (e: React.DragEvent<HTMLDivElement>, targetId: string) => void
|
||||||
|
}): React.ReactElement {
|
||||||
|
if (params.apis.length === 0) {
|
||||||
|
return <div className="text-center py-8 text-cyber-accent">{t('settings.nip95.empty')}</div>
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{params.apis.map((api, index) => (
|
||||||
|
<Nip95ApiCard key={api.id} api={api} priority={index + 1} isEditing={params.editingId === api.id} draggedId={params.draggedId} dragOverId={params.dragOverId} onStartEditing={params.onStartEditing} onStopEditing={params.onStopEditing} onUpdateUrl={params.onUpdateUrl} onToggleEnabled={params.onToggleEnabled} onRemoveApi={params.onRemoveApi} onDragStart={params.onDragStart} onDragOver={params.onDragOver} onDragLeave={params.onDragLeave} onDrop={params.onDrop} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
107
components/nip95Config/Nip95ConfigContent.tsx
Normal file
107
components/nip95Config/Nip95ConfigContent.tsx
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
import type { Nip95Config } from '@/lib/configStorageTypes'
|
||||||
|
import { t } from '@/lib/i18n'
|
||||||
|
import { Nip95ApiList } from './Nip95ApiList'
|
||||||
|
|
||||||
|
export function Nip95ConfigContent(params: {
|
||||||
|
apis: Nip95Config[]
|
||||||
|
error: string | null
|
||||||
|
editingId: string | null
|
||||||
|
newUrl: string
|
||||||
|
showAddForm: boolean
|
||||||
|
draggedId: string | null
|
||||||
|
dragOverId: string | null
|
||||||
|
onClearError: () => void
|
||||||
|
onToggleAddForm: () => void
|
||||||
|
onNewUrlChange: (value: string) => void
|
||||||
|
onCancelAdd: () => void
|
||||||
|
onAddApi: () => void
|
||||||
|
onStartEditing: (id: string) => void
|
||||||
|
onStopEditing: () => void
|
||||||
|
onUpdateUrl: (id: string, url: string) => void
|
||||||
|
onToggleEnabled: (id: string, enabled: boolean) => void
|
||||||
|
onRemoveApi: (id: string) => void
|
||||||
|
onDragStart: (e: React.DragEvent<HTMLDivElement>, id: string) => void
|
||||||
|
onDragOver: (e: React.DragEvent<HTMLDivElement>, id: string) => void
|
||||||
|
onDragLeave: () => void
|
||||||
|
onDrop: (e: React.DragEvent<HTMLDivElement>, targetId: string) => void
|
||||||
|
}): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{params.error ? <ErrorBanner error={params.error} onClear={params.onClearError} /> : null}
|
||||||
|
<Header showAddForm={params.showAddForm} onToggleAddForm={params.onToggleAddForm} />
|
||||||
|
{params.showAddForm ? <AddForm newUrl={params.newUrl} onNewUrlChange={params.onNewUrlChange} onAdd={params.onAddApi} onCancel={params.onCancelAdd} /> : null}
|
||||||
|
<Nip95ApiList apis={params.apis} editingId={params.editingId} draggedId={params.draggedId} dragOverId={params.dragOverId} onStartEditing={params.onStartEditing} onStopEditing={params.onStopEditing} onUpdateUrl={params.onUpdateUrl} onToggleEnabled={params.onToggleEnabled} onRemoveApi={params.onRemoveApi} onDragStart={params.onDragStart} onDragOver={params.onDragOver} onDragLeave={params.onDragLeave} onDrop={params.onDrop} />
|
||||||
|
<Notes />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ErrorBanner(params: { error: string; onClear: () => void }): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="bg-red-900/30 border border-red-500/50 rounded p-4 text-red-300">
|
||||||
|
{params.error}
|
||||||
|
<button type="button" onClick={params.onClear} className="ml-4 text-red-400 hover:text-red-200">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Header(params: { showAddForm: boolean; onToggleAddForm: () => void }): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h2 className="text-2xl font-bold text-neon-cyan">{t('settings.nip95.title')}</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={params.onToggleAddForm}
|
||||||
|
className="px-4 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan border border-neon-cyan/50 rounded transition-colors"
|
||||||
|
>
|
||||||
|
{params.showAddForm ? t('settings.nip95.add.cancel') : t('settings.nip95.addButton')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddForm(params: { newUrl: string; onNewUrlChange: (value: string) => void; onAdd: () => void; onCancel: () => void }): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="bg-cyber-dark border border-neon-cyan/30 rounded p-4 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-cyber-accent mb-2">{t('settings.nip95.add.url')}</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={params.newUrl}
|
||||||
|
onChange={(e) => params.onNewUrlChange(e.target.value)}
|
||||||
|
placeholder={t('settings.nip95.add.placeholder')}
|
||||||
|
className="w-full px-4 py-2 bg-cyber-darker border border-cyber-accent/30 rounded text-cyber-light focus:border-neon-cyan focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={params.onAdd}
|
||||||
|
className="px-4 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan border border-neon-cyan/50 rounded transition-colors"
|
||||||
|
>
|
||||||
|
{t('settings.nip95.add.add')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={params.onCancel}
|
||||||
|
className="px-4 py-2 bg-cyber-accent/20 hover:bg-cyber-accent/30 text-cyber-accent border border-cyber-accent/50 rounded transition-colors"
|
||||||
|
>
|
||||||
|
{t('settings.nip95.add.cancel')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Notes(): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="text-sm text-cyber-accent space-y-2">
|
||||||
|
<p>
|
||||||
|
<strong>{t('settings.nip95.note.title')}</strong> {t('settings.nip95.note.priority')}
|
||||||
|
</p>
|
||||||
|
<p>{t('settings.nip95.note.fallback')}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
45
components/nip95Config/Nip95ConfigManager.tsx
Normal file
45
components/nip95Config/Nip95ConfigManager.tsx
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import type { Nip95Config } from '@/lib/configStorageTypes'
|
||||||
|
import { Nip95ConfigView } from './view'
|
||||||
|
import { loadApis } from './controller'
|
||||||
|
import { createNip95ConfigViewProps } from './viewModel'
|
||||||
|
|
||||||
|
export interface Nip95ConfigManagerProps {
|
||||||
|
onConfigChange?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Nip95ConfigManager(props: Nip95ConfigManagerProps): React.ReactElement {
|
||||||
|
const [apis, setApis] = useState<Nip95Config[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
const [newUrl, setNewUrl] = useState('')
|
||||||
|
const [showAddForm, setShowAddForm] = useState(false)
|
||||||
|
const [draggedId, setDraggedId] = useState<string | null>(null)
|
||||||
|
const [dragOverId, setDragOverId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadApis({ setApis, setLoading, setError })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const viewProps = createNip95ConfigViewProps({
|
||||||
|
apis,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
editingId,
|
||||||
|
newUrl,
|
||||||
|
showAddForm,
|
||||||
|
draggedId,
|
||||||
|
dragOverId,
|
||||||
|
setApis,
|
||||||
|
setError,
|
||||||
|
setEditingId,
|
||||||
|
setNewUrl,
|
||||||
|
setShowAddForm,
|
||||||
|
setDraggedId,
|
||||||
|
setDragOverId,
|
||||||
|
onConfigChange: props.onConfigChange,
|
||||||
|
})
|
||||||
|
|
||||||
|
return <Nip95ConfigView {...viewProps} />
|
||||||
|
}
|
||||||
37
components/nip95Config/Nip95ConfigView.tsx
Normal file
37
components/nip95Config/Nip95ConfigView.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import type { Nip95Config } from '@/lib/configStorageTypes'
|
||||||
|
import { t } from '@/lib/i18n'
|
||||||
|
import { Nip95ConfigContent } from './Nip95ConfigContent'
|
||||||
|
|
||||||
|
export function Nip95ConfigView(params: {
|
||||||
|
apis: Nip95Config[]
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
editingId: string | null
|
||||||
|
newUrl: string
|
||||||
|
showAddForm: boolean
|
||||||
|
draggedId: string | null
|
||||||
|
dragOverId: string | null
|
||||||
|
onClearError: () => void
|
||||||
|
onToggleAddForm: () => void
|
||||||
|
onNewUrlChange: (value: string) => void
|
||||||
|
onCancelAdd: () => void
|
||||||
|
onAddApi: () => void
|
||||||
|
onStartEditing: (id: string) => void
|
||||||
|
onStopEditing: () => void
|
||||||
|
onUpdateUrl: (id: string, url: string) => void
|
||||||
|
onToggleEnabled: (id: string, enabled: boolean) => void
|
||||||
|
onRemoveApi: (id: string) => void
|
||||||
|
onDragStart: (e: React.DragEvent<HTMLDivElement>, id: string) => void
|
||||||
|
onDragOver: (e: React.DragEvent<HTMLDivElement>, id: string) => void
|
||||||
|
onDragLeave: () => void
|
||||||
|
onDrop: (e: React.DragEvent<HTMLDivElement>, targetId: string) => void
|
||||||
|
}): React.ReactElement {
|
||||||
|
if (params.loading) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-8 text-neon-cyan">
|
||||||
|
<div>{t('settings.nip95.loading')}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return <Nip95ConfigContent {...params} />
|
||||||
|
}
|
||||||
212
components/nip95Config/controller.ts
Normal file
212
components/nip95Config/controller.ts
Normal file
@ -0,0 +1,212 @@
|
|||||||
|
import { configStorage } from '@/lib/configStorage'
|
||||||
|
import type { Nip95Config } from '@/lib/configStorageTypes'
|
||||||
|
import { t } from '@/lib/i18n'
|
||||||
|
import { userConfirm } from '@/lib/userConfirm'
|
||||||
|
|
||||||
|
export async function loadApis(params: {
|
||||||
|
setApis: (value: Nip95Config[]) => void
|
||||||
|
setLoading: (value: boolean) => void
|
||||||
|
setError: (value: string | null) => void
|
||||||
|
}): Promise<void> {
|
||||||
|
try {
|
||||||
|
params.setLoading(true)
|
||||||
|
params.setError(null)
|
||||||
|
const config = await configStorage.getConfig()
|
||||||
|
params.setApis(config.nip95Apis.sort((a, b) => a.priority - b.priority))
|
||||||
|
} catch (e) {
|
||||||
|
const errorMessage = e instanceof Error ? e.message : t('settings.nip95.error.loadFailed')
|
||||||
|
params.setError(errorMessage)
|
||||||
|
console.error('[Nip95Config] Error loading APIs:', e)
|
||||||
|
} finally {
|
||||||
|
params.setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toggleEnabled(params: {
|
||||||
|
id: string
|
||||||
|
enabled: boolean
|
||||||
|
setApis: (value: Nip95Config[]) => void
|
||||||
|
setError: (value: string | null) => void
|
||||||
|
onConfigChange: (() => void) | undefined
|
||||||
|
}): Promise<void> {
|
||||||
|
try {
|
||||||
|
await configStorage.updateNip95Api(params.id, { enabled: params.enabled })
|
||||||
|
await reloadApis({ setApis: params.setApis, setError: params.setError })
|
||||||
|
params.onConfigChange?.()
|
||||||
|
} catch (e) {
|
||||||
|
const errorMessage = e instanceof Error ? e.message : t('settings.nip95.error.updateFailed')
|
||||||
|
params.setError(errorMessage)
|
||||||
|
console.error('[Nip95Config] Error updating API:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUrl(params: {
|
||||||
|
id: string
|
||||||
|
url: string
|
||||||
|
setApis: (value: Nip95Config[]) => void
|
||||||
|
setError: (value: string | null) => void
|
||||||
|
setEditingId: (value: string | null) => void
|
||||||
|
onConfigChange: (() => void) | undefined
|
||||||
|
}): Promise<void> {
|
||||||
|
try {
|
||||||
|
await configStorage.updateNip95Api(params.id, { url: params.url })
|
||||||
|
await reloadApis({ setApis: params.setApis, setError: params.setError })
|
||||||
|
params.setEditingId(null)
|
||||||
|
params.onConfigChange?.()
|
||||||
|
} catch (e) {
|
||||||
|
const errorMessage = e instanceof Error ? e.message : t('settings.nip95.error.urlFailed')
|
||||||
|
params.setError(errorMessage)
|
||||||
|
console.error('[Nip95Config] Error updating URL:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addApi(params: {
|
||||||
|
newUrl: string
|
||||||
|
setError: (value: string | null) => void
|
||||||
|
setNewUrl: (value: string) => void
|
||||||
|
setShowAddForm: (value: boolean | ((prev: boolean) => boolean)) => void
|
||||||
|
setApis: (value: Nip95Config[]) => void
|
||||||
|
onConfigChange: (() => void) | undefined
|
||||||
|
}): Promise<void> {
|
||||||
|
if (!params.newUrl.trim()) {
|
||||||
|
params.setError(t('settings.nip95.error.urlRequired'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
void new URL(params.newUrl)
|
||||||
|
await configStorage.addNip95Api(params.newUrl.trim(), false)
|
||||||
|
params.setNewUrl('')
|
||||||
|
params.setShowAddForm(false)
|
||||||
|
await reloadApis({ setApis: params.setApis, setError: params.setError })
|
||||||
|
params.onConfigChange?.()
|
||||||
|
} catch (e) {
|
||||||
|
params.setError(getAddApiErrorMessage(e))
|
||||||
|
console.error('[Nip95Config] Error adding API:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeApi(params: {
|
||||||
|
id: string
|
||||||
|
setApis: (value: Nip95Config[]) => void
|
||||||
|
setError: (value: string | null) => void
|
||||||
|
onConfigChange: (() => void) | undefined
|
||||||
|
}): Promise<void> {
|
||||||
|
const confirmed = await userConfirm(t('settings.nip95.remove.confirm'))
|
||||||
|
if (!confirmed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await configStorage.removeNip95Api(params.id)
|
||||||
|
await reloadApis({ setApis: params.setApis, setError: params.setError })
|
||||||
|
params.onConfigChange?.()
|
||||||
|
} catch (e) {
|
||||||
|
const errorMessage = e instanceof Error ? e.message : t('settings.nip95.error.removeFailed')
|
||||||
|
params.setError(errorMessage)
|
||||||
|
console.error('[Nip95Config] Error removing API:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onDragStart(params: {
|
||||||
|
e: React.DragEvent<HTMLDivElement>
|
||||||
|
id: string
|
||||||
|
setDraggedId: (value: string | null) => void
|
||||||
|
}): void {
|
||||||
|
params.setDraggedId(params.id)
|
||||||
|
const { dataTransfer } = params.e
|
||||||
|
dataTransfer.effectAllowed = 'move'
|
||||||
|
dataTransfer.setData('text/plain', params.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onDragOver(params: {
|
||||||
|
e: React.DragEvent<HTMLDivElement>
|
||||||
|
id: string
|
||||||
|
setDragOverId: (value: string | null) => void
|
||||||
|
}): void {
|
||||||
|
params.e.preventDefault()
|
||||||
|
const { dataTransfer } = params.e
|
||||||
|
dataTransfer.dropEffect = 'move'
|
||||||
|
params.setDragOverId(params.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function onDrop(params: {
|
||||||
|
e: React.DragEvent<HTMLDivElement>
|
||||||
|
targetId: string
|
||||||
|
apis: Nip95Config[]
|
||||||
|
draggedId: string | null
|
||||||
|
setApis: (value: Nip95Config[]) => void
|
||||||
|
setDraggedId: (value: string | null) => void
|
||||||
|
setDragOverId: (value: string | null) => void
|
||||||
|
setError: (value: string | null) => void
|
||||||
|
onConfigChange: (() => void) | undefined
|
||||||
|
}): Promise<void> {
|
||||||
|
params.e.preventDefault()
|
||||||
|
params.setDragOverId(null)
|
||||||
|
if (!params.draggedId || params.draggedId === params.targetId) {
|
||||||
|
params.setDraggedId(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const reordered = reorderApis({ apis: params.apis, draggedId: params.draggedId, targetId: params.targetId })
|
||||||
|
if (!reordered) {
|
||||||
|
params.setDraggedId(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
params.setApis(reordered)
|
||||||
|
params.setDraggedId(null)
|
||||||
|
await updatePriorities({ apis: reordered, setError: params.setError, onConfigChange: params.onConfigChange })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reloadApis(params: { setApis: (value: Nip95Config[]) => void; setError: (value: string | null) => void }): Promise<void> {
|
||||||
|
try {
|
||||||
|
const config = await configStorage.getConfig()
|
||||||
|
params.setApis(config.nip95Apis.sort((a, b) => a.priority - b.priority))
|
||||||
|
} catch (e) {
|
||||||
|
const errorMessage = e instanceof Error ? e.message : t('settings.nip95.error.loadFailed')
|
||||||
|
params.setError(errorMessage)
|
||||||
|
console.error('[Nip95Config] Error reloading APIs:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAddApiErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof TypeError && error.message.includes('Invalid URL')) {
|
||||||
|
return t('settings.nip95.error.invalidUrl')
|
||||||
|
}
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.message
|
||||||
|
}
|
||||||
|
return t('settings.nip95.error.addFailed')
|
||||||
|
}
|
||||||
|
|
||||||
|
function reorderApis(params: { apis: Nip95Config[]; draggedId: string; targetId: string }): Nip95Config[] | null {
|
||||||
|
const draggedIndex = params.apis.findIndex((api) => api.id === params.draggedId)
|
||||||
|
const targetIndex = params.apis.findIndex((api) => api.id === params.targetId)
|
||||||
|
if (draggedIndex === -1 || targetIndex === -1) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const next = [...params.apis]
|
||||||
|
const removed = next[draggedIndex]
|
||||||
|
if (!removed) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
next.splice(draggedIndex, 1)
|
||||||
|
next.splice(targetIndex, 0, removed)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updatePriorities(params: {
|
||||||
|
apis: Nip95Config[]
|
||||||
|
setError: (value: string | null) => void
|
||||||
|
onConfigChange: (() => void) | undefined
|
||||||
|
}): Promise<void> {
|
||||||
|
try {
|
||||||
|
const updates = params.apis.map((api, index) => {
|
||||||
|
const priority = index + 1
|
||||||
|
return api.priority !== priority ? configStorage.updateNip95Api(api.id, { priority }) : Promise.resolve()
|
||||||
|
})
|
||||||
|
await Promise.all(updates)
|
||||||
|
params.onConfigChange?.()
|
||||||
|
} catch (e) {
|
||||||
|
const errorMessage = e instanceof Error ? e.message : t('settings.nip95.error.priorityFailed')
|
||||||
|
params.setError(errorMessage)
|
||||||
|
console.error('[Nip95Config] Error updating priorities:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
9
components/nip95Config/getApiCardClassName.ts
Normal file
9
components/nip95Config/getApiCardClassName.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
export function getApiCardClassName(apiId: string, draggedId: string | null, dragOverId: string | null): string {
|
||||||
|
if (draggedId === apiId) {
|
||||||
|
return 'opacity-50 border-neon-cyan'
|
||||||
|
}
|
||||||
|
if (dragOverId === apiId) {
|
||||||
|
return 'border-neon-green shadow-lg'
|
||||||
|
}
|
||||||
|
return 'border-neon-cyan/30'
|
||||||
|
}
|
||||||
1
components/nip95Config/view.tsx
Normal file
1
components/nip95Config/view.tsx
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { Nip95ConfigView } from './Nip95ConfigView'
|
||||||
121
components/nip95Config/viewModel.ts
Normal file
121
components/nip95Config/viewModel.ts
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
import type { Nip95Config } from '@/lib/configStorageTypes'
|
||||||
|
import { Nip95ConfigView } from './view'
|
||||||
|
import { addApi, onDragOver, onDragStart, onDrop, removeApi, toggleEnabled, updateUrl } from './controller'
|
||||||
|
|
||||||
|
type ViewProps = Parameters<typeof Nip95ConfigView>[0]
|
||||||
|
|
||||||
|
export function createNip95ConfigViewProps(params: {
|
||||||
|
apis: Nip95Config[]
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
editingId: string | null
|
||||||
|
newUrl: string
|
||||||
|
showAddForm: boolean
|
||||||
|
draggedId: string | null
|
||||||
|
dragOverId: string | null
|
||||||
|
setApis: (value: Nip95Config[]) => void
|
||||||
|
setError: (value: string | null) => void
|
||||||
|
setEditingId: (value: string | null) => void
|
||||||
|
setNewUrl: (value: string) => void
|
||||||
|
setShowAddForm: (value: boolean | ((prev: boolean) => boolean)) => void
|
||||||
|
setDraggedId: (value: string | null) => void
|
||||||
|
setDragOverId: (value: string | null) => void
|
||||||
|
onConfigChange: (() => void) | undefined
|
||||||
|
}): ViewProps {
|
||||||
|
return { ...baseStateProps(params), ...formHandlers(params), ...apiHandlers(params), ...dragHandlers(params) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseStateProps(params: {
|
||||||
|
apis: Nip95Config[]
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
editingId: string | null
|
||||||
|
newUrl: string
|
||||||
|
showAddForm: boolean
|
||||||
|
draggedId: string | null
|
||||||
|
dragOverId: string | null
|
||||||
|
}): Pick<ViewProps, 'apis' | 'loading' | 'error' | 'editingId' | 'newUrl' | 'showAddForm' | 'draggedId' | 'dragOverId'> {
|
||||||
|
return {
|
||||||
|
apis: params.apis,
|
||||||
|
loading: params.loading,
|
||||||
|
error: params.error,
|
||||||
|
editingId: params.editingId,
|
||||||
|
newUrl: params.newUrl,
|
||||||
|
showAddForm: params.showAddForm,
|
||||||
|
draggedId: params.draggedId,
|
||||||
|
dragOverId: params.dragOverId,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formHandlers(params: {
|
||||||
|
newUrl: string
|
||||||
|
setApis: (value: Nip95Config[]) => void
|
||||||
|
setError: (value: string | null) => void
|
||||||
|
setNewUrl: (value: string) => void
|
||||||
|
setShowAddForm: (value: boolean | ((prev: boolean) => boolean)) => void
|
||||||
|
onConfigChange: (() => void) | undefined
|
||||||
|
}): Pick<ViewProps, 'onClearError' | 'onToggleAddForm' | 'onNewUrlChange' | 'onCancelAdd' | 'onAddApi'> {
|
||||||
|
return {
|
||||||
|
onClearError: () => params.setError(null),
|
||||||
|
onToggleAddForm: () => params.setShowAddForm((prev) => !prev),
|
||||||
|
onNewUrlChange: (value) => params.setNewUrl(value),
|
||||||
|
onCancelAdd: () => {
|
||||||
|
params.setShowAddForm(false)
|
||||||
|
params.setNewUrl('')
|
||||||
|
params.setError(null)
|
||||||
|
},
|
||||||
|
onAddApi: () =>
|
||||||
|
void addApi({
|
||||||
|
newUrl: params.newUrl,
|
||||||
|
setError: params.setError,
|
||||||
|
setNewUrl: params.setNewUrl,
|
||||||
|
setShowAddForm: params.setShowAddForm,
|
||||||
|
setApis: params.setApis,
|
||||||
|
onConfigChange: params.onConfigChange,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiHandlers(params: {
|
||||||
|
setApis: (value: Nip95Config[]) => void
|
||||||
|
setError: (value: string | null) => void
|
||||||
|
setEditingId: (value: string | null) => void
|
||||||
|
onConfigChange: (() => void) | undefined
|
||||||
|
}): Pick<ViewProps, 'onStartEditing' | 'onStopEditing' | 'onUpdateUrl' | 'onToggleEnabled' | 'onRemoveApi'> {
|
||||||
|
return {
|
||||||
|
onStartEditing: (id) => params.setEditingId(id),
|
||||||
|
onStopEditing: () => params.setEditingId(null),
|
||||||
|
onUpdateUrl: (id, url) =>
|
||||||
|
void updateUrl({ id, url, setError: params.setError, setEditingId: params.setEditingId, setApis: params.setApis, onConfigChange: params.onConfigChange }),
|
||||||
|
onToggleEnabled: (id, enabled) => void toggleEnabled({ id, enabled, setError: params.setError, setApis: params.setApis, onConfigChange: params.onConfigChange }),
|
||||||
|
onRemoveApi: (id) => void removeApi({ id, setError: params.setError, setApis: params.setApis, onConfigChange: params.onConfigChange }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dragHandlers(params: {
|
||||||
|
apis: Nip95Config[]
|
||||||
|
draggedId: string | null
|
||||||
|
setApis: (value: Nip95Config[]) => void
|
||||||
|
setDraggedId: (value: string | null) => void
|
||||||
|
setDragOverId: (value: string | null) => void
|
||||||
|
setError: (value: string | null) => void
|
||||||
|
onConfigChange: (() => void) | undefined
|
||||||
|
}): Pick<ViewProps, 'onDragStart' | 'onDragOver' | 'onDragLeave' | 'onDrop'> {
|
||||||
|
return {
|
||||||
|
onDragStart: (e, id) => onDragStart({ e, id, setDraggedId: params.setDraggedId }),
|
||||||
|
onDragOver: (e, id) => onDragOver({ e, id, setDragOverId: params.setDragOverId }),
|
||||||
|
onDragLeave: () => params.setDragOverId(null),
|
||||||
|
onDrop: (e, targetId) =>
|
||||||
|
void onDrop({
|
||||||
|
e,
|
||||||
|
targetId,
|
||||||
|
apis: params.apis,
|
||||||
|
draggedId: params.draggedId,
|
||||||
|
setApis: params.setApis,
|
||||||
|
setDraggedId: params.setDraggedId,
|
||||||
|
setDragOverId: params.setDragOverId,
|
||||||
|
setError: params.setError,
|
||||||
|
onConfigChange: params.onConfigChange,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
132
components/relayManager/RelayCard.tsx
Normal file
132
components/relayManager/RelayCard.tsx
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
import type { RelayConfig } from '@/lib/configStorageTypes'
|
||||||
|
import { t } from '@/lib/i18n'
|
||||||
|
import { DragHandle } from '../DragHandle'
|
||||||
|
import { getRelayCardClassName } from './controller'
|
||||||
|
|
||||||
|
export function RelayCard(params: {
|
||||||
|
relay: RelayConfig
|
||||||
|
isEditing: boolean
|
||||||
|
draggedId: string | null
|
||||||
|
dragOverId: string | null
|
||||||
|
onStartEditing: (id: string) => void
|
||||||
|
onStopEditing: () => void
|
||||||
|
onUpdateUrl: (id: string, url: string) => void
|
||||||
|
onToggleEnabled: (id: string, enabled: boolean) => void
|
||||||
|
onRemoveRelay: (id: string) => void
|
||||||
|
onDragStart: (e: React.DragEvent<HTMLDivElement>, id: string) => void
|
||||||
|
onDragOver: (e: React.DragEvent<HTMLDivElement>, id: string) => void
|
||||||
|
onDragLeave: () => void
|
||||||
|
onDrop: (e: React.DragEvent<HTMLDivElement>, targetId: string) => void
|
||||||
|
onDragEnd: () => void
|
||||||
|
}): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onDragOver={(e) => params.onDragOver(e, params.relay.id)}
|
||||||
|
onDragLeave={params.onDragLeave}
|
||||||
|
onDrop={(e) => params.onDrop(e, params.relay.id)}
|
||||||
|
className={`bg-cyber-dark border rounded p-4 space-y-3 transition-all ${getRelayCardClassName(params.relay.id, params.draggedId, params.dragOverId)}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3 flex-1">
|
||||||
|
<DragGrip relayId={params.relay.id} onDragStart={params.onDragStart} onDragEnd={params.onDragEnd} />
|
||||||
|
<UrlCell relay={params.relay} isEditing={params.isEditing} onStartEditing={params.onStartEditing} onStopEditing={params.onStopEditing} onUpdateUrl={params.onUpdateUrl} />
|
||||||
|
{params.relay.lastSyncDate ? <LastSync lastSyncDate={params.relay.lastSyncDate} /> : null}
|
||||||
|
</div>
|
||||||
|
<ActionsCell relay={params.relay} onToggleEnabled={params.onToggleEnabled} onRemoveRelay={params.onRemoveRelay} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DragGrip(params: {
|
||||||
|
relayId: string
|
||||||
|
onDragStart: (e: React.DragEvent<HTMLDivElement>, id: string) => void
|
||||||
|
onDragEnd: () => void
|
||||||
|
}): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="drag-handle cursor-grab active:cursor-grabbing"
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => params.onDragStart(e, params.relayId)}
|
||||||
|
onDragEnd={params.onDragEnd}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<DragHandle />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function UrlCell(params: {
|
||||||
|
relay: RelayConfig
|
||||||
|
isEditing: boolean
|
||||||
|
onStartEditing: (id: string) => void
|
||||||
|
onStopEditing: () => void
|
||||||
|
onUpdateUrl: (id: string, url: string) => void
|
||||||
|
}): React.ReactElement {
|
||||||
|
if (params.isEditing) {
|
||||||
|
return <UrlEditor relay={params.relay} onStop={params.onStopEditing} onUpdate={params.onUpdateUrl} />
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="text-neon-cyan cursor-pointer hover:text-neon-green transition-colors" onClick={() => params.onStartEditing(params.relay.id)} title={t('settings.relay.list.editUrl')}>
|
||||||
|
{params.relay.url}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function UrlEditor(params: { relay: RelayConfig; onStop: () => void; onUpdate: (id: string, url: string) => void }): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
defaultValue={params.relay.url}
|
||||||
|
onBlur={(e) => {
|
||||||
|
const next = e.target.value
|
||||||
|
if (next !== params.relay.url) {
|
||||||
|
params.onUpdate(params.relay.id, next)
|
||||||
|
} else {
|
||||||
|
params.onStop()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.currentTarget.blur()
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
params.onStop()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full px-3 py-2 bg-cyber-darker border border-neon-cyan/50 rounded text-cyber-light focus:border-neon-cyan focus:outline-none"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LastSync(params: { lastSyncDate: string }): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="text-xs text-cyber-accent/70 mt-1">
|
||||||
|
{t('settings.relay.list.lastSync')}: {new Date(params.lastSyncDate).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActionsCell(params: { relay: RelayConfig; onToggleEnabled: (id: string, enabled: boolean) => void; onRemoveRelay: (id: string) => void }): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={params.relay.enabled}
|
||||||
|
onChange={(e) => params.onToggleEnabled(params.relay.id, e.target.checked)}
|
||||||
|
className="w-4 h-4 text-neon-cyan bg-cyber-darker border-cyber-accent rounded focus:ring-neon-cyan"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-cyber-accent">{params.relay.enabled ? t('settings.relay.list.enabled') : t('settings.relay.list.disabled')}</span>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => params.onRemoveRelay(params.relay.id)}
|
||||||
|
className="px-3 py-1 text-sm bg-red-900/30 hover:bg-red-900/50 text-red-300 border border-red-500/50 rounded transition-colors"
|
||||||
|
title={t('settings.relay.list.remove')}
|
||||||
|
>
|
||||||
|
{t('settings.relay.list.remove')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
49
components/relayManager/RelayList.tsx
Normal file
49
components/relayManager/RelayList.tsx
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import type { RelayConfig } from '@/lib/configStorageTypes'
|
||||||
|
import { t } from '@/lib/i18n'
|
||||||
|
import { RelayCard } from './RelayCard'
|
||||||
|
|
||||||
|
interface RelayListProps {
|
||||||
|
relays: RelayConfig[]
|
||||||
|
editingId: string | null
|
||||||
|
draggedId: string | null
|
||||||
|
dragOverId: string | null
|
||||||
|
onStartEditing: (id: string) => void
|
||||||
|
onStopEditing: () => void
|
||||||
|
onUpdateUrl: (id: string, url: string) => void
|
||||||
|
onToggleEnabled: (id: string, enabled: boolean) => void
|
||||||
|
onRemoveRelay: (id: string) => void
|
||||||
|
onDragStart: (e: React.DragEvent<HTMLDivElement>, id: string) => void
|
||||||
|
onDragOver: (e: React.DragEvent<HTMLDivElement>, id: string) => void
|
||||||
|
onDragLeave: () => void
|
||||||
|
onDrop: (e: React.DragEvent<HTMLDivElement>, targetId: string) => void
|
||||||
|
onDragEnd: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RelayList(params: RelayListProps): React.ReactElement {
|
||||||
|
if (params.relays.length === 0) {
|
||||||
|
return <div className="text-center py-8 text-cyber-accent">{t('settings.relay.empty')}</div>
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{params.relays.map((relay) => (
|
||||||
|
<RelayCard
|
||||||
|
key={relay.id}
|
||||||
|
relay={relay}
|
||||||
|
isEditing={params.editingId === relay.id}
|
||||||
|
draggedId={params.draggedId}
|
||||||
|
dragOverId={params.dragOverId}
|
||||||
|
onStartEditing={params.onStartEditing}
|
||||||
|
onStopEditing={params.onStopEditing}
|
||||||
|
onUpdateUrl={params.onUpdateUrl}
|
||||||
|
onToggleEnabled={params.onToggleEnabled}
|
||||||
|
onRemoveRelay={params.onRemoveRelay}
|
||||||
|
onDragStart={params.onDragStart}
|
||||||
|
onDragOver={params.onDragOver}
|
||||||
|
onDragLeave={params.onDragLeave}
|
||||||
|
onDrop={params.onDrop}
|
||||||
|
onDragEnd={params.onDragEnd}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
46
components/relayManager/RelayManager.tsx
Normal file
46
components/relayManager/RelayManager.tsx
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import type { RelayConfig } from '@/lib/configStorageTypes'
|
||||||
|
import { RelayManagerView } from './view'
|
||||||
|
import { initialLoadRelays } from './viewModel'
|
||||||
|
import { buildRelayManagerViewProps } from './viewProps'
|
||||||
|
|
||||||
|
export interface RelayManagerProps {
|
||||||
|
onConfigChange?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RelayManager(props: RelayManagerProps): React.ReactElement {
|
||||||
|
const [relays, setRelays] = useState<RelayConfig[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
const [newUrl, setNewUrl] = useState('')
|
||||||
|
const [showAddForm, setShowAddForm] = useState(false)
|
||||||
|
const [draggedId, setDraggedId] = useState<string | null>(null)
|
||||||
|
const [dragOverId, setDragOverId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
initialLoadRelays({ setRelays, setLoading, setError })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const viewProps = buildRelayManagerViewProps({
|
||||||
|
relays,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
editingId,
|
||||||
|
newUrl,
|
||||||
|
showAddForm,
|
||||||
|
draggedId,
|
||||||
|
dragOverId,
|
||||||
|
setRelays,
|
||||||
|
setLoading,
|
||||||
|
setError,
|
||||||
|
setEditingId,
|
||||||
|
setNewUrl,
|
||||||
|
setShowAddForm,
|
||||||
|
setDraggedId,
|
||||||
|
setDragOverId,
|
||||||
|
onConfigChange: props.onConfigChange,
|
||||||
|
})
|
||||||
|
|
||||||
|
return <RelayManagerView {...viewProps} />
|
||||||
|
}
|
||||||
100
components/relayManager/RelayManagerContent.tsx
Normal file
100
components/relayManager/RelayManagerContent.tsx
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import { t } from '@/lib/i18n'
|
||||||
|
import { RelayList } from './RelayList'
|
||||||
|
import type { RelayManagerContentProps } from './types'
|
||||||
|
|
||||||
|
export function RelayManagerContent(params: RelayManagerContentProps): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{params.error ? <ErrorBanner error={params.error} onClear={params.onClearError} /> : null}
|
||||||
|
<Header showAddForm={params.showAddForm} onToggleAddForm={params.onToggleAddForm} />
|
||||||
|
{params.showAddForm ? <AddForm newUrl={params.newUrl} onNewUrlChange={params.onNewUrlChange} onAdd={params.onAddRelay} onCancel={params.onCancelAdd} /> : null}
|
||||||
|
<RelayList
|
||||||
|
relays={params.relays}
|
||||||
|
editingId={params.editingId}
|
||||||
|
draggedId={params.draggedId}
|
||||||
|
dragOverId={params.dragOverId}
|
||||||
|
onStartEditing={params.onStartEditing}
|
||||||
|
onStopEditing={params.onStopEditing}
|
||||||
|
onUpdateUrl={params.onUpdateUrl}
|
||||||
|
onToggleEnabled={params.onToggleEnabled}
|
||||||
|
onRemoveRelay={params.onRemoveRelay}
|
||||||
|
onDragStart={params.onDragStart}
|
||||||
|
onDragOver={params.onDragOver}
|
||||||
|
onDragLeave={params.onDragLeave}
|
||||||
|
onDrop={params.onDrop}
|
||||||
|
onDragEnd={params.onDragEnd}
|
||||||
|
/>
|
||||||
|
<Notes />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ErrorBanner(params: { error: string; onClear: () => void }): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="bg-red-900/30 border border-red-500/50 rounded p-4 text-red-300">
|
||||||
|
{params.error}
|
||||||
|
<button type="button" onClick={params.onClear} className="ml-4 text-red-400 hover:text-red-200">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Header(params: { showAddForm: boolean; onToggleAddForm: () => void }): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h2 className="text-2xl font-bold text-neon-cyan">{t('settings.relay.title')}</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={params.onToggleAddForm}
|
||||||
|
className="px-4 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan border border-neon-cyan/50 rounded transition-colors"
|
||||||
|
>
|
||||||
|
{params.showAddForm ? t('settings.relay.add.cancel') : t('settings.relay.addButton')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddForm(params: { newUrl: string; onNewUrlChange: (value: string) => void; onAdd: () => void; onCancel: () => void }): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="bg-cyber-dark border border-neon-cyan/30 rounded p-4 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-cyber-accent mb-2">{t('settings.relay.add.url')}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={params.newUrl}
|
||||||
|
onChange={(e) => params.onNewUrlChange(e.target.value)}
|
||||||
|
placeholder={t('settings.relay.add.placeholder')}
|
||||||
|
className="w-full px-4 py-2 bg-cyber-darker border border-cyber-accent/30 rounded text-cyber-light focus:border-neon-cyan focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={params.onAdd}
|
||||||
|
className="px-4 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan border border-neon-cyan/50 rounded transition-colors"
|
||||||
|
>
|
||||||
|
{t('settings.relay.add.add')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={params.onCancel}
|
||||||
|
className="px-4 py-2 bg-cyber-accent/20 hover:bg-cyber-accent/30 text-cyber-accent border border-cyber-accent/50 rounded transition-colors"
|
||||||
|
>
|
||||||
|
{t('settings.relay.add.cancel')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Notes(): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="text-sm text-cyber-accent space-y-2">
|
||||||
|
<p>
|
||||||
|
<strong>{t('settings.relay.note.title')}</strong> {t('settings.relay.note.priority')}
|
||||||
|
</p>
|
||||||
|
<p>{t('settings.relay.note.rotation')}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
256
components/relayManager/controller.ts
Normal file
256
components/relayManager/controller.ts
Normal file
@ -0,0 +1,256 @@
|
|||||||
|
import { configStorage } from '@/lib/configStorage'
|
||||||
|
import type { RelayConfig } from '@/lib/configStorageTypes'
|
||||||
|
import { t } from '@/lib/i18n'
|
||||||
|
import { relaySessionManager } from '@/lib/relaySessionManager'
|
||||||
|
import { userConfirm } from '@/lib/userConfirm'
|
||||||
|
|
||||||
|
export async function loadRelays(params: {
|
||||||
|
setRelays: (value: RelayConfig[]) => void
|
||||||
|
setLoading: (value: boolean) => void
|
||||||
|
setError: (value: string | null) => void
|
||||||
|
}): Promise<void> {
|
||||||
|
try {
|
||||||
|
params.setLoading(true)
|
||||||
|
params.setError(null)
|
||||||
|
const config = await configStorage.getConfig()
|
||||||
|
const updated = await maybeDisableFailedRelays(config.relays)
|
||||||
|
params.setRelays(sortByPriority(updated))
|
||||||
|
} catch (e) {
|
||||||
|
const errorMessage = e instanceof Error ? e.message : t('settings.relay.error.loadFailed')
|
||||||
|
params.setError(errorMessage)
|
||||||
|
console.error('[RelayManager] Error loading relays:', e)
|
||||||
|
} finally {
|
||||||
|
params.setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toggleEnabled(params: {
|
||||||
|
id: string
|
||||||
|
enabled: boolean
|
||||||
|
setRelays: (value: RelayConfig[]) => void
|
||||||
|
setError: (value: string | null) => void
|
||||||
|
onConfigChange: (() => void) | undefined
|
||||||
|
}): Promise<void> {
|
||||||
|
try {
|
||||||
|
await configStorage.updateRelay(params.id, { enabled: params.enabled })
|
||||||
|
await reloadRelays({ setRelays: params.setRelays, setError: params.setError })
|
||||||
|
params.onConfigChange?.()
|
||||||
|
} catch (e) {
|
||||||
|
const errorMessage = e instanceof Error ? e.message : t('settings.relay.error.updateFailed')
|
||||||
|
params.setError(errorMessage)
|
||||||
|
console.error('[RelayManager] Error updating relay:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUrl(params: {
|
||||||
|
id: string
|
||||||
|
url: string
|
||||||
|
setRelays: (value: RelayConfig[]) => void
|
||||||
|
setError: (value: string | null) => void
|
||||||
|
setEditingId: (value: string | null) => void
|
||||||
|
onConfigChange: (() => void) | undefined
|
||||||
|
}): Promise<void> {
|
||||||
|
try {
|
||||||
|
await configStorage.updateRelay(params.id, { url: params.url })
|
||||||
|
await reloadRelays({ setRelays: params.setRelays, setError: params.setError })
|
||||||
|
params.setEditingId(null)
|
||||||
|
params.onConfigChange?.()
|
||||||
|
} catch (e) {
|
||||||
|
const errorMessage = e instanceof Error ? e.message : t('settings.relay.error.urlFailed')
|
||||||
|
params.setError(errorMessage)
|
||||||
|
console.error('[RelayManager] Error updating URL:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addRelay(params: {
|
||||||
|
newUrl: string
|
||||||
|
setError: (value: string | null) => void
|
||||||
|
setNewUrl: (value: string) => void
|
||||||
|
setShowAddForm: (value: boolean | ((prev: boolean) => boolean)) => void
|
||||||
|
setRelays: (value: RelayConfig[]) => void
|
||||||
|
onConfigChange: (() => void) | undefined
|
||||||
|
}): Promise<void> {
|
||||||
|
const normalizedUrl = normalizeRelayUrl(params.newUrl)
|
||||||
|
if (!normalizedUrl) {
|
||||||
|
params.setError(t('settings.relay.error.urlRequired'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
void new URL(normalizedUrl)
|
||||||
|
await configStorage.addRelay(normalizedUrl, true)
|
||||||
|
params.setNewUrl('')
|
||||||
|
params.setShowAddForm(false)
|
||||||
|
await reloadRelays({ setRelays: params.setRelays, setError: params.setError })
|
||||||
|
params.onConfigChange?.()
|
||||||
|
} catch (e) {
|
||||||
|
params.setError(getAddRelayErrorMessage(e))
|
||||||
|
console.error('[RelayManager] Error adding relay:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeRelay(params: {
|
||||||
|
id: string
|
||||||
|
setRelays: (value: RelayConfig[]) => void
|
||||||
|
setError: (value: string | null) => void
|
||||||
|
onConfigChange: (() => void) | undefined
|
||||||
|
}): Promise<void> {
|
||||||
|
const confirmed = await userConfirm(t('settings.relay.remove.confirm'))
|
||||||
|
if (!confirmed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await configStorage.removeRelay(params.id)
|
||||||
|
await reloadRelays({ setRelays: params.setRelays, setError: params.setError })
|
||||||
|
params.onConfigChange?.()
|
||||||
|
} catch (e) {
|
||||||
|
const errorMessage = e instanceof Error ? e.message : t('settings.relay.error.removeFailed')
|
||||||
|
params.setError(errorMessage)
|
||||||
|
console.error('[RelayManager] Error removing relay:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updatePriorities(params: {
|
||||||
|
relays: RelayConfig[]
|
||||||
|
setError: (value: string | null) => void
|
||||||
|
onConfigChange: (() => void) | undefined
|
||||||
|
}): Promise<void> {
|
||||||
|
try {
|
||||||
|
const updates = params.relays.map((relay, index) => {
|
||||||
|
const priority = index + 1
|
||||||
|
return relay.priority !== priority ? configStorage.updateRelay(relay.id, { priority }) : Promise.resolve()
|
||||||
|
})
|
||||||
|
await Promise.all(updates)
|
||||||
|
params.onConfigChange?.()
|
||||||
|
} catch (e) {
|
||||||
|
const errorMessage = e instanceof Error ? e.message : t('settings.relay.error.priorityFailed')
|
||||||
|
params.setError(errorMessage)
|
||||||
|
console.error('[RelayManager] Error updating priorities:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onDragStart(params: {
|
||||||
|
e: React.DragEvent<HTMLDivElement>
|
||||||
|
id: string
|
||||||
|
setDraggedId: (value: string | null) => void
|
||||||
|
}): void {
|
||||||
|
params.setDraggedId(params.id)
|
||||||
|
const { dataTransfer } = params.e
|
||||||
|
dataTransfer.effectAllowed = 'move'
|
||||||
|
dataTransfer.setData('text/plain', params.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onDragOver(params: {
|
||||||
|
e: React.DragEvent<HTMLDivElement>
|
||||||
|
id: string
|
||||||
|
setDragOverId: (value: string | null) => void
|
||||||
|
}): void {
|
||||||
|
params.e.preventDefault()
|
||||||
|
const { dataTransfer } = params.e
|
||||||
|
dataTransfer.dropEffect = 'move'
|
||||||
|
params.setDragOverId(params.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function onDrop(params: {
|
||||||
|
e: React.DragEvent<HTMLDivElement>
|
||||||
|
targetId: string
|
||||||
|
relays: RelayConfig[]
|
||||||
|
draggedId: string | null
|
||||||
|
setRelays: (value: RelayConfig[]) => void
|
||||||
|
setDraggedId: (value: string | null) => void
|
||||||
|
setDragOverId: (value: string | null) => void
|
||||||
|
setError: (value: string | null) => void
|
||||||
|
onConfigChange: (() => void) | undefined
|
||||||
|
}): Promise<void> {
|
||||||
|
params.e.preventDefault()
|
||||||
|
params.setDragOverId(null)
|
||||||
|
if (!params.draggedId || params.draggedId === params.targetId) {
|
||||||
|
params.setDraggedId(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const reordered = reorderRelays({ relays: params.relays, draggedId: params.draggedId, targetId: params.targetId })
|
||||||
|
if (!reordered) {
|
||||||
|
params.setDraggedId(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
params.setRelays(reordered)
|
||||||
|
params.setDraggedId(null)
|
||||||
|
await updatePriorities({ relays: reordered, setError: params.setError, onConfigChange: params.onConfigChange })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRelayCardClassName(relayId: string, draggedId: string | null, dragOverId: string | null): string {
|
||||||
|
if (draggedId === relayId) {
|
||||||
|
return 'opacity-50 border-neon-cyan'
|
||||||
|
}
|
||||||
|
if (dragOverId === relayId) {
|
||||||
|
return 'border-neon-green shadow-lg'
|
||||||
|
}
|
||||||
|
return 'border-neon-cyan/30'
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortByPriority(relays: RelayConfig[]): RelayConfig[] {
|
||||||
|
return relays.sort((a, b) => a.priority - b.priority)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reloadRelays(params: { setRelays: (value: RelayConfig[]) => void; setError: (value: string | null) => void }): Promise<void> {
|
||||||
|
try {
|
||||||
|
const config = await configStorage.getConfig()
|
||||||
|
params.setRelays(sortByPriority(config.relays))
|
||||||
|
} catch (e) {
|
||||||
|
const errorMessage = e instanceof Error ? e.message : t('settings.relay.error.loadFailed')
|
||||||
|
params.setError(errorMessage)
|
||||||
|
console.error('[RelayManager] Error reloading relays:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function maybeDisableFailedRelays(relays: RelayConfig[]): Promise<RelayConfig[]> {
|
||||||
|
const failedRelays = relaySessionManager.getFailedRelays()
|
||||||
|
if (failedRelays.length === 0) {
|
||||||
|
return relays
|
||||||
|
}
|
||||||
|
const toDisable = relays.filter((relay) => relay.enabled && failedRelays.includes(relay.url))
|
||||||
|
if (toDisable.length === 0) {
|
||||||
|
return relays
|
||||||
|
}
|
||||||
|
for (const relay of toDisable) {
|
||||||
|
await configStorage.updateRelay(relay.id, { enabled: false })
|
||||||
|
}
|
||||||
|
const updated = await configStorage.getConfig()
|
||||||
|
return updated.relays
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRelayUrl(rawUrl: string): string | null {
|
||||||
|
const trimmed = rawUrl.trim()
|
||||||
|
if (trimmed.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (trimmed.startsWith('ws://') || trimmed.startsWith('wss://')) {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
return `wss://${trimmed}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAddRelayErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof TypeError && error.message.includes('Invalid URL')) {
|
||||||
|
return t('settings.relay.error.invalidUrl')
|
||||||
|
}
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.message
|
||||||
|
}
|
||||||
|
return t('settings.relay.error.addFailed')
|
||||||
|
}
|
||||||
|
|
||||||
|
function reorderRelays(params: { relays: RelayConfig[]; draggedId: string; targetId: string }): RelayConfig[] | null {
|
||||||
|
const draggedIndex = params.relays.findIndex((relay) => relay.id === params.draggedId)
|
||||||
|
const targetIndex = params.relays.findIndex((relay) => relay.id === params.targetId)
|
||||||
|
if (draggedIndex === -1 || targetIndex === -1) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const next = [...params.relays]
|
||||||
|
const removed = next[draggedIndex]
|
||||||
|
if (!removed) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
next.splice(draggedIndex, 1)
|
||||||
|
next.splice(targetIndex, 0, removed)
|
||||||
|
return next
|
||||||
|
}
|
||||||
29
components/relayManager/types.ts
Normal file
29
components/relayManager/types.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import type { RelayConfig } from '@/lib/configStorageTypes'
|
||||||
|
|
||||||
|
export interface RelayManagerViewProps {
|
||||||
|
relays: RelayConfig[]
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
editingId: string | null
|
||||||
|
newUrl: string
|
||||||
|
showAddForm: boolean
|
||||||
|
draggedId: string | null
|
||||||
|
dragOverId: string | null
|
||||||
|
onClearError: () => void
|
||||||
|
onToggleAddForm: () => void
|
||||||
|
onNewUrlChange: (value: string) => void
|
||||||
|
onCancelAdd: () => void
|
||||||
|
onAddRelay: () => void
|
||||||
|
onStartEditing: (id: string) => void
|
||||||
|
onStopEditing: () => void
|
||||||
|
onUpdateUrl: (id: string, url: string) => void
|
||||||
|
onToggleEnabled: (id: string, enabled: boolean) => void
|
||||||
|
onRemoveRelay: (id: string) => void
|
||||||
|
onDragStart: (e: React.DragEvent<HTMLDivElement>, id: string) => void
|
||||||
|
onDragOver: (e: React.DragEvent<HTMLDivElement>, id: string) => void
|
||||||
|
onDragLeave: () => void
|
||||||
|
onDrop: (e: React.DragEvent<HTMLDivElement>, targetId: string) => void
|
||||||
|
onDragEnd: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RelayManagerContentProps = Omit<RelayManagerViewProps, 'loading'>
|
||||||
14
components/relayManager/view.tsx
Normal file
14
components/relayManager/view.tsx
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { t } from '@/lib/i18n'
|
||||||
|
import { RelayManagerContent } from './RelayManagerContent'
|
||||||
|
import type { RelayManagerViewProps } from './types'
|
||||||
|
|
||||||
|
export function RelayManagerView(params: RelayManagerViewProps): React.ReactElement {
|
||||||
|
if (params.loading) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-8 text-neon-cyan">
|
||||||
|
<div>{t('settings.relay.loading')}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return <RelayManagerContent {...params} />
|
||||||
|
}
|
||||||
6
components/relayManager/viewModel.ts
Normal file
6
components/relayManager/viewModel.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import type { RelayConfig } from '@/lib/configStorageTypes'
|
||||||
|
import { loadRelays } from './controller'
|
||||||
|
|
||||||
|
export function initialLoadRelays(params: { setRelays: (value: RelayConfig[]) => void; setLoading: (value: boolean) => void; setError: (value: string | null) => void }): void {
|
||||||
|
void loadRelays({ setRelays: params.setRelays, setLoading: params.setLoading, setError: params.setError })
|
||||||
|
}
|
||||||
130
components/relayManager/viewProps.ts
Normal file
130
components/relayManager/viewProps.ts
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
import type { RelayConfig } from '@/lib/configStorageTypes'
|
||||||
|
import type { RelayManagerProps } from './RelayManager'
|
||||||
|
import { addRelay, onDragOver, onDragStart, onDrop, removeRelay, toggleEnabled, updateUrl } from './controller'
|
||||||
|
import type { RelayManagerViewProps } from './types'
|
||||||
|
|
||||||
|
export function buildRelayManagerViewProps(params: {
|
||||||
|
relays: RelayConfig[]
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
editingId: string | null
|
||||||
|
newUrl: string
|
||||||
|
showAddForm: boolean
|
||||||
|
draggedId: string | null
|
||||||
|
dragOverId: string | null
|
||||||
|
setRelays: (value: RelayConfig[]) => void
|
||||||
|
setLoading: (value: boolean) => void
|
||||||
|
setError: (value: string | null) => void
|
||||||
|
setEditingId: (value: string | null) => void
|
||||||
|
setNewUrl: (value: string) => void
|
||||||
|
setShowAddForm: (value: boolean | ((prev: boolean) => boolean)) => void
|
||||||
|
setDraggedId: (value: string | null) => void
|
||||||
|
setDragOverId: (value: string | null) => void
|
||||||
|
onConfigChange: RelayManagerProps['onConfigChange']
|
||||||
|
}): RelayManagerViewProps {
|
||||||
|
return { ...stateProps(params), ...formProps(params), ...relayActionProps(params), ...dragProps(params) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateProps(params: {
|
||||||
|
relays: RelayConfig[]
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
editingId: string | null
|
||||||
|
newUrl: string
|
||||||
|
showAddForm: boolean
|
||||||
|
draggedId: string | null
|
||||||
|
dragOverId: string | null
|
||||||
|
}): Pick<RelayManagerViewProps, 'relays' | 'loading' | 'error' | 'editingId' | 'newUrl' | 'showAddForm' | 'draggedId' | 'dragOverId'> {
|
||||||
|
return {
|
||||||
|
relays: params.relays,
|
||||||
|
loading: params.loading,
|
||||||
|
error: params.error,
|
||||||
|
editingId: params.editingId,
|
||||||
|
newUrl: params.newUrl,
|
||||||
|
showAddForm: params.showAddForm,
|
||||||
|
draggedId: params.draggedId,
|
||||||
|
dragOverId: params.dragOverId,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formProps(params: {
|
||||||
|
newUrl: string
|
||||||
|
setError: (value: string | null) => void
|
||||||
|
setNewUrl: (value: string) => void
|
||||||
|
setShowAddForm: (value: boolean | ((prev: boolean) => boolean)) => void
|
||||||
|
setRelays: (value: RelayConfig[]) => void
|
||||||
|
onConfigChange: RelayManagerProps['onConfigChange']
|
||||||
|
}): Pick<RelayManagerViewProps, 'onClearError' | 'onToggleAddForm' | 'onNewUrlChange' | 'onCancelAdd' | 'onAddRelay'> {
|
||||||
|
return {
|
||||||
|
onClearError: () => params.setError(null),
|
||||||
|
onToggleAddForm: () => params.setShowAddForm((prev) => !prev),
|
||||||
|
onNewUrlChange: (value) => params.setNewUrl(value),
|
||||||
|
onCancelAdd: () => {
|
||||||
|
params.setShowAddForm(false)
|
||||||
|
params.setNewUrl('')
|
||||||
|
params.setError(null)
|
||||||
|
},
|
||||||
|
onAddRelay: () =>
|
||||||
|
void addRelay({
|
||||||
|
newUrl: params.newUrl,
|
||||||
|
setError: params.setError,
|
||||||
|
setNewUrl: params.setNewUrl,
|
||||||
|
setShowAddForm: params.setShowAddForm,
|
||||||
|
setRelays: params.setRelays,
|
||||||
|
onConfigChange: params.onConfigChange,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function relayActionProps(params: {
|
||||||
|
setRelays: (value: RelayConfig[]) => void
|
||||||
|
setError: (value: string | null) => void
|
||||||
|
setEditingId: (value: string | null) => void
|
||||||
|
onConfigChange: RelayManagerProps['onConfigChange']
|
||||||
|
}): Pick<RelayManagerViewProps, 'onStartEditing' | 'onStopEditing' | 'onUpdateUrl' | 'onToggleEnabled' | 'onRemoveRelay'> {
|
||||||
|
return {
|
||||||
|
onStartEditing: (id) => params.setEditingId(id),
|
||||||
|
onStopEditing: () => params.setEditingId(null),
|
||||||
|
onUpdateUrl: (id, url) =>
|
||||||
|
void updateUrl({
|
||||||
|
id,
|
||||||
|
url,
|
||||||
|
setRelays: params.setRelays,
|
||||||
|
setError: params.setError,
|
||||||
|
setEditingId: params.setEditingId,
|
||||||
|
onConfigChange: params.onConfigChange,
|
||||||
|
}),
|
||||||
|
onToggleEnabled: (id, enabled) =>
|
||||||
|
void toggleEnabled({ id, enabled, setRelays: params.setRelays, setError: params.setError, onConfigChange: params.onConfigChange }),
|
||||||
|
onRemoveRelay: (id) => void removeRelay({ id, setRelays: params.setRelays, setError: params.setError, onConfigChange: params.onConfigChange }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dragProps(params: {
|
||||||
|
relays: RelayConfig[]
|
||||||
|
draggedId: string | null
|
||||||
|
setRelays: (value: RelayConfig[]) => void
|
||||||
|
setDraggedId: (value: string | null) => void
|
||||||
|
setDragOverId: (value: string | null) => void
|
||||||
|
setError: (value: string | null) => void
|
||||||
|
onConfigChange: RelayManagerProps['onConfigChange']
|
||||||
|
}): Pick<RelayManagerViewProps, 'onDragStart' | 'onDragOver' | 'onDragLeave' | 'onDrop' | 'onDragEnd'> {
|
||||||
|
return {
|
||||||
|
onDragStart: (e, id) => onDragStart({ e, id, setDraggedId: params.setDraggedId }),
|
||||||
|
onDragOver: (e, id) => onDragOver({ e, id, setDragOverId: params.setDragOverId }),
|
||||||
|
onDragLeave: () => params.setDragOverId(null),
|
||||||
|
onDrop: (e, targetId) =>
|
||||||
|
void onDrop({
|
||||||
|
e,
|
||||||
|
targetId,
|
||||||
|
relays: params.relays,
|
||||||
|
draggedId: params.draggedId,
|
||||||
|
setRelays: params.setRelays,
|
||||||
|
setDraggedId: params.setDraggedId,
|
||||||
|
setDragOverId: params.setDragOverId,
|
||||||
|
setError: params.setError,
|
||||||
|
onConfigChange: params.onConfigChange,
|
||||||
|
}),
|
||||||
|
onDragEnd: () => params.setDraggedId(null),
|
||||||
|
}
|
||||||
|
}
|
||||||
22
components/syncProgressBar/SyncProgressBar.tsx
Normal file
22
components/syncProgressBar/SyncProgressBar.tsx
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { t } from '@/lib/i18n'
|
||||||
|
import { useSyncProgressBarController } from './controller'
|
||||||
|
import { SyncDateRange, SyncErrorBanner, SyncProgressSection, SyncResyncButton, SyncStatusMessage } from './view'
|
||||||
|
|
||||||
|
export function SyncProgressBar(): React.ReactElement | null {
|
||||||
|
const controller = useSyncProgressBarController()
|
||||||
|
if (!controller) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="bg-cyber-darker border border-neon-cyan/30 rounded-lg p-4 mt-6">
|
||||||
|
<SyncErrorBanner error={controller.error} onDismiss={controller.dismissError} />
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h3 className="text-lg font-semibold text-neon-cyan">{t('settings.sync.title')}</h3>
|
||||||
|
<SyncResyncButton isSyncing={controller.isSyncing} onClick={controller.onResyncClick} />
|
||||||
|
</div>
|
||||||
|
<SyncDateRange totalDays={controller.totalDays} startDate={controller.startDateLabel} endDate={controller.endDateLabel} />
|
||||||
|
<SyncProgressSection isSyncing={controller.isSyncing} syncProgress={controller.syncProgress} progressPercentage={controller.progressPercentage} />
|
||||||
|
<SyncStatusMessage isSyncing={controller.isSyncing} totalDays={controller.totalDays} isRecentlySynced={controller.isRecentlySynced} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
216
components/syncProgressBar/controller.ts
Normal file
216
components/syncProgressBar/controller.ts
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { nostrAuthService } from '@/lib/nostrAuth'
|
||||||
|
import { MIN_EVENT_DATE } from '@/lib/platformConfig'
|
||||||
|
import { objectCache } from '@/lib/objectCache'
|
||||||
|
import { calculateDaysBetween, getCurrentTimestamp, getLastSyncDate, setLastSyncDate as setLastSyncDateStorage } from '@/lib/syncStorage'
|
||||||
|
import { useSyncProgress } from '@/lib/hooks/useSyncProgress'
|
||||||
|
import type { SyncProgress, SyncProgressBarController } from './types'
|
||||||
|
|
||||||
|
type ConnectionState = { isInitialized: boolean; connected: boolean; pubkey: string | null }
|
||||||
|
|
||||||
|
export function useSyncProgressBarController(): SyncProgressBarController | null {
|
||||||
|
const connection = useNostrConnectionState()
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const syncStatus = useSyncStatus()
|
||||||
|
const { syncProgress, isSyncing, startMonitoring, stopMonitoring } = useSyncProgress({ onComplete: syncStatus.loadSyncStatus })
|
||||||
|
useAutoSyncEffect({ connection, isSyncing, loadSyncStatus: syncStatus.loadSyncStatus, startMonitoring, stopMonitoring, setError })
|
||||||
|
|
||||||
|
if (!connection.isInitialized || !connection.connected || !connection.pubkey) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const progressPercentage = computeProgressPercentage(syncProgress)
|
||||||
|
const isRecentlySynced = isRecentlySyncedFromLastSyncDate(syncStatus.lastSyncDate)
|
||||||
|
const startDate = getSyncStartDate(syncStatus.lastSyncDate)
|
||||||
|
const endDate = getCurrentTimestamp()
|
||||||
|
|
||||||
|
return {
|
||||||
|
error,
|
||||||
|
dismissError: () => setError(null),
|
||||||
|
isSyncing,
|
||||||
|
syncProgress,
|
||||||
|
progressPercentage,
|
||||||
|
totalDays: syncStatus.totalDays,
|
||||||
|
startDateLabel: formatSyncDate(startDate),
|
||||||
|
endDateLabel: formatSyncDate(endDate),
|
||||||
|
isRecentlySynced,
|
||||||
|
onResyncClick: () => {
|
||||||
|
void resynchronizeUserContent({ startMonitoring, stopMonitoring, loadSyncStatus: syncStatus.loadSyncStatus, setError })
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function useNostrConnectionState(): ConnectionState {
|
||||||
|
const initial = nostrAuthService.getState()
|
||||||
|
const [state, setState] = useState<{ connected: boolean; pubkey: string | null }>(() => ({
|
||||||
|
connected: initial.connected ?? false,
|
||||||
|
pubkey: initial.pubkey ?? null,
|
||||||
|
}))
|
||||||
|
const [isInitialized] = useState(true)
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = nostrAuthService.subscribe((next) => setState({ connected: next.connected ?? false, pubkey: next.pubkey ?? null }))
|
||||||
|
return () => unsubscribe()
|
||||||
|
}, [])
|
||||||
|
return { isInitialized, connected: state.connected, pubkey: state.pubkey }
|
||||||
|
}
|
||||||
|
|
||||||
|
function useSyncStatus(): { lastSyncDate: number | null; totalDays: number; loadSyncStatus: () => Promise<void> } {
|
||||||
|
const [lastSyncDate, setLastSyncDate] = useState<number | null>(null)
|
||||||
|
const [totalDays, setTotalDays] = useState<number>(0)
|
||||||
|
const loadSyncStatus = useCallback(async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const state = nostrAuthService.getState()
|
||||||
|
if (!state.connected || !state.pubkey) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const storedLastSyncDate = await getLastSyncDate()
|
||||||
|
const days = calculateDaysBetween(storedLastSyncDate, getCurrentTimestamp())
|
||||||
|
setLastSyncDate(storedLastSyncDate)
|
||||||
|
setTotalDays(days)
|
||||||
|
} catch (loadError) {
|
||||||
|
console.error('[SyncProgressBar] Error loading sync status:', loadError)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
return { lastSyncDate, totalDays, loadSyncStatus }
|
||||||
|
}
|
||||||
|
|
||||||
|
function useAutoSyncEffect(params: {
|
||||||
|
connection: ConnectionState
|
||||||
|
isSyncing: boolean
|
||||||
|
loadSyncStatus: () => Promise<void>
|
||||||
|
startMonitoring: () => void
|
||||||
|
stopMonitoring: () => void
|
||||||
|
setError: (value: string | null) => void
|
||||||
|
}): void {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!params.connection.isInitialized || !params.connection.connected || !params.connection.pubkey) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void runAutoSyncCheck({
|
||||||
|
connection: { connected: true, pubkey: params.connection.pubkey },
|
||||||
|
isSyncing: params.isSyncing,
|
||||||
|
loadSyncStatus: params.loadSyncStatus,
|
||||||
|
startMonitoring: params.startMonitoring,
|
||||||
|
stopMonitoring: params.stopMonitoring,
|
||||||
|
setError: params.setError,
|
||||||
|
})
|
||||||
|
}, [params.connection.connected, params.connection.isInitialized, params.connection.pubkey, params.isSyncing, params.loadSyncStatus, params.setError, params.startMonitoring, params.stopMonitoring])
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSyncDate(timestamp: number): string {
|
||||||
|
const date = new Date(timestamp * 1000)
|
||||||
|
const locale = typeof window !== 'undefined' ? navigator.language : 'fr-FR'
|
||||||
|
return date.toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSyncStartDate(lastSyncDate: number | null): number {
|
||||||
|
return lastSyncDate ?? MIN_EVENT_DATE
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecentlySyncedFromLastSyncDate(lastSyncDate: number | null): boolean {
|
||||||
|
return lastSyncDate !== null && lastSyncDate >= getCurrentTimestamp() - 3600
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resynchronizeUserContent(params: {
|
||||||
|
startMonitoring: () => void
|
||||||
|
stopMonitoring: () => void
|
||||||
|
loadSyncStatus: () => Promise<void>
|
||||||
|
setError: (value: string | null) => void
|
||||||
|
}): Promise<void> {
|
||||||
|
try {
|
||||||
|
const state = nostrAuthService.getState()
|
||||||
|
if (!state.connected || !state.pubkey) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await clearUserContentCache()
|
||||||
|
await setLastSyncDateStorage(MIN_EVENT_DATE)
|
||||||
|
await params.loadSyncStatus()
|
||||||
|
await startUserSyncOrStop({ pubkey: state.pubkey, startMonitoring: params.startMonitoring, stopMonitoring: params.stopMonitoring })
|
||||||
|
} catch (resyncError) {
|
||||||
|
console.error('[SyncProgressBar] Error resynchronizing:', resyncError)
|
||||||
|
params.stopMonitoring()
|
||||||
|
params.setError(resyncError instanceof Error ? resyncError.message : 'Erreur de synchronisation')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearUserContentCache(): Promise<void> {
|
||||||
|
await Promise.all([
|
||||||
|
objectCache.clear('author'),
|
||||||
|
objectCache.clear('series'),
|
||||||
|
objectCache.clear('publication'),
|
||||||
|
objectCache.clear('review'),
|
||||||
|
objectCache.clear('purchase'),
|
||||||
|
objectCache.clear('sponsoring'),
|
||||||
|
objectCache.clear('review_tip'),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startUserSyncOrStop(params: { pubkey: string; startMonitoring: () => void; stopMonitoring: () => void }): Promise<void> {
|
||||||
|
const { swClient } = await import('@/lib/swClient')
|
||||||
|
const isReady = await swClient.isReady()
|
||||||
|
if (!isReady) {
|
||||||
|
params.stopMonitoring()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await swClient.startUserSync(params.pubkey)
|
||||||
|
params.startMonitoring()
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeProgressPercentage(syncProgress: SyncProgress): number {
|
||||||
|
if (!syncProgress || syncProgress.totalSteps <= 0) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return Math.min(100, (syncProgress.currentStep / syncProgress.totalSteps) * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAutoSyncCheck(params: {
|
||||||
|
connection: { connected: boolean; pubkey: string | null }
|
||||||
|
isSyncing: boolean
|
||||||
|
loadSyncStatus: () => Promise<void>
|
||||||
|
startMonitoring: () => void
|
||||||
|
stopMonitoring: () => void
|
||||||
|
setError: (value: string | null) => void
|
||||||
|
}): Promise<void> {
|
||||||
|
console.warn('[SyncProgressBar] Starting sync check...')
|
||||||
|
await params.loadSyncStatus()
|
||||||
|
const shouldStart = await shouldAutoStartSync({ isSyncing: params.isSyncing, pubkey: params.connection.pubkey })
|
||||||
|
if (!shouldStart || !params.connection.pubkey) {
|
||||||
|
console.warn('[SyncProgressBar] Skipping auto-sync:', { shouldStart, isSyncing: params.isSyncing, hasPubkey: Boolean(params.connection.pubkey) })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.warn('[SyncProgressBar] Starting auto-sync...')
|
||||||
|
await startAutoSync({
|
||||||
|
pubkey: params.connection.pubkey,
|
||||||
|
startMonitoring: params.startMonitoring,
|
||||||
|
stopMonitoring: params.stopMonitoring,
|
||||||
|
setError: params.setError,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function shouldAutoStartSync(params: { isSyncing: boolean; pubkey: string | null }): Promise<boolean> {
|
||||||
|
if (params.isSyncing || !params.pubkey) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const storedLastSyncDate = await getLastSyncDate()
|
||||||
|
const currentTimestamp = getCurrentTimestamp()
|
||||||
|
const isRecentlySynced = storedLastSyncDate >= currentTimestamp - 3600
|
||||||
|
console.warn('[SyncProgressBar] Sync status:', { storedLastSyncDate, currentTimestamp, isRecentlySynced })
|
||||||
|
return !isRecentlySynced
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startAutoSync(params: { pubkey: string; startMonitoring: () => void; stopMonitoring: () => void; setError: (value: string | null) => void }): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { swClient } = await import('@/lib/swClient')
|
||||||
|
const isReady = await swClient.isReady()
|
||||||
|
if (!isReady) {
|
||||||
|
params.stopMonitoring()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await swClient.startUserSync(params.pubkey)
|
||||||
|
params.startMonitoring()
|
||||||
|
} catch (autoSyncError) {
|
||||||
|
console.error('[SyncProgressBar] Error during auto-sync:', autoSyncError)
|
||||||
|
params.stopMonitoring()
|
||||||
|
params.setError(autoSyncError instanceof Error ? autoSyncError.message : 'Erreur de synchronisation')
|
||||||
|
}
|
||||||
|
}
|
||||||
16
components/syncProgressBar/types.ts
Normal file
16
components/syncProgressBar/types.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import type { useSyncProgress } from '@/lib/hooks/useSyncProgress'
|
||||||
|
|
||||||
|
export type SyncProgress = ReturnType<typeof useSyncProgress>['syncProgress']
|
||||||
|
|
||||||
|
export type SyncProgressBarController = {
|
||||||
|
error: string | null
|
||||||
|
dismissError: () => void
|
||||||
|
isSyncing: boolean
|
||||||
|
syncProgress: SyncProgress
|
||||||
|
progressPercentage: number
|
||||||
|
totalDays: number
|
||||||
|
startDateLabel: string
|
||||||
|
endDateLabel: string
|
||||||
|
isRecentlySynced: boolean
|
||||||
|
onResyncClick: () => void
|
||||||
|
}
|
||||||
73
components/syncProgressBar/view.tsx
Normal file
73
components/syncProgressBar/view.tsx
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import { t } from '@/lib/i18n'
|
||||||
|
import type { SyncProgress } from './types'
|
||||||
|
|
||||||
|
export function SyncErrorBanner(params: { error: string | null; onDismiss: () => void }): React.ReactElement | null {
|
||||||
|
if (!params.error) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="mb-4 bg-red-900/30 border border-red-500/50 rounded p-3 text-red-300 text-sm">
|
||||||
|
{params.error}
|
||||||
|
<button type="button" onClick={params.onDismiss} className="ml-2 text-red-400 hover:text-red-200">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SyncResyncButton(params: { isSyncing: boolean; onClick: () => void }): React.ReactElement | null {
|
||||||
|
if (params.isSyncing) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={params.onClick}
|
||||||
|
className="px-3 py-1 text-xs bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded border border-neon-cyan/50 hover:border-neon-cyan transition-colors"
|
||||||
|
>
|
||||||
|
{t('settings.sync.resync')}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SyncDateRange(params: { totalDays: number; startDate: string; endDate: string }): React.ReactElement | null {
|
||||||
|
if (params.totalDays <= 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="mb-2">
|
||||||
|
<p className="text-sm text-cyber-accent">
|
||||||
|
{t('settings.sync.daysRange', { startDate: params.startDate, endDate: params.endDate, days: params.totalDays })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SyncProgressSection(params: { isSyncing: boolean; syncProgress: SyncProgress; progressPercentage: number }): React.ReactElement | null {
|
||||||
|
if (!params.isSyncing || !params.syncProgress) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-cyber-accent">
|
||||||
|
{t('settings.sync.progress', { current: params.syncProgress.currentStep, total: params.syncProgress.totalSteps })}
|
||||||
|
</span>
|
||||||
|
<span className="text-neon-cyan font-semibold">{Math.round(params.progressPercentage)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-cyber-dark rounded-full h-2 overflow-hidden">
|
||||||
|
<div className="bg-neon-cyan h-full transition-all duration-300" style={{ width: `${params.progressPercentage}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SyncStatusMessage(params: { isSyncing: boolean; totalDays: number; isRecentlySynced: boolean }): React.ReactElement | null {
|
||||||
|
if (params.isSyncing || params.totalDays !== 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (params.isRecentlySynced) {
|
||||||
|
return <p className="text-sm text-green-400">{t('settings.sync.completed')}</p>
|
||||||
|
}
|
||||||
|
return <p className="text-sm text-cyber-accent">{t('settings.sync.ready')}</p>
|
||||||
|
}
|
||||||
26
components/unlockAccount/UnlockAccountButtons.tsx
Normal file
26
components/unlockAccount/UnlockAccountButtons.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
export function UnlockAccountButtons(params: {
|
||||||
|
loading: boolean
|
||||||
|
words: string[]
|
||||||
|
onUnlock: () => void
|
||||||
|
onClose: () => void
|
||||||
|
}): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={params.onClose}
|
||||||
|
className="flex-1 py-2 px-4 bg-gray-200 hover:bg-gray-300 rounded-lg font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={params.onUnlock}
|
||||||
|
disabled={params.loading || params.words.some((word) => !word)}
|
||||||
|
className="flex-1 py-2 px-4 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"
|
||||||
|
>
|
||||||
|
{params.loading ? 'Déverrouillage...' : 'Déverrouiller'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
45
components/unlockAccount/UnlockAccountForm.tsx
Normal file
45
components/unlockAccount/UnlockAccountForm.tsx
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { WordInputWithAutocomplete } from './WordInputWithAutocomplete'
|
||||||
|
|
||||||
|
export function UnlockAccountForm(params: {
|
||||||
|
words: string[]
|
||||||
|
onWordChange: (index: number, value: string) => void
|
||||||
|
onPaste: () => void
|
||||||
|
}): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="mb-4">
|
||||||
|
<WordInputsGrid words={params.words} onWordChange={params.onWordChange} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={params.onPaste}
|
||||||
|
className="mt-2 text-sm text-gray-600 hover:text-gray-800 underline"
|
||||||
|
>
|
||||||
|
Coller depuis le presse-papiers
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function WordInputsGrid(params: { words: string[]; onWordChange: (index: number, value: string) => void }): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<WordInputSlot index={0} value={params.words[0] ?? ''} onWordChange={params.onWordChange} />
|
||||||
|
<WordInputSlot index={1} value={params.words[1] ?? ''} onWordChange={params.onWordChange} />
|
||||||
|
<WordInputSlot index={2} value={params.words[2] ?? ''} onWordChange={params.onWordChange} />
|
||||||
|
<WordInputSlot index={3} value={params.words[3] ?? ''} onWordChange={params.onWordChange} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function WordInputSlot(params: { index: number; value: string; onWordChange: (index: number, value: string) => void }): React.ReactElement {
|
||||||
|
const onFocus = (): void => {}
|
||||||
|
const onBlur = (): void => {}
|
||||||
|
return (
|
||||||
|
<WordInputWithAutocomplete
|
||||||
|
index={params.index}
|
||||||
|
value={params.value}
|
||||||
|
onChange={(value) => params.onWordChange(params.index, value)}
|
||||||
|
onFocus={onFocus}
|
||||||
|
onBlur={onBlur}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
35
components/unlockAccount/UnlockAccountModal.tsx
Normal file
35
components/unlockAccount/UnlockAccountModal.tsx
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import type { UnlockAccountModalProps } from './types'
|
||||||
|
import { UnlockAccountButtons } from './UnlockAccountButtons'
|
||||||
|
import { UnlockAccountForm } from './UnlockAccountForm'
|
||||||
|
import { useUnlockAccountController } from './useUnlockAccountController'
|
||||||
|
|
||||||
|
export function UnlockAccountModal(params: UnlockAccountModalProps): React.ReactElement {
|
||||||
|
const [words, setWords] = useState<string[]>(['', '', '', ''])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const controller = useUnlockAccountController({
|
||||||
|
words,
|
||||||
|
setWords,
|
||||||
|
setLoading,
|
||||||
|
setError,
|
||||||
|
onSuccess: params.onSuccess,
|
||||||
|
onClose: params.onClose,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
|
||||||
|
<h2 className="text-2xl font-bold mb-4">Déverrouiller votre compte</h2>
|
||||||
|
<p className="text-gray-600 mb-6">
|
||||||
|
Entrez vos 4 mots-clés de récupération (dictionnaire BIP39) pour déverrouiller votre compte. Ces mots déchiffrent la clé de chiffrement
|
||||||
|
(KEK) stockée dans l'API Credentials, qui déchiffre ensuite votre clé privée.
|
||||||
|
</p>
|
||||||
|
<UnlockAccountForm words={words} onWordChange={controller.handleWordChange} onPaste={controller.handlePaste} />
|
||||||
|
{error ? <p className="text-sm text-red-600 mb-4">{error}</p> : null}
|
||||||
|
<UnlockAccountButtons loading={loading} words={words} onUnlock={controller.handleUnlock} onClose={params.onClose} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
52
components/unlockAccount/WordInputWithAutocomplete.tsx
Normal file
52
components/unlockAccount/WordInputWithAutocomplete.tsx
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { useRef } from 'react'
|
||||||
|
import { useWordAutocomplete } from './useWordAutocomplete'
|
||||||
|
import { WordSuggestions } from './WordSuggestions'
|
||||||
|
|
||||||
|
export interface WordInputWithAutocompleteProps {
|
||||||
|
index: number
|
||||||
|
value: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
onFocus: () => void
|
||||||
|
onBlur: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WordInputWithAutocomplete(params: WordInputWithAutocompleteProps): React.ReactElement {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const autocomplete = useWordAutocomplete({ value: params.value, onChange: params.onChange, inputRef })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<label htmlFor={`word-${params.index}`} className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Mot {params.index + 1}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
id={`word-${params.index}`}
|
||||||
|
type="text"
|
||||||
|
value={params.value}
|
||||||
|
onChange={autocomplete.handleChange}
|
||||||
|
onKeyDown={autocomplete.handleKeyDown}
|
||||||
|
onFocus={params.onFocus}
|
||||||
|
onBlur={() => hideSuggestionsOnBlur({ onBlur: params.onBlur, setShowSuggestions: autocomplete.setShowSuggestions })}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg font-mono text-lg text-center"
|
||||||
|
autoComplete="off"
|
||||||
|
autoCapitalize="off"
|
||||||
|
autoCorrect="off"
|
||||||
|
spellCheck="false"
|
||||||
|
/>
|
||||||
|
<WordSuggestions
|
||||||
|
showSuggestions={autocomplete.showSuggestions}
|
||||||
|
suggestions={autocomplete.suggestions}
|
||||||
|
selectedIndex={autocomplete.selectedIndex}
|
||||||
|
onSuggestionClick={autocomplete.applySuggestion}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideSuggestionsOnBlur(params: { onBlur: () => void; setShowSuggestions: (value: boolean) => void }): void {
|
||||||
|
setTimeout(() => {
|
||||||
|
params.setShowSuggestions(false)
|
||||||
|
params.onBlur()
|
||||||
|
}, 200)
|
||||||
|
}
|
||||||
24
components/unlockAccount/WordSuggestions.tsx
Normal file
24
components/unlockAccount/WordSuggestions.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
export function WordSuggestions(params: {
|
||||||
|
showSuggestions: boolean
|
||||||
|
suggestions: string[]
|
||||||
|
selectedIndex: number
|
||||||
|
onSuggestionClick: (suggestion: string) => void
|
||||||
|
}): React.ReactElement | null {
|
||||||
|
if (!params.showSuggestions || params.suggestions.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="absolute z-10 w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg max-h-40 overflow-y-auto">
|
||||||
|
{params.suggestions.map((suggestion, idx) => (
|
||||||
|
<button
|
||||||
|
key={suggestion}
|
||||||
|
type="button"
|
||||||
|
onClick={() => params.onSuggestionClick(suggestion)}
|
||||||
|
className={`w-full text-left px-3 py-2 hover:bg-gray-100 ${idx === params.selectedIndex ? 'bg-gray-100' : ''}`}
|
||||||
|
>
|
||||||
|
{suggestion}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
27
components/unlockAccount/autocompleteKeyDecision.ts
Normal file
27
components/unlockAccount/autocompleteKeyDecision.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
export type AutocompleteKeyDecision =
|
||||||
|
| { action: 'none' }
|
||||||
|
| { action: 'escape' }
|
||||||
|
| { action: 'move'; nextIndex: number }
|
||||||
|
| { action: 'select'; index: number }
|
||||||
|
|
||||||
|
export function decideAutocompleteKeyAction(params: {
|
||||||
|
key: string
|
||||||
|
selectedIndex: number
|
||||||
|
suggestionsCount: number
|
||||||
|
}): AutocompleteKeyDecision {
|
||||||
|
if (params.key === 'Escape') {
|
||||||
|
return { action: 'escape' }
|
||||||
|
}
|
||||||
|
if (params.key === 'ArrowDown') {
|
||||||
|
const nextIndex = params.selectedIndex < params.suggestionsCount - 1 ? params.selectedIndex + 1 : params.selectedIndex
|
||||||
|
return { action: 'move', nextIndex }
|
||||||
|
}
|
||||||
|
if (params.key === 'ArrowUp') {
|
||||||
|
const nextIndex = params.selectedIndex > 0 ? params.selectedIndex - 1 : -1
|
||||||
|
return { action: 'move', nextIndex }
|
||||||
|
}
|
||||||
|
if (params.key === 'Enter' && params.selectedIndex >= 0) {
|
||||||
|
return { action: 'select', index: params.selectedIndex }
|
||||||
|
}
|
||||||
|
return { action: 'none' }
|
||||||
|
}
|
||||||
4
components/unlockAccount/types.ts
Normal file
4
components/unlockAccount/types.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export interface UnlockAccountModalProps {
|
||||||
|
onSuccess: () => void
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
92
components/unlockAccount/useUnlockAccountController.ts
Normal file
92
components/unlockAccount/useUnlockAccountController.ts
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import { nostrAuthService } from '@/lib/nostrAuth'
|
||||||
|
|
||||||
|
export interface UseUnlockAccountControllerResult {
|
||||||
|
handleWordChange: (index: number, value: string) => void
|
||||||
|
handlePaste: () => void
|
||||||
|
handleUnlock: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUnlockAccountController(params: {
|
||||||
|
words: string[]
|
||||||
|
setWords: (words: string[]) => void
|
||||||
|
setLoading: (loading: boolean) => void
|
||||||
|
setError: (error: string | null) => void
|
||||||
|
onSuccess: () => void
|
||||||
|
onClose: () => void
|
||||||
|
}): UseUnlockAccountControllerResult {
|
||||||
|
const handleWordChange = (index: number, value: string): void => {
|
||||||
|
const nextWords = buildNextWords({ current: params.words, index, value })
|
||||||
|
params.setWords(nextWords)
|
||||||
|
params.setError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePaste = (): void => {
|
||||||
|
void pasteWordsFromClipboard({ setWords: params.setWords, setError: params.setError })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUnlock = (): void => {
|
||||||
|
void unlockAccount({
|
||||||
|
words: params.words,
|
||||||
|
setLoading: params.setLoading,
|
||||||
|
setError: params.setError,
|
||||||
|
onSuccess: params.onSuccess,
|
||||||
|
onClose: params.onClose,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { handleWordChange, handlePaste, handleUnlock }
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildNextWords(params: { current: string[]; index: number; value: string }): string[] {
|
||||||
|
const next = [...params.current]
|
||||||
|
next[params.index] = normalizeWord(params.value)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeWord(value: string): string {
|
||||||
|
return value.trim().toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pasteWordsFromClipboard(params: {
|
||||||
|
setWords: (words: string[]) => void
|
||||||
|
setError: (error: string | null) => void
|
||||||
|
}): Promise<void> {
|
||||||
|
try {
|
||||||
|
const text = await navigator.clipboard.readText()
|
||||||
|
const words = text.trim().split(/\s+/).slice(0, 4).map(normalizeWord)
|
||||||
|
if (words.length !== 4) {
|
||||||
|
params.setError('Le presse-papiers ne contient pas 4 mots.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
params.setWords(words)
|
||||||
|
params.setError(null)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[UnlockAccountModal] Error reading clipboard:', e)
|
||||||
|
params.setError('Impossible de lire le presse-papiers.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unlockAccount(params: {
|
||||||
|
words: string[]
|
||||||
|
setLoading: (loading: boolean) => void
|
||||||
|
setError: (error: string | null) => void
|
||||||
|
onSuccess: () => void
|
||||||
|
onClose: () => void
|
||||||
|
}): Promise<void> {
|
||||||
|
if (params.words.some((word) => !word)) {
|
||||||
|
params.setError('Veuillez remplir tous les mots-clés')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
params.setLoading(true)
|
||||||
|
params.setError(null)
|
||||||
|
try {
|
||||||
|
await nostrAuthService.unlockAccount(params.words)
|
||||||
|
params.onSuccess()
|
||||||
|
params.onClose()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[UnlockAccountModal] Error unlocking account:', e)
|
||||||
|
params.setError(e instanceof Error ? e.message : 'Échec du déverrouillage. Vérifiez vos mots-clés.')
|
||||||
|
} finally {
|
||||||
|
params.setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
108
components/unlockAccount/useWordAutocomplete.ts
Normal file
108
components/unlockAccount/useWordAutocomplete.ts
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { getWordSuggestions } from '@/lib/keyManagementBIP39'
|
||||||
|
import { decideAutocompleteKeyAction } from './autocompleteKeyDecision'
|
||||||
|
|
||||||
|
export interface WordAutocompleteState {
|
||||||
|
suggestions: string[]
|
||||||
|
showSuggestions: boolean
|
||||||
|
setShowSuggestions: (value: boolean) => void
|
||||||
|
selectedIndex: number
|
||||||
|
handleChange: (event: React.ChangeEvent<HTMLInputElement>) => void
|
||||||
|
handleKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void
|
||||||
|
applySuggestion: (suggestion: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWordAutocomplete(params: {
|
||||||
|
value: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
inputRef: React.RefObject<HTMLInputElement | null>
|
||||||
|
}): WordAutocompleteState {
|
||||||
|
const [showSuggestions, setShowSuggestions] = useState(false)
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(-1)
|
||||||
|
const suggestions = useWordSuggestions(params.value)
|
||||||
|
|
||||||
|
const applySuggestion = (suggestion: string): void => {
|
||||||
|
applySuggestionImpl({ suggestion, onChange: params.onChange, setShowSuggestions, inputRef: params.inputRef })
|
||||||
|
}
|
||||||
|
const handleChange = (event: React.ChangeEvent<HTMLInputElement>): void => {
|
||||||
|
handleChangeImpl({ event, onChange: params.onChange, setShowSuggestions, setSelectedIndex })
|
||||||
|
}
|
||||||
|
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>): void => {
|
||||||
|
handleKeyDownImpl({
|
||||||
|
event,
|
||||||
|
suggestions,
|
||||||
|
selectedIndex,
|
||||||
|
setSelectedIndex,
|
||||||
|
setShowSuggestions,
|
||||||
|
inputRef: params.inputRef,
|
||||||
|
applySuggestion,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { suggestions, showSuggestions, setShowSuggestions, selectedIndex, handleChange, handleKeyDown, applySuggestion }
|
||||||
|
}
|
||||||
|
|
||||||
|
function useWordSuggestions(value: string): string[] {
|
||||||
|
return useMemo((): string[] => {
|
||||||
|
if (value.length === 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return getWordSuggestions(value, 5)
|
||||||
|
}, [value])
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySuggestionImpl(params: {
|
||||||
|
suggestion: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
setShowSuggestions: (value: boolean) => void
|
||||||
|
inputRef: React.RefObject<HTMLInputElement | null>
|
||||||
|
}): void {
|
||||||
|
params.onChange(params.suggestion)
|
||||||
|
params.setShowSuggestions(false)
|
||||||
|
params.inputRef.current?.blur()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChangeImpl(params: {
|
||||||
|
event: React.ChangeEvent<HTMLInputElement>
|
||||||
|
onChange: (value: string) => void
|
||||||
|
setShowSuggestions: (value: boolean) => void
|
||||||
|
setSelectedIndex: (value: number) => void
|
||||||
|
}): void {
|
||||||
|
const newValue = params.event.target.value.trim().toLowerCase()
|
||||||
|
params.setSelectedIndex(-1)
|
||||||
|
params.setShowSuggestions(newValue.length > 0)
|
||||||
|
params.onChange(newValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDownImpl(params: {
|
||||||
|
event: React.KeyboardEvent<HTMLInputElement>
|
||||||
|
suggestions: string[]
|
||||||
|
selectedIndex: number
|
||||||
|
setSelectedIndex: (value: number) => void
|
||||||
|
setShowSuggestions: (value: boolean) => void
|
||||||
|
inputRef: React.RefObject<HTMLInputElement | null>
|
||||||
|
applySuggestion: (suggestion: string) => void
|
||||||
|
}): void {
|
||||||
|
const decision = decideAutocompleteKeyAction({
|
||||||
|
key: params.event.key,
|
||||||
|
selectedIndex: params.selectedIndex,
|
||||||
|
suggestionsCount: params.suggestions.length,
|
||||||
|
})
|
||||||
|
if (decision.action === 'none') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
params.event.preventDefault()
|
||||||
|
if (decision.action === 'move') {
|
||||||
|
params.setSelectedIndex(decision.nextIndex)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (decision.action === 'escape') {
|
||||||
|
params.setShowSuggestions(false)
|
||||||
|
params.inputRef.current?.blur()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const suggestion = params.suggestions[decision.index]
|
||||||
|
if (suggestion) {
|
||||||
|
params.applySuggestion(suggestion)
|
||||||
|
}
|
||||||
|
}
|
||||||
17
components/userArticles/UserArticles.tsx
Normal file
17
components/userArticles/UserArticles.tsx
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { useUserArticlesController } from './controller'
|
||||||
|
import { UserArticlesLayout } from './layout'
|
||||||
|
import type { UserArticlesProps } from './types'
|
||||||
|
|
||||||
|
export function UserArticles(props: UserArticlesProps): React.ReactElement {
|
||||||
|
const controller = useUserArticlesController({ articles: props.articles, onLoadContent: props.onLoadContent, currentPubkey: props.currentPubkey })
|
||||||
|
return (
|
||||||
|
<UserArticlesLayout
|
||||||
|
controller={controller}
|
||||||
|
loading={props.loading}
|
||||||
|
error={props.error}
|
||||||
|
showEmptyMessage={props.showEmptyMessage ?? true}
|
||||||
|
currentPubkey={props.currentPubkey}
|
||||||
|
onSelectSeries={props.onSelectSeries}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
101
components/userArticles/controller.ts
Normal file
101
components/userArticles/controller.ts
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import type { Article } from '@/types/nostr'
|
||||||
|
import { useArticleEditing } from '@/hooks/useArticleEditing'
|
||||||
|
import type { ArticleDraft } from '@/lib/articlePublisherTypes'
|
||||||
|
import type { UserArticlesController, UserArticlesControllerParams } from './types'
|
||||||
|
|
||||||
|
export function useUserArticlesController(params: UserArticlesControllerParams): UserArticlesController {
|
||||||
|
const [deletedArticleIds, setDeletedArticleIds] = useState<Set<string>>(new Set())
|
||||||
|
const [articleOverridesById, setArticleOverridesById] = useState<Map<string, Article>>(new Map())
|
||||||
|
const [unlockedArticles, setUnlockedArticles] = useState<Set<string>>(new Set())
|
||||||
|
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null)
|
||||||
|
const editingCtx = useArticleEditing(params.currentPubkey)
|
||||||
|
|
||||||
|
const localArticles = useMemo((): Article[] => {
|
||||||
|
return params.articles.filter((a) => !deletedArticleIds.has(a.id)).map((a) => articleOverridesById.get(a.id) ?? a)
|
||||||
|
}, [articleOverridesById, deletedArticleIds, params.articles])
|
||||||
|
|
||||||
|
return {
|
||||||
|
localArticles,
|
||||||
|
unlockedArticles,
|
||||||
|
pendingDeleteId,
|
||||||
|
requestDelete: (id: string) => setPendingDeleteId(id),
|
||||||
|
handleUnlock: createHandleUnlock(params.onLoadContent, setUnlockedArticles),
|
||||||
|
handleDelete: createHandleDelete(editingCtx.deleteArticle, setDeletedArticleIds, setPendingDeleteId),
|
||||||
|
handleEditSubmit: createHandleEditSubmit(editingCtx.submitEdit, editingCtx.editingDraft, params.currentPubkey, setArticleOverridesById),
|
||||||
|
...editingCtx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHandleUnlock(
|
||||||
|
onLoadContent: (id: string, pubkey: string) => Promise<Article | null>,
|
||||||
|
setUnlocked: React.Dispatch<React.SetStateAction<Set<string>>>
|
||||||
|
): (article: Article) => Promise<void> {
|
||||||
|
return async (article: Article): Promise<void> => {
|
||||||
|
const full = await onLoadContent(article.id, article.pubkey)
|
||||||
|
if (full?.paid) {
|
||||||
|
setUnlocked((prev) => new Set([...prev, article.id]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHandleDelete(
|
||||||
|
deleteArticle: (id: string) => Promise<boolean>,
|
||||||
|
setDeletedArticleIds: React.Dispatch<React.SetStateAction<Set<string>>>,
|
||||||
|
setPendingDeleteId: React.Dispatch<React.SetStateAction<string | null>>
|
||||||
|
): (article: Article) => Promise<void> {
|
||||||
|
return async (article: Article): Promise<void> => {
|
||||||
|
const ok = await deleteArticle(article.id)
|
||||||
|
if (ok) {
|
||||||
|
setDeletedArticleIds((prev) => new Set([...prev, article.id]))
|
||||||
|
}
|
||||||
|
setPendingDeleteId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHandleEditSubmit(
|
||||||
|
submitEdit: () => Promise<import('@/lib/articleMutations').ArticleUpdateResult | null>,
|
||||||
|
draft: ReturnType<typeof useArticleEditing>['editingDraft'],
|
||||||
|
currentPubkey: string | null,
|
||||||
|
setArticleOverridesById: React.Dispatch<React.SetStateAction<Map<string, Article>>>
|
||||||
|
): () => Promise<void> {
|
||||||
|
return async (): Promise<void> => {
|
||||||
|
const result = await submitEdit()
|
||||||
|
if (result && draft) {
|
||||||
|
const updated = buildUpdatedArticle(draft, currentPubkey ?? '', result.articleId)
|
||||||
|
setArticleOverridesById((prev) => {
|
||||||
|
const next = new Map(prev)
|
||||||
|
next.set(result.originalArticleId, { ...updated, id: result.originalArticleId })
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUpdatedArticle(draft: ArticleDraft, pubkey: string, newId: string): Article {
|
||||||
|
const parts = newId.split('_')
|
||||||
|
const hash = parts[0] ?? ''
|
||||||
|
const index = Number.parseInt(parts[1] ?? '0', 10)
|
||||||
|
const version = Number.parseInt(parts[2] ?? '0', 10)
|
||||||
|
return {
|
||||||
|
id: newId,
|
||||||
|
hash,
|
||||||
|
version,
|
||||||
|
index,
|
||||||
|
pubkey,
|
||||||
|
title: draft.title,
|
||||||
|
preview: draft.preview,
|
||||||
|
content: '',
|
||||||
|
description: draft.preview,
|
||||||
|
contentDescription: draft.preview,
|
||||||
|
createdAt: Math.floor(Date.now() / 1000),
|
||||||
|
zapAmount: draft.zapAmount,
|
||||||
|
paid: false,
|
||||||
|
thumbnailUrl: draft.bannerUrl ?? '',
|
||||||
|
...(draft.category ? { category: draft.category } : {}),
|
||||||
|
...(draft.seriesId ? { seriesId: draft.seriesId } : {}),
|
||||||
|
...(draft.bannerUrl ? { bannerUrl: draft.bannerUrl } : {}),
|
||||||
|
...(draft.media ? { media: draft.media } : {}),
|
||||||
|
...(draft.pages ? { pages: draft.pages } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
93
components/userArticles/layout.tsx
Normal file
93
components/userArticles/layout.tsx
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
import type { Article } from '@/types/nostr'
|
||||||
|
import type { ArticleDraft } from '@/lib/articlePublisherTypes'
|
||||||
|
import { EditPanel } from '../UserArticlesEditPanel'
|
||||||
|
import { UserArticlesView } from '../UserArticlesList'
|
||||||
|
import type { UserArticlesController } from './types'
|
||||||
|
|
||||||
|
type UserArticlesLayoutProps = {
|
||||||
|
controller: UserArticlesController
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
showEmptyMessage: boolean
|
||||||
|
currentPubkey: string | null
|
||||||
|
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserArticlesLayout(props: UserArticlesLayoutProps): React.ReactElement {
|
||||||
|
const { editPanelProps, listProps } = createLayoutProps(props.controller, props)
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<EditPanel {...editPanelProps} />
|
||||||
|
<UserArticlesView {...listProps} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLayoutProps(controller: UserArticlesController, view: UserArticlesLayoutProps): { editPanelProps: EditPanelProps; listProps: UserArticlesListProps } {
|
||||||
|
return { editPanelProps: buildEditPanelProps(controller), listProps: buildListProps(controller, view) }
|
||||||
|
}
|
||||||
|
|
||||||
|
type EditPanelProps = {
|
||||||
|
draft: ArticleDraft | null
|
||||||
|
editingArticleId: string | null
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
onCancel: () => void
|
||||||
|
onDraftChange: (draft: ArticleDraft) => void
|
||||||
|
onSubmit: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEditPanelProps(controller: UserArticlesController): EditPanelProps {
|
||||||
|
return {
|
||||||
|
draft: controller.editingDraft,
|
||||||
|
editingArticleId: controller.editingArticleId,
|
||||||
|
loading: controller.loading,
|
||||||
|
error: controller.error,
|
||||||
|
onCancel: controller.cancelEditing,
|
||||||
|
onDraftChange: controller.updateDraft,
|
||||||
|
onSubmit: controller.handleEditSubmit,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserArticlesListProps = {
|
||||||
|
articles: Article[]
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
showEmptyMessage: boolean
|
||||||
|
unlockedArticles: Set<string>
|
||||||
|
onUnlock: (article: Article) => void
|
||||||
|
onEdit: (article: Article) => void
|
||||||
|
onDelete: (article: Article) => void
|
||||||
|
editingArticleId: string | null
|
||||||
|
currentPubkey: string | null
|
||||||
|
pendingDeleteId: string | null
|
||||||
|
requestDelete: (articleId: string) => void
|
||||||
|
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildListProps(controller: UserArticlesController, view: UserArticlesLayoutProps): UserArticlesListProps {
|
||||||
|
const handlers = buildUserArticlesHandlers(controller)
|
||||||
|
return {
|
||||||
|
articles: controller.localArticles,
|
||||||
|
loading: view.loading,
|
||||||
|
error: view.error,
|
||||||
|
showEmptyMessage: view.showEmptyMessage,
|
||||||
|
unlockedArticles: controller.unlockedArticles,
|
||||||
|
onUnlock: handlers.onUnlock,
|
||||||
|
onEdit: handlers.onEdit,
|
||||||
|
onDelete: handlers.onDelete,
|
||||||
|
editingArticleId: controller.editingArticleId,
|
||||||
|
currentPubkey: view.currentPubkey,
|
||||||
|
pendingDeleteId: controller.pendingDeleteId,
|
||||||
|
requestDelete: controller.requestDelete,
|
||||||
|
...(view.onSelectSeries ? { onSelectSeries: view.onSelectSeries } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUserArticlesHandlers(controller: UserArticlesController): { onUnlock: (article: Article) => void; onEdit: (article: Article) => void; onDelete: (article: Article) => void } {
|
||||||
|
return {
|
||||||
|
onUnlock: (a: Article): void => void controller.handleUnlock(a),
|
||||||
|
onEdit: (a: Article): void => void controller.startEditing(a),
|
||||||
|
onDelete: (a: Article): void => void controller.handleDelete(a),
|
||||||
|
}
|
||||||
|
}
|
||||||
45
components/userArticles/types.ts
Normal file
45
components/userArticles/types.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import type { Dispatch, SetStateAction } from 'react'
|
||||||
|
import type { Article } from '@/types/nostr'
|
||||||
|
import type { ArticleDraft } from '@/lib/articlePublisherTypes'
|
||||||
|
import type { useArticleEditing } from '@/hooks/useArticleEditing'
|
||||||
|
|
||||||
|
export interface UserArticlesProps {
|
||||||
|
articles: Article[]
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
onLoadContent: (articleId: string, authorPubkey: string) => Promise<Article | null>
|
||||||
|
showEmptyMessage?: boolean
|
||||||
|
currentPubkey: string | null
|
||||||
|
onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UserArticlesController = {
|
||||||
|
localArticles: Article[]
|
||||||
|
unlockedArticles: Set<string>
|
||||||
|
pendingDeleteId: string | null
|
||||||
|
requestDelete: (id: string) => void
|
||||||
|
handleUnlock: (article: Article) => Promise<void>
|
||||||
|
handleDelete: (article: Article) => Promise<void>
|
||||||
|
handleEditSubmit: () => Promise<void>
|
||||||
|
editingDraft: ArticleDraft | null
|
||||||
|
editingArticleId: string | null
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
updateDraft: (draft: ArticleDraft) => void
|
||||||
|
startEditing: (article: Article) => Promise<void>
|
||||||
|
cancelEditing: () => void
|
||||||
|
submitEdit: () => Promise<import('@/lib/articleMutations').ArticleUpdateResult | null>
|
||||||
|
deleteArticle: (id: string) => Promise<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UserArticlesControllerParams = {
|
||||||
|
articles: Article[]
|
||||||
|
onLoadContent: (articleId: string, authorPubkey: string) => Promise<Article | null>
|
||||||
|
currentPubkey: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SetStringSet = Dispatch<SetStateAction<Set<string>>>
|
||||||
|
export type SetStringOrNull = Dispatch<SetStateAction<string | null>>
|
||||||
|
export type SetArticleMap = Dispatch<SetStateAction<Map<string, Article>>>
|
||||||
|
|
||||||
|
export type EditingContext = ReturnType<typeof useArticleEditing>
|
||||||
@ -43,7 +43,8 @@ async function unlockArticlePayment(params: {
|
|||||||
setPaymentInvoice: (value: AlbyInvoice | null) => void
|
setPaymentInvoice: (value: AlbyInvoice | null) => void
|
||||||
setPaymentHash: (value: string | null) => void
|
setPaymentHash: (value: string | null) => void
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
if (!params.pubkey) {
|
const {pubkey} = params
|
||||||
|
if (!pubkey) {
|
||||||
await ensureConnectedOrError({
|
await ensureConnectedOrError({
|
||||||
connect: params.connect,
|
connect: params.connect,
|
||||||
setLoading: params.setLoading,
|
setLoading: params.setLoading,
|
||||||
@ -52,13 +53,40 @@ async function unlockArticlePayment(params: {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await startArticlePaymentFlow({
|
||||||
|
article: params.article,
|
||||||
|
pubkey,
|
||||||
|
onUnlockSuccess: params.onUnlockSuccess,
|
||||||
|
setLoading: params.setLoading,
|
||||||
|
setError: params.setError,
|
||||||
|
setPaymentInvoice: params.setPaymentInvoice,
|
||||||
|
setPaymentHash: params.setPaymentHash,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPaymentResult(value: Awaited<ReturnType<typeof paymentService.createArticlePayment>>): { invoice: AlbyInvoice; paymentHash: string } | null {
|
||||||
|
if (!value.success || !value.invoice || !value.paymentHash) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return { invoice: value.invoice, paymentHash: value.paymentHash }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startArticlePaymentFlow(params: {
|
||||||
|
article: Article
|
||||||
|
pubkey: string
|
||||||
|
onUnlockSuccess: (() => void) | undefined
|
||||||
|
setLoading: (value: boolean) => void
|
||||||
|
setError: (value: string | null) => void
|
||||||
|
setPaymentInvoice: (value: AlbyInvoice | null) => void
|
||||||
|
setPaymentHash: (value: string | null) => void
|
||||||
|
}): Promise<void> {
|
||||||
params.setLoading(true)
|
params.setLoading(true)
|
||||||
params.setError(null)
|
params.setError(null)
|
||||||
try {
|
try {
|
||||||
const paymentResult = await paymentService.createArticlePayment({ article: params.article, userPubkey: params.pubkey })
|
const result = await paymentService.createArticlePayment({ article: params.article, userPubkey: params.pubkey })
|
||||||
const ok = readPaymentResult(paymentResult)
|
const ok = readPaymentResult(result)
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
params.setError(paymentResult.error ?? 'Failed to create payment invoice')
|
params.setError(result.error ?? 'Failed to create payment invoice')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
params.setPaymentInvoice(ok.invoice)
|
params.setPaymentInvoice(ok.invoice)
|
||||||
@ -73,13 +101,6 @@ async function unlockArticlePayment(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function readPaymentResult(value: Awaited<ReturnType<typeof paymentService.createArticlePayment>>): { invoice: AlbyInvoice; paymentHash: string } | null {
|
|
||||||
if (!value.success || !value.invoice || !value.paymentHash) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return { invoice: value.invoice, paymentHash: value.paymentHash }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureConnectedOrError(params: {
|
async function ensureConnectedOrError(params: {
|
||||||
connect: (() => Promise<void>) | undefined
|
connect: (() => Promise<void>) | undefined
|
||||||
setLoading: (value: boolean) => void
|
setLoading: (value: boolean) => void
|
||||||
|
|||||||
@ -1,675 +1,5 @@
|
|||||||
import { nostrService } from './nostr'
|
export * from './articleMutations/index'
|
||||||
import { createArticleInvoice, createPreviewEvent } from './articleInvoice'
|
|
||||||
import { storePrivateContent, getStoredPrivateContent } from './articleStorage'
|
|
||||||
import { buildTags } from './nostrTagSystem'
|
|
||||||
import { PLATFORM_SERVICE } from './platformConfig'
|
|
||||||
import { generateSeriesHashId, generatePublicationHashId } from './hashIdGenerator'
|
|
||||||
import { buildObjectId } from './urlGenerator'
|
|
||||||
import type { ArticleDraft, PublishedArticle } from './articlePublisher'
|
|
||||||
import type { AlbyInvoice } from '@/types/alby'
|
|
||||||
import type { Article, Review, Series } from '@/types/nostr'
|
|
||||||
import { writeOrchestrator } from './writeOrchestrator'
|
|
||||||
import { finalizeEvent, type EventTemplate } from 'nostr-tools'
|
|
||||||
import { hexToBytes } from 'nostr-tools/utils'
|
|
||||||
import { buildParsedArticleFromDraft as buildParsedArticleFromDraftCore } from './articleDraftToParsedArticle'
|
|
||||||
import { getPublishRelays } from './relaySelection'
|
|
||||||
|
|
||||||
export interface ArticleUpdateResult extends PublishedArticle {
|
|
||||||
originalArticleId: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureKeys(authorPubkey: string, authorPrivateKey?: string): void {
|
|
||||||
nostrService.setPublicKey(authorPubkey)
|
|
||||||
if (authorPrivateKey) {
|
|
||||||
nostrService.setPrivateKey(authorPrivateKey)
|
|
||||||
} else if (!nostrService.getPrivateKey()) {
|
|
||||||
throw new Error('Private key required for signing. Connect a wallet that can sign.')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function requireCategory(category?: ArticleDraft['category']): asserts category is NonNullable<ArticleDraft['category']> {
|
|
||||||
if (category !== 'science-fiction' && category !== 'scientific-research') {
|
|
||||||
throw new Error('Vous devez sélectionner une catégorie (science-fiction ou recherche scientifique).')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensurePresentation(authorPubkey: string): Promise<string> {
|
|
||||||
const { articlePublisher } = await import('./articlePublisher')
|
|
||||||
const presentation = await articlePublisher.getAuthorPresentation(authorPubkey)
|
|
||||||
if (!presentation) {
|
|
||||||
throw new Error('Vous devez créer un article de présentation avant de publier des articles.')
|
|
||||||
}
|
|
||||||
return presentation.id
|
|
||||||
}
|
|
||||||
|
|
||||||
async function buildParsedArticleFromDraft(
|
|
||||||
draft: ArticleDraft,
|
|
||||||
invoice: AlbyInvoice,
|
|
||||||
authorPubkey: string
|
|
||||||
): Promise<{ article: Article; hash: string; version: number; index: number }> {
|
|
||||||
return buildParsedArticleFromDraftCore({ draft, invoice, authorPubkey })
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PublishPreviewWithInvoiceParams {
|
|
||||||
draft: ArticleDraft
|
|
||||||
invoice: AlbyInvoice
|
|
||||||
authorPubkey: string
|
|
||||||
presentationId: string
|
|
||||||
extraTags?: string[][]
|
|
||||||
customArticle?: Article
|
|
||||||
}
|
|
||||||
|
|
||||||
async function publishPreviewWithInvoice(
|
|
||||||
params: PublishPreviewWithInvoiceParams
|
|
||||||
): Promise<import('nostr-tools').Event | null> {
|
|
||||||
const payload = await resolvePublicationPayload(params)
|
|
||||||
|
|
||||||
// Build event template
|
|
||||||
const previewEventTemplate = await createPreviewEvent({
|
|
||||||
draft: params.draft,
|
|
||||||
invoice: params.invoice,
|
|
||||||
authorPubkey: params.authorPubkey,
|
|
||||||
authorPresentationId: params.presentationId,
|
|
||||||
...(params.extraTags ? { extraTags: params.extraTags } : {}),
|
|
||||||
})
|
|
||||||
|
|
||||||
// Set private key in orchestrator
|
|
||||||
const privateKey = nostrService.getPrivateKey()
|
|
||||||
if (!privateKey) {
|
|
||||||
throw new Error('Private key required for signing')
|
|
||||||
}
|
|
||||||
writeOrchestrator.setPrivateKey(privateKey)
|
|
||||||
|
|
||||||
// Finalize event
|
|
||||||
const secretKey = hexToBytes(privateKey)
|
|
||||||
const event = finalizeEvent(previewEventTemplate, secretKey)
|
|
||||||
|
|
||||||
return publishPublicationToRelays({ event, payload })
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolvePublicationPayload(params: PublishPreviewWithInvoiceParams): Promise<{
|
|
||||||
article: Article
|
|
||||||
hash: string
|
|
||||||
version: number
|
|
||||||
index: number
|
|
||||||
}> {
|
|
||||||
if (params.customArticle) {
|
|
||||||
return {
|
|
||||||
article: params.customArticle,
|
|
||||||
hash: params.customArticle.hash,
|
|
||||||
version: params.customArticle.version,
|
|
||||||
index: params.customArticle.index ?? 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return buildParsedArticleFromDraft(params.draft, params.invoice, params.authorPubkey)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function publishPublicationToRelays(params: {
|
|
||||||
event: import('nostr-tools').Event
|
|
||||||
payload: { article: Article; hash: string; version: number; index: number }
|
|
||||||
}): Promise<import('nostr-tools').Event | null> {
|
|
||||||
const relays = await getPublishRelays()
|
|
||||||
const result = await writeOrchestrator.writeAndPublish(
|
|
||||||
{
|
|
||||||
objectType: 'publication',
|
|
||||||
hash: params.payload.hash,
|
|
||||||
event: params.event,
|
|
||||||
parsed: params.payload.article,
|
|
||||||
version: params.payload.version,
|
|
||||||
hidden: false,
|
|
||||||
index: params.payload.index,
|
|
||||||
},
|
|
||||||
relays
|
|
||||||
)
|
|
||||||
return result.success ? params.event : null
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function publishSeries(params: {
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
preview?: string
|
|
||||||
coverUrl?: string
|
|
||||||
category: ArticleDraft['category']
|
|
||||||
authorPubkey: string
|
|
||||||
authorPrivateKey?: string
|
|
||||||
}): Promise<Series> {
|
|
||||||
ensureKeys(params.authorPubkey, params.authorPrivateKey)
|
|
||||||
const category = requireSeriesCategory(params.category)
|
|
||||||
const newCategory = mapSeriesCategoryToTag(category)
|
|
||||||
const preview = buildSeriesPreview(params.preview, params.description)
|
|
||||||
|
|
||||||
const hashId = await generateSeriesHashId({
|
|
||||||
pubkey: params.authorPubkey,
|
|
||||||
title: params.title,
|
|
||||||
description: params.description,
|
|
||||||
category: newCategory,
|
|
||||||
...(params.coverUrl ? { coverUrl: params.coverUrl } : {}),
|
|
||||||
})
|
|
||||||
|
|
||||||
const parsedSeries = buildParsedSeries({
|
|
||||||
authorPubkey: params.authorPubkey,
|
|
||||||
title: params.title,
|
|
||||||
description: params.description,
|
|
||||||
preview,
|
|
||||||
coverUrl: params.coverUrl,
|
|
||||||
category,
|
|
||||||
hashId,
|
|
||||||
})
|
|
||||||
|
|
||||||
const eventTemplate = buildSeriesEventTemplate({
|
|
||||||
authorPubkey: params.authorPubkey,
|
|
||||||
title: params.title,
|
|
||||||
description: params.description,
|
|
||||||
preview,
|
|
||||||
coverUrl: params.coverUrl,
|
|
||||||
category: newCategory,
|
|
||||||
hashId,
|
|
||||||
})
|
|
||||||
|
|
||||||
const event = finalizeEvent(eventTemplate, hexToBytes(getPrivateKeyForSigning(params.authorPrivateKey)))
|
|
||||||
const relays = await getPublishRelays()
|
|
||||||
const result = await writeOrchestrator.writeAndPublish(
|
|
||||||
{
|
|
||||||
objectType: 'series',
|
|
||||||
hash: parsedSeries.hash,
|
|
||||||
event,
|
|
||||||
parsed: parsedSeries,
|
|
||||||
version: parsedSeries.version,
|
|
||||||
hidden: false,
|
|
||||||
index: parsedSeries.index,
|
|
||||||
},
|
|
||||||
relays
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
throw new Error('Failed to publish series')
|
|
||||||
}
|
|
||||||
|
|
||||||
return parsedSeries
|
|
||||||
}
|
|
||||||
|
|
||||||
function requireSeriesCategory(category: ArticleDraft['category']): NonNullable<ArticleDraft['category']> {
|
|
||||||
requireCategory(category)
|
|
||||||
return category
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapSeriesCategoryToTag(category: NonNullable<ArticleDraft['category']>): 'sciencefiction' | 'research' {
|
|
||||||
return category === 'science-fiction' ? 'sciencefiction' : 'research'
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSeriesPreview(preview: string | undefined, description: string): string {
|
|
||||||
return preview ?? description.substring(0, 200)
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildParsedSeries(params: {
|
|
||||||
authorPubkey: string
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
preview: string
|
|
||||||
coverUrl: string | undefined
|
|
||||||
category: NonNullable<ArticleDraft['category']>
|
|
||||||
hashId: string
|
|
||||||
}): Series {
|
|
||||||
const hash = params.hashId
|
|
||||||
const version = 0
|
|
||||||
const index = 0
|
|
||||||
const id = buildObjectId(hash, index, version)
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
hash,
|
|
||||||
version,
|
|
||||||
index,
|
|
||||||
pubkey: params.authorPubkey,
|
|
||||||
title: params.title,
|
|
||||||
description: params.description,
|
|
||||||
preview: params.preview,
|
|
||||||
thumbnailUrl: params.coverUrl ?? '',
|
|
||||||
category: params.category,
|
|
||||||
...(params.coverUrl ? { coverUrl: params.coverUrl } : {}),
|
|
||||||
kindType: 'series',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSeriesEventTemplate(params: {
|
|
||||||
authorPubkey: string
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
preview: string
|
|
||||||
coverUrl: string | undefined
|
|
||||||
category: 'sciencefiction' | 'research'
|
|
||||||
hashId: string
|
|
||||||
}): EventTemplate {
|
|
||||||
const tags = buildTags({
|
|
||||||
type: 'series',
|
|
||||||
category: params.category,
|
|
||||||
id: params.hashId,
|
|
||||||
service: PLATFORM_SERVICE,
|
|
||||||
version: 0,
|
|
||||||
hidden: false,
|
|
||||||
paywall: false,
|
|
||||||
title: params.title,
|
|
||||||
description: params.description,
|
|
||||||
preview: params.preview,
|
|
||||||
...(params.coverUrl ? { coverUrl: params.coverUrl } : {}),
|
|
||||||
})
|
|
||||||
tags.push(['json', buildSeriesJson(params)])
|
|
||||||
return { kind: 1, created_at: Math.floor(Date.now() / 1000), content: params.preview, tags }
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSeriesJson(params: {
|
|
||||||
authorPubkey: string
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
preview: string
|
|
||||||
coverUrl: string | undefined
|
|
||||||
category: 'sciencefiction' | 'research'
|
|
||||||
hashId: string
|
|
||||||
}): string {
|
|
||||||
return JSON.stringify({
|
|
||||||
type: 'series',
|
|
||||||
pubkey: params.authorPubkey,
|
|
||||||
title: params.title,
|
|
||||||
description: params.description,
|
|
||||||
preview: params.preview,
|
|
||||||
...(params.coverUrl ? { coverUrl: params.coverUrl } : {}),
|
|
||||||
category: params.category,
|
|
||||||
id: params.hashId,
|
|
||||||
version: 0,
|
|
||||||
index: 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPrivateKeyForSigning(authorPrivateKey: string | undefined): string {
|
|
||||||
const privateKey = authorPrivateKey ?? nostrService.getPrivateKey()
|
|
||||||
if (!privateKey) {
|
|
||||||
throw new Error('Private key required for signing')
|
|
||||||
}
|
|
||||||
writeOrchestrator.setPrivateKey(privateKey)
|
|
||||||
return privateKey
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function publishReview(params: {
|
|
||||||
articleId: string
|
|
||||||
seriesId: string
|
|
||||||
category: ArticleDraft['category']
|
|
||||||
authorPubkey: string
|
|
||||||
reviewerPubkey: string
|
|
||||||
content: string
|
|
||||||
title?: string
|
|
||||||
text?: string
|
|
||||||
authorPrivateKey?: string
|
|
||||||
}): Promise<Review> {
|
|
||||||
ensureKeys(params.reviewerPubkey, params.authorPrivateKey)
|
|
||||||
const {category} = params
|
|
||||||
requireCategory(category)
|
|
||||||
|
|
||||||
// Generate hash ID from review data
|
|
||||||
const { generateReviewHashId } = await import('./hashIdGenerator')
|
|
||||||
const hashId = await generateReviewHashId({
|
|
||||||
pubkey: params.reviewerPubkey,
|
|
||||||
articleId: params.articleId,
|
|
||||||
reviewerPubkey: params.reviewerPubkey,
|
|
||||||
content: params.content,
|
|
||||||
...(params.title ? { title: params.title } : {}),
|
|
||||||
})
|
|
||||||
|
|
||||||
const hash = hashId
|
|
||||||
const version = 0
|
|
||||||
const index = 0
|
|
||||||
const id = buildObjectId(hash, index, version)
|
|
||||||
|
|
||||||
// Build parsed Review object
|
|
||||||
const parsedReview: Review = {
|
|
||||||
id,
|
|
||||||
hash,
|
|
||||||
version,
|
|
||||||
index,
|
|
||||||
articleId: params.articleId,
|
|
||||||
authorPubkey: params.authorPubkey,
|
|
||||||
reviewerPubkey: params.reviewerPubkey,
|
|
||||||
content: params.content,
|
|
||||||
description: params.content.substring(0, 200),
|
|
||||||
createdAt: Math.floor(Date.now() / 1000),
|
|
||||||
...(params.title ? { title: params.title } : {}),
|
|
||||||
...(params.text ? { text: params.text } : {}),
|
|
||||||
kindType: 'review',
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build event template
|
|
||||||
const eventTemplate = await buildReviewEvent(params, category)
|
|
||||||
|
|
||||||
// Set private key in orchestrator
|
|
||||||
const privateKey = params.authorPrivateKey ?? nostrService.getPrivateKey()
|
|
||||||
if (!privateKey) {
|
|
||||||
throw new Error('Private key required for signing')
|
|
||||||
}
|
|
||||||
writeOrchestrator.setPrivateKey(privateKey)
|
|
||||||
|
|
||||||
// Finalize event
|
|
||||||
const secretKey = hexToBytes(privateKey)
|
|
||||||
const event = finalizeEvent(eventTemplate, secretKey)
|
|
||||||
|
|
||||||
// Get active relays
|
|
||||||
const { relaySessionManager } = await import('./relaySessionManager')
|
|
||||||
const activeRelays = await relaySessionManager.getActiveRelays()
|
|
||||||
const { getPrimaryRelay } = await import('./config')
|
|
||||||
const relays = activeRelays.length > 0 ? activeRelays : [await getPrimaryRelay()]
|
|
||||||
|
|
||||||
// Publish via writeOrchestrator (parallel network + local write)
|
|
||||||
const result = await writeOrchestrator.writeAndPublish(
|
|
||||||
{
|
|
||||||
objectType: 'review',
|
|
||||||
hash,
|
|
||||||
event,
|
|
||||||
parsed: parsedReview,
|
|
||||||
version,
|
|
||||||
hidden: false,
|
|
||||||
index,
|
|
||||||
},
|
|
||||||
relays
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
throw new Error('Failed to publish review')
|
|
||||||
}
|
|
||||||
|
|
||||||
return parsedReview
|
|
||||||
}
|
|
||||||
|
|
||||||
async function buildReviewEvent(
|
|
||||||
params: {
|
|
||||||
articleId: string
|
|
||||||
seriesId: string
|
|
||||||
authorPubkey: string
|
|
||||||
reviewerPubkey: string
|
|
||||||
content: string
|
|
||||||
title?: string
|
|
||||||
text?: string
|
|
||||||
},
|
|
||||||
category: NonNullable<ArticleDraft['category']>
|
|
||||||
): Promise<{
|
|
||||||
kind: number
|
|
||||||
created_at: number
|
|
||||||
content: string
|
|
||||||
tags: string[][]
|
|
||||||
}> {
|
|
||||||
// Map category to new system
|
|
||||||
const newCategory = category === 'science-fiction' ? 'sciencefiction' : 'research'
|
|
||||||
|
|
||||||
// Generate hash ID from review data
|
|
||||||
const { generateReviewHashId } = await import('./hashIdGenerator')
|
|
||||||
const hashId = await generateReviewHashId({
|
|
||||||
pubkey: params.reviewerPubkey,
|
|
||||||
articleId: params.articleId,
|
|
||||||
reviewerPubkey: params.reviewerPubkey,
|
|
||||||
content: params.content,
|
|
||||||
...(params.title ? { title: params.title } : {}),
|
|
||||||
})
|
|
||||||
|
|
||||||
// Build JSON metadata
|
|
||||||
const reviewJson = JSON.stringify({
|
|
||||||
type: 'review',
|
|
||||||
pubkey: params.reviewerPubkey,
|
|
||||||
articleId: params.articleId,
|
|
||||||
reviewerPubkey: params.reviewerPubkey,
|
|
||||||
content: params.content,
|
|
||||||
title: params.title,
|
|
||||||
category: newCategory,
|
|
||||||
id: hashId,
|
|
||||||
version: 0,
|
|
||||||
index: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
const tags = buildTags({
|
|
||||||
type: 'quote',
|
|
||||||
category: newCategory,
|
|
||||||
id: hashId,
|
|
||||||
service: PLATFORM_SERVICE,
|
|
||||||
version: 0, // New object
|
|
||||||
hidden: false,
|
|
||||||
paywall: false,
|
|
||||||
articleId: params.articleId,
|
|
||||||
reviewerPubkey: params.reviewerPubkey,
|
|
||||||
...(params.title ? { title: params.title } : {}),
|
|
||||||
})
|
|
||||||
|
|
||||||
// Add text tag if provided
|
|
||||||
if (params.text) {
|
|
||||||
tags.push(['text', params.text])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add JSON metadata as a tag
|
|
||||||
tags.push(['json', reviewJson])
|
|
||||||
|
|
||||||
return {
|
|
||||||
kind: 1,
|
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
|
||||||
content: params.content,
|
|
||||||
tags,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function buildUpdateTags(params: {
|
|
||||||
draft: ArticleDraft
|
|
||||||
originalArticleId: string
|
|
||||||
newCategory: 'sciencefiction' | 'research'
|
|
||||||
authorPubkey: string
|
|
||||||
currentVersion?: number
|
|
||||||
}): Promise<string[][]> {
|
|
||||||
// Generate hash ID from publication data
|
|
||||||
const hashId = await generatePublicationHashId({
|
|
||||||
pubkey: params.authorPubkey,
|
|
||||||
title: params.draft.title,
|
|
||||||
preview: params.draft.preview,
|
|
||||||
category: params.newCategory,
|
|
||||||
seriesId: params.draft.seriesId ?? undefined,
|
|
||||||
bannerUrl: params.draft.bannerUrl ?? undefined,
|
|
||||||
zapAmount: params.draft.zapAmount,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Increment version for update
|
|
||||||
const currentVersion = params.currentVersion ?? 0
|
|
||||||
const nextVersion = currentVersion + 1
|
|
||||||
|
|
||||||
const updateTags = buildTags({
|
|
||||||
type: 'publication',
|
|
||||||
category: params.newCategory,
|
|
||||||
id: hashId,
|
|
||||||
service: PLATFORM_SERVICE,
|
|
||||||
version: nextVersion,
|
|
||||||
hidden: false,
|
|
||||||
paywall: true,
|
|
||||||
title: params.draft.title,
|
|
||||||
preview: params.draft.preview,
|
|
||||||
zapAmount: params.draft.zapAmount,
|
|
||||||
...(params.draft.seriesId ? { seriesId: params.draft.seriesId } : {}),
|
|
||||||
...(params.draft.bannerUrl ? { bannerUrl: params.draft.bannerUrl } : {}),
|
|
||||||
})
|
|
||||||
updateTags.push(['e', params.originalArticleId], ['replace', 'article-update'])
|
|
||||||
return updateTags
|
|
||||||
}
|
|
||||||
|
|
||||||
async function publishUpdate(
|
|
||||||
draft: ArticleDraft,
|
|
||||||
authorPubkey: string,
|
|
||||||
originalArticleId: string
|
|
||||||
): Promise<ArticleUpdateResult> {
|
|
||||||
const category = requireUpdateCategory(draft)
|
|
||||||
const originalArticle = await loadOriginalArticleForUpdate(originalArticleId)
|
|
||||||
if (!originalArticle) {
|
|
||||||
return updateFailure(originalArticleId, 'Original article not found in cache')
|
|
||||||
}
|
|
||||||
if (originalArticle.pubkey !== authorPubkey) {
|
|
||||||
return updateFailure(originalArticleId, 'Only the author can update this article')
|
|
||||||
}
|
|
||||||
|
|
||||||
const presentationId = await ensurePresentation(authorPubkey)
|
|
||||||
const invoice = await createArticleInvoice(draft)
|
|
||||||
const newCategory = mapPublicationCategoryToTag(category)
|
|
||||||
const currentVersion = originalArticle.version ?? 0
|
|
||||||
|
|
||||||
const updateTags = await buildUpdateTags({ draft, originalArticleId, newCategory, authorPubkey, currentVersion })
|
|
||||||
const updatedArticle = await buildUpdatedArticleForUpdate({ draft, invoice, authorPubkey, currentVersion })
|
|
||||||
const publishedEvent = await publishPreviewWithInvoice({ draft, invoice, authorPubkey, presentationId, extraTags: updateTags, customArticle: updatedArticle })
|
|
||||||
if (!publishedEvent) {
|
|
||||||
return updateFailure(originalArticleId, 'Failed to publish article update')
|
|
||||||
}
|
|
||||||
|
|
||||||
await storePrivateContent({ articleId: publishedEvent.id, content: draft.content, authorPubkey, invoice })
|
|
||||||
return { articleId: publishedEvent.id, previewEventId: publishedEvent.id, invoice, success: true, originalArticleId }
|
|
||||||
}
|
|
||||||
|
|
||||||
function requireUpdateCategory(draft: ArticleDraft): NonNullable<ArticleDraft['category']> {
|
|
||||||
requireCategory(draft.category)
|
|
||||||
return draft.category
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadOriginalArticleForUpdate(originalArticleId: string): Promise<Article | null> {
|
|
||||||
const { objectCache } = await import('./objectCache')
|
|
||||||
return (await objectCache.getById('publication', originalArticleId)) as Article | null
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapPublicationCategoryToTag(category: NonNullable<ArticleDraft['category']>): 'sciencefiction' | 'research' {
|
|
||||||
return category === 'science-fiction' ? 'sciencefiction' : 'research'
|
|
||||||
}
|
|
||||||
|
|
||||||
async function buildUpdatedArticleForUpdate(params: {
|
|
||||||
draft: ArticleDraft
|
|
||||||
invoice: AlbyInvoice
|
|
||||||
authorPubkey: string
|
|
||||||
currentVersion: number
|
|
||||||
}): Promise<Article> {
|
|
||||||
const { article } = await buildParsedArticleFromDraft(params.draft, params.invoice, params.authorPubkey)
|
|
||||||
return { ...article, version: params.currentVersion + 1 }
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function publishArticleUpdate(
|
|
||||||
originalArticleId: string,
|
|
||||||
draft: ArticleDraft,
|
|
||||||
authorPubkey: string,
|
|
||||||
authorPrivateKey?: string
|
|
||||||
): Promise<ArticleUpdateResult> {
|
|
||||||
try {
|
|
||||||
ensureKeys(authorPubkey, authorPrivateKey)
|
|
||||||
return publishUpdate(draft, authorPubkey, originalArticleId)
|
|
||||||
} catch (error) {
|
|
||||||
return updateFailure(originalArticleId, error instanceof Error ? error.message : 'Unknown error')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateFailure(originalArticleId: string, error?: string): ArticleUpdateResult {
|
|
||||||
return {
|
|
||||||
articleId: '',
|
|
||||||
previewEventId: '',
|
|
||||||
success: false,
|
|
||||||
originalArticleId,
|
|
||||||
...(error ? { error } : {}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteArticleEvent(articleId: string, authorPubkey: string, authorPrivateKey?: string): Promise<void> {
|
|
||||||
ensureKeys(authorPubkey, authorPrivateKey)
|
|
||||||
|
|
||||||
const originalEvent = await getOriginalPublicationEventOrThrow(articleId)
|
|
||||||
assertAuthorOwnsEvent({ eventPubkey: originalEvent.pubkey, authorPubkey })
|
|
||||||
|
|
||||||
const deleteEventTemplate = await buildDeleteEventTemplateOrThrow({ originalEvent, authorPubkey })
|
|
||||||
const originalParsed = await parseOriginalArticleOrThrow(originalEvent)
|
|
||||||
const deletePayload = await buildDeletedArticlePayload({ originalParsed, deleteEventTemplate })
|
|
||||||
|
|
||||||
const event = await finalizeEventTemplate({ template: deleteEventTemplate, authorPrivateKey })
|
|
||||||
const relays = await getPublishRelays()
|
|
||||||
await publishDeletion({ event, relays, payload: deletePayload })
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getOriginalPublicationEventOrThrow(articleId: string): Promise<import('nostr-tools').Event> {
|
|
||||||
const { objectCache } = await import('./objectCache')
|
|
||||||
const originalEvent = await objectCache.getEventById('publication', articleId)
|
|
||||||
if (!originalEvent) {
|
|
||||||
throw new Error('Article not found in cache')
|
|
||||||
}
|
|
||||||
return originalEvent
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertAuthorOwnsEvent(params: { eventPubkey: string; authorPubkey: string }): void {
|
|
||||||
if (params.eventPubkey !== params.authorPubkey) {
|
|
||||||
throw new Error('Only the author can delete this article')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function buildDeleteEventTemplateOrThrow(params: {
|
|
||||||
originalEvent: import('nostr-tools').Event
|
|
||||||
authorPubkey: string
|
|
||||||
}): Promise<import('nostr-tools').EventTemplate> {
|
|
||||||
const { buildDeleteEvent } = await import('./objectModification')
|
|
||||||
const template = await buildDeleteEvent(params.originalEvent, params.authorPubkey)
|
|
||||||
if (!template) {
|
|
||||||
throw new Error('Failed to build delete event')
|
|
||||||
}
|
|
||||||
return template
|
|
||||||
}
|
|
||||||
|
|
||||||
async function parseOriginalArticleOrThrow(originalEvent: import('nostr-tools').Event): Promise<Article> {
|
|
||||||
const { parseArticleFromEvent } = await import('./nostrEventParsing')
|
|
||||||
const parsed = await parseArticleFromEvent(originalEvent)
|
|
||||||
if (!parsed) {
|
|
||||||
throw new Error('Failed to parse original article')
|
|
||||||
}
|
|
||||||
return parsed
|
|
||||||
}
|
|
||||||
|
|
||||||
async function buildDeletedArticlePayload(params: {
|
|
||||||
originalParsed: Article
|
|
||||||
deleteEventTemplate: import('nostr-tools').EventTemplate
|
|
||||||
}): Promise<{ hash: string; index: number; version: number; parsed: Article }> {
|
|
||||||
const { extractTagsFromEvent } = await import('./nostrTagSystem')
|
|
||||||
const tags = extractTagsFromEvent(params.deleteEventTemplate)
|
|
||||||
const version = tags.version ?? params.originalParsed.version + 1
|
|
||||||
const index = params.originalParsed.index ?? 0
|
|
||||||
const parsed: Article = { ...params.originalParsed, version }
|
|
||||||
return { hash: params.originalParsed.hash, index, version, parsed }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function finalizeEventTemplate(params: {
|
|
||||||
template: import('nostr-tools').EventTemplate
|
|
||||||
authorPrivateKey: string | undefined
|
|
||||||
}): Promise<import('nostr-tools').Event> {
|
|
||||||
const privateKey = params.authorPrivateKey ?? nostrService.getPrivateKey()
|
|
||||||
if (!privateKey) {
|
|
||||||
throw new Error('Private key required for signing')
|
|
||||||
}
|
|
||||||
const { writeOrchestrator: writeOrchestratorInstance } = await import('./writeOrchestrator')
|
|
||||||
writeOrchestratorInstance.setPrivateKey(privateKey)
|
|
||||||
|
|
||||||
const { finalizeEvent: finalizeNostrEvent } = await import('nostr-tools')
|
|
||||||
const { hexToBytes: hexToBytesUtil } = await import('nostr-tools/utils')
|
|
||||||
const secretKey = hexToBytesUtil(privateKey)
|
|
||||||
return finalizeNostrEvent(params.template, secretKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function publishDeletion(params: {
|
|
||||||
event: import('nostr-tools').Event
|
|
||||||
relays: string[]
|
|
||||||
payload: { hash: string; index: number; version: number; parsed: Article }
|
|
||||||
}): Promise<void> {
|
|
||||||
const { writeOrchestrator: writeOrchestratorInstance } = await import('./writeOrchestrator')
|
|
||||||
const result = await writeOrchestratorInstance.writeAndPublish(
|
|
||||||
{
|
|
||||||
objectType: 'publication',
|
|
||||||
hash: params.payload.hash,
|
|
||||||
event: params.event,
|
|
||||||
parsed: params.payload.parsed,
|
|
||||||
version: params.payload.version,
|
|
||||||
hidden: true,
|
|
||||||
index: params.payload.index,
|
|
||||||
},
|
|
||||||
params.relays
|
|
||||||
)
|
|
||||||
if (!result.success) {
|
|
||||||
throw new Error('Failed to publish delete event')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-export for convenience to avoid circular imports in hooks
|
// Re-export for convenience to avoid circular imports in hooks
|
||||||
export { articlePublisher } from './articlePublisher'
|
export { articlePublisher } from './articlePublisher'
|
||||||
export const getStoredContent = getStoredPrivateContent
|
export { getStoredPrivateContent as getStoredContent } from './articleStorage'
|
||||||
|
|||||||
85
lib/articleMutations/delete.ts
Normal file
85
lib/articleMutations/delete.ts
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import type { Article } from '@/types/nostr'
|
||||||
|
import { nostrService } from '../nostr'
|
||||||
|
import { getPublishRelays } from '../relaySelection'
|
||||||
|
import { ensureKeys } from './shared'
|
||||||
|
|
||||||
|
export async function deleteArticleEvent(articleId: string, authorPubkey: string, authorPrivateKey?: string): Promise<void> {
|
||||||
|
ensureKeys(authorPubkey, authorPrivateKey)
|
||||||
|
const originalEvent = await getOriginalPublicationEventOrThrow(articleId)
|
||||||
|
assertAuthorOwnsEvent({ eventPubkey: originalEvent.pubkey, authorPubkey })
|
||||||
|
const deleteEventTemplate = await buildDeleteEventTemplateOrThrow({ originalEvent, authorPubkey })
|
||||||
|
const originalParsed = await parseOriginalArticleOrThrow(originalEvent)
|
||||||
|
const deletePayload = await buildDeletedArticlePayload({ originalParsed, deleteEventTemplate })
|
||||||
|
const event = await finalizeEventTemplate({ template: deleteEventTemplate, authorPrivateKey })
|
||||||
|
const relays = await getPublishRelays()
|
||||||
|
await publishDeletion({ event, relays, payload: deletePayload })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getOriginalPublicationEventOrThrow(articleId: string): Promise<import('nostr-tools').Event> {
|
||||||
|
const { objectCache } = await import('../objectCache')
|
||||||
|
const originalEvent = await objectCache.getEventById('publication', articleId)
|
||||||
|
if (!originalEvent) {
|
||||||
|
throw new Error('Article not found in cache')
|
||||||
|
}
|
||||||
|
return originalEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertAuthorOwnsEvent(params: { eventPubkey: string; authorPubkey: string }): void {
|
||||||
|
if (params.eventPubkey !== params.authorPubkey) {
|
||||||
|
throw new Error('Only the author can delete this article')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildDeleteEventTemplateOrThrow(params: { originalEvent: import('nostr-tools').Event; authorPubkey: string }): Promise<import('nostr-tools').EventTemplate> {
|
||||||
|
const { buildDeleteEvent } = await import('../objectModification')
|
||||||
|
const template = await buildDeleteEvent(params.originalEvent, params.authorPubkey)
|
||||||
|
if (!template) {
|
||||||
|
throw new Error('Failed to build delete event')
|
||||||
|
}
|
||||||
|
return template
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseOriginalArticleOrThrow(originalEvent: import('nostr-tools').Event): Promise<Article> {
|
||||||
|
const { parseArticleFromEvent } = await import('../nostrEventParsing')
|
||||||
|
const parsed = await parseArticleFromEvent(originalEvent)
|
||||||
|
if (!parsed) {
|
||||||
|
throw new Error('Failed to parse original article')
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildDeletedArticlePayload(params: { originalParsed: Article; deleteEventTemplate: import('nostr-tools').EventTemplate }): Promise<{ hash: string; index: number; version: number; parsed: Article }> {
|
||||||
|
const { extractTagsFromEvent } = await import('../nostrTagSystem')
|
||||||
|
const tags = extractTagsFromEvent(params.deleteEventTemplate)
|
||||||
|
const version = tags.version ?? params.originalParsed.version + 1
|
||||||
|
const index = params.originalParsed.index ?? 0
|
||||||
|
const parsed: Article = { ...params.originalParsed, version }
|
||||||
|
return { hash: params.originalParsed.hash, index, version, parsed }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function finalizeEventTemplate(params: { template: import('nostr-tools').EventTemplate; authorPrivateKey: string | undefined }): Promise<import('nostr-tools').Event> {
|
||||||
|
const privateKey = params.authorPrivateKey ?? nostrService.getPrivateKey()
|
||||||
|
if (!privateKey) {
|
||||||
|
throw new Error('Private key required for signing')
|
||||||
|
}
|
||||||
|
const { writeOrchestrator } = await import('../writeOrchestrator')
|
||||||
|
writeOrchestrator.setPrivateKey(privateKey)
|
||||||
|
const { finalizeEvent } = await import('nostr-tools')
|
||||||
|
const { hexToBytes } = await import('nostr-tools/utils')
|
||||||
|
return finalizeEvent(params.template, hexToBytes(privateKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publishDeletion(params: {
|
||||||
|
event: import('nostr-tools').Event
|
||||||
|
relays: string[]
|
||||||
|
payload: { hash: string; index: number; version: number; parsed: Article }
|
||||||
|
}): Promise<void> {
|
||||||
|
const { writeOrchestrator } = await import('../writeOrchestrator')
|
||||||
|
const result = await writeOrchestrator.writeAndPublish(
|
||||||
|
{ objectType: 'publication', hash: params.payload.hash, event: params.event, parsed: params.payload.parsed, version: params.payload.version, hidden: true, index: params.payload.index },
|
||||||
|
params.relays
|
||||||
|
)
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error('Failed to publish delete event')
|
||||||
|
}
|
||||||
|
}
|
||||||
5
lib/articleMutations/index.ts
Normal file
5
lib/articleMutations/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export type { ArticleUpdateResult } from './types'
|
||||||
|
export { publishSeries } from './series'
|
||||||
|
export { publishReview } from './review'
|
||||||
|
export { publishArticleUpdate } from './update'
|
||||||
|
export { deleteArticleEvent } from './delete'
|
||||||
57
lib/articleMutations/publicationPreview.ts
Normal file
57
lib/articleMutations/publicationPreview.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { createPreviewEvent } from '../articleInvoice'
|
||||||
|
import type { ArticleDraft } from '../articlePublisher'
|
||||||
|
import type { AlbyInvoice } from '@/types/alby'
|
||||||
|
import type { Article } from '@/types/nostr'
|
||||||
|
import { finalizeEvent } from 'nostr-tools'
|
||||||
|
import { hexToBytes } from 'nostr-tools/utils'
|
||||||
|
import { buildParsedArticleFromDraft as buildParsedArticleFromDraftCore } from '../articleDraftToParsedArticle'
|
||||||
|
import { getPublishRelays } from '../relaySelection'
|
||||||
|
import { writeOrchestrator } from '../writeOrchestrator'
|
||||||
|
import { getPrivateKeyForSigning } from './shared'
|
||||||
|
|
||||||
|
export async function buildParsedArticleFromDraft(
|
||||||
|
draft: ArticleDraft,
|
||||||
|
invoice: AlbyInvoice,
|
||||||
|
authorPubkey: string
|
||||||
|
): Promise<{ article: Article; hash: string; version: number; index: number }> {
|
||||||
|
return buildParsedArticleFromDraftCore({ draft, invoice, authorPubkey })
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublishPreviewWithInvoiceParams {
|
||||||
|
draft: ArticleDraft
|
||||||
|
invoice: AlbyInvoice
|
||||||
|
authorPubkey: string
|
||||||
|
presentationId: string
|
||||||
|
extraTags?: string[][]
|
||||||
|
customArticle?: Article
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function publishPreviewWithInvoice(params: PublishPreviewWithInvoiceParams): Promise<import('nostr-tools').Event | null> {
|
||||||
|
const payload = await resolvePublicationPayload(params)
|
||||||
|
const previewEventTemplate = await createPreviewEvent({
|
||||||
|
draft: params.draft,
|
||||||
|
invoice: params.invoice,
|
||||||
|
authorPubkey: params.authorPubkey,
|
||||||
|
authorPresentationId: params.presentationId,
|
||||||
|
...(params.extraTags ? { extraTags: params.extraTags } : {}),
|
||||||
|
})
|
||||||
|
const privateKey = getPrivateKeyForSigning(undefined)
|
||||||
|
const event = finalizeEvent(previewEventTemplate, hexToBytes(privateKey))
|
||||||
|
return publishPublicationToRelays({ event, payload })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolvePublicationPayload(params: PublishPreviewWithInvoiceParams): Promise<{ article: Article; hash: string; version: number; index: number }> {
|
||||||
|
if (params.customArticle) {
|
||||||
|
return { article: params.customArticle, hash: params.customArticle.hash, version: params.customArticle.version, index: params.customArticle.index ?? 0 }
|
||||||
|
}
|
||||||
|
return buildParsedArticleFromDraft(params.draft, params.invoice, params.authorPubkey)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publishPublicationToRelays(params: { event: import('nostr-tools').Event; payload: { article: Article; hash: string; version: number; index: number } }): Promise<import('nostr-tools').Event | null> {
|
||||||
|
const relays = await getPublishRelays()
|
||||||
|
const result = await writeOrchestrator.writeAndPublish(
|
||||||
|
{ objectType: 'publication', hash: params.payload.hash, event: params.event, parsed: params.payload.article, version: params.payload.version, hidden: false, index: params.payload.index },
|
||||||
|
relays
|
||||||
|
)
|
||||||
|
return result.success ? params.event : null
|
||||||
|
}
|
||||||
133
lib/articleMutations/review.ts
Normal file
133
lib/articleMutations/review.ts
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
import type { ArticleDraft } from '../articlePublisher'
|
||||||
|
import type { Review } from '@/types/nostr'
|
||||||
|
import { buildTags } from '../nostrTagSystem'
|
||||||
|
import { PLATFORM_SERVICE } from '../platformConfig'
|
||||||
|
import { buildObjectId } from '../urlGenerator'
|
||||||
|
import { getPublishRelays } from '../relaySelection'
|
||||||
|
import { writeOrchestrator } from '../writeOrchestrator'
|
||||||
|
import { finalizeEvent, type EventTemplate } from 'nostr-tools'
|
||||||
|
import { hexToBytes } from 'nostr-tools/utils'
|
||||||
|
import { ensureKeys, getPrivateKeyForSigning, mapDraftCategoryToTag, requireCategory } from './shared'
|
||||||
|
|
||||||
|
export async function publishReview(params: {
|
||||||
|
articleId: string
|
||||||
|
seriesId: string
|
||||||
|
category: ArticleDraft['category']
|
||||||
|
authorPubkey: string
|
||||||
|
reviewerPubkey: string
|
||||||
|
content: string
|
||||||
|
title?: string
|
||||||
|
text?: string
|
||||||
|
authorPrivateKey?: string
|
||||||
|
}): Promise<Review> {
|
||||||
|
ensureKeys(params.reviewerPubkey, params.authorPrivateKey)
|
||||||
|
requireCategory(params.category)
|
||||||
|
const hash = await buildReviewHashId({ reviewerPubkey: params.reviewerPubkey, articleId: params.articleId, content: params.content, ...(params.title ? { title: params.title } : {}) })
|
||||||
|
const parsedReview = buildParsedReview({ params, hash, version: 0, index: 0 })
|
||||||
|
const eventTemplate = await buildReviewEvent(params, params.category)
|
||||||
|
const privateKey = getPrivateKeyForSigning(params.authorPrivateKey)
|
||||||
|
const event = finalizeEvent(eventTemplate, hexToBytes(privateKey))
|
||||||
|
await publishReviewToRelays({ parsedReview, event })
|
||||||
|
return parsedReview
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildParsedReview(params: {
|
||||||
|
params: { articleId: string; authorPubkey: string; reviewerPubkey: string; content: string; title?: string; text?: string }
|
||||||
|
hash: string
|
||||||
|
version: number
|
||||||
|
index: number
|
||||||
|
}): Review {
|
||||||
|
return {
|
||||||
|
id: buildObjectId(params.hash, params.index, params.version),
|
||||||
|
hash: params.hash,
|
||||||
|
version: params.version,
|
||||||
|
index: params.index,
|
||||||
|
articleId: params.params.articleId,
|
||||||
|
authorPubkey: params.params.authorPubkey,
|
||||||
|
reviewerPubkey: params.params.reviewerPubkey,
|
||||||
|
content: params.params.content,
|
||||||
|
description: params.params.content.substring(0, 200),
|
||||||
|
createdAt: Math.floor(Date.now() / 1000),
|
||||||
|
...(params.params.title ? { title: params.params.title } : {}),
|
||||||
|
...(params.params.text ? { text: params.params.text } : {}),
|
||||||
|
kindType: 'review',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publishReviewToRelays(params: { parsedReview: Review; event: import('nostr-tools').Event }): Promise<void> {
|
||||||
|
const relays = await getPublishRelays()
|
||||||
|
const result = await writeOrchestrator.writeAndPublish(
|
||||||
|
{ objectType: 'review', hash: params.parsedReview.hash, event: params.event, parsed: params.parsedReview, version: params.parsedReview.version, hidden: false, index: params.parsedReview.index },
|
||||||
|
relays
|
||||||
|
)
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error('Failed to publish review')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildReviewEvent(
|
||||||
|
params: { articleId: string; seriesId: string; authorPubkey: string; reviewerPubkey: string; content: string; title?: string; text?: string },
|
||||||
|
category: NonNullable<ArticleDraft['category']>
|
||||||
|
): Promise<Pick<EventTemplate, 'kind' | 'created_at' | 'content' | 'tags'>> {
|
||||||
|
const ctx = await buildReviewEventContext(params, category)
|
||||||
|
return { kind: 1, created_at: Math.floor(Date.now() / 1000), content: params.content, tags: ctx.tags }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildReviewEventContext(
|
||||||
|
params: { articleId: string; reviewerPubkey: string; content: string; title?: string; text?: string },
|
||||||
|
category: NonNullable<ArticleDraft['category']>
|
||||||
|
): Promise<{ tags: string[][] }> {
|
||||||
|
const newCategory = mapDraftCategoryToTag(category)
|
||||||
|
const hashId = await buildReviewHashId({ reviewerPubkey: params.reviewerPubkey, articleId: params.articleId, content: params.content, ...(params.title ? { title: params.title } : {}) })
|
||||||
|
const reviewJson = buildReviewJson({ reviewerPubkey: params.reviewerPubkey, articleId: params.articleId, content: params.content, ...(params.title ? { title: params.title } : {}), newCategory, hashId })
|
||||||
|
const tags = buildReviewTags({ newCategory, hashId, articleId: params.articleId, reviewerPubkey: params.reviewerPubkey, ...(params.title ? { title: params.title } : {}), ...(params.text ? { text: params.text } : {}), reviewJson })
|
||||||
|
return { tags }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildReviewHashId(params: { reviewerPubkey: string; articleId: string; content: string; title?: string }): Promise<string> {
|
||||||
|
const { generateReviewHashId } = await import('../hashIdGenerator')
|
||||||
|
return generateReviewHashId({ pubkey: params.reviewerPubkey, articleId: params.articleId, reviewerPubkey: params.reviewerPubkey, content: params.content, ...(params.title ? { title: params.title } : {}) })
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildReviewJson(params: { reviewerPubkey: string; articleId: string; content: string; title?: string; newCategory: 'sciencefiction' | 'research'; hashId: string }): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
type: 'review',
|
||||||
|
pubkey: params.reviewerPubkey,
|
||||||
|
articleId: params.articleId,
|
||||||
|
reviewerPubkey: params.reviewerPubkey,
|
||||||
|
content: params.content,
|
||||||
|
title: params.title,
|
||||||
|
category: params.newCategory,
|
||||||
|
id: params.hashId,
|
||||||
|
version: 0,
|
||||||
|
index: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildReviewTags(params: {
|
||||||
|
newCategory: 'sciencefiction' | 'research'
|
||||||
|
hashId: string
|
||||||
|
articleId: string
|
||||||
|
reviewerPubkey: string
|
||||||
|
title?: string
|
||||||
|
text?: string
|
||||||
|
reviewJson: string
|
||||||
|
}): string[][] {
|
||||||
|
const tags = buildTags({
|
||||||
|
type: 'quote',
|
||||||
|
category: params.newCategory,
|
||||||
|
id: params.hashId,
|
||||||
|
service: PLATFORM_SERVICE,
|
||||||
|
version: 0,
|
||||||
|
hidden: false,
|
||||||
|
paywall: false,
|
||||||
|
articleId: params.articleId,
|
||||||
|
reviewerPubkey: params.reviewerPubkey,
|
||||||
|
...(params.title ? { title: params.title } : {}),
|
||||||
|
})
|
||||||
|
if (params.text) {
|
||||||
|
tags.push(['text', params.text])
|
||||||
|
}
|
||||||
|
tags.push(['json', params.reviewJson])
|
||||||
|
return tags
|
||||||
|
}
|
||||||
143
lib/articleMutations/series.ts
Normal file
143
lib/articleMutations/series.ts
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
import { buildTags } from '../nostrTagSystem'
|
||||||
|
import { PLATFORM_SERVICE } from '../platformConfig'
|
||||||
|
import { generateSeriesHashId } from '../hashIdGenerator'
|
||||||
|
import { buildObjectId } from '../urlGenerator'
|
||||||
|
import type { ArticleDraft } from '../articlePublisher'
|
||||||
|
import type { Series } from '@/types/nostr'
|
||||||
|
import { getPublishRelays } from '../relaySelection'
|
||||||
|
import { writeOrchestrator } from '../writeOrchestrator'
|
||||||
|
import { finalizeEvent } from 'nostr-tools'
|
||||||
|
import { hexToBytes } from 'nostr-tools/utils'
|
||||||
|
import type { EventTemplate } from 'nostr-tools'
|
||||||
|
import { ensureKeys, getPrivateKeyForSigning, mapDraftCategoryToTag, requireCategory } from './shared'
|
||||||
|
|
||||||
|
export async function publishSeries(params: {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
preview?: string
|
||||||
|
coverUrl?: string
|
||||||
|
category: ArticleDraft['category']
|
||||||
|
authorPubkey: string
|
||||||
|
authorPrivateKey?: string
|
||||||
|
}): Promise<Series> {
|
||||||
|
ensureKeys(params.authorPubkey, params.authorPrivateKey)
|
||||||
|
const ctx = await buildSeriesPublishContext(params)
|
||||||
|
const event = finalizeEvent(ctx.eventTemplate, hexToBytes(getPrivateKeyForSigning(params.authorPrivateKey)))
|
||||||
|
await publishSeriesToRelays({ parsedSeries: ctx.parsedSeries, event })
|
||||||
|
return ctx.parsedSeries
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSeriesPreview(preview: string | undefined, description: string): string {
|
||||||
|
return preview ?? description.substring(0, 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildSeriesPublishContext(params: {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
preview?: string
|
||||||
|
coverUrl?: string
|
||||||
|
category: ArticleDraft['category']
|
||||||
|
authorPubkey: string
|
||||||
|
}): Promise<{ parsedSeries: Series; eventTemplate: EventTemplate }> {
|
||||||
|
requireCategory(params.category)
|
||||||
|
const newCategory = mapDraftCategoryToTag(params.category)
|
||||||
|
const preview = buildSeriesPreview(params.preview, params.description)
|
||||||
|
const hashId = await generateSeriesHashId({
|
||||||
|
pubkey: params.authorPubkey,
|
||||||
|
title: params.title,
|
||||||
|
description: params.description,
|
||||||
|
category: newCategory,
|
||||||
|
...(params.coverUrl ? { coverUrl: params.coverUrl } : {}),
|
||||||
|
})
|
||||||
|
const parsedSeries = buildParsedSeries({ authorPubkey: params.authorPubkey, title: params.title, description: params.description, preview, coverUrl: params.coverUrl, category: params.category, hashId })
|
||||||
|
const eventTemplate = buildSeriesEventTemplate({ authorPubkey: params.authorPubkey, title: params.title, description: params.description, preview, coverUrl: params.coverUrl, category: newCategory, hashId })
|
||||||
|
return { parsedSeries, eventTemplate }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publishSeriesToRelays(params: { parsedSeries: Series; event: import('nostr-tools').Event }): Promise<void> {
|
||||||
|
const relays = await getPublishRelays()
|
||||||
|
const result = await writeOrchestrator.writeAndPublish(
|
||||||
|
{ objectType: 'series', hash: params.parsedSeries.hash, event: params.event, parsed: params.parsedSeries, version: params.parsedSeries.version, hidden: false, index: params.parsedSeries.index },
|
||||||
|
relays
|
||||||
|
)
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error('Failed to publish series')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildParsedSeries(params: {
|
||||||
|
authorPubkey: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
preview: string
|
||||||
|
coverUrl: string | undefined
|
||||||
|
category: NonNullable<ArticleDraft['category']>
|
||||||
|
hashId: string
|
||||||
|
}): Series {
|
||||||
|
const hash = params.hashId
|
||||||
|
const version = 0
|
||||||
|
const index = 0
|
||||||
|
return {
|
||||||
|
id: buildObjectId(hash, index, version),
|
||||||
|
hash,
|
||||||
|
version,
|
||||||
|
index,
|
||||||
|
pubkey: params.authorPubkey,
|
||||||
|
title: params.title,
|
||||||
|
description: params.description,
|
||||||
|
preview: params.preview,
|
||||||
|
thumbnailUrl: params.coverUrl ?? '',
|
||||||
|
category: params.category,
|
||||||
|
...(params.coverUrl ? { coverUrl: params.coverUrl } : {}),
|
||||||
|
kindType: 'series',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSeriesEventTemplate(params: {
|
||||||
|
authorPubkey: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
preview: string
|
||||||
|
coverUrl: string | undefined
|
||||||
|
category: 'sciencefiction' | 'research'
|
||||||
|
hashId: string
|
||||||
|
}): EventTemplate {
|
||||||
|
const tags = buildTags({
|
||||||
|
type: 'series',
|
||||||
|
category: params.category,
|
||||||
|
id: params.hashId,
|
||||||
|
service: PLATFORM_SERVICE,
|
||||||
|
version: 0,
|
||||||
|
hidden: false,
|
||||||
|
paywall: false,
|
||||||
|
title: params.title,
|
||||||
|
description: params.description,
|
||||||
|
preview: params.preview,
|
||||||
|
...(params.coverUrl ? { coverUrl: params.coverUrl } : {}),
|
||||||
|
})
|
||||||
|
tags.push(['json', buildSeriesJson(params)])
|
||||||
|
return { kind: 1, created_at: Math.floor(Date.now() / 1000), content: params.preview, tags }
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSeriesJson(params: {
|
||||||
|
authorPubkey: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
preview: string
|
||||||
|
coverUrl: string | undefined
|
||||||
|
category: 'sciencefiction' | 'research'
|
||||||
|
hashId: string
|
||||||
|
}): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
type: 'series',
|
||||||
|
pubkey: params.authorPubkey,
|
||||||
|
title: params.title,
|
||||||
|
description: params.description,
|
||||||
|
preview: params.preview,
|
||||||
|
...(params.coverUrl ? { coverUrl: params.coverUrl } : {}),
|
||||||
|
category: params.category,
|
||||||
|
id: params.hashId,
|
||||||
|
version: 0,
|
||||||
|
index: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
40
lib/articleMutations/shared.ts
Normal file
40
lib/articleMutations/shared.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { nostrService } from '../nostr'
|
||||||
|
import type { ArticleDraft } from '../articlePublisher'
|
||||||
|
import { writeOrchestrator } from '../writeOrchestrator'
|
||||||
|
|
||||||
|
export function ensureKeys(authorPubkey: string, authorPrivateKey?: string): void {
|
||||||
|
nostrService.setPublicKey(authorPubkey)
|
||||||
|
if (authorPrivateKey) {
|
||||||
|
nostrService.setPrivateKey(authorPrivateKey)
|
||||||
|
} else if (!nostrService.getPrivateKey()) {
|
||||||
|
throw new Error('Private key required for signing. Connect a wallet that can sign.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireCategory(category?: ArticleDraft['category']): asserts category is NonNullable<ArticleDraft['category']> {
|
||||||
|
if (category !== 'science-fiction' && category !== 'scientific-research') {
|
||||||
|
throw new Error('Vous devez sélectionner une catégorie (science-fiction ou recherche scientifique).')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensurePresentation(authorPubkey: string): Promise<string> {
|
||||||
|
const { articlePublisher } = await import('../articlePublisher')
|
||||||
|
const presentation = await articlePublisher.getAuthorPresentation(authorPubkey)
|
||||||
|
if (!presentation) {
|
||||||
|
throw new Error('Vous devez créer un article de présentation avant de publier des articles.')
|
||||||
|
}
|
||||||
|
return presentation.id
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapDraftCategoryToTag(category: NonNullable<ArticleDraft['category']>): 'sciencefiction' | 'research' {
|
||||||
|
return category === 'science-fiction' ? 'sciencefiction' : 'research'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPrivateKeyForSigning(authorPrivateKey: string | undefined): string {
|
||||||
|
const privateKey = authorPrivateKey ?? nostrService.getPrivateKey()
|
||||||
|
if (!privateKey) {
|
||||||
|
throw new Error('Private key required for signing')
|
||||||
|
}
|
||||||
|
writeOrchestrator.setPrivateKey(privateKey)
|
||||||
|
return privateKey
|
||||||
|
}
|
||||||
5
lib/articleMutations/types.ts
Normal file
5
lib/articleMutations/types.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import type { PublishedArticle } from '../articlePublisher'
|
||||||
|
|
||||||
|
export interface ArticleUpdateResult extends PublishedArticle {
|
||||||
|
originalArticleId: string
|
||||||
|
}
|
||||||
100
lib/articleMutations/update.ts
Normal file
100
lib/articleMutations/update.ts
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import type { ArticleDraft } from '../articlePublisher'
|
||||||
|
import type { AlbyInvoice } from '@/types/alby'
|
||||||
|
import type { Article } from '@/types/nostr'
|
||||||
|
import { createArticleInvoice } from '../articleInvoice'
|
||||||
|
import { storePrivateContent } from '../articleStorage'
|
||||||
|
import { buildTags } from '../nostrTagSystem'
|
||||||
|
import { PLATFORM_SERVICE } from '../platformConfig'
|
||||||
|
import { generatePublicationHashId } from '../hashIdGenerator'
|
||||||
|
import { ensureKeys, ensurePresentation, mapDraftCategoryToTag, requireCategory } from './shared'
|
||||||
|
import { buildParsedArticleFromDraft, publishPreviewWithInvoice } from './publicationPreview'
|
||||||
|
import type { ArticleUpdateResult } from './types'
|
||||||
|
|
||||||
|
export async function publishArticleUpdate(
|
||||||
|
originalArticleId: string,
|
||||||
|
draft: ArticleDraft,
|
||||||
|
authorPubkey: string,
|
||||||
|
authorPrivateKey?: string
|
||||||
|
): Promise<ArticleUpdateResult> {
|
||||||
|
try {
|
||||||
|
ensureKeys(authorPubkey, authorPrivateKey)
|
||||||
|
return publishUpdate(draft, authorPubkey, originalArticleId)
|
||||||
|
} catch (error) {
|
||||||
|
return updateFailure(originalArticleId, error instanceof Error ? error.message : 'Unknown error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publishUpdate(draft: ArticleDraft, authorPubkey: string, originalArticleId: string): Promise<ArticleUpdateResult> {
|
||||||
|
requireCategory(draft.category)
|
||||||
|
const originalArticle = await loadOriginalArticleForUpdate(originalArticleId)
|
||||||
|
if (!originalArticle) {
|
||||||
|
return updateFailure(originalArticleId, 'Original article not found in cache')
|
||||||
|
}
|
||||||
|
if (originalArticle.pubkey !== authorPubkey) {
|
||||||
|
return updateFailure(originalArticleId, 'Only the author can update this article')
|
||||||
|
}
|
||||||
|
|
||||||
|
const presentationId = await ensurePresentation(authorPubkey)
|
||||||
|
const invoice = await createArticleInvoice(draft)
|
||||||
|
const newCategory = mapDraftCategoryToTag(draft.category)
|
||||||
|
const currentVersion = originalArticle.version ?? 0
|
||||||
|
|
||||||
|
const updateTags = await buildUpdateTags({ draft, originalArticleId, newCategory, authorPubkey, currentVersion })
|
||||||
|
const updatedArticle = await buildUpdatedArticleForUpdate({ draft, invoice, authorPubkey, currentVersion })
|
||||||
|
const publishedEvent = await publishPreviewWithInvoice({ draft, invoice, authorPubkey, presentationId, extraTags: updateTags, customArticle: updatedArticle })
|
||||||
|
if (!publishedEvent) {
|
||||||
|
return updateFailure(originalArticleId, 'Failed to publish article update')
|
||||||
|
}
|
||||||
|
await storePrivateContent({ articleId: publishedEvent.id, content: draft.content, authorPubkey, invoice })
|
||||||
|
return { articleId: publishedEvent.id, previewEventId: publishedEvent.id, invoice, success: true, originalArticleId }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildUpdateTags(params: {
|
||||||
|
draft: ArticleDraft
|
||||||
|
originalArticleId: string
|
||||||
|
newCategory: 'sciencefiction' | 'research'
|
||||||
|
authorPubkey: string
|
||||||
|
currentVersion?: number
|
||||||
|
}): Promise<string[][]> {
|
||||||
|
const hashId = await generatePublicationHashId({
|
||||||
|
pubkey: params.authorPubkey,
|
||||||
|
title: params.draft.title,
|
||||||
|
preview: params.draft.preview,
|
||||||
|
category: params.newCategory,
|
||||||
|
seriesId: params.draft.seriesId ?? undefined,
|
||||||
|
bannerUrl: params.draft.bannerUrl ?? undefined,
|
||||||
|
zapAmount: params.draft.zapAmount,
|
||||||
|
})
|
||||||
|
const currentVersion = params.currentVersion ?? 0
|
||||||
|
const nextVersion = currentVersion + 1
|
||||||
|
const updateTags = buildTags({
|
||||||
|
type: 'publication',
|
||||||
|
category: params.newCategory,
|
||||||
|
id: hashId,
|
||||||
|
service: PLATFORM_SERVICE,
|
||||||
|
version: nextVersion,
|
||||||
|
hidden: false,
|
||||||
|
paywall: true,
|
||||||
|
title: params.draft.title,
|
||||||
|
preview: params.draft.preview,
|
||||||
|
zapAmount: params.draft.zapAmount,
|
||||||
|
...(params.draft.seriesId ? { seriesId: params.draft.seriesId } : {}),
|
||||||
|
...(params.draft.bannerUrl ? { bannerUrl: params.draft.bannerUrl } : {}),
|
||||||
|
})
|
||||||
|
updateTags.push(['e', params.originalArticleId], ['replace', 'article-update'])
|
||||||
|
return updateTags
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOriginalArticleForUpdate(originalArticleId: string): Promise<Article | null> {
|
||||||
|
const { objectCache } = await import('../objectCache')
|
||||||
|
return (await objectCache.getById('publication', originalArticleId)) as Article | null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildUpdatedArticleForUpdate(params: { draft: ArticleDraft; invoice: AlbyInvoice; authorPubkey: string; currentVersion: number }): Promise<Article> {
|
||||||
|
const { article } = await buildParsedArticleFromDraft(params.draft, params.invoice, params.authorPubkey)
|
||||||
|
return { ...article, version: params.currentVersion + 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFailure(originalArticleId: string, error?: string): ArticleUpdateResult {
|
||||||
|
return { articleId: '', previewEventId: '', success: false, originalArticleId, ...(error ? { error } : {}) }
|
||||||
|
}
|
||||||
@ -1,16 +1,14 @@
|
|||||||
import { nostrService } from './nostr'
|
import { nostrService } from './nostr'
|
||||||
import type { AlbyInvoice } from '@/types/alby'
|
import type { AlbyInvoice } from '@/types/alby'
|
||||||
import { getStoredPrivateContent, getStoredInvoice, removeStoredPrivateContent } from './articleStorage'
|
import { getStoredPrivateContent, getStoredInvoice, removeStoredPrivateContent } from './articleStorage'
|
||||||
import { buildPresentationEvent, sendEncryptedContent } from './articlePublisherHelpers'
|
import { sendEncryptedContent } from './articlePublisherHelpers'
|
||||||
import type { ArticleDraft, AuthorPresentationDraft, PublishedArticle } from './articlePublisherTypes'
|
import type { ArticleDraft, AuthorPresentationDraft, PublishedArticle } from './articlePublisherTypes'
|
||||||
import { prepareAuthorKeys, isValidCategory, type PublishValidationResult } from './articlePublisherValidation'
|
import { prepareAuthorKeys, isValidCategory, type PublishValidationResult } from './articlePublisherValidation'
|
||||||
import { buildFailure, encryptAndPublish } from './articlePublisherPublish'
|
import { buildFailure, encryptAndPublish } from './articlePublisherPublish'
|
||||||
import { writeOrchestrator } from './writeOrchestrator'
|
import { writeOrchestrator } from './writeOrchestrator'
|
||||||
import { finalizeEvent } from 'nostr-tools'
|
import { finalizeEvent, type Event, type EventTemplate } from 'nostr-tools'
|
||||||
import { hexToBytes } from 'nostr-tools/utils'
|
import { hexToBytes } from 'nostr-tools/utils'
|
||||||
import { generateAuthorHashId } from './hashIdGenerator'
|
import { buildPresentationPublishContext, getActiveRelaysOrPrimary } from './articlePublisherPresentationHelpers'
|
||||||
import { buildObjectId } from './urlGenerator'
|
|
||||||
import { extractAuthorNameFromTitle, parseAuthorPresentationDraft } from './authorPresentationParsing'
|
|
||||||
|
|
||||||
export type { ArticleDraft, AuthorPresentationDraft, PublishedArticle } from './articlePublisherTypes'
|
export type { ArticleDraft, AuthorPresentationDraft, PublishedArticle } from './articlePublisherTypes'
|
||||||
|
|
||||||
@ -215,114 +213,27 @@ async function publishPresentationArticleCore(params: {
|
|||||||
authorPubkey: string
|
authorPubkey: string
|
||||||
authorPrivateKey: string
|
authorPrivateKey: string
|
||||||
}): Promise<PublishedArticle> {
|
}): Promise<PublishedArticle> {
|
||||||
nostrService.setPublicKey(params.authorPubkey)
|
initializeAuthorKeys({ authorPubkey: params.authorPubkey, authorPrivateKey: params.authorPrivateKey })
|
||||||
nostrService.setPrivateKey(params.authorPrivateKey)
|
const ctx = await buildPresentationPublishContext({ draft: params.draft, authorPubkey: params.authorPubkey })
|
||||||
|
const event = finalizeEventFromTemplate({ eventTemplate: ctx.eventTemplate, authorPrivateKey: params.authorPrivateKey })
|
||||||
const authorName = extractAuthorNameFromTitle(params.draft.title)
|
|
||||||
const { presentation, contentDescription } = parseAuthorPresentationDraft(params.draft)
|
|
||||||
|
|
||||||
const category = 'sciencefiction'
|
|
||||||
const version = 0
|
|
||||||
const index = 0
|
|
||||||
|
|
||||||
const hashId = await generateAuthorHashId({
|
|
||||||
pubkey: params.authorPubkey,
|
|
||||||
authorName,
|
|
||||||
presentation,
|
|
||||||
contentDescription,
|
|
||||||
mainnetAddress: params.draft.mainnetAddress ?? undefined,
|
|
||||||
pictureUrl: params.draft.pictureUrl ?? undefined,
|
|
||||||
category,
|
|
||||||
})
|
|
||||||
|
|
||||||
const hash = hashId
|
|
||||||
const id = buildObjectId(hash, index, version)
|
|
||||||
|
|
||||||
const parsedAuthor = buildParsedAuthorPresentation({
|
|
||||||
draft: params.draft,
|
|
||||||
authorPubkey: params.authorPubkey,
|
|
||||||
id,
|
|
||||||
hash,
|
|
||||||
version,
|
|
||||||
index,
|
|
||||||
presentation,
|
|
||||||
contentDescription,
|
|
||||||
})
|
|
||||||
|
|
||||||
const eventTemplate = await buildPresentationEvent({
|
|
||||||
draft: params.draft,
|
|
||||||
authorPubkey: params.authorPubkey,
|
|
||||||
authorName,
|
|
||||||
category,
|
|
||||||
version,
|
|
||||||
index,
|
|
||||||
})
|
|
||||||
|
|
||||||
writeOrchestrator.setPrivateKey(params.authorPrivateKey)
|
writeOrchestrator.setPrivateKey(params.authorPrivateKey)
|
||||||
const secretKey = hexToBytes(params.authorPrivateKey)
|
|
||||||
const event = finalizeEvent(eventTemplate, secretKey)
|
|
||||||
|
|
||||||
const relays = await getActiveRelaysOrPrimary()
|
const relays = await getActiveRelaysOrPrimary()
|
||||||
const result = await writeOrchestrator.writeAndPublish(
|
const result = await writeOrchestrator.writeAndPublish(
|
||||||
{
|
{ objectType: 'author', hash: ctx.hash, event, parsed: ctx.parsedAuthor, version: ctx.version, hidden: false, index: ctx.index },
|
||||||
objectType: 'author',
|
|
||||||
hash,
|
|
||||||
event,
|
|
||||||
parsed: parsedAuthor,
|
|
||||||
version,
|
|
||||||
hidden: false,
|
|
||||||
index,
|
|
||||||
},
|
|
||||||
relays
|
relays
|
||||||
)
|
)
|
||||||
|
return result.success ? { articleId: event.id, previewEventId: event.id, success: true } : buildFailure('Failed to publish presentation article')
|
||||||
if (!result.success) {
|
|
||||||
return buildFailure('Failed to publish presentation article')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { articleId: event.id, previewEventId: event.id, success: true }
|
function initializeAuthorKeys(params: { authorPubkey: string; authorPrivateKey: string }): void {
|
||||||
|
nostrService.setPublicKey(params.authorPubkey)
|
||||||
|
nostrService.setPrivateKey(params.authorPrivateKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildParsedAuthorPresentation(params: {
|
function finalizeEventFromTemplate(params: { eventTemplate: EventTemplate; authorPrivateKey: string }): Event {
|
||||||
draft: AuthorPresentationDraft
|
const secretKey = hexToBytes(params.authorPrivateKey)
|
||||||
authorPubkey: string
|
return finalizeEvent(params.eventTemplate, secretKey)
|
||||||
id: string
|
|
||||||
hash: string
|
|
||||||
version: number
|
|
||||||
index: number
|
|
||||||
presentation: string
|
|
||||||
contentDescription: string
|
|
||||||
}): import('@/types/nostr').AuthorPresentationArticle {
|
|
||||||
return {
|
|
||||||
id: params.id,
|
|
||||||
hash: params.hash,
|
|
||||||
version: params.version,
|
|
||||||
index: params.index,
|
|
||||||
pubkey: params.authorPubkey,
|
|
||||||
title: params.draft.title,
|
|
||||||
preview: params.draft.preview,
|
|
||||||
content: params.draft.content,
|
|
||||||
description: params.presentation,
|
|
||||||
contentDescription: params.contentDescription,
|
|
||||||
thumbnailUrl: params.draft.pictureUrl ?? '',
|
|
||||||
createdAt: Math.floor(Date.now() / 1000),
|
|
||||||
zapAmount: 0,
|
|
||||||
paid: true,
|
|
||||||
category: 'author-presentation',
|
|
||||||
isPresentation: true,
|
|
||||||
mainnetAddress: params.draft.mainnetAddress ?? '',
|
|
||||||
totalSponsoring: 0,
|
|
||||||
originalCategory: 'science-fiction',
|
|
||||||
...(params.draft.pictureUrl ? { bannerUrl: params.draft.pictureUrl } : {}),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getActiveRelaysOrPrimary(): Promise<string[]> {
|
// moved to `articlePublisherPresentationHelpers.ts`
|
||||||
const { relaySessionManager } = await import('./relaySessionManager')
|
|
||||||
const activeRelays = await relaySessionManager.getActiveRelays()
|
|
||||||
if (activeRelays.length > 0) {
|
|
||||||
return activeRelays
|
|
||||||
}
|
|
||||||
const { getPrimaryRelay } = await import('./config')
|
|
||||||
return [await getPrimaryRelay()]
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,518 +1,3 @@
|
|||||||
import { type Event } from 'nostr-tools'
|
export { buildPresentationEvent } from './articlePublisherHelpersPresentation/buildPresentationEvent'
|
||||||
import { nip19 } from 'nostr-tools'
|
export { parsePresentationEvent } from './articlePublisherHelpersPresentation/parsePresentationEvent'
|
||||||
import type { AuthorPresentationDraft } from './articlePublisher'
|
export { fetchAuthorPresentationFromPool } from './articlePublisherHelpersPresentation/fetchAuthorPresentationFromPool'
|
||||||
import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
|
|
||||||
import { createSubscription } from '@/types/nostr-tools-extended'
|
|
||||||
import { buildTags, extractTagsFromEvent, buildTagFilter } from './nostrTagSystem'
|
|
||||||
import { getPrimaryRelaySync } from './config'
|
|
||||||
import { PLATFORM_SERVICE, MIN_EVENT_DATE } from './platformConfig'
|
|
||||||
import { generateAuthorHashId } from './hashIdGenerator'
|
|
||||||
import { generateObjectUrl, buildObjectId, parseObjectId } from './urlGenerator'
|
|
||||||
import { getLatestVersion } from './versionManager'
|
|
||||||
import { objectCache } from './objectCache'
|
|
||||||
import { parseAuthorPresentationDraft } from './authorPresentationParsing'
|
|
||||||
|
|
||||||
interface BuildPresentationEventParams {
|
|
||||||
draft: AuthorPresentationDraft
|
|
||||||
authorPubkey: string
|
|
||||||
authorName: string
|
|
||||||
category?: 'sciencefiction' | 'research'
|
|
||||||
version?: number
|
|
||||||
index?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function buildPresentationEvent(
|
|
||||||
params: BuildPresentationEventParams
|
|
||||||
): Promise<{
|
|
||||||
kind: 1
|
|
||||||
created_at: number
|
|
||||||
tags: string[][]
|
|
||||||
content: string
|
|
||||||
}> {
|
|
||||||
const category = params.category ?? 'sciencefiction'
|
|
||||||
const version = params.version ?? 0
|
|
||||||
const index = params.index ?? 0
|
|
||||||
|
|
||||||
const { presentation, contentDescription } = parseAuthorPresentationDraft(params.draft)
|
|
||||||
|
|
||||||
// Generate hash ID from author data first (needed for URL)
|
|
||||||
const hashId = await generateAuthorHashId({
|
|
||||||
pubkey: params.authorPubkey,
|
|
||||||
authorName: params.authorName,
|
|
||||||
presentation,
|
|
||||||
contentDescription,
|
|
||||||
mainnetAddress: params.draft.mainnetAddress ?? undefined,
|
|
||||||
pictureUrl: params.draft.pictureUrl ?? undefined,
|
|
||||||
category,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Build URL: https://zapwall.fr/author/<hash>_<index>_<version> (using hash ID)
|
|
||||||
const profileUrl = generateObjectUrl('author', hashId, index, version)
|
|
||||||
|
|
||||||
// Encode pubkey to npub (for metadata JSON)
|
|
||||||
const npub = nip19.npubEncode(params.authorPubkey)
|
|
||||||
|
|
||||||
const linkWithPreview = buildProfileLink({
|
|
||||||
profileUrl,
|
|
||||||
authorName: params.authorName,
|
|
||||||
pictureUrl: params.draft.pictureUrl,
|
|
||||||
})
|
|
||||||
|
|
||||||
const visibleContent = [
|
|
||||||
'Nouveau profil auteur publié sur zapwall.fr (plateforme de publications scientifiques)',
|
|
||||||
linkWithPreview,
|
|
||||||
`Présentation personnelle : ${presentation}`,
|
|
||||||
...(contentDescription ? [`Description de votre contenu : ${contentDescription}`] : []),
|
|
||||||
].join('\n')
|
|
||||||
|
|
||||||
// Build profile JSON for metadata (stored in tag, not in content)
|
|
||||||
const profileJson = JSON.stringify({
|
|
||||||
authorName: params.authorName,
|
|
||||||
npub,
|
|
||||||
pubkey: params.authorPubkey,
|
|
||||||
presentation,
|
|
||||||
contentDescription,
|
|
||||||
mainnetAddress: params.draft.mainnetAddress,
|
|
||||||
pictureUrl: params.draft.pictureUrl,
|
|
||||||
category,
|
|
||||||
url: profileUrl,
|
|
||||||
version,
|
|
||||||
index,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Build tags (profile JSON is in tag, not in content)
|
|
||||||
const tags = buildTags({
|
|
||||||
type: 'author',
|
|
||||||
category,
|
|
||||||
id: hashId,
|
|
||||||
service: PLATFORM_SERVICE,
|
|
||||||
version,
|
|
||||||
hidden: false,
|
|
||||||
paywall: false,
|
|
||||||
title: params.draft.title,
|
|
||||||
preview: params.draft.preview,
|
|
||||||
mainnetAddress: params.draft.mainnetAddress,
|
|
||||||
...(params.draft.pictureUrl ? { pictureUrl: params.draft.pictureUrl } : {}),
|
|
||||||
})
|
|
||||||
|
|
||||||
// Add JSON metadata as a tag (not in visible content)
|
|
||||||
tags.push(['json', profileJson])
|
|
||||||
|
|
||||||
return {
|
|
||||||
kind: 1 as const,
|
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
|
||||||
tags,
|
|
||||||
content: visibleContent,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildProfileLink(params: { profileUrl: string; authorName: string; pictureUrl: string | undefined }): string {
|
|
||||||
if (params.pictureUrl) {
|
|
||||||
return `[](${params.profileUrl})`
|
|
||||||
}
|
|
||||||
return params.profileUrl
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function parsePresentationEvent(event: Event): Promise<import('@/types/nostr').AuthorPresentationArticle | null> {
|
|
||||||
const tags = extractTagsFromEvent(event)
|
|
||||||
|
|
||||||
// Check if it's an author type (tag is 'author' in English)
|
|
||||||
if (tags.type !== 'author') {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const profileData = readPresentationProfileData(tags.json, event.content)
|
|
||||||
const originalCategory = mapTagCategoryToOriginalCategory(tags.category)
|
|
||||||
const { hash, version, index } = await resolvePresentationIdParts({ tags, event, profileData })
|
|
||||||
const id = buildObjectId(hash, index, version)
|
|
||||||
|
|
||||||
return buildPresentationArticle({
|
|
||||||
id,
|
|
||||||
hash,
|
|
||||||
version,
|
|
||||||
index,
|
|
||||||
event,
|
|
||||||
tags,
|
|
||||||
profileData,
|
|
||||||
originalCategory,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
type PresentationProfileData = {
|
|
||||||
authorName?: string
|
|
||||||
presentation?: string
|
|
||||||
contentDescription?: string
|
|
||||||
mainnetAddress?: string
|
|
||||||
pictureUrl?: string
|
|
||||||
category?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function readPresentationProfileData(jsonTag: string | undefined, content: string): PresentationProfileData | null {
|
|
||||||
if (jsonTag) {
|
|
||||||
return parsePresentationProfileJson(jsonTag)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Backward compatibility: invisible format (with zero-width characters)
|
|
||||||
const invisibleJsonMatch = content.match(/[\u200B\u200C]\[Metadata JSON\][\u200B\u200C]\n[\u200B\u200C](.+)[\u200B\u200C]$/s)
|
|
||||||
if (invisibleJsonMatch?.[1]) {
|
|
||||||
try {
|
|
||||||
const cleanedJson = invisibleJsonMatch[1].replace(/[\u200B\u200C\u200D\u200E\u200F]/g, '').trim()
|
|
||||||
return parsePresentationProfileJson(cleanedJson)
|
|
||||||
} catch (invisibleJsonError) {
|
|
||||||
console.error('Error parsing profile JSON from invisible content:', invisibleJsonError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Backward compatibility: visible format
|
|
||||||
const jsonMatch = content.match(/\[Metadata JSON\]\n(.+)$/s)
|
|
||||||
if (jsonMatch?.[1]) {
|
|
||||||
return parsePresentationProfileJson(jsonMatch[1].trim())
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapTagCategoryToOriginalCategory(category: unknown): 'science-fiction' | 'scientific-research' | undefined {
|
|
||||||
if (category === 'sciencefiction') {
|
|
||||||
return 'science-fiction'
|
|
||||||
}
|
|
||||||
if (category === 'research') {
|
|
||||||
return 'scientific-research'
|
|
||||||
}
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolvePresentationIdParts(params: {
|
|
||||||
tags: ReturnType<typeof extractTagsFromEvent>
|
|
||||||
event: Event
|
|
||||||
profileData: PresentationProfileData | null
|
|
||||||
}): Promise<{ hash: string; version: number; index: number }> {
|
|
||||||
const version = typeof params.tags.version === 'number' ? params.tags.version : 0
|
|
||||||
const index = 0
|
|
||||||
|
|
||||||
const fromIdTag = resolvePresentationIdPartsFromIdTag(params.tags.id, version, index)
|
|
||||||
if (fromIdTag) {
|
|
||||||
return fromIdTag
|
|
||||||
}
|
|
||||||
|
|
||||||
const mainnetAddress = resolvePresentationMainnetAddressCandidate(params.profileData, params.tags)
|
|
||||||
const pictureUrl = resolvePresentationPictureUrlCandidate(params.profileData, params.tags)
|
|
||||||
|
|
||||||
const hash = await generateAuthorHashId({
|
|
||||||
pubkey: params.event.pubkey,
|
|
||||||
authorName: resolveOptionalString(params.profileData?.authorName),
|
|
||||||
presentation: resolveOptionalString(params.profileData?.presentation),
|
|
||||||
contentDescription: resolveOptionalString(params.profileData?.contentDescription),
|
|
||||||
mainnetAddress,
|
|
||||||
pictureUrl,
|
|
||||||
category: resolvePresentationHashCategory(params.profileData, params.tags),
|
|
||||||
})
|
|
||||||
|
|
||||||
return { hash, version, index }
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolvePresentationIdPartsFromIdTag(
|
|
||||||
idTag: string | undefined,
|
|
||||||
defaultVersion: number,
|
|
||||||
defaultIndex: number
|
|
||||||
): { hash: string; version: number; index: number } | undefined {
|
|
||||||
if (!idTag) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = parseObjectId(idTag)
|
|
||||||
if (parsed.hash) {
|
|
||||||
return {
|
|
||||||
hash: parsed.hash,
|
|
||||||
version: parsed.version ?? defaultVersion,
|
|
||||||
index: parsed.index ?? defaultIndex,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { hash: idTag, version: defaultVersion, index: defaultIndex }
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildPresentationArticle(params: {
|
|
||||||
id: string
|
|
||||||
hash: string
|
|
||||||
version: number
|
|
||||||
index: number
|
|
||||||
event: Event
|
|
||||||
tags: ReturnType<typeof extractTagsFromEvent>
|
|
||||||
profileData: PresentationProfileData | null
|
|
||||||
originalCategory: 'science-fiction' | 'scientific-research' | undefined
|
|
||||||
}): import('@/types/nostr').AuthorPresentationArticle {
|
|
||||||
const description = resolvePresentationDescription(params.profileData, params.tags)
|
|
||||||
const contentDescription = sanitizePresentationContentDescription(resolvePresentationContentDescriptionRaw(params.profileData, params.tags))
|
|
||||||
const thumbnailUrl = resolvePresentationThumbnailUrl(params.profileData, params.tags)
|
|
||||||
const mainnetAddress = resolvePresentationMainnetAddress(params.profileData, params.tags)
|
|
||||||
const bannerUrl = resolvePresentationBannerUrl(params.profileData, params.tags)
|
|
||||||
const title = resolvePresentationTitle(params.tags)
|
|
||||||
const preview = resolvePresentationPreview(params.tags, params.event.content)
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: params.id,
|
|
||||||
hash: params.hash,
|
|
||||||
version: params.version,
|
|
||||||
index: params.index,
|
|
||||||
pubkey: params.event.pubkey,
|
|
||||||
title,
|
|
||||||
preview,
|
|
||||||
content: params.event.content,
|
|
||||||
description,
|
|
||||||
contentDescription,
|
|
||||||
thumbnailUrl,
|
|
||||||
createdAt: params.event.created_at,
|
|
||||||
zapAmount: 0,
|
|
||||||
paid: true,
|
|
||||||
category: 'author-presentation',
|
|
||||||
isPresentation: true,
|
|
||||||
mainnetAddress,
|
|
||||||
totalSponsoring: 0,
|
|
||||||
originalCategory: params.originalCategory ?? 'science-fiction',
|
|
||||||
...(bannerUrl ? { bannerUrl } : {}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolvePresentationTitle(tags: ReturnType<typeof extractTagsFromEvent>): string {
|
|
||||||
return typeof tags.title === 'string' && tags.title.length > 0 ? tags.title : 'Présentation'
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolvePresentationPreview(tags: ReturnType<typeof extractTagsFromEvent>, content: string): string {
|
|
||||||
if (typeof tags.preview === 'string' && tags.preview.length > 0) {
|
|
||||||
return tags.preview
|
|
||||||
}
|
|
||||||
return content.substring(0, 200)
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolvePresentationDescription(profileData: PresentationProfileData | null, tags: ReturnType<typeof extractTagsFromEvent>): string {
|
|
||||||
if (typeof profileData?.presentation === 'string') {
|
|
||||||
return profileData.presentation
|
|
||||||
}
|
|
||||||
return typeof tags.description === 'string' ? tags.description : ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolvePresentationContentDescriptionRaw(profileData: PresentationProfileData | null, tags: ReturnType<typeof extractTagsFromEvent>): string {
|
|
||||||
if (typeof profileData?.contentDescription === 'string') {
|
|
||||||
return profileData.contentDescription
|
|
||||||
}
|
|
||||||
return typeof tags.description === 'string' ? tags.description : ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveOptionalString(value: unknown): string {
|
|
||||||
return typeof value === 'string' ? value : ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolvePresentationMainnetAddressCandidate(
|
|
||||||
profileData: PresentationProfileData | null,
|
|
||||||
tags: ReturnType<typeof extractTagsFromEvent>
|
|
||||||
): string | undefined {
|
|
||||||
if (typeof profileData?.mainnetAddress === 'string') {
|
|
||||||
return profileData.mainnetAddress
|
|
||||||
}
|
|
||||||
return typeof tags.mainnetAddress === 'string' ? tags.mainnetAddress : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolvePresentationPictureUrlCandidate(
|
|
||||||
profileData: PresentationProfileData | null,
|
|
||||||
tags: ReturnType<typeof extractTagsFromEvent>
|
|
||||||
): string | undefined {
|
|
||||||
if (typeof profileData?.pictureUrl === 'string') {
|
|
||||||
return profileData.pictureUrl
|
|
||||||
}
|
|
||||||
return typeof tags.pictureUrl === 'string' ? tags.pictureUrl : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolvePresentationHashCategory(profileData: PresentationProfileData | null, tags: ReturnType<typeof extractTagsFromEvent>): string {
|
|
||||||
if (typeof profileData?.category === 'string') {
|
|
||||||
return profileData.category
|
|
||||||
}
|
|
||||||
return typeof tags.category === 'string' ? tags.category : 'sciencefiction'
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizePresentationContentDescription(raw: string): string {
|
|
||||||
return raw
|
|
||||||
.split('\n')
|
|
||||||
.filter((line) => !line.includes('Adresse Bitcoin mainnet (pour le sponsoring)'))
|
|
||||||
.join('\n')
|
|
||||||
.trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolvePresentationThumbnailUrl(profileData: PresentationProfileData | null, tags: ReturnType<typeof extractTagsFromEvent>): string {
|
|
||||||
if (typeof profileData?.pictureUrl === 'string') {
|
|
||||||
return profileData.pictureUrl
|
|
||||||
}
|
|
||||||
return typeof tags.pictureUrl === 'string' ? tags.pictureUrl : ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolvePresentationBannerUrl(profileData: PresentationProfileData | null, tags: ReturnType<typeof extractTagsFromEvent>): string | undefined {
|
|
||||||
if (typeof profileData?.pictureUrl === 'string' && profileData.pictureUrl.length > 0) {
|
|
||||||
return profileData.pictureUrl
|
|
||||||
}
|
|
||||||
return typeof tags.pictureUrl === 'string' ? tags.pictureUrl : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolvePresentationMainnetAddress(profileData: PresentationProfileData | null, tags: ReturnType<typeof extractTagsFromEvent>): string {
|
|
||||||
const fromProfile = profileData?.mainnetAddress
|
|
||||||
if (typeof fromProfile === 'string') {
|
|
||||||
return fromProfile
|
|
||||||
}
|
|
||||||
return typeof tags.mainnetAddress === 'string' ? tags.mainnetAddress : ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function parsePresentationProfileJson(json: string): {
|
|
||||||
authorName?: string
|
|
||||||
presentation?: string
|
|
||||||
contentDescription?: string
|
|
||||||
mainnetAddress?: string
|
|
||||||
pictureUrl?: string
|
|
||||||
category?: string
|
|
||||||
} | null {
|
|
||||||
try {
|
|
||||||
const parsed: unknown = JSON.parse(json)
|
|
||||||
if (typeof parsed !== 'object' || parsed === null) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const obj = parsed as Record<string, unknown>
|
|
||||||
return {
|
|
||||||
...readOptionalStringFields(obj, [
|
|
||||||
'authorName',
|
|
||||||
'presentation',
|
|
||||||
'contentDescription',
|
|
||||||
'mainnetAddress',
|
|
||||||
'pictureUrl',
|
|
||||||
'category',
|
|
||||||
]),
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error parsing presentation profile JSON:', error)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function readOptionalStringFields<TKeys extends readonly string[]>(
|
|
||||||
obj: Record<string, unknown>,
|
|
||||||
keys: TKeys
|
|
||||||
): Partial<Record<TKeys[number], string>> {
|
|
||||||
const result: Partial<Record<TKeys[number], string>> = {}
|
|
||||||
for (const key of keys as ReadonlyArray<TKeys[number]>) {
|
|
||||||
const value = obj[key]
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
result[key] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchAuthorPresentationFromPool(
|
|
||||||
pool: SimplePoolWithSub,
|
|
||||||
pubkey: string
|
|
||||||
): Promise<import('@/types/nostr').AuthorPresentationArticle | null> {
|
|
||||||
// Check cache first - this is the primary source
|
|
||||||
const cached = await objectCache.getAuthorByPubkey(pubkey)
|
|
||||||
if (cached) {
|
|
||||||
// Calculate totalSponsoring from cache
|
|
||||||
const { getAuthorSponsoring } = await import('./sponsoring')
|
|
||||||
cached.totalSponsoring = await getAuthorSponsoring(pubkey)
|
|
||||||
return cached
|
|
||||||
}
|
|
||||||
|
|
||||||
const filters = [
|
|
||||||
{
|
|
||||||
...buildTagFilter({
|
|
||||||
type: 'author',
|
|
||||||
authorPubkey: pubkey,
|
|
||||||
service: PLATFORM_SERVICE,
|
|
||||||
}),
|
|
||||||
since: MIN_EVENT_DATE,
|
|
||||||
limit: 100, // Get all versions to find the latest
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
return new Promise<import('@/types/nostr').AuthorPresentationArticle | null>((resolve) => {
|
|
||||||
let resolved = false
|
|
||||||
const relayUrl = getPrimaryRelaySync()
|
|
||||||
const sub = createSubscription(pool, [relayUrl], filters)
|
|
||||||
|
|
||||||
const events: Event[] = []
|
|
||||||
|
|
||||||
const finalize = async (value: import('@/types/nostr').AuthorPresentationArticle | null): Promise<void> => {
|
|
||||||
if (resolved) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
resolved = true
|
|
||||||
sub.unsub()
|
|
||||||
|
|
||||||
// Cache the result if found
|
|
||||||
if (value && events.length > 0) {
|
|
||||||
const event = events.find((e) => e.id === value.id) ?? events[0]
|
|
||||||
if (event) {
|
|
||||||
const tags = extractTagsFromEvent(event)
|
|
||||||
if (value.hash) {
|
|
||||||
// Calculate totalSponsoring from cache before storing
|
|
||||||
const { getAuthorSponsoring } = await import('./sponsoring')
|
|
||||||
const totalSponsoring = await getAuthorSponsoring(value.pubkey)
|
|
||||||
const cachedValue: import('@/types/nostr').AuthorPresentationArticle = {
|
|
||||||
...value,
|
|
||||||
totalSponsoring,
|
|
||||||
}
|
|
||||||
const { writeObjectToCache } = await import('./helpers/writeObjectHelper')
|
|
||||||
await writeObjectToCache({
|
|
||||||
objectType: 'author',
|
|
||||||
hash: value.hash,
|
|
||||||
event,
|
|
||||||
parsed: cachedValue,
|
|
||||||
version: tags.version,
|
|
||||||
hidden: tags.hidden,
|
|
||||||
index: value.index,
|
|
||||||
})
|
|
||||||
|
|
||||||
resolve(cachedValue)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
sub.on('event', (event: Event): void => {
|
|
||||||
// Collect all events first
|
|
||||||
const tags = extractTagsFromEvent(event)
|
|
||||||
if (tags.type === 'author' && !tags.hidden) {
|
|
||||||
events.push(event)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
sub.on('eose', (): void => {
|
|
||||||
void (async (): Promise<void> => {
|
|
||||||
// Get the latest version from all collected events
|
|
||||||
const latestEvent = getLatestVersion(events)
|
|
||||||
if (latestEvent) {
|
|
||||||
const parsed = await parsePresentationEvent(latestEvent)
|
|
||||||
if (parsed) {
|
|
||||||
await finalize(parsed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await finalize(null)
|
|
||||||
})()
|
|
||||||
})
|
|
||||||
// Reduced timeout for faster feedback when cache is empty
|
|
||||||
setTimeout((): void => {
|
|
||||||
void (async (): Promise<void> => {
|
|
||||||
// Get the latest version from all collected events
|
|
||||||
const latestEvent = getLatestVersion(events)
|
|
||||||
if (latestEvent) {
|
|
||||||
const parsed = await parsePresentationEvent(latestEvent)
|
|
||||||
if (parsed) {
|
|
||||||
await finalize(parsed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await finalize(null)
|
|
||||||
})()
|
|
||||||
}, 2000).unref?.()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
102
lib/articlePublisherHelpersPresentation/articleBuilder.ts
Normal file
102
lib/articlePublisherHelpersPresentation/articleBuilder.ts
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
import type { Event } from 'nostr-tools'
|
||||||
|
import { extractTagsFromEvent } from '../nostrTagSystem'
|
||||||
|
import type { PresentationProfileData } from './profileJson'
|
||||||
|
|
||||||
|
type ExtractedTags = ReturnType<typeof extractTagsFromEvent>
|
||||||
|
|
||||||
|
export function buildPresentationArticle(params: {
|
||||||
|
id: string
|
||||||
|
hash: string
|
||||||
|
version: number
|
||||||
|
index: number
|
||||||
|
event: Event
|
||||||
|
tags: ExtractedTags
|
||||||
|
profileData: PresentationProfileData | null
|
||||||
|
originalCategory: 'science-fiction' | 'scientific-research' | undefined
|
||||||
|
}): import('@/types/nostr').AuthorPresentationArticle {
|
||||||
|
const description = resolvePresentationDescription(params.profileData, params.tags)
|
||||||
|
const contentDescription = sanitizePresentationContentDescription(resolvePresentationContentDescriptionRaw(params.profileData, params.tags))
|
||||||
|
const thumbnailUrl = resolvePresentationThumbnailUrl(params.profileData, params.tags)
|
||||||
|
const mainnetAddress = resolvePresentationMainnetAddress(params.profileData, params.tags)
|
||||||
|
const bannerUrl = resolvePresentationBannerUrl(params.profileData, params.tags)
|
||||||
|
const title = resolvePresentationTitle(params.tags)
|
||||||
|
const preview = resolvePresentationPreview(params.tags, params.event.content)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: params.id,
|
||||||
|
hash: params.hash,
|
||||||
|
version: params.version,
|
||||||
|
index: params.index,
|
||||||
|
pubkey: params.event.pubkey,
|
||||||
|
title,
|
||||||
|
preview,
|
||||||
|
content: params.event.content,
|
||||||
|
description,
|
||||||
|
contentDescription,
|
||||||
|
thumbnailUrl,
|
||||||
|
createdAt: params.event.created_at,
|
||||||
|
zapAmount: 0,
|
||||||
|
paid: true,
|
||||||
|
category: 'author-presentation',
|
||||||
|
isPresentation: true,
|
||||||
|
mainnetAddress,
|
||||||
|
totalSponsoring: 0,
|
||||||
|
originalCategory: params.originalCategory ?? 'science-fiction',
|
||||||
|
...(bannerUrl ? { bannerUrl } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePresentationTitle(tags: ExtractedTags): string {
|
||||||
|
return typeof tags.title === 'string' && tags.title.length > 0 ? tags.title : 'Présentation'
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePresentationPreview(tags: ExtractedTags, content: string): string {
|
||||||
|
if (typeof tags.preview === 'string' && tags.preview.length > 0) {
|
||||||
|
return tags.preview
|
||||||
|
}
|
||||||
|
return content.substring(0, 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePresentationDescription(profileData: PresentationProfileData | null, tags: ExtractedTags): string {
|
||||||
|
if (typeof profileData?.presentation === 'string') {
|
||||||
|
return profileData.presentation
|
||||||
|
}
|
||||||
|
return typeof tags.description === 'string' ? tags.description : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePresentationContentDescriptionRaw(profileData: PresentationProfileData | null, tags: ExtractedTags): string {
|
||||||
|
if (typeof profileData?.contentDescription === 'string') {
|
||||||
|
return profileData.contentDescription
|
||||||
|
}
|
||||||
|
return typeof tags.description === 'string' ? tags.description : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizePresentationContentDescription(raw: string): string {
|
||||||
|
return raw
|
||||||
|
.split('\n')
|
||||||
|
.filter((line) => !line.includes('Adresse Bitcoin mainnet (pour le sponsoring)'))
|
||||||
|
.join('\n')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePresentationThumbnailUrl(profileData: PresentationProfileData | null, tags: ExtractedTags): string {
|
||||||
|
if (typeof profileData?.pictureUrl === 'string') {
|
||||||
|
return profileData.pictureUrl
|
||||||
|
}
|
||||||
|
return typeof tags.pictureUrl === 'string' ? tags.pictureUrl : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePresentationBannerUrl(profileData: PresentationProfileData | null, tags: ExtractedTags): string | undefined {
|
||||||
|
if (typeof profileData?.pictureUrl === 'string' && profileData.pictureUrl.length > 0) {
|
||||||
|
return profileData.pictureUrl
|
||||||
|
}
|
||||||
|
return typeof tags.pictureUrl === 'string' ? tags.pictureUrl : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePresentationMainnetAddress(profileData: PresentationProfileData | null, tags: ExtractedTags): string {
|
||||||
|
const fromProfile = profileData?.mainnetAddress
|
||||||
|
if (typeof fromProfile === 'string') {
|
||||||
|
return fromProfile
|
||||||
|
}
|
||||||
|
return typeof tags.mainnetAddress === 'string' ? tags.mainnetAddress : ''
|
||||||
|
}
|
||||||
@ -0,0 +1,148 @@
|
|||||||
|
import { nip19 } from 'nostr-tools'
|
||||||
|
import type { AuthorPresentationDraft } from '../articlePublisher'
|
||||||
|
import { buildTags } from '../nostrTagSystem'
|
||||||
|
import { PLATFORM_SERVICE } from '../platformConfig'
|
||||||
|
import { generateAuthorHashId } from '../hashIdGenerator'
|
||||||
|
import { generateObjectUrl } from '../urlGenerator'
|
||||||
|
import { parseAuthorPresentationDraft } from '../authorPresentationParsing'
|
||||||
|
|
||||||
|
interface BuildPresentationEventParams {
|
||||||
|
draft: AuthorPresentationDraft
|
||||||
|
authorPubkey: string
|
||||||
|
authorName: string
|
||||||
|
category?: 'sciencefiction' | 'research'
|
||||||
|
version?: number
|
||||||
|
index?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildPresentationEvent(
|
||||||
|
params: BuildPresentationEventParams
|
||||||
|
): Promise<{ kind: 1; created_at: number; tags: string[][]; content: string }> {
|
||||||
|
const { category, version, index } = normalizeBuildPresentationEventParams(params)
|
||||||
|
const parsedDraft = parseAuthorPresentationDraft(params.draft)
|
||||||
|
const hashId = await generateAuthorHashIdForPresentation({
|
||||||
|
authorPubkey: params.authorPubkey,
|
||||||
|
authorName: params.authorName,
|
||||||
|
parsedDraft,
|
||||||
|
mainnetAddress: params.draft.mainnetAddress,
|
||||||
|
pictureUrl: params.draft.pictureUrl,
|
||||||
|
category,
|
||||||
|
})
|
||||||
|
const profileUrl = generateObjectUrl('author', hashId, index, version)
|
||||||
|
const visibleContent = buildPresentationVisibleContent({
|
||||||
|
profileUrl,
|
||||||
|
authorName: params.authorName,
|
||||||
|
pictureUrl: params.draft.pictureUrl,
|
||||||
|
presentation: parsedDraft.presentation,
|
||||||
|
contentDescription: parsedDraft.contentDescription,
|
||||||
|
})
|
||||||
|
const profileJson = buildPresentationProfileJson({
|
||||||
|
authorPubkey: params.authorPubkey,
|
||||||
|
authorName: params.authorName,
|
||||||
|
profileUrl,
|
||||||
|
presentation: parsedDraft.presentation,
|
||||||
|
contentDescription: parsedDraft.contentDescription,
|
||||||
|
mainnetAddress: params.draft.mainnetAddress,
|
||||||
|
pictureUrl: params.draft.pictureUrl,
|
||||||
|
category,
|
||||||
|
version,
|
||||||
|
index,
|
||||||
|
})
|
||||||
|
const tags = buildPresentationTags({ draft: params.draft, category, hashId, version, profileJson })
|
||||||
|
return { kind: 1 as const, created_at: Math.floor(Date.now() / 1000), tags, content: visibleContent }
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBuildPresentationEventParams(params: BuildPresentationEventParams): { category: 'sciencefiction' | 'research'; version: number; index: number } {
|
||||||
|
return { category: params.category ?? 'sciencefiction', version: params.version ?? 0, index: params.index ?? 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateAuthorHashIdForPresentation(params: {
|
||||||
|
authorPubkey: string
|
||||||
|
authorName: string
|
||||||
|
parsedDraft: { presentation: string; contentDescription: string }
|
||||||
|
mainnetAddress: string | undefined
|
||||||
|
pictureUrl: string | undefined
|
||||||
|
category: 'sciencefiction' | 'research'
|
||||||
|
}): Promise<string> {
|
||||||
|
return generateAuthorHashId({
|
||||||
|
pubkey: params.authorPubkey,
|
||||||
|
authorName: params.authorName,
|
||||||
|
presentation: params.parsedDraft.presentation,
|
||||||
|
contentDescription: params.parsedDraft.contentDescription,
|
||||||
|
mainnetAddress: params.mainnetAddress ?? undefined,
|
||||||
|
pictureUrl: params.pictureUrl ?? undefined,
|
||||||
|
category: params.category,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPresentationVisibleContent(params: {
|
||||||
|
profileUrl: string
|
||||||
|
authorName: string
|
||||||
|
pictureUrl: string | undefined
|
||||||
|
presentation: string
|
||||||
|
contentDescription: string
|
||||||
|
}): string {
|
||||||
|
const linkWithPreview = buildProfileLink({ profileUrl: params.profileUrl, authorName: params.authorName, pictureUrl: params.pictureUrl })
|
||||||
|
const lines = [
|
||||||
|
'Nouveau profil auteur publié sur zapwall.fr (plateforme de publications scientifiques)',
|
||||||
|
linkWithPreview,
|
||||||
|
`Présentation personnelle : ${params.presentation}`,
|
||||||
|
]
|
||||||
|
if (params.contentDescription) {
|
||||||
|
lines.push(`Description de votre contenu : ${params.contentDescription}`)
|
||||||
|
}
|
||||||
|
return lines.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPresentationProfileJson(params: {
|
||||||
|
authorPubkey: string
|
||||||
|
authorName: string
|
||||||
|
profileUrl: string
|
||||||
|
presentation: string
|
||||||
|
contentDescription: string
|
||||||
|
mainnetAddress: string | undefined
|
||||||
|
pictureUrl: string | undefined
|
||||||
|
category: 'sciencefiction' | 'research'
|
||||||
|
version: number
|
||||||
|
index: number
|
||||||
|
}): string {
|
||||||
|
const npub = nip19.npubEncode(params.authorPubkey)
|
||||||
|
return JSON.stringify({
|
||||||
|
authorName: params.authorName,
|
||||||
|
npub,
|
||||||
|
pubkey: params.authorPubkey,
|
||||||
|
presentation: params.presentation,
|
||||||
|
contentDescription: params.contentDescription,
|
||||||
|
mainnetAddress: params.mainnetAddress,
|
||||||
|
pictureUrl: params.pictureUrl,
|
||||||
|
category: params.category,
|
||||||
|
url: params.profileUrl,
|
||||||
|
version: params.version,
|
||||||
|
index: params.index,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPresentationTags(params: { draft: AuthorPresentationDraft; category: 'sciencefiction' | 'research'; hashId: string; version: number; profileJson: string }): string[][] {
|
||||||
|
const tags = buildTags({
|
||||||
|
type: 'author',
|
||||||
|
category: params.category,
|
||||||
|
id: params.hashId,
|
||||||
|
service: PLATFORM_SERVICE,
|
||||||
|
version: params.version,
|
||||||
|
hidden: false,
|
||||||
|
paywall: false,
|
||||||
|
title: params.draft.title,
|
||||||
|
preview: params.draft.preview,
|
||||||
|
mainnetAddress: params.draft.mainnetAddress,
|
||||||
|
...(params.draft.pictureUrl ? { pictureUrl: params.draft.pictureUrl } : {}),
|
||||||
|
})
|
||||||
|
tags.push(['json', params.profileJson])
|
||||||
|
return tags
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildProfileLink(params: { profileUrl: string; authorName: string; pictureUrl: string | undefined }): string {
|
||||||
|
if (params.pictureUrl) {
|
||||||
|
return `[](${params.profileUrl})`
|
||||||
|
}
|
||||||
|
return params.profileUrl
|
||||||
|
}
|
||||||
@ -0,0 +1,122 @@
|
|||||||
|
import type { Event, Filter } from 'nostr-tools'
|
||||||
|
import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
|
||||||
|
import { createSubscription } from '@/types/nostr-tools-extended'
|
||||||
|
import { buildTagFilter, extractTagsFromEvent } from '../nostrTagSystem'
|
||||||
|
import { getPrimaryRelaySync } from '../config'
|
||||||
|
import { MIN_EVENT_DATE, PLATFORM_SERVICE } from '../platformConfig'
|
||||||
|
import { getLatestVersion } from '../versionManager'
|
||||||
|
import { objectCache } from '../objectCache'
|
||||||
|
import { parsePresentationEvent } from './parsePresentationEvent'
|
||||||
|
|
||||||
|
export async function fetchAuthorPresentationFromPool(
|
||||||
|
pool: SimplePoolWithSub,
|
||||||
|
pubkey: string
|
||||||
|
): Promise<import('@/types/nostr').AuthorPresentationArticle | null> {
|
||||||
|
const cached = await objectCache.getAuthorByPubkey(pubkey)
|
||||||
|
if (cached) {
|
||||||
|
return enrichAuthorPresentationWithSponsoring({ author: cached, pubkey })
|
||||||
|
}
|
||||||
|
const filters: Filter[] = [
|
||||||
|
{
|
||||||
|
...buildTagFilter({ type: 'author', authorPubkey: pubkey, service: PLATFORM_SERVICE }),
|
||||||
|
since: MIN_EVENT_DATE,
|
||||||
|
limit: 100,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
return fetchAuthorPresentationFromRelay({ pool, pubkey, filters })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enrichAuthorPresentationWithSponsoring(params: {
|
||||||
|
author: import('@/types/nostr').AuthorPresentationArticle
|
||||||
|
pubkey: string
|
||||||
|
}): Promise<import('@/types/nostr').AuthorPresentationArticle> {
|
||||||
|
const { getAuthorSponsoring } = await import('../sponsoring')
|
||||||
|
const totalSponsoring = await getAuthorSponsoring(params.pubkey)
|
||||||
|
return { ...params.author, totalSponsoring }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAuthorPresentationFromRelay(params: {
|
||||||
|
pool: SimplePoolWithSub
|
||||||
|
pubkey: string
|
||||||
|
filters: Filter[]
|
||||||
|
}): Promise<import('@/types/nostr').AuthorPresentationArticle | null> {
|
||||||
|
const relayUrl = getPrimaryRelaySync()
|
||||||
|
const events: Event[] = []
|
||||||
|
const sub = createSubscription(params.pool, [relayUrl], params.filters)
|
||||||
|
const finalize = createRelayFetchFinalizer({ pubkey: params.pubkey, sub, events })
|
||||||
|
|
||||||
|
return new Promise<import('@/types/nostr').AuthorPresentationArticle | null>((resolve) => {
|
||||||
|
const finalizeAndResolve = async (value: import('@/types/nostr').AuthorPresentationArticle | null): Promise<void> => {
|
||||||
|
const finalValue = await finalize(value)
|
||||||
|
resolve(finalValue)
|
||||||
|
}
|
||||||
|
sub.on('event', (event: Event): void => {
|
||||||
|
const tags = extractTagsFromEvent(event)
|
||||||
|
if (tags.type === 'author' && !tags.hidden) {
|
||||||
|
events.push(event)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
sub.on('eose', (): void => {
|
||||||
|
void tryFinalizeFromLatestEvent({ events, finalize: finalizeAndResolve })
|
||||||
|
})
|
||||||
|
setTimeout((): void => {
|
||||||
|
void tryFinalizeFromLatestEvent({ events, finalize: finalizeAndResolve })
|
||||||
|
}, 2000).unref?.()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tryFinalizeFromLatestEvent(params: {
|
||||||
|
events: Event[]
|
||||||
|
finalize: (value: import('@/types/nostr').AuthorPresentationArticle | null) => Promise<void>
|
||||||
|
}): Promise<void> {
|
||||||
|
const latestEvent = getLatestVersion(params.events)
|
||||||
|
if (!latestEvent) {
|
||||||
|
await params.finalize(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const parsed = await parsePresentationEvent(latestEvent)
|
||||||
|
await params.finalize(parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRelayFetchFinalizer(params: {
|
||||||
|
pubkey: string
|
||||||
|
sub: import('@/types/nostr-tools-extended').Subscription
|
||||||
|
events: Event[]
|
||||||
|
}): (value: import('@/types/nostr').AuthorPresentationArticle | null) => Promise<import('@/types/nostr').AuthorPresentationArticle | null> {
|
||||||
|
let resolved = false
|
||||||
|
return async (value: import('@/types/nostr').AuthorPresentationArticle | null): Promise<import('@/types/nostr').AuthorPresentationArticle | null> => {
|
||||||
|
if (resolved) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
resolved = true
|
||||||
|
params.sub.unsub()
|
||||||
|
return cacheAuthorPresentationFromEvents({ value, events: params.events, pubkey: params.pubkey })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cacheAuthorPresentationFromEvents(params: {
|
||||||
|
value: import('@/types/nostr').AuthorPresentationArticle | null
|
||||||
|
events: Event[]
|
||||||
|
pubkey: string
|
||||||
|
}): Promise<import('@/types/nostr').AuthorPresentationArticle | null> {
|
||||||
|
if (!params.value || params.events.length === 0 || !params.value.hash) {
|
||||||
|
return params.value
|
||||||
|
}
|
||||||
|
const event = params.events.find((e) => e.id === params.value?.id) ?? params.events[0]
|
||||||
|
if (!event) {
|
||||||
|
return params.value
|
||||||
|
}
|
||||||
|
const tags = extractTagsFromEvent(event)
|
||||||
|
const cachedValue = await enrichAuthorPresentationWithSponsoring({ author: params.value, pubkey: params.pubkey })
|
||||||
|
const { writeObjectToCache } = await import('../helpers/writeObjectHelper')
|
||||||
|
await writeObjectToCache({
|
||||||
|
objectType: 'author',
|
||||||
|
hash: params.value.hash,
|
||||||
|
event,
|
||||||
|
parsed: cachedValue,
|
||||||
|
version: tags.version,
|
||||||
|
hidden: tags.hidden,
|
||||||
|
index: params.value.index,
|
||||||
|
})
|
||||||
|
return cachedValue
|
||||||
|
}
|
||||||
97
lib/articlePublisherHelpersPresentation/idResolution.ts
Normal file
97
lib/articlePublisherHelpersPresentation/idResolution.ts
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import type { Event } from 'nostr-tools'
|
||||||
|
import { generateAuthorHashId } from '../hashIdGenerator'
|
||||||
|
import { parseObjectId } from '../urlGenerator'
|
||||||
|
import { extractTagsFromEvent } from '../nostrTagSystem'
|
||||||
|
import type { PresentationProfileData } from './profileJson'
|
||||||
|
|
||||||
|
type ExtractedTags = ReturnType<typeof extractTagsFromEvent>
|
||||||
|
|
||||||
|
export function mapTagCategoryToOriginalCategory(category: unknown): 'science-fiction' | 'scientific-research' | undefined {
|
||||||
|
if (category === 'sciencefiction') {
|
||||||
|
return 'science-fiction'
|
||||||
|
}
|
||||||
|
if (category === 'research') {
|
||||||
|
return 'scientific-research'
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolvePresentationIdParts(params: {
|
||||||
|
tags: ExtractedTags
|
||||||
|
event: Event
|
||||||
|
profileData: PresentationProfileData | null
|
||||||
|
}): Promise<{ hash: string; version: number; index: number }> {
|
||||||
|
const version = typeof params.tags.version === 'number' ? params.tags.version : 0
|
||||||
|
const index = 0
|
||||||
|
|
||||||
|
const fromIdTag = resolvePresentationIdPartsFromIdTag(params.tags.id, version, index)
|
||||||
|
if (fromIdTag) {
|
||||||
|
return fromIdTag
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainnetAddress = resolvePresentationMainnetAddressCandidate(params.profileData, params.tags)
|
||||||
|
const pictureUrl = resolvePresentationPictureUrlCandidate(params.profileData, params.tags)
|
||||||
|
|
||||||
|
const hash = await generateAuthorHashId({
|
||||||
|
pubkey: params.event.pubkey,
|
||||||
|
authorName: resolveOptionalString(params.profileData?.authorName),
|
||||||
|
presentation: resolveOptionalString(params.profileData?.presentation),
|
||||||
|
contentDescription: resolveOptionalString(params.profileData?.contentDescription),
|
||||||
|
mainnetAddress,
|
||||||
|
pictureUrl,
|
||||||
|
category: resolvePresentationHashCategory(params.profileData, params.tags),
|
||||||
|
})
|
||||||
|
|
||||||
|
return { hash, version, index }
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePresentationIdPartsFromIdTag(
|
||||||
|
idTag: string | undefined,
|
||||||
|
defaultVersion: number,
|
||||||
|
defaultIndex: number
|
||||||
|
): { hash: string; version: number; index: number } | undefined {
|
||||||
|
if (!idTag) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseObjectId(idTag)
|
||||||
|
if (parsed.hash) {
|
||||||
|
return { hash: parsed.hash, version: parsed.version ?? defaultVersion, index: parsed.index ?? defaultIndex }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { hash: idTag, version: defaultVersion, index: defaultIndex }
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveOptionalString(value: unknown): string {
|
||||||
|
return typeof value === 'string' ? value : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePresentationMainnetAddressCandidate(
|
||||||
|
profileData: PresentationProfileData | null,
|
||||||
|
tags: ExtractedTags
|
||||||
|
): string | undefined {
|
||||||
|
if (typeof profileData?.mainnetAddress === 'string') {
|
||||||
|
return profileData.mainnetAddress
|
||||||
|
}
|
||||||
|
return typeof tags.mainnetAddress === 'string' ? tags.mainnetAddress : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePresentationPictureUrlCandidate(
|
||||||
|
profileData: PresentationProfileData | null,
|
||||||
|
tags: ExtractedTags
|
||||||
|
): string | undefined {
|
||||||
|
if (typeof profileData?.pictureUrl === 'string') {
|
||||||
|
return profileData.pictureUrl
|
||||||
|
}
|
||||||
|
return typeof tags.pictureUrl === 'string' ? tags.pictureUrl : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePresentationHashCategory(
|
||||||
|
profileData: PresentationProfileData | null,
|
||||||
|
tags: ExtractedTags
|
||||||
|
): string {
|
||||||
|
if (typeof profileData?.category === 'string') {
|
||||||
|
return profileData.category
|
||||||
|
}
|
||||||
|
return typeof tags.category === 'string' ? tags.category : 'sciencefiction'
|
||||||
|
}
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
import type { Event } from 'nostr-tools'
|
||||||
|
import { extractTagsFromEvent } from '../nostrTagSystem'
|
||||||
|
import { buildObjectId } from '../urlGenerator'
|
||||||
|
import { readPresentationProfileData } from './profileData'
|
||||||
|
import { mapTagCategoryToOriginalCategory, resolvePresentationIdParts } from './idResolution'
|
||||||
|
import { buildPresentationArticle } from './articleBuilder'
|
||||||
|
|
||||||
|
export async function parsePresentationEvent(event: Event): Promise<import('@/types/nostr').AuthorPresentationArticle | null> {
|
||||||
|
const tags = extractTagsFromEvent(event)
|
||||||
|
if (tags.type !== 'author') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const profileData = readPresentationProfileData(tags.json, event.content)
|
||||||
|
const originalCategory = mapTagCategoryToOriginalCategory(tags.category)
|
||||||
|
const { hash, version, index } = await resolvePresentationIdParts({ tags, event, profileData })
|
||||||
|
const id = buildObjectId(hash, index, version)
|
||||||
|
return buildPresentationArticle({ id, hash, version, index, event, tags, profileData, originalCategory })
|
||||||
|
}
|
||||||
26
lib/articlePublisherHelpersPresentation/profileData.ts
Normal file
26
lib/articlePublisherHelpersPresentation/profileData.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { parsePresentationProfileJson, type PresentationProfileData } from './profileJson'
|
||||||
|
|
||||||
|
export function readPresentationProfileData(jsonTag: string | undefined, content: string): PresentationProfileData | null {
|
||||||
|
if (jsonTag) {
|
||||||
|
return parsePresentationProfileJson(jsonTag)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backward compatibility: invisible format (with zero-width characters)
|
||||||
|
const invisibleJsonMatch = content.match(/[\u200B\u200C]\[Metadata JSON\][\u200B\u200C]\n[\u200B\u200C](.+)[\u200B\u200C]$/s)
|
||||||
|
if (invisibleJsonMatch?.[1]) {
|
||||||
|
try {
|
||||||
|
const cleanedJson = invisibleJsonMatch[1].replace(/[\u200B\u200C\u200D\u200E\u200F]/g, '').trim()
|
||||||
|
return parsePresentationProfileJson(cleanedJson)
|
||||||
|
} catch (invisibleJsonError) {
|
||||||
|
console.error('Error parsing profile JSON from invisible content:', invisibleJsonError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backward compatibility: visible format
|
||||||
|
const jsonMatch = content.match(/\[Metadata JSON\]\n(.+)$/s)
|
||||||
|
if (jsonMatch?.[1]) {
|
||||||
|
return parsePresentationProfileJson(jsonMatch[1].trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
38
lib/articlePublisherHelpersPresentation/profileJson.ts
Normal file
38
lib/articlePublisherHelpersPresentation/profileJson.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
export type PresentationProfileData = {
|
||||||
|
authorName?: string
|
||||||
|
presentation?: string
|
||||||
|
contentDescription?: string
|
||||||
|
mainnetAddress?: string
|
||||||
|
pictureUrl?: string
|
||||||
|
category?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePresentationProfileJson(json: string): PresentationProfileData | null {
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(json)
|
||||||
|
if (typeof parsed !== 'object' || parsed === null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const obj = parsed as Record<string, unknown>
|
||||||
|
return {
|
||||||
|
...readOptionalStringFields(obj, ['authorName', 'presentation', 'contentDescription', 'mainnetAddress', 'pictureUrl', 'category']),
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error parsing presentation profile JSON:', error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readOptionalStringFields<TKeys extends readonly string[]>(
|
||||||
|
obj: Record<string, unknown>,
|
||||||
|
keys: TKeys
|
||||||
|
): Partial<Record<TKeys[number], string>> {
|
||||||
|
const result: Partial<Record<TKeys[number], string>> = {}
|
||||||
|
for (const key of keys as ReadonlyArray<TKeys[number]>) {
|
||||||
|
const value = obj[key]
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
result[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
91
lib/articlePublisherPresentationHelpers.ts
Normal file
91
lib/articlePublisherPresentationHelpers.ts
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import type { AuthorPresentationDraft } from './articlePublisherTypes'
|
||||||
|
import { buildPresentationEvent } from './articlePublisherHelpers'
|
||||||
|
import { generateAuthorHashId } from './hashIdGenerator'
|
||||||
|
import { buildObjectId } from './urlGenerator'
|
||||||
|
import { extractAuthorNameFromTitle, parseAuthorPresentationDraft } from './authorPresentationParsing'
|
||||||
|
import type { EventTemplate } from 'nostr-tools'
|
||||||
|
|
||||||
|
export async function buildPresentationPublishContext(params: {
|
||||||
|
draft: AuthorPresentationDraft
|
||||||
|
authorPubkey: string
|
||||||
|
}): Promise<{
|
||||||
|
hash: string
|
||||||
|
version: number
|
||||||
|
index: number
|
||||||
|
parsedAuthor: import('@/types/nostr').AuthorPresentationArticle
|
||||||
|
eventTemplate: EventTemplate
|
||||||
|
}> {
|
||||||
|
const authorName = extractAuthorNameFromTitle(params.draft.title)
|
||||||
|
const { presentation, contentDescription } = parseAuthorPresentationDraft(params.draft)
|
||||||
|
const category = 'sciencefiction'
|
||||||
|
const version = 0
|
||||||
|
const index = 0
|
||||||
|
|
||||||
|
const hash = await generateAuthorHashId({
|
||||||
|
pubkey: params.authorPubkey,
|
||||||
|
authorName,
|
||||||
|
presentation,
|
||||||
|
contentDescription,
|
||||||
|
mainnetAddress: params.draft.mainnetAddress ?? undefined,
|
||||||
|
pictureUrl: params.draft.pictureUrl ?? undefined,
|
||||||
|
category,
|
||||||
|
})
|
||||||
|
const id = buildObjectId(hash, index, version)
|
||||||
|
|
||||||
|
const parsedAuthor = buildParsedAuthorPresentation({
|
||||||
|
draft: params.draft,
|
||||||
|
authorPubkey: params.authorPubkey,
|
||||||
|
id,
|
||||||
|
hash,
|
||||||
|
version,
|
||||||
|
index,
|
||||||
|
presentation,
|
||||||
|
contentDescription,
|
||||||
|
})
|
||||||
|
const eventTemplate = await buildPresentationEvent({ draft: params.draft, authorPubkey: params.authorPubkey, authorName, category, version, index })
|
||||||
|
return { hash, version, index, parsedAuthor, eventTemplate }
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildParsedAuthorPresentation(params: {
|
||||||
|
draft: AuthorPresentationDraft
|
||||||
|
authorPubkey: string
|
||||||
|
id: string
|
||||||
|
hash: string
|
||||||
|
version: number
|
||||||
|
index: number
|
||||||
|
presentation: string
|
||||||
|
contentDescription: string
|
||||||
|
}): import('@/types/nostr').AuthorPresentationArticle {
|
||||||
|
return {
|
||||||
|
id: params.id,
|
||||||
|
hash: params.hash,
|
||||||
|
version: params.version,
|
||||||
|
index: params.index,
|
||||||
|
pubkey: params.authorPubkey,
|
||||||
|
title: params.draft.title,
|
||||||
|
preview: params.draft.preview,
|
||||||
|
content: params.draft.content,
|
||||||
|
description: params.presentation,
|
||||||
|
contentDescription: params.contentDescription,
|
||||||
|
thumbnailUrl: params.draft.pictureUrl ?? '',
|
||||||
|
createdAt: Math.floor(Date.now() / 1000),
|
||||||
|
zapAmount: 0,
|
||||||
|
paid: true,
|
||||||
|
category: 'author-presentation',
|
||||||
|
isPresentation: true,
|
||||||
|
mainnetAddress: params.draft.mainnetAddress ?? '',
|
||||||
|
totalSponsoring: 0,
|
||||||
|
originalCategory: 'science-fiction',
|
||||||
|
...(params.draft.pictureUrl ? { bannerUrl: params.draft.pictureUrl } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getActiveRelaysOrPrimary(): Promise<string[]> {
|
||||||
|
const { relaySessionManager } = await import('./relaySessionManager')
|
||||||
|
const activeRelays = await relaySessionManager.getActiveRelays()
|
||||||
|
if (activeRelays.length > 0) {
|
||||||
|
return activeRelays
|
||||||
|
}
|
||||||
|
const { getPrimaryRelay } = await import('./config')
|
||||||
|
return [await getPrimaryRelay()]
|
||||||
|
}
|
||||||
@ -1,616 +1 @@
|
|||||||
/**
|
export * from './indexedDBHelper/index'
|
||||||
* Centralized IndexedDB helper for initialization and transaction management
|
|
||||||
* Provides unified API for all IndexedDB operations across the application
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface IndexedDBIndex {
|
|
||||||
name: string
|
|
||||||
keyPath: string | string[]
|
|
||||||
unique?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IndexedDBConfig {
|
|
||||||
dbName: string
|
|
||||||
version: number
|
|
||||||
storeName: string
|
|
||||||
keyPath: string
|
|
||||||
indexes?: IndexedDBIndex[]
|
|
||||||
onUpgrade?: (db: IDBDatabase, event: IDBVersionChangeEvent) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export class IndexedDBError extends Error {
|
|
||||||
public readonly operation: string
|
|
||||||
public readonly storeName: string | undefined
|
|
||||||
public override readonly cause: unknown
|
|
||||||
|
|
||||||
public override readonly name = 'IndexedDBError'
|
|
||||||
|
|
||||||
constructor(message: string, operation: string, storeName?: string, cause?: unknown) {
|
|
||||||
super(message)
|
|
||||||
this.operation = operation
|
|
||||||
this.storeName = storeName
|
|
||||||
this.cause = cause
|
|
||||||
console.error(`[IndexedDBError] ${operation}${storeName ? ` on ${storeName}` : ''}: ${message}`, cause)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class IndexedDBHelper {
|
|
||||||
private db: IDBDatabase | null = null
|
|
||||||
private initPromise: Promise<void> | null = null
|
|
||||||
private readonly config: IndexedDBConfig
|
|
||||||
|
|
||||||
constructor(config: IndexedDBConfig) {
|
|
||||||
this.config = config
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize the IndexedDB database
|
|
||||||
*/
|
|
||||||
async init(): Promise<IDBDatabase> {
|
|
||||||
if (this.db) {
|
|
||||||
return this.db
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.initPromise) {
|
|
||||||
await this.initPromise
|
|
||||||
if (this.db) {
|
|
||||||
return this.db
|
|
||||||
}
|
|
||||||
throw new IndexedDBError('Database initialization failed', 'init', this.config.storeName)
|
|
||||||
}
|
|
||||||
|
|
||||||
this.initPromise = this.openDatabase()
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.initPromise
|
|
||||||
if (!this.db) {
|
|
||||||
throw new IndexedDBError('Database not initialized after open', 'init', this.config.storeName)
|
|
||||||
}
|
|
||||||
return this.db
|
|
||||||
} catch (error) {
|
|
||||||
this.initPromise = null
|
|
||||||
throw new IndexedDBError(
|
|
||||||
error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
'init',
|
|
||||||
this.config.storeName,
|
|
||||||
error
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private openDatabase(): Promise<void> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
if (typeof window === 'undefined' || !window.indexedDB) {
|
|
||||||
reject(new IndexedDBError('IndexedDB is not available', 'openDatabase', this.config.storeName))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const request = window.indexedDB.open(this.config.dbName, this.config.version)
|
|
||||||
|
|
||||||
request.onerror = (): void => {
|
|
||||||
reject(
|
|
||||||
new IndexedDBError(
|
|
||||||
`Failed to open IndexedDB: ${request.error}`,
|
|
||||||
'openDatabase',
|
|
||||||
this.config.storeName,
|
|
||||||
request.error
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
request.onsuccess = (): void => {
|
|
||||||
this.db = request.result
|
|
||||||
resolve()
|
|
||||||
}
|
|
||||||
|
|
||||||
request.onupgradeneeded = (event: IDBVersionChangeEvent): void => {
|
|
||||||
this.handleUpgrade(event)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleUpgrade(event: IDBVersionChangeEvent): void {
|
|
||||||
const db = (event.target as IDBOpenDBRequest).result
|
|
||||||
|
|
||||||
// Create object store if it doesn't exist
|
|
||||||
if (!db.objectStoreNames.contains(this.config.storeName)) {
|
|
||||||
this.createObjectStore(db)
|
|
||||||
} else {
|
|
||||||
// Store exists, check for missing indexes
|
|
||||||
this.createMissingIndexes(db, event)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Call custom upgrade handler if provided
|
|
||||||
if (this.config.onUpgrade) {
|
|
||||||
this.config.onUpgrade(db, event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private createObjectStore(db: IDBDatabase): void {
|
|
||||||
const store = db.createObjectStore(this.config.storeName, { keyPath: this.config.keyPath })
|
|
||||||
|
|
||||||
// Create indexes
|
|
||||||
if (this.config.indexes) {
|
|
||||||
for (const index of this.config.indexes) {
|
|
||||||
if (!store.indexNames.contains(index.name)) {
|
|
||||||
store.createIndex(index.name, index.keyPath, { unique: index.unique ?? false })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private createMissingIndexes(_db: IDBDatabase, event: IDBVersionChangeEvent): void {
|
|
||||||
const target = event.target as IDBOpenDBRequest
|
|
||||||
const { transaction } = target
|
|
||||||
if (!transaction) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const store = transaction.objectStore(this.config.storeName)
|
|
||||||
if (this.config.indexes) {
|
|
||||||
for (const index of this.config.indexes) {
|
|
||||||
if (!store.indexNames.contains(index.name)) {
|
|
||||||
store.createIndex(index.name, index.keyPath, { unique: index.unique ?? false })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get object store for read operations
|
|
||||||
*/
|
|
||||||
async getStore(mode: 'readonly'): Promise<IDBObjectStore> {
|
|
||||||
const db = await this.init()
|
|
||||||
const transaction = db.transaction([this.config.storeName], mode)
|
|
||||||
return transaction.objectStore(this.config.storeName)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get object store for write operations
|
|
||||||
*/
|
|
||||||
async getStoreWrite(mode: 'readwrite'): Promise<IDBObjectStore> {
|
|
||||||
const db = await this.init()
|
|
||||||
const transaction = db.transaction([this.config.storeName], mode)
|
|
||||||
return transaction.objectStore(this.config.storeName)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a value from the store by key
|
|
||||||
*/
|
|
||||||
async get<T = unknown>(key: string | number): Promise<T | null> {
|
|
||||||
try {
|
|
||||||
const store = await this.getStore('readonly')
|
|
||||||
return new Promise<T | null>((resolve, reject) => {
|
|
||||||
const request = store.get(key)
|
|
||||||
request.onsuccess = (): void => {
|
|
||||||
resolve((request.result as T) ?? null)
|
|
||||||
}
|
|
||||||
request.onerror = (): void => {
|
|
||||||
reject(
|
|
||||||
new IndexedDBError(
|
|
||||||
`Failed to get value: ${request.error}`,
|
|
||||||
'get',
|
|
||||||
this.config.storeName,
|
|
||||||
request.error
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof IndexedDBError) {
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
throw new IndexedDBError(
|
|
||||||
error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
'get',
|
|
||||||
this.config.storeName,
|
|
||||||
error
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a value from an index
|
|
||||||
*/
|
|
||||||
async getByIndex<T = unknown>(indexName: string, key: string | number): Promise<T | null> {
|
|
||||||
try {
|
|
||||||
const store = await this.getStore('readonly')
|
|
||||||
const index = store.index(indexName)
|
|
||||||
return new Promise<T | null>((resolve, reject) => {
|
|
||||||
const request = index.get(key)
|
|
||||||
request.onsuccess = (): void => {
|
|
||||||
resolve((request.result as T) ?? null)
|
|
||||||
}
|
|
||||||
request.onerror = (): void => {
|
|
||||||
reject(
|
|
||||||
new IndexedDBError(
|
|
||||||
`Failed to get value by index: ${request.error}`,
|
|
||||||
'getByIndex',
|
|
||||||
this.config.storeName,
|
|
||||||
request.error
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof IndexedDBError) {
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
throw new IndexedDBError(
|
|
||||||
error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
'getByIndex',
|
|
||||||
this.config.storeName,
|
|
||||||
error
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all values from an index
|
|
||||||
*/
|
|
||||||
async getAllByIndex<T = unknown>(indexName: string, key?: IDBValidKey | IDBKeyRange): Promise<T[]> {
|
|
||||||
try {
|
|
||||||
const store = await this.getStore('readonly')
|
|
||||||
const index = store.index(indexName)
|
|
||||||
return new Promise<T[]>((resolve, reject) => {
|
|
||||||
const request = key !== undefined ? index.getAll(key) : index.getAll()
|
|
||||||
request.onsuccess = (): void => {
|
|
||||||
resolve((request.result as T[]) ?? [])
|
|
||||||
}
|
|
||||||
request.onerror = (): void => {
|
|
||||||
reject(
|
|
||||||
new IndexedDBError(
|
|
||||||
`Failed to get all values by index: ${request.error}`,
|
|
||||||
'getAllByIndex',
|
|
||||||
this.config.storeName,
|
|
||||||
request.error
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof IndexedDBError) {
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
throw new IndexedDBError(
|
|
||||||
error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
'getAllByIndex',
|
|
||||||
this.config.storeName,
|
|
||||||
error
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Put a value in the store
|
|
||||||
*/
|
|
||||||
async put<T = unknown>(value: T): Promise<void> {
|
|
||||||
try {
|
|
||||||
const store = await this.getStoreWrite('readwrite')
|
|
||||||
return new Promise<void>((resolve, reject) => {
|
|
||||||
const request = store.put(value)
|
|
||||||
request.onsuccess = (): void => {
|
|
||||||
resolve()
|
|
||||||
}
|
|
||||||
request.onerror = (): void => {
|
|
||||||
reject(
|
|
||||||
new IndexedDBError(
|
|
||||||
`Failed to put value: ${request.error}`,
|
|
||||||
'put',
|
|
||||||
this.config.storeName,
|
|
||||||
request.error
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof IndexedDBError) {
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
throw new IndexedDBError(
|
|
||||||
error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
'put',
|
|
||||||
this.config.storeName,
|
|
||||||
error
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add a value to the store (fails if key exists)
|
|
||||||
*/
|
|
||||||
async add<T = unknown>(value: T): Promise<void> {
|
|
||||||
try {
|
|
||||||
const store = await this.getStoreWrite('readwrite')
|
|
||||||
return new Promise<void>((resolve, reject) => {
|
|
||||||
const request = store.add(value)
|
|
||||||
request.onsuccess = (): void => {
|
|
||||||
resolve()
|
|
||||||
}
|
|
||||||
request.onerror = (): void => {
|
|
||||||
reject(
|
|
||||||
new IndexedDBError(
|
|
||||||
`Failed to add value: ${request.error}`,
|
|
||||||
'add',
|
|
||||||
this.config.storeName,
|
|
||||||
request.error
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof IndexedDBError) {
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
throw new IndexedDBError(
|
|
||||||
error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
'add',
|
|
||||||
this.config.storeName,
|
|
||||||
error
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a value from the store by key
|
|
||||||
*/
|
|
||||||
async delete(key: string | number): Promise<void> {
|
|
||||||
try {
|
|
||||||
const store = await this.getStoreWrite('readwrite')
|
|
||||||
return new Promise<void>((resolve, reject) => {
|
|
||||||
const request = store.delete(key)
|
|
||||||
request.onsuccess = (): void => {
|
|
||||||
resolve()
|
|
||||||
}
|
|
||||||
request.onerror = (): void => {
|
|
||||||
reject(
|
|
||||||
new IndexedDBError(
|
|
||||||
`Failed to delete value: ${request.error}`,
|
|
||||||
'delete',
|
|
||||||
this.config.storeName,
|
|
||||||
request.error
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof IndexedDBError) {
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
throw new IndexedDBError(
|
|
||||||
error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
'delete',
|
|
||||||
this.config.storeName,
|
|
||||||
error
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear all values from the store
|
|
||||||
*/
|
|
||||||
async clear(): Promise<void> {
|
|
||||||
try {
|
|
||||||
const store = await this.getStoreWrite('readwrite')
|
|
||||||
return new Promise<void>((resolve, reject) => {
|
|
||||||
const request = store.clear()
|
|
||||||
request.onsuccess = (): void => {
|
|
||||||
resolve()
|
|
||||||
}
|
|
||||||
request.onerror = (): void => {
|
|
||||||
reject(
|
|
||||||
new IndexedDBError(
|
|
||||||
`Failed to clear store: ${request.error}`,
|
|
||||||
'clear',
|
|
||||||
this.config.storeName,
|
|
||||||
request.error
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof IndexedDBError) {
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
throw new IndexedDBError(
|
|
||||||
error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
'clear',
|
|
||||||
this.config.storeName,
|
|
||||||
error
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Open a cursor on the store
|
|
||||||
*/
|
|
||||||
async openCursor(
|
|
||||||
direction?: IDBCursorDirection,
|
|
||||||
range?: IDBKeyRange
|
|
||||||
): Promise<IDBCursorWithValue | null> {
|
|
||||||
try {
|
|
||||||
const store = await this.getStore('readonly')
|
|
||||||
return new Promise<IDBCursorWithValue | null>((resolve, reject) => {
|
|
||||||
const request = range ? store.openCursor(range, direction) : store.openCursor(direction)
|
|
||||||
request.onsuccess = (): void => {
|
|
||||||
resolve(request.result)
|
|
||||||
}
|
|
||||||
request.onerror = (): void => {
|
|
||||||
reject(
|
|
||||||
new IndexedDBError(
|
|
||||||
`Failed to open cursor: ${request.error}`,
|
|
||||||
'openCursor',
|
|
||||||
this.config.storeName,
|
|
||||||
request.error
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof IndexedDBError) {
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
throw new IndexedDBError(
|
|
||||||
error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
'openCursor',
|
|
||||||
this.config.storeName,
|
|
||||||
error
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Open a cursor on an index
|
|
||||||
*/
|
|
||||||
async openCursorOnIndex(
|
|
||||||
indexName: string,
|
|
||||||
direction?: IDBCursorDirection,
|
|
||||||
range?: IDBKeyRange
|
|
||||||
): Promise<IDBCursorWithValue | null> {
|
|
||||||
try {
|
|
||||||
const store = await this.getStore('readonly')
|
|
||||||
const index = store.index(indexName)
|
|
||||||
return new Promise<IDBCursorWithValue | null>((resolve, reject) => {
|
|
||||||
const request = range ? index.openCursor(range, direction) : index.openCursor(direction)
|
|
||||||
request.onsuccess = (): void => {
|
|
||||||
resolve(request.result)
|
|
||||||
}
|
|
||||||
request.onerror = (): void => {
|
|
||||||
reject(
|
|
||||||
new IndexedDBError(
|
|
||||||
`Failed to open cursor on index: ${request.error}`,
|
|
||||||
'openCursorOnIndex',
|
|
||||||
this.config.storeName,
|
|
||||||
request.error
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof IndexedDBError) {
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
throw new IndexedDBError(
|
|
||||||
error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
'openCursorOnIndex',
|
|
||||||
this.config.storeName,
|
|
||||||
error
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Count records in the store
|
|
||||||
*/
|
|
||||||
async count(range?: IDBKeyRange): Promise<number> {
|
|
||||||
try {
|
|
||||||
const store = await this.getStore('readonly')
|
|
||||||
return new Promise<number>((resolve, reject) => {
|
|
||||||
const request = range ? store.count(range) : store.count()
|
|
||||||
request.onsuccess = (): void => {
|
|
||||||
resolve(request.result)
|
|
||||||
}
|
|
||||||
request.onerror = (): void => {
|
|
||||||
reject(
|
|
||||||
new IndexedDBError(
|
|
||||||
`Failed to count records: ${request.error}`,
|
|
||||||
'count',
|
|
||||||
this.config.storeName,
|
|
||||||
request.error
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof IndexedDBError) {
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
throw new IndexedDBError(
|
|
||||||
error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
'count',
|
|
||||||
this.config.storeName,
|
|
||||||
error
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Count records in an index
|
|
||||||
*/
|
|
||||||
async countByIndex(indexName: string, range?: IDBKeyRange): Promise<number> {
|
|
||||||
try {
|
|
||||||
const store = await this.getStore('readonly')
|
|
||||||
const index = store.index(indexName)
|
|
||||||
return new Promise<number>((resolve, reject) => {
|
|
||||||
const request = range ? index.count(range) : index.count()
|
|
||||||
request.onsuccess = (): void => {
|
|
||||||
resolve(request.result)
|
|
||||||
}
|
|
||||||
request.onerror = (): void => {
|
|
||||||
reject(
|
|
||||||
new IndexedDBError(
|
|
||||||
`Failed to count records by index: ${request.error}`,
|
|
||||||
'countByIndex',
|
|
||||||
this.config.storeName,
|
|
||||||
request.error
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof IndexedDBError) {
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
throw new IndexedDBError(
|
|
||||||
error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
'countByIndex',
|
|
||||||
this.config.storeName,
|
|
||||||
error
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all values from the store
|
|
||||||
*/
|
|
||||||
async getAll<T = unknown>(range?: IDBKeyRange, count?: number): Promise<T[]> {
|
|
||||||
try {
|
|
||||||
const store = await this.getStore('readonly')
|
|
||||||
return new Promise<T[]>((resolve, reject) => {
|
|
||||||
const request = range ? store.getAll(range, count) : store.getAll(undefined, count)
|
|
||||||
request.onsuccess = (): void => {
|
|
||||||
resolve((request.result as T[]) ?? [])
|
|
||||||
}
|
|
||||||
request.onerror = (): void => {
|
|
||||||
reject(
|
|
||||||
new IndexedDBError(
|
|
||||||
`Failed to get all values: ${request.error}`,
|
|
||||||
'getAll',
|
|
||||||
this.config.storeName,
|
|
||||||
request.error
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof IndexedDBError) {
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
throw new IndexedDBError(
|
|
||||||
error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
'getAll',
|
|
||||||
this.config.storeName,
|
|
||||||
error
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new IndexedDB helper instance
|
|
||||||
*/
|
|
||||||
export function createIndexedDBHelper(config: IndexedDBConfig): IndexedDBHelper {
|
|
||||||
return new IndexedDBHelper(config)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { IndexedDBHelper }
|
|
||||||
|
|||||||
15
lib/helpers/indexedDBHelper/IndexedDBError.ts
Normal file
15
lib/helpers/indexedDBHelper/IndexedDBError.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
export class IndexedDBError extends Error {
|
||||||
|
public readonly operation: string
|
||||||
|
public readonly storeName: string | undefined
|
||||||
|
public override readonly cause: unknown
|
||||||
|
|
||||||
|
public override readonly name = 'IndexedDBError'
|
||||||
|
|
||||||
|
constructor(message: string, operation: string, storeName?: string, cause?: unknown) {
|
||||||
|
super(message)
|
||||||
|
this.operation = operation
|
||||||
|
this.storeName = storeName
|
||||||
|
this.cause = cause
|
||||||
|
console.error(`[IndexedDBError] ${operation}${storeName ? ` on ${storeName}` : ''}: ${message}`, cause)
|
||||||
|
}
|
||||||
|
}
|
||||||
97
lib/helpers/indexedDBHelper/IndexedDBHelper.ts
Normal file
97
lib/helpers/indexedDBHelper/IndexedDBHelper.ts
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import type { IndexedDBConfig } from './types'
|
||||||
|
import { IndexedDBError } from './IndexedDBError'
|
||||||
|
import { initDatabase, type CoreAccessors } from './core'
|
||||||
|
import { getObjectStore } from './store'
|
||||||
|
import { getAll, getAllByIndex, getByIndex, getValue } from './read'
|
||||||
|
import { addValue, clearStore, deleteValue, putValue } from './write'
|
||||||
|
import { count, countByIndex, openCursor, openCursorOnIndex } from './cursor'
|
||||||
|
|
||||||
|
export class IndexedDBHelper {
|
||||||
|
private db: IDBDatabase | null = null
|
||||||
|
private initPromise: Promise<void> | null = null
|
||||||
|
private readonly config: IndexedDBConfig
|
||||||
|
|
||||||
|
constructor(config: IndexedDBConfig) {
|
||||||
|
this.config = config
|
||||||
|
}
|
||||||
|
|
||||||
|
private accessors(): CoreAccessors {
|
||||||
|
return {
|
||||||
|
getDb: () => this.db,
|
||||||
|
setDb: (db) => {
|
||||||
|
this.db = db
|
||||||
|
},
|
||||||
|
getInitPromise: () => this.initPromise,
|
||||||
|
setInitPromise: (promise) => {
|
||||||
|
this.initPromise = promise
|
||||||
|
},
|
||||||
|
getConfig: () => this.config,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async init(): Promise<IDBDatabase> {
|
||||||
|
return initDatabase(this.accessors())
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStore(mode: 'readonly'): Promise<IDBObjectStore> {
|
||||||
|
return getObjectStore(this.accessors(), mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStoreWrite(mode: 'readwrite'): Promise<IDBObjectStore> {
|
||||||
|
return getObjectStore(this.accessors(), mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
async get<T = unknown>(key: string | number): Promise<T | null> {
|
||||||
|
return getValue<T>(this.accessors(), key)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getByIndex<T = unknown>(indexName: string, key: string | number): Promise<T | null> {
|
||||||
|
return getByIndex<T>(this.accessors(), indexName, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllByIndex<T = unknown>(indexName: string, key?: IDBValidKey | IDBKeyRange): Promise<T[]> {
|
||||||
|
return getAllByIndex<T>(this.accessors(), indexName, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
async put<T = unknown>(value: T): Promise<void> {
|
||||||
|
return putValue<T>(this.accessors(), value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async add<T = unknown>(value: T): Promise<void> {
|
||||||
|
return addValue<T>(this.accessors(), value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(key: string | number): Promise<void> {
|
||||||
|
return deleteValue(this.accessors(), key)
|
||||||
|
}
|
||||||
|
|
||||||
|
async clear(): Promise<void> {
|
||||||
|
return clearStore(this.accessors())
|
||||||
|
}
|
||||||
|
|
||||||
|
async openCursor(direction?: IDBCursorDirection, range?: IDBKeyRange): Promise<IDBCursorWithValue | null> {
|
||||||
|
return openCursor(this.accessors(), direction, range)
|
||||||
|
}
|
||||||
|
|
||||||
|
async openCursorOnIndex(indexName: string, direction?: IDBCursorDirection, range?: IDBKeyRange): Promise<IDBCursorWithValue | null> {
|
||||||
|
return openCursorOnIndex(this.accessors(), indexName, direction, range)
|
||||||
|
}
|
||||||
|
|
||||||
|
async count(range?: IDBKeyRange): Promise<number> {
|
||||||
|
return count(this.accessors(), range)
|
||||||
|
}
|
||||||
|
|
||||||
|
async countByIndex(indexName: string, range?: IDBKeyRange): Promise<number> {
|
||||||
|
return countByIndex(this.accessors(), indexName, range)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAll<T = unknown>(range?: IDBKeyRange, countParam?: number): Promise<T[]> {
|
||||||
|
return getAll<T>(this.accessors(), range, countParam)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertIsIndexedDBError(error: unknown): asserts error is IndexedDBError {
|
||||||
|
if (!(error instanceof IndexedDBError)) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
97
lib/helpers/indexedDBHelper/core.ts
Normal file
97
lib/helpers/indexedDBHelper/core.ts
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import type { IndexedDBConfig } from './types'
|
||||||
|
import { IndexedDBError } from './IndexedDBError'
|
||||||
|
|
||||||
|
export type CoreAccessors = {
|
||||||
|
getDb: () => IDBDatabase | null
|
||||||
|
setDb: (db: IDBDatabase | null) => void
|
||||||
|
getInitPromise: () => Promise<void> | null
|
||||||
|
setInitPromise: (promise: Promise<void> | null) => void
|
||||||
|
getConfig: () => IndexedDBConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initDatabase(accessors: CoreAccessors): Promise<IDBDatabase> {
|
||||||
|
const existing = accessors.getDb()
|
||||||
|
if (existing) {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
const pending = accessors.getInitPromise()
|
||||||
|
if (pending) {
|
||||||
|
await pending
|
||||||
|
const db = accessors.getDb()
|
||||||
|
if (db) {
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
throw new IndexedDBError('Database initialization failed', 'init', accessors.getConfig().storeName)
|
||||||
|
}
|
||||||
|
|
||||||
|
const initPromise = openDatabase(accessors)
|
||||||
|
accessors.setInitPromise(initPromise)
|
||||||
|
try {
|
||||||
|
await initPromise
|
||||||
|
const db = accessors.getDb()
|
||||||
|
if (!db) {
|
||||||
|
throw new IndexedDBError('Database not initialized after open', 'init', accessors.getConfig().storeName)
|
||||||
|
}
|
||||||
|
return db
|
||||||
|
} catch (error) {
|
||||||
|
accessors.setInitPromise(null)
|
||||||
|
throw new IndexedDBError(error instanceof Error ? error.message : 'Unknown error', 'init', accessors.getConfig().storeName, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openDatabase(accessors: CoreAccessors): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const config = accessors.getConfig()
|
||||||
|
if (typeof window === 'undefined' || !window.indexedDB) {
|
||||||
|
reject(new IndexedDBError('IndexedDB is not available', 'openDatabase', config.storeName))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const request = window.indexedDB.open(config.dbName, config.version)
|
||||||
|
request.onerror = (): void => {
|
||||||
|
reject(new IndexedDBError(`Failed to open IndexedDB: ${request.error}`, 'openDatabase', config.storeName, request.error))
|
||||||
|
}
|
||||||
|
request.onsuccess = (): void => {
|
||||||
|
accessors.setDb(request.result)
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
request.onupgradeneeded = (event: IDBVersionChangeEvent): void => {
|
||||||
|
handleUpgrade({ config, event })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUpgrade(params: { config: IndexedDBConfig; event: IDBVersionChangeEvent }): void {
|
||||||
|
const db = (params.event.target as IDBOpenDBRequest).result
|
||||||
|
if (!db.objectStoreNames.contains(params.config.storeName)) {
|
||||||
|
createObjectStore(db, params.config)
|
||||||
|
} else {
|
||||||
|
createMissingIndexes(db, params.event, params.config)
|
||||||
|
}
|
||||||
|
params.config.onUpgrade?.(db, params.event)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createObjectStore(db: IDBDatabase, config: IndexedDBConfig): void {
|
||||||
|
const store = db.createObjectStore(config.storeName, { keyPath: config.keyPath })
|
||||||
|
if (!config.indexes) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for (const index of config.indexes) {
|
||||||
|
if (!store.indexNames.contains(index.name)) {
|
||||||
|
store.createIndex(index.name, index.keyPath, { unique: index.unique ?? false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMissingIndexes(_db: IDBDatabase, event: IDBVersionChangeEvent, config: IndexedDBConfig): void {
|
||||||
|
const target = event.target as IDBOpenDBRequest
|
||||||
|
const { transaction } = target
|
||||||
|
if (!transaction || !config.indexes) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const store = transaction.objectStore(config.storeName)
|
||||||
|
for (const index of config.indexes) {
|
||||||
|
if (!store.indexNames.contains(index.name)) {
|
||||||
|
store.createIndex(index.name, index.keyPath, { unique: index.unique ?? false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
77
lib/helpers/indexedDBHelper/cursor.ts
Normal file
77
lib/helpers/indexedDBHelper/cursor.ts
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import { IndexedDBError } from './IndexedDBError'
|
||||||
|
import type { CoreAccessors } from './core'
|
||||||
|
import { getObjectStore } from './store'
|
||||||
|
|
||||||
|
export async function openCursor(accessors: CoreAccessors, direction?: IDBCursorDirection, range?: IDBKeyRange): Promise<IDBCursorWithValue | null> {
|
||||||
|
const {storeName} = accessors.getConfig()
|
||||||
|
try {
|
||||||
|
const store = await getObjectStore(accessors, 'readonly')
|
||||||
|
const request = range ? store.openCursor(range, direction) : store.openCursor(direction)
|
||||||
|
return await wrapCursorRequest(request, 'openCursor', storeName)
|
||||||
|
} catch (error) {
|
||||||
|
throw ensureIndexedDBError(error, 'openCursor', storeName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openCursorOnIndex(
|
||||||
|
accessors: CoreAccessors,
|
||||||
|
indexName: string,
|
||||||
|
direction?: IDBCursorDirection,
|
||||||
|
range?: IDBKeyRange
|
||||||
|
): Promise<IDBCursorWithValue | null> {
|
||||||
|
const {storeName} = accessors.getConfig()
|
||||||
|
try {
|
||||||
|
const store = await getObjectStore(accessors, 'readonly')
|
||||||
|
const index = store.index(indexName)
|
||||||
|
const request = range ? index.openCursor(range, direction) : index.openCursor(direction)
|
||||||
|
return await wrapCursorRequest(request, 'openCursorOnIndex', storeName)
|
||||||
|
} catch (error) {
|
||||||
|
throw ensureIndexedDBError(error, 'openCursorOnIndex', storeName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function count(accessors: CoreAccessors, range?: IDBKeyRange): Promise<number> {
|
||||||
|
const {storeName} = accessors.getConfig()
|
||||||
|
try {
|
||||||
|
const store = await getObjectStore(accessors, 'readonly')
|
||||||
|
const request = range ? store.count(range) : store.count()
|
||||||
|
return await wrapNumberRequest(request, 'count', storeName)
|
||||||
|
} catch (error) {
|
||||||
|
throw ensureIndexedDBError(error, 'count', storeName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function countByIndex(accessors: CoreAccessors, indexName: string, range?: IDBKeyRange): Promise<number> {
|
||||||
|
const {storeName} = accessors.getConfig()
|
||||||
|
try {
|
||||||
|
const store = await getObjectStore(accessors, 'readonly')
|
||||||
|
const index = store.index(indexName)
|
||||||
|
const request = range ? index.count(range) : index.count()
|
||||||
|
return await wrapNumberRequest(request, 'countByIndex', storeName)
|
||||||
|
} catch (error) {
|
||||||
|
throw ensureIndexedDBError(error, 'countByIndex', storeName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapCursorRequest(requestParam: IDBRequest, operation: string, storeName: string): Promise<IDBCursorWithValue | null> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = requestParam
|
||||||
|
request.onsuccess = (): void => resolve(request.result as IDBCursorWithValue | null)
|
||||||
|
request.onerror = (): void => reject(new IndexedDBError(`Failed to ${operation}: ${request.error}`, operation, storeName, request.error))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapNumberRequest(requestParam: IDBRequest, operation: string, storeName: string): Promise<number> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = requestParam
|
||||||
|
request.onsuccess = (): void => resolve(request.result as number)
|
||||||
|
request.onerror = (): void => reject(new IndexedDBError(`Failed to ${operation}: ${request.error}`, operation, storeName, request.error))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureIndexedDBError(error: unknown, operation: string, storeName: string): IndexedDBError {
|
||||||
|
if (error instanceof IndexedDBError) {
|
||||||
|
return error
|
||||||
|
}
|
||||||
|
return new IndexedDBError(error instanceof Error ? error.message : 'Unknown error', operation, storeName, error)
|
||||||
|
}
|
||||||
10
lib/helpers/indexedDBHelper/index.ts
Normal file
10
lib/helpers/indexedDBHelper/index.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
export type { IndexedDBConfig, IndexedDBIndex } from './types'
|
||||||
|
export { IndexedDBError } from './IndexedDBError'
|
||||||
|
export { IndexedDBHelper } from './IndexedDBHelper'
|
||||||
|
|
||||||
|
import type { IndexedDBConfig } from './types'
|
||||||
|
import { IndexedDBHelper } from './IndexedDBHelper'
|
||||||
|
|
||||||
|
export function createIndexedDBHelper(config: IndexedDBConfig): IndexedDBHelper {
|
||||||
|
return new IndexedDBHelper(config)
|
||||||
|
}
|
||||||
67
lib/helpers/indexedDBHelper/read.ts
Normal file
67
lib/helpers/indexedDBHelper/read.ts
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import { IndexedDBError } from './IndexedDBError'
|
||||||
|
import type { CoreAccessors } from './core'
|
||||||
|
import { getObjectStore } from './store'
|
||||||
|
|
||||||
|
export async function getValue<T>(accessors: CoreAccessors, key: string | number): Promise<T | null> {
|
||||||
|
const {storeName} = accessors.getConfig()
|
||||||
|
try {
|
||||||
|
const store = await getObjectStore(accessors, 'readonly')
|
||||||
|
return await wrapRequest<T | null>(store.get(key), 'get', storeName, (r) => (r.result as T) ?? null)
|
||||||
|
} catch (error) {
|
||||||
|
throw ensureIndexedDBError(error, 'get', storeName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getByIndex<T>(accessors: CoreAccessors, indexName: string, key: string | number): Promise<T | null> {
|
||||||
|
const {storeName} = accessors.getConfig()
|
||||||
|
try {
|
||||||
|
const store = await getObjectStore(accessors, 'readonly')
|
||||||
|
const index = store.index(indexName)
|
||||||
|
return await wrapRequest<T | null>(index.get(key), 'getByIndex', storeName, (r) => (r.result as T) ?? null)
|
||||||
|
} catch (error) {
|
||||||
|
throw ensureIndexedDBError(error, 'getByIndex', storeName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllByIndex<T>(accessors: CoreAccessors, indexName: string, key?: IDBValidKey | IDBKeyRange): Promise<T[]> {
|
||||||
|
const {storeName} = accessors.getConfig()
|
||||||
|
try {
|
||||||
|
const store = await getObjectStore(accessors, 'readonly')
|
||||||
|
const index = store.index(indexName)
|
||||||
|
const request = key !== undefined ? index.getAll(key) : index.getAll()
|
||||||
|
return await wrapRequest<T[]>(request, 'getAllByIndex', storeName, (r) => (r.result as T[]) ?? [])
|
||||||
|
} catch (error) {
|
||||||
|
throw ensureIndexedDBError(error, 'getAllByIndex', storeName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAll<T>(accessors: CoreAccessors, range?: IDBKeyRange, count?: number): Promise<T[]> {
|
||||||
|
const {storeName} = accessors.getConfig()
|
||||||
|
try {
|
||||||
|
const store = await getObjectStore(accessors, 'readonly')
|
||||||
|
const request = range ? store.getAll(range, count) : store.getAll(undefined, count)
|
||||||
|
return await wrapRequest<T[]>(request, 'getAll', storeName, (r) => (r.result as T[]) ?? [])
|
||||||
|
} catch (error) {
|
||||||
|
throw ensureIndexedDBError(error, 'getAll', storeName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapRequest<TResult>(
|
||||||
|
requestParam: IDBRequest,
|
||||||
|
operation: string,
|
||||||
|
storeName: string,
|
||||||
|
mapResult: (request: IDBRequest) => TResult
|
||||||
|
): Promise<TResult> {
|
||||||
|
return new Promise<TResult>((resolve, reject) => {
|
||||||
|
const request = requestParam
|
||||||
|
request.onsuccess = (): void => resolve(mapResult(request))
|
||||||
|
request.onerror = (): void => reject(new IndexedDBError(`Failed to ${operation}: ${request.error}`, operation, storeName, request.error))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureIndexedDBError(error: unknown, operation: string, storeName: string): IndexedDBError {
|
||||||
|
if (error instanceof IndexedDBError) {
|
||||||
|
return error
|
||||||
|
}
|
||||||
|
return new IndexedDBError(error instanceof Error ? error.message : 'Unknown error', operation, storeName, error)
|
||||||
|
}
|
||||||
14
lib/helpers/indexedDBHelper/store.ts
Normal file
14
lib/helpers/indexedDBHelper/store.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import type { IndexedDBConfig } from './types'
|
||||||
|
import { IndexedDBError } from './IndexedDBError'
|
||||||
|
import { initDatabase, type CoreAccessors } from './core'
|
||||||
|
|
||||||
|
export async function getObjectStore(accessors: CoreAccessors, mode: IDBTransactionMode): Promise<IDBObjectStore> {
|
||||||
|
const db = await initDatabase(accessors)
|
||||||
|
const config: IndexedDBConfig = accessors.getConfig()
|
||||||
|
try {
|
||||||
|
const transaction = db.transaction([config.storeName], mode)
|
||||||
|
return transaction.objectStore(config.storeName)
|
||||||
|
} catch (error) {
|
||||||
|
throw new IndexedDBError(error instanceof Error ? error.message : 'Unknown error', 'getStore', config.storeName, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
14
lib/helpers/indexedDBHelper/types.ts
Normal file
14
lib/helpers/indexedDBHelper/types.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
export interface IndexedDBIndex {
|
||||||
|
name: string
|
||||||
|
keyPath: string | string[]
|
||||||
|
unique?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IndexedDBConfig {
|
||||||
|
dbName: string
|
||||||
|
version: number
|
||||||
|
storeName: string
|
||||||
|
keyPath: string
|
||||||
|
indexes?: IndexedDBIndex[]
|
||||||
|
onUpgrade?: (db: IDBDatabase, event: IDBVersionChangeEvent) => void
|
||||||
|
}
|
||||||
58
lib/helpers/indexedDBHelper/write.ts
Normal file
58
lib/helpers/indexedDBHelper/write.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import { IndexedDBError } from './IndexedDBError'
|
||||||
|
import type { CoreAccessors } from './core'
|
||||||
|
import { getObjectStore } from './store'
|
||||||
|
|
||||||
|
export async function putValue<T>(accessors: CoreAccessors, value: T): Promise<void> {
|
||||||
|
const {storeName} = accessors.getConfig()
|
||||||
|
try {
|
||||||
|
const store = await getObjectStore(accessors, 'readwrite')
|
||||||
|
await wrapVoidRequest(store.put(value), 'put', storeName)
|
||||||
|
} catch (error) {
|
||||||
|
throw ensureIndexedDBError(error, 'put', storeName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addValue<T>(accessors: CoreAccessors, value: T): Promise<void> {
|
||||||
|
const {storeName} = accessors.getConfig()
|
||||||
|
try {
|
||||||
|
const store = await getObjectStore(accessors, 'readwrite')
|
||||||
|
await wrapVoidRequest(store.add(value), 'add', storeName)
|
||||||
|
} catch (error) {
|
||||||
|
throw ensureIndexedDBError(error, 'add', storeName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteValue(accessors: CoreAccessors, key: string | number): Promise<void> {
|
||||||
|
const {storeName} = accessors.getConfig()
|
||||||
|
try {
|
||||||
|
const store = await getObjectStore(accessors, 'readwrite')
|
||||||
|
await wrapVoidRequest(store.delete(key), 'delete', storeName)
|
||||||
|
} catch (error) {
|
||||||
|
throw ensureIndexedDBError(error, 'delete', storeName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearStore(accessors: CoreAccessors): Promise<void> {
|
||||||
|
const {storeName} = accessors.getConfig()
|
||||||
|
try {
|
||||||
|
const store = await getObjectStore(accessors, 'readwrite')
|
||||||
|
await wrapVoidRequest(store.clear(), 'clear', storeName)
|
||||||
|
} catch (error) {
|
||||||
|
throw ensureIndexedDBError(error, 'clear', storeName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapVoidRequest(requestParam: IDBRequest, operation: string, storeName: string): Promise<void> {
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
const request = requestParam
|
||||||
|
request.onsuccess = (): void => resolve()
|
||||||
|
request.onerror = (): void => reject(new IndexedDBError(`Failed to ${operation}: ${request.error}`, operation, storeName, request.error))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureIndexedDBError(error: unknown, operation: string, storeName: string): IndexedDBError {
|
||||||
|
if (error instanceof IndexedDBError) {
|
||||||
|
return error
|
||||||
|
}
|
||||||
|
return new IndexedDBError(error instanceof Error ? error.message : 'Unknown error', operation, storeName, error)
|
||||||
|
}
|
||||||
@ -3,7 +3,7 @@
|
|||||||
* Centralizes the pattern of polling syncProgressManager and updating state
|
* Centralizes the pattern of polling syncProgressManager and updating state
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
import { useState, useEffect, useRef, useCallback, type MutableRefObject } from 'react'
|
||||||
import type { SyncProgress } from '../helpers/syncProgressHelper'
|
import type { SyncProgress } from '../helpers/syncProgressHelper'
|
||||||
|
|
||||||
export interface UseSyncProgressOptions {
|
export interface UseSyncProgressOptions {
|
||||||
@ -23,85 +23,125 @@ export interface UseSyncProgressResult {
|
|||||||
* Hook to monitor sync progress from syncProgressManager
|
* Hook to monitor sync progress from syncProgressManager
|
||||||
*/
|
*/
|
||||||
export function useSyncProgress(options: UseSyncProgressOptions = {}): UseSyncProgressResult {
|
export function useSyncProgress(options: UseSyncProgressOptions = {}): UseSyncProgressResult {
|
||||||
const { onComplete, pollInterval = 500, maxDuration = 60000 } = options
|
const config = normalizeSyncProgressOptions(options)
|
||||||
|
const state = useSyncProgressState({ onComplete: config.onComplete })
|
||||||
|
const checkProgress = useCheckProgress({ onCompleteRef: state.onCompleteRef, setSyncProgress: state.setSyncProgress, setIsSyncing: state.setIsSyncing, stopMonitoring: state.stopMonitoring })
|
||||||
|
const startMonitoring = useStartMonitoring({ pollInterval: config.pollInterval, maxDuration: config.maxDuration, checkProgress, stopMonitoring: state.stopMonitoring, setIsSyncing: state.setIsSyncing, setSyncProgress: state.setSyncProgress, isMonitoringRef: state.isMonitoringRef, intervalRef: state.intervalRef, timeoutRef: state.timeoutRef })
|
||||||
|
useUnmountCleanup({ stopMonitoring: state.stopMonitoring })
|
||||||
|
return { syncProgress: state.syncProgress, isSyncing: state.isSyncing, startMonitoring, stopMonitoring: state.stopMonitoring }
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSyncProgressOptions(options: UseSyncProgressOptions): { onComplete: UseSyncProgressOptions['onComplete']; pollInterval: number; maxDuration: number } {
|
||||||
|
return { onComplete: options.onComplete, pollInterval: options.pollInterval ?? 500, maxDuration: options.maxDuration ?? 60000 }
|
||||||
|
}
|
||||||
|
|
||||||
|
function useSyncProgressState(params: { onComplete: UseSyncProgressOptions['onComplete'] }): {
|
||||||
|
syncProgress: SyncProgress | null
|
||||||
|
setSyncProgress: (value: SyncProgress | null) => void
|
||||||
|
isSyncing: boolean
|
||||||
|
setIsSyncing: (value: boolean) => void
|
||||||
|
intervalRef: MutableRefObject<ReturnType<typeof setInterval> | null>
|
||||||
|
timeoutRef: MutableRefObject<ReturnType<typeof setTimeout> | null>
|
||||||
|
isMonitoringRef: MutableRefObject<boolean>
|
||||||
|
onCompleteRef: MutableRefObject<UseSyncProgressOptions['onComplete']>
|
||||||
|
stopMonitoring: () => void
|
||||||
|
} {
|
||||||
const [syncProgress, setSyncProgress] = useState<SyncProgress | null>(null)
|
const [syncProgress, setSyncProgress] = useState<SyncProgress | null>(null)
|
||||||
const [isSyncing, setIsSyncing] = useState(false)
|
const [isSyncing, setIsSyncing] = useState(false)
|
||||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const onCompleteRef = useRef(onComplete)
|
|
||||||
const isMonitoringRef = useRef(false)
|
const isMonitoringRef = useRef(false)
|
||||||
|
const onCompleteRef = useRef(params.onComplete)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onCompleteRef.current = params.onComplete
|
||||||
|
}, [params.onComplete])
|
||||||
|
|
||||||
const stopMonitoring = useCallback((): void => {
|
const stopMonitoring = useCallback((): void => {
|
||||||
if (!isMonitoringRef.current) {
|
if (!isMonitoringRef.current) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
isMonitoringRef.current = false
|
isMonitoringRef.current = false
|
||||||
setIsSyncing(false)
|
setIsSyncing(false)
|
||||||
|
intervalRef.current = clearIntervalAndReturnNull(intervalRef.current)
|
||||||
if (intervalRef.current) {
|
timeoutRef.current = clearTimeoutAndReturnNull(timeoutRef.current)
|
||||||
clearInterval(intervalRef.current)
|
|
||||||
intervalRef.current = null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (timeoutRef.current) {
|
|
||||||
clearTimeout(timeoutRef.current)
|
|
||||||
timeoutRef.current = null
|
|
||||||
}
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Update onComplete ref when it changes
|
return { syncProgress, setSyncProgress, isSyncing, setIsSyncing, intervalRef, timeoutRef, isMonitoringRef, onCompleteRef, stopMonitoring }
|
||||||
useEffect(() => {
|
}
|
||||||
onCompleteRef.current = onComplete
|
|
||||||
}, [onComplete])
|
|
||||||
|
|
||||||
const checkProgress = useCallback(async (): Promise<void> => {
|
function clearIntervalAndReturnNull(current: ReturnType<typeof setInterval> | null): null {
|
||||||
|
if (current) {
|
||||||
|
clearInterval(current)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearTimeoutAndReturnNull(current: ReturnType<typeof setTimeout> | null): null {
|
||||||
|
if (current) {
|
||||||
|
clearTimeout(current)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function useCheckProgress(params: {
|
||||||
|
onCompleteRef: MutableRefObject<UseSyncProgressOptions['onComplete']>
|
||||||
|
setSyncProgress: (value: SyncProgress | null) => void
|
||||||
|
setIsSyncing: (value: boolean) => void
|
||||||
|
stopMonitoring: () => void
|
||||||
|
}): () => Promise<void> {
|
||||||
|
const { onCompleteRef, setSyncProgress, setIsSyncing, stopMonitoring } = params
|
||||||
|
return useCallback(async (): Promise<void> => {
|
||||||
const { syncProgressManager } = await import('../syncProgressManager')
|
const { syncProgressManager } = await import('../syncProgressManager')
|
||||||
const currentProgress = syncProgressManager.getProgress()
|
const current = syncProgressManager.getProgress()
|
||||||
if (currentProgress) {
|
if (!current) {
|
||||||
setSyncProgress(currentProgress)
|
return
|
||||||
if (currentProgress.completed) {
|
}
|
||||||
|
setSyncProgress(current)
|
||||||
|
if (!current.completed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
setIsSyncing(false)
|
setIsSyncing(false)
|
||||||
if (onCompleteRef.current) {
|
await onCompleteRef.current?.()
|
||||||
await onCompleteRef.current()
|
|
||||||
}
|
|
||||||
stopMonitoring()
|
stopMonitoring()
|
||||||
|
}, [onCompleteRef, setIsSyncing, setSyncProgress, stopMonitoring])
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}, [stopMonitoring])
|
|
||||||
|
|
||||||
const startMonitoring = useCallback((): void => {
|
function useStartMonitoring(params: {
|
||||||
|
pollInterval: number
|
||||||
|
maxDuration: number
|
||||||
|
checkProgress: () => Promise<void>
|
||||||
|
stopMonitoring: () => void
|
||||||
|
setIsSyncing: (value: boolean) => void
|
||||||
|
setSyncProgress: (value: SyncProgress | null) => void
|
||||||
|
isMonitoringRef: MutableRefObject<boolean>
|
||||||
|
intervalRef: MutableRefObject<ReturnType<typeof setInterval> | null>
|
||||||
|
timeoutRef: MutableRefObject<ReturnType<typeof setTimeout> | null>
|
||||||
|
}): () => void {
|
||||||
|
const {
|
||||||
|
pollInterval,
|
||||||
|
maxDuration,
|
||||||
|
checkProgress,
|
||||||
|
stopMonitoring,
|
||||||
|
setIsSyncing,
|
||||||
|
setSyncProgress,
|
||||||
|
isMonitoringRef,
|
||||||
|
intervalRef,
|
||||||
|
timeoutRef,
|
||||||
|
} = params
|
||||||
|
return useCallback((): void => {
|
||||||
if (isMonitoringRef.current) {
|
if (isMonitoringRef.current) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
isMonitoringRef.current = true
|
isMonitoringRef.current = true
|
||||||
setIsSyncing(true)
|
setIsSyncing(true)
|
||||||
setSyncProgress({ currentStep: 0, totalSteps: 7, completed: false })
|
setSyncProgress({ currentStep: 0, totalSteps: 7, completed: false })
|
||||||
|
intervalRef.current = setInterval(() => void checkProgress(), pollInterval)
|
||||||
// Poll progress periodically
|
timeoutRef.current = setTimeout(() => stopMonitoring(), maxDuration)
|
||||||
intervalRef.current = setInterval(() => {
|
}, [checkProgress, intervalRef, isMonitoringRef, maxDuration, pollInterval, setIsSyncing, setSyncProgress, stopMonitoring, timeoutRef])
|
||||||
void checkProgress()
|
|
||||||
}, pollInterval)
|
|
||||||
|
|
||||||
// Cleanup after max duration
|
|
||||||
timeoutRef.current = setTimeout(() => {
|
|
||||||
stopMonitoring()
|
|
||||||
}, maxDuration)
|
|
||||||
}, [checkProgress, maxDuration, pollInterval, stopMonitoring])
|
|
||||||
|
|
||||||
// Cleanup on unmount
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
stopMonitoring()
|
|
||||||
}
|
}
|
||||||
}, [stopMonitoring])
|
|
||||||
|
|
||||||
return {
|
function useUnmountCleanup(params: { stopMonitoring: () => void }): void {
|
||||||
syncProgress,
|
const { stopMonitoring } = params
|
||||||
isSyncing,
|
useEffect(() => () => stopMonitoring(), [stopMonitoring])
|
||||||
startMonitoring,
|
|
||||||
stopMonitoring,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,439 +1 @@
|
|||||||
/**
|
export * from './keyManagementTwoLevel/api'
|
||||||
* Two-level encryption system for key management
|
|
||||||
*
|
|
||||||
* Level 1: Private key encrypted with KEK (Key Encryption Key)
|
|
||||||
* Level 2: KEK encrypted with 4-word recovery phrase
|
|
||||||
*
|
|
||||||
* Flow:
|
|
||||||
* - KEK is generated randomly and stored encrypted in Credentials API
|
|
||||||
* - Private key is encrypted with KEK and stored in IndexedDB
|
|
||||||
* - Recovery phrase (4 words) is used to encrypt/decrypt KEK
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { EncryptedPayload } from './keyManagementEncryption'
|
|
||||||
import { generateRecoveryPhrase } from './keyManagementBIP39'
|
|
||||||
const PBKDF2_ITERATIONS = 100000
|
|
||||||
const PBKDF2_HASH = 'SHA-256'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a random KEK (Key Encryption Key)
|
|
||||||
*/
|
|
||||||
async function generateKEK(): Promise<CryptoKey> {
|
|
||||||
return globalThis.crypto.subtle.generateKey(
|
|
||||||
{ name: 'AES-GCM', length: 256 },
|
|
||||||
true, // extractable
|
|
||||||
['encrypt', 'decrypt']
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Derive encryption key from recovery phrase using PBKDF2
|
|
||||||
*/
|
|
||||||
async function deriveKeyFromPhrase(phrase: string[]): Promise<CryptoKey> {
|
|
||||||
const phraseString = phrase.join(' ')
|
|
||||||
const encoder = new TextEncoder()
|
|
||||||
const password = encoder.encode(phraseString)
|
|
||||||
|
|
||||||
// Generate deterministic salt from phrase
|
|
||||||
const saltBuffer = await globalThis.crypto.subtle.digest('SHA-256', password)
|
|
||||||
const saltArray = new Uint8Array(saltBuffer)
|
|
||||||
const salt = saltArray.slice(0, 32)
|
|
||||||
|
|
||||||
// Import password as key material
|
|
||||||
const keyMaterial = await globalThis.crypto.subtle.importKey(
|
|
||||||
'raw',
|
|
||||||
password,
|
|
||||||
'PBKDF2',
|
|
||||||
false,
|
|
||||||
['deriveBits', 'deriveKey']
|
|
||||||
)
|
|
||||||
|
|
||||||
// Derive key using PBKDF2
|
|
||||||
const derivedKey = await globalThis.crypto.subtle.deriveKey(
|
|
||||||
{
|
|
||||||
name: 'PBKDF2',
|
|
||||||
salt,
|
|
||||||
iterations: PBKDF2_ITERATIONS,
|
|
||||||
hash: PBKDF2_HASH,
|
|
||||||
},
|
|
||||||
keyMaterial,
|
|
||||||
{ name: 'AES-GCM', length: 256 },
|
|
||||||
false,
|
|
||||||
['encrypt', 'decrypt']
|
|
||||||
)
|
|
||||||
|
|
||||||
return derivedKey
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Export KEK to raw bytes (for storage)
|
|
||||||
*/
|
|
||||||
async function exportKEK(kek: CryptoKey): Promise<Uint8Array> {
|
|
||||||
const exported = await globalThis.crypto.subtle.exportKey('raw', kek)
|
|
||||||
return new Uint8Array(exported)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Import KEK from raw bytes
|
|
||||||
*/
|
|
||||||
async function importKEK(keyBytes: Uint8Array): Promise<CryptoKey> {
|
|
||||||
// Create a new ArrayBuffer from the Uint8Array
|
|
||||||
const buffer = new ArrayBuffer(keyBytes.length)
|
|
||||||
const view = new Uint8Array(buffer)
|
|
||||||
view.set(keyBytes)
|
|
||||||
return globalThis.crypto.subtle.importKey(
|
|
||||||
'raw',
|
|
||||||
buffer,
|
|
||||||
{ name: 'AES-GCM' },
|
|
||||||
false,
|
|
||||||
['encrypt', 'decrypt']
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Encrypt KEK with recovery phrase
|
|
||||||
*/
|
|
||||||
async function encryptKEK(kek: CryptoKey, recoveryPhrase: string[]): Promise<EncryptedPayload> {
|
|
||||||
const phraseKey = await deriveKeyFromPhrase(recoveryPhrase)
|
|
||||||
const kekBytes = await exportKEK(kek)
|
|
||||||
|
|
||||||
const encoder = new TextEncoder()
|
|
||||||
const data = encoder.encode(Array.from(kekBytes).map(b => b.toString(16).padStart(2, '0')).join(''))
|
|
||||||
const iv = globalThis.crypto.getRandomValues(new Uint8Array(12))
|
|
||||||
|
|
||||||
const encrypted = await globalThis.crypto.subtle.encrypt(
|
|
||||||
{ name: 'AES-GCM', iv },
|
|
||||||
phraseKey,
|
|
||||||
data
|
|
||||||
)
|
|
||||||
|
|
||||||
const encryptedArray = new Uint8Array(encrypted)
|
|
||||||
|
|
||||||
function toBase64(bytes: Uint8Array): string {
|
|
||||||
let binary = ''
|
|
||||||
bytes.forEach((b) => {
|
|
||||||
binary += String.fromCharCode(b)
|
|
||||||
})
|
|
||||||
return globalThis.btoa(binary)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
iv: toBase64(iv),
|
|
||||||
ciphertext: toBase64(encryptedArray),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decrypt KEK with recovery phrase
|
|
||||||
*/
|
|
||||||
async function decryptKEK(encryptedKEK: EncryptedPayload, recoveryPhrase: string[]): Promise<CryptoKey> {
|
|
||||||
const phraseKey = await deriveKeyFromPhrase(recoveryPhrase)
|
|
||||||
|
|
||||||
function fromBase64(value: string): Uint8Array {
|
|
||||||
const binary = globalThis.atob(value)
|
|
||||||
const bytes = new Uint8Array(binary.length)
|
|
||||||
for (let i = 0; i < binary.length; i += 1) {
|
|
||||||
bytes[i] = binary.charCodeAt(i)
|
|
||||||
}
|
|
||||||
return bytes
|
|
||||||
}
|
|
||||||
|
|
||||||
const iv = fromBase64(encryptedKEK.iv)
|
|
||||||
const ciphertext = fromBase64(encryptedKEK.ciphertext)
|
|
||||||
|
|
||||||
// Create ArrayBuffer views for decrypt
|
|
||||||
const ivBuffer = new ArrayBuffer(iv.length)
|
|
||||||
const ivView = new Uint8Array(ivBuffer)
|
|
||||||
ivView.set(iv)
|
|
||||||
|
|
||||||
const cipherBuffer = new ArrayBuffer(ciphertext.length)
|
|
||||||
const cipherView = new Uint8Array(cipherBuffer)
|
|
||||||
cipherView.set(ciphertext)
|
|
||||||
|
|
||||||
const decrypted = await globalThis.crypto.subtle.decrypt(
|
|
||||||
{ name: 'AES-GCM', iv: ivView },
|
|
||||||
phraseKey,
|
|
||||||
cipherBuffer
|
|
||||||
)
|
|
||||||
|
|
||||||
const decoder = new TextDecoder()
|
|
||||||
const hexString = decoder.decode(decrypted)
|
|
||||||
|
|
||||||
// Convert hex string back to bytes
|
|
||||||
const kekBytes = new Uint8Array(hexString.match(/.{1,2}/g)?.map(byte => parseInt(byte, 16)) ?? [])
|
|
||||||
|
|
||||||
return importKEK(kekBytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Encrypt private key with KEK
|
|
||||||
*/
|
|
||||||
async function encryptPrivateKeyWithKEK(privateKey: string, kek: CryptoKey): Promise<EncryptedPayload> {
|
|
||||||
const encoder = new TextEncoder()
|
|
||||||
const data = encoder.encode(privateKey)
|
|
||||||
const iv = globalThis.crypto.getRandomValues(new Uint8Array(12))
|
|
||||||
|
|
||||||
const encrypted = await globalThis.crypto.subtle.encrypt(
|
|
||||||
{ name: 'AES-GCM', iv },
|
|
||||||
kek,
|
|
||||||
data
|
|
||||||
)
|
|
||||||
|
|
||||||
const encryptedArray = new Uint8Array(encrypted)
|
|
||||||
|
|
||||||
function toBase64(bytes: Uint8Array): string {
|
|
||||||
let binary = ''
|
|
||||||
bytes.forEach((b) => {
|
|
||||||
binary += String.fromCharCode(b)
|
|
||||||
})
|
|
||||||
return globalThis.btoa(binary)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
iv: toBase64(iv),
|
|
||||||
ciphertext: toBase64(encryptedArray),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decrypt private key with KEK
|
|
||||||
*/
|
|
||||||
async function decryptPrivateKeyWithKEK(encryptedPrivateKey: EncryptedPayload, kek: CryptoKey): Promise<string> {
|
|
||||||
function fromBase64(value: string): Uint8Array {
|
|
||||||
const binary = globalThis.atob(value)
|
|
||||||
const bytes = new Uint8Array(binary.length)
|
|
||||||
for (let i = 0; i < binary.length; i += 1) {
|
|
||||||
bytes[i] = binary.charCodeAt(i)
|
|
||||||
}
|
|
||||||
return bytes
|
|
||||||
}
|
|
||||||
|
|
||||||
const iv = fromBase64(encryptedPrivateKey.iv)
|
|
||||||
const ciphertext = fromBase64(encryptedPrivateKey.ciphertext)
|
|
||||||
|
|
||||||
// Create ArrayBuffer views for decrypt
|
|
||||||
const ivBuffer = new ArrayBuffer(iv.length)
|
|
||||||
const ivView = new Uint8Array(ivBuffer)
|
|
||||||
ivView.set(iv)
|
|
||||||
|
|
||||||
const cipherBuffer = new ArrayBuffer(ciphertext.length)
|
|
||||||
const cipherView = new Uint8Array(cipherBuffer)
|
|
||||||
cipherView.set(ciphertext)
|
|
||||||
|
|
||||||
const decrypted = await globalThis.crypto.subtle.decrypt(
|
|
||||||
{ name: 'AES-GCM', iv: ivView },
|
|
||||||
kek,
|
|
||||||
cipherBuffer
|
|
||||||
)
|
|
||||||
|
|
||||||
const decoder = new TextDecoder()
|
|
||||||
return decoder.decode(decrypted)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Store encrypted KEK in Credentials API
|
|
||||||
*/
|
|
||||||
async function storeEncryptedKEK(encryptedKEK: EncryptedPayload): Promise<void> {
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
throw new Error('Window not available')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Type definition for PasswordCredential
|
|
||||||
interface PasswordCredentialData {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
password: string
|
|
||||||
iconURL?: string
|
|
||||||
}
|
|
||||||
type PasswordCredentialConstructorType = new (data: PasswordCredentialData) => Credential & { id: string; password: string }
|
|
||||||
const PasswordCredentialConstructor = (window as unknown as { PasswordCredential?: PasswordCredentialConstructorType }).PasswordCredential
|
|
||||||
if (!PasswordCredentialConstructor || !navigator.credentials?.store) {
|
|
||||||
throw new Error('PasswordCredential API not available')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store encrypted KEK as password in credential
|
|
||||||
// Type assertion for PasswordCredential
|
|
||||||
type PasswordCredentialType = new (data: PasswordCredentialData) => Credential & { id: string; password: string }
|
|
||||||
const PasswordCredentialClass = PasswordCredentialConstructor as PasswordCredentialType
|
|
||||||
const credential = new PasswordCredentialClass({
|
|
||||||
id: 'nostr_kek',
|
|
||||||
name: 'Nostr KEK',
|
|
||||||
password: JSON.stringify(encryptedKEK),
|
|
||||||
iconURL: `${window.location.origin }/favicon.ico`,
|
|
||||||
})
|
|
||||||
|
|
||||||
await navigator.credentials.store(credential)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieve encrypted KEK from Credentials API
|
|
||||||
*/
|
|
||||||
async function getEncryptedKEK(): Promise<EncryptedPayload | null> {
|
|
||||||
if (typeof window === 'undefined' || !navigator.credentials?.get) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const credential = await navigator.credentials.get({
|
|
||||||
password: true,
|
|
||||||
} as CredentialRequestOptions)
|
|
||||||
|
|
||||||
if (credential && 'password' in credential && typeof credential.password === 'string' && credential.id === 'nostr_kek') {
|
|
||||||
return JSON.parse(credential.password) as EncryptedPayload
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error retrieving encrypted KEK:', e)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateAccountResult {
|
|
||||||
recoveryPhrase: string[]
|
|
||||||
npub: string
|
|
||||||
publicKey: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UnlockAccountResult {
|
|
||||||
privateKey: string
|
|
||||||
publicKey: string
|
|
||||||
npub: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create account with two-level encryption
|
|
||||||
*/
|
|
||||||
export async function createAccountTwoLevel(
|
|
||||||
privateKeyHex: string,
|
|
||||||
getPublicKey: (secretKey: Uint8Array) => string,
|
|
||||||
encodeNpub: (publicKey: string) => string
|
|
||||||
): Promise<CreateAccountResult> {
|
|
||||||
// Step 1: Generate recovery phrase (4 words from BIP39)
|
|
||||||
const recoveryPhrase = generateRecoveryPhrase()
|
|
||||||
|
|
||||||
// Step 2: Generate KEK (in memory)
|
|
||||||
const kek = await generateKEK()
|
|
||||||
|
|
||||||
// Step 3: Encrypt private key with KEK
|
|
||||||
const encryptedPrivateKey = await encryptPrivateKeyWithKEK(privateKeyHex, kek)
|
|
||||||
|
|
||||||
// Step 4: Encrypt KEK with recovery phrase
|
|
||||||
const encryptedKEK = await encryptKEK(kek, recoveryPhrase)
|
|
||||||
|
|
||||||
// Step 5: Store encrypted KEK in Credentials API
|
|
||||||
await storeEncryptedKEK(encryptedKEK)
|
|
||||||
|
|
||||||
// Step 6: Store encrypted private key in IndexedDB (via storage service)
|
|
||||||
const { storageService } = await import('./storage/indexedDB')
|
|
||||||
await storageService.set('nostr_encrypted_key', encryptedPrivateKey, 'nostr_key_storage')
|
|
||||||
|
|
||||||
// Step 7: Compute public key and npub
|
|
||||||
const { hexToBytes } = await import('nostr-tools/utils')
|
|
||||||
const secretKey = hexToBytes(privateKeyHex)
|
|
||||||
const publicKey = getPublicKey(secretKey)
|
|
||||||
const npub = encodeNpub(publicKey)
|
|
||||||
|
|
||||||
// Step 8: Store public keys in IndexedDB
|
|
||||||
await storageService.set('nostr_public_key', { publicKey, npub }, 'nostr_key_storage')
|
|
||||||
|
|
||||||
// Step 9: Store account flag in IndexedDB (not localStorage)
|
|
||||||
await storageService.set('nostr_account_exists', true, 'nostr_key_storage')
|
|
||||||
|
|
||||||
// Step 10: Clear KEK from memory (it's now encrypted and stored)
|
|
||||||
// Note: In JavaScript, we can't force garbage collection, but we can null the reference
|
|
||||||
// The KEK will be garbage collected automatically
|
|
||||||
|
|
||||||
return {
|
|
||||||
recoveryPhrase,
|
|
||||||
npub,
|
|
||||||
publicKey,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unlock account with two-level decryption
|
|
||||||
*/
|
|
||||||
export async function unlockAccountTwoLevel(
|
|
||||||
recoveryPhrase: string[],
|
|
||||||
getPublicKey: (secretKey: Uint8Array) => string,
|
|
||||||
encodeNpub: (publicKey: string) => string
|
|
||||||
): Promise<UnlockAccountResult> {
|
|
||||||
// Step 1: Get encrypted KEK from Credentials API
|
|
||||||
const encryptedKEK = await getEncryptedKEK()
|
|
||||||
if (!encryptedKEK) {
|
|
||||||
throw new Error('No encrypted KEK found in Credentials API')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: Decrypt KEK with recovery phrase (in memory)
|
|
||||||
const kek = await decryptKEK(encryptedKEK, recoveryPhrase)
|
|
||||||
|
|
||||||
// Step 3: Clear recovery phrase from memory (set to empty)
|
|
||||||
// Note: In JavaScript, we can't force memory clearing, but we can overwrite
|
|
||||||
recoveryPhrase.fill('')
|
|
||||||
|
|
||||||
// Step 4: Get encrypted private key from IndexedDB
|
|
||||||
const { storageService: indexedDBStorage } = await import('./storage/indexedDB')
|
|
||||||
const encryptedPrivateKey = await indexedDBStorage.get<EncryptedPayload>('nostr_encrypted_key', 'nostr_key_storage')
|
|
||||||
if (!encryptedPrivateKey) {
|
|
||||||
throw new Error('No encrypted private key found in IndexedDB')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 5: Decrypt private key with KEK (in memory)
|
|
||||||
const privateKeyHex = await decryptPrivateKeyWithKEK(encryptedPrivateKey, kek)
|
|
||||||
|
|
||||||
// Step 6: Clear KEK from memory
|
|
||||||
// Note: In JavaScript, we can't force memory clearing, but we can null the reference
|
|
||||||
|
|
||||||
// Step 7: Verify by computing public key
|
|
||||||
const { hexToBytes } = await import('nostr-tools/utils')
|
|
||||||
const secretKey = hexToBytes(privateKeyHex)
|
|
||||||
const publicKey = getPublicKey(secretKey)
|
|
||||||
const npub = encodeNpub(publicKey)
|
|
||||||
|
|
||||||
return {
|
|
||||||
privateKey: privateKeyHex,
|
|
||||||
publicKey,
|
|
||||||
npub,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if account exists
|
|
||||||
*/
|
|
||||||
export async function accountExistsTwoLevel(): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const { storageService: indexedDBStorage } = await import('./storage/indexedDB')
|
|
||||||
const exists = await indexedDBStorage.get<boolean>('nostr_account_exists', 'nostr_key_storage')
|
|
||||||
return exists === true
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get public keys if account exists
|
|
||||||
*/
|
|
||||||
export async function getPublicKeysTwoLevel(): Promise<{ publicKey: string; npub: string } | null> {
|
|
||||||
try {
|
|
||||||
const { storageService: indexedDBStorage } = await import('./storage/indexedDB')
|
|
||||||
return indexedDBStorage.get<{ publicKey: string; npub: string }>('nostr_public_key', 'nostr_key_storage')
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete account (remove all stored data)
|
|
||||||
*/
|
|
||||||
export async function deleteAccountTwoLevel(): Promise<void> {
|
|
||||||
const { storageService: indexedDBStorage } = await import('./storage/indexedDB')
|
|
||||||
await indexedDBStorage.delete('nostr_encrypted_key')
|
|
||||||
await indexedDBStorage.delete('nostr_public_key')
|
|
||||||
await indexedDBStorage.delete('nostr_account_exists')
|
|
||||||
|
|
||||||
// Try to remove credential (may not be possible via API)
|
|
||||||
if (navigator.credentials?.preventSilentAccess) {
|
|
||||||
void navigator.credentials.preventSilentAccess()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
94
lib/keyManagementTwoLevel/api.ts
Normal file
94
lib/keyManagementTwoLevel/api.ts
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
import { generateRecoveryPhrase } from '../keyManagementBIP39'
|
||||||
|
import type { EncryptedPayload } from '../keyManagementEncryption'
|
||||||
|
import { generateKEK } from './crypto'
|
||||||
|
import { decryptKEK, encryptKEK } from './kekEncryption'
|
||||||
|
import { storeEncryptedKEK, getEncryptedKEK } from './credentials'
|
||||||
|
import { decryptPrivateKeyWithKEK, encryptPrivateKeyWithKEK } from './privateKeyEncryption'
|
||||||
|
|
||||||
|
export interface CreateAccountResult {
|
||||||
|
recoveryPhrase: string[]
|
||||||
|
npub: string
|
||||||
|
publicKey: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnlockAccountResult {
|
||||||
|
privateKey: string
|
||||||
|
publicKey: string
|
||||||
|
npub: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAccountTwoLevel(
|
||||||
|
privateKeyHex: string,
|
||||||
|
getPublicKey: (secretKey: Uint8Array) => string,
|
||||||
|
encodeNpub: (publicKey: string) => string
|
||||||
|
): Promise<CreateAccountResult> {
|
||||||
|
const recoveryPhrase = generateRecoveryPhrase()
|
||||||
|
const kek = await generateKEK()
|
||||||
|
const encryptedPrivateKey = await encryptPrivateKeyWithKEK(privateKeyHex, kek)
|
||||||
|
const encryptedKEK = await encryptKEK(kek, recoveryPhrase)
|
||||||
|
await storeEncryptedKEK(encryptedKEK)
|
||||||
|
const { storageService } = await import('../storage/indexedDB')
|
||||||
|
await storageService.set('nostr_encrypted_key', encryptedPrivateKey, 'nostr_key_storage')
|
||||||
|
const { hexToBytes } = await import('nostr-tools/utils')
|
||||||
|
const secretKey = hexToBytes(privateKeyHex)
|
||||||
|
const publicKey = getPublicKey(secretKey)
|
||||||
|
const npub = encodeNpub(publicKey)
|
||||||
|
await storageService.set('nostr_public_key', { publicKey, npub }, 'nostr_key_storage')
|
||||||
|
await storageService.set('nostr_account_exists', true, 'nostr_key_storage')
|
||||||
|
return { recoveryPhrase, npub, publicKey }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unlockAccountTwoLevel(
|
||||||
|
recoveryPhrase: string[],
|
||||||
|
getPublicKey: (secretKey: Uint8Array) => string,
|
||||||
|
encodeNpub: (publicKey: string) => string
|
||||||
|
): Promise<UnlockAccountResult> {
|
||||||
|
const encryptedKEK = await getEncryptedKEK()
|
||||||
|
if (!encryptedKEK) {
|
||||||
|
throw new Error('No encrypted KEK found in Credentials API')
|
||||||
|
}
|
||||||
|
const kek = await decryptKEK(encryptedKEK, recoveryPhrase)
|
||||||
|
recoveryPhrase.fill('')
|
||||||
|
const { storageService: indexedDBStorage } = await import('../storage/indexedDB')
|
||||||
|
const encryptedPrivateKey = await indexedDBStorage.get<EncryptedPayload>('nostr_encrypted_key', 'nostr_key_storage')
|
||||||
|
if (!encryptedPrivateKey) {
|
||||||
|
throw new Error('No encrypted private key found in IndexedDB')
|
||||||
|
}
|
||||||
|
const privateKeyHex = await decryptPrivateKeyWithKEK(encryptedPrivateKey, kek)
|
||||||
|
const { hexToBytes } = await import('nostr-tools/utils')
|
||||||
|
const secretKey = hexToBytes(privateKeyHex)
|
||||||
|
const publicKey = getPublicKey(secretKey)
|
||||||
|
const npub = encodeNpub(publicKey)
|
||||||
|
return { privateKey: privateKeyHex, publicKey, npub }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function accountExistsTwoLevel(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const { storageService: indexedDBStorage } = await import('../storage/indexedDB')
|
||||||
|
const exists = await indexedDBStorage.get<boolean>('nostr_account_exists', 'nostr_key_storage')
|
||||||
|
return exists === true
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[keyManagementTwoLevel] Error checking account existence:', e)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPublicKeysTwoLevel(): Promise<{ publicKey: string; npub: string } | null> {
|
||||||
|
try {
|
||||||
|
const { storageService: indexedDBStorage } = await import('../storage/indexedDB')
|
||||||
|
return indexedDBStorage.get<{ publicKey: string; npub: string }>('nostr_public_key', 'nostr_key_storage')
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[keyManagementTwoLevel] Error reading public keys:', e)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAccountTwoLevel(): Promise<void> {
|
||||||
|
const { storageService: indexedDBStorage } = await import('../storage/indexedDB')
|
||||||
|
await indexedDBStorage.delete('nostr_encrypted_key')
|
||||||
|
await indexedDBStorage.delete('nostr_public_key')
|
||||||
|
await indexedDBStorage.delete('nostr_account_exists')
|
||||||
|
if (navigator.credentials?.preventSilentAccess) {
|
||||||
|
void navigator.credentials.preventSilentAccess()
|
||||||
|
}
|
||||||
|
}
|
||||||
51
lib/keyManagementTwoLevel/credentials.ts
Normal file
51
lib/keyManagementTwoLevel/credentials.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import type { EncryptedPayload } from '../keyManagementEncryption'
|
||||||
|
|
||||||
|
type PasswordCredentialData = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
password: string
|
||||||
|
iconURL?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type PasswordCredentialType = new (data: PasswordCredentialData) => Credential & { id: string; password: string }
|
||||||
|
|
||||||
|
function getPasswordCredentialConstructor(): PasswordCredentialType | undefined {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
const ctor = (window as unknown as { PasswordCredential?: PasswordCredentialType }).PasswordCredential
|
||||||
|
return ctor
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function storeEncryptedKEK(encryptedKEK: EncryptedPayload): Promise<void> {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
throw new Error('Window not available')
|
||||||
|
}
|
||||||
|
const PasswordCredentialConstructor = getPasswordCredentialConstructor()
|
||||||
|
if (!PasswordCredentialConstructor || !navigator.credentials?.store) {
|
||||||
|
throw new Error('PasswordCredential API not available')
|
||||||
|
}
|
||||||
|
const credential = new PasswordCredentialConstructor({
|
||||||
|
id: 'nostr_kek',
|
||||||
|
name: 'Nostr KEK',
|
||||||
|
password: JSON.stringify(encryptedKEK),
|
||||||
|
iconURL: `${window.location.origin}/favicon.ico`,
|
||||||
|
})
|
||||||
|
await navigator.credentials.store(credential)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getEncryptedKEK(): Promise<EncryptedPayload | null> {
|
||||||
|
if (typeof window === 'undefined' || !navigator.credentials?.get) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const credential = await navigator.credentials.get({ password: true } as CredentialRequestOptions)
|
||||||
|
if (credential && 'password' in credential && typeof credential.password === 'string' && credential.id === 'nostr_kek') {
|
||||||
|
return JSON.parse(credential.password) as EncryptedPayload
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[keyManagementTwoLevel] Error retrieving encrypted KEK:', e)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
60
lib/keyManagementTwoLevel/crypto.ts
Normal file
60
lib/keyManagementTwoLevel/crypto.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import { bytesToHex, hexToBytes } from './encoding'
|
||||||
|
|
||||||
|
const PBKDF2_ITERATIONS = 100000
|
||||||
|
const PBKDF2_HASH = 'SHA-256'
|
||||||
|
|
||||||
|
export async function generateKEK(): Promise<CryptoKey> {
|
||||||
|
return globalThis.crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt'])
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deriveKeyFromPhrase(phrase: string[]): Promise<CryptoKey> {
|
||||||
|
const phraseString = phrase.join(' ')
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
const password = encoder.encode(phraseString)
|
||||||
|
const saltBuffer = await globalThis.crypto.subtle.digest('SHA-256', password)
|
||||||
|
const salt = new Uint8Array(saltBuffer).slice(0, 32)
|
||||||
|
const keyMaterial = await globalThis.crypto.subtle.importKey('raw', password, 'PBKDF2', false, ['deriveBits', 'deriveKey'])
|
||||||
|
return globalThis.crypto.subtle.deriveKey(
|
||||||
|
{ name: 'PBKDF2', salt, iterations: PBKDF2_ITERATIONS, hash: PBKDF2_HASH },
|
||||||
|
keyMaterial,
|
||||||
|
{ name: 'AES-GCM', length: 256 },
|
||||||
|
false,
|
||||||
|
['encrypt', 'decrypt']
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportKEK(kek: CryptoKey): Promise<Uint8Array> {
|
||||||
|
const exported = await globalThis.crypto.subtle.exportKey('raw', kek)
|
||||||
|
return new Uint8Array(exported)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importKEK(keyBytes: Uint8Array): Promise<CryptoKey> {
|
||||||
|
const buffer = new ArrayBuffer(keyBytes.length)
|
||||||
|
new Uint8Array(buffer).set(keyBytes)
|
||||||
|
return globalThis.crypto.subtle.importKey('raw', buffer, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt'])
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function encryptWithAesGcm(params: { key: CryptoKey; plaintext: Uint8Array }): Promise<{ iv: Uint8Array; ciphertext: Uint8Array }> {
|
||||||
|
const iv = globalThis.crypto.getRandomValues(new Uint8Array(12))
|
||||||
|
const encrypted = await globalThis.crypto.subtle.encrypt({ name: 'AES-GCM', iv }, params.key, params.plaintext)
|
||||||
|
return { iv, ciphertext: new Uint8Array(encrypted) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decryptWithAesGcm(params: { key: CryptoKey; iv: Uint8Array; ciphertext: Uint8Array }): Promise<Uint8Array> {
|
||||||
|
const ivBuffer = new ArrayBuffer(params.iv.length)
|
||||||
|
new Uint8Array(ivBuffer).set(params.iv)
|
||||||
|
const cipherBuffer = new ArrayBuffer(params.ciphertext.length)
|
||||||
|
new Uint8Array(cipherBuffer).set(params.ciphertext)
|
||||||
|
const decrypted = await globalThis.crypto.subtle.decrypt({ name: 'AES-GCM', iv: new Uint8Array(ivBuffer) }, params.key, cipherBuffer)
|
||||||
|
return new Uint8Array(decrypted)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodeKekBytesForStorage(bytes: Uint8Array): Uint8Array {
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
return encoder.encode(bytesToHex(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeKekBytesFromStorage(payload: Uint8Array): Uint8Array {
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
return hexToBytes(decoder.decode(payload))
|
||||||
|
}
|
||||||
30
lib/keyManagementTwoLevel/encoding.ts
Normal file
30
lib/keyManagementTwoLevel/encoding.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
export function bytesToBase64(bytes: Uint8Array): string {
|
||||||
|
let binary = ''
|
||||||
|
bytes.forEach((b) => {
|
||||||
|
binary += String.fromCharCode(b)
|
||||||
|
})
|
||||||
|
return globalThis.btoa(binary)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function base64ToBytes(value: string): Uint8Array {
|
||||||
|
const binary = globalThis.atob(value)
|
||||||
|
const bytes = new Uint8Array(binary.length)
|
||||||
|
for (let i = 0; i < binary.length; i += 1) {
|
||||||
|
bytes[i] = binary.charCodeAt(i)
|
||||||
|
}
|
||||||
|
return bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bytesToHex(bytes: Uint8Array): string {
|
||||||
|
return Array.from(bytes)
|
||||||
|
.map((b) => b.toString(16).padStart(2, '0'))
|
||||||
|
.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hexToBytes(hexString: string): Uint8Array {
|
||||||
|
const parts = hexString.match(/.{1,2}/g)
|
||||||
|
if (!parts) {
|
||||||
|
return new Uint8Array([])
|
||||||
|
}
|
||||||
|
return new Uint8Array(parts.map((part) => Number.parseInt(part, 16)))
|
||||||
|
}
|
||||||
20
lib/keyManagementTwoLevel/kekEncryption.ts
Normal file
20
lib/keyManagementTwoLevel/kekEncryption.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import type { EncryptedPayload } from '../keyManagementEncryption'
|
||||||
|
import { base64ToBytes, bytesToBase64 } from './encoding'
|
||||||
|
import { decodeKekBytesFromStorage, decryptWithAesGcm, deriveKeyFromPhrase, encodeKekBytesForStorage, encryptWithAesGcm, exportKEK, importKEK } from './crypto'
|
||||||
|
|
||||||
|
export async function encryptKEK(kek: CryptoKey, recoveryPhrase: string[]): Promise<EncryptedPayload> {
|
||||||
|
const phraseKey = await deriveKeyFromPhrase(recoveryPhrase)
|
||||||
|
const kekBytes = await exportKEK(kek)
|
||||||
|
const plaintext = encodeKekBytesForStorage(kekBytes)
|
||||||
|
const { iv, ciphertext } = await encryptWithAesGcm({ key: phraseKey, plaintext })
|
||||||
|
return { iv: bytesToBase64(iv), ciphertext: bytesToBase64(ciphertext) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decryptKEK(encryptedKEK: EncryptedPayload, recoveryPhrase: string[]): Promise<CryptoKey> {
|
||||||
|
const phraseKey = await deriveKeyFromPhrase(recoveryPhrase)
|
||||||
|
const iv = base64ToBytes(encryptedKEK.iv)
|
||||||
|
const ciphertext = base64ToBytes(encryptedKEK.ciphertext)
|
||||||
|
const decrypted = await decryptWithAesGcm({ key: phraseKey, iv, ciphertext })
|
||||||
|
const kekBytes = decodeKekBytesFromStorage(decrypted)
|
||||||
|
return importKEK(kekBytes)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user