Sfoglia il codice sorgente

[front] react-virtualized-selects

Innocenty Enikeew 7 anni fa
parent
commit
62995d7557

+ 27 - 0
front/package-lock.json

@@ -9143,6 +9143,14 @@
       "resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-4.0.0.tgz",
       "integrity": "sha512-FlsPxavEyMuR6TjVbSSywovXSEyOg6ZDj5+Z8nbsRl9EkOzAhEIcS+GLoQDC5fz/t9suhUXWmUrOBrgeUvrMxw=="
     },
+    "react-input-autosize": {
+      "version": "2.2.1",
+      "resolved": "https://registry.npmjs.org/react-input-autosize/-/react-input-autosize-2.2.1.tgz",
+      "integrity": "sha512-3+K4CD13iE4lQQ2WlF8PuV5htfmTRLH6MDnfndHM6LuBRszuXnuyIfE7nhSKt8AzRBZ50bu0sAhkNMeS5pxQQA==",
+      "requires": {
+        "prop-types": "15.6.1"
+      }
+    },
     "react-scripts": {
       "version": "1.1.1",
       "resolved": "https://registry.npmjs.org/react-scripts/-/react-scripts-1.1.1.tgz",
@@ -9198,6 +9206,14 @@
         }
       }
     },
+    "react-select": {
+      "version": "git://github.com/enikesha/react-select.git#c43000630899b664da4e77d43b28310c5836835a",
+      "requires": {
+        "classnames": "2.2.5",
+        "prop-types": "15.6.1",
+        "react-input-autosize": "2.2.1"
+      }
+    },
     "react-sound": {
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/react-sound/-/react-sound-1.1.0.tgz",
@@ -9219,6 +9235,17 @@
         "prop-types": "15.6.1"
       }
     },
+    "react-virtualized-select": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/react-virtualized-select/-/react-virtualized-select-3.1.3.tgz",
+      "integrity": "sha512-u6j/EfynCB9s4Lz5GGZhNUCZHvFQdtLZws7W/Tcd/v03l19OjpQs3eYjK82iYS0FgD2+lDIBpqS8LpD/hjqDRQ==",
+      "requires": {
+        "babel-runtime": "6.26.0",
+        "prop-types": "15.6.1",
+        "react-select": "git://github.com/enikesha/react-select.git#c43000630899b664da4e77d43b28310c5836835a",
+        "react-virtualized": "9.18.5"
+      }
+    },
     "read-pkg": {
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-1.1.0.tgz",

+ 3 - 1
front/package.json

@@ -6,8 +6,10 @@
     "react": "^16.2.0",
     "react-dom": "^16.2.0",
     "react-scripts": "1.1.1",
+    "react-select": "git://github.com/enikesha/react-select.git#feature/async-pagination",
     "react-sound": "^1.1.0",
-    "react-virtualized": "^9.18.5"
+    "react-virtualized": "^9.18.5",
+    "react-virtualized-select": "^3.1.3"
   },
   "proxy": "http://localhost:5000/",
   "scripts": {

+ 18 - 0
front/src/AlbumList.css

@@ -0,0 +1,18 @@
+.AlbumList {
+  clear: both;
+  padding-top: 15px;
+}
+.AlbumList img {
+  width: 64px;
+  height: 64px;
+}
+.AlbumList table td {
+  width: 200px;
+}
+.AlbumList table td:nth-child(1) { width: 75px; }
+.AlbumList table td:nth-child(2) { width: 64px; }
+.AlbumList table td:nth-child(3) { width: 256px; }
+.AlbumList table td:nth-child(5) { width: 64px; }
+.AlbumList table td:nth-child(7) { width: 64px; }
+.AlbumList table td:nth-child(8) { width: 64px; }
+.AlbumList table td:nth-child(9) { width: 64px; }

+ 66 - 0
front/src/AlbumList.js

@@ -0,0 +1,66 @@
+import React from 'react';
+import {formatDuration} from './utils.js';
+import logo from './logo.svg';
+import './AlbumList.css';
+
+function mapAlbums(items) {
+  var artists = [], curArtist = {}, curType = {};
+  for (const album of items) {
+    if (album.artist !== curArtist.name) {
+      curArtist = {name: album.artist, types: []}
+      curType = {}
+      artists.push(curArtist)
+    }
+    if (album.type !== curType.type) {
+      curType = {type: album.type, albums: []}
+      curArtist.types.push(curType)
+    }
+    curType.albums.push(album);
+  }
+  return artists;
+}
+
+export default function AlbumList(props) {
+  const { error, isLoaded, items } = props.state;
+
+  var content;
+  if (error) {
+    content = <div>Error: {error.message}</div>
+  } else if (!isLoaded) {
+    content = <div>Loading</div>
+  } else {
+    const artists = mapAlbums(items);
+
+    content = artists.map(artist => (
+      <div key={artist.name}>
+        <h3>{artist.name}</h3>
+        {artist.types.map(type => (
+          <div key={type.type}>
+            <h4>{type.type}</h4>
+            <table><tbody>
+            {type.albums.map(album => (
+              <tr key={album.id}>
+                <td><img src={album.cover || logo} alt={album.album} /></td>
+                <td>{album.year}</td>
+                <td>{album.album}</td>
+                <td>{album.publisher}</td>
+                <td>{album.country}</td>
+                <td>{album.genre}</td>
+                <td>{album.track_count}</td>
+                <td>{formatDuration(album.total_duration)}</td>
+                <td><button onClick={() => props.onPlayAlbum(album)}>play</button></td>
+              </tr>
+            ))}
+            </tbody></table>
+          </div>
+        ))}
+      </div>
+    ))
+  }
+  return (
+    <div className="AlbumList">
+      <h2>{props.title}</h2>
+      {content}
+    </div>
+  );
+}

+ 17 - 27
front/src/App.css

@@ -5,17 +5,19 @@
 .App-logo {
   animation: App-logo-spin infinite 20s linear;
   height: 80px;
+  float: left;
 }
 
 .App-header {
   background-color: #222;
-  height: 150px;
-  padding: 20px;
+  height: 80px;
   color: white;
 }
 
 .App-title {
   font-size: 1.5em;
+  margin: 0;
+  line-height: 80px;
 }
 
 .App-intro {
@@ -32,38 +34,26 @@
   padding: 0 20px;
 }
 
-.category-list {
+.section {
   float: left;
-  padding: 0 10px;
-}
-.category-list select {
-  min-width: 150px;
+  padding: 0 10px 0 0;
+  width: 256px;
 }
+.section:nth-child(2) { width: 128px; }
+.section:nth-child(5) { width: 128px; }
 
-.AlbumList {
-  clear: both;
-  padding-top: 15px;
-}
-.AlbumList img {
-  width: 64px;
-  height: 64px;
-}
-.AlbumList table td {
-  width: 200px;
-}
-.AlbumList table td:nth-child(1) { width: 75px; }
-.AlbumList table td:nth-child(2) { width: 64px; }
-.AlbumList table td:nth-child(3) { width: 256px; }
-.AlbumList table td:nth-child(5) { width: 64px; }
-.AlbumList table td:nth-child(7) { width: 64px; }
-.AlbumList table td:nth-child(8) { width: 64px; }
-.AlbumList table td:nth-child(9) { width: 64px; }
+.Filter {
+  width: 100%;
+  padding: 9px;
+  border: 1px solid gray;
+  border-radius: 4px;
+}
 
 .Controls {
-  margin: 10px 0;
+  margin: 16px 0;
 }
 .Controls-progress {
-  width: 512px;
+  width: 540px;
   border: 1px black solid;
   height: 16px;
   position: relative;

+ 124 - 219
front/src/App.js

@@ -1,228 +1,68 @@
+import "react-select/dist/react-select.css";
+import "react-virtualized/styles.css";
+import "react-virtualized-select/styles.css";
+
 import React, { Component } from 'react';
+import Select from "react-virtualized-select";
 import Sound from 'react-sound';
+import AlbumList from './AlbumList.js';
 import MediaSession from './MediaSession.js';
+import Controls from './Controls.js';
+import Playlist from './Playlist.js';
+import {getQueryString} from './utils.js';
 import logo from './logo.svg';
 import './App.css';
 
-function CategoryFlatList(props) {
-  const { error, isLoaded, items } = props.state;
-  var content;
-  if (error) {
-    content = <div>Error: {error.message}</div>
-  } else if (!isLoaded) {
-    content = <div>Loading</div>
-  } else {
-    content = (
-        <select size="10" defaultValue="" onChange={props.onChange}>
-        <option value="">[ANY]</option>
-        {items.map(item => (
-            <option key={item.item} value={item.item}>{item.item} - {item.count}</option>
-        ))}
-      </select>
-    );
-  }
-  return (
-      <div className="category-list">
-      <h2>{props.title}</h2>
-      {content}
-    </div>
-  );
-}
-
-function formatDuration(duration) {
-  duration = Math.floor(duration);
-  var seconds = duration % 60;
-  const minutes = Math.floor(duration / 60);
-  if (seconds < 10) {
-    seconds = '0' + seconds;
-  }
-  return minutes + ':' + seconds;
-}
-
-function mapAlbums(items) {
-  var artists = [], curArtist = {}, curType = {};
-  for (const album of items) {
-    if (album.artist !== curArtist.name) {
-      curArtist = {name: album.artist, types: []}
-      curType = {}
-      artists.push(curArtist)
-    }
-    if (album.type !== curType.type) {
-      curType = {type: album.type, albums: []}
-      curArtist.types.push(curType)
-    }
-    curType.albums.push(album);
-  }
-  return artists;
-}
-
-function AlbumList(props) {
-  const { error, isLoaded, items } = props.state;
-
-  var content;
-  if (error) {
-    content = <div>Error: {error.message}</div>
-  } else if (!isLoaded) {
-    content = <div>Loading</div>
-  } else {
-    const artists = mapAlbums(items);
-
-    content = artists.map(artist => (
-      <div key={artist.name}>
-        <h3>{artist.name}</h3>
-        {artist.types.map(type => (
-          <div key={type.type}>
-            <h4>{type.type}</h4>
-            <table><tbody>
-            {type.albums.map(album => (
-              <tr key={album.id}>
-                <td><img src={album.cover || logo} alt={album.album} /></td>
-                <td>{album.year}</td>
-                <td>{album.album}</td>
-                <td>{album.publisher}</td>
-                <td>{album.country}</td>
-                <td>{album.genre}</td>
-                <td>{album.track_count}</td>
-                <td>{formatDuration(album.total_duration)}</td>
-                <td><button onClick={() => props.onPlayAlbum(album)}>play</button></td>
-              </tr>
-            ))}
-            </tbody></table>
-          </div>
-        ))}
-      </div>
-    ))
-  }
-  return (
-    <div className="AlbumList">
-      <h2>{props.title}</h2>
-      {content}
-    </div>
-  );
-}
-
-function Controls(props) {
-  const playPause = (props.playStatus === Sound.status.PLAYING ? 'pause' : 'play');
-  const percent = (props.duration ? (100 * props.position / props.duration) : 0);
-  const elapsed = (props.position ? formatDuration(props.position/1000) : '');
-  const duration = (props.duration ? formatDuration(props.duration/1000) : '');
-  return (
-      <div className="Controls">
-        <div className="Controls-progress">
-          <div className="Controls-bar" style={{width:percent+'%'}} />
-          <span className="Controls-elapsed">{elapsed}</span>
-          <span className="Controls-duration">{duration}</span>
-        </div>
-        <div className="Controls-title">{props.title}</div>
-        <div className="Controls-buttons">
-          <button onClick={props.onPrev}>prev</button>
-          <button onClick={props.onPlayPause}>{playPause}</button>
-          <button onClick={props.onStop}>stop</button>
-          <button onClick={props.onNext}>next</button>
-        </div>
-      </div>
-  );
-}
-
-function Playlist(props) {
-  const {tracks} = props
-  if (!tracks) {
-    return null;
-  }
-  if (!Array.isArray(tracks)) {
-    return <div className="Playlist-error">{tracks}</div>;
-  }
-  return (
-    <table className="Playlist"><tbody>
-      {tracks.map((track, i) => (
-      <tr key={i} className={ i === props.activeTrack ? "Playlist-active" : "" }>
-        <td>{track.no}</td>
-        <td>{track.title}</td>
-        <td>{track.artist}</td>
-        <td>{track.year}</td>
-        <td>{track.album}</td>
-        <td>{track.bit_rate} kbps</td>
-        <td>{formatDuration(track.duration)}</td>
-        <td><button onClick={() => props.onActivateTrack(i)}>play</button></td>
-      </tr>))}
-    </tbody></table>
-  )
-}
+const fetchOpts = { credentials: 'same-origin' }
 
 class Player extends Component {
-  stateLoading = {
-    error: null,
-    isLoaded: false,
-    items: []
-  };
+  selects = [{type: 'artist', title: 'Artists'},
+             {type: 'year', title: 'Years'},
+             {type: 'genre', title: 'Genres'},
+             {type: 'publisher', title: 'Labels'},
+             {type: 'country', title: 'Countries'},
+             {type: 'type', title: 'Album types'},
+             {type: 'status', title: 'Album statuses'}];
   constructor(props) {
     super(props);
-    this.categories = [{type: 'artist', title: 'Artists'},
-                       {type: 'year', title: 'Years'},
-                       {type: 'genre', title: 'Genres'},
-                       {type: 'publisher', title: 'Labels'},
-                       {type: 'country', title: 'Countries'},
-                       {type: 'type', title: 'Album types'},
-                       {type: 'status', title: 'Album statuses'}];
     this.state = {
-      album: Object.assign({}, this.stateLoading),
+      album: {
+        error: null,
+        isLoaded: false,
+        items: []
+      },
+      filters: {},
+      filter: '',
       sound: {url: '', playStatus: Sound.status.STOPPED},
       tracks: null,
       activeTrack: -1,
       activeAlbum: null,
       metadata: {artist: '', album: '', title: '', cover: ''}
     };
-    for (var {type} of this.categories) {
-      this.state[type] = Object.assign({}, this.stateLoading);
-    }
     this.filterTimeout = null;
   }
-  fetchCategory(cat) {
-    const {restrictions, filter} = this.state;
-
-    function getQueryString(params) {
-      var esc = encodeURIComponent;
-      return Object.keys(params)
-        .map(k => esc(k) + '=' + esc(params[k]))
-        .join('&');
-    }
-    var params = Object.assign({}, restrictions, {offset:0, limit:100});
-    if (cat === 'album' && filter) {
-      params.filter = filter;
+  loadAlbums() {
+    const {filter, filters} = this.state
+    const page = 1
+    const pageSize = 30
+    const params = {filter, offset: (page - 1) * pageSize, limit: pageSize};
+    for (const {type} of this.selects) {
+      if (filters[type]) {
+        params[type] = filters[type].item;
+      }
     }
-    const qs = getQueryString(params);
-
-    fetch('/cat/' + cat + (qs ? ('?' + qs) : ''))
+    return fetch(`/cat/album?${getQueryString(params)}`, fetchOpts)
       .then(res => (res.ok ? res.json() : Promise.reject({message:res.statusText})))
-      .then(result => {
-        this.setState({[cat]: {
-          isLoaded: true,
-          items: result
-        }});
-      })
-      .catch(error => {
-        this.setState({[cat]: {
-          isLoaded: true,
-          error: error
-        }});
-      });
-    this.setState({[cat]: Object.assign({}, this.stateLoading)});
-  }
-  fetchAll() {
-    const {restrictions} = this.state;
-    console.log(restrictions);
-    this.categories.map(({type}) => !restrictions[type] && this.fetchCategory(type))
-    this.fetchCategory('album');
+      .then(items => this.setState({album:{items, isLoaded: true}}))
+      .catch(error => this.setState({album:{error, items:[]}}))
   }
   componentDidMount() {
-    this.setState({restrictions: {}});
+    this.loadAlbums();
   }
   componentDidUpdate(prevProps, prevState) {
-    if (this.state.restrictions !== prevState.restrictions) {
-      // Reload category filters on restriction changes
-      this.fetchAll();
-    } else if (this.state.filter !== prevState.filter) {
-      this.fetchCategory('album');
+    const {filters} = this.state;
+    if (filters !== prevState.filters) {
+      this.loadAlbums();
     }
     if (this.state.activeAlbum !== prevState.activeAlbum) {
       this.fetchTracks(this.state.activeAlbum);
@@ -251,28 +91,19 @@ class Player extends Component {
     }
   }
   fetchTracks(album) {
-    fetch(`/album/${album.id}/tracks`)
+    fetch(`/album/${album.id}/tracks`, fetchOpts)
       .then(res => (res.ok ? res.json() : Promise.reject({message:res.statusText})))
       .then(result => this.setState({tracks: result, activeTrack: 0}))
       .catch(error => this.setState({tracks: error.message}));
     this.setState({tracks: "Loading", activeTrack: -1})
   }
-  handleCategoryChange(type, value) {
-    console.log(type, value);
-    var restrictions = Object.assign({}, this.state.restrictions);
-    if (value) {
-      restrictions[type] = value;
-    } else {
-      delete restrictions[type];
-    }
-    this.setState({restrictions: restrictions});
-  }
   handleFilterChange = (e) => {
     if (this.filterTimeout) {
       clearInterval(this.filterTimeout);
     }
-    const value = e.target.value;
-    this.filterTimeout = setTimeout(() => this.setState({filter:value}), 500);
+    const filter = e.target.value;
+    this.filterTimeout = setTimeout(() => this.loadAlbums(), 500);
+    this.setState({filter})
   }
   handleControlPrev = () => {
     if (!Array.isArray(this.state.tracks)) {
@@ -330,16 +161,90 @@ class Player extends Component {
   handlePlayAlbum = (album) => {
     this.setState({activeAlbum: album})
   }
+  handleLoadOptions(cat, q, page) {
+    const pageSize = 100
+    const params = {filter:q}
+    params.offset = (page - 1) * pageSize;
+    params.limit = pageSize;
+    return fetch(`/cat/${cat}?${getQueryString(params)}`, fetchOpts)
+      .then(res => (res.ok ? res.json() : Promise.reject({message:res.statusText})))
+      .then(options => ({options}))
+  }
+  handleSelectChange(cat, option) {
+    const filters = Object.assign({}, this.state.filters,  {[cat]: option});
+    this.setState({filters, filter: ''})
+  }
+
+  _optionRenderer ({ focusedOption, focusOption, key, labelKey, option, selectValue, style, valueArray }) {
+    const className = ['VirtualizedSelectOption']
+
+    if (option === focusedOption) {
+      className.push('VirtualizedSelectFocusedOption')
+    }
+
+    if (option.disabled) {
+      className.push('VirtualizedSelectDisabledOption')
+    }
+
+    if (valueArray && valueArray.indexOf(option) >= 0) {
+      className.push('VirtualizedSelectSelectedOption')
+    }
+
+    if (option.className) {
+      className.push(option.className)
+    }
+
+    const events = option.disabled
+      ? {}
+      : {
+        onClick: () => selectValue(option),
+        onMouseEnter: () => focusOption(option)
+      }
+
+    return (
+      <div
+        className={className.join(' ')}
+        key={key}
+        style={style}
+        title={option.title}
+        {...events}
+      >
+        {option.item} - {option.count}
+      </div>
+    )
+  }
   render() {
     return (
       <div className="Player">
         <div className="PlayerFilters">
-        {this.categories.map(({type, title}) => (
-          <CategoryFlatList key={type} title={title} state={this.state[type]} onChange={e => this.handleCategoryChange(type, e.target.value)}/>
-        ))}
-        </div><div style={{clear:'both', paddingBottom:'20px'}}/>
-        <input type="text" placeholder="Search albums" onChange={this.handleFilterChange} size={150} />
-        <Controls {...this.state.sound}
+          {this.selects.map(({type, title}) => (
+            <div className="section" key={type}>
+              <h3>{title}</h3>
+              <Select
+                async
+                pagination
+                autoload={true}
+                optionRenderer={this._optionRenderer}
+                loadOptions={(q,p) => this.handleLoadOptions(type, q, p)}
+                onChange={o => this.handleSelectChange(type, o)}
+                labelKey="item"
+                valueKey="item"
+                value={this.state.filters[type]}
+              />
+            </div>
+          ))}
+          <div className="section">
+            <h3>Search</h3>
+            <input className="Filter"
+              type="text"
+              value={this.state.filter}
+              placeholder="Search albums"
+              onChange={this.handleFilterChange} />
+          </div>
+        </div><br style={{clear:'both'}} />
+        <Controls
+          visible={true}
+          {...this.state.sound}
           onPrev={this.handleControlPrev}
           onPlayPause={this.handleControlPlayPause}
           onStop={this.handleControlStop}

+ 29 - 0
front/src/Controls.js

@@ -0,0 +1,29 @@
+import React from 'react';
+import Sound from 'react-sound';
+import {formatDuration} from './utils.js';
+
+export default function Controls(props) {
+  const playPause = (props.playStatus === Sound.status.PLAYING ? 'pause' : 'play');
+  const percent = (props.duration ? (100 * props.position / props.duration) : 0);
+  const elapsed = (props.position ? formatDuration(props.position/1000) : '');
+  const duration = (props.duration ? formatDuration(props.duration/1000) : '');
+  if (props.visible === false) {
+    return null;
+  }
+  return (
+      <div className="Controls">
+        <div className="Controls-progress">
+          <div className="Controls-bar" style={{width:percent+'%'}} />
+          <span className="Controls-elapsed">{elapsed}</span>
+          <span className="Controls-duration">{duration}</span>
+        </div>
+        <div className="Controls-title">{props.title}</div>
+        <div className="Controls-buttons">
+          <button onClick={props.onPrev}>prev</button>
+          <button onClick={props.onPlayPause}>{playPause}</button>
+          <button onClick={props.onStop}>stop</button>
+          <button onClick={props.onNext}>next</button>
+        </div>
+      </div>
+  );
+}

+ 27 - 0
front/src/Playlist.js

@@ -0,0 +1,27 @@
+import React from 'react';
+import {formatDuration} from './utils.js';
+
+export default function Playlist(props) {
+  const {tracks} = props
+  if (!tracks) {
+    return null;
+  }
+  if (!Array.isArray(tracks)) {
+    return <div className="Playlist-error">{tracks}</div>;
+  }
+  return (
+    <table className="Playlist"><tbody>
+      {tracks.map((track, i) => (
+      <tr key={i} className={ i === props.activeTrack ? "Playlist-active" : "" }>
+        <td>{track.no}</td>
+        <td>{track.title}</td>
+        <td>{track.artist}</td>
+        <td>{track.year}</td>
+        <td>{track.album}</td>
+        <td>{track.bit_rate} kbps</td>
+        <td>{formatDuration(track.duration)}</td>
+        <td><button onClick={() => props.onActivateTrack(i)}>play</button></td>
+      </tr>))}
+    </tbody></table>
+  )
+}

+ 18 - 0
front/src/utils.js

@@ -0,0 +1,18 @@
+export function formatDuration(duration) {
+  duration = Math.floor(duration);
+  var seconds = duration % 60;
+  const minutes = Math.floor(duration / 60);
+  if (seconds < 10) {
+    seconds = '0' + seconds;
+  }
+  return minutes + ':' + seconds;
+}
+
+export function getQueryString(params) {
+  var esc = encodeURIComponent;
+  return Object.keys(params)
+    .map(k => esc(k) + '=' + esc(params[k]))
+    .join('&');
+}
+
+export default {formatDuration, getQueryString}