diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6c309d7 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.env +.env.* +README.md +.next +node_modules +**/node_modules +docstore diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index dfe0e8d..723e0eb 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -10,7 +10,6 @@ jobs: timeout-minutes: 60 runs-on: ubuntu-latest env: - FFMPEG_BIN: /usr/bin/ffmpeg USE_EMBEDDED_WEED_MINI: true BASE_URL: http://127.0.0.1:3003 S3_ENDPOINT: http://127.0.0.1:8333 diff --git a/.gitignore b/.gitignore index 4014f63..55b2755 100644 --- a/.gitignore +++ b/.gitignore @@ -32,9 +32,8 @@ yarn-error.log* .pnpm-debug.log* # env files (can opt-in for committing if needed) -.env -.env.prod -.dockerignore +.env* +!.env.example *.creds # vercel diff --git a/compute/core/src/local-runtime.ts b/compute/core/src/local-runtime.ts index ad57513..604d8d7 100644 --- a/compute/core/src/local-runtime.ts +++ b/compute/core/src/local-runtime.ts @@ -25,6 +25,10 @@ export async function runWhisperAlignmentFromAudioBuffer(input: { export async function runPdfLayoutFromPdfBuffer(input: { documentId: string; pdfBytes: ArrayBuffer; + onPageStarted?: (input: { + pageNumber: number; + totalPages: number; + }) => void | Promise; onPageParsed?: (input: { pageNumber: number; totalPages: number; @@ -34,6 +38,7 @@ export async function runPdfLayoutFromPdfBuffer(input: { const parsed = await parsePdf({ documentId: input.documentId, pdfBytes: input.pdfBytes, + onPageStarted: input.onPageStarted, onPageParsed: input.onPageParsed, }); return { parsed }; diff --git a/compute/core/src/pdf/parse.ts b/compute/core/src/pdf/parse.ts index 6aef27d..a6dbe22 100644 --- a/compute/core/src/pdf/parse.ts +++ b/compute/core/src/pdf/parse.ts @@ -11,6 +11,10 @@ import { normalizeTextItemsForLayout } from './normalize-text'; interface ParsePdfInput { documentId: string; pdfBytes: ArrayBuffer; + onPageStarted?: (input: { + pageNumber: number; + totalPages: number; + }) => void | Promise; onPageParsed?: (input: { pageNumber: number; totalPages: number; @@ -57,6 +61,12 @@ export async function parsePdf(input: ParsePdfInput): Promise for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber += 1) { const pageStartedAt = Date.now(); + if (input.onPageStarted) { + await input.onPageStarted({ + pageNumber, + totalPages: pdf.numPages, + }); + } const page = await pdf.getPage(pageNumber); const viewport = page.getViewport({ scale: 1.0 }); const textContent = await page.getTextContent(); @@ -91,9 +101,9 @@ export async function parsePdf(input: ParsePdfInput): Promise pageImage: rendered.image, }); const merged = mergeTextWithRegions(regions, layoutTextItems); - if (textItems.length > 0 && merged.length === 0) { - throw new Error(`layout-merge-empty: page=${pageNumber} regions=${regions.length}`); - } + // Do not fail the full document parse when a page has text but model + // detection/assignment yields no merged regions. Emit an empty page so + // downstream playback/tts flows can naturally skip it. const blocks = merged .map((entry, readingOrder) => ({ diff --git a/compute/worker/src/pdf-progress.ts b/compute/worker/src/pdf-progress.ts new file mode 100644 index 0000000..756a256 --- /dev/null +++ b/compute/worker/src/pdf-progress.ts @@ -0,0 +1,25 @@ +import type { PdfLayoutProgress } from '@openreader/compute-core/api-contracts'; + +export function buildInferProgressForPageStart(input: { + pageNumber: number; + totalPages: number; +}): PdfLayoutProgress { + return { + totalPages: input.totalPages, + pagesParsed: Math.max(0, input.pageNumber - 1), + currentPage: input.pageNumber, + phase: 'infer', + }; +} + +export function buildInferProgressForPageParsed(input: { + pageNumber: number; + totalPages: number; +}): PdfLayoutProgress { + return { + totalPages: input.totalPages, + pagesParsed: input.pageNumber, + currentPage: input.pageNumber, + phase: 'infer', + }; +} diff --git a/compute/worker/src/server.ts b/compute/worker/src/server.ts index 2b9a04b..8c62d03 100644 --- a/compute/worker/src/server.ts +++ b/compute/worker/src/server.ts @@ -56,6 +56,7 @@ import { hashOpKey, } from './control-plane/jetstream'; import { type JsonCodec, createJsonCodec } from './control-plane/json-codec'; +import { buildInferProgressForPageParsed, buildInferProgressForPageStart } from './pdf-progress'; const JOBS_STREAM_NAME = 'compute_jobs'; const WHISPER_JOBS_SUBJECT = 'jobs.whisper'; @@ -874,7 +875,11 @@ async function main(): Promise { const parsed = alignSchema.parse(payload); const s3FetchStartedAt = Date.now(); - const audioBuffer = await readObjectByKey(parsed.audioObjectKey); + const audioBuffer = await withTimeout( + readObjectByKey(parsed.audioObjectKey), + whisperTimeoutMs, + 'whisper s3 fetch', + ); const s3FetchMs = Date.now() - s3FetchStartedAt; const computeStartedAt = Date.now(); @@ -908,7 +913,11 @@ async function main(): Promise { const parsed = layoutSchema.parse(payload); const s3FetchStartedAt = Date.now(); - const pdfBytes = await readObjectByKey(parsed.documentObjectKey); + const pdfBytes = await withTimeout( + readObjectByKey(parsed.documentObjectKey), + Math.max(pdfTimeoutMs, 1_000), + 'pdf s3 fetch', + ); const s3FetchMs = Date.now() - s3FetchStartedAt; let lastTotalPages = 0; @@ -921,17 +930,24 @@ async function main(): Promise { run: async (touchProgress) => runPdfLayoutFromPdfBuffer({ documentId: parsed.documentId, pdfBytes, + onPageStarted: async ({ pageNumber, totalPages }) => { + touchProgress(); + lastTotalPages = totalPages; + if (!hooks?.onProgress) return; + await hooks.onProgress(buildInferProgressForPageStart({ + pageNumber, + totalPages, + })); + }, onPageParsed: async ({ pageNumber, totalPages }) => { touchProgress(); lastTotalPages = totalPages; lastPagesParsed = pageNumber; if (!hooks?.onProgress) return; - await hooks.onProgress({ + await hooks.onProgress(buildInferProgressForPageParsed({ + pageNumber, totalPages, - pagesParsed: pageNumber, - currentPage: pageNumber, - phase: 'infer', - }); + })); }, }), }); diff --git a/package.json b/package.json index f509a63..eb62569 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "start:raw": "next start -p 3003", "lint": "next lint", "test": "playwright test", + "test:ci-env": "CI=true playwright test", "migrate": "node drizzle/scripts/migrate.mjs", "migrate-fs": "node scripts/migrate-fs-v2.mjs", "migrate-fs:dry-run": "node scripts/migrate-fs-v2.mjs --dry-run true", @@ -25,7 +26,6 @@ "compute:worker:dev": "pnpm --filter @openreader/compute-worker dev", "compute:worker:start": "pnpm --filter @openreader/compute-worker start", "compute:dev:compose": "docker compose --env-file compute/worker/.env -f compute/worker/docker-compose.yml up --build", - "compute:dev:watch": "docker compose --env-file compute/worker/.env -f compute/worker/docker-compose.yml up --watch", "lint:route-errors": "node scripts/check-route-error-responses.mjs" }, "dependencies": { @@ -64,6 +64,7 @@ "react": "^19.2.6", "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", + "react-dnd-touch-backend": "^16.0.1", "react-dom": "^19.2.6", "react-dropzone": "^14.4.1", "react-hot-toast": "^2.6.0", diff --git a/playwright.config.ts b/playwright.config.ts index 3c080b9..a1f9f25 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,5 +1,15 @@ import { defineConfig, devices } from '@playwright/test'; import 'dotenv/config'; +import fs from 'node:fs'; +import path from 'node:path'; +import dotenv from 'dotenv'; + +if (process.env.CI === 'true') { + const envCiPath = path.join(process.cwd(), '.env.ci'); + if (fs.existsSync(envCiPath)) { + dotenv.config({ path: envCiPath, override: true }); + } +} /** * See https://playwright.dev/docs/test-configuration. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1cebe16..f15c27b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -118,6 +118,9 @@ importers: react-dnd-html5-backend: specifier: ^16.0.1 version: 16.0.1 + react-dnd-touch-backend: + specifier: ^16.0.1 + version: 16.0.1 react-dom: specifier: ^19.2.6 version: 19.2.6(react@19.2.6) @@ -4249,6 +4252,9 @@ packages: react-dnd-html5-backend@16.0.1: resolution: {integrity: sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw==} + react-dnd-touch-backend@16.0.1: + resolution: {integrity: sha512-NonoCABzzjyWGZuDxSG77dbgMZ2Wad7eQiCd/ECtsR2/NBLTjGksPUx9UPezZ1nQ/L7iD130Tz3RUshL/ClKLA==} + react-dnd@16.0.1: resolution: {integrity: sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q==} peerDependencies: @@ -9307,6 +9313,11 @@ snapshots: dependencies: dnd-core: 16.0.1 + react-dnd-touch-backend@16.0.1: + dependencies: + '@react-dnd/invariant': 4.0.2 + dnd-core: 16.0.1 + react-dnd@16.0.1(@types/node@20.19.41)(@types/react@19.2.14)(react@19.2.6): dependencies: '@react-dnd/invariant': 4.0.2 diff --git a/scripts/openreader-entrypoint.mjs b/scripts/openreader-entrypoint.mjs index 97dcb5d..06dd1d4 100644 --- a/scripts/openreader-entrypoint.mjs +++ b/scripts/openreader-entrypoint.mjs @@ -11,6 +11,15 @@ import * as dotenv from 'dotenv'; function loadEnvFiles() { const cwd = process.cwd(); + const isCi = isTrue(process.env.CI, false); + if (isCi) { + const envCiPath = path.join(cwd, '.env.ci'); + if (fs.existsSync(envCiPath)) { + dotenv.config({ path: envCiPath }); + } + return; + } + const envPath = path.join(cwd, '.env'); const envLocalPath = path.join(cwd, '.env.local'); diff --git a/src/app/(app)/app/page.tsx b/src/app/(app)/app/page.tsx index 402c51e..e5e5ce4 100644 --- a/src/app/(app)/app/page.tsx +++ b/src/app/(app)/app/page.tsx @@ -1,30 +1,12 @@ -import { Header } from '@/components/Header'; import { HomeContent } from '@/components/HomeContent'; -import { SettingsModal } from '@/components/SettingsModal'; -import { UserMenu } from '@/components/auth/UserMenu'; import { RateLimitBanner } from '@/components/auth/RateLimitBanner'; export default function Home() { return (
-
- {/* eslint-disable-next-line @next/next/no-img-element */} - -

OpenReader

-
- } - right={ -
- - -
- } - /> -
-
- +
+ +
diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index 7cb8cb8..c554046 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -34,8 +34,8 @@ export default function AppLayout({ children }: { children: ReactNode }) { allowAnonymousAuthSessions={allowAnonymousAuthSessions} githubAuthEnabled={githubAuthEnabled} > -
-
{children}
+
+
{children}
{ + if (closed || isAbortLikeError(error)) return; logServerError(logger, { event: 'documents.parsed.events.worker_proxy_crashed', error, diff --git a/src/components/HomeContent.tsx b/src/components/HomeContent.tsx index 02255bd..8e9240f 100644 --- a/src/components/HomeContent.tsx +++ b/src/components/HomeContent.tsx @@ -1,33 +1,33 @@ 'use client'; -import { DocumentUploader } from '@/components/documents/DocumentUploader'; import { DocumentList } from '@/components/doclist/DocumentList'; -import { DocumentListSkeleton } from '@/components/doclist/DocumentListSkeleton'; -import { useDocuments } from '@/contexts/DocumentContext'; +import { SettingsModal } from '@/components/SettingsModal'; +import { UserMenu } from '@/components/auth/UserMenu'; + +const Brand = () => ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + +

+ OpenReader +

+
+); + +const AppActions = () => ( +
+ + +
+); export function HomeContent() { - const { pdfDocs, epubDocs, htmlDocs, isPDFLoading } = useDocuments(); - const totalDocs = (pdfDocs?.length || 0) + (epubDocs?.length || 0) + (htmlDocs?.length || 0); - - if (isPDFLoading) { - return ( -
- -
- ); - } - - if (totalDocs === 0) { - return ( -
- -
- ); - } - return ( -
- +
+ } appActions={} />
); } diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index 4c25005..50fee42 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -130,7 +130,13 @@ const SIDEBAR_SECTIONS: SidebarSection[] = [ type AdminSubTab = 'providers' | 'features'; -export function SettingsModal({ className = '' }: { className?: string }) { +export function SettingsModal({ + className = '', + triggerLabel, +}: { + className?: string; + triggerLabel?: string; +}) { const runtimeConfig = useRuntimeConfig(); const enableDestructiveDelete = runtimeConfig.enableDestructiveDeleteActions; const showAllProviderModels = runtimeConfig.showAllProviderModels; @@ -172,7 +178,7 @@ export function SettingsModal({ className = '' }: { className?: string }) { const { progress, setProgress, estimatedTimeRemaining } = useTimeEstimation(); const { authEnabled, baseUrl: authBaseUrl } = useAuthConfig(); const { data: session } = useAuthSession(); - const { requestOpenSettings, registerSettingsController } = useOnboardingFlow(); + const { changelogOpenSignal } = useOnboardingFlow(); const router = useRouter(); const isBusy = isImportingLibrary; const { @@ -205,25 +211,11 @@ export function SettingsModal({ className = '' }: { className?: string }) { const isSharedSelected = Boolean(selectedSharedProvider); const selectedProviderOption = ttsProviders.find((p) => p.id === localProviderRef) ?? ttsProviders[0]; - const closeSettings = useCallback(() => { - setIsOpen(false); - setIsChangelogOpen(false); - }, []); - - const openSettings = useCallback((options?: { changelog?: boolean }) => { - setIsOpen(true); - setIsChangelogOpen(Boolean(options?.changelog)); - }, []); - useEffect(() => { - registerSettingsController({ - open: openSettings, - close: closeSettings, - }); - return () => { - registerSettingsController(null); - }; - }, [closeSettings, openSettings, registerSettingsController]); + if (changelogOpenSignal <= 0) return; + setIsOpen(true); + setIsChangelogOpen(true); + }, [changelogOpenSignal]); useEffect(() => { setLocalApiKey(apiKey); @@ -499,13 +491,15 @@ export function SettingsModal({ className = '' }: { className?: string }) { <> @@ -1447,7 +1441,7 @@ function SettingsChangelogPanel({
+ ); + } + return (
- {enableUserSignups && ( - @@ -41,6 +70,25 @@ export function UserMenu({ className = '' }: { className?: string }) { ); } + if (variant === 'sidebar') { + return ( + + ); + } + return (
@@ -49,7 +97,7 @@ export function UserMenu({ className = '' }: { className?: string }) { -
- -
-
- -
- {sortedDocuments.map(doc => ( - - ))} -
-
- - -

- {(calculateFolderSize(sortedDocuments) / 1024 / 1024).toFixed(2)} MB - {` • ${sortedDocuments.length} ${sortedDocuments.length === 1 ? 'file' : 'files'}`} -

-
-
-
-
-
- ); -} diff --git a/src/components/doclist/DocumentList.tsx b/src/components/doclist/DocumentList.tsx index 431049b..77dfd36 100644 --- a/src/components/doclist/DocumentList.tsx +++ b/src/components/doclist/DocumentList.tsx @@ -1,410 +1,789 @@ 'use client'; -import { useCallback, useState, useEffect, DragEvent, KeyboardEvent } from 'react'; +import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'; +import { useLiveQuery } from 'dexie-react-hooks'; import { useDocuments } from '@/contexts/DocumentContext'; -import { DndProvider } from 'react-dnd'; -import { HTML5Backend } from 'react-dnd-html5-backend'; -import { DocumentType, DocumentListDocument, Folder, DocumentListState, SortBy, SortDirection } from '@/types/documents'; -import { getDocumentListState, saveDocumentListState } from '@/lib/client/dexie'; +import type { + DocumentListDocument, + DocumentListState, + Folder, + IconSize, + SidebarFilter, + SortBy, + SortDirection, + ViewMode, +} from '@/types/documents'; +import { + getDocumentListState, + getDocumentRecentlyOpenedMap, + saveDocumentListState, +} from '@/lib/client/dexie'; import { ConfirmDialog } from '@/components/ConfirmDialog'; -import { DocumentListItem } from '@/components/doclist/DocumentListItem'; -import { DocumentFolder } from '@/components/doclist/DocumentFolder'; -import { SortControls } from '@/components/doclist/SortControls'; import { CreateFolderDialog } from '@/components/doclist/CreateFolderDialog'; import { DocumentListSkeleton } from '@/components/doclist/DocumentListSkeleton'; -import { Button } from '@headlessui/react'; -import { DocumentUploader } from '@/components/documents/DocumentUploader'; -import { buttonClass } from '@/components/formPrimitives'; +import { DocumentUploader, type UploadBatchState } from '@/components/documents/DocumentUploader'; +import { DocumentDndProvider } from './dnd/DocumentDndProvider'; +import { + DocumentSelectionProvider, + useDocumentSelection, +} from './dnd/DocumentSelectionContext'; +import { documentIdentityKey, type DocumentDragItem } from './dnd/dndTypes'; +import { FinderWindow, useIsNarrow } from './window/FinderWindow'; +import { FinderToolbar } from './window/FinderToolbar'; +import { FinderSidebar } from './window/FinderSidebar'; +import { FinderStatusBar } from './window/FinderStatusBar'; +import { IconsView } from './views/IconsView'; +import { ListView } from './views/ListView'; +import { GalleryView } from './views/GalleryView'; + +let cachedDocumentListState: DocumentListState | null = null; type DocumentToDelete = { id: string; name: string; - type: DocumentType; + type: DocumentListDocument['type']; }; -const generateDefaultFolderName = (doc1: DocumentListDocument, doc2: DocumentListDocument) => { - // Try to find common words between the two document names - const words1 = doc1.name.toLowerCase().split(/[\s-_\.]+/); - const words2 = doc2.name.toLowerCase().split(/[\s-_\.]+/); - const commonWords = words1.filter(word => words2.includes(word)); +const DEFAULT_STATE: Required< + Pick< + DocumentListState, + 'sortBy' | 'sortDirection' | 'folders' | 'collapsedFolders' | 'showHint' + > +> & { + viewMode: ViewMode; + iconSize: IconSize; + sidebarWidth: number; + sidebarFilter: SidebarFilter; + sidebarCollapsed: boolean; +} = { + sortBy: 'name', + sortDirection: 'asc', + folders: [], + collapsedFolders: [], + showHint: true, + viewMode: 'icons', + iconSize: 'md', + sidebarWidth: 220, + sidebarFilter: 'all', + sidebarCollapsed: false, +}; - if (commonWords.length > 0) { - // Use the first common word that's at least 3 characters long - const significant = commonWords.find(word => word.length >= 3); - if (significant) { - if (significant === 'pdf') return 'PDFs'; - if (significant === 'epub') return 'EPUBs'; - if (significant === 'txt' || significant === 'md') return 'Documents'; - return `${significant.charAt(0).toUpperCase()}${significant.slice(1)}`; - } +function normalizeViewMode(stored: DocumentListState['viewMode']): ViewMode { + if (stored === 'grid' || stored === undefined) return 'icons'; + if (stored === 'list') return 'list'; + if (stored === 'gallery') return 'gallery'; + return 'icons'; +} + +function generateDefaultFolderName( + doc1: DocumentListDocument, + doc2: DocumentListDocument, +): string { + const words1 = doc1.name.toLowerCase().split(/[\s\-_.]+/); + const words2 = doc2.name.toLowerCase().split(/[\s\-_.]+/); + const common = words1.filter((w) => words2.includes(w)); + const significant = common.find((w) => w.length >= 3); + if (significant) { + if (significant === 'pdf') return 'PDFs'; + if (significant === 'epub') return 'EPUBs'; + if (significant === 'txt' || significant === 'md') return 'Documents'; + return significant.charAt(0).toUpperCase() + significant.slice(1); } - - // Fallback to a numbered folder const timestamp = new Date().toISOString().slice(0, 10); return `Folder ${timestamp}`; -}; +} + +function sortDocs( + docs: DocumentListDocument[], + sortBy: SortBy, + direction: SortDirection, +): DocumentListDocument[] { + const sorted = [...docs].sort((a, b) => { + switch (sortBy) { + case 'name': + return a.name.localeCompare(b.name); + case 'type': + return a.type.localeCompare(b.type); + case 'size': + return a.size - b.size; + default: + return a.lastModified - b.lastModified; + } + }); + return direction === 'asc' ? sorted : sorted.reverse(); +} + +interface DocumentListInnerProps { + brand?: ReactNode; + appActions?: ReactNode; +} + +function SidebarUploadLoader({ + totalFiles, + completedFiles, + phase, + currentFileName, +}: { + totalFiles: number; + completedFiles: number; + phase: 'uploading' | 'converting'; + currentFileName: string | null; +}) { + const progress = totalFiles > 0 ? Math.min(100, Math.round((completedFiles / totalFiles) * 100)) : 0; + const label = phase === 'converting' ? 'Converting' : 'Uploading'; + const radius = 7; + const stroke = 2; + const size = 18; + const normalizedRadius = radius - stroke / 2; + const circumference = 2 * Math.PI * normalizedRadius; + const dashOffset = circumference - (progress / 100) * circumference; + + return ( +
+
+
+ {label} + {completedFiles}/{totalFiles} +
+
+ {progress}% + + + + +
+
+ {currentFileName && ( +

+ {currentFileName} +

+ )} +
+ ); +} + +function DocumentListInner({ brand, appActions }: DocumentListInnerProps) { + const cachedState = cachedDocumentListState; + const [sortBy, setSortBy] = useState(cachedState?.sortBy ?? DEFAULT_STATE.sortBy); + const [sortDirection, setSortDirection] = useState( + cachedState?.sortDirection ?? DEFAULT_STATE.sortDirection, + ); + const [viewMode, setViewMode] = useState( + normalizeViewMode(cachedState?.viewMode ?? DEFAULT_STATE.viewMode), + ); + const [iconSize, setIconSize] = useState(cachedState?.iconSize ?? DEFAULT_STATE.iconSize); + const [folders, setFolders] = useState(cachedState?.folders ?? DEFAULT_STATE.folders); + const [showHint, setShowHint] = useState(cachedState?.showHint ?? true); + const [sidebarWidth, setSidebarWidth] = useState(cachedState?.sidebarWidth ?? DEFAULT_STATE.sidebarWidth); + const [sidebarFilter, setSidebarFilter] = useState(cachedState?.sidebarFilter ?? 'all'); + const [sidebarOpen, setSidebarOpen] = useState(!(cachedState?.sidebarCollapsed ?? false)); + const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); + const [query, setQuery] = useState(''); + const [activeUploadBatches, setActiveUploadBatches] = useState>({}); + + const [isInitialized, setIsInitialized] = useState(cachedState !== null); -export function DocumentList() { - // State hooks - const [sortBy, setSortBy] = useState('name'); - const [sortDirection, setSortDirection] = useState('asc'); - const [folders, setFolders] = useState([]); const [documentToDelete, setDocumentToDelete] = useState(null); - const [draggedDoc, setDraggedDoc] = useState(null); - const [dropTargetDoc, setDropTargetDoc] = useState(null); + const [pendingMerge, setPendingMerge] = useState< + | { sources: DocumentListDocument[]; target: DocumentListDocument } + | null + >(null); const [newFolderName, setNewFolderName] = useState(''); - const [pendingFolderDocs, setPendingFolderDocs] = useState<{ source: DocumentListDocument, target: DocumentListDocument } | null>(null); - const [collapsedFolders, setCollapsedFolders] = useState>(new Set()); - const [isInitialized, setIsInitialized] = useState(false); - const [showHint, setShowHint] = useState(true); - const [viewMode, setViewMode] = useState<'list' | 'grid'>('grid'); + const [manualFolderPrompt, setManualFolderPrompt] = useState(false); + const [clearFoldersPrompt, setClearFoldersPrompt] = useState(false); + + const isNarrow = useIsNarrow(); + const selection = useDocumentSelection(); const { pdfDocs, - removePDFDocument: removePDF, + removePDFDocument, isPDFLoading, epubDocs, - removeEPUBDocument: removeEPUB, + removeEPUBDocument, isEPUBLoading, htmlDocs, - removeHTMLDocument: removeHTML, + removeHTMLDocument, isHTMLLoading, } = useDocuments(); + // Load saved state. useEffect(() => { - // Load saved state - const loadState = async () => { - const savedState = await getDocumentListState(); - if (savedState) { - setSortBy(savedState.sortBy); - setSortDirection(savedState.sortDirection); - setFolders(savedState.folders); - setCollapsedFolders(new Set(savedState.collapsedFolders)); - setShowHint(savedState.showHint ?? true); // Use saved hint state or default to true - setViewMode(savedState.viewMode ?? 'grid'); + let cancelled = false; + (async () => { + const saved = await getDocumentListState(); + if (cancelled) return; + if (saved) { + cachedDocumentListState = saved; + setSortBy(saved.sortBy); + setSortDirection(saved.sortDirection); + setFolders(saved.folders ?? []); + setShowHint(saved.showHint ?? true); + setViewMode(normalizeViewMode(saved.viewMode)); + setIconSize(saved.iconSize ?? DEFAULT_STATE.iconSize); + setSidebarWidth(saved.sidebarWidth ?? DEFAULT_STATE.sidebarWidth); + setSidebarFilter(saved.sidebarFilter ?? 'all'); + setSidebarOpen(!(saved.sidebarCollapsed ?? false)); + } else { + cachedDocumentListState = null; + setSortBy(DEFAULT_STATE.sortBy); + setSortDirection(DEFAULT_STATE.sortDirection); + setFolders(DEFAULT_STATE.folders); + setShowHint(DEFAULT_STATE.showHint); + setViewMode(DEFAULT_STATE.viewMode); + setIconSize(DEFAULT_STATE.iconSize); + setSidebarWidth(DEFAULT_STATE.sidebarWidth); + setSidebarFilter(DEFAULT_STATE.sidebarFilter); + setSidebarOpen(!DEFAULT_STATE.sidebarCollapsed); } setIsInitialized(true); + })(); + return () => { + cancelled = true; }; - - loadState(); }, []); - useEffect(() => { - const saveState = async () => { - const state: DocumentListState = { - sortBy, - sortDirection, - folders, - collapsedFolders: Array.from(collapsedFolders), - showHint, - viewMode - }; - await saveDocumentListState(state); - }; - - if (isInitialized) { // Prevents saving empty state on first render or back navigation - saveState(); - } - }, [sortBy, sortDirection, folders, collapsedFolders, showHint, viewMode, isInitialized]); - - // Reconcile folder state against the current server-backed document list. - // If a document no longer exists on the server, drop it from folders to avoid stale UI. + // Persist. useEffect(() => { if (!isInitialized) return; - const ids = new Set([...pdfDocs, ...epubDocs, ...htmlDocs].map((d) => d.id)); - setFolders((prev) => - prev.map((folder) => ({ - ...folder, - documents: folder.documents.filter((d) => ids.has(d.id)), - })), - ); - }, [isInitialized, pdfDocs, epubDocs, htmlDocs]); + const state: DocumentListState = { + sortBy, + sortDirection, + folders, + collapsedFolders: [], + showHint, + viewMode, + iconSize, + sidebarWidth, + sidebarFilter, + sidebarCollapsed: !sidebarOpen, + }; + cachedDocumentListState = state; + void saveDocumentListState(state); + }, [ + sortBy, + sortDirection, + folders, + showHint, + viewMode, + iconSize, + sidebarWidth, + sidebarFilter, + sidebarOpen, + isInitialized, + ]); - const allDocuments: DocumentListDocument[] = [ - ...pdfDocs.map((doc) => ({ ...doc, type: 'pdf' as const })), - ...epubDocs.map((doc) => ({ ...doc, type: 'epub' as const })), - ...htmlDocs.map((doc) => ({ ...doc, type: 'html' as const })), - ]; + // Mobile drawer should never auto-open from persisted desktop state. + useEffect(() => { + if (!isNarrow) return; + setMobileSidebarOpen(false); + }, [isNarrow]); - const sortDocuments = useCallback((docs: DocumentListDocument[]) => { - return [...docs].sort((a, b) => { - switch (sortBy) { - case 'name': - return sortDirection === 'asc' - ? a.name.localeCompare(b.name) - : b.name.localeCompare(a.name); - case 'type': - return sortDirection === 'asc' - ? a.type.localeCompare(b.type) - : b.type.localeCompare(a.type); - case 'size': - return sortDirection === 'asc' - ? a.size - b.size - : b.size - a.size; - default: - return sortDirection === 'asc' - ? a.lastModified - b.lastModified - : b.lastModified - a.lastModified; + // Build the union document list. + const rawDocuments: DocumentListDocument[] = useMemo( + () => [ + ...pdfDocs.map((d) => ({ ...d, type: 'pdf' as const })), + ...epubDocs.map((d) => ({ ...d, type: 'epub' as const })), + ...htmlDocs.map((d) => ({ ...d, type: 'html' as const })), + ], + [pdfDocs, epubDocs, htmlDocs], + ); + const rawDocumentIdsKey = useMemo( + () => rawDocuments.map((d) => documentIdentityKey(d)).sort().join('|'), + [rawDocuments], + ); + const recentlyOpenedById = useLiveQuery, Record>( + async () => { + try { + return await getDocumentRecentlyOpenedMap(); + } catch (err) { + console.warn('Failed to load recently opened cache metadata:', err); + return {}; } - }); - }, [sortBy, sortDirection]); + }, + [rawDocumentIdsKey], + {}, + ); + + const allDocuments: DocumentListDocument[] = useMemo( + () => + rawDocuments.map((doc) => ({ + ...doc, + recentlyOpenedAt: recentlyOpenedById[documentIdentityKey(doc)] ?? 0, + })), + [rawDocuments, recentlyOpenedById], + ); + + const allDocumentsById = useMemo(() => { + const map = new Map(); + for (const doc of allDocuments) map.set(documentIdentityKey(doc), doc); + return map; + }, [allDocuments]); + + const foldersWithLiveDocs = useMemo( + () => + folders.map((folder) => ({ + ...folder, + documents: folder.documents + .map((d) => allDocumentsById.get(documentIdentityKey(d))) + .filter((d): d is DocumentListDocument => Boolean(d)) + .map((d) => ({ ...d, folderId: folder.id })), + })), + [folders, allDocumentsById], + ); + + const folderNameById = useMemo( + () => + foldersWithLiveDocs.reduce>((acc, folder) => { + acc[folder.id] = folder.name; + return acc; + }, {}), + [foldersWithLiveDocs], + ); + + const folderIdByDocId = useMemo(() => { + const map = new Map(); + for (const folder of foldersWithLiveDocs) { + for (const doc of folder.documents) map.set(documentIdentityKey(doc), folder.id); + } + return map; + }, [foldersWithLiveDocs]); + + const allDocumentsWithFolder = useMemo( + () => + allDocuments.map((doc) => ({ + ...doc, + folderId: folderIdByDocId.get(documentIdentityKey(doc)), + })), + [allDocuments, folderIdByDocId], + ); + + // Filter based on sidebar selection + search query. + const visibleDocuments = useMemo(() => { + const q = query.trim().toLowerCase(); + let docs = allDocumentsWithFolder; + if (sidebarFilter === 'pdf') docs = docs.filter((d) => d.type === 'pdf'); + else if (sidebarFilter === 'epub') docs = docs.filter((d) => d.type === 'epub'); + else if (sidebarFilter === 'html') docs = docs.filter((d) => d.type === 'html'); + else if (sidebarFilter === 'recents') { + docs = [...docs] + .filter((d) => (d.recentlyOpenedAt ?? 0) > 0) + .sort((a, b) => (b.recentlyOpenedAt ?? 0) - (a.recentlyOpenedAt ?? 0)) + .slice(0, 20); + } else if (sidebarFilter.startsWith('folder:')) { + const fid = sidebarFilter.slice('folder:'.length); + const folder = foldersWithLiveDocs.find((f) => f.id === fid); + docs = folder + ? folder.documents + .map((d) => allDocumentsById.get(documentIdentityKey(d))) + .filter((d): d is DocumentListDocument => Boolean(d)) + .map((d) => ({ ...d, folderId: fid })) + : []; + } + if (q) docs = docs.filter((d) => d.name.toLowerCase().includes(q)); + return docs; + }, [allDocumentsWithFolder, sidebarFilter, query, foldersWithLiveDocs, allDocumentsById]); + + // Apply sort. + const sortedVisible = useMemo(() => { + if (sidebarFilter === 'recents') return visibleDocuments; + return sortDocs(visibleDocuments, sortBy, sortDirection); + }, [visibleDocuments, sidebarFilter, sortBy, sortDirection]); + + const counts = useMemo( + () => ({ + all: allDocuments.length, + pdf: pdfDocs.length, + epub: epubDocs.length, + html: htmlDocs.length, + }), + [allDocuments.length, pdfDocs.length, epubDocs.length, htmlDocs.length], + ); + + // --- Actions --- const handleDelete = useCallback(async () => { if (!documentToDelete) return; - try { - if (documentToDelete.type === 'pdf') { - await removePDF(documentToDelete.id); - } else if (documentToDelete.type === 'epub') { - await removeEPUB(documentToDelete.id); - } else if (documentToDelete.type === 'html') { - await removeHTML(documentToDelete.id); - } - - // Remove from folders if document is in one - setFolders(prev => prev.map(folder => ({ - ...folder, - documents: folder.documents.filter(doc => - !(doc.id === documentToDelete.id && doc.type === documentToDelete.type) - ) - }))); - + if (documentToDelete.type === 'pdf') await removePDFDocument(documentToDelete.id); + else if (documentToDelete.type === 'epub') await removeEPUBDocument(documentToDelete.id); + else if (documentToDelete.type === 'html') await removeHTMLDocument(documentToDelete.id); + setFolders((prev) => + prev.map((f) => ({ + ...f, + documents: f.documents.filter( + (d) => !(d.id === documentToDelete.id && d.type === documentToDelete.type), + ), + })), + ); setDocumentToDelete(null); } catch (err) { console.error('Failed to remove document:', err); } - }, [documentToDelete, removePDF, removeEPUB, removeHTML]); + }, [documentToDelete, removePDFDocument, removeEPUBDocument, removeHTMLDocument]); - const handleDragStart = useCallback((doc: DocumentListDocument) => { - if (!doc.folderId) { - setDraggedDoc(doc); - } + const handleDeleteDoc = useCallback((doc: DocumentListDocument) => { + setDocumentToDelete({ id: doc.id, name: doc.name, type: doc.type }); }, []); - const handleDragEnd = useCallback(() => { - setDraggedDoc(null); - setDropTargetDoc(null); - }, []); + const handleDropOnFolder = useCallback( + (folderId: string, item: DocumentDragItem) => { + setFolders((prev) => + prev.map((f) => { + if (f.id !== folderId) { + // Remove the dropped docs from any other folder they were in. + return { + ...f, + documents: f.documents.filter( + (d) => !item.items.some((it) => it.id === d.id && it.type === d.type), + ), + }; + } + const existingIdentities = new Set(f.documents.map((d) => documentIdentityKey(d))); + const newDocs = item.docs + .filter((d) => !existingIdentities.has(documentIdentityKey(d))) + .map((d) => ({ ...d, folderId })); + return { ...f, documents: [...f.documents, ...newDocs] }; + }), + ); + setSidebarFilter(`folder:${folderId}`); + selection.clear(); + }, + [selection], + ); - const handleDragOver = useCallback((e: DragEvent, doc: DocumentListDocument) => { - e.preventDefault(); - if (draggedDoc && draggedDoc.id !== doc.id && !draggedDoc.folderId) { - // Only highlight target if neither document is in a folder - if (!doc.folderId) { - setDropTargetDoc(doc); - } - } - }, [draggedDoc]); - - const handleDragLeave = useCallback(() => { - setDropTargetDoc(null); - }, []); - - const handleDrop = useCallback((e: DragEvent, targetDoc: DocumentListDocument) => { - e.preventDefault(); - console.log('Dropped', draggedDoc?.name, 'on', targetDoc.name); - - if (!draggedDoc || draggedDoc.id === targetDoc.id || draggedDoc.folderId) return; - - // If target doc is unfoldered, create a new folder - if (!targetDoc.folderId) { - setPendingFolderDocs({ - source: draggedDoc, - target: targetDoc - }); + const handleMergeIntoFolder = useCallback( + (sources: DocumentListDocument[], target: DocumentListDocument) => { + if (target.folderId) return; + const targetKey = documentIdentityKey(target); + const filtered = sources.filter((s) => documentIdentityKey(s) !== targetKey && !s.folderId); + if (filtered.length === 0) return; + setPendingMerge({ sources: filtered, target }); setNewFolderName(''); - } - - setDraggedDoc(null); - setDropTargetDoc(null); - }, [draggedDoc]); - - const handleFolderDrop = useCallback((e: DragEvent, folderId: string) => { - e.preventDefault(); - if (!draggedDoc || draggedDoc.folderId) return; - - // Add document to existing folder - setFolders(folders.map(f => { - if (f.id === folderId && !f.documents.some(d => d.id === draggedDoc.id)) { - return { - ...f, - documents: [...f.documents, { ...draggedDoc, folderId }] - }; - } - return f; - })); - - setDraggedDoc(null); - setDropTargetDoc(null); - }, [draggedDoc, folders]); - - const toggleFolderCollapse = useCallback((folderId: string) => { - setCollapsedFolders((prev) => { - const next = new Set(prev); - if (next.has(folderId)) { - next.delete(folderId); - } else { - next.add(folderId); - } - return next; - }); - }, []); - - const createFolder = useCallback(() => { - if (!pendingFolderDocs) return; - - const folderName = newFolderName.trim() || - generateDefaultFolderName(pendingFolderDocs.source, pendingFolderDocs.target); + }, + [], + ); + const createFolderFromPending = useCallback(() => { + if (!pendingMerge) return; + const name = + newFolderName.trim() || + generateDefaultFolderName(pendingMerge.sources[0], pendingMerge.target); const folderId = `folder-${Date.now()}`; - setFolders(prev => [ + setFolders((prev) => [ ...prev, { id: folderId, - name: folderName, + name, documents: [ - { ...pendingFolderDocs.source, folderId }, - { ...pendingFolderDocs.target, folderId } - ] - } + ...pendingMerge.sources.map((d) => ({ ...d, folderId })), + { ...pendingMerge.target, folderId }, + ], + }, ]); - - setPendingFolderDocs(null); + setPendingMerge(null); setNewFolderName(''); - setDropTargetDoc(null); - setDraggedDoc(null); setShowHint(false); - }, [pendingFolderDocs, newFolderName]); + setSidebarFilter(`folder:${folderId}`); + selection.clear(); + }, [pendingMerge, newFolderName, selection]); - const handleFolderNameKeyDown = useCallback((e: KeyboardEvent) => { - if (e.key === 'Enter') { - e.preventDefault(); - createFolder(); - } else if (e.key === 'Escape') { - setPendingFolderDocs(null); - setNewFolderName(''); - } - }, [createFolder]); + const createManualFolder = useCallback(() => { + const name = newFolderName.trim() || `New Folder`; + const folderId = `folder-${Date.now()}`; + setFolders((prev) => [...prev, { id: folderId, name, documents: [] }]); + setNewFolderName(''); + setManualFolderPrompt(false); + setSidebarFilter(`folder:${folderId}`); + }, [newFolderName]); - if (isPDFLoading || isEPUBLoading || isHTMLLoading) { - return ; - } + const handleDeleteFolder = useCallback((folderId: string) => { + setFolders((prev) => prev.filter((f) => f.id !== folderId)); + if (sidebarFilter === `folder:${folderId}`) setSidebarFilter('all'); + }, [sidebarFilter]); - if (allDocuments.length === 0) { - return
No documents uploaded yet
; - } + const handleClearFolders = useCallback(() => { + setFolders([]); + if (sidebarFilter.startsWith('folder:')) setSidebarFilter('all'); + setClearFoldersPrompt(false); + selection.clear(); + }, [selection, sidebarFilter]); - const unfolderedDocuments = allDocuments.filter( - doc => !folders.some(folder => folder.documents.some(d => d.id === doc.id)) + // Status bar summary. + const summary = useMemo(() => { + const parts: string[] = []; + if (counts.pdf) parts.push(`${counts.pdf} PDF${counts.pdf === 1 ? '' : 's'}`); + if (counts.epub) parts.push(`${counts.epub} EPUB${counts.epub === 1 ? '' : 's'}`); + if (counts.html) parts.push(`${counts.html} Text${counts.html === 1 ? ' Doc' : ' Docs'}`); + return parts.join(' • '); + }, [counts]); + + const totalBytes = useMemo( + () => allDocuments.reduce((acc, d) => acc + d.size, 0), + [allDocuments], + ); + const visibleSelectedCount = useMemo( + () => sortedVisible.reduce((count, doc) => count + (selection.isSelected(doc) ? 1 : 0), 0), + [sortedVisible, selection], ); - // Build compact summary (counts per type + total size) - const summaryParts: string[] = []; - if (pdfDocs.length) summaryParts.push(`${pdfDocs.length} PDF${pdfDocs.length === 1 ? '' : 's'}`); - if (epubDocs.length) summaryParts.push(`${epubDocs.length} EPUB${epubDocs.length === 1 ? '' : 's'}`); - if (htmlDocs.length) summaryParts.push(`${htmlDocs.length} HTML${htmlDocs.length === 1 ? '' : 's'}`); - const totalSizeMB = (allDocuments.reduce((acc, d) => acc + d.size, 0) / 1024 / 1024).toFixed(2); + const isLoading = isPDFLoading || isEPUBLoading || isHTMLLoading; + + const handleUploadBatchChange = useCallback((state: UploadBatchState) => { + setActiveUploadBatches((prev) => { + if (!state.isActive) { + if (!prev[state.uploaderId]) return prev; + const next = { ...prev }; + delete next[state.uploaderId]; + return next; + } + return { ...prev, [state.uploaderId]: state }; + }); + }, []); + + const sidebarUploadState = useMemo(() => { + const batches = Object.values(activeUploadBatches); + if (batches.length === 0) return null; + const totalFiles = batches.reduce((sum, batch) => sum + batch.totalFiles, 0); + const completedFiles = batches.reduce((sum, batch) => sum + batch.completedFiles, 0); + const convertingBatch = batches.find((batch) => batch.phase === 'converting'); + const phase: 'uploading' | 'converting' = convertingBatch ? 'converting' : 'uploading'; + const currentFileName = convertingBatch?.currentFileName ?? batches.find((batch) => batch.currentFileName)?.currentFileName ?? null; + return { totalFiles, completedFiles, phase, currentFileName }; + }, [activeUploadBatches]); + + const fallbackViewMode: ViewMode = viewMode; + const effectiveSidebarOpen = isNarrow ? mobileSidebarOpen : sidebarOpen; return ( - -
-
-

Your Documents

- setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc')} - viewMode={viewMode} - onViewModeChange={setViewMode} - /> -
- -

- {summaryParts.join(' • ')}{summaryParts.length ? ' • ' : ''}{totalSizeMB} MB total -

-
- -
- - {showHint && allDocuments.length > 1 && ( -
-

Drag files on top of each other to make folders

- +
- )} - -
- - {folders.map(folder => ( - setFolders(prev => prev.filter(f => f.id !== folder.id))} - sortedDocuments={sortDocuments(folder.documents)} - onDocumentDelete={setDocumentToDelete} - draggedDoc={draggedDoc} - onDragStart={handleDragStart} - onDragEnd={handleDragEnd} - onDrop={handleFolderDrop} - viewMode={viewMode} - /> - ))} - - {sortDocuments(unfolderedDocuments).map(doc => ( - - ))}
+ )} - setPendingFolderDocs(null)} - folderName={newFolderName} - onFolderNameChange={setNewFolderName} - onKeyDown={handleFolderNameKeyDown} - /> + {isLoading ? ( +
+ +
+ ) : allDocuments.length === 0 ? ( +
+ +
+ ) : ( + + {fallbackViewMode === 'icons' && ( + + )} + {fallbackViewMode === 'list' && ( + { + setSortBy(b); + setSortDirection(d); + }} + onDeleteDoc={handleDeleteDoc} + onMergeIntoFolder={handleMergeIntoFolder} + /> + )} + {fallbackViewMode === 'gallery' && ( + + )} + + )} - setDocumentToDelete(null)} - onConfirm={handleDelete} - title="Delete Document" - message={`Are you sure you want to delete ${documentToDelete?.name || 'this document'}?`} - confirmText="Delete" - isDangerous={true} - /> -
-
+ setPendingMerge(null)} + folderName={newFolderName} + onFolderNameChange={setNewFolderName} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + createFolderFromPending(); + } else if (e.key === 'Escape') { + setPendingMerge(null); + setNewFolderName(''); + } + }} + /> + + setManualFolderPrompt(false)} + folderName={newFolderName} + onFolderNameChange={setNewFolderName} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + createManualFolder(); + } else if (e.key === 'Escape') { + setManualFolderPrompt(false); + setNewFolderName(''); + } + }} + /> + + setDocumentToDelete(null)} + onConfirm={handleDelete} + title="Delete Document" + message={`Are you sure you want to delete ${documentToDelete?.name ?? 'this document'}?`} + confirmText="Delete" + isDangerous + /> + + setClearFoldersPrompt(false)} + onConfirm={handleClearFolders} + title="Remove All Folders" + message="Remove all folders? This will not delete documents." + confirmText="Remove Folders" + isDangerous + /> + + ); +} + +export function DocumentList({ + brand, + appActions, +}: { + brand?: ReactNode; + appActions?: ReactNode; +} = {}) { + return ( + + + + + ); } diff --git a/src/components/doclist/DocumentListItem.tsx b/src/components/doclist/DocumentListItem.tsx deleted file mode 100644 index 8cb029c..0000000 --- a/src/components/doclist/DocumentListItem.tsx +++ /dev/null @@ -1,176 +0,0 @@ -import Link from 'next/link'; -import { DragEvent, useState } from 'react'; -import { Button } from '@headlessui/react'; -import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons'; -import { DocumentListDocument } from '@/types/documents'; -import { DocumentPreview } from '@/components/doclist/DocumentPreview'; -import { useAuthSession } from '@/hooks/useAuthSession'; -import { useAuthConfig } from '@/contexts/AuthRateLimitContext'; -import { buttonClass } from '@/components/formPrimitives'; - -interface DocumentListItemProps { - doc: DocumentListDocument; - onDelete: (doc: DocumentListDocument) => void; - dragEnabled?: boolean; - onDragStart?: (doc: DocumentListDocument) => void; - onDragEnd?: () => void; - onDragOver?: (e: DragEvent, doc: DocumentListDocument) => void; - onDragLeave?: () => void; - onDrop?: (e: DragEvent, doc: DocumentListDocument) => void; - isDropTarget?: boolean; - viewMode: 'list' | 'grid'; -} - -export function DocumentListItem({ - doc, - onDelete, - dragEnabled = true, - onDragStart, - onDragEnd, - onDragOver, - onDragLeave, - onDrop, - isDropTarget = false, - viewMode, -}: DocumentListItemProps) { - const [loading, setLoading] = useState(false); - const { authEnabled } = useAuthConfig(); - const { data: session } = useAuthSession(); - const href = `/${doc.type}/${encodeURIComponent(doc.id)}`; - - // Only allow drag and drop interactions for documents not in folders - const isDraggable = dragEnabled && !doc.folderId; - const allowDropTarget = !doc.folderId; - const isAnonymousAuthed = Boolean(authEnabled && session?.user?.isAnonymous); - const showDeleteButton = !(isAnonymousAuthed && doc.scope === 'unclaimed'); - - const handleDocumentClick = () => { - setLoading(true); - }; - - return ( -
onDragStart?.(doc)} - onDragEnd={onDragEnd} - onDragOver={(e) => allowDropTarget && onDragOver?.(e, doc)} - onDragLeave={() => allowDropTarget && onDragLeave?.()} - onDrop={(e) => allowDropTarget && onDrop?.(e, doc)} - aria-busy={loading} - className={ - viewMode === 'grid' - ? ` - flex w-full min-w-0 flex-col - group border border-offbase rounded-md overflow-hidden - transition-colors duration-150 relative bg-base hover:bg-offbase - ${allowDropTarget && isDropTarget ? 'ring-2 ring-accent' : ''} - ${loading ? 'prism-outline' : ''} - ` - : ` - w-full group - ${allowDropTarget && isDropTarget ? 'ring-2 ring-accent' : ''} - ${loading ? 'prism-outline' : 'bg-base hover:bg-offbase'} - border border-offbase rounded-md p-1 - transition-colors duration-150 relative - ` - } - > - {viewMode === 'grid' ? ( - <> - - - -
- -
- {doc.type === 'pdf' ? ( - - ) : doc.type === 'epub' ? ( - - ) : ( - - )} -
-
-

- {doc.name} -

-

- {(doc.size / 1024 / 1024).toFixed(2)} MB -

-
- - {showDeleteButton && ( - - )} -
- - ) : ( -
- -
- {doc.type === 'pdf' ? ( - - ) : doc.type === 'epub' ? ( - - ) : ( - - )} -
-
-

- {doc.name} -

-

- {(doc.size / 1024 / 1024).toFixed(2)} MB -

-
- - {showDeleteButton && ( - - )} -
- )} -
- ); -} diff --git a/src/components/doclist/DocumentListSkeleton.tsx b/src/components/doclist/DocumentListSkeleton.tsx index f376a59..d80c076 100644 --- a/src/components/doclist/DocumentListSkeleton.tsx +++ b/src/components/doclist/DocumentListSkeleton.tsx @@ -1,49 +1,130 @@ 'use client'; +import type { IconSize, ViewMode } from '@/types/documents'; +import { iconsGridStyle } from '@/components/doclist/views/iconsGrid'; + interface DocumentListSkeletonProps { - viewMode?: 'list' | 'grid'; + viewMode?: ViewMode; + iconSize?: IconSize; } -export function DocumentListSkeleton({ viewMode = 'grid' }: DocumentListSkeletonProps) { - const placeholders = Array.from({ length: viewMode === 'grid' ? 10 : 6 }); +const ICON_SKELETON_ITEM_COUNT = 12; +function IconsSkeleton({ iconSize }: { iconSize: IconSize }) { return ( -
-
-
-
-
-
-
- -
- {placeholders.map((_, index) => ( -
- {viewMode === 'grid' ? ( - <> -
-
-
-
-
- - ) : null} +
+
+ {Array.from({ length: ICON_SKELETON_ITEM_COUNT }).map((_, index) => ( +
+
+
+
+
+
))}
); } + +function ListSkeleton() { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {Array.from({ length: 10 }).map((_, index) => ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))} +
+
+ ); +} + +function GallerySkeleton() { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {Array.from({ length: 10 }).map((_, index) => ( +
+
+
+
+
+
+ ))} +
+
+
+ ); +} + +export function DocumentListSkeleton({ + viewMode = 'icons', + iconSize = 'md', +}: DocumentListSkeletonProps) { + let body; + if (viewMode === 'list') { + body = ; + } else if (viewMode === 'gallery') { + body = ; + } else { + body = ; + } + + return ( +
+ {body} +
+ ); +} diff --git a/src/components/doclist/DocumentPreview.tsx b/src/components/doclist/DocumentPreview.tsx index 6249b03..7f23165 100644 --- a/src/components/doclist/DocumentPreview.tsx +++ b/src/components/doclist/DocumentPreview.tsx @@ -12,6 +12,7 @@ import { primeDocumentPreviewCache, setInMemoryDocumentPreviewUrl, } from '@/lib/client/cache/previews'; +import { formatDocumentSize } from './formatSize'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; @@ -61,6 +62,7 @@ export function DocumentPreview({ doc }: DocumentPreviewProps) { lowerName.endsWith('.mkd')); const containerRef = useRef(null); + const imageRef = useRef(null); const [isVisible, setIsVisible] = useState(false); const [imagePreview, setImagePreview] = useState(null); const [isImageReady, setIsImageReady] = useState(false); @@ -205,6 +207,17 @@ export function DocumentPreview({ doc }: DocumentPreviewProps) { setIsImageReady(false); }, [imagePreview]); + // Cached blob/http sources can already be decoded before React's onLoad handler + // runs on remount, leaving opacity at 0. Promote already-complete images. + useEffect(() => { + if (!imagePreview) return; + const img = imageRef.current; + if (!img) return; + if (img.complete && img.naturalWidth > 0) { + setIsImageReady(true); + } + }, [imagePreview]); + const gradientClass = isPDF ? 'from-red-500/80 via-red-400/60 to-red-600/80' : isEPUB @@ -245,6 +258,7 @@ export function DocumentPreview({ doc }: DocumentPreviewProps) { ) : null} {/* eslint-disable-next-line @next/next/no-img-element */} {`${doc.name} )} -
- {isGenerating ? '…' : typeLabel} +
+ {isGenerating + ? '…' + : `${typeLabel} • ${formatDocumentSize(doc.size)}`}
); diff --git a/src/components/doclist/SortControls.tsx b/src/components/doclist/SortControls.tsx deleted file mode 100644 index 6d29c99..0000000 --- a/src/components/doclist/SortControls.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { Listbox, ListboxButton, ListboxOption, ListboxOptions, Button } from '@headlessui/react'; -import { ChevronUpDownIcon, ListIcon, GridIcon } from '@/components/icons/Icons'; -import { SortBy, SortDirection } from '@/types/documents'; - -interface SortControlsProps { - sortBy: SortBy; - sortDirection: SortDirection; - onSortByChange: (value: SortBy) => void; - onSortDirectionChange: () => void; - viewMode: 'list' | 'grid'; - onViewModeChange: (mode: 'list' | 'grid') => void; -} - -export function SortControls({ - sortBy, - sortDirection, - onSortByChange, - onSortDirectionChange, - viewMode, - onViewModeChange, -}: SortControlsProps) { - const sortOptions: Array<{ value: SortBy; label: string, up: string, down: string }> = [ - { value: 'name', label: 'Name', up: 'A-Z', down: 'Z-A' }, - { value: 'type', label: 'Type', up: 'A-Z', down: 'Z-A' }, - { value: 'date', label: 'Date', up: 'Newest', down: 'Oldest' }, - { value: 'size', label: 'Size' , up: 'Smallest', down: 'Largest' }, - ]; - - const currentSort = sortOptions.find(opt => opt.value === sortBy); - const directionLabel = sortDirection === 'asc' ? currentSort?.up : currentSort?.down; - - const buttonBaseClass = "h-6 flex items-center justify-center bg-base hover:bg-offbase rounded border border-transparent hover:border-offbase text-xs sm:text-sm transition-all duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"; - const activeIconClass = "text-accent"; - const inactiveIconClass = "text-muted"; - - return ( -
-
- - -
- -
- -
- -
- - - {sortOptions.find(opt => opt.value === sortBy)?.label} - - - - {sortOptions.map((option) => ( - - `relative cursor-pointer select-none py-1.5 px-2 rounded text-xs ${active ? 'bg-offbase text-accent' : 'text-foreground'} ${selected ? 'font-medium' : ''}` - } - > - {option.label} - - ))} - - -
-
-
- ); -} \ No newline at end of file diff --git a/src/components/doclist/dnd/DocumentDndProvider.tsx b/src/components/doclist/dnd/DocumentDndProvider.tsx new file mode 100644 index 0000000..5f5796a --- /dev/null +++ b/src/components/doclist/dnd/DocumentDndProvider.tsx @@ -0,0 +1,60 @@ +'use client'; + +import { useEffect, useState, type ReactNode } from 'react'; +import { DndProvider } from 'react-dnd'; +import { HTML5Backend } from 'react-dnd-html5-backend'; +import { TouchBackend } from 'react-dnd-touch-backend'; + +const TOUCH_QUERY = '(hover: none) and (pointer: coarse)'; + +function detectTouchInitial(): boolean { + if (typeof window === 'undefined') return false; + try { + return window.matchMedia(TOUCH_QUERY).matches; + } catch { + return false; + } +} + +/** + * DnD provider that swaps between HTML5 (mouse/keyboard) and Touch backends + * based on the primary input device. Long-press activates a drag on touch. + * + * react-dnd doesn't allow swapping backends after mount, so the subtree is + * remounted with a `key` when the media query changes. That happens at most + * when the user docks/undocks a tablet — fine in practice. + */ +export function DocumentDndProvider({ children }: { children: ReactNode }) { + const [isTouch, setIsTouch] = useState(detectTouchInitial); + + useEffect(() => { + if (typeof window === 'undefined') return; + const mq = window.matchMedia(TOUCH_QUERY); + const handler = (e: MediaQueryListEvent) => setIsTouch(e.matches); + mq.addEventListener('change', handler); + return () => mq.removeEventListener('change', handler); + }, []); + + if (isTouch) { + return ( + + {children} + + ); + } + + return ( + + {children} + + ); +} diff --git a/src/components/doclist/dnd/DocumentSelectionContext.tsx b/src/components/doclist/dnd/DocumentSelectionContext.tsx new file mode 100644 index 0000000..137fbae --- /dev/null +++ b/src/components/doclist/dnd/DocumentSelectionContext.tsx @@ -0,0 +1,123 @@ +'use client'; + +import { + createContext, + useCallback, + useContext, + useMemo, + useState, + type ReactNode, +} from 'react'; +import type { DocumentListDocument } from '@/types/documents'; + +type DocKey = string; // `${type}-${id}` + +const docKey = (doc: Pick): DocKey => + `${doc.type}-${doc.id}`; + +interface SelectionContextValue { + selection: ReadonlySet; + isSelected: (doc: Pick) => boolean; + selectionSize: number; + /** Treat the visible-doc order so shift-click range-select can resolve. */ + setVisibleOrder: (docs: DocumentListDocument[]) => void; + /** Click semantics: with shift = range-select, with meta/ctrl = toggle, plain = single-select. */ + select: ( + doc: DocumentListDocument, + opts?: { shift?: boolean; meta?: boolean }, + ) => void; + clear: () => void; + /** Force a precise selection (e.g. on drag start when nothing was selected). */ + replace: (docs: DocumentListDocument[]) => void; + /** Resolve concrete docs for the current selection from the visible-order. */ + getSelectedDocs: () => DocumentListDocument[]; +} + +const SelectionContext = createContext(null); + +export function DocumentSelectionProvider({ children }: { children: ReactNode }) { + const [selection, setSelection] = useState>(() => new Set()); + const [order, setOrder] = useState([]); + const [anchor, setAnchor] = useState(null); + + const isSelected = useCallback( + (doc: Pick) => selection.has(docKey(doc)), + [selection], + ); + + const setVisibleOrder = useCallback((docs: DocumentListDocument[]) => { + setOrder(docs); + }, []); + + const select = useCallback( + (doc, opts) => { + const key = docKey(doc); + if (opts?.shift && anchor && order.length > 0) { + const anchorIdx = order.findIndex((d) => docKey(d) === anchor); + const targetIdx = order.findIndex((d) => docKey(d) === key); + if (anchorIdx >= 0 && targetIdx >= 0) { + const [lo, hi] = + anchorIdx < targetIdx ? [anchorIdx, targetIdx] : [targetIdx, anchorIdx]; + const next = new Set(); + for (let i = lo; i <= hi; i++) next.add(docKey(order[i])); + setSelection(next); + return; + } + } + if (opts?.meta) { + setSelection((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + setAnchor(key); + return; + } + setSelection(new Set([key])); + setAnchor(key); + }, + [anchor, order], + ); + + const clear = useCallback(() => { + setSelection(new Set()); + setAnchor(null); + }, []); + + const replace = useCallback((docs: DocumentListDocument[]) => { + const next = new Set(); + for (const d of docs) next.add(docKey(d)); + setSelection(next); + setAnchor(docs[0] ? docKey(docs[0]) : null); + }, []); + + const getSelectedDocs = useCallback(() => { + if (selection.size === 0 || order.length === 0) return []; + return order.filter((d) => selection.has(docKey(d))); + }, [selection, order]); + + const value = useMemo( + () => ({ + selection, + isSelected, + selectionSize: selection.size, + setVisibleOrder, + select, + clear, + replace, + getSelectedDocs, + }), + [selection, isSelected, setVisibleOrder, select, clear, replace, getSelectedDocs], + ); + + return {children}; +} + +export function useDocumentSelection(): SelectionContextValue { + const ctx = useContext(SelectionContext); + if (!ctx) { + throw new Error('useDocumentSelection must be used inside DocumentSelectionProvider'); + } + return ctx; +} diff --git a/src/components/doclist/dnd/dndTypes.ts b/src/components/doclist/dnd/dndTypes.ts new file mode 100644 index 0000000..61cc625 --- /dev/null +++ b/src/components/doclist/dnd/dndTypes.ts @@ -0,0 +1,22 @@ +import type { DocumentListDocument } from '@/types/documents'; + +export const DND_DOCUMENT = 'openreader/document' as const; + +export type DocumentIdentity = Pick; + +export const documentIdentityKey = ({ id, type }: DocumentIdentity): string => `${type}|${id}`; + +export interface DocumentDragItem { + /** Doc identities being dragged together (may be a single doc). */ + items: DocumentIdentity[]; + /** Concrete doc records for the dragged identities — used for previews and folder hints. */ + docs: DocumentListDocument[]; + /** Folder id the drag originated from, if any (cross-folder vs unfoldered moves). */ + fromFolderId?: string; +} + +export type DropTarget = + | { kind: 'document'; id: string } + | { kind: 'folder'; id: string } + | { kind: 'sidebar-folder'; id: string } + | { kind: 'pane'; id: 'unfoldered' }; diff --git a/src/components/doclist/formatSize.ts b/src/components/doclist/formatSize.ts new file mode 100644 index 0000000..2402e36 --- /dev/null +++ b/src/components/doclist/formatSize.ts @@ -0,0 +1,12 @@ +export function formatDocumentSize(bytes: number): string { + if (bytes < 1024) { + return `${Math.max(0, Math.floor(bytes))} B`; + } + if (bytes >= 1024 * 1024 * 1024) { + return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`; + } + if (bytes >= 1024 * 1024) { + return `${(bytes / 1024 / 1024).toFixed(2)} MB`; + } + return `${(bytes / 1024).toFixed(1)} KB`; +} diff --git a/src/components/doclist/views/DocumentTile.tsx b/src/components/doclist/views/DocumentTile.tsx new file mode 100644 index 0000000..e43ddef --- /dev/null +++ b/src/components/doclist/views/DocumentTile.tsx @@ -0,0 +1,211 @@ +'use client'; + +import Link from 'next/link'; +import { useDrag, useDrop, type DragSourceMonitor } from 'react-dnd'; +import { Button } from '@headlessui/react'; +import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons'; +import type { DocumentListDocument, IconSize } from '@/types/documents'; +import { DocumentPreview } from '@/components/doclist/DocumentPreview'; +import { useAuthSession } from '@/hooks/useAuthSession'; +import { useAuthConfig } from '@/contexts/AuthRateLimitContext'; +import { useDocumentSelection } from '../dnd/DocumentSelectionContext'; +import { DND_DOCUMENT, documentIdentityKey, type DocumentDragItem } from '../dnd/dndTypes'; + +interface DocumentTileProps { + doc: DocumentListDocument; + iconSize: IconSize; + onDelete: (doc: DocumentListDocument) => void; + /** Fired when two unfoldered docs are dropped together → caller should open a "create folder" dialog. */ + onMergeIntoFolder: (source: DocumentListDocument[], target: DocumentListDocument) => void; +} + +const NAME_SIZE_CLASSES: Record = { + sm: 'text-[10px]', + md: 'text-[11px]', + lg: 'text-[12px]', + xl: 'text-[13px]', +}; + +const BOTTOM_PADDING_CLASSES: Record = { + sm: 'px-[7px] py-[4px]', + md: 'px-[8px] py-[5px]', + lg: 'px-[9px] py-[5px]', + xl: 'px-[10px] py-[6px]', +}; + +const LINK_PADDING_CLASS = 'px-[2px] py-[2px]'; + +const GAP_CLASSES: Record = { + sm: 'gap-1', + md: 'gap-1.5', + lg: 'gap-2', + xl: 'gap-2', +}; + +const FILE_ICON_CLASSES: Record = { + sm: 'w-3 h-3', + md: 'w-3.5 h-3.5', + lg: 'w-3.5 h-3.5', + xl: 'w-4 h-4', +}; + +const TRASH_BTN_CLASSES: Record = { + sm: 'ml-0.5 h-[18px] w-[18px] rounded-sm', + md: 'ml-0.5 h-[21px] w-[21px] rounded-sm', + lg: 'ml-1 h-[23px] w-[23px] rounded', + xl: 'ml-1.5 h-[25px] w-[25px] rounded', +}; + +const TRASH_ICON_CLASSES: Record = { + sm: 'w-[10px] h-[10px]', + md: 'w-[11px] h-[11px]', + lg: 'w-[12px] h-[12px]', + xl: 'w-[13px] h-[13px]', +}; + +export function DocumentTile({ + doc, + iconSize, + onDelete, + onMergeIntoFolder, +}: DocumentTileProps) { + const { authEnabled } = useAuthConfig(); + const { data: session } = useAuthSession(); + const href = `/${doc.type}/${encodeURIComponent(doc.id)}`; + const selection = useDocumentSelection(); + + const isAnonymousAuthed = Boolean(authEnabled && session?.user?.isAnonymous); + const showDeleteButton = !(isAnonymousAuthed && doc.scope === 'unclaimed'); + const isSelected = selection.isSelected(doc); + const isInFolder = Boolean(doc.folderId); + + const [{ isDragging }, dragRef, previewRef] = useDrag< + DocumentDragItem, + void, + { isDragging: boolean } + >(() => { + return { + type: DND_DOCUMENT, + item: () => { + // If the dragged doc is selected and there are multiple selected, drag the group. + const selected = selection.getSelectedDocs(); + const dragging = isSelected && selected.length > 1 + ? selected + : [doc]; + // Reflect the actual drag in the selection so visuals match. + if (!isSelected) selection.replace([doc]); + return { + items: dragging.map(({ id, type }) => ({ id, type })), + docs: dragging, + fromFolderId: doc.folderId, + }; + }, + collect: (monitor: DragSourceMonitor) => ({ isDragging: monitor.isDragging() }), + }; + }, [doc, isSelected, selection]); + + const [{ isOver, canDrop }, dropRef] = useDrop< + DocumentDragItem, + void, + { isOver: boolean; canDrop: boolean } + >(() => ({ + accept: DND_DOCUMENT, + canDrop: (item) => { + // Only allow drop-to-merge on unfoldered docs, and don't drop a doc on itself. + if (isInFolder) return false; + return !item.items.some((it) => documentIdentityKey(it) === documentIdentityKey(doc)); + }, + drop: (item) => onMergeIntoFolder(item.docs, doc), + collect: (monitor) => ({ + isOver: monitor.isOver({ shallow: true }), + canDrop: monitor.canDrop(), + }), + }), [doc, isInFolder, onMergeIntoFolder]); + + const isDropTarget = isOver && canDrop; + + const setRefs = (node: HTMLDivElement | null) => { + dragRef(node); + dropRef(node); + previewRef(node); + }; + + const handleClick: React.MouseEventHandler = (e) => { + if (e.shiftKey || e.metaKey || e.ctrlKey) { + e.preventDefault(); + selection.select(doc, { shift: e.shiftKey, meta: e.metaKey || e.ctrlKey }); + } + }; + + return ( +
+ + + +
+ + + {doc.type === 'pdf' ? ( + + ) : doc.type === 'epub' ? ( + + ) : ( + + )} + + + {doc.name} + + + {showDeleteButton && ( + + )} +
+
+ ); +} diff --git a/src/components/doclist/views/GalleryView.tsx b/src/components/doclist/views/GalleryView.tsx new file mode 100644 index 0000000..eb8fd7b --- /dev/null +++ b/src/components/doclist/views/GalleryView.tsx @@ -0,0 +1,257 @@ +'use client'; + +import Link from 'next/link'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useDrag, useDrop } from 'react-dnd'; +import type { DocumentListDocument } from '@/types/documents'; +import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons'; +import { DocumentPreview } from '@/components/doclist/DocumentPreview'; +import { formatDocumentSize } from '@/components/doclist/formatSize'; +import { useDocumentSelection } from '../dnd/DocumentSelectionContext'; +import { DND_DOCUMENT, documentIdentityKey, type DocumentDragItem } from '../dnd/dndTypes'; + +interface GalleryViewProps { + documents: DocumentListDocument[]; + folderNameById?: Record; + onDeleteDoc: (doc: DocumentListDocument) => void; + onMergeIntoFolder: (sources: DocumentListDocument[], target: DocumentListDocument) => void; +} + +function formatDateTime(value: number | undefined): string { + if (!value || !Number.isFinite(value) || value <= 0) return 'Never'; + return new Date(value).toLocaleString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); +} + +function formatParseStatus(status: DocumentListDocument['parseStatus']): string { + if (!status) return 'N/A'; + if (status === 'pending') return 'Pending'; + if (status === 'running') return 'Running'; + if (status === 'ready') return 'Ready'; + return 'Failed'; +} + +function KindIcon({ doc, className }: { doc: DocumentListDocument; className?: string }) { + if (doc.type === 'pdf') return ; + if (doc.type === 'epub') return ; + return ; +} + +function GalleryThumb({ + doc, + active, + onClick, + onMergeIntoFolder, +}: { + doc: DocumentListDocument; + active: boolean; + onClick: () => void; + onMergeIntoFolder: (sources: DocumentListDocument[], target: DocumentListDocument) => void; +}) { + const selection = useDocumentSelection(); + const isSelected = selection.isSelected(doc); + const isInFolder = Boolean(doc.folderId); + + const [{ isDragging }, dragRef] = useDrag(() => ({ + type: DND_DOCUMENT, + item: () => { + const sel = selection.getSelectedDocs(); + const dragging = isSelected && sel.length > 1 ? sel : [doc]; + if (!isSelected) selection.replace([doc]); + return { + items: dragging.map(({ id, type }) => ({ id, type })), + docs: dragging, + fromFolderId: doc.folderId, + }; + }, + collect: (m) => ({ isDragging: m.isDragging() }), + }), [doc, isSelected, selection]); + + const [{ isOver, canDrop }, dropRef] = useDrop(() => ({ + accept: DND_DOCUMENT, + canDrop: (item) => !isInFolder && !item.items.some((it) => documentIdentityKey(it) === documentIdentityKey(doc)), + drop: (item) => onMergeIntoFolder(item.docs, doc), + collect: (m) => ({ isOver: m.isOver({ shallow: true }), canDrop: m.canDrop() }), + }), [doc, isInFolder, onMergeIntoFolder]); + + const setRefs = (node: HTMLDivElement | null) => { + dragRef(node); + dropRef(node); + }; + + return ( +
+
+ +
+
+ + + {doc.name} + +
+
+ ); +} + +export function GalleryView({ + documents, + folderNameById, + onDeleteDoc, + onMergeIntoFolder, +}: GalleryViewProps) { + const { setVisibleOrder } = useDocumentSelection(); + const railRef = useRef(null); + const [activeIdx, setActiveIdx] = useState(0); + const activeDoc = useMemo(() => documents[activeIdx], [documents, activeIdx]); + const openHref = activeDoc ? `/${activeDoc.type}/${encodeURIComponent(activeDoc.id)}` : null; + + useEffect(() => { + setVisibleOrder(documents); + }, [documents, setVisibleOrder]); + + useEffect(() => { + if (activeIdx >= documents.length) { + setActiveIdx(Math.max(0, documents.length - 1)); + } + }, [documents.length, activeIdx]); + + useEffect(() => { + const rail = railRef.current; + if (!rail) return; + rail.scrollLeft = 0; + }, [documents.length]); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (documents.length === 0) return; + const target = e.target as HTMLElement; + if (target?.closest('input, textarea, [contenteditable]')) return; + if (e.key === 'ArrowRight') { + setActiveIdx((i) => Math.min(documents.length - 1, i + 1)); + } else if (e.key === 'ArrowLeft') { + setActiveIdx((i) => Math.max(0, i - 1)); + } + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [documents.length]); + + return ( +
+
+ {activeDoc ? ( +
+
+
+ +
+
+

+ {activeDoc.name} +

+

+ {activeDoc.type.toUpperCase()} • {formatDocumentSize(activeDoc.size)} +

+
+
+ + Open + + +
+
+
+
Type
+
{activeDoc.type}
+
Size
+
{formatDocumentSize(activeDoc.size)}
+
Last opened
+
{formatDateTime(activeDoc.recentlyOpenedAt)}
+
Last modified
+
{formatDateTime(activeDoc.lastModified)}
+ {activeDoc.folderId && ( + <> +
Folder
+
+ {folderNameById?.[activeDoc.folderId] ?? activeDoc.folderId} +
+ + )} + {activeDoc.type === 'pdf' && ( + <> +
Parse status
+
{formatParseStatus(activeDoc.parseStatus)}
+ + )} +
+
+ ) : ( +

No documents to show

+ )} +
+ +
+
+ {documents.map((doc, i) => ( + setActiveIdx(i)} + onMergeIntoFolder={onMergeIntoFolder} + /> + ))} +
+
+
+ ); +} diff --git a/src/components/doclist/views/IconsView.tsx b/src/components/doclist/views/IconsView.tsx new file mode 100644 index 0000000..bbc6220 --- /dev/null +++ b/src/components/doclist/views/IconsView.tsx @@ -0,0 +1,75 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import type { DocumentListDocument, IconSize } from '@/types/documents'; +import { DocumentTile } from './DocumentTile'; +import { useDocumentSelection } from '../dnd/DocumentSelectionContext'; +import { iconsGridStyle, maxColumnsForIconGrid } from './iconsGrid'; + +interface IconsViewProps { + documents: DocumentListDocument[]; + iconSize: IconSize; + onDeleteDoc: (doc: DocumentListDocument) => void; + onMergeIntoFolder: (sources: DocumentListDocument[], target: DocumentListDocument) => void; +} + +export function IconsView({ + documents, + iconSize, + onDeleteDoc, + onMergeIntoFolder, +}: IconsViewProps) { + const { setVisibleOrder, clear } = useDocumentSelection(); + const gridRef = useRef(null); + const [suppressSingleRowStretch, setSuppressSingleRowStretch] = useState(false); + + useEffect(() => { + setVisibleOrder(documents); + }, [documents, setVisibleOrder]); + + useEffect(() => { + const node = gridRef.current; + if (!node) return; + + const recompute = () => { + const maxColumns = maxColumnsForIconGrid(iconSize, node.clientWidth); + const isSingleRow = documents.length > 0 && documents.length <= maxColumns; + setSuppressSingleRowStretch((prev) => (prev === isSingleRow ? prev : isSingleRow)); + }; + + recompute(); + + if (typeof ResizeObserver === 'undefined') return; + const observer = new ResizeObserver(() => recompute()); + observer.observe(node); + return () => observer.disconnect(); + }, [documents.length, iconSize]); + + const handleBackgroundClick: React.MouseEventHandler = (e) => { + if ((e.target as HTMLElement).closest('[data-doc-tile]')) return; + clear(); + }; + + return ( +
+
+ {documents.map((doc) => ( + + ))} +
+
+ ); +} diff --git a/src/components/doclist/views/ListView.tsx b/src/components/doclist/views/ListView.tsx new file mode 100644 index 0000000..e73f3ab --- /dev/null +++ b/src/components/doclist/views/ListView.tsx @@ -0,0 +1,220 @@ +'use client'; + +import Link from 'next/link'; +import { useEffect } from 'react'; +import { useDrag, useDrop } from 'react-dnd'; +import { Button } from '@headlessui/react'; +import type { + DocumentListDocument, + SortBy, + SortDirection, +} from '@/types/documents'; +import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons'; +import { formatDocumentSize } from '@/components/doclist/formatSize'; +import { useDocumentSelection } from '../dnd/DocumentSelectionContext'; +import { DND_DOCUMENT, documentIdentityKey, type DocumentDragItem } from '../dnd/dndTypes'; + +interface ListViewProps { + documents: DocumentListDocument[]; + sortBy: SortBy; + sortDirection: SortDirection; + onSortChange: (sortBy: SortBy, direction: SortDirection) => void; + onDeleteDoc: (doc: DocumentListDocument) => void; + onMergeIntoFolder: (sources: DocumentListDocument[], target: DocumentListDocument) => void; +} + +function formatDate(ms: number): string { + const d = new Date(ms); + return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); +} + +function KindIcon({ doc }: { doc: DocumentListDocument }) { + if (doc.type === 'pdf') return ; + if (doc.type === 'epub') return ; + return ; +} + +function HeaderCell({ + label, + field, + sortBy, + sortDirection, + onSortChange, + className, + align = 'left', +}: { + label: string; + field: SortBy; + sortBy: SortBy; + sortDirection: SortDirection; + onSortChange: (b: SortBy, d: SortDirection) => void; + className?: string; + align?: 'left' | 'right'; +}) { + const active = sortBy === field; + const arrow = active ? (sortDirection === 'asc' ? '↑' : '↓') : ''; + return ( + + ); +} + +function DocRow({ + doc, + onDeleteDoc, + onMergeIntoFolder, +}: { + doc: DocumentListDocument; + onDeleteDoc: (d: DocumentListDocument) => void; + onMergeIntoFolder: (sources: DocumentListDocument[], target: DocumentListDocument) => void; +}) { + const selection = useDocumentSelection(); + const isSelected = selection.isSelected(doc); + const isInFolder = Boolean(doc.folderId); + const href = `/${doc.type}/${encodeURIComponent(doc.id)}`; + + const [{ isDragging }, dragRef] = useDrag(() => ({ + type: DND_DOCUMENT, + item: () => { + const selected = selection.getSelectedDocs(); + const dragging = isSelected && selected.length > 1 ? selected : [doc]; + if (!isSelected) selection.replace([doc]); + return { + items: dragging.map(({ id, type }) => ({ id, type })), + docs: dragging, + fromFolderId: doc.folderId, + }; + }, + collect: (m) => ({ isDragging: m.isDragging() }), + }), [doc, isSelected, selection]); + + const [{ isOver, canDrop }, dropRef] = useDrop(() => ({ + accept: DND_DOCUMENT, + canDrop: (item) => !isInFolder && !item.items.some((it) => documentIdentityKey(it) === documentIdentityKey(doc)), + drop: (item) => onMergeIntoFolder(item.docs, doc), + collect: (m) => ({ isOver: m.isOver({ shallow: true }), canDrop: m.canDrop() }), + }), [doc, isInFolder, onMergeIntoFolder]); + + const setRefs = (node: HTMLDivElement | null) => { + dragRef(node); + dropRef(node); + }; + + const isTarget = isOver && canDrop; + + const handleClick: React.MouseEventHandler = (e) => { + if (e.shiftKey || e.metaKey || e.ctrlKey) { + e.preventDefault(); + selection.select(doc, { shift: e.shiftKey, meta: e.metaKey || e.ctrlKey }); + } + }; + + return ( +
+ + + {doc.name} + + {doc.type} + + {formatDocumentSize(doc.size)} + + + {formatDate(doc.lastModified)} + + +
+ ); +} + +export function ListView({ + documents, + sortBy, + sortDirection, + onSortChange, + onDeleteDoc, + onMergeIntoFolder, +}: ListViewProps) { + const { setVisibleOrder, clear } = useDocumentSelection(); + + useEffect(() => { + setVisibleOrder(documents); + }, [documents, setVisibleOrder]); + + const handleBackgroundClick: React.MouseEventHandler = (e) => { + if ((e.target as HTMLElement).closest('[data-doc-tile]')) return; + clear(); + }; + + return ( +
+
+ + + + + +
+
+ {documents.map((doc) => ( + + ))} +
+
+ ); +} diff --git a/src/components/doclist/views/iconsGrid.ts b/src/components/doclist/views/iconsGrid.ts new file mode 100644 index 0000000..4ebb63a --- /dev/null +++ b/src/components/doclist/views/iconsGrid.ts @@ -0,0 +1,43 @@ +import type { CSSProperties } from 'react'; +import type { IconSize } from '@/types/documents'; + +const TILE_WIDTH_PX: Record = { + sm: 112, + md: 136, + lg: 162, + xl: 192, +}; + +const SMALL_GRID_ITEM_COUNT = 3; +export const GRID_GAP_PX = 12; + +export function iconTileWidthPx(iconSize: IconSize): number { + return TILE_WIDTH_PX[iconSize]; +} + +export function maxColumnsForIconGrid(iconSize: IconSize, gridWidthPx: number): number { + if (!Number.isFinite(gridWidthPx) || gridWidthPx <= 0) return 1; + const tileWidth = iconTileWidthPx(iconSize); + return Math.max(1, Math.floor((gridWidthPx + GRID_GAP_PX) / (tileWidth + GRID_GAP_PX))); +} + +function responsiveGridTemplate(iconSize: IconSize, itemCount: number, suppressStretch: boolean): string { + const width = TILE_WIDTH_PX[iconSize]; + if (suppressStretch || itemCount <= SMALL_GRID_ITEM_COUNT) { + return `repeat(auto-fill, minmax(min(100%, ${width}px), ${width}px))`; + } + return `repeat(auto-fit, minmax(${width}px, 1fr))`; +} + +export function iconsGridStyle( + iconSize: IconSize, + itemCount: number, + options?: { suppressSingleRowStretch?: boolean }, +): CSSProperties { + const suppressSingleRowStretch = Boolean(options?.suppressSingleRowStretch); + return { + gridTemplateColumns: responsiveGridTemplate(iconSize, itemCount, suppressSingleRowStretch), + gap: `${GRID_GAP_PX}px`, + justifyContent: suppressSingleRowStretch || itemCount <= SMALL_GRID_ITEM_COUNT ? 'start' : undefined, + }; +} diff --git a/src/components/doclist/window/FinderSidebar.tsx b/src/components/doclist/window/FinderSidebar.tsx new file mode 100644 index 0000000..d6216cb --- /dev/null +++ b/src/components/doclist/window/FinderSidebar.tsx @@ -0,0 +1,374 @@ +'use client'; + +import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'; +import { Fragment, useRef, type CSSProperties, type ReactNode } from 'react'; +import { useDrop } from 'react-dnd'; +import type { Folder, SidebarFilter } from '@/types/documents'; +import { PDFIcon, EPUBIcon, FileIcon, DotsHorizontalIcon } from '@/components/icons/Icons'; +import { FolderIcon, HomeIcon, ClockIcon, FolderPlusIcon } from './finderIcons'; +import { DND_DOCUMENT, type DocumentDragItem } from '../dnd/dndTypes'; + +interface FinderSidebarProps { + filter: SidebarFilter; + onFilterChange: (filter: SidebarFilter) => void; + folders: Folder[]; + counts: { all: number; pdf: number; epub: number; html: number }; + onDeleteFolder: (folderId: string) => void; + onNewFolder: () => void; + onClearFolders: () => void; + /** When dragging onto a folder row, move dropped docs into that folder. */ + onDropOnFolder: (folderId: string, item: DocumentDragItem) => void; + /** Width controls (desktop only). */ + width: number; + onWidthChange: (px: number) => void; + topSlot?: ReactNode; + bottomSlot?: ReactNode; + /** Fired for explicit row/button actions (used to close mobile drawer). */ + onRowAction?: () => void; +} + +const MIN_WIDTH = 168; +const MAX_WIDTH = 320; + +interface SidebarRowProps { + active: boolean; + onClick: () => void; + icon: ReactNode; + label: string; + count?: number; + countClassName?: string; + trailing?: ReactNode; + isDropTarget?: boolean; +} + +function SidebarRow({ + active, + onClick, + icon, + label, + count, + countClassName, + trailing, + isDropTarget, +}: SidebarRowProps) { + return ( + + ); +} + +function FolderRow({ + folder, + active, + onClick, + onDelete, + onDropOnFolder, +}: { + folder: Folder; + active: boolean; + onClick: () => void; + onDelete: () => void; + onDropOnFolder: (folderId: string, item: DocumentDragItem) => void; +}) { + const [{ isOver, canDrop }, dropRef] = useDrop< + DocumentDragItem, + void, + { isOver: boolean; canDrop: boolean } + >(() => ({ + accept: DND_DOCUMENT, + drop: (item) => { + onDropOnFolder(folder.id, item); + }, + canDrop: (item) => { + // Don't accept if all items are already in this folder. + return item.docs.some((d) => d.folderId !== folder.id); + }, + collect: (monitor) => ({ + isOver: monitor.isOver({ shallow: true }), + canDrop: monitor.canDrop(), + }), + }), [folder.id, onDropOnFolder]); + + const isDropTarget = isOver && canDrop; + return ( +
} + className="group/folder relative" + > + } + label={folder.name} + count={folder.documents.length} + countClassName="group-hover/folder:-translate-x-6 group-focus-within/folder:-translate-x-6" + isDropTarget={isDropTarget} + /> + +
+ ); +} + +function SectionHeader({ + children, + isFirst, + rightSlot, +}: { + children: ReactNode; + isFirst?: boolean; + rightSlot?: ReactNode; +}) { + return ( +
+ {children} + {rightSlot && {rightSlot}} +
+ ); +} + +export function FinderSidebar({ + filter, + onFilterChange, + folders, + counts, + onDeleteFolder, + onNewFolder, + onClearFolders, + onDropOnFolder, + width, + onWidthChange, + topSlot, + bottomSlot, + onRowAction, +}: FinderSidebarProps) { + const startRef = useRef<{ x: number; w: number } | null>(null); + + const onResizeStart = (e: React.PointerEvent) => { + (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); + startRef.current = { x: e.clientX, w: width }; + }; + const onResizeMove = (e: React.PointerEvent) => { + if (!startRef.current) return; + const delta = e.clientX - startRef.current.x; + const next = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, startRef.current.w + delta)); + onWidthChange(next); + }; + const onResizeEnd = (e: React.PointerEvent) => { + (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); + startRef.current = null; + }; + + return ( + + ); +} diff --git a/src/components/doclist/window/FinderStatusBar.tsx b/src/components/doclist/window/FinderStatusBar.tsx new file mode 100644 index 0000000..e8c74de --- /dev/null +++ b/src/components/doclist/window/FinderStatusBar.tsx @@ -0,0 +1,34 @@ +'use client'; + +import { formatDocumentSize } from '@/components/doclist/formatSize'; + +interface FinderStatusBarProps { + itemCount: number; + selectedCount: number; + totalSize: number; + summary?: string; +} + +export function FinderStatusBar({ + itemCount, + selectedCount, + totalSize, + summary, +}: FinderStatusBarProps) { + return ( +
+ {summary} + + {selectedCount > 0 + ? `${selectedCount} of ${itemCount} selected` + : `${itemCount} item${itemCount === 1 ? '' : 's'}`} + + {formatDocumentSize(totalSize)} + +
+ ); +} diff --git a/src/components/doclist/window/FinderToolbar.tsx b/src/components/doclist/window/FinderToolbar.tsx new file mode 100644 index 0000000..4e57f18 --- /dev/null +++ b/src/components/doclist/window/FinderToolbar.tsx @@ -0,0 +1,226 @@ +'use client'; + +import { Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/react'; +import type { IconSize, SortBy, SortDirection, ViewMode } from '@/types/documents'; +import { + IconsViewIcon, + ListViewIcon, + GalleryViewIcon, + SearchIcon, + HamburgerIcon, +} from './finderIcons'; +import { ChevronUpDownIcon } from '@/components/icons/Icons'; +import type { ReactNode } from 'react'; + +interface FinderToolbarProps { + viewMode: ViewMode; + onViewModeChange: (mode: ViewMode) => void; + iconSize: IconSize; + onIconSizeChange: (size: IconSize) => void; + sortBy: SortBy; + sortDirection: SortDirection; + onSortByChange: (s: SortBy) => void; + onSortDirectionToggle: () => void; + query: string; + onQueryChange: (q: string) => void; + onToggleSidebar: () => void; + isSidebarOpen: boolean; + showSortControls?: boolean; + /** App-level content rendered at the far left (brand/logo). */ + leftSlot?: ReactNode; + /** App-level content rendered at the far right (settings, user menu). */ + rightSlot?: ReactNode; +} + +const VIEW_BUTTONS: Array<{ value: ViewMode; label: string; Icon: typeof IconsViewIcon }> = [ + { value: 'icons', label: 'Icons', Icon: IconsViewIcon }, + { value: 'list', label: 'List', Icon: ListViewIcon }, + { value: 'gallery', label: 'Gallery', Icon: GalleryViewIcon }, +]; + +const SORT_OPTIONS: Array<{ value: SortBy; label: string; asc: string; desc: string }> = [ + { value: 'name', label: 'Name', asc: 'A → Z', desc: 'Z → A' }, + { value: 'type', label: 'Kind', asc: 'A → Z', desc: 'Z → A' }, + { value: 'date', label: 'Modified', asc: 'Oldest', desc: 'Newest' }, + { value: 'size', label: 'Size', asc: 'Smallest', desc: 'Largest' }, +]; + +const ICON_SIZES: Array<{ value: IconSize; label: string }> = [ + { value: 'sm', label: 'S' }, + { value: 'md', label: 'M' }, + { value: 'lg', label: 'L' }, + { value: 'xl', label: 'XL' }, +]; + +// Match SettingsModal / UserMenu trigger sizing exactly so all bar buttons share one rhythm. +const TOOLBAR_BTN = + 'inline-flex items-center py-1 px-2 rounded-md border bg-base text-xs transition-all duration-200 ease-out hover:scale-[1.01]'; +const TOOLBAR_BTN_INACTIVE = + 'border-offbase text-foreground hover:text-accent hover:border-accent hover:bg-offbase'; +const TOOLBAR_BTN_ACTIVE = 'border-accent bg-offbase text-accent'; + +// Pill-grouped segmented control. Outer pill carries the border; inner segments are +// borderless and rely on bg/text color to show active/hover. Sized so the whole pill +// matches the height of a standalone TOOLBAR_BTN. +const PILL = 'inline-flex items-center rounded-md border border-offbase bg-base p-0.5 gap-0.5 shrink-0'; +const PILL_SEGMENT = + 'inline-flex items-center justify-center rounded-[5px] text-xs transition-colors duration-200 ease-out'; +const PILL_SEGMENT_INACTIVE = 'text-muted hover:bg-offbase hover:text-accent'; +const PILL_SEGMENT_ACTIVE = 'bg-offbase text-accent'; + +export function FinderToolbar({ + viewMode, + onViewModeChange, + iconSize, + onIconSizeChange, + sortBy, + sortDirection, + onSortByChange, + onSortDirectionToggle, + query, + onQueryChange, + onToggleSidebar, + isSidebarOpen, + showSortControls = true, + leftSlot, + rightSlot, +}: FinderToolbarProps) { + const currentSort = SORT_OPTIONS.find((o) => o.value === sortBy) ?? SORT_OPTIONS[0]; + const directionLabel = sortDirection === 'asc' ? currentSort.asc : currentSort.desc; + + return ( +
+
+ {leftSlot && ( +
+ {leftSlot} +
+ )} + + + +
+ {VIEW_BUTTONS.map(({ value, label, Icon }) => { + const active = viewMode === value; + const isIconsToggle = value === 'icons'; + return ( +
+ + {isIconsToggle && viewMode === 'icons' && ( +
+
+ {ICON_SIZES.map(({ value: sizeValue, label: sizeLabel }) => { + const sizeActive = iconSize === sizeValue; + return ( + + ); + })} +
+
+ )} +
+ ); + })} +
+ + {showSortControls && ( +
+ + + + {currentSort.label} + + + + {SORT_OPTIONS.map((opt) => ( + + `cursor-pointer select-none rounded-sm py-1.5 px-2.5 text-xs ${ + active ? 'bg-offbase text-accent' : 'text-foreground' + } ${selected ? 'font-semibold' : ''}` + } + > + {opt.label} + + ))} + + +
+ )} + +
+ +
+ + onQueryChange(e.target.value)} + placeholder="Search" + className="flex-1 min-w-0 bg-transparent outline-none text-xs text-foreground placeholder:text-muted" + /> +
+ + {rightSlot && ( +
+ {rightSlot} +
+ )} +
+
+ ); +} diff --git a/src/components/doclist/window/FinderWindow.tsx b/src/components/doclist/window/FinderWindow.tsx new file mode 100644 index 0000000..be5f157 --- /dev/null +++ b/src/components/doclist/window/FinderWindow.tsx @@ -0,0 +1,102 @@ +'use client'; + +import { Dialog, DialogPanel, Transition, TransitionChild } from '@headlessui/react'; +import { Fragment, useEffect, useState, type ReactNode } from 'react'; + +interface FinderWindowProps { + toolbar: ReactNode; + sidebar: ReactNode; + statusBar: ReactNode; + children: ReactNode; + /** Controlled sidebar open/closed state (drives mobile drawer + desktop collapse). */ + sidebarOpen: boolean; + /** Handles close requests from mobile drawer interactions (backdrop tap, Esc). */ + onRequestSidebarClose?: () => void; +} + +const NARROW_QUERY = '(max-width: 767px)'; + +export function useIsNarrow(): boolean { + const [isNarrow, setIsNarrow] = useState(() => + typeof window !== 'undefined' ? window.matchMedia(NARROW_QUERY).matches : false, + ); + useEffect(() => { + if (typeof window === 'undefined') return; + const mq = window.matchMedia(NARROW_QUERY); + const update = () => setIsNarrow(mq.matches); + update(); + mq.addEventListener('change', update); + return () => mq.removeEventListener('change', update); + }, []); + return isNarrow; +} + +/** + * Finder-style file pane: sidebar + toolbar + content + status bar. + * No separate window chrome — it sits flush under the existing app header. + */ +export function FinderWindow({ + toolbar, + sidebar, + statusBar, + children, + sidebarOpen, + onRequestSidebarClose, +}: FinderWindowProps) { + const isNarrow = useIsNarrow(); + + return ( +
+ {toolbar} + +
+ {!isNarrow && sidebarOpen && ( +
{sidebar}
+ )} + +
+ {children} +
+
+ + {statusBar} + + {/* Mobile drawer */} + + undefined)} + className="relative z-40 md:hidden" + > + +
+ +
+ + + {sidebar} + + +
+
+
+
+ ); +} diff --git a/src/components/doclist/window/finderIcons.tsx b/src/components/doclist/window/finderIcons.tsx new file mode 100644 index 0000000..f2f0fa3 --- /dev/null +++ b/src/components/doclist/window/finderIcons.tsx @@ -0,0 +1,102 @@ +import type { SVGProps } from 'react'; + +type IconProps = SVGProps; + +const baseSvg = (props: IconProps) => { + const { width = '1em', height = '1em', ...rest } = props; + return { + xmlns: 'http://www.w3.org/2000/svg', + viewBox: '0 0 24 24', + fill: 'none', + stroke: 'currentColor', + strokeWidth: 1.6, + strokeLinecap: 'round' as const, + strokeLinejoin: 'round' as const, + width, + height, + ...rest, + }; +}; + +export const SearchIcon = (props: IconProps) => ( + + + + +); + +export const FolderIcon = (props: IconProps) => ( + + + +); + +export const FolderPlusIcon = (props: IconProps) => ( + + + + +); + +export const SidebarIcon = (props: IconProps) => ( + + + + +); + +export const IconsViewIcon = (props: IconProps) => ( + + + + + + +); + +export const ListViewIcon = (props: IconProps) => ( + + + +); + +export const GalleryViewIcon = (props: IconProps) => ( + + + + + + +); + +export const ChevronRightSmall = (props: IconProps) => ( + + + +); + +export const ChevronLeftSmall = (props: IconProps) => ( + + + +); + +export const HamburgerIcon = (props: IconProps) => ( + + + +); + +export const ClockIcon = (props: IconProps) => ( + + + + +); + +export const HomeIcon = (props: IconProps) => ( + + + + +); diff --git a/src/components/documents/DocumentSkeleton.tsx b/src/components/documents/DocumentSkeleton.tsx index 0adccda..9f39b7b 100644 --- a/src/components/documents/DocumentSkeleton.tsx +++ b/src/components/documents/DocumentSkeleton.tsx @@ -8,7 +8,7 @@ export function DocumentSkeleton() { icon: '⚠️', duration: 5000, }); - }, 3000); + }, 10000); return () => clearTimeout(timer); }, []); diff --git a/src/components/documents/DocumentUploader.tsx b/src/components/documents/DocumentUploader.tsx index b850f4a..c5ce9fe 100644 --- a/src/components/documents/DocumentUploader.tsx +++ b/src/components/documents/DocumentUploader.tsx @@ -1,19 +1,35 @@ 'use client'; -import { useState, useCallback } from 'react'; +import { useState, useCallback, useId, type ReactNode } from 'react'; import { useDropzone } from 'react-dropzone'; import { UploadIcon } from '@/components/icons/Icons'; import { useDocuments } from '@/contexts/DocumentContext'; import { uploadDocxAsPdf } from '@/lib/client/api/documents'; import { useFeatureFlag } from '@/contexts/RuntimeConfigContext'; - interface DocumentUploaderProps { className?: string; - variant?: 'default' | 'compact'; + variant?: 'default' | 'compact' | 'overlay'; + children?: ReactNode; + onUploadBatchChange?: (state: UploadBatchState) => void; } -export function DocumentUploader({ className = '', variant = 'default' }: DocumentUploaderProps) { +export interface UploadBatchState { + uploaderId: string; + isActive: boolean; + totalFiles: number; + completedFiles: number; + phase: 'uploading' | 'converting'; + currentFileName: string | null; +} + +export function DocumentUploader({ + className = '', + variant = 'default', + children, + onUploadBatchChange, +}: DocumentUploaderProps) { + const uploaderId = useId(); const enableDocx = useFeatureFlag('enableDocxConversion'); const { addPDFDocument: addPDF, @@ -25,30 +41,86 @@ export function DocumentUploader({ className = '', variant = 'default' }: Docume const [isConverting, setIsConverting] = useState(false); const [error, setError] = useState(null); + const emitBatchState = useCallback((state: Omit) => { + onUploadBatchChange?.({ uploaderId, ...state }); + }, [onUploadBatchChange, uploaderId]); + const onDrop = useCallback(async (acceptedFiles: File[]) => { if (!acceptedFiles || acceptedFiles.length === 0) return; + const totalFiles = acceptedFiles.length; + let completedFiles = 0; + setIsUploading(true); setError(null); + emitBatchState({ + isActive: true, + totalFiles, + completedFiles, + phase: 'uploading', + currentFileName: acceptedFiles[0]?.name ?? null, + }); try { for (const file of acceptedFiles) { if (file.type === 'application/pdf') { + emitBatchState({ + isActive: true, + totalFiles, + completedFiles, + phase: 'uploading', + currentFileName: file.name, + }); await addPDF(file); + completedFiles += 1; } else if (file.type === 'application/epub+zip') { + emitBatchState({ + isActive: true, + totalFiles, + completedFiles, + phase: 'uploading', + currentFileName: file.name, + }); await addEPUB(file); + completedFiles += 1; } else if (file.type === 'text/plain' || file.type === 'text/markdown' || file.name.endsWith('.md')) { + emitBatchState({ + isActive: true, + totalFiles, + completedFiles, + phase: 'uploading', + currentFileName: file.name, + }); await addHTML(file); + completedFiles += 1; } else if (enableDocx && file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') { // Preserve prior UX: show "Converting DOCX..." state rather than generic uploading. setIsUploading(false); setIsConverting(true); + emitBatchState({ + isActive: true, + totalFiles, + completedFiles, + phase: 'converting', + currentFileName: file.name, + }); // Convert+upload directly on the server. Use sha(docx) as stable ID to avoid duplicates. await uploadDocxAsPdf(file); await refreshDocuments(); setIsConverting(false); setIsUploading(true); + completedFiles += 1; + } else { + continue; } + + emitBatchState({ + isActive: true, + totalFiles, + completedFiles, + phase: 'uploading', + currentFileName: null, + }); } } catch (err) { setError('Failed to upload file. Please try again.'); @@ -56,8 +128,15 @@ export function DocumentUploader({ className = '', variant = 'default' }: Docume } finally { setIsUploading(false); setIsConverting(false); + emitBatchState({ + isActive: false, + totalFiles, + completedFiles, + phase: 'uploading', + currentFileName: null, + }); } - }, [addHTML, addPDF, addEPUB, refreshDocuments, enableDocx]); + }, [addHTML, addPDF, addEPUB, refreshDocuments, enableDocx, emitBatchState]); const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop, @@ -71,31 +150,86 @@ export function DocumentUploader({ className = '', variant = 'default' }: Docume } : {}) }, multiple: true, - disabled: isUploading || isConverting + disabled: isUploading || isConverting, + noClick: variant === 'overlay', + noKeyboard: variant === 'overlay' }); - const containerBase = `w-full border-2 border-dashed rounded-lg ${isDragActive ? 'border-accent bg-base' : 'border-muted'} transform transition-transform duration-200 ease-in-out ${(isUploading || isConverting) ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:border-accent hover:bg-base hover:scale-[1.008]'} ${className}`; - const paddingClass = variant === 'compact' ? 'py-1.5 px-2' : 'py-5 px-3'; + const containerBase = `group w-full rounded transform transition-all duration-200 ease-in-out ${ + variant === 'compact' ? 'hover:scale-[1.01]' : '' + } ${ + isUploading || isConverting ? 'cursor-not-allowed opacity-50' : 'cursor-pointer' + } ${className}`; + + const borderBgClass = + variant === 'compact' + ? `${ + isDragActive + ? 'border border-accent bg-offbase text-accent' + : 'border border-dashed border-offbase text-foreground hover:border-accent hover:bg-offbase hover:text-accent' + }` + : `${ + isDragActive + ? 'border-2 border-dashed border-accent bg-base text-foreground' + : 'border-2 border-dashed border-muted bg-transparent text-foreground hover:border-accent hover:bg-base hover:scale-[1.01]' + }`; + + const paddingClass = variant === 'compact' ? 'py-1 px-2 rounded-md' : 'py-5 px-3 rounded-lg'; + + if (variant === 'overlay') { + const rootProps = getRootProps(); + return ( +
+ + {children} + {isDragActive && ( +
+
+ +

+ Drop files here to upload +

+

+ {enableDocx + ? 'Accepts PDF, EPUB, TXT, MD, or DOCX' + : 'Accepts PDF, EPUB, TXT, or MD'} +

+ {error && ( +

+ Upload failed: {error} — try again. +

+ )} +
+
+ )} + {!isDragActive && error && ( +
+ Upload failed: {error} — try again. +
+ )} +
+ ); + } return (
{variant === 'compact' ? ( -
- +
+ {isUploading ? ( -

Uploading…

+

Uploading…

) : isConverting ? ( -

Converting DOCX…

+

Converting DOCX…

) : ( -
-

- {isDragActive ? 'Drop files here' : 'Drop files or click'} +

+

+ {isDragActive ? 'Drop files here' : 'Upload documents'}

- {error &&

{error}

} + {error &&

{error}

}
)}
diff --git a/src/components/icons/Icons.tsx b/src/components/icons/Icons.tsx index 9e0d10b..74c6e8f 100644 --- a/src/components/icons/Icons.tsx +++ b/src/components/icons/Icons.tsx @@ -393,6 +393,24 @@ export function DotsVerticalIcon(props: React.SVGProps) { ); } +export function DotsHorizontalIcon(props: React.SVGProps) { + return ( + + + + + + ); +} + export function ChevronLeftIcon(props: React.SVGProps) { return ( void; - close: () => void; -}; - type OnboardingFlowContextValue = { - requestOpenSettings: (options?: SettingsOpenOptions) => Promise; - registerSettingsController: (controller: SettingsController | null) => void; + changelogOpenSignal: number; }; const OnboardingFlowContext = createContext(null); @@ -61,9 +52,9 @@ async function fetchClaimableCounts(): Promise { return toClaimableCounts(data); } -async function getMigrationPromptState(): Promise { +async function getMigrationPromptState(privacyGateSatisfied: boolean): Promise { const cfg = await getAppConfig(); - if (!cfg?.privacyAccepted || cfg.documentsMigrationPrompted) { + if (!privacyGateSatisfied || cfg?.documentsMigrationPrompted) { return { shouldPrompt: false, localCount: 0, missingCount: 0 }; } @@ -127,142 +118,131 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) { localCount: 0, missingCount: 0, }); + const [changelogOpenSignal, setChangelogOpenSignal] = useState(0); - const settingsControllerRef = useRef(null); const pendingChangelogOpenRef = useRef(false); - const runningAdvanceRef = useRef(false); const claimDismissedUsersRef = useRef>(new Set()); const changelogVersionCheckKeyRef = useRef(null); const changelogVersionCheckInFlightRef = useRef(null); - const openSettingsNow = useCallback((options?: SettingsOpenOptions) => { - settingsControllerRef.current?.open(options); - }, []); + const runOnceFlowRef = useRef<() => Promise>(async () => {}); - const advanceFlow = useCallback(async () => { - if (runningAdvanceRef.current) { - return; - } - runningAdvanceRef.current = true; - try { - const local = await readLocalOnboardingSnapshot(); - if (authEnabled && !local.privacyAccepted) { - setActiveBlockingModal('privacy'); - return; - } - if (activeBlockingModal === 'privacy') { - setActiveBlockingModal(null); - } else if (activeBlockingModal) { - return; - } + const runFlow = useMemo( + () => createCoalescedAsyncRunner(async () => { + await runOnceFlowRef.current(); + }), + [], + ); - if (authEnabled && userId && !isAnonymous && !claimDismissedUsersRef.current.has(userId)) { - const counts = await fetchClaimableCounts(); - const total = counts.documents + counts.audiobooks + counts.preferences + counts.progress; - if (total > 0) { - setClaimableCounts(counts); - setActiveBlockingModal('claim'); - return; - } + const runOnceFlow = useCallback(async () => { + const local = await readLocalOnboardingSnapshot(); + const privacyRequired = authEnabled; + const privacyAccepted = !privacyRequired || local.privacyAccepted; + + const isClaimEligible = Boolean( + authEnabled + && userId + && !isAnonymous + && !claimDismissedUsersRef.current.has(userId), + ); + + let claimCounts = EMPTY_CLAIM_COUNTS; + let claimHasData = false; + + if (isClaimEligible) { + claimCounts = await fetchClaimableCounts(); + const total = claimCounts.documents + claimCounts.audiobooks + claimCounts.preferences + claimCounts.progress; + claimHasData = total > 0; + if (!claimHasData && userId) { claimDismissedUsersRef.current.add(userId); } - - const migrationState = await getMigrationPromptState(); - if (migrationState.shouldPrompt) { - setMigrationCounts({ - localCount: migrationState.localCount, - missingCount: migrationState.missingCount, - }); - setActiveBlockingModal('migration'); - return; - } - - if (!local.firstVisitSettingsOpened) { - await setFirstVisit(true); - openSettingsNow(); - return; - } - - if (pendingChangelogOpenRef.current) { - pendingChangelogOpenRef.current = false; - openSettingsNow({ changelog: true }); - } - } finally { - runningAdvanceRef.current = false; - } - }, [activeBlockingModal, authEnabled, isAnonymous, openSettingsNow, userId]); - - const requestOpenSettings = useCallback(async (options?: SettingsOpenOptions): Promise => { - const local = await readLocalOnboardingSnapshot(); - if (authEnabled && !local.privacyAccepted) { - if (options?.changelog) { - pendingChangelogOpenRef.current = true; - } - settingsControllerRef.current?.close(); - return false; } - if (activeBlockingModal) { - if (options?.changelog) { - pendingChangelogOpenRef.current = true; - } - return false; + const migrationState = await getMigrationPromptState(privacyAccepted); + + const nextStep = resolveNextOnboardingStep({ + privacyRequired, + privacyAccepted, + claimEligible: isClaimEligible, + claimHasData, + migrationRequired: migrationState.shouldPrompt, + changelogPending: pendingChangelogOpenRef.current, + }); + + if (nextStep === 'privacy') { + setActiveBlockingModal('privacy'); + return; } - if (!settingsControllerRef.current) { - if (options?.changelog) { - pendingChangelogOpenRef.current = true; - } - return false; + if (nextStep === 'claim') { + setClaimableCounts(claimCounts); + setActiveBlockingModal('claim'); + return; } - settingsControllerRef.current.open(options); - return true; - }, [activeBlockingModal, authEnabled]); - - const registerSettingsController = useCallback((controller: SettingsController | null) => { - settingsControllerRef.current = controller; - if (controller) { - void advanceFlow(); + if (nextStep === 'migration') { + setMigrationCounts({ + localCount: migrationState.localCount, + missingCount: migrationState.missingCount, + }); + setActiveBlockingModal('migration'); + return; } - }, [advanceFlow]); + + setActiveBlockingModal(null); + + if (!local.firstVisitSettingsOpened) { + await setFirstVisit(true); + } + + if (nextStep === 'changelog') { + pendingChangelogOpenRef.current = false; + setChangelogOpenSignal((value) => value + 1); + } + }, [authEnabled, isAnonymous, userId]); + + runOnceFlowRef.current = runOnceFlow; const handleClaimComplete = useCallback(() => { if (userId) { claimDismissedUsersRef.current.add(userId); } setActiveBlockingModal(null); - void advanceFlow(); - }, [advanceFlow, userId]); + void runFlow(); + }, [runFlow, userId]); const handleMigrationComplete = useCallback(() => { setActiveBlockingModal(null); - void advanceFlow(); - }, [advanceFlow]); + void runFlow(); + }, [runFlow]); const handlePrivacyAccepted = useCallback(() => { setActiveBlockingModal(null); - void advanceFlow(); - }, [advanceFlow]); + void runFlow(); + }, [runFlow]); useEffect(() => { - void advanceFlow(); - }, [advanceFlow, authEnabled, isAnonymous, userId]); + void runFlow(); + }, [authEnabled, isAnonymous, runFlow, userId]); useEffect(() => { if (!authEnabled) { return; } const onPrivacyAccepted = () => { - void advanceFlow(); + void runFlow(); }; window.addEventListener('openreader:privacyAccepted', onPrivacyAccepted); return () => { window.removeEventListener('openreader:privacyAccepted', onPrivacyAccepted); }; - }, [advanceFlow, authEnabled]); + }, [authEnabled, runFlow]); useEffect(() => { + if (!authEnabled) { + return () => { }; + } + return scheduleChangelogCheck({ authEnabled, isSessionPending, @@ -273,17 +253,16 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) { postCheck: async (currentVersion) => postChangelogVersionCheck(currentVersion), onShouldOpen: () => { pendingChangelogOpenRef.current = true; - void advanceFlow(); + void runFlow(); }, delayMs: 120, retryDelayMs: 400, }); - }, [advanceFlow, authEnabled, isSessionPending, runtimeConfig.appVersion, userId]); + }, [authEnabled, isSessionPending, runFlow, runtimeConfig.appVersion, userId]); const contextValue = useMemo(() => ({ - requestOpenSettings, - registerSettingsController, - }), [registerSettingsController, requestOpenSettings]); + changelogOpenSignal, + }), [changelogOpenSignal]); return ( diff --git a/src/lib/client/cache/previews.ts b/src/lib/client/cache/previews.ts index 736c273..9fb067f 100644 --- a/src/lib/client/cache/previews.ts +++ b/src/lib/client/cache/previews.ts @@ -8,6 +8,7 @@ import { documentPreviewFallbackUrl, documentPreviewPresignUrl } from '@/lib/cli const inMemoryPreviewUrlCache = new Map(); const inFlightPreviewPrime = new Map>(); +const inFlightPersistedPreviewUrl = new Map>(); const PREVIEW_CACHE_SCHEMA_VERSION = 4; function revokeIfBlobUrl(url: string | null | undefined): void { @@ -37,6 +38,7 @@ export function clearInMemoryDocumentPreviewCache(): void { revokeIfBlobUrl(value); } inMemoryPreviewUrlCache.clear(); + inFlightPersistedPreviewUrl.clear(); } export async function getPersistedDocumentPreviewUrl( @@ -44,29 +46,52 @@ export async function getPersistedDocumentPreviewUrl( lastModified: number, cacheKey: string, ): Promise { - const row = await getDocumentPreviewCache(docId); - if (!row) return null; + const cachedUrl = getInMemoryDocumentPreviewUrl(cacheKey); + if (cachedUrl) return cachedUrl; - if (Number((row as { previewVersion?: number }).previewVersion ?? 1) !== PREVIEW_CACHE_SCHEMA_VERSION) { - await removeDocumentPreviewCache(docId).catch(() => {}); - return null; + const persistedKey = `${cacheKey}:${Number(lastModified)}`; + const existing = inFlightPersistedPreviewUrl.get(persistedKey); + if (existing) { + return existing; } - if (Number(row.lastModified) !== Number(lastModified)) { - await removeDocumentPreviewCache(docId).catch(() => {}); - return null; - } + const promise = (async (): Promise => { + const row = await getDocumentPreviewCache(docId); + if (!row) return null; - const contentType = row.contentType || 'image/jpeg'; - const bytes = row.data; - if (!(bytes instanceof ArrayBuffer) || bytes.byteLength === 0) { - await removeDocumentPreviewCache(docId).catch(() => {}); - return null; - } + if (Number((row as { previewVersion?: number }).previewVersion ?? 1) !== PREVIEW_CACHE_SCHEMA_VERSION) { + await removeDocumentPreviewCache(docId).catch(() => {}); + return null; + } - const url = URL.createObjectURL(new Blob([bytes], { type: contentType })); - setInMemoryDocumentPreviewUrl(cacheKey, url); - return url; + if (Number(row.lastModified) !== Number(lastModified)) { + await removeDocumentPreviewCache(docId).catch(() => {}); + return null; + } + + const latestCachedUrl = getInMemoryDocumentPreviewUrl(cacheKey); + if (latestCachedUrl) return latestCachedUrl; + + const contentType = row.contentType || 'image/jpeg'; + const bytes = row.data; + if (!(bytes instanceof ArrayBuffer) || bytes.byteLength === 0) { + await removeDocumentPreviewCache(docId).catch(() => {}); + return null; + } + + const url = URL.createObjectURL(new Blob([bytes], { type: contentType })); + setInMemoryDocumentPreviewUrl(cacheKey, url); + return url; + })(); + + inFlightPersistedPreviewUrl.set(persistedKey, promise); + try { + return await promise; + } finally { + if (inFlightPersistedPreviewUrl.get(persistedKey) === promise) { + inFlightPersistedPreviewUrl.delete(persistedKey); + } + } } export async function primeDocumentPreviewCache( diff --git a/src/lib/client/dexie.ts b/src/lib/client/dexie.ts index 3bb4d0a..40e99f6 100644 --- a/src/lib/client/dexie.ts +++ b/src/lib/client/dexie.ts @@ -825,6 +825,31 @@ export async function clearHtmlDocuments(): Promise { }); } +export async function getDocumentRecentlyOpenedMap(): Promise> { + return withDB(async () => { + const byId: Record = {}; + const write = (identity: string, ts: unknown) => { + const value = toPositiveInt(ts, 0); + if (value <= 0) return; + if (!byId[identity] || value > byId[identity]) byId[identity] = value; + }; + + await Promise.all([ + db[PDF_TABLE] + .orderBy('cacheAccessedAt') + .eachKey((cacheAccessedAt, cursor) => write(`pdf|${String(cursor.primaryKey)}`, cacheAccessedAt)), + db[EPUB_TABLE] + .orderBy('cacheAccessedAt') + .eachKey((cacheAccessedAt, cursor) => write(`epub|${String(cursor.primaryKey)}`, cacheAccessedAt)), + db[HTML_TABLE] + .orderBy('cacheAccessedAt') + .eachKey((cacheAccessedAt, cursor) => write(`html|${String(cursor.primaryKey)}`, cacheAccessedAt)), + ]); + + return byId; + }); +} + export async function getAppConfig(): Promise { return withDB(async () => { const row = await db[APP_CONFIG_TABLE].get('singleton'); diff --git a/src/lib/client/onboarding-flow.ts b/src/lib/client/onboarding-flow.ts new file mode 100644 index 0000000..cac1f6d --- /dev/null +++ b/src/lib/client/onboarding-flow.ts @@ -0,0 +1,52 @@ +export type OnboardingStep = 'privacy' | 'claim' | 'migration' | 'changelog' | 'done'; + +export type OnboardingStepSnapshot = { + privacyRequired: boolean; + privacyAccepted: boolean; + claimEligible: boolean; + claimHasData: boolean; + migrationRequired: boolean; + changelogPending: boolean; +}; + +export function resolveNextOnboardingStep(snapshot: OnboardingStepSnapshot): OnboardingStep { + if (snapshot.privacyRequired && !snapshot.privacyAccepted) { + return 'privacy'; + } + + if (snapshot.claimEligible && snapshot.claimHasData) { + return 'claim'; + } + + if (snapshot.migrationRequired) { + return 'migration'; + } + + if (snapshot.changelogPending) { + return 'changelog'; + } + + return 'done'; +} + +export function createCoalescedAsyncRunner(runOnce: () => Promise): () => Promise { + let running = false; + let rerunRequested = false; + + return async () => { + if (running) { + rerunRequested = true; + return; + } + + running = true; + try { + do { + rerunRequested = false; + await runOnce(); + } while (rerunRequested); + } finally { + running = false; + } + }; +} diff --git a/src/lib/server/compute/abort-like-error.ts b/src/lib/server/compute/abort-like-error.ts new file mode 100644 index 0000000..2ce848b --- /dev/null +++ b/src/lib/server/compute/abort-like-error.ts @@ -0,0 +1,9 @@ +export function isAbortLikeError(error: unknown): boolean { + if (!error) return false; + if (error instanceof DOMException) return error.name === 'AbortError'; + if (error instanceof Error) return error.name === 'AbortError' || error.message === 'This operation was aborted'; + if (typeof error === 'object' && error !== null && 'name' in error) { + return (error as { name?: unknown }).name === 'AbortError'; + } + return false; +} diff --git a/src/types/documents.ts b/src/types/documents.ts index 3fa90c7..e9fcc42 100644 --- a/src/types/documents.ts +++ b/src/types/documents.ts @@ -5,6 +5,7 @@ export interface BaseDocument { name: string; size: number; lastModified: number; + recentlyOpenedAt?: number; type: DocumentType; parseStatus?: 'pending' | 'running' | 'ready' | 'failed' | null; parsedJsonKey?: string | null; @@ -60,13 +61,24 @@ export interface Folder { export type SortBy = 'name' | 'type' | 'date' | 'size'; export type SortDirection = 'asc' | 'desc'; +export type ViewMode = 'icons' | 'list' | 'gallery'; +export type IconSize = 'sm' | 'md' | 'lg' | 'xl'; + +// Filter applied from the sidebar. +// Examples: 'all', 'recents', 'pdf', 'epub', 'html', or `folder:`. +export type SidebarFilter = string; + export interface DocumentListState { sortBy: SortBy; sortDirection: SortDirection; folders: Folder[]; collapsedFolders: string[]; showHint: boolean; - viewMode?: 'list' | 'grid'; + viewMode?: ViewMode | 'grid'; + iconSize?: IconSize; + sidebarWidth?: number; + sidebarFilter?: SidebarFilter; + sidebarCollapsed?: boolean; } export interface LibraryDocument extends BaseDocument { diff --git a/tests/accessibility.spec.ts b/tests/accessibility.spec.ts index 21337f2..7bd3ba0 100644 --- a/tests/accessibility.spec.ts +++ b/tests/accessibility.spec.ts @@ -15,7 +15,7 @@ test.describe('Accessibility smoke', () => { test('dropzone input and hint text are accessible', async ({ page }) => { // Input is present and visible - await expect(page.locator('input[type="file"]')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('input[type="file"]').first()).toBeVisible({ timeout: 10000 }); // Hint text present (supports compact or default variants) await expect( @@ -27,9 +27,9 @@ test.describe('Accessibility smoke', () => { await uploadFiles(page, 'sample.pdf', 'sample.epub', 'sample.txt'); await ensureDocumentsListed(page, ['sample.pdf', 'sample.epub', 'sample.txt']); - await expect(page.getByRole('link', { name: /sample\.pdf/i })).toBeVisible(); - await expect(page.getByRole('link', { name: /sample\.epub/i })).toBeVisible(); - await expect(page.getByRole('link', { name: /sample\.txt/i })).toBeVisible(); + await expect(page.getByRole('link', { name: /^sample\.pdf$/i })).toBeVisible(); + await expect(page.getByRole('link', { name: /^sample\.epub$/i })).toBeVisible(); + await expect(page.getByRole('link', { name: /^sample\.txt$/i })).toBeVisible(); }); test('ConfirmDialog exposes role=dialog with title and actions', async ({ page }) => { @@ -37,7 +37,7 @@ test.describe('Accessibility smoke', () => { await ensureDocumentsListed(page, ['sample.pdf']); // Open the confirm dialog by clicking the row delete button - await page.getByRole('button', { name: 'Delete document' }).first().click(); + await page.getByRole('button', { name: /^Delete sample\.pdf$/i }).first().click(); // Title and dialog role visible const heading = page.getByRole('heading', { name: 'Delete Document' }); diff --git a/tests/delete.spec.ts b/tests/delete.spec.ts index 6d8004b..b42600c 100644 --- a/tests/delete.spec.ts +++ b/tests/delete.spec.ts @@ -22,9 +22,6 @@ test.describe('Document deletion flow', () => { await expectNoDocumentLink(page, 'sample.txt'); await expectDocumentListed(page, 'sample.pdf'); - // Optional: summary exists (best-effort) - const summary = page.locator('[data-doc-summary]'); - await expect(summary).toBeVisible(); }); test('deletes all local documents from Settings modal', async ({ page }) => { @@ -50,6 +47,6 @@ test.describe('Document deletion flow', () => { await expectNoDocumentLink(page, 'sample.epub'); // Uploader should be visible when no docs remain - await expect(page.locator('input[type=file]')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('input[type=file]').first()).toBeVisible({ timeout: 10000 }); }); }); diff --git a/tests/folders.spec.ts b/tests/folders.spec.ts index 3f9e0fb..7e36725 100644 --- a/tests/folders.spec.ts +++ b/tests/folders.spec.ts @@ -1,5 +1,13 @@ import { test, expect, type Page } from '@playwright/test'; -import { setupTest, uploadFiles, ensureDocumentsListed, waitForDocumentListHintPersist, dispatchHtml5DragAndDrop } from './helpers'; +import { + setupTest, + uploadFiles, + ensureDocumentsListed, + waitForDocumentListHintPersist, + dispatchHtml5DragAndDrop, + expectDocumentListed, + expectNoDocumentLink, +} from './helpers'; test.describe('Document folders and hint persistence', () => { test.beforeEach(async ({ page }, testInfo) => { @@ -13,10 +21,16 @@ test.describe('Document folders and hint persistence', () => { return link.locator('xpath=ancestor::*[@draggable="true"][1]'); }; + const folderRow = (page: Page, folderName: string) => + page.getByRole('button', { name: new RegExp(`^${folderName}\\b`, 'i') }).first(); + + const allDocumentsRow = (page: Page) => + page.getByRole('button', { name: /^All Documents\b/i }).first(); + test('Folder creation via drag-and-drop with persistence', async ({ page }) => { - // Upload three docs - await uploadFiles(page, 'sample.pdf', 'sample.epub', 'sample.txt'); - await ensureDocumentsListed(page, ['sample.pdf', 'sample.epub', 'sample.txt']); + // Upload four docs (one stays outside folder to verify filtering) + await uploadFiles(page, 'sample.pdf', 'sample.epub', 'sample.txt', 'sample.md'); + await ensureDocumentsListed(page, ['sample.pdf', 'sample.epub', 'sample.txt', 'sample.md']); // Drag PDF onto EPUB to create a folder const pdfRow = rowFor(page, 'sample.pdf'); @@ -30,51 +44,31 @@ test.describe('Document folders and hint persistence', () => { await nameInput.press('Enter'); await expect(page.getByRole('dialog', { name: 'Create New Folder' })).toHaveCount(0); - // Folder shows with both docs - const folderHeading = page.getByRole('heading', { name: 'My Folder' }); - await expect(folderHeading).toBeVisible(); + // Sidebar folder row exists and folder becomes selected (content filtered to folder docs) + const myFolderRow = folderRow(page, 'My Folder'); + await expect(myFolderRow).toBeVisible(); + await expectDocumentListed(page, 'sample.pdf'); + await expectDocumentListed(page, 'sample.epub'); + await expectNoDocumentLink(page, 'sample.txt'); + await expectNoDocumentLink(page, 'sample.md'); - // Scope checks inside the folder container - const folderContainer = folderHeading.locator('xpath=ancestor::*[contains(concat(" ", normalize-space(@class), " "), " rounded-md ") and contains(concat(" ", normalize-space(@class), " "), " border ")][1]'); - await expect(folderContainer.getByRole('link', { name: /sample\.pdf/i })).toBeVisible(); - await expect(folderContainer.getByRole('link', { name: /sample\.epub/i })).toBeVisible(); - - // Drag third doc (TXT) into folder + // Switch to all documents and drag TXT into sidebar folder row + await allDocumentsRow(page).click(); const txtRow = rowFor(page, 'sample.txt'); - await dispatchHtml5DragAndDrop(page, txtRow, folderContainer); - await expect(folderContainer.getByRole('link', { name: /sample\.txt/i })).toBeVisible(); + await dispatchHtml5DragAndDrop(page, txtRow, myFolderRow); + await expectDocumentListed(page, 'sample.txt'); + await expectNoDocumentLink(page, 'sample.md'); - // Collapse folder and verify items are hidden - const collapseBtn = folderContainer.getByRole('button', { name: 'Collapse folder' }); - await collapseBtn.scrollIntoViewIfNeeded(); - await expect(collapseBtn).toBeVisible(); - await collapseBtn.click(); - await expect(folderContainer.getByRole('button', { name: 'Expand folder' })).toBeVisible(); - await expect(folderContainer.getByRole('link', { name: /sample\.pdf/i })).toHaveCount(0); - await expect(folderContainer.getByRole('link', { name: /sample\.epub/i })).toHaveCount(0); - await expect(folderContainer.getByRole('link', { name: /sample\.txt/i })).toHaveCount(0); - - // Reload and verify persisted folder with collapsed state and documents + // Reload and verify persisted folder + membership await page.reload(); await page.waitForLoadState('networkidle'); - const folderHeadingAfter = page.getByRole('heading', { name: 'My Folder' }); - await expect(folderHeadingAfter).toBeVisible(); - const folderContainerAfter = folderHeadingAfter.locator('xpath=ancestor::*[contains(concat(" ", normalize-space(@class), " "), " rounded-md ") and contains(concat(" ", normalize-space(@class), " "), " border ")][1]'); - - // Still collapsed after reload - await expect(folderContainerAfter.getByRole('button', { name: 'Expand folder' })).toBeVisible(); - await expect(folderContainerAfter.getByRole('link', { name: /sample\.pdf/i })).toHaveCount(0); - await expect(folderContainerAfter.getByRole('link', { name: /sample\.epub/i })).toHaveCount(0); - await expect(folderContainerAfter.getByRole('link', { name: /sample\.txt/i })).toHaveCount(0); - - // Expand and verify all three documents visible - const expandBtn = folderContainerAfter.getByRole('button', { name: 'Expand folder' }); - await expandBtn.scrollIntoViewIfNeeded(); - await expect(expandBtn).toBeVisible(); - await expandBtn.click(); - await expect(folderContainerAfter.getByRole('link', { name: /sample\.pdf/i })).toBeVisible(); - await expect(folderContainerAfter.getByRole('link', { name: /sample\.epub/i })).toBeVisible(); - await expect(folderContainerAfter.getByRole('link', { name: /sample\.txt/i })).toBeVisible(); + const myFolderRowAfter = folderRow(page, 'My Folder'); + await expect(myFolderRowAfter).toBeVisible(); + await myFolderRowAfter.click(); + await expectDocumentListed(page, 'sample.pdf'); + await expectDocumentListed(page, 'sample.epub'); + await expectDocumentListed(page, 'sample.txt'); + await expectNoDocumentLink(page, 'sample.md'); }); test('Dismiss “Drag files to make folders” hint persists after reload', async ({ page }) => { @@ -82,7 +76,7 @@ test.describe('Document folders and hint persistence', () => { await uploadFiles(page, 'sample.pdf', 'sample.epub'); await ensureDocumentsListed(page, ['sample.pdf', 'sample.epub']); - const hint = page.getByText('Drag files on top of each other to make folders'); + const hint = page.getByText('Drag files onto each other to make folders. Drop into the sidebar to move.'); await expect(hint).toBeVisible(); await page.getByRole('button', { name: 'Dismiss hint' }).click(); @@ -95,6 +89,6 @@ test.describe('Document folders and hint persistence', () => { // Reload and ensure it remains dismissed await page.reload(); await page.waitForLoadState('networkidle'); - await expect(page.getByText('Drag files on top of each other to make folders')).toHaveCount(0); + await expect(page.getByText('Drag files onto each other to make folders. Drop into the sidebar to move.')).toHaveCount(0); }); }); diff --git a/tests/helpers.ts b/tests/helpers.ts index 500b3dc..79d15b4 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -111,6 +111,7 @@ export async function uploadAndDisplay(page: Page, fileName: string) { async function dismissOnboardingModals(page: Page): Promise { const privacyDialog = page.getByTestId('privacy-modal'); + const claimDialog = page.getByTestId('claim-modal'); const migrationDialog = page.getByTestId('migration-modal'); const settingsDialog = page.getByTestId('settings-modal'); @@ -135,6 +136,16 @@ async function dismissOnboardingModals(page: Page): Promise { continue; } + if (await claimDialog.isVisible().catch(() => false)) { + const dismissBtn = page.getByTestId('claim-dismiss-button'); + await expect(dismissBtn).toBeEnabled({ timeout: 10000 }); + await dismissBtn.click(); + await claimDialog.waitFor({ state: 'hidden', timeout: 15000 }); + await page.waitForTimeout(100); + settledWithoutDialog = 0; + continue; + } + if (await settingsDialog.isVisible().catch(() => false)) { const backToSettingsBtn = settingsDialog.getByRole('button', { name: /back to settings/i }); if (await backToSettingsBtn.isVisible().catch(() => false)) { @@ -419,8 +430,7 @@ export async function expectViewerForFile(page: Page, fileName: string) { // Delete a single document by filename via row action and confirm dialog export async function deleteDocumentByName(page: Page, fileName: string) { - const link = page.getByRole('link', { name: new RegExp(escapeRegExp(fileName), 'i') }).first(); - await link.locator('xpath=..').getByRole('button', { name: 'Delete document' }).click(); + await page.getByRole('button', { name: new RegExp(`^Delete\\s+${escapeRegExp(fileName)}$`, 'i') }).first().click(); const heading = page.getByRole('heading', { name: 'Delete Document' }); await expect(heading).toBeVisible({ timeout: 10000 }); @@ -432,7 +442,9 @@ export async function deleteDocumentByName(page: Page, fileName: string) { // Open Settings modal and navigate to Documents section export async function openSettingsDocumentsTab(page: Page) { await page.getByRole('button', { name: 'Settings' }).click(); - await page.getByRole('button', { name: 'Documents' }).click(); + const settingsDialog = page.locator('[data-testid="settings-modal"]'); + await expect(settingsDialog).toBeVisible({ timeout: 10000 }); + await settingsDialog.getByRole('button', { name: /^Documents$/ }).click(); } // Delete all local documents through Settings and close dialogs diff --git a/tests/unit/compute-abort-like-error.spec.ts b/tests/unit/compute-abort-like-error.spec.ts new file mode 100644 index 0000000..e31287e --- /dev/null +++ b/tests/unit/compute-abort-like-error.spec.ts @@ -0,0 +1,18 @@ +import { expect, test } from '@playwright/test'; +import { isAbortLikeError } from '../../src/lib/server/compute/abort-like-error'; + +test.describe('isAbortLikeError', () => { + test('matches abort-shaped errors', () => { + expect(isAbortLikeError(new DOMException('This operation was aborted', 'AbortError'))).toBe(true); + expect(isAbortLikeError(Object.assign(new Error('random'), { name: 'AbortError' }))).toBe(true); + expect(isAbortLikeError(new Error('This operation was aborted'))).toBe(true); + expect(isAbortLikeError({ name: 'AbortError' })).toBe(true); + }); + + test('does not match non-abort errors', () => { + expect(isAbortLikeError(new Error('boom'))).toBe(false); + expect(isAbortLikeError({ name: 'TypeError' })).toBe(false); + expect(isAbortLikeError(null)).toBe(false); + expect(isAbortLikeError(undefined)).toBe(false); + }); +}); diff --git a/tests/unit/compute-worker-pdf-progress.spec.ts b/tests/unit/compute-worker-pdf-progress.spec.ts new file mode 100644 index 0000000..284fd04 --- /dev/null +++ b/tests/unit/compute-worker-pdf-progress.spec.ts @@ -0,0 +1,32 @@ +import { expect, test } from '@playwright/test'; +import { + buildInferProgressForPageParsed, + buildInferProgressForPageStart, +} from '../../compute/worker/src/pdf-progress'; + +test.describe('compute worker pdf progress helpers', () => { + test('page-start progress keeps current page but does not count it as parsed yet', () => { + expect(buildInferProgressForPageStart({ pageNumber: 1, totalPages: 12 })).toEqual({ + totalPages: 12, + pagesParsed: 0, + currentPage: 1, + phase: 'infer', + }); + + expect(buildInferProgressForPageStart({ pageNumber: 5, totalPages: 12 })).toEqual({ + totalPages: 12, + pagesParsed: 4, + currentPage: 5, + phase: 'infer', + }); + }); + + test('page-parsed progress counts the current page as parsed', () => { + expect(buildInferProgressForPageParsed({ pageNumber: 5, totalPages: 12 })).toEqual({ + totalPages: 12, + pagesParsed: 5, + currentPage: 5, + phase: 'infer', + }); + }); +}); diff --git a/tests/unit/icons-grid.spec.ts b/tests/unit/icons-grid.spec.ts new file mode 100644 index 0000000..5663c47 --- /dev/null +++ b/tests/unit/icons-grid.spec.ts @@ -0,0 +1,24 @@ +import { expect, test } from '@playwright/test'; +import { iconsGridStyle, maxColumnsForIconGrid } from '../../src/components/doclist/views/iconsGrid'; + +test.describe('icons grid layout', () => { + test('calculates max columns from width and icon size', () => { + expect(maxColumnsForIconGrid('md', 136)).toBe(1); + expect(maxColumnsForIconGrid('md', 300)).toBe(2); + expect(maxColumnsForIconGrid('md', 1000)).toBe(6); + }); + + test('uses auto-fit by default when single-row suppression is off', () => { + const style = iconsGridStyle('md', 4); + expect(style.gridTemplateColumns).toContain('repeat(auto-fit'); + expect(style.gridTemplateColumns).toContain('1fr'); + expect(style.justifyContent).toBeUndefined(); + }); + + test('disables stretch when single-row suppression is on', () => { + const style = iconsGridStyle('md', 4, { suppressSingleRowStretch: true }); + expect(style.gridTemplateColumns).toContain('repeat(auto-fill'); + expect(style.gridTemplateColumns).not.toContain('1fr'); + expect(style.justifyContent).toBe('start'); + }); +}); diff --git a/tests/unit/onboarding-flow.spec.ts b/tests/unit/onboarding-flow.spec.ts new file mode 100644 index 0000000..9533aa8 --- /dev/null +++ b/tests/unit/onboarding-flow.spec.ts @@ -0,0 +1,107 @@ +import { expect, test } from '@playwright/test'; + +import { createCoalescedAsyncRunner, resolveNextOnboardingStep } from '../../src/lib/client/onboarding-flow'; + +test.describe('onboarding flow resolver', () => { + test('resolves deterministic order with privacy first', () => { + const step = resolveNextOnboardingStep({ + privacyRequired: true, + privacyAccepted: false, + claimEligible: true, + claimHasData: true, + migrationRequired: true, + changelogPending: true, + }); + + expect(step).toBe('privacy'); + }); + + test('resolves claim after privacy is accepted', () => { + const step = resolveNextOnboardingStep({ + privacyRequired: true, + privacyAccepted: true, + claimEligible: true, + claimHasData: true, + migrationRequired: true, + changelogPending: true, + }); + + expect(step).toBe('claim'); + }); + + test('resolves migration when no claim is needed', () => { + const step = resolveNextOnboardingStep({ + privacyRequired: true, + privacyAccepted: true, + claimEligible: true, + claimHasData: false, + migrationRequired: true, + changelogPending: true, + }); + + expect(step).toBe('migration'); + }); + + test('resolves changelog when prior steps are clear', () => { + const step = resolveNextOnboardingStep({ + privacyRequired: true, + privacyAccepted: true, + claimEligible: true, + claimHasData: false, + migrationRequired: false, + changelogPending: true, + }); + + expect(step).toBe('changelog'); + }); + + test('resolves done when no steps are pending (auth and no-auth parity)', () => { + const authStep = resolveNextOnboardingStep({ + privacyRequired: true, + privacyAccepted: true, + claimEligible: true, + claimHasData: false, + migrationRequired: false, + changelogPending: false, + }); + const noAuthStep = resolveNextOnboardingStep({ + privacyRequired: false, + privacyAccepted: false, + claimEligible: false, + claimHasData: false, + migrationRequired: false, + changelogPending: false, + }); + + expect(authStep).toBe('done'); + expect(noAuthStep).toBe('done'); + }); +}); + +test.describe('coalesced onboarding runner', () => { + test('coalesces concurrent triggers into one extra rerun', async () => { + let runs = 0; + let nestedRequested = false; + + const run = createCoalescedAsyncRunner(async () => { + runs += 1; + if (!nestedRequested) { + nestedRequested = true; + await run(); + } + }); + + await run(); + expect(runs).toBe(2); + }); + + test('does not rerun when no trigger arrives during execution', async () => { + let runs = 0; + const run = createCoalescedAsyncRunner(async () => { + runs += 1; + }); + + await run(); + expect(runs).toBe(1); + }); +}); diff --git a/tests/upload.spec.ts b/tests/upload.spec.ts index 7c66b73..2008f72 100644 --- a/tests/upload.spec.ts +++ b/tests/upload.spec.ts @@ -130,13 +130,13 @@ test.describe('Document Upload Tests', () => { await clickDocumentLink(page, 'sample.pdf'); await expectViewerForFile(page, 'sample.pdf'); await page.goBack(); - await expect(page.getByText('Your Documents')).toBeVisible({ timeout: 10000 }); + await ensureDocumentsListed(page, ['sample.pdf', 'sample.epub', 'sample.txt']); // EPUB navigation and viewer await clickDocumentLink(page, 'sample.epub'); await expectViewerForFile(page, 'sample.epub'); await page.goBack(); - await expect(page.getByText('Your Documents')).toBeVisible({ timeout: 10000 }); + await ensureDocumentsListed(page, ['sample.pdf', 'sample.epub', 'sample.txt']); // TXT navigation and viewer (HTML viewer) await clickDocumentLink(page, 'sample.txt');