From 303c0bf7df5da8ab48b78dce7bf997afecee26a8 Mon Sep 17 00:00:00 2001 From: Nicolas Cantu Date: Tue, 6 Jan 2026 15:57:34 +0100 Subject: [PATCH] lint fix wip --- components/ArticleEditorForm.tsx | 2 +- components/KeyManagementManager.tsx | 4 + components/MarkdownEditorTwoColumns.tsx | 6 +- components/ReviewForm.tsx | 8 +- components/ReviewTipForm.tsx | 8 +- components/SyncProgressBar.tsx | 160 +++++++++++++++++++++ components/UserArticles.tsx | 14 ++ lib/articleMutations.ts | 17 +++ lib/articlePublisherHelpersPresentation.ts | 6 +- lib/nostrEventParsing.ts | 4 +- lib/syncStorage.ts | 51 +++++++ lib/userContentSync.ts | 78 ++++++++-- pages/profile.tsx | 2 +- public/locales/en.txt | 5 + public/locales/fr.txt | 5 + 15 files changed, 340 insertions(+), 30 deletions(-) create mode 100644 components/SyncProgressBar.tsx create mode 100644 lib/syncStorage.ts diff --git a/components/ArticleEditorForm.tsx b/components/ArticleEditorForm.tsx index dc7feb1..8644b42 100644 --- a/components/ArticleEditorForm.tsx +++ b/components/ArticleEditorForm.tsx @@ -202,7 +202,7 @@ const ArticleFieldsRight = ({ onDraftChange({ ...draft, content: value })} - pages={draft.pages} + {...(draft.pages ? { pages: draft.pages } : {})} onPagesChange={(pages) => onDraftChange({ ...draft, pages })} onMediaAdd={(media: MediaRef) => { const nextMedia = [...(draft.media ?? []), media] diff --git a/components/KeyManagementManager.tsx b/components/KeyManagementManager.tsx index 73a4762..7588080 100644 --- a/components/KeyManagementManager.tsx +++ b/components/KeyManagementManager.tsx @@ -3,6 +3,7 @@ import { nostrAuthService } from '@/lib/nostrAuth' import { keyManagementService } from '@/lib/keyManagement' import { nip19 } from 'nostr-tools' import { t } from '@/lib/i18n' +import { SyncProgressBar } from './SyncProgressBar' interface PublicKeys { publicKey: string @@ -251,6 +252,9 @@ export function KeyManagementManager(): React.ReactElement { )} + {/* Sync Progress Bar */} + {publicKeys && } + {!publicKeys && !accountExists && (

{t('settings.keyManagement.noAccount.title')}

diff --git a/components/MarkdownEditorTwoColumns.tsx b/components/MarkdownEditorTwoColumns.tsx index 9ef8cff..5e27080 100644 --- a/components/MarkdownEditorTwoColumns.tsx +++ b/components/MarkdownEditorTwoColumns.tsx @@ -89,7 +89,7 @@ export function MarkdownEditorTwoColumns({ }} uploading={uploading} error={error} - onAddPage={onPagesChange ? handleAddPage : undefined} + {...(onPagesChange ? { onAddPage: handleAddPage } : {})} />
@@ -205,8 +205,8 @@ function PagesManager({ onContentChange={(content) => onPageContentChange(page.number, content)} onTypeChange={(type) => onPageTypeChange(page.number, type)} onRemove={() => onRemovePage(page.number)} - onImageUpload={(file) => { - void onImageUpload(file, page.number) + onImageUpload={async (file) => { + await onImageUpload(file, page.number) }} /> ))} diff --git a/components/ReviewForm.tsx b/components/ReviewForm.tsx index 4246f02..faece29 100644 --- a/components/ReviewForm.tsx +++ b/components/ReviewForm.tsx @@ -41,18 +41,18 @@ export function ReviewForm({ article, onSuccess, onCancel }: ReviewFormProps): R return } - const category = article.category ?? 'science-fiction' + const category = article.category === 'author-presentation' ? 'science-fiction' : (article.category ?? 'science-fiction') const seriesId = article.seriesId ?? '' await publishReview({ articleId: article.id, seriesId, - category, + category: category === 'science-fiction' || category === 'scientific-research' ? category : 'science-fiction', authorPubkey: article.pubkey, reviewerPubkey: pubkey, content: content.trim(), - title: title.trim() || undefined, - text: text.trim() || undefined, + ...(title.trim() ? { title: title.trim() } : {}), + ...(text.trim() ? { text: text.trim() } : {}), authorPrivateKey: privateKey, }) diff --git a/components/ReviewTipForm.tsx b/components/ReviewTipForm.tsx index 60be79d..39b885c 100644 --- a/components/ReviewTipForm.tsx +++ b/components/ReviewTipForm.tsx @@ -38,17 +38,17 @@ export function ReviewTipForm({ review, article, onSuccess, onCancel }: ReviewTi } const split = calculateReviewSplit() - const category = article.category === 'science-fiction' ? 'sciencefiction' : article.category === 'scientific-research' ? 'research' : 'sciencefiction' // Build zap request tags + const category = article.category === 'author-presentation' ? undefined : (article.category === 'science-fiction' || article.category === 'scientific-research' ? article.category : undefined) const zapRequestTags = buildReviewTipZapRequestTags({ articleId: article.id, reviewId: review.id, authorPubkey: article.pubkey, reviewerPubkey: review.reviewerPubkey, - category: article.category, - seriesId: article.seriesId, - text: text.trim() || undefined, + ...(category ? { category } : {}), + ...(article.seriesId ? { seriesId: article.seriesId } : {}), + ...(text.trim() ? { text: text.trim() } : {}), }) // Create zap request event (kind 9734) and publish it diff --git a/components/SyncProgressBar.tsx b/components/SyncProgressBar.tsx new file mode 100644 index 0000000..e1bc5de --- /dev/null +++ b/components/SyncProgressBar.tsx @@ -0,0 +1,160 @@ +import { useState, useEffect } from 'react' +import { nostrAuthService } from '@/lib/nostrAuth' +import { syncUserContentToCache, type SyncProgress } from '@/lib/userContentSync' +import { getLastSyncDate, getCurrentTimestamp, calculateDaysBetween } from '@/lib/syncStorage' +import { MIN_EVENT_DATE } from '@/lib/platformConfig' +import { t } from '@/lib/i18n' + +export function SyncProgressBar(): React.ReactElement | null { + const [syncProgress, setSyncProgress] = useState(null) + const [isSyncing, setIsSyncing] = useState(false) + const [lastSyncDate, setLastSyncDate] = useState(null) + const [totalDays, setTotalDays] = useState(0) + + useEffect(() => { + void loadSyncStatus() + }, []) + + async function loadSyncStatus(): Promise { + try { + const state = nostrAuthService.getState() + if (!state.connected || !state.pubkey) { + return + } + + const storedLastSyncDate = await getLastSyncDate() + const currentTimestamp = getCurrentTimestamp() + const days = calculateDaysBetween(storedLastSyncDate, currentTimestamp) + + setLastSyncDate(storedLastSyncDate) + setTotalDays(days) + + // If everything is synced (no days to sync), don't show the progress bar + if (days === 0 && storedLastSyncDate >= currentTimestamp) { + setSyncProgress(null) + + } + } catch (error) { + console.error('Error loading sync status:', error) + } + } + + async function startSync(): Promise { + try { + const state = nostrAuthService.getState() + if (!state.connected || !state.pubkey) { + return + } + + setIsSyncing(true) + setSyncProgress({ currentDay: 0, totalDays, completed: false }) + + await syncUserContentToCache(state.pubkey, (progress) => { + setSyncProgress(progress) + if (progress.completed) { + setIsSyncing(false) + void loadSyncStatus() + } + }) + } catch (error) { + console.error('Error starting sync:', error) + setIsSyncing(false) + } + } + + // Don't show if not connected or if everything is synced + const state = nostrAuthService.getState() + if (!state.connected || !state.pubkey) { + return null + } + + // If everything is synced, don't show the progress bar + if (totalDays === 0 && lastSyncDate !== null && lastSyncDate >= getCurrentTimestamp()) { + return null + } + + // If sync is completed and no days to sync, don't show + if (syncProgress?.completed && totalDays === 0) { + return null + } + + const progressPercentage = syncProgress && totalDays > 0 + ? Math.min(100, (syncProgress.currentDay / totalDays) * 100) + : 0 + + const formatDate = (timestamp: number): string => { + const date = new Date(timestamp * 1000) + const locale = typeof window !== 'undefined' ? navigator.language : 'fr-FR' + return date.toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric' }) + } + + const getStartDate = (): number => { + if (lastSyncDate !== null) { + return lastSyncDate + } + return MIN_EVENT_DATE + } + + const startDate = getStartDate() + const endDate = getCurrentTimestamp() + + return ( +
+
+

+ {t('settings.sync.title')} +

+ {!isSyncing && totalDays > 0 && ( + + )} +
+ + {totalDays > 0 && ( +
+

+ {t('settings.sync.daysRange', { + startDate: formatDate(startDate), + endDate: formatDate(endDate), + days: totalDays, + })} +

+
+ )} + + {isSyncing && syncProgress && ( +
+
+ + {t('settings.sync.progress', { + current: syncProgress.currentDay, + total: syncProgress.totalDays, + })} + + + {Math.round(progressPercentage)}% + +
+
+
+
+
+ )} + + {!isSyncing && totalDays === 0 && lastSyncDate !== null && ( +

+ {t('settings.sync.completed')} +

+ )} +
+ ) +} diff --git a/components/UserArticles.tsx b/components/UserArticles.tsx index e819541..d9d6890 100644 --- a/components/UserArticles.tsx +++ b/components/UserArticles.tsx @@ -1,5 +1,6 @@ import { useEffect, useState, type Dispatch, type SetStateAction } from 'react' import type { Article } from '@/types/nostr' +import type { ArticleDraft } from '@/lib/articlePublisherTypes' import { useArticleEditing } from '@/hooks/useArticleEditing' import { UserArticlesView } from './UserArticlesList' import { EditPanel } from './UserArticlesEditPanel' @@ -135,16 +136,29 @@ function buildUpdatedArticle( pubkey: string, newId: string ): Article { + const hash = newId.split('_')[0] ?? '' + const index = Number.parseInt(newId.split('_')[1] ?? '0', 10) + const version = Number.parseInt(newId.split('_')[2] ?? '0', 10) return { id: newId, + hash, + version, + index, pubkey, title: draft.title, preview: draft.preview, content: '', + description: draft.preview, + contentDescription: draft.preview, createdAt: Math.floor(Date.now() / 1000), zapAmount: draft.zapAmount, paid: false, + thumbnailUrl: draft.bannerUrl ?? '', ...(draft.category ? { category: draft.category } : {}), + ...(draft.seriesId ? { seriesId: draft.seriesId } : {}), + ...(draft.bannerUrl ? { bannerUrl: draft.bannerUrl } : {}), + ...(draft.media ? { media: draft.media } : {}), + ...(draft.pages ? { pages: draft.pages } : {}), } } diff --git a/lib/articleMutations.ts b/lib/articleMutations.ts index bde571a..3c1e3ef 100644 --- a/lib/articleMutations.ts +++ b/lib/articleMutations.ts @@ -4,6 +4,7 @@ import { storePrivateContent, getStoredPrivateContent } from './articleStorage' import { buildTags } from './nostrTagSystem' import { PLATFORM_SERVICE } from './platformConfig' import { generateSeriesHashId, generatePublicationHashId } from './hashIdGenerator' +import { parseObjectId } from './urlGenerator' import type { ArticleDraft, PublishedArticle } from './articlePublisher' import type { AlbyInvoice } from '@/types/alby' import type { Review, Series } from '@/types/nostr' @@ -64,12 +65,20 @@ export async function publishSeries(params: { if (!published) { throw new Error('Failed to publish series') } + const parsed = parseObjectId(published.id) + const hash = parsed.hash ?? published.id + const version = parsed.version ?? 0 + const index = parsed.index ?? 0 return { id: published.id, + hash, + version, + index, pubkey: params.authorPubkey, title: params.title, description: params.description, preview: params.preview ?? params.description.substring(0, 200), + thumbnailUrl: params.coverUrl ?? '', category, ...(params.coverUrl ? { coverUrl: params.coverUrl } : {}), kindType: 'series', @@ -156,12 +165,20 @@ export async function publishReview(params: { if (!published) { throw new Error('Failed to publish review') } + const parsed = parseObjectId(published.id) + const hash = parsed.hash ?? published.id + const version = parsed.version ?? 0 + const index = parsed.index ?? 0 return { id: published.id, + hash, + version, + index, articleId: params.articleId, authorPubkey: params.authorPubkey, reviewerPubkey: params.reviewerPubkey, content: params.content, + description: params.content.substring(0, 200), createdAt: published.created_at, ...(params.title ? { title: params.title } : {}), ...(params.text ? { text: params.text } : {}), diff --git a/lib/articlePublisherHelpersPresentation.ts b/lib/articlePublisherHelpersPresentation.ts index 5137f88..05af256 100644 --- a/lib/articlePublisherHelpersPresentation.ts +++ b/lib/articlePublisherHelpersPresentation.ts @@ -181,8 +181,8 @@ export async function parsePresentationEvent(event: Event): Promise { + try { + const stored = await storageService.get(LAST_SYNC_DATE_KEY, SYNC_STORAGE_SECRET) + if (stored !== null && typeof stored === 'number') { + return stored + } + } catch (error) { + console.error('Error getting last sync date:', error) + } + // Return MIN_EVENT_DATE if no date is stored + const { MIN_EVENT_DATE } = await import('./platformConfig') + return MIN_EVENT_DATE +} + +/** + * Store the last synchronization date + */ +export async function setLastSyncDate(timestamp: number): Promise { + try { + await storageService.set(LAST_SYNC_DATE_KEY, timestamp, SYNC_STORAGE_SECRET) + } catch (error) { + console.error('Error setting last sync date:', error) + } +} + +/** + * Calculate the number of days between two timestamps + */ +export function calculateDaysBetween(startTimestamp: number, endTimestamp: number): number { + const startDate = new Date(startTimestamp * 1000) + const endDate = new Date(endTimestamp * 1000) + const diffTime = endDate.getTime() - startDate.getTime() + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + return Math.max(0, diffDays) +} + +/** + * Get the current date as Unix timestamp + */ +export function getCurrentTimestamp(): number { + return Math.floor(Date.now() / 1000) +} diff --git a/lib/userContentSync.ts b/lib/userContentSync.ts index ea45ba0..6776a1c 100644 --- a/lib/userContentSync.ts +++ b/lib/userContentSync.ts @@ -67,20 +67,23 @@ async function fetchAndCachePublications( } // Cache each publication - for (const [hash, hashEvents] of eventsByHashId.entries()) { + for (const [_hash, hashEvents] of eventsByHashId.entries()) { const latestEvent = getLatestVersion(hashEvents) if (latestEvent) { const extracted = await extractPublicationFromEvent(latestEvent) - if (extracted?.hash) { + if (extracted) { + const parsed = parseObjectId(extracted.id) + const extractedHash = parsed.hash ?? extracted.id + const extractedIndex = parsed.index ?? 0 const tags = extractTagsFromEvent(latestEvent) await objectCache.set( 'publication', - extracted.hash, + extractedHash, latestEvent, extracted, tags.version ?? 0, tags.hidden ?? false, - extracted.index + extractedIndex ) } } @@ -158,20 +161,23 @@ async function fetchAndCacheSeries( } // Cache each series - for (const [hash, hashEvents] of eventsByHashId.entries()) { + for (const [_hash, hashEvents] of eventsByHashId.entries()) { const latestEvent = getLatestVersion(hashEvents) if (latestEvent) { const extracted = await extractSeriesFromEvent(latestEvent) - if (extracted?.hash) { + if (extracted) { + const parsed = parseObjectId(extracted.id) + const extractedHash = parsed.hash ?? extracted.id + const extractedIndex = parsed.index ?? 0 const tags = extractTagsFromEvent(latestEvent) await objectCache.set( 'series', - extracted.hash, + extractedHash, latestEvent, extracted, tags.version ?? 0, tags.hidden ?? false, - extracted.index + extractedIndex ) } } @@ -233,9 +239,6 @@ async function fetchAndCachePurchases( for (const event of events) { const extracted = await extractPurchaseFromEvent(event) if (extracted) { - const parsed = parseObjectId(extracted.id) - const hash = parsed.hash ?? extracted.id - const index = parsed.index ?? 0 // Parse to Purchase object for cache const { parsePurchaseFromEvent } = await import('./nostrEventParsing') const purchase = await parsePurchaseFromEvent(event) @@ -386,11 +389,22 @@ async function fetchAndCacheReviewTips( }) } +export interface SyncProgress { + currentDay: number + totalDays: number + completed: boolean +} + /** * Synchronize all user content to IndexedDB cache * Fetches profile, series, publications, purchases, sponsoring, and review tips and caches them + * @param userPubkey - The user's public key + * @param onProgress - Optional callback to report progress (currentDay, totalDays, completed) */ -export async function syncUserContentToCache(userPubkey: string): Promise { +export async function syncUserContentToCache( + userPubkey: string, + onProgress?: (progress: SyncProgress) => void +): Promise { try { const pool = nostrService.getPool() if (!pool) { @@ -400,23 +414,63 @@ export async function syncUserContentToCache(userPubkey: string): Promise const poolWithSub = pool as unknown as SimplePoolWithSub + // Get last sync date and calculate days + const { getLastSyncDate, setLastSyncDate, getCurrentTimestamp, calculateDaysBetween } = await import('./syncStorage') + const lastSyncDate = await getLastSyncDate() + const currentTimestamp = getCurrentTimestamp() + const totalDays = calculateDaysBetween(lastSyncDate, currentTimestamp) + + // Report initial progress + if (onProgress) { + onProgress({ currentDay: 0, totalDays, completed: false }) + } + + let currentDay = 0 + // Fetch and cache author profile (already caches itself) await fetchAuthorPresentationFromPool(poolWithSub, userPubkey) + currentDay++ + if (onProgress) { + onProgress({ currentDay, totalDays, completed: false }) + } // Fetch and cache all series await fetchAndCacheSeries(poolWithSub, userPubkey) + currentDay++ + if (onProgress) { + onProgress({ currentDay, totalDays, completed: false }) + } // Fetch and cache all publications await fetchAndCachePublications(poolWithSub, userPubkey) + currentDay++ + if (onProgress) { + onProgress({ currentDay, totalDays, completed: false }) + } // Fetch and cache all purchases (as payer) await fetchAndCachePurchases(poolWithSub, userPubkey) + currentDay++ + if (onProgress) { + onProgress({ currentDay, totalDays, completed: false }) + } // Fetch and cache all sponsoring (as author) await fetchAndCacheSponsoring(poolWithSub, userPubkey) + currentDay++ + if (onProgress) { + onProgress({ currentDay, totalDays, completed: false }) + } // Fetch and cache all review tips (as author) await fetchAndCacheReviewTips(poolWithSub, userPubkey) + currentDay++ + if (onProgress) { + onProgress({ currentDay, totalDays, completed: true }) + } + + // Store the current timestamp as last sync date + await setLastSyncDate(currentTimestamp) } catch (error) { console.error('Error syncing user content to cache:', error) // Don't throw - this is a background operation diff --git a/pages/profile.tsx b/pages/profile.tsx index 2b89098..6153031 100644 --- a/pages/profile.tsx +++ b/pages/profile.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react' import { useRouter } from 'next/router' import type { ArticleFilters } from '@/components/ArticleFilters' -import type { NostrProfile } from '@/types/nostr' +import type { Article, NostrProfile } from '@/types/nostr' import { ProfileView } from '@/components/ProfileView' import { useNostrAuth } from '@/hooks/useNostrAuth' import { useUserArticles } from '@/hooks/useUserArticles' diff --git a/public/locales/en.txt b/public/locales/en.txt index 234c8ca..d582880 100644 --- a/public/locales/en.txt +++ b/public/locales/en.txt @@ -236,6 +236,11 @@ settings.keyManagement.recovery.copy=Copy Recovery Words settings.keyManagement.recovery.copied=✓ Copied! settings.keyManagement.recovery.newNpub=Your new public key (npub) settings.keyManagement.recovery.done=Done +settings.sync.title=Notes Synchronization +settings.sync.start=Start Synchronization +settings.sync.daysRange=From {{startDate}} to {{endDate}} ({{days}} days) +settings.sync.progress=Day {{current}} of {{total}} +settings.sync.completed=Everything is synchronized settings.nip95.title=NIP-95 Upload Endpoints settings.nip95.loading=Loading... settings.nip95.error.loadFailed=Failed to load NIP-95 APIs diff --git a/public/locales/fr.txt b/public/locales/fr.txt index 1978b80..0b27b6f 100644 --- a/public/locales/fr.txt +++ b/public/locales/fr.txt @@ -236,6 +236,11 @@ settings.keyManagement.recovery.copy=Copier les mots de récupération settings.keyManagement.recovery.copied=✓ Copié ! settings.keyManagement.recovery.newNpub=Votre nouvelle clé publique (npub) settings.keyManagement.recovery.done=Terminé +settings.sync.title=Synchronisation des notes +settings.sync.start=Démarrer la synchronisation +settings.sync.daysRange=Du {{startDate}} au {{endDate}} ({{days}} jours) +settings.sync.progress=Jour {{current}} sur {{total}} +settings.sync.completed=Tout est synchronisé settings.language.title=Langue de préférence settings.language.description=Choisissez votre langue préférée pour l'interface settings.language.loading=Chargement...