A journey through parallax effects, smooth scrolling, and creative problem-solving as we craft a cutting-edge digital presence for Juba's premier media studio.
In the heart of Juba, South Sudan's thriving media scene, Mogz Visuals had already established a stellar reputation. Their portfolio boasted photography that could leave even seasoned professionals speechless. But they craved a final frontier to conquer: a website that truly captured their essence. And guess who got the call to make this digital dream a reality? You guessed it – me. No pressure, right?
Jacob Mogga Kei, the visionary behind Mogz Visuals, had a clear vision. He needed a website that could:
Little did I know, that this project would propel me on a journey through the quirky world of scroll manipulation, the delicate art of cookie management, and the exciting realm of on-the-fly file compression.
The easy route? Building a generic portfolio site in record time. But where's the fun in that? I opted to delve into the world of parallax effects and smooth scrolling, transforming the viewing experience into an art form in itself.
Inspiration struck when I stumbled upon Codrops' "Scroll Animations for Image Grids" implementation. It was a revelation, providing the perfect lens to bring the project into focus. This led me to Locomotive Scroll, a JavaScript library promising to transform ordinary websites into smooth-scrolling marvels.
Integrating Locomotive Scroll into a Next.js 13 project proved to be as straightforward as parallel parking a locomotive (which, given that I still don't know how to drive, is saying something). The library desired to hijack the entire app, turning it into a single client-side component. Meanwhile, Next.js 13 (and above versions) proudly flaunted its new App Router and server components. It was like trying to convince a vegan to enjoy a steak – philosophical differences were bound to arise.
But challenges breed solutions. Enter the ScrollProvider:
This savior kept the server components blissfully unaware of the client-side scrolling shenanigans. But how exactly does it work? Let me break it down for you:
With our ScrollProvider in place, we were ready to take on the next challenge: giving Locomotive Scroll more precise control over our content.
To grant Locomotive Scroll more granular control, I created a LocomotiveScrollSection component:
This component became my secret weapon for defining precise areas where Locomotive's magic could work its wonders. It's like giving Locomotive Scroll a map of our website, saying, "Hey buddy, you can do your thing here, here, and here." The beauty of this component is its flexibility – it can be a section, a div, or even a footer, adapting to whatever part of the site needs that smooth scroll magic.
Now, with our scrolling shenanigans sorted, it was time to tackle the next big challenge: the gallery.
With scrolling conquered, the gallery awaited. Mogz Visuals needed a system that was part Fort Knox, part art exhibition. Public collections were a breeze, but private collections demanded a more intricate approach.
I devised a system utilizing session cookies that would put even the most security-conscious client at ease. It's like granting clients a VIP pass to an exclusive gallery showing, except this pass self-destructs after an hour:
To ensure seamless access to private collections, I implemented a verification system that uses encrypted session cookies. Think of it as a bouncer for our digital art gallery, but instead of checking IDs, it's verifying encrypted cookies. Here's how it works:
This API route is like a secret handshake between the client and the server. It checks if the user knows the secret password (the collection ID and password) and if they do, it gives them a special encrypted cookie – their VIP pass to the private gallery.
But what good is a VIP pass if anyone can use it? That's where our next piece of the puzzle comes in:
This middleware is like a second bouncer, standing at the entrance of our private gallery sections. It checks every request to make sure:
If either of these checks fails, it's "Sorry, folks. Gallery's closed. Moose out front shoulda told ya."
A great gallery is only as good as its accessibility. To address this, I created a download functionality that bundled selected images into a convenient zip file. Because nothing says "I value your art" like downloading it all in one go, right?
This hook is like a helpful gallery assistant who not only packages up your chosen artworks but also:
As the dust settled and the last line of code was written, I stood back to admire my handiwork. The Mogz Visuals website wasn't just a website - it was a digital experience that captured the essence of their artistry.
I'd tamed the wild beast that is Locomotive Scroll, juggled server and client components like a pro and created a security system that would make a spy movie proud. All in a day's work, right?
So there you have it, folks. A website that's not just a pretty face, but a smooth-scrolling, parallax-popping, secure-as-Fort-Knox masterpiece. Mogz Visuals, welcome to the digital age – your pixels have never looked so good.
The journey from concept to completion was a rollercoaster of learning experiences. I discovered the joys (and occasional headaches) of working with Locomotive Scroll, mastered the art of cookie management, and even dipped my toes into the world of file compression and download handling. It's safe to say that my toolkit has expanded considerably, and I'm ready to take on whatever digital challenge comes my way next.
As for Mogz Visuals, they now have a digital showcase that truly reflects their artistic prowess. It's a testament to what can be achieved when you combine cutting-edge web technologies with a dash of creativity and a whole lot of persistence. Now, if you'll excuse me, I think I'll go practice my parallel parking. You never know when those skills might come in handy in the world of web development!
'use client';
import { usePathname } from 'next/navigation';
import {
ReactNode,
createContext,
useContext,
useEffect,
useRef,
useState,
} from 'react';
// Define the type for the context value, including the scroll instance and functions
type ScrollContextValue = {
scrollInstance: LocomotiveScroll | null;
scroll: number;
windowHeight: number;
scrollToSection: (id: string) => void;
};
// Create a context to hold the scroll-related data and functions
const ScrollContext = createContext<ScrollContextValue | null>(null);
// Custom hook to use the ScrollContext
// Throws an error if used outside the ScrollProvider
export const useScroll = (): ScrollContextValue => {
const context = useContext(ScrollContext);
if (!context) {
throw new Error('useScroll must be used within a ScrollProvider');
}
return context;
};
// ScrollProvider component to wrap the application or part of it
// Provides scroll-related data and functions to its children
export const ScrollProvider = ({ children }: { children: ReactNode }) => {
const scrollRef = useRef<LocomotiveScroll | null>(null); // Ref to hold the LocomotiveScroll instance
const [scrollPosition, setScrollPosition] = useState(0); // State to track the current scroll position
const [windowHeight, setWindowHeight] = useState(0); // State to track the window height for scroll limits
const pathname = usePathname(); // Get the current pathname to reset scroll on navigation
// useEffect to initialize LocomotiveScroll and handle scroll events
useEffect(() => {
if (!scrollRef.current) {
const initializeScroll = async () => {
const LocomotiveScroll = (await import('locomotive-scroll')).default;
scrollRef.current = new LocomotiveScroll({
el: document.querySelector('[data-scroll-container]') as HTMLElement,
lerp: 0.05,
smooth: true,
reloadOnContextChange: true,
smartphone: { smooth: true },
touchMultiplier: 3,
});
// Event listener for scroll events to update the scroll position and window height
scrollRef.current.on('scroll', (event: any) => {
setScrollPosition(event.scroll.y);
if (windowHeight !== event.limit.y) {
setWindowHeight(event.limit.y);
}
});
};
initializeScroll();
}
// Cleanup function to destroy the scroll instance when the component unmounts or pathname changes
return () => {
if (scrollRef.current) {
scrollRef.current.destroy();
scrollRef.current = null;
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pathname]);
// Function to scroll to a specific section by ID
const scrollToSection = (id: string) => {
if (scrollRef.current) {
scrollRef.current.scrollTo(id);
}
};
// Provide the scroll data and functions to the context
return (
<ScrollContext.Provider
value={{
scroll: scrollPosition,
scrollToSection,
windowHeight,
scrollInstance: scrollRef.current,
}}
>
{children}
</ScrollContext.Provider>
);
};
import Cookies from 'js-cookie';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useToast } from '../context/ToastContext';
export const useAutoDeleteCookie = (slug: string, isPrivate: boolean) => {
const [decryptedSlug, setDecryptedSlug] = useState<string | null>(null);
const { show } = useToast();
const router = useRouter();
useEffect(() => {
if (isPrivate) {
const func = async () => {
const encryptedCookie = Cookies.get('collectionAccess');
if (encryptedCookie) {
const parsedCookie = JSON.parse(encryptedCookie);
console.log('encrypted slug', parsedCookie.slug);
const decryptedSlug = await getDecryptedSlug(parsedCookie.slug);
setDecryptedSlug(decryptedSlug);
}
};
// Decrypt the cookie and set the state
func();
}
}, [isPrivate]);
useEffect(() => {
if (isPrivate && decryptedSlug) {
const timer = setTimeout(() => {
if (slug === decryptedSlug) {
Cookies.remove('collectionAccess');
router.push('/gallery');
show('Your access to private collection expired!', {
status: 'info',
autoClose: false,
});
}
}, 1 * 60 * 60 * 1000); // 1 hour in milliseconds
// Listen to the 'beforeunload' event to delete the cookie when the tab is closed
const handleBeforeUnload = async () => {
if (slug === decryptedSlug) {
Cookies.remove('collectionAccess');
}
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => {
clearTimeout(timer);
window.removeEventListener('beforeunload', handleBeforeUnload);
};
}
}, [decryptedSlug, isPrivate, router, show, slug]);
};
const getDecryptedSlug = async (encryptedCookie: string) => {
const response = await fetch('/api/decryptCookie', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ encryptedCookie }),
});
const { decryptedSlug } = await response.json();
return decryptedSlug;
};
import CryptoJS from 'crypto-js';
import { NextRequest, NextResponse } from 'next/server';
import { fetchSanityData } from '@/lib/sanity/client';
import { getCollectionCredentials } from '@/lib/sanity/queries';
import {
COLLECTION_CREDENTIALS,
VERIFY_ACCESS_RESPONSE_BODY,
} from '@/lib/types';
import { ENCRYPTION_KEY } from '@/lib/Constants';
export async function POST(req: NextRequest) {
if (req.method !== 'POST') {
return NextResponse.json(
{ message: 'Method not allowed', status: 405 },
{ status: 405 }
);
}
const requestBody = await req.json();
const { id, password } = requestBody;
const credentials: COLLECTION_CREDENTIALS = await fetchSanityData(
getCollectionCredentials,
{ id }
);
if (!credentials || credentials.password !== password) {
return NextResponse.json(
{ message: 'Invalid collection ID or password', status: 401 },
{ status: 401 }
);
}
const encryptedSlug = CryptoJS.AES.encrypt(
credentials.slug.current,
ENCRYPTION_KEY
).toString();
const responseBody: VERIFY_ACCESS_RESPONSE_BODY = {
status: 200,
message: 'Access granted, redirecting...',
slug: credentials.slug.current,
encryptedSlug,
};
return NextResponse.json(responseBody, { status: 200 });
}
import CryptoJS from 'crypto-js';
import { NextRequest, NextResponse } from 'next/server';
import { ENCRYPTION_KEY } from './lib/Constants';
export function middleware(req: NextRequest) {
const url = req.nextUrl.clone();
const cookies = req.cookies;
const slug = url.searchParams.get('slug');
const encryptedCookie = cookies.get('collectionAccess');
if (!encryptedCookie) {
url.searchParams.delete('slug');
url.pathname = '/';
return NextResponse.redirect(url);
}
const parsedCookie = JSON.parse(encryptedCookie.value);
const decryptedSlug = CryptoJS.AES.decrypt(
parsedCookie.slug,
ENCRYPTION_KEY
).toString(CryptoJS.enc.Utf8);
if (slug !== decryptedSlug) {
url.searchParams.delete('slug');
url.pathname = '/';
return NextResponse.redirect(url);
}
return NextResponse.next();
}
export const config = {
matcher: '/private/:path*',
};
import JSZip from 'jszip';
import { useState } from 'react';
import { saveAs } from 'file-saver';
import { useToast } from '../context/ToastContext';
const useDownloadCollection = (images: string[], title: string) => {
const [loading, setLoading] = useState(false);
const { show } = useToast();
const folderName = `[MOGZ] ${title}`;
const zip = new JSZip();
const folder = zip.folder(folderName);
const showToast = (
message: string,
status: 'success' | 'error',
autoClose: boolean = true
) => {
show(message, { status, autoClose });
};
const checkRateLimit = async (): Promise<boolean> => {
const response = await fetch('/api/rateLimit', { method: 'GET' });
if (!response.ok) {
const { message } = await response.json();
console.log('Rate limit status:', response.status, message);
showToast('Rate limit exceeded, please try again later.', 'error', false);
return false;
}
return true;
};
const addEmailToAudience = async (email: string) => {
try {
const response = await fetch('/api/contact/audience', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email }),
});
console.log(response);
} catch (error) {
console.log(error);
}
};
const downloadImages = async (email: string) => {
setLoading(true);
try {
if (!(await checkRateLimit())) return;
await addEmailToAudience(email);
const imageFetchPromises = images.map(async (image, index) => {
const blob = await fetch(image).then((response) => response.blob());
folder?.file(generateImageName(title, index), blob, { binary: true });
});
await Promise.all(imageFetchPromises);
const content = await zip.generateAsync({ type: 'blob' });
saveAs(content, `${folderName}.zip`);
showToast('Collection downloaded successfully!', 'success');
} catch (err: any) {
console.error(err);
showToast(
`An error occurred while downloading the collection! Try again later.`,
'error'
);
} finally {
setLoading(false);
}
};
return {
loading,
downloadImages,
};
};
export default useDownloadCollection;
const generateImageName = (title: string, index: number): string => {
const formattedTitle = title.replace(/\s/g, '-');
return `[MOGZ]-${formattedTitle}-${index + 1}.jpg`;
};
import { ReactNode } from 'react';
import { Tag } from '@/lib/types';
// Define the allowed HTML tags for the component
type SectionTags = Extract<Tag, 'section' | 'div' | 'footer'>;
type LocomotiveScrollWrapperProps = {
children: ReactNode;
Tag?: SectionTags;
className?: string;
[x: string]: any;
};
// Component to wrap content with LocomotiveScroll and dynamic HTML tags
const LocomotiveScrollSection = ({
children,
className,
Tag = 'section', // Default tag is 'section'
...rest
}: LocomotiveScrollWrapperProps) => {
return (
<Tag
data-scroll-section
className={`overflow-hidden ${className}`}
{...rest} // Spread any additional props
>
{children}
</Tag>
);
};
export default LocomotiveScrollSection;