diff --git a/.eslintrc.json b/.eslintrc.json index 8798e28..54ce2d6 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,6 +1,7 @@ { "extends": ["@finsweet"], "rules": { - "no-console": "off" + "no-console": "off", + "simple-import-sort/imports": "off" } } diff --git a/README.md b/README.md index 04388d9..d0cb429 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,21 @@ Note: The setup won't automatically clean up deleted files that already exist in 5. As changes are made to the code locally and saved, the [localhost:3000](http://localhost:3000) will then serve those files +#### Code execution + +To execute code after all the scripts have loaded, the script loader emits an event listener on the `window` object. This can come in handy when you want to ensure a certain imported library from another script file has loaded before executing it. + +You can use that as following: + + ```html + + ``` + #### Debugging There is an opt-in debugging setup that turns on logs in the console. The preference can be toggled via browser console, and is stored in browser localStorage. diff --git a/home-projects-lightbox.ts b/home-projects-lightbox.ts deleted file mode 100644 index bc8619a..0000000 --- a/home-projects-lightbox.ts +++ /dev/null @@ -1 +0,0 @@ -window.Webflow?.push(() => {}); diff --git a/package.json b/package.json index 02da93c..3314e64 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ }, "dependencies": { "@finsweet/ts-utils": "^0.40.0", + "fslightbox": "^3.4.1", "gsap": "^3.12.5", "htmx.org": "^1.9.10", "swiper": "^11.0.7" diff --git a/src/components/cursor.ts b/src/components/cursor.ts new file mode 100644 index 0000000..8367d5d --- /dev/null +++ b/src/components/cursor.ts @@ -0,0 +1,102 @@ +const CURSOR_OUTER_SELECTOR = '.page-cursor.is-outer'; +const CURSOR_INNER_SELECTOR = '.page-cursor.is-inner'; + +let scaleAnim: gsap.core.Timeline; +let outerX: gsap.QuickToFunc; +let outerY: gsap.QuickToFunc; +let InnerX: gsap.QuickToFunc; +let innerY: gsap.QuickToFunc; + +export function cursorInit() { + if (window.innerWidth < 992) { + return; + } + + window.gsap.set(CURSOR_INNER_SELECTOR, { scale: 0.3 }); + window.gsap.set(`${CURSOR_OUTER_SELECTOR}, ${CURSOR_INNER_SELECTOR}`, { opacity: 1 }); + + document.addEventListener('mousemove', cursorMove); + + outerX = window.gsap.quickTo(CURSOR_OUTER_SELECTOR, 'left', { + duration: 0.2, + ease: 'power3', + }); + outerY = window.gsap.quickTo(CURSOR_OUTER_SELECTOR, 'top', { + duration: 0.2, + ease: 'power3', + }); + + InnerX = window.gsap.quickTo(CURSOR_INNER_SELECTOR, 'left', { + duration: 0.6, + ease: 'power3', + }); + innerY = window.gsap.quickTo(CURSOR_INNER_SELECTOR, 'top', { + duration: 0.6, + ease: 'power3', + }); + + setScaleAnimations(); + setLinksInteraction(); +} + +function setScaleAnimations() { + scaleAnim = window.gsap.timeline({ paused: true }); + + scaleAnim.to( + CURSOR_OUTER_SELECTOR, + { + scale: 0.4, + duration: 0.35, + }, + 0 + ); + scaleAnim.to( + CURSOR_INNER_SELECTOR, + { + opacity: 0, + duration: 0.35, + }, + 0 + ); +} + +function cursorMove(mouseEv: MouseEvent) { + const cursorPosition = { + left: mouseEv.clientX, + top: mouseEv.clientY, + }; + + outerX(cursorPosition.left); + outerY(cursorPosition.top); + InnerX(cursorPosition.left); + innerY(cursorPosition.top); +} + +function setLinksInteraction() { + const TARGET_SELECTORS_LIST = + 'a, [data-cursor-link], .button.is-form-submit, .process_slider_pagination-bullet'; + + document.addEventListener( + 'mouseenter', + (e: MouseEvent) => { + const target = e.target as HTMLElement; + const isTargetMatch = target.matches(TARGET_SELECTORS_LIST); + if (!isTargetMatch) { + return; + } + + window.DEBUG('link hover - cursor scale play'); + scaleAnim.play(); + + target.addEventListener( + 'mouseleave', + (e) => { + window.DEBUG('link hover - cursor scale reverse'); + scaleAnim.reverse(); + }, + { once: true } + ); + }, + true + ); +} diff --git a/src/components/home-hero-image-reveal.ts b/src/components/home-hero-image-reveal.ts index dcd5ca6..ee48399 100644 --- a/src/components/home-hero-image-reveal.ts +++ b/src/components/home-hero-image-reveal.ts @@ -1,16 +1,17 @@ export function homeHeroImageReveal() { const COMPONENT_SELECTOR = '[data-image-reveal-parent]'; + const BLOCKS_SELECTOR = `${COMPONENT_SELECTOR} > div`; const boxes = 10 * 5; - const DURATION_IN_SEC = 3; + const DURATION_IN_SEC = 1; window.gsap.fromTo( - `${COMPONENT_SELECTOR} > div`, + BLOCKS_SELECTOR, { opacity: 1, }, { opacity: 0, - duration: DURATION_IN_SEC / boxes, + duration: 0.3, stagger: { each: DURATION_IN_SEC / boxes, from: 'random', diff --git a/src/components/home-projects-gallery.ts b/src/components/home-projects-gallery.ts new file mode 100644 index 0000000..255fff9 --- /dev/null +++ b/src/components/home-projects-gallery.ts @@ -0,0 +1,118 @@ +import 'fslightbox'; +import { SCRIPTS_LOADED_EVENT } from 'src/constants'; + +const PROJECTS_LIST_SELECTOR = '[data-projects-list]'; +const PROJECT_ITEM_SELECTOR = '.projects_item-link-wrapper'; +const LIGHTBOX_WRAPPER_SELECTOR = '[data-lightbox-wrapper]'; +const LIGHTBOX_CONTENT_WRAPPER_SELECTOR = '[data-lightbox-content-wrapper]'; +const LIGHTBOX_CLOSE_BTN_ATTRIBUTE = 'data-lightbox-close'; + +let lightboxWrapperEl: HTMLElement | null; +let projectsListEl: HTMLElement | null; +let projectItemsList: NodeListOf; + +window.addEventListener(SCRIPTS_LOADED_EVENT, () => { + lightboxWrapperEl = document.querySelector(LIGHTBOX_WRAPPER_SELECTOR); + projectsListEl = document.querySelector(PROJECTS_LIST_SELECTOR); + projectItemsList = document.querySelectorAll(PROJECT_ITEM_SELECTOR); + + if (!lightboxWrapperEl || !projectsListEl || !projectItemsList.length) { + window.DEBUG( + 'One of these elements not found on page - Lightbox wrapper, Projects List, or any Project items.', + 'Looking for', + { LIGHTBOX_WRAPPER_SELECTOR, PROJECTS_LIST_SELECTOR, PROJECT_ITEM_SELECTOR }, + { lightboxWrapperEl, projectsListEl, projectItemsList } + ); + return; + } + + initLightbox(); + onProjectsItemLoad(); + refreshLightbox(); +}); + +/** + * On loading new projects via CMS Load + */ +window.fsAttributes = window.fsAttributes || []; +window.fsAttributes.push([ + 'cmsload', + (listInstances) => { + const [listInstance] = listInstances; + + listInstance.on('renderitems', (renderedItems: NodeListOf) => { + projectItemsList = renderedItems; + + onProjectsItemLoad(); + }); + }, +]); + +function onProjectsItemLoad() { + document.querySelectorAll(`${PROJECT_ITEM_SELECTOR}[data-slug]`).forEach((item) => { + initHTMX(item); + }); +} + +/** + * Sets `hx-get` attribute on the project list items, dynamically joining the slug + */ +function initHTMX(item: HTMLElement) { + const slug = item.getAttribute('data-slug'); + const currentGetPath = item.getAttribute('hx-get') || '/projects/'; + item.setAttribute('hx-get', currentGetPath + slug); + + item.removeAttribute('data-slug'); + + htmx.process(item); +} + +function initLightbox() { + window.gsap.set(lightboxWrapperEl, { opacity: 0 }); + + const lightboxContentWrapperEl = lightboxWrapperEl?.querySelector( + LIGHTBOX_CONTENT_WRAPPER_SELECTOR + ); + + // open + projectsListEl?.addEventListener('click', (clickEv) => { + const target = clickEv.target as HTMLElement; + if (!target.closest(PROJECT_ITEM_SELECTOR)) { + return; + } + + window.gsap.set(lightboxWrapperEl, { display: 'block' }); + window.gsap.set('body', { overflow: 'hidden' }); + window.gsap.to(lightboxWrapperEl, { opacity: 1, duration: 0.3 }); + }); + + // close + lightboxWrapperEl?.addEventListener('click', (clickEv) => { + const target = clickEv.target as HTMLElement; + if (!target.closest(`[${LIGHTBOX_CLOSE_BTN_ATTRIBUTE}]`)) { + return; + } + + window.gsap.to(lightboxWrapperEl, { + opacity: 0, + duration: 0.3, + onComplete: () => { + window.gsap.set(lightboxWrapperEl, { display: 'none' }); + window.gsap.set('body', { overflow: 'auto' }); + + if (lightboxContentWrapperEl) { + lightboxContentWrapperEl.innerHTML = ''; + } + }, + }); + }); +} + +function refreshLightbox() { + htmx.onLoad(function (content) { + window.DEBUG('htmx content loaded', content); + refreshFsLightbox(); + }); +} + +export {}; diff --git a/src/components/home-projects-load.ts b/src/components/home-projects-load.ts deleted file mode 100644 index 452f8cc..0000000 --- a/src/components/home-projects-load.ts +++ /dev/null @@ -1,11 +0,0 @@ -window.fsAttributes = window.fsAttributes || []; -window.fsAttributes.push([ - 'cmsload', - (listInstances) => { - const [listInstance] = listInstances; - - listInstance.on('renderitems', (renderedItems) => { - console.log(renderedItems); - }); - }, -]); diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..641827b --- /dev/null +++ b/src/constants.ts @@ -0,0 +1 @@ +export const SCRIPTS_LOADED_EVENT = 'scriptsLoaded'; diff --git a/src/entry.ts b/src/entry.ts index d5e6435..97ca2ec 100644 --- a/src/entry.ts +++ b/src/entry.ts @@ -3,6 +3,7 @@ * Fetches scripts from localhost or production site depending on the setup * Polls `localhost` on page load, else falls back to deriving code from production URL */ +import { SCRIPTS_LOADED_EVENT } from './constants'; import './dev/debug'; import './dev/env'; @@ -12,8 +13,6 @@ const PRODUCTION_BASE = window.JS_SCRIPTS = new Set(); -export const SCRIPTS_LOADED_EVENT = 'scriptsLoaded'; - const SCRIPT_LOAD_PROMISES: Array> = []; // init adding scripts to the page @@ -81,6 +80,3 @@ function fetchLocalScripts() { appendScripts(); }); } - -// window.Webflow = window.Webflow || []; -// window.Webflow.push(() => {}); diff --git a/src/global.ts b/src/global.ts index 067be9d..84e9ca5 100644 --- a/src/global.ts +++ b/src/global.ts @@ -2,10 +2,12 @@ import { gsap } from 'gsap'; import { ScrollTrigger } from 'gsap/ScrollTrigger'; import { animatedDetailsAccordions } from './components/accordions'; +import { cursorInit } from './components/cursor'; window.gsap = gsap; window.gsap.registerPlugin(ScrollTrigger); window.Webflow?.push(() => { animatedDetailsAccordions(); + cursorInit(); }); diff --git a/src/pages/home.ts b/src/pages/home.ts index 68f5184..fcbb317 100644 --- a/src/pages/home.ts +++ b/src/pages/home.ts @@ -1,5 +1,9 @@ +import { homeHeroImageReveal } from 'src/components/home-hero-image-reveal'; import { initProcessSlider } from 'src/components/home-process-slider'; +import 'src/components/home-projects-gallery'; +import { SCRIPTS_LOADED_EVENT } from 'src/constants'; -window.Webflow?.push(() => { +window.addEventListener(SCRIPTS_LOADED_EVENT, () => { + homeHeroImageReveal(); initProcessSlider(); });