├── .gitignore
├── .prettierrc
├── .storybook
├── main.js
└── preview.js
├── README.md
├── index.html
├── package.json
├── public
├── favicon.ico
└── images
│ └── games
│ ├── 0.jpeg
│ ├── 1.jpeg
│ ├── 10.jpeg
│ ├── 11.jpeg
│ ├── 12.jpeg
│ ├── 13.jpeg
│ ├── 14.jpeg
│ ├── 15.jpeg
│ ├── 16.jpeg
│ ├── 17.jpeg
│ ├── 18.jpeg
│ ├── 19.jpeg
│ ├── 2.jpeg
│ ├── 20.jpeg
│ ├── 21.jpeg
│ ├── 22.jpeg
│ ├── 23.jpeg
│ ├── 24.jpeg
│ ├── 25.jpeg
│ ├── 26.jpeg
│ ├── 27.jpeg
│ ├── 28.jpeg
│ ├── 29.jpeg
│ ├── 3.jpeg
│ ├── 30.jpeg
│ ├── 31.jpeg
│ ├── 32.jpeg
│ ├── 33.jpeg
│ ├── 34.jpeg
│ ├── 35.jpeg
│ ├── 36.jpeg
│ ├── 37.jpeg
│ ├── 38.jpeg
│ ├── 39.jpeg
│ ├── 4.jpeg
│ ├── 40.jpeg
│ ├── 41.jpeg
│ ├── 42.jpeg
│ ├── 43.jpeg
│ ├── 44.jpeg
│ ├── 45.jpeg
│ ├── 46.jpeg
│ ├── 47.jpeg
│ ├── 48.jpeg
│ ├── 49.jpeg
│ ├── 5.jpeg
│ ├── 50.jpeg
│ ├── 51.jpeg
│ ├── 52.jpeg
│ ├── 53.jpeg
│ ├── 54.jpeg
│ ├── 6.jpeg
│ ├── 7.jpeg
│ ├── 8.jpeg
│ └── 9.jpeg
├── src
├── App.jsx
├── Sphere.jsx
├── SphereItem.jsx
├── components
│ ├── Controls
│ │ ├── Controls.jsx
│ │ ├── Joystick
│ │ │ └── Joystick.jsx
│ │ ├── Slider
│ │ │ └── Slider.jsx
│ │ └── Toggle
│ │ │ └── Toggle.jsx
│ └── GameCard
│ │ ├── GameContent.jsx
│ │ └── GameCover.jsx
├── constants.js
├── hooks
│ ├── useControlsUpdate.jsx
│ ├── useInitialPosition.jsx
│ ├── useKeyboardControls.jsx
│ ├── useMediaQuery.jsx
│ └── useSelectedPosition.jsx
├── index.jsx
├── styles.css
└── utils.js
├── tailwind.config.js
├── vite.config.js
├── yarn-error.log
└── yarn.lock
/.gitignore:
--------------------------------------------------------------------------------
1 | .vscode
2 | node_modules
3 | .DS_Store
4 | dist
5 | dist-ssr
6 | *.local
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 140,
3 | "useTabs": false,
4 | "semi": false,
5 | "singleQuote": true,
6 | "trailingComma": "es5",
7 | "bracketSpacing": true,
8 | "jsxBracketSameLine": false
9 | }
10 |
--------------------------------------------------------------------------------
/.storybook/main.js:
--------------------------------------------------------------------------------
1 | // .storybook/main.js
2 | const path = require('path')
3 | const WindiCSS = require('vite-plugin-windicss').default
4 |
5 | module.exports = {
6 | stories: ['../src/**/*.stories.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'],
7 | addons: ['@storybook/addon-links', '@storybook/addon-essentials'],
8 | core: {
9 | builder: 'storybook-builder-vite',
10 | },
11 | async viteFinal(config, { configType }) {
12 | config.plugins = config.plugins ?? []
13 | config.plugins.push(
14 | WindiCSS({
15 | config: path.join(__dirname, '..', 'tailwind.config.js'),
16 | })
17 | )
18 | return config
19 | },
20 | }
21 |
--------------------------------------------------------------------------------
/.storybook/preview.js:
--------------------------------------------------------------------------------
1 | import 'virtual:windi.css'
2 |
3 | export const parameters = {
4 | actions: { argTypesRegex: '^on[A-Z].*' },
5 | controls: {
6 | matchers: {
7 | color: /(background|color)$/i,
8 | date: /Date$/,
9 | },
10 | },
11 | }
12 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
2 |
3 |
4 | ## Setup and run
5 |
6 | ```console
7 | yarn
8 | yarn dev
9 | ```
10 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Vite App
8 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "r3f-vite",
3 | "version": "0.0.0",
4 | "scripts": {
5 | "dev": "vite",
6 | "build": "vite build",
7 | "serve": "vite preview",
8 | "storybook": "start-storybook -p 6006",
9 | "build-storybook": "build-storybook"
10 | },
11 | "dependencies": {
12 | "@headlessui/react": "^1.4.2",
13 | "@radix-ui/react-icons": "^1.0.3",
14 | "@radix-ui/react-slider": "^0.1.1",
15 | "@react-three/a11y": "^2.2.3",
16 | "@react-three/drei": "7.22.4",
17 | "@react-three/fiber": "7.0.19",
18 | "@use-gesture/react": "^10.1.6",
19 | "leva": "^0.9.15",
20 | "lorem-ipsum": "^2.0.4",
21 | "react": "17.0.2",
22 | "react-dom": "17.0.2",
23 | "react-spring": "^9.3.1",
24 | "three": "0.134.0",
25 | "three-stdlib": "^2.5.9",
26 | "vite-plugin-windicss": "^1.5.1",
27 | "windicss": "^3.2.1",
28 | "zustand": "^3.6.5"
29 | },
30 | "devDependencies": {
31 | "@babel/core": "^7.16.0",
32 | "@storybook/addon-actions": "^6.4.0-rc.6",
33 | "@storybook/addon-essentials": "^6.4.0-rc.6",
34 | "@storybook/addon-links": "^6.4.0-rc.6",
35 | "@storybook/react": "^6.4.0-rc.6",
36 | "@vitejs/plugin-react": "^1.0.2",
37 | "babel-loader": "^8.2.3",
38 | "r3f-perf": "^4.9.1",
39 | "storybook-builder-vite": "^0.1.9",
40 | "vite": "^2.6.3"
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/favicon.ico
--------------------------------------------------------------------------------
/public/images/games/0.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/0.jpeg
--------------------------------------------------------------------------------
/public/images/games/1.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/1.jpeg
--------------------------------------------------------------------------------
/public/images/games/10.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/10.jpeg
--------------------------------------------------------------------------------
/public/images/games/11.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/11.jpeg
--------------------------------------------------------------------------------
/public/images/games/12.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/12.jpeg
--------------------------------------------------------------------------------
/public/images/games/13.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/13.jpeg
--------------------------------------------------------------------------------
/public/images/games/14.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/14.jpeg
--------------------------------------------------------------------------------
/public/images/games/15.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/15.jpeg
--------------------------------------------------------------------------------
/public/images/games/16.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/16.jpeg
--------------------------------------------------------------------------------
/public/images/games/17.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/17.jpeg
--------------------------------------------------------------------------------
/public/images/games/18.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/18.jpeg
--------------------------------------------------------------------------------
/public/images/games/19.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/19.jpeg
--------------------------------------------------------------------------------
/public/images/games/2.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/2.jpeg
--------------------------------------------------------------------------------
/public/images/games/20.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/20.jpeg
--------------------------------------------------------------------------------
/public/images/games/21.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/21.jpeg
--------------------------------------------------------------------------------
/public/images/games/22.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/22.jpeg
--------------------------------------------------------------------------------
/public/images/games/23.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/23.jpeg
--------------------------------------------------------------------------------
/public/images/games/24.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/24.jpeg
--------------------------------------------------------------------------------
/public/images/games/25.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/25.jpeg
--------------------------------------------------------------------------------
/public/images/games/26.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/26.jpeg
--------------------------------------------------------------------------------
/public/images/games/27.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/27.jpeg
--------------------------------------------------------------------------------
/public/images/games/28.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/28.jpeg
--------------------------------------------------------------------------------
/public/images/games/29.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/29.jpeg
--------------------------------------------------------------------------------
/public/images/games/3.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/3.jpeg
--------------------------------------------------------------------------------
/public/images/games/30.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/30.jpeg
--------------------------------------------------------------------------------
/public/images/games/31.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/31.jpeg
--------------------------------------------------------------------------------
/public/images/games/32.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/32.jpeg
--------------------------------------------------------------------------------
/public/images/games/33.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/33.jpeg
--------------------------------------------------------------------------------
/public/images/games/34.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/34.jpeg
--------------------------------------------------------------------------------
/public/images/games/35.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/35.jpeg
--------------------------------------------------------------------------------
/public/images/games/36.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/36.jpeg
--------------------------------------------------------------------------------
/public/images/games/37.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/37.jpeg
--------------------------------------------------------------------------------
/public/images/games/38.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/38.jpeg
--------------------------------------------------------------------------------
/public/images/games/39.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/39.jpeg
--------------------------------------------------------------------------------
/public/images/games/4.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/4.jpeg
--------------------------------------------------------------------------------
/public/images/games/40.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/40.jpeg
--------------------------------------------------------------------------------
/public/images/games/41.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/41.jpeg
--------------------------------------------------------------------------------
/public/images/games/42.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/42.jpeg
--------------------------------------------------------------------------------
/public/images/games/43.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/43.jpeg
--------------------------------------------------------------------------------
/public/images/games/44.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/44.jpeg
--------------------------------------------------------------------------------
/public/images/games/45.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/45.jpeg
--------------------------------------------------------------------------------
/public/images/games/46.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/46.jpeg
--------------------------------------------------------------------------------
/public/images/games/47.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/47.jpeg
--------------------------------------------------------------------------------
/public/images/games/48.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/48.jpeg
--------------------------------------------------------------------------------
/public/images/games/49.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/49.jpeg
--------------------------------------------------------------------------------
/public/images/games/5.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/5.jpeg
--------------------------------------------------------------------------------
/public/images/games/50.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/50.jpeg
--------------------------------------------------------------------------------
/public/images/games/51.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/51.jpeg
--------------------------------------------------------------------------------
/public/images/games/52.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/52.jpeg
--------------------------------------------------------------------------------
/public/images/games/53.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/53.jpeg
--------------------------------------------------------------------------------
/public/images/games/54.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/54.jpeg
--------------------------------------------------------------------------------
/public/images/games/6.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/6.jpeg
--------------------------------------------------------------------------------
/public/images/games/7.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/7.jpeg
--------------------------------------------------------------------------------
/public/images/games/8.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/8.jpeg
--------------------------------------------------------------------------------
/public/images/games/9.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/flagrede/r3f-sphere-collection/a1e19f6abd72704a6699df7cf1375768496266d0/public/images/games/9.jpeg
--------------------------------------------------------------------------------
/src/App.jsx:
--------------------------------------------------------------------------------
1 | import { Stars } from '@react-three/drei'
2 | import { Canvas } from '@react-three/fiber'
3 | import { useEffect, useMemo, useRef, useState } from 'react'
4 | import * as THREE from 'three'
5 | import 'virtual:windi-devtools'
6 | import 'virtual:windi.css'
7 | import create from 'zustand'
8 | import Controls from './components/Controls/Controls'
9 | import { DEFAULT_CARD } from './constants'
10 | import useControlsUpdate from './hooks/useControlsUpdate'
11 | import useKeyboardControls from './hooks/useKeyboardControls'
12 | import Sphere from './Sphere'
13 | import './styles.css'
14 | import { getData, getNewIndex } from './utils'
15 |
16 | export const useStore = create((set) => ({
17 | sphereControls: { left: false, right: false },
18 | selectedIndex: DEFAULT_CARD,
19 | cardHidden: false,
20 | selectedVector: new THREE.Vector3(),
21 | ratingFilter: [0],
22 | setRight: () => set({ sphereControls: { left: false, right: true } }),
23 | setLeft: () => set({ sphereControls: { left: true, right: false } }),
24 | setControls: (sphereControls) => set({ sphereControls }),
25 | resetControls: () => set({ sphereControls: { left: false, right: false } }),
26 | setCardHidden: (cardHidden) => set({ cardHidden }),
27 | setSelectedIndex: (selectedIndex) => set({ selectedIndex }),
28 | setRatingFilter: (ratingFilter) => set({ ratingFilter }),
29 | }))
30 |
31 | export default function App() {
32 | const canvasContainerRef = useRef()
33 | const ratingFilter = useStore((state) => state.ratingFilter)
34 | const cardVisibleRef = useRef(false)
35 | const [data] = useState(getData())
36 | const dataWithFiltered = useMemo(() => data.map((game) => ({ ...game, filtered: ratingFilter > game.pressRating })), [ratingFilter])
37 |
38 | useControlsUpdate({ data: dataWithFiltered })
39 | const { handleKeyDown, handleKeyUp } = useKeyboardControls()
40 |
41 | useEffect(() => {
42 | canvasContainerRef.current.focus()
43 | }, [])
44 |
45 | return (
46 | <>
47 |
55 |
60 |
61 |
62 | >
63 | )
64 | }
65 |
--------------------------------------------------------------------------------
/src/Sphere.jsx:
--------------------------------------------------------------------------------
1 | import { useFrame } from '@react-three/fiber'
2 | import React, { useEffect, useRef } from 'react'
3 | import * as THREE from 'three'
4 | import { useStore } from './App'
5 | import SphereItem from './SphereItem'
6 |
7 | const originVector = new THREE.Vector3(0, 0, 0)
8 | const Sphere = ({ cardVisibleRef, data }) => {
9 | const sphereRef = useRef()
10 | const selectedIndex = useStore((state) => state.selectedIndex)
11 | const selectedVector = useStore((state) => state.selectedVector)
12 | const setSelectedIndex = useStore((state) => state.setSelectedIndex)
13 | const ratingFilter = useStore((state) => state.ratingFilter)
14 |
15 | useFrame(({ camera }) => {
16 | camera.position.lerp(selectedVector, 0.02)
17 | camera.lookAt(originVector)
18 | if (!cardVisibleRef.current && camera.position.distanceTo(selectedVector) < 1) {
19 | cardVisibleRef.current = true
20 | }
21 | })
22 |
23 | useEffect(() => {
24 | cardVisibleRef.current = false
25 | }, [selectedIndex])
26 |
27 | useEffect(() => {
28 | setSelectedIndex(null)
29 | }, [ratingFilter])
30 |
31 | return (
32 |
33 | {data.map(({ id, playerRating, pressRating, filtered }, index) => (
34 |
43 | ))}
44 |
45 | )
46 | }
47 |
48 | export default Sphere
49 |
--------------------------------------------------------------------------------
/src/SphereItem.jsx:
--------------------------------------------------------------------------------
1 | import { animated as a, useSpring } from '@react-spring/three'
2 | import { A11y } from '@react-three/a11y'
3 | import { Html, Plane } from '@react-three/drei'
4 | import { useFrame } from '@react-three/fiber'
5 | import React, { Suspense, useRef, useState } from 'react'
6 | import * as THREE from 'three'
7 | import { useStore } from './App'
8 | import GameContent from './components/GameCard/GameContent'
9 | import GameCard from './components/GameCard/GameCover'
10 | import useInitialPosition from './hooks/useInitialPosition'
11 | import useSelectedPosition from './hooks/useSelectedPosition'
12 | import useMediaQuery from './hooks/useMediaQuery'
13 |
14 | const cameraVector = new THREE.Vector3()
15 | const tempVector = new THREE.Vector3(0, 0, 0)
16 | const translationVector = new THREE.Vector3(-1, 0, 0)
17 |
18 | const bgMaterialProps = {
19 | thickness: 5,
20 | roughness: 0.8,
21 | clearcoat: 1,
22 | clearcoatRoughness: 0,
23 | transmission: 1,
24 | ior: 1.45,
25 | envMapIntensity: 25,
26 | color: '#ffffff',
27 | attenuationTint: '#ffe79e',
28 | attenuationDistance: 0,
29 | }
30 |
31 | const SphereItem = ({ cardIndex, isSelected, id, playerRating, pressRating, filtered }) => {
32 | const ref = useRef()
33 | const planeRef = useRef()
34 | const selectedVector = useStore((state) => state.selectedVector)
35 | const setSelectedIndex = useStore((state) => state.setSelectedIndex)
36 | const [initialPositionVector] = useState(new THREE.Vector3())
37 | const [zoomPositionVector] = useState(new THREE.Vector3())
38 | const isZoomCompleteRef = useRef(false)
39 | const [{ scale }, scaleApi] = useSpring(() => ({
40 | scale: 0,
41 | }))
42 | const { scaleGroup } = useSpring({
43 | scaleGroup: filtered ? 0 : 1,
44 | })
45 | const isDesktop = useMediaQuery('(min-width: 960px)')
46 |
47 | useInitialPosition({ cameraVector, cardIndex, initialPositionVector, itemRef: ref })
48 | useSelectedPosition({ isSelected, selectedVector, tempVector, zoomPositionVector, translationVector, itemRef: ref })
49 |
50 | useFrame(() => {
51 | if (isSelected && !useStore.getState().cardHidden) {
52 | ref.current.position.lerp(zoomPositionVector, 0.1)
53 | const distance = ref.current.position.distanceTo(zoomPositionVector)
54 | if (!isZoomCompleteRef.current && distance !== 0 && distance < 0.2) {
55 | isZoomCompleteRef.current = true
56 | scaleApi.start({ scale: 1 })
57 | }
58 | } else {
59 | ref.current.position.lerp(initialPositionVector, 0.1)
60 | if (isZoomCompleteRef.current) {
61 | isZoomCompleteRef.current = false
62 | scaleApi.start({ scale: 0 })
63 | }
64 | }
65 | })
66 | return (
67 | {
71 | setSelectedIndex(id)
72 | }}
73 | >
74 |
75 |
76 |
77 |
78 | {isSelected && (
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 | )}
88 |
89 |
90 | )
91 | }
92 |
93 | export default React.memo(SphereItem)
94 |
--------------------------------------------------------------------------------
/src/components/Controls/Controls.jsx:
--------------------------------------------------------------------------------
1 | import Joystick from './Joystick/Joystick'
2 | import Slider from './Slider/Slider'
3 | import Toggle from './Toggle/Toggle'
4 |
5 | const Controls = () => {
6 | return (
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 | )
15 | }
16 |
17 | export default Controls
18 |
--------------------------------------------------------------------------------
/src/components/Controls/Joystick/Joystick.jsx:
--------------------------------------------------------------------------------
1 | import { WidthIcon } from '@radix-ui/react-icons'
2 | import { animated as a, useSpring } from '@react-spring/web'
3 | import { useDrag } from '@use-gesture/react'
4 | import { useRef } from 'react'
5 | import { useStore } from '../../../App'
6 |
7 | const Joystick = () => {
8 | const [{ x }, api] = useSpring(() => ({ x: 0 }))
9 | const containerRef = useRef()
10 | const { left, right } = useStore((state) => state.sphereControls)
11 | const setControls = useStore((state) => state.setControls)
12 | const resetControls = useStore((state) => state.resetControls)
13 |
14 | const bind = useDrag(
15 | ({ down, movement: [mx], direction: [dx] }) => {
16 | if (Math.abs(mx) > 10 && !left && !right) {
17 | setControls({ left: dx < 0, right: dx > 0 })
18 | }
19 | if (!down && (left || right)) {
20 | resetControls()
21 | }
22 | api.start({ x: down ? mx : 0, immediate: down })
23 | },
24 | { bounds: containerRef }
25 | )
26 |
27 | return (
28 |
38 | )
39 | }
40 |
41 | export default Joystick
42 |
--------------------------------------------------------------------------------
/src/components/Controls/Slider/Slider.jsx:
--------------------------------------------------------------------------------
1 | import { StarFilledIcon } from '@radix-ui/react-icons'
2 | import * as Slider from '@radix-ui/react-slider'
3 | import { useStore } from '../../../App'
4 |
5 | const SliderComponent = () => {
6 | const ratingFilter = useStore((state) => state.ratingFilter)
7 | const setRatingFilter = useStore((state) => state.setRatingFilter)
8 | return (
9 |
10 |
18 |
19 |
20 |
21 |
25 |
26 |
27 |
28 |
29 | {'>'}
30 | {ratingFilter[0]}
31 |
32 |
33 |
34 | )
35 | }
36 |
37 | export default SliderComponent
38 |
--------------------------------------------------------------------------------
/src/components/Controls/Toggle/Toggle.jsx:
--------------------------------------------------------------------------------
1 | import { Switch } from '@headlessui/react'
2 | import { EyeClosedIcon, EyeOpenIcon } from '@radix-ui/react-icons'
3 | import { useStore } from '../../../App'
4 |
5 | const Toggle = () => {
6 | const cardHidden = useStore((state) => state.cardHidden)
7 | const setCardHidden = useStore((state) => state.setCardHidden)
8 | return (
9 |
10 |
11 |
17 | Card hidden
18 |
23 |
24 |
25 | {cardHidden ?
:
}
26 |
27 | )
28 | }
29 |
30 | export default Toggle
31 |
--------------------------------------------------------------------------------
/src/components/GameCard/GameContent.jsx:
--------------------------------------------------------------------------------
1 | import { animated as a, config, useChain, useSpring, useSpringRef } from '@react-spring/web'
2 | import { LoremIpsum, loremIpsum } from 'lorem-ipsum'
3 | import React, { useState } from 'react'
4 | import { getRandomRating, getRandomTags } from '../../utils'
5 |
6 | const lorem = new LoremIpsum({
7 | wordsPerSentence: {
8 | max: 16,
9 | min: 4,
10 | },
11 | })
12 |
13 | const GameContent = ({ id, playerRating, pressRating }) => {
14 | const springRate1Ref = useSpringRef()
15 | const springRate2Ref = useSpringRef()
16 | const [introText] = useState(lorem.generateSentences(1))
17 | const [descriptionText] = useState(lorem.generateSentences(4))
18 | const { rate1 } = useSpring({
19 | from: { rate1: 0 },
20 | to: { rate1: pressRating },
21 | config: config.slow,
22 | ref: springRate1Ref,
23 | delay: 2000,
24 | })
25 | const { rate2 } = useSpring({ from: { rate2: 0 }, to: { rate2: playerRating }, config: config.slow, ref: springRate2Ref })
26 | useChain([springRate1Ref, springRate2Ref])
27 | const tags = getRandomTags()
28 |
29 | return (
30 |
31 |
32 |
33 |
Game {id}
34 |
35 | {tags.map((tag, index) => (
36 |
40 | {tag.label}
41 |
42 | ))}
43 |
44 |
{introText}
45 |
{descriptionText}
46 |
47 |
48 |
Press
49 |
50 | {rate1.to((v) => v.toFixed(0))}
51 | /20
52 |
53 |
54 |
55 |
Players
56 |
57 | {rate2.to((v) => v.toFixed(0))}
58 | /20
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 | )
67 | }
68 |
69 | export default React.memo(GameContent)
70 |
--------------------------------------------------------------------------------
/src/components/GameCard/GameCover.jsx:
--------------------------------------------------------------------------------
1 | import { animated as a, useSpring } from '@react-spring/three'
2 | import { Image } from '@react-three/drei'
3 | import { useRef } from 'react'
4 |
5 | const GameCover = ({ id, isSelected }) => {
6 | const imageIndex = id % 54
7 | const imageUrl = `images/games/${imageIndex}.jpeg`
8 | const { scale } = useSpring({ scale: isSelected ? [2, 2, 1] : [1, 1, 1] })
9 | const ref = useRef()
10 | const imageRef = useRef()
11 |
12 | return (
13 |
14 |
15 |
16 | )
17 | }
18 |
19 | export default GameCover
20 |
--------------------------------------------------------------------------------
/src/constants.js:
--------------------------------------------------------------------------------
1 | export const CARDS_NUMBER = 100
2 | export const DEFAULT_CARD = 50
3 |
--------------------------------------------------------------------------------
/src/hooks/useControlsUpdate.jsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react'
2 | import { useStore } from '../App'
3 | import { DEFAULT_CARD } from '../constants'
4 | import { getNewIndex } from '../utils'
5 |
6 | const useControlsUpdate = ({ data }) => {
7 | const selectedIndex = useStore((state) => state.selectedIndex)
8 | const setSelectedIndex = useStore((state) => state.setSelectedIndex)
9 | const { left, right } = useStore((state) => state.sphereControls)
10 |
11 | useEffect(() => {
12 | let controlsTimeout
13 | const updateControl = (index) => {
14 | controlsTimeout = setTimeout(() => {
15 | const newIndex = getNewIndex({ direction: left ? -1 : 1, currentIndex: index, data })
16 | setSelectedIndex(newIndex)
17 | if (right || left) {
18 | updateControl(newIndex)
19 | }
20 | }, 500)
21 | }
22 |
23 | if (left || right) {
24 | const newIndex =
25 | selectedIndex !== null
26 | ? getNewIndex({ direction: left ? -1 : 1, currentIndex: useStore.getState().selectedIndex, data })
27 | : DEFAULT_CARD
28 | setSelectedIndex(newIndex)
29 | updateControl(newIndex)
30 | }
31 |
32 | return () => clearInterval(controlsTimeout)
33 | }, [left, right, data])
34 |
35 | return null
36 | }
37 |
38 | export default useControlsUpdate
39 |
--------------------------------------------------------------------------------
/src/hooks/useInitialPosition.jsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react'
2 | import { getElementPosition } from '../utils'
3 |
4 | const useInitialPosition = ({ itemRef, cardIndex, initialPositionVector, cameraVector }) => {
5 | useEffect(() => {
6 | if (itemRef.current !== undefined) {
7 | const { radius, phi, theta } = getElementPosition({ index: cardIndex })
8 | itemRef.current.position.setFromSphericalCoords(radius, phi, theta)
9 | initialPositionVector.copy(itemRef.current.position)
10 | cameraVector.copy(itemRef.current.position).multiplyScalar(-2)
11 | itemRef.current.lookAt(cameraVector)
12 | }
13 | }, [cardIndex])
14 |
15 | return null
16 | }
17 |
18 | export default useInitialPosition
19 |
--------------------------------------------------------------------------------
/src/hooks/useKeyboardControls.jsx:
--------------------------------------------------------------------------------
1 | import { useStore } from '../App'
2 |
3 | const useKeyboardControls = () => {
4 | const { left, right } = useStore((state) => state.sphereControls)
5 | const setRight = useStore((state) => state.setRight)
6 | const setLeft = useStore((state) => state.setLeft)
7 | const resetControls = useStore((state) => state.resetControls)
8 |
9 | const handleKeyDown = (event) => {
10 | if (event.key === 'ArrowLeft' && !left) {
11 | setLeft()
12 | } else if (event.key === 'ArrowRight' && !right) {
13 | setRight()
14 | }
15 | }
16 |
17 | const handleKeyUp = (event) => {
18 | if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
19 | resetControls()
20 | }
21 | }
22 |
23 | return { handleKeyDown, handleKeyUp }
24 | }
25 |
26 | export default useKeyboardControls
27 |
--------------------------------------------------------------------------------
/src/hooks/useMediaQuery.jsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react'
2 |
3 | const useMediaQuery = (query) => {
4 | const [matches, setMatches] = useState(false)
5 |
6 | useEffect(() => {
7 | const media = window.matchMedia(query)
8 | if (media.matches !== matches) {
9 | setMatches(media.matches)
10 | }
11 | const listener = () => setMatches(media.matches)
12 | window.addEventListener('resize', listener)
13 | return () => window.removeEventListener('resize', listener)
14 | }, [matches, query])
15 |
16 | return matches
17 | }
18 |
19 | export default useMediaQuery
20 |
--------------------------------------------------------------------------------
/src/hooks/useSelectedPosition.jsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react'
2 |
3 | const useSelectedPosition = ({ isSelected, zoomPositionVector, selectedVector, tempVector, translationVector, itemRef }) => {
4 | useEffect(() => {
5 | let vectorUpdateTimeout
6 | if (isSelected) {
7 | zoomPositionVector.copy(itemRef.current.position)
8 | selectedVector.copy(itemRef.current.position).multiplyScalar(-0.5)
9 | vectorUpdateTimeout = setTimeout(() => {
10 | zoomPositionVector.copy(itemRef.current.position).multiplyScalar(0)
11 | tempVector.copy(translationVector)
12 | tempVector.transformDirection(itemRef.current.matrixWorld).normalize().multiplyScalar(1.7)
13 | zoomPositionVector.add(tempVector)
14 | }, 500)
15 | }
16 | return () => clearTimeout(vectorUpdateTimeout)
17 | }, [isSelected])
18 | }
19 |
20 | export default useSelectedPosition
21 |
--------------------------------------------------------------------------------
/src/index.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import ReactDOM from 'react-dom'
3 | import App from './App'
4 |
5 | ReactDOM.render(
6 |
7 |
8 | ,
9 | document.getElementById('root')
10 | )
11 |
--------------------------------------------------------------------------------
/src/styles.css:
--------------------------------------------------------------------------------
1 | * {
2 | box-sizing: border-box;
3 | }
4 |
5 | html,
6 | body,
7 | #root {
8 | width: 100%;
9 | height: 100%;
10 | margin: 0;
11 | padding: 0;
12 | -webkit-tap-highlight-color: rgba(255, 255, 255, 0);
13 | }
14 |
15 | body {
16 | font-family: system-ui, -apple-system, /* Firefox supports this but not yet `system-ui` */ 'Segoe UI', Roboto, Helvetica, Arial,
17 | sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji';
18 | }
19 |
--------------------------------------------------------------------------------
/src/utils.js:
--------------------------------------------------------------------------------
1 | import { CARDS_NUMBER } from './constants'
2 |
3 | const radius = 8
4 |
5 | const getRandomRating = () => Math.round(Math.random() * 20)
6 |
7 | const getData = () =>
8 | Array.from({ length: CARDS_NUMBER }, (value, key) => ({ id: key, playerRating: getRandomRating(), pressRating: getRandomRating() }))
9 |
10 | const getElementPosition = ({ index }) => {
11 | const phi = Math.acos(-1 + (2 * index) / CARDS_NUMBER)
12 | const theta = Math.sqrt(CARDS_NUMBER * Math.PI) * phi
13 |
14 | return { radius, phi, theta }
15 | }
16 |
17 | const getRandomTags = () => {
18 | const tags = [
19 | { color: 'blue', label: 'RPG' },
20 | { color: 'red', label: 'FPS' },
21 | { color: 'green', label: 'ADVENTURE' },
22 | { color: 'purple', label: 'ACTION' },
23 | { color: 'indigo', label: 'RTS' },
24 | { color: 'gray', label: 'MOBA' },
25 | ]
26 |
27 | const randomTagIndex1 = Math.floor(Math.random() * tags.length)
28 | const randomTagIndex2 = (randomTagIndex1 + 1) % tags.length
29 |
30 | return [tags[randomTagIndex1], tags[randomTagIndex2]]
31 | }
32 |
33 | const getNewIndex = ({ data, direction, currentIndex }) => {
34 | const minSlice = direction < 0 ? 0 : currentIndex + 1
35 | const maxSlice = direction < 0 ? currentIndex : data.length
36 | const dataSliced = data.slice(minSlice, maxSlice)
37 | const dataSort = direction < 0 ? dataSliced.reverse() : dataSliced
38 | return dataSort.find((element) => !element.filtered)?.id || currentIndex
39 | }
40 |
41 | export { getElementPosition, getRandomRating, getRandomTags, getData, getNewIndex }
42 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | darkMode: false,
3 | theme: {
4 | extend: {},
5 | },
6 | plugins: [],
7 | safelist: 'bg-indigo-600 bg-blue-600 bg-green-600 bg-purple-600 bg-red-600 bg-gray-600',
8 | }
9 |
--------------------------------------------------------------------------------
/vite.config.js:
--------------------------------------------------------------------------------
1 | import react from '@vitejs/plugin-react'
2 | import { defineConfig } from 'vite'
3 | import WindiCSS from 'vite-plugin-windicss'
4 |
5 | // https://vitejs.dev/config/
6 | export default defineConfig({
7 | plugins: [react(), WindiCSS()],
8 | })
9 |
--------------------------------------------------------------------------------