Add theme switching + fixes
This commit is contained in:
parent
51352943c6
commit
3e82def76c
13 changed files with 553 additions and 80 deletions
30
public/theme.js
Normal file
30
public/theme.js
Normal 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';
|
||||
}
|
||||
})();
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
});
|
||||
|
||||
|
|
|
|||
147
src/components/SettingsModal.tsx
Normal file
147
src/components/SettingsModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
75
src/context/ThemeContext.tsx
Normal file
75
src/context/ThemeContext.tsx
Normal 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
16
src/lib/theme.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
|
|
@ -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: [],
|
||||
|
|
|
|||
Loading…
Reference in a new issue