Added About Page
This commit is contained in:
@@ -5,6 +5,9 @@ import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
|
||||
import type { DefineComponent } from 'vue';
|
||||
import { createApp, h } from 'vue';
|
||||
import { initializeTheme } from './composables/useAppearance';
|
||||
import vuetify from "@/plugins/vuetify.js";
|
||||
import {intersectionTransition} from "@/directives/intersectionTransition";
|
||||
import {hoverEffects} from "@/directives/hoverEffects";
|
||||
|
||||
|
||||
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
|
||||
@@ -13,8 +16,12 @@ createInertiaApp({
|
||||
title: (title) => (title ? `${title} - ${appName}` : appName),
|
||||
resolve: (name) => resolvePageComponent(`./pages/${name}.vue`, import.meta.glob<DefineComponent>('./pages/**/*.vue')),
|
||||
setup({ el, App, props, plugin }) {
|
||||
console.log('Inertia app mounted', props.initialPage);
|
||||
createApp({ render: () => h(App, props) })
|
||||
.use(plugin)
|
||||
.use(vuetify)
|
||||
.directive('show-on-intersect', intersectionTransition)
|
||||
.directive('hover', hoverEffects)
|
||||
.mount(el);
|
||||
},
|
||||
progress: {
|
||||
|
||||
39
resources/js/components/Footer.vue
Normal file
39
resources/js/components/Footer.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer class="footer">
|
||||
<span>© Dr Edgy Adventures</span>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.footer {
|
||||
will-change: transform, opacity;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 5dvh;
|
||||
|
||||
/* glassy background */
|
||||
background: hsla(30, 100%, 46%, 0.8);
|
||||
|
||||
/* frosted borders */
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.25);
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.15);
|
||||
|
||||
/* chamfered corners */
|
||||
clip-path: polygon(
|
||||
20px 0, /* top-left chamfer */
|
||||
calc(100% - 20px) 0, /* top-right chamfer */
|
||||
100% 20px,
|
||||
100% 100%,
|
||||
0 100%,
|
||||
0 20px /* bottom-left chamfer */
|
||||
);
|
||||
|
||||
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.15);
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
@@ -3,29 +3,25 @@
|
||||
<header class="header">
|
||||
<section class="navContainer">
|
||||
<nav class="nav">
|
||||
<div class="nav-brand">Dr Edgy Adventures</div>
|
||||
<div class="nav-brand">
|
||||
<Link href="/">
|
||||
<img src="/img/logos/logo_main.png" alt="Dr Edgy Logo" style="height:5vh; width:auto;" />
|
||||
</Link>
|
||||
</div>
|
||||
<button class="mobile-menu-btn">
|
||||
<span></span><span></span><span></span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="nav-menu">
|
||||
<a href="/about" class="nav-link">About</a>
|
||||
<a href="#contact" class="nav-link">Contact</a>
|
||||
<div
|
||||
class="dropdown"
|
||||
:class="{ open: dropdownOpen }"
|
||||
>
|
||||
<Link href="/about" @click="dropdownOpen = false" class="nav-link">About</Link>
|
||||
<div class="dropdown" ref="dropdownRef" :class="{ open: dropdownOpen }">
|
||||
<button
|
||||
class="dropdown-toggle nav-link"
|
||||
@click="dropdownOpen = !dropdownOpen"
|
||||
@click.stop="dropdownOpen = !dropdownOpen"
|
||||
>
|
||||
Adventures
|
||||
<svg
|
||||
class="dropdown-icon"
|
||||
width="16" height="16" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2"
|
||||
>
|
||||
<svg class="dropdown-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="6,9 12,15 18,9"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
@@ -47,36 +43,52 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, computed, ref } from 'vue';
|
||||
import { usePage} from "@inertiajs/vue3";
|
||||
import {GlobalProperties} from "@/types";
|
||||
import { usePage } from "@inertiajs/vue3";
|
||||
import { GlobalProperties } from "@/types";
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
|
||||
const { continents_with_tours } = usePage().props as unknown as GlobalProperties
|
||||
const { continents_with_tours } = usePage().props as unknown as GlobalProperties;
|
||||
|
||||
const dropdownOpen = ref(false)
|
||||
const dropdownOpen = ref(false);
|
||||
const windowWidth = ref(window.innerWidth);
|
||||
const dropdownRef = ref<HTMLElement | null>(null);
|
||||
|
||||
|
||||
onMounted(() =>{
|
||||
initDropdown();
|
||||
initHeaderScroll()
|
||||
initMobileMenu()
|
||||
window.addEventListener('resize', handleResize)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
});
|
||||
|
||||
|
||||
const isDropdownVisible = computed(() => {
|
||||
return dropdownOpen.value || windowWidth.value < 768;
|
||||
});
|
||||
const isDropdownVisible = computed(() => dropdownOpen.value || windowWidth.value < 768);
|
||||
|
||||
const handleResize = () => {
|
||||
windowWidth.value = window.innerWidth;
|
||||
};
|
||||
|
||||
// Close when clicking outside (desktop only)
|
||||
const handleClickOutside = (e: Event) => {
|
||||
if (!dropdownRef.value) return;
|
||||
if (windowWidth.value < 768) return; // on mobile we show inline; skip closing here
|
||||
|
||||
// Support composedPath for shadow DOM correctness
|
||||
const path = (e as MouseEvent).composedPath?.();
|
||||
const clickedInside = path ? path.includes(dropdownRef.value) : dropdownRef.value.contains(e.target as Node);
|
||||
|
||||
if (!clickedInside) {
|
||||
dropdownOpen.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') dropdownOpen.value = false;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initMobileMenu(); // keep your mobile menu init
|
||||
window.addEventListener('resize', handleResize);
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
document.addEventListener('keydown', handleEsc);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
document.removeEventListener('keydown', handleEsc);
|
||||
});
|
||||
|
||||
// Mobile menu functionality
|
||||
const initMobileMenu = (): void => {
|
||||
@@ -85,50 +97,21 @@ const initMobileMenu = (): void => {
|
||||
|
||||
if (!mobileMenuBtn || !navMenu) return;
|
||||
|
||||
// Toggle menu open/close
|
||||
mobileMenuBtn.addEventListener('click', () => {
|
||||
navMenu.classList.toggle('open');
|
||||
mobileMenuBtn.classList.toggle('open');
|
||||
});
|
||||
};
|
||||
|
||||
// Header background on scroll
|
||||
const initHeaderScroll = (): void => {
|
||||
const header = document.querySelector('.header') as HTMLElement | null;
|
||||
if (!header) return;
|
||||
|
||||
window.addEventListener('scroll', () => {
|
||||
if (window.scrollY > 100) {
|
||||
header.classList.add('scrolled');
|
||||
} else {
|
||||
header.classList.remove('scrolled');
|
||||
}
|
||||
// Close menu when a link is clicked
|
||||
navMenu.querySelectorAll('.nav-link').forEach(link => {
|
||||
link.addEventListener('click', () => {
|
||||
navMenu.classList.remove('open');
|
||||
mobileMenuBtn.classList.remove('open');
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const initDropdown = (): void => {
|
||||
const dropdown = document.querySelector('.dropdown') as HTMLElement | null;
|
||||
const dropdownToggle = dropdown?.querySelector('.dropdown-toggle') as HTMLElement | null;
|
||||
|
||||
if (!dropdown || !dropdownToggle) return;
|
||||
|
||||
dropdownToggle.addEventListener('click', (e: Event) => {
|
||||
e.preventDefault();
|
||||
dropdown.classList.toggle('open');
|
||||
});
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
document.addEventListener('click', (e: Event) => {
|
||||
if (!dropdown.contains(e.target as Node)) {
|
||||
dropdown.classList.remove('open');
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
dropdown.classList.remove('open');
|
||||
}
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -208,20 +191,9 @@ const initDropdown = (): void => {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 1em;
|
||||
/* padding / width handled in the desktop guard above */
|
||||
}
|
||||
|
||||
/* brand */
|
||||
.nav-brand {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
background: var(--gradient-adventure);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
/* links container (desktop default) */
|
||||
.nav-menu {
|
||||
display: flex;
|
||||
@@ -359,6 +331,8 @@ const initDropdown = (): void => {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.nav-menu.open {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
@@ -370,6 +344,20 @@ const initDropdown = (): void => {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.nav-brand {
|
||||
position:relative;
|
||||
padding: 1em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 90dvw;
|
||||
}
|
||||
|
||||
.nav-brand img {
|
||||
display: block;
|
||||
margin-left: calc(10dvw / 2);
|
||||
}
|
||||
|
||||
/* Dropdown in mobile: behave as inline sublist */
|
||||
.dropdown {
|
||||
width: 100%;
|
||||
@@ -408,6 +396,7 @@ const initDropdown = (): void => {
|
||||
|
||||
.mobile-menu-btn {
|
||||
display: flex;
|
||||
width: 10dvw;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
139
resources/js/components/ProfileCard.vue
Normal file
139
resources/js/components/ProfileCard.vue
Normal file
@@ -0,0 +1,139 @@
|
||||
<script setup lang="ts">
|
||||
defineProps({
|
||||
name: String,
|
||||
photo: String,
|
||||
blurb: String,
|
||||
slide: String,
|
||||
position: {
|
||||
type: String,
|
||||
default: undefined
|
||||
},
|
||||
photoSide: {
|
||||
type: String as () => 'left' | 'right',
|
||||
default: 'left'
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article v-hover="{ transform: 'translateY(-8px) rotate(0deg) scale(1.02)'}" class="profile-card" v-show-on-intersect="{type: 'slide-'+slide}">
|
||||
<div :class="['profile-flex', photoSide === 'right' ? 'reverse' : '']">
|
||||
<div class="profile-image">
|
||||
<img class="profile-photo" :src="photo" :alt="'Photo of ' + name" />
|
||||
</div>
|
||||
<div class="profile-body">
|
||||
<h3 class="profile-name">{{ name }}</h3>
|
||||
<p v-if="position" class="profile-position">{{ position }}</p>
|
||||
<p class="profile-blurb">{{ blurb }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.profile-card {
|
||||
cursor: pointer;
|
||||
background: var(--card);
|
||||
border-radius: 0;
|
||||
clip-path: polygon(0 0, calc(100% - 20px) 0, 100% 20px, 100% 100%, 20px 100%, 0 calc(100% - 20px));
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.profile-card img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 1.5s ease;
|
||||
}
|
||||
|
||||
.profile-card:hover img {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.profile-flex {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.profile-flex.reverse {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.profile-flex {
|
||||
flex-direction: row;
|
||||
align-items: flex-start; /* align top for image and text */
|
||||
}
|
||||
|
||||
.profile-flex.reverse {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.profile-card {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* Always photo above text on mobile */
|
||||
.profile-flex,
|
||||
.profile-flex.reverse {
|
||||
flex-direction: column !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Make the text take remaining space */
|
||||
.profile-body {
|
||||
padding: 0.5rem;
|
||||
flex: 1 1 0; /* allow body to grow */
|
||||
}
|
||||
|
||||
.profile-image {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 320px; /* similar to your old grid column */
|
||||
aspect-ratio: 4 / 3;
|
||||
background: var(--muted);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
clip-path: polygon(
|
||||
50% 0%, 50% 0%,
|
||||
100% 25%, 100% 75%,
|
||||
50% 100%, 50% 100%,
|
||||
0% 75%, 0% 25%
|
||||
);
|
||||
}
|
||||
|
||||
.profile-photo {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.profile-name {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
background: var(--gradient-adventure);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.profile-position {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.95rem;
|
||||
letter-spacing: 0.2px;
|
||||
margin-bottom: 0.75rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.profile-blurb {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 1.0625rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
</style>
|
||||
79
resources/js/components/dredgy/ScrollIndicator.vue
Normal file
79
resources/js/components/dredgy/ScrollIndicator.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<!-- wrapper controls opacity so animation on the inner element is unaffected -->
|
||||
<div class="scroll-indicator-fade" :style="{ opacity: indicatorOpacity }">
|
||||
<div class="scroll-indicator">
|
||||
<div class="scroll-mouse">
|
||||
<div class="scroll-dot"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
const indicatorOpacity = ref(1)
|
||||
|
||||
function handleScroll() {
|
||||
const maxScroll = 200
|
||||
const scrolled = window.scrollY
|
||||
indicatorOpacity.value = Math.max(0, 1 - scrolled / maxScroll)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('scroll', handleScroll, { passive: true })
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('scroll', handleScroll)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* wrapper: centering + fade transition */
|
||||
.scroll-indicator-fade {
|
||||
position: absolute;
|
||||
bottom: 2rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%); /* keep horizontal centering here */
|
||||
transition: opacity 0.3s ease;
|
||||
pointer-events: none; /* prevents accidental clicks while visible */
|
||||
}
|
||||
|
||||
/* inner element has the bounce animation — animate translateY only */
|
||||
.scroll-indicator {
|
||||
animation: bounce 2s infinite;
|
||||
/* no translateX here; wrapper handles the horizontal offset */
|
||||
}
|
||||
|
||||
.scroll-mouse {
|
||||
width: 24px;
|
||||
height: 40px;
|
||||
border: 2px solid var(--foreground);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.scroll-dot {
|
||||
width: 4px;
|
||||
height: 12px;
|
||||
background: var(--foreground);
|
||||
border-radius: 2px;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
/* bounce now only moves Y — no translateX so it won't conflict */
|
||||
@keyframes bounce {
|
||||
0%, 20%, 53%, 80%, 100% { transform: translateY(0); }
|
||||
40%, 43% { transform: translateY(-15px); }
|
||||
70% { transform: translateY(-7px); }
|
||||
90% { transform: translateY(-2px); }
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
</style>
|
||||
@@ -38,7 +38,6 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import GradientText from "@/components/dredgy/GradientText.vue";
|
||||
import { defineProps } from 'vue'
|
||||
|
||||
|
||||
const props = defineProps<{
|
||||
|
||||
@@ -9,11 +9,13 @@ const formatPrice = (price: number) => {
|
||||
|
||||
const props = defineProps<{
|
||||
tour: Tour
|
||||
dataIndex: number
|
||||
}>()
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tour-card" data-index="0">
|
||||
<div v-hover="{ transform: 'translateY(-8px) rotate(0deg) scale(1.02)'}" v-show-on-intersect="dataIndex % 2 == 0 ? 'rotate-left' : 'rotate-right'" class="tour-card" :data-index="dataIndex">
|
||||
<div class="tour-image">
|
||||
<img :src="`/img/tours/${tour.internal_name}.jpg`" alt="">
|
||||
<div class="tour-overlay"></div>
|
||||
@@ -67,18 +69,8 @@ const props = defineProps<{
|
||||
transform-origin: center bottom;
|
||||
}
|
||||
|
||||
.tour-card:nth-child(2n) {
|
||||
transform: translateY(100px) rotate(6deg) scale(0.9);
|
||||
}
|
||||
|
||||
.tour-card.visible {
|
||||
transform: translateY(0) rotate(0deg) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tour-card:hover {
|
||||
box-shadow: var(--shadow-intense);
|
||||
transform: translateY(-8px) rotate(0deg) scale(1.02);
|
||||
}
|
||||
|
||||
.tour-image {
|
||||
@@ -187,47 +179,4 @@ const props = defineProps<{
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes bounce {
|
||||
0%, 20%, 53%, 80%, 100% {
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
40%, 43% {
|
||||
transform: translateX(-50%) translateY(-15px);
|
||||
}
|
||||
70% {
|
||||
transform: translateX(-50%) translateY(-7px);
|
||||
}
|
||||
90% {
|
||||
transform: translateX(-50%) translateY(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade {
|
||||
animation: fadeInUp 1s ease-out;
|
||||
}
|
||||
|
||||
.animate-slide {
|
||||
animation: fadeInUp 1s ease-out 0.2s both;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,32 +7,39 @@
|
||||
</div>
|
||||
|
||||
<SectionContainer>
|
||||
<div class="about-content">
|
||||
<SectionTitle title="Beyond" gradient="Borders" />
|
||||
|
||||
<div class="about-grid">
|
||||
<div class="about-text">
|
||||
<p>
|
||||
We've been travelling the world our whole lives. From North Korea to Afghanistan, Comoros to Venezuela, Suriname to Uruguay. We know the world, and we want to show it to you.
|
||||
</p>
|
||||
<p>
|
||||
Dr Edgy offers immersive, authentic small group and private adventures, leveraging an extensive global network forged through decades of adventure travel.
|
||||
</p>
|
||||
<p>
|
||||
Our main focus for now is China, where we offer deep-dive adventures of individual provinces and subjects-of-interest and going to places that most other tours completely bypass.
|
||||
We can arrange private adventures in many other countries on request.
|
||||
</p>
|
||||
<div
|
||||
v-show-on-intersect="'fade-up'"
|
||||
class="about-content"
|
||||
>
|
||||
<div>
|
||||
<SectionTitle title="Beyond" gradient="Borders" />
|
||||
|
||||
<div class="about-grid">
|
||||
<div class="about-text" v-show-on-intersect="{type: 'slide-right'}">
|
||||
<p>
|
||||
We've been travelling the world our whole lives. From North Korea to Afghanistan, Comoros to Venezuela, Suriname to Uruguay. We know the world, and we want to show it to you.
|
||||
</p>
|
||||
<p>
|
||||
Dr Edgy offers immersive, authentic small group and private adventures, leveraging an extensive global network forged through decades of adventure travel.
|
||||
</p>
|
||||
<p>
|
||||
Our main focus for now is China, where we offer deep-dive adventures of individual provinces and subjects-of-interest and going to places that most other tours completely bypass.
|
||||
We can arrange private adventures in many other countries on request.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="about-cta" v-show-on-intersect="{type:'slide-left'}">
|
||||
<h3>Who Are We?</h3>
|
||||
<p>Dr Edgy was founded by two close friends who met travelling in North Korea. Both of us love adventure travel with a bit of luxury. One of us is even an actual doctor.</p>
|
||||
<p>You are in safe hands.</p>
|
||||
<EdgyButton classes="btn-primary">
|
||||
<Link href="/about">
|
||||
GET TO KNOW US
|
||||
</Link>
|
||||
</EdgyButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="about-cta">
|
||||
<h3>Who Are We?</h3>
|
||||
<p>Dr Edgy was founded by two close friends who met travelling in North Korea. Both of us love adventure travel with a bit of luxury. One of us is even an actual doctor.</p>
|
||||
<p> You are in safe hands.</p>
|
||||
<EdgyButton classes="btn-primary">GET TO KNOW US</EdgyButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SectionContainer>
|
||||
</section>
|
||||
@@ -83,17 +90,6 @@
|
||||
background: var(--gradient-adventure);
|
||||
}
|
||||
|
||||
.about-content {
|
||||
opacity: 0;
|
||||
transform: translateY(50px);
|
||||
transition: all 1s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.about-content.visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.about-grid {
|
||||
display: grid;
|
||||
gap: 3rem;
|
||||
@@ -113,33 +109,6 @@
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 2rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.stat {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 2.5rem;
|
||||
font-weight: bold;
|
||||
background: var(--gradient-adventure);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.about-cta {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
@@ -176,34 +145,15 @@
|
||||
</style>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
import { ref } from 'vue'
|
||||
import EdgyButton from "@/components/dredgy/EdgyButton.vue";
|
||||
import SectionContainer from "@/components/dredgy/SectionContainer.vue";
|
||||
import SectionTitle from "@/components/dredgy/SectionTitle.vue";
|
||||
import {createIntersectionObserver} from "@/composables/useIntersectionObserver";
|
||||
import {onMounted} from "vue";
|
||||
import {Link} from "@inertiajs/vue3";
|
||||
|
||||
onMounted(() => {
|
||||
initAboutAnimation()
|
||||
})
|
||||
|
||||
// About section animation
|
||||
const initAboutAnimation = (): void => {
|
||||
const aboutContent = document.querySelector('.about-content') as HTMLElement | null;
|
||||
if (!aboutContent) return;
|
||||
|
||||
const observer = createIntersectionObserver((entries: IntersectionObserverEntry[]) => {
|
||||
entries.forEach((entry: IntersectionObserverEntry) => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.add('visible');
|
||||
} else {
|
||||
entry.target.classList.remove('visible');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(aboutContent);
|
||||
};
|
||||
const isVisible = ref(false)
|
||||
|
||||
const onIntersect = (isIntersecting: boolean, entries: IntersectionObserverEntry[]) => {
|
||||
isVisible.value = isIntersecting
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -6,28 +6,33 @@
|
||||
</div>
|
||||
|
||||
<SectionContainer>
|
||||
<div class="tours-header">
|
||||
<div
|
||||
v-show-on-intersect="{ type: 'fade-up', duration: '1s' }"
|
||||
class="tours-header"
|
||||
>
|
||||
<SectionTitle title="Featured" gradient="Adventures" subtitle="Discover somewhere new, in a new way" />
|
||||
</div>
|
||||
|
||||
<div class="tours-grid">
|
||||
<TourCard
|
||||
v-for="(tour, index) in featuredTours"
|
||||
:key="tour.id ?? index"
|
||||
:key="index"
|
||||
class="tour-card"
|
||||
:data-index="index"
|
||||
:tour="tour"
|
||||
/>
|
||||
</div>
|
||||
<br/>
|
||||
<EdgyButton classes="btn-primary btn-full">Explore All Adventures</EdgyButton>
|
||||
|
||||
<EdgyButton
|
||||
v-show-on-intersect="{ type: 'fade-up', duration: '0.6s' }"
|
||||
classes="btn-primary btn-full"
|
||||
>
|
||||
Explore All Adventures
|
||||
</EdgyButton>
|
||||
</SectionContainer>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
|
||||
|
||||
<style scoped>
|
||||
/* Featured Tours Section */
|
||||
.featured-tours {
|
||||
@@ -69,19 +74,12 @@
|
||||
.tours-header {
|
||||
text-align: center;
|
||||
margin-bottom: 4rem;
|
||||
opacity: 0;
|
||||
transform: translateY(50px);
|
||||
transition: all 1s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.tours-header.visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.tours-grid {
|
||||
display: grid;
|
||||
gap: 2rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
@@ -95,85 +93,27 @@
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* Hover effects for tour cards */
|
||||
.tour-card {
|
||||
transition: transform 0.3s ease;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tour-card:hover {
|
||||
transform: translateY(-8px) scale(1.02);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script setup lang="ts">
|
||||
import SectionContainer from "@/components/dredgy/SectionContainer.vue";
|
||||
import SectionTitle from "@/components/dredgy/SectionTitle.vue";
|
||||
import TourCard from "@/components/dredgy/TourCard.vue";
|
||||
import { Tour } from "@/types";
|
||||
import EdgyButton from "@/components/dredgy/EdgyButton.vue";
|
||||
import { Tour } from "@/types";
|
||||
|
||||
|
||||
import { defineProps, onMounted } from 'vue'
|
||||
import {createIntersectionObserver} from "@/composables/useIntersectionObserver";
|
||||
|
||||
const props = defineProps<{
|
||||
defineProps<{
|
||||
featuredTours: Tour[]
|
||||
}>()
|
||||
|
||||
onMounted(() => {
|
||||
initToursAnimation()
|
||||
initTourCardInteractions()
|
||||
})
|
||||
|
||||
const initTourCardInteractions = (): void => {
|
||||
const tourCards = document.querySelectorAll('.tour-card') as NodeListOf<HTMLElement>;
|
||||
|
||||
tourCards.forEach((card: HTMLElement) => {
|
||||
// Add hover effects
|
||||
card.addEventListener('mouseenter', () => {
|
||||
card.style.transform = 'translateY(-8px) scale(1.02)';
|
||||
});
|
||||
|
||||
card.addEventListener('mouseleave', () => {
|
||||
if (card.classList.contains('visible')) {
|
||||
card.style.transform = 'translateY(0) rotate(0deg) scale(1)';
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const initToursAnimation = (): void => {
|
||||
const toursHeader = document.querySelector('.tours-header') as HTMLElement | null;
|
||||
const tourCards = document.querySelectorAll('.tour-card') as NodeListOf<HTMLElement>;
|
||||
|
||||
if (!toursHeader || !tourCards.length) return;
|
||||
|
||||
// Header animation
|
||||
const headerObserver = createIntersectionObserver((entries: IntersectionObserverEntry[]) => {
|
||||
entries.forEach((entry: IntersectionObserverEntry) => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.add('visible');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
headerObserver.observe(toursHeader);
|
||||
|
||||
// Tour cards animation with staggered effect
|
||||
const cardsObserver = createIntersectionObserver((entries: IntersectionObserverEntry[]) => {
|
||||
entries.forEach((entry: IntersectionObserverEntry) => {
|
||||
const tourCard = entry.target as HTMLElement;
|
||||
const index = parseInt(tourCard.dataset.index || '0');
|
||||
|
||||
if (entry.isIntersecting) {
|
||||
// Staggered animation when scrolling down
|
||||
setTimeout(() => {
|
||||
tourCard.classList.add('visible');
|
||||
}, index * 150);
|
||||
} else {
|
||||
// Reverse staggered animation when scrolling up
|
||||
const reverseIndex = (tourCards.length - 1) - index;
|
||||
setTimeout(() => {
|
||||
tourCard.classList.remove('visible');
|
||||
}, reverseIndex * 100);
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.2 });
|
||||
|
||||
tourCards.forEach((card: HTMLElement) => {
|
||||
cardsObserver.observe(card);
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -1,183 +1,126 @@
|
||||
<template>
|
||||
<section class="hero">
|
||||
<div class="hero-bg"></div>
|
||||
<div class="hero-overlay"></div>
|
||||
<section class="hero">
|
||||
<div class="hero-bg"></div>
|
||||
<div class="hero-overlay"></div>
|
||||
|
||||
<div class="hero-content">
|
||||
<h1 class="hero-title animate-fade">
|
||||
<GradientText>Travel.</GradientText><br>
|
||||
<span>On the Edge</span>
|
||||
</h1>
|
||||
<div class="hero-content">
|
||||
<h1 class="hero-title animate-fade">
|
||||
<GradientText>Travel.</GradientText><br>
|
||||
<span>On the Edge</span>
|
||||
</h1>
|
||||
|
||||
<p class="hero-subtitle animate-slide">
|
||||
We don't go wherever is trendy. <br/>
|
||||
We set the trends.
|
||||
</p>
|
||||
<div class="hero-buttons">
|
||||
<EdgyButton classes="btn-secondary">View Adventures</EdgyButton>
|
||||
</div>
|
||||
<p class="hero-subtitle animate-slide">
|
||||
We don't go wherever is trendy. <br/>
|
||||
We set the trends.
|
||||
</p>
|
||||
<div class="hero-buttons">
|
||||
<EdgyButton classes="btn-secondary">View Adventures</EdgyButton>
|
||||
</div>
|
||||
|
||||
<div class="scroll-indicator">
|
||||
<div class="scroll-mouse">
|
||||
<div class="scroll-dot"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
<ScrollIndicator />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Hero Section */
|
||||
.hero {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero-bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 120%;
|
||||
background-image: url('/img/hero.jpg');
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
will-change: transform;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 120%;
|
||||
background-image: url('/img/hero.jpg');
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.hero-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 120%;
|
||||
background: var(--gradient-hero);
|
||||
opacity: 0.8;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 120%;
|
||||
background: var(--gradient-hero);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
text-align: center;
|
||||
max-width: 1000px;
|
||||
padding: 0 2rem;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
text-align: center;
|
||||
max-width: 1000px;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: clamp(3rem, 8vw, 6rem);
|
||||
font-weight: bold;
|
||||
margin-bottom: 1.5rem;
|
||||
line-height: 1.1;
|
||||
font-size: clamp(3rem, 8vw, 6rem);
|
||||
font-weight: bold;
|
||||
margin-bottom: 1.5rem;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: clamp(1.125rem, 2.5vw, 1.5rem);
|
||||
color: var(--muted-foreground);
|
||||
margin-bottom: 2rem;
|
||||
max-width: 600px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
font-size: clamp(1.125rem, 2.5vw, 1.5rem);
|
||||
color: var(--muted-foreground);
|
||||
margin-bottom: 2rem;
|
||||
max-width: 600px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.hero-buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.hero-title,
|
||||
.hero-subtitle {
|
||||
opacity: 0;
|
||||
transform: translateY(18px);
|
||||
transition: opacity 1000ms cubic-bezier(.22,.9,.32,1),
|
||||
transform 1000ms cubic-bezier(.22,.9,.32,1);
|
||||
will-change: opacity, transform;
|
||||
opacity: 0;
|
||||
transform: translateY(18px);
|
||||
transition: opacity 1000ms cubic-bezier(.22,.9,.32,1),
|
||||
transform 1000ms cubic-bezier(.22,.9,.32,1);
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
|
||||
/* visible state toggled by JS */
|
||||
.hero-title.is-visible,
|
||||
.hero-subtitle.is-visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.hero-buttons {
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
}
|
||||
.hero-buttons {
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
|
||||
.hero-content {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
.hero-content {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes bounce {
|
||||
0%, 20%, 53%, 80%, 100% {
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
40%, 43% {
|
||||
transform: translateX(-50%) translateY(-15px);
|
||||
}
|
||||
70% {
|
||||
transform: translateX(-50%) translateY(-7px);
|
||||
}
|
||||
90% {
|
||||
transform: translateX(-50%) translateY(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.scroll-indicator {
|
||||
position: absolute;
|
||||
bottom: 2rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
animation: bounce 2s infinite;
|
||||
}
|
||||
|
||||
.scroll-mouse {
|
||||
width: 24px;
|
||||
height: 40px;
|
||||
border: 2px solid var(--foreground);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.scroll-dot {
|
||||
width: 4px;
|
||||
height: 12px;
|
||||
background: var(--foreground);
|
||||
border-radius: 2px;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
import EdgyButton from "@/components/dredgy/EdgyButton.vue";
|
||||
import GradientText from "@/components/dredgy/GradientText.vue";
|
||||
import {onMounted} from "vue";
|
||||
import { onMounted } from "vue";
|
||||
import ScrollIndicator from "@/components/dredgy/ScrollIndicator.vue";
|
||||
|
||||
const initParallax = (): void => {
|
||||
const heroBackground = document.querySelector('.hero-bg') as HTMLElement | null;
|
||||
|
||||
242
resources/js/directives/hoverEffects.ts
Normal file
242
resources/js/directives/hoverEffects.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
// directives/hoverEffects.ts
|
||||
import { Directive } from 'vue'
|
||||
|
||||
export interface HoverOptions {
|
||||
type?: 'lift' | 'scale' | 'tilt' | 'glow' | 'rotate' | 'slide-up' | 'slide-down' | 'bounce' | 'shake'
|
||||
duration?: number | string
|
||||
easing?: string
|
||||
intensity?: number // Scale factor for the effect (0.1 = subtle, 1.0 = normal, 2.0 = dramatic)
|
||||
transform?: string // Custom transform for hover state
|
||||
boxShadow?: string // Custom box shadow for hover state
|
||||
backgroundColor?: string // Custom background color for hover state
|
||||
borderColor?: string // Custom border color for hover state
|
||||
color?: string // Custom text color for hover state
|
||||
scale?: number // Custom scale value
|
||||
translateY?: number // Custom Y translation in pixels
|
||||
translateX?: number // Custom X translation in pixels
|
||||
rotateZ?: number // Custom rotation in degrees
|
||||
disabled?: boolean // Disable hover effects
|
||||
}
|
||||
|
||||
export type HoverValue = string | HoverOptions
|
||||
|
||||
interface HoverElement extends HTMLElement {
|
||||
_hoverOptions?: HoverOptions
|
||||
_originalStyles?: {
|
||||
transition?: string
|
||||
transform?: string
|
||||
boxShadow?: string
|
||||
backgroundColor?: string
|
||||
borderColor?: string
|
||||
color?: string
|
||||
}
|
||||
_mouseEnterHandler?: (e: MouseEvent) => void
|
||||
_mouseLeaveHandler?: (e: MouseEvent) => void
|
||||
}
|
||||
|
||||
function setupHoverEffects(el: HoverElement, binding: any) {
|
||||
// Clean up existing handlers
|
||||
if (el._mouseEnterHandler) {
|
||||
el.removeEventListener('mouseenter', el._mouseEnterHandler)
|
||||
el.removeEventListener('mouseleave', el._mouseLeaveHandler!)
|
||||
}
|
||||
|
||||
let options: HoverOptions = {}
|
||||
|
||||
if (typeof binding.value === 'string') {
|
||||
options = { type: binding.value }
|
||||
} else if (typeof binding.value === 'object') {
|
||||
options = { ...binding.value }
|
||||
}
|
||||
|
||||
const {
|
||||
type = 'lift',
|
||||
duration = '0.3s',
|
||||
easing = 'ease',
|
||||
intensity = 1.0,
|
||||
transform,
|
||||
boxShadow,
|
||||
backgroundColor,
|
||||
borderColor,
|
||||
color,
|
||||
scale,
|
||||
translateY,
|
||||
translateX,
|
||||
rotateZ,
|
||||
disabled = false
|
||||
} = options
|
||||
|
||||
el._hoverOptions = options
|
||||
|
||||
if (disabled) return
|
||||
|
||||
// Store original styles
|
||||
const computedStyle = window.getComputedStyle(el)
|
||||
el._originalStyles = {
|
||||
transition: computedStyle.transition,
|
||||
transform: computedStyle.transform,
|
||||
boxShadow: computedStyle.boxShadow,
|
||||
backgroundColor: computedStyle.backgroundColor,
|
||||
borderColor: computedStyle.borderColor,
|
||||
color: computedStyle.color
|
||||
}
|
||||
|
||||
// Set base transition
|
||||
el.style.transition = `all ${duration} ${easing}`
|
||||
|
||||
// Get hover effects based on type
|
||||
const hoverEffects = getHoverEffects(type, intensity, {
|
||||
transform,
|
||||
boxShadow,
|
||||
backgroundColor,
|
||||
borderColor,
|
||||
color,
|
||||
scale,
|
||||
translateY,
|
||||
translateX,
|
||||
rotateZ
|
||||
})
|
||||
|
||||
// Mouse enter handler
|
||||
el._mouseEnterHandler = (e: MouseEvent) => {
|
||||
Object.assign(el.style, hoverEffects.enter)
|
||||
}
|
||||
|
||||
// Mouse leave handler
|
||||
el._mouseLeaveHandler = (e: MouseEvent) => {
|
||||
Object.assign(el.style, hoverEffects.leave)
|
||||
}
|
||||
|
||||
el.addEventListener('mouseenter', el._mouseEnterHandler)
|
||||
el.addEventListener('mouseleave', el._mouseLeaveHandler)
|
||||
}
|
||||
|
||||
function getHoverEffects(
|
||||
type: string,
|
||||
intensity: number,
|
||||
customValues: Partial<HoverOptions>
|
||||
) {
|
||||
const effects = {
|
||||
enter: {} as any,
|
||||
leave: {} as any
|
||||
}
|
||||
|
||||
// Custom values take precedence
|
||||
if (customValues.transform) {
|
||||
effects.enter.transform = customValues.transform
|
||||
effects.leave.transform = 'none'
|
||||
return effects
|
||||
}
|
||||
|
||||
// Preset effects
|
||||
switch (type) {
|
||||
case 'lift':
|
||||
effects.enter.transform = `translateY(${customValues.translateY || -8 * intensity}px) scale(${customValues.scale || 1 + 0.02 * intensity})`
|
||||
effects.enter.boxShadow = customValues.boxShadow || `0 ${10 * intensity}px ${25 * intensity}px rgba(0,0,0,0.15)`
|
||||
break
|
||||
|
||||
case 'scale':
|
||||
effects.enter.transform = `scale(${customValues.scale || 1 + 0.05 * intensity})`
|
||||
break
|
||||
|
||||
case 'tilt':
|
||||
effects.enter.transform = `perspective(1000px) rotateY(${customValues.rotateZ || 5 * intensity}deg) scale(${1 + 0.02 * intensity})`
|
||||
break
|
||||
|
||||
case 'glow':
|
||||
effects.enter.boxShadow = customValues.boxShadow || `0 0 ${20 * intensity}px rgba(59, 130, 246, 0.5)`
|
||||
effects.enter.transform = `scale(${1 + 0.02 * intensity})`
|
||||
break
|
||||
|
||||
case 'rotate':
|
||||
effects.enter.transform = `rotate(${customValues.rotateZ || 5 * intensity}deg) scale(${1 + 0.02 * intensity})`
|
||||
break
|
||||
|
||||
case 'slide-up':
|
||||
effects.enter.transform = `translateY(${customValues.translateY || -5 * intensity}px)`
|
||||
break
|
||||
|
||||
case 'slide-down':
|
||||
effects.enter.transform = `translateY(${customValues.translateY || 5 * intensity}px)`
|
||||
break
|
||||
|
||||
case 'bounce':
|
||||
effects.enter.transform = `translateY(${-3 * intensity}px) scale(${1 + 0.05 * intensity})`
|
||||
effects.enter.transition = `all 0.2s cubic-bezier(0.68, -0.55, 0.265, 1.55)`
|
||||
break
|
||||
|
||||
case 'shake':
|
||||
effects.enter.animation = `shake-${intensity} 0.5s ease-in-out`
|
||||
break
|
||||
}
|
||||
|
||||
// Add custom color effects
|
||||
if (customValues.backgroundColor) {
|
||||
effects.enter.backgroundColor = customValues.backgroundColor
|
||||
}
|
||||
if (customValues.borderColor) {
|
||||
effects.enter.borderColor = customValues.borderColor
|
||||
}
|
||||
if (customValues.color) {
|
||||
effects.enter.color = customValues.color
|
||||
}
|
||||
|
||||
// Leave effects (return to original)
|
||||
effects.leave.transform = 'none'
|
||||
effects.leave.boxShadow = ''
|
||||
effects.leave.backgroundColor = ''
|
||||
effects.leave.borderColor = ''
|
||||
effects.leave.color = ''
|
||||
effects.leave.animation = ''
|
||||
|
||||
return effects
|
||||
}
|
||||
|
||||
export const hoverEffects: Directive = {
|
||||
mounted(el: HoverElement, binding) {
|
||||
setupHoverEffects(el, binding)
|
||||
},
|
||||
|
||||
updated(el: HoverElement, binding) {
|
||||
if (JSON.stringify(binding.value) !== JSON.stringify(binding.oldValue)) {
|
||||
setupHoverEffects(el, binding)
|
||||
}
|
||||
},
|
||||
|
||||
unmounted(el: HoverElement) {
|
||||
if (el._mouseEnterHandler) {
|
||||
el.removeEventListener('mouseenter', el._mouseEnterHandler)
|
||||
el.removeEventListener('mouseleave', el._mouseLeaveHandler!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add shake keyframes to document if not already added
|
||||
if (typeof window !== 'undefined') {
|
||||
const addShakeKeyframes = () => {
|
||||
if (document.querySelector('#hover-directive-styles')) return
|
||||
|
||||
const style = document.createElement('style')
|
||||
style.id = 'hover-directive-styles'
|
||||
style.innerHTML = `
|
||||
@keyframes shake-1 {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
25% { transform: translateX(-2px); }
|
||||
75% { transform: translateX(2px); }
|
||||
}
|
||||
@keyframes shake-2 {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
25% { transform: translateX(-5px); }
|
||||
75% { transform: translateX(5px); }
|
||||
}
|
||||
`
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
|
||||
// Add styles when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', addShakeKeyframes)
|
||||
} else {
|
||||
addShakeKeyframes()
|
||||
}
|
||||
}
|
||||
122
resources/js/directives/intersectionTransition.ts
Normal file
122
resources/js/directives/intersectionTransition.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
// directives/intersectionTransition.ts
|
||||
import { Directive } from 'vue'
|
||||
|
||||
interface TransitionOptions {
|
||||
type?: string
|
||||
duration?: number | string
|
||||
easing?: string
|
||||
delay?: number | string
|
||||
opacity?: number | string
|
||||
transform?: string
|
||||
threshold?: number
|
||||
}
|
||||
|
||||
interface IntersectionElement extends HTMLElement {
|
||||
_observer?: IntersectionObserver
|
||||
_options?: TransitionOptions
|
||||
}
|
||||
|
||||
function setupTransition(el: IntersectionElement, binding: any) {
|
||||
// Handle different binding value types
|
||||
let options: TransitionOptions = {}
|
||||
|
||||
if (typeof binding.value === 'string') {
|
||||
// Simple string usage: v-show-on-intersect="'fade-up'"
|
||||
options = { type: binding.value }
|
||||
} else if (typeof binding.value === 'object') {
|
||||
// Object usage: v-show-on-intersect="{ type: 'fade-up', duration: '2s', transform: 'translateX(-100px)' }"
|
||||
options = { ...binding.value }
|
||||
}
|
||||
|
||||
// Set defaults
|
||||
const {
|
||||
type = 'fade-up',
|
||||
duration = '1s',
|
||||
easing = 'cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
delay = '0s',
|
||||
opacity = '0',
|
||||
transform,
|
||||
threshold = 0.1
|
||||
} = options
|
||||
|
||||
el._options = options
|
||||
|
||||
// Get initial transform (custom or preset)
|
||||
const initialTransform = transform || getInitialTransform(type)
|
||||
|
||||
// Initially hide the element
|
||||
el.style.opacity = String(opacity)
|
||||
el.style.transform = initialTransform
|
||||
el.style.transition = `all ${duration} ${easing} ${delay}`
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
// Show element
|
||||
el.style.opacity = '1'
|
||||
el.style.transform = 'none'
|
||||
} else {
|
||||
// Hide element
|
||||
el.style.opacity = String(opacity)
|
||||
el.style.transform = initialTransform
|
||||
}
|
||||
})
|
||||
}, {
|
||||
threshold: threshold as number
|
||||
})
|
||||
|
||||
el._observer = observer
|
||||
observer.observe(el)
|
||||
}
|
||||
|
||||
export const intersectionTransition: Directive = {
|
||||
mounted(el: IntersectionElement, binding) {
|
||||
setupTransition(el, binding)
|
||||
},
|
||||
|
||||
updated(el: IntersectionElement, binding) {
|
||||
// Handle dynamic updates to the directive value
|
||||
if (JSON.stringify(binding.value) !== JSON.stringify(binding.oldValue)) {
|
||||
// Disconnect old observer and setup with new options
|
||||
if (el._observer) {
|
||||
el._observer.disconnect()
|
||||
}
|
||||
setupTransition(el, binding)
|
||||
}
|
||||
},
|
||||
|
||||
unmounted(el: IntersectionElement) {
|
||||
if (el._observer) {
|
||||
el._observer.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getInitialTransform(transitionType: string): string {
|
||||
switch (transitionType) {
|
||||
case 'fade-up':
|
||||
return 'translateY(5em)'
|
||||
case 'fade-down':
|
||||
return 'translateY(-5em)'
|
||||
case 'slide-left':
|
||||
return 'translateX(5em)'
|
||||
case 'slide-right':
|
||||
return 'translateX(-5em)'
|
||||
case 'scale':
|
||||
return 'scale(0.8)'
|
||||
case 'scale-up':
|
||||
return 'scale(1.2)'
|
||||
case 'rotate':
|
||||
return 'rotate(180deg)'
|
||||
case 'rotate-left':
|
||||
return 'translateY(100px) rotate(12deg) scale(0.9)'
|
||||
case 'rotate-right':
|
||||
return 'translateY(100px) rotate(-12deg) scale(0.9)'
|
||||
case 'flip-x':
|
||||
return 'rotateX(90deg)'
|
||||
case 'flip-y':
|
||||
return 'rotateY(90deg)'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { BreadcrumbItemType } from '@/types';
|
||||
import { onMounted } from 'vue';
|
||||
import Header from "@/components/Header.vue";
|
||||
import {VApp} from "vuetify/components/VApp";
|
||||
import Footer from "@/components/Footer.vue";
|
||||
|
||||
interface Props {
|
||||
breadcrumbs?: BreadcrumbItemType[];
|
||||
@@ -17,10 +17,34 @@ withDefaults(defineProps<Props>(), {
|
||||
|
||||
<template>
|
||||
<Header />
|
||||
<slot />
|
||||
</template>
|
||||
|
||||
<transition name="fade" mode="out-in">
|
||||
<main :key="$page.url">
|
||||
<slot />
|
||||
<Footer />
|
||||
</main>
|
||||
</transition>
|
||||
</template>
|
||||
<style scoped>
|
||||
/* fade transition */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.5s ease; /* change 0.5s to slower/faster */
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.fade-enter-to,
|
||||
.fade-leave-from {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
</style>
|
||||
<style>
|
||||
|
||||
:root {
|
||||
/* Colors */
|
||||
--primary: hsl(210, 100%, 50%);
|
||||
@@ -64,11 +88,46 @@ withDefaults(defineProps<Props>(), {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body{
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style:none;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Temporary debug styles */
|
||||
/* Hide scrollbar for webkit browsers (Chrome, Safari, Edge) */
|
||||
::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
html::-webkit-scrollbar,
|
||||
body::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
html{
|
||||
overflow:auto;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
line-height: 1.6;
|
||||
overflow-x: hidden;
|
||||
display:flex;
|
||||
flex-direction: column;
|
||||
|
||||
}
|
||||
|
||||
main{
|
||||
margin-top: 5dvh;
|
||||
min-height: 90dvh;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import AppLayout from "@/layouts/AppLayout.vue";
|
||||
import SectionContainer from "@/components/dredgy/SectionContainer.vue";
|
||||
import SectionTitle from "@/components/dredgy/SectionTitle.vue";
|
||||
import ProfileCard from "@/components/ProfileCard.vue";
|
||||
|
||||
defineOptions({
|
||||
layout: AppLayout
|
||||
@@ -7,11 +10,119 @@ defineOptions({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section style="position: relative;min-height: 100vh;display:flex;align-items: center;justify-content: center;">
|
||||
<v-date-picker show-adjacent-months></v-date-picker>
|
||||
</section>
|
||||
<section class="about about-us" id="about-us">
|
||||
<div class="about-decorations">
|
||||
<div class="about-decoration about-decoration-1"></div>
|
||||
<div class="about-decoration about-decoration-2"></div>
|
||||
<div class="about-decoration about-decoration-3"></div>
|
||||
</div>
|
||||
|
||||
<SectionContainer>
|
||||
<div v-show-on-intersect="'fade-up'" class="about-content">
|
||||
<SectionTitle title="Meet" gradient="The Team" subtitle="Dr Edgy was started by friends who can't get enough of adventuring and who enjoy the thrill of planning and sharing their experiences." />
|
||||
<div class="company-blurb" v-show-on-intersect="{type:'fade-up'}">
|
||||
<p>
|
||||
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="profiles">
|
||||
<ProfileCard
|
||||
photoSide="left"
|
||||
name="Joshua Dredge"
|
||||
position="Founder & Guide"
|
||||
slide="left"
|
||||
photo="/img/staff/josh_dredge.jpg"
|
||||
blurb="Josh has been travelling since he was an infant, and has no plans to stop! He loves the act of discovery and is always going to places that he can't find an information on! He has a particular penchant for China, Central Asia and anywhere with a desert.
|
||||
His background is in bartending, small-business management, wine logistics and software development; but made the move to guiding so that he's not tied down! He can't wait to introduce you to new and exciting places!"
|
||||
/>
|
||||
</div>
|
||||
<div class="profiles">
|
||||
<ProfileCard
|
||||
slide="right"
|
||||
photoSide="right"
|
||||
name="Dr Rainbow Yuan"
|
||||
position="Founder & Tour Designer (China)"
|
||||
photo="/img/staff/rainbow_yuan.jpg"
|
||||
blurb="Born and raised in Dongguan, China, Rainbow earned her doctorate of education and has taught widely across China, Malaysia, Thailand and even
|
||||
at Kim il-Sung university in North Korea! She's of Hakka descent and is fluent in English, Mandarin and Cantonese. Rainbow has travelled far and wide
|
||||
and is always eager to share her knowledge and experiences with you."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SectionContainer>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Company blurb */
|
||||
.company-blurb {
|
||||
margin-top: 1.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.company-blurb p {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 1.08rem;
|
||||
line-height: 1.7;
|
||||
max-width: 70ch;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Base "About" styling to match the Home page aesthetic */
|
||||
.about {
|
||||
position: relative;
|
||||
padding: 5rem 0;
|
||||
background: var(--background);
|
||||
overflow: hidden;
|
||||
min-height: 90dvh;
|
||||
}
|
||||
|
||||
.about-decorations {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.about-decoration {
|
||||
position: absolute;
|
||||
opacity: 0.05;
|
||||
clip-path: polygon(0 0, calc(100% - 20px) 0, 100% 20px, 100% 100%, 20px 100%, 0 calc(100% - 20px));
|
||||
}
|
||||
|
||||
.about-decoration-1 {
|
||||
top: 2rem;
|
||||
right: 2rem;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
background: var(--gradient-adventure);
|
||||
}
|
||||
|
||||
.about-decoration-2 {
|
||||
bottom: 3rem;
|
||||
left: 3rem;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: var(--gradient-accent);
|
||||
}
|
||||
|
||||
.about-decoration-3 {
|
||||
top: 50%;
|
||||
right: 5rem;
|
||||
width: 6px;
|
||||
height: 100px;
|
||||
background: var(--gradient-adventure);
|
||||
}
|
||||
|
||||
/* Profiles layout */
|
||||
.profiles {
|
||||
display: grid;
|
||||
gap: 2.5rem;
|
||||
margin-top: 3rem;
|
||||
}
|
||||
|
||||
/* Mobile tweaks */
|
||||
|
||||
</style>
|
||||
|
||||
@@ -28,57 +28,4 @@ defineOptions({
|
||||
layout: AppLayout
|
||||
})
|
||||
|
||||
// Types
|
||||
const initSmoothScrolling = (): void => {
|
||||
const navLinks = document.querySelectorAll('a[href^="#"]') as NodeListOf<HTMLAnchorElement>;
|
||||
|
||||
navLinks.forEach((link: HTMLAnchorElement) => {
|
||||
link.addEventListener('click', (e: Event) => {
|
||||
e.preventDefault();
|
||||
|
||||
const targetId = link.getAttribute('href');
|
||||
const targetElement = targetId ? document.querySelector(targetId) as HTMLElement | null : null;
|
||||
|
||||
if (targetElement) {
|
||||
const header = document.querySelector('.header') as HTMLElement | null;
|
||||
const headerHeight = header ? header.offsetHeight : 0;
|
||||
const targetPosition = targetElement.offsetTop - headerHeight;
|
||||
|
||||
window.scrollTo({
|
||||
top: targetPosition,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
|
||||
// Close dropdown if open
|
||||
const dropdown = document.querySelector('.dropdown') as HTMLElement | null;
|
||||
if (dropdown) {
|
||||
dropdown.classList.remove('open');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Initialize all functionality when DOM is loaded
|
||||
onMounted(() => {
|
||||
initSmoothScrolling();
|
||||
});
|
||||
|
||||
// Add performance optimization
|
||||
const debounce = <T extends (...args: any[]) => void>(func: T, wait: number): T => {
|
||||
let timeout: number | undefined;
|
||||
return ((...args: Parameters<T>) => {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
}) as T;
|
||||
};
|
||||
|
||||
// Optimize scroll events
|
||||
window.addEventListener('scroll', debounce(() => {
|
||||
// Scroll-based animations can be added here
|
||||
}, 16)); // ~60fps
|
||||
</script>
|
||||
|
||||
17
resources/js/plugins/vuetify.js.ts
Normal file
17
resources/js/plugins/vuetify.js.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { createVuetify } from 'vuetify';
|
||||
import 'vuetify/styles';
|
||||
import { aliases, mdi } from 'vuetify/iconsets/mdi';
|
||||
import '@mdi/font/css/materialdesignicons.css';
|
||||
|
||||
export default createVuetify({
|
||||
icons: {
|
||||
defaultSet: 'mdi',
|
||||
aliases,
|
||||
sets: {
|
||||
mdi,
|
||||
},
|
||||
},
|
||||
theme: {
|
||||
defaultTheme: 'dark',
|
||||
}
|
||||
});
|
||||
8
resources/js/types/globals.d.ts
vendored
8
resources/js/types/globals.d.ts
vendored
@@ -1,5 +1,13 @@
|
||||
import { AppPageProps } from '@/types/index';
|
||||
// resources/js/types/inertia.d.ts
|
||||
import { Page } from '@inertiajs/core';
|
||||
import { ComponentCustomProperties } from 'vue';
|
||||
|
||||
declare module '@vue/runtime-core' {
|
||||
interface ComponentCustomProperties {
|
||||
$page: Page<any>;
|
||||
}
|
||||
}
|
||||
// Extend ImportMeta interface for Vite...
|
||||
declare module 'vite/client' {
|
||||
interface ImportMetaEnv {
|
||||
|
||||
Reference in New Issue
Block a user