Add theme switching + fixes

This commit is contained in:
Richard Roberson 2025-01-19 03:44:04 -07:00
parent 51352943c6
commit 3e82def76c
13 changed files with 553 additions and 80 deletions

30
public/theme.js Normal file
View file

@ -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';
}
})();

View file

@ -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;
}

View file

@ -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 (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} font-sans antialiased`}
>
<html lang="en" suppressHydrationWarning>
<head>
<meta name="color-scheme" content="light dark" />
<script src="/theme.js" />
<style dangerouslySetInnerHTML={{ __html: `
:root { color-scheme: light dark; }
html.dark { color-scheme: dark; }
html.light { color-scheme: light; }
html { background: var(--background); }
`}} />
</head>
<body className="antialiased">
<Providers>
<div className="min-h-screen bg-background p-4">
<div className="max-w-6xl mx-auto align-center">

View file

@ -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 (
<div className='p-4'>
<h1 className="text-2xl font-bold mb-6 text-center">OpenReader WebUI</h1>
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold text-center flex-grow">OpenReader WebUI</h1>
<button
onClick={() => setIsSettingsOpen(true)}
className="rounded-full p-2.5 text-foreground hover:bg-base focus:bg-base
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>
</button>
</div>
<div className="flex flex-col items-center gap-6">
<PDFUploader className="w-full max-w-md" />
<DocumentList />
</div>
<SettingsModal isOpen={isSettingsOpen} setIsOpen={setIsSettingsOpen} />
</div>
);
}

View file

@ -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 (
<TTSProvider>
<PDFProvider>
{children}
</PDFProvider>
</TTSProvider>
<ThemeProvider>
<TTSProvider>
<PDFProvider>
{children}
</PDFProvider>
</TTSProvider>
</ThemeProvider>
);
}

View file

@ -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() {
<h2 className="text-xl font-semibold mb-4 text-foreground">Your Documents</h2>
<div className="bg-background rounded-lg shadow p-2 space-y-2">
{documents.map((doc) => (
<div
<Transition
key={doc.id}
className="flex items-center justify-between hover:bg-base p-2 rounded-lg transition-colors"
show={true}
enter="transition duration-100 ease-out"
enterFrom="transform scale-95 opacity-0"
enterTo="transform scale-100 opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform scale-100 opacity-100"
leaveTo="transform scale-95 opacity-0"
>
<Link
href={`/pdf/${encodeURIComponent(doc.id)}`}
className="flex items-center space-x-4 flex-1 min-w-0"
<div
className="flex items-center justify-between hover:bg-base p-2 rounded-lg transition-colors"
>
<div className="flex-shrink-0">
<svg className="w-8 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" />
<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">
<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">
<p className="text-foreground font-medium truncate">{doc.name}</p>
<p className="text-sm text-muted truncate">
{(doc.size / 1024 / 1024).toFixed(2)} MB
</p>
</div>
</Link>
<button
onClick={() => {
setDocumentToDelete({ id: doc.id, name: doc.name });
setIsDeleteDialogOpen(true);
}}
className="ml-4 p-2 text-muted hover:text-red-500 rounded-lg hover:bg-red-50 transition-colors"
aria-label="Delete document"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</div>
<div className="flex-1 min-w-0">
<p className="text-foreground font-medium truncate">{doc.name}</p>
<p className="text-sm text-muted truncate">
{(doc.size / 1024 / 1024).toFixed(2)} MB
</p>
</div>
</Link>
<button
onClick={async () => {
try {
await removeDocument(doc.id);
} catch (err) {
console.error('Failed to remove document:', err);
}
}}
className="ml-4 p-2 text-muted hover:text-red-500 rounded-lg hover:bg-red-50 transition-colors"
aria-label="Delete document"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</button>
</div>
</Transition>
))}
</div>
<Transition appear show={isDeleteDialogOpen} as={Fragment}>
<Dialog
as="div"
className="relative z-50"
onClose={() => setIsDeleteDialogOpen(false)}
>
<Transition.Child
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 bg-opacity-25" />
</Transition.Child>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center">
<Transition.Child
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"
>
<Dialog.Panel className="w-full max-w-md transform overflow-hidden rounded-2xl bg-background p-6 text-left align-middle shadow-xl transition-all">
<Dialog.Title
as="h3"
className="text-lg font-medium leading-6 text-foreground"
>
Delete Document
</Dialog.Title>
<div className="mt-2">
<p className="text-sm text-muted">
Are you sure you want to delete "{documentToDelete?.name}"? This action cannot be undone.
</p>
</div>
<div className="mt-4 flex justify-end space-x-3">
<button
type="button"
className="inline-flex justify-center rounded-md border border-transparent bg-base px-4 py-2 text-sm font-medium text-foreground hover:bg-base/80 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2"
onClick={() => setIsDeleteDialogOpen(false)}
>
Cancel
</button>
<button
type="button"
className="inline-flex justify-center rounded-md border border-transparent bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2"
onClick={handleDelete}
>
Delete
</button>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition>
</div>
);
}

View file

@ -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';
});

View file

@ -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 (
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={() => setIsOpen(false)}>
<Transition.Child
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" />
</Transition.Child>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center">
<Transition.Child
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"
>
<Dialog.Panel className="w-full max-w-md transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all">
<Dialog.Title
as="h3"
className="text-lg font-semibold leading-6 text-foreground"
>
Settings
</Dialog.Title>
<div className="mt-4">
<div className="space-y-4">
<div className="space-y-2">
<label className="block text-sm font-medium text-foreground">Theme</label>
<Listbox value={selectedTheme} onChange={(newTheme) => setTheme(newTheme.id)}>
<div className="relative">
<Listbox.Button 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>
</span>
</Listbox.Button>
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options 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">
{themes.map((theme) => (
<Listbox.Option
key={theme.id}
className={({ active }) =>
`relative cursor-pointer select-none py-2 pl-10 pr-4 ${
active ? 'bg-accent/10 text-accent' : 'text-foreground'
}`
}
value={theme}
>
{({ selected }) => (
<>
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
{theme.name}
</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>
</span>
) : null}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</Listbox>
</div>
</div>
</div>
<div className="mt-6 flex justify-end">
<button
type="button"
className="inline-flex justify-center rounded-lg bg-accent px-4 py-2 text-sm
font-medium text-white hover:bg-accent/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>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition>
);
}

View file

@ -10,9 +10,18 @@ import {
SkipBackwardIcon,
} from './icons/Icons';
// Loading spinner component
function LoadingSpinner() {
return (
<div className="absolute inset-0 flex items-center justify-center">
<div className="animate-spin h-4 w-4 border-2 border-foreground border-t-transparent rounded-full" />
</div>
);
}
export default function TTSPlayer() {
const [isVisible, setIsVisible] = useState(true);
const { isPlaying, togglePlay, skipForward, skipBackward } = useTTS();
const { isPlaying, togglePlay, skipForward, skipBackward, isProcessing } = useTTS();
return (
<div
@ -23,10 +32,11 @@ export default function TTSPlayer() {
<div className="bg-base dark:bg-base rounded-full shadow-lg px-2 py-1 flex items-center space-x-2 relative">
<Button
onClick={skipBackward}
className="relative p-2 rounded-full text-foreground hover:bg-offbase data-[hover]:bg-offbase data-[active]:bg-offbase/80 transition-colors duration-200 focus:outline-none"
className="relative p-2 rounded-full text-foreground hover:bg-offbase data-[hover]:bg-offbase data-[active]:bg-offbase/80 transition-colors duration-200 focus:outline-none disabled:opacity-50"
aria-label="Skip backward"
disabled={isProcessing}
>
<SkipBackwardIcon />
{isProcessing ? <LoadingSpinner /> : <SkipBackwardIcon />}
</Button>
<Button
@ -39,10 +49,11 @@ export default function TTSPlayer() {
<Button
onClick={skipForward}
className="relative p-2 rounded-full text-foreground hover:bg-offbase data-[hover]:bg-offbase data-[active]:bg-offbase/80 transition-colors duration-200 focus:outline-none"
className="relative p-2 rounded-full text-foreground hover:bg-offbase data-[hover]:bg-offbase data-[active]:bg-offbase/80 transition-colors duration-200 focus:outline-none disabled:opacity-50"
aria-label="Skip forward"
disabled={isProcessing}
>
<SkipForwardIcon />
{isProcessing ? <LoadingSpinner /> : <SkipForwardIcon />}
</Button>
</div>
</div>

View file

@ -37,6 +37,7 @@ interface TTSContextType {
setCurrentIndex: (index: number) => void;
stopAndPlayFromIndex: (index: number) => void;
sentences: string[];
isProcessing: boolean;
}
const TTSContext = createContext<TTSContextType | undefined>(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 (

View file

@ -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<ThemeContextType | undefined>(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<Theme>(() => {
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 (
<ThemeContext.Provider value={{ theme, setTheme: handleThemeChange }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}

16
src/lib/theme.ts Normal file
View file

@ -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';
}
}

View file

@ -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: [],