Skip to content

Commit 03bbab7

Browse files
committed
pass at hovering posters
1 parent d2ba8c6 commit 03bbab7

File tree

3 files changed

+106
-35
lines changed

3 files changed

+106
-35
lines changed

web/src/components/AppLayoutHeader.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ onUnmounted(() => {
102102

103103
<template>
104104
<header
105-
class="z-10 fixed w-full flex h-16 shrink-0 items-center gap-2 border-b bg-background transition-[width,height] ease-linear"
105+
class="z-20 fixed w-full flex h-16 shrink-0 items-center gap-2 border-b bg-background transition-[width,height] ease-linear"
106106
>
107107
<div class="flex items-center gap-2 px-4">
108108
<SidebarTrigger class="-ml-1" />

web/src/components/poster/Poster.vue

Lines changed: 104 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,39 @@
11
<template>
2-
<component :is="to ? 'router-link' : 'div'" :to="to" class="poster-wrap" :class="sizeClass">
3-
<img
4-
class="poster"
5-
:class="{ 'is-loaded': !isLoading, 'cursor-pointer': clickable }"
6-
:src="posterPath"
7-
:alt="item.title"
8-
loading="lazy"
9-
decoding="async"
10-
@load="onLoad"
11-
@error="onError"
12-
/>
13-
<Skeleton v-if="isLoading" class="poster-skeleton" />
14-
<!-- Show download status badge when downloading, otherwise show library badge -->
15-
<div v-if="isDownloading" class="status-badge download-badge">
16-
<Loader2 class="size-4 animate-spin" aria-hidden="true" />
17-
<span>Downloading</span>
2+
<component
3+
:is="to ? 'router-link' : 'div'"
4+
:to="to"
5+
class="poster-outer"
6+
:class="sizeClass"
7+
>
8+
<!-- Inner container: scales on hover, clips image to rounded corners -->
9+
<div class="poster-inner">
10+
<img
11+
class="poster"
12+
:class="{ 'is-loaded': !isLoading, 'cursor-pointer': clickable }"
13+
:src="posterPath"
14+
:alt="item.title"
15+
loading="lazy"
16+
decoding="async"
17+
@load="onLoad"
18+
@error="onError"
19+
/>
20+
<Skeleton v-if="isLoading" class="poster-skeleton" />
21+
<!-- Status badges stay with the image -->
22+
<div v-if="isDownloading" class="status-badge download-badge">
23+
<Loader2 class="size-4 animate-spin" aria-hidden="true" />
24+
<span>Downloading</span>
25+
</div>
26+
<div v-else-if="showLibraryBadge" class="status-badge library-badge">
27+
<CheckCircle2 class="size-4" aria-hidden="true" />
28+
<span>In library</span>
29+
</div>
1830
</div>
19-
<div v-else-if="showLibraryBadge" class="status-badge library-badge">
20-
<CheckCircle2 class="size-4" aria-hidden="true" />
21-
<span>In library</span>
31+
<!-- Overlay: sibling to inner, not clipped by it -->
32+
<div class="poster-overlay">
33+
<div class="poster-info">
34+
<span class="poster-title">{{ item.title }}</span>
35+
<span class="poster-year">{{ item.year }}</span>
36+
</div>
2237
</div>
2338
</component>
2439
</template>
@@ -33,7 +48,6 @@ import {
3348
type ModelMovieDetail,
3449
type ModelMovieRail,
3550
type ModelSeriesDetail,
36-
type ModelSeriesRail,
3751
} from '@/client/types.gen'
3852
3953
/**
@@ -71,7 +85,7 @@ type PosterSize = keyof typeof POSTER_SIZES
7185
7286
const props = withDefaults(
7387
defineProps<{
74-
item: ModelMovieDetail | ModelSeriesDetail | ModelHydratedTitle | ModelMovieRail | ModelSeriesRail | ModelLibraryItem
88+
item: ModelMovieDetail | ModelSeriesDetail | ModelHydratedTitle | ModelMovieRail | ModelLibraryItem
7589
size?: PosterSize
7690
to?: { path: string } | string
7791
clickable?: boolean
@@ -118,18 +132,32 @@ const onError = () => {
118132
</script>
119133

120134
<style scoped>
121-
.poster-wrap {
135+
/* Outer container: handles sizing, is the clickable link, no clipping */
136+
.poster-outer {
122137
display: block;
123138
width: 100%;
124-
aspect-ratio: 2 / 3; /* common movie/TV poster ratio */
139+
aspect-ratio: 2 / 3;
125140
position: relative;
126-
border-radius: 8px !important;
127-
overflow: hidden;
128-
background-color: #111827; /* neutral placeholder while loading */
129141
text-decoration: none;
130142
color: inherit;
131143
}
132144
145+
/* Inner container: scales on hover, clips image to rounded corners */
146+
.poster-inner {
147+
position: absolute;
148+
inset: 0;
149+
border-radius: 8px;
150+
overflow: hidden;
151+
background-color: #111827;
152+
transition: transform 0.2s ease, box-shadow 0.2s ease;
153+
}
154+
155+
.poster-outer:hover .poster-inner {
156+
transform: scale(1.05);
157+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
158+
z-index: 10;
159+
}
160+
133161
.poster {
134162
position: absolute;
135163
inset: 0;
@@ -138,7 +166,6 @@ const onError = () => {
138166
object-fit: cover;
139167
opacity: 0;
140168
transition: opacity 150ms ease;
141-
border-radius: 8px !important;
142169
}
143170
144171
.poster.is-loaded {
@@ -168,36 +195,80 @@ const onError = () => {
168195
}
169196
170197
.library-badge svg {
171-
color: #22c55e; /* emerald-500 */
198+
color: #22c55e;
172199
}
173200
174201
.download-badge svg {
175-
color: #3b82f6; /* blue-500 */
202+
color: #3b82f6;
176203
}
177204
178205
.poster-skeleton {
179206
position: absolute;
180207
inset: 0;
181208
width: 100%;
182209
height: 100%;
183-
border-radius: 8px !important;
184210
}
185211
186212
.poster--sm {
187-
--poster-width: 8rem; /* ~128px */
213+
--poster-width: 8rem;
188214
width: var(--poster-width);
189215
max-width: var(--poster-width);
190216
}
191217
192218
.poster--md {
193-
--poster-width: 11rem; /* ~176px */
219+
--poster-width: 11rem;
194220
width: var(--poster-width);
195221
max-width: var(--poster-width);
196222
}
197223
198224
.poster--lg {
199-
--poster-width: 16rem; /* ~256px */
225+
--poster-width: 16rem;
200226
width: var(--poster-width);
201227
max-width: var(--poster-width);
202228
}
229+
230+
/* Overlay: sibling to inner, not clipped by it */
231+
.poster-overlay {
232+
position: absolute;
233+
inset: 0;
234+
border-radius: 8px;
235+
background: linear-gradient(to top, rgba(0, 0, 0, 0.8) 0%, rgba(0, 0, 0, 0.4) 40%, transparent 100%);
236+
opacity: 0;
237+
transition: opacity 0.2s ease, transform 0.2s ease;
238+
pointer-events: none;
239+
display: flex;
240+
flex-direction: column;
241+
justify-content: flex-end;
242+
padding: 0.75rem;
243+
}
244+
245+
.poster-outer:hover .poster-overlay {
246+
opacity: 1;
247+
transform: scale(1.05);
248+
z-index: 20;
249+
}
250+
251+
/* Info text at bottom */
252+
.poster-info {
253+
display: flex;
254+
flex-direction: column;
255+
gap: 0.125rem;
256+
}
257+
258+
.poster-title {
259+
font-weight: 600;
260+
font-size: 0.875rem;
261+
line-height: 1.2;
262+
color: white;
263+
display: -webkit-box;
264+
-webkit-line-clamp: 2;
265+
line-clamp: 2;
266+
-webkit-box-orient: vertical;
267+
overflow: hidden;
268+
}
269+
270+
.poster-year {
271+
font-size: 0.75rem;
272+
color: rgba(255, 255, 255, 0.7);
273+
}
203274
</style>

web/src/components/rails/Rail.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
<div class="rail-body relative">
2828
<div
2929
ref="scroller"
30-
class="scroller flex gap-3 overflow-x-auto overflow-y-hidden pb-4"
30+
class="scroller flex gap-3 overflow-x-auto overflow-y-hidden pt-4 pb-8 px-4 -mx-4"
3131
@scroll="onScroll"
3232
>
3333
<slot />

0 commit comments

Comments
 (0)