Add different view modes

This commit is contained in:
Richard Roberson 2025-01-26 18:34:31 -07:00
parent 92ee491363
commit 55919310a5
7 changed files with 346 additions and 73 deletions

View file

@ -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"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-6 h-6 hover:animate-spin-slow"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
<SettingsIcon className="w-6 h-6 hover:animate-spin-slow" />
</button>
</div>
<div className="flex flex-col items-center gap-6">

View file

@ -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<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [zoomLevel, setZoomLevel] = useState<number>(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() {
<>
<div className="p-2 pb-2 border-b border-offbase">
<div className="flex flex-wrap items-center justify-between">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Link
href="/"
onClick={() => {clearCurrDoc(); stop();}}
@ -100,6 +103,14 @@ export default function PDFViewerPage() {
</Button>
</div>
<Button
onClick={() => setIsSettingsOpen(true)}
className="rounded-full p-2 text-foreground hover:bg-offbase focus:bg-offbase
focus:outline-none focus:ring-2 focus:ring-accent transition-colors"
aria-label="View Settings"
>
<SettingsIcon className="w-5 h-5 hover:animate-spin-slow" />
</Button>
</div>
<h1 className="mr-2 text-md font-semibold text-foreground truncate">
{isLoading ? 'Loading...' : currDocName}
@ -113,6 +124,7 @@ export default function PDFViewerPage() {
) : (
<PDFViewer zoomLevel={zoomLevel} />
)}
<PDFViewSettings isOpen={isSettingsOpen} setIsOpen={setIsSettingsOpen} />
</>
);
}

View file

@ -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 (
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={() => setIsOpen(false)}>
<TransitionChild
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-black/25 backdrop-blur-sm" />
</TransitionChild>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center">
<TransitionChild
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<DialogPanel className="w-full max-w-md transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all">
<DialogTitle
as="h3"
className="text-lg font-semibold leading-6 text-foreground"
>
View Settings
</DialogTitle>
<div className="mt-4">
<div className="space-y-4">
<div className="space-y-2">
<label className="block text-sm font-medium text-foreground">Mode</label>
<Listbox
value={selectedView}
onChange={(newView) => updateConfigKey('viewType', newView.id as ViewType)}
>
<div className="relative">
<ListboxButton className="relative w-full cursor-pointer rounded-lg bg-background py-2 pl-3 pr-10 text-left text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent">
<span className="block truncate">{selectedView.name}</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-5 w-5 text-muted" />
</span>
</ListboxButton>
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<ListboxOptions className="absolute mt-1 max-h-60 w-full overflow-auto rounded-md bg-background py-1 shadow-lg ring-1 ring-black/5 focus:outline-none">
{viewTypes.map((view) => (
<ListboxOption
key={view.id}
className={({ active }) =>
`relative cursor-pointer select-none py-2 pl-10 pr-4 ${
active ? 'bg-accent/10 text-accent' : 'text-foreground'
}`
}
value={view}
>
{({ selected }) => (
<>
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
{view.name}
</span>
{selected ? (
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-accent">
<CheckIcon className="h-5 w-5" />
</span>
) : null}
</>
)}
</ListboxOption>
))}
</ListboxOptions>
</Transition>
</div>
</Listbox>
{selectedView.id === 'scroll' && (
<p className="text-sm text-warning pt-2">
Note: Continuous scroll may perform poorly for larger documents.
</p>
)}
</div>
</div>
</div>
<div className="mt-3 flex justify-end">
<button
type="button"
className="inline-flex justify-center rounded-lg bg-background px-4 py-2 text-sm
font-medium text-foreground hover:bg-background/90 focus:outline-none
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
transition-colors"
onClick={() => setIsOpen(false)}
>
Close
</button>
</div>
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</Transition>
);
}

View file

@ -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<number>(0);
const containerRef = useRef<HTMLDivElement>(null);
// Config context
const { viewType } = useConfig();
// TTS context
const {
currentSentence,
@ -112,21 +116,36 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
const [pageWidth, setPageWidth] = useState<number>(595); // default A4 width
const [pageHeight, setPageHeight] = useState<number>(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"
>
<div>
<div className="flex justify-center">
<Page
pageNumber={currDocPage}
renderAnnotationLayer={true}
renderTextLayer={true}
className="shadow-lg"
scale={calculateScale()}
onLoadSuccess={(page) => {
setPageWidth(page.originalWidth);
setPageHeight(page.originalHeight);
}}
/>
</div>
{viewType === 'scroll' ? (
// Scroll mode: render all pages
<div className="flex flex-col gap-4">
{currDocPages && [...Array(currDocPages)].map((_, i) => (
<Page
key={`page_${i + 1}`}
pageNumber={i + 1}
renderAnnotationLayer={true}
renderTextLayer={i + 1 === currDocPage}
className="shadow-lg"
scale={calculateScale()}
onLoadSuccess={(page) => {
setPageWidth(page.originalWidth);
setPageHeight(page.originalHeight);
}}
/>
))}
</div>
) : (
// Single/Dual page mode
<div className="flex justify-center gap-4">
{currDocPages && leftPage > 0 && (
<Page
key={`page_${leftPage}`}
pageNumber={leftPage}
renderAnnotationLayer={true}
renderTextLayer={leftPage === currDocPage}
className="shadow-lg"
scale={calculateScale()}
onLoadSuccess={(page) => {
setPageWidth(page.originalWidth);
setPageHeight(page.originalHeight);
}}
/>
)}
{currDocPages && rightPage && rightPage <= currDocPages && viewType === 'dual' && (
<Page
key={`page_${rightPage}`}
pageNumber={rightPage}
renderAnnotationLayer={true}
renderTextLayer={rightPage === currDocPage}
className="shadow-lg"
scale={calculateScale()}
onLoadSuccess={(page) => {
setPageWidth(page.originalWidth);
setPageHeight(page.originalHeight);
}}
/>
)}
</div>
)}
</div>
</Document>
<TTSPlayer

View file

@ -4,6 +4,7 @@ import { Fragment, useState, useEffect } from 'react';
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild, Listbox, ListboxButton, ListboxOptions, ListboxOption } from '@headlessui/react';
import { useTheme } from '@/contexts/ThemeContext';
import { useConfig } from '@/contexts/ConfigContext';
import { ChevronUpDownIcon, CheckIcon } from './icons/Icons';
interface SettingsModalProps {
isOpen: boolean;
@ -70,18 +71,7 @@ export function SettingsModal({ isOpen, setIsOpen }: SettingsModalProps) {
<ListboxButton className="relative w-full cursor-pointer rounded-lg bg-background py-2 pl-3 pr-10 text-left text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent">
<span className="block truncate">{selectedTheme.name}</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5 text-muted"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
<ChevronUpDownIcon className="h-5 w-5 text-muted" />
</span>
</ListboxButton>
<Transition
@ -108,18 +98,7 @@ export function SettingsModal({ isOpen, setIsOpen }: SettingsModalProps) {
</span>
{selected ? (
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-accent">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
<CheckIcon className="h-5 w-5" />
</span>
) : null}
</>

View file

@ -128,3 +128,48 @@ export function ChevronUpDownIcon(props: React.SVGProps<SVGSVGElement>) {
</svg>
);
}
export function SettingsIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className={props.className || "w-6 h-6"}
{...props}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
);
}
export function CheckIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className={props.className}
width={props.width || "1.5em"}
height={props.height || "1.5em"}
{...props}
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
);
}

View file

@ -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<void>;
viewType: ViewType;
voiceSpeed: number;
voice: string;
updateConfig: (newConfig: Partial<{ apiKey: string; baseUrl: string; viewType: ViewType }>) => Promise<void>;
updateConfigKey: <K extends keyof ConfigValues>(key: K, value: ConfigValues[K]) => Promise<void>;
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<ConfigContextType | undefined>(undefined);
export function ConfigProvider({ children }: { children: React.ReactNode }) {
// Config state
const [apiKey, setApiKey] = useState<string>('');
const [baseUrl, setBaseUrl] = useState<string>('');
const [viewType, setViewType] = useState<ViewType>('single');
const [voiceSpeed, setVoiceSpeed] = useState<number>(1);
const [voice, setVoice] = useState<string>('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 <K extends keyof ConfigValues>(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 (
<ConfigContext.Provider value={{ apiKey, baseUrl, updateConfig, isLoading, isDBReady }}>
<ConfigContext.Provider value={{
apiKey,
baseUrl,
viewType,
voiceSpeed,
voice,
updateConfig,
updateConfigKey,
isLoading,
isDBReady
}}>
{children}
</ConfigContext.Provider>
);