From 55919310a55d511bd0503b8514e9d73b0fbf78ca Mon Sep 17 00:00:00 2001 From: Richard Roberson Date: Sun, 26 Jan 2025 18:34:31 -0700 Subject: [PATCH] Add different view modes --- src/app/page.tsx | 21 +---- src/app/pdf/[id]/page.tsx | 14 ++- src/components/PDFViewSettings.tsx | 134 +++++++++++++++++++++++++++++ src/components/PDFViewer.tsx | 98 ++++++++++++++++----- src/components/SettingsModal.tsx | 27 +----- src/components/icons/Icons.tsx | 45 ++++++++++ src/contexts/ConfigContext.tsx | 80 +++++++++++++++-- 7 files changed, 346 insertions(+), 73 deletions(-) create mode 100644 src/components/PDFViewSettings.tsx diff --git a/src/app/page.tsx b/src/app/page.tsx index 7f2e3f5..1ea0ce0 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -4,6 +4,7 @@ import { useState } from 'react'; import { PDFUploader } from '@/components/PDFUploader'; import { DocumentList } from '@/components/DocumentList'; import { SettingsModal } from '@/components/SettingsModal'; +import { SettingsIcon } from '@/components/icons/Icons'; export default function Home() { const [isSettingsOpen, setIsSettingsOpen] = useState(false); @@ -18,25 +19,7 @@ export default function Home() { focus:outline-none focus:ring-2 focus:ring-accent transition-colors" aria-label="Settings" > - - - - +
diff --git a/src/app/pdf/[id]/page.tsx b/src/app/pdf/[id]/page.tsx index 1057f23..fca825c 100644 --- a/src/app/pdf/[id]/page.tsx +++ b/src/app/pdf/[id]/page.tsx @@ -8,6 +8,8 @@ import { useCallback, useEffect, useState } from 'react'; import { PDFSkeleton } from '@/components/PDFSkeleton'; import { useTTS } from '@/contexts/TTSContext'; import { Button } from '@headlessui/react'; +import { PDFViewSettings } from '@/components/PDFViewSettings'; +import { SettingsIcon } from '@/components/icons/Icons'; // Dynamic import for client-side rendering only const PDFViewer = dynamic( @@ -25,6 +27,7 @@ export default function PDFViewerPage() { const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(true); const [zoomLevel, setZoomLevel] = useState(100); + const [isSettingsOpen, setIsSettingsOpen] = useState(false); const loadDocument = useCallback(async () => { if (!isLoading) return; // Prevent calls when not loading new doc @@ -72,7 +75,7 @@ export default function PDFViewerPage() { <>
-
+
{clearCurrDoc(); stop();}} @@ -100,6 +103,14 @@ export default function PDFViewerPage() { +
+

{isLoading ? 'Loading...' : currDocName} @@ -113,6 +124,7 @@ export default function PDFViewerPage() { ) : ( )} + ); } diff --git a/src/components/PDFViewSettings.tsx b/src/components/PDFViewSettings.tsx new file mode 100644 index 0000000..b32ae03 --- /dev/null +++ b/src/components/PDFViewSettings.tsx @@ -0,0 +1,134 @@ +'use client'; + +import { Fragment } from 'react'; +import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild, Listbox, ListboxButton, ListboxOptions, ListboxOption } from '@headlessui/react'; +import { useConfig, ViewType } from '@/contexts/ConfigContext'; +import { ChevronUpDownIcon, CheckIcon } from '@/components/icons/Icons'; + +interface PDFViewSettingsProps { + isOpen: boolean; + setIsOpen: (isOpen: boolean) => void; +} + +const viewTypes = [ + { id: 'single', name: 'Single Page' }, + { id: 'dual', name: 'Two Pages' }, + { id: 'scroll', name: 'Continuous Scroll' }, +]; + +export function PDFViewSettings({ isOpen, setIsOpen }: PDFViewSettingsProps) { + const { viewType, updateConfigKey } = useConfig(); + const selectedView = viewTypes.find(v => v.id === viewType) || viewTypes[0]; + + return ( + + setIsOpen(false)}> + +
+ + +
+
+ + + + View Settings + +
+
+
+ + updateConfigKey('viewType', newView.id as ViewType)} + > +
+ + {selectedView.name} + + + + + + + {viewTypes.map((view) => ( + + `relative cursor-pointer select-none py-2 pl-10 pr-4 ${ + active ? 'bg-accent/10 text-accent' : 'text-foreground' + }` + } + value={view} + > + {({ selected }) => ( + <> + + {view.name} + + {selected ? ( + + + + ) : null} + + )} + + ))} + + +
+
+ {selectedView.id === 'scroll' && ( +

+ Note: Continuous scroll may perform poorly for larger documents. +

+ )} +
+
+
+ +
+ +
+
+
+
+
+
+
+ ); +} diff --git a/src/components/PDFViewer.tsx b/src/components/PDFViewer.tsx index e07b6cf..314bb82 100644 --- a/src/components/PDFViewer.tsx +++ b/src/components/PDFViewer.tsx @@ -8,6 +8,7 @@ import { PDFSkeleton } from './PDFSkeleton'; import { useTTS } from '@/contexts/TTSContext'; import { usePDF } from '@/contexts/PDFContext'; import TTSPlayer from '@/components/player/TTSPlayer'; +import { useConfig } from '@/contexts/ConfigContext'; interface PDFViewerProps { zoomLevel: number; @@ -17,6 +18,9 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) { const [containerWidth, setContainerWidth] = useState(0); const containerRef = useRef(null); + // Config context + const { viewType } = useConfig(); + // TTS context const { currentSentence, @@ -112,21 +116,36 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) { const [pageWidth, setPageWidth] = useState(595); // default A4 width const [pageHeight, setPageHeight] = useState(842); // default A4 height - // Modify scale calculation function to handle orientation + // Calculate which pages to show based on viewType + const leftPage = viewType === 'dual' + ? (currDocPage % 2 === 0 ? currDocPage - 1 : currDocPage) + : currDocPage; + const rightPage = viewType === 'dual' + ? (currDocPage % 2 === 0 ? currDocPage : currDocPage + 1) + : null; + + // Modify scale calculation to account for view type const calculateScale = useCallback((width = pageWidth, height = pageHeight): number => { - const margin = 24; // 24px padding on each side - const containerHeight = window.innerHeight - 100; // approximate visible height - const targetWidth = containerWidth - margin; + const margin = viewType === 'dual' ? 48 : 24; // adjust margin based on view type + const containerHeight = window.innerHeight - 100; + const targetWidth = viewType === 'dual' + ? (containerWidth - margin) / 2 // divide by 2 for dual pages + : containerWidth - margin; const targetHeight = containerHeight - margin; - // Calculate scales based on both dimensions + if (viewType === 'scroll') { + // For scroll mode, use a more comfortable width-based scale + // Use 75% of the width-based scale to make it less zoomed in + const scaleByWidth = (targetWidth / width) * 0.75; + return scaleByWidth * (zoomLevel / 100); + } + const scaleByWidth = targetWidth / width; const scaleByHeight = targetHeight / height; - // Use the smaller scale to ensure the page fits both dimensions const baseScale = Math.min(scaleByWidth, scaleByHeight); return baseScale * (zoomLevel / 100); - }, [containerWidth, zoomLevel, pageWidth, pageHeight]); + }, [containerWidth, zoomLevel, pageWidth, pageHeight, viewType]); // Add resize observer effect useEffect(() => { @@ -151,24 +170,61 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) { file={currDocURL} onLoadSuccess={(pdf) => { onDocumentLoadSuccess(pdf); - //handlePageChange(1); // Load first page text }} className="flex flex-col items-center m-0" >
-
- { - setPageWidth(page.originalWidth); - setPageHeight(page.originalHeight); - }} - /> -
+ {viewType === 'scroll' ? ( + // Scroll mode: render all pages +
+ {currDocPages && [...Array(currDocPages)].map((_, i) => ( + { + setPageWidth(page.originalWidth); + setPageHeight(page.originalHeight); + }} + /> + ))} +
+ ) : ( + // Single/Dual page mode +
+ {currDocPages && leftPage > 0 && ( + { + setPageWidth(page.originalWidth); + setPageHeight(page.originalHeight); + }} + /> + )} + {currDocPages && rightPage && rightPage <= currDocPages && viewType === 'dual' && ( + { + setPageWidth(page.originalWidth); + setPageHeight(page.originalHeight); + }} + /> + )} +
+ )}
{selectedTheme.name} - - - + {selected ? ( - - - + ) : null} diff --git a/src/components/icons/Icons.tsx b/src/components/icons/Icons.tsx index 784906f..f7ae714 100644 --- a/src/components/icons/Icons.tsx +++ b/src/components/icons/Icons.tsx @@ -128,3 +128,48 @@ export function ChevronUpDownIcon(props: React.SVGProps) { ); } + +export function SettingsIcon(props: React.SVGProps) { + return ( + + + + + ); +} + +export function CheckIcon(props: React.SVGProps) { + return ( + + + + ); +} diff --git a/src/contexts/ConfigContext.tsx b/src/contexts/ConfigContext.tsx index 697958f..66041c2 100644 --- a/src/contexts/ConfigContext.tsx +++ b/src/contexts/ConfigContext.tsx @@ -3,19 +3,39 @@ import { createContext, useContext, useEffect, useState } from 'react'; import { getItem, indexedDBService, setItem } from '@/services/indexedDB'; +export type ViewType = 'single' | 'dual' | 'scroll'; interface ConfigContextType { apiKey: string; baseUrl: string; - updateConfig: (newConfig: Partial<{ apiKey: string; baseUrl: string }>) => Promise; + viewType: ViewType; + voiceSpeed: number; + voice: string; + updateConfig: (newConfig: Partial<{ apiKey: string; baseUrl: string; viewType: ViewType }>) => Promise; + updateConfigKey: (key: K, value: ConfigValues[K]) => Promise; isLoading: boolean; isDBReady: boolean; } +// Add this type to help with type safety +type ConfigValues = { + apiKey: string; + baseUrl: string; + viewType: ViewType; + voiceSpeed: number; + voice: string; +}; + const ConfigContext = createContext(undefined); export function ConfigProvider({ children }: { children: React.ReactNode }) { + // Config state const [apiKey, setApiKey] = useState(''); const [baseUrl, setBaseUrl] = useState(''); + const [viewType, setViewType] = useState('single'); + const [voiceSpeed, setVoiceSpeed] = useState(1); + const [voice, setVoice] = useState('af_sarah'); + + const [isLoading, setIsLoading] = useState(true); const [isDBReady, setIsDBReady] = useState(false); @@ -29,13 +49,15 @@ export function ConfigProvider({ children }: { children: React.ReactNode }) { // Now load config const cachedApiKey = await getItem('apiKey'); const cachedBaseUrl = await getItem('baseUrl'); + const cachedViewType = await getItem('viewType'); + const cachedVoiceSpeed = await getItem('voiceSpeed'); + const cachedVoice = await getItem('voice'); - if (cachedApiKey) { - console.log('Cached API key found:', cachedApiKey); - } - if (cachedBaseUrl) { - console.log('Cached base URL found:', cachedBaseUrl); - } + if (cachedApiKey) console.log('Cached API key found:', cachedApiKey); + if (cachedBaseUrl) console.log('Cached base URL found:', cachedBaseUrl); + if (cachedViewType) console.log('Cached view type found:', cachedViewType); + if (cachedVoiceSpeed) console.log('Cached voice speed found:', cachedVoiceSpeed); + if (cachedVoice) console.log('Cached voice found:', cachedVoice); // If not in cache, use env variables const defaultApiKey = process.env.NEXT_PUBLIC_OPENAI_API_KEY || ''; @@ -44,6 +66,9 @@ export function ConfigProvider({ children }: { children: React.ReactNode }) { // Set the values setApiKey(cachedApiKey || defaultApiKey); setBaseUrl(cachedBaseUrl || defaultBaseUrl); + setViewType((cachedViewType || 'single') as ViewType); + setVoiceSpeed(parseFloat(cachedVoiceSpeed || '1')); + setVoice(cachedVoice || 'af_sarah'); // If not in cache, save to cache if (!cachedApiKey) { @@ -52,6 +77,9 @@ export function ConfigProvider({ children }: { children: React.ReactNode }) { if (!cachedBaseUrl) { await setItem('baseUrl', defaultBaseUrl); } + if (!cachedViewType) { + await setItem('viewType', 'single'); + } } catch (error) { console.error('Error initializing:', error); @@ -79,8 +107,44 @@ export function ConfigProvider({ children }: { children: React.ReactNode }) { } }; + const updateConfigKey = async (key: K, value: ConfigValues[K]) => { + try { + await setItem(key, value.toString()); + switch (key) { + case 'apiKey': + setApiKey(value as string); + break; + case 'baseUrl': + setBaseUrl(value as string); + break; + case 'viewType': + setViewType(value as ViewType); + break; + case 'voiceSpeed': + setVoiceSpeed(value as number); + break; + case 'voice': + setVoice(value as string); + break; + } + } catch (error) { + console.error(`Error updating config key ${key}:`, error); + throw error; + } + }; + return ( - + {children} );