Skip to content

Commit 4bd443a

Browse files
committed
Add server side document store
1 parent 2973965 commit 4bd443a

File tree

7 files changed

+263
-3
lines changed

7 files changed

+263
-3
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,6 @@ yarn-error.log*
4040
# typescript
4141
*.tsbuildinfo
4242
next-env.d.ts
43+
44+
# documents
45+
/docstore

src/app/api/documents/route.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { writeFile, readFile, readdir, mkdir } from 'fs/promises';
2+
import { NextRequest, NextResponse } from 'next/server';
3+
import path from 'path';
4+
5+
const DOCS_DIR = path.join(process.cwd(), 'docstore');
6+
7+
// Ensure documents directory exists
8+
async function ensureDocsDir() {
9+
try {
10+
await mkdir(DOCS_DIR, { recursive: true });
11+
} catch (error) {
12+
console.error('Error creating documents directory:', error);
13+
}
14+
}
15+
16+
export async function POST(req: NextRequest) {
17+
try {
18+
await ensureDocsDir();
19+
const data = await req.json();
20+
21+
// Save document metadata and content
22+
for (const doc of data.documents) {
23+
const docPath = path.join(DOCS_DIR, `${doc.id}.json`);
24+
const contentPath = path.join(DOCS_DIR, `${doc.id}.${doc.type}`);
25+
26+
// Save metadata (excluding binary data)
27+
const metadata = {
28+
id: doc.id,
29+
name: doc.name,
30+
size: doc.size,
31+
lastModified: doc.lastModified,
32+
type: doc.type
33+
};
34+
35+
await writeFile(docPath, JSON.stringify(metadata));
36+
37+
// Save content as raw binary file with proper handling for both PDF and EPUB
38+
const content = Buffer.from(new Uint8Array(doc.data));
39+
await writeFile(contentPath, content);
40+
}
41+
42+
return NextResponse.json({ success: true });
43+
} catch (error) {
44+
console.error('Error saving documents:', error);
45+
return NextResponse.json({ error: 'Failed to save documents' }, { status: 500 });
46+
}
47+
}
48+
49+
export async function GET() {
50+
try {
51+
await ensureDocsDir();
52+
const documents = [];
53+
54+
const files = await readdir(DOCS_DIR);
55+
const jsonFiles = files.filter(file => file.endsWith('.json'));
56+
57+
for (const file of jsonFiles) {
58+
const docPath = path.join(DOCS_DIR, file);
59+
60+
try {
61+
const metadata = JSON.parse(await readFile(docPath, 'utf8'));
62+
const contentPath = path.join(DOCS_DIR, `${metadata.id}.${metadata.type}`);
63+
const content = await readFile(contentPath);
64+
65+
// Ensure consistent array format for both PDF and EPUB
66+
const uint8Array = new Uint8Array(content);
67+
68+
documents.push({
69+
...metadata,
70+
data: Array.from(uint8Array)
71+
});
72+
} catch (error) {
73+
console.error(`Error processing file ${file}:`, error);
74+
continue;
75+
}
76+
}
77+
78+
return NextResponse.json({ documents });
79+
} catch (error) {
80+
console.error('Error loading documents:', error);
81+
return NextResponse.json({ error: 'Failed to load documents' }, { status: 500 });
82+
}
83+
}

src/components/SettingsModal.tsx

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild, Listbox,
55
import { useTheme } from '@/contexts/ThemeContext';
66
import { useConfig } from '@/contexts/ConfigContext';
77
import { ChevronUpDownIcon, CheckIcon } from './icons/Icons';
8+
import { indexedDBService } from '@/utils/indexedDB';
9+
import { useDocuments } from '@/contexts/DocumentContext';
10+
11+
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
812

913
interface SettingsModalProps {
1014
isOpen: boolean;
@@ -20,15 +24,41 @@ const themes = [
2024
export function SettingsModal({ isOpen, setIsOpen }: SettingsModalProps) {
2125
const { theme, setTheme } = useTheme();
2226
const { apiKey, baseUrl, updateConfig } = useConfig();
27+
const { refreshPDFs, refreshEPUBs } = useDocuments();
2328
const [localApiKey, setLocalApiKey] = useState(apiKey);
2429
const [localBaseUrl, setLocalBaseUrl] = useState(baseUrl);
30+
const [isSyncing, setIsSyncing] = useState(false);
31+
const [isLoading, setIsLoading] = useState(false);
2532
const selectedTheme = themes.find(t => t.id === theme) || themes[0];
2633

2734
useEffect(() => {
2835
setLocalApiKey(apiKey);
2936
setLocalBaseUrl(baseUrl);
3037
}, [apiKey, baseUrl]);
3138

39+
const handleSync = async () => {
40+
try {
41+
setIsSyncing(true);
42+
await indexedDBService.syncToServer();
43+
} catch (error) {
44+
console.error('Sync failed:', error);
45+
} finally {
46+
setIsSyncing(false);
47+
}
48+
};
49+
50+
const handleLoad = async () => {
51+
try {
52+
setIsLoading(true);
53+
await indexedDBService.loadFromServer();
54+
await Promise.all([refreshPDFs(), refreshEPUBs()]);
55+
} catch (error) {
56+
console.error('Load failed:', error);
57+
} finally {
58+
setIsLoading(false);
59+
}
60+
};
61+
3262
return (
3363
<Transition appear show={isOpen} as={Fragment}>
3464
<Dialog as="div" className="relative z-50" onClose={() => setIsOpen(false)}>
@@ -130,6 +160,38 @@ export function SettingsModal({ isOpen, setIsOpen }: SettingsModalProps) {
130160
className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent"
131161
/>
132162
</div>
163+
164+
{isDev && <div className="space-y-2">
165+
<label className="block text-sm font-medium text-foreground">Document Sync</label>
166+
<div className="flex items-center justify-between">
167+
<div className="space-y-2">
168+
<div className="flex gap-2">
169+
<button
170+
onClick={handleSync}
171+
disabled={isSyncing || isLoading}
172+
className="inline-flex justify-center rounded-lg bg-background px-4 py-2 text-sm
173+
font-medium text-foreground hover:bg-background/90 focus:outline-none
174+
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
175+
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent
176+
disabled:opacity-50"
177+
>
178+
{isSyncing ? 'Saving...' : 'Save to Server'}
179+
</button>
180+
<button
181+
onClick={handleLoad}
182+
disabled={isSyncing || isLoading}
183+
className="inline-flex justify-center rounded-lg bg-background px-4 py-2 text-sm
184+
font-medium text-foreground hover:bg-background/90 focus:outline-none
185+
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
186+
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent
187+
disabled:opacity-50"
188+
>
189+
{isLoading ? 'Loading...' : 'Load from Server'}
190+
</button>
191+
</div>
192+
</div>
193+
</div>
194+
</div>}
133195
</div>
134196
</div>
135197

src/contexts/DocumentContext.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ interface DocumentContextType {
1717
addEPUBDocument: (file: File) => Promise<string>;
1818
removeEPUBDocument: (id: string) => Promise<void>;
1919
isEPUBLoading: boolean;
20+
21+
refreshPDFs: () => Promise<void>;
22+
refreshEPUBs: () => Promise<void>;
2023
}
2124

2225
const DocumentContext = createContext<DocumentContextType | undefined>(undefined);
@@ -26,14 +29,16 @@ export function DocumentProvider({ children }: { children: ReactNode }) {
2629
documents: pdfDocs,
2730
addDocument: addPDFDocument,
2831
removeDocument: removePDFDocument,
29-
isLoading: isPDFLoading
32+
isLoading: isPDFLoading,
33+
refresh: refreshPDFs
3034
} = usePDFDocuments();
3135

3236
const {
3337
documents: epubDocs,
3438
addDocument: addEPUBDocument,
3539
removeDocument: removeEPUBDocument,
36-
isLoading: isEPUBLoading
40+
isLoading: isEPUBLoading,
41+
refresh: refreshEPUBs
3742
} = useEPUBDocuments();
3843

3944
return (
@@ -45,7 +50,9 @@ export function DocumentProvider({ children }: { children: ReactNode }) {
4550
epubDocs,
4651
addEPUBDocument,
4752
removeEPUBDocument,
48-
isEPUBLoading
53+
isEPUBLoading,
54+
refreshPDFs,
55+
refreshEPUBs
4956
}}>
5057
{children}
5158
</DocumentContext.Provider>

src/hooks/useEPUBDocuments.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,10 +62,25 @@ export function useEPUBDocuments() {
6262
}
6363
}, []);
6464

65+
const refresh = useCallback(async () => {
66+
if (isDBReady) {
67+
setIsLoading(true);
68+
try {
69+
const docs = await indexedDBService.getAllEPUBDocuments();
70+
setDocuments(docs);
71+
} catch (error) {
72+
console.error('Failed to refresh documents:', error);
73+
} finally {
74+
setIsLoading(false);
75+
}
76+
}
77+
}, [isDBReady]);
78+
6579
return {
6680
documents,
6781
isLoading,
6882
addDocument,
6983
removeDocument,
84+
refresh,
7085
};
7186
}

src/hooks/usePDFDocuments.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,10 +71,25 @@ export function usePDFDocuments() {
7171
}
7272
}, []);
7373

74+
const refresh = useCallback(async () => {
75+
if (isDBReady) {
76+
setIsLoading(true);
77+
try {
78+
const docs = await indexedDBService.getAllDocuments();
79+
setDocuments(docs);
80+
} catch (error) {
81+
console.error('Failed to refresh documents:', error);
82+
} finally {
83+
setIsLoading(false);
84+
}
85+
}
86+
}, [isDBReady]);
87+
7488
return {
7589
documents,
7690
isLoading,
7791
addDocument,
7892
removeDocument,
93+
refresh, // Add refresh to return value
7994
};
8095
}

src/utils/indexedDB.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -487,6 +487,81 @@ class IndexedDBService {
487487
}
488488
});
489489
}
490+
491+
async syncToServer(): Promise<{ lastSync: number }> {
492+
const pdfDocs = await this.getAllDocuments();
493+
const epubDocs = await this.getAllEPUBDocuments();
494+
495+
const documents = [];
496+
497+
// Process PDF documents - store the raw PDF data
498+
for (const doc of pdfDocs) {
499+
const arrayBuffer = await doc.data.arrayBuffer();
500+
const uint8Array = new Uint8Array(arrayBuffer);
501+
documents.push({
502+
...doc,
503+
type: 'pdf',
504+
data: Array.from(uint8Array) // Convert to regular array for JSON serialization
505+
});
506+
}
507+
508+
// Process EPUB documents
509+
for (const doc of epubDocs) {
510+
documents.push({
511+
...doc,
512+
type: 'epub',
513+
data: Array.from(new Uint8Array(doc.data)) // Convert to regular array for JSON serialization
514+
});
515+
}
516+
517+
const response = await fetch('/api/documents', {
518+
method: 'POST',
519+
headers: { 'Content-Type': 'application/json' },
520+
body: JSON.stringify({ documents })
521+
});
522+
523+
if (!response.ok) {
524+
throw new Error('Failed to sync documents to server');
525+
}
526+
527+
return { lastSync: Date.now() };
528+
}
529+
530+
async loadFromServer(): Promise<{ lastSync: number }> {
531+
const response = await fetch('/api/documents');
532+
if (!response.ok) {
533+
throw new Error('Failed to fetch documents from server');
534+
}
535+
536+
const { documents } = await response.json();
537+
538+
// Process each document
539+
for (const doc of documents) {
540+
if (doc.type === 'pdf') {
541+
// Create a Blob from the raw binary data
542+
const blob = new Blob([new Uint8Array(doc.data)], { type: 'application/pdf' });
543+
await this.addDocument({
544+
id: doc.id,
545+
name: doc.name,
546+
size: doc.size,
547+
lastModified: doc.lastModified,
548+
data: blob
549+
});
550+
} else if (doc.type === 'epub') {
551+
// Convert the numeric array back to ArrayBuffer for EPUB
552+
const uint8Array = new Uint8Array(doc.data);
553+
await this.addEPUBDocument({
554+
id: doc.id,
555+
name: doc.name,
556+
size: doc.size,
557+
lastModified: doc.lastModified,
558+
data: uint8Array.buffer
559+
});
560+
}
561+
}
562+
563+
return { lastSync: Date.now() };
564+
}
490565
}
491566

492567
// Make sure we export a singleton instance

0 commit comments

Comments
 (0)