create for series
This commit is contained in:
parent
055465ac7b
commit
fb0457b8d6
@ -1,5 +1,6 @@
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { Button } from './ui'
|
||||
import type { NostrProfile } from '@/types/nostr'
|
||||
import { NotificationCenter } from './NotificationCenter'
|
||||
|
||||
@ -36,13 +37,14 @@ export function ConnectedUserMenu({
|
||||
)}
|
||||
<span className="text-sm font-medium">{displayName}</span>
|
||||
</Link>
|
||||
<button
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={onDisconnect}
|
||||
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
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { uploadNip95Media } from '@/lib/nip95'
|
||||
import { Button } from './ui'
|
||||
import { t } from '@/lib/i18n'
|
||||
import Image from 'next/image'
|
||||
import { UnlockAccountModal } from './UnlockAccountModal'
|
||||
@ -37,13 +38,14 @@ function RemoveButton({ value, onChange }: { value: string | undefined; onChange
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="danger"
|
||||
size="small"
|
||||
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')}
|
||||
</button>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
@ -62,11 +64,10 @@ function ImageUploadControls({
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<label
|
||||
htmlFor={id}
|
||||
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"
|
||||
>
|
||||
<label htmlFor={id} className="cursor-pointer">
|
||||
<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">
|
||||
<UploadButtonLabel uploading={uploading} value={value} />
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id={id}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Button } from './ui'
|
||||
import { SeriesSection } from './SeriesSection'
|
||||
import { CreateSeriesModal } from './CreateSeriesModal'
|
||||
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">
|
||||
<h3 className="text-lg font-semibold">Séries</h3>
|
||||
{isAuthor && (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
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')}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { Button } from './ui'
|
||||
import type { Series } from '@/types/nostr'
|
||||
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>
|
||||
<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>
|
||||
<button
|
||||
<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)}
|
||||
>
|
||||
{t('common.open')}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-neon-cyan/70">
|
||||
<Link href={`/series/${series.id}`} className="hover:text-neon-cyan transition-colors underline">
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { ArticleCard } from './ArticleCard'
|
||||
import { Button } from './ui'
|
||||
import type { Article } from '@/types/nostr'
|
||||
import { memo } from 'react'
|
||||
import Link from 'next/link'
|
||||
@ -56,19 +57,21 @@ function ArticleActions({
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
<Button
|
||||
variant="primary"
|
||||
size="small"
|
||||
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}
|
||||
>
|
||||
{t('common.edit')}
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="small"
|
||||
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')}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { CreateSeriesModal } from '@/components/CreateSeriesModal'
|
||||
import { SeriesCard } from '@/components/SeriesCard'
|
||||
import { Button } from '@/components/ui'
|
||||
import { useNostrAuth } from '@/hooks/useNostrAuth'
|
||||
import { t } from '@/lib/i18n'
|
||||
import type { Series } from '@/types/nostr'
|
||||
@ -30,13 +31,13 @@ function SeriesListHeader(params: { isAuthor: boolean; onCreate: () => void }):
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-semibold text-neon-cyan">{t('series.title')}</h2>
|
||||
{params.isAuthor && (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
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')}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui'
|
||||
import { SponsoringForm } from '@/components/SponsoringForm'
|
||||
import { t } from '@/lib/i18n'
|
||||
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">
|
||||
<h2 className="text-xl font-semibold text-neon-cyan">{t('author.sponsoring')}</h2>
|
||||
{params.showSponsorButton && (
|
||||
<button
|
||||
<Button
|
||||
variant="success"
|
||||
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')}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useNostrAuth } from '@/hooks/useNostrAuth'
|
||||
import { useAuthorPresentation } from '@/hooks/useAuthorPresentation'
|
||||
import { Button } from '../ui'
|
||||
import { t } from '@/lib/i18n'
|
||||
import { NoAccountView } from './NoAccountView'
|
||||
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>
|
||||
{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"
|
||||
>
|
||||
<a href={`/author/${params.pubkey}`}>
|
||||
<Button variant="primary">
|
||||
{t('presentation.manageSeries')}
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { FormEvent } from 'react'
|
||||
import { PresentationFormHeader } from '../PresentationFormHeader'
|
||||
import { Button, ErrorState } from '../ui'
|
||||
import { t } from '@/lib/i18n'
|
||||
import { PresentationFields } from './fields'
|
||||
import type { AuthorPresentationDraft } from './types'
|
||||
@ -29,13 +30,15 @@ export function PresentationForm(props: PresentationFormProps): React.ReactEleme
|
||||
<ValidationError message={props.validationError ?? props.error} />
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1">
|
||||
<button
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
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 })}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
{props.hasExistingPresentation ? <DeleteButton onDelete={props.handleDelete} deleting={props.deleting} /> : null}
|
||||
</div>
|
||||
@ -47,23 +50,21 @@ function ValidationError(params: { message: string | null }): React.ReactElement
|
||||
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>
|
||||
)
|
||||
return <ErrorState message={params.message} />
|
||||
}
|
||||
|
||||
function DeleteButton(params: { onDelete: () => void; deleting: boolean }): React.ReactElement {
|
||||
return (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="danger"
|
||||
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"
|
||||
loading={params.deleting}
|
||||
size="small"
|
||||
>
|
||||
{params.deleting ? t('presentation.delete.deleting') : t('presentation.delete.button')}
|
||||
</button>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import React from 'react'
|
||||
import { ImageUploadField } from '../ImageUploadField'
|
||||
import { Button, Input, Textarea } from '../ui'
|
||||
import { t } from '@/lib/i18n'
|
||||
import type { SeriesDraft } from './createSeriesModalTypes'
|
||||
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 {
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-4 pt-4">
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={params.onClose}
|
||||
disabled={params.loading}
|
||||
className="px-4 py-2 bg-cyber-darker border border-cyber-accent/30 rounded text-cyber-light hover:border-neon-cyan transition-colors disabled:opacity-50"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
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')}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -173,20 +175,15 @@ function TextField(params: {
|
||||
onChange: (value: string) => void
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor={params.id} className="block text-sm font-medium text-neon-cyan mb-2">
|
||||
{params.label}
|
||||
</label>
|
||||
<input
|
||||
<Input
|
||||
id={params.id}
|
||||
type="text"
|
||||
label={params.label}
|
||||
value={params.value}
|
||||
onChange={(e) => params.onChange(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-cyber-darker border border-cyber-accent/30 rounded text-cyber-light focus:border-neon-cyan focus:outline-none"
|
||||
required={params.required}
|
||||
disabled={params.disabled}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -201,20 +198,15 @@ function TextAreaField(params: {
|
||||
onChange: (value: string) => void
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor={params.id} className="block text-sm font-medium text-neon-cyan mb-2">
|
||||
{params.label}
|
||||
</label>
|
||||
<textarea
|
||||
<Textarea
|
||||
id={params.id}
|
||||
label={params.label}
|
||||
value={params.value}
|
||||
onChange={(e) => params.onChange(e.target.value)}
|
||||
rows={params.rows}
|
||||
className="w-full px-3 py-2 bg-cyber-darker border border-cyber-accent/30 rounded text-cyber-light focus:border-neon-cyan focus:outline-none"
|
||||
required={params.required}
|
||||
disabled={params.disabled}
|
||||
{...(params.helpText ? { helperText: params.helpText } : {})}
|
||||
/>
|
||||
{params.helpText ? <p className="text-xs text-cyber-accent/70 mt-1">{params.helpText}</p> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { Button } from '@/components/ui'
|
||||
import { t } from '@/lib/i18n'
|
||||
import type { KeyManagementManagerActions } from './useKeyManagementManager'
|
||||
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="flex justify-between items-start mb-2">
|
||||
<p className="text-neon-blue font-semibold">{params.label}</p>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="small"
|
||||
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>
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-neon-cyan text-sm font-mono break-all">{params.value}</p>
|
||||
</div>
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import type { Nip95Config } from '@/lib/configStorageTypes'
|
||||
import { Button } from '../ui'
|
||||
import { t } from '@/lib/i18n'
|
||||
import { Nip95ApiList } from './Nip95ApiList'
|
||||
|
||||
@ -51,13 +52,13 @@ function Header(params: { showAddForm: boolean; onToggleAddForm: () => void }):
|
||||
return (
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-2xl font-bold text-neon-cyan">{t('settings.nip95.title')}</h2>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
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>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -76,20 +77,20 @@ function AddForm(params: { newUrl: string; onNewUrlChange: (value: string) => vo
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
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
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
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>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { Button } from '../ui'
|
||||
import { t } from '@/lib/i18n'
|
||||
import { RelayList } from './RelayList'
|
||||
import type { RelayManagerContentProps } from './types'
|
||||
@ -44,13 +45,13 @@ function Header(params: { showAddForm: boolean; onToggleAddForm: () => void }):
|
||||
return (
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-2xl font-bold text-neon-cyan">{t('settings.relay.title')}</h2>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
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>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -69,20 +70,20 @@ function AddForm(params: { newUrl: string; onNewUrlChange: (value: string) => vo
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
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
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
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>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { Button, ErrorState } from '../ui'
|
||||
import { t } from '@/lib/i18n'
|
||||
import type { SyncProgress } from './types'
|
||||
|
||||
@ -6,11 +7,11 @@ export function SyncErrorBanner(params: { error: string | null; onDismiss: () =>
|
||||
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">
|
||||
<div className="mb-4">
|
||||
<ErrorState message={params.error} />
|
||||
<Button type="button" variant="ghost" size="small" onClick={params.onDismiss} className="mt-2">
|
||||
×
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -20,13 +21,14 @@ export function SyncResyncButton(params: { isSyncing: boolean; onClick: () => vo
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
size="small"
|
||||
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>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,8 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { nostrService } from '@/lib/nostr'
|
||||
import { articlePublisher } from '@/lib/articlePublisher'
|
||||
import type { Article } from '@/types/nostr'
|
||||
import type { NostrProfile } from '@/types/nostr'
|
||||
|
||||
interface AuthorPresentationDraft {
|
||||
authorName: string
|
||||
@ -60,7 +58,6 @@ async function publishAuthorPresentation(params: {
|
||||
params.setError(null)
|
||||
try {
|
||||
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 })
|
||||
params.setSuccess(result.success === true)
|
||||
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 } {
|
||||
const title = `Présentation de ${draft.authorName.trim()}`
|
||||
const preview = draft.presentation.substring(0, 200)
|
||||
|
||||
@ -4,6 +4,8 @@
|
||||
*/
|
||||
|
||||
import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
|
||||
import type { AuthorPresentationArticle, NostrProfile } from '@/types/nostr'
|
||||
import { extractAuthorNameFromTitle } from './authorPresentationParsing'
|
||||
import { objectCache } from './objectCache'
|
||||
|
||||
/**
|
||||
@ -46,3 +48,20 @@ export async function fetchAuthorByHashId(
|
||||
// Not found in cache - return null (no network request)
|
||||
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
|
||||
}
|
||||
|
||||
@ -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 type { Article, NostrProfile } from '@/types/nostr'
|
||||
import type { PublishResult } from '../publishResult'
|
||||
@ -121,31 +121,19 @@ class NostrService {
|
||||
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
|
||||
}
|
||||
|
||||
async updateProfile(updates: Partial<NostrProfile>): Promise<void> {
|
||||
if (!this.privateKey || !this.publicKey) {
|
||||
throw new Error('Private key and public key must be set to update profile')
|
||||
}
|
||||
const existingProfile = await this.getProfile(this.publicKey)
|
||||
const currentProfile: NostrProfile = existingProfile ?? { pubkey: this.publicKey }
|
||||
const updatedProfile: NostrProfile = { ...currentProfile, ...updates, pubkey: this.publicKey }
|
||||
const profileEvent: EventTemplate = {
|
||||
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)
|
||||
const { getPrimaryRelaySync } = await import('../config')
|
||||
const { createSubscription } = await import('@/types/nostr-tools-extended')
|
||||
const relayUrl = getPrimaryRelaySync()
|
||||
|
||||
const filters: Filter[] = [{ kinds: [0], authors: [pubkey] }]
|
||||
const sub = createSubscription(this.pool, [relayUrl], filters)
|
||||
|
||||
return subscribeToProfile({ sub, pubkey })
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { nostrService } from './nostr'
|
||||
import { keyManagementService } from './keyManagement'
|
||||
import { fetchAuthorByHashId, convertPlatformProfileToNostrProfile } from './authorQueries'
|
||||
import type { NostrConnectState, NostrProfile } from '@/types/nostr'
|
||||
|
||||
/**
|
||||
@ -173,8 +174,14 @@ export class NostrAuthService {
|
||||
}
|
||||
|
||||
try {
|
||||
const profile = await nostrService.getProfile(this.state.pubkey)
|
||||
if (profile) {
|
||||
const pool = nostrService.getPool()
|
||||
if (!pool) {
|
||||
return
|
||||
}
|
||||
|
||||
const presentation = await fetchAuthorByHashId(pool, this.state.pubkey)
|
||||
if (presentation) {
|
||||
const profile = convertPlatformProfileToNostrProfile(presentation)
|
||||
this.state.profile = profile
|
||||
void this.saveStateToStorage()
|
||||
this.notifyListeners()
|
||||
|
||||
@ -6,6 +6,7 @@ import { ProfileView } from '@/components/ProfileView'
|
||||
import { useNostrAuth } from '@/hooks/useNostrAuth'
|
||||
import { useUserArticles } from '@/hooks/useUserArticles'
|
||||
import { nostrService } from '@/lib/nostr'
|
||||
import { fetchAuthorByHashId, convertPlatformProfileToNostrProfile } from '@/lib/authorQueries'
|
||||
|
||||
function useUserProfileData(currentPubkey: string | null): {
|
||||
profile: NostrProfile | null
|
||||
@ -25,8 +26,20 @@ function useUserProfileData(currentPubkey: string | null): {
|
||||
|
||||
const load = async (): Promise<void> => {
|
||||
try {
|
||||
const loadedProfile = await nostrService.getProfile(currentPubkey)
|
||||
setProfile(loadedProfile ?? createMinimalProfile())
|
||||
const pool = nostrService.getPool()
|
||||
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) {
|
||||
console.error('Error loading profile:', e)
|
||||
setProfile(createMinimalProfile())
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user