1
0

12 کامیت‌ها cd9cabfca3 ... efab1db193

نویسنده SHA1 پیام تاریخ
  v.shebanov efab1db193 update sorvor 4 سال پیش
  Innocenty Enikeev 0f7b53729b Merge branch 'new-build-system' of termina1/chad-music into master 4 سال پیش
  v.shebanov 7a5bcb4e88 merge 4 سال پیش
  Innokentiy Enikeev 13384cd86d [web] Mobile firstgit diff --cached -w 4 سال پیش
  v.shebanov e949f0676f enable live reload 4 سال پیش
  v.shebanov 3a3975d805 support old build 4 سال پیش
  v.shebanov d87716809c chad indie fontend pack 4 سال پیش
  Innokentiy Enikeev bc276b2811 [back] Fix album sorting. Move non-alpha to the back. Fixes #10 4 سال پیش
  Innocenty Enikeev 5e4b4ee418 Merge branch 'new-build-system' of termina1/chad-music into master 4 سال پیش
  v.shebanov d5cb33028e fix eslint errors 4 سال پیش
  v.shebanov 3c0e122cd1 more structure for components 4 سال پیش
  Innocenty Enikeev 645a861415 Merge branch 'master' of termina1/chad-music into master 4 سال پیش
46فایلهای تغییر یافته به همراه396 افزوده شده و 208 حذف شده
  1. 33 26
      back/db.lisp
  2. 1 1
      back/server.lisp
  3. 10 2
      web/config-overrides.js
  4. 10 0
      web/package-lock.json
  5. 5 2
      web/package.json
  6. 5 26
      web/public/index.html
  7. 43 0
      web/public/index_old.html
  8. BIN
      web/public/logo256.png
  9. 4 14
      web/public/manifest.json
  10. 2 0
      web/react-shim.js
  11. 0 6
      web/src/AlbumPage.css
  12. 0 45
      web/src/App.js
  13. 0 12
      web/src/Filters.css
  14. 0 18
      web/src/Header.css
  15. 0 9
      web/src/Settings.css
  16. 4 4
      web/src/components/Album/Album.css
  17. 4 3
      web/src/components/Album/Album.js
  18. 4 5
      web/src/components/AlbumList/AlbumList.js
  19. 12 0
      web/src/components/AlbumPage/AlbumPage.css
  20. 3 3
      web/src/components/AlbumPage/AlbumPage.js
  21. 36 0
      web/src/components/App/App.css
  22. 54 0
      web/src/components/App/App.js
  23. 0 0
      web/src/components/App/App.test.js
  24. 2 2
      web/src/components/ArtistPage.js
  25. 0 0
      web/src/components/Browser.js
  26. 29 0
      web/src/components/Controls/Controls.css
  27. 2 2
      web/src/components/Controls/Controls.js
  28. 38 0
      web/src/components/Filters/Filters.css
  29. 8 10
      web/src/components/Filters/Filters.js
  30. 45 0
      web/src/components/Header/Header.css
  31. 5 5
      web/src/components/Header/Header.js
  32. 0 0
      web/src/components/Login/Login.css
  33. 2 2
      web/src/components/Login/Login.js
  34. 0 0
      web/src/components/Main.js
  35. 0 0
      web/src/components/MediaSession.js
  36. 0 0
      web/src/components/Player.js
  37. 0 0
      web/src/components/Queue/Queue.css
  38. 3 3
      web/src/components/Queue/Queue.js
  39. 20 0
      web/src/components/Settings/Settings.css
  40. 2 2
      web/src/components/Settings/Settings.js
  41. 0 0
      web/src/components/TelegramLoginButton.js
  42. 4 0
      web/src/components/UserInfo/UserInfo.css
  43. 2 2
      web/src/components/UserInfo/UserInfo.js
  44. 2 2
      web/src/create-router.js
  45. 1 1
      web/src/index.js
  46. 1 1
      web/src/utils.js

+ 33 - 26
back/db.lisp

@@ -184,6 +184,19 @@
         (integer track)
         (integer track)
         (string (parse-integer track :junk-allowed t))))))
         (string (parse-integer track :junk-allowed t))))))
 
 
+(defun slot< (a b)
+  (declare #.*standard-optimize-settings*
+           (type simple-string a b))
+  (when (xor (emptyp a) (emptyp b))
+    (return-from slot< (when (emptyp b) 0)))
+  (let ((aa (alpha-char-p (elt a 0)))
+        (ba (alpha-char-p (elt b 0))))
+    (cond
+      ((and aa ba) (string< a b))
+      (aa 0)
+      (ba)
+      (t (string< a b)))))
+
 (defparameter +album-type-order+ '("album" "lp" "ep" "single" "compilation" "live" "soundtrack"
 (defparameter +album-type-order+ '("album" "lp" "ep" "single" "compilation" "live" "soundtrack"
                                    "spokenword" "remix" "mixed" "dj-mix" "mixtape" "broadcast")
                                    "spokenword" "remix" "mixed" "dj-mix" "mixtape" "broadcast")
   "Half-arbitrary album type order")
   "Half-arbitrary album type order")
@@ -194,22 +207,26 @@
       (let ((slot-a (slot-value a slot))
       (let ((slot-a (slot-value a slot))
             (slot-b (slot-value b slot)))
             (slot-b (slot-value b slot)))
         (when (xor (null slot-a) (null slot-b))
         (when (xor (null slot-a) (null slot-b))
-          (return-from info<> (if (null slot-b) 1 -1)))
-        (case slot
-          (type
-           (setf slot-a (or (position slot-a (the list +album-type-order+) :test 'string-equal) 0)
-                 slot-b (or (position slot-b (the list +album-type-order+) :test 'string-equal) 0)))
-          (no
-           (setf slot-a (clear-track-no slot-a)
-                 slot-b (clear-track-no slot-b))))
-        (unless (or (and (null slot-a) (null slot-b))
-                    (case slot
-                      ((type year no) (= (the fixnum slot-a) (the fixnum slot-b)))
-                      (t (string-equal slot-a slot-b))))
-          (return-from info<> (case slot
-                                ((type year no) (- (the fixnum slot-a) (the fixnum slot-b)))
-                                (t (if (string< slot-a slot-b) -1 1)))))))))
-(defparameter +album<>+ (gen-comparator '(artist type original-date year album)))
+          (return-from info<> (if (null slot-b) -1 1)))
+        (unless (and (null slot-a) (null slot-b))
+          (case slot
+            (type
+             (let ((cmp (if (slot< slot-a slot-b) -1 1)))
+               (setf slot-a (or (position slot-a (the list +album-type-order+) :test 'string-equal)
+                                (+ 100 cmp))
+                     slot-b (or (position slot-b (the list +album-type-order+) :test 'string-equal)
+                                (- 100 cmp)))))
+            (no
+             (setf slot-a (clear-track-no slot-a)
+                   slot-b (clear-track-no slot-b))))
+          (unless (case slot
+                    ((type year no) (= (the fixnum slot-a) (the fixnum slot-b)))
+                    (t (string-equal slot-a slot-b)))
+            (return-from info<> (case slot
+                                  ((type year no) (- (the fixnum slot-a) (the fixnum slot-b)))
+                                  (t (if (slot< slot-a slot-b) -1 1))))))))))
+
+(defparameter +album<>+ (gen-comparator '(artist type year album)))
 (defun album< (a b)
 (defun album< (a b)
   (declare #.*standard-optimize-settings*
   (declare #.*standard-optimize-settings*
            (type function +album<>+))
            (type function +album<>+))
@@ -231,16 +248,6 @@
         (< (the fixnum (funcall +track<>+ a b)) 0)
         (< (the fixnum (funcall +track<>+ a b)) 0)
         albs)))
         albs)))
 
 
-(defun slot< (a b)
-  (declare #.*standard-optimize-settings*
-           (type simple-string a b))
-  (let ((aa (alpha-char-p (elt a 0)))
-        (ba (alpha-char-p (elt b 0))))
-    (cond
-      ((and aa ba) (string< a b))
-      (aa 0)
-      (ba)
-      (t (string< a b)))))
 
 
 (defun match-filter (data category filter)
 (defun match-filter (data category filter)
   (declare #.*standard-optimize-settings*)
   (declare #.*standard-optimize-settings*)

+ 1 - 1
back/server.lisp

@@ -53,7 +53,7 @@
     (maybe-key-value "publisher" (album-publisher album))
     (maybe-key-value "publisher" (album-publisher album))
     (maybe-key-value "country" (album-country album))
     (maybe-key-value "country" (album-country album))
     (maybe-key-value "genre" (album-genre album))
     (maybe-key-value "genre" (album-genre album))
-    (maybe-key-value "type" (or (album-type album) "album"))
+    (maybe-key-value "type" (or (album-type album) "release"))
     (maybe-key-value "status" (album-status album))
     (maybe-key-value "status" (album-status album))
     (maybe-key-value "mb_id" (album-mb-id album))
     (maybe-key-value "mb_id" (album-mb-id album))
     (maybe-key-value "track_count" (album-track-count album))
     (maybe-key-value "track_count" (album-track-count album))

+ 10 - 2
web/config-overrides.js

@@ -1,8 +1,16 @@
 const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
 const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
+const DirectoryNamedWebpackPlugin = require("directory-named-webpack-plugin");
+const path = require('path');
 
 
 module.exports = function override(config, env) {
 module.exports = function override(config, env) {
-    config.plugins.push(
-      new BundleAnalyzerPlugin()
+    // config.plugins.push(
+    //   new BundleAnalyzerPlugin()
+    // );
+    config.plugins[0].options.template = path.join(__dirname, 'public/index_old.html');
+    debugger
+    config.resolve.plugins.push(
+      new DirectoryNamedWebpackPlugin()
     );
     );
+    config.resolve.alias.src = path.resolve(__dirname, 'src/');
     return config;
     return config;
 }
 }

+ 10 - 0
web/package-lock.json

@@ -5118,6 +5118,16 @@
         "path-type": "^4.0.0"
         "path-type": "^4.0.0"
       }
       }
     },
     },
+    "directory-named-webpack-plugin": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/directory-named-webpack-plugin/-/directory-named-webpack-plugin-4.0.1.tgz",
+      "integrity": "sha512-cULe7U64O9NM+O+L9gfcVKPo/A0pNEntsXTpuRHoCFMYE5pV9XQrJI9mJ8bgo0WKPmKPw/kozXfRolNNFJICCA==",
+      "dev": true,
+      "requires": {
+        "enhanced-resolve": "^4.0.0",
+        "object-assign": "^4.1.0"
+      }
+    },
     "dns-equal": {
     "dns-equal": {
       "version": "1.0.0",
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/dns-equal/-/dns-equal-1.0.0.tgz",
       "resolved": "https://registry.npmjs.org/dns-equal/-/dns-equal-1.0.0.tgz",

+ 5 - 2
web/package.json

@@ -7,7 +7,6 @@
     "@testing-library/react": "^11.2.5",
     "@testing-library/react": "^11.2.5",
     "@testing-library/user-event": "^12.8.3",
     "@testing-library/user-event": "^12.8.3",
     "immer": "^8.0.3",
     "immer": "^8.0.3",
-    "path-parser": "^6.1.0",
     "react": "^17.0.1",
     "react": "^17.0.1",
     "react-dom": "^17.0.1",
     "react-dom": "^17.0.1",
     "react-infinite-scroller": "^1.2.4",
     "react-infinite-scroller": "^1.2.4",
@@ -26,7 +25,10 @@
     "start": "react-app-rewired start",
     "start": "react-app-rewired start",
     "build": "react-app-rewired build",
     "build": "react-app-rewired build",
     "test": "react-app-rewired test",
     "test": "react-app-rewired test",
-    "eject": "react-scripts eject"
+    "eject": "react-scripts eject",
+    "postinstall": "curl https://gobinaries.com/Termina1/sorvor@v0.4.7 | PREFIX=./node_modules/.bin sh ",
+    "chadpack": "sorvor --serve ./public/index.html --loader:.js=jsx  --loader:.png=file --loader:.svg=file --inject:./react-shim.js --port=3000 --outdir=./build",
+    "chadpack-prod": "sorvor ./public/index.html --loader:.js=jsx  --loader:.png=file --loader:.svg=file --inject:./react-shim.js --outdir=./build --minify"
   },
   },
   "eslintConfig": {
   "eslintConfig": {
     "extends": [
     "extends": [
@@ -47,6 +49,7 @@
     ]
     ]
   },
   },
   "devDependencies": {
   "devDependencies": {
+    "directory-named-webpack-plugin": "^4.0.1",
     "react-app-rewired": "^2.1.8",
     "react-app-rewired": "^2.1.8",
     "webpack-bundle-analyzer": "^4.4.0"
     "webpack-bundle-analyzer": "^4.4.0"
   }
   }

+ 5 - 26
web/public/index.html

@@ -2,42 +2,21 @@
 <html lang="en">
 <html lang="en">
   <head>
   <head>
     <meta charset="utf-8" />
     <meta charset="utf-8" />
-    <link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
+    <link rel="icon" href="{{ copy "./logo256.png" }}" />
     <meta name="viewport" content="width=device-width, initial-scale=1" />
     <meta name="viewport" content="width=device-width, initial-scale=1" />
     <meta name="theme-color" content="#000000" />
     <meta name="theme-color" content="#000000" />
     <meta
     <meta
       name="description"
       name="description"
       content="Chad music streaming service"
       content="Chad music streaming service"
     />
     />
-    <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
-    <!--
-      manifest.json provides metadata used when your web app is installed on a
-      user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-    -->
-    <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
-    <!--
-      Notice the use of %PUBLIC_URL% in the tags above.
-      It will be replaced with the URL of the `public` folder during the build.
-      Only files inside the `public` folder can be referenced from the HTML.
-
-      Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
-      work correctly both with client-side routing and a non-root public URL.
-      Learn how to configure a non-root public URL by running `npm run build`.
-    -->
+    <link rel="apple-touch-icon" href="{{ copy "./logo192.png" }}" />
+    <link rel="manifest" href="{{ copy "./manifest.json" }}" />
+    {{ livereload }}
     <title>Chad music</title>
     <title>Chad music</title>
   </head>
   </head>
   <body>
   <body>
     <noscript>You need to enable JavaScript to run this app.</noscript>
     <noscript>You need to enable JavaScript to run this app.</noscript>
     <div id="root"></div>
     <div id="root"></div>
-    <!--
-      This HTML file is a template.
-      If you open it directly in the browser, you will see an empty page.
-
-      You can add webfonts, meta tags, or analytics to this file.
-      The build step will place the bundled scripts into the <body> tag.
-
-      To begin the development, run `npm start` or `yarn start`.
-      To create a production bundle, use `npm run build` or `yarn build`.
-    -->
+    {{ esbuild "../src/index.js" true}}
   </body>
   </body>
 </html>
 </html>

+ 43 - 0
web/public/index_old.html

@@ -0,0 +1,43 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8" />
+    <link rel="icon" href="%PUBLIC_URL%/logo256.png" />
+    <meta name="viewport" content="width=device-width, initial-scale=1" />
+    <meta name="theme-color" content="#000000" />
+    <meta
+      name="description"
+      content="Chad music streaming service"
+    />
+    <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
+    <!--
+      manifest.json provides metadata used when your web app is installed on a
+      user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
+    -->
+    <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
+    <!--
+      Notice the use of %PUBLIC_URL% in the tags above.
+      It will be replaced with the URL of the `public` folder during the build.
+      Only files inside the `public` folder can be referenced from the HTML.
+
+      Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
+      work correctly both with client-side routing and a non-root public URL.
+      Learn how to configure a non-root public URL by running `npm run build`.
+    -->
+    <title>Chad music</title>
+  </head>
+  <body>
+    <noscript>You need to enable JavaScript to run this app.</noscript>
+    <div id="root"></div>
+    <!--
+      This HTML file is a template.
+      If you open it directly in the browser, you will see an empty page.
+
+      You can add webfonts, meta tags, or analytics to this file.
+      The build step will place the bundled scripts into the <body> tag.
+
+      To begin the development, run `npm start` or `yarn start`.
+      To create a production bundle, use `npm run build` or `yarn build`.
+    -->
+  </body>
+</html>

BIN
web/public/logo256.png


+ 4 - 14
web/public/manifest.json

@@ -1,21 +1,11 @@
 {
 {
-  "short_name": "React App",
-  "name": "Create React App Sample",
+  "short_name": "Chad Music",
+  "name": "Chad music streaming service",
   "icons": [
   "icons": [
     {
     {
-      "src": "favicon.ico",
-      "sizes": "64x64 32x32 24x24 16x16",
-      "type": "image/x-icon"
-    },
-    {
-      "src": "logo192.png",
-      "type": "image/png",
-      "sizes": "192x192"
-    },
-    {
-      "src": "logo512.png",
+      "src": "logo256.png",
       "type": "image/png",
       "type": "image/png",
-      "sizes": "512x512"
+      "sizes": "256x256"
     }
     }
   ],
   ],
   "start_url": ".",
   "start_url": ".",

+ 2 - 0
web/react-shim.js

@@ -0,0 +1,2 @@
+import * as React from 'react'
+export { React }

+ 0 - 6
web/src/AlbumPage.css

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

+ 0 - 45
web/src/App.js

@@ -1,45 +0,0 @@
-import {useReducer} from 'react';
-import './App.css';
-import {storeToken, loadToken} from './Api';
-import Login from './Login';
-import Header from './Header';
-import Player from './Player';
-
-function storeSettings(settings) {
-  localStorage.setItem('settings', JSON.stringify(settings));
-  return settings;
-}
-function loadSettings() {
-  const settings = localStorage.getItem('settings');
-  return (settings && JSON.parse(settings)) || {login:{}, theme:'light', layout:'two-col'};
-}
-
-function settingsReducer(state, action) {
-  console.log(action);
-  switch(action.type) {
-  case 'LOGIN_SUCCESS':
-    storeToken(action.payload.token);
-    return {...state, login: {loading: false, error: null}};
-  case 'LOGIN_INIT':    return {...state, login: {loading: true, error: null}};
-  case 'LOGIN_FAIL':    return {...state, login: {loading: false, error: action.payload}};
-  case 'LAYOUT_SET':    return storeSettings({...state, layout: action.payload});
-  case 'THEME_SET':     return storeSettings({...state, theme: action.payload});
-  default: throw new Error(`Bad action ${action}`);
-  }
-}
-
-function App() {
-  const [settings, dispatch] = useReducer(settingsReducer, loadSettings());
-  if(!loadToken()) {
-    return <Login state={settings} dispatch={dispatch} />
-  }
-
-  return (
-    <div className="App" data-theme={settings.theme} data-layout={settings.layout}>
-      <Header settings={settings} dispatch={dispatch} />
-      <Player />
-    </div>
-  );
-}
-
-export default App;

+ 0 - 12
web/src/Filters.css

@@ -1,12 +0,0 @@
-.Filters {
-  display: grid;
-  grid-template-columns: repeat(3, 1fr);
-  grid-column-gap: 10px;
-}
-.Filters h3{
-  font-size: 1em;
-}
-.Filters .Filter {
-  grid-column-end: span 2;
-}
-.Filters .Filter input { width: 100%; }

+ 0 - 18
web/src/Header.css

@@ -1,18 +0,0 @@
-.Header {
-  grid-area: header;
-  background-color: var(--header-background);
-  display: grid;
-  grid: 1fr / auto 1fr 2fr 2fr auto;
-  align-items: center;
-}
-
-.Header-logo {
-  min-width: 64px;
-  height: 64px;
-  padding: 8px;
-}
-
-.Header-title {
-  color: var(--header-color);
-  font-size: 1.5em;
-}

+ 0 - 9
web/src/Settings.css

@@ -1,9 +0,0 @@
-.Settings {
-  justify-self: right;
-  padding-right: 64px;
-
-  display: grid;
-  grid: auto / 1fr 1fr;
-  width: 384px;
-  grid-column-gap: 10px;
-}

+ 4 - 4
web/src/AlbumList.css → web/src/components/Album/Album.css

@@ -1,9 +1,9 @@
 .Album{
 .Album{
   display: grid;
   display: grid;
-  grid: "cover year    title     actions" 1fr
-        "cover country publisher actions" 1fr
-        "cover tracks  genre     actions" 1fr
-        / auto 1fr 7fr 1fr;
+  grid: "cover year    title     actions" auto
+        "cover country publisher actions" auto
+        "cover tracks  genre     actions" auto
+        / auto auto 1fr auto;
   grid-column-gap: 8px;
   grid-column-gap: 8px;
   padding-bottom: 10px;
   padding-bottom: 10px;
 }
 }

+ 4 - 3
web/src/Album.js → web/src/components/Album/Album.js

@@ -1,7 +1,8 @@
-import logo from './logo.svg';
+import './Album.css';
+import logo from 'src/logo.svg';
 import { Link } from 'react-router5'
 import { Link } from 'react-router5'
-import {formatDuration, slugify} from './utils';
-import {getTracks, request} from './Api';
+import {formatDuration, slugify} from 'src/utils';
+import {getTracks, request} from 'src/Api';
 
 
 export function fetchTracks(album_id, type, dispatch) {
 export function fetchTracks(album_id, type, dispatch) {
   const {url, options} = getTracks(album_id);
   const {url, options} = getTracks(album_id);

+ 4 - 5
web/src/AlbumList.js → web/src/components/AlbumList/AlbumList.js

@@ -1,9 +1,8 @@
-import './AlbumList.css';
 import InfiniteScroll from 'react-infinite-scroller';
 import InfiniteScroll from 'react-infinite-scroller';
 import {useEffect} from 'react';
 import {useEffect} from 'react';
-import {getAlbums, request} from './Api';
-import Album from './Album';
-import {CATEGORIES} from './Filters';
+import {getAlbums, request} from 'src/Api';
+import Album from '../Album';
+import {CATEGORIES} from '../Filters';
 
 
 function _albumProps(albums, index, props) {
 function _albumProps(albums, index, props) {
   if (index === 0) {
   if (index === 0) {
@@ -54,7 +53,7 @@ export default function AlbumList(props) {
     <div className="AlbumList">
     <div className="AlbumList">
       {title && <h2>{title}</h2>}
       {title && <h2>{title}</h2>}
       { error ? (
       { error ? (
-        <div className="error">{error}></div>
+        <div className="error">{error}&gt;</div>
       ) : (
       ) : (
         <InfiniteScroll
         <InfiniteScroll
           loadMore={(p) => {dispatch({type: 'SET_PAGE', payload: p})}}
           loadMore={(p) => {dispatch({type: 'SET_PAGE', payload: p})}}

+ 12 - 0
web/src/components/AlbumPage/AlbumPage.css

@@ -0,0 +1,12 @@
+.AlbumPage table button {
+  padding: 0;
+  width: 24px;
+  height: 24px;
+  vertical-align: bottom;
+}
+
+@media screen and (max-width: 800px) {
+  .AlbumPage h1 { margin: 5px 0; }
+  .AlbumPage h2 { margin: 2px 0; }
+  .AlbumPage h3 { margin: 1px 0; }
+}

+ 3 - 3
web/src/AlbumPage.js → web/src/components/AlbumPage/AlbumPage.js

@@ -1,10 +1,10 @@
 import './AlbumPage.css';
 import './AlbumPage.css';
 import { Link } from 'react-router5'
 import { Link } from 'react-router5'
 import {useRouteNode} from 'react-router5'
 import {useRouteNode} from 'react-router5'
-import {unslugify, formatDuration} from './utils.js';
+import {unslugify, formatDuration} from '../../utils.js';
 import {useReducer, useEffect} from 'react';
 import {useReducer, useEffect} from 'react';
-import {fetchAlbums} from './AlbumList';
-import {fetchTracks} from './Album';
+import {fetchAlbums} from '../AlbumList';
+import {fetchTracks} from '../Album';
 
 
 function pageReducer(state, action) {
 function pageReducer(state, action) {
   switch (action.type) {
   switch (action.type) {

+ 36 - 0
web/src/App.css → web/src/components/App/App.css

@@ -38,6 +38,7 @@
 .App-intro {
 .App-intro {
   font-size: large;
   font-size: large;
 }
 }
+.App-panels { display: none; }
 
 
 .App main {
 .App main {
   grid-area: browser;
   grid-area: browser;
@@ -51,3 +52,38 @@
 .App footer {
 .App footer {
   grid-area: footer;
   grid-area: footer;
 }
 }
+
+@media screen and (max-width: 800px) {
+  .App {
+    grid: "header" auto
+          "panels" auto
+          "main"   1fr
+          "footer" auto
+          / 1fr;
+  }
+  .App main {
+    grid-area: main;
+    padding: 0 8px;
+  }
+  .App-panels {
+    grid-area: panels;
+    display: grid;
+    grid: auto / 1fr 1fr;
+    text-align: center;
+    margin-bottom: 4px;
+  }
+  .App-panels input { display: none; }
+
+  .App-panels label {
+    background-color: var(--controls-background);
+  }
+  .App-panels #panel-main:checked ~ label[for='panel-main'],
+  .App-panels #panel-side:checked ~ label[for='panel-side'] {
+    background-color: var(--background);
+  }
+  .App aside { grid-area: main; }
+  .App[data-panel='side'] main { display: none; }
+  .App[data-panel='main'] aside { display: none; }
+
+  .ArtistName { margin: 0; }
+}

+ 54 - 0
web/src/components/App/App.js

@@ -0,0 +1,54 @@
+import {useReducer} from 'react';
+import './App.css';
+import {storeToken, loadToken} from 'src/Api';
+import Login from '../Login';
+import Header from '../Header';
+import Player from '../Player';
+
+function storeSettings(settings) {
+  localStorage.setItem('settings', JSON.stringify(settings));
+  return settings;
+}
+function loadSettings() {
+  const settings = localStorage.getItem('settings');
+  return (settings && JSON.parse(settings)) || {login:{}, theme:'light', layout:'two-col', panel:'main'};
+}
+
+function settingsReducer(state, action) {
+  console.log(action);
+  switch(action.type) {
+  case 'LOGIN_SUCCESS':
+    storeToken(action.payload.token);
+    return {...state, login: {loading: false, error: null}};
+  case 'LOGIN_INIT': return {...state, login: {loading: true, error: null}};
+  case 'LOGIN_FAIL': return {...state, login: {loading: false, error: action.payload}};
+  case 'LAYOUT_SET': return storeSettings({...state, layout: action.payload});
+  case 'THEME_SET':  return storeSettings({...state, theme: action.payload});
+  case 'PANEL_SET':  return storeSettings({...state, panel: action.payload});
+  default: throw new Error(`Bad action ${action}`);
+  }
+}
+
+const handleRadio = (dispatch, type) => (e) => dispatch({type:`${e.target.name.toUpperCase()}_SET`, payload: e.target.value});
+
+function App() {
+  const [settings, dispatch] = useReducer(settingsReducer, loadSettings());
+  if(!loadToken()) {
+    return <Login state={settings} dispatch={dispatch} />
+  }
+
+  return (
+    <div className="App" data-theme={settings.theme} data-layout={settings.layout} data-panel={settings.panel || 'main'}>
+      <div className="App-panels">
+        <input id="panel-main" type="radio" name="panel" value="main" checked={settings.panel === 'main'} onChange={handleRadio(dispatch)} />
+        <input id="panel-side" type="radio" name="panel" value="side" checked={settings.panel === 'side'} onChange={handleRadio(dispatch)} />
+        <label htmlFor="panel-main">Browse</label>
+        <label htmlFor="panel-side">Queue</label>
+      </div>
+      <Header settings={settings} dispatch={dispatch} />
+      <Player />
+    </div>
+  );
+}
+
+export default App;

+ 0 - 0
web/src/App.test.js → web/src/components/App/App.test.js


+ 2 - 2
web/src/ArtistPage.js → web/src/components/ArtistPage.js

@@ -1,6 +1,6 @@
 import { Link } from 'react-router5'
 import { Link } from 'react-router5'
 import {useRouteNode} from 'react-router5'
 import {useRouteNode} from 'react-router5'
-import {unslugify} from './utils'
+import {unslugify} from '../utils'
 import {useReducer} from 'react';
 import {useReducer} from 'react';
 import AlbumList from './AlbumList';
 import AlbumList from './AlbumList';
 import {browserReducer, ALBUMS_EMPTY} from './Browser';
 import {browserReducer, ALBUMS_EMPTY} from './Browser';
@@ -13,7 +13,7 @@ export default function ArtistPage({dispatch, scrollRef}) {
 
 
   return (
   return (
     <>
     <>
-      <h1>{artist}</h1><Link routeName="browser">Browse</Link>
+      <h1 className="ArtistName">{artist}</h1><Link routeName="browser">Browse</Link>
       <AlbumList {...state} dispatch={dispatchBrowser} playerDispatch={dispatch} scrollRef={scrollRef} noTitle noArtist/>
       <AlbumList {...state} dispatch={dispatchBrowser} playerDispatch={dispatch} scrollRef={scrollRef} noTitle noArtist/>
     </>
     </>
   );
   );

+ 0 - 0
web/src/Browser.js → web/src/components/Browser.js


+ 29 - 0
web/src/Controls.css → web/src/components/Controls/Controls.css

@@ -124,3 +124,32 @@
   height: 48px;
   height: 48px;
   width: 48px;
   width: 48px;
 }
 }
+
+@media screen and (max-width: 800px) {
+  .Controls {
+    grid: "progress progress progress" auto
+          "cover    buttons time" auto
+          "cover    title artist" auto
+          "album    album album" auto
+          / auto auto 1fr;
+    grid-column-gap: 4px;
+  }
+  .Controls-buttons {
+    grid-column-gap: 10px;
+  }
+  .Controls progress {
+    height: 4px;
+  }
+  .Controls-album {
+    padding: 0 8px;
+  }
+  .Controls-artist {
+    min-width: 25vw;
+    padding: 0 8px;
+    text-align: right;
+  }
+  .Controls-time {
+    justify-self: right;
+    padding-right: 8px;
+  }
+}

+ 2 - 2
web/src/Controls.js → web/src/components/Controls/Controls.js

@@ -1,7 +1,7 @@
 import './Controls.css';
 import './Controls.css';
-import logo from './logo.svg';
+import logo from 'src/logo.svg';
 import Sound from 'react-sound';
 import Sound from 'react-sound';
-import {slugify,formatDuration} from './utils.js';
+import {slugify,formatDuration} from 'src/utils';
 
 
 export default function Controls({queue, error, sound, dispatch}) {
 export default function Controls({queue, error, sound, dispatch}) {
   const track = queue.items[queue.pos] || {};
   const track = queue.items[queue.pos] || {};

+ 38 - 0
web/src/components/Filters/Filters.css

@@ -0,0 +1,38 @@
+.Filters {
+  padding: 8px 0;
+  display: grid;
+  grid-template-columns: repeat(3, 1fr);
+  grid-column-gap: 10px;
+  grid-row-gap: 4px;
+}
+.Filters h3{
+  font-size: 1em;
+}
+.Filters .Latest {
+  align-self: center;
+}
+.Filters .Latest label {
+  color: hsl(0, 0%, 50%);
+}
+.Filters .Latest input {
+  margin-left: 8px;
+}
+
+.Filters .Filter {
+  grid-column-end: span 3;
+}
+.Filters .Filter input {
+  width: 100%;
+  height: 30px;
+  border-radius: 4px;
+  border-width: 1px;
+  padding-left: 8px;
+  font-size: 16px;
+}
+
+@media screen and (max-width: 800px) {
+  .Filters {
+    grid-template-columns: repeat(2, 1fr);
+  }
+  .Filters .Filter { display: none; }
+}

+ 8 - 10
web/src/Filters.js → web/src/components/Filters/Filters.js

@@ -1,7 +1,7 @@
 import { useRoute } from 'react-router5'
 import { useRoute } from 'react-router5'
 import { useRef } from 'react';
 import { useRef } from 'react';
 import AsyncSelect from 'react-select/async';
 import AsyncSelect from 'react-select/async';
-import {getCategory, request} from './Api';
+import {getCategory, request} from 'src/Api';
 import './Filters.css';
 import './Filters.css';
 
 
 export const CATEGORIES = [{cat: 'artist', title: 'Artists'},
 export const CATEGORIES = [{cat: 'artist', title: 'Artists'},
@@ -41,10 +41,10 @@ export default function Filters({dispatch, filters, filter}) {
     <div className="Filters">
     <div className="Filters">
       {CATEGORIES.map(({cat, title, hidden}) => (!hidden &&
       {CATEGORIES.map(({cat, title, hidden}) => (!hidden &&
         <div className="section" key={cat}>
         <div className="section" key={cat}>
-          <h3>{title}</h3>
           <AsyncSelect
           <AsyncSelect
             isClearable
             isClearable
             cacheOptions
             cacheOptions
+            placeholder={title}
             defaultValue={filters[cat] && {item: filters[cat]}}
             defaultValue={filters[cat] && {item: filters[cat]}}
             defaultOptions
             defaultOptions
             loadOptions={(q) => handleLoadOptions(cat, q)}
             loadOptions={(q) => handleLoadOptions(cat, q)}
@@ -54,21 +54,19 @@ export default function Filters({dispatch, filters, filter}) {
             />
             />
         </div>
         </div>
       ))}
       ))}
+      <div className="Latest">
+      <label>Latest<input
+             type="checkbox"
+             checked={ !filters.alpha }
+             onChange={(e) => handleCategoryChange(router, 'alpha', !e.target.checked)} /></label>
+      </div>
       <div className="Filter">
       <div className="Filter">
-        <h3>Search</h3>
         <input
         <input
           type="text"
           type="text"
           placeholder="Search albums"
           placeholder="Search albums"
           value={filter || ''}
           value={filter || ''}
           onChange={(e)=>handleFilterChange(dispatch, router, timeout, e.target.value)} />
           onChange={(e)=>handleFilterChange(dispatch, router, timeout, e.target.value)} />
       </div>
       </div>
-      <div>
-      <h3>Latest</h3>
-          <input className="Latest"
-             type="checkbox"
-             checked={ !filters.alpha }
-             onChange={(e) => handleCategoryChange(router, 'alpha', !e.target.checked)} />
-      </div>
     </div>
     </div>
   );
   );
 }
 }

+ 45 - 0
web/src/components/Header/Header.css

@@ -0,0 +1,45 @@
+.Header {
+  grid-area: header;
+  background-color: var(--header-background);
+  display: grid;
+  grid: "logo title search settings user" auto
+        / auto auto 1fr auto auto;
+  align-items: center;
+  grid-column-gap: 10px;
+}
+
+.Header-logo {
+  grid-area: logo;
+  height: 80px;
+}
+.Header-logo img {
+  min-width: 64px;
+  height: 64px;
+  padding: 8px;
+}
+.Header-title {
+  grid-area: title;
+  color: var(--header-color);
+  font-size: 1.5em;
+}
+.Header-search {
+  grid-area: search;
+  max-width: 600px;
+}
+
+@media screen and (max-width: 800px) {
+  .Header {
+    grid: "logo title  user" auto
+          "logo search user" auto
+          / auto 1fr auto;
+    grid-column-gap: 2px;
+  }
+  .Header-title {
+    margin: 0;
+    justify-self: center;
+  }
+  .Header-search {
+    height: 20px;
+    margin: 6px 0;
+  }
+}

+ 5 - 5
web/src/Header.js → web/src/components/Header/Header.js

@@ -1,8 +1,8 @@
 import './Header.css';
 import './Header.css';
-import logo from './chad-logo-256.png';
+import logo from 'src/chad-logo-256.png';
 import { BaseLink, useRoute } from 'react-router5'
 import { BaseLink, useRoute } from 'react-router5'
-import Settings from './Settings';
-import UserInfo from './UserInfo';
+import Settings from '../Settings';
+import UserInfo from '../UserInfo';
 
 
 function handleSearch(e, router) {
 function handleSearch(e, router) {
   if (e.charCode === 13) router.navigate('browser', {filter: e.target.value});
   if (e.charCode === 13) router.navigate('browser', {filter: e.target.value});
@@ -14,8 +14,8 @@ export default function Header({settings, dispatch}) {
 
 
   return (
   return (
     <header className="Header">
     <header className="Header">
-      <BaseLink router={router} routeName="browser" routeOptions={{ reload: true }}>
-        <img src={logo} className="Header-logo" alt="Chad music" />
+      <BaseLink className="Header-logo" router={router} routeName="browser" routeOptions={{ reload: true }}>
+        <img src={logo} alt="Chad music" />
       </BaseLink>
       </BaseLink>
       <h1 className="Header-title">Chad Music</h1>
       <h1 className="Header-title">Chad Music</h1>
       <input className="Header-search" type="text"
       <input className="Header-search" type="text"

+ 0 - 0
web/src/Login.css → web/src/components/Login/Login.css


+ 2 - 2
web/src/Login.js → web/src/components/Login/Login.js

@@ -1,6 +1,6 @@
 import './Login.css';
 import './Login.css';
-import TelegramLoginButton from './TelegramLoginButton';
-import {login, useFetch} from './Api';
+import TelegramLoginButton from '../TelegramLoginButton';
+import {login, useFetch} from 'src/Api';
 
 
 export default function Login({state, dispatch}) {
 export default function Login({state, dispatch}) {
   const setUrl = useFetch(null, dispatch, 'LOGIN');
   const setUrl = useFetch(null, dispatch, 'LOGIN');

+ 0 - 0
web/src/Main.js → web/src/components/Main.js


+ 0 - 0
web/src/MediaSession.js → web/src/components/MediaSession.js


+ 0 - 0
web/src/Player.js → web/src/components/Player.js


+ 0 - 0
web/src/Queue.css → web/src/components/Queue/Queue.css


+ 3 - 3
web/src/Queue.js → web/src/components/Queue/Queue.js

@@ -1,6 +1,6 @@
 import { Link } from 'react-router5'
 import { Link } from 'react-router5'
 import './Queue.css';
 import './Queue.css';
-import {slugify, formatDuration} from './utils.js';
+import {slugify, formatDuration} from 'src/utils';
 
 
 export default function Queue({queue, dispatch}) {
 export default function Queue({queue, dispatch}) {
   return (
   return (
@@ -21,7 +21,7 @@ export default function Queue({queue, dispatch}) {
                   <svg viewBox="0 0 24 24"><g><path d="M8 5v14l11-7z"/></g></svg>
                   <svg viewBox="0 0 24 24"><g><path d="M8 5v14l11-7z"/></g></svg>
               </button></td>
               </button></td>
             </tr>))}
             </tr>))}
-    </tbody></table>
-      </aside>
+      </tbody></table>
+    </aside>
   );
   );
 }
 }

+ 20 - 0
web/src/components/Settings/Settings.css

@@ -0,0 +1,20 @@
+.Settings {
+  grid-area: settings;
+  justify-self: right;
+
+  display: grid;
+  grid: auto / auto auto;
+  grid-column-gap: 10px;
+}
+
+.Settings-theme {
+  width: 100px;
+}
+
+.Settings-layout {
+  width: 170px;
+}
+
+@media screen and (max-width: 800px) {
+  .Settings { display: none; }
+}

+ 2 - 2
web/src/Settings.js → web/src/components/Settings/Settings.js

@@ -18,8 +18,8 @@ export default function Settings({state, dispatch}) {
 
 
   return (
   return (
     <div className="Settings">
     <div className="Settings">
-      <Select defaultValue={theme} options={THEMES} onChange={(e)=>dispatch({type: 'THEME_SET', payload: e.value})} />
-      <Select defaultValue={layout} options={LAYOUTS} onChange={(e)=>dispatch({type: 'LAYOUT_SET', payload: e.value})} />
+      <Select className="Settings-theme" defaultValue={theme} options={THEMES} onChange={(e)=>dispatch({type: 'THEME_SET', payload: e.value})} />
+      <Select className="Settings-layout" defaultValue={layout} options={LAYOUTS} onChange={(e)=>dispatch({type: 'LAYOUT_SET', payload: e.value})} />
     </div>
     </div>
   );
   );
 }
 }

+ 0 - 0
web/src/TelegramLoginButton.js → web/src/components/TelegramLoginButton.js


+ 4 - 0
web/src/UserInfo.css → web/src/components/UserInfo/UserInfo.css

@@ -1,4 +1,5 @@
 .UserInfo {
 .UserInfo {
+  grid-area: user;
   justify-self: right;
   justify-self: right;
   display: grid;
   display: grid;
   grid: 1fr / 1fr auto;
   grid: 1fr / 1fr auto;
@@ -14,3 +15,6 @@
   padding: 8px;
   padding: 8px;
   border-radius: 64px;
   border-radius: 64px;
 }
 }
+@media screen and (max-width: 800px) {
+  .UserInfo span { display: none; }
+}

+ 2 - 2
web/src/UserInfo.js → web/src/components/UserInfo/UserInfo.js

@@ -1,7 +1,7 @@
 import {useReducer} from 'react';
 import {useReducer} from 'react';
 import './UserInfo.css';
 import './UserInfo.css';
-import logo from './chad-logo-256.png';
-import {useFetch, initialState, fetchReducer, getUser} from './Api';
+import logo from 'src/chad-logo-256.png';
+import {useFetch, initialState, fetchReducer, getUser} from 'src/Api';
 
 
 function UserInfo() {
 function UserInfo() {
   const [{loading, error, data}, dispatch] = useReducer(fetchReducer, initialState());
   const [{loading, error, data}, dispatch] = useReducer(fetchReducer, initialState());

+ 2 - 2
web/src/create-router.js

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

+ 1 - 1
web/src/index.js

@@ -2,7 +2,7 @@ import React from 'react';
 import ReactDOM from 'react-dom';
 import ReactDOM from 'react-dom';
 import { RouterProvider } from 'react-router5'
 import { RouterProvider } from 'react-router5'
 import './index.css';
 import './index.css';
-import App from './App';
+import App from './components/App/App';
 import createRouter from './create-router'
 import createRouter from './create-router'
 import reportWebVitals from './reportWebVitals';
 import reportWebVitals from './reportWebVitals';
 
 

+ 1 - 1
web/src/utils.js

@@ -31,7 +31,7 @@ const SUBST = {
   '-':'-dash-'
   '-':'-dash-'
 };
 };
 const UNSUBST = Object.assign({}, ...Object.entries(SUBST).map(([a,b]) => ({[b]: a})))
 const UNSUBST = Object.assign({}, ...Object.entries(SUBST).map(([a,b]) => ({[b]: a})))
-const FORW = new RegExp(/[:\/\\?#\[\]@~ _‐-]/, 'g');
+const FORW = new RegExp(/[:/\\?#[\]@~ _‐-]/, 'g');
 const BACK = new RegExp(`(${Object.values(SUBST).join('|')})`,'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 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 unslugify(str) { return str && decodeURI(str).replace(BACK, e=>UNSUBST[e]) }