This commit is contained in:
Nicolas Cantu 2026-01-13 14:49:19 +01:00
parent f454553a66
commit 9ad602d100
181 changed files with 11140 additions and 10498 deletions

View File

@ -1,593 +1 @@
import { useState, useCallback, useEffect, type FormEvent } from 'react'
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} />
}
export { AuthorPresentationEditor } from './authorPresentationEditor/AuthorPresentationEditor'

View File

@ -1,559 +1 @@
import { useState, useEffect } from 'react'
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>
)
}
export { KeyManagementManager } from './keyManagement/KeyManagementManager'

View File

@ -1,379 +1 @@
import { useState } from 'react'
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)
}
}
}
export { MarkdownEditorTwoColumns } from './markdownEditorTwoColumns/MarkdownEditorTwoColumns'

View File

@ -1,367 +1 @@
import { useState, useEffect } from 'react'
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'
}
export { Nip95ConfigManager } from './nip95Config/Nip95ConfigManager'

View File

@ -1,412 +1 @@
import { useState, useEffect } from 'react'
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>
)
}
export { RelayManager } from './relayManager/RelayManager'

View File

@ -13,190 +13,189 @@ interface SponsoringFormProps {
export function SponsoringForm({ author, onSuccess, onCancel }: SponsoringFormProps): React.ReactElement {
const { pubkey, connect } = useNostrAuth()
const [text, setText] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [instructions, setInstructions] = useState<{
authorAddress: string
platformAddress: string
authorBtc: string
platformBtc: string
} | null>(null)
const handleSubmit = async (e: React.FormEvent): Promise<void> => {
const state = useSponsoringFormState()
const onSubmit = (e: React.FormEvent): void => {
e.preventDefault()
if (!pubkey) {
await connect()
return
}
if (!author.mainnetAddress) {
setError(t('sponsoring.form.error.noAddress'))
return
}
setLoading(true)
setError(null)
try {
const privateKey = nostrService.getPrivateKey()
if (!privateKey) {
setError(t('sponsoring.form.error.noPrivateKey'))
return
}
// 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) {
setError(result.error ?? t('sponsoring.form.error.paymentFailed'))
return
}
// Note: Sponsoring is done via Bitcoin mainnet, not Lightning zap
// 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) {
setError(submitError instanceof Error ? submitError.message : t('sponsoring.form.error.paymentFailed'))
} finally {
setLoading(false)
}
void handleSubmit({ pubkey, author, setInstructions: state.setInstructions, setError: state.setError, setLoading: state.setLoading })
}
if (!pubkey) {
return (
<div className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark">
<p className="text-cyber-accent mb-4">{t('sponsoring.form.connectRequired')}</p>
<button
onClick={() => {
void connect()
}}
className="px-4 py-2 bg-neon-green/20 hover:bg-neon-green/30 text-neon-green rounded-lg font-medium transition-all border border-neon-green/50"
>
{t('connect.connect')}
</button>
</div>
)
return <SponsoringConnectRequired onConnect={() => { void connect() }} />
}
if (!author.mainnetAddress) {
return (
<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>
</div>
)
return <SponsoringNoAddress />
}
if (instructions) {
if (state.instructions) {
return (
<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>
<p className="text-sm text-cyber-accent/70">
{t('sponsoring.form.instructions', {
authorAddress: instructions.authorAddress,
platformAddress: instructions.platformAddress,
authorAmount: instructions.authorBtc,
platformAmount: instructions.platformBtc,
})}
</p>
<div className="flex gap-3">
<button
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')}
</button>
<button
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')}
</button>
</div>
</div>
<SponsoringInstructions
instructions={state.instructions}
onClose={() => {
state.setInstructions(null)
onSuccess?.()
}}
onCancel={() => {
state.setInstructions(null)
onCancel?.()
}}
/>
)
}
return (
<form onSubmit={(e) => {
void handleSubmit(e)
}} className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark space-y-4">
<h3 className="text-lg font-semibold text-neon-cyan">{t('sponsoring.form.title')}</h3>
<p className="text-sm text-cyber-accent/70">
{t('sponsoring.form.description', { amount: '0.046' })}
</p>
<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
platformAddress: string
authorBtc: string
platformBtc: string
}
function useSponsoringFormState(): {
text: string
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 }
}
async function submitSponsoring(params: {
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 {
const privateKey = nostrService.getPrivateKey()
if (!privateKey) {
params.setError(t('sponsoring.form.error.noPrivateKey'))
return
}
const result = await sponsoringPaymentService.createSponsoringPayment({ authorPubkey: params.author.pubkey, authorMainnetAddress: params.author.mainnetAddress ?? '', amount: 0.046 })
if (!result.success) {
params.setError(result.error ?? t('sponsoring.form.error.paymentFailed'))
return
}
console.warn('Sponsoring payment info:', { authorAddress: result.authorAddress, platformAddress: result.platformAddress, authorAmount: result.split.authorSats, platformAmount: result.split.platformSats, totalAmount: result.split.totalSats })
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) })
} catch (submitError) {
params.setError(submitError instanceof Error ? submitError.message : t('sponsoring.form.error.paymentFailed'))
} finally {
params.setLoading(false)
}
}
function SponsoringConnectRequired(params: { onConnect: () => void }): React.ReactElement {
return (
<div className="border border-neon-cyan/30 rounded-lg p-4 bg-cyber-dark">
<p className="text-cyber-accent mb-4">{t('sponsoring.form.connectRequired')}</p>
<button onClick={params.onConnect} className="px-4 py-2 bg-neon-green/20 hover:bg-neon-green/30 text-neon-green rounded-lg font-medium transition-all border border-neon-green/50">
{t('connect.connect')}
</button>
</div>
)
}
function SponsoringNoAddress(): React.ReactElement {
return (
<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>
</div>
)
}
function SponsoringInstructions(params: { instructions: SponsoringInstructionsState; onClose: () => void; onCancel: () => void }): React.ReactElement {
return (
<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>
<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>
<div className="flex gap-3">
<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">
{t('common.close')}
</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">
{t('common.cancel')}
</button>
</div>
</div>
)
}
function SponsoringFormView(params: {
text: string
setText: (value: string) => void
loading: boolean
error: string | null
onSubmit: (e: React.FormEvent) => void
onCancel?: () => void
}): React.ReactElement {
return (
<form onSubmit={params.onSubmit} 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>
<p className="text-sm text-cyber-accent/70">{t('sponsoring.form.description', { amount: '0.046' })}</p>
<div>
<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>
</label>
<textarea
id="sponsoring-text"
value={text}
onChange={(e) => {
setText(e.target.value)
}}
value={params.text}
onChange={(e) => params.setText(e.target.value)}
placeholder={t('sponsoring.form.text.placeholder')}
rows={3}
className="w-full px-3 py-2 bg-cyber-darker border border-neon-cyan/30 rounded text-cyber-accent focus:border-neon-cyan focus:outline-none"
/>
<p className="text-xs text-cyber-accent/70 mt-1">{t('sponsoring.form.text.help')}</p>
</div>
{error && (
<div className="p-3 bg-red-900/20 border border-red-500/50 rounded text-red-400 text-sm">
{error}
</div>
)}
{params.error && <div className="p-3 bg-red-900/20 border border-red-500/50 rounded text-red-400 text-sm">{params.error}</div>}
<div className="flex gap-2">
<button
type="submit"
disabled={loading}
className="px-4 py-2 bg-neon-green/20 hover:bg-neon-green/30 text-neon-green rounded-lg font-medium transition-all border border-neon-green/50 hover:shadow-glow-green disabled:opacity-50"
>
{loading ? t('common.loading') : t('sponsoring.form.submit')}
<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">
{params.loading ? t('common.loading') : t('sponsoring.form.submit')}
</button>
{onCancel && (
<button
type="button"
onClick={onCancel}
className="px-4 py-2 bg-cyber-darker hover:bg-cyber-dark text-cyber-accent rounded-lg font-medium transition-all border border-neon-cyan/30"
>
{params.onCancel && (
<button type="button" onClick={params.onCancel} className="px-4 py-2 bg-cyber-darker hover:bg-cyber-dark text-cyber-accent rounded-lg font-medium transition-all border border-neon-cyan/30">
{t('common.cancel')}
</button>
)}

View File

@ -1,346 +1 @@
import { useState, useEffect, useCallback } from 'react'
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')
}
}
export { SyncProgressBar } from './syncProgressBar/SyncProgressBar'

View File

@ -1,274 +1 @@
import { useState, useRef, useMemo } from 'react'
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&apos;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>
)
}
export { UnlockAccountModal } from './unlockAccount/UnlockAccountModal'

View File

@ -1,325 +1 @@
import { useMemo, useState, type Dispatch, type SetStateAction } from 'react'
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)
},
}
}
export { UserArticles } from './userArticles/UserArticles'

View File

@ -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} />
}

View 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('')
}

View 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')
}

View 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 })} />
}

View File

@ -0,0 +1,7 @@
export interface AuthorPresentationDraft {
authorName: string
presentation: string
contentDescription: string
mainnetAddress: string
pictureUrl?: string
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View 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
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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
}

View 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)
}
}

View 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)
}
}

View File

@ -0,0 +1,4 @@
export interface PublicKeys {
publicKey: string
npub: string
}

View 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)
}

View 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}
</>
)
}

View 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 })))
},
}
}

View 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)
}
}
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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} />
}

View 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} />
}

View 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)
}
}

View 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'
}

View File

@ -0,0 +1 @@
export { Nip95ConfigView } from './Nip95ConfigView'

View 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,
}),
}
}

View 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>
)
}

View 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>
)
}

View 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} />
}

View 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>
)
}

View 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
}

View 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'>

View 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} />
}

View 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 })
}

View 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),
}
}

View 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>
)
}

View 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')
}
}

View 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
}

View 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>
}

View 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>
)
}

View 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}
/>
)
}

View File

@ -0,0 +1,35 @@
import { useState } from 'react'
import type { UnlockAccountModalProps } from './types'
import { UnlockAccountButtons } from './UnlockAccountButtons'
import { UnlockAccountForm } from './UnlockAccountForm'