diff --git a/components/AlbyInstaller.tsx b/components/AlbyInstaller.tsx index a425df5..7eb5ac7 100644 --- a/components/AlbyInstaller.tsx +++ b/components/AlbyInstaller.tsx @@ -5,7 +5,7 @@ interface AlbyInstallerProps { onInstalled?: () => void } -function InfoIcon(): JSX.Element { +function InfoIcon(): React.ReactElement { return ( void } -function InstallerActions({ onInstalled, markInstalled }: InstallerActionsProps): JSX.Element { +function InstallerActions({ onInstalled, markInstalled }: InstallerActionsProps): React.ReactElement { const connect = useCallback(() => { const alby = getAlbyService() void alby.enable().then(() => { @@ -60,7 +60,7 @@ function InstallerActions({ onInstalled, markInstalled }: InstallerActionsProps) ) } -function InstallerBody({ onInstalled, markInstalled }: InstallerActionsProps): JSX.Element { +function InstallerBody({ onInstalled, markInstalled }: InstallerActionsProps): React.ReactElement { return (

Alby Extension Required

@@ -108,7 +108,7 @@ function useAlbyStatus(onInstalled?: () => void): { isInstalled: boolean; isChec return { isInstalled, isChecking, markInstalled } } -export function AlbyInstaller({ onInstalled }: AlbyInstallerProps): JSX.Element | null { +export function AlbyInstaller({ onInstalled }: AlbyInstallerProps): React.ReactElement | null { const { isInstalled, isChecking, markInstalled } = useAlbyStatus(onInstalled) if (isChecking || isInstalled) { diff --git a/components/ArticleCard.tsx b/components/ArticleCard.tsx index 31de68e..f34c58e 100644 --- a/components/ArticleCard.tsx +++ b/components/ArticleCard.tsx @@ -11,7 +11,7 @@ interface ArticleCardProps { onUnlock?: (article: Article) => void } -function ArticleHeader({ article }: { article: Article }): JSX.Element { +function ArticleHeader({ article }: { article: Article }): React.ReactElement { return (

{article.title}

@@ -37,7 +37,7 @@ function ArticleMeta({ paymentInvoice: ReturnType['paymentInvoice'] onClose: () => void onPaymentComplete: () => void -}): JSX.Element { +}): React.ReactElement { return ( <> {error &&

{error}

} @@ -55,7 +55,7 @@ function ArticleMeta({ ) } -export function ArticleCard({ article, onUnlock }: ArticleCardProps): JSX.Element { +export function ArticleCard({ article, onUnlock }: ArticleCardProps): React.ReactElement { const { pubkey, connect } = useNostrAuth() const { loading, diff --git a/components/ArticleEditor.tsx b/components/ArticleEditor.tsx index f50c52f..370ee55 100644 --- a/components/ArticleEditor.tsx +++ b/components/ArticleEditor.tsx @@ -12,7 +12,7 @@ interface ArticleEditorProps { } -function SuccessMessage(): JSX.Element { +function SuccessMessage(): React.ReactElement { return (

Article Published!

@@ -21,7 +21,7 @@ function SuccessMessage(): JSX.Element { ) } -export function ArticleEditor({ onPublishSuccess, onCancel, seriesOptions, onSelectSeries }: ArticleEditorProps): JSX.Element { +export function ArticleEditor({ onPublishSuccess, onCancel, seriesOptions, onSelectSeries }: ArticleEditorProps): React.ReactElement { const { connected, pubkey, connect } = useNostrAuth() const { loading, error, success, publishArticle } = useArticlePublishing(pubkey ?? null) const [draft, setDraft] = useState({ diff --git a/components/ArticleEditorForm.tsx b/components/ArticleEditorForm.tsx index d3ec9b1..dc7feb1 100644 --- a/components/ArticleEditorForm.tsx +++ b/components/ArticleEditorForm.tsx @@ -5,6 +5,7 @@ import { ArticleField } from './ArticleField' import { ArticleFormButtons } from './ArticleFormButtons' import { CategorySelect } from './CategorySelect' import { MarkdownEditor } from './MarkdownEditor' +import { MarkdownEditorTwoColumns } from './MarkdownEditorTwoColumns' import type { MediaRef } from '@/types/nostr' import { t } from '@/lib/i18n' @@ -25,7 +26,7 @@ function CategoryField({ }: { value: ArticleDraft['category'] onChange: (value: import('@/types/nostr').ArticleCategory | undefined) => void -}): JSX.Element { +}): React.ReactElement { return ( void seriesOptions?: { id: string; title: string }[] | undefined onSelectSeries?: ((seriesId: string | undefined) => void) | undefined -}): JSX.Element => ( +}): React.ReactElement => (
) -function ArticleTitleField({ draft, onDraftChange }: { draft: ArticleDraft; onDraftChange: (draft: ArticleDraft) => void }): JSX.Element { +function ArticleTitleField({ draft, onDraftChange }: { draft: ArticleDraft; onDraftChange: (draft: ArticleDraft) => void }): React.ReactElement { return ( void -}): JSX.Element { +}): React.ReactElement { return ( void seriesOptions: { id: string; title: string }[] onSelectSeries?: ((seriesId: string | undefined) => void) | undefined -}): JSX.Element { +}): React.ReactElement { const handleChange = buildSeriesChangeHandler(draft, onDraftChange, onSelectSeries) return ( @@ -189,37 +190,58 @@ const ArticleFieldsRight = ({ }: { draft: ArticleDraft onDraftChange: (draft: ArticleDraft) => void -}): JSX.Element => ( -
-
-
{t('article.editor.content.label')}
- onDraftChange({ ...draft, content: value })} - onMediaAdd={(media: MediaRef) => { - const nextMedia = [...(draft.media ?? []), media] - onDraftChange({ ...draft, media: nextMedia }) - }} - onBannerChange={(url: string) => { - onDraftChange({ ...draft, bannerUrl: url }) - }} +}): React.ReactElement => { + // Use two-column editor with pages for series publications + const useTwoColumns = draft.seriesId !== undefined + + return ( +
+
+
{t('article.editor.content.label')}
+ {useTwoColumns ? ( + onDraftChange({ ...draft, content: value })} + pages={draft.pages} + onPagesChange={(pages) => onDraftChange({ ...draft, pages })} + onMediaAdd={(media: MediaRef) => { + const nextMedia = [...(draft.media ?? []), media] + onDraftChange({ ...draft, media: nextMedia }) + }} + onBannerChange={(url: string) => { + onDraftChange({ ...draft, bannerUrl: url }) + }} + /> + ) : ( + onDraftChange({ ...draft, content: value })} + onMediaAdd={(media: MediaRef) => { + const nextMedia = [...(draft.media ?? []), media] + onDraftChange({ ...draft, media: nextMedia }) + }} + onBannerChange={(url: string) => { + onDraftChange({ ...draft, bannerUrl: url }) + }} + /> + )} +

+ {t('article.editor.content.help')} +

+
+ onDraftChange({ ...draft, zapAmount: value as number })} + required + type="number" + min={1} + helpText={t('article.editor.sponsoring.help')} /> -

- {t('article.editor.content.help')} -

- onDraftChange({ ...draft, zapAmount: value as number })} - required - type="number" - min={1} - helpText={t('article.editor.sponsoring.help')} - /> -
-) + ) +} export function ArticleEditorForm({ draft, @@ -230,7 +252,7 @@ export function ArticleEditorForm({ onCancel, seriesOptions, onSelectSeries, -}: ArticleEditorFormProps): JSX.Element { +}: ArticleEditorFormProps): React.ReactElement { return (

{t('article.editor.title')}

diff --git a/components/ArticleField.tsx b/components/ArticleField.tsx index 0de08c3..4eb9a3f 100644 --- a/components/ArticleField.tsx +++ b/components/ArticleField.tsx @@ -30,7 +30,7 @@ function NumberOrTextInput({ min?: number className: string onChange: (value: string | number) => void -}): JSX.Element { +}): React.ReactElement { const inputProps = { id, type, @@ -64,7 +64,7 @@ function TextAreaInput({ rows?: number className: string onChange: (value: string | number) => void -}): JSX.Element { +}): React.ReactElement { const areaProps = { id, value, @@ -81,7 +81,7 @@ function TextAreaInput({ ) } -export function ArticleField(props: ArticleFieldProps): JSX.Element { +export function ArticleField(props: ArticleFieldProps): React.ReactElement { const { id, label, value, onChange, required = false, type = 'text', rows, placeholder, helpText, min } = props const inputClass = diff --git a/components/ArticleFilters.tsx b/components/ArticleFilters.tsx index 43ddfd3..422fed7 100644 --- a/components/ArticleFilters.tsx +++ b/components/ArticleFilters.tsx @@ -46,7 +46,7 @@ function FiltersGrid({ data: FiltersData filters: ArticleFilters onFiltersChange: (filters: ArticleFilters) => void -}): JSX.Element { +}): React.ReactElement { const update = (patch: Partial): void => onFiltersChange({ ...filters, ...patch }) return ( @@ -63,7 +63,7 @@ function FiltersHeader({ }: { hasActiveFilters: boolean onClear: () => void -}): JSX.Element { +}): React.ReactElement { return (

{t('filters.sort')}

@@ -83,7 +83,7 @@ function SortFilter({ }: { value: SortOption onChange: (value: SortOption) => void -}): JSX.Element { +}): React.ReactElement { return (
) } -function ArticleReviewsList({ reviews }: { reviews: Review[] }): JSX.Element { +function ArticleReviewsList({ reviews, onTipReview }: { reviews: Review[]; onTipReview: (reviewId: string) => void }): React.ReactElement { return ( - <> +
{reviews.map((r) => ( -
-
{r.content}
-
- Auteur critique : {formatPubkey(r.reviewerPubkey)} +
+ {r.title && ( +

{r.title}

+ )} +
{r.content}
+ {r.text && ( +
+ {r.text} +
+ )} +
+ {t('review.reviewer')}: {formatPubkey(r.reviewerPubkey)} {formatDate(r.createdAt)} +
))} - +
) } diff --git a/components/ArticlesList.tsx b/components/ArticlesList.tsx index c88de80..1271035 100644 --- a/components/ArticlesList.tsx +++ b/components/ArticlesList.tsx @@ -11,7 +11,7 @@ interface ArticlesListProps { unlockedArticles: Set } -function LoadingState(): JSX.Element { +function LoadingState(): React.ReactElement { // Use generic loading message at startup, then specific message once we know what we're loading return (
@@ -20,7 +20,7 @@ function LoadingState(): JSX.Element { ) } -function ErrorState({ message }: { message: string }): JSX.Element { +function ErrorState({ message }: { message: string }): React.ReactElement { return (

{message}

@@ -28,7 +28,7 @@ function ErrorState({ message }: { message: string }): JSX.Element { ) } -function EmptyState({ hasAny }: { hasAny: boolean }): JSX.Element { +function EmptyState({ hasAny }: { hasAny: boolean }): React.ReactElement { return (

@@ -45,7 +45,7 @@ export function ArticlesList({ error, onUnlock, unlockedArticles, -}: ArticlesListProps): JSX.Element { +}: ArticlesListProps): React.ReactElement { if (loading) { return } diff --git a/components/AuthorCard.tsx b/components/AuthorCard.tsx index 66b6cb6..91703de 100644 --- a/components/AuthorCard.tsx +++ b/components/AuthorCard.tsx @@ -7,7 +7,7 @@ interface AuthorCardProps { presentation: Article } -export function AuthorCard({ presentation }: AuthorCardProps): JSX.Element { +export function AuthorCard({ presentation }: AuthorCardProps): React.ReactElement { const authorName = presentation.title.replace(/^Présentation de /, '') || t('common.author') const totalBTC = (presentation.totalSponsoring ?? 0) / 100_000_000 diff --git a/components/AuthorFilter.tsx b/components/AuthorFilter.tsx index 97a0150..749cbe5 100644 --- a/components/AuthorFilter.tsx +++ b/components/AuthorFilter.tsx @@ -4,7 +4,7 @@ import { useAuthorFilterProps } from './AuthorFilterHooks' import { AuthorFilterButtonWrapper } from './AuthorFilterButton' import { AuthorDropdown } from './AuthorFilterDropdown' -function AuthorFilterLabel(): JSX.Element { +function AuthorFilterLabel(): React.ReactElement { return (

void -}): JSX.Element { +}): React.ReactElement { const props = useAuthorFilterProps(authors, value) return ( diff --git a/components/AuthorFilterButton.tsx b/components/AuthorFilterButton.tsx index aa8d93a..3828073 100644 --- a/components/AuthorFilterButton.tsx +++ b/components/AuthorFilterButton.tsx @@ -1,7 +1,7 @@ import React from 'react' import { AuthorAvatar } from './AuthorFilterDropdown' -export function AuthorMnemonicIcons({ value, getMnemonicIcons }: { value: string; getMnemonicIcons: (pubkey: string) => string[] }): JSX.Element { +export function AuthorMnemonicIcons({ value, getMnemonicIcons }: { value: string; getMnemonicIcons: (pubkey: string) => string[] }): React.ReactElement { return (
{getMnemonicIcons(value).map((icon, idx) => ( @@ -23,7 +23,7 @@ export function AuthorFilterButtonContent({ selectedAuthor: { name?: string; picture?: string } | null | undefined selectedDisplayName: string getMnemonicIcons: (pubkey: string) => string[] -}): JSX.Element { +}): React.ReactElement { return ( <> {value && ( @@ -38,7 +38,7 @@ export function AuthorFilterButtonContent({ ) } -export function DropdownArrowIcon({ isOpen }: { isOpen: boolean }): JSX.Element { +export function DropdownArrowIcon({ isOpen }: { isOpen: boolean }): React.ReactElement { return ( void buttonRef: React.RefObject -}): JSX.Element { +}): React.ReactElement { return (
{t('filters.loading')}
) : ( @@ -201,7 +201,7 @@ export function AuthorDropdown({ getDisplayName: (pubkey: string) => string getPicture: (pubkey: string) => string | undefined getMnemonicIcons: (pubkey: string) => string[] -}): JSX.Element { +}): React.ReactElement { return (
void): { dropdownRef: React.RefObject; buttonRef: React.RefObject } { - const dropdownRef = useRef(null) - const buttonRef = useRef(null) +export function useAuthorFilterDropdown(isOpen: boolean, setIsOpen: (open: boolean) => void): { dropdownRef: React.RefObject; buttonRef: React.RefObject } { + const dropdownRef = useRef(null) + const buttonRef = useRef(null) useEffect(() => { const handleClickOutside = (event: MouseEvent): void => { @@ -62,8 +62,8 @@ export function useAuthorFilterState(authors: string[], value: string | null): { loading: boolean isOpen: boolean setIsOpen: (open: boolean) => void - dropdownRef: React.RefObject - buttonRef: React.RefObject + dropdownRef: React.RefObject + buttonRef: React.RefObject getDisplayName: (pubkey: string) => string getPicture: (pubkey: string) => string | undefined getMnemonicIcons: (pubkey: string) => string[] diff --git a/components/AuthorsList.tsx b/components/AuthorsList.tsx index 704da77..9140d8b 100644 --- a/components/AuthorsList.tsx +++ b/components/AuthorsList.tsx @@ -9,7 +9,7 @@ interface AuthorsListProps { error: string | null } -function LoadingState(): JSX.Element { +function LoadingState(): React.ReactElement { return (

{t('common.loading.authors')}

@@ -17,7 +17,7 @@ function LoadingState(): JSX.Element { ) } -function ErrorState({ message }: { message: string }): JSX.Element { +function ErrorState({ message }: { message: string }): React.ReactElement { return (

{message}

@@ -25,7 +25,7 @@ function ErrorState({ message }: { message: string }): JSX.Element { ) } -function EmptyState({ hasAny }: { hasAny: boolean }): JSX.Element { +function EmptyState({ hasAny }: { hasAny: boolean }): React.ReactElement { return (

@@ -35,7 +35,7 @@ function EmptyState({ hasAny }: { hasAny: boolean }): JSX.Element { ) } -export function AuthorsList({ authors, allAuthors, loading, error }: AuthorsListProps): JSX.Element { +export function AuthorsList({ authors, allAuthors, loading, error }: AuthorsListProps): React.ReactElement { if (loading) { return } diff --git a/components/CacheUpdateManager.tsx b/components/CacheUpdateManager.tsx new file mode 100644 index 0000000..f4b484d --- /dev/null +++ b/components/CacheUpdateManager.tsx @@ -0,0 +1,106 @@ +import { useState } from 'react' +import { nostrAuthService } from '@/lib/nostrAuth' +import { objectCache } from '@/lib/objectCache' +import { syncUserContentToCache } from '@/lib/userContentSync' + +async function updateCache(): Promise { + const state = nostrAuthService.getState() + if (!state.connected || !state.pubkey) { + throw new Error('Vous devez être connecté pour mettre à jour le cache') + } + + await Promise.all([ + objectCache.clear('author'), + objectCache.clear('series'), + objectCache.clear('publication'), + objectCache.clear('review'), + objectCache.clear('purchase'), + objectCache.clear('sponsoring'), + objectCache.clear('review_tip'), + ]) + + await syncUserContentToCache(state.pubkey) +} + +function ErrorMessage({ error }: { error: string }): React.ReactElement { + return ( +

+

{error}

+
+ ) +} + +function SuccessMessage(): React.ReactElement { + return ( +
+

Cache mis à jour avec succès

+
+ ) +} + +function NotConnectedMessage(): React.ReactElement { + return ( +
+

Vous devez être connecté pour mettre à jour le cache

+
+ ) +} + +function createUpdateHandler( + setUpdating: (value: boolean) => void, + setError: (value: string | null) => void, + setSuccess: (value: boolean) => void +): () => Promise { + return async (): Promise => { + try { + setUpdating(true) + setError(null) + setSuccess(false) + await updateCache() + setSuccess(true) + setTimeout(() => { + setSuccess(false) + }, 3000) + } catch (e) { + const errorMessage = e instanceof Error ? e.message : 'Erreur lors de la mise à jour du cache' + setError(errorMessage) + console.error('Error updating cache:', e) + } finally { + setUpdating(false) + } + } +} + +export function CacheUpdateManager(): React.ReactElement { + const [updating, setUpdating] = useState(false) + const [success, setSuccess] = useState(false) + const [error, setError] = useState(null) + const handleUpdateCache = createUpdateHandler(setUpdating, setError, setSuccess) + const state = nostrAuthService.getState() + const isConnected = state.connected && state.pubkey + + return ( +
+

Mise à jour du cache

+ +

+ Videz et re-synchronisez le cache IndexedDB avec les données depuis les relais Nostr. + Cela permet de récupérer les dernières versions de vos publications, séries et profil. +

+ + {error && } + {success && } + {!isConnected && } + + +
+ ) +} diff --git a/components/CategorySelect.tsx b/components/CategorySelect.tsx index 29d0eaa..8733af4 100644 --- a/components/CategorySelect.tsx +++ b/components/CategorySelect.tsx @@ -17,7 +17,7 @@ export function CategorySelect({ onChange, required = false, helpText, -}: CategorySelectProps): JSX.Element { +}: CategorySelectProps): React.ReactElement { return (