fix: restructure video as global backdrop for iOS notch extension
All checks were successful
build-website / build (push) Successful in 1m31s
All checks were successful
build-website / build (push) Successful in 1m31s
- Created VideoBackdrop component as global sibling layer - Video now sits at app level behind all content (z-index: 0) - Hero section made transparent to reveal video backdrop - Video extends into notch with negative top positioning - Content remains in safe areas with proper padding - Removed video container from HeroSection component - Added app.vue to manage global layout structure This separation allows the video to truly extend edge-to-edge including under the iOS notch/Dynamic Island while keeping all interactive content within safe areas.
This commit is contained in:
33
apps/website/app.vue
Normal file
33
apps/website/app.vue
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- Global video backdrop - sits behind all content -->
|
||||||
|
<VideoBackdrop />
|
||||||
|
|
||||||
|
<!-- Navigation -->
|
||||||
|
<AppNavbar />
|
||||||
|
|
||||||
|
<!-- Page content -->
|
||||||
|
<NuxtPage />
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<AppFooter />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// The VideoBackdrop component handles the full-viewport video
|
||||||
|
// that extends under the iOS notch
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Ensure the app container doesn't clip the video */
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -410,6 +410,8 @@ html {
|
|||||||
/* Hero Section */
|
/* Hero Section */
|
||||||
.hero-voyage {
|
.hero-voyage {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
/* Hero is now transparent - video handled by VideoBackdrop component */
|
||||||
|
background: transparent;
|
||||||
height: 100vh; /* Fallback for browsers that don't support newer units */
|
height: 100vh; /* Fallback for browsers that don't support newer units */
|
||||||
height: -webkit-fill-available; /* iOS Safari - fills available space */
|
height: -webkit-fill-available; /* iOS Safari - fills available space */
|
||||||
height: 100dvh; /* Dynamic viewport height */
|
height: 100dvh; /* Dynamic viewport height */
|
||||||
@@ -1674,6 +1676,9 @@ html {
|
|||||||
/* Responsive */
|
/* Responsive */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.hero-voyage {
|
.hero-voyage {
|
||||||
|
/* Hero is now transparent - video handled by VideoBackdrop component */
|
||||||
|
position: relative;
|
||||||
|
background: transparent;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
/* iOS-specific height handling */
|
/* iOS-specific height handling */
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
@@ -1682,24 +1687,11 @@ html {
|
|||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
min-height: -webkit-fill-available;
|
min-height: -webkit-fill-available;
|
||||||
min-height: 100dvh;
|
min-height: 100dvh;
|
||||||
/* Video will extend under notch - no padding-top here */
|
|
||||||
padding-bottom: env(safe-area-inset-bottom);
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
overflow-x: hidden !important;
|
overflow-x: hidden !important;
|
||||||
max-width: 100vw !important;
|
max-width: 100vw !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Make video container fixed on mobile to fill entire viewport including notch */
|
|
||||||
.hero-video-container {
|
|
||||||
position: fixed !important;
|
|
||||||
inset: 0;
|
|
||||||
width: 100vw;
|
|
||||||
height: 100vh;
|
|
||||||
height: -webkit-fill-available;
|
|
||||||
height: 100dvh;
|
|
||||||
z-index: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-content {
|
.hero-content {
|
||||||
/* Adjust padding to account for safe areas on all sides */
|
/* Adjust padding to account for safe areas on all sides */
|
||||||
padding: clamp(1rem, 4vw, 2rem);
|
padding: clamp(1rem, 4vw, 2rem);
|
||||||
|
|||||||
@@ -1,36 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="hero-voyage" id="heroSection">
|
<section class="hero-voyage" id="heroSection">
|
||||||
<div ref="videoContainer" class="hero-video-container">
|
<!-- Video is now handled by VideoBackdrop component -->
|
||||||
<!-- White overlay that fades out when video is ready -->
|
<div class="hero-content">
|
||||||
<div
|
|
||||||
class="video-white-overlay"
|
|
||||||
:class="{ 'fade-out': videoLoaded }"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<video
|
|
||||||
ref="videoElement"
|
|
||||||
autoplay
|
|
||||||
loop
|
|
||||||
muted
|
|
||||||
playsinline
|
|
||||||
preload="auto"
|
|
||||||
class="hero-video"
|
|
||||||
:class="{ 'video-loaded': videoLoaded }"
|
|
||||||
@loadeddata="handleVideoLoaded"
|
|
||||||
@canplaythrough="handleVideoLoaded"
|
|
||||||
@play="handleVideoLoaded"
|
|
||||||
>
|
|
||||||
<source
|
|
||||||
src="https://videos.pexels.com/video-files/3571264/3571264-uhd_2560_1440_30fps.mp4"
|
|
||||||
type="video/mp4"
|
|
||||||
>
|
|
||||||
</video>
|
|
||||||
|
|
||||||
<div class="hero-overlay gradient-warm" />
|
|
||||||
<div class="hero-overlay gradient-depth" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="hero-content" :class="{ 'is-ready': videoLoaded }">
|
|
||||||
<div class="hero-logo animate-fade-in">
|
<div class="hero-logo animate-fade-in">
|
||||||
<img src="/HARBOR-SMITH-white.png" alt="Harbor Smith" width="400" height="250">
|
<img src="/HARBOR-SMITH-white.png" alt="Harbor Smith" width="400" height="250">
|
||||||
</div>
|
</div>
|
||||||
@@ -65,25 +36,16 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useParallax } from '~/composables/useParallax'
|
|
||||||
import { useIntersectionAnimations } from '~/composables/useIntersectionAnimations'
|
import { useIntersectionAnimations } from '~/composables/useIntersectionAnimations'
|
||||||
import { useRipple } from '~/composables/useRipple'
|
import { useRipple } from '~/composables/useRipple'
|
||||||
import { useSmoothScroll } from '~/composables/useSmoothScroll'
|
import { useSmoothScroll } from '~/composables/useSmoothScroll'
|
||||||
|
|
||||||
const { scrollToElement } = useSmoothScroll()
|
const { scrollToElement } = useSmoothScroll()
|
||||||
const videoLoaded = ref(false)
|
|
||||||
const videoContainer = ref<HTMLElement | null>(null)
|
|
||||||
const videoElement = ref<HTMLVideoElement | null>(null)
|
|
||||||
const animatedCount = ref(0)
|
const animatedCount = ref(0)
|
||||||
|
|
||||||
useParallax(videoContainer, 0.5)
|
|
||||||
useIntersectionAnimations()
|
useIntersectionAnimations()
|
||||||
useRipple()
|
useRipple()
|
||||||
|
|
||||||
const handleVideoLoaded = () => {
|
|
||||||
videoLoaded.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlePhoneClick = () => {
|
const handlePhoneClick = () => {
|
||||||
window.location.href = 'tel:510-701-2535'
|
window.location.href = 'tel:510-701-2535'
|
||||||
}
|
}
|
||||||
@@ -96,26 +58,6 @@ const handleScrollToExplore = () => {
|
|||||||
scrollToElement('#services', 800) // Constant-speed scroll to services
|
scrollToElement('#services', 800) // Constant-speed scroll to services
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleVideoVisibility = () => {
|
|
||||||
if (!videoElement.value) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const observer = new IntersectionObserver((entries) => {
|
|
||||||
entries.forEach((entry) => {
|
|
||||||
if (entry.isIntersecting) {
|
|
||||||
videoElement.value?.play()
|
|
||||||
} else {
|
|
||||||
videoElement.value?.pause()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}, { threshold: 0.25 })
|
|
||||||
|
|
||||||
observer.observe(videoElement.value)
|
|
||||||
|
|
||||||
return () => observer.disconnect()
|
|
||||||
}
|
|
||||||
|
|
||||||
const initSmoothScroll = () => {
|
const initSmoothScroll = () => {
|
||||||
document.querySelectorAll('a[href^="#"]').forEach((anchor) => {
|
document.querySelectorAll('a[href^="#"]').forEach((anchor) => {
|
||||||
anchor.addEventListener('click', (event) => {
|
anchor.addEventListener('click', (event) => {
|
||||||
@@ -156,53 +98,21 @@ const animateCount = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
handleVideoVisibility()
|
|
||||||
initSmoothScroll()
|
initSmoothScroll()
|
||||||
animateCount()
|
animateCount()
|
||||||
|
|
||||||
// Fallback: ensure video becomes visible after a delay
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!videoLoaded.value) {
|
|
||||||
videoLoaded.value = true
|
|
||||||
}
|
|
||||||
}, 1500)
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* Video fade-in transition to prevent flash */
|
/* Hero section is now transparent to show video backdrop behind it */
|
||||||
.hero-video {
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 1s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-video.video-loaded {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* White overlay for smooth video loading transition */
|
|
||||||
.video-white-overlay {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background: linear-gradient(180deg, #ffffff 0%, #f8f9fa 100%);
|
|
||||||
z-index: 2;
|
|
||||||
opacity: 1;
|
|
||||||
transition: opacity 800ms ease-out;
|
|
||||||
will-change: opacity;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-white-overlay.fade-out {
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Container to prevent horizontal overflow */
|
|
||||||
.hero-voyage {
|
.hero-voyage {
|
||||||
|
position: relative;
|
||||||
|
background: transparent;
|
||||||
overflow-x: hidden !important;
|
overflow-x: hidden !important;
|
||||||
max-width: 100vw !important;
|
max-width: 100vw !important;
|
||||||
|
/* Ensure hero takes up full viewport height */
|
||||||
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Scroll to explore indicator */
|
/* Scroll to explore indicator */
|
||||||
|
|||||||
189
apps/website/components/VideoBackdrop.vue
Normal file
189
apps/website/components/VideoBackdrop.vue
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
<template>
|
||||||
|
<div class="video-backdrop" v-if="isHeroPage">
|
||||||
|
<div
|
||||||
|
ref="videoContainer"
|
||||||
|
class="video-layer"
|
||||||
|
:class="{ 'video-loaded': videoLoaded }"
|
||||||
|
>
|
||||||
|
<!-- White overlay that fades out when video is ready -->
|
||||||
|
<div
|
||||||
|
class="video-white-overlay"
|
||||||
|
:class="{ 'fade-out': videoLoaded }"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<video
|
||||||
|
ref="videoElement"
|
||||||
|
autoplay
|
||||||
|
loop
|
||||||
|
muted
|
||||||
|
playsinline
|
||||||
|
preload="auto"
|
||||||
|
class="hero-video"
|
||||||
|
@loadeddata="handleVideoLoaded"
|
||||||
|
@canplaythrough="handleVideoLoaded"
|
||||||
|
@play="handleVideoLoaded"
|
||||||
|
>
|
||||||
|
<source
|
||||||
|
src="https://videos.pexels.com/video-files/3571264/3571264-uhd_2560_1440_30fps.mp4"
|
||||||
|
type="video/mp4"
|
||||||
|
>
|
||||||
|
</video>
|
||||||
|
|
||||||
|
<div class="hero-overlay gradient-warm" />
|
||||||
|
<div class="hero-overlay gradient-depth" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const videoLoaded = ref(false)
|
||||||
|
const videoElement = ref<HTMLVideoElement | null>(null)
|
||||||
|
const videoContainer = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
// Only show on home page
|
||||||
|
const isHeroPage = computed(() => route.path === '/')
|
||||||
|
|
||||||
|
const handleVideoLoaded = () => {
|
||||||
|
videoLoaded.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleVideoVisibility = () => {
|
||||||
|
if (!videoElement.value) return
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver((entries) => {
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
videoElement.value?.play()
|
||||||
|
} else {
|
||||||
|
videoElement.value?.pause()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, { threshold: 0.25 })
|
||||||
|
|
||||||
|
observer.observe(videoElement.value)
|
||||||
|
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
handleVideoVisibility()
|
||||||
|
|
||||||
|
// Fallback: ensure video becomes visible after a delay
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!videoLoaded.value) {
|
||||||
|
videoLoaded.value = true
|
||||||
|
}
|
||||||
|
}, 1500)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Video backdrop container - sits behind everything */
|
||||||
|
.video-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
height: 100dvh; /* Dynamic viewport height for iOS */
|
||||||
|
z-index: 0; /* Behind all content */
|
||||||
|
pointer-events: none; /* Don't block interactions */
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Video layer that extends into safe areas */
|
||||||
|
.video-layer {
|
||||||
|
position: absolute;
|
||||||
|
/* Extend into notch area on iOS */
|
||||||
|
top: calc(-1 * env(safe-area-inset-top, 0px));
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 100vw;
|
||||||
|
/* Height includes the safe area extension */
|
||||||
|
height: calc(100dvh + env(safe-area-inset-top, 0px));
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 1s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-layer.video-loaded {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Video element */
|
||||||
|
.hero-video {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
min-width: 100%;
|
||||||
|
min-height: 100%;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* White overlay for smooth loading */
|
||||||
|
.video-white-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(180deg, #ffffff 0%, #f8f9fa 100%);
|
||||||
|
z-index: 2;
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 800ms ease-out;
|
||||||
|
will-change: opacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-white-overlay.fade-out {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gradient overlays */
|
||||||
|
.hero-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradient-warm {
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
rgba(0, 31, 63, 0.3) 0%,
|
||||||
|
rgba(180, 139, 78, 0.2) 50%,
|
||||||
|
rgba(157, 121, 67, 0.3) 100%
|
||||||
|
);
|
||||||
|
mix-blend-mode: multiply;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradient-depth {
|
||||||
|
background: radial-gradient(
|
||||||
|
ellipse at center,
|
||||||
|
rgba(0, 0, 0, 0) 0%,
|
||||||
|
rgba(0, 0, 0, 0.4) 100%
|
||||||
|
);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile optimizations */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.video-backdrop {
|
||||||
|
height: 100vh;
|
||||||
|
height: -webkit-fill-available;
|
||||||
|
height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-layer {
|
||||||
|
/* Ensure video extends into notch on mobile */
|
||||||
|
top: calc(-1 * env(safe-area-inset-top, 0px));
|
||||||
|
height: calc(100dvh + env(safe-area-inset-top, 0px));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user