Avatar
Home
Projects
Contact
Available For Work
HomeProjectsContact
Local Time (Africa/Juba)
Local Time (Africa/Juba)
HomeProjectsContact
©2025, All Rights ReservedBuilt with by Maged

Scrolling Through Pixels: The Mogz Visuals Website Saga

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.

Jul, 2024Completedmogz.studioSource
Carousel image (1)
Carousel image (2)
Carousel image (3)
Carousel image (4)

Table of Contents

  • The Digital Frontier
  • The Blueprint
  • The Scrolling Odyssey
  • The Locomotive Dilemma
  • Code: scrollContext.tsx
  • Sectioning Off the Scrolls
  • Code: LocomotiveScrollSection.tsx
  • The Gallery Gambit
  • Code: useAutoDeleteCookie.ts
  • Verifying Access: The Digital Handshake
  • Code: route.ts
  • Code: middleware.ts
  • The Download Dilemma
  • Code: useDownloadCollection.ts
  • The Final Frame

The Digital Frontier

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?

The Blueprint

Jacob Mogga Kei, the visionary behind Mogz Visuals, had a clear vision. He needed a website that could:

  • Showcase their impressive work in a visually stunning way: High-quality visuals are paramount in the creative industry, and the website needed to reflect that.
  • Provide a seamless client experience: Clients should be able to access and share their memories effortlessly.
  • Offer a secure system for private collections: Protecting sensitive content was crucial.
  • Include an intuitive download feature: Making it easy for clients to access their photos was essential.
  • Look phenomenal: In the world of visual arts, aesthetics reign supreme.

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 Scrolling Odyssey

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.

The Locomotive Dilemma

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:

File:scrollContext.tsx

'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>
  );
};

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:

  • The ScrollProvider wraps our app, creating a cozy little ecosystem for Locomotive Scroll to play in.
  • It uses React's useEffect to initialize Locomotive Scroll only on the client-side, keeping our server components happy and oblivious.
  • The provider tracks scroll position and window height, feeding this info to any component that's curious enough to ask.
  • It even provides a handy scrollToSection function, because who doesn't love a good shortcut?

With our ScrollProvider in place, we were ready to take on the next challenge: giving Locomotive Scroll more precise control over our content.

Sectioning Off the Scrolls

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.

The Gallery Gambit

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:

Verifying Access: The Digital Handshake

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:

  • The user has a cookie (their VIP pass)
  • The cookie is valid for the gallery they're trying to access

If either of these checks fails, it's "Sorry, folks. Gallery's closed. Moose out front shoulda told ya."

The Download Dilemma

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:

  • Checks if you're not being too greedy with downloads (rate limiting)
  • Adds your email to the gallery's mailing list (with your permission, of course)
  • Neatly organizes everything into a branded folder
  • It's the digital equivalent of gift-wrapping your purchases and throwing in a loyalty card application.

The Final Frame

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!

Share Project

File:useAutoDeleteCookie.ts

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;
};

File:route.ts

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 });
}

File:middleware.ts

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*',
};

File:useDownloadCollection.ts

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`;
};

File:LocomotiveScrollSection.tsx

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;