Better mobile styling
This commit is contained in:
parent
610167f058
commit
ca1e3a83a9
10 changed files with 98 additions and 52 deletions
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue