From a19b601205785e56afc9c347afdea982f9ad9427 Mon Sep 17 00:00:00 2001 From: Nicolas Cantu Date: Sun, 28 Dec 2025 16:11:54 +0100 Subject: [PATCH] Add image upload to presentation form and profile note - Add ImageUploadField component for profile picture upload (NIP-95) - Add pictureUrl field to AuthorPresentationDraft interface - Store picture URL in Nostr event tags as 'picture' - Display profile picture on author page - Add discrete note indicating zapwall.fr profile differs from Nostr profile - Update translations (FR/EN) for profile note - All TypeScript checks pass --- components/AuthorPresentationEditor.tsx | 26 +++++++- components/ImageUploadField.tsx | 89 +++++++++++++++++++++++++ hooks/useAuthorPresentation.ts | 1 + lib/articlePublisher.ts | 1 + lib/articlePublisherHelpers.ts | 2 + lib/nostrTagSystem.ts | 9 +++ locales/en.txt | 2 + locales/fr.txt | 1 + pages/author/[pubkey].tsx | 28 ++++++-- public/locales/en.txt | 1 + public/locales/fr.txt | 1 + 11 files changed, 155 insertions(+), 6 deletions(-) create mode 100644 components/ImageUploadField.tsx diff --git a/components/AuthorPresentationEditor.tsx b/components/AuthorPresentationEditor.tsx index c43fc1e..4937990 100644 --- a/components/AuthorPresentationEditor.tsx +++ b/components/AuthorPresentationEditor.tsx @@ -4,12 +4,14 @@ import { useAuthorPresentation } from '@/hooks/useAuthorPresentation' import { ArticleField } from './ArticleField' import { ArticleFormButtons } from './ArticleFormButtons' import { ConnectButton } from './ConnectButton' +import { ImageUploadField } from './ImageUploadField' import { t } from '@/lib/i18n' interface AuthorPresentationDraft { presentation: string contentDescription: string mainnetAddress: string + pictureUrl?: string } const ADDRESS_PATTERN = /^(1|3|bc1)[a-zA-Z0-9]{25,62}$/ @@ -114,6 +116,24 @@ function MainnetAddressField({ ) } +function PictureField({ + draft, + onChange, +}: { + draft: AuthorPresentationDraft + onChange: (next: AuthorPresentationDraft) => void +}) { + return ( + onChange({ ...draft, pictureUrl: url })} + helpText="Image de profil pour votre page auteur (max 5Mo, formats: PNG, JPG, WebP)" + /> + ) +} + const PresentationFields = ({ draft, onChange, @@ -122,6 +142,7 @@ const PresentationFields = ({ onChange: (next: AuthorPresentationDraft) => void }) => (
+ @@ -156,9 +177,12 @@ function PresentationForm({

{userName}

-

+

{t('presentation.description')}

+

+ {t('presentation.profileNote')} +

diff --git a/components/ImageUploadField.tsx b/components/ImageUploadField.tsx new file mode 100644 index 0000000..00b0ff7 --- /dev/null +++ b/components/ImageUploadField.tsx @@ -0,0 +1,89 @@ +import { useState } from 'react' +import { uploadNip95Media } from '@/lib/nip95' +import Image from 'next/image' + +interface ImageUploadFieldProps { + id: string + label: string + value?: string | undefined + onChange: (url: string) => void + helpText?: string | undefined +} + +export function ImageUploadField({ id, label, value, onChange, helpText }: ImageUploadFieldProps) { + const [uploading, setUploading] = useState(false) + const [error, setError] = useState(null) + + const handleFileSelect = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) { + return + } + + setError(null) + setUploading(true) + + try { + const media = await uploadNip95Media(file) + if (media.type === 'image') { + onChange(media.url) + } else { + setError('Seules les images sont autorisées') + } + } catch (e) { + setError(e instanceof Error ? e.message : 'Erreur lors de l\'upload') + } finally { + setUploading(false) + } + } + + return ( +
+ + {value && ( +
+ Profile picture +
+ )} +
+ + + {value && ( + + )} +
+ {error && ( +

{error}

+ )} + {helpText && ( +

{helpText}

+ )} +
+ ) +} + diff --git a/hooks/useAuthorPresentation.ts b/hooks/useAuthorPresentation.ts index 50ad055..cba8835 100644 --- a/hooks/useAuthorPresentation.ts +++ b/hooks/useAuthorPresentation.ts @@ -7,6 +7,7 @@ interface AuthorPresentationDraft { presentation: string contentDescription: string mainnetAddress: string + pictureUrl?: string } export function useAuthorPresentation(pubkey: string | null) { diff --git a/lib/articlePublisher.ts b/lib/articlePublisher.ts index 08fc542..d1f751b 100644 --- a/lib/articlePublisher.ts +++ b/lib/articlePublisher.ts @@ -33,6 +33,7 @@ export interface AuthorPresentationDraft { presentation: string contentDescription: string mainnetAddress: string + pictureUrl?: string | undefined } export interface PublishedArticle { diff --git a/lib/articlePublisherHelpers.ts b/lib/articlePublisherHelpers.ts index 05cf3d5..90590f9 100644 --- a/lib/articlePublisherHelpers.ts +++ b/lib/articlePublisherHelpers.ts @@ -19,6 +19,7 @@ export function buildPresentationEvent(draft: AuthorPresentationDraft, eventId: preview: draft.preview, mainnetAddress: draft.mainnetAddress, totalSponsoring: 0, + ...(draft.pictureUrl ? { pictureUrl: draft.pictureUrl } : {}), }), content: draft.content, } @@ -45,6 +46,7 @@ export function parsePresentationEvent(event: Event): import('@/types/nostr').Au isPresentation: true, mainnetAddress: (tags.mainnetAddress as string | undefined) ?? '', totalSponsoring: (tags.totalSponsoring as number | undefined) ?? 0, + ...(tags.pictureUrl ? { bannerUrl: tags.pictureUrl as string } : {}), } } diff --git a/lib/nostrTagSystem.ts b/lib/nostrTagSystem.ts index af432a1..e2f5a79 100644 --- a/lib/nostrTagSystem.ts +++ b/lib/nostrTagSystem.ts @@ -27,6 +27,7 @@ export interface AuthorTags extends BaseTags { preview?: string mainnetAddress?: string totalSponsoring?: number + pictureUrl?: string } export interface SeriesTags extends BaseTags { @@ -85,12 +86,19 @@ export function buildTags(tags: AuthorTags | SeriesTags | PublicationTags | Quot // Type-specific tags if (tags.type === 'author') { const authorTags = tags as AuthorTags + result.push(['title', authorTags.title]) + if (authorTags.preview) { + result.push(['preview', authorTags.preview]) + } if (authorTags.mainnetAddress) { result.push(['mainnet_address', authorTags.mainnetAddress]) } if (authorTags.totalSponsoring !== undefined) { result.push(['total_sponsoring', authorTags.totalSponsoring.toString()]) } + if (authorTags.pictureUrl) { + result.push(['picture', authorTags.pictureUrl]) + } } else if (tags.type === 'series') { const seriesTags = tags as SeriesTags result.push(['title', seriesTags.title]) @@ -185,6 +193,7 @@ export function extractTagsFromEvent(event: { tags: string[][] }): { const val = findTag('total_sponsoring') return val ? parseInt(val, 10) : undefined })(), + pictureUrl: findTag('picture'), seriesId: findTag('series'), coverUrl: findTag('cover'), bannerUrl: findTag('banner'), diff --git a/locales/en.txt b/locales/en.txt index b0cd7b4..db5aa7f 100644 --- a/locales/en.txt +++ b/locales/en.txt @@ -45,6 +45,7 @@ author.sponsoring=Sponsoring author.sponsoring.total=Total received: {{amount}} BTC author.sponsoring.sats=In satoshis: {{amount}} sats author.notFound=Author page not found. +author.profileNote=This profile data is specific to zapwall.fr and may differ from your Nostr profile. # Publish publish.title=Publish a new publication @@ -59,6 +60,7 @@ presentation.description=This article is required to publish on zapwall.fr. It a presentation.success=Presentation article created! presentation.successMessage=Your presentation article has been created successfully. You can now publish articles. presentation.notConnected=Connect with Nostr to create your presentation article +presentation.profileNote=This profile data is specific to zapwall.fr and may differ from your Nostr profile. # Filters filters.clear=Clear all diff --git a/locales/fr.txt b/locales/fr.txt index 0fe4089..3b63057 100644 --- a/locales/fr.txt +++ b/locales/fr.txt @@ -45,6 +45,7 @@ author.sponsoring=Sponsoring author.sponsoring.total=Total reçu : {{amount}} BTC author.sponsoring.sats=En satoshis : {{amount}} sats author.notFound=Page auteur introuvable. +author.profileNote=Les données de ce profil sont spécifiques à zapwall.fr et peuvent différer de votre profil Nostr. # Publish publish.title=Publier une nouvelle publication diff --git a/pages/author/[pubkey].tsx b/pages/author/[pubkey].tsx index 1f732c3..643c2f0 100644 --- a/pages/author/[pubkey].tsx +++ b/pages/author/[pubkey].tsx @@ -11,6 +11,7 @@ import { Footer } from '@/components/Footer' import { t } from '@/lib/i18n' import Link from 'next/link' import { SeriesCard } from '@/components/SeriesCard' +import Image from 'next/image' function AuthorPageHeader({ presentation }: { presentation: AuthorPresentationArticle | null }) { if (!presentation) { @@ -19,11 +20,28 @@ function AuthorPageHeader({ presentation }: { presentation: AuthorPresentationAr return (
-

- {presentation.title || t('author.presentation')} -

-
-

{presentation.content}

+
+ {presentation.bannerUrl && ( +
+ Profile picture +
+ )} +
+

+ {presentation.title || t('author.presentation')} +

+

+ {t('author.profileNote')} +

+
+

{presentation.content}

+
+
) diff --git a/public/locales/en.txt b/public/locales/en.txt index e18ef44..da1f531 100644 --- a/public/locales/en.txt +++ b/public/locales/en.txt @@ -45,6 +45,7 @@ author.sponsoring=Sponsoring author.sponsoring.total=Total received: {{amount}} BTC author.sponsoring.sats=In satoshis: {{amount}} sats author.notFound=Author page not found. +author.profileNote=This profile data is specific to zapwall.fr and may differ from your Nostr profile. # Publish publish.title=Publish a new publication diff --git a/public/locales/fr.txt b/public/locales/fr.txt index 59ed32e..219a7ea 100644 --- a/public/locales/fr.txt +++ b/public/locales/fr.txt @@ -45,6 +45,7 @@ author.sponsoring=Sponsoring author.sponsoring.total=Total reçu : {{amount}} BTC author.sponsoring.sats=En satoshis : {{amount}} sats author.notFound=Page auteur introuvable. +author.profileNote=Les données de ce profil sont spécifiques à zapwall.fr et peuvent différer de votre profil Nostr. # Publish publish.title=Publier une nouvelle publication