556 lines
12 KiB
JavaScript
556 lines
12 KiB
JavaScript
|
|
/**
|
||
|
|
* Page Transitions and Smooth Navigation
|
||
|
|
* Handles page loading, transitions, and history management
|
||
|
|
*/
|
||
|
|
|
||
|
|
class PageTransitions {
|
||
|
|
constructor() {
|
||
|
|
this.transitionDuration = 300;
|
||
|
|
this.isTransitioning = false;
|
||
|
|
this.init();
|
||
|
|
}
|
||
|
|
|
||
|
|
init() {
|
||
|
|
// Wait for body to exist
|
||
|
|
if (!document.body) return;
|
||
|
|
|
||
|
|
// Add transition wrapper if it doesn't exist
|
||
|
|
if (!document.getElementById("page-transition")) {
|
||
|
|
const wrapper = document.createElement("div");
|
||
|
|
wrapper.id = "page-transition";
|
||
|
|
wrapper.className = "page-transition";
|
||
|
|
|
||
|
|
// Wrap main content
|
||
|
|
const main = document.querySelector("main") || document.body;
|
||
|
|
const parent = main.parentNode;
|
||
|
|
parent.insertBefore(wrapper, main);
|
||
|
|
wrapper.appendChild(main);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Add fade-in on page load
|
||
|
|
this.fadeIn();
|
||
|
|
|
||
|
|
// Intercept navigation clicks
|
||
|
|
this.setupLinkInterception();
|
||
|
|
|
||
|
|
// Handle back/forward buttons
|
||
|
|
window.addEventListener("popstate", (e) => {
|
||
|
|
if (e.state && e.state.url) {
|
||
|
|
this.navigate(e.state.url, false);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
// Add scroll restoration
|
||
|
|
if ("scrollRestoration" in history) {
|
||
|
|
history.scrollRestoration = "manual";
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fadeIn() {
|
||
|
|
const wrapper = document.getElementById("page-transition");
|
||
|
|
if (wrapper) {
|
||
|
|
wrapper.classList.add("fade-in");
|
||
|
|
setTimeout(() => {
|
||
|
|
wrapper.classList.remove("fade-in");
|
||
|
|
}, this.transitionDuration);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fadeOut(callback) {
|
||
|
|
const wrapper = document.getElementById("page-transition");
|
||
|
|
if (wrapper) {
|
||
|
|
wrapper.classList.add("fade-out");
|
||
|
|
setTimeout(() => {
|
||
|
|
if (callback) callback();
|
||
|
|
wrapper.classList.remove("fade-out");
|
||
|
|
}, this.transitionDuration);
|
||
|
|
} else {
|
||
|
|
if (callback) callback();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
setupLinkInterception() {
|
||
|
|
document.addEventListener("click", (e) => {
|
||
|
|
const link = e.target.closest("a");
|
||
|
|
|
||
|
|
// Check if it's a valid internal link
|
||
|
|
if (!link) return;
|
||
|
|
if (link.hasAttribute("data-no-transition")) return;
|
||
|
|
if (link.target === "_blank") return;
|
||
|
|
if (link.hasAttribute("download")) return;
|
||
|
|
|
||
|
|
const href = link.getAttribute("href");
|
||
|
|
if (
|
||
|
|
!href ||
|
||
|
|
href.startsWith("#") ||
|
||
|
|
href.startsWith("mailto:") ||
|
||
|
|
href.startsWith("tel:")
|
||
|
|
)
|
||
|
|
return;
|
||
|
|
|
||
|
|
// Check if it's an external link
|
||
|
|
const url = new URL(href, window.location.origin);
|
||
|
|
if (url.origin !== window.location.origin) return;
|
||
|
|
|
||
|
|
// Intercept the navigation
|
||
|
|
e.preventDefault();
|
||
|
|
this.navigate(href, true);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
navigate(url, updateHistory = true) {
|
||
|
|
if (this.isTransitioning) return;
|
||
|
|
this.isTransitioning = true;
|
||
|
|
|
||
|
|
this.fadeOut(() => {
|
||
|
|
if (updateHistory) {
|
||
|
|
history.pushState({ url }, "", url);
|
||
|
|
}
|
||
|
|
window.location.href = url;
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// Scroll to element with smooth animation
|
||
|
|
scrollTo(selector, offset = 0) {
|
||
|
|
const element = document.querySelector(selector);
|
||
|
|
if (!element) return;
|
||
|
|
|
||
|
|
const top =
|
||
|
|
element.getBoundingClientRect().top + window.pageYOffset - offset;
|
||
|
|
|
||
|
|
window.scrollTo({
|
||
|
|
top,
|
||
|
|
behavior: "smooth",
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// Scroll to top
|
||
|
|
scrollToTop() {
|
||
|
|
window.scrollTo({
|
||
|
|
top: 0,
|
||
|
|
behavior: "smooth",
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Lazy Loading Images
|
||
|
|
* Improves performance by loading images only when they're visible
|
||
|
|
*/
|
||
|
|
class LazyLoader {
|
||
|
|
constructor() {
|
||
|
|
this.images = [];
|
||
|
|
this.observer = null;
|
||
|
|
this.init();
|
||
|
|
}
|
||
|
|
|
||
|
|
init() {
|
||
|
|
// Find all lazy images
|
||
|
|
this.images = document.querySelectorAll(
|
||
|
|
'img[data-src], img[loading="lazy"]'
|
||
|
|
);
|
||
|
|
|
||
|
|
// Set up Intersection Observer
|
||
|
|
if ("IntersectionObserver" in window) {
|
||
|
|
this.observer = new IntersectionObserver(
|
||
|
|
(entries) => {
|
||
|
|
entries.forEach((entry) => {
|
||
|
|
if (entry.isIntersecting) {
|
||
|
|
this.loadImage(entry.target);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
},
|
||
|
|
{
|
||
|
|
rootMargin: "50px",
|
||
|
|
}
|
||
|
|
);
|
||
|
|
|
||
|
|
this.images.forEach((img) => this.observer.observe(img));
|
||
|
|
} else {
|
||
|
|
// Fallback for older browsers
|
||
|
|
this.images.forEach((img) => this.loadImage(img));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
loadImage(img) {
|
||
|
|
const src = img.getAttribute("data-src");
|
||
|
|
if (src) {
|
||
|
|
img.src = src;
|
||
|
|
img.removeAttribute("data-src");
|
||
|
|
}
|
||
|
|
|
||
|
|
// Add fade-in effect
|
||
|
|
img.addEventListener("load", () => {
|
||
|
|
img.classList.add("loaded");
|
||
|
|
});
|
||
|
|
|
||
|
|
if (this.observer) {
|
||
|
|
this.observer.unobserve(img);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Add new images to observer
|
||
|
|
observe(images) {
|
||
|
|
if (!images) return;
|
||
|
|
|
||
|
|
const imageList = Array.isArray(images) ? images : [images];
|
||
|
|
imageList.forEach((img) => {
|
||
|
|
if (this.observer) {
|
||
|
|
this.observer.observe(img);
|
||
|
|
} else {
|
||
|
|
this.loadImage(img);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Smooth Scroll Handler
|
||
|
|
* Adds smooth scrolling to anchor links
|
||
|
|
*/
|
||
|
|
class SmoothScroll {
|
||
|
|
constructor() {
|
||
|
|
this.init();
|
||
|
|
}
|
||
|
|
|
||
|
|
init() {
|
||
|
|
document.querySelectorAll('a[href^="#"]').forEach((anchor) => {
|
||
|
|
anchor.addEventListener("click", (e) => {
|
||
|
|
const href = anchor.getAttribute("href");
|
||
|
|
if (href === "#") return;
|
||
|
|
|
||
|
|
e.preventDefault();
|
||
|
|
const target = document.querySelector(href);
|
||
|
|
|
||
|
|
if (target) {
|
||
|
|
const offset = 80; // Account for fixed header
|
||
|
|
const top =
|
||
|
|
target.getBoundingClientRect().top + window.pageYOffset - offset;
|
||
|
|
|
||
|
|
window.scrollTo({
|
||
|
|
top,
|
||
|
|
behavior: "smooth",
|
||
|
|
});
|
||
|
|
|
||
|
|
// Update URL without scrolling
|
||
|
|
history.pushState(null, "", href);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Back to Top Button
|
||
|
|
* Shows/hides button based on scroll position
|
||
|
|
*/
|
||
|
|
class BackToTop {
|
||
|
|
constructor() {
|
||
|
|
this.button = null;
|
||
|
|
this.scrollThreshold = 300;
|
||
|
|
this.init();
|
||
|
|
}
|
||
|
|
|
||
|
|
init() {
|
||
|
|
// Wait for body to exist
|
||
|
|
if (!document.body) return;
|
||
|
|
|
||
|
|
// Create button if it doesn't exist
|
||
|
|
this.button = document.getElementById("back-to-top");
|
||
|
|
if (!this.button) {
|
||
|
|
this.button = document.createElement("button");
|
||
|
|
this.button.id = "back-to-top";
|
||
|
|
this.button.className = "back-to-top";
|
||
|
|
this.button.innerHTML = "↑";
|
||
|
|
this.button.setAttribute("aria-label", "Back to top");
|
||
|
|
document.body.appendChild(this.button);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Handle scroll
|
||
|
|
window.addEventListener("scroll", () => {
|
||
|
|
if (window.pageYOffset > this.scrollThreshold) {
|
||
|
|
this.button.classList.add("visible");
|
||
|
|
} else {
|
||
|
|
this.button.classList.remove("visible");
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
// Handle click
|
||
|
|
this.button.addEventListener("click", () => {
|
||
|
|
window.scrollTo({
|
||
|
|
top: 0,
|
||
|
|
behavior: "smooth",
|
||
|
|
});
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Loading Overlay
|
||
|
|
* Shows loading state during async operations
|
||
|
|
*/
|
||
|
|
class LoadingOverlay {
|
||
|
|
constructor() {
|
||
|
|
this.overlay = null;
|
||
|
|
this.activeOperations = 0;
|
||
|
|
this.init();
|
||
|
|
}
|
||
|
|
|
||
|
|
init() {
|
||
|
|
// Wait for body to exist
|
||
|
|
if (!document.body) return;
|
||
|
|
|
||
|
|
// Create overlay if it doesn't exist
|
||
|
|
this.overlay = document.getElementById("loading-overlay");
|
||
|
|
if (!this.overlay) {
|
||
|
|
this.overlay = document.createElement("div");
|
||
|
|
this.overlay.id = "loading-overlay";
|
||
|
|
this.overlay.className = "loading-overlay";
|
||
|
|
this.overlay.innerHTML = `
|
||
|
|
<div class="loading-spinner">
|
||
|
|
<div class="spinner"></div>
|
||
|
|
<p>Loading...</p>
|
||
|
|
</div>
|
||
|
|
`;
|
||
|
|
document.body.appendChild(this.overlay);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
show() {
|
||
|
|
this.activeOperations++;
|
||
|
|
this.overlay.classList.add("active");
|
||
|
|
document.body.style.overflow = "hidden";
|
||
|
|
}
|
||
|
|
|
||
|
|
hide() {
|
||
|
|
this.activeOperations = Math.max(0, this.activeOperations - 1);
|
||
|
|
|
||
|
|
if (this.activeOperations === 0) {
|
||
|
|
this.overlay.classList.remove("active");
|
||
|
|
document.body.style.overflow = "";
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Force hide regardless of operation count
|
||
|
|
forceHide() {
|
||
|
|
this.activeOperations = 0;
|
||
|
|
this.overlay.classList.remove("active");
|
||
|
|
document.body.style.overflow = "";
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Page Visibility Handler
|
||
|
|
* Handles actions when page becomes visible/hidden
|
||
|
|
*/
|
||
|
|
class PageVisibility {
|
||
|
|
constructor() {
|
||
|
|
this.callbacks = {
|
||
|
|
visible: [],
|
||
|
|
hidden: [],
|
||
|
|
};
|
||
|
|
this.init();
|
||
|
|
}
|
||
|
|
|
||
|
|
init() {
|
||
|
|
document.addEventListener("visibilitychange", () => {
|
||
|
|
if (document.hidden) {
|
||
|
|
this.callbacks.hidden.forEach((cb) => cb());
|
||
|
|
} else {
|
||
|
|
this.callbacks.visible.forEach((cb) => cb());
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
onVisible(callback) {
|
||
|
|
this.callbacks.visible.push(callback);
|
||
|
|
}
|
||
|
|
|
||
|
|
onHidden(callback) {
|
||
|
|
this.callbacks.hidden.push(callback);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Network Status Handler
|
||
|
|
* Monitors online/offline status
|
||
|
|
*/
|
||
|
|
class NetworkStatus {
|
||
|
|
constructor() {
|
||
|
|
this.isOnline = navigator.onLine;
|
||
|
|
this.callbacks = {
|
||
|
|
online: [],
|
||
|
|
offline: [],
|
||
|
|
};
|
||
|
|
this.init();
|
||
|
|
}
|
||
|
|
|
||
|
|
init() {
|
||
|
|
window.addEventListener("online", () => {
|
||
|
|
this.isOnline = true;
|
||
|
|
this.callbacks.online.forEach((cb) => cb());
|
||
|
|
this.showNotification("Back online", "success");
|
||
|
|
});
|
||
|
|
|
||
|
|
window.addEventListener("offline", () => {
|
||
|
|
this.isOnline = false;
|
||
|
|
this.callbacks.offline.forEach((cb) => cb());
|
||
|
|
this.showNotification("No internet connection", "error");
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
onOnline(callback) {
|
||
|
|
this.callbacks.online.push(callback);
|
||
|
|
}
|
||
|
|
|
||
|
|
onOffline(callback) {
|
||
|
|
this.callbacks.offline.push(callback);
|
||
|
|
}
|
||
|
|
|
||
|
|
showNotification(message, type) {
|
||
|
|
if (window.Utils && window.Utils.notify) {
|
||
|
|
window.Utils.notify(message, type);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Initialize when DOM is ready
|
||
|
|
if (document.readyState === "loading") {
|
||
|
|
document.addEventListener("DOMContentLoaded", initPageTransitions);
|
||
|
|
} else {
|
||
|
|
initPageTransitions();
|
||
|
|
}
|
||
|
|
|
||
|
|
function initPageTransitions() {
|
||
|
|
// Initialize all modules
|
||
|
|
window.pageTransitions = new PageTransitions();
|
||
|
|
window.lazyLoader = new LazyLoader();
|
||
|
|
window.smoothScroll = new SmoothScroll();
|
||
|
|
window.backToTop = new BackToTop();
|
||
|
|
window.loadingOverlay = new LoadingOverlay();
|
||
|
|
window.pageVisibility = new PageVisibility();
|
||
|
|
window.networkStatus = new NetworkStatus();
|
||
|
|
|
||
|
|
console.log("Page transitions initialized");
|
||
|
|
}
|
||
|
|
|
||
|
|
// Add CSS if not already present
|
||
|
|
if (!document.getElementById("page-transitions-styles")) {
|
||
|
|
const style = document.createElement("style");
|
||
|
|
style.id = "page-transitions-styles";
|
||
|
|
style.textContent = `
|
||
|
|
.page-transition {
|
||
|
|
opacity: 1;
|
||
|
|
transition: opacity 300ms ease;
|
||
|
|
}
|
||
|
|
|
||
|
|
.page-transition.fade-in {
|
||
|
|
opacity: 0;
|
||
|
|
animation: fadeIn 300ms ease forwards;
|
||
|
|
}
|
||
|
|
|
||
|
|
.page-transition.fade-out {
|
||
|
|
opacity: 1;
|
||
|
|
animation: fadeOut 300ms ease forwards;
|
||
|
|
}
|
||
|
|
|
||
|
|
@keyframes fadeIn {
|
||
|
|
from { opacity: 0; }
|
||
|
|
to { opacity: 1; }
|
||
|
|
}
|
||
|
|
|
||
|
|
@keyframes fadeOut {
|
||
|
|
from { opacity: 1; }
|
||
|
|
to { opacity: 0; }
|
||
|
|
}
|
||
|
|
|
||
|
|
img[data-src] {
|
||
|
|
opacity: 0;
|
||
|
|
transition: opacity 300ms ease;
|
||
|
|
}
|
||
|
|
|
||
|
|
img.loaded {
|
||
|
|
opacity: 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
.back-to-top {
|
||
|
|
position: fixed;
|
||
|
|
bottom: 20px;
|
||
|
|
right: 20px;
|
||
|
|
width: 50px;
|
||
|
|
height: 50px;
|
||
|
|
background: #667eea;
|
||
|
|
color: white;
|
||
|
|
border: none;
|
||
|
|
border-radius: 50%;
|
||
|
|
font-size: 24px;
|
||
|
|
cursor: pointer;
|
||
|
|
opacity: 0;
|
||
|
|
visibility: hidden;
|
||
|
|
transform: translateY(20px);
|
||
|
|
transition: all 0.3s ease;
|
||
|
|
z-index: 999;
|
||
|
|
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
|
||
|
|
}
|
||
|
|
|
||
|
|
.back-to-top.visible {
|
||
|
|
opacity: 1;
|
||
|
|
visibility: visible;
|
||
|
|
transform: translateY(0);
|
||
|
|
}
|
||
|
|
|
||
|
|
.back-to-top:hover {
|
||
|
|
background: #5568d3;
|
||
|
|
transform: translateY(-2px);
|
||
|
|
}
|
||
|
|
|
||
|
|
.loading-overlay {
|
||
|
|
position: fixed;
|
||
|
|
top: 0;
|
||
|
|
left: 0;
|
||
|
|
width: 100%;
|
||
|
|
height: 100%;
|
||
|
|
background: rgba(255,255,255,0.95);
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
justify-content: center;
|
||
|
|
opacity: 0;
|
||
|
|
visibility: hidden;
|
||
|
|
transition: all 0.3s ease;
|
||
|
|
z-index: 9999;
|
||
|
|
}
|
||
|
|
|
||
|
|
.loading-overlay.active {
|
||
|
|
opacity: 1;
|
||
|
|
visibility: visible;
|
||
|
|
}
|
||
|
|
|
||
|
|
.loading-spinner {
|
||
|
|
text-align: center;
|
||
|
|
}
|
||
|
|
|
||
|
|
.spinner {
|
||
|
|
width: 60px;
|
||
|
|
height: 60px;
|
||
|
|
border: 4px solid #f3f3f3;
|
||
|
|
border-top: 4px solid #667eea;
|
||
|
|
border-radius: 50%;
|
||
|
|
animation: spin 1s linear infinite;
|
||
|
|
margin: 0 auto 16px;
|
||
|
|
}
|
||
|
|
|
||
|
|
@keyframes spin {
|
||
|
|
0% { transform: rotate(0deg); }
|
||
|
|
100% { transform: rotate(360deg); }
|
||
|
|
}
|
||
|
|
|
||
|
|
.loading-spinner p {
|
||
|
|
color: #667eea;
|
||
|
|
font-size: 16px;
|
||
|
|
font-weight: 600;
|
||
|
|
margin: 0;
|
||
|
|
}
|
||
|
|
`;
|
||
|
|
document.head.appendChild(style);
|
||
|
|
}
|