series building

This commit is contained in:
Nicolas Cantu 2026-01-05 23:37:29 +01:00
parent 4787bd5410
commit 4a619c9576
12 changed files with 255 additions and 23 deletions

View File

@ -1,5 +1,6 @@
import type { Article } from '@/types/nostr'
import { ArticleCard } from './ArticleCard'
import { t } from '@/lib/i18n'
interface ArticlesListProps {
articles: Article[]
@ -13,7 +14,7 @@ interface ArticlesListProps {
function LoadingState() {
return (
<div className="text-center py-12">
<p className="text-cyber-accent/70">Loading articles...</p>
<p className="text-cyber-accent/70">{t('common.loading.articles')}</p>
</div>
)
}

View File

@ -1,4 +1,5 @@
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'
@ -8,6 +9,8 @@ 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'
interface AuthorPresentationDraft {
@ -170,6 +173,19 @@ const PresentationFields = ({
</div>
)
function DeleteButton({ onDelete, deleting }: { onDelete: () => void; deleting: boolean }) {
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>
)
}
function PresentationForm({
draft,
setDraft,
@ -177,6 +193,9 @@ function PresentationForm({
error,
loading,
handleSubmit,
deleting,
handleDelete,
hasExistingPresentation,
}: {
draft: AuthorPresentationDraft
setDraft: (next: AuthorPresentationDraft) => void
@ -184,6 +203,9 @@ function PresentationForm({
error: string | null
loading: boolean
handleSubmit: (e: FormEvent<HTMLFormElement>) => Promise<void>
deleting: boolean
handleDelete: () => void
hasExistingPresentation: boolean
}) {
return (
<form
@ -195,27 +217,59 @@ function PresentationForm({
<PresentationFormHeader />
<PresentationFields draft={draft} onChange={setDraft} />
<ValidationError message={validationError ?? error} />
<ArticleFormButtons loading={loading} />
<div className="flex items-center gap-4">
<div className="flex-1">
<button
type="submit"
disabled={loading || 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 || deleting
? t('publish.publishing')
: hasExistingPresentation
? t('presentation.update.button')
: t('publish.button')}
</button>
</div>
{hasExistingPresentation && (
<DeleteButton onDelete={handleDelete} deleting={deleting} />
)}
</div>
</form>
)
}
function useAuthorPresentationState(pubkey: string | null, existingAuthorName?: string) {
const { loading, error, success, publishPresentation } = useAuthorPresentation(pubkey)
const [draft, setDraft] = useState<AuthorPresentationDraft>({
function useAuthorPresentationState(pubkey: string | null, existingAuthorName?: string, existingPresentation?: Article | null) {
const { loading, error, success, publishPresentation, deletePresentation } = useAuthorPresentation(pubkey)
const router = useRouter()
const [draft, setDraft] = useState<AuthorPresentationDraft>(() => {
if (existingPresentation) {
const { presentation, contentDescription } = extractPresentationData(existingPresentation)
const authorName = existingPresentation.title.replace(/^Présentation de /, '') || existingAuthorName || ''
return {
authorName,
presentation,
contentDescription,
mainnetAddress: existingPresentation.mainnetAddress || '',
pictureUrl: existingPresentation.bannerUrl,
}
}
return {
authorName: existingAuthorName ?? '',
presentation: '',
contentDescription: '',
mainnetAddress: '',
}
})
const [validationError, setValidationError] = useState<string | null>(null)
const [deleting, setDeleting] = useState(false)
// Update authorName when profile changes
useEffect(() => {
if (existingAuthorName && existingAuthorName !== draft.authorName) {
if (existingAuthorName && existingAuthorName !== draft.authorName && !existingPresentation) {
setDraft((prev) => ({ ...prev, authorName: existingAuthorName }))
}
}, [existingAuthorName])
}, [existingAuthorName, existingPresentation])
const handleSubmit = useCallback(
async (e: FormEvent<HTMLFormElement>) => {
@ -235,7 +289,28 @@ function useAuthorPresentationState(pubkey: string | null, existingAuthorName?:
[draft, publishPresentation]
)
return { loading, error, success, draft, setDraft, validationError, handleSubmit }
const handleDelete = useCallback(async () => {
if (!existingPresentation?.id) {
return
}
if (!confirm(t('presentation.delete.confirm'))) {
return
}
setDeleting(true)
setValidationError(null)
try {
await deletePresentation(existingPresentation.id)
await router.push('/')
} catch (e) {
setValidationError(e instanceof Error ? e.message : t('presentation.delete.error'))
} finally {
setDeleting(false)
}
}, [existingPresentation, deletePresentation, router])
return { loading, error, success, draft, setDraft, validationError, handleSubmit, deleting, handleDelete, existingPresentation }
}
function NoAccountActionButtons({
@ -348,11 +423,42 @@ function AuthorPresentationFormView({
pubkey: string | null
profile: { name?: string; pubkey: string } | null
}) {
const state = useAuthorPresentationState(pubkey, profile?.name)
const { checkPresentationExists } = useAuthorPresentation(pubkey)
const [existingPresentation, setExistingPresentation] = useState<Article | null>(null)
const [loadingPresentation, setLoadingPresentation] = useState(true)
useEffect(() => {
const load = async () => {
if (!pubkey) {
setLoadingPresentation(false)
return
}
try {
const presentation = await checkPresentationExists()
setExistingPresentation(presentation)
} catch (e) {
console.error('Error loading presentation:', e)
} finally {
setLoadingPresentation(false)
}
}
void load()
}, [pubkey, checkPresentationExists])
const state = useAuthorPresentationState(pubkey, profile?.name, existingPresentation)
if (!pubkey) {
return <NoAccountView />
}
if (loadingPresentation) {
return (
<div className="text-center py-12">
<p className="text-cyber-accent/70">{t('common.loading')}</p>
</div>
)
}
if (state.success) {
return <SuccessNotice pubkey={pubkey} />
}
@ -365,6 +471,9 @@ function AuthorPresentationFormView({
error={state.error}
loading={state.loading}
handleSubmit={state.handleSubmit}
deleting={state.deleting}
handleDelete={state.handleDelete}
hasExistingPresentation={!!existingPresentation}
/>
)
}

View File

@ -1,5 +1,6 @@
import type { Article } from '@/types/nostr'
import { AuthorCard } from './AuthorCard'
import { t } from '@/lib/i18n'
interface AuthorsListProps {
authors: Article[]
@ -11,7 +12,7 @@ interface AuthorsListProps {
function LoadingState() {
return (
<div className="text-center py-12">
<p className="text-cyber-accent/70">Loading authors...</p>
<p className="text-cyber-accent/70">{t('common.loading.authors')}</p>
</div>
)
}

View File

@ -1,8 +1,10 @@
import Link from 'next/link'
import Image from 'next/image'
import { useNostrAuth } from '@/hooks/useNostrAuth'
import { useAuthorPresentation } from '@/hooks/useAuthorPresentation'
import { useEffect, useState } from 'react'
import { t } from '@/lib/i18n'
import type { Article } from '@/types/nostr'
const buttonClassName = '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'
@ -30,19 +32,51 @@ function LoadingButton() {
)
}
function AuthorProfileLink({ presentation, profile }: { presentation: Article; profile: { name?: string; picture?: string } | null }) {
const authorName = presentation.title.replace(/^Présentation de /, '') || profile?.name || 'Auteur'
const picture = presentation.bannerUrl || profile?.picture
return (
<Link
href={`/author/${presentation.pubkey}`}
className="flex items-center gap-2 px-3 py-2 bg-cyber-dark/50 hover:bg-cyber-dark border border-neon-cyan/20 hover:border-neon-cyan/40 rounded-lg transition-all"
>
{picture ? (
<div className="relative w-8 h-8 rounded-full overflow-hidden border border-neon-cyan/30 flex-shrink-0">
<Image
src={picture}
alt={authorName}
fill
className="object-cover"
/>
</div>
) : (
<div className="w-8 h-8 rounded-full bg-cyber-light border border-neon-cyan/30 flex items-center justify-center flex-shrink-0">
<span className="text-xs text-neon-cyan font-medium">{authorName.charAt(0).toUpperCase()}</span>
</div>
)}
<span className="text-sm text-neon-cyan font-medium truncate max-w-[120px]">{authorName}</span>
</Link>
)
}
export function ConditionalPublishButton() {
const { connected, pubkey } = useNostrAuth()
const { connected, pubkey, profile } = useNostrAuth()
const { checkPresentationExists } = useAuthorPresentation(pubkey ?? null)
const [hasPresentation, setHasPresentation] = useState<boolean | null>(null)
const [presentation, setPresentation] = useState<Article | null>(null)
const [loading, setLoading] = useState(false)
useEffect(() => {
const check = async () => {
if (!connected || !pubkey) {
setHasPresentation(null)
setPresentation(null)
setLoading(false)
return
}
const presentation = await checkPresentationExists()
setHasPresentation(presentation !== null)
setLoading(true)
const pres = await checkPresentationExists()
setPresentation(pres)
setLoading(false)
}
void check()
}, [connected, pubkey, checkPresentationExists])
@ -51,13 +85,14 @@ export function ConditionalPublishButton() {
return <CreateAuthorPageLink />
}
if (hasPresentation === null) {
if (loading) {
return <LoadingButton />
}
if (!hasPresentation) {
if (!presentation) {
return <CreateAuthorPageLink />
}
return <PublishLink />
// If presentation exists, show author profile link instead of publish button
return <AuthorProfileLink presentation={presentation} profile={profile} />
}

View File

@ -54,3 +54,4 @@ Aucun déploiement spécial nécessaire. Les modifications sont purement fronten

View File

@ -92,11 +92,38 @@ export function useAuthorPresentation(pubkey: string | null) {
}
}
const deletePresentation = async (articleId: string): Promise<void> => {
if (!pubkey) {
throw new Error('Clé publique non disponible')
}
setLoading(true)
setError(null)
try {
const privateKey = nostrService.getPrivateKey()
if (!privateKey) {
throw new Error('Clé privée requise pour supprimer. Veuillez vous connecter avec un portefeuille Nostr qui fournit des capacités de signature.')
}
const { deleteArticleEvent } = await import('@/lib/articleMutations')
await deleteArticleEvent(articleId, pubkey, privateKey)
} catch (e) {
const errorMessage = e instanceof Error ? e.message : 'Erreur inconnue'
console.error('Error deleting presentation:', e)
setError(errorMessage)
throw e
} finally {
setLoading(false)
}
}
return {
loading,
error,
success,
publishPresentation,
checkPresentationExists,
deletePresentation,
}
}

View File

@ -0,0 +1,30 @@
import type { Article } from '@/types/nostr'
/**
* Extract presentation data from article content
* Content format: "${presentation}\n\n---\n\nDescription du contenu :\n${contentDescription}"
*/
export function extractPresentationData(presentation: Article): {
presentation: string
contentDescription: string
} {
const content = presentation.content
const separator = '\n\n---\n\nDescription du contenu :\n'
const separatorIndex = content.indexOf(separator)
if (separatorIndex === -1) {
// Fallback: return content as presentation if separator not found
return {
presentation: content,
contentDescription: '',
}
}
const presentationText = content.substring(0, separatorIndex)
const contentDescription = content.substring(separatorIndex + separator.length)
return {
presentation: presentationText,
contentDescription,
}
}

View File

@ -83,6 +83,11 @@ presentation.field.mainnetAddress.placeholder=1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa
presentation.field.mainnetAddress.help=Bitcoin mainnet address where you will receive sponsoring payments (0.046 BTC excluding fees per sponsoring)
presentation.validation.invalidAddress=Invalid Bitcoin address (must start with 1, 3 or bc1)
presentation.fallback.user=User
presentation.update.button=Update author page
presentation.delete.button=Delete author page
presentation.delete.confirm=Are you sure you want to delete your author page? This action is irreversible.
presentation.delete.deleting=Deleting...
presentation.delete.error=Error deleting author page
# Filters
filters.clear=Clear all
@ -102,6 +107,8 @@ footer.privacy=Privacy Policy
# Common
common.loading=Loading...
common.loading.articles=Loading articles...
common.loading.authors=Loading authors...
common.error=Error
common.back=Back
common.open=Open

View File

@ -83,6 +83,11 @@ presentation.field.mainnetAddress.placeholder=1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa
presentation.field.mainnetAddress.help=Adresse Bitcoin mainnet où vous recevrez les paiements de sponsoring (0.046 BTC hors frais par sponsoring)
presentation.validation.invalidAddress=Adresse Bitcoin invalide (doit commencer par 1, 3 ou bc1)
presentation.fallback.user=Utilisateur
presentation.update.button=Mettre à jour la page auteur
presentation.delete.button=Supprimer la page auteur
presentation.delete.confirm=Êtes-vous sûr de vouloir supprimer votre page auteur ? Cette action est irréversible.
presentation.delete.deleting=Suppression...
presentation.delete.error=Erreur lors de la suppression de la page auteur
# Filters
filters.clear=Effacer tout
@ -102,6 +107,8 @@ footer.privacy=Politique de confidentialité
# Common
common.loading=Chargement...
common.loading.articles=Chargement des articles...
common.loading.authors=Chargement des auteurs...
common.error=Erreur
common.back=Retour
common.open=Ouvrir

View File

@ -18,7 +18,7 @@ function usePresentationRedirect(connected: boolean, pubkey: string | null) {
}
const presentation = await checkPresentationExists()
if (presentation) {
await router.push('/')
await router.push(`/author/${pubkey}`)
}
}, [checkPresentationExists, connected, pubkey, router])

View File

@ -83,6 +83,11 @@ presentation.field.mainnetAddress.placeholder=1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa
presentation.field.mainnetAddress.help=Bitcoin mainnet address where you will receive sponsoring payments (0.046 BTC excluding fees per sponsoring)
presentation.validation.invalidAddress=Invalid Bitcoin address (must start with 1, 3 or bc1)
presentation.fallback.user=User
presentation.update.button=Update author page
presentation.delete.button=Delete author page
presentation.delete.confirm=Are you sure you want to delete your author page? This action is irreversible.
presentation.delete.deleting=Deleting...
presentation.delete.error=Error deleting author page
# Filters
filters.clear=Clear all
@ -102,6 +107,8 @@ footer.privacy=Privacy Policy
# Common
common.loading=Loading...
common.loading.articles=Loading articles...
common.loading.authors=Loading authors...
common.error=Error
common.back=Back
common.open=Open

View File

@ -83,6 +83,11 @@ presentation.field.mainnetAddress.placeholder=1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa
presentation.field.mainnetAddress.help=Adresse Bitcoin mainnet où vous recevrez les paiements de sponsoring (0.046 BTC hors frais par sponsoring)
presentation.validation.invalidAddress=Adresse Bitcoin invalide (doit commencer par 1, 3 ou bc1)
presentation.fallback.user=Utilisateur
presentation.update.button=Mettre à jour la page auteur
presentation.delete.button=Supprimer la page auteur
presentation.delete.confirm=Êtes-vous sûr de vouloir supprimer votre page auteur ? Cette action est irréversible.
presentation.delete.deleting=Suppression...
presentation.delete.error=Erreur lors de la suppression de la page auteur
# Filters
filters.clear=Effacer tout
@ -102,6 +107,8 @@ footer.privacy=Politique de confidentialité
# Common
common.loading=Chargement...
common.loading.articles=Chargement des articles...
common.loading.authors=Chargement des auteurs...
common.error=Erreur
common.back=Retour
common.open=Ouvrir