create for series

This commit is contained in:
Nicolas Cantu 2026-01-14 01:08:33 +01:00
parent 055465ac7b
commit fb0457b8d6
19 changed files with 252 additions and 152 deletions

View File

@ -1,5 +1,6 @@
import Image from 'next/image' import Image from 'next/image'
import Link from 'next/link' import Link from 'next/link'
import { Button } from './ui'
import type { NostrProfile } from '@/types/nostr' import type { NostrProfile } from '@/types/nostr'
import { NotificationCenter } from './NotificationCenter' import { NotificationCenter } from './NotificationCenter'
@ -36,13 +37,14 @@ export function ConnectedUserMenu({
)} )}
<span className="text-sm font-medium">{displayName}</span> <span className="text-sm font-medium">{displayName}</span>
</Link> </Link>
<button <Button
variant="secondary"
size="small"
onClick={onDisconnect} onClick={onDisconnect}
disabled={loading} disabled={loading}
className="px-4 py-2 bg-gray-200 hover:bg-gray-300 rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
> >
Disconnect Disconnect
</button> </Button>
</div> </div>
) )
} }

View File

@ -1,5 +1,6 @@
import { useState } from 'react' import { useState } from 'react'
import { uploadNip95Media } from '@/lib/nip95' import { uploadNip95Media } from '@/lib/nip95'
import { Button } from './ui'
import { t } from '@/lib/i18n' import { t } from '@/lib/i18n'
import Image from 'next/image' import Image from 'next/image'
import { UnlockAccountModal } from './UnlockAccountModal' import { UnlockAccountModal } from './UnlockAccountModal'
@ -37,13 +38,14 @@ function RemoveButton({ value, onChange }: { value: string | undefined; onChange
return null return null
} }
return ( return (
<button <Button
type="button" type="button"
variant="danger"
size="small"
onClick={() => onChange('')} onClick={() => onChange('')}
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"
> >
{t('presentation.field.picture.remove')} {t('presentation.field.picture.remove')}
</button> </Button>
) )
} }
@ -62,11 +64,10 @@ function ImageUploadControls({
}): React.ReactElement { }): React.ReactElement {
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label <label htmlFor={id} className="cursor-pointer">
htmlFor={id} <span className="inline-flex items-center justify-center px-4 py-2 text-sm font-medium rounded-lg transition-all border bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan border-neon-cyan/50 hover:shadow-glow-cyan">
className="px-4 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg text-sm font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan cursor-pointer"
>
<UploadButtonLabel uploading={uploading} value={value} /> <UploadButtonLabel uploading={uploading} value={value} />
</span>
</label> </label>
<input <input
id={id} id={id}

View File

@ -1,5 +1,6 @@
import { useState } from 'react' import { useState } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { Button } from './ui'
import { SeriesSection } from './SeriesSection' import { SeriesSection } from './SeriesSection'
import { CreateSeriesModal } from './CreateSeriesModal' import { CreateSeriesModal } from './CreateSeriesModal'
import { useNostrAuth } from '@/hooks/useNostrAuth' import { useNostrAuth } from '@/hooks/useNostrAuth'
@ -54,13 +55,13 @@ function ProfileSeriesHeader({ isAuthor, onCreate }: { isAuthor: boolean; onCrea
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<h3 className="text-lg font-semibold">Séries</h3> <h3 className="text-lg font-semibold">Séries</h3>
{isAuthor && ( {isAuthor && (
<button <Button
type="button" type="button"
variant="primary"
onClick={onCreate} onClick={onCreate}
className="px-4 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan"
> >
{t('series.create.button')} {t('series.create.button')}
</button> </Button>
)} )}
</div> </div>
) )

View File

@ -1,5 +1,6 @@
import Image from 'next/image' import Image from 'next/image'
import Link from 'next/link' import Link from 'next/link'
import { Button } from './ui'
import type { Series } from '@/types/nostr' import type { Series } from '@/types/nostr'
import { t } from '@/lib/i18n' import { t } from '@/lib/i18n'
@ -33,13 +34,14 @@ export function SeriesCard({ series, onSelect, selected }: SeriesCardProps): Rea
<p className="text-sm text-cyber-accent line-clamp-3 mb-3">{series.description}</p> <p className="text-sm text-cyber-accent line-clamp-3 mb-3">{series.description}</p>
<div className="mt-3 flex items-center justify-between text-sm text-cyber-accent/70"> <div className="mt-3 flex items-center justify-between text-sm text-cyber-accent/70">
<span>{series.category === 'science-fiction' ? t('category.science-fiction') : t('category.scientific-research')}</span> <span>{series.category === 'science-fiction' ? t('category.science-fiction') : t('category.scientific-research')}</span>
<button <Button
type="button" type="button"
className="px-3 py-1 text-sm rounded-lg bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan border border-neon-cyan/50 hover:shadow-glow-cyan transition-all font-medium" variant="primary"
size="small"
onClick={() => onSelect(series.id)} onClick={() => onSelect(series.id)}
> >
{t('common.open')} {t('common.open')}
</button> </Button>
</div> </div>
<div className="mt-2 text-xs text-neon-cyan/70"> <div className="mt-2 text-xs text-neon-cyan/70">
<Link href={`/series/${series.id}`} className="hover:text-neon-cyan transition-colors underline"> <Link href={`/series/${series.id}`} className="hover:text-neon-cyan transition-colors underline">

View File

@ -1,4 +1,5 @@
import { ArticleCard } from './ArticleCard' import { ArticleCard } from './ArticleCard'
import { Button } from './ui'
import type { Article } from '@/types/nostr' import type { Article } from '@/types/nostr'
import { memo } from 'react' import { memo } from 'react'
import Link from 'next/link' import Link from 'next/link'
@ -56,19 +57,21 @@ function ArticleActions({
}): React.ReactElement { }): React.ReactElement {
return ( return (
<div className="flex gap-2"> <div className="flex gap-2">
<button <Button
variant="primary"
size="small"
onClick={() => onEdit(article)} onClick={() => onEdit(article)}
className="px-3 py-1 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
disabled={editingArticleId !== null && editingArticleId !== article.id} disabled={editingArticleId !== null && editingArticleId !== article.id}
> >
{t('common.edit')} {t('common.edit')}
</button> </Button>
<button <Button
variant="danger"
size="small"
onClick={() => (pendingDeleteId === article.id ? onDelete(article) : requestDelete(article.id))} onClick={() => (pendingDeleteId === article.id ? onDelete(article) : requestDelete(article.id))}
className="px-3 py-1 text-sm rounded bg-red-600 text-white hover:bg-red-700"
> >
{pendingDeleteId === article.id ? t('common.confirmDelete') : t('common.delete')} {pendingDeleteId === article.id ? t('common.confirmDelete') : t('common.delete')}
</button> </Button>
</div> </div>
) )
} }

View File

@ -2,6 +2,7 @@ import Link from 'next/link'
import { useState } from 'react' import { useState } from 'react'
import { CreateSeriesModal } from '@/components/CreateSeriesModal' import { CreateSeriesModal } from '@/components/CreateSeriesModal'
import { SeriesCard } from '@/components/SeriesCard' import { SeriesCard } from '@/components/SeriesCard'
import { Button } from '@/components/ui'
import { useNostrAuth } from '@/hooks/useNostrAuth' import { useNostrAuth } from '@/hooks/useNostrAuth'
import { t } from '@/lib/i18n' import { t } from '@/lib/i18n'
import type { Series } from '@/types/nostr' import type { Series } from '@/types/nostr'
@ -30,13 +31,13 @@ function SeriesListHeader(params: { isAuthor: boolean; onCreate: () => void }):
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-2xl font-semibold text-neon-cyan">{t('series.title')}</h2> <h2 className="text-2xl font-semibold text-neon-cyan">{t('series.title')}</h2>
{params.isAuthor && ( {params.isAuthor && (
<button <Button
type="button" type="button"
variant="primary"
onClick={params.onCreate} onClick={params.onCreate}
className="px-4 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan"
> >
{t('series.create.button')} {t('series.create.button')}
</button> </Button>
)} )}
</div> </div>
) )

View File

@ -1,4 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import { Button } from '@/components/ui'
import { SponsoringForm } from '@/components/SponsoringForm' import { SponsoringForm } from '@/components/SponsoringForm'
import { t } from '@/lib/i18n' import { t } from '@/lib/i18n'
import type { AuthorPresentationArticle } from '@/types/nostr' import type { AuthorPresentationArticle } from '@/types/nostr'
@ -27,12 +28,12 @@ function SponsoringSummaryHeader(params: { showSponsorButton: boolean; onSponsor
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-neon-cyan">{t('author.sponsoring')}</h2> <h2 className="text-xl font-semibold text-neon-cyan">{t('author.sponsoring')}</h2>
{params.showSponsorButton && ( {params.showSponsorButton && (
<button <Button
variant="success"
onClick={params.onSponsorClick} onClick={params.onSponsorClick}
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('sponsoring.form.submit')} {t('sponsoring.form.submit')}
</button> </Button>
)} )}
</div> </div>
) )

View File

@ -1,6 +1,7 @@
import { useEffect } from 'react' import { useEffect } from 'react'
import { useNostrAuth } from '@/hooks/useNostrAuth' import { useNostrAuth } from '@/hooks/useNostrAuth'
import { useAuthorPresentation } from '@/hooks/useAuthorPresentation' import { useAuthorPresentation } from '@/hooks/useAuthorPresentation'
import { Button } from '../ui'
import { t } from '@/lib/i18n' import { t } from '@/lib/i18n'
import { NoAccountView } from './NoAccountView' import { NoAccountView } from './NoAccountView'
import { PresentationForm } from './PresentationForm' import { PresentationForm } from './PresentationForm'
@ -14,11 +15,10 @@ function SuccessNotice(params: { pubkey: string | null }): React.ReactElement {
<p className="text-cyber-accent mb-4">{t('presentation.successMessage')}</p> <p className="text-cyber-accent mb-4">{t('presentation.successMessage')}</p>
{params.pubkey ? ( {params.pubkey ? (
<div className="mt-4"> <div className="mt-4">
<a <a href={`/author/${params.pubkey}`}>
href={`/author/${params.pubkey}`} <Button variant="primary">
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')} {t('presentation.manageSeries')}
</Button>
</a> </a>
</div> </div>
) : null} ) : null}

View File

@ -1,5 +1,6 @@
import type { FormEvent } from 'react' import type { FormEvent } from 'react'
import { PresentationFormHeader } from '../PresentationFormHeader' import { PresentationFormHeader } from '../PresentationFormHeader'
import { Button, ErrorState } from '../ui'
import { t } from '@/lib/i18n' import { t } from '@/lib/i18n'
import { PresentationFields } from './fields' import { PresentationFields } from './fields'
import type { AuthorPresentationDraft } from './types' import type { AuthorPresentationDraft } from './types'
@ -29,13 +30,15 @@ export function PresentationForm(props: PresentationFormProps): React.ReactEleme
<ValidationError message={props.validationError ?? props.error} /> <ValidationError message={props.validationError ?? props.error} />
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="flex-1"> <div className="flex-1">
<button <Button
type="submit" type="submit"
variant="primary"
disabled={props.loading || props.deleting} 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" loading={props.loading || props.deleting}
className="w-full"
> >
{getSubmitLabel({ loading: props.loading, deleting: props.deleting, hasExistingPresentation: props.hasExistingPresentation })} {getSubmitLabel({ loading: props.loading, deleting: props.deleting, hasExistingPresentation: props.hasExistingPresentation })}
</button> </Button>
</div> </div>
{props.hasExistingPresentation ? <DeleteButton onDelete={props.handleDelete} deleting={props.deleting} /> : null} {props.hasExistingPresentation ? <DeleteButton onDelete={props.handleDelete} deleting={props.deleting} /> : null}
</div> </div>
@ -47,23 +50,21 @@ function ValidationError(params: { message: string | null }): React.ReactElement
if (!params.message) { if (!params.message) {
return null return null
} }
return ( return <ErrorState message={params.message} />
<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 { function DeleteButton(params: { onDelete: () => void; deleting: boolean }): React.ReactElement {
return ( return (
<button <Button
type="button" type="button"
variant="danger"
onClick={params.onDelete} onClick={params.onDelete}
disabled={params.deleting} 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" loading={params.deleting}
size="small"
> >
{params.deleting ? t('presentation.delete.deleting') : t('presentation.delete.button')} {params.deleting ? t('presentation.delete.deleting') : t('presentation.delete.button')}
</button> </Button>
) )
} }

View File

@ -1,5 +1,6 @@
import React from 'react' import React from 'react'
import { ImageUploadField } from '../ImageUploadField' import { ImageUploadField } from '../ImageUploadField'
import { Button, Input, Textarea } from '../ui'
import { t } from '@/lib/i18n' import { t } from '@/lib/i18n'
import type { SeriesDraft } from './createSeriesModalTypes' import type { SeriesDraft } from './createSeriesModalTypes'
import type { CreateSeriesModalController } from './useCreateSeriesModalController' import type { CreateSeriesModalController } from './useCreateSeriesModalController'
@ -145,21 +146,22 @@ function SeriesError({ error }: { error: string | null }): React.ReactElement |
function SeriesActions(params: { loading: boolean; canPublish: boolean; onClose: () => void }): React.ReactElement { function SeriesActions(params: { loading: boolean; canPublish: boolean; onClose: () => void }): React.ReactElement {
return ( return (
<div className="flex items-center justify-end gap-4 pt-4"> <div className="flex items-center justify-end gap-4 pt-4">
<button <Button
type="button" type="button"
variant="ghost"
onClick={params.onClose} onClick={params.onClose}
disabled={params.loading} disabled={params.loading}
className="px-4 py-2 bg-cyber-darker border border-cyber-accent/30 rounded text-cyber-light hover:border-neon-cyan transition-colors disabled:opacity-50"
> >
{t('common.cancel')} {t('common.cancel')}
</button> </Button>
<button <Button
type="submit" type="submit"
variant="primary"
disabled={params.loading || !params.canPublish} disabled={params.loading || !params.canPublish}
className="px-4 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan disabled:opacity-50 disabled:cursor-not-allowed" loading={params.loading}
> >
{params.loading ? t('common.loading') : t('series.create.submit')} {params.loading ? t('common.loading') : t('series.create.submit')}
</button> </Button>
</div> </div>
) )
} }
@ -173,20 +175,15 @@ function TextField(params: {
onChange: (value: string) => void onChange: (value: string) => void
}): React.ReactElement { }): React.ReactElement {
return ( return (
<div> <Input
<label htmlFor={params.id} className="block text-sm font-medium text-neon-cyan mb-2">
{params.label}
</label>
<input
id={params.id} id={params.id}
type="text" type="text"
label={params.label}
value={params.value} value={params.value}
onChange={(e) => params.onChange(e.target.value)} onChange={(e) => params.onChange(e.target.value)}
className="w-full px-3 py-2 bg-cyber-darker border border-cyber-accent/30 rounded text-cyber-light focus:border-neon-cyan focus:outline-none"
required={params.required} required={params.required}
disabled={params.disabled} disabled={params.disabled}
/> />
</div>
) )
} }
@ -201,20 +198,15 @@ function TextAreaField(params: {
onChange: (value: string) => void onChange: (value: string) => void
}): React.ReactElement { }): React.ReactElement {
return ( return (
<div> <Textarea
<label htmlFor={params.id} className="block text-sm font-medium text-neon-cyan mb-2">
{params.label}
</label>
<textarea
id={params.id} id={params.id}
label={params.label}
value={params.value} value={params.value}
onChange={(e) => params.onChange(e.target.value)} onChange={(e) => params.onChange(e.target.value)}
rows={params.rows} rows={params.rows}
className="w-full px-3 py-2 bg-cyber-darker border border-cyber-accent/30 rounded text-cyber-light focus:border-neon-cyan focus:outline-none"
required={params.required} required={params.required}
disabled={params.disabled} disabled={params.disabled}
{...(params.helpText ? { helperText: params.helpText } : {})}
/> />
{params.helpText ? <p className="text-xs text-cyber-accent/70 mt-1">{params.helpText}</p> : null}
</div>
) )
} }

View File

@ -1,3 +1,4 @@
import { Button } from '@/components/ui'
import { t } from '@/lib/i18n' import { t } from '@/lib/i18n'
import type { KeyManagementManagerActions } from './useKeyManagementManager' import type { KeyManagementManagerActions } from './useKeyManagementManager'
import type { KeyManagementManagerState } from './keyManagementController' import type { KeyManagementManagerState } from './keyManagementController'
@ -77,13 +78,14 @@ function KeyManagementKeyCard(params: {
<div className="bg-neon-blue/10 border border-neon-blue/30 rounded-lg p-4"> <div className="bg-neon-blue/10 border border-neon-blue/30 rounded-lg p-4">
<div className="flex justify-between items-start mb-2"> <div className="flex justify-between items-start mb-2">
<p className="text-neon-blue font-semibold">{params.label}</p> <p className="text-neon-blue font-semibold">{params.label}</p>
<button <Button
type="button" type="button"
variant="secondary"
size="small"
onClick={params.onCopy} 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')} {params.copied ? t('settings.keyManagement.copied') : t('settings.keyManagement.copy')}
</button> </Button>
</div> </div>
<p className="text-neon-cyan text-sm font-mono break-all">{params.value}</p> <p className="text-neon-cyan text-sm font-mono break-all">{params.value}</p>
</div> </div>

View File

@ -1,4 +1,5 @@
import type { Nip95Config } from '@/lib/configStorageTypes' import type { Nip95Config } from '@/lib/configStorageTypes'
import { Button } from '../ui'
import { t } from '@/lib/i18n' import { t } from '@/lib/i18n'
import { Nip95ApiList } from './Nip95ApiList' import { Nip95ApiList } from './Nip95ApiList'
@ -51,13 +52,13 @@ function Header(params: { showAddForm: boolean; onToggleAddForm: () => void }):
return ( return (
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<h2 className="text-2xl font-bold text-neon-cyan">{t('settings.nip95.title')}</h2> <h2 className="text-2xl font-bold text-neon-cyan">{t('settings.nip95.title')}</h2>
<button <Button
type="button" type="button"
variant="primary"
onClick={params.onToggleAddForm} 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')} {params.showAddForm ? t('settings.nip95.add.cancel') : t('settings.nip95.addButton')}
</button> </Button>
</div> </div>
) )
} }
@ -76,20 +77,20 @@ function AddForm(params: { newUrl: string; onNewUrlChange: (value: string) => vo
/> />
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<button <Button
type="button" type="button"
variant="primary"
onClick={params.onAdd} 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')} {t('settings.nip95.add.add')}
</button> </Button>
<button <Button
type="button" type="button"
variant="ghost"
onClick={params.onCancel} 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')} {t('settings.nip95.add.cancel')}
</button> </Button>
</div> </div>
</div> </div>
) )

View File

@ -1,3 +1,4 @@
import { Button } from '../ui'
import { t } from '@/lib/i18n' import { t } from '@/lib/i18n'
import { RelayList } from './RelayList' import { RelayList } from './RelayList'
import type { RelayManagerContentProps } from './types' import type { RelayManagerContentProps } from './types'
@ -44,13 +45,13 @@ function Header(params: { showAddForm: boolean; onToggleAddForm: () => void }):
return ( return (
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<h2 className="text-2xl font-bold text-neon-cyan">{t('settings.relay.title')}</h2> <h2 className="text-2xl font-bold text-neon-cyan">{t('settings.relay.title')}</h2>
<button <Button
type="button" type="button"
variant="primary"
onClick={params.onToggleAddForm} 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')} {params.showAddForm ? t('settings.relay.add.cancel') : t('settings.relay.addButton')}
</button> </Button>
</div> </div>
) )
} }
@ -69,20 +70,20 @@ function AddForm(params: { newUrl: string; onNewUrlChange: (value: string) => vo
/> />
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<button <Button
type="button" type="button"
variant="primary"
onClick={params.onAdd} 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')} {t('settings.relay.add.add')}
</button> </Button>
<button <Button
type="button" type="button"
variant="ghost"
onClick={params.onCancel} 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')} {t('settings.relay.add.cancel')}
</button> </Button>
</div> </div>
</div> </div>
) )

View File

@ -1,3 +1,4 @@
import { Button, ErrorState } from '../ui'
import { t } from '@/lib/i18n' import { t } from '@/lib/i18n'
import type { SyncProgress } from './types' import type { SyncProgress } from './types'
@ -6,11 +7,11 @@ export function SyncErrorBanner(params: { error: string | null; onDismiss: () =>
return null return null
} }
return ( return (
<div className="mb-4 bg-red-900/30 border border-red-500/50 rounded p-3 text-red-300 text-sm"> <div className="mb-4">
{params.error} <ErrorState message={params.error} />
<button type="button" onClick={params.onDismiss} className="ml-2 text-red-400 hover:text-red-200"> <Button type="button" variant="ghost" size="small" onClick={params.onDismiss} className="mt-2">
× ×
</button> </Button>
</div> </div>
) )
} }
@ -20,13 +21,14 @@ export function SyncResyncButton(params: { isSyncing: boolean; onClick: () => vo
return null return null
} }
return ( return (
<button <Button
type="button" type="button"
variant="primary"
size="small"
onClick={params.onClick} 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')} {t('settings.sync.resync')}
</button> </Button>
) )
} }

View File

@ -1,8 +1,6 @@
import { useState } from 'react' import { useState } from 'react'
import { nostrService } from '@/lib/nostr'
import { articlePublisher } from '@/lib/articlePublisher' import { articlePublisher } from '@/lib/articlePublisher'
import type { Article } from '@/types/nostr' import type { Article } from '@/types/nostr'
import type { NostrProfile } from '@/types/nostr'
interface AuthorPresentationDraft { interface AuthorPresentationDraft {
authorName: string authorName: string
@ -60,7 +58,6 @@ async function publishAuthorPresentation(params: {
params.setError(null) params.setError(null)
try { try {
const privateKey = getPrivateKeyOrThrow('Clé privée requise pour publier. Veuillez vous connecter avec un portefeuille Nostr qui fournit des capacités de signature.') const privateKey = getPrivateKeyOrThrow('Clé privée requise pour publier. Veuillez vous connecter avec un portefeuille Nostr qui fournit des capacités de signature.')
await updateProfileBestEffort(params.draft)
const result = await publishPresentationArticleWithDraft({ draft: params.draft, pubkey: params.pubkey, privateKey }) const result = await publishPresentationArticleWithDraft({ draft: params.draft, pubkey: params.pubkey, privateKey })
params.setSuccess(result.success === true) params.setSuccess(result.success === true)
if (!result.success) { if (!result.success) {
@ -96,18 +93,6 @@ async function publishPresentationArticleWithDraft(params: {
) )
} }
async function updateProfileBestEffort(draft: AuthorPresentationDraft): Promise<void> {
const profileUpdates: Partial<NostrProfile> = {
name: draft.authorName.trim(),
...(draft.pictureUrl ? { picture: draft.pictureUrl } : {}),
}
try {
await nostrService.updateProfile(profileUpdates)
} catch (e) {
console.error('Error updating profile:', e)
}
}
function buildPresentationContent(draft: AuthorPresentationDraft): { title: string; preview: string; fullContent: string } { function buildPresentationContent(draft: AuthorPresentationDraft): { title: string; preview: string; fullContent: string } {
const title = `Présentation de ${draft.authorName.trim()}` const title = `Présentation de ${draft.authorName.trim()}`
const preview = draft.presentation.substring(0, 200) const preview = draft.presentation.substring(0, 200)

View File

@ -4,6 +4,8 @@
*/ */
import type { SimplePoolWithSub } from '@/types/nostr-tools-extended' import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
import type { AuthorPresentationArticle, NostrProfile } from '@/types/nostr'
import { extractAuthorNameFromTitle } from './authorPresentationParsing'
import { objectCache } from './objectCache' import { objectCache } from './objectCache'
/** /**
@ -46,3 +48,20 @@ export async function fetchAuthorByHashId(
// Not found in cache - return null (no network request) // Not found in cache - return null (no network request)
return null return null
} }
/**
* Convert AuthorPresentationArticle to NostrProfile for display purposes
* Extracts name from title, uses bannerUrl as picture, description as about
*/
export function convertPlatformProfileToNostrProfile(
presentation: AuthorPresentationArticle
): NostrProfile {
const authorName = extractAuthorNameFromTitle(presentation.title)
const profile: NostrProfile = {
pubkey: presentation.pubkey,
name: authorName,
about: presentation.description,
picture: presentation.bannerUrl,
}
return profile
}

View File

@ -1,4 +1,4 @@
import { finalizeEvent, nip19, SimplePool, type Event, type EventTemplate } from 'nostr-tools' import { finalizeEvent, nip19, SimplePool, type Event, type EventTemplate, type Filter } from 'nostr-tools'
import { hexToBytes } from 'nostr-tools/utils' import { hexToBytes } from 'nostr-tools/utils'
import type { Article, NostrProfile } from '@/types/nostr' import type { Article, NostrProfile } from '@/types/nostr'
import type { PublishResult } from '../publishResult' import type { PublishResult } from '../publishResult'
@ -121,31 +121,19 @@ class NostrService {
return getDecryptedArticleContent({ pool: this.pool, eventId, authorPubkey, privateKey: this.privateKey, publicKey: this.publicKey }) return getDecryptedArticleContent({ pool: this.pool, eventId, authorPubkey, privateKey: this.privateKey, publicKey: this.publicKey })
} }
async getProfile(_pubkey: string): Promise<NostrProfile | null> { async getProfile(pubkey: string): Promise<NostrProfile | null> {
if (!this.pool) {
return null return null
} }
async updateProfile(updates: Partial<NostrProfile>): Promise<void> { const { getPrimaryRelaySync } = await import('../config')
if (!this.privateKey || !this.publicKey) { const { createSubscription } = await import('@/types/nostr-tools-extended')
throw new Error('Private key and public key must be set to update profile') const relayUrl = getPrimaryRelaySync()
}
const existingProfile = await this.getProfile(this.publicKey) const filters: Filter[] = [{ kinds: [0], authors: [pubkey] }]
const currentProfile: NostrProfile = existingProfile ?? { pubkey: this.publicKey } const sub = createSubscription(this.pool, [relayUrl], filters)
const updatedProfile: NostrProfile = { ...currentProfile, ...updates, pubkey: this.publicKey }
const profileEvent: EventTemplate = { return subscribeToProfile({ sub, pubkey })
kind: 0,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: JSON.stringify({
name: updatedProfile.name,
about: updatedProfile.about,
picture: updatedProfile.picture,
nip05: updatedProfile.nip05,
lud16: updatedProfile.lud16,
lud06: updatedProfile.lud06,
}),
}
await this.publishEvent(profileEvent)
} }
async createZapRequest(targetPubkey: string, targetEventId: string, amount: number, extraTags: string[][] = []): Promise<Event> { async createZapRequest(targetPubkey: string, targetEventId: string, amount: number, extraTags: string[][] = []): Promise<Event> {
@ -179,4 +167,82 @@ class NostrService {
} }
} }
async function subscribeToProfile(params: { sub: ReturnType<typeof import('@/types/nostr-tools-extended').createSubscription>; pubkey: string }): Promise<NostrProfile | null> {
return new Promise<NostrProfile | null>((resolve) => {
const resolved = { value: false }
let timeoutId: ReturnType<typeof setTimeout> | null = null
const cleanup = (): void => {
if (timeoutId) {
clearTimeout(timeoutId)
}
params.sub.unsub()
}
const resolveOnce = (value: NostrProfile | null): void => {
if (resolved.value) {
return
}
resolved.value = true
cleanup()
resolve(value)
}
params.sub.on('event', (event: Event): void => {
try {
const parsed = parseProfileEvent(event, params.pubkey)
if (parsed) {
resolveOnce(parsed)
}
} catch (e) {
console.error('Error parsing profile event:', e)
}
})
params.sub.on('eose', (): void => {
resolveOnce(null)
})
timeoutId = setTimeout(() => {
resolveOnce(null)
}, 5000)
})
}
function parseProfileEvent(event: Event, pubkey: string): NostrProfile | null {
if (event.kind !== 0) {
return null
}
if (event.pubkey !== pubkey) {
return null
}
try {
const parsed = JSON.parse(event.content) as Record<string, unknown>
const profile: NostrProfile = { pubkey }
if (typeof parsed.name === 'string') {
profile.name = parsed.name
}
if (typeof parsed.about === 'string') {
profile.about = parsed.about
}
if (typeof parsed.picture === 'string') {
profile.picture = parsed.picture
}
if (typeof parsed.nip05 === 'string') {
profile.nip05 = parsed.nip05
}
if (typeof parsed.lud16 === 'string') {
profile.lud16 = parsed.lud16
}
if (typeof parsed.lud06 === 'string') {
profile.lud06 = parsed.lud06
}
return profile
} catch (e) {
console.error('Error parsing profile JSON:', e)
return null
}
}
export const nostrService = new NostrService() export const nostrService = new NostrService()

View File

@ -1,5 +1,6 @@
import { nostrService } from './nostr' import { nostrService } from './nostr'
import { keyManagementService } from './keyManagement' import { keyManagementService } from './keyManagement'
import { fetchAuthorByHashId, convertPlatformProfileToNostrProfile } from './authorQueries'
import type { NostrConnectState, NostrProfile } from '@/types/nostr' import type { NostrConnectState, NostrProfile } from '@/types/nostr'
/** /**
@ -173,8 +174,14 @@ export class NostrAuthService {
} }
try { try {
const profile = await nostrService.getProfile(this.state.pubkey) const pool = nostrService.getPool()
if (profile) { if (!pool) {
return
}
const presentation = await fetchAuthorByHashId(pool, this.state.pubkey)
if (presentation) {
const profile = convertPlatformProfileToNostrProfile(presentation)
this.state.profile = profile this.state.profile = profile
void this.saveStateToStorage() void this.saveStateToStorage()
this.notifyListeners() this.notifyListeners()

View File

@ -6,6 +6,7 @@ import { ProfileView } from '@/components/ProfileView'
import { useNostrAuth } from '@/hooks/useNostrAuth' import { useNostrAuth } from '@/hooks/useNostrAuth'
import { useUserArticles } from '@/hooks/useUserArticles' import { useUserArticles } from '@/hooks/useUserArticles'
import { nostrService } from '@/lib/nostr' import { nostrService } from '@/lib/nostr'
import { fetchAuthorByHashId, convertPlatformProfileToNostrProfile } from '@/lib/authorQueries'
function useUserProfileData(currentPubkey: string | null): { function useUserProfileData(currentPubkey: string | null): {
profile: NostrProfile | null profile: NostrProfile | null
@ -25,8 +26,20 @@ function useUserProfileData(currentPubkey: string | null): {
const load = async (): Promise<void> => { const load = async (): Promise<void> => {
try { try {
const loadedProfile = await nostrService.getProfile(currentPubkey) const pool = nostrService.getPool()
setProfile(loadedProfile ?? createMinimalProfile()) if (!pool) {
setProfile(createMinimalProfile())
setLoadingProfile(false)
return
}
const presentation = await fetchAuthorByHashId(pool, currentPubkey)
if (presentation) {
const loadedProfile = convertPlatformProfileToNostrProfile(presentation)
setProfile(loadedProfile)
} else {
setProfile(createMinimalProfile())
}
} catch (e) { } catch (e) {
console.error('Error loading profile:', e) console.error('Error loading profile:', e)
setProfile(createMinimalProfile()) setProfile(createMinimalProfile())