Add server side document store

This commit is contained in:
Richard Roberson 2025-02-11 01:29:02 -07:00
parent 297396579b
commit 4bd443aea4
7 changed files with 263 additions and 3 deletions

3
.gitignore vendored
View file

@ -40,3 +40,6 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
# documents
/docstore

View file

@ -0,0 +1,83 @@
import { writeFile, readFile, readdir, mkdir } from 'fs/promises';
import { NextRequest, NextResponse } from 'next/server';
import path from 'path';
const DOCS_DIR = path.join(process.cwd(), 'docstore');
// Ensure documents directory exists
async function ensureDocsDir() {
try {
await mkdir(DOCS_DIR, { recursive: true });
} catch (error) {
console.error('Error creating documents directory:', error);
}
}
export async function POST(req: NextRequest) {
try {
await ensureDocsDir();
const data = await req.json();
// Save document metadata and content
for (const doc of data.documents) {
const docPath = path.join(DOCS_DIR, `${doc.id}.json`);
const contentPath = path.join(DOCS_DIR, `${doc.id}.${doc.type}`);
// Save metadata (excluding binary data)
const metadata = {
id: doc.id,
name: doc.name,
size: doc.size,
lastModified: doc.lastModified,
type: doc.type
};
await writeFile(docPath, JSON.stringify(metadata));
// Save content as raw binary file with proper handling for both PDF and EPUB
const content = Buffer.from(new Uint8Array(doc.data));
await writeFile(contentPath, content);
}
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error saving documents:', error);
return NextResponse.json({ error: 'Failed to save documents' }, { status: 500 });
}
}
export async function GET() {
try {
await ensureDocsDir();
const documents = [];
const files = await readdir(DOCS_DIR);
const jsonFiles = files.filter(file => file.endsWith('.json'));
for (const file of jsonFiles) {
const docPath = path.join(DOCS_DIR, file);
try {
const metadata = JSON.parse(await readFile(docPath, 'utf8'));
const contentPath = path.join(DOCS_DIR, `${metadata.id}.${metadata.type}`);
const content = await readFile(contentPath);
// Ensure consistent array format for both PDF and EPUB
const uint8Array = new Uint8Array(content);
documents.push({
...metadata,
data: Array.from(uint8Array)
});
} catch (error) {
console.error(`Error processing file ${file}:`, error);
continue;
}
}
return NextResponse.json({ documents });
} catch (error) {
console.error('Error loading documents:', error);
return NextResponse.json({ error: 'Failed to load documents' }, { status: 500 });
}
}

View file

@ -5,6 +5,10 @@ import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild, Listbox,
import { useTheme } from '@/contexts/ThemeContext';
import { useConfig } from '@/contexts/ConfigContext';
import { ChevronUpDownIcon, CheckIcon } from './icons/Icons';
import { indexedDBService } from '@/utils/indexedDB';
import { useDocuments } from '@/contexts/DocumentContext';
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
interface SettingsModalProps {
isOpen: boolean;
@ -20,8 +24,11 @@ const themes = [
export function SettingsModal({ isOpen, setIsOpen }: SettingsModalProps) {
const { theme, setTheme } = useTheme();
const { apiKey, baseUrl, updateConfig } = useConfig();
const { refreshPDFs, refreshEPUBs } = useDocuments();
const [localApiKey, setLocalApiKey] = useState(apiKey);
const [localBaseUrl, setLocalBaseUrl] = useState(baseUrl);
const [isSyncing, setIsSyncing] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const selectedTheme = themes.find(t => t.id === theme) || themes[0];
useEffect(() => {
@ -29,6 +36,29 @@ export function SettingsModal({ isOpen, setIsOpen }: SettingsModalProps) {
setLocalBaseUrl(baseUrl);
}, [apiKey, baseUrl]);
const handleSync = async () => {
try {
setIsSyncing(true);
await indexedDBService.syncToServer();
} catch (error) {
console.error('Sync failed:', error);
} finally {
setIsSyncing(false);
}
};
const handleLoad = async () => {
try {
setIsLoading(true);
await indexedDBService.loadFromServer();
await Promise.all([refreshPDFs(), refreshEPUBs()]);
} catch (error) {
console.error('Load failed:', error);
} finally {
setIsLoading(false);
}
};
return (
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={() => setIsOpen(false)}>
@ -130,6 +160,38 @@ export function SettingsModal({ isOpen, setIsOpen }: SettingsModalProps) {
className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
{isDev && <div className="space-y-2">
<label className="block text-sm font-medium text-foreground">Document Sync</label>
<div className="flex items-center justify-between">
<div className="space-y-2">
<div className="flex gap-2">
<button
onClick={handleSync}
disabled={isSyncing || isLoading}
className="inline-flex justify-center rounded-lg bg-background px-4 py-2 text-sm
font-medium text-foreground hover:bg-background/90 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-accent
disabled:opacity-50"
>
{isSyncing ? 'Saving...' : 'Save to Server'}
</button>
<button
onClick={handleLoad}
disabled={isSyncing || isLoading}
className="inline-flex justify-center rounded-lg bg-background px-4 py-2 text-sm
font-medium text-foreground hover:bg-background/90 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-accent
disabled:opacity-50"
>
{isLoading ? 'Loading...' : 'Load from Server'}
</button>
</div>
</div>
</div>
</div>}
</div>
</div>

View file

@ -17,6 +17,9 @@ interface DocumentContextType {
addEPUBDocument: (file: File) => Promise<string>;
removeEPUBDocument: (id: string) => Promise<void>;
isEPUBLoading: boolean;
refreshPDFs: () => Promise<void>;
refreshEPUBs: () => Promise<void>;
}
const DocumentContext = createContext<DocumentContextType | undefined>(undefined);
@ -26,14 +29,16 @@ export function DocumentProvider({ children }: { children: ReactNode }) {
documents: pdfDocs,
addDocument: addPDFDocument,
removeDocument: removePDFDocument,
isLoading: isPDFLoading
isLoading: isPDFLoading,
refresh: refreshPDFs
} = usePDFDocuments();
const {
documents: epubDocs,
addDocument: addEPUBDocument,
removeDocument: removeEPUBDocument,
isLoading: isEPUBLoading
isLoading: isEPUBLoading,
refresh: refreshEPUBs
} = useEPUBDocuments();
return (
@ -45,7 +50,9 @@ export function DocumentProvider({ children }: { children: ReactNode }) {
epubDocs,
addEPUBDocument,
removeEPUBDocument,
isEPUBLoading
isEPUBLoading,
refreshPDFs,
refreshEPUBs
}}>
{children}
</DocumentContext.Provider>

View file

@ -62,10 +62,25 @@ export function useEPUBDocuments() {
}
}, []);
const refresh = useCallback(async () => {
if (isDBReady) {
setIsLoading(true);
try {
const docs = await indexedDBService.getAllEPUBDocuments();
setDocuments(docs);
} catch (error) {
console.error('Failed to refresh documents:', error);
} finally {
setIsLoading(false);
}
}
}, [isDBReady]);
return {
documents,
isLoading,
addDocument,
removeDocument,
refresh,
};
}

View file

@ -71,10 +71,25 @@ export function usePDFDocuments() {
}
}, []);
const refresh = useCallback(async () => {
if (isDBReady) {
setIsLoading(true);
try {
const docs = await indexedDBService.getAllDocuments();
setDocuments(docs);
} catch (error) {
console.error('Failed to refresh documents:', error);
} finally {
setIsLoading(false);
}
}
}, [isDBReady]);
return {
documents,
isLoading,
addDocument,
removeDocument,
refresh, // Add refresh to return value
};
}

View file

@ -487,6 +487,81 @@ class IndexedDBService {
}
});
}
async syncToServer(): Promise<{ lastSync: number }> {
const pdfDocs = await this.getAllDocuments();
const epubDocs = await this.getAllEPUBDocuments();
const documents = [];
// Process PDF documents - store the raw PDF data
for (const doc of pdfDocs) {
const arrayBuffer = await doc.data.arrayBuffer();
const uint8Array = new Uint8Array(arrayBuffer);
documents.push({
...doc,
type: 'pdf',
data: Array.from(uint8Array) // Convert to regular array for JSON serialization
});
}
// Process EPUB documents
for (const doc of epubDocs) {
documents.push({
...doc,
type: 'epub',
data: Array.from(new Uint8Array(doc.data)) // Convert to regular array for JSON serialization
});
}
const response = await fetch('/api/documents', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ documents })
});
if (!response.ok) {
throw new Error('Failed to sync documents to server');
}
return { lastSync: Date.now() };
}
async loadFromServer(): Promise<{ lastSync: number }> {
const response = await fetch('/api/documents');
if (!response.ok) {
throw new Error('Failed to fetch documents from server');
}
const { documents } = await response.json();
// Process each document
for (const doc of documents) {
if (doc.type === 'pdf') {
// Create a Blob from the raw binary data
const blob = new Blob([new Uint8Array(doc.data)], { type: 'application/pdf' });
await this.addDocument({
id: doc.id,
name: doc.name,
size: doc.size,
lastModified: doc.lastModified,
data: blob
});
} else if (doc.type === 'epub') {
// Convert the numeric array back to ArrayBuffer for EPUB
const uint8Array = new Uint8Array(doc.data);
await this.addEPUBDocument({
id: doc.id,
name: doc.name,
size: doc.size,
lastModified: doc.lastModified,
data: uint8Array.buffer
});
}
}
return { lastSync: Date.now() };
}
}
// Make sure we export a singleton instance