├── README.md └── frontend ├── .babelrc ├── .eslintrc.json ├── .gitignore ├── .prettierrc.js ├── global.d.ts ├── next-env.d.ts ├── next.config.js ├── package-lock.json ├── package.json ├── public ├── favicon.ico ├── fonts │ ├── bonVoyage.woff2 │ ├── nexaBlack.woff2 │ └── openSans400.woff2 └── vercel.svg ├── src ├── classes │ ├── CanvasApp.ts │ ├── Components │ │ ├── Circle2D.ts │ │ ├── InteractiveObject3D.ts │ │ ├── InteractiveScene.ts │ │ ├── IntersectiveBackground3D.ts │ │ └── Transition.ts │ ├── HTMLComponents │ │ ├── Animation.ts │ │ ├── BottomHide.ts │ │ ├── Curtain.ts │ │ ├── MoreLabel.ts │ │ └── Paragraph.ts │ ├── Pages │ │ ├── DetailsPage │ │ │ ├── Canvas │ │ │ │ ├── Components │ │ │ │ │ ├── Image3D.ts │ │ │ │ │ └── MediaObject3D.ts │ │ │ │ ├── DetailsPageCanvas.ts │ │ │ │ └── shaders │ │ │ │ │ └── media │ │ │ │ │ ├── fragment.glsl │ │ │ │ │ └── vertex.glsl │ │ │ └── DetailsPage.ts │ │ ├── IndexPage │ │ │ ├── Canvas │ │ │ │ ├── Components │ │ │ │ │ ├── Image3D.ts │ │ │ │ │ └── MediaObject3D.ts │ │ │ │ ├── IndexPageCanvas.ts │ │ │ │ └── shaders │ │ │ │ │ └── media │ │ │ │ │ ├── fragment.glsl │ │ │ │ │ └── vertex.glsl │ │ │ └── IndexPage.ts │ │ ├── Page.ts │ │ ├── PageCanvas.ts │ │ └── PageManager.ts │ ├── Singletons │ │ ├── MouseMove.ts │ │ └── Scroll.ts │ ├── Utility │ │ └── Preloader.ts │ └── utils │ │ ├── getRand.ts │ │ ├── lerp.ts │ │ └── wrapEl.ts ├── components │ ├── CardContent │ │ └── CardContent.tsx │ ├── CardPreview │ │ └── CardPreview.tsx │ ├── Layout │ │ ├── Layout.module.scss │ │ └── Layout.tsx │ ├── LinkHandler │ │ └── LinkHandler.tsx │ └── RichText │ │ └── RichText.tsx ├── containers │ ├── DetailsPage │ │ ├── DetailsPage.tsx │ │ └── data.ts │ ├── ErrorPage │ │ └── ErrorPage.tsx │ └── IndexPage │ │ ├── IndexPage.tsx │ │ └── data.ts ├── pages │ ├── 404.tsx │ ├── _app.tsx │ ├── _document.tsx │ ├── _error.tsx │ ├── details │ │ └── [id] │ │ │ └── index.tsx │ └── index.tsx ├── seo │ ├── GoogleAnalytics │ │ └── GoogleAnalytics.tsx │ └── Head │ │ └── Head.tsx ├── styles │ ├── base │ │ ├── fonts.scss │ │ ├── global.scss │ │ └── reset.scss │ ├── components │ │ ├── animations.scss │ │ ├── canvas.scss │ │ ├── cardContent.scss │ │ ├── cardPreview.scss │ │ ├── pageWrapper.scss │ │ └── richText.scss │ ├── index.scss │ ├── pages │ │ ├── details.scss │ │ ├── error.scss │ │ └── index.scss │ └── utils │ │ ├── responsive.scss │ │ └── variables.scss ├── types.ts ├── utils │ ├── functions │ │ ├── getFrontHost.ts │ │ ├── isDev.ts │ │ ├── isTouchDevice.ts │ │ ├── sliceSlash.tsx │ │ └── stripHtml.ts │ ├── globalState.ts │ ├── prismic │ │ ├── client.ts │ │ ├── isrTimeout.ts │ │ └── queries │ │ │ ├── getCards.ts │ │ │ ├── getLayout.ts │ │ │ └── getSeoHead.ts │ ├── setCssVariables.ts │ └── sharedStyles.module.scss └── variables.ts └── tsconfig.json /README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | ``` 12 | 13 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 14 | 15 | You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file. 16 | 17 | [API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`. 18 | 19 | The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. 20 | 21 | ## Learn More 22 | 23 | To learn more about Next.js, take a look at the following resources: 24 | 25 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 26 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 27 | 28 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 29 | 30 | ## Deploy on Vercel 31 | 32 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 33 | 34 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 35 | -------------------------------------------------------------------------------- /frontend/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["next/babel"], 3 | "plugins": [] 4 | } 5 | -------------------------------------------------------------------------------- /frontend/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "./node_modules/gts/", 4 | "plugin:react/recommended", 5 | "plugin:react-hooks/recommended" 6 | ], 7 | "rules": { 8 | "@typescript-eslint/no-unused-vars": "off", 9 | "react/react-in-jsx-scope": "off", 10 | "@next/next/no-document-import-in-page": "off", 11 | "@typescript-eslint/no-empty-interface": "off", 12 | "react/prop-types": 0, 13 | "prettier/prettier": [ 14 | "error", 15 | { 16 | "endOfLine": "auto" 17 | } 18 | ] 19 | }, 20 | "settings": { 21 | "react": { 22 | "version": "detect" 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel 35 | -------------------------------------------------------------------------------- /frontend/.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: true, 3 | singleQuote: true, 4 | trailingComma: "all", 5 | printWidth: 80, 6 | }; 7 | -------------------------------------------------------------------------------- /frontend/global.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'prefix'; 2 | 3 | declare module '*.glsl' { 4 | const value: string; 5 | export default value; 6 | } 7 | 8 | declare module '*.glb' { 9 | const value: string; 10 | export default value; 11 | } 12 | 13 | declare module '*.mp3' { 14 | const src: string; 15 | export default src; 16 | } 17 | -------------------------------------------------------------------------------- /frontend/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | 5 | // NOTE: This file should not be edited 6 | // see https://nextjs.org/docs/basic-features/typescript for more information. 7 | -------------------------------------------------------------------------------- /frontend/next.config.js: -------------------------------------------------------------------------------- 1 | const nextConfig = { 2 | reactStrictMode: true, 3 | i18n: { 4 | locales: ['en-US'], 5 | defaultLocale: 'en-US', 6 | }, 7 | webpack: (config) => { 8 | config.module.rules.push({ 9 | test: /\.(glsl|vs|fs|vert|frag)$/, 10 | exclude: /node_modules/, 11 | use: ['raw-loader', 'glslify-loader'], 12 | }); 13 | 14 | config.module.rules.push({ 15 | test: /\.(mp3)$/, 16 | use: { 17 | loader: 'file-loader', 18 | options: { 19 | publicPath: '/_next/static/sounds/', 20 | outputPath: 'static/sounds/', 21 | name: '[name].[ext]', 22 | esModule: false, 23 | }, 24 | }, 25 | }); 26 | 27 | config.module.rules.push({ 28 | test: /\.(glb|gltf)$/, 29 | use: { 30 | loader: 'file-loader', 31 | options: { 32 | publicPath: '/_next/static/images', 33 | outputPath: 'static/images/', 34 | }, 35 | }, 36 | }); 37 | 38 | return config; 39 | }, 40 | }; 41 | 42 | module.exports = nextConfig; 43 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "0.2.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@prismicio/client": "^5.1.0", 13 | "@tweenjs/tween.js": "^18.6.4", 14 | "clsx": "^1.1.1", 15 | "fontfaceobserver": "^2.1.0", 16 | "intersection-observer": "^0.12.0", 17 | "lodash": "^4.17.21", 18 | "markdown-to-jsx": "^7.1.3", 19 | "next": "11.1.2", 20 | "normalize-wheel": "^1.0.1", 21 | "prefix": "^1.0.0", 22 | "react": "17.0.2", 23 | "react-dom": "17.0.2", 24 | "react-transition-group": "^4.4.2", 25 | "split-type": "^0.2.5", 26 | "three": "^0.132.2" 27 | }, 28 | "devDependencies": { 29 | "@types/fontfaceobserver": "^2.1.0", 30 | "@types/lodash": "^4.14.173", 31 | "@types/normalize-wheel": "^1.0.0", 32 | "@types/react": "17.0.24", 33 | "@types/react-transition-group": "^4.4.3", 34 | "@types/three": "^0.132.0", 35 | "eslint": "7.32.0", 36 | "eslint-config-next": "11.1.2", 37 | "eslint-plugin-react-hooks": "^4.2.0", 38 | "file-loader": "^6.2.0", 39 | "glslify-loader": "^2.0.0", 40 | "gts": "^3.1.0", 41 | "include-media": "^1.4.10", 42 | "next-compose-plugins": "^2.2.1", 43 | "prettier": "^2.4.1", 44 | "raw-loader": "^4.0.2", 45 | "sass": "^1.42.1", 46 | "typescript": "4.4.3" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michalzalobny/threejs-page-transition/66b9e17fd166413de937263992eaa00bd8c4f7ea/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/public/fonts/bonVoyage.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michalzalobny/threejs-page-transition/66b9e17fd166413de937263992eaa00bd8c4f7ea/frontend/public/fonts/bonVoyage.woff2 -------------------------------------------------------------------------------- /frontend/public/fonts/nexaBlack.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michalzalobny/threejs-page-transition/66b9e17fd166413de937263992eaa00bd8c4f7ea/frontend/public/fonts/nexaBlack.woff2 -------------------------------------------------------------------------------- /frontend/public/fonts/openSans400.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michalzalobny/threejs-page-transition/66b9e17fd166413de937263992eaa00bd8c4f7ea/frontend/public/fonts/openSans400.woff2 -------------------------------------------------------------------------------- /frontend/public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/src/classes/CanvasApp.ts: -------------------------------------------------------------------------------- 1 | import TWEEN, { Tween } from '@tweenjs/tween.js'; 2 | import * as THREE from 'three'; 3 | import debounce from 'lodash/debounce'; 4 | 5 | import { globalState } from 'utils/globalState'; 6 | 7 | import { MouseMove } from './Singletons/MouseMove'; 8 | import { Scroll } from './Singletons/Scroll'; 9 | import { Preloader } from './Utility/Preloader'; 10 | import { PageManager } from './Pages/PageManager'; 11 | import { InteractiveScene } from './Components/InteractiveScene'; 12 | import { Circle2D } from './Components/Circle2D'; 13 | 14 | export class CanvasApp extends THREE.EventDispatcher { 15 | static defaultFps = 60; 16 | static dtFps = 1000 / CanvasApp.defaultFps; 17 | 18 | static _instance: CanvasApp | null; 19 | static _canCreate = false; 20 | static getInstance() { 21 | if (!CanvasApp._instance) { 22 | CanvasApp._canCreate = true; 23 | CanvasApp._instance = new CanvasApp(); 24 | CanvasApp._canCreate = false; 25 | } 26 | 27 | return CanvasApp._instance; 28 | } 29 | 30 | _rendererWrapperEl: HTMLDivElement | null = null; 31 | _rafId: number | null = null; 32 | _isResumed = true; 33 | _lastFrameTime: number | null = null; 34 | _canvas: HTMLCanvasElement | null = null; 35 | _camera: THREE.PerspectiveCamera | null = null; 36 | _renderer: THREE.WebGLRenderer | null = null; 37 | _mouseMove = MouseMove.getInstance(); 38 | _scroll = Scroll.getInstance(); 39 | _preloader = new Preloader(); 40 | _pageManager = new PageManager(); 41 | _interactiveScene: InteractiveScene | null = null; 42 | _opacityTween: Tween<{ progress: number }> | null = null; 43 | circle2D = new Circle2D(); 44 | 45 | constructor() { 46 | super(); 47 | 48 | if (CanvasApp._instance || !CanvasApp._canCreate) { 49 | throw new Error('Use CanvasApp.getInstance()'); 50 | } 51 | 52 | CanvasApp._instance = this; 53 | } 54 | 55 | _onResizeDebounced = debounce(() => this._onResize(), 300); 56 | 57 | _onResize() { 58 | if (!this._rendererWrapperEl || !this._camera || !this._renderer) { 59 | return; 60 | } 61 | const rendererBounds = this._rendererWrapperEl.getBoundingClientRect(); 62 | const aspectRatio = rendererBounds.width / rendererBounds.height; 63 | this._camera.aspect = aspectRatio; 64 | 65 | //Set to match pixel size of the elements in three with pixel size of DOM elements 66 | this._camera.position.z = 1000; 67 | this._camera.fov = 68 | 2 * 69 | Math.atan(rendererBounds.height / 2 / this._camera.position.z) * 70 | (180 / Math.PI); 71 | 72 | this._renderer.setSize(rendererBounds.width, rendererBounds.height); 73 | this._renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); 74 | this._camera.updateProjectionMatrix(); 75 | 76 | this._interactiveScene && 77 | this._interactiveScene.setRendererBounds(rendererBounds); 78 | this._pageManager.setRendererBounds(rendererBounds); 79 | 80 | this.circle2D.setRendererBounds(rendererBounds); 81 | } 82 | 83 | _onVisibilityChange = () => { 84 | if (document.hidden) { 85 | this._stopAppFrame(); 86 | } else { 87 | this._resumeAppFrame(); 88 | } 89 | }; 90 | 91 | _animateGlobalOpacity() { 92 | if (this._opacityTween) { 93 | this._opacityTween.stop(); 94 | } 95 | 96 | this._opacityTween = new TWEEN.Tween({ 97 | progress: globalState.globalOpacity, 98 | }) 99 | .to({ progress: 1 }, 1300) 100 | 101 | .easing(TWEEN.Easing.Exponential.InOut) 102 | .onUpdate((obj) => { 103 | globalState.globalOpacity = obj.progress; 104 | }); 105 | 106 | this._opacityTween.start(); 107 | } 108 | 109 | _onAssetsLoaded = (e: THREE.Event) => { 110 | globalState.textureItems = (e.target as Preloader).textureItems; 111 | this._animateGlobalOpacity(); 112 | 113 | const pageWrapper = Array.from( 114 | document.querySelectorAll('.page-wrapper'), 115 | )[0] as HTMLElement; 116 | 117 | const pageOverlay = Array.from( 118 | document.querySelectorAll('.page__overlay'), 119 | )[0] as HTMLElement; 120 | 121 | pageWrapper.classList.add('page-wrapper--active'); 122 | pageOverlay.classList.add('page__overlay--disabled'); 123 | this._pageManager.onAssetsLoaded(); 124 | }; 125 | 126 | _addListeners() { 127 | window.addEventListener('resize', this._onResizeDebounced); 128 | window.addEventListener('visibilitychange', this._onVisibilityChange); 129 | this._preloader.addEventListener('loaded', this._onAssetsLoaded); 130 | } 131 | 132 | _removeListeners() { 133 | window.removeEventListener('resize', this._onResizeDebounced); 134 | window.removeEventListener('visibilitychange', this._onVisibilityChange); 135 | this._preloader.removeEventListener('loaded', this._onAssetsLoaded); 136 | } 137 | 138 | _resumeAppFrame() { 139 | this._rafId = window.requestAnimationFrame(this._renderOnFrame); 140 | this._isResumed = true; 141 | } 142 | 143 | _renderOnFrame = (time: number) => { 144 | this._rafId = window.requestAnimationFrame(this._renderOnFrame); 145 | 146 | if (this._isResumed || !this._lastFrameTime) { 147 | this._lastFrameTime = window.performance.now(); 148 | this._isResumed = false; 149 | return; 150 | } 151 | 152 | TWEEN.update(time); 153 | 154 | const delta = time - this._lastFrameTime; 155 | let slowDownFactor = delta / CanvasApp.dtFps; 156 | 157 | //Rounded slowDown factor to the nearest integer reduces physics lags 158 | const slowDownFactorRounded = Math.round(slowDownFactor); 159 | 160 | if (slowDownFactorRounded >= 1) { 161 | slowDownFactor = slowDownFactorRounded; 162 | } 163 | this._lastFrameTime = time; 164 | 165 | this._mouseMove.update({ delta, slowDownFactor, time }); 166 | this._scroll.update({ delta, slowDownFactor, time }); 167 | 168 | if (!this._interactiveScene || !this._renderer || !this._camera) { 169 | return; 170 | } 171 | 172 | this._interactiveScene.update({ delta, slowDownFactor, time }); 173 | this._pageManager.update({ delta, slowDownFactor, time }); 174 | this.circle2D.update({ delta, slowDownFactor, time }); 175 | this._renderer.render(this._interactiveScene, this._camera); 176 | }; 177 | 178 | _stopAppFrame() { 179 | if (this._rafId) { 180 | window.cancelAnimationFrame(this._rafId); 181 | } 182 | } 183 | 184 | destroy() { 185 | if (this._canvas && this._canvas.parentNode) { 186 | this._canvas.parentNode.removeChild(this._canvas); 187 | } 188 | this._stopAppFrame(); 189 | this._removeListeners(); 190 | 191 | this.circle2D.destroy(); 192 | 193 | this._interactiveScene && this._interactiveScene.destroy(); 194 | this._preloader.destroy(); 195 | } 196 | 197 | handlePageEnter(pageEl: HTMLElement) { 198 | this._pageManager.handlePageEnter(pageEl); 199 | } 200 | 201 | init() { 202 | const page = Array.from( 203 | document.querySelectorAll(`[data-pageid="${globalState.currentPageId}"]`), 204 | )[0] as HTMLElement; 205 | 206 | this._onResize(); 207 | this.handlePageEnter(page); 208 | globalState.isCanvasAppInit = true; 209 | } 210 | 211 | set rendererWrapperEl(el: HTMLDivElement) { 212 | this._rendererWrapperEl = el; 213 | this._canvas = document.createElement('canvas'); 214 | this._rendererWrapperEl.appendChild(this._canvas); 215 | this._camera = new THREE.PerspectiveCamera(); 216 | 217 | this._renderer = new THREE.WebGLRenderer({ 218 | canvas: this._canvas, 219 | antialias: true, 220 | alpha: true, 221 | }); 222 | 223 | this._interactiveScene = new InteractiveScene({ 224 | camera: this._camera, 225 | }); 226 | 227 | this._pageManager.setInteractiveScene(this._interactiveScene); 228 | 229 | this._addListeners(); 230 | this._resumeAppFrame(); 231 | } 232 | 233 | set imagesToPreload(images: string[]) { 234 | this._preloader.images = images; 235 | } 236 | } 237 | -------------------------------------------------------------------------------- /frontend/src/classes/Components/Circle2D.ts: -------------------------------------------------------------------------------- 1 | import TWEEN, { Tween } from '@tweenjs/tween.js'; 2 | 3 | import { Bounds, UpdateInfo } from 'types'; 4 | import { indexCurtainDuration } from 'variables'; 5 | import { isTouchDevice } from 'utils/functions/isTouchDevice'; 6 | 7 | import { MouseMove } from '../Singletons/MouseMove'; 8 | import { lerp } from '../utils/lerp'; 9 | 10 | export class Circle2D { 11 | static mouseLerp = 0.25; 12 | 13 | _mouseMove = MouseMove.getInstance(); 14 | _canvas: HTMLCanvasElement; 15 | _ctx: CanvasRenderingContext2D | null; 16 | _hoverProgress = 0; 17 | _hoverProgressTween: Tween<{ progress: number }> | null = null; 18 | _color = '#ffffff'; 19 | _rendererBounds: Bounds = { height: 10, width: 100 }; 20 | _mouse = { 21 | x: { 22 | target: 0, 23 | current: 0, 24 | }, 25 | y: { 26 | target: 0, 27 | current: 0, 28 | }, 29 | }; 30 | _radius = 35; 31 | _extraRadius = 15; 32 | _showProgress = 0; 33 | _showProgressTween: Tween<{ progress: number }> | null = null; 34 | _isCircleInit = false; 35 | _isTouchDevice = isTouchDevice(); 36 | 37 | constructor() { 38 | this._canvas = document.createElement('canvas'); 39 | this._canvas.className = 'circle-cursor'; 40 | this._ctx = this._canvas.getContext('2d'); 41 | document.body.appendChild(this._canvas); 42 | 43 | this._addListeners(); 44 | } 45 | 46 | _setSizes() { 47 | if (this._canvas && this._ctx) { 48 | const w = this._rendererBounds.width; 49 | const h = this._rendererBounds.height; 50 | const ratio = Math.min(window.devicePixelRatio, 2); 51 | 52 | this._canvas.width = w * ratio; 53 | this._canvas.height = h * ratio; 54 | this._canvas.style.width = w + 'px'; 55 | this._canvas.style.height = h + 'px'; 56 | this._ctx.setTransform(ratio, 0, 0, ratio, 0, 0); 57 | } 58 | } 59 | 60 | _animateShow(destination: number) { 61 | if (this._isTouchDevice) return; 62 | 63 | if (this._showProgressTween) { 64 | this._showProgressTween.stop(); 65 | } 66 | 67 | this._showProgressTween = new TWEEN.Tween({ 68 | progress: this._showProgress, 69 | }) 70 | .to({ progress: destination }, indexCurtainDuration) 71 | .easing(TWEEN.Easing.Exponential.InOut) 72 | .onUpdate((obj) => { 73 | this._showProgress = obj.progress; 74 | }); 75 | 76 | this._showProgressTween.start(); 77 | } 78 | 79 | _animateHover(destination: number) { 80 | if (this._hoverProgressTween) { 81 | this._hoverProgressTween.stop(); 82 | } 83 | 84 | this._hoverProgressTween = new TWEEN.Tween({ 85 | progress: this._hoverProgress, 86 | }) 87 | .to({ progress: destination }, indexCurtainDuration) 88 | .easing(TWEEN.Easing.Exponential.InOut) 89 | .onUpdate((obj) => { 90 | this._hoverProgress = obj.progress; 91 | }); 92 | 93 | this._hoverProgressTween.start(); 94 | } 95 | 96 | _onMouseMove = (e: THREE.Event) => { 97 | this._mouse.x.target = (e.target as MouseMove).mouse.x; 98 | this._mouse.y.target = (e.target as MouseMove).mouse.y; 99 | }; 100 | 101 | _onMouseMoveInternal = () => { 102 | if (!this._isCircleInit) { 103 | this._isCircleInit = true; 104 | this._animateShow(1); 105 | } 106 | }; 107 | 108 | _onMouseOut = (event: MouseEvent) => { 109 | if ( 110 | event.clientY <= 0 || 111 | event.clientX <= 0 || 112 | event.clientX >= this._rendererBounds.width || 113 | event.clientY >= this._rendererBounds.height 114 | ) { 115 | this._animateShow(0); 116 | } 117 | }; 118 | 119 | _onMouseEnter = () => { 120 | this._animateShow(1); 121 | }; 122 | 123 | _addListeners() { 124 | this._mouseMove.addEventListener('mousemove', this._onMouseMove); 125 | document.addEventListener('mouseenter', this._onMouseEnter); 126 | document.addEventListener('mouseleave', this._onMouseOut); 127 | document.addEventListener('mousemove', this._onMouseMoveInternal); 128 | } 129 | 130 | _removeListeners() { 131 | this._mouseMove.removeEventListener('mousemove', this._onMouseMove); 132 | document.removeEventListener('mouseenter', this._onMouseEnter); 133 | document.removeEventListener('mouseleave', this._onMouseOut); 134 | document.removeEventListener('mousemove', this._onMouseMoveInternal); 135 | } 136 | 137 | _draw() { 138 | if (!this._ctx) return; 139 | 140 | this._ctx.beginPath(); 141 | this._ctx.arc( 142 | this._mouse.x.current, 143 | this._mouse.y.current, 144 | (this._radius + this._extraRadius * this._hoverProgress) * 145 | this._showProgress, 146 | 0, 147 | 2 * Math.PI, 148 | ); 149 | this._ctx.fillStyle = 'rgba(255,255,255, 1)'; 150 | this._ctx.fill(); 151 | } 152 | 153 | _clear() { 154 | if (this._ctx) 155 | this._ctx.clearRect(0, 0, this._canvas.width, this._canvas.height); 156 | } 157 | 158 | update(updateInfo: UpdateInfo) { 159 | this._clear(); 160 | this._draw(); 161 | 162 | this._mouse.x.current = lerp( 163 | this._mouse.x.current, 164 | this._mouse.x.target, 165 | Circle2D.mouseLerp * updateInfo.slowDownFactor, 166 | ); 167 | 168 | this._mouse.y.current = lerp( 169 | this._mouse.y.current, 170 | this._mouse.y.target, 171 | Circle2D.mouseLerp * updateInfo.slowDownFactor, 172 | ); 173 | } 174 | 175 | setRendererBounds(rendererBounds: Bounds) { 176 | this._rendererBounds = rendererBounds; 177 | this._setSizes(); 178 | } 179 | 180 | destroy() { 181 | this._removeListeners(); 182 | } 183 | 184 | zoomIn() { 185 | this._animateHover(1); 186 | } 187 | 188 | zoomOut() { 189 | this._animateHover(0); 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /frontend/src/classes/Components/InteractiveObject3D.ts: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | 3 | import { UpdateInfo } from 'types'; 4 | 5 | export type ColliderName = 'image3D'; 6 | 7 | export class InteractiveObject3D extends THREE.Object3D { 8 | colliderName: ColliderName | null = null; 9 | _isHovered = false; 10 | 11 | constructor() { 12 | super(); 13 | } 14 | 15 | setColliderName(name: ColliderName) { 16 | this.colliderName = name; 17 | } 18 | 19 | onMouseEnter() { 20 | this._isHovered = true; 21 | this.dispatchEvent({ type: 'mouseenter' }); 22 | } 23 | 24 | onMouseLeave() { 25 | this._isHovered = false; 26 | this.dispatchEvent({ type: 'mouseleave' }); 27 | } 28 | 29 | onClick() { 30 | this.dispatchEvent({ type: 'click' }); 31 | } 32 | 33 | update(updateInfo: UpdateInfo) {} 34 | 35 | destroy() {} 36 | } 37 | -------------------------------------------------------------------------------- /frontend/src/classes/Components/InteractiveScene.ts: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | 3 | import { Bounds, UpdateInfo, Mouse } from 'types'; 4 | 5 | import { MouseMove } from '../Singletons/MouseMove'; 6 | import { InteractiveObject3D, ColliderName } from './InteractiveObject3D'; 7 | import { IntersectiveBackground3D } from './IntersectiveBackground3D'; 8 | import { lerp } from '../utils/lerp'; 9 | 10 | interface Constructor { 11 | camera: THREE.PerspectiveCamera; 12 | } 13 | 14 | interface PerformRaycast { 15 | x: number; 16 | y: number; 17 | colliderName?: ColliderName; 18 | fnToCallIfHit?: string; 19 | } 20 | 21 | export class InteractiveScene extends THREE.Scene { 22 | static lerpEase = 0.06; 23 | 24 | _raycaster = new THREE.Raycaster(); 25 | _rendererBounds: Bounds = { height: 100, width: 100 }; 26 | _camera: THREE.PerspectiveCamera; 27 | _mouseMove = MouseMove.getInstance(); 28 | 29 | _mouse2D: Mouse = { 30 | current: { x: 0, y: 0 }, 31 | target: { x: 0, y: 0 }, 32 | }; 33 | _mouse3D: Mouse = { 34 | current: { x: 0, y: 0 }, 35 | target: { x: 0, y: 0 }, 36 | }; 37 | _mouseStrength = { 38 | current: 0, 39 | target: 0, 40 | }; 41 | 42 | _intersectPoint = new THREE.Vector3(0); 43 | _intersectPointLerp = new THREE.Vector3(0); 44 | _hoveredObject: InteractiveObject3D | null = null; 45 | _canHoverObject = true; 46 | _intersectiveBackground3D = new IntersectiveBackground3D(); 47 | 48 | constructor({ camera }: Constructor) { 49 | super(); 50 | this._camera = camera; 51 | 52 | this.add(this._intersectiveBackground3D); 53 | this._addListeners(); 54 | } 55 | 56 | _performRaycast({ x, y, colliderName, fnToCallIfHit }: PerformRaycast) { 57 | this._raycaster.setFromCamera({ x, y }, this._camera); 58 | const intersects = this._raycaster.intersectObjects(this.children, true); 59 | const intersectingObjects: InteractiveObject3D[] = []; 60 | 61 | for (let i = 0; i < intersects.length; ++i) { 62 | const interactiveObject = intersects[i].object 63 | .parent as InteractiveObject3D; 64 | if (interactiveObject.colliderName) { 65 | intersectingObjects.push(interactiveObject); 66 | if (fnToCallIfHit) { 67 | if (interactiveObject.colliderName === colliderName) { 68 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 69 | //@ts-ignore 70 | interactiveObject[fnToCallIfHit](); 71 | } 72 | } 73 | break; 74 | } 75 | } 76 | 77 | return intersectingObjects; 78 | } 79 | 80 | _onMouseMove = (e: THREE.Event) => { 81 | this._mouseStrength.target = (e.target as MouseMove).strength; 82 | 83 | const mouseX = (e.target as MouseMove).mouse.x; 84 | const mouseY = (e.target as MouseMove).mouse.y; 85 | 86 | this._mouse2D.target.x = mouseX; 87 | this._mouse2D.target.y = mouseY; 88 | 89 | this._mouse3D.target.x = (mouseX / this._rendererBounds.width) * 2 - 1; 90 | this._mouse3D.target.y = -(mouseY / this._rendererBounds.height) * 2 + 1; 91 | 92 | const intersectPoint = this._intersectiveBackground3D.getIntersectPoint( 93 | this._mouse3D.target.x, 94 | this._mouse3D.target.y, 95 | this._raycaster, 96 | this._camera, 97 | ); 98 | 99 | if (intersectPoint) { 100 | this._intersectPoint = intersectPoint; 101 | } 102 | 103 | const objects = this._performRaycast({ 104 | x: this._mouse3D.target.x, 105 | y: this._mouse3D.target.y, 106 | }); 107 | 108 | if (objects.length > 0 && this._canHoverObject) { 109 | const hoveredObject = objects[0]; 110 | if (hoveredObject !== this._hoveredObject) { 111 | if (this._hoveredObject) { 112 | this._hoveredObject.onMouseLeave(); 113 | } 114 | this._hoveredObject = hoveredObject; 115 | this._hoveredObject.onMouseEnter(); 116 | } 117 | } else if (this._hoveredObject) { 118 | this._hoveredObject.onMouseLeave(); 119 | this._hoveredObject = null; 120 | } 121 | }; 122 | 123 | _onClick = (e: THREE.Event) => { 124 | const mouseX = (e.target as MouseMove).mouse.x; 125 | const mouseY = (e.target as MouseMove).mouse.y; 126 | 127 | const mouse3DX = (mouseX / this._rendererBounds.width) * 2 - 1; 128 | const mouse3DY = -(mouseY / this._rendererBounds.height) * 2 + 1; 129 | 130 | this._performRaycast({ 131 | x: mouse3DX, 132 | y: mouse3DY, 133 | colliderName: 'image3D', 134 | fnToCallIfHit: 'onClick', 135 | }); 136 | }; 137 | 138 | _addListeners() { 139 | this._mouseMove.addEventListener('mousemove', this._onMouseMove); 140 | this._mouseMove.addEventListener('click', this._onClick); 141 | } 142 | 143 | _removeListeners() { 144 | this._mouseMove.removeEventListener('mousemove', this._onMouseMove); 145 | this._mouseMove.removeEventListener('click', this._onClick); 146 | } 147 | 148 | setRendererBounds(bounds: Bounds) { 149 | this._rendererBounds = bounds; 150 | } 151 | 152 | update(updateInfo: UpdateInfo) { 153 | //Lerp mouse move strength 154 | this._mouseStrength.current = lerp( 155 | this._mouseStrength.current, 156 | this._mouseStrength.target, 157 | InteractiveScene.lerpEase * updateInfo.slowDownFactor, 158 | ); 159 | 160 | //Lerp 2D mouse coords 161 | this._mouse2D.current.x = lerp( 162 | this._mouse2D.current.x, 163 | this._mouse2D.target.x, 164 | InteractiveScene.lerpEase * updateInfo.slowDownFactor, 165 | ); 166 | this._mouse2D.current.y = lerp( 167 | this._mouse2D.current.y, 168 | this._mouse2D.target.y, 169 | InteractiveScene.lerpEase * updateInfo.slowDownFactor, 170 | ); 171 | 172 | //Lerp 3D mouse coords 173 | this._mouse3D.current.x = lerp( 174 | this._mouse3D.current.x, 175 | this._mouse3D.target.x, 176 | InteractiveScene.lerpEase * updateInfo.slowDownFactor, 177 | ); 178 | this._mouse3D.current.y = lerp( 179 | this._mouse3D.current.y, 180 | this._mouse3D.target.y, 181 | InteractiveScene.lerpEase * updateInfo.slowDownFactor, 182 | ); 183 | 184 | //Lerp intersect 3D point 185 | const intersectLerpX = lerp( 186 | this._intersectPointLerp.x, 187 | this._intersectPoint.x, 188 | InteractiveScene.lerpEase * updateInfo.slowDownFactor, 189 | ); 190 | 191 | const intersectLerpY = lerp( 192 | this._intersectPointLerp.y, 193 | this._intersectPoint.y, 194 | InteractiveScene.lerpEase * updateInfo.slowDownFactor, 195 | ); 196 | 197 | const intersectLerpZ = lerp( 198 | this._intersectPointLerp.z, 199 | this._intersectPoint.z, 200 | InteractiveScene.lerpEase * updateInfo.slowDownFactor, 201 | ); 202 | 203 | this._intersectPointLerp.set( 204 | intersectLerpX, 205 | intersectLerpY, 206 | intersectLerpZ, 207 | ); 208 | } 209 | 210 | destroy() { 211 | this._removeListeners(); 212 | this._intersectiveBackground3D.destroy(); 213 | this.remove(this._intersectiveBackground3D); 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /frontend/src/classes/Components/IntersectiveBackground3D.ts: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | 3 | import { UpdateInfo } from 'types'; 4 | 5 | import { InteractiveObject3D } from './InteractiveObject3D'; 6 | 7 | export class IntersectiveBackground3D extends InteractiveObject3D { 8 | _mesh: THREE.Mesh | null = null; 9 | _geometry: THREE.PlaneGeometry | null = null; 10 | _material: THREE.MeshBasicMaterial | null = null; 11 | 12 | constructor() { 13 | super(); 14 | this._drawRaycasterPlane(); 15 | } 16 | 17 | _drawRaycasterPlane() { 18 | this._geometry = new THREE.PlaneBufferGeometry(5000, 5000); 19 | this._material = new THREE.MeshBasicMaterial({ 20 | transparent: true, 21 | depthWrite: false, 22 | depthTest: false, 23 | opacity: 0, 24 | }); 25 | 26 | this._mesh = new THREE.Mesh(this._geometry, this._material); 27 | this.add(this._mesh); 28 | } 29 | 30 | getIntersectPoint( 31 | x: number, 32 | y: number, 33 | raycaster: THREE.Raycaster, 34 | camera: THREE.Camera, 35 | ) { 36 | raycaster.setFromCamera({ x, y }, camera); 37 | 38 | if (this._mesh) { 39 | const intersects = raycaster.intersectObjects([this._mesh], true); 40 | if (intersects[0]) { 41 | return intersects[0].point; 42 | } 43 | } 44 | 45 | return null; 46 | } 47 | 48 | setPlaneDepth(value: number) { 49 | if (this._mesh) { 50 | this._mesh.position.z = value; 51 | } 52 | } 53 | 54 | update(updateInfo: UpdateInfo) { 55 | super.update(updateInfo); 56 | } 57 | 58 | destroy() { 59 | super.destroy(); 60 | this._geometry?.dispose(); 61 | this._material?.dispose(); 62 | if (this._mesh) { 63 | this.remove(this._mesh); 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /frontend/src/classes/Components/Transition.ts: -------------------------------------------------------------------------------- 1 | import TWEEN, { Tween } from '@tweenjs/tween.js'; 2 | import Prefix from 'prefix'; 3 | 4 | import { pageTransitionDuration } from 'variables'; 5 | import { Bounds } from 'types'; 6 | 7 | export class Transition { 8 | static animateParentRatio = 0.1; // value from 0 to 1, fires animating in elements; 9 | 10 | _canvas: HTMLCanvasElement; 11 | _ctx: CanvasRenderingContext2D | null; 12 | _curtainProgress = 0; 13 | _curtainProgressTween: Tween<{ progress: number }> | null = null; 14 | _color = '#000000'; 15 | _transformPrefix = Prefix('transform'); 16 | _parentFn: (() => void) | null = null; 17 | _rendererBounds: Bounds = { height: 10, width: 100 }; 18 | 19 | constructor() { 20 | this._canvas = document.createElement('canvas'); 21 | this._canvas.className = 'transition'; 22 | this._ctx = this._canvas.getContext('2d'); 23 | document.body.appendChild(this._canvas); 24 | } 25 | 26 | _setSizes() { 27 | if (this._canvas && this._ctx) { 28 | const w = this._rendererBounds.width; 29 | const h = this._rendererBounds.height; 30 | const ratio = Math.min(window.devicePixelRatio, 2); 31 | 32 | this._canvas.width = w * ratio; 33 | this._canvas.height = h * ratio; 34 | this._canvas.style.width = w + 'px'; 35 | this._canvas.style.height = h + 'px'; 36 | this._ctx.setTransform(ratio, 0, 0, ratio, 0, 0); 37 | } 38 | } 39 | 40 | _animateProgress(destination: number) { 41 | if (this._curtainProgressTween) { 42 | this._curtainProgressTween.stop(); 43 | } 44 | 45 | this._curtainProgressTween = new TWEEN.Tween({ 46 | progress: this._curtainProgress, 47 | }) 48 | .to({ progress: destination }, pageTransitionDuration) 49 | .easing(TWEEN.Easing.Exponential.InOut) 50 | .onUpdate((obj) => { 51 | this._curtainProgress = obj.progress; 52 | this._onUpdate(); 53 | }) 54 | .onComplete(() => { 55 | if (destination === 1) this._hide(); 56 | if (destination === 0 && this._parentFn) this._parentFn(); 57 | }); 58 | 59 | this._curtainProgressTween.start(); 60 | } 61 | 62 | _hide() { 63 | this._canvas.style[this._transformPrefix] = 'rotate(0deg)'; 64 | this._animateProgress(0); 65 | } 66 | 67 | _onUpdate() { 68 | if (!this._ctx) { 69 | return; 70 | } 71 | 72 | this._ctx.clearRect( 73 | 0, 74 | 0, 75 | this._rendererBounds.width, 76 | this._rendererBounds.height, 77 | ); 78 | this._ctx.save(); 79 | this._ctx.beginPath(); 80 | 81 | const segments = 20; 82 | 83 | const widthSegments = Math.ceil(this._rendererBounds.width / segments); 84 | this._ctx.moveTo(this._rendererBounds.width, this._rendererBounds.height); 85 | this._ctx.lineTo(0, this._rendererBounds.height); 86 | 87 | const t = (1 - this._curtainProgress) * this._rendererBounds.height; 88 | const amplitude = 89 | this._rendererBounds.width * 90 | 0.1 * 91 | Math.sin(this._curtainProgress * Math.PI); 92 | 93 | this._ctx.lineTo(0, t); 94 | 95 | for (let index = 0; index <= widthSegments; index++) { 96 | const n = segments * index; 97 | const r = 98 | t - Math.sin((n / this._rendererBounds.width) * Math.PI) * amplitude; 99 | 100 | this._ctx.lineTo(n, r); 101 | } 102 | 103 | this._ctx.fillStyle = this._color; 104 | this._ctx.fill(); 105 | this._ctx.restore(); 106 | } 107 | 108 | show(color: string, parentFn: () => void) { 109 | this._color = color; 110 | this._canvas.style[this._transformPrefix] = 'rotate(180deg)'; 111 | this._parentFn = parentFn; 112 | this._animateProgress(1); 113 | } 114 | 115 | setRendererBounds(rendererBounds: Bounds) { 116 | this._rendererBounds = rendererBounds; 117 | this._setSizes(); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /frontend/src/classes/HTMLComponents/Animation.ts: -------------------------------------------------------------------------------- 1 | import Prefix from 'prefix'; 2 | 3 | interface Constructor { 4 | observerElement?: HTMLElement | null; 5 | element: HTMLElement; 6 | } 7 | 8 | export class Animation { 9 | _observerElement: HTMLElement; 10 | _element: HTMLElement; 11 | _transformPrefix = Prefix('transform'); 12 | _observer: IntersectionObserver; 13 | _triggerOnce = true; 14 | _shouldObserve: boolean; 15 | 16 | constructor({ observerElement, element }: Constructor) { 17 | this._element = element; 18 | const shouldObserve = this._element.dataset.observer !== 'none'; 19 | 20 | if (observerElement) this._observerElement = observerElement; 21 | else this._observerElement = this._element; 22 | 23 | this._shouldObserve = shouldObserve; 24 | this._observer = new IntersectionObserver(this._handleIntersection); 25 | } 26 | 27 | _handleIntersection = ( 28 | entries: IntersectionObserverEntry[], 29 | observer: IntersectionObserver, 30 | ) => { 31 | if (!this._shouldObserve) return this.animateIn(); 32 | 33 | entries.forEach((entry) => { 34 | if (entry.intersectionRatio > 0) { 35 | this.animateIn(); 36 | if (this._triggerOnce) observer.unobserve(entry.target); 37 | } else { 38 | this.animateOut(); 39 | } 40 | }); 41 | }; 42 | 43 | initObserver() { 44 | this._observer.unobserve(this._observerElement); 45 | this._observer.observe(this._observerElement); 46 | } 47 | 48 | animateIn() {} 49 | 50 | animateOut() {} 51 | 52 | onResize() {} 53 | } 54 | -------------------------------------------------------------------------------- /frontend/src/classes/HTMLComponents/BottomHide.ts: -------------------------------------------------------------------------------- 1 | import { pageTransitionDuration } from 'variables'; 2 | 3 | import { wrapEl } from '../utils/wrapEl'; 4 | 5 | interface Constructor { 6 | element: HTMLElement; 7 | } 8 | 9 | export class BottomHide { 10 | _element: HTMLElement; 11 | _innerWrapper: HTMLElement; 12 | _outerWrapper: HTMLElement; 13 | 14 | constructor({ element }: Constructor) { 15 | if (!element.dataset.wrapped) { 16 | wrapEl({ el: element, wrapperClass: 'bottom-hide__inner' }); 17 | wrapEl({ 18 | el: element.parentElement, 19 | wrapperClass: 'bottom-hide__outer', 20 | }); 21 | element.dataset.wrapped = 'wrapped'; 22 | } 23 | 24 | const innerWrapper = element.parentElement as HTMLElement; 25 | const outerWrapper = innerWrapper.parentElement as HTMLElement; 26 | 27 | this._element = element; 28 | 29 | this._innerWrapper = innerWrapper; 30 | this._outerWrapper = outerWrapper; 31 | } 32 | 33 | animateIn() { 34 | this._innerWrapper.classList.add('bottom-hide__inner--active'); 35 | this._innerWrapper.style.transition = `transform ${ 36 | pageTransitionDuration * 0.8 37 | }ms cubic-bezier(0.77, 0, 0.175, 1)`; 38 | } 39 | 40 | animateOut() { 41 | this._innerWrapper.classList.remove('bottom-hide__inner--active'); 42 | this._innerWrapper.style.transition = `transform ${ 43 | pageTransitionDuration * 0.7 44 | }ms cubic-bezier(0.77, 0, 0.175, 1)`; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /frontend/src/classes/HTMLComponents/Curtain.ts: -------------------------------------------------------------------------------- 1 | import TWEEN, { Tween } from '@tweenjs/tween.js'; 2 | 3 | import { AnimateProps } from 'types'; 4 | import { globalState } from 'utils/globalState'; 5 | import { indexCurtainDuration } from 'variables'; 6 | 7 | import { Animation } from './Animation'; 8 | 9 | interface Constructor { 10 | element: HTMLElement; 11 | } 12 | 13 | export class Curtain extends Animation { 14 | static topId = '[data-curtain="top"]'; 15 | static bottomId = '[data-curtain="bottom"]'; 16 | static hoverTarget = '[data-curtain="hover"]'; 17 | 18 | _curtainTop: HTMLElement; 19 | _curtainTopChild: HTMLElement; 20 | _curtainBottom: HTMLElement; 21 | _curtainBottomChild: HTMLElement; 22 | _hoverProgress = 0; 23 | _hoverTween: Tween<{ progress: number }> | null = null; 24 | _hoverTargetEl: HTMLElement; 25 | 26 | constructor({ element }: Constructor) { 27 | super({ element }); 28 | 29 | this._hoverTargetEl = Array.from( 30 | this._element.querySelectorAll(Curtain.hoverTarget), 31 | )[0] as HTMLElement; 32 | 33 | this._curtainTop = Array.from( 34 | this._element.querySelectorAll(Curtain.topId), 35 | )[0] as HTMLElement; 36 | 37 | this._curtainTopChild = this._curtainTop.childNodes[0] as HTMLElement; 38 | 39 | this._curtainBottom = Array.from( 40 | this._element.querySelectorAll(Curtain.bottomId), 41 | )[0] as HTMLElement; 42 | 43 | this._curtainBottomChild = this._curtainBottom.childNodes[0] as HTMLElement; 44 | 45 | this._addListeners(); 46 | } 47 | 48 | _animateHover({ 49 | destination, 50 | duration = indexCurtainDuration, 51 | delay = 0, 52 | easing = TWEEN.Easing.Exponential.InOut, 53 | }: AnimateProps) { 54 | if (this._hoverTween) { 55 | this._hoverTween.stop(); 56 | } 57 | 58 | this._hoverTween = new TWEEN.Tween({ 59 | progress: this._hoverProgress, 60 | }) 61 | .to({ progress: destination }, duration) 62 | .delay(delay) 63 | .easing(easing) 64 | .onUpdate((obj) => { 65 | this._hoverProgress = obj.progress; 66 | 67 | this._curtainTop.style[this._transformPrefix] = `translateY(${ 68 | this._hoverProgress * -50 + '%' 69 | })`; 70 | 71 | this._curtainTopChild.style[this._transformPrefix] = `translateY(${ 72 | this._hoverProgress * 50 + '%' 73 | })`; 74 | 75 | this._curtainBottom.style[this._transformPrefix] = `translateY(${ 76 | this._hoverProgress * 50 + '%' 77 | })`; 78 | 79 | this._curtainBottomChild.style[this._transformPrefix] = `translateY(${ 80 | this._hoverProgress * -50 + '%' 81 | })`; 82 | }); 83 | 84 | this._hoverTween.start(); 85 | } 86 | 87 | _onMouseEnter = () => { 88 | this._animateHover({ destination: 1 }); 89 | }; 90 | 91 | _onMouseLeave = () => { 92 | this._animateHover({ destination: 0 }); 93 | }; 94 | 95 | _onClick = () => { 96 | const elId = this._element.dataset.curtainUid; 97 | 98 | if (globalState.router) { 99 | globalState.router.push('/details/[id]', `/details/${elId}`); 100 | } 101 | }; 102 | 103 | _addListeners() { 104 | this._hoverTargetEl.addEventListener('mouseenter', this._onMouseEnter); 105 | this._hoverTargetEl.addEventListener('mouseleave', this._onMouseLeave); 106 | this._hoverTargetEl.addEventListener('click', this._onClick); 107 | } 108 | 109 | stopHoverTween() { 110 | if (this._hoverTween) { 111 | this._hoverTween.stop(); 112 | } 113 | } 114 | 115 | removeListeners() { 116 | this._hoverTargetEl.removeEventListener('mouseenter', this._onMouseEnter); 117 | this._hoverTargetEl.removeEventListener('mouseleave', this._onMouseLeave); 118 | this._hoverTargetEl.removeEventListener('click', this._onClick); 119 | } 120 | 121 | animateIn() { 122 | super.animateIn(); 123 | } 124 | 125 | animateOut() { 126 | super.animateOut(); 127 | } 128 | 129 | onResize() { 130 | super.onResize(); 131 | 132 | this.initObserver(); 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /frontend/src/classes/HTMLComponents/MoreLabel.ts: -------------------------------------------------------------------------------- 1 | import { indexCurtainDuration } from 'variables'; 2 | 3 | import { Animation } from './Animation'; 4 | 5 | interface Constructor { 6 | element: HTMLElement; 7 | } 8 | 9 | export class MoreLabel extends Animation { 10 | static lineId = '[data-more="line"]'; 11 | static labelId = '[data-more="label"]'; 12 | 13 | _lineEls: HTMLElement[]; 14 | _labelEls: HTMLElement[]; 15 | 16 | constructor({ element }: Constructor) { 17 | super({ element }); 18 | 19 | this._lineEls = Array.from( 20 | this._element.querySelectorAll(MoreLabel.lineId), 21 | ) as HTMLElement[]; 22 | 23 | this._labelEls = Array.from( 24 | this._element.querySelectorAll(MoreLabel.labelId), 25 | ) as HTMLElement[]; 26 | 27 | this._addListeners(); 28 | } 29 | 30 | _onMouseEnter = () => { 31 | this._lineEls.forEach((el) => { 32 | el.style.transition = `transform ${indexCurtainDuration}ms cubic-bezier(0.77, 0, 0.175, 1)`; 33 | el.classList.add('card-content__more-label__line--active'); 34 | }); 35 | 36 | this._labelEls.forEach((el) => { 37 | el.style.transition = `transform ${indexCurtainDuration}ms cubic-bezier(0.77, 0, 0.175, 1)`; 38 | el.classList.add('card-content__more-label__more--active'); 39 | }); 40 | }; 41 | 42 | _onMouseLeave = () => { 43 | this._lineEls.forEach((el) => { 44 | el.classList.remove('card-content__more-label__line--active'); 45 | el.style.transition = `transform ${indexCurtainDuration}ms cubic-bezier(0.77, 0, 0.175, 1)`; 46 | }); 47 | 48 | this._labelEls.forEach((el) => { 49 | el.classList.remove('card-content__more-label__more--active'); 50 | el.style.transition = `transform ${indexCurtainDuration}ms cubic-bezier(0.77, 0, 0.175, 1)`; 51 | }); 52 | }; 53 | 54 | _addListeners() { 55 | this._element.addEventListener('mouseenter', this._onMouseEnter); 56 | this._element.addEventListener('mouseleave', this._onMouseLeave); 57 | } 58 | 59 | removeListeners() { 60 | this._element.removeEventListener('mouseenter', this._onMouseEnter); 61 | this._element.removeEventListener('mouseleave', this._onMouseLeave); 62 | } 63 | 64 | animateIn() { 65 | super.animateIn(); 66 | } 67 | 68 | animateOut() { 69 | super.animateOut(); 70 | } 71 | 72 | onResize() { 73 | super.onResize(); 74 | this.initObserver(); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /frontend/src/classes/HTMLComponents/Paragraph.ts: -------------------------------------------------------------------------------- 1 | import SplitType from 'split-type'; 2 | 3 | import { Animation } from './Animation'; 4 | 5 | interface Constructor { 6 | element: HTMLElement; 7 | } 8 | 9 | export class Paragraph extends Animation { 10 | _text: SplitType; 11 | 12 | constructor({ element }: Constructor) { 13 | super({ element }); 14 | 15 | this._text = new SplitType(this._element, { 16 | tagName: 'span', 17 | types: 'lines,words', 18 | }); 19 | 20 | this._element.style.opacity = '1'; 21 | } 22 | 23 | animateIn() { 24 | super.animateIn(); 25 | if (!this._text.lines) return; 26 | 27 | this._text.lines.forEach((line, lineIndex) => { 28 | (Array.from(line.children) as HTMLElement[]).forEach( 29 | (word, _wordIndex) => { 30 | word.style.transition = `transform 1.5s ${ 31 | lineIndex * 0.2 32 | }s cubic-bezier(0.77, 0, 0.175, 1)`; 33 | word.classList.add('word--active'); 34 | }, 35 | ); 36 | }); 37 | } 38 | 39 | animateOut() { 40 | super.animateOut(); 41 | 42 | if (!this._text.lines) return; 43 | 44 | this._text.lines.forEach((line, lineIndex) => { 45 | (Array.from(line.children) as HTMLElement[]).forEach( 46 | (word, _wordIndex) => { 47 | word.style.transition = `transform 1s ${ 48 | lineIndex * 0.1 49 | }s cubic-bezier(0.77, 0, 0.175, 1)`; 50 | word.classList.remove('word--active'); 51 | }, 52 | ); 53 | }); 54 | } 55 | 56 | onResize() { 57 | super.onResize(); 58 | this._text.revert(); 59 | 60 | this._text = new SplitType(this._element, { 61 | tagName: 'span', 62 | types: 'lines,words', 63 | }); 64 | 65 | this.initObserver(); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /frontend/src/classes/Pages/DetailsPage/Canvas/Components/Image3D.ts: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | import TWEEN, { Tween } from '@tweenjs/tween.js'; 3 | 4 | import { 5 | UpdateInfo, 6 | ScrollValues, 7 | DomRectSSR, 8 | AnimateProps, 9 | AnimateScale, 10 | } from 'types'; 11 | import { pageTransitionDuration } from 'variables'; 12 | 13 | import { MediaObject3D } from './MediaObject3D'; 14 | 15 | interface Constructor { 16 | geometry: THREE.PlaneGeometry; 17 | domEl: HTMLElement; 18 | } 19 | 20 | export class Image3D extends MediaObject3D { 21 | elId: string; 22 | _domEl: HTMLElement; 23 | _domElBounds: DomRectSSR = { 24 | bottom: 0, 25 | height: 0, 26 | left: 0, 27 | right: 0, 28 | top: 0, 29 | width: 0, 30 | x: 0, 31 | y: 0, 32 | }; 33 | _extraScaleTranslate = { y: 0 }; 34 | _transitionElBounds = { 35 | left: 0, 36 | top: 0, 37 | height: 0, 38 | }; 39 | _scrollValues: ScrollValues | null = null; 40 | _opacityTween: Tween<{ progress: number }> | null = null; 41 | _transitionTween: Tween<{ progress: number }> | null = null; 42 | _transitionProgress = 0; 43 | _scaleTween: Tween<{ 44 | x: number; 45 | y: number; 46 | }> | null = null; 47 | 48 | constructor({ geometry, domEl }: Constructor) { 49 | super({ geometry }); 50 | 51 | this._domEl = domEl; 52 | this.setColliderName('image3D'); 53 | this.elId = this._domEl.dataset.curtainUid as string; 54 | } 55 | 56 | _updateBounds() { 57 | const rect = this._domEl.getBoundingClientRect(); 58 | 59 | this._domElBounds.bottom = rect.bottom; 60 | this._domElBounds.height = rect.height; 61 | this._domElBounds.left = rect.left; 62 | this._domElBounds.right = rect.right; 63 | this._domElBounds.top = rect.top; 64 | this._domElBounds.width = rect.width; 65 | this._domElBounds.x = rect.x; 66 | this._domElBounds.y = rect.y; 67 | 68 | if (this._scrollValues) 69 | this._domElBounds.top -= this._scrollValues.scroll.current; 70 | 71 | this._updateScale(); 72 | 73 | if (this._mesh) { 74 | this._mesh.material.uniforms.uPlaneSizes.value = [ 75 | this._mesh.scale.x, 76 | this._mesh.scale.y, 77 | ]; 78 | } 79 | } 80 | 81 | _updateScale() { 82 | if (this._mesh) { 83 | this._mesh.scale.x = this._domElBounds.width; 84 | this._mesh.scale.y = this._domElBounds.height; 85 | } 86 | } 87 | 88 | _updateX(x: number) { 89 | if (this._mesh) { 90 | this._mesh.position.x = 91 | -x * (1 - this._transitionProgress) + 92 | this._transitionElBounds.left * this._transitionProgress + 93 | this._domElBounds.left * (1 - this._transitionProgress) - 94 | this._rendererBounds.width / 2 + 95 | this._mesh.scale.x / 2; 96 | } 97 | } 98 | 99 | _updateY(y: number) { 100 | if (this._mesh) { 101 | this._mesh.position.y = 102 | -y * (1 - this._transitionProgress) - 103 | this._domElBounds.top * (1 - this._transitionProgress) - 104 | this._extraScaleTranslate.y * this._transitionProgress - 105 | this._transitionElBounds.top * this._transitionProgress + 106 | this._rendererBounds.height / 2 - 107 | this._mesh.scale.y / 2; 108 | } 109 | } 110 | 111 | setScrollValues(scrollValues: ScrollValues) { 112 | this._scrollValues = scrollValues; 113 | } 114 | 115 | animateTransition({ 116 | destination, 117 | duration, 118 | delay = 0, 119 | easing = TWEEN.Easing.Exponential.InOut, 120 | }: AnimateProps) { 121 | if (this._transitionTween) { 122 | this._transitionTween.stop(); 123 | } 124 | 125 | this._transitionTween = new TWEEN.Tween({ 126 | progress: this._transitionProgress, 127 | }) 128 | .to({ progress: destination }, duration) 129 | .delay(delay) 130 | .easing(easing) 131 | .onUpdate((obj) => { 132 | this._transitionProgress = obj.progress; 133 | }); 134 | 135 | this._transitionTween.start(); 136 | } 137 | 138 | animateOpacity({ 139 | destination, 140 | duration, 141 | delay = 0, 142 | easing = TWEEN.Easing.Linear.None, 143 | }: AnimateProps) { 144 | if (this._opacityTween) { 145 | this._opacityTween.stop(); 146 | } 147 | 148 | this._opacityTween = new TWEEN.Tween({ progress: this._tweenOpacity }) 149 | .to({ progress: destination }, duration) 150 | .delay(delay) 151 | .easing(easing) 152 | .onUpdate((obj) => { 153 | this._tweenOpacity = obj.progress; 154 | }); 155 | 156 | this._opacityTween.start(); 157 | } 158 | 159 | animateIn() { 160 | this.animateOpacity({ destination: 1, delay: 0, duration: 0 }); 161 | } 162 | 163 | _animateScale({ 164 | xScale, 165 | yScale, 166 | parentFn, 167 | duration = pageTransitionDuration, 168 | }: AnimateScale) { 169 | if (this._scaleTween) { 170 | this._scaleTween.stop(); 171 | } 172 | 173 | if (!this._mesh) return; 174 | 175 | this._scaleTween = new TWEEN.Tween({ 176 | x: this._mesh.scale.x, 177 | y: this._mesh.scale.y, 178 | }) 179 | .to({ x: xScale, y: yScale }, duration) 180 | .easing(TWEEN.Easing.Exponential.InOut) 181 | .onUpdate((obj) => { 182 | if (this._mesh) { 183 | this._extraScaleTranslate.y = 184 | (this._transitionElBounds.height - obj.y) / 2; 185 | 186 | this._mesh.scale.x = obj.x; 187 | this._mesh.scale.y = obj.y; 188 | 189 | this._mesh.material.uniforms.uPlaneSizes.value = [ 190 | this._mesh.scale.x, 191 | this._mesh.scale.y, 192 | ]; 193 | } 194 | }) 195 | .onComplete(() => { 196 | parentFn && parentFn(); 197 | }); 198 | 199 | this._scaleTween.start(); 200 | } 201 | 202 | onExitToIndex(parentFn: () => void) { 203 | const transitionEl = Array.from( 204 | document.querySelectorAll(`[data-transition="${this.elId}"]`), 205 | )[0] as HTMLElement; 206 | 207 | // Raf fixes css styles issue 208 | window.requestAnimationFrame(() => { 209 | const bounds = transitionEl.getBoundingClientRect(); 210 | this._transitionElBounds.top = bounds.top; 211 | this._transitionElBounds.left = bounds.left; 212 | this._transitionElBounds.height = bounds.height; 213 | 214 | this._animateScale({ xScale: bounds.width, yScale: 0, parentFn }); 215 | 216 | this.animateTransition({ 217 | destination: 1, 218 | duration: pageTransitionDuration, 219 | }); 220 | }); 221 | } 222 | 223 | onResize() { 224 | super.onResize(); 225 | 226 | // Raf fixes css styles issue 227 | window.requestAnimationFrame(() => { 228 | this._updateBounds(); 229 | }); 230 | } 231 | 232 | update(updateInfo: UpdateInfo) { 233 | super.update(updateInfo); 234 | if (this._scrollValues) this._updateY(this._scrollValues.scroll.current); 235 | this._updateX(0); 236 | 237 | if (this._mesh && this._scrollValues) { 238 | this._mesh.material.uniforms.uStrength.value = 239 | this._scrollValues.strength.current * 0.7 + 8; 240 | } 241 | } 242 | 243 | destroy() { 244 | super.destroy(); 245 | this._transitionTween && this._transitionTween.stop(); 246 | this._opacityTween && this._opacityTween.stop(); 247 | this._scaleTween && this._scaleTween.stop(); 248 | } 249 | } 250 | -------------------------------------------------------------------------------- /frontend/src/classes/Pages/DetailsPage/Canvas/Components/MediaObject3D.ts: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | 3 | import { InteractiveObject3D } from 'classes/Components/InteractiveObject3D'; 4 | import { Bounds, TextureItem, UpdateInfo } from 'types'; 5 | import { globalState } from 'utils/globalState'; 6 | 7 | import fragmentShader from '../shaders/media/fragment.glsl'; 8 | import vertexShader from '../shaders/media/vertex.glsl'; 9 | 10 | interface Constructor { 11 | geometry: THREE.PlaneGeometry; 12 | } 13 | 14 | export class MediaObject3D extends InteractiveObject3D { 15 | _geometry: THREE.PlaneGeometry; 16 | _material: THREE.ShaderMaterial | null = null; 17 | _mesh: THREE.Mesh | null = null; 18 | _rendererBounds: Bounds = { height: 100, width: 100 }; 19 | _textureItem: TextureItem | null = null; 20 | _intersectPoint: THREE.Vector3 | null = null; 21 | _masterOpacity = 1; 22 | _tweenOpacity = 0; 23 | _isVisible = false; 24 | 25 | constructor({ geometry }: Constructor) { 26 | super(); 27 | this._geometry = geometry; 28 | this._createMesh(); 29 | } 30 | 31 | _createMesh() { 32 | this._material = new THREE.ShaderMaterial({ 33 | transparent: true, 34 | side: THREE.FrontSide, 35 | depthWrite: false, 36 | depthTest: false, 37 | uniforms: { 38 | tMap: { value: null }, 39 | uPlaneSizes: { value: [0, 0] }, 40 | uImageSizes: { value: [0, 0] }, 41 | uTime: { value: 0 }, 42 | uHovered: { value: 0 }, 43 | uMouse3D: { value: new THREE.Vector3(0, 0, 0) }, 44 | uViewportSizes: { 45 | value: [this._rendererBounds.width, this._rendererBounds.height], 46 | }, 47 | uStrength: { value: 0 }, 48 | uOpacity: { value: 0 }, 49 | }, 50 | fragmentShader: fragmentShader, 51 | vertexShader: vertexShader, 52 | }); 53 | 54 | this._mesh = new THREE.Mesh(this._geometry, this._material); 55 | this.add(this._mesh); 56 | } 57 | 58 | _updateTexture() { 59 | if (this._material && this._textureItem && this._mesh) { 60 | this._material.uniforms.tMap.value = this._textureItem.texture; 61 | 62 | this._material.uniforms.uImageSizes.value = [ 63 | this._textureItem.naturalWidth, 64 | this._textureItem.naturalHeight, 65 | ]; 66 | } 67 | } 68 | 69 | set opacity(value: number) { 70 | this._masterOpacity = value; 71 | } 72 | 73 | _updateOpacity() { 74 | if (this._mesh) { 75 | const computedOpacity = 76 | Math.min(this._masterOpacity, 1) * 77 | this._tweenOpacity * 78 | globalState.globalOpacity; 79 | 80 | this._mesh.material.uniforms.uOpacity.value = computedOpacity; 81 | this._isVisible = computedOpacity > 0; 82 | } 83 | } 84 | 85 | set intersectPoint(point: THREE.Vector3) { 86 | this._intersectPoint = point; 87 | } 88 | 89 | set rendererBounds(bounds: Bounds) { 90 | this._rendererBounds = bounds; 91 | 92 | this.onResize(); 93 | 94 | if (this._mesh) { 95 | this._mesh.material.uniforms.uViewportSizes.value = [ 96 | this._rendererBounds.width, 97 | this._rendererBounds.height, 98 | ]; 99 | } 100 | } 101 | 102 | set textureItem(textureItem: TextureItem) { 103 | this._textureItem = textureItem; 104 | this._updateTexture(); 105 | } 106 | 107 | onResize() {} 108 | 109 | update(updateInfo: UpdateInfo) { 110 | super.update(updateInfo); 111 | 112 | this._updateOpacity(); 113 | 114 | if (this._intersectPoint && this._mesh) { 115 | this._mesh.material.uniforms.uMouse3D.value = this._intersectPoint; 116 | } 117 | } 118 | 119 | destroy() { 120 | super.destroy(); 121 | this._geometry?.dispose(); 122 | this._material?.dispose(); 123 | if (this._mesh) { 124 | this.remove(this._mesh); 125 | } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /frontend/src/classes/Pages/DetailsPage/Canvas/DetailsPageCanvas.ts: -------------------------------------------------------------------------------- 1 | import { Bounds, UpdateInfo, ExitFn } from 'types'; 2 | import { globalState } from 'utils/globalState'; 3 | 4 | import { PageCanvas } from '../../PageCanvas'; 5 | import { InteractiveScene } from '../../../Components/InteractiveScene'; 6 | import { Image3D } from './Components/Image3D'; 7 | 8 | interface Constructor {} 9 | 10 | export class DetailsPageCanvas extends PageCanvas { 11 | static anmImage3D = '[data-animation="image3d"]'; 12 | 13 | _anmImages3D: Image3D[] = []; 14 | 15 | constructor(props: Constructor) { 16 | super({}); 17 | } 18 | 19 | setInteractiveScene(scene: InteractiveScene) { 20 | super.setInteractiveScene(scene); 21 | } 22 | 23 | _destroyItems() { 24 | this._anmImages3D.forEach((item) => { 25 | item.destroy(); 26 | }); 27 | this._anmImages3D = []; 28 | this._planeGeometry.dispose(); 29 | } 30 | 31 | onEnter(el: HTMLElement) { 32 | super.onEnter(el); 33 | 34 | if (!this._pageEl) return; 35 | 36 | this._destroyItems(); 37 | 38 | const medias = Array.from( 39 | this._pageEl.querySelectorAll(DetailsPageCanvas.anmImage3D), 40 | ) as HTMLElement[]; 41 | 42 | this._anmImages3D = medias.map((el) => { 43 | return new Image3D({ geometry: this._planeGeometry, domEl: el }); 44 | }); 45 | 46 | this._anmImages3D.forEach((el) => { 47 | if (this._interactiveScene) { 48 | this._scrollValues && el.setScrollValues(this._scrollValues); 49 | el.rendererBounds = this._rendererBounds; 50 | this._interactiveScene.add(el); 51 | } 52 | }); 53 | 54 | this.onAssetsLoaded(); 55 | } 56 | 57 | setRendererBounds(bounds: Bounds) { 58 | super.setRendererBounds(bounds); 59 | 60 | this._anmImages3D.forEach((el) => { 61 | el.rendererBounds = this._rendererBounds; 62 | }); 63 | } 64 | 65 | onAssetsLoaded() { 66 | super.onAssetsLoaded(); 67 | this._anmImages3D.forEach((el) => { 68 | const figureSrc = el._domEl.dataset.src; 69 | if (figureSrc) el.textureItem = globalState.textureItems[figureSrc]; 70 | }); 71 | } 72 | 73 | onExit = () => { 74 | //RAF delays photo swap 75 | window.requestAnimationFrame(() => { 76 | this._destroyItems(); 77 | }); 78 | }; 79 | 80 | animateIn() { 81 | this._anmImages3D.forEach((el, key) => { 82 | el.animateIn(); 83 | }); 84 | } 85 | 86 | onExitToIndex({ targetId, parentFn }: ExitFn) { 87 | //WIP (we need to get the exact element to animate) 88 | this._anmImages3D.forEach((el, key) => { 89 | const endAnimationFn = () => { 90 | this.onExit(); 91 | parentFn(); 92 | }; 93 | 94 | if (el.elId === targetId) { 95 | el.onExitToIndex(endAnimationFn); 96 | } 97 | }); 98 | } 99 | 100 | update(updateInfo: UpdateInfo) { 101 | super.update(updateInfo); 102 | 103 | this._anmImages3D.forEach((el) => { 104 | el.update(updateInfo); 105 | }); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /frontend/src/classes/Pages/DetailsPage/Canvas/shaders/media/fragment.glsl: -------------------------------------------------------------------------------- 1 | precision highp float; 2 | 3 | uniform vec2 uImageSizes; 4 | uniform vec2 uPlaneSizes; 5 | uniform sampler2D tMap; 6 | uniform float uOpacity; 7 | 8 | varying vec2 vUv; 9 | 10 | void main() { 11 | vec2 ratio = vec2( 12 | min((uPlaneSizes.x / uPlaneSizes.y) / (uImageSizes.x / uImageSizes.y), 1.0), 13 | min((uPlaneSizes.y / uPlaneSizes.x) / (uImageSizes.y / uImageSizes.x), 1.0) 14 | ); 15 | 16 | vec2 uv = vec2( 17 | vUv.x * ratio.x + (1.0 - ratio.x) * 0.5, 18 | vUv.y * ratio.y + (1.0 - ratio.y) * 0.5 19 | ); 20 | 21 | gl_FragColor.rgb = texture2D(tMap, uv).rgb; 22 | gl_FragColor.a = uOpacity; 23 | } 24 | -------------------------------------------------------------------------------- /frontend/src/classes/Pages/DetailsPage/Canvas/shaders/media/vertex.glsl: -------------------------------------------------------------------------------- 1 | #define PI 3.1415926535897932384626433832795 2 | 3 | precision highp float; 4 | precision highp int; 5 | 6 | uniform float uStrength; 7 | uniform vec2 uViewportSizes; 8 | uniform vec2 uPlaneSizes; 9 | uniform vec3 uMouse3D; 10 | 11 | varying vec2 vUv; 12 | 13 | void main() { 14 | 15 | vec3 stablePosition = position; 16 | 17 | //Parallax mouse animation 18 | // stablePosition.x -= uMouse3D.x * 0.0009; 19 | // stablePosition.y -= uMouse3D.y * 0.0009; 20 | 21 | // Cursor animation 22 | // float dist = distance(position.xy, uMouse3D.xy); 23 | // float area = 1.- smoothstep(0., 30., dist); 24 | // stablePosition.z += dist * 0.1; 25 | 26 | vec4 newPosition = modelViewMatrix * vec4(stablePosition, 1.0); 27 | 28 | //Barrel animation 29 | // newPosition.z += sin(newPosition.y / uViewportSizes.y * PI + PI / 2.0) * -uStrength * 2.5; 30 | // newPosition.z += sin(newPosition.x / uViewportSizes.x * PI + PI / 2.0) * -uStrength * 2.5; 31 | 32 | gl_Position = projectionMatrix * newPosition; 33 | 34 | vUv = uv; 35 | } 36 | -------------------------------------------------------------------------------- /frontend/src/classes/Pages/DetailsPage/DetailsPage.ts: -------------------------------------------------------------------------------- 1 | import { UpdateInfo, Bounds, ExitFn } from 'types'; 2 | 3 | import { Page } from '../Page'; 4 | import { DetailsPageCanvas } from './Canvas/DetailsPageCanvas'; 5 | import { InteractiveScene } from '../../Components/InteractiveScene'; 6 | 7 | interface Constructor { 8 | pageId: string; 9 | } 10 | 11 | export class DetailsPage extends Page { 12 | _pageCanvas: DetailsPageCanvas; 13 | 14 | constructor({ pageId }: Constructor) { 15 | super({ pageId }); 16 | 17 | this._pageCanvas = new DetailsPageCanvas({}); 18 | this._pageCanvas.scrollValues = this._scrollValues; 19 | } 20 | 21 | setRendererBounds(bounds: Bounds) { 22 | super.setRendererBounds(bounds); 23 | 24 | this._pageCanvas.setRendererBounds(bounds); 25 | } 26 | 27 | onEnter(el: HTMLElement) { 28 | super.onEnter(el); 29 | this._pageCanvas.onEnter(el); 30 | } 31 | 32 | onAssetsLoaded() { 33 | super.onAssetsLoaded(); 34 | this._pageCanvas.onAssetsLoaded(); 35 | } 36 | 37 | onExit() { 38 | super.onExit(); 39 | this._pageCanvas.onExit(); 40 | } 41 | 42 | animateIn() { 43 | super.animateIn(); 44 | this._pageCanvas.animateIn(); 45 | } 46 | 47 | onExitToIndex(props: ExitFn) { 48 | const { parentFn, targetId } = props; 49 | //It executes the functions that onExit() normally executes (WIP) 50 | this._animateOut(); 51 | 52 | this._removeListeners(); 53 | 54 | const updatedParentFn = () => { 55 | parentFn(); 56 | this._resetScrollValues(); 57 | }; 58 | 59 | this._pageCanvas.onExitToIndex({ parentFn: updatedParentFn, targetId }); 60 | } 61 | 62 | setInteractiveScene(scene: InteractiveScene) { 63 | this._pageCanvas.setInteractiveScene(scene); 64 | } 65 | 66 | update(updateInfo: UpdateInfo) { 67 | super.update(updateInfo); 68 | this._pageCanvas.update(updateInfo); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /frontend/src/classes/Pages/IndexPage/Canvas/Components/Image3D.ts: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | import TWEEN, { Tween } from '@tweenjs/tween.js'; 3 | 4 | import { globalState } from 'utils/globalState'; 5 | 6 | import { 7 | UpdateInfo, 8 | ScrollValues, 9 | DomRectSSR, 10 | AnimateProps, 11 | AnimateScale, 12 | } from 'types'; 13 | import { indexCurtainDuration, pageTransitionDuration } from 'variables'; 14 | 15 | import { MediaObject3D } from './MediaObject3D'; 16 | 17 | interface Constructor { 18 | geometry: THREE.PlaneGeometry; 19 | domEl: HTMLElement; 20 | parentDomEl: HTMLElement; 21 | } 22 | 23 | export class Image3D extends MediaObject3D { 24 | static transitionElId = '[data-transition="details"]'; 25 | static hoverTarget = '[data-curtain="hover"]'; 26 | 27 | elId: string; 28 | _isTransitioning = false; 29 | _parentDomEl: HTMLElement; 30 | _domEl: HTMLElement; 31 | _domElBounds: DomRectSSR = { 32 | bottom: 0, 33 | height: 0, 34 | left: 0, 35 | right: 0, 36 | top: 0, 37 | width: 0, 38 | x: 0, 39 | y: 0, 40 | }; 41 | _transitionElBounds = { 42 | left: 0, 43 | top: 0, 44 | }; 45 | _scrollValues: ScrollValues | null = null; 46 | _opacityTween: Tween<{ progress: number }> | null = null; 47 | _transitionTween: Tween<{ progress: number }> | null = null; 48 | _transitionProgress = 0; 49 | _scaleTween: Tween<{ 50 | x: number; 51 | y: number; 52 | }> | null = null; 53 | _extraScaleTranslate = { y: 0 }; 54 | _hoverTargetEl: HTMLElement; 55 | _zoomTween: Tween<{ progress: number }> | null = null; 56 | 57 | constructor({ parentDomEl, geometry, domEl }: Constructor) { 58 | super({ geometry }); 59 | 60 | this._parentDomEl = parentDomEl; 61 | this._domEl = domEl; 62 | 63 | this._hoverTargetEl = Array.from( 64 | this._parentDomEl.querySelectorAll(Image3D.hoverTarget), 65 | )[0] as HTMLElement; 66 | 67 | this.setColliderName('image3D'); 68 | this._addListeners(); 69 | 70 | this.elId = this._domEl.dataset.curtainUid as string; 71 | } 72 | 73 | _updateBounds() { 74 | const rect = this._domEl.getBoundingClientRect(); 75 | 76 | this._domElBounds.bottom = rect.bottom; 77 | this._domElBounds.height = rect.height; 78 | this._domElBounds.left = rect.left; 79 | this._domElBounds.right = rect.right; 80 | this._domElBounds.top = rect.top; 81 | this._domElBounds.width = rect.width; 82 | this._domElBounds.x = rect.x; 83 | this._domElBounds.y = rect.y; 84 | 85 | if (this._scrollValues) 86 | this._domElBounds.top -= this._scrollValues.scroll.current; //Fixes scroll issues 87 | 88 | this._updateScale(); 89 | 90 | if (this._mesh) { 91 | this._mesh.material.uniforms.uPlaneSizes.value = [ 92 | this._mesh.scale.x, 93 | this._mesh.scale.y, 94 | ]; 95 | } 96 | } 97 | 98 | _updateScale() { 99 | if (this._mesh) { 100 | this._mesh.scale.x = this._domElBounds.width; 101 | this._mesh.scale.y = 0; //this._domElBounds.height -> this should be normally, but our default state is 0 102 | } 103 | } 104 | 105 | _updateX(x: number) { 106 | if (this._mesh) { 107 | this._mesh.position.x = 108 | -x * (1 - this._transitionProgress) + 109 | this._transitionElBounds.left * this._transitionProgress + 110 | this._domElBounds.left * (1 - this._transitionProgress) - 111 | this._rendererBounds.width / 2 + 112 | this._mesh.scale.x / 2; 113 | } 114 | } 115 | 116 | _updateY(y: number) { 117 | if (this._mesh) { 118 | this._mesh.position.y = 119 | -y * (1 - this._transitionProgress) - 120 | this._domElBounds.top * (1 - this._transitionProgress) - 121 | this._extraScaleTranslate.y * (1 - this._transitionProgress) - 122 | this._transitionElBounds.top * this._transitionProgress + 123 | this._rendererBounds.height / 2 - 124 | this._mesh.scale.y / 2; 125 | } 126 | } 127 | 128 | _onMouseEnter = () => { 129 | if (this._isTransitioning) return; 130 | 131 | globalState.canvasApp?.circle2D.zoomIn(); 132 | 133 | this._animateScale({ 134 | xScale: this._domElBounds.width, 135 | yScale: this._domElBounds.height, 136 | duration: indexCurtainDuration, 137 | }); 138 | 139 | this._animateZoom({ 140 | destination: 0, 141 | }); 142 | }; 143 | 144 | _onMouseLeave = () => { 145 | if (this._isTransitioning) return; 146 | this.hideBanner(); 147 | globalState.canvasApp?.circle2D.zoomOut(); 148 | }; 149 | 150 | _addListeners() { 151 | this._hoverTargetEl.addEventListener('mouseenter', this._onMouseEnter); 152 | this._hoverTargetEl.addEventListener('mouseleave', this._onMouseLeave); 153 | } 154 | 155 | _removeListeners() { 156 | this._hoverTargetEl.removeEventListener('mouseenter', this._onMouseEnter); 157 | this._hoverTargetEl.removeEventListener('mouseleave', this._onMouseLeave); 158 | } 159 | 160 | _animateTransition({ 161 | destination, 162 | duration, 163 | delay = 0, 164 | easing = TWEEN.Easing.Exponential.InOut, 165 | }: AnimateProps) { 166 | if (this._transitionTween) { 167 | this._transitionTween.stop(); 168 | } 169 | 170 | this._transitionTween = new TWEEN.Tween({ 171 | progress: this._transitionProgress, 172 | }) 173 | .to({ progress: destination }, duration) 174 | .delay(delay) 175 | .easing(easing) 176 | .onUpdate((obj) => { 177 | this._transitionProgress = obj.progress; 178 | }); 179 | 180 | this._transitionTween.start(); 181 | } 182 | 183 | _animateOpacity({ 184 | destination, 185 | duration, 186 | delay = 0, 187 | easing = TWEEN.Easing.Linear.None, 188 | }: AnimateProps) { 189 | if (this._opacityTween) { 190 | this._opacityTween.stop(); 191 | } 192 | 193 | this._opacityTween = new TWEEN.Tween({ progress: this._tweenOpacity }) 194 | .to({ progress: destination }, duration) 195 | .delay(delay) 196 | .easing(easing) 197 | .onUpdate((obj) => { 198 | this._tweenOpacity = obj.progress; 199 | }); 200 | 201 | this._opacityTween.start(); 202 | } 203 | 204 | _animateZoom({ 205 | destination, 206 | duration = indexCurtainDuration, 207 | delay = 0, 208 | easing = TWEEN.Easing.Exponential.InOut, 209 | }: AnimateProps) { 210 | if (this._zoomTween) { 211 | this._zoomTween.stop(); 212 | } 213 | 214 | this._zoomTween = new TWEEN.Tween({ progress: this._zoomProgress }) 215 | .to({ progress: destination }, duration) 216 | .delay(delay) 217 | .easing(easing) 218 | .onUpdate((obj) => { 219 | if (!this._mesh) return; 220 | this._zoomProgress = obj.progress; 221 | 222 | this._mesh.material.uniforms.uZoomProgress.value = this._zoomProgress; 223 | }); 224 | 225 | this._zoomTween.start(); 226 | } 227 | 228 | _animateScale({ 229 | xScale, 230 | yScale, 231 | parentFn, 232 | duration = pageTransitionDuration, 233 | }: AnimateScale) { 234 | if (this._scaleTween) { 235 | this._scaleTween.stop(); 236 | } 237 | 238 | if (!this._mesh) return; 239 | 240 | this._scaleTween = new TWEEN.Tween({ 241 | x: this._mesh.scale.x, 242 | y: this._mesh.scale.y, 243 | }) 244 | .to({ x: xScale, y: yScale }, duration) 245 | .easing(TWEEN.Easing.Exponential.InOut) 246 | .onUpdate((obj) => { 247 | if (this._mesh) { 248 | this._extraScaleTranslate.y = (this._domElBounds.height - obj.y) / 2; 249 | 250 | this._mesh.scale.x = obj.x; 251 | this._mesh.scale.y = obj.y; 252 | 253 | this._mesh.material.uniforms.uPlaneSizes.value = [ 254 | this._mesh.scale.x, 255 | this._mesh.scale.y, 256 | ]; 257 | } 258 | }) 259 | .onComplete(() => { 260 | parentFn && parentFn(); 261 | }); 262 | 263 | this._scaleTween.start(); 264 | } 265 | 266 | setScrollValues(scrollValues: ScrollValues) { 267 | this._scrollValues = scrollValues; 268 | } 269 | 270 | hideBanner() { 271 | this._animateScale({ 272 | xScale: this._domElBounds.width, 273 | yScale: 0, 274 | duration: indexCurtainDuration, 275 | }); 276 | 277 | this._animateZoom({ 278 | destination: 1, 279 | }); 280 | } 281 | 282 | animateIn() { 283 | this._animateOpacity({ destination: 1, delay: 0, duration: 0 }); 284 | } 285 | 286 | onExitToDetails(parentFn: () => void) { 287 | const transitionEl = Array.from( 288 | document.querySelectorAll(Image3D.transitionElId), 289 | )[0] as HTMLElement; 290 | 291 | // Raf fixes css styles issue 292 | window.requestAnimationFrame(() => { 293 | const bounds = transitionEl.getBoundingClientRect(); 294 | this._transitionElBounds.top = bounds.top; 295 | this._transitionElBounds.left = bounds.left; 296 | 297 | this._animateScale({ 298 | xScale: bounds.width, 299 | yScale: bounds.height, 300 | parentFn, 301 | }); 302 | this._animateTransition({ 303 | destination: 1, 304 | duration: pageTransitionDuration, 305 | }); 306 | this._animateZoom({ 307 | destination: 0, 308 | }); 309 | globalState.canvasApp?.circle2D.zoomOut(); 310 | }); 311 | } 312 | 313 | onResize() { 314 | super.onResize(); 315 | 316 | // Raf fixes css styles issue 317 | window.requestAnimationFrame(() => { 318 | this._updateBounds(); 319 | }); 320 | } 321 | 322 | update(updateInfo: UpdateInfo) { 323 | super.update(updateInfo); 324 | if (this._scrollValues) this._updateY(this._scrollValues.scroll.current); 325 | this._updateX(0); 326 | 327 | if (this._mesh && this._scrollValues) { 328 | this._mesh.material.uniforms.uStrength.value = 329 | this._scrollValues.strength.current * 0.7 + 8; 330 | } 331 | } 332 | 333 | destroy() { 334 | super.destroy(); 335 | this._transitionTween && this._transitionTween.stop(); 336 | this._opacityTween && this._opacityTween.stop(); 337 | this._scaleTween && this._scaleTween.stop(); 338 | this._removeListeners(); 339 | } 340 | 341 | set isTransitioning(value: boolean) { 342 | this._isTransitioning = value; 343 | } 344 | } 345 | -------------------------------------------------------------------------------- /frontend/src/classes/Pages/IndexPage/Canvas/Components/MediaObject3D.ts: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | 3 | import { InteractiveObject3D } from 'classes/Components/InteractiveObject3D'; 4 | import { Bounds, TextureItem, UpdateInfo } from 'types'; 5 | import { globalState } from 'utils/globalState'; 6 | 7 | import fragmentShader from '../shaders/media/fragment.glsl'; 8 | import vertexShader from '../shaders/media/vertex.glsl'; 9 | 10 | interface Constructor { 11 | geometry: THREE.PlaneGeometry; 12 | } 13 | 14 | export class MediaObject3D extends InteractiveObject3D { 15 | _geometry: THREE.PlaneGeometry; 16 | _material: THREE.ShaderMaterial | null = null; 17 | _mesh: THREE.Mesh | null = null; 18 | _rendererBounds: Bounds = { height: 100, width: 100 }; 19 | _textureItem: TextureItem | null = null; 20 | _intersectPoint: THREE.Vector3 | null = null; 21 | _masterOpacity = 1; 22 | _tweenOpacity = 0; 23 | _isVisible = false; 24 | _zoomProgress = 1; //by default the images are zoomed in 25 | 26 | constructor({ geometry }: Constructor) { 27 | super(); 28 | this._geometry = geometry; 29 | this._createMesh(); 30 | } 31 | 32 | _createMesh() { 33 | this._material = new THREE.ShaderMaterial({ 34 | transparent: true, 35 | side: THREE.FrontSide, 36 | depthWrite: false, 37 | depthTest: false, 38 | uniforms: { 39 | tMap: { value: null }, 40 | uPlaneSizes: { value: [0, 0] }, 41 | uImageSizes: { value: [0, 0] }, 42 | uTime: { value: 0 }, 43 | uHovered: { value: 0 }, 44 | uZoom: { value: 0.82 }, 45 | uZoomProgress: { value: this._zoomProgress }, 46 | uMouse3D: { value: new THREE.Vector3(0, 0, 0) }, 47 | uViewportSizes: { 48 | value: [this._rendererBounds.width, this._rendererBounds.height], 49 | }, 50 | uStrength: { value: 0 }, 51 | uOpacity: { value: 0 }, 52 | }, 53 | fragmentShader: fragmentShader, 54 | vertexShader: vertexShader, 55 | }); 56 | 57 | this._mesh = new THREE.Mesh(this._geometry, this._material); 58 | this.add(this._mesh); 59 | } 60 | 61 | _updateTexture() { 62 | if (this._material && this._textureItem && this._mesh) { 63 | this._material.uniforms.tMap.value = this._textureItem.texture; 64 | 65 | this._material.uniforms.uImageSizes.value = [ 66 | this._textureItem.naturalWidth, 67 | this._textureItem.naturalHeight, 68 | ]; 69 | } 70 | } 71 | 72 | set opacity(value: number) { 73 | this._masterOpacity = value; 74 | } 75 | 76 | _updateOpacity() { 77 | if (this._mesh) { 78 | const computedOpacity = 79 | Math.min(this._masterOpacity, 1) * 80 | this._tweenOpacity * 81 | globalState.globalOpacity; 82 | 83 | this._mesh.material.uniforms.uOpacity.value = computedOpacity; 84 | this._isVisible = computedOpacity > 0; 85 | } 86 | } 87 | 88 | set intersectPoint(point: THREE.Vector3) { 89 | this._intersectPoint = point; 90 | } 91 | 92 | set rendererBounds(bounds: Bounds) { 93 | this._rendererBounds = bounds; 94 | 95 | this.onResize(); 96 | 97 | if (this._mesh) { 98 | this._mesh.material.uniforms.uViewportSizes.value = [ 99 | this._rendererBounds.width, 100 | this._rendererBounds.height, 101 | ]; 102 | } 103 | } 104 | 105 | set textureItem(textureItem: TextureItem) { 106 | this._textureItem = textureItem; 107 | this._updateTexture(); 108 | // this.onResize(); 109 | } 110 | 111 | onResize() {} 112 | 113 | update(updateInfo: UpdateInfo) { 114 | super.update(updateInfo); 115 | 116 | this._updateOpacity(); 117 | 118 | if (this._intersectPoint && this._mesh) { 119 | this._mesh.material.uniforms.uMouse3D.value = this._intersectPoint; 120 | } 121 | } 122 | 123 | destroy() { 124 | super.destroy(); 125 | this._geometry?.dispose(); 126 | this._material?.dispose(); 127 | if (this._mesh) { 128 | this.remove(this._mesh); 129 | } 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /frontend/src/classes/Pages/IndexPage/Canvas/IndexPageCanvas.ts: -------------------------------------------------------------------------------- 1 | import { Bounds, UpdateInfo, ExitFn } from 'types'; 2 | import { globalState } from 'utils/globalState'; 3 | 4 | import { PageCanvas } from '../../PageCanvas'; 5 | import { InteractiveScene } from '../../../Components/InteractiveScene'; 6 | import { Image3D } from './Components/Image3D'; 7 | 8 | interface Constructor {} 9 | 10 | export class IndexPageCanvas extends PageCanvas { 11 | static anmImage3D = '[data-animation="image3d-landing"]'; 12 | 13 | _anmImages3D: Image3D[] = []; 14 | 15 | constructor(props: Constructor) { 16 | super({}); 17 | } 18 | 19 | setInteractiveScene(scene: InteractiveScene) { 20 | super.setInteractiveScene(scene); 21 | } 22 | 23 | _destroyItems() { 24 | this._anmImages3D.forEach((item) => { 25 | item.destroy(); 26 | }); 27 | this._anmImages3D = []; 28 | this._planeGeometry.dispose(); 29 | } 30 | 31 | onEnter(el: HTMLElement) { 32 | super.onEnter(el); 33 | 34 | if (!this._pageEl) return; 35 | 36 | this._destroyItems(); 37 | 38 | const medias = Array.from( 39 | this._pageEl.querySelectorAll(IndexPageCanvas.anmImage3D), 40 | ) as HTMLElement[]; 41 | 42 | this._anmImages3D = medias.map((el) => { 43 | const parentDomEl = el.parentNode as HTMLElement; 44 | return new Image3D({ 45 | geometry: this._planeGeometry, 46 | domEl: el, 47 | parentDomEl, 48 | }); 49 | }); 50 | 51 | this._anmImages3D.forEach((el) => { 52 | if (this._interactiveScene) { 53 | this._scrollValues && el.setScrollValues(this._scrollValues); 54 | el.rendererBounds = this._rendererBounds; 55 | this._interactiveScene.add(el); 56 | } 57 | }); 58 | 59 | this.onAssetsLoaded(); 60 | } 61 | 62 | setRendererBounds(bounds: Bounds) { 63 | super.setRendererBounds(bounds); 64 | 65 | this._anmImages3D.forEach((el) => { 66 | el.rendererBounds = this._rendererBounds; 67 | }); 68 | } 69 | 70 | onAssetsLoaded() { 71 | super.onAssetsLoaded(); 72 | this._anmImages3D.forEach((el) => { 73 | const figureSrc = el._domEl.dataset.src; 74 | if (figureSrc) el.textureItem = globalState.textureItems[figureSrc]; 75 | }); 76 | } 77 | 78 | onExit = () => { 79 | //RAF delays photo swap 80 | window.requestAnimationFrame(() => { 81 | this._destroyItems(); 82 | }); 83 | }; 84 | 85 | animateIn() { 86 | this._anmImages3D.forEach((el, key) => { 87 | el.animateIn(); 88 | }); 89 | } 90 | 91 | onExitToDetails({ targetId, parentFn }: ExitFn) { 92 | //WIP (we need to get the exact element to animate) 93 | this._anmImages3D.forEach((el, key) => { 94 | el.isTransitioning = true; 95 | 96 | const endAnimationFn = () => { 97 | this.onExit(); 98 | parentFn(); 99 | }; 100 | 101 | if (el.elId === targetId) { 102 | el.onExitToDetails(endAnimationFn); 103 | } else { 104 | el.hideBanner(); 105 | } 106 | }); 107 | } 108 | 109 | update(updateInfo: UpdateInfo) { 110 | super.update(updateInfo); 111 | 112 | this._anmImages3D.forEach((el) => { 113 | el.update(updateInfo); 114 | }); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /frontend/src/classes/Pages/IndexPage/Canvas/shaders/media/fragment.glsl: -------------------------------------------------------------------------------- 1 | precision highp float; 2 | 3 | uniform vec2 uImageSizes; 4 | uniform vec2 uPlaneSizes; 5 | uniform sampler2D tMap; 6 | uniform float uOpacity; 7 | uniform float uZoom; 8 | uniform float uZoomProgress; 9 | 10 | varying vec2 vUv; 11 | 12 | void main() { 13 | vec2 ratio = vec2( 14 | min((uPlaneSizes.x / uPlaneSizes.y) / (uImageSizes.x / uImageSizes.y), 1.0), 15 | min((uPlaneSizes.y / uPlaneSizes.x) / (uImageSizes.y / uImageSizes.x), 1.0) 16 | ); 17 | 18 | float zoomStateX = (1. - uZoom) * 0.5 + uZoom * vUv.x * ratio.x; 19 | float zoomStateY = (1. - uZoom) * 0.25 + uZoom * vUv.y * ratio.y; 20 | 21 | float normalStateX = vUv.x * ratio.x; 22 | float normalStateY = vUv.y * ratio.y; 23 | 24 | float finalStateX = mix(normalStateX, zoomStateX, uZoomProgress); 25 | float finalStateY = mix(normalStateY, zoomStateY, uZoomProgress); 26 | 27 | vec2 uv = vec2( 28 | finalStateX + (1.0 - ratio.x) * 0.5, 29 | finalStateY + (1.0 - ratio.y) * 0.5 30 | ); 31 | 32 | gl_FragColor.rgb = texture2D(tMap, uv).rgb; 33 | gl_FragColor.a = uOpacity; 34 | } 35 | -------------------------------------------------------------------------------- /frontend/src/classes/Pages/IndexPage/Canvas/shaders/media/vertex.glsl: -------------------------------------------------------------------------------- 1 | #define PI 3.1415926535897932384626433832795 2 | 3 | precision highp float; 4 | precision highp int; 5 | 6 | uniform float uStrength; 7 | uniform vec2 uViewportSizes; 8 | uniform vec2 uPlaneSizes; 9 | uniform vec3 uMouse3D; 10 | 11 | varying vec2 vUv; 12 | 13 | void main() { 14 | 15 | vec3 stablePosition = position; 16 | 17 | //Parallax mouse animation 18 | // stablePosition.x -= uMouse3D.x * 0.0009; 19 | // stablePosition.y -= uMouse3D.y * 0.0009; 20 | 21 | // Cursor animation 22 | // float dist = distance(position.xy, uMouse3D.xy); 23 | // float area = 1.- smoothstep(0., 30., dist); 24 | // stablePosition.z += dist * 0.1; 25 | 26 | vec4 newPosition = modelViewMatrix * vec4(stablePosition, 1.0); 27 | 28 | //Barrel animation 29 | // newPosition.z += sin(newPosition.y / uViewportSizes.y * PI + PI / 2.0) * -uStrength * 2.5; 30 | // newPosition.z += sin(newPosition.x / uViewportSizes.x * PI + PI / 2.0) * -uStrength * 2.5; 31 | 32 | gl_Position = projectionMatrix * newPosition; 33 | 34 | vUv = uv; 35 | } 36 | -------------------------------------------------------------------------------- /frontend/src/classes/Pages/IndexPage/IndexPage.ts: -------------------------------------------------------------------------------- 1 | import { UpdateInfo, Bounds, ExitFn } from 'types'; 2 | 3 | import { Page } from '../Page'; 4 | import { IndexPageCanvas } from './Canvas/IndexPageCanvas'; 5 | import { InteractiveScene } from '../../Components/InteractiveScene'; 6 | import { Curtain } from '../../HTMLComponents/Curtain'; 7 | import { MoreLabel } from '../../HTMLComponents/MoreLabel'; 8 | 9 | interface Constructor { 10 | pageId: string; 11 | } 12 | 13 | export class IndexPage extends Page { 14 | static anmCurtain = '[data-curtain="wrapper"]'; 15 | static anmMoreLabel = '[data-morelabel="hover"]'; 16 | 17 | _pageCanvas: IndexPageCanvas; 18 | _anmCurtains: Curtain[] = []; 19 | _anmMoreLabels: MoreLabel[] = []; 20 | 21 | constructor({ pageId }: Constructor) { 22 | super({ pageId }); 23 | 24 | this._pageCanvas = new IndexPageCanvas({}); 25 | this._pageCanvas.scrollValues = this._scrollValues; 26 | } 27 | 28 | setRendererBounds(bounds: Bounds) { 29 | super.setRendererBounds(bounds); 30 | 31 | this._pageCanvas.setRendererBounds(bounds); 32 | } 33 | 34 | onEnter(el: HTMLElement) { 35 | super.onEnter(el); 36 | 37 | if (!this._pageEl) return; 38 | 39 | const curtains = Array.from( 40 | this._pageEl.querySelectorAll(IndexPage.anmCurtain), 41 | ) as HTMLElement[]; 42 | 43 | this._anmCurtains = curtains.map((el) => { 44 | return new Curtain({ element: el }); 45 | }); 46 | 47 | const moreLabels = Array.from( 48 | this._pageEl.querySelectorAll(IndexPage.anmMoreLabel), 49 | ) as HTMLElement[]; 50 | 51 | this._anmMoreLabels = moreLabels.map((el) => { 52 | return new MoreLabel({ element: el }); 53 | }); 54 | 55 | this._pageCanvas.onEnter(el); 56 | } 57 | 58 | onAssetsLoaded() { 59 | super.onAssetsLoaded(); 60 | this._pageCanvas.onAssetsLoaded(); 61 | } 62 | 63 | onExit() { 64 | super.onExit(); 65 | this._pageCanvas.onExit(); 66 | } 67 | 68 | animateIn() { 69 | super.animateIn(); 70 | this._pageCanvas.animateIn(); 71 | } 72 | 73 | onExitToDetails(props: ExitFn) { 74 | //It executes the functions that onExit() normally executes 75 | this._animateOut(); 76 | this._removeListeners(); 77 | 78 | this._anmCurtains.forEach((el) => { 79 | el.stopHoverTween(); 80 | }); 81 | 82 | this._pageCanvas.onExitToDetails(props); 83 | } 84 | 85 | setInteractiveScene(scene: InteractiveScene) { 86 | this._pageCanvas.setInteractiveScene(scene); 87 | } 88 | 89 | _removeListeners() { 90 | super._removeListeners(); 91 | 92 | this._anmCurtains.forEach((el) => { 93 | el.removeListeners(); 94 | }); 95 | 96 | this._anmMoreLabels.forEach((el) => { 97 | el.removeListeners(); 98 | }); 99 | } 100 | 101 | update(updateInfo: UpdateInfo) { 102 | super.update(updateInfo); 103 | this._pageCanvas.update(updateInfo); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /frontend/src/classes/Pages/Page.ts: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | import Prefix from 'prefix'; 3 | 4 | import { Bounds, ScrollValues, UpdateInfo } from 'types'; 5 | import { globalState } from 'utils/globalState'; 6 | 7 | import { Scroll } from '../Singletons/Scroll'; 8 | import { Paragraph } from '../HTMLComponents/Paragraph'; 9 | import { InteractiveScene } from '../Components/InteractiveScene'; 10 | import { lerp } from '../utils/lerp'; 11 | import { BottomHide } from '../HTMLComponents/BottomHide'; 12 | 13 | interface Constructor { 14 | pageId: string; 15 | } 16 | 17 | export class Page extends THREE.EventDispatcher { 18 | static lerpEase = 0.08; 19 | static wheelMultiplier = 1; 20 | static mouseMultiplier = 2; 21 | static touchMultiplier = 1; 22 | static anmParagraph = '[data-animation="paragraph"]'; 23 | static anmBottomHide = '[data-animation="bottomhide"]'; 24 | static scrollContainer = '[data-scroll="page"]'; 25 | static pageContainer = '[data-page="wrapper"]'; 26 | 27 | pageId: string; 28 | _anmParagraphs: Paragraph[] = []; 29 | _anmBottomHide: BottomHide[] = []; 30 | _scroll = Scroll.getInstance(); 31 | _pageEl: HTMLElement | null = null; 32 | _rendererBounds: Bounds = { height: 10, width: 100 }; 33 | _pageElScroll: HTMLElement | null = null; 34 | _pageElScrollBounds: DOMRect | null = null; 35 | _transformPrefix = Prefix('transform'); 36 | _scrollValues: ScrollValues = { 37 | scroll: { 38 | current: 0, 39 | target: 0, 40 | last: 0, 41 | }, 42 | direction: 'down', 43 | strength: { 44 | current: 0, 45 | target: 0, 46 | }, 47 | }; 48 | 49 | constructor({ pageId }: Constructor) { 50 | super(); 51 | this.pageId = pageId; 52 | } 53 | 54 | _resetScrollValues() { 55 | this._scrollValues.direction = 'down'; 56 | 57 | this._scrollValues.scroll.current = 0; 58 | this._scrollValues.scroll.target = 0; 59 | this._scrollValues.scroll.last = 0; 60 | 61 | this._scrollValues.strength.current = 0; 62 | this._scrollValues.strength.target = 0; 63 | } 64 | 65 | _animateOut() { 66 | this._anmParagraphs.forEach((el) => { 67 | el.animateOut(); 68 | }); 69 | 70 | this._anmParagraphs = []; 71 | 72 | this._anmBottomHide.forEach((el) => { 73 | el.animateOut(); 74 | }); 75 | 76 | this._anmBottomHide = []; 77 | } 78 | 79 | _updateScrollValues(updateInfo: UpdateInfo) { 80 | //Update scroll direction 81 | if (this._scrollValues.scroll.current > this._scrollValues.scroll.last) { 82 | this._scrollValues.direction = 'up'; 83 | } else { 84 | this._scrollValues.direction = 'down'; 85 | } 86 | 87 | //Update scroll strength 88 | const deltaY = 89 | this._scrollValues.scroll.current - this._scrollValues.scroll.last; 90 | 91 | this._scrollValues.strength.target = deltaY; 92 | 93 | this._scrollValues.strength.current = lerp( 94 | this._scrollValues.strength.current, 95 | this._scrollValues.strength.target, 96 | Page.lerpEase * updateInfo.slowDownFactor, 97 | ); 98 | 99 | this._scrollValues.scroll.last = this._scrollValues.scroll.current; 100 | 101 | //lerp scroll 102 | this._scrollValues.scroll.current = lerp( 103 | this._scrollValues.scroll.current, 104 | this._scrollValues.scroll.target, 105 | Page.lerpEase * updateInfo.slowDownFactor, 106 | ); 107 | } 108 | 109 | _applyScroll = (x: number, y: number) => { 110 | if (!this._pageElScrollBounds || globalState.isAppTransitioning) return; 111 | 112 | let newY = this._scrollValues.scroll.target + y; 113 | 114 | const bottomBound = 0; 115 | let topBound = 116 | this._pageElScrollBounds.height - this._rendererBounds.height; 117 | if (topBound < 0) topBound = 0; 118 | 119 | if (-newY <= bottomBound) { 120 | newY = bottomBound; 121 | } else if (-newY >= topBound) { 122 | newY = -topBound; 123 | } 124 | 125 | this._scrollValues.scroll.target = newY; 126 | }; 127 | 128 | _onScrollMouse = (e: THREE.Event) => { 129 | this._applyScroll(e.x * Page.mouseMultiplier, e.y * Page.mouseMultiplier); 130 | }; 131 | _onScrollTouch = (e: THREE.Event) => { 132 | this._applyScroll(e.x * Page.touchMultiplier, e.y * Page.touchMultiplier); 133 | }; 134 | _onScrollWheel = (e: THREE.Event) => { 135 | this._applyScroll(e.x * Page.wheelMultiplier, e.y * Page.wheelMultiplier); 136 | }; 137 | 138 | _addListeners() { 139 | this._scroll.addEventListener('mouse', this._onScrollMouse); 140 | this._scroll.addEventListener('touch', this._onScrollTouch); 141 | this._scroll.addEventListener('wheel', this._onScrollWheel); 142 | } 143 | 144 | _removeListeners() { 145 | this._scroll.removeEventListener('mouse', this._onScrollMouse); 146 | this._scroll.removeEventListener('touch', this._onScrollTouch); 147 | this._scroll.removeEventListener('wheel', this._onScrollWheel); 148 | } 149 | 150 | _updateCss() { 151 | if (this._pageElScroll) { 152 | this._pageElScroll.style[ 153 | this._transformPrefix 154 | ] = `translate3d(0,${this._scrollValues.scroll.current}px,0)`; 155 | } 156 | } 157 | 158 | animateIn() { 159 | this._anmParagraphs.forEach((el) => { 160 | el.initObserver(); 161 | }); 162 | 163 | this._anmBottomHide.forEach((el) => { 164 | el.animateIn(); 165 | }); 166 | } 167 | 168 | onEnter(el: HTMLElement) { 169 | this._pageEl = Array.from( 170 | el.querySelectorAll(Page.pageContainer), 171 | )[0] as HTMLElement; 172 | 173 | this._pageElScroll = Array.from( 174 | el.querySelectorAll(Page.scrollContainer), 175 | )[0] as HTMLElement; 176 | 177 | this._pageElScrollBounds = this._pageElScroll.getBoundingClientRect(); 178 | 179 | const paragraphs = Array.from( 180 | this._pageEl.querySelectorAll(Page.anmParagraph), 181 | ) as HTMLElement[]; 182 | 183 | this._anmParagraphs = paragraphs.map((el) => { 184 | return new Paragraph({ element: el }); 185 | }); 186 | 187 | const bottomHides = Array.from( 188 | this._pageEl.querySelectorAll(Page.anmBottomHide), 189 | ) as HTMLElement[]; 190 | 191 | this._anmBottomHide = bottomHides.map((el) => { 192 | return new BottomHide({ element: el }); 193 | }); 194 | 195 | this._addListeners(); 196 | } 197 | 198 | onExit() { 199 | this._animateOut(); 200 | this._removeListeners(); 201 | } 202 | 203 | setInteractiveScene(scene: InteractiveScene) {} 204 | 205 | onAssetsLoaded() {} 206 | 207 | setRendererBounds(bounds: Bounds) { 208 | this._rendererBounds = bounds; 209 | 210 | this._anmParagraphs.forEach((el) => { 211 | el.onResize(); 212 | }); 213 | 214 | if (this._pageElScroll) 215 | this._pageElScrollBounds = this._pageElScroll.getBoundingClientRect(); 216 | } 217 | 218 | update(updateInfo: UpdateInfo) { 219 | this._updateScrollValues(updateInfo); 220 | this._updateCss(); 221 | } 222 | } 223 | -------------------------------------------------------------------------------- /frontend/src/classes/Pages/PageCanvas.ts: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | 3 | import { Bounds, UpdateInfo, ScrollValues } from 'types'; 4 | 5 | import { InteractiveScene } from '../Components/InteractiveScene'; 6 | 7 | interface Constructor {} 8 | 9 | export class PageCanvas { 10 | _interactiveScene: InteractiveScene | null = null; 11 | _pageEl: HTMLElement | null = null; 12 | _planeGeometry = new THREE.PlaneGeometry(1, 1, 50, 50); 13 | _rendererBounds: Bounds = { height: 100, width: 100 }; 14 | _scrollValues: ScrollValues | null = null; 15 | 16 | constructor(props: Constructor) {} 17 | 18 | setInteractiveScene(scene: InteractiveScene) { 19 | this._interactiveScene = scene; 20 | } 21 | 22 | onEnter(el: HTMLElement) { 23 | this._pageEl = el; 24 | } 25 | 26 | onExit() {} 27 | 28 | setRendererBounds(bounds: Bounds) { 29 | this._rendererBounds = bounds; 30 | } 31 | 32 | onAssetsLoaded() {} 33 | 34 | update(updateInfo: UpdateInfo) {} 35 | 36 | set scrollValues(values: ScrollValues) { 37 | this._scrollValues = values; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /frontend/src/classes/Pages/PageManager.ts: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | 3 | import { Bounds, UpdateInfo } from 'types'; 4 | import { globalState } from 'utils/globalState'; 5 | 6 | import { Transition } from '../Components/Transition'; 7 | import { DetailsPage } from './DetailsPage/DetailsPage'; 8 | import { IndexPage } from './IndexPage/IndexPage'; 9 | import { Page } from './Page'; 10 | import { InteractiveScene } from '../Components/InteractiveScene'; 11 | 12 | export class PageManager extends THREE.EventDispatcher { 13 | _pagesArray: Page[] = []; 14 | _transition: Transition; 15 | 16 | constructor() { 17 | super(); 18 | 19 | this._pagesArray.push(new IndexPage({ pageId: '/' })); 20 | this._pagesArray.push(new DetailsPage({ pageId: '/details/[id]' })); 21 | this._transition = new Transition(); 22 | } 23 | 24 | handlePageEnter(pageEl: HTMLElement) { 25 | const oldPageId = globalState.currentPageId; 26 | const newPageId = pageEl.dataset.pageid; 27 | 28 | const oldQueryId = globalState.currentQueryId || ''; 29 | const newQueryId = pageEl.dataset.queryid || ''; 30 | 31 | if (newPageId) globalState.currentPageId = newPageId; 32 | if (newQueryId) globalState.currentQueryId = newQueryId; 33 | 34 | let isEnterInitial = false; 35 | if (newPageId === oldPageId) isEnterInitial = true; 36 | 37 | const fromDetailsToIndex = 38 | oldPageId === '/details/[id]' && newPageId === '/'; 39 | const fromIndexToDetails = 40 | oldPageId === '/' && newPageId === '/details/[id]'; 41 | 42 | const newPage = this._pagesArray.find((page) => page.pageId === newPageId); 43 | const oldPage = this._pagesArray.find((page) => page.pageId === oldPageId); 44 | 45 | if (newPage) newPage.onEnter(pageEl); 46 | 47 | globalState.isAppTransitioning = true; 48 | 49 | const parentFn = () => { 50 | if (newPage) newPage.animateIn(); 51 | globalState.isAppTransitioning = false; 52 | }; 53 | 54 | if (fromDetailsToIndex) { 55 | if (oldPage) 56 | (oldPage as DetailsPage).onExitToIndex({ 57 | parentFn, 58 | targetId: oldQueryId, 59 | }); 60 | } else if (fromIndexToDetails) { 61 | if (oldPage) 62 | (oldPage as IndexPage).onExitToDetails({ 63 | parentFn, 64 | targetId: newQueryId, 65 | }); 66 | } else { 67 | if (isEnterInitial) { 68 | // Raf fixes css styles issue (without Raf, they are being added at the same time as a class, and it removes the initial animation) 69 | return window.requestAnimationFrame(() => { 70 | parentFn(); 71 | }); 72 | } 73 | 74 | if (oldPage) oldPage.onExit(); 75 | this._transition.show('#ded4bd', parentFn); 76 | } 77 | return; 78 | } 79 | 80 | setRendererBounds(rendererBounds: Bounds) { 81 | this._pagesArray.forEach((page) => { 82 | page.setRendererBounds(rendererBounds); 83 | }); 84 | 85 | this._transition.setRendererBounds(rendererBounds); 86 | } 87 | 88 | setInteractiveScene(scene: InteractiveScene) { 89 | this._pagesArray.forEach((page) => { 90 | page.setInteractiveScene(scene); 91 | }); 92 | } 93 | 94 | onAssetsLoaded() { 95 | this._pagesArray.forEach((page) => { 96 | page.onAssetsLoaded(); 97 | }); 98 | } 99 | 100 | update(updateInfo: UpdateInfo) { 101 | this._pagesArray.forEach((page) => { 102 | page.update(updateInfo); 103 | }); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /frontend/src/classes/Singletons/MouseMove.ts: -------------------------------------------------------------------------------- 1 | import { EventDispatcher } from 'three'; 2 | 3 | import { UpdateInfo } from 'types'; 4 | 5 | interface Mouse { 6 | x: number; 7 | y: number; 8 | } 9 | 10 | export class MouseMove extends EventDispatcher { 11 | _mouseLast: Mouse = { x: 0, y: 0 }; 12 | _isTouching = false; 13 | _clickStart: Mouse = { x: 0, y: 0 }; 14 | mouse: Mouse = { x: 0, y: 0 }; 15 | strength = 0; 16 | 17 | static _instance: MouseMove | null; 18 | static _canCreate = false; 19 | static getInstance() { 20 | if (!MouseMove._instance) { 21 | MouseMove._canCreate = true; 22 | MouseMove._instance = new MouseMove(); 23 | MouseMove._canCreate = false; 24 | } 25 | 26 | return MouseMove._instance; 27 | } 28 | 29 | constructor() { 30 | super(); 31 | 32 | if (MouseMove._instance || !MouseMove._canCreate) { 33 | throw new Error('Use MouseMove.getInstance()'); 34 | } 35 | 36 | this._addEvents(); 37 | 38 | MouseMove._instance = this; 39 | } 40 | 41 | _onTouchDown = (event: TouchEvent | MouseEvent) => { 42 | this._isTouching = true; 43 | this._mouseLast.x = 44 | 'touches' in event ? event.touches[0].clientX : event.clientX; 45 | this._mouseLast.y = 46 | 'touches' in event ? event.touches[0].clientY : event.clientY; 47 | 48 | this.mouse.x = this._mouseLast.x; 49 | this.mouse.y = this._mouseLast.y; 50 | 51 | this._clickStart.x = this.mouse.x; 52 | this._clickStart.y = this.mouse.y; 53 | this.dispatchEvent({ type: 'down' }); 54 | }; 55 | 56 | _onTouchMove = (event: TouchEvent | MouseEvent) => { 57 | const touchX = 58 | 'touches' in event ? event.touches[0].clientX : event.clientX; 59 | const touchY = 60 | 'touches' in event ? event.touches[0].clientY : event.clientY; 61 | 62 | const deltaX = touchX - this._mouseLast.x; 63 | const deltaY = touchY - this._mouseLast.y; 64 | 65 | this.strength = deltaX * deltaX + deltaY * deltaY; 66 | 67 | this._mouseLast.x = touchX; 68 | this._mouseLast.y = touchY; 69 | 70 | this.mouse.x += deltaX; 71 | this.mouse.y += deltaY; 72 | }; 73 | 74 | _onTouchUp = () => { 75 | this._isTouching = false; 76 | this.dispatchEvent({ type: 'up' }); 77 | }; 78 | 79 | _onMouseLeave = () => {}; 80 | 81 | _onClick = () => { 82 | const clickBounds = 10; 83 | const xDiff = Math.abs(this._clickStart.x - this.mouse.x); 84 | const yDiff = Math.abs(this._clickStart.y - this.mouse.y); 85 | 86 | //Make sure that the user's click is held between certain boundaries 87 | if (xDiff <= clickBounds && yDiff <= clickBounds) { 88 | this.dispatchEvent({ type: 'click' }); 89 | } 90 | }; 91 | 92 | _addEvents() { 93 | window.addEventListener('mousedown', this._onTouchDown); 94 | window.addEventListener('mousemove', this._onTouchMove); 95 | window.addEventListener('mouseup', this._onTouchUp); 96 | window.addEventListener('click', this._onClick); 97 | 98 | window.addEventListener('touchstart', this._onTouchDown); 99 | window.addEventListener('touchmove', this._onTouchMove); 100 | window.addEventListener('touchend', this._onTouchUp); 101 | 102 | window.addEventListener('mouseout', this._onMouseLeave); 103 | } 104 | 105 | update(updateInfo: UpdateInfo) { 106 | this.dispatchEvent({ type: 'mousemove' }); 107 | const { mouse, _mouseLast } = this; 108 | 109 | _mouseLast.x = mouse.x; 110 | _mouseLast.y = mouse.y; 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /frontend/src/classes/Singletons/Scroll.ts: -------------------------------------------------------------------------------- 1 | import { EventDispatcher } from 'three'; 2 | import normalizeWheel from 'normalize-wheel'; 3 | 4 | import { UpdateInfo } from 'types'; 5 | 6 | interface ApplyScrollXY { 7 | x: number; 8 | y: number; 9 | type: 'touchmove' | 'mousemove' | 'wheel'; 10 | } 11 | 12 | export class Scroll extends EventDispatcher { 13 | _lastTouch = { x: 0, y: 0 }; 14 | _useMomentum = false; 15 | _touchMomentum = { x: 0, y: 0 }; 16 | _isTouching = false; 17 | 18 | static momentumCarry = 0.5; 19 | static momentumDamping = 0.95; 20 | static _instance: Scroll; 21 | static _canCreate = false; 22 | static getInstance() { 23 | if (!Scroll._instance) { 24 | Scroll._canCreate = true; 25 | Scroll._instance = new Scroll(); 26 | Scroll._canCreate = false; 27 | } 28 | 29 | return Scroll._instance; 30 | } 31 | 32 | constructor() { 33 | super(); 34 | 35 | if (Scroll._instance || !Scroll._canCreate) { 36 | throw new Error('Use Scroll.getInstance()'); 37 | } 38 | 39 | this._addEvents(); 40 | 41 | Scroll._instance = this; 42 | } 43 | 44 | _applyScrollXY({ x, y, type }: ApplyScrollXY) { 45 | switch (type) { 46 | case 'mousemove': 47 | this.dispatchEvent({ type: 'mouse', x, y }); 48 | break; 49 | case 'touchmove': 50 | this.dispatchEvent({ type: 'touch', x, y }); 51 | break; 52 | case 'wheel': 53 | this.dispatchEvent({ type: 'wheel', x, y }); 54 | break; 55 | default: 56 | break; 57 | } 58 | } 59 | 60 | _onTouchDown = (event: TouchEvent | MouseEvent) => { 61 | this._isTouching = true; 62 | this.dispatchEvent({ type: 'touchdown' }); 63 | this._useMomentum = false; 64 | this._lastTouch.x = 65 | 'touches' in event ? event.touches[0].clientX : event.clientX; 66 | this._lastTouch.y = 67 | 'touches' in event ? event.touches[0].clientY : event.clientY; 68 | }; 69 | 70 | _onTouchMove = (event: TouchEvent | MouseEvent) => { 71 | if (!this._isTouching) { 72 | return; 73 | } 74 | 75 | const touchX = 76 | 'touches' in event ? event.touches[0].clientX : event.clientX; 77 | const touchY = 78 | 'touches' in event ? event.touches[0].clientY : event.clientY; 79 | 80 | const deltaX = touchX - this._lastTouch.x; 81 | const deltaY = touchY - this._lastTouch.y; 82 | 83 | this._lastTouch.x = touchX; 84 | this._lastTouch.y = touchY; 85 | 86 | this._touchMomentum.x *= Scroll.momentumCarry; 87 | this._touchMomentum.y *= Scroll.momentumCarry; 88 | 89 | this._touchMomentum.y += deltaY; 90 | this._touchMomentum.x += deltaX; 91 | 92 | const type = 'touches' in event ? 'touchmove' : 'mousemove'; 93 | 94 | this._applyScrollXY({ x: deltaX, y: deltaY, type }); 95 | }; 96 | 97 | _onTouchUp = () => { 98 | this._isTouching = false; 99 | this.dispatchEvent({ type: 'touchup' }); 100 | this._useMomentum = true; 101 | }; 102 | 103 | _onWheel = (event: WheelEvent) => { 104 | this._useMomentum = false; 105 | 106 | const { pixelY } = normalizeWheel(event); 107 | 108 | this._applyScrollXY({ 109 | x: 0, 110 | y: -pixelY, 111 | type: 'wheel', 112 | }); 113 | }; 114 | 115 | _onResize = () => { 116 | this._useMomentum = false; 117 | }; 118 | 119 | _addEvents() { 120 | window.addEventListener('wheel', this._onWheel); 121 | 122 | window.addEventListener('mousedown', this._onTouchDown); 123 | window.addEventListener('mousemove', this._onTouchMove); 124 | window.addEventListener('mouseup', this._onTouchUp); 125 | 126 | window.addEventListener('touchstart', this._onTouchDown); 127 | window.addEventListener('touchmove', this._onTouchMove); 128 | window.addEventListener('touchend', this._onTouchUp); 129 | 130 | window.addEventListener('resize', this._onResize); 131 | 132 | this._onResize(); 133 | } 134 | 135 | update(updateInfo: UpdateInfo) { 136 | //Apply scroll momentum after user touch is ended 137 | if (!this._useMomentum) { 138 | return; 139 | } 140 | 141 | const timeFactor = Math.min( 142 | Math.max(updateInfo.time / (1000 / updateInfo.time), 1), 143 | 4, 144 | ); 145 | this._touchMomentum.x *= Math.pow(Scroll.momentumDamping, timeFactor); 146 | this._touchMomentum.y *= Math.pow(Scroll.momentumDamping, timeFactor); 147 | 148 | if (Math.abs(this._touchMomentum.x) >= 0.01) { 149 | this._applyScrollXY({ 150 | y: 0, 151 | x: this._touchMomentum.x, 152 | type: 'touchmove', 153 | }); 154 | } 155 | 156 | if (Math.abs(this._touchMomentum.y) >= 0.01) { 157 | this._applyScrollXY({ 158 | y: this._touchMomentum.y, 159 | x: 0, 160 | type: 'touchmove', 161 | }); 162 | } 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /frontend/src/classes/Utility/Preloader.ts: -------------------------------------------------------------------------------- 1 | import { EventDispatcher } from 'three'; 2 | import * as THREE from 'three'; 3 | 4 | import { TextureItems } from 'types'; 5 | 6 | export class Preloader extends EventDispatcher { 7 | _assetsLoaded = 0; 8 | _images: string[] = []; 9 | textureItems: TextureItems = {}; 10 | 11 | constructor() { 12 | super(); 13 | } 14 | 15 | _preloadTextures() { 16 | this._images.forEach((item) => { 17 | const texture = new THREE.Texture(); 18 | const image = new window.Image(); 19 | image.crossOrigin = 'anonymous'; 20 | image.src = item; 21 | image.onload = () => { 22 | texture.image = image; 23 | texture.needsUpdate = true; 24 | this.textureItems[item] = { 25 | texture, 26 | naturalWidth: image.naturalWidth, 27 | naturalHeight: image.naturalHeight, 28 | }; 29 | this._onAssetLoaded(); 30 | }; 31 | }); 32 | } 33 | 34 | _onAssetLoaded() { 35 | this._assetsLoaded += 1; 36 | 37 | const loadRatio = this._assetsLoaded / this._images.length; 38 | 39 | if (loadRatio === 1) { 40 | this._onLoadingComplete(); 41 | } 42 | } 43 | 44 | _onLoadingComplete() { 45 | this.dispatchEvent({ type: 'loaded' }); 46 | } 47 | 48 | set images(images: string[]) { 49 | //Does not load the images twice for the whole app 50 | if (this._images.length !== 0) return; 51 | this._images = images; 52 | this._preloadTextures(); 53 | } 54 | 55 | destroy() {} 56 | } 57 | -------------------------------------------------------------------------------- /frontend/src/classes/utils/getRand.ts: -------------------------------------------------------------------------------- 1 | export const getRandInt = (minimum: number, maximum: number): number => { 2 | return Math.floor(Math.random() * (maximum - minimum + 1)) + minimum; 3 | }; 4 | 5 | export const getRandFloat = (minimum: number, maximum: number): number => { 6 | return Math.random() * (maximum - minimum + 1) + minimum; 7 | }; 8 | -------------------------------------------------------------------------------- /frontend/src/classes/utils/lerp.ts: -------------------------------------------------------------------------------- 1 | export const lerp = (p1: number, p2: number, t: number) => { 2 | return p1 + (p2 - p1) * t; 3 | }; 4 | -------------------------------------------------------------------------------- /frontend/src/classes/utils/wrapEl.ts: -------------------------------------------------------------------------------- 1 | import { WrapEl } from 'types'; 2 | 3 | export const wrapEl = ({ el, wrapperClass }: WrapEl) => { 4 | if (!el || !el.parentNode) return; 5 | 6 | const wrapper = document.createElement('span'); 7 | wrapper.classList.add(wrapperClass); 8 | 9 | el.parentNode.insertBefore(wrapper, el); 10 | wrapper.appendChild(el); 11 | }; 12 | -------------------------------------------------------------------------------- /frontend/src/components/CardContent/CardContent.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { RichText } from 'components/RichText/RichText'; 4 | 5 | export interface Props { 6 | moreLabel: string; 7 | title: string; 8 | whiteColor?: boolean; 9 | elIndex: number; 10 | } 11 | 12 | export const CardContent = (props: Props) => { 13 | const { elIndex, whiteColor, moreLabel, title } = props; 14 | return ( 15 | 16 | 17 | 24 | 25 | 26 | 27 | 28 | 33 | 38 | {elIndex < 10 ? `0${elIndex}` : elIndex} 39 | 40 | 41 | 47 | 48 | 54 | {moreLabel} 55 | 56 | 57 | 58 | 59 | ); 60 | }; 61 | -------------------------------------------------------------------------------- /frontend/src/components/CardPreview/CardPreview.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { CardContent } from 'components/CardContent/CardContent'; 4 | 5 | export interface Props { 6 | moreLabel: string; 7 | title: string; 8 | frontImgSrc: string; 9 | elIndex: number; 10 | cardUid: string; 11 | } 12 | 13 | export const CardPreview = (props: Props) => { 14 | const { cardUid, elIndex, frontImgSrc, moreLabel, title } = props; 15 | return ( 16 | 22 | 27 | 32 | 38 | 39 | 40 | 44 | 45 | 46 | 51 | 52 | 53 | 54 | 55 | 59 | 60 | 61 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 80 | 81 | 82 | 83 | ); 84 | }; 85 | -------------------------------------------------------------------------------- /frontend/src/components/Layout/Layout.module.scss: -------------------------------------------------------------------------------- 1 | .inspiredWrapper { 2 | position: fixed; 3 | z-index: 35; 4 | top: 20px; 5 | right: 40px; 6 | display: flex; 7 | mix-blend-mode: difference; 8 | } 9 | 10 | .gitWrapper { 11 | position: fixed; 12 | z-index: 35; 13 | bottom: 20px; 14 | left: 40px; 15 | display: flex; 16 | mix-blend-mode: difference; 17 | } 18 | 19 | .authorWrapper { 20 | display: none; 21 | 22 | @media screen and (min-width: 767px) { 23 | display: initial; 24 | position: fixed; 25 | z-index: 5; 26 | bottom: 20px; 27 | right: 40px; 28 | display: flex; 29 | mix-blend-mode: difference; 30 | } 31 | } 32 | 33 | .contactSpacer { 34 | width: 8px; 35 | } 36 | -------------------------------------------------------------------------------- /frontend/src/components/Layout/Layout.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import clsx from 'clsx'; 3 | 4 | import { LinkHandler } from 'components/LinkHandler/LinkHandler'; 5 | import { useRouter } from 'next/router'; 6 | import sharedStyles from 'utils/sharedStyles.module.scss'; 7 | 8 | import styles from './Layout.module.scss'; 9 | 10 | export const Layout = () => { 11 | const router = useRouter(); 12 | 13 | return ( 14 | <> 15 | 16 | 17 | NextJS page tansitions with ThreeJS by 18 | 19 | 20 | 21 | 28 | Michal Zalobny 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | Inspired by Nowhere 37 | 38 | 39 | 40 | 41 | 42 | 46 | 47 | GitHub repo 48 | 49 | 50 | 51 | > 52 | ); 53 | }; 54 | -------------------------------------------------------------------------------- /frontend/src/components/LinkHandler/LinkHandler.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Link from 'next/link'; 3 | 4 | interface Props { 5 | elHref?: string; 6 | isExternal?: boolean; 7 | onClickFn?: () => void; 8 | children: React.ReactNode; 9 | } 10 | 11 | export const LinkHandler = (props: Props) => { 12 | const { elHref, children, isExternal, onClickFn } = props; 13 | 14 | return ( 15 | <> 16 | {isExternal ? ( 17 | 23 | {children} 24 | 25 | ) : onClickFn ? ( 26 | onClickFn()} 29 | > 30 | {children} 31 | 32 | ) : ( 33 | elHref && ( 34 | 35 | {children} 36 | 37 | ) 38 | )} 39 | > 40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /frontend/src/components/RichText/RichText.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { compiler } from 'markdown-to-jsx'; 3 | 4 | export interface RichTextProps { 5 | text: string; 6 | } 7 | 8 | export const RichText = (props: RichTextProps) => { 9 | const { text } = props; 10 | 11 | return {compiler(text, { wrapper: null })}; 12 | }; 13 | -------------------------------------------------------------------------------- /frontend/src/containers/DetailsPage/DetailsPage.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { useRouter } from 'next/router'; 3 | 4 | import { RichText } from 'components/RichText/RichText'; 5 | import { globalState } from 'utils/globalState'; 6 | import { Head } from 'seo/Head/Head'; 7 | 8 | import { Props } from './data'; 9 | 10 | export default function DetailsPage(props: Props) { 11 | const { card, cardsCms } = props; 12 | 13 | const router = useRouter(); 14 | 15 | useEffect(() => { 16 | const imagesToPreload = cardsCms.map((card) => card.imageSrc); 17 | window.requestAnimationFrame(() => { 18 | if (globalState.canvasApp) 19 | globalState.canvasApp.imagesToPreload = imagesToPreload; 20 | }); 21 | }, [cardsCms]); 22 | 23 | return ( 24 | <> 25 | 31 | 32 | 33 | router.push('/')}> 34 | 38 | Back 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 56 | 57 | 58 | 59 | 60 | {card.description} 61 | 62 | 63 | 64 | 65 | > 66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /frontend/src/containers/DetailsPage/data.ts: -------------------------------------------------------------------------------- 1 | import { GetStaticProps } from 'next'; 2 | 3 | import { getCards, Card } from 'utils/prismic/queries/getCards'; 4 | import { getLayout } from 'utils/prismic/queries/getLayout'; 5 | 6 | import { ISR_TIMEOUT } from 'utils/prismic/isrTimeout'; 7 | 8 | export interface Props { 9 | card: Card; 10 | cardsCms: Card[]; 11 | } 12 | 13 | export const getStaticProps: GetStaticProps = async ({ params }) => { 14 | const detailId = params?.id; 15 | const cards = await getCards(); 16 | 17 | const urlCard = cards.find((el) => el.uid === detailId); 18 | const layout = await getLayout(); 19 | 20 | return { 21 | props: { 22 | card: urlCard, 23 | cardsCms: cards, 24 | }, 25 | revalidate: ISR_TIMEOUT, 26 | }; 27 | }; 28 | -------------------------------------------------------------------------------- /frontend/src/containers/ErrorPage/ErrorPage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface ErrorPageProps { 4 | statusCode: number; 5 | } 6 | 7 | export default function ErrorPage(props: ErrorPageProps) { 8 | const { statusCode } = props; 9 | return ( 10 | <> 11 | 12 | 13 | Something went wrong {`| ${statusCode || 'undefined code'}`} 14 | 15 | 16 | > 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /frontend/src/containers/IndexPage/IndexPage.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | 3 | import { globalState } from 'utils/globalState'; 4 | import { CardPreview } from 'components/CardPreview/CardPreview'; 5 | import { Head } from 'seo/Head/Head'; 6 | 7 | import { Props } from './data'; 8 | 9 | export default function IndexPage(props: Props) { 10 | const { layout, head, cardsCms } = props; 11 | 12 | useEffect(() => { 13 | const imagesToPreload = cardsCms.map((card) => card.imageSrc); 14 | window.requestAnimationFrame(() => { 15 | if (globalState.canvasApp) 16 | globalState.canvasApp.imagesToPreload = imagesToPreload; 17 | }); 18 | }, [cardsCms]); 19 | 20 | return ( 21 | <> 22 | 23 | 24 | {cardsCms.map((el, key) => ( 25 | 33 | ))} 34 | 35 | > 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /frontend/src/containers/IndexPage/data.ts: -------------------------------------------------------------------------------- 1 | import { GetStaticProps } from 'next'; 2 | 3 | import { getCards, Card } from 'utils/prismic/queries/getCards'; 4 | import { getLayout, Layout } from 'utils/prismic/queries/getLayout'; 5 | import { getSeoHead } from 'utils/prismic/queries/getSeoHead'; 6 | import { HeadProps } from 'seo/Head/Head'; 7 | 8 | import { ISR_TIMEOUT } from 'utils/prismic/isrTimeout'; 9 | 10 | export interface Props { 11 | cardsCms: Card[]; 12 | head: HeadProps; 13 | layout: Layout; 14 | } 15 | 16 | export const getStaticProps: GetStaticProps = async ({ locale }) => { 17 | const cards = await getCards(); 18 | const layout = await getLayout(); 19 | const head = await getSeoHead('indexpage'); 20 | 21 | return { 22 | props: { 23 | cardsCms: cards, 24 | layout, 25 | head, 26 | }, 27 | revalidate: ISR_TIMEOUT, 28 | }; 29 | }; 30 | -------------------------------------------------------------------------------- /frontend/src/pages/404.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useCallback } from 'react'; 2 | import { useRouter } from 'next/router'; 3 | 4 | export default function Error404() { 5 | const router = useRouter(); 6 | 7 | const goToLanding = useCallback(() => { 8 | router.push('/'); 9 | }, [router]); 10 | 11 | useEffect(() => { 12 | goToLanding(); 13 | }, [goToLanding]); 14 | 15 | return null; 16 | } 17 | -------------------------------------------------------------------------------- /frontend/src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useEffect } from 'react'; 2 | import FontFaceObserver from 'fontfaceobserver'; 3 | import type { AppProps } from 'next/app'; 4 | import { useRouter } from 'next/router'; 5 | import { CSSTransition, TransitionGroup } from 'react-transition-group'; 6 | import 'intersection-observer'; 7 | 8 | import { Layout } from 'components/Layout/Layout'; 9 | import { globalState } from 'utils/globalState'; 10 | import { CanvasApp } from 'classes/CanvasApp'; 11 | import { pageTransitionDuration } from 'variables'; 12 | 13 | import '../styles/index.scss'; 14 | 15 | export default function MyApp(props: AppProps) { 16 | const { Component, pageProps } = props; 17 | const router = useRouter(); 18 | 19 | useEffect(() => { 20 | //Used just to navigate in canvasApp 21 | globalState.router = router; 22 | }, [router]); 23 | 24 | const initTimeout = useRef | null>(null); 25 | const rendererWrapperEl = useRef(null); 26 | 27 | useEffect(() => { 28 | if (!rendererWrapperEl.current) return; 29 | 30 | if (rendererWrapperEl.current) { 31 | const el = document.querySelectorAll('.page')[0] as HTMLElement; 32 | const pageId = el.dataset.pageid; 33 | const queryId = el.dataset.queryid; 34 | if (pageId) globalState.currentPageId = pageId; 35 | if (queryId) globalState.currentQueryId = queryId; 36 | 37 | globalState.canvasApp = CanvasApp.getInstance(); 38 | globalState.canvasApp.rendererWrapperEl = rendererWrapperEl.current; 39 | } 40 | 41 | return () => { 42 | if (globalState.canvasApp) globalState.canvasApp.destroy(); 43 | }; 44 | }, []); 45 | 46 | const onPageEnter = (el: HTMLElement) => { 47 | if (globalState.canvasApp) globalState.canvasApp.handlePageEnter(el); 48 | }; 49 | 50 | useEffect(() => { 51 | const fontA = new FontFaceObserver('nexa'); 52 | const fontB = new FontFaceObserver('voyage'); 53 | const fontC = new FontFaceObserver('opensans'); 54 | 55 | Promise.all([fontA.load(null, 2000), fontB.load(), fontC.load()]) 56 | .then( 57 | () => { 58 | if (globalState.canvasApp) globalState.canvasApp.init(); 59 | }, 60 | () => { 61 | console.warn('Fonts were loading too long (over 2000ms)'); 62 | }, 63 | ) 64 | .catch((err) => { 65 | console.warn('Some critical font are not available:', err); 66 | }); 67 | 68 | initTimeout.current = setTimeout(() => { 69 | if (globalState.canvasApp && !globalState.isCanvasAppInit) 70 | globalState.canvasApp.init(); 71 | }, 2000); 72 | 73 | return () => { 74 | if (initTimeout.current) clearTimeout(initTimeout.current); 75 | }; 76 | }, []); 77 | 78 | return ( 79 | <> 80 | 81 | 82 | 83 | 84 | 85 | 86 | 93 | 98 | 99 | 100 | 101 | 102 | 103 | > 104 | ); 105 | } 106 | -------------------------------------------------------------------------------- /frontend/src/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import Document, { Html, Head, Main, NextScript } from 'next/document'; 2 | 3 | import { setCssVariables, VARIABLES } from 'utils/setCssVariables'; 4 | 5 | import type { DocumentContext } from 'next/document'; 6 | 7 | export default class MyDocument extends Document { 8 | static async getInitialProps(ctx: DocumentContext) { 9 | const initialProps = await Document.getInitialProps(ctx); 10 | return { ...initialProps }; 11 | } 12 | 13 | render() { 14 | return ( 15 | 16 | 17 | 24 | 31 | 38 | 39 | 40 | 41 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | ); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /frontend/src/pages/_error.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { NextApiResponse } from 'next'; 3 | 4 | import ErrorPage from 'containers/ErrorPage/ErrorPage'; 5 | 6 | interface ErrorProps { 7 | res?: NextApiResponse; 8 | err?: NextApiResponse; 9 | } 10 | 11 | interface Error { 12 | statusCode: number; 13 | } 14 | 15 | export default function ErrorServer({ statusCode }: Error) { 16 | return ; 17 | } 18 | 19 | ErrorServer.getInitialProps = ({ res, err }: ErrorProps) => { 20 | const statusCode = res ? res.statusCode : err ? err.statusCode : 404; 21 | return { statusCode }; 22 | }; 23 | -------------------------------------------------------------------------------- /frontend/src/pages/details/[id]/index.tsx: -------------------------------------------------------------------------------- 1 | import { GetStaticPaths, GetStaticPathsResult } from 'next'; 2 | 3 | import { getCards } from 'utils/prismic/queries/getCards'; 4 | 5 | export { default } from 'containers/DetailsPage/DetailsPage'; 6 | export { getStaticProps } from 'containers/DetailsPage/data'; 7 | 8 | export const getStaticPaths: GetStaticPaths = async ({ locales }) => { 9 | const cards = await getCards(); 10 | 11 | let paths: GetStaticPathsResult['paths'] = []; 12 | 13 | (locales as string[]).forEach((locale) => { 14 | cards.map((card) => { 15 | paths = paths.concat({ 16 | params: { 17 | id: card.uid, 18 | }, 19 | locale, 20 | }); 21 | }); 22 | }); 23 | 24 | return { 25 | paths, 26 | fallback: false, 27 | }; 28 | }; 29 | -------------------------------------------------------------------------------- /frontend/src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | export { default } from 'containers/IndexPage/IndexPage'; 2 | export { getStaticProps } from 'containers/IndexPage/data'; 3 | -------------------------------------------------------------------------------- /frontend/src/seo/GoogleAnalytics/GoogleAnalytics.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const GoogleAnalytics = () => { 4 | const googleAnalyticsTag = process.env.NEXT_PUBLIC_GA_KEY; 5 | return ( 6 | <> 7 | 11 | 22 | > 23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /frontend/src/seo/Head/Head.tsx: -------------------------------------------------------------------------------- 1 | import NextHead from 'next/head'; 2 | import React from 'react'; 3 | import { useRouter } from 'next/router'; 4 | import { getFrontHost } from 'utils/functions/getFrontHost'; 5 | import { stripHtml } from 'utils/functions/stripHtml'; 6 | 7 | import { GoogleAnalytics } from '../GoogleAnalytics/GoogleAnalytics'; 8 | 9 | export interface HeadProps { 10 | title: string; 11 | description: string; 12 | ogType: string; 13 | ogImageSrc: string; 14 | } 15 | 16 | export const Head = (props: HeadProps) => { 17 | const { title, description, ogType, ogImageSrc } = props; 18 | 19 | const router = useRouter(); 20 | const frontHost = getFrontHost(); 21 | 22 | return ( 23 | 24 | {stripHtml(title)} 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | {ogImageSrc && } 33 | 34 | 35 | 36 | 37 | 38 | 42 | 43 | 44 | 45 | ); 46 | }; 47 | -------------------------------------------------------------------------------- /frontend/src/styles/base/fonts.scss: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'voyage'; 3 | font-style: normal; 4 | font-weight: normal; 5 | font-display: swap; 6 | src: url('/fonts/bonVoyage.woff2') format('woff2'); 7 | } 8 | 9 | @font-face { 10 | font-family: 'nexa'; 11 | font-weight: 800; 12 | font-style: normal; 13 | font-display: swap; 14 | src: url('/fonts/nexaBlack.woff2') format('woff2'); 15 | } 16 | 17 | @font-face { 18 | font-family: 'opensans'; 19 | font-style: normal; 20 | font-weight: normal; 21 | font-display: swap; 22 | src: url('/fonts/openSans400.woff2') format('woff2'); 23 | } 24 | -------------------------------------------------------------------------------- /frontend/src/styles/base/global.scss: -------------------------------------------------------------------------------- 1 | *, 2 | *:before, 3 | *:after { 4 | box-sizing: inherit; 5 | outline: none; 6 | margin: 0; 7 | padding: 0; 8 | -webkit-touch-callout: none; 9 | -webkit-tap-highlight-color: rgba(0, 0, 0, 0); 10 | -webkit-tap-highlight-color: transparent; 11 | } 12 | 13 | html { 14 | box-sizing: border-box; 15 | -moz-osx-font-smoothing: grayscale; 16 | -webkit-font-smoothing: antialiased; 17 | 18 | font-family: 'voyage', 'nexa', 'opensans', Roboto, -apple-system, 19 | BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Cantarell, Fira Sans, 20 | Droid Sans, Helvetica Neue, sans-serif; 21 | } 22 | 23 | html { 24 | font-size: calc(100vw / 375 * 10); 25 | 26 | @include media('>phone') { 27 | font-size: calc(100vw / 1920 * 10); 28 | } 29 | 30 | @include media('>=desktop') { 31 | font-size: 62.5%; 32 | } 33 | } 34 | 35 | a { 36 | color: inherit; 37 | outline: none; 38 | pointer-events: auto; 39 | text-decoration: none; 40 | display: inline-block; 41 | } 42 | 43 | a[href^='tel'] { 44 | color: inherit; 45 | text-decoration: none; 46 | } 47 | 48 | button { 49 | background: none; 50 | border: none; 51 | border-radius: none; 52 | color: inherit; 53 | font: inherit; 54 | outline: none; 55 | pointer-events: auto; 56 | } 57 | 58 | input, 59 | textarea { 60 | appearance: none; 61 | background: none; 62 | border: none; 63 | border-radius: 0; 64 | outline: none; 65 | pointer-events: auto; 66 | } 67 | 68 | img, 69 | svg { 70 | vertical-align: center; 71 | } 72 | 73 | a[href], 74 | area[href], 75 | input:not([disabled]), 76 | select:not([disabled]), 77 | textarea:not([disabled]), 78 | button:not([disabled]), 79 | iframe, 80 | [tabindex], 81 | [contentEditable='true'] { 82 | &:not([tabindex='-1']) { 83 | &:focus { 84 | outline: 2px dashed var(--brand-color-main); 85 | } 86 | @supports selector(a:focus-visible) { 87 | &:focus { 88 | outline: none; 89 | } 90 | &:focus-visible { 91 | outline: 2px dashed var(--brand-color-main); 92 | } 93 | } 94 | } 95 | } 96 | 97 | //Creative 98 | 99 | body { 100 | position: fixed; 101 | top: 0; 102 | left: 0; 103 | width: 100%; 104 | height: 100%; 105 | } 106 | 107 | html { 108 | position: fixed; 109 | top: 0; 110 | left: 0; 111 | width: 100%; 112 | height: 100%; 113 | overflow: hidden; 114 | overscroll-behavior: none; 115 | } 116 | 117 | img { 118 | user-select: none; 119 | pointer-events: none; 120 | } 121 | 122 | *, 123 | *:before, 124 | *:after { 125 | user-select: none; 126 | } 127 | -------------------------------------------------------------------------------- /frontend/src/styles/base/reset.scss: -------------------------------------------------------------------------------- 1 | /* http://meyerweb.com/eric/tools/css/reset/ 2 | v2.0 | 20110126 3 | License: none (public domain) 4 | */ 5 | 6 | html, 7 | body, 8 | div, 9 | span, 10 | applet, 11 | object, 12 | iframe, 13 | h1, 14 | h2, 15 | h3, 16 | h4, 17 | h5, 18 | h6, 19 | p, 20 | blockquote, 21 | pre, 22 | a, 23 | abbr, 24 | acronym, 25 | address, 26 | big, 27 | cite, 28 | code, 29 | del, 30 | dfn, 31 | em, 32 | img, 33 | ins, 34 | kbd, 35 | q, 36 | s, 37 | samp, 38 | small, 39 | strike, 40 | strong, 41 | sub, 42 | sup, 43 | tt, 44 | var, 45 | b, 46 | u, 47 | i, 48 | center, 49 | dl, 50 | dt, 51 | dd, 52 | ol, 53 | ul, 54 | li, 55 | fieldset, 56 | form, 57 | label, 58 | legend, 59 | table, 60 | caption, 61 | tbody, 62 | tfoot, 63 | thead, 64 | tr, 65 | th, 66 | td, 67 | article, 68 | aside, 69 | canvas, 70 | details, 71 | embed, 72 | figure, 73 | figcaption, 74 | footer, 75 | header, 76 | hgroup, 77 | menu, 78 | nav, 79 | output, 80 | ruby, 81 | section, 82 | summary, 83 | time, 84 | mark, 85 | audio, 86 | video { 87 | margin: 0; 88 | padding: 0; 89 | border: 0; 90 | font-size: 100%; 91 | font: inherit; 92 | vertical-align: baseline; 93 | } 94 | 95 | article, 96 | aside, 97 | details, 98 | figcaption, 99 | figure, 100 | footer, 101 | header, 102 | hgroup, 103 | menu, 104 | nav, 105 | section { 106 | display: block; 107 | } 108 | body { 109 | line-height: 1; 110 | } 111 | ol, 112 | ul { 113 | list-style: none; 114 | } 115 | blockquote, 116 | q { 117 | quotes: none; 118 | } 119 | blockquote:before, 120 | blockquote:after, 121 | q:before, 122 | q:after { 123 | content: ""; 124 | content: none; 125 | } 126 | table { 127 | border-collapse: collapse; 128 | border-spacing: 0; 129 | } 130 | -------------------------------------------------------------------------------- /frontend/src/styles/components/animations.scss: -------------------------------------------------------------------------------- 1 | [data-animation='paragraph'] { 2 | //The opacity is 0 until the text sliced to spans 3 | opacity: 0; 4 | 5 | .line { 6 | display: inline-block; 7 | overflow: hidden; 8 | vertical-align: top; 9 | } 10 | 11 | .word { 12 | transform: translateY(100%); 13 | 14 | &--active { 15 | transform: translateY(0%); 16 | } 17 | } 18 | } 19 | 20 | .bottom-hide { 21 | &__outer { 22 | overflow: hidden; 23 | display: block; 24 | } 25 | &__inner { 26 | display: block; 27 | transform: translateY(100%); 28 | &--active { 29 | transform: translateY(0); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /frontend/src/styles/components/canvas.scss: -------------------------------------------------------------------------------- 1 | .canvas { 2 | &__wrapper { 3 | width: 100%; 4 | height: 100%; 5 | position: fixed; 6 | top: 0; 7 | left: 0; 8 | z-index: -1; 9 | user-select: none; 10 | pointer-events: none; 11 | } 12 | } 13 | 14 | .transition { 15 | position: fixed; 16 | top: 0; 17 | left: 0; 18 | width: 100%; 19 | height: 100%; 20 | z-index: 5; 21 | user-select: none; 22 | pointer-events: none; 23 | } 24 | 25 | .circle-cursor { 26 | position: fixed; 27 | top: 0; 28 | left: 0; 29 | width: 100%; 30 | height: 100%; 31 | z-index: 4; 32 | user-select: none; 33 | pointer-events: none; 34 | mix-blend-mode: difference; 35 | } 36 | -------------------------------------------------------------------------------- /frontend/src/styles/components/cardContent.scss: -------------------------------------------------------------------------------- 1 | .card-content { 2 | padding: 3rem 0; 3 | display: flex; 4 | flex-direction: column; 5 | 6 | @include media('>phone') { 7 | padding: 6rem 0; 8 | } 9 | 10 | &__more-label { 11 | &__container { 12 | padding: 1.5rem 0; 13 | } 14 | &__wrapper { 15 | display: flex; 16 | align-items: center; 17 | } 18 | 19 | &__number { 20 | font-size: 14px; 21 | padding-right: 7px; 22 | 23 | @include media('>phone') { 24 | font-size: 20px; 25 | padding-right: 20px; 26 | } 27 | 28 | &--white { 29 | color: var(--white); 30 | } 31 | } 32 | 33 | &__line { 34 | width: 20rem; 35 | height: 1px; 36 | background-color: var(--black); 37 | transform: scaleX(0.5); 38 | transform-origin: left; 39 | 40 | &--white { 41 | background-color: var(--white); 42 | } 43 | 44 | &--active { 45 | transform: scaleX(1); 46 | } 47 | } 48 | 49 | &__more { 50 | font-size: 14px; 51 | padding-left: 7px; 52 | transform: translateX(-10rem); //Its half of the line width 53 | 54 | @include media('>phone') { 55 | font-size: 20px; 56 | padding-left: 20px; 57 | } 58 | 59 | &--white { 60 | color: var(--white); 61 | } 62 | 63 | &--active { 64 | transform: translateX(0); 65 | } 66 | } 67 | } 68 | 69 | &__text-wrapper { 70 | position: relative; 71 | 72 | &__text { 73 | position: relative; 74 | font-size: 7rem; 75 | color: var(--black); 76 | 77 | @include media('>phone') { 78 | font-size: 15rem; 79 | } 80 | 81 | &--white { 82 | color: var(--white); 83 | } 84 | 85 | b { 86 | font-family: 'nexa'; 87 | } 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /frontend/src/styles/components/cardPreview.scss: -------------------------------------------------------------------------------- 1 | .card-preview { 2 | position: relative; 3 | 4 | &__container { 5 | max-width: 80%; 6 | margin: 0 auto; 7 | display: flex; 8 | justify-content: flex-start; 9 | 10 | @include media('>phone') { 11 | max-width: 55%; 12 | } 13 | 14 | &--secondary { 15 | justify-content: flex-end; 16 | } 17 | } 18 | 19 | &__img { 20 | position: absolute; 21 | top: 0; 22 | left: 0; 23 | width: 100%; 24 | height: 100%; 25 | object-fit: cover; 26 | 27 | &__wrapper { 28 | opacity: 0; 29 | position: absolute; 30 | top: 0; 31 | left: 0; 32 | width: 100%; 33 | height: 100%; 34 | z-index: -2; 35 | } 36 | } 37 | 38 | &__placeholder { 39 | overflow: hidden; 40 | position: relative; 41 | cursor: pointer; 42 | } 43 | 44 | &__curtains-wrapper { 45 | width: 100%; 46 | position: absolute; 47 | top: 50%; 48 | left: 50%; 49 | transform: translate(-50%, -50%); 50 | display: flex; 51 | flex-direction: column; 52 | 53 | &__top { 54 | overflow: hidden; 55 | 56 | &__wrapper { 57 | &__wrapper { 58 | transform: translateY(50%); 59 | } 60 | } 61 | } 62 | 63 | &__bottom { 64 | overflow: hidden; 65 | 66 | &__wrapper { 67 | &__wrapper { 68 | transform: translateY(-50%); 69 | } 70 | } 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /frontend/src/styles/components/pageWrapper.scss: -------------------------------------------------------------------------------- 1 | .page-transition { 2 | &-enter { 3 | opacity: 0; 4 | } 5 | 6 | &-enter-active { 7 | opacity: 1; 8 | transition: opacity 0ms ease-in; 9 | transition-delay: var(--transition-duration); 10 | } 11 | 12 | &-exit { 13 | opacity: 1; 14 | } 15 | 16 | &-exit-active { 17 | opacity: 0; 18 | transition: opacity 0ms ease-in; 19 | transition-delay: var(--transition-duration); 20 | } 21 | } 22 | 23 | .page-wrapper { 24 | opacity: 0; 25 | 26 | &--active { 27 | opacity: 1; 28 | } 29 | } 30 | 31 | .page { 32 | position: fixed; 33 | width: 100%; 34 | height: 100%; 35 | top: 0; 36 | left: 0; 37 | 38 | &__background { 39 | position: fixed; 40 | top: 0; 41 | left: 0; 42 | width: 100%; 43 | height: 100%; 44 | background-color: var(--grey); 45 | z-index: -5; 46 | } 47 | 48 | &__overlay { 49 | position: fixed; 50 | width: 100%; 51 | height: 100%; 52 | top: 0; 53 | left: 0; 54 | z-index: 10; 55 | 56 | &--disabled { 57 | pointer-events: none; 58 | user-select: none; 59 | display: none; 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /frontend/src/styles/components/richText.scss: -------------------------------------------------------------------------------- 1 | .rich-text { 2 | position: relative; 3 | display: inline-block; 4 | 5 | & > * { 6 | margin: revert; 7 | padding: revert; 8 | text-decoration: revert; 9 | border: revert; 10 | } 11 | 12 | & > * { 13 | &:first-child { 14 | margin-top: 0; 15 | padding-top: 0; 16 | } 17 | &:last-child { 18 | margin-bottom: 0; 19 | padding-bottom: 0; 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /frontend/src/styles/index.scss: -------------------------------------------------------------------------------- 1 | @import './utils/responsive.scss'; 2 | @import './utils/variables.scss'; 3 | 4 | @import './base/fonts.scss'; 5 | @import './base/reset.scss'; 6 | @import './base/global.scss'; 7 | 8 | @import './pages/details.scss'; 9 | @import './pages/index.scss'; 10 | @import './pages/error.scss'; 11 | 12 | @import './components/canvas.scss'; 13 | @import './components/pageWrapper.scss'; 14 | @import './components/animations.scss'; 15 | @import './components/cardPreview.scss'; 16 | @import './components/cardContent.scss'; 17 | -------------------------------------------------------------------------------- /frontend/src/styles/pages/details.scss: -------------------------------------------------------------------------------- 1 | .details { 2 | &__back-btn { 3 | position: absolute; 4 | z-index: 1; 5 | top: 20px; 6 | left: 40px; 7 | 8 | @include media('>phone') { 9 | top: 20px; 10 | left: 40px; 11 | } 12 | 13 | &__label { 14 | cursor: pointer; 15 | font-size: 15px; 16 | font-family: 'nexa'; 17 | color: var(--black); 18 | 19 | @include media('>phone') { 20 | font-size: 22px; 21 | } 22 | } 23 | } 24 | &__title { 25 | position: relative; 26 | font-size: 7rem; 27 | color: var(--white); 28 | text-align: center; 29 | 30 | @include media('>phone') { 31 | font-size: 15rem; 32 | } 33 | 34 | b { 35 | font-family: 'nexa'; 36 | } 37 | 38 | &__wrapper { 39 | display: block; 40 | transform: translateY(50%); 41 | } 42 | } 43 | 44 | &__wrapper { 45 | width: 100%; 46 | } 47 | 48 | &__container { 49 | max-width: 80%; 50 | margin: 0 auto; 51 | 52 | @include media('>phone') { 53 | max-width: 100rem; 54 | } 55 | } 56 | 57 | &__p { 58 | font-family: 'opensans'; 59 | font-size: 1.8rem; 60 | line-height: 2; 61 | color: black; 62 | padding: 4rem 0; 63 | 64 | @include media('>phone') { 65 | line-height: 2.5; 66 | font-size: 2rem; 67 | padding: 12rem 0; 68 | } 69 | } 70 | 71 | &__img { 72 | object-fit: cover; 73 | position: absolute; 74 | top: 0; 75 | left: 0; 76 | width: 100%; 77 | height: 100%; 78 | 79 | &__wrapper { 80 | opacity: 0; 81 | margin: 0 auto; 82 | width: 80%; 83 | position: relative; 84 | 85 | @include media('>phone') { 86 | width: 70%; 87 | } 88 | 89 | &:before { 90 | content: ''; 91 | display: block; 92 | width: 100%; 93 | padding-bottom: 56.25%; 94 | 95 | @include media('>phone') { 96 | padding-bottom: 56.25%; 97 | } 98 | } 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /frontend/src/styles/pages/error.scss: -------------------------------------------------------------------------------- 1 | .error { 2 | &__code { 3 | font-size: 15px; 4 | color: var(--black); 5 | letter-spacing: 1px; 6 | 7 | &__wrapper { 8 | position: fixed; 9 | top: 50%; 10 | left: 50%; 11 | transform: translate(-50%, -50%); 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /frontend/src/styles/pages/index.scss: -------------------------------------------------------------------------------- 1 | .index { 2 | &__wrapper { 3 | } 4 | } 5 | -------------------------------------------------------------------------------- /frontend/src/styles/utils/responsive.scss: -------------------------------------------------------------------------------- 1 | $breakpoints: ( 2 | "phone": 767px, 3 | "tablet": 1024px, 4 | "desktop": 1920px, 5 | ) !default; 6 | 7 | @import "include-media"; 8 | -------------------------------------------------------------------------------- /frontend/src/styles/utils/variables.scss: -------------------------------------------------------------------------------- 1 | :root { 2 | --grey: rgb(238, 238, 238); 3 | --white: rgb(255, 255, 255); 4 | --black: rgb(0, 0, 0); 5 | } 6 | -------------------------------------------------------------------------------- /frontend/src/types.ts: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | 3 | export interface TextureItem { 4 | texture: THREE.Texture; 5 | naturalWidth: number; 6 | naturalHeight: number; 7 | } 8 | 9 | export type TextureItems = Record; 10 | 11 | export interface UpdateInfo { 12 | slowDownFactor: number; 13 | delta: number; 14 | time: number; 15 | } 16 | 17 | export interface Bounds { 18 | width: number; 19 | height: number; 20 | } 21 | 22 | interface Coords { 23 | x: number; 24 | y: number; 25 | } 26 | 27 | export interface Mouse { 28 | current: Coords; 29 | target: Coords; 30 | } 31 | 32 | export interface CardItemProps { 33 | itemKey: number; 34 | itemKeyReverse: number; 35 | } 36 | 37 | export interface AnimateProps { 38 | duration?: number; 39 | delay?: number; 40 | destination: number; 41 | easing?: (amount: number) => number; 42 | } 43 | 44 | export type DirectionX = 'left' | 'right'; 45 | export type DirectionY = 'up' | 'down'; 46 | 47 | export interface ScrollValues { 48 | direction: DirectionY; 49 | scroll: { 50 | current: number; 51 | target: number; 52 | last: number; 53 | }; 54 | strength: { 55 | current: number; 56 | target: number; 57 | }; 58 | } 59 | 60 | export interface DomRectSSR { 61 | bottom: number; 62 | height: number; 63 | left: number; 64 | right: number; 65 | top: number; 66 | width: number; 67 | x: number; 68 | y: number; 69 | } 70 | 71 | export interface AnimateScale { 72 | xScale: number; 73 | yScale: number; 74 | duration?: number; 75 | parentFn?: () => void; 76 | } 77 | 78 | export interface ExitFn { 79 | targetId: string; 80 | parentFn: () => void; 81 | } 82 | 83 | export interface WrapEl { 84 | el: HTMLElement | null; 85 | wrapperClass: string; 86 | } 87 | -------------------------------------------------------------------------------- /frontend/src/utils/functions/getFrontHost.ts: -------------------------------------------------------------------------------- 1 | import { sliceSlash } from 'utils/functions/sliceSlash'; 2 | 3 | import { isDev } from './isDev'; 4 | 5 | const prodHost = sliceSlash(process.env.NEXT_PUBLIC_FRONTEND_PROD as string); 6 | const localHost = sliceSlash(process.env.NEXT_PUBLIC_FRONTEND_LOCAL as string); 7 | 8 | export const getFrontHost = () => { 9 | return isDev() ? localHost : prodHost; 10 | }; 11 | -------------------------------------------------------------------------------- /frontend/src/utils/functions/isDev.ts: -------------------------------------------------------------------------------- 1 | export const isDev = () => { 2 | return process.env.NODE_ENV !== 'production'; 3 | }; 4 | -------------------------------------------------------------------------------- /frontend/src/utils/functions/isTouchDevice.ts: -------------------------------------------------------------------------------- 1 | export const isTouchDevice = () => { 2 | return ( 3 | 'ontouchstart' in window || 4 | navigator.maxTouchPoints > 0 || 5 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 6 | // @ts-ignore 7 | navigator.msMaxTouchPoints > 0 8 | ); 9 | }; 10 | -------------------------------------------------------------------------------- /frontend/src/utils/functions/sliceSlash.tsx: -------------------------------------------------------------------------------- 1 | export const sliceSlash = (string: string) => { 2 | return string && string.endsWith('/') ? string.slice(0, -1) : string; 3 | }; 4 | -------------------------------------------------------------------------------- /frontend/src/utils/functions/stripHtml.ts: -------------------------------------------------------------------------------- 1 | export const stripHtml = (text: string) => { 2 | return text.replace(/<[^>]*>/g, ''); 3 | }; 4 | -------------------------------------------------------------------------------- /frontend/src/utils/globalState.ts: -------------------------------------------------------------------------------- 1 | import { CanvasApp } from 'classes/CanvasApp'; 2 | import { NextRouter } from 'next/router'; 3 | import { TextureItems } from 'types'; 4 | 5 | interface GlobalState { 6 | canvasApp: CanvasApp | null; 7 | isCanvasAppInit: boolean; 8 | currentPageId: string | null; 9 | currentQueryId: string | null; 10 | textureItems: TextureItems; 11 | isAppTransitioning: boolean; 12 | router: NextRouter | null; 13 | globalOpacity: number; 14 | } 15 | 16 | export const globalState: GlobalState = { 17 | canvasApp: null, 18 | isCanvasAppInit: false, 19 | currentPageId: null, 20 | currentQueryId: null, 21 | textureItems: {}, 22 | isAppTransitioning: false, 23 | router: null, 24 | globalOpacity: 0, 25 | }; 26 | -------------------------------------------------------------------------------- /frontend/src/utils/prismic/client.ts: -------------------------------------------------------------------------------- 1 | import Prismic from '@prismicio/client'; 2 | 3 | export const API_URL = process.env.NEXT_PUBLIC_PRISMIC_ENDPOINT as string; 4 | export const API_TOKEN = process.env.NEXT_PUBLIC_PRISMIC_ACCESS_TOKEN; 5 | 6 | export const client = Prismic.client(API_URL, { accessToken: API_TOKEN }); 7 | -------------------------------------------------------------------------------- /frontend/src/utils/prismic/isrTimeout.ts: -------------------------------------------------------------------------------- 1 | export const ISR_TIMEOUT = process.env.ENV === 'local' ? 3 : 50; 2 | -------------------------------------------------------------------------------- /frontend/src/utils/prismic/queries/getCards.ts: -------------------------------------------------------------------------------- 1 | import Prismic from '@prismicio/client'; 2 | 3 | import { client } from 'utils/prismic/client'; 4 | 5 | export interface Card { 6 | uid: string; 7 | imageSrc: string; 8 | name: string; 9 | description: string; 10 | } 11 | 12 | const queryCards = async () => 13 | client.query(Prismic.Predicates.at('document.type', 'card')); 14 | 15 | export const getCards = async (): Promise => { 16 | const queriedCards = await queryCards(); 17 | 18 | const cards: Card[] = queriedCards.results.map((el) => { 19 | return { 20 | uid: el.uid as string, 21 | description: el.data.description as string, 22 | imageSrc: el.data.image.url as string, 23 | name: el.data.name[0].text as string, 24 | }; 25 | }); 26 | 27 | return cards; 28 | }; 29 | -------------------------------------------------------------------------------- /frontend/src/utils/prismic/queries/getLayout.ts: -------------------------------------------------------------------------------- 1 | import Prismic from '@prismicio/client'; 2 | 3 | import { client } from 'utils/prismic/client'; 4 | 5 | export interface Layout { 6 | readmore: string; 7 | } 8 | 9 | const queryLayout = async () => 10 | client.query(Prismic.Predicates.at('document.type', 'layout')); 11 | 12 | export const getLayout = async (): Promise => { 13 | const queriedLayout = await queryLayout(); 14 | 15 | const layout: Layout = queriedLayout.results[0].data; 16 | 17 | return layout; 18 | }; 19 | -------------------------------------------------------------------------------- /frontend/src/utils/prismic/queries/getSeoHead.ts: -------------------------------------------------------------------------------- 1 | import Prismic from '@prismicio/client'; 2 | 3 | import { client } from 'utils/prismic/client'; 4 | import { HeadProps } from 'seo/Head/Head'; 5 | 6 | const queryPage = async (name: string) => 7 | client.query(Prismic.Predicates.at('document.type', name)); 8 | 9 | export const getSeoHead = async (name: string): Promise => { 10 | const page = await queryPage(name); 11 | 12 | const head: HeadProps = { 13 | description: page.results[0].data.body[0].primary.description, 14 | ogImageSrc: page.results[0].data.body[0].primary.image.url, 15 | ogType: page.results[0].data.body[0].primary.type, 16 | title: page.results[0].data.body[0].primary.title, 17 | }; 18 | 19 | return head; 20 | }; 21 | -------------------------------------------------------------------------------- /frontend/src/utils/setCssVariables.ts: -------------------------------------------------------------------------------- 1 | import { pageTransitionDuration } from 'variables'; 2 | 3 | export const VARIABLES = [ 4 | { 5 | name: '--transition-duration', 6 | value: pageTransitionDuration + 'ms', 7 | }, 8 | ]; 9 | 10 | interface SetCssVariables { 11 | variables: typeof VARIABLES; 12 | } 13 | 14 | export const setCssVariables = ({ variables }: SetCssVariables) => { 15 | const root = document.documentElement; 16 | 17 | for (let i = 0; i < variables.length; i++) { 18 | const el = variables[i]; 19 | root.style.setProperty(el.name, el.value); 20 | } 21 | }; 22 | -------------------------------------------------------------------------------- /frontend/src/utils/sharedStyles.module.scss: -------------------------------------------------------------------------------- 1 | //breakpoints : 767px, 1024px, 1920px 2 | 3 | .blobBtn { 4 | background-color: var(--black); 5 | color: var(--white); 6 | border-radius: 50px; 7 | cursor: pointer; 8 | padding: 14px 25px; 9 | 10 | display: inline-block; 11 | position: relative; 12 | z-index: 1; 13 | box-shadow: 0 0 13px var(--black); 14 | font-size: 15px; 15 | line-height: 1.6; 16 | 17 | &Small { 18 | font-size: 11px; 19 | padding: 6px 18px; 20 | } 21 | 22 | &NoShadow { 23 | box-shadow: none; 24 | } 25 | 26 | &::before { 27 | content: ''; 28 | pointer-events: none; 29 | position: absolute; 30 | top: 50%; 31 | left: 50%; 32 | width: 100%; 33 | height: 100%; 34 | transform: translate(-50%, -50%); 35 | background-color: inherit; 36 | z-index: -1; 37 | border-radius: inherit; 38 | transition-duration: 0.8s; 39 | transition-property: opacity, width, height; 40 | } 41 | 42 | &:hover { 43 | &::before { 44 | opacity: 0; 45 | width: calc(100% + 25px); 46 | height: calc(100% + 25px); 47 | } 48 | } 49 | } 50 | 51 | .text { 52 | font-family: 'opensans'; 53 | display: inline-block; 54 | font-size: 14px; 55 | color: var(--white); 56 | position: relative; 57 | 58 | &Bold { 59 | font-weight: 800; 60 | } 61 | 62 | &Box { 63 | position: relative; 64 | cursor: pointer; 65 | border: 2px solid var(--white); 66 | padding: 12px 15px; 67 | font-size: 13px; 68 | border-radius: 8px; 69 | 70 | &:before { 71 | content: ''; 72 | position: absolute; 73 | top: 70%; 74 | left: 15px; 75 | width: calc(100% - 30px); 76 | height: 1px; 77 | background-color: currentColor; 78 | transform-origin: left; 79 | transform: scaleX(0); 80 | transition: transform 0.6s cubic-bezier(0.64, 0.02, 0.16, 0.97); 81 | 82 | @media only screen and (min-width: 767px) { 83 | left: 20px; 84 | width: calc(100% - 40px); 85 | } 86 | } 87 | 88 | &:hover { 89 | &:before { 90 | transform: scaleX(1); 91 | } 92 | } 93 | 94 | @media only screen and (min-width: 767px) { 95 | padding: 12px 20px; 96 | font-size: 14px; 97 | } 98 | } 99 | 100 | &Black { 101 | color: var(--black); 102 | border-color: var(--black); 103 | } 104 | 105 | &Underline { 106 | &:before { 107 | content: ''; 108 | position: absolute; 109 | top: 100%; 110 | width: 100%; 111 | height: 1px; 112 | background-color: currentColor; 113 | transform-origin: left; 114 | transform: scaleX(0); 115 | transition: transform 0.6s cubic-bezier(0.64, 0.02, 0.16, 0.97); 116 | } 117 | 118 | &:hover { 119 | &:before { 120 | transform: scaleX(1); 121 | } 122 | } 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /frontend/src/variables.ts: -------------------------------------------------------------------------------- 1 | export const pageTransitionDuration = 1200; 2 | 3 | export const indexCurtainDuration = pageTransitionDuration * 0.68; 4 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./node_modules/gts/tsconfig-google.json", 3 | "compilerOptions": { 4 | "target": "es2020", 5 | "lib": ["dom", "dom.iterable", "esnext"], 6 | "allowJs": true, 7 | "skipLibCheck": true, 8 | "strict": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "noEmit": true, 11 | "esModuleInterop": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "jsx": "preserve", 17 | "baseUrl": "src" 18 | }, 19 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 20 | "exclude": ["node_modules"] 21 | } 22 | --------------------------------------------------------------------------------
60 | {card.description} 61 |
13 | Something went wrong {`| ${statusCode || 'undefined code'}`} 14 |