lint wip
This commit is contained in:
parent
f454553a66
commit
9ad602d100
@ -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'
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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'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'
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -0,0 +1,80 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useNostrAuth } from '@/hooks/useNostrAuth'
|
||||
import { useAuthorPresentation } from '@/hooks/useAuthorPresentation'
|
||||
import { t } from '@/lib/i18n'
|
||||
import { NoAccountView } from './NoAccountView'
|
||||
import { PresentationForm } from './PresentationForm'
|
||||
import { useExistingPresentation } from './useExistingPresentation'
|
||||
import { useAuthorPresentationState } from './useAuthorPresentationState'
|
||||
|
||||
function SuccessNotice(params: { pubkey: string | null }): React.ReactElement {
|
||||
return (
|
||||
<div className="border border-neon-green/50 rounded-lg p-6 bg-neon-green/10">
|
||||
<h3 className="text-lg font-semibold text-neon-green mb-2">{t('presentation.success')}</h3>
|
||||
<p className="text-cyber-accent mb-4">{t('presentation.successMessage')}</p>
|
||||
{params.pubkey ? (
|
||||
<div className="mt-4">
|
||||
<a
|
||||
href={`/author/${params.pubkey}`}
|
||||
className="inline-block px-4 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan"
|
||||
>
|
||||
{t('presentation.manageSeries')}
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LoadingNotice(): React.ReactElement {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-cyber-accent/70">{t('common.loading')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AuthorPresentationFormView(props: { pubkey: string | null; profile: { name?: string; pubkey: string } | null }): React.ReactElement {
|
||||
const { checkPresentationExists } = useAuthorPresentation(props.pubkey)
|
||||
const presentation = useExistingPresentation({ pubkey: props.pubkey, checkPresentationExists })
|
||||
const state = useAuthorPresentationState(props.pubkey, props.profile?.name, presentation.existingPresentation)
|
||||
|
||||
if (!props.pubkey) {
|
||||
return <NoAccountView />
|
||||
}
|
||||
if (presentation.loadingPresentation) {
|
||||
return <LoadingNotice />
|
||||
}
|
||||
if (state.success) {
|
||||
return <SuccessNotice pubkey={props.pubkey} />
|
||||
}
|
||||
return (
|
||||
<PresentationForm
|
||||
draft={state.draft}
|
||||
setDraft={state.setDraft}
|
||||
validationError={state.validationError}
|
||||
error={state.error}
|
||||
loading={state.loading}
|
||||
handleSubmit={state.handleSubmit}
|
||||
deleting={state.deleting}
|
||||
handleDelete={() => {
|
||||
void state.handleDelete()
|
||||
}}
|
||||
hasExistingPresentation={presentation.existingPresentation !== null}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function useAutoLoadPubkey(accountExists: boolean | null, pubkey: string | null, connect: () => Promise<void>): void {
|
||||
useEffect(() => {
|
||||
if (accountExists === true && !pubkey) {
|
||||
void connect()
|
||||
}
|
||||
}, [accountExists, pubkey, connect])
|
||||
}
|
||||
|
||||
export function AuthorPresentationEditor(): React.ReactElement {
|
||||
const { pubkey, profile, accountExists, connect } = useNostrAuth()
|
||||
useAutoLoadPubkey(accountExists, pubkey ?? null, connect)
|
||||
return <AuthorPresentationFormView pubkey={pubkey ?? null} profile={profile} />
|
||||
}
|
||||
147
components/authorPresentationEditor/NoAccountView.tsx
Normal file
147
components/authorPresentationEditor/NoAccountView.tsx
Normal file
@ -0,0 +1,147 @@
|
||||
import { useState } from 'react'
|
||||
import { CreateAccountModal } from '../CreateAccountModal'
|
||||
import { RecoveryStep } from '../CreateAccountModalSteps'
|
||||
import { UnlockAccountModal } from '../UnlockAccountModal'
|
||||
import { t } from '@/lib/i18n'
|
||||
|
||||
export function NoAccountView(): React.ReactElement {
|
||||
const [showImportModal, setShowImportModal] = useState(false)
|
||||
const [showRecoveryStep, setShowRecoveryStep] = useState(false)
|
||||
const [showUnlockModal, setShowUnlockModal] = useState(false)
|
||||
const [recoveryPhrase, setRecoveryPhrase] = useState<string[]>([])
|
||||
const [npub, setNpub] = useState('')
|
||||
const [generating, setGenerating] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleGenerate = (): void => {
|
||||
void generateNoAccount({ setGenerating, setError, setRecoveryPhrase, setNpub, setShowRecoveryStep })
|
||||
}
|
||||
const handleRecoveryContinue = (): void => transitionToUnlock({ setShowRecoveryStep, setShowUnlockModal })
|
||||
const handleUnlockSuccess = (): void => resetNoAccountAfterUnlock({ setShowUnlockModal, setRecoveryPhrase, setNpub })
|
||||
const handleImportSuccess = (): void => {
|
||||
setShowImportModal(false)
|
||||
setShowUnlockModal(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<NoAccountCard
|
||||
error={error}
|
||||
generating={generating}
|
||||
onGenerate={handleGenerate}
|
||||
onImport={() => setShowImportModal(true)}
|
||||
modals={
|
||||
<NoAccountModals
|
||||
showImportModal={showImportModal}
|
||||
onCloseImport={() => setShowImportModal(false)}
|
||||
onImportSuccess={handleImportSuccess}
|
||||
showRecoveryStep={showRecoveryStep}
|
||||
recoveryPhrase={recoveryPhrase}
|
||||
npub={npub}
|
||||
onRecoveryContinue={handleRecoveryContinue}
|
||||
showUnlockModal={showUnlockModal}
|
||||
onUnlockSuccess={handleUnlockSuccess}
|
||||
onCloseUnlock={() => setShowUnlockModal(false)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function NoAccountActionButtons(params: { onGenerate: () => void; onImport: () => void }): React.ReactElement {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 w-full max-w-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={params.onGenerate}
|
||||
className="px-6 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan"
|
||||
>
|
||||
{t('account.create.generateButton')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={params.onImport}
|
||||
className="px-6 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg font-medium transition-colors"
|
||||
>
|
||||
{t('account.create.importButton')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NoAccountCard(params: {
|
||||
error: string | null
|
||||
generating: boolean
|
||||
onGenerate: () => void
|
||||
onImport: () => void
|
||||
modals: React.ReactElement
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div className="border border-neon-cyan/20 rounded-lg p-6 bg-cyber-dark/50">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<p className="text-center text-cyber-accent mb-2">Créez un compte ou importez votre clé secrète pour commencer</p>
|
||||
{params.error ? <p className="text-sm text-red-400">{params.error}</p> : null}
|
||||
<NoAccountActionButtons onGenerate={params.onGenerate} onImport={params.onImport} />
|
||||
{params.generating ? <p className="text-cyber-accent text-sm">Génération du compte...</p> : null}
|
||||
{params.modals}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NoAccountModals(params: {
|
||||
showImportModal: boolean
|
||||
onImportSuccess: () => void
|
||||
onCloseImport: () => void
|
||||
showRecoveryStep: boolean
|
||||
recoveryPhrase: string[]
|
||||
npub: string
|
||||
onRecoveryContinue: () => void
|
||||
showUnlockModal: boolean
|
||||
onUnlockSuccess: () => void
|
||||
onCloseUnlock: () => void
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<>
|
||||
{params.showImportModal ? <CreateAccountModal onSuccess={params.onImportSuccess} onClose={params.onCloseImport} initialStep="import" /> : null}
|
||||
{params.showRecoveryStep ? <RecoveryStep recoveryPhrase={params.recoveryPhrase} npub={params.npub} onContinue={params.onRecoveryContinue} /> : null}
|
||||
{params.showUnlockModal ? <UnlockAccountModal onSuccess={params.onUnlockSuccess} onClose={params.onCloseUnlock} /> : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
async function generateNoAccount(params: {
|
||||
setGenerating: (value: boolean) => void
|
||||
setError: (value: string | null) => void
|
||||
setRecoveryPhrase: (value: string[]) => void
|
||||
setNpub: (value: string) => void
|
||||
setShowRecoveryStep: (value: boolean) => void
|
||||
}): Promise<void> {
|
||||
params.setGenerating(true)
|
||||
params.setError(null)
|
||||
try {
|
||||
const { nostrAuthService } = await import('@/lib/nostrAuth')
|
||||
const result = await nostrAuthService.createAccount()
|
||||
params.setRecoveryPhrase(result.recoveryPhrase)
|
||||
params.setNpub(result.npub)
|
||||
params.setShowRecoveryStep(true)
|
||||
} catch (e) {
|
||||
params.setError(e instanceof Error ? e.message : t('account.create.error.failed'))
|
||||
} finally {
|
||||
params.setGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
function transitionToUnlock(params: { setShowRecoveryStep: (value: boolean) => void; setShowUnlockModal: (value: boolean) => void }): void {
|
||||
params.setShowRecoveryStep(false)
|
||||
params.setShowUnlockModal(true)
|
||||
}
|
||||
|
||||
function resetNoAccountAfterUnlock(params: {
|
||||
setShowUnlockModal: (value: boolean) => void
|
||||
setRecoveryPhrase: (value: string[]) => void
|
||||
setNpub: (value: string) => void
|
||||
}): void {
|
||||
params.setShowUnlockModal(false)
|
||||
params.setRecoveryPhrase([])
|
||||
params.setNpub('')
|
||||
}
|
||||
75
components/authorPresentationEditor/PresentationForm.tsx
Normal file
75
components/authorPresentationEditor/PresentationForm.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
import type { FormEvent } from 'react'
|
||||
import { PresentationFormHeader } from '../PresentationFormHeader'
|
||||
import { t } from '@/lib/i18n'
|
||||
import { PresentationFields } from './fields'
|
||||
import type { AuthorPresentationDraft } from './types'
|
||||
|
||||
export interface PresentationFormProps {
|
||||
draft: AuthorPresentationDraft
|
||||
setDraft: (next: AuthorPresentationDraft) => void
|
||||
validationError: string | null
|
||||
error: string | null
|
||||
loading: boolean
|
||||
handleSubmit: (e: FormEvent<HTMLFormElement>) => Promise<void>
|
||||
deleting: boolean
|
||||
handleDelete: () => void
|
||||
hasExistingPresentation: boolean
|
||||
}
|
||||
|
||||
export function PresentationForm(props: PresentationFormProps): React.ReactElement {
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e: FormEvent<HTMLFormElement>) => {
|
||||
void props.handleSubmit(e)
|
||||
}}
|
||||
className="border border-neon-cyan/20 rounded-lg p-6 bg-cyber-dark space-y-4"
|
||||
>
|
||||
<PresentationFormHeader />
|
||||
<PresentationFields draft={props.draft} onChange={props.setDraft} />
|
||||
<ValidationError message={props.validationError ?? props.error} />
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={props.loading || props.deleting}
|
||||
className="w-full px-4 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{getSubmitLabel({ loading: props.loading, deleting: props.deleting, hasExistingPresentation: props.hasExistingPresentation })}
|
||||
</button>
|
||||
</div>
|
||||
{props.hasExistingPresentation ? <DeleteButton onDelete={props.handleDelete} deleting={props.deleting} /> : null}
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function ValidationError(params: { message: string | null }): React.ReactElement | null {
|
||||
if (!params.message) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div className="bg-red-500/10 border border-red-500/50 rounded-lg p-3">
|
||||
<p className="text-sm text-red-400">{params.message}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DeleteButton(params: { onDelete: () => void; deleting: boolean }): React.ReactElement {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={params.onDelete}
|
||||
disabled={params.deleting}
|
||||
className="px-4 py-2 bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded-lg text-sm font-medium transition-all border border-red-500/50 hover:shadow-glow-red disabled:opacity-50"
|
||||
>
|
||||
{params.deleting ? t('presentation.delete.deleting') : t('presentation.delete.button')}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function getSubmitLabel(params: { loading: boolean; deleting: boolean; hasExistingPresentation: boolean }): string {
|
||||
if (params.loading || params.deleting) {
|
||||
return t('publish.publishing')
|
||||
}
|
||||
return params.hasExistingPresentation ? t('presentation.update.button') : t('publish.button')
|
||||
}
|
||||
88
components/authorPresentationEditor/fields.tsx
Normal file
88
components/authorPresentationEditor/fields.tsx
Normal file
@ -0,0 +1,88 @@
|
||||
import { ArticleField } from '../ArticleField'
|
||||
import { ImageUploadField } from '../ImageUploadField'
|
||||
import { t } from '@/lib/i18n'
|
||||
import type { AuthorPresentationDraft } from './types'
|
||||
|
||||
export function PresentationFields(params: {
|
||||
draft: AuthorPresentationDraft
|
||||
onChange: (next: AuthorPresentationDraft) => void
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<AuthorNameField draft={params.draft} onChange={params.onChange} />
|
||||
<PictureField draft={params.draft} onChange={params.onChange} />
|
||||
<PresentationField draft={params.draft} onChange={params.onChange} />
|
||||
<ContentDescriptionField draft={params.draft} onChange={params.onChange} />
|
||||
<MainnetAddressField draft={params.draft} onChange={params.onChange} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PresentationField(params: { draft: AuthorPresentationDraft; onChange: (next: AuthorPresentationDraft) => void }): React.ReactElement {
|
||||
return (
|
||||
<ArticleField
|
||||
id="presentation"
|
||||
label={t('presentation.field.presentation')}
|
||||
value={params.draft.presentation}
|
||||
onChange={(value) => params.onChange({ ...params.draft, presentation: value as string })}
|
||||
required
|
||||
type="textarea"
|
||||
rows={6}
|
||||
placeholder={t('presentation.field.presentation.placeholder')}
|
||||
helpText={t('presentation.field.presentation.help')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContentDescriptionField(params: {
|
||||
draft: AuthorPresentationDraft
|
||||
onChange: (next: AuthorPresentationDraft) => void
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<ArticleField
|
||||
id="contentDescription"
|
||||
label={t('presentation.field.contentDescription')}
|
||||
value={params.draft.contentDescription}
|
||||
onChange={(value) => params.onChange({ ...params.draft, contentDescription: value as string })}
|
||||
required
|
||||
type="textarea"
|
||||
rows={6}
|
||||
placeholder={t('presentation.field.contentDescription.placeholder')}
|
||||
helpText={t('presentation.field.contentDescription.help')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MainnetAddressField(params: { draft: AuthorPresentationDraft; onChange: (next: AuthorPresentationDraft) => void }): React.ReactElement {
|
||||
return (
|
||||
<ArticleField
|
||||
id="mainnetAddress"
|
||||
label={t('presentation.field.mainnetAddress')}
|
||||
value={params.draft.mainnetAddress}
|
||||
onChange={(value) => params.onChange({ ...params.draft, mainnetAddress: value as string })}
|
||||
required
|
||||
type="text"
|
||||
placeholder={t('presentation.field.mainnetAddress.placeholder')}
|
||||
helpText={t('presentation.field.mainnetAddress.help')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AuthorNameField(params: { draft: AuthorPresentationDraft; onChange: (next: AuthorPresentationDraft) => void }): React.ReactElement {
|
||||
return (
|
||||
<ArticleField
|
||||
id="authorName"
|
||||
label={t('presentation.field.authorName')}
|
||||
value={params.draft.authorName}
|
||||
onChange={(value) => params.onChange({ ...params.draft, authorName: value as string })}
|
||||
required
|
||||
type="text"
|
||||
placeholder={t('presentation.field.authorName.placeholder')}
|
||||
helpText={t('presentation.field.authorName.help')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PictureField(params: { draft: AuthorPresentationDraft; onChange: (next: AuthorPresentationDraft) => void }): React.ReactElement {
|
||||
return <ImageUploadField id="picture" value={params.draft.pictureUrl} onChange={(url) => params.onChange({ ...params.draft, pictureUrl: url })} />
|
||||
}
|
||||
7
components/authorPresentationEditor/types.ts
Normal file
7
components/authorPresentationEditor/types.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export interface AuthorPresentationDraft {
|
||||
authorName: string
|
||||
presentation: string
|
||||
contentDescription: string
|
||||
mainnetAddress: string
|
||||
pictureUrl?: string
|
||||
}
|
||||
@ -0,0 +1,124 @@
|
||||
import { useCallback, useEffect, useState, type Dispatch, type FormEvent, type SetStateAction } from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useAuthorPresentation } from '@/hooks/useAuthorPresentation'
|
||||
import { extractPresentationData } from '@/lib/presentationParsing'
|
||||
import { userConfirm } from '@/lib/userConfirm'
|
||||
import { t } from '@/lib/i18n'
|
||||
import type { Article } from '@/types/nostr'
|
||||
import type { AuthorPresentationDraft } from './types'
|
||||
import { validatePresentationDraft } from './validation'
|
||||
|
||||
export interface AuthorPresentationState {
|
||||
draft: AuthorPresentationDraft
|
||||
setDraft: (next: AuthorPresentationDraft) => void
|
||||
validationError: string | null
|
||||
error: string | null
|
||||
loading: boolean
|
||||
handleSubmit: (e: FormEvent<HTMLFormElement>) => Promise<void>
|
||||
deleting: boolean
|
||||
handleDelete: () => Promise<void>
|
||||
success: boolean
|
||||
}
|
||||
|
||||
export function useAuthorPresentationState(
|
||||
pubkey: string | null,
|
||||
existingAuthorName: string | undefined,
|
||||
existingPresentation: Article | null | undefined,
|
||||
): AuthorPresentationState {
|
||||
const { loading, error, success, publishPresentation, deletePresentation } = useAuthorPresentation(pubkey)
|
||||
const router = useRouter()
|
||||
const [draft, setDraft] = useState<AuthorPresentationDraft>(() => buildInitialDraft(existingPresentation, existingAuthorName))
|
||||
const [validationError, setValidationError] = useState<string | null>(null)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
syncAuthorNameIntoDraft({ existingAuthorName, draftAuthorName: draft.authorName, hasExistingPresentation: Boolean(existingPresentation), setDraft })
|
||||
}, [existingAuthorName, existingPresentation, draft.authorName])
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
await submitPresentationDraft({ draft, setValidationError, publishPresentation })
|
||||
},
|
||||
[draft, publishPresentation],
|
||||
)
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
await deletePresentationFlow({
|
||||
existingPresentationId: existingPresentation?.id,
|
||||
deletePresentation,
|
||||
router,
|
||||
setDeleting,
|
||||
setValidationError,
|
||||
})
|
||||
}, [existingPresentation, deletePresentation, router])
|
||||
|
||||
return { loading, error, success, draft, setDraft, validationError, handleSubmit, deleting, handleDelete }
|
||||
}
|
||||
|
||||
function buildInitialDraft(existingPresentation: Article | null | undefined, existingAuthorName: string | undefined): AuthorPresentationDraft {
|
||||
if (existingPresentation) {
|
||||
const { presentation, contentDescription } = extractPresentationData(existingPresentation)
|
||||
const authorName = existingPresentation.title.replace(/^Présentation de /, '') ?? existingAuthorName ?? ''
|
||||
return {
|
||||
authorName,
|
||||
presentation,
|
||||
contentDescription,
|
||||
mainnetAddress: existingPresentation.mainnetAddress ?? '',
|
||||
...(existingPresentation.bannerUrl ? { pictureUrl: existingPresentation.bannerUrl } : {}),
|
||||
}
|
||||
}
|
||||
return { authorName: existingAuthorName ?? '', presentation: '', contentDescription: '', mainnetAddress: '' }
|
||||
}
|
||||
|
||||
function syncAuthorNameIntoDraft(params: {
|
||||
existingAuthorName: string | undefined
|
||||
draftAuthorName: string
|
||||
hasExistingPresentation: boolean
|
||||
setDraft: Dispatch<SetStateAction<AuthorPresentationDraft>>
|
||||
}): void {
|
||||
if (!params.existingAuthorName || params.hasExistingPresentation || params.existingAuthorName === params.draftAuthorName) {
|
||||
return
|
||||
}
|
||||
params.setDraft((prev) => ({ ...prev, authorName: params.existingAuthorName as string }))
|
||||
}
|
||||
|
||||
async function submitPresentationDraft(params: {
|
||||
draft: AuthorPresentationDraft
|
||||
setValidationError: (value: string | null) => void
|
||||
publishPresentation: (draft: AuthorPresentationDraft) => Promise<void>
|
||||
}): Promise<void> {
|
||||
const validationError = validatePresentationDraft(params.draft)
|
||||
if (validationError) {
|
||||
params.setValidationError(validationError)
|
||||
return
|
||||
}
|
||||
params.setValidationError(null)
|
||||
await params.publishPresentation(params.draft)
|
||||
}
|
||||
|
||||
async function deletePresentationFlow(params: {
|
||||
existingPresentationId: string | undefined
|
||||
deletePresentation: (articleId: string) => Promise<void>
|
||||
router: ReturnType<typeof useRouter>
|
||||
setDeleting: (value: boolean) => void
|
||||
setValidationError: (value: string | null) => void
|
||||
}): Promise<void> {
|
||||
if (!params.existingPresentationId) {
|
||||
return
|
||||
}
|
||||
const confirmed = await userConfirm(t('presentation.delete.confirm'))
|
||||
if (!confirmed) {
|
||||
return
|
||||
}
|
||||
params.setDeleting(true)
|
||||
params.setValidationError(null)
|
||||
try {
|
||||
await params.deletePresentation(params.existingPresentationId)
|
||||
await params.router.push('/')
|
||||
} catch (e) {
|
||||
params.setValidationError(e instanceof Error ? e.message : t('presentation.delete.error'))
|
||||
} finally {
|
||||
params.setDeleting(false)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { Article } from '@/types/nostr'
|
||||
|
||||
export function useExistingPresentation(params: {
|
||||
pubkey: string | null
|
||||
checkPresentationExists: () => Promise<Article | null>
|
||||
}): { existingPresentation: Article | null; loadingPresentation: boolean } {
|
||||
const [existingPresentation, setExistingPresentation] = useState<Article | null>(null)
|
||||
const [loadingPresentation, setLoadingPresentation] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
void loadExistingPresentation({
|
||||
pubkey: params.pubkey,
|
||||
checkPresentationExists: params.checkPresentationExists,
|
||||
setExistingPresentation,
|
||||
setLoadingPresentation,
|
||||
})
|
||||
}, [params.pubkey, params.checkPresentationExists])
|
||||
|
||||
return { existingPresentation, loadingPresentation }
|
||||
}
|
||||
|
||||
async function loadExistingPresentation(params: {
|
||||
pubkey: string | null
|
||||
checkPresentationExists: () => Promise<Article | null>
|
||||
setExistingPresentation: (value: Article | null) => void
|
||||
setLoadingPresentation: (value: boolean) => void
|
||||
}): Promise<void> {
|
||||
if (!params.pubkey) {
|
||||
params.setLoadingPresentation(false)
|
||||
return
|
||||
}
|
||||
try {
|
||||
params.setExistingPresentation(await params.checkPresentationExists())
|
||||
} catch (e) {
|
||||
console.error('[AuthorPresentationEditor] Error loading presentation:', e)
|
||||
} finally {
|
||||
params.setLoadingPresentation(false)
|
||||
}
|
||||
}
|
||||
15
components/authorPresentationEditor/validation.ts
Normal file
15
components/authorPresentationEditor/validation.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { t } from '@/lib/i18n'
|
||||
import type { AuthorPresentationDraft } from './types'
|
||||
|
||||
const ADDRESS_PATTERN = /^(1|3|bc1)[a-zA-Z0-9]{25,62}$/
|
||||
|
||||
export function validatePresentationDraft(draft: AuthorPresentationDraft): string | null {
|
||||
const address = draft.mainnetAddress.trim()
|
||||
if (!ADDRESS_PATTERN.test(address)) {
|
||||
return t('presentation.validation.invalidAddress')
|
||||
}
|
||||
if (!draft.authorName.trim()) {
|
||||
return t('presentation.validation.authorNameRequired')
|
||||
}
|
||||
return null
|
||||
}
|
||||
130
components/keyManagement/KeyManagementImportForm.tsx
Normal file
130
components/keyManagement/KeyManagementImportForm.tsx
Normal file
@ -0,0 +1,130 @@
|
||||
import { t } from '@/lib/i18n'
|
||||
import type { KeyManagementManagerActions } from './useKeyManagementManager'
|
||||
import type { KeyManagementManagerState } from './keyManagementController'
|
||||
|
||||
export function KeyManagementImportForm(params: {
|
||||
state: KeyManagementManagerState
|
||||
actions: KeyManagementManagerActions
|
||||
}): React.ReactElement | null {
|
||||
if (!params.state.showImportForm) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<KeyManagementImportWarning accountExists={params.state.accountExists} />
|
||||
<KeyManagementImportTextarea importKey={params.state.importKey} onChangeImportKey={params.actions.onChangeImportKey} />
|
||||
<KeyManagementReplaceWarning
|
||||
show={params.state.showReplaceWarning}
|
||||
importing={params.state.importing}
|
||||
onCancel={params.actions.onDismissReplaceWarning}
|
||||
onConfirm={params.actions.onConfirmReplace}
|
||||
/>
|
||||
<KeyManagementImportFormActions
|
||||
show={!params.state.showReplaceWarning}
|
||||
importing={params.state.importing}
|
||||
onCancel={params.actions.onCancelImport}
|
||||
onImport={params.actions.onImport}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function KeyManagementImportWarning(params: { accountExists: boolean }): React.ReactElement {
|
||||
return (
|
||||
<div className="bg-yellow-900/20 border border-yellow-400/50 rounded-lg p-4">
|
||||
<p className="text-yellow-400 font-semibold mb-2">{t('settings.keyManagement.import.warning.title')}</p>
|
||||
<p className="text-yellow-300/90 text-sm" dangerouslySetInnerHTML={{ __html: t('settings.keyManagement.import.warning.description') }} />
|
||||
{params.accountExists ? (
|
||||
<p className="text-yellow-300/90 text-sm mt-2" dangerouslySetInnerHTML={{ __html: t('settings.keyManagement.import.warning.replace') }} />
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function KeyManagementImportTextarea(params: {
|
||||
importKey: string
|
||||
onChangeImportKey: (value: string) => void
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor="importKey" className="block text-sm font-medium text-cyber-accent mb-2">
|
||||
{t('settings.keyManagement.import.label')}
|
||||
</label>
|
||||
<textarea
|
||||
id="importKey"
|
||||
value={params.importKey}
|
||||
onChange={(e) => {
|
||||
params.onChangeImportKey(e.target.value)
|
||||
}}
|
||||
placeholder={t('settings.keyManagement.import.placeholder')}
|
||||
className="w-full px-3 py-2 bg-cyber-dark border border-neon-cyan/30 rounded-lg font-mono text-sm text-neon-cyan focus:border-neon-cyan focus:outline-none"
|
||||
rows={4}
|
||||
/>
|
||||
<p className="text-sm text-cyber-accent/70 mt-2">{t('settings.keyManagement.import.help')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function KeyManagementReplaceWarning(params: {
|
||||
show: boolean
|
||||
importing: boolean
|
||||
onCancel: () => void
|
||||
onConfirm: () => void
|
||||
}): React.ReactElement | null {
|
||||
if (!params.show) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div className="bg-red-900/20 border border-red-400/50 rounded-lg p-4">
|
||||
<p className="text-red-400 font-semibold mb-2">{t('settings.keyManagement.replace.warning.title')}</p>
|
||||
<p className="text-red-300/90 text-sm mb-4">{t('settings.keyManagement.replace.warning.description')}</p>
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={params.onCancel}
|
||||
className="flex-1 py-2 px-4 bg-cyber-light border border-neon-cyan/30 hover:border-neon-cyan/50 hover:bg-cyber-dark text-cyber-accent hover:text-neon-cyan rounded-lg font-medium transition-colors"
|
||||
>
|
||||
{t('settings.keyManagement.replace.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={params.onConfirm}
|
||||
disabled={params.importing}
|
||||
className="flex-1 py-2 px-4 bg-red-600/20 hover:bg-red-600/30 text-red-400 rounded-lg font-medium transition-all border border-red-400/50 hover:shadow-glow-red disabled:opacity-50"
|
||||
>
|
||||
{params.importing ? t('settings.keyManagement.replace.replacing') : t('settings.keyManagement.replace.confirm')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function KeyManagementImportFormActions(params: {
|
||||
show: boolean
|
||||
importing: boolean
|
||||
onCancel: () => void
|
||||
onImport: () => void
|
||||
}): React.ReactElement | null {
|
||||
if (!params.show) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={params.onCancel}
|
||||
className="flex-1 py-2 px-4 bg-cyber-light border border-neon-cyan/30 hover:border-neon-cyan/50 hover:bg-cyber-dark text-cyber-accent hover:text-neon-cyan rounded-lg font-medium transition-colors"
|
||||
>
|
||||
{t('settings.keyManagement.import.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={params.onImport}
|
||||
disabled={params.importing}
|
||||
className="flex-1 py-2 px-4 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan disabled:opacity-50"
|
||||
>
|
||||
{params.importing ? t('settings.keyManagement.import.importing') : t('settings.keyManagement.import.import')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
125
components/keyManagement/KeyManagementImportSection.tsx
Normal file
125
components/keyManagement/KeyManagementImportSection.tsx
Normal file
@ -0,0 +1,125 @@
|
||||
import { t } from '@/lib/i18n'
|
||||
import type { KeyManagementManagerActions } from './useKeyManagementManager'
|
||||
import type { KeyManagementManagerState } from './keyManagementController'
|
||||
import { KeyManagementImportForm } from './KeyManagementImportForm'
|
||||
|
||||
export function KeyManagementImportSection(params: {
|
||||
state: KeyManagementManagerState
|
||||
actions: KeyManagementManagerActions
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<>
|
||||
<KeyManagementErrorBanner error={params.state.error} />
|
||||
<KeyManagementPublicKeysPanel
|
||||
publicKeys={params.state.publicKeys}
|
||||
copiedNpub={params.state.copiedNpub}
|
||||
copiedPublicKey={params.state.copiedPublicKey}
|
||||
onCopyNpub={params.actions.onCopyNpub}
|
||||
onCopyPublicKey={params.actions.onCopyPublicKey}
|
||||
/>
|
||||
<KeyManagementNoAccountBanner publicKeys={params.state.publicKeys} accountExists={params.state.accountExists} />
|
||||
<KeyManagementImportButton
|
||||
accountExists={params.state.accountExists}
|
||||
showImportForm={params.state.showImportForm}
|
||||
onClick={params.actions.onShowImportForm}
|
||||
/>
|
||||
<KeyManagementImportForm state={params.state} actions={params.actions} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function KeyManagementErrorBanner(params: { error: string | null }): React.ReactElement | null {
|
||||
if (!params.error) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div className="bg-red-900/20 border border-red-400/50 rounded-lg p-4 mb-4">
|
||||
<p className="text-red-400">{params.error}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function KeyManagementPublicKeysPanel(params: {
|
||||
publicKeys: KeyManagementManagerState['publicKeys']
|
||||
copiedNpub: boolean
|
||||
copiedPublicKey: boolean
|
||||
onCopyNpub: () => void
|
||||
onCopyPublicKey: () => void
|
||||
}): React.ReactElement | null {
|
||||
if (!params.publicKeys) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div className="space-y-4 mb-6">
|
||||
<KeyManagementKeyCard
|
||||
label={t('settings.keyManagement.publicKey.npub')}
|
||||
value={params.publicKeys.npub}
|
||||
copied={params.copiedNpub}
|
||||
onCopy={params.onCopyNpub}
|
||||
/>
|
||||
<KeyManagementKeyCard
|
||||
label={t('settings.keyManagement.publicKey.hex')}
|
||||
value={params.publicKeys.publicKey}
|
||||
copied={params.copiedPublicKey}
|
||||
onCopy={params.onCopyPublicKey}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function KeyManagementKeyCard(params: {
|
||||
label: string
|
||||
value: string
|
||||
copied: boolean
|
||||
onCopy: () => void
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div className="bg-neon-blue/10 border border-neon-blue/30 rounded-lg p-4">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<p className="text-neon-blue font-semibold">{params.label}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={params.onCopy}
|
||||
className="px-3 py-1 text-xs bg-cyber-light border border-neon-cyan/30 hover:border-neon-cyan/50 hover:bg-cyber-dark text-cyber-accent hover:text-neon-cyan rounded transition-colors"
|
||||
>
|
||||
{params.copied ? t('settings.keyManagement.copied') : t('settings.keyManagement.copy')}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-neon-cyan text-sm font-mono break-all">{params.value}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function KeyManagementNoAccountBanner(params: {
|
||||
publicKeys: KeyManagementManagerState['publicKeys']
|
||||
accountExists: boolean
|
||||
}): React.ReactElement | null {
|
||||
if (params.publicKeys || params.accountExists) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div className="bg-yellow-900/20 border border-yellow-400/50 rounded-lg p-4 mb-6">
|
||||
<p className="text-yellow-400 font-semibold mb-2">{t('settings.keyManagement.noAccount.title')}</p>
|
||||
<p className="text-yellow-300/90 text-sm">{t('settings.keyManagement.noAccount.description')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function KeyManagementImportButton(params: {
|
||||
accountExists: boolean
|
||||
showImportForm: boolean
|
||||
onClick: () => void
|
||||
}): React.ReactElement | null {
|
||||
if (params.showImportForm) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={params.onClick}
|
||||
className="w-full py-3 px-6 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan"
|
||||
>
|
||||
{params.accountExists ? t('settings.keyManagement.import.button.replace') : t('settings.keyManagement.import.button.new')}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
28
components/keyManagement/KeyManagementManager.tsx
Normal file
28
components/keyManagement/KeyManagementManager.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import { t } from '@/lib/i18n'
|
||||
import { SyncProgressBar } from '../SyncProgressBar'
|
||||
import { KeyManagementImportSection } from './KeyManagementImportSection'
|
||||
import { KeyManagementRecoverySection } from './KeyManagementRecoverySection'
|
||||
import { useKeyManagementManager } from './useKeyManagementManager'
|
||||
|
||||
export function KeyManagementManager(): React.ReactElement {
|
||||
const { state, actions } = useKeyManagementManager()
|
||||
|
||||
if (state.loading) {
|
||||
return (
|
||||
<div className="bg-cyber-darker border border-neon-cyan/30 rounded-lg p-6">
|
||||
<p className="text-cyber-accent">{t('settings.keyManagement.loading')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-cyber-darker border border-neon-cyan/30 rounded-lg p-6">
|
||||
<h2 className="text-2xl font-bold text-neon-cyan mb-4">{t('settings.keyManagement.title')}</h2>
|
||||
<KeyManagementImportSection state={state} actions={actions} />
|
||||
<SyncProgressBar />
|
||||
<KeyManagementRecoverySection state={state} actions={actions} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
98
components/keyManagement/KeyManagementRecoverySection.tsx
Normal file
98
components/keyManagement/KeyManagementRecoverySection.tsx
Normal file
@ -0,0 +1,98 @@
|
||||
import { t } from '@/lib/i18n'
|
||||
import type { KeyManagementManagerActions } from './useKeyManagementManager'
|
||||
import type { KeyManagementManagerState } from './keyManagementController'
|
||||
|
||||
export function KeyManagementRecoverySection(params: {
|
||||
state: KeyManagementManagerState
|
||||
actions: KeyManagementManagerActions
|
||||
}): React.ReactElement | null {
|
||||
if (!params.state.recoveryPhrase || !params.state.newNpub) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div className="mt-6 space-y-4">
|
||||
<KeyManagementRecoveryWarning />
|
||||
<div className="bg-cyber-darker border border-neon-cyan/30 rounded-lg p-6">
|
||||
<RecoveryWordsGrid recoveryPhrase={params.state.recoveryPhrase} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={params.actions.onCopyRecoveryPhrase}
|
||||
className="w-full py-2 px-4 bg-cyber-light border border-neon-cyan/30 hover:border-neon-cyan/50 hover:bg-cyber-dark text-cyber-accent hover:text-neon-cyan rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
{params.state.copiedRecoveryPhrase ? t('settings.keyManagement.recovery.copied') : t('settings.keyManagement.recovery.copy')}
|
||||
</button>
|
||||
</div>
|
||||
<KeyManagementNewNpubCard newNpub={params.state.newNpub} />
|
||||
<KeyManagementDoneButton onDone={params.actions.onDoneRecovery} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function KeyManagementRecoveryWarning(): React.ReactElement {
|
||||
return (
|
||||
<div className="bg-yellow-900/20 border border-yellow-400/50 rounded-lg p-4">
|
||||
<p className="text-yellow-400 font-semibold mb-2">{t('settings.keyManagement.recovery.warning.title')}</p>
|
||||
<p className="text-yellow-300/90 text-sm" dangerouslySetInnerHTML={{ __html: t('settings.keyManagement.recovery.warning.part1') }} />
|
||||
<p className="text-yellow-300/90 text-sm mt-2" dangerouslySetInnerHTML={{ __html: t('settings.keyManagement.recovery.warning.part2') }} />
|
||||
<p className="text-yellow-300/90 text-sm mt-2">{t('settings.keyManagement.recovery.warning.part3')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function KeyManagementNewNpubCard(params: { newNpub: string }): React.ReactElement {
|
||||
return (
|
||||
<div className="bg-neon-blue/10 border border-neon-blue/30 rounded-lg p-4">
|
||||
<p className="text-neon-blue font-semibold mb-2">{t('settings.keyManagement.recovery.newNpub')}</p>
|
||||
<p className="text-neon-cyan text-sm font-mono break-all">{params.newNpub}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function KeyManagementDoneButton(params: { onDone: () => void }): React.ReactElement {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={params.onDone}
|
||||
className="w-full py-2 px-4 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan"
|
||||
>
|
||||
{t('settings.keyManagement.recovery.done')}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function RecoveryWordsGrid(params: { recoveryPhrase: string[] }): React.ReactElement {
|
||||
const items = buildRecoveryWordItems(params.recoveryPhrase)
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="bg-cyber-dark border border-neon-cyan/30 rounded-lg p-3 text-center font-mono text-lg"
|
||||
>
|
||||
<span className="text-cyber-accent/70 text-sm mr-2">{item.position}.</span>
|
||||
<span className="font-semibold text-neon-cyan">{item.word}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface RecoveryWordItem {
|
||||
id: string
|
||||
position: number
|
||||
word: string
|
||||
}
|
||||
|
||||
function buildRecoveryWordItems(recoveryPhrase: readonly string[]): RecoveryWordItem[] {
|
||||
const occurrences = new Map<string, number>()
|
||||
const items: RecoveryWordItem[] = []
|
||||
let position = 1
|
||||
for (const word of recoveryPhrase) {
|
||||
const current = occurrences.get(word) ?? 0
|
||||
const next = current + 1
|
||||
occurrences.set(word, next)
|
||||
items.push({ id: `${word}-${next}`, position, word })
|
||||
position += 1
|
||||
}
|
||||
return items
|
||||
}
|
||||
53
components/keyManagement/keyImportParsing.ts
Normal file
53
components/keyManagement/keyImportParsing.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import { nip19 } from 'nostr-tools'
|
||||
|
||||
export function extractKeyFromInput(rawInput: string): string | null {
|
||||
const trimmed = rawInput.trim()
|
||||
if (trimmed.length === 0) {
|
||||
return null
|
||||
}
|
||||
const fromUrl = extractKeyFromUrl(trimmed)
|
||||
return fromUrl ?? extractKeyFromText(trimmed)
|
||||
}
|
||||
|
||||
function extractKeyFromUrl(url: string): string | null {
|
||||
try {
|
||||
const urlObj = new URL(url)
|
||||
const {protocol} = urlObj
|
||||
if (protocol === 'nostr:' || protocol === 'nostr://') {
|
||||
const path = urlObj.pathname ?? urlObj.href.replace(/^nostr:?\/\//, '')
|
||||
if (path.startsWith('nsec')) {
|
||||
return path
|
||||
}
|
||||
}
|
||||
const nsecMatch = url.match(/nsec1[a-z0-9]+/i)
|
||||
return nsecMatch?.[0] ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function extractKeyFromText(text: string): string | null {
|
||||
const nsec = extractNsec(text)
|
||||
if (nsec) {
|
||||
return nsec
|
||||
}
|
||||
const trimmed = text.trim()
|
||||
return trimmed.length > 0 ? trimmed : null
|
||||
}
|
||||
|
||||
function extractNsec(text: string): string | null {
|
||||
const nsecMatch = text.match(/nsec1[a-z0-9]+/i)
|
||||
return nsecMatch?.[0] ?? null
|
||||
}
|
||||
|
||||
export function isValidPrivateKeyFormat(key: string): boolean {
|
||||
try {
|
||||
const decoded = nip19.decode(key)
|
||||
if (decoded.type !== 'nsec') {
|
||||
return false
|
||||
}
|
||||
return typeof decoded.data === 'string' || decoded.data instanceof Uint8Array
|
||||
} catch {
|
||||
return /^[0-9a-f]{64}$/i.test(key)
|
||||
}
|
||||
}
|
||||
154
components/keyManagement/keyManagementController.ts
Normal file
154
components/keyManagement/keyManagementController.ts
Normal file
@ -0,0 +1,154 @@
|
||||
import { nostrAuthService } from '@/lib/nostrAuth'
|
||||
import { keyManagementService } from '@/lib/keyManagement'
|
||||
import { t } from '@/lib/i18n'
|
||||
import type { PublicKeys } from './types'
|
||||
import { extractKeyFromInput, isValidPrivateKeyFormat } from './keyImportParsing'
|
||||
|
||||
export interface KeyManagementManagerState {
|
||||
publicKeys: PublicKeys | null
|
||||
accountExists: boolean
|
||||
loading: boolean
|
||||
error: string | null
|
||||
importKey: string
|
||||
importing: boolean
|
||||
showImportForm: boolean
|
||||
showReplaceWarning: boolean
|
||||
recoveryPhrase: string[] | null
|
||||
newNpub: string | null
|
||||
copiedNpub: boolean
|
||||
copiedPublicKey: boolean
|
||||
copiedRecoveryPhrase: boolean
|
||||
}
|
||||
|
||||
export type PatchState = (patch: Partial<KeyManagementManagerState>) => void
|
||||
|
||||
export const INITIAL_KEY_MANAGEMENT_STATE: KeyManagementManagerState = {
|
||||
publicKeys: null,
|
||||
accountExists: false,
|
||||
loading: true,
|
||||
error: null,
|
||||
importKey: '',
|
||||
importing: false,
|
||||
showImportForm: false,
|
||||
showReplaceWarning: false,
|
||||
recoveryPhrase: null,
|
||||
newNpub: null,
|
||||
copiedNpub: false,
|
||||
copiedPublicKey: false,
|
||||
copiedRecoveryPhrase: false,
|
||||
}
|
||||
|
||||
export async function loadKeys(params: { patchState: PatchState }): Promise<void> {
|
||||
try {
|
||||
params.patchState({ loading: true, error: null })
|
||||
const exists = await nostrAuthService.accountExists()
|
||||
params.patchState({ accountExists: exists })
|
||||
if (!exists) {
|
||||
params.patchState({ publicKeys: null })
|
||||
return
|
||||
}
|
||||
const keys = await keyManagementService.getPublicKeys()
|
||||
params.patchState({ publicKeys: keys ?? null })
|
||||
} catch (e) {
|
||||
const errorMessage = e instanceof Error ? e.message : t('settings.keyManagement.loading')
|
||||
params.patchState({ error: errorMessage })
|
||||
console.error('[KeyManagement] Error loading keys:', e)
|
||||
} finally {
|
||||
params.patchState({ loading: false })
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleImport(params: {
|
||||
state: KeyManagementManagerState
|
||||
patchState: PatchState
|
||||
}): Promise<void> {
|
||||
const extractedKey = validateAndExtractImportKey({ importKey: params.state.importKey, patchState: params.patchState })
|
||||
if (!extractedKey) {
|
||||
return
|
||||
}
|
||||
if (params.state.accountExists) {
|
||||
params.patchState({ showReplaceWarning: true })
|
||||
return
|
||||
}
|
||||
await performImport({ key: extractedKey, accountExists: false, patchState: params.patchState })
|
||||
}
|
||||
|
||||
export async function confirmReplace(params: {
|
||||
state: KeyManagementManagerState
|
||||
patchState: PatchState
|
||||
}): Promise<void> {
|
||||
const extractedKey = validateAndExtractImportKey({ importKey: params.state.importKey, patchState: params.patchState })
|
||||
if (!extractedKey) {
|
||||
return
|
||||
}
|
||||
await performImport({ key: extractedKey, accountExists: true, patchState: params.patchState })
|
||||
}
|
||||
|
||||
function validateAndExtractImportKey(params: { importKey: string; patchState: PatchState }): string | null {
|
||||
if (!params.importKey.trim()) {
|
||||
params.patchState({ error: t('settings.keyManagement.import.error.required') })
|
||||
return null
|
||||
}
|
||||
const extractedKey = extractKeyFromInput(params.importKey)
|
||||
if (!extractedKey) {
|
||||
params.patchState({ error: t('settings.keyManagement.import.error.invalid') })
|
||||
return null
|
||||
}
|
||||
if (!isValidPrivateKeyFormat(extractedKey)) {
|
||||
params.patchState({ error: t('settings.keyManagement.import.error.invalid') })
|
||||
return null
|
||||
}
|
||||
return extractedKey
|
||||
}
|
||||
|
||||
async function performImport(params: {
|
||||
key: string
|
||||
accountExists: boolean
|
||||
patchState: PatchState
|
||||
}): Promise<void> {
|
||||
try {
|
||||
params.patchState({ importing: true, error: null, showReplaceWarning: false })
|
||||
if (params.accountExists) {
|
||||
await nostrAuthService.deleteAccount()
|
||||
}
|
||||
const result = await nostrAuthService.createAccount(params.key)
|
||||
params.patchState({
|
||||
recoveryPhrase: result.recoveryPhrase,
|
||||
newNpub: result.npub,
|
||||
importKey: '',
|
||||
showImportForm: false,
|
||||
})
|
||||
await loadKeys({ patchState: params.patchState })
|
||||
await maybeStartUserSync({ publicKey: result.publicKey })
|
||||
} catch (e) {
|
||||
const errorMessage = e instanceof Error ? e.message : t('settings.keyManagement.import.error.failed')
|
||||
params.patchState({ error: errorMessage })
|
||||
console.error('[KeyManagement] Error importing key:', e)
|
||||
} finally {
|
||||
params.patchState({ importing: false })
|
||||
}
|
||||
}
|
||||
|
||||
async function maybeStartUserSync(params: { publicKey: string | undefined }): Promise<void> {
|
||||
if (!params.publicKey) {
|
||||
return
|
||||
}
|
||||
const { swClient } = await import('@/lib/swClient')
|
||||
const isReady = await swClient.isReady()
|
||||
if (isReady) {
|
||||
void swClient.startUserSync(params.publicKey)
|
||||
}
|
||||
}
|
||||
|
||||
export async function copyToClipboard(params: {
|
||||
text: string
|
||||
onCopied: () => void
|
||||
onCopyFailed: (error: unknown) => void
|
||||
}): Promise<void> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(params.text)
|
||||
params.onCopied()
|
||||
} catch (e) {
|
||||
params.onCopyFailed(e)
|
||||
}
|
||||
}
|
||||
4
components/keyManagement/types.ts
Normal file
4
components/keyManagement/types.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export interface PublicKeys {
|
||||
publicKey: string
|
||||
npub: string
|
||||
}
|
||||
140
components/keyManagement/useKeyManagementManager.ts
Normal file
140
components/keyManagement/useKeyManagementManager.ts
Normal file
@ -0,0 +1,140 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
INITIAL_KEY_MANAGEMENT_STATE,
|
||||
confirmReplace,
|
||||
copyToClipboard,
|
||||
handleImport,
|
||||
loadKeys,
|
||||
type KeyManagementManagerState,
|
||||
type PatchState,
|
||||
} from './keyManagementController'
|
||||
|
||||
export interface KeyManagementManagerActions {
|
||||
onChangeImportKey: (value: string) => void
|
||||
onShowImportForm: () => void
|
||||
onCancelImport: () => void
|
||||
onImport: () => void
|
||||
onDismissReplaceWarning: () => void
|
||||
onConfirmReplace: () => void
|
||||
onCopyNpub: () => void
|
||||
onCopyPublicKey: () => void
|
||||
onCopyRecoveryPhrase: () => void
|
||||
onDoneRecovery: () => void
|
||||
}
|
||||
|
||||
export interface UseKeyManagementManagerResult {
|
||||
state: KeyManagementManagerState
|
||||
actions: KeyManagementManagerActions
|
||||
}
|
||||
|
||||
export function useKeyManagementManager(): UseKeyManagementManagerResult {
|
||||
const [state, setState] = useState<KeyManagementManagerState>(INITIAL_KEY_MANAGEMENT_STATE)
|
||||
const patchState = useMemo((): PatchState => createPatchState(setState), [])
|
||||
|
||||
useEffect(() => {
|
||||
void loadKeys({ patchState })
|
||||
}, [patchState])
|
||||
|
||||
const actions = useMemo((): KeyManagementManagerActions => createActions({ state, patchState }), [state, patchState])
|
||||
|
||||
return { state, actions }
|
||||
}
|
||||
|
||||
function createPatchState(setState: React.Dispatch<React.SetStateAction<KeyManagementManagerState>>): PatchState {
|
||||
return (patch: Partial<KeyManagementManagerState>): void => {
|
||||
setState((previous) => ({ ...previous, ...patch }))
|
||||
}
|
||||
}
|
||||
|
||||
function createActions(params: { state: KeyManagementManagerState; patchState: PatchState }): KeyManagementManagerActions {
|
||||
return {
|
||||
onChangeImportKey: (value: string): void => {
|
||||
params.patchState({ importKey: value, error: null })
|
||||
},
|
||||
onShowImportForm: (): void => {
|
||||
params.patchState({ showImportForm: true, error: null })
|
||||
},
|
||||
onCancelImport: (): void => {
|
||||
params.patchState({ showImportForm: false, importKey: '', error: null, showReplaceWarning: false })
|
||||
},
|
||||
onImport: (): void => {
|
||||
void handleImport({ state: params.state, patchState: params.patchState })
|
||||
},
|
||||
onDismissReplaceWarning: (): void => {
|
||||
params.patchState({ showReplaceWarning: false })
|
||||
},
|
||||
onConfirmReplace: (): void => {
|
||||
void confirmReplace({ state: params.state, patchState: params.patchState })
|
||||
},
|
||||
onCopyNpub: (): void => {
|
||||
void copyNpub({ state: params.state, patchState: params.patchState })
|
||||
},
|
||||
onCopyPublicKey: (): void => {
|
||||
void copyPublicKey({ state: params.state, patchState: params.patchState })
|
||||
},
|
||||
onCopyRecoveryPhrase: (): void => {
|
||||
void copyRecoveryPhrase({ state: params.state, patchState: params.patchState })
|
||||
},
|
||||
onDoneRecovery: (): void => {
|
||||
params.patchState({ recoveryPhrase: null, newNpub: null })
|
||||
void loadKeys({ patchState: params.patchState })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function copyNpub(params: { state: KeyManagementManagerState; patchState: PatchState }): Promise<void> {
|
||||
const npub = params.state.publicKeys?.npub
|
||||
if (!npub) {
|
||||
return
|
||||
}
|
||||
await copyToClipboard({
|
||||
text: npub,
|
||||
onCopied: () => {
|
||||
params.patchState({ copiedNpub: true })
|
||||
scheduleResetCopiedFlag(() => params.patchState({ copiedNpub: false }))
|
||||
},
|
||||
onCopyFailed: (e) => {
|
||||
console.error('[KeyManagement] Error copying npub:', e)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function copyPublicKey(params: { state: KeyManagementManagerState; patchState: PatchState }): Promise<void> {
|
||||
const publicKey = params.state.publicKeys?.publicKey
|
||||
if (!publicKey) {
|
||||
return
|
||||
}
|
||||
await copyToClipboard({
|
||||
text: publicKey,
|
||||
onCopied: () => {
|
||||
params.patchState({ copiedPublicKey: true })
|
||||
scheduleResetCopiedFlag(() => params.patchState({ copiedPublicKey: false }))
|
||||
},
|
||||
onCopyFailed: (e) => {
|
||||
console.error('[KeyManagement] Error copying public key:', e)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function copyRecoveryPhrase(params: { state: KeyManagementManagerState; patchState: PatchState }): Promise<void> {
|
||||
const {recoveryPhrase} = params.state
|
||||
if (!recoveryPhrase) {
|
||||
return
|
||||
}
|
||||
await copyToClipboard({
|
||||
text: recoveryPhrase.join(' '),
|
||||
onCopied: () => {
|
||||
params.patchState({ copiedRecoveryPhrase: true })
|
||||
scheduleResetCopiedFlag(() => params.patchState({ copiedRecoveryPhrase: false }))
|
||||
},
|
||||
onCopyFailed: (e) => {
|
||||
console.error('[KeyManagement] Error copying recovery phrase:', e)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function scheduleResetCopiedFlag(reset: () => void): void {
|
||||
setTimeout(() => {
|
||||
reset()
|
||||
}, 2000)
|
||||
}
|
||||
158
components/markdownEditorTwoColumns/MarkdownEditorTwoColumns.tsx
Normal file
158
components/markdownEditorTwoColumns/MarkdownEditorTwoColumns.tsx
Normal file
@ -0,0 +1,158 @@
|
||||
import { useState } from 'react'
|
||||
import type { MediaRef, Page } from '@/types/nostr'
|
||||
import { t } from '@/lib/i18n'
|
||||
import { createPagesHandlers, PagesManager } from './PagesManager'
|
||||
import { createImageUploadHandler } from './imageUpload'
|
||||
|
||||
export interface MarkdownEditorTwoColumnsProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
pages?: Page[]
|
||||
onPagesChange?: (pages: Page[]) => void
|
||||
onMediaAdd?: (media: MediaRef) => void
|
||||
onBannerChange?: (url: string) => void
|
||||
}
|
||||
|
||||
export function MarkdownEditorTwoColumns(props: MarkdownEditorTwoColumnsProps): React.ReactElement {
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const pages = props.pages ?? []
|
||||
const pagesHandlers = createPagesHandlers({ pages, onPagesChange: props.onPagesChange })
|
||||
const handleImageUpload = createImageUploadHandler({
|
||||
setError,
|
||||
setUploading,
|
||||
onMediaAdd: props.onMediaAdd,
|
||||
onBannerChange: props.onBannerChange,
|
||||
onSetPageImageUrl: pagesHandlers.setPageContent,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<MarkdownToolbar
|
||||
onFileSelected={(file) => {
|
||||
void handleImageUpload({ file })
|
||||
}}
|
||||
uploading={uploading}
|
||||
error={error}
|
||||
{...(props.onPagesChange ? { onAddPage: pagesHandlers.addPage } : {})}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<EditorColumn value={props.value} onChange={props.onChange} />
|
||||
<PreviewColumn value={props.value} />
|
||||
</div>
|
||||
{props.onPagesChange ? (
|
||||
<PagesManager
|
||||
pages={pages}
|
||||
onPageContentChange={pagesHandlers.setPageContent}
|
||||
onPageTypeChange={pagesHandlers.setPageType}
|
||||
onRemovePage={pagesHandlers.removePage}
|
||||
onImageUpload={async (file, pageNumber) => {
|
||||
await handleImageUpload({ file, pageNumber })
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MarkdownToolbar(params: {
|
||||
onFileSelected: (file: File) => void
|
||||
uploading: boolean
|
||||
error: string | null
|
||||
onAddPage?: (type: 'markdown' | 'image') => void
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<ToolbarUploadButton onFileSelected={params.onFileSelected} />
|
||||
<ToolbarAddPageButtons onAddPage={params.onAddPage} />
|
||||
<ToolbarStatus uploading={params.uploading} error={params.error} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EditorColumn(params: { value: string; onChange: (value: string) => void }): React.ReactElement {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-semibold text-gray-800">{t('markdown.editor')}</label>
|
||||
<textarea
|
||||
className="w-full border rounded p-3 h-96 font-mono text-sm"
|
||||
value={params.value}
|
||||
onChange={(e) => params.onChange(e.target.value)}
|
||||
placeholder={t('markdown.placeholder')}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PreviewColumn(params: { value: string }): React.ReactElement {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-semibold text-gray-800">{t('markdown.preview')}</label>
|
||||
<MarkdownPreview value={params.value} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MarkdownPreview(params: { value: string }): React.ReactElement {
|
||||
return (
|
||||
<div className="prose max-w-none border rounded p-3 bg-white h-96 overflow-y-auto whitespace-pre-wrap">
|
||||
{params.value || <span className="text-gray-400">{t('markdown.preview.empty')}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ToolbarUploadButton(params: { onFileSelected: (file: File) => void }): React.ReactElement {
|
||||
return (
|
||||
<label className="px-3 py-1 text-sm rounded bg-blue-600 text-white cursor-pointer hover:bg-blue-700">
|
||||
{t('markdown.upload.media')}
|
||||
<input
|
||||
type="file"
|
||||
accept=".png,.jpg,.jpeg,.webp"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
params.onFileSelected(file)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function ToolbarAddPageButtons(params: { onAddPage: ((type: 'markdown' | 'image') => void) | undefined }): React.ReactElement | null {
|
||||
if (!params.onAddPage) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="px-3 py-1 text-sm rounded bg-green-600 text-white hover:bg-green-700"
|
||||
onClick={() => params.onAddPage?.('markdown')}
|
||||
>
|
||||
{t('page.add.markdown')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="px-3 py-1 text-sm rounded bg-purple-600 text-white hover:bg-purple-700"
|
||||
onClick={() => params.onAddPage?.('image')}
|
||||
>
|
||||
{t('page.add.image')}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ToolbarStatus(params: { uploading: boolean; error: string | null }): React.ReactElement | null {
|
||||
if (!params.uploading && !params.error) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{params.uploading ? <span className="text-sm text-gray-500">{t('markdown.upload.uploading')}</span> : null}
|
||||
{params.error ? <span className="text-sm text-red-600">{params.error}</span> : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
165
components/markdownEditorTwoColumns/PagesManager.tsx
Normal file
165
components/markdownEditorTwoColumns/PagesManager.tsx
Normal file
@ -0,0 +1,165 @@
|
||||
import { t } from '@/lib/i18n'
|
||||
import type { Page } from '@/types/nostr'
|
||||
|
||||
export function PagesManager(params: {
|
||||
pages: Page[]
|
||||
onPageContentChange: (pageNumber: number, content: string) => void
|
||||
onPageTypeChange: (pageNumber: number, type: 'markdown' | 'image') => void
|
||||
onRemovePage: (pageNumber: number) => void
|
||||
onImageUpload: (file: File, pageNumber: number) => Promise<void>
|
||||
}): React.ReactElement {
|
||||
if (params.pages.length === 0) {
|
||||
return <div className="text-sm text-gray-500">{t('page.empty')}</div>
|
||||
}
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">{t('page.title')}</h3>
|
||||
{params.pages.map((page) => (
|
||||
<PageEditor
|
||||
key={page.number}
|
||||
page={page}
|
||||
onContentChange={(content) => params.onPageContentChange(page.number, content)}
|
||||
onTypeChange={(type) => params.onPageTypeChange(page.number, type)}
|
||||
onRemove={() => params.onRemovePage(page.number)}
|
||||
onImageUpload={async (file) => {
|
||||
await params.onImageUpload(file, page.number)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PageEditor(params: {
|
||||
page: Page
|
||||
onContentChange: (content: string) => void
|
||||
onTypeChange: (type: 'markdown' | 'image') => void
|
||||
onRemove: () => void
|
||||
onImageUpload: (file: File) => Promise<void>
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div className="border rounded-lg p-4 space-y-3">
|
||||
<PageEditorHeader page={params.page} onTypeChange={params.onTypeChange} onRemove={params.onRemove} />
|
||||
<PageEditorBody page={params.page} onContentChange={params.onContentChange} onImageUpload={params.onImageUpload} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PageEditorHeader(params: { page: Page; onTypeChange: (type: 'markdown' | 'image') => void; onRemove: () => void }): React.ReactElement {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-semibold">
|
||||
{t('page.number', { number: params.page.number })} - {t(`page.type.${params.page.type}`)}
|
||||
</h4>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={params.page.type}
|
||||
onChange={(e) => params.onTypeChange(e.target.value as 'markdown' | 'image')}
|
||||
className="text-sm border rounded px-2 py-1"
|
||||
>
|
||||
<option value="markdown">{t('page.type.markdown')}</option>
|
||||
<option value="image">{t('page.type.image')}</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className="px-2 py-1 text-sm rounded bg-red-600 text-white hover:bg-red-700"
|
||||
onClick={params.onRemove}
|
||||
>
|
||||
{t('page.remove')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PageEditorBody(params: { page: Page; onContentChange: (content: string) => void; onImageUpload: (file: File) => Promise<void> }): React.ReactElement {
|
||||
if (params.page.type === 'markdown') {
|
||||
return (
|
||||
<textarea
|
||||
className="w-full border rounded p-2 h-48 font-mono text-sm"
|
||||
value={params.page.content}
|
||||
onChange={(e) => params.onContentChange(e.target.value)}
|
||||
placeholder={t('page.markdown.placeholder')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return <PageEditorImageBody page={params.page} onContentChange={params.onContentChange} onImageUpload={params.onImageUpload} />
|
||||
}
|
||||
|
||||
function PageEditorImageBody(params: { page: Page; onContentChange: (content: string) => void; onImageUpload: (file: File) => Promise<void> }): React.ReactElement {
|
||||
if (params.page.content) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<img src={params.page.content} alt={t('page.image.alt', { number: params.page.number })} className="max-w-full h-auto rounded" />
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-2 right-2 px-2 py-1 text-sm rounded bg-red-600 text-white hover:bg-red-700"
|
||||
onClick={() => params.onContentChange('')}
|
||||
>
|
||||
{t('page.image.remove')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <PageImageUploadButton onFileSelected={params.onImageUpload} />
|
||||
}
|
||||
|
||||
function PageImageUploadButton(params: { onFileSelected: (file: File) => Promise<void> }): React.ReactElement {
|
||||
return (
|
||||
<label className="block px-3 py-2 text-sm rounded bg-blue-600 text-white cursor-pointer hover:bg-blue-700 text-center">
|
||||
{t('page.image.upload')}
|
||||
<input
|
||||
type="file"
|
||||
accept=".png,.jpg,.jpeg,.webp"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
void params.onFileSelected(file)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
export function createPagesHandlers(params: {
|
||||
pages: Page[]
|
||||
onPagesChange: ((pages: Page[]) => void) | undefined
|
||||
}): {
|
||||
addPage: (type: 'markdown' | 'image') => void
|
||||
setPageContent: (pageNumber: number, content: string) => void
|
||||
setPageType: (pageNumber: number, type: 'markdown' | 'image') => void
|
||||
removePage: (pageNumber: number) => void
|
||||
} {
|
||||
const update = (next: Page[]): void => {
|
||||
params.onPagesChange?.(next)
|
||||
}
|
||||
return {
|
||||
addPage: (type: 'markdown' | 'image'): void => {
|
||||
if (!params.onPagesChange) {
|
||||
return
|
||||
}
|
||||
const newPage: Page = { number: params.pages.length + 1, type, content: '' }
|
||||
update([...params.pages, newPage])
|
||||
},
|
||||
setPageContent: (pageNumber: number, content: string): void => {
|
||||
if (!params.onPagesChange) {
|
||||
return
|
||||
}
|
||||
update(params.pages.map((p) => (p.number === pageNumber ? { ...p, content } : p)))
|
||||
},
|
||||
setPageType: (pageNumber: number, type: 'markdown' | 'image'): void => {
|
||||
if (!params.onPagesChange) {
|
||||
return
|
||||
}
|
||||
update(params.pages.map((p) => (p.number === pageNumber ? { ...p, type, content: '' } : p)))
|
||||
},
|
||||
removePage: (pageNumber: number): void => {
|
||||
if (!params.onPagesChange) {
|
||||
return
|
||||
}
|
||||
update(params.pages.filter((p) => p.number !== pageNumber).map((p, idx) => ({ ...p, number: idx + 1 })))
|
||||
},
|
||||
}
|
||||
}
|
||||
32
components/markdownEditorTwoColumns/imageUpload.ts
Normal file
32
components/markdownEditorTwoColumns/imageUpload.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import type { MediaRef } from '@/types/nostr'
|
||||
import { uploadNip95Media } from '@/lib/nip95'
|
||||
import { t } from '@/lib/i18n'
|
||||
|
||||
export function createImageUploadHandler(params: {
|
||||
setError: (value: string | null) => void
|
||||
setUploading: (value: boolean) => void
|
||||
onMediaAdd: ((media: MediaRef) => void) | undefined
|
||||
onBannerChange: ((url: string) => void) | undefined
|
||||
onSetPageImageUrl: (pageNumber: number, url: string) => void
|
||||
}): (args: { file: File; pageNumber?: number }) => Promise<void> {
|
||||
return async (args): Promise<void> => {
|
||||
params.setError(null)
|
||||
params.setUploading(true)
|
||||
try {
|
||||
const media = await uploadNip95Media(args.file)
|
||||
if (media.type !== 'image') {
|
||||
return
|
||||
}
|
||||
if (args.pageNumber !== undefined) {
|
||||
params.onSetPageImageUrl(args.pageNumber, media.url)
|
||||
} else {
|
||||
params.onBannerChange?.(media.url)
|
||||
}
|
||||
params.onMediaAdd?.(media)
|
||||
} catch (e) {
|
||||
params.setError(e instanceof Error ? e.message : t('upload.error.failed'))
|
||||
} finally {
|
||||
params.setUploading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
155
components/nip95Config/Nip95ApiCard.tsx
Normal file
155
components/nip95Config/Nip95ApiCard.tsx
Normal file
@ -0,0 +1,155 @@
|
||||
import type { Nip95Config } from '@/lib/configStorageTypes'
|
||||
import { t } from '@/lib/i18n'
|
||||
import { DragHandle } from '../DragHandle'
|
||||
import { getApiCardClassName } from './getApiCardClassName'
|
||||
|
||||
export function Nip95ApiCard(params: {
|
||||
api: Nip95Config
|
||||
priority: number
|
||||
isEditing: boolean
|
||||
draggedId: string | null
|
||||
dragOverId: string | null
|
||||
onStartEditing: (id: string) => void
|
||||
onStopEditing: () => void
|
||||
onUpdateUrl: (id: string, url: string) => void
|
||||
onToggleEnabled: (id: string, enabled: boolean) => void
|
||||
onRemoveApi: (id: string) => void
|
||||
onDragStart: (e: React.DragEvent<HTMLDivElement>, id: string) => void
|
||||
onDragOver: (e: React.DragEvent<HTMLDivElement>, id: string) => void
|
||||
onDragLeave: () => void
|
||||
onDrop: (e: React.DragEvent<HTMLDivElement>, targetId: string) => void
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div
|
||||
onDragOver={(e) => params.onDragOver(e, params.api.id)}
|
||||
onDragLeave={params.onDragLeave}
|
||||
onDrop={(e) => params.onDrop(e, params.api.id)}
|
||||
className={`bg-cyber-dark border rounded p-4 space-y-3 transition-all ${getApiCardClassName(params.api.id, params.draggedId, params.dragOverId)}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<DragGrip apiId={params.api.id} onDragStart={params.onDragStart} />
|
||||
<UrlCell
|
||||
api={params.api}
|
||||
isEditing={params.isEditing}
|
||||
onStartEditing={params.onStartEditing}
|
||||
onStopEditing={params.onStopEditing}
|
||||
onUpdateUrl={params.onUpdateUrl}
|
||||
/>
|
||||
</div>
|
||||
<ActionsCell api={params.api} onToggleEnabled={params.onToggleEnabled} onRemoveApi={params.onRemoveApi} />
|
||||
</div>
|
||||
<PriorityRow priority={params.priority} apiId={params.api.id} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DragGrip(params: { apiId: string; onDragStart: (e: React.DragEvent<HTMLDivElement>, id: string) => void }): React.ReactElement {
|
||||
return (
|
||||
<div
|
||||
className="drag-handle cursor-grab active:cursor-grabbing"
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
params.onDragStart(e, params.apiId)
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DragHandle />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function UrlCell(params: {
|
||||
api: Nip95Config
|
||||
isEditing: boolean
|
||||
onStartEditing: (id: string) => void
|
||||
onStopEditing: () => void
|
||||
onUpdateUrl: (id: string, url: string) => void
|
||||
}): React.ReactElement {
|
||||
return <div className="flex-1">{params.isEditing ? <UrlEditor api={params.api} onStop={params.onStopEditing} onUpdate={params.onUpdateUrl} /> : <UrlText api={params.api} onStartEditing={params.onStartEditing} />}</div>
|
||||
}
|
||||
|
||||
function UrlText(params: { api: Nip95Config; onStartEditing: (id: string) => void }): React.ReactElement {
|
||||
return (
|
||||
<div
|
||||
className="text-neon-cyan cursor-pointer hover:text-neon-green transition-colors"
|
||||
onClick={() => params.onStartEditing(params.api.id)}
|
||||
title={t('settings.nip95.list.editUrl')}
|
||||
>
|
||||
{params.api.url}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function UrlEditor(params: { api: Nip95Config; onStop: () => void; onUpdate: (id: string, url: string) => void }): React.ReactElement {
|
||||
return (
|
||||
<input
|
||||
type="url"
|
||||
defaultValue={params.api.url}
|
||||
onBlur={(e) => {
|
||||
const next = e.target.value
|
||||
if (next !== params.api.url) {
|
||||
params.onUpdate(params.api.id, next)
|
||||
} else {
|
||||
params.onStop()
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.currentTarget.blur()
|
||||
} else if (e.key === 'Escape') {
|
||||
params.onStop()
|
||||
}
|
||||
}}
|
||||
className="w-full px-3 py-2 bg-cyber-darker border border-neon-cyan/50 rounded text-cyber-light focus:border-neon-cyan focus:outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ActionsCell(params: { api: Nip95Config; onToggleEnabled: (id: string, enabled: boolean) => void; onRemoveApi: (id: string) => void }): React.ReactElement {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<EnabledToggle api={params.api} onToggleEnabled={params.onToggleEnabled} />
|
||||
<RemoveButton apiId={params.api.id} onRemove={params.onRemoveApi} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EnabledToggle(params: { api: Nip95Config; onToggleEnabled: (id: string, enabled: boolean) => void }): React.ReactElement {
|
||||
return (
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={params.api.enabled}
|
||||
onChange={(e) => params.onToggleEnabled(params.api.id, e.target.checked)}
|
||||
className="w-4 h-4 text-neon-cyan bg-cyber-darker border-cyber-accent rounded focus:ring-neon-cyan"
|
||||
/>
|
||||
<span className="text-sm text-cyber-accent">
|
||||
{params.api.enabled ? t('settings.nip95.list.enabled') : t('settings.nip95.list.disabled')}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function RemoveButton(params: { apiId: string; onRemove: (id: string) => void }): React.ReactElement {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => params.onRemove(params.apiId)}
|
||||
className="px-3 py-1 text-sm bg-red-900/30 hover:bg-red-900/50 text-red-300 border border-red-500/50 rounded transition-colors"
|
||||
title={t('settings.nip95.list.remove')}
|
||||
>
|
||||
{t('settings.nip95.list.remove')}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function PriorityRow(params: { priority: number; apiId: string }): React.ReactElement {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs text-cyber-accent/70">
|
||||
<span>{t('settings.nip95.list.priorityLabel', { priority: params.priority, id: params.apiId })}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
30
components/nip95Config/Nip95ApiList.tsx
Normal file
30
components/nip95Config/Nip95ApiList.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import type { Nip95Config } from '@/lib/configStorageTypes'
|
||||
import { t } from '@/lib/i18n'
|
||||
import { Nip95ApiCard } from './Nip95ApiCard'
|
||||
|
||||
export function Nip95ApiList(params: {
|
||||
apis: Nip95Config[]
|
||||
editingId: string | null
|
||||
draggedId: string | null
|
||||
dragOverId: string | null
|
||||
onStartEditing: (id: string) => void
|
||||
onStopEditing: () => void
|
||||
onUpdateUrl: (id: string, url: string) => void
|
||||
onToggleEnabled: (id: string, enabled: boolean) => void
|
||||
onRemoveApi: (id: string) => void
|
||||
onDragStart: (e: React.DragEvent<HTMLDivElement>, id: string) => void
|
||||
onDragOver: (e: React.DragEvent<HTMLDivElement>, id: string) => void
|
||||
onDragLeave: () => void
|
||||
onDrop: (e: React.DragEvent<HTMLDivElement>, targetId: string) => void
|
||||
}): React.ReactElement {
|
||||
if (params.apis.length === 0) {
|
||||
return <div className="text-center py-8 text-cyber-accent">{t('settings.nip95.empty')}</div>
|
||||
}
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{params.apis.map((api, index) => (
|
||||
<Nip95ApiCard key={api.id} api={api} priority={index + 1} isEditing={params.editingId === api.id} draggedId={params.draggedId} dragOverId={params.dragOverId} onStartEditing={params.onStartEditing} onStopEditing={params.onStopEditing} onUpdateUrl={params.onUpdateUrl} onToggleEnabled={params.onToggleEnabled} onRemoveApi={params.onRemoveApi} onDragStart={params.onDragStart} onDragOver={params.onDragOver} onDragLeave={params.onDragLeave} onDrop={params.onDrop} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
107
components/nip95Config/Nip95ConfigContent.tsx
Normal file
107
components/nip95Config/Nip95ConfigContent.tsx
Normal file
@ -0,0 +1,107 @@
|
||||
import type { Nip95Config } from '@/lib/configStorageTypes'
|
||||
import { t } from '@/lib/i18n'
|
||||
import { Nip95ApiList } from './Nip95ApiList'
|
||||
|
||||
export function Nip95ConfigContent(params: {
|
||||
apis: Nip95Config[]
|
||||
error: string | null
|
||||
editingId: string | null
|
||||
newUrl: string
|
||||
showAddForm: boolean
|
||||
draggedId: string | null
|
||||
dragOverId: string | null
|
||||
onClearError: () => void
|
||||
onToggleAddForm: () => void
|
||||
onNewUrlChange: (value: string) => void
|
||||
onCancelAdd: () => void
|
||||
onAddApi: () => void
|
||||
onStartEditing: (id: string) => void
|
||||
onStopEditing: () => void
|
||||
onUpdateUrl: (id: string, url: string) => void
|
||||
onToggleEnabled: (id: string, enabled: boolean) => void
|
||||
onRemoveApi: (id: string) => void
|
||||
onDragStart: (e: React.DragEvent<HTMLDivElement>, id: string) => void
|
||||
onDragOver: (e: React.DragEvent<HTMLDivElement>, id: string) => void
|
||||
onDragLeave: () => void
|
||||
onDrop: (e: React.DragEvent<HTMLDivElement>, targetId: string) => void
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{params.error ? <ErrorBanner error={params.error} onClear={params.onClearError} /> : null}
|
||||
<Header showAddForm={params.showAddForm} onToggleAddForm={params.onToggleAddForm} />
|
||||
{params.showAddForm ? <AddForm newUrl={params.newUrl} onNewUrlChange={params.onNewUrlChange} onAdd={params.onAddApi} onCancel={params.onCancelAdd} /> : null}
|
||||
<Nip95ApiList apis={params.apis} editingId={params.editingId} draggedId={params.draggedId} dragOverId={params.dragOverId} onStartEditing={params.onStartEditing} onStopEditing={params.onStopEditing} onUpdateUrl={params.onUpdateUrl} onToggleEnabled={params.onToggleEnabled} onRemoveApi={params.onRemoveApi} onDragStart={params.onDragStart} onDragOver={params.onDragOver} onDragLeave={params.onDragLeave} onDrop={params.onDrop} />
|
||||
<Notes />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ErrorBanner(params: { error: string; onClear: () => void }): React.ReactElement {
|
||||
return (
|
||||
<div className="bg-red-900/30 border border-red-500/50 rounded p-4 text-red-300">
|
||||
{params.error}
|
||||
<button type="button" onClick={params.onClear} className="ml-4 text-red-400 hover:text-red-200">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Header(params: { showAddForm: boolean; onToggleAddForm: () => void }): React.ReactElement {
|
||||
return (
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-2xl font-bold text-neon-cyan">{t('settings.nip95.title')}</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={params.onToggleAddForm}
|
||||
className="px-4 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan border border-neon-cyan/50 rounded transition-colors"
|
||||
>
|
||||
{params.showAddForm ? t('settings.nip95.add.cancel') : t('settings.nip95.addButton')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AddForm(params: { newUrl: string; onNewUrlChange: (value: string) => void; onAdd: () => void; onCancel: () => void }): React.ReactElement {
|
||||
return (
|
||||
<div className="bg-cyber-dark border border-neon-cyan/30 rounded p-4 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-cyber-accent mb-2">{t('settings.nip95.add.url')}</label>
|
||||
<input
|
||||
type="url"
|
||||
value={params.newUrl}
|
||||
onChange={(e) => params.onNewUrlChange(e.target.value)}
|
||||
placeholder={t('settings.nip95.add.placeholder')}
|
||||
className="w-full px-4 py-2 bg-cyber-darker border border-cyber-accent/30 rounded text-cyber-light focus:border-neon-cyan focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={params.onAdd}
|
||||
className="px-4 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan border border-neon-cyan/50 rounded transition-colors"
|
||||
>
|
||||
{t('settings.nip95.add.add')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={params.onCancel}
|
||||
className="px-4 py-2 bg-cyber-accent/20 hover:bg-cyber-accent/30 text-cyber-accent border border-cyber-accent/50 rounded transition-colors"
|
||||
>
|
||||
{t('settings.nip95.add.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Notes(): React.ReactElement {
|
||||
return (
|
||||
<div className="text-sm text-cyber-accent space-y-2">
|
||||
<p>
|
||||
<strong>{t('settings.nip95.note.title')}</strong> {t('settings.nip95.note.priority')}
|
||||
</p>
|
||||
<p>{t('settings.nip95.note.fallback')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
45
components/nip95Config/Nip95ConfigManager.tsx
Normal file
45
components/nip95Config/Nip95ConfigManager.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { Nip95Config } from '@/lib/configStorageTypes'
|
||||
import { Nip95ConfigView } from './view'
|
||||
import { loadApis } from './controller'
|
||||
import { createNip95ConfigViewProps } from './viewModel'
|
||||
|
||||
export interface Nip95ConfigManagerProps {
|
||||
onConfigChange?: () => void
|
||||
}
|
||||
|
||||
export function Nip95ConfigManager(props: Nip95ConfigManagerProps): React.ReactElement {
|
||||
const [apis, setApis] = useState<Nip95Config[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [newUrl, setNewUrl] = useState('')
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
const [draggedId, setDraggedId] = useState<string | null>(null)
|
||||
const [dragOverId, setDragOverId] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
void loadApis({ setApis, setLoading, setError })
|
||||
}, [])
|
||||
|
||||
const viewProps = createNip95ConfigViewProps({
|
||||
apis,
|
||||
loading,
|
||||
error,
|
||||
editingId,
|
||||
newUrl,
|
||||
showAddForm,
|
||||
draggedId,
|
||||
dragOverId,
|
||||
setApis,
|
||||
setError,
|
||||
setEditingId,
|
||||
setNewUrl,
|
||||
setShowAddForm,
|
||||
setDraggedId,
|
||||
setDragOverId,
|
||||
onConfigChange: props.onConfigChange,
|
||||
})
|
||||
|
||||
return <Nip95ConfigView {...viewProps} />
|
||||
}
|
||||
37
components/nip95Config/Nip95ConfigView.tsx
Normal file
37
components/nip95Config/Nip95ConfigView.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import type { Nip95Config } from '@/lib/configStorageTypes'
|
||||
import { t } from '@/lib/i18n'
|
||||
import { Nip95ConfigContent } from './Nip95ConfigContent'
|
||||
|
||||
export function Nip95ConfigView(params: {
|
||||
apis: Nip95Config[]
|
||||
loading: boolean
|
||||
error: string | null
|
||||
editingId: string | null
|
||||
newUrl: string
|
||||
showAddForm: boolean
|
||||
draggedId: string | null
|
||||
dragOverId: string | null
|
||||
onClearError: () => void
|
||||
onToggleAddForm: () => void
|
||||
onNewUrlChange: (value: string) => void
|
||||
onCancelAdd: () => void
|
||||
onAddApi: () => void
|
||||
onStartEditing: (id: string) => void
|
||||
onStopEditing: () => void
|
||||
onUpdateUrl: (id: string, url: string) => void
|
||||
onToggleEnabled: (id: string, enabled: boolean) => void
|
||||
onRemoveApi: (id: string) => void
|
||||
onDragStart: (e: React.DragEvent<HTMLDivElement>, id: string) => void
|
||||
onDragOver: (e: React.DragEvent<HTMLDivElement>, id: string) => void
|
||||
onDragLeave: () => void
|
||||
onDrop: (e: React.DragEvent<HTMLDivElement>, targetId: string) => void
|
||||
}): React.ReactElement {
|
||||
if (params.loading) {
|
||||
return (
|
||||
<div className="text-center py-8 text-neon-cyan">
|
||||
<div>{t('settings.nip95.loading')}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <Nip95ConfigContent {...params} />
|
||||
}
|
||||
212
components/nip95Config/controller.ts
Normal file
212
components/nip95Config/controller.ts
Normal file
@ -0,0 +1,212 @@
|
||||
import { configStorage } from '@/lib/configStorage'
|
||||
import type { Nip95Config } from '@/lib/configStorageTypes'
|
||||
import { t } from '@/lib/i18n'
|
||||
import { userConfirm } from '@/lib/userConfirm'
|
||||
|
||||
export async function loadApis(params: {
|
||||
setApis: (value: Nip95Config[]) => void
|
||||
setLoading: (value: boolean) => void
|
||||
setError: (value: string | null) => void
|
||||
}): Promise<void> {
|
||||
try {
|
||||
params.setLoading(true)
|
||||
params.setError(null)
|
||||
const config = await configStorage.getConfig()
|
||||
params.setApis(config.nip95Apis.sort((a, b) => a.priority - b.priority))
|
||||
} catch (e) {
|
||||
const errorMessage = e instanceof Error ? e.message : t('settings.nip95.error.loadFailed')
|
||||
params.setError(errorMessage)
|
||||
console.error('[Nip95Config] Error loading APIs:', e)
|
||||
} finally {
|
||||
params.setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
export async function toggleEnabled(params: {
|
||||
id: string
|
||||
enabled: boolean
|
||||
setApis: (value: Nip95Config[]) => void
|
||||
setError: (value: string | null) => void
|
||||
onConfigChange: (() => void) | undefined
|
||||
}): Promise<void> {
|
||||
try {
|
||||
await configStorage.updateNip95Api(params.id, { enabled: params.enabled })
|
||||
await reloadApis({ setApis: params.setApis, setError: params.setError })
|
||||
params.onConfigChange?.()
|
||||
} catch (e) {
|
||||
const errorMessage = e instanceof Error ? e.message : t('settings.nip95.error.updateFailed')
|
||||
params.setError(errorMessage)
|
||||
console.error('[Nip95Config] Error updating API:', e)
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateUrl(params: {
|
||||
id: string
|
||||
url: string
|
||||
setApis: (value: Nip95Config[]) => void
|
||||
setError: (value: string | null) => void
|
||||
setEditingId: (value: string | null) => void
|
||||
onConfigChange: (() => void) | undefined
|
||||
}): Promise<void> {
|
||||
try {
|
||||
await configStorage.updateNip95Api(params.id, { url: params.url })
|
||||
await reloadApis({ setApis: params.setApis, setError: params.setError })
|
||||
params.setEditingId(null)
|
||||
params.onConfigChange?.()
|
||||
} catch (e) {
|
||||
const errorMessage = e instanceof Error ? e.message : t('settings.nip95.error.urlFailed')
|
||||
params.setError(errorMessage)
|
||||
console.error('[Nip95Config] Error updating URL:', e)
|
||||
}
|
||||
}
|
||||
|
||||
export async function addApi(params: {
|
||||
newUrl: string
|
||||
setError: (value: string | null) => void
|
||||
setNewUrl: (value: string) => void
|
||||
setShowAddForm: (value: boolean | ((prev: boolean) => boolean)) => void
|
||||
setApis: (value: Nip95Config[]) => void
|
||||
onConfigChange: (() => void) | undefined
|
||||
}): Promise<void> {
|
||||
if (!params.newUrl.trim()) {
|
||||
params.setError(t('settings.nip95.error.urlRequired'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
void new URL(params.newUrl)
|
||||
await configStorage.addNip95Api(params.newUrl.trim(), false)
|
||||
params.setNewUrl('')
|
||||
params.setShowAddForm(false)
|
||||
await reloadApis({ setApis: params.setApis, setError: params.setError })
|
||||
params.onConfigChange?.()
|
||||
} catch (e) {
|
||||
params.setError(getAddApiErrorMessage(e))
|
||||
console.error('[Nip95Config] Error adding API:', e)
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeApi(params: {
|
||||
id: string
|
||||
setApis: (value: Nip95Config[]) => void
|
||||
setError: (value: string | null) => void
|
||||
onConfigChange: (() => void) | undefined
|
||||
}): Promise<void> {
|
||||
const confirmed = await userConfirm(t('settings.nip95.remove.confirm'))
|
||||
if (!confirmed) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await configStorage.removeNip95Api(params.id)
|
||||
await reloadApis({ setApis: params.setApis, setError: params.setError })
|
||||
params.onConfigChange?.()
|
||||
} catch (e) {
|
||||
const errorMessage = e instanceof Error ? e.message : t('settings.nip95.error.removeFailed')
|
||||
params.setError(errorMessage)
|
||||
console.error('[Nip95Config] Error removing API:', e)
|
||||
}
|
||||
}
|
||||
|
||||
export function onDragStart(params: {
|
||||
e: React.DragEvent<HTMLDivElement>
|
||||
id: string
|
||||
setDraggedId: (value: string | null) => void
|
||||
}): void {
|
||||
params.setDraggedId(params.id)
|
||||
const { dataTransfer } = params.e
|
||||
dataTransfer.effectAllowed = 'move'
|
||||
dataTransfer.setData('text/plain', params.id)
|
||||
}
|
||||
|
||||
export function onDragOver(params: {
|
||||
e: React.DragEvent<HTMLDivElement>
|
||||
id: string
|
||||
setDragOverId: (value: string | null) => void
|
||||
}): void {
|
||||
params.e.preventDefault()
|
||||
const { dataTransfer } = params.e
|
||||
dataTransfer.dropEffect = 'move'
|
||||
params.setDragOverId(params.id)
|
||||
}
|
||||
|
||||
export async function onDrop(params: {
|
||||
e: React.DragEvent<HTMLDivElement>
|
||||
targetId: string
|
||||
apis: Nip95Config[]
|
||||
draggedId: string | null
|
||||
setApis: (value: Nip95Config[]) => void
|
||||
setDraggedId: (value: string | null) => void
|
||||
setDragOverId: (value: string | null) => void
|
||||
setError: (value: string | null) => void
|
||||
onConfigChange: (() => void) | undefined
|
||||
}): Promise<void> {
|
||||
params.e.preventDefault()
|
||||
params.setDragOverId(null)
|
||||
if (!params.draggedId || params.draggedId === params.targetId) {
|
||||
params.setDraggedId(null)
|
||||
return
|
||||
}
|
||||
const reordered = reorderApis({ apis: params.apis, draggedId: params.draggedId, targetId: params.targetId })
|
||||
if (!reordered) {
|
||||
params.setDraggedId(null)
|
||||
return
|
||||
}
|
||||
params.setApis(reordered)
|
||||
params.setDraggedId(null)
|
||||
await updatePriorities({ apis: reordered, setError: params.setError, onConfigChange: params.onConfigChange })
|
||||
}
|
||||
|
||||
async function reloadApis(params: { setApis: (value: Nip95Config[]) => void; setError: (value: string | null) => void }): Promise<void> {
|
||||
try {
|
||||
const config = await configStorage.getConfig()
|
||||
params.setApis(config.nip95Apis.sort((a, b) => a.priority - b.priority))
|
||||
} catch (e) {
|
||||
const errorMessage = e instanceof Error ? e.message : t('settings.nip95.error.loadFailed')
|
||||
params.setError(errorMessage)
|
||||
console.error('[Nip95Config] Error reloading APIs:', e)
|
||||
}
|
||||
}
|
||||
|
||||
function getAddApiErrorMessage(error: unknown): string {
|
||||
if (error instanceof TypeError && error.message.includes('Invalid URL')) {
|
||||
return t('settings.nip95.error.invalidUrl')
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message
|
||||
}
|
||||
return t('settings.nip95.error.addFailed')
|
||||
}
|
||||
|
||||
function reorderApis(params: { apis: Nip95Config[]; draggedId: string; targetId: string }): Nip95Config[] | null {
|
||||
const draggedIndex = params.apis.findIndex((api) => api.id === params.draggedId)
|
||||
const targetIndex = params.apis.findIndex((api) => api.id === params.targetId)
|
||||
if (draggedIndex === -1 || targetIndex === -1) {
|
||||
return null
|
||||
}
|
||||
const next = [...params.apis]
|
||||
const removed = next[draggedIndex]
|
||||
if (!removed) {
|
||||
return null
|
||||
}
|
||||
next.splice(draggedIndex, 1)
|
||||
next.splice(targetIndex, 0, removed)
|
||||
return next
|
||||
}
|
||||
|
||||
async function updatePriorities(params: {
|
||||
apis: Nip95Config[]
|
||||
setError: (value: string | null) => void
|
||||
onConfigChange: (() => void) | undefined
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const updates = params.apis.map((api, index) => {
|
||||
const priority = index + 1
|
||||
return api.priority !== priority ? configStorage.updateNip95Api(api.id, { priority }) : Promise.resolve()
|
||||
})
|
||||
await Promise.all(updates)
|
||||
params.onConfigChange?.()
|
||||
} catch (e) {
|
||||
const errorMessage = e instanceof Error ? e.message : t('settings.nip95.error.priorityFailed')
|
||||
params.setError(errorMessage)
|
||||
console.error('[Nip95Config] Error updating priorities:', e)
|
||||
}
|
||||
}
|
||||
9
components/nip95Config/getApiCardClassName.ts
Normal file
9
components/nip95Config/getApiCardClassName.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export function getApiCardClassName(apiId: string, draggedId: string | null, dragOverId: string | null): string {
|
||||
if (draggedId === apiId) {
|
||||
return 'opacity-50 border-neon-cyan'
|
||||
}
|
||||
if (dragOverId === apiId) {
|
||||
return 'border-neon-green shadow-lg'
|
||||
}
|
||||
return 'border-neon-cyan/30'
|
||||
}
|
||||
1
components/nip95Config/view.tsx
Normal file
1
components/nip95Config/view.tsx
Normal file
@ -0,0 +1 @@
|
||||
export { Nip95ConfigView } from './Nip95ConfigView'
|
||||
121
components/nip95Config/viewModel.ts
Normal file
121
components/nip95Config/viewModel.ts
Normal file
@ -0,0 +1,121 @@
|
||||
import type { Nip95Config } from '@/lib/configStorageTypes'
|
||||
import { Nip95ConfigView } from './view'
|
||||
import { addApi, onDragOver, onDragStart, onDrop, removeApi, toggleEnabled, updateUrl } from './controller'
|
||||
|
||||
type ViewProps = Parameters<typeof Nip95ConfigView>[0]
|
||||
|
||||
export function createNip95ConfigViewProps(params: {
|
||||
apis: Nip95Config[]
|
||||
loading: boolean
|
||||
error: string | null
|
||||
editingId: string | null
|
||||
newUrl: string
|
||||
showAddForm: boolean
|
||||
draggedId: string | null
|
||||
dragOverId: string | null
|
||||
setApis: (value: Nip95Config[]) => void
|
||||
setError: (value: string | null) => void
|
||||
setEditingId: (value: string | null) => void
|
||||
setNewUrl: (value: string) => void
|
||||
setShowAddForm: (value: boolean | ((prev: boolean) => boolean)) => void
|
||||
setDraggedId: (value: string | null) => void
|
||||
setDragOverId: (value: string | null) => void
|
||||
onConfigChange: (() => void) | undefined
|
||||
}): ViewProps {
|
||||
return { ...baseStateProps(params), ...formHandlers(params), ...apiHandlers(params), ...dragHandlers(params) }
|
||||
}
|
||||
|
||||
function baseStateProps(params: {
|
||||
apis: Nip95Config[]
|
||||
loading: boolean
|
||||
error: string | null
|
||||
editingId: string | null
|
||||
newUrl: string
|
||||
showAddForm: boolean
|
||||
draggedId: string | null
|
||||
dragOverId: string | null
|
||||
}): Pick<ViewProps, 'apis' | 'loading' | 'error' | 'editingId' | 'newUrl' | 'showAddForm' | 'draggedId' | 'dragOverId'> {
|
||||
return {
|
||||
apis: params.apis,
|
||||
loading: params.loading,
|
||||
error: params.error,
|
||||
editingId: params.editingId,
|
||||
newUrl: params.newUrl,
|
||||
showAddForm: params.showAddForm,
|
||||
draggedId: params.draggedId,
|
||||
dragOverId: params.dragOverId,
|
||||
}
|
||||
}
|
||||
|
||||
function formHandlers(params: {
|
||||
newUrl: string
|
||||
setApis: (value: Nip95Config[]) => void
|
||||
setError: (value: string | null) => void
|
||||
setNewUrl: (value: string) => void
|
||||
setShowAddForm: (value: boolean | ((prev: boolean) => boolean)) => void
|
||||
onConfigChange: (() => void) | undefined
|
||||
}): Pick<ViewProps, 'onClearError' | 'onToggleAddForm' | 'onNewUrlChange' | 'onCancelAdd' | 'onAddApi'> {
|
||||
return {
|
||||
onClearError: () => params.setError(null),
|
||||
onToggleAddForm: () => params.setShowAddForm((prev) => !prev),
|
||||
onNewUrlChange: (value) => params.setNewUrl(value),
|
||||
onCancelAdd: () => {
|
||||
params.setShowAddForm(false)
|
||||
params.setNewUrl('')
|
||||
params.setError(null)
|
||||
},
|
||||
onAddApi: () =>
|
||||
void addApi({
|
||||
newUrl: params.newUrl,
|
||||
setError: params.setError,
|
||||
setNewUrl: params.setNewUrl,
|
||||
setShowAddForm: params.setShowAddForm,
|
||||
setApis: params.setApis,
|
||||
onConfigChange: params.onConfigChange,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
function apiHandlers(params: {
|
||||
setApis: (value: Nip95Config[]) => void
|
||||
setError: (value: string | null) => void
|
||||
setEditingId: (value: string | null) => void
|
||||
onConfigChange: (() => void) | undefined
|
||||
}): Pick<ViewProps, 'onStartEditing' | 'onStopEditing' | 'onUpdateUrl' | 'onToggleEnabled' | 'onRemoveApi'> {
|
||||
return {
|
||||
onStartEditing: (id) => params.setEditingId(id),
|
||||
onStopEditing: () => params.setEditingId(null),
|
||||
onUpdateUrl: (id, url) =>
|
||||
void updateUrl({ id, url, setError: params.setError, setEditingId: params.setEditingId, setApis: params.setApis, onConfigChange: params.onConfigChange }),
|
||||
onToggleEnabled: (id, enabled) => void toggleEnabled({ id, enabled, setError: params.setError, setApis: params.setApis, onConfigChange: params.onConfigChange }),
|
||||
onRemoveApi: (id) => void removeApi({ id, setError: params.setError, setApis: params.setApis, onConfigChange: params.onConfigChange }),
|
||||
}
|
||||
}
|
||||
|
||||
function dragHandlers(params: {
|
||||
apis: Nip95Config[]
|
||||
draggedId: string | null
|
||||
setApis: (value: Nip95Config[]) => void
|
||||
setDraggedId: (value: string | null) => void
|
||||
setDragOverId: (value: string | null) => void
|
||||
setError: (value: string | null) => void
|
||||
onConfigChange: (() => void) | undefined
|
||||
}): Pick<ViewProps, 'onDragStart' | 'onDragOver' | 'onDragLeave' | 'onDrop'> {
|
||||
return {
|
||||
onDragStart: (e, id) => onDragStart({ e, id, setDraggedId: params.setDraggedId }),
|
||||
onDragOver: (e, id) => onDragOver({ e, id, setDragOverId: params.setDragOverId }),
|
||||
onDragLeave: () => params.setDragOverId(null),
|
||||
onDrop: (e, targetId) =>
|
||||
void onDrop({
|
||||
e,
|
||||
targetId,
|
||||
apis: params.apis,
|
||||
draggedId: params.draggedId,
|
||||
setApis: params.setApis,
|
||||
setDraggedId: params.setDraggedId,
|
||||
setDragOverId: params.setDragOverId,
|
||||
setError: params.setError,
|
||||
onConfigChange: params.onConfigChange,
|
||||
}),
|
||||
}
|
||||
}
|
||||
132
components/relayManager/RelayCard.tsx
Normal file
132
components/relayManager/RelayCard.tsx
Normal file
@ -0,0 +1,132 @@
|
||||
import type { RelayConfig } from '@/lib/configStorageTypes'
|
||||
import { t } from '@/lib/i18n'
|
||||
import { DragHandle } from '../DragHandle'
|
||||
import { getRelayCardClassName } from './controller'
|
||||
|
||||
export function RelayCard(params: {
|
||||
relay: RelayConfig
|
||||
isEditing: boolean
|
||||
draggedId: string | null
|
||||
dragOverId: string | null
|
||||
onStartEditing: (id: string) => void
|
||||
onStopEditing: () => void
|
||||
onUpdateUrl: (id: string, url: string) => void
|
||||
onToggleEnabled: (id: string, enabled: boolean) => void
|
||||
onRemoveRelay: (id: string) => void
|
||||
onDragStart: (e: React.DragEvent<HTMLDivElement>, id: string) => void
|
||||
onDragOver: (e: React.DragEvent<HTMLDivElement>, id: string) => void
|
||||
onDragLeave: () => void
|
||||
onDrop: (e: React.DragEvent<HTMLDivElement>, targetId: string) => void
|
||||
onDragEnd: () => void
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div
|
||||
onDragOver={(e) => params.onDragOver(e, params.relay.id)}
|
||||
onDragLeave={params.onDragLeave}
|
||||
onDrop={(e) => params.onDrop(e, params.relay.id)}
|
||||
className={`bg-cyber-dark border rounded p-4 space-y-3 transition-all ${getRelayCardClassName(params.relay.id, params.draggedId, params.dragOverId)}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<DragGrip relayId={params.relay.id} onDragStart={params.onDragStart} onDragEnd={params.onDragEnd} />
|
||||
<UrlCell relay={params.relay} isEditing={params.isEditing} onStartEditing={params.onStartEditing} onStopEditing={params.onStopEditing} onUpdateUrl={params.onUpdateUrl} />
|
||||
{params.relay.lastSyncDate ? <LastSync lastSyncDate={params.relay.lastSyncDate} /> : null}
|
||||
</div>
|
||||
<ActionsCell relay={params.relay} onToggleEnabled={params.onToggleEnabled} onRemoveRelay={params.onRemoveRelay} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DragGrip(params: {
|
||||
relayId: string
|
||||
onDragStart: (e: React.DragEvent<HTMLDivElement>, id: string) => void
|
||||
onDragEnd: () => void
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div
|
||||
className="drag-handle cursor-grab active:cursor-grabbing"
|
||||
draggable
|
||||
onDragStart={(e) => params.onDragStart(e, params.relayId)}
|
||||
onDragEnd={params.onDragEnd}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DragHandle />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function UrlCell(params: {
|
||||
relay: RelayConfig
|
||||
isEditing: boolean
|
||||
onStartEditing: (id: string) => void
|
||||
onStopEditing: () => void
|
||||
onUpdateUrl: (id: string, url: string) => void
|
||||
}): React.ReactElement {
|
||||
if (params.isEditing) {
|
||||
return <UrlEditor relay={params.relay} onStop={params.onStopEditing} onUpdate={params.onUpdateUrl} />
|
||||
}
|
||||
return (
|
||||
<div className="text-neon-cyan cursor-pointer hover:text-neon-green transition-colors" onClick={() => params.onStartEditing(params.relay.id)} title={t('settings.relay.list.editUrl')}>
|
||||
{params.relay.url}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function UrlEditor(params: { relay: RelayConfig; onStop: () => void; onUpdate: (id: string, url: string) => void }): React.ReactElement {
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
defaultValue={params.relay.url}
|
||||
onBlur={(e) => {
|
||||
const next = e.target.value
|
||||
if (next !== params.relay.url) {
|
||||
params.onUpdate(params.relay.id, next)
|
||||
} else {
|
||||
params.onStop()
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.currentTarget.blur()
|
||||
} else if (e.key === 'Escape') {
|
||||
params.onStop()
|
||||
}
|
||||
}}
|
||||
className="w-full px-3 py-2 bg-cyber-darker border border-neon-cyan/50 rounded text-cyber-light focus:border-neon-cyan focus:outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function LastSync(params: { lastSyncDate: string }): React.ReactElement {
|
||||
return (
|
||||
<div className="text-xs text-cyber-accent/70 mt-1">
|
||||
{t('settings.relay.list.lastSync')}: {new Date(params.lastSyncDate).toLocaleString()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ActionsCell(params: { relay: RelayConfig; onToggleEnabled: (id: string, enabled: boolean) => void; onRemoveRelay: (id: string) => void }): React.ReactElement {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={params.relay.enabled}
|
||||
onChange={(e) => params.onToggleEnabled(params.relay.id, e.target.checked)}
|
||||
className="w-4 h-4 text-neon-cyan bg-cyber-darker border-cyber-accent rounded focus:ring-neon-cyan"
|
||||
/>
|
||||
<span className="text-sm text-cyber-accent">{params.relay.enabled ? t('settings.relay.list.enabled') : t('settings.relay.list.disabled')}</span>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => params.onRemoveRelay(params.relay.id)}
|
||||
className="px-3 py-1 text-sm bg-red-900/30 hover:bg-red-900/50 text-red-300 border border-red-500/50 rounded transition-colors"
|
||||
title={t('settings.relay.list.remove')}
|
||||
>
|
||||
{t('settings.relay.list.remove')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
49
components/relayManager/RelayList.tsx
Normal file
49
components/relayManager/RelayList.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
import type { RelayConfig } from '@/lib/configStorageTypes'
|
||||
import { t } from '@/lib/i18n'
|
||||
import { RelayCard } from './RelayCard'
|
||||
|
||||
interface RelayListProps {
|
||||
relays: RelayConfig[]
|
||||
editingId: string | null
|
||||
draggedId: string | null
|
||||
dragOverId: string | null
|
||||
onStartEditing: (id: string) => void
|
||||
onStopEditing: () => void
|
||||
onUpdateUrl: (id: string, url: string) => void
|
||||
onToggleEnabled: (id: string, enabled: boolean) => void
|
||||
onRemoveRelay: (id: string) => void
|
||||
onDragStart: (e: React.DragEvent<HTMLDivElement>, id: string) => void
|
||||
onDragOver: (e: React.DragEvent<HTMLDivElement>, id: string) => void
|
||||
onDragLeave: () => void
|
||||
onDrop: (e: React.DragEvent<HTMLDivElement>, targetId: string) => void
|
||||
onDragEnd: () => void
|
||||
}
|
||||
|
||||
export function RelayList(params: RelayListProps): React.ReactElement {
|
||||
if (params.relays.length === 0) {
|
||||
return <div className="text-center py-8 text-cyber-accent">{t('settings.relay.empty')}</div>
|
||||
}
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{params.relays.map((relay) => (
|
||||
<RelayCard
|
||||
key={relay.id}
|
||||
relay={relay}
|
||||
isEditing={params.editingId === relay.id}
|
||||
draggedId={params.draggedId}
|
||||
dragOverId={params.dragOverId}
|
||||
onStartEditing={params.onStartEditing}
|
||||
onStopEditing={params.onStopEditing}
|
||||
onUpdateUrl={params.onUpdateUrl}
|
||||
onToggleEnabled={params.onToggleEnabled}
|
||||
onRemoveRelay={params.onRemoveRelay}
|
||||
onDragStart={params.onDragStart}
|
||||
onDragOver={params.onDragOver}
|
||||
onDragLeave={params.onDragLeave}
|
||||
onDrop={params.onDrop}
|
||||
onDragEnd={params.onDragEnd}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
46
components/relayManager/RelayManager.tsx
Normal file
46
components/relayManager/RelayManager.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { RelayConfig } from '@/lib/configStorageTypes'
|
||||
import { RelayManagerView } from './view'
|
||||
import { initialLoadRelays } from './viewModel'
|
||||
import { buildRelayManagerViewProps } from './viewProps'
|
||||
|
||||
export interface RelayManagerProps {
|
||||
onConfigChange?: () => void
|
||||
}
|
||||
|
||||
export function RelayManager(props: RelayManagerProps): React.ReactElement {
|
||||
const [relays, setRelays] = useState<RelayConfig[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [newUrl, setNewUrl] = useState('')
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
const [draggedId, setDraggedId] = useState<string | null>(null)
|
||||
const [dragOverId, setDragOverId] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
initialLoadRelays({ setRelays, setLoading, setError })
|
||||
}, [])
|
||||
|
||||
const viewProps = buildRelayManagerViewProps({
|
||||
relays,
|
||||
loading,
|
||||
error,
|
||||
editingId,
|
||||
newUrl,
|
||||
showAddForm,
|
||||
draggedId,
|
||||
dragOverId,
|
||||
setRelays,
|
||||
setLoading,
|
||||
setError,
|
||||
setEditingId,
|
||||
setNewUrl,
|
||||
setShowAddForm,
|
||||
setDraggedId,
|
||||
setDragOverId,
|
||||
onConfigChange: props.onConfigChange,
|
||||
})
|
||||
|
||||
return <RelayManagerView {...viewProps} />
|
||||
}
|
||||
100
components/relayManager/RelayManagerContent.tsx
Normal file
100
components/relayManager/RelayManagerContent.tsx
Normal file
@ -0,0 +1,100 @@
|
||||
import { t } from '@/lib/i18n'
|
||||
import { RelayList } from './RelayList'
|
||||
import type { RelayManagerContentProps } from './types'
|
||||
|
||||
export function RelayManagerContent(params: RelayManagerContentProps): React.ReactElement {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{params.error ? <ErrorBanner error={params.error} onClear={params.onClearError} /> : null}
|
||||
<Header showAddForm={params.showAddForm} onToggleAddForm={params.onToggleAddForm} />
|
||||
{params.showAddForm ? <AddForm newUrl={params.newUrl} onNewUrlChange={params.onNewUrlChange} onAdd={params.onAddRelay} onCancel={params.onCancelAdd} /> : null}
|
||||
<RelayList
|
||||
relays={params.relays}
|
||||
editingId={params.editingId}
|
||||
draggedId={params.draggedId}
|
||||
dragOverId={params.dragOverId}
|
||||
onStartEditing={params.onStartEditing}
|
||||
onStopEditing={params.onStopEditing}
|
||||
onUpdateUrl={params.onUpdateUrl}
|
||||
onToggleEnabled={params.onToggleEnabled}
|
||||
onRemoveRelay={params.onRemoveRelay}
|
||||
onDragStart={params.onDragStart}
|
||||
onDragOver={params.onDragOver}
|
||||
onDragLeave={params.onDragLeave}
|
||||
onDrop={params.onDrop}
|
||||
onDragEnd={params.onDragEnd}
|
||||
/>
|
||||
<Notes />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ErrorBanner(params: { error: string; onClear: () => void }): React.ReactElement {
|
||||
return (
|
||||
<div className="bg-red-900/30 border border-red-500/50 rounded p-4 text-red-300">
|
||||
{params.error}
|
||||
<button type="button" onClick={params.onClear} className="ml-4 text-red-400 hover:text-red-200">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Header(params: { showAddForm: boolean; onToggleAddForm: () => void }): React.ReactElement {
|
||||
return (
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-2xl font-bold text-neon-cyan">{t('settings.relay.title')}</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={params.onToggleAddForm}
|
||||
className="px-4 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan border border-neon-cyan/50 rounded transition-colors"
|
||||
>
|
||||
{params.showAddForm ? t('settings.relay.add.cancel') : t('settings.relay.addButton')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AddForm(params: { newUrl: string; onNewUrlChange: (value: string) => void; onAdd: () => void; onCancel: () => void }): React.ReactElement {
|
||||
return (
|
||||
<div className="bg-cyber-dark border border-neon-cyan/30 rounded p-4 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-cyber-accent mb-2">{t('settings.relay.add.url')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={params.newUrl}
|
||||
onChange={(e) => params.onNewUrlChange(e.target.value)}
|
||||
placeholder={t('settings.relay.add.placeholder')}
|
||||
className="w-full px-4 py-2 bg-cyber-darker border border-cyber-accent/30 rounded text-cyber-light focus:border-neon-cyan focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={params.onAdd}
|
||||
className="px-4 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan border border-neon-cyan/50 rounded transition-colors"
|
||||
>
|
||||
{t('settings.relay.add.add')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={params.onCancel}
|
||||
className="px-4 py-2 bg-cyber-accent/20 hover:bg-cyber-accent/30 text-cyber-accent border border-cyber-accent/50 rounded transition-colors"
|
||||
>
|
||||
{t('settings.relay.add.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Notes(): React.ReactElement {
|
||||
return (
|
||||
<div className="text-sm text-cyber-accent space-y-2">
|
||||
<p>
|
||||
<strong>{t('settings.relay.note.title')}</strong> {t('settings.relay.note.priority')}
|
||||
</p>
|
||||
<p>{t('settings.relay.note.rotation')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
256
components/relayManager/controller.ts
Normal file
256
components/relayManager/controller.ts
Normal file
@ -0,0 +1,256 @@
|
||||
import { configStorage } from '@/lib/configStorage'
|
||||
import type { RelayConfig } from '@/lib/configStorageTypes'
|
||||
import { t } from '@/lib/i18n'
|
||||
import { relaySessionManager } from '@/lib/relaySessionManager'
|
||||
import { userConfirm } from '@/lib/userConfirm'
|
||||
|
||||
export async function loadRelays(params: {
|
||||
setRelays: (value: RelayConfig[]) => void
|
||||
setLoading: (value: boolean) => void
|
||||
setError: (value: string | null) => void
|
||||
}): Promise<void> {
|
||||
try {
|
||||
params.setLoading(true)
|
||||
params.setError(null)
|
||||
const config = await configStorage.getConfig()
|
||||
const updated = await maybeDisableFailedRelays(config.relays)
|
||||
params.setRelays(sortByPriority(updated))
|
||||
} catch (e) {
|
||||
const errorMessage = e instanceof Error ? e.message : t('settings.relay.error.loadFailed')
|
||||
params.setError(errorMessage)
|
||||
console.error('[RelayManager] Error loading relays:', e)
|
||||
} finally {
|
||||
params.setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
export async function toggleEnabled(params: {
|
||||
id: string
|
||||
enabled: boolean
|
||||
setRelays: (value: RelayConfig[]) => void
|
||||
setError: (value: string | null) => void
|
||||
onConfigChange: (() => void) | undefined
|
||||
}): Promise<void> {
|
||||
try {
|
||||
await configStorage.updateRelay(params.id, { enabled: params.enabled })
|
||||
await reloadRelays({ setRelays: params.setRelays, setError: params.setError })
|
||||
params.onConfigChange?.()
|
||||
} catch (e) {
|
||||
const errorMessage = e instanceof Error ? e.message : t('settings.relay.error.updateFailed')
|
||||
params.setError(errorMessage)
|
||||
console.error('[RelayManager] Error updating relay:', e)
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateUrl(params: {
|
||||
id: string
|
||||
url: string
|
||||
setRelays: (value: RelayConfig[]) => void
|
||||
setError: (value: string | null) => void
|
||||
setEditingId: (value: string | null) => void
|
||||
onConfigChange: (() => void) | undefined
|
||||
}): Promise<void> {
|
||||
try {
|
||||
await configStorage.updateRelay(params.id, { url: params.url })
|
||||
await reloadRelays({ setRelays: params.setRelays, setError: params.setError })
|
||||
params.setEditingId(null)
|
||||
params.onConfigChange?.()
|
||||
} catch (e) {
|
||||
const errorMessage = e instanceof Error ? e.message : t('settings.relay.error.urlFailed')
|
||||
params.setError(errorMessage)
|
||||
console.error('[RelayManager] Error updating URL:', e)
|
||||
}
|
||||
}
|
||||
|
||||
export async function addRelay(params: {
|
||||
newUrl: string
|
||||
setError: (value: string | null) => void
|
||||
setNewUrl: (value: string) => void
|
||||
setShowAddForm: (value: boolean | ((prev: boolean) => boolean)) => void
|
||||
setRelays: (value: RelayConfig[]) => void
|
||||
onConfigChange: (() => void) | undefined
|
||||
}): Promise<void> {
|
||||
const normalizedUrl = normalizeRelayUrl(params.newUrl)
|
||||
if (!normalizedUrl) {
|
||||
params.setError(t('settings.relay.error.urlRequired'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
void new URL(normalizedUrl)
|
||||
await configStorage.addRelay(normalizedUrl, true)
|
||||
params.setNewUrl('')
|
||||
params.setShowAddForm(false)
|
||||
await reloadRelays({ setRelays: params.setRelays, setError: params.setError })
|
||||
params.onConfigChange?.()
|
||||
} catch (e) {
|
||||
params.setError(getAddRelayErrorMessage(e))
|
||||
console.error('[RelayManager] Error adding relay:', e)
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeRelay(params: {
|
||||
id: string
|
||||
setRelays: (value: RelayConfig[]) => void
|
||||
setError: (value: string | null) => void
|
||||
onConfigChange: (() => void) | undefined
|
||||
}): Promise<void> {
|
||||
const confirmed = await userConfirm(t('settings.relay.remove.confirm'))
|
||||
if (!confirmed) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await configStorage.removeRelay(params.id)
|
||||
await reloadRelays({ setRelays: params.setRelays, setError: params.setError })
|
||||
params.onConfigChange?.()
|
||||
} catch (e) {
|
||||
const errorMessage = e instanceof Error ? e.message : t('settings.relay.error.removeFailed')
|
||||
params.setError(errorMessage)
|
||||
console.error('[RelayManager] Error removing relay:', e)
|
||||
}
|
||||
}
|
||||
|
||||
export async function updatePriorities(params: {
|
||||
relays: RelayConfig[]
|
||||
setError: (value: string | null) => void
|
||||
onConfigChange: (() => void) | undefined
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const updates = params.relays.map((relay, index) => {
|
||||
const priority = index + 1
|
||||
return relay.priority !== priority ? configStorage.updateRelay(relay.id, { priority }) : Promise.resolve()
|
||||
})
|
||||
await Promise.all(updates)
|
||||
params.onConfigChange?.()
|
||||
} catch (e) {
|
||||
const errorMessage = e instanceof Error ? e.message : t('settings.relay.error.priorityFailed')
|
||||
params.setError(errorMessage)
|
||||
console.error('[RelayManager] Error updating priorities:', e)
|
||||
}
|
||||
}
|
||||
|
||||
export function onDragStart(params: {
|
||||
e: React.DragEvent<HTMLDivElement>
|
||||
id: string
|
||||
setDraggedId: (value: string | null) => void
|
||||
}): void {
|
||||
params.setDraggedId(params.id)
|
||||
const { dataTransfer } = params.e
|
||||
dataTransfer.effectAllowed = 'move'
|
||||
dataTransfer.setData('text/plain', params.id)
|
||||
}
|
||||
|
||||
export function onDragOver(params: {
|
||||
e: React.DragEvent<HTMLDivElement>
|
||||
id: string
|
||||
setDragOverId: (value: string | null) => void
|
||||
}): void {
|
||||
params.e.preventDefault()
|
||||
const { dataTransfer } = params.e
|
||||
dataTransfer.dropEffect = 'move'
|
||||
params.setDragOverId(params.id)
|
||||
}
|
||||
|
||||
export async function onDrop(params: {
|
||||
e: React.DragEvent<HTMLDivElement>
|
||||
targetId: string
|
||||
relays: RelayConfig[]
|
||||
draggedId: string | null
|
||||
setRelays: (value: RelayConfig[]) => void
|
||||
setDraggedId: (value: string | null) => void
|
||||
setDragOverId: (value: string | null) => void
|
||||
setError: (value: string | null) => void
|
||||
onConfigChange: (() => void) | undefined
|
||||
}): Promise<void> {
|
||||
params.e.preventDefault()
|
||||
params.setDragOverId(null)
|
||||
if (!params.draggedId || params.draggedId === params.targetId) {
|
||||
params.setDraggedId(null)
|
||||
return
|
||||
}
|
||||
const reordered = reorderRelays({ relays: params.relays, draggedId: params.draggedId, targetId: params.targetId })
|
||||
if (!reordered) {
|
||||
params.setDraggedId(null)
|
||||
return
|
||||
}
|
||||
params.setRelays(reordered)
|
||||
params.setDraggedId(null)
|
||||
await updatePriorities({ relays: reordered, setError: params.setError, onConfigChange: params.onConfigChange })
|
||||
}
|
||||
|
||||
export function getRelayCardClassName(relayId: string, draggedId: string | null, dragOverId: string | null): string {
|
||||
if (draggedId === relayId) {
|
||||
return 'opacity-50 border-neon-cyan'
|
||||
}
|
||||
if (dragOverId === relayId) {
|
||||
return 'border-neon-green shadow-lg'
|
||||
}
|
||||
return 'border-neon-cyan/30'
|
||||
}
|
||||
|
||||
function sortByPriority(relays: RelayConfig[]): RelayConfig[] {
|
||||
return relays.sort((a, b) => a.priority - b.priority)
|
||||
}
|
||||
|
||||
async function reloadRelays(params: { setRelays: (value: RelayConfig[]) => void; setError: (value: string | null) => void }): Promise<void> {
|
||||
try {
|
||||
const config = await configStorage.getConfig()
|
||||
params.setRelays(sortByPriority(config.relays))
|
||||
} catch (e) {
|
||||
const errorMessage = e instanceof Error ? e.message : t('settings.relay.error.loadFailed')
|
||||
params.setError(errorMessage)
|
||||
console.error('[RelayManager] Error reloading relays:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function maybeDisableFailedRelays(relays: RelayConfig[]): Promise<RelayConfig[]> {
|
||||
const failedRelays = relaySessionManager.getFailedRelays()
|
||||
if (failedRelays.length === 0) {
|
||||
return relays
|
||||
}
|
||||
const toDisable = relays.filter((relay) => relay.enabled && failedRelays.includes(relay.url))
|
||||
if (toDisable.length === 0) {
|
||||
return relays
|
||||
}
|
||||
for (const relay of toDisable) {
|
||||
await configStorage.updateRelay(relay.id, { enabled: false })
|
||||
}
|
||||
const updated = await configStorage.getConfig()
|
||||
return updated.relays
|
||||
}
|
||||
|
||||
function normalizeRelayUrl(rawUrl: string): string | null {
|
||||
const trimmed = rawUrl.trim()
|
||||
if (trimmed.length === 0) {
|
||||
return null
|
||||
}
|
||||
if (trimmed.startsWith('ws://') || trimmed.startsWith('wss://')) {
|
||||
return trimmed
|
||||
}
|
||||
return `wss://${trimmed}`
|
||||
}
|
||||
|
||||
function getAddRelayErrorMessage(error: unknown): string {
|
||||
if (error instanceof TypeError && error.message.includes('Invalid URL')) {
|
||||
return t('settings.relay.error.invalidUrl')
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message
|
||||
}
|
||||
return t('settings.relay.error.addFailed')
|
||||
}
|
||||
|
||||
function reorderRelays(params: { relays: RelayConfig[]; draggedId: string; targetId: string }): RelayConfig[] | null {
|
||||
const draggedIndex = params.relays.findIndex((relay) => relay.id === params.draggedId)
|
||||
const targetIndex = params.relays.findIndex((relay) => relay.id === params.targetId)
|
||||
if (draggedIndex === -1 || targetIndex === -1) {
|
||||
return null
|
||||
}
|
||||
const next = [...params.relays]
|
||||
const removed = next[draggedIndex]
|
||||
if (!removed) {
|
||||
return null
|
||||
}
|
||||
next.splice(draggedIndex, 1)
|
||||
next.splice(targetIndex, 0, removed)
|
||||
return next
|
||||
}
|
||||
29
components/relayManager/types.ts
Normal file
29
components/relayManager/types.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import type { RelayConfig } from '@/lib/configStorageTypes'
|
||||
|
||||
export interface RelayManagerViewProps {
|
||||
relays: RelayConfig[]
|
||||
loading: boolean
|
||||
error: string | null
|
||||
editingId: string | null
|
||||
newUrl: string
|
||||
showAddForm: boolean
|
||||
draggedId: string | null
|
||||
dragOverId: string | null
|
||||
onClearError: () => void
|
||||
onToggleAddForm: () => void
|
||||
onNewUrlChange: (value: string) => void
|
||||
onCancelAdd: () => void
|
||||
onAddRelay: () => void
|
||||
onStartEditing: (id: string) => void
|
||||
onStopEditing: () => void
|
||||
onUpdateUrl: (id: string, url: string) => void
|
||||
onToggleEnabled: (id: string, enabled: boolean) => void
|
||||
onRemoveRelay: (id: string) => void
|
||||
onDragStart: (e: React.DragEvent<HTMLDivElement>, id: string) => void
|
||||
onDragOver: (e: React.DragEvent<HTMLDivElement>, id: string) => void
|
||||
onDragLeave: () => void
|
||||
onDrop: (e: React.DragEvent<HTMLDivElement>, targetId: string) => void
|
||||
onDragEnd: () => void
|
||||
}
|
||||
|
||||
export type RelayManagerContentProps = Omit<RelayManagerViewProps, 'loading'>
|
||||
14
components/relayManager/view.tsx
Normal file
14
components/relayManager/view.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import { t } from '@/lib/i18n'
|
||||
import { RelayManagerContent } from './RelayManagerContent'
|
||||
import type { RelayManagerViewProps } from './types'
|
||||
|
||||
export function RelayManagerView(params: RelayManagerViewProps): React.ReactElement {
|
||||
if (params.loading) {
|
||||
return (
|
||||
<div className="text-center py-8 text-neon-cyan">
|
||||
<div>{t('settings.relay.loading')}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <RelayManagerContent {...params} />
|
||||
}
|
||||
6
components/relayManager/viewModel.ts
Normal file
6
components/relayManager/viewModel.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import type { RelayConfig } from '@/lib/configStorageTypes'
|
||||
import { loadRelays } from './controller'
|
||||
|
||||
export function initialLoadRelays(params: { setRelays: (value: RelayConfig[]) => void; setLoading: (value: boolean) => void; setError: (value: string | null) => void }): void {
|
||||
void loadRelays({ setRelays: params.setRelays, setLoading: params.setLoading, setError: params.setError })
|
||||
}
|
||||
130
components/relayManager/viewProps.ts
Normal file
130
components/relayManager/viewProps.ts
Normal file
@ -0,0 +1,130 @@
|
||||
import type { RelayConfig } from '@/lib/configStorageTypes'
|
||||
import type { RelayManagerProps } from './RelayManager'
|
||||
import { addRelay, onDragOver, onDragStart, onDrop, removeRelay, toggleEnabled, updateUrl } from './controller'
|
||||
import type { RelayManagerViewProps } from './types'
|
||||
|
||||
export function buildRelayManagerViewProps(params: {
|
||||
relays: RelayConfig[]
|
||||
loading: boolean
|
||||
error: string | null
|
||||
editingId: string | null
|
||||
newUrl: string
|
||||
showAddForm: boolean
|
||||
draggedId: string | null
|
||||
dragOverId: string | null
|
||||
setRelays: (value: RelayConfig[]) => void
|
||||
setLoading: (value: boolean) => void
|
||||
setError: (value: string | null) => void
|
||||
setEditingId: (value: string | null) => void
|
||||
setNewUrl: (value: string) => void
|
||||
setShowAddForm: (value: boolean | ((prev: boolean) => boolean)) => void
|
||||
setDraggedId: (value: string | null) => void
|
||||
setDragOverId: (value: string | null) => void
|
||||
onConfigChange: RelayManagerProps['onConfigChange']
|
||||
}): RelayManagerViewProps {
|
||||
return { ...stateProps(params), ...formProps(params), ...relayActionProps(params), ...dragProps(params) }
|
||||
}
|
||||
|
||||
function stateProps(params: {
|
||||
relays: RelayConfig[]
|
||||
loading: boolean
|
||||
error: string | null
|
||||
editingId: string | null
|
||||
newUrl: string
|
||||
showAddForm: boolean
|
||||
draggedId: string | null
|
||||
dragOverId: string | null
|
||||
}): Pick<RelayManagerViewProps, 'relays' | 'loading' | 'error' | 'editingId' | 'newUrl' | 'showAddForm' | 'draggedId' | 'dragOverId'> {
|
||||
return {
|
||||
relays: params.relays,
|
||||
loading: params.loading,
|
||||
error: params.error,
|
||||
editingId: params.editingId,
|
||||
newUrl: params.newUrl,
|
||||
showAddForm: params.showAddForm,
|
||||
draggedId: params.draggedId,
|
||||
dragOverId: params.dragOverId,
|
||||
}
|
||||
}
|
||||
|
||||
function formProps(params: {
|
||||
newUrl: string
|
||||
setError: (value: string | null) => void
|
||||
setNewUrl: (value: string) => void
|
||||
setShowAddForm: (value: boolean | ((prev: boolean) => boolean)) => void
|
||||
setRelays: (value: RelayConfig[]) => void
|
||||
onConfigChange: RelayManagerProps['onConfigChange']
|
||||
}): Pick<RelayManagerViewProps, 'onClearError' | 'onToggleAddForm' | 'onNewUrlChange' | 'onCancelAdd' | 'onAddRelay'> {
|
||||
return {
|
||||
onClearError: () => params.setError(null),
|
||||
onToggleAddForm: () => params.setShowAddForm((prev) => !prev),
|
||||
onNewUrlChange: (value) => params.setNewUrl(value),
|
||||
onCancelAdd: () => {
|
||||
params.setShowAddForm(false)
|
||||
params.setNewUrl('')
|
||||
params.setError(null)
|
||||
},
|
||||
onAddRelay: () =>
|
||||
void addRelay({
|
||||
newUrl: params.newUrl,
|
||||
setError: params.setError,
|
||||
setNewUrl: params.setNewUrl,
|
||||
setShowAddForm: params.setShowAddForm,
|
||||
setRelays: params.setRelays,
|
||||
onConfigChange: params.onConfigChange,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
function relayActionProps(params: {
|
||||
setRelays: (value: RelayConfig[]) => void
|
||||
setError: (value: string | null) => void
|
||||
setEditingId: (value: string | null) => void
|
||||
onConfigChange: RelayManagerProps['onConfigChange']
|
||||
}): Pick<RelayManagerViewProps, 'onStartEditing' | 'onStopEditing' | 'onUpdateUrl' | 'onToggleEnabled' | 'onRemoveRelay'> {
|
||||
return {
|
||||
onStartEditing: (id) => params.setEditingId(id),
|
||||
onStopEditing: () => params.setEditingId(null),
|
||||
onUpdateUrl: (id, url) =>
|
||||
void updateUrl({
|
||||
id,
|
||||
url,
|
||||
setRelays: params.setRelays,
|
||||
setError: params.setError,
|
||||
setEditingId: params.setEditingId,
|
||||
onConfigChange: params.onConfigChange,
|
||||
}),
|
||||
onToggleEnabled: (id, enabled) =>
|
||||
void toggleEnabled({ id, enabled, setRelays: params.setRelays, setError: params.setError, onConfigChange: params.onConfigChange }),
|
||||
onRemoveRelay: (id) => void removeRelay({ id, setRelays: params.setRelays, setError: params.setError, onConfigChange: params.onConfigChange }),
|
||||
}
|
||||
}
|
||||
|
||||
function dragProps(params: {
|
||||
relays: RelayConfig[]
|
||||
draggedId: string | null
|
||||
setRelays: (value: RelayConfig[]) => void
|
||||
setDraggedId: (value: string | null) => void
|
||||
setDragOverId: (value: string | null) => void
|
||||
setError: (value: string | null) => void
|
||||
onConfigChange: RelayManagerProps['onConfigChange']
|
||||
}): Pick<RelayManagerViewProps, 'onDragStart' | 'onDragOver' | 'onDragLeave' | 'onDrop' | 'onDragEnd'> {
|
||||
return {
|
||||
onDragStart: (e, id) => onDragStart({ e, id, setDraggedId: params.setDraggedId }),
|
||||
onDragOver: (e, id) => onDragOver({ e, id, setDragOverId: params.setDragOverId }),
|
||||
onDragLeave: () => params.setDragOverId(null),
|
||||
onDrop: (e, targetId) =>
|
||||
void onDrop({
|
||||
e,
|
||||
targetId,
|
||||
relays: params.relays,
|
||||
draggedId: params.draggedId,
|
||||
setRelays: params.setRelays,
|
||||
setDraggedId: params.setDraggedId,
|
||||
setDragOverId: params.setDragOverId,
|
||||
setError: params.setError,
|
||||
onConfigChange: params.onConfigChange,
|
||||
}),
|
||||
onDragEnd: () => params.setDraggedId(null),
|
||||
}
|
||||
}
|
||||
22
components/syncProgressBar/SyncProgressBar.tsx
Normal file
22
components/syncProgressBar/SyncProgressBar.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import { t } from '@/lib/i18n'
|
||||
import { useSyncProgressBarController } from './controller'
|
||||
import { SyncDateRange, SyncErrorBanner, SyncProgressSection, SyncResyncButton, SyncStatusMessage } from './view'
|
||||
|
||||
export function SyncProgressBar(): React.ReactElement | null {
|
||||
const controller = useSyncProgressBarController()
|
||||
if (!controller) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div className="bg-cyber-darker border border-neon-cyan/30 rounded-lg p-4 mt-6">
|
||||
<SyncErrorBanner error={controller.error} onDismiss={controller.dismissError} />
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-lg font-semibold text-neon-cyan">{t('settings.sync.title')}</h3>
|
||||
<SyncResyncButton isSyncing={controller.isSyncing} onClick={controller.onResyncClick} />
|
||||
</div>
|
||||
<SyncDateRange totalDays={controller.totalDays} startDate={controller.startDateLabel} endDate={controller.endDateLabel} />
|
||||
<SyncProgressSection isSyncing={controller.isSyncing} syncProgress={controller.syncProgress} progressPercentage={controller.progressPercentage} />
|
||||
<SyncStatusMessage isSyncing={controller.isSyncing} totalDays={controller.totalDays} isRecentlySynced={controller.isRecentlySynced} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
216
components/syncProgressBar/controller.ts
Normal file
216
components/syncProgressBar/controller.ts
Normal file
@ -0,0 +1,216 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { nostrAuthService } from '@/lib/nostrAuth'
|
||||
import { MIN_EVENT_DATE } from '@/lib/platformConfig'
|
||||
import { objectCache } from '@/lib/objectCache'
|
||||
import { calculateDaysBetween, getCurrentTimestamp, getLastSyncDate, setLastSyncDate as setLastSyncDateStorage } from '@/lib/syncStorage'
|
||||
import { useSyncProgress } from '@/lib/hooks/useSyncProgress'
|
||||
import type { SyncProgress, SyncProgressBarController } from './types'
|
||||
|
||||
type ConnectionState = { isInitialized: boolean; connected: boolean; pubkey: string | null }
|
||||
|
||||
export function useSyncProgressBarController(): SyncProgressBarController | null {
|
||||
const connection = useNostrConnectionState()
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const syncStatus = useSyncStatus()
|
||||
const { syncProgress, isSyncing, startMonitoring, stopMonitoring } = useSyncProgress({ onComplete: syncStatus.loadSyncStatus })
|
||||
useAutoSyncEffect({ connection, isSyncing, loadSyncStatus: syncStatus.loadSyncStatus, startMonitoring, stopMonitoring, setError })
|
||||
|
||||
if (!connection.isInitialized || !connection.connected || !connection.pubkey) {
|
||||
return null
|
||||
}
|
||||
|
||||
const progressPercentage = computeProgressPercentage(syncProgress)
|
||||
const isRecentlySynced = isRecentlySyncedFromLastSyncDate(syncStatus.lastSyncDate)
|
||||
const startDate = getSyncStartDate(syncStatus.lastSyncDate)
|
||||
const endDate = getCurrentTimestamp()
|
||||
|
||||
return {
|
||||
error,
|
||||
dismissError: () => setError(null),
|
||||
isSyncing,
|
||||
syncProgress,
|
||||
progressPercentage,
|
||||
totalDays: syncStatus.totalDays,
|
||||
startDateLabel: formatSyncDate(startDate),
|
||||
endDateLabel: formatSyncDate(endDate),
|
||||
isRecentlySynced,
|
||||
onResyncClick: () => {
|
||||
void resynchronizeUserContent({ startMonitoring, stopMonitoring, loadSyncStatus: syncStatus.loadSyncStatus, setError })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function useNostrConnectionState(): ConnectionState {
|
||||
const initial = nostrAuthService.getState()
|
||||
const [state, setState] = useState<{ connected: boolean; pubkey: string | null }>(() => ({
|
||||
connected: initial.connected ?? false,
|
||||
pubkey: initial.pubkey ?? null,
|
||||
}))
|
||||
const [isInitialized] = useState(true)
|
||||
useEffect(() => {
|
||||
const unsubscribe = nostrAuthService.subscribe((next) => setState({ connected: next.connected ?? false, pubkey: next.pubkey ?? null }))
|
||||
return () => unsubscribe()
|
||||
}, [])
|
||||
return { isInitialized, connected: state.connected, pubkey: state.pubkey }
|
||||
}
|
||||
|
||||
function useSyncStatus(): { lastSyncDate: number | null; totalDays: number; loadSyncStatus: () => Promise<void> } {
|
||||
const [lastSyncDate, setLastSyncDate] = useState<number | null>(null)
|
||||
const [totalDays, setTotalDays] = useState<number>(0)
|
||||
const loadSyncStatus = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
const state = nostrAuthService.getState()
|
||||
if (!state.connected || !state.pubkey) {
|
||||
return
|
||||
}
|
||||
const storedLastSyncDate = await getLastSyncDate()
|
||||
const days = calculateDaysBetween(storedLastSyncDate, getCurrentTimestamp())
|
||||
setLastSyncDate(storedLastSyncDate)
|
||||
setTotalDays(days)
|
||||
} catch (loadError) {
|
||||
console.error('[SyncProgressBar] Error loading sync status:', loadError)
|
||||
}
|
||||
}, [])
|
||||
return { lastSyncDate, totalDays, loadSyncStatus }
|
||||
}
|
||||
|
||||
function useAutoSyncEffect(params: {
|
||||
connection: ConnectionState
|
||||
isSyncing: boolean
|
||||
loadSyncStatus: () => Promise<void>
|
||||
startMonitoring: () => void
|
||||
stopMonitoring: () => void
|
||||
setError: (value: string | null) => void
|
||||
}): void {
|
||||
useEffect(() => {
|
||||
if (!params.connection.isInitialized || !params.connection.connected || !params.connection.pubkey) {
|
||||
return
|
||||
}
|
||||
void runAutoSyncCheck({
|
||||
connection: { connected: true, pubkey: params.connection.pubkey },
|
||||
isSyncing: params.isSyncing,
|
||||
loadSyncStatus: params.loadSyncStatus,
|
||||
startMonitoring: params.startMonitoring,
|
||||
stopMonitoring: params.stopMonitoring,
|
||||
setError: params.setError,
|
||||
})
|
||||
}, [params.connection.connected, params.connection.isInitialized, params.connection.pubkey, params.isSyncing, params.loadSyncStatus, params.setError, params.startMonitoring, params.stopMonitoring])
|
||||
}
|
||||
|
||||
function formatSyncDate(timestamp: number): string {
|
||||
const date = new Date(timestamp * 1000)
|
||||
const locale = typeof window !== 'undefined' ? navigator.language : 'fr-FR'
|
||||
return date.toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
}
|
||||
|
||||
function getSyncStartDate(lastSyncDate: number | null): number {
|
||||
return lastSyncDate ?? MIN_EVENT_DATE
|
||||
}
|
||||
|
||||
function isRecentlySyncedFromLastSyncDate(lastSyncDate: number | null): boolean {
|
||||
return lastSyncDate !== null && lastSyncDate >= getCurrentTimestamp() - 3600
|
||||
}
|
||||
|
||||
async function resynchronizeUserContent(params: {
|
||||
startMonitoring: () => void
|
||||
stopMonitoring: () => void
|
||||
loadSyncStatus: () => Promise<void>
|
||||
setError: (value: string | null) => void
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const state = nostrAuthService.getState()
|
||||
if (!state.connected || !state.pubkey) {
|
||||
return
|
||||
}
|
||||
await clearUserContentCache()
|
||||
await setLastSyncDateStorage(MIN_EVENT_DATE)
|
||||
await params.loadSyncStatus()
|
||||
await startUserSyncOrStop({ pubkey: state.pubkey, startMonitoring: params.startMonitoring, stopMonitoring: params.stopMonitoring })
|
||||
} catch (resyncError) {
|
||||
console.error('[SyncProgressBar] Error resynchronizing:', resyncError)
|
||||
params.stopMonitoring()
|
||||
params.setError(resyncError instanceof Error ? resyncError.message : 'Erreur de synchronisation')
|
||||
}
|
||||
}
|
||||
|
||||
async function clearUserContentCache(): Promise<void> {
|
||||
await Promise.all([
|
||||
objectCache.clear('author'),
|
||||
objectCache.clear('series'),
|
||||
objectCache.clear('publication'),
|
||||
objectCache.clear('review'),
|
||||
objectCache.clear('purchase'),
|
||||
objectCache.clear('sponsoring'),
|
||||
objectCache.clear('review_tip'),
|
||||
])
|
||||
}
|
||||
|
||||
async function startUserSyncOrStop(params: { pubkey: string; startMonitoring: () => void; stopMonitoring: () => void }): Promise<void> {
|
||||
const { swClient } = await import('@/lib/swClient')
|
||||
const isReady = await swClient.isReady()
|
||||
if (!isReady) {
|
||||
params.stopMonitoring()
|
||||
return
|
||||
}
|
||||
await swClient.startUserSync(params.pubkey)
|
||||
params.startMonitoring()
|
||||
}
|
||||
|
||||
function computeProgressPercentage(syncProgress: SyncProgress): number {
|
||||
if (!syncProgress || syncProgress.totalSteps <= 0) {
|
||||
return 0
|
||||
}
|
||||
return Math.min(100, (syncProgress.currentStep / syncProgress.totalSteps) * 100)
|
||||
}
|
||||
|
||||
async function runAutoSyncCheck(params: {
|
||||
connection: { connected: boolean; pubkey: string | null }
|
||||
isSyncing: boolean
|
||||
loadSyncStatus: () => Promise<void>
|
||||
startMonitoring: () => void
|
||||
stopMonitoring: () => void
|
||||
setError: (value: string | null) => void
|
||||
}): Promise<void> {
|
||||
console.warn('[SyncProgressBar] Starting sync check...')
|
||||
await params.loadSyncStatus()
|
||||
const shouldStart = await shouldAutoStartSync({ isSyncing: params.isSyncing, pubkey: params.connection.pubkey })
|
||||
if (!shouldStart || !params.connection.pubkey) {
|
||||
console.warn('[SyncProgressBar] Skipping auto-sync:', { shouldStart, isSyncing: params.isSyncing, hasPubkey: Boolean(params.connection.pubkey) })
|
||||
return
|
||||
}
|
||||
console.warn('[SyncProgressBar] Starting auto-sync...')
|
||||
await startAutoSync({
|
||||
pubkey: params.connection.pubkey,
|
||||
startMonitoring: params.startMonitoring,
|
||||
stopMonitoring: params.stopMonitoring,
|
||||
setError: params.setError,
|
||||
})
|
||||
}
|
||||
|
||||
async function shouldAutoStartSync(params: { isSyncing: boolean; pubkey: string | null }): Promise<boolean> {
|
||||
if (params.isSyncing || !params.pubkey) {
|
||||
return false
|
||||
}
|
||||
const storedLastSyncDate = await getLastSyncDate()
|
||||
const currentTimestamp = getCurrentTimestamp()
|
||||
const isRecentlySynced = storedLastSyncDate >= currentTimestamp - 3600
|
||||
console.warn('[SyncProgressBar] Sync status:', { storedLastSyncDate, currentTimestamp, isRecentlySynced })
|
||||
return !isRecentlySynced
|
||||
}
|
||||
|
||||
async function startAutoSync(params: { pubkey: string; startMonitoring: () => void; stopMonitoring: () => void; setError: (value: string | null) => void }): Promise<void> {
|
||||
try {
|
||||
const { swClient } = await import('@/lib/swClient')
|
||||
const isReady = await swClient.isReady()
|
||||
if (!isReady) {
|
||||
params.stopMonitoring()
|
||||
return
|
||||
}
|
||||
await swClient.startUserSync(params.pubkey)
|
||||
params.startMonitoring()
|
||||
} catch (autoSyncError) {
|
||||
console.error('[SyncProgressBar] Error during auto-sync:', autoSyncError)
|
||||
params.stopMonitoring()
|
||||
params.setError(autoSyncError instanceof Error ? autoSyncError.message : 'Erreur de synchronisation')
|
||||
}
|
||||
}
|
||||
16
components/syncProgressBar/types.ts
Normal file
16
components/syncProgressBar/types.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import type { useSyncProgress } from '@/lib/hooks/useSyncProgress'
|
||||
|
||||
export type SyncProgress = ReturnType<typeof useSyncProgress>['syncProgress']
|
||||
|
||||
export type SyncProgressBarController = {
|
||||
error: string | null
|
||||
dismissError: () => void
|
||||
isSyncing: boolean
|
||||
syncProgress: SyncProgress
|
||||
progressPercentage: number
|
||||
totalDays: number
|
||||
startDateLabel: string
|
||||
endDateLabel: string
|
||||
isRecentlySynced: boolean
|
||||
onResyncClick: () => void
|
||||
}
|
||||
73
components/syncProgressBar/view.tsx
Normal file
73
components/syncProgressBar/view.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
import { t } from '@/lib/i18n'
|
||||
import type { SyncProgress } from './types'
|
||||
|
||||
export function SyncErrorBanner(params: { error: string | null; onDismiss: () => void }): React.ReactElement | null {
|
||||
if (!params.error) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div className="mb-4 bg-red-900/30 border border-red-500/50 rounded p-3 text-red-300 text-sm">
|
||||
{params.error}
|
||||
<button type="button" onClick={params.onDismiss} className="ml-2 text-red-400 hover:text-red-200">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SyncResyncButton(params: { isSyncing: boolean; onClick: () => void }): React.ReactElement | null {
|
||||
if (params.isSyncing) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={params.onClick}
|
||||
className="px-3 py-1 text-xs bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded border border-neon-cyan/50 hover:border-neon-cyan transition-colors"
|
||||
>
|
||||
{t('settings.sync.resync')}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function SyncDateRange(params: { totalDays: number; startDate: string; endDate: string }): React.ReactElement | null {
|
||||
if (params.totalDays <= 0) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div className="mb-2">
|
||||
<p className="text-sm text-cyber-accent">
|
||||
{t('settings.sync.daysRange', { startDate: params.startDate, endDate: params.endDate, days: params.totalDays })}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SyncProgressSection(params: { isSyncing: boolean; syncProgress: SyncProgress; progressPercentage: number }): React.ReactElement | null {
|
||||
if (!params.isSyncing || !params.syncProgress) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-cyber-accent">
|
||||
{t('settings.sync.progress', { current: params.syncProgress.currentStep, total: params.syncProgress.totalSteps })}
|
||||
</span>
|
||||
<span className="text-neon-cyan font-semibold">{Math.round(params.progressPercentage)}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-cyber-dark rounded-full h-2 overflow-hidden">
|
||||
<div className="bg-neon-cyan h-full transition-all duration-300" style={{ width: `${params.progressPercentage}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SyncStatusMessage(params: { isSyncing: boolean; totalDays: number; isRecentlySynced: boolean }): React.ReactElement | null {
|
||||
if (params.isSyncing || params.totalDays !== 0) {
|
||||
return null
|
||||
}
|
||||
if (params.isRecentlySynced) {
|
||||
return <p className="text-sm text-green-400">{t('settings.sync.completed')}</p>
|
||||
}
|
||||
return <p className="text-sm text-cyber-accent">{t('settings.sync.ready')}</p>
|
||||
}
|
||||
26
components/unlockAccount/UnlockAccountButtons.tsx
Normal file
26
components/unlockAccount/UnlockAccountButtons.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
export function UnlockAccountButtons(params: {
|
||||
loading: boolean
|
||||
words: string[]
|
||||
onUnlock: () => void
|
||||
onClose: () => void
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={params.onClose}
|
||||
className="flex-1 py-2 px-4 bg-gray-200 hover:bg-gray-300 rounded-lg font-medium transition-colors"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={params.onUnlock}
|
||||
disabled={params.loading || params.words.some((word) => !word)}
|
||||
className="flex-1 py-2 px-4 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan disabled:opacity-50"
|
||||
>
|
||||
{params.loading ? 'Déverrouillage...' : 'Déverrouiller'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
45
components/unlockAccount/UnlockAccountForm.tsx
Normal file
45
components/unlockAccount/UnlockAccountForm.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import { WordInputWithAutocomplete } from './WordInputWithAutocomplete'
|
||||
|
||||
export function UnlockAccountForm(params: {
|
||||
words: string[]
|
||||
onWordChange: (index: number, value: string) => void
|
||||
onPaste: () => void
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<WordInputsGrid words={params.words} onWordChange={params.onWordChange} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={params.onPaste}
|
||||
className="mt-2 text-sm text-gray-600 hover:text-gray-800 underline"
|
||||
>
|
||||
Coller depuis le presse-papiers
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WordInputsGrid(params: { words: string[]; onWordChange: (index: number, value: string) => void }): React.ReactElement {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<WordInputSlot index={0} value={params.words[0] ?? ''} onWordChange={params.onWordChange} />
|
||||
<WordInputSlot index={1} value={params.words[1] ?? ''} onWordChange={params.onWordChange} />
|
||||
<WordInputSlot index={2} value={params.words[2] ?? ''} onWordChange={params.onWordChange} />
|
||||
<WordInputSlot index={3} value={params.words[3] ?? ''} onWordChange={params.onWordChange} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WordInputSlot(params: { index: number; value: string; onWordChange: (index: number, value: string) => void }): React.ReactElement {
|
||||
const onFocus = (): void => {}
|
||||
const onBlur = (): void => {}
|
||||
return (
|
||||
<WordInputWithAutocomplete
|
||||
index={params.index}
|
||||
value={params.value}
|
||||
onChange={(value) => params.onWordChange(params.index, value)}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
)
|
||||
}
|
||||
35
components/unlockAccount/UnlockAccountModal.tsx
Normal file
35
components/unlockAccount/UnlockAccountModal.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import { useState } from 'react'
|
||||
import type { UnlockAccountModalProps } from './types'
|
||||
import { UnlockAccountButtons } from './UnlockAccountButtons'
|
||||
import { UnlockAccountForm } from './UnlockAccountForm'
|
||||