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 type { Article } from '@/types/nostr'
import { ArticleCard } from './ArticleCard' import { ArticleCard } from './ArticleCard'
import { t } from '@/lib/i18n'
interface ArticlesListProps { interface ArticlesListProps {
articles: Article[] articles: Article[]
@ -13,7 +14,7 @@ interface ArticlesListProps {
function LoadingState() { function LoadingState() {
return ( return (
<div className="text-center py-12"> <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> </div>
) )
} }

View File

@ -1,4 +1,5 @@
import { useState, useCallback, useEffect, type FormEvent } from 'react' import { useState, useCallback, useEffect, type FormEvent } from 'react'
import { useRouter } from 'next/router'
import { useNostrAuth } from '@/hooks/useNostrAuth' import { useNostrAuth } from '@/hooks/useNostrAuth'
import { useAuthorPresentation } from '@/hooks/useAuthorPresentation' import { useAuthorPresentation } from '@/hooks/useAuthorPresentation'
import { ArticleField } from './ArticleField' import { ArticleField } from './ArticleField'
@ -8,6 +9,8 @@ import { RecoveryStep } from './CreateAccountModalSteps'
import { UnlockAccountModal } from './UnlockAccountModal' import { UnlockAccountModal } from './UnlockAccountModal'
import { ImageUploadField } from './ImageUploadField' import { ImageUploadField } from './ImageUploadField'
import { PresentationFormHeader } from './PresentationFormHeader' import { PresentationFormHeader } from './PresentationFormHeader'
import { extractPresentationData } from '@/lib/presentationParsing'
import type { Article } from '@/types/nostr'
import { t } from '@/lib/i18n' import { t } from '@/lib/i18n'
interface AuthorPresentationDraft { interface AuthorPresentationDraft {
@ -170,6 +173,19 @@ const PresentationFields = ({
</div> </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({ function PresentationForm({
draft, draft,
setDraft, setDraft,
@ -177,6 +193,9 @@ function PresentationForm({
error, error,
loading, loading,
handleSubmit, handleSubmit,
deleting,
handleDelete,
hasExistingPresentation,
}: { }: {
draft: AuthorPresentationDraft draft: AuthorPresentationDraft
setDraft: (next: AuthorPresentationDraft) => void setDraft: (next: AuthorPresentationDraft) => void
@ -184,6 +203,9 @@ function PresentationForm({
error: string | null error: string | null
loading: boolean loading: boolean
handleSubmit: (e: FormEvent<HTMLFormElement>) => Promise<void> handleSubmit: (e: FormEvent<HTMLFormElement>) => Promise<void>
deleting: boolean
handleDelete: () => void
hasExistingPresentation: boolean
}) { }) {
return ( return (
<form <form
@ -195,27 +217,59 @@ function PresentationForm({
<PresentationFormHeader /> <PresentationFormHeader />
<PresentationFields draft={draft} onChange={setDraft} /> <PresentationFields draft={draft} onChange={setDraft} />
<ValidationError message={validationError ?? error} /> <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> </form>
) )
} }
function useAuthorPresentationState(pubkey: string | null, existingAuthorName?: string) { function useAuthorPresentationState(pubkey: string | null, existingAuthorName?: string, existingPresentation?: Article | null) {
const { loading, error, success, publishPresentation } = useAuthorPresentation(pubkey) const { loading, error, success, publishPresentation, deletePresentation } = useAuthorPresentation(pubkey)
const [draft, setDraft] = useState<AuthorPresentationDraft>({ const router = useRouter()
authorName: existingAuthorName ?? '', const [draft, setDraft] = useState<AuthorPresentationDraft>(() => {
presentation: '', if (existingPresentation) {
contentDescription: '', const { presentation, contentDescription } = extractPresentationData(existingPresentation)
mainnetAddress: '', 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 [validationError, setValidationError] = useState<string | null>(null)
const [deleting, setDeleting] = useState(false)
// Update authorName when profile changes // Update authorName when profile changes
useEffect(() => { useEffect(() => {
if (existingAuthorName && existingAuthorName !== draft.authorName) { if (existingAuthorName && existingAuthorName !== draft.authorName && !existingPresentation) {
setDraft((prev) => ({ ...prev, authorName: existingAuthorName })) setDraft((prev) => ({ ...prev, authorName: existingAuthorName }))
} }
}, [existingAuthorName]) }, [existingAuthorName, existingPresentation])
const handleSubmit = useCallback( const handleSubmit = useCallback(
async (e: FormEvent<HTMLFormElement>) => { async (e: FormEvent<HTMLFormElement>) => {
@ -235,7 +289,28 @@ function useAuthorPresentationState(pubkey: string | null, existingAuthorName?:
[draft, publishPresentation] [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({ function NoAccountActionButtons({
@ -348,11 +423,42 @@ function AuthorPresentationFormView({
pubkey: string | null pubkey: string | null
profile: { name?: string; 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) { if (!pubkey) {
return <NoAccountView /> 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) { if (state.success) {
return <SuccessNotice pubkey={pubkey} /> return <SuccessNotice pubkey={pubkey} />
} }
@ -365,6 +471,9 @@ function AuthorPresentationFormView({
error={state.error} error={state.error}
loading={state.loading} loading={state.loading}
handleSubmit={state.handleSubmit} handleSubmit={state.handleSubmit}
deleting={state.deleting}
handleDelete={state.handleDelete}
hasExistingPresentation={!!existingPresentation}
/> />
) )
} }

View File

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

View File

@ -1,8 +1,10 @@
import Link from 'next/link' import Link from 'next/link'
import Image from 'next/image'
import { useNostrAuth } from '@/hooks/useNostrAuth' import { useNostrAuth } from '@/hooks/useNostrAuth'
import { useAuthorPresentation } from '@/hooks/useAuthorPresentation' import { useAuthorPresentation } from '@/hooks/useAuthorPresentation'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { t } from '@/lib/i18n' 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' 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() { export function ConditionalPublishButton() {
const { connected, pubkey } = useNostrAuth() const { connected, pubkey, profile } = useNostrAuth()
const { checkPresentationExists } = useAuthorPresentation(pubkey ?? null) 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(() => { useEffect(() => {
const check = async () => { const check = async () => {
if (!connected || !pubkey) { if (!connected || !pubkey) {
setHasPresentation(null) setPresentation(null)
setLoading(false)
return return
} }
const presentation = await checkPresentationExists() setLoading(true)
setHasPresentation(presentation !== null) const pres = await checkPresentationExists()
setPresentation(pres)
setLoading(false)
} }
void check() void check()
}, [connected, pubkey, checkPresentationExists]) }, [connected, pubkey, checkPresentationExists])
@ -51,13 +85,14 @@ export function ConditionalPublishButton() {
return <CreateAuthorPageLink /> return <CreateAuthorPageLink />
} }
if (hasPresentation === null) { if (loading) {
return <LoadingButton /> return <LoadingButton />
} }
if (!hasPresentation) { if (!presentation) {
return <CreateAuthorPageLink /> 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 { return {
loading, loading,
error, error,
success, success,
publishPresentation, publishPresentation,
checkPresentationExists, 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.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.validation.invalidAddress=Invalid Bitcoin address (must start with 1, 3 or bc1)
presentation.fallback.user=User 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
filters.clear=Clear all filters.clear=Clear all
@ -102,6 +107,8 @@ footer.privacy=Privacy Policy
# Common # Common
common.loading=Loading... common.loading=Loading...
common.loading.articles=Loading articles...
common.loading.authors=Loading authors...
common.error=Error common.error=Error
common.back=Back common.back=Back
common.open=Open 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.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.validation.invalidAddress=Adresse Bitcoin invalide (doit commencer par 1, 3 ou bc1)
presentation.fallback.user=Utilisateur 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
filters.clear=Effacer tout filters.clear=Effacer tout
@ -102,6 +107,8 @@ footer.privacy=Politique de confidentialité
# Common # Common
common.loading=Chargement... common.loading=Chargement...
common.loading.articles=Chargement des articles...
common.loading.authors=Chargement des auteurs...
common.error=Erreur common.error=Erreur
common.back=Retour common.back=Retour
common.open=Ouvrir common.open=Ouvrir

View File

@ -18,7 +18,7 @@ function usePresentationRedirect(connected: boolean, pubkey: string | null) {
} }
const presentation = await checkPresentationExists() const presentation = await checkPresentationExists()
if (presentation) { if (presentation) {
await router.push('/') await router.push(`/author/${pubkey}`)
} }
}, [checkPresentationExists, connected, pubkey, router]) }, [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.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.validation.invalidAddress=Invalid Bitcoin address (must start with 1, 3 or bc1)
presentation.fallback.user=User 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
filters.clear=Clear all filters.clear=Clear all
@ -102,6 +107,8 @@ footer.privacy=Privacy Policy
# Common # Common
common.loading=Loading... common.loading=Loading...
common.loading.articles=Loading articles...
common.loading.authors=Loading authors...
common.error=Error common.error=Error
common.back=Back common.back=Back
common.open=Open 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.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.validation.invalidAddress=Adresse Bitcoin invalide (doit commencer par 1, 3 ou bc1)
presentation.fallback.user=Utilisateur 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
filters.clear=Effacer tout filters.clear=Effacer tout
@ -102,6 +107,8 @@ footer.privacy=Politique de confidentialité
# Common # Common
common.loading=Chargement... common.loading=Chargement...
common.loading.articles=Chargement des articles...
common.loading.authors=Chargement des auteurs...
common.error=Erreur common.error=Erreur
common.back=Retour common.back=Retour
common.open=Ouvrir common.open=Ouvrir