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:
MechaCat02
2026-06-07 11:07:30 +02:00
parent be6b974150
commit 780632bee3
6 changed files with 741 additions and 5 deletions

View File

@@ -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

View File

@@ -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>