diff --git a/public/theme.js b/public/theme.js
new file mode 100644
index 0000000..9d1c19e
--- /dev/null
+++ b/public/theme.js
@@ -0,0 +1,30 @@
+// Immediately apply the theme before any content loads
+(function() {
+ try {
+ let theme = localStorage.getItem('theme') || 'system';
+ const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches;
+
+ // If theme is system, use the system preference
+ if (theme === 'system') {
+ theme = systemTheme ? 'dark' : 'light';
+ }
+
+ // Remove both classes and add the current one
+ document.documentElement.classList.remove('light', 'dark');
+ document.documentElement.classList.add(theme);
+ document.documentElement.style.colorScheme = theme;
+
+ // Watch for system theme changes
+ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
+ if (localStorage.getItem('theme') === 'system') {
+ document.documentElement.classList.remove('light', 'dark');
+ document.documentElement.classList.add(e.matches ? 'dark' : 'light');
+ document.documentElement.style.colorScheme = e.matches ? 'dark' : 'light';
+ }
+ });
+ } catch (e) {
+ // Fallback to light theme if localStorage is not available
+ document.documentElement.classList.add('light');
+ document.documentElement.style.colorScheme = 'light';
+ }
+})();
diff --git a/src/app/globals.css b/src/app/globals.css
index 697735e..08d34f2 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -2,7 +2,8 @@
@tailwind components;
@tailwind utilities;
-:root {
+:root,
+html.light {
--background: #ffffff;
--foreground: #171717;
--base: #f5f5f5;
@@ -11,19 +12,26 @@
--muted: #737373;
}
-@media (prefers-color-scheme: dark) {
- :root {
- --background: #0a0a0a;
- --foreground: #ededed;
- --base: #333333;
- --offbase: #4a4a4a;
- --accent: #f87171;
- --muted: #a3a3a3;
- }
+html.dark {
+ --background: #111111;
+ --foreground: #ededed;
+ --base: #1f1f1f;
+ --offbase: #4a4a4a;
+ --accent: #f87171;
+ --muted: #a3a3a3;
+}
+
+/* Ensure background color is set before content loads */
+html {
+ background-color: var(--background);
}
body {
color: var(--foreground);
background: var(--background);
- font-family: Arial, Helvetica, sans-serif;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
+ "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
+ sans-serif;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index d200d6c..1d65e58 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,22 +1,11 @@
-import type { Metadata } from "next";
-import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { Providers } from "./providers";
import TTSPlayer from "@/components/TTSPlayer";
-
-const geistSans = Geist({
- variable: "--font-geist-sans",
- subsets: ["latin"],
-});
-
-const geistMono = Geist_Mono({
- variable: "--font-geist-mono",
- subsets: ["latin"],
-});
+import { Metadata } from "next";
export const metadata: Metadata = {
- title: "Create Next App",
- description: "Generated by create next app",
+ title: "OpenReader WebUI",
+ description: "A modern web interface for reading and managing documents",
};
export default function RootLayout({
@@ -25,10 +14,18 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
-
-
+
+
+
+
+
+
+
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 6c78312..7f2e3f5 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,16 +1,49 @@
'use client';
+import { useState } from 'react';
import { PDFUploader } from '@/components/PDFUploader';
import { DocumentList } from '@/components/DocumentList';
+import { SettingsModal } from '@/components/SettingsModal';
export default function Home() {
+ const [isSettingsOpen, setIsSettingsOpen] = useState(false);
+
return (
-
OpenReader WebUI
+
+
OpenReader WebUI
+
+
+
);
}
diff --git a/src/app/providers.tsx b/src/app/providers.tsx
index 91b3742..f80f018 100644
--- a/src/app/providers.tsx
+++ b/src/app/providers.tsx
@@ -2,14 +2,17 @@
import { PDFProvider } from '@/context/PDFContext';
import { TTSProvider } from '@/context/TTSContext';
+import { ThemeProvider } from '@/context/ThemeContext';
import { ReactNode } from 'react';
export function Providers({ children }: { children: ReactNode }) {
return (
-
-
- {children}
-
-
+
+
+
+ {children}
+
+
+
);
}
diff --git a/src/components/DocumentList.tsx b/src/components/DocumentList.tsx
index 564b15a..1b309a1 100644
--- a/src/components/DocumentList.tsx
+++ b/src/components/DocumentList.tsx
@@ -1,8 +1,25 @@
import { usePDF } from '@/context/PDFContext';
import Link from 'next/link';
+import { Dialog } from '@headlessui/react';
+import { Transition } from '@headlessui/react';
+import { Fragment, useState } from 'react';
export function DocumentList() {
const { documents, removeDocument, isLoading, error } = usePDF();
+ const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
+ const [documentToDelete, setDocumentToDelete] = useState<{ id: string; name: string } | null>(null);
+
+ const handleDelete = async () => {
+ if (documentToDelete) {
+ try {
+ await removeDocument(documentToDelete.id);
+ setIsDeleteDialogOpen(false);
+ setDocumentToDelete(null);
+ } catch (err) {
+ console.error('Failed to remove document:', err);
+ }
+ }
+ };
if (isLoading) {
return (
@@ -33,44 +50,116 @@ export function DocumentList() {
Your Documents
{documents.map((doc) => (
-
-
-
-
-
-
{doc.name}
-
- {(doc.size / 1024 / 1024).toFixed(2)} MB
-
-
-
-
-
+
+
+
))}
+
+
+
+
);
}
diff --git a/src/components/PDFViewer.tsx b/src/components/PDFViewer.tsx
index 223afe8..f070aa0 100644
--- a/src/components/PDFViewer.tsx
+++ b/src/components/PDFViewer.tsx
@@ -244,7 +244,7 @@ export function PDFViewer({ pdfData }: PDFViewerProps) {
});
bestMatch.elements.forEach(element => {
- element.style.backgroundColor = 'yellow';
+ element.style.backgroundColor = 'grey';
element.style.opacity = '0.4';
});
diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx
new file mode 100644
index 0000000..17fa84b
--- /dev/null
+++ b/src/components/SettingsModal.tsx
@@ -0,0 +1,147 @@
+'use client';
+
+import { Fragment } from 'react';
+import { Dialog, Transition, Listbox } from '@headlessui/react';
+import { useTheme } from '@/context/ThemeContext';
+
+interface SettingsModalProps {
+ isOpen: boolean;
+ setIsOpen: (isOpen: boolean) => void;
+}
+
+const themes = [
+ { id: 'light' as const, name: 'Light' },
+ { id: 'dark' as const, name: 'Dark' },
+ { id: 'system' as const, name: 'System' },
+];
+
+export function SettingsModal({ isOpen, setIsOpen }: SettingsModalProps) {
+ const { theme, setTheme } = useTheme();
+ const selectedTheme = themes.find(t => t.id === theme) || themes[0];
+
+ return (
+
+
+
+ );
+}
diff --git a/src/components/TTSPlayer.tsx b/src/components/TTSPlayer.tsx
index b06ece7..61c41ad 100644
--- a/src/components/TTSPlayer.tsx
+++ b/src/components/TTSPlayer.tsx
@@ -10,9 +10,18 @@ import {
SkipBackwardIcon,
} from './icons/Icons';
+// Loading spinner component
+function LoadingSpinner() {
+ return (
+
+ );
+}
+
export default function TTSPlayer() {
const [isVisible, setIsVisible] = useState(true);
- const { isPlaying, togglePlay, skipForward, skipBackward } = useTTS();
+ const { isPlaying, togglePlay, skipForward, skipBackward, isProcessing } = useTTS();
return (
diff --git a/src/context/TTSContext.tsx b/src/context/TTSContext.tsx
index 331489a..f9766a1 100644
--- a/src/context/TTSContext.tsx
+++ b/src/context/TTSContext.tsx
@@ -37,6 +37,7 @@ interface TTSContextType {
setCurrentIndex: (index: number) => void;
stopAndPlayFromIndex: (index: number) => void;
sentences: string[];
+ isProcessing: boolean;
}
const TTSContext = createContext(undefined);
@@ -123,9 +124,14 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
const processAndPlaySentence = async (sentence: string) => {
if (!audioContext || isProcessing) return;
- setIsProcessing(true);
try {
+ // Only set processing if we need to fetch from API
+ const cleanedSentence = preprocessText(sentence);
+ if (!audioCacheRef.current.has(cleanedSentence)) {
+ setIsProcessing(true);
+ }
+
// Cancel any existing request
if (currentRequestRef.current) {
currentRequestRef.current.abort();
@@ -140,8 +146,6 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
setActiveSource(null);
}
- // Clean the sentence before processing
- const cleanedSentence = preprocessText(sentence);
let audioBuffer = audioCacheRef.current.get(cleanedSentence);
if (!audioBuffer) {
@@ -161,9 +165,10 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
// Store in cache
audioCacheRef.current.set(cleanedSentence, audioBuffer);
+ setIsProcessing(false);
}
- // If the request was aborted, do not proceed
+ // If the request was aborted or component unmounted, do not proceed
if (!currentRequestRef.current) return;
const source = audioContext.createBufferSource();
@@ -174,7 +179,6 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
// Set up onended handler before starting
source.onended = () => {
setActiveSource(null);
- setIsProcessing(false);
// Only advance if we're playing and not pausing or skipping
if (isPlaying && !skipTriggeredRef.current && !isPausingRef.current) {
processNextSentence();
@@ -218,6 +222,7 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
}
skipTriggeredRef.current = true;
+ setIsProcessing(true);
// Cancel any ongoing request
if (currentRequestRef.current) {
currentRequestRef.current.abort();
@@ -239,6 +244,7 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
// Reset skip flag after a short delay
skipTimeoutRef.current = setTimeout(() => {
skipTriggeredRef.current = false;
+ setIsProcessing(false);
}, 100);
}, [sentences, activeSource]);
@@ -248,6 +254,7 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
}
skipTriggeredRef.current = true;
+ setIsProcessing(true);
// Cancel any ongoing request
if (currentRequestRef.current) {
currentRequestRef.current.abort();
@@ -269,6 +276,7 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
// Reset skip flag after a short delay
skipTimeoutRef.current = setTimeout(() => {
skipTriggeredRef.current = false;
+ setIsProcessing(false);
}, 100);
}, [sentences, activeSource]);
@@ -309,10 +317,39 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
preloadAdjacentSentences();
}, [currentIndex, sentences]);
+ const isMounted = useRef(false);
+
useEffect(() => {
- if (isPlaying && sentences[currentIndex] && !isProcessing) {
- processAndPlaySentence(sentences[currentIndex]);
+ // Skip the first mount in development
+ if (process.env.NODE_ENV === 'development') {
+ if (!isMounted.current) {
+ isMounted.current = true;
+ return;
+ }
}
+
+ let isEffectActive = true;
+
+ const playAudio = async () => {
+ if (isPlaying && sentences[currentIndex] && !isProcessing && isEffectActive) {
+ await processAndPlaySentence(sentences[currentIndex]);
+ }
+ };
+
+ playAudio();
+
+ return () => {
+ isEffectActive = false;
+ // Clean up any playing audio when the effect is cleaned up
+ if (activeSource) {
+ activeSource.stop();
+ setActiveSource(null);
+ }
+ if (currentRequestRef.current) {
+ currentRequestRef.current.abort();
+ currentRequestRef.current = null;
+ }
+ };
}, [isPlaying, currentIndex, sentences, isProcessing]);
const preloadSentence = async (sentence: string) => {
@@ -424,6 +461,7 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
setCurrentIndex: setCurrentIndexWithoutPlay,
stopAndPlayFromIndex,
sentences,
+ isProcessing,
};
return (
diff --git a/src/context/ThemeContext.tsx b/src/context/ThemeContext.tsx
new file mode 100644
index 0000000..721f708
--- /dev/null
+++ b/src/context/ThemeContext.tsx
@@ -0,0 +1,75 @@
+'use client';
+
+import { createContext, useContext, useEffect, useState } from 'react';
+
+type Theme = 'light' | 'dark' | 'system';
+
+interface ThemeContextType {
+ theme: Theme;
+ setTheme: (theme: Theme) => void;
+}
+
+const ThemeContext = createContext(undefined);
+
+const getSystemTheme = () => {
+ if (typeof window === 'undefined') return 'light';
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
+};
+
+const getEffectiveTheme = (theme: Theme): 'light' | 'dark' => {
+ if (theme === 'system') {
+ return getSystemTheme();
+ }
+ return theme;
+};
+
+export function ThemeProvider({ children }: { children: React.ReactNode }) {
+ const [theme, setTheme] = useState(() => {
+ if (typeof window === 'undefined') return 'system';
+ return (localStorage.getItem('theme') as Theme) || 'system';
+ });
+
+ const handleThemeChange = (newTheme: Theme) => {
+ const root = window.document.documentElement;
+ const effectiveTheme = getEffectiveTheme(newTheme);
+
+ root.classList.remove('light', 'dark');
+ root.classList.add(effectiveTheme);
+ root.style.colorScheme = effectiveTheme;
+
+ localStorage.setItem('theme', newTheme);
+ setTheme(newTheme);
+ };
+
+ // Handle system theme changes
+ useEffect(() => {
+ const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
+
+ const handleChange = () => {
+ if (theme === 'system') {
+ const effectiveTheme = getSystemTheme();
+ const root = window.document.documentElement;
+ root.classList.remove('light', 'dark');
+ root.classList.add(effectiveTheme);
+ root.style.colorScheme = effectiveTheme;
+ }
+ };
+
+ mediaQuery.addEventListener('change', handleChange);
+ return () => mediaQuery.removeEventListener('change', handleChange);
+ }, [theme]);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useTheme() {
+ const context = useContext(ThemeContext);
+ if (context === undefined) {
+ throw new Error('useTheme must be used within a ThemeProvider');
+ }
+ return context;
+}
diff --git a/src/lib/theme.ts b/src/lib/theme.ts
new file mode 100644
index 0000000..459c435
--- /dev/null
+++ b/src/lib/theme.ts
@@ -0,0 +1,16 @@
+// This will be loaded as a module
+export function initializeTheme() {
+ try {
+ const savedTheme = localStorage.getItem('theme') || 'system';
+ const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
+ const theme = savedTheme === 'system' ? systemTheme : savedTheme;
+
+ document.documentElement.classList.remove('light', 'dark');
+ document.documentElement.classList.add(theme);
+ document.documentElement.style.colorScheme = theme;
+ } catch (e) {
+ // Fallback to light theme if localStorage is not available
+ document.documentElement.classList.add('light');
+ document.documentElement.style.colorScheme = 'light';
+ }
+}
diff --git a/tailwind.config.ts b/tailwind.config.ts
index 555205d..5db2dd5 100644
--- a/tailwind.config.ts
+++ b/tailwind.config.ts
@@ -6,6 +6,7 @@ export default {
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
+ darkMode: 'class',
theme: {
extend: {
colors: {
@@ -16,6 +17,31 @@ export default {
accent: "var(--accent)",
muted: "var(--muted)",
},
+ animation: {
+ 'spin-slow': 'spin 2s linear infinite',
+ 'fade-in': 'fadeIn 200ms ease-in',
+ 'fade-out': 'fadeOut 200ms ease-out',
+ 'scale-in': 'scaleIn 200ms ease-in',
+ 'scale-out': 'scaleOut 200ms ease-out',
+ },
+ keyframes: {
+ fadeIn: {
+ '0%': { opacity: '0' },
+ '100%': { opacity: '1' },
+ },
+ fadeOut: {
+ '0%': { opacity: '1' },
+ '100%': { opacity: '0' },
+ },
+ scaleIn: {
+ '0%': { transform: 'scale(0.95)', opacity: '0' },
+ '100%': { transform: 'scale(1)', opacity: '1' },
+ },
+ scaleOut: {
+ '0%': { transform: 'scale(1)', opacity: '1' },
+ '100%': { transform: 'scale(0.95)', opacity: '0' },
+ },
+ },
},
},
plugins: [],