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
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
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 { useTheme } from '@/contexts/ThemeContext';
|
||||||
import { useConfig } from '@/contexts/ConfigContext';
|
import { useConfig } from '@/contexts/ConfigContext';
|
||||||
import { ChevronUpDownIcon, CheckIcon } from './icons/Icons';
|
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 {
|
interface SettingsModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -20,8 +24,11 @@ const themes = [
|
||||||
export function SettingsModal({ isOpen, setIsOpen }: SettingsModalProps) {
|
export function SettingsModal({ isOpen, setIsOpen }: SettingsModalProps) {
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
const { apiKey, baseUrl, updateConfig } = useConfig();
|
const { apiKey, baseUrl, updateConfig } = useConfig();
|
||||||
|
const { refreshPDFs, refreshEPUBs } = useDocuments();
|
||||||
const [localApiKey, setLocalApiKey] = useState(apiKey);
|
const [localApiKey, setLocalApiKey] = useState(apiKey);
|
||||||
const [localBaseUrl, setLocalBaseUrl] = useState(baseUrl);
|
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];
|
const selectedTheme = themes.find(t => t.id === theme) || themes[0];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -29,6 +36,29 @@ export function SettingsModal({ isOpen, setIsOpen }: SettingsModalProps) {
|
||||||
setLocalBaseUrl(baseUrl);
|
setLocalBaseUrl(baseUrl);
|
||||||
}, [apiKey, 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 (
|
return (
|
||||||
<Transition appear show={isOpen} as={Fragment}>
|
<Transition appear show={isOpen} as={Fragment}>
|
||||||
<Dialog as="div" className="relative z-50" onClose={() => setIsOpen(false)}>
|
<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"
|
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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,9 @@ interface DocumentContextType {
|
||||||
addEPUBDocument: (file: File) => Promise<string>;
|
addEPUBDocument: (file: File) => Promise<string>;
|
||||||
removeEPUBDocument: (id: string) => Promise<void>;
|
removeEPUBDocument: (id: string) => Promise<void>;
|
||||||
isEPUBLoading: boolean;
|
isEPUBLoading: boolean;
|
||||||
|
|
||||||
|
refreshPDFs: () => Promise<void>;
|
||||||
|
refreshEPUBs: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DocumentContext = createContext<DocumentContextType | undefined>(undefined);
|
const DocumentContext = createContext<DocumentContextType | undefined>(undefined);
|
||||||
|
|
@ -26,14 +29,16 @@ export function DocumentProvider({ children }: { children: ReactNode }) {
|
||||||
documents: pdfDocs,
|
documents: pdfDocs,
|
||||||
addDocument: addPDFDocument,
|
addDocument: addPDFDocument,
|
||||||
removeDocument: removePDFDocument,
|
removeDocument: removePDFDocument,
|
||||||
isLoading: isPDFLoading
|
isLoading: isPDFLoading,
|
||||||
|
refresh: refreshPDFs
|
||||||
} = usePDFDocuments();
|
} = usePDFDocuments();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
documents: epubDocs,
|
documents: epubDocs,
|
||||||
addDocument: addEPUBDocument,
|
addDocument: addEPUBDocument,
|
||||||
removeDocument: removeEPUBDocument,
|
removeDocument: removeEPUBDocument,
|
||||||
isLoading: isEPUBLoading
|
isLoading: isEPUBLoading,
|
||||||
|
refresh: refreshEPUBs
|
||||||
} = useEPUBDocuments();
|
} = useEPUBDocuments();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -45,7 +50,9 @@ export function DocumentProvider({ children }: { children: ReactNode }) {
|
||||||
epubDocs,
|
epubDocs,
|
||||||
addEPUBDocument,
|
addEPUBDocument,
|
||||||
removeEPUBDocument,
|
removeEPUBDocument,
|
||||||
isEPUBLoading
|
isEPUBLoading,
|
||||||
|
refreshPDFs,
|
||||||
|
refreshEPUBs
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{children}
|
||||||
</DocumentContext.Provider>
|
</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 {
|
return {
|
||||||
documents,
|
documents,
|
||||||
isLoading,
|
isLoading,
|
||||||
addDocument,
|
addDocument,
|
||||||
removeDocument,
|
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 {
|
return {
|
||||||
documents,
|
documents,
|
||||||
isLoading,
|
isLoading,
|
||||||
addDocument,
|
addDocument,
|
||||||
removeDocument,
|
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
|
// Make sure we export a singleton instance
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue