series building
This commit is contained in:
parent
4787bd5410
commit
4a619c9576
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>({
|
||||
authorName: existingAuthorName ?? '',
|
||||
presentation: '',
|
||||
contentDescription: '',
|
||||
mainnetAddress: '',
|
||||
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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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} />
|
||||
}
|
||||
|
||||
@ -54,3 +54,4 @@ Aucun déploiement spécial nécessaire. Les modifications sont purement fronten
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
30
lib/presentationParsing.ts
Normal file
30
lib/presentationParsing.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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])
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user