forked from akai/readest
fix(share): make /s landing build under Next 16 layout-prop validation (#4040)
* fix(share): make /s landing build under Next 16 layout-prop validation
Production builds (`next build --webpack` for OpenNext on Cloudflare)
rejected the Layout default export with "Type 'LayoutProps' is not valid"
because Next 16 strictly enforces that layout components only accept
`{ children }` (and `{ params }` for dynamic segments) — never
`searchParams`. The previous design tried to read `searchParams` from
both the layout component AND its `generateMetadata`, but layouts don't
get `searchParams` at all (they're shared across child pages with
potentially different query strings).
Restructure:
- Move `generateMetadata` from `app/s/layout.tsx` to `app/s/page.tsx`,
which DOES receive `searchParams`. Page is now a server component.
- Split the existing client component into `app/s/ShareLanding.tsx`
(still `'use client'`); page.tsx wraps it in `<Suspense>` per the
Next 16 client-component contract.
- Delete `app/s/layout.tsx` — no longer needed; the project's root
layout still wraps everything.
Verified locally: `pnpm lint` clean, `pnpm build-web` green, all 9
share API routes plus `/s` show up in the route manifest.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(share): drop edge runtime from og.png so OpenNext can bundle it
OpenNext on Cloudflare errors out when bundling edge-runtime routes inside
the default server function:
app/api/share/[token]/og.png/route cannot use the edge runtime.
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`'s `ImageResponse` (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.
Verified: `pnpm exec opennextjs-cloudflare build` now completes.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 }>;
|
||||
|
||||
@@ -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<ShareMetadata | null>(null);
|
||||
const [loadError, setLoadError] = useState<{ status: number; message: string } | null>(null);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [importError, setImportError] = useState<string | null>(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 (
|
||||
<main className='bg-base-200 flex min-h-dvh flex-col items-center justify-center p-4 sm:p-8'>
|
||||
<Card>
|
||||
<div className='flex flex-col items-center text-center'>
|
||||
<div className='bg-base-200 mb-4 flex h-16 w-16 items-center justify-center rounded-2xl'>
|
||||
<IoAlertCircleOutline className='text-base-content/60 h-8 w-8' />
|
||||
</div>
|
||||
<h1 className='text-base-content text-2xl font-semibold'>{heading}</h1>
|
||||
<p className='text-base-content/70 mt-2 text-sm'>{body}</p>
|
||||
<a
|
||||
href={DOWNLOAD_READEST_URL}
|
||||
target='_blank'
|
||||
rel='noopener'
|
||||
className='btn btn-ghost btn-block mt-6'
|
||||
>
|
||||
{_('Get Readest')}
|
||||
</a>
|
||||
</div>
|
||||
</Card>
|
||||
<PageFooter tagline={_('Open-source ebook reader for everyone, on every device.')} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (!meta) {
|
||||
return (
|
||||
<main className='bg-base-200 flex min-h-dvh flex-col items-center justify-center p-4 sm:p-8'>
|
||||
<Card>
|
||||
<BrandHeader title={_('Loading shared book…')} alt={_('Readest logo')} />
|
||||
<div
|
||||
className='mt-6 flex flex-col items-center gap-3 py-4'
|
||||
role='status'
|
||||
aria-live='polite'
|
||||
>
|
||||
<span className='loading loading-dots loading-md text-primary' aria-hidden='true' />
|
||||
</div>
|
||||
</Card>
|
||||
<PageFooter tagline={_('Open-source ebook reader for everyone, on every device.')} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const coverSrc = meta.hasCover ? `/api/share/${encodeURIComponent(token)}/cover` : null;
|
||||
const expiryLabel = formatExpiry(meta.expiresAt, _);
|
||||
|
||||
return (
|
||||
<main className='bg-base-200 flex min-h-dvh flex-col items-center justify-center p-4 sm:p-6'>
|
||||
{/* Inline card instead of <Card>: 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. */}
|
||||
<div className='bg-base-100 border-base-300/60 mx-auto w-full max-w-md overflow-hidden rounded-2xl border shadow-xl sm:max-w-2xl'>
|
||||
{/* 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. */}
|
||||
<div className='flex flex-col items-center gap-2 px-5 pb-2 pt-5 sm:px-7 sm:pb-3 sm:pt-7'>
|
||||
<Image
|
||||
src='/icon.png'
|
||||
alt={_('Readest logo')}
|
||||
width={40}
|
||||
height={40}
|
||||
priority
|
||||
className='rounded-lg'
|
||||
/>
|
||||
<span className='text-base-content text-base font-semibold'>{_('Shared with you')}</span>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col items-center gap-5 px-5 pb-5 sm:flex-row sm:items-stretch sm:gap-7 sm:px-7 sm:pb-7'>
|
||||
{/* 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. */}
|
||||
<div className='aspect-[2/3] w-32 shrink-0 overflow-hidden rounded-lg shadow-lg sm:w-40 sm:self-center'>
|
||||
{coverSrc ? (
|
||||
// Plain <img>: 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
|
||||
<img src={coverSrc} alt='' className='h-full w-full object-cover' loading='eager' />
|
||||
) : (
|
||||
<div className='bg-base-200 flex h-full w-full items-center justify-center'>
|
||||
<IoBookOutline className='text-base-content/30 h-10 w-10' aria-hidden='true' />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content column: title, author, meta line, then actions.
|
||||
Centered on mobile, left-aligned on desktop where it sits to
|
||||
the right of the cover. */}
|
||||
<div className='flex min-w-0 flex-1 flex-col items-center text-center sm:items-start sm:justify-center sm:text-left'>
|
||||
<h1 className='text-base-content line-clamp-3 text-xl font-semibold leading-tight sm:text-2xl'>
|
||||
{meta.title}
|
||||
</h1>
|
||||
{meta.author && (
|
||||
<p className='text-base-content/70 mt-1 truncate text-sm'>{meta.author}</p>
|
||||
)}
|
||||
<p className='text-base-content/50 mt-2 text-xs'>
|
||||
{meta.format.toUpperCase()} · {formatBytes(meta.size)} · {expiryLabel}
|
||||
</p>
|
||||
|
||||
<div className='mt-4 flex w-full flex-col gap-2 sm:mt-5'>
|
||||
{user ? (
|
||||
<>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleAddToLibrary}
|
||||
disabled={importing}
|
||||
className='btn btn-primary btn-block flex-nowrap gap-2 whitespace-nowrap rounded-xl'
|
||||
>
|
||||
<IoLibraryOutline className='h-5 w-5' aria-hidden='true' />
|
||||
{importing ? _('Adding…') : _('Add to my library')}
|
||||
</button>
|
||||
{/* 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. */}
|
||||
<div className='flex gap-2'>
|
||||
<a
|
||||
href={downloadHref}
|
||||
onClick={handleDownloadClick}
|
||||
className='btn btn-ghost btn-sm flex-1 flex-nowrap gap-1.5 whitespace-nowrap rounded-xl'
|
||||
>
|
||||
<IoCloudDownloadOutline className='h-4 w-4' aria-hidden='true' />
|
||||
{_('Download')}
|
||||
</a>
|
||||
<a
|
||||
href={appHref}
|
||||
className='btn btn-ghost btn-sm flex-1 flex-nowrap gap-1.5 whitespace-nowrap rounded-xl'
|
||||
>
|
||||
<IoOpenOutline className='h-4 w-4' aria-hidden='true' />
|
||||
{_('Open in app')}
|
||||
</a>
|
||||
</div>
|
||||
{importError && (
|
||||
<p className='text-error mt-1 text-center text-xs sm:text-left' role='alert'>
|
||||
{importError}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* 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. */}
|
||||
<div className='flex w-full gap-2'>
|
||||
<a
|
||||
href={downloadHref}
|
||||
onClick={handleDownloadClick}
|
||||
className='btn btn-primary flex-1 flex-nowrap gap-1.5 whitespace-nowrap rounded-xl'
|
||||
>
|
||||
<IoCloudDownloadOutline className='h-5 w-5' aria-hidden='true' />
|
||||
{_('Download')}
|
||||
</a>
|
||||
<a
|
||||
href={appHref}
|
||||
className='btn btn-ghost flex-1 flex-nowrap gap-1.5 whitespace-nowrap rounded-xl'
|
||||
>
|
||||
<IoOpenOutline className='h-5 w-5' aria-hidden='true' />
|
||||
{_('Open in app')}
|
||||
</a>
|
||||
</div>
|
||||
<p className='text-base-content/60 mt-1 text-center text-xs sm:text-left'>
|
||||
{_("Don't have Readest?")}{' '}
|
||||
<a
|
||||
href={DOWNLOAD_READEST_URL}
|
||||
target='_blank'
|
||||
rel='noopener'
|
||||
className='text-primary font-medium hover:underline'
|
||||
>
|
||||
{_('Download Readest')}
|
||||
</a>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PageFooter tagline={_('Open-source ebook reader for everyone, on every device.')} />
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShareLanding;
|
||||
@@ -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<Record<string, string | string[] | undefined>>;
|
||||
}
|
||||
|
||||
interface MetadataProps {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}
|
||||
|
||||
export async function generateMetadata(
|
||||
{ searchParams }: MetadataProps,
|
||||
_parent: ResolvingMetadata,
|
||||
): Promise<Metadata> {
|
||||
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}</>;
|
||||
}
|
||||
@@ -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<Record<string, string | string[] | undefined>>;
|
||||
}
|
||||
|
||||
const ShareLanding = () => {
|
||||
const _ = useTranslation();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const pathname = usePathname();
|
||||
const { user } = useAuth();
|
||||
export async function generateMetadata({ searchParams }: PageProps): Promise<Metadata> {
|
||||
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<ShareMetadata | null>(null);
|
||||
const [loadError, setLoadError] = useState<{ status: number; message: string } | null>(null);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [importError, setImportError] = useState<string | null>(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 (
|
||||
<main className='bg-base-200 flex min-h-dvh flex-col items-center justify-center p-4 sm:p-8'>
|
||||
<Card>
|
||||
<div className='flex flex-col items-center text-center'>
|
||||
<div className='bg-base-200 mb-4 flex h-16 w-16 items-center justify-center rounded-2xl'>
|
||||
<IoAlertCircleOutline className='text-base-content/60 h-8 w-8' />
|
||||
</div>
|
||||
<h1 className='text-base-content text-2xl font-semibold'>{heading}</h1>
|
||||
<p className='text-base-content/70 mt-2 text-sm'>{body}</p>
|
||||
<a
|
||||
href={DOWNLOAD_READEST_URL}
|
||||
target='_blank'
|
||||
rel='noopener'
|
||||
className='btn btn-ghost btn-block mt-6'
|
||||
>
|
||||
{_('Get Readest')}
|
||||
</a>
|
||||
</div>
|
||||
</Card>
|
||||
<PageFooter tagline={_('Open-source ebook reader for everyone, on every device.')} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (!meta) {
|
||||
return (
|
||||
<main className='bg-base-200 flex min-h-dvh flex-col items-center justify-center p-4 sm:p-8'>
|
||||
<Card>
|
||||
<BrandHeader title={_('Loading shared book…')} alt={_('Readest logo')} />
|
||||
<div
|
||||
className='mt-6 flex flex-col items-center gap-3 py-4'
|
||||
role='status'
|
||||
aria-live='polite'
|
||||
>
|
||||
<span className='loading loading-dots loading-md text-primary' aria-hidden='true' />
|
||||
</div>
|
||||
</Card>
|
||||
<PageFooter tagline={_('Open-source ebook reader for everyone, on every device.')} />
|
||||
</main>
|
||||
);
|
||||
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 (
|
||||
<main className='bg-base-200 flex min-h-dvh flex-col items-center justify-center p-4 sm:p-6'>
|
||||
{/* Inline card instead of <Card>: 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. */}
|
||||
<div className='bg-base-100 border-base-300/60 mx-auto w-full max-w-md overflow-hidden rounded-2xl border shadow-xl sm:max-w-2xl'>
|
||||
{/* 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. */}
|
||||
<div className='flex flex-col items-center gap-2 px-5 pb-2 pt-5 sm:px-7 sm:pb-3 sm:pt-7'>
|
||||
<Image
|
||||
src='/icon.png'
|
||||
alt={_('Readest logo')}
|
||||
width={40}
|
||||
height={40}
|
||||
priority
|
||||
className='rounded-lg'
|
||||
/>
|
||||
<span className='text-base-content text-base font-semibold'>{_('Shared with you')}</span>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col items-center gap-5 px-5 pb-5 sm:flex-row sm:items-stretch sm:gap-7 sm:px-7 sm:pb-7'>
|
||||
{/* 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. */}
|
||||
<div className='aspect-[2/3] w-32 shrink-0 overflow-hidden rounded-lg shadow-lg sm:w-40 sm:self-center'>
|
||||
{coverSrc ? (
|
||||
// Plain <img>: 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
|
||||
<img src={coverSrc} alt='' className='h-full w-full object-cover' loading='eager' />
|
||||
) : (
|
||||
<div className='bg-base-200 flex h-full w-full items-center justify-center'>
|
||||
<IoBookOutline className='text-base-content/30 h-10 w-10' aria-hidden='true' />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content column: title, author, meta line, then actions.
|
||||
Centered on mobile, left-aligned on desktop where it sits to
|
||||
the right of the cover. */}
|
||||
<div className='flex min-w-0 flex-1 flex-col items-center text-center sm:items-start sm:justify-center sm:text-left'>
|
||||
<h1 className='text-base-content line-clamp-3 text-xl font-semibold leading-tight sm:text-2xl'>
|
||||
{meta.title}
|
||||
</h1>
|
||||
{meta.author && (
|
||||
<p className='text-base-content/70 mt-1 truncate text-sm'>{meta.author}</p>
|
||||
)}
|
||||
<p className='text-base-content/50 mt-2 text-xs'>
|
||||
{meta.format.toUpperCase()} · {formatBytes(meta.size)} · {expiryLabel}
|
||||
</p>
|
||||
|
||||
<div className='mt-4 flex w-full flex-col gap-2 sm:mt-5'>
|
||||
{user ? (
|
||||
<>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleAddToLibrary}
|
||||
disabled={importing}
|
||||
className='btn btn-primary btn-block flex-nowrap gap-2 whitespace-nowrap rounded-xl'
|
||||
>
|
||||
<IoLibraryOutline className='h-5 w-5' aria-hidden='true' />
|
||||
{importing ? _('Adding…') : _('Add to my library')}
|
||||
</button>
|
||||
{/* 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. */}
|
||||
<div className='flex gap-2'>
|
||||
<a
|
||||
href={downloadHref}
|
||||
onClick={handleDownloadClick}
|
||||
className='btn btn-ghost btn-sm flex-1 flex-nowrap gap-1.5 whitespace-nowrap rounded-xl'
|
||||
>
|
||||
<IoCloudDownloadOutline className='h-4 w-4' aria-hidden='true' />
|
||||
{_('Download')}
|
||||
</a>
|
||||
<a
|
||||
href={appHref}
|
||||
className='btn btn-ghost btn-sm flex-1 flex-nowrap gap-1.5 whitespace-nowrap rounded-xl'
|
||||
>
|
||||
<IoOpenOutline className='h-4 w-4' aria-hidden='true' />
|
||||
{_('Open in app')}
|
||||
</a>
|
||||
</div>
|
||||
{importError && (
|
||||
<p className='text-error mt-1 text-center text-xs sm:text-left' role='alert'>
|
||||
{importError}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* 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. */}
|
||||
<div className='flex w-full gap-2'>
|
||||
<a
|
||||
href={downloadHref}
|
||||
onClick={handleDownloadClick}
|
||||
className='btn btn-primary flex-1 flex-nowrap gap-1.5 whitespace-nowrap rounded-xl'
|
||||
>
|
||||
<IoCloudDownloadOutline className='h-5 w-5' aria-hidden='true' />
|
||||
{_('Download')}
|
||||
</a>
|
||||
<a
|
||||
href={appHref}
|
||||
className='btn btn-ghost flex-1 flex-nowrap gap-1.5 whitespace-nowrap rounded-xl'
|
||||
>
|
||||
<IoOpenOutline className='h-5 w-5' aria-hidden='true' />
|
||||
{_('Open in app')}
|
||||
</a>
|
||||
</div>
|
||||
<p className='text-base-content/60 mt-1 text-center text-xs sm:text-left'>
|
||||
{_("Don't have Readest?")}{' '}
|
||||
<a
|
||||
href={DOWNLOAD_READEST_URL}
|
||||
target='_blank'
|
||||
rel='noopener'
|
||||
className='text-primary font-medium hover:underline'
|
||||
>
|
||||
{_('Download Readest')}
|
||||
</a>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PageFooter tagline={_('Open-source ebook reader for everyone, on every device.')} />
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<Suspense fallback={null}>
|
||||
<ShareLanding />
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
}
|
||||
|
||||
+1
-1
Submodule packages/js-mdict updated: 3355568e44...52ac38779d
Reference in New Issue
Block a user