Merge pull request #91 from richardr1126/redesign/app-view-documents-list
This commit is contained in:
commit
e262f93f90
58 changed files with 3563 additions and 1081 deletions
7
.dockerignore
Normal file
7
.dockerignore
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
.env
|
||||
.env.*
|
||||
README.md
|
||||
.next
|
||||
node_modules
|
||||
**/node_modules
|
||||
docstore
|
||||
1
.github/workflows/playwright.yml
vendored
1
.github/workflows/playwright.yml
vendored
|
|
@ -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
5
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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) => ({
|
||||
|
|
|
|||
25
compute/worker/src/pdf-progress.ts
Normal file
25
compute/worker/src/pdf-progress.ts
Normal 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',
|
||||
};
|
||||
}
|
||||
|
|
@ -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',
|
||||
});
|
||||
}));
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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={{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
60
src/components/doclist/dnd/DocumentDndProvider.tsx
Normal file
60
src/components/doclist/dnd/DocumentDndProvider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
123
src/components/doclist/dnd/DocumentSelectionContext.tsx
Normal file
123
src/components/doclist/dnd/DocumentSelectionContext.tsx
Normal 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;
|
||||
}
|
||||
22
src/components/doclist/dnd/dndTypes.ts
Normal file
22
src/components/doclist/dnd/dndTypes.ts
Normal 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' };
|
||||
12
src/components/doclist/formatSize.ts
Normal file
12
src/components/doclist/formatSize.ts
Normal 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`;
|
||||
}
|
||||
211
src/components/doclist/views/DocumentTile.tsx
Normal file
211
src/components/doclist/views/DocumentTile.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
257
src/components/doclist/views/GalleryView.tsx
Normal file
257
src/components/doclist/views/GalleryView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
src/components/doclist/views/IconsView.tsx
Normal file
75
src/components/doclist/views/IconsView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
220
src/components/doclist/views/ListView.tsx
Normal file
220
src/components/doclist/views/ListView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
src/components/doclist/views/iconsGrid.ts
Normal file
43
src/components/doclist/views/iconsGrid.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
374
src/components/doclist/window/FinderSidebar.tsx
Normal file
374
src/components/doclist/window/FinderSidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
src/components/doclist/window/FinderStatusBar.tsx
Normal file
34
src/components/doclist/window/FinderStatusBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
226
src/components/doclist/window/FinderToolbar.tsx
Normal file
226
src/components/doclist/window/FinderToolbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
102
src/components/doclist/window/FinderWindow.tsx
Normal file
102
src/components/doclist/window/FinderWindow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
102
src/components/doclist/window/finderIcons.tsx
Normal file
102
src/components/doclist/window/finderIcons.tsx
Normal 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>
|
||||
);
|
||||
|
|
@ -8,7 +8,7 @@ export function DocumentSkeleton() {
|
|||
icon: '⚠️',
|
||||
duration: 5000,
|
||||
});
|
||||
}, 3000);
|
||||
}, 10000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
61
src/lib/client/cache/previews.ts
vendored
61
src/lib/client/cache/previews.ts
vendored
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
52
src/lib/client/onboarding-flow.ts
Normal file
52
src/lib/client/onboarding-flow.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
}
|
||||
9
src/lib/server/compute/abort-like-error.ts
Normal file
9
src/lib/server/compute/abort-like-error.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
18
tests/unit/compute-abort-like-error.spec.ts
Normal file
18
tests/unit/compute-abort-like-error.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
32
tests/unit/compute-worker-pdf-progress.spec.ts
Normal file
32
tests/unit/compute-worker-pdf-progress.spec.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
24
tests/unit/icons-grid.spec.ts
Normal file
24
tests/unit/icons-grid.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
107
tests/unit/onboarding-flow.spec.ts
Normal file
107
tests/unit/onboarding-flow.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in a new issue