diff --git a/.eslintrc.json b/.eslintrc.json
index 122076d..222d475 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -1,23 +1,54 @@
{
- "env": {
- "browser": true,
- "es2021": true
- },
- "extends": [
- "standard-with-typescript",
- "prettier"
+ "env": {
+ "browser": true,
+ "es2021": true
+ },
+ "extends": [
+ "standard-with-typescript",
+ "prettier",
+ "eslint:recommended",
+ "plugin:prettier/recommended",
+ "plugin:@typescript-eslint/recommended"
+ ],
+ "overrides": [],
+ "plugins": ["prettier", "@typescript-eslint"],
+ "parserOptions": {
+ "ecmaVersion": "latest",
+ "project": "./tsconfig.json",
+ "sourceType": "module"
+ },
+ "rules": {
+ "prettier/prettier": "error",
+ "arrow-body-style": "off",
+ "prefer-arrow-callback": "off",
+ "sort-imports": [
+ "error",
+ {
+ "ignoreCase": false,
+ "ignoreDeclarationSort": true,
+ "ignoreMemberSort": false,
+ "memberSyntaxSortOrder": ["none", "all", "multiple", "single"],
+ "allowSeparatedGroups": true
+ }
],
- "overrides": [
- ],
- "plugins": ["prettier"],
- "parserOptions": {
- "ecmaVersion": "latest",
- "project": "./tsconfig.json",
- "sourceType": "module"
- },
- "rules": {
- "prettier/prettier": "error",
- "arrow-body-style": "off",
- "prefer-arrow-callback": "off"
+ "import/no-unresolved": "error",
+ "import/order": [
+ "error",
+ {
+ "groups": ["builtin", "external", "internal", "parent", "sibling", "index"],
+ "newlines-between": "always",
+ "alphabetize": {
+ "order": "asc",
+ "caseInsensitive": true
+ }
+ }
+ ]
+ },
+ "settings": {
+ "import/resolver": {
+ "typescript": {
+ "project": "./tsconfig.json"
+ }
}
+ }
}
diff --git a/.prettierrc.json b/.prettierrc.json
index 263da8b..78d451e 100644
--- a/.prettierrc.json
+++ b/.prettierrc.json
@@ -6,7 +6,7 @@
"trailingComma": "none",
"bracketSpacing": true,
"semi": false,
- "plugins": ["prettier-plugin-go-template"],
+ "plugins": ["prettier-plugin-go-template", "prettier-plugin-organize-imports"],
"overrides": [
{
"files": ["*.html"],
diff --git a/assets/css/_partial/_customCursor.scss b/assets/css/_partial/_customCursor.scss
deleted file mode 100644
index 591c8f5..0000000
--- a/assets/css/_partial/_customCursor.scss
+++ /dev/null
@@ -1,21 +0,0 @@
-.cursor {
- position: fixed;
- z-index: var(--z-cursor);
- top: 0;
- left: 0;
-
- display: none;
- cursor: none;
- pointer-events: none;
-
- color: white;
- mix-blend-mode: difference;
-}
-
-.active {
- display: block;
-}
-
-.cursorInner {
- transform: translate3d(-50%, -50%, 0);
-}
diff --git a/assets/css/_core/_base.scss b/assets/scss/_core/_base.scss
similarity index 100%
rename from assets/css/_core/_base.scss
rename to assets/scss/_core/_base.scss
diff --git a/assets/scss/_core/_font.scss b/assets/scss/_core/_font.scss
new file mode 100644
index 0000000..397038e
--- /dev/null
+++ b/assets/scss/_core/_font.scss
@@ -0,0 +1,6 @@
+@font-face {
+ font-family: HelveticaNow;
+ src: url('/fonts/HelveticaNowText-Regular.woff') format('woff');
+ font-weight: 400;
+ font-style: normal;
+}
diff --git a/assets/scss/_core/_mixins.scss b/assets/scss/_core/_mixins.scss
new file mode 100644
index 0000000..d818128
--- /dev/null
+++ b/assets/scss/_core/_mixins.scss
@@ -0,0 +1,28 @@
+$breakpoints: (
+ 'mobile': 375px,
+ 'tablet': 768px,
+ 'laptop': 1024px,
+ 'desktop': 1440px
+) !default;
+
+// Breakpoints
+
+@mixin min-width($breakpoint) {
+ @if map-has-key($breakpoints, $breakpoint) {
+ @media (min-width: map-get($breakpoints, $breakpoint)) {
+ @content;
+ }
+ } @else {
+ @error "Unfortunately, no value could be retrieved from `#{$breakpoint}`. " + "Available breakpoints are: #{map-keys($breakpoints)}.";
+ }
+}
+
+@mixin max-width($breakpoint) {
+ @if map-has-key($breakpoints, $breakpoint) {
+ @media (max-width: (map-get($breakpoints, $breakpoint) - 1px)) {
+ @content;
+ }
+ } @else {
+ @error "Unfortunately, no value could be retrieved from `#{$breakpoint}`. " + "Available breakpoints are: #{map-keys($breakpoints)}.";
+ }
+}
diff --git a/assets/scss/_core/_reset.scss b/assets/scss/_core/_reset.scss
new file mode 100644
index 0000000..4070fab
--- /dev/null
+++ b/assets/scss/_core/_reset.scss
@@ -0,0 +1,109 @@
+/***
+ The new CSS reset - version 1.11.1 (last updated 24.10.2023)
+ GitHub page: https://github.com/elad2412/the-new-css-reset
+***/
+
+/*
+ Remove all the styles of the "User-Agent-Stylesheet", except for the 'display' property
+ - The "symbol *" part is to solve Firefox SVG sprite bug
+ - The "html" element is excluded, otherwise a bug in Chrome breaks the CSS hyphens property (https://github.com/elad2412/the-new-css-reset/issues/36)
+ */
+*:where(:not(html, iframe, canvas, img, svg, video, audio):not(svg *, symbol *)) {
+ all: unset;
+ display: revert;
+}
+
+/* Preferred box-sizing value */
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+
+/* Fix mobile Safari increase font-size on landscape mode */
+html {
+ -moz-text-size-adjust: none;
+ -webkit-text-size-adjust: none;
+ text-size-adjust: none;
+}
+
+/* Reapply the pointer cursor for anchor tags */
+a,
+button {
+ cursor: revert;
+}
+
+/* Remove list styles (bullets/numbers) */
+ol,
+ul,
+menu,
+summary {
+ list-style: none;
+}
+
+/* For images to not be able to exceed their container */
+img {
+ max-inline-size: 100%;
+ max-block-size: 100%;
+}
+
+/* removes spacing between cells in tables */
+table {
+ border-collapse: collapse;
+}
+
+/* Safari - solving issue when using user-select:none on the
text input doesn't working */
+input,
+textarea {
+ -webkit-user-select: auto;
+}
+
+/* revert the 'white-space' property for textarea elements on Safari */
+textarea {
+ white-space: revert;
+}
+
+/* minimum style to allow to style meter element */
+meter {
+ -webkit-appearance: revert;
+ appearance: revert;
+}
+
+/* preformatted text - use only for this feature */
+:where(pre) {
+ all: revert;
+ box-sizing: border-box;
+}
+
+/* reset default text opacity of input placeholder */
+::placeholder {
+ color: unset;
+}
+
+/* fix the feature of 'hidden' attribute.
+ display:revert; revert to element instead of attribute */
+:where([hidden]) {
+ display: none;
+}
+
+/* revert for bug in Chromium browsers
+ - fix for the content editable attribute will work properly.
+ - webkit-user-select: auto; added for Safari in case of using user-select:none on wrapper element*/
+:where([contenteditable]:not([contenteditable='false'])) {
+ -moz-user-modify: read-write;
+ -webkit-user-modify: read-write;
+ overflow-wrap: break-word;
+ -webkit-line-break: after-white-space;
+ -webkit-user-select: auto;
+}
+
+/* apply back the draggable feature - exist only in Chromium and Safari */
+:where([draggable='true']) {
+ -webkit-user-drag: element;
+}
+
+/* Revert Modal native behavior */
+:where(dialog:modal) {
+ all: revert;
+ box-sizing: border-box;
+}
diff --git a/assets/scss/_core/_typography.scss b/assets/scss/_core/_typography.scss
new file mode 100644
index 0000000..00d5d55
--- /dev/null
+++ b/assets/scss/_core/_typography.scss
@@ -0,0 +1,14 @@
+@import 'mixins';
+
+body {
+ line-height: 1.2;
+ font-size: 16px;
+ font-family: HelveticaNow, helvetica, arial, sans-serif;
+
+ @include min-width('tablet') {
+ font-size: 18px;
+ }
+ @include min-width('laptop') {
+ font-size: 19px;
+ }
+}
diff --git a/assets/scss/_partial/_collection.scss b/assets/scss/_partial/_collection.scss
new file mode 100644
index 0000000..ec63f67
--- /dev/null
+++ b/assets/scss/_partial/_collection.scss
@@ -0,0 +1,29 @@
+.collection {
+ display: flex;
+ flex-direction: column;
+ gap: 20vh;
+
+ padding-top: 50vh;
+ margin-top: calc(var(--nav-height) * -1);
+
+ img {
+ position: sticky;
+ top: 50vh;
+
+ width: 60vw;
+ height: 20vh;
+
+ object-fit: contain;
+
+ transform: translate3d(0, -50%, 0);
+ align-self: center;
+
+ &:last-child {
+ margin-bottom: 20vh;
+ }
+ }
+}
+
+.hidden {
+ display: none;
+}
diff --git a/assets/scss/_partial/_container.scss b/assets/scss/_partial/_container.scss
new file mode 100644
index 0000000..330d239
--- /dev/null
+++ b/assets/scss/_partial/_container.scss
@@ -0,0 +1,19 @@
+.container {
+ position: fixed;
+ top: 0;
+ z-index: 0;
+
+ width: 100vw;
+ height: var(--window-height);
+
+ overflow-y: scroll;
+ overflow-x: hidden;
+ background: white;
+
+ overscroll-behavior: none;
+ -webkit-overflow-scrolling: none;
+}
+
+.disableScroll {
+ pointer-events: none;
+}
diff --git a/assets/scss/_partial/_gallery.scss b/assets/scss/_partial/_gallery.scss
new file mode 100644
index 0000000..1d82815
--- /dev/null
+++ b/assets/scss/_partial/_gallery.scss
@@ -0,0 +1,56 @@
+.gallery {
+ pointer-events: all;
+
+ position: fixed;
+ top: var(--nav-height);
+ z-index: var(--z-nav-gallery);
+
+ display: flex;
+ flex-direction: column;
+
+ width: 100vw;
+ height: calc(var(--window-height) - var(--nav-height));
+ background: white;
+ transform: translate3d(0, 100%, 0);
+
+ .galleryInner {
+ flex: 1;
+ height: 0;
+
+ .swiper-slide {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ img {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+ }
+ }
+ }
+
+ .nav {
+ height: var(--nav-height);
+ padding: var(--space-standard);
+
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ }
+}
+
+.curtain {
+ position: fixed;
+ top: 0;
+ left: 0;
+ z-index: var(--z-curtain);
+
+ width: 100vw;
+ height: var(--window-height);
+
+ background: rgba(0, 0, 0, 0.5);
+ opacity: 0;
+
+ pointer-events: none;
+}
diff --git a/assets/scss/_partial/_nav.scss b/assets/scss/_partial/_nav.scss
new file mode 100644
index 0000000..586b3d9
--- /dev/null
+++ b/assets/scss/_partial/_nav.scss
@@ -0,0 +1,43 @@
+@import '../_core/mixins';
+$tablet: map-get($breakpoints, 'tablet') - 1;
+
+nav {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+
+ width: 100%;
+ height: var(--nav-height);
+ padding: 0 var(--space-standard);
+
+ position: fixed;
+ bottom: 0;
+ background: white;
+
+ z-index: var(--z-nav);
+
+ // Maintain functionality while container is locked
+ pointer-events: all;
+}
+
+.num {
+ width: 0.625em;
+ display: inline-block;
+ text-align: center;
+}
+
+.current {
+ font-style: italic;
+ text-decoration: underline;
+}
+
+@media (max-width: $tablet), (hover: none) {
+ nav {
+ top: 0;
+ }
+
+ .index,
+ .threshold {
+ display: none;
+ }
+}
diff --git a/assets/scss/_partial/_stage.scss b/assets/scss/_partial/_stage.scss
new file mode 100644
index 0000000..ef53850
--- /dev/null
+++ b/assets/scss/_partial/_stage.scss
@@ -0,0 +1,22 @@
+.stage {
+ position: relative;
+ overflow: hidden;
+ width: 100vw;
+ height: calc(var(--window-height) - var(--nav-height));
+
+ cursor: pointer;
+
+ img {
+ position: absolute;
+ top: 0;
+ left: 0;
+
+ width: 100vw;
+ height: var(--window-height);
+ object-fit: contain;
+
+ transform: scale(0.6);
+ opacity: 0;
+ pointer-events: none;
+ }
+}
diff --git a/assets/scss/_partial/_stageNav.scss b/assets/scss/_partial/_stageNav.scss
new file mode 100644
index 0000000..c347049
--- /dev/null
+++ b/assets/scss/_partial/_stageNav.scss
@@ -0,0 +1,21 @@
+.navOverlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ z-index: var(--z-nav-gallery);
+
+ width: 100vw;
+ height: calc(var(--window-height) - var(--nav-height));
+
+ display: flex;
+ cursor: none;
+
+ &:not(.active) {
+ pointer-events: none;
+ display: none;
+ }
+
+ .overlay {
+ flex: 1;
+ }
+}
diff --git a/assets/css/_variables.scss b/assets/scss/_variables.scss
similarity index 100%
rename from assets/css/_variables.scss
rename to assets/scss/_variables.scss
diff --git a/assets/css/style.scss b/assets/scss/style.scss
similarity index 81%
rename from assets/css/style.scss
rename to assets/scss/style.scss
index 96c8842..8e8e9e8 100644
--- a/assets/css/style.scss
+++ b/assets/scss/style.scss
@@ -7,7 +7,11 @@
@import '_variables';
@import '_core/base';
-@import '_partial/customCursor';
@import '_partial/nav';
+
+@import '_partial/customCursor';
@import '_partial/stage';
@import '_partial/stageNav';
+
+@import '_partial/collection';
+@import '_partial/gallery';
diff --git a/assets/ts/container.ts b/assets/ts/container.ts
new file mode 100644
index 0000000..8dd3c99
--- /dev/null
+++ b/assets/ts/container.ts
@@ -0,0 +1,14 @@
+import { scrollable } from './mobile/scroll'
+
+export let container: HTMLDivElement
+
+export function initContainer(): void {
+ container = document.getElementsByClassName('container').item(0) as HTMLDivElement
+ scrollable.addWatcher(() => {
+ if (scrollable.get()) {
+ container.classList.remove('disableScroll')
+ } else {
+ container.classList.add('disableScroll')
+ }
+ })
+}
diff --git a/assets/ts/desktop/customCursor.ts b/assets/ts/desktop/customCursor.ts
new file mode 100644
index 0000000..8d91739
--- /dev/null
+++ b/assets/ts/desktop/customCursor.ts
@@ -0,0 +1,47 @@
+import { active } from './stage'
+import { container } from '../container'
+
+/**
+ * variables
+ */
+
+const cursor = document.createElement('div')
+const cursorInner = document.createElement('div')
+
+/**
+ * main functions
+ */
+
+function onMouse(e: MouseEvent) {
+ const x = e.clientX
+ const y = e.clientY
+ cursor.style.transform = `translate3d(${x}px, ${y}px, 0)`
+}
+
+export function setCustomCursor(text: string): void {
+ cursorInner.innerText = text
+}
+
+/**
+ * init
+ */
+export function initCustomCursor(): void {
+ // cursor class name
+ cursor.className = 'cursor'
+ // cursor inner class name
+ cursorInner.className = 'cursorInner'
+ // append cursor inner to cursor
+ cursor.append(cursorInner)
+ // append cursor to main
+ container.append(cursor)
+ // bind mousemove event to window
+ window.addEventListener('mousemove', onMouse)
+ // add active callback
+ active.addWatcher(() => {
+ if (active.get()) {
+ cursor.classList.add('active')
+ } else {
+ cursor.classList.remove('active')
+ }
+ })
+}
diff --git a/assets/ts/desktop/stage.ts b/assets/ts/desktop/stage.ts
new file mode 100644
index 0000000..0400671
--- /dev/null
+++ b/assets/ts/desktop/stage.ts
@@ -0,0 +1,197 @@
+import { incIndex, state } from '../state'
+import { gsap, Power3 } from 'gsap'
+import { ImageJSON } from '../resources'
+import { Watchable } from '../utils'
+import { container } from '../container'
+
+/**
+ * types
+ */
+
+export type HistoryItem = { i: number; x: number; y: number }
+
+/**
+ * variables
+ */
+
+let imgs: HTMLImageElement[] = []
+let last = { x: 0, y: 0 }
+export const cordHist = new Watchable([])
+export const isOpen = new Watchable(false)
+export const isAnimating = new Watchable(false)
+export const active = new Watchable(false)
+
+/**
+ * getter
+ */
+
+function getElTrail(): HTMLImageElement[] {
+ return cordHist.get().map((item) => imgs[item.i])
+}
+
+function getElTrailCurrent(): HTMLImageElement[] {
+ return getElTrail().slice(-state.get().trailLength)
+}
+
+function getElTrailInactive(): HTMLImageElement[] {
+ const elTrailCurrent = getElTrailCurrent()
+ return elTrailCurrent.slice(0, elTrailCurrent.length - 1)
+}
+
+function getElCurrent(): HTMLImageElement {
+ const elTrail = getElTrail()
+ return elTrail[elTrail.length - 1]
+}
+
+/**
+ * main functions
+ */
+
+// on mouse
+function onMouse(e: MouseEvent): void {
+ if (isOpen.get() || isAnimating.get()) return
+ const cord = { x: e.clientX, y: e.clientY }
+ const travelDist = Math.hypot(cord.x - last.x, cord.y - last.y)
+
+ if (travelDist > state.get().threshold) {
+ last = cord
+ incIndex()
+
+ const newHist = { i: state.get().index, ...cord }
+ cordHist.set([...cordHist.get(), newHist].slice(-state.get().length))
+ }
+}
+
+// set image position with gsap
+function setPositions(): void {
+ const elTrail = getElTrail()
+ if (!elTrail.length) return
+
+ gsap.set(elTrail, {
+ x: (i: number) => cordHist.get()[i].x - window.innerWidth / 2,
+ y: (i: number) => cordHist.get()[i].y - window.innerHeight / 2,
+ opacity: (i: number) =>
+ i + 1 + state.get().trailLength <= cordHist.get().length ? 0 : 1,
+ zIndex: (i: number) => i,
+ scale: 0.6
+ })
+
+ if (isOpen.get()) {
+ gsap.set(imgs, { opacity: 0 })
+ gsap.set(getElCurrent(), { opacity: 1, x: 0, y: 0, scale: 1 })
+ }
+}
+
+// open image into navigation
+function expandImage(): void {
+ if (isAnimating.get()) return
+
+ isOpen.set(true)
+ isAnimating.set(true)
+
+ const tl = gsap.timeline()
+ // move down and hide trail inactive
+ tl.to(getElTrailInactive(), {
+ y: '+=20',
+ ease: Power3.easeIn,
+ stagger: 0.075,
+ duration: 0.3,
+ delay: 0.1,
+ opacity: 0
+ })
+ // current move to center
+ tl.to(getElCurrent(), {
+ x: 0,
+ y: 0,
+ ease: Power3.easeInOut,
+ duration: 0.7,
+ delay: 0.3
+ })
+ // current expand
+ tl.to(getElCurrent(), {
+ delay: 0.1,
+ scale: 1,
+ ease: Power3.easeInOut
+ })
+ // finished
+ tl.then(() => {
+ isAnimating.set(false)
+ })
+}
+
+// close navigation and back to stage
+export function minimizeImage(): void {
+ if (isAnimating.get()) return
+
+ isOpen.set(false)
+ isAnimating.set(true)
+
+ const tl = gsap.timeline()
+ // shrink current
+ tl.to(getElCurrent(), {
+ scale: 0.6,
+ duration: 0.6,
+ ease: Power3.easeInOut
+ })
+ // move current to original position
+ tl.to(getElCurrent(), {
+ delay: 0.3,
+ duration: 0.7,
+ ease: Power3.easeInOut,
+ x: cordHist.get()[cordHist.get().length - 1].x - window.innerWidth / 2,
+ y: cordHist.get()[cordHist.get().length - 1].y - window.innerHeight / 2
+ })
+ // show trail inactive
+ tl.to(getElTrailInactive(), {
+ y: '-=20',
+ ease: Power3.easeOut,
+ stagger: -0.1,
+ duration: 0.3,
+ opacity: 1
+ })
+ // finished
+ tl.then(() => {
+ isAnimating.set(false)
+ })
+}
+
+/**
+ * init
+ */
+
+export function initStage(ijs: ImageJSON[]): void {
+ // create stage element
+ createStage(ijs)
+ // get stage
+ const stage = document.getElementsByClassName('stage').item(0) as HTMLDivElement
+ // get image elements
+ imgs = Array.from(stage.getElementsByTagName('img'))
+ // event listeners
+ stage.addEventListener('click', () => expandImage())
+ stage.addEventListener('keydown', () => expandImage())
+ window.addEventListener('mousemove', onMouse)
+ // watchers
+ isOpen.addWatcher(() => active.set(isOpen.get() && !isAnimating.get()))
+ isAnimating.addWatcher(() => active.set(isOpen.get() && !isAnimating.get()))
+ cordHist.addWatcher(() => setPositions())
+}
+
+/**
+ * hepler
+ */
+
+function createStage(ijs: ImageJSON[]): void {
+ // create container for images
+ const stage: HTMLDivElement = document.createElement('div')
+ stage.className = 'stage'
+ // append images to container
+ for (let ij of ijs) {
+ const e = document.createElement('img')
+ e.src = ij.url
+ e.height = ij.imgH
+ e.width = ij.imgW
+ e.alt = 'image'
+ stage.append(e)
+ }
+ container.append(stage)
+}
diff --git a/assets/ts/desktop/stageNav.ts b/assets/ts/desktop/stageNav.ts
new file mode 100644
index 0000000..61e42b7
--- /dev/null
+++ b/assets/ts/desktop/stageNav.ts
@@ -0,0 +1,103 @@
+import { setCustomCursor } from './customCursor'
+import { decIndex, incIndex, state } from '../state'
+import { increment, decrement } from '../utils'
+import { cordHist, isOpen, isAnimating, active, minimizeImage } from './stage'
+import { container } from '../container'
+
+/**
+ * types
+ */
+
+type NavItem = (typeof navItems)[number]
+
+/**
+ * variables
+ */
+
+const navItems = ['prev', 'close', 'next'] as const
+
+/**
+ * main functions
+ */
+
+function handleClick(type: NavItem) {
+ switch (type) {
+ case 'prev':
+ prevImage()
+ break
+ case 'close':
+ minimizeImage()
+ break
+ case 'next':
+ nextImage()
+ break
+ }
+}
+
+function handleKey(e: KeyboardEvent) {
+ if (isOpen.get() || isAnimating.get()) return
+ switch (e.key) {
+ case 'ArrowLeft':
+ prevImage()
+ break
+ case 'Escape':
+ minimizeImage()
+ break
+ case 'ArrowRight':
+ nextImage()
+ break
+ }
+}
+
+/**
+ * init
+ */
+
+export function initStageNav() {
+ const navOverlay = document.createElement('div')
+ navOverlay.className = 'navOverlay'
+ for (let navItem of navItems) {
+ const overlay = document.createElement('div')
+ overlay.className = 'overlay'
+ overlay.addEventListener('click', () => handleClick(navItem))
+ overlay.addEventListener('keydown', () => handleClick(navItem))
+ overlay.addEventListener('mouseover', () => setCustomCursor(navItem))
+ overlay.addEventListener('focus', () => setCustomCursor(navItem))
+ navOverlay.append(overlay)
+ }
+ active.addWatcher(() => {
+ if (active.get()) {
+ navOverlay.classList.add('active')
+ } else {
+ navOverlay.classList.remove('active')
+ }
+ })
+ container.append(navOverlay)
+ window.addEventListener('keydown', handleKey)
+}
+
+/**
+ * hepler
+ */
+
+function nextImage() {
+ if (isAnimating.get()) return
+ cordHist.set(
+ cordHist.get().map((item) => {
+ return { ...item, i: increment(item.i, state.get().length) }
+ })
+ )
+
+ incIndex()
+}
+
+function prevImage() {
+ if (isAnimating.get()) return
+ cordHist.set(
+ cordHist.get().map((item) => {
+ return { ...item, i: decrement(item.i, state.get().length) }
+ })
+ )
+
+ decIndex()
+}
diff --git a/assets/ts/main.ts b/assets/ts/main.ts
index 2d58579..5e82b59 100644
--- a/assets/ts/main.ts
+++ b/assets/ts/main.ts
@@ -1,13 +1,25 @@
+import { initContainer } from './container'
+import { initCustomCursor } from './desktop/customCursor'
+import { initStage } from './desktop/stage'
+import { initStageNav } from './desktop/stageNav'
+import { initCollection } from './mobile/collection'
+import { initGallery } from './mobile/gallery'
+import { initNav } from './nav'
import { initResources } from './resources'
import { initState } from './state'
-import { initCustomCursor } from './customCursor'
-import { initNav } from './nav'
-import { initStage } from './stage'
-import { initStageNav } from './stageNav'
+import { isMobile } from './utils'
+initContainer()
initCustomCursor()
const ijs = initResources()
initState(ijs.length)
-initStage(ijs)
-initStageNav()
+
initNav()
+
+if (!isMobile()) {
+ initStage(ijs)
+ initStageNav()
+} else {
+ initCollection(ijs)
+ initGallery(ijs)
+}
diff --git a/assets/ts/mobile/collection.ts b/assets/ts/mobile/collection.ts
new file mode 100644
index 0000000..a661bc2
--- /dev/null
+++ b/assets/ts/mobile/collection.ts
@@ -0,0 +1,73 @@
+import { container } from '../container'
+import { ImageJSON } from '../resources'
+import { setIndex } from '../state'
+import { getRandom, Watchable } from '../utils'
+import { slideUp } from './gallery'
+
+/**
+ * variables
+ */
+
+export let imgs: HTMLImageElement[] = []
+export const mounted = new Watchable(false)
+
+/**
+ * main functions
+ */
+
+function handleClick(i: number): void {
+ setIndex(i)
+ slideUp()
+}
+
+/**
+ * init
+ */
+
+export function initCollection(ijs: ImageJSON[]): void {
+ createCollection(ijs)
+ // get container
+ const container = document
+ .getElementsByClassName('collection')
+ .item(0) as HTMLDivElement
+ // add watcher
+ mounted.addWatcher(() => {
+ if (mounted.get()) {
+ container.classList.remove('hidden')
+ } else {
+ container.classList.add('hidden')
+ }
+ })
+ // get image elements
+ imgs = Array.from(container.getElementsByTagName('img'))
+ // add event listeners
+ imgs.forEach((img, i) => {
+ img.addEventListener('click', () => handleClick(i))
+ img.addEventListener('keydown', () => handleClick(i))
+ })
+}
+
+/**
+ * helper
+ */
+
+function createCollection(ijs: ImageJSON[]): void {
+ // create container for images
+ const collection: HTMLDivElement = document.createElement('div')
+ collection.className = 'collection'
+ // append images to container
+ for (let [i, ij] of ijs.entries()) {
+ // random x and y
+ const x = i !== 0 ? getRandom(-25, 25) : 0
+ const y = i !== 0 ? getRandom(-30, 30) : 0
+ // element
+ const e = document.createElement('img')
+ e.src = ij.url
+ e.height = ij.imgH
+ e.width = ij.imgW
+ e.alt = 'image'
+ e.style.transform = `translate3d(${x}%, ${y - 50}%, 0)`
+ collection.append(e)
+ }
+ container.append(collection)
+}
diff --git a/assets/ts/mobile/gallery.ts b/assets/ts/mobile/gallery.ts
new file mode 100644
index 0000000..a9167ae
--- /dev/null
+++ b/assets/ts/mobile/gallery.ts
@@ -0,0 +1,198 @@
+import { Power3, gsap } from 'gsap'
+import Swiper from 'swiper'
+import { container } from '../container'
+import { ImageJSON } from '../resources'
+import { setIndex, state } from '../state'
+import { Watchable, expand } from '../utils'
+import { imgs, mounted } from './collection'
+import { scrollable } from './scroll'
+
+/**
+ * variables
+ */
+
+let swiperNode: HTMLDivElement
+let gallery: HTMLDivElement
+let curtain: HTMLDivElement
+let swiper: Swiper
+const isAnimating = new Watchable(false)
+let lastIndex = -1
+let indexDispNums: HTMLSpanElement[] = []
+
+/**
+ * main functions
+ */
+
+export function slideUp(): void {
+ if (isAnimating.get()) return
+ isAnimating.set(true)
+
+ gsap.to(curtain, {
+ opacity: 1,
+ duration: 1
+ })
+
+ gsap.to(gallery, {
+ y: 0,
+ ease: Power3.easeInOut,
+ duration: 1,
+ delay: 0.4
+ })
+
+ setTimeout(() => {
+ scrollable.set(false)
+ isAnimating.set(false)
+ }, 1200)
+}
+
+function slideDown(): void {
+ scrollable.set(true)
+ scrollToActive()
+
+ gsap.to(gallery, {
+ y: '100%',
+ ease: Power3.easeInOut,
+ duration: 1
+ })
+
+ gsap.to(curtain, {
+ opacity: 0,
+ duration: 1.2,
+ delay: 0.4
+ })
+}
+
+/**
+ * init
+ */
+
+export function initGallery(ijs: ImageJSON[]): void {
+ // create gallery
+ createGallery(ijs)
+ // get elements
+ indexDispNums = Array.from(
+ document.getElementsByClassName('nav').item(0)!.getElementsByClassName('num')
+ ) as HTMLSpanElement[]
+ swiperNode = document.getElementsByClassName('galleryInner').item(0) as HTMLDivElement
+ gallery = document.getElementsByClassName('gallery').item(0) as HTMLDivElement
+ curtain = document.getElementsByClassName('curtain').item(0) as HTMLDivElement
+ // state watcher
+ state.addWatcher(() => {
+ const s = state.get()
+ // change slide only when index is changed
+ if (s.index === lastIndex) return
+ changeSlide(s.index)
+ updateIndexText()
+ lastIndex = s.index
+ })
+ // mounted watcher
+ mounted.addWatcher(() => {
+ if (!mounted.get()) return
+ scrollable.set(true)
+ swiper = new Swiper(swiperNode, { spaceBetween: 20 })
+ swiper.on('slideChange', ({ realIndex }) => {
+ setIndex(realIndex)
+ })
+ })
+
+ // mounted
+ mounted.set(true)
+}
+
+/**
+ * helper
+ */
+
+function changeSlide(slide: number): void {
+ console.log(slide)
+ swiper?.slideTo(slide, 0)
+}
+
+function scrollToActive(): void {
+ imgs[state.get().index].scrollIntoView({
+ block: 'center',
+ behavior: 'auto'
+ })
+}
+
+function updateIndexText(): void {
+ const indexValue: string = expand(state.get().index + 1)
+ const indexLength: string = expand(state.get().length)
+ indexDispNums.forEach((e: HTMLSpanElement, i: number) => {
+ if (i < 4) {
+ e.innerText = indexValue[i]
+ } else {
+ e.innerText = indexLength[i - 4]
+ }
+ })
+}
+
+function createGallery(ijs: ImageJSON[]): void {
+ /**
+ * gallery
+ * |- galleryInner
+ * |- swiper-wrapper
+ * |- swiper-slide
+ * |- img
+ * |- swiper-slide
+ * |- img
+ * |- ...
+ * |- nav
+ * |- index
+ * |- close
+ */
+ // swiper wrapper
+ const _swiperWrapper = document.createElement('div')
+ _swiperWrapper.className = 'swiper-wrapper'
+ // swiper slide
+ for (let ij of ijs) {
+ const _swiperSlide = document.createElement('div')
+ _swiperSlide.className = 'swiper-slide'
+ // img
+ const e = document.createElement('img')
+ e.src = ij.url
+ e.alt = 'image'
+ // append
+ _swiperSlide.append(e)
+ _swiperWrapper.append(_swiperSlide)
+ }
+ // swiper node
+ const _swiperNode = document.createElement('div')
+ _swiperNode.className = 'galleryInner'
+ _swiperNode.append(_swiperWrapper)
+ // index
+ const _index = document.createElement('div')
+ _index.insertAdjacentHTML(
+ 'afterbegin',
+ `
+ /
+ `
+ )
+ // close
+ const _close = document.createElement('div')
+ _close.innerText = 'Close'
+ _close.addEventListener('click', () => slideDown())
+ _close.addEventListener('keydown', () => slideDown())
+ // nav
+ const _navDiv = document.createElement('div')
+ _navDiv.className = 'nav'
+ _navDiv.append(_index, _close)
+ // gallery
+ const _gallery = document.createElement('div')
+ _gallery.className = 'gallery'
+ _gallery.append(_swiperNode)
+ _gallery.append(_navDiv)
+
+ /**
+ * curtain
+ */
+ const _curtain = document.createElement('div')
+ _curtain.className = 'curtain'
+
+ /**
+ * container
+ * |- gallery
+ * |- curtain
+ */
+ container.append(_gallery, _curtain)
+}
diff --git a/assets/ts/mobile/scroll.ts b/assets/ts/mobile/scroll.ts
new file mode 100644
index 0000000..46ecb41
--- /dev/null
+++ b/assets/ts/mobile/scroll.ts
@@ -0,0 +1,3 @@
+import { Watchable } from '../utils'
+
+export const scrollable = new Watchable(true)
diff --git a/assets/ts/nav.ts b/assets/ts/nav.ts
index 6a8b235..25282d4 100644
--- a/assets/ts/nav.ts
+++ b/assets/ts/nav.ts
@@ -1,6 +1,10 @@
-import { getState, incThreshold, decThreshold } from './state'
+import { decThreshold, incThreshold, state } from './state'
import { expand } from './utils'
+/**
+ * variables
+ */
+
// threshold div
const thresholdDiv = document
.getElementsByClassName('threshold')
@@ -27,6 +31,10 @@ const indexDispNums = Array.from(
indexDiv.getElementsByClassName('num')
) as HTMLSpanElement[]
+/**
+ * init
+ */
+
export function initNav() {
// init threshold text
updateThresholdText()
@@ -40,15 +48,15 @@ export function initNav() {
// helper
export function updateThresholdText(): void {
- const thresholdValue: string = expand(getState().threshold)
+ const thresholdValue: string = expand(state.get().threshold)
thresholdDispNums.forEach((e: HTMLSpanElement, i: number) => {
e.innerText = thresholdValue[i]
})
}
export function updateIndexText(): void {
- const indexValue: string = expand(getState().index + 1)
- const indexLength: string = expand(getState().length)
+ const indexValue: string = expand(state.get().index + 1)
+ const indexLength: string = expand(state.get().length)
indexDispNums.forEach((e: HTMLSpanElement, i: number) => {
if (i < 4) {
e.innerText = indexValue[i]
diff --git a/assets/ts/state.ts b/assets/ts/state.ts
index 4aebfe9..482f215 100644
--- a/assets/ts/state.ts
+++ b/assets/ts/state.ts
@@ -1,5 +1,5 @@
-import { increment, decrement } from './utils'
import { updateIndexText, updateThresholdText } from './nav'
+import { Watchable, decrement, increment } from './utils'
const thresholds = [
{ threshold: 20, trailLength: 20 },
@@ -18,43 +18,46 @@ const defaultState = {
export type State = typeof defaultState
-let state = defaultState
-
-export function getState(): State {
- // return a copy of state
- return Object.create(
- Object.getPrototypeOf(state),
- Object.getOwnPropertyDescriptors(state)
- )
-}
+export const state = new Watchable(defaultState)
export function initState(length: number): void {
- state.length = length
+ const s = state.get()
+ s.length = length
+ state.set(s)
+ state.addWatcher(() => {
+ updateIndexText()
+ updateThresholdText()
+ })
}
export function setIndex(index: number): void {
- state.index = index
- updateIndexText()
+ const s = state.get()
+ s.index = index
+ state.set(s)
}
export function incIndex(): void {
- state.index = increment(state.index, state.length)
- updateIndexText()
+ const s = state.get()
+ s.index = increment(s.index, s.length)
+ state.set(s)
}
export function decIndex(): void {
- state.index = decrement(state.index, state.length)
- updateIndexText()
+ const s = state.get()
+ s.index = decrement(s.index, s.length)
+ state.set(s)
}
export function incThreshold(): void {
- state = updateThreshold(state, 1)
- updateThresholdText()
+ let s = state.get()
+ s = updateThreshold(s, 1)
+ state.set(s)
}
export function decThreshold(): void {
- state = updateThreshold(state, -1)
- updateThresholdText()
+ let s = state.get()
+ s = updateThreshold(s, 1)
+ state.set(s)
}
// helper
diff --git a/assets/ts/utils.ts b/assets/ts/utils.ts
index e2e21e7..9717d03 100644
--- a/assets/ts/utils.ts
+++ b/assets/ts/utils.ts
@@ -1,3 +1,7 @@
+/**
+ * custom helpers
+ */
+
export function increment(num: number, length: number): number {
return (num + 1) % length
}
@@ -14,6 +18,14 @@ export function isMobile(): boolean {
return window.matchMedia('(hover: none)').matches
}
+export function getRandom(min: number, max: number) {
+ return Math.floor(Math.random() * (max - min + 1)) + min
+}
+
+/**
+ * custom types
+ */
+
export class Watchable {
constructor(private obj: T) {}
private watchers: (() => void)[] = []
diff --git a/layouts/index.html b/layouts/index.html
index 9fe8cfc..5e9a693 100644
--- a/layouts/index.html
+++ b/layouts/index.html
@@ -37,6 +37,6 @@
{{ end }}
- {{ partial "nav.html" . }}
+ {{ partial "nav.html" . }}