Add compromise, TTSPlayer template

This commit is contained in:
Richard Roberson 2025-01-18 15:47:37 -07:00
parent ae8eb084c2
commit 1ce63a8fba
9 changed files with 330 additions and 13 deletions

39
package-lock.json generated
View file

@ -9,6 +9,7 @@
"version": "0.1.0",
"dependencies": {
"@headlessui/react": "^2.2.0",
"compromise": "^14.14.4",
"next": "15.1.5",
"pdfjs-dist": "4.8.69",
"react": "^19.0.0",
@ -2055,6 +2056,20 @@
"node": ">= 6"
}
},
"node_modules/compromise": {
"version": "14.14.4",
"resolved": "https://registry.npmjs.org/compromise/-/compromise-14.14.4.tgz",
"integrity": "sha512-QdbJwronwxeqb7a5KFK/+Y5YieZ4PE1f7ai0vU58Pp4jih+soDCBMuKVbhDEPQ+6+vI3vSiG4UAAjTAXLJw1Qw==",
"license": "MIT",
"dependencies": {
"efrt": "2.7.0",
"grad-school": "0.0.5",
"suffix-thumb": "5.0.2"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@ -2310,6 +2325,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/efrt": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/efrt/-/efrt-2.7.0.tgz",
"integrity": "sha512-/RInbCy1d4P6Zdfa+TMVsf/ufZVotat5hCw3QXmWtjU+3pFEOvOQ7ibo3aIxyCJw2leIeAMjmPj+1SLJiCpdrQ==",
"license": "MIT",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/emoji-regex": {
"version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
@ -3405,6 +3429,15 @@
"dev": true,
"license": "ISC"
},
"node_modules/grad-school": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/grad-school/-/grad-school-0.0.5.tgz",
"integrity": "sha512-rXunEHF9M9EkMydTBux7+IryYXEZinRk6g8OBOGDBzo/qWJjhTxy86i5q7lQYpCLHN8Sqv1XX3OIOc7ka2gtvQ==",
"license": "MIT",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/graphemer": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
@ -6084,6 +6117,12 @@
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/suffix-thumb": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/suffix-thumb/-/suffix-thumb-5.0.2.tgz",
"integrity": "sha512-I5PWXAFKx3FYnI9a+dQMWNqTxoRt6vdBdb0O+BJ1sxXCWtSoQCusc13E58f+9p4MYx/qCnEMkD5jac6K2j3dgA==",
"license": "MIT"
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",

View file

@ -10,6 +10,7 @@
},
"dependencies": {
"@headlessui/react": "^2.2.0",
"compromise": "^14.14.4",
"next": "15.1.5",
"pdfjs-dist": "4.8.69",
"react": "^19.0.0",

View file

@ -2,6 +2,8 @@ import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { Providers } from "./providers";
import { TTSProvider } from "@/context/TTSContext";
import TTSPlayer from "@/components/TTSPlayer";
const geistSans = Geist({
variable: "--font-geist-sans",
@ -29,13 +31,16 @@ export default function RootLayout({
className={`${geistSans.variable} ${geistMono.variable} font-sans antialiased`}
>
<Providers>
<div className="min-h-screen bg-background py-4">
<div className="max-w-6xl mx-auto px-2">
<div className="bg-base rounded-lg shadow-lg">
{children}
<TTSProvider>
<div className="min-h-screen bg-background p-4">
<div className="max-w-6xl mx-auto align-center">
<div className="bg-base rounded-lg shadow-lg">
{children}
</div>
</div>
</div>
</div>
<TTSPlayer />
</TTSProvider>
</Providers>
</body>
</html>

View file

@ -13,25 +13,25 @@ export function DocumentList() {
}
return (
<div className="w-full">
<div className="w-full max-w-3xl mx-auto">
<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
key={doc.id}
className="flex items-center justify-between hover:bg-base p-1 rounded-lg transition-colors"
className="flex items-center justify-between hover:bg-base p-2 rounded-lg transition-colors"
>
<Link
href={`/pdf/${encodeURIComponent(doc.id)}`}
className="flex items-center space-x-4 flex-1"
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>
<h3 className="font-medium text-foreground">{doc.name}</h3>
<div className="min-w-0">
<h3 className="font-medium text-foreground truncate">{doc.name}</h3>
<p className="text-sm text-muted">
{(doc.size / 1024 / 1024).toFixed(2)} MB
</p>
@ -39,7 +39,7 @@ export function DocumentList() {
</Link>
<button
onClick={() => removeDocument(doc.id)}
className="p-2 text-muted hover:text-accent transition-colors"
className="p-2 text-muted hover:text-accent transition-colors flex-shrink-0 ml-2"
title="Remove document"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">

View file

@ -23,7 +23,7 @@ export function PDFSkeleton() {
{[1, 2, 3].map((index) => (
<div key={`skeleton_${index}`}>
{/* Page content skeleton */}
<div className="flex justify-center my-4">
<div className="flex flex-col justify-center my-4">
<div className="bg-gray-200 shadow-lg">
{/* Approximate dimensions of a PDF page */}
<div className="w-[595px] h-[842px]"></div>

View file

@ -3,8 +3,9 @@
import { Document, Page, pdfjs } from 'react-pdf';
import 'react-pdf/dist/Page/AnnotationLayer.css';
import 'react-pdf/dist/Page/TextLayer.css';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { PDFSkeleton } from './PDFSkeleton';
import { useTTS } from '@/context/TTSContext';
// Set worker from public directory
pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.mjs';
@ -15,11 +16,42 @@ interface PDFViewerProps {
export function PDFViewer({ pdfFile }: PDFViewerProps) {
const [numPages, setNumPages] = useState<number>();
const { setText } = useTTS();
const [pdfText, setPdfText] = useState('');
function onDocumentLoadSuccess({ numPages }: { numPages: number }): void {
setNumPages(numPages);
}
useEffect(() => {
if (pdfFile) {
// Load PDF text when file changes
const loadPdfText = async () => {
try {
const pdf = await pdfjs.getDocument(pdfFile).promise;
let fullText = '';
// Get text from all pages
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i);
const textContent = await page.getTextContent();
const pageText = textContent.items
.map((item: any) => item.str)
.join(' ');
fullText += pageText + ' ';
}
setPdfText(fullText);
setText(fullText);
} catch (error) {
console.error('Error loading PDF text:', error);
}
};
loadPdfText();
}
}, [pdfFile, setText]);
return (
<div className="flex flex-col items-center">
<Document

View file

@ -0,0 +1,50 @@
'use client';
import React, { useState } from 'react';
import { useTTS } from '@/context/TTSContext';
import { Button } from '@headlessui/react';
import {
PlayIcon,
PauseIcon,
SkipForwardIcon,
SkipBackwardIcon,
} from './icons/Icons';
export default function TTSPlayer() {
const [isVisible, setIsVisible] = useState(true);
const { isPlaying, togglePlay, skipForward, skipBackward } = useTTS();
return (
<div
className={`fixed bottom-4 left-1/2 transform -translate-x-1/2 z-50 ${
isVisible ? 'opacity-100' : 'opacity-0'
} transition-opacity duration-300`}
>
<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"
aria-label="Skip backward"
>
<SkipBackwardIcon />
</Button>
<Button
onClick={togglePlay}
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"
aria-label={isPlaying ? 'Pause' : 'Play'}
>
{isPlaying ? <PauseIcon /> : <PlayIcon />}
</Button>
<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"
aria-label="Skip forward"
>
<SkipForwardIcon />
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,110 @@
export const PlayIcon = () => (
<svg
width="32"
height="32"
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle
cx="16"
cy="16"
r="15"
stroke="currentColor"
strokeWidth="2"
className="transition-colors"
/>
<path
d="M21.5 15.134c.667.385.667 1.347 0 1.732l-7.5 4.33c-.667.386-1.5-.096-1.5-.866V11.67c0-.77.833-1.252 1.5-.866l7.5 4.33z"
fill="currentColor"
className="transition-colors"
/>
</svg>
);
export const PauseIcon = () => (
<svg
width="32"
height="32"
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle
cx="16"
cy="16"
r="15"
stroke="currentColor"
strokeWidth="2"
className="transition-colors"
/>
<rect
x="12"
y="11"
width="2.5"
height="10"
rx="1"
fill="currentColor"
className="transition-colors"
/>
<rect
x="17.5"
y="11"
width="2.5"
height="10"
rx="1"
fill="currentColor"
className="transition-colors"
/>
</svg>
);
export const SkipForwardIcon = () => (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 4l10 8-10 8V4z"
fill="currentColor"
className="transition-colors"
/>
<rect
x="16"
y="4"
width="2"
height="16"
rx="1"
fill="currentColor"
className="transition-colors"
/>
</svg>
);
export const SkipBackwardIcon = () => (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M18 4L8 12l10 8V4z"
fill="currentColor"
className="transition-colors"
/>
<rect
x="6"
y="4"
width="2"
height="16"
rx="1"
fill="currentColor"
className="transition-colors"
/>
</svg>
);

View file

@ -0,0 +1,80 @@
'use client';
import React, { createContext, useContext, useState, useCallback } from 'react';
import nlp from 'compromise';
interface TTSContextType {
isPlaying: boolean;
currentText: string;
togglePlay: () => void;
skipForward: () => void;
skipBackward: () => void;
setText: (text: string) => void;
currentSentence: string;
}
const TTSContext = createContext<TTSContextType | undefined>(undefined);
export function TTSProvider({ children }: { children: React.ReactNode }) {
const [isPlaying, setIsPlaying] = useState(false);
const [currentText, setCurrentText] = useState('');
const [sentences, setSentences] = useState<string[]>([]);
const [currentIndex, setCurrentIndex] = useState(0);
const splitIntoSentences = (text: string): string[] => {
const doc = nlp(text);
// Convert to array and ensure we get strings
return doc.sentences().out('array') as string[];
};
const togglePlay = useCallback(() => {
setIsPlaying(prev => !prev);
// TODO: Implement actual TTS play/pause logic
}, []);
const skipForward = useCallback(() => {
setCurrentIndex(prev => {
const nextIndex = Math.min(prev + 1, sentences.length - 1);
console.log('Current sentence:', sentences[nextIndex]);
return nextIndex;
});
}, [sentences]);
const skipBackward = useCallback(() => {
setCurrentIndex(prev => {
const nextIndex = Math.max(prev - 1, 0);
console.log('Current sentence:', sentences[nextIndex]);
return nextIndex;
});
}, [sentences]);
const setText = useCallback((text: string) => {
setCurrentText(text);
const newSentences = splitIntoSentences(text);
setSentences(newSentences);
setCurrentIndex(0);
if (newSentences.length > 0) {
console.log('Starting sentence:', newSentences[0]);
}
}, []);
const value = {
isPlaying,
currentText,
togglePlay,
skipForward,
skipBackward,
setText,
currentSentence: sentences[currentIndex] || '',
};
return <TTSContext.Provider value={value}>{children}</TTSContext.Provider>;
}
export function useTTS() {
const context = useContext(TTSContext);
if (context === undefined) {
throw new Error('useTTS must be used within a TTSProvider');
}
return context;
}