Changes...
This commit is contained in:
parent
f4fcae38e1
commit
9dacd1944d
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Start Frontend",
|
||||
"type": "command",
|
||||
"program": "npm",
|
||||
"args": [
|
||||
"start"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
|
@ -15,14 +15,17 @@
|
|||
"@types/node": "^16.18.10",
|
||||
"@types/react": "^18.0.26",
|
||||
"@types/react-dom": "^18.0.9",
|
||||
"@types/react-router-bootstrap": "^0.24.5",
|
||||
"axios": "^1.2.1",
|
||||
"bootstrap": "^5.2.3",
|
||||
"bootstrap-icons": "^1.10.3",
|
||||
"react": "^18.2.0",
|
||||
"react-bootstrap": "^2.7.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-bootstrap": "^0.26.2",
|
||||
"react-router-dom": "^6.6.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"sass": "^1.57.1",
|
||||
"typescript": "^4.9.4",
|
||||
"web-vitals": "^2.1.4"
|
||||
}
|
||||
|
@ -3817,6 +3820,11 @@
|
|||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/history": {
|
||||
"version": "4.7.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz",
|
||||
"integrity": "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA=="
|
||||
},
|
||||
"node_modules/@types/html-minifier-terser": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz",
|
||||
|
@ -3928,6 +3936,34 @@
|
|||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-router": {
|
||||
"version": "5.1.20",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.20.tgz",
|
||||
"integrity": "sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==",
|
||||
"dependencies": {
|
||||
"@types/history": "^4.7.11",
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-router-bootstrap": {
|
||||
"version": "0.24.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-router-bootstrap/-/react-router-bootstrap-0.24.5.tgz",
|
||||
"integrity": "sha512-GRx/8xF/skw4/Pmm6d+xbExi8gobCLOe8Eoz9kXPQGbYo7p5Wbi61tjpOF5AbfJ5XMN+fIzweToTi56odj/LOQ==",
|
||||
"dependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-router-dom": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-router-dom": {
|
||||
"version": "5.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.3.3.tgz",
|
||||
"integrity": "sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==",
|
||||
"dependencies": {
|
||||
"@types/history": "^4.7.11",
|
||||
"@types/react": "*",
|
||||
"@types/react-router": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-transition-group": {
|
||||
"version": "4.4.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.5.tgz",
|
||||
|
@ -5266,6 +5302,11 @@
|
|||
"@popperjs/core": "^2.11.6"
|
||||
}
|
||||
},
|
||||
"node_modules/bootstrap-icons": {
|
||||
"version": "1.10.3",
|
||||
"resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.10.3.tgz",
|
||||
"integrity": "sha512-7Qvj0j0idEm/DdX9Q0CpxAnJYqBCFCiUI6qzSPYfERMcokVuV9Mdm/AJiVZI8+Gawe4h/l6zFcOzvV7oXCZArw=="
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
||||
|
@ -8748,6 +8789,11 @@
|
|||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/immutable": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-4.2.1.tgz",
|
||||
"integrity": "sha512-7WYV7Q5BTs0nlQm7tl92rDYYoyELLKHoDMBKhrxEoiV4mrfVdRz8hzPiYOzH7yWjzoVEamxRuAqhxL2PLRwZYQ=="
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
|
||||
|
@ -14742,6 +14788,22 @@
|
|||
"resolved": "https://registry.npmjs.org/sanitize.css/-/sanitize.css-13.0.0.tgz",
|
||||
"integrity": "sha512-ZRwKbh/eQ6w9vmTjkuG0Ioi3HBwPFce0O+v//ve+aOq1oeCy7jMV2qzzAlpsNuqpqCBjjriM1lbtZbF/Q8jVyA=="
|
||||
},
|
||||
"node_modules/sass": {
|
||||
"version": "1.57.1",
|
||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.57.1.tgz",
|
||||
"integrity": "sha512-O2+LwLS79op7GI0xZ8fqzF7X2m/m8WFfI02dHOdsK5R2ECeS5F62zrwg/relM1rjSLy7Vd/DiMNIvPrQGsA0jw==",
|
||||
"dependencies": {
|
||||
"chokidar": ">=3.0.0 <4.0.0",
|
||||
"immutable": "^4.0.0",
|
||||
"source-map-js": ">=0.6.2 <2.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"sass": "sass.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sass-loader": {
|
||||
"version": "12.6.0",
|
||||
"resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-12.6.0.tgz",
|
||||
|
@ -19642,6 +19704,11 @@
|
|||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"@types/history": {
|
||||
"version": "4.7.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz",
|
||||
"integrity": "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA=="
|
||||
},
|
||||
"@types/html-minifier-terser": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz",
|
||||
|
@ -19753,6 +19820,34 @@
|
|||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"@types/react-router": {
|
||||
"version": "5.1.20",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.20.tgz",
|
||||
"integrity": "sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==",
|
||||
"requires": {
|
||||
"@types/history": "^4.7.11",
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"@types/react-router-bootstrap": {
|
||||
"version": "0.24.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-router-bootstrap/-/react-router-bootstrap-0.24.5.tgz",
|
||||
"integrity": "sha512-GRx/8xF/skw4/Pmm6d+xbExi8gobCLOe8Eoz9kXPQGbYo7p5Wbi61tjpOF5AbfJ5XMN+fIzweToTi56odj/LOQ==",
|
||||
"requires": {
|
||||
"@types/react": "*",
|
||||
"@types/react-router-dom": "*"
|
||||
}
|
||||
},
|
||||
"@types/react-router-dom": {
|
||||
"version": "5.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.3.3.tgz",
|
||||
"integrity": "sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==",
|
||||
"requires": {
|
||||
"@types/history": "^4.7.11",
|
||||
"@types/react": "*",
|
||||
"@types/react-router": "*"
|
||||
}
|
||||
},
|
||||
"@types/react-transition-group": {
|
||||
"version": "4.4.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.5.tgz",
|
||||
|
@ -20759,6 +20854,11 @@
|
|||
"integrity": "sha512-cEKPM+fwb3cT8NzQZYEu4HilJ3anCrWqh3CHAok1p9jXqMPsPTBhU25fBckEJHJ/p+tTxTFTsFQGM+gaHpi3QQ==",
|
||||
"requires": {}
|
||||
},
|
||||
"bootstrap-icons": {
|
||||
"version": "1.10.3",
|
||||
"resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.10.3.tgz",
|
||||
"integrity": "sha512-7Qvj0j0idEm/DdX9Q0CpxAnJYqBCFCiUI6qzSPYfERMcokVuV9Mdm/AJiVZI8+Gawe4h/l6zFcOzvV7oXCZArw=="
|
||||
},
|
||||
"brace-expansion": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
||||
|
@ -23291,6 +23391,11 @@
|
|||
"resolved": "https://registry.npmjs.org/immer/-/immer-9.0.16.tgz",
|
||||
"integrity": "sha512-qenGE7CstVm1NrHQbMh8YaSzTZTFNP3zPqr3YU0S0UY441j4bJTg4A2Hh5KAhwgaiU6ZZ1Ar6y/2f4TblnMReQ=="
|
||||
},
|
||||
"immutable": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-4.2.1.tgz",
|
||||
"integrity": "sha512-7WYV7Q5BTs0nlQm7tl92rDYYoyELLKHoDMBKhrxEoiV4mrfVdRz8hzPiYOzH7yWjzoVEamxRuAqhxL2PLRwZYQ=="
|
||||
},
|
||||
"import-fresh": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
|
||||
|
@ -27417,6 +27522,16 @@
|
|||
"resolved": "https://registry.npmjs.org/sanitize.css/-/sanitize.css-13.0.0.tgz",
|
||||
"integrity": "sha512-ZRwKbh/eQ6w9vmTjkuG0Ioi3HBwPFce0O+v//ve+aOq1oeCy7jMV2qzzAlpsNuqpqCBjjriM1lbtZbF/Q8jVyA=="
|
||||
},
|
||||
"sass": {
|
||||
"version": "1.57.1",
|
||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.57.1.tgz",
|
||||
"integrity": "sha512-O2+LwLS79op7GI0xZ8fqzF7X2m/m8WFfI02dHOdsK5R2ECeS5F62zrwg/relM1rjSLy7Vd/DiMNIvPrQGsA0jw==",
|
||||
"requires": {
|
||||
"chokidar": ">=3.0.0 <4.0.0",
|
||||
"immutable": "^4.0.0",
|
||||
"source-map-js": ">=0.6.2 <2.0.0"
|
||||
}
|
||||
},
|
||||
"sass-loader": {
|
||||
"version": "12.6.0",
|
||||
"resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-12.6.0.tgz",
|
||||
|
|
|
@ -10,14 +10,17 @@
|
|||
"@types/node": "^16.18.10",
|
||||
"@types/react": "^18.0.26",
|
||||
"@types/react-dom": "^18.0.9",
|
||||
"@types/react-router-bootstrap": "^0.24.5",
|
||||
"axios": "^1.2.1",
|
||||
"bootstrap": "^5.2.3",
|
||||
"bootstrap-icons": "^1.10.3",
|
||||
"react": "^18.2.0",
|
||||
"react-bootstrap": "^2.7.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-bootstrap": "^0.26.2",
|
||||
"react-router-dom": "^6.6.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"sass": "^1.57.1",
|
||||
"typescript": "^4.9.4",
|
||||
"web-vitals": "^2.1.4"
|
||||
},
|
||||
|
|
38
src/App.css
38
src/App.css
|
@ -1,38 +0,0 @@
|
|||
.App {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.App-logo {
|
||||
height: 40vmin;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.App-logo {
|
||||
animation: App-logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.App-header {
|
||||
background-color: #282c34;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: calc(10px + 2vmin);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.App-link {
|
||||
color: #61dafb;
|
||||
}
|
||||
|
||||
@keyframes App-logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
17
src/App.tsx
17
src/App.tsx
|
@ -1,21 +1,20 @@
|
|||
import axios from 'axios';
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import './App.css';
|
||||
import SongDisplay from './components/SongDisplay';
|
||||
import Song from './model/song.model';
|
||||
import TrackDisplay from './components/TrackDisplay';
|
||||
import api from "./api";
|
||||
import Track from './model/track.model';
|
||||
|
||||
const App: React.FC = () => {
|
||||
const [songs, setSongs] = useState(new Array<Song>());
|
||||
const [tracks, setTracks] = useState(new Array<Track>());
|
||||
|
||||
useEffect(() => {
|
||||
axios.get("http://localhost:8080/api/v1/song/")
|
||||
.then(response => setSongs(response.data));
|
||||
api.get("/tracks/")
|
||||
.then(response => setTracks(response.data));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
{songs.map(s => (
|
||||
<SongDisplay key={s.id} song={s}/>
|
||||
{tracks.map(t => (
|
||||
<TrackDisplay key={t.id} track={t}/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
import axios from "axios";
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: "http://localhost:8080/api/v1",
|
||||
withCredentials: true
|
||||
});
|
||||
|
||||
export default api;
|
|
@ -0,0 +1,43 @@
|
|||
import React, {FC} from "react";
|
||||
import ExternalTrack from "../model/externalTrack.model";
|
||||
import Icon from "./Icon";
|
||||
|
||||
const ExternalTrackCard: FC<ExternalTrackCardProps> = ({
|
||||
track,
|
||||
source,
|
||||
imported,
|
||||
previewing,
|
||||
onImportClick,
|
||||
onPreviewClick,
|
||||
onSpotifyIconClick
|
||||
}) => (
|
||||
<div className="external-track-card">
|
||||
<img className="external-track-thumbnail" src={track.thumbnailUrl} alt={'Thumbnail - ' + track.name}/>
|
||||
<div className="external-track-description">
|
||||
<p className="external-track-title" onClick={onSpotifyIconClick}
|
||||
title={'Open on ' + source}>{track.name}</p>
|
||||
<p className="external-track-subtitle">{track.authors.join(', ') + ' - ' + track.albumName}</p>
|
||||
</div>
|
||||
<div className="external-track-actions">
|
||||
{previewing ?
|
||||
<Icon name="pause" hover="pause-fill" onClick={onPreviewClick} title="Stop preview"/> :
|
||||
<Icon name="play" hover="play-fill" onClick={onPreviewClick} title="Preview track"/>}
|
||||
|
||||
{imported ?
|
||||
<Icon name="check" hover="check-circle-fill" onClick={onImportClick} title="Remove from library"/> :
|
||||
<Icon name="plus" hover="plus-circle-fill" onClick={onImportClick} title="Import in library"/>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
interface ExternalTrackCardProps {
|
||||
track: ExternalTrack,
|
||||
source: string,
|
||||
imported: boolean,
|
||||
previewing: boolean,
|
||||
onImportClick: () => void,
|
||||
onPreviewClick: () => void,
|
||||
onSpotifyIconClick: () => void
|
||||
}
|
||||
|
||||
export default ExternalTrackCard;
|
|
@ -0,0 +1,35 @@
|
|||
import {FC, useEffect, useState} from "react";
|
||||
|
||||
const Icon: FC<IconProps> = ({name, hover, title, onClick}) => {
|
||||
const [hovering, setHovering] = useState(false);
|
||||
const [className, setClassName] = useState("bi bi-" + name);
|
||||
|
||||
useEffect(() => {
|
||||
let icon;
|
||||
|
||||
if (hover && hovering) {
|
||||
icon = hover;
|
||||
} else {
|
||||
icon = name;
|
||||
}
|
||||
|
||||
setClassName("bi bi-" + icon);
|
||||
}, [hovering, name, hover]);
|
||||
|
||||
return (
|
||||
<i className={className}
|
||||
title={title}
|
||||
onMouseEnter={() => setHovering(true)}
|
||||
onMouseLeave={() => setHovering(false)}
|
||||
onClick={onClick}></i>
|
||||
);
|
||||
};
|
||||
|
||||
interface IconProps {
|
||||
name: string
|
||||
hover?: string
|
||||
title?: string
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
export default Icon;
|
|
@ -0,0 +1,27 @@
|
|||
import React, {FC} from "react";
|
||||
import {Nav} from "react-bootstrap";
|
||||
import {Link} from "react-router-dom";
|
||||
|
||||
const NavTab: FC<NavTabProps> = ({eventKey, label, icon, activeIcon, activeKey, onTabClick}) => {
|
||||
const isActive = () => activeKey === eventKey;
|
||||
|
||||
return (
|
||||
<Nav.Link as={Link} eventKey={eventKey} to={'/' + eventKey} onClick={() => onTabClick(eventKey)}>
|
||||
{!isActive() || activeIcon == null ?
|
||||
<i className={'bi bi-' + icon}></i> :
|
||||
<i className={'bi bi-' + activeIcon}></i>}
|
||||
<span>{label}</span>
|
||||
</Nav.Link>
|
||||
);
|
||||
};
|
||||
|
||||
interface NavTabProps {
|
||||
eventKey: string,
|
||||
label: string,
|
||||
icon: string,
|
||||
activeIcon?: string,
|
||||
activeKey: string,
|
||||
onTabClick: (key: string) => void
|
||||
}
|
||||
|
||||
export default NavTab;
|
|
@ -0,0 +1,29 @@
|
|||
import {FC, FormEvent, useState} from "react";
|
||||
|
||||
const SearchSongForm: FC<SearchSongFormProps> = ({submitSearchHandler}) => {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
const submitFormHandler = (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
submitSearchHandler(searchQuery);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={submitFormHandler}>
|
||||
<input
|
||||
name="query"
|
||||
type="string"
|
||||
placeholder="Search query"
|
||||
required
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.currentTarget.value)}/>
|
||||
<button>Search</button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
interface SearchSongFormProps {
|
||||
submitSearchHandler: (query: string) => void
|
||||
}
|
||||
|
||||
export default SearchSongForm;
|
|
@ -1,16 +0,0 @@
|
|||
import React from "react";
|
||||
import Song from "../model/song.model";
|
||||
|
||||
const SongDisplay: React.FC<SongDisplayProps> = ({song}) => {
|
||||
return (
|
||||
<div>
|
||||
<p>{song.name} - {song.authors.join(" and ")}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export interface SongDisplayProps {
|
||||
song: Song
|
||||
}
|
||||
|
||||
export default SongDisplay;
|
|
@ -0,0 +1,16 @@
|
|||
import React from "react";
|
||||
import Track from "../model/track.model";
|
||||
|
||||
const TrackDisplay: React.FC<TrackDisplayProps> = ({track}) => {
|
||||
return (
|
||||
<div>
|
||||
<p>{track.name} - {track.authors.join(" and ")}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export interface TrackDisplayProps {
|
||||
track: Track
|
||||
}
|
||||
|
||||
export default TrackDisplay;
|
|
@ -0,0 +1,28 @@
|
|||
import {FC, useState} from "react";
|
||||
import {Container, Nav, Navbar} from "react-bootstrap";
|
||||
import NavTab from "../components/NavTab";
|
||||
import "../style/navbar.scss";
|
||||
|
||||
const NavbarContainer: FC = () => {
|
||||
const [activeKey, setActiveKey] = useState("library");
|
||||
|
||||
return (
|
||||
<Navbar bg="dark">
|
||||
<Container>
|
||||
<Nav className="me-auto" activeKey={activeKey}>
|
||||
<NavTab eventKey="library" label="Library" icon="vinyl" activeIcon="vinyl-fill"
|
||||
activeKey={activeKey} onTabClick={setActiveKey}/>
|
||||
<NavTab eventKey="search" label="Search" icon="search" activeKey={activeKey}
|
||||
onTabClick={setActiveKey}/>
|
||||
<NavTab eventKey="playlists" label="Playlists" icon="bookmarks" activeIcon="bookmarks-fill"
|
||||
activeKey={activeKey} onTabClick={setActiveKey}/>
|
||||
<NavTab eventKey="spotify-login" label="Spotify Login" icon="spotify" activeIcon="spotify"
|
||||
activeKey={activeKey}
|
||||
onTabClick={() => window.open("http://localhost:8080/module/spotify/login", "_blank")}/>
|
||||
</Nav>
|
||||
</Container>
|
||||
</Navbar>
|
||||
);
|
||||
};
|
||||
|
||||
export default NavbarContainer;
|
|
@ -1,14 +1,70 @@
|
|||
import React from "react";
|
||||
import React, {useEffect, useState} from "react";
|
||||
import SearchSongForm from "../components/SearchSongForm";
|
||||
import api from "../api";
|
||||
import SearchResultContainer from "./SearchResult.container";
|
||||
import ExternalTrack from "../model/externalTrack.model";
|
||||
|
||||
const audio = new Audio();
|
||||
|
||||
const SearchContainer: React.FC = () => {
|
||||
const [results, setResults] = useState({});
|
||||
const [previewTrackId, setPreviewTrackId] = useState("");
|
||||
const [importedTracksIds, setImportedTracksIds] = useState(new Array<string>());
|
||||
|
||||
const submitSearchHandler = async (query: string) => {
|
||||
const response = await api.get("/tracks/search?q=" + query, {withCredentials: true});
|
||||
setResults(response.data);
|
||||
};
|
||||
|
||||
const onPreviewClick = (trackId: string, previewUrl: string) => {
|
||||
audio.pause();
|
||||
|
||||
if (trackId !== previewTrackId) {
|
||||
audio.src = previewUrl;
|
||||
audio.play();
|
||||
setPreviewTrackId(trackId);
|
||||
} else {
|
||||
setPreviewTrackId("");
|
||||
}
|
||||
};
|
||||
|
||||
const onImportClick = async (trackId: string) => {
|
||||
if (importedTracksIds.some(id => trackId === id)) {
|
||||
await api.delete(`/tracks/trackId/spotify/${trackId}`);
|
||||
setImportedTracksIds(importedTracksIds.filter(id => trackId !== id));
|
||||
} else {
|
||||
await api.post("/tracks/", {source: "spotify", trackId}, {withCredentials: true});
|
||||
setImportedTracksIds([...importedTracksIds, trackId]);
|
||||
}
|
||||
};
|
||||
|
||||
const onSpotifyIconClick = (trackId: string) => {
|
||||
const spotifyTrackUrl = "https://open.spotify.com/track/" + trackId;
|
||||
window.open(spotifyTrackUrl, "_blank");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const getLibraryTrackIds = async () => {
|
||||
const response = await api.get("/tracks/trackIds/");
|
||||
if (response.data.spotify) {
|
||||
setImportedTracksIds(response.data.spotify);
|
||||
}
|
||||
};
|
||||
|
||||
getLibraryTrackIds();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<form>
|
||||
<input name="query" type="string" placeholder="Search query" required />
|
||||
<button>Search</button>
|
||||
</form>
|
||||
</div>
|
||||
<SearchSongForm submitSearchHandler={submitSearchHandler}/>
|
||||
{Object.entries(results).map(([source, tracks]) => (
|
||||
<SearchResultContainer key={source} source={source} importedTracksIds={importedTracksIds}
|
||||
previewTrackId={previewTrackId}
|
||||
tracks={tracks as ExternalTrack[]}
|
||||
onImportClick={onImportClick}
|
||||
onPreviewClick={onPreviewClick}
|
||||
onSpotifyIconClick={onSpotifyIconClick}/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,44 @@
|
|||
import {FC} from "react";
|
||||
import ExternalTrackCard from "../components/ExternalTrackCard";
|
||||
import ExternalTrack from "../model/externalTrack.model";
|
||||
import SpotifyLogo from "../images/Spotify_Logo_RGB_White.png";
|
||||
import "../style/search-results.scss";
|
||||
|
||||
const SearchResultContainer: FC<SearchResultContainerProps> = ({
|
||||
source,
|
||||
importedTracksIds,
|
||||
previewTrackId,
|
||||
tracks,
|
||||
onImportClick,
|
||||
onPreviewClick,
|
||||
onSpotifyIconClick
|
||||
}) => {
|
||||
return (
|
||||
<div className="d-flex flex-column">
|
||||
<img className="search-results-source-logo" src={SpotifyLogo} alt="Spotify logo"/>
|
||||
<div className="search-results-wrapper d-flex flex-row">
|
||||
{tracks.map(s => (
|
||||
<ExternalTrackCard key={s.trackId} track={s}
|
||||
source={source}
|
||||
imported={importedTracksIds.some(id => s.trackId === id)}
|
||||
previewing={previewTrackId === s.trackId}
|
||||
onImportClick={() => onImportClick(s.trackId)}
|
||||
onPreviewClick={() => onPreviewClick(s.trackId, s.previewUrl)}
|
||||
onSpotifyIconClick={() => onSpotifyIconClick(s.trackId)}/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface SearchResultContainerProps {
|
||||
source: string,
|
||||
importedTracksIds: string[],
|
||||
previewTrackId: string,
|
||||
tracks: ExternalTrack[],
|
||||
onImportClick: (trackId: string) => void,
|
||||
onPreviewClick: (trackId: string, previewUrl: string) => void,
|
||||
onSpotifyIconClick: (trackId: string) => void
|
||||
}
|
||||
|
||||
export default SearchResultContainer;
|
Binary file not shown.
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
After Width: | Height: | Size: 15 KiB |
Binary file not shown.
After Width: | Height: | Size: 43 KiB |
|
@ -1,13 +0,0 @@
|
|||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
|
@ -1,9 +1,11 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import './index.css';
|
||||
import reportWebVitals from './reportWebVitals';
|
||||
import {RouterProvider} from 'react-router-dom';
|
||||
import router from './router';
|
||||
import {BrowserRouter, Route, Routes} from 'react-router-dom';
|
||||
import './style/index.scss';
|
||||
import NavbarContainer from "./containers/Navbar.container";
|
||||
import SearchContainer from "./containers/Search.container";
|
||||
import MainContainer from './containers/Main.container';
|
||||
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById('root') as HTMLElement
|
||||
|
@ -11,7 +13,20 @@ const root = ReactDOM.createRoot(
|
|||
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<RouterProvider router={router}/>
|
||||
<BrowserRouter>
|
||||
<div className="d-flex flex-row">
|
||||
<div className="sidebar">
|
||||
<NavbarContainer/>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<Routes>
|
||||
<Route path="/" element={<MainContainer/>}/>
|
||||
<Route path="/library" element={<MainContainer/>}/>
|
||||
<Route path="/search" element={<SearchContainer/>}/>
|
||||
</Routes>
|
||||
</div>
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
|
|
|
@ -0,0 +1,41 @@
|
|||
export default class ExternalTrack {
|
||||
private readonly _trackId: string;
|
||||
private readonly _name: string;
|
||||
private readonly _albumName: string;
|
||||
private readonly _authors: string[];
|
||||
private readonly _thumbnailUrl: string;
|
||||
private readonly _previewUrl: string;
|
||||
|
||||
public constructor(trackId: string, name: string, albumName: string, authors: string[], thumbnailUrl: string, previewUrl: string) {
|
||||
this._trackId = trackId;
|
||||
this._name = name;
|
||||
this._albumName = albumName;
|
||||
this._authors = authors;
|
||||
this._thumbnailUrl = thumbnailUrl;
|
||||
this._previewUrl = previewUrl;
|
||||
}
|
||||
|
||||
public get trackId(): string {
|
||||
return this._trackId;
|
||||
}
|
||||
|
||||
public get name(): string {
|
||||
return this._name;
|
||||
}
|
||||
|
||||
public get albumName(): string {
|
||||
return this._albumName;
|
||||
}
|
||||
|
||||
public get authors(): string[] {
|
||||
return this._authors;
|
||||
}
|
||||
|
||||
public get thumbnailUrl(): string {
|
||||
return this._thumbnailUrl;
|
||||
}
|
||||
|
||||
public get previewUrl(): string {
|
||||
return this._previewUrl;
|
||||
}
|
||||
}
|
|
@ -1,12 +1,12 @@
|
|||
export default class Song {
|
||||
export default class Track {
|
||||
private readonly _id: string;
|
||||
private readonly _songId: string;
|
||||
private readonly _trackId: string;
|
||||
private readonly _name: string;
|
||||
private readonly _authors: string[];
|
||||
|
||||
public constructor(id: string, songId: string, name: string, authors: string[]) {
|
||||
public constructor(id: string, trackId: string, name: string, authors: string[]) {
|
||||
this._id = id;
|
||||
this._songId = songId;
|
||||
this._trackId = trackId;
|
||||
this._name = name;
|
||||
this._authors = authors;
|
||||
}
|
||||
|
@ -15,8 +15,8 @@ export default class Song {
|
|||
return this._id;
|
||||
}
|
||||
|
||||
public get songId(): string {
|
||||
return this._songId;
|
||||
public get trackId(): string {
|
||||
return this._trackId;
|
||||
}
|
||||
|
||||
public get name(): string {
|
|
@ -0,0 +1,32 @@
|
|||
@import "~bootstrap/scss/functions";
|
||||
@import "~bootstrap/scss/variables";
|
||||
@import "~bootstrap/scss/mixins";
|
||||
|
||||
/* https://coolors.co/fc8607-8d99ae-edf2f4-29262c-1f1c21 */
|
||||
|
||||
$color-primary: #fc8607;
|
||||
$color-background: #191919;
|
||||
$color-background-2: darken($color-background, 5%);
|
||||
$font-color-background: white;
|
||||
|
||||
$body-bg: $color-background !default;
|
||||
$body-color: white;
|
||||
|
||||
$navbar-width: 15rem;
|
||||
|
||||
$theme-colors: (
|
||||
"primary": $color-primary,
|
||||
"secondary": #6c757d,
|
||||
"success": #28a745,
|
||||
"danger": #dc3545,
|
||||
"warning": #ffc107,
|
||||
"info": #17a2b8,
|
||||
"light": #f8f9fa,
|
||||
"dark": $color-background-2
|
||||
);
|
||||
//$theme-colors-rgb: map-loop($theme-colors, to-rgb, "$value");
|
||||
//$utilities-colors: map-merge($utilities-colors, $theme-colors-rgb);
|
||||
//$utilities-text-colors: map-loop($utilities-colors, rgba-css-var, "$key", "text");
|
||||
//$utilities-bg-colors: map-loop($utilities-colors, rgba-css-var, "$key", "bg");
|
||||
|
||||
@import "~bootstrap/scss/bootstrap";
|
|
@ -0,0 +1,28 @@
|
|||
@import "~bootstrap/scss/bootstrap";
|
||||
@import "~bootstrap-icons/font/bootstrap-icons";
|
||||
@import "theme";
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
// font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
// 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
// sans-serif;
|
||||
font-family: 'Helvetica Neue', sans-serif;
|
||||
background-color: $color-background !important;
|
||||
color: $font-color-background;
|
||||
overflow: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background-color: $color-background-2;
|
||||
min-width: 15rem;
|
||||
height: 100vh;
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
@import "theme";
|
||||
|
||||
nav {
|
||||
background-color: $color-background-2;
|
||||
width: $navbar-width;
|
||||
|
||||
.navbar-nav {
|
||||
width: $navbar-width;
|
||||
flex-direction: column !important;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-weight: bold;
|
||||
font-size: 1.2em;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
i, span {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
margin-right: 1em;
|
||||
}
|
||||
|
||||
&:hover i, &:hover span, &.active span {
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.active i {
|
||||
color: $color-primary !important;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,59 @@
|
|||
@import "theme";
|
||||
|
||||
$thumbnail-size: 15em;
|
||||
$wrapper-margin: 1em;
|
||||
$wrapper-width: calc(100vw - $navbar-width - (2 * $wrapper-margin));
|
||||
|
||||
.search-results-wrapper {
|
||||
overflow-x: scroll;
|
||||
width: $wrapper-width;
|
||||
}
|
||||
|
||||
.search-results-source-logo {
|
||||
width: 10em;
|
||||
margin-top: $wrapper-margin;
|
||||
margin-left: $wrapper-margin;
|
||||
}
|
||||
|
||||
.external-track-card {
|
||||
width: $thumbnail-size;
|
||||
margin: 1em;
|
||||
|
||||
img.external-track-thumbnail {
|
||||
width: $thumbnail-size;
|
||||
height: $thumbnail-size;
|
||||
margin-bottom: .5em;
|
||||
}
|
||||
|
||||
.external-track-description {
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.external-track-title {
|
||||
font-weight: bold;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.bi {
|
||||
margin-left: .25em;
|
||||
}
|
||||
}
|
||||
|
||||
.external-track-subtitle {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
line-height: 1.2em;
|
||||
}
|
||||
}
|
||||
|
||||
.external-track-actions {
|
||||
font-size: 1.2em;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.bi, .external-track-title {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue