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 components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
:root {
|
:root,
|
||||||
|
html.light {
|
||||||
--background: #ffffff;
|
--background: #ffffff;
|
||||||
--foreground: #171717;
|
--foreground: #171717;
|
||||||
--base: #f5f5f5;
|
--base: #f5f5f5;
|
||||||
|
|
@ -11,19 +12,26 @@
|
||||||
--muted: #737373;
|
--muted: #737373;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
html.dark {
|
||||||
:root {
|
--background: #111111;
|
||||||
--background: #0a0a0a;
|
--foreground: #ededed;
|
||||||
--foreground: #ededed;
|
--base: #1f1f1f;
|
||||||
--base: #333333;
|
--offbase: #4a4a4a;
|
||||||
--offbase: #4a4a4a;
|
--accent: #f87171;
|
||||||
--accent: #f87171;
|
--muted: #a3a3a3;
|
||||||
--muted: #a3a3a3;
|
}
|
||||||
}
|
|
||||||
|
/* Ensure background color is set before content loads */
|
||||||
|
html {
|
||||||
|
background-color: var(--background);
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
background: var(--background);
|
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 "./globals.css";
|
||||||
import { Providers } from "./providers";
|
import { Providers } from "./providers";
|
||||||
import TTSPlayer from "@/components/TTSPlayer";
|
import TTSPlayer from "@/components/TTSPlayer";
|
||||||
|
import { Metadata } from "next";
|
||||||
const geistSans = Geist({
|
|
||||||
variable: "--font-geist-sans",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
|
||||||
variable: "--font-geist-mono",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "OpenReader WebUI",
|
||||||
description: "Generated by create next app",
|
description: "A modern web interface for reading and managing documents",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
|
|
@ -25,10 +14,18 @@ export default function RootLayout({
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en" suppressHydrationWarning>
|
||||||
<body
|
<head>
|
||||||
className={`${geistSans.variable} ${geistMono.variable} font-sans antialiased`}
|
<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>
|
<Providers>
|
||||||
<div className="min-h-screen bg-background p-4">
|
<div className="min-h-screen bg-background p-4">
|
||||||
<div className="max-w-6xl mx-auto align-center">
|
<div className="max-w-6xl mx-auto align-center">
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,49 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
import { PDFUploader } from '@/components/PDFUploader';
|
import { PDFUploader } from '@/components/PDFUploader';
|
||||||
import { DocumentList } from '@/components/DocumentList';
|
import { DocumentList } from '@/components/DocumentList';
|
||||||
|
import { SettingsModal } from '@/components/SettingsModal';
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
|
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='p-4'>
|
<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">
|
<div className="flex flex-col items-center gap-6">
|
||||||
<PDFUploader className="w-full max-w-md" />
|
<PDFUploader className="w-full max-w-md" />
|
||||||
<DocumentList />
|
<DocumentList />
|
||||||
</div>
|
</div>
|
||||||
|
<SettingsModal isOpen={isSettingsOpen} setIsOpen={setIsSettingsOpen} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,17 @@
|
||||||
|
|
||||||
import { PDFProvider } from '@/context/PDFContext';
|
import { PDFProvider } from '@/context/PDFContext';
|
||||||
import { TTSProvider } from '@/context/TTSContext';
|
import { TTSProvider } from '@/context/TTSContext';
|
||||||
|
import { ThemeProvider } from '@/context/ThemeContext';
|
||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
export function Providers({ children }: { children: ReactNode }) {
|
export function Providers({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<TTSProvider>
|
<ThemeProvider>
|
||||||
<PDFProvider>
|
<TTSProvider>
|
||||||
{children}
|
<PDFProvider>
|
||||||
</PDFProvider>
|
{children}
|
||||||
</TTSProvider>
|
</PDFProvider>
|
||||||
|
</TTSProvider>
|
||||||
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,25 @@
|
||||||
import { usePDF } from '@/context/PDFContext';
|
import { usePDF } from '@/context/PDFContext';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { Dialog } from '@headlessui/react';
|
||||||
|
import { Transition } from '@headlessui/react';
|
||||||
|
import { Fragment, useState } from 'react';
|
||||||
|
|
||||||
export function DocumentList() {
|
export function DocumentList() {
|
||||||
const { documents, removeDocument, isLoading, error } = usePDF();
|
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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -33,44 +50,116 @@ export function DocumentList() {
|
||||||
<h2 className="text-xl font-semibold mb-4 text-foreground">Your Documents</h2>
|
<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">
|
<div className="bg-background rounded-lg shadow p-2 space-y-2">
|
||||||
{documents.map((doc) => (
|
{documents.map((doc) => (
|
||||||
<div
|
<Transition
|
||||||
key={doc.id}
|
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
|
<div
|
||||||
href={`/pdf/${encodeURIComponent(doc.id)}`}
|
className="flex items-center justify-between hover:bg-base p-2 rounded-lg transition-colors"
|
||||||
className="flex items-center space-x-4 flex-1 min-w-0"
|
|
||||||
>
|
>
|
||||||
<div className="flex-shrink-0">
|
<Link
|
||||||
<svg className="w-8 h-8 text-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
href={`/pdf/${encodeURIComponent(doc.id)}`}
|
||||||
<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" />
|
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>
|
</svg>
|
||||||
</div>
|
</button>
|
||||||
<div className="flex-1 min-w-0">
|
</div>
|
||||||
<p className="text-foreground font-medium truncate">{doc.name}</p>
|
</Transition>
|
||||||
<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>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -244,7 +244,7 @@ export function PDFViewer({ pdfData }: PDFViewerProps) {
|
||||||
});
|
});
|
||||||
|
|
||||||
bestMatch.elements.forEach(element => {
|
bestMatch.elements.forEach(element => {
|
||||||
element.style.backgroundColor = 'yellow';
|
element.style.backgroundColor = 'grey';
|
||||||
element.style.opacity = '0.4';
|
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,
|
SkipBackwardIcon,
|
||||||
} from './icons/Icons';
|
} 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() {
|
export default function TTSPlayer() {
|
||||||
const [isVisible, setIsVisible] = useState(true);
|
const [isVisible, setIsVisible] = useState(true);
|
||||||
const { isPlaying, togglePlay, skipForward, skipBackward } = useTTS();
|
const { isPlaying, togglePlay, skipForward, skipBackward, isProcessing } = useTTS();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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">
|
<div className="bg-base dark:bg-base rounded-full shadow-lg px-2 py-1 flex items-center space-x-2 relative">
|
||||||
<Button
|
<Button
|
||||||
onClick={skipBackward}
|
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"
|
aria-label="Skip backward"
|
||||||
|
disabled={isProcessing}
|
||||||
>
|
>
|
||||||
<SkipBackwardIcon />
|
{isProcessing ? <LoadingSpinner /> : <SkipBackwardIcon />}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -39,10 +49,11 @@ export default function TTSPlayer() {
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={skipForward}
|
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"
|
aria-label="Skip forward"
|
||||||
|
disabled={isProcessing}
|
||||||
>
|
>
|
||||||
<SkipForwardIcon />
|
{isProcessing ? <LoadingSpinner /> : <SkipForwardIcon />}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ interface TTSContextType {
|
||||||
setCurrentIndex: (index: number) => void;
|
setCurrentIndex: (index: number) => void;
|
||||||
stopAndPlayFromIndex: (index: number) => void;
|
stopAndPlayFromIndex: (index: number) => void;
|
||||||
sentences: string[];
|
sentences: string[];
|
||||||
|
isProcessing: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TTSContext = createContext<TTSContextType | undefined>(undefined);
|
const TTSContext = createContext<TTSContextType | undefined>(undefined);
|
||||||
|
|
@ -123,9 +124,14 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
|
||||||
const processAndPlaySentence = async (sentence: string) => {
|
const processAndPlaySentence = async (sentence: string) => {
|
||||||
if (!audioContext || isProcessing) return;
|
if (!audioContext || isProcessing) return;
|
||||||
setIsProcessing(true);
|
|
||||||
|
|
||||||
try {
|
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
|
// Cancel any existing request
|
||||||
if (currentRequestRef.current) {
|
if (currentRequestRef.current) {
|
||||||
currentRequestRef.current.abort();
|
currentRequestRef.current.abort();
|
||||||
|
|
@ -140,8 +146,6 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
|
||||||
setActiveSource(null);
|
setActiveSource(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean the sentence before processing
|
|
||||||
const cleanedSentence = preprocessText(sentence);
|
|
||||||
let audioBuffer = audioCacheRef.current.get(cleanedSentence);
|
let audioBuffer = audioCacheRef.current.get(cleanedSentence);
|
||||||
|
|
||||||
if (!audioBuffer) {
|
if (!audioBuffer) {
|
||||||
|
|
@ -161,9 +165,10 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
|
||||||
// Store in cache
|
// Store in cache
|
||||||
audioCacheRef.current.set(cleanedSentence, audioBuffer);
|
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;
|
if (!currentRequestRef.current) return;
|
||||||
|
|
||||||
const source = audioContext.createBufferSource();
|
const source = audioContext.createBufferSource();
|
||||||
|
|
@ -174,7 +179,6 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
|
||||||
// Set up onended handler before starting
|
// Set up onended handler before starting
|
||||||
source.onended = () => {
|
source.onended = () => {
|
||||||
setActiveSource(null);
|
setActiveSource(null);
|
||||||
setIsProcessing(false);
|
|
||||||
// Only advance if we're playing and not pausing or skipping
|
// Only advance if we're playing and not pausing or skipping
|
||||||
if (isPlaying && !skipTriggeredRef.current && !isPausingRef.current) {
|
if (isPlaying && !skipTriggeredRef.current && !isPausingRef.current) {
|
||||||
processNextSentence();
|
processNextSentence();
|
||||||
|
|
@ -218,6 +222,7 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
skipTriggeredRef.current = true;
|
skipTriggeredRef.current = true;
|
||||||
|
setIsProcessing(true);
|
||||||
// Cancel any ongoing request
|
// Cancel any ongoing request
|
||||||
if (currentRequestRef.current) {
|
if (currentRequestRef.current) {
|
||||||
currentRequestRef.current.abort();
|
currentRequestRef.current.abort();
|
||||||
|
|
@ -239,6 +244,7 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
|
||||||
// Reset skip flag after a short delay
|
// Reset skip flag after a short delay
|
||||||
skipTimeoutRef.current = setTimeout(() => {
|
skipTimeoutRef.current = setTimeout(() => {
|
||||||
skipTriggeredRef.current = false;
|
skipTriggeredRef.current = false;
|
||||||
|
setIsProcessing(false);
|
||||||
}, 100);
|
}, 100);
|
||||||
}, [sentences, activeSource]);
|
}, [sentences, activeSource]);
|
||||||
|
|
||||||
|
|
@ -248,6 +254,7 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
skipTriggeredRef.current = true;
|
skipTriggeredRef.current = true;
|
||||||
|
setIsProcessing(true);
|
||||||
// Cancel any ongoing request
|
// Cancel any ongoing request
|
||||||
if (currentRequestRef.current) {
|
if (currentRequestRef.current) {
|
||||||
currentRequestRef.current.abort();
|
currentRequestRef.current.abort();
|
||||||
|
|
@ -269,6 +276,7 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
|
||||||
// Reset skip flag after a short delay
|
// Reset skip flag after a short delay
|
||||||
skipTimeoutRef.current = setTimeout(() => {
|
skipTimeoutRef.current = setTimeout(() => {
|
||||||
skipTriggeredRef.current = false;
|
skipTriggeredRef.current = false;
|
||||||
|
setIsProcessing(false);
|
||||||
}, 100);
|
}, 100);
|
||||||
}, [sentences, activeSource]);
|
}, [sentences, activeSource]);
|
||||||
|
|
||||||
|
|
@ -309,10 +317,39 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
|
||||||
preloadAdjacentSentences();
|
preloadAdjacentSentences();
|
||||||
}, [currentIndex, sentences]);
|
}, [currentIndex, sentences]);
|
||||||
|
|
||||||
|
const isMounted = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isPlaying && sentences[currentIndex] && !isProcessing) {
|
// Skip the first mount in development
|
||||||
processAndPlaySentence(sentences[currentIndex]);
|
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]);
|
}, [isPlaying, currentIndex, sentences, isProcessing]);
|
||||||
|
|
||||||
const preloadSentence = async (sentence: string) => {
|
const preloadSentence = async (sentence: string) => {
|
||||||
|
|
@ -424,6 +461,7 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
|
||||||
setCurrentIndex: setCurrentIndexWithoutPlay,
|
setCurrentIndex: setCurrentIndexWithoutPlay,
|
||||||
stopAndPlayFromIndex,
|
stopAndPlayFromIndex,
|
||||||
sentences,
|
sentences,
|
||||||
|
isProcessing,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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/components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
|
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
],
|
],
|
||||||
|
darkMode: 'class',
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
|
|
@ -16,6 +17,31 @@ export default {
|
||||||
accent: "var(--accent)",
|
accent: "var(--accent)",
|
||||||
muted: "var(--muted)",
|
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: [],
|
plugins: [],
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue