From 148ce166cdc61c630d4a9863477f43ca05570ee2 Mon Sep 17 00:00:00 2001 From: zmeyer44 Date: Wed, 19 Aug 2026 15:29:20 -0400 Subject: [PATCH 1/5] Allow viewing shared documents on share and tracked link pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recipients of tracked links (including View Only) and share links could only see the file name and size — there was no way to open the document. View-only links had no server path to the file at all since getDownloadUrl rejects access !== "download". - Add public getPreviewUrl procedures to the trackedLinks and shares routers: same validation as getDownloadUrl, but allowed for view-only access and not counted against download limits - Add SharedFilePreview component reusing the in-app PreviewArea - Make file cards and folder file rows clickable on /t/[token] and /shared/[token], opening an inline preview with Back and (for download links) Download actions - Make onDownload optional in PreviewArea/UnsupportedPreview so view-only links don't offer a download button Co-Authored-By: Claude Fable 5 --- apps/web/app/shared/[token]/page.tsx | 102 +++++++++++++--- apps/web/app/t/[token]/page.tsx | 109 +++++++++++++++--- .../file-viewer/components/preview-area.tsx | 2 +- .../components/unsupported-preview.tsx | 12 +- .../features/files/shared-preview/index.tsx | 76 ++++++++++++ apps/web/server/trpc/routers/shares.ts | 73 ++++++++++++ apps/web/server/trpc/routers/tracked-links.ts | 84 ++++++++++++++ 7 files changed, 424 insertions(+), 34 deletions(-) create mode 100644 apps/web/features/files/shared-preview/index.tsx diff --git a/apps/web/app/shared/[token]/page.tsx b/apps/web/app/shared/[token]/page.tsx index 00d2448..d40e3f3 100644 --- a/apps/web/app/shared/[token]/page.tsx +++ b/apps/web/app/shared/[token]/page.tsx @@ -7,10 +7,12 @@ import { AlertCircle, Folder, ChevronRight, + ArrowLeft, } from "lucide-react"; import { Logo } from "@/assets/logo"; import { trpc } from "@/lib/trpc/client"; -import { formatBytes } from "@/lib/utils"; +import { cn, formatBytes } from "@/lib/utils"; +import { SharedFilePreview } from "@/features/files/shared-preview"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { FileIcon } from "@/components/file-icon"; @@ -25,6 +27,12 @@ export default function SharedPage({ const [password, setPassword] = useState(""); const [enteredPassword, setEnteredPassword] = useState(); const [currentFolderId, setCurrentFolderId] = useState(null); + const [previewFile, setPreviewFile] = useState<{ + fileId?: string; + name: string; + mimeType: string; + size: number; + } | null>(null); const { data, isLoading } = trpc.shares.access.useQuery({ token, @@ -54,6 +62,7 @@ export default function SharedPage({ ); const getDownloadUrl = trpc.shares.getDownloadUrl.useMutation(); + const getPreviewUrl = trpc.shares.getPreviewUrl.useMutation(); const handleDownload = async (fileId?: string) => { try { @@ -172,7 +181,12 @@ export default function SharedPage({ return (
-
+
Locker @@ -181,9 +195,56 @@ export default function SharedPage({
- {item.type === "file" ? ( + {previewFile ? (
-
+
+ +

+ {previewFile.name} +

+ +
+ + + getPreviewUrl + .mutateAsync({ + token, + fileId: previewFile.fileId, + password: enteredPassword, + }) + .then((r) => r.url) + } + onDownload={() => handleDownload(previewFile.fileId)} + /> +
+ ) : item.type === "file" ? ( +
+
-
+ + {formatBytes(file.size)} diff --git a/apps/web/app/t/[token]/page.tsx b/apps/web/app/t/[token]/page.tsx index a2d9ff1..176c3eb 100644 --- a/apps/web/app/t/[token]/page.tsx +++ b/apps/web/app/t/[token]/page.tsx @@ -8,10 +8,12 @@ import { Folder, Mail, ChevronRight, + ArrowLeft, } from "lucide-react"; import { Logo } from "@/assets/logo"; import { trpc } from "@/lib/trpc/client"; -import { formatBytes } from "@/lib/utils"; +import { cn, formatBytes } from "@/lib/utils"; +import { SharedFilePreview } from "@/features/files/shared-preview"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { FileIcon } from "@/components/file-icon"; @@ -39,6 +41,12 @@ export default function TrackedLinkPage({ const eventIdRef = useRef(null); const startTimeRef = useRef(Date.now()); const [currentFolderId, setCurrentFolderId] = useState(null); + const [previewFile, setPreviewFile] = useState<{ + fileId?: string; + name: string; + mimeType: string; + size: number; + } | null>(null); const { data, isLoading } = trpc.trackedLinks.access.useQuery({ token, @@ -57,6 +65,7 @@ export default function TrackedLinkPage({ ); const getDownloadUrl = trpc.trackedLinks.getDownloadUrl.useMutation(); + const getPreviewUrl = trpc.trackedLinks.getPreviewUrl.useMutation(); // Send tracking beacon when access succeeds const sendTrackingBeacon = useCallback(async () => { @@ -279,7 +288,12 @@ export default function TrackedLinkPage({ return (
-
+
Locker @@ -288,9 +302,63 @@ export default function TrackedLinkPage({
- {item.type === "file" ? ( + {previewFile ? ( +
+
+ +

+ {previewFile.name} +

+ {access === "download" && ( + + )} +
+ + + getPreviewUrl + .mutateAsync({ + token, + fileId: previewFile.fileId, + password: enteredPassword, + email: enteredEmail, + }) + .then((r) => r.url) + } + onDownload={ + access === "download" + ? () => handleDownload(previewFile.fileId) + : undefined + } + /> +
+ ) : item.type === "file" ? (
-
+
-
+ + {access === "download" && ( {formatBytes(file.size)} diff --git a/apps/web/features/files/file-viewer/components/preview-area.tsx b/apps/web/features/files/file-viewer/components/preview-area.tsx index cdaa9c3..a87e15c 100644 --- a/apps/web/features/files/file-viewer/components/preview-area.tsx +++ b/apps/web/features/files/file-viewer/components/preview-area.tsx @@ -24,7 +24,7 @@ export function PreviewArea({ textContent: string | null; file: { name: string; mimeType: string; size: number }; loading: boolean; - onDownload: () => void; + onDownload?: () => void; }) { if (loading) { return ( diff --git a/apps/web/features/files/file-viewer/components/unsupported-preview.tsx b/apps/web/features/files/file-viewer/components/unsupported-preview.tsx index 160d751..fef3f59 100644 --- a/apps/web/features/files/file-viewer/components/unsupported-preview.tsx +++ b/apps/web/features/files/file-viewer/components/unsupported-preview.tsx @@ -8,7 +8,7 @@ export function UnsupportedPreview({ onDownload, }: { file: { name: string; mimeType: string; size: number }; - onDownload: () => void; + onDownload?: () => void; }) { return (
@@ -28,10 +28,12 @@ export function UnsupportedPreview({ {formatBytes(file.size)}

- + {onDownload && ( + + )}
); } diff --git a/apps/web/features/files/shared-preview/index.tsx b/apps/web/features/files/shared-preview/index.tsx new file mode 100644 index 0000000..d56b273 --- /dev/null +++ b/apps/web/features/files/shared-preview/index.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { AlertCircle } from "lucide-react"; +import { PreviewArea } from "../file-viewer/components/preview-area"; +import { getViewerType } from "../utils"; + +/** + * Renders a document preview on public share/tracked-link pages. Fetches the + * signed URL via the provided callback so it works for both share links and + * tracked links; `onDownload` is omitted for view-only access. + */ +export function SharedFilePreview({ + file, + fetchUrl, + onDownload, +}: { + file: { name: string; mimeType: string; size: number }; + fetchUrl: () => Promise; + onDownload?: () => void; +}) { + const [previewUrl, setPreviewUrl] = useState(null); + const [textContent, setTextContent] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + setLoading(true); + + fetchUrl() + .then(async (url) => { + if (cancelled) return; + setPreviewUrl(url); + + const vt = getViewerType(file.mimeType, file.name); + if (vt === "text" || vt === "markdown" || vt === "csv" || vt === "html") { + const text = await fetch(url).then((r) => r.text()); + if (!cancelled) setTextContent(text); + } + if (!cancelled) setLoading(false); + }) + .catch((err) => { + if (!cancelled) { + setError((err as Error).message); + setLoading(false); + } + }); + + return () => { + cancelled = true; + }; + // Parent remounts this component (via key) when the file changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if (error) { + return ( +
+ +

{error}

+
+ ); + } + + return ( + + ); +} diff --git a/apps/web/server/trpc/routers/shares.ts b/apps/web/server/trpc/routers/shares.ts index 1d7956c..a620bab 100644 --- a/apps/web/server/trpc/routers/shares.ts +++ b/apps/web/server/trpc/routers/shares.ts @@ -387,6 +387,79 @@ export const sharesRouter = createRouter({ }; }), + // Public: signed URL for rendering a preview (does not count as a download) + getPreviewUrl: publicProcedure + .input( + z.object({ + token: z.string(), + fileId: z.string().uuid().optional(), + password: z.string().optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + const [link] = await ctx.db + .select() + .from(shareLinks) + .where(eq(shareLinks.token, input.token)); + + if (!link || !link.isActive) throw new Error("Link not found"); + if (link.expiresAt && new Date(link.expiresAt) < new Date()) { + throw new Error("Link expired"); + } + if ( + link.hasPassword && + !verifyLinkPassword(input.password, link.passwordHash) + ) { + throw new Error("Incorrect password"); + } + if (link.maxDownloads && link.downloadCount >= link.maxDownloads) { + throw new Error("Download limit reached"); + } + + let fileId: string; + if (link.fileId) { + if (input.fileId && input.fileId !== link.fileId) { + throw new Error("File not found"); + } + fileId = link.fileId; + } else if (link.folderId) { + if (!input.fileId) throw new Error("No file specified"); + fileId = input.fileId; + } else { + throw new Error("Link target not found"); + } + + const [targetFile] = await ctx.db + .select() + .from(files) + .where( + and( + eq(files.id, fileId), + eq(files.workspaceId, link.workspaceId), + eq(files.status, "ready"), + ), + ); + if (!targetFile) throw new Error("File not found"); + + // For folder shares, verify the file lives inside the shared folder tree + if (link.folderId) { + if (!targetFile.folderId) throw new Error("File not found"); + const allowed = await isDescendantFolder( + ctx.db, + targetFile.folderId, + link.folderId, + ); + if (!allowed) throw new Error("File not found"); + } + + const storage = await createStorageForFile(targetFile.id); + const url = await storage.getSignedUrl( + await getFileStoragePath(targetFile.id), + 3600, + ); + return { url, filename: targetFile.name }; + }), + getDownloadUrl: publicProcedure .input( z.object({ diff --git a/apps/web/server/trpc/routers/tracked-links.ts b/apps/web/server/trpc/routers/tracked-links.ts index ee4c694..1141c7b 100644 --- a/apps/web/server/trpc/routers/tracked-links.ts +++ b/apps/web/server/trpc/routers/tracked-links.ts @@ -610,6 +610,90 @@ export const trackedLinksRouter = createRouter({ }; }), + // Public: signed URL for rendering a preview (allowed for both "view" and + // "download" access; does not count as a download) + getPreviewUrl: publicProcedure + .input( + z.object({ + token: z.string(), + fileId: z.string().uuid().optional(), + password: z.string().optional(), + email: z.string().email().optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + const [link] = await ctx.db + .select() + .from(trackedLinks) + .where(eq(trackedLinks.token, input.token)); + + if (!link || !link.isActive) throw new Error("Link not found"); + if (link.expiresAt && new Date(link.expiresAt) < new Date()) { + throw new Error("Link expired"); + } + if (link.validFrom && new Date(link.validFrom) > new Date()) { + throw new Error("Link is not yet active"); + } + if (link.validUntil && new Date(link.validUntil) < new Date()) { + throw new Error("Link is no longer active"); + } + if (link.maxViews && link.viewCount >= link.maxViews) { + throw new Error("View limit reached"); + } + if (link.requireEmail && !input.email) { + throw new Error("Email required"); + } + if ( + link.hasPassword && + !verifyLinkPassword(input.password, link.passwordHash) + ) { + throw new Error("Incorrect password"); + } + + let fileId: string; + if (link.fileId) { + if (input.fileId && input.fileId !== link.fileId) { + throw new Error("File not found"); + } + fileId = link.fileId; + } else if (link.folderId) { + if (!input.fileId) throw new Error("No file specified"); + fileId = input.fileId; + } else { + throw new Error("Link target not found"); + } + + const [targetFile] = await ctx.db + .select() + .from(files) + .where( + and( + eq(files.id, fileId), + eq(files.workspaceId, link.workspaceId), + eq(files.status, "ready"), + ), + ); + if (!targetFile) throw new Error("File not found"); + + // For folder shares, verify the file lives inside the shared folder tree + if (link.folderId) { + if (!targetFile.folderId) throw new Error("File not found"); + const allowed = await isDescendantFolder( + ctx.db, + targetFile.folderId, + link.folderId, + ); + if (!allowed) throw new Error("File not found"); + } + + const storage = await createStorageForFile(targetFile.id); + const url = await storage.getSignedUrl( + await getFileStoragePath(targetFile.id), + 3600, + ); + return { url, filename: targetFile.name }; + }), + getDownloadUrl: publicProcedure .input( z.object({ From c22ed94349a001df284484d4ab3b10d1d43865fe Mon Sep 17 00:00:00 2001 From: zmeyer44 Date: Wed, 19 Aug 2026 15:41:22 -0400 Subject: [PATCH 2/5] Add Excel (xls/xlsx) file preview support Excel files previously fell through to the unsupported-preview state. Add a spreadsheet viewer using SheetJS (installed from the SheetJS CDN tarball, since the npm-registry xlsx package is stale and has known vulnerabilities), which handles both legacy .xls and modern .xlsx. The viewer converts the active sheet to CSV and reuses the existing CsvPreview table (search, sort, pagination), with a tab bar for multi-sheet workbooks. The library is dynamically imported client-side only, matching the docx/pdf viewer pattern. Works in the in-app file viewer and on share/tracked link pages. Co-Authored-By: Claude Fable 5 --- apps/web/components/xlsx-viewer.tsx | 86 +++++++++++++++++++ .../file-viewer/components/preview-area.tsx | 3 + .../file-viewer/components/xlsx-preview.tsx | 16 ++++ apps/web/features/files/file-viewer/types.ts | 1 + apps/web/features/files/utils/index.tsx | 8 ++ apps/web/package.json | 1 + pnpm-lock.yaml | 16 +++- 7 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 apps/web/components/xlsx-viewer.tsx create mode 100644 apps/web/features/files/file-viewer/components/xlsx-preview.tsx diff --git a/apps/web/components/xlsx-viewer.tsx b/apps/web/components/xlsx-viewer.tsx new file mode 100644 index 0000000..42b391d --- /dev/null +++ b/apps/web/components/xlsx-viewer.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { read, utils, type WorkBook } from "xlsx"; +import { Loader2, AlertCircle } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { CsvPreview } from "@/features/files/file-viewer/components/csv-preview"; + +export function XlsxViewer({ url, name }: { url: string; name: string }) { + const [workbook, setWorkbook] = useState(null); + const [error, setError] = useState(null); + const [activeSheet, setActiveSheet] = useState(0); + + useEffect(() => { + let cancelled = false; + setWorkbook(null); + setError(null); + setActiveSheet(0); + + fetch(url) + .then((r) => { + if (!r.ok) throw new Error(`Failed to load file (${r.status})`); + return r.arrayBuffer(); + }) + .then((buf) => { + if (!cancelled) setWorkbook(read(buf)); + }) + .catch((err) => { + if (!cancelled) setError((err as Error).message); + }); + + return () => { + cancelled = true; + }; + }, [url]); + + const csv = useMemo(() => { + if (!workbook) return null; + const sheetName = workbook.SheetNames[activeSheet]; + const sheet = sheetName ? workbook.Sheets[sheetName] : undefined; + return sheet ? utils.sheet_to_csv(sheet) : ""; + }, [workbook, activeSheet]); + + if (error) { + return ( +
+ +

{error}

+
+ ); + } + + if (!workbook) { + return ( +
+ +
+ ); + } + + return ( +
+ {workbook.SheetNames.length > 1 && ( +
+ {workbook.SheetNames.map((sheetName, i) => ( + + ))} +
+ )} +
+ +
+
+ ); +} diff --git a/apps/web/features/files/file-viewer/components/preview-area.tsx b/apps/web/features/files/file-viewer/components/preview-area.tsx index a87e15c..7254ee3 100644 --- a/apps/web/features/files/file-viewer/components/preview-area.tsx +++ b/apps/web/features/files/file-viewer/components/preview-area.tsx @@ -5,6 +5,7 @@ import { VideoPreview } from "./video-preview"; import { AudioPreview } from "./audio-preview"; import { PdfPreview } from "./pdf-preview"; import { DocxPreview } from "./docx-preview"; +import { XlsxPreview } from "./xlsx-preview"; import { MarkdownPreview } from "./markdown-preview"; import { TextPreview } from "./text-preview"; import { CsvPreview } from "./csv-preview"; @@ -45,6 +46,8 @@ export function PreviewArea({ return ; case "docx": return ; + case "xlsx": + return ; case "markdown": return ; case "csv": diff --git a/apps/web/features/files/file-viewer/components/xlsx-preview.tsx b/apps/web/features/files/file-viewer/components/xlsx-preview.tsx new file mode 100644 index 0000000..d9adcd1 --- /dev/null +++ b/apps/web/features/files/file-viewer/components/xlsx-preview.tsx @@ -0,0 +1,16 @@ +import dynamic from "next/dynamic"; + +const XlsxViewer = dynamic( + () => + import("@/components/xlsx-viewer").then((m) => ({ default: m.XlsxViewer })), + { ssr: false }, +); + +export function XlsxPreview({ url, name }: { url: string | null; name: string }) { + if (!url) return null; + return ( +
+ +
+ ); +} diff --git a/apps/web/features/files/file-viewer/types.ts b/apps/web/features/files/file-viewer/types.ts index f375d52..125b3ce 100644 --- a/apps/web/features/files/file-viewer/types.ts +++ b/apps/web/features/files/file-viewer/types.ts @@ -4,6 +4,7 @@ export type ViewerType = | "audio" | "pdf" | "docx" + | "xlsx" | "markdown" | "csv" | "html" diff --git a/apps/web/features/files/utils/index.tsx b/apps/web/features/files/utils/index.tsx index 5348d63..62502da 100644 --- a/apps/web/features/files/utils/index.tsx +++ b/apps/web/features/files/utils/index.tsx @@ -62,6 +62,14 @@ export function getViewerType(mimeType: string, name: string): ViewerType { "application/vnd.openxmlformats-officedocument.wordprocessingml.document" ) return "docx"; + if ( + ext === "xlsx" || + ext === "xls" || + mimeType === + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" || + mimeType === "application/vnd.ms-excel" + ) + return "xlsx"; if (ext === "md" || ext === "mdx" || mimeType === "text/markdown") return "markdown"; if (ext === "csv" || mimeType === "text/csv") return "csv"; diff --git a/apps/web/package.json b/apps/web/package.json index cb3ea85..3a2b07f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -61,6 +61,7 @@ "superjson": "^2.2.6", "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.4.0", + "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", "zod": "^4.3.6" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ac42cdb..8230db3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -210,6 +210,9 @@ importers: tw-animate-css: specifier: ^1.4.0 version: 1.4.0 + xlsx: + specifier: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz + version: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz zod: specifier: ^4.3.6 version: 4.3.6 @@ -858,11 +861,11 @@ packages: '@esbuild-kit/core-utils@3.3.2': resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} - deprecated: 'Merged into tsx: https://tsx.is' + deprecated: 'Merged into tsx: https://tsx.hirok.io' '@esbuild-kit/esm-loader@2.6.5': resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} - deprecated: 'Merged into tsx: https://tsx.is' + deprecated: 'Merged into tsx: https://tsx.hirok.io' '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} @@ -3556,6 +3559,7 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@use-gesture/core@10.3.1': resolution: {integrity: sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==} @@ -7375,6 +7379,12 @@ packages: resolution: {integrity: sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==} engines: {node: '>=12'} + xlsx@https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz: + resolution: {tarball: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz} + version: 0.20.3 + engines: {node: '>=0.8'} + hasBin: true + xml2js@0.6.2: resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==} engines: {node: '>=4.0.0'} @@ -15149,6 +15159,8 @@ snapshots: xdg-basedir@5.1.0: {} + xlsx@https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz: {} + xml2js@0.6.2: dependencies: sax: 1.6.0 From fc7efbc2665e40926cdf1c48bc0445d2a5460f61 Mon Sep 17 00:00:00 2001 From: zmeyer44 Date: Wed, 19 Aug 2026 15:45:43 -0400 Subject: [PATCH 3/5] Fix PDF viewer initial zoom for landscape documents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Landscape PDFs overflowed horizontally on initial load. Two bugs: - The container-width ResizeObserver attached in a mount-only effect, but on mount the loading skeleton is rendered and the scroll container doesn't exist yet, so containerWidth stayed 0 and PDFPage skipped its fit-to-width clamp entirely, rendering wide pages at natural size. The effect now re-attaches when loading completes. - With the width actually measured, the auto-scale-on-load effect and the fit-width/fit-page handlers double-applied the fit factor (scale was set to fitScale and PDFPage multiplied by min(fitScale, 1) again, yielding fitScale^2). Removed the redundant auto-scale effect — the per-page clamp already makes scale 1 fit the full width — and divided the clamp back out in the fit handlers so they hit exact targets. Co-Authored-By: Claude Fable 5 --- apps/web/components/pdf-viewer.tsx | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/apps/web/components/pdf-viewer.tsx b/apps/web/components/pdf-viewer.tsx index ec669ff..bb42e8b 100644 --- a/apps/web/components/pdf-viewer.tsx +++ b/apps/web/components/pdf-viewer.tsx @@ -525,6 +525,8 @@ export function PDFViewer({ }, [url, onLoadSuccess, onLoadError]); /* ---- Observe container width ---- */ + // Re-run when loading finishes: the scroll container only exists once the + // loading skeleton is replaced, so a mount-only effect would never attach. useEffect(() => { const container = scrollContainerRef.current; if (!container) return; @@ -536,7 +538,7 @@ export function PDFViewer({ }); observer.observe(container); return () => observer.disconnect(); - }, []); + }, [isLoading]); /* ---- Observe visible pages ---- */ useEffect(() => { @@ -612,17 +614,11 @@ export function PDFViewer({ [pages.length, scrollToPage, onPageChangeProp], ); - /* ---- Auto-scale on load ---- */ - useEffect(() => { - if (pages.length === 0 || !containerWidth) return; - const firstPage = pages[0]; - if (!firstPage) return; - const viewport = firstPage.getViewport({ scale: 1 }); - const fitScale = (containerWidth - 48) / viewport.width; - if (fitScale < 1) setScale(fitScale); - }, [pages, containerWidth]); - /* ---- Zoom handlers ---- */ + // PDFPage renders at scale * min(pageFitScale, 1), so pages wider than the + // container (e.g. landscape) already fit the full width at scale 1 — no + // auto-scale on load needed, and the fit handlers below must divide that + // clamp back out to hit an exact target scale. const handleScaleChange = useCallback((s: number) => { setScale(Math.max(MIN_SCALE, Math.min(MAX_SCALE, s))); }, []); @@ -632,7 +628,8 @@ export function PDFViewer({ const firstPage = pages[0]; if (!firstPage) return; const vp = firstPage.getViewport({ scale: 1 }); - setScale((containerWidth - 48) / vp.width); + const fitScale = (containerWidth - 48) / vp.width; + setScale(Math.max(fitScale, 1)); }, [pages, containerWidth]); const handleFitPage = useCallback(() => { @@ -645,7 +642,7 @@ export function PDFViewer({ const containerHeight = container.clientHeight - PAGE_GAP * 2; const widthScale = (containerWidth - 48) / vp.width; const heightScale = containerHeight / vp.height; - setScale(Math.min(widthScale, heightScale)); + setScale(Math.min(widthScale, heightScale) / Math.min(widthScale, 1)); }, [pages, containerWidth]); /* ---- Keyboard navigation ---- */ From 2b2938fbcb85ac3bb05b0247fdeec1c361f6de88 Mon Sep 17 00:00:00 2001 From: zmeyer44 Date: Wed, 19 Aug 2026 15:51:09 -0400 Subject: [PATCH 4/5] Fix layout shift while shared document preview loads The share-page card is vertically centered and the preview area had no fixed height, so the page jumped as the loader was swapped for the rendered document. Give SharedFilePreview a fixed-height container and force the preview roots to fill it (same [&>div]:h-full pattern the in-app viewer uses), so the card height is stable from first paint. Co-Authored-By: Claude Fable 5 --- .../features/files/shared-preview/index.tsx | 37 ++++++++++--------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/apps/web/features/files/shared-preview/index.tsx b/apps/web/features/files/shared-preview/index.tsx index d56b273..4674caf 100644 --- a/apps/web/features/files/shared-preview/index.tsx +++ b/apps/web/features/files/shared-preview/index.tsx @@ -54,23 +54,26 @@ export function SharedFilePreview({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - if (error) { - return ( -
- -

{error}

-
- ); - } - + // Fixed height so the page doesn't jump as the loader is swapped for the + // rendered document; [&>div]:h-full gives the preview roots a height + // context, matching the in-app viewer's layout. return ( - +
+ {error ? ( +
+ +

{error}

+
+ ) : ( + + )} +
); } From 661193ad2247656d699750e40b9dad1f9e5bcc87 Mon Sep 17 00:00:00 2001 From: zmeyer44 Date: Fri, 21 Aug 2026 20:17:28 -0400 Subject: [PATCH 5/5] better duration format --- .../(dashboard)/w/[slug]/tracked-links/[id]/page.tsx | 4 ++-- apps/web/lib/utils.ts | 11 +++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/apps/web/app/(dashboard)/w/[slug]/tracked-links/[id]/page.tsx b/apps/web/app/(dashboard)/w/[slug]/tracked-links/[id]/page.tsx index eb047f7..ff3d932 100644 --- a/apps/web/app/(dashboard)/w/[slug]/tracked-links/[id]/page.tsx +++ b/apps/web/app/(dashboard)/w/[slug]/tracked-links/[id]/page.tsx @@ -23,7 +23,7 @@ import { Mail, } from 'lucide-react'; import { trpc } from '@/lib/trpc/client'; -import { formatDate, getRelativeTime } from '@/lib/utils'; +import { formatDate, formatDuration, getRelativeTime } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { Separator } from '@/components/ui/separator'; import { FileIcon } from '@/components/file-icon'; @@ -314,7 +314,7 @@ export default function TrackedLinkDetailPage({ label="Avg. Duration" value={ analytics?.avgDurationSeconds != null - ? `${analytics.avgDurationSeconds}s` + ? formatDuration(analytics.avgDurationSeconds) : '--' } sub={ diff --git a/apps/web/lib/utils.ts b/apps/web/lib/utils.ts index cb19d23..66f39bd 100644 --- a/apps/web/lib/utils.ts +++ b/apps/web/lib/utils.ts @@ -13,6 +13,17 @@ export function formatBytes(bytes: number): string { return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; } +export function formatDuration(seconds: number): string { + if (!Number.isFinite(seconds) || seconds < 1) return "<1s"; + const total = Math.round(seconds); + const hours = Math.floor(total / 3600); + const minutes = Math.floor((total % 3600) / 60); + const secs = total % 60; + if (hours > 0) return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`; + if (minutes > 0) return secs > 0 ? `${minutes}m ${secs}s` : `${minutes}m`; + return `${secs}s`; +} + export function formatDate(date: Date | string): string { return new Date(date).toLocaleDateString("en-US", { month: "short",