diff --git a/package-lock.json b/package-lock.json index 368ef0b..feb34d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,9 +10,11 @@ "dependencies": { "axios": "^1.5.0", "file-saver": "^2.0.5", + "inversify": "^6.0.1", "react": "^18.2.0", "react-dom": "^18.2.0", "reactflow": "^11.8.3", + "reflect-metadata": "^0.1.13", "use-debounce": "^9.0.4" }, "devDependencies": { @@ -2199,6 +2201,11 @@ "dev": true, "license": "ISC" }, + "node_modules/inversify": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/inversify/-/inversify-6.0.1.tgz", + "integrity": "sha512-B3ex30927698TJENHR++8FfEaJGqoWOgI6ZY5Ht/nLUsFCwHn6akbwtnUAPCgUepAnTpe2qHxhDNjoKLyz6rgQ==" + }, "node_modules/is-binary-path": { "version": "2.1.0", "dev": true, @@ -2963,6 +2970,11 @@ "node": ">=8.10.0" } }, + "node_modules/reflect-metadata": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.13.tgz", + "integrity": "sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg==" + }, "node_modules/resolve": { "version": "1.22.6", "dev": true, diff --git a/package.json b/package.json index 96760a8..3ed00cf 100644 --- a/package.json +++ b/package.json @@ -14,9 +14,11 @@ "dependencies": { "axios": "^1.5.0", "file-saver": "^2.0.5", + "inversify": "^6.0.1", "react": "^18.2.0", "react-dom": "^18.2.0", "reactflow": "^11.8.3", + "reflect-metadata": "^0.1.13", "use-debounce": "^9.0.4" }, "devDependencies": { diff --git a/src/Search.tsx b/src/Search.tsx index d71ff9f..6c16052 100644 --- a/src/Search.tsx +++ b/src/Search.tsx @@ -1,147 +1,49 @@ -import React from 'react'; -import SearchList from './SearchList' -import {useContext, useState} from 'react' -import axios from 'axios' -import {SpotifyContext} from './context/SpotifyContext' -import {FlowContext} from './context/FlowContext' -import {saveAs} from 'file-saver'; - -const CLIENT_ID = '0350c90137454dc5a748549664e5ba75' -const REDIRECT_URI = window.location.href -const AUTH_ENDPOINT = 'https://accounts.spotify.com/authorize' -const RESPONSE_TYPE = 'token' +import { useContext, useState } from 'react' +import { OAuthMusicContext } from './context/OAuthMusicContext' +import SearchList from './SearchList.tsx' +import { Track } from './services/music/interface.ts' function Search() { - const {token, logout} = useContext(SpotifyContext) - const {nodes, setNodes, edges, setEdges} = useContext(FlowContext) - - const [searchKey, setSearchKey] = useState('') - const [tracks, setTracks] = useState([]) - - async function getTrackFeatures(token, track_id) { - const response = await axios.get(`https://api.spotify.com/v1/audio-features/${track_id}`, { - headers: { - Authorization: `Bearer ${token}` - } - }); - return response.data; - }; - - async function searchTracks(e) { - e.preventDefault() - const {data} = await axios.get('https://api.spotify.com/v1/search', { - headers: { - Authorization: `Bearer ${token}` - }, - params: { - q: searchKey, - type: 'track' - } - }) - - const trackItems = data.tracks.items; - - const trackFeaturesPromises = trackItems.map(async (item) => { - const features = await getTrackFeatures(token, item.id); - return {track: item, features: features}; - }); - const trackFeatures = await Promise.all(trackFeaturesPromises); - - setTracks(trackFeatures) - } - - function handleExport(e) { - const data = { - nodes: nodes, - edges: edges - }; - const json = JSON.stringify(data); - const blob = new Blob([json], {type: 'application/json'}); - saveAs(blob, 'track_lineage_export.json'); - } - - const fileInputRef = React.createRef(); - - function handleImportButtonClick() { - fileInputRef.current.click(); - } - - - function handleImport(event) { - const file = event.target.files[0]; - const reader = new FileReader(); - - reader.onload = (e) => { - try { - const data = JSON.parse(e.target.result); - - if (data.nodes && data.edges) { - setNodes(data.nodes); - setEdges(data.edges); - alert('Import successful!'); - } else { - alert('Invalid file format. Please select a valid JSON file.'); - } - } catch (error) { - alert('Error parsing JSON. Please check the file format.'); - } - }; - - reader.readAsText(file); - } - - return ( -
- { - !token ? ( - - Login to Spotify - - ) : ( - - ) - } -
- - -
- - -
- setSearchKey(e.target.value) - } - className="col-span-4 block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-blue-500 dark:focus:ring-blue-500"/> - -
-
- -
- ) + const { login, logout } = useContext(OAuthMusicContext) + const { search, token } = useContext(OAuthMusicContext) + const [key, setKey] = useState('') + const [tracks, setTracks] = useState([]) + + return ( +
+ {!token?.token ? ( + + ) : ( + + )} + setKey(e.target.value)} + className="col-span-4 block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-blue-500 dark:focus:ring-blue-500" + /> + +
+ +
+ ) } export default Search diff --git a/src/SearchList.tsx b/src/SearchList.tsx index 98eb904..0f424f1 100644 --- a/src/SearchList.tsx +++ b/src/SearchList.tsx @@ -1,118 +1,65 @@ -import React from 'react' -import { FormEvent } from 'react' -import { Node } from 'reactflow' +import { useContext } from 'react' +import { FlowContext } from './context/FlowContext' +import { Track } from './services/music/interface' type Props = { - nodes: Node[]; - setNodes: React.Dispatch < React.SetStateAction < Node[] >>; + tracks: Track[] } +const SearchList: React.FC = ({ tracks }) => { + const { nodes, setNodes } = useContext(FlowContext) -function getKey(key) { - switch (key) { - case 0: - return 'C'; - case 1: - return 'C#/Db'; - case 2: - return 'D'; - case 3: - return 'D#/Eb'; - case 4: - return 'E'; - case 5: - return 'F'; - case 6: - return 'F#/Gb'; - case 7: - return 'G'; - case 8: - return 'G#/Ab'; - case 9: - return 'A'; - case 10: - return 'A#/Bb'; - case 11: - return 'B' - default: - return 'KEY'; - } -} -function SearchList({nodes, setNodes, tracks} : Props) { - function onSubmit(track, event : FormEvent < HTMLFormElement >) { - event.preventDefault() - setNodes(nodes.concat({ - id: track.track.id, - type: 'customNode', - data: { - title: track.track.name, - artist: track.track.artists[0].name, - bpm: track.features.tempo, - key: getKey(track.features.key), - image: track.track.album.images[0].url - }, - position: { - x: 500, - y: 25 - } - }),) - } - - return ( -
- { - tracks.map((track) => ( -
-
onSubmit(track, e) - } - key={ - track.track.id - }> -
-

- { - track.track.name - }

-

- { - track.track.artists[0].name - }

-

- BPM: { - track.features.tempo - }

-
-
- -
-
-
- )) - }
+ const onSubmit = (track: Track) => { + setNodes( + nodes.concat({ + id: track.id, + type: 'customNode', + data: { + title: track.name, + artist: track.artists[0].name, + bpm: '110', + key: 'Eb', + style: 'Disco', + image: track.album.images[0].url, + }, + position: { x: 500, y: 25 }, + }), ) + } + + return ( +
+ {tracks.map((track) => ( +
+
+

+ + {track.name} +

+

+ {track.artists[0].name} +

+
+
+ +
+
+ ))} +
+ ) } export default SearchList diff --git a/src/context/FlowContext.tsx b/src/context/FlowContext.tsx index 02909fd..2c7ee1d 100644 --- a/src/context/FlowContext.tsx +++ b/src/context/FlowContext.tsx @@ -1,13 +1,9 @@ import React, { createContext, useEffect, useState } from 'react' import { Edge, Node } from 'reactflow' import { useDebounce } from 'use-debounce' - -const FLOW_LOCALSTORAGE_KEY = 'flow' - -type FlowStorage = { - nodes: Node[] - edges: Edge[] -} +import useInjection from '../hooks/useInjection' +import { IStorage } from '../services/storage/interface' +import { TYPES } from '../types' type FlowContextProps = { nodes: Node[] @@ -32,6 +28,7 @@ type FlowContextProviderProps = { const FlowContextProvider: React.FC = ({ children, }: FlowContextProviderProps) => { + const storage = useInjection(TYPES.StorageService) const [nodes, setNodes] = useState([]) const [edges, setEdges] = useState([]) const [isLoaded, setIsLoaded] = useState(false) @@ -41,32 +38,26 @@ const FlowContextProvider: React.FC = ({ useEffect(() => { if (isLoaded) return - const json = window.localStorage.getItem(FLOW_LOCALSTORAGE_KEY) - if (!json) { - console.log('no flow localstorage') + const data = storage.get('flow') + + if (!data) { setIsLoaded(true) return } - try { - const storage = JSON.parse(json) as FlowStorage - setEdges(storage.edges) - setNodes(storage.nodes) - console.log('flow localstorage loaded') - } catch (e) { - console.error(e) - } + setNodes(data.nodes) + setEdges(data.edges) setIsLoaded(true) - }, [isLoaded]) + }, [isLoaded, storage]) useEffect(() => { - if (!isLoaded) return + if (!isLoaded || (debounceEdges.length === 0 && debounceNodes.length === 0)) + return console.log('update nodes/edges') - const storage: FlowStorage = { nodes: debounceNodes, edges: debounceEdges } - window.localStorage.setItem(FLOW_LOCALSTORAGE_KEY, JSON.stringify(storage)) - }, [debounceNodes, debounceEdges, isLoaded]) + storage.set('flow', { nodes: debounceNodes, edges: debounceEdges }) + }, [debounceNodes, debounceEdges, isLoaded, storage]) return ( diff --git a/src/context/InversifyContext.tsx b/src/context/InversifyContext.tsx new file mode 100644 index 0000000..7085c0c --- /dev/null +++ b/src/context/InversifyContext.tsx @@ -0,0 +1,23 @@ +import React from 'react' +import { Container } from 'inversify' + +export const InversifyContext = React.createContext<{ + container: Container | null +}>({ + container: null, +}) + +type Props = { + container: Container + children: React.ReactElement +} + +const InversifyContextProvider: React.FC = ({ container, children }) => { + return ( + + {children} + + ) +} + +export default InversifyContextProvider diff --git a/src/context/OAuthMusicContext.tsx b/src/context/OAuthMusicContext.tsx new file mode 100644 index 0000000..0a297b5 --- /dev/null +++ b/src/context/OAuthMusicContext.tsx @@ -0,0 +1,126 @@ +import React, { + createContext, + useCallback, + useEffect, + useMemo, + useState, +} from 'react' +import useInjection from '../hooks/useInjection' +import { IStorage } from '../services/storage/interface' +import { TYPES } from '../types' +import { + AccessToken, + IOAuthMusicProvider, + Track, +} from '../services/music/interface' + +type OAuthMusicContextProps = { + token: AccessToken | undefined + login: () => void + logout: () => void + search: (key: string) => Promise +} + +const initialOAuthMusicContext: OAuthMusicContextProps = { + token: undefined, + login: () => {}, + logout: () => {}, + search: () => Promise.resolve([]), +} + +export const OAuthMusicContext = createContext( + initialOAuthMusicContext, +) + +type OAuthMusicContextProviderProps = { + children: React.ReactElement +} + +const OAuthMusicContextProvider: React.FC = ({ + children, +}) => { + const storage = useInjection(TYPES.StorageService) + const service = useInjection( + TYPES.IOAuthMusicProviderService, + ) + const { authEndpoint, clientId, redirectUri } = service + const [accessToken, setToken] = useState({ + token: undefined, + expires: 0, + }) + const [isLoaded, setIsLoaded] = useState(false) + const { token } = accessToken + + // TODO: verify expire + + const loginUri = useMemo( + () => + `${authEndpoint}?client_id=${clientId}&redirect_uri=${redirectUri}&response_type=token`, + [authEndpoint, clientId, redirectUri], + ) + + const search = useCallback( + (key: string) => { + if (token) return service.search(token, key) + return Promise.resolve([]) + }, + [token, service], + ) + + const login = useCallback(() => { + console.log('OAuth: login') + window.location.assign(loginUri) + }, [loginUri]) + + const logout = useCallback(() => { + console.log('OAuth: logout') + setToken({ token: undefined, expires: 0 }) + storage.del('oauth') + }, [storage]) + + useEffect(() => { + if (!window.location.hash) return + + console.log(window.location.hash) + + const params = new URLSearchParams(window.location.hash.substring(1)) + const newToken = params.get('access_token') + const expires = params.get('expires_in') + window.location.hash = '' + + const expiresTimestamp = Number(expires) * 1000 + new Date().getTime() + + if (newToken && newToken !== token) + setToken({ token: newToken, expires: expiresTimestamp }) + }, [storage, token]) + + useEffect(() => { + if (isLoaded) return + + const storageToken = storage.get('oauth') + setIsLoaded(true) + + if (!storageToken?.token) return + + setToken(storageToken) + }, [isLoaded, storage]) + + useEffect(() => { + const storageToken = storage.get('oauth') + + if (!token || token === storageToken?.token) return + + console.log('token updated') + storage.set('oauth', accessToken) + }, [accessToken, service, storage, token]) + + return ( + + {children} + + ) +} + +export default OAuthMusicContextProvider diff --git a/src/context/SpotifyContext.tsx b/src/context/SpotifyContext.tsx deleted file mode 100644 index 0b6b3b4..0000000 --- a/src/context/SpotifyContext.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import React, { createContext, useCallback, useEffect, useState } from 'react' - -type SpotifyContextProps = { - token: string | undefined - logout: () => void -} - -const initialSpotifyContext: SpotifyContextProps = { - token: undefined, - logout: () => {}, -} - -export const SpotifyContext = createContext( - initialSpotifyContext, -) - -type SpotifyContextProviderProps = { - children: React.ReactElement -} - -const SpotifyContextProvider: React.FC = ({ - children, -}: SpotifyContextProviderProps) => { - const [token, setToken] = useState() - - const logout = useCallback(() => { - window.localStorage.removeItem('token') - setToken(undefined) - }, [setToken]) - - useEffect(() => { - if (token) return - setToken(window.localStorage.getItem('token') || undefined) - }, [token]) - - useEffect(() => { - if (!window.location.hash) return - - const token = window.location.hash - .substring(1) - .split('&') - .find((e) => e.startsWith('access_token')) - ?.split('=')[1] - window.location.hash = '' - - if (token) window.localStorage.setItem('token', token) - setToken(token) - }, [setToken]) - - return ( - - {children} - - ) -} - -export default SpotifyContextProvider diff --git a/src/hooks/useInjection.ts b/src/hooks/useInjection.ts new file mode 100644 index 0000000..0486947 --- /dev/null +++ b/src/hooks/useInjection.ts @@ -0,0 +1,17 @@ +import { interfaces } from 'inversify' +import { useContext, useMemo } from 'react' +import { InversifyContext } from '../context/InversifyContext' + +const useInjection = (identifier: interfaces.ServiceIdentifier): T => { + const { container } = useContext(InversifyContext) + + if (!container) { + throw new Error( + 'Inversify container not found. Make sure you have wrapped your app with the InversifyProvider.', + ) + } + + return useMemo(() => container.get(identifier), [container, identifier]) +} + +export default useInjection diff --git a/src/inversify.config.ts b/src/inversify.config.ts new file mode 100644 index 0000000..f764d30 --- /dev/null +++ b/src/inversify.config.ts @@ -0,0 +1,16 @@ +import 'reflect-metadata' + +import { Container } from 'inversify' +import { IStorage } from './services/storage/interface' +import LocalStorageService from './services/storage/localStorage.service' +import { TYPES } from './types' +import SpotifyService from './services/music/spotify.service' +import { IOAuthMusicProvider } from './services/music/interface' + +const container = new Container() +container.bind(TYPES.StorageService).to(LocalStorageService) +container + .bind(TYPES.IOAuthMusicProviderService) + .to(SpotifyService) + +export default container diff --git a/src/main.tsx b/src/main.tsx index 6cf8ca5..182907b 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,16 +1,22 @@ +import 'reflect-metadata' + import React from 'react' import ReactDOM from 'react-dom/client' import App from './App.tsx' import './index.css' -import SpotifyContextProvider from './context/SpotifyContext.tsx' import FlowContextProvider from './context/FlowContext.tsx' +import InversifyContextProvider from './context/InversifyContext.tsx' +import container from './inversify.config.ts' +import OAuthMusicContextProvider from './context/OAuthMusicContext.tsx' ReactDOM.createRoot(document.getElementById('root')!).render( - - - - - + + + + + + + , ) diff --git a/src/services/music/interface.ts b/src/services/music/interface.ts new file mode 100644 index 0000000..984510a --- /dev/null +++ b/src/services/music/interface.ts @@ -0,0 +1,50 @@ +export type AccessToken = { token: string | undefined; expires: number } + +export interface Track { + album: Album + artists: Artist[] + available_markets: string[] + disc_number: number + duration_ms: number + explicit: boolean + href: string + id: string + is_local: boolean + name: string + popularity: number + preview_url?: string + track_number: number + type: string + uri: string +} + +export interface Album { + album_type: string + artists: Artist[] + images: Image[] + name: string + release_date: string + release_date_precision: string + type: string + uri: string +} + +export interface Artist { + name: string + type: string + uri: string +} + +export interface Image { + height: number + url: string + width: number +} + +export interface IOAuthMusicProvider { + clientId: string + redirectUri: string + authEndpoint: string + + search: (token: string, key: string) => Promise +} diff --git a/src/services/music/spotify.service.ts b/src/services/music/spotify.service.ts new file mode 100644 index 0000000..f1880f9 --- /dev/null +++ b/src/services/music/spotify.service.ts @@ -0,0 +1,34 @@ +import { injectable } from 'inversify' +import { IOAuthMusicProvider, Track } from './interface' +import axios from 'axios' + +type SearchResult = { + tracks: { items: Track[] } +} + +@injectable() +class SpotifyService implements IOAuthMusicProvider { + public clientId: string = 'dd9b77b5b9d84bebbe346163d0275dfa' + public redirectUri: string = 'http://localhost:5173/' // TODO: change this url + public authEndpoint: string = 'https://accounts.spotify.com/authorize' + private apiEndpoint: string = 'https://api.spotify.com/v1/' + + search = async (token: string, key: string) => { + const { data } = await axios.get( + this.apiEndpoint + 'search', + { + headers: { + Authorization: `Bearer ${token}`, + }, + params: { + q: key, + type: 'track', + }, + }, + ) + + return data.tracks.items + } +} + +export default SpotifyService diff --git a/src/services/storage/interface.ts b/src/services/storage/interface.ts new file mode 100644 index 0000000..883abce --- /dev/null +++ b/src/services/storage/interface.ts @@ -0,0 +1,15 @@ +import { Edge, Node } from 'reactflow' +import { AccessToken } from '../music/interface' + +export type StorageKey = 'oauth' | 'flow' + +export type StorageData = { + flow: { nodes: Node[]; edges: Edge[] } + oauth: AccessToken +} + +export interface IStorage { + get: (key: K) => StorageData[K] | undefined + set: (key: K, data: StorageData[K]) => void + del: (key: K) => void +} diff --git a/src/services/storage/localStorage.service.ts b/src/services/storage/localStorage.service.ts new file mode 100644 index 0000000..9e5c3fe --- /dev/null +++ b/src/services/storage/localStorage.service.ts @@ -0,0 +1,23 @@ +import { injectable } from 'inversify' +import { IStorage, StorageData, StorageKey } from './interface' + +@injectable() +class LocalStorageService implements IStorage { + get = (key: K) => { + const data = window.localStorage.getItem(key) + + if (!data) return undefined + + return JSON.parse(data) as StorageData[K] + } + + set = (key: K, data: StorageData[K]) => { + window.localStorage.setItem(key, JSON.stringify(data)) + } + + del = (key: K) => { + window.localStorage.removeItem(key) + } +} + +export default LocalStorageService diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..39fa3ac --- /dev/null +++ b/src/types.ts @@ -0,0 +1,4 @@ +export const TYPES = { + StorageService: Symbol('StorageService'), + IOAuthMusicProviderService: Symbol('IOAuthMusicProviderService'), +} diff --git a/tsconfig.json b/tsconfig.json index eec131c..2bf3928 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,7 +18,11 @@ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true + "noFallthroughCasesInSwitch": true, + + /* inversifyjs */ + "experimentalDecorators": true, + "emitDecoratorMetadata": true }, "exclude": ["src"], // "include": ["src"],