@@ -20,6 +21,16 @@ export function ArticleFormButtons({ loading, onCancel }: ArticleFormButtonsProp
>
{loading ? t('publish.publishing') : t('publish.button')}
+ {onPreview && (
+
diff --git a/components/ArticlePreviewModal.tsx b/components/ArticlePreviewModal.tsx
new file mode 100644
index 0000000..892b1e3
--- /dev/null
+++ b/components/ArticlePreviewModal.tsx
@@ -0,0 +1,87 @@
+import { Modal } from './ui'
+import type { ArticleDraft } from '@/lib/articlePublisher'
+import { t } from '@/lib/i18n'
+
+interface ArticlePreviewModalProps {
+ isOpen: boolean
+ onClose: () => void
+ draft: ArticleDraft
+}
+
+export function ArticlePreviewModal({ isOpen, onClose, draft }: ArticlePreviewModalProps): React.ReactElement {
+ return (
+
+
+
+ )
+}
+
+function ArticlePreviewContent({ draft }: { draft: ArticleDraft }): React.ReactElement {
+ return (
+
+ )
+}
+
+function ArticlePreviewHeader({ draft }: { draft: ArticleDraft }): React.ReactElement {
+ return (
+
+
{draft.title || t('article.preview.noTitle')}
+ {draft.category && (
+
+ {t(`category.${draft.category}`)}
+
+ )}
+
+ )
+}
+
+function ArticlePreviewBody({ draft }: { draft: ArticleDraft }): React.ReactElement {
+ return (
+
+ {draft.preview && (
+
+
{t('article.preview.previewLabel')}
+
{draft.preview}
+
+ )}
+ {draft.content && (
+
+
{t('article.preview.contentLabel')}
+
{draft.content}
+
+ )}
+ {(!draft.preview && !draft.content) && (
+
{t('article.preview.empty')}
+ )}
+
+ )
+}
+
+function ArticlePreviewMeta({ draft }: { draft: ArticleDraft }): React.ReactElement {
+ return (
+
+
+ {t('article.preview.zapAmount')}: {draft.zapAmount} sats
+
+ {draft.seriesId && (
+
+ {t('article.preview.series')}: {draft.seriesId}
+
+ )}
+ {draft.media && draft.media.length > 0 && (
+
+ {t('article.preview.media')}: {draft.media.length} {t('article.preview.mediaItems')}
+
+ )}
+
+ )
+}
diff --git a/components/ArticlesList.tsx b/components/ArticlesList.tsx
index 8a66371..f3409dd 100644
--- a/components/ArticlesList.tsx
+++ b/components/ArticlesList.tsx
@@ -1,9 +1,10 @@
import { useRef } from 'react'
import type { Article } from '@/types/nostr'
import { ArticleCard } from './ArticleCard'
-import { ErrorState, EmptyState, Skeleton } from './ui'
+import { ErrorState, EmptyState, Skeleton, Button } from './ui'
import { t } from '@/lib/i18n'
import { useArrowNavigation } from '@/hooks/useArrowNavigation'
+import { usePagination } from '@/hooks/usePagination'
interface ArticlesListProps {
articles: Article[]
@@ -14,6 +15,8 @@ interface ArticlesListProps {
unlockedArticles: Set
}
+const ITEMS_PER_PAGE = 10
+
function ArticleCardSkeleton(): React.ReactElement {
return (
@@ -69,7 +72,106 @@ function ArticlesErrorState({ error }: { error: string }): React.ReactElement {
)
}
-function ArticlesContent({
+function PaginationInfo({ currentPage, totalPages }: { currentPage: number; totalPages: number }): React.ReactElement {
+ const stepText = t('pagination.page', { current: currentPage, total: totalPages })
+ return (
+
+ {stepText}
+
+ )
+}
+
+function PaginationButtons({
+ onNext,
+ onPrevious,
+ hasNext,
+ hasPrevious,
+}: {
+ onNext: () => void
+ onPrevious: () => void
+ hasNext: boolean
+ hasPrevious: boolean
+}): React.ReactElement {
+ return (
+
+
+
+
+ )
+}
+
+function PaginationControls({
+ currentPage,
+ totalPages,
+ onNext,
+ onPrevious,
+ hasNext,
+ hasPrevious,
+}: {
+ currentPage: number
+ totalPages: number
+ onNext: () => void
+ onPrevious: () => void
+ hasNext: boolean
+ hasPrevious: boolean
+}): React.ReactElement {
+ return (
+
+ )
+}
+
+function ArticlesListItems({
+ articles,
+ allArticles,
+ onUnlock,
+ unlockedArticles,
+}: {
+ articles: Article[]
+ allArticles: Article[]
+ onUnlock: (article: Article) => void
+ unlockedArticles: Set
+}): React.ReactElement {
+ return (
+
+ {articles.map((article) => (
+
+ ))}
+
+ )
+}
+
+function ArticlesListContent({
articles,
allArticles,
onUnlock,
@@ -82,23 +184,36 @@ function ArticlesContent({
unlockedArticles: Set
containerRef: React.RefObject
}): React.ReactElement {
+ const pagination = usePagination({ items: articles, itemsPerPage: ITEMS_PER_PAGE })
+ const showingText = t('pagination.showing', {
+ current: pagination.paginatedItems.length,
+ total: articles.length,
+ all: allArticles.length,
+ })
+
return (
- Showing {articles.length} of {allArticles.length} article{allArticles.length !== 1 ? 's' : ''}
+ {showingText}
-
- {articles.map((article) => (
-
- ))}
+
+ {pagination.totalPages > 1 && (
+
+ )}
)
}
@@ -125,7 +240,7 @@ export function ArticlesList({
}
return (
-
+
>
diff --git a/components/OnboardingTour.tsx b/components/OnboardingTour.tsx
new file mode 100644
index 0000000..a2dc167
--- /dev/null
+++ b/components/OnboardingTour.tsx
@@ -0,0 +1,233 @@
+import { useState, useEffect } from 'react'
+import { Button, Modal } from './ui'
+import { t } from '@/lib/i18n'
+import { isOnboardingCompleted, markOnboardingCompleted } from '@/lib/onboardingPreferences'
+
+interface OnboardingStep {
+ id: string
+ title: string
+ content: string
+ targetSelector?: string
+}
+
+const ONBOARDING_STEPS: OnboardingStep[] = [
+ {
+ id: 'welcome',
+ title: t('onboarding.steps.welcome.title'),
+ content: t('onboarding.steps.welcome.content'),
+ },
+ {
+ id: 'search',
+ title: t('onboarding.steps.search.title'),
+ content: t('onboarding.steps.search.content'),
+ targetSelector: '[role="search"]',
+ },
+ {
+ id: 'filters',
+ title: t('onboarding.steps.filters.title'),
+ content: t('onboarding.steps.filters.content'),
+ targetSelector: '#filters-section',
+ },
+ {
+ id: 'articles',
+ title: t('onboarding.steps.articles.title'),
+ content: t('onboarding.steps.articles.content'),
+ targetSelector: '#articles-section',
+ },
+ {
+ id: 'payment',
+ title: t('onboarding.steps.payment.title'),
+ content: t('onboarding.steps.payment.content'),
+ },
+]
+
+interface OnboardingTourProps {
+ onComplete?: () => void
+}
+
+interface OnboardingHandlersParams {
+ isLastStep: boolean
+ currentStep: number
+ setCurrentStep: (value: number) => void
+ setIsActive: (value: boolean) => void
+ onComplete?: () => void
+}
+
+function useOnboardingState(): {
+ isActive: boolean
+ setIsActive: (value: boolean) => void
+ currentStep: number
+ setCurrentStep: (value: number) => void
+ isLoading: boolean
+} {
+ const [isActive, setIsActive] = useState(false)
+ const [currentStep, setCurrentStep] = useState(0)
+ const [isLoading, setIsLoading] = useState(true)
+
+ useEffect(() => {
+ const checkOnboarding = async (): Promise => {
+ try {
+ const completed = await isOnboardingCompleted()
+ if (!completed) {
+ setIsActive(true)
+ }
+ } catch (error) {
+ console.error('Error checking onboarding status:', error)
+ } finally {
+ setIsLoading(false)
+ }
+ }
+ void checkOnboarding()
+ }, [])
+
+ return { isActive, setIsActive, currentStep, setCurrentStep, isLoading }
+}
+
+function useStepScrolling(isActive: boolean, currentStep: number): void {
+ useEffect(() => {
+ if (isActive && currentStep < ONBOARDING_STEPS.length) {
+ const step = ONBOARDING_STEPS[currentStep]
+ if (step?.targetSelector) {
+ const element = document.querySelector(step.targetSelector)
+ if (element) {
+ element.scrollIntoView({ behavior: 'smooth', block: 'center' })
+ }
+ }
+ }
+ }, [isActive, currentStep])
+}
+
+function OnboardingTourActions({
+ currentStep,
+ isLastStep,
+ onNext,
+ onSkip,
+}: {
+ currentStep: number
+ isLastStep: boolean
+ onNext: () => void
+ onSkip: () => void
+}): React.ReactElement {
+ const stepText = t('onboarding.step', { current: currentStep + 1, total: ONBOARDING_STEPS.length })
+
+ return (
+
+
+ {stepText}
+
+
+
+
+
+
+ )
+}
+
+function OnboardingTourContent({
+ step,
+ currentStep,
+ isLastStep,
+ onNext,
+ onSkip,
+}: {
+ step: OnboardingStep
+ currentStep: number
+ isLastStep: boolean
+ onNext: () => void
+ onSkip: () => void
+}): React.ReactElement {
+ return (
+
+ )
+}
+
+function useOnboardingHandlers(params: OnboardingHandlersParams): {
+ handleNext: () => void
+ handleSkip: () => void
+} {
+ const handleComplete = async (): Promise => {
+ try {
+ await markOnboardingCompleted()
+ params.setIsActive(false)
+ params.onComplete?.()
+ } catch (error) {
+ console.error('Error marking onboarding as completed:', error)
+ }
+ }
+
+ const handleNext = (): void => {
+ if (params.isLastStep) {
+ void handleComplete()
+ } else {
+ params.setCurrentStep(params.currentStep + 1)
+ }
+ }
+
+ const handleSkip = (): void => {
+ void handleComplete()
+ }
+
+ return { handleNext, handleSkip }
+}
+
+export function OnboardingTour({ onComplete }: OnboardingTourProps): React.ReactElement | null {
+ const { isActive, setIsActive, currentStep, setCurrentStep, isLoading } = useOnboardingState()
+ useStepScrolling(isActive, currentStep)
+
+ if (isLoading || !isActive) {
+ return null
+ }
+
+ const step = ONBOARDING_STEPS[currentStep]
+ if (!step) {
+ return null
+ }
+
+ const isLastStep = currentStep === ONBOARDING_STEPS.length - 1
+ const { handleNext, handleSkip } = useOnboardingHandlers({
+ isLastStep,
+ currentStep,
+ setCurrentStep,
+ setIsActive,
+ onComplete,
+ })
+
+ return (
+
+
+
+ )
+}
diff --git a/hooks/useInfiniteScroll.ts b/hooks/useInfiniteScroll.ts
new file mode 100644
index 0000000..397904e
--- /dev/null
+++ b/hooks/useInfiniteScroll.ts
@@ -0,0 +1,57 @@
+import { useEffect, useRef, useState } from 'react'
+
+interface UseInfiniteScrollOptions {
+ hasMore: boolean
+ loading: boolean
+ onLoadMore: () => void
+ threshold?: number
+}
+
+export function useInfiniteScroll({
+ hasMore,
+ loading,
+ onLoadMore,
+ threshold = 200,
+}: UseInfiniteScrollOptions): React.RefObject {
+ const observerRef = useRef(null)
+ const [isIntersecting, setIsIntersecting] = useState(false)
+
+ useEffect(() => {
+ if (!hasMore || loading) {
+ return
+ }
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ const entry = entries[0]
+ if (entry?.isIntersecting) {
+ setIsIntersecting(true)
+ } else {
+ setIsIntersecting(false)
+ }
+ },
+ {
+ rootMargin: `${threshold}px`,
+ }
+ )
+
+ const currentRef = observerRef.current
+ if (currentRef) {
+ observer.observe(currentRef)
+ }
+
+ return () => {
+ if (currentRef) {
+ observer.unobserve(currentRef)
+ }
+ }
+ }, [hasMore, loading, threshold])
+
+ useEffect(() => {
+ if (isIntersecting && hasMore && !loading) {
+ onLoadMore()
+ }
+ }, [isIntersecting, hasMore, loading, onLoadMore])
+
+ return observerRef
+}
diff --git a/hooks/usePagination.ts b/hooks/usePagination.ts
new file mode 100644
index 0000000..9fa7228
--- /dev/null
+++ b/hooks/usePagination.ts
@@ -0,0 +1,60 @@
+import { useState, useMemo } from 'react'
+
+interface UsePaginationOptions {
+ items: T[]
+ itemsPerPage: number
+}
+
+interface UsePaginationResult {
+ currentPage: number
+ totalPages: number
+ paginatedItems: T[]
+ goToPage: (page: number) => void
+ nextPage: () => void
+ previousPage: () => void
+ hasNextPage: boolean
+ hasPreviousPage: boolean
+}
+
+export function usePagination({ items, itemsPerPage }: UsePaginationOptions): UsePaginationResult {
+ const [currentPage, setCurrentPage] = useState(1)
+
+ const totalPages = Math.max(1, Math.ceil(items.length / itemsPerPage))
+
+ const paginatedItems = useMemo(() => {
+ const startIndex = (currentPage - 1) * itemsPerPage
+ const endIndex = startIndex + itemsPerPage
+ return items.slice(startIndex, endIndex)
+ }, [items, currentPage, itemsPerPage])
+
+ const goToPage = (page: number): void => {
+ const validPage = Math.max(1, Math.min(page, totalPages))
+ setCurrentPage(validPage)
+ }
+
+ const nextPage = (): void => {
+ if (currentPage < totalPages) {
+ setCurrentPage(currentPage + 1)
+ }
+ }
+
+ const previousPage = (): void => {
+ if (currentPage > 1) {
+ setCurrentPage(currentPage - 1)
+ }
+ }
+
+ const hasNextPage = currentPage < totalPages
+ const hasPreviousPage = currentPage > 1
+
+ return {
+ currentPage,
+ totalPages,
+ paginatedItems,
+ goToPage,
+ nextPage,
+ previousPage,
+ hasNextPage,
+ hasPreviousPage,
+ }
+}
diff --git a/lib/onboardingPreferences.ts b/lib/onboardingPreferences.ts
new file mode 100644
index 0000000..ccc7c40
--- /dev/null
+++ b/lib/onboardingPreferences.ts
@@ -0,0 +1,73 @@
+import { createIndexedDBHelper, type IndexedDBHelper } from './helpers/indexedDBHelper'
+
+const DB_NAME = 'nostr_paywall_settings'
+const DB_VERSION = 3
+const STORE_NAME = 'onboarding_preferences'
+
+export interface OnboardingPreferencesItem {
+ key: 'completed'
+ value: boolean
+ timestamp: number
+}
+
+const COMPLETED_KEY = 'completed'
+
+class OnboardingPreferencesService {
+ private readonly dbHelper: IndexedDBHelper
+
+ constructor() {
+ this.dbHelper = createIndexedDBHelper({
+ dbName: DB_NAME,
+ version: DB_VERSION,
+ storeName: STORE_NAME,
+ keyPath: 'key',
+ indexes: [{ name: 'timestamp', keyPath: 'timestamp', unique: false }],
+ })
+ }
+
+ async isCompleted(): Promise {
+ try {
+ const result = await this.dbHelper.get(COMPLETED_KEY)
+ return result?.value ?? false
+ } catch (error) {
+ console.error('Error loading onboarding preferences:', error)
+ return false
+ }
+ }
+
+ async markCompleted(): Promise {
+ try {
+ await this.dbHelper.put({
+ key: COMPLETED_KEY,
+ value: true,
+ timestamp: Date.now(),
+ })
+ } catch (error) {
+ console.error('Error saving onboarding preferences:', error)
+ throw error
+ }
+ }
+
+ async clear(): Promise {
+ try {
+ await this.dbHelper.delete(COMPLETED_KEY)
+ } catch (error) {
+ console.error('Error clearing onboarding preferences:', error)
+ throw error
+ }
+ }
+}
+
+const onboardingPreferencesService = new OnboardingPreferencesService()
+
+export async function isOnboardingCompleted(): Promise {
+ return onboardingPreferencesService.isCompleted()
+}
+
+export async function markOnboardingCompleted(): Promise {
+ return onboardingPreferencesService.markCompleted()
+}
+
+export async function clearOnboardingPreferences(): Promise {
+ return onboardingPreferencesService.clear()
+}