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:
Huang Xin
2026-05-03 10:40:16 +08:00
committed by GitHub
parent d1e7b4902c
commit 19f3e65b62
5 changed files with 383 additions and 386 deletions
@@ -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 }>;
+311
View File
@@ -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;
-75
View File
@@ -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}</>;
}
+61 -306
View File
@@ -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;
}