diff --git a/apps/readest-app/src/app/api/share/[token]/og.png/route.tsx b/apps/readest-app/src/app/api/share/[token]/og.png/render.tsx similarity index 80% rename from apps/readest-app/src/app/api/share/[token]/og.png/route.tsx rename to apps/readest-app/src/app/api/share/[token]/og.png/render.tsx index 9e89bd6b..e31818db 100644 --- a/apps/readest-app/src/app/api/share/[token]/og.png/route.tsx +++ b/apps/readest-app/src/app/api/share/[token]/og.png/render.tsx @@ -4,32 +4,15 @@ import { getDownloadSignedUrl } from '@/utils/object'; import { rejectionToHttp, resolveActiveShare } from '@/libs/share-server'; import { SHARE_PRESIGN_TTL_SECONDS } from '@/services/constants'; -// Intentionally NO `export const runtime = 'edge'`. -// -// OpenNext on Cloudflare can't bundle edge-runtime routes inside the default -// server function — it errors with "OpenNext requires edge runtime function -// to be defined in a separate function." Splitting into a second function -// bundle is more config surgery than this route deserves. -// -// `next/og` (Satori + WASM yoga/resvg) has supported the default Node-compat -// runtime since Next 13.4, and on Cloudflare via OpenNext the default -// function IS already a Worker, so cold-start cost is similar to edge. - -interface RouteParams { - params: Promise<{ token: string }>; -} +// JSX renderer for the OG image. Lives in a non-route `.tsx` so the route +// file itself can be `.ts` and get filtered out of the Tauri static export +// by `pageExtensions: ['jsx', 'tsx']` (no `ts`) — same trick used by every +// other `share/[token]/*/route.ts` neighbor. const WIDTH = 1200; const HEIGHT = 630; -// GET /api/share/[token]/og.png — server-rendered branded card for chat -// unfurls. Stable URL, cached for an hour: unfurlers (iMessage, WhatsApp, -// Twitter, Slack) cache aggressively, so a short-lived signed cover URL would -// break previews after expiry. By proxying through this route we get a stable -// URL even though the underlying R2 object is presigned per-fetch. -export async function GET(_request: Request, { params }: RouteParams) { - const { token } = await params; - +export const renderShareOgImage = async (token: string): Promise => { const result = await resolveActiveShare(token); if (!result.ok) { const { status, body } = rejectionToHttp(result.reason); @@ -67,7 +50,7 @@ export async function GET(_request: Request, { params }: RouteParams) { }, }, ); -} +}; const arrayBufferToBase64 = (buffer: ArrayBuffer): string => { const bytes = new Uint8Array(buffer); diff --git a/apps/readest-app/src/app/api/share/[token]/og.png/route.ts b/apps/readest-app/src/app/api/share/[token]/og.png/route.ts new file mode 100644 index 00000000..f84cd502 --- /dev/null +++ b/apps/readest-app/src/app/api/share/[token]/og.png/route.ts @@ -0,0 +1,31 @@ +import { renderShareOgImage } from './render'; + +// Intentionally NO `export const runtime = 'edge'`. +// +// OpenNext on Cloudflare can't bundle edge-runtime routes inside the default +// server function — it errors with "OpenNext requires edge runtime function +// to be defined in a separate function." Splitting into a second function +// bundle is more config surgery than this route deserves. +// +// `next/og` (Satori + WASM yoga/resvg) has supported the default Node-compat +// runtime since Next 13.4, and on Cloudflare via OpenNext the default +// function IS already a Worker, so cold-start cost is similar to edge. +// +// The route file is `.ts` (not `.tsx`) so the Tauri static-export build +// drops it via `pageExtensions: ['jsx', 'tsx']` in next.config.mjs — same +// gating used by every other share API route. JSX rendering lives in the +// sibling `render.tsx` which the bundler simply doesn't import in Tauri. + +interface RouteParams { + params: Promise<{ token: string }>; +} + +// GET /api/share/[token]/og.png — server-rendered branded card for chat +// unfurls. Stable URL, cached for an hour: unfurlers (iMessage, WhatsApp, +// Twitter, Slack) cache aggressively, so a short-lived signed cover URL would +// break previews after expiry. By proxying through this route we get a stable +// URL even though the underlying R2 object is presigned per-fetch. +export async function GET(_request: Request, { params }: RouteParams) { + const { token } = await params; + return renderShareOgImage(token); +} diff --git a/apps/readest-app/src/app/reader/components/HeaderBar.tsx b/apps/readest-app/src/app/reader/components/HeaderBar.tsx index d5f4700a..8f714dd1 100644 --- a/apps/readest-app/src/app/reader/components/HeaderBar.tsx +++ b/apps/readest-app/src/app/reader/components/HeaderBar.tsx @@ -26,7 +26,6 @@ import WindowButtons from '@/components/WindowButtons'; import QuickActionMenu from './annotator/QuickActionMenu'; import SidebarToggler from './SidebarToggler'; import BookmarkToggler from './BookmarkToggler'; -import ShareToggler from './ShareToggler'; import NotebookToggler from './NotebookToggler'; import SettingsToggler from './SettingsToggler'; import TranslationToggler from './TranslationToggler'; @@ -220,7 +219,6 @@ const HeaderBar: React.FC = ({ - {enableAnnotationQuickActions && ( diff --git a/apps/readest-app/src/app/reader/components/ShareToggler.tsx b/apps/readest-app/src/app/reader/components/ShareToggler.tsx deleted file mode 100644 index 32cc0ad8..00000000 --- a/apps/readest-app/src/app/reader/components/ShareToggler.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import React, { useCallback } from 'react'; -import { IoShareOutline } from 'react-icons/io5'; -import { useReaderStore } from '@/store/readerStore'; -import { useBookDataStore } from '@/store/bookDataStore'; -import { useTranslation } from '@/hooks/useTranslation'; -import { useResponsiveSize } from '@/hooks/useResponsiveSize'; -import { eventDispatcher } from '@/utils/event'; - -interface ShareTogglerProps { - bookKey: string; -} - -// Reader top-bar Share button. Matches BookmarkToggler/TranslationToggler -// shape so it slots into the existing header rhythm. -const ShareToggler: React.FC = ({ bookKey }) => { - const _ = useTranslation(); - const iconSize18 = useResponsiveSize(18); - const { getProgress } = useReaderStore(); - const { getBookData } = useBookDataStore(); - - const handleShare = useCallback(() => { - const bookData = getBookData(bookKey); - if (!bookData?.book) return; - const progress = getProgress(bookKey); - eventDispatcher.dispatch('show-share-dialog', { - book: bookData.book, - cfi: progress?.location ?? null, - }); - }, [bookKey, getBookData, getProgress]); - - return ( - - ); -}; - -export default ShareToggler; diff --git a/apps/readest-app/src/app/reader/components/ViewMenu.tsx b/apps/readest-app/src/app/reader/components/ViewMenu.tsx index 2f967204..8acb832f 100644 --- a/apps/readest-app/src/app/reader/components/ViewMenu.tsx +++ b/apps/readest-app/src/app/reader/components/ViewMenu.tsx @@ -7,6 +7,7 @@ import { TbSunMoon } from 'react-icons/tb'; import { MdZoomOut, MdZoomIn, MdCheck, MdInfoOutline } from 'react-icons/md'; import { MdSync, MdSyncProblem } from 'react-icons/md'; import { IoMdExpand } from 'react-icons/io'; +import { IoShareOutline } from 'react-icons/io5'; import { TbArrowAutofitWidth } from 'react-icons/tb'; import { TbColumns1, TbColumns2 } from 'react-icons/tb'; @@ -45,7 +46,7 @@ const ViewMenu: React.FC = ({ const { envConfig, appService } = useEnv(); const { getConfig, getBookData } = useBookDataStore(); const { setSettingsDialogOpen, setSettingsDialogBookKey } = useSettingsStore(); - const { getView, getViewSettings, getViewState, setViewSettings } = useReaderStore(); + const { getView, getViewSettings, getViewState, getProgress, setViewSettings } = useReaderStore(); const config = getConfig(bookKey)!; const bookData = getBookData(bookKey)!; const viewSettings = getViewSettings(bookKey)!; @@ -105,6 +106,16 @@ const ViewMenu: React.FC = ({ eventDispatcher.dispatch('rsvp-start', { bookKey }); }; + const handleShare = () => { + setIsDropdownOpen?.(false); + if (!bookData?.book) return; + const progress = getProgress(bookKey); + eventDispatcher.dispatch('show-share-dialog', { + book: bookData.book, + cfi: progress?.location ?? null, + }); + }; + useEffect(() => { if (isScrolledMode === viewSettings!.scrolled) return; viewSettings!.scrolled = isScrolledMode; @@ -366,6 +377,10 @@ const ViewMenu: React.FC = ({ Icon={invertImgColorInDark ? MdCheck : undefined} onClick={() => setInvertImgColorInDark(!invertImgColorInDark)} /> + + + + ); }; diff --git a/apps/readest-app/src/app/s/ShareLanding.tsx b/apps/readest-app/src/app/s/ShareLanding.tsx index e9978e72..289287eb 100644 --- a/apps/readest-app/src/app/s/ShareLanding.tsx +++ b/apps/readest-app/src/app/s/ShareLanding.tsx @@ -6,7 +6,6 @@ import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { IoAlertCircleOutline, IoBookOutline, - IoCloudDownloadOutline, IoLibraryOutline, IoOpenOutline, } from 'react-icons/io5'; @@ -16,7 +15,7 @@ import { useAuth } from '@/context/AuthContext'; import { BrandHeader } from '@/components/landing/BrandHeader'; import { Card } from '@/components/landing/Card'; import { PageFooter } from '@/components/landing/PageFooter'; -import { confirmDownload, getShare, importShare, type ShareMetadata } from '@/libs/share'; +import { getShare, importShare, type ShareMetadata } from '@/libs/share'; import { formatBytes } from '@/utils/book'; const formatExpiry = (iso: string, _: TranslationFunc): string => { @@ -78,14 +77,8 @@ const ShareLanding = () => { }; }, [token, _]); - const downloadHref = `/api/share/${encodeURIComponent(token)}/download`; const appHref = `readest://share/${encodeURIComponent(token)}`; - const handleDownloadClick = () => { - // Best-effort analytics ping. Doesn't block the navigation. - if (token) confirmDownload(token); - }; - const handleAddToLibrary = async () => { if (!token || importing) return; setImporting(true); @@ -221,6 +214,12 @@ const ShareLanding = () => { {meta.format.toUpperCase()} · {formatBytes(meta.size)} · {expiryLabel}

+ {/* Direct file download is intentionally disabled on the landing + page for now (rights / abuse risk). Recipients open the share + inside the app — logged-in via "Add to my library", anonymous + via the readest:// deep link with a "Get Readest" footnote + fallback. The /api/share/[token]/download route still exists + so we can re-enable the button without a server change. */}
{user ? ( <> @@ -233,27 +232,13 @@ const ShareLanding = () => {