Files
mediahive/frontend/src/components/CollageHero.vue
T

1112 lines
28 KiB
Vue

<template>
<section class="collage-hero" @keydown="handleKeyDown">
<!-- Diagonal collage grid -->
<div class="collage-grid">
<template v-for="(item, index) in collageItems" :key="item.id">
<a
:ref="(el: unknown) => setItemRef(el as HTMLElement | null, index)"
class="collage-item"
:class="[
`collage-item-${index}`,
{
'collage-featured': index === 0,
'collage-item-top-row': isTopRowItem(index),
'nav-focused': focusedIndex === index,
'collage-hidden': !isItemVisible(index),
},
]"
v-bind="getItemAttrs(index)"
:href="getItemHref(item)"
@click="handleItemClick($event, item)"
@focus="focusedIndex = index"
>
<!-- Featured item with hexagonal clip -->
<div v-if="index === 0" class="hex-showcase">
<img
v-if="getImageUrl(item)"
:src="getImageUrl(item)!"
:alt="item.title || 'Unknown'"
class="hex-clip"
/>
</div>
<!-- SVG focus outline for big hex -->
<svg
v-if="index === 0"
class="hex-focus-outline hex-focus-big"
viewBox="0 0 130 100"
preserveAspectRatio="none"
>
<polygon
points="21.67,0 21.67,25 0,37.5 0,62.5 21.67,75 21.67,100 108.33,100 108.33,75 130,62.5 130,37.5 108.33,25 108.33,0"
/>
</svg>
<!-- Non-featured items: image, video, or placeholder -->
<img
v-if="index !== 0 && getImageUrl(item)"
:src="getImageUrl(item)!"
:alt="item.title || 'Unknown'"
class="collage-media"
/>
<video
v-if="index !== 0 && !getImageUrl(item) && getVideoSources(item).length > 0"
class="collage-media"
loop
muted
playsinline
>
<source
v-for="source in getVideoSources(item)"
:key="source.src"
:src="source.src"
:type="source.type"
:codecs="source.codecs"
/>
</video>
<div
v-if="index !== 0 && !getImageUrl(item) && getVideoSources(item).length === 0"
class="collage-placeholder"
>
<span class="placeholder-title">{{ item.title }}</span>
</div>
<div class="collage-item-overlay"></div>
<!-- SVG focus outline for small hex items -->
<svg
v-if="index !== 0 && index !== 2 && !isTopRowItem(index)"
class="hex-focus-outline hex-focus-small"
viewBox="0 0 86.6 100"
preserveAspectRatio="none"
>
<polygon points="43.3,0 86.6,25 86.6,75 43.3,100 0,75 0,25" />
</svg>
<!-- Item 2 uses a custom flat-bottom hex outline to match its clip-path -->
<svg
v-if="index === 2"
class="hex-focus-outline hex-focus-small"
viewBox="0 0 86.6 100"
preserveAspectRatio="none"
>
<polygon points="43.3,0 86.6,33.333 86.6,100 0,100 0,33.333" />
</svg>
<!-- Top-row items use a custom flat-top hex outline to match their clip-path -->
<svg
v-if="isTopRowItem(index)"
class="hex-focus-outline hex-focus-small"
viewBox="0 0 86.6 100"
preserveAspectRatio="none"
>
<polygon points="86.6,0 86.6,66.667 43.3,100 0,66.667 0,0" />
</svg>
<div class="collage-item-info" v-if="index === 0">
<h1 class="collage-title">{{ item.title }}</h1>
<div class="collage-meta">
<span v-if="item.year" class="meta-year">{{ item.year }}</span>
<span v-if="getRating(item)" class="meta-rating" :class="getRatingClass(item)">
{{ getRating(item)?.toFixed(1) }}
</span>
<span v-if="getResolution(item)" class="meta-quality">{{ getResolution(item) }}</span>
</div>
<p v-if="getOverview(item)" class="collage-overview">{{ getOverview(item) }}</p>
</div>
<div class="collage-item-hover" v-else>
<span class="hover-title">{{ item.title }}</span>
<span v-if="getRating(item)" class="hover-rating"
> {{ getRating(item)?.toFixed(1) }}</span
>
</div>
</a>
</template>
</div>
</section>
</template>
<script setup lang="ts">
import { computed, ref, onMounted, onUnmounted, nextTick, watch } from "vue"
import type { MediaItem, Movie, Series } from "../types"
import { getCoverUrl, getVideoPreviewUrl, getVideoSourceAttributes, isVideoPath } from "../api"
import { navAttrs } from "../composables/useKeyboardNavigation"
const focusedIndex = ref<number | null>(null)
const itemRefs = ref<(HTMLElement | null)[]>([])
// Track last row when on col=0 (big image) for sideways navigation
const lastRow = ref(1)
// Reset lastRow when entering the hero from outside (via global nav)
watch(focusedIndex, (newVal, oldVal) => {
if (newVal === 0 && oldVal === null) {
// Entering big image from outside hero - reset to row 1
lastRow.value = 1
}
})
// Track which items are visible (at least 75% in viewport)
const visibleItems = ref<Set<number>>(new Set())
function setItemRef(el: HTMLElement | null, index: number) {
itemRefs.value[index] = el
}
// Check if element is at least 75% visible horizontally
function isElementVisible(el: HTMLElement | null): boolean {
if (!el) return false
const rect = el.getBoundingClientRect()
const viewportWidth = window.innerWidth
// Calculate how much of the element is visible
const visibleLeft = Math.max(0, rect.left)
const visibleRight = Math.min(viewportWidth, rect.right)
const visibleWidth = Math.max(0, visibleRight - visibleLeft)
const visibleRatio = visibleWidth / rect.width
return visibleRatio >= 0.75
}
function updateVisibility() {
const newVisible = new Set<number>()
for (let i = 0; i < itemRefs.value.length; i++) {
// Always include items 0, 1, 2 (big image and left side)
if (i <= 2 || isElementVisible(itemRefs.value[i])) {
newVisible.add(i)
}
}
visibleItems.value = newVisible
}
onMounted(() => {
window.addEventListener("resize", updateVisibility)
document.addEventListener("focusin", handleDocumentFocusIn)
// Initial visibility check after render
nextTick(() => {
updateVisibility()
})
})
function cleanupHeroVideos() {
// Find all video elements inside the hero and explicitly release them
const videos = document.querySelectorAll<HTMLVideoElement>(".collage-hero video")
videos.forEach((video) => {
video.pause()
video.src = ""
video.load()
})
}
onUnmounted(() => {
window.removeEventListener("resize", updateVisibility)
document.removeEventListener("focusin", handleDocumentFocusIn)
cleanupHeroVideos()
})
function clearHeroFocus() {
focusedIndex.value = null
}
function handleDocumentFocusIn(event: FocusEvent) {
const target = event.target as HTMLElement | null
if (!target?.closest(".collage-hero")) {
clearHeroFocus()
}
}
// Coordinate system for navigation based on actual CSS positioning:
// Visual layout (honeycomb stagger, but sequential cols for navigation):
// top=-12.5%: [1] [3] [6] [9] [12] [15] <- row 0
// top=25%: [0] [5] [8] [11] [14] <- row 1
// top=62.5%: [2] [4] [7] [10] [13] [16] <- row 2
//
// Navigation columns (no gaps - sequential numbering):
// col -1: left side items (1, 2)
// col 0: big image (0)
// col 1: first right column (3, 5, 4)
// col 2: second right column (6, 8, 7)
// col 3: third right column (9, 11, 10)
// col 4: fourth right column (12, 14, 13)
// col 5: fifth right column (15, 16)
interface NavCoord {
row: number
col: number
}
const coordMap: Record<number, NavCoord> = {
0: { row: 1, col: 0 }, // Big image
// Left side (col -1)
1: { row: 0, col: -1 }, // top left
2: { row: 2, col: -1 }, // bottom left
// Right side - sequential columns
3: { row: 0, col: 1 },
4: { row: 2, col: 1 },
5: { row: 1, col: 1 },
6: { row: 0, col: 2 },
7: { row: 2, col: 2 },
8: { row: 1, col: 2 },
9: { row: 0, col: 3 },
10: { row: 2, col: 3 },
11: { row: 1, col: 3 },
12: { row: 0, col: 4 },
13: { row: 2, col: 4 },
14: { row: 1, col: 4 },
15: { row: 0, col: 5 },
16: { row: 2, col: 5 },
17: { row: 1, col: 5 },
18: { row: 0, col: 6 },
19: { row: 2, col: 6 },
20: { row: 1, col: 6 },
21: { row: 0, col: 7 },
22: { row: 2, col: 7 },
23: { row: 1, col: 7 },
24: { row: 0, col: 8 },
25: { row: 2, col: 8 },
26: { row: 1, col: 8 },
}
function isTopRowItem(index: number): boolean {
const coord = coordMap[index]
return index !== 0 && coord?.row === 0
}
// Reverse lookup: find item index at given coordinates
function findItemAt(row: number, col: number): number | null {
for (const [idx, coord] of Object.entries(coordMap)) {
if (coord.row === row && coord.col === col) return parseInt(idx)
}
return null
}
// Find nearest item in a direction from current position
function findNext(currentIdx: number, direction: "up" | "down" | "left" | "right"): number | null {
const current = coordMap[currentIdx]
if (!current) return null
// Use lastRow for big image vertical navigation
const effectiveRow = current.col === 0 ? lastRow.value : current.row
if (direction === "left") {
// Moving left: decrease col
const targetCol = current.col - 1
if (current.col === 0) {
// From big image, go to col -1 if it exists for the effective row
// Row 1 has no col -1, so stay on big image (or could wrap to last item)
const leftItem = findItemAt(effectiveRow, -1)
if (leftItem !== null) {
return leftItem
}
// Row 1 has no left item - fall back to top-left tile so left side stays reachable
const topLeftItem = findItemAt(0, -1)
if (topLeftItem !== null) {
lastRow.value = 0
return topLeftItem
}
// No fallback available - stay put
return 0
}
if (targetCol < -1) {
// Already at col -1, can't go further left - stay put
return currentIdx
}
if (targetCol === 0) {
// Moving to big image - remember current row
lastRow.value = current.row
return 0
}
// Find item at same row, col-1
return findItemAt(current.row, targetCol)
}
if (direction === "right") {
// Moving right: increase col
const targetCol = current.col + 1
if (current.col === 0) {
// From big image, go to col 1 at remembered row
return findItemAt(lastRow.value, 1)
}
if (current.col === -1) {
// From left column, go to big image
lastRow.value = current.row
return 0
}
// Find item at same row, col+1, but only if it's visible
const nextItem = findItemAt(current.row, targetCol)
if (nextItem !== null && visibleItems.value.has(nextItem)) {
return nextItem
}
// No more visible items to the right - stay put
return currentIdx
}
// Helper to check if item at row/col is visible
const isItemVisibleAt = (row: number, col: number) => {
const item = findItemAt(row, col)
return item !== null && visibleItems.value.has(item)
}
if (direction === "up") {
const targetRow = effectiveRow - 1
if (targetRow < 0) return null // Exit up
if (current.col === 0) {
// Big image: up/down always exits the hero section
return null
}
// Check if target row item at same col is visible
if (!isItemVisibleAt(targetRow, current.col)) {
// Skip to row above if middle row is not visible at this column
if (targetRow === 1 && isItemVisibleAt(0, current.col)) {
const item = findItemAt(0, current.col)
if (item !== null) return item
}
return currentIdx // Stay put
}
// Find item at row-1, same col (or nearest)
let item = findItemAt(targetRow, current.col)
if (item !== null) return item
// Try to find nearest col in target row
for (let c = current.col; c >= -1; c--) {
item = findItemAt(targetRow, c)
if (item !== null && isItemVisibleAt(targetRow, c)) return item
}
for (let c = current.col + 1; c <= 10; c++) {
item = findItemAt(targetRow, c)
if (item !== null && isItemVisibleAt(targetRow, c)) return item
}
return null
}
if (direction === "down") {
const targetRow = effectiveRow + 1
if (targetRow > 2) return null // Exit down
if (current.col === 0) {
// Big image: up/down always exits the hero section
return null
}
// Check if target row item at same col is visible
if (!isItemVisibleAt(targetRow, current.col)) {
// Skip to row below if middle row is not visible at this column
if (targetRow === 1 && isItemVisibleAt(2, current.col)) {
const item = findItemAt(2, current.col)
if (item !== null) return item
}
return currentIdx // Stay put
}
// Find item at row+1, same col (or nearest)
let item = findItemAt(targetRow, current.col)
if (item !== null) return item
// Try to find nearest col in target row
for (let c = current.col; c >= -1; c--) {
item = findItemAt(targetRow, c)
if (item !== null && isItemVisibleAt(targetRow, c)) return item
}
for (let c = current.col + 1; c <= 10; c++) {
item = findItemAt(targetRow, c)
if (item !== null && isItemVisibleAt(targetRow, c)) return item
}
return null
}
return null
}
function handleKeyDown(e: KeyboardEvent) {
// Only handle if focus is within this component
const target = e.target as HTMLElement
if (!target.closest(".collage-hero")) return
const direction = {
ArrowUp: "up",
ArrowDown: "down",
ArrowLeft: "left",
ArrowRight: "right",
}[e.key] as "up" | "down" | "left" | "right" | undefined
if (!direction) {
if (e.key === "Enter" && focusedIndex.value !== null) {
const item = collageItems.value[focusedIndex.value]
e.preventDefault()
e.stopPropagation()
if (item) activateItem(item)
}
return
}
const current = focusedIndex.value ?? 0
const next = findNext(current, direction)
if (next !== null && itemRefs.value[next]) {
e.preventDefault()
e.stopPropagation()
focusedIndex.value = next
itemRefs.value[next]?.focus({ preventScroll: true })
return
}
// Navigation is leaving this section; clear local highlight and let global handler continue.
clearHeroFocus()
// If next is null, let event bubble to global navigation
}
// Get attributes for an item - only item 0 participates in global nav as entry point
function getItemAttrs(index: number) {
if (index === 0) {
// The hero always enters through the featured item when moving into row 0.
return { ...navAttrs(0, index, 0) }
}
// All tiles participate in the global focus model so only one visual highlight exists
// and Enter/gamepad A targets the currently highlighted tile.
return { ...navAttrs(0, index) }
}
// Check if an item index should be visible based on its column and row
function isItemVisible(index: number): boolean {
// Always show the first few items (big image and left side)
if (index <= 2) return true
return visibleItems.value.has(index)
}
const props = defineProps<{
items: MediaItem[]
featuredItem?: MediaItem | null
}>()
const emit = defineEmits<{
select: [MediaItem]
}>()
// Max items we might ever need - use a generous constant
const maxItems = 27
// Get items for the collage based on visible columns
const collageItems = computed(() => {
const result: MediaItem[] = []
// Add featured item first
if (props.featuredItem) {
result.push(props.featuredItem)
}
// Add more items, avoiding duplicates, up to max needed
for (const item of props.items) {
if (result.length >= maxItems) break
if (!result.find((r) => r.id === item.id)) {
result.push(item)
}
}
return result
})
function getImageUrl(item: MediaItem): string | undefined {
const coverPath = item.cover_path
const backdropPath =
item.type === "movies"
? (item.data as Movie).backdrop_path
: (item.data as Series).backdrop_path
const imagePath = coverPath || backdropPath
// Check if the path is an image (not a video)
if (imagePath && /\.(webm|mp4|mkv|avi|mov)$/i.test(imagePath)) {
return undefined
}
return getCoverUrl(imagePath, item.root_id)
}
function getVideoSources(item: MediaItem): Array<{ src: string; type: string; codecs: string }> {
if (isVideoPath(item.cover_path)) {
return [
{
src: getVideoPreviewUrl(getCoverUrl(item.cover_path, item.root_id)),
...getVideoSourceAttributes(item.cover_path),
},
]
}
const showreelSourceSets = item.showreel_source_sets
if (showreelSourceSets && showreelSourceSets.length > 0) {
return showreelSourceSets[0]
.filter((path) => isVideoPath(path))
.map((path) => ({
src: getVideoPreviewUrl(getCoverUrl(path, item.root_id)),
...getVideoSourceAttributes(path),
}))
}
const showreel = item.showreel_images
if (showreel && showreel.length > 0) {
return showreel
.filter((path) => isVideoPath(path))
.map((path) => ({
src: getVideoPreviewUrl(getCoverUrl(path, item.root_id)),
...getVideoSourceAttributes(path),
}))
}
return []
}
function getRating(item: MediaItem): number | null {
if (item.type === "movies") {
return (item.data as Movie).info?.rating ?? null
}
return (item.data as Series).info?.rating ?? null
}
function getRatingClass(item: MediaItem): string {
const rating = getRating(item)
if (!rating) return ""
if (rating >= 7.5) return "rating-high"
if (rating >= 6) return "rating-medium"
return "rating-low"
}
function getResolution(item: MediaItem): string | null {
if (item.type === "movies") {
const movie = item.data as Movie
return Object.values(movie.files || {})[0]?.resolution ?? null
}
return null
}
function getOverview(item: MediaItem): string | null {
const overview =
item.type === "movies"
? (item.data as Movie).info?.overview
: (item.data as Series).info?.overview
if (!overview) return null
return overview.length > 150 ? overview.slice(0, 150) + "..." : overview
}
function getItemHref(item: MediaItem): string {
return `/${item.type}/${item.id}`
}
function activateItem(item: MediaItem) {
emit("select", item)
}
function handleItemClick(event: MouseEvent, item: MediaItem) {
// Let modified clicks navigate natively
if (
event.button !== 0 ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey ||
event.altKey
) {
return
}
event.preventDefault()
emit("select", item)
}
</script>
<style scoped>
.collage-hero {
position: relative;
height: 70vh;
min-height: 450px;
max-height: 600px;
overflow: visible;
background: var(--bg-primary);
--h: clamp(450px, 70vh, 600px);
--small-w: calc(0.433 * var(--h)); /* Small hex width = 0.866 * 50% of height */
--big-edge: calc(1.0833 * var(--h)); /* Big hex right edge = 1.3 * 0.8333 * height */
}
.collage-grid {
position: relative;
height: 100%;
}
.collage-item {
position: absolute;
cursor: pointer;
overflow: hidden;
transition:
transform 0.3s ease,
filter 0.3s ease,
opacity 0.3s ease,
visibility 0.3s ease;
text-decoration: none;
color: inherit;
}
/* Media (images and videos) fill the collage item */
.collage-media {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
object-position: center 30%;
transition:
transform 0.3s ease,
filter 0.3s ease,
opacity 0.3s ease,
visibility 0.3s ease;
}
/* Hidden items - keep in DOM for measurement but invisible */
.collage-item.collage-hidden {
opacity: 0;
visibility: hidden;
pointer-events: none;
}
html.mouse-active .collage-item:hover,
html:not(.mouse-active) .collage-item.nav-focused {
z-index: 10;
}
/* SVG focus outline styles */
.hex-focus-outline {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 5;
opacity: 0;
transition: opacity 0.2s ease;
}
.hex-focus-outline polygon {
fill: none;
stroke: rgba(255, 255, 255, 0.9);
stroke-width: 4;
vector-effect: non-scaling-stroke;
}
/* Show outline on hover and focus */
html.mouse-active .collage-item:hover .hex-focus-outline,
html:not(.mouse-active) .collage-item.nav-focused .hex-focus-outline {
opacity: 1;
}
/* Blinking animation for focus outline */
@keyframes hex-outline-blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
}
/* Brighter outline for keyboard focus */
html:not(.mouse-active) .collage-item.nav-focused .hex-focus-outline polygon {
stroke: #ffffff;
stroke-width: 5;
filter: drop-shadow(0 0 6px rgba(255, 255, 255, 0.8));
}
/* Blinking effect on hover/focus */
html.mouse-active .collage-item:hover .hex-focus-outline,
html:not(.mouse-active) .collage-item.nav-focused .hex-focus-outline {
animation: hex-outline-blink 1s ease-in-out infinite;
}
/* Small items: slightly smaller to create gaps */
.collage-item:not(.collage-featured) {
transform: scale(0.97);
}
/* Featured item: slightly smaller to create gaps */
.collage-featured {
transform: scale(0.99);
}
/* Featured item - left side */
.collage-item-0 {
left: calc(var(--small-w) * 0.5);
top: 0;
height: 100%;
aspect-ratio: 1.3 / 1;
clip-path: polygon(
16.67% 0%,
16.67% 25%,
0% 37.5%,
0% 62.5%,
16.67% 75%,
16.67% 100%,
83.33% 100%,
83.33% 75%,
100% 62.5%,
100% 37.5%,
83.33% 25%,
83.33% 0%
);
}
/* Hexagonal showcase for featured item */
.hex-showcase {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.hex-showcase .hex-clip {
height: 100%;
aspect-ratio: 1.3 / 1;
object-fit: cover;
object-position: center 30%;
clip-path: polygon(
16.67% 0%,
16.67% 25%,
0% 37.5%,
0% 62.5%,
16.67% 75%,
16.67% 100%,
83.33% 100%,
83.33% 75%,
100% 62.5%,
100% 37.5%,
83.33% 25%,
83.33% 0%
);
}
/* Small items: 50% height, aspect 0.866:1 (pointy-top regular hex) */
/* 3 rows with 25% overlap, shifted up 12.5% (25% of small height) */
/* Fill order: left side first (1-2), then right side nearest to big image (3+) */
.collage-item:not(.collage-featured) {
height: 50%;
aspect-ratio: 0.866 / 1;
clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%);
}
/* LEFT SIDE - positioned at left edge */
/* Row 1 (top) */
.collage-item-1 {
top: -12.5%;
left: 0;
}
/* Row 3 (bottom) */
.collage-item-2 {
top: 62.5%;
left: 0;
}
/* Keep top hex shape for left-bottom item, but flatten bottom edge to avoid overflow */
.collage-item.collage-item-2:not(.collage-featured) {
height: 37.5%;
width: var(--small-w);
clip-path: polygon(50% 0%, 100% 33.333%, 100% 100%, 0% 100%, 0% 33.333%);
}
/* Top-row items: keep side angles but flatten top edge to avoid top overflow */
.collage-item.collage-item-top-row:not(.collage-featured) {
top: 0;
height: 37.5%;
width: var(--small-w);
clip-path: polygon(100% 0%, 100% 66.667%, 50% 100%, 0% 66.667%, 0% 0%);
}
/* RIGHT SIDE - Column 0 (closest to big image) */
/* Row 1 (top): -12.5% */
.collage-item-3 {
top: -12.5%;
left: calc(var(--big-edge) + var(--small-w) * 0.5);
}
/* Row 3 (bottom): 62.5% */
.collage-item-4 {
top: 62.5%;
left: calc(var(--big-edge) + var(--small-w) * 0.5);
}
/* Row 2 (middle): 25%, offset 0.5 */
.collage-item-5 {
top: 25%;
left: calc(var(--big-edge) + var(--small-w) * 1);
}
/* RIGHT SIDE - Column 1 */
.collage-item-6 {
top: -12.5%;
left: calc(var(--big-edge) + var(--small-w) * 1.5);
}
.collage-item-7 {
top: 62.5%;
left: calc(var(--big-edge) + var(--small-w) * 1.5);
}
.collage-item-8 {
top: 25%;
left: calc(var(--big-edge) + var(--small-w) * 2);
}
/* RIGHT SIDE - Column 2 */
.collage-item-9 {
top: -12.5%;
left: calc(var(--big-edge) + var(--small-w) * 2.5);
}
.collage-item-10 {
top: 62.5%;
left: calc(var(--big-edge) + var(--small-w) * 2.5);
}
.collage-item-11 {
top: 25%;
left: calc(var(--big-edge) + var(--small-w) * 3);
}
/* RIGHT SIDE - Column 3 */
.collage-item-12 {
top: -12.5%;
left: calc(var(--big-edge) + var(--small-w) * 3.5);
}
.collage-item-13 {
top: 62.5%;
left: calc(var(--big-edge) + var(--small-w) * 3.5);
}
.collage-item-14 {
top: 25%;
left: calc(var(--big-edge) + var(--small-w) * 4);
}
/* RIGHT SIDE - Column 4 */
.collage-item-15 {
top: -12.5%;
left: calc(var(--big-edge) + var(--small-w) * 4.5);
}
.collage-item-16 {
top: 62.5%;
left: calc(var(--big-edge) + var(--small-w) * 4.5);
}
.collage-item-17 {
top: 25%;
left: calc(var(--big-edge) + var(--small-w) * 5);
}
/* RIGHT SIDE - Column 5 */
.collage-item-18 {
top: -12.5%;
left: calc(var(--big-edge) + var(--small-w) * 5.5);
}
.collage-item-19 {
top: 62.5%;
left: calc(var(--big-edge) + var(--small-w) * 5.5);
}
.collage-item-20 {
top: 25%;
left: calc(var(--big-edge) + var(--small-w) * 6);
}
/* RIGHT SIDE - Column 6 */
.collage-item-21 {
top: -12.5%;
left: calc(var(--big-edge) + var(--small-w) * 6.5);
}
.collage-item-22 {
top: 62.5%;
left: calc(var(--big-edge) + var(--small-w) * 6.5);
}
.collage-item-23 {
top: 25%;
left: calc(var(--big-edge) + var(--small-w) * 7);
}
/* RIGHT SIDE - Column 7 */
.collage-item-24 {
top: -12.5%;
left: calc(var(--big-edge) + var(--small-w) * 7.5);
}
.collage-item-25 {
top: 62.5%;
left: calc(var(--big-edge) + var(--small-w) * 7.5);
}
.collage-item-26 {
top: 25%;
left: calc(var(--big-edge) + var(--small-w) * 8);
}
.collage-item-overlay {
position: absolute;
inset: 0;
background: transparent;
pointer-events: none;
}
/* Placeholder for missing images */
.collage-placeholder {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.collage-placeholder::before {
content: "";
position: absolute;
inset: 0;
background: #e50914;
}
.placeholder-title {
position: relative;
color: white;
font-size: 1rem;
font-weight: bold;
text-align: center;
padding: 10px;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.8);
z-index: 1;
}
/* Featured item content */
.collage-item-info {
position: absolute;
bottom: 15%;
left: 50%;
transform: translateX(-50%);
width: 60%;
text-align: center;
z-index: 2;
color: white;
}
.collage-title {
font-size: clamp(1.8rem, 4vw, 3rem);
font-weight: 700;
margin-bottom: 12px;
text-shadow: 2px 2px 8px rgba(0, 0, 0, 0.8);
line-height: 1.1;
}
.collage-meta {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.meta-year {
color: rgba(255, 255, 255, 0.8);
font-size: 0.95rem;
}
.meta-rating {
font-weight: 600;
padding: 3px 8px;
border-radius: 4px;
background: rgba(0, 0, 0, 0.6);
font-size: 0.85rem;
}
.rating-high {
color: #46d369;
}
.rating-medium {
color: #f9a825;
}
.rating-low {
color: #e53935;
}
.meta-quality {
background: rgba(255, 255, 255, 0.15);
padding: 3px 8px;
border-radius: 4px;
font-size: 0.8rem;
font-weight: 600;
}
.collage-overview {
color: rgba(255, 255, 255, 0.85);
font-size: 0.9rem;
line-height: 1.4;
margin-bottom: 16px;
max-width: 450px;
}
/* Hover info for non-featured items */
.collage-item-hover {
position: absolute;
bottom: 40%;
left: 50%;
transform: translateX(-50%) translateY(10px);
padding: 8px 16px;
background: rgba(0, 0, 0, 0.85);
border-radius: 4px;
opacity: 0;
transition:
opacity 0.3s ease,
transform 0.3s ease;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
white-space: nowrap;
}
html.mouse-active .collage-item:not(.collage-featured):hover .collage-item-hover,
html:not(.mouse-active) .collage-item:not(.collage-featured).nav-focused .collage-item-hover {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
.hover-title {
font-size: 0.85rem;
font-weight: 600;
color: white;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.hover-rating {
font-size: 0.75rem;
color: #46d369;
}
/* Responsive adjustments */
@media (max-width: 900px) {
.collage-hero {
height: 50vh;
min-height: 350px;
--h: clamp(350px, 50vh, 600px);
}
.collage-item-info {
right: 20%;
bottom: 10%;
}
.collage-overview {
display: none;
}
}
</style>