feat: improve document list and self-hosting clarity
- Introduce a toggleable grid/list view for the document list, enhancing organization and display flexibility. This involved updates across `DocumentList`, `DocumentFolder`, `DocumentListItem`, and `SortControls`. - Add a new `CodeBlock` component and integrate detailed Docker-based self-hosting instructions into the footer. - Enhance privacy policy popover with clearer details on Deepinfra usage and strongly recommend self-hosting for secure experience. - Implement conditional rendering and feature gating based on the `isDev` environment variable, differentiating features between the production demo and self-hosted instances. Affected components include `page.tsx`, `DocumentSettings.tsx`, `SettingsModal.tsx`, and `config.ts`. - Clarify that advanced features like audiobook export and word-by-word highlighting via `whisper.cpp` are exclusive to self-hosted setups and are disabled in the demo. - Expand the list of supported document types on the homepage to include MD and TXT. - Integrate new `ListIcon`, `GridIcon`, and `CopyIcon` to support UI enhancements. - Add a custom `xs` breakpoint in `tailwind.config.ts` for improved responsive design.
This commit is contained in:
parent
e39a5b8bcf
commit
e86782bf38
14 changed files with 279 additions and 84 deletions
|
|
@ -1,7 +1,7 @@
|
|||
import { HomeContent } from '@/components/HomeContent';
|
||||
import { SettingsModal } from '@/components/SettingsModal';
|
||||
|
||||
// Home page redesigned for fullscreen layout: hero + document area.
|
||||
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
|
|
@ -10,9 +10,9 @@ export default function Home() {
|
|||
<section className="px-4 pt-6 pb-4 md:pt-10 md:pb-6">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<h1 className="text-2xl md:text-3xl font-bold tracking-tight mb-2 text-foreground">OpenReader WebUI</h1>
|
||||
<p className="text-sm leading-relaxed max-w-prose text-foreground">
|
||||
Bring your own text-to-speech API.
|
||||
<span className="block font-medium">Read & listen to PDF, EPUB & HTML documents with high quality voices.</span>
|
||||
<p className="text-sm leading-relaxed max-w-[77ch] text-foreground">
|
||||
Open source document reader web app {isDev ? 'self-hosted server' : 'demo'}.
|
||||
<span className="block font-medium">Read & listen to PDF, EPUB, MD, and TXT documents with high quality text to speech voices.</span>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
32
src/components/CodeBlock.tsx
Normal file
32
src/components/CodeBlock.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { CopyIcon, CheckIcon } from '@/components/icons/Icons';
|
||||
|
||||
export function CodeBlock({ children }: { children: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(children);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative group my-2">
|
||||
<pre className="bg-background p-3 rounded-md overflow-x-auto text-xs text-left
|
||||
font-mono text-foreground border border-offbase">
|
||||
<code>{children}</code>
|
||||
</pre>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="absolute top-2 right-2 p-1.5 rounded-md bg-base hover:bg-offbase hover:text-accent
|
||||
transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100
|
||||
hover:scale-[1.05]"
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
{copied ? <CheckIcon className="w-4 h-4" /> : <CopyIcon className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -141,16 +141,18 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
|
|||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<DialogPanel className="w-full max-w-md transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all">
|
||||
{isDev && !html && <div className="space-y-2 mb-4">
|
||||
{!html && <div className="space-y-2 mb-4">
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full inline-flex justify-center rounded-lg bg-accent px-3 py-1.5 text-sm
|
||||
font-medium text-background hover:bg-secondary-accent focus:outline-none
|
||||
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
|
||||
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-background"
|
||||
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-background
|
||||
disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-[1] disabled:hover:bg-accent"
|
||||
onClick={() => setIsAudiobookModalOpen(true)}
|
||||
disabled={!isDev}
|
||||
>
|
||||
Export Audiobook
|
||||
Export Audiobook {!isDev && '(requires self-hosted)'}
|
||||
</Button>
|
||||
</div>}
|
||||
|
||||
|
|
@ -352,18 +354,18 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
|
|||
<input
|
||||
type="checkbox"
|
||||
checked={pdfWordHighlightEnabled && pdfHighlightEnabled}
|
||||
disabled={!pdfHighlightEnabled}
|
||||
disabled={!pdfHighlightEnabled || !isDev}
|
||||
onChange={(e) =>
|
||||
updateConfigKey('pdfWordHighlightEnabled', e.target.checked)
|
||||
}
|
||||
className="form-checkbox h-4 w-4 text-accent rounded border-muted disabled:opacity-50"
|
||||
className="form-checkbox h-4 w-4 text-accent rounded border-muted disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
/>
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
Word-by-word
|
||||
</span>
|
||||
</label>
|
||||
<p className="text-sm text-muted pl-6">
|
||||
Highlight individual words using audio timestamps generated by whisper.cpp
|
||||
Highlight individual words using audio timestamps generated by whisper.cpp {!isDev && '(requires self-hosted)'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -389,18 +391,18 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
|
|||
<input
|
||||
type="checkbox"
|
||||
checked={epubWordHighlightEnabled && epubHighlightEnabled}
|
||||
disabled={!epubHighlightEnabled}
|
||||
disabled={!epubHighlightEnabled || !isDev}
|
||||
onChange={(e) =>
|
||||
updateConfigKey('epubWordHighlightEnabled', e.target.checked)
|
||||
}
|
||||
className="form-checkbox h-4 w-4 text-accent rounded border-muted disabled:opacity-50"
|
||||
className="form-checkbox h-4 w-4 text-accent rounded border-muted disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
/>
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
Word-by-word
|
||||
</span>
|
||||
</label>
|
||||
<p className="text-sm text-muted pl-6">
|
||||
Highlight individual words using audio timestamps generated by whisper.cpp
|
||||
Highlight individual words using audio timestamps generated by whisper.cpp {!isDev && '(requires self-hosted)'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -156,18 +156,21 @@ export function EPUBViewer({ className = '' }: EPUBViewerProps) {
|
|||
{isTocOpen && tocRef.current && tocRef.current.length > 0 && (
|
||||
<div className="border-b border-offbase bg-background text-xs overflow-y-auto max-h-64 p-2">
|
||||
<div className="font-semibold text-muted pb-1">Skip to chapters</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<div className="flex flex-wrap gap-1 w-full">
|
||||
{tocRef.current.map((item, index) => (
|
||||
<button
|
||||
key={`${item.href}-${index}`}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (item.href) {
|
||||
handleLocationChanged(item.href);
|
||||
}
|
||||
if (item.href) handleLocationChanged(item.href);
|
||||
setIsTocOpen(false);
|
||||
}}
|
||||
className="w-full px-2 py-1 rounded-md text-foreground text-center bg-base hover:bg-offbase hover:text-accent transition-colors duration-150"
|
||||
className="
|
||||
px-2 py-1 rounded-md font-medium text-foreground text-center bg-base
|
||||
hover:bg-offbase hover:text-accent transition-colors duration-150
|
||||
whitespace-nowrap
|
||||
flex-1 min-w-[140px]
|
||||
"
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react'
|
||||
import { GithubIcon } from '@/components/icons/Icons'
|
||||
import { CodeBlock } from '@/components/CodeBlock'
|
||||
|
||||
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
|
||||
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="m-8 text-sm text-muted">
|
||||
<div className="flex flex-col items-center space-y-2">
|
||||
<footer className="m-8 mb-2 text-sm text-muted">
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="flex flex-wrap sm:flex-nowrap items-center justify-center text-center sm:space-x-3">
|
||||
<a
|
||||
href="https://github.com/richardr1126/OpenReader-WebUI"
|
||||
|
|
@ -16,15 +20,16 @@ export function Footer() {
|
|||
</a>
|
||||
<span className='w-full sm:w-fit'>•</span>
|
||||
<Popover className="flex">
|
||||
<PopoverButton className="hover:text-foreground transition-colors flex items-center gap-1">
|
||||
<PopoverButton className="font-bold hover:text-foreground transition-colors outline-none flex items-center gap-1">
|
||||
Privacy info
|
||||
</PopoverButton>
|
||||
<PopoverPanel anchor="top" className="bg-base p-4 rounded-lg shadow-lg w-64">
|
||||
<p>Documents are uploaded to your local browser cache.</p>
|
||||
<p className='mt-3'>Each sentence of the document you are viewing is sent to my Kokoro-FastAPI server for audio generation.</p>
|
||||
<p className='mt-3'>The audio is streamed back to your browser and played in real-time.</p>
|
||||
<PopoverPanel anchor="top" className="bg-base p-4 rounded-lg shadow-xl border border-offbase z-50">
|
||||
<p className='max-w-xs'>Documents are uploaded to your local browser cache.</p>
|
||||
<p className='mt-3 max-w-xs'>Each paragraph of the document you are viewing is sent to Deepinfra for audio generation through a Vercel backend proxy, containing a shared caching pool.</p>
|
||||
<p className='mt-3 max-w-xs'>The audio is streamed back to your browser and played in real-time.</p>
|
||||
<p className='mt-3 max-w-xs font-bold'><em>Self-hosting is the recommended way to use this app for a truly secure experience.</em></p>
|
||||
{/* Vercel analytics disclaimer */}
|
||||
<p className='mt-3'>This site uses Vercel Analytics to collect anonymous usage data to help improve the service.</p>
|
||||
<p className='mt-3 max-w-xs'>This site uses Vercel Analytics to collect anonymous usage data to help improve the service.</p>
|
||||
</PopoverPanel>
|
||||
</Popover>
|
||||
<span className='w-full sm:w-fit'>•</span>
|
||||
|
|
@ -34,7 +39,7 @@ export function Footer() {
|
|||
href="https://huggingface.co/hexgrad/Kokoro-82M"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-bold hover:text-foreground transition-colors"
|
||||
className="font-bold hover:text-foreground transition-colors underline decoration-dotted underline-offset-4"
|
||||
>
|
||||
hexgrad/Kokoro-82M
|
||||
</a>
|
||||
|
|
@ -43,12 +48,66 @@ export function Footer() {
|
|||
href="https://deepinfra.com/models?type=text-to-speech"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-bold hover:text-foreground transition-colors"
|
||||
className="font-bold hover:text-foreground transition-colors underline decoration-dotted underline-offset-4"
|
||||
>
|
||||
Deepinfra
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
<div className='font-medium text-center flex items-center justify-center gap-1'>
|
||||
<span>This is a demo app (</span>
|
||||
<Popover className="relative">
|
||||
<PopoverButton className="font-bold hover:text-foreground transition-colors outline-none">
|
||||
self-host
|
||||
</PopoverButton>
|
||||
<PopoverPanel anchor="top" className="bg-base p-6 rounded-xl shadow-2xl border border-offbase w-[90vw] max-w-3xl z-50 backdrop-blur-md flex flex-col gap-4">
|
||||
<div className="space-y-4 font-medium">
|
||||
<h3 className="text-lg font-bold text-foreground">Self-Hosting Instructions</h3>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 font-medium">
|
||||
1. Start the <a href="https://github.com/remsky/Kokoro-FastAPI" target="_blank" rel="noopener noreferrer" className="text-muted hover:text-foreground underline decoration-dotted underline-offset-4">Kokoro-FastAPI</a> container
|
||||
</p>
|
||||
<CodeBlock>
|
||||
{
|
||||
`docker run -d \\
|
||||
--name kokoro-tts \\
|
||||
--restart unless-stopped \\
|
||||
-p 8880:8880 \\
|
||||
-e ONNX_NUM_THREADS=8 \\
|
||||
-e ONNX_INTER_OP_THREADS=4 \\
|
||||
-e ONNX_EXECUTION_MODE=parallel \\
|
||||
-e ONNX_OPTIMIZATION_LEVEL=all \\
|
||||
-e ONNX_MEMORY_PATTERN=true \\
|
||||
-e ONNX_ARENA_EXTEND_STRATEGY=kNextPowerOfTwo \\
|
||||
-e API_LOG_LEVEL=DEBUG \\
|
||||
ghcr.io/remsky/kokoro-fastapi-cpu:v0.2.4`
|
||||
}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-foreground font-medium">2. Start OpenReader WebUI container</p>
|
||||
<CodeBlock>
|
||||
{
|
||||
`docker run --name openreader-webui --rm \\
|
||||
-e API_BASE=http://kokoro-tts:8880/v1 \\
|
||||
-p 3003:3003 \\
|
||||
-v openreader_docstore:/app/docstore \\
|
||||
ghcr.io/richardr1126/openreader-webui:latest`
|
||||
}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Visit <a href="http://localhost:3003" target="_blank" rel="noopener noreferrer" className="text-muted hover:text-foreground transition-colors underline decoration-dotted underline-offset-4">http://localhost:3003</a> to run the app and set your settings.
|
||||
{' '}See the <a href="https://github.com/richardr1126/OpenReader-WebUI#readme" target="_blank" rel="noopener noreferrer" className="text-muted hover:text-foreground transition-colors underline decoration-dotted underline-offset-4">README</a> for more details.
|
||||
</p>
|
||||
</div>
|
||||
</PopoverPanel>
|
||||
</Popover>
|
||||
<span> for full functionality)</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -400,7 +400,7 @@ export function SettingsModal() {
|
|||
type="password"
|
||||
value={localApiKey}
|
||||
onChange={(e) => handleInputChange('apiKey', e.target.value)}
|
||||
placeholder={!isDev && localTTSProvider === 'deepinfra' ? "Deepinfra free or override apikey" : "Using environment variable"}
|
||||
placeholder={!isDev && localTTSProvider === 'deepinfra' ? "Deepinfra free or use your API key" : "Using environment variable"}
|
||||
className="w-full rounded-lg bg-background py-1.5 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
"use client";
|
||||
|
||||
import { useState, DragEvent } from 'react';
|
||||
import { Button, Transition } from '@headlessui/react';
|
||||
import { DocumentListItem } from './DocumentListItem';
|
||||
|
|
@ -16,6 +14,7 @@ interface DocumentFolderProps {
|
|||
onDragStart: (doc: DocumentListDocument) => void;
|
||||
onDragEnd: () => void;
|
||||
onDrop: (e: DragEvent, folderId: string) => void;
|
||||
viewMode: 'list' | 'grid';
|
||||
}
|
||||
|
||||
const ChevronIcon = ({ className = "w-4 h-4" }) => (
|
||||
|
|
@ -39,6 +38,7 @@ export function DocumentFolder({
|
|||
onDragStart,
|
||||
onDragEnd,
|
||||
onDrop,
|
||||
viewMode,
|
||||
}: DocumentFolderProps) {
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
const isDropTarget = isHovering && draggedDoc && !draggedDoc.folderId && draggedDoc.id !== folder.id;
|
||||
|
|
@ -64,7 +64,7 @@ export function DocumentFolder({
|
|||
if (!draggedDoc || draggedDoc.folderId) return;
|
||||
onDrop(e, folder.id);
|
||||
}}
|
||||
className={`overflow-hidden rounded-md border border-offbase ${isDropTarget ? 'ring-2 ring-accent' : ''}`}
|
||||
className={`w-full overflow-hidden rounded-md border border-offbase ${isDropTarget ? 'ring-2 ring-accent' : ''}`}
|
||||
>
|
||||
<div className='flex flex-row justify-between p-0'>
|
||||
<div className="w-full">
|
||||
|
|
@ -104,7 +104,7 @@ export function DocumentFolder({
|
|||
leaveFrom="transform scale-y-100 opacity-100 max-h-[1000px]"
|
||||
leaveTo="transform scale-y-0 opacity-0 max-h-0"
|
||||
>
|
||||
<div id={`folder-panel-${folder.id}`} className="space-y-1 origin-top">
|
||||
<div id={`folder-panel-${folder.id}`} className={`${viewMode === 'grid' ? "flex flex-wrap gap-1" : "space-y-1"} w-full origin-top`}>
|
||||
{sortedDocuments.map(doc => (
|
||||
<DocumentListItem
|
||||
key={`${doc.type}-${doc.id}`}
|
||||
|
|
@ -114,6 +114,7 @@ export function DocumentFolder({
|
|||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
isDropTarget={false}
|
||||
viewMode={viewMode}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ export function DocumentList() {
|
|||
const [collapsedFolders, setCollapsedFolders] = useState<Set<string>>(new Set());
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [showHint, setShowHint] = useState(true);
|
||||
const [viewMode, setViewMode] = useState<'list' | 'grid'>('grid');
|
||||
|
||||
const {
|
||||
pdfDocs,
|
||||
|
|
@ -78,6 +79,7 @@ export function DocumentList() {
|
|||
setFolders(savedState.folders);
|
||||
setCollapsedFolders(new Set(savedState.collapsedFolders));
|
||||
setShowHint(savedState.showHint ?? true); // Use saved hint state or default to true
|
||||
setViewMode(savedState.viewMode ?? 'grid');
|
||||
}
|
||||
setIsInitialized(true);
|
||||
};
|
||||
|
|
@ -92,7 +94,8 @@ export function DocumentList() {
|
|||
sortDirection,
|
||||
folders,
|
||||
collapsedFolders: Array.from(collapsedFolders),
|
||||
showHint
|
||||
showHint,
|
||||
viewMode
|
||||
};
|
||||
await saveDocumentListState(state);
|
||||
};
|
||||
|
|
@ -100,7 +103,7 @@ export function DocumentList() {
|
|||
if (isInitialized) { // Prevents saving empty state on first render or back navigation
|
||||
saveState();
|
||||
}
|
||||
}, [sortBy, sortDirection, folders, collapsedFolders, showHint, isInitialized]);
|
||||
}, [sortBy, sortDirection, folders, collapsedFolders, showHint, viewMode, isInitialized]);
|
||||
|
||||
const allDocuments: DocumentListDocument[] = [
|
||||
...pdfDocs.map(doc => ({
|
||||
|
|
@ -315,6 +318,8 @@ export function DocumentList() {
|
|||
sortDirection={sortDirection}
|
||||
onSortByChange={setSortBy}
|
||||
onSortDirectionChange={() => setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc')}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -325,21 +330,22 @@ export function DocumentList() {
|
|||
<DocumentUploader variant="compact" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{showHint && allDocuments.length > 1 && (
|
||||
<div className="flex items-center justify-between bg-offbase border border-offbase rounded-md px-3 py-1 text-sm">
|
||||
<p className="text-sm text-foreground">Drag files on top of each other to make folders</p>
|
||||
<Button
|
||||
onClick={() => setShowHint(false)}
|
||||
className="p-1 rounded-md hover:bg-base hover:text-accent transition-colors"
|
||||
aria-label="Dismiss hint"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{showHint && allDocuments.length > 1 && (
|
||||
<div className="flex items-center justify-between bg-offbase border border-offbase rounded-md px-3 py-1 text-sm mb-2">
|
||||
<p className="text-sm text-foreground">Drag files on top of each other to make folders</p>
|
||||
<Button
|
||||
onClick={() => setShowHint(false)}
|
||||
className="p-1 rounded-md hover:bg-base hover:text-accent transition-colors"
|
||||
aria-label="Dismiss hint"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={viewMode === 'grid' ? "flex flex-wrap gap-1 w-full" : "space-y-1 w-full"}>
|
||||
|
||||
{folders.map(folder => (
|
||||
<DocumentFolder
|
||||
|
|
@ -354,6 +360,7 @@ export function DocumentList() {
|
|||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDrop={handleFolderDrop}
|
||||
viewMode={viewMode}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
|
@ -368,6 +375,7 @@ export function DocumentList() {
|
|||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
isDropTarget={dropTargetDoc?.id === doc.id}
|
||||
viewMode={viewMode}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ interface DocumentListItemProps {
|
|||
onDragLeave?: () => void;
|
||||
onDrop?: (e: DragEvent, doc: DocumentListDocument) => void;
|
||||
isDropTarget?: boolean;
|
||||
viewMode: 'list' | 'grid';
|
||||
}
|
||||
|
||||
export function DocumentListItem({
|
||||
|
|
@ -27,6 +28,7 @@ export function DocumentListItem({
|
|||
onDragLeave,
|
||||
onDrop,
|
||||
isDropTarget = false,
|
||||
viewMode,
|
||||
}: DocumentListItemProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
|
@ -51,18 +53,18 @@ export function DocumentListItem({
|
|||
onDrop={(e) => allowDropTarget && onDrop?.(e, doc)}
|
||||
aria-busy={loading}
|
||||
className={`
|
||||
w-full group
|
||||
${viewMode === 'grid' ? 'flex-auto min-w-[200px] max-w-full' : 'w-full'} group
|
||||
${allowDropTarget && isDropTarget ? 'ring-2 ring-accent' : ''}
|
||||
${loading ? 'prism-outline' : 'bg-base hover:bg-offbase'}
|
||||
border border-offbase rounded-md p-1
|
||||
transition-colors duration-150 relative
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className="flex items-center w-full">
|
||||
<Link
|
||||
href={`/${doc.type}/${encodeURIComponent(doc.id)}`}
|
||||
draggable={false}
|
||||
className="document-link flex items-center align-center gap-2 w-full truncate rounded-md py-0.5 px-0.5"
|
||||
className="document-link flex items-center align-center gap-2 flex-1 min-w-0 rounded-md py-0.5 px-0.5"
|
||||
onClick={handleDocumentClick}
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Listbox, ListboxButton, ListboxOption, ListboxOptions, Button } from '@headlessui/react';
|
||||
import { ChevronUpDownIcon } from '@/components/icons/Icons';
|
||||
import { ChevronUpDownIcon, ListIcon, GridIcon } from '@/components/icons/Icons';
|
||||
import { SortBy, SortDirection } from '@/types/documents';
|
||||
|
||||
interface SortControlsProps {
|
||||
|
|
@ -7,6 +7,8 @@ interface SortControlsProps {
|
|||
sortDirection: SortDirection;
|
||||
onSortByChange: (value: SortBy) => void;
|
||||
onSortDirectionChange: () => void;
|
||||
viewMode: 'list' | 'grid';
|
||||
onViewModeChange: (mode: 'list' | 'grid') => void;
|
||||
}
|
||||
|
||||
export function SortControls({
|
||||
|
|
@ -14,6 +16,8 @@ export function SortControls({
|
|||
sortDirection,
|
||||
onSortByChange,
|
||||
onSortDirectionChange,
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
}: SortControlsProps) {
|
||||
const sortOptions: Array<{ value: SortBy; label: string, up: string, down: string }> = [
|
||||
{ value: 'name', label: 'Name', up: 'A-Z', down: 'Z-A' },
|
||||
|
|
@ -25,34 +29,59 @@ export function SortControls({
|
|||
const currentSort = sortOptions.find(opt => opt.value === sortBy);
|
||||
const directionLabel = sortDirection === 'asc' ? currentSort?.up : currentSort?.down;
|
||||
|
||||
const buttonBaseClass = "h-6 flex items-center justify-center bg-base hover:bg-offbase rounded border border-transparent hover:border-offbase text-xs sm:text-sm transition-all duration-200 ease-in-out hover:scale-[1.04] hover:text-accent";
|
||||
const activeIconClass = "text-accent";
|
||||
const inactiveIconClass = "text-muted";
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
onClick={onSortDirectionChange}
|
||||
className="px-1.5 sm:px-2 py-0.5 sm:py-1 bg-base hover:bg-offbase rounded text-xs sm:text-sm whitespace-nowrap transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
|
||||
>
|
||||
{directionLabel}
|
||||
</Button>
|
||||
<div className="relative">
|
||||
<Listbox value={sortBy} onChange={onSortByChange}>
|
||||
<ListboxButton className="flex items-center space-x-0.5 sm:space-x-1 bg-background text-foreground text-xs sm:text-sm focus:outline-none cursor-pointer hover:bg-offbase rounded pl-1.5 sm:pl-2 pr-0.5 sm:pr-1 py-0.5 sm:py-1 transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent">
|
||||
<span>{sortOptions.find(opt => opt.value === sortBy)?.label}</span>
|
||||
<ChevronUpDownIcon className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
|
||||
</ListboxButton>
|
||||
<ListboxOptions anchor="top end" className="absolute z-50 w-28 sm:w-32 overflow-auto rounded-lg bg-background shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
{sortOptions.map((option) => (
|
||||
<ListboxOption
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className={({ active, selected }) =>
|
||||
`relative cursor-pointer select-none py-0.5 px-1.5 sm:py-2 sm:px-3 ${active ? 'bg-offbase' : ''} ${selected ? 'font-medium' : ''}`
|
||||
}
|
||||
>
|
||||
<span className="text-xs sm:text-sm">{option.label}</span>
|
||||
</ListboxOption>
|
||||
))}
|
||||
</ListboxOptions>
|
||||
</Listbox>
|
||||
<div className="hidden xs:flex items-center bg-base rounded p-[1px] gap-0.5 border border-transparent">
|
||||
<Button
|
||||
onClick={() => onViewModeChange('list')}
|
||||
className={`p-0.5 rounded hover:bg-offbase transition-all hover:scale-[1.07] ${viewMode === 'list' ? activeIconClass : inactiveIconClass}`}
|
||||
aria-label="List view"
|
||||
>
|
||||
<ListIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => onViewModeChange('grid')}
|
||||
className={`p-0.5 rounded hover:bg-offbase transition-all hover:scale-[1.07] ${viewMode === 'grid' ? activeIconClass : inactiveIconClass}`}
|
||||
aria-label="Grid view"
|
||||
>
|
||||
<GridIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="hidden xs:block h-4 w-px bg-offbase mx-1" />
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
onClick={onSortDirectionChange}
|
||||
className={`${buttonBaseClass} px-2 text-xs`}
|
||||
>
|
||||
{directionLabel}
|
||||
</Button>
|
||||
<div className="relative">
|
||||
<Listbox value={sortBy} onChange={onSortByChange}>
|
||||
<ListboxButton className={`${buttonBaseClass} pl-2 pr-1 gap-1 min-w-[80px] justify-between focus:outline-none focus:ring-accent focus:ring-2`}>
|
||||
<span>{sortOptions.find(opt => opt.value === sortBy)?.label}</span>
|
||||
<ChevronUpDownIcon className="h-3 w-3 opacity-50" />
|
||||
</ListboxButton>
|
||||
<ListboxOptions anchor="top end" className="absolute z-50 w-32 overflow-auto rounded-lg bg-background shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none p-1">
|
||||
{sortOptions.map((option) => (
|
||||
<ListboxOption
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className={({ active, selected }) =>
|
||||
`relative cursor-pointer select-none py-1.5 px-2 rounded text-xs ${active ? 'bg-offbase text-accent' : 'text-foreground'} ${selected ? 'font-medium' : ''}`
|
||||
}
|
||||
>
|
||||
{option.label}
|
||||
</ListboxOption>
|
||||
))}
|
||||
</ListboxOptions>
|
||||
</Listbox>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -458,3 +458,55 @@ export function AudioWaveIcon(props: React.SVGProps<SVGSVGElement>) {
|
|||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CopyIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={props.className}
|
||||
width={props.width || "1.5em"}
|
||||
height={props.height || "1.5em"}
|
||||
{...props}
|
||||
>
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
export function ListIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={props.className}
|
||||
width={props.width || "1.5em"}
|
||||
height={props.height || "1.5em"}
|
||||
{...props}
|
||||
>
|
||||
<path fillRule="evenodd" d="M2.625 6.75a1.125 1.125 0 112.25 0 1.125 1.125 0 01-2.25 0zm4.875 0A.75.75 0 018.25 6h12a.75.75 0 010 1.5h-12a.75.75 0 01-.75-.75zM2.625 12a1.125 1.125 0 112.25 0 1.125 1.125 0 01-2.25 0zM7.5 12a.75.75 0 01.75-.75h12a.75.75 0 010 1.5h-12A.75.75 0 017.5 12zm-4.875 5.25a1.125 1.125 0 112.25 0 1.125 1.125 0 01-2.25 0zm4.875 0a.75.75 0 01.75-.75h12a.75.75 0 010 1.5h-12a.75.75 0 01-.75-.75z" clipRule="evenodd" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function GridIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={props.className}
|
||||
width={props.width || "1.5em"}
|
||||
height={props.height || "1.5em"}
|
||||
{...props}
|
||||
>
|
||||
<path fillRule="evenodd" d="M1.5 6a2.25 2.25 0 012.25-2.25h16.5A2.25 2.25 0 0122.5 6v12a2.25 2.25 0 01-2.25 2.25H3.75A2.25 2.25 0 011.5 18V6zM3 16.06V18c0 .414.336.75.75.75h16.5A.75.75 0 0021 18v-1.94l-2.69-2.689a1.5 1.5 0 00-2.12 0l-.88.879.97.97a.75.75 0 11-1.06 1.06l-5.16-5.159a1.5 1.5 0 00-2.12 0L3 16.061zm10.125-7.81a1.125 1.125 0 112.25 0 1.125 1.125 0 01-2.25 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import type { DocumentListState } from '@/types/documents';
|
||||
|
||||
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
|
||||
|
||||
export type ViewType = 'single' | 'dual' | 'scroll';
|
||||
|
||||
export type SavedVoices = Record<string, string>;
|
||||
|
|
@ -43,15 +45,15 @@ export const APP_CONFIG_DEFAULTS: AppConfigValues = {
|
|||
footerMargin: 0,
|
||||
leftMargin: 0,
|
||||
rightMargin: 0,
|
||||
ttsProvider: 'custom-openai',
|
||||
ttsModel: 'kokoro',
|
||||
ttsProvider: isDev ? 'custom-openai' : 'deepinfra',
|
||||
ttsModel: isDev ? 'kokoro' : 'hexgrad/Kokoro-82M',
|
||||
ttsInstructions: '',
|
||||
savedVoices: {},
|
||||
smartSentenceSplitting: true,
|
||||
pdfHighlightEnabled: true,
|
||||
pdfWordHighlightEnabled: true,
|
||||
pdfWordHighlightEnabled: isDev,
|
||||
epubHighlightEnabled: true,
|
||||
epubWordHighlightEnabled: true,
|
||||
epubWordHighlightEnabled: isDev,
|
||||
firstVisit: false,
|
||||
documentListState: {
|
||||
sortBy: 'name',
|
||||
|
|
@ -59,6 +61,7 @@ export const APP_CONFIG_DEFAULTS: AppConfigValues = {
|
|||
folders: [],
|
||||
collapsedFolders: [],
|
||||
showHint: true,
|
||||
viewMode: 'grid',
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -63,4 +63,5 @@ export interface DocumentListState {
|
|||
folders: Folder[];
|
||||
collapsedFolders: string[];
|
||||
showHint: boolean;
|
||||
viewMode?: 'list' | 'grid';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,6 +67,9 @@ export default {
|
|||
},
|
||||
},
|
||||
},
|
||||
screens: {
|
||||
xs: '410px', // custom xs breakpoint
|
||||
},
|
||||
},
|
||||
plugins: [typography],
|
||||
} satisfies Config;
|
||||
|
|
|
|||
Loading…
Reference in a new issue