Selaa lähdekoodia

[web] Routing with artist/album pages.

Innokentiy Enikeev 4 vuotta sitten
vanhempi
commit
de42234407

+ 62 - 0
web/package-lock.json

@@ -10923,6 +10923,15 @@
       "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz",
       "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw=="
     },
+    "path-parser": {
+      "version": "6.1.0",
+      "resolved": "https://registry.npmjs.org/path-parser/-/path-parser-6.1.0.tgz",
+      "integrity": "sha512-nAB6J73z2rFcQP+870OHhpkHFj5kO4rPLc2Ol4Y3Ale7F6Hk1/cPKp7cQ8RznKF8FOSvu+YR9Xc6Gafk7DlpYA==",
+      "requires": {
+        "search-params": "3.0.0",
+        "tslib": "^1.10.0"
+      }
+    },
     "path-to-regexp": {
       "version": "0.1.7",
       "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz",
@@ -12582,6 +12591,14 @@
         "tiny-warning": "^1.0.0"
       }
     },
+    "react-router5": {
+      "version": "8.0.1",
+      "resolved": "https://registry.npmjs.org/react-router5/-/react-router5-8.0.1.tgz",
+      "integrity": "sha512-jdkhOql49DOjeF+O77mSfnyOyrXPpQKpSi/TdrDkS9lGrzeJWtgxpUEglHLaIhzrMOGhmqr6vosoh4dtExkzMQ==",
+      "requires": {
+        "router5-transition-path": "^8.0.1"
+      }
+    },
     "react-scripts": {
       "version": "4.0.3",
       "resolved": "https://registry.npmjs.org/react-scripts/-/react-scripts-4.0.3.tgz",
@@ -13268,6 +13285,41 @@
         }
       }
     },
+    "route-node": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/route-node/-/route-node-4.1.1.tgz",
+      "integrity": "sha512-bJVDVEQvXYqTC/LQ/2ohNZ2gPRO4+3ZvHeafuCyRZAd63qH17yspVfxOBzasE/t7SmSO30sT/yPSEMuPDGHhSQ==",
+      "requires": {
+        "path-parser": "6.1.0",
+        "search-params": "3.0.0",
+        "tslib": "^1.10.0"
+      }
+    },
+    "router5": {
+      "version": "8.0.1",
+      "resolved": "https://registry.npmjs.org/router5/-/router5-8.0.1.tgz",
+      "integrity": "sha512-LBLQAYd2ZI1FiiBqGf82oC+LRD0PUABrZqTzd6OmyK+t5N8wRs3hbIxeiy/9Zy+xI8Fdf/0WM68P41W6MH8pdQ==",
+      "requires": {
+        "route-node": "4.1.1",
+        "router5-transition-path": "^8.0.1",
+        "symbol-observable": "1.2.0"
+      }
+    },
+    "router5-plugin-browser": {
+      "version": "8.0.1",
+      "resolved": "https://registry.npmjs.org/router5-plugin-browser/-/router5-plugin-browser-8.0.1.tgz",
+      "integrity": "sha512-RuArnalkREtQj0ROD1RnITWwL+Fl8rbkFspDxRTt3aFG9k5bPOE0HfJRvGsd4TY9Je/kW3hvntvFXSx8Bl745w=="
+    },
+    "router5-plugin-logger": {
+      "version": "8.0.1",
+      "resolved": "https://registry.npmjs.org/router5-plugin-logger/-/router5-plugin-logger-8.0.1.tgz",
+      "integrity": "sha512-uGwLqKy8NswpceLMCW+sc1c84vKbRzHCcJS69GLS88ppLtiSRNdxBZ2wB2mjBp+6akv8svH09SGthxnc2GHsTA=="
+    },
+    "router5-transition-path": {
+      "version": "8.0.1",
+      "resolved": "https://registry.npmjs.org/router5-transition-path/-/router5-transition-path-8.0.1.tgz",
+      "integrity": "sha512-l7MFEUmasEhe1emrO79t7BkCqQQRZdenz4c3qJmZpe8V6BCerZQR+SYbZL5zmQ3hazY7nR264jCWuNed+cMD2A=="
+    },
     "rsvp": {
       "version": "4.8.5",
       "resolved": "https://registry.npmjs.org/rsvp/-/rsvp-4.8.5.tgz",
@@ -13498,6 +13550,11 @@
         "ajv-keywords": "^3.5.2"
       }
     },
+    "search-params": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/search-params/-/search-params-3.0.0.tgz",
+      "integrity": "sha512-8CYNl/bjkEhXWbDTU/K7c2jQtrnqEffIPyOLMqygW/7/b+ym8UtQumcAZjOfMLjZKR6AxK5tOr9fChbQZCzPqg=="
+    },
     "select-hose": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz",
@@ -14438,6 +14495,11 @@
         "util.promisify": "~1.0.0"
       }
     },
+    "symbol-observable": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz",
+      "integrity": "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ=="
+    },
     "symbol-tree": {
       "version": "3.2.4",
       "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",

+ 5 - 0
web/package.json

@@ -7,13 +7,18 @@
     "@testing-library/react": "^11.2.5",
     "@testing-library/user-event": "^12.8.3",
     "immer": "^8.0.3",
+    "path-parser": "^6.1.0",
     "react": "^17.0.1",
     "react-dom": "^17.0.1",
     "react-infinite-scroller": "^1.2.4",
     "react-router-dom": "^5.2.0",
+    "react-router5": "^8.0.1",
     "react-scripts": "4.0.3",
     "react-select": "^4.3.0",
     "react-sound": "^1.2.0",
+    "router5": "^8.0.1",
+    "router5-plugin-browser": "^8.0.1",
+    "router5-plugin-logger": "^8.0.1",
     "web-vitals": "^1.1.1"
   },
   "proxy": "http://localhost:5000/",

+ 40 - 0
web/src/Album.js

@@ -0,0 +1,40 @@
+import logo from './logo.svg';
+import { Link } from 'react-router5'
+import {formatDuration, slugify} from './utils';
+import {getTracks, request} from './Api';
+
+export function fetchTracks(album_id, type, dispatch) {
+  const {url, options} = getTracks(album_id);
+  request(url, options)
+    .then(payload => dispatch({type, payload: {tracks: payload}}))
+    .catch(e => console.log(e))
+}
+
+
+export default function Album({album, showArtist, showType, dispatch}) {
+  const artist = slugify(album.artist);
+
+  return (
+    <div>
+      {showArtist && (<h3><Link routeName="artist" routeParams={{artist}}>{album.artist}</Link></h3>)}
+      {showType && (<h4>{album.type}</h4>)}
+      <div className="Album">
+        <img src={album.cover || logo} alt={album.album} />
+        <span className="Album-year">{album.year}</span>
+        <span className="Album-country">{album.country}</span>
+        <span className="Album-tracks">{album.track_count}&nbsp;@&nbsp;{formatDuration(album.total_duration)}</span>
+        <span className="Album-title"><Link routeName='album' routeParams={{artist, album: slugify(album.album)}}>{album.album}</Link></span>
+        <span className="Album-publisher">{album.publisher}</span>
+        <span className="Album-genre">{album.genre}</span>
+        <div className="Album-actions">
+          <button onClick={(e)=>fetchTracks(album.id, 'ALBUM_PLAY', dispatch)}>
+            <svg viewBox="0 0 24 24"><g><path d="M8 5v14l11-7z"/></g></svg>
+          </button>
+          <button onClick={(e)=>fetchTracks(album.id, 'ALBUM_ENQUEUE', dispatch)}>
+            <svg viewBox="0 0 24 24"><g><path d="M10 6h4v4h4v4h-4v4h-4v-4h-4v-4h4z"/></g></svg>
+          </button>
+        </div>
+      </div>
+    </div>
+  )
+}

+ 9 - 42
web/src/AlbumList.js

@@ -1,57 +1,24 @@
 import './AlbumList.css';
 import InfiniteScroll from 'react-infinite-scroller';
 import {useEffect} from 'react';
-import {formatDuration} from './utils';
-import logo from './logo.svg';
-import {getAlbums, getTracks, request} from './Api';
+import {getAlbums, request} from './Api';
+import Album from './Album';
 
-function _albumProps(albums, index) {
+function _albumProps(albums, index, props) {
   if (index === 0) {
-    return {showArtist: true, showType: true}
+    return {showArtist: !props.noArtist, showType: true}
   }
 
   if (albums[index-1].artist !== albums[index].artist) {
-    return {showArtist: true, showType: true}
+    return {showArtist: !props.noArtist, showType: true}
   }
 
   let showType = (albums[index-1].type !== albums[index].type);
   return {showType};
 }
 
-function fetchTracks(album_id, type, dispatch) {
-  const {url, options} = getTracks(album_id);
-  request(url, options)
-    .then(payload => dispatch({type, payload}))
-    .catch(e => console.log(e))
-}
-
-function Album({album, showArtist, showType, dispatch}) {
-  return (
-    <div>
-      {showArtist && (<h3>{album.artist}</h3>)}
-      {showType && (<h4>{album.type}</h4>)}
-      <div className="Album">
-        <img src={album.cover || logo} alt={album.album} />
-        <span className="Album-year">{album.year}</span>
-        <span className="Album-country">{album.country}</span>
-        <span className="Album-tracks">{album.track_count}&nbsp;@&nbsp;{formatDuration(album.total_duration)}</span>
-        <span className="Album-title">{album.album}</span>
-        <span className="Album-publisher">{album.publisher}</span>
-        <span className="Album-genre">{album.genre}</span>
-        <div className="Album-actions">
-          <button onClick={(e)=>fetchTracks(album.id, 'ALBUM_PLAY', dispatch)}>
-            <svg viewBox="0 0 24 24"><g><path d="M8 5v14l11-7z"/></g></svg>
-          </button>
-          <button onClick={(e)=>fetchTracks(album.id, 'ALBUM_ENQUEUE', dispatch)}>
-            <svg viewBox="0 0 24 24"><g><path d="M10 6h4v4h4v4h-4v4h-4v-4h-4v-4h4z"/></g></svg>
-          </button>
-        </div>
-      </div>
-    </div>
-  )
-}
 
-function fetchAlbums(filters, page, dispatch) {
+export function fetchAlbums(filters, page, dispatch) {
   const {filter, categories, latest} = filters;
   const limit  = 15;
   const offset = page * limit;
@@ -74,7 +41,7 @@ function fetchAlbums(filters, page, dispatch) {
 
 export default function AlbumList(props) {
   const {albums, error, filters, dispatch, playerDispatch, scrollRef} = props;
-  const title = filters.latest ? 'Latest' : 'Alphabetical';
+  const title = (!props.noTitle && (filters.latest ? 'Latest' : 'Alphabetical'));
 
   useEffect(()=>{
     fetchAlbums(filters, albums.page, dispatch);
@@ -82,7 +49,7 @@ export default function AlbumList(props) {
 
   return (
     <div className="AlbumList">
-      <h2>{title}</h2>
+      {title && <h2>{title}</h2>}
       { error ? (
         <div className="error">{error}></div>
       ) : (
@@ -95,7 +62,7 @@ export default function AlbumList(props) {
           useWindow={false}
           >
           {albums.items.map((album, idx) => (
-            <Album key={idx} album={album} dispatch={playerDispatch} {..._albumProps(albums.items, idx)} />
+              <Album key={idx} album={album} dispatch={playerDispatch} {..._albumProps(albums.items, idx, props)}/>
           ))}
         </InfiniteScroll>
       )

+ 6 - 0
web/src/AlbumPage.css

@@ -0,0 +1,6 @@
+.AlbumPage table button {
+  padding: 0;
+  width: 24px;
+  height: 24px;
+  vertical-align: bottom;
+}

+ 76 - 0
web/src/AlbumPage.js

@@ -0,0 +1,76 @@
+import './AlbumPage.css';
+import { Link } from 'react-router5'
+import {useRouteNode} from 'react-router5'
+import {unslugify, formatDuration} from './utils.js';
+import {useReducer, useEffect} from 'react';
+import {fetchAlbums} from './AlbumList';
+import {fetchTracks} from './Album';
+
+function pageReducer(state, action) {
+  console.log(action);
+  switch (action.type) {
+  case 'LOAD_ALBUMS':
+    if (!action.payload.items || !action.payload.items.length)
+      return {error: 'Not found'};
+    const album = action.payload.items[0];
+    return {...state, album};
+  case 'SHOW_ALBUM':
+    const tracks = action.payload.tracks;
+    return {...state, tracks, loading: false};
+  default: throw new Error(`Unknown action type: ${action.type}`);
+  }
+}
+
+export default function AlbumPage({dispatch, scrollRef}) {
+  const { route } = useRouteNode('album');
+  const { artist: artistSlug, album: albumSlug } = route.params;
+  const artistName = unslugify(artistSlug);
+  const albumName = unslugify(albumSlug);
+  const [{album, tracks, loading, error}, dispatchPage] = useReducer(pageReducer, {loading: true});
+
+  useEffect(()=>{
+    if (album)
+      fetchTracks(album.id, 'SHOW_ALBUM', dispatchPage)
+    else
+      fetchAlbums({categories: {artist: artistName,
+                                album: albumName},
+                   filter: ''}, 0, dispatchPage);
+  }, [artistName, albumName, album, dispatch]);
+
+  return (
+    <div className="AlbumPage">
+      <h1>
+        <Link routeName="artist" routeParams={{artist: artistSlug}}>
+          {artistName}
+        </Link>
+      </h1>
+      <h2>{albumName}</h2>
+      {album && (<>
+        {album.date && <h3>Date: {album.year}</h3>}
+        {album.country && <h3>Country: {album.country}</h3>}
+        {album.track_count && <h3>Tracks: {album.track_count}</h3>}
+        {album.total_duration && <h3>Duration: {formatDuration(album.total_duration)}</h3>}
+        {album.publisher && <h3>Publisher: {album.publisher}</h3>}
+        {album.genre && <h3>Genre: {album.genre}</h3>}
+      </>)}
+      <Link routeName="browser">Browse</Link>
+      {loading && <h3>Loading...</h3>}
+      {error && <h3>{ error }</h3>}
+    {tracks && (
+      <table><tbody>
+          {tracks.map((track, i) => (
+            <tr key={i}>
+              <td>{track.no}.</td>
+              <td>{track.title}</td>
+              <td>{track.bit_rate} kbps</td>
+              <td>{formatDuration(track.duration)}</td>
+              <td><button onClick={() => dispatch({type:'ALBUM_PLAY', payload: {tracks, pos: i}})}>
+                  <svg viewBox="0 0 24 24"><g><path d="M8 5v14l11-7z"/></g></svg>
+              </button></td>
+            </tr>))
+          }
+      </tbody></table>
+    )}
+    </div>
+  );
+}

+ 5 - 1
web/src/App.js

@@ -1,3 +1,4 @@
+import { BaseLink, useRoute } from 'react-router5'
 import {useReducer} from 'react';
 import './App.css';
 import logo from './chad-logo-256.png';
@@ -31,6 +32,7 @@ function settingsReducer(state, action) {
 }
 
 function App() {
+  const { router } = useRoute()
   const [settings, dispatch] = useReducer(settingsReducer, loadSettings());
   if(!loadToken()) {
     return <Login state={settings} dispatch={dispatch} />
@@ -39,7 +41,9 @@ function App() {
   return (
     <div className="App" data-theme={settings.theme} data-layout={settings.layout}>
       <header className="App-header">
-        <img src={logo} className="App-logo" alt="Chad music" />
+        <BaseLink router={router} routeName="browser" routeOptions={{ reload: true }}>
+          <img src={logo} className="App-logo" alt="Chad music" />
+        </BaseLink>
         <h1 className="App-title">Chad Music</h1>
         <Settings state={settings} dispatch={dispatch} />
         <UserInfo/>

+ 21 - 0
web/src/ArtistPage.js

@@ -0,0 +1,21 @@
+import { Link } from 'react-router5'
+import {useRouteNode} from 'react-router5'
+import {unslugify} from './utils'
+import {useReducer} from 'react';
+import AlbumList from './AlbumList';
+import {browserReducer, ALBUMS_EMPTY} from './Browser';
+
+export default function ArtistPage({dispatch, scrollRef}) {
+  const { route } = useRouteNode('artist');
+  const artist = unslugify(route.params.artist);
+  const INIT = {albums: ALBUMS_EMPTY, error: false, filters: {categories: {artist},
+                                                              filter:''}};
+  const [state, dispatchBrowser] = useReducer(browserReducer, INIT);
+
+  return (
+    <>
+      <h1>{artist}</h1><Link routeName="browser">Browse</Link>
+      <AlbumList {...state} dispatch={dispatchBrowser} playerDispatch={dispatch} scrollRef={scrollRef} noTitle noArtist/>
+    </>
+  );
+}

+ 2 - 3
web/src/Browser.js

@@ -3,9 +3,8 @@ import {useReducer} from 'react';
 import Filters from './Filters';
 import AlbumList from './AlbumList';
 
-const ALBUMS_EMPTY = {items: [], page: 0, more: false};
-
-function browserReducer(state, action) {
+export const ALBUMS_EMPTY = {items: [], page: 0, more: false};
+export function browserReducer(state, action) {
   switch(action.type) {
   case 'LOAD_ALBUMS': {
     const {more} = action.payload;

+ 3 - 12
web/src/Controls.css

@@ -47,21 +47,12 @@
 }
 .App[data-layout='left'] .Controls-artist,
 .App[data-layout='right'] .Controls-artist {
-  padding: 0 8px;
   align-self: start;
 }
 
-.App[data-layout='left'] .Controls-time,
-.App[data-layout='left'] .Controls-buttons,
-.App[data-layout='left'] .Controls-title,
-.App[data-layout='left'] .Controls-album,
-.App[data-layout='left'] .Controls-artist,
-.App[data-layout='right'] .Controls-time,
-.App[data-layout='right'] .Controls-buttons,
-.App[data-layout='right'] .Controls-title,
-.App[data-layout='right'] .Controls-album,
-.App[data-layout='right'] .Controls-artist {
-  padding: 0 8px;
+.App[data-layout='left'] .Controls > div,
+.App[data-layout='right'] .Controls > div {
+  padding: 4px 8px;
 }
 
 .Controls-progress {

+ 3 - 3
web/src/Controls.js

@@ -1,7 +1,7 @@
 import './Controls.css';
 import logo from './logo.svg';
 import Sound from 'react-sound';
-import {formatDuration} from './utils.js';
+import {slugify,formatDuration} from './utils.js';
 
 export default function Controls({queue, error, sound, dispatch}) {
   const track = queue.items[queue.pos] || {};
@@ -17,8 +17,8 @@ export default function Controls({queue, error, sound, dispatch}) {
       {duration && <span className="Controls-time">{elapsed} / {duration}</span>}
       {error && <span className="Controls-error">{error.code}: {error.desc}</span>}
       <img src={track.cover || logo} alt={track.album} />
-      <div className="Controls-artist">{track.artist}</div>
-      <div className="Controls-album">{track.album}{track.year && ` [${track.year}]`}</div>
+      <div className="Controls-artist"><a href={`/${slugify(track.album_artist)}/`}>{track.artist}</a></div>
+      <div className="Controls-album"><a href={`/${slugify(track.album_artist)}/${slugify(track.album)}`}>{track.album}{track.year && ` [${track.year}]`}</a></div>
       <div className="Controls-title">{track.no && `${track.no}. `}{track.title}</div>
       <div className="Controls-buttons">
         <button disabled={queue.pos < 1} onClick={()=>dispatch({type: 'CONTROL_PREV'})}>

+ 13 - 4
web/src/Main.js

@@ -1,13 +1,22 @@
 import {useRef} from 'react';
+import {useRouteNode} from 'react-router5'
 import Browser from './Browser';
+import ArtistPage from './ArtistPage';
+import AlbumPage from './AlbumPage';
 
 export default function Main({dispatch}) {
   const scrollRef = useRef(null);
+  const { route } = useRouteNode('');
+
+  let content = <h1>Page not found!</h1>;
+  if (route.name === 'browser')
+    content = <Browser dispatch={dispatch} scrollRef={scrollRef} />
+  else if (route.name === 'artist')
+    content = <ArtistPage dispatch={dispatch} scrollRef={scrollRef}  />
+  else if (route.name === 'album')
+    content = <AlbumPage dispatch={dispatch} scrollRef={scrollRef}  />
 
-  // TODO: Artist, Album view. URL handling
   return (
-    <main ref={scrollRef}>
-      <Browser dispatch={dispatch} scrollRef={scrollRef} />
-    </main>
+    <main ref={scrollRef}>{ content }</main>
   )
 }

+ 3 - 3
web/src/Player.js

@@ -45,9 +45,9 @@ function playQueue(state, pos) {
 function playerReducer(state, action) {
   switch (action.type) {
   case 'ALBUM_PLAY': {
-    if (!action.payload) return state;
-    const queue = {items: action.payload, pos: 0}
-    return playQueue({...state, queue}, 0)};
+    if (!action.payload || !action.payload.tracks) return state;
+    const queue = {items: action.payload.tracks}
+    return playQueue({...state, queue}, (action.payload.pos || 0))}
   case 'ALBUM_ENQUEUE': {
     if (!action.payload) return state;
     const queue = {...state.queue, items: state.queue.items.concat(action.payload)};

+ 4 - 3
web/src/Queue.js

@@ -1,5 +1,6 @@
+import { Link } from 'react-router5'
 import './Queue.css';
-import {formatDuration} from './utils.js';
+import {slugify, formatDuration} from './utils.js';
 
 export default function Queue({queue, dispatch}) {
   return (
@@ -10,9 +11,9 @@ export default function Queue({queue, dispatch}) {
             <tr key={i} className={ i === queue.pos ? "Queue-active" : "" }>
               <td>{i+1}</td>
               <td>{track.title}</td>
-              <td>{track.artist}</td>
+              <td><Link routeName="artist" routeParams={{artist: slugify(track.album_artist || track.artist)}}>{track.artist}</Link></td>
               <td>{track.year}</td>
-              <td>{track.album}</td>
+              <td><Link routeName="album" routeParams={{artist: slugify(track.album_artist || track.artist), album: slugify(track.album)}}>{track.album}</Link></td>
               <td>{track.no}</td>
               <td>{track.bit_rate} kbps</td>
               <td>{formatDuration(track.duration)}</td>

+ 17 - 0
web/src/create-router.js

@@ -0,0 +1,17 @@
+import createRouter from 'router5'
+import loggerPlugin from 'router5-plugin-logger'
+import browserPlugin from 'router5-plugin-browser'
+import routes from './routes'
+
+export default function configureRouter() {
+  const router = createRouter(routes, {
+    defaultRoute: 'browser',
+    caseSensitive: true,
+    urlParamsEncoding: 'uri'
+  })
+
+  router.usePlugin(loggerPlugin)
+  router.usePlugin(browserPlugin())
+
+  return router
+}

+ 8 - 3
web/src/index.js

@@ -1,15 +1,20 @@
 import React from 'react';
 import ReactDOM from 'react-dom';
+import { RouterProvider } from 'react-router5'
 import './index.css';
 import App from './App';
+import createRouter from './create-router'
 import reportWebVitals from './reportWebVitals';
 
-ReactDOM.render(
+
+const router = createRouter()
+
+router.start(() => ReactDOM.render(
   <React.StrictMode>
-    <App />
+    <RouterProvider router={router}><App /></RouterProvider>
   </React.StrictMode>,
   document.getElementById('root')
-);
+));
 
 // If you want to start measuring performance in your app, pass a function
 // to log results (for example: reportWebVitals(console.log))

+ 6 - 0
web/src/routes.js

@@ -0,0 +1,6 @@
+const routes= [
+    { name: 'browser', path: '/' },
+    { name: 'artist', path: '/:artist<[^/?#\\[\\]]+>' },
+    { name: 'album', path: '/:artist<[^/?#\\[\\]]+>/:album<[^/?#\\[\\]]+>' }
+]
+export default routes;

+ 23 - 0
web/src/utils.js

@@ -14,3 +14,26 @@ export function getQueryString(params) {
     .map(k => esc(k) + '=' + esc(params[k]))
     .join('&');
 }
+
+const SUBST = {
+  ':':'-colon-',
+  '/':'-slash-',
+  '\\':'rslash',
+  '?':'-qmark-',
+  '#':'-hash-',
+  '[':'-obr-',
+  ']':'-cbr-',
+  '@':'-at-',
+  '~':'-tilda-',
+  ' ':'_',
+  '_':'-und-',
+  '‐':'-hyphen-',
+  '-':'-dash-'
+};
+const UNSUBST = Object.assign({}, ...Object.entries(SUBST).map(([a,b]) => ({[b]: a})))
+const FORW = new RegExp(/[:\/\\?#\[\]@~ _‐-]/, 'g');
+const BACK = new RegExp(`(${Object.values(SUBST).join('|')})`,'g');
+//export function slugify(str) { return str && encodeURI(str.replace(FORW, e=>SUBST[e])) }
+//export function unslugify(str) { return str && decodeURI(str).replace(BACK, e=>UNSUBST[e]) }
+export function slugify(str) { return str && str.replace(FORW, e=>SUBST[e]) }
+export function unslugify(str) { return str && str.replace(BACK, e=>UNSUBST[e]) }