Add server side document store
This commit is contained in:
parent
297396579b
commit
4bd443aea4
7 changed files with 263 additions and 3 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -40,3 +40,6 @@ yarn-error.log*
|
|||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# documents
|
||||
/docstore
|
||||
|
|
|
|||
83
src/app/api/documents/route.ts
Normal file
83
src/app/api/documents/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue