Merge pull request #91 from richardr1126/redesign/app-view-documents-list

This commit is contained in:
Richard R 2026-05-29 22:28:09 -06:00 committed by GitHub
commit e262f93f90
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
58 changed files with 3563 additions and 1081 deletions

7
.dockerignore Normal file
View file

@ -0,0 +1,7 @@
.env
.env.*
README.md
.next
node_modules
**/node_modules
docstore

View file

@ -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

5
.gitignore vendored
View file

@ -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

View file

@ -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<void>;
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 };

View file

@ -11,6 +11,10 @@ import { normalizeTextItemsForLayout } from './normalize-text';
interface ParsePdfInput {
documentId: string;
pdfBytes: ArrayBuffer;
onPageStarted?: (input: {
pageNumber: number;
totalPages: number;
}) => void | Promise<void>;
onPageParsed?: (input: {
pageNumber: number;
totalPages: number;
@ -57,6 +61,12 @@ export async function parsePdf(input: ParsePdfInput): Promise<ParsedPdfDocument>
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<ParsedPdfDocument>
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) => ({

View file

@ -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',
};
}

View file

@ -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<void> {
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<void> {
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<void> {
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',
});
}));
},
}),
});

View file

@ -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",

View file

@ -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.

View file

@ -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

View file

@ -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');

View file

@ -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 (
<div className="flex flex-col h-full w-full">
<Header
title={
<div className="flex items-center gap-2">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/icon.svg" alt="" className="w-5 h-5" aria-hidden="true" />
<h1 className="text-xs sm:text-sm font-bold truncate text-foreground tracking-tight">OpenReader</h1>
</div>
}
right={
<div className="flex items-center gap-2">
<SettingsModal />
<UserMenu />
</div>
}
/>
<section className="flex-1 px-4 pb-8 pt-4 overflow-auto">
<div className="max-w-7xl mx-auto">
<RateLimitBanner className="mb-6" />
<section className="flex-1 min-h-0 flex flex-col overflow-hidden">
<RateLimitBanner className="mx-2 mt-2" />
<div className="flex-1 min-h-0">
<HomeContent />
</div>
</section>

View file

@ -34,8 +34,8 @@ export default function AppLayout({ children }: { children: ReactNode }) {
allowAnonymousAuthSessions={allowAnonymousAuthSessions}
githubAuthEnabled={githubAuthEnabled}
>
<div className="app-shell min-h-screen flex flex-col bg-background">
<main className="flex-1 flex flex-col">{children}</main>
<div className="app-shell h-dvh flex flex-col bg-background overflow-hidden">
<main className="flex-1 min-h-0 flex flex-col">{children}</main>
</div>
<Toaster
toastOptions={{

View file

@ -4,6 +4,7 @@ import { db } from '@/db';
import { documents } from '@/db/schema';
import { requireAuthContext } from '@/lib/server/auth/auth';
import { getWorkerClientConfigFromEnv } from '@/lib/server/compute/worker';
import { isAbortLikeError } from '@/lib/server/compute/abort-like-error';
import { snapshotFromWorkerState } from '@/lib/server/compute/worker-parse-state';
import { fetchWorkerOperationState } from '@/lib/server/compute/worker-op-state';
import { isValidDocumentId } from '@/lib/server/documents/blobstore';
@ -472,6 +473,7 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string
void runWorkerProxy()
.catch((error) => {
if (closed || isAbortLikeError(error)) return;
logServerError(logger, {
event: 'documents.parsed.events.worker_proxy_crashed',
error,

View file

@ -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 = () => (
<div className="flex items-center gap-2 min-w-0">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/icon.svg" alt="" className="w-5 h-5 shrink-0" aria-hidden="true" />
<h1 className="hidden sm:block text-xs sm:text-sm font-bold truncate text-foreground tracking-tight">
OpenReader
</h1>
</div>
);
const AppActions = () => (
<div className="flex flex-col gap-0.5 w-full">
<SettingsModal
triggerLabel="Settings"
className="w-full justify-start gap-2 px-2 py-1 text-[12px] border-transparent hover:border-accent"
/>
<UserMenu variant="sidebar" />
</div>
);
export function HomeContent() {
const { pdfDocs, epubDocs, htmlDocs, isPDFLoading } = useDocuments();
const totalDocs = (pdfDocs?.length || 0) + (epubDocs?.length || 0) + (htmlDocs?.length || 0);
if (isPDFLoading) {
return (
<div className="w-full">
<DocumentListSkeleton />
</div>
);
}
if (totalDocs === 0) {
return (
<div className="w-full">
<DocumentUploader className="py-12" />
</div>
);
}
return (
<div className="w-full">
<DocumentList />
<div className="w-full h-full">
<DocumentList brand={<Brand />} appActions={<AppActions />} />
</div>
);
}

View file

@ -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 }) {
<>
<Button
onClick={() => {
void requestOpenSettings();
setIsOpen(true);
setIsChangelogOpen(false);
}}
className={`inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase hover:text-accent transition-transform transition-colors duration-200 ease-out hover:scale-[1.08] ${className}`}
className={`inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase hover:text-accent transition-transform transition-colors duration-200 ease-out hover:scale-[1.01] ${className}`}
aria-label="Settings"
tabIndex={0}
>
<SettingsIcon className="w-4 h-4 transition-transform duration-200 ease-out hover:scale-[1.08] hover:rotate-45" />
<SettingsIcon className="w-4 h-4 transition-transform duration-200 ease-out hover:scale-[1.01] hover:rotate-45" />
{triggerLabel && <span className="ml-2">{triggerLabel}</span>}
</Button>
<Transition appear show={isOpen} as={Fragment}>
@ -1447,7 +1441,7 @@ function SettingsChangelogPanel({
<div className="flex items-center gap-3 px-4 py-3 border-b border-offbase bg-background">
<Button
onClick={onClose}
className="inline-flex items-center justify-center rounded-md text-muted hover:text-accent hover:bg-base transition-all duration-200 ease-in-out transform hover:scale-[1.08]"
className="inline-flex items-center justify-center rounded-md text-muted hover:text-accent hover:bg-base transition-all duration-200 ease-in-out transform hover:scale-[1.01]"
aria-label="Back to settings"
title="Back"
>

View file

@ -99,7 +99,7 @@ export default function ClaimDataModal({
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<DialogPanel className="w-full max-w-md transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all">
<DialogPanel data-testid="claim-modal" className="w-full max-w-md transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all">
<DialogTitle
as="h3"
className="text-lg font-semibold leading-6 text-foreground mb-4"
@ -128,6 +128,7 @@ export default function ClaimDataModal({
<div className="flex justify-end gap-3">
<Button
data-testid="claim-dismiss-button"
type="button"
onClick={onDismiss}
disabled={isClaiming}
@ -140,6 +141,7 @@ export default function ClaimDataModal({
Dismiss
</Button>
<Button
data-testid="claim-submit-button"
type="button"
onClick={handleClaim}
disabled={isClaiming}

View file

@ -7,8 +7,17 @@ import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
import { useAuthSession } from '@/hooks/useAuthSession';
import { getAuthClient } from '@/lib/client/auth-client';
import { useRouter } from 'next/navigation';
import { UserIcon } from '@/components/icons/Icons';
export function UserMenu({ className = '' }: { className?: string }) {
type UserMenuVariant = 'toolbar' | 'sidebar';
export function UserMenu({
className = '',
variant = 'toolbar',
}: {
className?: string;
variant?: UserMenuVariant;
}) {
const { authEnabled, baseUrl } = useAuthConfig();
const enableUserSignups = useFeatureFlag('enableUserSignups');
const { data: session, isPending } = useAuthSession();
@ -22,17 +31,37 @@ export function UserMenu({ className = '' }: { className?: string }) {
router.push('/signin');
};
const rowClass =
'w-full inline-flex items-center gap-2 px-2 py-1 rounded-md text-[12px] border border-transparent transition-all duration-200 ease-out text-left hover:scale-[1.01] hover:border-accent hover:bg-offbase hover:text-accent';
if (!session || session.user.isAnonymous) {
if (variant === 'sidebar') {
return (
<div className={`flex w-full flex-col gap-0.5 ${className}`}>
<Link href="/signin" className={rowClass}>
<UserIcon className="h-3.5 w-3.5 text-muted" />
<span className="truncate">Connect</span>
</Link>
{enableUserSignups && (
<Link href="/signup" className={rowClass}>
<UserIcon className="h-3.5 w-3.5 text-muted" />
<span className="truncate">Create account</span>
</Link>
)}
</div>
);
}
return (
<div className={`flex gap-2 ${className}`}>
<Link href="/signin">
<Button className="inline-flex items-center rounded-md bg-base border border-offbase px-2 py-1 text-xs font-medium text-foreground hover:bg-offbase focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2 transform transition-all duration-200 ease-in-out hover:scale-[1.09] hover:text-accent">
<Button className="inline-flex items-center rounded-md bg-base border border-offbase px-2 py-1 text-xs font-medium text-foreground hover:bg-offbase focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2 transform transition-all duration-200 ease-in-out hover:scale-[1.01] hover:text-accent">
Connect
</Button>
</Link>
{enableUserSignups && (
<Link href="/signup">
<Button className="inline-flex items-center rounded-md bg-accent px-2 py-1 text-xs font-medium text-background hover:bg-secondary-accent focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2 transform transition-all duration-200 ease-in-out hover:scale-[1.09]">
<Button className="inline-flex items-center rounded-md bg-accent px-2 py-1 text-xs font-medium text-background hover:bg-secondary-accent focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2 transform transition-all duration-200 ease-in-out hover:scale-[1.01]">
Create account
</Button>
</Link>
@ -41,6 +70,25 @@ export function UserMenu({ className = '' }: { className?: string }) {
);
}
if (variant === 'sidebar') {
return (
<button
onClick={handleDisconnectAccount}
className={`${rowClass} ${className}`}
title="Disconnect account"
aria-label="Disconnect account"
>
<UserIcon className="h-3.5 w-3.5 text-muted" />
<span className="truncate flex-1">{session.user.email || 'Account'}</span>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
<polyline points="16 17 21 12 16 7"></polyline>
<line x1="21" y1="12" x2="9" y2="12"></line>
</svg>
</button>
);
}
return (
<div className={`flex items-center gap-2 px-2 py-1 rounded-md border border-offbase bg-base ${className}`}>
<span className="hidden sm:block text-xs font-medium text-foreground truncate max-w-[160px]">
@ -49,7 +97,7 @@ export function UserMenu({ className = '' }: { className?: string }) {
<Button
onClick={handleDisconnectAccount}
className="inline-flex items-center text-foreground text-xs hover:text-accent transform transition-all duration-200 ease-in-out hover:scale-[1.09]"
className="inline-flex items-center text-foreground text-xs hover:text-accent transform transition-all duration-200 ease-in-out hover:scale-[1.01]"
title="Disconnect account"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">

View file

@ -53,7 +53,7 @@ export function CreateFolderDialog({
onChange={(e) => onFolderNameChange(e.target.value)}
onKeyDown={onKeyDown}
placeholder="Enter folder name"
className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent"
className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-1 focus:ring-accent"
autoFocus
/>
<p className="mt-2 text-xs text-muted">Press Enter to create or Escape to cancel</p>

View file

@ -1,149 +0,0 @@
import { useState, DragEvent } from 'react';
import { Button, Transition } from '@headlessui/react';
import { DocumentListItem } from './DocumentListItem';
import { Folder, DocumentListDocument } from '@/types/documents';
interface DocumentFolderProps {
folder: Folder;
isCollapsed: boolean;
onToggleCollapse: (folderId: string) => void;
onDelete: () => void;
sortedDocuments: DocumentListDocument[];
onDocumentDelete: (doc: DocumentListDocument) => void;
draggedDoc: DocumentListDocument | null;
onDragStart: (doc: DocumentListDocument) => void;
onDragEnd: () => void;
onDrop: (e: DragEvent, folderId: string) => void;
viewMode: 'list' | 'grid';
}
const ChevronIcon = ({ className = "w-4 h-4" }) => (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
</svg>
);
const calculateFolderSize = (documents: DocumentListDocument[]) => {
return documents.reduce((total, doc) => total + doc.size, 0);
};
export function DocumentFolder({
folder,
isCollapsed,
onToggleCollapse,
onDelete,
sortedDocuments,
onDocumentDelete,
draggedDoc,
onDragStart,
onDragEnd,
onDrop,
viewMode,
}: DocumentFolderProps) {
const [isHovering, setIsHovering] = useState(false);
const isDropTarget = isHovering && draggedDoc && !draggedDoc.folderId && draggedDoc.id !== folder.id;
return (
<div
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
if (draggedDoc && !draggedDoc.folderId) {
setIsHovering(true);
}
}}
onDragLeave={(e) => {
e.preventDefault();
e.stopPropagation();
setIsHovering(false);
}}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
setIsHovering(false);
if (!draggedDoc || draggedDoc.folderId) return;
onDrop(e, folder.id);
}}
className={`w-full overflow-hidden rounded-md border border-offbase ${viewMode === 'grid' ? 'col-span-full' : ''} ${isDropTarget ? 'ring-2 ring-accent' : ''}`}
>
<div className='flex flex-row justify-between p-0'>
<div className="w-full">
<div className={`flex items-center justify-between px-2 py-1 bg-offbase rounded-t-md border-b border-offbase transition-all duration-200`}>
<div className="flex items-center">
<h3 className="text-sm px-1 font-semibold leading-tight">{folder.name}</h3>
<Button
type="button"
onClick={() => onToggleCollapse(folder.id)}
className="ml-0.5 p-1 inline-flex items-center justify-center transform transition-transform duration-200 ease-in-out hover:scale-[1.08] hover:text-accent"
aria-label={isCollapsed ? "Expand folder" : "Collapse folder"}
aria-expanded={!isCollapsed}
aria-controls={`folder-panel-${folder.id}`}
title={isCollapsed ? "Expand folder" : "Collapse folder"}
>
<ChevronIcon className={`w-4 h-4 transform transition-transform duration-300 ease-in-out ${isCollapsed ? '-rotate-180' : ''}`} />
</Button>
</div>
<Button
onClick={onDelete}
className="p-1 text-muted hover:text-accent hover:bg-base rounded-md transition-colors"
aria-label="Delete folder"
>
<svg className="w-3.5 h-3.5"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</Button>
</div>
<div className="relative bg-base px-1 py-1 rounded-b-md">
<Transition
show={!isCollapsed}
enter="transition-all duration-300 ease-out"
enterFrom="transform scale-y-0 opacity-0 max-h-0"
enterTo="transform scale-y-100 opacity-100 max-h-[1000px]"
leave="transition-all duration-200 ease-in"
leaveFrom="transform scale-y-100 opacity-100 max-h-[1000px]"
leaveTo="transform scale-y-0 opacity-0 max-h-0"
>
<div
id={`folder-panel-${folder.id}`}
className={`${
viewMode === 'grid'
? 'grid w-full grid-cols-2 gap-2 sm:grid-cols-3 sm:gap-3 md:grid-cols-4 lg:grid-cols-5'
: 'w-full space-y-1'
} origin-top`}
>
{sortedDocuments.map(doc => (
<DocumentListItem
key={`${doc.type}-${doc.id}`}
doc={doc}
onDelete={onDocumentDelete}
dragEnabled={false} // Documents in folders can't be dragged to other documents
onDragStart={onDragStart}
onDragEnd={onDragEnd}
isDropTarget={false}
viewMode={viewMode}
/>
))}
</div>
</Transition>
<Transition
show={isCollapsed}
enter="transition-all duration-200"
enterFrom="max-h-0 opacity-0"
enterTo="max-h-[50px] opacity-100"
leave="transition-all duration-100"
leaveFrom="max-h-[50px] opacity-100"
leaveTo="max-h-0 opacity-0"
>
<p className="text-[10px] px-1 text-left text-muted leading-tight">
{(calculateFolderSize(sortedDocuments) / 1024 / 1024).toFixed(2)} MB
{`${sortedDocuments.length} ${sortedDocuments.length === 1 ? 'file' : 'files'}`}
</p>
</Transition>
</div>
</div>
</div>
</div>
);
}

File diff suppressed because it is too large Load diff

View file

@ -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 (
<div
draggable={isDraggable}
onDragStart={() => 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' ? (
<>
<Link
href={href}
draggable={false}
className="block"
aria-label="Open document preview"
onClick={handleDocumentClick}
>
<DocumentPreview doc={doc} />
</Link>
<div className="flex items-center w-full px-1.5 py-1.5">
<Link
href={href}
draggable={false}
className="document-link flex items-center gap-2 flex-1 min-w-0 rounded-md py-0.5 px-0.5"
onClick={handleDocumentClick}
>
<div className="flex-shrink-0">
{doc.type === 'pdf' ? (
<PDFIcon className="w-5 h-5 sm:w-6 sm:h-6 text-red-500" />
) : doc.type === 'epub' ? (
<EPUBIcon className="w-5 h-5 sm:w-6 sm:h-6 text-blue-500" />
) : (
<FileIcon className="w-6 h-6 sm:w-6 sm:h-6 text-muted" />
)}
</div>
<div className="flex flex-col min-w-0 transform transition-transform duration-150 ease-in-out hover:scale-[1.009] w-full truncate">
<p className="text-[12px] sm:text-[13px] leading-tight text-foreground font-medium truncate group-hover:text-accent">
{doc.name}
</p>
<p className="text-[9px] sm:text-[10px] leading-tight text-muted truncate">
{(doc.size / 1024 / 1024).toFixed(2)} MB
</p>
</div>
</Link>
{showDeleteButton && (
<Button
onClick={() => onDelete(doc)}
className={buttonClass({
variant: 'ghost',
size: 'icon',
className: 'ml-1 h-7 w-7 text-muted hover:bg-offbase',
})}
aria-label="Delete document"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</Button>
)}
</div>
</>
) : (
<div className="flex items-center w-full">
<Link
href={href}
draggable={false}
className="document-link flex items-center align-center gap-2 flex-1 min-w-0 rounded-md py-0.5 px-0.5"
onClick={handleDocumentClick}
>
<div className="flex-shrink-0">
{doc.type === 'pdf' ? (
<PDFIcon className="w-5 h-5 sm:w-6 sm:h-6 text-red-500" />
) : doc.type === 'epub' ? (
<EPUBIcon className="w-5 h-5 sm:w-6 sm:h-6 text-blue-500" />
) : (
<FileIcon className="w-6 h-6 sm:w-6 sm:h-6 text-muted" />
)}
</div>
<div className="flex flex-col min-w-0 transform transition-transform duration-150 ease-in-out hover:scale-[1.009] w-full truncate">
<p className="text-[12px] sm:text-[13px] leading-tight text-foreground font-medium truncate group-hover:text-accent">
{doc.name}
</p>
<p className="text-[9px] sm:text-[10px] leading-tight text-muted truncate">
{(doc.size / 1024 / 1024).toFixed(2)} MB
</p>
</div>
</Link>
{showDeleteButton && (
<Button
onClick={() => onDelete(doc)}
className={buttonClass({
variant: 'ghost',
size: 'icon',
className: 'ml-1 h-7 w-7 text-muted hover:bg-offbase',
})}
aria-label="Delete document"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</Button>
)}
</div>
)}
</div>
);
}

View file

@ -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 (
<div className="w-full mx-auto animate-pulse" aria-label="Loading documents" aria-busy="true">
<div className="flex items-center justify-between mb-2">
<div className="h-6 w-36 rounded bg-offbase" />
<div className="h-6 w-44 rounded bg-offbase" />
</div>
<div className="h-3 w-48 rounded bg-offbase mb-3" />
<div className="h-9 w-full rounded-lg border-2 border-dashed border-offbase bg-base mb-3" />
<div
className={
viewMode === 'grid'
? 'grid w-full grid-cols-2 gap-2 sm:grid-cols-3 sm:gap-3 md:grid-cols-4 lg:grid-cols-5'
: 'w-full space-y-1'
}
>
{placeholders.map((_, index) => (
<div
key={index}
className={
viewMode === 'grid'
? 'overflow-hidden rounded-md border border-offbase bg-base'
: 'h-12 rounded-md border border-offbase bg-base'
}
>
{viewMode === 'grid' ? (
<>
<div className="aspect-[3/4] w-full bg-offbase" />
<div className="p-2">
<div className="h-3 w-4/5 rounded bg-offbase" />
<div className="mt-1 h-2.5 w-1/3 rounded bg-offbase" />
</div>
</>
) : null}
<div className="h-full min-h-0 overflow-y-auto p-3">
<div className="grid" style={iconsGridStyle(iconSize, ICON_SKELETON_ITEM_COUNT)}>
{Array.from({ length: ICON_SKELETON_ITEM_COUNT }).map((_, index) => (
<div key={index} className="overflow-hidden rounded-md border border-offbase bg-base">
<div className="aspect-[3/4] w-full bg-offbase" />
<div className="flex items-center gap-2 px-2 py-2">
<div className="h-3.5 w-3.5 rounded bg-offbase" />
<div className="h-2.5 w-4/5 rounded bg-offbase" />
</div>
</div>
))}
</div>
</div>
);
}
function ListSkeleton() {
return (
<div className="h-full min-h-0 overflow-y-auto">
<div className="sticky top-0 z-10 bg-base border-b border-offbase grid grid-cols-[minmax(0,1fr)_72px_88px_120px_28px] sm:grid-cols-[minmax(0,1fr)_88px_96px_140px_32px]">
<div className="h-8 flex items-center px-2">
<div className="h-2.5 w-12 rounded bg-offbase" />
</div>
<div className="h-8 flex items-center px-2">
<div className="h-2.5 w-10 rounded bg-offbase" />
</div>
<div className="h-8 flex items-center justify-end px-2">
<div className="h-2.5 w-10 rounded bg-offbase" />
</div>
<div className="h-8 flex items-center px-2">
<div className="h-2.5 w-14 rounded bg-offbase" />
</div>
<div />
</div>
<div>
{Array.from({ length: 10 }).map((_, index) => (
<div
key={index}
className="grid grid-cols-[minmax(0,1fr)_72px_88px_120px_28px] sm:grid-cols-[minmax(0,1fr)_88px_96px_140px_32px] items-center border-b border-offbase h-[35px]"
>
<div className="px-2 flex items-center gap-2">
<div className="h-3.5 w-3.5 rounded bg-offbase" />
<div className="h-2.5 w-2/3 rounded bg-offbase" />
</div>
<div className="px-2">
<div className="h-2.5 w-8 rounded bg-offbase" />
</div>
<div className="px-2 flex justify-end">
<div className="h-2.5 w-12 rounded bg-offbase" />
</div>
<div className="px-2">
<div className="h-2.5 w-16 rounded bg-offbase" />
</div>
<div className="px-2 flex justify-center">
<div className="h-4 w-4 rounded bg-offbase" />
</div>
</div>
))}
</div>
</div>
);
}
function GallerySkeleton() {
return (
<div className="h-full min-h-0 flex flex-col">
<div className="flex-1 min-h-0 flex items-center justify-center p-6 bg-background">
<div className="flex flex-col items-center gap-3 max-w-[420px]">
<div className="w-[260px] sm:w-[320px] aspect-[3/4] rounded-lg border border-offbase bg-offbase" />
<div className="h-3 w-40 rounded bg-offbase" />
<div className="h-2.5 w-28 rounded bg-offbase" />
<div className="flex gap-2">
<div className="h-8 w-20 rounded-md bg-offbase" />
<div className="h-8 w-20 rounded-md bg-offbase" />
</div>
</div>
</div>
<div className="shrink-0 border-t border-offbase bg-base">
<div className="flex gap-2 overflow-x-auto p-2">
{Array.from({ length: 10 }).map((_, index) => (
<div key={index} className="shrink-0 w-[88px] rounded-md overflow-hidden border border-offbase bg-base">
<div className="aspect-[3/4] bg-offbase" />
<div className="p-1.5">
<div className="h-2.5 w-5/6 rounded bg-offbase" />
</div>
</div>
))}
</div>
</div>
</div>
);
}
export function DocumentListSkeleton({
viewMode = 'icons',
iconSize = 'md',
}: DocumentListSkeletonProps) {
let body;
if (viewMode === 'list') {
body = <ListSkeleton />;
} else if (viewMode === 'gallery') {
body = <GallerySkeleton />;
} else {
body = <IconsSkeleton iconSize={iconSize} />;
}
return (
<div
className="h-full w-full min-h-0 flex flex-col animate-pulse"
aria-label="Loading documents"
aria-busy="true"
>
{body}
</div>
);
}

View file

@ -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<HTMLDivElement | null>(null);
const imageRef = useRef<HTMLImageElement | null>(null);
const [isVisible, setIsVisible] = useState(false);
const [imagePreview, setImagePreview] = useState<string | null>(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 */}
<img
ref={imageRef}
src={imagePreview}
alt={`${doc.name} preview`}
className={`absolute inset-0 h-full w-full object-cover transition-opacity duration-150 ${isImageReady ? 'opacity-100' : 'opacity-0'}`}
@ -329,8 +343,10 @@ export function DocumentPreview({ doc }: DocumentPreviewProps) {
</>
)}
<div className="absolute left-1 top-1 z-20 rounded bg-black/45 px-1.5 py-0.5 text-[10px] font-semibold tracking-wide text-white">
{isGenerating ? '…' : typeLabel}
<div className="absolute left-1 bottom-1 z-20 rounded bg-black/45 px-1.5 py-0.5 text-[9px] font-semibold tracking-wide text-white/90">
{isGenerating
? '…'
: `${typeLabel}${formatDocumentSize(doc.size)}`}
</div>
</div>
);

View file

@ -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 (
<div className="flex items-center gap-1">
<div className="hidden xs:flex items-center bg-base rounded p-[1px] gap-0.5 border border-transparent">
<Button
onClick={() => onViewModeChange('list')}
className={`p-0.5 rounded hover:bg-offbase transition-all hover:scale-[1.07] ${viewMode === 'list' ? activeIconClass : inactiveIconClass}`}
aria-label="List view"
>
<ListIcon className="w-4 h-4" />
</Button>
<Button
onClick={() => onViewModeChange('grid')}
className={`p-0.5 rounded hover:bg-offbase transition-all hover:scale-[1.07] ${viewMode === 'grid' ? activeIconClass : inactiveIconClass}`}
aria-label="Grid view"
>
<GridIcon className="w-4 h-4" />
</Button>
</div>
<div className="hidden xs:block h-4 w-px bg-offbase mx-1" />
<div className="flex items-center gap-1">
<Button
onClick={onSortDirectionChange}
className={`${buttonBaseClass} px-2 text-xs`}
>
{directionLabel}
</Button>
<div className="relative">
<Listbox value={sortBy} onChange={onSortByChange}>
<ListboxButton className={`${buttonBaseClass} pl-2 pr-1 gap-1 min-w-[80px] justify-between focus:outline-none focus:ring-accent focus:ring-2`}>
<span>{sortOptions.find(opt => opt.value === sortBy)?.label}</span>
<ChevronUpDownIcon className="h-3 w-3 opacity-50" />
</ListboxButton>
<ListboxOptions anchor="top end" className="absolute z-50 w-32 overflow-auto rounded-lg bg-background shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none p-1">
{sortOptions.map((option) => (
<ListboxOption
key={option.value}
value={option.value}
className={({ active, selected }) =>
`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}
</ListboxOption>
))}
</ListboxOptions>
</Listbox>
</div>
</div>
</div>
);
}

View file

@ -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<boolean>(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 (
<DndProvider
key="touch"
backend={TouchBackend}
options={{
enableMouseEvents: false,
enableTouchEvents: true,
delayTouchStart: 220,
ignoreContextMenu: true,
}}
>
{children}
</DndProvider>
);
}
return (
<DndProvider key="html5" backend={HTML5Backend}>
{children}
</DndProvider>
);
}

View file

@ -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<DocumentListDocument, 'id' | 'type'>): DocKey =>
`${doc.type}-${doc.id}`;
interface SelectionContextValue {
selection: ReadonlySet<DocKey>;
isSelected: (doc: Pick<DocumentListDocument, 'id' | 'type'>) => 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<SelectionContextValue | null>(null);
export function DocumentSelectionProvider({ children }: { children: ReactNode }) {
const [selection, setSelection] = useState<Set<DocKey>>(() => new Set());
const [order, setOrder] = useState<DocumentListDocument[]>([]);
const [anchor, setAnchor] = useState<DocKey | null>(null);
const isSelected = useCallback(
(doc: Pick<DocumentListDocument, 'id' | 'type'>) => selection.has(docKey(doc)),
[selection],
);
const setVisibleOrder = useCallback((docs: DocumentListDocument[]) => {
setOrder(docs);
}, []);
const select = useCallback<SelectionContextValue['select']>(
(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<DocKey>();
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<DocKey>();
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<SelectionContextValue>(
() => ({
selection,
isSelected,
selectionSize: selection.size,
setVisibleOrder,
select,
clear,
replace,
getSelectedDocs,
}),
[selection, isSelected, setVisibleOrder, select, clear, replace, getSelectedDocs],
);
return <SelectionContext.Provider value={value}>{children}</SelectionContext.Provider>;
}
export function useDocumentSelection(): SelectionContextValue {
const ctx = useContext(SelectionContext);
if (!ctx) {
throw new Error('useDocumentSelection must be used inside DocumentSelectionProvider');
}
return ctx;
}

View file

@ -0,0 +1,22 @@
import type { DocumentListDocument } from '@/types/documents';
export const DND_DOCUMENT = 'openreader/document' as const;
export type DocumentIdentity = Pick<DocumentListDocument, 'id' | 'type'>;
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' };

View file

@ -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`;
}

View file

@ -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<IconSize, string> = {
sm: 'text-[10px]',
md: 'text-[11px]',
lg: 'text-[12px]',
xl: 'text-[13px]',
};
const BOTTOM_PADDING_CLASSES: Record<IconSize, string> = {
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<IconSize, string> = {
sm: 'gap-1',
md: 'gap-1.5',
lg: 'gap-2',
xl: 'gap-2',
};
const FILE_ICON_CLASSES: Record<IconSize, string> = {
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<IconSize, string> = {
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<IconSize, string> = {
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 (
<div
ref={setRefs}
data-doc-tile
aria-selected={isSelected}
className={
'group relative flex flex-col rounded-md overflow-hidden border transition-all duration-200 ease-out hover:scale-[1.01] ' +
(isSelected
? 'border-accent bg-offbase'
: 'border-offbase bg-base hover:bg-offbase hover:border-accent') +
(isDropTarget ? ' ring-1 ring-accent' : '') +
(isDragging ? ' opacity-50' : '')
}
>
<Link
href={href}
prefetch={false}
draggable={false}
className="block"
aria-label={`Open ${doc.name}`}
onClick={handleClick}
>
<DocumentPreview doc={doc} />
</Link>
<div className={`flex items-center w-full ${BOTTOM_PADDING_CLASSES[iconSize]}`}>
<Link
href={href}
prefetch={false}
draggable={false}
className={`flex items-center flex-1 min-w-0 rounded-md ${LINK_PADDING_CLASS} ${GAP_CLASSES[iconSize]}`}
onClick={handleClick}
>
<span className="flex-shrink-0 flex items-center">
{doc.type === 'pdf' ? (
<PDFIcon className={`${FILE_ICON_CLASSES[iconSize]} text-red-500`} />
) : doc.type === 'epub' ? (
<EPUBIcon className={`${FILE_ICON_CLASSES[iconSize]} text-blue-500`} />
) : (
<FileIcon className={`${FILE_ICON_CLASSES[iconSize]} text-muted`} />
)}
</span>
<span
className={
'leading-none font-medium truncate flex-1 min-w-0 ' +
NAME_SIZE_CLASSES[iconSize] +
' ' +
(isSelected ? 'text-accent' : 'text-foreground group-hover:text-accent')
}
>
{doc.name}
</span>
</Link>
{showDeleteButton && (
<Button
onClick={() => onDelete(doc)}
className={`inline-flex items-center justify-center text-muted hover:text-accent hover:bg-base focus:outline-none transition-colors duration-200 ${TRASH_BTN_CLASSES[iconSize]}`}
aria-label={`Delete ${doc.name}`}
>
<svg className={TRASH_ICON_CLASSES[iconSize]} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</Button>
)}
</div>
</div>
);
}

View file

@ -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<string, string>;
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 <PDFIcon className={className ?? 'w-4 h-4 text-red-500'} />;
if (doc.type === 'epub') return <EPUBIcon className={className ?? 'w-4 h-4 text-blue-500'} />;
return <FileIcon className={className ?? 'w-4 h-4 text-muted'} />;
}
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<DocumentDragItem, void, { isDragging: boolean }>(() => ({
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<DocumentDragItem, void, { isOver: boolean; canDrop: boolean }>(() => ({
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 (
<div
ref={setRefs}
data-doc-tile
onClick={onClick}
aria-current={active ? 'true' : undefined}
className={
'group relative w-[98px] sm:w-[110px] shrink-0 cursor-pointer rounded-lg overflow-hidden border bg-base snap-start transition-all duration-200 ease-out ' +
(active
? 'border-accent shadow-[0_10px_24px_-18px_rgba(0,0,0,0.85)] -translate-y-0.5'
: 'border-offbase hover:border-accent hover:-translate-y-0.5') +
(isOver && canDrop ? ' border-accent' : '') +
(isDragging ? ' opacity-50' : '')
}
title={doc.name}
>
<div className="aspect-[3/4] bg-base">
<DocumentPreview doc={doc} />
</div>
<div
className={
'px-2 py-1.5 flex items-center gap-1.5 border-t transition-colors duration-200 ' +
(active ? 'bg-offbase border-accent' : 'bg-base border-offbase')
}
>
<KindIcon
doc={doc}
className={
'w-3 h-3 shrink-0 transition-colors duration-200 ' +
(active ? 'text-accent' : 'text-muted')
}
/>
<span
className={
'truncate text-[10px] leading-none transition-colors duration-200 ' +
(active ? 'text-accent font-medium' : 'text-foreground')
}
>
{doc.name}
</span>
</div>
</div>
);
}
export function GalleryView({
documents,
folderNameById,
onDeleteDoc,
onMergeIntoFolder,
}: GalleryViewProps) {
const { setVisibleOrder } = useDocumentSelection();
const railRef = useRef<HTMLDivElement | null>(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 (
<div className="flex-1 min-h-0 flex flex-col">
<div className="flex-1 min-h-0 flex items-center justify-center p-6 bg-background">
{activeDoc ? (
<div className="w-full max-w-[920px] flex flex-col md:flex-row items-center md:items-start justify-center gap-4 md:gap-6">
<div className="flex flex-col items-center gap-3 w-[260px] sm:w-[320px] shrink-0">
<div className="w-full aspect-[3/4] rounded-lg overflow-hidden border border-offbase shadow-lg">
<DocumentPreview doc={activeDoc} />
</div>
<div className="text-center">
<h2 className="text-[14px] font-semibold text-foreground truncate max-w-[320px]">
{activeDoc.name}
</h2>
<p className="text-[11px] text-muted">
{activeDoc.type.toUpperCase()} {formatDocumentSize(activeDoc.size)}
</p>
</div>
<div className="flex gap-2">
<Link
href={openHref || '/app'}
prefetch={false}
className="h-8 px-4 inline-flex items-center justify-center rounded-md bg-accent text-background text-[12px] font-medium hover:bg-secondary-accent hover:scale-[1.01] transition-all duration-200 ease-out"
>
Open
</Link>
<button
type="button"
onClick={() => onDeleteDoc(activeDoc)}
className="h-8 px-3 rounded-md border border-offbase bg-base text-[12px] text-muted hover:text-accent hover:border-accent hover:bg-offbase hover:scale-[1.01] transition-all duration-200 ease-out"
>
Delete
</button>
</div>
</div>
<dl className="w-full max-w-[360px] md:max-w-[340px] grid grid-cols-2 gap-x-2 gap-y-1 rounded-md border border-offbase bg-base px-3 py-2 text-[11px] md:self-center">
<dt className="text-muted">Type</dt>
<dd className="text-foreground text-right uppercase tracking-wide">{activeDoc.type}</dd>
<dt className="text-muted">Size</dt>
<dd className="text-foreground text-right tabular-nums">{formatDocumentSize(activeDoc.size)}</dd>
<dt className="text-muted">Last opened</dt>
<dd className="text-foreground text-right">{formatDateTime(activeDoc.recentlyOpenedAt)}</dd>
<dt className="text-muted">Last modified</dt>
<dd className="text-foreground text-right">{formatDateTime(activeDoc.lastModified)}</dd>
{activeDoc.folderId && (
<>
<dt className="text-muted">Folder</dt>
<dd className="text-foreground text-right truncate" title={folderNameById?.[activeDoc.folderId] ?? activeDoc.folderId}>
{folderNameById?.[activeDoc.folderId] ?? activeDoc.folderId}
</dd>
</>
)}
{activeDoc.type === 'pdf' && (
<>
<dt className="text-muted">Parse status</dt>
<dd className="text-foreground text-right">{formatParseStatus(activeDoc.parseStatus)}</dd>
</>
)}
</dl>
</div>
) : (
<p className="text-[12px] text-muted">No documents to show</p>
)}
</div>
<div className="shrink-0 border-t border-offbase bg-gradient-to-b from-base to-offbase/30">
<div
ref={railRef}
className="flex gap-2.5 overflow-x-auto pl-4 pr-3 pt-2.5 pb-1.5 snap-x snap-proximity scroll-pl-4 sm:scroll-pl-5 scroll-pr-3 sm:scroll-pr-4"
>
{documents.map((doc, i) => (
<GalleryThumb
key={`${doc.type}-${doc.id}`}
doc={doc}
active={i === activeIdx}
onClick={() => setActiveIdx(i)}
onMergeIntoFolder={onMergeIntoFolder}
/>
))}
</div>
</div>
</div>
);
}

View file

@ -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<HTMLDivElement | null>(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 (
<div
onClick={handleBackgroundClick}
className="flex-1 min-h-0 overflow-y-auto p-3"
>
<div
ref={gridRef}
className="grid"
style={iconsGridStyle(iconSize, documents.length, { suppressSingleRowStretch })}
>
{documents.map((doc) => (
<DocumentTile
key={`${doc.type}-${doc.id}`}
doc={doc}
iconSize={iconSize}
onDelete={onDeleteDoc}
onMergeIntoFolder={onMergeIntoFolder}
/>
))}
</div>
</div>
);
}

View file

@ -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 <PDFIcon className="w-4 h-4 text-red-500" />;
if (doc.type === 'epub') return <EPUBIcon className="w-4 h-4 text-blue-500" />;
return <FileIcon className="w-4 h-4 text-muted" />;
}
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 (
<button
type="button"
onClick={() => {
const nextDir: SortDirection =
active && sortDirection === 'asc' ? 'desc' : 'asc';
onSortChange(field, nextDir);
}}
className={
'flex items-center gap-1 px-2 py-1.5 text-[10px] uppercase tracking-wide font-semibold transition-colors duration-200 ease-out hover:text-accent ' +
(active ? 'text-accent' : 'text-muted') +
(align === 'right' ? ' justify-end' : '') +
' ' +
(className ?? '')
}
>
<span>{label}</span>
<span className="w-2 text-[10px]">{arrow}</span>
</button>
);
}
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<DocumentDragItem, void, { isDragging: boolean }>(() => ({
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<DocumentDragItem, void, { isOver: boolean; canDrop: boolean }>(() => ({
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 (
<div
ref={setRefs}
data-doc-tile
aria-selected={isSelected}
className={
'grid grid-cols-[minmax(0,1fr)_72px_88px_120px_28px] sm:grid-cols-[minmax(0,1fr)_88px_96px_140px_32px] items-center text-[12px] border-b border-offbase transition-colors duration-200 ease-out ' +
(isSelected
? 'bg-offbase text-accent'
: 'text-foreground hover:bg-offbase') +
(isTarget ? ' ring-1 ring-accent ring-inset' : '') +
(isDragging ? ' opacity-50' : '')
}
>
<Link
href={href}
prefetch={false}
draggable={false}
onClick={handleClick}
className="flex items-center gap-2 min-w-0 px-2 py-1.5"
>
<KindIcon doc={doc} />
<span className="truncate">{doc.name}</span>
</Link>
<span className="px-2 text-[11px] text-muted uppercase tracking-wide">{doc.type}</span>
<span className="px-2 text-[11px] text-muted text-right tabular-nums">
{formatDocumentSize(doc.size)}
</span>
<span className="px-2 text-[11px] text-muted tabular-nums">
{formatDate(doc.lastModified)}
</span>
<Button
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
onDeleteDoc(doc);
}}
className="h-7 w-7 flex items-center justify-center text-muted hover:text-accent hover:bg-offbase hover:scale-[1.02] rounded transition-all duration-200 ease-out"
aria-label={`Delete ${doc.name}`}
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</Button>
</div>
);
}
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 (
<div onClick={handleBackgroundClick} className="flex-1 min-h-0 overflow-y-auto">
<div className="sticky top-0 z-10 bg-base border-b border-offbase grid grid-cols-[minmax(0,1fr)_72px_88px_120px_28px] sm:grid-cols-[minmax(0,1fr)_88px_96px_140px_32px]">
<HeaderCell label="Name" field="name" sortBy={sortBy} sortDirection={sortDirection} onSortChange={onSortChange} />
<HeaderCell label="Kind" field="type" sortBy={sortBy} sortDirection={sortDirection} onSortChange={onSortChange} />
<HeaderCell label="Size" field="size" sortBy={sortBy} sortDirection={sortDirection} onSortChange={onSortChange} align="right" />
<HeaderCell label="Modified" field="date" sortBy={sortBy} sortDirection={sortDirection} onSortChange={onSortChange} />
<span />
</div>
<div>
{documents.map((doc) => (
<DocRow
key={`${doc.type}-${doc.id}`}
doc={doc}
onDeleteDoc={onDeleteDoc}
onMergeIntoFolder={onMergeIntoFolder}
/>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,43 @@
import type { CSSProperties } from 'react';
import type { IconSize } from '@/types/documents';
const TILE_WIDTH_PX: Record<IconSize, number> = {
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,
};
}

View file

@ -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 (
<button
type="button"
onClick={onClick}
className={
'group w-full flex items-center gap-2 px-2 py-1 rounded-md text-[12px] border transform transition-all duration-200 ease-out text-left hover:scale-[1.01] ' +
(active
? 'border-accent bg-offbase text-accent'
: 'border-transparent bg-transparent text-foreground hover:border-accent hover:text-accent') +
(isDropTarget ? ' ring-1 ring-accent' : '')
}
>
<span
className={
'w-4 h-4 shrink-0 flex items-center justify-center transition-colors duration-200 ' +
(active ? 'text-accent' : 'text-muted group-hover:text-accent')
}
>
{icon}
</span>
<span className="truncate flex-1">{label}</span>
{typeof count === 'number' && count > 0 && (
<span
className={`text-[10px] text-muted tabular-nums transition-transform duration-200 ease-out ${countClassName ?? ''}`}
>
{count}
</span>
)}
{trailing}
</button>
);
}
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 (
<div
ref={dropRef as unknown as React.RefObject<HTMLDivElement>}
className="group/folder relative"
>
<SidebarRow
active={active}
onClick={onClick}
icon={<FolderIcon className="w-3.5 h-3.5" />}
label={folder.name}
count={folder.documents.length}
countClassName="group-hover/folder:-translate-x-6 group-focus-within/folder:-translate-x-6"
isDropTarget={isDropTarget}
/>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
className="absolute right-1 top-1/2 -translate-y-1/2 h-5 w-5 inline-flex items-center justify-center rounded text-muted opacity-0 group-hover/folder:opacity-100 group-focus-within/folder:opacity-100 hover:text-accent hover:bg-offbase transition"
aria-label={`Delete ${folder.name}`}
title={`Delete ${folder.name}`}
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
);
}
function SectionHeader({
children,
isFirst,
rightSlot,
}: {
children: ReactNode;
isFirst?: boolean;
rightSlot?: ReactNode;
}) {
return (
<div
className={
'px-2 pb-1 text-[10px] uppercase tracking-[0.08em] text-muted font-semibold leading-none flex items-center justify-between ' +
(isFirst ? 'pt-1.5' : 'pt-3')
}
>
<span>{children}</span>
{rightSlot && <span className="inline-flex items-center leading-none shrink-0">{rightSlot}</span>}
</div>
);
}
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 (
<aside
style={{ '--sidebar-width': `${width}px` } as CSSProperties}
className="relative h-full w-full md:[width:var(--sidebar-width)] bg-base border-r border-offbase shrink-0 flex flex-col"
>
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="p-2 flex flex-col gap-0.5">
{topSlot && (
<div className="mb-0.5 shrink-0" onClick={(e) => e.stopPropagation()}>
{topSlot}
</div>
)}
<SectionHeader isFirst={!!topSlot}>Library</SectionHeader>
<SidebarRow
active={filter === 'all'}
onClick={() => {
onFilterChange('all');
onRowAction?.();
}}
icon={<HomeIcon className="w-3.5 h-3.5" />}
label="All Documents"
count={counts.all}
/>
<SidebarRow
active={filter === 'recents'}
onClick={() => {
onFilterChange('recents');
onRowAction?.();
}}
icon={<ClockIcon className="w-3.5 h-3.5" />}
label="Recently Opened"
/>
<SectionHeader>Kinds</SectionHeader>
<SidebarRow
active={filter === 'pdf'}
onClick={() => {
onFilterChange('pdf');
onRowAction?.();
}}
icon={<PDFIcon className="w-3.5 h-3.5" />}
label="PDF"
count={counts.pdf}
/>
<SidebarRow
active={filter === 'epub'}
onClick={() => {
onFilterChange('epub');
onRowAction?.();
}}
icon={<EPUBIcon className="w-3.5 h-3.5" />}
label="EPUB"
count={counts.epub}
/>
<SidebarRow
active={filter === 'html'}
onClick={() => {
onFilterChange('html');
onRowAction?.();
}}
icon={<FileIcon className="w-3.5 h-3.5" />}
label="Text"
count={counts.html}
/>
<SectionHeader
rightSlot={(
<Menu as="div" className="relative inline-flex items-center leading-none text-left shrink-0 normal-case tracking-normal font-normal">
<MenuButton
className="inline-flex items-center justify-center h-3.5 w-5 rounded-sm text-muted hover:text-accent transition-colors duration-200 ease-out focus:outline-none"
title="Folder actions"
aria-label="Folder actions"
>
<DotsHorizontalIcon className="w-4 h-2.5" />
</MenuButton>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<MenuItems
anchor="bottom start"
className="z-50 mt-2 min-w-[180px] rounded-md bg-base shadow-lg ring-1 ring-black/5 focus:outline-none p-1 normal-case tracking-normal font-normal"
>
<MenuItem>
{({ active }) => (
<button
type="button"
onClick={() => {
onNewFolder();
onRowAction?.();
}}
className={`${active ? 'bg-offbase text-accent' : 'text-foreground'} group flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`}
>
<FolderPlusIcon className="h-4 w-4" />
New Folder
</button>
)}
</MenuItem>
<MenuItem disabled={folders.length === 0}>
{({ active, disabled }) => (
<button
type="button"
onClick={() => {
onClearFolders();
onRowAction?.();
}}
disabled={disabled}
className={`${disabled ? 'text-muted/60 cursor-not-allowed' : active ? 'bg-offbase text-accent' : 'text-foreground'} group flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`}
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
</svg>
Remove All Folders
</button>
)}
</MenuItem>
</MenuItems>
</Transition>
</Menu>
)}
>
Folders
</SectionHeader>
{folders.length === 0 ? (
<p className="px-2 py-1 text-[11px] text-muted">No folders yet</p>
) : (
folders.map((folder) => (
<FolderRow
key={folder.id}
folder={folder}
active={filter === `folder:${folder.id}`}
onClick={() => {
onFilterChange(`folder:${folder.id}`);
onRowAction?.();
}}
onDelete={() => {
onDeleteFolder(folder.id);
onRowAction?.();
}}
onDropOnFolder={onDropOnFolder}
/>
))
)}
</div>
</div>
{bottomSlot && (
<div className="shrink-0 border-t border-offbase p-2" onClick={(e) => e.stopPropagation()}>
{bottomSlot}
</div>
)}
<div
role="separator"
aria-orientation="vertical"
aria-label="Resize sidebar"
onPointerDown={onResizeStart}
onPointerMove={onResizeMove}
onPointerUp={onResizeEnd}
onPointerCancel={onResizeEnd}
className="hidden md:block absolute top-0 right-0 h-full w-1 cursor-col-resize hover:bg-offbase active:bg-accent transition-colors duration-200 ease-out"
/>
</aside>
);
}

View file

@ -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 (
<div
role="status"
aria-live="polite"
className="h-6 px-3 flex items-center justify-between gap-3 text-[11px] text-muted bg-base border-t border-offbase select-none"
>
<span className="truncate">{summary}</span>
<span className="shrink-0">
{selectedCount > 0
? `${selectedCount} of ${itemCount} selected`
: `${itemCount} item${itemCount === 1 ? '' : 's'}`}
<span className="mx-1.5 text-muted"></span>
{formatDocumentSize(totalSize)}
</span>
</div>
);
}

View file

@ -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 (
<div className="sticky top-0 z-40 w-full border-b border-offbase bg-base">
<div className="px-2 sm:px-3 py-1 min-h-10 flex items-center gap-1.5 sm:gap-2">
{leftSlot && (
<div className="shrink-0 flex items-center gap-2 pr-1 sm:pr-2 sm:border-r sm:border-offbase">
{leftSlot}
</div>
)}
<button
type="button"
onClick={onToggleSidebar}
className={`${TOOLBAR_BTN} ${isSidebarOpen ? TOOLBAR_BTN_ACTIVE : TOOLBAR_BTN_INACTIVE} shrink-0`}
aria-pressed={isSidebarOpen}
aria-label="Toggle sidebar"
title="Toggle sidebar"
>
<HamburgerIcon className="w-4 h-4" />
</button>
<div className={PILL}>
{VIEW_BUTTONS.map(({ value, label, Icon }) => {
const active = viewMode === value;
const isIconsToggle = value === 'icons';
return (
<div
key={value}
className={isIconsToggle ? 'relative group/icons inline-flex items-center' : 'inline-flex items-center'}
>
<button
type="button"
onClick={() => onViewModeChange(value)}
aria-pressed={active}
aria-label={`${label} view`}
title={`${label} view`}
className={
PILL_SEGMENT +
' h-6 w-7 ' +
(active ? PILL_SEGMENT_ACTIVE : PILL_SEGMENT_INACTIVE)
}
>
<Icon className="w-4 h-4" />
</button>
{isIconsToggle && viewMode === 'icons' && (
<div
className="absolute top-full left-1/2 z-30 -translate-x-1/2 pt-1 opacity-0 pointer-events-none transition-opacity duration-150 group-hover/icons:opacity-100 group-hover/icons:pointer-events-auto group-focus-within/icons:opacity-100 group-focus-within/icons:pointer-events-auto"
>
<div className={`${PILL} shadow-lg`}>
{ICON_SIZES.map(({ value: sizeValue, label: sizeLabel }) => {
const sizeActive = iconSize === sizeValue;
return (
<button
key={sizeValue}
type="button"
onClick={() => onIconSizeChange(sizeValue)}
aria-pressed={sizeActive}
aria-label={`Icon size ${sizeLabel}`}
className={
PILL_SEGMENT +
' h-6 min-w-[26px] px-1.5 font-semibold tracking-wide ' +
(sizeActive ? PILL_SEGMENT_ACTIVE : PILL_SEGMENT_INACTIVE)
}
>
{sizeLabel}
</button>
);
})}
</div>
</div>
)}
</div>
);
})}
</div>
{showSortControls && (
<div className="flex items-center gap-1 shrink-0">
<button
type="button"
onClick={onSortDirectionToggle}
className={`${TOOLBAR_BTN} ${TOOLBAR_BTN_INACTIVE} whitespace-nowrap`}
title="Toggle sort direction"
>
{directionLabel}
</button>
<Listbox value={sortBy} onChange={onSortByChange}>
<ListboxButton
className={`${TOOLBAR_BTN} ${TOOLBAR_BTN_INACTIVE} gap-1 min-w-[90px] justify-between`}
>
<span>{currentSort.label}</span>
<ChevronUpDownIcon className="h-3 w-3 opacity-60" />
</ListboxButton>
<ListboxOptions
anchor="bottom end"
className="z-50 mt-1 rounded-md bg-background border border-offbase shadow-lg p-1 focus:outline-none"
>
{SORT_OPTIONS.map((opt) => (
<ListboxOption
key={opt.value}
value={opt.value}
className={({ active, selected }) =>
`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}
</ListboxOption>
))}
</ListboxOptions>
</Listbox>
</div>
)}
<div className="flex-1 min-w-0" />
<div className="hidden sm:flex items-center gap-1.5 px-2 py-1 rounded-md bg-background border border-offbase hover:border-accent focus-within:ring-1 focus-within:ring-accent focus-within:border-accent transition-colors duration-200 ease-out w-[160px] md:w-[200px]">
<SearchIcon className="w-3.5 h-3.5 text-muted shrink-0" />
<input
type="search"
value={query}
onChange={(e) => onQueryChange(e.target.value)}
placeholder="Search"
className="flex-1 min-w-0 bg-transparent outline-none text-xs text-foreground placeholder:text-muted"
/>
</div>
{rightSlot && (
<div className="shrink-0 flex items-center gap-2 pl-1 sm:pl-2 sm:border-l sm:border-offbase ml-0.5">
{rightSlot}
</div>
)}
</div>
</div>
);
}

View file

@ -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 (
<div className="flex flex-col h-full w-full bg-background overflow-hidden">
{toolbar}
<div className="flex flex-1 min-h-0">
{!isNarrow && sidebarOpen && (
<div className="h-full">{sidebar}</div>
)}
<div className="flex-1 min-w-0 min-h-0 flex flex-col overflow-hidden">
{children}
</div>
</div>
{statusBar}
{/* Mobile drawer */}
<Transition show={isNarrow && sidebarOpen} as={Fragment}>
<Dialog
onClose={onRequestSidebarClose ?? (() => undefined)}
className="relative z-40 md:hidden"
>
<TransitionChild
as={Fragment}
enter="transition-opacity duration-150"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm" />
</TransitionChild>
<div className="fixed inset-y-0 left-0 flex max-w-full">
<TransitionChild
as={Fragment}
enter="transition-transform duration-200 ease-out"
enterFrom="-translate-x-full"
enterTo="translate-x-0"
leave="transition-transform duration-150 ease-in"
leaveFrom="translate-x-0"
leaveTo="-translate-x-full"
>
<DialogPanel
className="w-[80vw] max-w-[280px] h-full bg-base shadow-xl"
>
{sidebar}
</DialogPanel>
</TransitionChild>
</div>
</Dialog>
</Transition>
</div>
);
}

View file

@ -0,0 +1,102 @@
import type { SVGProps } from 'react';
type IconProps = SVGProps<SVGSVGElement>;
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) => (
<svg {...baseSvg(props)}>
<circle cx="11" cy="11" r="6.5" />
<path d="m20 20-3.5-3.5" />
</svg>
);
export const FolderIcon = (props: IconProps) => (
<svg {...baseSvg(props)}>
<path d="M3 7.5C3 6.4 3.9 5.5 5 5.5h3.6c.5 0 1 .2 1.4.6l1.4 1.4c.4.4.9.6 1.4.6H19c1.1 0 2 .9 2 2v7c0 1.1-.9 2-2 2H5c-1.1 0-2-.9-2-2V7.5Z" />
</svg>
);
export const FolderPlusIcon = (props: IconProps) => (
<svg {...baseSvg(props)}>
<path d="M3 7.5C3 6.4 3.9 5.5 5 5.5h3.6c.5 0 1 .2 1.4.6l1.4 1.4c.4.4.9.6 1.4.6H19c1.1 0 2 .9 2 2v7c0 1.1-.9 2-2 2H5c-1.1 0-2-.9-2-2V7.5Z" />
<path d="M12 11v5M9.5 13.5h5" />
</svg>
);
export const SidebarIcon = (props: IconProps) => (
<svg {...baseSvg(props)}>
<rect x="3" y="5" width="18" height="14" rx="2" />
<path d="M9 5v14" />
</svg>
);
export const IconsViewIcon = (props: IconProps) => (
<svg {...baseSvg(props)}>
<rect x="4" y="4" width="6" height="6" rx="1" />
<rect x="14" y="4" width="6" height="6" rx="1" />
<rect x="4" y="14" width="6" height="6" rx="1" />
<rect x="14" y="14" width="6" height="6" rx="1" />
</svg>
);
export const ListViewIcon = (props: IconProps) => (
<svg {...baseSvg(props)}>
<path d="M4 6h16M4 12h16M4 18h16" />
</svg>
);
export const GalleryViewIcon = (props: IconProps) => (
<svg {...baseSvg(props)}>
<rect x="3" y="4" width="18" height="11" rx="1.5" />
<rect x="4" y="17" width="4" height="3" rx="0.6" />
<rect x="10" y="17" width="4" height="3" rx="0.6" />
<rect x="16" y="17" width="4" height="3" rx="0.6" />
</svg>
);
export const ChevronRightSmall = (props: IconProps) => (
<svg {...baseSvg(props)}>
<path d="m9 6 6 6-6 6" />
</svg>
);
export const ChevronLeftSmall = (props: IconProps) => (
<svg {...baseSvg(props)}>
<path d="m15 6-6 6 6 6" />
</svg>
);
export const HamburgerIcon = (props: IconProps) => (
<svg {...baseSvg(props)}>
<path d="M4 7h16M4 12h16M4 17h16" />
</svg>
);
export const ClockIcon = (props: IconProps) => (
<svg {...baseSvg(props)}>
<circle cx="12" cy="12" r="8.5" />
<path d="M12 7.5V12l3 2" />
</svg>
);
export const HomeIcon = (props: IconProps) => (
<svg {...baseSvg(props)}>
<path d="m3 11 9-7 9 7" />
<path d="M5 10v9a1 1 0 0 0 1 1h4v-6h4v6h4a1 1 0 0 0 1-1v-9" />
</svg>
);

View file

@ -8,7 +8,7 @@ export function DocumentSkeleton() {
icon: '⚠️',
duration: 5000,
});
}, 3000);
}, 10000);
return () => clearTimeout(timer);
}, []);

View file

@ -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<string | null>(null);
const emitBatchState = useCallback((state: Omit<UploadBatchState, 'uploaderId'>) => {
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 (
<div {...rootProps} className={`relative w-full h-full ${className}`}>
<input {...getInputProps()} />
{children}
{isDragActive && (
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-background/90 backdrop-blur-md pointer-events-none p-6">
<div className="w-full h-full border-2 border-dashed border-accent rounded-xl flex flex-col items-center justify-center bg-base/60 text-center p-4">
<UploadIcon className="w-14 h-14 text-accent mb-4 animate-bounce" />
<p className="text-xl font-bold text-foreground mb-1.5">
Drop files here to upload
</p>
<p className="text-sm text-foreground/70">
{enableDocx
? 'Accepts PDF, EPUB, TXT, MD, or DOCX'
: 'Accepts PDF, EPUB, TXT, or MD'}
</p>
{error && (
<p className="mt-3 text-sm text-red-500">
Upload failed: {error} try again.
</p>
)}
</div>
</div>
)}
{!isDragActive && error && (
<div className="absolute inset-x-4 bottom-4 z-40 rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-center text-sm text-red-500 pointer-events-none">
Upload failed: {error} try again.
</div>
)}
</div>
);
}
return (
<div
{...getRootProps()}
className={`${containerBase} ${paddingClass}`}
className={`${containerBase} ${borderBgClass} ${paddingClass}`}
>
<input {...getInputProps()} />
{variant === 'compact' ? (
<div className="flex items-center gap-2 text-left">
<UploadIcon className="w-5 h-5 text-muted" />
<div className="flex items-center gap-2 text-left w-full min-w-0">
<UploadIcon className="w-3.5 h-3.5 text-muted group-hover:text-accent shrink-0 transition-colors duration-200" />
{isUploading ? (
<p className="text-xs font-medium text-foreground">Uploading</p>
<p className="text-[12px] font-medium truncate flex-1">Uploading</p>
) : isConverting ? (
<p className="text-xs font-medium text-foreground">Converting DOCX</p>
<p className="text-[12px] font-medium truncate flex-1">Converting DOCX</p>
) : (
<div className="flex items-center gap-2">
<p className="text-xs font-medium text-foreground">
{isDragActive ? 'Drop files here' : 'Drop files or click'}
<div className="flex items-center gap-2 min-w-0 flex-1">
<p className="text-[12px] truncate flex-1">
{isDragActive ? 'Drop files here' : 'Upload documents'}
</p>
{error && <p className="text-xs text-red-500">{error}</p>}
{error && <p className="text-[10px] text-red-500 truncate shrink-0">{error}</p>}
</div>
)}
</div>

View file

@ -393,6 +393,24 @@ export function DotsVerticalIcon(props: React.SVGProps<SVGSVGElement>) {
);
}
export function DotsHorizontalIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 12"
fill="currentColor"
className={props.className}
width={props.width || "1.5em"}
height={props.height || "0.75em"}
{...props}
>
<circle cx="4" cy="6" r="1.5" />
<circle cx="12" cy="6" r="1.5" />
<circle cx="20" cy="6" r="1.5" />
</svg>
);
}
export function ChevronLeftIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg

View file

@ -118,9 +118,9 @@ function formatLocatorGroupLabel(locator: TTSSegmentLocator | null): string {
}
if (isHtmlLocator(locator)) {
if (/^\d+$/.test(locator.location)) {
return `Block ${locator.location} · HTML`;
return `Block ${locator.location} · Text`;
}
return `${locator.location} · HTML`;
return `${locator.location} · Text`;
}
return 'Unknown location';
}

View file

@ -11,20 +11,11 @@ import { getAllEpubDocuments, getAllHtmlDocuments, getAllPdfDocuments, getAppCon
import { listDocuments } from '@/lib/client/api/documents';
import { postChangelogVersionCheck } from '@/lib/client/api/user-state';
import { scheduleChangelogCheck } from '@/lib/client/changelog-check';
import { createCoalescedAsyncRunner, resolveNextOnboardingStep } from '@/lib/client/onboarding-flow';
import { ONBOARDING_STATE_REGISTRY } from '@/lib/shared/onboarding-state';
type SettingsOpenOptions = {
changelog?: boolean;
};
type SettingsController = {
open: (options?: SettingsOpenOptions) => void;
close: () => void;
};
type OnboardingFlowContextValue = {
requestOpenSettings: (options?: SettingsOpenOptions) => Promise<boolean>;
registerSettingsController: (controller: SettingsController | null) => void;
changelogOpenSignal: number;
};
const OnboardingFlowContext = createContext<OnboardingFlowContextValue | null>(null);
@ -61,9 +52,9 @@ async function fetchClaimableCounts(): Promise<ClaimableCounts> {
return toClaimableCounts(data);
}
async function getMigrationPromptState(): Promise<MigrationPromptState> {
async function getMigrationPromptState(privacyGateSatisfied: boolean): Promise<MigrationPromptState> {
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<SettingsController | null>(null);
const pendingChangelogOpenRef = useRef(false);
const runningAdvanceRef = useRef(false);
const claimDismissedUsersRef = useRef<Set<string>>(new Set());
const changelogVersionCheckKeyRef = useRef<string | null>(null);
const changelogVersionCheckInFlightRef = useRef<string | null>(null);
const openSettingsNow = useCallback((options?: SettingsOpenOptions) => {
settingsControllerRef.current?.open(options);
}, []);
const runOnceFlowRef = useRef<() => Promise<void>>(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<boolean> => {
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<OnboardingFlowContextValue>(() => ({
requestOpenSettings,
registerSettingsController,
}), [registerSettingsController, requestOpenSettings]);
changelogOpenSignal,
}), [changelogOpenSignal]);
return (
<OnboardingFlowContext.Provider value={contextValue}>

View file

@ -8,6 +8,7 @@ import { documentPreviewFallbackUrl, documentPreviewPresignUrl } from '@/lib/cli
const inMemoryPreviewUrlCache = new Map<string, string>();
const inFlightPreviewPrime = new Map<string, Promise<string | null>>();
const inFlightPersistedPreviewUrl = new Map<string, Promise<string | null>>();
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<string | null> {
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<string | null> => {
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(

View file

@ -825,6 +825,31 @@ export async function clearHtmlDocuments(): Promise<void> {
});
}
export async function getDocumentRecentlyOpenedMap(): Promise<Record<string, number>> {
return withDB(async () => {
const byId: Record<string, number> = {};
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<AppConfigRow | null> {
return withDB(async () => {
const row = await db[APP_CONFIG_TABLE].get('singleton');

View file

@ -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<void>): () => Promise<void> {
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;
}
};
}

View file

@ -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;
}

View file

@ -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:<folderId>`.
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 {

View file

@ -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' });

View file

@ -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 });
});
});

View file

@ -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);
});
});

View file

@ -111,6 +111,7 @@ export async function uploadAndDisplay(page: Page, fileName: string) {
async function dismissOnboardingModals(page: Page): Promise<void> {
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<void> {
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

View file

@ -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);
});
});

View file

@ -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',
});
});
});

View file

@ -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');
});
});

View file

@ -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);
});
});

View file

@ -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');