feat(frontend): mobile manga detail with hero + sticky CTA (0.58.0)
Phase 3 of the mobile redesign: the manga detail page gets a mobile-
native chrome — blurred-cover hero with a transparent app bar overlay,
secondary actions in an overflow Sheet, a 3-line description clamp
with Read more, and a sticky bottom CTA whose wording reflects read
progress. Desktop layout is untouched.
- /manga/[id] joins the layout's HIDE_MOBILE_CHROME route set so the
global AppBar and BottomNav step aside for the page-specific hero
chrome. A new `html[data-mobile-full-bleed]` opt-in zeroes main's
mobile top padding without touching horizontal/bottom gutters.
- The hero block (mobile-only via CSS) renders a blurred cover
backdrop, a transparent app bar (back / bookmark / overflow ⋯), and
a sharp cover thumbnail beside the title, authors, and status. The
bookmark icon swaps between Bookmark and BookmarkCheck based on the
current bookmark state — reusing the existing toggleBookmark flow.
- Secondary actions (Add to collection / Edit / Upload chapter /
Force resync) move into an overflow Sheet opened from the ⋯
button. The desktop action-row stays.
- Description gets a `.clamped` modifier (-webkit-line-clamp: 3) and
a Read more toggle on mobile, gated by a >200-char heuristic so
short blurbs don't render a dangling button. Desktop shows the
full description as before.
- Sticky bottom CTA bar reads "Continue {chapterLabel}" when
data.readProgress has a chapter_id, "Read first chapter" when
the manga has chapters but no progress, and hides otherwise. The
bar honors env(safe-area-inset-bottom) so it sits above the iOS
home indicator.
- New Playwright spec covers the CTA wording state machine
(no-progress → Read first chapter, has-progress → Continue),
Read more expand/collapse, overflow sheet contents, and a desktop
regression for the unchanged action-row.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
244
frontend/e2e/mobile-manga-detail.spec.ts
Normal file
244
frontend/e2e/mobile-manga-detail.spec.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
|
||||
// Phase 3: the manga detail page gains a mobile hero (blurred backdrop
|
||||
// + transparent app bar), a sticky bottom CTA whose wording reflects
|
||||
// read progress, a 3-line description clamp with Read more, and an
|
||||
// overflow Sheet that hosts secondary actions (Edit / Upload chapter /
|
||||
// Add to collection / Force resync). Desktop layout is preserved.
|
||||
|
||||
const MOBILE = { width: 390, height: 844 } as const;
|
||||
const DESKTOP = { width: 1280, height: 720 } as const;
|
||||
|
||||
const mangaId = 'a1111111-1111-1111-1111-111111111111';
|
||||
const firstChapterId = 'c1111111-1111-1111-1111-111111111111';
|
||||
const latestChapterId = 'c2222222-2222-2222-2222-222222222222';
|
||||
|
||||
// Chapters arrive newest-first from the API (source_index DESC) — the
|
||||
// detail page's "Read first chapter" CTA targets the last element.
|
||||
const chaptersFixture = [
|
||||
{
|
||||
id: latestChapterId,
|
||||
manga_id: mangaId,
|
||||
number: 2,
|
||||
title: 'Second',
|
||||
page_count: 10,
|
||||
created_at: '2026-02-01T00:00:00Z'
|
||||
},
|
||||
{
|
||||
id: firstChapterId,
|
||||
manga_id: mangaId,
|
||||
number: 1,
|
||||
title: 'The Brand',
|
||||
page_count: 8,
|
||||
created_at: '2026-01-01T00:00:00Z'
|
||||
}
|
||||
];
|
||||
|
||||
const shortDescription = 'A brief synopsis.';
|
||||
const longDescription =
|
||||
'This is an extended description that runs over multiple lines so that ' +
|
||||
'the clamp + Read more behavior on mobile has actual content to truncate. ' +
|
||||
'Long-form descriptions are common for manga that have been crawled from ' +
|
||||
'public sources with editorial blurbs, so the catalog has to handle them ' +
|
||||
'gracefully without pushing the chapter list below the fold on phones.';
|
||||
|
||||
function mangaFixture(description: string = shortDescription) {
|
||||
return {
|
||||
id: mangaId,
|
||||
title: 'Berserk',
|
||||
status: 'ongoing',
|
||||
alt_titles: [],
|
||||
description,
|
||||
cover_image_path: `mangas/${mangaId}/cover.png`,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
authors: [{ id: 'au1', name: 'Kentaro Miura' }],
|
||||
genres: [],
|
||||
tags: []
|
||||
};
|
||||
}
|
||||
|
||||
async function mockDetail(
|
||||
page: Page,
|
||||
opts: {
|
||||
description?: string;
|
||||
readProgress?: {
|
||||
chapter_id: string;
|
||||
chapter_number: number;
|
||||
page: number;
|
||||
} | null;
|
||||
authed?: boolean;
|
||||
} = {}
|
||||
) {
|
||||
const authed = opts.authed ?? false;
|
||||
|
||||
await page.route('**/api/v1/auth/config', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ self_register_enabled: true, private_mode: false })
|
||||
})
|
||||
);
|
||||
await page.route('**/api/v1/auth/me', (route) =>
|
||||
route.fulfill({
|
||||
status: authed ? 200 : 401,
|
||||
contentType: 'application/json',
|
||||
body: authed
|
||||
? JSON.stringify({
|
||||
user: {
|
||||
id: 'u1',
|
||||
username: 'reader',
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
is_admin: false
|
||||
}
|
||||
})
|
||||
: JSON.stringify({
|
||||
error: { code: 'unauthenticated', message: 'unauthenticated' }
|
||||
})
|
||||
})
|
||||
);
|
||||
await page.route('**/api/v1/auth/me/preferences', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({})
|
||||
})
|
||||
);
|
||||
await page.route('**/api/v1/me/bookmarks*', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } })
|
||||
})
|
||||
);
|
||||
await page.route(`**/api/v1/mangas/${mangaId}`, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mangaFixture(opts.description))
|
||||
})
|
||||
);
|
||||
await page.route(`**/api/v1/mangas/${mangaId}/chapters*`, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
items: chaptersFixture,
|
||||
page: { limit: 50, offset: 0, total: chaptersFixture.length }
|
||||
})
|
||||
})
|
||||
);
|
||||
await page.route(`**/api/v1/me/read-progress/${mangaId}`, (route) =>
|
||||
route.fulfill({
|
||||
status: opts.readProgress ? 200 : 404,
|
||||
contentType: 'application/json',
|
||||
body: opts.readProgress
|
||||
? JSON.stringify(opts.readProgress)
|
||||
: JSON.stringify({ error: { code: 'not_found', message: 'not found' } })
|
||||
})
|
||||
);
|
||||
// 1x1 transparent PNG so cover image requests don't 404 noisily.
|
||||
const png = Buffer.from(
|
||||
'89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c63000100000005000158a3b62a0000000049454e44ae426082',
|
||||
'hex'
|
||||
);
|
||||
await page.route('**/api/v1/files/**', (route) =>
|
||||
route.fulfill({ status: 200, contentType: 'image/png', body: png })
|
||||
);
|
||||
}
|
||||
|
||||
test.describe('mobile manga detail', () => {
|
||||
test('phone viewport: with no progress, CTA reads "Read first chapter" and links to the oldest chapter', async ({
|
||||
page
|
||||
}) => {
|
||||
await mockDetail(page);
|
||||
await page.setViewportSize(MOBILE);
|
||||
await page.goto(`/manga/${mangaId}`);
|
||||
|
||||
await expect(page.getByTestId('mobile-hero')).toBeVisible();
|
||||
|
||||
const cta = page.getByTestId('continue-cta');
|
||||
await expect(cta).toHaveText('Read first chapter');
|
||||
await expect(cta).toHaveAttribute(
|
||||
'href',
|
||||
`/manga/${mangaId}/chapter/${firstChapterId}`
|
||||
);
|
||||
});
|
||||
|
||||
test('phone viewport: with progress, CTA reads "Continue {chapterLabel}" and links to the in-progress chapter', async ({
|
||||
page
|
||||
}) => {
|
||||
await mockDetail(page, {
|
||||
authed: true,
|
||||
readProgress: { chapter_id: latestChapterId, chapter_number: 2, page: 5 }
|
||||
});
|
||||
await page.setViewportSize(MOBILE);
|
||||
await page.goto(`/manga/${mangaId}`);
|
||||
|
||||
const cta = page.getByTestId('continue-cta');
|
||||
// chapterLabel returns the title when present, falling back to
|
||||
// "Chapter N" only when empty — the fixture has title "Second".
|
||||
await expect(cta).toHaveText(/Continue\s+Second/);
|
||||
await expect(cta).toHaveAttribute(
|
||||
'href',
|
||||
`/manga/${mangaId}/chapter/${latestChapterId}`
|
||||
);
|
||||
});
|
||||
|
||||
test('phone viewport: short description shows no Read more toggle', async ({ page }) => {
|
||||
await mockDetail(page, { description: shortDescription });
|
||||
await page.setViewportSize(MOBILE);
|
||||
await page.goto(`/manga/${mangaId}`);
|
||||
|
||||
await expect(page.getByTestId('manga-description')).toBeVisible();
|
||||
await expect(page.getByTestId('read-more-toggle')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('phone viewport: long description shows Read more, expands on tap, collapses on second tap', async ({
|
||||
page
|
||||
}) => {
|
||||
await mockDetail(page, { description: longDescription });
|
||||
await page.setViewportSize(MOBILE);
|
||||
await page.goto(`/manga/${mangaId}`);
|
||||
|
||||
const toggle = page.getByTestId('read-more-toggle');
|
||||
await expect(toggle).toHaveText('Read more');
|
||||
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveText('Read less');
|
||||
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveText('Read more');
|
||||
});
|
||||
|
||||
test('phone viewport: overflow ⋯ opens the actions sheet with Edit / Upload / Add-to-collection rows', async ({
|
||||
page
|
||||
}) => {
|
||||
await mockDetail(page, { authed: true });
|
||||
await page.setViewportSize(MOBILE);
|
||||
await page.goto(`/manga/${mangaId}`);
|
||||
|
||||
await expect(page.getByTestId('detail-overflow-sheet')).toBeHidden();
|
||||
await page.getByTestId('detail-overflow').click();
|
||||
|
||||
const sheet = page.getByTestId('detail-overflow-sheet');
|
||||
await expect(sheet).toBeVisible();
|
||||
await expect(sheet.getByTestId('overflow-edit')).toBeVisible();
|
||||
await expect(sheet.getByTestId('overflow-upload-chapter')).toBeVisible();
|
||||
await expect(sheet.getByTestId('overflow-add-to-collection')).toBeVisible();
|
||||
});
|
||||
|
||||
test('desktop viewport: mobile hero is hidden, existing layout + action-row stays', async ({
|
||||
page
|
||||
}) => {
|
||||
await mockDetail(page, { authed: true });
|
||||
await page.setViewportSize(DESKTOP);
|
||||
await page.goto(`/manga/${mangaId}`);
|
||||
|
||||
// Hero block exists in DOM but is hidden by CSS at >640px.
|
||||
await expect(page.getByTestId('mobile-hero')).toBeHidden();
|
||||
// The desktop title/cover/action surfaces remain visible.
|
||||
await expect(page.getByTestId('manga-title')).toBeVisible();
|
||||
await expect(page.getByTestId('bookmark-toggle')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mangalord-frontend",
|
||||
"version": "0.57.0",
|
||||
"version": "0.58.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -87,9 +87,13 @@
|
||||
// is unaffected; it continues to follow the reader fullscreen slide
|
||||
// transform.
|
||||
const HIDE_MOBILE_CHROME_PATHS = new Set(['/login', '/register']);
|
||||
const HIDE_MOBILE_CHROME_ROUTES = new Set([
|
||||
'/manga/[id]',
|
||||
'/manga/[id]/chapter/[chapter_id]'
|
||||
]);
|
||||
const showMobileChrome = $derived.by(() => {
|
||||
if (HIDE_MOBILE_CHROME_PATHS.has($page.url.pathname)) return false;
|
||||
if ($page.route?.id === '/manga/[id]/chapter/[chapter_id]') return false;
|
||||
if (HIDE_MOBILE_CHROME_ROUTES.has($page.route?.id ?? '')) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -381,6 +385,18 @@
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
:global(html[data-mobile-full-bleed='true']) main {
|
||||
/* Mobile detail page surrenders the layout's top reservation
|
||||
so its hero can sit at the viewport top under a transparent
|
||||
page-specific app bar. Horizontal and bottom padding are
|
||||
preserved so the body content (description, chapter list)
|
||||
keeps its gutters; the hero negates the horizontal padding
|
||||
itself via negative margins. */
|
||||
padding-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile chrome (AppBar + BottomNav) is rendered always when the
|
||||
route opts in, but CSS-hides above the breakpoint so the desktop
|
||||
header is the only visible top-chrome. The ResizeObserver in the
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import { goto } from '$app/navigation';
|
||||
import { fileUrl, ApiError } from '$lib/api/client';
|
||||
import { createBookmark, deleteBookmark, type Bookmark } from '$lib/api/bookmarks';
|
||||
import {
|
||||
@@ -14,12 +16,17 @@
|
||||
import { listTags, type Tag } from '$lib/api/tags';
|
||||
import { session } from '$lib/session.svelte';
|
||||
import Chip from '$lib/components/Chip.svelte';
|
||||
import Sheet from '$lib/components/Sheet.svelte';
|
||||
import AddToCollectionModal from '$lib/components/AddToCollectionModal.svelte';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import FolderPlus from '@lucide/svelte/icons/folder-plus';
|
||||
import Pencil from '@lucide/svelte/icons/pencil';
|
||||
import UploadCloud from '@lucide/svelte/icons/upload-cloud';
|
||||
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
||||
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
|
||||
import MoreHorizontal from '@lucide/svelte/icons/more-horizontal';
|
||||
import BookmarkIcon from '@lucide/svelte/icons/bookmark';
|
||||
import BookmarkCheck from '@lucide/svelte/icons/bookmark-check';
|
||||
|
||||
let { data } = $props();
|
||||
// `manga` is locally overridable so a successful force resync can
|
||||
@@ -66,6 +73,58 @@
|
||||
|
||||
let busy = $state(false);
|
||||
let altTitlesOpen = $state(false);
|
||||
let overflowOpen = $state(false);
|
||||
let descExpanded = $state(false);
|
||||
|
||||
// Cheap heuristic — descriptions short enough to fit in ~3 lines
|
||||
// don't need a Read more toggle. Avoids dangling buttons that
|
||||
// would do nothing visible on tap.
|
||||
const descNeedsClamp = $derived((manga.description?.length ?? 0) > 200);
|
||||
|
||||
/**
|
||||
* Sticky-CTA target on the mobile detail. Three states:
|
||||
* - Has read progress → "Continue {chapterLabel}"
|
||||
* - No progress, has chapters → "Read first chapter" (oldest = end of
|
||||
* the list since the server returns chapters newest-first)
|
||||
* - No chapters → null (CTA bar hides)
|
||||
*/
|
||||
const ctaTarget = $derived.by(() => {
|
||||
if (continueChapterId && continueLabel) {
|
||||
return {
|
||||
href: `/manga/${manga.id}/chapter/${continueChapterId}`,
|
||||
label: `Continue ${continueLabel}`
|
||||
};
|
||||
}
|
||||
if (chapters.length > 0) {
|
||||
const first = chapters[chapters.length - 1];
|
||||
return {
|
||||
href: `/manga/${manga.id}/chapter/${first.id}`,
|
||||
label: 'Read first chapter'
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
function onBack() {
|
||||
if (!browser) return;
|
||||
if (window.history.length > 1) {
|
||||
window.history.back();
|
||||
} else {
|
||||
goto('/');
|
||||
}
|
||||
}
|
||||
|
||||
// Tell the root layout to drop its top padding so the hero can sit
|
||||
// at the viewport top under the page-specific app bar. Scoped to
|
||||
// <html> so the rule survives sibling re-renders; cleaned up on
|
||||
// navigation away from the detail.
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
document.documentElement.dataset.mobileFullBleed = 'true';
|
||||
return () => {
|
||||
delete document.documentElement.dataset.mobileFullBleed;
|
||||
};
|
||||
});
|
||||
|
||||
async function toggleBookmark() {
|
||||
if (!session.user) return;
|
||||
@@ -217,6 +276,76 @@
|
||||
</svelte:head>
|
||||
|
||||
<article>
|
||||
<header class="mobile-hero" data-testid="mobile-hero">
|
||||
{#if manga.cover_image_path}
|
||||
<div
|
||||
class="hero-bg"
|
||||
style="background-image: url('{fileUrl(manga.cover_image_path)}');"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
{/if}
|
||||
<div class="hero-scrim" aria-hidden="true"></div>
|
||||
|
||||
<div class="hero-appbar">
|
||||
<button
|
||||
type="button"
|
||||
class="hero-icon-btn"
|
||||
onclick={onBack}
|
||||
aria-label="Back"
|
||||
data-testid="detail-back"
|
||||
>
|
||||
<ChevronLeft size={22} aria-hidden="true" />
|
||||
</button>
|
||||
<div class="hero-spacer"></div>
|
||||
{#if session.user}
|
||||
<button
|
||||
type="button"
|
||||
class="hero-icon-btn"
|
||||
onclick={toggleBookmark}
|
||||
disabled={busy}
|
||||
aria-label={mangaBookmark ? 'Remove bookmark' : 'Add bookmark'}
|
||||
aria-pressed={mangaBookmark ? 'true' : 'false'}
|
||||
data-testid="detail-bookmark-icon"
|
||||
>
|
||||
{#if mangaBookmark}
|
||||
<BookmarkCheck size={22} aria-hidden="true" />
|
||||
{:else}
|
||||
<BookmarkIcon size={22} aria-hidden="true" />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="hero-icon-btn"
|
||||
onclick={() => (overflowOpen = true)}
|
||||
aria-label="More actions"
|
||||
data-testid="detail-overflow"
|
||||
>
|
||||
<MoreHorizontal size={22} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="hero-content">
|
||||
{#if manga.cover_image_path}
|
||||
<img
|
||||
class="hero-cover"
|
||||
src={fileUrl(manga.cover_image_path)}
|
||||
alt=""
|
||||
loading="eager"
|
||||
/>
|
||||
{/if}
|
||||
<div class="hero-text">
|
||||
<h1 class="hero-title" data-testid="mobile-manga-title">{manga.title}</h1>
|
||||
{#if authors.length > 0}
|
||||
<p class="hero-authors" data-testid="mobile-manga-authors">
|
||||
by {authors.map((a) => a.name).join(', ')}
|
||||
</p>
|
||||
{/if}
|
||||
<span class="hero-status status-{manga.status}">{statusLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<header class="overview">
|
||||
{#if manga.cover_image_path}
|
||||
<img
|
||||
@@ -342,7 +471,23 @@
|
||||
{/if}
|
||||
|
||||
{#if manga.description}
|
||||
<p class="description" data-testid="manga-description">{manga.description}</p>
|
||||
<p
|
||||
class="description"
|
||||
class:clamped={descNeedsClamp && !descExpanded}
|
||||
data-testid="manga-description"
|
||||
>
|
||||
{manga.description}
|
||||
</p>
|
||||
{#if descNeedsClamp}
|
||||
<button
|
||||
type="button"
|
||||
class="read-more"
|
||||
onclick={() => (descExpanded = !descExpanded)}
|
||||
data-testid="read-more-toggle"
|
||||
>
|
||||
{descExpanded ? 'Read less' : 'Read more'}
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if session.user}
|
||||
@@ -459,6 +604,73 @@
|
||||
</ol>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<Sheet
|
||||
open={overflowOpen}
|
||||
title="Actions"
|
||||
onClose={() => (overflowOpen = false)}
|
||||
testid="detail-overflow-sheet"
|
||||
>
|
||||
<div class="overflow-list">
|
||||
{#if session.user}
|
||||
<button
|
||||
type="button"
|
||||
class="overflow-item"
|
||||
onclick={() => {
|
||||
overflowOpen = false;
|
||||
collectionModalOpen = true;
|
||||
}}
|
||||
data-testid="overflow-add-to-collection"
|
||||
>
|
||||
<FolderPlus size={18} aria-hidden="true" />
|
||||
<span>Add to collection</span>
|
||||
</button>
|
||||
<a
|
||||
class="overflow-item"
|
||||
href="/manga/{manga.id}/edit"
|
||||
data-testid="overflow-edit"
|
||||
>
|
||||
<Pencil size={18} aria-hidden="true" />
|
||||
<span>Edit manga</span>
|
||||
</a>
|
||||
<a
|
||||
class="overflow-item"
|
||||
href="/manga/{manga.id}/upload-chapter"
|
||||
data-testid="overflow-upload-chapter"
|
||||
>
|
||||
<UploadCloud size={18} aria-hidden="true" />
|
||||
<span>Upload chapter</span>
|
||||
</a>
|
||||
{#if session.user.is_admin}
|
||||
<button
|
||||
type="button"
|
||||
class="overflow-item"
|
||||
onclick={() => {
|
||||
overflowOpen = false;
|
||||
forceResync();
|
||||
}}
|
||||
disabled={resyncBusy}
|
||||
data-testid="overflow-force-resync"
|
||||
>
|
||||
<RefreshCw size={18} aria-hidden="true" />
|
||||
<span>{resyncBusy ? 'Resyncing…' : 'Force resync'}</span>
|
||||
</button>
|
||||
{/if}
|
||||
{:else}
|
||||
<a class="overflow-item" href="/login" data-testid="overflow-signin">
|
||||
Sign in to bookmark or collect
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</Sheet>
|
||||
|
||||
{#if ctaTarget}
|
||||
<div class="cta-bar" data-testid="cta-bar">
|
||||
<a class="cta" href={ctaTarget.href} data-testid="continue-cta">
|
||||
{ctaTarget.label}
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</article>
|
||||
|
||||
<style>
|
||||
@@ -719,4 +931,268 @@
|
||||
margin-left: var(--space-2);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
|
||||
/* Mobile hero + chrome — hidden above 640px so the existing
|
||||
desktop layout stays untouched. */
|
||||
.mobile-hero {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.read-more {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cta-bar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.mobile-hero {
|
||||
position: relative;
|
||||
display: block;
|
||||
margin: 0 calc(-1 * var(--space-3)) var(--space-4);
|
||||
overflow: hidden;
|
||||
color: #fff;
|
||||
min-height: 220px;
|
||||
}
|
||||
|
||||
.hero-bg {
|
||||
position: absolute;
|
||||
inset: -20px;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
filter: blur(28px);
|
||||
transform: scale(1.1);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.hero-scrim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(0, 0, 0, 0.45),
|
||||
rgba(0, 0, 0, 0.75)
|
||||
);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.hero-appbar {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-2) var(--space-2);
|
||||
padding-top: calc(var(--space-2) + var(--safe-top));
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.hero-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.hero-icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
color: #fff;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: var(--radius-pill);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hero-icon-btn:hover:not(:disabled),
|
||||
.hero-icon-btn:focus-visible {
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-3) var(--space-4);
|
||||
}
|
||||
|
||||
.hero-cover {
|
||||
width: 110px;
|
||||
aspect-ratio: 2 / 3;
|
||||
object-fit: cover;
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4);
|
||||
background: var(--surface);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hero-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
justify-content: flex-end;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
margin: 0;
|
||||
font-size: var(--font-xl);
|
||||
line-height: var(--leading-tight);
|
||||
color: #fff;
|
||||
/* Clamp to two lines — long titles ellipsize rather than
|
||||
push the status badge off the cover. */
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero-authors {
|
||||
margin: 0;
|
||||
font-size: var(--font-sm);
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
.hero-status {
|
||||
align-self: flex-start;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px var(--space-2);
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: var(--font-xs);
|
||||
font-weight: var(--weight-semibold);
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: #fff;
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.hero-status.status-completed {
|
||||
background: rgba(74, 222, 128, 0.2);
|
||||
border-color: rgba(74, 222, 128, 0.6);
|
||||
}
|
||||
|
||||
/* Hide the desktop hero pieces (cover img + title-row) on mobile
|
||||
— the new hero block above replaces them. The rest of .meta
|
||||
(genres, alt-titles, tags, description) is preserved. */
|
||||
.overview > .cover {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.overview .title-row {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.overview {
|
||||
grid-template-columns: 1fr;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
/* Inline action-row is replaced by the AppBar bookmark icon +
|
||||
overflow sheet on mobile; the inline Continue link is
|
||||
replaced by the sticky bottom CTA. */
|
||||
.action-row {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.resync-msg {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.continue {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.description.clamped {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.read-more {
|
||||
display: inline-block;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
color: var(--primary);
|
||||
font-size: var(--font-sm);
|
||||
font-weight: var(--weight-medium);
|
||||
cursor: pointer;
|
||||
margin: var(--space-1) 0 var(--space-3);
|
||||
}
|
||||
|
||||
.cta-bar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: var(--z-sticky);
|
||||
display: block;
|
||||
padding: var(--space-3);
|
||||
padding-left: calc(var(--space-3) + var(--safe-left));
|
||||
padding-right: calc(var(--space-3) + var(--safe-right));
|
||||
padding-bottom: calc(var(--space-3) + var(--safe-bottom));
|
||||
background: var(--bg);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.cta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
min-height: 48px;
|
||||
padding: 0 var(--space-4);
|
||||
background: var(--primary);
|
||||
color: var(--primary-contrast);
|
||||
border-radius: var(--radius-md);
|
||||
text-decoration: none;
|
||||
font-weight: var(--weight-semibold);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.cta:hover {
|
||||
background: var(--primary-hover);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.overflow-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.overflow-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-1);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
text-decoration: none;
|
||||
font-size: var(--font-base);
|
||||
min-height: 48px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.overflow-item:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.overflow-item:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user