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/route.tsx index 59853aaf..9e89bd6b 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/route.tsx @@ -4,10 +4,16 @@ import { getDownloadSignedUrl } from '@/utils/object'; import { rejectionToHttp, resolveActiveShare } from '@/libs/share-server'; import { SHARE_PRESIGN_TTL_SECONDS } from '@/services/constants'; -// Edge runtime is required for `next/og` (uses Satori + WASM under the hood -// and doesn't run on Node-style serverless). Keeps cold start fast on -// CloudFlare Workers via OpenNext. -export const runtime = 'edge'; +// 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 }>; diff --git a/apps/readest-app/src/app/s/ShareLanding.tsx b/apps/readest-app/src/app/s/ShareLanding.tsx new file mode 100644 index 00000000..e9978e72 --- /dev/null +++ b/apps/readest-app/src/app/s/ShareLanding.tsx @@ -0,0 +1,311 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import Image from 'next/image'; +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; +import { + IoAlertCircleOutline, + IoBookOutline, + IoCloudDownloadOutline, + IoLibraryOutline, + IoOpenOutline, +} from 'react-icons/io5'; +import { DOWNLOAD_READEST_URL } from '@/services/constants'; +import { useTranslation, type TranslationFunc } from '@/hooks/useTranslation'; +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 { formatBytes } from '@/utils/book'; + +const formatExpiry = (iso: string, _: TranslationFunc): string => { + const ms = new Date(iso).getTime() - Date.now(); + const days = Math.round(ms / (24 * 60 * 60 * 1000)); + const hours = Math.round(ms / (60 * 60 * 1000)); + if (days >= 1) return _('Expires in {{count}} days', { count: days }); + if (hours > 0) return _('Expires in {{count}} hours', { count: hours }); + return _('Expiring soon'); +}; + +const ShareLanding = () => { + const _ = useTranslation(); + const router = useRouter(); + const searchParams = useSearchParams(); + const pathname = usePathname(); + const { user } = useAuth(); + + // Resolve the token from either the rewritten query (?token=) or the pretty + // path (/s/{token}). The next.config.mjs rewrite handles the web build; the + // pathname fallback handles Tauri (output: 'export', no rewrites), dev + // sessions where the rewrite isn't picked up without a server restart, and + // any deploy where the rewrite gets misconfigured. Mirrors src/app/o/page.tsx. + let token = searchParams?.get('token') ?? ''; + if (!token && pathname) { + const segments = pathname.split('/').filter(Boolean); + if (segments[0] === 's' && segments[1]) { + token = segments[1]; + } + } + + const [meta, setMeta] = useState(null); + const [loadError, setLoadError] = useState<{ status: number; message: string } | null>(null); + const [importing, setImporting] = useState(false); + const [importError, setImportError] = useState(null); + + useEffect(() => { + if (!token) { + setLoadError({ status: 400, message: _('Missing share token') }); + return; + } + let cancelled = false; + (async () => { + try { + const data = await getShare(token); + if (!cancelled) setMeta(data); + } catch (err) { + if (!cancelled) { + const status = + err && typeof err === 'object' && 'status' in err && typeof err.status === 'number' + ? err.status + : 500; + setLoadError({ status, message: err instanceof Error ? err.message : 'Unknown error' }); + } + } + })(); + return () => { + cancelled = true; + }; + }, [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); + setImportError(null); + try { + const result = await importShare(token); + // The reader resolves `ids` via getBookByHash, so pass the book hash — + // not the `files.id` UUID. Same identifier used by /reader/:hash links. + const params = new URLSearchParams(); + params.set('ids', result.bookHash); + if (result.cfi) params.set('cfi', result.cfi); + router.push(`/reader?${params.toString()}`); + } catch (err) { + setImporting(false); + const message = err instanceof Error ? err.message : _('Could not add to your library'); + setImportError(message); + } + }; + + if (loadError) { + // Pick a body copy that reflects the actual failure mode. Network / + // unknown failures get the generic "try again" message; only confirmed + // expired/revoked/not-found responses get the "no longer available" copy. + // This makes misconfigurations debuggable without inspecting devtools. + const isUnavailable = loadError.status === 410 || loadError.status === 404; + const isInvalidToken = loadError.status === 400; + const heading = isUnavailable + ? _('This share link is no longer available') + : isInvalidToken + ? _("This link can't be opened") + : _('Could not load shared book'); + const body = isUnavailable + ? _('The original link may have expired or been revoked.') + : isInvalidToken + ? _('The share link is missing required information.') + : _('Please check your connection and try again.'); + + return ( +
+ +
+
+ +
+

{heading}

+

{body}

+ + {_('Get Readest')} + +
+
+ +
+ ); + } + + if (!meta) { + return ( +
+ + +
+
+
+ +
+ ); + } + + const coverSrc = meta.hasCover ? `/api/share/${encodeURIComponent(token)}/cover` : null; + const expiryLabel = formatExpiry(meta.expiresAt, _); + + return ( +
+ {/* Inline card instead of : this surface needs a wider container + on desktop (sm:max-w-2xl) and a horizontal cover+content layout + that the shared Card primitive doesn't support. The /o landing + stays on the narrow Card; only /s gets the wider treatment. */} +
+ {/* Branded header — small Readest mark + headline. Stays compact so + the card still fits on common viewports without scroll, but + gives the page identity at a glance. */} +
+ {_('Readest + {_('Shared with you')} +
+ +
+ {/* Cover: dominant visual anchor. aspect-[2/3] keeps the box the + right shape whether or not the image loaded — stable layout + while the cover fetches. */} +
+ {coverSrc ? ( + // Plain : source is a presigned URL that varies per + // request, so next/image's loader gives no win. + // eslint-disable-next-line @next/next/no-img-element + + ) : ( +
+
+ )} +
+ + {/* Content column: title, author, meta line, then actions. + Centered on mobile, left-aligned on desktop where it sits to + the right of the cover. */} +
+

+ {meta.title} +

+ {meta.author && ( +

{meta.author}

+ )} +

+ {meta.format.toUpperCase()} · {formatBytes(meta.size)} · {expiryLabel} +

+ +
+ {user ? ( + <> + + {/* Secondary actions side-by-side. flex-nowrap + + whitespace-nowrap override daisyUI's default + `flex-wrap: wrap` on .btn so the icon and label stay + on one line in narrow viewports. */} + + {importError && ( +

+ {importError} +

+ )} + + ) : ( + <> + {/* Anonymous flow: only 2 actions, so they sit side-by-side + on one row. The 3-action logged-in flow above keeps its + stacked design (primary + 2 secondary). Download stays + `btn-primary` to keep the "main thing" hierarchy clear, + Open in app is ghost. Labels match the logged-in flow's + shorter forms to avoid wrap in the tighter row layout. */} + +

+ {_("Don't have Readest?")}{' '} + + {_('Download Readest')} + +

+ + )} +
+
+
+
+ + +
+ ); +}; + +export default ShareLanding; diff --git a/apps/readest-app/src/app/s/layout.tsx b/apps/readest-app/src/app/s/layout.tsx deleted file mode 100644 index 8159a367..00000000 --- a/apps/readest-app/src/app/s/layout.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import type { Metadata, ResolvingMetadata } from 'next'; -import { READEST_WEB_BASE_URL, SHARE_BASE_URL } from '@/services/constants'; -import { resolveActiveShare } from '@/libs/share-server'; - -// Server-rendered metadata for chat unfurls. The /s page itself is a client -// component that mirrors /o/page.tsx, but the OG/Twitter tags must be in the -// initial HTML so iMessage / WhatsApp / Twitter / Slack crawlers can read -// them without executing JS. -// -// In the Tauri build (output: 'export'), this whole route is dropped because -// rewrites and dynamic metadata require a server. Tauri intercepts the -// readest://share/{token} deep link before /s ever loads. -interface LayoutProps { - children: React.ReactNode; - searchParams?: Promise>; -} - -interface MetadataProps { - searchParams: Promise>; -} - -export async function generateMetadata( - { searchParams }: MetadataProps, - _parent: ResolvingMetadata, -): Promise { - const params = (await searchParams) ?? {}; - const tokenParam = params['token']; - const token = Array.isArray(tokenParam) ? tokenParam[0] : tokenParam; - - if (!token) { - return { - title: 'Open in Readest', - description: 'Open-source ebook reader for everyone, on every device.', - }; - } - - const result = await resolveActiveShare(token); - if (!result.ok) { - return { - title: 'Share link unavailable · Readest', - description: 'This share link is no longer available.', - }; - } - const { share } = result; - const shareUrl = `${SHARE_BASE_URL}/${token}`; - const ogImage = `${READEST_WEB_BASE_URL}/api/share/${token}/og.png`; - - return { - title: `${share.bookTitle} · Shared via Readest`, - description: share.bookAuthor - ? `${share.bookAuthor} · Shared via Readest` - : 'Shared via Readest', - openGraph: { - type: 'book', - url: shareUrl, - title: share.bookTitle, - description: share.bookAuthor - ? `${share.bookAuthor} · Shared via Readest` - : 'Shared via Readest', - images: [{ url: ogImage, width: 1200, height: 630 }], - }, - twitter: { - card: 'summary_large_image', - title: share.bookTitle, - description: share.bookAuthor - ? `${share.bookAuthor} · Shared via Readest` - : 'Shared via Readest', - images: [ogImage], - }, - }; -} - -export default function ShareLandingLayout({ children }: LayoutProps) { - return <>{children}; -} diff --git a/apps/readest-app/src/app/s/page.tsx b/apps/readest-app/src/app/s/page.tsx index a3c23b71..444e0583 100644 --- a/apps/readest-app/src/app/s/page.tsx +++ b/apps/readest-app/src/app/s/page.tsx @@ -1,321 +1,76 @@ -'use client'; +import type { Metadata } from 'next'; +import { Suspense } from 'react'; +import { READEST_WEB_BASE_URL, SHARE_BASE_URL } from '@/services/constants'; +import { resolveActiveShare } from '@/libs/share-server'; +import ShareLanding from './ShareLanding'; -import { Suspense, useEffect, useState } from 'react'; -import Image from 'next/image'; -import { usePathname, useRouter, useSearchParams } from 'next/navigation'; -import { - IoAlertCircleOutline, - IoBookOutline, - IoCloudDownloadOutline, - IoLibraryOutline, - IoOpenOutline, -} from 'react-icons/io5'; -import { DOWNLOAD_READEST_URL } from '@/services/constants'; -import { useTranslation, type TranslationFunc } from '@/hooks/useTranslation'; -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 { formatBytes } from '@/utils/book'; +// Server-rendered metadata for chat unfurls. Lives on the page (not the +// layout) because Next only passes `searchParams` to page-level +// `generateMetadata` — layout metadata is shared across child pages and +// can't see the query string. +// +// In the Tauri build (output: 'export'), this whole route is dropped because +// rewrites and dynamic metadata require a server. Tauri intercepts the +// readest://share/{token} deep link before /s ever loads. -const formatExpiry = (iso: string, _: TranslationFunc): string => { - const ms = new Date(iso).getTime() - Date.now(); - const days = Math.round(ms / (24 * 60 * 60 * 1000)); - const hours = Math.round(ms / (60 * 60 * 1000)); - if (days >= 1) return _('Expires in {{count}} days', { count: days }); - if (hours > 0) return _('Expires in {{count}} hours', { count: hours }); - return _('Expiring soon'); -}; +interface PageProps { + searchParams: Promise>; +} -const ShareLanding = () => { - const _ = useTranslation(); - const router = useRouter(); - const searchParams = useSearchParams(); - const pathname = usePathname(); - const { user } = useAuth(); +export async function generateMetadata({ searchParams }: PageProps): Promise { + const params = (await searchParams) ?? {}; + const tokenParam = params['token']; + const token = Array.isArray(tokenParam) ? tokenParam[0] : tokenParam; - // Resolve the token from either the rewritten query (?token=) or the pretty - // path (/s/{token}). The next.config.mjs rewrite handles the web build; the - // pathname fallback handles Tauri (output: 'export', no rewrites), dev - // sessions where the rewrite isn't picked up without a server restart, and - // any deploy where the rewrite gets misconfigured. Mirrors src/app/o/page.tsx. - let token = searchParams?.get('token') ?? ''; - if (!token && pathname) { - const segments = pathname.split('/').filter(Boolean); - if (segments[0] === 's' && segments[1]) { - token = segments[1]; - } - } - - const [meta, setMeta] = useState(null); - const [loadError, setLoadError] = useState<{ status: number; message: string } | null>(null); - const [importing, setImporting] = useState(false); - const [importError, setImportError] = useState(null); - - useEffect(() => { - if (!token) { - setLoadError({ status: 400, message: _('Missing share token') }); - return; - } - let cancelled = false; - (async () => { - try { - const data = await getShare(token); - if (!cancelled) setMeta(data); - } catch (err) { - if (!cancelled) { - const status = - err && typeof err === 'object' && 'status' in err && typeof err.status === 'number' - ? err.status - : 500; - setLoadError({ status, message: err instanceof Error ? err.message : 'Unknown error' }); - } - } - })(); - return () => { - cancelled = true; + if (!token) { + return { + title: 'Open in Readest', + description: 'Open-source ebook reader for everyone, on every device.', }; - }, [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); - setImportError(null); - try { - const result = await importShare(token); - // The reader resolves `ids` via getBookByHash, so pass the book hash — - // not the `files.id` UUID. Same identifier used by /reader/:hash links. - const params = new URLSearchParams(); - params.set('ids', result.bookHash); - if (result.cfi) params.set('cfi', result.cfi); - router.push(`/reader?${params.toString()}`); - } catch (err) { - setImporting(false); - const message = err instanceof Error ? err.message : _('Could not add to your library'); - setImportError(message); - } - }; - - if (loadError) { - // Pick a body copy that reflects the actual failure mode. Network / - // unknown failures get the generic "try again" message; only confirmed - // expired/revoked/not-found responses get the "no longer available" copy. - // This makes misconfigurations debuggable without inspecting devtools. - const isUnavailable = loadError.status === 410 || loadError.status === 404; - const isInvalidToken = loadError.status === 400; - const heading = isUnavailable - ? _('This share link is no longer available') - : isInvalidToken - ? _("This link can't be opened") - : _('Could not load shared book'); - const body = isUnavailable - ? _('The original link may have expired or been revoked.') - : isInvalidToken - ? _('The share link is missing required information.') - : _('Please check your connection and try again.'); - - return ( -
- -
-
- -
-

{heading}

-

{body}

- - {_('Get Readest')} - -
-
- -
- ); } - if (!meta) { - return ( -
- - -
-
-
- -
- ); + const result = await resolveActiveShare(token); + if (!result.ok) { + return { + title: 'Share link unavailable · Readest', + description: 'This share link is no longer available.', + }; } + const { share } = result; + const shareUrl = `${SHARE_BASE_URL}/${token}`; + const ogImage = `${READEST_WEB_BASE_URL}/api/share/${token}/og.png`; - const coverSrc = meta.hasCover ? `/api/share/${encodeURIComponent(token)}/cover` : null; - const expiryLabel = formatExpiry(meta.expiresAt, _); + return { + title: `${share.bookTitle} · Shared via Readest`, + description: share.bookAuthor + ? `${share.bookAuthor} · Shared via Readest` + : 'Shared via Readest', + openGraph: { + type: 'book', + url: shareUrl, + title: share.bookTitle, + description: share.bookAuthor + ? `${share.bookAuthor} · Shared via Readest` + : 'Shared via Readest', + images: [{ url: ogImage, width: 1200, height: 630 }], + }, + twitter: { + card: 'summary_large_image', + title: share.bookTitle, + description: share.bookAuthor + ? `${share.bookAuthor} · Shared via Readest` + : 'Shared via Readest', + images: [ogImage], + }, + }; +} - return ( -
- {/* Inline card instead of : this surface needs a wider container - on desktop (sm:max-w-2xl) and a horizontal cover+content layout - that the shared Card primitive doesn't support. The /o landing - stays on the narrow Card; only /s gets the wider treatment. */} -
- {/* Branded header — small Readest mark + headline. Stays compact so - the card still fits on common viewports without scroll, but - gives the page identity at a glance. */} -
- {_('Readest - {_('Shared with you')} -
- -
- {/* Cover: dominant visual anchor. aspect-[2/3] keeps the box the - right shape whether or not the image loaded — stable layout - while the cover fetches. */} -
- {coverSrc ? ( - // Plain : source is a presigned URL that varies per - // request, so next/image's loader gives no win. - // eslint-disable-next-line @next/next/no-img-element - - ) : ( -
-
- )} -
- - {/* Content column: title, author, meta line, then actions. - Centered on mobile, left-aligned on desktop where it sits to - the right of the cover. */} -
-

- {meta.title} -

- {meta.author && ( -

{meta.author}

- )} -

- {meta.format.toUpperCase()} · {formatBytes(meta.size)} · {expiryLabel} -

- -
- {user ? ( - <> - - {/* Secondary actions side-by-side. flex-nowrap + - whitespace-nowrap override daisyUI's default - `flex-wrap: wrap` on .btn so the icon and label stay - on one line in narrow viewports. */} - - {importError && ( -

- {importError} -

- )} - - ) : ( - <> - {/* Anonymous flow: only 2 actions, so they sit side-by-side - on one row. The 3-action logged-in flow above keeps its - stacked design (primary + 2 secondary). Download stays - `btn-primary` to keep the "main thing" hierarchy clear, - Open in app is ghost. Labels match the logged-in flow's - shorter forms to avoid wrap in the tighter row layout. */} - -

- {_("Don't have Readest?")}{' '} - - {_('Download Readest')} - -

- - )} -
-
-
-
- - -
- ); -}; - -const Page = () => { - // useSearchParams must be wrapped in Suspense per Next 16 client-component - // contract, mirrors src/app/o/page.tsx. +export default function Page() { + // Client child uses useSearchParams, which Next 16 requires to be wrapped + // in Suspense. Mirrors src/app/o/page.tsx. return ( ); -}; - -export default Page; +} diff --git a/packages/js-mdict b/packages/js-mdict index 3355568e..52ac3877 160000 --- a/packages/js-mdict +++ b/packages/js-mdict @@ -1 +1 @@ -Subproject commit 3355568e4439e3118701787384fe20ccbf420bba +Subproject commit 52ac38779da802d718da69155545898303eb2c8e