From 90aa685beeaeb1378e77aab1995f339af5ba60ea Mon Sep 17 00:00:00 2001 From: Harshith Mullapudi Date: Thu, 27 Aug 2026 21:02:11 -0700 Subject: [PATCH 1/9] feat(pages): default new pages to org, add cached thumbnails, owner, delete Publishing defaults to `org` so a page shared from the CLI or an agent is readable by the organization instead of silently invisible to everyone but its publisher. Existing pages are untouched: `updatePage` only patches visibility when the input supplies one, and there is no backfill. Thumbnails are captured once per page version into a disk cache in the main process and served over a new `superset-thumb://` scheme, replacing the grid's live per-card iframes. Cards now also show the owner when the viewer is not the owner, and expose Delete in the overflow menu. Also folds in the in-flight share/comment fixes on this branch: the page surface now reads its session from the desktop bearer-token auth client rather than the cookie-based web one, visibility changes apply optimistically and copy the link, and the comment overlay stops re-measuring on every frame. Claude-Session: https://claude.ai/code/session_015YytmsAiiZcTVrLwFSVpMF --- apps/desktop/src/lib/trpc/routers/index.ts | 2 + .../lib/trpc/routers/page-thumbnail/index.ts | 1 + .../routers/page-thumbnail/page-thumbnail.ts | 21 ++ apps/desktop/src/main/index.ts | 18 ++ .../src/main/lib/pageThumbnails/index.ts | 7 + .../main/lib/pageThumbnails/pageThumbnails.ts | 299 ++++++++++++++++++ .../components/PageViewer/PageViewer.tsx | 2 +- .../usePageHeaderData/usePageHeaderData.ts | 12 +- .../pages/components/PagesGrid/PagesGrid.tsx | 6 + .../components/PageCard/PageCard.tsx | 53 +++- .../PageThumbnail/PageThumbnail.tsx | 100 +++--- .../utils/pageThumbnailCache/index.ts | 7 - .../pageThumbnailCache.test.ts | 129 -------- .../pageThumbnailCache/pageThumbnailCache.ts | 84 ----- .../pages/components/PagesView/PagesView.tsx | 46 ++- packages/mcp/src/tools/pages/publish.ts | 2 +- packages/trpc/src/router/page/page.ts | 3 + .../src/router/page/publish.integration.ts | 3 +- packages/trpc/src/router/page/publish.ts | 2 +- .../PageCommentsView/PageCommentsView.tsx | 21 +- .../components/PageFrame/PageFrame.tsx | 2 +- .../components/PageHeader/PageHeader.tsx | 5 + .../PageSharePopover/PageSharePopover.tsx | 34 +- .../CommentProvider/CommentProvider.tsx | 23 +- .../utils/commentRuntime/commentRuntime.ts | 19 +- plugins/superset/skills/page/SKILL.md | 54 +++- 26 files changed, 635 insertions(+), 320 deletions(-) create mode 100644 apps/desktop/src/lib/trpc/routers/page-thumbnail/index.ts create mode 100644 apps/desktop/src/lib/trpc/routers/page-thumbnail/page-thumbnail.ts create mode 100644 apps/desktop/src/main/lib/pageThumbnails/index.ts create mode 100644 apps/desktop/src/main/lib/pageThumbnails/pageThumbnails.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesGrid/components/PageCard/components/PageThumbnail/utils/pageThumbnailCache/index.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesGrid/components/PageCard/components/PageThumbnail/utils/pageThumbnailCache/pageThumbnailCache.test.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesGrid/components/PageCard/components/PageThumbnail/utils/pageThumbnailCache/pageThumbnailCache.ts diff --git a/apps/desktop/src/lib/trpc/routers/index.ts b/apps/desktop/src/lib/trpc/routers/index.ts index ee3ac6858d7..37ffd933560 100644 --- a/apps/desktop/src/lib/trpc/routers/index.ts +++ b/apps/desktop/src/lib/trpc/routers/index.ts @@ -19,6 +19,7 @@ import { createMenuRouter } from "./menu"; import { createMigrationRouter } from "./migration"; import { createNotificationsRouter } from "./notifications"; import { createPageContentRouter } from "./page-content"; +import { createPageThumbnailRouter } from "./page-thumbnail"; import { createPermissionsRouter } from "./permissions"; import { createPluginsRouter } from "./plugins"; import { createPortsRouter } from "./ports"; @@ -51,6 +52,7 @@ export const createAppRouter = (getWindow: () => BrowserWindow | null) => { filesystem: createFilesystemRouter(), notifications: createNotificationsRouter(getWindow), pageContent: createPageContentRouter(), + pageThumbnail: createPageThumbnailRouter(), permissions: createPermissionsRouter(), plugins: createPluginsRouter(), ports: createPortsRouter(), diff --git a/apps/desktop/src/lib/trpc/routers/page-thumbnail/index.ts b/apps/desktop/src/lib/trpc/routers/page-thumbnail/index.ts new file mode 100644 index 00000000000..31bb6ca8f70 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/page-thumbnail/index.ts @@ -0,0 +1 @@ +export { createPageThumbnailRouter } from "./page-thumbnail"; diff --git a/apps/desktop/src/lib/trpc/routers/page-thumbnail/page-thumbnail.ts b/apps/desktop/src/lib/trpc/routers/page-thumbnail/page-thumbnail.ts new file mode 100644 index 00000000000..f85deb7df0a --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/page-thumbnail/page-thumbnail.ts @@ -0,0 +1,21 @@ +import { ensureThumbnail, peekThumbnail } from "main/lib/pageThumbnails"; +import { z } from "zod"; +import { publicProcedure, router } from "../.."; + +export const createPageThumbnailRouter = () => { + return router({ + peek: publicProcedure + .input(z.object({ pageId: z.string(), version: z.string() })) + .query(({ input }) => peekThumbnail(input.pageId, input.version)), + + ensure: publicProcedure + .input( + z.object({ + pageId: z.string(), + version: z.string(), + html: z.string(), + }), + ) + .mutation(({ input }) => ensureThumbnail(input)), + }); +}; diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 3139fa92a29..30554647326 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -36,6 +36,10 @@ import { localDb } from "./lib/local-db"; import { requestLocalNetworkAccess } from "./lib/local-network-permission"; import { menuEmitter } from "./lib/menu-events"; import { PAGE_SCHEME, pageProtocolHandler } from "./lib/pageContent"; +import { + THUMBNAIL_SCHEME, + thumbnailProtocolHandler, +} from "./lib/pageThumbnails"; import { initTanstackDbPersistence, shutdownTanstackDbPersistence, @@ -350,6 +354,15 @@ protocol.registerSchemesAsPrivileged([ secure: true, }, }, + { + scheme: THUMBNAIL_SCHEME, + privileges: { + standard: true, + secure: true, + bypassCSP: true, + supportFetchAPI: true, + }, + }, ]); const gotTheLock = app.requestSingleInstanceLock(); @@ -413,6 +426,11 @@ if (!gotTheLock) { .fromPartition("persist:superset") .protocol.handle(PAGE_SCHEME, pageProtocolHandler); + protocol.handle(THUMBNAIL_SCHEME, thumbnailProtocolHandler); + session + .fromPartition("persist:superset") + .protocol.handle(THUMBNAIL_SCHEME, thumbnailProtocolHandler); + // Serve system fonts (e.g. SF Mono on macOS) via custom protocol // so the renderer can use @font-face with font-src 'self' CSP if (process.platform === "darwin") { diff --git a/apps/desktop/src/main/lib/pageThumbnails/index.ts b/apps/desktop/src/main/lib/pageThumbnails/index.ts new file mode 100644 index 00000000000..fb998dda922 --- /dev/null +++ b/apps/desktop/src/main/lib/pageThumbnails/index.ts @@ -0,0 +1,7 @@ +export { + ensureThumbnail, + peekThumbnail, + THUMBNAIL_SCHEME, + thumbnailProtocolHandler, + thumbnailUrl, +} from "./pageThumbnails"; diff --git a/apps/desktop/src/main/lib/pageThumbnails/pageThumbnails.ts b/apps/desktop/src/main/lib/pageThumbnails/pageThumbnails.ts new file mode 100644 index 00000000000..0e3d64ec85e --- /dev/null +++ b/apps/desktop/src/main/lib/pageThumbnails/pageThumbnails.ts @@ -0,0 +1,299 @@ +import { + mkdir, + readdir, + readFile, + stat, + unlink, + writeFile, +} from "node:fs/promises"; +import { join } from "node:path"; +import { app, BrowserWindow, session } from "electron"; +import { + PAGE_SCHEME, + pageProtocolHandler, + registerPageContent, + releasePageContent, +} from "../pageContent"; + +export const THUMBNAIL_SCHEME = "superset-thumb"; + +const THUMBNAIL_PARTITION = "page-thumbnails"; + +const FRAME_WIDTH = 1280; +const FRAME_HEIGHT = 880; +const THUMBNAIL_WIDTH = 640; +const THUMBNAIL_HEIGHT = 440; +const JPEG_QUALITY = 80; + +const MAX_CACHED_THUMBNAILS = 512; +const MAX_CONCURRENT_CAPTURES = 2; + +const CAPTURE_DEADLINE_MS = 15_000; +const CAPTURE_ATTEMPT_TIMEOUT_MS = 1_500; +const CAPTURE_RETRY_INTERVAL_MS = 100; +const LOAD_TIMEOUT_MS = 10_000; +const SETTLE_MS = 400; + +const PAGE_ID_PATTERN = /^[a-zA-Z0-9-]+$/; +const VERSION_PATTERN = /^\d+$/; + +function cacheDir(): string { + return join(app.getPath("userData"), "page-thumbnails"); +} + +function thumbnailPath(pageId: string, version: string): string { + return join(cacheDir(), `${pageId}-${version}.jpg`); +} + +export function thumbnailUrl(pageId: string, version: string): string { + return `${THUMBNAIL_SCHEME}://${pageId}/${version}`; +} + +function isValidKey(pageId: string, version: string): boolean { + return PAGE_ID_PATTERN.test(pageId) && VERSION_PATTERN.test(version); +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function withTimeout( + promise: Promise, + ms: number, + message: string, +): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error(message)), ms); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + +let activeCaptures = 0; +const captureQueue: Array<() => void> = []; + +async function acquireCaptureSlot(): Promise { + if (activeCaptures < MAX_CONCURRENT_CAPTURES) { + activeCaptures += 1; + return; + } + await new Promise((resolve) => captureQueue.push(resolve)); + activeCaptures += 1; +} + +function releaseCaptureSlot(): void { + activeCaptures -= 1; + const next = captureQueue.shift(); + if (next) next(); +} + +async function captureWithRetry( + window: BrowserWindow, +): Promise { + const deadline = Date.now() + CAPTURE_DEADLINE_MS; + let lastError: unknown = null; + do { + if (window.isDestroyed()) break; + try { + const image = await withTimeout( + window.webContents.capturePage(), + CAPTURE_ATTEMPT_TIMEOUT_MS, + "Thumbnail capture attempt timed out", + ); + if (!image.isEmpty()) return image; + lastError = new Error("Thumbnail capture produced an empty image"); + } catch (error) { + lastError = error; + } + await delay(CAPTURE_RETRY_INTERVAL_MS); + } while (Date.now() < deadline); + + throw lastError instanceof Error + ? lastError + : new Error("Thumbnail capture failed"); +} + +let partitionReady = false; + +function ensurePartitionProtocol(): void { + if (partitionReady) return; + partitionReady = true; + const partition = session.fromPartition(THUMBNAIL_PARTITION); + if (!partition.protocol.isProtocolHandled(PAGE_SCHEME)) { + partition.protocol.handle(PAGE_SCHEME, pageProtocolHandler); + } +} + +async function captureHtml(html: string): Promise { + ensurePartitionProtocol(); + const { token, url } = registerPageContent(html); + const window = new BrowserWindow({ + show: false, + paintWhenInitiallyHidden: true, + width: FRAME_WIDTH, + height: FRAME_HEIGHT, + webPreferences: { + partition: THUMBNAIL_PARTITION, + backgroundThrottling: false, + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + spellcheck: false, + }, + }); + + try { + window.webContents.setAudioMuted(true); + await Promise.race([ + window.loadURL(url).catch(() => undefined), + delay(LOAD_TIMEOUT_MS), + ]); + if (window.isDestroyed()) { + throw new Error("Thumbnail window closed before capture"); + } + await delay(SETTLE_MS); + const image = await captureWithRetry(window); + return image + .resize({ + width: THUMBNAIL_WIDTH, + height: THUMBNAIL_HEIGHT, + quality: "good", + }) + .toJPEG(JPEG_QUALITY); + } finally { + releasePageContent(token); + if (!window.isDestroyed()) window.destroy(); + } +} + +let pruning = false; + +async function pruneCache(): Promise { + if (pruning) return; + pruning = true; + try { + const dir = cacheDir(); + const names = await readdir(dir); + if (names.length <= MAX_CACHED_THUMBNAILS) return; + + const entries = await Promise.all( + names.map(async (name) => { + try { + const info = await stat(join(dir, name)); + return { name, mtimeMs: info.mtimeMs }; + } catch { + return null; + } + }), + ); + + const sorted = entries + .filter((entry): entry is { name: string; mtimeMs: number } => + Boolean(entry), + ) + .sort((a, b) => a.mtimeMs - b.mtimeMs); + + const excess = sorted.length - MAX_CACHED_THUMBNAILS; + await Promise.all( + sorted + .slice(0, excess) + .map((entry) => unlink(join(dir, entry.name)).catch(() => undefined)), + ); + } catch { + return; + } finally { + pruning = false; + } +} + +async function hasThumbnail(pageId: string, version: string): Promise { + try { + await stat(thumbnailPath(pageId, version)); + return true; + } catch { + return false; + } +} + +export async function peekThumbnail( + pageId: string, + version: string, +): Promise { + if (!isValidKey(pageId, version)) return null; + return (await hasThumbnail(pageId, version)) + ? thumbnailUrl(pageId, version) + : null; +} + +const inflight = new Map>(); + +export function ensureThumbnail({ + pageId, + version, + html, +}: { + pageId: string; + version: string; + html: string; +}): Promise { + if (!isValidKey(pageId, version)) { + return Promise.reject(new Error("Invalid thumbnail key")); + } + + const key = `${pageId}:${version}`; + const existing = inflight.get(key); + if (existing) return existing; + + const pending: Promise = (async () => { + if (await hasThumbnail(pageId, version)) { + return thumbnailUrl(pageId, version); + } + + await acquireCaptureSlot(); + try { + const jpeg = await captureHtml(html); + await mkdir(cacheDir(), { recursive: true }); + await writeFile(thumbnailPath(pageId, version), jpeg); + void pruneCache(); + return thumbnailUrl(pageId, version); + } finally { + releaseCaptureSlot(); + } + })().finally(() => { + if (inflight.get(key) === pending) inflight.delete(key); + }); + + inflight.set(key, pending); + return pending; +} + +export async function thumbnailProtocolHandler( + request: Request, +): Promise { + const url = new URL(request.url); + const pageId = url.hostname; + const version = url.pathname.replace(/^\//, ""); + + if (!isValidKey(pageId, version)) { + return new Response("Not found", { status: 404 }); + } + + try { + const bytes = await readFile(thumbnailPath(pageId, version)); + return new Response(new Uint8Array(bytes), { + status: 200, + headers: { + "Content-Type": "image/jpeg", + "Cache-Control": "public, max-age=31536000, immutable", + }, + }); + } catch { + return new Response("Not found", { status: 404 }); + } +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/PageViewer/PageViewer.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/PageViewer/PageViewer.tsx index 747dd1684e9..f29f2cf1a59 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/PageViewer/PageViewer.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/PageViewer/PageViewer.tsx @@ -1,9 +1,9 @@ -import { authClient } from "@superset/auth/client"; import { CommentProvider, PageCommentsView } from "@superset/ui/page-comments"; import { Spinner } from "@superset/ui/spinner"; import { useQuery } from "@tanstack/react-query"; import { TRPCClientError } from "@trpc/client"; import { useCallback, useEffect, useRef } from "react"; +import { authClient } from "renderer/lib/auth-client"; import { cloudTrpc } from "renderer/lib/cloud-trpc"; import { electronTrpcClient } from "renderer/lib/trpc-client"; import { PageViewerMessage } from "./components/PageViewerMessage"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/hooks/usePageHeaderData/usePageHeaderData.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/hooks/usePageHeaderData/usePageHeaderData.ts index a46659e9a72..4a585348f27 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/hooks/usePageHeaderData/usePageHeaderData.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/hooks/usePageHeaderData/usePageHeaderData.ts @@ -1,10 +1,10 @@ -import { authClient } from "@superset/auth/client"; import type { CommentThread, PageHeaderPage, PageHeaderVersion, } from "@superset/ui/page-comments"; import { useCallback } from "react"; +import { authClient } from "renderer/lib/auth-client"; import { cloudTrpc } from "renderer/lib/cloud-trpc"; import { toThreads } from "renderer/routes/_authenticated/_dashboard/utils/toThreads"; @@ -41,6 +41,7 @@ export function usePageHeaderData(data: PageHeaderTarget): PageHeaderData { { enabled: Boolean(pageId) && version > 0 }, ); + const utils = cloudTrpc.useUtils(); const setVisibility = cloudTrpc.page.setVisibility.useMutation(); const setSharedVersion = cloudTrpc.page.setSharedVersion.useMutation(); const deletePage = cloudTrpc.page.delete.useMutation(); @@ -75,8 +76,13 @@ export function usePageHeaderData(data: PageHeaderTarget): PageHeaderData { currentUserId: session?.user.id, onSetVisibility: async (visibility) => { if (!pageId) return; - await setVisibility.mutateAsync({ id: pageId, visibility }); - await refresh(); + const updated = await setVisibility.mutateAsync({ + id: pageId, + visibility, + }); + utils.page.pull.setData(ref, (prev) => + prev ? { ...prev, visibility: updated.visibility } : prev, + ); }, onSetSharedVersion: async (version) => { if (!pageId) return; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesGrid/PagesGrid.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesGrid/PagesGrid.tsx index 05e0c766613..c035bf02078 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesGrid/PagesGrid.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesGrid/PagesGrid.tsx @@ -22,21 +22,25 @@ const SKELETON_KEYS = [ interface PagesGridProps { pages: PageCardItem[]; pinnedPageIds: ReadonlySet; + currentUserId: string | undefined; isPending: boolean; error?: string; hasFilters: boolean; onOpen: (page: PageCardItem, event: React.MouseEvent) => void; onTogglePin: (pageId: string) => void; + onDelete: (pageId: string) => Promise; } export function PagesGrid({ pages, pinnedPageIds, + currentUserId, isPending, error, hasFilters, onOpen, onTogglePin, + onDelete, }: PagesGridProps) { if (isPending) { return ( @@ -95,8 +99,10 @@ export function PagesGrid({ key={page.id} page={page} isPinned={pinnedPageIds.has(page.id)} + currentUserId={currentUserId} onOpen={onOpen} onTogglePin={onTogglePin} + onDelete={onDelete} /> ))} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesGrid/components/PageCard/PageCard.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesGrid/components/PageCard/PageCard.tsx index d715ac4233f..d01d1866064 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesGrid/components/PageCard/PageCard.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesGrid/components/PageCard/PageCard.tsx @@ -5,11 +5,20 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@superset/ui/dropdown-menu"; +import { DeletePageDialog } from "@superset/ui/page-comments"; import { toast } from "@superset/ui/sonner"; import { cn } from "@superset/ui/utils"; import { formatDistanceToNowStrict } from "date-fns"; -import { Globe, Link2, Lock, MoreVertical, Pin, PinOff } from "lucide-react"; -import type { MouseEvent } from "react"; +import { + Globe, + Link2, + Lock, + MoreVertical, + Pin, + PinOff, + Trash2, +} from "lucide-react"; +import { type MouseEvent, useState } from "react"; import { PageThumbnail } from "./components/PageThumbnail"; export interface PageCardItem { @@ -20,22 +29,34 @@ export interface PageCardItem { visibility: string; createdAt: Date | string; updatedAt: Date | string; + latestVersion: number | null; + sharedVersion: number | null; + createdByUserId: string | null; + ownerName: string | null; } interface PageCardProps { page: PageCardItem; isPinned: boolean; + currentUserId: string | undefined; onOpen: (page: PageCardItem, event: MouseEvent) => void; onTogglePin: (pageId: string) => void; + onDelete: (pageId: string) => Promise; } export function PageCard({ page, isPinned, + currentUserId, onOpen, onTogglePin, + onDelete, }: PageCardProps) { + const [deleteOpen, setDeleteOpen] = useState(false); const isShared = page.visibility === "org"; + const isOwner = + currentUserId !== undefined && currentUserId === page.createdByUserId; + const ownerName = isOwner ? null : page.ownerName; const VisibilityIcon = isShared ? Globe : Lock; const edited = new Date(page.updatedAt).getTime(); const created = new Date(page.createdAt).getTime(); @@ -63,7 +84,10 @@ export function PageCard({ onClick={(event) => onOpen(page, event)} className="flex flex-1 flex-col text-left" > - +
{page.title} @@ -72,6 +96,12 @@ export function PageCard({ {wasEdited ? "Edited" : "Created"} {timestamp} + {ownerName ? ( + <> + + {ownerName} + + ) : null}
@@ -108,8 +138,25 @@ export function PageCard({ Copy link + {isOwner ? ( + setDeleteOpen(true)} + > + + Delete + + ) : null} + + onDelete(page.id)} + /> ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesGrid/components/PageCard/components/PageThumbnail/PageThumbnail.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesGrid/components/PageCard/components/PageThumbnail/PageThumbnail.tsx index 895a79df3a8..c0e2bf6270f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesGrid/components/PageCard/components/PageThumbnail/PageThumbnail.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesGrid/components/PageCard/components/PageThumbnail/PageThumbnail.tsx @@ -3,25 +3,17 @@ import { useQuery } from "@tanstack/react-query"; import { FileText } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { cloudTrpc } from "renderer/lib/cloud-trpc"; -import { - FRAME_HEIGHT, - FRAME_WIDTH, - THUMBNAIL_ASPECT_RATIO, -} from "../../../../constants"; -import { - loadThumbnailUrl, - thumbnailCacheKey, -} from "./utils/pageThumbnailCache"; +import { electronTrpcClient } from "renderer/lib/trpc-client"; +import { THUMBNAIL_ASPECT_RATIO } from "../../../../constants"; interface PageThumbnailProps { - slug: string; pageId: string; + version: number | null; } -export function PageThumbnail({ slug, pageId }: PageThumbnailProps) { +export function PageThumbnail({ pageId, version }: PageThumbnailProps) { const containerRef = useRef(null); const [isVisible, setIsVisible] = useState(false); - const [scale, setScale] = useState(0); useEffect(() => { const element = containerRef.current; @@ -35,46 +27,55 @@ export function PageThumbnail({ slug, pageId }: PageThumbnailProps) { ); observer.observe(element); - const resize = new ResizeObserver(([entry]) => { - const width = entry?.contentRect.width ?? 0; - if (width > 0) setScale(width / FRAME_WIDTH); - }); - resize.observe(element); - - return () => { - observer.disconnect(); - resize.disconnect(); - }; + return () => observer.disconnect(); }, []); + const versionKey = version === null ? null : String(version); + const enabled = isVisible && versionKey !== null; + + const cached = useQuery({ + queryKey: ["page-thumbnail", "cached", pageId, versionKey], + enabled, + staleTime: Number.POSITIVE_INFINITY, + queryFn: () => + electronTrpcClient.pageThumbnail.peek.query({ + pageId, + version: versionKey as string, + }), + }); + + const needsCapture = enabled && cached.isSuccess && cached.data === null; + const pull = cloudTrpc.page.pull.useQuery( { id: pageId }, - { enabled: isVisible, staleTime: 5 * 60 * 1000 }, + { enabled: needsCapture, staleTime: 5 * 60 * 1000 }, ); const downloadUrl = pull.data?.downloadUrl; - const version = pull.data?.version ?? 0; - - const thumbnailEnabled = Boolean(downloadUrl) && version > 0; - const thumbnail = useQuery({ - queryKey: ["page-thumbnail", pageId, version], - enabled: thumbnailEnabled, + const captured = useQuery({ + queryKey: ["page-thumbnail", "captured", pageId, versionKey], + enabled: needsCapture && Boolean(downloadUrl), staleTime: Number.POSITIVE_INFINITY, - queryFn: () => - loadThumbnailUrl(thumbnailCacheKey(pageId, version), async () => { - const response = await fetch(downloadUrl as string, { - cache: "force-cache", - }); - if (!response.ok) { - throw new Error(`Preview failed to load (${response.status})`); - } - return response.text(); - }), + retry: false, + queryFn: async () => { + const response = await fetch(downloadUrl as string, { + cache: "force-cache", + }); + if (!response.ok) { + throw new Error(`Preview failed to load (${response.status})`); + } + return electronTrpcClient.pageThumbnail.ensure.mutate({ + pageId, + version: versionKey as string, + html: await response.text(), + }); + }, }); + const src = cached.data ?? captured.data ?? null; const isLoading = - isVisible && (pull.isPending || (thumbnailEnabled && thumbnail.isPending)); + enabled && !src && !captured.isError && (cached.isPending || needsCapture); return (
- {thumbnail.data && scale > 0 ? ( -