Better mobile styling

This commit is contained in:
Richard Roberson 2025-01-28 18:14:33 -07:00
parent 610167f058
commit ca1e3a83a9
10 changed files with 98 additions and 52 deletions

View file

@ -11,17 +11,17 @@ export default function Home() {
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
return (
<div className='p-3 md:p-5'>
<div className='p-3.5 sm:p-5'>
<Button
onClick={() => setIsSettingsOpen(true)}
className="rounded-full p-2 text-foreground hover:bg-offbase transform transition-transform duration-200 ease-in-out hover:scale-[1.1] hover:text-accent absolute top-3 right-3"
className="rounded-full p-2 text-foreground hover:bg-offbase transform transition-transform duration-200 ease-in-out hover:scale-[1.1] hover:text-accent absolute top-1 right-1 sm:top-3 sm:right-3"
aria-label="Settings"
tabIndex={0}
>
<SettingsIcon className="w-6 h-6 hover:animate-spin-slow" />
<SettingsIcon className="w-4 h-4 sm:w-5 sm:h-5 hover:animate-spin-slow" />
</Button>
<h1 className="text-xl font-bold text-center flex-grow">OpenReader WebUI</h1>
<p className="text-sm text-center text-muted mb-5">{'A "bring your own text-to-speech api" web interface for reading documents with high quality voices.'}</p>
<p className="text-sm mt-1 text-center text-muted mb-5">A bring your own text-to-speech api web interface for reading documents with high quality voices.</p>
<div className="flex flex-col items-center gap-5">
<PDFUploader className='max-w-xl' />
<DocumentList />

View file

@ -111,7 +111,7 @@ export default function PDFViewerPage() {
<SettingsIcon className="w-5 h-5 hover:animate-spin-slow" />
</Button>
</div>
<h1 className="mr-2 text-md font-semibold text-foreground truncate">
<h1 className="ml-2 mr-2 text-md font-semibold text-foreground truncate">
{isLoading ? 'Loading...' : currDocName}
</h1>
</div>

View file

@ -53,20 +53,20 @@ export function DocumentList() {
leaveTo="transform scale-95 opacity-0"
>
<div
className="flex items-center justify-between hover:bg-base p-2 rounded-lg transition-colors"
className="flex items-center justify-between hover:bg-base p-1 rounded-lg transition-colors"
>
<Link
href={`/pdf/${encodeURIComponent(doc.id)}`}
className="flex items-center space-x-4 flex-1 min-w-0"
>
<div className="flex-shrink-0">
<svg className="w-8 h-8 text-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg className="w-6 h-6 sm:w-8 sm:h-8 text-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
</div>
<div className="flex-1 min-w-0 transform transition-transform duration-200 ease-in-out hover:scale-[1.02]">
<p className="text-foreground font-medium truncate">{doc.name}</p>
<p className="text-sm text-muted truncate">
<p className="text-sm sm:text-md text-foreground font-medium truncate">{doc.name}</p>
<p className="text-xs sm:text-sm text-muted truncate">
{(doc.size / 1024 / 1024).toFixed(2)} MB
</p>
</div>

View file

@ -4,7 +4,7 @@ export function Footer() {
return (
<footer className="m-8 text-sm text-muted">
<div className="flex flex-col items-center space-y-2">
<div className="flex flex-wrap md:flex-nowrap items-center justify-center text-center md:space-x-3">
<div className="flex flex-wrap sm:flex-nowrap items-center justify-center text-center sm:space-x-3">
<Popover className="flex relative">
<PopoverButton className="hover:text-foreground transition-colors">
Privacy info
@ -14,7 +14,7 @@ export function Footer() {
<p className='mt-3'>Each sentence of the document you are viewing is sent to my FastAPI server for audio generation, no requests or data is collected.</p>
</PopoverPanel>
</Popover>
<span className='w-full md:w-fit'></span>
<span className='w-full sm:w-fit'></span>
<span>
Powered by{' '}
<a

View file

@ -12,25 +12,14 @@ export function PDFSkeleton() {
}, []);
return (
<div className="flex flex-col items-center w-full">
<div className="flex flex-col items-center justify-center min-h-[50vh] w-full">
{showNotification && (
<div className="fixed top-4 left-1/2 transform -translate-x-1/2 bg-yellow-100 border border-yellow-400 text-yellow-700 px-4 py-2 rounded shadow-lg z-50">
There might be an issue with the file import. Please try again.
</div>
)}
<div className="flex flex-col items-center w-full animate-pulse">
{/* Show 3 skeleton pages by default */}
{[1, 2, 3].map((index) => (
<div key={`skeleton_${index}`}>
{/* Page content skeleton */}
<div className="flex flex-col justify-center my-4">
<div className="bg-gray-200 shadow-lg">
{/* Approximate dimensions of a PDF page */}
<div className="w-[595px] h-[842px]"></div>
</div>
</div>
</div>
))}
<div className="flex flex-col items-center gap-4">
<div className="w-12 h-12 border-4 border-accent border-t-transparent rounded-full animate-spin"></div>
</div>
</div>
);

View file

@ -52,18 +52,18 @@ export function PDFUploader({ className = '' }: PDFUploaderProps) {
>
<input {...getInputProps()} />
<div className="flex flex-col items-center justify-center text-center">
<UploadIcon className="w-10 h-10 mb-2 text-muted" />
<UploadIcon className="w-7 h-7 sm:w-10 sm:h-10 mb-2 text-muted" />
{isUploading ? (
<p className="text-lg font-semibold text-foreground">
<p className="text-sm sm:text-lg font-semibold text-foreground">
Uploading PDF...
</p>
) : (
<>
<p className="mb-2 text-lg font-semibold text-foreground">
<p className="mb-2 text-sm sm:text-lg font-semibold text-foreground">
{isDragActive ? 'Drop your PDF here' : 'Drop your PDF here, or click to select'}
</p>
<p className="text-sm text-muted">
<p className="text-xs sm:text-sm text-muted">
Only PDF files are currently accepted
</p>
{error && <p className="mt-2 text-sm text-red-500">{error}</p>}

View file

@ -30,21 +30,20 @@ export const SpeedControl = ({ setSpeedAndRestart }: {
return (
<div className="relative">
<Listbox value={currentSpeed} onChange={setSpeedAndRestart}>
<ListboxButton className="flex items-center space-x-1 bg-transparent text-foreground text-sm focus:outline-none cursor-pointer hover:bg-offbase rounded pl-2 pr-1 py-1">
<ListboxButton className="flex items-center space-x-0.5 sm:space-x-1 bg-transparent text-foreground text-xs sm:text-sm focus:outline-none cursor-pointer hover:bg-offbase rounded pl-1.5 sm:pl-2 pr-0.5 sm:pr-1 py-0.5 sm:py-1">
<span>{currentSpeed}x</span>
<ChevronUpDownIcon className="h-3 w-3" />
<ChevronUpDownIcon className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
</ListboxButton>
<ListboxOptions className="absolute bottom-full mb-1 w-24 overflow-auto rounded-lg bg-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
<ListboxOptions anchor='top start' className="absolute z-50 w-20 sm:w-24 overflow-auto rounded-lg bg-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
{speedOptions.map((option) => (
<ListboxOption
key={option.value}
value={option.value}
className={({ active, selected }) =>
`relative cursor-pointer select-none py-2 px-3 ${active ? 'bg-offbase' : ''
} ${selected ? 'font-medium' : ''}`
`relative cursor-pointer select-none py-0.5 px-1.5 sm:py-2 sm:px-3 ${active ? 'bg-offbase' : ''} ${selected ? 'font-medium' : ''}`
}
>
{option.label}
<span className='text-xs sm:text-sm'>{option.label}</span>
</ListboxOption>
))}
</ListboxOptions>

View file

@ -30,8 +30,8 @@ export default function TTSPlayer({ currentPage, numPages }: {
} = useTTS();
return (
<div className={`fixed bottom-4 left-1/2 transform -translate-x-1/2 z-50 transition-opacity duration-300`}>
<div className="bg-base dark:bg-base rounded-full shadow-lg px-4 py-1 flex items-center space-x-1 relative">
<div className={`fixed bottom-4 left-1/2 transform -translate-x-1/2 z-49 transition-opacity duration-300`}>
<div className="bg-base dark:bg-base rounded-full shadow-lg px-3 sm:px-4 py-0.5 sm:py-1 flex items-center space-x-0.5 sm:space-x-1 relative scale-90 sm:scale-100">
{/* Speed control */}
<SpeedControl setSpeedAndRestart={setSpeedAndRestart} />

View file

@ -19,20 +19,20 @@ export const VoicesControl = ({ availableVoices, setVoiceAndRestart }: {
return (
<div className="relative">
<Listbox value={currentVoice} onChange={setVoiceAndRestart}>
<ListboxButton className="flex items-center space-x-1 bg-transparent text-foreground text-sm focus:outline-none cursor-pointer hover:bg-offbase rounded pl-2 pr-1 py-1">
<ListboxButton className="flex items-center space-x-0.5 sm:space-x-1 bg-transparent text-foreground text-xs sm:text-sm focus:outline-none cursor-pointer hover:bg-offbase rounded pl-1.5 sm:pl-2 pr-0.5 sm:pr-1 py-0.5 sm:py-1">
<span>{currentVoice}</span>
<ChevronUpDownIcon className="h-3 w-3" />
<ChevronUpDownIcon className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
</ListboxButton>
<ListboxOptions className="absolute bottom-full mb-1 w-32 overflow-auto rounded-lg bg-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
<ListboxOptions anchor='top end' className="absolute z-50 w-28 sm:w-32 overflow-auto rounded-lg bg-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
{availableVoices.map((voiceId) => (
<ListboxOption
key={voiceId}
value={voiceId}
className={({ active, selected }) =>
`relative cursor-pointer select-none py-2 px-3 ${active ? 'bg-offbase' : ''} ${selected ? 'font-medium' : ''}`
`relative cursor-pointer select-none py-0.5 px-1.5 sm:py-2 sm:px-3 ${active ? 'bg-offbase' : ''} ${selected ? 'font-medium' : ''}`
}
>
<span>{voiceId}</span>
<span className='text-xs sm:text-sm'>{voiceId}</span>
</ListboxOption>
))}
</ListboxOptions>

View file

@ -1,3 +1,17 @@
/**
* Text-to-Speech (TTS) Context Provider
*
* This module provides a React context for managing text-to-speech functionality.
* It handles audio playback, sentence processing, and integration with OpenAI's TTS API.
*
* Key features:
* - Audio playback control (play/pause/skip)
* - Sentence-by-sentence processing
* - Audio caching for better performance
* - Voice and speed control
* - Document navigation
*/
'use client';
import React, {
@ -23,10 +37,16 @@ declare global {
}
}
/**
* Type definition for AudioContext to handle browser compatibility
*/
type AudioContextType = typeof window extends undefined
? never
: (AudioContext);
/**
* Interface defining all available methods and properties in the TTS context
*/
interface TTSContextType {
isPlaying: boolean;
currentText: string;
@ -58,9 +78,17 @@ interface TTSContextType {
skipToPage: (page: number) => void;
}
// Create the context
const TTSContext = createContext<TTSContextType | undefined>(undefined);
/**
* TTSProvider Component
*
* Main provider component that manages the TTS state and functionality.
* Handles initialization of OpenAI client, audio context, and media session.
*/
export function TTSProvider({ children }: { children: React.ReactNode }) {
// Configuration context consumption
const {
apiKey: openApiKey,
baseUrl: openApiBaseUrl,
@ -70,9 +98,17 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
updateConfigKey
} = useConfig();
// Move openai initialization to a ref to avoid breaking hooks rules
// OpenAI client reference
const openaiRef = useRef<OpenAI | null>(null);
/**
* State Management
* - Playback control
* - Text and sentence management
* - Audio processing
* - Voice and speed settings
* - Document navigation
*/
// All existing state declarations and refs stay at the top
const [isPlaying, setIsPlaying] = useState(false);
const [currentText, setCurrentText] = useState('');
@ -90,15 +126,16 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
const [currDocPages, setCurrDocPages] = useState<number>();
const [nextPageLoading, setNextPageLoading] = useState(false);
// Audio cache using LRUCache with a maximum size of 50 entries
/**
* Audio Cache
* LRU cache to store processed audio buffers and improve performance
*/
const audioCacheRef = useRef(new LRUCache<string, AudioBuffer>({ max: 50 }));
// Update local state when config changes
useEffect(() => {
setSpeed(voiceSpeed);
setVoice(configVoice);
}, [voiceSpeed, configVoice]);
/**
* Text Processing Functions
* Handle text input and sentence splitting
*/
const setText = useCallback((text: string) => {
setCurrentText(text);
console.log('Setting page text:', text);
@ -118,6 +155,10 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
}
}, [activeHowl]);
/**
* Playback Control Functions
* Manage audio playback, navigation, and state
*/
const togglePlay = useCallback(() => {
setIsPlaying((prev) => {
if (!prev) {
@ -190,6 +231,10 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
setIsProcessing(false);
}, [abortAudio, advance]);
/**
* Audio Processing Functions
* Handle audio generation, caching, and playback
*/
// Initialize OpenAI instance when config loads
useEffect(() => {
const fetchVoices = async () => {
@ -222,6 +267,7 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
}
}, [configIsLoading, openApiKey, openApiBaseUrl]);
// Initialize AudioContext
useEffect(() => {
/*
* Initializes the AudioContext for text-to-speech playback.
@ -251,7 +297,7 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
}
}, [audioContext]);
// Now the MediaSession effect can use these functions
// Set up MediaSession API
useEffect(() => {
if ('mediaSession' in navigator) {
navigator.mediaSession.metadata = new MediaMetadata({
@ -376,7 +422,10 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
await playSentenceWithHowl(sentences[currentIndex]);
}, [sentences, currentIndex, playSentenceWithHowl]);
// main driver useEffect
/**
* Main Playback Driver
* Controls the flow of audio playback and sentence processing
*/
useEffect(() => {
if (!isPlaying) return; // Don't proceed if stopped
if (isProcessing) return; // Don't proceed if processing audio
@ -454,6 +503,10 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
}
}, [isPlaying, abortAudio, updateConfigKey]);
/**
* Context Value
* Aggregate all functions and state to be provided to consumers
*/
const value = {
isPlaying,
currentText,
@ -485,6 +538,7 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
skipToPage,
};
// Render provider with value
if (configIsLoading) {
return null;
}
@ -496,6 +550,10 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
);
}
/**
* Custom hook to consume the TTS context
* Ensures the context is used within a provider
*/
export function useTTS() {
const context = useContext(TTSContext);
if (context === undefined) {