From d839d3cfa4bc370ae71e083e64d8571c5eea867d Mon Sep 17 00:00:00 2001 From: Richard Roberson Date: Tue, 11 Feb 2025 14:16:47 -0700 Subject: [PATCH] App settings upgrade + new themes --- src/app/api/documents/route.ts | 19 +- src/app/globals.css | 35 +++- src/components/ConfirmDialog.tsx | 110 ++++++++++ src/components/DocumentList.tsx | 87 ++------ src/components/SettingsModal.tsx | 350 +++++++++++++++++++------------ src/contexts/DocumentContext.tsx | 13 +- src/contexts/ThemeContext.tsx | 11 +- src/hooks/useEPUBDocuments.ts | 11 + src/hooks/usePDFDocuments.ts | 13 +- src/utils/indexedDB.ts | 58 +++++ 10 files changed, 483 insertions(+), 224 deletions(-) create mode 100644 src/components/ConfirmDialog.tsx diff --git a/src/app/api/documents/route.ts b/src/app/api/documents/route.ts index 46c7c76..679b0ea 100644 --- a/src/app/api/documents/route.ts +++ b/src/app/api/documents/route.ts @@ -1,4 +1,4 @@ -import { writeFile, readFile, readdir, mkdir } from 'fs/promises'; +import { writeFile, readFile, readdir, mkdir, unlink } from 'fs/promises'; import { NextRequest, NextResponse } from 'next/server'; import path from 'path'; @@ -81,3 +81,20 @@ export async function GET() { return NextResponse.json({ error: 'Failed to load documents' }, { status: 500 }); } } + +export async function DELETE() { + try { + await ensureDocsDir(); + const files = await readdir(DOCS_DIR); + + for (const file of files) { + const filePath = path.join(DOCS_DIR, file); + await unlink(filePath); + } + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Error deleting documents:', error); + return NextResponse.json({ error: 'Failed to delete documents' }, { status: 500 }); + } +} diff --git a/src/app/globals.css b/src/app/globals.css index 3674127..450269a 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -5,11 +5,11 @@ :root, html.light { --background: #ffffff; - --foreground: #171717; - --base: #f5f5f5; - --offbase: #dbdbdb; + --foreground: #2d3748; + --base: #f7fafc; + --offbase: #e2e8f0; --accent: #ef4444; - --muted: #737373; + --muted: #718096; } html.dark { @@ -21,6 +21,33 @@ html.dark { --muted: #a3a3a3; } +html.aqua { + --background: #020617; + --foreground: #e2e8f0; + --base: #0f172a; + --offbase: #1e293b; + --accent: #38bdf8; + --muted: #94a3b8; +} + +html.forest { + --background: #0a0f0c; + --foreground: #d4e8d0; + --base: #111a15; + --offbase: #1a2820; + --accent: #4ade80; + --muted: #7c8f85; +} + +html.vibrant { + --background: #090420; + --foreground: #ffffff; + --base: #1a0942; + --offbase: #2d1163; + --accent: #ff3d81; + --muted: #9d7dcc; +} + /* Ensure background color is set before content loads */ html { background-color: var(--background); diff --git a/src/components/ConfirmDialog.tsx b/src/components/ConfirmDialog.tsx new file mode 100644 index 0000000..e8bca7e --- /dev/null +++ b/src/components/ConfirmDialog.tsx @@ -0,0 +1,110 @@ +'use client'; + +import { Fragment, useEffect } from 'react'; +import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react'; + +interface ConfirmDialogProps { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + title: string; + message: string; + confirmText?: string; + cancelText?: string; + isDangerous?: boolean; +} + +export function ConfirmDialog({ + isOpen, + onClose, + onConfirm, + title, + message, + confirmText = 'Confirm', + cancelText = 'Cancel', + isDangerous = false, +}: ConfirmDialogProps) { + useEffect(() => { + if (!isOpen) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + onConfirm(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isOpen, onConfirm]); + + return ( + + + +
+ + +
+
+ + + + {title} + +
+

{message}

+
+ +
+ + +
+
+
+
+
+
+
+ ); +} diff --git a/src/components/DocumentList.tsx b/src/components/DocumentList.tsx index a81c5ea..f78ec6e 100644 --- a/src/components/DocumentList.tsx +++ b/src/components/DocumentList.tsx @@ -1,9 +1,10 @@ import Link from 'next/link'; -import { Button, Dialog } from '@headlessui/react'; -import { Transition, TransitionChild, DialogPanel, DialogTitle } from '@headlessui/react'; -import { Fragment, useState } from 'react'; +import { Button } from '@headlessui/react'; +import { Transition } from '@headlessui/react'; +import { useState } from 'react'; import { useDocuments } from '@/contexts/DocumentContext'; import { PDFIcon, EPUBIcon } from '@/components/icons/Icons'; +import { ConfirmDialog } from '@/components/ConfirmDialog'; type DocumentToDelete = { id: string; @@ -21,7 +22,6 @@ export function DocumentList() { isEPUBLoading, } = useDocuments(); - const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [documentToDelete, setDocumentToDelete] = useState(null); const handleDelete = async () => { @@ -33,7 +33,6 @@ export function DocumentList() { } else { await removeEPUB(documentToDelete.id); } - setIsDeleteDialogOpen(false); setDocumentToDelete(null); } catch (err) { console.error('Failed to remove document:', err); @@ -101,10 +100,7 @@ export function DocumentList() { - - - - - - - - + setDocumentToDelete(null)} + onConfirm={handleDelete} + title="Delete Document" + message={`Are you sure you want to delete ${documentToDelete?.name ? documentToDelete.name : 'this document'}? This action cannot be undone.`} + confirmText="Delete" + isDangerous={true} + /> ); } diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index bdc4517..eb9309c 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -1,12 +1,15 @@ 'use client'; -import { Fragment, useState, useEffect } from 'react'; +import { Fragment, useState, useEffect, useCallback } from 'react'; import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild, Listbox, ListboxButton, ListboxOptions, ListboxOption, Button } from '@headlessui/react'; 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'; +import { setItem, getItem } from '@/utils/indexedDB'; +import { ConfirmDialog } from '@/components/ConfirmDialog'; +import { THEMES } from '@/contexts/ThemeContext'; const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null; @@ -15,26 +18,38 @@ interface SettingsModalProps { setIsOpen: (isOpen: boolean) => void; } -const themes = [ - { id: 'light' as const, name: 'Light' }, - { id: 'dark' as const, name: 'Dark' }, - { id: 'system' as const, name: 'System' }, -]; +const themes = THEMES.map(id => ({ + id, + name: id.charAt(0).toUpperCase() + id.slice(1) +})); export function SettingsModal({ isOpen, setIsOpen }: SettingsModalProps) { const { theme, setTheme } = useTheme(); const { apiKey, baseUrl, updateConfig } = useConfig(); - const { refreshPDFs, refreshEPUBs } = useDocuments(); + const { refreshPDFs, refreshEPUBs, clearPDFs, clearEPUBs } = 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]; + const [showClearLocalConfirm, setShowClearLocalConfirm] = useState(false); + const [showClearServerConfirm, setShowClearServerConfirm] = useState(false); + + // set firstVisit on initial load + const checkFirstVist = useCallback(async () => { + if (!isDev) return; + const firstVisit = await getItem('firstVisit'); + if (firstVisit == null) { + await setItem('firstVisit', 'true'); + setIsOpen(true); + } + }, [setIsOpen]); useEffect(() => { + checkFirstVist(); setLocalApiKey(apiKey); setLocalBaseUrl(baseUrl); - }, [apiKey, baseUrl]); + }, [apiKey, baseUrl, checkFirstVist]); const handleSync = async () => { try { @@ -59,45 +74,65 @@ export function SettingsModal({ isOpen, setIsOpen }: SettingsModalProps) { } }; - return ( - - setIsOpen(false)}> - -
- + const handleClearLocal = async () => { + await clearPDFs(); + await clearEPUBs(); + setShowClearLocalConfirm(false); + }; -
-
- - - - Settings - -
-
-
- - setTheme(newTheme.id)}> -
+ const handleClearServer = async () => { + try { + const response = await fetch('/api/documents', { + method: 'DELETE', + }); + if (!response.ok) { + throw new Error('Failed to delete server documents'); + } + } catch (error) { + console.error('Delete failed:', error); + } + setShowClearServerConfirm(false); + }; + + return ( + <> + + setIsOpen(false)}> + +
+ + +
+
+ + + + Settings + +
+
+
+ + setTheme(newTheme.id)}> {selectedTheme.name} @@ -110,13 +145,12 @@ export function SettingsModal({ isOpen, setIsOpen }: SettingsModalProps) { leaveFrom="opacity-100" leaveTo="opacity-0" > - + {themes.map((theme) => ( - `relative cursor-pointer select-none py-2 pl-10 pr-4 ${ - active ? 'bg-accent/10 text-accent' : 'text-foreground' + `cursor-pointer select-none py-2 pl-10 pr-4 ${active ? 'bg-accent/10 text-accent' : 'text-foreground' }` } value={theme} @@ -137,101 +171,141 @@ export function SettingsModal({ isOpen, setIsOpen }: SettingsModalProps) { ))} + +
+ +
+ + setLocalApiKey(e.target.value)} + className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent" + /> +
+ +
+ + setLocalBaseUrl(e.target.value)} + className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent" + /> +
+ + {isDev &&
+ +
+ +
- -
+
} -
- - setLocalApiKey(e.target.value)} - className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent" - /> -
- -
- - setLocalBaseUrl(e.target.value)} - className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent" - /> -
- - {isDev &&
- -
-
-
- - -
+
+ +
+ + {isDev && }
-
} +
-
-
- - -
-
-
+
+ + +
+ + +
-
-
-
+
+
+ + setShowClearLocalConfirm(false)} + onConfirm={handleClearLocal} + title="Delete Local Documents" + message="Are you sure you want to delete all local documents? This action cannot be undone." + confirmText="Delete" + isDangerous={true} + /> + + setShowClearServerConfirm(false)} + onConfirm={handleClearServer} + title="Delete Server Documents" + message="Are you sure you want to delete all documents from the server? This action cannot be undone." + confirmText="Delete" + isDangerous={true} + /> + ); } diff --git a/src/contexts/DocumentContext.tsx b/src/contexts/DocumentContext.tsx index 2c12951..8fa01e5 100644 --- a/src/contexts/DocumentContext.tsx +++ b/src/contexts/DocumentContext.tsx @@ -20,6 +20,9 @@ interface DocumentContextType { refreshPDFs: () => Promise; refreshEPUBs: () => Promise; + + clearPDFs: () => Promise; + clearEPUBs: () => Promise; } const DocumentContext = createContext(undefined); @@ -30,7 +33,8 @@ export function DocumentProvider({ children }: { children: ReactNode }) { addDocument: addPDFDocument, removeDocument: removePDFDocument, isLoading: isPDFLoading, - refresh: refreshPDFs + refresh: refreshPDFs, + clearDocuments: clearPDFs } = usePDFDocuments(); const { @@ -38,7 +42,8 @@ export function DocumentProvider({ children }: { children: ReactNode }) { addDocument: addEPUBDocument, removeDocument: removeEPUBDocument, isLoading: isEPUBLoading, - refresh: refreshEPUBs + refresh: refreshEPUBs, + clearDocuments: clearEPUBs } = useEPUBDocuments(); return ( @@ -52,7 +57,9 @@ export function DocumentProvider({ children }: { children: ReactNode }) { removeEPUBDocument, isEPUBLoading, refreshPDFs, - refreshEPUBs + refreshEPUBs, + clearPDFs, + clearEPUBs }}> {children} diff --git a/src/contexts/ThemeContext.tsx b/src/contexts/ThemeContext.tsx index d3b3f24..2562594 100644 --- a/src/contexts/ThemeContext.tsx +++ b/src/contexts/ThemeContext.tsx @@ -2,7 +2,8 @@ import { createContext, useContext, useEffect, useState } from 'react'; -type Theme = 'light' | 'dark' | 'system'; +const THEMES = ['system', 'light', 'dark', 'aqua', 'forest', 'vibrant'] as const; +type Theme = (typeof THEMES)[number]; interface ThemeContextType { theme: Theme; @@ -16,7 +17,7 @@ const getSystemTheme = () => { return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; }; -const getEffectiveTheme = (theme: Theme): 'light' | 'dark' => { +const getEffectiveTheme = (theme: Theme): Theme => { if (theme === 'system') { return getSystemTheme(); } @@ -33,7 +34,7 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) { const root = window.document.documentElement; const effectiveTheme = getEffectiveTheme(newTheme); - root.classList.remove('light', 'dark'); + root.classList.remove(...THEMES); root.classList.add(effectiveTheme); root.style.colorScheme = effectiveTheme; @@ -57,7 +58,7 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) { if (theme === 'system') { const effectiveTheme = getSystemTheme(); const root = window.document.documentElement; - root.classList.remove('light', 'dark'); + root.classList.remove(...THEMES); root.classList.add(effectiveTheme); root.style.colorScheme = effectiveTheme; } @@ -81,3 +82,5 @@ export function useTheme() { } return context; } + +export { THEMES }; diff --git a/src/hooks/useEPUBDocuments.ts b/src/hooks/useEPUBDocuments.ts index 85c318d..fe48243 100644 --- a/src/hooks/useEPUBDocuments.ts +++ b/src/hooks/useEPUBDocuments.ts @@ -76,11 +76,22 @@ export function useEPUBDocuments() { } }, [isDBReady]); + const clearDocuments = useCallback(async (): Promise => { + try { + await indexedDBService.clearEPUBDocuments(); + setDocuments([]); + } catch (error) { + console.error('Failed to clear EPUB documents:', error); + throw error; + } + }, []); + return { documents, isLoading, addDocument, removeDocument, refresh, + clearDocuments, // Add clearDocuments to return value }; } diff --git a/src/hooks/usePDFDocuments.ts b/src/hooks/usePDFDocuments.ts index 58ecc0d..c413d3a 100644 --- a/src/hooks/usePDFDocuments.ts +++ b/src/hooks/usePDFDocuments.ts @@ -85,11 +85,22 @@ export function usePDFDocuments() { } }, [isDBReady]); + const clearDocuments = useCallback(async (): Promise => { + try { + await indexedDBService.clearPDFDocuments(); + setDocuments([]); + } catch (error) { + console.error('Failed to clear PDF documents:', error); + throw error; + } + }, []); + return { documents, isLoading, addDocument, removeDocument, - refresh, // Add refresh to return value + refresh, + clearDocuments, // Add clearDocuments to return value }; } diff --git a/src/utils/indexedDB.ts b/src/utils/indexedDB.ts index 77199eb..fcfe8cc 100644 --- a/src/utils/indexedDB.ts +++ b/src/utils/indexedDB.ts @@ -562,6 +562,64 @@ class IndexedDBService { return { lastSync: Date.now() }; } + + async clearPDFDocuments(): Promise { + if (!this.db) { + await this.init(); + } + + return new Promise((resolve, reject) => { + try { + console.log('Clearing all PDF documents'); + const transaction = this.db!.transaction([PDF_STORE_NAME], 'readwrite'); + const store = transaction.objectStore(PDF_STORE_NAME); + const request = store.clear(); + + request.onerror = (event) => { + const error = (event.target as IDBRequest).error; + console.error('Error clearing PDF documents:', error); + reject(error); + }; + + transaction.oncomplete = () => { + console.log('All PDF documents cleared successfully'); + resolve(); + }; + } catch (error) { + console.error('Error in clearPDFDocuments transaction:', error); + reject(error); + } + }); + } + + async clearEPUBDocuments(): Promise { + if (!this.db) { + await this.init(); + } + + return new Promise((resolve, reject) => { + try { + console.log('Clearing all EPUB documents'); + const transaction = this.db!.transaction([EPUB_STORE_NAME], 'readwrite'); + const store = transaction.objectStore(EPUB_STORE_NAME); + const request = store.clear(); + + request.onerror = (event) => { + const error = (event.target as IDBRequest).error; + console.error('Error clearing EPUB documents:', error); + reject(error); + }; + + transaction.oncomplete = () => { + console.log('All EPUB documents cleared successfully'); + resolve(); + }; + } catch (error) { + console.error('Error in clearEPUBDocuments transaction:', error); + reject(error); + } + }); + } } // Make sure we export a singleton instance